Skip to content
Merged
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
18 changes: 17 additions & 1 deletion yarn-project/.claude/rules/typescript-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,20 @@ mock.getData.mockImplementation((id: string) => {
}
return Promise.resolve(undefined);
});
```
```

## Arrow Function Bodies

Use expression bodies instead of block bodies when the block only contains a `return`:

```typescript
// Good: Expression body
items.map(item => item.value * 2)
fn(arg => expression(arg, foo))

// Bad: Block body with just a return
items.map(item => { return item.value * 2; })
fn(arg => { return expression(arg, foo); })
```

Block bodies are appropriate when the callback has multiple statements or side effects beyond the return.
34 changes: 24 additions & 10 deletions yarn-project/archiver/src/archiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,19 +342,33 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
return Promise.resolve(this.synchronizer.getL1Timestamp());
}

public getSyncedL2SlotNumber(): Promise<SlotNumber | undefined> {
public async getSyncedL2SlotNumber(): Promise<SlotNumber | undefined> {
// The synced L2 slot is the latest slot for which we have all L1 data,
// either because we have seen all L1 blocks for that slot, or because
// we have seen the corresponding checkpoint.

let slotFromL1Sync: SlotNumber | undefined;
const l1Timestamp = this.synchronizer.getL1Timestamp();
if (l1Timestamp === undefined) {
return Promise.resolve(undefined);
if (l1Timestamp !== undefined) {
const nextL1BlockSlot = getSlotAtNextL1Block(l1Timestamp, this.l1Constants);
if (Number(nextL1BlockSlot) > 0) {
slotFromL1Sync = SlotNumber.add(nextL1BlockSlot, -1);
}
}

let slotFromCheckpoint: SlotNumber | undefined;
const latestCheckpointNumber = await this.store.getSynchedCheckpointNumber();
if (latestCheckpointNumber > 0) {
const checkpointData = await this.store.getCheckpointData(latestCheckpointNumber);
if (checkpointData) {
slotFromCheckpoint = checkpointData.header.slotNumber;
}
}
// The synced slot is the last L2 slot whose all L1 blocks have been processed.
// If the next L1 block (at l1Timestamp + ethereumSlotDuration) falls in slot N,
// then we've fully synced slot N-1.
const nextL1BlockSlot = getSlotAtNextL1Block(l1Timestamp, this.l1Constants);
if (Number(nextL1BlockSlot) === 0) {
return Promise.resolve(undefined);

if (slotFromL1Sync === undefined && slotFromCheckpoint === undefined) {
return undefined;
}
return Promise.resolve(SlotNumber(nextL1BlockSlot - 1));
return SlotNumber(Math.max(slotFromL1Sync ?? 0, slotFromCheckpoint ?? 0));
}

public async getSyncedL2EpochNumber(): Promise<EpochNumber | undefined> {
Expand Down
16 changes: 9 additions & 7 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,14 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
})
.catch(err => log.error('Failed to start p2p services after archiver sync', err));

const globalVariableBuilder = new GlobalVariableBuilder(dateProvider, publicClient, {
l1Contracts: config.l1Contracts,
ethereumSlotDuration: config.ethereumSlotDuration,
rollupVersion: BigInt(config.rollupVersion),
l1GenesisTime,
slotDuration: Number(slotDuration),
});

// Validator enabled, create/start relevant service
let sequencer: SequencerClient | undefined;
let slasherClient: SlasherClientInterface | undefined;
Expand Down Expand Up @@ -520,6 +528,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
dateProvider,
blobClient,
nodeKeyStore: keyStoreManager!,
globalVariableBuilder,
});
}

Expand Down Expand Up @@ -553,13 +562,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
}
}

const globalVariableBuilder = new GlobalVariableBuilder({
...config,
rollupVersion: BigInt(config.rollupVersion),
l1GenesisTime,
slotDuration: Number(slotDuration),
});

const node = new AztecNodeService(
config,
p2pClient,
Expand Down
152 changes: 152 additions & 0 deletions yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { ChainMonitorEventMap } from '@aztec/ethereum/test';
import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { AbortError } from '@aztec/foundation/error';
import { sleep } from '@aztec/foundation/sleep';
import { executeTimeout } from '@aztec/foundation/timer';
import { SequencerState } from '@aztec/sequencer-client';
import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';

import { jest } from '@jest/globals';

import { EpochsTestContext } from './epochs_test.js';

jest.setTimeout(1000 * 60 * 10);

// Validates that the sequencer can build a block in an L2 slot even when the archiver hasn't synced
// all L1 blocks of the previous slot. This happens when an L1 slot is missed (no block produced).
// The fix relies on getSyncedL2SlotNumber using the latest synced checkpoint slot as a signal,
// bypassing the stale L1 timestamp when L1 blocks are missing.
// Regression test for https://github.com/AztecProtocol/aztec-packages/issues/14766
describe('e2e_epochs/epochs_missed_l1_slot', () => {
let test: EpochsTestContext;

// Use enough L1 slots per L2 slot to have room for pausing mining mid-slot.
// With 6 L1 slots per L2 slot (L1=8s, L2=48s), we have plenty of time to
// publish a checkpoint and pause mining without accidentally skipping a slot.
const L1_SLOTS_PER_L2_SLOT = 6;

beforeEach(async () => {
test = await EpochsTestContext.setup({
numberOfAccounts: 0,
minTxsPerBlock: 0,
aztecSlotDurationInL1Slots: L1_SLOTS_PER_L2_SLOT,
startProverNode: false,
aztecProofSubmissionEpochs: 1024,
disableAnvilTestWatcher: true,
enforceTimeTable: true,
});
});

afterEach(async () => {
jest.restoreAllMocks();
await test.teardown();
});

it('builds a block after missed L1 slots when previous checkpoint is synced', async () => {
const { logger, constants, monitor, context } = test;
const eth = context.cheatCodes.eth;
const L1_BLOCK_TIME = test.L1_BLOCK_TIME_IN_S;
const L2_SLOT_DURATION = test.L2_SLOT_DURATION_IN_S;

// Step 1: Wait for a checkpoint that's published NOT in the last L1 slot of its L2 slot.
// We need the checkpoint to land early enough that when we pause mining, the archiver's
// L1 timestamp is still in the middle of the slot (not at the end).
logger.info('Waiting for a checkpoint published early in its L2 slot...');
const checkpointEvent = await executeTimeout(
signal =>
new Promise<ChainMonitorEventMap['checkpoint'][0]>((res, rej) => {
const handleCheckpoint = (...[ev]: ChainMonitorEventMap['checkpoint']) => {
// Skip the initial checkpoint (genesis state).
if (ev.checkpointNumber === 0) {
return;
}
const slotStart = getTimestampForSlot(ev.l2SlotNumber, constants);
const lastL1SlotStart = slotStart + BigInt(L2_SLOT_DURATION - L1_BLOCK_TIME);
if (ev.timestamp < lastL1SlotStart) {
logger.info(
`Checkpoint ${ev.checkpointNumber} in slot ${ev.l2SlotNumber} at L1 timestamp ${ev.timestamp}`,
{ slotStart, lastL1SlotStart },
);
res(ev);
monitor.off('checkpoint', handleCheckpoint);
} else {
logger.info(
`Skipping checkpoint ${ev.checkpointNumber}: published at ${ev.timestamp} (last L1 slot starts at ${lastL1SlotStart})`,
);
}
};
signal.onabort = () => {
monitor.off('checkpoint', handleCheckpoint);
rej(new AbortError());
};
monitor.on('checkpoint', handleCheckpoint);
}),
60_000,
'Wait for early checkpoint',
);

const checkpointSlotNumber = checkpointEvent.l2SlotNumber;
const nextSlotNumber = SlotNumber(checkpointSlotNumber + 1);
const nextSlotTimestamp = Number(getTimestampForSlot(nextSlotNumber, constants));

logger.info(`Using checkpoint ${checkpointEvent.checkpointNumber} in L2 slot ${checkpointSlotNumber}`, {
nextSlotNumber,
nextSlotTimestamp,
});

// Step 2: Wait briefly for the sequencer to finish its current work cycle, then pause mining.
await sleep(1500);

logger.info('Pausing L1 block production (simulating missed L1 slots)...');
await eth.setAutomine(false);
await eth.setIntervalMining(0, { silent: true });

const frozenL1Timestamp = await eth.timestamp();
logger.info(`L1 mining paused at L1 timestamp ${frozenL1Timestamp}`);

// Step 3: Wait until the sequencer reaches PUBLISHING_CHECKPOINT during the mining pause.
// With the fix: the sequencer sees the checkpoint for slot N, so getSyncedL2SlotNumber
// returns N, checkSync passes for slot N+1, and it advances all the way to publishing.
// Without the fix: getSyncedL2SlotNumber is stuck at N-1, checkSync fails, sequencer
// stays in IDLE/SYNCHRONIZING and never reaches PUBLISHING_CHECKPOINT.
const sequencer = context.sequencer!.getSequencer();

logger.info('Waiting for sequencer to reach PUBLISHING_CHECKPOINT during mining pause...');
await executeTimeout(
signal =>
new Promise<void>((res, rej) => {
const stateListener = ({ newState }: { newState: SequencerState }) => {
if (newState === SequencerState.PUBLISHING_CHECKPOINT) {
sequencer.off('state-changed', stateListener);
res();
}
};
signal.onabort = () => {
sequencer.off('state-changed', stateListener);
rej(new AbortError());
};
sequencer.on('state-changed', stateListener);
}),
L2_SLOT_DURATION * 2 * 1000,
'Wait for sequencer to reach PUBLISHING_CHECKPOINT',
);

logger.info('Sequencer reached PUBLISHING_CHECKPOINT during mining pause');

// Step 4: Resume mining so the pending L1 tx lands and the test can clean up.
logger.info('Resuming L1 block production...');
const resumeTimestamp = Math.floor(context.dateProvider.now() / 1000);
await eth.setNextBlockTimestamp(resumeTimestamp);
await eth.mine();
await eth.setIntervalMining(L1_BLOCK_TIME);

// Step 5: Wait for the next checkpoint to confirm the block was actually published.
const finalCheckpoint = CheckpointNumber(checkpointEvent.checkpointNumber + 1);
logger.info(`Waiting for checkpoint ${finalCheckpoint}...`);
await test.waitUntilCheckpointNumber(finalCheckpoint, 60);
await monitor.run();
logger.info(`Checkpoint ${finalCheckpoint} published in slot ${monitor.l2SlotNumber}`);

expect(monitor.checkpointNumber).toBeGreaterThanOrEqual(finalCheckpoint);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ describe('L1Publisher integration', () => {
const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v));
const attestations = orderAttestations(checkpointAttestations, committee!);

const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
expect(canPropose?.slot).toEqual(block.header.getSlot());
await publisher.validateBlockHeader(checkpoint.header);

Expand All @@ -630,7 +630,7 @@ describe('L1Publisher integration', () => {
const attestations = orderAttestations(checkpointAttestations, committee!).reverse();
const attestationsAndSigners = new CommitteeAttestationsAndSigners(attestations);

const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
expect(canPropose?.slot).toEqual(block.header.getSlot());
await publisher.validateBlockHeader(checkpoint.header);

Expand All @@ -645,7 +645,7 @@ describe('L1Publisher integration', () => {
const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v));
const attestations = orderAttestations(checkpointAttestations, committee!);

const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
expect(canPropose?.slot).toEqual(block.header.getSlot());
await publisher.validateBlockHeader(checkpoint.header);

Expand All @@ -670,7 +670,7 @@ describe('L1Publisher integration', () => {
const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v));
const attestations = orderAttestations(checkpointAttestations, committee!);

const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
expect(canPropose?.slot).toEqual(block.header.getSlot());
await publisher.validateBlockHeader(checkpoint.header);

Expand Down Expand Up @@ -742,8 +742,8 @@ describe('L1Publisher integration', () => {
// We cannot propose directly, we need to assume the previous checkpoint is invalidated
const genesis = new Fr(GENESIS_ARCHIVE_ROOT);
logger.warn(`Checking can propose at next eth block on top of genesis ${genesis}`);
expect(await publisher.canProposeAtNextEthBlock(genesis, proposer!)).toBeUndefined();
const canPropose = await publisher.canProposeAtNextEthBlock(genesis, proposer!, { forcePendingCheckpointNumber });
expect(await publisher.canProposeAt(genesis, proposer!)).toBeUndefined();
const canPropose = await publisher.canProposeAt(genesis, proposer!, { forcePendingCheckpointNumber });
expect(canPropose?.slot).toEqual(block.header.getSlot());

// Same for validation
Expand Down
16 changes: 7 additions & 9 deletions yarn-project/epoch-cache/src/epoch_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type L1RollupConstants,
getEpochAtSlot,
getEpochNumberAtTimestamp,
getNextL1SlotTimestamp,
getSlotAtTimestamp,
getSlotRangeForEpoch,
getTimestampForSlot,
Expand Down Expand Up @@ -148,19 +149,15 @@ export class EpochCache implements EpochCacheInterface {
return { ...this.getEpochAndSlotAtTimestamp(nowSeconds), nowMs };
}

public nowInSeconds(): bigint {
return BigInt(Math.floor(this.dateProvider.now() / 1000));
}

private getEpochAndSlotAtSlot(slot: SlotNumber): EpochAndSlot {
const epoch = getEpochAtSlot(slot, this.l1constants);
const ts = getTimestampRangeForEpoch(epoch, this.l1constants)[0];
return { epoch, ts, slot };
}

public getEpochAndSlotInNextL1Slot(): EpochAndSlot & { now: bigint } {
const now = this.nowInSeconds();
const nextSlotTs = now + BigInt(this.l1constants.ethereumSlotDuration);
const now = BigInt(this.dateProvider.nowInSeconds());
const nextSlotTs = getNextL1SlotTimestamp(Number(now), this.l1constants);
return { ...this.getEpochAndSlotAtTimestamp(nextSlotTs), now };
}

Expand Down Expand Up @@ -376,10 +373,11 @@ export class EpochCache implements EpochCacheInterface {
async getRegisteredValidators(): Promise<EthAddress[]> {
const validatorRefreshIntervalMs = this.config.validatorRefreshIntervalSeconds * 1000;
const validatorRefreshTime = this.lastValidatorRefresh + validatorRefreshIntervalMs;
if (validatorRefreshTime < this.dateProvider.now()) {
const currentSet = await this.rollup.getAttesters();
const now = this.dateProvider.now();
if (validatorRefreshTime < now) {
const currentSet = await this.rollup.getAttesters(BigInt(Math.floor(now / 1000)));
this.allValidators = new Set(currentSet.map(v => v.toString()));
this.lastValidatorRefresh = this.dateProvider.now();
this.lastValidatorRefresh = now;
}
return Array.from(this.allValidators.keys()).map(v => EthAddress.fromString(v));
}
Expand Down
12 changes: 5 additions & 7 deletions yarn-project/ethereum/src/contracts/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,14 +777,13 @@ export class RollupContract {
* timestamp of the next L1 block
* @throws otherwise
*/
public async canProposeAtNextEthBlock(
public async canProposeAt(
archive: Buffer,
account: `0x${string}` | Account,
slotDuration: number,
timestamp: bigint,
opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {},
): Promise<{ slot: SlotNumber; checkpointNumber: CheckpointNumber; timeOfNextL1Slot: bigint }> {
const latestBlock = await this.client.getBlock();
const timeOfNextL1Slot = latestBlock.timestamp + BigInt(slotDuration);
const timeOfNextL1Slot = timestamp;
const who = typeof account === 'string' ? account : account.address;

try {
Expand Down Expand Up @@ -937,11 +936,10 @@ export class RollupContract {
return this.rollup.read.getSpecificProverRewardsForEpoch([epoch, prover]);
}

async getAttesters(): Promise<EthAddress[]> {
async getAttesters(timestamp?: bigint): Promise<EthAddress[]> {
const attesterSize = await this.getActiveAttesterCount();
const gse = new GSEContract(this.client, await this.getGSE());
const ts = (await this.client.getBlock()).timestamp;

const ts = timestamp ?? (await this.client.getBlock()).timestamp;
const indices = Array.from({ length: attesterSize }, (_, i) => BigInt(i));
const chunks = chunk(indices, 1000);

Expand Down
5 changes: 5 additions & 0 deletions yarn-project/foundation/src/branded-types/slot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ SlotNumber.isValid = function (value: unknown): value is SlotNumber {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
};

/** Increments a SlotNumber by a given value. */
SlotNumber.add = function (sn: SlotNumber, increment: number): SlotNumber {
return SlotNumber(sn + increment);
};

/**
* The zero slot value.
*/
Expand Down
Loading
Loading