@aztec/sequencer-client 0.0.1-commit.3f296a7d2 → 0.0.1-commit.3f5453c7b

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 (63) hide show
  1. package/dest/client/sequencer-client.d.ts +3 -1
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +3 -4
  4. package/dest/config.d.ts +1 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +0 -5
  7. package/dest/global_variable_builder/fee_predictor.d.ts +37 -0
  8. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -0
  9. package/dest/global_variable_builder/fee_predictor.js +128 -0
  10. package/dest/global_variable_builder/fee_provider.d.ts +21 -0
  11. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -0
  12. package/dest/global_variable_builder/fee_provider.js +58 -0
  13. package/dest/global_variable_builder/global_builder.d.ts +3 -8
  14. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  15. package/dest/global_variable_builder/global_builder.js +4 -39
  16. package/dest/global_variable_builder/index.d.ts +3 -1
  17. package/dest/global_variable_builder/index.d.ts.map +1 -1
  18. package/dest/global_variable_builder/index.js +2 -0
  19. package/dest/publisher/sequencer-publisher-factory.d.ts +1 -3
  20. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  21. package/dest/publisher/sequencer-publisher-factory.js +0 -1
  22. package/dest/publisher/sequencer-publisher.d.ts +47 -44
  23. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  24. package/dest/publisher/sequencer-publisher.js +112 -112
  25. package/dest/sequencer/chain_state_overrides.d.ts +25 -0
  26. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  27. package/dest/sequencer/chain_state_overrides.js +39 -0
  28. package/dest/sequencer/checkpoint_proposal_job.d.ts +21 -10
  29. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  30. package/dest/sequencer/checkpoint_proposal_job.js +197 -131
  31. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  32. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  33. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  34. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  35. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  36. package/dest/sequencer/checkpoint_voter.js +2 -5
  37. package/dest/sequencer/metrics.d.ts +5 -10
  38. package/dest/sequencer/metrics.d.ts.map +1 -1
  39. package/dest/sequencer/metrics.js +12 -20
  40. package/dest/sequencer/sequencer.d.ts +17 -4
  41. package/dest/sequencer/sequencer.d.ts.map +1 -1
  42. package/dest/sequencer/sequencer.js +73 -20
  43. package/dest/sequencer/timetable.d.ts +14 -1
  44. package/dest/sequencer/timetable.d.ts.map +1 -1
  45. package/dest/sequencer/timetable.js +45 -36
  46. package/package.json +27 -27
  47. package/src/client/sequencer-client.ts +5 -7
  48. package/src/config.ts +0 -5
  49. package/src/global_variable_builder/README.md +44 -0
  50. package/src/global_variable_builder/fee_predictor.ts +172 -0
  51. package/src/global_variable_builder/fee_provider.ts +75 -0
  52. package/src/global_variable_builder/global_builder.ts +8 -43
  53. package/src/global_variable_builder/index.ts +2 -0
  54. package/src/publisher/sequencer-publisher-factory.ts +0 -3
  55. package/src/publisher/sequencer-publisher.ts +174 -158
  56. package/src/sequencer/README.md +83 -13
  57. package/src/sequencer/chain_state_overrides.ts +87 -0
  58. package/src/sequencer/checkpoint_proposal_job.ts +238 -153
  59. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  60. package/src/sequencer/checkpoint_voter.ts +1 -12
  61. package/src/sequencer/metrics.ts +15 -25
  62. package/src/sequencer/sequencer.ts +110 -22
  63. package/src/sequencer/timetable.ts +57 -45
@@ -1,4 +1,5 @@
1
1
  import type { EpochCache } from '@aztec/epoch-cache';
2
+ import type { SimulationOverridesPlan } from '@aztec/ethereum/contracts';
2
3
  import {
3
4
  BlockNumber,
4
5
  CheckpointNumber,
@@ -30,8 +31,8 @@ import {
30
31
  type L2BlockSource,
31
32
  MaliciousCommitteeAttestationsAndSigners,
32
33
  } from '@aztec/stdlib/block';
33
- import { type Checkpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint';
34
- import { getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
34
+ import { type Checkpoint, type ProposedCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint';
35
+ import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
35
36
  import { Gas } from '@aztec/stdlib/gas';
36
37
  import {
37
38
  type BlockBuilderOptions,
@@ -56,6 +57,11 @@ import { DutyAlreadySignedError, SlashingProtectionError } from '@aztec/validato
56
57
 
57
58
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
58
59
  import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
60
+ import {
61
+ buildPipelinedParentSimulationOverridesPlan,
62
+ buildSubmissionSimulationOverridesPlan,
63
+ } from './chain_state_overrides.js';
64
+ import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js';
59
65
  import { CheckpointVoter } from './checkpoint_voter.js';
60
66
  import { SequencerInterruptedError } from './errors.js';
61
67
  import type { SequencerEvents } from './events.js';
@@ -67,6 +73,20 @@ import { SequencerState } from './utils.js';
67
73
  /** How much time to sleep while waiting for min transactions to accumulate for a block */
68
74
  const TXS_POLLING_MS = 500;
69
75
 
76
+ /** Result from proposeCheckpoint when a checkpoint was successfully built and broadcast. */
77
+ type CheckpointProposalBroadcast = {
78
+ checkpoint: Checkpoint;
79
+ proposal: CheckpointProposal;
80
+ blockProposedAt: number;
81
+ };
82
+
83
+ /** Result after attestation collection and signing, ready for L1 submission. */
84
+ type CheckpointProposalResult = {
85
+ checkpoint: Checkpoint;
86
+ attestations: CommitteeAttestationsAndSigners;
87
+ attestationsSignature: Signature;
88
+ };
89
+
70
90
  /**
71
91
  * Handles the execution of a checkpoint proposal after the initial preparation phase.
72
92
  * This includes building blocks, collecting attestations, and publishing the checkpoint to L1,
@@ -76,10 +96,15 @@ const TXS_POLLING_MS = 500;
76
96
  export class CheckpointProposalJob implements Traceable {
77
97
  protected readonly log: Logger;
78
98
 
99
+ /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */
100
+ private pendingL1Submission: Promise<void> | undefined;
101
+
102
+ /** Pipelined parent chain state used while building and later submitting this checkpoint. */
103
+ private pipelinedParentSimulationOverridesPlan?: SimulationOverridesPlan;
104
+
79
105
  constructor(
80
106
  private readonly slotNow: SlotNumber,
81
107
  private readonly targetSlot: SlotNumber,
82
- private readonly epochNow: EpochNumber,
83
108
  private readonly targetEpoch: EpochNumber,
84
109
  private readonly checkpointNumber: CheckpointNumber,
85
110
  private readonly syncedToBlockNumber: BlockNumber,
@@ -103,10 +128,12 @@ export class CheckpointProposalJob implements Traceable {
103
128
  private readonly epochCache: EpochCache,
104
129
  private readonly dateProvider: DateProvider,
105
130
  private readonly metrics: SequencerMetrics,
131
+ private readonly checkpointMetrics: CheckpointProposalJobMetricsRecorder,
106
132
  private readonly eventEmitter: TypedEventEmitter<SequencerEvents>,
107
133
  private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void,
108
134
  public readonly tracer: Tracer,
109
135
  bindings?: LoggerBindings,
136
+ private readonly proposedCheckpointData?: ProposedCheckpointData,
110
137
  ) {
111
138
  this.log = createLogger('sequencer:checkpoint-proposal', {
112
139
  ...bindings,
@@ -114,19 +141,18 @@ export class CheckpointProposalJob implements Traceable {
114
141
  });
115
142
  }
116
143
 
117
- /** The wall-clock slot during which the proposer builds. */
118
- private get slot(): SlotNumber {
119
- return this.slotNow;
120
- }
121
-
122
- /** The wall-clock epoch. */
123
- private get epoch(): EpochNumber {
124
- return this.epochNow;
144
+ /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */
145
+ public async awaitPendingSubmission(): Promise<void> {
146
+ this.log.info('Awaiting pending L1 payload submission');
147
+ await this.pendingL1Submission;
125
148
  }
126
149
 
127
150
  /**
128
151
  * Executes the checkpoint proposal job.
129
- * Returns the published checkpoint if successful, undefined otherwise.
152
+ * Builds blocks, assembles checkpoint, and broadcasts the proposal (blocking).
153
+ * Attestation collection, signing, and L1 submission are backgrounded so the
154
+ * work loop can return to IDLE immediately for consecutive slot proposals.
155
+ * Returns the built checkpoint if successful, undefined otherwise.
130
156
  */
131
157
  @trackSpan('CheckpointProposalJob.execute')
132
158
  public async execute(): Promise<Checkpoint | undefined> {
@@ -145,57 +171,136 @@ export class CheckpointProposalJob implements Traceable {
145
171
  this.log,
146
172
  ).enqueueVotes();
147
173
 
148
- // Build and propose the checkpoint. This will enqueue the request on the publisher if a checkpoint is built.
149
- const checkpoint = await this.proposeCheckpoint();
150
-
151
- // Wait until the voting promises have resolved, so all requests are enqueued (not sent)
152
- await Promise.all(votesPromises);
174
+ // Build blocks, assemble checkpoint, and broadcast proposal (BLOCKING).
175
+ // Returns after broadcast — attestation collection is deferred.
176
+ const broadcast = await this.proposeCheckpoint();
153
177
 
154
- if (checkpoint) {
155
- this.metrics.recordCheckpointProposalSuccess();
178
+ if (!broadcast) {
179
+ await Promise.all(votesPromises);
180
+ // Still submit votes even without a checkpoint
181
+ if (!this.config.fishermanMode) {
182
+ this.pendingL1Submission = this.publisher.sendRequestsAt(this.dateProvider.nowAsDate()).then(() => {});
183
+ }
184
+ return undefined;
156
185
  }
157
186
 
187
+ const { checkpoint } = broadcast;
188
+ this.metrics.recordCheckpointProposalSuccess();
189
+
158
190
  // Do not post anything to L1 if we are fishermen, but do perform L1 fee analysis
159
191
  if (this.config.fishermanMode) {
160
192
  await this.handleCheckpointEndAsFisherman(checkpoint);
161
- return;
193
+ return checkpoint;
162
194
  }
163
195
 
164
- // If pipelining, wait until the submission slot so L1 recognizes the pipelined proposer
165
- if (this.epochCache.isProposerPipeliningEnabled()) {
166
- const submissionSlotTimestamp =
167
- getTimestampForSlot(this.targetSlot, this.l1Constants) - BigInt(this.l1Constants.ethereumSlotDuration);
168
- this.log.info(`Waiting until submission slot ${this.targetSlot} for L1 submission`, {
169
- slot: this.slot,
170
- submissionSlot: this.targetSlot,
171
- submissionSlotTimestamp,
172
- });
173
- await sleepUntil(new Date(Number(submissionSlotTimestamp) * 1000), this.dateProvider.nowAsDate());
196
+ // Background the attestation signing L1 pipeline so the work loop is unblocked
197
+ this.pendingL1Submission = this.waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises);
174
198
 
175
- // After waking, verify the parent checkpoint wasn't pruned during the sleep.
176
- // We check L1's pending tip directly instead of canProposeAt, which also validates the proposer
177
- // identity and would fail because the timestamp resolves to a different slot's proposer.
178
- const l1Tips = await this.publisher.rollupContract.getTips();
179
- if (l1Tips.pending < this.checkpointNumber - 1) {
180
- this.log.warn(
181
- `Parent checkpoint was pruned during pipelining sleep (L1 pending=${l1Tips.pending}, expected>=${this.checkpointNumber - 1}), skipping L1 submission for checkpoint ${this.checkpointNumber}`,
199
+ // Return the built checkpoint immediately the work loop is now unblocked
200
+ return checkpoint;
201
+ }
202
+
203
+ /**
204
+ * Background pipeline: collects attestations, signs them, enqueues the checkpoint, and submits to L1.
205
+ * Runs as a fire-and-forget task stored in `pendingL1Submission` so the work loop is unblocked.
206
+ */
207
+ private async waitForAttestationsAndEnqueueSubmissionAsync(
208
+ broadcast: CheckpointProposalBroadcast,
209
+ votesPromises: Promise<unknown>[],
210
+ ): Promise<void> {
211
+ const { checkpoint, proposal, blockProposedAt } = broadcast;
212
+ try {
213
+ await Promise.all(votesPromises);
214
+
215
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
216
+ const attestations = await this.waitForAttestations(proposal);
217
+
218
+ this.checkpointMetrics.recordCheckpointAttestationDelay(this.dateProvider.now() - blockProposedAt);
219
+
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,
182
229
  );
183
- return undefined;
230
+ } catch (err) {
231
+ if (this.handleHASigningError(err, 'Attestations signature')) {
232
+ return;
233
+ }
234
+ throw err;
235
+ }
236
+
237
+ // Enqueue the checkpoint for L1 submission
238
+ await this.enqueueCheckpointForSubmission({ checkpoint, attestations, attestationsSignature });
239
+
240
+ // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise.
241
+ const submitAfter = this.epochCache.isProposerPipeliningEnabled()
242
+ ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
243
+ : new Date(this.dateProvider.now());
244
+
245
+ const l1Response = await this.publisher.sendRequestsAt(submitAfter);
246
+ const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
247
+ if (proposedAction) {
248
+ this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.targetSlot });
249
+ const coinbase = checkpoint.header.coinbase;
250
+ await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
251
+ } else {
252
+ this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.targetSlot });
253
+ if (this.epochCache.isProposerPipeliningEnabled()) {
254
+ this.metrics.recordPipelineDiscard();
255
+ }
256
+ }
257
+ } catch (err) {
258
+ if (err instanceof SequencerInterruptedError) {
259
+ return;
260
+ }
261
+ this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err);
262
+ this.eventEmitter.emit('checkpoint-publish-failed', { slot: this.targetSlot });
263
+ if (this.epochCache.isProposerPipeliningEnabled()) {
264
+ this.metrics.recordPipelineDiscard();
184
265
  }
185
266
  }
267
+ }
186
268
 
187
- // Then send everything to L1
188
- const l1Response = await this.publisher.sendRequests();
189
- const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
190
- if (proposedAction) {
191
- this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.slot });
192
- const coinbase = checkpoint?.header.coinbase;
193
- await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
194
- return checkpoint;
195
- } else if (checkpoint) {
196
- this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.slot });
197
- return undefined;
269
+ /** Enqueues the checkpoint for L1 submission. Called after pipeline sleep in execute(). */
270
+ private async enqueueCheckpointForSubmission(result: CheckpointProposalResult): Promise<void> {
271
+ const { checkpoint, attestations, attestationsSignature } = result;
272
+
273
+ this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot);
274
+ const aztecSlotDuration = this.l1Constants.slotDuration;
275
+ const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
276
+ const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000);
277
+
278
+ // If we have been configured to potentially skip publishing checkpoint then roll the dice here
279
+ if (
280
+ this.config.skipPublishingCheckpointsPercent !== undefined &&
281
+ this.config.skipPublishingCheckpointsPercent > 0
282
+ ) {
283
+ const roll = Math.max(0, randomInt(100));
284
+ if (roll < this.config.skipPublishingCheckpointsPercent) {
285
+ this.log.warn(
286
+ `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${roll}`,
287
+ );
288
+ return;
289
+ }
198
290
  }
291
+
292
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
293
+ const submissionSimulationOverridesPlan = buildSubmissionSimulationOverridesPlan({
294
+ pipelinedParentPlan: this.pipelinedParentSimulationOverridesPlan,
295
+ invalidateToPendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
296
+ lastArchiveRoot: checkpoint.header.lastArchiveRoot,
297
+ pipeliningEnabled: isPipelining,
298
+ });
299
+
300
+ await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
301
+ txTimeoutAt,
302
+ ...(submissionSimulationOverridesPlan ? { simulationOverridesPlan: submissionSimulationOverridesPlan } : {}),
303
+ });
199
304
  }
200
305
 
201
306
  @trackSpan('CheckpointProposalJob.proposeCheckpoint', function () {
@@ -205,8 +310,18 @@ export class CheckpointProposalJob implements Traceable {
205
310
  [Attributes.SLOT_NUMBER]: this.targetSlot,
206
311
  };
207
312
  })
208
- private async proposeCheckpoint(): Promise<Checkpoint | undefined> {
313
+ private async proposeCheckpoint(): Promise<CheckpointProposalBroadcast | undefined> {
209
314
  try {
315
+ const now = this.dateProvider.now();
316
+ if (this.epochCache.isProposerPipeliningEnabled() && this.proposedCheckpointData) {
317
+ // Measure against the wall-clock slot whose build window we are currently using.
318
+ // In pipelining mode `targetSlot` is intentionally one slot ahead, which makes the
319
+ // target-slot boundary a full slot away from the actual build start time.
320
+ const slotBoundaryMs = Number(getTimestampForSlot(this.slotNow, this.l1Constants)) * 1000;
321
+ this.checkpointMetrics.recordPipelinedCheckpointBuildStartOffsetFromSlotBoundary(now - slotBoundaryMs);
322
+ }
323
+ this.checkpointMetrics.startCheckpointTiming(now);
324
+
210
325
  // Get operator configured coinbase and fee recipient for this attestor
211
326
  const coinbase = this.validatorClient.getCoinbaseForAttestor(this.attestorAddress);
212
327
  const feeRecipient = this.validatorClient.getFeeRecipientForAttestor(this.attestorAddress);
@@ -214,7 +329,7 @@ export class CheckpointProposalJob implements Traceable {
214
329
  // Start the checkpoint
215
330
  this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
216
331
  this.log.info(`Starting checkpoint proposal`, {
217
- buildSlot: this.slot,
332
+ buildSlot: this.slotNow,
218
333
  submissionSlot: this.targetSlot,
219
334
  pipelining: this.epochCache.isProposerPipeliningEnabled(),
220
335
  proposer: this.proposer?.toString(),
@@ -227,11 +342,24 @@ export class CheckpointProposalJob implements Traceable {
227
342
  this.publisher.enqueueInvalidateCheckpoint(this.invalidateCheckpoint);
228
343
  }
229
344
 
230
- // Create checkpoint builder for the slot
345
+ // Create checkpoint builder for the slot.
346
+ // When pipelining, force the proposed checkpoint number and fee header to our parent so the
347
+ // fee computation sees the same chain tip that L1 will see once the previous pipelined checkpoint lands.
348
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
349
+ this.pipelinedParentSimulationOverridesPlan = isPipelining
350
+ ? await buildPipelinedParentSimulationOverridesPlan({
351
+ checkpointNumber: this.checkpointNumber,
352
+ proposedCheckpointData: this.proposedCheckpointData,
353
+ rollup: this.publisher.rollupContract,
354
+ log: this.log,
355
+ })
356
+ : undefined;
357
+
231
358
  const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables(
232
359
  coinbase,
233
360
  feeRecipient,
234
361
  this.targetSlot,
362
+ this.pipelinedParentSimulationOverridesPlan,
235
363
  );
236
364
 
237
365
  // Collect L1 to L2 messages for the checkpoint and compute their hash
@@ -272,7 +400,7 @@ export class CheckpointProposalJob implements Traceable {
272
400
  };
273
401
 
274
402
  let blocksInCheckpoint: L2Block[] = [];
275
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
403
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
276
404
  const checkpointBuildTimer = new Timer();
277
405
 
278
406
  try {
@@ -326,21 +454,21 @@ export class CheckpointProposalJob implements Traceable {
326
454
  maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint,
327
455
  });
328
456
  } catch (err) {
329
- this.log.error(`Built an invalid checkpoint at slot ${this.slot} (skipping proposal)`, err, {
457
+ this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, {
330
458
  checkpoint: checkpoint.header.toInspect(),
331
459
  });
332
460
  return undefined;
333
461
  }
334
462
 
335
463
  // Record checkpoint-level build metrics
336
- this.metrics.recordCheckpointBuild(
464
+ this.checkpointMetrics.recordCheckpointBuild(
337
465
  checkpointBuildTimer.ms(),
338
466
  blocksInCheckpoint.length,
339
467
  checkpoint.getStats().txCount,
340
468
  Number(checkpoint.header.totalManaUsed.toBigInt()),
341
469
  );
342
470
 
343
- // Do not collect attestations nor publish to L1 in fisherman mode
471
+ // In fisherman mode, return the checkpoint without broadcasting or collecting attestations
344
472
  if (this.config.fishermanMode) {
345
473
  this.log.info(
346
474
  `Built checkpoint for slot ${this.targetSlot} with ${blocksInCheckpoint.length} blocks. ` +
@@ -352,87 +480,33 @@ export class CheckpointProposalJob implements Traceable {
352
480
  },
353
481
  );
354
482
  this.metrics.recordCheckpointSuccess();
355
- return checkpoint;
483
+ // Return a broadcast result with a dummy proposal — fisherman mode skips attestation collection
484
+ return { checkpoint, proposal: undefined!, blockProposedAt: this.dateProvider.now() };
356
485
  }
357
486
 
358
- // Include the block pending broadcast in the checkpoint proposal if any
359
- const lastBlock = blockPendingBroadcast && {
360
- blockHeader: blockPendingBroadcast.block.header,
361
- indexWithinCheckpoint: blockPendingBroadcast.block.indexWithinCheckpoint,
362
- txs: blockPendingBroadcast.txs,
363
- };
364
-
365
487
  // Create the checkpoint proposal and broadcast it
366
488
  const proposal = await this.validatorClient.createCheckpointProposal(
367
489
  checkpoint.header,
368
490
  checkpoint.archive.root,
369
491
  feeAssetPriceModifier,
370
- lastBlock,
492
+ blockPendingBroadcast,
371
493
  this.proposer,
372
494
  checkpointProposalOptions,
373
495
  );
374
496
 
375
497
  const blockProposedAt = this.dateProvider.now();
376
498
  await this.p2pClient.broadcastCheckpointProposal(proposal);
499
+ this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
377
500
 
378
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
379
- const attestations = await this.waitForAttestations(proposal);
380
- const blockAttestedAt = this.dateProvider.now();
381
-
382
- this.metrics.recordCheckpointAttestationDelay(blockAttestedAt - blockProposedAt);
383
-
384
- // Proposer must sign over the attestations before pushing them to L1
385
- const signer = this.proposer ?? this.publisher.getSenderAddress();
386
- let attestationsSignature: Signature;
387
- try {
388
- attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
389
- attestations,
390
- signer,
391
- this.targetSlot,
392
- this.checkpointNumber,
393
- );
394
- } catch (err) {
395
- // We shouldn't really get here since we yield to another HA node
396
- // as soon as we see these errors when creating block or checkpoint proposals.
397
- if (this.handleHASigningError(err, 'Attestations signature')) {
398
- return undefined;
399
- }
400
- throw err;
401
- }
402
-
403
- // Enqueue publishing the checkpoint to L1
404
- this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot);
405
- const aztecSlotDuration = this.l1Constants.slotDuration;
406
- const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
407
- const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000);
408
-
409
- // If we have been configured to potentially skip publishing checkpoint then roll the dice here
410
- if (
411
- this.config.skipPublishingCheckpointsPercent !== undefined &&
412
- this.config.skipPublishingCheckpointsPercent > 0
413
- ) {
414
- const result = Math.max(0, randomInt(100));
415
- if (result < this.config.skipPublishingCheckpointsPercent) {
416
- this.log.warn(
417
- `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${result}`,
418
- );
419
- return checkpoint;
420
- }
421
- }
422
-
423
- await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
424
- txTimeoutAt,
425
- forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
426
- });
427
-
428
- return checkpoint;
501
+ // Return immediately after broadcast — attestation collection happens in the background
502
+ return { checkpoint, proposal, blockProposedAt };
429
503
  } catch (err) {
430
504
  if (err && (err instanceof DutyAlreadySignedError || err instanceof SlashingProtectionError)) {
431
505
  // swallow this error. It's already been logged by a function deeper in the stack
432
506
  return undefined;
433
507
  }
434
508
 
435
- this.log.error(`Error building checkpoint at slot ${this.slot}`, err);
509
+ this.log.error(`Error building checkpoint at slot ${this.targetSlot}`, err);
436
510
  return undefined;
437
511
  }
438
512
  }
@@ -448,14 +522,14 @@ export class CheckpointProposalJob implements Traceable {
448
522
  blockProposalOptions: BlockProposalOptions,
449
523
  ): Promise<{
450
524
  blocksInCheckpoint: L2Block[];
451
- blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined;
525
+ blockPendingBroadcast: BlockProposal | undefined;
452
526
  }> {
453
527
  const blocksInCheckpoint: L2Block[] = [];
454
528
  const txHashesAlreadyIncluded = new Set<string>();
455
529
  const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1);
456
530
 
457
531
  // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal
458
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
532
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
459
533
 
460
534
  while (true) {
461
535
  const blocksBuilt = blocksInCheckpoint.length;
@@ -488,19 +562,20 @@ export class CheckpointProposalJob implements Traceable {
488
562
  txHashesAlreadyIncluded,
489
563
  });
490
564
 
491
- // TODO(palla/mbps): Review these conditions. We may want to keep trying in some scenarios.
492
- if (!buildResult && timingInfo.isLastBlock) {
493
- // If no block was produced due to not enough txs and this was the last subslot, exit
494
- break;
495
- } else if (!buildResult && timingInfo.deadline !== undefined) {
496
- // But if there is still time for more blocks, wait until the next subslot and try again
565
+ // If we failed to build the block due to insufficient txs, we try again if there is still time left in the slot
566
+ if ('failure' in buildResult) {
567
+ // If this was the last subslot, or we're running with a single block per slot, we're done
568
+ if (timingInfo.isLastBlock || timingInfo.deadline === undefined) {
569
+ break;
570
+ }
571
+ // Otherwise, if there is still time for more blocks, we wait until the next subslot and try again
497
572
  await this.waitUntilNextSubslot(timingInfo.deadline);
498
573
  continue;
499
- } else if (!buildResult) {
500
- // Exit if there is no possibility of building more blocks
501
- break;
502
- } else if ('error' in buildResult) {
503
- // If there was an error building the block, just exit the loop and give up the rest of the slot
574
+ }
575
+
576
+ // If there was an error building the block, we just exit the loop and give up the rest of the slot.
577
+ // We don't want to risk building more blocks if something went wrong.
578
+ if ('error' in buildResult) {
504
579
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
505
580
  this.log.warn(`Halting block building for slot ${this.targetSlot}`, {
506
581
  slot: this.targetSlot,
@@ -512,33 +587,35 @@ export class CheckpointProposalJob implements Traceable {
512
587
  }
513
588
 
514
589
  const { block, usedTxs } = buildResult;
590
+ this.checkpointMetrics.noteCheckpointBlockBuilt(this.dateProvider.now(), {
591
+ isFirstBlock: blocksBuilt === 0,
592
+ isLastBlock: timingInfo.isLastBlock,
593
+ });
594
+
515
595
  blocksInCheckpoint.push(block);
516
596
  usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
517
597
 
518
- // If this is the last block, sync it to the archiver and exit the loop
519
- // so we can build the checkpoint and start collecting attestations.
598
+ // Sign the block proposal. This will throw if HA signing fails.
599
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
600
+
601
+ // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
602
+ // so we avoid polluting our archive with a block that would fail.
603
+ // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
604
+ // If this throws, we abort the entire checkpoint.
605
+ await this.syncProposedBlockToArchiver(block);
606
+
607
+ // If this is the last block, do not broadcast it, since it will be included in the checkpoint proposal.
520
608
  if (timingInfo.isLastBlock) {
521
- await this.syncProposedBlockToArchiver(block);
522
609
  this.log.verbose(`Completed final block ${blockNumber} for slot ${this.targetSlot}`, {
523
610
  slot: this.targetSlot,
524
611
  blockNumber,
525
612
  blocksBuilt,
526
613
  });
527
- blockPendingBroadcast = { block, txs: usedTxs };
614
+
615
+ blockPendingBroadcast = proposal;
528
616
  break;
529
617
  }
530
618
 
531
- // Broadcast the block proposal (unless we're in fisherman mode) unless the block is the last one,
532
- // in which case we'll broadcast it along with the checkpoint at the end of the loop.
533
- // Note that we only send the block to the archiver if we manage to create the proposal, so if there's
534
- // a HA error we don't pollute our archiver with a block that won't make it to the chain.
535
- const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
536
-
537
- // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal.
538
- // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
539
- // If this throws, we abort the entire checkpoint.
540
- await this.syncProposedBlockToArchiver(block);
541
-
542
619
  // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
543
620
  proposal && (await this.p2pClient.broadcastProposal(proposal));
544
621
 
@@ -598,7 +675,9 @@ export class CheckpointProposalJob implements Traceable {
598
675
  buildDeadline: Date | undefined;
599
676
  txHashesAlreadyIncluded: Set<string>;
600
677
  },
601
- ): Promise<{ block: L2Block; usedTxs: Tx[] } | { error: Error } | undefined> {
678
+ ): Promise<
679
+ { block: L2Block; usedTxs: Tx[] } | { failure: 'insufficient-txs' | 'insufficient-valid-txs' } | { error: Error }
680
+ > {
602
681
  const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } =
603
682
  opts;
604
683
 
@@ -617,7 +696,7 @@ export class CheckpointProposalJob implements Traceable {
617
696
  );
618
697
  this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
619
698
  this.metrics.recordBlockProposalFailed('insufficient_txs');
620
- return undefined;
699
+ return { failure: 'insufficient-txs' };
621
700
  }
622
701
 
623
702
  // Create iterator to pending txs. We filter out txs already included in previous blocks in the checkpoint
@@ -680,7 +759,7 @@ export class CheckpointProposalJob implements Traceable {
680
759
  slot: this.targetSlot,
681
760
  });
682
761
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
683
- return undefined;
762
+ return { failure: 'insufficient-valid-txs' };
684
763
  }
685
764
 
686
765
  // Block creation succeeded, emit stats and metrics
@@ -702,12 +781,14 @@ export class CheckpointProposalJob implements Traceable {
702
781
  { blockHash, txHashes, manaPerSec, ...blockStats },
703
782
  );
704
783
 
784
+ // `slot` is the target/submission slot (may be one ahead when pipelining),
785
+ // `buildSlot` is the wall-clock slot during which the block was actually built.
705
786
  this.eventEmitter.emit('block-proposed', {
706
787
  blockNumber: block.number,
707
788
  slot: this.targetSlot,
708
789
  buildSlot: this.slotNow,
709
790
  });
710
- this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe());
791
+ this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe(), this.targetSlot);
711
792
 
712
793
  return { block, usedTxs };
713
794
  } catch (err: any) {
@@ -810,7 +891,7 @@ export class CheckpointProposalJob implements Traceable {
810
891
  this.log.debug(`Attesting committee length is ${committee.length}`, { committee });
811
892
  }
812
893
 
813
- const numberOfRequiredAttestations = Math.floor((committee.length * 2) / 3) + 1;
894
+ const numberOfRequiredAttestations = computeQuorum(committee.length);
814
895
 
815
896
  if (this.config.skipCollectingAttestations) {
816
897
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
@@ -819,7 +900,7 @@ export class CheckpointProposalJob implements Traceable {
819
900
  }
820
901
 
821
902
  const attestationTimeAllowed = this.config.enforceTimeTable
822
- ? this.timetable.getMaxAllowedTime(SequencerState.PUBLISHING_CHECKPOINT)!
903
+ ? this.timetable.getCheckpointAttestationDeadline()
823
904
  : this.l1Constants.slotDuration;
824
905
  const attestationDeadline = new Date((this.getSlotStartBuildTimestamp() + attestationTimeAllowed) * 1000);
825
906
 
@@ -958,9 +1039,13 @@ export class CheckpointProposalJob implements Traceable {
958
1039
  * Adds the proposed block to the archiver so it's available via P2P.
959
1040
  * Gossip doesn't echo messages back to the sender, so the proposer's archiver/world-state
960
1041
  * would never receive its own block without this explicit sync.
1042
+ *
1043
+ * In fisherman mode we skip this push: the fisherman builds blocks locally for validation
1044
+ * and fee analysis only, and pushing them to the archiver causes spurious reorg cascades
1045
+ * whenever the real proposer's block arrives from L1.
961
1046
  */
962
1047
  private async syncProposedBlockToArchiver(block: L2Block): Promise<void> {
963
- if (this.config.skipPushProposedBlocksToArchiver !== false) {
1048
+ if (this.config.skipPushProposedBlocksToArchiver || this.config.fishermanMode) {
964
1049
  this.log.warn(`Skipping push of proposed block ${block.number} to archiver`, {
965
1050
  blockNumber: block.number,
966
1051
  slot: block.header.globalVariables.slotNumber,
@@ -1035,7 +1120,7 @@ export class CheckpointProposalJob implements Traceable {
1035
1120
  }
1036
1121
 
1037
1122
  private getSlotStartBuildTimestamp(): number {
1038
- return getSlotStartBuildTimestamp(this.slot, this.l1Constants);
1123
+ return getSlotStartBuildTimestamp(this.slotNow, this.l1Constants);
1039
1124
  }
1040
1125
 
1041
1126
  private getSecondsIntoSlot(): number {