@aztec/sequencer-client 0.0.1-commit.cb6bed7c2 → 0.0.1-commit.cbf2c2d5d

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 (49) hide show
  1. package/dest/client/sequencer-client.d.ts +4 -12
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +27 -76
  4. package/dest/config.d.ts +4 -3
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +9 -2
  7. package/dest/global_variable_builder/global_builder.d.ts +13 -7
  8. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  9. package/dest/global_variable_builder/global_builder.js +22 -21
  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 -3
  16. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  17. package/dest/publisher/sequencer-publisher-factory.js +2 -2
  18. package/dest/publisher/sequencer-publisher.d.ts +9 -4
  19. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  20. package/dest/publisher/sequencer-publisher.js +33 -9
  21. package/dest/sequencer/checkpoint_proposal_job.d.ts +12 -4
  22. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  23. package/dest/sequencer/checkpoint_proposal_job.js +142 -97
  24. package/dest/sequencer/events.d.ts +2 -1
  25. package/dest/sequencer/events.d.ts.map +1 -1
  26. package/dest/sequencer/metrics.d.ts +5 -1
  27. package/dest/sequencer/metrics.d.ts.map +1 -1
  28. package/dest/sequencer/metrics.js +11 -0
  29. package/dest/sequencer/sequencer.d.ts +7 -5
  30. package/dest/sequencer/sequencer.d.ts.map +1 -1
  31. package/dest/sequencer/sequencer.js +71 -61
  32. package/dest/sequencer/types.d.ts +2 -5
  33. package/dest/sequencer/types.d.ts.map +1 -1
  34. package/dest/test/mock_checkpoint_builder.d.ts +4 -4
  35. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  36. package/package.json +27 -28
  37. package/src/client/sequencer-client.ts +37 -101
  38. package/src/config.ts +12 -1
  39. package/src/global_variable_builder/global_builder.ts +22 -23
  40. package/src/global_variable_builder/index.ts +1 -1
  41. package/src/publisher/config.ts +32 -0
  42. package/src/publisher/sequencer-publisher-factory.ts +3 -3
  43. package/src/publisher/sequencer-publisher.ts +39 -11
  44. package/src/sequencer/checkpoint_proposal_job.ts +190 -101
  45. package/src/sequencer/events.ts +1 -1
  46. package/src/sequencer/metrics.ts +14 -0
  47. package/src/sequencer/sequencer.ts +97 -68
  48. package/src/sequencer/types.ts +2 -5
  49. package/src/test/mock_checkpoint_builder.ts +3 -3
@@ -31,16 +31,21 @@ import {
31
31
  MaliciousCommitteeAttestationsAndSigners,
32
32
  } from '@aztec/stdlib/block';
33
33
  import { type Checkpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint';
34
- import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
34
+ import { getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
35
35
  import { Gas } from '@aztec/stdlib/gas';
36
36
  import {
37
- NoValidTxsError,
38
- type PublicProcessorLimits,
37
+ type BlockBuilderOptions,
38
+ InsufficientValidTxsError,
39
39
  type ResolvedSequencerConfig,
40
40
  type WorldStateSynchronizer,
41
41
  } from '@aztec/stdlib/interfaces/server';
42
42
  import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
43
- import type { BlockProposalOptions, CheckpointProposal, CheckpointProposalOptions } from '@aztec/stdlib/p2p';
43
+ import type {
44
+ BlockProposal,
45
+ BlockProposalOptions,
46
+ CheckpointProposal,
47
+ CheckpointProposalOptions,
48
+ } from '@aztec/stdlib/p2p';
44
49
  import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p';
45
50
  import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
46
51
  import { type FailedTx, Tx } from '@aztec/stdlib/tx';
@@ -72,8 +77,10 @@ export class CheckpointProposalJob implements Traceable {
72
77
  protected readonly log: Logger;
73
78
 
74
79
  constructor(
75
- private readonly epoch: EpochNumber,
76
- private readonly slot: SlotNumber,
80
+ private readonly slotNow: SlotNumber,
81
+ private readonly targetSlot: SlotNumber,
82
+ private readonly epochNow: EpochNumber,
83
+ private readonly targetEpoch: EpochNumber,
77
84
  private readonly checkpointNumber: CheckpointNumber,
78
85
  private readonly syncedToBlockNumber: BlockNumber,
79
86
  // TODO(palla/mbps): Can we remove the proposer in favor of attestorAddress? Need to check fisherman-node flows.
@@ -101,7 +108,20 @@ export class CheckpointProposalJob implements Traceable {
101
108
  public readonly tracer: Tracer,
102
109
  bindings?: LoggerBindings,
103
110
  ) {
104
- this.log = createLogger('sequencer:checkpoint-proposal', { ...bindings, instanceId: `slot-${slot}` });
111
+ this.log = createLogger('sequencer:checkpoint-proposal', {
112
+ ...bindings,
113
+ instanceId: `slot-${this.slotNow}`,
114
+ });
115
+ }
116
+
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;
105
125
  }
106
126
 
107
127
  /**
@@ -114,7 +134,7 @@ export class CheckpointProposalJob implements Traceable {
114
134
  // In fisherman mode, we simulate slashing but don't actually publish to L1
115
135
  // These are constant for the whole slot, so we only enqueue them once
116
136
  const votesPromises = new CheckpointVoter(
117
- this.slot,
137
+ this.targetSlot,
118
138
  this.publisher,
119
139
  this.attestorAddress,
120
140
  this.validatorClient,
@@ -141,6 +161,29 @@ export class CheckpointProposalJob implements Traceable {
141
161
  return;
142
162
  }
143
163
 
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());
174
+
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}`,
182
+ );
183
+ return undefined;
184
+ }
185
+ }
186
+
144
187
  // Then send everything to L1
145
188
  const l1Response = await this.publisher.sendRequests();
146
189
  const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
@@ -159,7 +202,7 @@ export class CheckpointProposalJob implements Traceable {
159
202
  return {
160
203
  // nullish operator needed for tests
161
204
  [Attributes.COINBASE]: this.validatorClient.getCoinbaseForAttestor(this.attestorAddress)?.toString(),
162
- [Attributes.SLOT_NUMBER]: this.slot,
205
+ [Attributes.SLOT_NUMBER]: this.targetSlot,
163
206
  };
164
207
  })
165
208
  private async proposeCheckpoint(): Promise<Checkpoint | undefined> {
@@ -169,8 +212,15 @@ export class CheckpointProposalJob implements Traceable {
169
212
  const feeRecipient = this.validatorClient.getFeeRecipientForAttestor(this.attestorAddress);
170
213
 
171
214
  // Start the checkpoint
172
- this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.slot);
173
- this.metrics.incOpenSlot(this.slot, this.proposer?.toString() ?? 'unknown');
215
+ this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
216
+ this.log.info(`Starting checkpoint proposal`, {
217
+ buildSlot: this.slot,
218
+ submissionSlot: this.targetSlot,
219
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
220
+ proposer: this.proposer?.toString(),
221
+ coinbase: coinbase.toString(),
222
+ });
223
+ this.metrics.incOpenSlot(this.targetSlot, this.proposer?.toString() ?? 'unknown');
174
224
 
175
225
  // Enqueues checkpoint invalidation (constant for the whole slot)
176
226
  if (this.invalidateCheckpoint && !this.config.skipInvalidateBlockAsProposer) {
@@ -181,7 +231,7 @@ export class CheckpointProposalJob implements Traceable {
181
231
  const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables(
182
232
  coinbase,
183
233
  feeRecipient,
184
- this.slot,
234
+ this.targetSlot,
185
235
  );
186
236
 
187
237
  // Collect L1 to L2 messages for the checkpoint and compute their hash
@@ -189,7 +239,7 @@ export class CheckpointProposalJob implements Traceable {
189
239
  const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
190
240
 
191
241
  // Collect the out hashes of all the checkpoints before this one in the same epoch
192
- const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.epoch))
242
+ const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.targetEpoch))
193
243
  .filter(c => c.checkpointNumber < this.checkpointNumber)
194
244
  .map(c => c.checkpointOutHash);
195
245
 
@@ -246,8 +296,8 @@ export class CheckpointProposalJob implements Traceable {
246
296
  }
247
297
 
248
298
  if (blocksInCheckpoint.length === 0) {
249
- this.log.warn(`No blocks were built for slot ${this.slot}`, { slot: this.slot });
250
- this.eventEmitter.emit('checkpoint-empty', { slot: this.slot });
299
+ this.log.warn(`No blocks were built for slot ${this.targetSlot}`, { slot: this.targetSlot });
300
+ this.eventEmitter.emit('checkpoint-empty', { slot: this.targetSlot });
251
301
  return undefined;
252
302
  }
253
303
 
@@ -255,17 +305,18 @@ export class CheckpointProposalJob implements Traceable {
255
305
  if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
256
306
  this.log.warn(
257
307
  `Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`,
258
- { slot: this.slot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
308
+ { slot: this.targetSlot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
259
309
  );
260
310
  return undefined;
261
311
  }
262
312
 
263
313
  // Assemble and broadcast the checkpoint proposal, including the last block that was not
264
314
  // broadcasted yet, and wait to collect the committee attestations.
265
- this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.slot);
315
+ this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.targetSlot);
266
316
  const checkpoint = await checkpointBuilder.completeCheckpoint();
267
317
 
268
- // Final validation round for the checkpoint before we propose it, just for safety
318
+ // Final validation: per-block limits are only checked if the operator set them explicitly.
319
+ // Otherwise, checkpoint-level budgets were already enforced by the redistribution logic.
269
320
  try {
270
321
  validateCheckpoint(checkpoint, {
271
322
  rollupManaLimit: this.l1Constants.rollupManaLimit,
@@ -292,10 +343,10 @@ export class CheckpointProposalJob implements Traceable {
292
343
  // Do not collect attestations nor publish to L1 in fisherman mode
293
344
  if (this.config.fishermanMode) {
294
345
  this.log.info(
295
- `Built checkpoint for slot ${this.slot} with ${blocksInCheckpoint.length} blocks. ` +
346
+ `Built checkpoint for slot ${this.targetSlot} with ${blocksInCheckpoint.length} blocks. ` +
296
347
  `Skipping proposal in fisherman mode.`,
297
348
  {
298
- slot: this.slot,
349
+ slot: this.targetSlot,
299
350
  checkpoint: checkpoint.header.toInspect(),
300
351
  blocksBuilt: blocksInCheckpoint.length,
301
352
  },
@@ -324,7 +375,7 @@ export class CheckpointProposalJob implements Traceable {
324
375
  const blockProposedAt = this.dateProvider.now();
325
376
  await this.p2pClient.broadcastCheckpointProposal(proposal);
326
377
 
327
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.slot);
378
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
328
379
  const attestations = await this.waitForAttestations(proposal);
329
380
  const blockAttestedAt = this.dateProvider.now();
330
381
 
@@ -337,7 +388,7 @@ export class CheckpointProposalJob implements Traceable {
337
388
  attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
338
389
  attestations,
339
390
  signer,
340
- this.slot,
391
+ this.targetSlot,
341
392
  this.checkpointNumber,
342
393
  );
343
394
  } catch (err) {
@@ -350,10 +401,10 @@ export class CheckpointProposalJob implements Traceable {
350
401
  }
351
402
 
352
403
  // Enqueue publishing the checkpoint to L1
353
- this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.slot);
404
+ this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot);
354
405
  const aztecSlotDuration = this.l1Constants.slotDuration;
355
- const slotStartBuildTimestamp = this.getSlotStartBuildTimestamp();
356
- const txTimeoutAt = new Date((slotStartBuildTimestamp + aztecSlotDuration) * 1000);
406
+ const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
407
+ const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000);
357
408
 
358
409
  // If we have been configured to potentially skip publishing checkpoint then roll the dice here
359
410
  if (
@@ -416,7 +467,7 @@ export class CheckpointProposalJob implements Traceable {
416
467
 
417
468
  if (!timingInfo.canStart) {
418
469
  this.log.debug(`Not enough time left in slot to start another block`, {
419
- slot: this.slot,
470
+ slot: this.targetSlot,
420
471
  blocksBuilt,
421
472
  secondsIntoSlot,
422
473
  });
@@ -451,8 +502,8 @@ export class CheckpointProposalJob implements Traceable {
451
502
  } else if ('error' in buildResult) {
452
503
  // If there was an error building the block, just exit the loop and give up the rest of the slot
453
504
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
454
- this.log.warn(`Halting block building for slot ${this.slot}`, {
455
- slot: this.slot,
505
+ this.log.warn(`Halting block building for slot ${this.targetSlot}`, {
506
+ slot: this.targetSlot,
456
507
  blocksBuilt,
457
508
  error: buildResult.error,
458
509
  });
@@ -462,21 +513,14 @@ export class CheckpointProposalJob implements Traceable {
462
513
 
463
514
  const { block, usedTxs } = buildResult;
464
515
  blocksInCheckpoint.push(block);
465
-
466
- // Sync the proposed block to the archiver to make it available
467
- // Note that the checkpoint builder uses its own fork so it should not need to wait for this syncing
468
- // Eventually we should refactor the checkpoint builder to not need a separate long-lived fork
469
- // Fire and forget - don't block the critical path, but log errors
470
- this.syncProposedBlockToArchiver(block).catch(err => {
471
- this.log.error(`Failed to sync proposed block ${block.number} to archiver`, { blockNumber: block.number, err });
472
- });
473
-
474
516
  usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
475
517
 
476
- // If this is the last block, exit the loop now so we start collecting attestations
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.
477
520
  if (timingInfo.isLastBlock) {
478
- this.log.verbose(`Completed final block ${blockNumber} for slot ${this.slot}`, {
479
- slot: this.slot,
521
+ await this.syncProposedBlockToArchiver(block);
522
+ this.log.verbose(`Completed final block ${blockNumber} for slot ${this.targetSlot}`, {
523
+ slot: this.targetSlot,
480
524
  blockNumber,
481
525
  blocksBuilt,
482
526
  });
@@ -484,38 +528,61 @@ export class CheckpointProposalJob implements Traceable {
484
528
  break;
485
529
  }
486
530
 
487
- // For non-last blocks, broadcast the block proposal (unless we're in fisherman mode)
488
- // If the block is the last one, we'll broadcast it along with the checkpoint at the end of the loop
489
- if (!this.config.fishermanMode) {
490
- const proposal = await this.validatorClient.createBlockProposal(
491
- block.header,
492
- block.indexWithinCheckpoint,
493
- inHash,
494
- block.archive.root,
495
- usedTxs,
496
- this.proposer,
497
- blockProposalOptions,
498
- );
499
- await this.p2pClient.broadcastProposal(proposal);
500
- }
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
+ // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
543
+ proposal && (await this.p2pClient.broadcastProposal(proposal));
501
544
 
502
545
  // Wait until the next block's start time
503
546
  await this.waitUntilNextSubslot(timingInfo.deadline);
504
547
  }
505
548
 
506
- this.log.verbose(`Block building loop completed for slot ${this.slot}`, {
507
- slot: this.slot,
549
+ this.log.verbose(`Block building loop completed for slot ${this.targetSlot}`, {
550
+ slot: this.targetSlot,
508
551
  blocksBuilt: blocksInCheckpoint.length,
509
552
  });
510
553
 
511
554
  return { blocksInCheckpoint, blockPendingBroadcast };
512
555
  }
513
556
 
557
+ /** Creates a block proposal for a given block via the validator client (unless in fisherman mode) */
558
+ private createBlockProposal(
559
+ block: L2Block,
560
+ inHash: Fr,
561
+ usedTxs: Tx[],
562
+ blockProposalOptions: BlockProposalOptions,
563
+ ): Promise<BlockProposal | undefined> {
564
+ if (this.config.fishermanMode) {
565
+ this.log.info(`Skipping block proposal for block ${block.number} in fisherman mode`);
566
+ return Promise.resolve(undefined);
567
+ }
568
+ return this.validatorClient.createBlockProposal(
569
+ block.header,
570
+ block.indexWithinCheckpoint,
571
+ inHash,
572
+ block.archive.root,
573
+ usedTxs,
574
+ this.proposer,
575
+ blockProposalOptions,
576
+ );
577
+ }
578
+
514
579
  /** Sleeps until it is time to produce the next block in the slot */
515
580
  @trackSpan('CheckpointProposalJob.waitUntilNextSubslot')
516
581
  private async waitUntilNextSubslot(nextSubslotStart: number) {
517
- this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.slot);
518
- this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, { slot: this.slot });
582
+ this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.targetSlot);
583
+ this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, {
584
+ slot: this.targetSlot,
585
+ });
519
586
  await this.waitUntilTimeInSlot(nextSubslotStart);
520
587
  }
521
588
 
@@ -536,20 +603,19 @@ export class CheckpointProposalJob implements Traceable {
536
603
  opts;
537
604
 
538
605
  this.log.verbose(
539
- `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.slot}`,
606
+ `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.targetSlot}`,
540
607
  { ...checkpointBuilder.getConstantData(), ...opts },
541
608
  );
542
609
 
543
610
  try {
544
611
  // Wait until we have enough txs to build the block
545
- const minTxs = this.config.minTxsPerBlock;
546
- const { availableTxs, canStartBuilding } = await this.waitForMinTxs(opts);
612
+ const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
547
613
  if (!canStartBuilding) {
548
614
  this.log.warn(
549
- `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (got ${availableTxs} txs but needs ${minTxs})`,
550
- { blockNumber, slot: this.slot, indexWithinCheckpoint },
615
+ `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
616
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
551
617
  );
552
- this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.slot });
618
+ this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
553
619
  this.metrics.recordBlockProposalFailed('insufficient_txs');
554
620
  return undefined;
555
621
  }
@@ -562,14 +628,16 @@ export class CheckpointProposalJob implements Traceable {
562
628
  );
563
629
 
564
630
  this.log.debug(
565
- `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.slot} with ${availableTxs} available txs`,
566
- { slot: this.slot, blockNumber, indexWithinCheckpoint },
631
+ `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot} with ${availableTxs} available txs`,
632
+ { slot: this.targetSlot, blockNumber, indexWithinCheckpoint },
567
633
  );
568
- this.setStateFn(SequencerState.CREATING_BLOCK, this.slot);
634
+ this.setStateFn(SequencerState.CREATING_BLOCK, this.targetSlot);
569
635
 
570
- // Per-block limits derived at startup by computeBlockLimits(), further capped
636
+ // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
571
637
  // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built.
572
- const blockBuilderOptions: PublicProcessorLimits = {
638
+ // minValidTxs is passed into the builder so it can reject the block *before* updating state.
639
+ const minValidTxs = forceCreate ? 0 : (this.config.minValidTxsPerBlock ?? minTxs);
640
+ const blockBuilderOptions: BlockBuilderOptions = {
573
641
  maxTransactions: this.config.maxTxsPerBlock,
574
642
  maxBlockGas:
575
643
  this.config.maxL2BlockGas !== undefined || this.config.maxDABlockGas !== undefined
@@ -577,9 +645,14 @@ export class CheckpointProposalJob implements Traceable {
577
645
  : undefined,
578
646
  deadline: buildDeadline,
579
647
  isBuildingProposal: true,
648
+ minValidTxs,
649
+ maxBlocksPerCheckpoint: this.timetable.maxNumberOfBlocks,
650
+ perBlockAllocationMultiplier: this.config.perBlockAllocationMultiplier,
580
651
  };
581
652
 
582
- // Actually build the block by executing txs
653
+ // Actually build the block by executing txs. The builder throws InsufficientValidTxsError
654
+ // if the number of successfully processed txs is below minValidTxs, ensuring state is not
655
+ // updated for blocks that will be discarded.
583
656
  const buildResult = await this.buildSingleBlockWithCheckpointBuilder(
584
657
  checkpointBuilder,
585
658
  pendingTxs,
@@ -591,22 +664,27 @@ export class CheckpointProposalJob implements Traceable {
591
664
  // If any txs failed during execution, drop them from the mempool so we don't pick them up again
592
665
  await this.dropFailedTxsFromP2P(buildResult.failedTxs);
593
666
 
594
- // Check if we have created a block with enough txs. If there were invalid txs in the pool, or if execution took
595
- // too long, then we may not get to minTxsPerBlock after executing public functions.
596
- const minValidTxs = this.config.minValidTxsPerBlock ?? minTxs;
597
- const numTxs = buildResult.status === 'no-valid-txs' ? 0 : buildResult.numTxs;
598
- if (buildResult.status === 'no-valid-txs' || (!forceCreate && numTxs < minValidTxs)) {
667
+ if (buildResult.status === 'insufficient-valid-txs') {
599
668
  this.log.warn(
600
- `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.slot} has too few valid txs to be proposed`,
601
- { slot: this.slot, blockNumber, numTxs, indexWithinCheckpoint, minValidTxs, buildResult: buildResult.status },
669
+ `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.targetSlot} has too few valid txs to be proposed`,
670
+ {
671
+ slot: this.targetSlot,
672
+ blockNumber,
673
+ numTxs: buildResult.processedCount,
674
+ indexWithinCheckpoint,
675
+ minValidTxs,
676
+ },
602
677
  );
603
- this.eventEmitter.emit('block-build-failed', { reason: `Insufficient valid txs`, slot: this.slot });
678
+ this.eventEmitter.emit('block-build-failed', {
679
+ reason: `Insufficient valid txs`,
680
+ slot: this.targetSlot,
681
+ });
604
682
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
605
683
  return undefined;
606
684
  }
607
685
 
608
686
  // Block creation succeeded, emit stats and metrics
609
- const { block, publicProcessorDuration, usedTxs, blockBuildDuration } = buildResult;
687
+ const { block, publicProcessorDuration, usedTxs, blockBuildDuration, numTxs } = buildResult;
610
688
 
611
689
  const blockStats = {
612
690
  eventName: 'l2-block-built',
@@ -620,30 +698,37 @@ export class CheckpointProposalJob implements Traceable {
620
698
  const manaPerSec = block.header.totalManaUsed.toNumberUnsafe() / (blockBuildDuration / 1000);
621
699
 
622
700
  this.log.info(
623
- `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.slot} with ${numTxs} txs`,
701
+ `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.targetSlot} with ${numTxs} txs`,
624
702
  { blockHash, txHashes, manaPerSec, ...blockStats },
625
703
  );
626
704
 
627
- this.eventEmitter.emit('block-proposed', { blockNumber: block.number, slot: this.slot });
705
+ this.eventEmitter.emit('block-proposed', {
706
+ blockNumber: block.number,
707
+ slot: this.targetSlot,
708
+ buildSlot: this.slotNow,
709
+ });
628
710
  this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe());
629
711
 
630
712
  return { block, usedTxs };
631
713
  } catch (err: any) {
632
- this.eventEmitter.emit('block-build-failed', { reason: err.message, slot: this.slot });
633
- this.log.error(`Error building block`, err, { blockNumber, slot: this.slot });
714
+ this.eventEmitter.emit('block-build-failed', {
715
+ reason: err.message,
716
+ slot: this.targetSlot,
717
+ });
718
+ this.log.error(`Error building block`, err, { blockNumber, slot: this.targetSlot });
634
719
  this.metrics.recordBlockProposalFailed(err.name || 'unknown_error');
635
720
  this.metrics.recordFailedBlock();
636
721
  return { error: err };
637
722
  }
638
723
  }
639
724
 
640
- /** Uses the checkpoint builder to build a block, catching specific txs */
725
+ /** Uses the checkpoint builder to build a block, catching InsufficientValidTxsError. */
641
726
  private async buildSingleBlockWithCheckpointBuilder(
642
727
  checkpointBuilder: CheckpointBuilder,
643
728
  pendingTxs: AsyncIterable<Tx>,
644
729
  blockNumber: BlockNumber,
645
730
  blockTimestamp: bigint,
646
- blockBuilderOptions: PublicProcessorLimits,
731
+ blockBuilderOptions: BlockBuilderOptions,
647
732
  ) {
648
733
  try {
649
734
  const workTimer = new Timer();
@@ -651,8 +736,12 @@ export class CheckpointProposalJob implements Traceable {
651
736
  const blockBuildDuration = workTimer.ms();
652
737
  return { ...result, blockBuildDuration, status: 'success' as const };
653
738
  } catch (err: unknown) {
654
- if (isErrorClass(err, NoValidTxsError)) {
655
- return { failedTxs: err.failedTxs, status: 'no-valid-txs' as const };
739
+ if (isErrorClass(err, InsufficientValidTxsError)) {
740
+ return {
741
+ failedTxs: err.failedTxs,
742
+ processedCount: err.processedCount,
743
+ status: 'insufficient-valid-txs' as const,
744
+ };
656
745
  }
657
746
  throw err;
658
747
  }
@@ -665,7 +754,7 @@ export class CheckpointProposalJob implements Traceable {
665
754
  blockNumber: BlockNumber;
666
755
  indexWithinCheckpoint: IndexWithinCheckpoint;
667
756
  buildDeadline: Date | undefined;
668
- }): Promise<{ canStartBuilding: boolean; availableTxs: number }> {
757
+ }): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
669
758
  const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
670
759
 
671
760
  // We only allow a block with 0 txs in the first block of the checkpoint
@@ -682,20 +771,20 @@ export class CheckpointProposalJob implements Traceable {
682
771
  // If we're past deadline, or we have no deadline, give up
683
772
  const now = this.dateProvider.nowAsDate();
684
773
  if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
685
- return { canStartBuilding: false, availableTxs: availableTxs };
774
+ return { canStartBuilding: false, availableTxs, minTxs };
686
775
  }
687
776
 
688
777
  // Wait a bit before checking again
689
- this.setStateFn(SequencerState.WAITING_FOR_TXS, this.slot);
778
+ this.setStateFn(SequencerState.WAITING_FOR_TXS, this.targetSlot);
690
779
  this.log.verbose(
691
- `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (have ${availableTxs} but need ${minTxs})`,
692
- { blockNumber, slot: this.slot, indexWithinCheckpoint },
780
+ `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
781
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
693
782
  );
694
783
  await this.waitForTxsPollingInterval();
695
784
  availableTxs = await this.p2pClient.getPendingTxCount();
696
785
  }
697
786
 
698
- return { canStartBuilding: true, availableTxs };
787
+ return { canStartBuilding: true, availableTxs, minTxs };
699
788
  }
700
789
 
701
790
  /**
@@ -889,19 +978,19 @@ export class CheckpointProposalJob implements Traceable {
889
978
  private async handleCheckpointEndAsFisherman(checkpoint: Checkpoint | undefined) {
890
979
  // Perform L1 fee analysis before clearing requests
891
980
  // The callback is invoked asynchronously after the next block is mined
892
- const feeAnalysis = await this.publisher.analyzeL1Fees(this.slot, analysis =>
981
+ const feeAnalysis = await this.publisher.analyzeL1Fees(this.targetSlot, analysis =>
893
982
  this.metrics.recordFishermanFeeAnalysis(analysis),
894
983
  );
895
984
 
896
985
  if (checkpoint) {
897
- this.log.info(`Validation checkpoint building SUCCEEDED for slot ${this.slot}`, {
986
+ this.log.info(`Validation checkpoint building SUCCEEDED for slot ${this.targetSlot}`, {
898
987
  ...checkpoint.toCheckpointInfo(),
899
988
  ...checkpoint.getStats(),
900
989
  feeAnalysisId: feeAnalysis?.id,
901
990
  });
902
991
  } else {
903
- this.log.warn(`Validation block building FAILED for slot ${this.slot}`, {
904
- slot: this.slot,
992
+ this.log.warn(`Validation block building FAILED for slot ${this.targetSlot}`, {
993
+ slot: this.targetSlot,
905
994
  feeAnalysisId: feeAnalysis?.id,
906
995
  });
907
996
  this.metrics.recordCheckpointProposalFailed('block_build_failed');
@@ -915,15 +1004,15 @@ export class CheckpointProposalJob implements Traceable {
915
1004
  */
916
1005
  private handleHASigningError(err: any, errorContext: string): boolean {
917
1006
  if (err instanceof DutyAlreadySignedError) {
918
- this.log.info(`${errorContext} for slot ${this.slot} already signed by another HA node, yielding`, {
919
- slot: this.slot,
1007
+ this.log.info(`${errorContext} for slot ${this.targetSlot} already signed by another HA node, yielding`, {
1008
+ slot: this.targetSlot,
920
1009
  signedByNode: err.signedByNode,
921
1010
  });
922
1011
  return true;
923
1012
  }
924
1013
  if (err instanceof SlashingProtectionError) {
925
- this.log.info(`${errorContext} for slot ${this.slot} blocked by slashing protection, yielding`, {
926
- slot: this.slot,
1014
+ this.log.info(`${errorContext} for slot ${this.targetSlot} blocked by slashing protection, yielding`, {
1015
+ slot: this.targetSlot,
927
1016
  existingMessageHash: err.existingMessageHash,
928
1017
  attemptedMessageHash: err.attemptedMessageHash,
929
1018
  });
@@ -13,7 +13,7 @@ export type SequencerEvents = {
13
13
  ['proposer-rollup-check-failed']: (args: { reason: string; slot: SlotNumber }) => void;
14
14
  ['block-tx-count-check-failed']: (args: { minTxs: number; availableTxs: number; slot: SlotNumber }) => void;
15
15
  ['block-build-failed']: (args: { reason: string; slot: SlotNumber }) => void;
16
- ['block-proposed']: (args: { blockNumber: BlockNumber; slot: SlotNumber }) => void;
16
+ ['block-proposed']: (args: { blockNumber: BlockNumber; slot: SlotNumber; buildSlot: SlotNumber }) => void;
17
17
  ['checkpoint-empty']: (args: { slot: SlotNumber }) => void;
18
18
  ['checkpoint-publish-failed']: (args: {
19
19
  slot: SlotNumber;
@@ -49,6 +49,8 @@ export class SequencerMetrics {
49
49
  private checkpointBlockCount: Gauge;
50
50
  private checkpointTxCount: Gauge;
51
51
  private checkpointTotalMana: Gauge;
52
+ private pipelineDepth: Gauge;
53
+ private pipelineDiscards: UpDownCounter;
52
54
 
53
55
  // Fisherman fee analysis metrics
54
56
  private fishermanWouldBeIncluded: UpDownCounter;
@@ -143,6 +145,10 @@ export class SequencerMetrics {
143
145
 
144
146
  this.slashingAttempts = createUpDownCounterWithDefault(this.meter, Metrics.SEQUENCER_SLASHING_ATTEMPTS_COUNT);
145
147
 
148
+ this.pipelineDepth = this.meter.createGauge(Metrics.SEQUENCER_PIPELINE_DEPTH);
149
+ this.pipelineDiscards = createUpDownCounterWithDefault(this.meter, Metrics.SEQUENCER_PIPELINE_DISCARDS_COUNT);
150
+ this.pipelineDepth.record(0);
151
+
146
152
  // Fisherman fee analysis metrics
147
153
  this.fishermanWouldBeIncluded = createUpDownCounterWithDefault(
148
154
  this.meter,
@@ -234,6 +240,14 @@ export class SequencerMetrics {
234
240
  });
235
241
  }
236
242
 
243
+ recordPipelineDepth(depth: number) {
244
+ this.pipelineDepth.record(depth);
245
+ }
246
+
247
+ recordPipelineDiscard(count = 1) {
248
+ this.pipelineDiscards.add(count);
249
+ }
250
+
237
251
  incOpenSlot(slot: SlotNumber, proposer: string) {
238
252
  // sequencer went through the loop a second time. Noop
239
253
  if (slot === this.lastSeenSlot) {