@aztec/sequencer-client 0.0.1-commit.ec5f612 → 0.0.1-commit.ec7ac5448

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dest/client/sequencer-client.d.ts +6 -1
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +49 -27
  4. package/dest/config.d.ts +25 -5
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +31 -22
  7. package/dest/global_variable_builder/global_builder.d.ts +15 -8
  8. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  9. package/dest/global_variable_builder/global_builder.js +26 -26
  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 +16 -3
  18. package/dest/publisher/sequencer-publisher.d.ts +55 -43
  19. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  20. package/dest/publisher/sequencer-publisher.js +180 -116
  21. package/dest/sequencer/chain_state_overrides.d.ts +25 -0
  22. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  23. package/dest/sequencer/chain_state_overrides.js +39 -0
  24. package/dest/sequencer/checkpoint_proposal_job.d.ts +24 -9
  25. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  26. package/dest/sequencer/checkpoint_proposal_job.js +331 -211
  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/events.d.ts +2 -1
  31. package/dest/sequencer/events.d.ts.map +1 -1
  32. package/dest/sequencer/metrics.d.ts +9 -2
  33. package/dest/sequencer/metrics.d.ts.map +1 -1
  34. package/dest/sequencer/metrics.js +23 -1
  35. package/dest/sequencer/sequencer.d.ts +28 -11
  36. package/dest/sequencer/sequencer.d.ts.map +1 -1
  37. package/dest/sequencer/sequencer.js +134 -74
  38. package/dest/sequencer/timetable.d.ts +17 -3
  39. package/dest/sequencer/timetable.d.ts.map +1 -1
  40. package/dest/sequencer/timetable.js +51 -43
  41. package/dest/sequencer/types.d.ts +2 -2
  42. package/dest/sequencer/types.d.ts.map +1 -1
  43. package/dest/test/mock_checkpoint_builder.d.ts +7 -9
  44. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  45. package/dest/test/mock_checkpoint_builder.js +39 -30
  46. package/package.json +27 -28
  47. package/src/client/sequencer-client.ts +61 -28
  48. package/src/config.ts +39 -24
  49. package/src/global_variable_builder/global_builder.ts +30 -27
  50. package/src/global_variable_builder/index.ts +1 -1
  51. package/src/publisher/config.ts +32 -0
  52. package/src/publisher/sequencer-publisher-factory.ts +18 -6
  53. package/src/publisher/sequencer-publisher.ts +263 -167
  54. package/src/sequencer/README.md +83 -13
  55. package/src/sequencer/chain_state_overrides.ts +87 -0
  56. package/src/sequencer/checkpoint_proposal_job.ts +429 -239
  57. package/src/sequencer/checkpoint_voter.ts +1 -12
  58. package/src/sequencer/events.ts +1 -1
  59. package/src/sequencer/metrics.ts +29 -1
  60. package/src/sequencer/sequencer.ts +194 -81
  61. package/src/sequencer/timetable.ts +64 -52
  62. package/src/sequencer/types.ts +1 -1
  63. 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 { SimulationOverridesPlan } from '@aztec/ethereum/contracts';
4
3
  import {
5
4
  BlockNumber,
6
5
  CheckpointNumber,
@@ -9,6 +8,11 @@ import {
9
8
  SlotNumber,
10
9
  } from '@aztec/foundation/branded-types';
11
10
  import { randomInt } from '@aztec/foundation/crypto/random';
11
+ import {
12
+ flipSignature,
13
+ generateRecoverableSignature,
14
+ generateUnrecoverableSignature,
15
+ } from '@aztec/foundation/crypto/secp256k1-signer';
12
16
  import { Fr } from '@aztec/foundation/curves/bn254';
13
17
  import { EthAddress } from '@aztec/foundation/eth-address';
14
18
  import { Signature } from '@aztec/foundation/eth-signature';
@@ -27,17 +31,22 @@ import {
27
31
  type L2BlockSource,
28
32
  MaliciousCommitteeAttestationsAndSigners,
29
33
  } from '@aztec/stdlib/block';
30
- import type { Checkpoint } from '@aztec/stdlib/checkpoint';
31
- 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';
32
36
  import { Gas } from '@aztec/stdlib/gas';
33
37
  import {
34
- NoValidTxsError,
35
- type PublicProcessorLimits,
38
+ type BlockBuilderOptions,
39
+ InsufficientValidTxsError,
36
40
  type ResolvedSequencerConfig,
37
41
  type WorldStateSynchronizer,
38
42
  } from '@aztec/stdlib/interfaces/server';
39
43
  import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
40
- 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';
41
50
  import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p';
42
51
  import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
43
52
  import { type FailedTx, Tx } from '@aztec/stdlib/tx';
@@ -48,6 +57,10 @@ import { DutyAlreadySignedError, SlashingProtectionError } from '@aztec/validato
48
57
 
49
58
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
50
59
  import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
60
+ import {
61
+ buildPipelinedParentSimulationOverridesPlan,
62
+ buildSubmissionSimulationOverridesPlan,
63
+ } from './chain_state_overrides.js';
51
64
  import { CheckpointVoter } from './checkpoint_voter.js';
52
65
  import { SequencerInterruptedError } from './errors.js';
53
66
  import type { SequencerEvents } from './events.js';
@@ -59,6 +72,20 @@ import { SequencerState } from './utils.js';
59
72
  /** How much time to sleep while waiting for min transactions to accumulate for a block */
60
73
  const TXS_POLLING_MS = 500;
61
74
 
75
+ /** Result from proposeCheckpoint when a checkpoint was successfully built and broadcast. */
76
+ type CheckpointProposalBroadcast = {
77
+ checkpoint: Checkpoint;
78
+ proposal: CheckpointProposal;
79
+ blockProposedAt: number;
80
+ };
81
+
82
+ /** Result after attestation collection and signing, ready for L1 submission. */
83
+ type CheckpointProposalResult = {
84
+ checkpoint: Checkpoint;
85
+ attestations: CommitteeAttestationsAndSigners;
86
+ attestationsSignature: Signature;
87
+ };
88
+
62
89
  /**
63
90
  * Handles the execution of a checkpoint proposal after the initial preparation phase.
64
91
  * This includes building blocks, collecting attestations, and publishing the checkpoint to L1,
@@ -68,9 +95,16 @@ const TXS_POLLING_MS = 500;
68
95
  export class CheckpointProposalJob implements Traceable {
69
96
  protected readonly log: Logger;
70
97
 
98
+ /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */
99
+ private pendingL1Submission: Promise<void> | undefined;
100
+
101
+ /** Pipelined parent chain state used while building and later submitting this checkpoint. */
102
+ private pipelinedParentSimulationOverridesPlan?: SimulationOverridesPlan;
103
+
71
104
  constructor(
72
- private readonly epoch: EpochNumber,
73
- private readonly slot: SlotNumber,
105
+ private readonly slotNow: SlotNumber,
106
+ private readonly targetSlot: SlotNumber,
107
+ private readonly targetEpoch: EpochNumber,
74
108
  private readonly checkpointNumber: CheckpointNumber,
75
109
  private readonly syncedToBlockNumber: BlockNumber,
76
110
  // TODO(palla/mbps): Can we remove the proposer in favor of attestorAddress? Need to check fisherman-node flows.
@@ -97,13 +131,26 @@ export class CheckpointProposalJob implements Traceable {
97
131
  private readonly setStateFn: (state: SequencerState, slot?: SlotNumber) => void,
98
132
  public readonly tracer: Tracer,
99
133
  bindings?: LoggerBindings,
134
+ private readonly proposedCheckpointData?: ProposedCheckpointData,
100
135
  ) {
101
- this.log = createLogger('sequencer:checkpoint-proposal', { ...bindings, instanceId: `slot-${slot}` });
136
+ this.log = createLogger('sequencer:checkpoint-proposal', {
137
+ ...bindings,
138
+ instanceId: `slot-${this.slotNow}`,
139
+ });
140
+ }
141
+
142
+ /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */
143
+ public async awaitPendingSubmission(): Promise<void> {
144
+ this.log.info('Awaiting pending L1 payload submission');
145
+ await this.pendingL1Submission;
102
146
  }
103
147
 
104
148
  /**
105
149
  * Executes the checkpoint proposal job.
106
- * Returns the published checkpoint if successful, undefined otherwise.
150
+ * Builds blocks, assembles checkpoint, and broadcasts the proposal (blocking).
151
+ * Attestation collection, signing, and L1 submission are backgrounded so the
152
+ * work loop can return to IDLE immediately for consecutive slot proposals.
153
+ * Returns the built checkpoint if successful, undefined otherwise.
107
154
  */
108
155
  @trackSpan('CheckpointProposalJob.execute')
109
156
  public async execute(): Promise<Checkpoint | undefined> {
@@ -111,7 +158,7 @@ export class CheckpointProposalJob implements Traceable {
111
158
  // In fisherman mode, we simulate slashing but don't actually publish to L1
112
159
  // These are constant for the whole slot, so we only enqueue them once
113
160
  const votesPromises = new CheckpointVoter(
114
- this.slot,
161
+ this.targetSlot,
115
162
  this.publisher,
116
163
  this.attestorAddress,
117
164
  this.validatorClient,
@@ -122,63 +169,185 @@ export class CheckpointProposalJob implements Traceable {
122
169
  this.log,
123
170
  ).enqueueVotes();
124
171
 
125
- // Build and propose the checkpoint. This will enqueue the request on the publisher if a checkpoint is built.
126
- const checkpoint = await this.proposeCheckpoint();
172
+ // Build blocks, assemble checkpoint, and broadcast proposal (BLOCKING).
173
+ // Returns after broadcast — attestation collection is deferred.
174
+ const broadcast = await this.proposeCheckpoint();
127
175
 
128
- // Wait until the voting promises have resolved, so all requests are enqueued (not sent)
129
- await Promise.all(votesPromises);
130
-
131
- if (checkpoint) {
132
- this.metrics.recordCheckpointProposalSuccess();
176
+ if (!broadcast) {
177
+ await Promise.all(votesPromises);
178
+ // Still submit votes even without a checkpoint
179
+ if (!this.config.fishermanMode) {
180
+ this.pendingL1Submission = this.publisher.sendRequestsAt(this.dateProvider.nowAsDate()).then(() => {});
181
+ }
182
+ return undefined;
133
183
  }
134
184
 
185
+ const { checkpoint } = broadcast;
186
+ this.metrics.recordCheckpointProposalSuccess();
187
+
135
188
  // Do not post anything to L1 if we are fishermen, but do perform L1 fee analysis
136
189
  if (this.config.fishermanMode) {
137
190
  await this.handleCheckpointEndAsFisherman(checkpoint);
138
- return;
191
+ return checkpoint;
139
192
  }
140
193
 
141
- // Then send everything to L1
142
- const l1Response = await this.publisher.sendRequests();
143
- const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
144
- if (proposedAction) {
145
- this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.slot });
146
- const coinbase = checkpoint?.header.coinbase;
147
- await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
148
- return checkpoint;
149
- } else if (checkpoint) {
150
- this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.slot });
151
- return undefined;
194
+ // Background the attestation signing → L1 pipeline so the work loop is unblocked
195
+ this.pendingL1Submission = this.waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises);
196
+
197
+ // Return the built checkpoint immediately — the work loop is now unblocked
198
+ return checkpoint;
199
+ }
200
+
201
+ /**
202
+ * Background pipeline: collects attestations, signs them, enqueues the checkpoint, and submits to L1.
203
+ * Runs as a fire-and-forget task stored in `pendingL1Submission` so the work loop is unblocked.
204
+ */
205
+ private async waitForAttestationsAndEnqueueSubmissionAsync(
206
+ broadcast: CheckpointProposalBroadcast,
207
+ votesPromises: Promise<unknown>[],
208
+ ): Promise<void> {
209
+ const { checkpoint, proposal, blockProposedAt } = broadcast;
210
+ try {
211
+ await Promise.all(votesPromises);
212
+
213
+ this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.targetSlot);
214
+ const attestations = await this.waitForAttestations(proposal);
215
+
216
+ this.metrics.recordCheckpointAttestationDelay(this.dateProvider.now() - blockProposedAt);
217
+
218
+ // Proposer must sign over the attestations before pushing them to L1
219
+ const signer = this.proposer ?? this.publisher.getSenderAddress();
220
+ let attestationsSignature: Signature;
221
+ try {
222
+ attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
223
+ attestations,
224
+ signer,
225
+ this.targetSlot,
226
+ this.checkpointNumber,
227
+ );
228
+ } catch (err) {
229
+ if (this.handleHASigningError(err, 'Attestations signature')) {
230
+ return;
231
+ }
232
+ throw err;
233
+ }
234
+
235
+ // Enqueue the checkpoint for L1 submission
236
+ await this.enqueueCheckpointForSubmission({ checkpoint, attestations, attestationsSignature });
237
+
238
+ // Compute the earliest time to submit: pipeline slot start when pipelining, now otherwise.
239
+ const submitAfter = this.epochCache.isProposerPipeliningEnabled()
240
+ ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
241
+ : new Date(this.dateProvider.now());
242
+
243
+ const l1Response = await this.publisher.sendRequestsAt(submitAfter);
244
+ const proposedAction = l1Response?.successfulActions.find(a => a === 'propose');
245
+ if (proposedAction) {
246
+ this.eventEmitter.emit('checkpoint-published', { checkpoint: this.checkpointNumber, slot: this.targetSlot });
247
+ const coinbase = checkpoint.header.coinbase;
248
+ await this.metrics.incFilledSlot(this.publisher.getSenderAddress().toString(), coinbase);
249
+ } else {
250
+ this.eventEmitter.emit('checkpoint-publish-failed', { ...l1Response, slot: this.targetSlot });
251
+ if (this.epochCache.isProposerPipeliningEnabled()) {
252
+ this.metrics.recordPipelineDiscard();
253
+ }
254
+ }
255
+ } catch (err) {
256
+ if (err instanceof SequencerInterruptedError) {
257
+ return;
258
+ }
259
+ this.log.error(`Background attestation/L1 pipeline failed for slot ${this.targetSlot}`, err);
260
+ this.eventEmitter.emit('checkpoint-publish-failed', { slot: this.targetSlot });
261
+ if (this.epochCache.isProposerPipeliningEnabled()) {
262
+ this.metrics.recordPipelineDiscard();
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Enqueues the checkpoint for L1 submission. Called after pipeline sleep in execute(). */
268
+ private async enqueueCheckpointForSubmission(result: CheckpointProposalResult): Promise<void> {
269
+ const { checkpoint, attestations, attestationsSignature } = result;
270
+
271
+ this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.targetSlot);
272
+ const aztecSlotDuration = this.l1Constants.slotDuration;
273
+ const submissionSlotStart = Number(getTimestampForSlot(this.targetSlot, this.l1Constants));
274
+ const txTimeoutAt = new Date((submissionSlotStart + aztecSlotDuration) * 1000);
275
+
276
+ // If we have been configured to potentially skip publishing checkpoint then roll the dice here
277
+ if (
278
+ this.config.skipPublishingCheckpointsPercent !== undefined &&
279
+ this.config.skipPublishingCheckpointsPercent > 0
280
+ ) {
281
+ const roll = Math.max(0, randomInt(100));
282
+ if (roll < this.config.skipPublishingCheckpointsPercent) {
283
+ this.log.warn(
284
+ `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${roll}`,
285
+ );
286
+ return;
287
+ }
152
288
  }
289
+
290
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
291
+ const submissionSimulationOverridesPlan = buildSubmissionSimulationOverridesPlan({
292
+ pipelinedParentPlan: this.pipelinedParentSimulationOverridesPlan,
293
+ invalidateToPendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
294
+ lastArchiveRoot: checkpoint.header.lastArchiveRoot,
295
+ pipeliningEnabled: isPipelining,
296
+ });
297
+
298
+ await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
299
+ txTimeoutAt,
300
+ ...(submissionSimulationOverridesPlan ? { simulationOverridesPlan: submissionSimulationOverridesPlan } : {}),
301
+ });
153
302
  }
154
303
 
155
304
  @trackSpan('CheckpointProposalJob.proposeCheckpoint', function () {
156
305
  return {
157
306
  // nullish operator needed for tests
158
307
  [Attributes.COINBASE]: this.validatorClient.getCoinbaseForAttestor(this.attestorAddress)?.toString(),
159
- [Attributes.SLOT_NUMBER]: this.slot,
308
+ [Attributes.SLOT_NUMBER]: this.targetSlot,
160
309
  };
161
310
  })
162
- private async proposeCheckpoint(): Promise<Checkpoint | undefined> {
311
+ private async proposeCheckpoint(): Promise<CheckpointProposalBroadcast | undefined> {
163
312
  try {
164
313
  // Get operator configured coinbase and fee recipient for this attestor
165
314
  const coinbase = this.validatorClient.getCoinbaseForAttestor(this.attestorAddress);
166
315
  const feeRecipient = this.validatorClient.getFeeRecipientForAttestor(this.attestorAddress);
167
316
 
168
317
  // Start the checkpoint
169
- this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.slot);
170
- this.metrics.incOpenSlot(this.slot, this.proposer?.toString() ?? 'unknown');
318
+ this.setStateFn(SequencerState.INITIALIZING_CHECKPOINT, this.targetSlot);
319
+ this.log.info(`Starting checkpoint proposal`, {
320
+ buildSlot: this.slotNow,
321
+ submissionSlot: this.targetSlot,
322
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
323
+ proposer: this.proposer?.toString(),
324
+ coinbase: coinbase.toString(),
325
+ });
326
+ this.metrics.incOpenSlot(this.targetSlot, this.proposer?.toString() ?? 'unknown');
171
327
 
172
328
  // Enqueues checkpoint invalidation (constant for the whole slot)
173
329
  if (this.invalidateCheckpoint && !this.config.skipInvalidateBlockAsProposer) {
174
330
  this.publisher.enqueueInvalidateCheckpoint(this.invalidateCheckpoint);
175
331
  }
176
332
 
177
- // Create checkpoint builder for the slot
333
+ // Create checkpoint builder for the slot.
334
+ // When pipelining, force the proposed checkpoint number and fee header to our parent so the
335
+ // fee computation sees the same chain tip that L1 will see once the previous pipelined checkpoint lands.
336
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
337
+ this.pipelinedParentSimulationOverridesPlan = isPipelining
338
+ ? await buildPipelinedParentSimulationOverridesPlan({
339
+ checkpointNumber: this.checkpointNumber,
340
+ proposedCheckpointData: this.proposedCheckpointData,
341
+ rollup: this.publisher.rollupContract,
342
+ log: this.log,
343
+ })
344
+ : undefined;
345
+
178
346
  const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables(
179
347
  coinbase,
180
348
  feeRecipient,
181
- this.slot,
349
+ this.targetSlot,
350
+ this.pipelinedParentSimulationOverridesPlan,
182
351
  );
183
352
 
184
353
  // Collect L1 to L2 messages for the checkpoint and compute their hash
@@ -186,7 +355,7 @@ export class CheckpointProposalJob implements Traceable {
186
355
  const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
187
356
 
188
357
  // Collect the out hashes of all the checkpoints before this one in the same epoch
189
- const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.epoch))
358
+ const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.targetEpoch))
190
359
  .filter(c => c.checkpointNumber < this.checkpointNumber)
191
360
  .map(c => c.checkpointOutHash);
192
361
 
@@ -219,7 +388,7 @@ export class CheckpointProposalJob implements Traceable {
219
388
  };
220
389
 
221
390
  let blocksInCheckpoint: L2Block[] = [];
222
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
391
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
223
392
  const checkpointBuildTimer = new Timer();
224
393
 
225
394
  try {
@@ -243,8 +412,8 @@ export class CheckpointProposalJob implements Traceable {
243
412
  }
244
413
 
245
414
  if (blocksInCheckpoint.length === 0) {
246
- this.log.warn(`No blocks were built for slot ${this.slot}`, { slot: this.slot });
247
- this.eventEmitter.emit('checkpoint-empty', { slot: this.slot });
415
+ this.log.warn(`No blocks were built for slot ${this.targetSlot}`, { slot: this.targetSlot });
416
+ this.eventEmitter.emit('checkpoint-empty', { slot: this.targetSlot });
248
417
  return undefined;
249
418
  }
250
419
 
@@ -252,16 +421,33 @@ export class CheckpointProposalJob implements Traceable {
252
421
  if (minBlocksForCheckpoint !== undefined && blocksInCheckpoint.length < minBlocksForCheckpoint) {
253
422
  this.log.warn(
254
423
  `Checkpoint has fewer blocks than minimum (${blocksInCheckpoint.length} < ${minBlocksForCheckpoint}), skipping proposal`,
255
- { slot: this.slot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
424
+ { slot: this.targetSlot, blocksBuilt: blocksInCheckpoint.length, minBlocksForCheckpoint },
256
425
  );
257
426
  return undefined;
258
427
  }
259
428
 
260
429
  // Assemble and broadcast the checkpoint proposal, including the last block that was not
261
430
  // broadcasted yet, and wait to collect the committee attestations.
262
- this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.slot);
431
+ this.setStateFn(SequencerState.ASSEMBLING_CHECKPOINT, this.targetSlot);
263
432
  const checkpoint = await checkpointBuilder.completeCheckpoint();
264
433
 
434
+ // Final validation: per-block limits are only checked if the operator set them explicitly.
435
+ // Otherwise, checkpoint-level budgets were already enforced by the redistribution logic.
436
+ try {
437
+ validateCheckpoint(checkpoint, {
438
+ rollupManaLimit: this.l1Constants.rollupManaLimit,
439
+ maxL2BlockGas: this.config.maxL2BlockGas,
440
+ maxDABlockGas: this.config.maxDABlockGas,
441
+ maxTxsPerBlock: this.config.maxTxsPerBlock,
442
+ maxTxsPerCheckpoint: this.config.maxTxsPerCheckpoint,
443
+ });
444
+ } catch (err) {
445
+ this.log.error(`Built an invalid checkpoint at slot ${this.slotNow} (skipping proposal)`, err, {
446
+ checkpoint: checkpoint.header.toInspect(),
447
+ });
448
+ return undefined;
449
+ }
450
+
265
451
  // Record checkpoint-level build metrics
266
452
  this.metrics.recordCheckpointBuild(
267
453
  checkpointBuildTimer.ms(),
@@ -270,34 +456,28 @@ export class CheckpointProposalJob implements Traceable {
270
456
  Number(checkpoint.header.totalManaUsed.toBigInt()),
271
457
  );
272
458
 
273
- // Do not collect attestations nor publish to L1 in fisherman mode
459
+ // In fisherman mode, return the checkpoint without broadcasting or collecting attestations
274
460
  if (this.config.fishermanMode) {
275
461
  this.log.info(
276
- `Built checkpoint for slot ${this.slot} with ${blocksInCheckpoint.length} blocks. ` +
462
+ `Built checkpoint for slot ${this.targetSlot} with ${blocksInCheckpoint.length} blocks. ` +
277
463
  `Skipping proposal in fisherman mode.`,
278
464
  {
279
- slot: this.slot,
465
+ slot: this.targetSlot,
280
466
  checkpoint: checkpoint.header.toInspect(),
281
467
  blocksBuilt: blocksInCheckpoint.length,
282
468
  },
283
469
  );
284
470
  this.metrics.recordCheckpointSuccess();
285
- return checkpoint;
471
+ // Return a broadcast result with a dummy proposal — fisherman mode skips attestation collection
472
+ return { checkpoint, proposal: undefined!, blockProposedAt: this.dateProvider.now() };
286
473
  }
287
474
 
288
- // Include the block pending broadcast in the checkpoint proposal if any
289
- const lastBlock = blockPendingBroadcast && {
290
- blockHeader: blockPendingBroadcast.block.header,
291
- indexWithinCheckpoint: blockPendingBroadcast.block.indexWithinCheckpoint,
292
- txs: blockPendingBroadcast.txs,
293
- };
294
-
295
475
  // Create the checkpoint proposal and broadcast it
296
476
  const proposal = await this.validatorClient.createCheckpointProposal(
297
477
  checkpoint.header,
298
478
  checkpoint.archive.root,
299
479
  feeAssetPriceModifier,
300
- lastBlock,
480
+ blockPendingBroadcast,
301
481
  this.proposer,
302
482
  checkpointProposalOptions,
303
483
  );
@@ -305,64 +485,15 @@ export class CheckpointProposalJob implements Traceable {
305
485
  const blockProposedAt = this.dateProvider.now();
306
486
  await this.p2pClient.broadcastCheckpointProposal(proposal);
307
487
 
308
- this.setStateFn(SequencerState.COLLECTING_ATTESTATIONS, this.slot);
309
- const attestations = await this.waitForAttestations(proposal);
310
- const blockAttestedAt = this.dateProvider.now();
311
-
312
- this.metrics.recordCheckpointAttestationDelay(blockAttestedAt - blockProposedAt);
313
-
314
- // Proposer must sign over the attestations before pushing them to L1
315
- const signer = this.proposer ?? this.publisher.getSenderAddress();
316
- let attestationsSignature: Signature;
317
- try {
318
- attestationsSignature = await this.validatorClient.signAttestationsAndSigners(
319
- attestations,
320
- signer,
321
- this.slot,
322
- this.checkpointNumber,
323
- );
324
- } catch (err) {
325
- // We shouldn't really get here since we yield to another HA node
326
- // as soon as we see these errors when creating block or checkpoint proposals.
327
- if (this.handleHASigningError(err, 'Attestations signature')) {
328
- return undefined;
329
- }
330
- throw err;
331
- }
332
-
333
- // Enqueue publishing the checkpoint to L1
334
- this.setStateFn(SequencerState.PUBLISHING_CHECKPOINT, this.slot);
335
- const aztecSlotDuration = this.l1Constants.slotDuration;
336
- const slotStartBuildTimestamp = this.getSlotStartBuildTimestamp();
337
- const txTimeoutAt = new Date((slotStartBuildTimestamp + aztecSlotDuration) * 1000);
338
-
339
- // If we have been configured to potentially skip publishing checkpoint then roll the dice here
340
- if (
341
- this.config.skipPublishingCheckpointsPercent !== undefined &&
342
- this.config.skipPublishingCheckpointsPercent > 0
343
- ) {
344
- const result = Math.max(0, randomInt(100));
345
- if (result < this.config.skipPublishingCheckpointsPercent) {
346
- this.log.warn(
347
- `Skipping publishing proposal for checkpoint ${checkpoint.number}. Configured percentage: ${this.config.skipPublishingCheckpointsPercent}, generated value: ${result}`,
348
- );
349
- return checkpoint;
350
- }
351
- }
352
-
353
- await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
354
- txTimeoutAt,
355
- forcePendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber,
356
- });
357
-
358
- return checkpoint;
488
+ // Return immediately after broadcast — attestation collection happens in the background
489
+ return { checkpoint, proposal, blockProposedAt };
359
490
  } catch (err) {
360
491
  if (err && (err instanceof DutyAlreadySignedError || err instanceof SlashingProtectionError)) {
361
492
  // swallow this error. It's already been logged by a function deeper in the stack
362
493
  return undefined;
363
494
  }
364
495
 
365
- this.log.error(`Error building checkpoint at slot ${this.slot}`, err);
496
+ this.log.error(`Error building checkpoint at slot ${this.targetSlot}`, err);
366
497
  return undefined;
367
498
  }
368
499
  }
@@ -378,17 +509,14 @@ export class CheckpointProposalJob implements Traceable {
378
509
  blockProposalOptions: BlockProposalOptions,
379
510
  ): Promise<{
380
511
  blocksInCheckpoint: L2Block[];
381
- blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined;
512
+ blockPendingBroadcast: BlockProposal | undefined;
382
513
  }> {
383
514
  const blocksInCheckpoint: L2Block[] = [];
384
515
  const txHashesAlreadyIncluded = new Set<string>();
385
516
  const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1);
386
517
 
387
- // Remaining blob fields available for blocks (checkpoint end marker already subtracted)
388
- let remainingBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS;
389
-
390
518
  // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal
391
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
519
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
392
520
 
393
521
  while (true) {
394
522
  const blocksBuilt = blocksInCheckpoint.length;
@@ -400,7 +528,7 @@ export class CheckpointProposalJob implements Traceable {
400
528
 
401
529
  if (!timingInfo.canStart) {
402
530
  this.log.debug(`Not enough time left in slot to start another block`, {
403
- slot: this.slot,
531
+ slot: this.targetSlot,
404
532
  blocksBuilt,
405
533
  secondsIntoSlot,
406
534
  });
@@ -419,25 +547,25 @@ export class CheckpointProposalJob implements Traceable {
419
547
  blockNumber,
420
548
  indexWithinCheckpoint,
421
549
  txHashesAlreadyIncluded,
422
- remainingBlobFields,
423
550
  });
424
551
 
425
- // TODO(palla/mbps): Review these conditions. We may want to keep trying in some scenarios.
426
- if (!buildResult && timingInfo.isLastBlock) {
427
- // If no block was produced due to not enough txs and this was the last subslot, exit
428
- break;
429
- } else if (!buildResult && timingInfo.deadline !== undefined) {
430
- // But if there is still time for more blocks, wait until the next subslot and try again
552
+ // If we failed to build the block due to insufficient txs, we try again if there is still time left in the slot
553
+ if ('failure' in buildResult) {
554
+ // If this was the last subslot, or we're running with a single block per slot, we're done
555
+ if (timingInfo.isLastBlock || timingInfo.deadline === undefined) {
556
+ break;
557
+ }
558
+ // Otherwise, if there is still time for more blocks, we wait until the next subslot and try again
431
559
  await this.waitUntilNextSubslot(timingInfo.deadline);
432
560
  continue;
433
- } else if (!buildResult) {
434
- // Exit if there is no possibility of building more blocks
435
- break;
436
- } else if ('error' in buildResult) {
437
- // If there was an error building the block, just exit the loop and give up the rest of the slot
561
+ }
562
+
563
+ // If there was an error building the block, we just exit the loop and give up the rest of the slot.
564
+ // We don't want to risk building more blocks if something went wrong.
565
+ if ('error' in buildResult) {
438
566
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
439
- this.log.warn(`Halting block building for slot ${this.slot}`, {
440
- slot: this.slot,
567
+ this.log.warn(`Halting block building for slot ${this.targetSlot}`, {
568
+ slot: this.targetSlot,
441
569
  blocksBuilt,
442
570
  error: buildResult.error,
443
571
  });
@@ -445,65 +573,75 @@ export class CheckpointProposalJob implements Traceable {
445
573
  break;
446
574
  }
447
575
 
448
- const { block, usedTxs, remainingBlobFields: newRemainingBlobFields } = buildResult;
576
+ const { block, usedTxs } = buildResult;
449
577
  blocksInCheckpoint.push(block);
578
+ usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
450
579
 
451
- // Update remaining blob fields for the next block
452
- remainingBlobFields = newRemainingBlobFields;
453
-
454
- // Sync the proposed block to the archiver to make it available
455
- // Note that the checkpoint builder uses its own fork so it should not need to wait for this syncing
456
- // Eventually we should refactor the checkpoint builder to not need a separate long-lived fork
457
- // Fire and forget - don't block the critical path, but log errors
458
- this.syncProposedBlockToArchiver(block).catch(err => {
459
- this.log.error(`Failed to sync proposed block ${block.number} to archiver`, { blockNumber: block.number, err });
460
- });
580
+ // Sign the block proposal. This will throw if HA signing fails.
581
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
461
582
 
462
- usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
583
+ // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
584
+ // so we avoid polluting our archive with a block that would fail.
585
+ // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
586
+ // If this throws, we abort the entire checkpoint.
587
+ await this.syncProposedBlockToArchiver(block);
463
588
 
464
- // If this is the last block, exit the loop now so we start collecting attestations
589
+ // If this is the last block, do not broadcast it, since it will be included in the checkpoint proposal.
465
590
  if (timingInfo.isLastBlock) {
466
- this.log.verbose(`Completed final block ${blockNumber} for slot ${this.slot}`, {
467
- slot: this.slot,
591
+ this.log.verbose(`Completed final block ${blockNumber} for slot ${this.targetSlot}`, {
592
+ slot: this.targetSlot,
468
593
  blockNumber,
469
594
  blocksBuilt,
470
595
  });
471
- blockPendingBroadcast = { block, txs: usedTxs };
596
+
597
+ blockPendingBroadcast = proposal;
472
598
  break;
473
599
  }
474
600
 
475
- // For non-last blocks, broadcast the block proposal (unless we're in fisherman mode)
476
- // If the block is the last one, we'll broadcast it along with the checkpoint at the end of the loop
477
- if (!this.config.fishermanMode) {
478
- const proposal = await this.validatorClient.createBlockProposal(
479
- block.header,
480
- block.indexWithinCheckpoint,
481
- inHash,
482
- block.archive.root,
483
- usedTxs,
484
- this.proposer,
485
- blockProposalOptions,
486
- );
487
- await this.p2pClient.broadcastProposal(proposal);
488
- }
601
+ // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
602
+ proposal && (await this.p2pClient.broadcastProposal(proposal));
489
603
 
490
604
  // Wait until the next block's start time
491
605
  await this.waitUntilNextSubslot(timingInfo.deadline);
492
606
  }
493
607
 
494
- this.log.verbose(`Block building loop completed for slot ${this.slot}`, {
495
- slot: this.slot,
608
+ this.log.verbose(`Block building loop completed for slot ${this.targetSlot}`, {
609
+ slot: this.targetSlot,
496
610
  blocksBuilt: blocksInCheckpoint.length,
497
611
  });
498
612
 
499
613
  return { blocksInCheckpoint, blockPendingBroadcast };
500
614
  }
501
615
 
616
+ /** Creates a block proposal for a given block via the validator client (unless in fisherman mode) */
617
+ private createBlockProposal(
618
+ block: L2Block,
619
+ inHash: Fr,
620
+ usedTxs: Tx[],
621
+ blockProposalOptions: BlockProposalOptions,
622
+ ): Promise<BlockProposal | undefined> {
623
+ if (this.config.fishermanMode) {
624
+ this.log.info(`Skipping block proposal for block ${block.number} in fisherman mode`);
625
+ return Promise.resolve(undefined);
626
+ }
627
+ return this.validatorClient.createBlockProposal(
628
+ block.header,
629
+ block.indexWithinCheckpoint,
630
+ inHash,
631
+ block.archive.root,
632
+ usedTxs,
633
+ this.proposer,
634
+ blockProposalOptions,
635
+ );
636
+ }
637
+
502
638
  /** Sleeps until it is time to produce the next block in the slot */
503
639
  @trackSpan('CheckpointProposalJob.waitUntilNextSubslot')
504
640
  private async waitUntilNextSubslot(nextSubslotStart: number) {
505
- this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.slot);
506
- this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, { slot: this.slot });
641
+ this.setStateFn(SequencerState.WAITING_UNTIL_NEXT_BLOCK, this.targetSlot);
642
+ this.log.verbose(`Waiting until time for the next block at ${nextSubslotStart}s into slot`, {
643
+ slot: this.targetSlot,
644
+ });
507
645
  await this.waitUntilTimeInSlot(nextSubslotStart);
508
646
  }
509
647
 
@@ -518,36 +656,29 @@ export class CheckpointProposalJob implements Traceable {
518
656
  indexWithinCheckpoint: IndexWithinCheckpoint;
519
657
  buildDeadline: Date | undefined;
520
658
  txHashesAlreadyIncluded: Set<string>;
521
- remainingBlobFields: number;
522
659
  },
523
- ): Promise<{ block: L2Block; usedTxs: Tx[]; remainingBlobFields: number } | { error: Error } | undefined> {
524
- const {
525
- blockTimestamp,
526
- forceCreate,
527
- blockNumber,
528
- indexWithinCheckpoint,
529
- buildDeadline,
530
- txHashesAlreadyIncluded,
531
- remainingBlobFields,
532
- } = opts;
660
+ ): Promise<
661
+ { block: L2Block; usedTxs: Tx[] } | { failure: 'insufficient-txs' | 'insufficient-valid-txs' } | { error: Error }
662
+ > {
663
+ const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } =
664
+ opts;
533
665
 
534
666
  this.log.verbose(
535
- `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.slot}`,
667
+ `Preparing block ${blockNumber} index ${indexWithinCheckpoint} at checkpoint ${this.checkpointNumber} for slot ${this.targetSlot}`,
536
668
  { ...checkpointBuilder.getConstantData(), ...opts },
537
669
  );
538
670
 
539
671
  try {
540
672
  // Wait until we have enough txs to build the block
541
- const minTxs = this.config.minTxsPerBlock;
542
- const { availableTxs, canStartBuilding } = await this.waitForMinTxs(opts);
673
+ const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
543
674
  if (!canStartBuilding) {
544
675
  this.log.warn(
545
- `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (got ${availableTxs} txs but needs ${minTxs})`,
546
- { blockNumber, slot: this.slot, indexWithinCheckpoint },
676
+ `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
677
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
547
678
  );
548
- this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.slot });
679
+ this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
549
680
  this.metrics.recordBlockProposalFailed('insufficient_txs');
550
- return undefined;
681
+ return { failure: 'insufficient-txs' };
551
682
  }
552
683
 
553
684
  // Create iterator to pending txs. We filter out txs already included in previous blocks in the checkpoint
@@ -558,24 +689,31 @@ export class CheckpointProposalJob implements Traceable {
558
689
  );
559
690
 
560
691
  this.log.debug(
561
- `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.slot} with ${availableTxs} available txs`,
562
- { slot: this.slot, blockNumber, indexWithinCheckpoint },
692
+ `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot} with ${availableTxs} available txs`,
693
+ { slot: this.targetSlot, blockNumber, indexWithinCheckpoint },
563
694
  );
564
- this.setStateFn(SequencerState.CREATING_BLOCK, this.slot);
565
-
566
- // Calculate blob fields limit for txs (remaining capacity - this block's end overhead)
567
- const blockEndOverhead = getNumBlockEndBlobFields(indexWithinCheckpoint === 0);
568
- const maxBlobFieldsForTxs = remainingBlobFields - blockEndOverhead;
695
+ this.setStateFn(SequencerState.CREATING_BLOCK, this.targetSlot);
569
696
 
570
- const blockBuilderOptions: PublicProcessorLimits = {
697
+ // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
698
+ // by remaining checkpoint-level budgets inside CheckpointBuilder before each block is built.
699
+ // minValidTxs is passed into the builder so it can reject the block *before* updating state.
700
+ const minValidTxs = forceCreate ? 0 : (this.config.minValidTxsPerBlock ?? minTxs);
701
+ const blockBuilderOptions: BlockBuilderOptions = {
571
702
  maxTransactions: this.config.maxTxsPerBlock,
572
- maxBlockSize: this.config.maxBlockSizeInBytes,
573
- maxBlockGas: new Gas(this.config.maxDABlockGas, this.config.maxL2BlockGas),
574
- maxBlobFields: maxBlobFieldsForTxs,
703
+ maxBlockGas:
704
+ this.config.maxL2BlockGas !== undefined || this.config.maxDABlockGas !== undefined
705
+ ? new Gas(this.config.maxDABlockGas ?? Infinity, this.config.maxL2BlockGas ?? Infinity)
706
+ : undefined,
575
707
  deadline: buildDeadline,
708
+ isBuildingProposal: true,
709
+ minValidTxs,
710
+ maxBlocksPerCheckpoint: this.timetable.maxNumberOfBlocks,
711
+ perBlockAllocationMultiplier: this.config.perBlockAllocationMultiplier,
576
712
  };
577
713
 
578
- // Actually build the block by executing txs
714
+ // Actually build the block by executing txs. The builder throws InsufficientValidTxsError
715
+ // if the number of successfully processed txs is below minValidTxs, ensuring state is not
716
+ // updated for blocks that will be discarded.
579
717
  const buildResult = await this.buildSingleBlockWithCheckpointBuilder(
580
718
  checkpointBuilder,
581
719
  pendingTxs,
@@ -587,22 +725,27 @@ export class CheckpointProposalJob implements Traceable {
587
725
  // If any txs failed during execution, drop them from the mempool so we don't pick them up again
588
726
  await this.dropFailedTxsFromP2P(buildResult.failedTxs);
589
727
 
590
- // Check if we have created a block with enough txs. If there were invalid txs in the pool, or if execution took
591
- // too long, then we may not get to minTxsPerBlock after executing public functions.
592
- const minValidTxs = this.config.minValidTxsPerBlock ?? minTxs;
593
- const numTxs = buildResult.status === 'no-valid-txs' ? 0 : buildResult.numTxs;
594
- if (buildResult.status === 'no-valid-txs' || (!forceCreate && numTxs < minValidTxs)) {
728
+ if (buildResult.status === 'insufficient-valid-txs') {
595
729
  this.log.warn(
596
- `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.slot} has too few valid txs to be proposed`,
597
- { slot: this.slot, blockNumber, numTxs, indexWithinCheckpoint, minValidTxs, buildResult: buildResult.status },
730
+ `Block ${blockNumber} at index ${indexWithinCheckpoint} on slot ${this.targetSlot} has too few valid txs to be proposed`,
731
+ {
732
+ slot: this.targetSlot,
733
+ blockNumber,
734
+ numTxs: buildResult.processedCount,
735
+ indexWithinCheckpoint,
736
+ minValidTxs,
737
+ },
598
738
  );
599
- this.eventEmitter.emit('block-build-failed', { reason: `Insufficient valid txs`, slot: this.slot });
739
+ this.eventEmitter.emit('block-build-failed', {
740
+ reason: `Insufficient valid txs`,
741
+ slot: this.targetSlot,
742
+ });
600
743
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
601
- return undefined;
744
+ return { failure: 'insufficient-valid-txs' };
602
745
  }
603
746
 
604
747
  // Block creation succeeded, emit stats and metrics
605
- const { publicGas, block, publicProcessorDuration, usedTxs, usedTxBlobFields, blockBuildDuration } = buildResult;
748
+ const { block, publicProcessorDuration, usedTxs, blockBuildDuration, numTxs } = buildResult;
606
749
 
607
750
  const blockStats = {
608
751
  eventName: 'l2-block-built',
@@ -613,33 +756,42 @@ export class CheckpointProposalJob implements Traceable {
613
756
 
614
757
  const blockHash = await block.hash();
615
758
  const txHashes = block.body.txEffects.map(tx => tx.txHash);
616
- const manaPerSec = publicGas.l2Gas / (blockBuildDuration / 1000);
759
+ const manaPerSec = block.header.totalManaUsed.toNumberUnsafe() / (blockBuildDuration / 1000);
617
760
 
618
761
  this.log.info(
619
- `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.slot} with ${numTxs} txs`,
762
+ `Built block ${block.number} at checkpoint ${this.checkpointNumber} for slot ${this.targetSlot} with ${numTxs} txs`,
620
763
  { blockHash, txHashes, manaPerSec, ...blockStats },
621
764
  );
622
765
 
623
- this.eventEmitter.emit('block-proposed', { blockNumber: block.number, slot: this.slot });
624
- this.metrics.recordBuiltBlock(blockBuildDuration, publicGas.l2Gas);
766
+ // `slot` is the target/submission slot (may be one ahead when pipelining),
767
+ // `buildSlot` is the wall-clock slot during which the block was actually built.
768
+ this.eventEmitter.emit('block-proposed', {
769
+ blockNumber: block.number,
770
+ slot: this.targetSlot,
771
+ buildSlot: this.slotNow,
772
+ });
773
+ this.metrics.recordBuiltBlock(blockBuildDuration, block.header.totalManaUsed.toNumberUnsafe(), this.targetSlot);
625
774
 
626
- return { block, usedTxs, remainingBlobFields: maxBlobFieldsForTxs - usedTxBlobFields };
775
+ return { block, usedTxs };
627
776
  } catch (err: any) {
628
- this.eventEmitter.emit('block-build-failed', { reason: err.message, slot: this.slot });
629
- this.log.error(`Error building block`, err, { blockNumber, slot: this.slot });
777
+ this.eventEmitter.emit('block-build-failed', {
778
+ reason: err.message,
779
+ slot: this.targetSlot,
780
+ });
781
+ this.log.error(`Error building block`, err, { blockNumber, slot: this.targetSlot });
630
782
  this.metrics.recordBlockProposalFailed(err.name || 'unknown_error');
631
783
  this.metrics.recordFailedBlock();
632
784
  return { error: err };
633
785
  }
634
786
  }
635
787
 
636
- /** Uses the checkpoint builder to build a block, catching specific txs */
788
+ /** Uses the checkpoint builder to build a block, catching InsufficientValidTxsError. */
637
789
  private async buildSingleBlockWithCheckpointBuilder(
638
790
  checkpointBuilder: CheckpointBuilder,
639
791
  pendingTxs: AsyncIterable<Tx>,
640
792
  blockNumber: BlockNumber,
641
793
  blockTimestamp: bigint,
642
- blockBuilderOptions: PublicProcessorLimits,
794
+ blockBuilderOptions: BlockBuilderOptions,
643
795
  ) {
644
796
  try {
645
797
  const workTimer = new Timer();
@@ -647,8 +799,12 @@ export class CheckpointProposalJob implements Traceable {
647
799
  const blockBuildDuration = workTimer.ms();
648
800
  return { ...result, blockBuildDuration, status: 'success' as const };
649
801
  } catch (err: unknown) {
650
- if (isErrorClass(err, NoValidTxsError)) {
651
- return { failedTxs: err.failedTxs, status: 'no-valid-txs' as const };
802
+ if (isErrorClass(err, InsufficientValidTxsError)) {
803
+ return {
804
+ failedTxs: err.failedTxs,
805
+ processedCount: err.processedCount,
806
+ status: 'insufficient-valid-txs' as const,
807
+ };
652
808
  }
653
809
  throw err;
654
810
  }
@@ -661,7 +817,7 @@ export class CheckpointProposalJob implements Traceable {
661
817
  blockNumber: BlockNumber;
662
818
  indexWithinCheckpoint: IndexWithinCheckpoint;
663
819
  buildDeadline: Date | undefined;
664
- }): Promise<{ canStartBuilding: boolean; availableTxs: number }> {
820
+ }): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
665
821
  const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
666
822
 
667
823
  // We only allow a block with 0 txs in the first block of the checkpoint
@@ -678,20 +834,20 @@ export class CheckpointProposalJob implements Traceable {
678
834
  // If we're past deadline, or we have no deadline, give up
679
835
  const now = this.dateProvider.nowAsDate();
680
836
  if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
681
- return { canStartBuilding: false, availableTxs: availableTxs };
837
+ return { canStartBuilding: false, availableTxs, minTxs };
682
838
  }
683
839
 
684
840
  // Wait a bit before checking again
685
- this.setStateFn(SequencerState.WAITING_FOR_TXS, this.slot);
841
+ this.setStateFn(SequencerState.WAITING_FOR_TXS, this.targetSlot);
686
842
  this.log.verbose(
687
- `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.slot} (have ${availableTxs} but need ${minTxs})`,
688
- { blockNumber, slot: this.slot, indexWithinCheckpoint },
843
+ `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
844
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
689
845
  );
690
846
  await this.waitForTxsPollingInterval();
691
847
  availableTxs = await this.p2pClient.getPendingTxCount();
692
848
  }
693
849
 
694
- return { canStartBuilding: true, availableTxs };
850
+ return { canStartBuilding: true, availableTxs, minTxs };
695
851
  }
696
852
 
697
853
  /**
@@ -717,7 +873,7 @@ export class CheckpointProposalJob implements Traceable {
717
873
  this.log.debug(`Attesting committee length is ${committee.length}`, { committee });
718
874
  }
719
875
 
720
- const numberOfRequiredAttestations = Math.floor((committee.length * 2) / 3) + 1;
876
+ const numberOfRequiredAttestations = computeQuorum(committee.length);
721
877
 
722
878
  if (this.config.skipCollectingAttestations) {
723
879
  this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
@@ -726,7 +882,7 @@ export class CheckpointProposalJob implements Traceable {
726
882
  }
727
883
 
728
884
  const attestationTimeAllowed = this.config.enforceTimeTable
729
- ? this.timetable.getMaxAllowedTime(SequencerState.PUBLISHING_CHECKPOINT)!
885
+ ? this.timetable.getCheckpointAttestationDeadline()
730
886
  : this.l1Constants.slotDuration;
731
887
  const attestationDeadline = new Date((this.getSlotStartBuildTimestamp() + attestationTimeAllowed) * 1000);
732
888
 
@@ -759,7 +915,12 @@ export class CheckpointProposalJob implements Traceable {
759
915
  const sorted = orderAttestations(trimmed, committee);
760
916
 
761
917
  // Manipulate the attestations if we've been configured to do so
762
- if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) {
918
+ if (
919
+ this.config.injectFakeAttestation ||
920
+ this.config.injectHighSValueAttestation ||
921
+ this.config.injectUnrecoverableSignatureAttestation ||
922
+ this.config.shuffleAttestationOrdering
923
+ ) {
763
924
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
764
925
  }
765
926
 
@@ -788,7 +949,11 @@ export class CheckpointProposalJob implements Traceable {
788
949
  this.epochCache.computeProposerIndex(slotNumber, epoch, seed, BigInt(committee.length)),
789
950
  );
790
951
 
791
- if (this.config.injectFakeAttestation) {
952
+ if (
953
+ this.config.injectFakeAttestation ||
954
+ this.config.injectHighSValueAttestation ||
955
+ this.config.injectUnrecoverableSignatureAttestation
956
+ ) {
792
957
  // Find non-empty attestations that are not from the proposer
793
958
  const nonProposerIndices: number[] = [];
794
959
  for (let i = 0; i < attestations.length; i++) {
@@ -798,8 +963,20 @@ export class CheckpointProposalJob implements Traceable {
798
963
  }
799
964
  if (nonProposerIndices.length > 0) {
800
965
  const targetIndex = nonProposerIndices[randomInt(nonProposerIndices.length)];
801
- this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`);
802
- unfreeze(attestations[targetIndex]).signature = Signature.random();
966
+ if (this.config.injectHighSValueAttestation) {
967
+ this.log.warn(
968
+ `Injecting high-s value attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`,
969
+ );
970
+ unfreeze(attestations[targetIndex]).signature = flipSignature(attestations[targetIndex].signature);
971
+ } else if (this.config.injectUnrecoverableSignatureAttestation) {
972
+ this.log.warn(
973
+ `Injecting unrecoverable signature attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`,
974
+ );
975
+ unfreeze(attestations[targetIndex]).signature = generateUnrecoverableSignature();
976
+ } else {
977
+ this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`);
978
+ unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature();
979
+ }
803
980
  }
804
981
  return new CommitteeAttestationsAndSigners(attestations);
805
982
  }
@@ -808,11 +985,20 @@ export class CheckpointProposalJob implements Traceable {
808
985
  this.log.warn(`Shuffling attestation ordering in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`);
809
986
 
810
987
  const shuffled = [...attestations];
811
- const [i, j] = [(proposerIndex + 1) % shuffled.length, (proposerIndex + 2) % shuffled.length];
812
- const valueI = shuffled[i];
813
- const valueJ = shuffled[j];
814
- shuffled[i] = valueJ;
815
- shuffled[j] = valueI;
988
+
989
+ // Find two non-proposer positions that both have non-empty signatures to swap.
990
+ // This ensures the bitmap doesn't change, so the MaliciousCommitteeAttestationsAndSigners
991
+ // signers array stays correctly aligned with L1's committee reconstruction.
992
+ const swappable: number[] = [];
993
+ for (let k = 0; k < shuffled.length; k++) {
994
+ if (!shuffled[k].signature.isEmpty() && k !== proposerIndex) {
995
+ swappable.push(k);
996
+ }
997
+ }
998
+ if (swappable.length >= 2) {
999
+ const [i, j] = [swappable[0], swappable[1]];
1000
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
1001
+ }
816
1002
 
817
1003
  const signers = new CommitteeAttestationsAndSigners(attestations).getSigners();
818
1004
  return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers);
@@ -835,9 +1021,13 @@ export class CheckpointProposalJob implements Traceable {
835
1021
  * Adds the proposed block to the archiver so it's available via P2P.
836
1022
  * Gossip doesn't echo messages back to the sender, so the proposer's archiver/world-state
837
1023
  * would never receive its own block without this explicit sync.
1024
+ *
1025
+ * In fisherman mode we skip this push: the fisherman builds blocks locally for validation
1026
+ * and fee analysis only, and pushing them to the archiver causes spurious reorg cascades
1027
+ * whenever the real proposer's block arrives from L1.
838
1028
  */
839
1029
  private async syncProposedBlockToArchiver(block: L2Block): Promise<void> {
840
- if (this.config.skipPushProposedBlocksToArchiver !== false) {
1030
+ if (this.config.skipPushProposedBlocksToArchiver || this.config.fishermanMode) {
841
1031
  this.log.warn(`Skipping push of proposed block ${block.number} to archiver`, {
842
1032
  blockNumber: block.number,
843
1033
  slot: block.header.globalVariables.slotNumber,
@@ -855,19 +1045,19 @@ export class CheckpointProposalJob implements Traceable {
855
1045
  private async handleCheckpointEndAsFisherman(checkpoint: Checkpoint | undefined) {
856
1046
  // Perform L1 fee analysis before clearing requests
857
1047
  // The callback is invoked asynchronously after the next block is mined
858
- const feeAnalysis = await this.publisher.analyzeL1Fees(this.slot, analysis =>
1048
+ const feeAnalysis = await this.publisher.analyzeL1Fees(this.targetSlot, analysis =>
859
1049
  this.metrics.recordFishermanFeeAnalysis(analysis),
860
1050
  );
861
1051
 
862
1052
  if (checkpoint) {
863
- this.log.info(`Validation checkpoint building SUCCEEDED for slot ${this.slot}`, {
1053
+ this.log.info(`Validation checkpoint building SUCCEEDED for slot ${this.targetSlot}`, {
864
1054
  ...checkpoint.toCheckpointInfo(),
865
1055
  ...checkpoint.getStats(),
866
1056
  feeAnalysisId: feeAnalysis?.id,
867
1057
  });
868
1058
  } else {
869
- this.log.warn(`Validation block building FAILED for slot ${this.slot}`, {
870
- slot: this.slot,
1059
+ this.log.warn(`Validation block building FAILED for slot ${this.targetSlot}`, {
1060
+ slot: this.targetSlot,
871
1061
  feeAnalysisId: feeAnalysis?.id,
872
1062
  });
873
1063
  this.metrics.recordCheckpointProposalFailed('block_build_failed');
@@ -881,15 +1071,15 @@ export class CheckpointProposalJob implements Traceable {
881
1071
  */
882
1072
  private handleHASigningError(err: any, errorContext: string): boolean {
883
1073
  if (err instanceof DutyAlreadySignedError) {
884
- this.log.info(`${errorContext} for slot ${this.slot} already signed by another HA node, yielding`, {
885
- slot: this.slot,
1074
+ this.log.info(`${errorContext} for slot ${this.targetSlot} already signed by another HA node, yielding`, {
1075
+ slot: this.targetSlot,
886
1076
  signedByNode: err.signedByNode,
887
1077
  });
888
1078
  return true;
889
1079
  }
890
1080
  if (err instanceof SlashingProtectionError) {
891
- this.log.info(`${errorContext} for slot ${this.slot} blocked by slashing protection, yielding`, {
892
- slot: this.slot,
1081
+ this.log.info(`${errorContext} for slot ${this.targetSlot} blocked by slashing protection, yielding`, {
1082
+ slot: this.targetSlot,
893
1083
  existingMessageHash: err.existingMessageHash,
894
1084
  attemptedMessageHash: err.attemptedMessageHash,
895
1085
  });
@@ -912,7 +1102,7 @@ export class CheckpointProposalJob implements Traceable {
912
1102
  }
913
1103
 
914
1104
  private getSlotStartBuildTimestamp(): number {
915
- return getSlotStartBuildTimestamp(this.slot, this.l1Constants);
1105
+ return getSlotStartBuildTimestamp(this.slotNow, this.l1Constants);
916
1106
  }
917
1107
 
918
1108
  private getSecondsIntoSlot(): number {