@aztec/sequencer-client 0.0.1-commit.3fd054f6 → 0.0.1-commit.431c48d

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 (113) hide show
  1. package/README.md +283 -21
  2. package/dest/client/sequencer-client.d.ts +20 -5
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +26 -22
  5. package/dest/config.d.ts +13 -4
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +67 -26
  8. package/dest/global_variable_builder/fee_predictor.d.ts +37 -0
  9. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -0
  10. package/dest/global_variable_builder/fee_predictor.js +138 -0
  11. package/dest/global_variable_builder/fee_provider.d.ts +21 -0
  12. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -0
  13. package/dest/global_variable_builder/fee_provider.js +80 -0
  14. package/dest/global_variable_builder/global_builder.d.ts +6 -25
  15. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  16. package/dest/global_variable_builder/global_builder.js +7 -65
  17. package/dest/global_variable_builder/index.d.ts +3 -1
  18. package/dest/global_variable_builder/index.d.ts.map +1 -1
  19. package/dest/global_variable_builder/index.js +2 -0
  20. package/dest/publisher/config.d.ts +7 -3
  21. package/dest/publisher/config.d.ts.map +1 -1
  22. package/dest/publisher/config.js +13 -3
  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 +3 -4
  27. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -1
  28. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  29. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  30. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  31. package/dest/publisher/sequencer-publisher-factory.d.ts +1 -3
  32. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  33. package/dest/publisher/sequencer-publisher-factory.js +0 -1
  34. package/dest/publisher/sequencer-publisher.d.ts +88 -71
  35. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  36. package/dest/publisher/sequencer-publisher.js +434 -548
  37. package/dest/publisher/write_json.d.ts +11 -0
  38. package/dest/publisher/write_json.d.ts.map +1 -0
  39. package/dest/publisher/write_json.js +57 -0
  40. package/dest/sequencer/automine/automine_factory.d.ts +56 -0
  41. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  42. package/dest/sequencer/automine/automine_factory.js +85 -0
  43. package/dest/sequencer/automine/automine_sequencer.d.ts +189 -0
  44. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  45. package/dest/sequencer/automine/automine_sequencer.js +696 -0
  46. package/dest/sequencer/automine/index.d.ts +3 -0
  47. package/dest/sequencer/automine/index.d.ts.map +1 -0
  48. package/dest/sequencer/automine/index.js +2 -0
  49. package/dest/sequencer/checkpoint_proposal_job.d.ts +71 -21
  50. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  51. package/dest/sequencer/checkpoint_proposal_job.js +738 -214
  52. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  53. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  54. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  55. package/dest/sequencer/checkpoint_voter.d.ts +1 -2
  56. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -1
  57. package/dest/sequencer/checkpoint_voter.js +2 -5
  58. package/dest/sequencer/errors.d.ts +1 -8
  59. package/dest/sequencer/errors.d.ts.map +1 -1
  60. package/dest/sequencer/errors.js +0 -9
  61. package/dest/sequencer/events.d.ts +61 -5
  62. package/dest/sequencer/events.d.ts.map +1 -1
  63. package/dest/sequencer/metrics.d.ts +10 -11
  64. package/dest/sequencer/metrics.d.ts.map +1 -1
  65. package/dest/sequencer/metrics.js +34 -20
  66. package/dest/sequencer/requests_tracker.d.ts +22 -0
  67. package/dest/sequencer/requests_tracker.d.ts.map +1 -0
  68. package/dest/sequencer/requests_tracker.js +33 -0
  69. package/dest/sequencer/sequencer.d.ts +157 -33
  70. package/dest/sequencer/sequencer.d.ts.map +1 -1
  71. package/dest/sequencer/sequencer.js +566 -158
  72. package/dest/sequencer/types.d.ts +2 -2
  73. package/dest/sequencer/types.d.ts.map +1 -1
  74. package/dest/test/index.d.ts +3 -3
  75. package/dest/test/index.d.ts.map +1 -1
  76. package/dest/test/utils.d.ts +15 -1
  77. package/dest/test/utils.d.ts.map +1 -1
  78. package/dest/test/utils.js +25 -7
  79. package/package.json +28 -27
  80. package/src/client/sequencer-client.ts +37 -27
  81. package/src/config.ts +79 -26
  82. package/src/global_variable_builder/README.md +44 -0
  83. package/src/global_variable_builder/fee_predictor.ts +182 -0
  84. package/src/global_variable_builder/fee_provider.ts +97 -0
  85. package/src/global_variable_builder/global_builder.ts +12 -80
  86. package/src/global_variable_builder/index.ts +2 -0
  87. package/src/publisher/config.ts +30 -7
  88. package/src/publisher/l1_to_l2_messaging.ts +85 -0
  89. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +3 -1
  90. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  91. package/src/publisher/sequencer-publisher-factory.ts +0 -3
  92. package/src/publisher/sequencer-publisher.ts +494 -596
  93. package/src/publisher/write_json.ts +78 -0
  94. package/src/sequencer/automine/README.md +60 -0
  95. package/src/sequencer/automine/automine_factory.ts +152 -0
  96. package/src/sequencer/automine/automine_sequencer.ts +800 -0
  97. package/src/sequencer/automine/index.ts +6 -0
  98. package/src/sequencer/checkpoint_proposal_job.ts +854 -243
  99. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  100. package/src/sequencer/checkpoint_voter.ts +1 -12
  101. package/src/sequencer/errors.ts +0 -15
  102. package/src/sequencer/events.ts +66 -5
  103. package/src/sequencer/metrics.ts +49 -25
  104. package/src/sequencer/requests_tracker.ts +43 -0
  105. package/src/sequencer/sequencer.ts +646 -175
  106. package/src/sequencer/types.ts +1 -1
  107. package/src/test/index.ts +2 -2
  108. package/src/test/utils.ts +61 -10
  109. package/dest/sequencer/timetable.d.ts +0 -88
  110. package/dest/sequencer/timetable.d.ts.map +0 -1
  111. package/dest/sequencer/timetable.js +0 -222
  112. package/src/sequencer/README.md +0 -531
  113. package/src/sequencer/timetable.ts +0 -283
@@ -370,14 +370,12 @@ function applyDecs2203RFactory() {
370
370
  function _apply_decs_2203_r(targetClass, memberDecs, classDecs, parentClass) {
371
371
  return (_apply_decs_2203_r = applyDecs2203RFactory())(targetClass, memberDecs, classDecs, parentClass);
372
372
  }
373
- var _dec, _dec1, _dec2, _initProto;
373
+ var _dec, _dec1, _initProto;
374
374
  import { Blob, getBlobsPerL1Block, getPrefixedEthBlobCommitments } from '@aztec/blob-lib';
375
- import { FeeAssetPriceOracle, MULTI_CALL_3_ADDRESS, Multicall3, RollupContract } from '@aztec/ethereum/contracts';
375
+ import { FeeAssetPriceOracle, MULTI_CALL_3_ADDRESS, Multicall3, MulticallForwarderRevertedError, buildSimulationOverridesStateOverride } from '@aztec/ethereum/contracts';
376
376
  import { L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
377
377
  import { MAX_L1_TX_LIMIT, WEI_CONST } from '@aztec/ethereum/l1-tx-utils';
378
- import { FormattedViemError, formatViemError, mergeAbis, tryExtractEvent } from '@aztec/ethereum/utils';
379
- import { sumBigint } from '@aztec/foundation/bigint';
380
- import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer';
378
+ import { FormattedViemError, formatViemError, mergeAbis, tryDecodeRevertReason, tryExtractEvent } from '@aztec/ethereum/utils';
381
379
  import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
382
380
  import { trimmedBytesLength } from '@aztec/foundation/buffer';
383
381
  import { pick } from '@aztec/foundation/collection';
@@ -385,31 +383,40 @@ import { TimeoutError } from '@aztec/foundation/error';
385
383
  import { EthAddress } from '@aztec/foundation/eth-address';
386
384
  import { Signature } from '@aztec/foundation/eth-signature';
387
385
  import { createLogger } from '@aztec/foundation/log';
388
- import { makeBackoff, retry } from '@aztec/foundation/retry';
386
+ import { InterruptibleSleep } from '@aztec/foundation/sleep';
389
387
  import { bufferToHex } from '@aztec/foundation/string';
390
388
  import { Timer } from '@aztec/foundation/timer';
391
- import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts';
389
+ import { EmpireBaseAbi, ErrorsAbi, RollupAbi, SlashingProposerAbi } from '@aztec/l1-artifacts';
392
390
  import { encodeSlashConsensusVotes } from '@aztec/slasher';
393
391
  import { CommitteeAttestationsAndSigners } from '@aztec/stdlib/block';
394
- import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers';
392
+ import { getLastL1SlotTimestampForL2Slot, getNextL1SlotTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
395
393
  import { getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
396
- import { encodeFunctionData, keccak256, multicall3Abi, toHex } from 'viem';
394
+ import { encodeFunctionData, keccak256, toHex } from 'viem';
397
395
  import { createL1TxFailedStore } from './l1_tx_failed_store/index.js';
396
+ import { SequencerBundleSimulator } from './sequencer-bundle-simulator.js';
398
397
  import { SequencerPublisherMetrics } from './sequencer-publisher-metrics.js';
398
+ /**
399
+ * Returns true if the receipt indicates a successful send AND the expected event was emitted
400
+ * by the target contract. Both pieces are required: an aggregate3 entry that reverted will
401
+ * have receipt.status === 'success' but no event log.
402
+ */ function extractEventSuccess(receipt, opts) {
403
+ if (!receipt || receipt.status !== 'success') {
404
+ return false;
405
+ }
406
+ return !!tryExtractEvent(receipt.logs, opts.address.toString(), opts.abi, opts.eventName);
407
+ }
399
408
  export const Actions = [
400
409
  'invalidate-by-invalid-attestation',
401
410
  'invalidate-by-insufficient-attestations',
411
+ 'prune',
402
412
  'propose',
403
413
  'governance-signal',
404
- 'empire-slashing-signal',
405
- 'create-empire-payload',
406
- 'execute-empire-payload',
407
414
  'vote-offenses',
408
415
  'execute-slash'
409
416
  ];
410
417
  // Sorting for actions such that invalidations go before proposals, and proposals go before votes
411
418
  export const compareActions = (a, b)=>Actions.indexOf(a) - Actions.indexOf(b);
412
- _dec = trackSpan('SequencerPublisher.sendRequests'), _dec1 = trackSpan('SequencerPublisher.validateBlockHeader'), _dec2 = trackSpan('SequencerPublisher.validateCheckpointForSubmission');
419
+ _dec = trackSpan('SequencerPublisher.sendRequests'), _dec1 = trackSpan('SequencerPublisher.validateCheckpointHeader');
413
420
  export class SequencerPublisher {
414
421
  config;
415
422
  static{
@@ -422,60 +429,60 @@ export class SequencerPublisher {
422
429
  [
423
430
  _dec1,
424
431
  2,
425
- "validateBlockHeader"
426
- ],
427
- [
428
- _dec2,
429
- 2,
430
- "validateCheckpointForSubmission"
432
+ "validateCheckpointHeader"
431
433
  ]
432
434
  ], []));
433
435
  }
434
436
  interrupted;
435
437
  metrics;
438
+ bundleSimulator;
436
439
  epochCache;
437
440
  failedTxStore;
438
- governanceLog;
439
- slashingLog;
441
+ /**
442
+ * ABI used to decode raw revert payloads from dropped bundle entries when the original
443
+ * request did not carry an abi (e.g. the propose request). Merges every contract the
444
+ * publisher can route to so any of their custom errors decode against it.
445
+ */ revertDecoderAbi;
440
446
  lastActions;
441
- isPayloadEmptyCache;
442
- payloadProposedCache;
443
447
  log;
444
448
  ethereumSlotDuration;
445
449
  aztecSlotDuration;
446
- dateProvider;
450
+ previousL1BlockWaitTimeoutMs;
451
+ previousL1BlockWaitPollIntervalMs;
452
+ /** Date provider for wall-clock time. */ dateProvider;
447
453
  blobClient;
448
- /** Address to use for simulations in fisherman mode (actual proposer's address) */ proposerAddressForSimulation;
449
454
  /** Optional callback to obtain a replacement publisher when the current one fails to send. */ getNextPublisher;
450
455
  /** L1 fee analyzer for fisherman mode */ l1FeeAnalyzer;
451
456
  /** Fee asset price oracle for computing price modifiers from Uniswap V4 */ feeAssetPriceOracle;
452
- // A CALL to a cold address is 2700 gas
453
- static MULTICALL_OVERHEAD_GAS_GUESS = 5000n;
454
- // Gas report for VotingWithSigTest shows a max gas of 100k, but we've seen it cost 700k+ in testnet
455
- static VOTE_GAS_GUESS = 800_000n;
457
+ /** Interruptible sleep used by sendRequestsAt to wait until a target timestamp. */ interruptibleSleep;
456
458
  l1TxUtils;
457
459
  rollupContract;
458
460
  govProposerContract;
459
461
  slashingProposerContract;
460
- slashFactoryContract;
461
462
  tracer;
462
463
  requests;
463
464
  constructor(config, deps){
464
465
  this.config = config;
465
466
  this.interrupted = (_initProto(this), false);
466
- this.governanceLog = createLogger('sequencer:publisher:governance');
467
- this.slashingLog = createLogger('sequencer:publisher:slashing');
467
+ this.revertDecoderAbi = mergeAbis([
468
+ RollupAbi,
469
+ SlashingProposerAbi,
470
+ EmpireBaseAbi,
471
+ ErrorsAbi
472
+ ]);
468
473
  this.lastActions = {};
469
- this.isPayloadEmptyCache = new Map();
470
- this.payloadProposedCache = new Set();
474
+ this.interruptibleSleep = new InterruptibleSleep();
471
475
  this.requests = [];
472
476
  this.log = deps.log ?? createLogger('sequencer:publisher');
473
477
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
474
478
  this.aztecSlotDuration = BigInt(config.aztecSlotDuration);
479
+ this.previousL1BlockWaitTimeoutMs = config.sequencerPublisherPreviousL1BlockWaitTimeoutMs;
480
+ this.previousL1BlockWaitPollIntervalMs = config.sequencerPublisherPreviousL1BlockWaitPollIntervalMs;
475
481
  this.dateProvider = deps.dateProvider;
476
482
  this.epochCache = deps.epochCache;
477
483
  this.lastActions = deps.lastActions;
478
484
  this.blobClient = deps.blobClient;
485
+ this.dateProvider = deps.dateProvider;
479
486
  const telemetry = deps.telemetry ?? getTelemetryClient();
480
487
  this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher');
481
488
  this.tracer = telemetry.getTracer('SequencerPublisher');
@@ -489,15 +496,20 @@ export class SequencerPublisher {
489
496
  const newSlashingProposer = await this.rollupContract.getSlashingProposer();
490
497
  this.slashingProposerContract = newSlashingProposer;
491
498
  });
492
- this.slashFactoryContract = deps.slashFactoryContract;
493
499
  // Initialize L1 fee analyzer for fisherman mode
494
500
  if (config.fishermanMode) {
495
- this.l1FeeAnalyzer = new L1FeeAnalyzer(this.l1TxUtils.client, deps.dateProvider, createLogger('sequencer:publisher:fee-analyzer'));
501
+ this.l1FeeAnalyzer = new L1FeeAnalyzer(this.l1TxUtils.client, deps.dateProvider, this.log.createChild('fee-analyzer'));
496
502
  }
497
503
  // Initialize fee asset price oracle
498
- this.feeAssetPriceOracle = new FeeAssetPriceOracle(this.l1TxUtils.client, this.rollupContract, createLogger('sequencer:publisher:price-oracle'));
504
+ this.feeAssetPriceOracle = new FeeAssetPriceOracle(this.l1TxUtils.client, this.rollupContract, this.log.createChild('price-oracle'));
499
505
  // Initialize failed L1 tx store (optional, for test networks)
500
506
  this.failedTxStore = createL1TxFailedStore(config.l1TxFailedStore, this.log);
507
+ this.bundleSimulator = new SequencerBundleSimulator({
508
+ getL1TxUtils: ()=>this.l1TxUtils,
509
+ rollupContract: this.rollupContract,
510
+ epochCache: this.epochCache,
511
+ log: this.log.createChild('bundle-simulator')
512
+ });
501
513
  }
502
514
  /**
503
515
  * Backs up a failed L1 transaction to the configured store for debugging.
@@ -520,9 +532,13 @@ export class SequencerPublisher {
520
532
  }
521
533
  /**
522
534
  * Gets the fee asset price modifier from the oracle.
523
- * Returns 0n if the oracle query fails.
524
- */ getFeeAssetPriceModifier() {
525
- return this.feeAssetPriceOracle.computePriceModifier();
535
+ *
536
+ * @param predictedParentEthPerFeeAssetE12 - Optional predicted parent eth-per-fee-asset (E12).
537
+ * Pipelined proposers should pass the value from the predicted parent fee header so the
538
+ * modifier matches the parent L1 will use when applying it.
539
+ * @returns The fee asset price modifier in basis points, or 0n if the oracle query fails.
540
+ */ getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12) {
541
+ return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12);
526
542
  }
527
543
  getSenderAddress() {
528
544
  return this.l1TxUtils.getSenderAddress();
@@ -532,12 +548,6 @@ export class SequencerPublisher {
532
548
  */ getL1FeeAnalyzer() {
533
549
  return this.l1FeeAnalyzer;
534
550
  }
535
- /**
536
- * Sets the proposer address to use for simulations in fisherman mode.
537
- * @param proposerAddress - The actual proposer's address to use for balance lookups in simulations
538
- */ setProposerAddressForSimulation(proposerAddress) {
539
- this.proposerAddressForSimulation = proposerAddress;
540
- }
541
551
  addRequest(request) {
542
552
  this.requests.push(request);
543
553
  }
@@ -595,11 +605,15 @@ export class SequencerPublisher {
595
605
  }
596
606
  /**
597
607
  * Sends all requests that are still valid.
608
+ * @param targetSlot - The target L2 slot for this send. When provided (the production path, via
609
+ * sendRequestsAt), it is threaded into bundleSimulate so the block.timestamp override matches
610
+ * the slot the propose is built for. When omitted, falls back to getCurrentL2Slot() for the
611
+ * AutomineSequencer, which publishes synchronously within the current slot.
598
612
  * @returns one of:
599
613
  * - A receipt and stats if the tx succeeded
600
614
  * - a receipt and errorMsg if it failed on L1
601
615
  * - undefined if no valid requests are found OR the tx failed to send.
602
- */ async sendRequests() {
616
+ */ async sendRequests(targetSlot) {
603
617
  const requestsToProcess = [
604
618
  ...this.requests
605
619
  ];
@@ -607,10 +621,9 @@ export class SequencerPublisher {
607
621
  if (this.interrupted || requestsToProcess.length === 0) {
608
622
  return undefined;
609
623
  }
610
- const currentL2Slot = this.getCurrentL2Slot();
624
+ const currentL2Slot = targetSlot ?? this.getCurrentL2Slot();
611
625
  this.log.debug(`Sending requests on L2 slot ${currentL2Slot}`);
612
626
  const validRequests = requestsToProcess.filter((request)=>request.lastValidL2Slot >= currentL2Slot);
613
- const validActions = validRequests.map((x)=>x.action);
614
627
  const expiredActions = requestsToProcess.filter((request)=>request.lastValidL2Slot < currentL2Slot).map((x)=>x.action);
615
628
  if (validRequests.length !== requestsToProcess.length) {
616
629
  this.log.warn(`Some requests were expired for slot ${currentL2Slot}`, {
@@ -628,71 +641,53 @@ export class SequencerPublisher {
628
641
  this.log.debug(`No valid requests to send`);
629
642
  return undefined;
630
643
  }
631
- // @note - we can only have one blob config per bundle
632
- // find requests with gas and blob configs
633
- // See https://github.com/AztecProtocol/aztec-packages/issues/11513
644
+ // Collect earliest txTimeoutAt across all requests.
634
645
  const gasConfigs = validRequests.filter((request)=>request.gasConfig).map((request)=>request.gasConfig);
635
- const blobConfigs = validRequests.filter((request)=>request.blobConfig).map((request)=>request.blobConfig);
636
- if (blobConfigs.length > 1) {
637
- throw new Error('Multiple blob configs found');
638
- }
639
- const blobConfig = blobConfigs[0];
640
- // Merge gasConfigs. Yields the sum of gasLimits, and the earliest txTimeoutAt, or undefined if no gasConfig sets them.
641
- const gasLimits = gasConfigs.map((g)=>g?.gasLimit).filter((g)=>g !== undefined);
642
- let gasLimit = gasLimits.length > 0 ? sumBigint(gasLimits) : undefined; // sum
643
- // Cap at L1 block gas limit so the node accepts the tx ("gas limit too high" otherwise).
644
- const maxGas = MAX_L1_TX_LIMIT;
645
- if (gasLimit !== undefined && gasLimit > maxGas) {
646
- this.log.debug('Capping bundled tx gas limit to L1 max', {
647
- requested: gasLimit,
648
- capped: maxGas
649
- });
650
- gasLimit = maxGas;
651
- }
652
646
  const txTimeoutAts = gasConfigs.map((g)=>g?.txTimeoutAt).filter((g)=>g !== undefined);
653
- const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map((g)=>g.getTime()))) : undefined; // earliest
654
- const txConfig = {
655
- gasLimit,
656
- txTimeoutAt
657
- };
647
+ const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map((g)=>g.getTime()))) : undefined;
658
648
  // Sort the requests so that proposals always go first
659
649
  // This ensures the committee gets precomputed correctly
660
650
  validRequests.sort((a, b)=>compareActions(a.action, b.action));
661
651
  try {
662
- // Capture context for failed tx backup before sending
663
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
664
- const multicallData = encodeFunctionData({
665
- abi: multicall3Abi,
666
- functionName: 'aggregate3',
667
- args: [
668
- validRequests.map((r)=>({
669
- target: r.request.to,
670
- callData: r.request.data,
671
- allowFailure: true
672
- }))
673
- ]
674
- });
675
- const blobDataHex = blobConfig?.blobs?.map((b)=>toHex(b));
676
- const txContext = {
677
- multicallData,
678
- blobData: blobDataHex,
679
- l1BlockNumber
652
+ // Bundle-level eth_simulateV1: filters out entries that revert and derives the gasLimit.
653
+ const bundleResult = await this.bundleSimulator.simulate(validRequests, currentL2Slot);
654
+ if (bundleResult.kind === 'aborted') {
655
+ this.logDroppedInSim(bundleResult.droppedRequests);
656
+ void this.backupDroppedInSim(bundleResult.droppedRequests).catch((err)=>this.log.error(`Failed to backup requests dropped in simulation`, err));
657
+ return undefined;
658
+ }
659
+ const { requests, droppedRequests, gasLimit } = bundleResult.kind === 'fallback' ? {
660
+ requests: bundleResult.requests,
661
+ droppedRequests: bundleResult.droppedRequests,
662
+ gasLimit: MAX_L1_TX_LIMIT
663
+ } : bundleResult;
664
+ this.logDroppedInSim(droppedRequests);
665
+ // Compute blobConfig from survivors (not original validRequests) so that if the propose
666
+ // entry was dropped by bundleSimulate we don't attach a blob-typed config to a non-blob tx.
667
+ const [blobConfig] = requests.filter((r)=>r.blobConfig).map((r)=>r.blobConfig);
668
+ const txConfig = {
669
+ gasLimit,
670
+ txTimeoutAt
680
671
  };
681
672
  this.log.debug('Forwarding transactions', {
682
- validRequests: validRequests.map((request)=>request.action),
673
+ requests: requests.map((request)=>request.action),
683
674
  txConfig
684
675
  });
685
- const result = await this.forwardWithPublisherRotation(validRequests, txConfig, blobConfig);
676
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
686
677
  if (result === undefined) {
687
678
  return undefined;
688
679
  }
689
- const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(validRequests, result, txContext);
680
+ const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(requests, result);
681
+ const allFailedActions = [
682
+ ...failedActions,
683
+ ...droppedRequests.map((d)=>d.request.action)
684
+ ];
690
685
  return {
691
686
  result,
692
687
  expiredActions,
693
- sentActions: validActions,
688
+ sentActions: requests.map((x)=>x.action),
694
689
  successfulActions,
695
- failedActions
690
+ failedActions: allFailedActions
696
691
  };
697
692
  } catch (err) {
698
693
  const viemError = formatViemError(err);
@@ -706,23 +701,78 @@ export class SequencerPublisher {
706
701
  }
707
702
  }
708
703
  }
704
+ /** Logs entries dropped by bundle simulation as warnings on the publisher's logger. */ logDroppedInSim(dropped) {
705
+ for (const drop of dropped){
706
+ const revertReasonDecoded = drop.revertReason ?? tryDecodeRevertReason(drop.returnData, this.revertDecoderAbi);
707
+ this.log.warn('Bundle entry dropped: action reverted in sim', {
708
+ action: drop.request.action,
709
+ revertReason: revertReasonDecoded ?? drop.returnData,
710
+ revertReasonDecoded,
711
+ returnData: drop.returnData
712
+ });
713
+ }
714
+ }
715
+ /** Backs up entries dropped by bundle simulation, one record per dropped action. */ async backupDroppedInSim(dropped) {
716
+ if (dropped.length === 0) {
717
+ return;
718
+ }
719
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
720
+ for (const { request: req } of dropped){
721
+ this.backupFailedTx({
722
+ id: keccak256(req.request.data),
723
+ failureType: 'simulation',
724
+ request: {
725
+ to: req.request.to,
726
+ data: req.request.data
727
+ },
728
+ l1BlockNumber: l1BlockNumber.toString(),
729
+ error: {
730
+ message: 'Bundle entry dropped: action reverted in sim'
731
+ },
732
+ context: {
733
+ actions: [
734
+ req.action
735
+ ],
736
+ sender: this.getSenderAddress().toString()
737
+ }
738
+ });
739
+ }
740
+ }
709
741
  /**
710
742
  * Forwards transactions via Multicall3, rotating to the next available publisher if a send
711
743
  * failure occurs (i.e. the tx never reached the chain).
712
744
  * On-chain reverts and simulation errors are returned as-is without rotation.
713
745
  */ async forwardWithPublisherRotation(validRequests, txConfig, blobConfig) {
746
+ if (!txConfig?.gasLimit) {
747
+ throw new Error('gasLimit is required for bundled transactions');
748
+ }
749
+ const txConfigWithGasLimit = txConfig;
714
750
  const triedAddresses = [];
715
751
  let currentPublisher = this.l1TxUtils;
716
752
  while(true){
753
+ if (txConfig.txTimeoutAt && new Date() > txConfig.txTimeoutAt) {
754
+ this.log.warn(`Tx timeout (${txConfig.txTimeoutAt.toISOString()}) elapsed; stopping publisher rotation`, {
755
+ triedAddresses: triedAddresses.map((a)=>a.toString())
756
+ });
757
+ return undefined;
758
+ }
717
759
  triedAddresses.push(currentPublisher.getSenderAddress());
718
760
  try {
719
- const result = await Multicall3.forward(validRequests.map((r)=>r.request), currentPublisher, txConfig, blobConfig, this.rollupContract.address, this.log);
761
+ const result = await Multicall3.forward(validRequests.map((r)=>r.request), currentPublisher, txConfigWithGasLimit, blobConfig, {
762
+ gasLimitRequired: true
763
+ });
720
764
  this.l1TxUtils = currentPublisher;
721
765
  return result;
722
766
  } catch (err) {
723
767
  if (err instanceof TimeoutError) {
724
768
  throw err;
725
769
  }
770
+ if (err instanceof MulticallForwarderRevertedError) {
771
+ this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
772
+ transactionHash: err.receipt.transactionHash
773
+ });
774
+ return undefined;
775
+ }
726
776
  const viemError = formatViemError(err);
727
777
  if (!this.getNextPublisher) {
728
778
  this.log.error('Failed to publish bundled transactions', viemError);
@@ -733,122 +783,132 @@ export class SequencerPublisher {
733
783
  ...triedAddresses
734
784
  ]);
735
785
  if (!nextPublisher) {
736
- this.log.error('All available publishers exhausted, failed to publish bundled transactions');
786
+ this.log.error(`All available publishers exhausted (tried ${triedAddresses.length}), failed to publish bundled transactions`, viemError, {
787
+ triedAddresses: triedAddresses.map((a)=>a.toString())
788
+ });
737
789
  return undefined;
738
790
  }
739
791
  currentPublisher = nextPublisher;
740
792
  }
741
793
  }
742
794
  }
743
- callbackBundledTransactions(requests, result, txContext) {
744
- const actionsListStr = requests.map((r)=>r.action).join(', ');
745
- if (result instanceof FormattedViemError) {
746
- this.log.error(`Failed to publish bundled transactions (${actionsListStr})`, result);
747
- this.backupFailedTx({
748
- id: keccak256(txContext.multicallData),
749
- failureType: 'send-error',
750
- request: {
751
- to: MULTI_CALL_3_ADDRESS,
752
- data: txContext.multicallData
753
- },
754
- blobData: txContext.blobData,
755
- l1BlockNumber: txContext.l1BlockNumber.toString(),
756
- error: {
757
- message: result.message,
758
- name: result.name
759
- },
760
- context: {
761
- actions: requests.map((r)=>r.action),
762
- requests: requests.map((r)=>({
763
- action: r.action,
764
- to: r.request.to,
765
- data: r.request.data
766
- })),
767
- sender: this.getSenderAddress().toString()
795
+ /*
796
+ * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
797
+ */ async sendRequestsAt(targetSlot) {
798
+ await this.waitForTargetSlot(targetSlot);
799
+ if (this.interrupted) {
800
+ return undefined;
801
+ }
802
+ return this.sendRequests(targetSlot);
803
+ }
804
+ /**
805
+ * Sleeps until one L1 slot before the L2 slot boundary, and then waits for that L1 block
806
+ * to be mined, so we don't risk being included in it. If that block never gets mined after
807
+ * a timeout, we assume it got skipped on L1, so we send the tx anyway.
808
+ */ async waitForTargetSlot(targetSlot) {
809
+ const l1Constants = this.epochCache.getL1Constants();
810
+ const nowInSeconds = this.dateProvider.nowInSeconds();
811
+ const startOfTargetSlotTs = getTimestampForSlot(targetSlot, l1Constants);
812
+ const previousL1BlockTs = startOfTargetSlotTs - this.ethereumSlotDuration;
813
+ const waitDeadlineTs = previousL1BlockTs + BigInt(this.previousL1BlockWaitTimeoutMs / 1000);
814
+ const logCtx = {
815
+ targetSlot,
816
+ startOfTargetSlotTs,
817
+ nowInSeconds,
818
+ previousL1BlockTs,
819
+ waitDeadlineTs
820
+ };
821
+ // Check if we are already past time
822
+ if (nowInSeconds >= startOfTargetSlotTs) {
823
+ this.log.verbose(`Target slot ${targetSlot} already started, sending requests immediately`, logCtx);
824
+ return;
825
+ }
826
+ // Otherwise we wait
827
+ this.log.debug(`Waiting for slot ${targetSlot} before sending requests`, logCtx);
828
+ // Wait until previous L1 block timestamp first
829
+ const sleepMs = (Number(previousL1BlockTs) - nowInSeconds) * 1000;
830
+ if (sleepMs > 0 && !this.interrupted) {
831
+ this.log.trace(`Sleeping ${sleepMs}ms before waiting for previous L1 block`, logCtx);
832
+ await this.interruptibleSleep.sleep(sleepMs);
833
+ }
834
+ // Then loop until we see the previous L1 block, so we know that we cannot be included in it.
835
+ // We time out after a while, once we are sure that that block is skipped in L1.
836
+ while(!this.interrupted){
837
+ try {
838
+ const nowInSeconds = this.dateProvider.nowInSeconds();
839
+ logCtx.nowInSeconds = nowInSeconds;
840
+ if (nowInSeconds >= waitDeadlineTs) {
841
+ this.log.warn(`Timed out waiting for previous L1 block before sending requests, proceeding`, logCtx);
842
+ return;
768
843
  }
769
- });
770
- return {
771
- failedActions: requests.map((r)=>r.action)
772
- };
773
- } else {
774
- this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
775
- result,
776
- requests: requests.map((r)=>({
777
- ...r,
778
- // Avoid logging large blob data
779
- blobConfig: r.blobConfig ? {
780
- ...r.blobConfig,
781
- blobs: r.blobConfig.blobs.map((b)=>({
782
- size: trimmedBytesLength(b)
783
- }))
784
- } : undefined
785
- }))
786
- });
787
- const successfulActions = [];
788
- const failedActions = [];
789
- for (const request of requests){
790
- if (request.checkSuccess(request.request, result)) {
791
- successfulActions.push(request.action);
792
- } else {
793
- failedActions.push(request.action);
844
+ const latestBlockTs = await this.l1TxUtils.getBlock().then((b)=>b.timestamp);
845
+ if (latestBlockTs >= previousL1BlockTs) {
846
+ this.log.debug(`Previous L1 block mined, proceeding to send requests`, {
847
+ ...logCtx,
848
+ latestBlockTs
849
+ });
850
+ return;
794
851
  }
795
- }
796
- // Single backup for the whole reverted tx
797
- if (failedActions.length > 0 && result?.receipt?.status === 'reverted') {
798
- this.backupFailedTx({
799
- id: result.receipt.transactionHash,
800
- failureType: 'revert',
801
- request: {
802
- to: MULTI_CALL_3_ADDRESS,
803
- data: txContext.multicallData
804
- },
805
- blobData: txContext.blobData,
806
- l1BlockNumber: result.receipt.blockNumber.toString(),
807
- receipt: {
808
- transactionHash: result.receipt.transactionHash,
809
- blockNumber: result.receipt.blockNumber.toString(),
810
- gasUsed: (result.receipt.gasUsed ?? 0n).toString(),
811
- status: 'reverted'
812
- },
813
- error: {
814
- message: result.errorMsg ?? 'Transaction reverted'
815
- },
816
- context: {
817
- actions: failedActions,
818
- requests: requests.filter((r)=>failedActions.includes(r.action)).map((r)=>({
819
- action: r.action,
820
- to: r.request.to,
821
- data: r.request.data
822
- })),
823
- sender: this.getSenderAddress().toString()
824
- }
852
+ this.log.trace(`Previous L1 block not mined yet, continuing to wait`, {
853
+ ...logCtx,
854
+ latestBlockTs
825
855
  });
856
+ } catch (err) {
857
+ this.log.error(`Error while waiting for previous L1 block before sending requests; retrying`, err, logCtx);
858
+ } finally{
859
+ await this.interruptibleSleep.sleep(this.previousL1BlockWaitPollIntervalMs);
826
860
  }
827
- return {
828
- successfulActions,
829
- failedActions
830
- };
831
861
  }
832
862
  }
863
+ callbackBundledTransactions(requests, result) {
864
+ const actionsListStr = requests.map((r)=>r.action).join(', ');
865
+ this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
866
+ result,
867
+ requests: requests.map((r)=>({
868
+ ...r,
869
+ // Avoid logging large blob data
870
+ blobConfig: r.blobConfig ? {
871
+ ...r.blobConfig,
872
+ blobs: r.blobConfig.blobs.map((b)=>({
873
+ size: trimmedBytesLength(b)
874
+ }))
875
+ } : undefined
876
+ }))
877
+ });
878
+ const successfulActions = [];
879
+ const failedActions = [];
880
+ for (const request of requests){
881
+ if (request.checkSuccess(request.request, result)) {
882
+ successfulActions.push(request.action);
883
+ } else {
884
+ failedActions.push(request.action);
885
+ }
886
+ }
887
+ return {
888
+ successfulActions,
889
+ failedActions
890
+ };
891
+ }
833
892
  /**
834
893
  * @notice Will call `canProposeAt` to make sure that it is possible to propose
835
894
  * @param tipArchive - The archive to check
836
895
  * @returns The slot and block number if it is possible to propose, undefined otherwise
837
- */ canProposeAt(tipArchive, msgSender, opts = {}) {
896
+ */ async canProposeAt(tipArchive, msgSender, simulationOverridesPlan) {
838
897
  // TODO: #14291 - should loop through multiple keys to check if any of them can propose
839
- const ignoredErrors = [
898
+ // These errors are expected when we cannot actually propose right now — usually because our
899
+ // local view of the chain is ahead of L1 (proposed parent hasn't landed yet, or someone
900
+ // else has just landed the slot, or the archive override doesn't match). We log a warn and
901
+ // skip the proposal; we do NOT treat these as bugs.
902
+ const expectedErrors = [
840
903
  'SlotAlreadyInChain',
841
904
  'InvalidProposer',
842
905
  'InvalidArchive'
843
906
  ];
844
- const pipelined = opts.pipelined ?? this.epochCache.isProposerPipeliningEnabled();
845
- const slotOffset = pipelined ? this.aztecSlotDuration : 0n;
907
+ const slotOffset = this.aztecSlotDuration;
846
908
  const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset;
847
- return this.rollupContract.canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, {
848
- forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber
849
- }).catch((err)=>{
850
- if (err instanceof FormattedViemError && ignoredErrors.find((e)=>err.message.includes(e))) {
851
- this.log.warn(`Failed canProposeAtTime check with ${ignoredErrors.find((e)=>err.message.includes(e))}`, {
909
+ return this.rollupContract.canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan)).catch((err)=>{
910
+ if (err instanceof FormattedViemError && expectedErrors.find((e)=>err.message.includes(e))) {
911
+ this.log.warn(`Failed canProposeAtTime check with ${expectedErrors.find((e)=>err.message.includes(e))}`, {
852
912
  error: err.message
853
913
  });
854
914
  } else {
@@ -858,37 +918,31 @@ export class SequencerPublisher {
858
918
  });
859
919
  }
860
920
  /**
861
- * @notice Will simulate `validateHeader` to make sure that the block header is valid
862
- * @dev This is a convenience function that can be used by the sequencer to validate a "partial" header.
863
- * It will throw if the block header is invalid.
864
- * @param header - The block header to validate
865
- */ async validateBlockHeader(header, opts) {
921
+ * @notice Will simulate the rollup's `validateHeaderWithAttestations` to make sure the checkpoint header is valid
922
+ * @dev This is a convenience function that can be used by the sequencer to validate a "partial" header,
923
+ * skipping the DA and signature checks. It will throw if the checkpoint header is invalid.
924
+ * @param header - The checkpoint header to validate
925
+ */ async validateCheckpointHeader(header, simulationOverridesPlan) {
866
926
  const flags = {
867
927
  ignoreDA: true,
868
928
  ignoreSignatures: true
869
929
  };
870
930
  const args = [
871
931
  header.toViem(),
872
- CommitteeAttestationsAndSigners.empty().getPackedAttestations(),
932
+ CommitteeAttestationsAndSigners.packAttestations([]),
873
933
  [],
874
934
  Signature.empty().toViemSignature(),
875
935
  `0x${'0'.repeat(64)}`,
876
936
  header.blobsHash.toString(),
877
937
  flags
878
938
  ];
879
- const ts = this.getNextL1SlotTimestamp();
880
- const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride(opts?.forcePendingCheckpointNumber);
881
- let balance = 0n;
882
- if (this.config.fishermanMode) {
883
- // In fisherman mode, we can't know where the proposer is publishing from
884
- // so we just add sufficient balance to the multicall3 address
885
- balance = 10n * WEI_CONST * WEI_CONST; // 10 ETH
886
- } else {
887
- balance = await this.l1TxUtils.getSenderBalance();
888
- }
939
+ const l1Constants = this.epochCache.getL1Constants();
940
+ const ts = getLastL1SlotTimestampForL2Slot(header.slotNumber, l1Constants);
941
+ const stateOverrides = await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan);
942
+ // Balance override for compatibility with providers that apply an upfront funds check to simulated calls.
889
943
  stateOverrides.push({
890
944
  address: MULTI_CALL_3_ADDRESS,
891
- balance
945
+ balance: 10n * WEI_CONST * WEI_CONST
892
946
  });
893
947
  await this.l1TxUtils.simulate({
894
948
  to: this.rollupContract.address,
@@ -899,7 +953,7 @@ export class SequencerPublisher {
899
953
  }),
900
954
  from: MULTI_CALL_3_ADDRESS
901
955
  }, {
902
- time: ts + 1n
956
+ time: ts
903
957
  }, stateOverrides);
904
958
  this.log.debug(`Simulated validateHeader`);
905
959
  }
@@ -945,6 +999,7 @@ export class SequencerPublisher {
945
999
  gasUsed,
946
1000
  checkpointNumber,
947
1001
  forcePendingCheckpointNumber: CheckpointNumber(checkpointNumber - 1),
1002
+ lastArchive: validationResult.checkpoint.lastArchive,
948
1003
  reason
949
1004
  };
950
1005
  } catch (err) {
@@ -957,8 +1012,8 @@ export class SequencerPublisher {
957
1012
  request,
958
1013
  error: viemError.message
959
1014
  });
960
- const latestPendingCheckpointNumber = await this.rollupContract.getCheckpointNumber();
961
- if (latestPendingCheckpointNumber < checkpointNumber) {
1015
+ const latestProposedCheckpointNumber = await this.rollupContract.getCheckpointNumber();
1016
+ if (latestProposedCheckpointNumber < checkpointNumber) {
962
1017
  this.log.verbose(`Checkpoint ${checkpointNumber} has already been invalidated`, {
963
1018
  ...logData
964
1019
  });
@@ -1008,7 +1063,11 @@ export class SequencerPublisher {
1008
1063
  reason
1009
1064
  };
1010
1065
  this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
1011
- const attestationsAndSigners = new CommitteeAttestationsAndSigners(validationResult.attestations).getPackedAttestations();
1066
+ // Use the exact packed tuple posted to L1 verbatim. A repack via `packAttestations` is not a
1067
+ // byte-faithful inverse of `fromPacked` (a canonicalized yParity byte or an all-zero signature slot
1068
+ // round-trips differently), so it would diverge from the stored `attestationsHash` and revert the
1069
+ // invalidation.
1070
+ const attestationsAndSigners = validationResult.verbatimAttestations;
1012
1071
  if (reason === 'invalid-attestation') {
1013
1072
  return this.rollupContract.buildInvalidateBadAttestationRequest(checkpoint.checkpointNumber, attestationsAndSigners, committee, validationResult.invalidIndex);
1014
1073
  } else if (reason === 'insufficient-attestations') {
@@ -1018,30 +1077,7 @@ export class SequencerPublisher {
1018
1077
  throw new Error(`Unknown reason for invalidation`);
1019
1078
  }
1020
1079
  }
1021
- /** Simulates `propose` to make sure that the checkpoint is valid for submission */ async validateCheckpointForSubmission(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, options) {
1022
- // Anchor the simulation timestamp to the checkpoint's own slot start time
1023
- // rather than the current L1 block timestamp, which may overshoot into the next slot if the build ran late.
1024
- const ts = checkpoint.header.timestamp;
1025
- const blobFields = checkpoint.toBlobFields();
1026
- const blobs = await getBlobsPerL1Block(blobFields);
1027
- const blobInput = getPrefixedEthBlobCommitments(blobs);
1028
- const args = [
1029
- {
1030
- header: checkpoint.header.toViem(),
1031
- archive: toHex(checkpoint.archive.root.toBuffer()),
1032
- oracleInput: {
1033
- feeAssetPriceModifier: checkpoint.feeAssetPriceModifier
1034
- }
1035
- },
1036
- attestationsAndSigners.getPackedAttestations(),
1037
- attestationsAndSigners.getSigners().map((signer)=>signer.toString()),
1038
- attestationsAndSignersSignature.toViemSignature(),
1039
- blobInput
1040
- ];
1041
- await this.simulateProposeTx(args, ts, options);
1042
- return ts;
1043
- }
1044
- async enqueueCastSignalHelper(slotNumber, timestamp, signalType, payload, base, signerAddress, signer) {
1080
+ async enqueueCastSignalHelper(slotNumber, signalType, payload, base, signerAddress, signer) {
1045
1081
  if (this.lastActions[signalType] && this.lastActions[signalType] === slotNumber) {
1046
1082
  this.log.debug(`Skipping duplicate vote cast signal ${signalType} for slot ${slotNumber}`);
1047
1083
  return false;
@@ -1053,6 +1089,17 @@ export class SequencerPublisher {
1053
1089
  this.log.warn(`Cannot enqueue vote cast signal ${signalType} for address zero at slot ${slotNumber}`);
1054
1090
  return false;
1055
1091
  }
1092
+ const canonicalRollup = await base.getRollupAddress();
1093
+ if (!canonicalRollup.equals(EthAddress.fromString(this.rollupContract.address))) {
1094
+ this.log.warn(`Rollup ${this.rollupContract.address} is not canonical, skipping governance signal`, {
1095
+ slotNumber,
1096
+ signalType,
1097
+ canonicalRollup,
1098
+ targetRollup: this.rollupContract.address,
1099
+ payload: payload.toString()
1100
+ });
1101
+ return false;
1102
+ }
1056
1103
  const round = await base.computeRound(slotNumber);
1057
1104
  const roundInfo = await base.getRoundInfo(this.rollupContract.address, round);
1058
1105
  if (roundInfo.quorumReached) {
@@ -1061,30 +1108,40 @@ export class SequencerPublisher {
1061
1108
  if (roundInfo.lastSignalSlot >= slotNumber) {
1062
1109
  return false;
1063
1110
  }
1064
- if (await this.isPayloadEmpty(payload)) {
1111
+ if (await base.isPayloadEmpty(payload)) {
1065
1112
  this.log.warn(`Skipping vote cast for payload with empty code`);
1066
1113
  return false;
1067
1114
  }
1068
- // Check if payload was already submitted to governance
1069
- const cacheKey = payload.toString();
1070
- if (!this.payloadProposedCache.has(cacheKey)) {
1071
- try {
1072
- const l1StartBlock = await this.rollupContract.getL1StartBlock();
1073
- const proposed = await retry(()=>base.hasPayloadBeenProposed(payload.toString(), l1StartBlock), 'Check if payload was proposed', makeBackoff([
1074
- 0,
1075
- 1,
1076
- 2
1077
- ]), this.log, true);
1078
- if (proposed) {
1079
- this.payloadProposedCache.add(cacheKey);
1080
- }
1081
- } catch (err) {
1082
- this.log.warn(`Failed to check if payload ${payload} was proposed after retries, skipping signal`, err);
1083
- return false;
1084
- }
1115
+ // Classify the payload against the Governance proposal history so we stop signalling once its
1116
+ // proposal is live or was already executed, while still re-signalling one whose proposal was
1117
+ // merely rejected/dropped/expired.
1118
+ let status = 'none';
1119
+ try {
1120
+ status = await base.getPayloadProposalStatus(payload.toString());
1121
+ } catch (err) {
1122
+ // We deliberately swallow the error and proceed to signal. Failing closed (skipping the
1123
+ // signal) on transient RPC errors would let a flaky L1 endpoint silence governance
1124
+ // participation entirely; failing open at worst produces a duplicate signal that the
1125
+ // contract will simply count alongside others in the round.
1126
+ this.log.error(`Failed to check governance proposal status for payload ${payload} (signalling anyway)`, err, {
1127
+ slotNumber,
1128
+ signalType
1129
+ });
1130
+ }
1131
+ if (status === 'live') {
1132
+ this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`, {
1133
+ slotNumber,
1134
+ signalType,
1135
+ payload: payload.toString()
1136
+ });
1137
+ return false;
1085
1138
  }
1086
- if (this.payloadProposedCache.has(cacheKey)) {
1087
- this.log.info(`Payload ${payload} was already proposed to governance, stopping signals`);
1139
+ if (status === 'executed' && !this.config.governanceProposerForcePayloadVote) {
1140
+ this.log.info(`Payload ${payload} was executed by governance within lookback, stopping signals ` + `(set GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE to re-signal)`, {
1141
+ slotNumber,
1142
+ signalType,
1143
+ payload: payload.toString()
1144
+ });
1088
1145
  return false;
1089
1146
  }
1090
1147
  const cachedLastVote = this.lastActions[signalType];
@@ -1097,53 +1154,17 @@ export class SequencerPublisher {
1097
1154
  signer: this.l1TxUtils.client.account?.address,
1098
1155
  lastValidL2Slot: slotNumber
1099
1156
  });
1100
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1101
- try {
1102
- await this.l1TxUtils.simulate(request, {
1103
- time: timestamp
1104
- }, [], mergeAbis([
1105
- request.abi ?? [],
1106
- ErrorsAbi
1107
- ]));
1108
- this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, {
1109
- request
1110
- });
1111
- } catch (err) {
1112
- const viemError = formatViemError(err);
1113
- this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, viemError);
1114
- this.backupFailedTx({
1115
- id: keccak256(request.data),
1116
- failureType: 'simulation',
1117
- request: {
1118
- to: request.to,
1119
- data: request.data,
1120
- value: request.value?.toString()
1121
- },
1122
- l1BlockNumber: l1BlockNumber.toString(),
1123
- error: {
1124
- message: viemError.message,
1125
- name: viemError.name
1126
- },
1127
- context: {
1128
- actions: [
1129
- action
1130
- ],
1131
- slot: slotNumber,
1132
- sender: this.getSenderAddress().toString()
1133
- }
1134
- });
1135
- // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself
1136
- }
1137
1157
  // TODO(palla/slash): All votes (governance and slashing) should txTimeoutAt at the end of the slot.
1138
1158
  this.addRequest({
1139
- gasConfig: {
1140
- gasLimit: SequencerPublisher.VOTE_GAS_GUESS
1141
- },
1142
1159
  action,
1143
1160
  request,
1144
1161
  lastValidL2Slot: slotNumber,
1145
1162
  checkSuccess: (_request, result)=>{
1146
- const success = result && result.receipt && result.receipt.status === 'success' && tryExtractEvent(result.receipt.logs, base.address.toString(), EmpireBaseAbi, 'SignalCast');
1163
+ const success = result && extractEventSuccess(result.receipt, {
1164
+ address: base.address.toString(),
1165
+ abi: EmpireBaseAbi,
1166
+ eventName: 'SignalCast'
1167
+ });
1147
1168
  const logData = {
1148
1169
  ...result,
1149
1170
  slotNumber,
@@ -1162,68 +1183,70 @@ export class SequencerPublisher {
1162
1183
  });
1163
1184
  return true;
1164
1185
  }
1165
- async isPayloadEmpty(payload) {
1166
- const key = payload.toString();
1167
- const cached = this.isPayloadEmptyCache.get(key);
1168
- if (cached) {
1169
- return cached;
1170
- }
1171
- const isEmpty = !await this.l1TxUtils.getCode(payload);
1172
- this.isPayloadEmptyCache.set(key, isEmpty);
1173
- return isEmpty;
1174
- }
1175
1186
  /**
1176
1187
  * Enqueues a governance castSignal transaction to cast a signal for a given slot number.
1177
1188
  * @param slotNumber - The slot number to cast a signal for.
1178
- * @param timestamp - The timestamp of the slot to cast a signal for.
1179
1189
  * @returns True if the signal was successfully enqueued, false otherwise.
1180
- */ enqueueGovernanceCastSignal(governancePayload, slotNumber, timestamp, signerAddress, signer) {
1181
- return this.enqueueCastSignalHelper(slotNumber, timestamp, 'governance-signal', governancePayload, this.govProposerContract, signerAddress, signer);
1190
+ */ enqueueGovernanceCastSignal(governancePayload, slotNumber, signerAddress, signer) {
1191
+ return this.enqueueCastSignalHelper(slotNumber, 'governance-signal', governancePayload, this.govProposerContract, signerAddress, signer);
1182
1192
  }
1183
- /** Enqueues all slashing actions as returned by the slasher client. */ async enqueueSlashingActions(actions, slotNumber, timestamp, signerAddress, signer) {
1193
+ /**
1194
+ * Enqueues a `prune()` transaction if the rollup is prunable at the given slot's L1 timestamp.
1195
+ * `prune()` is permissionless and idempotent — if the chain is no longer prunable by send time the
1196
+ * bundle simulation usually drops the entry; on a node without `eth_simulateV1` the bundle is sent
1197
+ * as-is and the prune reverts `Rollup__NothingToPrune` inside `aggregate3(allowFailure: true)`
1198
+ * (a failed action, never a whole-tx revert). Used by the failed-sync fallback so a stuck pending
1199
+ * chain (e.g. bad data blocking sync) can be wound back to recover.
1200
+ * @returns true if a prune request was enqueued, false otherwise.
1201
+ */ async enqueuePruneIfPrunable(slotNumber) {
1202
+ if (this.lastActions['prune'] === slotNumber) {
1203
+ this.log.debug(`Skipping duplicate prune for slot ${slotNumber}`, {
1204
+ slotNumber
1205
+ });
1206
+ return false;
1207
+ }
1208
+ // Use the SAME timestamp the bundle simulator overrides block.timestamp with at send time
1209
+ // (sequencer-bundle-simulator.ts) so this upfront check and the send-time sim agree. Slot-start
1210
+ // and last-L1-slot both fall within the same L2 slot (and epoch, which is what `canPruneAtTime`
1211
+ // derives), so they agree today; matching the simulator keeps it robust if the contract ever uses
1212
+ // the timestamp more granularly.
1213
+ const ts = getLastL1SlotTimestampForL2Slot(slotNumber, this.epochCache.getL1Constants());
1214
+ const canPrune = await this.rollupContract.canPruneAtTime(ts).catch((err)=>{
1215
+ this.log.error(`Failed to check canPruneAtTime for slot ${slotNumber}`, err, {
1216
+ slotNumber
1217
+ });
1218
+ return false;
1219
+ });
1220
+ if (!canPrune) {
1221
+ this.log.debug(`Rollup not prunable at slot ${slotNumber}`, {
1222
+ slotNumber
1223
+ });
1224
+ return false;
1225
+ }
1226
+ const request = {
1227
+ to: this.rollupContract.address,
1228
+ data: encodeFunctionData({
1229
+ abi: RollupAbi,
1230
+ functionName: 'prune',
1231
+ args: []
1232
+ })
1233
+ };
1234
+ this.log.info(`Enqueuing rollup prune for slot ${slotNumber}`, {
1235
+ slotNumber
1236
+ });
1237
+ return this.enqueueRequest('prune', request, {
1238
+ address: this.rollupContract.address,
1239
+ abi: RollupAbi,
1240
+ eventName: 'PrunedPending'
1241
+ }, slotNumber);
1242
+ }
1243
+ /** Enqueues all slashing actions as returned by the slasher client. */ async enqueueSlashingActions(actions, slotNumber, signerAddress, signer) {
1184
1244
  if (actions.length === 0) {
1185
1245
  this.log.debug(`No slashing actions to enqueue for slot ${slotNumber}`);
1186
1246
  return false;
1187
1247
  }
1188
1248
  for (const action of actions){
1189
1249
  switch(action.type){
1190
- case 'vote-empire-payload':
1191
- {
1192
- if (this.slashingProposerContract?.type !== 'empire') {
1193
- this.log.error('Cannot vote for empire payload on non-empire slashing contract');
1194
- break;
1195
- }
1196
- this.log.debug(`Enqueuing slashing vote for payload ${action.payload} at slot ${slotNumber}`, {
1197
- signerAddress
1198
- });
1199
- await this.enqueueCastSignalHelper(slotNumber, timestamp, 'empire-slashing-signal', action.payload, this.slashingProposerContract, signerAddress, signer);
1200
- break;
1201
- }
1202
- case 'create-empire-payload':
1203
- {
1204
- this.log.debug(`Enqueuing slashing create payload at slot ${slotNumber}`, {
1205
- slotNumber,
1206
- signerAddress
1207
- });
1208
- const request = this.slashFactoryContract.buildCreatePayloadRequest(action.data);
1209
- await this.simulateAndEnqueueRequest('create-empire-payload', request, (receipt)=>!!this.slashFactoryContract.tryExtractSlashPayloadCreatedEvent(receipt.logs), slotNumber, timestamp);
1210
- break;
1211
- }
1212
- case 'execute-empire-payload':
1213
- {
1214
- this.log.debug(`Enqueuing slashing execute payload at slot ${slotNumber}`, {
1215
- slotNumber,
1216
- signerAddress
1217
- });
1218
- if (this.slashingProposerContract?.type !== 'empire') {
1219
- this.log.error('Cannot execute slashing payload on non-empire slashing contract');
1220
- return false;
1221
- }
1222
- const empireSlashingProposer = this.slashingProposerContract;
1223
- const request = empireSlashingProposer.buildExecuteRoundRequest(action.round);
1224
- await this.simulateAndEnqueueRequest('execute-empire-payload', request, (receipt)=>!!empireSlashingProposer.tryExtractPayloadSubmittedEvent(receipt.logs), slotNumber, timestamp);
1225
- break;
1226
- }
1227
1250
  case 'vote-offenses':
1228
1251
  {
1229
1252
  this.log.debug(`Enqueuing slashing vote for ${action.votes.length} votes at slot ${slotNumber}`, {
@@ -1232,14 +1255,17 @@ export class SequencerPublisher {
1232
1255
  votesCount: action.votes.length,
1233
1256
  signerAddress
1234
1257
  });
1235
- if (this.slashingProposerContract?.type !== 'tally') {
1236
- this.log.error('Cannot vote for slashing offenses on non-tally slashing contract');
1258
+ if (!this.slashingProposerContract) {
1259
+ this.log.error('No slashing proposer contract available');
1237
1260
  return false;
1238
1261
  }
1239
- const tallySlashingProposer = this.slashingProposerContract;
1240
1262
  const votes = bufferToHex(encodeSlashConsensusVotes(action.votes));
1241
- const request = await tallySlashingProposer.buildVoteRequestFromSigner(votes, slotNumber, signer);
1242
- await this.simulateAndEnqueueRequest('vote-offenses', request, (receipt)=>!!tallySlashingProposer.tryExtractVoteCastEvent(receipt.logs), slotNumber, timestamp);
1263
+ const request = await this.slashingProposerContract.buildVoteRequestFromSigner(votes, slotNumber, signer);
1264
+ this.enqueueRequest('vote-offenses', request, {
1265
+ address: this.slashingProposerContract.address.toString(),
1266
+ abi: SlashingProposerAbi,
1267
+ eventName: 'VoteCast'
1268
+ }, slotNumber);
1243
1269
  break;
1244
1270
  }
1245
1271
  case 'execute-slash':
@@ -1249,13 +1275,16 @@ export class SequencerPublisher {
1249
1275
  round: action.round,
1250
1276
  signerAddress
1251
1277
  });
1252
- if (this.slashingProposerContract?.type !== 'tally') {
1253
- this.log.error('Cannot execute slashing offenses on non-tally slashing contract');
1278
+ if (!this.slashingProposerContract) {
1279
+ this.log.error('No slashing proposer contract available');
1254
1280
  return false;
1255
1281
  }
1256
- const tallySlashingProposer = this.slashingProposerContract;
1257
- const request = tallySlashingProposer.buildExecuteRoundRequest(action.round, action.committees);
1258
- await this.simulateAndEnqueueRequest('execute-slash', request, (receipt)=>!!tallySlashingProposer.tryExtractRoundExecutedEvent(receipt.logs), slotNumber, timestamp);
1282
+ const executeRequest = this.slashingProposerContract.buildExecuteRoundRequest(action.round, action.committees);
1283
+ this.enqueueRequest('execute-slash', executeRequest, {
1284
+ address: this.slashingProposerContract.address.toString(),
1285
+ abi: SlashingProposerAbi,
1286
+ eventName: 'RoundExecuted'
1287
+ }, slotNumber);
1259
1288
  break;
1260
1289
  }
1261
1290
  default:
@@ -1267,7 +1296,7 @@ export class SequencerPublisher {
1267
1296
  }
1268
1297
  return true;
1269
1298
  }
1270
- /** Simulates and enqueues a proposal for a checkpoint on L1 */ async enqueueProposeCheckpoint(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, opts = {}) {
1299
+ /** Enqueues a proposal for a checkpoint on L1 */ async enqueueProposeCheckpoint(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, opts = {}) {
1271
1300
  const checkpointHeader = checkpoint.header;
1272
1301
  const blobFields = checkpoint.toBlobFields();
1273
1302
  const blobs = await getBlobsPerL1Block(blobFields);
@@ -1279,52 +1308,38 @@ export class SequencerPublisher {
1279
1308
  attestationsAndSignersSignature,
1280
1309
  feeAssetPriceModifier: checkpoint.feeAssetPriceModifier
1281
1310
  };
1282
- let ts;
1283
- try {
1284
- // @note This will make sure that we are passing the checks for our header ASSUMING that the data is also made available
1285
- // This means that we can avoid the simulation issues in later checks.
1286
- // By simulation issue, I mean the fact that the block.timestamp is equal to the last block, not the next, which
1287
- // make time consistency checks break.
1288
- // TODO(palla): Check whether we're validating twice, once here and once within addProposeTx, since we call simulateProposeTx in both places.
1289
- ts = await this.validateCheckpointForSubmission(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, opts);
1290
- } catch (err) {
1291
- this.log.error(`Checkpoint validation failed. ${err instanceof Error ? err.message : 'No error message'}`, err, {
1292
- ...checkpoint.getStats(),
1293
- slotNumber: checkpoint.header.slotNumber,
1294
- forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber
1295
- });
1296
- throw err;
1297
- }
1298
1311
  this.log.verbose(`Enqueuing checkpoint propose transaction`, {
1299
1312
  ...checkpoint.toCheckpointInfo(),
1300
- ...opts
1313
+ txTimeoutAt: opts.txTimeoutAt
1314
+ });
1315
+ await this.addProposeTx(checkpoint, proposeTxArgs, {
1316
+ txTimeoutAt: opts.txTimeoutAt
1301
1317
  });
1302
- await this.addProposeTx(checkpoint, proposeTxArgs, opts, ts);
1303
1318
  }
1304
1319
  enqueueInvalidateCheckpoint(request, opts = {}) {
1305
1320
  if (!request) {
1306
1321
  return;
1307
1322
  }
1308
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1309
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil(Number(request.gasUsed) * 64 / 63)));
1310
1323
  const { gasUsed, checkpointNumber } = request;
1311
1324
  const logData = {
1312
1325
  gasUsed,
1313
1326
  checkpointNumber,
1314
- gasLimit,
1315
1327
  opts
1316
1328
  };
1317
1329
  this.log.verbose(`Enqueuing invalidate checkpoint request`, logData);
1318
1330
  this.addRequest({
1319
1331
  action: `invalidate-by-${request.reason}`,
1320
1332
  request: request.request,
1321
- gasConfig: {
1322
- gasLimit,
1333
+ gasConfig: opts.txTimeoutAt ? {
1323
1334
  txTimeoutAt: opts.txTimeoutAt
1324
- },
1335
+ } : undefined,
1325
1336
  lastValidL2Slot: SlotNumber(this.getCurrentL2Slot() + 2),
1326
1337
  checkSuccess: (_req, result)=>{
1327
- const success = result && result.receipt && result.receipt.status === 'success' && tryExtractEvent(result.receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointInvalidated');
1338
+ const success = result && extractEventSuccess(result.receipt, {
1339
+ address: this.rollupContract.address,
1340
+ abi: RollupAbi,
1341
+ eventName: 'CheckpointInvalidated'
1342
+ });
1328
1343
  if (!success) {
1329
1344
  this.log.warn(`Invalidate checkpoint ${request.checkpointNumber} failed`, {
1330
1345
  ...result,
@@ -1340,89 +1355,36 @@ export class SequencerPublisher {
1340
1355
  }
1341
1356
  });
1342
1357
  }
1343
- async simulateAndEnqueueRequest(action, request, checkSuccess, slotNumber, timestamp) {
1344
- const logData = {
1345
- slotNumber,
1346
- timestamp,
1347
- gasLimit: undefined
1348
- };
1358
+ /**
1359
+ * Dedup-checked enqueue helper for actions that are simulated at bundle-send time rather
1360
+ * than at enqueue time. Validates the (action, slot) dedup key, sets `lastActions`, and
1361
+ * enqueues without a gasLimit so the bundle simulate sets the only gasLimit that matters.
1362
+ */ enqueueRequest(action, request, eventOpts, slotNumber) {
1349
1363
  if (this.lastActions[action] && this.lastActions[action] === slotNumber) {
1350
1364
  this.log.debug(`Skipping duplicate action ${action} for slot ${slotNumber}`);
1351
1365
  return false;
1352
1366
  }
1353
1367
  const cachedLastActionSlot = this.lastActions[action];
1354
1368
  this.lastActions[action] = slotNumber;
1355
- this.log.debug(`Simulating ${action} for slot ${slotNumber}`, logData);
1356
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1357
- let gasUsed;
1358
- const simulateAbi = mergeAbis([
1359
- request.abi ?? [],
1360
- ErrorsAbi
1361
- ]);
1362
- try {
1363
- ({ gasUsed } = await this.l1TxUtils.simulate(request, {
1364
- time: timestamp
1365
- }, [], simulateAbi)); // TODO(palla/slash): Check the timestamp logic
1366
- this.log.verbose(`Simulation for ${action} succeeded`, {
1367
- ...logData,
1368
- request,
1369
- gasUsed
1370
- });
1371
- } catch (err) {
1372
- const viemError = formatViemError(err, simulateAbi);
1373
- this.log.error(`Simulation for ${action} at ${slotNumber} failed`, viemError, logData);
1374
- this.backupFailedTx({
1375
- id: keccak256(request.data),
1376
- failureType: 'simulation',
1377
- request: {
1378
- to: request.to,
1379
- data: request.data,
1380
- value: request.value?.toString()
1381
- },
1382
- l1BlockNumber: l1BlockNumber.toString(),
1383
- error: {
1384
- message: viemError.message,
1385
- name: viemError.name
1386
- },
1387
- context: {
1388
- actions: [
1389
- action
1390
- ],
1391
- slot: slotNumber,
1392
- sender: this.getSenderAddress().toString()
1393
- }
1394
- });
1395
- return false;
1396
- }
1397
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1398
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil(Number(gasUsed) * 64 / 63)));
1399
- logData.gasLimit = gasLimit;
1400
- // Store the ABI used for simulation on the request so Multicall3.forward can decode errors
1401
- // when the tx is sent and a revert is diagnosed via simulation.
1402
- const requestWithAbi = {
1403
- ...request,
1404
- abi: simulateAbi
1405
- };
1406
- this.log.debug(`Enqueuing ${action}`, logData);
1369
+ this.log.debug(`Enqueuing ${action}`, {
1370
+ slotNumber
1371
+ });
1407
1372
  this.addRequest({
1408
1373
  action,
1409
- request: requestWithAbi,
1410
- gasConfig: {
1411
- gasLimit
1412
- },
1374
+ request,
1413
1375
  lastValidL2Slot: slotNumber,
1414
- checkSuccess: (_req, result)=>{
1415
- const success = result && result.receipt && result.receipt.status === 'success' && checkSuccess(result.receipt);
1376
+ checkSuccess: (_request, result)=>{
1377
+ const success = result && extractEventSuccess(result.receipt, eventOpts);
1416
1378
  if (!success) {
1417
1379
  this.log.warn(`Action ${action} at ${slotNumber} failed`, {
1418
1380
  ...result,
1419
- ...logData
1381
+ slotNumber
1420
1382
  });
1421
1383
  this.lastActions[action] = cachedLastActionSlot;
1422
1384
  } else {
1423
1385
  this.log.info(`Action ${action} at ${slotNumber} succeeded`, {
1424
1386
  ...result,
1425
- ...logData
1387
+ slotNumber
1426
1388
  });
1427
1389
  }
1428
1390
  return !!success;
@@ -1437,13 +1399,14 @@ export class SequencerPublisher {
1437
1399
  * A call to `restart` is required before you can continue publishing.
1438
1400
  */ interrupt() {
1439
1401
  this.interrupted = true;
1402
+ this.interruptibleSleep.interrupt();
1440
1403
  this.l1TxUtils.interrupt();
1441
1404
  }
1442
1405
  /** Restarts the publisher after calling `interrupt`. */ restart() {
1443
1406
  this.interrupted = false;
1444
1407
  this.l1TxUtils.restart();
1445
1408
  }
1446
- async prepareProposeTx(encodedData, timestamp, options) {
1409
+ async prepareProposeTx(encodedData) {
1447
1410
  const kzg = Blob.getViemKzgInstance();
1448
1411
  const blobInput = getPrefixedEthBlobCommitments(encodedData.blobs);
1449
1412
  this.log.debug('Validating blob input', {
@@ -1457,7 +1420,11 @@ export class SequencerPublisher {
1457
1420
  blobEvaluationGas = BigInt(encodedData.blobs.length) * 21_000n;
1458
1421
  this.log.debug(`Using fixed blob evaluation gas estimate in fisherman mode: ${blobEvaluationGas}`);
1459
1422
  } else {
1460
- // Normal mode - use estimateGas with blob inputs
1423
+ // We call validateBlobs via estimateGas with real blob+kzg sidecars as a consistency check
1424
+ // that our locally-built blob commitments match the blob data. The bundle simulate at send
1425
+ // time uses eth_simulateV1, which cannot carry blob inputs, so the rollup's on-chain blob
1426
+ // check is forced off there — making this the only pre-flight detector of a commitment/data
1427
+ // mismatch. The returned gas estimate is stashed on the request for the bundle path to read.
1461
1428
  blobEvaluationGas = await this.l1TxUtils.estimateGas(this.getSenderAddress().toString(), {
1462
1429
  to: this.rollupContract.address,
1463
1430
  data: encodeFunctionData({
@@ -1520,109 +1487,23 @@ export class SequencerPublisher {
1520
1487
  encodedData.attestationsAndSignersSignature.toViemSignature(),
1521
1488
  blobInput
1522
1489
  ];
1523
- const { rollupData, simulationResult } = await this.simulateProposeTx(args, timestamp, options);
1524
- return {
1525
- args,
1526
- blobEvaluationGas,
1527
- rollupData,
1528
- simulationResult
1529
- };
1530
- }
1531
- /**
1532
- * Simulates the propose tx with eth_simulateV1
1533
- * @param args - The propose tx args
1534
- * @param timestamp - The timestamp to simulate proposal at
1535
- * @returns The simulation result
1536
- */ async simulateProposeTx(args, timestamp, options) {
1537
1490
  const rollupData = encodeFunctionData({
1538
1491
  abi: RollupAbi,
1539
1492
  functionName: 'propose',
1540
1493
  args
1541
1494
  });
1542
- // override the pending checkpoint number if requested
1543
- const forcePendingCheckpointNumberStateDiff = (options.forcePendingCheckpointNumber !== undefined ? await this.rollupContract.makePendingCheckpointNumberOverride(options.forcePendingCheckpointNumber) : []).flatMap((override)=>override.stateDiff ?? []);
1544
- const stateOverrides = [
1545
- {
1546
- address: this.rollupContract.address,
1547
- // @note we override checkBlob to false since blobs are not part simulate()
1548
- stateDiff: [
1549
- {
1550
- slot: toPaddedHex(RollupContract.checkBlobStorageSlot, true),
1551
- value: toPaddedHex(0n, true)
1552
- },
1553
- ...forcePendingCheckpointNumberStateDiff
1554
- ]
1555
- }
1556
- ];
1557
- // In fisherman mode, simulate as the proposer but with sufficient balance
1558
- if (this.proposerAddressForSimulation) {
1559
- stateOverrides.push({
1560
- address: this.proposerAddressForSimulation.toString(),
1561
- balance: 10n * WEI_CONST * WEI_CONST
1562
- });
1563
- }
1564
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1565
- const simulationResult = await this.l1TxUtils.simulate({
1566
- to: this.rollupContract.address,
1567
- data: rollupData,
1568
- gas: MAX_L1_TX_LIMIT,
1569
- ...this.proposerAddressForSimulation && {
1570
- from: this.proposerAddressForSimulation.toString()
1571
- }
1572
- }, {
1573
- // @note we add 1n to the timestamp because geth implementation doesn't like simulation timestamp to be equal to the current block timestamp
1574
- time: timestamp + 1n,
1575
- // @note reth should have a 30m gas limit per block but throws errors that this tx is beyond limit so we increase here
1576
- gasLimit: MAX_L1_TX_LIMIT * 2n
1577
- }, stateOverrides, RollupAbi, {
1578
- // @note fallback gas estimate to use if the node doesn't support simulation API
1579
- fallbackGasEstimate: MAX_L1_TX_LIMIT
1580
- }).catch((err)=>{
1581
- // In fisherman mode, we expect ValidatorSelection__MissingProposerSignature since fisherman doesn't have proposer signature
1582
- const viemError = formatViemError(err);
1583
- if (this.config.fishermanMode && viemError.message?.includes('ValidatorSelection__MissingProposerSignature')) {
1584
- this.log.debug(`Ignoring expected ValidatorSelection__MissingProposerSignature error in fisherman mode`);
1585
- // Return a minimal simulation result with the fallback gas estimate
1586
- return {
1587
- gasUsed: MAX_L1_TX_LIMIT,
1588
- logs: []
1589
- };
1590
- }
1591
- this.log.error(`Failed to simulate propose tx`, viemError);
1592
- this.backupFailedTx({
1593
- id: keccak256(rollupData),
1594
- failureType: 'simulation',
1595
- request: {
1596
- to: this.rollupContract.address,
1597
- data: rollupData
1598
- },
1599
- l1BlockNumber: l1BlockNumber.toString(),
1600
- error: {
1601
- message: viemError.message,
1602
- name: viemError.name
1603
- },
1604
- context: {
1605
- actions: [
1606
- 'propose'
1607
- ],
1608
- slot: Number(args[0].header.slotNumber),
1609
- sender: this.getSenderAddress().toString()
1610
- }
1611
- });
1612
- throw err;
1613
- });
1614
1495
  return {
1615
- rollupData,
1616
- simulationResult
1496
+ args,
1497
+ blobEvaluationGas,
1498
+ rollupData
1617
1499
  };
1618
1500
  }
1619
- async addProposeTx(checkpoint, encodedData, opts = {}, timestamp) {
1501
+ async addProposeTx(checkpoint, encodedData, opts = {}) {
1620
1502
  const slot = checkpoint.header.slotNumber;
1621
1503
  const timer = new Timer();
1622
1504
  const kzg = Blob.getViemKzgInstance();
1623
- const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx(encodedData, timestamp, opts);
1505
+ const { rollupData, blobEvaluationGas } = await this.prepareProposeTx(encodedData);
1624
1506
  const startBlock = await this.l1TxUtils.getBlockNumber();
1625
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil(Number(simulationResult.gasUsed) * 64 / 63)) + blobEvaluationGas + SequencerPublisher.MULTICALL_OVERHEAD_GAS_GUESS);
1626
1507
  // Send the blobs to the blob client preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
1627
1508
  // tx fails but it does get mined. We make sure that the blobs are sent to the blob client regardless of the tx outcome.
1628
1509
  void Promise.resolve().then(()=>this.blobClient.sendBlobsToFilestore(encodedData.blobs).catch((_err)=>{
@@ -1636,9 +1517,10 @@ export class SequencerPublisher {
1636
1517
  },
1637
1518
  lastValidL2Slot: checkpoint.header.slotNumber,
1638
1519
  gasConfig: {
1639
- ...opts,
1640
- gasLimit
1520
+ txTimeoutAt: opts.txTimeoutAt,
1521
+ gasLimit: undefined
1641
1522
  },
1523
+ blobEvaluationGas,
1642
1524
  blobConfig: {
1643
1525
  blobs: encodedData.blobs.map((b)=>b.data),
1644
1526
  kzg
@@ -1648,7 +1530,11 @@ export class SequencerPublisher {
1648
1530
  return false;
1649
1531
  }
1650
1532
  const { receipt, stats, errorMsg } = result;
1651
- const success = receipt && receipt.status === 'success' && tryExtractEvent(receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointProposed');
1533
+ const success = extractEventSuccess(receipt, {
1534
+ address: this.rollupContract.address,
1535
+ abi: RollupAbi,
1536
+ eventName: 'CheckpointProposed'
1537
+ });
1652
1538
  if (success) {
1653
1539
  const endBlock = receipt.blockNumber;
1654
1540
  const inclusionBlocks = Number(endBlock - startBlock);
@@ -1685,7 +1571,7 @@ export class SequencerPublisher {
1685
1571
  }
1686
1572
  });
1687
1573
  }
1688
- /** Returns the timestamp to use when simulating L1 proposal calls */ getNextL1SlotTimestamp() {
1574
+ /** Returns the timestamp of the next L1 slot boundary after now. */ getNextL1SlotTimestamp() {
1689
1575
  const l1Constants = this.epochCache.getL1Constants();
1690
1576
  return getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants);
1691
1577
  }