diff --git a/yarn-project/archiver/src/archiver-misc.test.ts b/yarn-project/archiver/src/archiver-misc.test.ts new file mode 100644 index 000000000000..1ee82651487f --- /dev/null +++ b/yarn-project/archiver/src/archiver-misc.test.ts @@ -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; + 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(); + + const publicClient = mock(); + const blobClient = mock(); + const rollupContract = mock(); + const epochCache = mock(); + epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo); + + const tracer = getTelemetryClient().getTracer(''); + const instrumentation = mock({ 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)); + }); + }); +}); diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index f590f5daf844..f27e8cbab3c7 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -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'; @@ -338,16 +337,35 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra return Promise.resolve(this.synchronizer.getL1Timestamp()); } - public getL2SlotNumber(): Promise { + public getSyncedL2SlotNumber(): Promise { 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 { - const l1Timestamp = this.synchronizer.getL1Timestamp(); - return Promise.resolve( - l1Timestamp === undefined ? undefined : getEpochNumberAtTimestamp(l1Timestamp, this.l1Constants), - ); + public async getSyncedL2EpochNumber(): Promise { + 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 { diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index 7a8cfc85f238..7bdb3e1faf99 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -46,9 +46,9 @@ export abstract class ArchiverDataSourceBase abstract getL2Tips(): Promise; - abstract getL2SlotNumber(): Promise; + abstract getSyncedL2SlotNumber(): Promise; - abstract getL2EpochNumber(): Promise; + abstract getSyncedL2EpochNumber(): Promise; abstract isEpochComplete(epochNumber: EpochNumber): Promise; diff --git a/yarn-project/archiver/src/test/mock_l2_block_source.ts b/yarn-project/archiver/src/test/mock_l2_block_source.ts index da295c09cb96..4491991066cd 100644 --- a/yarn-project/archiver/src/test/mock_l2_block_source.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -447,11 +447,11 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { }; } - getL2EpochNumber(): Promise { + getSyncedL2EpochNumber(): Promise { throw new Error('Method not implemented.'); } - getL2SlotNumber(): Promise { + getSyncedL2SlotNumber(): Promise { throw new Error('Method not implemented.'); } diff --git a/yarn-project/archiver/src/test/noop_l1_archiver.ts b/yarn-project/archiver/src/test/noop_l1_archiver.ts index 175253c42c3b..74dd9e604070 100644 --- a/yarn-project/archiver/src/test/noop_l1_archiver.ts +++ b/yarn-project/archiver/src/test/noop_l1_archiver.ts @@ -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'; @@ -30,7 +31,7 @@ class NoopL1Synchronizer implements FunctionsOf { return 0n; } getL1Timestamp(): bigint | undefined { - return 0n; + return undefined; } testEthereumNodeSynced(): Promise { return Promise.resolve(); @@ -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 { + return Promise.resolve(SlotNumber(Number.MAX_SAFE_INTEGER)); + } } /** Creates an archiver with mocked L1 connectivity for testing. */ diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.ts b/yarn-project/aztec-node/src/sentinel/sentinel.ts index 23f4cb21a613..eed7d863513a 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.ts @@ -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; } diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index a6be4b34799a..f53b3b0504b5 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -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; } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 9a75cc3a5935..c30a93fb770e 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -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({ @@ -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(); }); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 7da938a6c828..d75788ea3cf4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -14,7 +14,7 @@ import type { P2P } from '@aztec/p2p'; import type { SlasherClientInterface } from '@aztec/slasher'; import type { BlockData, L2BlockSink, L2BlockSource, ValidateCheckpointResult } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; -import { getSlotAtTimestamp, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers'; +import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers'; import { type ResolvedSequencerConfig, type SequencerConfig, @@ -281,8 +281,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter { - // Check that the archiver and dependencies have synced to the previous L1 slot at least + // Check that the archiver has fully synced the L2 slot before the one we want to propose in. // TODO(#14766): Archiver reports L1 timestamp based on L1 blocks seen, which means that a missed L1 block will // cause the archiver L1 timestamp to fall behind, and cause this sequencer to start processing one L1 slot later. - const l1Timestamp = await this.l2BlockSource.getL1Timestamp(); - const { slot, ts } = args; - if (l1Timestamp === undefined || l1Timestamp + BigInt(this.l1Constants.ethereumSlotDuration) < ts) { + const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber(); + const { slot } = args; + if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) { this.log.debug(`Cannot propose block at next L2 slot ${slot} due to pending sync from L1`, { slot, - ts, - l1Timestamp, + syncedL2Slot, }); return undefined; } @@ -524,7 +522,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter { - const { pendingChainValidationStatus, l1Timestamp } = syncedTo; + const { pendingChainValidationStatus, syncedL2Slot } = syncedTo; if (pendingChainValidationStatus.valid) { return; } @@ -735,7 +733,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter; /** - * Returns the current L2 slot number based on the currently synced L1 timestamp. + * Returns the last L2 slot number that has been fully synchronized from L1. + * An L2 slot is fully synced when all L1 blocks that fall within its time range have been processed. */ - getL2SlotNumber(): Promise; + getSyncedL2SlotNumber(): Promise; /** - * Returns the current L2 epoch number based on the currently synced L1 timestamp. + * Returns the last L2 epoch number that has been fully synchronized from L1. + * An epoch is fully synced when all its L2 slots have been fully synced. */ - getL2EpochNumber(): Promise; + getSyncedL2EpochNumber(): Promise; /** * Returns all checkpointed block headers for a given epoch. diff --git a/yarn-project/stdlib/src/interfaces/archiver.test.ts b/yarn-project/stdlib/src/interfaces/archiver.test.ts index 710118ceb5e9..c996c11d7178 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.test.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.test.ts @@ -185,13 +185,13 @@ describe('ArchiverApiSchema', () => { expect(result).toBeInstanceOf(TxReceipt); }); - it('getL2SlotNumber', async () => { - const result = await context.client.getL2SlotNumber(); + it('getSyncedL2SlotNumber', async () => { + const result = await context.client.getSyncedL2SlotNumber(); expect(result).toBe(SlotNumber(1)); }); - it('getL2EpochNumber', async () => { - const result = await context.client.getL2EpochNumber(); + it('getSyncedL2EpochNumber', async () => { + const result = await context.client.getSyncedL2EpochNumber(); expect(result).toBe(EpochNumber(1)); }); @@ -508,10 +508,10 @@ class MockArchiver implements ArchiverApi { expect(txHash).toBeInstanceOf(TxHash); return Promise.resolve(TxReceipt.empty()); } - getL2SlotNumber(): Promise { + getSyncedL2SlotNumber(): Promise { return Promise.resolve(SlotNumber(1)); } - getL2EpochNumber(): Promise { + getSyncedL2EpochNumber(): Promise { return Promise.resolve(EpochNumber(1)); } async getCheckpointsForEpoch(epochNumber: EpochNumber): Promise { diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 949c66575040..16ed315e131c 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -114,8 +114,8 @@ export const ArchiverApiSchema: ApiSchemaFor = { getL2BlockByArchive: z.function().args(schemas.Fr).returns(L2Block.schema.optional()), getTxEffect: z.function().args(TxHash.schema).returns(indexedTxSchema().optional()), getSettledTxReceipt: z.function().args(TxHash.schema).returns(TxReceipt.schema.optional()), - getL2SlotNumber: z.function().args().returns(schemas.SlotNumber.optional()), - getL2EpochNumber: z.function().args().returns(EpochNumberSchema.optional()), + getSyncedL2SlotNumber: z.function().args().returns(schemas.SlotNumber.optional()), + getSyncedL2EpochNumber: z.function().args().returns(EpochNumberSchema.optional()), getCheckpointsForEpoch: z.function().args(EpochNumberSchema).returns(z.array(Checkpoint.schema)), getCheckpointsDataForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointDataSchema)), getCheckpointedBlocksForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointedL2Block.schema)), diff --git a/yarn-project/stdlib/src/validators/errors.ts b/yarn-project/stdlib/src/validators/errors.ts index 24c082518b14..ab10f044c23e 100644 --- a/yarn-project/stdlib/src/validators/errors.ts +++ b/yarn-project/stdlib/src/validators/errors.ts @@ -36,6 +36,15 @@ export class FailedToReExecuteTransactionsError extends ValidatorError { } } +export class ReExInitialStateMismatchError extends ValidatorError { + constructor( + public readonly expectedArchiveRoot: Fr, + public readonly actualArchiveRoot: Fr, + ) { + super('Re-execution initial state mismatch'); + } +} + export class ReExStateMismatchError extends ValidatorError { constructor( public readonly expectedArchiveRoot: Fr, diff --git a/yarn-project/txe/src/state_machine/archiver.ts b/yarn-project/txe/src/state_machine/archiver.ts index c601ed33ce85..31b9dcbe9221 100644 --- a/yarn-project/txe/src/state_machine/archiver.ts +++ b/yarn-project/txe/src/state_machine/archiver.ts @@ -79,12 +79,12 @@ export class TXEArchiver extends ArchiverDataSourceBase { }; } - public getL2SlotNumber(): Promise { - throw new Error('TXE Archiver does not implement "getL2SlotNumber"'); + public getSyncedL2SlotNumber(): Promise { + throw new Error('TXE Archiver does not implement "getSyncedL2SlotNumber"'); } - public getL2EpochNumber(): Promise { - throw new Error('TXE Archiver does not implement "getL2EpochNumber"'); + public getSyncedL2EpochNumber(): Promise { + throw new Error('TXE Archiver does not implement "getSyncedL2EpochNumber"'); } public isEpochComplete(_epochNumber: EpochNumber): Promise { diff --git a/yarn-project/validator-client/src/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index 2bb4f0d8d555..43c890bdafa8 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -15,9 +15,11 @@ import { Gas } from '@aztec/stdlib/gas'; import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal } from '@aztec/stdlib/p2p'; +import { MerkleTreeId } from '@aztec/stdlib/trees'; import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx'; import { ReExFailedTxsError, + ReExInitialStateMismatchError, ReExStateMismatchError, ReExTimeoutError, TransactionsNotAvailableError, @@ -30,6 +32,7 @@ import type { ValidatorMetrics } from './metrics.js'; export type BlockProposalValidationFailureReason = | 'invalid_proposal' | 'parent_block_not_found' + | 'block_source_not_synced' | 'parent_block_wrong_slot' | 'in_hash_mismatch' | 'global_variables_mismatch' @@ -37,6 +40,7 @@ export type BlockProposalValidationFailureReason = | 'txs_not_available' | 'state_mismatch' | 'failed_txs' + | 'initial_state_mismatch' | 'timeout' | 'unknown_error'; @@ -138,7 +142,13 @@ export class BlockProposalHandler { return { isValid: false, reason: 'invalid_proposal' }; } - const proposalInfo = { ...proposal.toBlockInfo(), proposer: proposer.toString() }; + const proposalInfo = { + ...proposal.toBlockInfo(), + proposer: proposer.toString(), + blockNumber: undefined as BlockNumber | undefined, + checkpointNumber: undefined as CheckpointNumber | undefined, + }; + this.log.info(`Processing proposal for slot ${slotNumber}`, { ...proposalInfo, txHashes: proposal.txHashes.map(t => t.toString()), @@ -152,7 +162,20 @@ export class BlockProposalHandler { return { isValid: false, reason: 'invalid_proposal' }; } - // Check that the parent proposal is a block we know, otherwise reexecution would fail + // Ensure the block source is synced before checking for existing blocks, + // since a pending checkpoint prune may remove blocks we'd otherwise find. + // This affects mostly the block_number_already_exists check, since a pending + // checkpoint prune could remove a block that would conflict with this proposal. + // TODO(@Maddiaa0): This may break staggered slots. + const blockSourceSync = await this.waitForBlockSourceSync(slotNumber); + if (!blockSourceSync) { + this.log.warn(`Block source is not synced, skipping processing`, proposalInfo); + return { isValid: false, reason: 'block_source_not_synced' }; + } + + // Check that the parent proposal is a block we know, otherwise reexecution would fail. + // If we don't find it immediately, we keep retrying for a while; it may be we still + // need to process other block proposals to get to it. const parentBlock = await this.getParentBlock(proposal); if (parentBlock === undefined) { this.log.warn(`Parent block for proposal not found, skipping processing`, proposalInfo); @@ -174,6 +197,7 @@ export class BlockProposalHandler { parentBlock === 'genesis' ? BlockNumber(INITIAL_L2_BLOCK_NUM) : BlockNumber(parentBlock.header.getBlockNumber() + 1); + proposalInfo.blockNumber = blockNumber; // Check that this block number does not exist already const existingBlock = await this.blockSource.getBlockHeader(blockNumber); @@ -189,7 +213,7 @@ export class BlockProposalHandler { deadline: this.getReexecutionDeadline(slotNumber, config), }); - // If reexecution is disabled, bail. We are just interested in triggering tx collection. + // If reexecution is disabled, bail. We were just interested in triggering tx collection. if (!shouldReexecute) { this.log.info( `Received valid block ${blockNumber} proposal at index ${proposal.indexWithinCheckpoint} on slot ${slotNumber}`, @@ -204,6 +228,7 @@ export class BlockProposalHandler { return { isValid: false, blockNumber, reason: checkpointResult.reason }; } const checkpointNumber = checkpointResult.checkpointNumber; + proposalInfo.checkpointNumber = checkpointNumber; // Check that I have the same set of l1ToL2Messages as the proposal const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber); @@ -425,8 +450,46 @@ export class BlockProposalHandler { return new Date(nextSlotTimestampSeconds * 1000); } - private getReexecuteFailureReason(err: any) { - if (err instanceof ReExStateMismatchError) { + /** Waits for the block source to sync L1 data up to at least the slot before the given one. */ + private async waitForBlockSourceSync(slot: SlotNumber): Promise { + const deadline = this.getReexecutionDeadline(slot, this.checkpointsBuilder.getConfig()); + const timeoutMs = deadline.getTime() - this.dateProvider.now(); + if (slot === 0) { + return true; + } + + // Make a quick check before triggering an archiver sync + const syncedSlot = await this.blockSource.getSyncedL2SlotNumber(); + if (syncedSlot !== undefined && syncedSlot + 1 >= slot) { + return true; + } + + try { + // Trigger an immediate sync of the block source, and wait until it reports being synced to the required slot + return await retryUntil( + async () => { + await this.blockSource.syncImmediate(); + const syncedSlot = await this.blockSource.getSyncedL2SlotNumber(); + return syncedSlot !== undefined && syncedSlot + 1 >= slot; + }, + 'wait for block source sync', + timeoutMs / 1000, + 0.5, + ); + } catch (err) { + if (err instanceof TimeoutError) { + this.log.warn(`Timed out waiting for block source to sync to slot ${slot}`); + return false; + } else { + throw err; + } + } + } + + private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason { + if (err instanceof ReExInitialStateMismatchError) { + return 'initial_state_mismatch'; + } else if (err instanceof ReExStateMismatchError) { return 'state_mismatch'; } else if (err instanceof ReExFailedTxsError) { return 'failed_txs'; @@ -467,6 +530,13 @@ export class BlockProposalHandler { await this.worldState.syncImmediate(parentBlockNumber); await using fork = await this.worldState.fork(parentBlockNumber); + // Verify the fork's archive root matches the proposal's expected last archive. + // If they don't match, our world state synced to a different chain and reexecution would fail. + const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root); + if (!forkArchiveRoot.equals(proposal.blockHeader.lastArchive.root)) { + throw new ReExInitialStateMismatchError(proposal.blockHeader.lastArchive.root, forkArchiveRoot); + } + // Build checkpoint constants from proposal (excludes blockNumber which is per-block) const constants: CheckpointGlobalVariables = { chainId: new Fr(config.l1ChainId), diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index a8ad58ee8b9f..ab751b9b33ba 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -121,6 +121,8 @@ describe('ValidatorClient', () => { blockSource.getCheckpointedBlocksForEpoch.mockResolvedValue([]); blockSource.getCheckpointsDataForEpoch.mockResolvedValue([]); blockSource.getBlocksForSlot.mockResolvedValue([]); + blockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)); + blockSource.syncImmediate.mockResolvedValue(undefined); epochCache.isEscapeHatchOpenAtSlot.mockResolvedValue(false); l1ToL2MessageSource = mock(); txProvider = mock(); @@ -312,6 +314,7 @@ describe('ValidatorClient', () => { worldState.fork.mockResolvedValue({ close: () => Promise.resolve(), [Symbol.asyncDispose]: () => Promise.resolve(), + getTreeInfo: () => Promise.resolve({ root: proposal.blockHeader.lastArchive.root.toBuffer() }), } as never); }; @@ -529,6 +532,7 @@ describe('ValidatorClient', () => { blockSource.getBlockDataByArchive.mockResolvedValueOnce(undefined); blockSource.getBlockDataByArchive.mockResolvedValueOnce(undefined); const isValid = await validatorClient.validateBlockProposal(proposal, sender); + // Direct call returns undefined, then retryUntil: 2 undefined + 1 success = 4 total expect(blockSource.getBlockDataByArchive).toHaveBeenCalledTimes(4); expect(isValid).toBe(true); });