From 7dfc25cd0f7f5aa0c24c69d4142058de44d36fa2 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 15:13:52 -0300 Subject: [PATCH 1/8] feat(node): background fee snapshot service for zero-L1 warm fee RPCs 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. --- .../archiver/src/archiver-sync.test.ts | 20 + yarn-project/archiver/src/archiver.ts | 7 +- .../archiver/src/modules/l1_synchronizer.ts | 17 +- .../archiver/src/test/noop_l1_archiver.ts | 3 + .../aztec-node/src/aztec-node/server.ts | 16 +- yarn-project/aztec-node/src/factory.ts | 22 +- yarn-project/ethereum/src/contracts/rollup.ts | 244 ++++- .../src/contracts/rollup_fee_reads.test.ts | 127 +++ yarn-project/ethereum/src/l1_types.ts | 20 + .../global_variable_builder/fee_prediction.ts | 118 +++ .../fee_predictor.test.ts | 418 --------- .../global_variable_builder/fee_predictor.ts | 182 ---- .../fee_provider.test.ts | 103 --- .../global_variable_builder/fee_provider.ts | 100 +- .../global_variable_builder/fee_snapshot.ts | 161 ++++ .../fee_snapshot_equivalence.test.ts | 175 ++++ .../fee_snapshot_service.test.ts | 331 +++++++ .../fee_snapshot_service.ts | 868 ++++++++++++++++++ .../global_variable_builder/global_builder.ts | 2 +- .../src/global_variable_builder/index.ts | 22 +- .../legacy_fee_oracle.ts | 91 ++ 21 files changed, 2246 insertions(+), 801 deletions(-) create mode 100644 yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts create mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts delete mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts delete mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts delete mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_provider.test.ts create mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts create mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts create mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts create mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts create mode 100644 yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index c8595cfe9e43..d392320471d8 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -261,6 +261,26 @@ describe('Archiver Sync', () => { ).toEqual([1, 2, 3]); }, 30_000); + it('publishes an atomic L1 sync snapshot consistent with the individual getters', async () => { + // No snapshot is published before the first successful sync. + expect(archiver.getL1SyncSnapshot()).toBeUndefined(); + + await fake.addCheckpoint(CheckpointNumber(1), { + l1BlockNumber: 101n, + messagesL1BlockNumber: 98n, + numL1ToL2Messages: 3, + }); + fake.setL1BlockNumber(2500n); + await archiver.syncImmediate(); + + const snapshot = archiver.getL1SyncSnapshot(); + expect(snapshot).toBeDefined(); + // The atomic triple matches the individual getters assigned at the same point in the sync loop. + expect(snapshot!.blockNumber).toBe(archiver.getL1BlockNumber()); + expect(snapshot!.blockTimestamp).toBe(await archiver.getL1Timestamp()); + expect(snapshot!.blockNumber).toBe(2500n); + }, 30_000); + it('ignores checkpoint 3 because it has been pruned', async () => { const loggerSpy = jest.spyOn(syncLogger, 'warn'); diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 501d624da8eb..b811b3cb7bbe 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -2,6 +2,7 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { EpochCache } from '@aztec/epoch-cache'; import { BlockTagTooOldError, OutboxContract, RollupContract } from '@aztec/ethereum/contracts'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; @@ -89,7 +90,7 @@ const noCheckpointProposalPresence: CheckpointProposalPresence = { * Responsible for handling robust L1 polling so that other components do not need to * concern themselves with it. */ -export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Traceable { +export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, L1SyncSnapshotProvider, Traceable { /** Event emitter for archiver events (L2BlockProven, L2PruneUnproven, L2PruneUncheckpointed, etc). */ public readonly events: ArchiverEmitter; @@ -610,6 +611,10 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra return Promise.resolve(this.synchronizer.getL1Timestamp()); } + public getL1SyncSnapshot(): L1SyncSnapshot | undefined { + return this.synchronizer.getL1SyncSnapshot(); + } + public async getSyncedL2SlotNumber(): Promise { // The synced L2 slot is the latest slot for which we have all L1 data, // either because we have seen all L1 blocks for that slot, or because diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 1739dffccde2..6e79712842d1 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -1,7 +1,7 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { EpochCache } from '@aztec/epoch-cache'; import { InboxContract, type InboxContractState, RollupContract } from '@aztec/ethereum/contracts'; -import type { L1BlockId } from '@aztec/ethereum/l1-types'; +import type { L1BlockId, L1SyncSnapshot } from '@aztec/ethereum/l1-types'; import { getFinalizedL1Block } from '@aztec/ethereum/queries'; import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types'; import { asyncPool } from '@aztec/foundation/async-pool'; @@ -74,6 +74,11 @@ export class ArchiverL1Synchronizer implements Traceable { private l1BlockNumber: bigint | undefined; private l1BlockHash: Buffer32 | undefined; private l1Timestamp: bigint | undefined; + /** + * Atomic snapshot of the last fully-synced L1 identity. Published as a single object reference at the + * same point the three fields above are assigned, so a synchronous reader never observes a torn triple. + */ + private l1SyncSnapshot: L1SyncSnapshot | undefined; private readonly updater: ArchiverDataStoreUpdater; public readonly tracer: Tracer; @@ -129,6 +134,11 @@ export class ArchiverL1Synchronizer implements Traceable { return this.l1Timestamp; } + /** Returns the last fully-synced L1 identity as an atomic in-memory snapshot, or undefined before the first sync. */ + public getL1SyncSnapshot(): L1SyncSnapshot | undefined { + return this.l1SyncSnapshot; + } + private getSignatureContext(): CoordinationSignatureContext { return { chainId: this.publicClient.chain.id, @@ -240,6 +250,11 @@ export class ArchiverL1Synchronizer implements Traceable { this.l1Timestamp = currentL1Timestamp; this.l1BlockNumber = currentL1BlockNumber; this.l1BlockHash = currentL1BlockHash; + this.l1SyncSnapshot = { + blockNumber: currentL1BlockNumber, + blockHash: currentL1BlockHash, + blockTimestamp: currentL1Timestamp, + }; const l1BlockNumberAtEnd = await this.publicClient.getBlockNumber(); this.log.debug(`Archiver sync iteration complete`, { diff --git a/yarn-project/archiver/src/test/noop_l1_archiver.ts b/yarn-project/archiver/src/test/noop_l1_archiver.ts index 93eacb3aad62..4a34b280d04a 100644 --- a/yarn-project/archiver/src/test/noop_l1_archiver.ts +++ b/yarn-project/archiver/src/test/noop_l1_archiver.ts @@ -40,6 +40,9 @@ class NoopL1Synchronizer implements FunctionsOf { getL1Timestamp(): bigint | undefined { return undefined; } + getL1SyncSnapshot(): undefined { + return undefined; + } testEthereumNodeSynced(): Promise { return Promise.resolve(); } diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 6e9fb34918cc..1e534b74b565 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -27,7 +27,7 @@ import { uploadSnapshot } from '@aztec/node-lib/actions'; import { type P2P, createTxValidatorForAcceptingTxsOverRPC, getDefaultAllowedSetupFunctions } from '@aztec/p2p'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; import type { ProverNode } from '@aztec/prover-node'; -import { SequencerClient } from '@aztec/sequencer-client'; +import { type FeeSnapshotStats, SequencerClient } from '@aztec/sequencer-client'; import { AutomineSequencer } from '@aztec/sequencer-client/automine'; import type { SlasherClientInterface } from '@aztec/slasher'; import { STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS } from '@aztec/standard-contracts/multi-call-entrypoint'; @@ -138,6 +138,8 @@ export interface AztecNodeServiceDeps { globalVariableBuilder: GlobalVariableBuilderInterface; rollupContract: RollupContract | undefined; feeProvider: FeeProvider; + /** Background fee snapshot service, stopped before the archiver so its final refresh sees a live identity. */ + feeSnapshotService?: { stop(): Promise; getStats(): FeeSnapshotStats }; epochCache: EpochCacheInterface; packageVersion: string; peerProofVerifier: ClientProtocolCircuitVerifier; @@ -184,6 +186,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb protected readonly globalVariableBuilder: GlobalVariableBuilderInterface; protected readonly rollupContract: RollupContract | undefined; protected readonly feeProvider: FeeProvider; + private readonly feeSnapshotService?: { stop(): Promise; getStats(): FeeSnapshotStats }; protected readonly epochCache: EpochCacheInterface; protected readonly packageVersion: string; private peerProofVerifier: ClientProtocolCircuitVerifier; @@ -214,6 +217,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb this.globalVariableBuilder = deps.globalVariableBuilder; this.rollupContract = deps.rollupContract; this.feeProvider = deps.feeProvider; + this.feeSnapshotService = deps.feeSnapshotService; this.epochCache = deps.epochCache; this.packageVersion = deps.packageVersion; this.peerProofVerifier = deps.peerProofVerifier; @@ -567,6 +571,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb await tryStop(this.proverNode); await tryStop(this.p2pClient); await tryStop(this.worldStateSynchronizer); + // Stop the fee snapshot service before the archiver so its identity provider is still live during shutdown. + await tryStop(this.feeSnapshotService); await tryStop(this.blockSource); await tryStop(this.blobClient); await tryStop(this.telemetry); @@ -581,6 +587,14 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb return this.blobClient; } + /** + * Returns the background fee snapshot service counters, or undefined if the service is not wired. + * @internal - Exposed for benchmarking/testing purposes only. + */ + public getFeeSnapshotStats(): FeeSnapshotStats | undefined { + return this.feeSnapshotService?.getStats(); + } + /** * Method to retrieve pending txs. * @param limit - The number of items to returns diff --git a/yarn-project/aztec-node/src/factory.ts b/yarn-project/aztec-node/src/factory.ts index 52a94a1f91bf..86547f5fc2f5 100644 --- a/yarn-project/aztec-node/src/factory.ts +++ b/yarn-project/aztec-node/src/factory.ts @@ -20,9 +20,11 @@ import { type ProverNode, type ProverNodeDeps, createProverNode } from '@aztec/p import { createKeyStoreForProver } from '@aztec/prover-node/config'; import { FeeProviderImpl, + FeeSnapshotService, GlobalVariableBuilder, SequencerClient, type SequencerPublisher, + getDefaultFeeSnapshotServiceConfig, } from '@aztec/sequencer-client'; import { type AutomineSequencer, createAutomineSequencer } from '@aztec/sequencer-client/automine'; import { @@ -241,7 +243,24 @@ export async function createAztecNodeService( }; const globalVariableBuilder = new GlobalVariableBuilder(publicClient, globalVariableBuilderConfig); - const feeProvider = new FeeProviderImpl(dateProvider, publicClient, globalVariableBuilderConfig); + + // Serve fee RPCs (and the p2p mempool fee policy) from a background snapshot refreshed per L1 block, so + // warm calls issue zero L1 requests. The service pins its reads to the archiver's synced L1 identity. + const feeSnapshotService = new FeeSnapshotService( + rollupContract, + archiver, + dateProvider, + getDefaultFeeSnapshotServiceConfig({ + slotDuration: Number(slotDuration), + l1GenesisTime, + ethereumSlotDuration: config.ethereumSlotDuration, + epochDuration: Number(epochDuration), + }), + log.createChild('fee-snapshot'), + ); + feeSnapshotService.start(); + started.push(feeSnapshotService); + const feeProvider = new FeeProviderImpl(feeSnapshotService); const collectOffenses = !config.disableValidator || config.enableOffenseCollection; @@ -623,6 +642,7 @@ export async function createAztecNodeService( globalVariableBuilder, rollupContract, feeProvider, + feeSnapshotService, epochCache, packageVersion, peerProofVerifier, diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index ef62956c6522..bd908bd2f646 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -22,11 +22,13 @@ import { type StateOverride, type WatchContractEventReturnType, decodeErrorResult, + decodeFunctionResult, encodeAbiParameters, encodeFunctionData, getContract, hexToBigInt, keccak256, + multicall3Abi, } from 'viem'; import { getPublicClient } from '../client.js'; @@ -38,6 +40,7 @@ import type { ViemClient } from '../types.js'; import { formatViemError } from '../utils.js'; import { GSEContract } from './gse.js'; import type { L1EventLog } from './log.js'; +import { MULTI_CALL_3_ADDRESS } from './multicall.js'; import { SlasherContract } from './slasher_contract.js'; import { SlashingProposerContract } from './slashing_proposer.js'; import { checkBlockTag } from './utils.js'; @@ -134,6 +137,31 @@ export type L1FeeData = { blobFee: bigint; }; +/** + * A single fee-model read to batch into one pinned multicall. Every entry is a pure view function whose + * result feeds the fee snapshot; batching them into one `eth_call` gives a consistent per-wave view. + */ +export type RollupFeeRead = + | { kind: 'tips' } + | { kind: 'manaTarget' } + | { kind: 'manaLimit' } + | { kind: 'provingCostPerManaEth' } + | { kind: 'manaMinFeeAt'; timestamp: bigint } + | { kind: 'canPruneAtTime'; timestamp: bigint } + | { kind: 'l1FeesAt'; timestamp: bigint } + | { kind: 'checkpoint'; checkpointNumber: CheckpointNumber }; + +/** Decoded result of a {@link RollupFeeRead}, tagged with the same `kind` and echoing the read's key params. */ +export type RollupFeeReadResult = + | { kind: 'tips'; value: { pending: CheckpointNumber; proven: CheckpointNumber } } + | { kind: 'manaTarget'; value: bigint } + | { kind: 'manaLimit'; value: bigint } + | { kind: 'provingCostPerManaEth'; value: bigint } + | { kind: 'manaMinFeeAt'; timestamp: bigint; value: bigint } + | { kind: 'canPruneAtTime'; timestamp: bigint; value: boolean } + | { kind: 'l1FeesAt'; timestamp: bigint; value: L1FeeData } + | { kind: 'checkpoint'; checkpointNumber: CheckpointNumber; value: CheckpointLog }; + /** Field offsets within the CompressedTempCheckpointLog struct in Solidity storage. */ export enum TempCheckpointLogField { HeaderHash = 0, @@ -270,6 +298,8 @@ export class RollupContract { address: EthAddress; contract: GetContractReturnType; }; + /** Cached feature-detection of Multicall3 bytecode presence, used by {@link readFeeInputs}. */ + private multicall3Available: boolean | undefined; static get checkBlobStorageSlot(): bigint { const asString = RollupStorage.find(storage => storage.label === 'checkBlob')?.slot; @@ -740,8 +770,9 @@ export class RollupContract { ); } - async getTips(): Promise<{ pending: CheckpointNumber; proven: CheckpointNumber }> { - const { pending, proven } = await this.rollup.read.getTips(); + async getTips(options?: { blockNumber?: bigint }): Promise<{ pending: CheckpointNumber; proven: CheckpointNumber }> { + await checkBlockTag(options?.blockNumber, this.client); + const { pending, proven } = await this.rollup.read.getTips(options); return { pending: CheckpointNumber.fromBigInt(pending), proven: CheckpointNumber.fromBigInt(proven), @@ -1134,8 +1165,120 @@ export class RollupContract { return this.rollup.read.getHasSubmitted([BigInt(epochNumber), BigInt(numberOfCheckpointsInEpoch), prover]); } - getManaMinFeeAt(timestamp: bigint, inFeeAsset: boolean, stateOverride?: StateOverride): Promise { - return this.rollup.read.getManaMinFeeAt([timestamp, inFeeAsset], { stateOverride }); + getManaMinFeeAt( + timestamp: bigint, + inFeeAsset: boolean, + options?: { blockNumber?: bigint; stateOverride?: StateOverride }, + ): Promise { + return this.rollup.read.getManaMinFeeAt([timestamp, inFeeAsset], { + blockNumber: options?.blockNumber, + stateOverride: options?.stateOverride, + }); + } + + /** + * Non-memoized read of `getManaTarget` pinned to an optional L1 block. Governance can change the mana + * target, so a per-refresh reader must not reuse the process-lifetime `@memoize`d {@link getManaTarget}. + */ + async readManaTarget(options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + return this.rollup.read.getManaTarget(options); + } + + /** + * Non-memoized read of `getManaLimit` pinned to an optional L1 block. Derived from the mana target, which + * governance can change, so a per-refresh reader must not reuse the memoized {@link getManaLimit}. + */ + async readManaLimit(options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + return this.rollup.read.getManaLimit(options); + } + + /** + * Non-memoized read of `getProvingCostPerManaInEth` pinned to an optional L1 block. Governance can change + * the proving cost, so a per-refresh reader must not reuse the memoized {@link getProvingCostPerMana}. + */ + async readProvingCostPerManaInEth(options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + return this.rollup.read.getProvingCostPerManaInEth(options); + } + + /** Returns true iff Multicall3 bytecode is deployed at its canonical address (feature-detected once, cached). */ + private async hasMulticall3(): Promise { + if (this.multicall3Available === undefined) { + const code = await this.client.getCode({ address: MULTI_CALL_3_ADDRESS }); + this.multicall3Available = !!code && code !== '0x'; + } + return this.multicall3Available; + } + + /** + * Reads a batch of fee-model view functions pinned to a single L1 block. When Multicall3 is available the + * whole batch is a single `eth_call` (one atomic per-backend view via `aggregate3` with `allowFailure: false`); + * otherwise it falls back to parallel individual pinned calls, which is weaker (a single logical batch may then + * mix fallback-transport backends). Results are returned in the same order as the requests. + */ + async readFeeInputs( + reads: RollupFeeRead[], + options: { blockNumber: bigint; allowMulticall?: boolean }, + ): Promise { + const allowMulticall = options.allowMulticall ?? true; + if (allowMulticall && (await this.hasMulticall3())) { + return this.readFeeInputsViaMulticall(reads, options.blockNumber); + } + return Promise.all(reads.map(read => this.readSingleFeeInput(read, options.blockNumber))); + } + + private async readFeeInputsViaMulticall(reads: RollupFeeRead[], blockNumber: bigint): Promise { + const calls = reads.map(read => ({ + target: this.address, + allowFailure: false, + callData: encodeFeeRead(read), + })); + const aggregateData = encodeFunctionData({ abi: multicall3Abi, functionName: 'aggregate3', args: [calls] }); + const { data } = await this.client.call({ to: MULTI_CALL_3_ADDRESS, data: aggregateData, blockNumber }); + if (!data) { + throw new Error('Multicall3 aggregate3 returned no data'); + } + const entries = decodeFunctionResult({ abi: multicall3Abi, functionName: 'aggregate3', data }); + if (entries.length !== reads.length) { + throw new Error(`Multicall3 returned ${entries.length} results for ${reads.length} requests`); + } + return reads.map((read, i) => decodeFeeReadResult(read, entries[i].returnData)); + } + + private async readSingleFeeInput(read: RollupFeeRead, blockNumber: bigint): Promise { + const opts = { blockNumber }; + switch (read.kind) { + case 'tips': + return { kind: 'tips', value: await this.getTips(opts) }; + case 'manaTarget': + return { kind: 'manaTarget', value: await this.readManaTarget(opts) }; + case 'manaLimit': + return { kind: 'manaLimit', value: await this.readManaLimit(opts) }; + case 'provingCostPerManaEth': + return { kind: 'provingCostPerManaEth', value: await this.readProvingCostPerManaInEth(opts) }; + case 'manaMinFeeAt': + return { + kind: 'manaMinFeeAt', + timestamp: read.timestamp, + value: await this.getManaMinFeeAt(read.timestamp, true, opts), + }; + case 'canPruneAtTime': + return { + kind: 'canPruneAtTime', + timestamp: read.timestamp, + value: await this.canPruneAtTime(read.timestamp, opts), + }; + case 'l1FeesAt': + return { kind: 'l1FeesAt', timestamp: read.timestamp, value: await this.getL1FeesAt(read.timestamp, opts) }; + case 'checkpoint': + return { + kind: 'checkpoint', + checkpointNumber: read.checkpointNumber, + value: await this.getCheckpoint(read.checkpointNumber, opts), + }; + } } async getManaMinFeeComponentsAt(timestamp: bigint, inFeeAsset: boolean): Promise { @@ -1437,3 +1580,96 @@ export class RollupContract { return entryBase + fieldOffset; } } + +/** Encodes a {@link RollupFeeRead} into calldata using literal function names so the ABI types resolve. */ +function encodeFeeRead(read: RollupFeeRead): Hex { + switch (read.kind) { + case 'tips': + return encodeFunctionData({ abi: RollupAbi, functionName: 'getTips' }); + case 'manaTarget': + return encodeFunctionData({ abi: RollupAbi, functionName: 'getManaTarget' }); + case 'manaLimit': + return encodeFunctionData({ abi: RollupAbi, functionName: 'getManaLimit' }); + case 'provingCostPerManaEth': + return encodeFunctionData({ abi: RollupAbi, functionName: 'getProvingCostPerManaInEth' }); + case 'manaMinFeeAt': + return encodeFunctionData({ abi: RollupAbi, functionName: 'getManaMinFeeAt', args: [read.timestamp, true] }); + case 'canPruneAtTime': + return encodeFunctionData({ abi: RollupAbi, functionName: 'canPruneAtTime', args: [read.timestamp] }); + case 'l1FeesAt': + return encodeFunctionData({ abi: RollupAbi, functionName: 'getL1FeesAt', args: [read.timestamp] }); + case 'checkpoint': + return encodeFunctionData({ + abi: RollupAbi, + functionName: 'getCheckpoint', + args: [BigInt(read.checkpointNumber)], + }); + } +} + +/** Decodes one Multicall3 sub-call return value back into a typed {@link RollupFeeReadResult}. */ +function decodeFeeReadResult(read: RollupFeeRead, data: Hex): RollupFeeReadResult { + switch (read.kind) { + case 'tips': { + const { pending, proven } = decodeFunctionResult({ abi: RollupAbi, functionName: 'getTips', data }); + return { + kind: 'tips', + value: { pending: CheckpointNumber.fromBigInt(pending), proven: CheckpointNumber.fromBigInt(proven) }, + }; + } + case 'manaTarget': + return { + kind: 'manaTarget', + value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getManaTarget', data }), + }; + case 'manaLimit': + return { kind: 'manaLimit', value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getManaLimit', data }) }; + case 'provingCostPerManaEth': + return { + kind: 'provingCostPerManaEth', + value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getProvingCostPerManaInEth', data }), + }; + case 'manaMinFeeAt': + return { + kind: 'manaMinFeeAt', + timestamp: read.timestamp, + value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getManaMinFeeAt', data }), + }; + case 'canPruneAtTime': + return { + kind: 'canPruneAtTime', + timestamp: read.timestamp, + value: decodeFunctionResult({ abi: RollupAbi, functionName: 'canPruneAtTime', data }), + }; + case 'l1FeesAt': { + const result = decodeFunctionResult({ abi: RollupAbi, functionName: 'getL1FeesAt', data }); + return { + kind: 'l1FeesAt', + timestamp: read.timestamp, + value: { baseFee: result.baseFee, blobFee: result.blobFee }, + }; + } + case 'checkpoint': { + const result = decodeFunctionResult({ abi: RollupAbi, functionName: 'getCheckpoint', data }); + return { + kind: 'checkpoint', + checkpointNumber: read.checkpointNumber, + value: { + archive: Fr.fromString(result.archive), + headerHash: Buffer32.fromString(result.headerHash), + blobCommitmentsHash: Buffer32.fromString(result.blobCommitmentsHash), + attestationsHash: Buffer32.fromString(result.attestationsHash), + payloadDigest: Buffer32.fromString(result.payloadDigest), + slotNumber: SlotNumber.fromBigInt(result.slotNumber), + feeHeader: { + excessMana: result.feeHeader.excessMana, + manaUsed: result.feeHeader.manaUsed, + ethPerFeeAsset: result.feeHeader.ethPerFeeAsset, + congestionCost: result.feeHeader.congestionCost, + proverCost: result.feeHeader.proverCost, + }, + }, + }; + } + } +} diff --git a/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts new file mode 100644 index 000000000000..dfc65d22433f --- /dev/null +++ b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts @@ -0,0 +1,127 @@ +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { createLogger } from '@aztec/foundation/log'; +import { DateProvider } from '@aztec/foundation/timer'; + +import { foundry } from 'viem/chains'; + +import { getPublicClient } from '../client.js'; +import { DefaultL1ContractsConfig } from '../config.js'; +import { deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; +import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '../test/index.js'; +import type { ViemClient } from '../types.js'; +import { RollupContract, type RollupFeeRead } from './rollup.js'; + +// Sequential execution: the ethereum package shares Anvil ports across suites. +describe('RollupContract fee reads', () => { + let anvil: Anvil; + let rpcUrl: string; + let publicClient: ViemClient; + let rollup: RollupContract; + let rollupCheatCodes: RollupCheatCodes; + let cheatCodes: EthCheatCodes; + let slotDuration: number; + let l1GenesisTime: bigint; + + beforeAll(async () => { + const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; + ({ anvil, rpcUrl } = await startAnvil()); + publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); + cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); + + const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { + ...DefaultL1ContractsConfig, + vkTreeRoot: Fr.random(), + protocolContractsHash: Fr.random(), + genesisArchiveRoot: Fr.random(), + realVerifier: false, + }); + + rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); + rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployed.l1ContractAddresses, new DateProvider()); + slotDuration = await rollup.getSlotDuration(); + l1GenesisTime = await rollup.getL1GenesisTime(); + }, 60_000); + + afterAll(async () => { + await cheatCodes.setIntervalMining(0); + await anvil?.stop().catch(err => createLogger('cleanup').error('Error stopping anvil', err)); + }); + + function tsForSlot(slot: number): bigint { + return l1GenesisTime + BigInt(slot) * BigInt(slotDuration); + } + + it('reads via Multicall3 identically to parallel individual pinned reads', async () => { + const blockNumber = await publicClient.getBlockNumber(); + const { pending } = await rollup.getTips({ blockNumber }); + const currentSlot = await rollup.getSlotNumber({ blockNumber }); + const ts = tsForSlot(Number(currentSlot) + 1); + + const reads: RollupFeeRead[] = [ + { kind: 'tips' }, + { kind: 'manaTarget' }, + { kind: 'manaLimit' }, + { kind: 'provingCostPerManaEth' }, + { kind: 'manaMinFeeAt', timestamp: ts }, + { kind: 'canPruneAtTime', timestamp: ts }, + { kind: 'l1FeesAt', timestamp: ts }, + { kind: 'checkpoint', checkpointNumber: pending }, + ]; + + const viaMulticall = await rollup.readFeeInputs(reads, { blockNumber, allowMulticall: true }); + const viaIndividual = await rollup.readFeeInputs(reads, { blockNumber, allowMulticall: false }); + + expect(viaMulticall).toEqual(viaIndividual); + }); + + it('pins getManaMinFeeAt to a block number so later blocks do not change the read', async () => { + const blockNumber = await publicClient.getBlockNumber(); + const currentSlot = await rollup.getSlotNumber({ blockNumber }); + const ts = tsForSlot(Number(currentSlot) + 1); + + const before = await rollup.getManaMinFeeAt(ts, true, { blockNumber }); + + // Mine several blocks; the read pinned to the original block must be unchanged. + await cheatCodes.mine(3); + const afterPinned = await rollup.getManaMinFeeAt(ts, true, { blockNumber }); + expect(afterPinned).toBe(before); + }); + + it('reads governance values pinned and unmemoized, matching the memoized getters at the same block', async () => { + const blockNumber = await publicClient.getBlockNumber(); + const [manaTarget, manaLimit, provingCost] = await Promise.all([ + rollup.readManaTarget({ blockNumber }), + rollup.readManaLimit({ blockNumber }), + rollup.readProvingCostPerManaInEth({ blockNumber }), + ]); + expect(manaTarget).toBe(await rollup.getManaTarget()); + expect(manaLimit).toBe(await rollup.getManaLimit()); + expect(provingCost).toBe(await rollup.getProvingCostPerMana()); + }); + + it('supports a blockNumber option on getTips', async () => { + const blockNumber = await publicClient.getBlockNumber(); + const pinned = await rollup.getTips({ blockNumber }); + const latest = await rollup.getTips(); + expect(pinned.pending).toBe(latest.pending); + expect(pinned.proven).toBe(latest.proven); + }); + + describe('checkpoint-slot invariant', () => { + it('holds pendingCheckpointSlot <= slot of the pinned block timestamp across advancing states', async () => { + for (let i = 0; i < 3; i++) { + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const { pending } = await rollup.getTips({ blockNumber }); + const pendingCheckpoint = await rollup.getCheckpoint(pending, { blockNumber }); + const pinnedSlot = SlotNumber.fromBigInt((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); + // A proposed checkpoint's slot equals the slot of its L1 inclusion timestamp, so it can never exceed + // the slot of any later pinned block (ProposeLib require(slot == currentSlot)). + expect(Number(pendingCheckpoint.slotNumber)).toBeLessThanOrEqual(Number(pinnedSlot)); + await rollupCheatCodes.advanceSlots(2); + await cheatCodes.mine(); + } + }, 30_000); + }); +}); diff --git a/yarn-project/ethereum/src/l1_types.ts b/yarn-project/ethereum/src/l1_types.ts index c402ce81da77..a307a90f6524 100644 --- a/yarn-project/ethereum/src/l1_types.ts +++ b/yarn-project/ethereum/src/l1_types.ts @@ -4,3 +4,23 @@ export type L1BlockId = { l1BlockNumber: bigint; l1BlockHash: Buffer32; }; + +/** + * Atomic view of the L1 head an in-memory consumer (e.g. the fee snapshot service) can pin reads to. + * The three fields are always published together after a completed sync pass so that a synchronous read + * never observes a torn `(blockNumber, blockHash, blockTimestamp)` triple. + */ +export type L1SyncSnapshot = { + /** L1 block number the archiver has fully synced to. */ + blockNumber: bigint; + /** Hash of that L1 block, used to label pinned reads and detect reorgs. */ + blockHash: Buffer32; + /** Timestamp of that L1 block, used for L1-head staleness checks. */ + blockTimestamp: bigint; +}; + +/** Exposes the last fully-synced L1 identity as a synchronous, in-memory, atomic snapshot. */ +export interface L1SyncSnapshotProvider { + /** Returns the last published L1 sync snapshot, or undefined before the first sync completes. */ + getL1SyncSnapshot(): L1SyncSnapshot | undefined; +} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts new file mode 100644 index 000000000000..af1b7f1c0c69 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts @@ -0,0 +1,118 @@ +import { type FeeHeader, type L1FeeData, MAX_FEE_ASSET_PRICE_MODIFIER_BPS } from '@aztec/ethereum/contracts'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { times } from '@aztec/foundation/collection'; +import { + FEE_ORACLE_LAG, + GasFees, + MIN_ETH_PER_FEE_ASSET, + ManaUsageEstimate, + computeExcessMana, + computeManaMinFee, +} from '@aztec/stdlib/gas'; + +/** + * Resolved rollup state for a single prediction anchor slot. Every field is a finished input to the pure + * fee math below; nothing here reads L1. The snapshot service and the test-only legacy oracle both build + * this from pinned reads and then feed it to {@link computePredictions}. + */ +export type FeeOracleState = { + /** Slot of the effective parent checkpoint (prune-aware) at the anchor timestamp. */ + lastSlot: SlotNumber; + /** Excess mana carried into the anchor slot. */ + excessMana: bigint; + /** Fee-asset price (eth per fee asset) at the anchor. */ + ethPerFeeAsset: bigint; + manaTarget: bigint; + manaLimit: bigint; + provingCostPerManaEth: bigint; + epochDuration: bigint; + /** Pre-resolved L1 fees for each slot in the prediction window `[nextSlot, nextSlot + FEE_ORACLE_LAG)`. */ + l1FeesBySlot: L1FeeData[]; +}; + +/** + * Builds a {@link FeeOracleState} for a prediction anchored at `anchorSlot`, using already-fetched checkpoints, + * governance values, and L1 fees. Mirrors the state assembly `FeePredictor.fetchState` used to do inline, but + * takes every input explicitly so it is a pure function shared by the snapshot service and the legacy oracle. + * + * @param l1FeesForSlot - Resolves the L1 fees at a slot from the caller's pre-fetched map; throws if missing. + */ +export function buildFeeOracleState(params: { + anchorSlot: SlotNumber; + canPrune: boolean; + pendingCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; + provenCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; + manaTarget: bigint; + manaLimit: bigint; + provingCostPerManaEth: bigint; + epochDuration: bigint; + l1FeesForSlot: (slot: SlotNumber) => L1FeeData; +}): FeeOracleState { + const effectiveParent = params.canPrune ? params.provenCheckpoint : params.pendingCheckpoint; + const lastSlot = effectiveParent.slotNumber; + // The prediction starts at the slot after the effective parent, but never before the anchor slot. + const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), params.anchorSlot)); + const feeHeader = effectiveParent.feeHeader; + const l1FeesBySlot = times(FEE_ORACLE_LAG, i => params.l1FeesForSlot(SlotNumber.add(nextSlot, i))); + + return { + lastSlot, + excessMana: computeExcessMana(feeHeader.excessMana, feeHeader.manaUsed, params.manaTarget), + ethPerFeeAsset: feeHeader.ethPerFeeAsset, + manaTarget: params.manaTarget, + manaLimit: params.manaLimit, + provingCostPerManaEth: params.provingCostPerManaEth, + epochDuration: params.epochDuration, + l1FeesBySlot, + }; +} + +/** + * Computes per-slot fee predictions for a given mana-usage assumption. The first entry uses the resolved + * current state; subsequent entries advance excess mana by the assumed usage and decay the fee-asset price + * by `MAX_FEE_ASSET_PRICE_MODIFIER_BPS` per slot for a conservative (upper-bound) estimate. + */ +export function computePredictions(state: FeeOracleState, manaUsage: ManaUsageEstimate): GasFees[] { + const assumedManaUsed = getAssumedManaUsed(state, manaUsage); + + const result: GasFees[] = []; + let { excessMana } = state; + let { ethPerFeeAsset } = state; + + result.push(computeGasFees(state, excessMana, ethPerFeeAsset, state.l1FeesBySlot[0])); + + for (let i = 1; i < state.l1FeesBySlot.length; i++) { + excessMana = computeExcessMana(excessMana, assumedManaUsed, state.manaTarget); + const decayed = (ethPerFeeAsset * (10000n - MAX_FEE_ASSET_PRICE_MODIFIER_BPS)) / 10000n; + ethPerFeeAsset = decayed < MIN_ETH_PER_FEE_ASSET ? MIN_ETH_PER_FEE_ASSET : decayed; + result.push(computeGasFees(state, excessMana, ethPerFeeAsset, state.l1FeesBySlot[i])); + } + + return result; +} + +function getAssumedManaUsed(state: FeeOracleState, manaUsage: ManaUsageEstimate): bigint { + switch (manaUsage) { + case ManaUsageEstimate.None: + return 0n; + case ManaUsageEstimate.Target: + return state.manaTarget; + case ManaUsageEstimate.Limit: + return state.manaLimit; + } +} + +function computeGasFees(state: FeeOracleState, excessMana: bigint, ethPerFeeAsset: bigint, l1Fees: L1FeeData): GasFees { + return new GasFees( + 0, + computeManaMinFee({ + l1BaseFee: l1Fees.baseFee, + l1BlobFee: l1Fees.blobFee, + manaTarget: state.manaTarget, + epochDuration: state.epochDuration, + provingCostPerManaEth: state.provingCostPerManaEth, + excessMana, + ethPerFeeAsset, + }), + ); +} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts deleted file mode 100644 index 5c2139fa7b64..000000000000 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { getPublicClient } from '@aztec/ethereum/client'; -import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; -import type { FeeHeader } from '@aztec/ethereum/contracts'; -import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS, RollupContract, TempCheckpointLogField } from '@aztec/ethereum/contracts'; -import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; -import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; -import type { ViemClient } from '@aztec/ethereum/types'; -import { CheckpointNumber } from '@aztec/foundation/branded-types'; -import { Fr } from '@aztec/foundation/curves/bn254'; -import { EthAddress } from '@aztec/foundation/eth-address'; -import { createLogger } from '@aztec/foundation/log'; -import { promiseWithResolvers } from '@aztec/foundation/promise'; -import { DateProvider } from '@aztec/foundation/timer'; -import { FEE_ORACLE_LAG, type GasFees, ManaUsageEstimate, computeExcessMana } from '@aztec/stdlib/gas'; - -import { jest } from '@jest/globals'; -import { foundry } from 'viem/chains'; - -import { FeePredictor } from './fee_predictor.js'; - -describe('FeePredictor', () => { - let anvil: Anvil; - let rpcUrl: string; - let publicClient: ViemClient; - let cheatCodes: EthCheatCodes; - let rollupCheatCodes: RollupCheatCodes; - let rollup: RollupContract; - - let slotDuration: number; - let ethereumSlotDuration: number; - let l1GenesisTime: bigint; - let feePredictorConfig: { slotDuration: number; l1GenesisTime: bigint; ethereumSlotDuration: number }; - let dateProvider: DateProvider; - - beforeAll(async () => { - const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; - - ({ anvil, rpcUrl } = await startAnvil()); - - publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); - cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); - - const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { - ...DefaultL1ContractsConfig, - vkTreeRoot: Fr.random(), - protocolContractsHash: Fr.random(), - genesisArchiveRoot: Fr.random(), - realVerifier: false, - }); - - rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); - rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployed.l1ContractAddresses, new DateProvider()); - - slotDuration = await rollup.getSlotDuration(); - ethereumSlotDuration = DefaultL1ContractsConfig.ethereumSlotDuration; - l1GenesisTime = await rollup.getL1GenesisTime(); - feePredictorConfig = { slotDuration, l1GenesisTime, ethereumSlotDuration }; - dateProvider = new DateProvider(); - }, 60_000); - - afterAll(async () => { - await cheatCodes.setIntervalMining(0); - await anvil?.stop().catch(err => createLogger('cleanup').error(`Error stopping anvil`, err)); - }); - - function getTimestamp(slot: bigint): bigint { - return l1GenesisTime + slot * BigInt(slotDuration); - } - - /** Decays ethPerFeeAsset by MAX_FEE_ASSET_PRICE_MODIFIER_BPS per step, matching the predictor's conservative estimate. */ - function decayEthPerFeeAsset(ethPerFeeAsset: bigint, steps: number): bigint { - let value = ethPerFeeAsset; - for (let i = 0; i < steps; i++) { - value = (value * (10000n - MAX_FEE_ASSET_PRICE_MODIFIER_BPS)) / 10000n; - } - return value; - } - - /** Writes a fee header to the pending checkpoint's storage via cheat codes. */ - async function writePendingFeeHeader(feeHeader: FeeHeader) { - const rollupAddress = EthAddress.fromString(rollup.address); - const pendingCheckpointNumber = await rollup.getCheckpointNumber(); - const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( - pendingCheckpointNumber, - TempCheckpointLogField.FeeHeader, - ); - await cheatCodes.store(rollupAddress, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); - } - - /** Writes a fee header and slot number for the given checkpoint, then bumps the pending tip. */ - async function advanceCheckpoint(checkpointNumber: CheckpointNumber, feeHeader: FeeHeader, slotNumber: bigint) { - const rollupAddress = EthAddress.fromString(rollup.address); - const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( - checkpointNumber, - TempCheckpointLogField.FeeHeader, - ); - await cheatCodes.store(rollupAddress, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); - - const slotNumberSlot = await rollup.getTempCheckpointLogStorageSlot( - checkpointNumber, - TempCheckpointLogField.SlotNumber, - ); - await cheatCodes.store(rollupAddress, slotNumberSlot, slotNumber & ((1n << 32n) - 1n)); - - const currentTips = await cheatCodes.load(rollupAddress, RollupContract.chainTipsStorageSlot); - const provenCheckpointNumber = currentTips & ((1n << 128n) - 1n); - await cheatCodes.store( - rollupAddress, - RollupContract.chainTipsStorageSlot, - RollupContract.packChainTips(BigInt(checkpointNumber), provenCheckpointNumber), - ); - } - - async function getPredictionStartSlot(): Promise { - const lastCheckpoint = await rollup.getPendingCheckpoint(); - const currentSlot = await rollup.getSlotNumber(); - const afterCheckpoint = BigInt(lastCheckpoint.slotNumber) + 1n; - return afterCheckpoint > BigInt(currentSlot) ? afterCheckpoint : BigInt(currentSlot); - } - - it('slot 0 matches L1 getManaMinFeeAt for all ManaUsageEstimate values', async () => { - const startSlot = await getPredictionStartSlot(); - const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot), true); - - for (const manaUsage of Object.values(ManaUsageEstimate)) { - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(manaUsage); - expect(predicted[0].feePerL2Gas).toBe(l1Fee); - } - }); - - it('all slots match L1 with ManaUsageEstimate.None and zero congestion', async () => { - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); - - const startSlot = await getPredictionStartSlot(); - const pendingCheckpointNumber = await rollup.getCheckpointNumber(); - const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; - - for (let i = 0; i < predicted.length; i++) { - // Write the decayed ethPerFeeAsset to L1 so getManaMinFeeAt matches the predictor's conservative estimate. - if (i > 0) { - await writePendingFeeHeader({ - ...currentFeeHeader, - ethPerFeeAsset: decayEthPerFeeAsset(currentFeeHeader.ethPerFeeAsset, i), - }); - } - const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot + BigInt(i)), true); - expect(predicted[i].feePerL2Gas).toBe(l1Fee); - } - - // Restore original fee header. - await writePendingFeeHeader(currentFeeHeader); - }); - - it('each slot uses correct L1 fees across oracle transition', async () => { - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(1_000_000_000n); - await cheatCodes.mine(); - await rollupCheatCodes.updateL1GasFeeOracle(); - - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(200_000_000_000n); - await cheatCodes.mine(); - await rollupCheatCodes.updateL1GasFeeOracle(); - - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); - - const startSlot = await getPredictionStartSlot(); - const pendingCheckpointNumber = await rollup.getCheckpointNumber(); - const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; - - for (let i = 0; i < predicted.length; i++) { - // Write the decayed ethPerFeeAsset to L1 so getManaMinFeeAt matches the predictor's conservative estimate. - if (i > 0) { - await writePendingFeeHeader({ - ...currentFeeHeader, - ethPerFeeAsset: decayEthPerFeeAsset(currentFeeHeader.ethPerFeeAsset, i), - }); - } - const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot + BigInt(i)), true); - expect(predicted[i].feePerL2Gas).toBe(l1Fee); - } - - // Restore original fee header. - await writePendingFeeHeader(currentFeeHeader); - }); - - it('L1 base fee change is reflected in slot 0 prediction', async () => { - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(100_000_000_000n); - await cheatCodes.mine(); - await rollupCheatCodes.updateL1GasFeeOracle(); - await cheatCodes.mine(); - await rollupCheatCodes.advanceSlots(3); - - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); - - const startSlot = await getPredictionStartSlot(); - const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot), true); - expect(predicted[0].feePerL2Gas).toBe(l1Fee); - }); - - it('returns exactly FEE_ORACLE_LAG entries', async () => { - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.Target); - expect(predicted.length).toBe(FEE_ORACLE_LAG); - }); - - it.each([ - { name: 'None', estimate: ManaUsageEstimate.None }, - { name: 'Target', estimate: ManaUsageEstimate.Target }, - { name: 'Limit', estimate: ManaUsageEstimate.Limit }, - ])( - 'predictions match L1 across all slots when advancing with ManaUsageEstimate.$name', - async ({ estimate }) => { - const constantBaseFee = 50_000_000_000n; - - // Pin L1 fees to a constant by updating the oracle twice (sets both pre and post). - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); - await rollupCheatCodes.updateL1GasFeeOracle(); - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); - await rollupCheatCodes.updateL1GasFeeOracle(); - await rollupCheatCodes.advanceSlots(3); - await cheatCodes.mine(); - - const manaTarget = await rollup.getManaTarget(); - const manaLimit = await rollup.getManaLimit(); - const assumedManaUsed = - estimate === ManaUsageEstimate.None ? 0n : estimate === ManaUsageEstimate.Target ? manaTarget : manaLimit; - - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(estimate); - - const startSlot = await getPredictionStartSlot(); - const pendingCheckpointNumber = await rollup.getCheckpointNumber(); - const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; - - let prevExcessMana = currentFeeHeader.excessMana; - let prevManaUsed = currentFeeHeader.manaUsed; - - for (let i = 0; i < predicted.length; i++) { - const slotI = startSlot + BigInt(i); - const timestampI = getTimestamp(slotI); - - const l1Fee = await rollup.getManaMinFeeAt(timestampI, true); - expect(predicted[i].feePerL2Gas).toBe(l1Fee); - - // Advance: simulate proposing a checkpoint at this slot with the assumed mana usage - // and the decayed ethPerFeeAsset matching the predictor's conservative estimate. - const newExcessMana = computeExcessMana(prevExcessMana, prevManaUsed, manaTarget); - const newCheckpointNumber = CheckpointNumber.add(pendingCheckpointNumber, i + 1); - const newFeeHeader: FeeHeader = { - excessMana: newExcessMana, - manaUsed: assumedManaUsed, - ethPerFeeAsset: decayEthPerFeeAsset(currentFeeHeader.ethPerFeeAsset, i + 1), - congestionCost: 0n, - proverCost: 0n, - }; - - await advanceCheckpoint(newCheckpointNumber, newFeeHeader, slotI); - - if (i < predicted.length - 1) { - const nextTimestamp = getTimestamp(slotI + 1n); - await cheatCodes.warp(Number(nextTimestamp)); - await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); - await cheatCodes.mine(); - } - - prevExcessMana = newExcessMana; - prevManaUsed = assumedManaUsed; - } - }, - 60_000, - ); - - it('predictions match L1 across successive slots over time', async () => { - const constantBaseFee = 50_000_000_000n; - - // Pin L1 fees to a constant by updating the oracle twice (sets both pre and post). - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); - await rollupCheatCodes.updateL1GasFeeOracle(); - await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); - await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); - await rollupCheatCodes.updateL1GasFeeOracle(); - await rollupCheatCodes.advanceSlots(3); - await cheatCodes.mine(); - - const manaTarget = await rollup.getManaTarget(); - - const pendingCheckpointNumber = await rollup.getCheckpointNumber(); - const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; - - let prevExcessMana = currentFeeHeader.excessMana; - let prevManaUsed = currentFeeHeader.manaUsed; - let ethPerFeeAsset = currentFeeHeader.ethPerFeeAsset; - let nextCheckpointOffset = 1; - - // Store previous predictions to verify their future entries against L1 when those slots arrive. - const pastPredictions: { predicted: GasFees[]; startSlot: bigint }[] = []; - - // Step through 6 successive slots, creating a fresh predictor each time. - for (let step = 0; step < 6; step++) { - const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); - const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); - - expect(predicted.length).toBe(FEE_ORACLE_LAG); - - // Slot 0 of each fresh predictor must match L1 exactly. - const startSlot = await getPredictionStartSlot(); - const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot), true); - expect(predicted[0].feePerL2Gas).toBe(l1Fee); - - // Verify future entries of past predictions that now cover the current slot. - for (const past of pastPredictions) { - const offset = Number(startSlot - past.startSlot); - if (offset > 0 && offset < past.predicted.length) { - expect(past.predicted[offset].feePerL2Gas).toBe(l1Fee); - } - } - - pastPredictions.push({ predicted, startSlot }); - - // Advance: simulate proposing a checkpoint at the current slot with zero mana usage - // and the decayed ethPerFeeAsset matching the predictor's conservative estimate. - const newExcessMana = computeExcessMana(prevExcessMana, prevManaUsed, manaTarget); - const decayedEthPerFeeAsset = decayEthPerFeeAsset(ethPerFeeAsset, 1); - const newCheckpointNumber = CheckpointNumber.add(pendingCheckpointNumber, nextCheckpointOffset); - const newFeeHeader: FeeHeader = { - excessMana: newExcessMana, - manaUsed: 0n, - ethPerFeeAsset: decayedEthPerFeeAsset, - congestionCost: 0n, - proverCost: 0n, - }; - - await advanceCheckpoint(newCheckpointNumber, newFeeHeader, startSlot); - - // Warp to the next slot. - const nextTimestamp = getTimestamp(startSlot + 1n); - await cheatCodes.warp(Number(nextTimestamp)); - await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); - await cheatCodes.mine(); - - prevExcessMana = newExcessMana; - prevManaUsed = 0n; - ethPerFeeAsset = decayedEthPerFeeAsset; - nextCheckpointOffset++; - } - }, 60_000); -}); - -describe('FeePredictor state caching', () => { - it('recovers from a transient L1 read failure without waiting for a new L1 block', async () => { - const blockNumber = 1n; - const getBlockNumber = jest.fn<() => Promise>(() => Promise.resolve(blockNumber)); - const state = { manaTarget: 1n } as unknown; - const fetchState = jest - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('L1 RPC request failed')) - .mockResolvedValue(state); - - const predictor: FeePredictor = Object.create(FeePredictor.prototype); - Reflect.set(predictor, 'publicClient', { getBlockNumber }); - Reflect.set(predictor, 'cachedL1BlockNumber', undefined); - Reflect.set(predictor, 'cachedState', undefined); - Reflect.set(predictor, 'fetchState', fetchState); - - const getState = Reflect.get(FeePredictor.prototype, 'getState') as () => Promise; - - await expect(getState.call(predictor)).rejects.toThrow('L1 RPC request failed'); - // Same L1 block: must recompute rather than replay the cached rejection. - await expect(getState.call(predictor)).resolves.toBe(state); - expect(fetchState).toHaveBeenCalledTimes(2); - }); - - it('does not clear the block marker when a stale fetch for an older block rejects', async () => { - const blockN = 1n; - const blockNext = 2n; - const getBlockNumber = jest - .fn<() => Promise>() - .mockResolvedValueOnce(blockN) - .mockResolvedValueOnce(blockNext); - - const fetchN = promiseWithResolvers(); - const state = { manaTarget: 1n } as unknown; - const fetchState = jest - .fn<() => Promise>() - .mockImplementationOnce(() => fetchN.promise) - .mockResolvedValue(state); - - const predictor: FeePredictor = Object.create(FeePredictor.prototype); - Reflect.set(predictor, 'publicClient', { getBlockNumber }); - Reflect.set(predictor, 'cachedL1BlockNumber', undefined); - Reflect.set(predictor, 'cachedState', undefined); - Reflect.set(predictor, 'fetchState', fetchState); - - const getState = Reflect.get(FeePredictor.prototype, 'getState') as () => Promise; - - // Fetch for block N stays in flight; the block N+1 call advances the marker meanwhile. - const callN = getState.call(predictor); - const callNext = getState.call(predictor); - - // The stale N fetch now rejects. It must NOT clear the marker (which now points at N+1). - fetchN.reject(new Error('stale L1 RPC request failed')); - - await expect(callN).rejects.toThrow('stale L1 RPC request failed'); - await expect(callNext).resolves.toBe(state); - - expect(Reflect.get(predictor, 'cachedL1BlockNumber')).toBe(blockNext); - expect(fetchState).toHaveBeenCalledTimes(2); - }); -}); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts deleted file mode 100644 index da4f57f91c91..000000000000 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { type L1FeeData, MAX_FEE_ASSET_PRICE_MODIFIER_BPS, type RollupContract } from '@aztec/ethereum/contracts'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import { times } from '@aztec/foundation/collection'; -import type { DateProvider } from '@aztec/foundation/timer'; -import { getSlotAtNextL1Block, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; -import { - FEE_ORACLE_LAG, - GasFees, - MIN_ETH_PER_FEE_ASSET, - ManaUsageEstimate, - computeExcessMana, - computeManaMinFee, -} from '@aztec/stdlib/gas'; - -/** Cached rollup state for fee prediction. Refreshed once per L1 block. */ -type FeeOracleState = { - lastSlot: SlotNumber; - excessMana: bigint; - ethPerFeeAsset: bigint; - manaTarget: bigint; - manaLimit: bigint; - provingCostPerManaEth: bigint; - epochDuration: bigint; - /** Pre-resolved L1 fees for each slot in the prediction window. */ - l1FeesBySlot: L1FeeData[]; -}; - -/** - * Predicts min fees for LAG upcoming slots based on the L1 oracle state. - * A new oracle update can activate at startSlot + LAG, so only the first LAG entries - * are guaranteed stable. Caches L1 queries per L1 block and recomputes predictions - * for each mana usage estimate. - */ -export class FeePredictor { - private cachedState: Promise | undefined; - private cachedL1BlockNumber: bigint | undefined; - - private readonly slotDuration: number; - private readonly l1GenesisTime: bigint; - private readonly ethereumSlotDuration: number; - - constructor( - private readonly rollupContract: RollupContract, - private readonly publicClient: { getBlockNumber: (opts?: { cacheTime?: number }) => Promise }, - private readonly dateProvider: DateProvider, - config: { slotDuration: number; l1GenesisTime: bigint; ethereumSlotDuration: number }, - ) { - this.slotDuration = config.slotDuration; - this.l1GenesisTime = config.l1GenesisTime; - this.ethereumSlotDuration = config.ethereumSlotDuration; - } - - /** Returns predicted min fees for each slot in the prediction window. */ - async getPredictedMinFees(manaUsage: ManaUsageEstimate): Promise { - const state = await this.getState(); - return this.computePredictions(state, manaUsage); - } - - /** Fetches and caches rollup state. Refreshes when L1 block number advances. */ - private async getState(): Promise { - const blockNumber = await this.publicClient.getBlockNumber({ cacheTime: 0 }); - if (this.cachedL1BlockNumber === undefined || blockNumber > this.cachedL1BlockNumber) { - this.cachedL1BlockNumber = blockNumber; - // Reset the cached block number on failure so a transient L1 RPC error does not leave a - // rejected promise cached for this block, which would replay the same rejection on every - // subsequent call until the next L1 block arrives. Only clear it if it still points at the - // block this attempt was for, so a stale rejection from an older block cannot wipe a marker - // a newer call already advanced (which would also defeat the monotonic block-number guard). - this.cachedState = this.fetchState(blockNumber).catch(err => { - if (this.cachedL1BlockNumber === blockNumber) { - this.cachedL1BlockNumber = undefined; - } - throw err; - }); - } - return this.cachedState!; - } - - private async fetchState(blockNumber: bigint): Promise { - // Pin all non-constant queries to this L1 block number for a consistent snapshot. - const opts = { blockNumber }; - - // Cached constants don't need pinning - const [manaTarget, manaLimit, provingCostPerManaEth, epochDuration] = await Promise.all([ - this.rollupContract.getManaTarget(), - this.rollupContract.getManaLimit(), - this.rollupContract.getProvingCostPerMana(), - this.rollupContract.getEpochDuration(), - ]); - - // First, compute the earliest possible nextSlot independently of the checkpoint, so we can - // evaluate pruneability at the prediction start timestamp instead of the current L1 block time. - // This avoids an epoch-boundary edge case where the effective parent differs between now and nextSlot. - const slotConfig = { slotDuration: this.slotDuration, l1GenesisTime: this.l1GenesisTime }; - const currentSlot = await this.rollupContract.getSlotNumber(opts); - - const slotAtNextL1Block = getSlotAtNextL1Block(BigInt(this.dateProvider.nowInSeconds()), { - l1GenesisTime: this.l1GenesisTime, - slotDuration: this.slotDuration, - ethereumSlotDuration: this.ethereumSlotDuration, - }); - const preliminaryNextSlot = SlotNumber(Math.max(currentSlot, slotAtNextL1Block)); - const nextSlotTimestamp = getTimestampForSlot(preliminaryNextSlot, slotConfig); - - // Resolve the effective checkpoint at the prediction start timestamp - const lastCheckpoint = await this.rollupContract.getEffectivePendingCheckpoint(nextSlotTimestamp, opts); - const lastSlot = lastCheckpoint.slotNumber; - // Refine nextSlot: also account for the slot after the last checkpoint - const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), preliminaryNextSlot)); - const feeHeader = lastCheckpoint.feeHeader; - - const slotCount = FEE_ORACLE_LAG; - const timestamps = times(slotCount, i => getTimestampForSlot(SlotNumber.add(nextSlot, i), slotConfig)); - const l1FeesBySlot = await Promise.all(timestamps.map(ts => this.rollupContract.getL1FeesAt(ts, opts))); - - return { - lastSlot, - excessMana: computeExcessMana(feeHeader.excessMana, feeHeader.manaUsed, manaTarget), - ethPerFeeAsset: feeHeader.ethPerFeeAsset, - manaTarget, - manaLimit, - provingCostPerManaEth, - epochDuration: BigInt(epochDuration), - l1FeesBySlot, - }; - } - - /** Computes per-slot fee predictions given cached state and a mana usage assumption. */ - private computePredictions(state: FeeOracleState, manaUsage: ManaUsageEstimate): GasFees[] { - const assumedManaUsed = this.getAssumedManaUsed(state, manaUsage); - - const result: GasFees[] = []; - let { excessMana } = state; - let { ethPerFeeAsset } = state; - - // Slot 0: current state (next available slot after last checkpoint) - result.push(this.computeGasFees(state, excessMana, ethPerFeeAsset, state.l1FeesBySlot[0])); - - // Slots 1..LAG-1: advance excessMana with the assumed mana usage per checkpoint, - // and decay ethPerFeeAsset by MAX_FEE_ASSET_PRICE_MODIFIER_BPS per slot for conservative estimates. - // Lower ethPerFeeAsset means higher fees in fee asset terms. - for (let i = 1; i < state.l1FeesBySlot.length; i++) { - excessMana = computeExcessMana(excessMana, assumedManaUsed, state.manaTarget); - const decayed = (ethPerFeeAsset * (10000n - MAX_FEE_ASSET_PRICE_MODIFIER_BPS)) / 10000n; - ethPerFeeAsset = decayed < MIN_ETH_PER_FEE_ASSET ? MIN_ETH_PER_FEE_ASSET : decayed; - result.push(this.computeGasFees(state, excessMana, ethPerFeeAsset, state.l1FeesBySlot[i])); - } - - return result; - } - - private getAssumedManaUsed(state: FeeOracleState, manaUsage: ManaUsageEstimate): bigint { - switch (manaUsage) { - case ManaUsageEstimate.None: - return 0n; - case ManaUsageEstimate.Target: - return state.manaTarget; - case ManaUsageEstimate.Limit: - return state.manaLimit; - } - } - - private computeGasFees( - state: FeeOracleState, - excessMana: bigint, - ethPerFeeAsset: bigint, - l1Fees: L1FeeData, - ): GasFees { - return new GasFees( - 0, - computeManaMinFee({ - l1BaseFee: l1Fees.baseFee, - l1BlobFee: l1Fees.blobFee, - manaTarget: state.manaTarget, - epochDuration: state.epochDuration, - provingCostPerManaEth: state.provingCostPerManaEth, - excessMana, - ethPerFeeAsset, - }), - ); - } -} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.test.ts deleted file mode 100644 index ec64d472183d..000000000000 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { promiseWithResolvers } from '@aztec/foundation/promise'; -import { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; - -import { jest } from '@jest/globals'; - -import { FeeProviderImpl } from './fee_provider.js'; - -describe('FeeProviderImpl', () => { - function makeProvider(currentMinFees: GasFees, predictedMinFees: GasFees[]) { - const blockNumber = 1n; - const getBlockNumber = jest.fn<() => Promise>(() => Promise.resolve(blockNumber)); - const getPredictedMinFees = jest.fn<(manaUsage: ManaUsageEstimate) => Promise>(() => - Promise.resolve(predictedMinFees), - ); - const provider: FeeProviderImpl = Object.create(FeeProviderImpl.prototype); - - Reflect.set(provider, 'publicClient', { getBlockNumber }); - Reflect.set(provider, 'currentL1BlockNumber', blockNumber); - Reflect.set(provider, 'currentMinFees', Promise.resolve(currentMinFees)); - Reflect.set(provider, 'feePredictor', { getPredictedMinFees }); - - return { provider, getBlockNumber, getPredictedMinFees }; - } - - it('prepends current min fees to predicted future fees', async () => { - const currentMinFees = new GasFees(1, 2); - const predictedMinFees = [new GasFees(3, 4), new GasFees(5, 6)]; - const { provider, getBlockNumber, getPredictedMinFees } = makeProvider(currentMinFees, predictedMinFees); - - await expect(provider.getPredictedMinFees(ManaUsageEstimate.Limit)).resolves.toEqual([ - currentMinFees, - ...predictedMinFees, - ]); - expect(getBlockNumber).toHaveBeenCalledWith({ cacheTime: 0 }); - expect(getPredictedMinFees).toHaveBeenCalledWith(ManaUsageEstimate.Limit); - }); - - it('defaults future fee prediction to target mana usage', async () => { - const { provider, getPredictedMinFees } = makeProvider(new GasFees(1, 2), [new GasFees(3, 4)]); - - await provider.getPredictedMinFees(); - - expect(getPredictedMinFees).toHaveBeenCalledWith(ManaUsageEstimate.Target); - }); - - it('recovers from a transient L1 read failure without waiting for a new L1 block', async () => { - const blockNumber = 1n; - const getBlockNumber = jest.fn<() => Promise>(() => Promise.resolve(blockNumber)); - const computeCurrentMinFees = jest - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('L1 RPC request failed')) - .mockResolvedValue(new GasFees(0, 42)); - - const provider: FeeProviderImpl = Object.create(FeeProviderImpl.prototype); - Reflect.set(provider, 'publicClient', { getBlockNumber }); - Reflect.set(provider, 'currentL1BlockNumber', undefined); - Reflect.set(provider, 'currentMinFees', Promise.resolve(new GasFees(0, 0))); - Reflect.set(provider, 'computeCurrentMinFees', computeCurrentMinFees); - - // First call fails on the transient L1 read. - await expect(provider.getCurrentMinFees()).rejects.toThrow('L1 RPC request failed'); - - // A subsequent call at the SAME L1 block must recompute rather than replay the cached rejection. - await expect(provider.getCurrentMinFees()).resolves.toEqual(new GasFees(0, 42)); - expect(computeCurrentMinFees).toHaveBeenCalledTimes(2); - }); - - it('does not clear the block marker when a stale computation for an older block rejects', async () => { - const blockN = 1n; - const blockNext = 2n; - const getBlockNumber = jest - .fn<() => Promise>() - .mockResolvedValueOnce(blockN) - .mockResolvedValueOnce(blockNext); - - const computeN = promiseWithResolvers(); - const computeCurrentMinFees = jest - .fn<() => Promise>() - .mockImplementationOnce(() => computeN.promise) - .mockResolvedValue(new GasFees(0, 42)); - - const provider: FeeProviderImpl = Object.create(FeeProviderImpl.prototype); - Reflect.set(provider, 'publicClient', { getBlockNumber }); - Reflect.set(provider, 'currentL1BlockNumber', undefined); - Reflect.set(provider, 'currentMinFees', Promise.resolve(new GasFees(0, 0))); - Reflect.set(provider, 'computeCurrentMinFees', computeCurrentMinFees); - - // Call at block N starts a computation that stays in flight. - const callN = provider.getCurrentMinFees(); - // Call at block N+1 advances the marker while the N computation is still pending. - const callNext = provider.getCurrentMinFees(); - - // The stale N computation now rejects. It must NOT clear the marker (which now points at N+1). - computeN.reject(new Error('stale L1 RPC request failed')); - - await expect(callN).rejects.toThrow('stale L1 RPC request failed'); - await expect(callNext).resolves.toEqual(new GasFees(0, 42)); - - // Marker still reflects the newer block; the N+1 computation ran exactly once (no spurious recompute). - expect(Reflect.get(provider, 'currentL1BlockNumber')).toBe(blockNext); - expect(computeCurrentMinFees).toHaveBeenCalledTimes(2); - }); -}); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts index 765f7213831d..006acc173309 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts @@ -1,97 +1,21 @@ -import { RollupContract } from '@aztec/ethereum/contracts'; -import type { ViemPublicClient } from '@aztec/ethereum/types'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import type { DateProvider } from '@aztec/foundation/timer'; -import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers'; -import { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; +import { type GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; import type { FeeProvider } from '@aztec/stdlib/tx'; -import { FeePredictor } from './fee_predictor.js'; -import type { GlobalVariableBuilderConfig } from './global_builder.js'; +import type { FeeSnapshotService } from './fee_snapshot_service.js'; -/** Provides current and predicted fee information based on on-chain state. */ +/** + * Serves current and predicted fee information from the background {@link FeeSnapshotService}. Warm calls read + * an in-memory snapshot and issue zero L1 requests; only an archiver identity change or a coverage miss triggers + * a shared refresh. The service is owned by the node factory and shared with the p2p mempool policy. + */ export class FeeProviderImpl implements FeeProvider { - private currentMinFees: Promise = Promise.resolve(new GasFees(0, 0)); - private currentL1BlockNumber: bigint | undefined = undefined; + constructor(private readonly service: FeeSnapshotService) {} - private readonly rollupContract: RollupContract; - private readonly feePredictor: FeePredictor; - private readonly ethereumSlotDuration: number; - private readonly l1GenesisTime: bigint; - - constructor( - private readonly dateProvider: DateProvider, - private readonly publicClient: ViemPublicClient, - config: GlobalVariableBuilderConfig, - ) { - this.ethereumSlotDuration = config.ethereumSlotDuration; - this.l1GenesisTime = config.l1GenesisTime; - - this.rollupContract = new RollupContract(this.publicClient, config.rollupAddress); - this.feePredictor = new FeePredictor(this.rollupContract, this.publicClient, this.dateProvider, { - slotDuration: config.slotDuration, - l1GenesisTime: config.l1GenesisTime, - ethereumSlotDuration: config.ethereumSlotDuration, - }); - } - - /** - * Computes the "current" min fees, e.g., the price that you currently should pay to get include in the next block - * @returns Min fees for the next block - */ - private async computeCurrentMinFees(): Promise { - // Since this might be called in the middle of a slot where a block might have been published, - // we need to fetch the last block written, and estimate the earliest timestamp for the next block. - // The timestamp of that last block will act as a lower bound for the next block. - - const lastCheckpoint = await this.rollupContract.getPendingCheckpoint(); - const earliestTimestamp = await this.rollupContract.getTimestampForSlot( - SlotNumber.fromBigInt(BigInt(lastCheckpoint.slotNumber) + 1n), - ); - const nextEthTimestamp = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { - l1GenesisTime: this.l1GenesisTime, - ethereumSlotDuration: this.ethereumSlotDuration, - }); - const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; - - return new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true)); + public getCurrentMinFees(): Promise { + return this.service.getCurrentMinFees(); } - public async getCurrentMinFees(): Promise { - // Get the current block number - const blockNumber = await this.publicClient.getBlockNumber({ cacheTime: 0 }); - - // If the L1 block number has changed then chain a new promise to get the current min fees. - // We chain off the previous promise's settlement (via a swallowing catch) rather than its - // fulfillment, so a prior rejection does not short-circuit the new computation. If the new - // computation fails (e.g. a transient L1 RPC error), reset the cached block number so the - // next call recomputes instead of permanently replaying the rejected promise — otherwise a - // single transient failure would wedge fee estimation until the next L1 block arrives. Only - // clear it if it still points at the block this attempt was for, so a stale rejection from an - // older block cannot wipe a marker a newer call already advanced (which would also defeat the - // monotonic block-number guard). - if (this.currentL1BlockNumber === undefined || blockNumber > this.currentL1BlockNumber) { - this.currentL1BlockNumber = blockNumber; - this.currentMinFees = this.currentMinFees - .catch(() => undefined) - .then(() => - this.computeCurrentMinFees().catch(err => { - if (this.currentL1BlockNumber === blockNumber) { - this.currentL1BlockNumber = undefined; - } - throw err; - }), - ); - } - return this.currentMinFees; - } - - public async getPredictedMinFees(manaUsage?: ManaUsageEstimate): Promise { - const [currentMinFees, predictedMinFees] = await Promise.all([ - this.getCurrentMinFees(), - this.feePredictor.getPredictedMinFees(manaUsage ?? ManaUsageEstimate.Target), - ]); - - return [currentMinFees, ...predictedMinFees]; + public getPredictedMinFees(manaUsage?: ManaUsageEstimate): Promise { + return this.service.getPredictedMinFees(manaUsage ?? ManaUsageEstimate.Target); } } diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts new file mode 100644 index 000000000000..f2c022d667aa --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts @@ -0,0 +1,161 @@ +import type { L1SyncSnapshot } from '@aztec/ethereum/l1-types'; +import type { SlotNumber } from '@aztec/foundation/branded-types'; +import type { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; + +/** + * One complete, finished fee quote for a single candidate slot. Entries are outputs, never partially derived + * state: both the current fee and all three prediction arrays are precomputed at refresh time so the read path + * issues zero L1 requests and merges only complete arrays. + */ +export type FeeQuoteCandidate = { + /** The candidate L2 slot this quote is for. */ + slot: SlotNumber; + /** Slot-start timestamp used for the pinned reads. */ + timestamp: bigint; + /** Canonical current fee: Solidity `getManaMinFeeAt(timestamp)` at the pinned block. */ + currentMinFee: GasFees; + /** Precomputed `FEE_ORACLE_LAG`-length prediction array per mana-usage estimate. */ + predictions: Record; +}; + +/** + * Immutable, atomically-swapped view of the fee model at a single pinned L1 block. Selection floors come only + * from the snapshot-level fields (`pendingCheckpointSlot`, `pinnedSlot`); no per-candidate selection state. + */ +export type FeeSnapshot = { + /** L1 identity this snapshot was built at (block number + hash + timestamp). */ + l1: L1SyncSnapshot; + /** Raw pending checkpoint slot at the pinned block — floor for the current-fee anchor rule. */ + pendingCheckpointSlot: SlotNumber; + /** Slot of the pinned block timestamp (TS arithmetic) — L1-side floor for the prediction anchor rule. */ + pinnedSlot: SlotNumber; + /** One complete entry per materialized candidate slot, keyed by the primitive slot number. */ + candidates: ReadonlyMap; + /** Lowest slot of the contiguous materialized window (informational; coverage uses map membership). */ + baseSlot: SlotNumber; + /** Highest slot of the contiguous materialized window (informational; coverage uses map membership). */ + topSlot: SlotNumber; + /** Computation-age anchor (DateProvider ms). Reset by every successful refresh, including coverage-only. */ + refreshedAtMs: number; +}; + +/** Tuning and staleness configuration for the fee snapshot service. All durations use their stated units. */ +export type FeeSnapshotServiceConfig = { + slotDuration: number; + l1GenesisTime: bigint; + ethereumSlotDuration: number; + epochDuration: number; + /** Symmetric wall-clock error allowance (seconds) that widens the read-time window. `0` disables the window. */ + clockDriftAllowanceSeconds: number; + /** Extra slots added above the wanted window so empty-Ethereum-slot runs do not freeze quotes. */ + coverageHeadroomSlots: number; + /** Hard cap on the contiguous provisional window size; larger windows are materialized capped + topped up. */ + maxCandidateWindowSlots: number; + /** Hard cap on distinct enumerated candidate slots per rule; a drift producing more is rejected at startup. */ + maxClockCandidates: number; + /** Background poll interval (ms) for the refresh loop; only in-memory comparisons run per tick. */ + pollIntervalMs: number; + /** Max age (ms) of the last successful refresh before reads fail closed. `0` disables. */ + maxRefreshAgeMs: number; + /** Max age (seconds) of the pinned L1 head before reads fail closed. `0` disables. */ + maxL1HeadAgeSeconds: number; + /** Max seconds the pinned L1 head may be dated ahead of the wall clock before reads fail closed. `0` disables. */ + futureHeadAllowanceSeconds: number; + /** Short bound (ms) a read waits for an in-flight/triggered refresh before failing closed with a typed error. */ + refreshTimeoutMs: number; +}; + +/** Derives the default fee snapshot service config from the L1 timing constants. */ +export function getDefaultFeeSnapshotServiceConfig(base: { + slotDuration: number; + l1GenesisTime: bigint; + ethereumSlotDuration: number; + epochDuration: number; +}): FeeSnapshotServiceConfig { + const clockDriftAllowanceSeconds = 2; + return { + ...base, + clockDriftAllowanceSeconds, + coverageHeadroomSlots: 2, + maxCandidateWindowSlots: 16, + maxClockCandidates: 8, + pollIntervalMs: 150, + maxRefreshAgeMs: 60_000, + maxL1HeadAgeSeconds: 300, + futureHeadAllowanceSeconds: 2 * base.ethereumSlotDuration + clockDriftAllowanceSeconds, + refreshTimeoutMs: 5_000, + }; +} + +/** Base class for all fee snapshot errors, so callers can catch the whole family. */ +export class FeeSnapshotError extends Error { + constructor(message: string) { + super(message); + this.name = 'FeeSnapshotError'; + } +} + +/** Invalid service configuration detected at startup (e.g. drift produces more candidates than the cap allows). */ +export class FeeSnapshotConfigError extends FeeSnapshotError { + constructor(message: string) { + super(message); + this.name = 'FeeSnapshotConfigError'; + } +} + +/** No snapshot is available yet (first refresh not completed) within the caller's bound. */ +export class FeeSnapshotUnavailableError extends FeeSnapshotError { + constructor(message = 'Fee snapshot is not available yet') { + super(message); + this.name = 'FeeSnapshotUnavailableError'; + } +} + +/** The service was stopped while a read was waiting. */ +export class FeeSnapshotStoppedError extends FeeSnapshotError { + constructor(message = 'Fee snapshot service was stopped') { + super(message); + this.name = 'FeeSnapshotStoppedError'; + } +} + +/** A wanted slot fell outside the covered window and a refresh could not cover it within the bound. */ +export class FeeSnapshotCoverageError extends FeeSnapshotError { + constructor(message: string) { + super(message); + this.name = 'FeeSnapshotCoverageError'; + } +} + +/** The last successful refresh is too old (refresh is broken). */ +export class FeeSnapshotComputationStaleError extends FeeSnapshotError { + constructor( + public readonly ageMs: number, + public readonly maxAgeMs: number, + ) { + super(`Fee snapshot computation is stale: age ${ageMs}ms exceeds max ${maxAgeMs}ms`); + this.name = 'FeeSnapshotComputationStaleError'; + } +} + +/** The pinned L1 head is too old (provider or archiver frozen). */ +export class FeeSnapshotL1HeadStaleError extends FeeSnapshotError { + constructor( + public readonly ageSeconds: number, + public readonly maxAgeSeconds: number, + ) { + super(`Fee snapshot L1 head is stale: age ${ageSeconds}s exceeds max ${maxAgeSeconds}s`); + this.name = 'FeeSnapshotL1HeadStaleError'; + } +} + +/** The pinned L1 head is dated further into the future than allowed (fails closed in production). */ +export class FeeSnapshotFutureHeadError extends FeeSnapshotError { + constructor( + public readonly aheadSeconds: number, + public readonly allowanceSeconds: number, + ) { + super(`Fee snapshot L1 head is ${aheadSeconds}s ahead of wall clock, exceeding allowance ${allowanceSeconds}s`); + this.name = 'FeeSnapshotFutureHeadError'; + } +} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts new file mode 100644 index 000000000000..0e7954ed9f81 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts @@ -0,0 +1,175 @@ +import { getPublicClient } from '@aztec/ethereum/client'; +import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; +import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; +import type { ViemClient } from '@aztec/ethereum/types'; +import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { createLogger } from '@aztec/foundation/log'; +import { DateProvider, ManualDateProvider } from '@aztec/foundation/timer'; +import { ManaUsageEstimate } from '@aztec/stdlib/gas'; + +import { foundry } from 'viem/chains'; + +import { type FeeSnapshotServiceConfig, getDefaultFeeSnapshotServiceConfig } from './fee_snapshot.js'; +import { FeeSnapshotService } from './fee_snapshot_service.js'; +import { computeLegacyCurrentMinFees, computeLegacyPredictedMinFees } from './legacy_fee_oracle.js'; + +// The snapshot service path must reduce exactly to the legacy oracle on identical (blockNumber, now). +describe('FeeSnapshot equivalence with legacy oracle', () => { + let anvil: Anvil; + let rpcUrl: string; + let publicClient: ViemClient; + let rollup: RollupContract; + let cheatCodes: EthCheatCodes; + let rollupCheatCodes: RollupCheatCodes; + + let slotDuration: number; + let ethereumSlotDuration: number; + let l1GenesisTime: bigint; + let epochDuration: number; + let constants: { l1GenesisTime: bigint; slotDuration: number; ethereumSlotDuration: number }; + + beforeAll(async () => { + const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; + ({ anvil, rpcUrl } = await startAnvil()); + publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); + cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); + + const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { + ...DefaultL1ContractsConfig, + vkTreeRoot: Fr.random(), + protocolContractsHash: Fr.random(), + genesisArchiveRoot: Fr.random(), + realVerifier: false, + }); + + rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); + rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployed.l1ContractAddresses, new DateProvider()); + slotDuration = await rollup.getSlotDuration(); + ethereumSlotDuration = DefaultL1ContractsConfig.ethereumSlotDuration; + l1GenesisTime = await rollup.getL1GenesisTime(); + epochDuration = await rollup.getEpochDuration(); + constants = { l1GenesisTime, slotDuration, ethereumSlotDuration }; + }, 60_000); + + afterAll(async () => { + await cheatCodes.setIntervalMining(0); + await anvil?.stop().catch(err => createLogger('cleanup').error('Error stopping anvil', err)); + }); + + function makeService(identity: L1SyncSnapshot, dateProvider: ManualDateProvider): FeeSnapshotService { + const config: FeeSnapshotServiceConfig = { + ...getDefaultFeeSnapshotServiceConfig({ slotDuration, l1GenesisTime, ethereumSlotDuration, epochDuration }), + clockDriftAllowanceSeconds: 0, + maxRefreshAgeMs: 0, + maxL1HeadAgeSeconds: 0, + futureHeadAllowanceSeconds: 0, + refreshTimeoutMs: 30_000, + pollIntervalMs: 1_000_000_000, + }; + const identityProvider: L1SyncSnapshotProvider = { getL1SyncSnapshot: () => identity }; + return new FeeSnapshotService(rollup, identityProvider, dateProvider, config); + } + + /** Compares the snapshot service output against the legacy oracle at the current L1 block, for a given now. */ + async function assertEquivalent(nowSeconds: number): Promise { + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const identity: L1SyncSnapshot = { + blockNumber, + blockHash: Buffer32.fromString(block.hash), + blockTimestamp: block.timestamp, + }; + const dateProvider = new ManualDateProvider(); + dateProvider.setTime(nowSeconds * 1000); + const service = makeService(identity, dateProvider); + try { + const currentService = await service.getCurrentMinFees(); + const currentLegacy = await computeLegacyCurrentMinFees(rollup, blockNumber, nowSeconds, constants); + expect(currentService.feePerL2Gas).toBe(currentLegacy.feePerL2Gas); + + for (const estimate of [ManaUsageEstimate.None, ManaUsageEstimate.Target, ManaUsageEstimate.Limit]) { + const fromService = await service.getPredictedMinFees(estimate); + const fromLegacy = await computeLegacyPredictedMinFees(rollup, blockNumber, nowSeconds, constants, estimate); + expect(fromService.map(f => f.feePerL2Gas)).toEqual(fromLegacy.map(f => f.feePerL2Gas)); + } + } finally { + await service.stop(); + } + } + + async function currentBlockTimestamp(): Promise { + return Number((await publicClient.getBlock()).timestamp); + } + + it('matches the legacy oracle at a fresh deploy', async () => { + await assertEquivalent(await currentBlockTimestamp()); + }); + + it('matches the legacy oracle after advancing several slots', async () => { + await rollupCheatCodes.advanceSlots(5); + await cheatCodes.mine(); + await assertEquivalent(await currentBlockTimestamp()); + }, 30_000); + + it('matches the legacy oracle after an L1 gas fee oracle update', async () => { + await rollupCheatCodes.advanceSlots(3); + await cheatCodes.setNextBlockBaseFeePerGas(120_000_000_000n); + await cheatCodes.mine(); + await rollupCheatCodes.updateL1GasFeeOracle(); + await cheatCodes.mine(); + await assertEquivalent(await currentBlockTimestamp()); + }, 30_000); + + it('matches with the host clock one Ethereum slot behind and ahead of the L1 timestamp', async () => { + const ts = await currentBlockTimestamp(); + await assertEquivalent(ts - ethereumSlotDuration); + await assertEquivalent(ts + ethereumSlotDuration); + }, 30_000); + + it('is pinned: a snapshot built at block N is unaffected by later blocks until refresh', async () => { + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const nowSeconds = Number(block.timestamp); + const identity: L1SyncSnapshot = { + blockNumber, + blockHash: Buffer32.fromString(block.hash), + blockTimestamp: block.timestamp, + }; + const dateProvider = new ManualDateProvider(); + dateProvider.setTime(nowSeconds * 1000); + const service = makeService(identity, dateProvider); + try { + const before = await service.getCurrentMinFees(); + // Mine later blocks and bump the base fee; the fixed-identity service must still serve the block-N value. + await cheatCodes.setNextBlockBaseFeePerGas(500_000_000_000n); + await cheatCodes.mine(3); + const afterPinned = await service.getCurrentMinFees(); + expect(afterPinned.feePerL2Gas).toBe(before.feePerL2Gas); + // And it equals the legacy oracle recomputed at the same pinned block N. + const legacyAtN = await computeLegacyCurrentMinFees(rollup, blockNumber, nowSeconds, constants); + expect(afterPinned.feePerL2Gas).toBe(legacyAtN.feePerL2Gas); + } finally { + await service.stop(); + } + }, 30_000); + + it('getManaMinFeeAt is constant within an Aztec slot (start, mid, end)', async () => { + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const currentSlot = await rollup.getSlotNumber({ blockNumber }); + const slotStart = l1GenesisTime + BigInt(Number(currentSlot) + 1) * BigInt(slotDuration); + const mid = slotStart + BigInt(Math.floor(slotDuration / 2)); + const slotEnd = slotStart + BigInt(slotDuration - 1); + const [atStart, atMid, atEnd] = await Promise.all([ + rollup.getManaMinFeeAt(slotStart, true, { blockNumber }), + rollup.getManaMinFeeAt(mid, true, { blockNumber }), + rollup.getManaMinFeeAt(slotEnd, true, { blockNumber }), + ]); + expect(atMid).toBe(atStart); + expect(atEnd).toBe(atStart); + }); +}); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts new file mode 100644 index 000000000000..96e4af314f61 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts @@ -0,0 +1,331 @@ +import type { CheckpointLog, FeeHeader, RollupFeeRead, RollupFeeReadResult } from '@aztec/ethereum/contracts'; +import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { ManualDateProvider } from '@aztec/foundation/timer'; +import { FEE_ORACLE_LAG, ManaUsageEstimate } from '@aztec/stdlib/gas'; + +import { + FeeSnapshotComputationStaleError, + FeeSnapshotConfigError, + FeeSnapshotFutureHeadError, + FeeSnapshotL1HeadStaleError, + type FeeSnapshotServiceConfig, + FeeSnapshotStoppedError, + FeeSnapshotUnavailableError, + getDefaultFeeSnapshotServiceConfig, +} from './fee_snapshot.js'; +import { FeeSnapshotService, type RollupFeeReader } from './fee_snapshot_service.js'; + +const L1_GENESIS_TIME = 0n; +const SLOT_DURATION = 24; +const ETHEREUM_SLOT_DURATION = 12; +const EPOCH_DURATION = 32; + +const FEE_HEADER: FeeHeader = { + excessMana: 0n, + manaUsed: 0n, + ethPerFeeAsset: 1_000_000_000_000n, + congestionCost: 0n, + proverCost: 0n, +}; + +function slotOfTimestamp(ts: bigint): number { + return Number((ts - L1_GENESIS_TIME) / BigInt(SLOT_DURATION)); +} + +function makeCheckpoint(slot: number): CheckpointLog { + return { + archive: Fr.ZERO, + headerHash: Buffer32.ZERO, + blobCommitmentsHash: Buffer32.ZERO, + attestationsHash: Buffer32.ZERO, + payloadDigest: Buffer32.ZERO, + slotNumber: SlotNumber(slot), + feeHeader: FEE_HEADER, + }; +} + +/** Deterministic in-memory rollup reader. `getManaMinFeeAt` returns the slot number so current fees are identifiable. */ +class FakeRollup implements RollupFeeReader { + public callCount = 0; + public calls: { blockNumber: bigint; reads: RollupFeeRead[] }[] = []; + public tips = { pending: CheckpointNumber(5), proven: CheckpointNumber(3) }; + public pendingSlot = 100; + public provenSlot = 90; + public manaTarget = 1000n; + public manaLimit = 2000n; + public provingCost = 5n; + public canPrune = false; + /** Number of upcoming readFeeInputs calls to fail. */ + public failNext = 0; + /** Optional gate: when set, readFeeInputs waits on this before resolving. */ + public gate: Promise | undefined; + /** Wave-2 tips override to simulate fork mixing (applied once). */ + public wave2TipsOnce: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; + + async readFeeInputs(reads: RollupFeeRead[], options: { blockNumber: bigint }): Promise { + this.callCount++; + this.calls.push({ blockNumber: options.blockNumber, reads }); + if (this.gate) { + await this.gate; + } + if (this.failNext > 0) { + this.failNext--; + throw new Error('L1 read failed'); + } + const isWave2 = reads.some(r => r.kind === 'checkpoint'); + return reads.map(r => this.resolve(r, isWave2)); + } + + private resolve(read: RollupFeeRead, isWave2: boolean): RollupFeeReadResult { + switch (read.kind) { + case 'tips': { + if (isWave2 && this.wave2TipsOnce) { + const value = this.wave2TipsOnce; + this.wave2TipsOnce = undefined; + return { kind: 'tips', value }; + } + return { kind: 'tips', value: this.tips }; + } + case 'manaTarget': + return { kind: 'manaTarget', value: this.manaTarget }; + case 'manaLimit': + return { kind: 'manaLimit', value: this.manaLimit }; + case 'provingCostPerManaEth': + return { kind: 'provingCostPerManaEth', value: this.provingCost }; + case 'manaMinFeeAt': + return { kind: 'manaMinFeeAt', timestamp: read.timestamp, value: BigInt(slotOfTimestamp(read.timestamp)) }; + case 'canPruneAtTime': + return { kind: 'canPruneAtTime', timestamp: read.timestamp, value: this.canPrune }; + case 'l1FeesAt': + return { kind: 'l1FeesAt', timestamp: read.timestamp, value: { baseFee: 1n, blobFee: 1n } }; + case 'checkpoint': { + const slot = Number(read.checkpointNumber) === Number(this.tips.pending) ? this.pendingSlot : this.provenSlot; + return { kind: 'checkpoint', checkpointNumber: read.checkpointNumber, value: makeCheckpoint(slot) }; + } + } + } +} + +class FakeIdentityProvider implements L1SyncSnapshotProvider { + public snapshot: L1SyncSnapshot | undefined; + getL1SyncSnapshot(): L1SyncSnapshot | undefined { + return this.snapshot; + } +} + +function makeIdentity(blockNumber: bigint, pinnedSlot: number, hash?: Buffer32): L1SyncSnapshot { + return { + blockNumber, + blockHash: hash ?? Buffer32.fromNumber(Number(blockNumber)), + blockTimestamp: BigInt(pinnedSlot * SLOT_DURATION), + }; +} + +describe('FeeSnapshotService', () => { + let rollup: FakeRollup; + let identity: FakeIdentityProvider; + let dateProvider: ManualDateProvider; + let service: FeeSnapshotService; + + const PINNED_SLOT = 100; + + function makeService(overrides: Partial = {}): FeeSnapshotService { + const config: FeeSnapshotServiceConfig = { + ...getDefaultFeeSnapshotServiceConfig({ + slotDuration: SLOT_DURATION, + l1GenesisTime: L1_GENESIS_TIME, + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + epochDuration: EPOCH_DURATION, + }), + clockDriftAllowanceSeconds: 0, + refreshTimeoutMs: 100, + pollIntervalMs: 10_000_000, + ...overrides, + }; + return new FeeSnapshotService(rollup, identity, dateProvider, config); + } + + beforeEach(() => { + rollup = new FakeRollup(); + identity = new FakeIdentityProvider(); + dateProvider = new ManualDateProvider(); + // Align wall clock with the pinned L1 timestamp. + dateProvider.setTime(PINNED_SLOT * SLOT_DURATION * 1000); + identity.snapshot = makeIdentity(1n, PINNED_SLOT); + service = makeService(); + }); + + afterEach(async () => { + await service.stop(); + }); + + it('serves the current fee floored on the pending checkpoint slot', async () => { + // pendingSlot = 100, so wantedCurrent = max(101, slotAtNextL1Block(now)=100) = 101. + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + }); + + it('returns current fee followed by FEE_ORACLE_LAG predictions', async () => { + const fees = await service.getPredictedMinFees(ManaUsageEstimate.Limit); + expect(fees).toHaveLength(1 + FEE_ORACLE_LAG); + expect(fees[0].feePerL2Gas).toBe(101n); + }); + + it('warm calls issue zero L1 requests', async () => { + await service.getCurrentMinFees(); + const afterFirst = rollup.callCount; + expect(afterFirst).toBeGreaterThan(0); + for (let i = 0; i < 20; i++) { + await service.getPredictedMinFees(ManaUsageEstimate.Target); + } + expect(rollup.callCount).toBe(afterFirst); + }); + + it('shares a single refresh across concurrent cold calls (single-flight)', async () => { + const results = await Promise.all(Array.from({ length: 20 }, () => service.getCurrentMinFees())); + for (const fees of results) { + expect(fees.feePerL2Gas).toBe(101n); + } + // A normal refresh is two waves (wave1 + wave2); single-flight collapses the 20 callers into one refresh. + expect(rollup.callCount).toBe(2); + }); + + it('refreshes once on an archiver identity change and shares the completion', async () => { + await service.getCurrentMinFees(); + const before = rollup.callCount; + identity.snapshot = makeIdentity(2n, PINNED_SLOT); + const results = await Promise.all(Array.from({ length: 10 }, () => service.getCurrentMinFees())); + for (const fees of results) { + expect(fees.feePerL2Gas).toBe(101n); + } + expect(rollup.callCount).toBe(before + 2); + expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); + }); + + it('refreshes on same block number but different hash', async () => { + await service.getCurrentMinFees(); + const before = rollup.callCount; + identity.snapshot = makeIdentity(1n, PINNED_SLOT, Buffer32.fromNumber(999)); + await service.getCurrentMinFees(); + expect(rollup.callCount).toBe(before + 2); + expect(service.getSnapshot()!.l1.blockHash.equals(Buffer32.fromNumber(999))).toBe(true); + }); + + it('the RPC path issues no L1 request itself during an identity transition', async () => { + await service.getCurrentMinFees(); + // The archiver publishes a new identity; the RPC lookup observes it before any poll tick. + identity.snapshot = makeIdentity(2n, PINNED_SLOT); + const statsBefore = service.getStats().readTriggeredRefreshes; + await service.getCurrentMinFees(); + // The read triggered exactly one refresh (shared), and did not fan out its own read chain. + expect(service.getStats().readTriggeredRefreshes).toBe(statsBefore + 1); + expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); + }); + + it('keeps serving the last-good snapshot when a refresh fails, then recovers', async () => { + await service.getCurrentMinFees(); + const good = service.getSnapshot(); + identity.snapshot = makeIdentity(2n, PINNED_SLOT); + rollup.failNext = 1; + await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); + // Last-good snapshot is preserved (identity 1). + expect(service.getSnapshot()).toBe(good); + // Recovery: next call refreshes successfully to the new identity. + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); + }); + + it('discards and restarts the refresh when wave-2 tips differ from wave-1', async () => { + rollup.wave2TipsOnce = { pending: CheckpointNumber(6), proven: CheckpointNumber(3) }; + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + expect(service.getStats().tipsMismatchDiscards).toBe(1); + }); + + describe('first-snapshot promise', () => { + it('times out with a typed error while the first refresh keeps failing, then serves once it succeeds', async () => { + rollup.failNext = 100; + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); + rollup.failNext = 0; + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + }); + + it('reports unavailable when there is no L1 identity yet', async () => { + identity.snapshot = undefined; + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); + }); + }); + + describe('stop', () => { + it('rejects the first-snapshot waiter on stop', async () => { + // No L1 identity yet, so no refresh is triggered and the caller parks on the first-snapshot promise. + service = makeService({ refreshTimeoutMs: 10_000 }); + identity.snapshot = undefined; + const pending = service.getCurrentMinFees(); + await service.stop(); + await expect(pending).rejects.toBeInstanceOf(FeeSnapshotStoppedError); + }); + }); + + describe('staleness', () => { + it('fails closed when the computation age exceeds the bound', async () => { + service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); + await service.getCurrentMinFees(); + dateProvider.advanceTimeMs(2_000); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotComputationStaleError); + }); + + it('fails closed when the L1 head age exceeds the bound', async () => { + service = makeService({ maxRefreshAgeMs: 0, maxL1HeadAgeSeconds: 60, futureHeadAllowanceSeconds: 0 }); + await service.getCurrentMinFees(); + dateProvider.advanceTime(120); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotL1HeadStaleError); + }); + + it('fails closed when the L1 head is dated too far into the future', async () => { + service = makeService({ maxRefreshAgeMs: 0, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 10 }); + await service.getCurrentMinFees(); + // Step the wall clock backwards so the pinned head is far ahead of "now". + dateProvider.advanceTime(-120); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotFutureHeadError); + }); + + it('disables each staleness check independently when its config is 0', async () => { + service = makeService({ maxRefreshAgeMs: 0, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); + await service.getCurrentMinFees(); + dateProvider.advanceTime(100_000); + // With all checks disabled, a very old snapshot is still served for the covered slot. + await expect(service.getCurrentMinFees()).resolves.toBeDefined(); + }); + }); + + describe('drift window', () => { + it('rejects a configuration whose drift enumerates more candidates than the cap', () => { + expect(() => makeService({ clockDriftAllowanceSeconds: 1000, maxClockCandidates: 2 })).toThrow( + FeeSnapshotConfigError, + ); + }); + + it('drift 0 reduces to the single-anchor selection', async () => { + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + }); + + it('enumerates every slot across a multi-slot drift window and takes the element-wise max', async () => { + // Lower the pending checkpoint so the wall clock (not the floor) drives the enumerated window. + rollup.pendingSlot = 90; + // A drift of one slot each way spans multiple candidate slots; current fee == slot, so max == highest slot. + service = makeService({ clockDriftAllowanceSeconds: SLOT_DURATION, maxClockCandidates: 8 }); + const fees = await service.getCurrentMinFees(); + const nowSeconds = BigInt(dateProvider.nowInSeconds()); + const highBoundary = nowSeconds + BigInt(SLOT_DURATION) + BigInt(ETHEREUM_SLOT_DURATION); + const expectedHigh = Math.max(rollup.pendingSlot + 1, slotOfTimestamp(highBoundary)); + expect(fees.feePerL2Gas).toBe(BigInt(expectedHigh)); + }); + }); +}); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts new file mode 100644 index 000000000000..7ec489a93e7e --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts @@ -0,0 +1,868 @@ +import type { FeeHeader, RollupFeeRead, RollupFeeReadResult } from '@aztec/ethereum/contracts'; +import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { type Logger, createLogger } from '@aztec/foundation/log'; +import { type PromiseWithResolvers, RunningPromise, promiseWithResolvers } from '@aztec/foundation/promise'; +import type { DateProvider } from '@aztec/foundation/timer'; +import { + type L1RollupConstants, + getSlotAtNextL1Block, + getSlotAtTimestamp, + getTimestampForSlot, +} from '@aztec/stdlib/epoch-helpers'; +import { FEE_ORACLE_LAG, GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; + +import { buildFeeOracleState, computePredictions } from './fee_prediction.js'; +import { + type FeeQuoteCandidate, + type FeeSnapshot, + FeeSnapshotComputationStaleError, + FeeSnapshotConfigError, + FeeSnapshotCoverageError, + FeeSnapshotError, + FeeSnapshotFutureHeadError, + FeeSnapshotL1HeadStaleError, + type FeeSnapshotServiceConfig, + FeeSnapshotStoppedError, + FeeSnapshotUnavailableError, +} from './fee_snapshot.js'; + +/** The subset of {@link RollupContract} the fee snapshot service depends on: a batched pinned fee reader. */ +export interface RollupFeeReader { + readFeeInputs( + reads: RollupFeeRead[], + options: { blockNumber: bigint; allowMulticall?: boolean }, + ): Promise; +} + +/** Cause of a refresh, recorded on metrics/logs for observability. */ +type RefreshCause = 'poll-identity' | 'poll-coverage' | 'rpc-identity-mismatch' | 'coverage-miss' | 'rpc-first'; + +/** Counters exposed for benchmarking and observability. */ +export type FeeSnapshotStats = { + /** Total successful background/first refreshes that published a snapshot. */ + refreshes: number; + /** Total refresh failures (kept last-good, retried with backoff). */ + refreshFailures: number; + /** Reads that had to trigger a refresh because the warm snapshot did not serve them (identity or coverage). */ + readTriggeredRefreshes: number; + /** Wave-2 tips-mismatch discards. */ + tipsMismatchDiscards: number; + /** Targeted top-up waves fired during refresh. */ + topUpWaves: number; +}; + +const MAX_LOOKUP_ATTEMPTS = 4; +const MAX_REFRESH_ATTEMPTS = 4; +const BACKOFF_BASE_MS = 250; +const BACKOFF_MAX_MS = 8_000; + +class TimeoutError extends Error {} + +/** + * Serves current and predicted fee quotes from an in-memory snapshot refreshed in the background per L1 block, + * so warm RPC calls issue zero L1 requests. Reads are served from a complete, immutable, atomically-swapped + * {@link FeeSnapshot}; the refresh pins every read to the archiver's L1 block, labels it with the block hash, + * and re-validates the archiver identity before publishing. + */ +export class FeeSnapshotService { + private snapshot: FeeSnapshot | undefined; + private firstSnapshot: PromiseWithResolvers = promiseWithResolvers(); + private firstResolved = false; + + private readonly runningPromise: RunningPromise; + private stopped = false; + private readonly stopSignal: PromiseWithResolvers = promiseWithResolvers(); + + private inFlight: { key: string; promise: Promise } | undefined; + private consecutiveFailures = 0; + private nextRetryAtMs = 0; + + private readonly constants: Pick; + private readonly stats: FeeSnapshotStats = { + refreshes: 0, + refreshFailures: 0, + readTriggeredRefreshes: 0, + tipsMismatchDiscards: 0, + topUpWaves: 0, + }; + + constructor( + private readonly rollup: RollupFeeReader, + private readonly identityProvider: L1SyncSnapshotProvider, + private readonly dateProvider: DateProvider, + private readonly config: FeeSnapshotServiceConfig, + private readonly log: Logger = createLogger('sequencer:fee-snapshot'), + ) { + this.validateConfig(); + this.constants = { + l1GenesisTime: config.l1GenesisTime, + slotDuration: config.slotDuration, + ethereumSlotDuration: config.ethereumSlotDuration, + }; + this.runningPromise = new RunningPromise(() => this.tick(), this.log, config.pollIntervalMs); + // Keep the stop signal and the first-snapshot promise from surfacing as unhandled rejections when nothing + // is awaiting them (e.g. stop() rejects the first-snapshot promise before any read parked on it). + this.stopSignal.promise.catch(() => {}); + this.firstSnapshot.promise.catch(() => {}); + } + + /** Rejects a configuration whose drift window would enumerate more candidates than the cap allows. */ + private validateConfig(): void { + const { clockDriftAllowanceSeconds, slotDuration, maxClockCandidates } = this.config; + // Distinct slots enumerated across a `2 * drift` interval is at most `ceil(2*drift/slotDuration) + 1`. + const maxCandidates = Math.ceil((2 * clockDriftAllowanceSeconds) / slotDuration) + 1; + if (maxCandidates > maxClockCandidates) { + throw new FeeSnapshotConfigError( + `clockDriftAllowanceSeconds ${clockDriftAllowanceSeconds} with slotDuration ${slotDuration} enumerates up to ` + + `${maxCandidates} candidates, exceeding maxClockCandidates ${maxClockCandidates}`, + ); + } + } + + /** Starts the background refresh loop. */ + public start(): void { + if (this.stopped) { + throw new FeeSnapshotStoppedError('Cannot start a stopped fee snapshot service'); + } + this.runningPromise.start(); + this.log.verbose('Fee snapshot service started', { pollIntervalMs: this.config.pollIntervalMs }); + } + + /** Stops the loop, awaits in-flight work, and rejects all pending waiters and the first-snapshot promise. */ + public async stop(): Promise { + if (this.stopped) { + return; + } + this.stopped = true; + this.stopSignal.reject(new FeeSnapshotStoppedError()); + await this.runningPromise.stop(); + await this.inFlight?.promise.catch(() => undefined); + if (!this.firstResolved) { + this.firstSnapshot.reject(new FeeSnapshotStoppedError()); + } + this.log.verbose('Fee snapshot service stopped'); + } + + /** Returns a snapshot of the service counters. */ + public getStats(): FeeSnapshotStats { + return { ...this.stats }; + } + + /** Returns the currently published snapshot, if any. Exposed for testing. */ + public getSnapshot(): FeeSnapshot | undefined { + return this.snapshot; + } + + /** Returns the current minimum fees for inclusion in the next block. */ + public async getCurrentMinFees(): Promise { + const { snapshot, currentSlots } = await this.resolveLookup(); + return maxGasFees(currentSlots.map(s => this.candidate(snapshot, s).currentMinFee)); + } + + /** Returns current min fees first, followed by predicted min fees for each slot in the prediction window. */ + public async getPredictedMinFees(manaUsage: ManaUsageEstimate): Promise { + const { snapshot, currentSlots, predictionSlots } = await this.resolveLookup(); + const current = maxGasFees(currentSlots.map(s => this.candidate(snapshot, s).currentMinFee)); + const predictionArrays = predictionSlots.map(s => this.candidate(snapshot, s).predictions[manaUsage]); + return [current, ...maxGasFeesElementWise(predictionArrays)]; + } + + // --------------------------------------------------------------------------------------------------------- + // Read path (in-memory only; issues no L1 requests unless a refresh is required) + // --------------------------------------------------------------------------------------------------------- + + private async resolveLookup(): Promise<{ snapshot: FeeSnapshot; currentSlots: number[]; predictionSlots: number[] }> { + const deadline = this.dateProvider.now() + this.config.refreshTimeoutMs; + + if (this.snapshot === undefined) { + await this.awaitFirstSnapshot(deadline); + } + + for (let attempt = 0; attempt < MAX_LOOKUP_ATTEMPTS; attempt++) { + if (this.stopped) { + throw new FeeSnapshotStoppedError(); + } + const snapshot = this.snapshot!; + const nowMs = this.dateProvider.now(); + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); + this.assertFresh(snapshot, nowMs, nowSeconds); + + // Identity check: serve freshness parity with today's per-call latest-block check, at zero L1 cost. + const identity = this.identityProvider.getL1SyncSnapshot(); + if (identity && !identity.blockHash.equals(snapshot.l1.blockHash)) { + await this.readTriggeredRefresh(deadline, 'rpc-identity-mismatch'); + continue; + } + + const { currentSlots, predictionSlots } = this.computeWantedSlots(snapshot, nowSeconds); + const uncovered = [...currentSlots, ...predictionSlots].some(s => !snapshot.candidates.has(s)); + if (uncovered) { + await this.readTriggeredRefresh(deadline, 'coverage-miss'); + continue; + } + + return { snapshot, currentSlots, predictionSlots }; + } + + throw new FeeSnapshotCoverageError( + `Fee snapshot did not cover the wanted slots within ${this.config.refreshTimeoutMs}ms`, + ); + } + + private candidate(snapshot: FeeSnapshot, slot: number): FeeQuoteCandidate { + const candidate = snapshot.candidates.get(slot); + if (!candidate) { + // Coverage is validated before we get here; a miss is a programming error, never a substituted answer. + throw new FeeSnapshotCoverageError(`Fee snapshot has no candidate for slot ${slot}`); + } + return candidate; + } + + /** Enumerates the current-rule and prediction-rule wanted slots across the drift window (inclusive). */ + private computeWantedSlots( + snapshot: FeeSnapshot, + nowSeconds: bigint, + ): { currentSlots: number[]; predictionSlots: number[] } { + const drift = BigInt(this.config.clockDriftAllowanceSeconds); + const tLow = nowSeconds - drift; + const tHigh = nowSeconds + drift; + const currentSlots = this.enumerate( + this.wantedCurrent(snapshot.pendingCheckpointSlot, tLow), + this.wantedCurrent(snapshot.pendingCheckpointSlot, tHigh), + ); + const predictionSlots = this.enumerate( + this.wantedPrediction(snapshot.pinnedSlot, tLow), + this.wantedPrediction(snapshot.pinnedSlot, tHigh), + ); + return { currentSlots, predictionSlots }; + } + + private wantedCurrent(pendingCheckpointSlot: SlotNumber, t: bigint): number { + return Math.max(pendingCheckpointSlot + 1, getSlotAtNextL1Block(t, this.constants)); + } + + private wantedPrediction(pinnedSlot: SlotNumber, t: bigint): number { + return Math.max(pinnedSlot, getSlotAtNextL1Block(t, this.constants)); + } + + private enumerate(low: number, high: number): number[] { + const result: number[] = []; + for (let s = Math.min(low, high); s <= Math.max(low, high); s++) { + result.push(s); + if (result.length > this.config.maxClockCandidates) { + throw new FeeSnapshotConfigError( + `Drift window enumerated more than maxClockCandidates ${this.config.maxClockCandidates} slots`, + ); + } + } + return result; + } + + /** Three independent staleness checks, each failing closed with its own typed error. */ + private assertFresh(snapshot: FeeSnapshot, nowMs: number, nowSeconds: bigint): void { + const { maxRefreshAgeMs, maxL1HeadAgeSeconds, futureHeadAllowanceSeconds } = this.config; + + if (maxRefreshAgeMs > 0) { + const ageMs = nowMs - snapshot.refreshedAtMs; + if (ageMs > maxRefreshAgeMs) { + throw new FeeSnapshotComputationStaleError(ageMs, maxRefreshAgeMs); + } + } + + if (maxL1HeadAgeSeconds > 0) { + const ageSeconds = Number(nowSeconds - snapshot.l1.blockTimestamp); + if (ageSeconds > maxL1HeadAgeSeconds) { + throw new FeeSnapshotL1HeadStaleError(ageSeconds, maxL1HeadAgeSeconds); + } + } + + if (futureHeadAllowanceSeconds > 0) { + const aheadSeconds = Number(snapshot.l1.blockTimestamp - nowSeconds); + if (aheadSeconds > futureHeadAllowanceSeconds) { + throw new FeeSnapshotFutureHeadError(aheadSeconds, futureHeadAllowanceSeconds); + } + } + } + + private async awaitFirstSnapshot(deadline: number): Promise { + const identity = this.identityProvider.getL1SyncSnapshot(); + if (identity) { + // Kick a refresh so the first snapshot does not wait for the next poll tick. + void this.triggerRefresh('rpc-first').catch(() => undefined); + } + const remaining = deadline - this.dateProvider.now(); + try { + return await this.race(this.firstSnapshot.promise, remaining); + } catch (err) { + if (this.stopped || err instanceof FeeSnapshotStoppedError) { + throw new FeeSnapshotStoppedError(); + } + if (err instanceof TimeoutError) { + throw new FeeSnapshotUnavailableError(); + } + throw err; + } + } + + private async readTriggeredRefresh(deadline: number, cause: RefreshCause): Promise { + this.stats.readTriggeredRefreshes++; + const remaining = deadline - this.dateProvider.now(); + if (remaining <= 0) { + throw new FeeSnapshotCoverageError(`Timed out waiting for fee snapshot refresh (${cause})`); + } + try { + await this.race(this.triggerRefresh(cause), remaining); + } catch (err) { + if (this.stopped || err instanceof FeeSnapshotStoppedError) { + throw new FeeSnapshotStoppedError(); + } + if (err instanceof TimeoutError) { + throw new FeeSnapshotCoverageError(`Timed out waiting for fee snapshot refresh (${cause})`); + } + throw err; + } + } + + // --------------------------------------------------------------------------------------------------------- + // Background poll loop and single-flight refresh + // --------------------------------------------------------------------------------------------------------- + + private async tick(): Promise { + if (this.stopped) { + return; + } + const now = this.dateProvider.now(); + if (now < this.nextRetryAtMs) { + return; + } + const identity = this.identityProvider.getL1SyncSnapshot(); + if (!identity) { + return; + } + const snapshot = this.snapshot; + let cause: RefreshCause | undefined; + if (snapshot === undefined || !identity.blockHash.equals(snapshot.l1.blockHash)) { + cause = 'poll-identity'; + } else if (this.coverageDrifting(snapshot)) { + cause = 'poll-coverage'; + } + if (cause) { + await this.triggerRefresh(cause).catch(() => undefined); + } + } + + /** Detects that the wanted slots are approaching or beyond the covered window, so the window must extend. */ + private coverageDrifting(snapshot: FeeSnapshot): boolean { + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); + const { currentSlots, predictionSlots } = this.computeWantedSlots(snapshot, nowSeconds); + const wanted = [...currentSlots, ...predictionSlots]; + if (wanted.some(s => !snapshot.candidates.has(s))) { + return true; + } + const maxWanted = Math.max(...wanted); + const minWanted = Math.min(...wanted); + const headroomConsumed = maxWanted >= snapshot.topSlot - Math.max(0, this.config.coverageHeadroomSlots - 1); + return headroomConsumed || minWanted < snapshot.baseSlot; + } + + private refreshKey(identity: L1SyncSnapshot): string { + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); + const { base, top } = this.computeProvisionalWindow(identity, nowSeconds); + return `${identity.blockHash.toString()}:${base}:${top}`; + } + + private triggerRefresh(cause: RefreshCause): Promise { + if (this.stopped) { + return Promise.reject(new FeeSnapshotStoppedError()); + } + const identity = this.identityProvider.getL1SyncSnapshot(); + if (!identity) { + return Promise.reject(new FeeSnapshotUnavailableError('No L1 identity available yet')); + } + const key = this.refreshKey(identity); + if (this.inFlight && this.inFlight.key === key) { + return this.inFlight.promise; + } + // Keep refreshes serial: chain after any in-flight refresh (ignoring its outcome), then run this one. + const prior = this.inFlight?.promise.catch(() => undefined) ?? Promise.resolve(); + const promise = prior.then(() => this.runRefresh(identity, cause, 0)); + const entry = { key, promise }; + this.inFlight = entry; + return promise.then( + snapshot => { + if (this.inFlight === entry) { + this.inFlight = undefined; + } + return snapshot; + }, + err => { + if (this.inFlight === entry) { + this.inFlight = undefined; + } + throw err; + }, + ); + } + + private async runRefresh(identity: L1SyncSnapshot, cause: RefreshCause, attempt: number): Promise { + if (attempt >= MAX_REFRESH_ATTEMPTS) { + throw new FeeSnapshotRefreshError('Fee snapshot refresh exceeded max attempts (identity/tips instability)'); + } + try { + const snapshot = await this.buildSnapshot(identity, cause, attempt); + this.consecutiveFailures = 0; + this.nextRetryAtMs = 0; + return snapshot; + } catch (err) { + if (err instanceof RestartRefresh) { + return this.runRefresh(err.identity, cause, attempt + 1); + } + this.stats.refreshFailures++; + this.consecutiveFailures++; + this.nextRetryAtMs = this.dateProvider.now() + this.backoffMs(); + this.log.warn('Fee snapshot refresh failed; keeping last-good snapshot', { + cause, + attempt, + consecutiveFailures: this.consecutiveFailures, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + } + + private backoffMs(): number { + const exp = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(this.consecutiveFailures, 10)); + return exp / 2 + Math.floor(Math.random() * (exp / 2)); + } + + private async buildSnapshot(identity: L1SyncSnapshot, cause: RefreshCause, attempt: number): Promise { + const blockNumber = identity.blockNumber; + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); + const { base, top, pinnedSlot } = this.computeProvisionalWindow(identity, nowSeconds); + + const windowSlots = rangeInclusive(base, top); + const oracleSlots = rangeInclusive(base, top + FEE_ORACLE_LAG - 1); + + // Wave 1: globals + per-slot fee reads over the provisional window. + const wave1Reads: RollupFeeRead[] = [ + { kind: 'tips' }, + { kind: 'manaTarget' }, + { kind: 'manaLimit' }, + { kind: 'provingCostPerManaEth' }, + ...windowSlots.map((s): RollupFeeRead => ({ kind: 'manaMinFeeAt', timestamp: this.tsForSlot(s) })), + ...windowSlots.map((s): RollupFeeRead => ({ kind: 'canPruneAtTime', timestamp: this.tsForSlot(s) })), + ...oracleSlots.map((s): RollupFeeRead => ({ kind: 'l1FeesAt', timestamp: this.tsForSlot(s) })), + ]; + const wave1 = await this.rollup.readFeeInputs(wave1Reads, { blockNumber }); + const reads = new FeeReadResults(wave1); + const tips1 = reads.tips(); + const manaTarget = reads.manaTarget(); + const manaLimit = reads.manaLimit(); + const provingCostPerManaEth = reads.provingCostPerManaEth(); + const manaMinFeeByTs = reads.manaMinFeeByTs(); + const canPruneByTs = reads.canPruneByTs(); + const l1FeesByTs = reads.l1FeesByTs(); + + // Wave 2: pending + proven checkpoints and a re-read of tips for wave consistency. Checkpoint 0 is the + // valid genesis checkpoint, so `pending`/`proven` of 0 are read normally; the proven read is skipped only + // when it coincides with the pending one. + const includeProven = tips1.proven !== tips1.pending; + const wave2Reads: RollupFeeRead[] = [ + { kind: 'checkpoint', checkpointNumber: tips1.pending }, + ...(includeProven ? [{ kind: 'checkpoint' as const, checkpointNumber: tips1.proven }] : []), + { kind: 'tips' }, + ]; + const wave2 = new FeeReadResults(await this.rollup.readFeeInputs(wave2Reads, { blockNumber })); + const tips2 = wave2.tips(); + if (tips2.pending !== tips1.pending || tips2.proven !== tips1.proven) { + // The two waves saw different states (fallback-transport fork mixing); discard and restart. + this.stats.tipsMismatchDiscards++; + this.log.debug('Fee snapshot wave-2 tips differ from wave-1; discarding refresh', { tips1, tips2 }); + throw this.restartWith(identity); + } + + const pendingCheckpoint = wave2.checkpoint(tips1.pending); + const provenCheckpoint = includeProven ? wave2.checkpoint(tips1.proven) : pendingCheckpoint; + const pendingCheckpointSlot = pendingCheckpoint.slotNumber; + + const checkpoints = { + pendingCheckpoint: { slotNumber: pendingCheckpoint.slotNumber, feeHeader: pendingCheckpoint.feeHeader }, + provenCheckpoint: { slotNumber: provenCheckpoint.slotNumber, feeHeader: provenCheckpoint.feeHeader }, + }; + + // Step 4: exact coverage validation. With pendingCheckpointSlot now known, compute the exact wanted slots + // and top up any candidate not materialized by the provisional window. + const wantedSlots = this.exactWantedSlots(nowSeconds, pinnedSlot, pendingCheckpointSlot); + const missingCandidateSlots = wantedSlots.filter(s => !manaMinFeeByTs.has(this.tsForSlot(s))); + if (missingCandidateSlots.length > 0) { + this.stats.topUpWaves++; + const topUpReads: RollupFeeRead[] = missingCandidateSlots.flatMap((s): RollupFeeRead[] => [ + { kind: 'manaMinFeeAt', timestamp: this.tsForSlot(s) }, + { kind: 'canPruneAtTime', timestamp: this.tsForSlot(s) }, + ]); + const topUp = new FeeReadResults(await this.rollup.readFeeInputs(topUpReads, { blockNumber })); + topUp.mergeInto(manaMinFeeByTs, canPruneByTs, l1FeesByTs); + } + + const candidateSlots = unique([...windowSlots, ...wantedSlots]); + + // Fetch any oracle L1 fees still missing for the resolved prediction windows. + const neededOracleSlots = new Set(); + for (const s of candidateSlots) { + const canPrune = canPruneByTs.get(this.tsForSlot(s)); + if (canPrune === undefined) { + throw new FeeSnapshotRefreshError(`Missing canPrune for candidate slot ${s}`); + } + const effectiveParentSlot = canPrune + ? checkpoints.provenCheckpoint.slotNumber + : checkpoints.pendingCheckpoint.slotNumber; + const nextSlot = Math.max(effectiveParentSlot + 1, s); + for (let i = 0; i < FEE_ORACLE_LAG; i++) { + neededOracleSlots.add(nextSlot + i); + } + } + const missingOracleSlots = [...neededOracleSlots].filter(s => !l1FeesByTs.has(this.tsForSlot(s))); + if (missingOracleSlots.length > 0) { + this.stats.topUpWaves++; + const oracleReads: RollupFeeRead[] = missingOracleSlots.map( + (s): RollupFeeRead => ({ kind: 'l1FeesAt', timestamp: this.tsForSlot(s) }), + ); + new FeeReadResults(await this.rollup.readFeeInputs(oracleReads, { blockNumber })).mergeInto( + manaMinFeeByTs, + canPruneByTs, + l1FeesByTs, + ); + } + + // Build complete candidate entries. + const candidates = new Map(); + for (const s of candidateSlots) { + candidates.set( + s, + this.buildCandidate(SlotNumber(s), { + manaMinFeeByTs, + canPruneByTs, + l1FeesByTs, + checkpoints, + manaTarget, + manaLimit, + provingCostPerManaEth, + }), + ); + } + + // Archiver identity re-check before publish: if it changed during the reads, restart against the new one. + const after = this.identityProvider.getL1SyncSnapshot(); + if (!after || !after.blockHash.equals(identity.blockHash)) { + this.log.debug('Archiver identity changed during fee snapshot refresh; restarting', { + built: identity.blockNumber, + current: after?.blockNumber, + }); + if (!after) { + throw new FeeSnapshotUnavailableError('Archiver identity disappeared during refresh'); + } + throw this.restartWith(after); + } + + const snapshot: FeeSnapshot = { + l1: identity, + pendingCheckpointSlot, + pinnedSlot, + candidates, + baseSlot: SlotNumber(base), + topSlot: SlotNumber(top), + refreshedAtMs: this.dateProvider.now(), + }; + this.publish(snapshot, cause, attempt); + return snapshot; + } + + private restartWith(identity: L1SyncSnapshot): RestartRefresh { + return new RestartRefresh(identity); + } + + private buildCandidate( + slot: SlotNumber, + ctx: { + manaMinFeeByTs: Map; + canPruneByTs: Map; + l1FeesByTs: Map; + checkpoints: { + pendingCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; + provenCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; + }; + manaTarget: bigint; + manaLimit: bigint; + provingCostPerManaEth: bigint; + }, + ): FeeQuoteCandidate { + const timestamp = this.tsForSlot(slot); + const manaMinFee = ctx.manaMinFeeByTs.get(timestamp); + const canPrune = ctx.canPruneByTs.get(timestamp); + if (manaMinFee === undefined || canPrune === undefined) { + throw new FeeSnapshotRefreshError(`Missing fee reads for candidate slot ${slot}`); + } + + const l1FeesForSlot = (s: SlotNumber) => { + const fees = ctx.l1FeesByTs.get(this.tsForSlot(s)); + if (!fees) { + throw new FeeSnapshotRefreshError(`Missing L1 fees for slot ${s}`); + } + return fees; + }; + + const predictions = {} as Record; + for (const estimate of [ManaUsageEstimate.None, ManaUsageEstimate.Target, ManaUsageEstimate.Limit]) { + const state = buildFeeOracleState({ + anchorSlot: slot, + canPrune, + pendingCheckpoint: ctx.checkpoints.pendingCheckpoint, + provenCheckpoint: ctx.checkpoints.provenCheckpoint, + manaTarget: ctx.manaTarget, + manaLimit: ctx.manaLimit, + provingCostPerManaEth: ctx.provingCostPerManaEth, + epochDuration: BigInt(this.config.epochDuration), + l1FeesForSlot, + }); + predictions[estimate] = computePredictions(state, estimate); + } + + return { slot, timestamp, currentMinFee: new GasFees(0, manaMinFee), predictions }; + } + + private publish(snapshot: FeeSnapshot, cause: RefreshCause, attempt: number): void { + const current = this.snapshot; + if (current && current.l1.blockNumber > snapshot.l1.blockNumber) { + // An older refresh finished after a newer one; never overwrite the newer snapshot. + this.log.debug('Discarding stale fee snapshot refresh result', { + built: snapshot.l1.blockNumber, + current: current.l1.blockNumber, + }); + return; + } + this.snapshot = snapshot; + this.stats.refreshes++; + if (!this.firstResolved) { + this.firstResolved = true; + this.firstSnapshot.resolve(snapshot); + } + this.log.debug('Published fee snapshot', { + cause, + attempt, + blockNumber: snapshot.l1.blockNumber, + baseSlot: snapshot.baseSlot, + topSlot: snapshot.topSlot, + pendingCheckpointSlot: snapshot.pendingCheckpointSlot, + pinnedSlot: snapshot.pinnedSlot, + candidateCount: snapshot.candidates.size, + }); + } + + private computeProvisionalWindow( + identity: L1SyncSnapshot, + nowSeconds: bigint, + ): { base: number; top: number; pinnedSlot: SlotNumber } { + const drift = BigInt(this.config.clockDriftAllowanceSeconds); + const pinnedSlot = getSlotAtTimestamp(identity.blockTimestamp, this.constants); + const lowSlot = getSlotAtNextL1Block(nowSeconds - drift, this.constants); + const highSlot = getSlotAtNextL1Block(nowSeconds + drift, this.constants); + let base = Math.min(lowSlot, pinnedSlot); + const top = Math.max(highSlot, pinnedSlot + 1) + this.config.coverageHeadroomSlots; + if (top - base + 1 > this.config.maxCandidateWindowSlots) { + // Host clock diverges wildly from the pinned L1 timestamp: materialize a capped window anchored at the + // wall-clock end and rely on the exact-coverage top-up to add the pinned-slot floor candidates. + base = Math.max(base, top - this.config.maxCandidateWindowSlots + 1); + } + return { base, top, pinnedSlot }; + } + + private exactWantedSlots(nowSeconds: bigint, pinnedSlot: SlotNumber, pendingCheckpointSlot: SlotNumber): number[] { + const drift = BigInt(this.config.clockDriftAllowanceSeconds); + const slots = new Set(); + for (const t of [nowSeconds - drift, nowSeconds + drift]) { + slots.add(this.wantedCurrent(pendingCheckpointSlot, t)); + slots.add(this.wantedPrediction(pinnedSlot, t)); + } + return [...slots]; + } + + private tsForSlot(slot: number): bigint { + return getTimestampForSlot(SlotNumber(slot), this.constants); + } + + private async race(promise: Promise, ms: number): Promise { + if (ms <= 0) { + throw new TimeoutError(); + } + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new TimeoutError()), ms); + }); + try { + return await Promise.race([promise, timeout, this.stopSignal.promise]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } +} + +/** Sentinel thrown internally to restart a refresh against a newer identity without failing the attempt. */ +class RestartRefresh extends Error { + constructor(public readonly identity: L1SyncSnapshot) { + super('restart refresh'); + this.name = 'RestartRefresh'; + } +} + +/** Internal error thrown for unexpected refresh-build failures. */ +class FeeSnapshotRefreshError extends FeeSnapshotError { + constructor(message: string) { + super(message); + this.name = 'FeeSnapshotRefreshError'; + } +} + +/** Parses a `RollupFeeReadResult[]` into typed globals and timestamp-keyed maps. */ +class FeeReadResults { + private readonly minFee = new Map(); + private readonly canPrune = new Map(); + private readonly l1Fees = new Map(); + private readonly checkpoints = new Map(); + private tipsValue: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; + private manaTargetValue: bigint | undefined; + private manaLimitValue: bigint | undefined; + private provingCostValue: bigint | undefined; + + constructor(results: RollupFeeReadResult[]) { + for (const result of results) { + switch (result.kind) { + case 'tips': + this.tipsValue = result.value; + break; + case 'manaTarget': + this.manaTargetValue = result.value; + break; + case 'manaLimit': + this.manaLimitValue = result.value; + break; + case 'provingCostPerManaEth': + this.provingCostValue = result.value; + break; + case 'manaMinFeeAt': + this.minFee.set(result.timestamp, result.value); + break; + case 'canPruneAtTime': + this.canPrune.set(result.timestamp, result.value); + break; + case 'l1FeesAt': + this.l1Fees.set(result.timestamp, result.value); + break; + case 'checkpoint': + this.checkpoints.set(Number(result.checkpointNumber), { + slotNumber: result.value.slotNumber, + feeHeader: result.value.feeHeader, + }); + break; + } + } + } + + tips(): { pending: CheckpointNumber; proven: CheckpointNumber } { + if (!this.tipsValue) { + throw new FeeSnapshotRefreshError('Missing tips in fee reads'); + } + return this.tipsValue; + } + + manaTarget(): bigint { + return required(this.manaTargetValue, 'manaTarget'); + } + + manaLimit(): bigint { + return required(this.manaLimitValue, 'manaLimit'); + } + + provingCostPerManaEth(): bigint { + return required(this.provingCostValue, 'provingCostPerManaEth'); + } + + checkpoint(checkpointNumber: CheckpointNumber): { slotNumber: SlotNumber; feeHeader: FeeHeader } { + const value = this.checkpoints.get(Number(checkpointNumber)); + if (!value) { + throw new FeeSnapshotRefreshError(`Missing checkpoint ${checkpointNumber} in fee reads`); + } + return value; + } + + manaMinFeeByTs(): Map { + return this.minFee; + } + + canPruneByTs(): Map { + return this.canPrune; + } + + l1FeesByTs(): Map { + return this.l1Fees; + } + + mergeInto( + minFee: Map, + canPrune: Map, + l1Fees: Map, + ): void { + for (const [k, v] of this.minFee) { + minFee.set(k, v); + } + for (const [k, v] of this.canPrune) { + canPrune.set(k, v); + } + for (const [k, v] of this.l1Fees) { + l1Fees.set(k, v); + } + } +} + +function required(value: T | undefined, name: string): T { + if (value === undefined) { + throw new FeeSnapshotRefreshError(`Missing ${name} in fee reads`); + } + return value; +} + +function rangeInclusive(low: number, high: number): number[] { + const result: number[] = []; + for (let s = low; s <= high; s++) { + result.push(s); + } + return result; +} + +function unique(values: number[]): number[] { + return [...new Set(values)]; +} + +/** Element-wise max over a set of GasFees (per dimension). */ +function maxGasFees(fees: GasFees[]): GasFees { + return fees.reduce( + (acc, f) => + new GasFees( + acc.feePerDaGas > f.feePerDaGas ? acc.feePerDaGas : f.feePerDaGas, + acc.feePerL2Gas > f.feePerL2Gas ? acc.feePerL2Gas : f.feePerL2Gas, + ), + new GasFees(0, 0), + ); +} + +/** Element-wise max merge of several equal-length GasFees arrays. */ +function maxGasFeesElementWise(arrays: GasFees[][]): GasFees[] { + const length = arrays[0]?.length ?? 0; + const result: GasFees[] = []; + for (let i = 0; i < length; i++) { + result.push(maxGasFees(arrays.map(a => a[i]))); + } + return result; +} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts index 3ec926f260ac..7fc24980359f 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts @@ -61,7 +61,7 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { }); const stateOverride = await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan); - const gasFees = new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true, stateOverride)); + const gasFees = new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true, { stateOverride })); return { chainId, version, slotNumber, timestamp, coinbase, feeRecipient, gasFees }; } diff --git a/yarn-project/sequencer-client/src/global_variable_builder/index.ts b/yarn-project/sequencer-client/src/global_variable_builder/index.ts index 084db54844e1..2467e0487c89 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/index.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/index.ts @@ -1,3 +1,23 @@ export { FeeProviderImpl } from './fee_provider.js'; export { GlobalVariableBuilder, type GlobalVariableBuilderConfig } from './global_builder.js'; -export { FeePredictor } from './fee_predictor.js'; +export { FeeSnapshotService, type RollupFeeReader, type FeeSnapshotStats } from './fee_snapshot_service.js'; +export { + type FeeSnapshot, + type FeeQuoteCandidate, + type FeeSnapshotServiceConfig, + getDefaultFeeSnapshotServiceConfig, + FeeSnapshotError, + FeeSnapshotConfigError, + FeeSnapshotUnavailableError, + FeeSnapshotStoppedError, + FeeSnapshotCoverageError, + FeeSnapshotComputationStaleError, + FeeSnapshotL1HeadStaleError, + FeeSnapshotFutureHeadError, +} from './fee_snapshot.js'; +export { computePredictions, buildFeeOracleState, type FeeOracleState } from './fee_prediction.js'; +export { + computeLegacyCurrentMinFees, + computeLegacyPredictedMinFees, + fetchLegacyFeeOracleState, +} from './legacy_fee_oracle.js'; diff --git a/yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts b/yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts new file mode 100644 index 000000000000..7ce9d0606c89 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts @@ -0,0 +1,91 @@ +import type { RollupContract } from '@aztec/ethereum/contracts'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { times } from '@aztec/foundation/collection'; +import { + type L1RollupConstants, + getNextL1SlotTimestamp, + getSlotAtNextL1Block, + getTimestampForSlot, +} from '@aztec/stdlib/epoch-helpers'; +import { FEE_ORACLE_LAG, GasFees, type ManaUsageEstimate, computeExcessMana } from '@aztec/stdlib/gas'; + +import { type FeeOracleState, computePredictions } from './fee_prediction.js'; + +type LegacyOracleConstants = Pick; + +/** + * Faithful refactor of the legacy `FeeProviderImpl.computeCurrentMinFees`, taking `(blockNumber, now)` explicitly + * and pinned to that block. Retained only as the equivalence oracle for the snapshot service in tests; the + * production current-fee path is served from the snapshot. + */ +export async function computeLegacyCurrentMinFees( + rollup: RollupContract, + blockNumber: bigint, + nowSeconds: number, + constants: LegacyOracleConstants, +): Promise { + const pendingCheckpointNumber = await rollup.getCheckpointNumber({ blockNumber }); + const lastCheckpoint = await rollup.getCheckpoint(pendingCheckpointNumber, { blockNumber }); + const earliestTimestamp = getTimestampForSlot(SlotNumber.add(lastCheckpoint.slotNumber, 1), constants); + const nextEthTimestamp = getNextL1SlotTimestamp(nowSeconds, constants); + const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; + return new GasFees(0, await rollup.getManaMinFeeAt(timestamp, true, { blockNumber })); +} + +/** + * Faithful refactor of the legacy `FeePredictor.fetchState`, taking `(blockNumber, now)` explicitly and pinned to + * that block. Retained only as the equivalence oracle for the snapshot service in tests. + */ +export async function fetchLegacyFeeOracleState( + rollup: RollupContract, + blockNumber: bigint, + nowSeconds: number, + constants: LegacyOracleConstants, +): Promise { + const opts = { blockNumber }; + const [manaTarget, manaLimit, provingCostPerManaEth, epochDuration] = await Promise.all([ + rollup.readManaTarget(opts), + rollup.readManaLimit(opts), + rollup.readProvingCostPerManaInEth(opts), + rollup.getEpochDuration(), + ]); + + const currentSlot = await rollup.getSlotNumber(opts); + const slotAtNextL1Block = getSlotAtNextL1Block(BigInt(nowSeconds), constants); + const preliminaryNextSlot = SlotNumber(Math.max(currentSlot, slotAtNextL1Block)); + const nextSlotTimestamp = getTimestampForSlot(preliminaryNextSlot, constants); + + const lastCheckpoint = await rollup.getEffectivePendingCheckpoint(nextSlotTimestamp, opts); + const lastSlot = lastCheckpoint.slotNumber; + const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), preliminaryNextSlot)); + const feeHeader = lastCheckpoint.feeHeader; + + const timestamps = times(FEE_ORACLE_LAG, i => getTimestampForSlot(SlotNumber.add(nextSlot, i), constants)); + const l1FeesBySlot = await Promise.all(timestamps.map(ts => rollup.getL1FeesAt(ts, opts))); + + return { + lastSlot, + excessMana: computeExcessMana(feeHeader.excessMana, feeHeader.manaUsed, manaTarget), + ethPerFeeAsset: feeHeader.ethPerFeeAsset, + manaTarget, + manaLimit, + provingCostPerManaEth, + epochDuration: BigInt(epochDuration), + l1FeesBySlot, + }; +} + +/** Legacy `getPredictedMinFees`: current fee followed by the prediction window, on identical explicit inputs. */ +export async function computeLegacyPredictedMinFees( + rollup: RollupContract, + blockNumber: bigint, + nowSeconds: number, + constants: LegacyOracleConstants, + manaUsage: ManaUsageEstimate, +): Promise { + const [current, state] = await Promise.all([ + computeLegacyCurrentMinFees(rollup, blockNumber, nowSeconds, constants), + fetchLegacyFeeOracleState(rollup, blockNumber, nowSeconds, constants), + ]); + return [current, ...computePredictions(state, manaUsage)]; +} From 05fc9c8813724b2425c9c7a8ff746d6d8fb19eab Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 15:14:04 -0300 Subject: [PATCH 2/8] feat(bench): inject L1 latency proxy and benchmark warm fee RPCs 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. --- .../src/bench/node_rpc_perf.test.ts | 135 +++++++++++++++++- .../src/fixtures/latency_proxy.test.ts | 85 +++++++++++ .../end-to-end/src/fixtures/latency_proxy.ts | 113 +++++++++++++++ 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts create mode 100644 yarn-project/end-to-end/src/fixtures/latency_proxy.ts diff --git a/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts b/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts index 098e033ad634..6c936697d0c0 100644 --- a/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts +++ b/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts @@ -6,15 +6,17 @@ * 2. Builds a few blocks by sending transactions * 3. Benchmarks all node RPC API methods */ +import type { AztecNodeService } from '@aztec/aztec-node'; import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import type { AztecNode } from '@aztec/aztec.js/node'; -import type { RollupCheatCodes } from '@aztec/ethereum/test'; +import { type Anvil, type RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { Timer } from '@aztec/foundation/timer'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; import { BlockHash } from '@aztec/stdlib/block'; +import { ManaUsageEstimate } from '@aztec/stdlib/gas'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; import { SiloedTag, Tag } from '@aztec/stdlib/logs'; import { MerkleTreeId } from '@aztec/stdlib/trees'; @@ -26,10 +28,23 @@ import 'jest-extended'; import * as path from 'path'; import { PIPELINING_SETUP_OPTS } from '../fixtures/fixtures.js'; +import { type LatencyProxy, startLatencyProxy } from '../fixtures/latency_proxy.js'; import { setup } from '../fixtures/utils.js'; import type { TestWallet } from '../test-wallet/test_wallet.js'; import { proveInteraction } from '../test-wallet/utils.js'; +/** Injected per-L1-request latency (ms) used to make L1 round trips dominate any warm-path measurement. */ +const INJECTED_L1_LATENCY_MS = 100; + +function percentile(values: number[], p: number): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[index]; +} + /** Number of iterations for fast RPC calls */ const BENCHMARK_ITERATIONS_FAST = 20; @@ -103,10 +118,13 @@ describe('e2e_node_rpc_perf', () => { let logger: Logger; let aztecNode: AztecNode & AztecNodeDebug; + let aztecNodeService: AztecNodeService; let wallet: TestWallet; let ownerAddress: AztecAddress; let rollupCheatCodes: RollupCheatCodes; let teardown: () => Promise; + let anvil: Anvil; + let latencyProxy: LatencyProxy; const benchmarkResults: BenchmarkResult[] = []; // Data collected during block building for use in benchmarks @@ -138,17 +156,27 @@ describe('e2e_node_rpc_perf', () => { } await teardown(); + await latencyProxy?.stop(); + await anvil?.stop().catch(() => {}); }); beforeAll(async () => { + // Start Anvil directly and put a latency proxy in front of it, so the node talks to L1 through the proxy + // while we retain the direct Anvil URL for cheat codes. The proxy runs at zero delay during setup. + let directRpcUrl: string; + ({ anvil, rpcUrl: directRpcUrl } = await startAnvil()); + latencyProxy = await startLatencyProxy(directRpcUrl, 0); + ({ teardown, logger, aztecNode, + aztecNodeService, wallet, accounts: [ownerAddress], cheatCodes: { rollup: rollupCheatCodes }, } = await setup(1, { + l1RpcUrls: [latencyProxy.url], archiverPollingIntervalMS: 200, sequencerPollingIntervalMS: 200, worldStateBlockCheckIntervalMS: 200, @@ -563,6 +591,111 @@ describe('e2e_node_rpc_perf', () => { }); }); + describe('fee APIs with injected L1 latency', () => { + // Warm the snapshot and establish a same-run local-RPC baseline before injecting L1 latency, so the fee + // warm-path p95 is compared against a call that never touches L1 under identical process conditions. + async function measureWarm(fn: () => Promise, iterations: number): Promise { + const timings: number[] = []; + for (let i = 0; i < iterations; i++) { + const timer = new Timer(); + await fn(); + timings.push(timer.ms()); + } + return timings; + } + + it('serves warm getPredictedMinFees with zero fee-path L1 requests and near-local latency (sequential)', async () => { + // Warm the snapshot with zero delay, then inject latency so any L1 round trip would dominate. + await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); + latencyProxy.setDelayMs(INJECTED_L1_LATENCY_MS); + try { + const baseline = await measureWarm(() => aztecNode.getBlockNumber(), 20); + const statsBefore = aztecNodeService.getFeeSnapshotStats(); + const feeTimings = await measureWarm(() => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit), 20); + const statsAfter = aztecNodeService.getFeeSnapshotStats(); + + const baselineP95 = percentile(baseline, 95); + const feeP95 = percentile(feeTimings, 95); + addResult('getPredictedMinFees_warm_seq_p95', calculateStats(feeTimings)); + logger.info('Warm getPredictedMinFees (sequential) with 100ms L1 latency', { + feeP95, + baselineP95, + readTriggeredRefreshesDelta: + (statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0), + }); + + // Gated: warm reads must trigger no refresh (zero fee-path L1 requests) and stay near the local baseline. + if (statsBefore && statsAfter) { + expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes); + } + expect(feeP95).toBeLessThan(baselineP95 + INJECTED_L1_LATENCY_MS); + } finally { + latencyProxy.setDelayMs(0); + } + }); + + it('serves warm getPredictedMinFees with zero fee-path L1 requests and near-local latency (concurrent)', async () => { + await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); + latencyProxy.setDelayMs(INJECTED_L1_LATENCY_MS); + try { + const statsBefore = aztecNodeService.getFeeSnapshotStats(); + const timer = new Timer(); + await Promise.all(Array.from({ length: 20 }, () => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit))); + const totalMs = timer.ms(); + const statsAfter = aztecNodeService.getFeeSnapshotStats(); + + logger.info('Warm getPredictedMinFees (20 concurrent) with 100ms L1 latency', { + totalMs, + readTriggeredRefreshesDelta: + (statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0), + }); + addResult('getPredictedMinFees_warm_concurrent_total', { + avg: totalMs, + min: totalMs, + max: totalMs, + total: totalMs, + count: 20, + }); + + if (statsBefore && statsAfter) { + expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes); + } + // 20 concurrent warm reads should complete well within a single injected L1 round trip. + expect(totalMs).toBeLessThan(INJECTED_L1_LATENCY_MS); + } finally { + latencyProxy.setDelayMs(0); + } + }); + + it('reports refresh cost and the first call after a new L1 block (non-gating)', async () => { + latencyProxy.setDelayMs(INJECTED_L1_LATENCY_MS); + try { + latencyProxy.resetCounts(); + const before = aztecNodeService.getFeeSnapshotStats(); + // Advance the chain to force an archiver identity change and observe the boundary refresh cost. + await rollupCheatCodes.advanceSlots(1); + const timer = new Timer(); + await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); + const firstCallMs = timer.ms(); + const after = aztecNodeService.getFeeSnapshotStats(); + logger.info('First getPredictedMinFees after advancing the chain (reported)', { + firstCallMs, + refreshesDelta: (after?.refreshes ?? 0) - (before?.refreshes ?? 0), + proxyEthCalls: latencyProxy.getRequestCount('eth_call'), + }); + addResult('getPredictedMinFees_boundary_first_call', { + avg: firstCallMs, + min: firstCallMs, + max: firstCallMs, + total: firstCallMs, + count: 1, + }); + } finally { + latencyProxy.setDelayMs(0); + } + }); + }); + describe('log APIs', () => { it('benchmarks getPrivateLogsByTags', async () => { const tags = [SiloedTag.random()]; diff --git a/yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts b/yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts new file mode 100644 index 000000000000..3f727a9cba08 --- /dev/null +++ b/yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts @@ -0,0 +1,85 @@ +import { type Server, createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { type LatencyProxy, startLatencyProxy } from './latency_proxy.js'; + +describe('latency proxy', () => { + let upstream: Server; + let upstreamUrl: string; + let upstreamRequests: string[]; + let proxy: LatencyProxy; + + beforeEach(async () => { + upstreamRequests = []; + upstream = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', c => chunks.push(c as Buffer)); + req.on('end', () => { + upstreamRequests.push(Buffer.concat(chunks).toString('utf-8')); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x2a' })); + }); + }); + await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); + upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + await proxy?.stop(); + await new Promise(resolve => upstream.close(() => resolve())); + }); + + async function rpc(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json(); + } + + it('forwards requests to the upstream and returns its response', async () => { + proxy = await startLatencyProxy(upstreamUrl); + const result = await rpc(proxy.url, { jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }); + expect(result).toEqual({ jsonrpc: '2.0', id: 1, result: '0x2a' }); + expect(upstreamRequests).toHaveLength(1); + }); + + it('applies the configured per-request delay before forwarding', async () => { + proxy = await startLatencyProxy(upstreamUrl, 0); + proxy.setDelayMs(150); + const start = Date.now(); + await rpc(proxy.url, { jsonrpc: '2.0', id: 1, method: 'eth_call', params: [] }); + expect(Date.now() - start).toBeGreaterThanOrEqual(140); + }); + + it('counts requests per JSON-RPC method, including batches, and resets counters', async () => { + proxy = await startLatencyProxy(upstreamUrl); + await rpc(proxy.url, { jsonrpc: '2.0', id: 1, method: 'eth_call', params: [] }); + await rpc(proxy.url, { jsonrpc: '2.0', id: 2, method: 'eth_call', params: [] }); + await rpc(proxy.url, [ + { jsonrpc: '2.0', id: 3, method: 'eth_getBlockByNumber', params: [] }, + { jsonrpc: '2.0', id: 4, method: 'eth_call', params: [] }, + ]); + + expect(proxy.getRequestCount('eth_call')).toBe(3); + expect(proxy.getRequestCount('eth_getBlockByNumber')).toBe(1); + expect(proxy.getRequestCount()).toBe(4); + + proxy.resetCounts(); + expect(proxy.getRequestCount()).toBe(0); + expect(proxy.getRequestCount('eth_call')).toBe(0); + }); + + it('supports async-disposable teardown', async () => { + let url: string; + { + await using disposable = await startLatencyProxy(upstreamUrl); + url = disposable.url; + const result = await rpc(url, { jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] }); + expect(result).toEqual({ jsonrpc: '2.0', id: 1, result: '0x2a' }); + } + // After disposal the server is closed and no longer accepts connections. + await expect(fetch(url, { method: 'POST', body: '{}' })).rejects.toThrow(); + }); +}); diff --git a/yarn-project/end-to-end/src/fixtures/latency_proxy.ts b/yarn-project/end-to-end/src/fixtures/latency_proxy.ts new file mode 100644 index 000000000000..be7239874f20 --- /dev/null +++ b/yarn-project/end-to-end/src/fixtures/latency_proxy.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@aztec/aztec.js/log'; + +import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +const logger = createLogger('e2e:latency-proxy'); + +/** + * A small HTTP JSON-RPC reverse proxy that forwards requests to a target RPC, optionally after a configurable + * per-request delay, while counting requests per JSON-RPC method. Used by benchmarks to inject L1 latency and + * to observe how many L1 requests a code path issues. Not for production use. + */ +export interface LatencyProxy extends AsyncDisposable { + /** The URL callers should point their L1 client at. */ + readonly url: string; + /** Sets the artificial per-request delay applied before forwarding. */ + setDelayMs(ms: number): void; + /** Returns the request count for a specific JSON-RPC method, or the total across all methods when omitted. */ + getRequestCount(method?: string): number; + /** Resets all per-method counters to zero. */ + resetCounts(): void; + /** Stops the proxy server. */ + stop(): Promise; +} + +/** + * Starts a latency proxy in front of `targetUrl`. + * @param targetUrl - The upstream JSON-RPC endpoint to forward to. + * @param initialDelayMs - The initial artificial delay applied to each request (default 0). + */ +export async function startLatencyProxy(targetUrl: string, initialDelayMs = 0): Promise { + let delayMs = initialDelayMs; + const counts = new Map(); + + const record = (body: string) => { + try { + const parsed = JSON.parse(body); + const entries = Array.isArray(parsed) ? parsed : [parsed]; + for (const entry of entries) { + const method = typeof entry?.method === 'string' ? entry.method : 'unknown'; + counts.set(method, (counts.get(method) ?? 0) + 1); + } + } catch { + counts.set('unparseable', (counts.get('unparseable') ?? 0) + 1); + } + }; + + const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk as Buffer)); + req.on('end', () => { + void (async () => { + const body = Buffer.concat(chunks).toString('utf-8'); + record(body); + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + try { + const upstream = await fetch(targetUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }); + const text = await upstream.text(); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(text); + } catch (err) { + logger.error(`Latency proxy upstream request failed`, err); + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'proxy upstream error' } })); + } + })(); + }); + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address() as AddressInfo; + const url = `http://127.0.0.1:${address.port}`; + logger.info(`Latency proxy listening`, { url, targetUrl, initialDelayMs }); + + const stop = () => + new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close(err => (err ? reject(err) : resolve())); + }); + + return { + url, + setDelayMs(ms: number) { + delayMs = ms; + }, + getRequestCount(method?: string) { + if (method === undefined) { + return [...counts.values()].reduce((a, b) => a + b, 0); + } + return counts.get(method) ?? 0; + }, + resetCounts() { + counts.clear(); + }, + stop, + async [Symbol.asyncDispose]() { + await stop(); + }, + }; +} From dff0a371179539f516051267a358926b38f08ad8 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 15:14:13 -0300 Subject: [PATCH 3/8] docs(gas): document the fee snapshot model, anchor rules, and consistency 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. --- yarn-project/stdlib/src/gas/README.md | 77 +++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 1bce97106cdb..6b632e9725a7 100644 --- a/yarn-project/stdlib/src/gas/README.md +++ b/yarn-project/stdlib/src/gas/README.md @@ -117,9 +117,11 @@ every 5 slots. Because queued values only activate `LAG` slots later, the min fee for the next `LAG` slots is fully determined by current on-chain state. `fee_math.ts` ports the FeeLib -formulas so the sequencer can predict min fees over that window (`FeePredictor` in -`sequencer-client/src/global_variable_builder`), under a configurable mana-usage -assumption (`ManaUsageEstimate`: none / target / limit). +formulas so the sequencer can predict min fees over that window (the prediction math in +`fee_prediction.ts` in `sequencer-client/src/global_variable_builder`), under a configurable +mana-usage assumption (`ManaUsageEstimate`: none / target / limit). See +[Serving Fee Quotes](#serving-fee-quotes-the-fee-snapshot) for how those predictions are cached +and served without per-request L1 round trips. ### Worked Example @@ -152,6 +154,75 @@ Key observations: 4. **Slot 15**: A new oracle update can be queued (earliest `acceptableSlot`). If triggered, the new values would activate at slot 15 + LAG = 17. +## Serving Fee Quotes: the Fee Snapshot + +The `getCurrentMinFees` and `getPredictedMinFees` node RPCs (consumed by wallets and by the p2p mempool +admission/eviction policy) are served from an in-memory **fee snapshot** maintained by `FeeSnapshotService` +(`sequencer-client/src/global_variable_builder`), so a warm call issues zero L1 requests. A background loop +refreshes the snapshot once per L1 block — and proactively as the wall clock drifts toward the top of the +covered window, so empty-Ethereum-slot runs do not freeze quotes — pinning every read to the archiver's +synced L1 block number, labelling it with that block's hash, and re-validating the archiver identity before +publishing via an atomic pointer swap. Each candidate slot stores a *complete* precomputed quote: the +Solidity `getManaMinFeeAt` current fee plus a full `FEE_ORACLE_LAG`-length prediction array per +`ManaUsageEstimate`. Reads merge only complete arrays (element-wise max), never synthesizing a mixed oracle +tuple. + +### The two anchor rules + +A quote applies to a future slot, and the two RPCs floor that slot differently (preserving the +pre-snapshot behaviour): + +- **current fee** floors on the raw pending checkpoint slot: + `wantedCurrent(t) = max(pendingCheckpointSlot + 1, slotAtNextL1Block(t))`; +- **prediction** floors on the slot of the pinned L1 block and the prune-aware effective parent: + `wantedPrediction(t) = max(pinnedSlot, slotAtNextL1Block(t))`. + +These floors come only from snapshot-level fields (`pendingCheckpointSlot`, `pinnedSlot`); there is no +per-candidate selection state. The checkpoint-slot invariant `pendingCheckpointSlot <= pinnedSlot` (a +proposed checkpoint's slot equals the slot of its L1 inclusion timestamp) lets the refresh build a +conservative window before it has read the checkpoints, then validate exact coverage afterwards. + +### Drift window + +To tolerate clock skew between the node and L1, a read enumerates every distinct Aztec slot in +`[wanted(now - drift), wanted(now + drift)]` (small default, e.g. 2s; `0` disables the window and reduces +selection to the single legacy anchor) and takes the element-wise max of the complete candidate arrays. A +coverage miss — a wanted slot outside the materialized window, above or below, symmetrically — never +substitutes another slot's answer: it awaits a keyed single-flight refresh and, on timeout, fails closed +with a typed error. Concurrent reads and the poll loop share one refresh per identity/window. + +### Staleness bounds + +Three independent checks each fail closed with their own typed error and metric, and each is disabled by +setting its config to `0`: + +- **computation age** (`now - refreshedAtMs`, reset by every successful refresh including coverage-only) — + exceeding it means refresh is broken; +- **L1-head age** (`now - pinnedBlockTimestamp`, never reset by coverage-only refreshes) — exceeding it + means the provider or archiver is frozen; +- **future-dated head** (`pinnedBlockTimestamp - now`) — fails closed in production; test environments that + warp L1 time align their clock or set the allowance to `0`. + +### Consistency stance + +Reads are block-number pinned, hash-labelled, archiver-rechecked, and wave-consistency-checked: the refresh +re-reads `getTips` in its second multicall wave and discards the refresh if the tips differ from the first +wave (catching fork-mixing across the fallback transport). This is strictly better on state consistency than +the pre-snapshot current-fee path, which ran entirely unpinned at `latest`; on identity freshness the RPC-side +archiver identity check gives parity with the old per-call latest-block check, bounded by archiver sync +health. Residual risks, stated plainly: + +- each multicall wave is atomic only on whichever fallback-transport backend served it. Without the tips + check tripping, the two waves could still mix backends/forks whose tips coincide but whose oracle or + governance state differs — producing an **impossible combined state**, not merely a snapshot coloured by + one fork. The tips check narrows this; it does not eliminate it. +- in the parallel-individual-call fallback (no Multicall3 deployed), even a single logical wave can mix + backends — a deliberately weaker guarantee than in multicall mode. +- a same-height reorg not yet observed by the archiver persists for at most one archiver poll + refresh cycle. + +EIP-1898 hash pinning or explicit backend binding is the only complete fix and remains a contained follow-up +inside the refresh function; it is not part of the current design. + ## Fee Asset Price Fees are computed in ETH (wei) internally and converted to the fee asset (Fee Juice) via From 3184af40138310b818b4a4fdff3066fa26f9d649 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 15:28:11 -0300 Subject: [PATCH 4/8] fix(bench): gate warm fee RPCs on the snapshot service counter 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. --- .../src/bench/node_rpc_perf.test.ts | 174 ++++++++---------- 1 file changed, 76 insertions(+), 98 deletions(-) diff --git a/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts b/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts index 6c936697d0c0..a633b1954edb 100644 --- a/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts +++ b/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts @@ -11,7 +11,7 @@ import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import type { AztecNode } from '@aztec/aztec.js/node'; -import { type Anvil, type RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; +import type { RollupCheatCodes } from '@aztec/ethereum/test'; import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { Timer } from '@aztec/foundation/timer'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; @@ -28,14 +28,10 @@ import 'jest-extended'; import * as path from 'path'; import { PIPELINING_SETUP_OPTS } from '../fixtures/fixtures.js'; -import { type LatencyProxy, startLatencyProxy } from '../fixtures/latency_proxy.js'; import { setup } from '../fixtures/utils.js'; import type { TestWallet } from '../test-wallet/test_wallet.js'; import { proveInteraction } from '../test-wallet/utils.js'; -/** Injected per-L1-request latency (ms) used to make L1 round trips dominate any warm-path measurement. */ -const INJECTED_L1_LATENCY_MS = 100; - function percentile(values: number[], p: number): number { if (values.length === 0) { return 0; @@ -123,8 +119,6 @@ describe('e2e_node_rpc_perf', () => { let ownerAddress: AztecAddress; let rollupCheatCodes: RollupCheatCodes; let teardown: () => Promise; - let anvil: Anvil; - let latencyProxy: LatencyProxy; const benchmarkResults: BenchmarkResult[] = []; // Data collected during block building for use in benchmarks @@ -156,17 +150,9 @@ describe('e2e_node_rpc_perf', () => { } await teardown(); - await latencyProxy?.stop(); - await anvil?.stop().catch(() => {}); }); beforeAll(async () => { - // Start Anvil directly and put a latency proxy in front of it, so the node talks to L1 through the proxy - // while we retain the direct Anvil URL for cheat codes. The proxy runs at zero delay during setup. - let directRpcUrl: string; - ({ anvil, rpcUrl: directRpcUrl } = await startAnvil()); - latencyProxy = await startLatencyProxy(directRpcUrl, 0); - ({ teardown, logger, @@ -176,7 +162,6 @@ describe('e2e_node_rpc_perf', () => { accounts: [ownerAddress], cheatCodes: { rollup: rollupCheatCodes }, } = await setup(1, { - l1RpcUrls: [latencyProxy.url], archiverPollingIntervalMS: 200, sequencerPollingIntervalMS: 200, worldStateBlockCheckIntervalMS: 200, @@ -591,9 +576,12 @@ describe('e2e_node_rpc_perf', () => { }); }); - describe('fee APIs with injected L1 latency', () => { - // Warm the snapshot and establish a same-run local-RPC baseline before injecting L1 latency, so the fee - // warm-path p95 is compared against a call that never touches L1 under identical process conditions. + // The zero-fee-path-L1 property is gated directly on the FeeSnapshotService counter (readTriggeredRefreshes): + // a warm read that is served from the in-memory snapshot triggers no refresh and issues no L1 request. This + // is authoritative regardless of L1 latency, so it does not require routing the node through the latency proxy + // (which would conflict with the shared setup's ownership of the Anvil clock/mining). A same-run local-RPC + // baseline (getBlockNumber) bounds the warm fee-path latency. + describe('warm fee APIs served from the snapshot', () => { async function measureWarm(fn: () => Promise, iterations: number): Promise { const timings: number[] = []; for (let i = 0; i < iterations; i++) { @@ -604,95 +592,85 @@ describe('e2e_node_rpc_perf', () => { return timings; } - it('serves warm getPredictedMinFees with zero fee-path L1 requests and near-local latency (sequential)', async () => { - // Warm the snapshot with zero delay, then inject latency so any L1 round trip would dominate. + it('serves warm getPredictedMinFees with zero fee-path L1 requests, near a local baseline (sequential)', async () => { + // Warm the snapshot first so the measured calls are served from memory. await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); - latencyProxy.setDelayMs(INJECTED_L1_LATENCY_MS); - try { - const baseline = await measureWarm(() => aztecNode.getBlockNumber(), 20); - const statsBefore = aztecNodeService.getFeeSnapshotStats(); - const feeTimings = await measureWarm(() => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit), 20); - const statsAfter = aztecNodeService.getFeeSnapshotStats(); - - const baselineP95 = percentile(baseline, 95); - const feeP95 = percentile(feeTimings, 95); - addResult('getPredictedMinFees_warm_seq_p95', calculateStats(feeTimings)); - logger.info('Warm getPredictedMinFees (sequential) with 100ms L1 latency', { - feeP95, - baselineP95, - readTriggeredRefreshesDelta: - (statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0), - }); - // Gated: warm reads must trigger no refresh (zero fee-path L1 requests) and stay near the local baseline. - if (statsBefore && statsAfter) { - expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes); - } - expect(feeP95).toBeLessThan(baselineP95 + INJECTED_L1_LATENCY_MS); - } finally { - latencyProxy.setDelayMs(0); + const baseline = await measureWarm(() => aztecNode.getBlockNumber(), BENCHMARK_ITERATIONS_FAST); + const statsBefore = aztecNodeService.getFeeSnapshotStats(); + const feeTimings = await measureWarm( + () => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit), + BENCHMARK_ITERATIONS_FAST, + ); + const statsAfter = aztecNodeService.getFeeSnapshotStats(); + + const baselineP95 = percentile(baseline, 95); + const feeP95 = percentile(feeTimings, 95); + addResult('getPredictedMinFees_warm_seq_p95', calculateStats(feeTimings)); + logger.info('Warm getPredictedMinFees (sequential)', { + feeP95, + baselineP95, + readTriggeredRefreshesDelta: + (statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0), + }); + + // Gated: warm reads trigger no refresh (zero fee-path L1 requests) and stay within tolerance of the baseline. + if (statsBefore && statsAfter) { + expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes); } + expect(feeP95).toBeLessThan(baselineP95 + 100); }); - it('serves warm getPredictedMinFees with zero fee-path L1 requests and near-local latency (concurrent)', async () => { + it('serves warm getPredictedMinFees with zero fee-path L1 requests (concurrent)', async () => { await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); - latencyProxy.setDelayMs(INJECTED_L1_LATENCY_MS); - try { - const statsBefore = aztecNodeService.getFeeSnapshotStats(); - const timer = new Timer(); - await Promise.all(Array.from({ length: 20 }, () => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit))); - const totalMs = timer.ms(); - const statsAfter = aztecNodeService.getFeeSnapshotStats(); - - logger.info('Warm getPredictedMinFees (20 concurrent) with 100ms L1 latency', { - totalMs, - readTriggeredRefreshesDelta: - (statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0), - }); - addResult('getPredictedMinFees_warm_concurrent_total', { - avg: totalMs, - min: totalMs, - max: totalMs, - total: totalMs, - count: 20, - }); - if (statsBefore && statsAfter) { - expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes); - } - // 20 concurrent warm reads should complete well within a single injected L1 round trip. - expect(totalMs).toBeLessThan(INJECTED_L1_LATENCY_MS); - } finally { - latencyProxy.setDelayMs(0); + const statsBefore = aztecNodeService.getFeeSnapshotStats(); + const timer = new Timer(); + await Promise.all( + Array.from({ length: BENCHMARK_ITERATIONS_FAST }, () => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit)), + ); + const totalMs = timer.ms(); + const statsAfter = aztecNodeService.getFeeSnapshotStats(); + + logger.info('Warm getPredictedMinFees (20 concurrent)', { + totalMs, + readTriggeredRefreshesDelta: + (statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0), + }); + addResult('getPredictedMinFees_warm_concurrent_total', { + avg: totalMs, + min: totalMs, + max: totalMs, + total: totalMs, + count: BENCHMARK_ITERATIONS_FAST, + }); + + // Gated: 20 concurrent warm reads share the snapshot and trigger no refresh. + if (statsBefore && statsAfter) { + expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes); } + expect(totalMs).toBeLessThan(1000); }); - it('reports refresh cost and the first call after a new L1 block (non-gating)', async () => { - latencyProxy.setDelayMs(INJECTED_L1_LATENCY_MS); - try { - latencyProxy.resetCounts(); - const before = aztecNodeService.getFeeSnapshotStats(); - // Advance the chain to force an archiver identity change and observe the boundary refresh cost. - await rollupCheatCodes.advanceSlots(1); - const timer = new Timer(); - await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); - const firstCallMs = timer.ms(); - const after = aztecNodeService.getFeeSnapshotStats(); - logger.info('First getPredictedMinFees after advancing the chain (reported)', { - firstCallMs, - refreshesDelta: (after?.refreshes ?? 0) - (before?.refreshes ?? 0), - proxyEthCalls: latencyProxy.getRequestCount('eth_call'), - }); - addResult('getPredictedMinFees_boundary_first_call', { - avg: firstCallMs, - min: firstCallMs, - max: firstCallMs, - total: firstCallMs, - count: 1, - }); - } finally { - latencyProxy.setDelayMs(0); - } + it('reports the first getPredictedMinFees after advancing the chain (non-gating)', async () => { + const before = aztecNodeService.getFeeSnapshotStats(); + // Advance the chain to force an archiver identity change and observe the boundary refresh cost. + await rollupCheatCodes.advanceSlots(1); + const timer = new Timer(); + await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); + const firstCallMs = timer.ms(); + const after = aztecNodeService.getFeeSnapshotStats(); + logger.info('First getPredictedMinFees after advancing the chain (reported)', { + firstCallMs, + refreshesDelta: (after?.refreshes ?? 0) - (before?.refreshes ?? 0), + }); + addResult('getPredictedMinFees_boundary_first_call', { + avg: firstCallMs, + min: firstCallMs, + max: firstCallMs, + total: firstCallMs, + count: 1, + }); }); }); From 7abded144ac2cfcb776636326c4bec85a7cfb230 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 15:34:28 -0300 Subject: [PATCH 5/8] fix(node): materialize all drift-window slots in fee snapshot top-up 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. --- .../fee_snapshot_service.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts index 7ec489a93e7e..35d7753615cd 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts @@ -678,13 +678,21 @@ export class FeeSnapshotService { } private exactWantedSlots(nowSeconds: bigint, pinnedSlot: SlotNumber, pendingCheckpointSlot: SlotNumber): number[] { + // Enumerate every slot between the drift-window endpoints, exactly like the read path does: with a capped + // provisional window, materializing only the endpoints would leave intermediate wanted slots uncovered and + // reads would coverage-miss into an error no refresh can repair. const drift = BigInt(this.config.clockDriftAllowanceSeconds); - const slots = new Set(); - for (const t of [nowSeconds - drift, nowSeconds + drift]) { - slots.add(this.wantedCurrent(pendingCheckpointSlot, t)); - slots.add(this.wantedPrediction(pinnedSlot, t)); - } - return [...slots]; + const tLow = nowSeconds - drift; + const tHigh = nowSeconds + drift; + const current = this.enumerate( + this.wantedCurrent(pendingCheckpointSlot, tLow), + this.wantedCurrent(pendingCheckpointSlot, tHigh), + ); + const prediction = this.enumerate( + this.wantedPrediction(pinnedSlot, tLow), + this.wantedPrediction(pinnedSlot, tHigh), + ); + return unique([...current, ...prediction]); } private tsForSlot(slot: number): bigint { From 6f150d2d0bfd8a17dd549c165e39b9ba779bd940 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 15:52:39 -0300 Subject: [PATCH 6/8] fix(node): address fee snapshot review findings on rollback, staleness 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. --- .../fee_snapshot_service.test.ts | 77 ++++++++++++++- .../fee_snapshot_service.ts | 97 +++++++++++++++---- 2 files changed, 151 insertions(+), 23 deletions(-) diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts index 96e4af314f61..3b790bb019d0 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts @@ -7,7 +7,6 @@ import { ManualDateProvider } from '@aztec/foundation/timer'; import { FEE_ORACLE_LAG, ManaUsageEstimate } from '@aztec/stdlib/gas'; import { - FeeSnapshotComputationStaleError, FeeSnapshotConfigError, FeeSnapshotFutureHeadError, FeeSnapshotL1HeadStaleError, @@ -64,6 +63,8 @@ class FakeRollup implements RollupFeeReader { public gate: Promise | undefined; /** Wave-2 tips override to simulate fork mixing (applied once). */ public wave2TipsOnce: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; + /** Wave-2 tips override applied on every wave 2, to simulate persistent tips instability. */ + public wave2TipsAlways: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; async readFeeInputs(reads: RollupFeeRead[], options: { blockNumber: bigint }): Promise { this.callCount++; @@ -82,6 +83,9 @@ class FakeRollup implements RollupFeeReader { private resolve(read: RollupFeeRead, isWave2: boolean): RollupFeeReadResult { switch (read.kind) { case 'tips': { + if (isWave2 && this.wave2TipsAlways) { + return { kind: 'tips', value: this.wave2TipsAlways }; + } if (isWave2 && this.wave2TipsOnce) { const value = this.wave2TipsOnce; this.wave2TipsOnce = undefined; @@ -233,12 +237,51 @@ describe('FeeSnapshotService', () => { await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); // Last-good snapshot is preserved (identity 1). expect(service.getSnapshot()).toBe(good); - // Recovery: next call refreshes successfully to the new identity. + // Recovery: once the failure backoff elapses, the next call refreshes successfully to the new identity. + dateProvider.advanceTimeMs(1_000); const fees = await service.getCurrentMinFees(); expect(fees.feePerL2Gas).toBe(101n); expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); }); + it('read-triggered refreshes respect the failure backoff and do not hammer L1', async () => { + await service.getCurrentMinFees(); + identity.snapshot = makeIdentity(2n, PINNED_SLOT); + rollup.failNext = 1; + await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); + // During the backoff window, reads fail fast with a typed error and issue no L1 requests. + const calls = rollup.callCount; + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); + expect(rollup.callCount).toBe(calls); + // After the backoff elapses, reads refresh again. + dateProvider.advanceTimeMs(1_000); + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + }); + + it('publishes and serves a rollback to a lower L1 block number', async () => { + identity.snapshot = makeIdentity(5n, PINNED_SLOT); + await service.getCurrentMinFees(); + expect(service.getSnapshot()!.l1.blockNumber).toBe(5n); + // L1 identity is hash-authoritative: a reorg or lagging fallback backend can move the height backwards. + identity.snapshot = makeIdentity(4n, PINNED_SLOT, Buffer32.fromNumber(444)); + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + expect(service.getSnapshot()!.l1.blockNumber).toBe(4n); + }); + + it('exceeding the refresh restart bound counts as a failure and engages backoff', async () => { + rollup.wave2TipsAlways = { pending: CheckpointNumber(6), proven: CheckpointNumber(3) }; + // The cold caller parks on the first-snapshot promise (resolved only on success) and times out typed. + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); + expect(service.getStats().refreshFailures).toBe(1); + expect(service.getStats().tipsMismatchDiscards).toBe(4); + // Backoff is engaged: an immediate retry issues no further L1 reads. + const calls = rollup.callCount; + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); + expect(rollup.callCount).toBe(calls); + }); + it('discards and restarts the refresh when wave-2 tips differ from wave-1', async () => { rollup.wave2TipsOnce = { pending: CheckpointNumber(6), proven: CheckpointNumber(3) }; const fees = await service.getCurrentMinFees(); @@ -251,6 +294,7 @@ describe('FeeSnapshotService', () => { rollup.failNext = 100; await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); rollup.failNext = 0; + dateProvider.advanceTimeMs(1_000); const fees = await service.getCurrentMinFees(); expect(fees.feePerL2Gas).toBe(101n); }); @@ -273,11 +317,34 @@ describe('FeeSnapshotService', () => { }); describe('staleness', () => { - it('fails closed when the computation age exceeds the bound', async () => { + it('repairs a stale computation age with one refresh instead of failing', async () => { + service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); + await service.getCurrentMinFees(); + const refreshesBefore = service.getStats().refreshes; + dateProvider.advanceTimeMs(2_000); + // Computation staleness is recoverable: the read triggers one refresh (re-anchoring refreshedAtMs) and serves. + await expect(service.getCurrentMinFees()).resolves.toBeDefined(); + expect(service.getStats().refreshes).toBe(refreshesBefore + 1); + expect(service.getStats().computationStaleErrors).toBe(0); + }); + + it('fails closed when the computation age exceeds the bound and the repair refresh fails', async () => { service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); await service.getCurrentMinFees(); dateProvider.advanceTimeMs(2_000); - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotComputationStaleError); + rollup.failNext = 100; + // The repair refresh fails, so the caller sees the underlying refresh error rather than a stale serve. + await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); + }); + + it('refreshes instead of failing when stale but the archiver already has a new identity', async () => { + service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); + await service.getCurrentMinFees(); + dateProvider.advanceTimeMs(2_000); + identity.snapshot = makeIdentity(2n, PINNED_SLOT); + // Identity is checked before staleness: the read refreshes to the new identity and serves. + await expect(service.getCurrentMinFees()).resolves.toBeDefined(); + expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); }); it('fails closed when the L1 head age exceeds the bound', async () => { @@ -285,6 +352,7 @@ describe('FeeSnapshotService', () => { await service.getCurrentMinFees(); dateProvider.advanceTime(120); await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotL1HeadStaleError); + expect(service.getStats().l1HeadStaleErrors).toBe(1); }); it('fails closed when the L1 head is dated too far into the future', async () => { @@ -293,6 +361,7 @@ describe('FeeSnapshotService', () => { // Step the wall clock backwards so the pinned head is far ahead of "now". dateProvider.advanceTime(-120); await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotFutureHeadError); + expect(service.getStats().futureHeadErrors).toBe(1); }); it('disables each staleness check independently when its config is 0', async () => { diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts index 35d7753615cd..94a08678c32c 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts @@ -36,7 +36,13 @@ export interface RollupFeeReader { } /** Cause of a refresh, recorded on metrics/logs for observability. */ -type RefreshCause = 'poll-identity' | 'poll-coverage' | 'rpc-identity-mismatch' | 'coverage-miss' | 'rpc-first'; +type RefreshCause = + | 'poll-identity' + | 'poll-coverage' + | 'rpc-identity-mismatch' + | 'coverage-miss' + | 'stale-refresh' + | 'rpc-first'; /** Counters exposed for benchmarking and observability. */ export type FeeSnapshotStats = { @@ -50,6 +56,14 @@ export type FeeSnapshotStats = { tipsMismatchDiscards: number; /** Targeted top-up waves fired during refresh. */ topUpWaves: number; + /** Reads that failed closed because the last refresh was too old and a repair refresh did not fix it. */ + computationStaleErrors: number; + /** Reads that failed closed because the pinned L1 head was too old. */ + l1HeadStaleErrors: number; + /** Reads that failed closed because the pinned L1 head was future-dated beyond the allowance. */ + futureHeadErrors: number; + /** Reads that failed closed because a needed refresh did not complete within the bound. */ + coverageTimeouts: number; }; const MAX_LOOKUP_ATTEMPTS = 4; @@ -85,6 +99,10 @@ export class FeeSnapshotService { readTriggeredRefreshes: 0, tipsMismatchDiscards: 0, topUpWaves: 0, + computationStaleErrors: 0, + l1HeadStaleErrors: 0, + futureHeadErrors: 0, + coverageTimeouts: 0, }; constructor( @@ -179,22 +197,23 @@ export class FeeSnapshotService { await this.awaitFirstSnapshot(deadline); } + let attemptedStaleRefresh = false; for (let attempt = 0; attempt < MAX_LOOKUP_ATTEMPTS; attempt++) { if (this.stopped) { throw new FeeSnapshotStoppedError(); } const snapshot = this.snapshot!; - const nowMs = this.dateProvider.now(); - const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); - this.assertFresh(snapshot, nowMs, nowSeconds); - // Identity check: serve freshness parity with today's per-call latest-block check, at zero L1 cost. + // Identity check first: a stale or uncovered snapshot is recoverable when the archiver already has a + // newer identity, so refresh before applying any fail-closed staleness bound. This also serves freshness + // parity with today's per-call latest-block check, at zero L1 cost. const identity = this.identityProvider.getL1SyncSnapshot(); if (identity && !identity.blockHash.equals(snapshot.l1.blockHash)) { await this.readTriggeredRefresh(deadline, 'rpc-identity-mismatch'); continue; } + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); const { currentSlots, predictionSlots } = this.computeWantedSlots(snapshot, nowSeconds); const uncovered = [...currentSlots, ...predictionSlots].some(s => !snapshot.candidates.has(s)); if (uncovered) { @@ -202,9 +221,26 @@ export class FeeSnapshotService { continue; } + // Staleness last: the snapshot matches the archiver identity and covers the wanted slots. A stale + // computation age is repairable by one refresh (it re-anchors refreshedAtMs), so attempt that once + // before failing; head-age and future-head violations mean the L1 view itself is frozen or warped + // (the archiver has nothing newer), so they fail closed immediately. + try { + this.assertFresh(snapshot, this.dateProvider.now(), nowSeconds); + } catch (err) { + if (err instanceof FeeSnapshotComputationStaleError && !attemptedStaleRefresh) { + attemptedStaleRefresh = true; + await this.readTriggeredRefresh(deadline, 'stale-refresh'); + continue; + } + this.recordFailClosed(err); + throw err; + } + return { snapshot, currentSlots, predictionSlots }; } + this.stats.coverageTimeouts++; throw new FeeSnapshotCoverageError( `Fee snapshot did not cover the wanted slots within ${this.config.refreshTimeoutMs}ms`, ); @@ -305,10 +341,22 @@ export class FeeSnapshotService { } } + /** Increments the fail-closed counter matching a staleness error that is about to escape to the caller. */ + private recordFailClosed(err: unknown): void { + if (err instanceof FeeSnapshotComputationStaleError) { + this.stats.computationStaleErrors++; + } else if (err instanceof FeeSnapshotL1HeadStaleError) { + this.stats.l1HeadStaleErrors++; + } else if (err instanceof FeeSnapshotFutureHeadError) { + this.stats.futureHeadErrors++; + } + } + private async readTriggeredRefresh(deadline: number, cause: RefreshCause): Promise { this.stats.readTriggeredRefreshes++; const remaining = deadline - this.dateProvider.now(); if (remaining <= 0) { + this.stats.coverageTimeouts++; throw new FeeSnapshotCoverageError(`Timed out waiting for fee snapshot refresh (${cause})`); } try { @@ -318,6 +366,7 @@ export class FeeSnapshotService { throw new FeeSnapshotStoppedError(); } if (err instanceof TimeoutError) { + this.stats.coverageTimeouts++; throw new FeeSnapshotCoverageError(`Timed out waiting for fee snapshot refresh (${cause})`); } throw err; @@ -376,6 +425,15 @@ export class FeeSnapshotService { if (this.stopped) { return Promise.reject(new FeeSnapshotStoppedError()); } + // Reads must not bypass the failure backoff: with a failing L1, per-request refreshes would turn incoming + // RPC traffic directly into L1 load. Fail fast instead; the background loop retries once the backoff elapses. + if (this.dateProvider.now() < this.nextRetryAtMs) { + return Promise.reject( + new FeeSnapshotUnavailableError( + `Fee snapshot refresh is backing off after ${this.consecutiveFailures} consecutive failures`, + ), + ); + } const identity = this.identityProvider.getL1SyncSnapshot(); if (!identity) { return Promise.reject(new FeeSnapshotUnavailableError('No L1 identity available yet')); @@ -406,10 +464,11 @@ export class FeeSnapshotService { } private async runRefresh(identity: L1SyncSnapshot, cause: RefreshCause, attempt: number): Promise { - if (attempt >= MAX_REFRESH_ATTEMPTS) { - throw new FeeSnapshotRefreshError('Fee snapshot refresh exceeded max attempts (identity/tips instability)'); - } try { + // Thrown inside the try so exceeding the restart bound is accounted as a failure and sets backoff. + if (attempt >= MAX_REFRESH_ATTEMPTS) { + throw new FeeSnapshotRefreshError('Fee snapshot refresh exceeded max attempts (identity/tips instability)'); + } const snapshot = await this.buildSnapshot(identity, cause, attempt); this.consecutiveFailures = 0; this.nextRetryAtMs = 0; @@ -565,13 +624,17 @@ export class FeeSnapshotService { throw this.restartWith(after); } + // Publish the actual materialized bounds, not the provisional window: with a capped window plus top-up, + // candidates can lie outside [base, top], and provisional bounds would make coverageDrifting() request a + // refresh on every poll tick despite full map coverage. + const materializedSlots = [...candidates.keys()]; const snapshot: FeeSnapshot = { l1: identity, pendingCheckpointSlot, pinnedSlot, candidates, - baseSlot: SlotNumber(base), - topSlot: SlotNumber(top), + baseSlot: SlotNumber(Math.min(...materializedSlots)), + topSlot: SlotNumber(Math.max(...materializedSlots)), refreshedAtMs: this.dateProvider.now(), }; this.publish(snapshot, cause, attempt); @@ -632,15 +695,11 @@ export class FeeSnapshotService { } private publish(snapshot: FeeSnapshot, cause: RefreshCause, attempt: number): void { - const current = this.snapshot; - if (current && current.l1.blockNumber > snapshot.l1.blockNumber) { - // An older refresh finished after a newer one; never overwrite the newer snapshot. - this.log.debug('Discarding stale fee snapshot refresh result', { - built: snapshot.l1.blockNumber, - current: current.l1.blockNumber, - }); - return; - } + // No ordering guard here: refreshes are strictly serial (chained in triggerRefresh) and the archiver + // identity was re-checked synchronously just before this call, so the snapshot always reflects the + // archiver's current identity. In particular, never gate on block number — L1 identity is + // hash-authoritative and the height can legitimately move backwards (reorg, or a lagging fallback + // backend); a height guard would discard every rebuild after a rollback and wedge reads. this.snapshot = snapshot; this.stats.refreshes++; if (!this.firstResolved) { From 5609e8c2eaae218cda27fabdd38d290773ccc299 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 24 Jul 2026 16:09:49 -0300 Subject: [PATCH 7/8] test(node): cover fee prune-aware selection and oracle rotation placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/contracts/rollup_fee_reads.test.ts | 66 ++++++- .../fee_snapshot_equivalence.test.ts | 165 +++++++++++++++++- 2 files changed, 220 insertions(+), 11 deletions(-) diff --git a/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts index dfc65d22433f..c81245ba96ed 100644 --- a/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts +++ b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts @@ -1,5 +1,6 @@ -import { SlotNumber } from '@aztec/foundation/branded-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; @@ -10,7 +11,7 @@ import { DefaultL1ContractsConfig } from '../config.js'; import { deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '../test/index.js'; import type { ViemClient } from '../types.js'; -import { RollupContract, type RollupFeeRead } from './rollup.js'; +import { type FeeHeader, RollupContract, type RollupFeeRead, TempCheckpointLogField } from './rollup.js'; // Sequential execution: the ethereum package shares Anvil ports across suites. describe('RollupContract fee reads', () => { @@ -52,6 +53,30 @@ describe('RollupContract fee reads', () => { return l1GenesisTime + BigInt(slot) * BigInt(slotDuration); } + /** Writes a checkpoint log (slot + fee header) into the Rollup's temp-checkpoint circular buffer via storage. */ + async function writeCheckpointLog(checkpointNumber: CheckpointNumber, slot: number, feeHeader: FeeHeader) { + const address = EthAddress.fromString(rollup.address); + const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( + checkpointNumber, + TempCheckpointLogField.FeeHeader, + ); + await cheatCodes.store(address, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); + const slotNumberSlot = await rollup.getTempCheckpointLogStorageSlot( + checkpointNumber, + TempCheckpointLogField.SlotNumber, + ); + await cheatCodes.store(address, slotNumberSlot, BigInt(slot) & ((1n << 32n) - 1n)); + } + + /** Sets the chain tips (pending, proven) directly via storage. */ + async function setTips(pending: CheckpointNumber, proven: CheckpointNumber) { + await cheatCodes.store( + EthAddress.fromString(rollup.address), + RollupContract.chainTipsStorageSlot, + RollupContract.packChainTips(BigInt(pending), BigInt(proven)), + ); + } + it('reads via Multicall3 identically to parallel individual pinned reads', async () => { const blockNumber = await publicClient.getBlockNumber(); const { pending } = await rollup.getTips({ blockNumber }); @@ -123,5 +148,42 @@ describe('RollupContract fee reads', () => { await cheatCodes.mine(); } }, 30_000); + + it('holds with a pending checkpoint written at a non-zero slot across advancing states', async () => { + // Write a real pending checkpoint at the current (non-zero) slot so the invariant is exercised with a + // meaningful pendingCheckpointSlot rather than the genesis slot 0. + const startBlock = await publicClient.getBlock(); + const startSlot = Number((startBlock.timestamp - l1GenesisTime) / BigInt(slotDuration)); + expect(startSlot).toBeGreaterThan(0); + + const feeHeader: FeeHeader = { + excessMana: 0n, + manaUsed: 0n, + ethPerFeeAsset: 1_000_000_000_000n, + congestionCost: 0n, + proverCost: 0n, + }; + await writeCheckpointLog(CheckpointNumber(1), startSlot, feeHeader); + await setTips(CheckpointNumber(1), CheckpointNumber(0)); + + try { + for (let i = 0; i < 3; i++) { + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const { pending } = await rollup.getTips({ blockNumber }); + expect(pending).toBe(CheckpointNumber(1)); + const pendingCheckpoint = await rollup.getCheckpoint(pending, { blockNumber }); + const pinnedSlot = SlotNumber.fromBigInt((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); + // The written pending checkpoint sits at a non-zero slot and never exceeds the pinned block's slot. + expect(Number(pendingCheckpoint.slotNumber)).toBe(startSlot); + expect(Number(pendingCheckpoint.slotNumber)).toBeLessThanOrEqual(Number(pinnedSlot)); + await rollupCheatCodes.advanceSlots(2); + await cheatCodes.mine(); + } + } finally { + // Restore genesis tips so the written state does not leak into other tests. + await setTips(CheckpointNumber(0), CheckpointNumber(0)); + } + }, 30_000); }); }); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts index 0e7954ed9f81..3309445f3f52 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts @@ -1,15 +1,17 @@ import { getPublicClient } from '@aztec/ethereum/client'; import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; -import { RollupContract } from '@aztec/ethereum/contracts'; +import { type FeeHeader, RollupContract, TempCheckpointLogField } from '@aztec/ethereum/contracts'; import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; import type { ViemClient } from '@aztec/ethereum/types'; +import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider, ManualDateProvider } from '@aztec/foundation/timer'; -import { ManaUsageEstimate } from '@aztec/stdlib/gas'; +import { FEE_ORACLE_LAG, ManaUsageEstimate } from '@aztec/stdlib/gas'; import { foundry } from 'viem/chains'; @@ -30,6 +32,7 @@ describe('FeeSnapshot equivalence with legacy oracle', () => { let ethereumSlotDuration: number; let l1GenesisTime: bigint; let epochDuration: number; + let proofSubmissionEpochs: number; let constants: { l1GenesisTime: bigint; slotDuration: number; ethereumSlotDuration: number }; beforeAll(async () => { @@ -52,6 +55,7 @@ describe('FeeSnapshot equivalence with legacy oracle', () => { ethereumSlotDuration = DefaultL1ContractsConfig.ethereumSlotDuration; l1GenesisTime = await rollup.getL1GenesisTime(); epochDuration = await rollup.getEpochDuration(); + proofSubmissionEpochs = await rollup.getProofSubmissionEpochs(); constants = { l1GenesisTime, slotDuration, ethereumSlotDuration }; }, 60_000); @@ -105,6 +109,34 @@ describe('FeeSnapshot equivalence with legacy oracle', () => { return Number((await publicClient.getBlock()).timestamp); } + function tsForSlot(slot: number): bigint { + return l1GenesisTime + BigInt(slot) * BigInt(slotDuration); + } + + /** Writes a checkpoint log (slot + fee header) into the Rollup's temp-checkpoint circular buffer via storage. */ + async function writeCheckpointLog(checkpointNumber: CheckpointNumber, slot: number, feeHeader: FeeHeader) { + const address = EthAddress.fromString(rollup.address); + const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( + checkpointNumber, + TempCheckpointLogField.FeeHeader, + ); + await cheatCodes.store(address, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); + const slotNumberSlot = await rollup.getTempCheckpointLogStorageSlot( + checkpointNumber, + TempCheckpointLogField.SlotNumber, + ); + await cheatCodes.store(address, slotNumberSlot, BigInt(slot) & ((1n << 32n) - 1n)); + } + + /** Sets the chain tips (pending, proven) directly via storage. */ + async function setTips(pending: CheckpointNumber, proven: CheckpointNumber) { + await cheatCodes.store( + EthAddress.fromString(rollup.address), + RollupContract.chainTipsStorageSlot, + RollupContract.packChainTips(BigInt(pending), BigInt(proven)), + ); + } + it('matches the legacy oracle at a fresh deploy', async () => { await assertEquivalent(await currentBlockTimestamp()); }); @@ -115,14 +147,52 @@ describe('FeeSnapshot equivalence with legacy oracle', () => { await assertEquivalent(await currentBlockTimestamp()); }, 30_000); - it('matches the legacy oracle after an L1 gas fee oracle update', async () => { - await rollupCheatCodes.advanceSlots(3); - await cheatCodes.setNextBlockBaseFeePerGas(120_000_000_000n); - await cheatCodes.mine(); + it('matches the legacy oracle across an oracle rotation placed below/inside/above the window', async () => { + const preBaseFee = 50_000_000_000n; + const postBaseFee = 200_000_000_000n; + + // Two accepted updates leave pre=preBaseFee, post=postBaseFee with slotOfChange = updateSlot + LAG. The + // updates are spaced past the oracle LIFETIME cooldown so both take effect. + await rollupCheatCodes.advanceSlots(6); + await cheatCodes.setNextBlockBaseFeePerGas(preBaseFee); await rollupCheatCodes.updateL1GasFeeOracle(); - await cheatCodes.mine(); - await assertEquivalent(await currentBlockTimestamp()); - }, 30_000); + await rollupCheatCodes.advanceSlots(6); + await cheatCodes.setNextBlockBaseFeePerGas(postBaseFee); + await rollupCheatCodes.updateL1GasFeeOracle(); + + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const pinnedSlot = Number((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); + const slotOfChange = pinnedSlot + FEE_ORACLE_LAG; + + // Confirm the rotation is real (pre != post) and located exactly at slotOfChange. + const pre = await rollup.getL1FeesAt(tsForSlot(slotOfChange - 1), { blockNumber }); + const post = await rollup.getL1FeesAt(tsForSlot(slotOfChange), { blockNumber }); + expect(pre.baseFee).not.toBe(post.baseFee); + const classify = (fees: { baseFee: bigint }) => (fees.baseFee === pre.baseFee ? 'pre' : 'post'); + + // Placing the next-L1-block slot at `anchorSlot` makes the prediction window [anchorSlot, anchorSlot+1]. + const placements = [ + { name: 'above the window (whole window uses pre)', deltaT: 0, expected: ['pre', 'pre'] }, + { name: 'inside the window (window straddles the rotation)', deltaT: 1, expected: ['pre', 'post'] }, + { name: 'below the window (whole window uses post)', deltaT: 2, expected: ['post', 'post'] }, + ] as const; + + for (const { name, deltaT, expected } of placements) { + const anchorSlot = pinnedSlot + deltaT; + const nowSeconds = Number(tsForSlot(anchorSlot)) - ethereumSlotDuration; + + const windowLow = await rollup.getL1FeesAt(tsForSlot(anchorSlot), { blockNumber }); + const windowHigh = await rollup.getL1FeesAt(tsForSlot(anchorSlot + 1), { blockNumber }); + // Verify the rotation lands where we intend before comparing, so the case tests what it claims. + expect({ placement: name, composition: [classify(windowLow), classify(windowHigh)] }).toEqual({ + placement: name, + composition: [...expected], + }); + + await assertEquivalent(nowSeconds); + } + }, 60_000); it('matches with the host clock one Ethereum slot behind and ahead of the L1 timestamp', async () => { const ts = await currentBlockTimestamp(); @@ -172,4 +242,81 @@ describe('FeeSnapshot equivalence with legacy oracle', () => { expect(atMid).toBe(atStart); expect(atEnd).toBe(atStart); }); + + // These cases write a real pending checkpoint beyond the proven tip (pending != proven) so the prune-aware + // effective-parent selection is exercised: pending when canPrune is false, proven when canPrune is true. + describe('pending checkpoint beyond the proven tip', () => { + const provenFeeHeader: FeeHeader = { + excessMana: 0n, + manaUsed: 0n, + ethPerFeeAsset: 1_000_000_000_000n, + congestionCost: 0n, + proverCost: 0n, + }; + const pendingFeeHeader: FeeHeader = { + excessMana: 5_000n, + manaUsed: 1_000n, + ethPerFeeAsset: 500_000_000_000n, + congestionCost: 0n, + proverCost: 0n, + }; + + afterEach(async () => { + // Restore genesis tips so the written pending>proven state does not leak into other tests. + await setTips(CheckpointNumber(0), CheckpointNumber(0)); + }); + + it('canPrune=false selects the pending checkpoint as effective parent, matching legacy', async () => { + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const nowSeconds = Number(block.timestamp); + const pinnedSlot = Number((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); + + // Proven at slot 0; pending at the current (recent) slot, so its epoch still accepts proofs. + await writeCheckpointLog(CheckpointNumber(1), 0, provenFeeHeader); + await writeCheckpointLog(CheckpointNumber(2), pinnedSlot, pendingFeeHeader); + await setTips(CheckpointNumber(2), CheckpointNumber(1)); + + const tips = await rollup.getTips({ blockNumber }); + expect(tips.pending).toBe(CheckpointNumber(2)); + expect(tips.proven).toBe(CheckpointNumber(1)); + + const nextSlotTs = tsForSlot(pinnedSlot); + expect(await rollup.canPruneAtTime(nextSlotTs, { blockNumber })).toBe(false); + // Effective parent is the pending checkpoint (its slot), not the proven one (slot 0). + const effective = await rollup.getEffectivePendingCheckpoint(nextSlotTs, { blockNumber }); + expect(Number(effective.slotNumber)).toBe(pinnedSlot); + + await assertEquivalent(nowSeconds); + }, 30_000); + + it('canPrune=true selects the proven checkpoint as effective parent, matching legacy', async () => { + // Advance well past the proof-submission deadline of epoch 0 so a prune becomes possible. + await rollupCheatCodes.advanceSlots(epochDuration * (proofSubmissionEpochs + 2)); + await cheatCodes.mine(); + + const block = await publicClient.getBlock(); + const blockNumber = block.number!; + const nowSeconds = Number(block.timestamp); + const pinnedSlot = Number((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); + + // Proven at slot 0 and pending at slot 1, both in epoch 0 (long past its proof deadline). + await writeCheckpointLog(CheckpointNumber(1), 0, provenFeeHeader); + await writeCheckpointLog(CheckpointNumber(2), 1, pendingFeeHeader); + await setTips(CheckpointNumber(2), CheckpointNumber(1)); + + const tips = await rollup.getTips({ blockNumber }); + expect(tips.pending).toBe(CheckpointNumber(2)); + expect(tips.proven).toBe(CheckpointNumber(1)); + + // The effective-parent selection happens at the prediction anchor (the current slot at this late time). + const nextSlotTs = tsForSlot(pinnedSlot); + expect(await rollup.canPruneAtTime(nextSlotTs, { blockNumber })).toBe(true); + // Effective parent is the proven checkpoint (slot 0), not the pending one (slot 1). + const effective = await rollup.getEffectivePendingCheckpoint(nextSlotTs, { blockNumber }); + expect(Number(effective.slotNumber)).toBe(0); + + await assertEquivalent(nowSeconds); + }, 30_000); + }); }); From 1a399477a3ae41618677f6995393186e27c0a27c Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 29 Jul 2026 20:12:07 -0300 Subject: [PATCH 8/8] refactor(node): simplify the fee snapshot service 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. --- .../src/fixtures/latency_proxy.test.ts | 85 -- .../end-to-end/src/fixtures/latency_proxy.ts | 113 -- yarn-project/ethereum/src/contracts/rollup.ts | 378 +++--- .../src/contracts/rollup_fee_reads.test.ts | 129 +- .../global_variable_builder/fee_prediction.ts | 32 +- .../global_variable_builder/fee_snapshot.ts | 161 --- .../fee_snapshot_equivalence.test.ts | 102 +- .../fee_snapshot_service.test.ts | 346 +++--- .../fee_snapshot_service.ts | 1053 +++++------------ .../src/global_variable_builder/index.ts | 24 +- .../legacy_fee_oracle.ts | 91 -- yarn-project/stdlib/src/gas/README.md | 82 +- 12 files changed, 860 insertions(+), 1736 deletions(-) delete mode 100644 yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts delete mode 100644 yarn-project/end-to-end/src/fixtures/latency_proxy.ts delete mode 100644 yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts delete mode 100644 yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts diff --git a/yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts b/yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts deleted file mode 100644 index 3f727a9cba08..000000000000 --- a/yarn-project/end-to-end/src/fixtures/latency_proxy.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type Server, createServer } from 'node:http'; -import type { AddressInfo } from 'node:net'; - -import { type LatencyProxy, startLatencyProxy } from './latency_proxy.js'; - -describe('latency proxy', () => { - let upstream: Server; - let upstreamUrl: string; - let upstreamRequests: string[]; - let proxy: LatencyProxy; - - beforeEach(async () => { - upstreamRequests = []; - upstream = createServer((req, res) => { - const chunks: Buffer[] = []; - req.on('data', c => chunks.push(c as Buffer)); - req.on('end', () => { - upstreamRequests.push(Buffer.concat(chunks).toString('utf-8')); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x2a' })); - }); - }); - await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); - upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; - }); - - afterEach(async () => { - await proxy?.stop(); - await new Promise(resolve => upstream.close(() => resolve())); - }); - - async function rpc(url: string, body: unknown): Promise { - const res = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); - return res.json(); - } - - it('forwards requests to the upstream and returns its response', async () => { - proxy = await startLatencyProxy(upstreamUrl); - const result = await rpc(proxy.url, { jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }); - expect(result).toEqual({ jsonrpc: '2.0', id: 1, result: '0x2a' }); - expect(upstreamRequests).toHaveLength(1); - }); - - it('applies the configured per-request delay before forwarding', async () => { - proxy = await startLatencyProxy(upstreamUrl, 0); - proxy.setDelayMs(150); - const start = Date.now(); - await rpc(proxy.url, { jsonrpc: '2.0', id: 1, method: 'eth_call', params: [] }); - expect(Date.now() - start).toBeGreaterThanOrEqual(140); - }); - - it('counts requests per JSON-RPC method, including batches, and resets counters', async () => { - proxy = await startLatencyProxy(upstreamUrl); - await rpc(proxy.url, { jsonrpc: '2.0', id: 1, method: 'eth_call', params: [] }); - await rpc(proxy.url, { jsonrpc: '2.0', id: 2, method: 'eth_call', params: [] }); - await rpc(proxy.url, [ - { jsonrpc: '2.0', id: 3, method: 'eth_getBlockByNumber', params: [] }, - { jsonrpc: '2.0', id: 4, method: 'eth_call', params: [] }, - ]); - - expect(proxy.getRequestCount('eth_call')).toBe(3); - expect(proxy.getRequestCount('eth_getBlockByNumber')).toBe(1); - expect(proxy.getRequestCount()).toBe(4); - - proxy.resetCounts(); - expect(proxy.getRequestCount()).toBe(0); - expect(proxy.getRequestCount('eth_call')).toBe(0); - }); - - it('supports async-disposable teardown', async () => { - let url: string; - { - await using disposable = await startLatencyProxy(upstreamUrl); - url = disposable.url; - const result = await rpc(url, { jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] }); - expect(result).toEqual({ jsonrpc: '2.0', id: 1, result: '0x2a' }); - } - // After disposal the server is closed and no longer accepts connections. - await expect(fetch(url, { method: 'POST', body: '{}' })).rejects.toThrow(); - }); -}); diff --git a/yarn-project/end-to-end/src/fixtures/latency_proxy.ts b/yarn-project/end-to-end/src/fixtures/latency_proxy.ts deleted file mode 100644 index be7239874f20..000000000000 --- a/yarn-project/end-to-end/src/fixtures/latency_proxy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createLogger } from '@aztec/aztec.js/log'; - -import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http'; -import type { AddressInfo } from 'node:net'; - -const logger = createLogger('e2e:latency-proxy'); - -/** - * A small HTTP JSON-RPC reverse proxy that forwards requests to a target RPC, optionally after a configurable - * per-request delay, while counting requests per JSON-RPC method. Used by benchmarks to inject L1 latency and - * to observe how many L1 requests a code path issues. Not for production use. - */ -export interface LatencyProxy extends AsyncDisposable { - /** The URL callers should point their L1 client at. */ - readonly url: string; - /** Sets the artificial per-request delay applied before forwarding. */ - setDelayMs(ms: number): void; - /** Returns the request count for a specific JSON-RPC method, or the total across all methods when omitted. */ - getRequestCount(method?: string): number; - /** Resets all per-method counters to zero. */ - resetCounts(): void; - /** Stops the proxy server. */ - stop(): Promise; -} - -/** - * Starts a latency proxy in front of `targetUrl`. - * @param targetUrl - The upstream JSON-RPC endpoint to forward to. - * @param initialDelayMs - The initial artificial delay applied to each request (default 0). - */ -export async function startLatencyProxy(targetUrl: string, initialDelayMs = 0): Promise { - let delayMs = initialDelayMs; - const counts = new Map(); - - const record = (body: string) => { - try { - const parsed = JSON.parse(body); - const entries = Array.isArray(parsed) ? parsed : [parsed]; - for (const entry of entries) { - const method = typeof entry?.method === 'string' ? entry.method : 'unknown'; - counts.set(method, (counts.get(method) ?? 0) + 1); - } - } catch { - counts.set('unparseable', (counts.get('unparseable') ?? 0) + 1); - } - }; - - const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { - const chunks: Buffer[] = []; - req.on('data', chunk => chunks.push(chunk as Buffer)); - req.on('end', () => { - void (async () => { - const body = Buffer.concat(chunks).toString('utf-8'); - record(body); - if (delayMs > 0) { - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - try { - const upstream = await fetch(targetUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body, - }); - const text = await upstream.text(); - res.writeHead(upstream.status, { 'content-type': 'application/json' }); - res.end(text); - } catch (err) { - logger.error(`Latency proxy upstream request failed`, err); - res.writeHead(502, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'proxy upstream error' } })); - } - })(); - }); - }); - - await new Promise((resolve, reject) => { - server.on('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - - const address = server.address() as AddressInfo; - const url = `http://127.0.0.1:${address.port}`; - logger.info(`Latency proxy listening`, { url, targetUrl, initialDelayMs }); - - const stop = () => - new Promise((resolve, reject) => { - if (!server.listening) { - resolve(); - return; - } - server.close(err => (err ? reject(err) : resolve())); - }); - - return { - url, - setDelayMs(ms: number) { - delayMs = ms; - }, - getRequestCount(method?: string) { - if (method === undefined) { - return [...counts.values()].reduce((a, b) => a + b, 0); - } - return counts.get(method) ?? 0; - }, - resetCounts() { - counts.clear(); - }, - stop, - async [Symbol.asyncDispose]() { - await stop(); - }, - }; -} diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index bd908bd2f646..7110360a63a2 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -22,13 +22,11 @@ import { type StateOverride, type WatchContractEventReturnType, decodeErrorResult, - decodeFunctionResult, encodeAbiParameters, encodeFunctionData, getContract, hexToBigInt, keccak256, - multicall3Abi, } from 'viem'; import { getPublicClient } from '../client.js'; @@ -137,30 +135,27 @@ export type L1FeeData = { blobFee: bigint; }; -/** - * A single fee-model read to batch into one pinned multicall. Every entry is a pure view function whose - * result feeds the fee snapshot; batching them into one `eth_call` gives a consistent per-wave view. - */ -export type RollupFeeRead = - | { kind: 'tips' } - | { kind: 'manaTarget' } - | { kind: 'manaLimit' } - | { kind: 'provingCostPerManaEth' } - | { kind: 'manaMinFeeAt'; timestamp: bigint } - | { kind: 'canPruneAtTime'; timestamp: bigint } - | { kind: 'l1FeesAt'; timestamp: bigint } - | { kind: 'checkpoint'; checkpointNumber: CheckpointNumber }; - -/** Decoded result of a {@link RollupFeeRead}, tagged with the same `kind` and echoing the read's key params. */ -export type RollupFeeReadResult = - | { kind: 'tips'; value: { pending: CheckpointNumber; proven: CheckpointNumber } } - | { kind: 'manaTarget'; value: bigint } - | { kind: 'manaLimit'; value: bigint } - | { kind: 'provingCostPerManaEth'; value: bigint } - | { kind: 'manaMinFeeAt'; timestamp: bigint; value: bigint } - | { kind: 'canPruneAtTime'; timestamp: bigint; value: boolean } - | { kind: 'l1FeesAt'; timestamp: bigint; value: L1FeeData } - | { kind: 'checkpoint'; checkpointNumber: CheckpointNumber; value: CheckpointLog }; +/** Pending and proven checkpoint numbers, as reported together by the rollup's `getTips`. */ +export type RollupChainTips = { + pending: CheckpointNumber; + proven: CheckpointNumber; +}; + +/** Chain tips plus the governance-settable fee parameters, read as one batch at a pinned L1 block. */ +export type RollupFeeGlobals = { + tips: RollupChainTips; + manaTarget: bigint; + manaLimit: bigint; + provingCostPerManaEth: bigint; +}; + +/** Per-timestamp fee inputs: the canonical current fee and whether a prune applies at that timestamp. */ +export type RollupSlotFeeInputs = { + /** Solidity `getManaMinFeeAt(timestamp, true)`. */ + manaMinFee: bigint; + /** Solidity `canPruneAtTime(timestamp)`, which selects the effective parent checkpoint. */ + canPrune: boolean; +}; /** Field offsets within the CompressedTempCheckpointLog struct in Solidity storage. */ export enum TempCheckpointLogField { @@ -298,7 +293,7 @@ export class RollupContract { address: EthAddress; contract: GetContractReturnType; }; - /** Cached feature-detection of Multicall3 bytecode presence, used by {@link readFeeInputs}. */ + /** Cached feature-detection of Multicall3 bytecode presence, used by the batched fee reads. */ private multicall3Available: boolean | undefined; static get checkBlobStorageSlot(): bigint { @@ -713,22 +708,7 @@ export class RollupContract { async getCheckpoint(checkpointNumber: CheckpointNumber, options?: { blockNumber?: bigint }): Promise { await checkBlockTag(options?.blockNumber, this.client); - const result = await this.rollup.read.getCheckpoint([BigInt(checkpointNumber)], options); - return { - archive: Fr.fromString(result.archive), - headerHash: Buffer32.fromString(result.headerHash), - blobCommitmentsHash: Buffer32.fromString(result.blobCommitmentsHash), - attestationsHash: Buffer32.fromString(result.attestationsHash), - payloadDigest: Buffer32.fromString(result.payloadDigest), - slotNumber: SlotNumber.fromBigInt(result.slotNumber), - feeHeader: { - excessMana: result.feeHeader.excessMana, - manaUsed: result.feeHeader.manaUsed, - ethPerFeeAsset: result.feeHeader.ethPerFeeAsset, - congestionCost: result.feeHeader.congestionCost, - proverCost: result.feeHeader.proverCost, - }, - }; + return toCheckpointLog(await this.rollup.read.getCheckpoint([BigInt(checkpointNumber)], options)); } /** Returns the pending checkpoint from the rollup contract */ @@ -770,13 +750,9 @@ export class RollupContract { ); } - async getTips(options?: { blockNumber?: bigint }): Promise<{ pending: CheckpointNumber; proven: CheckpointNumber }> { + async getTips(options?: { blockNumber?: bigint }): Promise { await checkBlockTag(options?.blockNumber, this.client); - const { pending, proven } = await this.rollup.read.getTips(options); - return { - pending: CheckpointNumber.fromBigInt(pending), - proven: CheckpointNumber.fromBigInt(proven), - }; + return toChainTips(await this.rollup.read.getTips(options)); } getTimestampForSlot(slot: SlotNumber): Promise { @@ -1213,72 +1189,136 @@ export class RollupContract { } /** - * Reads a batch of fee-model view functions pinned to a single L1 block. When Multicall3 is available the - * whole batch is a single `eth_call` (one atomic per-backend view via `aggregate3` with `allowFailure: false`); - * otherwise it falls back to parallel individual pinned calls, which is weaker (a single logical batch may then - * mix fallback-transport backends). Results are returned in the same order as the requests. + * Shared parameters for the pinned fee multicalls below. `batchSize: 0` disables viem's calldata chunking so + * every batch stays a single `eth_call`: a chunked batch would be several calls, which the fallback transport + * may serve from different backends, losing the atomic per-stage view. */ - async readFeeInputs( - reads: RollupFeeRead[], - options: { blockNumber: bigint; allowMulticall?: boolean }, - ): Promise { - const allowMulticall = options.allowMulticall ?? true; - if (allowMulticall && (await this.hasMulticall3())) { - return this.readFeeInputsViaMulticall(reads, options.blockNumber); - } - return Promise.all(reads.map(read => this.readSingleFeeInput(read, options.blockNumber))); + private multicallOptions(blockNumber: bigint) { + return { + allowFailure: false as const, + blockNumber, + batchSize: 0, + multicallAddress: MULTI_CALL_3_ADDRESS, + }; } - private async readFeeInputsViaMulticall(reads: RollupFeeRead[], blockNumber: bigint): Promise { - const calls = reads.map(read => ({ - target: this.address, - allowFailure: false, - callData: encodeFeeRead(read), - })); - const aggregateData = encodeFunctionData({ abi: multicall3Abi, functionName: 'aggregate3', args: [calls] }); - const { data } = await this.client.call({ to: MULTI_CALL_3_ADDRESS, data: aggregateData, blockNumber }); - if (!data) { - throw new Error('Multicall3 aggregate3 returned no data'); + /** Reads the chain tips together with the governance-settable fee parameters, pinned to one L1 block. */ + async getFeeGlobals(options: { blockNumber: bigint }): Promise { + if (await this.hasMulticall3()) { + const [tips, manaTarget, manaLimit, provingCostPerManaEth] = await this.client.multicall({ + contracts: [ + { address: this.address, abi: RollupAbi, functionName: 'getTips' }, + { address: this.address, abi: RollupAbi, functionName: 'getManaTarget' }, + { address: this.address, abi: RollupAbi, functionName: 'getManaLimit' }, + { address: this.address, abi: RollupAbi, functionName: 'getProvingCostPerManaInEth' }, + ], + ...this.multicallOptions(options.blockNumber), + }); + return { tips: toChainTips(tips), manaTarget, manaLimit, provingCostPerManaEth }; } - const entries = decodeFunctionResult({ abi: multicall3Abi, functionName: 'aggregate3', data }); - if (entries.length !== reads.length) { - throw new Error(`Multicall3 returned ${entries.length} results for ${reads.length} requests`); + const [tips, manaTarget, manaLimit, provingCostPerManaEth] = await Promise.all([ + this.getTips(options), + this.readManaTarget(options), + this.readManaLimit(options), + this.readProvingCostPerManaInEth(options), + ]); + return { tips, manaTarget, manaLimit, provingCostPerManaEth }; + } + + /** Reads several checkpoint logs pinned to one L1 block, in the order requested. */ + async getCheckpoints( + checkpointNumbers: CheckpointNumber[], + options: { blockNumber: bigint }, + ): Promise { + if (await this.hasMulticall3()) { + const results = await this.client.multicall({ + contracts: checkpointNumbers.map(checkpointNumber => ({ + address: this.address, + abi: RollupAbi, + functionName: 'getCheckpoint' as const, + args: [BigInt(checkpointNumber)] as const, + })), + ...this.multicallOptions(options.blockNumber), + }); + return results.map(toCheckpointLog); } - return reads.map((read, i) => decodeFeeReadResult(read, entries[i].returnData)); - } - - private async readSingleFeeInput(read: RollupFeeRead, blockNumber: bigint): Promise { - const opts = { blockNumber }; - switch (read.kind) { - case 'tips': - return { kind: 'tips', value: await this.getTips(opts) }; - case 'manaTarget': - return { kind: 'manaTarget', value: await this.readManaTarget(opts) }; - case 'manaLimit': - return { kind: 'manaLimit', value: await this.readManaLimit(opts) }; - case 'provingCostPerManaEth': - return { kind: 'provingCostPerManaEth', value: await this.readProvingCostPerManaInEth(opts) }; - case 'manaMinFeeAt': - return { - kind: 'manaMinFeeAt', - timestamp: read.timestamp, - value: await this.getManaMinFeeAt(read.timestamp, true, opts), - }; - case 'canPruneAtTime': - return { - kind: 'canPruneAtTime', - timestamp: read.timestamp, - value: await this.canPruneAtTime(read.timestamp, opts), - }; - case 'l1FeesAt': - return { kind: 'l1FeesAt', timestamp: read.timestamp, value: await this.getL1FeesAt(read.timestamp, opts) }; - case 'checkpoint': - return { - kind: 'checkpoint', - checkpointNumber: read.checkpointNumber, - value: await this.getCheckpoint(read.checkpointNumber, opts), - }; + return Promise.all(checkpointNumbers.map(checkpointNumber => this.getCheckpoint(checkpointNumber, options))); + } + + /** Reads the current min fee and prune-ability at each given timestamp, pinned to one L1 block. */ + async getSlotFeeInputs(timestamps: bigint[], options: { blockNumber: bigint }): Promise { + if (await this.hasMulticall3()) { + const results = await this.client.multicall({ + contracts: [ + ...timestamps.map(timestamp => ({ + address: this.address, + abi: RollupAbi, + functionName: 'getManaMinFeeAt' as const, + args: [timestamp, true] as const, + })), + ...timestamps.map(timestamp => ({ + address: this.address, + abi: RollupAbi, + functionName: 'canPruneAtTime' as const, + args: [timestamp] as const, + })), + ], + ...this.multicallOptions(options.blockNumber), + }); + return timestamps.map((_, i) => { + const manaMinFee = results[i]; + const canPrune = results[timestamps.length + i]; + if (typeof manaMinFee !== 'bigint' || typeof canPrune !== 'boolean') { + throw new Error('Unexpected multicall result shapes for the per-slot fee reads'); + } + return { manaMinFee, canPrune }; + }); } + const [manaMinFees, prunes] = await Promise.all([ + Promise.all(timestamps.map(timestamp => this.getManaMinFeeAt(timestamp, true, options))), + Promise.all(timestamps.map(timestamp => this.canPruneAtTime(timestamp, options))), + ]); + return timestamps.map((_, i) => ({ manaMinFee: manaMinFees[i], canPrune: prunes[i] })); + } + + /** + * Reads the L1 fee oracle at each given timestamp plus a trailing `getTips`, pinned to one L1 block. The tips + * are returned so a caller batching several stages can detect that its earlier stages saw a different state. + */ + async getL1FeesAndTips( + timestamps: bigint[], + options: { blockNumber: bigint }, + ): Promise<{ l1Fees: L1FeeData[]; tips: RollupChainTips }> { + if (await this.hasMulticall3()) { + const results = await this.client.multicall({ + contracts: [ + ...timestamps.map(timestamp => ({ + address: this.address, + abi: RollupAbi, + functionName: 'getL1FeesAt' as const, + args: [timestamp] as const, + })), + { address: this.address, abi: RollupAbi, functionName: 'getTips' as const }, + ], + ...this.multicallOptions(options.blockNumber), + }); + const l1Fees = results.slice(0, timestamps.length).map(result => { + if (!('baseFee' in result)) { + throw new Error('Unexpected multicall result shape for an L1 fees read'); + } + return { baseFee: result.baseFee, blobFee: result.blobFee }; + }); + const tail = results[timestamps.length]; + if (!('pending' in tail)) { + throw new Error('Unexpected multicall result shape for the trailing tips read'); + } + return { l1Fees, tips: toChainTips(tail) }; + } + const [l1Fees, tips] = await Promise.all([ + Promise.all(timestamps.map(timestamp => this.getL1FeesAt(timestamp, options))), + this.getTips(options), + ]); + return { l1Fees, tips }; } async getManaMinFeeComponentsAt(timestamp: bigint, inFeeAsset: boolean): Promise { @@ -1581,95 +1621,37 @@ export class RollupContract { } } -/** Encodes a {@link RollupFeeRead} into calldata using literal function names so the ABI types resolve. */ -function encodeFeeRead(read: RollupFeeRead): Hex { - switch (read.kind) { - case 'tips': - return encodeFunctionData({ abi: RollupAbi, functionName: 'getTips' }); - case 'manaTarget': - return encodeFunctionData({ abi: RollupAbi, functionName: 'getManaTarget' }); - case 'manaLimit': - return encodeFunctionData({ abi: RollupAbi, functionName: 'getManaLimit' }); - case 'provingCostPerManaEth': - return encodeFunctionData({ abi: RollupAbi, functionName: 'getProvingCostPerManaInEth' }); - case 'manaMinFeeAt': - return encodeFunctionData({ abi: RollupAbi, functionName: 'getManaMinFeeAt', args: [read.timestamp, true] }); - case 'canPruneAtTime': - return encodeFunctionData({ abi: RollupAbi, functionName: 'canPruneAtTime', args: [read.timestamp] }); - case 'l1FeesAt': - return encodeFunctionData({ abi: RollupAbi, functionName: 'getL1FeesAt', args: [read.timestamp] }); - case 'checkpoint': - return encodeFunctionData({ - abi: RollupAbi, - functionName: 'getCheckpoint', - args: [BigInt(read.checkpointNumber)], - }); - } +/** Converts the raw `getTips` tuple into branded checkpoint numbers. */ +function toChainTips(tips: { pending: bigint; proven: bigint }): RollupChainTips { + return { + pending: CheckpointNumber.fromBigInt(tips.pending), + proven: CheckpointNumber.fromBigInt(tips.proven), + }; } -/** Decodes one Multicall3 sub-call return value back into a typed {@link RollupFeeReadResult}. */ -function decodeFeeReadResult(read: RollupFeeRead, data: Hex): RollupFeeReadResult { - switch (read.kind) { - case 'tips': { - const { pending, proven } = decodeFunctionResult({ abi: RollupAbi, functionName: 'getTips', data }); - return { - kind: 'tips', - value: { pending: CheckpointNumber.fromBigInt(pending), proven: CheckpointNumber.fromBigInt(proven) }, - }; - } - case 'manaTarget': - return { - kind: 'manaTarget', - value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getManaTarget', data }), - }; - case 'manaLimit': - return { kind: 'manaLimit', value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getManaLimit', data }) }; - case 'provingCostPerManaEth': - return { - kind: 'provingCostPerManaEth', - value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getProvingCostPerManaInEth', data }), - }; - case 'manaMinFeeAt': - return { - kind: 'manaMinFeeAt', - timestamp: read.timestamp, - value: decodeFunctionResult({ abi: RollupAbi, functionName: 'getManaMinFeeAt', data }), - }; - case 'canPruneAtTime': - return { - kind: 'canPruneAtTime', - timestamp: read.timestamp, - value: decodeFunctionResult({ abi: RollupAbi, functionName: 'canPruneAtTime', data }), - }; - case 'l1FeesAt': { - const result = decodeFunctionResult({ abi: RollupAbi, functionName: 'getL1FeesAt', data }); - return { - kind: 'l1FeesAt', - timestamp: read.timestamp, - value: { baseFee: result.baseFee, blobFee: result.blobFee }, - }; - } - case 'checkpoint': { - const result = decodeFunctionResult({ abi: RollupAbi, functionName: 'getCheckpoint', data }); - return { - kind: 'checkpoint', - checkpointNumber: read.checkpointNumber, - value: { - archive: Fr.fromString(result.archive), - headerHash: Buffer32.fromString(result.headerHash), - blobCommitmentsHash: Buffer32.fromString(result.blobCommitmentsHash), - attestationsHash: Buffer32.fromString(result.attestationsHash), - payloadDigest: Buffer32.fromString(result.payloadDigest), - slotNumber: SlotNumber.fromBigInt(result.slotNumber), - feeHeader: { - excessMana: result.feeHeader.excessMana, - manaUsed: result.feeHeader.manaUsed, - ethPerFeeAsset: result.feeHeader.ethPerFeeAsset, - congestionCost: result.feeHeader.congestionCost, - proverCost: result.feeHeader.proverCost, - }, - }, - }; - } - } +/** Converts a raw `getCheckpoint` struct into a {@link CheckpointLog}. */ +function toCheckpointLog(result: { + archive: Hex; + headerHash: Hex; + blobCommitmentsHash: Hex; + attestationsHash: Hex; + payloadDigest: Hex; + slotNumber: bigint; + feeHeader: FeeHeader; +}): CheckpointLog { + return { + archive: Fr.fromString(result.archive), + headerHash: Buffer32.fromString(result.headerHash), + blobCommitmentsHash: Buffer32.fromString(result.blobCommitmentsHash), + attestationsHash: Buffer32.fromString(result.attestationsHash), + payloadDigest: Buffer32.fromString(result.payloadDigest), + slotNumber: SlotNumber.fromBigInt(result.slotNumber), + feeHeader: { + excessMana: result.feeHeader.excessMana, + manaUsed: result.feeHeader.manaUsed, + ethPerFeeAsset: result.feeHeader.ethPerFeeAsset, + congestionCost: result.feeHeader.congestionCost, + proverCost: result.feeHeader.proverCost, + }, + }; } diff --git a/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts index c81245ba96ed..35141823bc01 100644 --- a/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts +++ b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts @@ -1,6 +1,4 @@ -import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; @@ -9,9 +7,9 @@ import { foundry } from 'viem/chains'; import { getPublicClient } from '../client.js'; import { DefaultL1ContractsConfig } from '../config.js'; import { deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; -import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '../test/index.js'; +import { type Anvil, EthCheatCodes, startAnvil } from '../test/index.js'; import type { ViemClient } from '../types.js'; -import { type FeeHeader, RollupContract, type RollupFeeRead, TempCheckpointLogField } from './rollup.js'; +import { RollupContract } from './rollup.js'; // Sequential execution: the ethereum package shares Anvil ports across suites. describe('RollupContract fee reads', () => { @@ -19,7 +17,8 @@ describe('RollupContract fee reads', () => { let rpcUrl: string; let publicClient: ViemClient; let rollup: RollupContract; - let rollupCheatCodes: RollupCheatCodes; + /** Same rollup read through a client that reports no Multicall3 bytecode, so it takes the fallback path. */ + let rollupWithoutMulticall: RollupContract; let cheatCodes: EthCheatCodes; let slotDuration: number; let l1GenesisTime: bigint; @@ -38,8 +37,12 @@ describe('RollupContract fee reads', () => { realVerifier: false, }); - rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); - rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployed.l1ContractAddresses, new DateProvider()); + const rollupAddress = deployed.l1ContractAddresses.rollupAddress.toString(); + rollup = new RollupContract(publicClient, rollupAddress); + rollupWithoutMulticall = new RollupContract( + { ...publicClient, getCode: () => Promise.resolve(undefined) }, + rollupAddress, + ); slotDuration = await rollup.getSlotDuration(); l1GenesisTime = await rollup.getL1GenesisTime(); }, 60_000); @@ -53,52 +56,24 @@ describe('RollupContract fee reads', () => { return l1GenesisTime + BigInt(slot) * BigInt(slotDuration); } - /** Writes a checkpoint log (slot + fee header) into the Rollup's temp-checkpoint circular buffer via storage. */ - async function writeCheckpointLog(checkpointNumber: CheckpointNumber, slot: number, feeHeader: FeeHeader) { - const address = EthAddress.fromString(rollup.address); - const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( - checkpointNumber, - TempCheckpointLogField.FeeHeader, + it('reads every fee stage via Multicall3 identically to the parallel individual fallback', async () => { + const blockNumber = await publicClient.getBlockNumber(); + const options = { blockNumber }; + const { pending, proven } = await rollup.getTips(options); + const currentSlot = await rollup.getSlotNumber(options); + const timestamps = [tsForSlot(Number(currentSlot) + 1), tsForSlot(Number(currentSlot) + 2)]; + + expect(await rollup.getFeeGlobals(options)).toEqual(await rollupWithoutMulticall.getFeeGlobals(options)); + expect(await rollup.getCheckpoints([pending, proven], options)).toEqual( + await rollupWithoutMulticall.getCheckpoints([pending, proven], options), ); - await cheatCodes.store(address, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); - const slotNumberSlot = await rollup.getTempCheckpointLogStorageSlot( - checkpointNumber, - TempCheckpointLogField.SlotNumber, + expect(await rollup.getSlotFeeInputs(timestamps, options)).toEqual( + await rollupWithoutMulticall.getSlotFeeInputs(timestamps, options), ); - await cheatCodes.store(address, slotNumberSlot, BigInt(slot) & ((1n << 32n) - 1n)); - } - - /** Sets the chain tips (pending, proven) directly via storage. */ - async function setTips(pending: CheckpointNumber, proven: CheckpointNumber) { - await cheatCodes.store( - EthAddress.fromString(rollup.address), - RollupContract.chainTipsStorageSlot, - RollupContract.packChainTips(BigInt(pending), BigInt(proven)), + expect(await rollup.getL1FeesAndTips(timestamps, options)).toEqual( + await rollupWithoutMulticall.getL1FeesAndTips(timestamps, options), ); - } - - it('reads via Multicall3 identically to parallel individual pinned reads', async () => { - const blockNumber = await publicClient.getBlockNumber(); - const { pending } = await rollup.getTips({ blockNumber }); - const currentSlot = await rollup.getSlotNumber({ blockNumber }); - const ts = tsForSlot(Number(currentSlot) + 1); - - const reads: RollupFeeRead[] = [ - { kind: 'tips' }, - { kind: 'manaTarget' }, - { kind: 'manaLimit' }, - { kind: 'provingCostPerManaEth' }, - { kind: 'manaMinFeeAt', timestamp: ts }, - { kind: 'canPruneAtTime', timestamp: ts }, - { kind: 'l1FeesAt', timestamp: ts }, - { kind: 'checkpoint', checkpointNumber: pending }, - ]; - - const viaMulticall = await rollup.readFeeInputs(reads, { blockNumber, allowMulticall: true }); - const viaIndividual = await rollup.readFeeInputs(reads, { blockNumber, allowMulticall: false }); - - expect(viaMulticall).toEqual(viaIndividual); - }); + }, 30_000); it('pins getManaMinFeeAt to a block number so later blocks do not change the read', async () => { const blockNumber = await publicClient.getBlockNumber(); @@ -132,58 +107,4 @@ describe('RollupContract fee reads', () => { expect(pinned.pending).toBe(latest.pending); expect(pinned.proven).toBe(latest.proven); }); - - describe('checkpoint-slot invariant', () => { - it('holds pendingCheckpointSlot <= slot of the pinned block timestamp across advancing states', async () => { - for (let i = 0; i < 3; i++) { - const block = await publicClient.getBlock(); - const blockNumber = block.number!; - const { pending } = await rollup.getTips({ blockNumber }); - const pendingCheckpoint = await rollup.getCheckpoint(pending, { blockNumber }); - const pinnedSlot = SlotNumber.fromBigInt((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); - // A proposed checkpoint's slot equals the slot of its L1 inclusion timestamp, so it can never exceed - // the slot of any later pinned block (ProposeLib require(slot == currentSlot)). - expect(Number(pendingCheckpoint.slotNumber)).toBeLessThanOrEqual(Number(pinnedSlot)); - await rollupCheatCodes.advanceSlots(2); - await cheatCodes.mine(); - } - }, 30_000); - - it('holds with a pending checkpoint written at a non-zero slot across advancing states', async () => { - // Write a real pending checkpoint at the current (non-zero) slot so the invariant is exercised with a - // meaningful pendingCheckpointSlot rather than the genesis slot 0. - const startBlock = await publicClient.getBlock(); - const startSlot = Number((startBlock.timestamp - l1GenesisTime) / BigInt(slotDuration)); - expect(startSlot).toBeGreaterThan(0); - - const feeHeader: FeeHeader = { - excessMana: 0n, - manaUsed: 0n, - ethPerFeeAsset: 1_000_000_000_000n, - congestionCost: 0n, - proverCost: 0n, - }; - await writeCheckpointLog(CheckpointNumber(1), startSlot, feeHeader); - await setTips(CheckpointNumber(1), CheckpointNumber(0)); - - try { - for (let i = 0; i < 3; i++) { - const block = await publicClient.getBlock(); - const blockNumber = block.number!; - const { pending } = await rollup.getTips({ blockNumber }); - expect(pending).toBe(CheckpointNumber(1)); - const pendingCheckpoint = await rollup.getCheckpoint(pending, { blockNumber }); - const pinnedSlot = SlotNumber.fromBigInt((block.timestamp - l1GenesisTime) / BigInt(slotDuration)); - // The written pending checkpoint sits at a non-zero slot and never exceeds the pinned block's slot. - expect(Number(pendingCheckpoint.slotNumber)).toBe(startSlot); - expect(Number(pendingCheckpoint.slotNumber)).toBeLessThanOrEqual(Number(pinnedSlot)); - await rollupCheatCodes.advanceSlots(2); - await cheatCodes.mine(); - } - } finally { - // Restore genesis tips so the written state does not leak into other tests. - await setTips(CheckpointNumber(0), CheckpointNumber(0)); - } - }, 30_000); - }); }); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts index af1b7f1c0c69..2ceed40ff468 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts @@ -12,8 +12,8 @@ import { /** * Resolved rollup state for a single prediction anchor slot. Every field is a finished input to the pure - * fee math below; nothing here reads L1. The snapshot service and the test-only legacy oracle both build - * this from pinned reads and then feed it to {@link computePredictions}. + * fee math below; nothing here reads L1. Callers build this from pinned reads and then feed it to + * {@link computePredictions}. */ export type FeeOracleState = { /** Slot of the effective parent checkpoint (prune-aware) at the anchor timestamp. */ @@ -31,29 +31,33 @@ export type FeeOracleState = { }; /** - * Builds a {@link FeeOracleState} for a prediction anchored at `anchorSlot`, using already-fetched checkpoints, - * governance values, and L1 fees. Mirrors the state assembly `FeePredictor.fetchState` used to do inline, but - * takes every input explicitly so it is a pure function shared by the snapshot service and the legacy oracle. + * The slots a prediction anchored at `anchorSlot` needs L1 fee oracle values for. The window starts at the slot + * after the effective parent, but never before the anchor slot. + */ +export function getPredictionWindowSlots(anchorSlot: SlotNumber, effectiveParentSlot: SlotNumber): SlotNumber[] { + const nextSlot = SlotNumber(Math.max(SlotNumber.add(effectiveParentSlot, 1), anchorSlot)); + return times(FEE_ORACLE_LAG, i => SlotNumber.add(nextSlot, i)); +} + +/** + * Builds a {@link FeeOracleState} for a prediction anchored at `anchorSlot`, using the already-selected + * prune-aware effective parent checkpoint, governance values, and L1 fees. Mirrors the state assembly + * `FeePredictor.fetchState` used to do inline, but takes every input explicitly so it is a pure function. * * @param l1FeesForSlot - Resolves the L1 fees at a slot from the caller's pre-fetched map; throws if missing. */ export function buildFeeOracleState(params: { anchorSlot: SlotNumber; - canPrune: boolean; - pendingCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; - provenCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; + effectiveParent: { slotNumber: SlotNumber; feeHeader: FeeHeader }; manaTarget: bigint; manaLimit: bigint; provingCostPerManaEth: bigint; epochDuration: bigint; l1FeesForSlot: (slot: SlotNumber) => L1FeeData; }): FeeOracleState { - const effectiveParent = params.canPrune ? params.provenCheckpoint : params.pendingCheckpoint; - const lastSlot = effectiveParent.slotNumber; - // The prediction starts at the slot after the effective parent, but never before the anchor slot. - const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), params.anchorSlot)); - const feeHeader = effectiveParent.feeHeader; - const l1FeesBySlot = times(FEE_ORACLE_LAG, i => params.l1FeesForSlot(SlotNumber.add(nextSlot, i))); + const lastSlot = params.effectiveParent.slotNumber; + const feeHeader = params.effectiveParent.feeHeader; + const l1FeesBySlot = getPredictionWindowSlots(params.anchorSlot, lastSlot).map(params.l1FeesForSlot); return { lastSlot, diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts deleted file mode 100644 index f2c022d667aa..000000000000 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { L1SyncSnapshot } from '@aztec/ethereum/l1-types'; -import type { SlotNumber } from '@aztec/foundation/branded-types'; -import type { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; - -/** - * One complete, finished fee quote for a single candidate slot. Entries are outputs, never partially derived - * state: both the current fee and all three prediction arrays are precomputed at refresh time so the read path - * issues zero L1 requests and merges only complete arrays. - */ -export type FeeQuoteCandidate = { - /** The candidate L2 slot this quote is for. */ - slot: SlotNumber; - /** Slot-start timestamp used for the pinned reads. */ - timestamp: bigint; - /** Canonical current fee: Solidity `getManaMinFeeAt(timestamp)` at the pinned block. */ - currentMinFee: GasFees; - /** Precomputed `FEE_ORACLE_LAG`-length prediction array per mana-usage estimate. */ - predictions: Record; -}; - -/** - * Immutable, atomically-swapped view of the fee model at a single pinned L1 block. Selection floors come only - * from the snapshot-level fields (`pendingCheckpointSlot`, `pinnedSlot`); no per-candidate selection state. - */ -export type FeeSnapshot = { - /** L1 identity this snapshot was built at (block number + hash + timestamp). */ - l1: L1SyncSnapshot; - /** Raw pending checkpoint slot at the pinned block — floor for the current-fee anchor rule. */ - pendingCheckpointSlot: SlotNumber; - /** Slot of the pinned block timestamp (TS arithmetic) — L1-side floor for the prediction anchor rule. */ - pinnedSlot: SlotNumber; - /** One complete entry per materialized candidate slot, keyed by the primitive slot number. */ - candidates: ReadonlyMap; - /** Lowest slot of the contiguous materialized window (informational; coverage uses map membership). */ - baseSlot: SlotNumber; - /** Highest slot of the contiguous materialized window (informational; coverage uses map membership). */ - topSlot: SlotNumber; - /** Computation-age anchor (DateProvider ms). Reset by every successful refresh, including coverage-only. */ - refreshedAtMs: number; -}; - -/** Tuning and staleness configuration for the fee snapshot service. All durations use their stated units. */ -export type FeeSnapshotServiceConfig = { - slotDuration: number; - l1GenesisTime: bigint; - ethereumSlotDuration: number; - epochDuration: number; - /** Symmetric wall-clock error allowance (seconds) that widens the read-time window. `0` disables the window. */ - clockDriftAllowanceSeconds: number; - /** Extra slots added above the wanted window so empty-Ethereum-slot runs do not freeze quotes. */ - coverageHeadroomSlots: number; - /** Hard cap on the contiguous provisional window size; larger windows are materialized capped + topped up. */ - maxCandidateWindowSlots: number; - /** Hard cap on distinct enumerated candidate slots per rule; a drift producing more is rejected at startup. */ - maxClockCandidates: number; - /** Background poll interval (ms) for the refresh loop; only in-memory comparisons run per tick. */ - pollIntervalMs: number; - /** Max age (ms) of the last successful refresh before reads fail closed. `0` disables. */ - maxRefreshAgeMs: number; - /** Max age (seconds) of the pinned L1 head before reads fail closed. `0` disables. */ - maxL1HeadAgeSeconds: number; - /** Max seconds the pinned L1 head may be dated ahead of the wall clock before reads fail closed. `0` disables. */ - futureHeadAllowanceSeconds: number; - /** Short bound (ms) a read waits for an in-flight/triggered refresh before failing closed with a typed error. */ - refreshTimeoutMs: number; -}; - -/** Derives the default fee snapshot service config from the L1 timing constants. */ -export function getDefaultFeeSnapshotServiceConfig(base: { - slotDuration: number; - l1GenesisTime: bigint; - ethereumSlotDuration: number; - epochDuration: number; -}): FeeSnapshotServiceConfig { - const clockDriftAllowanceSeconds = 2; - return { - ...base, - clockDriftAllowanceSeconds, - coverageHeadroomSlots: 2, - maxCandidateWindowSlots: 16, - maxClockCandidates: 8, - pollIntervalMs: 150, - maxRefreshAgeMs: 60_000, - maxL1HeadAgeSeconds: 300, - futureHeadAllowanceSeconds: 2 * base.ethereumSlotDuration + clockDriftAllowanceSeconds, - refreshTimeoutMs: 5_000, - }; -} - -/** Base class for all fee snapshot errors, so callers can catch the whole family. */ -export class FeeSnapshotError extends Error { - constructor(message: string) { - super(message); - this.name = 'FeeSnapshotError'; - } -} - -/** Invalid service configuration detected at startup (e.g. drift produces more candidates than the cap allows). */ -export class FeeSnapshotConfigError extends FeeSnapshotError { - constructor(message: string) { - super(message); - this.name = 'FeeSnapshotConfigError'; - } -} - -/** No snapshot is available yet (first refresh not completed) within the caller's bound. */ -export class FeeSnapshotUnavailableError extends FeeSnapshotError { - constructor(message = 'Fee snapshot is not available yet') { - super(message); - this.name = 'FeeSnapshotUnavailableError'; - } -} - -/** The service was stopped while a read was waiting. */ -export class FeeSnapshotStoppedError extends FeeSnapshotError { - constructor(message = 'Fee snapshot service was stopped') { - super(message); - this.name = 'FeeSnapshotStoppedError'; - } -} - -/** A wanted slot fell outside the covered window and a refresh could not cover it within the bound. */ -export class FeeSnapshotCoverageError extends FeeSnapshotError { - constructor(message: string) { - super(message); - this.name = 'FeeSnapshotCoverageError'; - } -} - -/** The last successful refresh is too old (refresh is broken). */ -export class FeeSnapshotComputationStaleError extends FeeSnapshotError { - constructor( - public readonly ageMs: number, - public readonly maxAgeMs: number, - ) { - super(`Fee snapshot computation is stale: age ${ageMs}ms exceeds max ${maxAgeMs}ms`); - this.name = 'FeeSnapshotComputationStaleError'; - } -} - -/** The pinned L1 head is too old (provider or archiver frozen). */ -export class FeeSnapshotL1HeadStaleError extends FeeSnapshotError { - constructor( - public readonly ageSeconds: number, - public readonly maxAgeSeconds: number, - ) { - super(`Fee snapshot L1 head is stale: age ${ageSeconds}s exceeds max ${maxAgeSeconds}s`); - this.name = 'FeeSnapshotL1HeadStaleError'; - } -} - -/** The pinned L1 head is dated further into the future than allowed (fails closed in production). */ -export class FeeSnapshotFutureHeadError extends FeeSnapshotError { - constructor( - public readonly aheadSeconds: number, - public readonly allowanceSeconds: number, - ) { - super(`Fee snapshot L1 head is ${aheadSeconds}s ahead of wall clock, exceeding allowance ${allowanceSeconds}s`); - this.name = 'FeeSnapshotFutureHeadError'; - } -} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts index 3309445f3f52..1904bf423748 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts @@ -5,19 +5,107 @@ import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contract import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; import type { ViemClient } from '@aztec/ethereum/types'; -import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; +import { times } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider, ManualDateProvider } from '@aztec/foundation/timer'; -import { FEE_ORACLE_LAG, ManaUsageEstimate } from '@aztec/stdlib/gas'; +import { + type L1RollupConstants, + getNextL1SlotTimestamp, + getSlotAtNextL1Block, + getTimestampForSlot, +} from '@aztec/stdlib/epoch-helpers'; +import { FEE_ORACLE_LAG, GasFees, ManaUsageEstimate, computeExcessMana } from '@aztec/stdlib/gas'; import { foundry } from 'viem/chains'; -import { type FeeSnapshotServiceConfig, getDefaultFeeSnapshotServiceConfig } from './fee_snapshot.js'; -import { FeeSnapshotService } from './fee_snapshot_service.js'; -import { computeLegacyCurrentMinFees, computeLegacyPredictedMinFees } from './legacy_fee_oracle.js'; +import { type FeeOracleState, computePredictions } from './fee_prediction.js'; +import { + FeeSnapshotService, + type FeeSnapshotServiceConfig, + getDefaultFeeSnapshotServiceConfig, +} from './fee_snapshot_service.js'; + +type LegacyOracleConstants = Pick; + +/** + * Faithful refactor of the pre-snapshot `FeeProviderImpl.computeCurrentMinFees`, taking `(blockNumber, now)` + * explicitly and pinned to that block. Kept here as the equivalence oracle for the snapshot service. + */ +async function computeLegacyCurrentMinFees( + rollup: RollupContract, + blockNumber: bigint, + nowSeconds: number, + constants: LegacyOracleConstants, +): Promise { + const pendingCheckpointNumber = await rollup.getCheckpointNumber({ blockNumber }); + const lastCheckpoint = await rollup.getCheckpoint(pendingCheckpointNumber, { blockNumber }); + const earliestTimestamp = getTimestampForSlot(SlotNumber.add(lastCheckpoint.slotNumber, 1), constants); + const nextEthTimestamp = getNextL1SlotTimestamp(nowSeconds, constants); + const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; + return new GasFees(0, await rollup.getManaMinFeeAt(timestamp, true, { blockNumber })); +} + +/** + * Faithful refactor of the pre-snapshot `FeePredictor.fetchState`, taking `(blockNumber, now)` explicitly and + * pinned to that block. + */ +async function fetchLegacyFeeOracleState( + rollup: RollupContract, + blockNumber: bigint, + nowSeconds: number, + constants: LegacyOracleConstants, +): Promise { + const opts = { blockNumber }; + const [manaTarget, manaLimit, provingCostPerManaEth, epochDuration] = await Promise.all([ + rollup.readManaTarget(opts), + rollup.readManaLimit(opts), + rollup.readProvingCostPerManaInEth(opts), + rollup.getEpochDuration(), + ]); + + const currentSlot = await rollup.getSlotNumber(opts); + const slotAtNextL1Block = getSlotAtNextL1Block(BigInt(nowSeconds), constants); + const preliminaryNextSlot = SlotNumber(Math.max(currentSlot, slotAtNextL1Block)); + const nextSlotTimestamp = getTimestampForSlot(preliminaryNextSlot, constants); + + const lastCheckpoint = await rollup.getEffectivePendingCheckpoint(nextSlotTimestamp, opts); + const lastSlot = lastCheckpoint.slotNumber; + const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), preliminaryNextSlot)); + const feeHeader = lastCheckpoint.feeHeader; + + const timestamps = times(FEE_ORACLE_LAG, i => getTimestampForSlot(SlotNumber.add(nextSlot, i), constants)); + const l1FeesBySlot = await Promise.all(timestamps.map(ts => rollup.getL1FeesAt(ts, opts))); + + return { + lastSlot, + excessMana: computeExcessMana(feeHeader.excessMana, feeHeader.manaUsed, manaTarget), + ethPerFeeAsset: feeHeader.ethPerFeeAsset, + manaTarget, + manaLimit, + provingCostPerManaEth, + epochDuration: BigInt(epochDuration), + l1FeesBySlot, + }; +} + +/** Legacy `getPredictedMinFees`: current fee followed by the prediction window, on identical explicit inputs. */ +async function computeLegacyPredictedMinFees( + rollup: RollupContract, + blockNumber: bigint, + nowSeconds: number, + constants: LegacyOracleConstants, + manaUsage: ManaUsageEstimate, +): Promise { + const [current, state] = await Promise.all([ + computeLegacyCurrentMinFees(rollup, blockNumber, nowSeconds, constants), + fetchLegacyFeeOracleState(rollup, blockNumber, nowSeconds, constants), + ]); + return [current, ...computePredictions(state, manaUsage)]; +} // The snapshot service path must reduce exactly to the legacy oracle on identical (blockNumber, now). describe('FeeSnapshot equivalence with legacy oracle', () => { @@ -67,10 +155,8 @@ describe('FeeSnapshot equivalence with legacy oracle', () => { function makeService(identity: L1SyncSnapshot, dateProvider: ManualDateProvider): FeeSnapshotService { const config: FeeSnapshotServiceConfig = { ...getDefaultFeeSnapshotServiceConfig({ slotDuration, l1GenesisTime, ethereumSlotDuration, epochDuration }), - clockDriftAllowanceSeconds: 0, - maxRefreshAgeMs: 0, + // The cases below drive the clock explicitly, so the head-age bound is disabled and the loop never ticks. maxL1HeadAgeSeconds: 0, - futureHeadAllowanceSeconds: 0, refreshTimeoutMs: 30_000, pollIntervalMs: 1_000_000_000, }; diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts index 3b790bb019d0..05e5465b775d 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts @@ -1,27 +1,38 @@ -import type { CheckpointLog, FeeHeader, RollupFeeRead, RollupFeeReadResult } from '@aztec/ethereum/contracts'; +import type { + CheckpointLog, + FeeHeader, + L1FeeData, + RollupChainTips, + RollupFeeGlobals, + RollupSlotFeeInputs, +} from '@aztec/ethereum/contracts'; import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; import { ManualDateProvider } from '@aztec/foundation/timer'; import { FEE_ORACLE_LAG, ManaUsageEstimate } from '@aztec/stdlib/gas'; import { - FeeSnapshotConfigError, - FeeSnapshotFutureHeadError, - FeeSnapshotL1HeadStaleError, + FeeQuoteStaleError, + FeeQuoteUnavailableError, + FeeSnapshotService, type FeeSnapshotServiceConfig, - FeeSnapshotStoppedError, - FeeSnapshotUnavailableError, + type RollupFeeReader, getDefaultFeeSnapshotServiceConfig, -} from './fee_snapshot.js'; -import { FeeSnapshotService, type RollupFeeReader } from './fee_snapshot_service.js'; +} from './fee_snapshot_service.js'; const L1_GENESIS_TIME = 0n; const SLOT_DURATION = 24; const ETHEREUM_SLOT_DURATION = 12; const EPOCH_DURATION = 32; +/** Round trips one refresh makes: globals, checkpoints, per-slot fee inputs, L1 fees + trailing tips. */ +const STAGES_PER_REFRESH = 4; + const FEE_HEADER: FeeHeader = { excessMana: 0n, manaUsed: 0n, @@ -46,29 +57,64 @@ function makeCheckpoint(slot: number): CheckpointLog { }; } -/** Deterministic in-memory rollup reader. `getManaMinFeeAt` returns the slot number so current fees are identifiable. */ +/** Deterministic in-memory rollup reader. `manaMinFee` returns the slot number so current fees are identifiable. */ class FakeRollup implements RollupFeeReader { + /** Number of stage round trips made (one refresh is {@link STAGES_PER_REFRESH}). */ public callCount = 0; - public calls: { blockNumber: bigint; reads: RollupFeeRead[] }[] = []; - public tips = { pending: CheckpointNumber(5), proven: CheckpointNumber(3) }; + public blockNumbers: bigint[] = []; + public tips: RollupChainTips = { pending: CheckpointNumber(5), proven: CheckpointNumber(3) }; public pendingSlot = 100; public provenSlot = 90; public manaTarget = 1000n; public manaLimit = 2000n; public provingCost = 5n; public canPrune = false; - /** Number of upcoming readFeeInputs calls to fail. */ + /** Number of upcoming stage calls to fail. */ public failNext = 0; - /** Optional gate: when set, readFeeInputs waits on this before resolving. */ + /** Optional gate: when set, every stage waits on this before resolving. */ public gate: Promise | undefined; - /** Wave-2 tips override to simulate fork mixing (applied once). */ - public wave2TipsOnce: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; - /** Wave-2 tips override applied on every wave 2, to simulate persistent tips instability. */ - public wave2TipsAlways: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; + /** Tips reported by the trailing re-read of the last stage, to simulate divergence across stages. */ + public tailTips: RollupChainTips | undefined; + + async getFeeGlobals(options: { blockNumber: bigint }): Promise { + await this.stage(options); + return { + tips: this.tips, + manaTarget: this.manaTarget, + manaLimit: this.manaLimit, + provingCostPerManaEth: this.provingCost, + }; + } + + async getCheckpoints( + checkpointNumbers: CheckpointNumber[], + options: { blockNumber: bigint }, + ): Promise { + await this.stage(options); + return checkpointNumbers.map(n => + makeCheckpoint(Number(n) === Number(this.tips.pending) ? this.pendingSlot : this.provenSlot), + ); + } + + async getSlotFeeInputs(timestamps: bigint[], options: { blockNumber: bigint }): Promise { + await this.stage(options); + return timestamps.map(ts => ({ manaMinFee: BigInt(slotOfTimestamp(ts)), canPrune: this.canPrune })); + } - async readFeeInputs(reads: RollupFeeRead[], options: { blockNumber: bigint }): Promise { + async getL1FeesAndTips( + timestamps: bigint[], + options: { blockNumber: bigint }, + ): Promise<{ l1Fees: L1FeeData[]; tips: RollupChainTips }> { + await this.stage(options); + return { + l1Fees: timestamps.map(() => ({ baseFee: 1n, blobFee: 1n })), + tips: this.tailTips ?? this.tips, + }; + } + + private async stage(options: { blockNumber: bigint }): Promise { this.callCount++; - this.calls.push({ blockNumber: options.blockNumber, reads }); + this.blockNumbers.push(options.blockNumber); if (this.gate) { await this.gate; } @@ -76,40 +122,6 @@ class FakeRollup implements RollupFeeReader { this.failNext--; throw new Error('L1 read failed'); } - const isWave2 = reads.some(r => r.kind === 'checkpoint'); - return reads.map(r => this.resolve(r, isWave2)); - } - - private resolve(read: RollupFeeRead, isWave2: boolean): RollupFeeReadResult { - switch (read.kind) { - case 'tips': { - if (isWave2 && this.wave2TipsAlways) { - return { kind: 'tips', value: this.wave2TipsAlways }; - } - if (isWave2 && this.wave2TipsOnce) { - const value = this.wave2TipsOnce; - this.wave2TipsOnce = undefined; - return { kind: 'tips', value }; - } - return { kind: 'tips', value: this.tips }; - } - case 'manaTarget': - return { kind: 'manaTarget', value: this.manaTarget }; - case 'manaLimit': - return { kind: 'manaLimit', value: this.manaLimit }; - case 'provingCostPerManaEth': - return { kind: 'provingCostPerManaEth', value: this.provingCost }; - case 'manaMinFeeAt': - return { kind: 'manaMinFeeAt', timestamp: read.timestamp, value: BigInt(slotOfTimestamp(read.timestamp)) }; - case 'canPruneAtTime': - return { kind: 'canPruneAtTime', timestamp: read.timestamp, value: this.canPrune }; - case 'l1FeesAt': - return { kind: 'l1FeesAt', timestamp: read.timestamp, value: { baseFee: 1n, blobFee: 1n } }; - case 'checkpoint': { - const slot = Number(read.checkpointNumber) === Number(this.tips.pending) ? this.pendingSlot : this.provenSlot; - return { kind: 'checkpoint', checkpointNumber: read.checkpointNumber, value: makeCheckpoint(slot) }; - } - } } } @@ -144,7 +156,6 @@ describe('FeeSnapshotService', () => { ethereumSlotDuration: ETHEREUM_SLOT_DURATION, epochDuration: EPOCH_DURATION, }), - clockDriftAllowanceSeconds: 0, refreshTimeoutMs: 100, pollIntervalMs: 10_000_000, ...overrides, @@ -152,6 +163,10 @@ describe('FeeSnapshotService', () => { return new FeeSnapshotService(rollup, identity, dateProvider, config); } + function coveredSlots(): number[] { + return [...service.getSnapshot()!.candidates.keys()].sort((a, b) => a - b); + } + beforeEach(() => { rollup = new FakeRollup(); identity = new FakeIdentityProvider(); @@ -167,7 +182,7 @@ describe('FeeSnapshotService', () => { }); it('serves the current fee floored on the pending checkpoint slot', async () => { - // pendingSlot = 100, so wantedCurrent = max(101, slotAtNextL1Block(now)=100) = 101. + // pendingSlot = 100, so the current anchor is max(101, slotAtNextL1Block(now) = 100) = 101. const fees = await service.getCurrentMinFees(); expect(fees.feePerL2Gas).toBe(101n); }); @@ -181,7 +196,7 @@ describe('FeeSnapshotService', () => { it('warm calls issue zero L1 requests', async () => { await service.getCurrentMinFees(); const afterFirst = rollup.callCount; - expect(afterFirst).toBeGreaterThan(0); + expect(afterFirst).toBe(STAGES_PER_REFRESH); for (let i = 0; i < 20; i++) { await service.getPredictedMinFees(ManaUsageEstimate.Target); } @@ -193,8 +208,7 @@ describe('FeeSnapshotService', () => { for (const fees of results) { expect(fees.feePerL2Gas).toBe(101n); } - // A normal refresh is two waves (wave1 + wave2); single-flight collapses the 20 callers into one refresh. - expect(rollup.callCount).toBe(2); + expect(rollup.callCount).toBe(STAGES_PER_REFRESH); }); it('refreshes once on an archiver identity change and shares the completion', async () => { @@ -205,7 +219,7 @@ describe('FeeSnapshotService', () => { for (const fees of results) { expect(fees.feePerL2Gas).toBe(101n); } - expect(rollup.callCount).toBe(before + 2); + expect(rollup.callCount).toBe(before + STAGES_PER_REFRESH); expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); }); @@ -214,7 +228,7 @@ describe('FeeSnapshotService', () => { const before = rollup.callCount; identity.snapshot = makeIdentity(1n, PINNED_SLOT, Buffer32.fromNumber(999)); await service.getCurrentMinFees(); - expect(rollup.callCount).toBe(before + 2); + expect(rollup.callCount).toBe(before + STAGES_PER_REFRESH); expect(service.getSnapshot()!.l1.blockHash.equals(Buffer32.fromNumber(999))).toBe(true); }); @@ -251,7 +265,7 @@ describe('FeeSnapshotService', () => { await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); // During the backoff window, reads fail fast with a typed error and issue no L1 requests. const calls = rollup.callCount; - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteUnavailableError); expect(rollup.callCount).toBe(calls); // After the backoff elapses, reads refresh again. dateProvider.advanceTimeMs(1_000); @@ -259,6 +273,15 @@ describe('FeeSnapshotService', () => { expect(fees.feePerL2Gas).toBe(101n); }); + it('recovers when the very first refresh fails', async () => { + rollup.failNext = 100; + await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); + rollup.failNext = 0; + dateProvider.advanceTimeMs(1_000); + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(101n); + }); + it('publishes and serves a rollback to a lower L1 block number', async () => { identity.snapshot = makeIdentity(5n, PINNED_SLOT); await service.getCurrentMinFees(); @@ -270,131 +293,128 @@ describe('FeeSnapshotService', () => { expect(service.getSnapshot()!.l1.blockNumber).toBe(4n); }); - it('exceeding the refresh restart bound counts as a failure and engages backoff', async () => { - rollup.wave2TipsAlways = { pending: CheckpointNumber(6), proven: CheckpointNumber(3) }; - // The cold caller parks on the first-snapshot promise (resolved only on success) and times out typed. - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); - expect(service.getStats().refreshFailures).toBe(1); - expect(service.getStats().tipsMismatchDiscards).toBe(4); - // Backoff is engaged: an immediate retry issues no further L1 reads. - const calls = rollup.callCount; - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); - expect(rollup.callCount).toBe(calls); + it('reports unavailable when there is no L1 identity yet', async () => { + identity.snapshot = undefined; + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteUnavailableError); }); - it('discards and restarts the refresh when wave-2 tips differ from wave-1', async () => { - rollup.wave2TipsOnce = { pending: CheckpointNumber(6), proven: CheckpointNumber(3) }; - const fees = await service.getCurrentMinFees(); - expect(fees.feePerL2Gas).toBe(101n); - expect(service.getStats().tipsMismatchDiscards).toBe(1); + it('reports unavailable for reads after the service is stopped', async () => { + await service.getCurrentMinFees(); + await service.stop(); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteUnavailableError); }); - describe('first-snapshot promise', () => { - it('times out with a typed error while the first refresh keeps failing, then serves once it succeeds', async () => { - rollup.failNext = 100; - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); - rollup.failNext = 0; - dateProvider.advanceTimeMs(1_000); - const fees = await service.getCurrentMinFees(); - expect(fees.feePerL2Gas).toBe(101n); - }); + it('fails closed when the pinned L1 head age exceeds the bound', async () => { + service = makeService({ maxL1HeadAgeSeconds: 60 }); + await service.getCurrentMinFees(); + dateProvider.advanceTime(120); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteStaleError); + }); - it('reports unavailable when there is no L1 identity yet', async () => { - identity.snapshot = undefined; - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotUnavailableError); - }); + it('serves an arbitrarily old pinned head when the age bound is disabled', async () => { + service = makeService({ maxL1HeadAgeSeconds: 0 }); + await service.getCurrentMinFees(); + dateProvider.advanceTime(100_000); + await expect(service.getCurrentMinFees()).resolves.toBeDefined(); }); - describe('stop', () => { - it('rejects the first-snapshot waiter on stop', async () => { - // No L1 identity yet, so no refresh is triggered and the caller parks on the first-snapshot promise. - service = makeService({ refreshTimeoutMs: 10_000 }); - identity.snapshot = undefined; - const pending = service.getCurrentMinFees(); - await service.stop(); - await expect(pending).rejects.toBeInstanceOf(FeeSnapshotStoppedError); - }); + it('fails the refresh when the trailing tips differ, keeping the stored snapshot until it can be replaced', async () => { + await service.getCurrentMinFees(); + const stored = service.getSnapshot(); + rollup.tailTips = { pending: CheckpointNumber(6), proven: CheckpointNumber(3) }; + + // (a) With the identity unchanged and the wanted slots covered, reads keep serving the identity-1 snapshot. + const callsAfterGood = rollup.callCount; + await expect(service.getCurrentMinFees()).resolves.toBeDefined(); + expect(rollup.callCount).toBe(callsAfterGood); + + // (b) On a new identity the read must refresh, and that refresh fails on the tips comparison: the caller + // gets the refresh error rather than the superseded quote, and the stored snapshot is left alone. + identity.snapshot = makeIdentity(2n, PINNED_SLOT); + await expect(service.getCurrentMinFees()).rejects.toThrow(/Chain tips changed/); + expect(service.getStats().refreshFailures).toBe(1); + expect(service.getSnapshot()).toBe(stored); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteUnavailableError); + expect(service.getSnapshot()).toBe(stored); + + // (c) Once the divergence clears and the backoff elapses, the next read serves the new identity. + rollup.tailTips = undefined; + dateProvider.advanceTimeMs(1_000); + await expect(service.getCurrentMinFees()).resolves.toBeDefined(); + expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); }); - describe('staleness', () => { - it('repairs a stale computation age with one refresh instead of failing', async () => { - service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); - await service.getCurrentMinFees(); - const refreshesBefore = service.getStats().refreshes; - dateProvider.advanceTimeMs(2_000); - // Computation staleness is recoverable: the read triggers one refresh (re-anchoring refreshedAtMs) and serves. - await expect(service.getCurrentMinFees()).resolves.toBeDefined(); - expect(service.getStats().refreshes).toBe(refreshesBefore + 1); - expect(service.getStats().computationStaleErrors).toBe(0); - }); + describe('identity change during an in-flight refresh', () => { + /** Gates the first refresh, publishes `next` while it is in flight, and asserts the read lands on `next`. */ + async function assertReadLandsOn(next: L1SyncSnapshot): Promise { + service = makeService({ refreshTimeoutMs: 5_000 }); + const gate = promiseWithResolvers(); + rollup.gate = gate.promise; - it('fails closed when the computation age exceeds the bound and the repair refresh fails', async () => { - service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); - await service.getCurrentMinFees(); - dateProvider.advanceTimeMs(2_000); - rollup.failNext = 100; - // The repair refresh fails, so the caller sees the underlying refresh error rather than a stale serve. - await expect(service.getCurrentMinFees()).rejects.toThrow('L1 read failed'); - }); + const read = service.getCurrentMinFees(); + await sleep(1); + identity.snapshot = next; + rollup.gate = undefined; + gate.resolve(); - it('refreshes instead of failing when stale but the archiver already has a new identity', async () => { - service = makeService({ maxRefreshAgeMs: 1_000, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); - await service.getCurrentMinFees(); - dateProvider.advanceTimeMs(2_000); - identity.snapshot = makeIdentity(2n, PINNED_SLOT); - // Identity is checked before staleness: the read refreshes to the new identity and serves. - await expect(service.getCurrentMinFees()).resolves.toBeDefined(); - expect(service.getSnapshot()!.l1.blockNumber).toBe(2n); + const fees = await read; + expect(fees.feePerL2Gas).toBe(101n); + expect(service.getSnapshot()!.l1.blockHash.equals(next.blockHash)).toBe(true); + // One refresh for the old identity plus one corrective refresh: no fan-out per caller. + expect(service.getStats().refreshes).toBe(2); + } + + it('lands on a higher block number published mid-refresh', async () => { + await assertReadLandsOn(makeIdentity(2n, PINNED_SLOT)); }); - it('fails closed when the L1 head age exceeds the bound', async () => { - service = makeService({ maxRefreshAgeMs: 0, maxL1HeadAgeSeconds: 60, futureHeadAllowanceSeconds: 0 }); - await service.getCurrentMinFees(); - dateProvider.advanceTime(120); - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotL1HeadStaleError); - expect(service.getStats().l1HeadStaleErrors).toBe(1); + it('lands on a same-height reorg published mid-refresh', async () => { + await assertReadLandsOn(makeIdentity(1n, PINNED_SLOT, Buffer32.fromNumber(777))); }); + }); - it('fails closed when the L1 head is dated too far into the future', async () => { - service = makeService({ maxRefreshAgeMs: 0, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 10 }); + describe('coverage across an L1 stall', () => { + it('serves the advancing wanted slot from headroom, then extends coverage from the poll tick', async () => { + service = makeService({ pollIntervalMs: 10 }); await service.getCurrentMinFees(); - // Step the wall clock backwards so the pinned head is far ahead of "now". - dateProvider.advanceTime(-120); - await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeSnapshotFutureHeadError); - expect(service.getStats().futureHeadErrors).toBe(1); + // Anchors at slot 101 (current) and 100 (prediction), each with two slots of headroom. + expect(coveredSlots()).toEqual([100, 101, 102, 103]); + + // The identity stays frozen: the wall clock alone moves the wanted slot up through the headroom. + const calls = rollup.callCount; + dateProvider.advanceTime(SLOT_DURATION); + expect((await service.getCurrentMinFees()).feePerL2Gas).toBe(101n); + dateProvider.advanceTime(SLOT_DURATION); + expect((await service.getCurrentMinFees()).feePerL2Gas).toBe(102n); + expect(rollup.callCount).toBe(calls); + + // One more slot leaves the wanted slot covered but the tick's one-slot lookahead uncovered, so the + // background loop re-centres the window on the same pinned block. + service.start(); + dateProvider.advanceTime(SLOT_DURATION); + await retryUntil(() => service.getStats().refreshes >= 2, 'coverage refresh', 10, 0.02); + expect(service.getSnapshot()!.l1.blockNumber).toBe(1n); + expect(coveredSlots()).toEqual([103, 104, 105]); }); - it('disables each staleness check independently when its config is 0', async () => { - service = makeService({ maxRefreshAgeMs: 0, maxL1HeadAgeSeconds: 0, futureHeadAllowanceSeconds: 0 }); + it('serves a read landing exactly on a slot boundary while a coverage refresh is in flight', async () => { + service = makeService({ pollIntervalMs: 10 }); await service.getCurrentMinFees(); - dateProvider.advanceTime(100_000); - // With all checks disabled, a very old snapshot is still served for the covered slot. - await expect(service.getCurrentMinFees()).resolves.toBeDefined(); - }); - }); - describe('drift window', () => { - it('rejects a configuration whose drift enumerates more candidates than the cap', () => { - expect(() => makeService({ clockDriftAllowanceSeconds: 1000, maxClockCandidates: 2 })).toThrow( - FeeSnapshotConfigError, - ); - }); + const gate = promiseWithResolvers(); + rollup.gate = gate.promise; + service.start(); + // Slot 103 starts exactly at this timestamp: the read wants a covered slot while the tick's lookahead + // (slot 104) does not, so the gated coverage refresh is in flight when the read arrives. + dateProvider.advanceTime(3 * SLOT_DURATION); + await retryUntil(() => rollup.callCount > STAGES_PER_REFRESH, 'gated refresh started', 10, 0.02); - it('drift 0 reduces to the single-anchor selection', async () => { const fees = await service.getCurrentMinFees(); - expect(fees.feePerL2Gas).toBe(101n); - }); + expect(fees.feePerL2Gas).toBe(103n); + expect(service.getStats().refreshes).toBe(1); - it('enumerates every slot across a multi-slot drift window and takes the element-wise max', async () => { - // Lower the pending checkpoint so the wall clock (not the floor) drives the enumerated window. - rollup.pendingSlot = 90; - // A drift of one slot each way spans multiple candidate slots; current fee == slot, so max == highest slot. - service = makeService({ clockDriftAllowanceSeconds: SLOT_DURATION, maxClockCandidates: 8 }); - const fees = await service.getCurrentMinFees(); - const nowSeconds = BigInt(dateProvider.nowInSeconds()); - const highBoundary = nowSeconds + BigInt(SLOT_DURATION) + BigInt(ETHEREUM_SLOT_DURATION); - const expectedHigh = Math.max(rollup.pendingSlot + 1, slotOfTimestamp(highBoundary)); - expect(fees.feePerL2Gas).toBe(BigInt(expectedHigh)); + rollup.gate = undefined; + gate.resolve(); }); }); }); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts index 94a08678c32c..1799702cde72 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts @@ -1,94 +1,173 @@ -import type { FeeHeader, RollupFeeRead, RollupFeeReadResult } from '@aztec/ethereum/contracts'; +import type { + CheckpointLog, + FeeHeader, + L1FeeData, + RollupChainTips, + RollupFeeGlobals, + RollupSlotFeeInputs, +} from '@aztec/ethereum/contracts'; import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types'; -import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { type CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { times, unique } from '@aztec/foundation/collection'; +import { TimeoutError } from '@aztec/foundation/error'; import { type Logger, createLogger } from '@aztec/foundation/log'; -import { type PromiseWithResolvers, RunningPromise, promiseWithResolvers } from '@aztec/foundation/promise'; -import type { DateProvider } from '@aztec/foundation/timer'; +import { RunningPromise } from '@aztec/foundation/promise'; +import { type DateProvider, executeTimeout } from '@aztec/foundation/timer'; import { type L1RollupConstants, getSlotAtNextL1Block, getSlotAtTimestamp, getTimestampForSlot, } from '@aztec/stdlib/epoch-helpers'; -import { FEE_ORACLE_LAG, GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; +import { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; -import { buildFeeOracleState, computePredictions } from './fee_prediction.js'; -import { - type FeeQuoteCandidate, - type FeeSnapshot, - FeeSnapshotComputationStaleError, - FeeSnapshotConfigError, - FeeSnapshotCoverageError, - FeeSnapshotError, - FeeSnapshotFutureHeadError, - FeeSnapshotL1HeadStaleError, - type FeeSnapshotServiceConfig, - FeeSnapshotStoppedError, - FeeSnapshotUnavailableError, -} from './fee_snapshot.js'; - -/** The subset of {@link RollupContract} the fee snapshot service depends on: a batched pinned fee reader. */ +import { buildFeeOracleState, computePredictions, getPredictionWindowSlots } from './fee_prediction.js'; + +/** The subset of {@link RollupContract} the fee snapshot service depends on: one batched pinned read per stage. */ export interface RollupFeeReader { - readFeeInputs( - reads: RollupFeeRead[], - options: { blockNumber: bigint; allowMulticall?: boolean }, - ): Promise; + /** Chain tips plus the governance-settable fee parameters. */ + getFeeGlobals(options: { blockNumber: bigint }): Promise; + /** Checkpoint logs for the given checkpoint numbers, in order. */ + getCheckpoints(checkpointNumbers: CheckpointNumber[], options: { blockNumber: bigint }): Promise; + /** Current min fee and prune-ability at each given timestamp, in order. */ + getSlotFeeInputs(timestamps: bigint[], options: { blockNumber: bigint }): Promise; + /** L1 fee oracle values at each given timestamp plus a trailing re-read of the chain tips. */ + getL1FeesAndTips( + timestamps: bigint[], + options: { blockNumber: bigint }, + ): Promise<{ l1Fees: L1FeeData[]; tips: RollupChainTips }>; } -/** Cause of a refresh, recorded on metrics/logs for observability. */ -type RefreshCause = - | 'poll-identity' - | 'poll-coverage' - | 'rpc-identity-mismatch' - | 'coverage-miss' - | 'stale-refresh' - | 'rpc-first'; +/** + * One complete, finished fee quote for a single candidate slot. Entries are outputs, never partially derived + * state: both the current fee and all three prediction arrays are precomputed at refresh time so the read path + * issues zero L1 requests. + */ +export type FeeQuoteCandidate = { + /** The candidate L2 slot this quote is for. */ + slot: SlotNumber; + /** Slot-start timestamp used for the pinned reads. */ + timestamp: bigint; + /** Canonical current fee: Solidity `getManaMinFeeAt(timestamp)` at the pinned block. */ + currentMinFee: GasFees; + /** Precomputed `FEE_ORACLE_LAG`-length prediction array per mana-usage estimate. */ + predictions: Record; +}; + +/** + * Immutable, atomically-swapped view of the fee model at a single pinned L1 block. Selection floors come only + * from the snapshot-level fields; coverage is map membership, so a wanted slot is either served exactly or + * triggers a refresh. + */ +export type FeeSnapshot = { + /** L1 identity this snapshot was built at (block number + hash + timestamp). */ + l1: L1SyncSnapshot; + /** Raw pending checkpoint slot at the pinned block — floor for the current-fee anchor rule. */ + pendingCheckpointSlot: SlotNumber; + /** Slot of the pinned block timestamp (TS arithmetic) — floor for the prediction anchor rule. */ + pinnedSlot: SlotNumber; + /** One complete entry per materialized candidate slot, keyed by the primitive slot number. */ + candidates: ReadonlyMap; +}; + +/** Tuning and staleness configuration for the fee snapshot service. All durations use their stated units. */ +export type FeeSnapshotServiceConfig = { + slotDuration: number; + l1GenesisTime: bigint; + ethereumSlotDuration: number; + epochDuration: number; + /** Background poll interval (ms) for the refresh loop; only in-memory comparisons run per tick. */ + pollIntervalMs: number; + /** Max age (seconds) of the pinned L1 head before reads fail closed. `0` disables. */ + maxL1HeadAgeSeconds: number; + /** Bound (ms) a read waits for refreshes before failing closed with a typed error. */ + refreshTimeoutMs: number; +}; + +/** Derives the default fee snapshot service config from the L1 timing constants. */ +export function getDefaultFeeSnapshotServiceConfig(base: { + slotDuration: number; + l1GenesisTime: bigint; + ethereumSlotDuration: number; + epochDuration: number; +}): FeeSnapshotServiceConfig { + return { + ...base, + pollIntervalMs: 500, + maxL1HeadAgeSeconds: 300, + refreshTimeoutMs: 5_000, + }; +} + +/** Base class for all fee snapshot errors, so callers can catch the whole family. */ +export class FeeSnapshotError extends Error { + constructor(message: string) { + super(message); + this.name = 'FeeSnapshotError'; + } +} + +/** No fee quote can be produced right now: no identity, no snapshot, refresh failure backoff, or stopped. */ +export class FeeQuoteUnavailableError extends FeeSnapshotError { + constructor(reason: string) { + super(`Fee quote is unavailable: ${reason}`); + this.name = 'FeeQuoteUnavailableError'; + } +} + +/** The pinned L1 head is older than the configured bound, so quotes fail closed instead of going stale. */ +export class FeeQuoteStaleError extends FeeSnapshotError { + constructor( + public readonly ageSeconds: number, + public readonly maxAgeSeconds: number, + ) { + super(`Fee quote is stale: pinned L1 head age ${ageSeconds}s exceeds max ${maxAgeSeconds}s`); + this.name = 'FeeQuoteStaleError'; + } +} /** Counters exposed for benchmarking and observability. */ export type FeeSnapshotStats = { - /** Total successful background/first refreshes that published a snapshot. */ + /** Total refreshes that published a snapshot. */ refreshes: number; /** Total refresh failures (kept last-good, retried with backoff). */ refreshFailures: number; /** Reads that had to trigger a refresh because the warm snapshot did not serve them (identity or coverage). */ readTriggeredRefreshes: number; - /** Wave-2 tips-mismatch discards. */ - tipsMismatchDiscards: number; - /** Targeted top-up waves fired during refresh. */ - topUpWaves: number; - /** Reads that failed closed because the last refresh was too old and a repair refresh did not fix it. */ - computationStaleErrors: number; - /** Reads that failed closed because the pinned L1 head was too old. */ - l1HeadStaleErrors: number; - /** Reads that failed closed because the pinned L1 head was future-dated beyond the allowance. */ - futureHeadErrors: number; - /** Reads that failed closed because a needed refresh did not complete within the bound. */ - coverageTimeouts: number; }; -const MAX_LOOKUP_ATTEMPTS = 4; -const MAX_REFRESH_ATTEMPTS = 4; +/** Cause of a refresh, recorded on logs for observability. */ +type RefreshCause = 'poll-identity' | 'poll-coverage' | 'read'; + +/** + * Extra slots materialized above each anchor so quotes survive a run of empty Ethereum slots or a short L1 + * stall without a refresh. Two suffices only because the Aztec slot duration is a positive multiple of the + * Ethereum slot duration, so one L1 block advances the wanted slot by at most one; together with the poll + * tick's one-slot lookahead that leaves a full slot of margin to refresh in. + */ +const CANDIDATE_HEADROOM_SLOTS = 2; + +/** + * Attempts a read makes before giving up: one stale in-flight publication, one corrective refresh, and one + * successful lookup. Identity churn beyond that inside a single call's deadline is reported as unavailable. + */ +const MAX_LOOKUP_ATTEMPTS = 3; + const BACKOFF_BASE_MS = 250; const BACKOFF_MAX_MS = 8_000; -class TimeoutError extends Error {} - /** * Serves current and predicted fee quotes from an in-memory snapshot refreshed in the background per L1 block, * so warm RPC calls issue zero L1 requests. Reads are served from a complete, immutable, atomically-swapped - * {@link FeeSnapshot}; the refresh pins every read to the archiver's L1 block, labels it with the block hash, - * and re-validates the archiver identity before publishing. + * {@link FeeSnapshot} whose every value was read at the archiver's synced L1 block. */ export class FeeSnapshotService { private snapshot: FeeSnapshot | undefined; - private firstSnapshot: PromiseWithResolvers = promiseWithResolvers(); - private firstResolved = false; + private inFlight: Promise | undefined; private readonly runningPromise: RunningPromise; private stopped = false; - private readonly stopSignal: PromiseWithResolvers = promiseWithResolvers(); - private inFlight: { key: string; promise: Promise } | undefined; private consecutiveFailures = 0; private nextRetryAtMs = 0; @@ -97,12 +176,6 @@ export class FeeSnapshotService { refreshes: 0, refreshFailures: 0, readTriggeredRefreshes: 0, - tipsMismatchDiscards: 0, - topUpWaves: 0, - computationStaleErrors: 0, - l1HeadStaleErrors: 0, - futureHeadErrors: 0, - coverageTimeouts: 0, }; constructor( @@ -112,53 +185,31 @@ export class FeeSnapshotService { private readonly config: FeeSnapshotServiceConfig, private readonly log: Logger = createLogger('sequencer:fee-snapshot'), ) { - this.validateConfig(); this.constants = { l1GenesisTime: config.l1GenesisTime, slotDuration: config.slotDuration, ethereumSlotDuration: config.ethereumSlotDuration, }; this.runningPromise = new RunningPromise(() => this.tick(), this.log, config.pollIntervalMs); - // Keep the stop signal and the first-snapshot promise from surfacing as unhandled rejections when nothing - // is awaiting them (e.g. stop() rejects the first-snapshot promise before any read parked on it). - this.stopSignal.promise.catch(() => {}); - this.firstSnapshot.promise.catch(() => {}); - } - - /** Rejects a configuration whose drift window would enumerate more candidates than the cap allows. */ - private validateConfig(): void { - const { clockDriftAllowanceSeconds, slotDuration, maxClockCandidates } = this.config; - // Distinct slots enumerated across a `2 * drift` interval is at most `ceil(2*drift/slotDuration) + 1`. - const maxCandidates = Math.ceil((2 * clockDriftAllowanceSeconds) / slotDuration) + 1; - if (maxCandidates > maxClockCandidates) { - throw new FeeSnapshotConfigError( - `clockDriftAllowanceSeconds ${clockDriftAllowanceSeconds} with slotDuration ${slotDuration} enumerates up to ` + - `${maxCandidates} candidates, exceeding maxClockCandidates ${maxClockCandidates}`, - ); - } } /** Starts the background refresh loop. */ public start(): void { if (this.stopped) { - throw new FeeSnapshotStoppedError('Cannot start a stopped fee snapshot service'); + throw new FeeSnapshotError('Cannot start a stopped fee snapshot service'); } this.runningPromise.start(); this.log.verbose('Fee snapshot service started', { pollIntervalMs: this.config.pollIntervalMs }); } - /** Stops the loop, awaits in-flight work, and rejects all pending waiters and the first-snapshot promise. */ + /** Stops the loop and awaits any in-flight refresh. Parked readers resolve with it or hit their own timeout. */ public async stop(): Promise { if (this.stopped) { return; } this.stopped = true; - this.stopSignal.reject(new FeeSnapshotStoppedError()); await this.runningPromise.stop(); - await this.inFlight?.promise.catch(() => undefined); - if (!this.firstResolved) { - this.firstSnapshot.reject(new FeeSnapshotStoppedError()); - } + await this.inFlight?.catch(() => undefined); this.log.verbose('Fee snapshot service stopped'); } @@ -174,215 +225,103 @@ export class FeeSnapshotService { /** Returns the current minimum fees for inclusion in the next block. */ public async getCurrentMinFees(): Promise { - const { snapshot, currentSlots } = await this.resolveLookup(); - return maxGasFees(currentSlots.map(s => this.candidate(snapshot, s).currentMinFee)); + const { current } = await this.resolveLookup(); + return current.currentMinFee; } /** Returns current min fees first, followed by predicted min fees for each slot in the prediction window. */ public async getPredictedMinFees(manaUsage: ManaUsageEstimate): Promise { - const { snapshot, currentSlots, predictionSlots } = await this.resolveLookup(); - const current = maxGasFees(currentSlots.map(s => this.candidate(snapshot, s).currentMinFee)); - const predictionArrays = predictionSlots.map(s => this.candidate(snapshot, s).predictions[manaUsage]); - return [current, ...maxGasFeesElementWise(predictionArrays)]; + const { current, prediction } = await this.resolveLookup(); + return [current.currentMinFee, ...prediction.predictions[manaUsage]]; } - // --------------------------------------------------------------------------------------------------------- - // Read path (in-memory only; issues no L1 requests unless a refresh is required) - // --------------------------------------------------------------------------------------------------------- - - private async resolveLookup(): Promise<{ snapshot: FeeSnapshot; currentSlots: number[]; predictionSlots: number[] }> { + /** + * Resolves the two anchor candidates for the current wall clock, refreshing when the snapshot is missing, no + * longer matches the archiver identity, or does not cover a wanted slot. Issues no L1 request on the warm path. + */ + private async resolveLookup(): Promise<{ current: FeeQuoteCandidate; prediction: FeeQuoteCandidate }> { const deadline = this.dateProvider.now() + this.config.refreshTimeoutMs; - if (this.snapshot === undefined) { - await this.awaitFirstSnapshot(deadline); - } - - let attemptedStaleRefresh = false; for (let attempt = 0; attempt < MAX_LOOKUP_ATTEMPTS; attempt++) { if (this.stopped) { - throw new FeeSnapshotStoppedError(); + throw new FeeQuoteUnavailableError('the service was stopped'); + } + const snapshot = this.snapshot; + if (!snapshot) { + await this.readTriggeredRefresh(deadline); + continue; } - const snapshot = this.snapshot!; - // Identity check first: a stale or uncovered snapshot is recoverable when the archiver already has a - // newer identity, so refresh before applying any fail-closed staleness bound. This also serves freshness - // parity with today's per-call latest-block check, at zero L1 cost. + // Identity check before the staleness bound: a snapshot the archiver has already moved past is never + // served, and a refresh that cannot replace it surfaces its own error rather than a stale quote. This is + // also what preserves freshness parity with a per-call latest-block check, at zero L1 cost, and what + // corrects a refresh that published while the identity was changing. const identity = this.identityProvider.getL1SyncSnapshot(); if (identity && !identity.blockHash.equals(snapshot.l1.blockHash)) { - await this.readTriggeredRefresh(deadline, 'rpc-identity-mismatch'); + await this.readTriggeredRefresh(deadline); continue; } const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); - const { currentSlots, predictionSlots } = this.computeWantedSlots(snapshot, nowSeconds); - const uncovered = [...currentSlots, ...predictionSlots].some(s => !snapshot.candidates.has(s)); - if (uncovered) { - await this.readTriggeredRefresh(deadline, 'coverage-miss'); + const current = snapshot.candidates.get(this.wantedCurrentSlot(snapshot.pendingCheckpointSlot, nowSeconds)); + const prediction = snapshot.candidates.get(this.wantedPredictionSlot(snapshot.pinnedSlot, nowSeconds)); + if (!current || !prediction) { + await this.readTriggeredRefresh(deadline); continue; } - // Staleness last: the snapshot matches the archiver identity and covers the wanted slots. A stale - // computation age is repairable by one refresh (it re-anchors refreshedAtMs), so attempt that once - // before failing; head-age and future-head violations mean the L1 view itself is frozen or warped - // (the archiver has nothing newer), so they fail closed immediately. - try { - this.assertFresh(snapshot, this.dateProvider.now(), nowSeconds); - } catch (err) { - if (err instanceof FeeSnapshotComputationStaleError && !attemptedStaleRefresh) { - attemptedStaleRefresh = true; - await this.readTriggeredRefresh(deadline, 'stale-refresh'); - continue; - } - this.recordFailClosed(err); - throw err; - } - - return { snapshot, currentSlots, predictionSlots }; + this.assertHeadFresh(snapshot, nowSeconds); + return { current, prediction }; } - this.stats.coverageTimeouts++; - throw new FeeSnapshotCoverageError( - `Fee snapshot did not cover the wanted slots within ${this.config.refreshTimeoutMs}ms`, - ); + throw new FeeQuoteUnavailableError(`no snapshot covered the wanted slots within ${this.config.refreshTimeoutMs}ms`); } - private candidate(snapshot: FeeSnapshot, slot: number): FeeQuoteCandidate { - const candidate = snapshot.candidates.get(slot); - if (!candidate) { - // Coverage is validated before we get here; a miss is a programming error, never a substituted answer. - throw new FeeSnapshotCoverageError(`Fee snapshot has no candidate for slot ${slot}`); - } - return candidate; + /** Anchor slot of the current-fee rule: the next proposable slot, floored on the pending checkpoint. */ + private wantedCurrentSlot(pendingCheckpointSlot: SlotNumber, atSeconds: bigint): number { + return Math.max(pendingCheckpointSlot + 1, getSlotAtNextL1Block(atSeconds, this.constants)); } - /** Enumerates the current-rule and prediction-rule wanted slots across the drift window (inclusive). */ - private computeWantedSlots( - snapshot: FeeSnapshot, - nowSeconds: bigint, - ): { currentSlots: number[]; predictionSlots: number[] } { - const drift = BigInt(this.config.clockDriftAllowanceSeconds); - const tLow = nowSeconds - drift; - const tHigh = nowSeconds + drift; - const currentSlots = this.enumerate( - this.wantedCurrent(snapshot.pendingCheckpointSlot, tLow), - this.wantedCurrent(snapshot.pendingCheckpointSlot, tHigh), - ); - const predictionSlots = this.enumerate( - this.wantedPrediction(snapshot.pinnedSlot, tLow), - this.wantedPrediction(snapshot.pinnedSlot, tHigh), - ); - return { currentSlots, predictionSlots }; + /** Anchor slot of the prediction rule: the next proposable slot, floored on the pinned block's slot. */ + private wantedPredictionSlot(pinnedSlot: SlotNumber, atSeconds: bigint): number { + return Math.max(pinnedSlot, getSlotAtNextL1Block(atSeconds, this.constants)); } - private wantedCurrent(pendingCheckpointSlot: SlotNumber, t: bigint): number { - return Math.max(pendingCheckpointSlot + 1, getSlotAtNextL1Block(t, this.constants)); - } - - private wantedPrediction(pinnedSlot: SlotNumber, t: bigint): number { - return Math.max(pinnedSlot, getSlotAtNextL1Block(t, this.constants)); - } - - private enumerate(low: number, high: number): number[] { - const result: number[] = []; - for (let s = Math.min(low, high); s <= Math.max(low, high); s++) { - result.push(s); - if (result.length > this.config.maxClockCandidates) { - throw new FeeSnapshotConfigError( - `Drift window enumerated more than maxClockCandidates ${this.config.maxClockCandidates} slots`, - ); - } - } - return result; - } - - /** Three independent staleness checks, each failing closed with its own typed error. */ - private assertFresh(snapshot: FeeSnapshot, nowMs: number, nowSeconds: bigint): void { - const { maxRefreshAgeMs, maxL1HeadAgeSeconds, futureHeadAllowanceSeconds } = this.config; - - if (maxRefreshAgeMs > 0) { - const ageMs = nowMs - snapshot.refreshedAtMs; - if (ageMs > maxRefreshAgeMs) { - throw new FeeSnapshotComputationStaleError(ageMs, maxRefreshAgeMs); - } - } - - if (maxL1HeadAgeSeconds > 0) { - const ageSeconds = Number(nowSeconds - snapshot.l1.blockTimestamp); - if (ageSeconds > maxL1HeadAgeSeconds) { - throw new FeeSnapshotL1HeadStaleError(ageSeconds, maxL1HeadAgeSeconds); - } - } - - if (futureHeadAllowanceSeconds > 0) { - const aheadSeconds = Number(snapshot.l1.blockTimestamp - nowSeconds); - if (aheadSeconds > futureHeadAllowanceSeconds) { - throw new FeeSnapshotFutureHeadError(aheadSeconds, futureHeadAllowanceSeconds); - } - } - } - - private async awaitFirstSnapshot(deadline: number): Promise { - const identity = this.identityProvider.getL1SyncSnapshot(); - if (identity) { - // Kick a refresh so the first snapshot does not wait for the next poll tick. - void this.triggerRefresh('rpc-first').catch(() => undefined); - } - const remaining = deadline - this.dateProvider.now(); - try { - return await this.race(this.firstSnapshot.promise, remaining); - } catch (err) { - if (this.stopped || err instanceof FeeSnapshotStoppedError) { - throw new FeeSnapshotStoppedError(); - } - if (err instanceof TimeoutError) { - throw new FeeSnapshotUnavailableError(); - } - throw err; + /** Fails closed when the pinned L1 head is older than the bound, i.e. the archiver or provider is frozen. */ + private assertHeadFresh(snapshot: FeeSnapshot, nowSeconds: bigint): void { + const { maxL1HeadAgeSeconds } = this.config; + if (maxL1HeadAgeSeconds <= 0) { + return; } - } - - /** Increments the fail-closed counter matching a staleness error that is about to escape to the caller. */ - private recordFailClosed(err: unknown): void { - if (err instanceof FeeSnapshotComputationStaleError) { - this.stats.computationStaleErrors++; - } else if (err instanceof FeeSnapshotL1HeadStaleError) { - this.stats.l1HeadStaleErrors++; - } else if (err instanceof FeeSnapshotFutureHeadError) { - this.stats.futureHeadErrors++; + const ageSeconds = Number(nowSeconds - snapshot.l1.blockTimestamp); + if (ageSeconds > maxL1HeadAgeSeconds) { + throw new FeeQuoteStaleError(ageSeconds, maxL1HeadAgeSeconds); } } - private async readTriggeredRefresh(deadline: number, cause: RefreshCause): Promise { + /** + * Awaits a refresh on behalf of a read, bounded by the remaining deadline. On timeout only the wait is + * abandoned: the shared refresh keeps running for the poll loop and any other waiter. + */ + private async readTriggeredRefresh(deadline: number): Promise { this.stats.readTriggeredRefreshes++; const remaining = deadline - this.dateProvider.now(); if (remaining <= 0) { - this.stats.coverageTimeouts++; - throw new FeeSnapshotCoverageError(`Timed out waiting for fee snapshot refresh (${cause})`); + throw new FeeQuoteUnavailableError('the read deadline elapsed before a refresh could complete'); } try { - await this.race(this.triggerRefresh(cause), remaining); + await executeTimeout(() => this.triggerRefresh('read'), remaining, 'fee snapshot refresh'); } catch (err) { - if (this.stopped || err instanceof FeeSnapshotStoppedError) { - throw new FeeSnapshotStoppedError(); - } if (err instanceof TimeoutError) { - this.stats.coverageTimeouts++; - throw new FeeSnapshotCoverageError(`Timed out waiting for fee snapshot refresh (${cause})`); + throw new FeeQuoteUnavailableError(`no refresh completed within ${remaining}ms`); } throw err; } } - // --------------------------------------------------------------------------------------------------------- - // Background poll loop and single-flight refresh - // --------------------------------------------------------------------------------------------------------- - + /** Refreshes when the archiver identity changed or the covered slots are about to be outrun by the clock. */ private async tick(): Promise { - if (this.stopped) { - return; - } - const now = this.dateProvider.now(); - if (now < this.nextRetryAtMs) { + if (this.stopped || this.dateProvider.now() < this.nextRetryAtMs) { return; } const identity = this.identityProvider.getL1SyncSnapshot(); @@ -390,99 +329,79 @@ export class FeeSnapshotService { return; } const snapshot = this.snapshot; - let cause: RefreshCause | undefined; - if (snapshot === undefined || !identity.blockHash.equals(snapshot.l1.blockHash)) { - cause = 'poll-identity'; - } else if (this.coverageDrifting(snapshot)) { - cause = 'poll-coverage'; - } - if (cause) { - await this.triggerRefresh(cause).catch(() => undefined); - } - } - - /** Detects that the wanted slots are approaching or beyond the covered window, so the window must extend. */ - private coverageDrifting(snapshot: FeeSnapshot): boolean { - const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); - const { currentSlots, predictionSlots } = this.computeWantedSlots(snapshot, nowSeconds); - const wanted = [...currentSlots, ...predictionSlots]; - if (wanted.some(s => !snapshot.candidates.has(s))) { - return true; + if (!snapshot || !identity.blockHash.equals(snapshot.l1.blockHash)) { + await this.triggerRefresh('poll-identity').catch(() => undefined); + } else if (!this.coversUpcomingSlots(snapshot)) { + await this.triggerRefresh('poll-coverage').catch(() => undefined); } - const maxWanted = Math.max(...wanted); - const minWanted = Math.min(...wanted); - const headroomConsumed = maxWanted >= snapshot.topSlot - Math.max(0, this.config.coverageHeadroomSlots - 1); - return headroomConsumed || minWanted < snapshot.baseSlot; } - private refreshKey(identity: L1SyncSnapshot): string { + /** True when the snapshot covers both anchors now and one slot ahead, so an L1 stall cannot freeze quotes. */ + private coversUpcomingSlots(snapshot: FeeSnapshot): boolean { const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); - const { base, top } = this.computeProvisionalWindow(identity, nowSeconds); - return `${identity.blockHash.toString()}:${base}:${top}`; + const lookahead = nowSeconds + BigInt(this.config.slotDuration); + return [nowSeconds, lookahead].every( + atSeconds => + snapshot.candidates.has(this.wantedCurrentSlot(snapshot.pendingCheckpointSlot, atSeconds)) && + snapshot.candidates.has(this.wantedPredictionSlot(snapshot.pinnedSlot, atSeconds)), + ); } + /** + * Single-flight refresh: concurrent callers and the poll loop share one in-flight refresh, which is cleared + * before its waiters resume so the next caller can start a fresh one against a newer identity. + */ private triggerRefresh(cause: RefreshCause): Promise { if (this.stopped) { - return Promise.reject(new FeeSnapshotStoppedError()); + return Promise.reject(new FeeQuoteUnavailableError('the service was stopped')); } // Reads must not bypass the failure backoff: with a failing L1, per-request refreshes would turn incoming // RPC traffic directly into L1 load. Fail fast instead; the background loop retries once the backoff elapses. if (this.dateProvider.now() < this.nextRetryAtMs) { return Promise.reject( - new FeeSnapshotUnavailableError( - `Fee snapshot refresh is backing off after ${this.consecutiveFailures} consecutive failures`, - ), + new FeeQuoteUnavailableError(`refreshes are backing off after ${this.consecutiveFailures} failures`), ); } + if (this.inFlight) { + return this.inFlight; + } const identity = this.identityProvider.getL1SyncSnapshot(); if (!identity) { - return Promise.reject(new FeeSnapshotUnavailableError('No L1 identity available yet')); - } - const key = this.refreshKey(identity); - if (this.inFlight && this.inFlight.key === key) { - return this.inFlight.promise; + return Promise.reject(new FeeQuoteUnavailableError('the archiver has no L1 identity yet')); } - // Keep refreshes serial: chain after any in-flight refresh (ignoring its outcome), then run this one. - const prior = this.inFlight?.promise.catch(() => undefined) ?? Promise.resolve(); - const promise = prior.then(() => this.runRefresh(identity, cause, 0)); - const entry = { key, promise }; - this.inFlight = entry; - return promise.then( - snapshot => { - if (this.inFlight === entry) { - this.inFlight = undefined; - } - return snapshot; - }, - err => { - if (this.inFlight === entry) { - this.inFlight = undefined; - } - throw err; - }, - ); + const refresh: Promise = this.runRefresh(identity, cause).finally(() => { + if (this.inFlight === refresh) { + this.inFlight = undefined; + } + }); + this.inFlight = refresh; + return refresh; } - private async runRefresh(identity: L1SyncSnapshot, cause: RefreshCause, attempt: number): Promise { + private async runRefresh(identity: L1SyncSnapshot, cause: RefreshCause): Promise { try { - // Thrown inside the try so exceeding the restart bound is accounted as a failure and sets backoff. - if (attempt >= MAX_REFRESH_ATTEMPTS) { - throw new FeeSnapshotRefreshError('Fee snapshot refresh exceeded max attempts (identity/tips instability)'); - } - const snapshot = await this.buildSnapshot(identity, cause, attempt); + const snapshot = await this.buildSnapshot(identity); + // No ordering guard on publish: refreshes are single-flight, and L1 identity is hash-authoritative, so + // the height can legitimately move backwards (reorg, or a lagging fallback backend). A height guard + // would discard every rebuild after a rollback and wedge reads. + this.snapshot = snapshot; + this.stats.refreshes++; this.consecutiveFailures = 0; this.nextRetryAtMs = 0; + this.log.debug('Published fee snapshot', { + cause, + blockNumber: snapshot.l1.blockNumber, + pendingCheckpointSlot: snapshot.pendingCheckpointSlot, + pinnedSlot: snapshot.pinnedSlot, + candidateSlots: [...snapshot.candidates.keys()], + }); return snapshot; } catch (err) { - if (err instanceof RestartRefresh) { - return this.runRefresh(err.identity, cause, attempt + 1); - } this.stats.refreshFailures++; this.consecutiveFailures++; this.nextRetryAtMs = this.dateProvider.now() + this.backoffMs(); this.log.warn('Fee snapshot refresh failed; keeping last-good snapshot', { cause, - attempt, consecutiveFailures: this.consecutiveFailures, error: err instanceof Error ? err.message : String(err), }); @@ -495,441 +414,113 @@ export class FeeSnapshotService { return exp / 2 + Math.floor(Math.random() * (exp / 2)); } - private async buildSnapshot(identity: L1SyncSnapshot, cause: RefreshCause, attempt: number): Promise { - const blockNumber = identity.blockNumber; - const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); - const { base, top, pinnedSlot } = this.computeProvisionalWindow(identity, nowSeconds); - - const windowSlots = rangeInclusive(base, top); - const oracleSlots = rangeInclusive(base, top + FEE_ORACLE_LAG - 1); - - // Wave 1: globals + per-slot fee reads over the provisional window. - const wave1Reads: RollupFeeRead[] = [ - { kind: 'tips' }, - { kind: 'manaTarget' }, - { kind: 'manaLimit' }, - { kind: 'provingCostPerManaEth' }, - ...windowSlots.map((s): RollupFeeRead => ({ kind: 'manaMinFeeAt', timestamp: this.tsForSlot(s) })), - ...windowSlots.map((s): RollupFeeRead => ({ kind: 'canPruneAtTime', timestamp: this.tsForSlot(s) })), - ...oracleSlots.map((s): RollupFeeRead => ({ kind: 'l1FeesAt', timestamp: this.tsForSlot(s) })), - ]; - const wave1 = await this.rollup.readFeeInputs(wave1Reads, { blockNumber }); - const reads = new FeeReadResults(wave1); - const tips1 = reads.tips(); - const manaTarget = reads.manaTarget(); - const manaLimit = reads.manaLimit(); - const provingCostPerManaEth = reads.provingCostPerManaEth(); - const manaMinFeeByTs = reads.manaMinFeeByTs(); - const canPruneByTs = reads.canPruneByTs(); - const l1FeesByTs = reads.l1FeesByTs(); - - // Wave 2: pending + proven checkpoints and a re-read of tips for wave consistency. Checkpoint 0 is the - // valid genesis checkpoint, so `pending`/`proven` of 0 are read normally; the proven read is skipped only - // when it coincides with the pending one. - const includeProven = tips1.proven !== tips1.pending; - const wave2Reads: RollupFeeRead[] = [ - { kind: 'checkpoint', checkpointNumber: tips1.pending }, - ...(includeProven ? [{ kind: 'checkpoint' as const, checkpointNumber: tips1.proven }] : []), - { kind: 'tips' }, - ]; - const wave2 = new FeeReadResults(await this.rollup.readFeeInputs(wave2Reads, { blockNumber })); - const tips2 = wave2.tips(); - if (tips2.pending !== tips1.pending || tips2.proven !== tips1.proven) { - // The two waves saw different states (fallback-transport fork mixing); discard and restart. - this.stats.tipsMismatchDiscards++; - this.log.debug('Fee snapshot wave-2 tips differ from wave-1; discarding refresh', { tips1, tips2 }); - throw this.restartWith(identity); - } + /** + * Builds a complete snapshot in four batched stages, all pinned to the identity's L1 block. Each stage's + * inputs are fully determined by the previous ones: tips and governance values, then the checkpoints those + * tips name, then per-candidate fee state, then the L1 fee oracle over the resulting prediction windows. + */ + private async buildSnapshot(identity: L1SyncSnapshot): Promise { + const options = { blockNumber: identity.blockNumber }; - const pendingCheckpoint = wave2.checkpoint(tips1.pending); - const provenCheckpoint = includeProven ? wave2.checkpoint(tips1.proven) : pendingCheckpoint; - const pendingCheckpointSlot = pendingCheckpoint.slotNumber; - - const checkpoints = { - pendingCheckpoint: { slotNumber: pendingCheckpoint.slotNumber, feeHeader: pendingCheckpoint.feeHeader }, - provenCheckpoint: { slotNumber: provenCheckpoint.slotNumber, feeHeader: provenCheckpoint.feeHeader }, - }; + const globals = await this.rollup.getFeeGlobals(options); + const { tips } = globals; - // Step 4: exact coverage validation. With pendingCheckpointSlot now known, compute the exact wanted slots - // and top up any candidate not materialized by the provisional window. - const wantedSlots = this.exactWantedSlots(nowSeconds, pinnedSlot, pendingCheckpointSlot); - const missingCandidateSlots = wantedSlots.filter(s => !manaMinFeeByTs.has(this.tsForSlot(s))); - if (missingCandidateSlots.length > 0) { - this.stats.topUpWaves++; - const topUpReads: RollupFeeRead[] = missingCandidateSlots.flatMap((s): RollupFeeRead[] => [ - { kind: 'manaMinFeeAt', timestamp: this.tsForSlot(s) }, - { kind: 'canPruneAtTime', timestamp: this.tsForSlot(s) }, - ]); - const topUp = new FeeReadResults(await this.rollup.readFeeInputs(topUpReads, { blockNumber })); - topUp.mergeInto(manaMinFeeByTs, canPruneByTs, l1FeesByTs); - } + // Checkpoint 0 is the valid genesis checkpoint, so `pending`/`proven` of 0 are read normally; the proven + // read is skipped only when it coincides with the pending one. + const includeProven = tips.proven !== tips.pending; + const checkpoints = await this.rollup.getCheckpoints( + includeProven ? [tips.pending, tips.proven] : [tips.pending], + options, + ); + const pendingCheckpoint = checkpoints[0]; + const provenCheckpoint = includeProven ? checkpoints[1] : pendingCheckpoint; - const candidateSlots = unique([...windowSlots, ...wantedSlots]); + // Sampled here rather than at refresh entry: two round trips have already elapsed, and the candidate set + // should be centred on the slot a read will want once this snapshot is published. + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); + const pendingCheckpointSlot = pendingCheckpoint.slotNumber; + const pinnedSlot = getSlotAtTimestamp(identity.blockTimestamp, this.constants); + const candidateSlots = unique([ + ...withHeadroom(this.wantedCurrentSlot(pendingCheckpointSlot, nowSeconds)), + ...withHeadroom(this.wantedPredictionSlot(pinnedSlot, nowSeconds)), + ]); + + const slotInputs = await this.rollup.getSlotFeeInputs( + candidateSlots.map(slot => this.tsForSlot(slot)), + options, + ); + const effectiveParents = slotInputs.map(input => (input.canPrune ? provenCheckpoint : pendingCheckpoint)); - // Fetch any oracle L1 fees still missing for the resolved prediction windows. - const neededOracleSlots = new Set(); - for (const s of candidateSlots) { - const canPrune = canPruneByTs.get(this.tsForSlot(s)); - if (canPrune === undefined) { - throw new FeeSnapshotRefreshError(`Missing canPrune for candidate slot ${s}`); - } - const effectiveParentSlot = canPrune - ? checkpoints.provenCheckpoint.slotNumber - : checkpoints.pendingCheckpoint.slotNumber; - const nextSlot = Math.max(effectiveParentSlot + 1, s); - for (let i = 0; i < FEE_ORACLE_LAG; i++) { - neededOracleSlots.add(nextSlot + i); + const oracleSlots = new Set(); + candidateSlots.forEach((slot, i) => { + for (const oracleSlot of getPredictionWindowSlots(SlotNumber(slot), effectiveParents[i].slotNumber)) { + oracleSlots.add(oracleSlot); } - } - const missingOracleSlots = [...neededOracleSlots].filter(s => !l1FeesByTs.has(this.tsForSlot(s))); - if (missingOracleSlots.length > 0) { - this.stats.topUpWaves++; - const oracleReads: RollupFeeRead[] = missingOracleSlots.map( - (s): RollupFeeRead => ({ kind: 'l1FeesAt', timestamp: this.tsForSlot(s) }), - ); - new FeeReadResults(await this.rollup.readFeeInputs(oracleReads, { blockNumber })).mergeInto( - manaMinFeeByTs, - canPruneByTs, - l1FeesByTs, + }); + const orderedOracleSlots = [...oracleSlots].sort((a, b) => a - b); + const { l1Fees, tips: tailTips } = await this.rollup.getL1FeesAndTips( + orderedOracleSlots.map(slot => this.tsForSlot(slot)), + options, + ); + if (tailTips.pending !== tips.pending || tailTips.proven !== tips.proven) { + // Every stage is pinned to one block number, but a fallback transport can still serve two stages from + // backends on different forks at that height. Failing the refresh keeps the last-good snapshot instead + // of publishing a quote assembled from two states; the backoff schedules the retry. + throw new FeeSnapshotError( + `Chain tips changed across the fee refresh: pending ${tips.pending} -> ${tailTips.pending}, ` + + `proven ${tips.proven} -> ${tailTips.proven}`, ); } + const l1FeesBySlot = new Map(orderedOracleSlots.map((slot, i) => [slot, l1Fees[i]])); - // Build complete candidate entries. const candidates = new Map(); - for (const s of candidateSlots) { - candidates.set( - s, - this.buildCandidate(SlotNumber(s), { - manaMinFeeByTs, - canPruneByTs, - l1FeesByTs, - checkpoints, - manaTarget, - manaLimit, - provingCostPerManaEth, - }), - ); - } - - // Archiver identity re-check before publish: if it changed during the reads, restart against the new one. - const after = this.identityProvider.getL1SyncSnapshot(); - if (!after || !after.blockHash.equals(identity.blockHash)) { - this.log.debug('Archiver identity changed during fee snapshot refresh; restarting', { - built: identity.blockNumber, - current: after?.blockNumber, + candidateSlots.forEach((slot, i) => { + candidates.set(slot, { + slot: SlotNumber(slot), + timestamp: this.tsForSlot(slot), + currentMinFee: new GasFees(0, slotInputs[i].manaMinFee), + predictions: this.computePredictionsForSlot(SlotNumber(slot), effectiveParents[i], globals, l1FeesBySlot), }); - if (!after) { - throw new FeeSnapshotUnavailableError('Archiver identity disappeared during refresh'); - } - throw this.restartWith(after); - } - - // Publish the actual materialized bounds, not the provisional window: with a capped window plus top-up, - // candidates can lie outside [base, top], and provisional bounds would make coverageDrifting() request a - // refresh on every poll tick despite full map coverage. - const materializedSlots = [...candidates.keys()]; - const snapshot: FeeSnapshot = { - l1: identity, - pendingCheckpointSlot, - pinnedSlot, - candidates, - baseSlot: SlotNumber(Math.min(...materializedSlots)), - topSlot: SlotNumber(Math.max(...materializedSlots)), - refreshedAtMs: this.dateProvider.now(), - }; - this.publish(snapshot, cause, attempt); - return snapshot; - } - - private restartWith(identity: L1SyncSnapshot): RestartRefresh { - return new RestartRefresh(identity); - } - - private buildCandidate( - slot: SlotNumber, - ctx: { - manaMinFeeByTs: Map; - canPruneByTs: Map; - l1FeesByTs: Map; - checkpoints: { - pendingCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; - provenCheckpoint: { slotNumber: SlotNumber; feeHeader: FeeHeader }; - }; - manaTarget: bigint; - manaLimit: bigint; - provingCostPerManaEth: bigint; - }, - ): FeeQuoteCandidate { - const timestamp = this.tsForSlot(slot); - const manaMinFee = ctx.manaMinFeeByTs.get(timestamp); - const canPrune = ctx.canPruneByTs.get(timestamp); - if (manaMinFee === undefined || canPrune === undefined) { - throw new FeeSnapshotRefreshError(`Missing fee reads for candidate slot ${slot}`); - } - - const l1FeesForSlot = (s: SlotNumber) => { - const fees = ctx.l1FeesByTs.get(this.tsForSlot(s)); - if (!fees) { - throw new FeeSnapshotRefreshError(`Missing L1 fees for slot ${s}`); - } - return fees; - }; - - const predictions = {} as Record; - for (const estimate of [ManaUsageEstimate.None, ManaUsageEstimate.Target, ManaUsageEstimate.Limit]) { - const state = buildFeeOracleState({ - anchorSlot: slot, - canPrune, - pendingCheckpoint: ctx.checkpoints.pendingCheckpoint, - provenCheckpoint: ctx.checkpoints.provenCheckpoint, - manaTarget: ctx.manaTarget, - manaLimit: ctx.manaLimit, - provingCostPerManaEth: ctx.provingCostPerManaEth, - epochDuration: BigInt(this.config.epochDuration), - l1FeesForSlot, - }); - predictions[estimate] = computePredictions(state, estimate); - } - - return { slot, timestamp, currentMinFee: new GasFees(0, manaMinFee), predictions }; - } - - private publish(snapshot: FeeSnapshot, cause: RefreshCause, attempt: number): void { - // No ordering guard here: refreshes are strictly serial (chained in triggerRefresh) and the archiver - // identity was re-checked synchronously just before this call, so the snapshot always reflects the - // archiver's current identity. In particular, never gate on block number — L1 identity is - // hash-authoritative and the height can legitimately move backwards (reorg, or a lagging fallback - // backend); a height guard would discard every rebuild after a rollback and wedge reads. - this.snapshot = snapshot; - this.stats.refreshes++; - if (!this.firstResolved) { - this.firstResolved = true; - this.firstSnapshot.resolve(snapshot); - } - this.log.debug('Published fee snapshot', { - cause, - attempt, - blockNumber: snapshot.l1.blockNumber, - baseSlot: snapshot.baseSlot, - topSlot: snapshot.topSlot, - pendingCheckpointSlot: snapshot.pendingCheckpointSlot, - pinnedSlot: snapshot.pinnedSlot, - candidateCount: snapshot.candidates.size, }); - } - private computeProvisionalWindow( - identity: L1SyncSnapshot, - nowSeconds: bigint, - ): { base: number; top: number; pinnedSlot: SlotNumber } { - const drift = BigInt(this.config.clockDriftAllowanceSeconds); - const pinnedSlot = getSlotAtTimestamp(identity.blockTimestamp, this.constants); - const lowSlot = getSlotAtNextL1Block(nowSeconds - drift, this.constants); - const highSlot = getSlotAtNextL1Block(nowSeconds + drift, this.constants); - let base = Math.min(lowSlot, pinnedSlot); - const top = Math.max(highSlot, pinnedSlot + 1) + this.config.coverageHeadroomSlots; - if (top - base + 1 > this.config.maxCandidateWindowSlots) { - // Host clock diverges wildly from the pinned L1 timestamp: materialize a capped window anchored at the - // wall-clock end and rely on the exact-coverage top-up to add the pinned-slot floor candidates. - base = Math.max(base, top - this.config.maxCandidateWindowSlots + 1); - } - return { base, top, pinnedSlot }; - } - - private exactWantedSlots(nowSeconds: bigint, pinnedSlot: SlotNumber, pendingCheckpointSlot: SlotNumber): number[] { - // Enumerate every slot between the drift-window endpoints, exactly like the read path does: with a capped - // provisional window, materializing only the endpoints would leave intermediate wanted slots uncovered and - // reads would coverage-miss into an error no refresh can repair. - const drift = BigInt(this.config.clockDriftAllowanceSeconds); - const tLow = nowSeconds - drift; - const tHigh = nowSeconds + drift; - const current = this.enumerate( - this.wantedCurrent(pendingCheckpointSlot, tLow), - this.wantedCurrent(pendingCheckpointSlot, tHigh), - ); - const prediction = this.enumerate( - this.wantedPrediction(pinnedSlot, tLow), - this.wantedPrediction(pinnedSlot, tHigh), - ); - return unique([...current, ...prediction]); + return { l1: identity, pendingCheckpointSlot, pinnedSlot, candidates }; + } + + /** Computes the complete prediction array for every mana-usage estimate at a single candidate slot. */ + private computePredictionsForSlot( + anchorSlot: SlotNumber, + effectiveParent: { slotNumber: SlotNumber; feeHeader: FeeHeader }, + globals: RollupFeeGlobals, + l1FeesBySlot: Map, + ): Record { + const state = buildFeeOracleState({ + anchorSlot, + effectiveParent, + manaTarget: globals.manaTarget, + manaLimit: globals.manaLimit, + provingCostPerManaEth: globals.provingCostPerManaEth, + epochDuration: BigInt(this.config.epochDuration), + l1FeesForSlot: slot => { + const fees = l1FeesBySlot.get(slot); + if (!fees) { + throw new FeeSnapshotError(`Fee refresh is missing the L1 fees for slot ${slot}`); + } + return fees; + }, + }); + return { + [ManaUsageEstimate.None]: computePredictions(state, ManaUsageEstimate.None), + [ManaUsageEstimate.Target]: computePredictions(state, ManaUsageEstimate.Target), + [ManaUsageEstimate.Limit]: computePredictions(state, ManaUsageEstimate.Limit), + }; } private tsForSlot(slot: number): bigint { return getTimestampForSlot(SlotNumber(slot), this.constants); } - - private async race(promise: Promise, ms: number): Promise { - if (ms <= 0) { - throw new TimeoutError(); - } - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new TimeoutError()), ms); - }); - try { - return await Promise.race([promise, timeout, this.stopSignal.promise]); - } finally { - if (timer) { - clearTimeout(timer); - } - } - } -} - -/** Sentinel thrown internally to restart a refresh against a newer identity without failing the attempt. */ -class RestartRefresh extends Error { - constructor(public readonly identity: L1SyncSnapshot) { - super('restart refresh'); - this.name = 'RestartRefresh'; - } -} - -/** Internal error thrown for unexpected refresh-build failures. */ -class FeeSnapshotRefreshError extends FeeSnapshotError { - constructor(message: string) { - super(message); - this.name = 'FeeSnapshotRefreshError'; - } -} - -/** Parses a `RollupFeeReadResult[]` into typed globals and timestamp-keyed maps. */ -class FeeReadResults { - private readonly minFee = new Map(); - private readonly canPrune = new Map(); - private readonly l1Fees = new Map(); - private readonly checkpoints = new Map(); - private tipsValue: { pending: CheckpointNumber; proven: CheckpointNumber } | undefined; - private manaTargetValue: bigint | undefined; - private manaLimitValue: bigint | undefined; - private provingCostValue: bigint | undefined; - - constructor(results: RollupFeeReadResult[]) { - for (const result of results) { - switch (result.kind) { - case 'tips': - this.tipsValue = result.value; - break; - case 'manaTarget': - this.manaTargetValue = result.value; - break; - case 'manaLimit': - this.manaLimitValue = result.value; - break; - case 'provingCostPerManaEth': - this.provingCostValue = result.value; - break; - case 'manaMinFeeAt': - this.minFee.set(result.timestamp, result.value); - break; - case 'canPruneAtTime': - this.canPrune.set(result.timestamp, result.value); - break; - case 'l1FeesAt': - this.l1Fees.set(result.timestamp, result.value); - break; - case 'checkpoint': - this.checkpoints.set(Number(result.checkpointNumber), { - slotNumber: result.value.slotNumber, - feeHeader: result.value.feeHeader, - }); - break; - } - } - } - - tips(): { pending: CheckpointNumber; proven: CheckpointNumber } { - if (!this.tipsValue) { - throw new FeeSnapshotRefreshError('Missing tips in fee reads'); - } - return this.tipsValue; - } - - manaTarget(): bigint { - return required(this.manaTargetValue, 'manaTarget'); - } - - manaLimit(): bigint { - return required(this.manaLimitValue, 'manaLimit'); - } - - provingCostPerManaEth(): bigint { - return required(this.provingCostValue, 'provingCostPerManaEth'); - } - - checkpoint(checkpointNumber: CheckpointNumber): { slotNumber: SlotNumber; feeHeader: FeeHeader } { - const value = this.checkpoints.get(Number(checkpointNumber)); - if (!value) { - throw new FeeSnapshotRefreshError(`Missing checkpoint ${checkpointNumber} in fee reads`); - } - return value; - } - - manaMinFeeByTs(): Map { - return this.minFee; - } - - canPruneByTs(): Map { - return this.canPrune; - } - - l1FeesByTs(): Map { - return this.l1Fees; - } - - mergeInto( - minFee: Map, - canPrune: Map, - l1Fees: Map, - ): void { - for (const [k, v] of this.minFee) { - minFee.set(k, v); - } - for (const [k, v] of this.canPrune) { - canPrune.set(k, v); - } - for (const [k, v] of this.l1Fees) { - l1Fees.set(k, v); - } - } } -function required(value: T | undefined, name: string): T { - if (value === undefined) { - throw new FeeSnapshotRefreshError(`Missing ${name} in fee reads`); - } - return value; -} - -function rangeInclusive(low: number, high: number): number[] { - const result: number[] = []; - for (let s = low; s <= high; s++) { - result.push(s); - } - return result; -} - -function unique(values: number[]): number[] { - return [...new Set(values)]; -} - -/** Element-wise max over a set of GasFees (per dimension). */ -function maxGasFees(fees: GasFees[]): GasFees { - return fees.reduce( - (acc, f) => - new GasFees( - acc.feePerDaGas > f.feePerDaGas ? acc.feePerDaGas : f.feePerDaGas, - acc.feePerL2Gas > f.feePerL2Gas ? acc.feePerL2Gas : f.feePerL2Gas, - ), - new GasFees(0, 0), - ); -} - -/** Element-wise max merge of several equal-length GasFees arrays. */ -function maxGasFeesElementWise(arrays: GasFees[][]): GasFees[] { - const length = arrays[0]?.length ?? 0; - const result: GasFees[] = []; - for (let i = 0; i < length; i++) { - result.push(maxGasFees(arrays.map(a => a[i]))); - } - return result; +/** The anchor slot plus its headroom slots, in ascending order. */ +function withHeadroom(anchorSlot: number): number[] { + return times(CANDIDATE_HEADROOM_SLOTS + 1, i => anchorSlot + i); } diff --git a/yarn-project/sequencer-client/src/global_variable_builder/index.ts b/yarn-project/sequencer-client/src/global_variable_builder/index.ts index 2467e0487c89..56d04ccadcfa 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/index.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/index.ts @@ -1,23 +1,11 @@ export { FeeProviderImpl } from './fee_provider.js'; export { GlobalVariableBuilder, type GlobalVariableBuilderConfig } from './global_builder.js'; -export { FeeSnapshotService, type RollupFeeReader, type FeeSnapshotStats } from './fee_snapshot_service.js'; export { - type FeeSnapshot, - type FeeQuoteCandidate, + FeeQuoteStaleError, + FeeQuoteUnavailableError, + FeeSnapshotError, + FeeSnapshotService, type FeeSnapshotServiceConfig, + type FeeSnapshotStats, getDefaultFeeSnapshotServiceConfig, - FeeSnapshotError, - FeeSnapshotConfigError, - FeeSnapshotUnavailableError, - FeeSnapshotStoppedError, - FeeSnapshotCoverageError, - FeeSnapshotComputationStaleError, - FeeSnapshotL1HeadStaleError, - FeeSnapshotFutureHeadError, -} from './fee_snapshot.js'; -export { computePredictions, buildFeeOracleState, type FeeOracleState } from './fee_prediction.js'; -export { - computeLegacyCurrentMinFees, - computeLegacyPredictedMinFees, - fetchLegacyFeeOracleState, -} from './legacy_fee_oracle.js'; +} from './fee_snapshot_service.js'; diff --git a/yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts b/yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts deleted file mode 100644 index 7ce9d0606c89..000000000000 --- a/yarn-project/sequencer-client/src/global_variable_builder/legacy_fee_oracle.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { RollupContract } from '@aztec/ethereum/contracts'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import { times } from '@aztec/foundation/collection'; -import { - type L1RollupConstants, - getNextL1SlotTimestamp, - getSlotAtNextL1Block, - getTimestampForSlot, -} from '@aztec/stdlib/epoch-helpers'; -import { FEE_ORACLE_LAG, GasFees, type ManaUsageEstimate, computeExcessMana } from '@aztec/stdlib/gas'; - -import { type FeeOracleState, computePredictions } from './fee_prediction.js'; - -type LegacyOracleConstants = Pick; - -/** - * Faithful refactor of the legacy `FeeProviderImpl.computeCurrentMinFees`, taking `(blockNumber, now)` explicitly - * and pinned to that block. Retained only as the equivalence oracle for the snapshot service in tests; the - * production current-fee path is served from the snapshot. - */ -export async function computeLegacyCurrentMinFees( - rollup: RollupContract, - blockNumber: bigint, - nowSeconds: number, - constants: LegacyOracleConstants, -): Promise { - const pendingCheckpointNumber = await rollup.getCheckpointNumber({ blockNumber }); - const lastCheckpoint = await rollup.getCheckpoint(pendingCheckpointNumber, { blockNumber }); - const earliestTimestamp = getTimestampForSlot(SlotNumber.add(lastCheckpoint.slotNumber, 1), constants); - const nextEthTimestamp = getNextL1SlotTimestamp(nowSeconds, constants); - const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; - return new GasFees(0, await rollup.getManaMinFeeAt(timestamp, true, { blockNumber })); -} - -/** - * Faithful refactor of the legacy `FeePredictor.fetchState`, taking `(blockNumber, now)` explicitly and pinned to - * that block. Retained only as the equivalence oracle for the snapshot service in tests. - */ -export async function fetchLegacyFeeOracleState( - rollup: RollupContract, - blockNumber: bigint, - nowSeconds: number, - constants: LegacyOracleConstants, -): Promise { - const opts = { blockNumber }; - const [manaTarget, manaLimit, provingCostPerManaEth, epochDuration] = await Promise.all([ - rollup.readManaTarget(opts), - rollup.readManaLimit(opts), - rollup.readProvingCostPerManaInEth(opts), - rollup.getEpochDuration(), - ]); - - const currentSlot = await rollup.getSlotNumber(opts); - const slotAtNextL1Block = getSlotAtNextL1Block(BigInt(nowSeconds), constants); - const preliminaryNextSlot = SlotNumber(Math.max(currentSlot, slotAtNextL1Block)); - const nextSlotTimestamp = getTimestampForSlot(preliminaryNextSlot, constants); - - const lastCheckpoint = await rollup.getEffectivePendingCheckpoint(nextSlotTimestamp, opts); - const lastSlot = lastCheckpoint.slotNumber; - const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), preliminaryNextSlot)); - const feeHeader = lastCheckpoint.feeHeader; - - const timestamps = times(FEE_ORACLE_LAG, i => getTimestampForSlot(SlotNumber.add(nextSlot, i), constants)); - const l1FeesBySlot = await Promise.all(timestamps.map(ts => rollup.getL1FeesAt(ts, opts))); - - return { - lastSlot, - excessMana: computeExcessMana(feeHeader.excessMana, feeHeader.manaUsed, manaTarget), - ethPerFeeAsset: feeHeader.ethPerFeeAsset, - manaTarget, - manaLimit, - provingCostPerManaEth, - epochDuration: BigInt(epochDuration), - l1FeesBySlot, - }; -} - -/** Legacy `getPredictedMinFees`: current fee followed by the prediction window, on identical explicit inputs. */ -export async function computeLegacyPredictedMinFees( - rollup: RollupContract, - blockNumber: bigint, - nowSeconds: number, - constants: LegacyOracleConstants, - manaUsage: ManaUsageEstimate, -): Promise { - const [current, state] = await Promise.all([ - computeLegacyCurrentMinFees(rollup, blockNumber, nowSeconds, constants), - fetchLegacyFeeOracleState(rollup, blockNumber, nowSeconds, constants), - ]); - return [current, ...computePredictions(state, manaUsage)]; -} diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 6b632e9725a7..6858f04b1f8e 100644 --- a/yarn-project/stdlib/src/gas/README.md +++ b/yarn-project/stdlib/src/gas/README.md @@ -159,69 +159,51 @@ Key observations: The `getCurrentMinFees` and `getPredictedMinFees` node RPCs (consumed by wallets and by the p2p mempool admission/eviction policy) are served from an in-memory **fee snapshot** maintained by `FeeSnapshotService` (`sequencer-client/src/global_variable_builder`), so a warm call issues zero L1 requests. A background loop -refreshes the snapshot once per L1 block — and proactively as the wall clock drifts toward the top of the -covered window, so empty-Ethereum-slot runs do not freeze quotes — pinning every read to the archiver's -synced L1 block number, labelling it with that block's hash, and re-validating the archiver identity before -publishing via an atomic pointer swap. Each candidate slot stores a *complete* precomputed quote: the -Solidity `getManaMinFeeAt` current fee plus a full `FEE_ORACLE_LAG`-length prediction array per -`ManaUsageEstimate`. Reads merge only complete arrays (element-wise max), never synthesizing a mixed oracle -tuple. +refreshes it whenever the archiver publishes a new L1 identity, and proactively when the wall clock is about +to outrun the covered slots so a run of empty Ethereum slots cannot freeze quotes. Every value in a snapshot +is read at the archiver's synced L1 block, and each candidate slot holds a *complete* precomputed quote: the +Solidity `getManaMinFeeAt` current fee plus a `FEE_ORACLE_LAG`-length prediction array per `ManaUsageEstimate`. + +The refresh runs four batched, pinned stages, each of whose inputs the previous ones fully determine: chain +tips and governance values; the checkpoints those tips name; the current fee and prune-ability per candidate +slot; the L1 fee oracle over the resulting prediction windows. A read serves the candidate for its wanted slot +or triggers a refresh — it never substitutes another slot's answer. Concurrent reads and the poll loop share +one in-flight refresh, which backs off exponentially on failure and keeps the last-good snapshot stored. ### The two anchor rules -A quote applies to a future slot, and the two RPCs floor that slot differently (preserving the -pre-snapshot behaviour): +A quote applies to a future slot, and the two RPCs floor that slot differently (preserving the pre-snapshot +behaviour, which they observably differ on whenever a checkpoint lands in the current slot): - **current fee** floors on the raw pending checkpoint slot: `wantedCurrent(t) = max(pendingCheckpointSlot + 1, slotAtNextL1Block(t))`; -- **prediction** floors on the slot of the pinned L1 block and the prune-aware effective parent: +- **prediction** floors on the slot of the pinned L1 block, over the prune-aware effective parent: `wantedPrediction(t) = max(pinnedSlot, slotAtNextL1Block(t))`. -These floors come only from snapshot-level fields (`pendingCheckpointSlot`, `pinnedSlot`); there is no -per-candidate selection state. The checkpoint-slot invariant `pendingCheckpointSlot <= pinnedSlot` (a -proposed checkpoint's slot equals the slot of its L1 inclusion timestamp) lets the refresh build a -conservative window before it has read the checkpoints, then validate exact coverage afterwards. - -### Drift window - -To tolerate clock skew between the node and L1, a read enumerates every distinct Aztec slot in -`[wanted(now - drift), wanted(now + drift)]` (small default, e.g. 2s; `0` disables the window and reduces -selection to the single legacy anchor) and takes the element-wise max of the complete candidate arrays. A -coverage miss — a wanted slot outside the materialized window, above or below, symmetrically — never -substitutes another slot's answer: it awaits a keyed single-flight refresh and, on timeout, fails closed -with a typed error. Concurrent reads and the poll loop share one refresh per identity/window. - -### Staleness bounds +Both floors come only from snapshot-level fields, so there is no per-candidate selection state. Each anchor is +materialized with a small headroom, which is what lets reads keep working while L1 is quiet. -Three independent checks each fail closed with their own typed error and metric, and each is disabled by -setting its config to `0`: +### Staleness -- **computation age** (`now - refreshedAtMs`, reset by every successful refresh including coverage-only) — - exceeding it means refresh is broken; -- **L1-head age** (`now - pinnedBlockTimestamp`, never reset by coverage-only refreshes) — exceeding it - means the provider or archiver is frozen; -- **future-dated head** (`pinnedBlockTimestamp - now`) — fails closed in production; test environments that - warp L1 time align their clock or set the allowance to `0`. +One bound, disabled by setting it to `0`: the pinned **L1-head age** (`now - pinnedBlockTimestamp`) fails +closed with a typed error, catching a frozen provider or archiver. Nothing else can go silently stale, +because a read compares the archiver identity against the snapshot before serving: a superseded snapshot is +never served, it forces a refresh, and a refresh that cannot replace it surfaces its own error. ### Consistency stance -Reads are block-number pinned, hash-labelled, archiver-rechecked, and wave-consistency-checked: the refresh -re-reads `getTips` in its second multicall wave and discards the refresh if the tips differ from the first -wave (catching fork-mixing across the fallback transport). This is strictly better on state consistency than -the pre-snapshot current-fee path, which ran entirely unpinned at `latest`; on identity freshness the RPC-side -archiver identity check gives parity with the old per-call latest-block check, bounded by archiver sync -health. Residual risks, stated plainly: - -- each multicall wave is atomic only on whichever fallback-transport backend served it. Without the tips - check tripping, the two waves could still mix backends/forks whose tips coincide but whose oracle or - governance state differs — producing an **impossible combined state**, not merely a snapshot coloured by - one fork. The tips check narrows this; it does not eliminate it. -- in the parallel-individual-call fallback (no Multicall3 deployed), even a single logical wave can mix - backends — a deliberately weaker guarantee than in multicall mode. -- a same-height reorg not yet observed by the archiver persists for at most one archiver poll + refresh cycle. - -EIP-1898 hash pinning or explicit backend binding is the only complete fix and remains a contained follow-up -inside the refresh function; it is not part of the current design. +Every stage is pinned to one L1 block number and the snapshot is labelled with that block's hash — strictly +better than the pre-snapshot current-fee path, which ran entirely unpinned at `latest`. On freshness, the +read-time archiver identity check gives parity with the old per-call latest-block check, bounded by archiver +sync health. + +Block-number pinning does not identify a fork, so divergent fallback-transport backends at the same height +could otherwise color a snapshot. The final stage therefore re-reads `getTips` in the same batch as the L1 +fees and fails the refresh if they differ from the first stage, turning a *persistent* divergence into loud, +fail-closed unavailability instead of persistently wrong quotes. Residual, stated honestly: divergence the +tips comparison cannot see — forks whose tips coincide but whose oracle or governance state differs, or +divergence confined to one stage's batch — can still color one published snapshot. EIP-1898 hash pinning or +explicit backend binding is the only complete fix and is out of scope. ## Fee Asset Price