@aztec/sequencer-client 0.0.1-commit.86469d5 → 0.0.1-commit.8655d4a

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 (125) hide show
  1. package/README.md +282 -21
  2. package/dest/client/sequencer-client.d.ts +19 -9
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +67 -33
  5. package/dest/config.d.ts +29 -7
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +77 -37
  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 -16
  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 +47 -17
  24. package/dest/publisher/config.d.ts.map +1 -1
  25. package/dest/publisher/config.js +121 -42
  26. package/dest/publisher/index.d.ts +2 -1
  27. package/dest/publisher/index.d.ts.map +1 -1
  28. package/dest/publisher/l1_tx_failed_store/factory.d.ts +11 -0
  29. package/dest/publisher/l1_tx_failed_store/factory.d.ts.map +1 -0
  30. package/dest/publisher/l1_tx_failed_store/factory.js +22 -0
  31. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +58 -0
  32. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -0
  33. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +1 -0
  34. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +15 -0
  35. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -0
  36. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +34 -0
  37. package/dest/publisher/l1_tx_failed_store/index.d.ts +4 -0
  38. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -0
  39. package/dest/publisher/l1_tx_failed_store/index.js +2 -0
  40. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  41. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  42. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  43. package/dest/publisher/sequencer-publisher-factory.d.ts +11 -5
  44. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  45. package/dest/publisher/sequencer-publisher-factory.js +27 -3
  46. package/dest/publisher/sequencer-publisher.d.ts +94 -67
  47. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  48. package/dest/publisher/sequencer-publisher.js +440 -378
  49. package/dest/sequencer/automine/automine_factory.d.ts +54 -0
  50. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  51. package/dest/sequencer/automine/automine_factory.js +84 -0
  52. package/dest/sequencer/automine/automine_sequencer.d.ts +151 -0
  53. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  54. package/dest/sequencer/automine/automine_sequencer.js +506 -0
  55. package/dest/sequencer/chain_state_overrides.d.ts +61 -0
  56. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  57. package/dest/sequencer/chain_state_overrides.js +98 -0
  58. package/dest/sequencer/checkpoint_proposal_job.d.ts +56 -11
  59. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  60. package/dest/sequencer/checkpoint_proposal_job.js +805 -225
  61. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  62. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  63. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  64. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  65. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  66. package/dest/sequencer/checkpoint_voter.js +2 -5
  67. package/dest/sequencer/events.d.ts +47 -2
  68. package/dest/sequencer/events.d.ts.map +1 -1
  69. package/dest/sequencer/index.d.ts +3 -1
  70. package/dest/sequencer/index.d.ts.map +1 -1
  71. package/dest/sequencer/index.js +2 -0
  72. package/dest/sequencer/metrics.d.ts +23 -8
  73. package/dest/sequencer/metrics.d.ts.map +1 -1
  74. package/dest/sequencer/metrics.js +117 -21
  75. package/dest/sequencer/sequencer.d.ts +74 -21
  76. package/dest/sequencer/sequencer.d.ts.map +1 -1
  77. package/dest/sequencer/sequencer.js +353 -136
  78. package/dest/sequencer/timetable.d.ts +17 -6
  79. package/dest/sequencer/timetable.d.ts.map +1 -1
  80. package/dest/sequencer/timetable.js +51 -46
  81. package/dest/sequencer/types.d.ts +2 -2
  82. package/dest/sequencer/types.d.ts.map +1 -1
  83. package/dest/test/index.d.ts +3 -5
  84. package/dest/test/index.d.ts.map +1 -1
  85. package/dest/test/mock_checkpoint_builder.d.ts +11 -11
  86. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  87. package/dest/test/mock_checkpoint_builder.js +45 -34
  88. package/dest/test/utils.d.ts +3 -3
  89. package/dest/test/utils.d.ts.map +1 -1
  90. package/dest/test/utils.js +10 -8
  91. package/package.json +27 -28
  92. package/src/client/sequencer-client.ts +92 -35
  93. package/src/config.ts +102 -46
  94. package/src/global_variable_builder/README.md +44 -0
  95. package/src/global_variable_builder/fee_predictor.ts +172 -0
  96. package/src/global_variable_builder/fee_provider.ts +75 -0
  97. package/src/global_variable_builder/global_builder.ts +26 -63
  98. package/src/global_variable_builder/index.ts +3 -1
  99. package/src/index.ts +10 -1
  100. package/src/publisher/config.ts +157 -45
  101. package/src/publisher/index.ts +3 -0
  102. package/src/publisher/l1_tx_failed_store/factory.ts +32 -0
  103. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +57 -0
  104. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +46 -0
  105. package/src/publisher/l1_tx_failed_store/index.ts +3 -0
  106. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  107. package/src/publisher/sequencer-publisher-factory.ts +38 -9
  108. package/src/publisher/sequencer-publisher.ts +533 -469
  109. package/src/sequencer/README.md +152 -450
  110. package/src/sequencer/automine/README.md +46 -0
  111. package/src/sequencer/automine/automine_factory.ts +145 -0
  112. package/src/sequencer/automine/automine_sequencer.ts +592 -0
  113. package/src/sequencer/chain_state_overrides.ts +162 -0
  114. package/src/sequencer/checkpoint_proposal_job.ts +984 -245
  115. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  116. package/src/sequencer/checkpoint_voter.ts +1 -12
  117. package/src/sequencer/events.ts +50 -2
  118. package/src/sequencer/index.ts +2 -0
  119. package/src/sequencer/metrics.ts +132 -25
  120. package/src/sequencer/sequencer.ts +444 -154
  121. package/src/sequencer/timetable.ts +70 -57
  122. package/src/sequencer/types.ts +1 -1
  123. package/src/test/index.ts +2 -4
  124. package/src/test/mock_checkpoint_builder.ts +63 -49
  125. package/src/test/utils.ts +31 -10
@@ -1,20 +1,26 @@
1
1
  import { getKzg } from '@aztec/blob-lib';
2
- import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
3
- import type { EpochCache } from '@aztec/epoch-cache';
2
+ import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
4
3
  import { NoCommitteeError, type RollupContract } from '@aztec/ethereum/contracts';
5
4
  import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
6
5
  import { merge, omit, pick } from '@aztec/foundation/collection';
7
6
  import { Fr } from '@aztec/foundation/curves/bn254';
8
7
  import { EthAddress } from '@aztec/foundation/eth-address';
9
- import { createLogger } from '@aztec/foundation/log';
8
+ import { type Logger, createLogger } from '@aztec/foundation/log';
10
9
  import { RunningPromise } from '@aztec/foundation/running-promise';
11
10
  import type { DateProvider } from '@aztec/foundation/timer';
12
11
  import type { TypedEventEmitter } from '@aztec/foundation/types';
13
12
  import type { P2P } from '@aztec/p2p';
14
13
  import type { SlasherClientInterface } from '@aztec/slasher';
15
- import type { L2Block, L2BlockSink, L2BlockSource, ValidateCheckpointResult } from '@aztec/stdlib/block';
16
- import type { Checkpoint } from '@aztec/stdlib/checkpoint';
17
- import { getSlotAtTimestamp, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
14
+ import type {
15
+ BlockData,
16
+ L2BlockSink,
17
+ L2BlockSource,
18
+ ProposedCheckpointSink,
19
+ ValidateCheckpointResult,
20
+ } from '@aztec/stdlib/block';
21
+ import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
22
+ import type { ChainConfig } from '@aztec/stdlib/config';
23
+ import { getEpochAtSlot, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
18
24
  import {
19
25
  type ResolvedSequencerConfig,
20
26
  type SequencerConfig,
@@ -22,10 +28,10 @@ import {
22
28
  type WorldStateSynchronizer,
23
29
  } from '@aztec/stdlib/interfaces/server';
24
30
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
31
+ import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p';
25
32
  import { pickFromSchema } from '@aztec/stdlib/schemas';
26
- import { MerkleTreeId } from '@aztec/stdlib/trees';
27
33
  import { Attributes, type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
28
- import { FullNodeCheckpointsBuilder, type ValidatorClient } from '@aztec/validator-client';
34
+ import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client';
29
35
 
30
36
  import EventEmitter from 'node:events';
31
37
 
@@ -33,7 +39,9 @@ import { DefaultSequencerConfig } from '../config.js';
33
39
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
34
40
  import type { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js';
35
41
  import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
42
+ import { buildCheckpointSimulationOverridesPlan } from './chain_state_overrides.js';
36
43
  import { CheckpointProposalJob } from './checkpoint_proposal_job.js';
44
+ import { CheckpointProposalJobMetrics } from './checkpoint_proposal_job_metrics.js';
37
45
  import { CheckpointVoter } from './checkpoint_voter.js';
38
46
  import { SequencerInterruptedError, SequencerTooSlowError } from './errors.js';
39
47
  import type { SequencerEvents } from './events.js';
@@ -44,6 +52,16 @@ import { SequencerState } from './utils.js';
44
52
 
45
53
  export { SequencerState };
46
54
 
55
+ /** Slot snapshot used to prepare a checkpoint proposal. */
56
+ type SequencerSlotContext = {
57
+ slot: SlotNumber;
58
+ targetSlot: SlotNumber;
59
+ epoch: EpochNumber;
60
+ targetEpoch: EpochNumber;
61
+ ts: bigint;
62
+ nowSeconds: bigint;
63
+ };
64
+
47
65
  /**
48
66
  * Sequencer client
49
67
  * - Checks whether it is elected as proposer for the next slot
@@ -55,11 +73,18 @@ export { SequencerState };
55
73
  export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<SequencerEvents>) {
56
74
  private runningPromise?: RunningPromise;
57
75
  private state = SequencerState.STOPPED;
76
+ private stateSlotNumber: SlotNumber | undefined;
77
+ private stateEnteredAtMs = performance.now();
58
78
  private metrics: SequencerMetrics;
79
+ private checkpointProposalJobMetrics: CheckpointProposalJobMetrics;
80
+ private readonly stateLog: Logger;
59
81
 
60
82
  /** The last slot for which we attempted to perform our voting duties with degraded block production */
61
83
  private lastSlotForFallbackVote: SlotNumber | undefined;
62
84
 
85
+ /** The last slot for which we logged "no committee" warning, to avoid spam */
86
+ private lastSlotForNoCommitteeWarning: SlotNumber | undefined;
87
+
63
88
  /** The last slot for which we triggered a checkpoint proposal job, to prevent duplicate attempts. */
64
89
  private lastSlotForCheckpointProposalJob: SlotNumber | undefined;
65
90
 
@@ -69,19 +94,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
69
94
  /** The last epoch for which we logged strategy comparison in fisherman mode. */
70
95
  private lastEpochForStrategyComparison: EpochNumber | undefined;
71
96
 
97
+ /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */
98
+ protected lastCheckpointProposalJob: CheckpointProposalJob | undefined;
99
+
72
100
  /** The maximum number of seconds that the sequencer can be into a slot to transition to a particular state. */
73
101
  protected timetable!: SequencerTimetable;
74
102
 
75
- // This shouldn't be here as this gets re-created each time we build/propose a block.
76
- // But we have a number of tests that abuse/rely on this class having a permanent publisher.
77
- // As long as those tests only configure a single publisher they will continue to work.
78
- // This will get re-assigned every time the sequencer goes to build a new block to a publisher that is valid
79
- // for the block proposer.
80
- // TODO(palla/mbps): Remove this field and fix tests
81
- protected publisher: SequencerPublisher | undefined;
82
-
83
103
  /** Config for the sequencer */
84
104
  protected config: ResolvedSequencerConfig = DefaultSequencerConfig;
105
+ private readonly signatureContext: CoordinationSignatureContext;
85
106
 
86
107
  constructor(
87
108
  protected publisherFactory: SequencerPublisherFactory,
@@ -90,51 +111,58 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
90
111
  protected p2pClient: P2P,
91
112
  protected worldState: WorldStateSynchronizer,
92
113
  protected slasherClient: SlasherClientInterface | undefined,
93
- protected l2BlockSource: L2BlockSource & L2BlockSink,
114
+ protected l2BlockSource: L2BlockSource & L2BlockSink & ProposedCheckpointSink,
94
115
  protected l1ToL2MessageSource: L1ToL2MessageSource,
95
116
  protected checkpointsBuilder: FullNodeCheckpointsBuilder,
96
117
  protected l1Constants: SequencerRollupConstants,
97
118
  protected dateProvider: DateProvider,
98
119
  protected epochCache: EpochCache,
99
120
  protected rollupContract: RollupContract,
100
- config: SequencerConfig,
121
+ config: SequencerConfig & Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'>,
101
122
  protected telemetry: TelemetryClient = getTelemetryClient(),
102
123
  protected log = createLogger('sequencer'),
103
124
  ) {
104
125
  super();
126
+ this.stateLog = log.createChild('state');
105
127
 
106
128
  // Add [FISHERMAN] prefix to logger if in fisherman mode
107
129
  if (config.fishermanMode) {
108
130
  this.log = log.createChild('[FISHERMAN]');
109
131
  }
110
132
 
133
+ this.signatureContext = {
134
+ chainId: config.l1ChainId,
135
+ rollupAddress: config.rollupAddress,
136
+ };
111
137
  this.metrics = new SequencerMetrics(telemetry, this.rollupContract, 'Sequencer');
138
+ this.checkpointProposalJobMetrics = new CheckpointProposalJobMetrics(telemetry);
112
139
  this.updateConfig(config);
113
140
  }
114
141
 
115
142
  /** Updates sequencer config by the defined values and updates the timetable */
116
143
  public updateConfig(config: Partial<SequencerConfig>) {
117
144
  const filteredConfig = pickFromSchema(config, SequencerConfigSchema);
118
- this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowList'));
145
+ this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend'));
119
146
  this.config = merge(this.config, filteredConfig);
147
+ const p2pPropagationTime = this.config.attestationPropagationTime;
120
148
  this.timetable = new SequencerTimetable(
121
149
  {
122
150
  ethereumSlotDuration: this.l1Constants.ethereumSlotDuration,
123
151
  aztecSlotDuration: this.aztecSlotDuration,
124
152
  l1PublishingTime: this.l1PublishingTime,
125
- p2pPropagationTime: this.config.attestationPropagationTime,
153
+ p2pPropagationTime,
126
154
  blockDurationMs: this.config.blockDurationMs,
127
155
  enforce: this.config.enforceTimeTable,
156
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
128
157
  },
129
158
  this.metrics,
130
159
  this.log,
131
160
  );
132
161
  }
133
162
 
134
- /** Initializes the sequencer (precomputes tables and creates a publisher). Takes about 3s. */
135
- public async init() {
163
+ /** Initializes the sequencer (precomputes tables). Takes about 3s. */
164
+ public init() {
136
165
  getKzg();
137
- this.publisher = (await this.publisherFactory.create(undefined)).publisher;
138
166
  }
139
167
 
140
168
  /** Starts the sequencer and moves to IDLE state. */
@@ -149,12 +177,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
149
177
  this.log.info('Started sequencer');
150
178
  }
151
179
 
180
+ /** Triggers an immediate run of the sequencer, bypassing the polling interval. */
181
+ public trigger() {
182
+ return this.runningPromise?.trigger();
183
+ }
184
+
152
185
  /** Stops the sequencer from building blocks and moves to STOPPED state. */
153
186
  public async stop(): Promise<void> {
154
187
  this.log.info(`Stopping sequencer`);
155
188
  this.setState(SequencerState.STOPPING, undefined, { force: true });
156
- this.publisher?.interrupt();
189
+ await this.publisherFactory.stopAll();
157
190
  await this.runningPromise?.stop();
191
+ await this.lastCheckpointProposalJob?.awaitPendingSubmission();
158
192
  this.setState(SequencerState.STOPPED, undefined, { force: true });
159
193
  this.log.info('Stopped sequencer');
160
194
  }
@@ -164,18 +198,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
164
198
  try {
165
199
  await this.work();
166
200
  } catch (err) {
167
- this.emit('checkpoint-error', { error: err as Error });
168
- if (err instanceof SequencerTooSlowError) {
169
- // TODO(palla/mbps): Add missing states
170
- // Log as warn only if we had to abort halfway through the block proposal
171
- const logLvl = [SequencerState.INITIALIZING_CHECKPOINT, SequencerState.PROPOSER_CHECK].includes(
172
- err.proposedState,
173
- )
174
- ? ('debug' as const)
175
- : ('warn' as const);
176
- this.log[logLvl](err.message, { now: this.dateProvider.nowInSeconds() });
201
+ if (
202
+ err instanceof SequencerTooSlowError &&
203
+ [SequencerState.INITIALIZING_CHECKPOINT, SequencerState.PROPOSER_CHECK].includes(err.proposedState)
204
+ ) {
205
+ // No need to alert if we just didn't get to start in time
206
+ this.log.debug(err.message, { now: this.dateProvider.nowInSeconds() });
177
207
  } else {
178
208
  // Re-throw other errors
209
+ this.emit('checkpoint-error', { error: err as Error });
179
210
  throw err;
180
211
  }
181
212
  } finally {
@@ -199,14 +230,24 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
199
230
  @trackSpan('Sequencer.work')
200
231
  protected async work() {
201
232
  this.setState(SequencerState.SYNCHRONIZING, undefined);
202
- const { slot, ts, now, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
233
+ const { slot, targetSlot, epoch, targetEpoch, ts, nowSeconds } = this.getSlotContextInNextL1Slot();
203
234
 
204
235
  // Check if we are synced and it's our slot, grab a publisher, check previous block invalidation, etc
205
- const checkpointProposalJob = await this.prepareCheckpointProposal(epoch, slot, ts, now);
236
+ const checkpointProposalJob = await this.prepareCheckpointProposal(
237
+ slot,
238
+ targetSlot,
239
+ epoch,
240
+ targetEpoch,
241
+ ts,
242
+ nowSeconds,
243
+ );
206
244
  if (!checkpointProposalJob) {
207
245
  return;
208
246
  }
209
247
 
248
+ // Track the job so we can await its pending L1 submission during shutdown
249
+ this.lastCheckpointProposalJob = checkpointProposalJob;
250
+
210
251
  // Execute the checkpoint proposal job
211
252
  const checkpoint = await checkpointProposalJob.execute();
212
253
 
@@ -215,64 +256,87 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
215
256
  this.lastCheckpointProposed = checkpoint;
216
257
  }
217
258
 
218
- // Log fee strategy comparison if on fisherman
259
+ // Log fee strategy comparison if on fisherman (uses target epoch since we mirror the proposer's perspective)
219
260
  if (
220
261
  this.config.fishermanMode &&
221
- (this.lastEpochForStrategyComparison === undefined || epoch > this.lastEpochForStrategyComparison)
262
+ (this.lastEpochForStrategyComparison === undefined || targetEpoch > this.lastEpochForStrategyComparison)
222
263
  ) {
223
- this.logStrategyComparison(epoch, checkpointProposalJob.getPublisher());
224
- this.lastEpochForStrategyComparison = epoch;
264
+ this.logStrategyComparison(targetEpoch, checkpointProposalJob.getPublisher());
265
+ this.lastEpochForStrategyComparison = targetEpoch;
225
266
  }
226
267
 
227
268
  return checkpoint;
228
269
  }
229
270
 
271
+ /** Returns slot and target slot from a single clock snapshot. */
272
+ protected getSlotContextInNextL1Slot(): SequencerSlotContext {
273
+ const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
274
+ const targetSlot = SlotNumber(
275
+ slot + (this.epochCache.isProposerPipeliningEnabled() ? PROPOSER_PIPELINING_SLOT_OFFSET : 0),
276
+ );
277
+ return { slot, targetSlot, epoch, targetEpoch: getEpochAtSlot(targetSlot, this.l1Constants), ts, nowSeconds };
278
+ }
279
+
230
280
  /**
231
281
  * Prepares the checkpoint proposal by performing all necessary checks and setup.
232
282
  * This is the initial step in the main loop.
233
283
  * @returns CheckpointProposalJob if successful, undefined if we are not yet synced or are not the proposer.
234
284
  */
235
285
  @trackSpan('Sequencer.prepareCheckpointProposal')
236
- private async prepareCheckpointProposal(
237
- epoch: EpochNumber,
286
+ protected async prepareCheckpointProposal(
238
287
  slot: SlotNumber,
288
+ targetSlot: SlotNumber,
289
+ epoch: EpochNumber,
290
+ targetEpoch: EpochNumber,
239
291
  ts: bigint,
240
- now: bigint,
292
+ nowSeconds: bigint,
241
293
  ): Promise<CheckpointProposalJob | undefined> {
242
- // Check we have not already processed this slot (cheapest check)
294
+ // Check we have not already processed this target slot (cheapest check)
243
295
  // We only check this if enforce timetable is set, since we want to keep processing the same slot if we are not
244
296
  // running against actual time (eg when we use sandbox-style automining)
245
297
  if (
246
298
  this.lastSlotForCheckpointProposalJob &&
247
- this.lastSlotForCheckpointProposalJob >= slot &&
299
+ this.lastSlotForCheckpointProposalJob >= targetSlot &&
248
300
  this.config.enforceTimeTable
249
301
  ) {
250
- this.log.trace(`Slot ${slot} has already been processed`);
302
+ this.log.trace(`Target slot ${targetSlot} has already been processed`);
251
303
  return undefined;
252
304
  }
253
305
 
254
- // But if we have already proposed for this slot, the we definitely have to skip it, automining or not
255
- if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= slot) {
256
- this.log.trace(`Slot ${slot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`);
306
+ // But if we have already proposed for this slot, then we definitely have to skip it, automining or not
307
+ if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= targetSlot) {
308
+ this.log.trace(
309
+ `Slot ${targetSlot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`,
310
+ );
311
+ return undefined;
312
+ }
313
+
314
+ // Test-only: skip proposing for explicitly paused slots. Attestation paths run in the validator
315
+ // client and are not gated by this hook, so paused proposers still attest to others' proposals.
316
+ if (this.config.pauseProposingForSlots?.some(s => s === targetSlot)) {
317
+ this.log.warn(`Skipping proposal for paused slot ${targetSlot} (test-only pauseProposingForSlots hook)`, {
318
+ targetSlot,
319
+ });
257
320
  return undefined;
258
321
  }
259
322
 
260
323
  // Check all components are synced to latest as seen by the archiver (queries all subsystems)
261
324
  const syncedTo = await this.checkSync({ ts, slot });
262
325
  if (!syncedTo) {
263
- await this.tryVoteWhenSyncFails({ slot, ts });
326
+ await this.tryVoteWhenSyncFails({ slot, targetSlot, ts });
264
327
  return undefined;
265
328
  }
266
329
 
267
- // If escape hatch is open for this epoch, do not start checkpoint proposal work and do not attempt invalidations.
330
+ // If escape hatch is open for the target epoch, do not start checkpoint proposal work and do not attempt invalidations.
268
331
  // Still perform governance/slashing voting (as proposer) once per slot.
269
- const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(epoch);
332
+ // When pipelining, we check the target epoch (slot+1's epoch) since that's the epoch we're building for.
333
+ const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(targetEpoch);
270
334
 
271
335
  if (isEscapeHatchOpen) {
272
336
  this.setState(SequencerState.PROPOSER_CHECK, slot);
273
- const [canPropose, proposer] = await this.checkCanPropose(slot);
337
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
274
338
  if (canPropose) {
275
- await this.tryVoteWhenEscapeHatchOpen({ slot, proposer });
339
+ await this.tryVoteWhenEscapeHatchOpen({ slot, targetSlot, proposer });
276
340
  } else {
277
341
  this.log.trace(`Escape hatch open but we are not proposer, skipping vote-only actions`, {
278
342
  slot,
@@ -287,18 +351,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
287
351
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
288
352
 
289
353
  const logCtx = {
290
- now,
291
- syncedToL1Ts: syncedTo.l1Timestamp,
292
- syncedToL2Slot: getSlotAtTimestamp(syncedTo.l1Timestamp, this.l1Constants),
354
+ nowSeconds,
355
+ syncedToL2Slot: syncedTo.syncedL2Slot,
293
356
  slot,
357
+ targetSlot,
294
358
  slotTs: ts,
295
359
  checkpointNumber,
296
360
  isPendingChainValid: pick(syncedTo.pendingChainValidationStatus, 'valid', 'reason', 'invalidIndex'),
297
361
  };
298
362
 
299
- // Check that we are a proposer for the next slot
363
+ // Check that we are a proposer for the target slot.
300
364
  this.setState(SequencerState.PROPOSER_CHECK, slot);
301
- const [canPropose, proposer] = await this.checkCanPropose(slot);
365
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
302
366
 
303
367
  // If we are not a proposer check if we should invalidate an invalid checkpoint, and bail
304
368
  if (!canPropose) {
@@ -306,13 +370,23 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
306
370
  return undefined;
307
371
  }
308
372
 
309
- // Check that the slot is not taken by a block already (should never happen, since only us can propose for this slot)
310
- if (syncedTo.block && syncedTo.block.header.getSlot() >= slot) {
373
+ // Guard: don't exceed 1-deep pipeline. Without a proposed checkpoint, we can only build
374
+ // confirmed + 1. With a proposed checkpoint, we can build confirmed + 2.
375
+ const confirmedCkpt = syncedTo.checkpointedCheckpointNumber;
376
+ if (checkpointNumber > confirmedCkpt + 2) {
377
+ this.log.verbose(
378
+ `Skipping slot ${targetSlot}: checkpoint ${checkpointNumber} exceeds max pipeline depth (confirmed=${confirmedCkpt})`,
379
+ );
380
+ return undefined;
381
+ }
382
+
383
+ // Check that the target slot is not taken by a block already (should never happen, since only us can propose for this slot)
384
+ if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= targetSlot) {
311
385
  this.log.warn(
312
- `Cannot propose block at next L2 slot ${slot} since that slot was taken by block ${syncedTo.blockNumber}`,
313
- { ...logCtx, block: syncedTo.block.header.toInspect() },
386
+ `Cannot propose block at target slot ${targetSlot} since that slot was taken by block ${syncedTo.blockNumber}`,
387
+ { ...logCtx, block: syncedTo.blockData.header.toInspect() },
314
388
  );
315
- this.metrics.recordBlockProposalPrecheckFailed('slot_already_taken');
389
+ this.metrics.recordCheckpointPrecheckFailed('slot_already_taken');
316
390
  return undefined;
317
391
  }
318
392
 
@@ -323,86 +397,175 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
323
397
  const proposerForPublisher = this.config.fishermanMode ? undefined : proposer;
324
398
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposerForPublisher);
325
399
  this.log.verbose(`Created publisher at address ${publisher.getSenderAddress()} for attestor ${attestorAddress}`);
326
- this.publisher = publisher;
327
400
 
328
- // In fisherman mode, set the actual proposer's address for simulations
329
- if (this.config.fishermanMode && proposer) {
330
- publisher.setProposerAddressForSimulation(proposer);
331
- this.log.debug(`Set proposer address ${proposer} for simulation in fisherman mode`);
401
+ const isPipelining = this.epochCache.isProposerPipeliningEnabled();
402
+
403
+ // Prepare invalidation request if the pending chain is invalid (returns undefined if no need).
404
+ // Only simulate invalidation when there's no proposed parent, since we assume the proposed parent
405
+ // will invalidate the currently invalid checkpoint on L1.
406
+ const invalidateCheckpoint =
407
+ (isPipelining && syncedTo.hasProposedCheckpoint) || syncedTo.pendingChainValidationStatus.valid
408
+ ? undefined
409
+ : await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
410
+
411
+ // Determine the correct archive and L1 state overrides for the canProposeAt check.
412
+ // The L1 contract reads archives[proposedCheckpointNumber] and compares it with the provided archive.
413
+ // When invalidating or pipelining, the local archive may differ from L1's, so we adjust accordingly.
414
+ let archiveForCheck = syncedTo.archive;
415
+
416
+ if (isPipelining && syncedTo.hasProposedCheckpoint) {
417
+ this.metrics.recordPipelineDepth(syncedTo.checkpointNumber - syncedTo.checkpointedCheckpointNumber);
418
+ this.log.verbose(
419
+ `Building on top of proposed checkpoint (pending=${syncedTo.proposedCheckpointData?.checkpointNumber}) for target slot ${targetSlot}`,
420
+ { targetSlot, parentCheckpointNumber: CheckpointNumber(checkpointNumber - 1) },
421
+ );
422
+ // Match what L1 will see at archives[pending] once the proposed parent lands: the parent's
423
+ // own archive root from the gossiped proposal. `syncedTo.archive` is the world-state-local
424
+ // view and can transiently diverge from the proposed parent (e.g. before the proposed
425
+ // parent's blocks have been applied locally); diverging here would cause the canProposeAt
426
+ // override to set archives[pending] to one value while we present another for comparison.
427
+ archiveForCheck = syncedTo.proposedCheckpointData!.archive.root;
428
+ } else if (invalidateCheckpoint) {
429
+ // After invalidation, L1 will roll back to checkpoint N-1. The archive at N-1 already
430
+ // exists on L1, so we just pass the matching archive (the lastArchive of the invalid checkpoint).
431
+ archiveForCheck = invalidateCheckpoint.lastArchive;
432
+ this.metrics.recordPipelineDepth(0);
433
+ } else {
434
+ this.metrics.recordPipelineDepth(0);
435
+ }
436
+
437
+ // Build the simulation plan: pending/proven override from pipelining or invalidation (or the
438
+ // current snapshot when neither applies, to short-circuit any pending prune in simulation),
439
+ // plus the parent checkpoint cell and fee header when pipelining.
440
+ const simulationOverridesPlan = await buildCheckpointSimulationOverridesPlan({
441
+ checkpointNumber,
442
+ proposedCheckpointData:
443
+ isPipelining && syncedTo.hasProposedCheckpoint ? syncedTo.proposedCheckpointData : undefined,
444
+ invalidateToPendingCheckpointNumber: invalidateCheckpoint?.forcePendingCheckpointNumber,
445
+ checkpointedCheckpointNumber: syncedTo.checkpointedCheckpointNumber,
446
+ rollup: this.rollupContract,
447
+ signatureContext: this.signatureContext,
448
+ log: this.log,
449
+ });
450
+
451
+ // The plan always pins both pending/proven (to short-circuit `canPruneAtTime` in simulation),
452
+ // so `provenOverride` always reflects the assumed proven checkpoint we are pinning the
453
+ // simulation to. We additionally warn when the pin is load-bearing — i.e. when a prune would
454
+ // actually fire at the target slot without it — so observers can spot "we are building
455
+ // optimistically across a pruning boundary" in the logs.
456
+ const provenOverride = simulationOverridesPlan?.chainTipsOverride?.proven;
457
+ if (provenOverride !== undefined && (await this.l2BlockSource.isPruneDueAtSlot(targetSlot))) {
458
+ this.log.warn(
459
+ `Assuming proof for epoch ending at checkpoint ${provenOverride} lands by target slot ${targetSlot}`,
460
+ { checkpointNumber, slot, targetSlot, provenOverride },
461
+ );
332
462
  }
333
463
 
334
- // Prepare invalidation request if the pending chain is invalid (returns undefined if no need)
335
- const invalidateCheckpoint = await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
464
+ this.emit('preparing-checkpoint', {
465
+ targetSlot,
466
+ checkpointNumber,
467
+ hadProposedParent: this.epochCache.isProposerPipeliningEnabled() && syncedTo.hasProposedCheckpoint,
468
+ provenOverride,
469
+ simulatedPending: simulationOverridesPlan?.chainTipsOverride?.pending,
470
+ });
336
471
 
337
- // Check with the rollup contract if we can indeed propose at the next L2 slot. This check should not fail
338
- // if all the previous checks are good, but we do it just in case.
339
- const canProposeCheck = await publisher.canProposeAtNextEthBlock(
340
- syncedTo.archive,
472
+ const canProposeCheck = await publisher.canProposeAt(
473
+ archiveForCheck,
341
474
  proposer ?? EthAddress.ZERO,
342
- invalidateCheckpoint,
475
+ simulationOverridesPlan,
343
476
  );
344
477
 
478
+ const proposeContext = {
479
+ hasProposedCheckpoint: syncedTo.hasProposedCheckpoint,
480
+ proposedCheckpointNumber: syncedTo.proposedCheckpointData?.checkpointNumber,
481
+ checkpointedCheckpointNumber: syncedTo.checkpointedCheckpointNumber,
482
+ isInvalidating: !!invalidateCheckpoint,
483
+ invalidatingCheckpointNumber: invalidateCheckpoint?.checkpointNumber,
484
+ archiveForCheck: archiveForCheck.toString(),
485
+ overridePendingCheckpointNumber: simulationOverridesPlan?.chainTipsOverride?.pending,
486
+ overrideArchive: simulationOverridesPlan?.pendingCheckpointState?.archive,
487
+ overrideFeeHeader: simulationOverridesPlan?.pendingCheckpointState?.feeHeader,
488
+ };
489
+
345
490
  if (canProposeCheck === undefined) {
346
491
  this.log.warn(
347
492
  `Cannot propose checkpoint ${checkpointNumber} at slot ${slot} due to failed rollup contract check`,
348
- logCtx,
493
+ { ...logCtx, ...proposeContext },
349
494
  );
350
495
  this.emit('proposer-rollup-check-failed', { reason: 'Rollup contract check failed', slot });
351
- this.metrics.recordBlockProposalPrecheckFailed('rollup_contract_check_failed');
496
+ this.metrics.recordCheckpointPrecheckFailed('rollup_contract_check_failed');
352
497
  return undefined;
353
498
  }
354
499
 
355
- if (canProposeCheck.slot !== slot) {
500
+ if (canProposeCheck.slot !== targetSlot) {
356
501
  this.log.warn(
357
- `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}.`,
358
- { ...logCtx, rollup: canProposeCheck, expectedSlot: slot },
502
+ `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}.`,
503
+ { ...logCtx, ...proposeContext, rollup: canProposeCheck, expectedSlot: targetSlot },
359
504
  );
360
505
  this.emit('proposer-rollup-check-failed', { reason: 'Slot mismatch', slot });
361
- this.metrics.recordBlockProposalPrecheckFailed('slot_mismatch');
506
+ this.metrics.recordCheckpointPrecheckFailed('slot_mismatch');
362
507
  return undefined;
363
508
  }
364
509
 
365
510
  if (canProposeCheck.checkpointNumber !== checkpointNumber) {
366
511
  this.log.warn(
367
512
  `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}.`,
368
- { ...logCtx, rollup: canProposeCheck, expectedSlot: slot },
513
+ { ...logCtx, ...proposeContext, rollup: canProposeCheck, expectedSlot: slot },
369
514
  );
370
515
  this.emit('proposer-rollup-check-failed', { reason: 'Block mismatch', slot });
371
- this.metrics.recordBlockProposalPrecheckFailed('block_number_mismatch');
516
+ this.metrics.recordCheckpointPrecheckFailed('block_number_mismatch');
372
517
  return undefined;
373
518
  }
374
519
 
375
- this.lastSlotForCheckpointProposalJob = slot;
376
- this.log.info(`Preparing checkpoint proposal ${checkpointNumber} at slot ${slot}`, { ...logCtx, proposer });
520
+ this.lastSlotForCheckpointProposalJob = targetSlot;
521
+
522
+ await this.p2pClient.prepareForSlot(targetSlot);
523
+ this.log.info(
524
+ `Preparing checkpoint proposal ${checkpointNumber} for target slot ${targetSlot} during wall-clock slot ${slot}`,
525
+ {
526
+ ...logCtx,
527
+ ...proposeContext,
528
+ proposer,
529
+ pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(),
530
+ },
531
+ );
377
532
 
378
533
  // Create and return the checkpoint proposal job
379
534
  return this.createCheckpointProposalJob(
380
- epoch,
381
535
  slot,
536
+ targetSlot,
537
+ targetEpoch,
382
538
  checkpointNumber,
383
539
  syncedTo.blockNumber,
540
+ syncedTo.checkpointedCheckpointNumber,
384
541
  proposer,
385
542
  publisher,
386
543
  attestorAddress,
387
544
  invalidateCheckpoint,
545
+ syncedTo.proposedCheckpointData,
388
546
  );
389
547
  }
390
548
 
391
549
  protected createCheckpointProposalJob(
392
- epoch: EpochNumber,
393
550
  slot: SlotNumber,
551
+ targetSlot: SlotNumber,
552
+ targetEpoch: EpochNumber,
394
553
  checkpointNumber: CheckpointNumber,
395
554
  syncedToBlockNumber: BlockNumber,
555
+ checkpointedCheckpointNumber: CheckpointNumber,
396
556
  proposer: EthAddress | undefined,
397
557
  publisher: SequencerPublisher,
398
558
  attestorAddress: EthAddress,
399
559
  invalidateCheckpoint: InvalidateCheckpointRequest | undefined,
560
+ proposedCheckpointData?: ProposedCheckpointData,
400
561
  ): CheckpointProposalJob {
401
562
  return new CheckpointProposalJob(
402
- epoch,
403
563
  slot,
564
+ targetSlot,
565
+ targetEpoch,
404
566
  checkpointNumber,
405
567
  syncedToBlockNumber,
568
+ checkpointedCheckpointNumber,
406
569
  proposer,
407
570
  publisher,
408
571
  attestorAddress,
@@ -416,19 +579,29 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
416
579
  this.checkpointsBuilder,
417
580
  this.l2BlockSource,
418
581
  this.l1Constants,
582
+ this.signatureContext,
419
583
  this.config,
420
584
  this.timetable,
421
585
  this.slasherClient,
422
586
  this.epochCache,
423
587
  this.dateProvider,
424
588
  this.metrics,
589
+ this.checkpointProposalJobMetrics.createRecorder(),
425
590
  this,
426
591
  this.setState.bind(this),
427
592
  this.tracer,
428
593
  this.log.getBindings(),
594
+ proposedCheckpointData,
429
595
  );
430
596
  }
431
597
 
598
+ /**
599
+ * Returns the current sequencer state.
600
+ */
601
+ public getState(): SequencerState {
602
+ return this.state;
603
+ }
604
+
432
605
  /**
433
606
  * Internal helper for setting the sequencer state and checks if we have enough time left in the slot to transition to the new state.
434
607
  * @param proposedState - The new state to transition to.
@@ -454,19 +627,35 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
454
627
  this.timetable.assertTimeLeft(proposedState, secondsIntoSlot);
455
628
  }
456
629
 
630
+ const oldState = this.state;
631
+ const oldStateSlotNumber = this.stateSlotNumber;
632
+ const stateChanged = proposedState !== oldState;
633
+ const transitionAtMs = performance.now();
634
+ const stateDurationMs = transitionAtMs - this.stateEnteredAtMs;
635
+
457
636
  const boringStates = [SequencerState.IDLE, SequencerState.SYNCHRONIZING];
458
637
  const logLevel =
459
- boringStates.includes(proposedState) && boringStates.includes(this.state)
460
- ? ('trace' as const)
461
- : ('debug' as const);
462
- this.log[logLevel](`Transitioning from ${this.state} to ${proposedState}`, { slotNumber, secondsIntoSlot });
638
+ boringStates.includes(proposedState) && boringStates.includes(oldState) ? ('trace' as const) : ('debug' as const);
639
+ this.stateLog[logLevel](`Transitioning from ${oldState} to ${proposedState}`, {
640
+ oldState,
641
+ newState: proposedState,
642
+ slotNumber,
643
+ stateSlotNumber: oldStateSlotNumber,
644
+ secondsIntoSlot,
645
+ ...(stateChanged && { stateDurationMs: Math.ceil(stateDurationMs) }),
646
+ });
463
647
 
464
648
  this.emit('state-changed', {
465
- oldState: this.state,
649
+ oldState,
466
650
  newState: proposedState,
467
651
  secondsIntoSlot,
468
652
  slot: slotNumber,
469
653
  });
654
+ if (stateChanged) {
655
+ this.metrics.recordStateDuration(stateDurationMs, oldState);
656
+ this.stateEnteredAtMs = transitionAtMs;
657
+ this.stateSlotNumber = slotNumber;
658
+ }
470
659
  this.state = proposedState;
471
660
  }
472
661
 
@@ -475,16 +664,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
475
664
  * We don't check against the previous block submitted since it may have been reorg'd out.
476
665
  */
477
666
  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) {
667
+ // Check that the archiver has fully synced the L2 slot before the one we want to propose in.
668
+ // The archiver reports sync progress via L1 block timestamps and synced checkpoint slots.
669
+ // See getSyncedL2SlotNumber for how missed L1 blocks are handled.
670
+ const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber();
671
+ const { slot } = args;
672
+ if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) {
484
673
  this.log.debug(`Cannot propose block at next L2 slot ${slot} due to pending sync from L1`, {
485
674
  slot,
486
- ts,
487
- l1Timestamp,
675
+ syncedL2Slot,
488
676
  });
489
677
  return undefined;
490
678
  }
@@ -494,54 +682,114 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
494
682
  number: syncSummary.latestBlockNumber,
495
683
  hash: syncSummary.latestBlockHash,
496
684
  })),
497
- this.l2BlockSource.getL2Tips().then(t => t.proposed),
685
+ this.l2BlockSource
686
+ .getL2Tips()
687
+ .then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed, proposedCheckpoint: t.proposedCheckpoint })),
498
688
  this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block),
499
- this.l1ToL2MessageSource.getL2Tips().then(t => t.proposed),
689
+ this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })),
500
690
  this.l2BlockSource.getPendingChainValidationStatus(),
691
+ this.l2BlockSource.getProposedCheckpointData(),
501
692
  ] as const);
502
693
 
503
- const [worldState, l2BlockSource, p2p, l1ToL2MessageSource, pendingChainValidationStatus] = syncedBlocks;
694
+ const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] =
695
+ syncedBlocks;
504
696
 
505
- // 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
- // as the world state can compute the new genesis block hash, but other components use the hardcoded constant.
507
- // TODO(palla/mbps): Fix the above. All components should be able to handle dynamic genesis block hashes.
508
697
  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);
698
+ worldState.hash === l2Tips.proposed.hash &&
699
+ p2p.hash === l2Tips.proposed.hash &&
700
+ l1ToL2MessageSourceTips.proposed.hash === l2Tips.proposed.hash &&
701
+ l1ToL2MessageSourceTips.checkpointed.block.hash === l2Tips.checkpointed.block.hash &&
702
+ l1ToL2MessageSourceTips.checkpointed.checkpoint.hash === l2Tips.checkpointed.checkpoint.hash;
513
703
 
514
704
  if (!result) {
515
- this.log.debug(`Sequencer sync check failed`, { worldState, l2BlockSource, p2p, l1ToL2MessageSource });
705
+ this.log.debug(`Sequencer sync check failed`, {
706
+ worldState,
707
+ l2BlockSource: l2Tips.proposed,
708
+ p2p,
709
+ l1ToL2MessageSourceTips,
710
+ });
516
711
  return undefined;
517
712
  }
518
713
 
519
- // Special case for genesis state
520
714
  const blockNumber = worldState.number;
521
- if (blockNumber < INITIAL_L2_BLOCK_NUM) {
522
- const archive = new Fr((await this.worldState.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root);
523
- return {
524
- checkpointNumber: CheckpointNumber.ZERO,
525
- blockNumber: BlockNumber.ZERO,
526
- archive,
527
- l1Timestamp,
528
- pendingChainValidationStatus,
529
- };
530
- }
531
-
532
- const block = await this.l2BlockSource.getL2Block(blockNumber);
533
- if (!block) {
534
- // this shouldn't really happen because a moment ago we checked that all components were in sync
535
- this.log.error(`Failed to get L2 block ${blockNumber} from the archiver with all components in sync`);
715
+ const blockData = await this.l2BlockSource.getBlockData({ number: blockNumber });
716
+ if (!blockData) {
717
+ this.log.warn(`Sequencer sync check failed: failed to get L2 block data ${blockNumber} from the archiver`, {
718
+ blockNumber,
719
+ l2Tips,
720
+ syncedL2Slot,
721
+ ...args,
722
+ });
723
+ return undefined;
724
+ }
725
+
726
+ // Refuse to build a checkpoint on top of a proposed block whose enclosing checkpoint was never
727
+ // proposed. Under pipelining we may have received and reexecuted such a block locally — advancing
728
+ // our world-state tip past the checkpointed tip while the proposing node never published the
729
+ // matching proposed checkpoint (e.g. it crashed before assembling it). Building on this orphan block
730
+ // would fork the chain off a tip no other node can follow. The archiver prunes these orphan blocks
731
+ // once their build slot ends; this guard is the correctness barrier during the grace window before.
732
+ if (
733
+ blockData.checkpointNumber > l2Tips.checkpointed.checkpoint.number &&
734
+ (l2Tips.proposedCheckpoint.checkpoint.number !== blockData.checkpointNumber ||
735
+ proposedCheckpointData?.checkpointNumber !== blockData.checkpointNumber)
736
+ ) {
737
+ this.log.warn(`Sequencer sync check failed: proposed block has no matching proposed checkpoint`, {
738
+ blockCheckpointNumber: blockData.checkpointNumber,
739
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
740
+ proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
741
+ proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
742
+ blockNumber: blockData.header.getBlockNumber(),
743
+ blockSlot: blockData.header.getSlot(),
744
+ syncedL2Slot,
745
+ ...args,
746
+ });
747
+ return undefined;
748
+ }
749
+
750
+ const hasProposedCheckpoint = l2Tips.proposedCheckpoint.checkpoint.number > l2Tips.checkpointed.checkpoint.number;
751
+
752
+ // The l2Tips and proposedCheckpointData reads above come from independent archiver snapshots
753
+ // (a JS-side tips cache vs. a direct store read on `#proposedCheckpoints`). A concurrent archiver
754
+ // write that mutates both can be observed split, leaving us with `hasProposedCheckpoint=true` but
755
+ // no proposedCheckpointData (or one whose number doesn't match the tip). Refuse to proceed in that
756
+ // window — the next checkSync tick will see a coherent snapshot.
757
+ if (
758
+ hasProposedCheckpoint &&
759
+ (!proposedCheckpointData ||
760
+ proposedCheckpointData.checkpointNumber !== l2Tips.proposedCheckpoint.checkpoint.number)
761
+ ) {
762
+ this.log.warn(`Sequencer sync check failed: inconsistent proposed-checkpoint state`, {
763
+ proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
764
+ checkpointedTipNumber: l2Tips.checkpointed.checkpoint.number,
765
+ proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
766
+ syncedL2Slot,
767
+ ...args,
768
+ });
769
+ return undefined;
770
+ }
771
+
772
+ // Check that the proposed checkpoint is indeed the parent of the checkpoint we'll be building
773
+ // The checkpoint number to build is derived as blockData.checkpointNumber + 1
774
+ if (proposedCheckpointData && proposedCheckpointData.checkpointNumber !== blockData.checkpointNumber) {
775
+ this.log.warn(`Sequencer sync check failed: proposed checkpoint number mismatch`, {
776
+ proposedCheckpointNumber: proposedCheckpointData.checkpointNumber,
777
+ blockCheckpointNumber: blockData.checkpointNumber,
778
+ syncedL2Slot,
779
+ ...args,
780
+ });
536
781
  return undefined;
537
782
  }
538
783
 
539
784
  return {
540
- block,
541
- blockNumber: block.number,
542
- checkpointNumber: block.checkpointNumber,
543
- archive: block.archive.root,
544
- l1Timestamp,
785
+ blockData,
786
+ blockNumber: blockData.header.getBlockNumber(),
787
+ checkpointNumber: blockData.checkpointNumber,
788
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
789
+ archive: blockData.archive.root,
790
+ hasProposedCheckpoint,
791
+ proposedCheckpointData,
792
+ syncedL2Slot,
545
793
  pendingChainValidationStatus,
546
794
  };
547
795
  }
@@ -550,17 +798,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
550
798
  * Checks if we are the proposer for the next slot.
551
799
  * @returns True if we can propose, and the proposer address (undefined if anyone can propose)
552
800
  */
553
- protected async checkCanPropose(slot: SlotNumber): Promise<[boolean, EthAddress | undefined]> {
801
+ protected async checkCanPropose(targetSlot: SlotNumber): Promise<[boolean, EthAddress | undefined]> {
554
802
  let proposer: EthAddress | undefined;
555
803
 
556
804
  try {
557
- proposer = await this.epochCache.getProposerAttesterAddressInSlot(slot);
805
+ proposer = await this.epochCache.getProposerAttesterAddressInSlot(targetSlot);
558
806
  } catch (e) {
559
807
  if (e instanceof NoCommitteeError) {
560
- this.log.warn(`Cannot propose at next L2 slot ${slot} since the committee does not exist on L1`);
808
+ if (this.lastSlotForNoCommitteeWarning !== targetSlot) {
809
+ this.lastSlotForNoCommitteeWarning = targetSlot;
810
+ this.log.warn(`Cannot propose at target slot ${targetSlot} since the committee does not exist on L1`);
811
+ }
561
812
  return [false, undefined];
562
813
  }
563
- this.log.error(`Error getting proposer for slot ${slot}`, e);
814
+ this.log.error(`Error getting proposer for target slot ${targetSlot}`, e);
564
815
  return [false, undefined];
565
816
  }
566
817
 
@@ -577,10 +828,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
577
828
  const weAreProposer = validatorAddresses.some(addr => addr.equals(proposer));
578
829
 
579
830
  if (!weAreProposer) {
580
- this.log.debug(`Cannot propose at slot ${slot} since we are not a proposer`, { validatorAddresses, proposer });
831
+ this.log.debug(`Cannot propose at target slot ${targetSlot} since we are not a proposer`, {
832
+ targetSlot,
833
+ validatorAddresses,
834
+ proposer,
835
+ });
581
836
  return [false, proposer];
582
837
  }
583
838
 
839
+ this.log.info(`We are the proposer for pipeline slot ${targetSlot}`, {
840
+ targetSlot,
841
+ proposer,
842
+ });
584
843
  return [true, proposer];
585
844
  }
586
845
 
@@ -589,8 +848,8 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
589
848
  * This allows the sequencer to participate in governance/slashing votes even when it cannot build blocks.
590
849
  */
591
850
  @trackSpan('Seqeuencer.tryVoteWhenSyncFails', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
592
- protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; ts: bigint }): Promise<void> {
593
- const { slot } = args;
851
+ protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; targetSlot: SlotNumber; ts: bigint }): Promise<void> {
852
+ const { slot, targetSlot } = args;
594
853
 
595
854
  // Prevent duplicate attempts in the same slot
596
855
  if (this.lastSlotForFallbackVote === slot) {
@@ -618,7 +877,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
618
877
  });
619
878
 
620
879
  // Check if we're a proposer or proposal is open
621
- const [canPropose, proposer] = await this.checkCanPropose(slot);
880
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
622
881
  if (!canPropose) {
623
882
  this.log.trace(`Cannot vote in slot ${slot} since we are not a proposer`, { slot, proposer });
624
883
  return;
@@ -635,9 +894,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
635
894
  slot,
636
895
  });
637
896
 
638
- // Enqueue governance and slashing votes
897
+ // Enqueue governance and slashing votes (voter uses the target slot for L1 submission)
639
898
  const voter = new CheckpointVoter(
640
- slot,
899
+ targetSlot,
641
900
  publisher,
642
901
  attestorAddress,
643
902
  this.validatorClient,
@@ -656,7 +915,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
656
915
  }
657
916
 
658
917
  this.log.info(`Voting in slot ${slot} despite sync failure`, { slot });
659
- await publisher.sendRequests();
918
+ // Votes are EIP-712-signed for `targetSlot` (the pipelined slot in which the multicall is
919
+ // expected to mine). Delay submission to the start of `targetSlot` so the tx mines in the
920
+ // slot the votes were signed for. We fire-and-forget so we don't block the sequencer's
921
+ // work loop while waiting for the target slot to start.
922
+ void publisher.sendRequestsAt(targetSlot).catch(err => {
923
+ this.log.error(`Failed to publish votes despite sync failure for slot ${slot}`, err, { slot });
924
+ });
660
925
  }
661
926
 
662
927
  /**
@@ -666,9 +931,10 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
666
931
  @trackSpan('Sequencer.tryVoteWhenEscapeHatchOpen', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
667
932
  protected async tryVoteWhenEscapeHatchOpen(args: {
668
933
  slot: SlotNumber;
934
+ targetSlot: SlotNumber;
669
935
  proposer: EthAddress | undefined;
670
936
  }): Promise<void> {
671
- const { slot, proposer } = args;
937
+ const { slot, targetSlot, proposer } = args;
672
938
 
673
939
  // Prevent duplicate attempts in the same slot
674
940
  if (this.lastSlotForFallbackVote === slot) {
@@ -681,10 +947,19 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
681
947
 
682
948
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
683
949
 
684
- this.log.debug(`Escape hatch open for slot ${slot}, attempting vote-only actions`, { slot, attestorAddress });
950
+ this.log.debug(`Escape hatch open for slot ${slot}, attempting vote-only actions`, {
951
+ slot,
952
+ targetSlot,
953
+ attestorAddress,
954
+ });
685
955
 
956
+ // Under proposer pipelining, the multicall is expected to mine in `targetSlot` (slot + 1).
957
+ // Governance and slashing votes are EIP-712-signed against the slot they will mine in, and the
958
+ // L1 contract checks `msg.sender == getCurrentProposer()` using the mining slot. So we must
959
+ // sign for `targetSlot` and delay submission to the start of `targetSlot`. When pipelining is
960
+ // disabled `targetSlot == slot` and `sendRequestsAt` resolves with no extra sleep.
686
961
  const voter = new CheckpointVoter(
687
- slot,
962
+ targetSlot,
688
963
  publisher,
689
964
  attestorAddress,
690
965
  this.validatorClient,
@@ -703,8 +978,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
703
978
  return;
704
979
  }
705
980
 
706
- this.log.info(`Voting in slot ${slot} (escape hatch open)`, { slot });
707
- await publisher.sendRequests();
981
+ this.log.info(`Voting in slot ${slot} (escape hatch open)`, { slot, targetSlot });
982
+ // Votes are EIP-712-signed for `targetSlot`. Delay submission to the start of `targetSlot` so
983
+ // the multicall mines in the slot the votes were signed for; otherwise the L1 contract reads
984
+ // `signaler = getCurrentProposer()` against the wrong slot and signature verification fails
985
+ // silently inside Multicall3. Fire-and-forget so we don't block the sequencer's work loop while
986
+ // waiting for the target slot to start, mirroring tryVoteWhenSyncFails.
987
+ void publisher.sendRequestsAt(targetSlot).catch(err => {
988
+ this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, { slot, targetSlot });
989
+ });
708
990
  }
709
991
 
710
992
  /**
@@ -717,7 +999,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
717
999
  syncedTo: SequencerSyncCheckResult,
718
1000
  currentSlot: SlotNumber,
719
1001
  ): Promise<void> {
720
- const { pendingChainValidationStatus, l1Timestamp } = syncedTo;
1002
+ const { pendingChainValidationStatus, syncedL2Slot } = syncedTo;
721
1003
  if (pendingChainValidationStatus.valid) {
722
1004
  return;
723
1005
  }
@@ -732,7 +1014,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
732
1014
 
733
1015
  const logData = {
734
1016
  invalidL1Timestamp: invalidCheckpointTimestamp,
735
- l1Timestamp,
1017
+ syncedL2Slot,
736
1018
  invalidCheckpoint: pendingChainValidationStatus.checkpoint,
737
1019
  secondsBeforeInvalidatingBlockAsCommitteeMember,
738
1020
  secondsBeforeInvalidatingBlockAsNonCommitteeMember,
@@ -860,6 +1142,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
860
1142
  return this.validatorClient?.getValidatorAddresses();
861
1143
  }
862
1144
 
1145
+ /** Updates the publisher factory's node keystore adapter after a keystore reload. */
1146
+ public updatePublisherNodeKeyStore(adapter: NodeKeystoreAdapter): void {
1147
+ this.publisherFactory.updateNodeKeyStore(adapter);
1148
+ }
1149
+
863
1150
  public getConfig() {
864
1151
  return this.config;
865
1152
  }
@@ -870,10 +1157,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
870
1157
  }
871
1158
 
872
1159
  type SequencerSyncCheckResult = {
873
- block?: L2Block;
1160
+ blockData?: BlockData;
874
1161
  checkpointNumber: CheckpointNumber;
1162
+ checkpointedCheckpointNumber: CheckpointNumber;
875
1163
  blockNumber: BlockNumber;
876
1164
  archive: Fr;
877
- l1Timestamp: bigint;
1165
+ hasProposedCheckpoint: boolean;
1166
+ proposedCheckpointData?: ProposedCheckpointData;
1167
+ syncedL2Slot: SlotNumber;
878
1168
  pendingChainValidationStatus: ValidateCheckpointResult;
879
1169
  };