@@ -23,21 +23,23 @@ import type { TypedEventEmitter } from '@aztec/foundation/types';
2323import { type P2P , P2PClientState } from '@aztec/p2p' ;
2424import type { SlasherClientInterface } from '@aztec/slasher' ;
2525import { AztecAddress } from '@aztec/stdlib/aztec-address' ;
26- import { CommitteeAttestation , type L2BlockSink , type L2BlockSource } from '@aztec/stdlib/block' ;
26+ import { CommitteeAttestation , L2Block , type L2BlockSink , type L2BlockSource } from '@aztec/stdlib/block' ;
2727import { Checkpoint } from '@aztec/stdlib/checkpoint' ;
2828import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers' ;
2929import { GasFees } from '@aztec/stdlib/gas' ;
30- import type {
31- MerkleTreeWriteOperations ,
32- ResolvedSequencerConfig ,
33- WorldStateSynchronizer ,
30+ import {
31+ type BuildBlockInCheckpointResult ,
32+ type MerkleTreeWriteOperations ,
33+ NoValidTxsError ,
34+ type ResolvedSequencerConfig ,
35+ type WorldStateSynchronizer ,
3436} from '@aztec/stdlib/interfaces/server' ;
3537import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging' ;
3638import { BlockProposal , CheckpointProposal } from '@aztec/stdlib/p2p' ;
37- import { GlobalVariables , type Tx } from '@aztec/stdlib/tx' ;
39+ import { type FailedTx , GlobalVariables , type Tx } from '@aztec/stdlib/tx' ;
3840import { AttestationTimeoutError } from '@aztec/stdlib/validators' ;
3941import { getTelemetryClient } from '@aztec/telemetry-client' ;
40- import type { FullNodeCheckpointsBuilder , ValidatorClient } from '@aztec/validator-client' ;
42+ import { CheckpointBuilder , type FullNodeCheckpointsBuilder , type ValidatorClient } from '@aztec/validator-client' ;
4143import { DutyAlreadySignedError , SlashingProtectionError } from '@aztec/validator-ha-signer/errors' ;
4244import { DutyType } from '@aztec/validator-ha-signer/types' ;
4345
@@ -458,7 +460,7 @@ describe('CheckpointProposalJob', () => {
458460 const txs = await Promise . all ( Array . from ( { length : totalTxs } , ( _ , i ) => makeTx ( i + 1 , chainId ) ) ) ;
459461
460462 // Set up p2p mocks
461- p2p . getPendingTxCount . mockResolvedValue ( 10 ) ;
463+ p2p . getPendingTxCount . mockResolvedValue ( txs . length ) ;
462464 p2p . iteratePendingTxs . mockImplementation ( ( ) => mockTxIterator ( Promise . resolve ( txs ) ) ) ;
463465
464466 // Create blocks with incrementing block numbers
@@ -585,6 +587,92 @@ describe('CheckpointProposalJob', () => {
585587 expect ( waitSpy . mock . calls [ 0 ] [ 0 ] ) . toEqual ( 10 ) ;
586588 } ) ;
587589
590+ it ( 'builds a single empty block when no txs are available and no min txs required' , async ( ) => {
591+ // Mock timetable to have two sub-slots
592+ jest
593+ . spyOn ( job . getTimetable ( ) , 'canStartNextBlock' )
594+ . mockReturnValueOnce ( { canStart : true , deadline : 2 , isLastBlock : false } )
595+ . mockReturnValueOnce ( { canStart : true , deadline : 4 , isLastBlock : true } )
596+ . mockReturnValue ( { canStart : false , deadline : undefined , isLastBlock : false } ) ;
597+
598+ // Set up test data for an empty block
599+ const { lastBlock } = await setupMultipleBlocks ( 1 , [ 0 ] ) ;
600+ validatorClient . collectAttestations . mockResolvedValue ( getAttestations ( lastBlock ) ) ;
601+
602+ // Install spy on waitUntilTimeInSlot to verify it's called with expected deadlines
603+ const waitSpy = jest . spyOn ( job , 'waitUntilTimeInSlot' ) ;
604+
605+ job . updateConfig ( { minTxsPerBlock : 0 } ) ;
606+ const checkpoint = await job . execute ( ) ;
607+
608+ expect ( checkpoint ) . toBeDefined ( ) ;
609+ expect ( checkpointBuilder . buildBlockCalls ) . toHaveLength ( 1 ) ;
610+ expect ( validatorClient . collectAttestations ) . toHaveBeenCalledTimes ( 1 ) ;
611+ expect ( publisher . enqueueProposeCheckpoint ) . toHaveBeenCalledTimes ( 1 ) ;
612+
613+ // Verify waitUntilTimeInSlot was called between blocks
614+ expect ( waitSpy ) . toHaveBeenCalledTimes ( 1 ) ;
615+ // The wait time is until the next block deadline
616+ expect ( waitSpy . mock . calls [ 0 ] [ 0 ] ) . toEqual ( 2 ) ;
617+ } ) ;
618+
619+ it ( 'builds a single block when not enough txs are available but we build empty checkpoints' , async ( ) => {
620+ // Mock timetable to have two sub-slots
621+ jest
622+ . spyOn ( job . getTimetable ( ) , 'canStartNextBlock' )
623+ . mockReturnValueOnce ( { canStart : true , deadline : 2 , isLastBlock : false } )
624+ . mockReturnValueOnce ( { canStart : true , deadline : 4 , isLastBlock : true } )
625+ . mockReturnValue ( { canStart : false , deadline : undefined , isLastBlock : false } ) ;
626+
627+ // Set up test data for a block with only 2 txs, note that min txs is 5
628+ const { lastBlock } = await setupMultipleBlocks ( 1 , [ 2 ] ) ;
629+ validatorClient . collectAttestations . mockResolvedValue ( getAttestations ( lastBlock ) ) ;
630+
631+ // Install spy on waitUntilTimeInSlot to verify it's called with expected deadlines
632+ const waitSpy = jest . spyOn ( job , 'waitUntilTimeInSlot' ) ;
633+
634+ job . updateConfig ( { minTxsPerBlock : 5 , buildCheckpointIfEmpty : true } ) ;
635+ const checkpoint = await job . execute ( ) ;
636+
637+ expect ( checkpoint ) . toBeDefined ( ) ;
638+ expect ( checkpointBuilder . buildBlockCalls ) . toHaveLength ( 1 ) ;
639+ expect ( validatorClient . collectAttestations ) . toHaveBeenCalledTimes ( 1 ) ;
640+ expect ( publisher . enqueueProposeCheckpoint ) . toHaveBeenCalledTimes ( 1 ) ;
641+
642+ // Verify waitUntilTimeInSlot was called between blocks
643+ expect ( waitSpy ) . toHaveBeenCalledTimes ( 1 ) ;
644+ // The wait time is until the next block deadline
645+ expect ( waitSpy . mock . calls [ 0 ] [ 0 ] ) . toEqual ( 2 ) ;
646+ } ) ;
647+
648+ it ( 'does not build anything if not enough txs and we do not build empty checkpoints' , async ( ) => {
649+ // Mock timetable to have two sub-slots
650+ jest
651+ . spyOn ( job . getTimetable ( ) , 'canStartNextBlock' )
652+ . mockReturnValueOnce ( { canStart : true , deadline : 2 , isLastBlock : false } )
653+ . mockReturnValueOnce ( { canStart : true , deadline : 4 , isLastBlock : true } )
654+ . mockReturnValue ( { canStart : false , deadline : undefined , isLastBlock : false } ) ;
655+
656+ // Not enough txs to build a block
657+ p2p . getPendingTxCount . mockResolvedValue ( 2 ) ;
658+
659+ // Install spy on waitUntilTimeInSlot to verify it's called with expected deadlines
660+ const waitSpy = jest . spyOn ( job , 'waitUntilTimeInSlot' ) ;
661+
662+ job . updateConfig ( { minTxsPerBlock : 5 , buildCheckpointIfEmpty : false } ) ;
663+ const checkpoint = await job . execute ( ) ;
664+
665+ expect ( checkpoint ) . toBeUndefined ( ) ;
666+ expect ( checkpointBuilder . buildBlockCalls ) . toHaveLength ( 0 ) ;
667+ expect ( validatorClient . collectAttestations ) . toHaveBeenCalledTimes ( 0 ) ;
668+ expect ( publisher . enqueueProposeCheckpoint ) . toHaveBeenCalledTimes ( 0 ) ;
669+
670+ // Verify waitUntilTimeInSlot was called between blocks
671+ expect ( waitSpy ) . toHaveBeenCalledTimes ( 1 ) ;
672+ // The wait time is until the next block deadline
673+ expect ( waitSpy . mock . calls [ 0 ] [ 0 ] ) . toEqual ( 2 ) ;
674+ } ) ;
675+
588676 it ( 'stops building when canStartNextBlock returns false' , async ( ) => {
589677 // Mock timetable to stop after 1 block (simulating time running out)
590678 jest
@@ -720,6 +808,53 @@ describe('CheckpointProposalJob', () => {
720808 } ) ;
721809 } ) ;
722810
811+ describe ( 'build single block' , ( ) => {
812+ it ( 'does not build a block if not enough valid txs are collected' , async ( ) => {
813+ // We have enough txs, but not enough valid ones
814+ job . updateConfig ( { minTxsPerBlock : 3 , minValidTxsPerBlock : 2 } ) ;
815+ const txs = await timesAsync ( 3 , i => makeTx ( i + 1 , chainId ) ) ;
816+ mockPendingTxs ( p2p , txs ) ;
817+
818+ const checkpointBuilder = mock < CheckpointBuilder > ( ) ;
819+ const failedTxs : FailedTx [ ] = txs . slice ( 1 ) . map ( tx => ( { tx, error : new Error ( 'Invalid tx' ) } ) ) ;
820+ checkpointBuilder . buildBlock . mockResolvedValue ( { failedTxs, numTxs : 1 } as BuildBlockInCheckpointResult ) ;
821+
822+ const checkpoint = await job . buildSingleBlock ( checkpointBuilder , {
823+ blockNumber : newBlockNumber ,
824+ indexWithinCheckpoint : IndexWithinCheckpoint ( 1 ) ,
825+ buildDeadline : undefined ,
826+ blockTimestamp : 0n ,
827+ remainingBlobFields : 1 ,
828+ txHashesAlreadyIncluded : new Set < string > ( ) ,
829+ } ) ;
830+
831+ expect ( checkpoint ) . toBeUndefined ( ) ;
832+ expect ( p2p . deleteTxs ) . toHaveBeenCalledWith ( failedTxs . map ( ftx => ftx . tx . txHash ) ) ;
833+ } ) ;
834+
835+ it ( 'does not build a block if checkpoint builder fails with invalid txs' , async ( ) => {
836+ job . updateConfig ( { minTxsPerBlock : 3 } ) ;
837+ const txs = await timesAsync ( 3 , i => makeTx ( i + 1 , chainId ) ) ;
838+ mockPendingTxs ( p2p , txs ) ;
839+
840+ const checkpointBuilder = mock < CheckpointBuilder > ( ) ;
841+ const failedTxs : FailedTx [ ] = txs . slice ( 1 ) . map ( tx => ( { tx, error : new Error ( 'Invalid tx' ) } ) ) ;
842+ checkpointBuilder . buildBlock . mockRejectedValue ( new NoValidTxsError ( failedTxs ) ) ;
843+
844+ const checkpoint = await job . buildSingleBlock ( checkpointBuilder , {
845+ blockNumber : newBlockNumber ,
846+ indexWithinCheckpoint : IndexWithinCheckpoint ( 1 ) ,
847+ buildDeadline : undefined ,
848+ blockTimestamp : 0n ,
849+ remainingBlobFields : 1 ,
850+ txHashesAlreadyIncluded : new Set < string > ( ) ,
851+ } ) ;
852+
853+ expect ( checkpoint ) . toBeUndefined ( ) ;
854+ expect ( p2p . deleteTxs ) . toHaveBeenCalledWith ( failedTxs . map ( ftx => ftx . tx . txHash ) ) ;
855+ } ) ;
856+ } ) ;
857+
723858 describe ( 'timing edge cases' , ( ) => {
724859 it ( 'handles insufficient time remaining in slot' , async ( ) => {
725860 // Mock canStartNextBlock to return false (not enough time)
@@ -855,7 +990,7 @@ describe('CheckpointProposalJob', () => {
855990 } ) ;
856991 } ) ;
857992
858- describe ( 'HA error handling during block building' , ( ) => {
993+ describe ( 'high-availability error handling during block building' , ( ) => {
859994 it ( 'should stop checkpoint building when block proposal throws DutyAlreadySignedError on first block' , async ( ) => {
860995 // Set up test data for 3 blocks (to verify it stops even with multiple blocks configured)
861996 const { lastBlock } = await setupMultipleBlocks ( 3 , 1 ) ;
@@ -961,4 +1096,20 @@ class TestCheckpointProposalJob extends CheckpointProposalJob {
9611096 public getTimetable ( ) : SequencerTimetable {
9621097 return this . timetable ;
9631098 }
1099+
1100+ /** Expose internal buildSingleBlock method */
1101+ public override buildSingleBlock (
1102+ checkpointBuilder : CheckpointBuilder ,
1103+ opts : {
1104+ forceCreate ?: boolean ;
1105+ blockTimestamp : bigint ;
1106+ blockNumber : BlockNumber ;
1107+ indexWithinCheckpoint : IndexWithinCheckpoint ;
1108+ buildDeadline : Date | undefined ;
1109+ txHashesAlreadyIncluded : Set < string > ;
1110+ remainingBlobFields : number ;
1111+ } ,
1112+ ) : Promise < { block : L2Block ; usedTxs : Tx [ ] ; remainingBlobFields : number } | { error : Error } | undefined > {
1113+ return super . buildSingleBlock ( checkpointBuilder , opts ) ;
1114+ }
9641115}
0 commit comments