@aztec/sequencer-client 0.0.1-commit.2f68f620 → 0.0.1-commit.321f6a9

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 (101) hide show
  1. package/README.md +27 -27
  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 +17 -12
  5. package/dest/config.d.ts +6 -2
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +23 -14
  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 +34 -6
  36. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  37. package/dest/publisher/sequencer-publisher.js +362 -61
  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 +199 -18
  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 +24 -16
  51. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  52. package/dest/sequencer/checkpoint_proposal_job.js +98 -90
  53. package/dest/sequencer/events.d.ts +16 -5
  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/requests_tracker.d.ts +22 -0
  59. package/dest/sequencer/requests_tracker.d.ts.map +1 -0
  60. package/dest/sequencer/requests_tracker.js +33 -0
  61. package/dest/sequencer/sequencer.d.ts +110 -36
  62. package/dest/sequencer/sequencer.d.ts.map +1 -1
  63. package/dest/sequencer/sequencer.js +329 -174
  64. package/dest/test/index.d.ts +3 -3
  65. package/dest/test/index.d.ts.map +1 -1
  66. package/dest/test/utils.d.ts +15 -1
  67. package/dest/test/utils.d.ts.map +1 -1
  68. package/dest/test/utils.js +18 -1
  69. package/package.json +30 -28
  70. package/src/client/sequencer-client.ts +20 -13
  71. package/src/config.ts +25 -12
  72. package/src/global_variable_builder/fee_predictor.ts +11 -1
  73. package/src/global_variable_builder/fee_provider.ts +27 -5
  74. package/src/global_variable_builder/global_builder.ts +2 -34
  75. package/src/index.ts +1 -10
  76. package/src/publisher/config.ts +22 -1
  77. package/src/publisher/l1_to_l2_messaging.ts +85 -0
  78. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +114 -7
  79. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +4 -3
  80. package/src/publisher/l1_tx_failed_store/index.ts +1 -1
  81. package/src/publisher/sequencer-publisher.ts +383 -72
  82. package/src/publisher/write_json.ts +78 -0
  83. package/src/sequencer/automine/README.md +18 -4
  84. package/src/sequencer/automine/automine_factory.ts +8 -1
  85. package/src/sequencer/automine/automine_sequencer.ts +221 -20
  86. package/src/sequencer/automine/index.ts +6 -0
  87. package/src/sequencer/checkpoint_proposal_job.ts +119 -100
  88. package/src/sequencer/events.ts +16 -4
  89. package/src/sequencer/index.ts +0 -2
  90. package/src/sequencer/requests_tracker.ts +43 -0
  91. package/src/sequencer/sequencer.ts +360 -187
  92. package/src/test/index.ts +2 -2
  93. package/src/test/utils.ts +33 -0
  94. package/dest/sequencer/chain_state_overrides.d.ts +0 -61
  95. package/dest/sequencer/chain_state_overrides.d.ts.map +0 -1
  96. package/dest/sequencer/chain_state_overrides.js +0 -98
  97. package/dest/sequencer/timetable.d.ts +0 -98
  98. package/dest/sequencer/timetable.d.ts.map +0 -1
  99. package/dest/sequencer/timetable.js +0 -227
  100. package/src/sequencer/chain_state_overrides.ts +0 -162
  101. package/src/sequencer/timetable.ts +0 -288
@@ -13,11 +13,12 @@ import {
13
13
  type SlashingProposerContract,
14
14
  buildSimulationOverridesStateOverride,
15
15
  } from '@aztec/ethereum/contracts';
16
- import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
16
+ import { type L1FeeAnalysisResult, L1FeeAnalyzer, captureWindowBlockFees } from '@aztec/ethereum/l1-fee-analysis';
17
17
  import {
18
18
  type L1BlobInputs,
19
19
  type L1TxConfig,
20
20
  type L1TxRequest,
21
+ L1TxTimeoutError,
21
22
  type L1TxUtils,
22
23
  MAX_L1_TX_LIMIT,
23
24
  type TransactionStats,
@@ -117,6 +118,7 @@ type L1ProcessArgs = {
117
118
  export const Actions = [
118
119
  'invalidate-by-invalid-attestation',
119
120
  'invalidate-by-insufficient-attestations',
121
+ 'prune',
120
122
  'propose',
121
123
  'governance-signal',
122
124
  'vote-offenses',
@@ -177,6 +179,8 @@ export class SequencerPublisher {
177
179
  protected log: Logger;
178
180
  protected ethereumSlotDuration: bigint;
179
181
  protected aztecSlotDuration: bigint;
182
+ private readonly previousL1BlockWaitTimeoutMs: number;
183
+ private readonly previousL1BlockWaitPollIntervalMs: number;
180
184
 
181
185
  /** Date provider for wall-clock time. */
182
186
  private readonly dateProvider: DateProvider;
@@ -205,7 +209,13 @@ export class SequencerPublisher {
205
209
  protected requests: RequestWithExpiry[] = [];
206
210
 
207
211
  constructor(
208
- private config: Pick<SequencerPublisherConfig, 'fishermanMode' | 'l1TxFailedStore'> &
212
+ private config: Pick<
213
+ SequencerPublisherConfig,
214
+ | 'fishermanMode'
215
+ | 'l1TxFailedStore'
216
+ | 'sequencerPublisherPreviousL1BlockWaitTimeoutMs'
217
+ | 'sequencerPublisherPreviousL1BlockWaitPollIntervalMs'
218
+ > &
209
219
  Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'> & { l1ChainId: number },
210
220
  deps: {
211
221
  telemetry?: TelemetryClient;
@@ -225,6 +235,8 @@ export class SequencerPublisher {
225
235
  this.log = deps.log ?? createLogger('sequencer:publisher');
226
236
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
227
237
  this.aztecSlotDuration = BigInt(config.aztecSlotDuration);
238
+ this.previousL1BlockWaitTimeoutMs = config.sequencerPublisherPreviousL1BlockWaitTimeoutMs;
239
+ this.previousL1BlockWaitPollIntervalMs = config.sequencerPublisherPreviousL1BlockWaitPollIntervalMs;
228
240
  this.dateProvider = deps.dateProvider;
229
241
  this.epochCache = deps.epochCache;
230
242
  this.lastActions = deps.lastActions;
@@ -276,25 +288,97 @@ export class SequencerPublisher {
276
288
  }
277
289
 
278
290
  /**
279
- * Backs up a failed L1 transaction to the configured store for debugging.
280
- * Does nothing if no store is configured.
291
+ * Logs the gas-pricing data of a failed L1 transaction at warn so underpricing is diagnosable
292
+ * from logs even with no failed-tx store configured — and backs the record up to the store when
293
+ * one is. When captureFeeSummary is true, also records the fee data of the already-mined L1
294
+ * blocks in the target slot's inclusion window.
281
295
  */
282
- private backupFailedTx(failedTx: Omit<FailedL1Tx, 'timestamp'>): void {
283
- if (!this.failedTxStore) {
284
- return;
285
- }
286
-
296
+ private backupFailedTx(
297
+ failedTx: Omit<FailedL1Tx, 'timestamp'>,
298
+ opts?: { captureFeeSummary?: boolean; targetSlot?: SlotNumber; sharedFeeSummary?: FailedL1Tx['gasInfo'] },
299
+ ): void {
287
300
  const tx: FailedL1Tx = {
288
301
  ...failedTx,
289
302
  timestamp: Date.now(),
290
303
  };
291
304
 
292
305
  // Fire and forget - don't block on backup
293
- void this.failedTxStore
294
- .then(store => store?.saveFailedTx(tx))
295
- .catch(err => {
306
+ void (async () => {
307
+ try {
308
+ // Prefer a pre-captured summary (shared across a batch of failures in the same slot) so we
309
+ // don't re-read the fee window per record. A capture error must not lose the record itself.
310
+ const feeSummary =
311
+ opts?.sharedFeeSummary ??
312
+ (opts?.captureFeeSummary
313
+ ? await this.captureFeeEnvironment(opts.targetSlot).catch(() => undefined)
314
+ : undefined);
315
+ if (feeSummary) {
316
+ tx.gasInfo = { ...tx.gasInfo, ...feeSummary };
317
+ }
318
+ if (tx.gasInfo) {
319
+ this.log.warn(`Gas pricing data for failed L1 tx (${tx.failureType})`, {
320
+ failureType: tx.failureType,
321
+ actions: tx.context.actions,
322
+ slot: tx.context.slot,
323
+ ...tx.gasInfo,
324
+ ...tx.timing,
325
+ });
326
+ }
327
+ const store = await this.failedTxStore;
328
+ if (store) {
329
+ await store.saveFailedTx(tx);
330
+ }
331
+ } catch (err) {
296
332
  this.log.warn(`Failed to backup failed L1 tx to store`, err);
297
- });
333
+ }
334
+ })();
335
+ }
336
+
337
+ /**
338
+ * Captures per-block fee data for the L1 blocks in the target slot's inclusion window (the blocks the
339
+ * tx could have landed in) for underpricing diagnostics. Reads only already-mined blocks, so it never
340
+ * waits on the chain. Safe to call off the critical path: the underlying capture never throws, and this
341
+ * returns undefined when there is no target slot or the window is not yet mined (e.g. an early send
342
+ * failure), in which case the record simply carries no window data.
343
+ */
344
+ private async captureFeeEnvironment(targetL2Slot: SlotNumber | undefined): Promise<FailedL1Tx['gasInfo']> {
345
+ if (targetL2Slot === undefined) {
346
+ return undefined;
347
+ }
348
+ const l1Constants = this.epochCache.getL1Constants();
349
+ // The inclusion window is [start of slot N, start of slot N+1): all L1 blocks that can include a tx
350
+ // for this L2 slot. getTimestampForSlot returns seconds, matching block.timestamp.
351
+ const windowStartS = getTimestampForSlot(targetL2Slot, l1Constants);
352
+ const windowEndS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
353
+ const windowBlocks = await captureWindowBlockFees(this.l1TxUtils.client, windowStartS, windowEndS);
354
+ if (windowBlocks.length === 0) {
355
+ return undefined;
356
+ }
357
+ return { windowBlocks };
358
+ }
359
+
360
+ /** Computes timing info relative to the L2 slot deadline. */
361
+ private computeTimingInfo(targetL2Slot: SlotNumber | undefined): FailedL1Tx['timing'] {
362
+ if (targetL2Slot === undefined) {
363
+ return undefined;
364
+ }
365
+ const l1Constants = this.epochCache.getL1Constants();
366
+ const slotDeadlineS = getTimestampForSlot(SlotNumber(Number(targetL2Slot) + 1), l1Constants);
367
+ const slotDeadlineMs = Number(slotDeadlineS) * 1000;
368
+ return {
369
+ targetL2Slot: Number(targetL2Slot),
370
+ slotDeadlineTimestampS: slotDeadlineS,
371
+ msUntilSlotDeadline: slotDeadlineMs - this.dateProvider.now(),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Builds an id for a synthetic failure record (send-error/timeout) that has no on-chain tx hash.
377
+ * Includes the failure time so each attempt — including retries of the same slot — is stored as its
378
+ * own record rather than overwriting the previous one.
379
+ */
380
+ private failureRecordId(actions: string[], targetSlot: SlotNumber | undefined): Hex {
381
+ return keccak256(toHex(`${actions.join(',')}:${targetSlot ?? ''}:${Date.now()}`));
298
382
  }
299
383
 
300
384
  public getRollupContract(): RollupContract {
@@ -458,7 +542,9 @@ export class SequencerPublisher {
458
542
 
459
543
  if (bundleResult.kind === 'aborted') {
460
544
  this.logDroppedInSim(bundleResult.droppedRequests);
461
- void this.backupDroppedInSim(bundleResult.droppedRequests);
545
+ void this.backupDroppedInSim(bundleResult.droppedRequests, currentL2Slot).catch(err =>
546
+ this.log.error(`Failed to backup requests dropped in simulation`, err),
547
+ );
462
548
  return undefined;
463
549
  }
464
550
 
@@ -482,7 +568,7 @@ export class SequencerPublisher {
482
568
  requests: requests.map(request => request.action),
483
569
  txConfig,
484
570
  });
485
- const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
571
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig, currentL2Slot);
486
572
  if (result === undefined) {
487
573
  return undefined;
488
574
  }
@@ -498,6 +584,44 @@ export class SequencerPublisher {
498
584
  } catch (err) {
499
585
  const viemError = formatViemError(err);
500
586
  this.log.error(`Failed to publish bundled transactions`, viemError);
587
+ if (err instanceof TimeoutError) {
588
+ const timeoutState = err instanceof L1TxTimeoutError ? err.txState : undefined;
589
+ void (async () => {
590
+ // The RPC is likely degraded right after a timeout, so back up without the block number
591
+ // rather than leaking an unhandled rejection.
592
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber().catch(() => 0n);
593
+ this.backupFailedTx(
594
+ {
595
+ id: this.failureRecordId(
596
+ validRequests.map(r => r.action),
597
+ currentL2Slot,
598
+ ),
599
+ failureType: 'timeout',
600
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
601
+ l1BlockNumber,
602
+ error: { message: viemError.message, name: 'TimeoutError' },
603
+ context: {
604
+ actions: validRequests.map(r => r.action),
605
+ requests: validRequests
606
+ .filter(r => r.request.to !== null)
607
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
608
+ sender: this.getSenderAddress().toString(),
609
+ slot: Number(currentL2Slot),
610
+ },
611
+ timing: this.computeTimingInfo(currentL2Slot),
612
+ gasInfo: timeoutState
613
+ ? {
614
+ sentGasPriceLadder: timeoutState.gasPriceHistory,
615
+ attempts: timeoutState.attempts,
616
+ gasLimit: timeoutState.gasLimit,
617
+ nonce: timeoutState.nonce,
618
+ }
619
+ : undefined,
620
+ },
621
+ { captureFeeSummary: true, targetSlot: currentL2Slot },
622
+ );
623
+ })();
624
+ }
501
625
  return undefined;
502
626
  } finally {
503
627
  try {
@@ -525,23 +649,37 @@ export class SequencerPublisher {
525
649
  }
526
650
 
527
651
  /** Backs up entries dropped by bundle simulation, one record per dropped action. */
528
- private async backupDroppedInSim(dropped: DroppedRequest[]): Promise<void> {
652
+ private async backupDroppedInSim(dropped: DroppedRequest[], targetSlot?: SlotNumber): Promise<void> {
529
653
  if (dropped.length === 0) {
530
654
  return;
531
655
  }
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
- });
656
+ // Invoked as `void backupDroppedInSim(...)` on the publish path, so it must not throw.
657
+ try {
658
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
659
+ // Every dropped entry failed in the same slot against the same L1 fee conditions, so capture
660
+ // the fee environment once and share it rather than re-reading the window per entry.
661
+ const sharedFeeSummary = await this.captureFeeEnvironment(targetSlot).catch(() => undefined);
662
+ const timing = this.computeTimingInfo(targetSlot);
663
+ for (const { request: req } of dropped) {
664
+ this.backupFailedTx(
665
+ {
666
+ id: keccak256(req.request.data!),
667
+ failureType: 'simulation',
668
+ request: { to: req.request.to! as Hex, data: req.request.data! },
669
+ l1BlockNumber,
670
+ error: { message: 'Bundle entry dropped: action reverted in sim' },
671
+ context: {
672
+ actions: [req.action],
673
+ sender: this.getSenderAddress().toString(),
674
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
675
+ },
676
+ timing,
677
+ },
678
+ { sharedFeeSummary },
679
+ );
680
+ }
681
+ } catch (err) {
682
+ this.log.warn(`Failed to back up dropped-in-sim entries`, err);
545
683
  }
546
684
  }
547
685
 
@@ -554,6 +692,7 @@ export class SequencerPublisher {
554
692
  validRequests: RequestWithExpiry[],
555
693
  txConfig: RequestWithExpiry['gasConfig'],
556
694
  blobConfig: L1BlobInputs | undefined,
695
+ targetSlot?: SlotNumber,
557
696
  ) {
558
697
  if (!txConfig?.gasLimit) {
559
698
  throw new Error('gasLimit is required for bundled transactions');
@@ -590,11 +729,13 @@ export class SequencerPublisher {
590
729
  this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
591
730
  transactionHash: err.receipt.transactionHash,
592
731
  });
732
+ this.backupRevertFailure(validRequests, err, currentPublisher, targetSlot);
593
733
  return undefined;
594
734
  }
595
735
  const viemError = formatViemError(err);
596
736
  if (!this.getNextPublisher) {
597
737
  this.log.error('Failed to publish bundled transactions', viemError);
738
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
598
739
  return undefined;
599
740
  }
600
741
  this.log.warn(
@@ -608,6 +749,7 @@ export class SequencerPublisher {
608
749
  viemError,
609
750
  { triedAddresses: triedAddresses.map(a => a.toString()) },
610
751
  );
752
+ this.backupSendFailure(validRequests, viemError, currentPublisher, targetSlot);
611
753
  return undefined;
612
754
  }
613
755
  currentPublisher = nextPublisher;
@@ -615,36 +757,152 @@ export class SequencerPublisher {
615
757
  }
616
758
  }
617
759
 
760
+ /** Backs up an on-chain revert failure to the failed tx store. */
761
+ private backupRevertFailure(
762
+ requests: RequestWithExpiry[],
763
+ err: MulticallForwarderRevertedError,
764
+ publisher: L1TxUtils,
765
+ targetSlot?: SlotNumber,
766
+ ): void {
767
+ this.backupFailedTx(
768
+ {
769
+ id: err.receipt.transactionHash,
770
+ failureType: 'revert',
771
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
772
+ l1BlockNumber: err.receipt.blockNumber,
773
+ receipt: {
774
+ transactionHash: err.receipt.transactionHash,
775
+ blockNumber: err.receipt.blockNumber,
776
+ gasUsed: err.receipt.gasUsed,
777
+ status: 'reverted',
778
+ },
779
+ error: { message: err.message, name: err.name },
780
+ context: {
781
+ actions: requests.map(r => r.action),
782
+ requests: requests
783
+ .filter(r => r.request.to !== null)
784
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
785
+ sender: publisher.getSenderAddress().toString(),
786
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
787
+ },
788
+ gasInfo: err.txState
789
+ ? {
790
+ sentGasPrice: err.txState.gasPrice,
791
+ gasLimit: err.txState.gasLimit,
792
+ nonce: err.txState.nonce,
793
+ }
794
+ : undefined,
795
+ timing: this.computeTimingInfo(targetSlot),
796
+ },
797
+ { captureFeeSummary: true, targetSlot },
798
+ );
799
+ }
800
+
801
+ /** Backs up a send failure (tx never reached chain) to the failed tx store. */
802
+ private backupSendFailure(
803
+ requests: RequestWithExpiry[],
804
+ error: FormattedViemError | Error,
805
+ publisher: L1TxUtils,
806
+ targetSlot?: SlotNumber,
807
+ ): void {
808
+ // If we can't get the block number, still back up without it.
809
+ void this.l1TxUtils
810
+ .getBlockNumber()
811
+ .catch(() => 0n)
812
+ .then(l1BlockNumber => {
813
+ this.backupFailedTx(
814
+ {
815
+ id: this.failureRecordId(
816
+ requests.map(r => r.action),
817
+ targetSlot,
818
+ ),
819
+ failureType: 'send-error',
820
+ request: { to: MULTI_CALL_3_ADDRESS as Hex, data: '0x' as Hex },
821
+ l1BlockNumber,
822
+ error: {
823
+ message: error.message,
824
+ name: 'name' in error ? error.name : undefined,
825
+ },
826
+ context: {
827
+ actions: requests.map(r => r.action),
828
+ requests: requests
829
+ .filter(r => r.request.to !== null)
830
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
831
+ sender: publisher.getSenderAddress().toString(),
832
+ slot: targetSlot !== undefined ? Number(targetSlot) : undefined,
833
+ },
834
+ timing: this.computeTimingInfo(targetSlot),
835
+ },
836
+ { captureFeeSummary: true, targetSlot },
837
+ );
838
+ });
839
+ }
840
+
618
841
  /*
619
842
  * 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
843
  */
626
844
  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;
845
+ await this.waitForTargetSlot(targetSlot);
633
846
  if (this.interrupted) {
634
847
  return undefined;
635
848
  }
636
- const sleepMs = submitAfterMs - this.dateProvider.now();
637
- if (sleepMs > 0) {
638
- this.log.debug(`Sleeping ${sleepMs}ms before sending requests`, {
639
- targetSlot,
640
- submitAfterMs,
641
- });
849
+
850
+ return this.sendRequests(targetSlot);
851
+ }
852
+
853
+ /**
854
+ * Sleeps until one L1 slot before the L2 slot boundary, and then waits for that L1 block
855
+ * to be mined, so we don't risk being included in it. If that block never gets mined after
856
+ * a timeout, we assume it got skipped on L1, so we send the tx anyway.
857
+ */
858
+ private async waitForTargetSlot(targetSlot: SlotNumber): Promise<void> {
859
+ const l1Constants = this.epochCache.getL1Constants();
860
+ const nowInSeconds = this.dateProvider.nowInSeconds();
861
+ const startOfTargetSlotTs = getTimestampForSlot(targetSlot, l1Constants);
862
+ const previousL1BlockTs = startOfTargetSlotTs - this.ethereumSlotDuration;
863
+ const waitDeadlineTs = previousL1BlockTs + BigInt(this.previousL1BlockWaitTimeoutMs / 1000);
864
+ const logCtx = { targetSlot, startOfTargetSlotTs, nowInSeconds, previousL1BlockTs, waitDeadlineTs };
865
+
866
+ // Check if we are already past time
867
+ if (nowInSeconds >= startOfTargetSlotTs) {
868
+ this.log.verbose(`Target slot ${targetSlot} already started, sending requests immediately`, logCtx);
869
+ return;
870
+ }
871
+
872
+ // Otherwise we wait
873
+ this.log.debug(`Waiting for slot ${targetSlot} before sending requests`, logCtx);
874
+
875
+ // Wait until previous L1 block timestamp first
876
+ const sleepMs = (Number(previousL1BlockTs) - nowInSeconds) * 1000;
877
+ if (sleepMs > 0 && !this.interrupted) {
878
+ this.log.trace(`Sleeping ${sleepMs}ms before waiting for previous L1 block`, logCtx);
642
879
  await this.interruptibleSleep.sleep(sleepMs);
643
880
  }
644
- if (this.interrupted) {
645
- return undefined;
881
+
882
+ // Then loop until we see the previous L1 block, so we know that we cannot be included in it.
883
+ // We time out after a while, once we are sure that that block is skipped in L1.
884
+ while (!this.interrupted) {
885
+ try {
886
+ const nowInSeconds = this.dateProvider.nowInSeconds();
887
+ logCtx.nowInSeconds = nowInSeconds;
888
+
889
+ if (nowInSeconds >= waitDeadlineTs) {
890
+ this.log.warn(`Timed out waiting for previous L1 block before sending requests, proceeding`, logCtx);
891
+ return;
892
+ }
893
+
894
+ const latestBlockTs = await this.l1TxUtils.getBlock().then(b => b.timestamp);
895
+ if (latestBlockTs >= previousL1BlockTs) {
896
+ this.log.debug(`Previous L1 block mined, proceeding to send requests`, { ...logCtx, latestBlockTs });
897
+ return;
898
+ }
899
+ this.log.trace(`Previous L1 block not mined yet, continuing to wait`, { ...logCtx, latestBlockTs });
900
+ } catch (err) {
901
+ this.log.error(`Error while waiting for previous L1 block before sending requests; retrying`, err, logCtx);
902
+ } finally {
903
+ await this.interruptibleSleep.sleep(this.previousL1BlockWaitPollIntervalMs);
904
+ }
646
905
  }
647
- return this.sendRequests(targetSlot);
648
906
  }
649
907
 
650
908
  private callbackBundledTransactions(
@@ -841,18 +1099,22 @@ export class SequencerPublisher {
841
1099
 
842
1100
  // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
843
1101
  this.log.error(`Simulation for invalidate checkpoint ${checkpointNumber} failed`, viemError, logData);
844
- this.backupFailedTx({
845
- id: keccak256(request.data!),
846
- failureType: 'simulation',
847
- request: { to: request.to!, data: request.data!, value: request.value?.toString() },
848
- l1BlockNumber: l1BlockNumber.toString(),
849
- error: { message: viemError.message, name: viemError.name },
850
- context: {
851
- actions: [`invalidate-${reason}`],
852
- checkpointNumber,
853
- sender: this.getSenderAddress().toString(),
1102
+ this.backupFailedTx(
1103
+ {
1104
+ id: keccak256(request.data!),
1105
+ failureType: 'simulation',
1106
+ request: { to: request.to!, data: request.data!, value: request.value },
1107
+ l1BlockNumber,
1108
+ error: { message: viemError.message, name: viemError.name },
1109
+ context: {
1110
+ actions: [`invalidate-${reason}`],
1111
+ checkpointNumber,
1112
+ sender: this.getSenderAddress().toString(),
1113
+ },
1114
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
854
1115
  },
855
- });
1116
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1117
+ );
856
1118
  throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, { cause: viemError });
857
1119
  }
858
1120
  }
@@ -866,7 +1128,11 @@ export class SequencerPublisher {
866
1128
  const logData = { ...checkpoint, reason };
867
1129
  this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
868
1130
 
869
- const attestationsAndSigners = CommitteeAttestationsAndSigners.packAttestations(validationResult.attestations);
1131
+ // Use the exact packed tuple posted to L1 verbatim. A repack via `packAttestations` is not a
1132
+ // byte-faithful inverse of `fromPacked` (a canonicalized yParity byte or an all-zero signature slot
1133
+ // round-trips differently), so it would diverge from the stored `attestationsHash` and revert the
1134
+ // invalidation.
1135
+ const attestationsAndSigners = validationResult.verbatimAttestations;
870
1136
 
871
1137
  if (reason === 'invalid-attestation') {
872
1138
  return this.rollupContract.buildInvalidateBadAttestationRequest(
@@ -1015,6 +1281,47 @@ export class SequencerPublisher {
1015
1281
  );
1016
1282
  }
1017
1283
 
1284
+ /**
1285
+ * Enqueues a `prune()` transaction if the rollup is prunable at the given slot's L1 timestamp.
1286
+ * `prune()` is permissionless and idempotent — if the chain is no longer prunable by send time the
1287
+ * bundle simulation usually drops the entry; on a node without `eth_simulateV1` the bundle is sent
1288
+ * as-is and the prune reverts `Rollup__NothingToPrune` inside `aggregate3(allowFailure: true)`
1289
+ * (a failed action, never a whole-tx revert). Used by the failed-sync fallback so a stuck pending
1290
+ * chain (e.g. bad data blocking sync) can be wound back to recover.
1291
+ * @returns true if a prune request was enqueued, false otherwise.
1292
+ */
1293
+ public async enqueuePruneIfPrunable(slotNumber: SlotNumber): Promise<boolean> {
1294
+ if (this.lastActions['prune'] === slotNumber) {
1295
+ this.log.debug(`Skipping duplicate prune for slot ${slotNumber}`, { slotNumber });
1296
+ return false;
1297
+ }
1298
+ // Use the SAME timestamp the bundle simulator overrides block.timestamp with at send time
1299
+ // (sequencer-bundle-simulator.ts) so this upfront check and the send-time sim agree. Slot-start
1300
+ // and last-L1-slot both fall within the same L2 slot (and epoch, which is what `canPruneAtTime`
1301
+ // derives), so they agree today; matching the simulator keeps it robust if the contract ever uses
1302
+ // the timestamp more granularly.
1303
+ const ts = getLastL1SlotTimestampForL2Slot(slotNumber, this.epochCache.getL1Constants());
1304
+ const canPrune = await this.rollupContract.canPruneAtTime(ts).catch(err => {
1305
+ this.log.error(`Failed to check canPruneAtTime for slot ${slotNumber}`, err, { slotNumber });
1306
+ return false;
1307
+ });
1308
+ if (!canPrune) {
1309
+ this.log.debug(`Rollup not prunable at slot ${slotNumber}`, { slotNumber });
1310
+ return false;
1311
+ }
1312
+ const request: L1TxRequest = {
1313
+ to: this.rollupContract.address,
1314
+ data: encodeFunctionData({ abi: RollupAbi, functionName: 'prune', args: [] }),
1315
+ };
1316
+ this.log.info(`Enqueuing rollup prune for slot ${slotNumber}`, { slotNumber });
1317
+ return this.enqueueRequest(
1318
+ 'prune',
1319
+ request,
1320
+ { address: this.rollupContract.address, abi: RollupAbi, eventName: 'PrunedPending' },
1321
+ slotNumber,
1322
+ );
1323
+ }
1324
+
1018
1325
  /** Enqueues all slashing actions as returned by the slasher client. */
1019
1326
  public async enqueueSlashingActions(
1020
1327
  actions: ProposerSlashAction[],
@@ -1253,18 +1560,22 @@ export class SequencerPublisher {
1253
1560
  args: [blobInput],
1254
1561
  });
1255
1562
  const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1256
- this.backupFailedTx({
1257
- id: keccak256(validateBlobsData),
1258
- failureType: 'simulation',
1259
- request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1260
- blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1261
- l1BlockNumber: l1BlockNumber.toString(),
1262
- error: { message: viemError.message, name: viemError.name },
1263
- context: {
1264
- actions: ['validate-blobs'],
1265
- sender: this.getSenderAddress().toString(),
1563
+ this.backupFailedTx(
1564
+ {
1565
+ id: keccak256(validateBlobsData),
1566
+ failureType: 'simulation',
1567
+ request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1568
+ blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1569
+ l1BlockNumber,
1570
+ error: { message: viemError.message, name: viemError.name },
1571
+ context: {
1572
+ actions: ['validate-blobs'],
1573
+ sender: this.getSenderAddress().toString(),
1574
+ },
1575
+ timing: this.computeTimingInfo(this.getCurrentL2Slot()),
1266
1576
  },
1267
- });
1577
+ { captureFeeSummary: true, targetSlot: this.getCurrentL2Slot() },
1578
+ );
1268
1579
  throw new Error('Failed to validate blobs');
1269
1580
  });
1270
1581
  }