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/aztec/src/testing/cheat_codes.ts b/yarn-project/aztec/src/testing/cheat_codes.ts index 14413fd4ddd3..7477c635bd4b 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,16 @@ 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. + // For large time jumps (e.g., 1 day), mining at the ethereum slot interval (12s) + // 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 = 1000; + 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(); 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/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/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) || 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..e13f706871fb --- /dev/null +++ b/yarn-project/epoch-cache/src/epoch_cache.integration.test.ts @@ -0,0 +1,272 @@ +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=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: 8, + dateProvider, + })); + + cheatCodes = new EthCheatCodes([rpcUrl], dateProvider); + + const initialValidators = validatorKeys.map((_, i) => ({ + attester: validatorAddresses[i], + withdrawer: validatorAddresses[i], + 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(), + realVerifier: false, + aztecTargetCommitteeSize: NUM_VALIDATORS, + initialValidators, + }); + + rollupCheatCodes = new RollupCheatCodes( + cheatCodes, + deployed.l1ContractAddresses, + DefaultL1ContractsConfig.ethereumSlotDuration, + ); + + 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); + // 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); + + // 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(); + 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); + } + + // 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 () => { + 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 () => { + // Restore interval mining even if a test assertion fails mid-way. + 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. + // 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); + + // 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; +} 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..cd807aa464cf 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,68 @@ 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); + }); + + 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', () => { 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..d6b11ddadcb3 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -279,6 +279,68 @@ 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 }); + } + + // 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 + extraBlocks; 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) { + 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..ca35dbfaf60f 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -27,8 +27,9 @@ export class RollupCheatCodes { private logger = createLogger('aztecjs:cheat_codes'); constructor( - private ethCheatCodes: EthCheatCodes, + public readonly 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}`); }