From c362c0e0b6825996fef32af568e896b5217befc9 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 12:44:14 -0300 Subject: [PATCH 01/12] fix(epoch-cache): use finalized L1 block and correct lag for committee guard The computeCommittee guard was using lagInEpochsForValidatorSet (the looser constraint) instead of lagInEpochsForRandao (the binding constraint), and queried the latest L1 block instead of the finalized one. This could allow caching a committee whose RANDAO seed is not yet finalized on L1. Fixes the guard to use lagInEpochsForRandao and the finalized block tag, computes sampling timestamp from epoch start (not slot timestamp), introduces EpochNotFinalizedError and EpochNotStableError, and adds integration tests against a real Anvil instance. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/epoch-cache/READMD.md | 3 - yarn-project/epoch-cache/README.md | 198 ++++++++++++++ .../src/epoch_cache.integration.test.ts | 249 ++++++++++++++++++ .../epoch-cache/src/epoch_cache.test.ts | 84 ++++-- yarn-project/epoch-cache/src/epoch_cache.ts | 118 ++++----- yarn-project/epoch-cache/src/errors.ts | 28 ++ yarn-project/epoch-cache/src/types.ts | 53 ++++ 7 files changed, 651 insertions(+), 82 deletions(-) delete mode 100644 yarn-project/epoch-cache/READMD.md create mode 100644 yarn-project/epoch-cache/README.md create mode 100644 yarn-project/epoch-cache/src/epoch_cache.integration.test.ts create mode 100644 yarn-project/epoch-cache/src/errors.ts create mode 100644 yarn-project/epoch-cache/src/types.ts diff --git a/yarn-project/epoch-cache/READMD.md b/yarn-project/epoch-cache/READMD.md deleted file mode 100644 index c5a9f339f131..000000000000 --- a/yarn-project/epoch-cache/READMD.md +++ /dev/null @@ -1,3 +0,0 @@ -## Epoch Cache - -Stores the current validator set. \ No newline at end of file diff --git a/yarn-project/epoch-cache/README.md b/yarn-project/epoch-cache/README.md new file mode 100644 index 000000000000..f1ecc340d47a --- /dev/null +++ b/yarn-project/epoch-cache/README.md @@ -0,0 +1,198 @@ +# Epoch Cache + +Caches validator committee information per epoch to reduce L1 RPC traffic. Provides +the current committee, proposer selection, and escape hatch status for any given slot +or epoch, used by the sequencer, validator client, and other node components that need +to know who can propose or attest in a given slot. + +## Committee Computation + +Each epoch has a **committee**: a subset of all registered validators selected to +participate in consensus for that epoch. The committee is determined by three inputs: + +1. **The validator set** -- the full list of registered attesters, snapshotted at a + past point in time. +2. **A RANDAO seed** -- pseudo-random value derived from Ethereum's `block.prevrandao`, + also sampled from the past. +3. **A sampling algorithm** -- a Fisher-Yates-style shuffle that picks + `targetCommitteeSize` indices from the validator set without replacement. + +Once computed, the committee is fixed for the entire epoch. The L1 rollup contract +stores a keccak256 commitment of the committee addresses to prevent substitution. + +### Sampling Algorithm + +The sampling draws `targetCommitteeSize` indices from a pool of `validatorSetSize` +using a sample-without-replacement approach: + +``` +for i in 0..committeeSize: + sampledIndex = keccak256(seed, i) % (poolSize - i) + committee[i] = pool[sampledIndex] + swap pool[sampledIndex] with pool[last] + shrink pool by 1 +``` + +Each iteration hashes the seed with the iteration index to pick a random position, +then swaps the picked element to the end and shrinks the pool, ensuring no validator +is selected twice. + +## LAG Values + +Two lag parameters prevent manipulation of committee composition: + +### `lagInEpochsForValidatorSet` + +When computing the committee for epoch N, the validator set is read from a snapshot +taken `lagInEpochsForValidatorSet` epochs in the past. The sampling timestamp is: + +``` +validatorSetTimestamp = epochStart(N) - (lagInEpochsForValidatorSet * epochDuration * slotDuration) +``` + +This prevents an attacker from registering new validators just before an epoch to +influence who gets selected. The validator set is locked well in advance. + +### `lagInEpochsForRandao` + +The RANDAO seed used for committee selection is sampled from +`lagInEpochsForRandao` epochs in the past: + +``` +randaoTimestamp = epochStart(N) - (lagInEpochsForRandao * epochDuration * slotDuration) +``` + +This prevents L1 validators from previewing the randomness and coordinating to +become proposers. + +### Why Two Separate Lags? + +The constraint `lagInEpochsForValidatorSet >= lagInEpochsForRandao` is enforced. +If both lags were equal, an attacker who learns the RANDAO seed could still +register validators in time to be included in the sampled set. By freezing the +validator set further back than the RANDAO seed, the attacker knows the randomness +but can no longer change the input population it selects from. + +## RANDAO Seed + +The RANDAO seed provides per-epoch randomness for committee selection and proposer +assignment. It works as follows: + +1. **Checkpointing**: Each epoch, `block.prevrandao` (Ethereum's beacon chain + randomness) is stored in a checkpointed mapping keyed by epoch timestamp. + Multiple calls in the same epoch are idempotent. + +2. **Seed derivation**: The actual seed used for sampling is: + ``` + seed = keccak256(abi.encode(epochNumber, storedRandao)) + ``` + where `storedRandao` is the value checkpointed at or before the RANDAO sampling + timestamp (determined by `lagInEpochsForRandao`). Mixing in the epoch number + ensures distinct seeds even if the same RANDAO value is reused. + +3. **Bootstrap**: The first two epochs use a bootstrapped RANDAO value stored at + initialization, since there is no prior history to sample from. + +## Proposer Selection + +Within a committee, a single **proposer** is designated for each slot. The proposer +is responsible for assembling transactions into a block and publishing it. + +The proposer index within the committee is: + +``` +proposerIndex = keccak256(abi.encode(epoch, slot, seed)) % committeeSize +``` + +This is deterministic: anyone with the epoch number, slot number, and seed can +independently compute who the proposer is. Each slot gets a different proposer +because the slot number is mixed into the hash. + +### Proposer Pipelining + +When proposer pipelining is enabled, the proposer builds for the *next* slot +rather than the current one (`PROPOSER_PIPELINING_SLOT_OFFSET = 1`). This gives +the proposer a full slot of lead time to assemble and propagate the block. The +"target slot" methods on the epoch cache apply this offset automatically. + +### Empty Committees + +If the committee is empty (i.e., `targetCommitteeSize` is 0), anyone can propose. +The proposer methods return `undefined` in this case rather than throwing. If the +committee should exist but doesn't (insufficient validators registered), a +`NoCommitteeError` is thrown. + +## Escape Hatch + +The escape hatch is a censorship-resistance mechanism. It opens periodically +(every `FREQUENCY` epochs, for `ACTIVE_DURATION` epochs) and allows a single +designated proposer to submit blocks without committee attestations. + +### Candidate System + +The escape hatch has its own candidate pool, separate from the main validator set: + +- Candidates join by posting a bond (`BOND_SIZE`). +- A designated proposer is selected per hatch window using a similar + RANDAO-based random selection from the candidate set. +- If the designated proposer fails to propose and prove during their window, + they are penalized (`FAILED_HATCH_PUNISHMENT`). +- Candidates exit through a two-step process (`initiateExit` then + `leaveCandidateSet`) with a withdrawal tax. + +### Integration with Epoch Cache + +The epoch cache queries `isHatchOpen(epoch)` on the escape hatch contract and +caches the result alongside the committee info for each epoch. This flag is +exposed via `isEscapeHatchOpen(epoch)` and `isEscapeHatchOpenAtSlot(slot)`, +used by the sequencer to decide whether to require committee attestations. + +## Finalized Block Guard + +Before caching a committee, the epoch cache checks that the epoch's sampling +data is based on **finalized** L1 state. It fetches the latest finalized L1 +block timestamp and verifies: + +``` +epochTimestamp - lagInEpochsForRandao * epochDuration * slotDuration <= l1FinalizedTimestamp +``` + +This uses `lagInEpochsForRandao` as the binding constraint (it is always +<= `lagInEpochsForValidatorSet`). The finalized block tag protects against +L1 reorgs: if a reorg changed the `block.prevrandao` at the RANDAO sampling +point, the committee seed would change, invalidating any committee we had +already cached. + +The finalized block fetch runs in parallel with the L1 contract calls +(committee, seed, escape hatch) to keep the happy path at minimum latency. +The guard check itself runs after all calls complete. In the rare case the +epoch is too far in the future, one of two things happens: + +- If the sampling timestamp exceeds the **latest** L1 block, the L1 + contract reverts with `ValidatorSelection__EpochNotStable` (wrapped as + `EpochNotStableError`). +- If the sampling timestamp is within latest but beyond the **finalized** + block, the contract calls succeed but the guard rejects with + `EpochNotFinalizedError`. + +## Caching Strategy + +The epoch cache stores committee info (committee members, seed, escape hatch +status) per epoch in an LRU-style map with a configurable size (default 12 +epochs). Cache entries are only created for epochs with non-empty committees; +empty results are not cached to allow retries. + +The cache also maintains a separate set of all registered validators, refreshed +on a configurable interval (`validatorRefreshIntervalSeconds`, default 60s), +used to check validator registration status independently of committee membership. + +## Configuration + +| Parameter | Default | Purpose | +|-----------|---------|---------| +| `cacheSize` | 12 | Max number of epoch committee entries to keep | +| `validatorRefreshIntervalSeconds` | 60 | How often to refresh the full validator list | +| `enableProposerPipelining` | false | Build for next slot instead of current | +| `lagInEpochsForValidatorSet` | (from L1) | How far back to snapshot the validator set | +| `lagInEpochsForRandao` | (from L1) | How far back to sample the RANDAO seed | +| `targetCommitteeSize` | (from L1) | Number of validators to select per epoch | diff --git a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts new file mode 100644 index 000000000000..80b6d5f6907a --- /dev/null +++ b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts @@ -0,0 +1,249 @@ +import { getPublicClient } from '@aztec/ethereum/client'; +import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import { EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; +import type { Anvil } from '@aztec/ethereum/test'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { SecretValue } from '@aztec/foundation/config'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createLogger } from '@aztec/foundation/log'; +import { TestDateProvider } from '@aztec/foundation/timer'; + +import { privateKeyToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +import { EpochCache, EpochNotFinalizedError, EpochNotStableError } from './epoch_cache.js'; + +/** + * Integration tests for EpochCache against a real Anvil instance with deployed L1 contracts. + * + * These tests verify: + * - Committee computation with real contract calls and RANDAO seeds + * - The finalized-block guard correctly rejects epochs whose data may still change + */ +describe('EpochCache Integration', () => { + let anvil: Anvil; + let rpcUrl: string; + let cheatCodes: EthCheatCodes; + let rollupCheatCodes: RollupCheatCodes; + let rollup: RollupContract; + let epochCache: EpochCache; + let dateProvider: TestDateProvider; + + const NUM_VALIDATORS = 4; + const deployerPrivateKey = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba' as const; + + // Use well-known funded Anvil accounts as validators + const validatorKeys = [ + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a', + '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6', + '0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a', + ] as const; + + const validatorAddresses = validatorKeys.map(k => EthAddress.fromString(privateKeyToAccount(k).address)); + + beforeAll(async () => { + dateProvider = new TestDateProvider(); + + // Start Anvil with slotsInAnEpoch=1 so finalized block advances quickly (finalized = latest - 2). + ({ anvil, rpcUrl } = await startAnvil({ + l1BlockTime: 1, + slotsInAnEpoch: 1, + dateProvider, + })); + + cheatCodes = new EthCheatCodes([rpcUrl], dateProvider); + + const initialValidators = validatorKeys.map((_, i) => ({ + attester: validatorAddresses[i], + withdrawer: validatorAddresses[i], + bn254SecretKey: new SecretValue(Fr.random().toBigInt()), + })); + + const deployed = await deployAztecL1Contracts(rpcUrl, deployerPrivateKey, foundry.id, { + ...DefaultL1ContractsConfig, + vkTreeRoot: Fr.random(), + protocolContractsHash: Fr.random(), + genesisArchiveRoot: Fr.random(), + realVerifier: false, + aztecTargetCommitteeSize: NUM_VALIDATORS, + initialValidators, + }); + + rollupCheatCodes = new RollupCheatCodes(cheatCodes, deployed.l1ContractAddresses); + + const publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: foundry.id }); + rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); + + // Create epoch cache from the real rollup contract. + epochCache = await EpochCache.create(rollup, undefined, { dateProvider }); + }, 120_000); + + afterAll(async () => { + await cheatCodes?.setIntervalMining(0); + await anvil?.stop().catch(err => createLogger('cleanup').error(err)); + }); + + describe('happy path', () => { + it('returns committee for a finalized epoch', async () => { + const constants = epochCache.getEpochCacheConstants(); + + // Advance past the validator set lag so the epoch's data is finalized. + const lagEpochs = Math.max(constants.lagInEpochsForValidatorSet, constants.lagInEpochsForRandao); + const targetEpoch = EpochNumber(lagEpochs + 2); + await rollupCheatCodes.advanceToEpoch(targetEpoch); + + // Mine a few extra blocks so the finalized tag catches up (finalized = latest - 2 with slotsInAnEpoch=1). + await cheatCodes.mine(5); + + // Setup epoch so the committee commitment is stored on-chain. + await rollupCheatCodes.setupEpoch(); + + const { committee, seed, epoch } = await epochCache.getCommittee('now'); + + expect(committee).toBeDefined(); + expect(committee!.length).toBe(NUM_VALIDATORS); + expect(seed).not.toBe(0n); + expect(epoch).toBe(targetEpoch); + + // All registered validators should be in the committee (since targetCommitteeSize == validator count). + const committeeStrings = new Set(committee!.map(v => v.toString())); + for (const v of validatorAddresses) { + expect(committeeStrings.has(v.toString())).toBe(true); + } + }, 60_000); + + it('caches committee and returns same result within epoch', async () => { + const first = await epochCache.getCommittee('now'); + const second = await epochCache.getCommittee('now'); + + expect(first.committee).toEqual(second.committee); + expect(first.seed).toEqual(second.seed); + expect(first.epoch).toEqual(second.epoch); + }); + + it('computes a deterministic proposer for each slot', async () => { + const { committee, epoch } = await epochCache.getCommittee('now'); + expect(committee).toBeDefined(); + expect(committee!.length).toBeGreaterThan(0); + + const constants = epochCache.getEpochCacheConstants(); + const startSlot = SlotNumber(Number(epoch) * constants.epochDuration); + + // Compute proposer for two different slots -- should be deterministic. + const proposer0 = await epochCache.getProposerAttesterAddressInSlot(startSlot); + const proposer1 = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(startSlot + 1)); + + expect(proposer0).toBeDefined(); + expect(proposer1).toBeDefined(); + + // Both proposers should be committee members. + const committeeStrings = new Set(committee!.map(v => v.toString())); + expect(committeeStrings.has(proposer0!.toString())).toBe(true); + expect(committeeStrings.has(proposer1!.toString())).toBe(true); + + // Calling again for the same slot should return the same proposer. + const proposer0Again = await epochCache.getProposerAttesterAddressInSlot(startSlot); + expect(proposer0Again!.equals(proposer0!)).toBe(true); + }); + }); + + describe('finalized block guard', () => { + /** + * To test the two rejection modes independently, we exploit the gap between the + * L1 "latest" and "finalized" block timestamps. + * + * The L1 contract checks: samplingTs <= latest_block.timestamp + * Our guard checks: samplingTs <= finalized_block.timestamp + * + * By stopping interval mining and warping L1 forward in a single block, we create + * a large gap: latest jumps far ahead while finalized (latest - 2 blocks) stays near + * the old timestamp. We then pick: + * + * - An epoch whose samplingTs lands between finalized and latest: + * L1 contract call succeeds, but our guard fires. + * + * - An epoch whose samplingTs is beyond latest: + * L1 contract reverts with ValidatorSelection__EpochNotStable. + */ + + afterEach(async () => { + // Ensure mining is restored even if a test assertion fails mid-way. + await cheatCodes.setAutomine(true); + await cheatCodes.setIntervalMining(1); + }); + + it('rejects with finalized-guard error when epoch is L1-stable but not finalized', async () => { + const constants = epochCache.getEpochCacheConstants(); + const { lagInEpochsForRandao, epochDuration, slotDuration } = constants; + const l1GenesisTime = constants.l1GenesisTime; + const lagSeconds = BigInt(lagInEpochsForRandao) * BigInt(epochDuration) * BigInt(slotDuration); + const epochSeconds = BigInt(epochDuration) * BigInt(slotDuration); + + // Stop interval mining so we control exactly how many blocks exist. + await cheatCodes.setIntervalMining(0); + await cheatCodes.setAutomine(false); + + // Warp latest forward by many epochs in a single block, creating a large gap. + const latestTs = (await rollup.client.getBlock()).timestamp; + const jumpSeconds = 20n * epochSeconds; + await cheatCodes.warp(Number(latestTs + jumpSeconds)); + + // After warp: latest is at latestTs + jumpSeconds, finalized is ~2 blocks back. + // With only 1 new block mined, finalized timestamp is still near preFinalizedTs. + const postLatestTs = (await rollup.client.getBlock()).timestamp; + const postFinalizedTs = (await rollup.client.getBlock({ blockTag: 'finalized' })).timestamp; + + // Find an epoch whose samplingTs is between finalized and latest. + // samplingTs(E) = l1GenesisTime + E * epochDuration * slotDuration - lagSeconds + // We need: postFinalizedTs < samplingTs <= postLatestTs + // So: postFinalizedTs < l1GenesisTime + E * epochSeconds - lagSeconds <= postLatestTs + // E > (postFinalizedTs - l1GenesisTime + lagSeconds) / epochSeconds + // E <= (postLatestTs - l1GenesisTime + lagSeconds) / epochSeconds + const minEpoch = (postFinalizedTs - l1GenesisTime + lagSeconds) / epochSeconds + 1n; + const maxEpoch = (postLatestTs - l1GenesisTime + lagSeconds) / epochSeconds; + + expect(maxEpoch).toBeGreaterThan(minEpoch); + + // Pick an epoch in the middle of the valid range. Use the first slot of that epoch + // so ts equals the epoch start exactly. + const targetEpoch = minEpoch + (maxEpoch - minEpoch) / 2n; + const targetSlot = SlotNumber(Number(targetEpoch) * epochDuration); + + // Verify our assumptions: sampling timestamp is between finalized and latest. + const samplingTs = l1GenesisTime + BigInt(targetSlot) * BigInt(slotDuration) - lagSeconds; + expect(samplingTs).toBeGreaterThan(postFinalizedTs); + expect(samplingTs).toBeLessThanOrEqual(postLatestTs); + + await expect(epochCache.getCommittee(targetSlot)).rejects.toThrow(EpochNotFinalizedError); + }, 60_000); + + it('rejects with EpochNotStable when epoch is beyond L1 latest', async () => { + const constants = epochCache.getEpochCacheConstants(); + const { lagInEpochsForRandao, epochDuration, slotDuration } = constants; + const l1GenesisTime = constants.l1GenesisTime; + const lagSeconds = BigInt(lagInEpochsForRandao) * BigInt(epochDuration) * BigInt(slotDuration); + const epochSeconds = BigInt(epochDuration) * BigInt(slotDuration); + + // Get the current latest timestamp. + const latestTs = (await rollup.client.getBlock()).timestamp; + + // Find the first epoch whose samplingTs exceeds latest. + // samplingTs(E) = l1GenesisTime + E * epochSeconds - lagSeconds > latestTs + // E > (latestTs - l1GenesisTime + lagSeconds) / epochSeconds + const firstUnstableEpoch = (latestTs - l1GenesisTime + lagSeconds) / epochSeconds + 1n; + + // Query the mid-slot of that epoch to account for being "halfway through". + const midSlot = SlotNumber(Number(firstUnstableEpoch) * epochDuration + Math.floor(epochDuration / 2)); + + // Verify: the sampling timestamp for this slot is beyond latest. + const samplingTs = l1GenesisTime + BigInt(midSlot) * BigInt(slotDuration) - lagSeconds; + expect(samplingTs).toBeGreaterThan(latestTs); + + await expect(epochCache.getCommittee(midSlot)).rejects.toThrow(EpochNotStableError); + }); + }); +}); diff --git a/yarn-project/epoch-cache/src/epoch_cache.test.ts b/yarn-project/epoch-cache/src/epoch_cache.test.ts index 7cc79f3aa96f..d6b4a38ffdb0 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.test.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.test.ts @@ -4,13 +4,19 @@ import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; -import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; import type { GetBlockReturnType } from 'viem'; -import { EpochCache, type EpochCommitteeInfo, PROPOSER_PIPELINING_SLOT_OFFSET } from './epoch_cache.js'; +import { + EpochCache, + type EpochCacheConstants, + type EpochCommitteeInfo, + EpochNotFinalizedError, + EpochNotStableError, + PROPOSER_PIPELINING_SLOT_OFFSET, +} from './epoch_cache.js'; class TestEpochCache extends EpochCache { public seedCache(epoch: EpochNumber, committeeInfo: EpochCommitteeInfo): void { @@ -56,12 +62,13 @@ describe('EpochCache', () => { l1GenesisTime = BigInt(Math.floor(Date.now() / 1000)); - // Mock the client.getBlock method for timestamp retrieval - // Return a timestamp far enough in the future to accommodate test queries - // lagInEpochsForValidatorSet * epochDuration * slotDuration = 2 * 32 * 12 = 768 seconds + // Mock the client.getBlock method for finalized timestamp retrieval. + // The epoch cache queries the finalized block to ensure RANDAO and validator set data + // are settled before caching a committee. + // lagInEpochsForRandao * epochDuration * slotDuration = 1 * 32 * 12 = 384 seconds // Add extra buffer for random slots in tests (e.g., 1000 slots = 12000 seconds) client = mock(); - const futureTimestamp = l1GenesisTime + BigInt(768 + 12000); + const futureTimestamp = l1GenesisTime + BigInt(384 + 12000); client.getBlock.mockResolvedValue({ timestamp: futureTimestamp } as GetBlockReturnType); (rollupContract as any).client = client; @@ -69,7 +76,7 @@ describe('EpochCache', () => { jest.useFakeTimers(); // Initialize with test constants - const testConstants: L1RollupConstants & { lagInEpochsForValidatorSet: number; lagInEpochsForRandao: number } = { + const testConstants: EpochCacheConstants = { l1StartBlock: 0n, l1GenesisTime, slotDuration: SLOT_DURATION, @@ -79,7 +86,7 @@ describe('EpochCache', () => { targetCommitteeSize: 48, rollupManaLimit: Number.MAX_SAFE_INTEGER, lagInEpochsForValidatorSet: 2, - lagInEpochsForRandao: 2, + lagInEpochsForRandao: 1, }; epochCache = new TestEpochCache(rollupContract, testConstants); @@ -270,22 +277,65 @@ describe('EpochCache', () => { expect(rollupContract.getAttesters).toHaveBeenCalledTimes(2); }); - it('should throw error when querying committee for future epoch beyond lag', async () => { + it('should throw error when querying committee for epoch beyond finalized L1 block', async () => { const { l1GenesisTime, epochDuration } = epochCache.getL1Constants(); - // Mock the client to return a current L1 timestamp that's close to genesis - const currentL1Timestamp = l1GenesisTime + BigInt(100); // Just 100 seconds after genesis - client.getBlock.mockResolvedValue({ timestamp: currentL1Timestamp } as GetBlockReturnType); + // Mock the finalized L1 block to be close to genesis + const finalizedL1Timestamp = l1GenesisTime + BigInt(100); + client.getBlock.mockResolvedValue({ timestamp: finalizedL1Timestamp } as GetBlockReturnType); // Calculate a slot far in the future (epoch 100) that's definitely not cached - // and is beyond the allowed lag (lagInEpochsForValidatorSet * epochDuration * slotDuration = 2 * 32 * 12 = 768 seconds) + // and is beyond the finalized point. + // lagInEpochsForRandao * epochDuration * slotDuration = 1 * 32 * 12 = 384 seconds const futureEpoch = BigInt(100); const futureSlot = futureEpoch * BigInt(epochDuration); - // Attempt to get committee for this future slot should throw - await expect(epochCache.getCommittee(SlotNumber.fromBigInt(futureSlot))).rejects.toThrow( - /Cannot query committee for future epoch.*with timestamp.*\(current L1 time is/, - ); + // Attempt to get committee for this future slot should throw an EpochNotFinalizedError + await expect(epochCache.getCommittee(SlotNumber.fromBigInt(futureSlot))).rejects.toThrow(EpochNotFinalizedError); + }); + + it('should use lagInEpochsForRandao (not lagInEpochsForValidatorSet) as the binding constraint', async () => { + const { l1GenesisTime, slotDuration, epochDuration } = epochCache.getL1Constants(); + + // Set the finalized L1 timestamp such that: + // - lagInEpochsForRandao (1) allows the query (epoch sampling ts is in the past) + // - lagInEpochsForValidatorSet (2) would also allow it (even further in the past) + // But if we made randao lag = 2 and validator lag = 2, the threshold would be different. + // + // We test an epoch where the randao sampling timestamp is just barely beyond finalized: + // epoch 5, ts = genesis + 5 * 32 * 12 = genesis + 1920 + // randao sampling ts = ts - 1 * 32 * 12 = genesis + 1920 - 384 = genesis + 1536 + // validator set sampling ts = ts - 2 * 32 * 12 = genesis + 1920 - 768 = genesis + 1152 + // + // If finalized L1 time = genesis + 1500: + // randao check: 1536 > 1500 → FAIL (guard should fire) + // validator set check: 1152 <= 1500 → would pass (old buggy behavior) + const epoch5Slot = BigInt(5) * BigInt(epochDuration); + const epoch5Ts = l1GenesisTime + epoch5Slot * BigInt(slotDuration); + const randaoSamplingTs = epoch5Ts - BigInt(1 * epochDuration * slotDuration); + + // Set finalized just below the randao sampling timestamp + const finalizedTs = randaoSamplingTs - 1n; + client.getBlock.mockResolvedValue({ timestamp: finalizedTs } as GetBlockReturnType); + + // The guard should fire because the randao data is not finalized + await expect(epochCache.getCommittee(SlotNumber.fromBigInt(epoch5Slot))).rejects.toThrow(EpochNotFinalizedError); + }); + + it('should wrap L1 EpochNotStable revert into EpochNotStableError', async () => { + // Mock getSampleSeedAt to reject with an error containing the L1 revert message + const l1Revert = new Error('ContractFunctionExecutionError: ValidatorSelection__EpochNotStable(999, 1000)'); + rollupContract.getSampleSeedAt.mockRejectedValue(l1Revert); + + const { epochDuration } = epochCache.getL1Constants(); + const futureSlot = SlotNumber(100 * epochDuration); + + const rejection = epochCache.getCommittee(futureSlot); + await expect(rejection).rejects.toThrow(EpochNotStableError); + await expect(rejection).rejects.toMatchObject({ + name: 'EpochNotStableError', + l1Error: l1Revert, + }); }); describe('proposer pipelining', () => { diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index 066c73ce121a..702dde3ecb0f 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -2,6 +2,7 @@ import { createEthereumChain } from '@aztec/ethereum/chain'; import { makeL1HttpTransport } from '@aztec/ethereum/client'; import { NoCommitteeError, RollupContract } from '@aztec/ethereum/contracts'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import type { Buffer32 } from '@aztec/foundation/buffer'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; @@ -19,50 +20,25 @@ import { import { createPublicClient, encodeAbiParameters, keccak256 } from 'viem'; import { type EpochCacheConfig, getEpochCacheConfigEnvVars } from './config.js'; - -/** When proposer pipelining is enabled, the proposer builds one slot ahead. */ -export const PROPOSER_PIPELINING_SLOT_OFFSET = 1; - -/** Flat return type for compound epoch/slot getters. */ -export type EpochAndSlot = { - slot: SlotNumber; - epoch: EpochNumber; - ts: bigint; -}; - -export type EpochCommitteeInfo = { - committee: EthAddress[] | undefined; - seed: bigint; - epoch: EpochNumber; - /** True if the epoch is within an open escape hatch window. */ - isEscapeHatchOpen: boolean; -}; - -export type SlotTag = 'now' | 'next' | SlotNumber; - -export interface EpochCacheInterface { - getCommittee(slot: SlotTag | undefined): Promise; - getSlotNow(): SlotNumber; - getTargetSlot(): SlotNumber; - getEpochNow(): EpochNumber; - getTargetEpoch(): EpochNumber; - getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint }; - getEpochAndSlotInNextL1Slot(): EpochAndSlot & { nowSeconds: bigint }; - /** Returns epoch/slot info for the next L1 slot with pipeline offset applied. */ - getTargetEpochAndSlotInNextL1Slot(): EpochAndSlot & { nowSeconds: bigint }; - isProposerPipeliningEnabled(): boolean; - isEscapeHatchOpen(epoch: EpochNumber): Promise; - isEscapeHatchOpenAtSlot(slot: SlotTag): Promise; - getProposerIndexEncoding(epoch: EpochNumber, slot: SlotNumber, seed: bigint): `0x${string}`; - computeProposerIndex(slot: SlotNumber, epoch: EpochNumber, seed: bigint, size: bigint): bigint; - getCurrentAndNextSlot(): { currentSlot: SlotNumber; nextSlot: SlotNumber }; - getTargetAndNextSlot(): { targetSlot: SlotNumber; nextSlot: SlotNumber }; - getProposerAttesterAddressInSlot(slot: SlotNumber): Promise; - getRegisteredValidators(): Promise; - isInCommittee(slot: SlotTag, validator: EthAddress): Promise; - filterInCommittee(slot: SlotTag, validators: EthAddress[]): Promise; - getL1Constants(): L1RollupConstants; -} +import { EpochNotFinalizedError, EpochNotStableError } from './errors.js'; +import { + type EpochAndSlot, + type EpochCacheConstants, + type EpochCacheInterface, + type EpochCommitteeInfo, + PROPOSER_PIPELINING_SLOT_OFFSET, + type SlotTag, +} from './types.js'; + +export { EpochNotFinalizedError, EpochNotStableError } from './errors.js'; +export { + type EpochAndSlot, + type EpochCacheConstants, + type EpochCacheInterface, + type EpochCommitteeInfo, + PROPOSER_PIPELINING_SLOT_OFFSET, + type SlotTag, +} from './types.js'; /** * Epoch cache @@ -84,10 +60,7 @@ export class EpochCache implements EpochCacheInterface { constructor( private rollup: RollupContract, - private readonly l1constants: L1RollupConstants & { - lagInEpochsForValidatorSet: number; - lagInEpochsForRandao: number; - }, + private readonly l1constants: EpochCacheConstants, private readonly dateProvider: DateProvider = new DateProvider(), protected readonly config = { cacheSize: 12, validatorRefreshIntervalSeconds: 60, enableProposerPipelining: false }, ) { @@ -165,6 +138,11 @@ export class EpochCache implements EpochCacheInterface { return this.l1constants; } + /** Returns L1 constants including the lag parameters used for committee computation. */ + public getEpochCacheConstants(): EpochCacheConstants { + return this.l1constants; + } + public isProposerPipeliningEnabled(): boolean { return this.enableProposerPipelining; } @@ -300,18 +278,34 @@ export class EpochCache implements EpochCacheInterface { private async computeCommittee(when: { epoch: EpochNumber; ts: bigint }): Promise { const { ts, epoch } = when; - const [committee, seedBuffer, l1Timestamp, isEscapeHatchOpen] = await Promise.all([ - this.rollup.getCommitteeAt(ts), - this.rollup.getSampleSeedAt(ts), - this.rollup.client.getBlock({ includeTransactions: false }).then(b => b.timestamp), - this.rollup.isEscapeHatchOpen(epoch), - ]); - const { lagInEpochsForValidatorSet, epochDuration, slotDuration } = this.l1constants; - const sub = BigInt(lagInEpochsForValidatorSet) * BigInt(epochDuration) * BigInt(slotDuration); - if (ts - sub > l1Timestamp) { - throw new Error( - `Cannot query committee for future epoch ${epoch} with timestamp ${ts} (current L1 time is ${l1Timestamp}). Check your Ethereum node is synced.`, - ); + let committee: EthAddress[] | undefined; + let seedBuffer: Buffer32; + let l1FinalizedTimestamp: bigint; + let isEscapeHatchOpen: boolean; + try { + [committee, seedBuffer, l1FinalizedTimestamp, isEscapeHatchOpen] = await Promise.all([ + this.rollup.getCommitteeAt(ts), + this.rollup.getSampleSeedAt(ts), + this.rollup.client.getBlock({ blockTag: 'finalized', includeTransactions: false }).then(b => b.timestamp), + this.rollup.isEscapeHatchOpen(epoch), + ]); + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('ValidatorSelection__EpochNotStable')) { + throw new EpochNotStableError(epoch, err); + } + throw err; + } + // Use the finalized block tag to ensure the RANDAO seed and validator set snapshot + // fall within finalized L1 history, protecting against L1 reorgs that could change + // the committee after we cache it. Uses lagInEpochsForRandao as the binding constraint + // (it's always <= lagInEpochsForValidatorSet). The sampling timestamp is computed from + // the epoch start (not the individual slot timestamp) to match the L1 contract's logic. + const { lagInEpochsForRandao, epochDuration, slotDuration } = this.l1constants; + const epochStartTs = getTimestampForSlot(getSlotRangeForEpoch(epoch, this.l1constants)[0], this.l1constants); + const lagSeconds = BigInt(lagInEpochsForRandao) * BigInt(epochDuration) * BigInt(slotDuration); + const samplingTs = epochStartTs - lagSeconds; + if (samplingTs > l1FinalizedTimestamp) { + throw new EpochNotFinalizedError(epoch, samplingTs, l1FinalizedTimestamp); } return { committee, seed: seedBuffer.toBigInt(), epoch, isEscapeHatchOpen }; } @@ -331,7 +325,7 @@ export class EpochCache implements EpochCacheInterface { } public computeProposerIndex(slot: SlotNumber, epoch: EpochNumber, seed: bigint, size: bigint): bigint { - // if committe size is 0, then mod 1 is 0 + // if committee size is 0, then mod 1 is 0 if (size === 0n) { return 0n; } @@ -349,7 +343,7 @@ export class EpochCache implements EpochCacheInterface { }; } - /** Returns the target and next L2 slot in the next L1 slot. */ + /** Returns the target and next L2 slot in the next L1 slot */ public getTargetAndNextSlot(): { targetSlot: SlotNumber; nextSlot: SlotNumber } { const nowSeconds = BigInt(this.dateProvider.nowInSeconds()); const offset = this.isProposerPipeliningEnabled() ? PROPOSER_PIPELINING_SLOT_OFFSET : 0; diff --git a/yarn-project/epoch-cache/src/errors.ts b/yarn-project/epoch-cache/src/errors.ts new file mode 100644 index 000000000000..ddd91baa9a76 --- /dev/null +++ b/yarn-project/epoch-cache/src/errors.ts @@ -0,0 +1,28 @@ +import type { EpochNumber } from '@aztec/foundation/branded-types'; + +/** Thrown when the epoch's sampling data has not yet been finalized on L1. */ +export class EpochNotFinalizedError extends Error { + constructor( + public readonly epoch: EpochNumber, + public readonly samplingTimestamp: bigint, + public readonly l1FinalizedTimestamp: bigint, + ) { + super( + `Cannot query committee for epoch ${epoch}: ` + + `sampling timestamp ${samplingTimestamp} is beyond last finalized L1 block at ${l1FinalizedTimestamp}. ` + + `The epoch's RANDAO seed and validator set may not be finalized yet.`, + ); + this.name = 'EpochNotFinalizedError'; + } +} + +/** Thrown when the L1 contract rejects the query because the epoch is not yet stable (sampling timestamp > latest L1 block). */ +export class EpochNotStableError extends Error { + constructor( + public readonly epoch: EpochNumber, + public readonly l1Error: Error, + ) { + super(`Cannot query committee for epoch ${epoch}: epoch is not yet stable on L1.`, { cause: l1Error }); + this.name = 'EpochNotStableError'; + } +} diff --git a/yarn-project/epoch-cache/src/types.ts b/yarn-project/epoch-cache/src/types.ts new file mode 100644 index 000000000000..eb1f9a985a14 --- /dev/null +++ b/yarn-project/epoch-cache/src/types.ts @@ -0,0 +1,53 @@ +import type { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import type { EthAddress } from '@aztec/foundation/eth-address'; +import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; + +/** When proposer pipelining is enabled, the proposer builds one slot ahead. */ +export const PROPOSER_PIPELINING_SLOT_OFFSET = 1; + +/** Flat return type for compound epoch/slot getters. */ +export type EpochAndSlot = { + slot: SlotNumber; + epoch: EpochNumber; + ts: bigint; +}; + +export type EpochCommitteeInfo = { + committee: EthAddress[] | undefined; + seed: bigint; + epoch: EpochNumber; + /** True if the epoch is within an open escape hatch window. */ + isEscapeHatchOpen: boolean; +}; + +/** L1 rollup constants extended with the lag parameters used for committee computation. */ +export type EpochCacheConstants = L1RollupConstants & { + lagInEpochsForValidatorSet: number; + lagInEpochsForRandao: number; +}; + +export type SlotTag = 'now' | 'next' | SlotNumber; + +export interface EpochCacheInterface { + getCommittee(slot: SlotTag | undefined): Promise; + getSlotNow(): SlotNumber; + getTargetSlot(): SlotNumber; + getEpochNow(): EpochNumber; + getTargetEpoch(): EpochNumber; + getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint }; + getEpochAndSlotInNextL1Slot(): EpochAndSlot & { nowSeconds: bigint }; + /** Returns epoch/slot info for the next L1 slot with pipeline offset applied. */ + getTargetEpochAndSlotInNextL1Slot(): EpochAndSlot & { nowSeconds: bigint }; + isProposerPipeliningEnabled(): boolean; + isEscapeHatchOpen(epoch: EpochNumber): Promise; + isEscapeHatchOpenAtSlot(slot: SlotTag): Promise; + getProposerIndexEncoding(epoch: EpochNumber, slot: SlotNumber, seed: bigint): `0x${string}`; + computeProposerIndex(slot: SlotNumber, epoch: EpochNumber, seed: bigint, size: bigint): bigint; + getCurrentAndNextSlot(): { currentSlot: SlotNumber; nextSlot: SlotNumber }; + getTargetAndNextSlot(): { targetSlot: SlotNumber; nextSlot: SlotNumber }; + getProposerAttesterAddressInSlot(slot: SlotNumber): Promise; + getRegisteredValidators(): Promise; + isInCommittee(slot: SlotTag, validator: EthAddress): Promise; + filterInCommittee(slot: SlotTag, validators: EthAddress[]): Promise; + getL1Constants(): L1RollupConstants; +} From 35967060f18540fd55a54050bebab3e94cc47706 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 14:24:02 -0300 Subject: [PATCH 02/12] fix(epoch-cache): use slotsInAnEpoch=8 in integration tests Increases the Anvil slotsInAnEpoch from 1 to 8 so finalized = latest - 16 blocks, making tests less likely to pass due to off-by-one near the finality boundary. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../epoch-cache/src/epoch_cache.integration.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts index 80b6d5f6907a..fd7b8945d092 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts @@ -48,10 +48,11 @@ describe('EpochCache Integration', () => { beforeAll(async () => { dateProvider = new TestDateProvider(); - // Start Anvil with slotsInAnEpoch=1 so finalized block advances quickly (finalized = latest - 2). + // Start Anvil with slotsInAnEpoch=8 so finalized = latest - 16 blocks. + // A larger value (vs 1) avoids tests passing due to off-by-one near the finality boundary. ({ anvil, rpcUrl } = await startAnvil({ l1BlockTime: 1, - slotsInAnEpoch: 1, + slotsInAnEpoch: 8, dateProvider, })); @@ -96,8 +97,8 @@ describe('EpochCache Integration', () => { const targetEpoch = EpochNumber(lagEpochs + 2); await rollupCheatCodes.advanceToEpoch(targetEpoch); - // Mine a few extra blocks so the finalized tag catches up (finalized = latest - 2 with slotsInAnEpoch=1). - await cheatCodes.mine(5); + // Mine enough blocks so the finalized tag catches up (finalized = latest - 16 with slotsInAnEpoch=8). + await cheatCodes.mine(20); // Setup epoch so the committee commitment is stored on-chain. await rollupCheatCodes.setupEpoch(); From d51b7e721b5d0446cc34cd6c0a75418161db5d28 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 14:28:23 -0300 Subject: [PATCH 03/12] fix(epoch-cache): cross-check epoch cache results against rollup contract Co-Authored-By: Claude Opus 4.6 (1M context) --- .../epoch-cache/src/epoch_cache.integration.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts index fd7b8945d092..ab605fa31403 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts @@ -115,6 +115,16 @@ describe('EpochCache Integration', () => { for (const v of validatorAddresses) { expect(committeeStrings.has(v.toString())).toBe(true); } + + // Cross-check against the rollup contract directly. + const slotTs = + BigInt(constants.l1GenesisTime) + + BigInt(epoch) * BigInt(constants.epochDuration) * BigInt(constants.slotDuration); + const [l1Committee, l1Seed] = await Promise.all([rollup.getCommitteeAt(slotTs), rollup.getSampleSeedAt(slotTs)]); + + expect(l1Committee).toBeDefined(); + expect(l1Committee!.map(v => v.toString()).sort()).toEqual(committee!.map(v => v.toString()).sort()); + expect(l1Seed.toBigInt()).toBe(seed); }, 60_000); it('caches committee and returns same result within epoch', async () => { From 769be08230d20c9d919f2f171ecc24225e221072 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 14:29:17 -0300 Subject: [PATCH 04/12] fix(epoch-cache): remove unnecessary automine toggle in integration tests Only interval mining needs to be stopped/restored to control the gap between latest and finalized blocks. Automine is not used since Anvil is started with l1BlockTime (interval mining). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../epoch-cache/src/epoch_cache.integration.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts index ab605fa31403..d947d150f171 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts @@ -182,8 +182,7 @@ describe('EpochCache Integration', () => { */ afterEach(async () => { - // Ensure mining is restored even if a test assertion fails mid-way. - await cheatCodes.setAutomine(true); + // Restore interval mining even if a test assertion fails mid-way. await cheatCodes.setIntervalMining(1); }); @@ -195,8 +194,9 @@ describe('EpochCache Integration', () => { const epochSeconds = BigInt(epochDuration) * BigInt(slotDuration); // Stop interval mining so we control exactly how many blocks exist. + // warp() mines a single block at the target timestamp, creating a large gap + // between latest and finalized since no intermediate blocks are produced. await cheatCodes.setIntervalMining(0); - await cheatCodes.setAutomine(false); // Warp latest forward by many epochs in a single block, creating a large gap. const latestTs = (await rollup.client.getBlock()).timestamp; From 97da23007c1ea24d4966cd5f047aaf0ddbd6f55a Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 18:31:39 -0300 Subject: [PATCH 05/12] feat(ethereum): add mineUntilTimestamp to EthCheatCodes and use in rollup cheat codes Adds mineUntilTimestamp which mines real L1 blocks (via hardhat_mine with a timestamp interval) so finalized block timestamps advance alongside latest. This prevents epoch-cache's finalized guard from rejecting committees after time advances in tests. The method derives the block interval from the last two block timestamps (to handle anvil_setBlockTimestampInterval overrides), stops interval mining before the burst, and leaves it stopped so the caller controls when to resume. Updates rollup cheat codes (advanceToEpoch, advanceToNextEpoch, advanceToNextSlot, advanceSlots) to use mineUntilTimestamp with automatic interval restore. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/epoch_cache.integration.test.ts | 20 +++++-- .../ethereum/src/test/eth_cheat_codes.test.ts | 40 ++++++++++++++ .../ethereum/src/test/eth_cheat_codes.ts | 52 +++++++++++++++++++ .../ethereum/src/test/rollup_cheat_codes.ts | 33 ++++++++---- 4 files changed, 130 insertions(+), 15 deletions(-) diff --git a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts index d947d150f171..e13f706871fb 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts @@ -64,8 +64,13 @@ describe('EpochCache Integration', () => { bn254SecretKey: new SecretValue(Fr.random().toBigInt()), })); + // Use short L2 epochs (6 slots * 24s = 144s) so mineUntilTimestamp doesn't + // need to mine thousands of blocks. With ethereumSlotDuration=12 and + // aztecSlotDuration=24, advancing one L2 epoch needs ceil(144/12) = 12 L1 blocks. const deployed = await deployAztecL1Contracts(rpcUrl, deployerPrivateKey, foundry.id, { ...DefaultL1ContractsConfig, + aztecSlotDuration: 24, + aztecEpochDuration: 6, vkTreeRoot: Fr.random(), protocolContractsHash: Fr.random(), genesisArchiveRoot: Fr.random(), @@ -74,7 +79,11 @@ describe('EpochCache Integration', () => { initialValidators, }); - rollupCheatCodes = new RollupCheatCodes(cheatCodes, deployed.l1ContractAddresses); + rollupCheatCodes = new RollupCheatCodes( + cheatCodes, + deployed.l1ContractAddresses, + DefaultL1ContractsConfig.ethereumSlotDuration, + ); const publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: foundry.id }); rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); @@ -95,14 +104,17 @@ describe('EpochCache Integration', () => { // Advance past the validator set lag so the epoch's data is finalized. const lagEpochs = Math.max(constants.lagInEpochsForValidatorSet, constants.lagInEpochsForRandao); const targetEpoch = EpochNumber(lagEpochs + 2); + // mineUntilTimestamp mines real blocks so finalized advances with them. + // It stops interval mining, so we must restore it before setupEpoch (which + // submits a transaction that needs to be mined). await rollupCheatCodes.advanceToEpoch(targetEpoch); - // Mine enough blocks so the finalized tag catches up (finalized = latest - 16 with slotsInAnEpoch=8). - await cheatCodes.mine(20); - // Setup epoch so the committee commitment is stored on-chain. await rollupCheatCodes.setupEpoch(); + // Stop interval mining to freeze the dateProvider while we run assertions. + await cheatCodes.setIntervalMining(0); + const { committee, seed, epoch } = await epochCache.getCommittee('now'); expect(committee).toBeDefined(); diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts index 9a489e18375a..e9aaa89d958c 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts @@ -212,6 +212,46 @@ describe('EthCheatCodes', () => { }); }); + describe('mineUntilTimestamp', () => { + it('mines blocks until the target timestamp is reached and restores interval mining', async () => { + const blockInterval = 2; + await cheatCodes.setIntervalMining(blockInterval); + + const currentTs = await cheatCodes.lastBlockTimestamp(); + const targetTs = currentTs + 20; + + await cheatCodes.mineUntilTimestamp(targetTs, { blockTimestampInterval: blockInterval }); + + const latestTs = await cheatCodes.lastBlockTimestamp(); + expect(latestTs).toBeGreaterThanOrEqual(targetTs); + + // The key difference from warp: finalized block timestamp also advances. + const finalizedBlock = await cheatCodes.publicClient.getBlock({ blockTag: 'finalized' }); + expect(Number(finalizedBlock.timestamp)).toBeGreaterThan(currentTs); + + // Interval mining should be restored. + const intervalAfter = await cheatCodes.getIntervalMining(); + expect(intervalAfter).toBe(blockInterval); + }); + + it('throws when blockTimestampInterval is not positive', async () => { + const currentTs = await cheatCodes.lastBlockTimestamp(); + await expect(cheatCodes.mineUntilTimestamp(currentTs + 10, { blockTimestampInterval: 0 })).rejects.toThrow( + 'blockTimestampInterval must be a positive number', + ); + }); + + it('does nothing if already past the target timestamp', async () => { + const currentTs = await cheatCodes.lastBlockTimestamp(); + const blockNumberBefore = await cheatCodes.blockNumber(); + + await cheatCodes.mineUntilTimestamp(currentTs - 5, { blockTimestampInterval: 1 }); + + const blockNumberAfter = await cheatCodes.blockNumber(); + expect(blockNumberAfter).toBe(blockNumberBefore); + }); + }); + describe('mineEmptyBlock', () => { it('mines an empty block while preserving pending transactions', async () => { // Deploy a token first (with automine enabled) diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index b1cbdd199d6f..f028e1bc71d8 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -279,6 +279,58 @@ export class EthCheatCodes { } } + /** + * Advances L1 time to the target timestamp by mining blocks at the given interval. + * Unlike `warp`, this mines real blocks so finalized block timestamps also advance. + * + * Stops interval mining for the burst, then restores it if it was previously enabled. + * + * @param timestamp - The target timestamp to advance to + * @param opts - Must include `blockTimestampInterval` (seconds between block timestamps). + */ + public async mineUntilTimestamp( + timestamp: number | bigint, + opts: { blockTimestampInterval: number; silent?: boolean }, + ): Promise { + const targetTimestamp = Number(timestamp); + const blockInterval = opts.blockTimestampInterval; + if (blockInterval <= 0) { + throw new Error('blockTimestampInterval must be a positive number'); + } + + const currentTimestamp = await this.lastBlockTimestamp(); + const blocksNeeded = Math.ceil((targetTimestamp - currentTimestamp) / blockInterval); + if (blocksNeeded <= 0) { + return; + } + + // Save and stop interval mining so Anvil doesn't auto-mine during the burst. + const previousInterval = await this.getIntervalMining(); + if (previousInterval !== null && previousInterval > 0) { + await this.setIntervalMining(0, { silent: true }); + } + + try { + // Mine all blocks at once with the correct interval between them. + // hardhat_mine accepts (count, interval) where interval is seconds between blocks. + await this.doRpcCall('hardhat_mine', [`0x${blocksNeeded.toString(16)}`, `0x${blockInterval.toString(16)}`]); + } finally { + // Restore interval mining if it was previously enabled. + if (previousInterval !== null && previousInterval > 0) { + await this.setIntervalMining(previousInterval, { silent: true }); + } + } + + // Query the actual last block timestamp (may overshoot the target due to rounding). + const actualTimestamp = await this.lastBlockTimestamp(); + if ('setTime' in this.dateProvider) { + this.dateProvider.setTime(actualTimestamp * 1000); + } + if (!opts.silent) { + this.logger.warn(`Mined ${blocksNeeded} L1 blocks until timestamp ${actualTimestamp}`); + } + } + /** * Load the value at a storage slot of a contract address on eth * @param contract - The contract address diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index 20a5cc20a0c1..0ab7ae635c3a 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -29,6 +29,7 @@ export class RollupCheatCodes { constructor( private ethCheatCodes: EthCheatCodes, addresses: Pick, + private readonly ethereumSlotDuration: number = 12, ) { this.client = createPublicClient({ chain: ethCheatCodes.chain, @@ -45,9 +46,10 @@ export class RollupCheatCodes { rpcUrls: string[], addresses: Pick, dateProvider: DateProvider, + ethereumSlotDuration: number = 12, ): RollupCheatCodes { const ethCheatCodes = new EthCheatCodes(rpcUrls, dateProvider); - return new RollupCheatCodes(ethCheatCodes, addresses); + return new RollupCheatCodes(ethCheatCodes, addresses, ethereumSlotDuration); } /** Returns the current slot */ @@ -136,47 +138,56 @@ export class RollupCheatCodes { const slotNumber = SlotNumber(Number(epoch) * Number(slotsInEpoch)); const timestamp = (await this.rollup.read.getTimestampForSlot([BigInt(slotNumber)])) + BigInt(opts.offset ?? 0); try { - await this.ethCheatCodes.warp(Number(timestamp), { ...opts, silent: true, resetBlockInterval: true }); - this.logger.warn(`Warped to epoch ${epoch}`, { offset: opts.offset, timestamp }); + await this.ethCheatCodes.mineUntilTimestamp(Number(timestamp), { + blockTimestampInterval: this.ethereumSlotDuration, + silent: true, + }); + this.logger.warn(`Advanced to epoch ${epoch}`, { offset: opts.offset, timestamp }); } catch (err) { - this.logger.warn(`Warp to epoch ${epoch} failed: ${err}`); + this.logger.warn(`Advance to epoch ${epoch} failed: ${err}`); } return timestamp; } - /** Warps time in L1 until the next epoch */ + /** Advances L1 time until the next epoch */ public async advanceToNextEpoch() { const slot = await this.getSlot(); const { epochDuration, slotDuration } = await this.getConfig(); const slotsUntilNextEpoch = epochDuration - (BigInt(slot) % epochDuration) + 1n; const timeToNextEpoch = slotsUntilNextEpoch * BigInt(slotDuration); const l1Timestamp = BigInt((await this.client.getBlock()).timestamp); - await this.ethCheatCodes.warp(Number(l1Timestamp + timeToNextEpoch), { + await this.ethCheatCodes.mineUntilTimestamp(Number(l1Timestamp + timeToNextEpoch), { + blockTimestampInterval: this.ethereumSlotDuration, silent: true, - resetBlockInterval: true, }); this.logger.warn(`Advanced to next epoch`); } - /** Warps time in L1 until the beginning of the next slot. */ + /** Advances L1 time until the beginning of the next slot. */ public async advanceToNextSlot() { const currentSlot = await this.getSlot(); const nextSlot = SlotNumber(currentSlot + 1); const timestamp = await this.rollup.read.getTimestampForSlot([BigInt(nextSlot)]); - await this.ethCheatCodes.warp(Number(timestamp), { silent: true, resetBlockInterval: true }); + await this.ethCheatCodes.mineUntilTimestamp(Number(timestamp), { + blockTimestampInterval: this.ethereumSlotDuration, + silent: true, + }); this.logger.warn(`Advanced to slot ${nextSlot}`); return [timestamp, nextSlot]; } /** - * Warps time in L1 equivalent to however many slots. + * Advances L1 time by the given number of slots. * @param howMany - The number of slots to advance. */ public async advanceSlots(howMany: number) { const l1Timestamp = (await this.client.getBlock()).timestamp; const slotDuration = Number(await this.rollup.read.getSlotDuration()); const timeToWarp = BigInt(howMany) * BigInt(slotDuration); - await this.ethCheatCodes.warp(l1Timestamp + timeToWarp, { silent: true, resetBlockInterval: true }); + await this.ethCheatCodes.mineUntilTimestamp(Number(l1Timestamp + timeToWarp), { + blockTimestampInterval: this.ethereumSlotDuration, + silent: true, + }); const [slot, epoch] = await Promise.all([this.getSlot(), this.getEpoch()]); this.logger.warn(`Advanced ${howMany} slots up to slot ${slot} in epoch ${epoch}`); } From e63755c7e5d4bc51c30ed720dc3137013659655c Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 19:15:06 -0300 Subject: [PATCH 06/12] fix(aztec): use mineUntilTimestamp in warpL2TimeAtLeastTo to advance finalized block warpL2TimeAtLeastTo used warp (single block jump), causing the finalized L1 block to lag behind after large time jumps. This triggered EpochNotFinalizedError in the epoch cache, blocking the sequencer from building blocks after the warp. Switches to mineUntilTimestamp which mines real blocks at the ethereum slot interval so finalized advances alongside latest. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/aztec/src/testing/cheat_codes.ts | 17 +++++++++++++---- yarn-project/end-to-end/src/fixtures/setup.ts | 7 ++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/yarn-project/aztec/src/testing/cheat_codes.ts b/yarn-project/aztec/src/testing/cheat_codes.ts index 14413fd4ddd3..f3491bb019dc 100644 --- a/yarn-project/aztec/src/testing/cheat_codes.ts +++ b/yarn-project/aztec/src/testing/cheat_codes.ts @@ -17,15 +17,23 @@ export class CheatCodes { public eth: EthCheatCodes, /** Cheat codes for the Aztec Rollup contract on L1. */ public rollup: RollupCheatCodes, + /** Ethereum slot duration in seconds, used for mineUntilTimestamp. */ + private readonly ethereumSlotDuration: number = 12, ) {} - static async create(rpcUrls: string[], node: AztecNode, dateProvider: DateProvider): Promise { + static async create( + rpcUrls: string[], + node: AztecNode, + dateProvider: DateProvider, + ethereumSlotDuration: number = 12, + ): Promise { const ethCheatCodes = new EthCheatCodes(rpcUrls, dateProvider); const rollupCheatCodes = new RollupCheatCodes( ethCheatCodes, await node.getNodeInfo().then(n => n.l1ContractAddresses), + ethereumSlotDuration, ); - return new CheatCodes(ethCheatCodes, rollupCheatCodes); + return new CheatCodes(ethCheatCodes, rollupCheatCodes, ethereumSlotDuration); } /** @@ -40,8 +48,9 @@ export class CheatCodes { async warpL2TimeAtLeastTo(sequencerClient: SequencerClient, node: AztecNode, targetTimestamp: bigint | number) { const currentL2BlockNumber: BlockNumber = await node.getBlockNumber(); - // We warp the L1 timestamp - await this.eth.warp(targetTimestamp, { resetBlockInterval: true }); + // Mine real L1 blocks to the target timestamp so finalized also advances, + // preventing EpochNotFinalizedError when the sequencer queries the committee. + await this.eth.mineUntilTimestamp(targetTimestamp, { blockTimestampInterval: this.ethereumSlotDuration }); // Wait until an L2 block is mined const sequencer = sequencerClient.getSequencer(); diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 805a6a9bc9de..644827f60d36 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -542,7 +542,12 @@ export async function setup( wallet.setMinFeePadding(opts.walletMinFeePadding); } - const cheatCodes = await CheatCodes.create(config.l1RpcUrls, aztecNodeService, dateProvider); + const cheatCodes = await CheatCodes.create( + config.l1RpcUrls, + aztecNodeService, + dateProvider, + config.ethereumSlotDuration, + ); if ( (opts.aztecTargetCommitteeSize && opts.aztecTargetCommitteeSize > 0) || From b74e6c9e6255265fddf9aa6517781d811d77f603 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 19:32:08 -0300 Subject: [PATCH 07/12] fix(aztec): cap block count in warpL2TimeAtLeastTo to avoid Anvil timeouts For large time jumps (e.g., 1 day in crossTimestampOfChange), mining at the ethereum slot interval (12s) would require thousands of blocks, causing Anvil to time out. Caps at ~100 blocks and spreads the interval to cover the full jump. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/aztec/src/testing/cheat_codes.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/yarn-project/aztec/src/testing/cheat_codes.ts b/yarn-project/aztec/src/testing/cheat_codes.ts index f3491bb019dc..3ae90cb23f6e 100644 --- a/yarn-project/aztec/src/testing/cheat_codes.ts +++ b/yarn-project/aztec/src/testing/cheat_codes.ts @@ -50,7 +50,14 @@ export class CheatCodes { // Mine real L1 blocks to the target timestamp so finalized also advances, // preventing EpochNotFinalizedError when the sequencer queries the committee. - await this.eth.mineUntilTimestamp(targetTimestamp, { blockTimestampInterval: this.ethereumSlotDuration }); + // For large time jumps (e.g., 1 day), mining at the ethereum slot interval (12s) + // would require thousands of blocks. Instead, cap at ~100 blocks and spread + // the interval to cover the full jump. + const currentTimestamp = await this.eth.lastBlockTimestamp(); + const jump = Number(targetTimestamp) - currentTimestamp; + const maxBlocks = 100; + const interval = Math.max(this.ethereumSlotDuration, Math.ceil(jump / maxBlocks)); + await this.eth.mineUntilTimestamp(targetTimestamp, { blockTimestampInterval: interval }); // Wait until an L2 block is mined const sequencer = sequencerClient.getSequencer(); From 73656ea9904e2eb0a53426b6dc7930058bce2e24 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 31 Mar 2026 09:43:44 -0300 Subject: [PATCH 08/12] fix(aztec): cap block count in warpL2TimeAtLeastTo to avoid Anvil timeouts For large time jumps (e.g., 1 day in crossTimestampOfChange), mining at the ethereum slot interval (12s) would require thousands of blocks, causing Anvil to time out. Caps at ~1000 blocks and spreads the interval to cover the full jump. Also fixes mineUntilTimestamp to use evm_setNextBlockTimestamp + evm_mine per block instead of hardhat_mine, because Anvil's hardhat_mine ignores the interval parameter when anvil_setBlockTimestampInterval has been set. Adds a unit test validating this workaround. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/aztec/src/testing/cheat_codes.ts | 6 ++--- .../ethereum/src/test/eth_cheat_codes.test.ts | 22 +++++++++++++++++++ .../ethereum/src/test/eth_cheat_codes.ts | 11 +++++++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/yarn-project/aztec/src/testing/cheat_codes.ts b/yarn-project/aztec/src/testing/cheat_codes.ts index 3ae90cb23f6e..7477c635bd4b 100644 --- a/yarn-project/aztec/src/testing/cheat_codes.ts +++ b/yarn-project/aztec/src/testing/cheat_codes.ts @@ -51,11 +51,11 @@ export class CheatCodes { // Mine real L1 blocks to the target timestamp so finalized also advances, // preventing EpochNotFinalizedError when the sequencer queries the committee. // For large time jumps (e.g., 1 day), mining at the ethereum slot interval (12s) - // would require thousands of blocks. Instead, cap at ~100 blocks and spread - // the interval to cover the full jump. + // would require thousands of blocks and Anvil may time out. Cap the block count + // and increase the interval proportionally if the jump is large. const currentTimestamp = await this.eth.lastBlockTimestamp(); const jump = Number(targetTimestamp) - currentTimestamp; - const maxBlocks = 100; + const maxBlocks = 1000; const interval = Math.max(this.ethereumSlotDuration, Math.ceil(jump / maxBlocks)); await this.eth.mineUntilTimestamp(targetTimestamp, { blockTimestampInterval: interval }); diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts index e9aaa89d958c..cd807aa464cf 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts @@ -250,6 +250,28 @@ describe('EthCheatCodes', () => { const blockNumberAfter = await cheatCodes.blockNumber(); expect(blockNumberAfter).toBe(blockNumberBefore); }); + + it('uses the requested interval even when anvil_setBlockTimestampInterval differs', async () => { + // Anvil quirk: hardhat_mine ignores its interval parameter when + // anvil_setBlockTimestampInterval has been set. mineUntilTimestamp works + // around this by using evm_setNextBlockTimestamp + evm_mine per block. + await cheatCodes.setIntervalMining(1); + await cheatCodes.rpcCall('anvil_setBlockTimestampInterval', [12]); + await cheatCodes.mine(2); // Establish the 12s interval in the chain + + const currentTs = await cheatCodes.lastBlockTimestamp(); + const requestedInterval = 100; // Much larger than the 12s block timestamp interval + const targetTs = currentTs + 500; + + await cheatCodes.mineUntilTimestamp(targetTs, { blockTimestampInterval: requestedInterval }); + + const latestTs = await cheatCodes.lastBlockTimestamp(); + // Should have advanced by ~500s (5 blocks * 100s), not by 5 * 12s = 60s + expect(latestTs).toBeGreaterThanOrEqual(targetTs); + + // Restore for other tests + await cheatCodes.rpcCall('anvil_setBlockTimestampInterval', [1]); + }); }); describe('mineEmptyBlock', () => { diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index f028e1bc71d8..714f2a6d35c1 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -311,9 +311,14 @@ export class EthCheatCodes { } try { - // Mine all blocks at once with the correct interval between them. - // hardhat_mine accepts (count, interval) where interval is seconds between blocks. - await this.doRpcCall('hardhat_mine', [`0x${blocksNeeded.toString(16)}`, `0x${blockInterval.toString(16)}`]); + // Mine blocks one by one with explicit timestamps, since Anvil's hardhat_mine ignores + // the interval parameter when anvil_setBlockTimestampInterval has been set (which the + // deploy script does). Using evm_setNextBlockTimestamp + evm_mine gives us full control. + for (let i = 1; i <= blocksNeeded; i++) { + const blockTs = currentTimestamp + i * blockInterval; + await this.doRpcCall('evm_setNextBlockTimestamp', [blockTs]); + await this.doRpcCall('evm_mine', []); + } } finally { // Restore interval mining if it was previously enabled. if (previousInterval !== null && previousInterval > 0) { From 2bc3335283811ac9c980e6d8483daa34eaa94860 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 31 Mar 2026 10:19:08 -0300 Subject: [PATCH 09/12] fix(ethereum): mine extra blocks in mineUntilTimestamp for finalized to catch up Anvil computes finalized = latest - slotsInAnEpoch * 2 blocks. When querying the committee for the next epoch right after advancing, the sampling timestamp can be beyond the finalized block. Mine 3 extra blocks past the target so finalized also advances past it. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/ethereum/src/test/eth_cheat_codes.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index 714f2a6d35c1..d6b11ddadcb3 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -310,11 +310,16 @@ export class EthCheatCodes { await this.setIntervalMining(0, { silent: true }); } + // Anvil computes finalized = latest - slotsInAnEpoch * 2 blocks. Mine extra blocks + // beyond the target so that the finalized block also advances past the target timestamp. + // 3 extra blocks covers slotsInAnEpoch=1 (the default, needs 2 extra) with margin. + const extraBlocks = 3; + try { // Mine blocks one by one with explicit timestamps, since Anvil's hardhat_mine ignores // the interval parameter when anvil_setBlockTimestampInterval has been set (which the // deploy script does). Using evm_setNextBlockTimestamp + evm_mine gives us full control. - for (let i = 1; i <= blocksNeeded; i++) { + for (let i = 1; i <= blocksNeeded + extraBlocks; i++) { const blockTs = currentTimestamp + i * blockInterval; await this.doRpcCall('evm_setNextBlockTimestamp', [blockTs]); await this.doRpcCall('evm_mine', []); From ce4640baf40e40c4eb06fe7d1e8cadc4ca01adef Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 31 Mar 2026 12:01:31 -0300 Subject: [PATCH 10/12] fix(e2e): handle EpochNotFinalizedError in advanceToEpochBeforeProposer When querying the next epoch's committee after advancing, the finalized block may not have caught up to the sampling timestamp yet (Anvil computes finalized = latest - slotsInAnEpoch * 2). Catch the error and mine extra blocks to push finalized forward before retrying. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/end-to-end/src/e2e_p2p/shared.ts | 28 +++++++++++++------ .../ethereum/src/test/rollup_cheat_codes.ts | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_p2p/shared.ts b/yarn-project/end-to-end/src/e2e_p2p/shared.ts index 1a4b2746614f..419b1a752e3d 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/shared.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/shared.ts @@ -6,7 +6,7 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { TxHash } from '@aztec/aztec.js/tx'; import type { RollupCheatCodes } from '@aztec/aztec/testing'; -import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { type EpochCacheInterface, EpochNotFinalizedError } from '@aztec/epoch-cache'; import type { EmpireSlashingProposerContract, RollupContract, @@ -186,14 +186,26 @@ export async function advanceToEpochBeforeProposer({ `Checking next epoch ${nextEpoch} (slots ${startSlot}-${endSlot - 1}) for proposer ${targetProposer} (current epoch: ${currentEpoch})`, ); - for (let s = startSlot; s < endSlot; s++) { - const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s)); - if (proposer && proposer.equals(targetProposer)) { - logger.warn( - `Found target proposer ${targetProposer} in slot ${s} of epoch ${nextEpoch}. Staying at epoch ${currentEpoch} to allow sequencer startup.`, - ); - return { targetEpoch: EpochNumber(nextEpoch) }; + try { + for (let s = startSlot; s < endSlot; s++) { + const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s)); + if (proposer && proposer.equals(targetProposer)) { + logger.warn( + `Found target proposer ${targetProposer} in slot ${s} of epoch ${nextEpoch}. Staying at epoch ${currentEpoch} to allow sequencer startup.`, + ); + return { targetEpoch: EpochNumber(nextEpoch) }; + } } + } catch (err) { + if (err instanceof EpochNotFinalizedError) { + // Finalized block hasn't caught up to the sampling timestamp yet. + // Mine extra empty blocks to push finalized forward and retry this epoch. + logger.info(`Finalized block behind sampling timestamp for epoch ${nextEpoch}, mining extra blocks`); + await cheatCodes.ethCheatCodes.mine(10); + attempt--; + continue; + } + throw err; } logger.info(`Target proposer not found in epoch ${nextEpoch}, advancing to next epoch`); diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index 0ab7ae635c3a..ca35dbfaf60f 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -27,7 +27,7 @@ export class RollupCheatCodes { private logger = createLogger('aztecjs:cheat_codes'); constructor( - private ethCheatCodes: EthCheatCodes, + public readonly ethCheatCodes: EthCheatCodes, addresses: Pick, private readonly ethereumSlotDuration: number = 12, ) { From 89e2520990a25c001e8921c94e2de831cd0e9d93 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 31 Mar 2026 15:29:03 -0300 Subject: [PATCH 11/12] fix(infra): add --slots-in-an-epoch 1 to all Anvil startup configs Anvil defaults to slotsInAnEpoch=32 (Ethereum mainnet), which means finalized = latest - 64 blocks (~768s behind). This causes the epoch cache finalized-block guard to reject all committee queries in test environments. Setting slotsInAnEpoch=1 keeps finalized close to latest (only 2 blocks behind). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/examples/ts/docker-compose.yml | 2 +- playground/docker-compose.yml | 2 +- yarn-project/aztec/scripts/aztec.sh | 2 +- yarn-project/end-to-end/scripts/docker-compose.yml | 2 +- yarn-project/end-to-end/scripts/ha/docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/examples/ts/docker-compose.yml b/docs/examples/ts/docker-compose.yml index d881961f4f74..718a33e0d293 100644 --- a/docs/examples/ts/docker-compose.yml +++ b/docs/examples/ts/docker-compose.yml @@ -4,7 +4,7 @@ services: cpus: 1 cpuset: ${CPU_LIST:-} mem_limit: 2G - entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337' + entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337 --slots-in-an-epoch 1' local-network: image: aztecprotocol/build:3.0 diff --git a/playground/docker-compose.yml b/playground/docker-compose.yml index ead515cbcf93..204a2ca36729 100644 --- a/playground/docker-compose.yml +++ b/playground/docker-compose.yml @@ -4,7 +4,7 @@ services: cpus: 1 cpuset: ${CPU_LIST:-} mem_limit: 2G - entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337' + entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337 --slots-in-an-epoch 1' aztec: image: aztecprotocol/build:3.0 diff --git a/yarn-project/aztec/scripts/aztec.sh b/yarn-project/aztec/scripts/aztec.sh index 8ec2ff401e86..f9d23aba82d0 100755 --- a/yarn-project/aztec/scripts/aztec.sh +++ b/yarn-project/aztec/scripts/aztec.sh @@ -49,7 +49,7 @@ case $cmd in export ETHEREUM_HOSTS=${ETHEREUM_HOSTS:-"http://127.0.0.1:${ANVIL_PORT}"} anvil --version - anvil --silent --port "$ANVIL_PORT" & + anvil --silent --port "$ANVIL_PORT" --slots-in-an-epoch 1 & anvil_pid=$! trap 'kill $anvil_pid &>/dev/null' EXIT fi diff --git a/yarn-project/end-to-end/scripts/docker-compose.yml b/yarn-project/end-to-end/scripts/docker-compose.yml index 528efb33a286..b91adf1c1788 100644 --- a/yarn-project/end-to-end/scripts/docker-compose.yml +++ b/yarn-project/end-to-end/scripts/docker-compose.yml @@ -4,7 +4,7 @@ services: cpus: 1 cpuset: ${CPU_LIST:-} mem_limit: 2G - entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337' + entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337 --slots-in-an-epoch 1' local-network: image: aztecprotocol/build:3.0 diff --git a/yarn-project/end-to-end/scripts/ha/docker-compose.yml b/yarn-project/end-to-end/scripts/ha/docker-compose.yml index eb8ecad5d320..3002ed68f211 100644 --- a/yarn-project/end-to-end/scripts/ha/docker-compose.yml +++ b/yarn-project/end-to-end/scripts/ha/docker-compose.yml @@ -33,7 +33,7 @@ services: image: aztecprotocol/build:3.0 cpus: 1 mem_limit: 2G - entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337' + entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337 --slots-in-an-epoch 1' end-to-end: image: aztecprotocol/build:3.0 From 0aa1f16f5d86327adeb3eef4196baa79f2e799b0 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 31 Mar 2026 17:25:04 -0300 Subject: [PATCH 12/12] fix(e2e): revert --slots-in-an-epoch for HA test docker-compose The HA test relies on slow finalization to keep attestations in the P2P pool long enough for verification. With --slots-in-an-epoch 1, Anvil finalizes every block immediately, triggering aggressive pool cleanup that deletes attestations before the test can read them. Co-Authored-By: Claude Opus 4.6 (1M context) --- yarn-project/end-to-end/scripts/ha/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/end-to-end/scripts/ha/docker-compose.yml b/yarn-project/end-to-end/scripts/ha/docker-compose.yml index 3002ed68f211..eb8ecad5d320 100644 --- a/yarn-project/end-to-end/scripts/ha/docker-compose.yml +++ b/yarn-project/end-to-end/scripts/ha/docker-compose.yml @@ -33,7 +33,7 @@ services: image: aztecprotocol/build:3.0 cpus: 1 mem_limit: 2G - entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337 --slots-in-an-epoch 1' + entrypoint: 'anvil --silent -p 8545 --host 0.0.0.0 --chain-id 31337' end-to-end: image: aztecprotocol/build:3.0