@aztec/sequencer-client 0.0.1-commit.2b2662070 → 0.0.1-commit.2c0ee1788

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -436,11 +436,12 @@ function _apply_decs_2203_r(targetClass, memberDecs, classDecs, parentClass) {
436
436
  return (_apply_decs_2203_r = applyDecs2203RFactory())(targetClass, memberDecs, classDecs, parentClass);
437
437
  }
438
438
  var _dec, _dec1, _dec2, _dec3, _dec4, _dec5, _dec6, _dec7, _initProto;
439
- import { BlockNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types';
439
+ import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types';
440
440
  import { randomInt } from '@aztec/foundation/crypto/random';
441
441
  import { flipSignature, generateRecoverableSignature, generateUnrecoverableSignature } from '@aztec/foundation/crypto/secp256k1-signer';
442
442
  import { filter } from '@aztec/foundation/iterator';
443
443
  import { createLogger } from '@aztec/foundation/log';
444
+ import { retryUntil } from '@aztec/foundation/retry';
444
445
  import { sleep, sleepUntil } from '@aztec/foundation/sleep';
445
446
  import { Timer } from '@aztec/foundation/timer';
446
447
  import { isErrorClass, unfreeze } from '@aztec/foundation/types';
@@ -490,6 +491,7 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
490
491
  checkpointsBuilder;
491
492
  blockSink;
492
493
  l1Constants;
494
+ signatureContext;
493
495
  config;
494
496
  timetable;
495
497
  slasherClient;
@@ -546,10 +548,14 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
546
548
  ], []));
547
549
  }
548
550
  log;
551
+ checkpointEventLog;
549
552
  /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */ pendingL1Submission;
550
553
  /** Pipelined parent chain state used while building and later submitting this checkpoint. */ pipelinedParentSimulationOverridesPlan;
554
+ getSignatureContext() {
555
+ return this.signatureContext;
556
+ }
551
557
  constructor(slotNow, targetSlot, targetEpoch, checkpointNumber, syncedToBlockNumber, // TODO(palla/mbps): Can we remove the proposer in favor of attestorAddress? Need to check fisherman-node flows.
552
- proposer, publisher, attestorAddress, invalidateCheckpoint, validatorClient, globalsBuilder, p2pClient, worldState, l1ToL2MessageSource, l2BlockSource, checkpointsBuilder, blockSink, l1Constants, config, timetable, slasherClient, epochCache, dateProvider, metrics, checkpointMetrics, eventEmitter, setStateFn, tracer, bindings, proposedCheckpointData){
558
+ proposer, publisher, attestorAddress, invalidateCheckpoint, validatorClient, globalsBuilder, p2pClient, worldState, l1ToL2MessageSource, l2BlockSource, checkpointsBuilder, blockSink, l1Constants, signatureContext, config, timetable, slasherClient, epochCache, dateProvider, metrics, checkpointMetrics, eventEmitter, setStateFn, tracer, bindings, proposedCheckpointData){
553
559
  this.slotNow = slotNow;
554
560
  this.targetSlot = targetSlot;
555
561
  this.targetEpoch = targetEpoch;
@@ -568,6 +574,7 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
568
574
  this.checkpointsBuilder = checkpointsBuilder;
569
575
  this.blockSink = blockSink;
570
576
  this.l1Constants = l1Constants;
577
+ this.signatureContext = signatureContext;
571
578
  this.config = config;
572
579
  this.timetable = timetable;
573
580
  this.slasherClient = slasherClient;
@@ -584,11 +591,21 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
584
591
  ...bindings,
585
592
  instanceId: `slot-${this.slotNow}`
586
593
  });
594
+ this.checkpointEventLog = createLogger('sequencer:checkpoint-events', {
595
+ ...bindings,
596
+ instanceId: `slot-${this.slotNow}`
597
+ });
587
598
  }
588
599
  /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */ async awaitPendingSubmission() {
589
600
  this.log.info('Awaiting pending L1 payload submission');
590
601
  await this.pendingL1Submission;
591
602
  }
603
+ logCheckpointEvent(eventName, message, fields) {
604
+ this.checkpointEventLog.debug(message, {
605
+ eventName: `sequencer-checkpoint-${eventName}`,
606
+ ...fields
607
+ });
608
+ }
592
609
  /**
593
610
  * Executes the checkpoint proposal job.
594
611
  * Builds blocks, assembles checkpoint, and broadcasts the proposal (blocking).
@@ -627,34 +644,45 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
627
644
  * Background pipeline: collects attestations, signs them, enqueues the checkpoint, and submits to L1.
628
645
  * Runs as a fire-and-forget task stored in `pendingL1Submission` so the work loop is unblocked.
629
646
  */ async waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises) {
630
- const { checkpoint, proposal, blockProposedAt } = broadcast;
647
+ const { checkpoint } = broadcast;
648
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
631
649
  try {
650
+ // Wait for all votes actions, enqueued at the beginning, to resolve
632
651
  await Promise.all(votesPromises);
633
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
634
- const attestations = await this.waitForAttestations(proposal);
635
- this.checkpointMetrics.recordCheckpointAttestationDelay(this.dateProvider.now() - blockProposedAt);
636
- // Proposer must sign over the attestations before pushing them to L1
637
- const signer = this.proposer ?? this.publisher.getSenderAddress();
638
- let attestationsSignature;
639
- try {
640
- attestationsSignature = await this.validatorClient.signAttestationsAndSigners(attestations, signer, this.targetSlot, this.checkpointNumber);
641
- } catch (err) {
642
- if (this.handleHASigningError(err, 'Attestations signature')) {
643
- return;
652
+ // Try to collect attestations from the committee
653
+ const signedAttestations = await this.getSignedCommitteeAttestations(broadcast);
654
+ // If pipelining, wait for the previous checkpoint to land on L1 before submitting,
655
+ // so we can check it matches the proposed checkpoint we used as parent, and has valid attestations.
656
+ if (signedAttestations && (!isPipelining || await this.waitForValidParentCheckpointOnL1())) {
657
+ await this.enqueueCheckpointForSubmission({
658
+ checkpoint,
659
+ ...signedAttestations
660
+ });
661
+ }
662
+ // If we failed to collect attestations, at least check if we need to issue an invalidation
663
+ // Note that if we are not pipelining, we enqueued the invalidation at the beginning
664
+ if (!signedAttestations && isPipelining && await this.waitForSyncedL2SlotNumber(this.slotNow)) {
665
+ const validationStatus = await this.l2BlockSource.getPendingChainValidationStatus();
666
+ if (!validationStatus.valid) {
667
+ this.log.warn(`Checkpoint ${validationStatus.checkpoint.checkpointNumber} has invalid attestations, enqueuing invalidation in spite of attestation collection failure`, {
668
+ checkpoint: validationStatus.checkpoint,
669
+ reason: validationStatus.reason
670
+ });
671
+ await this.enqueueInvalidation(validationStatus);
644
672
  }
645
- throw err;
646
673
  }
647
- // Enqueue the checkpoint for L1 submission
648
- await this.enqueueCheckpointForSubmission({
649
- checkpoint,
650
- attestations,
651
- attestationsSignature
652
- });
674
+ // Send whatever was enqueued: votes + (propose | invalidation | nothing).
653
675
  // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise.
654
- const submitAfter = this.epochCache.isProposerPipeliningEnabled() ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000) : new Date(this.dateProvider.now());
676
+ const submitAfter = isPipelining ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000) : new Date(this.dateProvider.now());
655
677
  const l1Response = await this.publisher.sendRequestsAt(submitAfter);
656
678
  const proposedAction = l1Response?.successfulActions.find((a)=>a === 'propose');
657
679
  if (proposedAction) {
680
+ this.logCheckpointEvent('published', `Checkpoint published for slot ${this.targetSlot}`, {
681
+ slot: this.targetSlot,
682
+ checkpointNumber: this.checkpointNumber,
683
+ successfulActions: l1Response?.successfulActions,
684
+ sentActions: l1Response?.sentActions
685
+ });
658
686
  this.eventEmitter.emit('checkpoint-published', {
659
687
  checkpoint: this.checkpointNumber,
660
688
  slot: this.targetSlot
@@ -662,11 +690,29 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
662
690
  const coinbase = checkpoint.header.coinbase;
663
691
  await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
664
692
  } else {
693
+ this.logCheckpointEvent('publish-failed', `Checkpoint publish failed for slot ${this.targetSlot}`, {
694
+ slot: this.targetSlot,
695
+ checkpointNumber: this.checkpointNumber,
696
+ successfulActions: l1Response?.successfulActions,
697
+ failedActions: l1Response?.failedActions,
698
+ sentActions: l1Response?.sentActions,
699
+ expiredActions: l1Response?.expiredActions,
700
+ reason: 'propose_action_not_successful'
701
+ });
702
+ this.log.warn(`Checkpoint publish failed for slot ${this.targetSlot}`, {
703
+ slot: this.targetSlot,
704
+ checkpointNumber: this.checkpointNumber,
705
+ successfulActions: l1Response?.successfulActions,
706
+ failedActions: l1Response?.failedActions,
707
+ sentActions: l1Response?.sentActions,
708
+ expiredActions: l1Response?.expiredActions,
709
+ reason: 'propose_action_not_successful'
710
+ });
665
711
  this.eventEmitter.emit('checkpoint-publish-failed', {
666
712
  ...l1Response,
667
713
  slot: this.targetSlot
668
714
  });
669
- if (this.epochCache.isProposerPipeliningEnabled()) {
715
+ if (isPipelining) {
670
716
  this.metrics.recordPipelineDiscard();
671
717
  }
672
718
  }
@@ -674,11 +720,20 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
674
720
  if (err instanceof SequencerInterruptedError) {
675
721
  return;
676
722
  }
677
- this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err);
723
+ this.logCheckpointEvent('publish-failed', `Checkpoint publish failed for slot ${this.targetSlot}`, {
724
+ slot: this.targetSlot,
725
+ checkpointNumber: this.checkpointNumber,
726
+ reason: err instanceof Error ? err.message : String(err)
727
+ });
728
+ this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err, {
729
+ slot: this.targetSlot,
730
+ checkpointNumber: this.checkpointNumber,
731
+ reason: err instanceof Error ? err.message : String(err)
732
+ });
678
733
  this.eventEmitter.emit('checkpoint-publish-failed', {
679
734
  slot: this.targetSlot
680
735
  });
681
- if (this.epochCache.isProposerPipeliningEnabled()) {
736
+ if (isPipelining) {
682
737
  this.metrics.recordPipelineDiscard();
683
738
  }
684
739
  }
@@ -711,6 +766,121 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
711
766
  } : {}
712
767
  });
713
768
  }
769
+ /**
770
+ * Wait until the archiver syncs past the given L2 slot number.
771
+ * The deadline is the end of `this.targetSlot`, beyond which any pipelined work would miss its
772
+ * L1 submission window and is no longer useful.
773
+ */ async waitForSyncedL2SlotNumber(waitForSlot) {
774
+ const targetSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
775
+ const targetSlotEndMs = (targetSlotStart + this.l1Constants.slotDuration) * 1000;
776
+ const syncDelayTolerance = this.l1Constants.ethereumSlotDuration * 2 * 1000;
777
+ const timeoutSeconds = Math.max(0.1, (targetSlotEndMs + syncDelayTolerance - this.dateProvider.now()) / 1000);
778
+ try {
779
+ return await retryUntil(async ()=>{
780
+ const syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
781
+ return syncedSlot !== undefined && syncedSlot >= waitForSlot;
782
+ }, `archiver sync past slot ${waitForSlot}`, timeoutSeconds, 0.2);
783
+ } catch {
784
+ this.log.warn(`Archiver did not sync L1 past slot ${waitForSlot} before slot ${this.targetSlot} expired, discarding pipelined work`, {
785
+ checkpointNumber: this.checkpointNumber
786
+ });
787
+ this.emitPipelinedCheckpointDiscarded('archiver-sync-timeout');
788
+ return false;
789
+ }
790
+ }
791
+ /**
792
+ * Waits for the parent checkpoint to land on L1 before submitting a pipelined checkpoint.
793
+ * Polls until the archiver has synced L1 past the parent's slot, then verifies:
794
+ * - If we built on a proposed parent: it must have landed on L1 with matching hash and valid attestations.
795
+ * - If we built without a proposed parent: no new checkpoint must have appeared for that slot.
796
+ * If the parent has invalid attestations, enqueues an invalidation. Returns whether to proceed with the proposal.
797
+ */ async waitForValidParentCheckpointOnL1() {
798
+ const parentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 1);
799
+ // Wait until archiver has synced L1 past the parent's slot (slotNow)
800
+ if (!await this.waitForSyncedL2SlotNumber(this.slotNow)) {
801
+ return false;
802
+ }
803
+ const tips = await this.l2BlockSource.getL2Tips();
804
+ const checkpointedNumber = tips.checkpointed.checkpoint.number;
805
+ // We built on top of a proposed checkpoint. Verify it landed on L1 as expected.
806
+ if (this.proposedCheckpointData) {
807
+ // After syncing from L1 we see the chain tip has invalid attestations. This means the parent checkpoint was posted
808
+ // with invalid attestations, or it built on top of something with invalid attestations and didnt invalidate them.
809
+ // Either way, we thought our parent would be valid, so we have to throw away our work. But at least we'll try and
810
+ // invalidate on L1 so we clean up the chain for the next proposer. And we'll slash them, but that's handled elsewhere.
811
+ const validationStatus = await this.l2BlockSource.getPendingChainValidationStatus();
812
+ if (!validationStatus.valid) {
813
+ this.log.warn(`Parent checkpoint ${parentCheckpointNumber} has invalid attestations, discarding pipelined work`, {
814
+ checkpointNumber: this.checkpointNumber,
815
+ reason: validationStatus.reason
816
+ });
817
+ this.emitPipelinedCheckpointDiscarded('parent-invalid-attestations');
818
+ await this.enqueueInvalidation(validationStatus);
819
+ return false;
820
+ }
821
+ // The pending chain is valid. But did the parent checkpoint land on L1 at all?
822
+ if (checkpointedNumber < parentCheckpointNumber) {
823
+ this.log.warn(`Parent checkpoint ${parentCheckpointNumber} did not land on L1, discarding pipelined work`, {
824
+ checkpointNumber: this.checkpointNumber,
825
+ checkpointedNumber
826
+ });
827
+ this.emitPipelinedCheckpointDiscarded('parent-not-on-l1');
828
+ return false;
829
+ }
830
+ // It landed. But is it the one we were expecting?
831
+ const expectedHash = this.proposedCheckpointData.header.hash().toString();
832
+ if (tips.checkpointed.checkpoint.hash !== expectedHash) {
833
+ this.log.warn(`Parent checkpoint ${parentCheckpointNumber} hash mismatch on L1, discarding pipelined work`, {
834
+ checkpointNumber: this.checkpointNumber,
835
+ expectedHash,
836
+ actualHash: tips.checkpointed.checkpoint.hash
837
+ });
838
+ this.emitPipelinedCheckpointDiscarded('parent-hash-mismatch');
839
+ return false;
840
+ }
841
+ return true;
842
+ } else {
843
+ // We didn't see a proposed checkpoint at build time, so we built on checkpointed parent from two slots ago.
844
+ // But if a new checkpoint for the previous slot appeared on L1 in the meantime, our checkpoint assumed the wrong parent,
845
+ // so we have to discard our work. This can happen if we're somehow cut off from p2p and fail to see the checkpoint
846
+ // proposal for the previous slot.
847
+ if (checkpointedNumber > parentCheckpointNumber) {
848
+ this.log.warn(`Unexpected checkpoint ${checkpointedNumber} landed on L1 after we built on top of parent ${parentCheckpointNumber}, discarding pipelined work`, {
849
+ checkpointNumber: this.checkpointNumber,
850
+ checkpointedNumber
851
+ });
852
+ this.emitPipelinedCheckpointDiscarded('unexpected-parent-appeared');
853
+ return false;
854
+ }
855
+ return true;
856
+ }
857
+ }
858
+ /** Emits the pipelined-checkpoint-discarded event and records the metric. */ emitPipelinedCheckpointDiscarded(reason) {
859
+ this.metrics.recordPipelineParentCheckpointMismatch(reason);
860
+ this.eventEmitter.emit('pipelined-checkpoint-discarded', {
861
+ slot: this.targetSlot,
862
+ checkpointNumber: this.checkpointNumber,
863
+ reason
864
+ });
865
+ }
866
+ /** Simulates and enqueues an invalidation request for the invalid parent checkpoint. */ async enqueueInvalidation(validationStatus) {
867
+ if (this.config.skipInvalidateBlockAsProposer) {
868
+ this.log.warn(`Skipping checkpoint invalidation as proposer due to test configuration`);
869
+ return;
870
+ }
871
+ const invalidateRequest = await this.publisher.simulateInvalidateCheckpoint(validationStatus);
872
+ if (invalidateRequest) {
873
+ const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
874
+ const txTimeoutAt = new Date((submissionSlotStart + this.l1Constants.slotDuration) * 1000);
875
+ this.publisher.enqueueInvalidateCheckpoint(invalidateRequest, {
876
+ txTimeoutAt
877
+ });
878
+ } else {
879
+ this.log.info(`Invalidation simulation returned undefined, checkpoint may have been removed already`, {
880
+ checkpointNumber: this.checkpointNumber
881
+ });
882
+ }
883
+ }
714
884
  async proposeCheckpoint() {
715
885
  try {
716
886
  const env = {
@@ -733,11 +903,15 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
733
903
  const feeRecipient = this.validatorClient.getFeeRecipientForAttestor(this.attestorAddress);
734
904
  // Start the checkpoint
735
905
  this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
736
- this.log.info(`Starting checkpoint proposal`, {
906
+ this.logCheckpointEvent('slot-started', `Starting checkpoint proposal for slot ${this.targetSlot}`, {
737
907
  buildSlot: this.slotNow,
738
908
  submissionSlot: this.targetSlot,
909
+ slot: this.targetSlot,
910
+ checkpointNumber: this.checkpointNumber,
739
911
  pipelining: this.epochCache.isProposerPipeliningEnabled(),
740
912
  proposer: this.proposer?.toString(),
913
+ attestorAddress: this.attestorAddress.toString(),
914
+ publisherAddress: this.publisher.getSenderAddress().toString(),
741
915
  coinbase: coinbase.toString()
742
916
  });
743
917
  this.metrics.incOpenSlot(this.targetSlot, this.proposer?.toString() ?? 'unknown');
@@ -795,8 +969,15 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
795
969
  throw err;
796
970
  }
797
971
  if (blocksInCheckpoint.length === 0) {
972
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
973
+ slot: this.targetSlot,
974
+ checkpointNumber: this.checkpointNumber,
975
+ reason: 'no_blocks_built'
976
+ });
798
977
  this.log.warn(`No blocks were built for slot ${this.targetSlot}`, {
799
- slot: this.targetSlot
978
+ slot: this.targetSlot,
979
+ checkpointNumber: this.checkpointNumber,
980
+ reason: 'no_blocks_built'
800
981
  });
801
982
  this.eventEmitter.emit('checkpoint-empty', {
802
983
  slot: this.targetSlot
@@ -805,10 +986,19 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
805
986
  }
806
987
  const minBlocksForCheckpoint = this.config.minBlocksForCheckpoint;
807
988
  if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
989
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
990
+ slot: this.targetSlot,
991
+ checkpointNumber: this.checkpointNumber,
992
+ blocksBuilt: blocksInCheckpoint.length,
993
+ minBlocksForCheckpoint,
994
+ reason: 'min_blocks_not_met'
995
+ });
808
996
  this.log.warn(`Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`, {
809
997
  slot: this.targetSlot,
998
+ checkpointNumber: this.checkpointNumber,
810
999
  blocksBuilt: blocksInCheckpoint.length,
811
- minBlocksForCheckpoint
1000
+ minBlocksForCheckpoint,
1001
+ reason: 'min_blocks_not_met'
812
1002
  });
813
1003
  return undefined;
814
1004
  }
@@ -827,13 +1017,35 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
827
1017
  maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint
828
1018
  });
829
1019
  } catch (err) {
1020
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
1021
+ slot: this.targetSlot,
1022
+ checkpointNumber: this.checkpointNumber,
1023
+ blocksBuilt: blocksInCheckpoint.length,
1024
+ reason: 'invalid_checkpoint',
1025
+ checkpoint: checkpoint.header.toInspect()
1026
+ });
830
1027
  this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, {
1028
+ slot: this.targetSlot,
1029
+ checkpointNumber: this.checkpointNumber,
1030
+ blocksBuilt: blocksInCheckpoint.length,
1031
+ reason: 'invalid_checkpoint',
831
1032
  checkpoint: checkpoint.header.toInspect()
832
1033
  });
833
1034
  return undefined;
834
1035
  }
835
1036
  // Record checkpoint-level build metrics
836
1037
  this.checkpointMetrics.recordCheckpointBuild(checkpointBuildTimer.ms(), blocksInCheckpoint.length, checkpoint.getStats().txCount, Number(checkpoint.header.totalManaUsed.toBigInt()));
1038
+ this.logCheckpointEvent('built', `Checkpoint built for slot ${this.targetSlot}`, {
1039
+ slot: this.targetSlot,
1040
+ buildSlot: this.slotNow,
1041
+ checkpointNumber: this.checkpointNumber,
1042
+ proposer: this.proposer?.toString(),
1043
+ attestorAddress: this.attestorAddress.toString(),
1044
+ publisherAddress: this.publisher.getSenderAddress().toString(),
1045
+ blocksBuilt: blocksInCheckpoint.length,
1046
+ txCount: checkpoint.getStats().txCount,
1047
+ totalMana: Number(checkpoint.header.totalManaUsed.toBigInt())
1048
+ });
837
1049
  // In fisherman mode, return the checkpoint without broadcasting or collecting attestations
838
1050
  if (this.config.fishermanMode) {
839
1051
  this.log.info(`Built checkpoint for slot ${this.targetSlot} with ${blocksInCheckpoint.length} blocks. ` + `Skipping proposal in fisherman mode.`, {
@@ -852,8 +1064,10 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
852
1064
  // Create the checkpoint proposal and broadcast it
853
1065
  const proposal = await this.validatorClient.createCheckpointProposal(checkpoint.header, checkpoint.archive.root, this.checkpointNumber, feeAssetPriceModifier, blockPendingBroadcast, this.proposer, checkpointProposalOptions);
854
1066
  const blockProposedAt = this.dateProvider.now();
855
- await this.p2pClient.broadcastCheckpointProposal(proposal);
856
- this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
1067
+ if (!this.config.skipBroadcastProposals) {
1068
+ await this.p2pClient.broadcastCheckpointProposal(proposal);
1069
+ this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
1070
+ }
857
1071
  // Return immediately after broadcast — attestation collection happens in the background
858
1072
  return {
859
1073
  checkpoint,
@@ -888,6 +1102,14 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
888
1102
  const blocksBuilt = blocksInCheckpoint.length;
889
1103
  const indexWithinCheckpoint = IndexWithinCheckpoint(blocksBuilt);
890
1104
  const blockNumber = BlockNumber(initialBlockNumber + blocksBuilt);
1105
+ if (blocksBuilt >= this.config.maxBlocksPerCheckpoint) {
1106
+ this.log.debug(`Reached max blocks per checkpoint`, {
1107
+ slot: this.targetSlot,
1108
+ blocksBuilt,
1109
+ maxBlocksPerCheckpoint: this.config.maxBlocksPerCheckpoint
1110
+ });
1111
+ break;
1112
+ }
891
1113
  const secondsIntoSlot = this.getSecondsIntoSlot();
892
1114
  const timingInfo = this.timetable.canStartNextBlock(secondsIntoSlot);
893
1115
  if (!timingInfo.canStart) {
@@ -956,7 +1178,9 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
956
1178
  break;
957
1179
  }
958
1180
  // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
959
- proposal && await this.p2pClient.broadcastProposal(proposal);
1181
+ if (proposal && !this.config.skipBroadcastProposals) {
1182
+ await this.p2pClient.broadcastProposal(proposal);
1183
+ }
960
1184
  // Wait until the next block's start time
961
1185
  await this.waitUntilNextSubslot(timingInfo.deadline);
962
1186
  }
@@ -993,10 +1217,23 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
993
1217
  // Wait until we have enough txs to build the block
994
1218
  const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
995
1219
  if (!canStartBuilding) {
1220
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1221
+ reason: 'insufficient_txs',
1222
+ blockNumber,
1223
+ slot: this.targetSlot,
1224
+ checkpointNumber: this.checkpointNumber,
1225
+ indexWithinCheckpoint,
1226
+ availableTxs,
1227
+ minTxs
1228
+ });
996
1229
  this.log.warn(`Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`, {
1230
+ reason: 'insufficient_txs',
997
1231
  blockNumber,
998
1232
  slot: this.targetSlot,
999
- indexWithinCheckpoint
1233
+ checkpointNumber: this.checkpointNumber,
1234
+ indexWithinCheckpoint,
1235
+ availableTxs,
1236
+ minTxs
1000
1237
  });
1001
1238
  this.eventEmitter.emit('block-tx-count-check-failed', {
1002
1239
  minTxs,
@@ -1037,8 +1274,19 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1037
1274
  // If any txs failed during execution, drop them from the mempool so we don't pick them up again
1038
1275
  await this.dropFailedTxsFromP2P(buildResult.failedTxs);
1039
1276
  if (buildResult.status === 'insufficient-valid-txs') {
1277
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1278
+ reason: 'insufficient_valid_txs',
1279
+ slot: this.targetSlot,
1280
+ checkpointNumber: this.checkpointNumber,
1281
+ blockNumber,
1282
+ numTxs: buildResult.processedCount,
1283
+ indexWithinCheckpoint,
1284
+ minValidTxs
1285
+ });
1040
1286
  this.log.warn(`Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.targetSlot} has too few valid txs to be proposed`, {
1287
+ reason: 'insufficient_valid_txs',
1041
1288
  slot: this.targetSlot,
1289
+ checkpointNumber: this.checkpointNumber,
1042
1290
  blockNumber,
1043
1291
  numTxs: buildResult.processedCount,
1044
1292
  indexWithinCheckpoint,
@@ -1087,9 +1335,17 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1087
1335
  reason: err.message,
1088
1336
  slot: this.targetSlot
1089
1337
  });
1338
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1339
+ reason: err instanceof Error ? err.message : String(err),
1340
+ slot: this.targetSlot,
1341
+ checkpointNumber: this.checkpointNumber,
1342
+ blockNumber
1343
+ });
1090
1344
  this.log.error(`Error building block`, err, {
1091
- blockNumber,
1092
- slot: this.targetSlot
1345
+ reason: err instanceof Error ? err.message : String(err),
1346
+ slot: this.targetSlot,
1347
+ checkpointNumber: this.checkpointNumber,
1348
+ blockNumber
1093
1349
  });
1094
1350
  this.metrics.recordBlockProposalFailed(err.name || 'unknown_error');
1095
1351
  this.metrics.recordFailedBlock();
@@ -1152,13 +1408,37 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1152
1408
  minTxs
1153
1409
  };
1154
1410
  }
1411
+ async getSignedCommitteeAttestations(broadcast) {
1412
+ const { proposal, blockProposedAt } = broadcast;
1413
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
1414
+ const attestations = await this.waitForAttestations(proposal);
1415
+ if (!attestations) {
1416
+ return undefined;
1417
+ }
1418
+ this.checkpointMetrics.recordCheckpointAttestationDelay(this.dateProvider.now() - blockProposedAt);
1419
+ // Proposer must sign over the attestations before pushing them to L1
1420
+ const signer = this.proposer ?? this.publisher.getSenderAddress();
1421
+ try {
1422
+ const attestationsSignature = await this.validatorClient.signAttestationsAndSigners(attestations, signer, this.targetSlot, this.checkpointNumber);
1423
+ return {
1424
+ attestations,
1425
+ attestationsSignature
1426
+ };
1427
+ } catch (err) {
1428
+ if (this.handleHASigningError(err, 'Attestations signature')) {
1429
+ return;
1430
+ }
1431
+ this.log.error(`Error signing attestations for checkpoint proposal at slot ${proposal.slotNumber}`, err);
1432
+ return undefined;
1433
+ }
1434
+ }
1155
1435
  /**
1156
1436
  * Waits for enough attestations to be collected via p2p.
1157
1437
  * This is run after all blocks for the checkpoint have been built.
1158
1438
  */ async waitForAttestations(proposal) {
1159
1439
  if (this.config.fishermanMode) {
1160
1440
  this.log.debug('Skipping attestation collection in fisherman mode');
1161
- return CommitteeAttestationsAndSigners.empty();
1441
+ return CommitteeAttestationsAndSigners.empty(this.getSignatureContext());
1162
1442
  }
1163
1443
  const slotNumber = proposal.slotNumber;
1164
1444
  const { committee, seed, epoch } = await this.epochCache.getCommittee(slotNumber);
@@ -1166,7 +1446,7 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1166
1446
  throw new Error('No committee when collecting attestations');
1167
1447
  } else if (committee.length === 0) {
1168
1448
  this.log.verbose(`Attesting committee is empty`);
1169
- return CommitteeAttestationsAndSigners.empty();
1449
+ return CommitteeAttestationsAndSigners.empty(this.getSignatureContext());
1170
1450
  } else {
1171
1451
  this.log.debug(`Attesting committee length is ${committee.length}`, {
1172
1452
  committee
@@ -1176,7 +1456,10 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1176
1456
  if (this.config.skipCollectingAttestations) {
1177
1457
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
1178
1458
  const attestations = await this.validatorClient?.collectOwnAttestations(proposal, this.checkpointNumber);
1179
- return new CommitteeAttestationsAndSigners(orderAttestations(attestations ?? [], committee));
1459
+ this.logCheckpointAttestations('collected', committee, attestations ?? [], numberOfRequiredAttestations, {
1460
+ reason: 'collect_own_only'
1461
+ });
1462
+ return new CommitteeAttestationsAndSigners(orderAttestations(attestations ?? [], committee), this.getSignatureContext());
1180
1463
  }
1181
1464
  const attestationTimeAllowed = this.config.enforceTimeTable ? this.timetable.getCheckpointAttestationDeadline() : this.l1Constants.slotDuration;
1182
1465
  const attestationDeadline = new Date((this.getSlotStartBuildTimestamp() + attestationTimeAllowed) * 1000);
@@ -1194,20 +1477,55 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1194
1477
  }
1195
1478
  // Rollup contract requires that the signatures are provided in the order of the committee
1196
1479
  const sorted = orderAttestations(trimmed, committee);
1480
+ this.logCheckpointAttestations('collected', committee, attestations, numberOfRequiredAttestations, {
1481
+ submittedCount: trimmed.length
1482
+ });
1197
1483
  // Manipulate the attestations if we've been configured to do so
1198
1484
  if (this.config.injectFakeAttestation || this.config.injectHighSValueAttestation || this.config.injectUnrecoverableSignatureAttestation || this.config.shuffleAttestationOrdering) {
1199
1485
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
1200
1486
  }
1201
- return new CommitteeAttestationsAndSigners(sorted);
1487
+ return new CommitteeAttestationsAndSigners(sorted, this.getSignatureContext());
1202
1488
  } catch (err) {
1203
1489
  if (err && err instanceof AttestationTimeoutError) {
1204
1490
  collectedAttestationsCount = err.collectedCount;
1491
+ this.logCheckpointAttestations('failed', committee, undefined, numberOfRequiredAttestations, {
1492
+ collectedCount: collectedAttestationsCount,
1493
+ reason: 'timeout'
1494
+ });
1495
+ this.log.error(`Timeout while waiting for attestations for checkpoint proposal at slot ${proposal.slotNumber} (collected ${collectedAttestationsCount}/${numberOfRequiredAttestations})`, err);
1496
+ } else {
1497
+ this.logCheckpointAttestations('failed', committee, undefined, numberOfRequiredAttestations, {
1498
+ collectedCount: collectedAttestationsCount,
1499
+ reason: err instanceof Error ? err.message : String(err)
1500
+ });
1501
+ this.log.error(`Error collecting attestations for checkpoint proposal at slot ${proposal.slotNumber}`, err);
1205
1502
  }
1206
- throw err;
1503
+ return undefined;
1207
1504
  } finally{
1208
1505
  this.metrics.recordCollectedAttestations(collectedAttestationsCount, collectAttestationsTimer.ms());
1209
1506
  }
1210
1507
  }
1508
+ logCheckpointAttestations(status, committee, attestations, requiredAttestations, opts = {}) {
1509
+ const signedValidators = attestations?.map((attestation)=>attestation.getSender()?.toString()).filter((address)=>address !== undefined) ?? [];
1510
+ const collectedCount = opts.collectedCount ?? new Set(signedValidators).size;
1511
+ const missingValidatorCount = status === 'failed' ? Math.max(0, requiredAttestations - collectedCount) : undefined;
1512
+ this.logCheckpointEvent(`attestations-${status}`, `Checkpoint attestations ${status} for slot ${this.targetSlot}`, {
1513
+ slot: this.targetSlot,
1514
+ checkpointNumber: this.checkpointNumber,
1515
+ committeeSize: committee.length,
1516
+ requiredAttestations,
1517
+ collectedAttestations: collectedCount,
1518
+ ...opts.submittedCount !== undefined && {
1519
+ submittedAttestations: opts.submittedCount
1520
+ },
1521
+ ...missingValidatorCount !== undefined && {
1522
+ missingValidatorCount
1523
+ },
1524
+ ...opts.reason !== undefined && {
1525
+ reason: opts.reason
1526
+ }
1527
+ });
1528
+ }
1211
1529
  /** Breaks the attestations before publishing based on attack configs */ manipulateAttestations(slotNumber, epoch, seed, committee, attestations) {
1212
1530
  // Compute the proposer index in the committee, since we dont want to tweak it.
1213
1531
  // Otherwise, the L1 rollup contract will reject the block outright.
@@ -1233,7 +1551,7 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1233
1551
  unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature();
1234
1552
  }
1235
1553
  }
1236
- return new CommitteeAttestationsAndSigners(attestations);
1554
+ return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1237
1555
  }
1238
1556
  if (this.config.shuffleAttestationOrdering) {
1239
1557
  this.log.warn(`Shuffling attestation ordering in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`);
@@ -1259,10 +1577,10 @@ _dec = trackSpan('CheckpointProposalJob.execute'), _dec1 = trackSpan('Checkpoint
1259
1577
  shuffled[i]
1260
1578
  ];
1261
1579
  }
1262
- const signers = new CommitteeAttestationsAndSigners(attestations).getSigners();
1263
- return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers);
1580
+ const signers = new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext()).getSigners();
1581
+ return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers, this.getSignatureContext());
1264
1582
  }
1265
- return new CommitteeAttestationsAndSigners(attestations);
1583
+ return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1266
1584
  }
1267
1585
  async dropFailedTxsFromP2P(failedTxs) {
1268
1586
  if (failedTxs.length === 0) {