diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index b34c42661457..7c31c6176cfb 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -19,7 +19,7 @@ import { type L2Tips, type ValidateCheckpointResult, } from '@aztec/stdlib/block'; -import { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { type PendingCheckpointData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { type L1RollupConstants, getEpochAtSlot, @@ -209,6 +209,14 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra }); } + public async setPendingCheckpoint(pending: PendingCheckpointData): Promise { + await this.dataStore.blockStore.setPendingCheckpoint(pending); + } + + public setPipeliningTreeInProgress(value: bigint): Promise { + return this.store.setPipeliningTreeInProgress(value); + } + /** * Processes all queued blocks, adding them to the store. * Called at the beginning of each sync iteration. diff --git a/yarn-project/archiver/src/errors.ts b/yarn-project/archiver/src/errors.ts index 0b2ff13eb6a7..bb2d5ac07cb6 100644 --- a/yarn-project/archiver/src/errors.ts +++ b/yarn-project/archiver/src/errors.ts @@ -26,9 +26,13 @@ export class InitialCheckpointNumberNotSequentialError extends Error { } export class CheckpointNumberNotSequentialError extends Error { - constructor(newCheckpointNumber: number, previous: number | undefined) { + constructor( + newCheckpointNumber: number, + previous: number | undefined, + source: 'confirmed' | 'pending' = 'confirmed', + ) { super( - `Cannot insert new checkpoint ${newCheckpointNumber} given previous checkpoint number in batch is ${previous ?? 'undefined'}`, + `Cannot insert new checkpoint ${newCheckpointNumber} given previous ${source} checkpoint number is ${previous ?? 'undefined'}`, ); } } diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index 32d0acbc51de..80792bfa8fbf 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -6,7 +6,12 @@ import { isDefined } from '@aztec/foundation/types'; import type { FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type BlockData, type BlockHash, CheckpointedL2Block, L2Block, type L2Tips } from '@aztec/stdlib/block'; -import { Checkpoint, type CheckpointData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { + Checkpoint, + type CheckpointData, + type PendingCheckpointData, + PublishedCheckpoint, +} from '@aztec/stdlib/checkpoint'; import type { ContractClassPublic, ContractDataSource, ContractInstanceWithAddress } from '@aztec/stdlib/contract'; import { type L1RollupConstants, getSlotRangeForEpoch } from '@aztec/stdlib/epoch-helpers'; import type { GetContractClassLogsResponse, GetPublicLogsResponse } from '@aztec/stdlib/interfaces/client'; @@ -157,6 +162,10 @@ export abstract class ArchiverDataSourceBase return this.store.getSettledTxReceipt(txHash, this.l1Constants); } + public getPendingCheckpoint(): Promise { + return this.store.blockStore.getPendingCheckpoint(); + } + public isPendingChainInvalid(): Promise { return this.getPendingChainValidationStatus().then(status => !status.valid); } diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 33afde395721..b6440530f138 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -258,9 +258,10 @@ export class ArchiverL1Synchronizer implements Traceable { /** Prune all proposed local blocks that should have been checkpointed by now. */ private async pruneUncheckpointedBlocks(currentL1Timestamp: bigint) { - const [lastCheckpointedBlockNumber, lastProposedBlockNumber] = await Promise.all([ + const [lastCheckpointedBlockNumber, lastProposedBlockNumber, pendingCheckpoint] = await Promise.all([ this.store.getCheckpointedL2BlockNumber(), this.store.getLatestBlockNumber(), + this.store.blockStore.getPendingCheckpoint(), ]); // If there are no uncheckpointed blocks, we got nothing to do @@ -269,8 +270,17 @@ export class ArchiverL1Synchronizer implements Traceable { return; } - // What's the slot of the first uncheckpointed block? + // Don't prune blocks that are covered by a pending checkpoint (awaiting L1 submission from pipelining) const firstUncheckpointedBlockNumber = BlockNumber(lastCheckpointedBlockNumber + 1); + if (pendingCheckpoint) { + const lastPendingBlock = BlockNumber(pendingCheckpoint.startBlock + pendingCheckpoint.blockCount - 1); + if (lastPendingBlock >= firstUncheckpointedBlockNumber) { + this.log.trace(`Skipping prune: pending checkpoint covers blocks up to ${lastPendingBlock}`); + return; + } + } + + // What's the slot of the first uncheckpointed block? const [firstUncheckpointedBlockHeader] = await this.store.getBlockHeaders(firstUncheckpointedBlockNumber, 1); const firstUncheckpointedBlockSlot = firstUncheckpointedBlockHeader?.getSlot(); diff --git a/yarn-project/archiver/src/modules/validation.ts b/yarn-project/archiver/src/modules/validation.ts index 861b00e91a9f..726054151729 100644 --- a/yarn-project/archiver/src/modules/validation.ts +++ b/yarn-project/archiver/src/modules/validation.ts @@ -9,7 +9,7 @@ import { getAttestationInfoFromPayload, } from '@aztec/stdlib/block'; import type { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; -import { type L1RollupConstants, getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; +import { type L1RollupConstants, computeQuorum, getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { ConsensusPayload } from '@aztec/stdlib/p2p'; export type { ValidateCheckpointResult }; @@ -66,7 +66,7 @@ export async function validateCheckpointAttestations( return { valid: true }; } - const requiredAttestationCount = Math.floor((committee.length * 2) / 3) + 1; + const requiredAttestationCount = computeQuorum(committee.length); const failedValidationResult = (reason: TReason) => ({ valid: false as const, diff --git a/yarn-project/archiver/src/store/block_store.ts b/yarn-project/archiver/src/store/block_store.ts index fc534436c3f5..b10ec3c2798e 100644 --- a/yarn-project/archiver/src/store/block_store.ts +++ b/yarn-project/archiver/src/store/block_store.ts @@ -19,7 +19,12 @@ import { deserializeValidateCheckpointResult, serializeValidateCheckpointResult, } from '@aztec/stdlib/block'; -import { type CheckpointData, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { + type CheckpointData, + L1PublishedData, + type PendingCheckpointData, + PublishedCheckpoint, +} from '@aztec/stdlib/checkpoint'; import { type L1RollupConstants, getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; @@ -69,6 +74,16 @@ type CheckpointStorage = { attestations: Buffer[]; }; +/** Storage format for a pending checkpoint (attested but not yet L1-confirmed). */ +type PendingCheckpointStore = { + header: Buffer; + checkpointNumber: number; + startBlock: number; + blockCount: number; + totalManaUsed: string; + feeAssetPriceModifier: string; +}; + export type RemoveCheckpointsResult = { blocksRemoved: L2Block[] | undefined }; /** @@ -111,6 +126,8 @@ export class BlockStore { /** Index mapping block archive to block number */ #blockArchiveIndex: AztecAsyncMap; + #pendingCheckpoint: AztecAsyncSingleton; + #log = createLogger('archiver:block_store'); constructor(private db: AztecAsyncKVStore) { @@ -126,6 +143,7 @@ export class BlockStore { this.#pendingChainValidationStatus = db.openSingleton('archiver_pending_chain_validation_status'); this.#checkpoints = db.openMap('archiver_checkpoints'); this.#slotToCheckpoint = db.openMap('archiver_slot_to_checkpoint'); + this.#pendingCheckpoint = db.openSingleton('pending_checkpoint_data'); } /** @@ -161,6 +179,7 @@ export class BlockStore { // Extract the latest block and checkpoint numbers const previousBlockNumber = await this.getLatestBlockNumber(); + const pendingCheckpointNumber = await this.getPendingCheckpointNumber(); const previousCheckpointNumber = await this.getLatestCheckpointNumber(); // Verify we're not overwriting checkpointed blocks @@ -179,9 +198,19 @@ export class BlockStore { throw new BlockNumberNotSequentialError(blockNumber, previousBlockNumber); } - // The same check as above but for checkpoints - if (!opts.force && previousCheckpointNumber !== blockCheckpointNumber - 1) { - throw new CheckpointNumberNotSequentialError(blockCheckpointNumber, previousCheckpointNumber); + // The same check as above but for checkpoints. Accept the block if either the confirmed + // checkpoint or the pending (locally validated but not yet confirmed) checkpoint matches. + const expectedCheckpointNumber = blockCheckpointNumber - 1; + if ( + !opts.force && + previousCheckpointNumber !== expectedCheckpointNumber && + pendingCheckpointNumber !== expectedCheckpointNumber + ) { + const [reported, source]: [CheckpointNumber, 'confirmed' | 'pending'] = + pendingCheckpointNumber > previousCheckpointNumber + ? [pendingCheckpointNumber, 'pending'] + : [previousCheckpointNumber, 'confirmed']; + throw new CheckpointNumberNotSequentialError(blockCheckpointNumber, reported, source); } // Extract the previous block if there is one and see if it is for the same checkpoint or not @@ -326,6 +355,13 @@ export class BlockStore { await this.#slotToCheckpoint.set(checkpoint.checkpoint.header.slotNumber, checkpoint.checkpoint.number); } + // Clear the pending checkpoint if any of the confirmed checkpoints match or supersede it + const pendingCheckpointNumber = await this.getPendingCheckpointNumber(); + const lastConfirmedCheckpointNumber = checkpoints[checkpoints.length - 1].checkpoint.number; + if (pendingCheckpointNumber <= lastConfirmedCheckpointNumber) { + await this.#pendingCheckpoint.delete(); + } + await this.#lastSynchedL1Block.set(checkpoints[checkpoints.length - 1].l1.blockNumber); return true; }); @@ -423,6 +459,12 @@ export class BlockStore { this.#log.debug(`Removed checkpoint ${c}`); } + // Clear any pending checkpoint that was removed + const pendingCheckpointNumber = await this.getPendingCheckpointNumber(); + if (pendingCheckpointNumber > checkpointNumber) { + await this.#pendingCheckpoint.delete(); + } + return { blocksRemoved }; }); } @@ -576,6 +618,34 @@ export class BlockStore { return CheckpointNumber(latestCheckpointNumber); } + async getPendingCheckpoint(): Promise { + const stored = await this.#pendingCheckpoint.getAsync(); + if (!stored) { + return undefined; + } + return { + checkpointNumber: CheckpointNumber(stored.checkpointNumber), + header: CheckpointHeader.fromBuffer(stored.header), + startBlock: BlockNumber(stored.startBlock), + blockCount: stored.blockCount, + totalManaUsed: BigInt(stored.totalManaUsed ?? '0'), + feeAssetPriceModifier: BigInt(stored.feeAssetPriceModifier ?? '0'), + }; + } + + async getPendingCheckpointNumber(): Promise { + const pending = await this.getPendingCheckpoint(); + return CheckpointNumber(pending?.checkpointNumber ?? INITIAL_CHECKPOINT_NUMBER - 1); + } + + async getPendingCheckpointL2BlockNumber(): Promise { + const pending = await this.getPendingCheckpoint(); + if (!pending) { + return BlockNumber(INITIAL_L2_BLOCK_NUM - 1); + } + return BlockNumber(pending.startBlock + pending.blockCount - 1); + } + async getCheckpointedBlock(number: BlockNumber): Promise { const blockStorage = await this.#blocks.getAsync(number); if (!blockStorage) { @@ -950,6 +1020,30 @@ export class BlockStore { return this.#lastSynchedL1Block.set(l1BlockNumber); } + /** Sets the pending checkpoint (quorum-attested but not yet L1-confirmed). Only accepts confirmed + 1. */ + async setPendingCheckpoint(pending: PendingCheckpointData) { + const current = await this.getPendingCheckpointNumber(); + if (pending.checkpointNumber <= current) { + this.#log.warn(`Ignoring stale pending checkpoint number ${pending.checkpointNumber} (current: ${current})`); + return; + } + const confirmed = await this.getLatestCheckpointNumber(); + if (pending.checkpointNumber !== confirmed + 1) { + this.#log.warn( + `Ignoring pending checkpoint ${pending.checkpointNumber}: expected ${confirmed + 1} (confirmed + 1)`, + ); + return; + } + await this.#pendingCheckpoint.set({ + header: pending.header.toBuffer(), + checkpointNumber: pending.checkpointNumber, + startBlock: pending.startBlock, + blockCount: pending.blockCount, + totalManaUsed: pending.totalManaUsed.toString(), + feeAssetPriceModifier: pending.feeAssetPriceModifier.toString(), + }); + } + async getProvenCheckpointNumber(): Promise { const [latestCheckpointNumber, provenCheckpointNumber] = await Promise.all([ this.getLatestCheckpointNumber(), diff --git a/yarn-project/archiver/src/store/kv_archiver_store.test.ts b/yarn-project/archiver/src/store/kv_archiver_store.test.ts index 92ae927f0ef3..79a18d4cff59 100644 --- a/yarn-project/archiver/src/store/kv_archiver_store.test.ts +++ b/yarn-project/archiver/src/store/kv_archiver_store.test.ts @@ -3579,6 +3579,270 @@ describe('KVArchiverDataStore', () => { }); }); + describe('pendingCheckpointNumber', () => { + it('returns initial value when no pending checkpoint is set', async () => { + const pending = await store.blockStore.getPendingCheckpointNumber(); + expect(pending).toBe(INITIAL_CHECKPOINT_NUMBER - 1); + }); + + it('stores and retrieves pending checkpoint number', async () => { + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + const pending = await store.blockStore.getPendingCheckpointNumber(); + expect(pending).toBe(1); + }); + + it('stores and retrieves pending checkpoint data with fee fields', async () => { + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 12345n, + feeAssetPriceModifier: -75n, + }); + const pending = await store.blockStore.getPendingCheckpoint(); + expect(pending).toBeDefined(); + expect(pending!.checkpointNumber).toBe(1); + expect(pending!.totalManaUsed).toBe(12345n); + expect(pending!.feeAssetPriceModifier).toBe(-75n); + }); + + it('clears pending checkpoint when confirmed checkpoints are added', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await store.addCheckpoints([checkpoint1]); + + // Set pending checkpoint to 2 (attested but not yet on L1) + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + expect(await store.blockStore.getPendingCheckpointNumber()).toBe(2); + + // Confirm checkpoint 2 on L1 + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await store.addCheckpoints([checkpoint2]); + + // Pending checkpoint should be cleared + const pending = await store.blockStore.getPendingCheckpointNumber(); + expect(pending).toBe(INITIAL_CHECKPOINT_NUMBER - 1); + }); + + it('ignores pending checkpoint that is more than 1 ahead of confirmed', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await store.addCheckpoints([checkpoint1]); + + // Try to set pending checkpoint to 3 (confirmed=1, expected=2) + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(3), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Pending checkpoint should remain unset (3 !== 1 + 1) + expect(await store.blockStore.getPendingCheckpointNumber()).toBe(INITIAL_CHECKPOINT_NUMBER - 1); + }); + + it('ignores pending checkpoint that equals the confirmed checkpoint', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await store.addCheckpoints([checkpoint1]); + + // Try to set pending checkpoint to 1 (confirmed=1, expected=2) + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Pending checkpoint should remain unset (1 !== 1 + 1) + expect(await store.blockStore.getPendingCheckpointNumber()).toBe(INITIAL_CHECKPOINT_NUMBER - 1); + }); + + it('clears pending checkpoint when checkpoints are removed past it', async () => { + // Add checkpoints 1 and 2 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await store.addCheckpoints([checkpoint1, checkpoint2]); + + // Set pending checkpoint to 3 + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(3), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Remove checkpoints after 1 (removes checkpoint 2, and pending 3 should be cleared) + await store.removeCheckpointsAfter(CheckpointNumber(1)); + + const pending = await store.blockStore.getPendingCheckpointNumber(); + expect(pending).toBe(INITIAL_CHECKPOINT_NUMBER - 1); + }); + + it('does not clear pending checkpoint when removing checkpoints before it', async () => { + // Add checkpoints 1, 2 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await store.addCheckpoints([checkpoint1, checkpoint2]); + + // Set pending to 3 (confirmed=2, 3===2+1 ✓) + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(3), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Remove checkpoints after 2 (nothing removed since latest is 2, pending=3 stays) + await store.removeCheckpointsAfter(CheckpointNumber(2)); + + expect(await store.blockStore.getPendingCheckpointNumber()).toBe(3); + }); + + it('allows addProposedBlocks when pending checkpoint matches expected', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await store.addCheckpoints([checkpoint1]); + + // Set pending checkpoint to 2 (attested but not on L1 yet) + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Add a block for checkpoint 2 — this should succeed because + // confirmed checkpoint (1) matches expectedCheckpointNumber (2 - 1 = 1) + const lastBlockArchive = checkpoint1.checkpoint.blocks[0].archive; + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(2), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: lastBlockArchive, + }); + + await expect(store.addProposedBlock(block2)).resolves.toBe(true); + }); + + it('throws with pending checkpoint value when neither confirmed nor pending matches', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await store.addCheckpoints([checkpoint1]); + + // Set pending checkpoint to 2 + await store.blockStore.setPendingCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Try to add a block for checkpoint 4 (expected = 3, confirmed = 1, pending = 2 — neither matches) + const lastBlockArchive = checkpoint1.checkpoint.blocks[0].archive; + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(4), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: lastBlockArchive, + }); + + await expect(store.addProposedBlock(block2)).rejects.toThrow( + // Error should report the pending checkpoint number (2), not the confirmed one (1) + 'Cannot insert new checkpoint 4 given previous pending checkpoint number is 2', + ); + }); + + it('throws with confirmed checkpoint value when pending is not set', async () => { + // Add checkpoint 1 (no pending set, so pending defaults to 0) + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await store.addCheckpoints([checkpoint1]); + + // Try to add a block for checkpoint 4 (expected = 3, confirmed = 1, pending = 0) + // Error should report confirmed (1) since it's higher than the default pending (0) + const lastBlockArchive = checkpoint1.checkpoint.blocks[0].archive; + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(4), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: lastBlockArchive, + }); + + await expect(store.addProposedBlock(block2)).rejects.toThrow( + 'Cannot insert new checkpoint 4 given previous confirmed checkpoint number is 1', + ); + }); + }); + describe('removeBlocksAfterBlock', () => { it('removes blocks with number > given blockNumber', async () => { // Create blocks for initial checkpoint diff --git a/yarn-project/archiver/src/store/kv_archiver_store.ts b/yarn-project/archiver/src/store/kv_archiver_store.ts index 723172fdcb53..1b38e979cb08 100644 --- a/yarn-project/archiver/src/store/kv_archiver_store.ts +++ b/yarn-project/archiver/src/store/kv_archiver_store.ts @@ -277,8 +277,12 @@ export class KVArchiverDataStore implements ContractDataSource { * Also removes ALL blocks (both checkpointed and uncheckpointed) after the last block of the given checkpoint. * @param checkpointNumber - Remove all checkpoints strictly after this one. */ - removeCheckpointsAfter(checkpointNumber: CheckpointNumber): Promise { - return this.#blockStore.removeCheckpointsAfter(checkpointNumber); + async removeCheckpointsAfter(checkpointNumber: CheckpointNumber): Promise { + const result = await this.#blockStore.removeCheckpointsAfter(checkpointNumber); + // The block store may have cleared the pending checkpoint during pruning. + // Clear the pipelining boundary so it doesn't outlive the pending checkpoint. + await this.#messageStore.clearPipeliningTreeInProgress(); + return result; } /** @@ -286,8 +290,12 @@ export class KVArchiverDataStore implements ContractDataSource { * @param checkpoints The collection of checkpoints to be added * @returns True if the operation is successful */ - addCheckpoints(checkpoints: PublishedCheckpoint[]): Promise { - return this.#blockStore.addCheckpoints(checkpoints); + async addCheckpoints(checkpoints: PublishedCheckpoint[]): Promise { + const result = await this.#blockStore.addCheckpoints(checkpoints); + // The block store clears the pending checkpoint when confirmed checkpoints supersede it. + // Clear the pipelining boundary so it doesn't outlive the pending checkpoint. + await this.#messageStore.clearPipeliningTreeInProgress(); + return result; } /** @@ -611,6 +619,11 @@ export class KVArchiverDataStore implements ContractDataSource { return this.#messageStore.setInboxTreeInProgress(value); } + /** Sets the pipelining tree-in-progress boundary for building ahead of L1 confirmation. */ + public setPipeliningTreeInProgress(value: bigint): Promise { + return this.#messageStore.setPipeliningTreeInProgress(value); + } + /** Returns an async iterator to all L1 to L2 messages on the range. */ public iterateL1ToL2Messages(range: CustomRange = {}): AsyncIterableIterator { return this.#messageStore.iterateL1ToL2Messages(range); diff --git a/yarn-project/archiver/src/store/l2_tips_cache.ts b/yarn-project/archiver/src/store/l2_tips_cache.ts index 64a0192e7624..cd4de54e5518 100644 --- a/yarn-project/archiver/src/store/l2_tips_cache.ts +++ b/yarn-project/archiver/src/store/l2_tips_cache.ts @@ -26,9 +26,16 @@ export class L2TipsCache { } private async loadFromStore(): Promise { - const [latestBlockNumber, provenBlockNumber, checkpointedBlockNumber, finalizedBlockNumber] = await Promise.all([ + const [ + latestBlockNumber, + provenBlockNumber, + pendingCheckpointBlockNumber, + checkpointedBlockNumber, + finalizedBlockNumber, + ] = await Promise.all([ this.blockStore.getLatestBlockNumber(), this.blockStore.getProvenBlockNumber(), + this.blockStore.getPendingCheckpointL2BlockNumber(), this.blockStore.getCheckpointedL2BlockNumber(), this.blockStore.getFinalizedL2BlockNumber(), ]); @@ -42,19 +49,28 @@ export class L2TipsCache { const getBlockData = (blockNumber: BlockNumber) => blockNumber > beforeInitialBlockNumber ? this.blockStore.getBlockData(blockNumber) : genesisBlockHeader; - const [latestBlockData, provenBlockData, checkpointedBlockData, finalizedBlockData] = await Promise.all( - [latestBlockNumber, provenBlockNumber, checkpointedBlockNumber, finalizedBlockNumber].map(getBlockData), - ); + const [latestBlockData, provenBlockData, pendingCheckpointBlockData, checkpointedBlockData, finalizedBlockData] = + await Promise.all( + [ + latestBlockNumber, + provenBlockNumber, + pendingCheckpointBlockNumber, + checkpointedBlockNumber, + finalizedBlockNumber, + ].map(getBlockData), + ); if (!latestBlockData || !provenBlockData || !finalizedBlockData || !checkpointedBlockData) { throw new Error('Failed to load block data for L2 tips'); } - const [provenCheckpointId, finalizedCheckpointId, checkpointedCheckpointId] = await Promise.all([ - this.getCheckpointIdForBlock(provenBlockData), - this.getCheckpointIdForBlock(finalizedBlockData), - this.getCheckpointIdForBlock(checkpointedBlockData), - ]); + const [provenCheckpointId, finalizedCheckpointId, pendingCheckpointId, checkpointedCheckpointId] = + await Promise.all([ + this.getCheckpointIdForBlock(provenBlockData), + this.getCheckpointIdForBlock(finalizedBlockData), + this.getCheckpointIdForPendingCheckpoint(), + this.getCheckpointIdForBlock(checkpointedBlockData), + ]); return { proposed: { number: latestBlockNumber, hash: latestBlockData.blockHash.toString() }, @@ -62,6 +78,12 @@ export class L2TipsCache { block: { number: provenBlockNumber, hash: provenBlockData.blockHash.toString() }, checkpoint: provenCheckpointId, }, + pendingCheckpoint: pendingCheckpointBlockData + ? { + block: { number: pendingCheckpointBlockNumber, hash: pendingCheckpointBlockData.blockHash.toString() }, + checkpoint: pendingCheckpointId, + } + : undefined, finalized: { block: { number: finalizedBlockNumber, hash: finalizedBlockData.blockHash.toString() }, checkpoint: finalizedCheckpointId, @@ -73,6 +95,20 @@ export class L2TipsCache { }; } + private async getCheckpointIdForPendingCheckpoint(): Promise { + const checkpointData = await this.blockStore.getPendingCheckpoint(); + if (!checkpointData) { + return { + number: CheckpointNumber.ZERO, + hash: GENESIS_BLOCK_HEADER_HASH.toString(), + }; + } + return { + number: checkpointData.checkpointNumber, + hash: checkpointData.header.hash().toString(), + }; + } + private async getCheckpointIdForBlock(blockData: Pick): Promise { const checkpointData = await this.blockStore.getCheckpointData(blockData.checkpointNumber); if (!checkpointData) { diff --git a/yarn-project/archiver/src/store/message_store.ts b/yarn-project/archiver/src/store/message_store.ts index ab0029800b1b..81ab474d746f 100644 --- a/yarn-project/archiver/src/store/message_store.ts +++ b/yarn-project/archiver/src/store/message_store.ts @@ -32,6 +32,16 @@ export class MessageStoreError extends Error { } } +function maxBigint(a: bigint | undefined, b: bigint | undefined): bigint | undefined { + if (a === undefined) { + return b; + } + if (b === undefined) { + return a; + } + return a > b ? a : b; +} + export class MessageStore { /** Maps from message index to serialized InboxMessage */ #l1ToL2Messages: AztecAsyncMap; @@ -43,6 +53,9 @@ export class MessageStore { #totalMessageCount: AztecAsyncSingleton; /** Stores the checkpoint number whose message tree is currently being filled on L1. */ #inboxTreeInProgress: AztecAsyncSingleton; + /** Stores the pipelining tree-in-progress boundary, set by the sequencer when building ahead. + * Separate from the synced value so normal callers still use the strict L1-synced guard. */ + #pipeliningTreeInProgress: AztecAsyncSingleton; #log = createLogger('archiver:message_store'); @@ -52,6 +65,7 @@ export class MessageStore { this.#lastSynchedL1Block = db.openSingleton('archiver_last_l1_block_id'); this.#totalMessageCount = db.openSingleton('archiver_l1_to_l2_message_count'); this.#inboxTreeInProgress = db.openSingleton('archiver_inbox_tree_in_progress'); + this.#pipeliningTreeInProgress = db.openSingleton('archiver_pipelining_tree_in_progress'); } public async getTotalL1ToL2MessageCount(): Promise { @@ -199,10 +213,34 @@ export class MessageStore { await this.#inboxTreeInProgress.set(value); } + /** Returns the pipelining tree-in-progress boundary, or undefined if not set. */ + public getPipeliningTreeInProgress(): Promise { + return this.#pipeliningTreeInProgress.getAsync(); + } + + /** Sets the pipelining tree-in-progress boundary. Called when a checkpoint proposal arrives + * via P2P, to reflect that L1 has already sealed trees beyond what the archiver has synced. */ + public async setPipeliningTreeInProgress(value: bigint): Promise { + await this.#pipeliningTreeInProgress.set(value); + } + + /** Clears the pipelining tree-in-progress boundary. Called when the pending checkpoint + * is cleared (confirmed on L1 or pruned). */ + public async clearPipeliningTreeInProgress(): Promise { + await this.#pipeliningTreeInProgress.delete(); + } + public async getL1ToL2Messages(checkpointNumber: CheckpointNumber): Promise { - const treeInProgress = await this.#inboxTreeInProgress.getAsync(); - if (treeInProgress !== undefined && BigInt(checkpointNumber) >= treeInProgress) { - throw new L1ToL2MessagesNotReadyError(checkpointNumber, treeInProgress); + // Check the effective tree-in-progress boundary: max of L1-synced and pipelining values. + // The pipelining value is set when a checkpoint proposal arrives via P2P, reflecting that L1 + // has already sealed trees beyond what the archiver's L1 sync has picked up. + const [synced, pipelining] = await Promise.all([ + this.#inboxTreeInProgress.getAsync(), + this.#pipeliningTreeInProgress.getAsync(), + ]); + const effectiveTreeInProgress = maxBigint(synced, pipelining); + if (effectiveTreeInProgress !== undefined && BigInt(checkpointNumber) >= effectiveTreeInProgress) { + throw new L1ToL2MessagesNotReadyError(checkpointNumber, effectiveTreeInProgress); } const messages: Fr[] = []; diff --git a/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts b/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts index f3b72a1c940f..afa3cd397666 100644 --- a/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts +++ b/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts @@ -44,6 +44,7 @@ export class MockL1ToL2MessageSource implements L1ToL2MessageSource { checkpointed: tip, proven: tip, finalized: tip, + pendingCheckpoint: undefined, }); } } 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 d3f46943b65a..b2d409a5eb9a 100644 --- a/yarn-project/archiver/src/test/mock_l2_block_source.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -16,7 +16,13 @@ import { type L2Tips, type ValidateCheckpointResult, } from '@aztec/stdlib/block'; -import { Checkpoint, type CheckpointData, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { + Checkpoint, + type CheckpointData, + L1PublishedData, + type PendingCheckpointData, + PublishedCheckpoint, +} from '@aztec/stdlib/checkpoint'; import type { ContractClassPublic, ContractDataSource, ContractInstanceWithAddress } from '@aztec/stdlib/contract'; import { EmptyL1RollupConstants, @@ -450,6 +456,7 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { checkpointed: makeTipId(checkpointedBlockId), proven: makeTipId(provenBlockId), finalized: makeTipId(finalizedBlockId), + pendingCheckpoint: undefined, }; } @@ -531,6 +538,10 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve({ valid: true }); } + getPendingCheckpoint(): Promise { + return Promise.resolve(undefined); + } + /** Returns checkpoints whose slot falls within the given epoch. */ private getCheckpointsInEpoch(epochNumber: EpochNumber): Checkpoint[] { const epochDuration = DefaultL1ContractsConfig.aztecEpochDuration; diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index 37f3adca164f..81bbcfd60207 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -1,7 +1,14 @@ +import type { Archiver } from '@aztec/archiver'; import { TestCircuitVerifier } from '@aztec/bb-prover'; import { EpochCache } from '@aztec/epoch-cache'; import type { RollupContract } from '@aztec/ethereum/contracts'; -import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + EpochNumber, + IndexWithinCheckpoint, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; @@ -23,8 +30,13 @@ import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; import type { L2LogsSource, MerkleTreeReadOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; -import { mockTx } from '@aztec/stdlib/testing'; -import { MerkleTreeId, PublicDataTreeLeaf, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees'; +import { makeCheckpointProposal, mockTx } from '@aztec/stdlib/testing'; +import { + AppendOnlyTreeSnapshot, + MerkleTreeId, + PublicDataTreeLeaf, + PublicDataTreeLeafPreimage, +} from '@aztec/stdlib/trees'; import { BlockHeader, GlobalVariables, @@ -980,4 +992,98 @@ describe('aztec node', () => { expect(result).toEqual([[[[msg1]]], [[[msg2]]]]); }); }); + + describe('handleCheckpointProposalForPendingCheckpoint', () => { + let mockArchiver: MockProxy< + Pick + >; + + beforeEach(() => { + mockArchiver = mock(); + jest.spyOn(epochCache, 'isProposerPipeliningEnabled').mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('sets pending checkpoint from proposal archive', async () => { + const archive = Fr.random(); + const checkpoint = (await makeCheckpointProposal({ archiveRoot: archive })).toCore(); + + const blockData = { + checkpointNumber: CheckpointNumber(5), + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(3) }) }), + archive: AppendOnlyTreeSnapshot.empty(), + indexWithinCheckpoint: IndexWithinCheckpoint(2), + } as any; + mockArchiver.getBlockDataByArchive.mockResolvedValue(blockData); + + await (node as any).handleCheckpointProposalForPendingCheckpoint(checkpoint, mockArchiver); + + expect(mockArchiver.getBlockDataByArchive).toHaveBeenCalledWith(archive); + expect(mockArchiver.setPendingCheckpoint).toHaveBeenCalledWith( + expect.objectContaining({ + checkpointNumber: CheckpointNumber(5), + header: checkpoint.checkpointHeader, + startBlock: BlockNumber(1), + blockCount: 3, + totalManaUsed: checkpoint.checkpointHeader.totalManaUsed.toBigInt(), + feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, + }), + ); + expect(mockArchiver.setPipeliningTreeInProgress).toHaveBeenCalledWith(7n); + }); + + it('uses the proposal archive to look up block data', async () => { + const proposalArchive = Fr.random(); + const checkpoint = (await makeCheckpointProposal({ archiveRoot: proposalArchive })).toCore(); + + mockArchiver.getBlockDataByArchive.mockResolvedValue({ + checkpointNumber: CheckpointNumber(3), + header: BlockHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + } as any); + + await (node as any).handleCheckpointProposalForPendingCheckpoint(checkpoint, mockArchiver); + + expect(mockArchiver.getBlockDataByArchive).toHaveBeenCalledTimes(1); + expect(mockArchiver.getBlockDataByArchive).toHaveBeenCalledWith(proposalArchive); + }); + + it('calls syncImmediate and retries when block data is not found initially', async () => { + const archive = Fr.random(); + const checkpoint = (await makeCheckpointProposal({ archiveRoot: archive })).toCore(); + + const blockData = { + checkpointNumber: CheckpointNumber(3), + header: BlockHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + } as any; + + // First call returns undefined, second call succeeds + mockArchiver.getBlockDataByArchive.mockResolvedValueOnce(undefined).mockResolvedValueOnce(blockData); + + await (node as any).handleCheckpointProposalForPendingCheckpoint(checkpoint, mockArchiver); + + expect(mockArchiver.syncImmediate).toHaveBeenCalledTimes(1); + expect(mockArchiver.getBlockDataByArchive).toHaveBeenCalledTimes(2); + expect(mockArchiver.setPendingCheckpoint).toHaveBeenCalled(); + expect(mockArchiver.setPipeliningTreeInProgress).toHaveBeenCalled(); + }); + + it('does not set pending checkpoint when block data is not found after retry', async () => { + const checkpoint = (await makeCheckpointProposal()).toCore(); + + mockArchiver.getBlockDataByArchive.mockResolvedValue(undefined); + + await (node as any).handleCheckpointProposalForPendingCheckpoint(checkpoint, mockArchiver); + + expect(mockArchiver.syncImmediate).toHaveBeenCalled(); + expect(mockArchiver.setPendingCheckpoint).not.toHaveBeenCalled(); + expect(mockArchiver.setPipeliningTreeInProgress).not.toHaveBeenCalled(); + }, 15_000); + }); }); diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index be709fdc6557..6214fbafee64 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -14,6 +14,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; import { type Logger, createLogger } from '@aztec/foundation/log'; +import { retryUntil } from '@aztec/foundation/retry'; import { count } from '@aztec/foundation/string'; import { DateProvider, Timer } from '@aztec/foundation/timer'; import { MembershipWitness, SiblingPath } from '@aztec/foundation/trees'; @@ -23,6 +24,7 @@ import { createForwarderL1TxUtilsFromSigners, createL1TxUtilsFromSigners } from import { type P2P, type P2PClientDeps, + type PeerId, createP2PClient, createTxValidatorForAcceptingTxsOverRPC, getDefaultAllowedSetupFunctions, @@ -79,6 +81,7 @@ import { import type { DebugLogStore, LogFilter, SiloedTag, Tag, TxScopedL2Log } from '@aztec/stdlib/logs'; import { InMemoryDebugLogStore, NullDebugLogStore } from '@aztec/stdlib/logs'; import { InboxLeaf, type L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import type { CheckpointProposalCore } from '@aztec/stdlib/p2p'; import type { Offense, SlashPayloadRound } from '@aztec/stdlib/slashing'; import type { NullifierLeafPreimage, PublicDataTreeLeaf, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees'; import { MerkleTreeId, NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; @@ -587,6 +590,17 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { debugLogStore, ); + // Register a callback for all nodes to set the pending checkpoint number when a checkpoint proposal is received. + // This runs before the validator callback so the archiver knows about the pending checkpoint. + p2pClient.registerAllNodesCheckpointProposalHandler(async (checkpoint: CheckpointProposalCore, _sender: PeerId) => { + try { + await node.handleCheckpointProposalForPendingCheckpoint(checkpoint, archiver); + } catch (err) { + log.warn(`Failed to set pending checkpoint number on checkpoint proposal received`, { err }); + } + return undefined; + }); + return node; } @@ -1681,6 +1695,54 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { return block as BlockNumber; } + /** Derives the pending checkpoint data from a checkpoint proposal's archive and sets it on the archiver. */ + protected async handleCheckpointProposalForPendingCheckpoint( + checkpoint: CheckpointProposalCore, + archiver: Pick< + Archiver, + 'getBlockDataByArchive' | 'setPendingCheckpoint' | 'setPipeliningTreeInProgress' | 'syncImmediate' + >, + ): Promise { + if (this.epochCache.isProposerPipeliningEnabled()) { + let blockData = await archiver.getBlockDataByArchive(checkpoint.archive); + + // The checkpoint proposal often arrives before the last block finishes re-execution. + // Trigger syncs to flush any queued blocks, retrying until we find the data or give up. + if (!blockData) { + blockData = await retryUntil( + async () => { + await archiver.syncImmediate(); + return await archiver.getBlockDataByArchive(checkpoint.archive); + }, + 'block data for checkpoint proposal', + 3, + 0.1, + ).catch(() => undefined); + } + + if (blockData) { + await Promise.all([ + archiver.setPendingCheckpoint({ + header: checkpoint.checkpointHeader, + checkpointNumber: blockData.checkpointNumber, + startBlock: BlockNumber(blockData.header.getBlockNumber() - blockData.indexWithinCheckpoint), + blockCount: blockData.indexWithinCheckpoint + 1, + totalManaUsed: checkpoint.checkpointHeader.totalManaUsed.toBigInt(), + feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, + }), + // Advance the pipelining tree-in-progress boundary so the next pipelined checkpoint + // can read L1-to-L2 messages. This mirrors L1's inProgress = N + LAG after consume(N-1), + // where LAG=2 in production. Using +2 conservatively allows reading checkpoint N+1. + archiver.setPipeliningTreeInProgress(BigInt(blockData.checkpointNumber) + 2n), + ]); + } else { + this.log.debug(`Block data not found for checkpoint proposal archive, cannot set pending checkpoint`, { + archive: checkpoint.archive.toString(), + }); + } + } + } + /** * Ensure we fully sync the world state * @returns A promise that fulfils once the world state is synced diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts index 6c135a0b0d63..4769773ebbd7 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts @@ -28,7 +28,7 @@ import { EpochsTestContext } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 20); const NODE_COUNT = 4; -const EXPECTED_BLOCKS_PER_CHECKPOINT = 1; +const EXPECTED_BLOCKS_PER_CHECKPOINT = 3; // Send enough transactions to trigger multiple blocks within a checkpoint assuming 2 txs per block. const TX_COUNT = 10; @@ -72,6 +72,7 @@ describe('e2e_epochs/epochs_mbps_pipeline', () => { mockGossipSubNetwork: true, disableAnvilTestWatcher: true, startProverNode: true, + perBlockAllocationMultiplier: 1, aztecEpochDuration: 4, enforceTimeTable: true, ethereumSlotDuration: 4, diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 45f01b5834fc..aa2fbe76a00e 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -236,7 +236,13 @@ describe('L1Publisher integration', () => { checkpoint: { number: CheckpointNumber.fromBlockNumber(blockId.number), hash: blockId.hash }, }; - return { proposed: blockId, checkpointed: tipId, proven: tipId, finalized: tipId }; + return { + proposed: blockId, + checkpointed: tipId, + proven: tipId, + finalized: tipId, + pendingCheckpoint: undefined, + }; }, getBlockNumber(): Promise { return Promise.resolve(BlockNumber(blocks.at(-1)?.number ?? BlockNumber.ZERO)); diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index 3ecb033c1f02..f5e52fe46c04 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -351,15 +351,19 @@ export class EpochCache implements EpochCacheInterface { }; } - /** Returns the taget and next L2 slot in the next L1 slot */ + /** Returns the target and next L2 slot in the next L1 slot. Computes the current time once to avoid redundant calls. */ public getTargetAndNextSlot(): { targetSlot: SlotNumber; nextSlot: SlotNumber } { - const targetSlot = this.getTargetSlot(); - const next = this.getTargetEpochAndSlotInNextL1Slot(); + const nowSeconds = this.nowInSeconds(); + const offset = this.isProposerPipeliningEnabled() ? PROPOSER_PIPELINING_SLOT_OFFSET : 0; - return { - targetSlot, - nextSlot: next.slot, - }; + const currentSlot = getSlotAtTimestamp(nowSeconds, this.l1constants); + const targetSlot = SlotNumber(currentSlot + offset); + + const nextSlotTs = nowSeconds + BigInt(this.l1constants.ethereumSlotDuration); + const nextL1Slot = getSlotAtTimestamp(nextSlotTs, this.l1constants); + const nextSlot = SlotNumber(nextL1Slot + offset); + + return { targetSlot, nextSlot }; } /** diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 96d1c553ee1b..7d668b8dafcc 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -16,6 +16,7 @@ import { type Hex, type StateOverride, type WatchContractEventReturnType, + encodeAbiParameters, encodeFunctionData, getContract, hexToBigInt, @@ -783,12 +784,20 @@ export class RollupContract { account: `0x${string}` | Account, slotDuration: bigint, slotOffset: bigint, - opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, + opts: { + forcePendingCheckpointNumber?: CheckpointNumber; + forceArchive?: { checkpointNumber: CheckpointNumber; archive: Fr }; + } = {}, ): Promise<{ slot: SlotNumber; checkpointNumber: CheckpointNumber; timeOfNextL1Slot: bigint }> { const latestBlock = await this.client.getBlock(); const timeOfNextL1Slot = latestBlock.timestamp + slotDuration + slotOffset; const who = typeof account === 'string' ? account : account.address; + const stateOverride = RollupContract.mergeStateOverrides( + await this.makePendingCheckpointNumberOverride(opts.forcePendingCheckpointNumber), + opts.forceArchive ? this.makeArchiveOverride(opts.forceArchive.checkpointNumber, opts.forceArchive.archive) : [], + ); + try { const { result: [slot, checkpointNumber], @@ -798,7 +807,7 @@ export class RollupContract { functionName: 'canProposeAtTime', args: [timeOfNextL1Slot, `0x${archive.toString('hex')}`, who], account, - stateOverride: await this.makePendingCheckpointNumberOverride(opts.forcePendingCheckpointNumber), + stateOverride, }); return { @@ -834,6 +843,150 @@ export class RollupContract { ]; } + /** + * Returns a state override that sets tempCheckpointLogs[checkpointNumber].feeHeader to the compressed fee header. + * Used when simulating a propose call where the parent checkpoint hasn't landed on L1 yet (pipelining). + */ + public async makeFeeHeaderOverride(checkpointNumber: CheckpointNumber, feeHeader: FeeHeader): Promise { + const { epochDuration, proofSubmissionEpochs } = await this.getRollupConstants(); + const roundaboutSize = BigInt(epochDuration * (proofSubmissionEpochs + 1) + 1); + const circularIndex = BigInt(checkpointNumber) % roundaboutSize; + + // tempCheckpointLogs is at offset 2 in RollupStore + const tempCheckpointLogsMappingBase = hexToBigInt(RollupContract.stfStorageSlot) + 2n; + + // Solidity mapping slot: keccak256(abi.encode(key, baseSlot)) + const structBaseSlot = hexToBigInt( + keccak256( + encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [circularIndex, tempCheckpointLogsMappingBase]), + ), + ); + + // feeHeader is the 7th field (offset 6) in CompressedTempCheckpointLog + const feeHeaderSlot = structBaseSlot + 6n; + const compressed = RollupContract.compressFeeHeader(feeHeader); + + return [ + { + address: this.address, + stateDiff: [ + { + slot: `0x${feeHeaderSlot.toString(16).padStart(64, '0')}`, + value: `0x${compressed.toString(16).padStart(64, '0')}`, + }, + ], + }, + ]; + } + + /** + * Returns a state override that sets archives[checkpointNumber] to the given archive value. + * Used when simulating a canProposeAtTime call where the local archive differs from L1 + * (e.g. pipelining where the parent checkpoint hasn't landed on L1 yet). + */ + public makeArchiveOverride(checkpointNumber: CheckpointNumber, archive: Fr): StateOverride { + const archivesMappingBase = hexToBigInt(RollupContract.stfStorageSlot) + 1n; + const archiveSlot = hexToBigInt( + keccak256( + encodeAbiParameters( + [{ type: 'uint256' }, { type: 'uint256' }], + [BigInt(checkpointNumber), archivesMappingBase], + ), + ), + ); + return [ + { + address: this.address, + stateDiff: [ + { + slot: `0x${archiveSlot.toString(16).padStart(64, '0')}`, + value: archive.toString(), + }, + ], + }, + ]; + } + + /** Merges multiple StateOverride arrays, combining stateDiff entries for the same address. */ + public static mergeStateOverrides(...overrides: StateOverride[]): StateOverride { + type StateDiffEntry = { slot: `0x${string}`; value: `0x${string}` }; + const byAddress = new Map(); + for (const override of overrides) { + for (const entry of override) { + const key = entry.address.toLowerCase(); + const existing = byAddress.get(key); + if (existing) { + existing.stateDiff.push(...(entry.stateDiff ?? [])); + if (entry.balance !== undefined) { + existing.balance = entry.balance; + } + } else { + byAddress.set(key, { + address: entry.address, + balance: entry.balance, + stateDiff: [...(entry.stateDiff ?? [])], + }); + } + } + } + return [...byAddress.values()]; + } + + /** Compresses a FeeHeader into a uint256 matching FeeHeaderLib.compress() in FeeStructs.sol. */ + public static compressFeeHeader(feeHeader: FeeHeader): bigint { + const MASK_48_BITS = (1n << 48n) - 1n; + const MASK_64_BITS = (1n << 64n) - 1n; + const MASK_63_BITS = (1n << 63n) - 1n; + + let value = BigInt(feeHeader.manaUsed) & ((1n << 32n) - 1n); // bits [0:31] + value |= (feeHeader.excessMana < MASK_48_BITS ? feeHeader.excessMana : MASK_48_BITS) << 32n; // bits [32:79] + value |= (BigInt(feeHeader.ethPerFeeAsset) & MASK_48_BITS) << 80n; // bits [80:127] + value |= (feeHeader.congestionCost < MASK_64_BITS ? feeHeader.congestionCost : MASK_64_BITS) << 128n; // bits [128:191] + value |= (feeHeader.proverCost < MASK_63_BITS ? feeHeader.proverCost : MASK_63_BITS) << 192n; // bits [192:254] + value |= 1n << 255n; // preheat flag + return value; + } + + /** Computes the fee header for a child checkpoint given parent fee header and child data. */ + public static computeChildFeeHeader( + parentFeeHeader: FeeHeader, + childManaUsed: bigint, + feeAssetPriceModifier: bigint, + manaTarget: bigint, + ): FeeHeader { + const MIN_ETH_PER_FEE_ASSET = 100n; + const MAX_ETH_PER_FEE_ASSET = 100_000_000_000_000n; // 1e14, matches FeeLib.sol + + // excessMana = clampedAdd(parent.excessMana + parent.manaUsed, -manaTarget) + const sum = parentFeeHeader.excessMana + parentFeeHeader.manaUsed; + const excessMana = sum > manaTarget ? sum - manaTarget : 0n; + + // ethPerFeeAsset = computeNewEthPerFeeAsset(max(parent.ethPerFeeAsset, MIN), modifier) + const parentPrice = + parentFeeHeader.ethPerFeeAsset > MIN_ETH_PER_FEE_ASSET ? parentFeeHeader.ethPerFeeAsset : MIN_ETH_PER_FEE_ASSET; + let newPrice: bigint; + if (feeAssetPriceModifier >= 0n) { + newPrice = (parentPrice * (10_000n + feeAssetPriceModifier)) / 10_000n; + } else { + const absMod = -feeAssetPriceModifier; + newPrice = (parentPrice * (10_000n - absMod)) / 10_000n; + } + if (newPrice < MIN_ETH_PER_FEE_ASSET) { + newPrice = MIN_ETH_PER_FEE_ASSET; + } + if (newPrice > MAX_ETH_PER_FEE_ASSET) { + newPrice = MAX_ETH_PER_FEE_ASSET; + } + + return { + excessMana, + manaUsed: childManaUsed, + ethPerFeeAsset: newPrice, + congestionCost: 0n, + proverCost: 0n, + }; + } + /** Creates a request to Rollup#invalidateBadAttestation to be simulated or sent */ public buildInvalidateBadAttestationRequest( checkpointNumber: CheckpointNumber, @@ -882,8 +1035,8 @@ export class RollupContract { return this.rollup.read.getHasSubmitted([BigInt(epochNumber), BigInt(numberOfCheckpointsInEpoch), prover]); } - getManaMinFeeAt(timestamp: bigint, inFeeAsset: boolean): Promise { - return this.rollup.read.getManaMinFeeAt([timestamp, inFeeAsset]); + getManaMinFeeAt(timestamp: bigint, inFeeAsset: boolean, stateOverride?: StateOverride): Promise { + return this.rollup.read.getManaMinFeeAt([timestamp, inFeeAsset], { stateOverride }); } async getManaMinFeeComponentsAt(timestamp: bigint, inFeeAsset: boolean): Promise { diff --git a/yarn-project/p2p/src/client/interface.ts b/yarn-project/p2p/src/client/interface.ts index 3ba683362f39..7a2a24779358 100644 --- a/yarn-project/p2p/src/client/interface.ts +++ b/yarn-project/p2p/src/client/interface.ts @@ -82,7 +82,15 @@ export type P2P = P2PClient & { * * @param handler - A function taking a received checkpoint proposal and producing attestations */ - registerCheckpointProposalHandler(callback: P2PCheckpointReceivedCallback): void; + registerValidatorCheckpointProposalHandler(callback: P2PCheckpointReceivedCallback): void; + + /** + * Registers a callback that runs for ALL nodes (not just validators) when a checkpoint proposal is received. + * Used to set the pending checkpoint number on the archiver so the sequencer can build on top of it. + * + * @param handler - A function taking a received checkpoint proposal + */ + registerAllNodesCheckpointProposalHandler(callback: P2PCheckpointReceivedCallback): void; /** * Registers a callback invoked when a duplicate proposal is detected (equivocation). diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 7d0ba924746c..eda27d431dab 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -357,6 +357,8 @@ export class P2PClient extends WithTracer implements P2P { // Store our own last-block proposal so we can respond to req/resp requests for it. await this.attestationPool.tryAddBlockProposal(blockProposal); } + // Gossipsub doesn't deliver own messages, so fire the all-nodes handler locally + await this.p2pService.notifyOwnCheckpointProposal(proposal.toCore()); return this.p2pService.propagate(proposal); } @@ -388,8 +390,12 @@ export class P2PClient extends WithTracer implements P2P { this.p2pService.registerBlockReceivedCallback(handler); } - public registerCheckpointProposalHandler(handler: P2PCheckpointReceivedCallback): void { - this.p2pService.registerCheckpointReceivedCallback(handler); + public registerValidatorCheckpointProposalHandler(handler: P2PCheckpointReceivedCallback): void { + this.p2pService.registerValidatorCheckpointReceivedCallback(handler); + } + + public registerAllNodesCheckpointProposalHandler(handler: P2PCheckpointReceivedCallback): void { + this.p2pService.registerAllNodesCheckpointReceivedCallback(handler); } public registerDuplicateProposalCallback(callback: (info: DuplicateProposalInfo) => void): void { @@ -697,13 +703,19 @@ export class P2PClient extends WithTracer implements P2P { /** Checks if the slot has changed and calls prepareForSlot if so. */ private async maybeCallPrepareForSlot(): Promise { // If we have a pending checkpoint available, we want to prepare the target slot - otherwise we prepare the current slot - // Knowledege of pending checkpoints is in the PR above - const { targetSlot } = this.epochCache.getTargetAndNextSlot(); - if (targetSlot <= this.lastSlotProcessed) { + let slot; + if (this.epochCache.isProposerPipeliningEnabled() && (await this.l2BlockSource.getL2Tips()).pendingCheckpoint) { + const { targetSlot } = this.epochCache.getTargetAndNextSlot(); + slot = targetSlot; + } else { + const { currentSlot } = this.epochCache.getCurrentAndNextSlot(); + slot = currentSlot; + } + if (slot <= this.lastSlotProcessed) { return; } - this.lastSlotProcessed = targetSlot; - await this.txPool.prepareForSlot(targetSlot); + this.lastSlotProcessed = slot; + await this.txPool.prepareForSlot(slot); } private async startServiceIfSynched() { diff --git a/yarn-project/p2p/src/errors/p2p-service.error.ts b/yarn-project/p2p/src/errors/p2p-service.error.ts new file mode 100644 index 000000000000..8420ecbb3775 --- /dev/null +++ b/yarn-project/p2p/src/errors/p2p-service.error.ts @@ -0,0 +1,10 @@ +/**Checkpoint Proposal Recieved Callback Not Registered Error + * + * Error triggered if the allNodesCheckpointReceivedCallback is not registered + * @category Errors + */ +export class CheckpointProposalRecievedCallbackNotRegisteredError extends Error { + constructor() { + super('FATAL (allNodesCheckpointReceivedCallback): All nodes should register a checkpoint proposal handler'); + } +} diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts index 4f925245d443..4e4596bc791b 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts @@ -278,7 +278,7 @@ export class AttestationPool { * @returns Result indicating whether the proposal was added and duplicate detection info */ public async tryAddCheckpointProposal(proposal: CheckpointProposalCore): Promise { - return await this.store.transactionAsync(async () => { + const result = await this.store.transactionAsync(async () => { const proposalId = proposal.archive.toString(); // Check if already exists @@ -304,6 +304,8 @@ export class AttestationPool { return { added: true, alreadyExists: false, count: count + 1 }; }); + + return result; } /** Internal method - must be called within a transaction. */ @@ -345,7 +347,7 @@ export class AttestationPool { await this.store.transactionAsync(async () => { for (const attestation of attestations) { const slotNumber = attestation.payload.header.slotNumber; - const proposalId = attestation.archive; + const proposalId = attestation.archive.toString(); const sender = attestation.getSender(); // Skip attestations with invalid signatures diff --git a/yarn-project/p2p/src/mem_pools/tx_pool/tx_pool_bench.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool/tx_pool_bench.test.ts index 300e3ec7351c..23de9cead73a 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool/tx_pool_bench.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool/tx_pool_bench.test.ts @@ -167,6 +167,7 @@ describe('TxPool: Benchmarks', () => { checkpointed: tipId, proven: tipId, finalized: tipId, + pendingCheckpoint: undefined, }); }, }); diff --git a/yarn-project/p2p/src/services/dummy_service.ts b/yarn-project/p2p/src/services/dummy_service.ts index 73f4c4d21abd..5158ade97362 100644 --- a/yarn-project/p2p/src/services/dummy_service.ts +++ b/yarn-project/p2p/src/services/dummy_service.ts @@ -1,6 +1,6 @@ import type { EthAddress } from '@aztec/foundation/eth-address'; import type { PeerInfo } from '@aztec/stdlib/interfaces/server'; -import type { Gossipable, PeerErrorSeverity, TopicType } from '@aztec/stdlib/p2p'; +import type { CheckpointProposalCore, Gossipable, PeerErrorSeverity, TopicType } from '@aztec/stdlib/p2p'; import { Tx, TxHash } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; @@ -86,7 +86,12 @@ export class DummyP2PService implements P2PService { /** * Register a callback into the validator client for when a checkpoint proposal is received */ - public registerCheckpointReceivedCallback(_callback: P2PCheckpointReceivedCallback) {} + public registerValidatorCheckpointReceivedCallback(_callback: P2PCheckpointReceivedCallback) {} + public registerAllNodesCheckpointReceivedCallback(_callback: P2PCheckpointReceivedCallback) {} + + public notifyOwnCheckpointProposal(_checkpoint: CheckpointProposalCore): Promise { + return Promise.resolve(); + } /** * Register a callback for when a duplicate proposal is detected diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts index f3330dd612b7..3519ab77c1b7 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts @@ -799,7 +799,8 @@ describe('LibP2PService', () => { let mockEpochCache: MockProxy; let signer: Secp256k1Signer; let blockReceivedCallback: jest.Mock; - let checkpointReceivedCallback: jest.Mock; + let validatorCheckpointReceivedCallback: jest.Mock; + let allNodesCheckpointReceivedCallback: jest.Mock; let duplicateProposalCallback: jest.Mock; const targetSlot = SlotNumber(100); @@ -828,10 +829,12 @@ describe('LibP2PService', () => { ); blockReceivedCallback = jest.fn().mockImplementation(() => Promise.resolve(true)); - checkpointReceivedCallback = jest.fn().mockImplementation(() => Promise.resolve([])); + allNodesCheckpointReceivedCallback = jest.fn().mockImplementation(() => Promise.resolve([])); + validatorCheckpointReceivedCallback = jest.fn().mockImplementation(() => Promise.resolve([])); duplicateProposalCallback = jest.fn(); service.registerBlockReceivedCallback(blockReceivedCallback as any); - service.registerCheckpointReceivedCallback(checkpointReceivedCallback as any); + service.registerValidatorCheckpointReceivedCallback(validatorCheckpointReceivedCallback as any); + service.registerAllNodesCheckpointReceivedCallback(allNodesCheckpointReceivedCallback as any); service.registerDuplicateProposalCallback(duplicateProposalCallback); }); @@ -842,8 +845,11 @@ describe('LibP2PService', () => { await service.handleGossipedCheckpointProposal(proposal.toBuffer(), 'msg-1', mockPeerId); // Verify callback was invoked with checkpoint core - expect(checkpointReceivedCallback).toHaveBeenCalledTimes(1); - expect(checkpointReceivedCallback).toHaveBeenCalledWith(expect.any(Object), mockPeerId); + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledWith(expect.any(Object), mockPeerId); + + expect(validatorCheckpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(validatorCheckpointReceivedCallback).toHaveBeenCalledWith(expect.any(Object), mockPeerId); // Verify message was accepted expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Accept); @@ -863,10 +869,12 @@ describe('LibP2PService', () => { archiveRoot: Fr.random(), }); await service.handleGossipedCheckpointProposal(checkpoint1.toBuffer(), 'msg-1', mockPeerId); - expect(checkpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(validatorCheckpointReceivedCallback).toHaveBeenCalledTimes(1); // Reset mocks - checkpointReceivedCallback.mockClear(); + allNodesCheckpointReceivedCallback.mockClear(); + validatorCheckpointReceivedCallback.mockClear(); reportMessageValidationResultSpy.mockClear(); // Second checkpoint at same slot (equivocation) @@ -881,7 +889,8 @@ describe('LibP2PService', () => { expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-2', MOCK_PEER_ID, TopicValidatorResult.Accept); // Verify callback was NOT invoked - expect(checkpointReceivedCallback).not.toHaveBeenCalled(); + expect(allNodesCheckpointReceivedCallback).not.toHaveBeenCalled(); + expect(validatorCheckpointReceivedCallback).not.toHaveBeenCalled(); // Verify duplicate callback was invoked expect(duplicateProposalCallback).toHaveBeenCalledWith({ @@ -904,7 +913,8 @@ describe('LibP2PService', () => { // Verify both callbacks were invoked expect(blockReceivedCallback).toHaveBeenCalledTimes(1); - expect(checkpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(validatorCheckpointReceivedCallback).toHaveBeenCalledTimes(1); // Verify txs were marked as non-evictable (for the lastBlock) expect(mockTxPool.protectTxs).toHaveBeenCalledTimes(1); @@ -935,7 +945,8 @@ describe('LibP2PService', () => { // Reset mocks blockReceivedCallback.mockClear(); - checkpointReceivedCallback.mockClear(); + allNodesCheckpointReceivedCallback.mockClear(); + validatorCheckpointReceivedCallback.mockClear(); reportMessageValidationResultSpy.mockClear(); mockTxPool.protectTxs.mockClear(); mockPeerManager.penalizePeer.mockClear(); @@ -960,7 +971,8 @@ describe('LibP2PService', () => { ); // Verify checkpoint callback was NOT invoked - expect(checkpointReceivedCallback).not.toHaveBeenCalled(); + expect(allNodesCheckpointReceivedCallback).not.toHaveBeenCalled(); + expect(validatorCheckpointReceivedCallback).not.toHaveBeenCalled(); // But the lastBlock IS processed since it was valid expect(blockReceivedCallback).toHaveBeenCalled(); @@ -991,7 +1003,8 @@ describe('LibP2PService', () => { // Reset mocks blockReceivedCallback.mockClear(); - checkpointReceivedCallback.mockClear(); + allNodesCheckpointReceivedCallback.mockClear(); + validatorCheckpointReceivedCallback.mockClear(); reportMessageValidationResultSpy.mockClear(); // Create checkpoint with different lastBlock at same position @@ -1008,7 +1021,8 @@ describe('LibP2PService', () => { expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Reject); // Verify neither callback was invoked - expect(checkpointReceivedCallback).not.toHaveBeenCalled(); + expect(allNodesCheckpointReceivedCallback).not.toHaveBeenCalled(); + expect(validatorCheckpointReceivedCallback).not.toHaveBeenCalled(); expect(blockReceivedCallback).not.toHaveBeenCalled(); }); @@ -1026,6 +1040,16 @@ describe('LibP2PService', () => { // Verify message was rejected expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Reject); }); + + it('notifyOwnCheckpointProposal fires allNodesCheckpointReceivedCallback', async () => { + const checkpointHeader = makeCheckpointHeader(1, { slotNumber: targetSlot }); + const proposal = await makeCheckpointProposal({ signer, checkpointHeader }); + + await service.notifyOwnCheckpointProposal(proposal.toCore()); + + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledWith(expect.any(Object), expect.anything()); + }); }); }); diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index f7dae99a092f..126ad4c44a5f 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -58,6 +58,7 @@ import { ENR } from '@nethermindeth/enr'; import { createLibp2p } from 'libp2p'; import type { P2PConfig } from '../../config.js'; +import { CheckpointProposalRecievedCallbackNotRegisteredError } from '../../errors/p2p-service.error.js'; import type { MemPools } from '../../mem_pools/interface.js'; import { BlockProposalValidator, @@ -171,7 +172,13 @@ export class LibP2PService extends WithTracer implements P2PService { * @param checkpoint - The checkpoint proposal received from the peer. * @returns The attestations for the checkpoint, if any. */ - private checkpointReceivedCallback: P2PCheckpointReceivedCallback; + private allNodesCheckpointReceivedCallback: P2PCheckpointReceivedCallback; + /** + * Callback for when a checkpoint proposal is received - specifically for validators - from a peer. + * @param checkpoint - The checkpoint proposal received from the peer. + * @returns The attestations for the checkpoint, if any. + */ + private validatorCheckpointReceivedCallback: P2PCheckpointReceivedCallback; private gossipSubEventHandler: (e: CustomEvent) => void; @@ -243,12 +250,15 @@ export class LibP2PService extends WithTracer implements P2PService { return true; }; - this.checkpointReceivedCallback = ( - checkpoint: CheckpointProposalCore, + this.allNodesCheckpointReceivedCallback = ( + _checkpoint: CheckpointProposalCore, + ): Promise => { + throw new CheckpointProposalRecievedCallbackNotRegisteredError(); + }; + + this.validatorCheckpointReceivedCallback = ( + _checkpoint: CheckpointProposalCore, ): Promise => { - this.logger.debug( - `Handler not yet registered: Checkpoint received callback not set. Received checkpoint for slot ${checkpoint.slotNumber} from peer.`, - ); return Promise.resolve(undefined); }; } @@ -666,8 +676,16 @@ export class LibP2PService extends WithTracer implements P2PService { this.blockReceivedCallback = callback; } - public registerCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback) { - this.checkpointReceivedCallback = callback; + public registerValidatorCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback) { + this.validatorCheckpointReceivedCallback = callback; + } + + public registerAllNodesCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback) { + this.allNodesCheckpointReceivedCallback = callback; + } + + public async notifyOwnCheckpointProposal(checkpoint: CheckpointProposalCore): Promise { + await this.allNodesCheckpointReceivedCallback(checkpoint, this.node.peerId); } /** @@ -1367,9 +1385,11 @@ export class LibP2PService extends WithTracer implements P2PService { source: sender.toString(), }); + await this.allNodesCheckpointReceivedCallback(checkpoint, sender); + // Call the checkpoint received callback with the core version (without lastBlock) // to validate and potentially generate attestations - const attestations = await this.checkpointReceivedCallback(checkpoint, sender); + const attestations = await this.validatorCheckpointReceivedCallback(checkpoint, sender); if (attestations && attestations.length > 0) { // If the callback returned attestations, add them to the pool and propagate them await this.mempools.attestationPool.addOwnCheckpointAttestations(attestations); diff --git a/yarn-project/p2p/src/services/service.ts b/yarn-project/p2p/src/services/service.ts index 59594e169788..3151973b31a0 100644 --- a/yarn-project/p2p/src/services/service.ts +++ b/yarn-project/p2p/src/services/service.ts @@ -117,7 +117,12 @@ export interface P2PService { // Leaky abstraction: fix https://github.com/AztecProtocol/aztec-packages/issues/7963 registerBlockReceivedCallback(callback: P2PBlockReceivedCallback): void; - registerCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback): void; + registerValidatorCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback): void; + + registerAllNodesCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback): void; + + /** Fires the all-nodes checkpoint callback for our own proposal (gossipsub doesn't deliver own messages). */ + notifyOwnCheckpointProposal(checkpoint: CheckpointProposalCore): Promise; /** * Registers a callback invoked when a duplicate proposal is detected (equivocation). diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index 9effe18edd71..160e41f46092 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -163,6 +163,7 @@ describe('prover-node', () => { }, proven: genesisTipId, finalized: genesisTipId, + pendingCheckpoint: undefined, }); l2BlockSource.getBlockHeader.mockImplementation(number => Promise.resolve(number === checkpoints[0].blocks[0].number - 1 ? previousBlockHeader : undefined), diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 7325d8b80ed7..5698bc6357a8 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -193,6 +193,7 @@ describe('PXE', () => { checkpointed: tipId, proven: tipId, finalized: tipId, + pendingCheckpoint: undefined, }); // This is read when PXE tries to resolve the diff --git a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts index 20c0f236292d..dd86496ad4b5 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts @@ -12,6 +12,7 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type L1RollupConstants, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; import type { + BuildCheckpointGlobalVariablesOpts, CheckpointGlobalVariables, GlobalVariableBuilder as GlobalVariableBuilderInterface, } from '@aztec/stdlib/tx'; @@ -121,6 +122,7 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { coinbase: EthAddress, feeRecipient: AztecAddress, slotNumber: SlotNumber, + opts?: BuildCheckpointGlobalVariablesOpts, ): Promise { const { chainId, version } = this; @@ -129,9 +131,19 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { l1GenesisTime: this.l1GenesisTime, }); - // We can skip much of the logic in getCurrentMinFees since it we already check that we are not within a slot elsewhere. - // TODO(palla/mbps): Can we use a cached value here? - const gasFees = new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true)); + // When pipelining, force the pending checkpoint number and fee header to the parent so that + // the fee computation matches what L1 will see when the previous pipelined checkpoint has landed. + const pendingNumberOverride = await this.rollupContract.makePendingCheckpointNumberOverride( + opts?.forcePendingCheckpointNumber, + ); + const feeHeaderOverride = opts?.forcePendingFeeHeader + ? await this.rollupContract.makeFeeHeaderOverride( + opts.forcePendingFeeHeader.checkpointNumber, + opts.forcePendingFeeHeader.feeHeader, + ) + : []; + const stateOverride = RollupContract.mergeStateOverrides(pendingNumberOverride, feeHeaderOverride); + const gasFees = new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true, stateOverride)); return { chainId, version, slotNumber, timestamp, coinbase, feeRecipient, gasFees }; } diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index 971a338d8647..b97055d1915c 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -5,6 +5,7 @@ import type { L1ContractsConfig } from '@aztec/ethereum/config'; import { type EmpireSlashingProposerContract, FeeAssetPriceOracle, + type FeeHeader, type GovernanceProposerContract, type IEmpireBase, MULTI_CALL_3_ADDRESS, @@ -36,8 +37,9 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { makeBackoff, retry } from '@aztec/foundation/retry'; +import { InterruptibleSleep } from '@aztec/foundation/sleep'; import { bufferToHex } from '@aztec/foundation/string'; -import { DateProvider, Timer } from '@aztec/foundation/timer'; +import { type DateProvider, Timer } from '@aztec/foundation/timer'; import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts'; import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher'; import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block'; @@ -62,6 +64,20 @@ import type { SequencerPublisherConfig } from './config.js'; import { type FailedL1Tx, type L1TxFailedStore, createL1TxFailedStore } from './l1_tx_failed_store/index.js'; import { SequencerPublisherMetrics } from './sequencer-publisher-metrics.js'; +/** Result of a sendRequests call, returned by both sendRequests() and sendRequestsAt(). */ +export type SendRequestsResult = { + /** The L1 transaction receipt or error from the bundled multicall. */ + result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError; + /** Actions that expired (past their deadline) before the request was sent. */ + expiredActions: Action[]; + /** Actions that were included in the sent L1 transaction. */ + sentActions: Action[]; + /** Actions whose L1 simulation succeeded (subset of sentActions). */ + successfulActions: Action[]; + /** Actions whose L1 simulation failed (subset of sentActions). */ + failedActions: Action[]; +}; + /** Arguments to the process method of the rollup contract */ type L1ProcessArgs = { /** The L2 block header. */ @@ -103,6 +119,8 @@ export type InvalidateCheckpointRequest = { gasUsed: bigint; checkpointNumber: CheckpointNumber; forcePendingCheckpointNumber: CheckpointNumber; + /** Archive at the rollback target checkpoint (checkpoint N-1). */ + lastArchive: Fr; }; interface RequestWithExpiry { @@ -149,6 +167,12 @@ export class SequencerPublisher { /** Fee asset price oracle for computing price modifiers from Uniswap V4 */ private feeAssetPriceOracle: FeeAssetPriceOracle; + /** Date provider for wall-clock time. */ + private readonly dateProvider: DateProvider; + + /** Interruptible sleep used by sendRequestsAt to wait until a target timestamp. */ + private readonly interruptibleSleep = new InterruptibleSleep(); + // A CALL to a cold address is 2700 gas public static MULTICALL_OVERHEAD_GAS_GUESS = 5000n; @@ -191,6 +215,7 @@ export class SequencerPublisher { this.lastActions = deps.lastActions; this.blobClient = deps.blobClient; + this.dateProvider = deps.dateProvider; const telemetry = deps.telemetry ?? getTelemetryClient(); this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher'); @@ -366,9 +391,10 @@ export class SequencerPublisher { * - undefined if no valid requests are found OR the tx failed to send. */ @trackSpan('SequencerPublisher.sendRequests') - public async sendRequests() { + public async sendRequests(): Promise { const requestsToProcess = [...this.requests]; this.requests = []; + if (this.interrupted || requestsToProcess.length === 0) { return undefined; } @@ -527,6 +553,23 @@ export class SequencerPublisher { } } + /* + * Schedules sending all enqueued requests at (or after) the given timestamp. + * Uses InterruptibleSleep so it can be cancelled via interrupt(). + * Returns the promise for the L1 response (caller should NOT await this in the work loop). + */ + public async sendRequestsAt(submitAfter: Date): Promise { + const ms = submitAfter.getTime() - this.dateProvider.now(); + if (ms > 0) { + this.log.debug(`Sleeping ${ms}ms before sending requests`, { submitAfter }); + await this.interruptibleSleep.sleep(ms); + } + if (this.interrupted) { + return undefined; + } + return this.sendRequests(); + } + private callbackBundledTransactions( requests: RequestWithExpiry[], result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError | undefined, @@ -605,7 +648,11 @@ export class SequencerPublisher { public canProposeAt( tipArchive: Fr, msgSender: EthAddress, - opts: { forcePendingCheckpointNumber?: CheckpointNumber; pipelined?: boolean } = {}, + opts: { + forcePendingCheckpointNumber?: CheckpointNumber; + forceArchive?: { checkpointNumber: CheckpointNumber; archive: Fr }; + pipelined?: boolean; + } = {}, ) { // TODO: #14291 - should loop through multiple keys to check if any of them can propose const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive']; @@ -616,6 +663,7 @@ export class SequencerPublisher { return this.rollupContract .canProposeAt(tipArchive.toBuffer(), msgSender.toString(), this.ethereumSlotDuration, slotOffset, { forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber, + forceArchive: opts.forceArchive, }) .catch(err => { if (err instanceof FormattedViemError && ignoredErrors.find(e => err.message.includes(e))) { @@ -728,6 +776,7 @@ export class SequencerPublisher { gasUsed, checkpointNumber, forcePendingCheckpointNumber: CheckpointNumber(checkpointNumber - 1), + lastArchive: validationResult.checkpoint.lastArchive, reason, }; } catch (err) { @@ -815,7 +864,10 @@ export class SequencerPublisher { checkpoint: Checkpoint, attestationsAndSigners: CommitteeAttestationsAndSigners, attestationsAndSignersSignature: Signature, - options: { forcePendingCheckpointNumber?: CheckpointNumber }, + options: { + forcePendingCheckpointNumber?: CheckpointNumber; + forcePendingFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader }; + }, ): Promise { // Anchor the simulation timestamp to the checkpoint's own slot start time // rather than the current L1 block timestamp, which may overshoot into the next slot if the build ran late. @@ -1141,7 +1193,11 @@ export class SequencerPublisher { checkpoint: Checkpoint, attestationsAndSigners: CommitteeAttestationsAndSigners, attestationsAndSignersSignature: Signature, - opts: { txTimeoutAt?: Date; forcePendingCheckpointNumber?: CheckpointNumber } = {}, + opts: { + txTimeoutAt?: Date; + forcePendingCheckpointNumber?: CheckpointNumber; + forcePendingFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader }; + } = {}, ): Promise { const checkpointHeader = checkpoint.header; @@ -1300,6 +1356,7 @@ export class SequencerPublisher { */ public interrupt() { this.interrupted = true; + this.interruptibleSleep.interrupt(); this.l1TxUtils.interrupt(); } @@ -1410,7 +1467,10 @@ export class SequencerPublisher { `0x${string}`, ], timestamp: bigint, - options: { forcePendingCheckpointNumber?: CheckpointNumber }, + options: { + forcePendingCheckpointNumber?: CheckpointNumber; + forcePendingFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader }; + }, ) { const rollupData = encodeFunctionData({ abi: RollupAbi, @@ -1425,6 +1485,16 @@ export class SequencerPublisher { : [] ).flatMap(override => override.stateDiff ?? []); + // override the fee header for a specific checkpoint number if requested (used when pipelining) + const forcePendingFeeHeaderStateDiff = ( + options.forcePendingFeeHeader !== undefined + ? await this.rollupContract.makeFeeHeaderOverride( + options.forcePendingFeeHeader.checkpointNumber, + options.forcePendingFeeHeader.feeHeader, + ) + : [] + ).flatMap(override => override.stateDiff ?? []); + const stateOverrides: StateOverride = [ { address: this.rollupContract.address, @@ -1432,6 +1502,7 @@ export class SequencerPublisher { stateDiff: [ { slot: toPaddedHex(RollupContract.checkBlobStorageSlot, true), value: toPaddedHex(0n, true) }, ...forcePendingCheckpointNumberStateDiff, + ...forcePendingFeeHeaderStateDiff, ], }, ]; @@ -1499,7 +1570,11 @@ export class SequencerPublisher { private async addProposeTx( checkpoint: Checkpoint, encodedData: L1ProcessArgs, - opts: { txTimeoutAt?: Date; forcePendingCheckpointNumber?: CheckpointNumber } = {}, + opts: { + txTimeoutAt?: Date; + forcePendingCheckpointNumber?: CheckpointNumber; + forcePendingFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader }; + } = {}, timestamp: bigint, ): Promise { const slot = checkpoint.header.slotNumber; diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 5821f87fb080..8a23b8bbfb26 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -162,7 +162,7 @@ describe('CheckpointProposalJob', () => { publisher.enqueueProposeCheckpoint.mockResolvedValue(undefined); publisher.enqueueGovernanceCastSignal.mockResolvedValue(true); publisher.enqueueSlashingActions.mockResolvedValue(true); - publisher.sendRequests.mockResolvedValue({ + publisher.sendRequestsAt.mockResolvedValue({ result: { receipt: { status: 'success' } as TransactionReceipt, errorMsg: undefined }, successfulActions: ['propose'], failedActions: [], @@ -441,8 +441,6 @@ describe('CheckpointProposalJob', () => { it('uses targetEpoch for previousCheckpointOutHashes when pipelining crosses epoch boundary', async () => { // Pipelining scenario: wall-clock is in epoch 0, but target slot is in epoch 1. - // The key fix: getCheckpointsDataForEpoch must be called with targetEpoch, not epochNow. - const epochNow = EpochNumber(0); const targetEpoch = EpochNumber(1); // Target slot is first slot of epoch 1 (epochDuration = 16) const targetSlot = SlotNumber(l1Constants.epochDuration); @@ -454,7 +452,7 @@ describe('CheckpointProposalJob', () => { l2BlockSource.getCheckpointsDataForEpoch.mockResolvedValue([toCheckpointData(previousCheckpoint)]); - job = createCheckpointProposalJob({ slotNow, targetSlot, epochNow, targetEpoch }); + job = createCheckpointProposalJob({ slotNow, targetSlot, targetEpoch }); job.setTimetable( new SequencerTimetable({ ethereumSlotDuration, @@ -471,7 +469,7 @@ describe('CheckpointProposalJob', () => { await job.execute(); - // Verify getCheckpointsDataForEpoch was called with targetEpoch (1), not epochNow (0) + // Verify getCheckpointsDataForEpoch was called with targetEpoch (1), not the wall-clock epoch (0) expect(l2BlockSource.getCheckpointsDataForEpoch).toHaveBeenCalledWith(targetEpoch); }); }); @@ -551,7 +549,6 @@ describe('CheckpointProposalJob', () => { function createCheckpointProposalJob(overrides?: { slotNow?: SlotNumber; targetSlot?: SlotNumber; - epochNow?: EpochNumber; targetEpoch?: EpochNumber; }): TestCheckpointProposalJob { const setStateFn = jest.fn(); @@ -560,7 +557,6 @@ describe('CheckpointProposalJob', () => { return new TestCheckpointProposalJob( overrides?.slotNow ?? SlotNumber(newSlotNumber), overrides?.targetSlot ?? SlotNumber(newSlotNumber), - overrides?.epochNow ?? epoch, overrides?.targetEpoch ?? epoch, checkpointNumber, lastBlockNumber, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index 2a3bd79c7a9e..0b660e9bcba3 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -292,7 +292,6 @@ describe('CheckpointProposalJob Timing Tests', () => { slotNumber, slotNumber, epoch, - epoch, checkpointNumber, BlockNumber.ZERO, proposer, @@ -396,7 +395,7 @@ describe('CheckpointProposalJob Timing Tests', () => { publisher.enqueueProposeCheckpoint.mockResolvedValue(undefined); publisher.enqueueGovernanceCastSignal.mockResolvedValue(true); publisher.enqueueSlashingActions.mockResolvedValue(true); - publisher.sendRequests.mockResolvedValue({ + publisher.sendRequestsAt.mockResolvedValue({ result: { receipt: { status: 'success' } as any, errorMsg: undefined }, successfulActions: ['propose'], failedActions: [], diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 24026200d942..dfaef0ca52fe 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -1,4 +1,5 @@ import type { EpochCache } from '@aztec/epoch-cache'; +import { type FeeHeader, RollupContract } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, @@ -30,8 +31,8 @@ import { type L2BlockSource, MaliciousCommitteeAttestationsAndSigners, } from '@aztec/stdlib/block'; -import { type Checkpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint'; -import { getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { type Checkpoint, type PendingCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint'; +import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { Gas } from '@aztec/stdlib/gas'; import { type BlockBuilderOptions, @@ -67,6 +68,13 @@ import { SequencerState } from './utils.js'; /** How much time to sleep while waiting for min transactions to accumulate for a block */ const TXS_POLLING_MS = 500; +/** Result from proposeCheckpoint when a checkpoint was successfully built and attested. */ +type CheckpointProposalResult = { + checkpoint: Checkpoint; + attestations: CommitteeAttestationsAndSigners; + attestationsSignature: Signature; +}; + /** * Handles the execution of a checkpoint proposal after the initial preparation phase. * This includes building blocks, collecting attestations, and publishing the checkpoint to L1, @@ -76,10 +84,15 @@ const TXS_POLLING_MS = 500; export class CheckpointProposalJob implements Traceable { protected readonly log: Logger; + /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */ + private pendingL1Submission: Promise | undefined; + + /** Fee header override computed during proposeCheckpoint, reused in enqueueCheckpointForSubmission. */ + private computedForcePendingFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader }; + constructor( private readonly slotNow: SlotNumber, private readonly targetSlot: SlotNumber, - private readonly epochNow: EpochNumber, private readonly targetEpoch: EpochNumber, private readonly checkpointNumber: CheckpointNumber, private readonly syncedToBlockNumber: BlockNumber, @@ -107,6 +120,7 @@ export class CheckpointProposalJob implements Traceable { private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void, public readonly tracer: Tracer, bindings?: LoggerBindings, + private readonly pendingCheckpointData?: PendingCheckpointData, ) { this.log = createLogger('sequencer:checkpoint-proposal', { ...bindings, @@ -114,19 +128,17 @@ export class CheckpointProposalJob implements Traceable { }); } - /** The wall-clock slot during which the proposer builds. */ - private get slot(): SlotNumber { - return this.slotNow; - } - - /** The wall-clock epoch. */ - private get epoch(): EpochNumber { - return this.epochNow; + /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */ + public async awaitPendingSubmission(): Promise { + this.log.info('Awaiting pending L1 payload submission'); + await this.pendingL1Submission; } /** * Executes the checkpoint proposal job. - * Returns the published checkpoint if successful, undefined otherwise. + * Builds blocks, collects attestations, enqueues requests, and schedules L1 submission as a + * background task so the work loop can return to IDLE immediately. + * Returns the built checkpoint if successful, undefined otherwise. */ @trackSpan('CheckpointProposalJob.execute') public async execute(): Promise { @@ -145,8 +157,10 @@ export class CheckpointProposalJob implements Traceable { this.log, ).enqueueVotes(); - // Build and propose the checkpoint. This will enqueue the request on the publisher if a checkpoint is built. - const checkpoint = await this.proposeCheckpoint(); + // Build and propose the checkpoint. Builds blocks, broadcasts, collects attestations, and signs. + // Does NOT enqueue to L1 yet — that happens after the pipeline sleep. + const proposalResult = await this.proposeCheckpoint(); + const checkpoint = proposalResult?.checkpoint; // Wait until the voting promises have resolved, so all requests are enqueued (not sent) await Promise.all(votesPromises); @@ -161,41 +175,85 @@ export class CheckpointProposalJob implements Traceable { return; } - // If pipelining, wait until the submission slot so L1 recognizes the pipelined proposer - if (this.epochCache.isProposerPipeliningEnabled()) { - const submissionSlotTimestamp = - getTimestampForSlot(this.targetSlot, this.l1Constants) - BigInt(this.l1Constants.ethereumSlotDuration); - this.log.info(`Waiting until submission slot ${this.targetSlot} for L1 submission`, { - slot: this.slot, - submissionSlot: this.targetSlot, - submissionSlotTimestamp, + // Enqueue the checkpoint for L1 submission + if (proposalResult) { + try { + await this.enqueueCheckpointForSubmission(proposalResult); + } catch (err) { + this.log.error(`Failed to enqueue checkpoint for L1 submission at slot ${this.targetSlot}`, err); + // Continue to sendRequestsAt so votes are still sent + } + } + + // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise. + const submitAfter = this.epochCache.isProposerPipeliningEnabled() + ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000) + : new Date(this.dateProvider.now()); + + // TODO(md): should discard the pending submission if a reorg occurs underneath + + // Schedule L1 submission in the background so the work loop returns immediately. + // The publisher will sleep until submitAfter, then send the bundled requests. + // The promise is stored so it can be awaited during shutdown. + this.pendingL1Submission = this.publisher + .sendRequestsAt(submitAfter) + .then(async l1Response => { + const proposedAction = l1Response?.successfulActions.find(a => a === 'propose'); + if (proposedAction) { + this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.targetSlot }); + const coinbase = checkpoint?.header.coinbase; + await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase); + } else if (checkpoint) { + this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.targetSlot }); + + if (this.epochCache.isProposerPipeliningEnabled()) { + this.metrics.recordPipelineDiscard(); + } + } + }) + .catch(err => { + this.log.error(`Background L1 submission failed for slot ${this.targetSlot}`, err); + if (checkpoint) { + this.eventEmitter.emit('checkpoint-publish-failed', { slot: this.targetSlot }); + + if (this.epochCache.isProposerPipeliningEnabled()) { + this.metrics.recordPipelineDiscard(); + } + } }); - await sleepUntil(new Date(Number(submissionSlotTimestamp) * 1000), this.dateProvider.nowAsDate()); - // After waking, verify the parent checkpoint wasn't pruned during the sleep. - // We check L1's pending tip directly instead of canProposeAt, which also validates the proposer - // identity and would fail because the timestamp resolves to a different slot's proposer. - const l1Tips = await this.publisher.rollupContract.getTips(); - if (l1Tips.pending < this.checkpointNumber - 1) { + // Return the built checkpoint immediately — the work loop is now unblocked + return checkpoint; + } + + /** Enqueues the checkpoint for L1 submission. Called after pipeline sleep in execute(). */ + private async enqueueCheckpointForSubmission(result: CheckpointProposalResult): Promise { + const { checkpoint, attestations, attestationsSignature } = result; + + this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot); + const aztecSlotDuration = this.l1Constants.slotDuration; + const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants)); + const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000); + + // If we have been configured to potentially skip publishing checkpoint then roll the dice here + if ( + this.config.skipPublishingCheckpointsPercent !== undefined && + this.config.skipPublishingCheckpointsPercent > 0 + ) { + const roll = Math.max(0, randomInt(100)); + if (roll < this.config.skipPublishingCheckpointsPercent) { this.log.warn( - `Parent checkpoint was pruned during pipelining sleep (L1 pending=${l1Tips.pending}, expected>=${this.checkpointNumber - 1}), skipping L1 submission for checkpoint ${this.checkpointNumber}`, + `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${roll}`, ); - return undefined; + return; } } - // Then send everything to L1 - const l1Response = await this.publisher.sendRequests(); - const proposedAction = l1Response?.successfulActions.find(a => a === 'propose'); - if (proposedAction) { - this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.slot }); - const coinbase = checkpoint?.header.coinbase; - await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase); - return checkpoint; - } else if (checkpoint) { - this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.slot }); - return undefined; - } + await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, { + txTimeoutAt, + forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber, + forcePendingFeeHeader: this.computedForcePendingFeeHeader, + }); } @trackSpan('CheckpointProposalJob.proposeCheckpoint', function () { @@ -205,7 +263,7 @@ export class CheckpointProposalJob implements Traceable { [Attributes.SLOT_NUMBER]: this.targetSlot, }; }) - private async proposeCheckpoint(): Promise { + private async proposeCheckpoint(): Promise { try { // Get operator configured coinbase and fee recipient for this attestor const coinbase = this.validatorClient.getCoinbaseForAttestor(this.attestorAddress); @@ -214,7 +272,7 @@ export class CheckpointProposalJob implements Traceable { // Start the checkpoint this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot); this.log.info(`Starting checkpoint proposal`, { - buildSlot: this.slot, + buildSlot: this.slotNow, submissionSlot: this.targetSlot, pipelining: this.epochCache.isProposerPipeliningEnabled(), proposer: this.proposer?.toString(), @@ -227,11 +285,36 @@ export class CheckpointProposalJob implements Traceable { this.publisher.enqueueInvalidateCheckpoint(this.invalidateCheckpoint); } - // Create checkpoint builder for the slot + // Create checkpoint builder for the slot. + // When pipelining, force the pending checkpoint number and fee header to our parent so the + // fee computation sees the same chain tip that L1 will see once the previous pipelined checkpoint lands. + const isPipelining = this.epochCache.isProposerPipeliningEnabled(); + const parentCheckpointNumber = isPipelining ? CheckpointNumber(this.checkpointNumber - 1) : undefined; + + // Compute the parent's fee header override when pipelining + let forcePendingFeeHeader: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader } | undefined; + if (isPipelining && this.pendingCheckpointData) { + const rollup = this.publisher.rollupContract; + const grandparentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 2); + const [grandparentCheckpoint, manaTarget] = await Promise.all([ + rollup.getCheckpoint(grandparentCheckpointNumber), + rollup.getManaTarget(), + ]); + const parentFeeHeader = RollupContract.computeChildFeeHeader( + grandparentCheckpoint.feeHeader, + this.pendingCheckpointData.totalManaUsed, + this.pendingCheckpointData.feeAssetPriceModifier, + manaTarget, + ); + forcePendingFeeHeader = { checkpointNumber: parentCheckpointNumber!, feeHeader: parentFeeHeader }; + this.computedForcePendingFeeHeader = forcePendingFeeHeader; + } + const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables( coinbase, feeRecipient, this.targetSlot, + { forcePendingCheckpointNumber: parentCheckpointNumber, forcePendingFeeHeader }, ); // Collect L1 to L2 messages for the checkpoint and compute their hash @@ -326,7 +409,7 @@ export class CheckpointProposalJob implements Traceable { maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint, }); } catch (err) { - this.log.error(`Built an invalid checkpoint at slot ${this.slot} (skipping proposal)`, err, { + this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, { checkpoint: checkpoint.header.toInspect(), }); return undefined; @@ -352,7 +435,11 @@ export class CheckpointProposalJob implements Traceable { }, ); this.metrics.recordCheckpointSuccess(); - return checkpoint; + return { + checkpoint, + attestations: CommitteeAttestationsAndSigners.empty(), + attestationsSignature: Signature.empty(), + }; } // Include the block pending broadcast in the checkpoint proposal if any @@ -400,39 +487,15 @@ export class CheckpointProposalJob implements Traceable { throw err; } - // Enqueue publishing the checkpoint to L1 - this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot); - const aztecSlotDuration = this.l1Constants.slotDuration; - const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants)); - const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000); - - // If we have been configured to potentially skip publishing checkpoint then roll the dice here - if ( - this.config.skipPublishingCheckpointsPercent !== undefined && - this.config.skipPublishingCheckpointsPercent > 0 - ) { - const result = Math.max(0, randomInt(100)); - if (result < this.config.skipPublishingCheckpointsPercent) { - this.log.warn( - `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${result}`, - ); - return checkpoint; - } - } - - await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, { - txTimeoutAt, - forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber, - }); - - return checkpoint; + // Return the result for the caller to enqueue after the pipeline sleep + return { checkpoint, attestations, attestationsSignature }; } catch (err) { if (err && (err instanceof DutyAlreadySignedError || err instanceof SlashingProtectionError)) { // swallow this error. It's already been logged by a function deeper in the stack return undefined; } - this.log.error(`Error building checkpoint at slot ${this.slot}`, err); + this.log.error(`Error building checkpoint at slot ${this.targetSlot}`, err); return undefined; } } @@ -702,6 +765,8 @@ export class CheckpointProposalJob implements Traceable { { blockHash, txHashes, manaPerSec, ...blockStats }, ); + // `slot` is the target/submission slot (may be one ahead when pipelining), + // `buildSlot` is the wall-clock slot during which the block was actually built. this.eventEmitter.emit('block-proposed', { blockNumber: block.number, slot: this.targetSlot, @@ -810,7 +875,7 @@ export class CheckpointProposalJob implements Traceable { this.log.debug(`Attesting committee length is ${committee.length}`, { committee }); } - const numberOfRequiredAttestations = Math.floor((committee.length * 2) / 3) + 1; + const numberOfRequiredAttestations = computeQuorum(committee.length); if (this.config.skipCollectingAttestations) { this.log.warn('Skipping attestation collection as per config (attesting with own keys only)'); @@ -1035,7 +1100,7 @@ export class CheckpointProposalJob implements Traceable { } private getSlotStartBuildTimestamp(): number { - return getSlotStartBuildTimestamp(this.slot, this.l1Constants); + return getSlotStartBuildTimestamp(this.slotNow, this.l1Constants); } private getSecondsIntoSlot(): number { diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index d9ff3cb58919..ec930edf9fb2 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -95,11 +95,31 @@ describe('sequencer', () => { let feeRecipient: AztecAddress; + const mockedArchiveRoot = Fr.random(); + const signer = Secp256k1Signer.random(); const mockedSig = Signature.random(); const mockedAttestation = new CommitteeAttestation(signer.address, mockedSig); const committee = [signer.address]; + /** A minimal invalid pending chain status for tests that just need `valid: false`. */ + const invalidPendingChainStatus: ValidateCheckpointNegativeResult = { + valid: false, + checkpoint: { + checkpointNumber: CheckpointNumber(1), + timestamp: 0n, + archive: Fr.ZERO, + lastArchive: Fr.ZERO, + slotNumber: SlotNumber(1), + }, + committee: [], + epoch: EpochNumber(1), + seed: 0n, + attestors: [], + attestations: [], + reason: 'insufficient-attestations', + }; + const getSignatures = () => [mockedAttestation]; const getCheckpointAttestations = () => { @@ -197,6 +217,13 @@ describe('sequencer', () => { publisher.enqueueProposeCheckpoint.mockResolvedValue(undefined); publisher.enqueueGovernanceCastSignal.mockResolvedValue(true); publisher.enqueueSlashingActions.mockResolvedValue(true); + publisher.sendRequestsAt.mockResolvedValue({ + result: { receipt: { status: 'success' } as any, errorMsg: undefined }, + successfulActions: ['propose'], + failedActions: [], + sentActions: ['propose'], + expiredActions: [], + }); publisher.canProposeAt.mockResolvedValue({ slot: SlotNumber(newSlotNumber), checkpointNumber: CheckpointNumber.fromBlockNumber(newBlockNumber), @@ -218,11 +245,12 @@ describe('sequencer', () => { p2p = mock({ getStatus: mockFn().mockResolvedValue({ syncedToL2Block: { number: lastBlockNumber, hash } }), + getCheckpointAttestationsForSlot: mockFn().mockResolvedValue([]), }); worldState = mock({ getCommitted: mockFn().mockReturnValue({ - getTreeInfo: mockFn().mockResolvedValue({ root: Fr.random().toBuffer(), size: 99n, depth: 5 }), + getTreeInfo: mockFn().mockResolvedValue({ root: mockedArchiveRoot.toBuffer(), size: 99n, depth: 5 }), }), status: mockFn().mockResolvedValue({ state: WorldStateRunningState.IDLE, @@ -286,6 +314,7 @@ describe('sequencer', () => { getCheckpointsForEpoch: mockFn().mockResolvedValue([]), getCheckpointsDataForEpoch: mockFn().mockResolvedValue([]), getSyncedL2SlotNumber: mockFn().mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)), + getPendingCheckpoint: mockFn().mockResolvedValue(undefined), }); l1ToL2MessageSource = mock({ @@ -370,7 +399,10 @@ describe('sequencer', () => { it('builds a checkpoint when it is their turn', async () => { await setupSingleTxBlock(); - // Not your turn! canProposeAtNextEthBlock returns undefined + // Force L1 check by marking pending chain as not yet validated + l2BlockSource.getPendingChainValidationStatus.mockResolvedValue(invalidPendingChainStatus); + + // Not your turn! canProposeAt returns undefined publisher.canProposeAt.mockResolvedValue(undefined); await sequencer.work(); @@ -445,8 +477,8 @@ describe('sequencer', () => { await sequencer.work(); - // We still call sendRequests in case there are votes enqueued - expect(publisher.sendRequests).toHaveBeenCalled(); + // We still call sendRequestsAt in case there are votes enqueued + expect(publisher.sendRequestsAt).toHaveBeenCalled(); }); it('should proceed with block proposal when there is no proposer yet', async () => { @@ -486,6 +518,13 @@ describe('sequencer', () => { pub.enqueueProposeCheckpoint.mockResolvedValue(undefined); pub.enqueueGovernanceCastSignal.mockResolvedValue(true); pub.enqueueSlashingActions.mockResolvedValue(true); + pub.sendRequestsAt.mockResolvedValue({ + result: { receipt: { status: 'success' } as any, errorMsg: undefined }, + successfulActions: ['propose'], + failedActions: [], + sentActions: ['propose'], + expiredActions: [], + }); pub.canProposeAt.mockResolvedValue({ slot: SlotNumber(newSlotNumber + i), checkpointNumber: CheckpointNumber.fromBlockNumber(BlockNumber(newBlockNumber)), @@ -926,6 +965,200 @@ describe('sequencer', () => { }); }); + describe('pipelining with pending checkpoint-based L1 check skip', () => { + beforeEach(() => { + // Skip execute() to avoid the pipeline sleep (which would block for 16s in real time). + // We only need to test prepareCheckpointProposal behavior here. + sequencer.skipExecute = true; + + // Set up a pipelining scenario: slot.now=1, slot.pipeline=2 + epochCache.isProposerPipeliningEnabled.mockReturnValue(true); + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(1), + ts: 1000n, + nowSeconds: 1000n, + }); + epochCache.getTargetEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(2), + ts: 1000n, + nowSeconds: 1000n, + }); + + // canProposeAt returns slot 2 (pipeline slot) + publisher.canProposeAt.mockResolvedValue({ + slot: SlotNumber(2), + checkpointNumber: CheckpointNumber.fromBlockNumber(newBlockNumber), + timeOfNextL1Slot: 1000n, + }); + + // We are the proposer + validatorClient.getValidatorAddresses.mockReturnValue([signer.address]); + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(signer.address); + }); + + afterEach(() => { + sequencer.skipExecute = false; + }); + + it('skips L1 check when pending checkpoint exists', async () => { + await setupSingleTxBlock(); + + // Override to non-genesis state so checkSync doesn't take the genesis path. + // pendingCheckpoint is set with checkpoint number 1 > checkpointed tip 0, so hasPendingCheckpoint is true. + const nonGenesisHash = Fr.random().toString(); + const pendingCheckpointHash = Fr.random().toString(); + worldState.status.mockResolvedValue({ + state: WorldStateRunningState.IDLE, + syncSummary: { + latestBlockNumber: BlockNumber(1), + latestBlockHash: nonGenesisHash, + finalizedBlockNumber: BlockNumber.ZERO, + oldestHistoricBlockNumber: BlockNumber.ZERO, + treesAreSynched: true, + }, + } satisfies WorldStateSynchronizerStatus); + const tipsWithBlock1 = { + proposed: { number: BlockNumber(1), hash: nonGenesisHash }, + pendingCheckpoint: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber(1), hash: pendingCheckpointHash }, + }, + checkpointed: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + proven: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + finalized: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + }; + l2BlockSource.getL2Tips.mockResolvedValue(tipsWithBlock1); + l1ToL2MessageSource.getL2Tips.mockResolvedValue(tipsWithBlock1); + p2p.getStatus.mockResolvedValue({ + syncedToL2Block: { number: BlockNumber(1), hash: nonGenesisHash }, + } as any); + l2BlockSource.getBlockData.mockResolvedValue({ + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(1) }) }), + archive: AppendOnlyTreeSnapshot.empty(), + blockHash: Fr.ZERO, + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + } satisfies BlockData); + l2BlockSource.getPendingCheckpoint.mockResolvedValue({ + checkpointNumber: CheckpointNumber(1), + } as any); + + await sequencer.work(); + + // L1 check should be called with archive override for the parent checkpoint + expect(publisher.canProposeAt).toHaveBeenCalledWith( + expect.anything(), // archive + expect.anything(), // proposer + expect.objectContaining({ + forceArchive: expect.objectContaining({ checkpointNumber: CheckpointNumber(1) }), + }), + ); + }); + + it('skips proposal when checkpoint exceeds pipeline depth', async () => { + await setupSingleTxBlock(); + + // Simulate the bug scenario: proposed tip has advanced through 2 pipelined checkpoints. + // Confirmed checkpoint is 1, pending is 2, proposed tip is in checkpoint 3. + // So sequencer would try to build checkpoint 4, which exceeds the 1-deep pipeline limit. + const nonGenesisHash = Fr.random().toString(); + const pendingCheckpointHash = Fr.random().toString(); + const checkpointedHash = Fr.random().toString(); + worldState.status.mockResolvedValue({ + state: WorldStateRunningState.IDLE, + syncSummary: { + latestBlockNumber: BlockNumber(3), + latestBlockHash: nonGenesisHash, + finalizedBlockNumber: BlockNumber.ZERO, + oldestHistoricBlockNumber: BlockNumber.ZERO, + treesAreSynched: true, + }, + } satisfies WorldStateSynchronizerStatus); + const tips = { + proposed: { number: BlockNumber(3), hash: nonGenesisHash }, + pendingCheckpoint: { + block: { number: BlockNumber(2), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber(2), hash: pendingCheckpointHash }, + }, + checkpointed: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber(1), hash: checkpointedHash }, + }, + proven: { + block: { number: BlockNumber.ZERO, hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + finalized: { + block: { number: BlockNumber.ZERO, hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + }; + l2BlockSource.getL2Tips.mockResolvedValue(tips); + l1ToL2MessageSource.getL2Tips.mockResolvedValue(tips); + p2p.getStatus.mockResolvedValue({ + syncedToL2Block: { number: BlockNumber(3), hash: nonGenesisHash }, + } as any); + l2BlockSource.getBlockData.mockResolvedValue({ + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(3) }) }), + archive: AppendOnlyTreeSnapshot.empty(), + blockHash: Fr.ZERO, + checkpointNumber: CheckpointNumber(3), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + } satisfies BlockData); + l2BlockSource.getPendingCheckpoint.mockResolvedValue({ + checkpointNumber: CheckpointNumber(2), + } as any); + + await sequencer.work(); + + // Should have bailed before reaching the L1 check: checkpoint 4 > min(1+2, 2+1) = 3 + expect(publisher.canProposeAt).not.toHaveBeenCalled(); + }); + + it('calls L1 check without archive override when no pending checkpoint', async () => { + await setupSingleTxBlock(); + + await sequencer.work(); + + // L1 check should be called without archive override (empty overrides object) + expect(publisher.canProposeAt).toHaveBeenCalledWith(expect.anything(), expect.anything(), {}); + }); + + it('calls L1 check without overrides when not pipelining', async () => { + await setupSingleTxBlock(); + + // Override back to non-pipelining + epochCache.isProposerPipeliningEnabled.mockReturnValue(false); + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(1), + ts: 1000n, + nowSeconds: 1000n, + }); + publisher.canProposeAt.mockResolvedValue({ + slot: SlotNumber(1), + checkpointNumber: CheckpointNumber.fromBlockNumber(newBlockNumber), + timeOfNextL1Slot: 1000n, + }); + + await sequencer.work(); + + // L1 check should be called without any overrides (empty object) + expect(publisher.canProposeAt).toHaveBeenCalledWith(expect.anything(), expect.anything(), {}); + }); + }); + describe('view-based proposer lookup', () => { it('passes target slot to getProposerAttesterAddressInSlot', async () => { const proposer = signer.address; @@ -957,6 +1190,9 @@ describe('sequencer', () => { }); class TestSequencer extends Sequencer { + /** When true, work() only runs prepareCheckpointProposal and skips execute(). */ + public skipExecute = false; + public getTimeTable() { return this.timetable; } @@ -965,8 +1201,15 @@ class TestSequencer extends Sequencer { this.l1Constants.l1GenesisTime = BigInt(l1GenesisTime); } - public override work() { + public override async work() { this.setState(SequencerState.IDLE, undefined, { force: true }); + if (this.skipExecute) { + this.setState(SequencerState.SYNCHRONIZING, undefined); + const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot(); + const { slot: targetSlot, epoch: targetEpoch } = this.epochCache.getTargetEpochAndSlotInNextL1Slot(); + await this.prepareCheckpointProposal(slot, targetSlot, epoch, targetEpoch, ts, nowSeconds); + return; + } return super.work(); } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 1392a1be2ec7..553c73127851 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -13,7 +13,7 @@ import type { TypedEventEmitter } from '@aztec/foundation/types'; 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 type { Checkpoint, PendingCheckpointData } from '@aztec/stdlib/checkpoint'; import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers'; import { type ResolvedSequencerConfig, @@ -72,6 +72,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter maxAllowedCheckpoint) { + this.log.warn( + `Skipping slot ${targetSlot}: checkpoint ${checkpointNumber} exceeds max pipeline depth (confirmed=${confirmedCkpt}, pending=${pendingCkpt})`, + ); + return undefined; + } + const logCtx = { nowSeconds, syncedToL2Slot: syncedTo.syncedL2Slot, @@ -339,18 +361,44 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter { // Check that the archiver has fully synced the L2 slot before the one we want to propose in. @@ -518,25 +567,37 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter t.proposed), + this.l2BlockSource + .getL2Tips() + .then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed, pendingCheckpoint: t.pendingCheckpoint })), this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block), this.l1ToL2MessageSource.getL2Tips().then(t => t.proposed), this.l2BlockSource.getPendingChainValidationStatus(), + this.l2BlockSource.getPendingCheckpoint(), ] as const); - const [worldState, l2BlockSource, p2p, l1ToL2MessageSource, pendingChainValidationStatus] = syncedBlocks; + const [worldState, l2Tips, p2p, l1ToL2MessageSource, pendingChainValidationStatus, pendingCheckpointData] = + syncedBlocks; // Handle zero as a special case, since the block hash won't match across services if we're changing the prefilled data for the genesis block, // as the world state can compute the new genesis block hash, but other components use the hardcoded constant. // TODO(palla/mbps): Fix the above. All components should be able to handle dynamic genesis block hashes. const result = - (l2BlockSource.number === 0 && worldState.number === 0 && p2p.number === 0 && l1ToL2MessageSource.number === 0) || - (worldState.hash === l2BlockSource.hash && - p2p.hash === l2BlockSource.hash && - l1ToL2MessageSource.hash === l2BlockSource.hash); + (l2Tips.proposed.number === 0 && + worldState.number === 0 && + p2p.number === 0 && + l1ToL2MessageSource.number === 0) || + (worldState.hash === l2Tips.proposed.hash && + p2p.hash === l2Tips.proposed.hash && + l1ToL2MessageSource.hash === l2Tips.proposed.hash); if (!result) { - this.log.debug(`Sequencer sync check failed`, { worldState, l2BlockSource, p2p, l1ToL2MessageSource }); + this.log.debug(`Sequencer sync check failed`, { + worldState, + l2BlockSource: l2Tips.proposed, + p2p, + l1ToL2MessageSource, + }); return undefined; } @@ -546,8 +607,10 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter; + /** Returns the pending checkpoint data, if set. */ + getPendingCheckpoint(): Promise; + /** Force a sync. */ syncImmediate(): Promise; @@ -310,12 +314,13 @@ export interface L2BlockSourceEventEmitter extends L2BlockSource { * - proven: Proven block on L1. * - finalized: Proven block on a finalized L1 block (not implemented, set to proven for now). */ -export type L2BlockTag = 'proposed' | 'checkpointed' | 'proven' | 'finalized'; +export type L2BlockTag = 'proposed' | 'pendingCheckpoint' | 'checkpointed' | 'proven' | 'finalized'; /** Tips of the L2 chain. */ export type L2Tips = { proposed: L2BlockId; checkpointed: L2TipId; + pendingCheckpoint?: L2TipId; proven: L2TipId; finalized: L2TipId; }; @@ -360,6 +365,7 @@ const L2TipIdSchema = z.object({ export const L2TipsSchema = z.object({ proposed: L2BlockIdSchema, checkpointed: L2TipIdSchema, + pendingCheckpoint: L2TipIdSchema.optional(), proven: L2TipIdSchema, finalized: L2TipIdSchema, }); diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts index 1ba698b4b307..303523697942 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts @@ -92,6 +92,7 @@ describe('L2BlockStream', () => { blockSource.getL2Tips.mockResolvedValue({ proposed: { number: BlockNumber(latest), hash: makeHash(latest) }, checkpointed: makeTipId(checkpointed_), + pendingCheckpoint: undefined, proven: makeTipId(proven), finalized: makeTipId(finalized), }); diff --git a/yarn-project/stdlib/src/block/test/l2_tips_store_test_suite.ts b/yarn-project/stdlib/src/block/test/l2_tips_store_test_suite.ts index c80191c7a158..163c76efe45e 100644 --- a/yarn-project/stdlib/src/block/test/l2_tips_store_test_suite.ts +++ b/yarn-project/stdlib/src/block/test/l2_tips_store_test_suite.ts @@ -72,6 +72,7 @@ export function testL2TipsStore(makeTipsStore: () => Promise) { proven: makeTipId(proven), finalized: makeTipId(finalized), checkpointed: makeTipId(checkpointed), + pendingCheckpoint: undefined, }); const makeCheckpoint = async (checkpointNumber: number, blocks: L2Block[]): Promise => { diff --git a/yarn-project/stdlib/src/checkpoint/index.ts b/yarn-project/stdlib/src/checkpoint/index.ts index 96c176e1d861..c8e2466019d8 100644 --- a/yarn-project/stdlib/src/checkpoint/index.ts +++ b/yarn-project/stdlib/src/checkpoint/index.ts @@ -1,5 +1,6 @@ export * from './checkpoint.js'; export * from './checkpoint_data.js'; export * from './checkpoint_info.js'; +export * from './pending_checkpoint_data.js'; export * from './published_checkpoint.js'; export * from './validate.js'; diff --git a/yarn-project/stdlib/src/checkpoint/pending_checkpoint_data.ts b/yarn-project/stdlib/src/checkpoint/pending_checkpoint_data.ts new file mode 100644 index 000000000000..9ea5a39e4faa --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/pending_checkpoint_data.ts @@ -0,0 +1,31 @@ +import { + type BlockNumber, + BlockNumberSchema, + type CheckpointNumber, + CheckpointNumberSchema, +} from '@aztec/foundation/branded-types'; + +import { z } from 'zod'; + +import { CheckpointHeader } from '../rollup/checkpoint_header.js'; +import { schemas } from '../schemas/schemas.js'; + +/** Lightweight data for a pending checkpoint (attested but not yet L1-confirmed). + * Includes fee-relevant fields used during pipelining to compute the fee header override. */ +export type PendingCheckpointData = { + checkpointNumber: CheckpointNumber; + header: CheckpointHeader; + startBlock: BlockNumber; + blockCount: number; + totalManaUsed: bigint; + feeAssetPriceModifier: bigint; +}; + +export const PendingCheckpointDataSchema = z.object({ + checkpointNumber: CheckpointNumberSchema, + header: CheckpointHeader.schema, + startBlock: BlockNumberSchema, + blockCount: z.number(), + totalManaUsed: schemas.BigInt, + feeAssetPriceModifier: schemas.BigInt, +}); diff --git a/yarn-project/stdlib/src/epoch-helpers/index.test.ts b/yarn-project/stdlib/src/epoch-helpers/index.test.ts index 150d8216714d..b2549e6f163e 100644 --- a/yarn-project/stdlib/src/epoch-helpers/index.test.ts +++ b/yarn-project/stdlib/src/epoch-helpers/index.test.ts @@ -1,6 +1,11 @@ import { EpochNumber } from '@aztec/foundation/branded-types'; -import { type L1RollupConstants, getProofSubmissionDeadlineTimestamp, getTimestampRangeForEpoch } from './index.js'; +import { + type L1RollupConstants, + computeQuorum, + getProofSubmissionDeadlineTimestamp, + getTimestampRangeForEpoch, +} from './index.js'; describe('EpochHelpers', () => { let constants: Omit; @@ -34,4 +39,30 @@ describe('EpochHelpers', () => { const deadline = getProofSubmissionDeadlineTimestamp(EpochNumber.fromBigInt(3n), constants); expect(deadline).toEqual(l1GenesisTime + BigInt(24 * 4 * 3) + BigInt(24 * 8)); }); + + describe('computeQuorum', () => { + it('returns 1 for committee size 0', () => { + expect(computeQuorum(0)).toBe(1); + }); + + it('returns 1 for committee size 1', () => { + expect(computeQuorum(1)).toBe(1); + }); + + it('returns 2 for committee size 2', () => { + expect(computeQuorum(2)).toBe(2); + }); + + it('returns 3 for committee size 3', () => { + expect(computeQuorum(3)).toBe(3); + }); + + it('returns 3 for committee size 4', () => { + expect(computeQuorum(4)).toBe(3); + }); + + it('returns 33 for committee size 48', () => { + expect(computeQuorum(48)).toBe(33); + }); + }); }); diff --git a/yarn-project/stdlib/src/epoch-helpers/index.ts b/yarn-project/stdlib/src/epoch-helpers/index.ts index 637afa3caf09..1bd95bad77af 100644 --- a/yarn-project/stdlib/src/epoch-helpers/index.ts +++ b/yarn-project/stdlib/src/epoch-helpers/index.ts @@ -143,6 +143,11 @@ export function getProofSubmissionDeadlineTimestamp( return getTimestampForSlot(deadlineSlot, constants); } +/** Computes the quorum size required for a committee (⌊2n/3⌋ + 1). */ +export function computeQuorum(committeeSize: number): number { + return Math.floor((committeeSize * 2) / 3) + 1; +} + /** Returns the timestamp to start building a block for a given L2 slot. Computed as the start timestamp of the slot minus one L1 slot duration. */ export function getSlotStartBuildTimestamp( slotNumber: SlotNumber, diff --git a/yarn-project/stdlib/src/interfaces/archiver.test.ts b/yarn-project/stdlib/src/interfaces/archiver.test.ts index c996c11d7178..85f1d31f84eb 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.test.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.test.ts @@ -15,6 +15,7 @@ import type { L2Tips } from '../block/l2_block_source.js'; import type { ValidateCheckpointResult } from '../block/validate_block_result.js'; import { Checkpoint } from '../checkpoint/checkpoint.js'; import type { CheckpointData } from '../checkpoint/checkpoint_data.js'; +import type { PendingCheckpointData } from '../checkpoint/pending_checkpoint_data.js'; import { L1PublishedData, PublishedCheckpoint } from '../checkpoint/published_checkpoint.js'; import { getContractClassFromArtifact } from '../contract/contract_class.js'; import { @@ -359,6 +360,11 @@ describe('ArchiverApiSchema', () => { expect(result).toBe(1n); }); + it('getPendingCheckpoint', async () => { + const result = await context.client.getPendingCheckpoint(); + expect(result).toBeUndefined(); + }); + it('getPendingChainValidationStatus', async () => { const result = await context.client.getPendingChainValidationStatus(); expect(result).toEqual({ valid: true }); @@ -392,6 +398,9 @@ class MockArchiver implements ArchiverApi { getPendingChainValidationStatus(): Promise { return Promise.resolve({ valid: true }); } + getPendingCheckpoint(): Promise { + return Promise.resolve(undefined); + } syncImmediate() { return Promise.resolve(); } @@ -567,6 +576,7 @@ class MockArchiver implements ArchiverApi { return Promise.resolve({ proposed: { number: BlockNumber(1), hash: `0x01` }, checkpointed: tipId, + pendingCheckpoint: undefined, proven: tipId, finalized: tipId, }); diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 16ed315e131c..da9d29ee1f63 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -12,6 +12,7 @@ import { type L2BlockSource, L2TipsSchema } from '../block/l2_block_source.js'; import { ValidateCheckpointResultSchema } from '../block/validate_block_result.js'; import { Checkpoint } from '../checkpoint/checkpoint.js'; import { CheckpointDataSchema } from '../checkpoint/checkpoint_data.js'; +import { PendingCheckpointDataSchema } from '../checkpoint/pending_checkpoint_data.js'; import { PublishedCheckpoint } from '../checkpoint/published_checkpoint.js'; import { ContractClassPublicSchema, @@ -150,6 +151,7 @@ export const ArchiverApiSchema: ApiSchemaFor = { .args() .returns(z.object({ genesisArchiveRoot: schemas.Fr })), getL1Timestamp: z.function().args().returns(schemas.BigInt.optional()), + getPendingCheckpoint: z.function().args().returns(PendingCheckpointDataSchema.optional()), syncImmediate: z.function().args().returns(z.void()), isPendingChainInvalid: z.function().args().returns(z.boolean()), getPendingChainValidationStatus: z.function().args().returns(ValidateCheckpointResultSchema), diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index 71eef7dca702..d59224019b45 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -533,6 +533,7 @@ class MockAztecNode implements AztecNode { return Promise.resolve({ proposed: { number: BlockNumber(1), hash: `0x01` }, checkpointed: tipId, + pendingCheckpoint: undefined, proven: tipId, finalized: tipId, }); diff --git a/yarn-project/stdlib/src/interfaces/prover-node.test.ts b/yarn-project/stdlib/src/interfaces/prover-node.test.ts index 884d5110cb7d..98faf714deb9 100644 --- a/yarn-project/stdlib/src/interfaces/prover-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/prover-node.test.ts @@ -75,6 +75,7 @@ class MockProverNode implements ProverNodeApi { return Promise.resolve({ proposed: { number: BlockNumber(1), hash: `0x01` }, checkpointed: tipId, + pendingCheckpoint: undefined, proven: tipId, finalized: tipId, }); diff --git a/yarn-project/stdlib/src/tests/factories.ts b/yarn-project/stdlib/src/tests/factories.ts index c33aaa591bd4..18b2f32d4174 100644 --- a/yarn-project/stdlib/src/tests/factories.ts +++ b/yarn-project/stdlib/src/tests/factories.ts @@ -1748,6 +1748,7 @@ export function makeL2Tips( block: { number: bn, hash }, checkpoint: { number: cpn, hash: cph }, }, + pendingCheckpoint: undefined, proven: { block: { number: bn, hash }, checkpoint: { number: cpn, hash: cph }, diff --git a/yarn-project/stdlib/src/tx/global_variable_builder.ts b/yarn-project/stdlib/src/tx/global_variable_builder.ts index 7cc64ab7bf18..1882ff54a2ed 100644 --- a/yarn-project/stdlib/src/tx/global_variable_builder.ts +++ b/yarn-project/stdlib/src/tx/global_variable_builder.ts @@ -1,3 +1,4 @@ +import type { CheckpointNumber } from '@aztec/foundation/branded-types'; import type { EthAddress } from '@aztec/foundation/eth-address'; import type { SlotNumber } from '@aztec/foundation/schemas'; @@ -6,6 +7,24 @@ import type { GasFees } from '../gas/gas_fees.js'; import type { UInt32 } from '../types/index.js'; import type { CheckpointGlobalVariables, GlobalVariables } from './global_variables.js'; +/** Fee header fields needed for pipelining overrides. */ +export type ForcePendingFeeHeader = { + checkpointNumber: CheckpointNumber; + feeHeader: { + excessMana: bigint; + manaUsed: bigint; + ethPerFeeAsset: bigint; + congestionCost: bigint; + proverCost: bigint; + }; +}; + +/** Options for building checkpoint global variables during pipelining. */ +export type BuildCheckpointGlobalVariablesOpts = { + forcePendingCheckpointNumber?: CheckpointNumber; + forcePendingFeeHeader?: ForcePendingFeeHeader; +}; + /** * Interface for building global variables for Aztec blocks. */ @@ -32,5 +51,6 @@ export interface GlobalVariableBuilder { coinbase: EthAddress, feeRecipient: AztecAddress, slotNumber: SlotNumber, + opts?: BuildCheckpointGlobalVariablesOpts, ): Promise; } diff --git a/yarn-project/txe/src/state_machine/archiver.ts b/yarn-project/txe/src/state_machine/archiver.ts index 251a8b579189..ff4e0cbb9203 100644 --- a/yarn-project/txe/src/state_machine/archiver.ts +++ b/yarn-project/txe/src/state_machine/archiver.ts @@ -76,6 +76,7 @@ export class TXEArchiver extends ArchiverDataSourceBase { proven: tipId, finalized: tipId, checkpointed: tipId, + pendingCheckpoint: undefined, }; } diff --git a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts index 4958f8773169..28e9564f1a79 100644 --- a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts +++ b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts @@ -61,8 +61,12 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "registerBlockProposalHandler"'); } - public registerCheckpointProposalHandler(_handler: P2PCheckpointReceivedCallback): void { - throw new Error('DummyP2P does not implement "registerCheckpointProposalHandler"'); + public registerValidatorCheckpointProposalHandler(_handler: P2PCheckpointReceivedCallback): void { + throw new Error('DummyP2P does not implement "registerValidatorCheckpointProposalHandler"'); + } + + public registerAllNodesCheckpointProposalHandler(_handler: P2PCheckpointReceivedCallback): void { + throw new Error('DummyP2P does not implement "registerAllNodesCheckpointProposalHandler"'); } public requestTxs(_txHashes: TxHash[]): Promise<(Tx | undefined)[]> { diff --git a/yarn-project/txe/src/state_machine/global_variable_builder.ts b/yarn-project/txe/src/state_machine/global_variable_builder.ts index 4650ac503bf6..68143e67c383 100644 --- a/yarn-project/txe/src/state_machine/global_variable_builder.ts +++ b/yarn-project/txe/src/state_machine/global_variable_builder.ts @@ -3,7 +3,12 @@ import type { EthAddress } from '@aztec/foundation/eth-address'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { GasFees } from '@aztec/stdlib/gas'; import { makeGlobalVariables } from '@aztec/stdlib/testing'; -import { type CheckpointGlobalVariables, type GlobalVariableBuilder, GlobalVariables } from '@aztec/stdlib/tx'; +import { + type BuildCheckpointGlobalVariablesOpts, + type CheckpointGlobalVariables, + type GlobalVariableBuilder, + GlobalVariables, +} from '@aztec/stdlib/tx'; export class TXEGlobalVariablesBuilder implements GlobalVariableBuilder { public getCurrentMinFees(): Promise { @@ -23,6 +28,7 @@ export class TXEGlobalVariablesBuilder implements GlobalVariableBuilder { _coinbase: EthAddress, _feeRecipient: AztecAddress, _slotNumber: SlotNumber, + _opts?: BuildCheckpointGlobalVariablesOpts, ): Promise { const vars = makeGlobalVariables(); return Promise.resolve({ diff --git a/yarn-project/validator-client/src/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index 844c8adec1e4..7943b68e9fe3 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -279,7 +279,7 @@ export class BlockProposalHandler { // If we succeeded, push this block into the archiver (unless disabled) if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) { - await this.blockSource.addBlock(reexecutionResult?.block); + await this.blockSource.addBlock(reexecutionResult.block); } this.log.info( diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index b76ac2bce67c..db4abfdd147c 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -2,7 +2,13 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS } from '@aztec/ethereum/contracts'; -import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + EpochNumber, + IndexWithinCheckpoint, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times } from '@aztec/foundation/collection'; import { SecretValue, getConfigFromMappings } from '@aztec/foundation/config'; @@ -119,6 +125,25 @@ describe('ValidatorClient', () => { epochCache.getL1Constants.mockReturnValue({ epochDuration: 8 } satisfies Parameters< typeof getEpochAtSlot >[1] as any); + epochCache.getEpochAndSlotNow.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(1), + ts: 0n, + nowMs: 0n, + }); + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(1), + ts: 0n, + nowSeconds: 0n, + }); + epochCache.getTargetSlot.mockReturnValue(SlotNumber(1)); + epochCache.getTargetEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(2), + ts: 0n, + nowSeconds: 0n, + }); blockSource = mock(); blockSource.getCheckpointedBlocksForEpoch.mockResolvedValue([]); @@ -347,6 +372,25 @@ describe('ValidatorClient', () => { targetSlot: proposal.slotNumber, nextSlot: SlotNumber(proposal.slotNumber + 1), }); + epochCache.getEpochAndSlotNow.mockReturnValue({ + epoch: EpochNumber(1), + slot: proposal.slotNumber, + ts: 0n, + nowMs: 0n, + }); + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: proposal.slotNumber, + ts: 0n, + nowSeconds: 0n, + }); + epochCache.getTargetSlot.mockReturnValue(proposal.slotNumber); + epochCache.getTargetEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(proposal.slotNumber + 1), + ts: 0n, + nowSeconds: 0n, + }); epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposal.getSender()); epochCache.filterInCommittee.mockResolvedValue([EthAddress.fromString(validatorAccounts[0].address)]); epochCache.isEscapeHatchOpenAtSlot.mockResolvedValue(false); @@ -697,6 +741,25 @@ describe('ValidatorClient', () => { targetSlot: SlotNumber(proposal.slotNumber + 20), nextSlot: SlotNumber(proposal.slotNumber + 21), }); + epochCache.getEpochAndSlotNow.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(proposal.slotNumber + 20), + ts: 0n, + nowMs: 0n, + }); + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(proposal.slotNumber + 20), + ts: 0n, + nowSeconds: 0n, + }); + epochCache.getTargetSlot.mockReturnValue(SlotNumber(proposal.slotNumber + 20)); + epochCache.getTargetEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(proposal.slotNumber + 21), + ts: 0n, + nowSeconds: 0n, + }); const isValid = await validatorClient.validateBlockProposal(proposal, sender); expect(isValid).toBe(false); @@ -759,6 +822,25 @@ describe('ValidatorClient', () => { targetSlot: nonFirstBlockProposal.slotNumber, nextSlot: SlotNumber(nonFirstBlockProposal.slotNumber + 1), }); + epochCache.getEpochAndSlotNow.mockReturnValue({ + epoch: EpochNumber(1), + slot: nonFirstBlockProposal.slotNumber, + ts: 0n, + nowMs: 0n, + }); + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: nonFirstBlockProposal.slotNumber, + ts: 0n, + nowSeconds: 0n, + }); + epochCache.getTargetSlot.mockReturnValue(nonFirstBlockProposal.slotNumber); + epochCache.getTargetEpochAndSlotInNextL1Slot.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(nonFirstBlockProposal.slotNumber + 1), + ts: 0n, + nowSeconds: 0n, + }); // Mock parent block data returned by getBlockDataByArchive blockSource.getBlockDataByArchive.mockResolvedValue({ diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index e699d0bdf0bc..238d3fecc5d9 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -350,7 +350,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) checkpoint: CheckpointProposalCore, proposalSender: PeerId, ): Promise => this.attestToCheckpointProposal(checkpoint, proposalSender); - this.p2pClient.registerCheckpointProposalHandler(checkpointHandler); + this.p2pClient.registerValidatorCheckpointProposalHandler(checkpointHandler); // Duplicate proposal handler - triggers slashing for equivocation this.p2pClient.registerDuplicateProposalCallback((info: DuplicateProposalInfo) => { diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts index 8cc23fa2ac0c..0f8484dfc87a 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts @@ -307,6 +307,7 @@ class TestWorldStateSynchronizer extends ServerWorldStateSynchronizer { checkpointed: makeTipId(this.latest), proven: makeTipId(this.proven), finalized: makeTipId(this.finalized), + pendingCheckpoint: undefined, }); } } diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index 37d55fce1eb4..e9fc38cd54be 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -301,6 +301,7 @@ export class ServerWorldStateSynchronizer block: { number: provenBlockNumber, hash: provenBlockHash ?? '' }, checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, + pendingCheckpoint: undefined, }; }