From 562e53d23d4ef04d61c35e6359068016ff5dc6e8 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 19 Mar 2026 23:19:11 -0300 Subject: [PATCH] fix: sync dateProvider from anvil stdout on every mined block Anvil logs block timestamps to stdout on each mined block. Parse these and update the TestDateProvider so it stays in lockstep with the L1 chain, eliminating drift between wall clock and anvil chain time. Also rename cheatCodes.timestamp() to lastBlockTimestamp() to clarify it returns the latest block's discrete timestamp, not the current time. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../aztec/src/testing/anvil_test_watcher.ts | 2 +- yarn-project/aztec/src/testing/cheat_codes.ts | 2 +- .../end-to-end/src/e2e_cheat_codes.test.ts | 8 ++-- .../src/e2e_crowdfunding_and_claim.test.ts | 2 +- .../end-to-end/src/e2e_p2p/add_rollup.test.ts | 2 +- .../end-to-end/src/e2e_synching.test.ts | 2 +- yarn-project/end-to-end/src/fixtures/setup.ts | 16 ++++---- .../src/simulators/lending_simulator.ts | 6 ++- .../ethereum/src/test/eth_cheat_codes.ts | 10 +++-- .../ethereum/src/test/start_anvil.test.ts | 37 +++++++++++++++++++ yarn-project/ethereum/src/test/start_anvil.ts | 27 +++++++++++++- 11 files changed, 90 insertions(+), 24 deletions(-) diff --git a/yarn-project/aztec/src/testing/anvil_test_watcher.ts b/yarn-project/aztec/src/testing/anvil_test_watcher.ts index 44ef6662a9c8..4eabc6583949 100644 --- a/yarn-project/aztec/src/testing/anvil_test_watcher.ts +++ b/yarn-project/aztec/src/testing/anvil_test_watcher.ts @@ -130,7 +130,7 @@ export class AnvilTestWatcher { return; } - const l1Time = (await this.cheatcodes.timestamp()) * 1000; + const l1Time = (await this.cheatcodes.lastBlockTimestamp()) * 1000; const wallTime = this.dateProvider.now(); if (l1Time > wallTime) { this.logger.warn(`L1 is ahead of wall time. Syncing wall time to L1 time`); diff --git a/yarn-project/aztec/src/testing/cheat_codes.ts b/yarn-project/aztec/src/testing/cheat_codes.ts index 94ebe0b87046..14413fd4ddd3 100644 --- a/yarn-project/aztec/src/testing/cheat_codes.ts +++ b/yarn-project/aztec/src/testing/cheat_codes.ts @@ -72,7 +72,7 @@ export class CheatCodes { * @param duration - The duration to advance time by (in seconds) */ async warpL2TimeAtLeastBy(sequencerClient: SequencerClient, node: AztecNode, duration: bigint | number) { - const currentTimestamp = await this.eth.timestamp(); + const currentTimestamp = await this.eth.lastBlockTimestamp(); const targetTimestamp = BigInt(currentTimestamp) + BigInt(duration); await this.warpL2TimeAtLeastTo(sequencerClient, node, targetTimestamp); } diff --git a/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts b/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts index 27b0bf124dfc..5a4646542537 100644 --- a/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts +++ b/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts @@ -46,19 +46,19 @@ describe('e2e_cheat_codes', () => { it.each([100, 42, 99])(`setNextBlockTimestamp by %i`, async increment => { const blockNumber = await ethCheatCodes.blockNumber(); - const timestamp = await ethCheatCodes.timestamp(); + const timestamp = await ethCheatCodes.lastBlockTimestamp(); await ethCheatCodes.setNextBlockTimestamp(timestamp + increment); - expect(await ethCheatCodes.timestamp()).toBe(timestamp); + expect(await ethCheatCodes.lastBlockTimestamp()).toBe(timestamp); await ethCheatCodes.mine(); expect(await ethCheatCodes.blockNumber()).toBe(blockNumber + 1); - expect(await ethCheatCodes.timestamp()).toBe(timestamp + increment); + expect(await ethCheatCodes.lastBlockTimestamp()).toBe(timestamp + increment); }); it('setNextBlockTimestamp to a past timestamp throws', async () => { - const timestamp = await ethCheatCodes.timestamp(); + const timestamp = await ethCheatCodes.lastBlockTimestamp(); const pastTimestamp = timestamp - 1000; await expect(async () => await ethCheatCodes.setNextBlockTimestamp(pastTimestamp)).rejects.toThrow( 'Timestamp error', diff --git a/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts b/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts index 68711711b23b..5668ba4e3e77 100644 --- a/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts +++ b/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts @@ -60,7 +60,7 @@ describe('e2e_crowdfunding_and_claim', () => { } = await setup(3)); // We set the deadline to a week from now - deadline = (await cheatCodes.eth.timestamp()) + 7 * 24 * 60 * 60; + deadline = (await cheatCodes.eth.lastBlockTimestamp()) + 7 * 24 * 60 * 60; ({ contract: donationToken } = await TokenContract.deploy( wallet, diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index 2b3d00198fa4..c1a2c1db8da6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -517,7 +517,7 @@ describe('e2e_p2p_add_rollup', () => { const futureEpoch = EpochNumber.fromBigInt(500n + BigInt(await newRollup.getCurrentEpochNumber())); const futureSlot = SlotNumber.fromBigInt(BigInt(futureEpoch) * BigInt(t.ctx.aztecNodeConfig.aztecEpochDuration)); const time = await newRollup.getTimestampForSlot(futureSlot); - if (time > BigInt(await t.ctx.cheatCodes.eth.timestamp())) { + if (time > BigInt(await t.ctx.cheatCodes.eth.lastBlockTimestamp())) { await t.ctx.cheatCodes.eth.warp(Number(time)); await waitL1Block(); } diff --git a/yarn-project/end-to-end/src/e2e_synching.test.ts b/yarn-project/end-to-end/src/e2e_synching.test.ts index dbf533d16e94..ece38c7340aa 100644 --- a/yarn-project/end-to-end/src/e2e_synching.test.ts +++ b/yarn-project/end-to-end/src/e2e_synching.test.ts @@ -470,7 +470,7 @@ describe('e2e_synching', () => { for (const checkpoint of checkpoints) { const lastBlock = checkpoint.blocks.at(-1)!; const targetTime = Number(lastBlock.header.globalVariables.timestamp) - ETHEREUM_SLOT_DURATION; - while ((await cheatCodes.eth.timestamp()) < targetTime) { + while ((await cheatCodes.eth.lastBlockTimestamp()) < targetTime) { await cheatCodes.eth.mine(); } // If it breaks here, first place you should look is the pruning. diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 059c7c295899..5352d81ec8ca 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -302,6 +302,8 @@ export async function setup( config.dataDirectory = directoryToCleanup; } + const dateProvider = new TestDateProvider(); + if (!config.l1RpcUrls?.length) { if (!isAnvilTestChain(chain.id)) { throw new Error(`No ETHEREUM_HOSTS set but non anvil chain requested`); @@ -311,6 +313,7 @@ export async function setup( accounts: opts.anvilAccounts, port: opts.anvilPort ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : undefined), slotsInAnEpoch: opts.anvilSlotsInAnEpoch, + dateProvider, }); anvil = res.anvil; config.l1RpcUrls = [res.rpcUrl]; @@ -322,8 +325,6 @@ export async function setup( logger.info(`Logging metrics to ${filename}`); setupMetricsLogger(filename); } - - const dateProvider = new TestDateProvider(); const ethCheatCodes = new EthCheatCodesWithState(config.l1RpcUrls, dateProvider); if (opts.stateLoad) { @@ -419,11 +420,12 @@ export async function setup( await ethCheatCodes.setIntervalMining(config.ethereumSlotDuration); } - // Always sync dateProvider to L1 time after deploying L1 contracts, regardless of mining mode. - // In compose mode, L1 time may have drifted ahead of system time due to the local-network watcher - // warping time forward on each filled slot. Without this sync, the sequencer computes the wrong - // slot from its dateProvider and cannot propose blocks. - dateProvider.setTime((await ethCheatCodes.timestamp()) * 1000); + // In compose mode (no local anvil), sync dateProvider to L1 time since it may have drifted + // ahead of system time due to the local-network watcher warping time forward on each filled slot. + // When running with a local anvil, the dateProvider is kept in sync via the stdout listener. + if (!anvil) { + dateProvider.setTime((await ethCheatCodes.lastBlockTimestamp()) * 1000); + } if (opts.l2StartTime) { await ethCheatCodes.warp(opts.l2StartTime, { resetBlockInterval: true }); diff --git a/yarn-project/end-to-end/src/simulators/lending_simulator.ts b/yarn-project/end-to-end/src/simulators/lending_simulator.ts index 404bb3d5ad8d..ae299b31e249 100644 --- a/yarn-project/end-to-end/src/simulators/lending_simulator.ts +++ b/yarn-project/end-to-end/src/simulators/lending_simulator.ts @@ -94,7 +94,9 @@ export class LendingSimulator { async prepare() { this.accumulator = BASE; - const slot = await this.rollup.getSlotAt(BigInt(await this.cc.eth.timestamp()) + BigInt(this.ethereumSlotDuration)); + const slot = await this.rollup.getSlotAt( + BigInt(await this.cc.eth.lastBlockTimestamp()) + BigInt(this.ethereumSlotDuration), + ); this.time = Number(await this.rollup.getTimestampForSlot(slot)); } @@ -103,7 +105,7 @@ export class LendingSimulator { return; } - const slot = await this.rollup.getSlotAt(BigInt(await this.cc.eth.timestamp())); + const slot = await this.rollup.getSlotAt(BigInt(await this.cc.eth.lastBlockTimestamp())); const targetSlot = SlotNumber(slot + diff); const ts = Number(await this.rollup.getTimestampForSlot(targetSlot)); const timeDiff = ts - this.time; diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index d62b194c0241..b1cbdd199d6f 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -85,10 +85,12 @@ export class EthCheatCodes { } /** - * Get the current timestamp - * @returns The current timestamp + * Get the timestamp of the latest mined L1 block. + * Note: this is NOT the current time — it's the discrete timestamp of the last block. + * Between blocks, the actual chain time advances but no new block reflects it. + * @returns The latest block timestamp in seconds */ - public async timestamp(): Promise { + public async lastBlockTimestamp(): Promise { const res = await this.doRpcCall('eth_getBlockByNumber', ['latest', true]); return parseInt(res.timestamp, 16); } @@ -552,7 +554,7 @@ export class EthCheatCodes { } public async syncDateProvider() { - const timestamp = await this.timestamp(); + const timestamp = await this.lastBlockTimestamp(); if ('setTime' in this.dateProvider) { this.dateProvider.setTime(timestamp * 1000); } diff --git a/yarn-project/ethereum/src/test/start_anvil.test.ts b/yarn-project/ethereum/src/test/start_anvil.test.ts index 7e3b899ef7f6..ebd0fd80a304 100644 --- a/yarn-project/ethereum/src/test/start_anvil.test.ts +++ b/yarn-project/ethereum/src/test/start_anvil.test.ts @@ -1,5 +1,6 @@ import { type Logger, createLogger } from '@aztec/foundation/log'; import { sleep } from '@aztec/foundation/sleep'; +import { TestDateProvider } from '@aztec/foundation/timer'; import { createPublicClient, http, parseAbiItem } from 'viem'; @@ -52,4 +53,40 @@ describe('start_anvil', () => { stopWatching(); await sleep(100); }); + + it('syncs dateProvider to anvil block time on each mined block', async () => { + // Stop the default anvil instance (no dateProvider). + await anvil.stop(); + + const dateProvider = new TestDateProvider(); + const res = await startAnvil({ dateProvider }); + anvil = res.anvil; + rpcUrl = res.rpcUrl; + + const publicClient = createPublicClient({ transport: http(rpcUrl, { batch: false }) }); + + // Mine a block so anvil emits a "Block Time" line. + await publicClient.request({ method: 'evm_mine', params: [] } as any); + // Give the stdout listener time to fire. + await sleep(200); + + const block = await publicClient.getBlock({ blockTag: 'latest' }); + const blockTimeMs = Number(block.timestamp) * 1000; + // The dateProvider should now be within 2 seconds of the anvil block time. + // TestDateProvider.now() = Date.now() + offset, and setTime sets offset = blockTimeMs - Date.now(), + // so subsequent now() calls return blockTimeMs + elapsed. We check the difference is small. + expect(Math.abs(dateProvider.now() - blockTimeMs)).toBeLessThan(2000); + + // Warp anvil forward by 1000 seconds and verify the dateProvider follows. + const futureTimestamp = Number(block.timestamp) + 1000; + await publicClient.request({ + method: 'evm_setNextBlockTimestamp', + params: [futureTimestamp], + } as any); + await publicClient.request({ method: 'evm_mine', params: [] } as any); + await sleep(200); + + const futureTimeMs = futureTimestamp * 1000; + expect(Math.abs(dateProvider.now() - futureTimeMs)).toBeLessThan(2000); + }); }); diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index ba2f8b573604..f5ba8609e15f 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -1,5 +1,6 @@ import { createLogger } from '@aztec/foundation/log'; import { makeBackoff, retry } from '@aztec/foundation/retry'; +import type { TestDateProvider } from '@aztec/foundation/timer'; import { fileURLToPath } from '@aztec/foundation/url'; import { type ChildProcess, spawn } from 'child_process'; @@ -33,6 +34,12 @@ export async function startAnvil( * L1-finality-based logic work without needing hundreds of mined blocks. */ slotsInAnEpoch?: number; + /** + * If provided, the date provider will be synced to anvil's block time on every mined block. + * This keeps the dateProvider in lockstep with anvil's chain time, avoiding drift between + * the wall clock and the L1 chain when computing L1 slot timestamps. + */ + dateProvider?: TestDateProvider; } = {}, ): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise }> { const anvilBinary = resolve(dirname(fileURLToPath(import.meta.url)), '../../', 'scripts/anvil_kill_wrapper.sh'); @@ -108,12 +115,15 @@ export async function startAnvil( child.once('close', onClose); }); - // Continue piping for logging / method-call capture after startup. - if (logger || opts.captureMethodCalls) { + // Continue piping for logging, method-call capture, and/or dateProvider sync after startup. + if (logger || opts.captureMethodCalls || opts.dateProvider) { child.stdout?.on('data', (data: Buffer) => { const text = data.toString(); logger?.debug(text.trim()); methodCalls?.push(...(text.match(/eth_[^\s]+/g) || [])); + if (opts.dateProvider) { + syncDateProviderFromAnvilOutput(text, opts.dateProvider); + } }); child.stderr?.on('data', (data: Buffer) => { logger?.debug(data.toString().trim()); @@ -160,6 +170,19 @@ export async function startAnvil( return { anvil: anvilObj, methodCalls, stop, rpcUrl: `http://127.0.0.1:${port}` }; } +/** Extracts block time from anvil stdout and syncs the dateProvider. */ +function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDateProvider): void { + // Anvil logs mined blocks as: + // Block Time: "Fri, 20 Mar 2026 02:10:46 +0000" + const match = text.match(/Block Time:\s*"([^"]+)"/); + if (match) { + const blockTimeMs = new Date(match[1]).getTime(); + if (!isNaN(blockTimeMs)) { + dateProvider.setTime(blockTimeMs); + } + } +} + /** Send SIGTERM, wait up to 5 s, then SIGKILL. All timers are always cleared. */ function killChild(child: ChildProcess): Promise { return new Promise(resolve => {