@aztec/sequencer-client 0.0.1-commit.993d240 → 0.0.1-commit.9a89641

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 +40 -41
  2. package/dest/client/sequencer-client.d.ts +16 -3
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +18 -14
  5. package/dest/config.d.ts +9 -4
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +29 -15
  8. package/dest/global_variable_builder/fee_predictor.d.ts +1 -1
  9. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -1
  10. package/dest/global_variable_builder/fee_predictor.js +11 -1
  11. package/dest/global_variable_builder/fee_provider.d.ts +1 -1
  12. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -1
  13. package/dest/global_variable_builder/fee_provider.js +27 -5
  14. package/dest/global_variable_builder/global_builder.d.ts +3 -16
  15. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  16. package/dest/global_variable_builder/global_builder.js +2 -25
  17. package/dest/index.d.ts +2 -2
  18. package/dest/index.d.ts.map +1 -1
  19. package/dest/index.js +1 -1
  20. package/dest/publisher/config.d.ts +5 -1
  21. package/dest/publisher/config.d.ts.map +1 -1
  22. package/dest/publisher/config.js +11 -1
  23. package/dest/publisher/l1_to_l2_messaging.d.ts +21 -0
  24. package/dest/publisher/l1_to_l2_messaging.d.ts.map +1 -0
  25. package/dest/publisher/l1_to_l2_messaging.js +70 -0
  26. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +48 -8
  27. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -1
  28. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +68 -1
  29. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +2 -2
  30. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -1
  31. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +4 -2
  32. package/dest/publisher/l1_tx_failed_store/index.d.ts +2 -2
  33. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -1
  34. package/dest/publisher/l1_tx_failed_store/index.js +1 -0
  35. package/dest/publisher/sequencer-publisher.d.ts +39 -10
  36. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  37. package/dest/publisher/sequencer-publisher.js +403 -75
  38. package/dest/publisher/write_json.d.ts +11 -0
  39. package/dest/publisher/write_json.d.ts.map +1 -0
  40. package/dest/publisher/write_json.js +57 -0
  41. package/dest/sequencer/automine/automine_factory.d.ts +5 -3
  42. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -1
  43. package/dest/sequencer/automine/automine_factory.js +2 -1
  44. package/dest/sequencer/automine/automine_sequencer.d.ts +43 -5
  45. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -1
  46. package/dest/sequencer/automine/automine_sequencer.js +209 -19
  47. package/dest/sequencer/automine/index.d.ts +3 -0
  48. package/dest/sequencer/automine/index.d.ts.map +1 -0
  49. package/dest/sequencer/automine/index.js +2 -0
  50. package/dest/sequencer/checkpoint_proposal_job.d.ts +28 -15
  51. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  52. package/dest/sequencer/checkpoint_proposal_job.js +158 -117
  53. package/dest/sequencer/events.d.ts +16 -4
  54. package/dest/sequencer/events.d.ts.map +1 -1
  55. package/dest/sequencer/index.d.ts +1 -3
  56. package/dest/sequencer/index.d.ts.map +1 -1
  57. package/dest/sequencer/index.js +0 -2
  58. package/dest/sequencer/missing_committee.d.ts +72 -0
  59. package/dest/sequencer/missing_committee.d.ts.map +1 -0
  60. package/dest/sequencer/missing_committee.js +139 -0
  61. package/dest/sequencer/requests_tracker.d.ts +22 -0
  62. package/dest/sequencer/requests_tracker.d.ts.map +1 -0
  63. package/dest/sequencer/requests_tracker.js +33 -0
  64. package/dest/sequencer/sequencer.d.ts +113 -28
  65. package/dest/sequencer/sequencer.d.ts.map +1 -1
  66. package/dest/sequencer/sequencer.js +352 -162
  67. package/dest/test/index.d.ts +3 -3
  68. package/dest/test/index.d.ts.map +1 -1
  69. package/dest/test/utils.d.ts +15 -1
  70. package/dest/test/utils.d.ts.map +1 -1
  71. package/dest/test/utils.js +18 -1
  72. package/package.json +29 -28
  73. package/src/client/sequencer-client.ts +20 -14
  74. package/src/config.ts +32 -16
  75. package/src/global_variable_builder/fee_predictor.ts +11 -1
  76. package/src/global_variable_builder/fee_provider.ts +27 -5
  77. package/src/global_variable_builder/global_builder.ts +2 -34
  78. package/src/index.ts +1 -10
  79. package/src/publisher/config.ts +22 -1
  80. package/src/publisher/l1_to_l2_messaging.ts +85 -0
  81. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +114 -7
  82. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +4 -3
  83. package/src/publisher/l1_tx_failed_store/index.ts +1 -1
  84. package/src/publisher/sequencer-publisher.ts +430 -86
  85. package/src/publisher/write_json.ts +78 -0
  86. package/src/sequencer/README.md +16 -6
  87. package/src/sequencer/automine/README.md +18 -4
  88. package/src/sequencer/automine/automine_factory.ts +8 -1
  89. package/src/sequencer/automine/automine_sequencer.ts +229 -21
  90. package/src/sequencer/automine/index.ts +6 -0
  91. package/src/sequencer/checkpoint_proposal_job.ts +177 -132
  92. package/src/sequencer/events.ts +16 -3
  93. package/src/sequencer/index.ts +0 -2
  94. package/src/sequencer/missing_committee.ts +192 -0
  95. package/src/sequencer/requests_tracker.ts +43 -0
  96. package/src/sequencer/sequencer.ts +382 -176
  97. package/src/test/index.ts +2 -2
  98. package/src/test/utils.ts +33 -0
  99. package/dest/sequencer/chain_state_overrides.d.ts +0 -61
  100. package/dest/sequencer/chain_state_overrides.d.ts.map +0 -1
  101. package/dest/sequencer/chain_state_overrides.js +0 -98
  102. package/dest/sequencer/timetable.d.ts +0 -101
  103. package/dest/sequencer/timetable.d.ts.map +0 -1
  104. package/dest/sequencer/timetable.js +0 -231
  105. package/src/sequencer/chain_state_overrides.ts +0 -162
  106. package/src/sequencer/timetable.ts +0 -295
@@ -8,16 +8,18 @@ import {
8
8
  MULTI_CALL_3_ADDRESS,
9
9
  Multicall3,
10
10
  MulticallForwarderRevertedError,
11
+ type PayloadProposalStatus,
11
12
  type RollupContract,
12
13
  type SimulationOverridesPlan,
13
14
  type SlashingProposerContract,
14
15
  buildSimulationOverridesStateOverride,
15
16
  } from '@aztec/ethereum/contracts';
16
- import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
17
+ import { type L1FeeAnalysisResult, L1FeeAnalyzer, captureWindowBlockFees } from '@aztec/ethereum/l1-fee-analysis';
17
18
  import {
18
19
  type L1BlobInputs,
19
20
  type L1TxConfig,
20
21
  type L1TxRequest,
22
+ L1TxTimeoutError,
21
23
  type L1TxUtils,
22
24
  MAX_L1_TX_LIMIT,
23
25
  type TransactionStats,
@@ -45,6 +47,7 @@ import { EmpireBaseAbi, ErrorsAbi, RollupAbi, SlashingProposerAbi } from '@aztec
45
47
  import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher';
46
48
  import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block';
47
49
  import type { Checkpoint } from '@aztec/stdlib/checkpoint';
50
+ import type { SequencerConfig } from '@aztec/stdlib/config';
48
51
  import {
49
52
  getLastL1SlotTimestampForL2Slot,
50
53
  getNextL1SlotTimestamp,
@@ -117,6 +120,7 @@ type L1ProcessArgs = {
117
120
  export const Actions = [
118
121
  'invalidate-by-invalid-attestation',
119
122
  'invalidate-by-insufficient-attestations',
123
+ 'prune',
120
124
  'propose',
121
125
  'governance-signal',
122
126
  'vote-offenses',
@@ -177,6 +181,8 @@ export class SequencerPublisher {
177
181
  protected log: Logger;
178
182
  protected ethereumSlotDuration: bigint;
179
183
  protected aztecSlotDuration: bigint;
184
+ private readonly previousL1BlockWaitTimeoutMs: number;
185
+ private readonly previousL1BlockWaitPollIntervalMs: number;
180
186
 
181
187
  /** Date provider for wall-clock time. */
182
188
  private readonly dateProvider: DateProvider;
@@ -205,7 +211,14 @@ export class SequencerPublisher {
205
211
  protected requests: RequestWithExpiry[] = [];
206
212
 
207
213
  constructor(
208
- private config: Pick<SequencerPublisherConfig, 'fishermanMode' | 'l1TxFailedStore'> &
214
+ private config: Pick<
215
+ SequencerPublisherConfig,
216
+ | 'fishermanMode'
217
+ | 'l1TxFailedStore'
218
+ | 'sequencerPublisherPreviousL1BlockWaitTimeoutMs'
219
+ | 'sequencerPublisherPreviousL1BlockWaitPollIntervalMs'
220
+ > &
221
+ Pick<SequencerConfig, 'governanceProposerForcePayloadVote'> &
209
222
  Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'> & { l1ChainId: number },
210
223
  deps: {
211
224
  telemetry?: TelemetryClient;
@@ -225,6 +238,8 @@ export class SequencerPublisher {
225
238
  this.log = deps.log ?? createLogger('sequencer:publisher');
226
239
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
227
240
  this.aztecSlotDuration = BigInt(config.aztecSlotDuration);
241
+ this.previousL1BlockWaitTimeoutMs = config.sequencerPublisherPreviousL1BlockWaitTimeoutMs;
242
+ this.previousL1BlockWaitPollIntervalMs = config.sequencerPublisherPreviousL1BlockWaitPollIntervalMs;
228
243
  this.dateProvider = deps.dateProvider;
229
244
  this.epochCache = deps.epochCache;
230
245
  this.lastActions = deps.lastActions;
@@ -276,25 +291,97 @@ export class SequencerPublisher {
276
291
  }
277
292
 
278
293
  /**
279
- * Backs up a failed L1 transaction to the configured store for debugging.
280
- * Does nothing if no store is configured.
294
+ * Logs the gas-pricing data of a failed L1 transaction at warn so underpricing is diagnosable
295
+ * from logs even with no failed-tx store configured — and backs the record up to the store when
296
+ * one is. When captureFeeSummary is true, also records the fee data of the already-mined L1
297
+ * blocks in the target slot's inclusion window.
281
298
  */
282
- private backupFailedTx(failedTx: Omit<FailedL1Tx, 'timestamp'>): void {
283
- if (!this.failedTxStore) {
284
- return;
285
- }
286
-
299
+ private backupFailedTx(
300
+ failedTx: Omit<FailedL1Tx, 'timestamp'>,
301
+ opts?: { captureFeeSummary?: boolean; targetSlot?: SlotNumber; sharedFeeSummary?: FailedL1Tx['gasInfo'] },
302
+ ): void {
287
303
  const tx: FailedL1Tx = {
288
304
  ...failedTx,
289
305
  timestamp: Date.now(),
290
306
  };
291
307
 
292
308
  // Fire and forget - don't block on backup
293
- void this.failedTxStore
294
- .then(store => store?.saveFailedTx(tx))
295
- .catch(err => {
309
+ void (async () => {
310
+ try {
311
+ // Prefer a pre-captured summary (shared across a batch of failures in the same slot) so we
312
+ // don't re-read the fee window per record. A capture error must not lose the record itself.
313
+ const feeSummary =
314
+ opts?.sharedFeeSummary ??
315
+ (opts?.captureFeeSummary
316
+ ? await this.captureFeeEnvironment(opts.targetSlot).catch(() => undefined)
317
+ : undefined);
318
+ if (feeSummary) {
319
+ tx.gasInfo = { ...tx.gasInfo, ...feeSummary };
320
+ }
321
+ if (tx.gasInfo) {
322
+ this.log.warn(`Gas pricing data for failed L1 tx (${tx.failureType})`, {
323
+ failureType: tx.failureType,
324
+ actions: tx.context.actions,
325
+ slot: tx.context.slot,
326
+ ...tx.gasInfo,
327
+ ...tx.timing,
328
+ });
329
+ }
330
+ const store = await this.failedTxStore;
331
+ if (store) {
332
+ await store.saveFailedTx(tx);
333
+ }
334
+ } catch (err) {
296
335
  this.log.warn(`Failed to backup failed L1 tx to store`, err);
297
- });
336
+ }
337
+ })();
338
+ }
339
+
340
+ /**
341
+ * Captures per-block fee data for the L1 blocks in the target slot's inclusion window (the blocks the
342
+ * tx could have landed in) for underpricing diagnostics. Reads only already-mined blocks, so it never
343
+ * waits on the chain. Safe to call off the critical path: the underlying capture never throws, and this
344
+ * returns undefined when there is no target slot or the window is not yet mined (e.g. an early send
345
+ * failure), in which case the record simply carries no window data.
346
+ */
347
+ private async captureFeeEnvironment(targetL2Slot: SlotNumber | undefined): Promise<FailedL1Tx['gasInfo']> {
348
+ if (targetL2Slot === undefined) {
349
+ return undefined;
350
+ }
351
+ const l1Constants = this.epochCache.getL1Constants();
352
+ // The inclusion window is [start of slot N, start of slot N+1): all L1 blocks that can include a tx
353
+ // for this L2 slot. getTimestampForSlot returns seconds, matching block.timestamp.
354
+ const windowStartS = getTimestampForSlot(targetL2Slot, l1Constants);
355
+ const windowEndS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
356
+ const windowBlocks = await captureWindowBlockFees(this.l1TxUtils.client, windowStartS, windowEndS);
357
+ if (windowBlocks.length === 0) {
358
+ return undefined;
359
+ }
360
+ return { windowBlocks };
361
+ }
362
+
363
+ /** Computes timing info relative to the L2 slot deadline. */
364
+ private computeTimingInfo(targetL2Slot: SlotNumber | undefined): FailedL1Tx['timing'] {
365
+ if (targetL2Slot === undefined) {
366
+ return undefined;
367
+ }
368
+ const l1Constants = this.epochCache.getL1Constants();
369
+ const slotDeadlineS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
370
+ const slotDeadlineMs = Number(slotDeadlineS) * 1000;
371
+ return {
372
+ targetL2Slot: Number(targetL2Slot),
373
+ slotDeadlineTimestampS: slotDeadlineS,
374
+ msUntilSlotDeadline: slotDeadlineMs - this.dateProvider.now(),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Builds an id for a synthetic failure record (send-error/timeout) that has no on-chain tx hash.
380
+ * Includes the failure time so each attempt — including retries of the same slot — is stored as its
381
+ * own record rather than overwriting the previous one.
382
+ */
383
+ private failureRecordId(actions: string[], targetSlot: SlotNumber | undefined): Hex {
384
+ return keccak256(toHex(`${actions.join(',')}:${targetSlot ?? ''}:${Date.now()}`));
298
385
  }
299
386
 
300
387
  public getRollupContract(): RollupContract {
@@ -401,10 +488,10 @@ export class SequencerPublisher {
401
488
 
402
489
  /**
403
490
  * Sends all requests that are still valid.
404
- * @param targetSlot - The target L2 slot for this send. When provided (pipelined path via
405
- * sendRequestsAt), it is threaded into bundleSimulate so the block.timestamp override
406
- * matches the slot the propose is built for. When omitted, falls back to
407
- * getCurrentL2Slot() for the non-pipelined callers in Sequencer.doWork.
491
+ * @param targetSlot - The target L2 slot for this send. When provided (the production path, via
492
+ * sendRequestsAt), it is threaded into bundleSimulate so the block.timestamp override matches
493
+ * the slot the propose is built for. When omitted, falls back to getCurrentL2Slot() for the
494
+ * AutomineSequencer, which publishes synchronously within the current slot.
408
495
  * @returns one of:
409
496
  * - A receipt and stats if the tx succeeded
410
497
  * - a receipt and errorMsg if it failed on L1
@@ -458,7 +545,9 @@ export class SequencerPublisher {
458
545
 
459
546
  if (bundleResult.kind === 'aborted') {
460
547
  this.logDroppedInSim(bundleResult.droppedRequests);
461
- void this.backupDroppedInSim(bundleResult.droppedRequests);
548
+ void this.backupDroppedInSim(bundleResult.droppedRequests, currentL2Slot).catch(err =>
549
+ this.log.error(`Failed to backup requests dropped in simulation`, err),
550
+ );
462
551
  return undefined;
463
552
  }
464
553
 
@@ -482,7 +571,7 @@ export class SequencerPublisher {
482
571
  requests: requests.map(request => request.action),
483
572
  txConfig,
484
573
  });
485
- const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
574
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig, currentL2Slot);
486
575
  if (result === undefined) {
487
576
  return undefined;
488
577
  }
@@ -498,6 +587,44 @@ export class SequencerPublisher {
498
587
  } catch (err) {
499
588
  const viemError = formatViemError(err);
500
589
  this.log.error(`Failed to publish bundled transactions`, viemError);
590
+ if (err instanceof TimeoutError) {
591
+ const timeoutState = err instanceof L1TxTimeoutError ? err.txState : undefined;
592
+ void (async () => {
593
+ // The RPC is likely degraded right after a timeout, so back up without the block number
594
+ // rather than leaking an unhandled rejection.
595
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber().catch(() => 0n);
596
+ this.backupFailedTx(
597
+ {
598
+ id: this.failureRecordId(
599
+ validRequests.map(r => r.action),
600
+ currentL2Slot,
601
+ ),
602
+ failureType: 'timeout',
603
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
604
+ l1BlockNumber,
605
+ error: { message: viemError.message, name: 'TimeoutError' },
606
+ context: {
607
+ actions: validRequests.map(r => r.action),
608
+ requests: validRequests
609
+ .filter(r => r.request.to !== null)
610
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
611
+ sender: this.getSenderAddress().toString(),
612
+ slot: Number(currentL2Slot),
613
+ },
614
+ timing: this.computeTimingInfo(currentL2Slot),
615
+ gasInfo: timeoutState
616
+ ? {
617
+ sentGasPriceLadder: timeoutState.gasPriceHistory,
618
+ attempts: timeoutState.attempts,
619
+ gasLimit: timeoutState.gasLimit,
620
+ nonce: timeoutState.nonce,
621
+ }
622
+ : undefined,
623
+ },
624
+ { captureFeeSummary: true, targetSlot: currentL2Slot },
625
+ );
626
+ })();
627
+ }
501
628
  return undefined;
502
629
  } finally {
503
630
  try {
@@ -525,23 +652,37 @@ export class SequencerPublisher {
525
652
  }
526
653
 
527
654
  /** Backs up entries dropped by bundle simulation, one record per dropped action. */
528
- private async backupDroppedInSim(dropped: DroppedRequest[]): Promise<void> {
655
+ private async backupDroppedInSim(dropped: DroppedRequest[], targetSlot?: SlotNumber): Promise<void> {
529
656
  if (dropped.length === 0) {
530
657
  return;
531
658
  }
532
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
533
- for (const { request: req } of dropped) {
534
- this.backupFailedTx({
535
- id: keccak256(req.request.data!),
536
- failureType: 'simulation',
537
- request: { to: req.request.to! as Hex, data: req.request.data! },
538
- l1BlockNumber: l1BlockNumber.toString(),
539
- error: { message: 'Bundle entry dropped: action reverted in sim' },
540
- context: {
541
- actions: [req.action],
542
- sender: this.getSenderAddress().toString(),
543
- },
544
- });
659
+ // Invoked as `void backupDroppedInSim(...)` on the publish path, so it must not throw.
660
+ try {
661
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
662
+ // Every dropped entry failed in the same slot against the same L1 fee conditions, so capture
663
+ // the fee environment once and share it rather than re-reading the window per entry.
664
+ const sharedFeeSummary = await this.captureFeeEnvironment(targetSlot).catch(() => undefined);
665
+ const timing = this.computeTimingInfo(targetSlot);
666
+ for (const { request: req } of dropped) {
667
+ this.backupFailedTx(
668
+ {
669
+ id: keccak256(req.request.data!),
670
+ failureType: 'simulation',
671
+ request: { to: req.request.to! as Hex, data: req.request.data! },
672
+ l1BlockNumber,
673
+ error: { message: 'Bundle entry dropped: action reverted in sim' },
674
+ context: {
675
+ actions: [req.action],
676
+ sender: this.getSenderAddress().toString(),
677
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
678
+ },
679
+ timing,
680
+ },
681
+ { sharedFeeSummary },
682
+ );
683
+ }
684
+ } catch (err) {
685
+ this.log.warn(`Failed to back up dropped-in-sim entries`, err);
545
686
  }
546
687
  }
547
688
 
@@ -554,6 +695,7 @@ export class SequencerPublisher {
554
695
  validRequests: RequestWithExpiry[],
555
696
  txConfig: RequestWithExpiry['gasConfig'],
556
697
  blobConfig: L1BlobInputs | undefined,
698
+ targetSlot?: SlotNumber,
557
699
  ) {
558
700
  if (!txConfig?.gasLimit) {
559
701
  throw new Error('gasLimit is required for bundled transactions');
@@ -590,11 +732,13 @@ export class SequencerPublisher {
590
732
  this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
591
733
  transactionHash: err.receipt.transactionHash,
592
734
  });
735
+ this.backupRevertFailure(validRequests, err, currentPublisher, targetSlot);
593
736
  return undefined;
594
737
  }
595
738
  const viemError = formatViemError(err);
596
739
  if (!this.getNextPublisher) {
597
740
  this.log.error('Failed to publish bundled transactions', viemError);
741
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
598
742
  return undefined;
599
743
  }
600
744
  this.log.warn(
@@ -608,6 +752,7 @@ export class SequencerPublisher {
608
752
  viemError,
609
753
  { triedAddresses: triedAddresses.map(a => a.toString()) },
610
754
  );
755
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
611
756
  return undefined;
612
757
  }
613
758
  currentPublisher = nextPublisher;
@@ -615,35 +760,154 @@ export class SequencerPublisher {
615
760
  }
616
761
  }
617
762
 
763
+ /** Backs up an on-chain revert failure to the failed tx store. */
764
+ private backupRevertFailure(
765
+ requests: RequestWithExpiry[],
766
+ err: MulticallForwarderRevertedError,
767
+ publisher: L1TxUtils,
768
+ targetSlot?: SlotNumber,
769
+ ): void {
770
+ this.backupFailedTx(
771
+ {
772
+ id: err.receipt.transactionHash,
773
+ failureType: 'revert',
774
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
775
+ l1BlockNumber: err.receipt.blockNumber,
776
+ receipt: {
777
+ transactionHash: err.receipt.transactionHash,
778
+ blockNumber: err.receipt.blockNumber,
779
+ gasUsed: err.receipt.gasUsed,
780
+ status: 'reverted',
781
+ },
782
+ error: { message: err.message, name: err.name },
783
+ context: {
784
+ actions: requests.map(r => r.action),
785
+ requests: requests
786
+ .filter(r => r.request.to !== null)
787
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
788
+ sender: publisher.getSenderAddress().toString(),
789
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
790
+ },
791
+ gasInfo: err.txState
792
+ ? {
793
+ sentGasPrice: err.txState.gasPrice,
794
+ gasLimit: err.txState.gasLimit,
795
+ nonce: err.txState.nonce,
796
+ }
797
+ : undefined,
798
+ timing: this.computeTimingInfo(targetSlot),
799
+ },
800
+ { captureFeeSummary: true, targetSlot },
801
+ );
802
+ }
803
+
804
+ /** Backs up a send failure (tx never reached chain) to the failed tx store. */
805
+ private backupSendFailure(
806
+ requests: RequestWithExpiry[],
807
+ error: FormattedViemError | Error,
808
+ publisher: L1TxUtils,
809
+ targetSlot?: SlotNumber,
810
+ ): void {
811
+ // If we can't get the block number, still back up without it.
812
+ void this.l1TxUtils
813
+ .getBlockNumber()
814
+ .catch(() => 0n)
815
+ .then(l1BlockNumber => {
816
+ this.backupFailedTx(
817
+ {
818
+ id: this.failureRecordId(
819
+ requests.map(r => r.action),
820
+ targetSlot,
821
+ ),
822
+ failureType: 'send-error',
823
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
824
+ l1BlockNumber,
825
+ error: {
826
+ message: error.message,
827
+ name: 'name' in error ? error.name : undefined,
828
+ },
829
+ context: {
830
+ actions: requests.map(r => r.action),
831
+ requests: requests
832
+ .filter(r => r.request.to !== null)
833
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
834
+ sender: publisher.getSenderAddress().toString(),
835
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
836
+ },
837
+ timing: this.computeTimingInfo(targetSlot),
838
+ },
839
+ { captureFeeSummary: true, targetSlot },
840
+ );
841
+ });
842
+ }
843
+
618
844
  /*
619
845
  * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
620
- * Sleeps until one L1 slot before the L2 slot boundary so the tx has a chance of being
621
- * picked up by the first L1 block of the L2 slot.
622
- * NB: there is a known correctness risk — being included in the L1 block right before the
623
- * L2 slot starts would revert propose with HeaderLib__InvalidSlotNumber.
624
- * Uses InterruptibleSleep so it can be cancelled via interrupt().
625
846
  */
626
847
  public async sendRequestsAt(targetSlot: SlotNumber): Promise<SendRequestsResult | undefined> {
627
- const l1Constants = this.epochCache.getL1Constants();
628
- // Start of the target L2 slot, in ms (getTimestampForSlot returns seconds).
629
- const startOfTargetSlotMs = Number(getTimestampForSlot(targetSlot, l1Constants)) * 1000;
630
- // Aim to be in the mempool one L1 slot before the L2 slot starts, so we have a chance of
631
- // being picked up by the first L1 block of the L2 slot.
632
- const submitAfterMs = startOfTargetSlotMs - Number(this.ethereumSlotDuration) * 1000;
633
- const sleepMs = submitAfterMs - this.dateProvider.now();
634
- if (sleepMs > 0) {
635
- this.log.debug(`Sleeping ${sleepMs}ms before sending requests`, {
636
- targetSlot,
637
- submitAfterMs,
638
- });
639
- await this.interruptibleSleep.sleep(sleepMs);
640
- }
848
+ await this.waitForTargetSlot(targetSlot);
641
849
  if (this.interrupted) {
642
850
  return undefined;
643
851
  }
852
+
644
853
  return this.sendRequests(targetSlot);
645
854
  }
646
855
 
856
+ /**
857
+ * Sleeps until one L1 slot before the L2 slot boundary, and then waits for that L1 block
858
+ * to be mined, so we don't risk being included in it. If that block never gets mined after
859
+ * a timeout, we assume it got skipped on L1, so we send the tx anyway.
860
+ */
861
+ private async waitForTargetSlot(targetSlot: SlotNumber): Promise<void> {
862
+ const l1Constants = this.epochCache.getL1Constants();
863
+ const nowInSeconds = this.dateProvider.nowInSeconds();
864
+ const startOfTargetSlotTs = getTimestampForSlot(targetSlot, l1Constants);
865
+ const previousL1BlockTs = startOfTargetSlotTs - this.ethereumSlotDuration;
866
+ const waitDeadlineTs = previousL1BlockTs + BigInt(this.previousL1BlockWaitTimeoutMs / 1000);
867
+ const logCtx = { targetSlot, startOfTargetSlotTs, nowInSeconds, previousL1BlockTs, waitDeadlineTs };
868
+
869
+ // Check if we are already past time
870
+ if (nowInSeconds >= startOfTargetSlotTs) {
871
+ this.log.verbose(`Target slot ${targetSlot} already started, sending requests immediately`, logCtx);
872
+ return;
873
+ }
874
+
875
+ // Otherwise we wait
876
+ this.log.debug(`Waiting for slot ${targetSlot} before sending requests`, logCtx);
877
+
878
+ // Wait until previous L1 block timestamp first
879
+ const sleepMs = (Number(previousL1BlockTs) - nowInSeconds) * 1000;
880
+ if (sleepMs > 0 && !this.interrupted) {
881
+ this.log.trace(`Sleeping ${sleepMs}ms before waiting for previous L1 block`, logCtx);
882
+ await this.interruptibleSleep.sleep(sleepMs);
883
+ }
884
+
885
+ // Then loop until we see the previous L1 block, so we know that we cannot be included in it.
886
+ // We time out after a while, once we are sure that that block is skipped in L1.
887
+ while (!this.interrupted) {
888
+ try {
889
+ const nowInSeconds = this.dateProvider.nowInSeconds();
890
+ logCtx.nowInSeconds = nowInSeconds;
891
+
892
+ if (nowInSeconds >= waitDeadlineTs) {
893
+ this.log.warn(`Timed out waiting for previous L1 block before sending requests, proceeding`, logCtx);
894
+ return;
895
+ }
896
+
897
+ const latestBlockTs = await this.l1TxUtils.getBlock().then(b => b.timestamp);
898
+ if (latestBlockTs >= previousL1BlockTs) {
899
+ this.log.debug(`Previous L1 block mined, proceeding to send requests`, { ...logCtx, latestBlockTs });
900
+ return;
901
+ }
902
+ this.log.trace(`Previous L1 block not mined yet, continuing to wait`, { ...logCtx, latestBlockTs });
903
+ } catch (err) {
904
+ this.log.error(`Error while waiting for previous L1 block before sending requests; retrying`, err, logCtx);
905
+ } finally {
906
+ await this.interruptibleSleep.sleep(this.previousL1BlockWaitPollIntervalMs);
907
+ }
908
+ }
909
+ }
910
+
647
911
  private callbackBundledTransactions(
648
912
  requests: RequestWithExpiry[],
649
913
  result: { receipt: TransactionReceipt; multicallData: Hex },
@@ -684,8 +948,7 @@ export class SequencerPublisher {
684
948
  // skip the proposal; we do NOT treat these as bugs.
685
949
  const expectedErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive'];
686
950
 
687
- const pipelined = this.epochCache.isProposerPipeliningEnabled();
688
- const slotOffset = pipelined ? this.aztecSlotDuration : 0n;
951
+ const slotOffset = this.aztecSlotDuration;
689
952
  const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset;
690
953
 
691
954
  return this.rollupContract
@@ -839,18 +1102,22 @@ export class SequencerPublisher {
839
1102
 
840
1103
  // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
841
1104
  this.log.error(`Simulation for invalidate checkpoint ${checkpointNumber} failed`, viemError, logData);
842
- this.backupFailedTx({
843
- id: keccak256(request.data!),
844
- failureType: 'simulation',
845
- request: { to: request.to!, data: request.data!, value: request.value?.toString() },
846
- l1BlockNumber: l1BlockNumber.toString(),
847
- error: { message: viemError.message, name: viemError.name },
848
- context: {
849
- actions: [`invalidate-${reason}`],
850
- checkpointNumber,
851
- sender: this.getSenderAddress().toString(),
1105
+ this.backupFailedTx(
1106
+ {
1107
+ id: keccak256(request.data!),
1108
+ failureType: 'simulation',
1109
+ request: { to: request.to!, data: request.data!, value: request.value },
1110
+ l1BlockNumber,
1111
+ error: { message: viemError.message, name: viemError.name },
1112
+ context: {
1113
+ actions: [`invalidate-${reason}`],
1114
+ checkpointNumber,
1115
+ sender: this.getSenderAddress().toString(),
1116
+ },
1117
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
852
1118
  },
853
- });
1119
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1120
+ );
854
1121
  throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, { cause: viemError });
855
1122
  }
856
1123
  }
@@ -864,7 +1131,11 @@ export class SequencerPublisher {
864
1131
  const logData = { ...checkpoint, reason };
865
1132
  this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
866
1133
 
867
- const attestationsAndSigners = CommitteeAttestationsAndSigners.packAttestations(validationResult.attestations);
1134
+ // Use the exact packed tuple posted to L1 verbatim. A repack via `packAttestations` is not a
1135
+ // byte-faithful inverse of `fromPacked` (a canonicalized yParity byte or an all-zero signature slot
1136
+ // round-trips differently), so it would diverge from the stored `attestationsHash` and revert the
1137
+ // invalidation.
1138
+ const attestationsAndSigners = validationResult.verbatimAttestations;
868
1139
 
869
1140
  if (reason === 'invalid-attestation') {
870
1141
  return this.rollupContract.buildInvalidateBadAttestationRequest(
@@ -904,6 +1175,19 @@ export class SequencerPublisher {
904
1175
  this.log.warn(`Cannot enqueue vote cast signal ${signalType} for address zero at slot ${slotNumber}`);
905
1176
  return false;
906
1177
  }
1178
+
1179
+ const canonicalRollup = await base.getRollupAddress();
1180
+ if (!canonicalRollup.equals(EthAddress.fromString(this.rollupContract.address))) {
1181
+ this.log.warn(`Rollup ${this.rollupContract.address} is not canonical, skipping governance signal`, {
1182
+ slotNumber,
1183
+ signalType,
1184
+ canonicalRollup,
1185
+ targetRollup: this.rollupContract.address,
1186
+ payload: payload.toString(),
1187
+ });
1188
+ return false;
1189
+ }
1190
+
907
1191
  const round = await base.computeRound(slotNumber);
908
1192
  const roundInfo = await base.getRoundInfo(this.rollupContract.address, round);
909
1193
 
@@ -920,23 +1204,38 @@ export class SequencerPublisher {
920
1204
  return false;
921
1205
  }
922
1206
 
923
- // Skip signaling if there is already a live (non-terminal) Governance proposal for this
924
- // payload. This is intentionally not cached: a previously-live proposal may transition to
925
- // a terminal state (Dropped/Rejected/Expired/Executed), at which point we may want to re-signal
926
- // the same payload in a future round.
927
- let proposed = false;
1207
+ // Classify the payload against the Governance proposal history so we stop signalling once its
1208
+ // proposal is live or was already executed, while still re-signalling one whose proposal was
1209
+ // merely rejected/dropped/expired.
1210
+ let status: PayloadProposalStatus = 'none';
928
1211
  try {
929
- proposed = await base.hasActiveProposalWithPayload(payload.toString());
1212
+ status = await base.getPayloadProposalStatus(payload.toString());
930
1213
  } catch (err) {
931
1214
  // We deliberately swallow the error and proceed to signal. Failing closed (skipping the
932
1215
  // signal) on transient RPC errors would let a flaky L1 endpoint silence governance
933
1216
  // participation entirely; failing open at worst produces a duplicate signal that the
934
1217
  // contract will simply count alongside others in the round.
935
- this.log.error(`Failed to check if payload ${payload} was already proposed (signalling anyway)`, err);
1218
+ this.log.error(`Failed to check governance proposal status for payload ${payload} (signalling anyway)`, err, {
1219
+ slotNumber,
1220
+ signalType,
1221
+ });
936
1222
  }
937
1223
 
938
- if (proposed) {
939
- this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`);
1224
+ if (status === 'live') {
1225
+ this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`, {
1226
+ slotNumber,
1227
+ signalType,
1228
+ payload: payload.toString(),
1229
+ });
1230
+ return false;
1231
+ }
1232
+
1233
+ if (status === 'executed' && !this.config.governanceProposerForcePayloadVote) {
1234
+ this.log.info(
1235
+ `Payload ${payload} was executed by governance within lookback, stopping signals ` +
1236
+ `(set GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE to re-signal)`,
1237
+ { slotNumber, signalType, payload: payload.toString() },
1238
+ );
940
1239
  return false;
941
1240
  }
942
1241
 
@@ -1013,6 +1312,47 @@ export class SequencerPublisher {
1013
1312
  );
1014
1313
  }
1015
1314
 
1315
+ /**
1316
+ * Enqueues a `prune()` transaction if the rollup is prunable at the given slot's L1 timestamp.
1317
+ * `prune()` is permissionless and idempotent — if the chain is no longer prunable by send time the
1318
+ * bundle simulation usually drops the entry; on a node without `eth_simulateV1` the bundle is sent
1319
+ * as-is and the prune reverts `Rollup__NothingToPrune` inside `aggregate3(allowFailure: true)`
1320
+ * (a failed action, never a whole-tx revert). Used by the failed-sync fallback so a stuck pending
1321
+ * chain (e.g. bad data blocking sync) can be wound back to recover.
1322
+ * @returns true if a prune request was enqueued, false otherwise.
1323
+ */
1324
+ public async enqueuePruneIfPrunable(slotNumber: SlotNumber): Promise<boolean> {
1325
+ if (this.lastActions['prune'] === slotNumber) {
1326
+ this.log.debug(`Skipping duplicate prune for slot ${slotNumber}`, { slotNumber });
1327
+ return false;
1328
+ }
1329
+ // Use the SAME timestamp the bundle simulator overrides block.timestamp with at send time
1330
+ // (sequencer-bundle-simulator.ts) so this upfront check and the send-time sim agree. Slot-start
1331
+ // and last-L1-slot both fall within the same L2 slot (and epoch, which is what `canPruneAtTime`
1332
+ // derives), so they agree today; matching the simulator keeps it robust if the contract ever uses
1333
+ // the timestamp more granularly.
1334
+ const ts = getLastL1SlotTimestampForL2Slot(slotNumber, this.epochCache.getL1Constants());
1335
+ const canPrune = await this.rollupContract.canPruneAtTime(ts).catch(err => {
1336
+ this.log.error(`Failed to check canPruneAtTime for slot ${slotNumber}`, err, { slotNumber });
1337
+ return false;
1338
+ });
1339
+ if (!canPrune) {
1340
+ this.log.debug(`Rollup not prunable at slot ${slotNumber}`, { slotNumber });
1341
+ return false;
1342
+ }
1343
+ const request: L1TxRequest = {
1344
+ to: this.rollupContract.address,
1345
+ data: encodeFunctionData({ abi: RollupAbi, functionName: 'prune', args: [] }),
1346
+ };
1347
+ this.log.info(`Enqueuing rollup prune for slot ${slotNumber}`, { slotNumber });
1348
+ return this.enqueueRequest(
1349
+ 'prune',
1350
+ request,
1351
+ { address: this.rollupContract.address, abi: RollupAbi, eventName: 'PrunedPending' },
1352
+ slotNumber,
1353
+ );
1354
+ }
1355
+
1016
1356
  /** Enqueues all slashing actions as returned by the slasher client. */
1017
1357
  public async enqueueSlashingActions(
1018
1358
  actions: ProposerSlashAction[],
@@ -1251,18 +1591,22 @@ export class SequencerPublisher {
1251
1591
  args: [blobInput],
1252
1592
  });
1253
1593
  const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1254
- this.backupFailedTx({
1255
- id: keccak256(validateBlobsData),
1256
- failureType: 'simulation',
1257
- request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1258
- blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1259
- l1BlockNumber: l1BlockNumber.toString(),
1260
- error: { message: viemError.message, name: viemError.name },
1261
- context: {
1262
- actions: ['validate-blobs'],
1263
- sender: this.getSenderAddress().toString(),
1594
+ this.backupFailedTx(
1595
+ {
1596
+ id: keccak256(validateBlobsData),
1597
+ failureType: 'simulation',
1598
+ request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1599
+ blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1600
+ l1BlockNumber,
1601
+ error: { message: viemError.message, name: viemError.name },
1602
+ context: {
1603
+ actions: ['validate-blobs'],
1604
+ sender: this.getSenderAddress().toString(),
1605
+ },
1606
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
1264
1607
  },
1265
- });
1608
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1609
+ );
1266
1610
  throw new Error('Failed to validate blobs');
1267
1611
  });
1268
1612
  }