@aztec/sequencer-client 0.0.1-commit.b2a5d0dd1 → 0.0.1-commit.b3d3157a

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 (75) hide show
  1. package/README.md +281 -21
  2. package/dest/client/sequencer-client.d.ts +3 -3
  3. package/dest/client/sequencer-client.d.ts.map +1 -1
  4. package/dest/client/sequencer-client.js +6 -6
  5. package/dest/config.d.ts +6 -3
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +33 -10
  8. package/dest/global_variable_builder/fee_provider.js +1 -1
  9. package/dest/global_variable_builder/global_builder.d.ts +2 -3
  10. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  11. package/dest/global_variable_builder/global_builder.js +1 -1
  12. package/dest/index.d.ts +2 -2
  13. package/dest/index.d.ts.map +1 -1
  14. package/dest/index.js +1 -1
  15. package/dest/publisher/config.d.ts +3 -3
  16. package/dest/publisher/config.d.ts.map +1 -1
  17. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  18. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  19. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  20. package/dest/publisher/sequencer-publisher.d.ts +36 -36
  21. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  22. package/dest/publisher/sequencer-publisher.js +271 -500
  23. package/dest/sequencer/automine/automine_factory.d.ts +54 -0
  24. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  25. package/dest/sequencer/automine/automine_factory.js +84 -0
  26. package/dest/sequencer/automine/automine_sequencer.d.ts +151 -0
  27. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  28. package/dest/sequencer/automine/automine_sequencer.js +509 -0
  29. package/dest/sequencer/chain_state_overrides.d.ts +52 -16
  30. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -1
  31. package/dest/sequencer/chain_state_overrides.js +86 -27
  32. package/dest/sequencer/checkpoint_proposal_job.d.ts +24 -6
  33. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -1
  34. package/dest/sequencer/checkpoint_proposal_job.js +373 -83
  35. package/dest/sequencer/events.d.ts +42 -2
  36. package/dest/sequencer/events.d.ts.map +1 -1
  37. package/dest/sequencer/index.d.ts +3 -1
  38. package/dest/sequencer/index.d.ts.map +1 -1
  39. package/dest/sequencer/index.js +2 -0
  40. package/dest/sequencer/metrics.d.ts +3 -1
  41. package/dest/sequencer/metrics.d.ts.map +1 -1
  42. package/dest/sequencer/metrics.js +7 -0
  43. package/dest/sequencer/sequencer.d.ts +44 -9
  44. package/dest/sequencer/sequencer.d.ts.map +1 -1
  45. package/dest/sequencer/sequencer.js +256 -79
  46. package/dest/sequencer/timetable.d.ts +3 -6
  47. package/dest/sequencer/timetable.d.ts.map +1 -1
  48. package/dest/sequencer/timetable.js +3 -7
  49. package/dest/sequencer/types.d.ts +2 -2
  50. package/dest/sequencer/types.d.ts.map +1 -1
  51. package/dest/test/utils.d.ts +1 -1
  52. package/dest/test/utils.d.ts.map +1 -1
  53. package/dest/test/utils.js +7 -6
  54. package/package.json +27 -27
  55. package/src/client/sequencer-client.ts +12 -7
  56. package/src/config.ts +38 -10
  57. package/src/global_variable_builder/fee_provider.ts +1 -1
  58. package/src/global_variable_builder/global_builder.ts +2 -3
  59. package/src/index.ts +10 -1
  60. package/src/publisher/config.ts +2 -2
  61. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  62. package/src/publisher/sequencer-publisher.ts +286 -521
  63. package/src/sequencer/README.md +162 -520
  64. package/src/sequencer/automine/README.md +46 -0
  65. package/src/sequencer/automine/automine_factory.ts +145 -0
  66. package/src/sequencer/automine/automine_sequencer.ts +595 -0
  67. package/src/sequencer/chain_state_overrides.ts +133 -58
  68. package/src/sequencer/checkpoint_proposal_job.ts +420 -100
  69. package/src/sequencer/events.ts +46 -2
  70. package/src/sequencer/index.ts +2 -0
  71. package/src/sequencer/metrics.ts +9 -0
  72. package/src/sequencer/sequencer.ts +284 -95
  73. package/src/sequencer/timetable.ts +2 -9
  74. package/src/sequencer/types.ts +1 -1
  75. package/src/test/utils.ts +28 -10
@@ -5,15 +5,12 @@ import type { L1ContractsConfig } from '@aztec/ethereum/config';
5
5
  import {
6
6
  FeeAssetPriceOracle,
7
7
  type GovernanceProposerContract,
8
- type IEmpireBase,
9
8
  MULTI_CALL_3_ADDRESS,
10
9
  Multicall3,
11
- RollupContract,
12
- SimulationOverridesBuilder,
10
+ MulticallForwarderRevertedError,
11
+ type RollupContract,
13
12
  type SimulationOverridesPlan,
14
13
  type SlashingProposerContract,
15
- type ViemCommitteeAttestations,
16
- type ViemHeader,
17
14
  buildSimulationOverridesStateOverride,
18
15
  } from '@aztec/ethereum/contracts';
19
16
  import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
@@ -26,47 +23,71 @@ import {
26
23
  type TransactionStats,
27
24
  WEI_CONST,
28
25
  } from '@aztec/ethereum/l1-tx-utils';
29
- import { FormattedViemError, formatViemError, mergeAbis, tryExtractEvent } from '@aztec/ethereum/utils';
30
- import { sumBigint } from '@aztec/foundation/bigint';
26
+ import {
27
+ FormattedViemError,
28
+ formatViemError,
29
+ mergeAbis,
30
+ tryDecodeRevertReason,
31
+ tryExtractEvent,
32
+ } from '@aztec/ethereum/utils';
31
33
  import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
32
34
  import { trimmedBytesLength } from '@aztec/foundation/buffer';
33
35
  import { pick } from '@aztec/foundation/collection';
34
36
  import type { Fr } from '@aztec/foundation/curves/bn254';
35
37
  import { TimeoutError } from '@aztec/foundation/error';
36
38
  import { EthAddress } from '@aztec/foundation/eth-address';
37
- import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature';
39
+ import { Signature } from '@aztec/foundation/eth-signature';
38
40
  import { type Logger, createLogger } from '@aztec/foundation/log';
39
- import { makeBackoff, retry } from '@aztec/foundation/retry';
40
41
  import { InterruptibleSleep } from '@aztec/foundation/sleep';
41
42
  import { bufferToHex } from '@aztec/foundation/string';
42
43
  import { type DateProvider, Timer } from '@aztec/foundation/timer';
43
- import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts';
44
+ import { EmpireBaseAbi, ErrorsAbi, RollupAbi, SlashingProposerAbi } from '@aztec/l1-artifacts';
44
45
  import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher';
45
46
  import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block';
46
47
  import type { Checkpoint } from '@aztec/stdlib/checkpoint';
47
- import { getLastL1SlotTimestampForL2Slot, getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers';
48
+ import {
49
+ getLastL1SlotTimestampForL2Slot,
50
+ getNextL1SlotTimestamp,
51
+ getTimestampForSlot,
52
+ } from '@aztec/stdlib/epoch-helpers';
48
53
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
49
54
  import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats';
50
55
  import { type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
51
56
 
52
57
  import {
58
+ type Abi,
53
59
  type Hex,
54
60
  type TransactionReceipt,
55
61
  type TypedDataDefinition,
56
62
  encodeFunctionData,
57
63
  keccak256,
58
- multicall3Abi,
59
64
  toHex,
60
65
  } from 'viem';
61
66
 
62
67
  import type { SequencerPublisherConfig } from './config.js';
63
68
  import { type FailedL1Tx, type L1TxFailedStore, createL1TxFailedStore } from './l1_tx_failed_store/index.js';
69
+ import { type DroppedRequest, SequencerBundleSimulator } from './sequencer-bundle-simulator.js';
64
70
  import { SequencerPublisherMetrics } from './sequencer-publisher-metrics.js';
65
71
 
72
+ /**
73
+ * Returns true if the receipt indicates a successful send AND the expected event was emitted
74
+ * by the target contract. Both pieces are required: an aggregate3 entry that reverted will
75
+ * have receipt.status === 'success' but no event log.
76
+ */
77
+ function extractEventSuccess(
78
+ receipt: TransactionReceipt | undefined,
79
+ opts: { address: string; abi: Abi; eventName: string },
80
+ ): boolean {
81
+ if (!receipt || receipt.status !== 'success') {
82
+ return false;
83
+ }
84
+ return !!tryExtractEvent(receipt.logs, opts.address.toString() as Hex, opts.abi, opts.eventName);
85
+ }
86
+
66
87
  /** Result of a sendRequests call, returned by both sendRequests() and sendRequestsAt(). */
67
88
  export type SendRequestsResult = {
68
- /** The L1 transaction receipt or error from the bundled multicall. */
69
- result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError;
89
+ /** The L1 transaction receipt from the bundled multicall. */
90
+ result: { receipt: TransactionReceipt };
70
91
  /** Actions that expired (past their deadline) before the request was sent. */
71
92
  expiredActions: Action[];
72
93
  /** Actions that were included in the sent L1 transaction. */
@@ -121,17 +142,16 @@ export type InvalidateCheckpointRequest = {
121
142
 
122
143
  type EnqueueProposeCheckpointOpts = {
123
144
  txTimeoutAt?: Date;
124
- simulationOverridesPlan?: SimulationOverridesPlan;
125
145
  };
126
146
 
127
- interface RequestWithExpiry {
147
+ export interface RequestWithExpiry {
128
148
  action: Action;
129
149
  request: L1TxRequest;
130
150
  lastValidL2Slot: SlotNumber;
131
151
  gasConfig?: Pick<L1TxConfig, 'txTimeoutAt' | 'gasLimit'>;
132
152
  blobConfig?: L1BlobInputs;
133
- /** Optional pre-send validation. If it rejects, the request is discarded. */
134
- preCheck?: () => Promise<void>;
153
+ /** Gas consumed by validateBlobs; stashed for the bundle simulate at send time. */
154
+ blobEvaluationGas?: bigint;
135
155
  checkSuccess: (
136
156
  request: L1TxRequest,
137
157
  result?: { receipt: TransactionReceipt; stats?: TransactionStats; errorMsg?: string },
@@ -141,17 +161,19 @@ interface RequestWithExpiry {
141
161
  export class SequencerPublisher {
142
162
  private interrupted = false;
143
163
  private metrics: SequencerPublisherMetrics;
164
+ private bundleSimulator: SequencerBundleSimulator;
144
165
  public epochCache: EpochCache;
145
166
  private failedTxStore?: Promise<L1TxFailedStore | undefined>;
146
167
 
147
- protected governanceLog = createLogger('sequencer:publisher:governance');
148
- protected slashingLog = createLogger('sequencer:publisher:slashing');
168
+ /**
169
+ * ABI used to decode raw revert payloads from dropped bundle entries when the original
170
+ * request did not carry an abi (e.g. the propose request). Merges every contract the
171
+ * publisher can route to so any of their custom errors decode against it.
172
+ */
173
+ private readonly revertDecoderAbi: Abi = mergeAbis([RollupAbi, SlashingProposerAbi, EmpireBaseAbi, ErrorsAbi]);
149
174
 
150
175
  protected lastActions: Partial<Record<Action, SlotNumber>> = {};
151
176
 
152
- private isPayloadEmptyCache: Map<string, boolean> = new Map<string, boolean>();
153
- private payloadProposedCache: Set<string> = new Set<string>();
154
-
155
177
  protected log: Logger;
156
178
  protected ethereumSlotDuration: bigint;
157
179
  protected aztecSlotDuration: bigint;
@@ -161,9 +183,6 @@ export class SequencerPublisher {
161
183
 
162
184
  private blobClient: BlobClientInterface;
163
185
 
164
- /** Address to use for simulations in fisherman mode (actual proposer's address) */
165
- private proposerAddressForSimulation?: EthAddress;
166
-
167
186
  /** Optional callback to obtain a replacement publisher when the current one fails to send. */
168
187
  private getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise<L1TxUtils | undefined>;
169
188
 
@@ -176,12 +195,6 @@ export class SequencerPublisher {
176
195
  /** Interruptible sleep used by sendRequestsAt to wait until a target timestamp. */
177
196
  private readonly interruptibleSleep = new InterruptibleSleep();
178
197
 
179
- // A CALL to a cold address is 2700 gas
180
- public static MULTICALL_OVERHEAD_GAS_GUESS = 5000n;
181
-
182
- // Gas report for VotingWithSigTest shows a max gas of 100k, but we've seen it cost 700k+ in testnet
183
- public static VOTE_GAS_GUESS: bigint = 800_000n;
184
-
185
198
  public l1TxUtils: L1TxUtils;
186
199
  public rollupContract: RollupContract;
187
200
  public govProposerContract: GovernanceProposerContract;
@@ -240,7 +253,7 @@ export class SequencerPublisher {
240
253
  this.l1FeeAnalyzer = new L1FeeAnalyzer(
241
254
  this.l1TxUtils.client,
242
255
  deps.dateProvider,
243
- createLogger('sequencer:publisher:fee-analyzer'),
256
+ this.log.createChild('fee-analyzer'),
244
257
  );
245
258
  }
246
259
 
@@ -248,11 +261,18 @@ export class SequencerPublisher {
248
261
  this.feeAssetPriceOracle = new FeeAssetPriceOracle(
249
262
  this.l1TxUtils.client,
250
263
  this.rollupContract,
251
- createLogger('sequencer:publisher:price-oracle'),
264
+ this.log.createChild('price-oracle'),
252
265
  );
253
266
 
254
267
  // Initialize failed L1 tx store (optional, for test networks)
255
268
  this.failedTxStore = createL1TxFailedStore(config.l1TxFailedStore, this.log);
269
+
270
+ this.bundleSimulator = new SequencerBundleSimulator({
271
+ getL1TxUtils: () => this.l1TxUtils,
272
+ rollupContract: this.rollupContract,
273
+ epochCache: this.epochCache,
274
+ log: this.log.createChild('bundle-simulator'),
275
+ });
256
276
  }
257
277
 
258
278
  /**
@@ -283,10 +303,14 @@ export class SequencerPublisher {
283
303
 
284
304
  /**
285
305
  * Gets the fee asset price modifier from the oracle.
286
- * Returns 0n if the oracle query fails.
306
+ *
307
+ * @param predictedParentEthPerFeeAssetE12 - Optional predicted parent eth-per-fee-asset (E12).
308
+ * Pipelined proposers should pass the value from the predicted parent fee header so the
309
+ * modifier matches the parent L1 will use when applying it.
310
+ * @returns The fee asset price modifier in basis points, or 0n if the oracle query fails.
287
311
  */
288
- public getFeeAssetPriceModifier(): Promise<bigint> {
289
- return this.feeAssetPriceOracle.computePriceModifier();
312
+ public getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12?: bigint): Promise<bigint> {
313
+ return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12);
290
314
  }
291
315
 
292
316
  public getSenderAddress() {
@@ -300,14 +324,6 @@ export class SequencerPublisher {
300
324
  return this.l1FeeAnalyzer;
301
325
  }
302
326
 
303
- /**
304
- * Sets the proposer address to use for simulations in fisherman mode.
305
- * @param proposerAddress - The actual proposer's address to use for balance lookups in simulations
306
- */
307
- public setProposerAddressForSimulation(proposerAddress: EthAddress | undefined) {
308
- this.proposerAddressForSimulation = proposerAddress;
309
- }
310
-
311
327
  public addRequest(request: RequestWithExpiry) {
312
328
  this.requests.push(request);
313
329
  }
@@ -385,23 +401,26 @@ export class SequencerPublisher {
385
401
 
386
402
  /**
387
403
  * Sends all requests that are still valid.
404
+ * @param targetSlot - The target L2 slot for this send. When provided (the production path, via
405
+ * sendRequestsAt), it is threaded into bundleSimulate so the block.timestamp override matches
406
+ * the slot the propose is built for. When omitted, falls back to getCurrentL2Slot() for the
407
+ * AutomineSequencer, which publishes synchronously within the current slot.
388
408
  * @returns one of:
389
409
  * - A receipt and stats if the tx succeeded
390
410
  * - a receipt and errorMsg if it failed on L1
391
411
  * - undefined if no valid requests are found OR the tx failed to send.
392
412
  */
393
413
  @trackSpan('SequencerPublisher.sendRequests')
394
- public async sendRequests(): Promise<SendRequestsResult | undefined> {
414
+ public async sendRequests(targetSlot?: SlotNumber): Promise<SendRequestsResult | undefined> {
395
415
  const requestsToProcess = [...this.requests];
396
416
  this.requests = [];
397
417
 
398
418
  if (this.interrupted || requestsToProcess.length === 0) {
399
419
  return undefined;
400
420
  }
401
- const currentL2Slot = this.getCurrentL2Slot();
421
+ const currentL2Slot = targetSlot ?? this.getCurrentL2Slot();
402
422
  this.log.debug(`Sending requests on L2 slot ${currentL2Slot}`);
403
423
  const validRequests = requestsToProcess.filter(request => request.lastValidL2Slot >= currentL2Slot);
404
- const validActions = validRequests.map(x => x.action);
405
424
  const expiredActions = requestsToProcess
406
425
  .filter(request => request.lastValidL2Slot < currentL2Slot)
407
426
  .map(x => x.action);
@@ -424,70 +443,58 @@ export class SequencerPublisher {
424
443
  return undefined;
425
444
  }
426
445
 
427
- // @note - we can only have one blob config per bundle
428
- // find requests with gas and blob configs
429
- // See https://github.com/AztecProtocol/aztec-packages/issues/11513
446
+ // Collect earliest txTimeoutAt across all requests.
430
447
  const gasConfigs = validRequests.filter(request => request.gasConfig).map(request => request.gasConfig);
431
- const blobConfigs = validRequests.filter(request => request.blobConfig).map(request => request.blobConfig);
432
-
433
- if (blobConfigs.length > 1) {
434
- throw new Error('Multiple blob configs found');
435
- }
436
-
437
- const blobConfig = blobConfigs[0];
438
-
439
- // Merge gasConfigs. Yields the sum of gasLimits, and the earliest txTimeoutAt, or undefined if no gasConfig sets them.
440
- const gasLimits = gasConfigs.map(g => g?.gasLimit).filter((g): g is bigint => g !== undefined);
441
- let gasLimit = gasLimits.length > 0 ? sumBigint(gasLimits) : undefined; // sum
442
- // Cap at L1 block gas limit so the node accepts the tx ("gas limit too high" otherwise).
443
- const maxGas = MAX_L1_TX_LIMIT;
444
- if (gasLimit !== undefined && gasLimit > maxGas) {
445
- this.log.debug('Capping bundled tx gas limit to L1 max', {
446
- requested: gasLimit,
447
- capped: maxGas,
448
- });
449
- gasLimit = maxGas;
450
- }
451
448
  const txTimeoutAts = gasConfigs.map(g => g?.txTimeoutAt).filter((g): g is Date => g !== undefined);
452
- const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map(g => g.getTime()))) : undefined; // earliest
453
- const txConfig: RequestWithExpiry['gasConfig'] = { gasLimit, txTimeoutAt };
449
+ const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map(g => g.getTime()))) : undefined;
454
450
 
455
451
  // Sort the requests so that proposals always go first
456
452
  // This ensures the committee gets precomputed correctly
457
453
  validRequests.sort((a, b) => compareActions(a.action, b.action));
458
454
 
459
455
  try {
460
- // Capture context for failed tx backup before sending
461
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
462
- const multicallData = encodeFunctionData({
463
- abi: multicall3Abi,
464
- functionName: 'aggregate3',
465
- args: [
466
- validRequests.map(r => ({
467
- target: r.request.to!,
468
- callData: r.request.data!,
469
- allowFailure: true,
470
- })),
471
- ],
472
- });
473
- const blobDataHex = blobConfig?.blobs?.map(b => toHex(b)) as Hex[] | undefined;
456
+ // Bundle-level eth_simulateV1: filters out entries that revert and derives the gasLimit.
457
+ const bundleResult = await this.bundleSimulator.simulate(validRequests, currentL2Slot);
458
+
459
+ if (bundleResult.kind === 'aborted') {
460
+ this.logDroppedInSim(bundleResult.droppedRequests);
461
+ void this.backupDroppedInSim(bundleResult.droppedRequests);
462
+ return undefined;
463
+ }
464
+
465
+ const { requests, droppedRequests, gasLimit } =
466
+ bundleResult.kind === 'fallback'
467
+ ? {
468
+ requests: bundleResult.requests,
469
+ droppedRequests: bundleResult.droppedRequests,
470
+ gasLimit: MAX_L1_TX_LIMIT,
471
+ }
472
+ : bundleResult;
474
473
 
475
- const txContext = { multicallData, blobData: blobDataHex, l1BlockNumber };
474
+ this.logDroppedInSim(droppedRequests);
475
+
476
+ // Compute blobConfig from survivors (not original validRequests) so that if the propose
477
+ // entry was dropped by bundleSimulate we don't attach a blob-typed config to a non-blob tx.
478
+ const [blobConfig] = requests.filter(r => r.blobConfig).map(r => r.blobConfig);
479
+ const txConfig: RequestWithExpiry['gasConfig'] = { gasLimit, txTimeoutAt };
476
480
 
477
481
  this.log.debug('Forwarding transactions', {
478
- validRequests: validRequests.map(request => request.action),
482
+ requests: requests.map(request => request.action),
479
483
  txConfig,
480
484
  });
481
- const result = await this.forwardWithPublisherRotation(validRequests, txConfig, blobConfig);
485
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
482
486
  if (result === undefined) {
483
487
  return undefined;
484
488
  }
485
- const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(
486
- validRequests,
489
+ const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(requests, result);
490
+ const allFailedActions = [...failedActions, ...droppedRequests.map(d => d.request.action)];
491
+ return {
487
492
  result,
488
- txContext,
489
- );
490
- return { result, expiredActions, sentActions: validActions, successfulActions, failedActions };
493
+ expiredActions,
494
+ sentActions: requests.map(x => x.action),
495
+ successfulActions,
496
+ failedActions: allFailedActions,
497
+ };
491
498
  } catch (err) {
492
499
  const viemError = formatViemError(err);
493
500
  this.log.error(`Failed to publish bundled transactions`, viemError);
@@ -504,6 +511,40 @@ export class SequencerPublisher {
504
511
  }
505
512
  }
506
513
 
514
+ /** Logs entries dropped by bundle simulation as warnings on the publisher's logger. */
515
+ private logDroppedInSim(dropped: DroppedRequest[]): void {
516
+ for (const drop of dropped) {
517
+ const revertReasonDecoded = drop.revertReason ?? tryDecodeRevertReason(drop.returnData, this.revertDecoderAbi);
518
+ this.log.warn('Bundle entry dropped: action reverted in sim', {
519
+ action: drop.request.action,
520
+ revertReason: revertReasonDecoded ?? drop.returnData,
521
+ revertReasonDecoded,
522
+ returnData: drop.returnData,
523
+ });
524
+ }
525
+ }
526
+
527
+ /** Backs up entries dropped by bundle simulation, one record per dropped action. */
528
+ private async backupDroppedInSim(dropped: DroppedRequest[]): Promise<void> {
529
+ if (dropped.length === 0) {
530
+ return;
531
+ }
532
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
533
+ for (const { request: req } of dropped) {
534
+ this.backupFailedTx({
535
+ id: keccak256(req.request.data!),
536
+ failureType: 'simulation',
537
+ request: { to: req.request.to! as Hex, data: req.request.data! },
538
+ l1BlockNumber: l1BlockNumber.toString(),
539
+ error: { message: 'Bundle entry dropped: action reverted in sim' },
540
+ context: {
541
+ actions: [req.action],
542
+ sender: this.getSenderAddress().toString(),
543
+ },
544
+ });
545
+ }
546
+ }
547
+
507
548
  /**
508
549
  * Forwards transactions via Multicall3, rotating to the next available publisher if a send
509
550
  * failure occurs (i.e. the tx never reached the chain).
@@ -514,19 +555,30 @@ export class SequencerPublisher {
514
555
  txConfig: RequestWithExpiry['gasConfig'],
515
556
  blobConfig: L1BlobInputs | undefined,
516
557
  ) {
558
+ if (!txConfig?.gasLimit) {
559
+ throw new Error('gasLimit is required for bundled transactions');
560
+ }
561
+ const txConfigWithGasLimit = txConfig as L1TxConfig & { gasLimit: bigint };
562
+
517
563
  const triedAddresses: EthAddress[] = [];
518
564
  let currentPublisher = this.l1TxUtils;
519
565
 
520
566
  while (true) {
567
+ if (txConfig.txTimeoutAt && new Date() > txConfig.txTimeoutAt) {
568
+ this.log.warn(`Tx timeout (${txConfig.txTimeoutAt.toISOString()}) elapsed; stopping publisher rotation`, {
569
+ triedAddresses: triedAddresses.map(a => a.toString()),
570
+ });
571
+ return undefined;
572
+ }
521
573
  triedAddresses.push(currentPublisher.getSenderAddress());
574
+
522
575
  try {
523
576
  const result = await Multicall3.forward(
524
577
  validRequests.map(r => r.request),
525
578
  currentPublisher,
526
- txConfig,
579
+ txConfigWithGasLimit,
527
580
  blobConfig,
528
- this.rollupContract.address,
529
- this.log,
581
+ { gasLimitRequired: true },
530
582
  );
531
583
  this.l1TxUtils = currentPublisher;
532
584
  return result;
@@ -534,6 +586,12 @@ export class SequencerPublisher {
534
586
  if (err instanceof TimeoutError) {
535
587
  throw err;
536
588
  }
589
+ if (err instanceof MulticallForwarderRevertedError) {
590
+ this.log.error('Forwarder transaction reverted on-chain; not rotating publisher', err, {
591
+ transactionHash: err.receipt.transactionHash,
592
+ });
593
+ return undefined;
594
+ }
537
595
  const viemError = formatViemError(err);
538
596
  if (!this.getNextPublisher) {
539
597
  this.log.error('Failed to publish bundled transactions', viemError);
@@ -545,7 +603,11 @@ export class SequencerPublisher {
545
603
  );
546
604
  const nextPublisher = await this.getNextPublisher([...triedAddresses]);
547
605
  if (!nextPublisher) {
548
- this.log.error('All available publishers exhausted, failed to publish bundled transactions');
606
+ this.log.error(
607
+ `All available publishers exhausted (tried ${triedAddresses.length}), failed to publish bundled transactions`,
608
+ viemError,
609
+ { triedAddresses: triedAddresses.map(a => a.toString()) },
610
+ );
549
611
  return undefined;
550
612
  }
551
613
  currentPublisher = nextPublisher;
@@ -554,112 +616,62 @@ export class SequencerPublisher {
554
616
  }
555
617
 
556
618
  /*
557
- * Schedules sending all enqueued requests at (or after) the given timestamp.
619
+ * Schedules sending all enqueued requests at (or after) the start of the given L2 slot.
620
+ * Sleeps until one L1 slot before the L2 slot boundary so the tx has a chance of being
621
+ * picked up by the first L1 block of the L2 slot.
622
+ * NB: there is a known correctness risk — being included in the L1 block right before the
623
+ * L2 slot starts would revert propose with HeaderLib__InvalidSlotNumber.
558
624
  * Uses InterruptibleSleep so it can be cancelled via interrupt().
559
- * Returns the promise for the L1 response (caller should NOT await this in the work loop).
560
625
  */
561
- public async sendRequestsAt(submitAfter: Date): Promise<SendRequestsResult | undefined> {
562
- const ms = submitAfter.getTime() - this.dateProvider.now();
563
- if (ms > 0) {
564
- this.log.debug(`Sleeping ${ms}ms before sending requests`, { submitAfter });
565
- await this.interruptibleSleep.sleep(ms);
566
- }
626
+ public async sendRequestsAt(targetSlot: SlotNumber): Promise<SendRequestsResult | undefined> {
627
+ const l1Constants = this.epochCache.getL1Constants();
628
+ // Start of the target L2 slot, in ms (getTimestampForSlot returns seconds).
629
+ const startOfTargetSlotMs = Number(getTimestampForSlot(targetSlot, l1Constants)) * 1000;
630
+ // Aim to be in the mempool one L1 slot before the L2 slot starts, so we have a chance of
631
+ // being picked up by the first L1 block of the L2 slot.
632
+ const submitAfterMs = startOfTargetSlotMs - Number(this.ethereumSlotDuration) * 1000;
567
633
  if (this.interrupted) {
568
634
  return undefined;
569
635
  }
570
-
571
- // Re-validate enqueued requests after the sleep (state may have changed, e.g. prune or L1 reorg)
572
- const validRequests: RequestWithExpiry[] = [];
573
- for (const request of this.requests) {
574
- if (!request.preCheck) {
575
- validRequests.push(request);
576
- continue;
577
- }
578
-
579
- try {
580
- await request.preCheck();
581
- validRequests.push(request);
582
- } catch (err) {
583
- this.log.warn(`Pre-send validation failed for ${request.action}, discarding request`, err);
584
- }
636
+ const sleepMs = submitAfterMs - this.dateProvider.now();
637
+ if (sleepMs > 0) {
638
+ this.log.debug(`Sleeping ${sleepMs}ms before sending requests`, {
639
+ targetSlot,
640
+ submitAfterMs,
641
+ });
642
+ await this.interruptibleSleep.sleep(sleepMs);
585
643
  }
586
-
587
- this.requests = validRequests;
588
- if (this.requests.length === 0) {
644
+ if (this.interrupted) {
589
645
  return undefined;
590
646
  }
591
-
592
- return this.sendRequests();
647
+ return this.sendRequests(targetSlot);
593
648
  }
594
649
 
595
650
  private callbackBundledTransactions(
596
651
  requests: RequestWithExpiry[],
597
- result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError | undefined,
598
- txContext: { multicallData: Hex; blobData?: Hex[]; l1BlockNumber: bigint },
652
+ result: { receipt: TransactionReceipt; multicallData: Hex },
599
653
  ) {
600
654
  const actionsListStr = requests.map(r => r.action).join(', ');
601
- if (result instanceof FormattedViemError) {
602
- this.log.error(`Failed to publish bundled transactions (${actionsListStr})`, result);
603
- this.backupFailedTx({
604
- id: keccak256(txContext.multicallData),
605
- failureType: 'send-error',
606
- request: { to: MULTI_CALL_3_ADDRESS, data: txContext.multicallData },
607
- blobData: txContext.blobData,
608
- l1BlockNumber: txContext.l1BlockNumber.toString(),
609
- error: { message: result.message, name: result.name },
610
- context: {
611
- actions: requests.map(r => r.action),
612
- requests: requests.map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
613
- sender: this.getSenderAddress().toString(),
614
- },
615
- });
616
- return { failedActions: requests.map(r => r.action) };
617
- } else {
618
- this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
619
- result,
620
- requests: requests.map(r => ({
621
- ...r,
622
- // Avoid logging large blob data
623
- blobConfig: r.blobConfig
624
- ? { ...r.blobConfig, blobs: r.blobConfig.blobs.map(b => ({ size: trimmedBytesLength(b) })) }
625
- : undefined,
626
- })),
627
- });
628
- const successfulActions: Action[] = [];
629
- const failedActions: Action[] = [];
630
- for (const request of requests) {
631
- if (request.checkSuccess(request.request, result)) {
632
- successfulActions.push(request.action);
633
- } else {
634
- failedActions.push(request.action);
635
- }
636
- }
637
- // Single backup for the whole reverted tx
638
- if (failedActions.length > 0 && result?.receipt?.status === 'reverted') {
639
- this.backupFailedTx({
640
- id: result.receipt.transactionHash,
641
- failureType: 'revert',
642
- request: { to: MULTI_CALL_3_ADDRESS, data: txContext.multicallData },
643
- blobData: txContext.blobData,
644
- l1BlockNumber: result.receipt.blockNumber.toString(),
645
- receipt: {
646
- transactionHash: result.receipt.transactionHash,
647
- blockNumber: result.receipt.blockNumber.toString(),
648
- gasUsed: (result.receipt.gasUsed ?? 0n).toString(),
649
- status: 'reverted',
650
- },
651
- error: { message: result.errorMsg ?? 'Transaction reverted' },
652
- context: {
653
- actions: failedActions,
654
- requests: requests
655
- .filter(r => failedActions.includes(r.action))
656
- .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
657
- sender: this.getSenderAddress().toString(),
658
- },
659
- });
655
+ this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
656
+ result,
657
+ requests: requests.map(r => ({
658
+ ...r,
659
+ // Avoid logging large blob data
660
+ blobConfig: r.blobConfig
661
+ ? { ...r.blobConfig, blobs: r.blobConfig.blobs.map(b => ({ size: trimmedBytesLength(b) })) }
662
+ : undefined,
663
+ })),
664
+ });
665
+ const successfulActions: Action[] = [];
666
+ const failedActions: Action[] = [];
667
+ for (const request of requests) {
668
+ if (request.checkSuccess(request.request, result)) {
669
+ successfulActions.push(request.action);
670
+ } else {
671
+ failedActions.push(request.action);
660
672
  }
661
- return { successfulActions, failedActions };
662
673
  }
674
+ return { successfulActions, failedActions };
663
675
  }
664
676
 
665
677
  /**
@@ -669,10 +681,13 @@ export class SequencerPublisher {
669
681
  */
670
682
  public async canProposeAt(tipArchive: Fr, msgSender: EthAddress, simulationOverridesPlan?: SimulationOverridesPlan) {
671
683
  // TODO: #14291 - should loop through multiple keys to check if any of them can propose
672
- const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive'];
684
+ // These errors are expected when we cannot actually propose right now — usually because our
685
+ // local view of the chain is ahead of L1 (proposed parent hasn't landed yet, or someone
686
+ // else has just landed the slot, or the archive override doesn't match). We log a warn and
687
+ // skip the proposal; we do NOT treat these as bugs.
688
+ const expectedErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive'];
673
689
 
674
- const pipelined = this.epochCache.isProposerPipeliningEnabled();
675
- const slotOffset = pipelined ? this.aztecSlotDuration : 0n;
690
+ const slotOffset = this.aztecSlotDuration;
676
691
  const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset;
677
692
 
678
693
  return this.rollupContract
@@ -683,8 +698,8 @@ export class SequencerPublisher {
683
698
  await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan),
684
699
  )
685
700
  .catch(err => {
686
- if (err instanceof FormattedViemError && ignoredErrors.find(e => err.message.includes(e))) {
687
- this.log.warn(`Failed canProposeAtTime check with ${ignoredErrors.find(e => err.message.includes(e))}`, {
701
+ if (err instanceof FormattedViemError && expectedErrors.find(e => err.message.includes(e))) {
702
+ this.log.warn(`Failed canProposeAtTime check with ${expectedErrors.find(e => err.message.includes(e))}`, {
688
703
  error: err.message,
689
704
  });
690
705
  } else {
@@ -709,7 +724,7 @@ export class SequencerPublisher {
709
724
 
710
725
  const args = [
711
726
  header.toViem(),
712
- CommitteeAttestationsAndSigners.empty().getPackedAttestations(),
727
+ CommitteeAttestationsAndSigners.packAttestations([]),
713
728
  [], // no signers
714
729
  Signature.empty().toViemSignature(),
715
730
  `0x${'0'.repeat(64)}`, // 32 empty bytes
@@ -717,7 +732,8 @@ export class SequencerPublisher {
717
732
  flags,
718
733
  ] as const;
719
734
 
720
- const ts = this.getSimulationTimestamp(header.slotNumber);
735
+ const l1Constants = this.epochCache.getL1Constants();
736
+ const ts = getLastL1SlotTimestampForL2Slot(header.slotNumber, l1Constants);
721
737
  const stateOverrides = await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan);
722
738
  let balance = 0n;
723
739
  if (this.config.fishermanMode) {
@@ -850,9 +866,7 @@ export class SequencerPublisher {
850
866
  const logData = { ...checkpoint, reason };
851
867
  this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
852
868
 
853
- const attestationsAndSigners = new CommitteeAttestationsAndSigners(
854
- validationResult.attestations,
855
- ).getPackedAttestations();
869
+ const attestationsAndSigners = CommitteeAttestationsAndSigners.packAttestations(validationResult.attestations);
856
870
 
857
871
  if (reason === 'invalid-attestation') {
858
872
  return this.rollupContract.buildInvalidateBadAttestationRequest(
@@ -873,40 +887,11 @@ export class SequencerPublisher {
873
887
  }
874
888
  }
875
889
 
876
- /** Simulates `propose` to make sure that the checkpoint is valid for submission */
877
- @trackSpan('SequencerPublisher.validateCheckpointForSubmission')
878
- public async validateCheckpointForSubmission(
879
- checkpoint: Checkpoint,
880
- attestationsAndSigners: CommitteeAttestationsAndSigners,
881
- attestationsAndSignersSignature: Signature,
882
- simulationOverridesPlan?: SimulationOverridesPlan,
883
- ): Promise<void> {
884
- const blobFields = checkpoint.toBlobFields();
885
- const blobs = await getBlobsPerL1Block(blobFields);
886
- const blobInput = getPrefixedEthBlobCommitments(blobs);
887
-
888
- const args = [
889
- {
890
- header: checkpoint.header.toViem(),
891
- archive: toHex(checkpoint.archive.root.toBuffer()),
892
- oracleInput: {
893
- feeAssetPriceModifier: checkpoint.feeAssetPriceModifier,
894
- },
895
- },
896
- attestationsAndSigners.getPackedAttestations(),
897
- attestationsAndSigners.getSigners().map(signer => signer.toString()),
898
- attestationsAndSignersSignature.toViemSignature(),
899
- blobInput,
900
- ] as const;
901
-
902
- await this.simulateProposeTx(args, simulationOverridesPlan);
903
- }
904
-
905
890
  private async enqueueCastSignalHelper(
906
891
  slotNumber: SlotNumber,
907
892
  signalType: GovernanceSignalAction,
908
893
  payload: EthAddress,
909
- base: IEmpireBase,
894
+ base: GovernanceProposerContract,
910
895
  signerAddress: EthAddress,
911
896
  signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>,
912
897
  ): Promise<boolean> {
@@ -932,34 +917,28 @@ export class SequencerPublisher {
932
917
  return false;
933
918
  }
934
919
 
935
- if (await this.isPayloadEmpty(payload)) {
920
+ if (await base.isPayloadEmpty(payload)) {
936
921
  this.log.warn(`Skipping vote cast for payload with empty code`);
937
922
  return false;
938
923
  }
939
924
 
940
- // Check if payload was already submitted to governance
941
- const cacheKey = payload.toString();
942
- if (!this.payloadProposedCache.has(cacheKey)) {
943
- try {
944
- const l1StartBlock = await this.rollupContract.getL1StartBlock();
945
- const proposed = await retry(
946
- () => base.hasPayloadBeenProposed(payload.toString(), l1StartBlock),
947
- 'Check if payload was proposed',
948
- makeBackoff([0, 1, 2]),
949
- this.log,
950
- true,
951
- );
952
- if (proposed) {
953
- this.payloadProposedCache.add(cacheKey);
954
- }
955
- } catch (err) {
956
- this.log.warn(`Failed to check if payload ${payload} was proposed after retries, skipping signal`, err);
957
- return false;
958
- }
925
+ // Skip signaling if there is already a live (non-terminal) Governance proposal for this
926
+ // payload. This is intentionally not cached: a previously-live proposal may transition to
927
+ // a terminal state (Dropped/Rejected/Expired/Executed), at which point we may want to re-signal
928
+ // the same payload in a future round.
929
+ let proposed = false;
930
+ try {
931
+ proposed = await base.hasActiveProposalWithPayload(payload.toString());
932
+ } catch (err) {
933
+ // We deliberately swallow the error and proceed to signal. Failing closed (skipping the
934
+ // signal) on transient RPC errors would let a flaky L1 endpoint silence governance
935
+ // participation entirely; failing open at worst produces a duplicate signal that the
936
+ // contract will simply count alongside others in the round.
937
+ this.log.error(`Failed to check if payload ${payload} was already proposed (signalling anyway)`, err);
959
938
  }
960
939
 
961
- if (this.payloadProposedCache.has(cacheKey)) {
962
- this.log.info(`Payload ${payload} was already proposed to governance, stopping signals`);
940
+ if (proposed) {
941
+ this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`);
963
942
  return false;
964
943
  }
965
944
 
@@ -981,45 +960,19 @@ export class SequencerPublisher {
981
960
  lastValidL2Slot: slotNumber,
982
961
  });
983
962
 
984
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
985
- const timestamp = this.getSimulationTimestamp(slotNumber);
986
-
987
- try {
988
- await this.l1TxUtils.simulate(request, { time: timestamp }, [], mergeAbis([request.abi ?? [], ErrorsAbi]));
989
- this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, { request });
990
- } catch (err) {
991
- const viemError = formatViemError(err);
992
- this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, viemError, {
993
- simulationTimestamp: timestamp,
994
- l1BlockNumber,
995
- });
996
- this.backupFailedTx({
997
- id: keccak256(request.data!),
998
- failureType: 'simulation',
999
- request: { to: request.to!, data: request.data!, value: request.value?.toString() },
1000
- l1BlockNumber: l1BlockNumber.toString(),
1001
- error: { message: viemError.message, name: viemError.name },
1002
- context: {
1003
- actions: [action],
1004
- slot: slotNumber,
1005
- sender: this.getSenderAddress().toString(),
1006
- },
1007
- });
1008
- // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself
1009
- }
1010
-
1011
963
  // TODO(palla/slash): All votes (governance and slashing) should txTimeoutAt at the end of the slot.
1012
964
  this.addRequest({
1013
- gasConfig: { gasLimit: SequencerPublisher.VOTE_GAS_GUESS },
1014
965
  action,
1015
966
  request,
1016
967
  lastValidL2Slot: slotNumber,
1017
968
  checkSuccess: (_request, result) => {
1018
969
  const success =
1019
970
  result &&
1020
- result.receipt &&
1021
- result.receipt.status === 'success' &&
1022
- tryExtractEvent(result.receipt.logs, base.address.toString(), EmpireBaseAbi, 'SignalCast');
971
+ extractEventSuccess(result.receipt, {
972
+ address: base.address.toString(),
973
+ abi: EmpireBaseAbi,
974
+ eventName: 'SignalCast',
975
+ });
1023
976
 
1024
977
  const logData = { ...result, slotNumber, round, payload: payload.toString() };
1025
978
  if (!success) {
@@ -1041,17 +994,6 @@ export class SequencerPublisher {
1041
994
  return true;
1042
995
  }
1043
996
 
1044
- private async isPayloadEmpty(payload: EthAddress): Promise<boolean> {
1045
- const key = payload.toString();
1046
- const cached = this.isPayloadEmptyCache.get(key);
1047
- if (cached) {
1048
- return cached;
1049
- }
1050
- const isEmpty = !(await this.l1TxUtils.getCode(payload));
1051
- this.isPayloadEmptyCache.set(key, isEmpty);
1052
- return isEmpty;
1053
- }
1054
-
1055
997
  /**
1056
998
  * Enqueues a governance castSignal transaction to cast a signal for a given slot number.
1057
999
  * @param slotNumber - The slot number to cast a signal for.
@@ -1100,10 +1042,14 @@ export class SequencerPublisher {
1100
1042
  }
1101
1043
  const votes = bufferToHex(encodeSlashConsensusVotes(action.votes));
1102
1044
  const request = await this.slashingProposerContract.buildVoteRequestFromSigner(votes, slotNumber, signer);
1103
- await this.simulateAndEnqueueRequest(
1045
+ this.enqueueRequest(
1104
1046
  'vote-offenses',
1105
1047
  request,
1106
- (receipt: TransactionReceipt) => !!this.slashingProposerContract!.tryExtractVoteCastEvent(receipt.logs),
1048
+ {
1049
+ address: this.slashingProposerContract.address.toString(),
1050
+ abi: SlashingProposerAbi,
1051
+ eventName: 'VoteCast',
1052
+ },
1107
1053
  slotNumber,
1108
1054
  );
1109
1055
  break;
@@ -1123,11 +1069,14 @@ export class SequencerPublisher {
1123
1069
  action.round,
1124
1070
  action.committees,
1125
1071
  );
1126
- await this.simulateAndEnqueueRequest(
1072
+ this.enqueueRequest(
1127
1073
  'execute-slash',
1128
1074
  executeRequest,
1129
- (receipt: TransactionReceipt) =>
1130
- !!this.slashingProposerContract!.tryExtractRoundExecutedEvent(receipt.logs),
1075
+ {
1076
+ address: this.slashingProposerContract.address.toString(),
1077
+ abi: SlashingProposerAbi,
1078
+ eventName: 'RoundExecuted',
1079
+ },
1131
1080
  slotNumber,
1132
1081
  );
1133
1082
  break;
@@ -1143,7 +1092,7 @@ export class SequencerPublisher {
1143
1092
  return true;
1144
1093
  }
1145
1094
 
1146
- /** Simulates and enqueues a proposal for a checkpoint on L1 */
1095
+ /** Enqueues a proposal for a checkpoint on L1 */
1147
1096
  public async enqueueProposeCheckpoint(
1148
1097
  checkpoint: Checkpoint,
1149
1098
  attestationsAndSigners: CommitteeAttestationsAndSigners,
@@ -1164,57 +1113,11 @@ export class SequencerPublisher {
1164
1113
  feeAssetPriceModifier: checkpoint.feeAssetPriceModifier,
1165
1114
  };
1166
1115
 
1167
- const simulationOverridesPlan = SimulationOverridesBuilder.from(opts.simulationOverridesPlan)
1168
- .withoutBlobCheck()
1169
- .build();
1170
-
1171
- try {
1172
- // @note This will make sure that we are passing the checks for our header ASSUMING that the data is also made available
1173
- // This means that we can avoid the simulation issues in later checks.
1174
- // By simulation issue, I mean the fact that the block.timestamp is equal to the last block, not the next, which
1175
- // make time consistency checks break.
1176
- // TODO(palla): Check whether we're validating twice, once here and once within addProposeTx, since we call simulateProposeTx in both places.
1177
- await this.validateCheckpointForSubmission(
1178
- checkpoint,
1179
- attestationsAndSigners,
1180
- attestationsAndSignersSignature,
1181
- simulationOverridesPlan,
1182
- );
1183
- } catch (err: any) {
1184
- this.log.error(`Checkpoint validation failed. ${err instanceof Error ? err.message : 'No error message'}`, err, {
1185
- ...checkpoint.getStats(),
1186
- slotNumber: checkpoint.header.slotNumber,
1187
- simulationOverridesPlan,
1188
- });
1189
- throw err;
1190
- }
1191
-
1192
- // Build a pre-check callback that re-validates the checkpoint before L1 submission.
1193
- // During pipelining this catches stale proposals due to prunes or L1 reorgs that occur during the pipeline sleep.
1194
- let preCheck = undefined;
1195
- if (this.epochCache.isProposerPipeliningEnabled()) {
1196
- preCheck = async () => {
1197
- this.log.debug(`Re-validating checkpoint ${checkpoint.number} before L1 submission`);
1198
- await this.validateCheckpointForSubmission(
1199
- checkpoint,
1200
- attestationsAndSigners,
1201
- attestationsAndSignersSignature,
1202
- simulationOverridesPlan,
1203
- );
1204
- };
1205
- }
1206
-
1207
1116
  this.log.verbose(`Enqueuing checkpoint propose transaction`, {
1208
1117
  ...checkpoint.toCheckpointInfo(),
1209
1118
  txTimeoutAt: opts.txTimeoutAt,
1210
- simulationOverridesPlan,
1211
1119
  });
1212
- await this.addProposeTx(
1213
- checkpoint,
1214
- proposeTxArgs,
1215
- { txTimeoutAt: opts.txTimeoutAt, simulationOverridesPlan },
1216
- preCheck,
1217
- );
1120
+ await this.addProposeTx(checkpoint, proposeTxArgs, { txTimeoutAt: opts.txTimeoutAt });
1218
1121
  }
1219
1122
 
1220
1123
  public enqueueInvalidateCheckpoint(
@@ -1225,23 +1128,22 @@ export class SequencerPublisher {
1225
1128
  return;
1226
1129
  }
1227
1130
 
1228
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1229
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil((Number(request.gasUsed) * 64) / 63)));
1230
-
1231
1131
  const { gasUsed, checkpointNumber } = request;
1232
- const logData = { gasUsed, checkpointNumber, gasLimit, opts };
1132
+ const logData = { gasUsed, checkpointNumber, opts };
1233
1133
  this.log.verbose(`Enqueuing invalidate checkpoint request`, logData);
1234
1134
  this.addRequest({
1235
1135
  action: `invalidate-by-${request.reason}`,
1236
1136
  request: request.request,
1237
- gasConfig: { gasLimit, txTimeoutAt: opts.txTimeoutAt },
1137
+ gasConfig: opts.txTimeoutAt ? { txTimeoutAt: opts.txTimeoutAt } : undefined,
1238
1138
  lastValidL2Slot: SlotNumber(this.getCurrentL2Slot() + 2),
1239
1139
  checkSuccess: (_req, result) => {
1240
1140
  const success =
1241
1141
  result &&
1242
- result.receipt &&
1243
- result.receipt.status === 'success' &&
1244
- tryExtractEvent(result.receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointInvalidated');
1142
+ extractEventSuccess(result.receipt, {
1143
+ address: this.rollupContract.address,
1144
+ abi: RollupAbi,
1145
+ eventName: 'CheckpointInvalidated',
1146
+ });
1245
1147
  if (!success) {
1246
1148
  this.log.warn(`Invalidate checkpoint ${request.checkpointNumber} failed`, { ...result, ...logData });
1247
1149
  } else {
@@ -1252,73 +1154,36 @@ export class SequencerPublisher {
1252
1154
  });
1253
1155
  }
1254
1156
 
1255
- private async simulateAndEnqueueRequest(
1157
+ /**
1158
+ * Dedup-checked enqueue helper for actions that are simulated at bundle-send time rather
1159
+ * than at enqueue time. Validates the (action, slot) dedup key, sets `lastActions`, and
1160
+ * enqueues without a gasLimit so the bundle simulate sets the only gasLimit that matters.
1161
+ */
1162
+ private enqueueRequest(
1256
1163
  action: Action,
1257
1164
  request: L1TxRequest,
1258
- checkSuccess: (receipt: TransactionReceipt) => boolean | undefined,
1165
+ eventOpts: { address: string; abi: Abi; eventName: string },
1259
1166
  slotNumber: SlotNumber,
1260
- ) {
1261
- const timestamp = this.getSimulationTimestamp(slotNumber);
1262
- const logData = { slotNumber, timestamp, gasLimit: undefined as bigint | undefined };
1167
+ ): boolean {
1263
1168
  if (this.lastActions[action] && this.lastActions[action] === slotNumber) {
1264
1169
  this.log.debug(`Skipping duplicate action ${action} for slot ${slotNumber}`);
1265
1170
  return false;
1266
1171
  }
1267
-
1268
1172
  const cachedLastActionSlot = this.lastActions[action];
1269
1173
  this.lastActions[action] = slotNumber;
1270
1174
 
1271
- this.log.debug(`Simulating ${action} for slot ${slotNumber}`, logData);
1272
-
1273
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1274
-
1275
- let gasUsed: bigint;
1276
- const simulateAbi = mergeAbis([request.abi ?? [], ErrorsAbi]);
1277
-
1278
- try {
1279
- ({ gasUsed } = await this.l1TxUtils.simulate(request, { time: timestamp }, [], simulateAbi));
1280
- this.log.verbose(`Simulation for ${action} succeeded`, { ...logData, request, gasUsed });
1281
- } catch (err) {
1282
- const viemError = formatViemError(err, simulateAbi);
1283
- this.log.error(`Simulation for ${action} at ${slotNumber} failed`, viemError, logData);
1284
-
1285
- this.backupFailedTx({
1286
- id: keccak256(request.data!),
1287
- failureType: 'simulation',
1288
- request: { to: request.to!, data: request.data!, value: request.value?.toString() },
1289
- l1BlockNumber: l1BlockNumber.toString(),
1290
- error: { message: viemError.message, name: viemError.name },
1291
- context: {
1292
- actions: [action],
1293
- slot: slotNumber,
1294
- sender: this.getSenderAddress().toString(),
1295
- },
1296
- });
1297
-
1298
- return false;
1299
- }
1300
-
1301
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1302
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil((Number(gasUsed) * 64) / 63)));
1303
- logData.gasLimit = gasLimit;
1304
-
1305
- // Store the ABI used for simulation on the request so Multicall3.forward can decode errors
1306
- // when the tx is sent and a revert is diagnosed via simulation.
1307
- const requestWithAbi = { ...request, abi: simulateAbi };
1308
-
1309
- this.log.debug(`Enqueuing ${action}`, logData);
1175
+ this.log.debug(`Enqueuing ${action}`, { slotNumber });
1310
1176
  this.addRequest({
1311
1177
  action,
1312
- request: requestWithAbi,
1313
- gasConfig: { gasLimit },
1178
+ request,
1314
1179
  lastValidL2Slot: slotNumber,
1315
- checkSuccess: (_req, result) => {
1316
- const success = result && result.receipt && result.receipt.status === 'success' && checkSuccess(result.receipt);
1180
+ checkSuccess: (_request, result) => {
1181
+ const success = result && extractEventSuccess(result.receipt, eventOpts);
1317
1182
  if (!success) {
1318
- this.log.warn(`Action ${action} at ${slotNumber} failed`, { ...result, ...logData });
1183
+ this.log.warn(`Action ${action} at ${slotNumber} failed`, { ...result, slotNumber });
1319
1184
  this.lastActions[action] = cachedLastActionSlot;
1320
1185
  } else {
1321
- this.log.info(`Action ${action} at ${slotNumber} succeeded`, { ...result, ...logData });
1186
+ this.log.info(`Action ${action} at ${slotNumber} succeeded`, { ...result, slotNumber });
1322
1187
  }
1323
1188
  return !!success;
1324
1189
  },
@@ -1344,7 +1209,7 @@ export class SequencerPublisher {
1344
1209
  this.l1TxUtils.restart();
1345
1210
  }
1346
1211
 
1347
- private async prepareProposeTx(encodedData: L1ProcessArgs, simulationOverridesPlan?: SimulationOverridesPlan) {
1212
+ private async prepareProposeTx(encodedData: L1ProcessArgs) {
1348
1213
  const kzg = Blob.getViemKzgInstance();
1349
1214
  const blobInput = getPrefixedEthBlobCommitments(encodedData.blobs);
1350
1215
  this.log.debug('Validating blob input', { blobInput });
@@ -1357,7 +1222,11 @@ export class SequencerPublisher {
1357
1222
  blobEvaluationGas = BigInt(encodedData.blobs.length) * 21_000n;
1358
1223
  this.log.debug(`Using fixed blob evaluation gas estimate in fisherman mode: ${blobEvaluationGas}`);
1359
1224
  } else {
1360
- // Normal mode - use estimateGas with blob inputs
1225
+ // We call validateBlobs via estimateGas with real blob+kzg sidecars as a consistency check
1226
+ // that our locally-built blob commitments match the blob data. The bundle simulate at send
1227
+ // time uses eth_simulateV1, which cannot carry blob inputs, so the rollup's on-chain blob
1228
+ // check is forced off there — making this the only pre-flight detector of a commitment/data
1229
+ // mismatch. The returned gas estimate is stashed on the request for the bundle path to read.
1361
1230
  blobEvaluationGas = await this.l1TxUtils
1362
1231
  .estimateGas(
1363
1232
  this.getSenderAddress().toString(),
@@ -1415,119 +1284,21 @@ export class SequencerPublisher {
1415
1284
  blobInput,
1416
1285
  ] as const;
1417
1286
 
1418
- const { rollupData, simulationResult } = await this.simulateProposeTx(args, simulationOverridesPlan);
1287
+ const rollupData = encodeFunctionData({ abi: RollupAbi, functionName: 'propose', args });
1419
1288
 
1420
- return { args, blobEvaluationGas, rollupData, simulationResult };
1421
- }
1422
-
1423
- /**
1424
- * Simulates the propose tx with eth_simulateV1
1425
- * @param args - The propose tx args
1426
- * @returns The simulation result
1427
- */
1428
- private async simulateProposeTx(
1429
- args: readonly [
1430
- {
1431
- readonly header: ViemHeader;
1432
- readonly archive: `0x${string}`;
1433
- readonly oracleInput: {
1434
- readonly feeAssetPriceModifier: bigint;
1435
- };
1436
- },
1437
- ViemCommitteeAttestations,
1438
- `0x${string}`[], // Signers
1439
- ViemSignature,
1440
- `0x${string}`,
1441
- ],
1442
- simulationOverridesPlan?: SimulationOverridesPlan,
1443
- ) {
1444
- const rollupData = encodeFunctionData({
1445
- abi: RollupAbi,
1446
- functionName: 'propose',
1447
- args,
1448
- });
1449
-
1450
- const stateOverrides = await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan);
1451
- // In fisherman mode, simulate as the proposer but with sufficient balance
1452
- if (this.proposerAddressForSimulation) {
1453
- stateOverrides.push({
1454
- address: this.proposerAddressForSimulation.toString(),
1455
- balance: 10n * WEI_CONST * WEI_CONST, // 10 ETH
1456
- });
1457
- }
1458
-
1459
- const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1460
- const simTs = this.getSimulationTimestamp(SlotNumber.fromBigInt(args[0].header.slotNumber));
1461
-
1462
- const simulationResult = await this.l1TxUtils
1463
- .simulate(
1464
- {
1465
- to: this.rollupContract.address,
1466
- data: rollupData,
1467
- gas: MAX_L1_TX_LIMIT,
1468
- ...(this.proposerAddressForSimulation && { from: this.proposerAddressForSimulation.toString() }),
1469
- },
1470
- {
1471
- time: simTs,
1472
- // @note reth should have a 30m gas limit per block but throws errors that this tx is beyond limit so we increase here
1473
- gasLimit: MAX_L1_TX_LIMIT * 2n,
1474
- },
1475
- stateOverrides,
1476
- RollupAbi,
1477
- {
1478
- // @note fallback gas estimate to use if the node doesn't support simulation API
1479
- fallbackGasEstimate: MAX_L1_TX_LIMIT,
1480
- },
1481
- )
1482
- .catch(err => {
1483
- // In fisherman mode, we expect ValidatorSelection__MissingProposerSignature since fisherman doesn't have proposer signature
1484
- const viemError = formatViemError(err);
1485
- if (this.config.fishermanMode && viemError.message?.includes('ValidatorSelection__MissingProposerSignature')) {
1486
- this.log.debug(`Ignoring expected ValidatorSelection__MissingProposerSignature error in fisherman mode`);
1487
- // Return a minimal simulation result with the fallback gas estimate
1488
- return {
1489
- gasUsed: MAX_L1_TX_LIMIT,
1490
- logs: [],
1491
- };
1492
- }
1493
- this.log.error(`Failed to simulate propose tx`, viemError, { simulationTimestamp: simTs });
1494
- this.backupFailedTx({
1495
- id: keccak256(rollupData),
1496
- failureType: 'simulation',
1497
- request: { to: this.rollupContract.address, data: rollupData },
1498
- l1BlockNumber: l1BlockNumber.toString(),
1499
- error: { message: viemError.message, name: viemError.name },
1500
- context: {
1501
- actions: ['propose'],
1502
- slot: Number(args[0].header.slotNumber),
1503
- sender: this.getSenderAddress().toString(),
1504
- },
1505
- });
1506
- throw err;
1507
- });
1508
-
1509
- return { rollupData, simulationResult };
1289
+ return { args, blobEvaluationGas, rollupData };
1510
1290
  }
1511
1291
 
1512
1292
  private async addProposeTx(
1513
1293
  checkpoint: Checkpoint,
1514
1294
  encodedData: L1ProcessArgs,
1515
1295
  opts: EnqueueProposeCheckpointOpts = {},
1516
- preCheck?: () => Promise<void>,
1517
1296
  ): Promise<void> {
1518
1297
  const slot = checkpoint.header.slotNumber;
1519
1298
  const timer = new Timer();
1520
1299
  const kzg = Blob.getViemKzgInstance();
1521
- const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx(
1522
- encodedData,
1523
- opts.simulationOverridesPlan,
1524
- );
1300
+ const { rollupData, blobEvaluationGas } = await this.prepareProposeTx(encodedData);
1525
1301
  const startBlock = await this.l1TxUtils.getBlockNumber();
1526
- const gasLimit = this.l1TxUtils.bumpGasLimit(
1527
- BigInt(Math.ceil((Number(simulationResult.gasUsed) * 64) / 63)) +
1528
- blobEvaluationGas +
1529
- SequencerPublisher.MULTICALL_OVERHEAD_GAS_GUESS, // We issue the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1530
- );
1531
1302
 
1532
1303
  // Send the blobs to the blob client preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
1533
1304
  // tx fails but it does get mined. We make sure that the blobs are sent to the blob client regardless of the tx outcome.
@@ -1544,8 +1315,8 @@ export class SequencerPublisher {
1544
1315
  data: rollupData,
1545
1316
  },
1546
1317
  lastValidL2Slot: checkpoint.header.slotNumber,
1547
- gasConfig: { txTimeoutAt: opts.txTimeoutAt, gasLimit },
1548
- preCheck,
1318
+ gasConfig: { txTimeoutAt: opts.txTimeoutAt, gasLimit: undefined },
1319
+ blobEvaluationGas,
1549
1320
  blobConfig: {
1550
1321
  blobs: encodedData.blobs.map(b => b.data),
1551
1322
  kzg,
@@ -1555,10 +1326,11 @@ export class SequencerPublisher {
1555
1326
  return false;
1556
1327
  }
1557
1328
  const { receipt, stats, errorMsg } = result;
1558
- const success =
1559
- receipt &&
1560
- receipt.status === 'success' &&
1561
- tryExtractEvent(receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointProposed');
1329
+ const success = extractEventSuccess(receipt, {
1330
+ address: this.rollupContract.address,
1331
+ abi: RollupAbi,
1332
+ eventName: 'CheckpointProposed',
1333
+ });
1562
1334
 
1563
1335
  if (success) {
1564
1336
  const endBlock = receipt.blockNumber;
@@ -1599,13 +1371,6 @@ export class SequencerPublisher {
1599
1371
  });
1600
1372
  }
1601
1373
 
1602
- /** Returns the timestamp of the last L1 slot within a given L2 slot. Used as the simulation timestamp
1603
- * for eth_simulateV1 calls, since it's guaranteed to be greater than any L1 block produced during the slot. */
1604
- private getSimulationTimestamp(slot: SlotNumber): bigint {
1605
- const l1Constants = this.epochCache.getL1Constants();
1606
- return getLastL1SlotTimestampForL2Slot(slot, l1Constants);
1607
- }
1608
-
1609
1374
  /** Returns the timestamp of the next L1 slot boundary after now. */
1610
1375
  private getNextL1SlotTimestamp(): bigint {
1611
1376
  const l1Constants = this.epochCache.getL1Constants();