@aztec/sequencer-client 0.0.1-commit.b2a5d0dd1 → 0.0.1-commit.b3d3157a

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.
Files changed (75) hide show
  1. package/README.md +281 -21
  2. package/dest/client/sequencer-client.d.ts +3 -3
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +6 -6
  5. package/dest/config.d.ts +6 -3
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +33 -10
  8. package/dest/global_variable_builder/fee_provider.js +1 -1
  9. package/dest/global_variable_builder/global_builder.d.ts +2 -3
  10. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  11. package/dest/global_variable_builder/global_builder.js +1 -1
  12. package/dest/index.d.ts +2 -2
  13. package/dest/index.d.ts.map +1 -1
  14. package/dest/index.js +1 -1
  15. package/dest/publisher/config.d.ts +3 -3
  16. package/dest/publisher/config.d.ts.map +1 -1
  17. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  18. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  19. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  20. package/dest/publisher/sequencer-publisher.d.ts +36 -36
  21. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  22. package/dest/publisher/sequencer-publisher.js +271 -500
  23. package/dest/sequencer/automine/automine_factory.d.ts +54 -0
  24. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  25. package/dest/sequencer/automine/automine_factory.js +84 -0
  26. package/dest/sequencer/automine/automine_sequencer.d.ts +151 -0
  27. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  28. package/dest/sequencer/automine/automine_sequencer.js +509 -0
  29. package/dest/sequencer/chain_state_overrides.d.ts +52 -16
  30. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -1
  31. package/dest/sequencer/chain_state_overrides.js +86 -27
  32. package/dest/sequencer/checkpoint_proposal_job.d.ts +24 -6
  33. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  34. package/dest/sequencer/checkpoint_proposal_job.js +373 -83
  35. package/dest/sequencer/events.d.ts +42 -2
  36. package/dest/sequencer/events.d.ts.map +1 -1
  37. package/dest/sequencer/index.d.ts +3 -1
  38. package/dest/sequencer/index.d.ts.map +1 -1
  39. package/dest/sequencer/index.js +2 -0
  40. package/dest/sequencer/metrics.d.ts +3 -1
  41. package/dest/sequencer/metrics.d.ts.map +1 -1
  42. package/dest/sequencer/metrics.js +7 -0
  43. package/dest/sequencer/sequencer.d.ts +44 -9
  44. package/dest/sequencer/sequencer.d.ts.map +1 -1
  45. package/dest/sequencer/sequencer.js +256 -79
  46. package/dest/sequencer/timetable.d.ts +3 -6
  47. package/dest/sequencer/timetable.d.ts.map +1 -1
  48. package/dest/sequencer/timetable.js +3 -7
  49. package/dest/sequencer/types.d.ts +2 -2
  50. package/dest/sequencer/types.d.ts.map +1 -1
  51. package/dest/test/utils.d.ts +1 -1
  52. package/dest/test/utils.d.ts.map +1 -1
  53. package/dest/test/utils.js +7 -6
  54. package/package.json +27 -27
  55. package/src/client/sequencer-client.ts +12 -7
  56. package/src/config.ts +38 -10
  57. package/src/global_variable_builder/fee_provider.ts +1 -1
  58. package/src/global_variable_builder/global_builder.ts +2 -3
  59. package/src/index.ts +10 -1
  60. package/src/publisher/config.ts +2 -2
  61. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  62. package/src/publisher/sequencer-publisher.ts +286 -521
  63. package/src/sequencer/README.md +162 -520
  64. package/src/sequencer/automine/README.md +46 -0
  65. package/src/sequencer/automine/automine_factory.ts +145 -0
  66. package/src/sequencer/automine/automine_sequencer.ts +595 -0
  67. package/src/sequencer/chain_state_overrides.ts +133 -58
  68. package/src/sequencer/checkpoint_proposal_job.ts +420 -100
  69. package/src/sequencer/events.ts +46 -2
  70. package/src/sequencer/index.ts +2 -0
  71. package/src/sequencer/metrics.ts +9 -0
  72. package/src/sequencer/sequencer.ts +284 -95
  73. package/src/sequencer/timetable.ts +2 -9
  74. package/src/sequencer/types.ts +1 -1
  75. package/src/test/utils.ts +28 -10
@@ -14,12 +14,12 @@ import {
14
14
  generateUnrecoverableSignature,
15
15
  } from '@aztec/foundation/crypto/secp256k1-signer';
16
16
  import { Fr } from '@aztec/foundation/curves/bn254';
17
+ import { InterruptError, TimeoutError } from '@aztec/foundation/error';
17
18
  import { EthAddress } from '@aztec/foundation/eth-address';
18
19
  import { Signature } from '@aztec/foundation/eth-signature';
19
20
  import { filter } from '@aztec/foundation/iterator';
20
21
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
21
- import { retryUntil } from '@aztec/foundation/retry';
22
- import { sleep, sleepUntil } from '@aztec/foundation/sleep';
22
+ import { InterruptibleSleep } from '@aztec/foundation/sleep';
23
23
  import { type DateProvider, Timer } from '@aztec/foundation/timer';
24
24
  import { type TypedEventEmitter, isErrorClass, unfreeze } from '@aztec/foundation/types';
25
25
  import type { P2P } from '@aztec/p2p';
@@ -31,9 +31,15 @@ import {
31
31
  type L2BlockSink,
32
32
  type L2BlockSource,
33
33
  MaliciousCommitteeAttestationsAndSigners,
34
+ type ProposedCheckpointSink,
34
35
  type ValidateCheckpointResult,
35
36
  } from '@aztec/stdlib/block';
36
- import { type Checkpoint, type ProposedCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint';
37
+ import {
38
+ type Checkpoint,
39
+ type ProposedCheckpointData,
40
+ getPreviousCheckpointOutHashes,
41
+ validateCheckpoint,
42
+ } from '@aztec/stdlib/checkpoint';
37
43
  import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
38
44
  import { Gas } from '@aztec/stdlib/gas';
39
45
  import {
@@ -46,8 +52,10 @@ import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@azte
46
52
  import type {
47
53
  BlockProposal,
48
54
  BlockProposalOptions,
55
+ CheckpointAttestation,
49
56
  CheckpointProposal,
50
57
  CheckpointProposalOptions,
58
+ CoordinationSignatureContext,
51
59
  } from '@aztec/stdlib/p2p';
52
60
  import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p';
53
61
  import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
@@ -59,10 +67,7 @@ import { DutyAlreadySignedError, SlashingProtectionError } from '@aztec/validato
59
67
 
60
68
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
61
69
  import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
62
- import {
63
- buildPipelinedParentSimulationOverridesPlan,
64
- buildSubmissionSimulationOverridesPlan,
65
- } from './chain_state_overrides.js';
70
+ import { buildCheckpointSimulationOverridesPlan } from './chain_state_overrides.js';
66
71
  import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js';
67
72
  import { CheckpointVoter } from './checkpoint_voter.js';
68
73
  import { SequencerInterruptedError } from './errors.js';
@@ -74,6 +79,7 @@ import { SequencerState } from './utils.js';
74
79
 
75
80
  /** How much time to sleep while waiting for min transactions to accumulate for a block */
76
81
  const TXS_POLLING_MS = 500;
82
+ const ARCHIVER_SYNC_POLLING_MS = 200;
77
83
 
78
84
  /** Result from proposeCheckpoint when a checkpoint was successfully built and broadcast. */
79
85
  type CheckpointProposalBroadcast = {
@@ -97,12 +103,24 @@ type CheckpointProposalResult = {
97
103
  */
98
104
  export class CheckpointProposalJob implements Traceable {
99
105
  protected readonly log: Logger;
106
+ private readonly checkpointEventLog: Logger;
100
107
 
101
108
  /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */
102
109
  private pendingL1Submission: Promise<void> | undefined;
110
+ private readonly interruptibleSleep = new InterruptibleSleep();
111
+ private interrupted = false;
112
+
113
+ /**
114
+ * Chain state overrides built once per slot in proposeCheckpoint after the checkpoint is
115
+ * complete. Carries the pending parent override (archive + slot + fee header) for pipelining,
116
+ * or the invalidation pending override when rolling back. Consumed by
117
+ * publisher.validateBlockHeader before broadcast.
118
+ */
119
+ private checkpointSimulationOverridesPlan?: SimulationOverridesPlan;
103
120
 
104
- /** Pipelined parent chain state used while building and later submitting this checkpoint. */
105
- private pipelinedParentSimulationOverridesPlan?: SimulationOverridesPlan;
121
+ private getSignatureContext(): CoordinationSignatureContext {
122
+ return this.signatureContext;
123
+ }
106
124
 
107
125
  constructor(
108
126
  private readonly slotNow: SlotNumber,
@@ -110,6 +128,7 @@ export class CheckpointProposalJob implements Traceable {
110
128
  private readonly targetEpoch: EpochNumber,
111
129
  private readonly checkpointNumber: CheckpointNumber,
112
130
  private readonly syncedToBlockNumber: BlockNumber,
131
+ private readonly checkpointedCheckpointNumber: CheckpointNumber,
113
132
  // TODO(palla/mbps): Can we remove the proposer in favor of attestorAddress? Need to check fisherman-node flows.
114
133
  private readonly proposer: EthAddress | undefined,
115
134
  private readonly publisher: SequencerPublisher,
@@ -122,8 +141,9 @@ export class CheckpointProposalJob implements Traceable {
122
141
  private readonly l1ToL2MessageSource: L1ToL2MessageSource,
123
142
  private readonly l2BlockSource: L2BlockSource,
124
143
  private readonly checkpointsBuilder: FullNodeCheckpointsBuilder,
125
- private readonly blockSink: L2BlockSink,
144
+ private readonly blockSink: L2BlockSink & ProposedCheckpointSink,
126
145
  private readonly l1Constants: SequencerRollupConstants,
146
+ private readonly signatureContext: CoordinationSignatureContext,
127
147
  protected config: ResolvedSequencerConfig,
128
148
  protected timetable: SequencerTimetable,
129
149
  private readonly slasherClient: SlasherClientInterface | undefined,
@@ -132,7 +152,7 @@ export class CheckpointProposalJob implements Traceable {
132
152
  private readonly metrics: SequencerMetrics,
133
153
  private readonly checkpointMetrics: CheckpointProposalJobMetricsRecorder,
134
154
  protected readonly eventEmitter: TypedEventEmitter<SequencerEvents>,
135
- private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void,
155
+ private readonly setStateFn: (state: SequencerState, slot?: SlotNumber, timeReferenceSlot?: SlotNumber) => void,
136
156
  public readonly tracer: Tracer,
137
157
  bindings?: LoggerBindings,
138
158
  private readonly proposedCheckpointData?: ProposedCheckpointData,
@@ -141,6 +161,10 @@ export class CheckpointProposalJob implements Traceable {
141
161
  ...bindings,
142
162
  instanceId: `slot-${this.slotNow}`,
143
163
  });
164
+ this.checkpointEventLog = createLogger('sequencer:checkpoint-events', {
165
+ ...bindings,
166
+ instanceId: `slot-${this.slotNow}`,
167
+ });
144
168
  }
145
169
 
146
170
  /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */
@@ -149,6 +173,37 @@ export class CheckpointProposalJob implements Traceable {
149
173
  await this.pendingL1Submission;
150
174
  }
151
175
 
176
+ /** Interrupts job-owned waits, including the publisher's send-at-slot sleep, so shutdown can finish. */
177
+ public interrupt(): void {
178
+ this.interrupted = true;
179
+ this.interruptibleSleep.interrupt(true);
180
+ this.publisher.interrupt();
181
+ }
182
+
183
+ private async awaitInterruptibleSleep(ms: number): Promise<void> {
184
+ if (this.interrupted) {
185
+ throw new SequencerInterruptedError();
186
+ }
187
+ if (ms <= 0) {
188
+ return;
189
+ }
190
+ try {
191
+ await this.interruptibleSleep.sleep(ms);
192
+ } catch (err) {
193
+ if (err instanceof InterruptError) {
194
+ throw new SequencerInterruptedError();
195
+ }
196
+ throw err;
197
+ }
198
+ }
199
+
200
+ private logCheckpointEvent(eventName: string, message: string, fields: Record<string, unknown>): void {
201
+ this.checkpointEventLog.debug(message, {
202
+ eventName: `sequencer-checkpoint-${eventName}`,
203
+ ...fields,
204
+ });
205
+ }
206
+
152
207
  /**
153
208
  * Executes the checkpoint proposal job.
154
209
  * Builds blocks, assembles checkpoint, and broadcasts the proposal (blocking).
@@ -179,9 +234,14 @@ export class CheckpointProposalJob implements Traceable {
179
234
 
180
235
  if (!broadcast) {
181
236
  await Promise.all(votesPromises);
182
- // Still submit votes even without a checkpoint
237
+ // Still submit votes even without a checkpoint.
238
+ // Under proposer pipelining, vote-offenses signatures are EIP-712-bound to `targetSlot`
239
+ // (the pipelined slot in which the multicall is expected to mine). Submitting at the
240
+ // wall-clock time would let the multicall mine in a different L2 slot, causing
241
+ // signature verification to fail silently inside Multicall3. Delay submission to the
242
+ // start of `targetSlot` so the tx mines in the slot the vote was signed for.
183
243
  if (!this.config.fishermanMode) {
184
- this.pendingL1Submission = this.publisher.sendRequestsAt(this.dateProvider.nowAsDate()).then(() => {});
244
+ this.pendingL1Submission = this.publisher.sendRequestsAt(this.targetSlot).then(() => {});
185
245
  }
186
246
  return undefined;
187
247
  }
@@ -211,7 +271,6 @@ export class CheckpointProposalJob implements Traceable {
211
271
  votesPromises: Promise<unknown>[],
212
272
  ): Promise<void> {
213
273
  const { checkpoint } = broadcast;
214
- const isPipelining = this.epochCache.isProposerPipeliningEnabled();
215
274
 
216
275
  try {
217
276
  // Wait for all votes actions, enqueued at the beginning, to resolve
@@ -220,15 +279,14 @@ export class CheckpointProposalJob implements Traceable {
220
279
  // Try to collect attestations from the committee
221
280
  const signedAttestations = await this.getSignedCommitteeAttestations(broadcast);
222
281
 
223
- // If pipelining, wait for the previous checkpoint to land on L1 before submitting,
224
- // so we can check it matches the proposed checkpoint we used as parent, and has valid attestations.
225
- if (signedAttestations && (!isPipelining || (await this.waitForValidParentCheckpointOnL1()))) {
282
+ // Wait for the previous checkpoint to land on L1 before submitting, so we can check it
283
+ // matches the proposed checkpoint we used as parent, and has valid attestations.
284
+ if (signedAttestations && (await this.waitForValidParentCheckpointOnL1())) {
226
285
  await this.enqueueCheckpointForSubmission({ checkpoint, ...signedAttestations });
227
286
  }
228
287
 
229
288
  // If we failed to collect attestations, at least check if we need to issue an invalidation
230
- // Note that if we are not pipelining, we enqueued the invalidation at the beginning
231
- if (!signedAttestations && isPipelining && (await this.waitForSyncedL2SlotNumber(this.slotNow))) {
289
+ if (!signedAttestations && (await this.waitForSyncedL2SlotNumber(this.slotNow))) {
232
290
  const validationStatus = await this.l2BlockSource.getPendingChainValidationStatus();
233
291
  if (!validationStatus.valid) {
234
292
  this.log.warn(
@@ -240,32 +298,56 @@ export class CheckpointProposalJob implements Traceable {
240
298
  }
241
299
 
242
300
  // Send whatever was enqueued: votes + (propose | invalidation | nothing).
243
- // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise.
244
- const submitAfter = isPipelining
245
- ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
246
- : new Date(this.dateProvider.now());
247
-
248
- const l1Response = await this.publisher.sendRequestsAt(submitAfter);
301
+ const l1Response = await this.publisher.sendRequestsAt(this.targetSlot);
249
302
  const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
250
303
  if (proposedAction) {
304
+ this.logCheckpointEvent('published', `Checkpoint published for slot ${this.targetSlot}`, {
305
+ slot: this.targetSlot,
306
+ checkpointNumber: this.checkpointNumber,
307
+ successfulActions: l1Response?.successfulActions,
308
+ sentActions: l1Response?.sentActions,
309
+ });
251
310
  this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.targetSlot });
252
311
  const coinbase = checkpoint.header.coinbase;
253
312
  await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
254
313
  } else {
314
+ this.logCheckpointEvent('publish-failed', `Checkpoint publish failed for slot ${this.targetSlot}`, {
315
+ slot: this.targetSlot,
316
+ checkpointNumber: this.checkpointNumber,
317
+ successfulActions: l1Response?.successfulActions,
318
+ failedActions: l1Response?.failedActions,
319
+ sentActions: l1Response?.sentActions,
320
+ expiredActions: l1Response?.expiredActions,
321
+ reason: 'propose_action_not_successful',
322
+ });
323
+ this.log.warn(`Checkpoint publish failed for slot ${this.targetSlot}`, {
324
+ slot: this.targetSlot,
325
+ checkpointNumber: this.checkpointNumber,
326
+ successfulActions: l1Response?.successfulActions,
327
+ failedActions: l1Response?.failedActions,
328
+ sentActions: l1Response?.sentActions,
329
+ expiredActions: l1Response?.expiredActions,
330
+ reason: 'propose_action_not_successful',
331
+ });
255
332
  this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.targetSlot });
256
- if (isPipelining) {
257
- this.metrics.recordPipelineDiscard();
258
- }
333
+ this.metrics.recordPipelineDiscard();
259
334
  }
260
335
  } catch (err) {
261
336
  if (err instanceof SequencerInterruptedError) {
262
337
  return;
263
338
  }
264
- this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err);
339
+ this.logCheckpointEvent('publish-failed', `Checkpoint publish failed for slot ${this.targetSlot}`, {
340
+ slot: this.targetSlot,
341
+ checkpointNumber: this.checkpointNumber,
342
+ reason: err instanceof Error ? err.message : String(err),
343
+ });
344
+ this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err, {
345
+ slot: this.targetSlot,
346
+ checkpointNumber: this.checkpointNumber,
347
+ reason: err instanceof Error ? err.message : String(err),
348
+ });
265
349
  this.eventEmitter.emit('checkpoint-publish-failed', { slot: this.targetSlot });
266
- if (isPipelining) {
267
- this.metrics.recordPipelineDiscard();
268
- }
350
+ this.metrics.recordPipelineDiscard();
269
351
  }
270
352
  }
271
353
 
@@ -273,7 +355,8 @@ export class CheckpointProposalJob implements Traceable {
273
355
  private async enqueueCheckpointForSubmission(result: CheckpointProposalResult): Promise<void> {
274
356
  const { checkpoint, attestations, attestationsSignature } = result;
275
357
 
276
- this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot);
358
+ // Build-frame deadlines are measured against `slotNow`; observers still see `targetSlot`.
359
+ this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot, this.slotNow);
277
360
  const aztecSlotDuration = this.l1Constants.slotDuration;
278
361
  const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
279
362
  const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000);
@@ -292,17 +375,8 @@ export class CheckpointProposalJob implements Traceable {
292
375
  }
293
376
  }
294
377
 
295
- const isPipelining = this.epochCache.isProposerPipeliningEnabled();
296
- const submissionSimulationOverridesPlan = buildSubmissionSimulationOverridesPlan({
297
- pipelinedParentPlan: this.pipelinedParentSimulationOverridesPlan,
298
- invalidateToPendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
299
- lastArchiveRoot: checkpoint.header.lastArchiveRoot,
300
- pipeliningEnabled: isPipelining,
301
- });
302
-
303
378
  await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
304
379
  txTimeoutAt,
305
- ...(submissionSimulationOverridesPlan ? { simulationOverridesPlan: submissionSimulationOverridesPlan } : {}),
306
380
  });
307
381
  }
308
382
 
@@ -318,16 +392,21 @@ export class CheckpointProposalJob implements Traceable {
318
392
  const timeoutSeconds = Math.max(0.1, (targetSlotEndMs + syncDelayTolerance - this.dateProvider.now()) / 1000);
319
393
 
320
394
  try {
321
- return await retryUntil(
322
- async () => {
323
- const syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
324
- return syncedSlot !== undefined && syncedSlot >= waitForSlot;
325
- },
326
- `archiver sync past slot ${waitForSlot}`,
327
- timeoutSeconds,
328
- 0.2,
329
- );
330
- } catch {
395
+ const timer = new Timer();
396
+ while (true) {
397
+ const syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
398
+ if (syncedSlot !== undefined && syncedSlot >= waitForSlot) {
399
+ return true;
400
+ }
401
+ if (timeoutSeconds && timer.s() > timeoutSeconds) {
402
+ throw new TimeoutError(`Timeout awaiting archiver sync past slot ${waitForSlot}`);
403
+ }
404
+ await this.awaitInterruptibleSleep(ARCHIVER_SYNC_POLLING_MS);
405
+ }
406
+ } catch (err) {
407
+ if (err instanceof SequencerInterruptedError) {
408
+ throw err;
409
+ }
331
410
  this.log.warn(
332
411
  `Archiver did not sync L1 past slot ${waitForSlot} before slot ${this.targetSlot} expired, discarding pipelined work`,
333
412
  { checkpointNumber: this.checkpointNumber },
@@ -345,6 +424,13 @@ export class CheckpointProposalJob implements Traceable {
345
424
  * If the parent has invalid attestations, enqueues an invalidation. Returns whether to proceed with the proposal.
346
425
  */
347
426
  protected async waitForValidParentCheckpointOnL1(): Promise<boolean> {
427
+ if (this.config.skipWaitForValidParentCheckpointOnL1) {
428
+ this.log.warn(`Skipping waitForValidParentCheckpointOnL1 due to test configuration`, {
429
+ checkpointNumber: this.checkpointNumber,
430
+ });
431
+ return true;
432
+ }
433
+
348
434
  const parentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 1);
349
435
 
350
436
  // Wait until archiver has synced L1 past the parent's slot (slotNow)
@@ -451,7 +537,7 @@ export class CheckpointProposalJob implements Traceable {
451
537
  private async proposeCheckpoint(): Promise<CheckpointProposalBroadcast | undefined> {
452
538
  try {
453
539
  const now = this.dateProvider.now();
454
- if (this.epochCache.isProposerPipeliningEnabled() && this.proposedCheckpointData) {
540
+ if (this.proposedCheckpointData) {
455
541
  // Measure against the wall-clock slot whose build window we are currently using.
456
542
  // In pipelining mode `targetSlot` is intentionally one slot ahead, which makes the
457
543
  // target-slot boundary a full slot away from the actual build start time.
@@ -465,12 +551,15 @@ export class CheckpointProposalJob implements Traceable {
465
551
  const feeRecipient = this.validatorClient.getFeeRecipientForAttestor(this.attestorAddress);
466
552
 
467
553
  // Start the checkpoint
468
- this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
469
- this.log.info(`Starting checkpoint proposal`, {
554
+ this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot, this.slotNow);
555
+ this.logCheckpointEvent('slot-started', `Starting checkpoint proposal for slot ${this.targetSlot}`, {
470
556
  buildSlot: this.slotNow,
471
557
  submissionSlot: this.targetSlot,
472
- pipelining: this.epochCache.isProposerPipeliningEnabled(),
558
+ slot: this.targetSlot,
559
+ checkpointNumber: this.checkpointNumber,
473
560
  proposer: this.proposer?.toString(),
561
+ attestorAddress: this.attestorAddress.toString(),
562
+ publisherAddress: this.publisher.getSenderAddress().toString(),
474
563
  coinbase: coinbase.toString(),
475
564
  });
476
565
  this.metrics.incOpenSlot(this.targetSlot, this.proposer?.toString() ?? 'unknown');
@@ -480,37 +569,51 @@ export class CheckpointProposalJob implements Traceable {
480
569
  this.publisher.enqueueInvalidateCheckpoint(this.invalidateCheckpoint);
481
570
  }
482
571
 
483
- // Create checkpoint builder for the slot.
484
- // When pipelining, force the proposed checkpoint number and fee header to our parent so the
485
- // fee computation sees the same chain tip that L1 will see once the previous pipelined checkpoint lands.
486
- const isPipelining = this.epochCache.isProposerPipeliningEnabled();
487
- this.pipelinedParentSimulationOverridesPlan = isPipelining
488
- ? await buildPipelinedParentSimulationOverridesPlan({
489
- checkpointNumber: this.checkpointNumber,
490
- proposedCheckpointData: this.proposedCheckpointData,
491
- rollup: this.publisher.rollupContract,
492
- log: this.log,
493
- })
494
- : undefined;
572
+ // Build the simulation plan for this slot. When pipelining, this overrides L1's view of
573
+ // pending/archive/fee-header to "as if the proposed parent had landed", so both the
574
+ // mana-min-fee simulation (in the globals builder) and the pre-broadcast
575
+ // validateBlockHeader see the chain tip the eventual L1 send will see.
576
+ this.checkpointSimulationOverridesPlan = await buildCheckpointSimulationOverridesPlan({
577
+ checkpointNumber: this.checkpointNumber,
578
+ proposedCheckpointData: this.proposedCheckpointData,
579
+ invalidateToPendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
580
+ checkpointedCheckpointNumber: this.checkpointedCheckpointNumber,
581
+ rollup: this.publisher.rollupContract,
582
+ signatureContext: this.signatureContext,
583
+ log: this.log,
584
+ });
495
585
 
496
586
  const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables(
497
587
  coinbase,
498
588
  feeRecipient,
499
589
  this.targetSlot,
500
- this.pipelinedParentSimulationOverridesPlan,
590
+ this.checkpointSimulationOverridesPlan,
501
591
  );
502
592
 
503
593
  // Collect L1 to L2 messages for the checkpoint and compute their hash
504
594
  const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(this.checkpointNumber);
505
595
  const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
506
596
 
507
- // Collect the out hashes of all the checkpoints before this one in the same epoch
508
- const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.targetEpoch))
509
- .filter(c => c.checkpointNumber < this.checkpointNumber)
510
- .map(c => c.checkpointOutHash);
597
+ // Collect the out hashes of all the checkpoints before this one in the same epoch.
598
+ // Under pipelining the parent checkpoint may not be on L1 yet at build time, so the helper
599
+ // splices in the parent's checkpointOutHash from the locally-known proposed checkpoint so
600
+ // the resulting `epochOutHash` matches what validators (and L1) compute once the parent
601
+ // lands on L1.
602
+ const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({
603
+ blockSource: this.l2BlockSource,
604
+ epoch: this.targetEpoch,
605
+ checkpointNumber: this.checkpointNumber,
606
+ l1Constants: this.epochCache.getL1Constants(),
607
+ pipeliningEnabled: true,
608
+ proposedCheckpointData: this.proposedCheckpointData,
609
+ log: this.log,
610
+ });
511
611
 
512
- // Get the fee asset price modifier from the oracle
513
- const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier();
612
+ // Anchor the modifier to the predicted parent fee header: L1 will apply it against
613
+ // that, not against the latest published checkpoint (which lags by one under pipelining).
614
+ const predictedParentEthPerFeeAssetE12 =
615
+ this.checkpointSimulationOverridesPlan?.pendingCheckpointState?.feeHeader?.ethPerFeeAsset;
616
+ const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12);
514
617
 
515
618
  // Create a long-lived forked world state for the checkpoint builder
516
619
  await using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 });
@@ -534,7 +637,8 @@ export class CheckpointProposalJob implements Traceable {
534
637
 
535
638
  const checkpointProposalOptions: CheckpointProposalOptions = {
536
639
  publishFullTxs: !!this.config.publishTxsWithProposals,
537
- broadcastInvalidCheckpointProposal: this.config.broadcastInvalidBlockProposal,
640
+ broadcastInvalidCheckpointProposal:
641
+ this.config.broadcastInvalidCheckpointProposalOnly || this.config.broadcastInvalidBlockProposal,
538
642
  };
539
643
 
540
644
  let blocksInCheckpoint: L2Block[] = [];
@@ -562,23 +666,45 @@ export class CheckpointProposalJob implements Traceable {
562
666
  }
563
667
 
564
668
  if (blocksInCheckpoint.length === 0) {
565
- this.log.warn(`No blocks were built for slot ${this.targetSlot}`, { slot: this.targetSlot });
669
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
670
+ slot: this.targetSlot,
671
+ checkpointNumber: this.checkpointNumber,
672
+ reason: 'no_blocks_built',
673
+ });
674
+ this.log.warn(`No blocks were built for slot ${this.targetSlot}`, {
675
+ slot: this.targetSlot,
676
+ checkpointNumber: this.checkpointNumber,
677
+ reason: 'no_blocks_built',
678
+ });
566
679
  this.eventEmitter.emit('checkpoint-empty', { slot: this.targetSlot });
567
680
  return undefined;
568
681
  }
569
682
 
570
683
  const minBlocksForCheckpoint = this.config.minBlocksForCheckpoint;
571
684
  if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
685
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
686
+ slot: this.targetSlot,
687
+ checkpointNumber: this.checkpointNumber,
688
+ blocksBuilt: blocksInCheckpoint.length,
689
+ minBlocksForCheckpoint,
690
+ reason: 'min_blocks_not_met',
691
+ });
572
692
  this.log.warn(
573
693
  `Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`,
574
- { slot: this.targetSlot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
694
+ {
695
+ slot: this.targetSlot,
696
+ checkpointNumber: this.checkpointNumber,
697
+ blocksBuilt: blocksInCheckpoint.length,
698
+ minBlocksForCheckpoint,
699
+ reason: 'min_blocks_not_met',
700
+ },
575
701
  );
576
702
  return undefined;
577
703
  }
578
704
 
579
705
  // Assemble and broadcast the checkpoint proposal, including the last block that was not
580
706
  // broadcasted yet, and wait to collect the committee attestations.
581
- this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.targetSlot);
707
+ this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.targetSlot, this.slotNow);
582
708
  const checkpoint = await checkpointBuilder.completeCheckpoint();
583
709
 
584
710
  // Final validation: per-block limits are only checked if the operator set them explicitly.
@@ -592,7 +718,18 @@ export class CheckpointProposalJob implements Traceable {
592
718
  maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint,
593
719
  });
594
720
  } catch (err) {
721
+ this.logCheckpointEvent('build-failed', `Checkpoint build failed for slot ${this.targetSlot}`, {
722
+ slot: this.targetSlot,
723
+ checkpointNumber: this.checkpointNumber,
724
+ blocksBuilt: blocksInCheckpoint.length,
725
+ reason: 'invalid_checkpoint',
726
+ checkpoint: checkpoint.header.toInspect(),
727
+ });
595
728
  this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, {
729
+ slot: this.targetSlot,
730
+ checkpointNumber: this.checkpointNumber,
731
+ blocksBuilt: blocksInCheckpoint.length,
732
+ reason: 'invalid_checkpoint',
596
733
  checkpoint: checkpoint.header.toInspect(),
597
734
  });
598
735
  return undefined;
@@ -605,6 +742,17 @@ export class CheckpointProposalJob implements Traceable {
605
742
  checkpoint.getStats().txCount,
606
743
  Number(checkpoint.header.totalManaUsed.toBigInt()),
607
744
  );
745
+ this.logCheckpointEvent('built', `Checkpoint built for slot ${this.targetSlot}`, {
746
+ slot: this.targetSlot,
747
+ buildSlot: this.slotNow,
748
+ checkpointNumber: this.checkpointNumber,
749
+ proposer: this.proposer?.toString(),
750
+ attestorAddress: this.attestorAddress.toString(),
751
+ publisherAddress: this.publisher.getSenderAddress().toString(),
752
+ blocksBuilt: blocksInCheckpoint.length,
753
+ txCount: checkpoint.getStats().txCount,
754
+ totalMana: Number(checkpoint.header.totalManaUsed.toBigInt()),
755
+ });
608
756
 
609
757
  // In fisherman mode, return the checkpoint without broadcasting or collecting attestations
610
758
  if (this.config.fishermanMode) {
@@ -622,6 +770,25 @@ export class CheckpointProposalJob implements Traceable {
622
770
  return { checkpoint, proposal: undefined!, blockProposedAt: this.dateProvider.now() };
623
771
  }
624
772
 
773
+ // Validate the header against L1 state before broadcasting.
774
+ // If this fails the slot is aborted before any gossip work; state drift between here
775
+ // and the eventual L1 send is caught by the bundle simulate at send time.
776
+ try {
777
+ await this.publisher.validateBlockHeader(checkpoint.header, this.checkpointSimulationOverridesPlan);
778
+ } catch (err) {
779
+ this.log.error(`Pre-broadcast header validation failed for slot ${this.targetSlot}; aborting`, err, {
780
+ slot: this.targetSlot,
781
+ checkpointNumber: this.checkpointNumber,
782
+ });
783
+ this.metrics.recordCheckpointProposalFailed('header_validation_failed');
784
+ this.eventEmitter.emit('header-validation-failed', {
785
+ slot: this.targetSlot,
786
+ checkpointNumber: this.checkpointNumber,
787
+ reason: err instanceof Error ? err.message : String(err),
788
+ });
789
+ return undefined;
790
+ }
791
+
625
792
  // Create the checkpoint proposal and broadcast it
626
793
  const proposal = await this.validatorClient.createCheckpointProposal(
627
794
  checkpoint.header,
@@ -633,9 +800,26 @@ export class CheckpointProposalJob implements Traceable {
633
800
  checkpointProposalOptions,
634
801
  );
635
802
 
803
+ // Advance our own optimistic proposed-checkpoint tip locally before gossiping. Gossipsub
804
+ // doesn't echo our own messages back, so this is how the proposer makes its own proposed
805
+ // checkpoint visible for pipelining the next slot. Built from local checkpoint data — never
806
+ // from the broadcast proposal archive, which may be deliberately corrupted under test flags.
807
+ // Fail closed: if this throws, the outer catch aborts the slot before gossiping.
808
+ await this.syncProposedCheckpointToArchiver(checkpoint, blocksInCheckpoint.length, feeAssetPriceModifier);
809
+
636
810
  const blockProposedAt = this.dateProvider.now();
637
- await this.p2pClient.broadcastCheckpointProposal(proposal);
638
- this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
811
+ if (this.config.skipBroadcastCheckpointProposal) {
812
+ // Test-only: suppress the CheckpointProposal so peers never see a proposed checkpoint for
813
+ // this slot, but still broadcast the held last block standalone so peers' archivers ingest
814
+ // it as a proposed-but-uncheckpointed tip — the exact orphan-block state that
815
+ // pruneOrphanProposedBlocks / checkSync must handle.
816
+ if (blockPendingBroadcast && !this.config.skipBroadcastProposals) {
817
+ await this.p2pClient.broadcastProposal(blockPendingBroadcast);
818
+ }
819
+ } else if (!this.config.skipBroadcastProposals) {
820
+ await this.p2pClient.broadcastCheckpointProposal(proposal);
821
+ this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
822
+ }
639
823
 
640
824
  // Return immediately after broadcast — attestation collection happens in the background
641
825
  return { checkpoint, proposal, blockProposedAt };
@@ -675,6 +859,15 @@ export class CheckpointProposalJob implements Traceable {
675
859
  const indexWithinCheckpoint = IndexWithinCheckpoint(blocksBuilt);
676
860
  const blockNumber = BlockNumber(initialBlockNumber + blocksBuilt);
677
861
 
862
+ if (blocksBuilt >= this.config.maxBlocksPerCheckpoint) {
863
+ this.log.debug(`Reached max blocks per checkpoint`, {
864
+ slot: this.targetSlot,
865
+ blocksBuilt,
866
+ maxBlocksPerCheckpoint: this.config.maxBlocksPerCheckpoint,
867
+ });
868
+ break;
869
+ }
870
+
678
871
  const secondsIntoSlot = this.getSecondsIntoSlot();
679
872
  const timingInfo = this.timetable.canStartNextBlock(secondsIntoSlot);
680
873
 
@@ -735,7 +928,12 @@ export class CheckpointProposalJob implements Traceable {
735
928
  usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
736
929
 
737
930
  // Sign the block proposal. This will throw if HA signing fails.
738
- const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
931
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, {
932
+ ...blockProposalOptions,
933
+ broadcastInvalidBlockProposal:
934
+ blockProposalOptions.broadcastInvalidBlockProposal ||
935
+ block.indexWithinCheckpoint === this.config.invalidBlockProposalIndexWithinCheckpoint,
936
+ });
739
937
 
740
938
  // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
741
939
  // so we avoid polluting our archive with a block that would fail.
@@ -756,7 +954,9 @@ export class CheckpointProposalJob implements Traceable {
756
954
  }
757
955
 
758
956
  // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
759
- proposal && (await this.p2pClient.broadcastProposal(proposal));
957
+ if (proposal && !this.config.skipBroadcastProposals) {
958
+ await this.p2pClient.broadcastProposal(proposal);
959
+ }
760
960
 
761
961
  // Wait until the next block's start time
762
962
  await this.waitUntilNextSubslot(timingInfo.deadline);
@@ -796,7 +996,7 @@ export class CheckpointProposalJob implements Traceable {
796
996
  /** Sleeps until it is time to produce the next block in the slot */
797
997
  @trackSpan('CheckpointProposalJob.waitUntilNextSubslot')
798
998
  private async waitUntilNextSubslot(nextSubslotStart: number) {
799
- this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.targetSlot);
999
+ this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.targetSlot, this.slotNow);
800
1000
  this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, {
801
1001
  slot: this.targetSlot,
802
1002
  });
@@ -830,9 +1030,26 @@ export class CheckpointProposalJob implements Traceable {
830
1030
  // Wait until we have enough txs to build the block
831
1031
  const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
832
1032
  if (!canStartBuilding) {
1033
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1034
+ reason: 'insufficient_txs',
1035
+ blockNumber,
1036
+ slot: this.targetSlot,
1037
+ checkpointNumber: this.checkpointNumber,
1038
+ indexWithinCheckpoint,
1039
+ availableTxs,
1040
+ minTxs,
1041
+ });
833
1042
  this.log.warn(
834
1043
  `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
835
- { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
1044
+ {
1045
+ reason: 'insufficient_txs',
1046
+ blockNumber,
1047
+ slot: this.targetSlot,
1048
+ checkpointNumber: this.checkpointNumber,
1049
+ indexWithinCheckpoint,
1050
+ availableTxs,
1051
+ minTxs,
1052
+ },
836
1053
  );
837
1054
  this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
838
1055
  this.metrics.recordBlockProposalFailed('insufficient_txs');
@@ -850,7 +1067,7 @@ export class CheckpointProposalJob implements Traceable {
850
1067
  `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot} with ${availableTxs} available txs`,
851
1068
  { slot: this.targetSlot, blockNumber, indexWithinCheckpoint },
852
1069
  );
853
- this.setStateFn(SequencerState.CREATING_BLOCK, this.targetSlot);
1070
+ this.setStateFn(SequencerState.CREATING_BLOCK, this.targetSlot, this.slotNow);
854
1071
 
855
1072
  // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
856
1073
  // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built.
@@ -884,10 +1101,21 @@ export class CheckpointProposalJob implements Traceable {
884
1101
  await this.dropFailedTxsFromP2P(buildResult.failedTxs);
885
1102
 
886
1103
  if (buildResult.status === 'insufficient-valid-txs') {
1104
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1105
+ reason: 'insufficient_valid_txs',
1106
+ slot: this.targetSlot,
1107
+ checkpointNumber: this.checkpointNumber,
1108
+ blockNumber,
1109
+ numTxs: buildResult.processedCount,
1110
+ indexWithinCheckpoint,
1111
+ minValidTxs,
1112
+ });
887
1113
  this.log.warn(
888
1114
  `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.targetSlot} has too few valid txs to be proposed`,
889
1115
  {
1116
+ reason: 'insufficient_valid_txs',
890
1117
  slot: this.targetSlot,
1118
+ checkpointNumber: this.checkpointNumber,
891
1119
  blockNumber,
892
1120
  numTxs: buildResult.processedCount,
893
1121
  indexWithinCheckpoint,
@@ -925,6 +1153,9 @@ export class CheckpointProposalJob implements Traceable {
925
1153
  // `buildSlot` is the wall-clock slot during which the block was actually built.
926
1154
  this.eventEmitter.emit('block-proposed', {
927
1155
  blockNumber: block.number,
1156
+ blockHash,
1157
+ checkpointNumber: this.checkpointNumber,
1158
+ indexWithinCheckpoint: block.indexWithinCheckpoint,
928
1159
  slot: this.targetSlot,
929
1160
  buildSlot: this.slotNow,
930
1161
  });
@@ -936,7 +1167,18 @@ export class CheckpointProposalJob implements Traceable {
936
1167
  reason: err.message,
937
1168
  slot: this.targetSlot,
938
1169
  });
939
- this.log.error(`Error building block`, err, { blockNumber, slot: this.targetSlot });
1170
+ this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1171
+ reason: err instanceof Error ? err.message : String(err),
1172
+ slot: this.targetSlot,
1173
+ checkpointNumber: this.checkpointNumber,
1174
+ blockNumber,
1175
+ });
1176
+ this.log.error(`Error building block`, err, {
1177
+ reason: err instanceof Error ? err.message : String(err),
1178
+ slot: this.targetSlot,
1179
+ checkpointNumber: this.checkpointNumber,
1180
+ blockNumber,
1181
+ });
940
1182
  this.metrics.recordBlockProposalFailed(err.name || 'unknown_error');
941
1183
  this.metrics.recordFailedBlock();
942
1184
  return { error: err };
@@ -996,7 +1238,7 @@ export class CheckpointProposalJob implements Traceable {
996
1238
  }
997
1239
 
998
1240
  // Wait a bit before checking again
999
- this.setStateFn(SequencerState.WAITING_FOR_TXS, this.targetSlot);
1241
+ this.setStateFn(SequencerState.WAITING_FOR_TXS, this.targetSlot, this.slotNow);
1000
1242
  this.log.verbose(
1001
1243
  `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
1002
1244
  { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
@@ -1012,7 +1254,7 @@ export class CheckpointProposalJob implements Traceable {
1012
1254
  broadcast: CheckpointProposalBroadcast,
1013
1255
  ): Promise<{ attestations: CommitteeAttestationsAndSigners; attestationsSignature: Signature } | undefined> {
1014
1256
  const { proposal, blockProposedAt } = broadcast;
1015
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
1257
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot, this.slotNow);
1016
1258
  const attestations = await this.waitForAttestations(proposal);
1017
1259
  if (!attestations) {
1018
1260
  return undefined;
@@ -1048,7 +1290,7 @@ export class CheckpointProposalJob implements Traceable {
1048
1290
  ): Promise<CommitteeAttestationsAndSigners | undefined> {
1049
1291
  if (this.config.fishermanMode) {
1050
1292
  this.log.debug('Skipping attestation collection in fisherman mode');
1051
- return CommitteeAttestationsAndSigners.empty();
1293
+ return CommitteeAttestationsAndSigners.empty(this.getSignatureContext());
1052
1294
  }
1053
1295
 
1054
1296
  const slotNumber = proposal.slotNumber;
@@ -1058,7 +1300,7 @@ export class CheckpointProposalJob implements Traceable {
1058
1300
  throw new Error('No committee when collecting attestations');
1059
1301
  } else if (committee.length === 0) {
1060
1302
  this.log.verbose(`Attesting committee is empty`);
1061
- return CommitteeAttestationsAndSigners.empty();
1303
+ return CommitteeAttestationsAndSigners.empty(this.getSignatureContext());
1062
1304
  } else {
1063
1305
  this.log.debug(`Attesting committee length is ${committee.length}`, { committee });
1064
1306
  }
@@ -1068,7 +1310,13 @@ export class CheckpointProposalJob implements Traceable {
1068
1310
  if (this.config.skipCollectingAttestations) {
1069
1311
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
1070
1312
  const attestations = await this.validatorClient?.collectOwnAttestations(proposal, this.checkpointNumber);
1071
- return new CommitteeAttestationsAndSigners(orderAttestations(attestations ?? [], committee));
1313
+ this.logCheckpointAttestations('collected', committee, attestations ?? [], numberOfRequiredAttestations, {
1314
+ reason: 'collect_own_only',
1315
+ });
1316
+ return new CommitteeAttestationsAndSigners(
1317
+ orderAttestations(attestations ?? [], committee),
1318
+ this.getSignatureContext(),
1319
+ );
1072
1320
  }
1073
1321
 
1074
1322
  const attestationTimeAllowed = this.config.enforceTimeTable
@@ -1104,6 +1352,9 @@ export class CheckpointProposalJob implements Traceable {
1104
1352
 
1105
1353
  // Rollup contract requires that the signatures are provided in the order of the committee
1106
1354
  const sorted = orderAttestations(trimmed, committee);
1355
+ this.logCheckpointAttestations('collected', committee, attestations, numberOfRequiredAttestations, {
1356
+ submittedCount: trimmed.length,
1357
+ });
1107
1358
 
1108
1359
  // Manipulate the attestations if we've been configured to do so
1109
1360
  if (
@@ -1115,15 +1366,23 @@ export class CheckpointProposalJob implements Traceable {
1115
1366
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
1116
1367
  }
1117
1368
 
1118
- return new CommitteeAttestationsAndSigners(sorted);
1369
+ return new CommitteeAttestationsAndSigners(sorted, this.getSignatureContext());
1119
1370
  } catch (err) {
1120
1371
  if (err && err instanceof AttestationTimeoutError) {
1121
1372
  collectedAttestationsCount = err.collectedCount;
1373
+ this.logCheckpointAttestations('failed', committee, undefined, numberOfRequiredAttestations, {
1374
+ collectedCount: collectedAttestationsCount,
1375
+ reason: 'timeout',
1376
+ });
1122
1377
  this.log.error(
1123
1378
  `Timeout while waiting for attestations for checkpoint proposal at slot ${proposal.slotNumber} (collected ${collectedAttestationsCount}/${numberOfRequiredAttestations})`,
1124
1379
  err,
1125
1380
  );
1126
1381
  } else {
1382
+ this.logCheckpointAttestations('failed', committee, undefined, numberOfRequiredAttestations, {
1383
+ collectedCount: collectedAttestationsCount,
1384
+ reason: err instanceof Error ? err.message : String(err),
1385
+ });
1127
1386
  this.log.error(`Error collecting attestations for checkpoint proposal at slot ${proposal.slotNumber}`, err);
1128
1387
  }
1129
1388
  return undefined;
@@ -1132,6 +1391,31 @@ export class CheckpointProposalJob implements Traceable {
1132
1391
  }
1133
1392
  }
1134
1393
 
1394
+ private logCheckpointAttestations(
1395
+ status: 'collected' | 'failed',
1396
+ committee: EthAddress[],
1397
+ attestations: CheckpointAttestation[] | undefined,
1398
+ requiredAttestations: number,
1399
+ opts: { collectedCount?: number; submittedCount?: number; reason?: string } = {},
1400
+ ) {
1401
+ const signedValidators =
1402
+ attestations
1403
+ ?.map(attestation => attestation.getSender()?.toString())
1404
+ .filter((address): address is `0x${string}` => address !== undefined) ?? [];
1405
+ const collectedCount = opts.collectedCount ?? new Set(signedValidators).size;
1406
+ const missingValidatorCount = status === 'failed' ? Math.max(0, requiredAttestations - collectedCount) : undefined;
1407
+ this.logCheckpointEvent(`attestations-${status}`, `Checkpoint attestations ${status} for slot ${this.targetSlot}`, {
1408
+ slot: this.targetSlot,
1409
+ checkpointNumber: this.checkpointNumber,
1410
+ committeeSize: committee.length,
1411
+ requiredAttestations,
1412
+ collectedAttestations: collectedCount,
1413
+ ...(opts.submittedCount !== undefined && { submittedAttestations: opts.submittedCount }),
1414
+ ...(missingValidatorCount !== undefined && { missingValidatorCount }),
1415
+ ...(opts.reason !== undefined && { reason: opts.reason }),
1416
+ });
1417
+ }
1418
+
1135
1419
  /** Breaks the attestations before publishing based on attack configs */
1136
1420
  private manipulateAttestations(
1137
1421
  slotNumber: SlotNumber,
@@ -1175,7 +1459,7 @@ export class CheckpointProposalJob implements Traceable {
1175
1459
  unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature();
1176
1460
  }
1177
1461
  }
1178
- return new CommitteeAttestationsAndSigners(attestations);
1462
+ return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1179
1463
  }
1180
1464
 
1181
1465
  if (this.config.shuffleAttestationOrdering) {
@@ -1197,11 +1481,11 @@ export class CheckpointProposalJob implements Traceable {
1197
1481
  [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
1198
1482
  }
1199
1483
 
1200
- const signers = new CommitteeAttestationsAndSigners(attestations).getSigners();
1201
- return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers);
1484
+ const signers = new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext()).getSigners();
1485
+ return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers, this.getSignatureContext());
1202
1486
  }
1203
1487
 
1204
- return new CommitteeAttestationsAndSigners(attestations);
1488
+ return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1205
1489
  }
1206
1490
 
1207
1491
  private async dropFailedTxsFromP2P(failedTxs: FailedTx[]) {
@@ -1238,6 +1522,41 @@ export class CheckpointProposalJob implements Traceable {
1238
1522
  await this.blockSink.addBlock(block);
1239
1523
  }
1240
1524
 
1525
+ /**
1526
+ * Adds the proposed checkpoint to the archiver so the proposer's optimistic proposed-checkpoint
1527
+ * tip advances locally. Gossip doesn't echo our own messages back, so without this the proposer
1528
+ * would never see its own proposed checkpoint and couldn't pipeline the next slot.
1529
+ *
1530
+ * Skipped whenever proposed blocks aren't pushed (`skipPushProposedBlocksToArchiver`, fisherman
1531
+ * mode): the archiver derives the checkpoint archive from its stored blocks, so without them the
1532
+ * push would fail. All blocks were already added (and awaited) during block building, so this
1533
+ * needs no retry — they are guaranteed present by the time we get here.
1534
+ */
1535
+ private async syncProposedCheckpointToArchiver(
1536
+ checkpoint: Checkpoint,
1537
+ blockCount: number,
1538
+ feeAssetPriceModifier: bigint,
1539
+ ): Promise<void> {
1540
+ if (this.config.skipPushProposedBlocksToArchiver || this.config.fishermanMode) {
1541
+ return;
1542
+ }
1543
+ const startBlock = BlockNumber(this.syncedToBlockNumber + 1);
1544
+ this.log.debug(`Syncing proposed checkpoint ${this.checkpointNumber} to archiver`, {
1545
+ checkpointNumber: this.checkpointNumber,
1546
+ slot: this.targetSlot,
1547
+ startBlock,
1548
+ blockCount,
1549
+ });
1550
+ await this.blockSink.addProposedCheckpoint({
1551
+ header: checkpoint.header,
1552
+ checkpointNumber: this.checkpointNumber,
1553
+ startBlock,
1554
+ blockCount,
1555
+ totalManaUsed: checkpoint.header.totalManaUsed.toBigInt(),
1556
+ feeAssetPriceModifier,
1557
+ });
1558
+ }
1559
+
1241
1560
  /** Runs fee analysis and logs checkpoint outcome as fisherman */
1242
1561
  private async handleCheckpointEndAsFisherman(checkpoint: Checkpoint | undefined) {
1243
1562
  // Perform L1 fee analysis before clearing requests
@@ -1290,12 +1609,13 @@ export class CheckpointProposalJob implements Traceable {
1290
1609
  protected async waitUntilTimeInSlot(targetSecondsIntoSlot: number): Promise<void> {
1291
1610
  const slotStartTimestamp = this.getSlotStartBuildTimestamp();
1292
1611
  const targetTimestamp = slotStartTimestamp + targetSecondsIntoSlot;
1293
- await sleepUntil(new Date(targetTimestamp * 1000), this.dateProvider.nowAsDate());
1612
+ const delayMs = targetTimestamp * 1000 - this.dateProvider.nowAsDate().getTime();
1613
+ await this.awaitInterruptibleSleep(delayMs);
1294
1614
  }
1295
1615
 
1296
- /** Waits the polling interval for transactions. Extracted for test overriding. */
1616
+ /** Pause between mempool polls in waitForMinTxs. Extracted for test overriding. */
1297
1617
  protected async waitForTxsPollingInterval(): Promise<void> {
1298
- await sleep(TXS_POLLING_MS);
1618
+ await this.awaitInterruptibleSleep(TXS_POLLING_MS);
1299
1619
  }
1300
1620
 
1301
1621
  private getSlotStartBuildTimestamp(): number {