diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index 066c73ce121a..6fb3cbec7157 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -51,6 +51,7 @@ export interface EpochCacheInterface { /** Returns epoch/slot info for the next L1 slot with pipeline offset applied. */ getTargetEpochAndSlotInNextL1Slot(): EpochAndSlot & { nowSeconds: bigint }; isProposerPipeliningEnabled(): boolean; + pipeliningOffset(): number; isEscapeHatchOpen(epoch: EpochNumber): Promise; isEscapeHatchOpenAtSlot(slot: SlotTag): Promise; getProposerIndexEncoding(epoch: EpochNumber, slot: SlotNumber, seed: bigint): `0x${string}`; @@ -169,6 +170,10 @@ export class EpochCache implements EpochCacheInterface { return this.enableProposerPipelining; } + public pipeliningOffset(): number { + return this.enableProposerPipelining ? PROPOSER_PIPELINING_SLOT_OFFSET : 0; + } + public getSlotNow(): SlotNumber { return this.getEpochAndSlotNow().slot; } diff --git a/yarn-project/epoch-cache/src/test/test_epoch_cache.ts b/yarn-project/epoch-cache/src/test/test_epoch_cache.ts index b9e50a06128f..8900ebac9537 100644 --- a/yarn-project/epoch-cache/src/test/test_epoch_cache.ts +++ b/yarn-project/epoch-cache/src/test/test_epoch_cache.ts @@ -147,6 +147,10 @@ export class TestEpochCache implements EpochCacheInterface { return this.proposerPipeliningEnabled; } + pipeliningOffset(): number { + return this.proposerPipeliningEnabled ? PROPOSER_PIPELINING_SLOT_OFFSET : 0; + } + getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint } { const epochNow = getEpochAtSlot(this.currentSlot, this.l1Constants); const ts = getTimestampRangeForEpoch(epochNow, this.l1Constants)[0]; diff --git a/yarn-project/p2p/src/test-helpers/testbench-utils.ts b/yarn-project/p2p/src/test-helpers/testbench-utils.ts index 86903280deed..c8d142136278 100644 --- a/yarn-project/p2p/src/test-helpers/testbench-utils.ts +++ b/yarn-project/p2p/src/test-helpers/testbench-utils.ts @@ -287,6 +287,7 @@ export function createMockEpochCache(): EpochCacheInterface { nowMs: 0n, }), isProposerPipeliningEnabled: () => false, + pipeliningOffset: () => 0, computeProposerIndex: () => 0n, getCurrentAndNextSlot: () => ({ currentSlot: SlotNumber.ZERO, nextSlot: SlotNumber.ZERO }), getTargetAndNextSlot: () => ({ targetSlot: SlotNumber.ZERO, nextSlot: SlotNumber.ZERO }), diff --git a/yarn-project/sequencer-client/src/sequencer/README.md b/yarn-project/sequencer-client/src/sequencer/README.md index 366e79d7fc7a..5c62434b4e5a 100644 --- a/yarn-project/sequencer-client/src/sequencer/README.md +++ b/yarn-project/sequencer-client/src/sequencer/README.md @@ -1,8 +1,8 @@ # Sequencer Timing Model -The Aztec sequencer divides each slot into **fixed-duration sub-slots**. Each sub-slot has a pre-defined start and end time based on an initialization offset (how much time we expect syncing the previous slot will take), a finalization time (how much time we need for closing a checkpoint and publishing it to L1), and the configured block duration. +The Aztec sequencer divides each slot into **fixed-duration sub-slots**. Each sub-slot has a pre-defined start and end time based on an initialization offset (how much time we expect syncing the previous slot will take), the configured block duration, and whether checkpoint finalization is paid for in the current slot or deferred under proposer pipelining. -**Example: 72-second slot with 8-second sub-slots** +**Example: 72-second slot with 8-second sub-slots (non-pipelined)** ``` 0s: Slot starts @@ -31,7 +31,7 @@ Deadlines are fixed relative to slot start, not relative to when work actually c ## Overview -The Aztec sequencer operates in fixed-duration **slots** (typically 72 seconds). During each slot, a designated proposer builds multiple **blocks** containing transactions over multiple **sub-slots**, then collects a single round of attestations for the entire **checkpoint** from validators, and finally publishes the resulting checkpoint to L1 Ethereum. +The Aztec sequencer operates in fixed-duration **slots** (typically 72 seconds). During each slot, a designated proposer builds multiple **blocks** containing transactions over multiple **sub-slots**. In the default mode, the same slot also reserves time to collect attestations for the resulting **checkpoint**, finalize it, and publish it to L1 Ethereum. When proposer pipelining is enabled, the slot budget for block building is larger because checkpoint finalization is deferred to the next target slot. ## Key Concepts @@ -42,12 +42,14 @@ The Aztec sequencer operates in fixed-duration **slots** (typically 72 seconds). - **Checkpoint**: The collection of all blocks built in a slot, attested by validators and published to L1 - **Sub-slot**: A fixed-duration time window within a slot (e.g., 8 seconds) during which a block should be built -In a typical configuration, a 72-second slot contains: +In a typical configuration without pipelining, a 72-second slot contains: - 1 initialization period (2 seconds) - 5 block-building sub-slots (8 seconds each = 40 seconds) - 1 last validator re-execution sub-slot (8 seconds) - 1 attestation and publishing period (17 seconds) +With proposer pipelining enabled, the last validator re-execution sub-slot is still reserved, but the checkpoint finalization and L1 publishing budget is no longer subtracted when deciding how many block-building sub-slots fit in the slot. + ### The Fixed Sub-Slot Model Building multiple blocks per slot uses **fixed sub-slots** with predictable deadlines: @@ -75,14 +77,18 @@ These values are configurable but must satisfy certain constraints (explained be ## Calculating Sub-Slots and Blocks -Given a slot configuration, we calculate how many blocks fit using this formula: +Given a slot configuration, we calculate how many blocks fit using these formulas: ``` -timeReservedAtEnd = blockDuration (last sub-slot for reexecution) - + propagationTime (validators receive proposal) - + propagationTime (attestations come back) - + finalizationTime (checkpoint finalization) - + l1PublishingTime (L1 transaction) +checkpointFinalizationTime = propagationTime + + propagationTime + + finalizationTime + + l1PublishingTime + +timeReservedAtEnd (normal mode) = blockDuration (last sub-slot for reexecution) + + checkpointFinalizationTime + +timeReservedAtEnd (pipelining) = blockDuration (last sub-slot for reexecution only) timeAvailableForBlocks = slotDuration - initializationOffset - timeReservedAtEnd @@ -101,6 +107,62 @@ This means: - Sub-slot 6: Reserved for validator re-execution of block 5 - After sub-slot 6: Attestation collection, finalization, and L1 publishing +**The same slot with proposer pipelining enabled:** +``` +timeReservedAtEnd = 8s +timeAvailableForBlocks = 72s - 2s - 8s = 62s +numberOfBlocks = floor(62s / 8s) = 7 blocks +``` + +The extra two block opportunities come from not charging the current slot for checkpoint finalization and L1 publishing. + +### Pipelining Mode + +When proposer pipelining is enabled, the sequencer uses the current wall-clock slot to build the checkpoint for the **next target slot**. + +It helps to think in terms of two different slots: + +- **Wall-clock slot N-1**: The sequencer initializes checkpoint `N`, builds its blocks, and validators re-execute the last block +- **Target slot N**: Checkpoint `N` is proposed, attestations are gathered, and the L1 transaction is submitted + +So the work is split like this: + +- **During slot N-1**: Initialization, block building, and last-block re-execution +- **Near the end of slot N-1**: The checkpoint proposal is broadcast and validators attest to checkpoint N. +- **During slot N**: The proposer collects signatures, and the checkpoint is submitted to L1 + +In other words, pipelining does not mean "do everything for slot N earlier". It specifically moves **block production and block re-execution** earlier, while **checkpoint proposal, attestation gathering, and L1 submission** remain aligned with slot `N`. + +**Example: building checkpoint 12 while wall-clock time is in slot 11** +``` +Slot 11 (wall clock): +- Build blocks that will make up checkpoint 12 +- Validators re-execute the last block of checkpoint 12 +- Broadcast checkpoint 12 proposal +- Collect checkpoint 12 attestations + +Slot 12 (target/submission slot): +- Collect remaining checkpoint 12 attestations +- Submit checkpoint 12 to L1 +``` + +For timetable purposes, this changes two things: + +- `maxNumberOfBlocks` is computed by reserving only the final validator re-execution sub-slot +- `initializeDeadline` no longer subtracts checkpoint finalization time; it only requires enough time for initialization, execution, and validator re-execution + +In code, that means: + +``` +initializeDeadline (normal mode) = + slotDuration - initializationOffset - 2 * minExecutionTime - checkpointFinalizationTime + +initializeDeadline (pipelining) = + slotDuration - initializationOffset - 2 * minExecutionTime +``` + +The fixed sub-slot deadlines themselves do not change. Pipelining only changes how much of the slot is considered available for block building. + ## The Sequencer's Work When elected as proposer for a slot, the sequencer performs these tasks: @@ -226,7 +288,9 @@ After the last block is built and validators have re-executed it: **Time reserved:** `2*propagationTime + finalizationTime + l1PublishingTime = 2s + 2s + 1s + 12s = 17s` -This 17s comes after the last sub-slot, ensuring we have enough time to complete the checkpoint. If the sequencer receives the necessary attestations before the reserved time, the L1 tx is submitted earlier. +In the non-pipelined path, this 17s comes after the last sub-slot, ensuring we have enough time to complete the checkpoint. If the sequencer receives the necessary attestations before the reserved time, the L1 tx is submitted earlier. + +With proposer pipelining enabled, this finalization budget is not charged against the current slot when calculating how many blocks fit. The checkpoint is instead queued for submission at the start of the target slot, so proposal broadcast, attestation gathering, and L1 submission happen in slot `N` while block building and block re-execution already happened in slot `N-1`. ## Handling Timing Variations @@ -399,7 +463,7 @@ When configuring timing parameters, ensure these constraints are satisfied: ### Minimum Slot Duration -For a valid configuration: +For a valid multi-block configuration without pipelining: ``` slotDuration >= initializationOffset + blockDuration * 2 (at least 2 blocks) @@ -414,6 +478,11 @@ Simplified: slotDuration >= initializationOffset + 3*blockDuration + 2*propagationTime + finalizationTime + l1PublishingTime ``` +With proposer pipelining enabled, the same "at least 2 buildable blocks plus the final validator re-execution sub-slot" requirement becomes: +``` +slotDuration >= initializationOffset + 3*blockDuration +``` + **Example:** ``` slotDuration >= 2s + 3*8s + 2*2s + 1s + 12s = 2s + 24s + 4s + 1s + 12s = 43s diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index a881f6b9eb97..d665bb845ef4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -123,6 +123,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter ({ proposed: t.proposed, checkpointed: t.checkpointed, proposedCheckpoint: t.proposedCheckpoint })), this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block), - this.l1ToL2MessageSource.getL2Tips().then(t => t.proposed), + this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })), this.l2BlockSource.getPendingChainValidationStatus(), this.l2BlockSource.getProposedCheckpointOnly(), ] as const); - const [worldState, l2Tips, p2p, l1ToL2MessageSource, pendingChainValidationStatus, proposedCheckpointData] = + const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] = 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, @@ -580,19 +581,25 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { }); }); }); + + describe('pipelining mode', () => { + const BLOCK_DURATION_MS = 8000; + + it('allows more blocks per slot than non-pipelining with same config', () => { + const baseOpts = { + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: AZTEC_SLOT_DURATION, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + }; + + const withoutPipelining = new SequencerTimetable({ ...baseOpts, pipelining: false }); + const withPipelining = new SequencerTimetable({ ...baseOpts, pipelining: true }); + + expect(withPipelining.maxNumberOfBlocks).toBeGreaterThan(withoutPipelining.maxNumberOfBlocks); + }); + + it('uses entire slot minus init and re-execution for block building', () => { + const tt = new SequencerTimetable({ + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: AZTEC_SLOT_DURATION, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + pipelining: true, + }); + + const blockDuration = BLOCK_DURATION_MS / 1000; + // Reserves one blockDuration for validator re-execution, but no finalization time + const availableTime = AZTEC_SLOT_DURATION - tt.initializationOffset - blockDuration; + expect(tt.maxNumberOfBlocks).toBe(Math.floor(availableTime / blockDuration)); + }); + + it('has later initialize deadline than non-pipelining', () => { + const baseOpts = { + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: AZTEC_SLOT_DURATION, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + }; + + const withoutPipelining = new SequencerTimetable({ ...baseOpts, pipelining: false }); + const withPipelining = new SequencerTimetable({ ...baseOpts, pipelining: true }); + + expect(withPipelining.initializeDeadline).toBeGreaterThan(withoutPipelining.initializeDeadline); + }); + + it('produces expected block count with test config', () => { + // Mimics e2e test config: ethereumSlotDuration=4, aztecSlotDuration=36, blockDuration=8s + const tt = new SequencerTimetable({ + ethereumSlotDuration: 4, + aztecSlotDuration: 36, + l1PublishingTime: 2, + p2pPropagationTime: 0.5, + blockDurationMs: 8000, + enforce: true, + pipelining: true, + }); + + // With pipelining and test config (ethereumSlotDuration < 8): + // init=0.5, reExec=8, available = 36 - 0.5 - 8 = 27.5, floor(27.5/8) = 3 + expect(tt.maxNumberOfBlocks).toBe(3); + }); + + it('produces more blocks with production config where finalization time is large', () => { + // With production-like config, the large finalization time means pipelining saves enough to gain blocks + const baseOpts = { + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: 120, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + }; + + const withoutPipelining = new SequencerTimetable({ ...baseOpts, pipelining: false }); + const withPipelining = new SequencerTimetable({ ...baseOpts, pipelining: true }); + + // Finalization time (1 + 2*2 + 12 = 17s) > blockDuration, so pipelining gains at least one more block + expect(withPipelining.maxNumberOfBlocks).toBeGreaterThan(withoutPipelining.maxNumberOfBlocks); + }); + }); }); diff --git a/yarn-project/sequencer-client/src/sequencer/timetable.ts b/yarn-project/sequencer-client/src/sequencer/timetable.ts index e692fb1a6159..98373bf284da 100644 --- a/yarn-project/sequencer-client/src/sequencer/timetable.ts +++ b/yarn-project/sequencer-client/src/sequencer/timetable.ts @@ -70,6 +70,9 @@ export class SequencerTimetable { /** Maximum number of blocks that can be built in this slot configuration */ public readonly maxNumberOfBlocks: number; + /** Whether pipelining is enabled (checkpoint finalization deferred to next slot). */ + public readonly pipelining: boolean; + constructor( opts: { ethereumSlotDuration: number; @@ -78,6 +81,7 @@ export class SequencerTimetable { p2pPropagationTime?: number; blockDurationMs?: number; enforce: boolean; + pipelining?: boolean; }, private readonly metrics?: SequencerMetrics, private readonly log?: Logger, @@ -88,6 +92,7 @@ export class SequencerTimetable { this.p2pPropagationTime = opts.p2pPropagationTime ?? DEFAULT_P2P_PROPAGATION_TIME; this.blockDuration = opts.blockDurationMs ? opts.blockDurationMs / 1000 : undefined; this.enforce = opts.enforce; + this.pipelining = opts.pipelining ?? false; // Assume zero-cost propagation time and faster runs in test environments where L1 slot duration is shortened if (this.ethereumSlotDuration < 8) { @@ -116,18 +121,23 @@ export class SequencerTimetable { if (!this.blockDuration) { this.maxNumberOfBlocks = 1; // Single block per slot } else { - const timeReservedAtEnd = - this.blockDuration + // Last sub-slot for validator re-execution - this.checkpointFinalizationTime; // Checkpoint finalization + // When pipelining, finalization is deferred to the next slot, but we still need + // a sub-slot for validator re-execution so they can produce attestations. + let timeReservedAtEnd = this.blockDuration; // Validatior re-execution only + if (!this.pipelining) { + timeReservedAtEnd += this.checkpointFinalizationTime; + } + const timeAvailableForBlocks = this.aztecSlotDuration - this.initializationOffset - timeReservedAtEnd; this.maxNumberOfBlocks = Math.floor(timeAvailableForBlocks / this.blockDuration); } - // Minimum work to do within a slot for building a block with the minimum time for execution and publishing its checkpoint - const minWorkToDo = - this.initializationOffset + - this.minExecutionTime * 2 + // Execution and reexecution - this.checkpointFinalizationTime; + // Minimum work to do within a slot for building a block with the minimum time for execution and publishing its checkpoint. + // When pipelining, finalization is deferred, but we still need time for execution and validator re-execution. + let minWorkToDo = this.initializationOffset + this.minExecutionTime * 2; + if (!this.pipelining) { + minWorkToDo += this.checkpointFinalizationTime; + } const initializeDeadline = this.aztecSlotDuration - minWorkToDo; this.initializeDeadline = initializeDeadline; @@ -144,6 +154,7 @@ export class SequencerTimetable { blockAssembleTime: this.checkpointAssembleTime, initializeDeadline: this.initializeDeadline, enforce: this.enforce, + pipelining: this.pipelining, minWorkToDo, blockDuration: this.blockDuration, maxNumberOfBlocks: this.maxNumberOfBlocks, diff --git a/yarn-project/stdlib/src/timetable/index.ts b/yarn-project/stdlib/src/timetable/index.ts index e598b6849afb..eb76fb63f72a 100644 --- a/yarn-project/stdlib/src/timetable/index.ts +++ b/yarn-project/stdlib/src/timetable/index.ts @@ -42,6 +42,7 @@ export function calculateMaxBlocksPerSlot( checkpointAssembleTime?: number; p2pPropagationTime?: number; l1PublishingTime?: number; + pipelining?: boolean; } = {}, ): number { if (!blockDurationSec) { @@ -56,8 +57,12 @@ export function calculateMaxBlocksPerSlot( // Calculate checkpoint finalization time (assembly + round-trip propagation + L1 publishing) const checkpointFinalizationTime = assembleTime + p2pTime * 2 + l1Time; - // Time reserved at end for last sub-slot (validator re-execution) + finalization - const timeReservedAtEnd = blockDurationSec + checkpointFinalizationTime; + // When pipelining, finalization is deferred to the next slot, but we still reserve + // a sub-slot for validator re-execution so they can produce attestations. + let timeReservedAtEnd = blockDurationSec; + if (!opts.pipelining) { + timeReservedAtEnd += checkpointFinalizationTime; + } // Time available for building blocks const timeAvailableForBlocks = aztecSlotDurationSec - initOffset - timeReservedAtEnd; diff --git a/yarn-project/txe/src/state_machine/mock_epoch_cache.ts b/yarn-project/txe/src/state_machine/mock_epoch_cache.ts index 66c0f098222d..cfca8a29605b 100644 --- a/yarn-project/txe/src/state_machine/mock_epoch_cache.ts +++ b/yarn-project/txe/src/state_machine/mock_epoch_cache.ts @@ -33,6 +33,10 @@ export class MockEpochCache implements EpochCacheInterface { return EpochNumber.ZERO; } + pipeliningOffset(): number { + return 0; + } + getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint } { return { epoch: EpochNumber.ZERO, diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index af81e14fc8a7..5cabb18f6731 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -374,7 +374,6 @@ export class ProposalHandler { private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> { const parentArchive = proposal.blockHeader.lastArchive.root; - const slot = proposal.slotNumber; const config = this.checkpointsBuilder.getConfig(); const { genesisArchiveRoot } = await this.blockSource.getGenesisValues(); @@ -382,7 +381,7 @@ export class ProposalHandler { return 'genesis'; } - const deadline = this.getReexecutionDeadline(slot, config); + const deadline = this.getReexecutionDeadline(proposal.slotNumber, config); const currentTime = this.dateProvider.now(); const timeoutDurationMs = deadline.getTime() - currentTime; @@ -531,8 +530,14 @@ export class ProposalHandler { return undefined; } - private getReexecutionDeadline(slot: SlotNumber, config: { l1GenesisTime: bigint; slotDuration: number }): Date { - const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config)); + private getReexecutionDeadline( + slotNumber: SlotNumber, + config: { l1GenesisTime: bigint; slotDuration: number }, + ): Date { + // Under proposer pipelining, the proposal slot may be ahead of wall clock time. + // Reexecution budgets should still be bounded by the current slot we are in now. + const wallclockSlot = slotNumber - this.epochCache.pipeliningOffset(); + const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(wallclockSlot + 1), config)); return new Date(nextSlotTimestampSeconds * 1000); } @@ -545,8 +550,9 @@ export class ProposalHandler { } // Make a quick check before triggering an archiver sync + // If we are pipelining and have a pending checkpoint number stored, we will allow the block proposal to be for a slot further const syncedSlot = await this.blockSource.getSyncedL2SlotNumber(); - if (syncedSlot !== undefined && syncedSlot + 1 >= slot) { + if (syncedSlot !== undefined && syncedSlot + 1 + this.epochCache.pipeliningOffset() >= slot) { return true; } @@ -555,8 +561,8 @@ export class ProposalHandler { return await retryUntil( async () => { await this.blockSource.syncImmediate(); - const syncedSlot = await this.blockSource.getSyncedL2SlotNumber(); - return syncedSlot !== undefined && syncedSlot + 1 >= slot; + const updatedSyncedSlot = await this.blockSource.getSyncedL2SlotNumber(); + return updatedSyncedSlot !== undefined && updatedSyncedSlot + 1 >= slot; }, 'wait for block source sync', timeoutMs / 1000, diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 594720d0ac01..ce5f7526abcd 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -30,7 +30,7 @@ import { import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type BlockData, L2Block, type L2BlockSink, type L2BlockSource } from '@aztec/stdlib/block'; -import type { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; +import { type getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import type { SlasherConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal } from '@aztec/stdlib/p2p'; @@ -126,6 +126,9 @@ describe('ValidatorClient', () => { epochCache.getL1Constants.mockReturnValue({ epochDuration: 8 } satisfies Parameters< typeof getEpochAtSlot >[1] as any); + epochCache.getSlotNow.mockReturnValue(SlotNumber(1)); + epochCache.pipeliningOffset.mockReturnValue(0); + epochCache.isProposerPipeliningEnabled.mockReturnValue(false); epochCache.getEpochAndSlotNow.mockReturnValue({ epoch: EpochNumber(1), slot: SlotNumber(1), @@ -333,6 +336,8 @@ describe('ValidatorClient', () => { let mockCheckpointBuilder: MockProxy; const makeTxFromHash = (txHash: TxHash) => ({ getTxHash: () => txHash, txHash }) as Tx; + const getExpectedWallClockDeadline = (currentSlot: SlotNumber) => + new Date(Number(getTimestampForSlot(SlotNumber(currentSlot + 1), checkpointsBuilder.getConfig())) * 1000); beforeEach(async () => { const emptyInHash = computeInHashFromL1ToL2Messages([]); @@ -356,6 +361,7 @@ describe('ValidatorClient', () => { ); epochCache.isInCommittee.mockResolvedValue(true); + epochCache.getSlotNow.mockReturnValue(proposal.slotNumber); epochCache.getTargetAndNextSlot.mockReturnValue({ targetSlot: proposal.slotNumber, nextSlot: SlotNumber(proposal.slotNumber + 1), @@ -432,6 +438,43 @@ describe('ValidatorClient', () => { expect(isValid).toBe(true); }); + it('uses the next wall-clock slot as the tx collection deadline for pipelined proposals', async () => { + const pipelineOffsetInSlots = 1; + epochCache.isProposerPipeliningEnabled.mockReturnValue(true); + epochCache.pipeliningOffset.mockReturnValue(pipelineOffsetInSlots); + epochCache.filterInCommittee.mockResolvedValue([EthAddress.fromString(validatorAccounts[0].address)]); + + const futureSlot = SlotNumber(proposal.slotNumber + 20); + const futureProposal = await makeBlockProposal({ + blockHeader: makeBlockHeader(1, { + blockNumber, + slotNumber: futureSlot, + }), + inHash: computeInHashFromL1ToL2Messages([]), + }); + + // Under pipelining, the target slot is the future slot the proposer is building for, + // and the expected proposer for that slot is whoever signed the future proposal. + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(futureProposal.getSender()); + epochCache.getTargetAndNextSlot.mockReturnValue({ + targetSlot: futureSlot, + nextSlot: SlotNumber(futureSlot + 1), + }); + + const result = await validatorClient.getProposalHandler().handleBlockProposal(futureProposal, sender, false); + + expect(result.isValid).toBe(true); + expect(txProvider.getTxsForBlockProposal).toHaveBeenCalledWith( + futureProposal, + blockNumber, + expect.objectContaining({ + pinnedPeer: sender, + // Expect wall clock time + deadline: getExpectedWallClockDeadline(SlotNumber(Number(futureProposal.slotNumber) - pipelineOffsetInSlots)), + }), + ); + }); + it('should process block proposal from own validator key (HA peer)', async () => { const selfSigner = new Secp256k1Signer(Buffer32.fromString(validatorPrivateKeys[0])); const emptyInHash = computeInHashFromL1ToL2Messages([]);