@aztec/sequencer-client 5.0.0-rc.2 → 5.0.1

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 (52) 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 +7 -2
  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/publisher/l1_to_l2_messaging.d.ts +21 -0
  14. package/dest/publisher/l1_to_l2_messaging.d.ts.map +1 -0
  15. package/dest/publisher/l1_to_l2_messaging.js +70 -0
  16. package/dest/publisher/sequencer-publisher.d.ts +1 -1
  17. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  18. package/dest/publisher/sequencer-publisher.js +6 -2
  19. package/dest/publisher/write_json.d.ts +11 -0
  20. package/dest/publisher/write_json.d.ts.map +1 -0
  21. package/dest/publisher/write_json.js +57 -0
  22. package/dest/sequencer/automine/automine_sequencer.d.ts +1 -1
  23. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -1
  24. package/dest/sequencer/automine/automine_sequencer.js +11 -10
  25. package/dest/sequencer/checkpoint_proposal_job.d.ts +4 -6
  26. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  27. package/dest/sequencer/checkpoint_proposal_job.js +27 -24
  28. package/dest/sequencer/events.d.ts +1 -2
  29. package/dest/sequencer/events.d.ts.map +1 -1
  30. package/dest/sequencer/requests_tracker.d.ts +22 -0
  31. package/dest/sequencer/requests_tracker.d.ts.map +1 -0
  32. package/dest/sequencer/requests_tracker.js +33 -0
  33. package/dest/sequencer/sequencer.d.ts +39 -4
  34. package/dest/sequencer/sequencer.d.ts.map +1 -1
  35. package/dest/sequencer/sequencer.js +81 -21
  36. package/dest/test/utils.d.ts +1 -1
  37. package/dest/test/utils.d.ts.map +1 -1
  38. package/dest/test/utils.js +1 -0
  39. package/package.json +27 -27
  40. package/src/client/sequencer-client.ts +19 -3
  41. package/src/config.ts +7 -2
  42. package/src/global_variable_builder/fee_predictor.ts +11 -1
  43. package/src/global_variable_builder/fee_provider.ts +19 -2
  44. package/src/publisher/l1_to_l2_messaging.ts +85 -0
  45. package/src/publisher/sequencer-publisher.ts +8 -2
  46. package/src/publisher/write_json.ts +78 -0
  47. package/src/sequencer/automine/automine_sequencer.ts +11 -10
  48. package/src/sequencer/checkpoint_proposal_job.ts +40 -29
  49. package/src/sequencer/events.ts +1 -1
  50. package/src/sequencer/requests_tracker.ts +43 -0
  51. package/src/sequencer/sequencer.ts +83 -15
  52. package/src/test/utils.ts +1 -0
@@ -362,8 +362,7 @@ export class AutomineSequencer {
362
362
  return;
363
363
  }
364
364
  try {
365
- const pending = await this.deps.p2pClient.getPendingTxCount();
366
- if (pending > 0) {
365
+ if (await this.deps.p2pClient.hasEligiblePendingTxs(1)) {
367
366
  // Fire-and-forget; the build result is delivered via `buildIfPending()` callers,
368
367
  // not via the poller.
369
368
  void this.buildIfPending().catch(err => {
@@ -389,13 +388,13 @@ export class AutomineSequencer {
389
388
  }
390
389
  await this.reconcileDateProvider();
391
390
 
392
- const txCount = await this.deps.p2pClient.getPendingTxCount();
393
- // For mempool-driven builds, wait for at least `minTxsPerBlock` pending txs (or 1 if not set)
391
+ // For mempool-driven builds, wait for at least `minTxsPerBlock` age-eligible txs (or 1 if not set)
394
392
  // before building. This mirrors the production sequencer's `waitForMinTxs` behavior, and is
395
393
  // required for tests that bundle multiple txs into one block via `setConfig({ minTxsPerBlock })`.
396
- // Explicit empty-block / warp paths pass `allowEmpty: true` and bypass this gate.
394
+ // Explicit empty-block / warp paths pass `allowEmpty: true`, giving minRequired 0, which
395
+ // hasEligiblePendingTxs treats as always satisfied so the gate is bypassed.
397
396
  const minRequired = allowEmpty ? 0 : Math.max(this.deps.config.minTxsPerBlock ?? 1, 1);
398
- if (txCount < minRequired) {
397
+ if (!(await this.deps.p2pClient.hasEligiblePendingTxs(minRequired))) {
399
398
  return undefined;
400
399
  }
401
400
 
@@ -439,7 +438,6 @@ export class AutomineSequencer {
439
438
  blockNumber: nextBlockNumber,
440
439
  slot: targetSlot,
441
440
  slotTimestamp: slotBoundaryTs,
442
- txCount,
443
441
  allowEmpty,
444
442
  });
445
443
 
@@ -717,9 +715,12 @@ export class AutomineSequencer {
717
715
  await rollupCheatCodes.markAsProven(target);
718
716
  // Settlement is a direct L1 storage write that mines no block, unlike a real epoch proof landing
719
717
  // on L1. The archiver's L1 sync short-circuits while the L1 block hash is unchanged, so it would
720
- // never re-read the proven tip until the next build/warp mines a block. Mine one empty L1 block so
721
- // the block hash advances, then force an immediate sync that observes the new proven checkpoint.
722
- await this.deps.ethCheatCodes.mineEmptyBlock();
718
+ // never re-read the proven tip until the next build/warp mines a block. Mine one L1 block so the
719
+ // block hash advances, then force an immediate sync that observes the new proven checkpoint. Use
720
+ // evmMine (not mineEmptyBlock): mineEmptyBlock drops+re-adds the mempool non-atomically, which can
721
+ // silently lose a test's concurrently-submitted direct L1 tx. evm_mine just includes whatever is
722
+ // pending, which is fine here — we only need the block hash to advance.
723
+ await this.deps.ethCheatCodes.evmMine();
723
724
  await this.deps.archiver.syncImmediate();
724
725
 
725
726
  this.log.verbose(`Proved up to checkpoint ${target}`, { target, proven, startEpoch, endEpoch });
@@ -31,6 +31,7 @@ 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';
@@ -74,6 +75,7 @@ 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,
@@ -1049,7 +1048,7 @@ export class CheckpointProposalJob implements Traceable {
1049
1048
 
1050
1049
  try {
1051
1050
  // Wait until we have enough txs to build the block
1052
- const { availableTxs, canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
1051
+ const { canStartBuilding, minTxs } = await this.waitForMinTxs(opts);
1053
1052
  if (!canStartBuilding) {
1054
1053
  this.logCheckpointEvent('block-build-failed', `Block build failed for slot ${this.targetSlot}`, {
1055
1054
  reason: 'insufficient_txs',
@@ -1057,22 +1056,20 @@ export class CheckpointProposalJob implements Traceable {
1057
1056
  slot: this.targetSlot,
1058
1057
  checkpointNumber: this.checkpointNumber,
1059
1058
  indexWithinCheckpoint,
1060
- availableTxs,
1061
1059
  minTxs,
1062
1060
  });
1063
1061
  this.log.verbose(
1064
- `Not enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (got ${availableTxs} txs but needs ${minTxs})`,
1062
+ `Not enough age-eligible txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (needs ${minTxs} eligible)`,
1065
1063
  {
1066
1064
  reason: 'insufficient_txs',
1067
1065
  blockNumber,
1068
1066
  slot: this.targetSlot,
1069
1067
  checkpointNumber: this.checkpointNumber,
1070
1068
  indexWithinCheckpoint,
1071
- availableTxs,
1072
1069
  minTxs,
1073
1070
  },
1074
1071
  );
1075
- 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 });
1076
1073
  this.metrics.recordBlockProposalFailed('insufficient_txs');
1077
1074
  return { failure: 'insufficient-txs' };
1078
1075
  }
@@ -1086,10 +1083,11 @@ export class CheckpointProposalJob implements Traceable {
1086
1083
  tx => !txHashesAlreadyIncluded.has(tx.txHash.toString()),
1087
1084
  );
1088
1085
 
1089
- this.log.debug(
1090
- `Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot} with ${availableTxs} available txs`,
1091
- { slot: this.targetSlot, blockNumber, indexWithinCheckpoint },
1092
- );
1086
+ this.log.debug(`Building block ${blockNumber} at index ${indexWithinCheckpoint} for slot ${this.targetSlot}`, {
1087
+ slot: this.targetSlot,
1088
+ blockNumber,
1089
+ indexWithinCheckpoint,
1090
+ });
1093
1091
  this.setState(SequencerState.CREATING_BLOCK);
1094
1092
 
1095
1093
  // Per-block limits are operator overrides (from SEQ_MAX_L2_BLOCK_GAS etc.) further capped
@@ -1241,7 +1239,7 @@ export class CheckpointProposalJob implements Traceable {
1241
1239
  blockNumber: BlockNumber;
1242
1240
  indexWithinCheckpoint: IndexWithinCheckpoint;
1243
1241
  buildDeadline: Date | undefined;
1244
- }): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
1242
+ }): Promise<{ canStartBuilding: boolean; minTxs: number }> {
1245
1243
  const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
1246
1244
 
1247
1245
  // We only allow a block with 0 txs in the first block of the checkpoint
@@ -1252,26 +1250,25 @@ export class CheckpointProposalJob implements Traceable {
1252
1250
  ? new Date(buildDeadline.getTime() - this.timetable.minBlockDuration * 1000)
1253
1251
  : undefined;
1254
1252
 
1255
- let availableTxs = await this.p2pClient.getPendingTxCount();
1256
-
1257
- 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))) {
1258
1256
  // If we're past deadline, or we have no deadline, give up
1259
1257
  const now = this.dateProvider.nowAsDate();
1260
1258
  if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
1261
- return { canStartBuilding: false, availableTxs, minTxs };
1259
+ return { canStartBuilding: false, minTxs };
1262
1260
  }
1263
1261
 
1264
1262
  // Wait a bit before checking again
1265
1263
  this.setState(SequencerState.WAITING_FOR_TXS);
1266
1264
  this.log.verbose(
1267
- `Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
1268
- { 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 },
1269
1267
  );
1270
1268
  await this.waitForTxsPollingInterval();
1271
- availableTxs = await this.p2pClient.getPendingTxCount();
1272
1269
  }
1273
1270
 
1274
- return { canStartBuilding: true, availableTxs, minTxs };
1271
+ return { canStartBuilding: true, minTxs };
1275
1272
  }
1276
1273
 
1277
1274
  private async getSignedCommitteeAttestations(
@@ -1387,6 +1384,7 @@ export class CheckpointProposalJob implements Traceable {
1387
1384
  this.config.injectFakeAttestation ||
1388
1385
  this.config.injectHighSValueAttestation ||
1389
1386
  this.config.injectUnrecoverableSignatureAttestation ||
1387
+ this.config.injectYParityAttestation ||
1390
1388
  this.config.shuffleAttestationOrdering
1391
1389
  ) {
1392
1390
  return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted);
@@ -1488,6 +1486,19 @@ export class CheckpointProposalJob implements Traceable {
1488
1486
  return new CommitteeAttestationsAndSigners(attestations, this.getSignatureContext());
1489
1487
  }
1490
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
+
1491
1502
  if (this.config.shuffleAttestationOrdering) {
1492
1503
  this.log.warn(`Shuffling attestation ordering in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`);
1493
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
+ }
@@ -55,6 +55,7 @@ import { CheckpointVoter } from './checkpoint_voter.js';
55
55
  import { SequencerInterruptedError } from './errors.js';
56
56
  import type { SequencerEvents } from './events.js';
57
57
  import { SequencerMetrics } from './metrics.js';
58
+ import { RequestsTracker } from './requests_tracker.js';
58
59
  import type { SequencerRollupConstants } from './types.js';
59
60
  import { SequencerState } from './utils.js';
60
61
 
@@ -79,7 +80,7 @@ type SequencerSlotContext = {
79
80
  * - Votes for proposals and slashes on L1
80
81
  */
81
82
  export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<SequencerEvents>) {
82
- private runningPromise?: RunningPromise;
83
+ protected runningPromise?: RunningPromise;
83
84
  private state = SequencerState.STOPPED;
84
85
  private stateSlotNumber: SlotNumber | undefined;
85
86
  /** Wall-clock time (ms, via the date provider) at which the current state was entered. */
@@ -110,6 +111,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
110
111
  /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */
111
112
  protected lastCheckpointProposalJob: CheckpointProposalJob | undefined;
112
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
+
113
125
  /** Proposer schedule and block sub-slot timetable for the sequencer, rebuilt on every config update. */
114
126
  protected timetable!: ProposerTimetable;
115
127
 
@@ -273,8 +285,22 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
273
285
  getKzg();
274
286
  }
275
287
 
276
- /** 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
+ */
277
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
+ }
278
304
  this.runningPromise = new RunningPromise(
279
305
  this.safeWork.bind(this),
280
306
  this.log,
@@ -290,18 +316,56 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
290
316
  return this.runningPromise?.trigger();
291
317
  }
292
318
 
293
- /** 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
+ */
294
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
+ }
295
331
  this.log.info(`Stopping sequencer`);
296
332
  this.setState(SequencerState.STOPPING, undefined, { force: true });
297
333
  this.lastCheckpointProposalJob?.interrupt();
298
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.
299
339
  await this.runningPromise?.stop();
300
- await this.lastCheckpointProposalJob?.awaitPendingSubmission();
340
+ this.pendingRequests.interruptRequests();
341
+ await this.pendingRequests.awaitRequests();
301
342
  this.setState(SequencerState.STOPPED, undefined, { force: true });
302
343
  this.log.info('Stopped sequencer');
303
344
  }
304
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
+
305
369
  /** Main sequencer loop with a try/catch */
306
370
  protected async safeWork() {
307
371
  try {
@@ -452,13 +516,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
452
516
  return undefined;
453
517
  }
454
518
 
455
- // We are the proposer and the escape hatch is closed: now run the full sync check before building.
456
- const syncedTo = await this.checkSync({ ts, slot });
457
- if (!syncedTo) {
458
- await this.tryVoteAndPruneWhenCannotBuild({ slot, targetSlot });
459
- return undefined;
460
- }
461
-
462
519
  // Explicit build-loop entry gate: if we are past the latest useful block-building start for the
463
520
  // target slot, abandon building for this slot. The proposer prioritizes the ideal L1-publish path
464
521
  // and does not plan around the late consensus-handoff path. This is the proposer build path's
@@ -480,6 +537,13 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
480
537
  return undefined;
481
538
  }
482
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
+
483
547
  // Next checkpoint follows from the last synced one
484
548
  const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
485
549
 
@@ -707,6 +771,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
707
771
  this.metrics,
708
772
  this.checkpointProposalJobMetrics.createRecorder(),
709
773
  this,
774
+ this.pendingRequests,
710
775
  this.setState.bind(this),
711
776
  this.tracer,
712
777
  this.log.getBindings(),
@@ -1023,10 +1088,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1023
1088
  // Votes are EIP-712-signed for `targetSlot` (the pipelined slot in which the multicall is
1024
1089
  // expected to mine). Delay submission to the start of `targetSlot` so the tx mines in the
1025
1090
  // slot the votes were signed for. We fire-and-forget so we don't block the sequencer's
1026
- // work loop while waiting for the target slot to start.
1027
- void publisher.sendRequestsAt(targetSlot).catch(err => {
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 => {
1028
1093
  this.log.error(`Failed to publish fallback requests despite sync failure for slot ${slot}`, err, { slot });
1029
1094
  });
1095
+ this.pendingRequests.trackRequest(send, () => publisher.interrupt());
1030
1096
  }
1031
1097
 
1032
1098
  private async tryEnqueuePruneIfPrunable(targetSlot: SlotNumber, publisher: SequencerPublisher): Promise<boolean> {
@@ -1096,10 +1162,12 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1096
1162
  // the multicall mines in the slot the votes were signed for; otherwise the L1 contract reads
1097
1163
  // `signaler = getCurrentProposer()` against the wrong slot and signature verification fails
1098
1164
  // silently inside Multicall3. Fire-and-forget so we don't block the sequencer's work loop while
1099
- // waiting for the target slot to start, mirroring tryVoteAndPruneWhenCannotBuild.
1100
- 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 => {
1101
1168
  this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, { slot, targetSlot });
1102
1169
  });
1170
+ this.pendingRequests.trackRequest(send, () => publisher.interrupt());
1103
1171
  }
1104
1172
 
1105
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
  }