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/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..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 @@ -6,6 +6,7 @@ * 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'; @@ -15,6 +16,7 @@ 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'; @@ -30,6 +32,15 @@ import { setup } from '../fixtures/utils.js'; import type { TestWallet } from '../test-wallet/test_wallet.js'; import { proveInteraction } from '../test-wallet/utils.js'; +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,6 +114,7 @@ describe('e2e_node_rpc_perf', () => { let logger: Logger; let aztecNode: AztecNode & AztecNodeDebug; + let aztecNodeService: AztecNodeService; let wallet: TestWallet; let ownerAddress: AztecAddress; let rollupCheatCodes: RollupCheatCodes; @@ -145,6 +157,7 @@ describe('e2e_node_rpc_perf', () => { teardown, logger, aztecNode, + aztecNodeService, wallet, accounts: [ownerAddress], cheatCodes: { rollup: rollupCheatCodes }, @@ -563,6 +576,104 @@ describe('e2e_node_rpc_perf', () => { }); }); + // 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++) { + const timer = new Timer(); + await fn(); + timings.push(timer.ms()); + } + return timings; + } + + 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); + + 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 (concurrent)', async () => { + await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit); + + 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 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, + }); + }); + }); + describe('log APIs', () => { it('benchmarks getPrivateLogsByTags', async () => { const tags = [SiloedTag.random()]; diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index ef62956c6522..7110360a63a2 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -38,6 +38,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 +135,28 @@ export type L1FeeData = { blobFee: bigint; }; +/** 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 { HeaderHash = 0, @@ -270,6 +293,8 @@ export class RollupContract { address: EthAddress; contract: GetContractReturnType; }; + /** Cached feature-detection of Multicall3 bytecode presence, used by the batched fee reads. */ + private multicall3Available: boolean | undefined; static get checkBlobStorageSlot(): bigint { const asString = RollupStorage.find(storage => storage.label === 'checkBlob')?.slot; @@ -683,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 */ @@ -740,12 +750,9 @@ export class RollupContract { ); } - async getTips(): Promise<{ pending: CheckpointNumber; proven: CheckpointNumber }> { - const { pending, proven } = await this.rollup.read.getTips(); - return { - pending: CheckpointNumber.fromBigInt(pending), - proven: CheckpointNumber.fromBigInt(proven), - }; + async getTips(options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + return toChainTips(await this.rollup.read.getTips(options)); } getTimestampForSlot(slot: SlotNumber): Promise { @@ -1134,8 +1141,184 @@ 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; + } + + /** + * 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. + */ + private multicallOptions(blockNumber: bigint) { + return { + allowFailure: false as const, + blockNumber, + batchSize: 0, + multicallAddress: MULTI_CALL_3_ADDRESS, + }; + } + + /** 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 [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 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 { @@ -1437,3 +1620,38 @@ export class RollupContract { return entryBase + fieldOffset; } } + +/** 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), + }; +} + +/** 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 new file mode 100644 index 000000000000..35141823bc01 --- /dev/null +++ b/yarn-project/ethereum/src/contracts/rollup_fee_reads.test.ts @@ -0,0 +1,110 @@ +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, startAnvil } from '../test/index.js'; +import type { ViemClient } from '../types.js'; +import { RollupContract } 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; + /** 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; + + 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, + }); + + 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); + + 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 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), + ); + expect(await rollup.getSlotFeeInputs(timestamps, options)).toEqual( + await rollupWithoutMulticall.getSlotFeeInputs(timestamps, options), + ); + expect(await rollup.getL1FeesAndTips(timestamps, options)).toEqual( + await rollupWithoutMulticall.getL1FeesAndTips(timestamps, options), + ); + }, 30_000); + + 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); + }); +}); 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..2ceed40ff468 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_prediction.ts @@ -0,0 +1,122 @@ +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. 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. */ + 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[]; +}; + +/** + * 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; + effectiveParent: { slotNumber: SlotNumber; feeHeader: FeeHeader }; + manaTarget: bigint; + manaLimit: bigint; + provingCostPerManaEth: bigint; + epochDuration: bigint; + l1FeesForSlot: (slot: SlotNumber) => L1FeeData; +}): FeeOracleState { + const lastSlot = params.effectiveParent.slotNumber; + const feeHeader = params.effectiveParent.feeHeader; + const l1FeesBySlot = getPredictionWindowSlots(params.anchorSlot, lastSlot).map(params.l1FeesForSlot); + + 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_equivalence.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts new file mode 100644 index 000000000000..1904bf423748 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_equivalence.test.ts @@ -0,0 +1,408 @@ +import { getPublicClient } from '@aztec/ethereum/client'; +import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; +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, 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 { + 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 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', () => { + 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 proofSubmissionEpochs: 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(); + proofSubmissionEpochs = await rollup.getProofSubmissionEpochs(); + 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 }), + // The cases below drive the clock explicitly, so the head-age bound is disabled and the loop never ticks. + maxL1HeadAgeSeconds: 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); + } + + 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()); + }); + + 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 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 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(); + 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); + }); + + // 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); + }); +}); 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..05e5465b775d --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.test.ts @@ -0,0 +1,420 @@ +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 { + FeeQuoteStaleError, + FeeQuoteUnavailableError, + FeeSnapshotService, + type FeeSnapshotServiceConfig, + type RollupFeeReader, + getDefaultFeeSnapshotServiceConfig, +} 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, + 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. `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 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 stage calls to fail. */ + public failNext = 0; + /** Optional gate: when set, every stage waits on this before resolving. */ + public gate: Promise | 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 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.blockNumbers.push(options.blockNumber); + if (this.gate) { + await this.gate; + } + if (this.failNext > 0) { + this.failNext--; + throw new Error('L1 read failed'); + } + } +} + +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, + }), + refreshTimeoutMs: 100, + pollIntervalMs: 10_000_000, + ...overrides, + }; + 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(); + 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 the current anchor is 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).toBe(STAGES_PER_REFRESH); + 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); + } + expect(rollup.callCount).toBe(STAGES_PER_REFRESH); + }); + + 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 + STAGES_PER_REFRESH); + 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 + STAGES_PER_REFRESH); + 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: 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(FeeQuoteUnavailableError); + 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('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(); + 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('reports unavailable when there is no L1 identity yet', async () => { + identity.snapshot = undefined; + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteUnavailableError); + }); + + it('reports unavailable for reads after the service is stopped', async () => { + await service.getCurrentMinFees(); + await service.stop(); + await expect(service.getCurrentMinFees()).rejects.toBeInstanceOf(FeeQuoteUnavailableError); + }); + + 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('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(); + }); + + 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('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; + + const read = service.getCurrentMinFees(); + await sleep(1); + identity.snapshot = next; + rollup.gate = undefined; + gate.resolve(); + + 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('lands on a same-height reorg published mid-refresh', async () => { + await assertReadLandsOn(makeIdentity(1n, PINNED_SLOT, Buffer32.fromNumber(777))); + }); + }); + + 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(); + // 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('serves a read landing exactly on a slot boundary while a coverage refresh is in flight', async () => { + service = makeService({ pollIntervalMs: 10 }); + await service.getCurrentMinFees(); + + 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); + + const fees = await service.getCurrentMinFees(); + expect(fees.feePerL2Gas).toBe(103n); + expect(service.getStats().refreshes).toBe(1); + + 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 new file mode 100644 index 000000000000..1799702cde72 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_snapshot_service.ts @@ -0,0 +1,526 @@ +import type { + CheckpointLog, + FeeHeader, + L1FeeData, + RollupChainTips, + RollupFeeGlobals, + RollupSlotFeeInputs, +} from '@aztec/ethereum/contracts'; +import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-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 { 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 { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; + +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 { + /** 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 }>; +} + +/** + * 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 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; +}; + +/** 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; + +/** + * 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} whose every value was read at the archiver's synced L1 block. + */ +export class FeeSnapshotService { + private snapshot: FeeSnapshot | undefined; + private inFlight: Promise | undefined; + + private readonly runningPromise: RunningPromise; + private stopped = false; + + private consecutiveFailures = 0; + private nextRetryAtMs = 0; + + private readonly constants: Pick; + private readonly stats: FeeSnapshotStats = { + refreshes: 0, + refreshFailures: 0, + readTriggeredRefreshes: 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.constants = { + l1GenesisTime: config.l1GenesisTime, + slotDuration: config.slotDuration, + ethereumSlotDuration: config.ethereumSlotDuration, + }; + this.runningPromise = new RunningPromise(() => this.tick(), this.log, config.pollIntervalMs); + } + + /** Starts the background refresh loop. */ + public start(): void { + if (this.stopped) { + 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 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; + await this.runningPromise.stop(); + await this.inFlight?.catch(() => undefined); + 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 { 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 { current, prediction } = await this.resolveLookup(); + return [current.currentMinFee, ...prediction.predictions[manaUsage]]; + } + + /** + * 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; + + for (let attempt = 0; attempt < MAX_LOOKUP_ATTEMPTS; attempt++) { + if (this.stopped) { + throw new FeeQuoteUnavailableError('the service was stopped'); + } + const snapshot = this.snapshot; + if (!snapshot) { + await this.readTriggeredRefresh(deadline); + continue; + } + + // 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); + continue; + } + + const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); + 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; + } + + this.assertHeadFresh(snapshot, nowSeconds); + return { current, prediction }; + } + + throw new FeeQuoteUnavailableError(`no snapshot covered the wanted slots within ${this.config.refreshTimeoutMs}ms`); + } + + /** 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)); + } + + /** 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)); + } + + /** 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; + } + const ageSeconds = Number(nowSeconds - snapshot.l1.blockTimestamp); + if (ageSeconds > maxL1HeadAgeSeconds) { + throw new FeeQuoteStaleError(ageSeconds, maxL1HeadAgeSeconds); + } + } + + /** + * 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) { + throw new FeeQuoteUnavailableError('the read deadline elapsed before a refresh could complete'); + } + try { + await executeTimeout(() => this.triggerRefresh('read'), remaining, 'fee snapshot refresh'); + } catch (err) { + if (err instanceof TimeoutError) { + throw new FeeQuoteUnavailableError(`no refresh completed within ${remaining}ms`); + } + throw err; + } + } + + /** 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 || this.dateProvider.now() < this.nextRetryAtMs) { + return; + } + const identity = this.identityProvider.getL1SyncSnapshot(); + if (!identity) { + return; + } + const snapshot = this.snapshot; + 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); + } + } + + /** 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 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 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 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 FeeQuoteUnavailableError('the archiver has no L1 identity yet')); + } + 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): Promise { + try { + 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) { + this.stats.refreshFailures++; + this.consecutiveFailures++; + this.nextRetryAtMs = this.dateProvider.now() + this.backoffMs(); + this.log.warn('Fee snapshot refresh failed; keeping last-good snapshot', { + cause, + 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)); + } + + /** + * 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 globals = await this.rollup.getFeeGlobals(options); + const { tips } = globals; + + // 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; + + // 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)); + + const oracleSlots = new Set(); + candidateSlots.forEach((slot, i) => { + for (const oracleSlot of getPredictionWindowSlots(SlotNumber(slot), effectiveParents[i].slotNumber)) { + oracleSlots.add(oracleSlot); + } + }); + 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]])); + + const candidates = new Map(); + 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), + }); + }); + + 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); + } +} + +/** 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/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..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,3 +1,11 @@ export { FeeProviderImpl } from './fee_provider.js'; export { GlobalVariableBuilder, type GlobalVariableBuilderConfig } from './global_builder.js'; -export { FeePredictor } from './fee_predictor.js'; +export { + FeeQuoteStaleError, + FeeQuoteUnavailableError, + FeeSnapshotError, + FeeSnapshotService, + type FeeSnapshotServiceConfig, + type FeeSnapshotStats, + getDefaultFeeSnapshotServiceConfig, +} from './fee_snapshot_service.js'; diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 1bce97106cdb..6858f04b1f8e 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,57 @@ 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 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, 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, over the prune-aware effective parent: + `wantedPrediction(t) = max(pinnedSlot, slotAtNextL1Block(t))`. + +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. + +### Staleness + +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 + +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 Fees are computed in ETH (wei) internally and converted to the fee asset (Fee Juice) via