@aztec/sequencer-client 0.0.1-commit.2448fdb → 0.0.1-commit.2606882

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 (118) hide show
  1. package/README.md +281 -21
  2. package/dest/client/sequencer-client.d.ts +4 -3
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +11 -11
  5. package/dest/config.d.ts +4 -1
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +35 -14
  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 +128 -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 +58 -0
  14. package/dest/global_variable_builder/global_builder.d.ts +4 -10
  15. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  16. package/dest/global_variable_builder/global_builder.js +5 -40
  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/index.d.ts +2 -2
  21. package/dest/index.d.ts.map +1 -1
  22. package/dest/index.js +1 -1
  23. package/dest/publisher/config.d.ts +19 -3
  24. package/dest/publisher/config.d.ts.map +1 -1
  25. package/dest/publisher/config.js +25 -5
  26. package/dest/publisher/index.d.ts +2 -1
  27. package/dest/publisher/index.d.ts.map +1 -1
  28. package/dest/publisher/l1_tx_failed_store/factory.d.ts +11 -0
  29. package/dest/publisher/l1_tx_failed_store/factory.d.ts.map +1 -0
  30. package/dest/publisher/l1_tx_failed_store/factory.js +22 -0
  31. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +58 -0
  32. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -0
  33. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +1 -0
  34. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +15 -0
  35. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -0
  36. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +34 -0
  37. package/dest/publisher/l1_tx_failed_store/index.d.ts +4 -0
  38. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -0
  39. package/dest/publisher/l1_tx_failed_store/index.js +2 -0
  40. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  41. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  42. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  43. package/dest/publisher/sequencer-publisher-factory.d.ts +3 -5
  44. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  45. package/dest/publisher/sequencer-publisher-factory.js +16 -3
  46. package/dest/publisher/sequencer-publisher.d.ts +77 -66
  47. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  48. package/dest/publisher/sequencer-publisher.js +410 -398
  49. package/dest/sequencer/automine/automine_factory.d.ts +54 -0
  50. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  51. package/dest/sequencer/automine/automine_factory.js +84 -0
  52. package/dest/sequencer/automine/automine_sequencer.d.ts +151 -0
  53. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  54. package/dest/sequencer/automine/automine_sequencer.js +509 -0
  55. package/dest/sequencer/chain_state_overrides.d.ts +61 -0
  56. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  57. package/dest/sequencer/chain_state_overrides.js +98 -0
  58. package/dest/sequencer/checkpoint_proposal_job.d.ts +58 -10
  59. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  60. package/dest/sequencer/checkpoint_proposal_job.js +736 -191
  61. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  62. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  63. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  64. package/dest/sequencer/events.d.ts +48 -2
  65. package/dest/sequencer/events.d.ts.map +1 -1
  66. package/dest/sequencer/index.d.ts +3 -1
  67. package/dest/sequencer/index.d.ts.map +1 -1
  68. package/dest/sequencer/index.js +2 -0
  69. package/dest/sequencer/metrics.d.ts +13 -10
  70. package/dest/sequencer/metrics.d.ts.map +1 -1
  71. package/dest/sequencer/metrics.js +45 -20
  72. package/dest/sequencer/sequencer.d.ts +59 -12
  73. package/dest/sequencer/sequencer.d.ts.map +1 -1
  74. package/dest/sequencer/sequencer.js +344 -105
  75. package/dest/sequencer/timetable.d.ts +11 -1
  76. package/dest/sequencer/timetable.d.ts.map +1 -1
  77. package/dest/sequencer/timetable.js +41 -36
  78. package/dest/sequencer/types.d.ts +2 -2
  79. package/dest/sequencer/types.d.ts.map +1 -1
  80. package/dest/test/mock_checkpoint_builder.d.ts +1 -1
  81. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -1
  82. package/dest/test/mock_checkpoint_builder.js +0 -2
  83. package/dest/test/utils.d.ts +1 -1
  84. package/dest/test/utils.d.ts.map +1 -1
  85. package/dest/test/utils.js +7 -6
  86. package/package.json +27 -28
  87. package/src/client/sequencer-client.ts +19 -20
  88. package/src/config.ts +40 -11
  89. package/src/global_variable_builder/README.md +44 -0
  90. package/src/global_variable_builder/fee_predictor.ts +172 -0
  91. package/src/global_variable_builder/fee_provider.ts +75 -0
  92. package/src/global_variable_builder/global_builder.ts +10 -46
  93. package/src/global_variable_builder/index.ts +2 -0
  94. package/src/index.ts +10 -1
  95. package/src/publisher/config.ts +49 -6
  96. package/src/publisher/index.ts +3 -0
  97. package/src/publisher/l1_tx_failed_store/factory.ts +32 -0
  98. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +57 -0
  99. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +46 -0
  100. package/src/publisher/l1_tx_failed_store/index.ts +3 -0
  101. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  102. package/src/publisher/sequencer-publisher-factory.ts +18 -6
  103. package/src/publisher/sequencer-publisher.ts +485 -474
  104. package/src/sequencer/README.md +162 -450
  105. package/src/sequencer/automine/README.md +46 -0
  106. package/src/sequencer/automine/automine_factory.ts +145 -0
  107. package/src/sequencer/automine/automine_sequencer.ts +595 -0
  108. package/src/sequencer/chain_state_overrides.ts +162 -0
  109. package/src/sequencer/checkpoint_proposal_job.ts +857 -201
  110. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  111. package/src/sequencer/events.ts +51 -2
  112. package/src/sequencer/index.ts +2 -0
  113. package/src/sequencer/metrics.ts +57 -24
  114. package/src/sequencer/sequencer.ts +421 -118
  115. package/src/sequencer/timetable.ts +49 -44
  116. package/src/sequencer/types.ts +1 -1
  117. package/src/test/mock_checkpoint_builder.ts +0 -2
  118. package/src/test/utils.ts +28 -10
@@ -370,44 +370,52 @@ 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';
382
+ import { TimeoutError } from '@aztec/foundation/error';
384
383
  import { EthAddress } from '@aztec/foundation/eth-address';
385
384
  import { Signature } from '@aztec/foundation/eth-signature';
386
385
  import { createLogger } from '@aztec/foundation/log';
387
- import { makeBackoff, retry } from '@aztec/foundation/retry';
386
+ import { InterruptibleSleep } from '@aztec/foundation/sleep';
388
387
  import { bufferToHex } from '@aztec/foundation/string';
389
388
  import { Timer } from '@aztec/foundation/timer';
390
- import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts';
389
+ import { EmpireBaseAbi, ErrorsAbi, RollupAbi, SlashingProposerAbi } from '@aztec/l1-artifacts';
391
390
  import { encodeSlashConsensusVotes } from '@aztec/slasher';
392
391
  import { CommitteeAttestationsAndSigners } from '@aztec/stdlib/block';
393
- import { getLastL1SlotTimestampForL2Slot, getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers';
392
+ import { getLastL1SlotTimestampForL2Slot, getNextL1SlotTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
394
393
  import { getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
395
- import { encodeFunctionData, toHex } from 'viem';
394
+ import { encodeFunctionData, keccak256, toHex } from 'viem';
395
+ import { createL1TxFailedStore } from './l1_tx_failed_store/index.js';
396
+ import { SequencerBundleSimulator } from './sequencer-bundle-simulator.js';
396
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
+ }
397
408
  export const Actions = [
398
409
  'invalidate-by-invalid-attestation',
399
410
  'invalidate-by-insufficient-attestations',
400
411
  'propose',
401
412
  'governance-signal',
402
- 'empire-slashing-signal',
403
- 'create-empire-payload',
404
- 'execute-empire-payload',
405
413
  'vote-offenses',
406
414
  'execute-slash'
407
415
  ];
408
416
  // Sorting for actions such that invalidations go before proposals, and proposals go before votes
409
417
  export const compareActions = (a, b)=>Actions.indexOf(a) - Actions.indexOf(b);
410
- _dec = trackSpan('SequencerPublisher.sendRequests'), _dec1 = trackSpan('SequencerPublisher.validateBlockHeader'), _dec2 = trackSpan('SequencerPublisher.validateCheckpointForSubmission');
418
+ _dec = trackSpan('SequencerPublisher.sendRequests'), _dec1 = trackSpan('SequencerPublisher.validateBlockHeader');
411
419
  export class SequencerPublisher {
412
420
  config;
413
421
  static{
@@ -421,49 +429,46 @@ export class SequencerPublisher {
421
429
  _dec1,
422
430
  2,
423
431
  "validateBlockHeader"
424
- ],
425
- [
426
- _dec2,
427
- 2,
428
- "validateCheckpointForSubmission"
429
432
  ]
430
433
  ], []));
431
434
  }
432
435
  interrupted;
433
436
  metrics;
437
+ bundleSimulator;
434
438
  epochCache;
435
- governanceLog;
436
- slashingLog;
439
+ failedTxStore;
440
+ /**
441
+ * ABI used to decode raw revert payloads from dropped bundle entries when the original
442
+ * request did not carry an abi (e.g. the propose request). Merges every contract the
443
+ * publisher can route to so any of their custom errors decode against it.
444
+ */ revertDecoderAbi;
437
445
  lastActions;
438
- isPayloadEmptyCache;
439
- payloadProposedCache;
440
446
  log;
441
447
  ethereumSlotDuration;
442
448
  aztecSlotDuration;
443
- dateProvider;
449
+ /** Date provider for wall-clock time. */ dateProvider;
444
450
  blobClient;
445
- /** Address to use for simulations in fisherman mode (actual proposer's address) */ proposerAddressForSimulation;
451
+ /** Optional callback to obtain a replacement publisher when the current one fails to send. */ getNextPublisher;
446
452
  /** L1 fee analyzer for fisherman mode */ l1FeeAnalyzer;
447
453
  /** Fee asset price oracle for computing price modifiers from Uniswap V4 */ feeAssetPriceOracle;
448
- // A CALL to a cold address is 2700 gas
449
- static MULTICALL_OVERHEAD_GAS_GUESS = 5000n;
450
- // Gas report for VotingWithSigTest shows a max gas of 100k, but we've seen it cost 700k+ in testnet
451
- static VOTE_GAS_GUESS = 800_000n;
454
+ /** Interruptible sleep used by sendRequestsAt to wait until a target timestamp. */ interruptibleSleep;
452
455
  l1TxUtils;
453
456
  rollupContract;
454
457
  govProposerContract;
455
458
  slashingProposerContract;
456
- slashFactoryContract;
457
459
  tracer;
458
460
  requests;
459
461
  constructor(config, deps){
460
462
  this.config = config;
461
463
  this.interrupted = (_initProto(this), false);
462
- this.governanceLog = createLogger('sequencer:publisher:governance');
463
- this.slashingLog = createLogger('sequencer:publisher:slashing');
464
+ this.revertDecoderAbi = mergeAbis([
465
+ RollupAbi,
466
+ SlashingProposerAbi,
467
+ EmpireBaseAbi,
468
+ ErrorsAbi
469
+ ]);
464
470
  this.lastActions = {};
465
- this.isPayloadEmptyCache = new Map();
466
- this.payloadProposedCache = new Set();
471
+ this.interruptibleSleep = new InterruptibleSleep();
467
472
  this.requests = [];
468
473
  this.log = deps.log ?? createLogger('sequencer:publisher');
469
474
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
@@ -472,10 +477,12 @@ export class SequencerPublisher {
472
477
  this.epochCache = deps.epochCache;
473
478
  this.lastActions = deps.lastActions;
474
479
  this.blobClient = deps.blobClient;
480
+ this.dateProvider = deps.dateProvider;
475
481
  const telemetry = deps.telemetry ?? getTelemetryClient();
476
482
  this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher');
477
483
  this.tracer = telemetry.getTracer('SequencerPublisher');
478
484
  this.l1TxUtils = deps.l1TxUtils;
485
+ this.getNextPublisher = deps.getNextPublisher;
479
486
  this.rollupContract = deps.rollupContract;
480
487
  this.govProposerContract = deps.governanceProposerContract;
481
488
  this.slashingProposerContract = deps.slashingProposerContract;
@@ -484,22 +491,49 @@ export class SequencerPublisher {
484
491
  const newSlashingProposer = await this.rollupContract.getSlashingProposer();
485
492
  this.slashingProposerContract = newSlashingProposer;
486
493
  });
487
- this.slashFactoryContract = deps.slashFactoryContract;
488
494
  // Initialize L1 fee analyzer for fisherman mode
489
495
  if (config.fishermanMode) {
490
- this.l1FeeAnalyzer = new L1FeeAnalyzer(this.l1TxUtils.client, deps.dateProvider, createLogger('sequencer:publisher:fee-analyzer'));
496
+ this.l1FeeAnalyzer = new L1FeeAnalyzer(this.l1TxUtils.client, deps.dateProvider, this.log.createChild('fee-analyzer'));
491
497
  }
492
498
  // Initialize fee asset price oracle
493
- this.feeAssetPriceOracle = new FeeAssetPriceOracle(this.l1TxUtils.client, this.rollupContract, createLogger('sequencer:publisher:price-oracle'));
499
+ this.feeAssetPriceOracle = new FeeAssetPriceOracle(this.l1TxUtils.client, this.rollupContract, this.log.createChild('price-oracle'));
500
+ // Initialize failed L1 tx store (optional, for test networks)
501
+ this.failedTxStore = createL1TxFailedStore(config.l1TxFailedStore, this.log);
502
+ this.bundleSimulator = new SequencerBundleSimulator({
503
+ getL1TxUtils: ()=>this.l1TxUtils,
504
+ rollupContract: this.rollupContract,
505
+ epochCache: this.epochCache,
506
+ log: this.log.createChild('bundle-simulator')
507
+ });
508
+ }
509
+ /**
510
+ * Backs up a failed L1 transaction to the configured store for debugging.
511
+ * Does nothing if no store is configured.
512
+ */ backupFailedTx(failedTx) {
513
+ if (!this.failedTxStore) {
514
+ return;
515
+ }
516
+ const tx = {
517
+ ...failedTx,
518
+ timestamp: Date.now()
519
+ };
520
+ // Fire and forget - don't block on backup
521
+ void this.failedTxStore.then((store)=>store?.saveFailedTx(tx)).catch((err)=>{
522
+ this.log.warn(`Failed to backup failed L1 tx to store`, err);
523
+ });
494
524
  }
495
525
  getRollupContract() {
496
526
  return this.rollupContract;
497
527
  }
498
528
  /**
499
529
  * Gets the fee asset price modifier from the oracle.
500
- * Returns 0n if the oracle query fails.
501
- */ getFeeAssetPriceModifier() {
502
- return this.feeAssetPriceOracle.computePriceModifier();
530
+ *
531
+ * @param predictedParentEthPerFeeAssetE12 - Optional predicted parent eth-per-fee-asset (E12).
532
+ * Pipelined proposers should pass the value from the predicted parent fee header so the
533
+ * modifier matches the parent L1 will use when applying it.
534
+ * @returns The fee asset price modifier in basis points, or 0n if the oracle query fails.
535
+ */ getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12) {
536
+ return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12);
503
537
  }
504
538
  getSenderAddress() {
505
539
  return this.l1TxUtils.getSenderAddress();
@@ -509,17 +543,11 @@ export class SequencerPublisher {
509
543
  */ getL1FeeAnalyzer() {
510
544
  return this.l1FeeAnalyzer;
511
545
  }
512
- /**
513
- * Sets the proposer address to use for simulations in fisherman mode.
514
- * @param proposerAddress - The actual proposer's address to use for balance lookups in simulations
515
- */ setProposerAddressForSimulation(proposerAddress) {
516
- this.proposerAddressForSimulation = proposerAddress;
517
- }
518
546
  addRequest(request) {
519
547
  this.requests.push(request);
520
548
  }
521
549
  getCurrentL2Slot() {
522
- return this.epochCache.getEpochAndSlotNow().slot;
550
+ return this.epochCache.getSlotNow();
523
551
  }
524
552
  /**
525
553
  * Clears all pending requests without sending them.
@@ -572,11 +600,15 @@ export class SequencerPublisher {
572
600
  }
573
601
  /**
574
602
  * Sends all requests that are still valid.
603
+ * @param targetSlot - The target L2 slot for this send. When provided (the production path, via
604
+ * sendRequestsAt), it is threaded into bundleSimulate so the block.timestamp override matches
605
+ * the slot the propose is built for. When omitted, falls back to getCurrentL2Slot() for the
606
+ * AutomineSequencer, which publishes synchronously within the current slot.
575
607
  * @returns one of:
576
608
  * - A receipt and stats if the tx succeeded
577
609
  * - a receipt and errorMsg if it failed on L1
578
610
  * - undefined if no valid requests are found OR the tx failed to send.
579
- */ async sendRequests() {
611
+ */ async sendRequests(targetSlot) {
580
612
  const requestsToProcess = [
581
613
  ...this.requests
582
614
  ];
@@ -584,10 +616,9 @@ export class SequencerPublisher {
584
616
  if (this.interrupted || requestsToProcess.length === 0) {
585
617
  return undefined;
586
618
  }
587
- const currentL2Slot = this.getCurrentL2Slot();
619
+ const currentL2Slot = targetSlot ?? this.getCurrentL2Slot();
588
620
  this.log.debug(`Sending requests on L2 slot ${currentL2Slot}`);
589
621
  const validRequests = requestsToProcess.filter((request)=>request.lastValidL2Slot >= currentL2Slot);
590
- const validActions = validRequests.map((x)=>x.action);
591
622
  const expiredActions = requestsToProcess.filter((request)=>request.lastValidL2Slot < currentL2Slot).map((x)=>x.action);
592
623
  if (validRequests.length !== requestsToProcess.length) {
593
624
  this.log.warn(`Some requests were expired for slot ${currentL2Slot}`, {
@@ -605,49 +636,53 @@ export class SequencerPublisher {
605
636
  this.log.debug(`No valid requests to send`);
606
637
  return undefined;
607
638
  }
608
- // @note - we can only have one blob config per bundle
609
- // find requests with gas and blob configs
610
- // See https://github.com/AztecProtocol/aztec-packages/issues/11513
639
+ // Collect earliest txTimeoutAt across all requests.
611
640
  const gasConfigs = validRequests.filter((request)=>request.gasConfig).map((request)=>request.gasConfig);
612
- const blobConfigs = validRequests.filter((request)=>request.blobConfig).map((request)=>request.blobConfig);
613
- if (blobConfigs.length > 1) {
614
- throw new Error('Multiple blob configs found');
615
- }
616
- const blobConfig = blobConfigs[0];
617
- // Merge gasConfigs. Yields the sum of gasLimits, and the earliest txTimeoutAt, or undefined if no gasConfig sets them.
618
- const gasLimits = gasConfigs.map((g)=>g?.gasLimit).filter((g)=>g !== undefined);
619
- let gasLimit = gasLimits.length > 0 ? sumBigint(gasLimits) : undefined; // sum
620
- // Cap at L1 block gas limit so the node accepts the tx ("gas limit too high" otherwise).
621
- const maxGas = MAX_L1_TX_LIMIT;
622
- if (gasLimit !== undefined && gasLimit > maxGas) {
623
- this.log.debug('Capping bundled tx gas limit to L1 max', {
624
- requested: gasLimit,
625
- capped: maxGas
626
- });
627
- gasLimit = maxGas;
628
- }
629
641
  const txTimeoutAts = gasConfigs.map((g)=>g?.txTimeoutAt).filter((g)=>g !== undefined);
630
- const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map((g)=>g.getTime()))) : undefined; // earliest
631
- const txConfig = {
632
- gasLimit,
633
- txTimeoutAt
634
- };
642
+ const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map((g)=>g.getTime()))) : undefined;
635
643
  // Sort the requests so that proposals always go first
636
644
  // This ensures the committee gets precomputed correctly
637
645
  validRequests.sort((a, b)=>compareActions(a.action, b.action));
638
646
  try {
647
+ // Bundle-level eth_simulateV1: filters out entries that revert and derives the gasLimit.
648
+ const bundleResult = await this.bundleSimulator.simulate(validRequests, currentL2Slot);
649
+ if (bundleResult.kind === 'aborted') {
650
+ this.logDroppedInSim(bundleResult.droppedRequests);
651
+ void this.backupDroppedInSim(bundleResult.droppedRequests);
652
+ return undefined;
653
+ }
654
+ const { requests, droppedRequests, gasLimit } = bundleResult.kind === 'fallback' ? {
655
+ requests: bundleResult.requests,
656
+ droppedRequests: bundleResult.droppedRequests,
657
+ gasLimit: MAX_L1_TX_LIMIT
658
+ } : bundleResult;
659
+ this.logDroppedInSim(droppedRequests);
660
+ // Compute blobConfig from survivors (not original validRequests) so that if the propose
661
+ // entry was dropped by bundleSimulate we don't attach a blob-typed config to a non-blob tx.
662
+ const [blobConfig] = requests.filter((r)=>r.blobConfig).map((r)=>r.blobConfig);
663
+ const txConfig = {
664
+ gasLimit,
665
+ txTimeoutAt
666
+ };
639
667
  this.log.debug('Forwarding transactions', {
640
- validRequests: validRequests.map((request)=>request.action),
668
+ requests: requests.map((request)=>request.action),
641
669
  txConfig
642
670
  });
643
- const result = await Multicall3.forward(validRequests.map((request)=>request.request), this.l1TxUtils, txConfig, blobConfig, this.rollupContract.address, this.log);
644
- const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(validRequests, result);
671
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
672
+ if (result === undefined) {
673
+ return undefined;
674
+ }
675
+ const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(requests, result);
676
+ const allFailedActions = [
677
+ ...failedActions,
678
+ ...droppedRequests.map((d)=>d.request.action)
679
+ ];
645
680
  return {
646
681
  result,
647
682
  expiredActions,
648
- sentActions: validActions,
683
+ sentActions: requests.map((x)=>x.action),
649
684
  successfulActions,
650
- failedActions
685
+ failedActions: allFailedActions
651
686
  };
652
687
  } catch (err) {
653
688
  const viemError = formatViemError(err);
@@ -661,61 +696,173 @@ export class SequencerPublisher {
661
696
  }
662
697
  }
663
698
  }
664
- callbackBundledTransactions(requests, result) {
665
- const actionsListStr = requests.map((r)=>r.action).join(', ');
666
- if (result instanceof FormattedViemError) {
667
- this.log.error(`Failed to publish bundled transactions (${actionsListStr})`, result);
668
- return {
669
- failedActions: requests.map((r)=>r.action)
670
- };
671
- } else {
672
- this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
673
- result,
674
- requests: requests.map((r)=>({
675
- ...r,
676
- // Avoid logging large blob data
677
- blobConfig: r.blobConfig ? {
678
- ...r.blobConfig,
679
- blobs: r.blobConfig.blobs.map((b)=>({
680
- size: trimmedBytesLength(b)
681
- }))
682
- } : undefined
683
- }))
699
+ /** Logs entries dropped by bundle simulation as warnings on the publisher's logger. */ logDroppedInSim(dropped) {
700
+ for (const drop of dropped){
701
+ const revertReasonDecoded = drop.revertReason ?? tryDecodeRevertReason(drop.returnData, this.revertDecoderAbi);
702
+ this.log.warn('Bundle entry dropped: action reverted in sim', {
703
+ action: drop.request.action,
704
+ revertReason: revertReasonDecoded ?? drop.returnData,
705
+ revertReasonDecoded,
706
+ returnData: drop.returnData
684
707
  });
685
- const successfulActions = [];
686
- const failedActions = [];
687
- for (const request of requests){
688
- if (request.checkSuccess(request.request, result)) {
689
- successfulActions.push(request.action);
690
- } else {
691
- failedActions.push(request.action);
708
+ }
709
+ }
710
+ /** Backs up entries dropped by bundle simulation, one record per dropped action. */ async backupDroppedInSim(dropped) {
711
+ if (dropped.length === 0) {
712
+ return;
713
+ }
714
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
715
+ for (const { request: req } of dropped){
716
+ this.backupFailedTx({
717
+ id: keccak256(req.request.data),
718
+ failureType: 'simulation',
719
+ request: {
720
+ to: req.request.to,
721
+ data: req.request.data
722
+ },
723
+ l1BlockNumber: l1BlockNumber.toString(),
724
+ error: {
725
+ message: 'Bundle entry dropped: action reverted in sim'
726
+ },
727
+ context: {
728
+ actions: [
729
+ req.action
730
+ ],
731
+ sender: this.getSenderAddress().toString()
692
732
  }
733
+ });
734
+ }
735
+ }
736
+ /**
737
+ * Forwards transactions via Multicall3, rotating to the next available publisher if a send
738
+ * failure occurs (i.e. the tx never reached the chain).
739
+ * On-chain reverts and simulation errors are returned as-is without rotation.
740
+ */ async forwardWithPublisherRotation(validRequests, txConfig, blobConfig) {
741
+ if (!txConfig?.gasLimit) {
742
+ throw new Error('gasLimit is required for bundled transactions');
743
+ }
744
+ const txConfigWithGasLimit = txConfig;
745
+ const triedAddresses = [];
746
+ let currentPublisher = this.l1TxUtils;
747
+ while(true){
748
+ if (txConfig.txTimeoutAt && new Date() > txConfig.txTimeoutAt) {
749
+ this.log.warn(`Tx timeout (${txConfig.txTimeoutAt.toISOString()}) elapsed; stopping publisher rotation`, {
750
+ triedAddresses: triedAddresses.map((a)=>a.toString())
751
+ });
752
+ return undefined;
753
+ }
754
+ triedAddresses.push(currentPublisher.getSenderAddress());
755
+ try {
756
+ const result = await Multicall3.forward(validRequests.map((r)=>r.request), currentPublisher, txConfigWithGasLimit, blobConfig, {
757
+ gasLimitRequired: true
758
+ });
759
+ this.l1TxUtils = currentPublisher;
760
+ return result;
761
+ } catch (err) {
762
+ if (err instanceof TimeoutError) {
763
+ throw err;
764
+ }
765
+ if (err instanceof MulticallForwarderRevertedError) {
766
+ this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
767
+ transactionHash: err.receipt.transactionHash
768
+ });
769
+ return undefined;
770
+ }
771
+ const viemError = formatViemError(err);
772
+ if (!this.getNextPublisher) {
773
+ this.log.error('Failed to publish bundled transactions', viemError);
774
+ return undefined;
775
+ }
776
+ this.log.warn(`Publisher ${currentPublisher.getSenderAddress()} failed to send, rotating to next publisher`, viemError);
777
+ const nextPublisher = await this.getNextPublisher([
778
+ ...triedAddresses
779
+ ]);
780
+ if (!nextPublisher) {
781
+ this.log.error(`All available publishers exhausted (tried ${triedAddresses.length}), failed to publish bundled transactions`, viemError, {
782
+ triedAddresses: triedAddresses.map((a)=>a.toString())
783
+ });
784
+ return undefined;
785
+ }
786
+ currentPublisher = nextPublisher;
787
+ }
788
+ }
789
+ }
790
+ /*
791
+ * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
792
+ * Sleeps until one L1 slot before the L2 slot boundary so the tx has a chance of being
793
+ * picked up by the first L1 block of the L2 slot.
794
+ * NB: there is a known correctness risk — being included in the L1 block right before the
795
+ * L2 slot starts would revert propose with HeaderLib__InvalidSlotNumber.
796
+ * Uses InterruptibleSleep so it can be cancelled via interrupt().
797
+ */ async sendRequestsAt(targetSlot) {
798
+ const l1Constants = this.epochCache.getL1Constants();
799
+ // Start of the target L2 slot, in ms (getTimestampForSlot returns seconds).
800
+ const startOfTargetSlotMs = Number(getTimestampForSlot(targetSlot, l1Constants)) * 1000;
801
+ // Aim to be in the mempool one L1 slot before the L2 slot starts, so we have a chance of
802
+ // being picked up by the first L1 block of the L2 slot.
803
+ const submitAfterMs = startOfTargetSlotMs - Number(this.ethereumSlotDuration) * 1000;
804
+ const sleepMs = submitAfterMs - this.dateProvider.now();
805
+ if (sleepMs > 0) {
806
+ this.log.debug(`Sleeping ${sleepMs}ms before sending requests`, {
807
+ targetSlot,
808
+ submitAfterMs
809
+ });
810
+ await this.interruptibleSleep.sleep(sleepMs);
811
+ }
812
+ if (this.interrupted) {
813
+ return undefined;
814
+ }
815
+ return this.sendRequests(targetSlot);
816
+ }
817
+ callbackBundledTransactions(requests, result) {
818
+ const actionsListStr = requests.map((r)=>r.action).join(', ');
819
+ this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
820
+ result,
821
+ requests: requests.map((r)=>({
822
+ ...r,
823
+ // Avoid logging large blob data
824
+ blobConfig: r.blobConfig ? {
825
+ ...r.blobConfig,
826
+ blobs: r.blobConfig.blobs.map((b)=>({
827
+ size: trimmedBytesLength(b)
828
+ }))
829
+ } : undefined
830
+ }))
831
+ });
832
+ const successfulActions = [];
833
+ const failedActions = [];
834
+ for (const request of requests){
835
+ if (request.checkSuccess(request.request, result)) {
836
+ successfulActions.push(request.action);
837
+ } else {
838
+ failedActions.push(request.action);
693
839
  }
694
- return {
695
- successfulActions,
696
- failedActions
697
- };
698
840
  }
841
+ return {
842
+ successfulActions,
843
+ failedActions
844
+ };
699
845
  }
700
846
  /**
701
847
  * @notice Will call `canProposeAt` to make sure that it is possible to propose
702
848
  * @param tipArchive - The archive to check
703
849
  * @returns The slot and block number if it is possible to propose, undefined otherwise
704
- */ canProposeAt(tipArchive, msgSender, opts = {}) {
850
+ */ async canProposeAt(tipArchive, msgSender, simulationOverridesPlan) {
705
851
  // TODO: #14291 - should loop through multiple keys to check if any of them can propose
706
- const ignoredErrors = [
852
+ // These errors are expected when we cannot actually propose right now — usually because our
853
+ // local view of the chain is ahead of L1 (proposed parent hasn't landed yet, or someone
854
+ // else has just landed the slot, or the archive override doesn't match). We log a warn and
855
+ // skip the proposal; we do NOT treat these as bugs.
856
+ const expectedErrors = [
707
857
  'SlotAlreadyInChain',
708
858
  'InvalidProposer',
709
859
  'InvalidArchive'
710
860
  ];
711
- const pipelined = opts.pipelined ?? false;
712
- const slotOffset = pipelined ? this.aztecSlotDuration : 0n;
861
+ const slotOffset = this.aztecSlotDuration;
713
862
  const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset;
714
- return this.rollupContract.canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, {
715
- forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber
716
- }).catch((err)=>{
717
- if (err instanceof FormattedViemError && ignoredErrors.find((e)=>err.message.includes(e))) {
718
- this.log.warn(`Failed canProposeAtTime check with ${ignoredErrors.find((e)=>err.message.includes(e))}`, {
863
+ return this.rollupContract.canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan)).catch((err)=>{
864
+ if (err instanceof FormattedViemError && expectedErrors.find((e)=>err.message.includes(e))) {
865
+ this.log.warn(`Failed canProposeAtTime check with ${expectedErrors.find((e)=>err.message.includes(e))}`, {
719
866
  error: err.message
720
867
  });
721
868
  } else {
@@ -729,22 +876,23 @@ export class SequencerPublisher {
729
876
  * @dev This is a convenience function that can be used by the sequencer to validate a "partial" header.
730
877
  * It will throw if the block header is invalid.
731
878
  * @param header - The block header to validate
732
- */ async validateBlockHeader(header, opts) {
879
+ */ async validateBlockHeader(header, simulationOverridesPlan) {
733
880
  const flags = {
734
881
  ignoreDA: true,
735
882
  ignoreSignatures: true
736
883
  };
737
884
  const args = [
738
885
  header.toViem(),
739
- CommitteeAttestationsAndSigners.empty().getPackedAttestations(),
886
+ CommitteeAttestationsAndSigners.packAttestations([]),
740
887
  [],
741
888
  Signature.empty().toViemSignature(),
742
889
  `0x${'0'.repeat(64)}`,
743
890
  header.blobsHash.toString(),
744
891
  flags
745
892
  ];
746
- const ts = this.getSimulationTimestamp(header.slotNumber);
747
- const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride(opts?.forcePendingCheckpointNumber);
893
+ const l1Constants = this.epochCache.getL1Constants();
894
+ const ts = getLastL1SlotTimestampForL2Slot(header.slotNumber, l1Constants);
895
+ const stateOverrides = await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan);
748
896
  let balance = 0n;
749
897
  if (this.config.fishermanMode) {
750
898
  // In fisherman mode, we can't know where the proposer is publishing from
@@ -796,6 +944,7 @@ export class SequencerPublisher {
796
944
  ...logData,
797
945
  request
798
946
  });
947
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
799
948
  try {
800
949
  const { gasUsed } = await this.l1TxUtils.simulate(request, undefined, undefined, mergeAbis([
801
950
  request.abi ?? [],
@@ -811,6 +960,7 @@ export class SequencerPublisher {
811
960
  gasUsed,
812
961
  checkpointNumber,
813
962
  forcePendingCheckpointNumber: CheckpointNumber(checkpointNumber - 1),
963
+ lastArchive: validationResult.checkpoint.lastArchive,
814
964
  reason
815
965
  };
816
966
  } catch (err) {
@@ -823,8 +973,8 @@ export class SequencerPublisher {
823
973
  request,
824
974
  error: viemError.message
825
975
  });
826
- const latestPendingCheckpointNumber = await this.rollupContract.getCheckpointNumber();
827
- if (latestPendingCheckpointNumber < checkpointNumber) {
976
+ const latestProposedCheckpointNumber = await this.rollupContract.getCheckpointNumber();
977
+ if (latestProposedCheckpointNumber < checkpointNumber) {
828
978
  this.log.verbose(`Checkpoint ${checkpointNumber} has already been invalidated`, {
829
979
  ...logData
830
980
  });
@@ -838,6 +988,27 @@ export class SequencerPublisher {
838
988
  }
839
989
  // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
840
990
  this.log.error(`Simulation for invalidate checkpoint ${checkpointNumber} failed`, viemError, logData);
991
+ this.backupFailedTx({
992
+ id: keccak256(request.data),
993
+ failureType: 'simulation',
994
+ request: {
995
+ to: request.to,
996
+ data: request.data,
997
+ value: request.value?.toString()
998
+ },
999
+ l1BlockNumber: l1BlockNumber.toString(),
1000
+ error: {
1001
+ message: viemError.message,
1002
+ name: viemError.name
1003
+ },
1004
+ context: {
1005
+ actions: [
1006
+ `invalidate-${reason}`
1007
+ ],
1008
+ checkpointNumber,
1009
+ sender: this.getSenderAddress().toString()
1010
+ }
1011
+ });
841
1012
  throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, {
842
1013
  cause: viemError
843
1014
  });
@@ -853,7 +1024,7 @@ export class SequencerPublisher {
853
1024
  reason
854
1025
  };
855
1026
  this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
856
- const attestationsAndSigners = new CommitteeAttestationsAndSigners(validationResult.attestations).getPackedAttestations();
1027
+ const attestationsAndSigners = CommitteeAttestationsAndSigners.packAttestations(validationResult.attestations);
857
1028
  if (reason === 'invalid-attestation') {
858
1029
  return this.rollupContract.buildInvalidateBadAttestationRequest(checkpoint.checkpointNumber, attestationsAndSigners, committee, validationResult.invalidIndex);
859
1030
  } else if (reason === 'insufficient-attestations') {
@@ -863,25 +1034,6 @@ export class SequencerPublisher {
863
1034
  throw new Error(`Unknown reason for invalidation`);
864
1035
  }
865
1036
  }
866
- /** Simulates `propose` to make sure that the checkpoint is valid for submission */ async validateCheckpointForSubmission(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, options) {
867
- const blobFields = checkpoint.toBlobFields();
868
- const blobs = await getBlobsPerL1Block(blobFields);
869
- const blobInput = getPrefixedEthBlobCommitments(blobs);
870
- const args = [
871
- {
872
- header: checkpoint.header.toViem(),
873
- archive: toHex(checkpoint.archive.root.toBuffer()),
874
- oracleInput: {
875
- feeAssetPriceModifier: checkpoint.feeAssetPriceModifier
876
- }
877
- },
878
- attestationsAndSigners.getPackedAttestations(),
879
- attestationsAndSigners.getSigners().map((signer)=>signer.toString()),
880
- attestationsAndSignersSignature.toViemSignature(),
881
- blobInput
882
- ];
883
- await this.simulateProposeTx(args, options);
884
- }
885
1037
  async enqueueCastSignalHelper(slotNumber, signalType, payload, base, signerAddress, signer) {
886
1038
  if (this.lastActions[signalType] && this.lastActions[signalType] === slotNumber) {
887
1039
  this.log.debug(`Skipping duplicate vote cast signal ${signalType} for slot ${slotNumber}`);
@@ -902,30 +1054,26 @@ export class SequencerPublisher {
902
1054
  if (roundInfo.lastSignalSlot >= slotNumber) {
903
1055
  return false;
904
1056
  }
905
- if (await this.isPayloadEmpty(payload)) {
1057
+ if (await base.isPayloadEmpty(payload)) {
906
1058
  this.log.warn(`Skipping vote cast for payload with empty code`);
907
1059
  return false;
908
1060
  }
909
- // Check if payload was already submitted to governance
910
- const cacheKey = payload.toString();
911
- if (!this.payloadProposedCache.has(cacheKey)) {
912
- try {
913
- const l1StartBlock = await this.rollupContract.getL1StartBlock();
914
- const proposed = await retry(()=>base.hasPayloadBeenProposed(payload.toString(), l1StartBlock), 'Check if payload was proposed', makeBackoff([
915
- 0,
916
- 1,
917
- 2
918
- ]), this.log, true);
919
- if (proposed) {
920
- this.payloadProposedCache.add(cacheKey);
921
- }
922
- } catch (err) {
923
- this.log.warn(`Failed to check if payload ${payload} was proposed after retries, skipping signal`, err);
924
- return false;
925
- }
1061
+ // Skip signaling if there is already a live (non-terminal) Governance proposal for this
1062
+ // payload. This is intentionally not cached: a previously-live proposal may transition to
1063
+ // a terminal state (Dropped/Rejected/Expired/Executed), at which point we may want to re-signal
1064
+ // the same payload in a future round.
1065
+ let proposed = false;
1066
+ try {
1067
+ proposed = await base.hasActiveProposalWithPayload(payload.toString());
1068
+ } catch (err) {
1069
+ // We deliberately swallow the error and proceed to signal. Failing closed (skipping the
1070
+ // signal) on transient RPC errors would let a flaky L1 endpoint silence governance
1071
+ // participation entirely; failing open at worst produces a duplicate signal that the
1072
+ // contract will simply count alongside others in the round.
1073
+ this.log.error(`Failed to check if payload ${payload} was already proposed (signalling anyway)`, err);
926
1074
  }
927
- if (this.payloadProposedCache.has(cacheKey)) {
928
- this.log.info(`Payload ${payload} was already proposed to governance, stopping signals`);
1075
+ if (proposed) {
1076
+ this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`);
929
1077
  return false;
930
1078
  }
931
1079
  const cachedLastVote = this.lastActions[signalType];
@@ -938,34 +1086,17 @@ export class SequencerPublisher {
938
1086
  signer: this.l1TxUtils.client.account?.address,
939
1087
  lastValidL2Slot: slotNumber
940
1088
  });
941
- const timestamp = this.getSimulationTimestamp(slotNumber);
942
- try {
943
- await this.l1TxUtils.simulate(request, {
944
- time: timestamp
945
- }, [], mergeAbis([
946
- request.abi ?? [],
947
- ErrorsAbi
948
- ]));
949
- this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, {
950
- request
951
- });
952
- } catch (err) {
953
- const viemError = formatViemError(err);
954
- this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, viemError, {
955
- simulationTimestamp: timestamp
956
- });
957
- // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself
958
- }
959
1089
  // TODO(palla/slash): All votes (governance and slashing) should txTimeoutAt at the end of the slot.
960
1090
  this.addRequest({
961
- gasConfig: {
962
- gasLimit: SequencerPublisher.VOTE_GAS_GUESS
963
- },
964
1091
  action,
965
1092
  request,
966
1093
  lastValidL2Slot: slotNumber,
967
1094
  checkSuccess: (_request, result)=>{
968
- const success = result && result.receipt && result.receipt.status === 'success' && tryExtractEvent(result.receipt.logs, base.address.toString(), EmpireBaseAbi, 'SignalCast');
1095
+ const success = result && extractEventSuccess(result.receipt, {
1096
+ address: base.address.toString(),
1097
+ abi: EmpireBaseAbi,
1098
+ eventName: 'SignalCast'
1099
+ });
969
1100
  const logData = {
970
1101
  ...result,
971
1102
  slotNumber,
@@ -984,16 +1115,6 @@ export class SequencerPublisher {
984
1115
  });
985
1116
  return true;
986
1117
  }
987
- async isPayloadEmpty(payload) {
988
- const key = payload.toString();
989
- const cached = this.isPayloadEmptyCache.get(key);
990
- if (cached) {
991
- return cached;
992
- }
993
- const isEmpty = !await this.l1TxUtils.getCode(payload);
994
- this.isPayloadEmptyCache.set(key, isEmpty);
995
- return isEmpty;
996
- }
997
1118
  /**
998
1119
  * Enqueues a governance castSignal transaction to cast a signal for a given slot number.
999
1120
  * @param slotNumber - The slot number to cast a signal for.
@@ -1008,43 +1129,6 @@ export class SequencerPublisher {
1008
1129
  }
1009
1130
  for (const action of actions){
1010
1131
  switch(action.type){
1011
- case 'vote-empire-payload':
1012
- {
1013
- if (this.slashingProposerContract?.type !== 'empire') {
1014
- this.log.error('Cannot vote for empire payload on non-empire slashing contract');
1015
- break;
1016
- }
1017
- this.log.debug(`Enqueuing slashing vote for payload ${action.payload} at slot ${slotNumber}`, {
1018
- signerAddress
1019
- });
1020
- await this.enqueueCastSignalHelper(slotNumber, 'empire-slashing-signal', action.payload, this.slashingProposerContract, signerAddress, signer);
1021
- break;
1022
- }
1023
- case 'create-empire-payload':
1024
- {
1025
- this.log.debug(`Enqueuing slashing create payload at slot ${slotNumber}`, {
1026
- slotNumber,
1027
- signerAddress
1028
- });
1029
- const request = this.slashFactoryContract.buildCreatePayloadRequest(action.data);
1030
- await this.simulateAndEnqueueRequest('create-empire-payload', request, (receipt)=>!!this.slashFactoryContract.tryExtractSlashPayloadCreatedEvent(receipt.logs), slotNumber);
1031
- break;
1032
- }
1033
- case 'execute-empire-payload':
1034
- {
1035
- this.log.debug(`Enqueuing slashing execute payload at slot ${slotNumber}`, {
1036
- slotNumber,
1037
- signerAddress
1038
- });
1039
- if (this.slashingProposerContract?.type !== 'empire') {
1040
- this.log.error('Cannot execute slashing payload on non-empire slashing contract');
1041
- return false;
1042
- }
1043
- const empireSlashingProposer = this.slashingProposerContract;
1044
- const request = empireSlashingProposer.buildExecuteRoundRequest(action.round);
1045
- await this.simulateAndEnqueueRequest('execute-empire-payload', request, (receipt)=>!!empireSlashingProposer.tryExtractPayloadSubmittedEvent(receipt.logs), slotNumber);
1046
- break;
1047
- }
1048
1132
  case 'vote-offenses':
1049
1133
  {
1050
1134
  this.log.debug(`Enqueuing slashing vote for ${action.votes.length} votes at slot ${slotNumber}`, {
@@ -1053,14 +1137,17 @@ export class SequencerPublisher {
1053
1137
  votesCount: action.votes.length,
1054
1138
  signerAddress
1055
1139
  });
1056
- if (this.slashingProposerContract?.type !== 'tally') {
1057
- this.log.error('Cannot vote for slashing offenses on non-tally slashing contract');
1140
+ if (!this.slashingProposerContract) {
1141
+ this.log.error('No slashing proposer contract available');
1058
1142
  return false;
1059
1143
  }
1060
- const tallySlashingProposer = this.slashingProposerContract;
1061
1144
  const votes = bufferToHex(encodeSlashConsensusVotes(action.votes));
1062
- const request = await tallySlashingProposer.buildVoteRequestFromSigner(votes, slotNumber, signer);
1063
- await this.simulateAndEnqueueRequest('vote-offenses', request, (receipt)=>!!tallySlashingProposer.tryExtractVoteCastEvent(receipt.logs), slotNumber);
1145
+ const request = await this.slashingProposerContract.buildVoteRequestFromSigner(votes, slotNumber, signer);
1146
+ this.enqueueRequest('vote-offenses', request, {
1147
+ address: this.slashingProposerContract.address.toString(),
1148
+ abi: SlashingProposerAbi,
1149
+ eventName: 'VoteCast'
1150
+ }, slotNumber);
1064
1151
  break;
1065
1152
  }
1066
1153
  case 'execute-slash':
@@ -1070,13 +1157,16 @@ export class SequencerPublisher {
1070
1157
  round: action.round,
1071
1158
  signerAddress
1072
1159
  });
1073
- if (this.slashingProposerContract?.type !== 'tally') {
1074
- this.log.error('Cannot execute slashing offenses on non-tally slashing contract');
1160
+ if (!this.slashingProposerContract) {
1161
+ this.log.error('No slashing proposer contract available');
1075
1162
  return false;
1076
1163
  }
1077
- const tallySlashingProposer = this.slashingProposerContract;
1078
- const request = tallySlashingProposer.buildExecuteRoundRequest(action.round, action.committees);
1079
- await this.simulateAndEnqueueRequest('execute-slash', request, (receipt)=>!!tallySlashingProposer.tryExtractRoundExecutedEvent(receipt.logs), slotNumber);
1164
+ const executeRequest = this.slashingProposerContract.buildExecuteRoundRequest(action.round, action.committees);
1165
+ this.enqueueRequest('execute-slash', executeRequest, {
1166
+ address: this.slashingProposerContract.address.toString(),
1167
+ abi: SlashingProposerAbi,
1168
+ eventName: 'RoundExecuted'
1169
+ }, slotNumber);
1080
1170
  break;
1081
1171
  }
1082
1172
  default:
@@ -1088,7 +1178,7 @@ export class SequencerPublisher {
1088
1178
  }
1089
1179
  return true;
1090
1180
  }
1091
- /** Simulates and enqueues a proposal for a checkpoint on L1 */ async enqueueProposeCheckpoint(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, opts = {}) {
1181
+ /** Enqueues a proposal for a checkpoint on L1 */ async enqueueProposeCheckpoint(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, opts = {}) {
1092
1182
  const checkpointHeader = checkpoint.header;
1093
1183
  const blobFields = checkpoint.toBlobFields();
1094
1184
  const blobs = await getBlobsPerL1Block(blobFields);
@@ -1100,51 +1190,38 @@ export class SequencerPublisher {
1100
1190
  attestationsAndSignersSignature,
1101
1191
  feeAssetPriceModifier: checkpoint.feeAssetPriceModifier
1102
1192
  };
1103
- try {
1104
- // @note This will make sure that we are passing the checks for our header ASSUMING that the data is also made available
1105
- // This means that we can avoid the simulation issues in later checks.
1106
- // By simulation issue, I mean the fact that the block.timestamp is equal to the last block, not the next, which
1107
- // make time consistency checks break.
1108
- // TODO(palla): Check whether we're validating twice, once here and once within addProposeTx, since we call simulateProposeTx in both places.
1109
- await this.validateCheckpointForSubmission(checkpoint, attestationsAndSigners, attestationsAndSignersSignature, opts);
1110
- } catch (err) {
1111
- this.log.error(`Checkpoint validation failed. ${err instanceof Error ? err.message : 'No error message'}`, err, {
1112
- ...checkpoint.getStats(),
1113
- slotNumber: checkpoint.header.slotNumber,
1114
- forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber
1115
- });
1116
- throw err;
1117
- }
1118
1193
  this.log.verbose(`Enqueuing checkpoint propose transaction`, {
1119
1194
  ...checkpoint.toCheckpointInfo(),
1120
- ...opts
1195
+ txTimeoutAt: opts.txTimeoutAt
1196
+ });
1197
+ await this.addProposeTx(checkpoint, proposeTxArgs, {
1198
+ txTimeoutAt: opts.txTimeoutAt
1121
1199
  });
1122
- await this.addProposeTx(checkpoint, proposeTxArgs, opts);
1123
1200
  }
1124
1201
  enqueueInvalidateCheckpoint(request, opts = {}) {
1125
1202
  if (!request) {
1126
1203
  return;
1127
1204
  }
1128
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1129
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil(Number(request.gasUsed) * 64 / 63)));
1130
1205
  const { gasUsed, checkpointNumber } = request;
1131
1206
  const logData = {
1132
1207
  gasUsed,
1133
1208
  checkpointNumber,
1134
- gasLimit,
1135
1209
  opts
1136
1210
  };
1137
1211
  this.log.verbose(`Enqueuing invalidate checkpoint request`, logData);
1138
1212
  this.addRequest({
1139
1213
  action: `invalidate-by-${request.reason}`,
1140
1214
  request: request.request,
1141
- gasConfig: {
1142
- gasLimit,
1215
+ gasConfig: opts.txTimeoutAt ? {
1143
1216
  txTimeoutAt: opts.txTimeoutAt
1144
- },
1217
+ } : undefined,
1145
1218
  lastValidL2Slot: SlotNumber(this.getCurrentL2Slot() + 2),
1146
1219
  checkSuccess: (_req, result)=>{
1147
- const success = result && result.receipt && result.receipt.status === 'success' && tryExtractEvent(result.receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointInvalidated');
1220
+ const success = result && extractEventSuccess(result.receipt, {
1221
+ address: this.rollupContract.address,
1222
+ abi: RollupAbi,
1223
+ eventName: 'CheckpointInvalidated'
1224
+ });
1148
1225
  if (!success) {
1149
1226
  this.log.warn(`Invalidate checkpoint ${request.checkpointNumber} failed`, {
1150
1227
  ...result,
@@ -1160,68 +1237,36 @@ export class SequencerPublisher {
1160
1237
  }
1161
1238
  });
1162
1239
  }
1163
- async simulateAndEnqueueRequest(action, request, checkSuccess, slotNumber) {
1164
- const timestamp = this.getSimulationTimestamp(slotNumber);
1165
- const logData = {
1166
- slotNumber,
1167
- timestamp,
1168
- gasLimit: undefined
1169
- };
1240
+ /**
1241
+ * Dedup-checked enqueue helper for actions that are simulated at bundle-send time rather
1242
+ * than at enqueue time. Validates the (action, slot) dedup key, sets `lastActions`, and
1243
+ * enqueues without a gasLimit so the bundle simulate sets the only gasLimit that matters.
1244
+ */ enqueueRequest(action, request, eventOpts, slotNumber) {
1170
1245
  if (this.lastActions[action] && this.lastActions[action] === slotNumber) {
1171
1246
  this.log.debug(`Skipping duplicate action ${action} for slot ${slotNumber}`);
1172
1247
  return false;
1173
1248
  }
1174
1249
  const cachedLastActionSlot = this.lastActions[action];
1175
1250
  this.lastActions[action] = slotNumber;
1176
- this.log.debug(`Simulating ${action} for slot ${slotNumber}`, logData);
1177
- let gasUsed;
1178
- const simulateAbi = mergeAbis([
1179
- request.abi ?? [],
1180
- ErrorsAbi
1181
- ]);
1182
- try {
1183
- ({ gasUsed } = await this.l1TxUtils.simulate(request, {
1184
- time: timestamp
1185
- }, [], simulateAbi));
1186
- this.log.verbose(`Simulation for ${action} succeeded`, {
1187
- ...logData,
1188
- request,
1189
- gasUsed
1190
- });
1191
- } catch (err) {
1192
- const viemError = formatViemError(err, simulateAbi);
1193
- this.log.error(`Simulation for ${action} at ${slotNumber} failed`, viemError, logData);
1194
- return false;
1195
- }
1196
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1197
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil(Number(gasUsed) * 64 / 63)));
1198
- logData.gasLimit = gasLimit;
1199
- // Store the ABI used for simulation on the request so Multicall3.forward can decode errors
1200
- // when the tx is sent and a revert is diagnosed via simulation.
1201
- const requestWithAbi = {
1202
- ...request,
1203
- abi: simulateAbi
1204
- };
1205
- this.log.debug(`Enqueuing ${action}`, logData);
1251
+ this.log.debug(`Enqueuing ${action}`, {
1252
+ slotNumber
1253
+ });
1206
1254
  this.addRequest({
1207
1255
  action,
1208
- request: requestWithAbi,
1209
- gasConfig: {
1210
- gasLimit
1211
- },
1256
+ request,
1212
1257
  lastValidL2Slot: slotNumber,
1213
- checkSuccess: (_req, result)=>{
1214
- const success = result && result.receipt && result.receipt.status === 'success' && checkSuccess(result.receipt);
1258
+ checkSuccess: (_request, result)=>{
1259
+ const success = result && extractEventSuccess(result.receipt, eventOpts);
1215
1260
  if (!success) {
1216
1261
  this.log.warn(`Action ${action} at ${slotNumber} failed`, {
1217
1262
  ...result,
1218
- ...logData
1263
+ slotNumber
1219
1264
  });
1220
1265
  this.lastActions[action] = cachedLastActionSlot;
1221
1266
  } else {
1222
1267
  this.log.info(`Action ${action} at ${slotNumber} succeeded`, {
1223
1268
  ...result,
1224
- ...logData
1269
+ slotNumber
1225
1270
  });
1226
1271
  }
1227
1272
  return !!success;
@@ -1236,13 +1281,14 @@ export class SequencerPublisher {
1236
1281
  * A call to `restart` is required before you can continue publishing.
1237
1282
  */ interrupt() {
1238
1283
  this.interrupted = true;
1284
+ this.interruptibleSleep.interrupt();
1239
1285
  this.l1TxUtils.interrupt();
1240
1286
  }
1241
1287
  /** Restarts the publisher after calling `interrupt`. */ restart() {
1242
1288
  this.interrupted = false;
1243
1289
  this.l1TxUtils.restart();
1244
1290
  }
1245
- async prepareProposeTx(encodedData, options) {
1291
+ async prepareProposeTx(encodedData) {
1246
1292
  const kzg = Blob.getViemKzgInstance();
1247
1293
  const blobInput = getPrefixedEthBlobCommitments(encodedData.blobs);
1248
1294
  this.log.debug('Validating blob input', {
@@ -1256,7 +1302,11 @@ export class SequencerPublisher {
1256
1302
  blobEvaluationGas = BigInt(encodedData.blobs.length) * 21_000n;
1257
1303
  this.log.debug(`Using fixed blob evaluation gas estimate in fisherman mode: ${blobEvaluationGas}`);
1258
1304
  } else {
1259
- // Normal mode - use estimateGas with blob inputs
1305
+ // We call validateBlobs via estimateGas with real blob+kzg sidecars as a consistency check
1306
+ // that our locally-built blob commitments match the blob data. The bundle simulate at send
1307
+ // time uses eth_simulateV1, which cannot carry blob inputs, so the rollup's on-chain blob
1308
+ // check is forced off there — making this the only pre-flight detector of a commitment/data
1309
+ // mismatch. The returned gas estimate is stashed on the request for the bundle path to read.
1260
1310
  blobEvaluationGas = await this.l1TxUtils.estimateGas(this.getSenderAddress().toString(), {
1261
1311
  to: this.rollupContract.address,
1262
1312
  data: encodeFunctionData({
@@ -1269,10 +1319,38 @@ export class SequencerPublisher {
1269
1319
  }, {}, {
1270
1320
  blobs: encodedData.blobs.map((b)=>b.data),
1271
1321
  kzg
1272
- }).catch((err)=>{
1273
- const { message, metaMessages } = formatViemError(err);
1274
- this.log.error(`Failed to validate blobs`, message, {
1275
- metaMessages
1322
+ }).catch(async (err)=>{
1323
+ const viemError = formatViemError(err);
1324
+ this.log.error(`Failed to validate blobs`, viemError.message, {
1325
+ metaMessages: viemError.metaMessages
1326
+ });
1327
+ const validateBlobsData = encodeFunctionData({
1328
+ abi: RollupAbi,
1329
+ functionName: 'validateBlobs',
1330
+ args: [
1331
+ blobInput
1332
+ ]
1333
+ });
1334
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1335
+ this.backupFailedTx({
1336
+ id: keccak256(validateBlobsData),
1337
+ failureType: 'simulation',
1338
+ request: {
1339
+ to: this.rollupContract.address,
1340
+ data: validateBlobsData
1341
+ },
1342
+ blobData: encodedData.blobs.map((b)=>toHex(b.data)),
1343
+ l1BlockNumber: l1BlockNumber.toString(),
1344
+ error: {
1345
+ message: viemError.message,
1346
+ name: viemError.name
1347
+ },
1348
+ context: {
1349
+ actions: [
1350
+ 'validate-blobs'
1351
+ ],
1352
+ sender: this.getSenderAddress().toString()
1353
+ }
1276
1354
  });
1277
1355
  throw new Error('Failed to validate blobs');
1278
1356
  });
@@ -1291,89 +1369,23 @@ export class SequencerPublisher {
1291
1369
  encodedData.attestationsAndSignersSignature.toViemSignature(),
1292
1370
  blobInput
1293
1371
  ];
1294
- const { rollupData, simulationResult } = await this.simulateProposeTx(args, options);
1295
- return {
1296
- args,
1297
- blobEvaluationGas,
1298
- rollupData,
1299
- simulationResult
1300
- };
1301
- }
1302
- /**
1303
- * Simulates the propose tx with eth_simulateV1
1304
- * @param args - The propose tx args
1305
- * @returns The simulation result
1306
- */ async simulateProposeTx(args, options) {
1307
1372
  const rollupData = encodeFunctionData({
1308
1373
  abi: RollupAbi,
1309
1374
  functionName: 'propose',
1310
1375
  args
1311
1376
  });
1312
- // override the pending checkpoint number if requested
1313
- const forcePendingCheckpointNumberStateDiff = (options.forcePendingCheckpointNumber !== undefined ? await this.rollupContract.makePendingCheckpointNumberOverride(options.forcePendingCheckpointNumber) : []).flatMap((override)=>override.stateDiff ?? []);
1314
- const stateOverrides = [
1315
- {
1316
- address: this.rollupContract.address,
1317
- // @note we override checkBlob to false since blobs are not part simulate()
1318
- stateDiff: [
1319
- {
1320
- slot: toPaddedHex(RollupContract.checkBlobStorageSlot, true),
1321
- value: toPaddedHex(0n, true)
1322
- },
1323
- ...forcePendingCheckpointNumberStateDiff
1324
- ]
1325
- }
1326
- ];
1327
- // In fisherman mode, simulate as the proposer but with sufficient balance
1328
- if (this.proposerAddressForSimulation) {
1329
- stateOverrides.push({
1330
- address: this.proposerAddressForSimulation.toString(),
1331
- balance: 10n * WEI_CONST * WEI_CONST
1332
- });
1333
- }
1334
- const simTs = this.getSimulationTimestamp(SlotNumber.fromBigInt(args[0].header.slotNumber));
1335
- const simulationResult = await this.l1TxUtils.simulate({
1336
- to: this.rollupContract.address,
1337
- data: rollupData,
1338
- gas: MAX_L1_TX_LIMIT,
1339
- ...this.proposerAddressForSimulation && {
1340
- from: this.proposerAddressForSimulation.toString()
1341
- }
1342
- }, {
1343
- time: simTs,
1344
- // @note reth should have a 30m gas limit per block but throws errors that this tx is beyond limit so we increase here
1345
- gasLimit: MAX_L1_TX_LIMIT * 2n
1346
- }, stateOverrides, RollupAbi, {
1347
- // @note fallback gas estimate to use if the node doesn't support simulation API
1348
- fallbackGasEstimate: MAX_L1_TX_LIMIT
1349
- }).catch((err)=>{
1350
- // In fisherman mode, we expect ValidatorSelection__MissingProposerSignature since fisherman doesn't have proposer signature
1351
- const viemError = formatViemError(err);
1352
- if (this.config.fishermanMode && viemError.message?.includes('ValidatorSelection__MissingProposerSignature')) {
1353
- this.log.debug(`Ignoring expected ValidatorSelection__MissingProposerSignature error in fisherman mode`);
1354
- // Return a minimal simulation result with the fallback gas estimate
1355
- return {
1356
- gasUsed: MAX_L1_TX_LIMIT,
1357
- logs: []
1358
- };
1359
- }
1360
- this.log.error(`Failed to simulate propose tx`, viemError, {
1361
- simulationTimestamp: simTs
1362
- });
1363
- throw err;
1364
- });
1365
1377
  return {
1366
- rollupData,
1367
- simulationResult
1378
+ args,
1379
+ blobEvaluationGas,
1380
+ rollupData
1368
1381
  };
1369
1382
  }
1370
1383
  async addProposeTx(checkpoint, encodedData, opts = {}) {
1371
1384
  const slot = checkpoint.header.slotNumber;
1372
1385
  const timer = new Timer();
1373
1386
  const kzg = Blob.getViemKzgInstance();
1374
- const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx(encodedData, opts);
1387
+ const { rollupData, blobEvaluationGas } = await this.prepareProposeTx(encodedData);
1375
1388
  const startBlock = await this.l1TxUtils.getBlockNumber();
1376
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil(Number(simulationResult.gasUsed) * 64 / 63)) + blobEvaluationGas + SequencerPublisher.MULTICALL_OVERHEAD_GAS_GUESS);
1377
1389
  // Send the blobs to the blob client preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
1378
1390
  // tx fails but it does get mined. We make sure that the blobs are sent to the blob client regardless of the tx outcome.
1379
1391
  void Promise.resolve().then(()=>this.blobClient.sendBlobsToFilestore(encodedData.blobs).catch((_err)=>{
@@ -1387,9 +1399,10 @@ export class SequencerPublisher {
1387
1399
  },
1388
1400
  lastValidL2Slot: checkpoint.header.slotNumber,
1389
1401
  gasConfig: {
1390
- ...opts,
1391
- gasLimit
1402
+ txTimeoutAt: opts.txTimeoutAt,
1403
+ gasLimit: undefined
1392
1404
  },
1405
+ blobEvaluationGas,
1393
1406
  blobConfig: {
1394
1407
  blobs: encodedData.blobs.map((b)=>b.data),
1395
1408
  kzg
@@ -1399,7 +1412,11 @@ export class SequencerPublisher {
1399
1412
  return false;
1400
1413
  }
1401
1414
  const { receipt, stats, errorMsg } = result;
1402
- const success = receipt && receipt.status === 'success' && tryExtractEvent(receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointProposed');
1415
+ const success = extractEventSuccess(receipt, {
1416
+ address: this.rollupContract.address,
1417
+ abi: RollupAbi,
1418
+ eventName: 'CheckpointProposed'
1419
+ });
1403
1420
  if (success) {
1404
1421
  const endBlock = receipt.blockNumber;
1405
1422
  const inclusionBlocks = Number(endBlock - startBlock);
@@ -1436,11 +1453,6 @@ export class SequencerPublisher {
1436
1453
  }
1437
1454
  });
1438
1455
  }
1439
- /** Returns the timestamp of the last L1 slot within a given L2 slot. Used as the simulation timestamp
1440
- * for eth_simulateV1 calls, since it's guaranteed to be greater than any L1 block produced during the slot. */ getSimulationTimestamp(slot) {
1441
- const l1Constants = this.epochCache.getL1Constants();
1442
- return getLastL1SlotTimestampForL2Slot(slot, l1Constants);
1443
- }
1444
1456
  /** Returns the timestamp of the next L1 slot boundary after now. */ getNextL1SlotTimestamp() {
1445
1457
  const l1Constants = this.epochCache.getL1Constants();
1446
1458
  return getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants);