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 => {