@aztec/sequencer-client 0.0.1-commit.7b86788 → 0.0.1-commit.7cbc774

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 (106) hide show
  1. package/README.md +282 -21
  2. package/dest/client/sequencer-client.d.ts +8 -3
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +53 -30
  5. package/dest/config.d.ts +28 -5
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +62 -27
  8. package/dest/global_variable_builder/fee_predictor.d.ts +37 -0
  9. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -0
  10. package/dest/global_variable_builder/fee_predictor.js +128 -0
  11. package/dest/global_variable_builder/fee_provider.d.ts +21 -0
  12. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -0
  13. package/dest/global_variable_builder/fee_provider.js +58 -0
  14. package/dest/global_variable_builder/global_builder.d.ts +14 -14
  15. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  16. package/dest/global_variable_builder/global_builder.js +16 -50
  17. package/dest/global_variable_builder/index.d.ts +4 -2
  18. package/dest/global_variable_builder/index.d.ts.map +1 -1
  19. package/dest/global_variable_builder/index.js +2 -0
  20. package/dest/index.d.ts +2 -2
  21. package/dest/index.d.ts.map +1 -1
  22. package/dest/index.js +1 -1
  23. package/dest/publisher/config.d.ts +15 -3
  24. package/dest/publisher/config.d.ts.map +1 -1
  25. package/dest/publisher/config.js +19 -4
  26. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +3 -4
  27. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -1
  28. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  29. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  30. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  31. package/dest/publisher/sequencer-publisher-factory.d.ts +3 -5
  32. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  33. package/dest/publisher/sequencer-publisher-factory.js +16 -3
  34. package/dest/publisher/sequencer-publisher.d.ts +77 -65
  35. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  36. package/dest/publisher/sequencer-publisher.js +345 -512
  37. package/dest/sequencer/automine/automine_factory.d.ts +54 -0
  38. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  39. package/dest/sequencer/automine/automine_factory.js +84 -0
  40. package/dest/sequencer/automine/automine_sequencer.d.ts +151 -0
  41. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  42. package/dest/sequencer/automine/automine_sequencer.js +506 -0
  43. package/dest/sequencer/chain_state_overrides.d.ts +61 -0
  44. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  45. package/dest/sequencer/chain_state_overrides.js +98 -0
  46. package/dest/sequencer/checkpoint_proposal_job.d.ts +54 -11
  47. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  48. package/dest/sequencer/checkpoint_proposal_job.js +776 -227
  49. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  50. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  51. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  52. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  53. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  54. package/dest/sequencer/checkpoint_voter.js +2 -5
  55. package/dest/sequencer/events.d.ts +47 -2
  56. package/dest/sequencer/events.d.ts.map +1 -1
  57. package/dest/sequencer/index.d.ts +3 -1
  58. package/dest/sequencer/index.d.ts.map +1 -1
  59. package/dest/sequencer/index.js +2 -0
  60. package/dest/sequencer/metrics.d.ts +13 -10
  61. package/dest/sequencer/metrics.d.ts.map +1 -1
  62. package/dest/sequencer/metrics.js +45 -20
  63. package/dest/sequencer/sequencer.d.ts +59 -16
  64. package/dest/sequencer/sequencer.d.ts.map +1 -1
  65. package/dest/sequencer/sequencer.js +333 -119
  66. package/dest/sequencer/timetable.d.ts +17 -3
  67. package/dest/sequencer/timetable.d.ts.map +1 -1
  68. package/dest/sequencer/timetable.js +51 -43
  69. package/dest/sequencer/types.d.ts +2 -2
  70. package/dest/sequencer/types.d.ts.map +1 -1
  71. package/dest/test/mock_checkpoint_builder.d.ts +7 -9
  72. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  73. package/dest/test/mock_checkpoint_builder.js +39 -30
  74. package/dest/test/utils.d.ts +1 -1
  75. package/dest/test/utils.d.ts.map +1 -1
  76. package/dest/test/utils.js +7 -6
  77. package/package.json +27 -28
  78. package/src/client/sequencer-client.ts +72 -33
  79. package/src/config.ts +76 -27
  80. package/src/global_variable_builder/README.md +44 -0
  81. package/src/global_variable_builder/fee_predictor.ts +172 -0
  82. package/src/global_variable_builder/fee_provider.ts +75 -0
  83. package/src/global_variable_builder/global_builder.ts +25 -62
  84. package/src/global_variable_builder/index.ts +3 -1
  85. package/src/index.ts +10 -1
  86. package/src/publisher/config.ts +40 -6
  87. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +3 -1
  88. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  89. package/src/publisher/sequencer-publisher-factory.ts +18 -6
  90. package/src/publisher/sequencer-publisher.ts +426 -567
  91. package/src/sequencer/README.md +152 -450
  92. package/src/sequencer/automine/README.md +46 -0
  93. package/src/sequencer/automine/automine_factory.ts +145 -0
  94. package/src/sequencer/automine/automine_sequencer.ts +592 -0
  95. package/src/sequencer/chain_state_overrides.ts +162 -0
  96. package/src/sequencer/checkpoint_proposal_job.ts +941 -256
  97. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  98. package/src/sequencer/checkpoint_voter.ts +1 -12
  99. package/src/sequencer/events.ts +50 -2
  100. package/src/sequencer/index.ts +2 -0
  101. package/src/sequencer/metrics.ts +57 -24
  102. package/src/sequencer/sequencer.ts +414 -132
  103. package/src/sequencer/timetable.ts +64 -52
  104. package/src/sequencer/types.ts +1 -1
  105. package/src/test/mock_checkpoint_builder.ts +51 -48
  106. package/src/test/utils.ts +28 -10
@@ -372,22 +372,22 @@ function _apply_decs_2203_r(targetClass, memberDecs, classDecs, parentClass) {
372
372
  }
373
373
  var _dec, _dec1, _dec2, _dec3, _initProto;
374
374
  import { getKzg } from '@aztec/blob-lib';
375
- import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
375
+ import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
376
376
  import { NoCommitteeError } from '@aztec/ethereum/contracts';
377
- import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types';
377
+ import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
378
378
  import { merge, omit, pick } from '@aztec/foundation/collection';
379
- import { Fr } from '@aztec/foundation/curves/bn254';
380
379
  import { EthAddress } from '@aztec/foundation/eth-address';
381
380
  import { createLogger } from '@aztec/foundation/log';
382
381
  import { RunningPromise } from '@aztec/foundation/running-promise';
383
- import { getSlotAtTimestamp, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
382
+ import { getEpochAtSlot, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
384
383
  import { SequencerConfigSchema } from '@aztec/stdlib/interfaces/server';
385
384
  import { pickFromSchema } from '@aztec/stdlib/schemas';
386
- import { MerkleTreeId } from '@aztec/stdlib/trees';
387
385
  import { Attributes, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
388
386
  import EventEmitter from 'node:events';
389
387
  import { DefaultSequencerConfig } from '../config.js';
388
+ import { buildCheckpointSimulationOverridesPlan } from './chain_state_overrides.js';
390
389
  import { CheckpointProposalJob } from './checkpoint_proposal_job.js';
390
+ import { CheckpointProposalJobMetrics } from './checkpoint_proposal_job_metrics.js';
391
391
  import { CheckpointVoter } from './checkpoint_voter.js';
392
392
  import { SequencerInterruptedError, SequencerTooSlowError } from './errors.js';
393
393
  import { SequencerMetrics } from './metrics.js';
@@ -448,34 +448,48 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
448
448
  }
449
449
  runningPromise;
450
450
  state;
451
+ stateSlotNumber;
452
+ stateEnteredAtMs;
451
453
  metrics;
454
+ checkpointProposalJobMetrics;
455
+ stateLog;
452
456
  /** The last slot for which we attempted to perform our voting duties with degraded block production */ lastSlotForFallbackVote;
453
457
  /** The last slot for which we logged "no committee" warning, to avoid spam */ lastSlotForNoCommitteeWarning;
454
458
  /** The last slot for which we triggered a checkpoint proposal job, to prevent duplicate attempts. */ lastSlotForCheckpointProposalJob;
455
459
  /** Last successful checkpoint proposed */ lastCheckpointProposed;
456
460
  /** The last epoch for which we logged strategy comparison in fisherman mode. */ lastEpochForStrategyComparison;
461
+ /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */ lastCheckpointProposalJob;
457
462
  /** The maximum number of seconds that the sequencer can be into a slot to transition to a particular state. */ timetable;
458
463
  /** Config for the sequencer */ config;
464
+ signatureContext;
459
465
  constructor(publisherFactory, validatorClient, globalsBuilder, p2pClient, worldState, slasherClient, l2BlockSource, l1ToL2MessageSource, checkpointsBuilder, l1Constants, dateProvider, epochCache, rollupContract, config, telemetry = getTelemetryClient(), log = createLogger('sequencer')){
460
- super(), this.publisherFactory = publisherFactory, this.validatorClient = validatorClient, this.globalsBuilder = globalsBuilder, this.p2pClient = p2pClient, this.worldState = worldState, this.slasherClient = slasherClient, this.l2BlockSource = l2BlockSource, this.l1ToL2MessageSource = l1ToL2MessageSource, this.checkpointsBuilder = checkpointsBuilder, this.l1Constants = l1Constants, this.dateProvider = dateProvider, this.epochCache = epochCache, this.rollupContract = rollupContract, this.telemetry = telemetry, this.log = log, this.state = (_initProto(this), SequencerState.STOPPED), this.config = DefaultSequencerConfig;
466
+ super(), this.publisherFactory = publisherFactory, this.validatorClient = validatorClient, this.globalsBuilder = globalsBuilder, this.p2pClient = p2pClient, this.worldState = worldState, this.slasherClient = slasherClient, this.l2BlockSource = l2BlockSource, this.l1ToL2MessageSource = l1ToL2MessageSource, this.checkpointsBuilder = checkpointsBuilder, this.l1Constants = l1Constants, this.dateProvider = dateProvider, this.epochCache = epochCache, this.rollupContract = rollupContract, this.telemetry = telemetry, this.log = log, this.state = (_initProto(this), SequencerState.STOPPED), this.stateEnteredAtMs = performance.now(), this.config = DefaultSequencerConfig;
467
+ this.stateLog = log.createChild('state');
461
468
  // Add [FISHERMAN] prefix to logger if in fisherman mode
462
469
  if (config.fishermanMode) {
463
470
  this.log = log.createChild('[FISHERMAN]');
464
471
  }
472
+ this.signatureContext = {
473
+ chainId: config.l1ChainId,
474
+ rollupAddress: config.rollupAddress
475
+ };
465
476
  this.metrics = new SequencerMetrics(telemetry, this.rollupContract, 'Sequencer');
477
+ this.checkpointProposalJobMetrics = new CheckpointProposalJobMetrics(telemetry);
466
478
  this.updateConfig(config);
467
479
  }
468
480
  /** Updates sequencer config by the defined values and updates the timetable */ updateConfig(config) {
469
481
  const filteredConfig = pickFromSchema(config, SequencerConfigSchema);
470
- this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowList'));
482
+ this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend'));
471
483
  this.config = merge(this.config, filteredConfig);
484
+ const p2pPropagationTime = this.config.attestationPropagationTime;
472
485
  this.timetable = new SequencerTimetable({
473
486
  ethereumSlotDuration: this.l1Constants.ethereumSlotDuration,
474
487
  aztecSlotDuration: this.aztecSlotDuration,
475
488
  l1PublishingTime: this.l1PublishingTime,
476
- p2pPropagationTime: this.config.attestationPropagationTime,
489
+ p2pPropagationTime,
477
490
  blockDurationMs: this.config.blockDurationMs,
478
- enforce: this.config.enforceTimeTable
491
+ enforce: this.config.enforceTimeTable,
492
+ pipelining: this.epochCache.isProposerPipeliningEnabled()
479
493
  }, this.metrics, this.log);
480
494
  }
481
495
  /** Initializes the sequencer (precomputes tables). Takes about 3s. */ init() {
@@ -489,13 +503,17 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
489
503
  this.runningPromise.start();
490
504
  this.log.info('Started sequencer');
491
505
  }
506
+ /** Triggers an immediate run of the sequencer, bypassing the polling interval. */ trigger() {
507
+ return this.runningPromise?.trigger();
508
+ }
492
509
  /** Stops the sequencer from building blocks and moves to STOPPED state. */ async stop() {
493
510
  this.log.info(`Stopping sequencer`);
494
511
  this.setState(SequencerState.STOPPING, undefined, {
495
512
  force: true
496
513
  });
497
- this.publisherFactory.interruptAll();
514
+ await this.publisherFactory.stopAll();
498
515
  await this.runningPromise?.stop();
516
+ await this.lastCheckpointProposalJob?.awaitPendingSubmission();
499
517
  this.setState(SequencerState.STOPPED, undefined, {
500
518
  force: true
501
519
  });
@@ -505,20 +523,19 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
505
523
  try {
506
524
  await this.work();
507
525
  } catch (err) {
508
- this.emit('checkpoint-error', {
509
- error: err
510
- });
511
- if (err instanceof SequencerTooSlowError) {
512
- // Log as warn only if we had to abort halfway through the block proposal
513
- const logLvl = [
514
- SequencerState.INITIALIZING_CHECKPOINT,
515
- SequencerState.PROPOSER_CHECK
516
- ].includes(err.proposedState) ? 'debug' : 'warn';
517
- this.log[logLvl](err.message, {
526
+ if (err instanceof SequencerTooSlowError && [
527
+ SequencerState.INITIALIZING_CHECKPOINT,
528
+ SequencerState.PROPOSER_CHECK
529
+ ].includes(err.proposedState)) {
530
+ // No need to alert if we just didn't get to start in time
531
+ this.log.debug(err.message, {
518
532
  now: this.dateProvider.nowInSeconds()
519
533
  });
520
534
  } else {
521
535
  // Re-throw other errors
536
+ this.emit('checkpoint-error', {
537
+ error: err
538
+ });
522
539
  throw err;
523
540
  }
524
541
  } finally{
@@ -539,40 +556,62 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
539
556
  * - Submit checkpoint
540
557
  */ async work() {
541
558
  this.setState(SequencerState.SYNCHRONIZING, undefined);
542
- const { slot, ts, now, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
559
+ const { slot, targetSlot, epoch, targetEpoch, ts, nowSeconds } = this.getSlotContextInNextL1Slot();
543
560
  // Check if we are synced and it's our slot, grab a publisher, check previous block invalidation, etc
544
- const checkpointProposalJob = await this.prepareCheckpointProposal(epoch, slot, ts, now);
561
+ const checkpointProposalJob = await this.prepareCheckpointProposal(slot, targetSlot, epoch, targetEpoch, ts, nowSeconds);
545
562
  if (!checkpointProposalJob) {
546
563
  return;
547
564
  }
565
+ // Track the job so we can await its pending L1 submission during shutdown
566
+ this.lastCheckpointProposalJob = checkpointProposalJob;
548
567
  // Execute the checkpoint proposal job
549
568
  const checkpoint = await checkpointProposalJob.execute();
550
569
  // Update last checkpoint proposed (currently unused)
551
570
  if (checkpoint) {
552
571
  this.lastCheckpointProposed = checkpoint;
553
572
  }
554
- // Log fee strategy comparison if on fisherman
555
- if (this.config.fishermanMode && (this.lastEpochForStrategyComparison === undefined || epoch > this.lastEpochForStrategyComparison)) {
556
- this.logStrategyComparison(epoch, checkpointProposalJob.getPublisher());
557
- this.lastEpochForStrategyComparison = epoch;
573
+ // Log fee strategy comparison if on fisherman (uses target epoch since we mirror the proposer's perspective)
574
+ if (this.config.fishermanMode && (this.lastEpochForStrategyComparison === undefined || targetEpoch > this.lastEpochForStrategyComparison)) {
575
+ this.logStrategyComparison(targetEpoch, checkpointProposalJob.getPublisher());
576
+ this.lastEpochForStrategyComparison = targetEpoch;
558
577
  }
559
578
  return checkpoint;
560
579
  }
580
+ /** Returns slot and target slot from a single clock snapshot. */ getSlotContextInNextL1Slot() {
581
+ const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
582
+ const targetSlot = SlotNumber(slot + (this.epochCache.isProposerPipeliningEnabled() ? PROPOSER_PIPELINING_SLOT_OFFSET : 0));
583
+ return {
584
+ slot,
585
+ targetSlot,
586
+ epoch,
587
+ targetEpoch: getEpochAtSlot(targetSlot, this.l1Constants),
588
+ ts,
589
+ nowSeconds
590
+ };
591
+ }
561
592
  /**
562
593
  * Prepares the checkpoint proposal by performing all necessary checks and setup.
563
594
  * This is the initial step in the main loop.
564
595
  * @returns CheckpointProposalJob if successful, undefined if we are not yet synced or are not the proposer.
565
- */ async prepareCheckpointProposal(epoch, slot, ts, now) {
566
- // Check we have not already processed this slot (cheapest check)
596
+ */ async prepareCheckpointProposal(slot, targetSlot, epoch, targetEpoch, ts, nowSeconds) {
597
+ // Check we have not already processed this target slot (cheapest check)
567
598
  // We only check this if enforce timetable is set, since we want to keep processing the same slot if we are not
568
599
  // running against actual time (eg when we use sandbox-style automining)
569
- if (this.lastSlotForCheckpointProposalJob && this.lastSlotForCheckpointProposalJob >= slot && this.config.enforceTimeTable) {
570
- this.log.trace(`Slot ${slot} has already been processed`);
600
+ if (this.lastSlotForCheckpointProposalJob && this.lastSlotForCheckpointProposalJob >= targetSlot && this.config.enforceTimeTable) {
601
+ this.log.trace(`Target slot ${targetSlot} has already been processed`);
602
+ return undefined;
603
+ }
604
+ // But if we have already proposed for this slot, then we definitely have to skip it, automining or not
605
+ if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= targetSlot) {
606
+ this.log.trace(`Slot ${targetSlot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`);
571
607
  return undefined;
572
608
  }
573
- // But if we have already proposed for this slot, the we definitely have to skip it, automining or not
574
- if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= slot) {
575
- this.log.trace(`Slot ${slot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`);
609
+ // Test-only: skip proposing for explicitly paused slots. Attestation paths run in the validator
610
+ // client and are not gated by this hook, so paused proposers still attest to others' proposals.
611
+ if (this.config.pauseProposingForSlots?.some((s)=>s === targetSlot)) {
612
+ this.log.warn(`Skipping proposal for paused slot ${targetSlot} (test-only pauseProposingForSlots hook)`, {
613
+ targetSlot
614
+ });
576
615
  return undefined;
577
616
  }
578
617
  // Check all components are synced to latest as seen by the archiver (queries all subsystems)
@@ -583,19 +622,22 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
583
622
  if (!syncedTo) {
584
623
  await this.tryVoteWhenSyncFails({
585
624
  slot,
625
+ targetSlot,
586
626
  ts
587
627
  });
588
628
  return undefined;
589
629
  }
590
- // If escape hatch is open for this epoch, do not start checkpoint proposal work and do not attempt invalidations.
630
+ // If escape hatch is open for the target epoch, do not start checkpoint proposal work and do not attempt invalidations.
591
631
  // Still perform governance/slashing voting (as proposer) once per slot.
592
- const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(epoch);
632
+ // When pipelining, we check the target epoch (slot+1's epoch) since that's the epoch we're building for.
633
+ const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(targetEpoch);
593
634
  if (isEscapeHatchOpen) {
594
635
  this.setState(SequencerState.PROPOSER_CHECK, slot);
595
- const [canPropose, proposer] = await this.checkCanPropose(slot);
636
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
596
637
  if (canPropose) {
597
638
  await this.tryVoteWhenEscapeHatchOpen({
598
639
  slot,
640
+ targetSlot,
599
641
  proposer
600
642
  });
601
643
  } else {
@@ -610,25 +652,32 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
610
652
  // Next checkpoint follows from the last synced one
611
653
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
612
654
  const logCtx = {
613
- now,
614
- syncedToL1Ts: syncedTo.l1Timestamp,
615
- syncedToL2Slot: getSlotAtTimestamp(syncedTo.l1Timestamp, this.l1Constants),
655
+ nowSeconds,
656
+ syncedToL2Slot: syncedTo.syncedL2Slot,
616
657
  slot,
658
+ targetSlot,
617
659
  slotTs: ts,
618
660
  checkpointNumber,
619
661
  isPendingChainValid: pick(syncedTo.pendingChainValidationStatus, 'valid', 'reason', 'invalidIndex')
620
662
  };
621
- // Check that we are a proposer for the next slot
663
+ // Check that we are a proposer for the target slot.
622
664
  this.setState(SequencerState.PROPOSER_CHECK, slot);
623
- const [canPropose, proposer] = await this.checkCanPropose(slot);
665
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
624
666
  // If we are not a proposer check if we should invalidate an invalid checkpoint, and bail
625
667
  if (!canPropose) {
626
668
  await this.considerInvalidatingCheckpoint(syncedTo, slot);
627
669
  return undefined;
628
670
  }
629
- // Check that the slot is not taken by a block already (should never happen, since only us can propose for this slot)
630
- if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= slot) {
631
- this.log.warn(`Cannot propose block at next L2 slot ${slot} since that slot was taken by block ${syncedTo.blockNumber}`, {
671
+ // Guard: don't exceed 1-deep pipeline. Without a proposed checkpoint, we can only build
672
+ // confirmed + 1. With a proposed checkpoint, we can build confirmed + 2.
673
+ const confirmedCkpt = syncedTo.checkpointedCheckpointNumber;
674
+ if (checkpointNumber > confirmedCkpt + 2) {
675
+ this.log.verbose(`Skipping slot ${targetSlot}: checkpoint ${checkpointNumber} exceeds max pipeline depth (confirmed=${confirmedCkpt})`);
676
+ return undefined;
677
+ }
678
+ // Check that the target slot is not taken by a block already (should never happen, since only us can propose for this slot)
679
+ if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= targetSlot) {
680
+ this.log.warn(`Cannot propose block at target slot ${targetSlot} since that slot was taken by block ${syncedTo.blockNumber}`, {
632
681
  ...logCtx,
633
682
  block: syncedTo.blockData.header.toInspect()
634
683
  });
@@ -642,18 +691,85 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
642
691
  const proposerForPublisher = this.config.fishermanMode ? undefined : proposer;
643
692
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposerForPublisher);
644
693
  this.log.verbose(`Created publisher at address ${publisher.getSenderAddress()} for attestor ${attestorAddress}`);
645
- // In fisherman mode, set the actual proposer's address for simulations
646
- if (this.config.fishermanMode && proposer) {
647
- publisher.setProposerAddressForSimulation(proposer);
648
- this.log.debug(`Set proposer address ${proposer} for simulation in fisherman mode`);
694
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
695
+ // Prepare invalidation request if the pending chain is invalid (returns undefined if no need).
696
+ // Only simulate invalidation when there's no proposed parent, since we assume the proposed parent
697
+ // will invalidate the currently invalid checkpoint on L1.
698
+ const invalidateCheckpoint = isPipelining && syncedTo.hasProposedCheckpoint || syncedTo.pendingChainValidationStatus.valid ? undefined : await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
699
+ // Determine the correct archive and L1 state overrides for the canProposeAt check.
700
+ // The L1 contract reads archives[proposedCheckpointNumber] and compares it with the provided archive.
701
+ // When invalidating or pipelining, the local archive may differ from L1's, so we adjust accordingly.
702
+ let archiveForCheck = syncedTo.archive;
703
+ if (isPipelining && syncedTo.hasProposedCheckpoint) {
704
+ this.metrics.recordPipelineDepth(syncedTo.checkpointNumber - syncedTo.checkpointedCheckpointNumber);
705
+ this.log.verbose(`Building on top of proposed checkpoint (pending=${syncedTo.proposedCheckpointData?.checkpointNumber}) for target slot ${targetSlot}`, {
706
+ targetSlot,
707
+ parentCheckpointNumber: CheckpointNumber(checkpointNumber - 1)
708
+ });
709
+ // Match what L1 will see at archives[pending] once the proposed parent lands: the parent's
710
+ // own archive root from the gossiped proposal. `syncedTo.archive` is the world-state-local
711
+ // view and can transiently diverge from the proposed parent (e.g. before the proposed
712
+ // parent's blocks have been applied locally); diverging here would cause the canProposeAt
713
+ // override to set archives[pending] to one value while we present another for comparison.
714
+ archiveForCheck = syncedTo.proposedCheckpointData.archive.root;
715
+ } else if (invalidateCheckpoint) {
716
+ // After invalidation, L1 will roll back to checkpoint N-1. The archive at N-1 already
717
+ // exists on L1, so we just pass the matching archive (the lastArchive of the invalid checkpoint).
718
+ archiveForCheck = invalidateCheckpoint.lastArchive;
719
+ this.metrics.recordPipelineDepth(0);
720
+ } else {
721
+ this.metrics.recordPipelineDepth(0);
722
+ }
723
+ // Build the simulation plan: pending/proven override from pipelining or invalidation (or the
724
+ // current snapshot when neither applies, to short-circuit any pending prune in simulation),
725
+ // plus the parent checkpoint cell and fee header when pipelining.
726
+ const simulationOverridesPlan = await buildCheckpointSimulationOverridesPlan({
727
+ checkpointNumber,
728
+ proposedCheckpointData: isPipelining && syncedTo.hasProposedCheckpoint ? syncedTo.proposedCheckpointData : undefined,
729
+ invalidateToPendingCheckpointNumber: invalidateCheckpoint?.forcePendingCheckpointNumber,
730
+ checkpointedCheckpointNumber: syncedTo.checkpointedCheckpointNumber,
731
+ rollup: this.rollupContract,
732
+ signatureContext: this.signatureContext,
733
+ log: this.log
734
+ });
735
+ // The plan always pins both pending/proven (to short-circuit `canPruneAtTime` in simulation),
736
+ // so `provenOverride` always reflects the assumed proven checkpoint we are pinning the
737
+ // simulation to. We additionally warn when the pin is load-bearing — i.e. when a prune would
738
+ // actually fire at the target slot without it — so observers can spot "we are building
739
+ // optimistically across a pruning boundary" in the logs.
740
+ const provenOverride = simulationOverridesPlan?.chainTipsOverride?.proven;
741
+ if (provenOverride !== undefined && await this.l2BlockSource.isPruneDueAtSlot(targetSlot)) {
742
+ this.log.warn(`Assuming proof for epoch ending at checkpoint ${provenOverride} lands by target slot ${targetSlot}`, {
743
+ checkpointNumber,
744
+ slot,
745
+ targetSlot,
746
+ provenOverride
747
+ });
649
748
  }
650
- // Prepare invalidation request if the pending chain is invalid (returns undefined if no need)
651
- const invalidateCheckpoint = await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
652
- // Check with the rollup contract if we can indeed propose at the next L2 slot. This check should not fail
653
- // if all the previous checks are good, but we do it just in case.
654
- const canProposeCheck = await publisher.canProposeAtNextEthBlock(syncedTo.archive, proposer ?? EthAddress.ZERO, invalidateCheckpoint);
749
+ this.emit('preparing-checkpoint', {
750
+ targetSlot,
751
+ checkpointNumber,
752
+ hadProposedParent: this.epochCache.isProposerPipeliningEnabled() && syncedTo.hasProposedCheckpoint,
753
+ provenOverride,
754
+ simulatedPending: simulationOverridesPlan?.chainTipsOverride?.pending
755
+ });
756
+ const canProposeCheck = await publisher.canProposeAt(archiveForCheck, proposer ?? EthAddress.ZERO, simulationOverridesPlan);
757
+ const proposeContext = {
758
+ hasProposedCheckpoint: syncedTo.hasProposedCheckpoint,
759
+ proposedCheckpointNumber: syncedTo.proposedCheckpointData?.checkpointNumber,
760
+ checkpointedCheckpointNumber: syncedTo.checkpointedCheckpointNumber,
761
+ isInvalidating: !!invalidateCheckpoint,
762
+ invalidatingCheckpointNumber: invalidateCheckpoint?.checkpointNumber,
763
+ archiveForCheck: archiveForCheck.toString(),
764
+ overridePendingCheckpointNumber: simulationOverridesPlan?.chainTipsOverride?.pending,
765
+ overrideArchive: simulationOverridesPlan?.pendingCheckpointState?.archive,
766
+ overrideFeeHeader: simulationOverridesPlan?.pendingCheckpointState?.feeHeader
767
+ };
655
768
  if (canProposeCheck === undefined) {
656
- this.log.warn(`Cannot propose checkpoint ${checkpointNumber} at slot ${slot} due to failed rollup contract check`, logCtx);
769
+ this.log.warn(`Cannot propose checkpoint ${checkpointNumber} at slot ${slot} due to failed rollup contract check`, {
770
+ ...logCtx,
771
+ ...proposeContext
772
+ });
657
773
  this.emit('proposer-rollup-check-failed', {
658
774
  reason: 'Rollup contract check failed',
659
775
  slot
@@ -661,11 +777,12 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
661
777
  this.metrics.recordCheckpointPrecheckFailed('rollup_contract_check_failed');
662
778
  return undefined;
663
779
  }
664
- if (canProposeCheck.slot !== slot) {
665
- this.log.warn(`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}.`, {
780
+ if (canProposeCheck.slot !== targetSlot) {
781
+ this.log.warn(`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}.`, {
666
782
  ...logCtx,
783
+ ...proposeContext,
667
784
  rollup: canProposeCheck,
668
- expectedSlot: slot
785
+ expectedSlot: targetSlot
669
786
  });
670
787
  this.emit('proposer-rollup-check-failed', {
671
788
  reason: 'Slot mismatch',
@@ -677,6 +794,7 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
677
794
  if (canProposeCheck.checkpointNumber !== checkpointNumber) {
678
795
  this.log.warn(`Cannot propose due to block mismatch with rollup contract (this can be caused by a pending archiver sync). Expected checkpoint ${checkpointNumber} but got ${canProposeCheck.checkpointNumber}.`, {
679
796
  ...logCtx,
797
+ ...proposeContext,
680
798
  rollup: canProposeCheck,
681
799
  expectedSlot: slot
682
800
  });
@@ -687,17 +805,19 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
687
805
  this.metrics.recordCheckpointPrecheckFailed('block_number_mismatch');
688
806
  return undefined;
689
807
  }
690
- this.lastSlotForCheckpointProposalJob = slot;
691
- await this.p2pClient.prepareForSlot(slot);
692
- this.log.info(`Preparing checkpoint proposal ${checkpointNumber} at slot ${slot}`, {
808
+ this.lastSlotForCheckpointProposalJob = targetSlot;
809
+ await this.p2pClient.prepareForSlot(targetSlot);
810
+ this.log.info(`Preparing checkpoint proposal ${checkpointNumber} for target slot ${targetSlot} during wall-clock slot ${slot}`, {
693
811
  ...logCtx,
694
- proposer
812
+ ...proposeContext,
813
+ proposer,
814
+ pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled()
695
815
  });
696
816
  // Create and return the checkpoint proposal job
697
- return this.createCheckpointProposalJob(epoch, slot, checkpointNumber, syncedTo.blockNumber, proposer, publisher, attestorAddress, invalidateCheckpoint);
817
+ return this.createCheckpointProposalJob(slot, targetSlot, targetEpoch, checkpointNumber, syncedTo.blockNumber, syncedTo.checkpointedCheckpointNumber, proposer, publisher, attestorAddress, invalidateCheckpoint, syncedTo.proposedCheckpointData);
698
818
  }
699
- createCheckpointProposalJob(epoch, slot, checkpointNumber, syncedToBlockNumber, proposer, publisher, attestorAddress, invalidateCheckpoint) {
700
- return new CheckpointProposalJob(epoch, slot, checkpointNumber, syncedToBlockNumber, proposer, publisher, attestorAddress, invalidateCheckpoint, this.validatorClient, this.globalsBuilder, this.p2pClient, this.worldState, this.l1ToL2MessageSource, this.l2BlockSource, this.checkpointsBuilder, this.l2BlockSource, this.l1Constants, this.config, this.timetable, this.slasherClient, this.epochCache, this.dateProvider, this.metrics, this, this.setState.bind(this), this.tracer, this.log.getBindings());
819
+ createCheckpointProposalJob(slot, targetSlot, targetEpoch, checkpointNumber, syncedToBlockNumber, checkpointedCheckpointNumber, proposer, publisher, attestorAddress, invalidateCheckpoint, proposedCheckpointData) {
820
+ return new CheckpointProposalJob(slot, targetSlot, targetEpoch, checkpointNumber, syncedToBlockNumber, checkpointedCheckpointNumber, proposer, publisher, attestorAddress, invalidateCheckpoint, this.validatorClient, this.globalsBuilder, this.p2pClient, this.worldState, this.l1ToL2MessageSource, this.l2BlockSource, this.checkpointsBuilder, this.l2BlockSource, this.l1Constants, this.signatureContext, this.config, this.timetable, this.slasherClient, this.epochCache, this.dateProvider, this.metrics, this.checkpointProposalJobMetrics.createRecorder(), this, this.setState.bind(this), this.tracer, this.log.getBindings(), proposedCheckpointData);
701
821
  }
702
822
  /**
703
823
  * Returns the current sequencer state.
@@ -723,37 +843,52 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
723
843
  secondsIntoSlot = this.getSecondsIntoSlot(slotNumber);
724
844
  this.timetable.assertTimeLeft(proposedState, secondsIntoSlot);
725
845
  }
846
+ const oldState = this.state;
847
+ const oldStateSlotNumber = this.stateSlotNumber;
848
+ const stateChanged = proposedState !== oldState;
849
+ const transitionAtMs = performance.now();
850
+ const stateDurationMs = transitionAtMs - this.stateEnteredAtMs;
726
851
  const boringStates = [
727
852
  SequencerState.IDLE,
728
853
  SequencerState.SYNCHRONIZING
729
854
  ];
730
- const logLevel = boringStates.includes(proposedState) && boringStates.includes(this.state) ? 'trace' : 'debug';
731
- this.log[logLevel](`Transitioning from ${this.state} to ${proposedState}`, {
855
+ const logLevel = boringStates.includes(proposedState) && boringStates.includes(oldState) ? 'trace' : 'debug';
856
+ this.stateLog[logLevel](`Transitioning from ${oldState} to ${proposedState}`, {
857
+ oldState,
858
+ newState: proposedState,
732
859
  slotNumber,
733
- secondsIntoSlot
860
+ stateSlotNumber: oldStateSlotNumber,
861
+ secondsIntoSlot,
862
+ ...stateChanged && {
863
+ stateDurationMs: Math.ceil(stateDurationMs)
864
+ }
734
865
  });
735
866
  this.emit('state-changed', {
736
- oldState: this.state,
867
+ oldState,
737
868
  newState: proposedState,
738
869
  secondsIntoSlot,
739
870
  slot: slotNumber
740
871
  });
872
+ if (stateChanged) {
873
+ this.metrics.recordStateDuration(stateDurationMs, oldState);
874
+ this.stateEnteredAtMs = transitionAtMs;
875
+ this.stateSlotNumber = slotNumber;
876
+ }
741
877
  this.state = proposedState;
742
878
  }
743
879
  /**
744
880
  * Returns whether all dependencies have caught up.
745
881
  * We don't check against the previous block submitted since it may have been reorg'd out.
746
882
  */ async checkSync(args) {
747
- // Check that the archiver and dependencies have synced to the previous L1 slot at least
748
- // TODO(#14766): Archiver reports L1 timestamp based on L1 blocks seen, which means that a missed L1 block will
749
- // cause the archiver L1 timestamp to fall behind, and cause this sequencer to start processing one L1 slot later.
750
- const l1Timestamp = await this.l2BlockSource.getL1Timestamp();
751
- const { slot, ts } = args;
752
- if (l1Timestamp === undefined || l1Timestamp + BigInt(this.l1Constants.ethereumSlotDuration) < ts) {
883
+ // Check that the archiver has fully synced the L2 slot before the one we want to propose in.
884
+ // The archiver reports sync progress via L1 block timestamps and synced checkpoint slots.
885
+ // See getSyncedL2SlotNumber for how missed L1 blocks are handled.
886
+ const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber();
887
+ const { slot } = args;
888
+ if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) {
753
889
  this.log.debug(`Cannot propose block at next L2 slot ${slot} due to pending sync from L1`, {
754
890
  slot,
755
- ts,
756
- l1Timestamp
891
+ syncedL2Slot
757
892
  });
758
893
  return undefined;
759
894
  }
@@ -762,71 +897,120 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
762
897
  number: syncSummary.latestBlockNumber,
763
898
  hash: syncSummary.latestBlockHash
764
899
  })),
765
- this.l2BlockSource.getL2Tips().then((t)=>t.proposed),
900
+ this.l2BlockSource.getL2Tips().then((t)=>({
901
+ proposed: t.proposed,
902
+ checkpointed: t.checkpointed,
903
+ proposedCheckpoint: t.proposedCheckpoint
904
+ })),
766
905
  this.p2pClient.getStatus().then((p2p)=>p2p.syncedToL2Block),
767
- this.l1ToL2MessageSource.getL2Tips().then((t)=>t.proposed),
768
- this.l2BlockSource.getPendingChainValidationStatus()
906
+ this.l1ToL2MessageSource.getL2Tips().then((t)=>({
907
+ proposed: t.proposed,
908
+ checkpointed: t.checkpointed
909
+ })),
910
+ this.l2BlockSource.getPendingChainValidationStatus(),
911
+ this.l2BlockSource.getProposedCheckpointData()
769
912
  ]);
770
- const [worldState, l2BlockSource, p2p, l1ToL2MessageSource, pendingChainValidationStatus] = syncedBlocks;
771
- // 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,
772
- // as the world state can compute the new genesis block hash, but other components use the hardcoded constant.
773
- // TODO(palla/mbps): Fix the above. All components should be able to handle dynamic genesis block hashes.
774
- const result = l2BlockSource.number === 0 && worldState.number === 0 && p2p.number === 0 && l1ToL2MessageSource.number === 0 || worldState.hash === l2BlockSource.hash && p2p.hash === l2BlockSource.hash && l1ToL2MessageSource.hash === l2BlockSource.hash;
913
+ const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] = syncedBlocks;
914
+ const result = worldState.hash === l2Tips.proposed.hash && p2p.hash === l2Tips.proposed.hash && l1ToL2MessageSourceTips.proposed.hash === l2Tips.proposed.hash && l1ToL2MessageSourceTips.checkpointed.block.hash === l2Tips.checkpointed.block.hash && l1ToL2MessageSourceTips.checkpointed.checkpoint.hash === l2Tips.checkpointed.checkpoint.hash;
775
915
  if (!result) {
776
916
  this.log.debug(`Sequencer sync check failed`, {
777
917
  worldState,
778
- l2BlockSource,
918
+ l2BlockSource: l2Tips.proposed,
779
919
  p2p,
780
- l1ToL2MessageSource
920
+ l1ToL2MessageSourceTips
781
921
  });
782
922
  return undefined;
783
923
  }
784
- // Special case for genesis state
785
924
  const blockNumber = worldState.number;
786
- if (blockNumber < INITIAL_L2_BLOCK_NUM) {
787
- const archive = new Fr((await this.worldState.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root);
788
- return {
789
- checkpointNumber: CheckpointNumber.ZERO,
790
- blockNumber: BlockNumber.ZERO,
791
- archive,
792
- l1Timestamp,
793
- pendingChainValidationStatus
794
- };
795
- }
796
- const blockData = await this.l2BlockSource.getBlockData(blockNumber);
925
+ const blockData = await this.l2BlockSource.getBlockData({
926
+ number: blockNumber
927
+ });
797
928
  if (!blockData) {
798
- // this shouldn't really happen because a moment ago we checked that all components were in sync
799
- this.log.error(`Failed to get L2 block data ${blockNumber} from the archiver with all components in sync`);
929
+ this.log.warn(`Sequencer sync check failed: failed to get L2 block data ${blockNumber} from the archiver`, {
930
+ blockNumber,
931
+ l2Tips,
932
+ syncedL2Slot,
933
+ ...args
934
+ });
935
+ return undefined;
936
+ }
937
+ // Refuse to build a checkpoint on top of a proposed block whose enclosing checkpoint was never
938
+ // proposed. Under pipelining we may have received and reexecuted such a block locally — advancing
939
+ // our world-state tip past the checkpointed tip — while the proposing node never published the
940
+ // matching proposed checkpoint (e.g. it crashed before assembling it). Building on this orphan block
941
+ // would fork the chain off a tip no other node can follow. The archiver prunes these orphan blocks
942
+ // once their build slot ends; this guard is the correctness barrier during the grace window before.
943
+ if (blockData.checkpointNumber > l2Tips.checkpointed.checkpoint.number && (l2Tips.proposedCheckpoint.checkpoint.number !== blockData.checkpointNumber || proposedCheckpointData?.checkpointNumber !== blockData.checkpointNumber)) {
944
+ this.log.warn(`Sequencer sync check failed: proposed block has no matching proposed checkpoint`, {
945
+ blockCheckpointNumber: blockData.checkpointNumber,
946
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
947
+ proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
948
+ proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
949
+ blockNumber: blockData.header.getBlockNumber(),
950
+ blockSlot: blockData.header.getSlot(),
951
+ syncedL2Slot,
952
+ ...args
953
+ });
954
+ return undefined;
955
+ }
956
+ const hasProposedCheckpoint = l2Tips.proposedCheckpoint.checkpoint.number > l2Tips.checkpointed.checkpoint.number;
957
+ // The l2Tips and proposedCheckpointData reads above come from independent archiver snapshots
958
+ // (a JS-side tips cache vs. a direct store read on `#proposedCheckpoints`). A concurrent archiver
959
+ // write that mutates both can be observed split, leaving us with `hasProposedCheckpoint=true` but
960
+ // no proposedCheckpointData (or one whose number doesn't match the tip). Refuse to proceed in that
961
+ // window — the next checkSync tick will see a coherent snapshot.
962
+ if (hasProposedCheckpoint && (!proposedCheckpointData || proposedCheckpointData.checkpointNumber !== l2Tips.proposedCheckpoint.checkpoint.number)) {
963
+ this.log.warn(`Sequencer sync check failed: inconsistent proposed-checkpoint state`, {
964
+ proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
965
+ checkpointedTipNumber: l2Tips.checkpointed.checkpoint.number,
966
+ proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
967
+ syncedL2Slot,
968
+ ...args
969
+ });
970
+ return undefined;
971
+ }
972
+ // Check that the proposed checkpoint is indeed the parent of the checkpoint we'll be building
973
+ // The checkpoint number to build is derived as blockData.checkpointNumber + 1
974
+ if (proposedCheckpointData && proposedCheckpointData.checkpointNumber !== blockData.checkpointNumber) {
975
+ this.log.warn(`Sequencer sync check failed: proposed checkpoint number mismatch`, {
976
+ proposedCheckpointNumber: proposedCheckpointData.checkpointNumber,
977
+ blockCheckpointNumber: blockData.checkpointNumber,
978
+ syncedL2Slot,
979
+ ...args
980
+ });
800
981
  return undefined;
801
982
  }
802
983
  return {
803
984
  blockData,
804
985
  blockNumber: blockData.header.getBlockNumber(),
805
986
  checkpointNumber: blockData.checkpointNumber,
987
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
806
988
  archive: blockData.archive.root,
807
- l1Timestamp,
989
+ hasProposedCheckpoint,
990
+ proposedCheckpointData,
991
+ syncedL2Slot,
808
992
  pendingChainValidationStatus
809
993
  };
810
994
  }
811
995
  /**
812
996
  * Checks if we are the proposer for the next slot.
813
997
  * @returns True if we can propose, and the proposer address (undefined if anyone can propose)
814
- */ async checkCanPropose(slot) {
998
+ */ async checkCanPropose(targetSlot) {
815
999
  let proposer;
816
1000
  try {
817
- proposer = await this.epochCache.getProposerAttesterAddressInSlot(slot);
1001
+ proposer = await this.epochCache.getProposerAttesterAddressInSlot(targetSlot);
818
1002
  } catch (e) {
819
1003
  if (e instanceof NoCommitteeError) {
820
- if (this.lastSlotForNoCommitteeWarning !== slot) {
821
- this.lastSlotForNoCommitteeWarning = slot;
822
- this.log.warn(`Cannot propose at next L2 slot ${slot} since the committee does not exist on L1`);
1004
+ if (this.lastSlotForNoCommitteeWarning !== targetSlot) {
1005
+ this.lastSlotForNoCommitteeWarning = targetSlot;
1006
+ this.log.warn(`Cannot propose at target slot ${targetSlot} since the committee does not exist on L1`);
823
1007
  }
824
1008
  return [
825
1009
  false,
826
1010
  undefined
827
1011
  ];
828
1012
  }
829
- this.log.error(`Error getting proposer for slot ${slot}`, e);
1013
+ this.log.error(`Error getting proposer for target slot ${targetSlot}`, e);
830
1014
  return [
831
1015
  false,
832
1016
  undefined
@@ -849,7 +1033,8 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
849
1033
  const validatorAddresses = this.validatorClient.getValidatorAddresses();
850
1034
  const weAreProposer = validatorAddresses.some((addr)=>addr.equals(proposer));
851
1035
  if (!weAreProposer) {
852
- this.log.debug(`Cannot propose at slot ${slot} since we are not a proposer`, {
1036
+ this.log.debug(`Cannot propose at target slot ${targetSlot} since we are not a proposer`, {
1037
+ targetSlot,
853
1038
  validatorAddresses,
854
1039
  proposer
855
1040
  });
@@ -858,6 +1043,10 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
858
1043
  proposer
859
1044
  ];
860
1045
  }
1046
+ this.log.info(`We are the proposer for pipeline slot ${targetSlot}`, {
1047
+ targetSlot,
1048
+ proposer
1049
+ });
861
1050
  return [
862
1051
  true,
863
1052
  proposer
@@ -867,7 +1056,7 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
867
1056
  * Tries to vote on slashing actions and governance when the sync check fails but we're past the max time for initializing a proposal.
868
1057
  * This allows the sequencer to participate in governance/slashing votes even when it cannot build blocks.
869
1058
  */ async tryVoteWhenSyncFails(args) {
870
- const { slot } = args;
1059
+ const { slot, targetSlot } = args;
871
1060
  // Prevent duplicate attempts in the same slot
872
1061
  if (this.lastSlotForFallbackVote === slot) {
873
1062
  this.log.trace(`Already attempted to vote in slot ${slot} (skipping)`);
@@ -890,7 +1079,7 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
890
1079
  maxAllowedTime
891
1080
  });
892
1081
  // Check if we're a proposer or proposal is open
893
- const [canPropose, proposer] = await this.checkCanPropose(slot);
1082
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
894
1083
  if (!canPropose) {
895
1084
  this.log.trace(`Cannot vote in slot ${slot} since we are not a proposer`, {
896
1085
  slot,
@@ -906,8 +1095,8 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
906
1095
  attestorAddress,
907
1096
  slot
908
1097
  });
909
- // Enqueue governance and slashing votes
910
- const voter = new CheckpointVoter(slot, publisher, attestorAddress, this.validatorClient, this.slasherClient, this.l1Constants, this.config, this.metrics, this.log);
1098
+ // Enqueue governance and slashing votes (voter uses the target slot for L1 submission)
1099
+ const voter = new CheckpointVoter(targetSlot, publisher, attestorAddress, this.validatorClient, this.slasherClient, this.l1Constants, this.config, this.metrics, this.log);
911
1100
  const votesPromises = voter.enqueueVotes();
912
1101
  const votes = await Promise.all(votesPromises);
913
1102
  if (votes.every((p)=>!p)) {
@@ -917,13 +1106,21 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
917
1106
  this.log.info(`Voting in slot ${slot} despite sync failure`, {
918
1107
  slot
919
1108
  });
920
- await publisher.sendRequests();
1109
+ // Votes are EIP-712-signed for `targetSlot` (the pipelined slot in which the multicall is
1110
+ // expected to mine). Delay submission to the start of `targetSlot` so the tx mines in the
1111
+ // slot the votes were signed for. We fire-and-forget so we don't block the sequencer's
1112
+ // work loop while waiting for the target slot to start.
1113
+ void publisher.sendRequestsAt(targetSlot).catch((err)=>{
1114
+ this.log.error(`Failed to publish votes despite sync failure for slot ${slot}`, err, {
1115
+ slot
1116
+ });
1117
+ });
921
1118
  }
922
1119
  /**
923
1120
  * Tries to vote on slashing actions and governance proposals when escape hatch is open.
924
1121
  * This allows the sequencer to participate in voting without performing checkpoint proposal work.
925
1122
  */ async tryVoteWhenEscapeHatchOpen(args) {
926
- const { slot, proposer } = args;
1123
+ const { slot, targetSlot, proposer } = args;
927
1124
  // Prevent duplicate attempts in the same slot
928
1125
  if (this.lastSlotForFallbackVote === slot) {
929
1126
  this.log.trace(`Already attempted to vote in slot ${slot} (escape hatch open, skipping)`);
@@ -934,9 +1131,15 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
934
1131
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
935
1132
  this.log.debug(`Escape hatch open for slot ${slot}, attempting vote-only actions`, {
936
1133
  slot,
1134
+ targetSlot,
937
1135
  attestorAddress
938
1136
  });
939
- const voter = new CheckpointVoter(slot, publisher, attestorAddress, this.validatorClient, this.slasherClient, this.l1Constants, this.config, this.metrics, this.log);
1137
+ // Under proposer pipelining, the multicall is expected to mine in `targetSlot` (slot + 1).
1138
+ // Governance and slashing votes are EIP-712-signed against the slot they will mine in, and the
1139
+ // L1 contract checks `msg.sender == getCurrentProposer()` using the mining slot. So we must
1140
+ // sign for `targetSlot` and delay submission to the start of `targetSlot`. When pipelining is
1141
+ // disabled `targetSlot == slot` and `sendRequestsAt` resolves with no extra sleep.
1142
+ const voter = new CheckpointVoter(targetSlot, publisher, attestorAddress, this.validatorClient, this.slasherClient, this.l1Constants, this.config, this.metrics, this.log);
940
1143
  const votesPromises = voter.enqueueVotes();
941
1144
  const votes = await Promise.all(votesPromises);
942
1145
  if (votes.every((p)=>!p)) {
@@ -944,9 +1147,20 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
944
1147
  return;
945
1148
  }
946
1149
  this.log.info(`Voting in slot ${slot} (escape hatch open)`, {
947
- slot
1150
+ slot,
1151
+ targetSlot
1152
+ });
1153
+ // Votes are EIP-712-signed for `targetSlot`. Delay submission to the start of `targetSlot` so
1154
+ // the multicall mines in the slot the votes were signed for; otherwise the L1 contract reads
1155
+ // `signaler = getCurrentProposer()` against the wrong slot and signature verification fails
1156
+ // silently inside Multicall3. Fire-and-forget so we don't block the sequencer's work loop while
1157
+ // waiting for the target slot to start, mirroring tryVoteWhenSyncFails.
1158
+ void publisher.sendRequestsAt(targetSlot).catch((err)=>{
1159
+ this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, {
1160
+ slot,
1161
+ targetSlot
1162
+ });
948
1163
  });
949
- await publisher.sendRequests();
950
1164
  }
951
1165
  /**
952
1166
  * Considers invalidating a block if the pending chain is invalid. Depends on how long the invalid block
@@ -954,7 +1168,7 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
954
1168
  * have the proposer try to invalidate, but if they fail, the sequencers in the committee are expected to try,
955
1169
  * and if they fail, any sequencer will try as well.
956
1170
  */ async considerInvalidatingCheckpoint(syncedTo, currentSlot) {
957
- const { pendingChainValidationStatus, l1Timestamp } = syncedTo;
1171
+ const { pendingChainValidationStatus, syncedL2Slot } = syncedTo;
958
1172
  if (pendingChainValidationStatus.valid) {
959
1173
  return;
960
1174
  }
@@ -965,7 +1179,7 @@ _dec = trackSpan('Sequencer.work'), _dec1 = trackSpan('Sequencer.prepareCheckpoi
965
1179
  const { secondsBeforeInvalidatingBlockAsCommitteeMember, secondsBeforeInvalidatingBlockAsNonCommitteeMember } = this.config;
966
1180
  const logData = {
967
1181
  invalidL1Timestamp: invalidCheckpointTimestamp,
968
- l1Timestamp,
1182
+ syncedL2Slot,
969
1183
  invalidCheckpoint: pendingChainValidationStatus.checkpoint,
970
1184
  secondsBeforeInvalidatingBlockAsCommitteeMember,
971
1185
  secondsBeforeInvalidatingBlockAsNonCommitteeMember,