@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.
@@ -18,6 +18,7 @@ import { EthAddress } from '@aztec/foundation/eth-address';
18
18
  import { Signature } from '@aztec/foundation/eth-signature';
19
19
  import { filter } from '@aztec/foundation/iterator';
20
20
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
21
+ import { retryUntil } from '@aztec/foundation/retry';
21
22
  import { sleep, sleepUntil } from '@aztec/foundation/sleep';
22
23
  import { type DateProvider, Timer } from '@aztec/foundation/timer';
23
24
  import { type TypedEventEmitter, isErrorClass, unfreeze } from '@aztec/foundation/types';
@@ -30,6 +31,7 @@ import {
30
31
  type L2BlockSink,
31
32
  type L2BlockSource,
32
33
  MaliciousCommitteeAttestationsAndSigners,
34
+ type ValidateCheckpointResult,
33
35
  } from '@aztec/stdlib/block';
34
36
  import { type Checkpoint, type ProposedCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint';
35
37
  import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
@@ -44,8 +46,10 @@ import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@azte
44
46
  import type {
45
47
  BlockProposal,
46
48
  BlockProposalOptions,
49
+ CheckpointAttestation,
47
50
  CheckpointProposal,
48
51
  CheckpointProposalOptions,
52
+ CoordinationSignatureContext,
49
53
  } from '@aztec/stdlib/p2p';
50
54
  import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p';
51
55
  import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
@@ -95,6 +99,7 @@ type CheckpointProposalResult = {
95
99
  */
96
100
  export class CheckpointProposalJob implements Traceable {
97
101
  protected readonly log: Logger;
102
+ private readonly checkpointEventLog: Logger;
98
103
 
99
104
  /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */
100
105
  private pendingL1Submission: Promise<void> | undefined;
@@ -102,6 +107,10 @@ export class CheckpointProposalJob implements Traceable {
102
107
  /** Pipelined parent chain state used while building and later submitting this checkpoint. */
103
108
  private pipelinedParentSimulationOverridesPlan?: SimulationOverridesPlan;
104
109
 
110
+ private getSignatureContext(): CoordinationSignatureContext {
111
+ return this.signatureContext;
112
+ }
113
+
105
114
  constructor(
106
115
  private readonly slotNow: SlotNumber,
107
116
  private readonly targetSlot: SlotNumber,
@@ -122,6 +131,7 @@ export class CheckpointProposalJob implements Traceable {
122
131
  private readonly checkpointsBuilder: FullNodeCheckpointsBuilder,
123
132
  private readonly blockSink: L2BlockSink,
124
133
  private readonly l1Constants: SequencerRollupConstants,
134
+ private readonly signatureContext: CoordinationSignatureContext,
125
135
  protected config: ResolvedSequencerConfig,
126
136
  protected timetable: SequencerTimetable,
127
137
  private readonly slasherClient: SlasherClientInterface | undefined,
@@ -129,7 +139,7 @@ export class CheckpointProposalJob implements Traceable {
129
139
  private readonly dateProvider: DateProvider,
130
140
  private readonly metrics: SequencerMetrics,
131
141
  private readonly checkpointMetrics: CheckpointProposalJobMetricsRecorder,
132
- private readonly eventEmitter: TypedEventEmitter<SequencerEvents>,
142
+ protected readonly eventEmitter: TypedEventEmitter<SequencerEvents>,
133
143
  private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void,
134
144
  public readonly tracer: Tracer,
135
145
  bindings?: LoggerBindings,
@@ -139,6 +149,10 @@ export class CheckpointProposalJob implements Traceable {
139
149
  ...bindings,
140
150
  instanceId: `slot-${this.slotNow}`,
141
151
  });
152
+ this.checkpointEventLog = createLogger('sequencer:checkpoint-events', {
153
+ ...bindings,
154
+ instanceId: `slot-${this.slotNow}`,
155
+ });
142
156
  }
143
157
 
144
158
  /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */
@@ -147,6 +161,13 @@ export class CheckpointProposalJob implements Traceable {
147
161
  await this.pendingL1Submission;
148
162
  }
149
163
 
164
+ private logCheckpointEvent(eventName: string, message: string, fields: Record<string, unknown>): void {
165
+ this.checkpointEventLog.debug(message, {
166
+ eventName: `sequencer-checkpoint-${eventName}`,
167
+ ...fields,
168
+ });
169
+ }
170
+
150
171
  /**
151
172
  * Executes the checkpoint proposal job.
152
173
  * Builds blocks, assembles checkpoint, and broadcasts the proposal (blocking).
@@ -208,49 +229,74 @@ export class CheckpointProposalJob implements Traceable {
208
229
  broadcast: CheckpointProposalBroadcast,
209
230
  votesPromises: Promise<unknown>[],
210
231
  ): Promise<void> {
211
- const { checkpoint, proposal, blockProposedAt } = broadcast;
232
+ const { checkpoint } = broadcast;
233
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
234
+
212
235
  try {
236
+ // Wait for all votes actions, enqueued at the beginning, to resolve
213
237
  await Promise.all(votesPromises);
214
238
 
215
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
216
- const attestations = await this.waitForAttestations(proposal);
239
+ // Try to collect attestations from the committee
240
+ const signedAttestations = await this.getSignedCommitteeAttestations(broadcast);
217
241
 
218
- this.checkpointMetrics.recordCheckpointAttestationDelay(this.dateProvider.now() - blockProposedAt);
242
+ // If pipelining, wait for the previous checkpoint to land on L1 before submitting,
243
+ // so we can check it matches the proposed checkpoint we used as parent, and has valid attestations.
244
+ if (signedAttestations && (!isPipelining || (await this.waitForValidParentCheckpointOnL1()))) {
245
+ await this.enqueueCheckpointForSubmission({ checkpoint, ...signedAttestations });
246
+ }
219
247
 
220
- // Proposer must sign over the attestations before pushing them to L1
221
- const signer = this.proposer ?? this.publisher.getSenderAddress();
222
- let attestationsSignature: Signature;
223
- try {
224
- attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
225
- attestations,
226
- signer,
227
- this.targetSlot,
228
- this.checkpointNumber,
229
- );
230
- } catch (err) {
231
- if (this.handleHASigningError(err, 'Attestations signature')) {
232
- return;
248
+ // If we failed to collect attestations, at least check if we need to issue an invalidation
249
+ // Note that if we are not pipelining, we enqueued the invalidation at the beginning
250
+ if (!signedAttestations && isPipelining && (await this.waitForSyncedL2SlotNumber(this.slotNow))) {
251
+ const validationStatus = await this.l2BlockSource.getPendingChainValidationStatus();
252
+ if (!validationStatus.valid) {
253
+ this.log.warn(
254
+ `Checkpoint ${validationStatus.checkpoint.checkpointNumber} has invalid attestations, enqueuing invalidation in spite of attestation collection failure`,
255
+ { checkpoint: validationStatus.checkpoint, reason: validationStatus.reason },
256
+ );
257
+ await this.enqueueInvalidation(validationStatus);
233
258
  }
234
- throw err;
235
259
  }
236
260
 
237
- // Enqueue the checkpoint for L1 submission
238
- await this.enqueueCheckpointForSubmission({ checkpoint, attestations, attestationsSignature });
239
-
261
+ // Send whatever was enqueued: votes + (propose | invalidation | nothing).
240
262
  // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise.
241
- const submitAfter = this.epochCache.isProposerPipeliningEnabled()
263
+ const submitAfter = isPipelining
242
264
  ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
243
265
  : new Date(this.dateProvider.now());
244
266
 
245
267
  const l1Response = await this.publisher.sendRequestsAt(submitAfter);
246
268
  const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
247
269
  if (proposedAction) {
270
+ this.logCheckpointEvent('published', `Checkpoint published for slot ${this.targetSlot}`, {
271
+ slot: this.targetSlot,
272
+ checkpointNumber: this.checkpointNumber,
273
+ successfulActions: l1Response?.successfulActions,
274
+ sentActions: l1Response?.sentActions,
275
+ });
248
276
  this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.targetSlot });
249
277
  const coinbase = checkpoint.header.coinbase;
250
278
  await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
251
279
  } else {
280
+ this.logCheckpointEvent('publish-failed', `Checkpoint publish failed for slot ${this.targetSlot}`, {
281
+ slot: this.targetSlot,
282
+ checkpointNumber: this.checkpointNumber,
283
+ successfulActions: l1Response?.successfulActions,
284
+ failedActions: l1Response?.failedActions,
285
+ sentActions: l1Response?.sentActions,
286
+ expiredActions: l1Response?.expiredActions,
287
+ reason: 'propose_action_not_successful',
288
+ });
289
+ this.log.warn(`Checkpoint publish failed for slot ${this.targetSlot}`, {
290
+ slot: this.targetSlot,
291
+ checkpointNumber: this.checkpointNumber,
292
+ successfulActions: l1Response?.successfulActions,
293
+ failedActions: l1Response?.failedActions,
294
+ sentActions: l1Response?.sentActions,
295
+ expiredActions: l1Response?.expiredActions,
296
+ reason: 'propose_action_not_successful',
297
+ });
252
298
  this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.targetSlot });
253
- if (this.epochCache.isProposerPipeliningEnabled()) {
299
+ if (isPipelining) {
254
300
  this.metrics.recordPipelineDiscard();
255
301
  }
256
302
  }
@@ -258,9 +304,18 @@ export class CheckpointProposalJob implements Traceable {
258
304
  if (err instanceof SequencerInterruptedError) {
259
305
  return;
260
306
  }
261
- this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err);
307
+ this.logCheckpointEvent('publish-failed', `Checkpoint publish failed for slot ${this.targetSlot}`, {
308
+ slot: this.targetSlot,
309
+ checkpointNumber: this.checkpointNumber,
310
+ reason: err instanceof Error ? err.message : String(err),
311
+ });
312
+ this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err, {
313
+ slot: this.targetSlot,
314
+ checkpointNumber: this.checkpointNumber,
315
+ reason: err instanceof Error ? err.message : String(err),
316
+ });
262
317
  this.eventEmitter.emit('checkpoint-publish-failed', { slot: this.targetSlot });
263
- if (this.epochCache.isProposerPipeliningEnabled()) {
318
+ if (isPipelining) {
264
319
  this.metrics.recordPipelineDiscard();
265
320
  }
266
321
  }
@@ -303,6 +358,141 @@ export class CheckpointProposalJob implements Traceable {
303
358
  });
304
359
  }
305
360
 
361
+ /**
362
+ * Wait until the archiver syncs past the given L2 slot number.
363
+ * The deadline is the end of `this.targetSlot`, beyond which any pipelined work would miss its
364
+ * L1 submission window and is no longer useful.
365
+ */
366
+ private async waitForSyncedL2SlotNumber(waitForSlot: SlotNumber): Promise<boolean> {
367
+ const targetSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
368
+ const targetSlotEndMs = (targetSlotStart + this.l1Constants.slotDuration) * 1000;
369
+ const syncDelayTolerance = this.l1Constants.ethereumSlotDuration * 2 * 1000;
370
+ const timeoutSeconds = Math.max(0.1, (targetSlotEndMs + syncDelayTolerance - this.dateProvider.now()) / 1000);
371
+
372
+ try {
373
+ return await retryUntil(
374
+ async () => {
375
+ const syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
376
+ return syncedSlot !== undefined && syncedSlot >= waitForSlot;
377
+ },
378
+ `archiver sync past slot ${waitForSlot}`,
379
+ timeoutSeconds,
380
+ 0.2,
381
+ );
382
+ } catch {
383
+ this.log.warn(
384
+ `Archiver did not sync L1 past slot ${waitForSlot} before slot ${this.targetSlot} expired, discarding pipelined work`,
385
+ { checkpointNumber: this.checkpointNumber },
386
+ );
387
+ this.emitPipelinedCheckpointDiscarded('archiver-sync-timeout');
388
+ return false;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Waits for the parent checkpoint to land on L1 before submitting a pipelined checkpoint.
394
+ * Polls until the archiver has synced L1 past the parent's slot, then verifies:
395
+ * - If we built on a proposed parent: it must have landed on L1 with matching hash and valid attestations.
396
+ * - If we built without a proposed parent: no new checkpoint must have appeared for that slot.
397
+ * If the parent has invalid attestations, enqueues an invalidation. Returns whether to proceed with the proposal.
398
+ */
399
+ protected async waitForValidParentCheckpointOnL1(): Promise<boolean> {
400
+ const parentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 1);
401
+
402
+ // Wait until archiver has synced L1 past the parent's slot (slotNow)
403
+ if (!(await this.waitForSyncedL2SlotNumber(this.slotNow))) {
404
+ return false;
405
+ }
406
+
407
+ const tips = await this.l2BlockSource.getL2Tips();
408
+ const checkpointedNumber = tips.checkpointed.checkpoint.number;
409
+
410
+ // We built on top of a proposed checkpoint. Verify it landed on L1 as expected.
411
+ if (this.proposedCheckpointData) {
412
+ // After syncing from L1 we see the chain tip has invalid attestations. This means the parent checkpoint was posted
413
+ // with invalid attestations, or it built on top of something with invalid attestations and didnt invalidate them.
414
+ // Either way, we thought our parent would be valid, so we have to throw away our work. But at least we'll try and
415
+ // invalidate on L1 so we clean up the chain for the next proposer. And we'll slash them, but that's handled elsewhere.
416
+ const validationStatus = await this.l2BlockSource.getPendingChainValidationStatus();
417
+ if (!validationStatus.valid) {
418
+ this.log.warn(
419
+ `Parent checkpoint ${parentCheckpointNumber} has invalid attestations, discarding pipelined work`,
420
+ { checkpointNumber: this.checkpointNumber, reason: validationStatus.reason },
421
+ );
422
+ this.emitPipelinedCheckpointDiscarded('parent-invalid-attestations');
423
+ await this.enqueueInvalidation(validationStatus);
424
+ return false;
425
+ }
426
+
427
+ // The pending chain is valid. But did the parent checkpoint land on L1 at all?
428
+ if (checkpointedNumber < parentCheckpointNumber) {
429
+ this.log.warn(`Parent checkpoint ${parentCheckpointNumber} did not land on L1, discarding pipelined work`, {
430
+ checkpointNumber: this.checkpointNumber,
431
+ checkpointedNumber,
432
+ });
433
+ this.emitPipelinedCheckpointDiscarded('parent-not-on-l1');
434
+ return false;
435
+ }
436
+
437
+ // It landed. But is it the one we were expecting?
438
+ const expectedHash = this.proposedCheckpointData.header.hash().toString();
439
+ if (tips.checkpointed.checkpoint.hash !== expectedHash) {
440
+ this.log.warn(`Parent checkpoint ${parentCheckpointNumber} hash mismatch on L1, discarding pipelined work`, {
441
+ checkpointNumber: this.checkpointNumber,
442
+ expectedHash,
443
+ actualHash: tips.checkpointed.checkpoint.hash,
444
+ });
445
+ this.emitPipelinedCheckpointDiscarded('parent-hash-mismatch');
446
+ return false;
447
+ }
448
+
449
+ return true;
450
+ } else {
451
+ // We didn't see a proposed checkpoint at build time, so we built on checkpointed parent from two slots ago.
452
+ // But if a new checkpoint for the previous slot appeared on L1 in the meantime, our checkpoint assumed the wrong parent,
453
+ // so we have to discard our work. This can happen if we're somehow cut off from p2p and fail to see the checkpoint
454
+ // proposal for the previous slot.
455
+ if (checkpointedNumber > parentCheckpointNumber) {
456
+ this.log.warn(
457
+ `Unexpected checkpoint ${checkpointedNumber} landed on L1 after we built on top of parent ${parentCheckpointNumber}, discarding pipelined work`,
458
+ { checkpointNumber: this.checkpointNumber, checkpointedNumber },
459
+ );
460
+ this.emitPipelinedCheckpointDiscarded('unexpected-parent-appeared');
461
+ return false;
462
+ }
463
+
464
+ return true;
465
+ }
466
+ }
467
+
468
+ /** Emits the pipelined-checkpoint-discarded event and records the metric. */
469
+ private emitPipelinedCheckpointDiscarded(reason: string): void {
470
+ this.metrics.recordPipelineParentCheckpointMismatch(reason);
471
+ this.eventEmitter.emit('pipelined-checkpoint-discarded', {
472
+ slot: this.targetSlot,
473
+ checkpointNumber: this.checkpointNumber,
474
+ reason,
475
+ });
476
+ }
477
+
478
+ /** Simulates and enqueues an invalidation request for the invalid parent checkpoint. */
479
+ private async enqueueInvalidation(validationStatus: ValidateCheckpointResult): Promise<void> {
480
+ if (this.config.skipInvalidateBlockAsProposer) {
481
+ this.log.warn(`Skipping checkpoint invalidation as proposer due to test configuration`);
482
+ return;
483
+ }
484
+ const invalidateRequest = await this.publisher.simulateInvalidateCheckpoint(validationStatus);
485
+ if (invalidateRequest) {
486
+ const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
487
+ const txTimeoutAt = new Date((submissionSlotStart + this.l1Constants.slotDuration) * 1000);
488
+ this.publisher.enqueueInvalidateCheckpoint(invalidateRequest, { txTimeoutAt });
489
+ } else {
490
+ this.log.info(`Invalidation simulation returned undefined, checkpoint may have been removed already`, {
491
+ checkpointNumber: this.checkpointNumber,
492
+ });
493
+ }
494
+ }
495
+
306
496
  @trackSpan('CheckpointProposalJob.proposeCheckpoint', function () {
307
497
  return {
308
498
  // nullish operator needed for tests
@@ -328,11 +518,15 @@ export class CheckpointProposalJob implements Traceable {
328
518
 
329
519
  // Start the checkpoint
330
520
  this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
331
- this.log.info(`Starting checkpoint proposal`, {
521
+ this.logCheckpointEvent('slot-started', `Starting checkpoint proposal for slot ${this.targetSlot}`, {
332
522
  buildSlot: this.slotNow,
333
523
  submissionSlot: this.targetSlot,
524
+ slot: this.targetSlot,
525
+ checkpointNumber: this.checkpointNumber,
334
526
  pipelining: this.epochCache.isProposerPipeliningEnabled(),
335
527
  proposer: this.proposer?.toString(),
528
+ attestorAddress: this.attestorAddress.toString(),
529
+ publisherAddress: this.publisher.getSenderAddress().toString(),
336
530
  coinbase: coinbase.toString(),
337
531
  });
338
532
  this.metrics.incOpenSlot(this.targetSlot, this.proposer?.toString() ?? 'unknown');
@@ -424,16 +618,38 @@ export class CheckpointProposalJob implements Traceable {
424
618
  }
425
619
 
426
620
  if (blocksInCheckpoint.length === 0) {
427
- this.log.warn(`No blocks were built for slot ${this.targetSlot}`, { slot: this.targetSlot });
621
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
622
+ slot: this.targetSlot,
623
+ checkpointNumber: this.checkpointNumber,
624
+ reason: 'no_blocks_built',
625
+ });
626
+ this.log.warn(`No blocks were built for slot ${this.targetSlot}`, {
627
+ slot: this.targetSlot,
628
+ checkpointNumber: this.checkpointNumber,
629
+ reason: 'no_blocks_built',
630
+ });
428
631
  this.eventEmitter.emit('checkpoint-empty', { slot: this.targetSlot });
429
632
  return undefined;
430
633
  }
431
634
 
432
635
  const minBlocksForCheckpoint = this.config.minBlocksForCheckpoint;
433
636
  if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
637
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
638
+ slot: this.targetSlot,
639
+ checkpointNumber: this.checkpointNumber,
640
+ blocksBuilt: blocksInCheckpoint.length,
641
+ minBlocksForCheckpoint,
642
+ reason: 'min_blocks_not_met',
643
+ });
434
644
  this.log.warn(
435
645
  `Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`,
436
- { slot: this.targetSlot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
646
+ {
647
+ slot: this.targetSlot,
648
+ checkpointNumber: this.checkpointNumber,
649
+ blocksBuilt: blocksInCheckpoint.length,
650
+ minBlocksForCheckpoint,
651
+ reason: 'min_blocks_not_met',
652
+ },
437
653
  );
438
654
  return undefined;
439
655
  }
@@ -454,7 +670,18 @@ export class CheckpointProposalJob implements Traceable {
454
670
  maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint,
455
671
  });
456
672
  } catch (err) {
673
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
674
+ slot: this.targetSlot,
675
+ checkpointNumber: this.checkpointNumber,
676
+ blocksBuilt: blocksInCheckpoint.length,
677
+ reason: 'invalid_checkpoint',
678
+ checkpoint: checkpoint.header.toInspect(),
679
+ });
457
680
  this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, {
681
+ slot: this.targetSlot,
682
+ checkpointNumber: this.checkpointNumber,
683
+ blocksBuilt: blocksInCheckpoint.length,
684
+ reason: 'invalid_checkpoint',
458
685
  checkpoint: checkpoint.header.toInspect(),
459
686
  });
460
687
  return undefined;
@@ -467,6 +694,17 @@ export class CheckpointProposalJob implements Traceable {
467
694
  checkpoint.getStats().txCount,
468
695
  Number(checkpoint.header.totalManaUsed.toBigInt()),
469
696
  );
697
+ this.logCheckpointEvent('built', `Checkpoint built for slot ${this.targetSlot}`, {
698
+ slot: this.targetSlot,
699
+ buildSlot: this.slotNow,
700
+ checkpointNumber: this.checkpointNumber,
701
+ proposer: this.proposer?.toString(),
702
+ attestorAddress: this.attestorAddress.toString(),
703
+ publisherAddress: this.publisher.getSenderAddress().toString(),
704
+ blocksBuilt: blocksInCheckpoint.length,
705
+ txCount: checkpoint.getStats().txCount,
706
+ totalMana: Number(checkpoint.header.totalManaUsed.toBigInt()),
707
+ });
470
708
 
471
709
  // In fisherman mode, return the checkpoint without broadcasting or collecting attestations
472
710
  if (this.config.fishermanMode) {
@@ -496,8 +734,10 @@ export class CheckpointProposalJob implements Traceable {
496
734
  );
497
735
 
498
736
  const blockProposedAt = this.dateProvider.now();
499
- await this.p2pClient.broadcastCheckpointProposal(proposal);
500
- this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
737
+ if (!this.config.skipBroadcastProposals) {
738
+ await this.p2pClient.broadcastCheckpointProposal(proposal);
739
+ this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
740
+ }
501
741
 
502
742
  // Return immediately after broadcast — attestation collection happens in the background
503
743
  return { checkpoint, proposal, blockProposedAt };
@@ -537,6 +777,15 @@ export class CheckpointProposalJob implements Traceable {
537
777
  const indexWithinCheckpoint = IndexWithinCheckpoint(blocksBuilt);
538
778
  const blockNumber = BlockNumber(initialBlockNumber + blocksBuilt);
539
779
 
780
+ if (blocksBuilt >= this.config.maxBlocksPerCheckpoint) {
781
+ this.log.debug(`Reached max blocks per checkpoint`, {
782
+ slot: this.targetSlot,
783
+ blocksBuilt,
784
+ maxBlocksPerCheckpoint: this.config.maxBlocksPerCheckpoint,
785
+ });
786
+ break;
787
+ }
788
+
540
789
  const secondsIntoSlot = this.getSecondsIntoSlot();
541
790
  const timingInfo = this.timetable.canStartNextBlock(secondsIntoSlot);
542
791
 
@@ -618,7 +867,9 @@ export class CheckpointProposalJob implements Traceable {
618
867
  }
619
868
 
620
869
  // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
621
- proposal && (await this.p2pClient.broadcastProposal(proposal));
870
+ if (proposal && !this.config.skipBroadcastProposals) {
871
+ await this.p2pClient.broadcastProposal(proposal);
872
+ }
622
873
 
623
874
  // Wait until the next block's start time
624
875
  await this.waitUntilNextSubslot(timingInfo.deadline);
@@ -692,9 +943,26 @@ export class CheckpointProposalJob implements Traceable {
692
943
  // Wait until we have enough txs to build the block
693
944
  const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
694
945
  if (!canStartBuilding) {
946
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
947
+ reason: 'insufficient_txs',
948
+ blockNumber,
949
+ slot: this.targetSlot,
950
+ checkpointNumber: this.checkpointNumber,
951
+ indexWithinCheckpoint,
952
+ availableTxs,
953
+ minTxs,
954
+ });
695
955
  this.log.warn(
696
956
  `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
697
- { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
957
+ {
958
+ reason: 'insufficient_txs',
959
+ blockNumber,
960
+ slot: this.targetSlot,
961
+ checkpointNumber: this.checkpointNumber,
962
+ indexWithinCheckpoint,
963
+ availableTxs,
964
+ minTxs,
965
+ },
698
966
  );
699
967
  this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
700
968
  this.metrics.recordBlockProposalFailed('insufficient_txs');
@@ -746,10 +1014,21 @@ export class CheckpointProposalJob implements Traceable {
746
1014
  await this.dropFailedTxsFromP2P(buildResult.failedTxs);
747
1015
 
748
1016
  if (buildResult.status === 'insufficient-valid-txs') {
1017
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1018
+ reason: 'insufficient_valid_txs',
1019
+ slot: this.targetSlot,
1020
+ checkpointNumber: this.checkpointNumber,
1021
+ blockNumber,
1022
+ numTxs: buildResult.processedCount,
1023
+ indexWithinCheckpoint,
1024
+ minValidTxs,
1025
+ });
749
1026
  this.log.warn(
750
1027
  `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.targetSlot} has too few valid txs to be proposed`,
751
1028
  {
1029
+ reason: 'insufficient_valid_txs',
752
1030
  slot: this.targetSlot,
1031
+ checkpointNumber: this.checkpointNumber,
753
1032
  blockNumber,
754
1033
  numTxs: buildResult.processedCount,
755
1034
  indexWithinCheckpoint,
@@ -798,7 +1077,18 @@ export class CheckpointProposalJob implements Traceable {
798
1077
  reason: err.message,
799
1078
  slot: this.targetSlot,
800
1079
  });
801
- this.log.error(`Error building block`, err, { blockNumber, slot: this.targetSlot });
1080
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1081
+ reason: err instanceof Error ? err.message : String(err),
1082
+ slot: this.targetSlot,
1083
+ checkpointNumber: this.checkpointNumber,
1084
+ blockNumber,
1085
+ });
1086
+ this.log.error(`Error building block`, err, {
1087
+ reason: err instanceof Error ? err.message : String(err),
1088
+ slot: this.targetSlot,
1089
+ checkpointNumber: this.checkpointNumber,
1090
+ blockNumber,
1091
+ });
802
1092
  this.metrics.recordBlockProposalFailed(err.name || 'unknown_error');
803
1093
  this.metrics.recordFailedBlock();
804
1094
  return { error: err };
@@ -870,15 +1160,47 @@ export class CheckpointProposalJob implements Traceable {
870
1160
  return { canStartBuilding: true, availableTxs, minTxs };
871
1161
  }
872
1162
 
1163
+ private async getSignedCommitteeAttestations(
1164
+ broadcast: CheckpointProposalBroadcast,
1165
+ ): Promise<{ attestations: CommitteeAttestationsAndSigners; attestationsSignature: Signature } | undefined> {
1166
+ const { proposal, blockProposedAt } = broadcast;
1167
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
1168
+ const attestations = await this.waitForAttestations(proposal);
1169
+ if (!attestations) {
1170
+ return undefined;
1171
+ }
1172
+ this.checkpointMetrics.recordCheckpointAttestationDelay(this.dateProvider.now() - blockProposedAt);
1173
+
1174
+ // Proposer must sign over the attestations before pushing them to L1
1175
+ const signer = this.proposer ?? this.publisher.getSenderAddress();
1176
+ try {
1177
+ const attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
1178
+ attestations,
1179
+ signer,
1180
+ this.targetSlot,
1181
+ this.checkpointNumber,
1182
+ );
1183
+ return { attestations, attestationsSignature };
1184
+ } catch (err) {
1185
+ if (this.handleHASigningError(err, 'Attestations signature')) {
1186
+ return;
1187
+ }
1188
+ this.log.error(`Error signing attestations for checkpoint proposal at slot ${proposal.slotNumber}`, err);
1189
+ return undefined;
1190
+ }
1191
+ }
1192
+
873
1193
  /**
874
1194
  * Waits for enough attestations to be collected via p2p.
875
1195
  * This is run after all blocks for the checkpoint have been built.
876
1196
  */
877
1197
  @trackSpan('CheckpointProposalJob.waitForAttestations')
878
- private async waitForAttestations(proposal: CheckpointProposal): Promise<CommitteeAttestationsAndSigners> {
1198
+ private async waitForAttestations(
1199
+ proposal: CheckpointProposal,
1200
+ ): Promise<CommitteeAttestationsAndSigners | undefined> {
879
1201
  if (this.config.fishermanMode) {
880
1202
  this.log.debug('Skipping attestation collection in fisherman mode');
881
- return CommitteeAttestationsAndSigners.empty();
1203
+ return CommitteeAttestationsAndSigners.empty(this.getSignatureContext());
882
1204
  }
883
1205
 
884
1206
  const slotNumber = proposal.slotNumber;
@@ -888,7 +1210,7 @@ export class CheckpointProposalJob implements Traceable {
888
1210
  throw new Error('No committee when collecting attestations');
889
1211
  } else if (committee.length === 0) {
890
1212
  this.log.verbose(`Attesting committee is empty`);
891
- return CommitteeAttestationsAndSigners.empty();
1213
+ return CommitteeAttestationsAndSigners.empty(this.getSignatureContext());
892
1214
  } else {
893
1215
  this.log.debug(`Attesting committee length is ${committee.length}`, { committee });
894
1216
  }
@@ -898,7 +1220,13 @@ export class CheckpointProposalJob implements Traceable {
898
1220
  if (this.config.skipCollectingAttestations) {
899
1221
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
900
1222
  const attestations = await this.validatorClient?.collectOwnAttestations(proposal, this.checkpointNumber);
901
- return new CommitteeAttestationsAndSigners(orderAttestations(attestations ?? [], committee));
1223
+ this.logCheckpointAttestations('collected', committee, attestations ?? [], numberOfRequiredAttestations, {
1224
+ reason: 'collect_own_only',
1225
+ });
1226
+ return new CommitteeAttestationsAndSigners(
1227
+ orderAttestations(attestations ?? [], committee),
1228
+ this.getSignatureContext(),
1229
+ );
902
1230
  }
903
1231
 
904
1232
  const attestationTimeAllowed = this.config.enforceTimeTable
@@ -934,6 +1262,9 @@ export class CheckpointProposalJob implements Traceable {
934
1262
 
935
1263
  // Rollup contract requires that the signatures are provided in the order of the committee
936
1264
  const sorted = orderAttestations(trimmed, committee);
1265
+ this.logCheckpointAttestations('collected', committee, attestations, numberOfRequiredAttestations, {
1266
+ submittedCount: trimmed.length,
1267
+ });
937
1268
 
938
1269
  // Manipulate the attestations if we've been configured to do so
939
1270
  if (
@@ -945,17 +1276,56 @@ export class CheckpointProposalJob implements Traceable {
945
1276
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
946
1277
  }
947
1278
 
948
- return new CommitteeAttestationsAndSigners(sorted);
1279
+ return new CommitteeAttestationsAndSigners(sorted, this.getSignatureContext());
949
1280
  } catch (err) {
950
1281
  if (err && err instanceof AttestationTimeoutError) {
951
1282
  collectedAttestationsCount = err.collectedCount;
1283
+ this.logCheckpointAttestations('failed', committee, undefined, numberOfRequiredAttestations, {
1284
+ collectedCount: collectedAttestationsCount,
1285
+ reason: 'timeout',
1286
+ });
1287
+ this.log.error(
1288
+ `Timeout while waiting for attestations for checkpoint proposal at slot ${proposal.slotNumber} (collected ${collectedAttestationsCount}/${numberOfRequiredAttestations})`,
1289
+ err,
1290
+ );
1291
+ } else {
1292
+ this.logCheckpointAttestations('failed', committee, undefined, numberOfRequiredAttestations, {
1293
+ collectedCount: collectedAttestationsCount,
1294
+ reason: err instanceof Error ? err.message : String(err),
1295
+ });
1296
+ this.log.error(`Error collecting attestations for checkpoint proposal at slot ${proposal.slotNumber}`, err);
952
1297
  }
953
- throw err;
1298
+ return undefined;
954
1299
  } finally {
955
1300
  this.metrics.recordCollectedAttestations(collectedAttestationsCount, collectAttestationsTimer.ms());
956
1301
  }
957
1302
  }
958
1303
 
1304
+ private logCheckpointAttestations(
1305
+ status: 'collected' | 'failed',
1306
+ committee: EthAddress[],
1307
+ attestations: CheckpointAttestation[] | undefined,
1308
+ requiredAttestations: number,
1309
+ opts: { collectedCount?: number; submittedCount?: number; reason?: string } = {},
1310
+ ) {
1311
+ const signedValidators =
1312
+ attestations
1313
+ ?.map(attestation => attestation.getSender()?.toString())
1314
+ .filter((address): address is `0x${string}` => address !== undefined) ?? [];
1315
+ const collectedCount = opts.collectedCount ?? new Set(signedValidators).size;
1316
+ const missingValidatorCount = status === 'failed' ? Math.max(0, requiredAttestations - collectedCount) : undefined;
1317
+ this.logCheckpointEvent(`attestations-${status}`, `Checkpoint attestations ${status} for slot ${this.targetSlot}`, {
1318
+ slot: this.targetSlot,
1319
+ checkpointNumber: this.checkpointNumber,
1320
+ committeeSize: committee.length,
1321
+ requiredAttestations,
1322
+ collectedAttestations: collectedCount,
1323
+ ...(opts.submittedCount !== undefined && { submittedAttestations: opts.submittedCount }),
1324
+ ...(missingValidatorCount !== undefined && { missingValidatorCount }),
1325
+ ...(opts.reason !== undefined && { reason: opts.reason }),
1326
+ });
1327
+ }
1328
+
959
1329
  /** Breaks the attestations before publishing based on attack configs */
960
1330
  private manipulateAttestations(
961
1331
  slotNumber: SlotNumber,
@@ -999,7 +1369,7 @@ export class CheckpointProposalJob implements Traceable {
999
1369
  unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature();
1000
1370
  }
1001
1371
  }
1002
- return new CommitteeAttestationsAndSigners(attestations);
1372
+ return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1003
1373
  }
1004
1374
 
1005
1375
  if (this.config.shuffleAttestationOrdering) {
@@ -1021,11 +1391,11 @@ export class CheckpointProposalJob implements Traceable {
1021
1391
  [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
1022
1392
  }
1023
1393
 
1024
- const signers = new CommitteeAttestationsAndSigners(attestations).getSigners();
1025
- return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers);
1394
+ const signers = new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext()).getSigners();
1395
+ return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers, this.getSignatureContext());
1026
1396
  }
1027
1397
 
1028
- return new CommitteeAttestationsAndSigners(attestations);
1398
+ return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1029
1399
  }
1030
1400
 
1031
1401
  private async dropFailedTxsFromP2P(failedTxs: FailedTx[]) {