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
193 changes: 193 additions & 0 deletions yarn-project/archiver/src/archiver-misc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type { BlobClientInterface } from '@aztec/blob-client/client';
import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants';
import type { EpochCache, EpochCommitteeInfo } from '@aztec/epoch-cache';
import { DefaultL1ContractsConfig } from '@aztec/ethereum/config';
import type { RollupContract } from '@aztec/ethereum/contracts';
import type { ViemPublicClient } from '@aztec/ethereum/types';
import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { Buffer32 } from '@aztec/foundation/buffer';
import { Fr } from '@aztec/foundation/curves/bn254';
import { EthAddress } from '@aztec/foundation/eth-address';
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
import { getTelemetryClient } from '@aztec/telemetry-client';

import { EventEmitter } from 'events';
import { type MockProxy, mock } from 'jest-mock-extended';

import { Archiver, type ArchiverEmitter } from './archiver.js';
import type { ArchiverInstrumentation } from './modules/instrumentation.js';
import { ArchiverL1Synchronizer } from './modules/l1_synchronizer.js';
import { KVArchiverDataStore } from './store/kv_archiver_store.js';
import { L2TipsCache } from './store/l2_tips_cache.js';

describe('Archiver misc', () => {
let archiver: Archiver;
let synchronizer: MockProxy<ArchiverL1Synchronizer>;
let l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32; genesisArchiveRoot: Fr };

const L1_GENESIS_TIME = 1000n;
const SLOT_DURATION = 24;
const ETH_SLOT_DURATION = DefaultL1ContractsConfig.ethereumSlotDuration;
const EPOCH_DURATION = 4;

beforeEach(async () => {
l1Constants = {
l1GenesisTime: L1_GENESIS_TIME,
l1StartBlock: 0n,
l1StartBlockHash: Buffer32.random(),
epochDuration: EPOCH_DURATION,
slotDuration: SLOT_DURATION,
ethereumSlotDuration: ETH_SLOT_DURATION,
proofSubmissionEpochs: 1,
targetCommitteeSize: 48,
rollupManaLimit: Number.MAX_SAFE_INTEGER,
genesisArchiveRoot: new Fr(GENESIS_ARCHIVE_ROOT),
};

synchronizer = mock<ArchiverL1Synchronizer>();

const publicClient = mock<ViemPublicClient>();
const blobClient = mock<BlobClientInterface>();
const rollupContract = mock<RollupContract>();
const epochCache = mock<EpochCache>();
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo);

const tracer = getTelemetryClient().getTracer('');
const instrumentation = mock<ArchiverInstrumentation>({ isEnabled: () => true, tracer });
const archiverStore = new KVArchiverDataStore(await openTmpStore('archiver_misc_test'), 1000, {
epochDuration: EPOCH_DURATION,
});
const events = new EventEmitter() as ArchiverEmitter;
const l2TipsCache = new L2TipsCache(archiverStore.blockStore);

archiver = new Archiver(
publicClient,
publicClient,
rollupContract,
{
registryAddress: EthAddress.random(),
governanceProposerAddress: EthAddress.random(),
slashFactoryAddress: EthAddress.random(),
slashingProposerAddress: EthAddress.random(),
},
archiverStore,
{ pollingIntervalMs: 1000, batchSize: 1000, maxAllowedEthClientDriftSeconds: 300 },
blobClient,
instrumentation,
l1Constants,
synchronizer,
events,
l2TipsCache,
);
});

afterEach(async () => {
await archiver?.stop();
});

/** Returns the L1 timestamp at the start of an L2 slot. */
function slotStart(slot: number): bigint {
return L1_GENESIS_TIME + BigInt(slot) * BigInt(SLOT_DURATION);
}

/** Returns the L1 timestamp at the last L1 block of an L2 slot. */
function slotLastL1Block(slot: number): bigint {
// The last L1 block in an L2 slot is the one where the next L1 block falls in the next L2 slot.
// Start of next slot minus ethereumSlotDuration gives us the last L1 block still in this slot.
return slotStart(slot + 1) - BigInt(ETH_SLOT_DURATION);
}

describe('getSyncedL2SlotNumber', () => {
it('returns undefined before any sync', async () => {
synchronizer.getL1Timestamp.mockReturnValue(undefined);
expect(await archiver.getSyncedL2SlotNumber()).toBeUndefined();
});

it('returns undefined when L1 timestamp is before genesis', async () => {
synchronizer.getL1Timestamp.mockReturnValue(L1_GENESIS_TIME - 100n);
expect(await archiver.getSyncedL2SlotNumber()).toBeUndefined();
});

it('returns undefined at very start of slot 0 (next L1 block still in slot 0)', async () => {
// At genesis, next L1 block at genesis+12 is still in slot 0 (slot 0 covers [0, 24)).
synchronizer.getL1Timestamp.mockReturnValue(L1_GENESIS_TIME);
expect(await archiver.getSyncedL2SlotNumber()).toBeUndefined();
});

it('returns slot 0 when last L1 block of slot 0 has been synced', async () => {
// Last L1 block in slot 0: next L1 block (at ts+12) lands in slot 1.
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(0));
expect(await archiver.getSyncedL2SlotNumber()).toEqual(SlotNumber(0));
});

it('returns slot 0 at the start of slot 1', async () => {
synchronizer.getL1Timestamp.mockReturnValue(slotStart(1));
expect(await archiver.getSyncedL2SlotNumber()).toEqual(SlotNumber(0));
});

it('returns slot 4 when last L1 block of slot 4 has been synced', async () => {
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(4));
expect(await archiver.getSyncedL2SlotNumber()).toEqual(SlotNumber(4));
});

it('returns slot N-1 when L1 timestamp is mid-slot N', async () => {
// Mid slot 3: next L1 block (ts+12) still in slot 3, so slot 2 is last fully synced.
const midSlot3 = slotStart(3) + BigInt(ETH_SLOT_DURATION);
synchronizer.getL1Timestamp.mockReturnValue(midSlot3);
// next L1 block = midSlot3 + 12 = genesis + 3*24 + 24 = genesis + 96
// slot at genesis+96 = 96/24 = 4, so synced = 4-1 = 3
// Actually midSlot3 = genesis + 3*24 + 12 = genesis + 84
// next = genesis + 84 + 12 = genesis + 96, slot = 96/24 = 4, synced = 3
expect(await archiver.getSyncedL2SlotNumber()).toEqual(SlotNumber(3));
});
});

describe('getSyncedL2EpochNumber', () => {
// With epochDuration=4: epoch 0 = slots 0-3, epoch 1 = slots 4-7, epoch 2 = slots 8-11

it('returns undefined before any sync', async () => {
synchronizer.getL1Timestamp.mockReturnValue(undefined);
expect(await archiver.getSyncedL2EpochNumber()).toBeUndefined();
});

it('returns undefined when only part of epoch 0 is synced', async () => {
// Synced slot 0 => epoch 0 not fully synced, no previous epoch.
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(0));
expect(await archiver.getSyncedL2EpochNumber()).toBeUndefined();
});

it('returns undefined when synced to slot 2 (mid epoch 0)', async () => {
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(2));
expect(await archiver.getSyncedL2EpochNumber()).toBeUndefined();
});

it('returns epoch 0 when synced through last slot of epoch 0', async () => {
// Epoch 0 last slot = 3
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(3));
expect(await archiver.getSyncedL2EpochNumber()).toEqual(EpochNumber(0));
});

it('returns epoch 0 when synced to first slot of epoch 1', async () => {
// Synced slot 4 = first slot of epoch 1, so only epoch 0 is fully synced.
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(4));
expect(await archiver.getSyncedL2EpochNumber()).toEqual(EpochNumber(0));
});

it('returns epoch 0 when synced to slot 6 (mid epoch 1)', async () => {
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(6));
expect(await archiver.getSyncedL2EpochNumber()).toEqual(EpochNumber(0));
});

it('returns epoch 1 when synced through last slot of epoch 1', async () => {
// Epoch 1 last slot = 7
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(7));
expect(await archiver.getSyncedL2EpochNumber()).toEqual(EpochNumber(1));
});

it('returns epoch 1 when synced to mid epoch 2', async () => {
synchronizer.getL1Timestamp.mockReturnValue(slotLastL1Block(9));
expect(await archiver.getSyncedL2EpochNumber()).toEqual(EpochNumber(1));
});
});
});
36 changes: 27 additions & 9 deletions yarn-project/archiver/src/archiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import {
import { PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
import {
type L1RollupConstants,
getEpochNumberAtTimestamp,
getEpochAtSlot,
getSlotAtNextL1Block,
getSlotAtTimestamp,
getSlotRangeForEpoch,
getTimestampRangeForEpoch,
} from '@aztec/stdlib/epoch-helpers';
Expand Down Expand Up @@ -338,16 +337,35 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
return Promise.resolve(this.synchronizer.getL1Timestamp());
}

public getL2SlotNumber(): Promise<SlotNumber | undefined> {
public getSyncedL2SlotNumber(): Promise<SlotNumber | undefined> {
const l1Timestamp = this.synchronizer.getL1Timestamp();
return Promise.resolve(l1Timestamp === undefined ? undefined : getSlotAtTimestamp(l1Timestamp, this.l1Constants));
if (l1Timestamp === undefined) {
return Promise.resolve(undefined);
}
// 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);
}
return Promise.resolve(SlotNumber(nextL1BlockSlot - 1));
}

public getL2EpochNumber(): Promise<EpochNumber | undefined> {
const l1Timestamp = this.synchronizer.getL1Timestamp();
return Promise.resolve(
l1Timestamp === undefined ? undefined : getEpochNumberAtTimestamp(l1Timestamp, this.l1Constants),
);
public async getSyncedL2EpochNumber(): Promise<EpochNumber | undefined> {
const syncedSlot = await this.getSyncedL2SlotNumber();
if (syncedSlot === undefined) {
return undefined;
}
// An epoch is fully synced when all its slots are synced.
// We check if syncedSlot is the last slot of its epoch; if so, that epoch is fully synced.
// Otherwise, only the previous epoch is fully synced.
const epoch = getEpochAtSlot(syncedSlot, this.l1Constants);
const [, endSlot] = getSlotRangeForEpoch(epoch, this.l1Constants);
if (syncedSlot >= endSlot) {
return epoch;
}
return Number(epoch) > 0 ? EpochNumber(Number(epoch) - 1) : undefined;
}

public async isEpochComplete(epochNumber: EpochNumber): Promise<boolean> {
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/archiver/src/modules/data_source_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export abstract class ArchiverDataSourceBase

abstract getL2Tips(): Promise<L2Tips>;

abstract getL2SlotNumber(): Promise<SlotNumber | undefined>;
abstract getSyncedL2SlotNumber(): Promise<SlotNumber | undefined>;

abstract getL2EpochNumber(): Promise<EpochNumber | undefined>;
abstract getSyncedL2EpochNumber(): Promise<EpochNumber | undefined>;

abstract isEpochComplete(epochNumber: EpochNumber): Promise<boolean>;

Expand Down
4 changes: 2 additions & 2 deletions yarn-project/archiver/src/test/mock_l2_block_source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,11 +447,11 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource {
};
}

getL2EpochNumber(): Promise<EpochNumber> {
getSyncedL2EpochNumber(): Promise<EpochNumber> {
throw new Error('Method not implemented.');
}

getL2SlotNumber(): Promise<SlotNumber> {
getSyncedL2SlotNumber(): Promise<SlotNumber> {
throw new Error('Method not implemented.');
}

Expand Down
8 changes: 7 additions & 1 deletion yarn-project/archiver/src/test/noop_l1_archiver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BlobClientInterface } from '@aztec/blob-client/client';
import type { RollupContract } from '@aztec/ethereum/contracts';
import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
import { SlotNumber } from '@aztec/foundation/branded-types';
import { Buffer32 } from '@aztec/foundation/buffer';
import { Fr } from '@aztec/foundation/curves/bn254';
import { EthAddress } from '@aztec/foundation/eth-address';
Expand Down Expand Up @@ -30,7 +31,7 @@ class NoopL1Synchronizer implements FunctionsOf<ArchiverL1Synchronizer> {
return 0n;
}
getL1Timestamp(): bigint | undefined {
return 0n;
return undefined;
}
testEthereumNodeSynced(): Promise<void> {
return Promise.resolve();
Expand Down Expand Up @@ -96,6 +97,11 @@ export class NoopL1Archiver extends Archiver {
this.runningPromise.start();
return Promise.resolve();
}

/** Always reports as fully synced since there is no real L1 to sync from. */
public override getSyncedL2SlotNumber(): Promise<SlotNumber | undefined> {
return Promise.resolve(SlotNumber(Number.MAX_SAFE_INTEGER));
}
}

/** Creates an archiver with mocked L1 connectivity for testing. */
Expand Down
6 changes: 3 additions & 3 deletions yarn-project/aztec-node/src/sentinel/sentinel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,9 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
return false;
}

const archiverSlot = await this.archiver.getL2SlotNumber();
if (archiverSlot === undefined || archiverSlot < targetSlot) {
this.logger.debug(`Waiting for archiver to sync with L2 slot ${targetSlot}`, { archiverSlot, targetSlot });
const syncedSlot = await this.archiver.getSyncedL2SlotNumber();
if (syncedSlot === undefined || syncedSlot < targetSlot) {
this.logger.debug(`Waiting for archiver to sync with L2 slot ${targetSlot}`, { syncedSlot, targetSlot });
return false;
}

Expand Down
9 changes: 5 additions & 4 deletions yarn-project/p2p/src/client/p2p_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,10 +679,11 @@ export class P2PClient extends WithTracer implements P2P {
if (oldCheckpointNumber <= CheckpointNumber.ZERO) {
return false;
}
const isEpochPrune = oldCheckpointNumber !== newCheckpoint.number;
this.log.info(
`Detected epoch prune: ${isEpochPrune}. Old checkpoint: ${oldCheckpointNumber}, new checkpoint: ${newCheckpoint.number}`,
);
const newCheckpointNumber = newCheckpoint.number;
const isEpochPrune = oldCheckpointNumber !== newCheckpointNumber;
if (isEpochPrune) {
this.log.info(`Detected epoch prune to ${newCheckpointNumber}`, { oldCheckpointNumber, newCheckpointNumber });
}
return isEpochPrune;
}

Expand Down
9 changes: 6 additions & 3 deletions yarn-project/sequencer-client/src/sequencer/sequencer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ describe('sequencer', () => {
getCheckpointedBlocksForEpoch: mockFn().mockResolvedValue([]),
getCheckpointsForEpoch: mockFn().mockResolvedValue([]),
getCheckpointsDataForEpoch: mockFn().mockResolvedValue([]),
getSyncedL2SlotNumber: mockFn().mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)),
});

l1ToL2MessageSource = mock<L1ToL2MessageSource>({
Expand Down Expand Up @@ -399,14 +400,16 @@ describe('sequencer', () => {
expectPublisherProposeL2Block();
});

it('builds a block only when synced to previous L1 slot', async () => {
it('builds a block only when synced to previous L2 slot', async () => {
await setupSingleTxBlock();

l2BlockSource.getL1Timestamp.mockResolvedValue(1000n - BigInt(ethereumSlotDuration) - 1n);
// Archiver reports it hasn't synced any slot yet, so sequencer should not propose
l2BlockSource.getSyncedL2SlotNumber.mockResolvedValue(undefined);
await sequencer.work();
expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();

l2BlockSource.getL1Timestamp.mockResolvedValue(1000n - BigInt(ethereumSlotDuration));
// Archiver reports synced to slot 0, which satisfies syncedL2Slot + 1 >= slot (slot=1)
l2BlockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(0));
await sequencer.work();
expect(publisher.enqueueProposeCheckpoint).toHaveBeenCalled();
});
Expand Down
Loading
Loading