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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,6 @@ import { Blob, getBlobsPerL1Block, getPrefixedEthBlobCommitments } from '@aztec/
3
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
4
  import type { L1ContractsConfig } from '@aztec/ethereum/config';
5
5
  import {
6
- type EmpireSlashingProposerContract,
7
6
  FeeAssetPriceOracle,
8
7
  type FeeHeader,
9
8
  type GovernanceProposerContract,
@@ -11,7 +10,7 @@ import {
11
10
  MULTI_CALL_3_ADDRESS,
12
11
  Multicall3,
13
12
  RollupContract,
14
- type TallySlashingProposerContract,
13
+ type SlashingProposerContract,
15
14
  type ViemCommitteeAttestations,
16
15
  type ViemHeader,
17
16
  } from '@aztec/ethereum/contracts';
@@ -45,7 +44,6 @@ import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slas
45
44
  import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block';
46
45
  import type { Checkpoint } from '@aztec/stdlib/checkpoint';
47
46
  import { getLastL1SlotTimestampForL2Slot, getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers';
48
- import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts';
49
47
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
50
48
  import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats';
51
49
  import { type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
@@ -100,16 +98,13 @@ export const Actions = [
100
98
  'invalidate-by-insufficient-attestations',
101
99
  'propose',
102
100
  'governance-signal',
103
- 'empire-slashing-signal',
104
- 'create-empire-payload',
105
- 'execute-empire-payload',
106
101
  'vote-offenses',
107
102
  'execute-slash',
108
103
  ] as const;
109
104
 
110
105
  export type Action = (typeof Actions)[number];
111
106
 
112
- type GovernanceSignalAction = Extract<Action, 'governance-signal' | 'empire-slashing-signal'>;
107
+ type GovernanceSignalAction = Extract<Action, 'governance-signal'>;
113
108
 
114
109
  // Sorting for actions such that invalidations go before proposals, and proposals go before votes
115
110
  export const compareActions = (a: Action, b: Action) => Actions.indexOf(a) - Actions.indexOf(b);
@@ -130,6 +125,8 @@ interface RequestWithExpiry {
130
125
  lastValidL2Slot: SlotNumber;
131
126
  gasConfig?: Pick<L1TxConfig, 'txTimeoutAt' | 'gasLimit'>;
132
127
  blobConfig?: L1BlobInputs;
128
+ /** Optional pre-send validation. If it rejects, the request is discarded. */
129
+ preCheck?: () => Promise<void>;
133
130
  checkSuccess: (
134
131
  request: L1TxRequest,
135
132
  result?: { receipt: TransactionReceipt; stats?: TransactionStats; errorMsg?: string },
@@ -183,8 +180,7 @@ export class SequencerPublisher {
183
180
  public l1TxUtils: L1TxUtils;
184
181
  public rollupContract: RollupContract;
185
182
  public govProposerContract: GovernanceProposerContract;
186
- public slashingProposerContract: EmpireSlashingProposerContract | TallySlashingProposerContract | undefined;
187
- public slashFactoryContract: SlashFactoryContract;
183
+ public slashingProposerContract: SlashingProposerContract | undefined;
188
184
 
189
185
  public readonly tracer: Tracer;
190
186
 
@@ -198,9 +194,8 @@ export class SequencerPublisher {
198
194
  blobClient: BlobClientInterface;
199
195
  l1TxUtils: L1TxUtils;
200
196
  rollupContract: RollupContract;
201
- slashingProposerContract: EmpireSlashingProposerContract | TallySlashingProposerContract | undefined;
197
+ slashingProposerContract: SlashingProposerContract | undefined;
202
198
  governanceProposerContract: GovernanceProposerContract;
203
- slashFactoryContract: SlashFactoryContract;
204
199
  epochCache: EpochCache;
205
200
  dateProvider: DateProvider;
206
201
  metrics: SequencerPublisherMetrics;
@@ -235,8 +230,6 @@ export class SequencerPublisher {
235
230
  const newSlashingProposer = await this.rollupContract.getSlashingProposer();
236
231
  this.slashingProposerContract = newSlashingProposer;
237
232
  });
238
- this.slashFactoryContract = deps.slashFactoryContract;
239
-
240
233
  // Initialize L1 fee analyzer for fisherman mode
241
234
  if (config.fishermanMode) {
242
235
  this.l1FeeAnalyzer = new L1FeeAnalyzer(
@@ -569,6 +562,28 @@ export class SequencerPublisher {
569
562
  if (this.interrupted) {
570
563
  return undefined;
571
564
  }
565
+
566
+ // Re-validate enqueued requests after the sleep (state may have changed, e.g. prune or L1 reorg)
567
+ const validRequests: RequestWithExpiry[] = [];
568
+ for (const request of this.requests) {
569
+ if (!request.preCheck) {
570
+ validRequests.push(request);
571
+ continue;
572
+ }
573
+
574
+ try {
575
+ await request.preCheck();
576
+ validRequests.push(request);
577
+ } catch (err) {
578
+ this.log.warn(`Pre-send validation failed for ${request.action}, discarding request`, err);
579
+ }
580
+ }
581
+
582
+ this.requests = validRequests;
583
+ if (this.requests.length === 0) {
584
+ return undefined;
585
+ }
586
+
572
587
  return this.sendRequests();
573
588
  }
574
589
 
@@ -1078,55 +1093,6 @@ export class SequencerPublisher {
1078
1093
 
1079
1094
  for (const action of actions) {
1080
1095
  switch (action.type) {
1081
- case 'vote-empire-payload': {
1082
- if (this.slashingProposerContract?.type !== 'empire') {
1083
- this.log.error('Cannot vote for empire payload on non-empire slashing contract');
1084
- break;
1085
- }
1086
- this.log.debug(`Enqueuing slashing vote for payload ${action.payload} at slot ${slotNumber}`, {
1087
- signerAddress,
1088
- });
1089
- await this.enqueueCastSignalHelper(
1090
- slotNumber,
1091
- 'empire-slashing-signal',
1092
- action.payload,
1093
- this.slashingProposerContract,
1094
- signerAddress,
1095
- signer,
1096
- );
1097
- break;
1098
- }
1099
-
1100
- case 'create-empire-payload': {
1101
- this.log.debug(`Enqueuing slashing create payload at slot ${slotNumber}`, { slotNumber, signerAddress });
1102
- const request = this.slashFactoryContract.buildCreatePayloadRequest(action.data);
1103
- await this.simulateAndEnqueueRequest(
1104
- 'create-empire-payload',
1105
- request,
1106
- (receipt: TransactionReceipt) =>
1107
- !!this.slashFactoryContract.tryExtractSlashPayloadCreatedEvent(receipt.logs),
1108
- slotNumber,
1109
- );
1110
- break;
1111
- }
1112
-
1113
- case 'execute-empire-payload': {
1114
- this.log.debug(`Enqueuing slashing execute payload at slot ${slotNumber}`, { slotNumber, signerAddress });
1115
- if (this.slashingProposerContract?.type !== 'empire') {
1116
- this.log.error('Cannot execute slashing payload on non-empire slashing contract');
1117
- return false;
1118
- }
1119
- const empireSlashingProposer = this.slashingProposerContract as EmpireSlashingProposerContract;
1120
- const request = empireSlashingProposer.buildExecuteRoundRequest(action.round);
1121
- await this.simulateAndEnqueueRequest(
1122
- 'execute-empire-payload',
1123
- request,
1124
- (receipt: TransactionReceipt) => !!empireSlashingProposer.tryExtractPayloadSubmittedEvent(receipt.logs),
1125
- slotNumber,
1126
- );
1127
- break;
1128
- }
1129
-
1130
1096
  case 'vote-offenses': {
1131
1097
  this.log.debug(`Enqueuing slashing vote for ${action.votes.length} votes at slot ${slotNumber}`, {
1132
1098
  slotNumber,
@@ -1134,17 +1100,16 @@ export class SequencerPublisher {
1134
1100
  votesCount: action.votes.length,
1135
1101
  signerAddress,
1136
1102
  });
1137
- if (this.slashingProposerContract?.type !== 'tally') {
1138
- this.log.error('Cannot vote for slashing offenses on non-tally slashing contract');
1103
+ if (!this.slashingProposerContract) {
1104
+ this.log.error('No slashing proposer contract available');
1139
1105
  return false;
1140
1106
  }
1141
- const tallySlashingProposer = this.slashingProposerContract as TallySlashingProposerContract;
1142
1107
  const votes = bufferToHex(encodeSlashConsensusVotes(action.votes));
1143
- const request = await tallySlashingProposer.buildVoteRequestFromSigner(votes, slotNumber, signer);
1108
+ const request = await this.slashingProposerContract.buildVoteRequestFromSigner(votes, slotNumber, signer);
1144
1109
  await this.simulateAndEnqueueRequest(
1145
1110
  'vote-offenses',
1146
1111
  request,
1147
- (receipt: TransactionReceipt) => !!tallySlashingProposer.tryExtractVoteCastEvent(receipt.logs),
1112
+ (receipt: TransactionReceipt) => !!this.slashingProposerContract!.tryExtractVoteCastEvent(receipt.logs),
1148
1113
  slotNumber,
1149
1114
  );
1150
1115
  break;
@@ -1156,16 +1121,19 @@ export class SequencerPublisher {
1156
1121
  round: action.round,
1157
1122
  signerAddress,
1158
1123
  });
1159
- if (this.slashingProposerContract?.type !== 'tally') {
1160
- this.log.error('Cannot execute slashing offenses on non-tally slashing contract');
1124
+ if (!this.slashingProposerContract) {
1125
+ this.log.error('No slashing proposer contract available');
1161
1126
  return false;
1162
1127
  }
1163
- const tallySlashingProposer = this.slashingProposerContract as TallySlashingProposerContract;
1164
- const request = tallySlashingProposer.buildExecuteRoundRequest(action.round, action.committees);
1128
+ const executeRequest = this.slashingProposerContract.buildExecuteRoundRequest(
1129
+ action.round,
1130
+ action.committees,
1131
+ );
1165
1132
  await this.simulateAndEnqueueRequest(
1166
1133
  'execute-slash',
1167
- request,
1168
- (receipt: TransactionReceipt) => !!tallySlashingProposer.tryExtractRoundExecutedEvent(receipt.logs),
1134
+ executeRequest,
1135
+ (receipt: TransactionReceipt) =>
1136
+ !!this.slashingProposerContract!.tryExtractRoundExecutedEvent(receipt.logs),
1169
1137
  slotNumber,
1170
1138
  );
1171
1139
  break;
@@ -1227,8 +1195,26 @@ export class SequencerPublisher {
1227
1195
  throw err;
1228
1196
  }
1229
1197
 
1198
+ // Build a pre-check callback that re-validates the checkpoint before L1 submission.
1199
+ // During pipelining this catches stale proposals due to prunes or L1 reorgs that occur during the pipeline sleep.
1200
+ let preCheck = undefined;
1201
+ if (this.epochCache.isProposerPipeliningEnabled()) {
1202
+ preCheck = async () => {
1203
+ this.log.debug(`Re-validating checkpoint ${checkpoint.number} before L1 submission`);
1204
+ await this.validateCheckpointForSubmission(
1205
+ checkpoint,
1206
+ attestationsAndSigners,
1207
+ attestationsAndSignersSignature,
1208
+ {
1209
+ // Forcing pending checkpoint number is included its required if an invalidation request is included
1210
+ forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber,
1211
+ },
1212
+ );
1213
+ };
1214
+ }
1215
+
1230
1216
  this.log.verbose(`Enqueuing checkpoint propose transaction`, { ...checkpoint.toCheckpointInfo(), ...opts });
1231
- await this.addProposeTx(checkpoint, proposeTxArgs, opts);
1217
+ await this.addProposeTx(checkpoint, proposeTxArgs, opts, preCheck);
1232
1218
  }
1233
1219
 
1234
1220
  public enqueueInvalidateCheckpoint(
@@ -1564,6 +1550,7 @@ export class SequencerPublisher {
1564
1550
  forcePendingCheckpointNumber?: CheckpointNumber;
1565
1551
  forceProposedFeeHeader?: { checkpointNumber: CheckpointNumber; feeHeader: FeeHeader };
1566
1552
  } = {},
1553
+ preCheck?: () => Promise<void>,
1567
1554
  ): Promise<void> {
1568
1555
  const slot = checkpoint.header.slotNumber;
1569
1556
  const timer = new Timer();
@@ -1592,6 +1579,7 @@ export class SequencerPublisher {
1592
1579
  },
1593
1580
  lastValidL2Slot: checkpoint.header.slotNumber,
1594
1581
  gasConfig: { ...opts, gasLimit },
1582
+ preCheck,
1595
1583
  blobConfig: {
1596
1584
  blobs: encodedData.blobs.map(b => b.data),
1597
1585
  kzg,
@@ -1,8 +1,8 @@
1
1
  # Sequencer Timing Model
2
2
 
3
- The Aztec sequencer divides each slot into **fixed-duration sub-slots**. Each sub-slot has a pre-defined start and end time based on an initialization offset (how much time we expect syncing the previous slot will take), a finalization time (how much time we need for closing a checkpoint and publishing it to L1), and the configured block duration.
3
+ The Aztec sequencer divides each slot into **fixed-duration sub-slots**. Each sub-slot has a pre-defined start and end time based on an initialization offset (how much time we expect syncing the previous slot will take), the configured block duration, and whether checkpoint finalization is paid for in the current slot or deferred under proposer pipelining.
4
4
 
5
- **Example: 72-second slot with 8-second sub-slots**
5
+ **Example: 72-second slot with 8-second sub-slots (non-pipelined)**
6
6
 
7
7
  ```
8
8
  0s: Slot starts
@@ -31,7 +31,7 @@ Deadlines are fixed relative to slot start, not relative to when work actually c
31
31
 
32
32
  ## Overview
33
33
 
34
- The Aztec sequencer operates in fixed-duration **slots** (typically 72 seconds). During each slot, a designated proposer builds multiple **blocks** containing transactions over multiple **sub-slots**, then collects a single round of attestations for the entire **checkpoint** from validators, and finally publishes the resulting checkpoint to L1 Ethereum.
34
+ The Aztec sequencer operates in fixed-duration **slots** (typically 72 seconds). During each slot, a designated proposer builds multiple **blocks** containing transactions over multiple **sub-slots**. In the default mode, the same slot also reserves time to collect attestations for the resulting **checkpoint**, finalize it, and publish it to L1 Ethereum. When proposer pipelining is enabled, the slot budget for block building is larger because checkpoint finalization is deferred to the next target slot.
35
35
 
36
36
  ## Key Concepts
37
37
 
@@ -42,12 +42,14 @@ The Aztec sequencer operates in fixed-duration **slots** (typically 72 seconds).
42
42
  - **Checkpoint**: The collection of all blocks built in a slot, attested by validators and published to L1
43
43
  - **Sub-slot**: A fixed-duration time window within a slot (e.g., 8 seconds) during which a block should be built
44
44
 
45
- In a typical configuration, a 72-second slot contains:
45
+ In a typical configuration without pipelining, a 72-second slot contains:
46
46
  - 1 initialization period (2 seconds)
47
47
  - 5 block-building sub-slots (8 seconds each = 40 seconds)
48
48
  - 1 last validator re-execution sub-slot (8 seconds)
49
49
  - 1 attestation and publishing period (17 seconds)
50
50
 
51
+ With proposer pipelining enabled, the last validator re-execution sub-slot is still reserved, but the checkpoint finalization and L1 publishing budget is no longer subtracted when deciding how many block-building sub-slots fit in the slot.
52
+
51
53
  ### The Fixed Sub-Slot Model
52
54
 
53
55
  Building multiple blocks per slot uses **fixed sub-slots** with predictable deadlines:
@@ -75,14 +77,18 @@ These values are configurable but must satisfy certain constraints (explained be
75
77
 
76
78
  ## Calculating Sub-Slots and Blocks
77
79
 
78
- Given a slot configuration, we calculate how many blocks fit using this formula:
80
+ Given a slot configuration, we calculate how many blocks fit using these formulas:
79
81
 
80
82
  ```
81
- timeReservedAtEnd = blockDuration (last sub-slot for reexecution)
82
- + propagationTime (validators receive proposal)
83
- + propagationTime (attestations come back)
84
- + finalizationTime (checkpoint finalization)
85
- + l1PublishingTime (L1 transaction)
83
+ checkpointFinalizationTime = propagationTime
84
+ + propagationTime
85
+ + finalizationTime
86
+ + l1PublishingTime
87
+
88
+ timeReservedAtEnd (normal mode) = blockDuration (last sub-slot for reexecution)
89
+ + checkpointFinalizationTime
90
+
91
+ timeReservedAtEnd (pipelining) = blockDuration (last sub-slot for reexecution only)
86
92
 
87
93
  timeAvailableForBlocks = slotDuration - initializationOffset - timeReservedAtEnd
88
94
 
@@ -101,6 +107,62 @@ This means:
101
107
  - Sub-slot 6: Reserved for validator re-execution of block 5
102
108
  - After sub-slot 6: Attestation collection, finalization, and L1 publishing
103
109
 
110
+ **The same slot with proposer pipelining enabled:**
111
+ ```
112
+ timeReservedAtEnd = 8s
113
+ timeAvailableForBlocks = 72s - 2s - 8s = 62s
114
+ numberOfBlocks = floor(62s / 8s) = 7 blocks
115
+ ```
116
+
117
+ The extra two block opportunities come from not charging the current slot for checkpoint finalization and L1 publishing.
118
+
119
+ ### Pipelining Mode
120
+
121
+ When proposer pipelining is enabled, the sequencer uses the current wall-clock slot to build the checkpoint for the **next target slot**.
122
+
123
+ It helps to think in terms of two different slots:
124
+
125
+ - **Wall-clock slot N-1**: The sequencer initializes checkpoint `N`, builds its blocks, and validators re-execute the last block
126
+ - **Target slot N**: Checkpoint `N` is proposed, attestations are gathered, and the L1 transaction is submitted
127
+
128
+ So the work is split like this:
129
+
130
+ - **During slot N-1**: Initialization, block building, and last-block re-execution
131
+ - **Near the end of slot N-1**: The checkpoint proposal is broadcast and validators attest to checkpoint N.
132
+ - **During slot N**: The proposer collects signatures, and the checkpoint is submitted to L1
133
+
134
+ In other words, pipelining does not mean "do everything for slot N earlier". It specifically moves **block production and block re-execution** earlier, while **checkpoint proposal, attestation gathering, and L1 submission** remain aligned with slot `N`.
135
+
136
+ **Example: building checkpoint 12 while wall-clock time is in slot 11**
137
+ ```
138
+ Slot 11 (wall clock):
139
+ - Build blocks that will make up checkpoint 12
140
+ - Validators re-execute the last block of checkpoint 12
141
+ - Broadcast checkpoint 12 proposal
142
+ - Collect checkpoint 12 attestations
143
+
144
+ Slot 12 (target/submission slot):
145
+ - Collect remaining checkpoint 12 attestations
146
+ - Submit checkpoint 12 to L1
147
+ ```
148
+
149
+ For timetable purposes, this changes two things:
150
+
151
+ - `maxNumberOfBlocks` is computed by reserving only the final validator re-execution sub-slot
152
+ - `initializeDeadline` no longer subtracts checkpoint finalization time; it only requires enough time for initialization, execution, and validator re-execution
153
+
154
+ In code, that means:
155
+
156
+ ```
157
+ initializeDeadline (normal mode) =
158
+ slotDuration - initializationOffset - 2 * minExecutionTime - checkpointFinalizationTime
159
+
160
+ initializeDeadline (pipelining) =
161
+ slotDuration - initializationOffset - 2 * minExecutionTime
162
+ ```
163
+
164
+ The fixed sub-slot deadlines themselves do not change. Pipelining only changes how much of the slot is considered available for block building.
165
+
104
166
  ## The Sequencer's Work
105
167
 
106
168
  When elected as proposer for a slot, the sequencer performs these tasks:
@@ -226,7 +288,9 @@ After the last block is built and validators have re-executed it:
226
288
 
227
289
  **Time reserved:** `2*propagationTime + finalizationTime + l1PublishingTime = 2s + 2s + 1s + 12s = 17s`
228
290
 
229
- This 17s comes after the last sub-slot, ensuring we have enough time to complete the checkpoint. If the sequencer receives the necessary attestations before the reserved time, the L1 tx is submitted earlier.
291
+ In the non-pipelined path, this 17s comes after the last sub-slot, ensuring we have enough time to complete the checkpoint. If the sequencer receives the necessary attestations before the reserved time, the L1 tx is submitted earlier.
292
+
293
+ With proposer pipelining enabled, this finalization budget is not charged against the current slot when calculating how many blocks fit. The checkpoint is instead queued for submission at the start of the target slot, so proposal broadcast, attestation gathering, and L1 submission happen in slot `N` while block building and block re-execution already happened in slot `N-1`.
230
294
 
231
295
  ## Handling Timing Variations
232
296
 
@@ -399,7 +463,7 @@ When configuring timing parameters, ensure these constraints are satisfied:
399
463
 
400
464
  ### Minimum Slot Duration
401
465
 
402
- For a valid configuration:
466
+ For a valid multi-block configuration without pipelining:
403
467
  ```
404
468
  slotDuration >= initializationOffset
405
469
  + blockDuration * 2 (at least 2 blocks)
@@ -414,6 +478,11 @@ Simplified:
414
478
  slotDuration >= initializationOffset + 3*blockDuration + 2*propagationTime + finalizationTime + l1PublishingTime
415
479
  ```
416
480
 
481
+ With proposer pipelining enabled, the same "at least 2 buildable blocks plus the final validator re-execution sub-slot" requirement becomes:
482
+ ```
483
+ slotDuration >= initializationOffset + 3*blockDuration
484
+ ```
485
+
417
486
  **Example:**
418
487
  ```
419
488
  slotDuration >= 2s + 3*8s + 2*2s + 1s + 12s = 2s + 24s + 4s + 1s + 12s = 43s
@@ -190,8 +190,6 @@ export class CheckpointProposalJob implements Traceable {
190
190
  ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000)
191
191
  : new Date(this.dateProvider.now());
192
192
 
193
- // TODO(https://github.com/AztecProtocol/aztec-packages/pull/21250): should discard the pending submission if a reorg occurs underneath
194
-
195
193
  // Schedule L1 submission in the background so the work loop returns immediately.
196
194
  // The publisher will sleep until submitAfter, then send the bundled requests.
197
195
  // The promise is stored so it can be awaited during shutdown.
@@ -344,7 +342,7 @@ export class CheckpointProposalJob implements Traceable {
344
342
  };
345
343
 
346
344
  let blocksInCheckpoint: L2Block[] = [];
347
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
345
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
348
346
  const checkpointBuildTimer = new Timer();
349
347
 
350
348
  try {
@@ -431,19 +429,12 @@ export class CheckpointProposalJob implements Traceable {
431
429
  };
432
430
  }
433
431
 
434
- // Include the block pending broadcast in the checkpoint proposal if any
435
- const lastBlock = blockPendingBroadcast && {
436
- blockHeader: blockPendingBroadcast.block.header,
437
- indexWithinCheckpoint: blockPendingBroadcast.block.indexWithinCheckpoint,
438
- txs: blockPendingBroadcast.txs,
439
- };
440
-
441
432
  // Create the checkpoint proposal and broadcast it
442
433
  const proposal = await this.validatorClient.createCheckpointProposal(
443
434
  checkpoint.header,
444
435
  checkpoint.archive.root,
445
436
  feeAssetPriceModifier,
446
- lastBlock,
437
+ blockPendingBroadcast,
447
438
  this.proposer,
448
439
  checkpointProposalOptions,
449
440
  );
@@ -500,14 +491,14 @@ export class CheckpointProposalJob implements Traceable {
500
491
  blockProposalOptions: BlockProposalOptions,
501
492
  ): Promise<{
502
493
  blocksInCheckpoint: L2Block[];
503
- blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined;
494
+ blockPendingBroadcast: BlockProposal | undefined;
504
495
  }> {
505
496
  const blocksInCheckpoint: L2Block[] = [];
506
497
  const txHashesAlreadyIncluded = new Set<string>();
507
498
  const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1);
508
499
 
509
500
  // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal
510
- let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined;
501
+ let blockPendingBroadcast: BlockProposal | undefined = undefined;
511
502
 
512
503
  while (true) {
513
504
  const blocksBuilt = blocksInCheckpoint.length;
@@ -540,19 +531,20 @@ export class CheckpointProposalJob implements Traceable {
540
531
  txHashesAlreadyIncluded,
541
532
  });
542
533
 
543
- // TODO(palla/mbps): Review these conditions. We may want to keep trying in some scenarios.
544
- if (!buildResult && timingInfo.isLastBlock) {
545
- // If no block was produced due to not enough txs and this was the last subslot, exit
546
- break;
547
- } else if (!buildResult && timingInfo.deadline !== undefined) {
548
- // But if there is still time for more blocks, wait until the next subslot and try again
534
+ // If we failed to build the block due to insufficient txs, we try again if there is still time left in the slot
535
+ if ('failure' in buildResult) {
536
+ // If this was the last subslot, or we're running with a single block per slot, we're done
537
+ if (timingInfo.isLastBlock || timingInfo.deadline === undefined) {
538
+ break;
539
+ }
540
+ // Otherwise, if there is still time for more blocks, we wait until the next subslot and try again
549
541
  await this.waitUntilNextSubslot(timingInfo.deadline);
550
542
  continue;
551
- } else if (!buildResult) {
552
- // Exit if there is no possibility of building more blocks
553
- break;
554
- } else if ('error' in buildResult) {
555
- // If there was an error building the block, just exit the loop and give up the rest of the slot
543
+ }
544
+
545
+ // If there was an error building the block, we just exit the loop and give up the rest of the slot.
546
+ // We don't want to risk building more blocks if something went wrong.
547
+ if ('error' in buildResult) {
556
548
  if (!(buildResult.error instanceof SequencerInterruptedError)) {
557
549
  this.log.warn(`Halting block building for slot ${this.targetSlot}`, {
558
550
  slot: this.targetSlot,
@@ -567,30 +559,26 @@ export class CheckpointProposalJob implements Traceable {
567
559
  blocksInCheckpoint.push(block);
568
560
  usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString()));
569
561
 
570
- // If this is the last block, sync it to the archiver and exit the loop
571
- // so we can build the checkpoint and start collecting attestations.
562
+ // Sign the block proposal. This will throw if HA signing fails.
563
+ const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
564
+
565
+ // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal,
566
+ // so we avoid polluting our archive with a block that would fail.
567
+ // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
568
+ // If this throws, we abort the entire checkpoint.
569
+ await this.syncProposedBlockToArchiver(block);
570
+
571
+ // If this is the last block, do not broadcast it, since it will be included in the checkpoint proposal.
572
572
  if (timingInfo.isLastBlock) {
573
- await this.syncProposedBlockToArchiver(block);
574
573
  this.log.verbose(`Completed final block ${blockNumber} for slot ${this.targetSlot}`, {
575
574
  slot: this.targetSlot,
576
575
  blockNumber,
577
576
  blocksBuilt,
578
577
  });
579
- blockPendingBroadcast = { block, txs: usedTxs };
578
+ blockPendingBroadcast = proposal;
580
579
  break;
581
580
  }
582
581
 
583
- // Broadcast the block proposal (unless we're in fisherman mode) unless the block is the last one,
584
- // in which case we'll broadcast it along with the checkpoint at the end of the loop.
585
- // Note that we only send the block to the archiver if we manage to create the proposal, so if there's
586
- // a HA error we don't pollute our archiver with a block that won't make it to the chain.
587
- const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions);
588
-
589
- // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal.
590
- // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building.
591
- // If this throws, we abort the entire checkpoint.
592
- await this.syncProposedBlockToArchiver(block);
593
-
594
582
  // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it.
595
583
  proposal && (await this.p2pClient.broadcastProposal(proposal));
596
584
 
@@ -650,7 +638,9 @@ export class CheckpointProposalJob implements Traceable {
650
638
  buildDeadline: Date | undefined;
651
639
  txHashesAlreadyIncluded: Set<string>;
652
640
  },
653
- ): Promise<{ block: L2Block; usedTxs: Tx[] } | { error: Error } | undefined> {
641
+ ): Promise<
642
+ { block: L2Block; usedTxs: Tx[] } | { failure: 'insufficient-txs' | 'insufficient-valid-txs' } | { error: Error }
643
+ > {
654
644
  const { blockTimestamp, forceCreate, blockNumber, indexWithinCheckpoint, buildDeadline, txHashesAlreadyIncluded } =
655
645
  opts;
656
646
 
@@ -669,7 +659,7 @@ export class CheckpointProposalJob implements Traceable {
669
659
  );
670
660
  this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
671
661
  this.metrics.recordBlockProposalFailed('insufficient_txs');
672
- return undefined;
662
+ return { failure: 'insufficient-txs' };
673
663
  }
674
664
 
675
665
  // Create iterator to pending txs. We filter out txs already included in previous blocks in the checkpoint
@@ -732,7 +722,7 @@ export class CheckpointProposalJob implements Traceable {
732
722
  slot: this.targetSlot,
733
723
  });
734
724
  this.metrics.recordBlockProposalFailed('insufficient_valid_txs');
735
- return undefined;
725
+ return { failure: 'insufficient-valid-txs' };
736
726
  }
737
727
 
738
728
  // Block creation succeeded, emit stats and metrics
@@ -1012,9 +1002,13 @@ export class CheckpointProposalJob implements Traceable {
1012
1002
  * Adds the proposed block to the archiver so it's available via P2P.
1013
1003
  * Gossip doesn't echo messages back to the sender, so the proposer's archiver/world-state
1014
1004
  * would never receive its own block without this explicit sync.
1005
+ *
1006
+ * In fisherman mode we skip this push: the fisherman builds blocks locally for validation
1007
+ * and fee analysis only, and pushing them to the archiver causes spurious reorg cascades
1008
+ * whenever the real proposer's block arrives from L1.
1015
1009
  */
1016
1010
  private async syncProposedBlockToArchiver(block: L2Block): Promise<void> {
1017
- if (this.config.skipPushProposedBlocksToArchiver) {
1011
+ if (this.config.skipPushProposedBlocksToArchiver || this.config.fishermanMode) {
1018
1012
  this.log.warn(`Skipping push of proposed block ${block.number} to archiver`, {
1019
1013
  blockNumber: block.number,
1020
1014
  slot: block.header.globalVariables.slotNumber,
@@ -123,6 +123,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
123
123
  p2pPropagationTime: this.config.attestationPropagationTime,
124
124
  blockDurationMs: this.config.blockDurationMs,
125
125
  enforce: this.config.enforceTimeTable,
126
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
126
127
  },
127
128
  this.metrics,
128
129
  this.log,
@@ -299,16 +300,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
299
300
  // Next checkpoint follows from the last synced one
300
301
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
301
302
 
302
- // Guard: don't exceed 1-deep pipeline. Without a proposed checkpoint, we can only build
303
- // confirmed + 1. With a proposed checkpoint, we can build confirmed + 2.
304
- const confirmedCkpt = syncedTo.checkpointedCheckpointNumber;
305
- if (checkpointNumber > confirmedCkpt + 2) {
306
- this.log.warn(
307
- `Skipping slot ${targetSlot}: checkpoint ${checkpointNumber} exceeds max pipeline depth (confirmed=${confirmedCkpt})`,
308
- );
309
- return undefined;
310
- }
311
-
312
303
  const logCtx = {
313
304
  nowSeconds,
314
305
  syncedToL2Slot: syncedTo.syncedL2Slot,
@@ -329,6 +320,16 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
329
320
  return undefined;
330
321
  }
331
322
 
323
+ // Guard: don't exceed 1-deep pipeline. Without a proposed checkpoint, we can only build
324
+ // confirmed + 1. With a proposed checkpoint, we can build confirmed + 2.
325
+ const confirmedCkpt = syncedTo.checkpointedCheckpointNumber;
326
+ if (checkpointNumber > confirmedCkpt + 2) {
327
+ this.log.verbose(
328
+ `Skipping slot ${targetSlot}: checkpoint ${checkpointNumber} exceeds max pipeline depth (confirmed=${confirmedCkpt})`,
329
+ );
330
+ return undefined;
331
+ }
332
+
332
333
  // Check that the target slot is not taken by a block already (should never happen, since only us can propose for this slot)
333
334
  if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= targetSlot) {
334
335
  this.log.warn(
@@ -567,12 +568,12 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
567
568
  .getL2Tips()
568
569
  .then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed, proposedCheckpoint: t.proposedCheckpoint })),
569
570
  this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block),
570
- this.l1ToL2MessageSource.getL2Tips().then(t => t.proposed),
571
+ this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })),
571
572
  this.l2BlockSource.getPendingChainValidationStatus(),
572
573
  this.l2BlockSource.getProposedCheckpointOnly(),
573
574
  ] as const);
574
575
 
575
- const [worldState, l2Tips, p2p, l1ToL2MessageSource, pendingChainValidationStatus, proposedCheckpointData] =
576
+ const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] =
576
577
  syncedBlocks;
577
578
 
578
579
  // Handle zero as a special case, since the block hash won't match across services if we're changing the prefilled data for the genesis block,
@@ -580,19 +581,25 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
580
581
  // TODO(palla/mbps): Fix the above. All components should be able to handle dynamic genesis block hashes.
581
582
  const result =
582
583
  (l2Tips.proposed.number === 0 &&
584
+ l2Tips.checkpointed.block.number === 0 &&
585
+ l2Tips.checkpointed.checkpoint.number === 0 &&
583
586
  worldState.number === 0 &&
584
587
  p2p.number === 0 &&
585
- l1ToL2MessageSource.number === 0) ||
588
+ l1ToL2MessageSourceTips.proposed.number === 0 &&
589
+ l1ToL2MessageSourceTips.checkpointed.block.number === 0 &&
590
+ l1ToL2MessageSourceTips.checkpointed.checkpoint.number === 0) ||
586
591
  (worldState.hash === l2Tips.proposed.hash &&
587
592
  p2p.hash === l2Tips.proposed.hash &&
588
- l1ToL2MessageSource.hash === l2Tips.proposed.hash);
593
+ l1ToL2MessageSourceTips.proposed.hash === l2Tips.proposed.hash &&
594
+ l1ToL2MessageSourceTips.checkpointed.block.hash === l2Tips.checkpointed.block.hash &&
595
+ l1ToL2MessageSourceTips.checkpointed.checkpoint.hash === l2Tips.checkpointed.checkpoint.hash);
589
596
 
590
597
  if (!result) {
591
598
  this.log.debug(`Sequencer sync check failed`, {
592
599
  worldState,
593
600
  l2BlockSource: l2Tips.proposed,
594
601
  p2p,
595
- l1ToL2MessageSource,
602
+ l1ToL2MessageSourceTips,
596
603
  });
597
604
  return undefined;
598
605
  }