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 new file mode 100644 index 000000000000..a9e82c3595fd --- /dev/null +++ b/yarn-project/aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts @@ -0,0 +1,365 @@ +import { TestCircuitVerifier } from '@aztec/bb-prover'; +import { EpochCache } from '@aztec/epoch-cache'; +import { getPublicClient } from '@aztec/ethereum/client'; +import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; +import { type FeeHeader, RollupContract } from '@aztec/ethereum/contracts'; +import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; +import type { ViemPublicClient } from '@aztec/ethereum/types'; +import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; +import { maxBy } from '@aztec/foundation/collection'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createLogger } from '@aztec/foundation/log'; +import { ManualDateProvider } from '@aztec/foundation/timer'; +import type { P2P } from '@aztec/p2p'; +import { FeeProviderImpl, GlobalVariableBuilder, type GlobalVariableBuilderConfig } from '@aztec/sequencer-client'; +import { + type BlockData, + BlockHash, + type BlockQuery, + L2Block, + type L2BlockSource, + type L2Tips, +} from '@aztec/stdlib/block'; +import type { ContractDataSource } from '@aztec/stdlib/contract'; +import { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; +import type { L2LogsSource, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; +import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import { mockTx } from '@aztec/stdlib/testing'; +import { BlockHeader, GlobalVariables, type Tx } from '@aztec/stdlib/tx'; +import { getPackageVersion } from '@aztec/stdlib/update-checker'; +import { NativeWorldStateService } from '@aztec/world-state'; + +import { type MockProxy, mock } from 'jest-mock-extended'; +import { foundry } from 'viem/chains'; + +import { type AztecNodeConfig, getConfigEnvVars } from './config.js'; +import { AztecNodeService } from './server.js'; + +/** + * Reproduces the wallet-quote / public-simulation fee mismatch: both ask L1 for a mana min fee, but they can + * ask about different slots, and the L1 gas oracle steps between them. The oracle is driven so that the fee + * before slot `C` (`HIGH`) is far above the fee from slot `C` on (`LOW`), and the L1 pending checkpoint is + * planted at slot `C - 1`, which anchors the wallet quote to slot `C`. + */ +describe('fee quote vs public simulation', () => { + const HIGH_L1_BASE_FEE = 1_000_000_000_000n; // 1000 gwei + const LOW_L1_BASE_FEE = 1_000_000_000n; // 1 gwei + const PENDING_CHECKPOINT = CheckpointNumber(1); + /** Slots to skip before each oracle update so the rollup's `LIFETIME - LAG` cooldown has elapsed. */ + const ORACLE_UPDATE_SLOT_GAP = 10; + + let anvil: Anvil; + let rpcUrl: string; + let publicClient: ViemPublicClient; + let cheatCodes: EthCheatCodes; + let rollupCheatCodes: RollupCheatCodes; + let rollup: RollupContract; + + const ethereumSlotDuration = DefaultL1ContractsConfig.ethereumSlotDuration; + let slotDuration: number; + let l1GenesisTime: bigint; + let rollupVersion: bigint; + let globalVariableBuilderConfig: GlobalVariableBuilderConfig; + + let dateProvider: ManualDateProvider; + let epochCache: EpochCache; + let globalVariableBuilder: GlobalVariableBuilder; + let worldState: NativeWorldStateService; + + let blockSource: MockProxy; + let worldStateSynchronizer: MockProxy; + let l1ToL2MessageSource: MockProxy; + let contractDataSource: MockProxy; + + let feeProvider: FeeProviderImpl; + let node: AztecNodeService; + + /** First slot at which the stepped-down oracle value applies. */ + let changeSlot: SlotNumber; + /** Mana min fee at `changeSlot - 1` (pre-step, high). */ + let highFee: bigint; + /** Mana min fee at `changeSlot` and beyond (post-step, low). */ + let lowFee: bigint; + + const tsOf = (slot: SlotNumber): bigint => l1GenesisTime + BigInt(slot) * BigInt(slotDuration); + const feeAt = (slot: SlotNumber): Promise => rollup.getManaMinFeeAt(tsOf(slot), true); + + /** Mirrors `BaseWallet.getMinFees` + its default `minFeePadding` of 0.5: worst fee by `feePerL2Gas`, padded. */ + const walletCap = (fees: GasFees[]): GasFees => maxBy(fees, f => f.feePerL2Gas)!.mul(1.5); + + /** Stable per-block hash, so tips and the block-data mock agree on identity for hash lookups. */ + const blockHashOf = (blockNumber: BlockNumber): BlockHash => new BlockHash(new Fr(1000 + blockNumber)); + + const makeTips = (args: { proposed: BlockNumber; checkpointedBlock: BlockNumber }): L2Tips => { + const checkpointId = { number: PENDING_CHECKPOINT, hash: '0xc1' }; + const checkpointedBlockId = { + number: args.checkpointedBlock, + hash: blockHashOf(args.checkpointedBlock).toString(), + }; + return { + proposed: { number: args.proposed, hash: blockHashOf(args.proposed).toString() }, + checkpointed: { block: checkpointedBlockId, checkpoint: checkpointId }, + proven: { block: checkpointedBlockId, checkpoint: checkpointId }, + finalized: { block: checkpointedBlockId, checkpoint: checkpointId }, + }; + }; + + const makeBlockData = (blockNumber: BlockNumber, slotNumber: SlotNumber, gasFees: GasFees): BlockData => ({ + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber, slotNumber, gasFees }) }), + archive: L2Block.empty().archive, + blockHash: blockHashOf(blockNumber), + checkpointNumber: PENDING_CHECKPOINT, + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }); + + /** Answers both number and hash lookups for the blocks the tips name, as the archiver does. */ + const mockBlockData = (blocks: { number: BlockNumber; slotNumber: SlotNumber; gasFees: GasFees }[]) => + blockSource.getBlockData.mockImplementation((query: BlockQuery) => { + const match = blocks.find(b => + 'number' in query ? query.number === b.number : 'hash' in query && query.hash.equals(blockHashOf(b.number)), + ); + return Promise.resolve(match ? makeBlockData(match.number, match.slotNumber, match.gasFees) : undefined); + }); + + const makeTx = (seed: number, maxFeesPerGas: GasFees): Promise => + mockTx(seed, { + numberOfNonRevertiblePublicCallRequests: 0, + numberOfRevertiblePublicCallRequests: 0, + maxFeesPerGas, + chainId: new Fr(foundry.id), + version: new Fr(rollupVersion), + }); + + /** + * Builds a fee provider and a node against the current node clock. The provider caches its quote at + * `start()`, so it must be built after the clock is positioned for the scenario under test. + */ + const startNodeAt = async (nodeClockSlotStart: bigint) => { + dateProvider.setTime(Number(nodeClockSlotStart) * 1000); + + feeProvider = new FeeProviderImpl(dateProvider, publicClient, globalVariableBuilderConfig); + // Long polling interval: the quote must stay pinned to the clock this scenario set. + await feeProvider.start(60_000); + + const config: AztecNodeConfig = { + ...getConfigEnvVars(), + rollupAddress: EthAddress.fromString(rollup.address), + }; + + node = new AztecNodeService({ + config, + p2pClient: mock(), + blockSource, + logsSource: mock(), + contractDataSource, + l1ToL2MessageSource, + worldStateSynchronizer, + sequencer: undefined, + proverNode: undefined, + slasherClient: undefined, + validatorsSentinel: undefined, + stopStartedWatchers: () => Promise.resolve(), + l1ChainId: foundry.id, + version: Number(rollupVersion), + globalVariableBuilder, + rollupContract: rollup, + feeProvider, + epochCache, + packageVersion: getPackageVersion(), + peerProofVerifier: new TestCircuitVerifier(), + rpcProofVerifier: new TestCircuitVerifier(), + }); + }; + + beforeAll(async () => { + const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; + + ({ anvil, rpcUrl } = await startAnvil()); + + publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: foundry.id }); + cheatCodes = new EthCheatCodes([rpcUrl], new ManualDateProvider()); + + const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { + ...DefaultL1ContractsConfig, + vkTreeRoot: Fr.random(), + protocolContractsHash: Fr.random(), + genesisArchiveRoot: Fr.random(), + realVerifier: false, + }); + + rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); + rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployed.l1ContractAddresses, new ManualDateProvider()); + + [slotDuration, l1GenesisTime, rollupVersion] = await Promise.all([ + rollup.getSlotDuration(), + rollup.getL1GenesisTime(), + rollup.getVersion().then(BigInt), + ]); + + // Step the L1 gas oracle down: the first update publishes the high value, the second one demotes it to + // `pre` and publishes the low value as `post`, which activates a couple of slots later. + await publishOracleBaseFee(HIGH_L1_BASE_FEE); + await publishOracleBaseFee(LOW_L1_BASE_FEE); + + changeSlot = await findOracleChangeSlot(); + + // Plant the L1 pending checkpoint at the slot right before the oracle steps, with a neutral fee header so + // the fee difference between slots comes purely from the L1 base fee. Marking it proven keeps the pending + // chain unprunable, so the fee reads never fall back to the genesis checkpoint. + const genesisFeeHeader = (await rollup.getCheckpoint(CheckpointNumber(0))).feeHeader; + const feeHeader: FeeHeader = { + excessMana: 0n, + manaUsed: 0n, + ethPerFeeAsset: genesisFeeHeader.ethPerFeeAsset, + congestionCost: 0n, + proverCost: 0n, + }; + await rollupCheatCodes.setPendingCheckpoint(PENDING_CHECKPOINT, SlotNumber(changeSlot - 1), feeHeader); + await rollupCheatCodes.markAsProven(PENDING_CHECKPOINT); + + highFee = await feeAt(SlotNumber(changeSlot - 1)); + lowFee = await feeAt(changeSlot); + + // Precondition: a wallet quote priced at the post-step slot cannot cover a simulation priced at the + // pre-step slot, even with the wallet's default 1.5x padding. + expect(highFee).toBeGreaterThan((lowFee * 3n) / 2n); + + globalVariableBuilderConfig = { + rollupAddress: EthAddress.fromString(rollup.address), + ethereumSlotDuration, + rollupVersion, + l1GenesisTime, + slotDuration, + }; + + dateProvider = new ManualDateProvider(); + globalVariableBuilder = new GlobalVariableBuilder(publicClient, globalVariableBuilderConfig); + const [ + l1StartBlock, + epochDuration, + proofSubmissionEpochs, + targetCommitteeSize, + rollupManaLimit, + lagInEpochsForValidatorSet, + lagInEpochsForRandao, + ] = await Promise.all([ + rollup.getL1StartBlock(), + rollup.getEpochDuration(), + rollup.getProofSubmissionEpochs(), + rollup.getTargetCommitteeSize(), + rollup.getManaLimit(), + rollup.getLagInEpochsForValidatorSet(), + rollup.getLagInEpochsForRandao(), + ]); + epochCache = new EpochCache( + rollup, + { + l1StartBlock, + l1GenesisTime, + slotDuration, + epochDuration: Number(epochDuration), + ethereumSlotDuration, + proofSubmissionEpochs: Number(proofSubmissionEpochs), + targetCommitteeSize: Number(targetCommitteeSize), + rollupManaLimit: Number(rollupManaLimit), + lagInEpochsForValidatorSet: Number(lagInEpochsForValidatorSet), + lagInEpochsForRandao: Number(lagInEpochsForRandao), + }, + dateProvider, + ); + + worldState = await NativeWorldStateService.tmp(); + }, 180_000); + + afterAll(async () => { + await worldState?.close(); + await anvil?.stop().catch(err => createLogger('cleanup').error(`Error stopping anvil`, err)); + }); + + beforeEach(() => { + blockSource = mock(); + worldStateSynchronizer = mock(); + l1ToL2MessageSource = mock(); + contractDataSource = mock(); + + blockSource.getPendingChainValidationStatus.mockResolvedValue({ valid: true }); + blockSource.getProposedCheckpointData.mockResolvedValue(undefined); + l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue([]); + worldStateSynchronizer.syncImmediate.mockResolvedValue(BlockNumber.ZERO); + // The mocked archiver reports block numbers the fresh world state does not have, so every fork is taken + // at its (empty) latest block. + worldStateSynchronizer.fork.mockImplementation(() => worldState.fork()); + }); + + afterEach(async () => { + await feeProvider?.stop(); + }); + + /** + * Publishes `baseFee` as the oracle's `post` value. The rollup silently ignores an update that lands too + * soon after the previous one activates, so L1 time is advanced well past that window first. + */ + async function publishOracleBaseFee(baseFee: bigint): Promise { + const currentSlot = await rollup.getSlotNumber(); + await rollupCheatCodes.advanceToSlot(SlotNumber(currentSlot + ORACLE_UPDATE_SLOT_GAP)); + await cheatCodes.setNextBlockBaseFeePerGas(baseFee); + await cheatCodes.mine(); + await rollupCheatCodes.updateL1GasFeeOracle(); + } + + /** Binary-searches for the first slot at which the oracle serves its `post` value instead of `pre`. */ + async function findOracleChangeSlot(): Promise { + const currentSlot = await rollup.getSlotNumber(); + const farFutureSlot = SlotNumber(currentSlot + 100); + const pre = (await rollup.getL1FeesAt(tsOf(SlotNumber(0)))).baseFee; + const post = (await rollup.getL1FeesAt(tsOf(farFutureSlot))).baseFee; + // Both oracle updates must have landed, and the step must be downwards for the scenarios below. + expect(pre).toBeGreaterThan(post); + + let low = 0; + let high = farFutureSlot as number; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((await rollup.getL1FeesAt(tsOf(SlotNumber(mid)))).baseFee === post) { + high = mid; + } else { + low = mid + 1; + } + } + return SlotNumber(low); + } + + it('prices a tx the same way whether the node clock lags L1 or not', async () => { + // The node still believes the next buildable slot is the one the pending checkpoint already took. + await startNodeAt(tsOf(SlotNumber(changeSlot - 2))); + expect(epochCache.getEpochAndSlotInNextL1Slot().slot).toEqual(SlotNumber(changeSlot - 2)); + + blockSource.getL2Tips.mockResolvedValue(makeTips({ proposed: BlockNumber(1), checkpointedBlock: BlockNumber(1) })); + mockBlockData([{ number: BlockNumber(1), slotNumber: SlotNumber(changeSlot - 1), gasFees: GasFees.empty() }]); + + const fees = await feeProvider.getPredictedMinFees(ManaUsageEstimate.Limit); + expect(fees[0].feePerL2Gas).toEqual(lowFee); + + const output = await node.simulatePublicCalls(await makeTx(0x10000, walletCap(fees))); + + expect(output.globalVariables.slotNumber).toEqual(changeSlot); + expect(output.globalVariables.gasFees.feePerL2Gas).toEqual(fees[0].feePerL2Gas); + }, 120_000); + + it('quotes the same fee the simulation charges when nothing is lagging', async () => { + await startNodeAt(tsOf(changeSlot)); + + blockSource.getL2Tips.mockResolvedValue(makeTips({ proposed: BlockNumber(1), checkpointedBlock: BlockNumber(1) })); + mockBlockData([{ number: BlockNumber(1), slotNumber: SlotNumber(changeSlot - 1), gasFees: GasFees.empty() }]); + + const fees = await node.getPredictedMinFees(ManaUsageEstimate.Limit); + expect(fees[0].feePerL2Gas).toEqual(lowFee); + + const output = await node.simulatePublicCalls(await makeTx(0x30000, walletCap(fees))); + + expect(output.globalVariables.slotNumber).toEqual(SlotNumber(changeSlot + 1)); + expect(output.globalVariables.gasFees.feePerL2Gas).toEqual(lowFee); + }, 120_000); +}); 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 9dd3e089a1c4..6218b76f46c7 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 @@ -63,37 +63,50 @@ describe('NodePublicCallsSimulator', () => { // would run them through, so tests can assert on the result rather than on mock call counts. let builtGlobals: GlobalVariables | undefined; + /** Stable per-block hash, so tips and the block-data mock agree on block identity for hash lookups. */ + const blockHashOf = (blockNumber: BlockNumber): BlockHash => new BlockHash(new Fr(1000 + blockNumber)); + const makeTips = (args: { proposed: BlockNumber; checkpointedBlock: BlockNumber; checkpointed: CheckpointNumber; proven?: CheckpointNumber; - }): L2Tips => ({ - proposed: { number: args.proposed, hash: '0x0' }, - checkpointed: { - block: { number: args.checkpointedBlock, hash: '0x0' }, - checkpoint: { number: args.checkpointed, hash: '0x0' }, - }, - proven: { - block: { number: BlockNumber.ZERO, hash: '0x0' }, - checkpoint: { number: args.proven ?? args.checkpointed, hash: '0x0' }, - }, - finalized: { - block: { number: BlockNumber.ZERO, hash: '0x0' }, - checkpoint: { number: args.proven ?? args.checkpointed, hash: '0x0' }, - }, - }); + }): L2Tips => { + const blockId = (number: BlockNumber) => ({ number, hash: blockHashOf(number).toString() }); + const checkpointId = (number: CheckpointNumber) => ({ number, hash: '0x0' }); + 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 makeBlockData = (blockNumber: BlockNumber, slotNumber: SlotNumber, gasFees = GasFees.empty()): BlockData => ({ header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber, slotNumber, gasFees }), }), archive: L2Block.empty().archive, - blockHash: BlockHash.random(), + blockHash: blockHashOf(blockNumber), checkpointNumber: CheckpointNumber(1), indexWithinCheckpoint: IndexWithinCheckpoint(0), }); + /** + * Answers both number and hash lookups with a block at the given slot. The block number only matters for + * the copy branch, which always looks the latest proposed block up by number. + */ + const mockBlockDataAtSlot = (slotNumber: SlotNumber, gasFees = GasFees.empty()) => + blockSource.getBlockData.mockImplementation((query: BlockQuery) => + Promise.resolve( + 'number' in query + ? makeBlockData(query.number, slotNumber, gasFees) + : 'hash' in query + ? makeBlockData(BlockNumber.ZERO, slotNumber, gasFees) + : undefined, + ), + ); + const mockNextL1Slot = (slot: SlotNumber) => { epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ epoch: EpochNumber.ZERO, @@ -203,9 +216,7 @@ describe('NodePublicCallsSimulator', () => { const headerSlot = SlotNumber(42); const headerGasFees = new GasFees(0, 777); setupMidCheckpoint(); - blockSource.getBlockData.mockImplementation((query: BlockQuery) => - Promise.resolve('number' in query ? makeBlockData(query.number, headerSlot, headerGasFees) : undefined), - ); + mockBlockDataAtSlot(headerSlot, headerGasFees); mockNextL1Slot(SlotNumber(100)); await simulator.simulate(tx); @@ -222,9 +233,7 @@ describe('NodePublicCallsSimulator', () => { it('does not insert L1-to-L2 messages', async () => { const tx = await lowGasTx(); setupMidCheckpoint(); - blockSource.getBlockData.mockImplementation((query: BlockQuery) => - Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(42)) : undefined), - ); + mockBlockDataAtSlot(SlotNumber(42)); mockNextL1Slot(SlotNumber(100)); await simulator.simulate(tx); @@ -263,9 +272,7 @@ describe('NodePublicCallsSimulator', () => { it('targets the next L1 slot plus the pipelining offset and pins tips to the checkpointed tip when idle', async () => { const tx = await lowGasTx(); blockSource.getL2Tips.mockResolvedValue(setupBoundary()); - blockSource.getBlockData.mockImplementation((query: BlockQuery) => - Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(5)) : undefined), - ); + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(20)); await simulator.simulate(tx); @@ -278,13 +285,88 @@ describe('NodePublicCallsSimulator', () => { expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(1), proven: CheckpointNumber(1) }); }); - it('inserts L1-to-L2 messages for the next checkpoint', async () => { + it('floors the target slot at the checkpointed tip slot plus one when the node clock lags the chain', async () => { + const tx = await lowGasTx(); + blockSource.getL2Tips.mockResolvedValue(setupBoundary()); + // The checkpointed tip already sits at slot 14, so the next block cannot land before slot 15. + mockBlockDataAtSlot(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(); + blockSource.getL2Tips.mockResolvedValue(setupBoundary()); + mockBlockDataAtSlot(SlotNumber(14)); + mockNextL1Slot(SlotNumber(20)); + + await simulator.simulate(tx); + + const [, , slotArg] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; + expect(slotArg).toEqual(SlotNumber(21)); + }); + + it('reads the checkpointed tip block by hash rather than by number', async () => { + const tx = await lowGasTx(); + const tips = setupBoundary(); + blockSource.getL2Tips.mockResolvedValue(tips); + // Only the hash lookup resolves: a number lookup could answer with a different block after an unwind. + blockSource.getBlockData.mockImplementation((query: BlockQuery) => + Promise.resolve( + 'hash' in query && query.hash.equals(BlockHash.fromString(tips.checkpointed.block.hash)) + ? makeBlockData(tips.checkpointed.block.number, SlotNumber(14)) + : undefined, + ), + ); + mockNextL1Slot(SlotNumber(13)); + + await simulator.simulate(tx); + + const [, , slotArg] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; + expect(slotArg).toEqual(SlotNumber(15)); + }); + + it('skips the floor at genesis, where the checkpointed tip is the unstored genesis block', async () => { + const tx = await lowGasTx(); + blockSource.getL2Tips.mockResolvedValue( + makeTips({ + proposed: BlockNumber.ZERO, + checkpointedBlock: BlockNumber.ZERO, + checkpointed: CheckpointNumber(0), + }), + ); + // The archiver holds no block at genesis, so a floor read here would wrongly fail the simulation. + blockSource.getBlockData.mockResolvedValue(undefined); + mockNextL1Slot(SlotNumber(3)); + + await simulator.simulate(tx); + + const [, , slotArg] = globalVariableBuilder.buildCheckpointGlobalVariables.mock.calls[0]; + expect(slotArg).toEqual(SlotNumber(4)); + }); + + it('fails with a retryable error when the checkpointed tip block is missing', async () => { const tx = await lowGasTx(); - const messages = [Fr.fromString('0x1234'), Fr.fromString('0x5678')]; blockSource.getL2Tips.mockResolvedValue(setupBoundary()); + // Number lookups still resolve, so dropping the floor would silently succeed at the wrong slot. blockSource.getBlockData.mockImplementation((query: BlockQuery) => - Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(5)) : undefined), + Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(14)) : undefined), ); + mockNextL1Slot(SlotNumber(13)); + + await expect(simulator.simulate(tx)).rejects.toThrow(/torn archiver snapshot/); + expect(globalVariableBuilder.buildCheckpointGlobalVariables).not.toHaveBeenCalled(); + }); + + it('inserts L1-to-L2 messages for the next checkpoint', async () => { + const tx = await lowGasTx(); + const messages = [Fr.fromString('0x1234'), Fr.fromString('0x5678')]; + blockSource.getL2Tips.mockResolvedValue(setupBoundary()); + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(20)); l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue(messages); @@ -300,9 +382,7 @@ describe('NodePublicCallsSimulator', () => { it('tolerates L1ToL2MessagesNotReadyError and simulates without messages', async () => { const tx = await lowGasTx(); blockSource.getL2Tips.mockResolvedValue(setupBoundary()); - blockSource.getBlockData.mockImplementation((query: BlockQuery) => - Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(5)) : undefined), - ); + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(20)); l1ToL2MessageSource.getL1ToL2Messages.mockRejectedValue(new L1ToL2MessagesNotReadyError(CheckpointNumber(2), 0n)); @@ -315,11 +395,10 @@ describe('NodePublicCallsSimulator', () => { const parentSlot = SlotNumber(30); const parentArchiveRoot = Fr.fromString('0xabcabc'); blockSource.getL2Tips.mockResolvedValue(setupBoundary({ checkpointed: CheckpointNumber(2) })); - // The parent slot must come from the proposed checkpoint data itself, not from a separate - // block-data read that can be torn from it — so leave block data unavailable here. - blockSource.getBlockData.mockResolvedValue(undefined); - // The next L1 slot is well behind the proposed parent's slot, so the proposed-checkpoint + 1 - // term must win the max(). + // 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. + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(5)); const proposedCheckpointData = makeProposedCheckpointData({ @@ -354,9 +433,7 @@ describe('NodePublicCallsSimulator', () => { it('pins tips to firstInvalid - 1 when the pending chain is invalid', async () => { const tx = await lowGasTx(); blockSource.getL2Tips.mockResolvedValue(setupBoundary({ checkpointed: CheckpointNumber(5) })); - blockSource.getBlockData.mockImplementation((query: BlockQuery) => - Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(5)) : undefined), - ); + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(20)); blockSource.getPendingChainValidationStatus.mockResolvedValue(makeInvalidStatus(CheckpointNumber(4))); @@ -371,6 +448,7 @@ describe('NodePublicCallsSimulator', () => { const tx = await lowGasTx(); simulator = makeSimulatorWithoutRollupContract(); blockSource.getL2Tips.mockResolvedValue(setupBoundary({ checkpointed: CheckpointNumber(2) })); + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(5)); blockSource.getProposedCheckpointData.mockResolvedValue( makeProposedCheckpointData({ @@ -393,6 +471,7 @@ describe('NodePublicCallsSimulator', () => { const tx = await lowGasTx(); simulator = makeSimulatorWithoutRollupContract(); blockSource.getL2Tips.mockResolvedValue(setupBoundary()); + mockBlockDataAtSlot(SlotNumber(5)); mockNextL1Slot(SlotNumber(20)); await expect(simulator.simulate(tx)).resolves.toBeDefined(); 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 60e4142aab26..41673cda588f 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,4 +1,5 @@ import { L1ToL2MessagesNotReadyError } from '@aztec/archiver'; +import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache'; import type { EpochCacheInterface } from '@aztec/epoch-cache'; import { @@ -17,7 +18,7 @@ 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, L2Tips } from '@aztec/stdlib/block'; +import { BlockHash, type L2BlockSource, type L2Tips } from '@aztec/stdlib/block'; import { type ProposedCheckpointData, buildCheckpointSimulationOverridesPlan } from '@aztec/stdlib/checkpoint'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; @@ -301,7 +302,7 @@ export class NodePublicCallsSimulator { // `proposedCheckpointData` read, so they cannot disagree about the proposed parent. const proposedCheckpointNumber = proposedCheckpointData?.checkpointNumber ?? checkpointedCheckpointNumber; - const targetSlot = this.computeTargetSlot(proposedCheckpointData); + const targetSlot = this.computeTargetSlot(proposedCheckpointData, await this.getCheckpointedTipSlot(l2Tips)); const plan = await this.buildSimulationOverridesPlan(proposedCheckpointData, checkpointedCheckpointNumber); const checkpointGlobalVariables = await this.globalVariableBuilder.buildCheckpointGlobalVariables( @@ -318,22 +319,56 @@ export class NodePublicCallsSimulator { } /** - * Slot the next block will land in. The first term is the sequencer's exact formula - * (`getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET`). The `max` with - * `proposedCheckpointSlot + 1` is 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. + * 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. 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): SlotNumber { + 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; - return SlotNumber(Math.max(...compactArray([slotFromNextL1Timestamp, slotAfterProposedCheckpoint]))); + const slotAfterCheckpointedTip = checkpointedTipSlot !== undefined ? checkpointedTipSlot + 1 : undefined; + return SlotNumber( + Math.max(...compactArray([slotFromNextL1Timestamp, slotAfterProposedCheckpoint, slotAfterCheckpointedTip])), + ); + } + + /** + * Slot of the latest checkpointed block, used as the floor for the next block's slot. Undefined before the + * first checkpoint lands, where the checkpointed tip is the genesis block and no slot is taken yet. + * + * Looked up by the tip's block hash rather than its number, so a checkpoint unwind that replaces the block + * at that number cannot silently answer with a different block. A miss means the archiver no longer holds + * the block its own tips name, which is a torn snapshot rather than a reason to drop the floor. + */ + private async getCheckpointedTipSlot(l2Tips: L2Tips): Promise { + const { number, hash } = l2Tips.checkpointed.block; + if (number < INITIAL_L2_BLOCK_NUM) { + return undefined; + } + const blockData = await this.blockSource.getBlockData({ hash: BlockHash.fromString(hash) }); + if (!blockData) { + throw new Error( + `Cannot simulate public calls: checkpointed tip block ${number} (${hash}) has no header on this node ` + + `(torn archiver snapshot); retry`, + ); + } + return blockData.header.globalVariables.slotNumber; } /** diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index 9aa17c44994d..9f3996c1a960 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -1,4 +1,4 @@ -import { OutboxContract, RollupContract } from '@aztec/ethereum/contracts'; +import { type FeeHeader, OutboxContract, RollupContract, TempCheckpointLogField } from '@aztec/ethereum/contracts'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ViemPublicClient } from '@aztec/ethereum/types'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; @@ -372,6 +372,38 @@ export class RollupCheatCodes { }); } + /** + * Rewrites the pending chain tip directly in storage: stores the given slot number and fee header in the + * checkpoint's `tempCheckpointLogs` entry and points the pending tip at it, leaving the proven tip alone. + * Lets tests place the chain at an arbitrary checkpoint and slot without proposing anything. + * @param checkpointNumber - Checkpoint to become the pending tip. + * @param slotNumber - Slot the checkpoint claims to have been proposed at. + * @param feeHeader - Fee header to store for the checkpoint. + */ + public async setPendingCheckpoint( + checkpointNumber: CheckpointNumber, + slotNumber: SlotNumber, + feeHeader: FeeHeader, + ): Promise { + const rollup = new RollupContract(this.client, this.rollup.address); + const rollupAddress = EthAddress.fromString(this.rollup.address); + const [feeHeaderSlot, slotNumberSlot] = await Promise.all([ + rollup.getTempCheckpointLogStorageSlot(checkpointNumber, TempCheckpointLogField.FeeHeader), + rollup.getTempCheckpointLogStorageSlot(checkpointNumber, TempCheckpointLogField.SlotNumber), + ]); + + await this.ethCheatCodes.store(rollupAddress, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); + await this.ethCheatCodes.store(rollupAddress, slotNumberSlot, BigInt(slotNumber)); + + const { proven } = await this.getTips(); + await this.ethCheatCodes.store( + rollupAddress, + RollupContract.chainTipsStorageSlot, + RollupContract.packChainTips(BigInt(checkpointNumber), BigInt(proven)), + ); + this.logger.warn(`Set pending checkpoint ${checkpointNumber} at slot ${slotNumber}`); + } + /** Directly calls the L1 gas fee oracle. */ public async updateL1GasFeeOracle() { await this.asOwner(async (account, rollup) => { diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts index c2707c907231..6ee7c430c9ee 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts @@ -5,7 +5,7 @@ import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS, RollupContract, TempCheckpointLogFiel import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; import type { ViemClient } from '@aztec/ethereum/types'; -import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; @@ -87,27 +87,8 @@ describe('FeePredictor', () => { } /** Writes a fee header and slot number for the given checkpoint, then bumps the pending tip. */ - async function advanceCheckpoint(checkpointNumber: CheckpointNumber, feeHeader: FeeHeader, slotNumber: bigint) { - const rollupAddress = EthAddress.fromString(rollup.address); - const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( - checkpointNumber, - TempCheckpointLogField.FeeHeader, - ); - await cheatCodes.store(rollupAddress, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); - - const slotNumberSlot = await rollup.getTempCheckpointLogStorageSlot( - checkpointNumber, - TempCheckpointLogField.SlotNumber, - ); - await cheatCodes.store(rollupAddress, slotNumberSlot, slotNumber & ((1n << 32n) - 1n)); - - const currentTips = await cheatCodes.load(rollupAddress, RollupContract.chainTipsStorageSlot); - const provenCheckpointNumber = currentTips & ((1n << 128n) - 1n); - await cheatCodes.store( - rollupAddress, - RollupContract.chainTipsStorageSlot, - RollupContract.packChainTips(BigInt(checkpointNumber), provenCheckpointNumber), - ); + function advanceCheckpoint(checkpointNumber: CheckpointNumber, feeHeader: FeeHeader, slotNumber: bigint) { + return rollupCheatCodes.setPendingCheckpoint(checkpointNumber, SlotNumber.fromBigInt(slotNumber), feeHeader); } async function getPredictionStartSlot(): Promise {