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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dest/client/sequencer-client.d.ts +4 -1
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +46 -27
  4. package/dest/config.d.ts +25 -5
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +21 -12
  7. package/dest/global_variable_builder/global_builder.d.ts +15 -9
  8. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  9. package/dest/global_variable_builder/global_builder.js +29 -25
  10. package/dest/global_variable_builder/index.d.ts +2 -2
  11. package/dest/global_variable_builder/index.d.ts.map +1 -1
  12. package/dest/publisher/config.d.ts +13 -1
  13. package/dest/publisher/config.d.ts.map +1 -1
  14. package/dest/publisher/config.js +17 -2
  15. package/dest/publisher/sequencer-publisher-factory.d.ts +3 -5
  16. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  17. package/dest/publisher/sequencer-publisher-factory.js +2 -3
  18. package/dest/publisher/sequencer-publisher.d.ts +58 -32
  19. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  20. package/dest/publisher/sequencer-publisher.js +132 -89
  21. package/dest/sequencer/checkpoint_proposal_job.d.ts +36 -9
  22. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  23. package/dest/sequencer/checkpoint_proposal_job.js +302 -180
  24. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  25. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  26. package/dest/sequencer/checkpoint_voter.js +2 -5
  27. package/dest/sequencer/events.d.ts +2 -1
  28. package/dest/sequencer/events.d.ts.map +1 -1
  29. package/dest/sequencer/metrics.d.ts +5 -1
  30. package/dest/sequencer/metrics.d.ts.map +1 -1
  31. package/dest/sequencer/metrics.js +11 -0
  32. package/dest/sequencer/sequencer.d.ts +23 -10
  33. package/dest/sequencer/sequencer.d.ts.map +1 -1
  34. package/dest/sequencer/sequencer.js +130 -71
  35. package/dest/sequencer/timetable.d.ts +7 -3
  36. package/dest/sequencer/timetable.d.ts.map +1 -1
  37. package/dest/sequencer/timetable.js +21 -12
  38. package/dest/sequencer/types.d.ts +2 -2
  39. package/dest/sequencer/types.d.ts.map +1 -1
  40. package/dest/test/mock_checkpoint_builder.d.ts +7 -9
  41. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  42. package/dest/test/mock_checkpoint_builder.js +39 -30
  43. package/package.json +27 -28
  44. package/src/client/sequencer-client.ts +56 -28
  45. package/src/config.ts +28 -14
  46. package/src/global_variable_builder/global_builder.ts +37 -26
  47. package/src/global_variable_builder/index.ts +1 -1
  48. package/src/publisher/config.ts +32 -0
  49. package/src/publisher/sequencer-publisher-factory.ts +3 -6
  50. package/src/publisher/sequencer-publisher.ts +205 -127
  51. package/src/sequencer/README.md +81 -12
  52. package/src/sequencer/checkpoint_proposal_job.ts +385 -199
  53. package/src/sequencer/checkpoint_voter.ts +1 -12
  54. package/src/sequencer/events.ts +1 -1
  55. package/src/sequencer/metrics.ts +14 -0
  56. package/src/sequencer/sequencer.ts +186 -80
  57. package/src/sequencer/timetable.ts +26 -15
  58. package/src/sequencer/types.ts +1 -1
  59. package/src/test/mock_checkpoint_builder.ts +51 -48
@@ -2,7 +2,6 @@ import type { SlotNumber } from '@aztec/foundation/branded-types';
2
2
  import type { EthAddress } from '@aztec/foundation/eth-address';
3
3
  import type { Logger } from '@aztec/foundation/log';
4
4
  import type { SlasherClientInterface } from '@aztec/slasher';
5
- import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
6
5
  import type { ResolvedSequencerConfig } from '@aztec/stdlib/interfaces/server';
7
6
  import type { ValidatorClient } from '@aztec/validator-client';
8
7
  import { DutyAlreadySignedError } from '@aztec/validator-ha-signer/errors';
@@ -18,7 +17,6 @@ import type { SequencerRollupConstants } from './types.js';
18
17
  * Handles governance and slashing voting for a given slot.
19
18
  */
20
19
  export class CheckpointVoter {
21
- private slotTimestamp: bigint;
22
20
  private governanceSigner: (msg: TypedDataDefinition) => Promise<`0x${string}`>;
23
21
  private slashingSigner: (msg: TypedDataDefinition) => Promise<`0x${string}`>;
24
22
 
@@ -33,8 +31,6 @@ export class CheckpointVoter {
33
31
  private readonly metrics: SequencerMetrics,
34
32
  private readonly log: Logger,
35
33
  ) {
36
- this.slotTimestamp = getTimestampForSlot(this.slot, this.l1Constants);
37
-
38
34
  // Create separate signers with appropriate duty contexts for governance and slashing votes
39
35
  // These use HA protection to ensure only one node signs per slot/duty
40
36
  const governanceContext: SigningContext = { slot: this.slot, dutyType: DutyType.GOVERNANCE_VOTE };
@@ -77,7 +73,6 @@ export class CheckpointVoter {
77
73
  return await this.publisher.enqueueGovernanceCastSignal(
78
74
  governanceProposerPayload,
79
75
  this.slot,
80
- this.slotTimestamp,
81
76
  this.attestorAddress,
82
77
  this.governanceSigner,
83
78
  );
@@ -108,13 +103,7 @@ export class CheckpointVoter {
108
103
 
109
104
  this.metrics.recordSlashingAttempt(actions.length);
110
105
 
111
- return await this.publisher.enqueueSlashingActions(
112
- actions,
113
- this.slot,
114
- this.slotTimestamp,
115
- this.attestorAddress,
116
- this.slashingSigner,
117
- );
106
+ return await this.publisher.enqueueSlashingActions(actions, this.slot, this.attestorAddress, this.slashingSigner);
118
107
  } catch (err) {
119
108
  if (err instanceof DutyAlreadySignedError) {
120
109
  this.log.info(`Slashing vote already signed by another node`, {
@@ -13,7 +13,7 @@ export type SequencerEvents = {
13
13
  ['proposer-rollup-check-failed']: (args: { reason: string; slot: SlotNumber }) => void;
14
14
  ['block-tx-count-check-failed']: (args: { minTxs: number; availableTxs: number; slot: SlotNumber }) => void;
15
15
  ['block-build-failed']: (args: { reason: string; slot: SlotNumber }) => void;
16
- ['block-proposed']: (args: { blockNumber: BlockNumber; slot: SlotNumber }) => void;
16
+ ['block-proposed']: (args: { blockNumber: BlockNumber; slot: SlotNumber; buildSlot: SlotNumber }) => void;
17
17
  ['checkpoint-empty']: (args: { slot: SlotNumber }) => void;
18
18
  ['checkpoint-publish-failed']: (args: {
19
19
  slot: SlotNumber;
@@ -49,6 +49,8 @@ export class SequencerMetrics {
49
49
  private checkpointBlockCount: Gauge;
50
50
  private checkpointTxCount: Gauge;
51
51
  private checkpointTotalMana: Gauge;
52
+ private pipelineDepth: Gauge;
53
+ private pipelineDiscards: UpDownCounter;
52
54
 
53
55
  // Fisherman fee analysis metrics
54
56
  private fishermanWouldBeIncluded: UpDownCounter;
@@ -143,6 +145,10 @@ export class SequencerMetrics {
143
145
 
144
146
  this.slashingAttempts = createUpDownCounterWithDefault(this.meter, Metrics.SEQUENCER_SLASHING_ATTEMPTS_COUNT);
145
147
 
148
+ this.pipelineDepth = this.meter.createGauge(Metrics.SEQUENCER_PIPELINE_DEPTH);
149
+ this.pipelineDiscards = createUpDownCounterWithDefault(this.meter, Metrics.SEQUENCER_PIPELINE_DISCARDS_COUNT);
150
+ this.pipelineDepth.record(0);
151
+
146
152
  // Fisherman fee analysis metrics
147
153
  this.fishermanWouldBeIncluded = createUpDownCounterWithDefault(
148
154
  this.meter,
@@ -234,6 +240,14 @@ export class SequencerMetrics {
234
240
  });
235
241
  }
236
242
 
243
+ recordPipelineDepth(depth: number) {
244
+ this.pipelineDepth.record(depth);
245
+ }
246
+
247
+ recordPipelineDiscard(count = 1) {
248
+ this.pipelineDiscards.add(count);
249
+ }
250
+
237
251
  incOpenSlot(slot: SlotNumber, proposer: string) {
238
252
  // sequencer went through the loop a second time. Noop
239
253
  if (slot === this.lastSeenSlot) {
@@ -13,8 +13,8 @@ import type { TypedEventEmitter } from '@aztec/foundation/types';
13
13
  import type { P2P } from '@aztec/p2p';
14
14
  import type { SlasherClientInterface } from '@aztec/slasher';
15
15
  import type { BlockData, L2BlockSink, L2BlockSource, ValidateCheckpointResult } from '@aztec/stdlib/block';
16
- import type { Checkpoint } from '@aztec/stdlib/checkpoint';
17
- import { getSlotAtTimestamp, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
16
+ import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
17
+ import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
18
18
  import {
19
19
  type ResolvedSequencerConfig,
20
20
  type SequencerConfig,
@@ -72,6 +72,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
72
72
  /** The last epoch for which we logged strategy comparison in fisherman mode. */
73
73
  private lastEpochForStrategyComparison: EpochNumber | undefined;
74
74
 
75
+ /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */
76
+ private lastCheckpointProposalJob: CheckpointProposalJob | undefined;
77
+
75
78
  /** The maximum number of seconds that the sequencer can be into a slot to transition to a particular state. */
76
79
  protected timetable!: SequencerTimetable;
77
80
 
@@ -120,6 +123,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
120
123
  p2pPropagationTime: this.config.attestationPropagationTime,
121
124
  blockDurationMs: this.config.blockDurationMs,
122
125
  enforce: this.config.enforceTimeTable,
126
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
123
127
  },
124
128
  this.metrics,
125
129
  this.log,
@@ -147,8 +151,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
147
151
  public async stop(): Promise<void> {
148
152
  this.log.info(`Stopping sequencer`);
149
153
  this.setState(SequencerState.STOPPING, undefined, { force: true });
150
- this.publisherFactory.interruptAll();
154
+ await this.publisherFactory.stopAll();
151
155
  await this.runningPromise?.stop();
156
+ await this.lastCheckpointProposalJob?.awaitPendingSubmission();
152
157
  this.setState(SequencerState.STOPPED, undefined, { force: true });
153
158
  this.log.info('Stopped sequencer');
154
159
  }
@@ -192,14 +197,25 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
192
197
  @trackSpan('Sequencer.work')
193
198
  protected async work() {
194
199
  this.setState(SequencerState.SYNCHRONIZING, undefined);
195
- const { slot, ts, now, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
200
+ const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
201
+ const { slot: targetSlot, epoch: targetEpoch } = this.epochCache.getTargetEpochAndSlotInNextL1Slot();
196
202
 
197
203
  // Check if we are synced and it's our slot, grab a publisher, check previous block invalidation, etc
198
- const checkpointProposalJob = await this.prepareCheckpointProposal(epoch, slot, ts, now);
204
+ const checkpointProposalJob = await this.prepareCheckpointProposal(
205
+ slot,
206
+ targetSlot,
207
+ epoch,
208
+ targetEpoch,
209
+ ts,
210
+ nowSeconds,
211
+ );
199
212
  if (!checkpointProposalJob) {
200
213
  return;
201
214
  }
202
215
 
216
+ // Track the job so we can await its pending L1 submission during shutdown
217
+ this.lastCheckpointProposalJob = checkpointProposalJob;
218
+
203
219
  // Execute the checkpoint proposal job
204
220
  const checkpoint = await checkpointProposalJob.execute();
205
221
 
@@ -208,13 +224,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
208
224
  this.lastCheckpointProposed = checkpoint;
209
225
  }
210
226
 
211
- // Log fee strategy comparison if on fisherman
227
+ // Log fee strategy comparison if on fisherman (uses target epoch since we mirror the proposer's perspective)
212
228
  if (
213
229
  this.config.fishermanMode &&
214
- (this.lastEpochForStrategyComparison === undefined || epoch > this.lastEpochForStrategyComparison)
230
+ (this.lastEpochForStrategyComparison === undefined || targetEpoch > this.lastEpochForStrategyComparison)
215
231
  ) {
216
- this.logStrategyComparison(epoch, checkpointProposalJob.getPublisher());
217
- this.lastEpochForStrategyComparison = epoch;
232
+ this.logStrategyComparison(targetEpoch, checkpointProposalJob.getPublisher());
233
+ this.lastEpochForStrategyComparison = targetEpoch;
218
234
  }
219
235
 
220
236
  return checkpoint;
@@ -226,44 +242,49 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
226
242
  * @returns CheckpointProposalJob if successful, undefined if we are not yet synced or are not the proposer.
227
243
  */
228
244
  @trackSpan('Sequencer.prepareCheckpointProposal')
229
- private async prepareCheckpointProposal(
230
- epoch: EpochNumber,
245
+ protected async prepareCheckpointProposal(
231
246
  slot: SlotNumber,
247
+ targetSlot: SlotNumber,
248
+ epoch: EpochNumber,
249
+ targetEpoch: EpochNumber,
232
250
  ts: bigint,
233
- now: bigint,
251
+ nowSeconds: bigint,
234
252
  ): Promise<CheckpointProposalJob | undefined> {
235
- // Check we have not already processed this slot (cheapest check)
253
+ // Check we have not already processed this target slot (cheapest check)
236
254
  // We only check this if enforce timetable is set, since we want to keep processing the same slot if we are not
237
255
  // running against actual time (eg when we use sandbox-style automining)
238
256
  if (
239
257
  this.lastSlotForCheckpointProposalJob &&
240
- this.lastSlotForCheckpointProposalJob >= slot &&
258
+ this.lastSlotForCheckpointProposalJob >= targetSlot &&
241
259
  this.config.enforceTimeTable
242
260
  ) {
243
- this.log.trace(`Slot ${slot} has already been processed`);
261
+ this.log.trace(`Target slot ${targetSlot} has already been processed`);
244
262
  return undefined;
245
263
  }
246
264
 
247
- // But if we have already proposed for this slot, the we definitely have to skip it, automining or not
248
- if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= slot) {
249
- this.log.trace(`Slot ${slot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`);
265
+ // But if we have already proposed for this slot, then we definitely have to skip it, automining or not
266
+ if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= targetSlot) {
267
+ this.log.trace(
268
+ `Slot ${targetSlot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`,
269
+ );
250
270
  return undefined;
251
271
  }
252
272
 
253
273
  // Check all components are synced to latest as seen by the archiver (queries all subsystems)
254
274
  const syncedTo = await this.checkSync({ ts, slot });
255
275
  if (!syncedTo) {
256
- await this.tryVoteWhenSyncFails({ slot, ts });
276
+ await this.tryVoteWhenSyncFails({ slot, targetSlot, ts });
257
277
  return undefined;
258
278
  }
259
279
 
260
- // If escape hatch is open for this epoch, do not start checkpoint proposal work and do not attempt invalidations.
280
+ // If escape hatch is open for the target epoch, do not start checkpoint proposal work and do not attempt invalidations.
261
281
  // Still perform governance/slashing voting (as proposer) once per slot.
262
- const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(epoch);
282
+ // When pipelining, we check the target epoch (slot+1's epoch) since that's the epoch we're building for.
283
+ const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(targetEpoch);
263
284
 
264
285
  if (isEscapeHatchOpen) {
265
286
  this.setState(SequencerState.PROPOSER_CHECK, slot);
266
- const [canPropose, proposer] = await this.checkCanPropose(slot);
287
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
267
288
  if (canPropose) {
268
289
  await this.tryVoteWhenEscapeHatchOpen({ slot, proposer });
269
290
  } else {
@@ -280,18 +301,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
280
301
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
281
302
 
282
303
  const logCtx = {
283
- now,
284
- syncedToL1Ts: syncedTo.l1Timestamp,
285
- syncedToL2Slot: getSlotAtTimestamp(syncedTo.l1Timestamp, this.l1Constants),
304
+ nowSeconds,
305
+ syncedToL2Slot: syncedTo.syncedL2Slot,
286
306
  slot,
307
+ targetSlot,
287
308
  slotTs: ts,
288
309
  checkpointNumber,
289
310
  isPendingChainValid: pick(syncedTo.pendingChainValidationStatus, 'valid', 'reason', 'invalidIndex'),
290
311
  };
291
312
 
292
- // Check that we are a proposer for the next slot
313
+ // Check that we are a proposer for the target slot.
293
314
  this.setState(SequencerState.PROPOSER_CHECK, slot);
294
- const [canPropose, proposer] = await this.checkCanPropose(slot);
315
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
295
316
 
296
317
  // If we are not a proposer check if we should invalidate an invalid checkpoint, and bail
297
318
  if (!canPropose) {
@@ -299,10 +320,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
299
320
  return undefined;
300
321
  }
301
322
 
302
- // Check that the slot is not taken by a block already (should never happen, since only us can propose for this slot)
303
- if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= slot) {
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
+
333
+ // Check that the target slot is not taken by a block already (should never happen, since only us can propose for this slot)
334
+ if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= targetSlot) {
304
335
  this.log.warn(
305
- `Cannot propose block at next L2 slot ${slot} since that slot was taken by block ${syncedTo.blockNumber}`,
336
+ `Cannot propose block at target slot ${targetSlot} since that slot was taken by block ${syncedTo.blockNumber}`,
306
337
  { ...logCtx, block: syncedTo.blockData.header.toInspect() },
307
338
  );
308
339
  this.metrics.recordCheckpointPrecheckFailed('slot_already_taken');
@@ -324,15 +355,41 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
324
355
  }
325
356
 
326
357
  // Prepare invalidation request if the pending chain is invalid (returns undefined if no need)
327
- const invalidateCheckpoint = await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
358
+ let invalidateCheckpoint = await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
359
+
360
+ // Determine the correct archive and L1 state overrides for the canProposeAt check.
361
+ // The L1 contract reads archives[proposedCheckpointNumber] and compares it with the provided archive.
362
+ // When invalidating or pipelining, the local archive may differ from L1's, so we adjust accordingly.
363
+ let archiveForCheck = syncedTo.archive;
364
+ const l1Overrides: {
365
+ forcePendingCheckpointNumber?: CheckpointNumber;
366
+ forceArchive?: { checkpointNumber: CheckpointNumber; archive: Fr };
367
+ } = {};
368
+
369
+ if (this.epochCache.isProposerPipeliningEnabled() && syncedTo.hasProposedCheckpoint) {
370
+ // Parent checkpoint hasn't landed on L1 yet. Override both the proposed checkpoint number
371
+ // and the archive at that checkpoint so L1 simulation sees the correct chain tip.
372
+ const parentCheckpointNumber = CheckpointNumber(checkpointNumber - 1);
373
+ l1Overrides.forcePendingCheckpointNumber = parentCheckpointNumber;
374
+ l1Overrides.forceArchive = { checkpointNumber: parentCheckpointNumber, archive: syncedTo.archive };
375
+ this.metrics.recordPipelineDepth(1);
376
+
377
+ this.log.verbose(
378
+ `Building on top of proposed checkpoint (pending=${syncedTo.proposedCheckpointData?.checkpointNumber})`,
379
+ );
380
+ // Clear the invalidation - the proposed checkpoint should handle it.
381
+ invalidateCheckpoint = undefined;
382
+ } else if (invalidateCheckpoint) {
383
+ // After invalidation, L1 will roll back to checkpoint N-1. The archive at N-1 already
384
+ // exists on L1, so we just pass the matching archive (the lastArchive of the invalid checkpoint).
385
+ archiveForCheck = invalidateCheckpoint.lastArchive;
386
+ l1Overrides.forcePendingCheckpointNumber = invalidateCheckpoint.forcePendingCheckpointNumber;
387
+ this.metrics.recordPipelineDepth(0);
388
+ } else {
389
+ this.metrics.recordPipelineDepth(0);
390
+ }
328
391
 
329
- // Check with the rollup contract if we can indeed propose at the next L2 slot. This check should not fail
330
- // if all the previous checks are good, but we do it just in case.
331
- const canProposeCheck = await publisher.canProposeAtNextEthBlock(
332
- syncedTo.archive,
333
- proposer ?? EthAddress.ZERO,
334
- invalidateCheckpoint,
335
- );
392
+ const canProposeCheck = await publisher.canProposeAt(archiveForCheck, proposer ?? EthAddress.ZERO, l1Overrides);
336
393
 
337
394
  if (canProposeCheck === undefined) {
338
395
  this.log.warn(
@@ -344,10 +401,10 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
344
401
  return undefined;
345
402
  }
346
403
 
347
- if (canProposeCheck.slot !== slot) {
404
+ if (canProposeCheck.slot !== targetSlot) {
348
405
  this.log.warn(
349
- `Cannot propose block due to slot mismatch with rollup contract (this can be caused by a clock out of sync). Expected slot ${slot} but got ${canProposeCheck.slot}.`,
350
- { ...logCtx, rollup: canProposeCheck, expectedSlot: slot },
406
+ `Cannot propose block due to slot mismatch with rollup contract (this can be caused by a clock out of sync). Expected slot ${targetSlot} but got ${canProposeCheck.slot}.`,
407
+ { ...logCtx, rollup: canProposeCheck, expectedSlot: targetSlot },
351
408
  );
352
409
  this.emit('proposer-rollup-check-failed', { reason: 'Slot mismatch', slot });
353
410
  this.metrics.recordCheckpointPrecheckFailed('slot_mismatch');
@@ -364,36 +421,49 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
364
421
  return undefined;
365
422
  }
366
423
 
367
- this.lastSlotForCheckpointProposalJob = slot;
368
- await this.p2pClient.prepareForSlot(slot);
369
- this.log.info(`Preparing checkpoint proposal ${checkpointNumber} at slot ${slot}`, { ...logCtx, proposer });
424
+ this.lastSlotForCheckpointProposalJob = targetSlot;
425
+
426
+ await this.p2pClient.prepareForSlot(targetSlot);
427
+ this.log.info(
428
+ `Preparing checkpoint proposal ${checkpointNumber} for target slot ${targetSlot} during wall-clock slot ${slot}`,
429
+ {
430
+ ...logCtx,
431
+ proposer,
432
+ pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(),
433
+ },
434
+ );
370
435
 
371
436
  // Create and return the checkpoint proposal job
372
437
  return this.createCheckpointProposalJob(
373
- epoch,
374
438
  slot,
439
+ targetSlot,
440
+ targetEpoch,
375
441
  checkpointNumber,
376
442
  syncedTo.blockNumber,
377
443
  proposer,
378
444
  publisher,
379
445
  attestorAddress,
380
446
  invalidateCheckpoint,
447
+ syncedTo.proposedCheckpointData,
381
448
  );
382
449
  }
383
450
 
384
451
  protected createCheckpointProposalJob(
385
- epoch: EpochNumber,
386
452
  slot: SlotNumber,
453
+ targetSlot: SlotNumber,
454
+ targetEpoch: EpochNumber,
387
455
  checkpointNumber: CheckpointNumber,
388
456
  syncedToBlockNumber: BlockNumber,
389
457
  proposer: EthAddress | undefined,
390
458
  publisher: SequencerPublisher,
391
459
  attestorAddress: EthAddress,
392
460
  invalidateCheckpoint: InvalidateCheckpointRequest | undefined,
461
+ proposedCheckpointData?: ProposedCheckpointData,
393
462
  ): CheckpointProposalJob {
394
463
  return new CheckpointProposalJob(
395
- epoch,
396
464
  slot,
465
+ targetSlot,
466
+ targetEpoch,
397
467
  checkpointNumber,
398
468
  syncedToBlockNumber,
399
469
  proposer,
@@ -419,6 +489,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
419
489
  this.setState.bind(this),
420
490
  this.tracer,
421
491
  this.log.getBindings(),
492
+ proposedCheckpointData,
422
493
  );
423
494
  }
424
495
 
@@ -475,16 +546,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
475
546
  * We don't check against the previous block submitted since it may have been reorg'd out.
476
547
  */
477
548
  protected async checkSync(args: { ts: bigint; slot: SlotNumber }): Promise<SequencerSyncCheckResult | undefined> {
478
- // Check that the archiver and dependencies have synced to the previous L1 slot at least
479
- // TODO(#14766): Archiver reports L1 timestamp based on L1 blocks seen, which means that a missed L1 block will
480
- // cause the archiver L1 timestamp to fall behind, and cause this sequencer to start processing one L1 slot later.
481
- const l1Timestamp = await this.l2BlockSource.getL1Timestamp();
482
- const { slot, ts } = args;
483
- if (l1Timestamp === undefined || l1Timestamp + BigInt(this.l1Constants.ethereumSlotDuration) < ts) {
549
+ // Check that the archiver has fully synced the L2 slot before the one we want to propose in.
550
+ // The archiver reports sync progress via L1 block timestamps and synced checkpoint slots.
551
+ // See getSyncedL2SlotNumber for how missed L1 blocks are handled.
552
+ const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber();
553
+ const { slot } = args;
554
+ if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) {
484
555
  this.log.debug(`Cannot propose block at next L2 slot ${slot} due to pending sync from L1`, {
485
556
  slot,
486
- ts,
487
- l1Timestamp,
557
+ syncedL2Slot,
488
558
  });
489
559
  return undefined;
490
560
  }
@@ -494,25 +564,43 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
494
564
  number: syncSummary.latestBlockNumber,
495
565
  hash: syncSummary.latestBlockHash,
496
566
  })),
497
- this.l2BlockSource.getL2Tips().then(t => t.proposed),
567
+ this.l2BlockSource
568
+ .getL2Tips()
569
+ .then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed, proposedCheckpoint: t.proposedCheckpoint })),
498
570
  this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block),
499
- this.l1ToL2MessageSource.getL2Tips().then(t => t.proposed),
571
+ this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })),
500
572
  this.l2BlockSource.getPendingChainValidationStatus(),
573
+ this.l2BlockSource.getProposedCheckpointOnly(),
501
574
  ] as const);
502
575
 
503
- const [worldState, l2BlockSource, p2p, l1ToL2MessageSource, pendingChainValidationStatus] = syncedBlocks;
576
+ const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] =
577
+ syncedBlocks;
504
578
 
505
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,
506
580
  // as the world state can compute the new genesis block hash, but other components use the hardcoded constant.
507
581
  // TODO(palla/mbps): Fix the above. All components should be able to handle dynamic genesis block hashes.
508
582
  const result =
509
- (l2BlockSource.number === 0 && worldState.number === 0 && p2p.number === 0 && l1ToL2MessageSource.number === 0) ||
510
- (worldState.hash === l2BlockSource.hash &&
511
- p2p.hash === l2BlockSource.hash &&
512
- l1ToL2MessageSource.hash === l2BlockSource.hash);
583
+ (l2Tips.proposed.number === 0 &&
584
+ l2Tips.checkpointed.block.number === 0 &&
585
+ l2Tips.checkpointed.checkpoint.number === 0 &&
586
+ worldState.number === 0 &&
587
+ p2p.number === 0 &&
588
+ l1ToL2MessageSourceTips.proposed.number === 0 &&
589
+ l1ToL2MessageSourceTips.checkpointed.block.number === 0 &&
590
+ l1ToL2MessageSourceTips.checkpointed.checkpoint.number === 0) ||
591
+ (worldState.hash === l2Tips.proposed.hash &&
592
+ p2p.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);
513
596
 
514
597
  if (!result) {
515
- this.log.debug(`Sequencer sync check failed`, { worldState, l2BlockSource, p2p, l1ToL2MessageSource });
598
+ this.log.debug(`Sequencer sync check failed`, {
599
+ worldState,
600
+ l2BlockSource: l2Tips.proposed,
601
+ p2p,
602
+ l1ToL2MessageSourceTips,
603
+ });
516
604
  return undefined;
517
605
  }
518
606
 
@@ -522,9 +610,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
522
610
  const archive = new Fr((await this.worldState.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root);
523
611
  return {
524
612
  checkpointNumber: CheckpointNumber.ZERO,
613
+ checkpointedCheckpointNumber: CheckpointNumber.ZERO,
525
614
  blockNumber: BlockNumber.ZERO,
526
615
  archive,
527
- l1Timestamp,
616
+ hasProposedCheckpoint: false,
617
+ syncedL2Slot,
528
618
  pendingChainValidationStatus,
529
619
  };
530
620
  }
@@ -536,12 +626,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
536
626
  return undefined;
537
627
  }
538
628
 
629
+ const hasProposedCheckpoint = l2Tips.proposedCheckpoint.checkpoint.number > l2Tips.checkpointed.checkpoint.number;
630
+
539
631
  return {
540
632
  blockData,
541
633
  blockNumber: blockData.header.getBlockNumber(),
542
634
  checkpointNumber: blockData.checkpointNumber,
635
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
543
636
  archive: blockData.archive.root,
544
- l1Timestamp,
637
+ hasProposedCheckpoint,
638
+ proposedCheckpointData,
639
+ syncedL2Slot,
545
640
  pendingChainValidationStatus,
546
641
  };
547
642
  }
@@ -550,20 +645,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
550
645
  * Checks if we are the proposer for the next slot.
551
646
  * @returns True if we can propose, and the proposer address (undefined if anyone can propose)
552
647
  */
553
- protected async checkCanPropose(slot: SlotNumber): Promise<[boolean, EthAddress | undefined]> {
648
+ protected async checkCanPropose(targetSlot: SlotNumber): Promise<[boolean, EthAddress | undefined]> {
554
649
  let proposer: EthAddress | undefined;
555
650
 
556
651
  try {
557
- proposer = await this.epochCache.getProposerAttesterAddressInSlot(slot);
652
+ proposer = await this.epochCache.getProposerAttesterAddressInSlot(targetSlot);
558
653
  } catch (e) {
559
654
  if (e instanceof NoCommitteeError) {
560
- if (this.lastSlotForNoCommitteeWarning !== slot) {
561
- this.lastSlotForNoCommitteeWarning = slot;
562
- this.log.warn(`Cannot propose at next L2 slot ${slot} since the committee does not exist on L1`);
655
+ if (this.lastSlotForNoCommitteeWarning !== targetSlot) {
656
+ this.lastSlotForNoCommitteeWarning = targetSlot;
657
+ this.log.warn(`Cannot propose at target slot ${targetSlot} since the committee does not exist on L1`);
563
658
  }
564
659
  return [false, undefined];
565
660
  }
566
- this.log.error(`Error getting proposer for slot ${slot}`, e);
661
+ this.log.error(`Error getting proposer for target slot ${targetSlot}`, e);
567
662
  return [false, undefined];
568
663
  }
569
664
 
@@ -580,10 +675,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
580
675
  const weAreProposer = validatorAddresses.some(addr => addr.equals(proposer));
581
676
 
582
677
  if (!weAreProposer) {
583
- this.log.debug(`Cannot propose at slot ${slot} since we are not a proposer`, { validatorAddresses, proposer });
678
+ this.log.debug(`Cannot propose at target slot ${targetSlot} since we are not a proposer`, {
679
+ targetSlot,
680
+ validatorAddresses,
681
+ proposer,
682
+ });
584
683
  return [false, proposer];
585
684
  }
586
685
 
686
+ this.log.info(`We are the proposer for pipeline slot ${targetSlot}`, {
687
+ targetSlot,
688
+ proposer,
689
+ });
587
690
  return [true, proposer];
588
691
  }
589
692
 
@@ -592,8 +695,8 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
592
695
  * This allows the sequencer to participate in governance/slashing votes even when it cannot build blocks.
593
696
  */
594
697
  @trackSpan('Seqeuencer.tryVoteWhenSyncFails', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
595
- protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; ts: bigint }): Promise<void> {
596
- const { slot } = args;
698
+ protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; targetSlot: SlotNumber; ts: bigint }): Promise<void> {
699
+ const { slot, targetSlot } = args;
597
700
 
598
701
  // Prevent duplicate attempts in the same slot
599
702
  if (this.lastSlotForFallbackVote === slot) {
@@ -621,7 +724,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
621
724
  });
622
725
 
623
726
  // Check if we're a proposer or proposal is open
624
- const [canPropose, proposer] = await this.checkCanPropose(slot);
727
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
625
728
  if (!canPropose) {
626
729
  this.log.trace(`Cannot vote in slot ${slot} since we are not a proposer`, { slot, proposer });
627
730
  return;
@@ -638,9 +741,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
638
741
  slot,
639
742
  });
640
743
 
641
- // Enqueue governance and slashing votes
744
+ // Enqueue governance and slashing votes (voter uses the target slot for L1 submission)
642
745
  const voter = new CheckpointVoter(
643
- slot,
746
+ targetSlot,
644
747
  publisher,
645
748
  attestorAddress,
646
749
  this.validatorClient,
@@ -720,7 +823,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
720
823
  syncedTo: SequencerSyncCheckResult,
721
824
  currentSlot: SlotNumber,
722
825
  ): Promise<void> {
723
- const { pendingChainValidationStatus, l1Timestamp } = syncedTo;
826
+ const { pendingChainValidationStatus, syncedL2Slot } = syncedTo;
724
827
  if (pendingChainValidationStatus.valid) {
725
828
  return;
726
829
  }
@@ -735,7 +838,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
735
838
 
736
839
  const logData = {
737
840
  invalidL1Timestamp: invalidCheckpointTimestamp,
738
- l1Timestamp,
841
+ syncedL2Slot,
739
842
  invalidCheckpoint: pendingChainValidationStatus.checkpoint,
740
843
  secondsBeforeInvalidatingBlockAsCommitteeMember,
741
844
  secondsBeforeInvalidatingBlockAsNonCommitteeMember,
@@ -880,8 +983,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
880
983
  type SequencerSyncCheckResult = {
881
984
  blockData?: BlockData;
882
985
  checkpointNumber: CheckpointNumber;
986
+ checkpointedCheckpointNumber: CheckpointNumber;
883
987
  blockNumber: BlockNumber;
884
988
  archive: Fr;
885
- l1Timestamp: bigint;
989
+ hasProposedCheckpoint: boolean;
990
+ proposedCheckpointData?: ProposedCheckpointData;
991
+ syncedL2Slot: SlotNumber;
886
992
  pendingChainValidationStatus: ValidateCheckpointResult;
887
993
  };