diff --git a/yarn-project/aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts b/yarn-project/aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts index bebbe9cd2fac..c2d2b1e8a80c 100644 --- a/yarn-project/aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts @@ -34,10 +34,12 @@ import { BlockHeader, GlobalVariables, type Tx } from '@aztec/stdlib/tx'; import { getPackageVersion } from '@aztec/stdlib/update-checker'; import { NativeWorldStateService } from '@aztec/world-state'; +import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; import { foundry } from 'viem/chains'; import { type AztecNodeConfig, getConfigEnvVars } from './config.js'; +import { NextBlockPredictor } from './next_block/index.js'; import { AztecNodeService } from './server.js'; /** @@ -182,6 +184,17 @@ describe('fee quote vs public simulation', () => { rollupAddress: EthAddress.fromString(rollup.address), }; + // Not started: every boundary fee is priced inline on the request that misses, so the scenarios below + // control exactly when L1 is asked. + const nextBlockPredictor = NextBlockPredictor.create({ + blockSource, + globalVariableBuilder, + rollupContract: rollup, + epochCache, + signatureContext: { chainId: foundry.id, rollupAddress: config.rollupAddress }, + dateProvider, + }); + node = new AztecNodeService({ config, p2pClient: mock(), @@ -200,6 +213,7 @@ describe('fee quote vs public simulation', () => { globalVariableBuilder, rollupContract: rollup, feeProvider, + nextBlockPredictor, epochCache, packageVersion: getPackageVersion(), peerProofVerifier: new TestCircuitVerifier(), @@ -406,6 +420,30 @@ describe('fee quote vs public simulation', () => { expect(output.globalVariables.gasFees.feePerL2Gas).toEqual(lowFee); }, 120_000); + it('reuses the cached boundary fee across simulations instead of asking L1 again', async () => { + await startNodeAt(tsOf(changeSlot)); + mockL2Frontier({ + proposed: BlockNumber(1), + checkpointedBlock: BlockNumber(1), + checkpointedTipSlot: SlotNumber(changeSlot - 1), + latestBlockGlobals: { slotNumber: SlotNumber(changeSlot - 1), gasFees: GasFees.empty() }, + }); + const fees = await feeProvider.getPredictedMinFees(ManaUsageEstimate.Limit); + const buildGlobals = jest.spyOn(globalVariableBuilder, 'buildCheckpointGlobalVariables'); + + const first = await node.simulatePublicCalls(await makeTx(0x50000, walletCap(fees))); + expect(buildGlobals).toHaveBeenCalledTimes(1); + + // Nothing moved between the two requests, so the second one is priced from memory: an RPC caller + // hammering the node cannot turn L2 simulation traffic into L1 traffic. + const second = await node.simulatePublicCalls(await makeTx(0x60000, walletCap(fees))); + expect(buildGlobals).toHaveBeenCalledTimes(1); + expect(second.globalVariables.gasFees).toEqual(first.globalVariables.gasFees); + expect(second.globalVariables.slotNumber).toEqual(first.globalVariables.slotNumber); + + buildGlobals.mockRestore(); + }, 120_000); + it('keeps quote and simulation on the archiver L1 view as a checkpoint lands', async () => { await startNodeAt(tsOf(changeSlot)); mockL2Frontier({ diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/index.ts b/yarn-project/aztec-node/src/aztec-node/next_block/index.ts new file mode 100644 index 000000000000..0edc18080f4e --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/index.ts @@ -0,0 +1,3 @@ +export * from './next_block_fee_cache.js'; +export * from './next_block_planner.js'; +export * from './next_block_predictor.js'; diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/next_block_fee_cache.test.ts b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_fee_cache.test.ts new file mode 100644 index 000000000000..cce1b27746b2 --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_fee_cache.test.ts @@ -0,0 +1,360 @@ +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { ManualDateProvider } from '@aztec/foundation/timer'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { L1SyncPoint, L2BlockSource, L2Frontier } from '@aztec/stdlib/block'; +import { GasFees } from '@aztec/stdlib/gas'; +import type { CheckpointGlobalVariables, GlobalVariableBuilder } from '@aztec/stdlib/tx'; + +import { jest } from '@jest/globals'; +import { type MockProxy, mock } from 'jest-mock-extended'; + +import { DEFAULT_REFRESH_INTERVAL_MS, MAX_AGE_INTERVALS, NextBlockFeeCache } from './next_block_fee_cache.js'; +import { type BoundaryFeeKey, computeBoundaryFeeKey, getClockSlot, planNextBlock } from './next_block_planner.js'; +import { + type L2FrontierArgs, + makeFeeHeader, + makeFrontier, + makeInvalidStatus, + makeProposedCheckpointData, +} from './test_helpers.js'; + +const CHAIN_ID = new Fr(12345); +const ROLLUP_VERSION = new Fr(1); +const MAX_AGE_MS = MAX_AGE_INTERVALS * DEFAULT_REFRESH_INTERVAL_MS; + +describe('NextBlockFeeCache', () => { + let blockSource: MockProxy; + let globalVariableBuilder: MockProxy; + let rollupContract: MockProxy; + let epochCache: MockProxy; + let dateProvider: ManualDateProvider; + let cache: NextBlockFeeCache; + + /** Distinct fee per pricing call, so a re-price is visible in the value a reader gets back. */ + let priceCount: number; + + const syncPoint = (seed: number): L1SyncPoint => ({ + blockNumber: BigInt(seed), + blockHash: Buffer32.fromField(new Fr(seed)), + }); + + const boundaryArgs = (args: Partial = {}): L2FrontierArgs => ({ + proposed: BlockNumber(5), + checkpointedBlock: BlockNumber(5), + checkpointed: CheckpointNumber(1), + checkpointedTipSlot: SlotNumber(5), + l1SyncPoint: syncPoint(1), + ...args, + }); + + const setFrontier = (args: Partial = {}): L2Frontier => { + const frontier = makeFrontier(boundaryArgs(args)); + blockSource.getL2Frontier.mockResolvedValue(frontier); + return frontier; + }; + + const keyOf = (frontier: L2Frontier): BoundaryFeeKey => + computeBoundaryFeeKey(planNextBlock(frontier, getClockSlot(epochCache)), frontier.pendingChainValidationStatus)!; + + const globalsFor = (slotNumber: SlotNumber, feePerL2Gas: number): CheckpointGlobalVariables => ({ + chainId: CHAIN_ID, + version: ROLLUP_VERSION, + slotNumber, + timestamp: BigInt(slotNumber) * 72n, + coinbase: EthAddress.ZERO, + feeRecipient: AztecAddress.ZERO, + gasFees: new GasFees(0, feePerL2Gas), + }); + + beforeEach(() => { + priceCount = 0; + blockSource = mock(); + globalVariableBuilder = mock(); + rollupContract = mock(); + epochCache = mock(); + dateProvider = new ManualDateProvider(); + + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber.ZERO, + slot: SlotNumber(19), + ts: 0n, + nowSeconds: 0n, + }); + globalVariableBuilder.buildCheckpointGlobalVariables.mockImplementation((_c, _f, slotNumber) => + Promise.resolve(globalsFor(slotNumber, ++priceCount)), + ); + + cache = new NextBlockFeeCache({ + blockSource, + globalVariableBuilder, + rollupContract, + epochCache, + signatureContext: { chainId: CHAIN_ID.toNumber(), rollupAddress: EthAddress.random() }, + dateProvider, + }); + }); + + afterEach(async () => { + await cache.stop(); + jest.restoreAllMocks(); + }); + + it('prices the boundary the frontier describes, pinned to its L1 block', async () => { + const frontier = setFrontier({ l1SyncPoint: syncPoint(42) }); + + const globals = await cache.getBoundaryGlobals(keyOf(frontier), frontier); + + expect(globals?.gasFees).toEqual(new GasFees(0, 1)); + const [coinbase, feeRecipient, slot, , options] = + globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; + expect(coinbase).toEqual(EthAddress.ZERO); + expect(feeRecipient).toEqual(AztecAddress.ZERO); + // Clock slot 19 + the pipelining offset. + expect(slot).toEqual(SlotNumber(20)); + expect(options).toEqual({ blockNumber: 42n }); + }); + + it('skips the re-price when neither the key nor the L1 sync point moved', async () => { + const frontier = setFrontier(); + await cache.refresh(); + + await cache.refresh(); + + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(1); + expect((await cache.getBoundaryGlobals(keyOf(frontier), frontier))?.gasFees).toEqual(new GasFees(0, 1)); + }); + + it('re-prices when the key moves and keeps serving the boundary it just left', async () => { + const before = setFrontier(); + await cache.refresh(); + + const after = setFrontier({ checkpointedTipSlot: SlotNumber(30) }); + await cache.refresh(); + + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(2); + expect((await cache.getBoundaryGlobals(keyOf(after), after))?.gasFees).toEqual(new GasFees(0, 2)); + // A request that planned from the previous frontier is still served from memory, no further pricing. + expect((await cache.getBoundaryGlobals(keyOf(before), before))?.gasFees).toEqual(new GasFees(0, 1)); + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(2); + }); + + it('re-prices in the background when the L1 sync point moves under the same key', async () => { + const frontier = setFrontier({ l1SyncPoint: syncPoint(1) }); + await cache.refresh(); + const moved = setFrontier({ l1SyncPoint: syncPoint(2) }); + + // The key did not move, so a reader in between never misses: it is served the old price. + expect((await cache.getBoundaryGlobals(keyOf(frontier), frontier))?.gasFees).toEqual(new GasFees(0, 1)); + + await cache.refresh(); + + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(2); + expect((await cache.getBoundaryGlobals(keyOf(moved), moved))?.gasFees).toEqual(new GasFees(0, 2)); + }); + + it('shares one in-flight refresh between concurrent readers and the loop', async () => { + const frontier = setFrontier(); + const gate = promiseWithResolvers(); + globalVariableBuilder.buildCheckpointGlobalVariables.mockReturnValueOnce(gate.promise); + + const first = cache.getBoundaryGlobals(keyOf(frontier), frontier); + const second = cache.getBoundaryGlobals(keyOf(frontier), frontier); + const loopPass = cache.refresh(); + gate.resolve(globalsFor(SlotNumber(20), 7)); + + const [firstGlobals, secondGlobals] = await Promise.all([first, second, loopPass]); + + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(1); + expect(firstGlobals?.gasFees).toEqual(new GasFees(0, 7)); + expect(secondGlobals?.gasFees).toEqual(new GasFees(0, 7)); + }); + + it('serves a record under the staleness cutoff to a capped reader while the refresh keeps failing', async () => { + const frontier = setFrontier(); + await cache.refresh(); + // The L1 sync point moved, so the next pass tries to re-price rather than confirming the record in place. + const moved = setFrontier({ l1SyncPoint: syncPoint(2) }); + globalVariableBuilder.buildCheckpointGlobalVariables.mockRejectedValue(new Error('L1 is down')); + dateProvider.setTime(dateProvider.now() + MAX_AGE_MS - 1); + + const globals = await cache.getBoundaryGlobals(keyOf(frontier), moved, { maxWaitMs: 50 }); + + expect(globals?.gasFees).toEqual(new GasFees(0, 1)); + }); + + it('gives a capped reader nothing once the record is past the staleness cutoff', async () => { + const frontier = setFrontier(); + await cache.refresh(); + const moved = setFrontier({ l1SyncPoint: syncPoint(2) }); + globalVariableBuilder.buildCheckpointGlobalVariables.mockRejectedValue(new Error('L1 is down')); + dateProvider.setTime(dateProvider.now() + MAX_AGE_MS); + + await expect(cache.getBoundaryGlobals(keyOf(frontier), moved, { maxWaitMs: 50 })).resolves.toBeUndefined(); + }); + + it('re-stamps a record a pass confirms unchanged, so a healthy loop never lets it go stale', async () => { + const frontier = setFrontier(); + await cache.refresh(); + dateProvider.setTime(dateProvider.now() + MAX_AGE_MS); + + await cache.refresh(); + + expect((await cache.getBoundaryGlobals(keyOf(frontier), frontier))?.gasFees).toEqual(new GasFees(0, 1)); + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(1); + }); + + it('gives up on a capped wait rather than hanging on a stalled L1 call', async () => { + const frontier = setFrontier(); + const stalled = promiseWithResolvers(); + globalVariableBuilder.buildCheckpointGlobalVariables.mockReturnValue(stalled.promise); + + await expect(cache.getBoundaryGlobals(keyOf(frontier), frontier, { maxWaitMs: 20 })).resolves.toBeUndefined(); + + stalled.resolve(globalsFor(SlotNumber(20), 1)); + }); + + it('surfaces the L1 failure to an uncapped reader', async () => { + const frontier = setFrontier(); + globalVariableBuilder.buildCheckpointGlobalVariables.mockRejectedValue(new Error('L1 is down')); + + await expect(cache.getBoundaryGlobals(keyOf(frontier), frontier)).rejects.toThrow('L1 is down'); + }); + + it('starts idempotently and serves a read arriving during the first pass from that pass', async () => { + const frontier = setFrontier(); + + cache.start(); + cache.start(); + + expect((await cache.getBoundaryGlobals(keyOf(frontier), frontier))?.gasFees).toEqual(new GasFees(0, 1)); + expect(globalVariableBuilder.buildCheckpointGlobalVariables).toHaveBeenCalledTimes(1); + }); + + it('starts even when the first pass fails', async () => { + const frontier = setFrontier(); + globalVariableBuilder.buildCheckpointGlobalVariables.mockRejectedValue(new Error('L1 is down')); + + cache.start(); + + await expect(cache.getBoundaryGlobals(keyOf(frontier), frontier)).rejects.toThrow('L1 is down'); + await expect(cache.stop()).resolves.toBeUndefined(); + }); + + describe('overrides plan', () => { + const overridesPlanOf = () => globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0][3]; + + it('pins both tips to the checkpointed tip when the chain is idle', async () => { + await cache.refresh(makeFrontier(boundaryArgs())); + + expect(overridesPlanOf()?.chainTipsOverride).toEqual({ + pending: CheckpointNumber(1), + proven: CheckpointNumber(1), + }); + }); + + it('carries the proposed parent state when pipelining on a proposed checkpoint', async () => { + const parentSlot = SlotNumber(30); + const parentArchiveRoot = Fr.fromString('0xabcabc'); + const proposedCheckpoint = makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(3), + lastBlock: BlockNumber(5), + slotNumber: parentSlot, + archiveRoot: parentArchiveRoot, + }); + const grandparentFeeHeader = makeFeeHeader(); + rollupContract.getCheckpoint.mockResolvedValue({ feeHeader: grandparentFeeHeader } as never); + rollupContract.getManaTarget.mockResolvedValue(1000n); + const childFeeHeader = makeFeeHeader(); + jest.spyOn(RollupContract, 'computeChildFeeHeader').mockReturnValue(childFeeHeader); + + await cache.refresh(makeFrontier(boundaryArgs({ checkpointed: CheckpointNumber(2), proposedCheckpoint }))); + + const [, , slot] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; + expect(slot).toEqual(SlotNumber(31)); + expect(overridesPlanOf()?.pendingCheckpointState?.archive).toEqual(parentArchiveRoot); + expect(overridesPlanOf()?.pendingCheckpointState?.slotNumber).toEqual(parentSlot); + expect(overridesPlanOf()?.pendingCheckpointState?.feeHeader).toEqual(childFeeHeader); + expect(RollupContract.computeChildFeeHeader).toHaveBeenCalledWith( + grandparentFeeHeader, + proposedCheckpoint.totalManaUsed, + proposedCheckpoint.feeAssetPriceModifier, + 1000n, + ); + }); + + it('pins tips to firstInvalid - 1 when the pending chain is invalid', async () => { + await cache.refresh( + makeFrontier( + boundaryArgs({ + checkpointed: CheckpointNumber(5), + pendingChainValidationStatus: makeInvalidStatus(CheckpointNumber(4)), + }), + ), + ); + + expect(overridesPlanOf()?.chainTipsOverride).toEqual({ + pending: CheckpointNumber(3), + proven: CheckpointNumber(3), + }); + }); + + describe('without a rollup contract, the TXE shape', () => { + beforeEach(() => { + cache = new NextBlockFeeCache({ + blockSource, + globalVariableBuilder, + epochCache, + signatureContext: { chainId: CHAIN_ID.toNumber(), rollupAddress: EthAddress.random() }, + dateProvider, + }); + }); + + it('degrades to a pinned-tips plan when pipelining', async () => { + const proposedCheckpoint = makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(3), + lastBlock: BlockNumber(5), + slotNumber: SlotNumber(30), + }); + + await cache.refresh(makeFrontier(boundaryArgs({ checkpointed: CheckpointNumber(2), proposedCheckpoint }))); + + expect(overridesPlanOf()?.chainTipsOverride).toEqual({ + pending: CheckpointNumber(2), + proven: CheckpointNumber(2), + }); + expect(overridesPlanOf()?.pendingCheckpointState).toBeUndefined(); + }); + + it('prices an idle chain', async () => { + await cache.refresh(makeFrontier(boundaryArgs())); + + expect(overridesPlanOf()?.chainTipsOverride).toEqual({ + pending: CheckpointNumber(1), + proven: CheckpointNumber(1), + }); + }); + }); + }); + + it('has nothing to price mid-checkpoint', async () => { + blockSource.getL2Frontier.mockResolvedValue( + makeFrontier({ + proposed: BlockNumber(9), + checkpointedBlock: BlockNumber(3), + checkpointed: CheckpointNumber(1), + latestBlockGlobals: { slotNumber: SlotNumber(42) }, + }), + ); + + await cache.refresh(); + + expect(globalVariableBuilder.buildCheckpointGlobalVariables).not.toHaveBeenCalled(); + expect(rollupContract.getCheckpoint).not.toHaveBeenCalled(); + }); +}); diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/next_block_fee_cache.ts b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_fee_cache.ts new file mode 100644 index 000000000000..5d177b138734 --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_fee_cache.ts @@ -0,0 +1,279 @@ +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { + type RollupContract, + SimulationOverridesBuilder, + type SimulationOverridesPlan, +} from '@aztec/ethereum/contracts'; +import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { type Logger, createLogger } from '@aztec/foundation/log'; +import { RunningPromise } from '@aztec/foundation/promise'; +import { type DateProvider, executeTimeout } from '@aztec/foundation/timer'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { L1SyncPoint, L2BlockSource, L2Frontier } from '@aztec/stdlib/block'; +import { buildCheckpointSimulationOverridesPlan } from '@aztec/stdlib/checkpoint'; +import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p'; +import type { CheckpointGlobalVariables, GlobalVariableBuilder } from '@aztec/stdlib/tx'; + +import { + type BoundaryFeeKey, + type NewCheckpointPlan, + boundaryFeeKeyEquals, + computeBoundaryFeeKey, + getClockSlot, + planNextBlock, +} from './next_block_planner.js'; + +/** Default interval for the background refresh, well below L1's block time. */ +export const DEFAULT_REFRESH_INTERVAL_MS = 1000; + +/** + * A record older than this many refresh intervals is treated as missing: the refresh has been failing long + * enough that serving its value could underquote a fee that has since stepped. Ten intervals because one + * failed pass is a hiccup, and a ten-second-old boundary fee is almost always still right. + */ +export const MAX_AGE_INTERVALS = 10; + +/** Refresh passes an uncapped reader will wait through before giving up on its boundary. */ +const MAX_REFRESH_ATTEMPTS = 2; + +/** A priced checkpoint boundary, with the L1 block it was priced at kept as metadata. */ +type BoundaryFeeRecord = { + key: BoundaryFeeKey; + l1SyncPoint: L1SyncPoint | undefined; + globals: CheckpointGlobalVariables; + refreshedAtMs: number; +}; + +/** Dependencies required to build a {@link NextBlockFeeCache}. */ +export interface NextBlockFeeCacheDeps { + blockSource: L2BlockSource; + globalVariableBuilder: GlobalVariableBuilder; + /** + * Rollup contract used to build the fee-relevant L1 state overrides when opening a new checkpoint. + * Only needed when a proposed parent checkpoint exists (pipelining) or the pending chain is invalid; + * may be omitted in environments that never reach those states (e.g. TXE). When omitted, those paths + * degrade to a pinned-tips plan (non-pipelined fees) instead. + */ + rollupContract?: RollupContract; + epochCache: EpochCacheInterface; + signatureContext: CoordinationSignatureContext; + dateProvider: DateProvider; + log?: Logger; +} + +/** + * Caches the checkpoint globals, and so the mana min fee, that a block opening a fresh checkpoint would carry. + * This is the only value on the RPC path that has to be read from L1. + * + * A background loop prices the upcoming boundary on every pass, so requests are normally answered from memory. + * Records are looked up by {@link BoundaryFeeKey}: the target slot, the checkpointed tip, the block the plan + * builds on, and the proposed parent's fee-relevant fields or the pending chain's validity. The L1 block a record + * was priced at is stored with it but is not part of the key: the rollup transactions that move the fee also move + * the frontier and hence the key, so a miss means the chain moved (a slot rollover, a checkpoint landing or being + * proposed, a validity flip), not merely that L1 produced a block. Governance updates such as the mana target are + * the exception; the loop re-prices a matching record whenever the L1 anchor moves, so those lag by at most one + * refresh interval. + * + * On a miss a request does not call L1 itself. It joins the single refresh already in flight, or starts the one + * the loop would have started, so a burst of requests during a transition costs one L1 round trip. Simulations + * wait for as long as the refresh takes and surface its failure; fee quotes cap their wait and fall back to the + * last record they can trust. + * + * Before {@link start} (or after {@link stop}) requests still price inline through the same refresh path, which + * is what tests and TXE-like environments rely on. + */ +export class NextBlockFeeCache { + private readonly blockSource: L2BlockSource; + private readonly globalVariableBuilder: GlobalVariableBuilder; + private readonly rollupContract: RollupContract | undefined; + private readonly epochCache: EpochCacheInterface; + private readonly signatureContext: CoordinationSignatureContext; + private readonly dateProvider: DateProvider; + private readonly log: Logger; + + private current: BoundaryFeeRecord | undefined; + private previous: BoundaryFeeRecord | undefined; + private refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS; + private refreshLoop: RunningPromise | undefined; + private inFlightRefresh: Promise | undefined; + + constructor(deps: NextBlockFeeCacheDeps) { + this.blockSource = deps.blockSource; + this.globalVariableBuilder = deps.globalVariableBuilder; + this.rollupContract = deps.rollupContract; + this.epochCache = deps.epochCache; + this.signatureContext = deps.signatureContext; + this.dateProvider = deps.dateProvider; + this.log = deps.log ?? createLogger('node:next-block-fee-cache'); + } + + /** + * Starts the background refresh. The loop's first pass begins immediately and a request that arrives before it + * completes joins it rather than pricing on its own, so nothing waits on priming here: a node whose archiver or + * L1 client is not ready yet still starts, and the loop fills the cache once they are. A second call while + * running is a no-op, so it cannot orphan a loop that {@link stop} could then never reach. + */ + public start(pollingIntervalMs = DEFAULT_REFRESH_INTERVAL_MS): void { + if (this.refreshLoop) { + return; + } + this.refreshIntervalMs = pollingIntervalMs; + this.refreshLoop = new RunningPromise(() => this.refresh(), this.log, pollingIntervalMs).start(); + } + + public async stop(): Promise { + const loop = this.refreshLoop; + this.refreshLoop = undefined; + await loop?.stop(); + // A refresh started by a request rather than by the loop may still be running; let it drain. + await this.inFlightRefresh?.catch(() => {}); + } + + /** + * The checkpoint globals for a boundary keyed by `key`, or undefined when they could not be produced in time. + * + * A matching record under the staleness cutoff is served straight away. Otherwise the caller joins the single + * shared refresh: a simulation waits for it and surfaces its failure, while a quote passes `maxWaitMs` and + * falls back to whatever the cache already holds rather than turning an L1 outage into a multi-second RPC. + */ + public async getBoundaryGlobals( + key: BoundaryFeeKey, + frontier: L2Frontier, + opts?: { maxWaitMs?: number }, + ): Promise { + const maxWaitMs = opts?.maxWaitMs; + // An uncapped reader gets more than one attempt because the refresh it joins may be one that started from an + // older frontier and therefore priced a different boundary; the next pass is its own. Still never concurrent. + const attempts = maxWaitMs === undefined ? MAX_REFRESH_ATTEMPTS : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + const hit = this.findRecord(key); + if (hit) { + return hit.globals; + } + const refresh = this.refresh(frontier); + await (maxWaitMs === undefined ? refresh : this.waitFor(refresh, maxWaitMs)); + } + return this.findRecord(key)?.globals; + } + + /** + * Runs one refresh pass, or joins the one already running. Single-flight is what keeps a burst of requests + * during a transition down to a single L1 round trip, shared with the background loop. + * @param frontier - Snapshot to plan from; read fresh from the archiver when omitted, as the loop does. + */ + public refresh(frontier?: L2Frontier): Promise { + if (this.inFlightRefresh) { + return this.inFlightRefresh; + } + const refresh = this.runRefresh(frontier).finally(() => { + this.inFlightRefresh = undefined; + }); + // Every caller awaits the returned promise and reports its failure, but the stored copy may only be awaited by + // stop() after it has settled. Mark it handled now so a failure can never surface as an unhandled rejection. + refresh.catch(() => {}); + this.inFlightRefresh = refresh; + return refresh; + } + + private async runRefresh(frontier?: L2Frontier): Promise { + const snapshot = frontier ?? (await this.blockSource.getL2Frontier()); + const plan = planNextBlock(snapshot, getClockSlot(this.epochCache)); + const key = computeBoundaryFeeKey(plan, snapshot.pendingChainValidationStatus); + if (!key || !plan.newCheckpoint) { + // Mid-checkpoint: the fee is frozen in the in-progress checkpoint's header, so there is nothing to price. + return; + } + + const current = this.current; + const sameKey = current !== undefined && boundaryFeeKeyEquals(current.key, key); + if (sameKey && l1SyncPointEquals(current.l1SyncPoint, snapshot.l1SyncPoint)) { + // This pass confirmed against a fresh snapshot that none of the fee's inputs moved, so the record is as + // good as one just priced. Re-stamping it is what makes the staleness cutoff mean "how long we have been + // unable to confirm", rather than expiring a value that is still exactly right. + this.current = { ...current, refreshedAtMs: this.dateProvider.now() }; + return; + } + + const overrides = await this.buildOverridesPlan(snapshot, plan.newCheckpoint); + // Pinned to the L1 block the frontier was read at, so the fee describes the same L1 state the plan derives + // from. Undefined before the archiver's first sync pass, where the read falls back to L1's head. + const globals = await this.globalVariableBuilder.buildCheckpointGlobalVariables( + EthAddress.ZERO, + AztecAddress.ZERO, + plan.newCheckpoint.targetSlot, + overrides, + { blockNumber: snapshot.l1SyncPoint?.blockNumber }, + ); + + const record = { key, l1SyncPoint: snapshot.l1SyncPoint, globals, refreshedAtMs: this.dateProvider.now() }; + if (!sameKey) { + // Keep the boundary we just left addressable: a request that planned from the previous frontier is still + // served while the new one settles. + this.previous = current; + } + this.current = record; + } + + /** The freshest record matching `key`, or undefined when none is recent enough to trust. */ + private findRecord(key: BoundaryFeeKey): BoundaryFeeRecord | undefined { + const maxAgeMs = MAX_AGE_INTERVALS * this.refreshIntervalMs; + const now = this.dateProvider.now(); + return [this.current, this.previous].find( + record => record !== undefined && boundaryFeeKeyEquals(record.key, key) && now - record.refreshedAtMs < maxAgeMs, + ); + } + + /** Awaits `promise` for at most `maxWaitMs`, swallowing both a timeout and the promise's own failure. */ + private waitFor(promise: Promise, maxWaitMs: number): Promise { + return executeTimeout(() => promise, maxWaitMs).catch(err => + this.log.debug(`Refreshing the next-block boundary fee failed or timed out`, err), + ); + } + + /** + * Builds the chain-state overrides plan passed to `buildCheckpointGlobalVariables`, mirroring the sequencer + * (which always pins tips to neutralize prunes). When pipelining, the plan carries the proposed parent's + * archive, temp-checkpoint-log cell, and locally-derived fee header; when the pending chain is invalid, it + * pins the tips to the last valid checkpoint instead. + * + * Both of those need a rollup contract for the L1 fee reads. Environments that omit it (e.g. TXE, which never + * has a proposed checkpoint and whose pending chain is always valid) fall back to pinning both pending and + * proven tips to the checkpointed tip, which neutralizes prunes in fee computation at the cost of + * non-pipelined fees. + */ + private buildOverridesPlan( + frontier: L2Frontier, + newCheckpoint: NewCheckpointPlan, + ): Promise { + const { targetCheckpoint, proposedCheckpointData, checkpointedCheckpointNumber } = newCheckpoint; + const rollup = this.rollupContract; + if (!rollup) { + return Promise.resolve( + new SimulationOverridesBuilder() + .withChainTips({ pending: checkpointedCheckpointNumber, proven: checkpointedCheckpointNumber }) + .build(), + ); + } + + // The helper treats pipelining and invalidation as mutually exclusive; a proposed parent takes precedence. + const validationStatus = frontier.pendingChainValidationStatus; + const invalidateToPendingCheckpointNumber = + !proposedCheckpointData && !validationStatus.valid + ? CheckpointNumber(validationStatus.checkpoint.checkpointNumber - 1) + : undefined; + return buildCheckpointSimulationOverridesPlan({ + checkpointNumber: targetCheckpoint, + proposedCheckpointData, + invalidateToPendingCheckpointNumber, + checkpointedCheckpointNumber, + rollup, + signatureContext: this.signatureContext, + log: this.log, + }); + } +} + +function l1SyncPointEquals(a: L1SyncPoint | undefined, b: L1SyncPoint | undefined): boolean { + return a === undefined || b === undefined ? a === b : a.blockHash.equals(b.blockHash); +} diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/next_block_planner.test.ts b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_planner.test.ts new file mode 100644 index 000000000000..fac8bdf1a868 --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_planner.test.ts @@ -0,0 +1,208 @@ +import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; + +import { + type BoundaryFeeKey, + boundaryFeeKeyEquals, + computeBoundaryFeeKey, + planNextBlock, +} from './next_block_planner.js'; +import { + type L2FrontierArgs, + blockHashOf, + makeFrontier, + makeInvalidStatus, + makeProposedCheckpointData, +} from './test_helpers.js'; + +describe('next block planner', () => { + /** The latest proposed block (5) coincides with the proposed-checkpoint frontier: the next block opens one. */ + const boundaryArgs = (args: Partial = {}): L2FrontierArgs => ({ + proposed: BlockNumber(5), + checkpointedBlock: BlockNumber(5), + checkpointed: CheckpointNumber(1), + checkpointedTipSlot: SlotNumber(5), + ...args, + }); + + /** A proposed checkpoint (#2) ends at block 5 while the proposed tip (9) is ahead: mid-checkpoint. */ + const midCheckpointArgs = (args: Partial = {}): L2FrontierArgs => ({ + proposed: BlockNumber(9), + checkpointedBlock: BlockNumber(3), + checkpointed: CheckpointNumber(1), + checkpointedTipSlot: SlotNumber(5), + latestBlockGlobals: { slotNumber: SlotNumber(42) }, + proposedCheckpoint: makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(2), + lastBlock: BlockNumber(5), + }), + ...args, + }); + + const plan = (args: L2FrontierArgs, clockSlot: SlotNumber) => planNextBlock(makeFrontier(args), clockSlot); + + const keyFor = (args: L2FrontierArgs, clockSlot: SlotNumber): BoundaryFeeKey => { + const frontier = makeFrontier(args); + const key = computeBoundaryFeeKey(planNextBlock(frontier, clockSlot), frontier.pendingChainValidationStatus); + expect(key).toBeDefined(); + return key!; + }; + + describe('continuing vs opening a checkpoint', () => { + it('continues the in-progress checkpoint when the proposed tip is ahead of its last block', () => { + const result = plan(midCheckpointArgs(), SlotNumber(100)); + + expect(result.newCheckpoint).toBeUndefined(); + expect(result.latestBlockNumber).toEqual(BlockNumber(9)); + expect(result.latestBlockHash).toEqual(blockHashOf(BlockNumber(9)).toString()); + }); + + it('opens a new checkpoint when the proposed tip coincides with the checkpoint frontier', () => { + const result = plan(boundaryArgs(), SlotNumber(20)); + + expect(result.latestBlockNumber).toEqual(BlockNumber(5)); + expect(result.newCheckpoint).toEqual({ + targetSlot: SlotNumber(20), + targetCheckpoint: CheckpointNumber(2), + proposedCheckpointData: undefined, + checkpointedCheckpointNumber: CheckpointNumber(1), + }); + }); + + it('targets the checkpoint after the proposed parent when pipelining', () => { + const result = plan( + boundaryArgs({ + checkpointed: CheckpointNumber(2), + proposedCheckpoint: makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(3), + lastBlock: BlockNumber(5), + slotNumber: SlotNumber(30), + }), + }), + SlotNumber(5), + ); + + expect(result.newCheckpoint?.targetCheckpoint).toEqual(CheckpointNumber(4)); + expect(result.newCheckpoint?.checkpointedCheckpointNumber).toEqual(CheckpointNumber(2)); + }); + }); + + describe('target slot', () => { + it('takes the clock slot when it is ahead of every floor', () => { + expect( + plan(boundaryArgs({ checkpointedTipSlot: SlotNumber(14) }), SlotNumber(21)).newCheckpoint?.targetSlot, + ).toEqual(SlotNumber(21)); + }); + + it('takes the proposed parent slot plus one when the parent is ahead of the clock', () => { + const args = boundaryArgs({ + checkpointed: CheckpointNumber(2), + proposedCheckpoint: makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(3), + lastBlock: BlockNumber(5), + slotNumber: SlotNumber(30), + }), + }); + + expect(plan(args, SlotNumber(5)).newCheckpoint?.targetSlot).toEqual(SlotNumber(31)); + }); + + it('floors the target slot at the checkpointed tip slot plus one when the clock lags the chain', () => { + const args = boundaryArgs({ checkpointedTipSlot: SlotNumber(14) }); + + expect(plan(args, SlotNumber(13)).newCheckpoint?.targetSlot).toEqual(SlotNumber(15)); + }); + + it('skips the floor at genesis, where no checkpoint has landed yet', () => { + const args: L2FrontierArgs = { + proposed: BlockNumber.ZERO, + checkpointedBlock: BlockNumber.ZERO, + checkpointed: CheckpointNumber(0), + }; + + expect(plan(args, SlotNumber(4)).newCheckpoint?.targetSlot).toEqual(SlotNumber(4)); + }); + }); + + describe('boundary fee key', () => { + it('is undefined mid-checkpoint, where the fee is frozen in the header', () => { + const frontier = makeFrontier(midCheckpointArgs()); + + expect( + computeBoundaryFeeKey(planNextBlock(frontier, SlotNumber(100)), frontier.pendingChainValidationStatus), + ).toBeUndefined(); + }); + + it('is stable when nothing moves', () => { + expect(boundaryFeeKeyEquals(keyFor(boundaryArgs(), SlotNumber(20)), keyFor(boundaryArgs(), SlotNumber(20)))).toBe( + true, + ); + }); + + it('moves when the target slot moves', () => { + expect(boundaryFeeKeyEquals(keyFor(boundaryArgs(), SlotNumber(20)), keyFor(boundaryArgs(), SlotNumber(21)))).toBe( + false, + ); + }); + + it('moves when the checkpointed checkpoint moves', () => { + const moved = boundaryArgs({ checkpointed: CheckpointNumber(2) }); + + expect(boundaryFeeKeyEquals(keyFor(boundaryArgs(), SlotNumber(20)), keyFor(moved, SlotNumber(20)))).toBe(false); + }); + + it('moves when the latest block hash moves', () => { + const moved = boundaryArgs({ proposed: BlockNumber(6), checkpointedBlock: BlockNumber(6) }); + + expect(boundaryFeeKeyEquals(keyFor(boundaryArgs(), SlotNumber(20)), keyFor(moved, SlotNumber(20)))).toBe(false); + }); + + it('moves when the pending chain validity flips', () => { + const invalid = boundaryArgs({ pendingChainValidationStatus: makeInvalidStatus(CheckpointNumber(4)) }); + + expect(boundaryFeeKeyEquals(keyFor(boundaryArgs(), SlotNumber(20)), keyFor(invalid, SlotNumber(20)))).toBe(false); + }); + + it('moves when the first invalid checkpoint moves', () => { + const first = boundaryArgs({ pendingChainValidationStatus: makeInvalidStatus(CheckpointNumber(4)) }); + const second = boundaryArgs({ pendingChainValidationStatus: makeInvalidStatus(CheckpointNumber(5)) }); + + expect(boundaryFeeKeyEquals(keyFor(first, SlotNumber(20)), keyFor(second, SlotNumber(20)))).toBe(false); + }); + + it('moves when a proposed parent replaces the checkpointed one', () => { + const pipelined = boundaryArgs({ + proposedCheckpoint: makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(2), + lastBlock: BlockNumber(5), + }), + }); + + expect(boundaryFeeKeyEquals(keyFor(boundaryArgs(), SlotNumber(20)), keyFor(pipelined, SlotNumber(20)))).toBe( + false, + ); + }); + + describe.each([ + ['archive root', { archiveRoot: Fr.fromString('0xdead') }], + ['checkpoint out hash', { checkpointOutHash: Fr.fromString('0xbeef') }], + ['total mana used', { totalManaUsed: 999n }], + ['fee asset price modifier', { feeAssetPriceModifier: 11n }], + ['header slot', { slotNumber: SlotNumber(31) }], + ])('proposed parent %s', (_name, change) => { + it('moves the key', () => { + const base = { + checkpointNumber: CheckpointNumber(3), + lastBlock: BlockNumber(5), + slotNumber: SlotNumber(30), + }; + // The target slot is pinned above both parents' slots so only the parent field under test differs. + const clockSlot = SlotNumber(100); + const before = boundaryArgs({ proposedCheckpoint: makeProposedCheckpointData(base) }); + const after = boundaryArgs({ proposedCheckpoint: makeProposedCheckpointData({ ...base, ...change }) }); + + expect(boundaryFeeKeyEquals(keyFor(before, clockSlot), keyFor(after, clockSlot))).toBe(false); + }); + }); + }); +}); diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/next_block_planner.ts b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_planner.ts new file mode 100644 index 000000000000..737b9eec710c --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_planner.ts @@ -0,0 +1,198 @@ +import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { compactArray } from '@aztec/foundation/collection'; +import { type L2Frontier, type ValidateCheckpointResult, getCheckpointedTipSlot } from '@aztec/stdlib/block'; +import type { ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; + +/** Slot, target checkpoint, and parent data for a next block that opens a fresh checkpoint. */ +export type NewCheckpointPlan = { + /** Slot the next block will land in. */ + targetSlot: SlotNumber; + /** Checkpoint whose L1-to-L2 messages the simulation fork needs. */ + targetCheckpoint: CheckpointNumber; + /** The proposed (not yet L1-confirmed) parent checkpoint, when pipelining. */ + proposedCheckpointData: ProposedCheckpointData | undefined; + /** Checkpointed tip at the time the plan's snapshot was taken. */ + checkpointedCheckpointNumber: CheckpointNumber; +}; + +/** + * How the next block sits on the chain, derived from a single atomic archiver snapshot. Everything here comes + * from archiver reads only; turning it into globals is what may hit L1. `newCheckpoint` is set only when the + * next block opens a fresh checkpoint rather than continuing the in-progress one. + */ +export type NextBlockPlan = { + latestBlockNumber: BlockNumber; + /** Hash of the latest proposed block, so the world-state fork can be checked against the plan's chain. */ + latestBlockHash: string; + newCheckpoint?: NewCheckpointPlan; +}; + +/** Identity of the parent the boundary fee is computed on top of. */ +export type BoundaryFeeParent = + | { + kind: 'proposed'; + headerHash: string; + archiveRoot: string; + checkpointOutHash: string; + totalManaUsed: bigint; + feeAssetPriceModifier: bigint; + } + | { kind: 'checkpointed'; pendingChainValid: boolean; firstInvalidCheckpoint?: CheckpointNumber }; + +/** + * Every input the min fee of a checkpoint-opening block derives from, so two equal keys prove a cached fee is + * still what a fresh build would produce: the target slot, the checkpointed tip, the block the plan sits on, + * and either the proposed parent's fee-relevant fields or the pending-chain validity that selects the + * invalidation override. + * + * The L1 block the fee was read at is deliberately not part of the key. The min fee for a fixed slot and parent + * depends only on rollup storage, and the rollup transactions that move it (checkpoints landing, invalidations, + * prunes) all move the frontier and therefore this key, so a new L1 block on its own is a hit. The exception is a + * governance parameter update such as the mana target, which changes the fee without touching the frontier; + * the cache re-prices a matching record whenever the L1 anchor moves, so that lags by at most one refresh. + */ +export type BoundaryFeeKey = { + targetSlot: SlotNumber; + checkpointedCheckpointNumber: CheckpointNumber; + latestBlockHash: string; + parent: BoundaryFeeParent; +}; + +/** + * Slot the next block will land in, the largest of three terms: + * + * - The sequencer's exact formula, `getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET`. + * - `proposedCheckpointSlot + 1`, an RPC-side approximation of the next build: when a proposed checkpoint + * is gossiped before its L1 slot starts, the next build (once its wall clock arrives) will target + * `parentSlot + 1`. The sequencer never advances its own target past wall clock — it just declines to + * build — so this is a prediction of inclusion globals, not literal sequencer behavior. The parent slot + * comes from the proposed checkpoint header so the slot and the overrides plan cannot derive from + * different snapshots. + * - `checkpointedTipSlot + 1`, a floor: the next block can never land in a slot already taken by a + * checkpointed checkpoint. The slot comes from the frontier's checkpointed checkpoint header, so it + * describes the same instant as the tips and the proposed checkpoint. This only binds when this node's + * clock is behind the chain, in which case the first term would otherwise price the next block in a slot + * L1 has already moved past — and the L1 gas oracle can step between the two, so wallet quotes and + * simulations would disagree on the fee. + */ +function computeTargetSlot( + clockSlot: SlotNumber, + proposedCheckpointData: ProposedCheckpointData | undefined, + checkpointedTipSlot: SlotNumber | undefined, +): SlotNumber { + const slotAfterProposedCheckpoint = proposedCheckpointData ? proposedCheckpointData.header.slotNumber + 1 : undefined; + const slotAfterCheckpointedTip = checkpointedTipSlot !== undefined ? checkpointedTipSlot + 1 : undefined; + return SlotNumber(Math.max(...compactArray([clockSlot, slotAfterProposedCheckpoint, slotAfterCheckpointedTip]))); +} + +/** + * The slot this node's clock says the next block would be built in, the first of the three terms + * {@link planNextBlock} maximizes over. Pure arithmetic over the epoch cache's in-memory view. + */ +export function getClockSlot(epochCache: EpochCacheInterface): SlotNumber { + return SlotNumber(epochCache.getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET); +} + +/** + * Works out how the next block sits on the chain from one atomic frontier snapshot: whether it continues the + * in-progress checkpoint or opens a fresh one, which slot it lands in, and which checkpoint's L1-to-L2 + * messages a fork would need. Pure: the caller supplies both the snapshot and the clock slot, so the fee a + * wallet is quoted and the fee a simulation charges can never derive from different decisions. + */ +export function planNextBlock(frontier: L2Frontier, clockSlot: SlotNumber): NextBlockPlan { + const { tips, proposedCheckpoint: proposedCheckpointData } = frontier; + const latestBlockNumber = tips.proposed.number; + const latestBlockHash = tips.proposed.hash; + + // Terminating block of the proposed-checkpoint frontier: the leading proposed (not-yet-L1-confirmed) + // checkpoint's last block is `startBlock + blockCount - 1`; with no proposed checkpoint the frontier + // coincides with the checkpointed tip. + const proposedCheckpointLastBlock = proposedCheckpointData + ? BlockNumber.add(proposedCheckpointData.startBlock, proposedCheckpointData.blockCount - 1) + : tips.checkpointed.block.number; + + // The next block continues the in-progress checkpoint when the latest proposed block is ahead of the + // proposed-checkpoint terminating block; it opens a new checkpoint when they coincide. + if (proposedCheckpointLastBlock !== latestBlockNumber) { + return { latestBlockNumber, latestBlockHash }; + } + + const checkpointedCheckpointNumber = tips.checkpointed.checkpoint.number; + // The new checkpoint sits on top of the proposed one when pipelining, otherwise on the checkpointed tip. + const parentCheckpointNumber = proposedCheckpointData?.checkpointNumber ?? checkpointedCheckpointNumber; + // Undefined before the first checkpoint lands: no slot is taken yet, so there is no floor. + const checkpointedTipSlot = frontier.checkpointedCheckpoint ? getCheckpointedTipSlot(frontier) : undefined; + + return { + latestBlockNumber, + latestBlockHash, + newCheckpoint: { + targetSlot: computeTargetSlot(clockSlot, proposedCheckpointData, checkpointedTipSlot), + targetCheckpoint: CheckpointNumber(parentCheckpointNumber + 1), + proposedCheckpointData, + checkpointedCheckpointNumber, + }, + }; +} + +/** + * Keys the fee of a plan that opens a new checkpoint. Undefined mid-checkpoint, where the fee is copied from + * the in-progress checkpoint's header and nothing needs pricing. + * @param pendingChainValidationStatus - From the same frontier snapshot the plan was built from. + */ +export function computeBoundaryFeeKey( + plan: NextBlockPlan, + pendingChainValidationStatus: ValidateCheckpointResult, +): BoundaryFeeKey | undefined { + if (!plan.newCheckpoint) { + return undefined; + } + const { targetSlot, proposedCheckpointData, checkpointedCheckpointNumber } = plan.newCheckpoint; + const parent: BoundaryFeeParent = proposedCheckpointData + ? { + kind: 'proposed', + headerHash: proposedCheckpointData.header.hash().toString(), + archiveRoot: proposedCheckpointData.archive.root.toString(), + checkpointOutHash: proposedCheckpointData.checkpointOutHash.toString(), + totalManaUsed: proposedCheckpointData.totalManaUsed, + feeAssetPriceModifier: proposedCheckpointData.feeAssetPriceModifier, + } + : { + kind: 'checkpointed', + pendingChainValid: pendingChainValidationStatus.valid, + firstInvalidCheckpoint: pendingChainValidationStatus.valid + ? undefined + : pendingChainValidationStatus.checkpoint.checkpointNumber, + }; + return { targetSlot, checkpointedCheckpointNumber, latestBlockHash: plan.latestBlockHash, parent }; +} + +/** Whether two boundary fee keys describe the same fee. */ +export function boundaryFeeKeyEquals(a: BoundaryFeeKey, b: BoundaryFeeKey): boolean { + return ( + a.targetSlot === b.targetSlot && + a.checkpointedCheckpointNumber === b.checkpointedCheckpointNumber && + a.latestBlockHash === b.latestBlockHash && + boundaryFeeParentEquals(a.parent, b.parent) + ); +} + +function boundaryFeeParentEquals(a: BoundaryFeeParent, b: BoundaryFeeParent): boolean { + if (a.kind === 'proposed') { + return ( + b.kind === 'proposed' && + a.headerHash === b.headerHash && + a.archiveRoot === b.archiveRoot && + a.checkpointOutHash === b.checkpointOutHash && + a.totalManaUsed === b.totalManaUsed && + a.feeAssetPriceModifier === b.feeAssetPriceModifier + ); + } + return ( + b.kind === 'checkpointed' && + a.pendingChainValid === b.pendingChainValid && + a.firstInvalidCheckpoint === b.firstInvalidCheckpoint + ); +} diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/next_block_predictor.test.ts b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_predictor.test.ts new file mode 100644 index 000000000000..c274d00b38e4 --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_predictor.test.ts @@ -0,0 +1,170 @@ +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { L1SyncPoint, L2BlockSource, L2Frontier } from '@aztec/stdlib/block'; +import { GasFees } from '@aztec/stdlib/gas'; +import type { CheckpointGlobalVariables } from '@aztec/stdlib/tx'; + +import { type MockProxy, mock } from 'jest-mock-extended'; + +import type { NextBlockFeeCache } from './next_block_fee_cache.js'; +import { NextBlockPredictor, QUOTE_MAX_WAIT_MS } from './next_block_predictor.js'; +import { type L2FrontierArgs, makeFrontier, makeProposedCheckpointData } from './test_helpers.js'; + +const CHAIN_ID = new Fr(12345); +const ROLLUP_VERSION = new Fr(1); +const BOUNDARY_FEES = new GasFees(0, 4242); + +describe('NextBlockPredictor', () => { + let blockSource: MockProxy; + let feeCache: MockProxy; + let epochCache: MockProxy; + let predictor: NextBlockPredictor; + + const l1SyncPoint: L1SyncPoint = { blockNumber: 99n, blockHash: Buffer32.fromField(new Fr(9)) }; + + const boundaryGlobals = (slotNumber: SlotNumber): CheckpointGlobalVariables => ({ + chainId: CHAIN_ID, + version: ROLLUP_VERSION, + slotNumber, + timestamp: BigInt(slotNumber) * 72n, + coinbase: EthAddress.ZERO, + feeRecipient: AztecAddress.ZERO, + gasFees: BOUNDARY_FEES, + }); + + const setFrontier = (args: L2FrontierArgs): L2Frontier => { + const frontier = makeFrontier(args); + blockSource.getL2Frontier.mockResolvedValue(frontier); + return frontier; + }; + + /** The proposed tip coincides with the checkpoint frontier: the next block opens a fresh checkpoint. */ + const setBoundaryFrontier = (args: Partial = {}) => + setFrontier({ + proposed: BlockNumber(5), + checkpointedBlock: BlockNumber(5), + checkpointed: CheckpointNumber(1), + checkpointedTipSlot: SlotNumber(5), + l1SyncPoint, + ...args, + }); + + /** A proposed checkpoint (#2) ends at block 5 while the proposed tip (9) is ahead: mid-checkpoint. */ + const setMidCheckpointFrontier = (args: Partial = {}) => + setFrontier({ + proposed: BlockNumber(9), + checkpointedBlock: BlockNumber(3), + checkpointed: CheckpointNumber(1), + latestBlockGlobals: { slotNumber: SlotNumber(42), gasFees: new GasFees(0, 777) }, + proposedCheckpoint: makeProposedCheckpointData({ + checkpointNumber: CheckpointNumber(2), + lastBlock: BlockNumber(5), + }), + l1SyncPoint, + ...args, + }); + + beforeEach(() => { + blockSource = mock(); + feeCache = mock(); + epochCache = mock(); + + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber.ZERO, + slot: SlotNumber(19), + ts: 0n, + nowSeconds: 0n, + }); + feeCache.getBoundaryGlobals.mockImplementation(key => Promise.resolve(boundaryGlobals(key.targetSlot))); + + predictor = new NextBlockPredictor({ blockSource, feeCache, epochCache }); + }); + + describe('predict', () => { + it('copies the in-progress checkpoint globals and bumps only the block number', async () => { + setMidCheckpointFrontier(); + + const { plan, globals } = await predictor.predict(); + + expect(plan.newCheckpoint).toBeUndefined(); + expect(globals.blockNumber).toEqual(BlockNumber(10)); + expect(globals.slotNumber).toEqual(SlotNumber(42)); + expect(globals.gasFees).toEqual(new GasFees(0, 777)); + expect(feeCache.getBoundaryGlobals).not.toHaveBeenCalled(); + }); + + it('rejects a snapshot missing the proposed tip header', async () => { + setMidCheckpointFrontier({ omitLatestBlockHeader: true }); + + await expect(predictor.predict()).rejects.toThrow(/carries no header/); + expect(feeCache.getBoundaryGlobals).not.toHaveBeenCalled(); + }); + + it('prices a checkpoint-opening block at the cached boundary fee, with zero payout addresses', async () => { + setBoundaryFrontier(); + + const { plan, globals } = await predictor.predict(); + + // Clock slot 19 plus the pipelining offset. + expect(plan.newCheckpoint?.targetSlot).toEqual(SlotNumber(20)); + expect(plan.newCheckpoint?.targetCheckpoint).toEqual(CheckpointNumber(2)); + expect(globals.blockNumber).toEqual(BlockNumber(6)); + expect(globals.slotNumber).toEqual(SlotNumber(20)); + expect(globals.timestamp).toEqual(BigInt(20) * 72n); + expect(globals.gasFees).toEqual(BOUNDARY_FEES); + expect(globals.coinbase).toEqual(EthAddress.ZERO); + expect(globals.feeRecipient).toEqual(AztecAddress.ZERO); + }); + + it('waits without a cap for the boundary fee', async () => { + const frontier = setBoundaryFrontier(); + + await predictor.predict(); + + const [, passedFrontier, opts] = feeCache.getBoundaryGlobals.mock.calls[0]; + expect(passedFrontier).toBe(frontier); + expect(opts).toBeUndefined(); + }); + + it('fails when the boundary fee cannot be produced', async () => { + setBoundaryFrontier(); + feeCache.getBoundaryGlobals.mockResolvedValue(undefined); + + await expect(predictor.predict()).rejects.toThrow(/no boundary fee available/); + }); + }); + + describe('quoteMinFees', () => { + it('quotes the fee the in-progress checkpoint froze, without touching the cache', async () => { + setMidCheckpointFrontier(); + + await expect(predictor.quoteMinFees()).resolves.toEqual({ fees: new GasFees(0, 777), l1SyncPoint }); + expect(feeCache.getBoundaryGlobals).not.toHaveBeenCalled(); + }); + + it('quotes the boundary fee with a bounded wait', async () => { + setBoundaryFrontier(); + + await expect(predictor.quoteMinFees()).resolves.toEqual({ fees: BOUNDARY_FEES, l1SyncPoint }); + const [, , opts] = feeCache.getBoundaryGlobals.mock.calls[0]; + expect(opts).toEqual({ maxWaitMs: QUOTE_MAX_WAIT_MS }); + }); + + it('quotes nothing when the cache has nothing usable', async () => { + setBoundaryFrontier(); + feeCache.getBoundaryGlobals.mockResolvedValue(undefined); + + await expect(predictor.quoteMinFees()).resolves.toBeUndefined(); + }); + + it('quotes nothing rather than throwing when the snapshot has no header mid-checkpoint', async () => { + setMidCheckpointFrontier({ omitLatestBlockHeader: true }); + + await expect(predictor.quoteMinFees()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/next_block_predictor.ts b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_predictor.ts new file mode 100644 index 000000000000..38652c260692 --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/next_block_predictor.ts @@ -0,0 +1,158 @@ +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { type Logger, createLogger } from '@aztec/foundation/log'; +import type { L1SyncPoint, L2BlockSource, L2Frontier } from '@aztec/stdlib/block'; +import type { GasFees } from '@aztec/stdlib/gas'; +import { GlobalVariables } from '@aztec/stdlib/tx'; + +import { NextBlockFeeCache, type NextBlockFeeCacheDeps } from './next_block_fee_cache.js'; +import { type NextBlockPlan, computeBoundaryFeeKey, getClockSlot, planNextBlock } from './next_block_planner.js'; + +/** + * How long a fee quote waits for a boundary refresh before answering with what it already has. Long enough for + * a slow but alive L1 RPC; short enough that an L1 outage does not turn every quote into a multi-second RPC. + */ +export const QUOTE_MAX_WAIT_MS = 5000; + +/** The next block as this node predicts it: how it sits on the chain, plus the globals it would carry. */ +export type NextBlockPrediction = { + plan: NextBlockPlan; + /** Snapshot the plan was derived from, so callers can pin their own reads to the same instant. */ + frontier: L2Frontier; + globals: GlobalVariables; +}; + +/** Dependencies required to build a {@link NextBlockPredictor}. */ +export interface NextBlockPredictorDeps { + blockSource: L2BlockSource; + feeCache: NextBlockFeeCache; + epochCache: EpochCacheInterface; + log?: Logger; +} + +/** + * Answers "what would the next block look like" for everything on the RPC side: the public simulation forks and + * executes against this prediction, and the fee quote reports the fee it carries. + * + * The plan is re-derived from a fresh archiver snapshot on every call — it is a handful of in-memory reads, and + * a stale plan would fork the wrong block, target the wrong checkpoint's L1-to-L2 messages, or miss that an + * in-progress checkpoint froze a different fee. Only the L1-derived part, the checkpoint globals of a block that + * opens a fresh checkpoint, is cached (see {@link NextBlockFeeCache}). + * + * Deliberately not used by the sequencer: its slot policy is stricter (it declines to build rather than + * predicting inclusion) and a stale fee would make L1 reject its checkpoint. + */ +export class NextBlockPredictor { + private readonly blockSource: L2BlockSource; + private readonly feeCache: NextBlockFeeCache; + private readonly epochCache: EpochCacheInterface; + private readonly log: Logger; + + constructor(deps: NextBlockPredictorDeps) { + this.blockSource = deps.blockSource; + this.feeCache = deps.feeCache; + this.epochCache = deps.epochCache; + this.log = deps.log ?? createLogger('node:next-block-predictor'); + } + + /** + * Builds a predictor together with the fee cache it reads from. It takes the cache's dependencies rather than + * {@link NextBlockPredictorDeps} because the cache is constructed here; everything the predictor itself needs is + * a subset of them. + */ + public static create(deps: NextBlockFeeCacheDeps): NextBlockPredictor { + const feeCache = new NextBlockFeeCache({ ...deps, log: deps.log?.createChild('fee-cache') }); + return new NextBlockPredictor({ + blockSource: deps.blockSource, + feeCache, + epochCache: deps.epochCache, + log: deps.log, + }); + } + + public start(pollingIntervalMs?: number): void { + this.feeCache.start(pollingIntervalMs); + } + + public stop(): Promise { + return this.feeCache.stop(); + } + + /** + * Plans the next block and builds the globals it would carry, in one of two ways, mirroring how the sequencer + * builds the next block: + * + * - **Continuing an in-progress checkpoint**: every block in a checkpoint shares the same + * `CheckpointGlobalVariables`, so the latest proposed block's globals are copied verbatim — including the + * proposer's real coinbase and fee recipient — with only the block number bumped. No L1 involved. + * - **Opening a new checkpoint**: the globals are priced for the slot the next block will land in, under the + * same overrides plan the sequencer applies, so the simulated mana min fee matches what the sequencer will + * write into the block header. Coinbase and fee recipient stay zero: the future proposer's payout addresses + * are unknowable. + * + * Waits without a bound for a boundary fee it does not have cached, and surfaces the L1 failure if that read + * fails, as a simulation did before this cache existed. + */ + public async predict(): Promise { + const { frontier, plan, key } = await this.planFromFrontier(); + const blockNumber = BlockNumber.add(plan.latestBlockNumber, 1); + if (!key) { + return { plan, frontier, globals: this.copyGlobalsFromLatestBlock(frontier, blockNumber) }; + } + + const checkpointGlobals = await this.feeCache.getBoundaryGlobals(key, frontier); + if (!checkpointGlobals) { + throw new Error(`Could not price the next block at slot ${key.targetSlot}: no boundary fee available`); + } + return { plan, frontier, globals: GlobalVariables.from({ blockNumber, ...checkpointGlobals }) }; + } + + /** + * The mana min fee the next block will charge, for wallets to pad and pay. Mid-checkpoint this is the fee the + * in-progress checkpoint froze into its first block, which no forward-looking L1 projection can see. At a + * boundary it is the cached L1 price, waited on for at most {@link QUOTE_MAX_WAIT_MS}; undefined when the node + * has nothing usable, in which case the caller falls back to the fee provider's projections alone. + * + * Returns the L1 block the answer describes so the caller can tag its own fee reads with the same anchor. + */ + public async quoteMinFees(): Promise<{ fees: GasFees; l1SyncPoint: L1SyncPoint | undefined } | undefined> { + const { frontier, plan, key } = await this.planFromFrontier(); + if (!key) { + const fees = frontier.latestBlockHeader?.globalVariables.gasFees; + if (!fees) { + this.log.warn(`Cannot quote the next block fee: frontier reports no header for its proposed tip`, { + blockNumber: plan.latestBlockNumber, + }); + return undefined; + } + return { fees, l1SyncPoint: frontier.l1SyncPoint }; + } + + const checkpointGlobals = await this.feeCache.getBoundaryGlobals(key, frontier, { maxWaitMs: QUOTE_MAX_WAIT_MS }); + return checkpointGlobals ? { fees: checkpointGlobals.gasFees, l1SyncPoint: frontier.l1SyncPoint } : undefined; + } + + /** Plans the next block from a fresh archiver snapshot; `key` is set only when that block opens a checkpoint. */ + private async planFromFrontier() { + const frontier = await this.blockSource.getL2Frontier(); + const plan = planNextBlock(frontier, getClockSlot(this.epochCache)); + const key = computeBoundaryFeeKey(plan, frontier.pendingChainValidationStatus); + return { frontier, plan, key }; + } + + /** + * The header comes from the same snapshot as the tips, so it cannot describe a different block than the + * proposed tip. A missing header at a non-genesis proposed tip is an invariant violation and throws rather + * than falling through to the new-checkpoint path: a fork at the proposed tip already contains the ongoing + * checkpoint's L1-to-L2 messages, so a caller inserting the next checkpoint's messages would append them a + * second time. + */ + private copyGlobalsFromLatestBlock(frontier: L2Frontier, blockNumber: BlockNumber): GlobalVariables { + if (!frontier.latestBlockHeader) { + throw new Error( + `Cannot predict the next block: frontier reports proposed tip ${frontier.tips.proposed.number} but carries no header`, + ); + } + return GlobalVariables.from({ ...frontier.latestBlockHeader.globalVariables, blockNumber }); + } +} diff --git a/yarn-project/aztec-node/src/aztec-node/next_block/test_helpers.ts b/yarn-project/aztec-node/src/aztec-node/next_block/test_helpers.ts new file mode 100644 index 000000000000..703aecb74708 --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/next_block/test_helpers.ts @@ -0,0 +1,115 @@ +import type { FeeHeader } from '@aztec/ethereum/contracts'; +import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { + BlockHash, + type L1SyncPoint, + type L2Frontier, + type L2Tips, + type ValidateCheckpointResult, +} from '@aztec/stdlib/block'; +import { L1PublishedData, type ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; +import { GasFees } from '@aztec/stdlib/gas'; +import { CheckpointHeader } from '@aztec/stdlib/rollup'; +import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; +import { BlockHeader, GlobalVariables } from '@aztec/stdlib/tx'; + +/** Shape of the frontier snapshot a next-block test wants; every field has a sensible default. */ +export type L2FrontierArgs = { + proposed: BlockNumber; + checkpointedBlock: BlockNumber; + checkpointed: CheckpointNumber; + /** Slot of the checkpointed checkpoint, the floor for the next block's slot. Omit for the genesis shape. */ + checkpointedTipSlot?: SlotNumber; + proposedCheckpoint?: ProposedCheckpointData; + /** Globals of the proposed tip's header, copied verbatim when the next block continues a checkpoint. */ + latestBlockGlobals?: { slotNumber: SlotNumber; gasFees?: GasFees }; + /** Omit the proposed tip's header from the snapshot, an invariant violation the predictor must reject. */ + omitLatestBlockHeader?: boolean; + pendingChainValidationStatus?: ValidateCheckpointResult; + /** L1 block the archiver's snapshot reflects; fee reads must be pinned to it. */ + l1SyncPoint?: L1SyncPoint; +}; + +/** Stable per-block hash, so tips and the frontier agree on block identity. */ +export const blockHashOf = (blockNumber: BlockNumber): BlockHash => new BlockHash(new Fr(1000 + blockNumber)); + +const makeTips = (args: L2FrontierArgs): L2Tips => { + const blockId = (number: BlockNumber) => ({ number, hash: blockHashOf(number).toString() }); + const checkpointId = (number: CheckpointNumber) => ({ number, hash: `0xc${number}` }); + return { + proposed: blockId(args.proposed), + checkpointed: { block: blockId(args.checkpointedBlock), checkpoint: checkpointId(args.checkpointed) }, + proven: { block: blockId(BlockNumber.ZERO), checkpoint: checkpointId(args.checkpointed) }, + finalized: { block: blockId(BlockNumber.ZERO), checkpoint: checkpointId(args.checkpointed) }, + }; +}; + +export const makeFrontier = (args: L2FrontierArgs): L2Frontier => ({ + tips: makeTips(args), + proposedCheckpoint: args.proposedCheckpoint, + l1SyncPoint: args.l1SyncPoint, + latestBlockHeader: + args.omitLatestBlockHeader || !args.latestBlockGlobals + ? undefined + : BlockHeader.empty({ + globalVariables: GlobalVariables.empty({ + blockNumber: args.proposed, + slotNumber: args.latestBlockGlobals.slotNumber, + gasFees: args.latestBlockGlobals.gasFees ?? GasFees.empty(), + }), + }), + checkpointedCheckpoint: + args.checkpointedTipSlot === undefined + ? undefined + : { + header: CheckpointHeader.empty({ slotNumber: args.checkpointedTipSlot }), + l1: new L1PublishedData(1n, 0n, `0x`), + }, + pendingChainValidationStatus: args.pendingChainValidationStatus ?? { valid: true }, +}); + +export function makeFeeHeader(): FeeHeader { + return { excessMana: 0n, manaUsed: 0n, ethPerFeeAsset: 0n, congestionCost: 0n, proverCost: 0n }; +} + +export function makeProposedCheckpointData(args: { + checkpointNumber: CheckpointNumber; + lastBlock: BlockNumber; + slotNumber?: SlotNumber; + archiveRoot?: Fr; + totalManaUsed?: bigint; + feeAssetPriceModifier?: bigint; + checkpointOutHash?: Fr; +}): ProposedCheckpointData { + return { + checkpointNumber: args.checkpointNumber, + header: CheckpointHeader.empty({ slotNumber: args.slotNumber ?? SlotNumber(0) }), + startBlock: args.lastBlock, + blockCount: 1, + totalManaUsed: args.totalManaUsed ?? 555n, + feeAssetPriceModifier: args.feeAssetPriceModifier ?? 7n, + archive: new AppendOnlyTreeSnapshot(args.archiveRoot ?? Fr.ZERO, 0), + checkpointOutHash: args.checkpointOutHash ?? Fr.fromString('0xfeed'), + }; +} + +export function makeInvalidStatus(firstInvalid: CheckpointNumber): ValidateCheckpointResult { + return { + valid: false, + checkpoint: { + archive: Fr.random(), + lastArchive: Fr.random(), + slotNumber: SlotNumber(10), + checkpointNumber: firstInvalid, + timestamp: 0n, + }, + committee: [], + epoch: EpochNumber.ZERO, + seed: 0n, + attestors: [], + attestations: [], + verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' }, + reason: 'insufficient-attestations', + }; +} diff --git a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts index c56f8d1031f1..58a025592225 100644 --- a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts @@ -1,143 +1,67 @@ import { L1ToL2MessagesNotReadyError } from '@aztec/archiver'; -import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { type FeeHeader, RollupContract } from '@aztec/ethereum/contracts'; -import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { Buffer32 } from '@aztec/foundation/buffer'; +import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { EthAddress } from '@aztec/foundation/eth-address'; import { unfreeze } from '@aztec/foundation/types'; import { PublicProcessor, PublicProcessorFactory } from '@aztec/simulator/server'; -import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { - BlockHash, - type L1SyncPoint, - type L2BlockSource, - type L2Frontier, - type L2Tips, - type ValidateCheckpointResult, -} from '@aztec/stdlib/block'; -import { L1PublishedData, type ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; +import { BlockHash, type L2Frontier } from '@aztec/stdlib/block'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { GasFees } from '@aztec/stdlib/gas'; import type { MerkleTreeWriteOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; -import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { mockTx } from '@aztec/stdlib/testing'; -import { AppendOnlyTreeSnapshot, MerkleTreeId } from '@aztec/stdlib/trees'; -import { - BlockHeader, - type CheckpointGlobalVariables, - type GlobalVariableBuilder, - GlobalVariables, - TxEffect, -} from '@aztec/stdlib/tx'; +import { MerkleTreeId } from '@aztec/stdlib/trees'; +import { GlobalVariables, TxEffect } from '@aztec/stdlib/tx'; +import { WorldStateSynchronizerError } from '@aztec/world-state'; import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; +import type { NextBlockPlan, NextBlockPredictor } from './next_block/index.js'; import { NodePublicCallsSimulator } from './node_public_calls_simulator.js'; const CHAIN_ID = new Fr(12345); const ROLLUP_VERSION = new Fr(1); -const ROLLUP_ADDRESS = EthAddress.random(); +const LATEST_BLOCK = BlockNumber(5); +const LATEST_BLOCK_HASH = new BlockHash(new Fr(0xb5)).toString(); describe('NodePublicCallsSimulator', () => { - let blockSource: MockProxy; let worldStateSynchronizer: MockProxy; let l1ToL2MessageSource: MockProxy; let contractDataSource: MockProxy; - let globalVariableBuilder: MockProxy; - let rollupContract: MockProxy; - let epochCache: MockProxy; + let predictor: MockProxy; let merkleTreeFork: MockProxy; let simulator: NodePublicCallsSimulator; - // Captures the globals the simulator builds for the next block by intercepting the processor it - // would run them through, so tests can assert on the result rather than on mock call counts. + // Captures the globals the simulator hands the processor, so tests assert on the result rather than on mocks. let builtGlobals: GlobalVariables | undefined; - /** Stable per-block hash, so tips and the frontier agree on block identity. */ - const blockHashOf = (blockNumber: BlockNumber): BlockHash => new BlockHash(new Fr(1000 + blockNumber)); - - type L2FrontierArgs = { - proposed: BlockNumber; - checkpointedBlock: BlockNumber; - checkpointed: CheckpointNumber; - proven?: CheckpointNumber; - /** Slot of the checkpointed checkpoint, the floor for the next block's slot. Omit for the genesis shape. */ - checkpointedTipSlot?: SlotNumber; - proposedCheckpoint?: ProposedCheckpointData; - /** Globals of the proposed tip's header, copied verbatim when the next block continues a checkpoint. */ - latestBlockGlobals?: { slotNumber: SlotNumber; gasFees?: GasFees }; - /** Omit the proposed tip's header from the snapshot, an invariant violation the simulator must reject. */ - omitLatestBlockHeader?: boolean; - pendingChainValidationStatus?: ValidateCheckpointResult; - /** L1 block the archiver's snapshot reflects; the fee read must be pinned to it. */ - l1SyncPoint?: L1SyncPoint; - }; - - const makeTips = (args: L2FrontierArgs): L2Tips => { - const blockId = (number: BlockNumber) => ({ number, hash: blockHashOf(number).toString() }); - const checkpointId = (number: CheckpointNumber) => ({ number, hash: `0xc${number}` }); - return { - proposed: blockId(args.proposed), - checkpointed: { block: blockId(args.checkpointedBlock), checkpoint: checkpointId(args.checkpointed) }, - proven: { block: blockId(BlockNumber.ZERO), checkpoint: checkpointId(args.proven ?? args.checkpointed) }, - finalized: { block: blockId(BlockNumber.ZERO), checkpoint: checkpointId(args.proven ?? args.checkpointed) }, - }; - }; - - const makeFrontier = (args: L2FrontierArgs): L2Frontier => ({ - tips: makeTips(args), - proposedCheckpoint: args.proposedCheckpoint, - l1SyncPoint: args.l1SyncPoint, - latestBlockHeader: - args.omitLatestBlockHeader || !args.latestBlockGlobals - ? undefined - : BlockHeader.empty({ - globalVariables: GlobalVariables.empty({ - blockNumber: args.proposed, - slotNumber: args.latestBlockGlobals.slotNumber, - gasFees: args.latestBlockGlobals.gasFees ?? GasFees.empty(), - }), - }), - checkpointedCheckpoint: - args.checkpointedTipSlot === undefined - ? undefined - : { - header: CheckpointHeader.empty({ slotNumber: args.checkpointedTipSlot }), - l1: new L1PublishedData(1n, 0n, `0x`), - }, - pendingChainValidationStatus: args.pendingChainValidationStatus ?? { valid: true }, + const globalsFor = (blockNumber: BlockNumber, slotNumber: SlotNumber) => + GlobalVariables.empty({ blockNumber, slotNumber, gasFees: new GasFees(0, 100) }); + + const boundaryPlan = (): NextBlockPlan => ({ + latestBlockNumber: LATEST_BLOCK, + latestBlockHash: LATEST_BLOCK_HASH, + newCheckpoint: { + targetSlot: SlotNumber(20), + targetCheckpoint: CheckpointNumber(2), + proposedCheckpointData: undefined, + checkpointedCheckpointNumber: CheckpointNumber(1), + }, }); - /** Points the block source at one atomic L2 frontier snapshot, the single read the simulator makes. */ - const mockL2Frontier = (args: L2FrontierArgs) => { - const frontier = makeFrontier(args); - blockSource.getL2Frontier.mockResolvedValue(frontier); - return frontier; - }; - - const mockNextL1Slot = (slot: SlotNumber) => { - epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ - epoch: EpochNumber.ZERO, - slot, - ts: 0n, - nowSeconds: 0n, - }); - }; - - const checkpointGlobals = (slotNumber: SlotNumber): CheckpointGlobalVariables => ({ - chainId: CHAIN_ID, - version: ROLLUP_VERSION, - slotNumber, - timestamp: BigInt(slotNumber) * 72n, - coinbase: EthAddress.ZERO, - feeRecipient: AztecAddress.ZERO, - gasFees: GasFees.empty(), + const midCheckpointPlan = (): NextBlockPlan => ({ + latestBlockNumber: LATEST_BLOCK, + latestBlockHash: LATEST_BLOCK_HASH, }); + const mockPrediction = (plan: NextBlockPlan) => + predictor.predict.mockResolvedValue({ + plan, + frontier: {} as L2Frontier, + globals: globalsFor(BlockNumber.add(plan.latestBlockNumber, 1), SlotNumber(20)), + }); + const lowGasTx = () => mockTx(0x10000, { numberOfNonRevertiblePublicCallRequests: 0, @@ -149,25 +73,19 @@ describe('NodePublicCallsSimulator', () => { beforeEach(() => { builtGlobals = undefined; - blockSource = mock(); worldStateSynchronizer = mock(); l1ToL2MessageSource = mock(); contractDataSource = mock(); - globalVariableBuilder = mock(); - rollupContract = mock(); - epochCache = mock(); + predictor = mock(); merkleTreeFork = mock(); - worldStateSynchronizer.syncImmediate.mockResolvedValue(BlockNumber.ZERO); + worldStateSynchronizer.syncImmediate.mockResolvedValue(LATEST_BLOCK); // The fork is an AsyncDisposable; provide the hook so `await using` does not throw. (merkleTreeFork as unknown as { [Symbol.asyncDispose]: () => Promise })[Symbol.asyncDispose] = () => Promise.resolve(); worldStateSynchronizer.fork.mockResolvedValue(merkleTreeFork); l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue([]); - - globalVariableBuilder.buildCheckpointGlobalVariables.mockImplementation((_c, _f, slotNumber) => - Promise.resolve(checkpointGlobals(slotNumber)), - ); + mockPrediction(boundaryPlan()); // Capture the globals passed to the public processor and short-circuit execution with a stub // processor that echoes them back, so `simulate` returns an output reflecting the chosen globals. @@ -187,14 +105,10 @@ describe('NodePublicCallsSimulator', () => { }); simulator = new NodePublicCallsSimulator({ - blockSource, worldStateSynchronizer, l1ToL2MessageSource, contractDataSource, - globalVariableBuilder, - rollupContract, - epochCache, - signatureContext: { chainId: CHAIN_ID.toNumber(), rollupAddress: ROLLUP_ADDRESS }, + predictor, config: { rpcSimulatePublicMaxGasLimit: 1e11, rpcSimulatePublicMaxDebugLogMemoryReads: 100 }, }); }); @@ -206,334 +120,102 @@ describe('NodePublicCallsSimulator', () => { it('rejects when the gas limit exceeds the maximum', async () => { const tx = await lowGasTx(); unfreeze(tx.data.constants.txContext.gasSettings.gasLimits).l2Gas = 1e12; + await expect(simulator.simulate(tx)).rejects.toThrow(/gas/i); + expect(predictor.predict).not.toHaveBeenCalled(); }); - describe('continuing an in-progress checkpoint', () => { - // A proposed checkpoint (#2) terminates at block 5, but the latest proposed block (block 9) is - // ahead of it, so the next block continues the in-progress checkpoint built on top of the proposed one. - const setupMidCheckpoint = (args: Partial = {}) => - mockL2Frontier({ - proposed: BlockNumber(9), - checkpointedBlock: BlockNumber(3), - checkpointed: CheckpointNumber(1), - latestBlockGlobals: { slotNumber: SlotNumber(42) }, - proposedCheckpoint: makeProposedCheckpointData({ - checkpointNumber: CheckpointNumber(2), - lastBlock: BlockNumber(5), - }), - ...args, - }); + it('simulates the block the predictor planned, on a fork of the block it builds on', async () => { + const output = await simulator.simulate(await lowGasTx()); - it('copies the snapshot header globals verbatim and bumps only the block number', async () => { - const tx = await lowGasTx(); - const headerSlot = SlotNumber(42); - const headerGasFees = new GasFees(0, 777); - setupMidCheckpoint({ latestBlockGlobals: { slotNumber: headerSlot, gasFees: headerGasFees } }); - mockNextL1Slot(SlotNumber(100)); - - await simulator.simulate(tx); - - expect(builtGlobals).toBeDefined(); - expect(builtGlobals!.blockNumber).toEqual(BlockNumber(10)); - expect(builtGlobals!.slotNumber).toEqual(headerSlot); - expect(builtGlobals!.gasFees).toEqual(headerGasFees); - // The header comes with the snapshot: no by-number or by-hash block lookup, no fresh globals, no L1 reads. - expect(blockSource.getBlockData).not.toHaveBeenCalled(); - expect(globalVariableBuilder.buildCheckpointGlobalVariables).not.toHaveBeenCalled(); - expect(rollupContract.getManaTarget).not.toHaveBeenCalled(); - }); - - it('does not insert L1-to-L2 messages', async () => { - const tx = await lowGasTx(); - setupMidCheckpoint(); - mockNextL1Slot(SlotNumber(100)); - - await simulator.simulate(tx); - - expect(l1ToL2MessageSource.getL1ToL2Messages).not.toHaveBeenCalled(); - expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); - }); - - it('rejects a snapshot missing the proposed tip header, without double-inserting messages', async () => { - const tx = await lowGasTx(); - setupMidCheckpoint({ omitLatestBlockHeader: true }); - mockNextL1Slot(SlotNumber(100)); - - await expect(simulator.simulate(tx)).rejects.toThrow(/carries no header/); - - // Must not treat the next block as opening a new checkpoint and re-insert the ongoing checkpoint's messages. - expect(l1ToL2MessageSource.getL1ToL2Messages).not.toHaveBeenCalled(); - expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); - expect(globalVariableBuilder.buildCheckpointGlobalVariables).not.toHaveBeenCalled(); - }); + expect(worldStateSynchronizer.syncImmediate).toHaveBeenCalledWith( + LATEST_BLOCK, + BlockHash.fromString(LATEST_BLOCK_HASH), + ); + expect(worldStateSynchronizer.fork).toHaveBeenCalledWith(LATEST_BLOCK); + expect(builtGlobals).toEqual(globalsFor(BlockNumber(6), SlotNumber(20))); + expect(output.globalVariables).toEqual(builtGlobals); }); - describe('opening a new checkpoint', () => { - // The latest proposed block (5) coincides with the proposed-checkpoint frontier, so the next - // block opens a new checkpoint. Tests that pipeline on a proposed checkpoint pass one in the - // snapshot; otherwise the frontier is the checkpointed tip (block 5). - const setupBoundary = (args: Partial = {}) => - mockL2Frontier({ - proposed: BlockNumber(5), - checkpointedBlock: BlockNumber(5), - checkpointed: CheckpointNumber(1), - checkpointedTipSlot: SlotNumber(5), - ...args, - }); - - it('targets the next L1 slot plus the pipelining offset and pins tips to the checkpointed tip when idle', async () => { - const tx = await lowGasTx(); - setupBoundary(); - mockNextL1Slot(SlotNumber(20)); - - await simulator.simulate(tx); - - // Sequencer formula: nextL1Slot + PROPOSER_PIPELINING_SLOT_OFFSET (=1). - const [, , slotArg, plan] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(slotArg).toEqual(SlotNumber(21)); - expect(builtGlobals!.blockNumber).toEqual(BlockNumber(6)); - // Idle: tips pinned to the checkpointed tip (number 1) for both pending and proven. - expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(1), proven: CheckpointNumber(1) }); - }); - - it('floors the target slot at the checkpointed tip slot plus one when the node clock lags the chain', async () => { - const tx = await lowGasTx(); - // The checkpointed tip already sits at slot 14, so the next block cannot land before slot 15. - setupBoundary({ checkpointedTipSlot: SlotNumber(14) }); - mockNextL1Slot(SlotNumber(13)); - - await simulator.simulate(tx); - - const [, , slotArg] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(slotArg).toEqual(SlotNumber(15)); - }); - - it('does not raise the target slot when the node clock is ahead of the checkpointed tip', async () => { - const tx = await lowGasTx(); - setupBoundary({ checkpointedTipSlot: SlotNumber(14) }); - mockNextL1Slot(SlotNumber(20)); - - await simulator.simulate(tx); - - const [, , slotArg] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(slotArg).toEqual(SlotNumber(21)); - }); - - it('takes the floor slot from the snapshot rather than reading the tip block', async () => { - const tx = await lowGasTx(); - setupBoundary({ checkpointedTipSlot: SlotNumber(14) }); - mockNextL1Slot(SlotNumber(13)); - - await simulator.simulate(tx); + it('inserts the L1-to-L2 messages of the checkpoint the next block opens', async () => { + const messages = [Fr.fromString('0x1234'), Fr.fromString('0x5678')]; + l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue(messages); - // The whole decision comes from the one frontier read: a by-number or by-hash lookup could answer with a - // block from a different instant after a checkpoint unwind. - expect(blockSource.getBlockData).not.toHaveBeenCalled(); - expect(blockSource.getL2Frontier).toHaveBeenCalledTimes(1); - }); - - it('skips the floor at genesis, where no checkpoint has landed yet', async () => { - const tx = await lowGasTx(); - mockL2Frontier({ - proposed: BlockNumber.ZERO, - checkpointedBlock: BlockNumber.ZERO, - checkpointed: CheckpointNumber(0), - }); - mockNextL1Slot(SlotNumber(3)); + await simulator.simulate(await lowGasTx()); - await simulator.simulate(tx); - - const [, , slotArg] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(slotArg).toEqual(SlotNumber(4)); - }); - - it('inserts L1-to-L2 messages for the next checkpoint', async () => { - const tx = await lowGasTx(); - const messages = [Fr.fromString('0x1234'), Fr.fromString('0x5678')]; - setupBoundary(); - mockNextL1Slot(SlotNumber(20)); - l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue(messages); - - await simulator.simulate(tx); - - // targetCheckpoint = proposedCheckpoint.number + 1 - expect(l1ToL2MessageSource.getL1ToL2Messages).toHaveBeenCalledWith(CheckpointNumber(2)); - const [treeId, appended] = merkleTreeFork.appendLeaves.mock.calls[0]; - expect(treeId).toEqual(MerkleTreeId.L1_TO_L2_MESSAGE_TREE); - expect(appended.slice(0, 2)).toEqual(messages); - }); - - it('tolerates L1ToL2MessagesNotReadyError and simulates without messages', async () => { - const tx = await lowGasTx(); - setupBoundary(); - mockNextL1Slot(SlotNumber(20)); - l1ToL2MessageSource.getL1ToL2Messages.mockRejectedValue(new L1ToL2MessagesNotReadyError(CheckpointNumber(2), 0n)); - - await expect(simulator.simulate(tx)).resolves.toBeDefined(); - expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); - }); + expect(l1ToL2MessageSource.getL1ToL2Messages).toHaveBeenCalledWith(CheckpointNumber(2)); + const [treeId, appended] = merkleTreeFork.appendLeaves.mock.calls[0]; + expect(treeId).toEqual(MerkleTreeId.L1_TO_L2_MESSAGE_TREE); + expect(appended.slice(0, 2)).toEqual(messages); + }); - it('targets parentSlot + 1 and carries the parent overrides when pipelining on a proposed checkpoint', async () => { - const tx = await lowGasTx(); - const parentSlot = SlotNumber(30); - const parentArchiveRoot = Fr.fromString('0xabcabc'); - const proposedCheckpointData = makeProposedCheckpointData({ - checkpointNumber: CheckpointNumber(3), - lastBlock: BlockNumber(5), - slotNumber: parentSlot, - archiveRoot: parentArchiveRoot, - }); - // Both the next L1 slot and the checkpointed tip sit well behind the proposed parent's slot, so the - // proposed-checkpoint + 1 term must win the max() and the parent slot must come from the proposed - // checkpoint data itself. - setupBoundary({ checkpointed: CheckpointNumber(2), proposedCheckpoint: proposedCheckpointData }); - mockNextL1Slot(SlotNumber(5)); - - const grandparentFeeHeader = makeFeeHeader(); - rollupContract.getCheckpoint.mockResolvedValue({ feeHeader: grandparentFeeHeader } as any); - rollupContract.getManaTarget.mockResolvedValue(1000n); - const childFeeHeader = makeFeeHeader(); - jest.spyOn(RollupContract, 'computeChildFeeHeader').mockReturnValue(childFeeHeader); - - await simulator.simulate(tx); - - const [, , slotArg, plan] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(slotArg).toEqual(SlotNumber(31)); - expect(plan?.pendingCheckpointState?.archive).toEqual(parentArchiveRoot); - expect(plan?.pendingCheckpointState?.slotNumber).toEqual(parentSlot); - expect(plan?.pendingCheckpointState?.feeHeader).toEqual(childFeeHeader); - expect(RollupContract.computeChildFeeHeader).toHaveBeenCalledWith( - grandparentFeeHeader, - proposedCheckpointData.totalManaUsed, - proposedCheckpointData.feeAssetPriceModifier, - 1000n, - ); - }); + it('inserts no L1-to-L2 messages when the next block continues a checkpoint', async () => { + mockPrediction(midCheckpointPlan()); - it('pins the fee read to the L1 block the frontier was read at', async () => { - const tx = await lowGasTx(); - setupBoundary({ l1SyncPoint: { blockNumber: 4242n, blockHash: Buffer32.fromNumber(7) } }); - mockNextL1Slot(SlotNumber(20)); + await simulator.simulate(await lowGasTx()); - await simulator.simulate(tx); + expect(l1ToL2MessageSource.getL1ToL2Messages).not.toHaveBeenCalled(); + expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); + }); - const [, , , , options] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(options).toEqual({ blockNumber: 4242n }); - }); + it('tolerates L1ToL2MessagesNotReadyError and simulates without messages', async () => { + l1ToL2MessageSource.getL1ToL2Messages.mockRejectedValue(new L1ToL2MessagesNotReadyError(CheckpointNumber(2), 0n)); - it('leaves the fee read unpinned before the archiver has synced', async () => { - const tx = await lowGasTx(); - setupBoundary(); - mockNextL1Slot(SlotNumber(20)); + await expect(simulator.simulate(await lowGasTx())).resolves.toBeDefined(); + expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); + }); - await simulator.simulate(tx); + it('tolerates a failed message fetch and simulates without messages', async () => { + l1ToL2MessageSource.getL1ToL2Messages.mockRejectedValue(new Error('archiver is down')); - const [, , , , options] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(options).toEqual({ blockNumber: undefined }); - }); + await expect(simulator.simulate(await lowGasTx())).resolves.toBeDefined(); + expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); + }); - it('pins tips to firstInvalid - 1 when the pending chain is invalid', async () => { - const tx = await lowGasTx(); - setupBoundary({ - checkpointed: CheckpointNumber(5), - pendingChainValidationStatus: makeInvalidStatus(CheckpointNumber(4)), + it('replans once when the world state holds a different block at the planned height', async () => { + worldStateSynchronizer.syncImmediate.mockRejectedValueOnce(hashMismatch()); + predictor.predict + .mockResolvedValueOnce({ + plan: boundaryPlan(), + frontier: {} as L2Frontier, + globals: globalsFor(BlockNumber(6), SlotNumber(20)), + }) + .mockResolvedValueOnce({ + plan: boundaryPlan(), + frontier: {} as L2Frontier, + globals: globalsFor(BlockNumber(6), SlotNumber(21)), }); - mockNextL1Slot(SlotNumber(20)); - - await simulator.simulate(tx); - const [, , , plan] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - // invalidateToPendingCheckpointNumber = firstInvalid (4) - 1 = 3. - expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(3), proven: CheckpointNumber(3) }); - }); + await simulator.simulate(await lowGasTx()); - it('degrades to a pinned-tips plan when pipelining without a rollup contract', async () => { - const tx = await lowGasTx(); - simulator = makeSimulatorWithoutRollupContract(); - setupBoundary({ - checkpointed: CheckpointNumber(2), - proposedCheckpoint: makeProposedCheckpointData({ - checkpointNumber: CheckpointNumber(3), - lastBlock: BlockNumber(5), - slotNumber: SlotNumber(30), - archiveRoot: Fr.fromString('0xabcabc'), - }), - }); - mockNextL1Slot(SlotNumber(5)); + expect(predictor.predict).toHaveBeenCalledTimes(2); + expect(builtGlobals!.slotNumber).toEqual(SlotNumber(21)); + }); - await simulator.simulate(tx); + it('fails with a retryable error when the world state keeps disagreeing with the plan', async () => { + worldStateSynchronizer.syncImmediate.mockRejectedValue(hashMismatch()); - const [, , , plan] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - // No rollup contract: pin tips to the checkpointed tip (2) without pipelining overrides. - expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(2), proven: CheckpointNumber(2) }); - expect(plan?.pendingCheckpointState).toBeUndefined(); - }); + await expect(simulator.simulate(await lowGasTx())).rejects.toThrow(/prune race/); + expect(predictor.predict).toHaveBeenCalledTimes(2); + expect(worldStateSynchronizer.fork).not.toHaveBeenCalled(); + }); - it('simulates without a rollup contract when idle (the TXE shape)', async () => { - const tx = await lowGasTx(); - simulator = makeSimulatorWithoutRollupContract(); - setupBoundary(); - mockNextL1Slot(SlotNumber(20)); + it('surfaces a block the world state cannot reach without replanning', async () => { + worldStateSynchronizer.syncImmediate.mockRejectedValue( + new WorldStateSynchronizerError('unable to sync', { cause: { reason: 'block_not_available' } }), + ); - await expect(simulator.simulate(tx)).resolves.toBeDefined(); + await expect(simulator.simulate(await lowGasTx())).rejects.toThrow('unable to sync'); + expect(predictor.predict).toHaveBeenCalledTimes(1); + }); - const [, , , plan] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; - expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(1), proven: CheckpointNumber(1) }); - }); + it('surfaces any other sync failure without replanning', async () => { + worldStateSynchronizer.syncImmediate.mockRejectedValue(new Error('world state is down')); - const makeSimulatorWithoutRollupContract = () => - new NodePublicCallsSimulator({ - blockSource, - worldStateSynchronizer, - l1ToL2MessageSource, - contractDataSource, - globalVariableBuilder, - epochCache, - signatureContext: { chainId: CHAIN_ID.toNumber(), rollupAddress: ROLLUP_ADDRESS }, - config: { rpcSimulatePublicMaxGasLimit: 1e11, rpcSimulatePublicMaxDebugLogMemoryReads: 100 }, - }); + await expect(simulator.simulate(await lowGasTx())).rejects.toThrow('world state is down'); + expect(predictor.predict).toHaveBeenCalledTimes(1); }); -}); -function makeFeeHeader(): FeeHeader { - return { excessMana: 0n, manaUsed: 0n, ethPerFeeAsset: 0n, congestionCost: 0n, proverCost: 0n }; -} - -function makeProposedCheckpointData(args: { - checkpointNumber: CheckpointNumber; - lastBlock: BlockNumber; - slotNumber?: SlotNumber; - archiveRoot?: Fr; -}): ProposedCheckpointData { - return { - checkpointNumber: args.checkpointNumber, - header: CheckpointHeader.empty({ slotNumber: args.slotNumber ?? SlotNumber(0) }), - startBlock: args.lastBlock, - blockCount: 1, - totalManaUsed: 555n, - feeAssetPriceModifier: 7n, - archive: new AppendOnlyTreeSnapshot(args.archiveRoot ?? Fr.ZERO, 0), - checkpointOutHash: Fr.fromString('0xfeed'), - }; -} - -function makeInvalidStatus(firstInvalid: CheckpointNumber): ValidateCheckpointResult { - return { - valid: false, - checkpoint: { - archive: Fr.random(), - lastArchive: Fr.random(), - slotNumber: SlotNumber(10), - checkpointNumber: firstInvalid, - timestamp: 0n, - }, - committee: [], - epoch: EpochNumber.ZERO, - seed: 0n, - attestors: [], - attestations: [], - verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' }, - reason: 'insufficient-attestations', - }; -} + const hashMismatch = () => + new WorldStateSynchronizerError('hash mismatch', { cause: { reason: 'block_hash_mismatch' } }); +}); diff --git a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts index 364e7a6c6e0c..c56e27675454 100644 --- a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts +++ b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts @@ -1,39 +1,26 @@ import { L1ToL2MessagesNotReadyError } from '@aztec/archiver'; -import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache'; -import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { - type RollupContract, - SimulationOverridesBuilder, - type SimulationOverridesPlan, -} from '@aztec/ethereum/contracts'; -import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { compactArray } from '@aztec/foundation/collection'; +import type { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; import { isErrorClass } from '@aztec/foundation/types'; import { PublicContractsDB, PublicProcessorFactory } from '@aztec/simulator/server'; import { CollectionLimitsConfig, PublicSimulatorConfig } from '@aztec/stdlib/avm'; -import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type L2BlockSource, type L2Frontier, getCheckpointedTipSlot } from '@aztec/stdlib/block'; -import { type ProposedCheckpointData, buildCheckpointSimulationOverridesPlan } from '@aztec/stdlib/checkpoint'; +import { BlockHash } from '@aztec/stdlib/block'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, appendL1ToL2MessagesToTree } from '@aztec/stdlib/messaging'; -import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p'; -import { - type GlobalVariableBuilder, - GlobalVariables, - PublicSimulationOutput, - type SimulationOverrides, - type Tx, -} from '@aztec/stdlib/tx'; +import { type GlobalVariables, PublicSimulationOutput, type SimulationOverrides, type Tx } from '@aztec/stdlib/tx'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; +import { WorldStateSynchronizerError } from '@aztec/world-state'; +import type { NextBlockPlan, NextBlockPredictor } from './next_block/index.js'; import { applyPublicDataOverrides } from './public_data_overrides.js'; +/** Attempts at planning the next block on a chain the world state agrees with, before giving up. */ +const MAX_PREDICTION_ATTEMPTS = 2; + /** Config fields the simulator needs — a narrow subset of `AztecNodeConfig`. */ export interface NodePublicCallsSimulatorConfig { /** Maximum total gas limit accepted for an incoming simulation. */ @@ -44,63 +31,45 @@ export interface NodePublicCallsSimulatorConfig { /** Dependencies required to build a {@link NodePublicCallsSimulator}. */ export interface NodePublicCallsSimulatorDeps { - blockSource: L2BlockSource; worldStateSynchronizer: WorldStateSynchronizer; l1ToL2MessageSource: L1ToL2MessageSource; contractDataSource: ContractDataSource; - globalVariableBuilder: GlobalVariableBuilder; - /** - * Rollup contract used to build the fee-relevant L1 state overrides when opening a new checkpoint. - * Only needed when a proposed parent checkpoint exists (pipelining) or the pending chain is invalid; - * may be omitted in environments that never reach those states (e.g. TXE). When omitted, those paths - * degrade to a pinned-tips plan (non-pipelined fees) instead. - */ - rollupContract?: RollupContract; - epochCache: EpochCacheInterface; - signatureContext: CoordinationSignatureContext; + predictor: NextBlockPredictor; config: NodePublicCallsSimulatorConfig; telemetry?: TelemetryClient; log?: Logger; } +/** The next block, planned and priced, on a chain the world state has caught up with. */ +type PreparedNextBlock = { + plan: NextBlockPlan; + globals: GlobalVariables; + /** L1-to-L2 messages of the checkpoint the next block opens; undefined when it continues one. */ + messages: Fr[] | undefined; +}; + /** * Simulates the public part of a transaction against a fresh world-state fork. * - * Extracted from `AztecNodeService` so the slot/globals selection can be unit-tested without - * standing up the whole node, and to keep `server.ts` smaller. - * - * The simulator picks globals in one of two ways, mirroring how the sequencer builds the next block: - * - **When the next block continues an in-progress checkpoint** (the latest proposed block is ahead of - * the proposed-checkpoint frontier): every block in a checkpoint shares the same - * `CheckpointGlobalVariables`, so we copy the latest proposed block's globals verbatim and only - * bump the block number. No L1 calls, no L1-to-L2 message insertion. - * - **When the next block opens a new checkpoint** (the latest proposed block coincides with the - * proposed-checkpoint frontier): we compute fresh globals for the slot the next block will land in, - * applying the same `SimulationOverridesPlan` the sequencer applies so the simulated mana min fee - * matches what the sequencer will write into the block header. + * Extracted from `AztecNodeService` so forking and execution can be unit-tested without standing up the whole + * node, and to keep `server.ts` smaller. Which block is simulated, and the globals it carries, are decided by + * the {@link NextBlockPredictor}: the simulator's job is to fork the chain that plan describes, insert the + * L1-to-L2 messages a checkpoint-opening block would see, and run the processor. */ export class NodePublicCallsSimulator { - private readonly blockSource: L2BlockSource; private readonly worldStateSynchronizer: WorldStateSynchronizer; private readonly l1ToL2MessageSource: L1ToL2MessageSource; private readonly contractDataSource: ContractDataSource; - private readonly globalVariableBuilder: GlobalVariableBuilder; - private readonly rollupContract: RollupContract | undefined; - private readonly epochCache: EpochCacheInterface; - private readonly signatureContext: CoordinationSignatureContext; + private readonly predictor: NextBlockPredictor; private readonly config: NodePublicCallsSimulatorConfig; private readonly telemetry: TelemetryClient; private readonly log: Logger; constructor(deps: NodePublicCallsSimulatorDeps) { - this.blockSource = deps.blockSource; this.worldStateSynchronizer = deps.worldStateSynchronizer; this.l1ToL2MessageSource = deps.l1ToL2MessageSource; this.contractDataSource = deps.contractDataSource; - this.globalVariableBuilder = deps.globalVariableBuilder; - this.rollupContract = deps.rollupContract; - this.epochCache = deps.epochCache; - this.signatureContext = deps.signatureContext; + this.predictor = deps.predictor; this.config = deps.config; this.telemetry = deps.telemetry ?? getTelemetryClient(); this.log = deps.log ?? createLogger('node:public-calls-simulator'); @@ -132,30 +101,7 @@ export class NodePublicCallsSimulator { } const txHash = tx.getTxHash(); - // One atomic read: the tips, the leading proposed checkpoint, the latest block header, the checkpointed - // checkpoint and the pending-chain validation status all describe the same instant, so no two decisions - // below can derive from different chain states. - const frontier = await this.blockSource.getL2Frontier(); - const { tips: l2Tips, proposedCheckpoint: proposedCheckpointData } = frontier; - const latestBlockNumber = l2Tips.proposed.number; - const blockNumber = BlockNumber.add(latestBlockNumber, 1); - - // Terminating block of the proposed-checkpoint frontier: the leading proposed (not-yet-L1-confirmed) - // checkpoint's last block is `startBlock + blockCount - 1`; with no proposed checkpoint the frontier - // coincides with the checkpointed tip. - const proposedCheckpointLastBlock = proposedCheckpointData - ? BlockNumber.add(proposedCheckpointData.startBlock, proposedCheckpointData.blockCount - 1) - : l2Tips.checkpointed.block.number; - - // The next block continues the in-progress checkpoint when the latest proposed block is ahead of - // the proposed-checkpoint terminating block; it opens a new checkpoint when they coincide. - const atCheckpointBoundary = proposedCheckpointLastBlock === l2Tips.proposed.number; - - // `targetCheckpoint` is the checkpoint whose L1-to-L2 messages must be inserted into the fork - // before simulation. Only set when opening a new checkpoint, where the next block is its first block. - const { globalVariables: newGlobalVariables, targetCheckpoint } = atCheckpointBoundary - ? await this.buildGlobalVariablesForNewCheckpoint(frontier, blockNumber) - : { globalVariables: this.copyGlobalVariablesFromLatestProposedBlock(frontier, blockNumber) }; + const { plan, globals, messages } = await this.prepareNextBlock(); const publicProcessorFactory = new PublicProcessorFactory( this.contractDataSource, @@ -165,26 +111,20 @@ export class NodePublicCallsSimulator { ); this.log.verbose(`Simulating public calls for tx ${txHash}`, { - globalVariables: newGlobalVariables.toInspect(), + globalVariables: globals.toInspect(), txHash, - blockNumber, - atCheckpointBoundary, + blockNumber: globals.blockNumber, + atCheckpointBoundary: plan.newCheckpoint !== undefined, }); - // Ensure world-state has caught up with the latest block we loaded from the archiver - await this.worldStateSynchronizer.syncImmediate(latestBlockNumber); - - const nextCheckpointMessages = await this.getNextCheckpointMessages(targetCheckpoint); - // Request a new fork of the world state at the latest block number, and apply any overrides and next checkpoint messages to it before simulation - await using merkleTreeFork = await this.worldStateSynchronizer.fork(latestBlockNumber); + await using merkleTreeFork = await this.worldStateSynchronizer.fork(plan.latestBlockNumber); - if (nextCheckpointMessages !== undefined) { - this.log.debug( - `Appending ${nextCheckpointMessages.length} L1-to-L2 messages to the world state tree for the next checkpoint`, - { checkpointNumber: targetCheckpoint }, - ); - await appendL1ToL2MessagesToTree(merkleTreeFork, nextCheckpointMessages); + if (messages !== undefined) { + this.log.debug(`Appending ${messages.length} L1-to-L2 messages to the world state tree for the next checkpoint`, { + checkpointNumber: plan.newCheckpoint?.targetCheckpoint, + }); + await appendL1ToL2MessagesToTree(merkleTreeFork, messages); } await applyPublicDataOverrides(merkleTreeFork, overrides?.publicStorage); @@ -204,7 +144,7 @@ export class NodePublicCallsSimulator { if (overrides?.contracts) { contractsDB.addContracts(Object.values(overrides.contracts).map(({ instance }) => instance)); } - const processor = publicProcessorFactory.create(merkleTreeFork, newGlobalVariables, config, contractsDB); + const processor = publicProcessorFactory.create(merkleTreeFork, globals, config, contractsDB); // REFACTOR: Consider merging ProcessReturnValues into ProcessedTx const [processedTxs, failedTxs, _usedTxs, returns, debugLogs] = await processor.process([tx]); @@ -225,6 +165,40 @@ export class NodePublicCallsSimulator { ); } + /** + * Plans and prices the next block, and brings the world state up to the block that plan builds on. Retries + * once when the world state reaches the planned height with a different block: a prune between the archiver + * read and the sync leaves the two disagreeing, and forking anyway would simulate against state the plan's + * globals do not belong to. Any other sync failure propagates as is. + */ + private async prepareNextBlock(): Promise { + for (let attempt = 0; attempt < MAX_PREDICTION_ATTEMPTS; attempt++) { + const { plan, globals } = await this.predictor.predict(); + try { + const [, messages] = await Promise.all([ + // Passing the hash makes the sync fork-aware: it waits for the block the plan builds on and throws if + // the world state ended up with a different block at that height. + this.worldStateSynchronizer.syncImmediate(plan.latestBlockNumber, BlockHash.fromString(plan.latestBlockHash)), + this.getNextCheckpointMessages(plan.newCheckpoint?.targetCheckpoint), + ]); + return { plan, globals, messages }; + } catch (err) { + if (!isBlockHashMismatch(err)) { + throw err; + } + this.log.warn(`World state disagrees with the planned next block, replanning`, { + blockNumber: plan.latestBlockNumber, + blockHash: plan.latestBlockHash, + error: err.message, + }); + } + } + + throw new Error( + `Cannot simulate public calls: world state and archiver disagree on the latest block (prune race), retry`, + ); + } + /** * Fetches the next checkpoint's L1-to-L2 messages to insert into the fork before simulation. Only set * when opening a new checkpoint; when continuing an in-progress checkpoint the ongoing checkpoint's @@ -255,148 +229,15 @@ export class NodePublicCallsSimulator { return undefined; } } +} - /** - * Continues an in-progress checkpoint: the next block extends the checkpoint the latest proposed - * block belongs to. Every block in a checkpoint shares the same `CheckpointGlobalVariables`, so the - * next block's globals are the latest proposed block's globals with only the block number bumped — - * including the proposer's real coinbase/feeRecipient. No L1 reads and no L1-to-L2 message insertion - * happen here. - * - * The header comes from the same snapshot as the tips, so it cannot describe a different block than the - * proposed tip. A missing header at a non-genesis proposed tip is an invariant violation and throws rather - * than falling through to the new-checkpoint path: the fork at `latestBlockNumber` already contains the - * ongoing checkpoint's L1-to-L2 messages, so inserting the next checkpoint's messages would append them a - * second time. - */ - private copyGlobalVariablesFromLatestProposedBlock(frontier: L2Frontier, blockNumber: BlockNumber): GlobalVariables { - const latestBlockNumber = frontier.tips.proposed.number; - if (!frontier.latestBlockHeader) { - throw new Error( - `Cannot simulate public calls: frontier reports proposed tip ${latestBlockNumber} but carries no header`, - ); - } - return GlobalVariables.from({ ...frontier.latestBlockHeader.globalVariables, blockNumber }); - } - - /** - * Opens a new checkpoint: the next block is the first of a fresh checkpoint. Picks the slot the next - * block will land in, mirroring the sequencer, and builds the same `SimulationOverridesPlan` the - * sequencer applies so the simulated mana min fee matches what the sequencer will write into the - * block header. Coinbase and fee recipient stay zero (we cannot know the future proposer's payout - * addresses), unlike continuing an in-progress checkpoint which inherits the real ones from the - * proposed header. Returns the target checkpoint so the caller inserts that checkpoint's L1-to-L2 - * messages into the fork. - */ - private async buildGlobalVariablesForNewCheckpoint( - frontier: L2Frontier, - blockNumber: BlockNumber, - ): Promise<{ globalVariables: GlobalVariables; targetCheckpoint: CheckpointNumber }> { - const proposedCheckpointData = frontier.proposedCheckpoint; - const checkpointedCheckpointNumber = frontier.tips.checkpointed.checkpoint.number; - // The new checkpoint sits on top of the proposed one when pipelining, otherwise on the - // checkpointed tip. The target slot and the overrides plan both derive from the same frontier - // snapshot, so they cannot disagree about the proposed parent. - const proposedCheckpointNumber = proposedCheckpointData?.checkpointNumber ?? checkpointedCheckpointNumber; - - // Undefined before the first checkpoint lands: no slot is taken yet, so there is no floor. - const checkpointedTipSlot = frontier.checkpointedCheckpoint ? getCheckpointedTipSlot(frontier) : undefined; - const targetSlot = this.computeTargetSlot(proposedCheckpointData, checkpointedTipSlot); - const plan = await this.buildSimulationOverridesPlan(frontier, checkpointedCheckpointNumber); - - // Pinned to the L1 block the frontier was read at, so the fee describes the same L1 state the plan above - // derives from. Undefined before the archiver's first sync pass, where the read falls back to L1's head. - const checkpointGlobalVariables = await this.globalVariableBuilder.buildCheckpointGlobalVariables( - EthAddress.ZERO, - AztecAddress.ZERO, - targetSlot, - plan, - { blockNumber: frontier.l1SyncPoint?.blockNumber }, - ); - - return { - globalVariables: GlobalVariables.from({ blockNumber, ...checkpointGlobalVariables }), - targetCheckpoint: CheckpointNumber(proposedCheckpointNumber + 1), - }; - } - - /** - * Slot the next block will land in, the largest of three terms: - * - * - The sequencer's exact formula, `getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET`. - * - `proposedCheckpointSlot + 1`, an RPC-side approximation of the next build: when a proposed checkpoint - * is gossiped before its L1 slot starts, the next build (once its wall clock arrives) will target - * `parentSlot + 1`. The sequencer never advances its own target past wall clock — it just declines to - * build — so this is a prediction of inclusion globals, not literal sequencer behavior. The parent slot - * comes from the proposed checkpoint header so the slot and the overrides plan cannot derive from - * different snapshots. - * - `checkpointedTipSlot + 1`, a floor: the next block can never land in a slot already taken by a - * checkpointed checkpoint. The slot comes from the frontier's checkpointed checkpoint header, so it - * describes the same instant as the tips and the proposed checkpoint. This only binds when this node's - * clock is behind the chain, in which case the first term would otherwise price the next block in a slot - * L1 has already moved past — and the L1 gas oracle can step between the two, so wallet quotes and - * simulations would disagree on the fee. - */ - private computeTargetSlot( - proposedCheckpointData: ProposedCheckpointData | undefined, - checkpointedTipSlot: SlotNumber | undefined, - ): SlotNumber { - const slotFromNextL1Timestamp = - this.epochCache.getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET; - const slotAfterProposedCheckpoint = proposedCheckpointData - ? proposedCheckpointData.header.slotNumber + 1 - : undefined; - const slotAfterCheckpointedTip = checkpointedTipSlot !== undefined ? checkpointedTipSlot + 1 : undefined; - return SlotNumber( - Math.max(...compactArray([slotFromNextL1Timestamp, slotAfterProposedCheckpoint, slotAfterCheckpointedTip])), - ); - } - - /** - * Builds the chain-state overrides plan the simulator passes to `buildCheckpointGlobalVariables`, - * mirroring the sequencer (which always pins tips to neutralize prunes). When pipelining, the plan - * carries the proposed parent's archive, temp-checkpoint-log cell, and locally-derived fee header. - * - * Both the pipelining and invalid-pending-chain paths need a rollup contract for the L1 fee reads. - * Environments that omit it (e.g. TXE, which never has a proposed checkpoint and whose pending chain - * is always valid) fall back to pinning both pending and proven tips to the checkpointed tip, which - * neutralizes prunes in fee computation at the cost of non-pipelined fees. - */ - private buildSimulationOverridesPlan( - frontier: L2Frontier, - checkpointedCheckpointNumber: CheckpointNumber, - ): Promise { - const proposedCheckpointData = frontier.proposedCheckpoint; - const rollup = this.rollupContract; - if (rollup) { - if (proposedCheckpointData) { - return buildCheckpointSimulationOverridesPlan({ - checkpointNumber: CheckpointNumber(proposedCheckpointData.checkpointNumber + 1), - proposedCheckpointData, - checkpointedCheckpointNumber, - rollup, - signatureContext: this.signatureContext, - log: this.log, - }); - } - - const validationStatus = frontier.pendingChainValidationStatus; - if (!validationStatus.valid) { - return buildCheckpointSimulationOverridesPlan({ - checkpointNumber: CheckpointNumber(checkpointedCheckpointNumber + 1), - invalidateToPendingCheckpointNumber: CheckpointNumber(validationStatus.checkpoint.checkpointNumber - 1), - checkpointedCheckpointNumber, - rollup, - signatureContext: this.signatureContext, - log: this.log, - }); - } - } - - return Promise.resolve( - new SimulationOverridesBuilder() - .withChainTips({ pending: checkpointedCheckpointNumber, proven: checkpointedCheckpointNumber }) - .build(), - ); - } +/** The sync reached the planned height but found a different block there, so the chain moved under the plan. */ +function isBlockHashMismatch(err: unknown): err is WorldStateSynchronizerError { + return ( + isErrorClass(err, WorldStateSynchronizerError) && + typeof err.cause === 'object' && + err.cause !== null && + 'reason' in err.cause && + err.cause.reason === 'block_hash_mismatch' + ); } diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index 390bae1f1f42..53c3d157ad23 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -84,6 +84,7 @@ import { join } from 'path'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { type AztecNodeConfig, getConfigEnvVars } from './config.js'; +import { NextBlockPredictor } from './next_block/index.js'; import { AztecNodeService } from './server.js'; // Arbitrary fixed timestamp for the mock date provider. DateProvider.now() returns milliseconds but ExpirationTimestamp @@ -142,6 +143,7 @@ describe('aztec node', () => { let createNode: (configOverrides?: Partial) => TestAztecNodeService; let feePayer: AztecAddress; let epochCache: EpochCache; + let nextBlockPredictor: NextBlockPredictor; let nodeConfig: AztecNodeConfig; const chainId = new Fr(12345); @@ -274,6 +276,15 @@ describe('aztec node', () => { new MockDateProvider(), ); + nextBlockPredictor = NextBlockPredictor.create({ + blockSource: l2BlockSource, + globalVariableBuilder: globalVariablesBuilder, + rollupContract, + epochCache, + signatureContext: { chainId: 12345, rollupAddress: EthAddress.ZERO }, + dateProvider: new MockDateProvider(), + }); + createNode = (configOverrides: Partial = {}) => new TestAztecNodeService({ config: { ...nodeConfig, ...configOverrides }, @@ -293,6 +304,7 @@ describe('aztec node', () => { globalVariableBuilder: globalVariablesBuilder, rollupContract, feeProvider, + nextBlockPredictor, epochCache, packageVersion: getPackageVersion(), peerProofVerifier: new TestCircuitVerifier(), @@ -1186,6 +1198,7 @@ describe('aztec node', () => { globalVariableBuilder: globalVariablesBuilder, rollupContract: undefined, feeProvider, + nextBlockPredictor, epochCache, packageVersion: getPackageVersion(), peerProofVerifier: new TestCircuitVerifier(), @@ -1374,6 +1387,7 @@ describe('aztec node', () => { globalVariableBuilder: globalVariablesBuilder, rollupContract: undefined, feeProvider, + nextBlockPredictor, epochCache, packageVersion: getPackageVersion(), peerProofVerifier: new TestCircuitVerifier(), @@ -1443,6 +1457,7 @@ describe('aztec node', () => { globalVariableBuilder: globalVariablesBuilder, rollupContract: undefined, feeProvider: mock(), + nextBlockPredictor, epochCache, packageVersion: getPackageVersion(), peerProofVerifier: new TestCircuitVerifier(), @@ -1497,6 +1512,7 @@ describe('aztec node', () => { globalVariableBuilder: globalVariablesBuilder, rollupContract: undefined, feeProvider: mock(), + nextBlockPredictor, epochCache, packageVersion: getPackageVersion(), peerProofVerifier: new TestCircuitVerifier(), diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 94ea9ca916d0..1d38e9cc318a 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -114,6 +114,7 @@ import { NodeWorldStateQueries } from '../modules/node_world_state_queries.js'; import { UnseenBlockHoldOff } from '../modules/unseen_block_hold_off.js'; import { Sentinel } from '../sentinel/sentinel.js'; import type { AztecNodeConfig } from './config.js'; +import type { NextBlockPredictor } from './next_block/index.js'; import { NodeMetrics } from './node_metrics.js'; import { NodePublicCallsSimulator } from './node_public_calls_simulator.js'; @@ -140,6 +141,7 @@ export interface AztecNodeServiceDeps { globalVariableBuilder: GlobalVariableBuilderInterface; rollupContract: RollupContract | undefined; feeProvider: FeeProvider; + nextBlockPredictor: NextBlockPredictor; epochCache: EpochCacheInterface; packageVersion: string; peerProofVerifier: ClientProtocolCircuitVerifier; @@ -187,6 +189,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb protected readonly globalVariableBuilder: GlobalVariableBuilderInterface; protected readonly rollupContract: RollupContract | undefined; protected readonly feeProvider: FeeProvider; + protected readonly nextBlockPredictor: NextBlockPredictor; protected readonly epochCache: EpochCacheInterface; protected readonly packageVersion: string; private peerProofVerifier: ClientProtocolCircuitVerifier; @@ -217,6 +220,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb this.globalVariableBuilder = deps.globalVariableBuilder; this.rollupContract = deps.rollupContract; this.feeProvider = deps.feeProvider; + this.nextBlockPredictor = deps.nextBlockPredictor; this.epochCache = deps.epochCache; this.packageVersion = deps.packageVersion; this.peerProofVerifier = deps.peerProofVerifier; @@ -232,17 +236,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb this.metrics = new NodeMetrics(this.telemetry, 'AztecNodeService'); this.tracer = this.telemetry.getTracer('AztecNodeService'); - // The node never represents a proposer's payout addresses, so the simulator zeroes coinbase and - // fee recipient. The signature context only needs chain id + rollup address (see signature_utils). this.nodePublicCallsSimulator = new NodePublicCallsSimulator({ - blockSource: this.blockSource, worldStateSynchronizer: this.worldStateSynchronizer, l1ToL2MessageSource: this.l1ToL2MessageSource, contractDataSource: this.contractDataSource, - globalVariableBuilder: this.globalVariableBuilder, - rollupContract: this.rollupContract, - epochCache: this.epochCache, - signatureContext: { chainId: this.l1ChainId, rollupAddress: this.config.rollupAddress }, + predictor: this.nextBlockPredictor, config: this.config, telemetry: this.telemetry, log: this.log.createChild('public-calls-simulator'), @@ -598,6 +596,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb await tryStop(this.automineSequencer); await tryStop(this.proverNode); await tryStop(this.p2pClient); + await tryStop(this.nextBlockPredictor); await tryStop(this.feeProvider); await tryStop(this.worldStateSynchronizer); await tryStop(this.blockSource); diff --git a/yarn-project/aztec-node/src/factory.ts b/yarn-project/aztec-node/src/factory.ts index 0895b4ed4234..1830d6bf15e4 100644 --- a/yarn-project/aztec-node/src/factory.ts +++ b/yarn-project/aztec-node/src/factory.ts @@ -56,6 +56,7 @@ import { createWorldState, createWorldStateSynchronizer } from '@aztec/world-sta import { createPublicClient } from 'viem'; import { type AztecNodeConfig, createKeyStoreForValidator } from './aztec-node/config.js'; +import { NextBlockPredictor } from './aztec-node/next_block/index.js'; import { AztecNodeService } from './aztec-node/server.js'; import { createSentinel } from './sentinel/factory.js'; @@ -254,6 +255,18 @@ export async function createAztecNodeService( await feeProvider.start(); started.push(feeProvider); + const nextBlockPredictor = NextBlockPredictor.create({ + blockSource: archiver, + globalVariableBuilder, + rollupContract, + epochCache, + signatureContext: { chainId: ethereumChain.chainInfo.id, rollupAddress: config.rollupAddress }, + dateProvider, + log: log.createChild('next-block-predictor'), + }); + nextBlockPredictor.start(); + started.push(nextBlockPredictor); + const collectOffenses = !config.disableValidator || config.enableOffenseCollection; // A prover node may still be proving an epoch whose blocks have already finalized on L1. Its proof @@ -640,6 +653,7 @@ export async function createAztecNodeService( globalVariableBuilder, rollupContract, feeProvider, + nextBlockPredictor, epochCache, packageVersion, peerProofVerifier, diff --git a/yarn-project/aztec-node/src/index.ts b/yarn-project/aztec-node/src/index.ts index 6de808cebdc2..700483d914f9 100644 --- a/yarn-project/aztec-node/src/index.ts +++ b/yarn-project/aztec-node/src/index.ts @@ -1,4 +1,5 @@ export * from './aztec-node/config.js'; +export { NextBlockPredictor } from './aztec-node/next_block/index.js'; export * from './aztec-node/register_node_rpc_handlers.js'; export * from './aztec-node/server.js'; export * from './factory.js'; diff --git a/yarn-project/txe/src/state_machine/index.ts b/yarn-project/txe/src/state_machine/index.ts index ddad4c0375ba..e3da888f77e8 100644 --- a/yarn-project/txe/src/state_machine/index.ts +++ b/yarn-project/txe/src/state_machine/index.ts @@ -1,8 +1,10 @@ -import { type AztecNodeConfig, AztecNodeService } from '@aztec/aztec-node'; +import { type AztecNodeConfig, AztecNodeService, NextBlockPredictor } from '@aztec/aztec-node'; import { TestCircuitVerifier } from '@aztec/bb-prover/test'; 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 { DateProvider } from '@aztec/foundation/timer'; import { type AnchorBlockStore, ContractClassService, @@ -49,6 +51,16 @@ export class TXEStateMachine { const aztecNodeConfig = {} as AztecNodeConfig; const log = createLogger('txe_node'); + const globalVariableBuilder = new TXEGlobalVariablesBuilder(); + const epochCache = new MockEpochCache(); + // Never started: TXE has no L1 to poll, and the predictor prices inline through the same path when asked. + const nextBlockPredictor = NextBlockPredictor.create({ + blockSource: archiver, + globalVariableBuilder, + epochCache, + signatureContext: { chainId: CHAIN_ID, rollupAddress: EthAddress.ZERO }, + dateProvider: new DateProvider(), + }); const node = new AztecNodeService({ config: aztecNodeConfig, p2pClient: new DummyP2P(), @@ -64,10 +76,11 @@ export class TXEStateMachine { stopStartedWatchers: async () => {}, l1ChainId: CHAIN_ID, version: VERSION, - globalVariableBuilder: new TXEGlobalVariablesBuilder(), + globalVariableBuilder, rollupContract: undefined, feeProvider: new TXEFeeProvider(), - epochCache: new MockEpochCache(), + nextBlockPredictor, + epochCache, packageVersion: PACKAGE_VERSION, peerProofVerifier: new TestCircuitVerifier(), rpcProofVerifier: new TestCircuitVerifier(),