Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/examples/ts/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion playground/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/aztec/scripts/aztec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions yarn-project/aztec/src/testing/cheat_codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheatCodes> {
static async create(
rpcUrls: string[],
node: AztecNode,
dateProvider: DateProvider,
ethereumSlotDuration: number = 12,
): Promise<CheatCodes> {
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);
}

/**
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/end-to-end/scripts/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/end-to-end/scripts/ha/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions yarn-project/end-to-end/src/e2e_p2p/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`);
Expand Down
7 changes: 6 additions & 1 deletion yarn-project/end-to-end/src/fixtures/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
3 changes: 0 additions & 3 deletions yarn-project/epoch-cache/READMD.md

This file was deleted.

198 changes: 198 additions & 0 deletions yarn-project/epoch-cache/README.md
Original file line number Diff line number Diff line change
@@ -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 |
Loading
Loading