@aztec/sequencer-client 5.0.0-rc.1 → 5.0.0

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 (64) hide show
  1. package/dest/client/sequencer-client.d.ts +16 -3
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +17 -3
  4. package/dest/config.d.ts +2 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +8 -3
  7. package/dest/global_variable_builder/fee_predictor.d.ts +1 -1
  8. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -1
  9. package/dest/global_variable_builder/fee_predictor.js +11 -1
  10. package/dest/global_variable_builder/fee_provider.d.ts +1 -1
  11. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -1
  12. package/dest/global_variable_builder/fee_provider.js +15 -2
  13. package/dest/global_variable_builder/global_builder.d.ts +3 -16
  14. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  15. package/dest/global_variable_builder/global_builder.js +2 -25
  16. package/dest/publisher/config.d.ts +5 -1
  17. package/dest/publisher/config.d.ts.map +1 -1
  18. package/dest/publisher/config.js +11 -1
  19. package/dest/publisher/l1_to_l2_messaging.d.ts +21 -0
  20. package/dest/publisher/l1_to_l2_messaging.d.ts.map +1 -0
  21. package/dest/publisher/l1_to_l2_messaging.js +70 -0
  22. package/dest/publisher/sequencer-publisher.d.ts +17 -4
  23. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  24. package/dest/publisher/sequencer-publisher.js +119 -22
  25. package/dest/publisher/write_json.d.ts +11 -0
  26. package/dest/publisher/write_json.d.ts.map +1 -0
  27. package/dest/publisher/write_json.js +57 -0
  28. package/dest/sequencer/automine/automine_sequencer.d.ts +8 -3
  29. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -1
  30. package/dest/sequencer/automine/automine_sequencer.js +27 -14
  31. package/dest/sequencer/checkpoint_proposal_job.d.ts +4 -6
  32. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  33. package/dest/sequencer/checkpoint_proposal_job.js +28 -26
  34. package/dest/sequencer/events.d.ts +1 -2
  35. package/dest/sequencer/events.d.ts.map +1 -1
  36. package/dest/sequencer/requests_tracker.d.ts +22 -0
  37. package/dest/sequencer/requests_tracker.d.ts.map +1 -0
  38. package/dest/sequencer/requests_tracker.js +33 -0
  39. package/dest/sequencer/sequencer.d.ts +47 -10
  40. package/dest/sequencer/sequencer.d.ts.map +1 -1
  41. package/dest/sequencer/sequencer.js +125 -60
  42. package/dest/test/utils.d.ts +1 -1
  43. package/dest/test/utils.d.ts.map +1 -1
  44. package/dest/test/utils.js +2 -1
  45. package/package.json +27 -27
  46. package/src/client/sequencer-client.ts +19 -3
  47. package/src/config.ts +8 -3
  48. package/src/global_variable_builder/fee_predictor.ts +11 -1
  49. package/src/global_variable_builder/fee_provider.ts +19 -2
  50. package/src/global_variable_builder/global_builder.ts +2 -34
  51. package/src/publisher/config.ts +22 -1
  52. package/src/publisher/l1_to_l2_messaging.ts +85 -0
  53. package/src/publisher/sequencer-publisher.ts +116 -23
  54. package/src/publisher/write_json.ts +78 -0
  55. package/src/sequencer/automine/automine_sequencer.ts +27 -14
  56. package/src/sequencer/checkpoint_proposal_job.ts +43 -34
  57. package/src/sequencer/events.ts +1 -1
  58. package/src/sequencer/requests_tracker.ts +43 -0
  59. package/src/sequencer/sequencer.ts +133 -61
  60. package/src/test/utils.ts +2 -0
  61. package/dest/sequencer/chain_state_overrides.d.ts +0 -61
  62. package/dest/sequencer/chain_state_overrides.d.ts.map +0 -1
  63. package/dest/sequencer/chain_state_overrides.js +0 -98
  64. package/src/sequencer/chain_state_overrides.ts +0 -169
@@ -31,12 +31,14 @@ import {
31
31
  type L2BlockSink,
32
32
  type L2BlockSource,
33
33
  MaliciousCommitteeAttestationsAndSigners,
34
+ MaliciousYParityCommitteeAttestationsAndSigners,
34
35
  type ProposedCheckpointSink,
35
36
  type ValidateCheckpointResult,
36
37
  } from '@aztec/stdlib/block';
37
38
  import {
38
39
  type Checkpoint,
39
40
  type ProposedCheckpointData,
41
+ buildCheckpointSimulationOverridesPlan,
40
42
  getPreviousCheckpointOutHashes,
41
43
  validateCheckpoint,
42
44
  } from '@aztec/stdlib/checkpoint';
@@ -68,12 +70,12 @@ import { DutyAlreadySignedError, SlashingProtectionError } from '@aztec/validato
68
70
 
69
71
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
70
72
  import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
71
- import { buildCheckpointSimulationOverridesPlan } from './chain_state_overrides.js';
72
73
  import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js';
73
74
  import { CheckpointVoter } from './checkpoint_voter.js';
74
75
  import { SequencerInterruptedError } from './errors.js';
75
76
  import type { SequencerEvents } from './events.js';
76
77
  import type { SequencerMetrics } from './metrics.js';
78
+ import type { RequestsTracker } from './requests_tracker.js';
77
79
  import type { SequencerRollupConstants } from './types.js';
78
80
  import { SequencerState } from './utils.js';
79
81
 
@@ -105,8 +107,6 @@ export class CheckpointProposalJob implements Traceable {
105
107
  protected readonly log: Logger;
106
108
  private readonly checkpointEventLog: Logger;
107
109
 
108
- /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */
109
- private pendingL1Submission: Promise<void> | undefined;
110
110
  private readonly interruptibleSleep = new InterruptibleSleep();
111
111
  private interrupted = false;
112
112
 
@@ -151,6 +151,9 @@ export class CheckpointProposalJob implements Traceable {
151
151
  private readonly metrics: SequencerMetrics,
152
152
  private readonly checkpointMetrics: CheckpointProposalJobMetricsRecorder,
153
153
  protected readonly eventEmitter: TypedEventEmitter<SequencerEvents>,
154
+ // Shared with the owning sequencer, which drains it during shutdown; the fire-and-forget L1
155
+ // submission this job backgrounds is tracked here rather than in a job-local tracker.
156
+ protected readonly pendingRequests: RequestsTracker,
154
157
  private readonly setStateFn: (state: SequencerState, slot: SlotNumber) => void,
155
158
  public readonly tracer: Tracer,
156
159
  bindings?: LoggerBindings,
@@ -183,12 +186,6 @@ export class CheckpointProposalJob implements Traceable {
183
186
  this.setStateFn(state, this.targetSlot);
184
187
  }
185
188
 
186
- /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */
187
- public async awaitPendingSubmission(): Promise<void> {
188
- this.log.info('Awaiting pending L1 payload submission');
189
- await this.pendingL1Submission;
190
- }
191
-
192
189
  /** Interrupts job-owned waits, including the publisher's send-at-slot sleep, so shutdown can finish. */
193
190
  public interrupt(): void {
194
191
  this.interrupted = true;
@@ -257,7 +254,7 @@ export class CheckpointProposalJob implements Traceable {
257
254
  // signature verification to fail silently inside Multicall3. Delay submission to the
258
255
  // start of `targetSlot` so the tx mines in the slot the vote was signed for.
259
256
  if (!this.config.fishermanMode) {
260
- this.pendingL1Submission = this.publisher.sendRequestsAt(this.targetSlot).then(() => {});
257
+ this.pendingRequests.trackRequest(this.publisher.sendRequestsAt(this.targetSlot), () => this.interrupt());
261
258
  }
262
259
  return undefined;
263
260
  }
@@ -272,7 +269,9 @@ export class CheckpointProposalJob implements Traceable {
272
269
  }
273
270
 
274
271
  // Background the attestation → signing → L1 pipeline so the work loop is unblocked
275
- this.pendingL1Submission = this.waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises);
272
+ this.pendingRequests.trackRequest(this.waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises), () =>
273
+ this.interrupt(),
274
+ );
276
275
 
277
276
  // Return the built checkpoint immediately — the work loop is now unblocked
278
277
  return checkpoint;
@@ -280,7 +279,7 @@ export class CheckpointProposalJob implements Traceable {
280
279
 
281
280
  /**
282
281
  * Background pipeline: collects attestations, signs them, enqueues the checkpoint, and submits to L1.
283
- * Runs as a fire-and-forget task stored in `pendingL1Submission` so the work loop is unblocked.
282
+ * Runs as a fire-and-forget task tracked in the sequencer's shared tracker so the work loop is unblocked.
284
283
  */
285
284
  private async waitForAttestationsAndEnqueueSubmissionAsync(
286
285
  broadcast: CheckpointProposalBroadcast,
@@ -398,9 +397,7 @@ export class CheckpointProposalJob implements Traceable {
398
397
  }
399
398
  }
400
399
 
401
- await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, {
402
- txTimeoutAt,
403
- });
400
+ await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, { txTimeoutAt });
404
401
  }
405
402
 
406
403
  /**
@@ -1051,7 +1048,7 @@ export class CheckpointProposalJob implements Traceable {
1051
1048
 
1052
1049
  try {
1053
1050
  // Wait until we have enough txs to build the block
1054
- const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
1051
+ const { canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
1055
1052
  if (!canStartBuilding) {
1056
1053
  this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1057
1054
  reason: 'insufficient_txs',
@@ -1059,22 +1056,20 @@ export class CheckpointProposalJob implements Traceable {
1059
1056
  slot: this.targetSlot,
1060
1057
  checkpointNumber: this.checkpointNumber,
1061
1058
  indexWithinCheckpoint,
1062
- availableTxs,
1063
1059
  minTxs,
1064
1060
  });
1065
- this.log.warn(
1066
- `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
1061
+ this.log.verbose(
1062
+ `Not enough age-eligible txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (needs ${minTxs} eligible)`,
1067
1063
  {
1068
1064
  reason: 'insufficient_txs',
1069
1065
  blockNumber,
1070
1066
  slot: this.targetSlot,
1071
1067
  checkpointNumber: this.checkpointNumber,
1072
1068
  indexWithinCheckpoint,
1073
- availableTxs,
1074
1069
  minTxs,
1075
1070
  },
1076
1071
  );
1077
- this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, availableTxs, slot: this.targetSlot });
1072
+ this.eventEmitter.emit('block-tx-count-check-failed', { minTxs, slot: this.targetSlot });
1078
1073
  this.metrics.recordBlockProposalFailed('insufficient_txs');
1079
1074
  return { failure: 'insufficient-txs' };
1080
1075
  }
@@ -1088,10 +1083,11 @@ export class CheckpointProposalJob implements Traceable {
1088
1083
  tx => !txHashesAlreadyIncluded.has(tx.txHash.toString()),
1089
1084
  );
1090
1085
 
1091
- this.log.debug(
1092
- `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot} with ${availableTxs} available txs`,
1093
- { slot: this.targetSlot, blockNumber, indexWithinCheckpoint },
1094
- );
1086
+ this.log.debug(`Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot}`, {
1087
+ slot: this.targetSlot,
1088
+ blockNumber,
1089
+ indexWithinCheckpoint,
1090
+ });
1095
1091
  this.setState(SequencerState.CREATING_BLOCK);
1096
1092
 
1097
1093
  // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
@@ -1243,7 +1239,7 @@ export class CheckpointProposalJob implements Traceable {
1243
1239
  blockNumber: BlockNumber;
1244
1240
  indexWithinCheckpoint: IndexWithinCheckpoint;
1245
1241
  buildDeadline: Date | undefined;
1246
- }): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
1242
+ }): Promise<{ canStartBuilding: boolean; minTxs: number }> {
1247
1243
  const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
1248
1244
 
1249
1245
  // We only allow a block with 0 txs in the first block of the checkpoint
@@ -1254,26 +1250,25 @@ export class CheckpointProposalJob implements Traceable {
1254
1250
  ? new Date(buildDeadline.getTime() - this.timetable.minBlockDuration * 1000)
1255
1251
  : undefined;
1256
1252
 
1257
- let availableTxs = await this.p2pClient.getPendingTxCount();
1258
-
1259
- while (!forceCreate && availableTxs < minTxs) {
1253
+ // Gate on age-eligible txs so we don't start building on txs the builder would then filter out for being
1254
+ // too fresh. hasEligiblePendingTxs early-exits once minTxs are eligible instead of counting the whole pool.
1255
+ while (!forceCreate && !(await this.p2pClient.hasEligiblePendingTxs(minTxs))) {
1260
1256
  // If we're past deadline, or we have no deadline, give up
1261
1257
  const now = this.dateProvider.nowAsDate();
1262
1258
  if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
1263
- return { canStartBuilding: false, availableTxs, minTxs };
1259
+ return { canStartBuilding: false, minTxs };
1264
1260
  }
1265
1261
 
1266
1262
  // Wait a bit before checking again
1267
1263
  this.setState(SequencerState.WAITING_FOR_TXS);
1268
1264
  this.log.verbose(
1269
- `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
1270
- { blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
1265
+ `Waiting for ${minTxs} age-eligible txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot}`,
1266
+ { blockNumber, slot: this.targetSlot, indexWithinCheckpoint, minTxs },
1271
1267
  );
1272
1268
  await this.waitForTxsPollingInterval();
1273
- availableTxs = await this.p2pClient.getPendingTxCount();
1274
1269
  }
1275
1270
 
1276
- return { canStartBuilding: true, availableTxs, minTxs };
1271
+ return { canStartBuilding: true, minTxs };
1277
1272
  }
1278
1273
 
1279
1274
  private async getSignedCommitteeAttestations(
@@ -1389,6 +1384,7 @@ export class CheckpointProposalJob implements Traceable {
1389
1384
  this.config.injectFakeAttestation ||
1390
1385
  this.config.injectHighSValueAttestation ||
1391
1386
  this.config.injectUnrecoverableSignatureAttestation ||
1387
+ this.config.injectYParityAttestation ||
1392
1388
  this.config.shuffleAttestationOrdering
1393
1389
  ) {
1394
1390
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
@@ -1490,6 +1486,19 @@ export class CheckpointProposalJob implements Traceable {
1490
1486
  return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1491
1487
  }
1492
1488
 
1489
+ if (this.config.injectYParityAttestation) {
1490
+ // Force every non-proposer signed slot's recovery byte to yParity (v ∈ {0, 1}) form in the packed L1
1491
+ // tuple, after packAttestations has canonicalized it. The proposer's own slot is left canonical so
1492
+ // propose() still passes verifyProposer. Models a malicious proposer landing a checkpoint L1 accepts
1493
+ // but that can never be proven (ECDSA.recover rejects v ∉ {27, 28}).
1494
+ this.log.warn(`Injecting yParity attestations in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`);
1495
+ return new MaliciousYParityCommitteeAttestationsAndSigners(
1496
+ attestations,
1497
+ proposerIndex,
1498
+ this.getSignatureContext(),
1499
+ );
1500
+ }
1501
+
1493
1502
  if (this.config.shuffleAttestationOrdering) {
1494
1503
  this.log.warn(`Shuffling attestation ordering in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`);
1495
1504
 
@@ -49,7 +49,7 @@ export type SequencerEvents = {
49
49
  simulatedPending: CheckpointNumber | undefined;
50
50
  }) => void;
51
51
  ['proposer-rollup-check-failed']: (args: { reason: string; slot: SlotNumber }) => void;
52
- ['block-tx-count-check-failed']: (args: { minTxs: number; availableTxs: number; slot: SlotNumber }) => void;
52
+ ['block-tx-count-check-failed']: (args: { minTxs: number; slot: SlotNumber }) => void;
53
53
  ['block-build-failed']: (args: { reason: string; slot: SlotNumber }) => void;
54
54
  ['block-proposed']: (args: {
55
55
  blockNumber: BlockNumber;
@@ -0,0 +1,43 @@
1
+ /** A tracked request: the awaitable to wait on, plus an optional interrupt to cancel it early. */
2
+ type TrackedRequest = { promise: Promise<unknown>; interrupt?: () => void };
3
+
4
+ /**
5
+ * Tracks fire-and-forget requests so a shutdown path can interrupt any in-flight work and await it to
6
+ * settle. Each tracked request is a promise plus an optional interrupt callback; the entry removes itself
7
+ * from the bag once its promise settles, so the tracker only ever holds requests that are still in flight.
8
+ */
9
+ export class RequestsTracker {
10
+ private readonly requests = new Map<number, TrackedRequest>();
11
+ private nextId = 0;
12
+
13
+ /** Number of in-flight requests currently tracked. */
14
+ public get size(): number {
15
+ return this.requests.size;
16
+ }
17
+
18
+ /**
19
+ * Tracks a fire-and-forget request. `interrupt`, when provided, is invoked by {@link interruptRequests}
20
+ * to cancel the in-flight work so its promise settles promptly. The entry auto-removes once `promise`
21
+ * settles, whether it resolves or rejects.
22
+ */
23
+ public trackRequest(promise: Promise<unknown>, interrupt?: () => void): void {
24
+ const id = this.nextId++;
25
+ const settled = promise.then(
26
+ () => this.requests.delete(id),
27
+ () => this.requests.delete(id),
28
+ );
29
+ this.requests.set(id, { promise: settled, interrupt });
30
+ }
31
+
32
+ /** Interrupts every in-flight request that was tracked with an interrupt callback. */
33
+ public interruptRequests(): void {
34
+ for (const { interrupt } of this.requests.values()) {
35
+ interrupt?.();
36
+ }
37
+ }
38
+
39
+ /** Awaits every currently in-flight request to settle. */
40
+ public async awaitRequests(): Promise<void> {
41
+ await Promise.all([...this.requests.values()].map(request => request.promise));
42
+ }
43
+ }
@@ -18,7 +18,11 @@ import type {
18
18
  ProposedCheckpointSink,
19
19
  ValidateCheckpointResult,
20
20
  } from '@aztec/stdlib/block';
21
- import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
21
+ import {
22
+ type Checkpoint,
23
+ type ProposedCheckpointData,
24
+ buildCheckpointSimulationOverridesPlan,
25
+ } from '@aztec/stdlib/checkpoint';
22
26
  import type { ChainConfig } from '@aztec/stdlib/config';
23
27
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
24
28
  import {
@@ -45,13 +49,13 @@ import { DefaultSequencerConfig } from '../config.js';
45
49
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
46
50
  import type { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js';
47
51
  import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
48
- import { buildCheckpointSimulationOverridesPlan } from './chain_state_overrides.js';
49
52
  import { CheckpointProposalJob } from './checkpoint_proposal_job.js';
50
53
  import { CheckpointProposalJobMetrics } from './checkpoint_proposal_job_metrics.js';
51
54
  import { CheckpointVoter } from './checkpoint_voter.js';
52
55
  import { SequencerInterruptedError } from './errors.js';
53
56
  import type { SequencerEvents } from './events.js';
54
57
  import { SequencerMetrics } from './metrics.js';
58
+ import { RequestsTracker } from './requests_tracker.js';
55
59
  import type { SequencerRollupConstants } from './types.js';
56
60
  import { SequencerState } from './utils.js';
57
61
 
@@ -76,7 +80,7 @@ type SequencerSlotContext = {
76
80
  * - Votes for proposals and slashes on L1
77
81
  */
78
82
  export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<SequencerEvents>) {
79
- private runningPromise?: RunningPromise;
83
+ protected runningPromise?: RunningPromise;
80
84
  private state = SequencerState.STOPPED;
81
85
  private stateSlotNumber: SlotNumber | undefined;
82
86
  /** Wall-clock time (ms, via the date provider) at which the current state was entered. */
@@ -85,8 +89,8 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
85
89
  private checkpointProposalJobMetrics: CheckpointProposalJobMetrics;
86
90
  private readonly stateLog: Logger;
87
91
 
88
- /** The last slot for which we attempted to perform our voting duties with degraded block production */
89
- private lastSlotForFallbackVote: SlotNumber | undefined;
92
+ /** The last slot for which we attempted to perform our fallback duties (votes and/or prune) with degraded block production */
93
+ private lastSlotForFallbackAction: SlotNumber | undefined;
90
94
 
91
95
  /** The (checkpoint, slot) of the last invalidation request we successfully simulated, to prevent
92
96
  * re-simulating and re-submitting the same invalidation across the many ticks within a single slot. */
@@ -107,6 +111,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
107
111
  /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */
108
112
  protected lastCheckpointProposalJob: CheckpointProposalJob | undefined;
109
113
 
114
+ /**
115
+ * In-flight fire-and-forget requests that {@link stop} interrupts and drains, and {@link pause} awaits
116
+ * untouched: the checkpoint proposal jobs' backgrounded L1 submissions (each job is handed this shared
117
+ * tracker) plus the sequencer's own fallback submissions (votes/prune when we cannot build, or
118
+ * escape-hatch votes). Each fallback send is gated by its wrapper publisher's interruptible target-slot
119
+ * sleep; the tracked interrupt wakes that sleep so the send short-circuits without publishing. A wrapper
120
+ * is created for a single send and is never restarted, so once interrupted its sleeper can never publish
121
+ * a stale-slot tx, even after the pooled publishers are restarted by a later {@link start}.
122
+ */
123
+ protected readonly pendingRequests = new RequestsTracker();
124
+
110
125
  /** Proposer schedule and block sub-slot timetable for the sequencer, rebuilt on every config update. */
111
126
  protected timetable!: ProposerTimetable;
112
127
 
@@ -270,8 +285,22 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
270
285
  getKzg();
271
286
  }
272
287
 
273
- /** Starts the sequencer and moves to IDLE state. */
288
+ /**
289
+ * Starts (or restarts) the sequencer and moves it to the IDLE state. Idempotent: a start while already
290
+ * running is a no-op, so it never orphans the previous poll loop. A start while STOPPING throws, since the
291
+ * in-flight stop would mark the fresh loop's state STOPPED and orphan it — silently doing nothing would
292
+ * leave the caller believing the sequencer is running when it is on its way to STOPPED. Safe to call after
293
+ * a previous {@link stop} has resolved; the caller must also restart the publishers (see
294
+ * {@link SequencerClient.start}) so L1 publishing is re-enabled.
295
+ */
274
296
  public start() {
297
+ if (this.state === SequencerState.STOPPING) {
298
+ throw new Error('Cannot start sequencer while it is stopping');
299
+ }
300
+ if (this.runningPromise?.isRunning()) {
301
+ this.log.warn('Attempted to start sequencer that is already running');
302
+ return;
303
+ }
275
304
  this.runningPromise = new RunningPromise(
276
305
  this.safeWork.bind(this),
277
306
  this.log,
@@ -287,18 +316,56 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
287
316
  return this.runningPromise?.trigger();
288
317
  }
289
318
 
290
- /** Stops the sequencer from building blocks and moves to STOPPED state. */
319
+ /**
320
+ * Stops the sequencer from building blocks and moves it to STOPPED state, interrupting the in-flight
321
+ * work() iteration and its pending L1 submissions for a fast shutdown, then draining before returning.
322
+ * Idempotent: a second call while already stopped or stopping is a no-op. Lifecycle calls are expected
323
+ * to be serialized by the caller. For a restartable pause that lets in-flight work finish untouched
324
+ * (e.g. around a test clock warp), use {@link pause} instead.
325
+ */
291
326
  public async stop(): Promise<void> {
327
+ if (this.state === SequencerState.STOPPED || this.state === SequencerState.STOPPING) {
328
+ this.log.debug(`Sequencer already ${this.state.toLowerCase()}, ignoring stop`);
329
+ return;
330
+ }
292
331
  this.log.info(`Stopping sequencer`);
293
332
  this.setState(SequencerState.STOPPING, undefined, { force: true });
294
333
  this.lastCheckpointProposalJob?.interrupt();
295
334
  await this.publisherFactory.stopAll();
335
+ // Stop the poll loop and await the in-flight work() iteration. work() registers its fire-and-forget
336
+ // requests synchronously before returning, so once the loop has stopped, pendingRequests holds every
337
+ // request that will ever exist. Interrupting any earlier could miss a request registered by the last
338
+ // in-flight iteration.
296
339
  await this.runningPromise?.stop();
297
- await this.lastCheckpointProposalJob?.awaitPendingSubmission();
340
+ this.pendingRequests.interruptRequests();
341
+ await this.pendingRequests.awaitRequests();
298
342
  this.setState(SequencerState.STOPPED, undefined, { force: true });
299
343
  this.log.info('Stopped sequencer');
300
344
  }
301
345
 
346
+ /**
347
+ * Gracefully pauses block production so the sequencer can later be resumed with {@link start}: halts the
348
+ * poll loop and waits for the in-flight work() iteration and every pending L1 submission / fallback send
349
+ * to finish, without interrupting them. No interrupt lands mid-build, so no spurious checkpoint-error is
350
+ * emitted and no enqueued checkpoint is dropped. Deliberately does not stop inner services (validator/HA
351
+ * signer, publishers), so the slashing-protection store stays open and the sequencer stays restartable.
352
+ * Used by tests that pause sequencers around an L1 clock warp. Idempotent.
353
+ */
354
+ public async pause(): Promise<void> {
355
+ if (!this.runningPromise?.isRunning()) {
356
+ this.log.debug('Sequencer not running, ignoring pause');
357
+ return;
358
+ }
359
+ this.log.info('Pausing sequencer');
360
+ // Halt the poll loop and let the in-flight iteration finish naturally — no interrupt, and no STOPPING
361
+ // state, since entering STOPPING would make the iteration's own setState calls throw and fail it.
362
+ // work() registers its fire-and-forget requests synchronously before returning, so once the loop has
363
+ // stopped pendingRequests holds every request that will ever exist; awaiting it lets them all finish.
364
+ await this.runningPromise.stop();
365
+ await this.pendingRequests.awaitRequests();
366
+ this.log.info('Paused sequencer');
367
+ }
368
+
302
369
  /** Main sequencer loop with a try/catch */
303
370
  protected async safeWork() {
304
371
  try {
@@ -449,13 +516,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
449
516
  return undefined;
450
517
  }
451
518
 
452
- // We are the proposer and the escape hatch is closed: now run the full sync check before building.
453
- const syncedTo = await this.checkSync({ ts, slot });
454
- if (!syncedTo) {
455
- await this.tryVoteWhenCannotBuild({ slot, targetSlot });
456
- return undefined;
457
- }
458
-
459
519
  // Explicit build-loop entry gate: if we are past the latest useful block-building start for the
460
520
  // target slot, abandon building for this slot. The proposer prioritizes the ideal L1-publish path
461
521
  // and does not plan around the late consensus-handoff path. This is the proposer build path's
@@ -472,11 +532,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
472
532
  });
473
533
  // Mark the slot as attempted so a deadline abort is not retried within the same slot. Vote-only actions
474
534
  // still need to run because sync can succeed even when it is too late to start building a checkpoint.
475
- await this.tryVoteWhenCannotBuild({ slot, targetSlot });
535
+ await this.tryVoteAndPruneWhenCannotBuild({ slot, targetSlot });
476
536
  this.lastSlotForCheckpointProposalJob = targetSlot;
477
537
  return undefined;
478
538
  }
479
539
 
540
+ // We are the proposer, the escape hatch is closed, and we have time before the build start deadline.
541
+ // Now run the full sync check before building.
542
+ const syncedTo = await this.checkSync({ ts, slot });
543
+ if (!syncedTo) {
544
+ return undefined;
545
+ }
546
+
480
547
  // Next checkpoint follows from the last synced one
481
548
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
482
549
 
@@ -704,6 +771,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
704
771
  this.metrics,
705
772
  this.checkpointProposalJobMetrics.createRecorder(),
706
773
  this,
774
+ this.pendingRequests,
707
775
  this.setState.bind(this),
708
776
  this.tracer,
709
777
  this.log.getBindings(),
@@ -798,9 +866,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
798
866
  number: syncSummary.latestBlockNumber,
799
867
  hash: syncSummary.latestBlockHash,
800
868
  })),
801
- this.l2BlockSource
802
- .getL2Tips()
803
- .then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed, proposedCheckpoint: t.proposedCheckpoint })),
869
+ this.l2BlockSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })),
804
870
  this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block),
805
871
  this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })),
806
872
  this.l2BlockSource.getPendingChainValidationStatus(),
@@ -845,16 +911,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
845
911
  // matching proposed checkpoint (e.g. it crashed before assembling it). Building on this orphan block
846
912
  // would fork the chain off a tip no other node can follow. The archiver prunes these orphan blocks
847
913
  // once their build slot ends; this guard is the correctness barrier during the grace window before.
914
+ // `getProposedCheckpointData()` returns the latest proposed checkpoint payload, which is always
915
+ // the leading one (a proposed entry is only stored beyond the confirmed frontier and is deleted
916
+ // on confirmation). It carries no tip, so there is no tip-vs-payload split read to reconcile.
848
917
  if (
849
918
  blockData.checkpointNumber > l2Tips.checkpointed.checkpoint.number &&
850
- (l2Tips.proposedCheckpoint.checkpoint.number !== blockData.checkpointNumber ||
851
- proposedCheckpointData?.checkpointNumber !== blockData.checkpointNumber)
919
+ proposedCheckpointData?.checkpointNumber !== blockData.checkpointNumber
852
920
  ) {
853
921
  const logCtx = {
854
922
  blockCheckpointNumber: blockData.checkpointNumber,
855
923
  checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
856
- proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
857
- proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
924
+ proposedCheckpointTipNumber: proposedCheckpointData?.checkpointNumber,
858
925
  blockNumber: blockData.header.getBlockNumber(),
859
926
  blockSlot: blockData.header.getSlot(),
860
927
  syncedL2Slot,
@@ -865,27 +932,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
865
932
  return undefined;
866
933
  }
867
934
 
868
- const hasProposedCheckpoint = l2Tips.proposedCheckpoint.checkpoint.number > l2Tips.checkpointed.checkpoint.number;
869
-
870
- // The l2Tips and proposedCheckpointData reads above come from independent archiver snapshots
871
- // (a JS-side tips cache vs. a direct store read on `#proposedCheckpoints`). A concurrent archiver
872
- // write that mutates both can be observed split, leaving us with `hasProposedCheckpoint=true` but
873
- // no proposedCheckpointData (or one whose number doesn't match the tip). Refuse to proceed in that
874
- // window — the next checkSync tick will see a coherent snapshot.
875
- if (
876
- hasProposedCheckpoint &&
877
- (!proposedCheckpointData ||
878
- proposedCheckpointData.checkpointNumber !== l2Tips.proposedCheckpoint.checkpoint.number)
879
- ) {
880
- this.log.warn(`Sequencer sync check failed: inconsistent proposed-checkpoint state`, {
881
- proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
882
- checkpointedTipNumber: l2Tips.checkpointed.checkpoint.number,
883
- proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
884
- syncedL2Slot,
885
- ...args,
886
- });
887
- return undefined;
888
- }
935
+ const hasProposedCheckpoint = proposedCheckpointData !== undefined;
889
936
 
890
937
  // Check that the proposed checkpoint is indeed the parent of the checkpoint we'll be building
891
938
  // The checkpoint number to build is derived as blockData.checkpointNumber + 1
@@ -962,16 +1009,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
962
1009
  }
963
1010
 
964
1011
  /**
965
- * Tries to vote on slashing actions and governance when we cannot build and are past the block-building window.
966
- * This allows the sequencer to participate in governance/slashing votes even when it cannot build blocks.
1012
+ * Tries to vote on slashing actions and governance and to prune when we cannot build and are past the
1013
+ * block-building window. This allows the sequencer to participate in governance/slashing votes even when it
1014
+ * cannot build blocks, and to prune the pending chain so it can recover from bad data that is blocking sync.
967
1015
  */
968
- @trackSpan('Sequencer.tryVoteWhenCannotBuild', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
969
- protected async tryVoteWhenCannotBuild(args: { slot: SlotNumber; targetSlot: SlotNumber }): Promise<void> {
1016
+ @trackSpan('Sequencer.tryVoteAndPruneWhenCannotBuild', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
1017
+ protected async tryVoteAndPruneWhenCannotBuild(args: { slot: SlotNumber; targetSlot: SlotNumber }): Promise<void> {
970
1018
  const { slot, targetSlot } = args;
971
1019
 
972
1020
  // Prevent duplicate attempts in the same slot
973
- if (this.lastSlotForFallbackVote === slot) {
974
- this.log.trace(`Already attempted to vote in slot ${slot} (skipping)`);
1021
+ if (this.lastSlotForFallbackAction === slot) {
1022
+ this.log.trace(`Already attempted fallback actions in slot ${slot} (skipping)`);
975
1023
  return;
976
1024
  }
977
1025
 
@@ -994,7 +1042,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
994
1042
  }
995
1043
 
996
1044
  // Mark this slot as attempted
997
- this.lastSlotForFallbackVote = slot;
1045
+ this.lastSlotForFallbackAction = slot;
998
1046
 
999
1047
  // Get a publisher for voting
1000
1048
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
@@ -1019,19 +1067,41 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1019
1067
  const votesPromises = voter.enqueueVotes();
1020
1068
  const votes = await Promise.all(votesPromises);
1021
1069
 
1022
- if (votes.every(p => !p)) {
1023
- this.log.debug(`No votes to enqueue for slot ${slot}`);
1070
+ // Even if we cannot build, try to prune so a stuck pending chain (e.g. bad data blocking sync) can
1071
+ // recover. prune() is permissionless, so it rides the same fallback multicall as the votes.
1072
+ const pruneEnqueued = await this.tryEnqueuePruneIfPrunable(targetSlot, publisher);
1073
+
1074
+ // Bail if nothing to do
1075
+ if (votes.every(p => !p) && !pruneEnqueued) {
1076
+ this.log.debug(`Nothing to enqueue for slot ${slot} (no votes, not prunable)`);
1024
1077
  return;
1025
1078
  }
1026
1079
 
1027
- this.log.info(`Voting in slot ${slot} despite sync failure`, { slot });
1080
+ const [governanceVoteEnqueued, slashingVoteEnqueued] = votes;
1081
+ this.log.info(`Submitting fallback requests in slot ${slot} despite sync failure`, {
1082
+ slot,
1083
+ pruneEnqueued,
1084
+ governanceVoteEnqueued: !!governanceVoteEnqueued,
1085
+ slashingVoteEnqueued: !!slashingVoteEnqueued,
1086
+ });
1087
+
1028
1088
  // Votes are EIP-712-signed for `targetSlot` (the pipelined slot in which the multicall is
1029
1089
  // expected to mine). Delay submission to the start of `targetSlot` so the tx mines in the
1030
1090
  // slot the votes were signed for. We fire-and-forget so we don't block the sequencer's
1031
- // work loop while waiting for the target slot to start.
1032
- void publisher.sendRequestsAt(targetSlot).catch(err => {
1033
- this.log.error(`Failed to publish votes despite sync failure for slot ${slot}`, err, { slot });
1091
+ // work loop while waiting for the target slot to start, but track it so stop() can drain it.
1092
+ const send = publisher.sendRequestsAt(targetSlot).catch(err => {
1093
+ this.log.error(`Failed to publish fallback requests despite sync failure for slot ${slot}`, err, { slot });
1034
1094
  });
1095
+ this.pendingRequests.trackRequest(send, () => publisher.interrupt());
1096
+ }
1097
+
1098
+ private async tryEnqueuePruneIfPrunable(targetSlot: SlotNumber, publisher: SequencerPublisher): Promise<boolean> {
1099
+ try {
1100
+ return await publisher.enqueuePruneIfPrunable(targetSlot);
1101
+ } catch (err) {
1102
+ this.log.error(`Failed to enqueue rollup prune for slot ${targetSlot}`, err, { targetSlot });
1103
+ return false;
1104
+ }
1035
1105
  }
1036
1106
 
1037
1107
  /**
@@ -1047,13 +1117,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1047
1117
  const { slot, targetSlot, proposer } = args;
1048
1118
 
1049
1119
  // Prevent duplicate attempts in the same slot
1050
- if (this.lastSlotForFallbackVote === slot) {
1120
+ if (this.lastSlotForFallbackAction === slot) {
1051
1121
  this.log.trace(`Already attempted to vote in slot ${slot} (escape hatch open, skipping)`);
1052
1122
  return;
1053
1123
  }
1054
1124
 
1055
1125
  // Mark this slot as attempted
1056
- this.lastSlotForFallbackVote = slot;
1126
+ this.lastSlotForFallbackAction = slot;
1057
1127
 
1058
1128
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
1059
1129
 
@@ -1092,10 +1162,12 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1092
1162
  // the multicall mines in the slot the votes were signed for; otherwise the L1 contract reads
1093
1163
  // `signaler = getCurrentProposer()` against the wrong slot and signature verification fails
1094
1164
  // silently inside Multicall3. Fire-and-forget so we don't block the sequencer's work loop while
1095
- // waiting for the target slot to start, mirroring tryVoteWhenCannotBuild.
1096
- void publisher.sendRequestsAt(targetSlot).catch(err => {
1165
+ // waiting for the target slot to start, mirroring tryVoteAndPruneWhenCannotBuild, but tracked so
1166
+ // stop() can drain it.
1167
+ const send = publisher.sendRequestsAt(targetSlot).catch(err => {
1097
1168
  this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, { slot, targetSlot });
1098
1169
  });
1170
+ this.pendingRequests.trackRequest(send, () => publisher.interrupt());
1099
1171
  }
1100
1172
 
1101
1173
  /**
package/src/test/utils.ts CHANGED
@@ -90,6 +90,7 @@ export async function makeBlock(txs: Tx[], globalVariables: GlobalVariables): Pr
90
90
  */
91
91
  export function mockPendingTxs(p2p: MockProxy<P2P>, txs: Tx[]): void {
92
92
  p2p.getPendingTxCount.mockResolvedValue(txs.length);
93
+ p2p.hasEligiblePendingTxs.mockImplementation(minCount => Promise.resolve(txs.length >= minCount));
93
94
  p2p.iteratePendingTxs.mockImplementation(() => mockTxIterator(Promise.resolve(txs)));
94
95
  p2p.iterateEligiblePendingTxs.mockImplementation(() => mockTxIterator(Promise.resolve(txs)));
95
96
  }
@@ -129,6 +130,7 @@ function createCheckpointHeaderFromBlock(block: L2Block): CheckpointHeader {
129
130
  gv.feeRecipient,
130
131
  gv.gasFees,
131
132
  block.header.totalManaUsed,
133
+ block.header.totalFees,
132
134
  );
133
135
  }
134
136