@aztec/sequencer-client 0.0.1-commit.85d7d01 → 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 (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
@@ -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 { BlockData, L2BlockSink, L2BlockSource, ValidateCheckpointResult } from '@aztec/stdlib/block';
16
- import type { Checkpoint } from '@aztec/stdlib/checkpoint';
17
- import { getSlotAtTimestamp, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
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,8 +28,8 @@ 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
34
  import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client';
29
35
 
@@ -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,7 +73,11 @@ 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;
@@ -72,11 +94,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
72
94
  /** The last epoch for which we logged strategy comparison in fisherman mode. */
73
95
  private lastEpochForStrategyComparison: EpochNumber | undefined;
74
96
 
97
+ /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */
98
+ protected lastCheckpointProposalJob: CheckpointProposalJob | undefined;
99
+
75
100
  /** The maximum number of seconds that the sequencer can be into a slot to transition to a particular state. */
76
101
  protected timetable!: SequencerTimetable;
77
102
 
78
103
  /** Config for the sequencer */
79
104
  protected config: ResolvedSequencerConfig = DefaultSequencerConfig;
105
+ private readonly signatureContext: CoordinationSignatureContext;
80
106
 
81
107
  constructor(
82
108
  protected publisherFactory: SequencerPublisherFactory,
@@ -85,41 +111,49 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
85
111
  protected p2pClient: P2P,
86
112
  protected worldState: WorldStateSynchronizer,
87
113
  protected slasherClient: SlasherClientInterface | undefined,
88
- protected l2BlockSource: L2BlockSource & L2BlockSink,
114
+ protected l2BlockSource: L2BlockSource & L2BlockSink & ProposedCheckpointSink,
89
115
  protected l1ToL2MessageSource: L1ToL2MessageSource,
90
116
  protected checkpointsBuilder: FullNodeCheckpointsBuilder,
91
117
  protected l1Constants: SequencerRollupConstants,
92
118
  protected dateProvider: DateProvider,
93
119
  protected epochCache: EpochCache,
94
120
  protected rollupContract: RollupContract,
95
- config: SequencerConfig,
121
+ config: SequencerConfig & Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'>,
96
122
  protected telemetry: TelemetryClient = getTelemetryClient(),
97
123
  protected log = createLogger('sequencer'),
98
124
  ) {
99
125
  super();
126
+ this.stateLog = log.createChild('state');
100
127
 
101
128
  // Add [FISHERMAN] prefix to logger if in fisherman mode
102
129
  if (config.fishermanMode) {
103
130
  this.log = log.createChild('[FISHERMAN]');
104
131
  }
105
132
 
133
+ this.signatureContext = {
134
+ chainId: config.l1ChainId,
135
+ rollupAddress: config.rollupAddress,
136
+ };
106
137
  this.metrics = new SequencerMetrics(telemetry, this.rollupContract, 'Sequencer');
138
+ this.checkpointProposalJobMetrics = new CheckpointProposalJobMetrics(telemetry);
107
139
  this.updateConfig(config);
108
140
  }
109
141
 
110
142
  /** Updates sequencer config by the defined values and updates the timetable */
111
143
  public updateConfig(config: Partial<SequencerConfig>) {
112
144
  const filteredConfig = pickFromSchema(config, SequencerConfigSchema);
113
- this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowList'));
145
+ this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend'));
114
146
  this.config = merge(this.config, filteredConfig);
147
+ const p2pPropagationTime = this.config.attestationPropagationTime;
115
148
  this.timetable = new SequencerTimetable(
116
149
  {
117
150
  ethereumSlotDuration: this.l1Constants.ethereumSlotDuration,
118
151
  aztecSlotDuration: this.aztecSlotDuration,
119
152
  l1PublishingTime: this.l1PublishingTime,
120
- p2pPropagationTime: this.config.attestationPropagationTime,
153
+ p2pPropagationTime,
121
154
  blockDurationMs: this.config.blockDurationMs,
122
155
  enforce: this.config.enforceTimeTable,
156
+ pipelining: this.epochCache.isProposerPipeliningEnabled(),
123
157
  },
124
158
  this.metrics,
125
159
  this.log,
@@ -143,12 +177,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
143
177
  this.log.info('Started sequencer');
144
178
  }
145
179
 
180
+ /** Triggers an immediate run of the sequencer, bypassing the polling interval. */
181
+ public trigger() {
182
+ return this.runningPromise?.trigger();
183
+ }
184
+
146
185
  /** Stops the sequencer from building blocks and moves to STOPPED state. */
147
186
  public async stop(): Promise<void> {
148
187
  this.log.info(`Stopping sequencer`);
149
188
  this.setState(SequencerState.STOPPING, undefined, { force: true });
150
- this.publisherFactory.interruptAll();
189
+ await this.publisherFactory.stopAll();
151
190
  await this.runningPromise?.stop();
191
+ await this.lastCheckpointProposalJob?.awaitPendingSubmission();
152
192
  this.setState(SequencerState.STOPPED, undefined, { force: true });
153
193
  this.log.info('Stopped sequencer');
154
194
  }
@@ -158,17 +198,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
158
198
  try {
159
199
  await this.work();
160
200
  } catch (err) {
161
- this.emit('checkpoint-error', { error: err as Error });
162
- if (err instanceof SequencerTooSlowError) {
163
- // Log as warn only if we had to abort halfway through the block proposal
164
- const logLvl = [SequencerState.INITIALIZING_CHECKPOINT, SequencerState.PROPOSER_CHECK].includes(
165
- err.proposedState,
166
- )
167
- ? ('debug' as const)
168
- : ('warn' as const);
169
- 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() });
170
207
  } else {
171
208
  // Re-throw other errors
209
+ this.emit('checkpoint-error', { error: err as Error });
172
210
  throw err;
173
211
  }
174
212
  } finally {
@@ -192,14 +230,24 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
192
230
  @trackSpan('Sequencer.work')
193
231
  protected async work() {
194
232
  this.setState(SequencerState.SYNCHRONIZING, undefined);
195
- const { slot, ts, now, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
233
+ const { slot, targetSlot, epoch, targetEpoch, ts, nowSeconds } = this.getSlotContextInNextL1Slot();
196
234
 
197
235
  // Check if we are synced and it's our slot, grab a publisher, check previous block invalidation, etc
198
- const checkpointProposalJob = await this.prepareCheckpointProposal(epoch, slot, ts, now);
236
+ const checkpointProposalJob = await this.prepareCheckpointProposal(
237
+ slot,
238
+ targetSlot,
239
+ epoch,
240
+ targetEpoch,
241
+ ts,
242
+ nowSeconds,
243
+ );
199
244
  if (!checkpointProposalJob) {
200
245
  return;
201
246
  }
202
247
 
248
+ // Track the job so we can await its pending L1 submission during shutdown
249
+ this.lastCheckpointProposalJob = checkpointProposalJob;
250
+
203
251
  // Execute the checkpoint proposal job
204
252
  const checkpoint = await checkpointProposalJob.execute();
205
253
 
@@ -208,64 +256,87 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
208
256
  this.lastCheckpointProposed = checkpoint;
209
257
  }
210
258
 
211
- // 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)
212
260
  if (
213
261
  this.config.fishermanMode &&
214
- (this.lastEpochForStrategyComparison === undefined || epoch > this.lastEpochForStrategyComparison)
262
+ (this.lastEpochForStrategyComparison === undefined || targetEpoch > this.lastEpochForStrategyComparison)
215
263
  ) {
216
- this.logStrategyComparison(epoch, checkpointProposalJob.getPublisher());
217
- this.lastEpochForStrategyComparison = epoch;
264
+ this.logStrategyComparison(targetEpoch, checkpointProposalJob.getPublisher());
265
+ this.lastEpochForStrategyComparison = targetEpoch;
218
266
  }
219
267
 
220
268
  return checkpoint;
221
269
  }
222
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
+
223
280
  /**
224
281
  * Prepares the checkpoint proposal by performing all necessary checks and setup.
225
282
  * This is the initial step in the main loop.
226
283
  * @returns CheckpointProposalJob if successful, undefined if we are not yet synced or are not the proposer.
227
284
  */
228
285
  @trackSpan('Sequencer.prepareCheckpointProposal')
229
- private async prepareCheckpointProposal(
230
- epoch: EpochNumber,
286
+ protected async prepareCheckpointProposal(
231
287
  slot: SlotNumber,
288
+ targetSlot: SlotNumber,
289
+ epoch: EpochNumber,
290
+ targetEpoch: EpochNumber,
232
291
  ts: bigint,
233
- now: bigint,
292
+ nowSeconds: bigint,
234
293
  ): Promise<CheckpointProposalJob | undefined> {
235
- // Check we have not already processed this slot (cheapest check)
294
+ // Check we have not already processed this target slot (cheapest check)
236
295
  // We only check this if enforce timetable is set, since we want to keep processing the same slot if we are not
237
296
  // running against actual time (eg when we use sandbox-style automining)
238
297
  if (
239
298
  this.lastSlotForCheckpointProposalJob &&
240
- this.lastSlotForCheckpointProposalJob >= slot &&
299
+ this.lastSlotForCheckpointProposalJob >= targetSlot &&
241
300
  this.config.enforceTimeTable
242
301
  ) {
243
- this.log.trace(`Slot ${slot} has already been processed`);
302
+ this.log.trace(`Target slot ${targetSlot} has already been processed`);
303
+ return undefined;
304
+ }
305
+
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
+ );
244
311
  return undefined;
245
312
  }
246
313
 
247
- // But if we have already proposed for this slot, the we definitely have to skip it, automining or not
248
- if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= slot) {
249
- this.log.trace(`Slot ${slot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`);
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
+ });
250
320
  return undefined;
251
321
  }
252
322
 
253
323
  // Check all components are synced to latest as seen by the archiver (queries all subsystems)
254
324
  const syncedTo = await this.checkSync({ ts, slot });
255
325
  if (!syncedTo) {
256
- await this.tryVoteWhenSyncFails({ slot, ts });
326
+ await this.tryVoteWhenSyncFails({ slot, targetSlot, ts });
257
327
  return undefined;
258
328
  }
259
329
 
260
- // 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.
261
331
  // Still perform governance/slashing voting (as proposer) once per slot.
262
- 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);
263
334
 
264
335
  if (isEscapeHatchOpen) {
265
336
  this.setState(SequencerState.PROPOSER_CHECK, slot);
266
- const [canPropose, proposer] = await this.checkCanPropose(slot);
337
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
267
338
  if (canPropose) {
268
- await this.tryVoteWhenEscapeHatchOpen({ slot, proposer });
339
+ await this.tryVoteWhenEscapeHatchOpen({ slot, targetSlot, proposer });
269
340
  } else {
270
341
  this.log.trace(`Escape hatch open but we are not proposer, skipping vote-only actions`, {
271
342
  slot,
@@ -280,18 +351,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
280
351
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
281
352
 
282
353
  const logCtx = {
283
- now,
284
- syncedToL1Ts: syncedTo.l1Timestamp,
285
- syncedToL2Slot: getSlotAtTimestamp(syncedTo.l1Timestamp, this.l1Constants),
354
+ nowSeconds,
355
+ syncedToL2Slot: syncedTo.syncedL2Slot,
286
356
  slot,
357
+ targetSlot,
287
358
  slotTs: ts,
288
359
  checkpointNumber,
289
360
  isPendingChainValid: pick(syncedTo.pendingChainValidationStatus, 'valid', 'reason', 'invalidIndex'),
290
361
  };
291
362
 
292
- // Check that we are a proposer for the next slot
363
+ // Check that we are a proposer for the target slot.
293
364
  this.setState(SequencerState.PROPOSER_CHECK, slot);
294
- const [canPropose, proposer] = await this.checkCanPropose(slot);
365
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
295
366
 
296
367
  // If we are not a proposer check if we should invalidate an invalid checkpoint, and bail
297
368
  if (!canPropose) {
@@ -299,10 +370,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
299
370
  return undefined;
300
371
  }
301
372
 
302
- // Check that the slot is not taken by a block already (should never happen, since only us can propose for this slot)
303
- if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= slot) {
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) {
304
385
  this.log.warn(
305
- `Cannot propose block at next L2 slot ${slot} since that slot was taken by block ${syncedTo.blockNumber}`,
386
+ `Cannot propose block at target slot ${targetSlot} since that slot was taken by block ${syncedTo.blockNumber}`,
306
387
  { ...logCtx, block: syncedTo.blockData.header.toInspect() },
307
388
  );
308
389
  this.metrics.recordCheckpointPrecheckFailed('slot_already_taken');
@@ -317,37 +398,109 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
317
398
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposerForPublisher);
318
399
  this.log.verbose(`Created publisher at address ${publisher.getSenderAddress()} for attestor ${attestorAddress}`);
319
400
 
320
- // In fisherman mode, set the actual proposer's address for simulations
321
- if (this.config.fishermanMode && proposer) {
322
- publisher.setProposerAddressForSimulation(proposer);
323
- 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
+ );
324
462
  }
325
463
 
326
- // Prepare invalidation request if the pending chain is invalid (returns undefined if no need)
327
- 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
+ });
328
471
 
329
- // Check with the rollup contract if we can indeed propose at the next L2 slot. This check should not fail
330
- // if all the previous checks are good, but we do it just in case.
331
- const canProposeCheck = await publisher.canProposeAtNextEthBlock(
332
- syncedTo.archive,
472
+ const canProposeCheck = await publisher.canProposeAt(
473
+ archiveForCheck,
333
474
  proposer ?? EthAddress.ZERO,
334
- invalidateCheckpoint,
475
+ simulationOverridesPlan,
335
476
  );
336
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
+
337
490
  if (canProposeCheck === undefined) {
338
491
  this.log.warn(
339
492
  `Cannot propose checkpoint ${checkpointNumber} at slot ${slot} due to failed rollup contract check`,
340
- logCtx,
493
+ { ...logCtx, ...proposeContext },
341
494
  );
342
495
  this.emit('proposer-rollup-check-failed', { reason: 'Rollup contract check failed', slot });
343
496
  this.metrics.recordCheckpointPrecheckFailed('rollup_contract_check_failed');
344
497
  return undefined;
345
498
  }
346
499
 
347
- if (canProposeCheck.slot !== slot) {
500
+ if (canProposeCheck.slot !== targetSlot) {
348
501
  this.log.warn(
349
- `Cannot propose block due to slot mismatch with rollup contract (this can be caused by a clock out of sync). Expected slot ${slot} but got ${canProposeCheck.slot}.`,
350
- { ...logCtx, rollup: canProposeCheck, expectedSlot: slot },
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 },
351
504
  );
352
505
  this.emit('proposer-rollup-check-failed', { reason: 'Slot mismatch', slot });
353
506
  this.metrics.recordCheckpointPrecheckFailed('slot_mismatch');
@@ -357,45 +510,62 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
357
510
  if (canProposeCheck.checkpointNumber !== checkpointNumber) {
358
511
  this.log.warn(
359
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}.`,
360
- { ...logCtx, rollup: canProposeCheck, expectedSlot: slot },
513
+ { ...logCtx, ...proposeContext, rollup: canProposeCheck, expectedSlot: slot },
361
514
  );
362
515
  this.emit('proposer-rollup-check-failed', { reason: 'Block mismatch', slot });
363
516
  this.metrics.recordCheckpointPrecheckFailed('block_number_mismatch');
364
517
  return undefined;
365
518
  }
366
519
 
367
- this.lastSlotForCheckpointProposalJob = slot;
368
- await this.p2pClient.prepareForSlot(slot);
369
- 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
+ );
370
532
 
371
533
  // Create and return the checkpoint proposal job
372
534
  return this.createCheckpointProposalJob(
373
- epoch,
374
535
  slot,
536
+ targetSlot,
537
+ targetEpoch,
375
538
  checkpointNumber,
376
539
  syncedTo.blockNumber,
540
+ syncedTo.checkpointedCheckpointNumber,
377
541
  proposer,
378
542
  publisher,
379
543
  attestorAddress,
380
544
  invalidateCheckpoint,
545
+ syncedTo.proposedCheckpointData,
381
546
  );
382
547
  }
383
548
 
384
549
  protected createCheckpointProposalJob(
385
- epoch: EpochNumber,
386
550
  slot: SlotNumber,
551
+ targetSlot: SlotNumber,
552
+ targetEpoch: EpochNumber,
387
553
  checkpointNumber: CheckpointNumber,
388
554
  syncedToBlockNumber: BlockNumber,
555
+ checkpointedCheckpointNumber: CheckpointNumber,
389
556
  proposer: EthAddress | undefined,
390
557
  publisher: SequencerPublisher,
391
558
  attestorAddress: EthAddress,
392
559
  invalidateCheckpoint: InvalidateCheckpointRequest | undefined,
560
+ proposedCheckpointData?: ProposedCheckpointData,
393
561
  ): CheckpointProposalJob {
394
562
  return new CheckpointProposalJob(
395
- epoch,
396
563
  slot,
564
+ targetSlot,
565
+ targetEpoch,
397
566
  checkpointNumber,
398
567
  syncedToBlockNumber,
568
+ checkpointedCheckpointNumber,
399
569
  proposer,
400
570
  publisher,
401
571
  attestorAddress,
@@ -409,16 +579,19 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
409
579
  this.checkpointsBuilder,
410
580
  this.l2BlockSource,
411
581
  this.l1Constants,
582
+ this.signatureContext,
412
583
  this.config,
413
584
  this.timetable,
414
585
  this.slasherClient,
415
586
  this.epochCache,
416
587
  this.dateProvider,
417
588
  this.metrics,
589
+ this.checkpointProposalJobMetrics.createRecorder(),
418
590
  this,
419
591
  this.setState.bind(this),
420
592
  this.tracer,
421
593
  this.log.getBindings(),
594
+ proposedCheckpointData,
422
595
  );
423
596
  }
424
597
 
@@ -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,45 +682,102 @@ 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 blockData = await this.l2BlockSource.getBlockData(blockNumber);
715
+ const blockData = await this.l2BlockSource.getBlockData({ number: blockNumber });
533
716
  if (!blockData) {
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 data ${blockNumber} from the archiver with all components in sync`);
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
 
@@ -540,8 +785,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
540
785
  blockData,
541
786
  blockNumber: blockData.header.getBlockNumber(),
542
787
  checkpointNumber: blockData.checkpointNumber,
788
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
543
789
  archive: blockData.archive.root,
544
- l1Timestamp,
790
+ hasProposedCheckpoint,
791
+ proposedCheckpointData,
792
+ syncedL2Slot,
545
793
  pendingChainValidationStatus,
546
794
  };
547
795
  }
@@ -550,20 +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
- if (this.lastSlotForNoCommitteeWarning !== slot) {
561
- this.lastSlotForNoCommitteeWarning = slot;
562
- this.log.warn(`Cannot propose at next L2 slot ${slot} since the committee does not exist on L1`);
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`);
563
811
  }
564
812
  return [false, undefined];
565
813
  }
566
- this.log.error(`Error getting proposer for slot ${slot}`, e);
814
+ this.log.error(`Error getting proposer for target slot ${targetSlot}`, e);
567
815
  return [false, undefined];
568
816
  }
569
817
 
@@ -580,10 +828,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
580
828
  const weAreProposer = validatorAddresses.some(addr => addr.equals(proposer));
581
829
 
582
830
  if (!weAreProposer) {
583
- 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
+ });
584
836
  return [false, proposer];
585
837
  }
586
838
 
839
+ this.log.info(`We are the proposer for pipeline slot ${targetSlot}`, {
840
+ targetSlot,
841
+ proposer,
842
+ });
587
843
  return [true, proposer];
588
844
  }
589
845
 
@@ -592,8 +848,8 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
592
848
  * This allows the sequencer to participate in governance/slashing votes even when it cannot build blocks.
593
849
  */
594
850
  @trackSpan('Seqeuencer.tryVoteWhenSyncFails', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
595
- protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; ts: bigint }): Promise<void> {
596
- const { slot } = args;
851
+ protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; targetSlot: SlotNumber; ts: bigint }): Promise<void> {
852
+ const { slot, targetSlot } = args;
597
853
 
598
854
  // Prevent duplicate attempts in the same slot
599
855
  if (this.lastSlotForFallbackVote === slot) {
@@ -621,7 +877,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
621
877
  });
622
878
 
623
879
  // Check if we're a proposer or proposal is open
624
- const [canPropose, proposer] = await this.checkCanPropose(slot);
880
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
625
881
  if (!canPropose) {
626
882
  this.log.trace(`Cannot vote in slot ${slot} since we are not a proposer`, { slot, proposer });
627
883
  return;
@@ -638,9 +894,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
638
894
  slot,
639
895
  });
640
896
 
641
- // Enqueue governance and slashing votes
897
+ // Enqueue governance and slashing votes (voter uses the target slot for L1 submission)
642
898
  const voter = new CheckpointVoter(
643
- slot,
899
+ targetSlot,
644
900
  publisher,
645
901
  attestorAddress,
646
902
  this.validatorClient,
@@ -659,7 +915,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
659
915
  }
660
916
 
661
917
  this.log.info(`Voting in slot ${slot} despite sync failure`, { slot });
662
- 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
+ });
663
925
  }
664
926
 
665
927
  /**
@@ -669,9 +931,10 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
669
931
  @trackSpan('Sequencer.tryVoteWhenEscapeHatchOpen', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
670
932
  protected async tryVoteWhenEscapeHatchOpen(args: {
671
933
  slot: SlotNumber;
934
+ targetSlot: SlotNumber;
672
935
  proposer: EthAddress | undefined;
673
936
  }): Promise<void> {
674
- const { slot, proposer } = args;
937
+ const { slot, targetSlot, proposer } = args;
675
938
 
676
939
  // Prevent duplicate attempts in the same slot
677
940
  if (this.lastSlotForFallbackVote === slot) {
@@ -684,10 +947,19 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
684
947
 
685
948
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
686
949
 
687
- 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
+ });
688
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.
689
961
  const voter = new CheckpointVoter(
690
- slot,
962
+ targetSlot,
691
963
  publisher,
692
964
  attestorAddress,
693
965
  this.validatorClient,
@@ -706,8 +978,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
706
978
  return;
707
979
  }
708
980
 
709
- this.log.info(`Voting in slot ${slot} (escape hatch open)`, { slot });
710
- 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
+ });
711
990
  }
712
991
 
713
992
  /**
@@ -720,7 +999,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
720
999
  syncedTo: SequencerSyncCheckResult,
721
1000
  currentSlot: SlotNumber,
722
1001
  ): Promise<void> {
723
- const { pendingChainValidationStatus, l1Timestamp } = syncedTo;
1002
+ const { pendingChainValidationStatus, syncedL2Slot } = syncedTo;
724
1003
  if (pendingChainValidationStatus.valid) {
725
1004
  return;
726
1005
  }
@@ -735,7 +1014,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
735
1014
 
736
1015
  const logData = {
737
1016
  invalidL1Timestamp: invalidCheckpointTimestamp,
738
- l1Timestamp,
1017
+ syncedL2Slot,
739
1018
  invalidCheckpoint: pendingChainValidationStatus.checkpoint,
740
1019
  secondsBeforeInvalidatingBlockAsCommitteeMember,
741
1020
  secondsBeforeInvalidatingBlockAsNonCommitteeMember,
@@ -880,8 +1159,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
880
1159
  type SequencerSyncCheckResult = {
881
1160
  blockData?: BlockData;
882
1161
  checkpointNumber: CheckpointNumber;
1162
+ checkpointedCheckpointNumber: CheckpointNumber;
883
1163
  blockNumber: BlockNumber;
884
1164
  archive: Fr;
885
- l1Timestamp: bigint;
1165
+ hasProposedCheckpoint: boolean;
1166
+ proposedCheckpointData?: ProposedCheckpointData;
1167
+ syncedL2Slot: SlotNumber;
886
1168
  pendingChainValidationStatus: ValidateCheckpointResult;
887
1169
  };