@aztec/sequencer-client 0.0.1-commit.7035c9bd6 → 0.0.1-commit.71324e566

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 (70) hide show
  1. package/dest/client/sequencer-client.d.ts +6 -1
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +11 -17
  4. package/dest/config.d.ts +1 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +8 -11
  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 +15 -14
  14. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  15. package/dest/global_variable_builder/global_builder.js +16 -51
  16. package/dest/global_variable_builder/index.d.ts +4 -2
  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/config.d.ts +13 -1
  20. package/dest/publisher/config.d.ts.map +1 -1
  21. package/dest/publisher/config.js +19 -4
  22. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +3 -4
  23. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -1
  24. package/dest/publisher/sequencer-publisher-factory.d.ts +3 -5
  25. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  26. package/dest/publisher/sequencer-publisher-factory.js +2 -3
  27. package/dest/publisher/sequencer-publisher.d.ts +48 -42
  28. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  29. package/dest/publisher/sequencer-publisher.js +117 -109
  30. package/dest/sequencer/chain_state_overrides.d.ts +25 -0
  31. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  32. package/dest/sequencer/chain_state_overrides.js +39 -0
  33. package/dest/sequencer/checkpoint_proposal_job.d.ts +21 -10
  34. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  35. package/dest/sequencer/checkpoint_proposal_job.js +200 -134
  36. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  37. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  38. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  39. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  40. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  41. package/dest/sequencer/checkpoint_voter.js +2 -5
  42. package/dest/sequencer/metrics.d.ts +5 -10
  43. package/dest/sequencer/metrics.d.ts.map +1 -1
  44. package/dest/sequencer/metrics.js +12 -20
  45. package/dest/sequencer/sequencer.d.ts +17 -4
  46. package/dest/sequencer/sequencer.d.ts.map +1 -1
  47. package/dest/sequencer/sequencer.js +76 -23
  48. package/dest/sequencer/timetable.d.ts +14 -1
  49. package/dest/sequencer/timetable.d.ts.map +1 -1
  50. package/dest/sequencer/timetable.js +45 -36
  51. package/package.json +27 -27
  52. package/src/client/sequencer-client.ts +16 -24
  53. package/src/config.ts +8 -11
  54. package/src/global_variable_builder/README.md +44 -0
  55. package/src/global_variable_builder/fee_predictor.ts +172 -0
  56. package/src/global_variable_builder/fee_provider.ts +75 -0
  57. package/src/global_variable_builder/global_builder.ts +26 -63
  58. package/src/global_variable_builder/index.ts +3 -1
  59. package/src/publisher/config.ts +38 -4
  60. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +3 -1
  61. package/src/publisher/sequencer-publisher-factory.ts +3 -6
  62. package/src/publisher/sequencer-publisher.ts +181 -155
  63. package/src/sequencer/README.md +83 -13
  64. package/src/sequencer/chain_state_overrides.ts +87 -0
  65. package/src/sequencer/checkpoint_proposal_job.ts +242 -154
  66. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  67. package/src/sequencer/checkpoint_voter.ts +1 -12
  68. package/src/sequencer/metrics.ts +15 -25
  69. package/src/sequencer/sequencer.ts +113 -25
  70. 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,34 @@ 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,
491
+ this.checkpointNumber,
369
492
  feeAssetPriceModifier,
370
- lastBlock,
493
+ blockPendingBroadcast,
371
494
  this.proposer,
372
495
  checkpointProposalOptions,
373
496
  );
374
497
 
375
498
  const blockProposedAt = this.dateProvider.now();
376
499
  await this.p2pClient.broadcastCheckpointProposal(proposal);
500
+ this.checkpointMetrics.noteCheckpointBroadcast(this.dateProvider.now());
377
501
 
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;
502
+ // Return immediately after broadcast — attestation collection happens in the background
503
+ return { checkpoint, proposal, blockProposedAt };
429
504
  } catch (err) {
430
505
  if (err && (err instanceof DutyAlreadySignedError || err instanceof SlashingProtectionError)) {
431
506
  // swallow this error. It's already been logged by a function deeper in the stack
432
507
  return undefined;
433
508
  }
434
509
 
435
- this.log.error(`Error building checkpoint at slot ${this.slot}`, err);
510
+ this.log.error(`Error building checkpoint at slot ${this.targetSlot}`, err);
436
511
  return undefined;
437
512
  }
438
513
  }
@@ -448,14 +523,14 @@ export class CheckpointProposalJob implements Traceable {
448
523
  blockProposalOptions: BlockProposalOptions,
449
524
  ): Promise<{
450
525
  blocksInCheckpoint: L2Block[];
451
- blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined;
526
+ blockPendingBroadcast: BlockProposal | undefined;
452
527
  }> {
453
528
  const blocksInCheckpoint: L2Block[] = [];
454
529
  const txHashesAlreadyIncluded = new Set<string>();
455
530
  const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1);
456
531
 
457
532
  // 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;
533
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
459
534
 
460
535
  while (true) {
461
536
  const blocksBuilt = blocksInCheckpoint.length;
@@ -488,19 +563,20 @@ export class CheckpointProposalJob implements Traceable {
488
563
  txHashesAlreadyIncluded,
489
564
  });
490
565
 
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
566
+ // If we failed to build the block due to insufficient txs, we try again if there is still time left in the slot
567
+ if ('failure' in buildResult) {
568
+ // If this was the last subslot, or we're running with a single block per slot, we're done
569
+ if (timingInfo.isLastBlock || timingInfo.deadline === undefined) {
570
+ break;
571
+ }
572
+ // Otherwise, if there is still time for more blocks, we wait until the next subslot and try again
497
573
  await this.waitUntilNextSubslot(timingInfo.deadline);
498
574
  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
575
+ }
576
+
577
+ // If there was an error building the block, we just exit the loop and give up the rest of the slot.
578
+ // We don't want to risk building more blocks if something went wrong.
579
+ if ('error' in buildResult) {
504
580
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
505
581
  this.log.warn(`Halting block building for slot ${this.targetSlot}`, {
506
582
  slot: this.targetSlot,
@@ -512,33 +588,35 @@ export class CheckpointProposalJob implements Traceable {
512
588
  }
513
589
 
514
590
  const { block, usedTxs } = buildResult;
591
+ this.checkpointMetrics.noteCheckpointBlockBuilt(this.dateProvider.now(), {
592
+ isFirstBlock: blocksBuilt === 0,
593
+ isLastBlock: timingInfo.isLastBlock,
594
+ });
595
+
515
596
  blocksInCheckpoint.push(block);
516
597
  usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
517
598
 
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.
599
+ // Sign the block proposal. This will throw if HA signing fails.
600
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
601
+
602
+ // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
603
+ // so we avoid polluting our archive with a block that would fail.
604
+ // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
605
+ // If this throws, we abort the entire checkpoint.
606
+ await this.syncProposedBlockToArchiver(block);
607
+
608
+ // If this is the last block, do not broadcast it, since it will be included in the checkpoint proposal.
520
609
  if (timingInfo.isLastBlock) {
521
- await this.syncProposedBlockToArchiver(block);
522
610
  this.log.verbose(`Completed final block ${blockNumber} for slot ${this.targetSlot}`, {
523
611
  slot: this.targetSlot,
524
612
  blockNumber,
525
613
  blocksBuilt,
526
614
  });
527
- blockPendingBroadcast = { block, txs: usedTxs };
615
+
616
+ blockPendingBroadcast = proposal;
528
617
  break;
529
618
  }
530
619
 
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
620
  // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
543
621
  proposal && (await this.p2pClient.broadcastProposal(proposal));
544
622
 
@@ -567,6 +645,7 @@ export class CheckpointProposalJob implements Traceable {
567
645
  }
568
646
  return this.validatorClient.createBlockProposal(
569
647
  block.header,
648
+ this.checkpointNumber,
570
649
  block.indexWithinCheckpoint,
571
650
  inHash,
572
651
  block.archive.root,
@@ -598,7 +677,9 @@ export class CheckpointProposalJob implements Traceable {
598
677
  buildDeadline: Date | undefined;
599
678
  txHashesAlreadyIncluded: Set<string>;
600
679
  },
601
- ): Promise<{ block: L2Block; usedTxs: Tx[] } | { error: Error } | undefined> {
680
+ ): Promise<
681
+ { block: L2Block; usedTxs: Tx[] } | { failure: 'insufficient-txs' | 'insufficient-valid-txs' } | { error: Error }
682
+ > {
602
683
  const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } =
603
684
  opts;
604
685
 
@@ -617,7 +698,7 @@ export class CheckpointProposalJob implements Traceable {
617
698
  );
618
699
  this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
619
700
  this.metrics.recordBlockProposalFailed('insufficient_txs');
620
- return undefined;
701
+ return { failure: 'insufficient-txs' };
621
702
  }
622
703
 
623
704
  // Create iterator to pending txs. We filter out txs already included in previous blocks in the checkpoint
@@ -680,7 +761,7 @@ export class CheckpointProposalJob implements Traceable {
680
761
  slot: this.targetSlot,
681
762
  });
682
763
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
683
- return undefined;
764
+ return { failure: 'insufficient-valid-txs' };
684
765
  }
685
766
 
686
767
  // Block creation succeeded, emit stats and metrics
@@ -702,12 +783,14 @@ export class CheckpointProposalJob implements Traceable {
702
783
  { blockHash, txHashes, manaPerSec, ...blockStats },
703
784
  );
704
785
 
786
+ // `slot` is the target/submission slot (may be one ahead when pipelining),
787
+ // `buildSlot` is the wall-clock slot during which the block was actually built.
705
788
  this.eventEmitter.emit('block-proposed', {
706
789
  blockNumber: block.number,
707
790
  slot: this.targetSlot,
708
791
  buildSlot: this.slotNow,
709
792
  });
710
- this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe());
793
+ this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe(), this.targetSlot);
711
794
 
712
795
  return { block, usedTxs };
713
796
  } catch (err: any) {
@@ -810,16 +893,16 @@ export class CheckpointProposalJob implements Traceable {
810
893
  this.log.debug(`Attesting committee length is ${committee.length}`, { committee });
811
894
  }
812
895
 
813
- const numberOfRequiredAttestations = Math.floor((committee.length * 2) / 3) + 1;
896
+ const numberOfRequiredAttestations = computeQuorum(committee.length);
814
897
 
815
898
  if (this.config.skipCollectingAttestations) {
816
899
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
817
- const attestations = await this.validatorClient?.collectOwnAttestations(proposal);
900
+ const attestations = await this.validatorClient?.collectOwnAttestations(proposal, this.checkpointNumber);
818
901
  return new CommitteeAttestationsAndSigners(orderAttestations(attestations ?? [], committee));
819
902
  }
820
903
 
821
904
  const attestationTimeAllowed = this.config.enforceTimeTable
822
- ? this.timetable.getMaxAllowedTime(SequencerState.PUBLISHING_CHECKPOINT)!
905
+ ? this.timetable.getCheckpointAttestationDeadline()
823
906
  : this.l1Constants.slotDuration;
824
907
  const attestationDeadline = new Date((this.getSlotStartBuildTimestamp() + attestationTimeAllowed) * 1000);
825
908
 
@@ -832,6 +915,7 @@ export class CheckpointProposalJob implements Traceable {
832
915
  proposal,
833
916
  numberOfRequiredAttestations,
834
917
  attestationDeadline,
918
+ this.checkpointNumber,
835
919
  );
836
920
 
837
921
  collectedAttestationsCount = attestations.length;
@@ -958,9 +1042,13 @@ export class CheckpointProposalJob implements Traceable {
958
1042
  * Adds the proposed block to the archiver so it's available via P2P.
959
1043
  * Gossip doesn't echo messages back to the sender, so the proposer's archiver/world-state
960
1044
  * would never receive its own block without this explicit sync.
1045
+ *
1046
+ * In fisherman mode we skip this push: the fisherman builds blocks locally for validation
1047
+ * and fee analysis only, and pushing them to the archiver causes spurious reorg cascades
1048
+ * whenever the real proposer's block arrives from L1.
961
1049
  */
962
1050
  private async syncProposedBlockToArchiver(block: L2Block): Promise<void> {
963
- if (this.config.skipPushProposedBlocksToArchiver !== false) {
1051
+ if (this.config.skipPushProposedBlocksToArchiver || this.config.fishermanMode) {
964
1052
  this.log.warn(`Skipping push of proposed block ${block.number} to archiver`, {
965
1053
  blockNumber: block.number,
966
1054
  slot: block.header.globalVariables.slotNumber,
@@ -1035,7 +1123,7 @@ export class CheckpointProposalJob implements Traceable {
1035
1123
  }
1036
1124
 
1037
1125
  private getSlotStartBuildTimestamp(): number {
1038
- return getSlotStartBuildTimestamp(this.slot, this.l1Constants);
1126
+ return getSlotStartBuildTimestamp(this.slotNow, this.l1Constants);
1039
1127
  }
1040
1128
 
1041
1129
  private getSecondsIntoSlot(): number {