@aztec/sequencer-client 0.0.1-commit.fffb133c → 0.0.1-dev

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 (66) hide show
  1. package/dest/client/sequencer-client.d.ts +16 -7
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +56 -23
  4. package/dest/config.d.ts +25 -6
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +46 -28
  7. package/dest/global_variable_builder/global_builder.d.ts +14 -10
  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 +31 -17
  13. package/dest/publisher/config.d.ts.map +1 -1
  14. package/dest/publisher/config.js +101 -42
  15. package/dest/publisher/sequencer-publisher-factory.d.ts +11 -3
  16. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  17. package/dest/publisher/sequencer-publisher-factory.js +13 -2
  18. package/dest/publisher/sequencer-publisher-metrics.d.ts +1 -1
  19. package/dest/publisher/sequencer-publisher-metrics.d.ts.map +1 -1
  20. package/dest/publisher/sequencer-publisher-metrics.js +12 -4
  21. package/dest/publisher/sequencer-publisher.d.ts +28 -14
  22. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  23. package/dest/publisher/sequencer-publisher.js +137 -74
  24. package/dest/sequencer/checkpoint_proposal_job.d.ts +35 -10
  25. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  26. package/dest/sequencer/checkpoint_proposal_job.js +212 -137
  27. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  28. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  29. package/dest/sequencer/checkpoint_voter.js +2 -5
  30. package/dest/sequencer/metrics.d.ts +17 -5
  31. package/dest/sequencer/metrics.d.ts.map +1 -1
  32. package/dest/sequencer/metrics.js +111 -30
  33. package/dest/sequencer/sequencer.d.ts +29 -13
  34. package/dest/sequencer/sequencer.d.ts.map +1 -1
  35. package/dest/sequencer/sequencer.js +48 -44
  36. package/dest/sequencer/timetable.d.ts +4 -6
  37. package/dest/sequencer/timetable.d.ts.map +1 -1
  38. package/dest/sequencer/timetable.js +7 -11
  39. package/dest/sequencer/types.d.ts +2 -2
  40. package/dest/sequencer/types.d.ts.map +1 -1
  41. package/dest/test/index.d.ts +3 -5
  42. package/dest/test/index.d.ts.map +1 -1
  43. package/dest/test/mock_checkpoint_builder.d.ts +12 -12
  44. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  45. package/dest/test/mock_checkpoint_builder.js +47 -36
  46. package/dest/test/utils.d.ts +3 -3
  47. package/dest/test/utils.d.ts.map +1 -1
  48. package/dest/test/utils.js +5 -4
  49. package/package.json +28 -28
  50. package/src/client/sequencer-client.ts +78 -21
  51. package/src/config.ts +61 -38
  52. package/src/global_variable_builder/global_builder.ts +23 -24
  53. package/src/global_variable_builder/index.ts +1 -1
  54. package/src/publisher/config.ts +112 -43
  55. package/src/publisher/sequencer-publisher-factory.ts +23 -6
  56. package/src/publisher/sequencer-publisher-metrics.ts +7 -3
  57. package/src/publisher/sequencer-publisher.ts +149 -90
  58. package/src/sequencer/checkpoint_proposal_job.ts +309 -186
  59. package/src/sequencer/checkpoint_voter.ts +1 -12
  60. package/src/sequencer/metrics.ts +124 -32
  61. package/src/sequencer/sequencer.ts +61 -50
  62. package/src/sequencer/timetable.ts +13 -12
  63. package/src/sequencer/types.ts +1 -1
  64. package/src/test/index.ts +2 -4
  65. package/src/test/mock_checkpoint_builder.ts +67 -53
  66. package/src/test/utils.ts +5 -2
@@ -1,16 +1,25 @@
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';
4
- import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
2
+ import {
3
+ BlockNumber,
4
+ CheckpointNumber,
5
+ EpochNumber,
6
+ IndexWithinCheckpoint,
7
+ SlotNumber,
8
+ } from '@aztec/foundation/branded-types';
5
9
  import { randomInt } from '@aztec/foundation/crypto/random';
10
+ import {
11
+ flipSignature,
12
+ generateRecoverableSignature,
13
+ generateUnrecoverableSignature,
14
+ } from '@aztec/foundation/crypto/secp256k1-signer';
6
15
  import { Fr } from '@aztec/foundation/curves/bn254';
7
16
  import { EthAddress } from '@aztec/foundation/eth-address';
8
17
  import { Signature } from '@aztec/foundation/eth-signature';
9
18
  import { filter } from '@aztec/foundation/iterator';
10
- import type { Logger } from '@aztec/foundation/log';
19
+ import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
11
20
  import { sleep, sleepUntil } from '@aztec/foundation/sleep';
12
21
  import { type DateProvider, Timer } from '@aztec/foundation/timer';
13
- import { type TypedEventEmitter, unfreeze } from '@aztec/foundation/types';
22
+ import { type TypedEventEmitter, isErrorClass, unfreeze } from '@aztec/foundation/types';
14
23
  import type { P2P } from '@aztec/p2p';
15
24
  import type { SlasherClientInterface } from '@aztec/slasher';
16
25
  import {
@@ -21,17 +30,23 @@ import {
21
30
  type L2BlockSource,
22
31
  MaliciousCommitteeAttestationsAndSigners,
23
32
  } from '@aztec/stdlib/block';
24
- import type { Checkpoint } from '@aztec/stdlib/checkpoint';
33
+ import { type Checkpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint';
25
34
  import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
26
35
  import { Gas } from '@aztec/stdlib/gas';
27
- import type {
28
- PublicProcessorLimits,
29
- ResolvedSequencerConfig,
30
- WorldStateSynchronizer,
36
+ import {
37
+ type BlockBuilderOptions,
38
+ InsufficientValidTxsError,
39
+ type ResolvedSequencerConfig,
40
+ type WorldStateSynchronizer,
31
41
  } from '@aztec/stdlib/interfaces/server';
32
42
  import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
33
- import type { BlockProposalOptions, CheckpointProposal, CheckpointProposalOptions } from '@aztec/stdlib/p2p';
34
- import { orderAttestations } from '@aztec/stdlib/p2p';
43
+ import type {
44
+ BlockProposal,
45
+ BlockProposalOptions,
46
+ CheckpointProposal,
47
+ CheckpointProposalOptions,
48
+ } from '@aztec/stdlib/p2p';
49
+ import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p';
35
50
  import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
36
51
  import { type FailedTx, Tx } from '@aztec/stdlib/tx';
37
52
  import { AttestationTimeoutError } from '@aztec/stdlib/validators';
@@ -59,6 +74,8 @@ const TXS_POLLING_MS = 500;
59
74
  * the Sequencer once the check for being the proposer for the slot has succeeded.
60
75
  */
61
76
  export class CheckpointProposalJob implements Traceable {
77
+ protected readonly log: Logger;
78
+
62
79
  constructor(
63
80
  private readonly epoch: EpochNumber,
64
81
  private readonly slot: SlotNumber,
@@ -86,9 +103,11 @@ export class CheckpointProposalJob implements Traceable {
86
103
  private readonly metrics: SequencerMetrics,
87
104
  private readonly eventEmitter: TypedEventEmitter<SequencerEvents>,
88
105
  private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void,
89
- protected readonly log: Logger,
90
106
  public readonly tracer: Tracer,
91
- ) {}
107
+ bindings?: LoggerBindings,
108
+ ) {
109
+ this.log = createLogger('sequencer:checkpoint-proposal', { ...bindings, instanceId: `slot-${slot}` });
110
+ }
92
111
 
93
112
  /**
94
113
  * Executes the checkpoint proposal job.
@@ -118,7 +137,7 @@ export class CheckpointProposalJob implements Traceable {
118
137
  await Promise.all(votesPromises);
119
138
 
120
139
  if (checkpoint) {
121
- this.metrics.recordBlockProposalSuccess();
140
+ this.metrics.recordCheckpointProposalSuccess();
122
141
  }
123
142
 
124
143
  // Do not post anything to L1 if we are fishermen, but do perform L1 fee analysis
@@ -175,21 +194,25 @@ export class CheckpointProposalJob implements Traceable {
175
194
  const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
176
195
 
177
196
  // Collect the out hashes of all the checkpoints before this one in the same epoch
178
- const previousCheckpoints = (await this.l2BlockSource.getCheckpointsForEpoch(this.epoch)).filter(
179
- c => c.number < this.checkpointNumber,
180
- );
181
- const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash());
197
+ const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.epoch))
198
+ .filter(c => c.checkpointNumber < this.checkpointNumber)
199
+ .map(c => c.checkpointOutHash);
200
+
201
+ // Get the fee asset price modifier from the oracle
202
+ const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier();
182
203
 
183
204
  // Create a long-lived forked world state for the checkpoint builder
184
- using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 });
205
+ await using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 });
185
206
 
186
207
  // Create checkpoint builder for the entire slot
187
208
  const checkpointBuilder = await this.checkpointsBuilder.startCheckpoint(
188
209
  this.checkpointNumber,
189
210
  checkpointGlobalVariables,
211
+ feeAssetPriceModifier,
190
212
  l1ToL2Messages,
191
213
  previousCheckpointOutHashes,
192
214
  fork,
215
+ this.log.getBindings(),
193
216
  );
194
217
 
195
218
  // Options for the validator client when creating block and checkpoint proposals
@@ -204,7 +227,8 @@ export class CheckpointProposalJob implements Traceable {
204
227
  };
205
228
 
206
229
  let blocksInCheckpoint: L2Block[] = [];
207
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
230
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
231
+ const checkpointBuildTimer = new Timer();
208
232
 
209
233
  try {
210
234
  // Main loop: build blocks for the checkpoint
@@ -220,19 +244,7 @@ export class CheckpointProposalJob implements Traceable {
220
244
  // These errors are expected in HA mode, so we yield and let another HA node handle the slot
221
245
  // The only distinction between the 2 errors is SlashingProtectionError throws when the payload is different,
222
246
  // which is normal for block building (may have picked different txs)
223
- if (err instanceof DutyAlreadySignedError) {
224
- this.log.info(`Checkpoint proposal for slot ${this.slot} already signed by another HA node, yielding`, {
225
- slot: this.slot,
226
- signedByNode: err.signedByNode,
227
- });
228
- return undefined;
229
- }
230
- if (err instanceof SlashingProtectionError) {
231
- this.log.info(`Checkpoint proposal for slot ${this.slot} blocked by slashing protection, yielding`, {
232
- slot: this.slot,
233
- existingMessageHash: err.existingMessageHash,
234
- attemptedMessageHash: err.attemptedMessageHash,
235
- });
247
+ if (this.handleHASigningError(err, 'Block proposal')) {
236
248
  return undefined;
237
249
  }
238
250
  throw err;
@@ -244,11 +256,45 @@ export class CheckpointProposalJob implements Traceable {
244
256
  return undefined;
245
257
  }
246
258
 
259
+ const minBlocksForCheckpoint = this.config.minBlocksForCheckpoint;
260
+ if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
261
+ this.log.warn(
262
+ `Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`,
263
+ { slot: this.slot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
264
+ );
265
+ return undefined;
266
+ }
267
+
247
268
  // Assemble and broadcast the checkpoint proposal, including the last block that was not
248
269
  // broadcasted yet, and wait to collect the committee attestations.
249
270
  this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.slot);
250
271
  const checkpoint = await checkpointBuilder.completeCheckpoint();
251
272
 
273
+ // Final validation: per-block limits are only checked if the operator set them explicitly.
274
+ // Otherwise, checkpoint-level budgets were already enforced by the redistribution logic.
275
+ try {
276
+ validateCheckpoint(checkpoint, {
277
+ rollupManaLimit: this.l1Constants.rollupManaLimit,
278
+ maxL2BlockGas: this.config.maxL2BlockGas,
279
+ maxDABlockGas: this.config.maxDABlockGas,
280
+ maxTxsPerBlock: this.config.maxTxsPerBlock,
281
+ maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint,
282
+ });
283
+ } catch (err) {
284
+ this.log.error(`Built an invalid checkpoint at slot ${this.slot} (skipping proposal)`, err, {
285
+ checkpoint: checkpoint.header.toInspect(),
286
+ });
287
+ return undefined;
288
+ }
289
+
290
+ // Record checkpoint-level build metrics
291
+ this.metrics.recordCheckpointBuild(
292
+ checkpointBuildTimer.ms(),
293
+ blocksInCheckpoint.length,
294
+ checkpoint.getStats().txCount,
295
+ Number(checkpoint.header.totalManaUsed.toBigInt()),
296
+ );
297
+
252
298
  // Do not collect attestations nor publish to L1 in fisherman mode
253
299
  if (this.config.fishermanMode) {
254
300
  this.log.info(
@@ -264,18 +310,12 @@ export class CheckpointProposalJob implements Traceable {
264
310
  return checkpoint;
265
311
  }
266
312
 
267
- // Include the block pending broadcast in the checkpoint proposal if any
268
- const lastBlock = blockPendingBroadcast && {
269
- blockHeader: blockPendingBroadcast.block.header,
270
- indexWithinCheckpoint: blockPendingBroadcast.block.indexWithinCheckpoint,
271
- txs: blockPendingBroadcast.txs,
272
- };
273
-
274
313
  // Create the checkpoint proposal and broadcast it
275
314
  const proposal = await this.validatorClient.createCheckpointProposal(
276
315
  checkpoint.header,
277
316
  checkpoint.archive.root,
278
- lastBlock,
317
+ feeAssetPriceModifier,
318
+ blockPendingBroadcast,
279
319
  this.proposer,
280
320
  checkpointProposalOptions,
281
321
  );
@@ -301,20 +341,8 @@ export class CheckpointProposalJob implements Traceable {
301
341
  );
302
342
  } catch (err) {
303
343
  // We shouldn't really get here since we yield to another HA node
304
- // as soon as we see these errors when creating block proposals.
305
- if (err instanceof DutyAlreadySignedError) {
306
- this.log.info(`Attestations signature for slot ${this.slot} already signed by another HA node, yielding`, {
307
- slot: this.slot,
308
- signedByNode: err.signedByNode,
309
- });
310
- return undefined;
311
- }
312
- if (err instanceof SlashingProtectionError) {
313
- this.log.info(`Attestations signature for slot ${this.slot} blocked by slashing protection, yielding`, {
314
- slot: this.slot,
315
- existingMessageHash: err.existingMessageHash,
316
- attemptedMessageHash: err.attemptedMessageHash,
317
- });
344
+ // as soon as we see these errors when creating block or checkpoint proposals.
345
+ if (this.handleHASigningError(err, 'Attestations signature')) {
318
346
  return undefined;
319
347
  }
320
348
  throw err;
@@ -325,6 +353,21 @@ export class CheckpointProposalJob implements Traceable {
325
353
  const aztecSlotDuration = this.l1Constants.slotDuration;
326
354
  const slotStartBuildTimestamp = this.getSlotStartBuildTimestamp();
327
355
  const txTimeoutAt = new Date((slotStartBuildTimestamp + aztecSlotDuration) * 1000);
356
+
357
+ // If we have been configured to potentially skip publishing checkpoint then roll the dice here
358
+ if (
359
+ this.config.skipPublishingCheckpointsPercent !== undefined &&
360
+ this.config.skipPublishingCheckpointsPercent > 0
361
+ ) {
362
+ const result = Math.max(0, randomInt(100));
363
+ if (result < this.config.skipPublishingCheckpointsPercent) {
364
+ this.log.warn(
365
+ `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${result}`,
366
+ );
367
+ return checkpoint;
368
+ }
369
+ }
370
+
328
371
  await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
329
372
  txTimeoutAt,
330
373
  forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
@@ -353,32 +396,26 @@ export class CheckpointProposalJob implements Traceable {
353
396
  blockProposalOptions: BlockProposalOptions,
354
397
  ): Promise<{
355
398
  blocksInCheckpoint: L2Block[];
356
- blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined;
399
+ blockPendingBroadcast: BlockProposal | undefined;
357
400
  }> {
358
401
  const blocksInCheckpoint: L2Block[] = [];
359
402
  const txHashesAlreadyIncluded = new Set<string>();
360
403
  const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1);
361
-
362
- // Remaining blob fields available for blocks (checkpoint end marker already subtracted)
363
- let remainingBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS;
404
+ const slot = this.slot;
364
405
 
365
406
  // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal
366
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
407
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
367
408
 
368
409
  while (true) {
369
410
  const blocksBuilt = blocksInCheckpoint.length;
370
- const indexWithinCheckpoint = blocksBuilt;
411
+ const indexWithinCheckpoint = IndexWithinCheckpoint(blocksBuilt);
371
412
  const blockNumber = BlockNumber(initialBlockNumber + blocksBuilt);
372
413
 
373
414
  const secondsIntoSlot = this.getSecondsIntoSlot();
374
415
  const timingInfo = this.timetable.canStartNextBlock(secondsIntoSlot);
375
416
 
376
417
  if (!timingInfo.canStart) {
377
- this.log.debug(`Not enough time left in slot to start another block`, {
378
- slot: this.slot,
379
- blocksBuilt,
380
- secondsIntoSlot,
381
- });
418
+ this.log.debug(`Not enough time left in slot to start another block`, { slot, blocksBuilt, secondsIntoSlot });
382
419
  break;
383
420
  }
384
421
 
@@ -394,72 +431,50 @@ export class CheckpointProposalJob implements Traceable {
394
431
  blockNumber,
395
432
  indexWithinCheckpoint,
396
433
  txHashesAlreadyIncluded,
397
- remainingBlobFields,
398
434
  });
399
435
 
400
- if (!buildResult && timingInfo.isLastBlock) {
401
- // If no block was produced due to not enough txs and this was the last subslot, exit
402
- break;
403
- } else if (!buildResult && timingInfo.deadline !== undefined) {
404
- // But if there is still time for more blocks, wait until the next subslot and try again
436
+ // If we failed to build the block due to insufficient txs, we try again if there is still time left in the slot
437
+ if ('failure' in buildResult) {
438
+ // If this was the last subslot, or we're running with a single block per slot, we're done
439
+ if (timingInfo.isLastBlock || timingInfo.deadline === undefined) {
440
+ break;
441
+ }
442
+ // Otherwise, if there is still time for more blocks, we wait until the next subslot and try again
405
443
  await this.waitUntilNextSubslot(timingInfo.deadline);
406
444
  continue;
407
- } else if (!buildResult) {
408
- // Exit if there is no possibility of building more blocks
409
- break;
410
- } else if ('error' in buildResult) {
411
- // If there was an error building the block, just exit the loop and give up the rest of the slot
445
+ }
446
+
447
+ // If there was an error building the block, we just exit the loop and give up the rest of the slot.
448
+ // We don't want to risk building more blocks if something went wrong.
449
+ if ('error' in buildResult) {
412
450
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
413
- this.log.warn(`Halting block building for slot ${this.slot}`, {
414
- slot: this.slot,
415
- blocksBuilt,
416
- error: buildResult.error,
417
- });
451
+ this.log.warn(`Halting block building for slot ${slot}`, { slot, blocksBuilt, error: buildResult.error });
418
452
  }
419
453
  break;
420
454
  }
421
455
 
422
- const { block, usedTxs, remainingBlobFields: newRemainingBlobFields } = buildResult;
456
+ const { block, usedTxs } = buildResult;
423
457
  blocksInCheckpoint.push(block);
458
+ usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
424
459
 
425
- // Update remaining blob fields for the next block
426
- remainingBlobFields = newRemainingBlobFields;
427
-
428
- // Sync the proposed block to the archiver to make it available
429
- // Note that the checkpoint builder uses its own fork so it should not need to wait for this syncing
430
- // Eventually we should refactor the checkpoint builder to not need a separate long-lived fork
431
- // Fire and forget - don't block the critical path, but log errors
432
- this.syncProposedBlockToArchiver(block).catch(err => {
433
- this.log.error(`Failed to sync proposed block ${block.number} to archiver`, { blockNumber: block.number, err });
434
- });
460
+ // Sign the block proposal. This will throw if HA signing fails.
461
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
435
462
 
436
- usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
463
+ // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
464
+ // so we avoid polluting our archive with a block that would fail.
465
+ // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
466
+ // If this throws, we abort the entire checkpoint.
467
+ await this.syncProposedBlockToArchiver(block);
437
468
 
438
- // If this is the last block, exit the loop now so we start collecting attestations
469
+ // If this is the last block, do not broadcast it, since it will be included in the checkpoint proposal.
439
470
  if (timingInfo.isLastBlock) {
440
- this.log.verbose(`Completed final block ${blockNumber} for slot ${this.slot}`, {
441
- slot: this.slot,
442
- blockNumber,
443
- blocksBuilt,
444
- });
445
- blockPendingBroadcast = { block, txs: usedTxs };
471
+ this.log.verbose(`Completed final block ${blockNumber} for slot ${slot}`, { slot, blockNumber, blocksBuilt });
472
+ blockPendingBroadcast = proposal;
446
473
  break;
447
474
  }
448
475
 
449
- // For non-last blocks, broadcast the block proposal (unless we're in fisherman mode)
450
- // If the block is the last one, we'll broadcast it along with the checkpoint at the end of the loop
451
- if (!this.config.fishermanMode) {
452
- const proposal = await this.validatorClient.createBlockProposal(
453
- block.header,
454
- block.indexWithinCheckpoint,
455
- inHash,
456
- block.archive.root,
457
- usedTxs,
458
- this.proposer,
459
- blockProposalOptions,
460
- );
461
- await this.p2pClient.broadcastProposal(proposal);
462
- }
476
+ // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
477
+ proposal && (await this.p2pClient.broadcastProposal(proposal));
463
478
 
464
479
  // Wait until the next block's start time
465
480
  await this.waitUntilNextSubslot(timingInfo.deadline);
@@ -473,6 +488,28 @@ export class CheckpointProposalJob implements Traceable {
473
488
  return { blocksInCheckpoint, blockPendingBroadcast };
474
489
  }
475
490
 
491
+ /** Creates a block proposal for a given block via the validator client (unless in fisherman mode) */
492
+ private createBlockProposal(
493
+ block: L2Block,
494
+ inHash: Fr,
495
+ usedTxs: Tx[],
496
+ blockProposalOptions: BlockProposalOptions,
497
+ ): Promise<BlockProposal | undefined> {
498
+ if (this.config.fishermanMode) {
499
+ this.log.info(`Skipping block proposal for block ${block.number} in fisherman mode`);
500
+ return Promise.resolve(undefined);
501
+ }
502
+ return this.validatorClient.createBlockProposal(
503
+ block.header,
504
+ block.indexWithinCheckpoint,
505
+ inHash,
506
+ block.archive.root,
507
+ usedTxs,
508
+ this.proposer,
509
+ blockProposalOptions,
510
+ );
511
+ }
512
+
476
513
  /** Sleeps until it is time to produce the next block in the slot */
477
514
  @trackSpan('CheckpointProposalJob.waitUntilNextSubslot')
478
515
  private async waitUntilNextSubslot(nextSubslotStart: number) {
@@ -483,27 +520,21 @@ export class CheckpointProposalJob implements Traceable {
483
520
 
484
521
  /** Builds a single block. Called from the main block building loop. */
485
522
  @trackSpan('CheckpointProposalJob.buildSingleBlock')
486
- private async buildSingleBlock(
523
+ protected async buildSingleBlock(
487
524
  checkpointBuilder: CheckpointBuilder,
488
525
  opts: {
489
526
  forceCreate?: boolean;
490
527
  blockTimestamp: bigint;
491
528
  blockNumber: BlockNumber;
492
- indexWithinCheckpoint: number;
529
+ indexWithinCheckpoint: IndexWithinCheckpoint;
493
530
  buildDeadline: Date | undefined;
494
531
  txHashesAlreadyIncluded: Set<string>;
495
- remainingBlobFields: number;
496
532
  },
497
- ): Promise<{ block: L2Block; usedTxs: Tx[]; remainingBlobFields: number } | { error: Error } | undefined> {
498
- const {
499
- blockTimestamp,
500
- forceCreate,
501
- blockNumber,
502
- indexWithinCheckpoint,
503
- buildDeadline,
504
- txHashesAlreadyIncluded,
505
- remainingBlobFields,
506
- } = opts;
533
+ ): Promise<
534
+ { block: L2Block; usedTxs: Tx[] } | { failure: 'insufficient-txs' | 'insufficient-valid-txs' } | { error: Error }
535
+ > {
536
+ const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } =
537
+ opts;
507
538
 
508
539
  this.log.verbose(
509
540
  `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.slot}`,
@@ -512,8 +543,7 @@ export class CheckpointProposalJob implements Traceable {
512
543
 
513
544
  try {
514
545
  // Wait until we have enough txs to build the block
515
- const minTxs = this.config.minTxsPerBlock;
516
- const { availableTxs, canStartBuilding } = await this.waitForMinTxs(opts);
546
+ const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
517
547
  if (!canStartBuilding) {
518
548
  this.log.warn(
519
549
  `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (got ${availableTxs} txs but needs ${minTxs})`,
@@ -521,13 +551,13 @@ export class CheckpointProposalJob implements Traceable {
521
551
  );
522
552
  this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.slot });
523
553
  this.metrics.recordBlockProposalFailed('insufficient_txs');
524
- return undefined;
554
+ return { failure: 'insufficient-txs' };
525
555
  }
526
556
 
527
557
  // Create iterator to pending txs. We filter out txs already included in previous blocks in the checkpoint
528
558
  // just in case p2p failed to sync the provisional block and didn't get to remove those txs from the mempool yet.
529
559
  const pendingTxs = filter(
530
- this.p2pClient.iteratePendingTxs(),
560
+ this.p2pClient.iterateEligiblePendingTxs(),
531
561
  tx => !txHashesAlreadyIncluded.has(tx.txHash.toString()),
532
562
  );
533
563
 
@@ -537,64 +567,66 @@ export class CheckpointProposalJob implements Traceable {
537
567
  );
538
568
  this.setStateFn(SequencerState.CREATING_BLOCK, this.slot);
539
569
 
540
- // Calculate blob fields limit for txs (remaining capacity - this block's end overhead)
541
- const blockEndOverhead = getNumBlockEndBlobFields(indexWithinCheckpoint === 0);
542
- const maxBlobFieldsForTxs = remainingBlobFields - blockEndOverhead;
543
-
544
- const blockBuilderOptions: PublicProcessorLimits = {
570
+ // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
571
+ // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built.
572
+ // minValidTxs is passed into the builder so it can reject the block *before* updating state.
573
+ const minValidTxs = forceCreate ? 0 : (this.config.minValidTxsPerBlock ?? minTxs);
574
+ const blockBuilderOptions: BlockBuilderOptions = {
545
575
  maxTransactions: this.config.maxTxsPerBlock,
546
- maxBlockSize: this.config.maxBlockSizeInBytes,
547
- maxBlockGas: new Gas(this.config.maxDABlockGas, this.config.maxL2BlockGas),
548
- maxBlobFields: maxBlobFieldsForTxs,
576
+ maxBlockGas:
577
+ this.config.maxL2BlockGas !== undefined || this.config.maxDABlockGas !== undefined
578
+ ? new Gas(this.config.maxDABlockGas ?? Infinity, this.config.maxL2BlockGas ?? Infinity)
579
+ : undefined,
549
580
  deadline: buildDeadline,
581
+ isBuildingProposal: true,
582
+ minValidTxs,
583
+ maxBlocksPerCheckpoint: this.timetable.maxNumberOfBlocks,
584
+ perBlockAllocationMultiplier: this.config.perBlockAllocationMultiplier,
550
585
  };
551
586
 
552
- // Actually build the block by executing txs
553
- const workTimer = new Timer();
554
- const {
555
- publicGas,
556
- block,
557
- publicProcessorDuration,
558
- numTxs,
559
- blockBuildingTimer,
560
- usedTxs,
561
- failedTxs,
562
- usedTxBlobFields,
563
- } = await checkpointBuilder.buildBlock(pendingTxs, blockNumber, blockTimestamp, blockBuilderOptions);
564
- const blockBuildDuration = workTimer.ms();
587
+ // Actually build the block by executing txs. The builder throws InsufficientValidTxsError
588
+ // if the number of successfully processed txs is below minValidTxs, ensuring state is not
589
+ // updated for blocks that will be discarded.
590
+ const buildResult = await this.buildSingleBlockWithCheckpointBuilder(
591
+ checkpointBuilder,
592
+ pendingTxs,
593
+ blockNumber,
594
+ blockTimestamp,
595
+ blockBuilderOptions,
596
+ );
565
597
 
566
598
  // If any txs failed during execution, drop them from the mempool so we don't pick them up again
567
- await this.dropFailedTxsFromP2P(failedTxs);
599
+ await this.dropFailedTxsFromP2P(buildResult.failedTxs);
568
600
 
569
- // Check if we have created a block with enough txs. If there were invalid txs in the pool, or if execution took
570
- // too long, then we may not get to minTxsPerBlock after executing public functions.
571
- const minValidTxs = this.config.minValidTxsPerBlock ?? minTxs;
572
- if (!forceCreate && numTxs < minValidTxs) {
601
+ if (buildResult.status === 'insufficient-valid-txs') {
573
602
  this.log.warn(
574
- `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.slot} has too few valid txs to be proposed (got ${numTxs} but required ${minValidTxs})`,
575
- { slot: this.slot, blockNumber, numTxs, indexWithinCheckpoint },
603
+ `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.slot} has too few valid txs to be proposed`,
604
+ {
605
+ slot: this.slot,
606
+ blockNumber,
607
+ numTxs: buildResult.processedCount,
608
+ indexWithinCheckpoint,
609
+ minValidTxs,
610
+ },
576
611
  );
577
- this.eventEmitter.emit('block-tx-count-check-failed', {
578
- minTxs: minValidTxs,
579
- availableTxs: numTxs,
580
- slot: this.slot,
581
- });
612
+ this.eventEmitter.emit('block-build-failed', { reason: `Insufficient valid txs`, slot: this.slot });
582
613
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
583
- return undefined;
614
+ return { failure: 'insufficient-valid-txs' };
584
615
  }
585
616
 
586
617
  // Block creation succeeded, emit stats and metrics
618
+ const { block, publicProcessorDuration, usedTxs, blockBuildDuration, numTxs } = buildResult;
619
+
587
620
  const blockStats = {
588
621
  eventName: 'l2-block-built',
589
622
  duration: blockBuildDuration,
590
623
  publicProcessDuration: publicProcessorDuration,
591
- rollupCircuitsDuration: blockBuildingTimer.ms(),
592
624
  ...block.getStats(),
593
625
  } satisfies L2BlockBuiltStats;
594
626
 
595
627
  const blockHash = await block.hash();
596
628
  const txHashes = block.body.txEffects.map(tx => tx.txHash);
597
- const manaPerSec = publicGas.l2Gas / (blockBuildDuration / 1000);
629
+ const manaPerSec = block.header.totalManaUsed.toNumberUnsafe() / (blockBuildDuration / 1000);
598
630
 
599
631
  this.log.info(
600
632
  `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.slot} with ${numTxs} txs`,
@@ -602,9 +634,9 @@ export class CheckpointProposalJob implements Traceable {
602
634
  );
603
635
 
604
636
  this.eventEmitter.emit('block-proposed', { blockNumber: block.number, slot: this.slot });
605
- this.metrics.recordBuiltBlock(blockBuildDuration, publicGas.l2Gas);
637
+ this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe());
606
638
 
607
- return { block, usedTxs, remainingBlobFields: maxBlobFieldsForTxs - usedTxBlobFields };
639
+ return { block, usedTxs };
608
640
  } catch (err: any) {
609
641
  this.eventEmitter.emit('block-build-failed', { reason: err.message, slot: this.slot });
610
642
  this.log.error(`Error building block`, err, { blockNumber, slot: this.slot });
@@ -614,17 +646,44 @@ export class CheckpointProposalJob implements Traceable {
614
646
  }
615
647
  }
616
648
 
649
+ /** Uses the checkpoint builder to build a block, catching InsufficientValidTxsError. */
650
+ private async buildSingleBlockWithCheckpointBuilder(
651
+ checkpointBuilder: CheckpointBuilder,
652
+ pendingTxs: AsyncIterable<Tx>,
653
+ blockNumber: BlockNumber,
654
+ blockTimestamp: bigint,
655
+ blockBuilderOptions: BlockBuilderOptions,
656
+ ) {
657
+ try {
658
+ const workTimer = new Timer();
659
+ const result = await checkpointBuilder.buildBlock(pendingTxs, blockNumber, blockTimestamp, blockBuilderOptions);
660
+ const blockBuildDuration = workTimer.ms();
661
+ return { ...result, blockBuildDuration, status: 'success' as const };
662
+ } catch (err: unknown) {
663
+ if (isErrorClass(err, InsufficientValidTxsError)) {
664
+ return {
665
+ failedTxs: err.failedTxs,
666
+ processedCount: err.processedCount,
667
+ status: 'insufficient-valid-txs' as const,
668
+ };
669
+ }
670
+ throw err;
671
+ }
672
+ }
673
+
617
674
  /** Waits until minTxs are available on the pool for building a block. */
618
675
  @trackSpan('CheckpointProposalJob.waitForMinTxs')
619
676
  private async waitForMinTxs(opts: {
620
677
  forceCreate?: boolean;
621
678
  blockNumber: BlockNumber;
622
- indexWithinCheckpoint: number;
679
+ indexWithinCheckpoint: IndexWithinCheckpoint;
623
680
  buildDeadline: Date | undefined;
624
- }): Promise<{ canStartBuilding: boolean; availableTxs: number }> {
625
- const minTxs = this.config.minTxsPerBlock;
681
+ }): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
626
682
  const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
627
683
 
684
+ // We only allow a block with 0 txs in the first block of the checkpoint
685
+ const minTxs = indexWithinCheckpoint > 0 && this.config.minTxsPerBlock === 0 ? 1 : this.config.minTxsPerBlock;
686
+
628
687
  // Deadline is undefined if we are not enforcing the timetable, meaning we'll exit immediately when out of time
629
688
  const startBuildingDeadline = buildDeadline
630
689
  ? new Date(buildDeadline.getTime() - this.timetable.minExecutionTime * 1000)
@@ -636,7 +695,7 @@ export class CheckpointProposalJob implements Traceable {
636
695
  // If we're past deadline, or we have no deadline, give up
637
696
  const now = this.dateProvider.nowAsDate();
638
697
  if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
639
- return { canStartBuilding: false, availableTxs: availableTxs };
698
+ return { canStartBuilding: false, availableTxs, minTxs };
640
699
  }
641
700
 
642
701
  // Wait a bit before checking again
@@ -645,11 +704,11 @@ export class CheckpointProposalJob implements Traceable {
645
704
  `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (have ${availableTxs} but need ${minTxs})`,
646
705
  { blockNumber, slot: this.slot, indexWithinCheckpoint },
647
706
  );
648
- await sleep(TXS_POLLING_MS);
707
+ await this.waitForTxsPollingInterval();
649
708
  availableTxs = await this.p2pClient.getPendingTxCount();
650
709
  }
651
710
 
652
- return { canStartBuilding: true, availableTxs };
711
+ return { canStartBuilding: true, availableTxs, minTxs };
653
712
  }
654
713
 
655
714
  /**
@@ -686,7 +745,7 @@ export class CheckpointProposalJob implements Traceable {
686
745
  const attestationTimeAllowed = this.config.enforceTimeTable
687
746
  ? this.timetable.getMaxAllowedTime(SequencerState.PUBLISHING_CHECKPOINT)!
688
747
  : this.l1Constants.slotDuration;
689
- const attestationDeadline = new Date(this.dateProvider.now() + attestationTimeAllowed * 1000);
748
+ const attestationDeadline = new Date((this.getSlotStartBuildTimestamp() + attestationTimeAllowed) * 1000);
690
749
 
691
750
  this.metrics.recordRequiredAttestations(numberOfRequiredAttestations, attestationTimeAllowed);
692
751
 
@@ -701,11 +760,28 @@ export class CheckpointProposalJob implements Traceable {
701
760
 
702
761
  collectedAttestationsCount = attestations.length;
703
762
 
763
+ // Trim attestations to minimum required to save L1 calldata gas
764
+ const localAddresses = this.validatorClient.getValidatorAddresses();
765
+ const trimmed = trimAttestations(
766
+ attestations,
767
+ numberOfRequiredAttestations,
768
+ this.attestorAddress,
769
+ localAddresses,
770
+ );
771
+ if (trimmed.length < attestations.length) {
772
+ this.log.debug(`Trimmed attestations from ${attestations.length} to ${trimmed.length} for L1 submission`);
773
+ }
774
+
704
775
  // Rollup contract requires that the signatures are provided in the order of the committee
705
- const sorted = orderAttestations(attestations, committee);
776
+ const sorted = orderAttestations(trimmed, committee);
706
777
 
707
778
  // Manipulate the attestations if we've been configured to do so
708
- if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) {
779
+ if (
780
+ this.config.injectFakeAttestation ||
781
+ this.config.injectHighSValueAttestation ||
782
+ this.config.injectUnrecoverableSignatureAttestation ||
783
+ this.config.shuffleAttestationOrdering
784
+ ) {
709
785
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
710
786
  }
711
787
 
@@ -734,7 +810,11 @@ export class CheckpointProposalJob implements Traceable {
734
810
  this.epochCache.computeProposerIndex(slotNumber, epoch, seed, BigInt(committee.length)),
735
811
  );
736
812
 
737
- if (this.config.injectFakeAttestation) {
813
+ if (
814
+ this.config.injectFakeAttestation ||
815
+ this.config.injectHighSValueAttestation ||
816
+ this.config.injectUnrecoverableSignatureAttestation
817
+ ) {
738
818
  // Find non-empty attestations that are not from the proposer
739
819
  const nonProposerIndices: number[] = [];
740
820
  for (let i = 0; i < attestations.length; i++) {
@@ -744,8 +824,20 @@ export class CheckpointProposalJob implements Traceable {
744
824
  }
745
825
  if (nonProposerIndices.length > 0) {
746
826
  const targetIndex = nonProposerIndices[randomInt(nonProposerIndices.length)];
747
- this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`);
748
- unfreeze(attestations[targetIndex]).signature = Signature.random();
827
+ if (this.config.injectHighSValueAttestation) {
828
+ this.log.warn(
829
+ `Injecting high-s value attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`,
830
+ );
831
+ unfreeze(attestations[targetIndex]).signature = flipSignature(attestations[targetIndex].signature);
832
+ } else if (this.config.injectUnrecoverableSignatureAttestation) {
833
+ this.log.warn(
834
+ `Injecting unrecoverable signature attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`,
835
+ );
836
+ unfreeze(attestations[targetIndex]).signature = generateUnrecoverableSignature();
837
+ } else {
838
+ this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`);
839
+ unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature();
840
+ }
749
841
  }
750
842
  return new CommitteeAttestationsAndSigners(attestations);
751
843
  }
@@ -774,16 +866,20 @@ export class CheckpointProposalJob implements Traceable {
774
866
  const failedTxData = failedTxs.map(fail => fail.tx);
775
867
  const failedTxHashes = failedTxData.map(tx => tx.getTxHash());
776
868
  this.log.verbose(`Dropping failed txs ${failedTxHashes.join(', ')}`);
777
- await this.p2pClient.deleteTxs(failedTxHashes);
869
+ await this.p2pClient.handleFailedExecution(failedTxHashes);
778
870
  }
779
871
 
780
872
  /**
781
873
  * Adds the proposed block to the archiver so it's available via P2P.
782
874
  * Gossip doesn't echo messages back to the sender, so the proposer's archiver/world-state
783
875
  * would never receive its own block without this explicit sync.
876
+ *
877
+ * In fisherman mode we skip this push: the fisherman builds blocks locally for validation
878
+ * and fee analysis only, and pushing them to the archiver causes spurious reorg cascades
879
+ * whenever the real proposer's block arrives from L1.
784
880
  */
785
881
  private async syncProposedBlockToArchiver(block: L2Block): Promise<void> {
786
- if (this.config.skipPushProposedBlocksToArchiver !== false) {
882
+ if (this.config.skipPushProposedBlocksToArchiver !== false || this.config.fishermanMode) {
787
883
  this.log.warn(`Skipping push of proposed block ${block.number} to archiver`, {
788
884
  blockNumber: block.number,
789
885
  slot: block.header.globalVariables.slotNumber,
@@ -816,12 +912,34 @@ export class CheckpointProposalJob implements Traceable {
816
912
  slot: this.slot,
817
913
  feeAnalysisId: feeAnalysis?.id,
818
914
  });
819
- this.metrics.recordBlockProposalFailed('block_build_failed');
915
+ this.metrics.recordCheckpointProposalFailed('block_build_failed');
820
916
  }
821
917
 
822
918
  this.publisher.clearPendingRequests();
823
919
  }
824
920
 
921
+ /**
922
+ * Helper to handle HA double-signing errors. Returns true if the error was handled (caller should yield).
923
+ */
924
+ private handleHASigningError(err: any, errorContext: string): boolean {
925
+ if (err instanceof DutyAlreadySignedError) {
926
+ this.log.info(`${errorContext} for slot ${this.slot} already signed by another HA node, yielding`, {
927
+ slot: this.slot,
928
+ signedByNode: err.signedByNode,
929
+ });
930
+ return true;
931
+ }
932
+ if (err instanceof SlashingProtectionError) {
933
+ this.log.info(`${errorContext} for slot ${this.slot} blocked by slashing protection, yielding`, {
934
+ slot: this.slot,
935
+ existingMessageHash: err.existingMessageHash,
936
+ attemptedMessageHash: err.attemptedMessageHash,
937
+ });
938
+ return true;
939
+ }
940
+ return false;
941
+ }
942
+
825
943
  /** Waits until a specific time within the current slot */
826
944
  @trackSpan('CheckpointProposalJob.waitUntilTimeInSlot')
827
945
  protected async waitUntilTimeInSlot(targetSecondsIntoSlot: number): Promise<void> {
@@ -830,6 +948,11 @@ export class CheckpointProposalJob implements Traceable {
830
948
  await sleepUntil(new Date(targetTimestamp * 1000), this.dateProvider.nowAsDate());
831
949
  }
832
950
 
951
+ /** Waits the polling interval for transactions. Extracted for test overriding. */
952
+ protected async waitForTxsPollingInterval(): Promise<void> {
953
+ await sleep(TXS_POLLING_MS);
954
+ }
955
+
833
956
  private getSlotStartBuildTimestamp(): number {
834
957
  return getSlotStartBuildTimestamp(this.slot, this.l1Constants);
835
958
  }