@aztec/sequencer-client 0.0.1-commit.f504929 → 0.0.1-commit.f650c0a5c

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 (59) hide show
  1. package/dest/client/sequencer-client.d.ts +4 -1
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +46 -27
  4. package/dest/config.d.ts +25 -5
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +21 -12
  7. package/dest/global_variable_builder/global_builder.d.ts +15 -9
  8. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  9. package/dest/global_variable_builder/global_builder.js +29 -25
  10. package/dest/global_variable_builder/index.d.ts +2 -2
  11. package/dest/global_variable_builder/index.d.ts.map +1 -1
  12. package/dest/publisher/config.d.ts +13 -1
  13. package/dest/publisher/config.d.ts.map +1 -1
  14. package/dest/publisher/config.js +17 -2
  15. package/dest/publisher/sequencer-publisher-factory.d.ts +3 -5
  16. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  17. package/dest/publisher/sequencer-publisher-factory.js +2 -3
  18. package/dest/publisher/sequencer-publisher.d.ts +58 -32
  19. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  20. package/dest/publisher/sequencer-publisher.js +132 -89
  21. package/dest/sequencer/checkpoint_proposal_job.d.ts +36 -9
  22. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  23. package/dest/sequencer/checkpoint_proposal_job.js +302 -180
  24. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  25. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  26. package/dest/sequencer/checkpoint_voter.js +2 -5
  27. package/dest/sequencer/events.d.ts +2 -1
  28. package/dest/sequencer/events.d.ts.map +1 -1
  29. package/dest/sequencer/metrics.d.ts +5 -1
  30. package/dest/sequencer/metrics.d.ts.map +1 -1
  31. package/dest/sequencer/metrics.js +11 -0
  32. package/dest/sequencer/sequencer.d.ts +23 -10
  33. package/dest/sequencer/sequencer.d.ts.map +1 -1
  34. package/dest/sequencer/sequencer.js +130 -71
  35. package/dest/sequencer/timetable.d.ts +7 -3
  36. package/dest/sequencer/timetable.d.ts.map +1 -1
  37. package/dest/sequencer/timetable.js +21 -12
  38. package/dest/sequencer/types.d.ts +2 -2
  39. package/dest/sequencer/types.d.ts.map +1 -1
  40. package/dest/test/mock_checkpoint_builder.d.ts +7 -9
  41. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  42. package/dest/test/mock_checkpoint_builder.js +39 -30
  43. package/package.json +27 -28
  44. package/src/client/sequencer-client.ts +56 -28
  45. package/src/config.ts +28 -14
  46. package/src/global_variable_builder/global_builder.ts +37 -26
  47. package/src/global_variable_builder/index.ts +1 -1
  48. package/src/publisher/config.ts +32 -0
  49. package/src/publisher/sequencer-publisher-factory.ts +3 -6
  50. package/src/publisher/sequencer-publisher.ts +205 -127
  51. package/src/sequencer/README.md +81 -12
  52. package/src/sequencer/checkpoint_proposal_job.ts +385 -199
  53. package/src/sequencer/checkpoint_voter.ts +1 -12
  54. package/src/sequencer/events.ts +1 -1
  55. package/src/sequencer/metrics.ts +14 -0
  56. package/src/sequencer/sequencer.ts +186 -80
  57. package/src/sequencer/timetable.ts +26 -15
  58. package/src/sequencer/types.ts +1 -1
  59. package/src/test/mock_checkpoint_builder.ts +51 -48
@@ -1,6 +1,5 @@
1
- import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@aztec/blob-lib/encoding';
2
- import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB } from '@aztec/constants';
3
1
  import type { EpochCache } from '@aztec/epoch-cache';
2
+ import { type FeeHeader, RollupContract } from '@aztec/ethereum/contracts';
4
3
  import {
5
4
  BlockNumber,
6
5
  CheckpointNumber,
@@ -32,17 +31,22 @@ import {
32
31
  type L2BlockSource,
33
32
  MaliciousCommitteeAttestationsAndSigners,
34
33
  } from '@aztec/stdlib/block';
35
- import type { Checkpoint } from '@aztec/stdlib/checkpoint';
36
- import { getSlotStartBuildTimestamp } 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';
37
36
  import { Gas } from '@aztec/stdlib/gas';
38
37
  import {
39
- NoValidTxsError,
40
- type PublicProcessorLimits,
38
+ type BlockBuilderOptions,
39
+ InsufficientValidTxsError,
41
40
  type ResolvedSequencerConfig,
42
41
  type WorldStateSynchronizer,
43
42
  } from '@aztec/stdlib/interfaces/server';
44
43
  import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
45
- import type { BlockProposalOptions, CheckpointProposal, CheckpointProposalOptions } from '@aztec/stdlib/p2p';
44
+ import type {
45
+ BlockProposal,
46
+ BlockProposalOptions,
47
+ CheckpointProposal,
48
+ CheckpointProposalOptions,
49
+ } from '@aztec/stdlib/p2p';
46
50
  import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p';
47
51
  import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
48
52
  import { type FailedTx, Tx } from '@aztec/stdlib/tx';
@@ -64,6 +68,13 @@ import { SequencerState } from './utils.js';
64
68
  /** How much time to sleep while waiting for min transactions to accumulate for a block */
65
69
  const TXS_POLLING_MS = 500;
66
70
 
71
+ /** Result from proposeCheckpoint when a checkpoint was successfully built and attested. */
72
+ type CheckpointProposalResult = {
73
+ checkpoint: Checkpoint;
74
+ attestations: CommitteeAttestationsAndSigners;
75
+ attestationsSignature: Signature;
76
+ };
77
+
67
78
  /**
68
79
  * Handles the execution of a checkpoint proposal after the initial preparation phase.
69
80
  * This includes building blocks, collecting attestations, and publishing the checkpoint to L1,
@@ -73,9 +84,16 @@ const TXS_POLLING_MS = 500;
73
84
  export class CheckpointProposalJob implements Traceable {
74
85
  protected readonly log: Logger;
75
86
 
87
+ /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */
88
+ private pendingL1Submission: Promise<void> | undefined;
89
+
90
+ /** Fee header override computed during proposeCheckpoint, reused in enqueueCheckpointForSubmission. */
91
+ private computedForceProposedFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader };
92
+
76
93
  constructor(
77
- private readonly epoch: EpochNumber,
78
- private readonly slot: SlotNumber,
94
+ private readonly slotNow: SlotNumber,
95
+ private readonly targetSlot: SlotNumber,
96
+ private readonly targetEpoch: EpochNumber,
79
97
  private readonly checkpointNumber: CheckpointNumber,
80
98
  private readonly syncedToBlockNumber: BlockNumber,
81
99
  // TODO(palla/mbps): Can we remove the proposer in favor of attestorAddress? Need to check fisherman-node flows.
@@ -102,13 +120,25 @@ export class CheckpointProposalJob implements Traceable {
102
120
  private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void,
103
121
  public readonly tracer: Tracer,
104
122
  bindings?: LoggerBindings,
123
+ private readonly proposedCheckpointData?: ProposedCheckpointData,
105
124
  ) {
106
- this.log = createLogger('sequencer:checkpoint-proposal', { ...bindings, instanceId: `slot-${slot}` });
125
+ this.log = createLogger('sequencer:checkpoint-proposal', {
126
+ ...bindings,
127
+ instanceId: `slot-${this.slotNow}`,
128
+ });
129
+ }
130
+
131
+ /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */
132
+ public async awaitPendingSubmission(): Promise<void> {
133
+ this.log.info('Awaiting pending L1 payload submission');
134
+ await this.pendingL1Submission;
107
135
  }
108
136
 
109
137
  /**
110
138
  * Executes the checkpoint proposal job.
111
- * Returns the published checkpoint if successful, undefined otherwise.
139
+ * Builds blocks, collects attestations, enqueues requests, and schedules L1 submission as a
140
+ * background task so the work loop can return to IDLE immediately.
141
+ * Returns the built checkpoint if successful, undefined otherwise.
112
142
  */
113
143
  @trackSpan('CheckpointProposalJob.execute')
114
144
  public async execute(): Promise<Checkpoint | undefined> {
@@ -116,7 +146,7 @@ export class CheckpointProposalJob implements Traceable {
116
146
  // In fisherman mode, we simulate slashing but don't actually publish to L1
117
147
  // These are constant for the whole slot, so we only enqueue them once
118
148
  const votesPromises = new CheckpointVoter(
119
- this.slot,
149
+ this.targetSlot,
120
150
  this.publisher,
121
151
  this.attestorAddress,
122
152
  this.validatorClient,
@@ -127,8 +157,10 @@ export class CheckpointProposalJob implements Traceable {
127
157
  this.log,
128
158
  ).enqueueVotes();
129
159
 
130
- // Build and propose the checkpoint. This will enqueue the request on the publisher if a checkpoint is built.
131
- const checkpoint = await this.proposeCheckpoint();
160
+ // Build and propose the checkpoint. Builds blocks, broadcasts, collects attestations, and signs.
161
+ // Does NOT enqueue to L1 yet — that happens after the pipeline sleep.
162
+ const proposalResult = await this.proposeCheckpoint();
163
+ const checkpoint = proposalResult?.checkpoint;
132
164
 
133
165
  // Wait until the voting promises have resolved, so all requests are enqueued (not sent)
134
166
  await Promise.all(votesPromises);
@@ -143,47 +175,133 @@ export class CheckpointProposalJob implements Traceable {
143
175
  return;
144
176
  }
145
177
 
146
- // Then send everything to L1
147
- const l1Response = await this.publisher.sendRequests();
148
- const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
149
- if (proposedAction) {
150
- this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.slot });
151
- const coinbase = checkpoint?.header.coinbase;
152
- await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
153
- return checkpoint;
154
- } else if (checkpoint) {
155
- this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.slot });
156
- return undefined;
178
+ // Enqueue the checkpoint for L1 submission
179
+ if (proposalResult) {
180
+ try {
181
+ await this.enqueueCheckpointForSubmission(proposalResult);
182
+ } catch (err) {
183
+ this.log.error(`Failed to enqueue checkpoint for L1 submission at slot ${this.targetSlot}`, err);
184
+ // Continue to sendRequestsAt so votes are still sent
185
+ }
157
186
  }
187
+
188
+ // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise.
189
+ const submitAfter = this.epochCache.isProposerPipeliningEnabled()
190
+ ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
191
+ : new Date(this.dateProvider.now());
192
+
193
+ // Schedule L1 submission in the background so the work loop returns immediately.
194
+ // The publisher will sleep until submitAfter, then send the bundled requests.
195
+ // The promise is stored so it can be awaited during shutdown.
196
+ this.pendingL1Submission = this.publisher
197
+ .sendRequestsAt(submitAfter)
198
+ .then(async l1Response => {
199
+ const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
200
+ if (proposedAction) {
201
+ this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.targetSlot });
202
+ const coinbase = checkpoint?.header.coinbase;
203
+ await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
204
+ } else if (checkpoint) {
205
+ this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.targetSlot });
206
+
207
+ if (this.epochCache.isProposerPipeliningEnabled()) {
208
+ this.metrics.recordPipelineDiscard();
209
+ }
210
+ }
211
+ })
212
+ .catch(err => {
213
+ this.log.error(`Background L1 submission failed for slot ${this.targetSlot}`, err);
214
+ if (checkpoint) {
215
+ this.eventEmitter.emit('checkpoint-publish-failed', { slot: this.targetSlot });
216
+
217
+ if (this.epochCache.isProposerPipeliningEnabled()) {
218
+ this.metrics.recordPipelineDiscard();
219
+ }
220
+ }
221
+ });
222
+
223
+ // Return the built checkpoint immediately — the work loop is now unblocked
224
+ return checkpoint;
225
+ }
226
+
227
+ /** Enqueues the checkpoint for L1 submission. Called after pipeline sleep in execute(). */
228
+ private async enqueueCheckpointForSubmission(result: CheckpointProposalResult): Promise<void> {
229
+ const { checkpoint, attestations, attestationsSignature } = result;
230
+
231
+ this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot);
232
+ const aztecSlotDuration = this.l1Constants.slotDuration;
233
+ const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
234
+ const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000);
235
+
236
+ // If we have been configured to potentially skip publishing checkpoint then roll the dice here
237
+ if (
238
+ this.config.skipPublishingCheckpointsPercent !== undefined &&
239
+ this.config.skipPublishingCheckpointsPercent > 0
240
+ ) {
241
+ const roll = Math.max(0, randomInt(100));
242
+ if (roll < this.config.skipPublishingCheckpointsPercent) {
243
+ this.log.warn(
244
+ `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${roll}`,
245
+ );
246
+ return;
247
+ }
248
+ }
249
+
250
+ await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
251
+ txTimeoutAt,
252
+ forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
253
+ forceProposedFeeHeader: this.computedForceProposedFeeHeader,
254
+ });
158
255
  }
159
256
 
160
257
  @trackSpan('CheckpointProposalJob.proposeCheckpoint', function () {
161
258
  return {
162
259
  // nullish operator needed for tests
163
260
  [Attributes.COINBASE]: this.validatorClient.getCoinbaseForAttestor(this.attestorAddress)?.toString(),
164
- [Attributes.SLOT_NUMBER]: this.slot,
261
+ [Attributes.SLOT_NUMBER]: this.targetSlot,
165
262
  };
166
263
  })
167
- private async proposeCheckpoint(): Promise<Checkpoint | undefined> {
264
+ private async proposeCheckpoint(): Promise<CheckpointProposalResult | undefined> {
168
265
  try {
169
266
  // Get operator configured coinbase and fee recipient for this attestor
170
267
  const coinbase = this.validatorClient.getCoinbaseForAttestor(this.attestorAddress);
171
268
  const feeRecipient = this.validatorClient.getFeeRecipientForAttestor(this.attestorAddress);
172
269
 
173
270
  // Start the checkpoint
174
- this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.slot);
175
- this.metrics.incOpenSlot(this.slot, this.proposer?.toString() ?? 'unknown');
271
+ this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
272
+ this.log.info(`Starting checkpoint proposal`, {
273
+ buildSlot: this.slotNow,
274
+ submissionSlot: this.targetSlot,
275
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
276
+ proposer: this.proposer?.toString(),
277
+ coinbase: coinbase.toString(),
278
+ });
279
+ this.metrics.incOpenSlot(this.targetSlot, this.proposer?.toString() ?? 'unknown');
176
280
 
177
281
  // Enqueues checkpoint invalidation (constant for the whole slot)
178
282
  if (this.invalidateCheckpoint && !this.config.skipInvalidateBlockAsProposer) {
179
283
  this.publisher.enqueueInvalidateCheckpoint(this.invalidateCheckpoint);
180
284
  }
181
285
 
182
- // Create checkpoint builder for the slot
286
+ // Create checkpoint builder for the slot.
287
+ // When pipelining, force the proposed checkpoint number and fee header to our parent so the
288
+ // fee computation sees the same chain tip that L1 will see once the previous pipelined checkpoint lands.
289
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
290
+ const parentCheckpointNumber = isPipelining ? CheckpointNumber(this.checkpointNumber - 1) : undefined;
291
+
292
+ // Compute the parent's fee header override when pipelining
293
+ if (isPipelining && this.proposedCheckpointData) {
294
+ this.computedForceProposedFeeHeader = await this.computeForceProposedFeeHeader(parentCheckpointNumber!);
295
+ }
296
+
183
297
  const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables(
184
298
  coinbase,
185
299
  feeRecipient,
186
- this.slot,
300
+ this.targetSlot,
301
+ {
302
+ forcePendingCheckpointNumber: parentCheckpointNumber,
303
+ forceProposedFeeHeader: this.computedForceProposedFeeHeader,
304
+ },
187
305
  );
188
306
 
189
307
  // Collect L1 to L2 messages for the checkpoint and compute their hash
@@ -191,7 +309,7 @@ export class CheckpointProposalJob implements Traceable {
191
309
  const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
192
310
 
193
311
  // Collect the out hashes of all the checkpoints before this one in the same epoch
194
- const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.epoch))
312
+ const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.targetEpoch))
195
313
  .filter(c => c.checkpointNumber < this.checkpointNumber)
196
314
  .map(c => c.checkpointOutHash);
197
315
 
@@ -224,7 +342,7 @@ export class CheckpointProposalJob implements Traceable {
224
342
  };
225
343
 
226
344
  let blocksInCheckpoint: L2Block[] = [];
227
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
345
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
228
346
  const checkpointBuildTimer = new Timer();
229
347
 
230
348
  try {
@@ -248,8 +366,8 @@ export class CheckpointProposalJob implements Traceable {
248
366
  }
249
367
 
250
368
  if (blocksInCheckpoint.length === 0) {
251
- this.log.warn(`No blocks were built for slot ${this.slot}`, { slot: this.slot });
252
- this.eventEmitter.emit('checkpoint-empty', { slot: this.slot });
369
+ this.log.warn(`No blocks were built for slot ${this.targetSlot}`, { slot: this.targetSlot });
370
+ this.eventEmitter.emit('checkpoint-empty', { slot: this.targetSlot });
253
371
  return undefined;
254
372
  }
255
373
 
@@ -257,16 +375,33 @@ export class CheckpointProposalJob implements Traceable {
257
375
  if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
258
376
  this.log.warn(
259
377
  `Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`,
260
- { slot: this.slot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
378
+ { slot: this.targetSlot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
261
379
  );
262
380
  return undefined;
263
381
  }
264
382
 
265
383
  // Assemble and broadcast the checkpoint proposal, including the last block that was not
266
384
  // broadcasted yet, and wait to collect the committee attestations.
267
- this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.slot);
385
+ this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.targetSlot);
268
386
  const checkpoint = await checkpointBuilder.completeCheckpoint();
269
387
 
388
+ // Final validation: per-block limits are only checked if the operator set them explicitly.
389
+ // Otherwise, checkpoint-level budgets were already enforced by the redistribution logic.
390
+ try {
391
+ validateCheckpoint(checkpoint, {
392
+ rollupManaLimit: this.l1Constants.rollupManaLimit,
393
+ maxL2BlockGas: this.config.maxL2BlockGas,
394
+ maxDABlockGas: this.config.maxDABlockGas,
395
+ maxTxsPerBlock: this.config.maxTxsPerBlock,
396
+ maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint,
397
+ });
398
+ } catch (err) {
399
+ this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, {
400
+ checkpoint: checkpoint.header.toInspect(),
401
+ });
402
+ return undefined;
403
+ }
404
+
270
405
  // Record checkpoint-level build metrics
271
406
  this.metrics.recordCheckpointBuild(
272
407
  checkpointBuildTimer.ms(),
@@ -278,31 +413,28 @@ export class CheckpointProposalJob implements Traceable {
278
413
  // Do not collect attestations nor publish to L1 in fisherman mode
279
414
  if (this.config.fishermanMode) {
280
415
  this.log.info(
281
- `Built checkpoint for slot ${this.slot} with ${blocksInCheckpoint.length} blocks. ` +
416
+ `Built checkpoint for slot ${this.targetSlot} with ${blocksInCheckpoint.length} blocks. ` +
282
417
  `Skipping proposal in fisherman mode.`,
283
418
  {
284
- slot: this.slot,
419
+ slot: this.targetSlot,
285
420
  checkpoint: checkpoint.header.toInspect(),
286
421
  blocksBuilt: blocksInCheckpoint.length,
287
422
  },
288
423
  );
289
424
  this.metrics.recordCheckpointSuccess();
290
- return checkpoint;
425
+ return {
426
+ checkpoint,
427
+ attestations: CommitteeAttestationsAndSigners.empty(),
428
+ attestationsSignature: Signature.empty(),
429
+ };
291
430
  }
292
431
 
293
- // Include the block pending broadcast in the checkpoint proposal if any
294
- const lastBlock = blockPendingBroadcast && {
295
- blockHeader: blockPendingBroadcast.block.header,
296
- indexWithinCheckpoint: blockPendingBroadcast.block.indexWithinCheckpoint,
297
- txs: blockPendingBroadcast.txs,
298
- };
299
-
300
432
  // Create the checkpoint proposal and broadcast it
301
433
  const proposal = await this.validatorClient.createCheckpointProposal(
302
434
  checkpoint.header,
303
435
  checkpoint.archive.root,
304
436
  feeAssetPriceModifier,
305
- lastBlock,
437
+ blockPendingBroadcast,
306
438
  this.proposer,
307
439
  checkpointProposalOptions,
308
440
  );
@@ -310,7 +442,7 @@ export class CheckpointProposalJob implements Traceable {
310
442
  const blockProposedAt = this.dateProvider.now();
311
443
  await this.p2pClient.broadcastCheckpointProposal(proposal);
312
444
 
313
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.slot);
445
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
314
446
  const attestations = await this.waitForAttestations(proposal);
315
447
  const blockAttestedAt = this.dateProvider.now();
316
448
 
@@ -323,7 +455,7 @@ export class CheckpointProposalJob implements Traceable {
323
455
  attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
324
456
  attestations,
325
457
  signer,
326
- this.slot,
458
+ this.targetSlot,
327
459
  this.checkpointNumber,
328
460
  );
329
461
  } catch (err) {
@@ -335,39 +467,15 @@ export class CheckpointProposalJob implements Traceable {
335
467
  throw err;
336
468
  }
337
469
 
338
- // Enqueue publishing the checkpoint to L1
339
- this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.slot);
340
- const aztecSlotDuration = this.l1Constants.slotDuration;
341
- const slotStartBuildTimestamp = this.getSlotStartBuildTimestamp();
342
- const txTimeoutAt = new Date((slotStartBuildTimestamp + aztecSlotDuration) * 1000);
343
-
344
- // If we have been configured to potentially skip publishing checkpoint then roll the dice here
345
- if (
346
- this.config.skipPublishingCheckpointsPercent !== undefined &&
347
- this.config.skipPublishingCheckpointsPercent > 0
348
- ) {
349
- const result = Math.max(0, randomInt(100));
350
- if (result < this.config.skipPublishingCheckpointsPercent) {
351
- this.log.warn(
352
- `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${result}`,
353
- );
354
- return checkpoint;
355
- }
356
- }
357
-
358
- await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
359
- txTimeoutAt,
360
- forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
361
- });
362
-
363
- return checkpoint;
470
+ // Return the result for the caller to enqueue after the pipeline sleep
471
+ return { checkpoint, attestations, attestationsSignature };
364
472
  } catch (err) {
365
473
  if (err && (err instanceof DutyAlreadySignedError || err instanceof SlashingProtectionError)) {
366
474
  // swallow this error. It's already been logged by a function deeper in the stack
367
475
  return undefined;
368
476
  }
369
477
 
370
- this.log.error(`Error building checkpoint at slot ${this.slot}`, err);
478
+ this.log.error(`Error building checkpoint at slot ${this.targetSlot}`, err);
371
479
  return undefined;
372
480
  }
373
481
  }
@@ -383,17 +491,14 @@ export class CheckpointProposalJob implements Traceable {
383
491
  blockProposalOptions: BlockProposalOptions,
384
492
  ): Promise<{
385
493
  blocksInCheckpoint: L2Block[];
386
- blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined;
494
+ blockPendingBroadcast: BlockProposal | undefined;
387
495
  }> {
388
496
  const blocksInCheckpoint: L2Block[] = [];
389
497
  const txHashesAlreadyIncluded = new Set<string>();
390
498
  const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1);
391
499
 
392
- // Remaining blob fields available for blocks (checkpoint end marker already subtracted)
393
- let remainingBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS;
394
-
395
500
  // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal
396
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
501
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
397
502
 
398
503
  while (true) {
399
504
  const blocksBuilt = blocksInCheckpoint.length;
@@ -405,7 +510,7 @@ export class CheckpointProposalJob implements Traceable {
405
510
 
406
511
  if (!timingInfo.canStart) {
407
512
  this.log.debug(`Not enough time left in slot to start another block`, {
408
- slot: this.slot,
513
+ slot: this.targetSlot,
409
514
  blocksBuilt,
410
515
  secondsIntoSlot,
411
516
  });
@@ -424,25 +529,25 @@ export class CheckpointProposalJob implements Traceable {
424
529
  blockNumber,
425
530
  indexWithinCheckpoint,
426
531
  txHashesAlreadyIncluded,
427
- remainingBlobFields,
428
532
  });
429
533
 
430
- // TODO(palla/mbps): Review these conditions. We may want to keep trying in some scenarios.
431
- if (!buildResult && timingInfo.isLastBlock) {
432
- // If no block was produced due to not enough txs and this was the last subslot, exit
433
- break;
434
- } else if (!buildResult && timingInfo.deadline !== undefined) {
435
- // But if there is still time for more blocks, wait until the next subslot and try again
534
+ // If we failed to build the block due to insufficient txs, we try again if there is still time left in the slot
535
+ if ('failure' in buildResult) {
536
+ // If this was the last subslot, or we're running with a single block per slot, we're done
537
+ if (timingInfo.isLastBlock || timingInfo.deadline === undefined) {
538
+ break;
539
+ }
540
+ // Otherwise, if there is still time for more blocks, we wait until the next subslot and try again
436
541
  await this.waitUntilNextSubslot(timingInfo.deadline);
437
542
  continue;
438
- } else if (!buildResult) {
439
- // Exit if there is no possibility of building more blocks
440
- break;
441
- } else if ('error' in buildResult) {
442
- // If there was an error building the block, just exit the loop and give up the rest of the slot
543
+ }
544
+
545
+ // If there was an error building the block, we just exit the loop and give up the rest of the slot.
546
+ // We don't want to risk building more blocks if something went wrong.
547
+ if ('error' in buildResult) {
443
548
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
444
- this.log.warn(`Halting block building for slot ${this.slot}`, {
445
- slot: this.slot,
549
+ this.log.warn(`Halting block building for slot ${this.targetSlot}`, {
550
+ slot: this.targetSlot,
446
551
  blocksBuilt,
447
552
  error: buildResult.error,
448
553
  });
@@ -450,65 +555,74 @@ export class CheckpointProposalJob implements Traceable {
450
555
  break;
451
556
  }
452
557
 
453
- const { block, usedTxs, remainingBlobFields: newRemainingBlobFields } = buildResult;
558
+ const { block, usedTxs } = buildResult;
454
559
  blocksInCheckpoint.push(block);
560
+ usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
455
561
 
456
- // Update remaining blob fields for the next block
457
- remainingBlobFields = newRemainingBlobFields;
458
-
459
- // Sync the proposed block to the archiver to make it available
460
- // Note that the checkpoint builder uses its own fork so it should not need to wait for this syncing
461
- // Eventually we should refactor the checkpoint builder to not need a separate long-lived fork
462
- // Fire and forget - don't block the critical path, but log errors
463
- this.syncProposedBlockToArchiver(block).catch(err => {
464
- this.log.error(`Failed to sync proposed block ${block.number} to archiver`, { blockNumber: block.number, err });
465
- });
562
+ // Sign the block proposal. This will throw if HA signing fails.
563
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
466
564
 
467
- usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
565
+ // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
566
+ // so we avoid polluting our archive with a block that would fail.
567
+ // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
568
+ // If this throws, we abort the entire checkpoint.
569
+ await this.syncProposedBlockToArchiver(block);
468
570
 
469
- // If this is the last block, exit the loop now so we start collecting attestations
571
+ // If this is the last block, do not broadcast it, since it will be included in the checkpoint proposal.
470
572
  if (timingInfo.isLastBlock) {
471
- this.log.verbose(`Completed final block ${blockNumber} for slot ${this.slot}`, {
472
- slot: this.slot,
573
+ this.log.verbose(`Completed final block ${blockNumber} for slot ${this.targetSlot}`, {
574
+ slot: this.targetSlot,
473
575
  blockNumber,
474
576
  blocksBuilt,
475
577
  });
476
- blockPendingBroadcast = { block, txs: usedTxs };
578
+ blockPendingBroadcast = proposal;
477
579
  break;
478
580
  }
479
581
 
480
- // For non-last blocks, broadcast the block proposal (unless we're in fisherman mode)
481
- // If the block is the last one, we'll broadcast it along with the checkpoint at the end of the loop
482
- if (!this.config.fishermanMode) {
483
- const proposal = await this.validatorClient.createBlockProposal(
484
- block.header,
485
- block.indexWithinCheckpoint,
486
- inHash,
487
- block.archive.root,
488
- usedTxs,
489
- this.proposer,
490
- blockProposalOptions,
491
- );
492
- await this.p2pClient.broadcastProposal(proposal);
493
- }
582
+ // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
583
+ proposal && (await this.p2pClient.broadcastProposal(proposal));
494
584
 
495
585
  // Wait until the next block's start time
496
586
  await this.waitUntilNextSubslot(timingInfo.deadline);
497
587
  }
498
588
 
499
- this.log.verbose(`Block building loop completed for slot ${this.slot}`, {
500
- slot: this.slot,
589
+ this.log.verbose(`Block building loop completed for slot ${this.targetSlot}`, {
590
+ slot: this.targetSlot,
501
591
  blocksBuilt: blocksInCheckpoint.length,
502
592
  });
503
593
 
504
594
  return { blocksInCheckpoint, blockPendingBroadcast };
505
595
  }
506
596
 
597
+ /** Creates a block proposal for a given block via the validator client (unless in fisherman mode) */
598
+ private createBlockProposal(
599
+ block: L2Block,
600
+ inHash: Fr,
601
+ usedTxs: Tx[],
602
+ blockProposalOptions: BlockProposalOptions,
603
+ ): Promise<BlockProposal | undefined> {
604
+ if (this.config.fishermanMode) {
605
+ this.log.info(`Skipping block proposal for block ${block.number} in fisherman mode`);
606
+ return Promise.resolve(undefined);
607
+ }
608
+ return this.validatorClient.createBlockProposal(
609
+ block.header,
610
+ block.indexWithinCheckpoint,
611
+ inHash,
612
+ block.archive.root,
613
+ usedTxs,
614
+ this.proposer,
615
+ blockProposalOptions,
616
+ );
617
+ }
618
+
507
619
  /** Sleeps until it is time to produce the next block in the slot */
508
620
  @trackSpan('CheckpointProposalJob.waitUntilNextSubslot')
509
621
  private async waitUntilNextSubslot(nextSubslotStart: number) {
510
- this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.slot);
511
- this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, { slot: this.slot });
622
+ this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.targetSlot);
623
+ this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, {
624
+ slot: this.targetSlot,
625
+ });
512
626
  await this.waitUntilTimeInSlot(nextSubslotStart);
513
627
  }
514
628
 
@@ -523,36 +637,29 @@ export class CheckpointProposalJob implements Traceable {
523
637
  indexWithinCheckpoint: IndexWithinCheckpoint;
524
638
  buildDeadline: Date | undefined;
525
639
  txHashesAlreadyIncluded: Set<string>;
526
- remainingBlobFields: number;
527
640
  },
528
- ): Promise<{ block: L2Block; usedTxs: Tx[]; remainingBlobFields: number } | { error: Error } | undefined> {
529
- const {
530
- blockTimestamp,
531
- forceCreate,
532
- blockNumber,
533
- indexWithinCheckpoint,
534
- buildDeadline,
535
- txHashesAlreadyIncluded,
536
- remainingBlobFields,
537
- } = opts;
641
+ ): Promise<
642
+ { block: L2Block; usedTxs: Tx[] } | { failure: 'insufficient-txs' | 'insufficient-valid-txs' } | { error: Error }
643
+ > {
644
+ const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } =
645
+ opts;
538
646
 
539
647
  this.log.verbose(
540
- `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.slot}`,
648
+ `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.targetSlot}`,
541
649
  { ...checkpointBuilder.getConstantData(), ...opts },
542
650
  );
543
651
 
544
652
  try {
545
653
  // Wait until we have enough txs to build the block
546
- const minTxs = this.config.minTxsPerBlock;
547
- const { availableTxs, canStartBuilding } = await this.waitForMinTxs(opts);
654
+ const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
548
655
  if (!canStartBuilding) {
549
656
  this.log.warn(
550
- `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (got ${availableTxs} txs but needs ${minTxs})`,
551
- { blockNumber, slot: this.slot, indexWithinCheckpoint },
657
+ `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
658
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
552
659
  );
553
- this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.slot });
660
+ this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
554
661
  this.metrics.recordBlockProposalFailed('insufficient_txs');
555
- return undefined;
662
+ return { failure: 'insufficient-txs' };
556
663
  }
557
664
 
558
665
  // Create iterator to pending txs. We filter out txs already included in previous blocks in the checkpoint
@@ -563,24 +670,31 @@ export class CheckpointProposalJob implements Traceable {
563
670
  );
564
671
 
565
672
  this.log.debug(
566
- `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.slot} with ${availableTxs} available txs`,
567
- { slot: this.slot, blockNumber, indexWithinCheckpoint },
673
+ `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot} with ${availableTxs} available txs`,
674
+ { slot: this.targetSlot, blockNumber, indexWithinCheckpoint },
568
675
  );
569
- this.setStateFn(SequencerState.CREATING_BLOCK, this.slot);
570
-
571
- // Calculate blob fields limit for txs (remaining capacity - this block's end overhead)
572
- const blockEndOverhead = getNumBlockEndBlobFields(indexWithinCheckpoint === 0);
573
- const maxBlobFieldsForTxs = remainingBlobFields - blockEndOverhead;
676
+ this.setStateFn(SequencerState.CREATING_BLOCK, this.targetSlot);
574
677
 
575
- const blockBuilderOptions: PublicProcessorLimits = {
678
+ // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
679
+ // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built.
680
+ // minValidTxs is passed into the builder so it can reject the block *before* updating state.
681
+ const minValidTxs = forceCreate ? 0 : (this.config.minValidTxsPerBlock ?? minTxs);
682
+ const blockBuilderOptions: BlockBuilderOptions = {
576
683
  maxTransactions: this.config.maxTxsPerBlock,
577
- maxBlockSize: this.config.maxBlockSizeInBytes,
578
- maxBlockGas: new Gas(this.config.maxDABlockGas, this.config.maxL2BlockGas),
579
- maxBlobFields: maxBlobFieldsForTxs,
684
+ maxBlockGas:
685
+ this.config.maxL2BlockGas !== undefined || this.config.maxDABlockGas !== undefined
686
+ ? new Gas(this.config.maxDABlockGas ?? Infinity, this.config.maxL2BlockGas ?? Infinity)
687
+ : undefined,
580
688
  deadline: buildDeadline,
689
+ isBuildingProposal: true,
690
+ minValidTxs,
691
+ maxBlocksPerCheckpoint: this.timetable.maxNumberOfBlocks,
692
+ perBlockAllocationMultiplier: this.config.perBlockAllocationMultiplier,
581
693
  };
582
694
 
583
- // Actually build the block by executing txs
695
+ // Actually build the block by executing txs. The builder throws InsufficientValidTxsError
696
+ // if the number of successfully processed txs is below minValidTxs, ensuring state is not
697
+ // updated for blocks that will be discarded.
584
698
  const buildResult = await this.buildSingleBlockWithCheckpointBuilder(
585
699
  checkpointBuilder,
586
700
  pendingTxs,
@@ -592,22 +706,27 @@ export class CheckpointProposalJob implements Traceable {
592
706
  // If any txs failed during execution, drop them from the mempool so we don't pick them up again
593
707
  await this.dropFailedTxsFromP2P(buildResult.failedTxs);
594
708
 
595
- // Check if we have created a block with enough txs. If there were invalid txs in the pool, or if execution took
596
- // too long, then we may not get to minTxsPerBlock after executing public functions.
597
- const minValidTxs = this.config.minValidTxsPerBlock ?? minTxs;
598
- const numTxs = buildResult.status === 'no-valid-txs' ? 0 : buildResult.numTxs;
599
- if (buildResult.status === 'no-valid-txs' || (!forceCreate && numTxs < minValidTxs)) {
709
+ if (buildResult.status === 'insufficient-valid-txs') {
600
710
  this.log.warn(
601
- `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.slot} has too few valid txs to be proposed`,
602
- { slot: this.slot, blockNumber, numTxs, indexWithinCheckpoint, minValidTxs, buildResult: buildResult.status },
711
+ `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.targetSlot} has too few valid txs to be proposed`,
712
+ {
713
+ slot: this.targetSlot,
714
+ blockNumber,
715
+ numTxs: buildResult.processedCount,
716
+ indexWithinCheckpoint,
717
+ minValidTxs,
718
+ },
603
719
  );
604
- this.eventEmitter.emit('block-build-failed', { reason: `Insufficient valid txs`, slot: this.slot });
720
+ this.eventEmitter.emit('block-build-failed', {
721
+ reason: `Insufficient valid txs`,
722
+ slot: this.targetSlot,
723
+ });
605
724
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
606
- return undefined;
725
+ return { failure: 'insufficient-valid-txs' };
607
726
  }
608
727
 
609
728
  // Block creation succeeded, emit stats and metrics
610
- const { publicGas, block, publicProcessorDuration, usedTxs, usedTxBlobFields, blockBuildDuration } = buildResult;
729
+ const { block, publicProcessorDuration, usedTxs, blockBuildDuration, numTxs } = buildResult;
611
730
 
612
731
  const blockStats = {
613
732
  eventName: 'l2-block-built',
@@ -618,33 +737,42 @@ export class CheckpointProposalJob implements Traceable {
618
737
 
619
738
  const blockHash = await block.hash();
620
739
  const txHashes = block.body.txEffects.map(tx => tx.txHash);
621
- const manaPerSec = publicGas.l2Gas / (blockBuildDuration / 1000);
740
+ const manaPerSec = block.header.totalManaUsed.toNumberUnsafe() / (blockBuildDuration / 1000);
622
741
 
623
742
  this.log.info(
624
- `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.slot} with ${numTxs} txs`,
743
+ `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.targetSlot} with ${numTxs} txs`,
625
744
  { blockHash, txHashes, manaPerSec, ...blockStats },
626
745
  );
627
746
 
628
- this.eventEmitter.emit('block-proposed', { blockNumber: block.number, slot: this.slot });
629
- this.metrics.recordBuiltBlock(blockBuildDuration, publicGas.l2Gas);
747
+ // `slot` is the target/submission slot (may be one ahead when pipelining),
748
+ // `buildSlot` is the wall-clock slot during which the block was actually built.
749
+ this.eventEmitter.emit('block-proposed', {
750
+ blockNumber: block.number,
751
+ slot: this.targetSlot,
752
+ buildSlot: this.slotNow,
753
+ });
754
+ this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe());
630
755
 
631
- return { block, usedTxs, remainingBlobFields: maxBlobFieldsForTxs - usedTxBlobFields };
756
+ return { block, usedTxs };
632
757
  } catch (err: any) {
633
- this.eventEmitter.emit('block-build-failed', { reason: err.message, slot: this.slot });
634
- this.log.error(`Error building block`, err, { blockNumber, slot: this.slot });
758
+ this.eventEmitter.emit('block-build-failed', {
759
+ reason: err.message,
760
+ slot: this.targetSlot,
761
+ });
762
+ this.log.error(`Error building block`, err, { blockNumber, slot: this.targetSlot });
635
763
  this.metrics.recordBlockProposalFailed(err.name || 'unknown_error');
636
764
  this.metrics.recordFailedBlock();
637
765
  return { error: err };
638
766
  }
639
767
  }
640
768
 
641
- /** Uses the checkpoint builder to build a block, catching specific txs */
769
+ /** Uses the checkpoint builder to build a block, catching InsufficientValidTxsError. */
642
770
  private async buildSingleBlockWithCheckpointBuilder(
643
771
  checkpointBuilder: CheckpointBuilder,
644
772
  pendingTxs: AsyncIterable<Tx>,
645
773
  blockNumber: BlockNumber,
646
774
  blockTimestamp: bigint,
647
- blockBuilderOptions: PublicProcessorLimits,
775
+ blockBuilderOptions: BlockBuilderOptions,
648
776
  ) {
649
777
  try {
650
778
  const workTimer = new Timer();
@@ -652,8 +780,12 @@ export class CheckpointProposalJob implements Traceable {
652
780
  const blockBuildDuration = workTimer.ms();
653
781
  return { ...result, blockBuildDuration, status: 'success' as const };
654
782
  } catch (err: unknown) {
655
- if (isErrorClass(err, NoValidTxsError)) {
656
- return { failedTxs: err.failedTxs, status: 'no-valid-txs' as const };
783
+ if (isErrorClass(err, InsufficientValidTxsError)) {
784
+ return {
785
+ failedTxs: err.failedTxs,
786
+ processedCount: err.processedCount,
787
+ status: 'insufficient-valid-txs' as const,
788
+ };
657
789
  }
658
790
  throw err;
659
791
  }
@@ -666,7 +798,7 @@ export class CheckpointProposalJob implements Traceable {
666
798
  blockNumber: BlockNumber;
667
799
  indexWithinCheckpoint: IndexWithinCheckpoint;
668
800
  buildDeadline: Date | undefined;
669
- }): Promise<{ canStartBuilding: boolean; availableTxs: number }> {
801
+ }): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
670
802
  const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
671
803
 
672
804
  // We only allow a block with 0 txs in the first block of the checkpoint
@@ -683,20 +815,20 @@ export class CheckpointProposalJob implements Traceable {
683
815
  // If we're past deadline, or we have no deadline, give up
684
816
  const now = this.dateProvider.nowAsDate();
685
817
  if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
686
- return { canStartBuilding: false, availableTxs: availableTxs };
818
+ return { canStartBuilding: false, availableTxs, minTxs };
687
819
  }
688
820
 
689
821
  // Wait a bit before checking again
690
- this.setStateFn(SequencerState.WAITING_FOR_TXS, this.slot);
822
+ this.setStateFn(SequencerState.WAITING_FOR_TXS, this.targetSlot);
691
823
  this.log.verbose(
692
- `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (have ${availableTxs} but need ${minTxs})`,
693
- { blockNumber, slot: this.slot, indexWithinCheckpoint },
824
+ `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
825
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
694
826
  );
695
827
  await this.waitForTxsPollingInterval();
696
828
  availableTxs = await this.p2pClient.getPendingTxCount();
697
829
  }
698
830
 
699
- return { canStartBuilding: true, availableTxs };
831
+ return { canStartBuilding: true, availableTxs, minTxs };
700
832
  }
701
833
 
702
834
  /**
@@ -722,7 +854,7 @@ export class CheckpointProposalJob implements Traceable {
722
854
  this.log.debug(`Attesting committee length is ${committee.length}`, { committee });
723
855
  }
724
856
 
725
- const numberOfRequiredAttestations = Math.floor((committee.length * 2) / 3) + 1;
857
+ const numberOfRequiredAttestations = computeQuorum(committee.length);
726
858
 
727
859
  if (this.config.skipCollectingAttestations) {
728
860
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
@@ -870,9 +1002,13 @@ export class CheckpointProposalJob implements Traceable {
870
1002
  * Adds the proposed block to the archiver so it's available via P2P.
871
1003
  * Gossip doesn't echo messages back to the sender, so the proposer's archiver/world-state
872
1004
  * would never receive its own block without this explicit sync.
1005
+ *
1006
+ * In fisherman mode we skip this push: the fisherman builds blocks locally for validation
1007
+ * and fee analysis only, and pushing them to the archiver causes spurious reorg cascades
1008
+ * whenever the real proposer's block arrives from L1.
873
1009
  */
874
1010
  private async syncProposedBlockToArchiver(block: L2Block): Promise<void> {
875
- if (this.config.skipPushProposedBlocksToArchiver !== false) {
1011
+ if (this.config.skipPushProposedBlocksToArchiver || this.config.fishermanMode) {
876
1012
  this.log.warn(`Skipping push of proposed block ${block.number} to archiver`, {
877
1013
  blockNumber: block.number,
878
1014
  slot: block.header.globalVariables.slotNumber,
@@ -890,19 +1026,19 @@ export class CheckpointProposalJob implements Traceable {
890
1026
  private async handleCheckpointEndAsFisherman(checkpoint: Checkpoint | undefined) {
891
1027
  // Perform L1 fee analysis before clearing requests
892
1028
  // The callback is invoked asynchronously after the next block is mined
893
- const feeAnalysis = await this.publisher.analyzeL1Fees(this.slot, analysis =>
1029
+ const feeAnalysis = await this.publisher.analyzeL1Fees(this.targetSlot, analysis =>
894
1030
  this.metrics.recordFishermanFeeAnalysis(analysis),
895
1031
  );
896
1032
 
897
1033
  if (checkpoint) {
898
- this.log.info(`Validation checkpoint building SUCCEEDED for slot ${this.slot}`, {
1034
+ this.log.info(`Validation checkpoint building SUCCEEDED for slot ${this.targetSlot}`, {
899
1035
  ...checkpoint.toCheckpointInfo(),
900
1036
  ...checkpoint.getStats(),
901
1037
  feeAnalysisId: feeAnalysis?.id,
902
1038
  });
903
1039
  } else {
904
- this.log.warn(`Validation block building FAILED for slot ${this.slot}`, {
905
- slot: this.slot,
1040
+ this.log.warn(`Validation block building FAILED for slot ${this.targetSlot}`, {
1041
+ slot: this.targetSlot,
906
1042
  feeAnalysisId: feeAnalysis?.id,
907
1043
  });
908
1044
  this.metrics.recordCheckpointProposalFailed('block_build_failed');
@@ -916,15 +1052,15 @@ export class CheckpointProposalJob implements Traceable {
916
1052
  */
917
1053
  private handleHASigningError(err: any, errorContext: string): boolean {
918
1054
  if (err instanceof DutyAlreadySignedError) {
919
- this.log.info(`${errorContext} for slot ${this.slot} already signed by another HA node, yielding`, {
920
- slot: this.slot,
1055
+ this.log.info(`${errorContext} for slot ${this.targetSlot} already signed by another HA node, yielding`, {
1056
+ slot: this.targetSlot,
921
1057
  signedByNode: err.signedByNode,
922
1058
  });
923
1059
  return true;
924
1060
  }
925
1061
  if (err instanceof SlashingProtectionError) {
926
- this.log.info(`${errorContext} for slot ${this.slot} blocked by slashing protection, yielding`, {
927
- slot: this.slot,
1062
+ this.log.info(`${errorContext} for slot ${this.targetSlot} blocked by slashing protection, yielding`, {
1063
+ slot: this.targetSlot,
928
1064
  existingMessageHash: err.existingMessageHash,
929
1065
  attemptedMessageHash: err.attemptedMessageHash,
930
1066
  });
@@ -933,6 +1069,56 @@ export class CheckpointProposalJob implements Traceable {
933
1069
  return false;
934
1070
  }
935
1071
 
1072
+ /**
1073
+ * In times of congestion we need to simulate using the correct fee header override for the previous block
1074
+ * We calculate the correct fee header values.
1075
+ *
1076
+ * If we are in block 1, or the checkpoint we are querying does not exist, we return undefined. However
1077
+ * If we are pipelining - where this function is called, the grandparentCheckpointNumber should always exist
1078
+ * @param parentCheckpointNumber
1079
+ * @returns
1080
+ */
1081
+ protected async computeForceProposedFeeHeader(parentCheckpointNumber: CheckpointNumber): Promise<
1082
+ | {
1083
+ checkpointNumber: CheckpointNumber;
1084
+ feeHeader: FeeHeader;
1085
+ }
1086
+ | undefined
1087
+ > {
1088
+ if (!this.proposedCheckpointData) {
1089
+ return undefined;
1090
+ }
1091
+
1092
+ const rollup = this.publisher.rollupContract;
1093
+ const grandparentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 2);
1094
+ try {
1095
+ const [grandparentCheckpoint, manaTarget] = await Promise.all([
1096
+ rollup.getCheckpoint(grandparentCheckpointNumber),
1097
+ rollup.getManaTarget(),
1098
+ ]);
1099
+
1100
+ if (!grandparentCheckpoint || !grandparentCheckpoint.feeHeader) {
1101
+ this.log.error(
1102
+ `Grandparent checkpoint or its feeHeader is undefined for checkpointNumber=${grandparentCheckpointNumber.toString()}`,
1103
+ );
1104
+ return undefined;
1105
+ } else {
1106
+ const parentFeeHeader = RollupContract.computeChildFeeHeader(
1107
+ grandparentCheckpoint.feeHeader,
1108
+ this.proposedCheckpointData.totalManaUsed,
1109
+ this.proposedCheckpointData.feeAssetPriceModifier,
1110
+ manaTarget,
1111
+ );
1112
+ return { checkpointNumber: parentCheckpointNumber, feeHeader: parentFeeHeader };
1113
+ }
1114
+ } catch (err) {
1115
+ this.log.error(
1116
+ `Failed to fetch grandparent checkpoint or mana target for checkpointNumber=${grandparentCheckpointNumber.toString()}: ${err}`,
1117
+ );
1118
+ return undefined;
1119
+ }
1120
+ }
1121
+
936
1122
  /** Waits until a specific time within the current slot */
937
1123
  @trackSpan('CheckpointProposalJob.waitUntilTimeInSlot')
938
1124
  protected async waitUntilTimeInSlot(targetSecondsIntoSlot: number): Promise<void> {
@@ -947,7 +1133,7 @@ export class CheckpointProposalJob implements Traceable {
947
1133
  }
948
1134
 
949
1135
  private getSlotStartBuildTimestamp(): number {
950
- return getSlotStartBuildTimestamp(this.slot, this.l1Constants);
1136
+ return getSlotStartBuildTimestamp(this.slotNow, this.l1Constants);
951
1137
  }
952
1138
 
953
1139
  private getSecondsIntoSlot(): number {