@aztec/sequencer-client 0.0.1-commit.9b94fc1 → 0.0.1-commit.9ee6fcc6

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/dest/client/sequencer-client.d.ts +24 -16
  2. package/dest/client/sequencer-client.d.ts.map +1 -1
  3. package/dest/client/sequencer-client.js +73 -32
  4. package/dest/config.d.ts +35 -9
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +106 -44
  7. package/dest/global_variable_builder/global_builder.d.ts +27 -14
  8. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  9. package/dest/global_variable_builder/global_builder.js +65 -54
  10. package/dest/global_variable_builder/index.d.ts +2 -2
  11. package/dest/global_variable_builder/index.d.ts.map +1 -1
  12. package/dest/index.d.ts +2 -3
  13. package/dest/index.d.ts.map +1 -1
  14. package/dest/index.js +1 -2
  15. package/dest/publisher/config.d.ts +53 -20
  16. package/dest/publisher/config.d.ts.map +1 -1
  17. package/dest/publisher/config.js +124 -39
  18. package/dest/publisher/index.d.ts +2 -1
  19. package/dest/publisher/index.d.ts.map +1 -1
  20. package/dest/publisher/l1_tx_failed_store/factory.d.ts +11 -0
  21. package/dest/publisher/l1_tx_failed_store/factory.d.ts.map +1 -0
  22. package/dest/publisher/l1_tx_failed_store/factory.js +22 -0
  23. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +59 -0
  24. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -0
  25. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +1 -0
  26. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +15 -0
  27. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -0
  28. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +34 -0
  29. package/dest/publisher/l1_tx_failed_store/index.d.ts +4 -0
  30. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -0
  31. package/dest/publisher/l1_tx_failed_store/index.js +2 -0
  32. package/dest/publisher/sequencer-publisher-factory.d.ts +15 -6
  33. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  34. package/dest/publisher/sequencer-publisher-factory.js +28 -3
  35. package/dest/publisher/sequencer-publisher-metrics.d.ts +3 -3
  36. package/dest/publisher/sequencer-publisher-metrics.d.ts.map +1 -1
  37. package/dest/publisher/sequencer-publisher-metrics.js +23 -86
  38. package/dest/publisher/sequencer-publisher.d.ts +79 -49
  39. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  40. package/dest/publisher/sequencer-publisher.js +932 -155
  41. package/dest/sequencer/checkpoint_proposal_job.d.ts +108 -0
  42. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -0
  43. package/dest/sequencer/checkpoint_proposal_job.js +1289 -0
  44. package/dest/sequencer/checkpoint_voter.d.ts +35 -0
  45. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -0
  46. package/dest/sequencer/checkpoint_voter.js +109 -0
  47. package/dest/sequencer/config.d.ts +3 -2
  48. package/dest/sequencer/config.d.ts.map +1 -1
  49. package/dest/sequencer/events.d.ts +47 -0
  50. package/dest/sequencer/events.d.ts.map +1 -0
  51. package/dest/sequencer/events.js +1 -0
  52. package/dest/sequencer/index.d.ts +4 -2
  53. package/dest/sequencer/index.d.ts.map +1 -1
  54. package/dest/sequencer/index.js +3 -1
  55. package/dest/sequencer/metrics.d.ts +42 -6
  56. package/dest/sequencer/metrics.d.ts.map +1 -1
  57. package/dest/sequencer/metrics.js +227 -72
  58. package/dest/sequencer/sequencer.d.ts +125 -134
  59. package/dest/sequencer/sequencer.d.ts.map +1 -1
  60. package/dest/sequencer/sequencer.js +754 -652
  61. package/dest/sequencer/timetable.d.ts +54 -16
  62. package/dest/sequencer/timetable.d.ts.map +1 -1
  63. package/dest/sequencer/timetable.js +147 -62
  64. package/dest/sequencer/types.d.ts +3 -0
  65. package/dest/sequencer/types.d.ts.map +1 -0
  66. package/dest/sequencer/types.js +1 -0
  67. package/dest/sequencer/utils.d.ts +14 -8
  68. package/dest/sequencer/utils.d.ts.map +1 -1
  69. package/dest/sequencer/utils.js +7 -4
  70. package/dest/test/index.d.ts +6 -7
  71. package/dest/test/index.d.ts.map +1 -1
  72. package/dest/test/mock_checkpoint_builder.d.ts +95 -0
  73. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -0
  74. package/dest/test/mock_checkpoint_builder.js +231 -0
  75. package/dest/test/utils.d.ts +53 -0
  76. package/dest/test/utils.d.ts.map +1 -0
  77. package/dest/test/utils.js +104 -0
  78. package/package.json +31 -30
  79. package/src/client/sequencer-client.ts +97 -55
  80. package/src/config.ts +124 -53
  81. package/src/global_variable_builder/global_builder.ts +76 -73
  82. package/src/global_variable_builder/index.ts +1 -1
  83. package/src/index.ts +1 -7
  84. package/src/publisher/config.ts +163 -50
  85. package/src/publisher/index.ts +3 -0
  86. package/src/publisher/l1_tx_failed_store/factory.ts +32 -0
  87. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +55 -0
  88. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +46 -0
  89. package/src/publisher/l1_tx_failed_store/index.ts +3 -0
  90. package/src/publisher/sequencer-publisher-factory.ts +43 -10
  91. package/src/publisher/sequencer-publisher-metrics.ts +19 -71
  92. package/src/publisher/sequencer-publisher.ts +635 -200
  93. package/src/sequencer/README.md +531 -0
  94. package/src/sequencer/checkpoint_proposal_job.ts +1049 -0
  95. package/src/sequencer/checkpoint_voter.ts +130 -0
  96. package/src/sequencer/config.ts +2 -1
  97. package/src/sequencer/events.ts +27 -0
  98. package/src/sequencer/index.ts +3 -1
  99. package/src/sequencer/metrics.ts +282 -82
  100. package/src/sequencer/sequencer.ts +521 -859
  101. package/src/sequencer/timetable.ts +178 -83
  102. package/src/sequencer/types.ts +6 -0
  103. package/src/sequencer/utils.ts +18 -9
  104. package/src/test/index.ts +5 -6
  105. package/src/test/mock_checkpoint_builder.ts +323 -0
  106. package/src/test/utils.ts +167 -0
  107. package/dest/sequencer/block_builder.d.ts +0 -27
  108. package/dest/sequencer/block_builder.d.ts.map +0 -1
  109. package/dest/sequencer/block_builder.js +0 -134
  110. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  111. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  112. package/dest/tx_validator/nullifier_cache.js +0 -24
  113. package/dest/tx_validator/tx_validator_factory.d.ts +0 -17
  114. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  115. package/dest/tx_validator/tx_validator_factory.js +0 -53
  116. package/src/sequencer/block_builder.ts +0 -222
  117. package/src/tx_validator/nullifier_cache.ts +0 -30
  118. package/src/tx_validator/tx_validator_factory.ts +0 -132
@@ -1,48 +1,66 @@
1
- import { L2Block } from '@aztec/aztec.js/block';
1
+ import type { BlobClientInterface } from '@aztec/blob-client/client';
2
2
  import { Blob, getBlobsPerL1Block, getPrefixedEthBlobCommitments } from '@aztec/blob-lib';
3
- import { type BlobSinkClientInterface, createBlobSinkClient } from '@aztec/blob-sink/client';
4
3
  import type { EpochCache } from '@aztec/epoch-cache';
4
+ import type { L1ContractsConfig } from '@aztec/ethereum/config';
5
5
  import {
6
6
  type EmpireSlashingProposerContract,
7
- FormattedViemError,
7
+ FeeAssetPriceOracle,
8
8
  type GovernanceProposerContract,
9
9
  type IEmpireBase,
10
- type L1BlobInputs,
11
- type L1ContractsConfig,
12
- type L1TxConfig,
13
- type L1TxRequest,
14
10
  MULTI_CALL_3_ADDRESS,
15
11
  Multicall3,
16
12
  RollupContract,
17
13
  type TallySlashingProposerContract,
18
- type TransactionStats,
19
14
  type ViemCommitteeAttestations,
20
15
  type ViemHeader,
16
+ } from '@aztec/ethereum/contracts';
17
+ import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
18
+ import {
19
+ type L1BlobInputs,
20
+ type L1TxConfig,
21
+ type L1TxRequest,
22
+ type L1TxUtils,
23
+ MAX_L1_TX_LIMIT,
24
+ type TransactionStats,
21
25
  WEI_CONST,
22
- formatViemError,
23
- tryExtractEvent,
24
- } from '@aztec/ethereum';
25
- import type { L1TxUtilsWithBlobs } from '@aztec/ethereum/l1-tx-utils-with-blobs';
26
+ } from '@aztec/ethereum/l1-tx-utils';
27
+ import { FormattedViemError, formatViemError, mergeAbis, tryExtractEvent } from '@aztec/ethereum/utils';
26
28
  import { sumBigint } from '@aztec/foundation/bigint';
27
29
  import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer';
28
- import { SlotNumber } from '@aztec/foundation/branded-types';
30
+ import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
31
+ import { trimmedBytesLength } from '@aztec/foundation/buffer';
32
+ import { pick } from '@aztec/foundation/collection';
33
+ import type { Fr } from '@aztec/foundation/curves/bn254';
34
+ import { TimeoutError } from '@aztec/foundation/error';
29
35
  import { EthAddress } from '@aztec/foundation/eth-address';
30
36
  import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature';
31
- import type { Fr } from '@aztec/foundation/fields';
32
37
  import { type Logger, createLogger } from '@aztec/foundation/log';
38
+ import { makeBackoff, retry } from '@aztec/foundation/retry';
33
39
  import { bufferToHex } from '@aztec/foundation/string';
34
40
  import { DateProvider, Timer } from '@aztec/foundation/timer';
35
41
  import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts';
36
42
  import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher';
37
- import { CommitteeAttestation, CommitteeAttestationsAndSigners, type ValidateBlockResult } from '@aztec/stdlib/block';
43
+ import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block';
44
+ import type { Checkpoint } from '@aztec/stdlib/checkpoint';
45
+ import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers';
38
46
  import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts';
39
47
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
40
- import type { L1PublishBlockStats } from '@aztec/stdlib/stats';
41
- import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client';
48
+ import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats';
49
+ import { type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
42
50
 
43
- import { type StateOverride, type TransactionReceipt, type TypedDataDefinition, encodeFunctionData, toHex } from 'viem';
44
-
45
- import type { PublisherConfig, TxSenderConfig } from './config.js';
51
+ import {
52
+ type Hex,
53
+ type StateOverride,
54
+ type TransactionReceipt,
55
+ type TypedDataDefinition,
56
+ encodeFunctionData,
57
+ keccak256,
58
+ multicall3Abi,
59
+ toHex,
60
+ } from 'viem';
61
+
62
+ import type { SequencerPublisherConfig } from './config.js';
63
+ import { type FailedL1Tx, type L1TxFailedStore, createL1TxFailedStore } from './l1_tx_failed_store/index.js';
46
64
  import { SequencerPublisherMetrics } from './sequencer-publisher-metrics.js';
47
65
 
48
66
  /** Arguments to the process method of the rollup contract */
@@ -57,6 +75,8 @@ type L1ProcessArgs = {
57
75
  attestationsAndSigners: CommitteeAttestationsAndSigners;
58
76
  /** Attestations and signers signature */
59
77
  attestationsAndSignersSignature: Signature;
78
+ /** The fee asset price modifier in basis points (from oracle) */
79
+ feeAssetPriceModifier: bigint;
60
80
  };
61
81
 
62
82
  export const Actions = [
@@ -78,12 +98,12 @@ type GovernanceSignalAction = Extract<Action, 'governance-signal' | 'empire-slas
78
98
  // Sorting for actions such that invalidations go before proposals, and proposals go before votes
79
99
  export const compareActions = (a: Action, b: Action) => Actions.indexOf(a) - Actions.indexOf(b);
80
100
 
81
- export type InvalidateBlockRequest = {
101
+ export type InvalidateCheckpointRequest = {
82
102
  request: L1TxRequest;
83
103
  reason: 'invalid-attestation' | 'insufficient-attestations';
84
104
  gasUsed: bigint;
85
- blockNumber: number;
86
- forcePendingBlockNumber: number;
105
+ checkpointNumber: CheckpointNumber;
106
+ forcePendingCheckpointNumber: CheckpointNumber;
87
107
  };
88
108
 
89
109
  interface RequestWithExpiry {
@@ -102,23 +122,34 @@ export class SequencerPublisher {
102
122
  private interrupted = false;
103
123
  private metrics: SequencerPublisherMetrics;
104
124
  public epochCache: EpochCache;
125
+ private failedTxStore?: Promise<L1TxFailedStore | undefined>;
105
126
 
106
127
  protected governanceLog = createLogger('sequencer:publisher:governance');
107
128
  protected slashingLog = createLogger('sequencer:publisher:slashing');
108
129
 
109
130
  protected lastActions: Partial<Record<Action, SlotNumber>> = {};
110
131
 
132
+ private isPayloadEmptyCache: Map<string, boolean> = new Map<string, boolean>();
133
+ private payloadProposedCache: Set<string> = new Set<string>();
134
+
111
135
  protected log: Logger;
112
136
  protected ethereumSlotDuration: bigint;
137
+ protected aztecSlotDuration: bigint;
138
+ private dateProvider: DateProvider;
113
139
 
114
- private blobSinkClient: BlobSinkClientInterface;
140
+ private blobClient: BlobClientInterface;
115
141
 
116
142
  /** Address to use for simulations in fisherman mode (actual proposer's address) */
117
143
  private proposerAddressForSimulation?: EthAddress;
118
- // @note - with blobs, the below estimate seems too large.
119
- // Total used for full block from int_l1_pub e2e test: 1m (of which 86k is 1x blob)
120
- // Total used for emptier block from above test: 429k (of which 84k is 1x blob)
121
- public static PROPOSE_GAS_GUESS: bigint = 12_000_000n;
144
+
145
+ /** Optional callback to obtain a replacement publisher when the current one fails to send. */
146
+ private getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise<L1TxUtils | undefined>;
147
+
148
+ /** L1 fee analyzer for fisherman mode */
149
+ private l1FeeAnalyzer?: L1FeeAnalyzer;
150
+
151
+ /** Fee asset price oracle for computing price modifiers from Uniswap V4 */
152
+ private feeAssetPriceOracle: FeeAssetPriceOracle;
122
153
 
123
154
  // A CALL to a cold address is 2700 gas
124
155
  public static MULTICALL_OVERHEAD_GAS_GUESS = 5000n;
@@ -126,20 +157,23 @@ export class SequencerPublisher {
126
157
  // Gas report for VotingWithSigTest shows a max gas of 100k, but we've seen it cost 700k+ in testnet
127
158
  public static VOTE_GAS_GUESS: bigint = 800_000n;
128
159
 
129
- public l1TxUtils: L1TxUtilsWithBlobs;
160
+ public l1TxUtils: L1TxUtils;
130
161
  public rollupContract: RollupContract;
131
162
  public govProposerContract: GovernanceProposerContract;
132
163
  public slashingProposerContract: EmpireSlashingProposerContract | TallySlashingProposerContract | undefined;
133
164
  public slashFactoryContract: SlashFactoryContract;
134
165
 
166
+ public readonly tracer: Tracer;
167
+
135
168
  protected requests: RequestWithExpiry[] = [];
136
169
 
137
170
  constructor(
138
- private config: TxSenderConfig & PublisherConfig & Pick<L1ContractsConfig, 'ethereumSlotDuration'>,
171
+ private config: Pick<SequencerPublisherConfig, 'fishermanMode' | 'l1TxFailedStore'> &
172
+ Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'> & { l1ChainId: number },
139
173
  deps: {
140
174
  telemetry?: TelemetryClient;
141
- blobSinkClient?: BlobSinkClientInterface;
142
- l1TxUtils: L1TxUtilsWithBlobs;
175
+ blobClient: BlobClientInterface;
176
+ l1TxUtils: L1TxUtils;
143
177
  rollupContract: RollupContract;
144
178
  slashingProposerContract: EmpireSlashingProposerContract | TallySlashingProposerContract | undefined;
145
179
  governanceProposerContract: GovernanceProposerContract;
@@ -149,19 +183,23 @@ export class SequencerPublisher {
149
183
  metrics: SequencerPublisherMetrics;
150
184
  lastActions: Partial<Record<Action, SlotNumber>>;
151
185
  log?: Logger;
186
+ getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise<L1TxUtils | undefined>;
152
187
  },
153
188
  ) {
154
189
  this.log = deps.log ?? createLogger('sequencer:publisher');
155
190
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
191
+ this.aztecSlotDuration = BigInt(config.aztecSlotDuration);
192
+ this.dateProvider = deps.dateProvider;
156
193
  this.epochCache = deps.epochCache;
157
194
  this.lastActions = deps.lastActions;
158
195
 
159
- this.blobSinkClient =
160
- deps.blobSinkClient ?? createBlobSinkClient(config, { logger: createLogger('sequencer:blob-sink:client') });
196
+ this.blobClient = deps.blobClient;
161
197
 
162
198
  const telemetry = deps.telemetry ?? getTelemetryClient();
163
199
  this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher');
200
+ this.tracer = telemetry.getTracer('SequencerPublisher');
164
201
  this.l1TxUtils = deps.l1TxUtils;
202
+ this.getNextPublisher = deps.getNextPublisher;
165
203
 
166
204
  this.rollupContract = deps.rollupContract;
167
205
 
@@ -174,16 +212,72 @@ export class SequencerPublisher {
174
212
  this.slashingProposerContract = newSlashingProposer;
175
213
  });
176
214
  this.slashFactoryContract = deps.slashFactoryContract;
215
+
216
+ // Initialize L1 fee analyzer for fisherman mode
217
+ if (config.fishermanMode) {
218
+ this.l1FeeAnalyzer = new L1FeeAnalyzer(
219
+ this.l1TxUtils.client,
220
+ deps.dateProvider,
221
+ createLogger('sequencer:publisher:fee-analyzer'),
222
+ );
223
+ }
224
+
225
+ // Initialize fee asset price oracle
226
+ this.feeAssetPriceOracle = new FeeAssetPriceOracle(
227
+ this.l1TxUtils.client,
228
+ this.rollupContract,
229
+ createLogger('sequencer:publisher:price-oracle'),
230
+ );
231
+
232
+ // Initialize failed L1 tx store (optional, for test networks)
233
+ this.failedTxStore = createL1TxFailedStore(config.l1TxFailedStore, this.log);
234
+ }
235
+
236
+ /**
237
+ * Backs up a failed L1 transaction to the configured store for debugging.
238
+ * Does nothing if no store is configured.
239
+ */
240
+ private backupFailedTx(failedTx: Omit<FailedL1Tx, 'timestamp'>): void {
241
+ if (!this.failedTxStore) {
242
+ return;
243
+ }
244
+
245
+ const tx: FailedL1Tx = {
246
+ ...failedTx,
247
+ timestamp: Date.now(),
248
+ };
249
+
250
+ // Fire and forget - don't block on backup
251
+ void this.failedTxStore
252
+ .then(store => store?.saveFailedTx(tx))
253
+ .catch(err => {
254
+ this.log.warn(`Failed to backup failed L1 tx to store`, err);
255
+ });
177
256
  }
178
257
 
179
258
  public getRollupContract(): RollupContract {
180
259
  return this.rollupContract;
181
260
  }
182
261
 
262
+ /**
263
+ * Gets the fee asset price modifier from the oracle.
264
+ * Returns 0n if the oracle query fails.
265
+ */
266
+ public getFeeAssetPriceModifier(): Promise<bigint> {
267
+ return this.feeAssetPriceOracle.computePriceModifier();
268
+ }
269
+
183
270
  public getSenderAddress() {
184
271
  return this.l1TxUtils.getSenderAddress();
185
272
  }
186
273
 
274
+ /**
275
+ * Gets the L1 fee analyzer instance (only available in fisherman mode)
276
+ */
277
+ public getL1FeeAnalyzer(): L1FeeAnalyzer | undefined {
278
+ return this.l1FeeAnalyzer;
279
+ }
280
+
187
281
  /**
188
282
  * Sets the proposer address to use for simulations in fisherman mode.
189
283
  * @param proposerAddress - The actual proposer's address to use for balance lookups in simulations
@@ -197,7 +291,7 @@ export class SequencerPublisher {
197
291
  }
198
292
 
199
293
  public getCurrentL2Slot(): SlotNumber {
200
- return this.epochCache.getEpochAndSlotNow().slot;
294
+ return this.epochCache.getSlotNow();
201
295
  }
202
296
 
203
297
  /**
@@ -211,6 +305,62 @@ export class SequencerPublisher {
211
305
  }
212
306
  }
213
307
 
308
+ /**
309
+ * Analyzes L1 fees for the pending requests without sending them.
310
+ * This is used in fisherman mode to validate fee calculations.
311
+ * @param l2SlotNumber - The L2 slot number for this analysis
312
+ * @param onComplete - Optional callback to invoke when analysis completes (after block is mined)
313
+ * @returns The analysis result (incomplete until block mines), or undefined if no requests
314
+ */
315
+ public async analyzeL1Fees(
316
+ l2SlotNumber: SlotNumber,
317
+ onComplete?: (analysis: L1FeeAnalysisResult) => void,
318
+ ): Promise<L1FeeAnalysisResult | undefined> {
319
+ if (!this.l1FeeAnalyzer) {
320
+ this.log.warn('L1 fee analyzer not available (not in fisherman mode)');
321
+ return undefined;
322
+ }
323
+
324
+ const requestsToAnalyze = [...this.requests];
325
+ if (requestsToAnalyze.length === 0) {
326
+ this.log.debug('No requests to analyze for L1 fees');
327
+ return undefined;
328
+ }
329
+
330
+ // Extract blob config from requests (if any)
331
+ const blobConfigs = requestsToAnalyze.filter(request => request.blobConfig).map(request => request.blobConfig);
332
+ const blobConfig = blobConfigs[0];
333
+
334
+ // Get gas configs
335
+ const gasConfigs = requestsToAnalyze.filter(request => request.gasConfig).map(request => request.gasConfig);
336
+ const gasLimits = gasConfigs.map(g => g?.gasLimit).filter((g): g is bigint => g !== undefined);
337
+ const gasLimit = gasLimits.length > 0 ? gasLimits.reduce((sum, g) => sum + g, 0n) : 0n;
338
+
339
+ // Get the transaction requests
340
+ const l1Requests = requestsToAnalyze.map(r => r.request);
341
+
342
+ // Start the analysis
343
+ const analysisId = await this.l1FeeAnalyzer.startAnalysis(
344
+ l2SlotNumber,
345
+ gasLimit > 0n ? gasLimit : MAX_L1_TX_LIMIT,
346
+ l1Requests,
347
+ blobConfig,
348
+ onComplete,
349
+ );
350
+
351
+ this.log.info('Started L1 fee analysis', {
352
+ analysisId,
353
+ l2SlotNumber: l2SlotNumber.toString(),
354
+ requestCount: requestsToAnalyze.length,
355
+ hasBlobConfig: !!blobConfig,
356
+ gasLimit: gasLimit.toString(),
357
+ actions: requestsToAnalyze.map(r => r.action),
358
+ });
359
+
360
+ // Return the analysis result (will be incomplete until block mines)
361
+ return this.l1FeeAnalyzer.getAnalysis(analysisId);
362
+ }
363
+
214
364
  /**
215
365
  * Sends all requests that are still valid.
216
366
  * @returns one of:
@@ -218,10 +368,11 @@ export class SequencerPublisher {
218
368
  * - a receipt and errorMsg if it failed on L1
219
369
  * - undefined if no valid requests are found OR the tx failed to send.
220
370
  */
371
+ @trackSpan('SequencerPublisher.sendRequests')
221
372
  public async sendRequests() {
222
373
  const requestsToProcess = [...this.requests];
223
374
  this.requests = [];
224
- if (this.interrupted) {
375
+ if (this.interrupted || requestsToProcess.length === 0) {
225
376
  return undefined;
226
377
  }
227
378
  const currentL2Slot = this.getCurrentL2Slot();
@@ -253,8 +404,8 @@ export class SequencerPublisher {
253
404
  // @note - we can only have one blob config per bundle
254
405
  // find requests with gas and blob configs
255
406
  // See https://github.com/AztecProtocol/aztec-packages/issues/11513
256
- const gasConfigs = requestsToProcess.filter(request => request.gasConfig).map(request => request.gasConfig);
257
- const blobConfigs = requestsToProcess.filter(request => request.blobConfig).map(request => request.blobConfig);
407
+ const gasConfigs = validRequests.filter(request => request.gasConfig).map(request => request.gasConfig);
408
+ const blobConfigs = validRequests.filter(request => request.blobConfig).map(request => request.blobConfig);
258
409
 
259
410
  if (blobConfigs.length > 1) {
260
411
  throw new Error('Multiple blob configs found');
@@ -264,7 +415,16 @@ export class SequencerPublisher {
264
415
 
265
416
  // Merge gasConfigs. Yields the sum of gasLimits, and the earliest txTimeoutAt, or undefined if no gasConfig sets them.
266
417
  const gasLimits = gasConfigs.map(g => g?.gasLimit).filter((g): g is bigint => g !== undefined);
267
- const gasLimit = gasLimits.length > 0 ? sumBigint(gasLimits) : undefined; // sum
418
+ let gasLimit = gasLimits.length > 0 ? sumBigint(gasLimits) : undefined; // sum
419
+ // Cap at L1 block gas limit so the node accepts the tx ("gas limit too high" otherwise).
420
+ const maxGas = MAX_L1_TX_LIMIT;
421
+ if (gasLimit !== undefined && gasLimit > maxGas) {
422
+ this.log.debug('Capping bundled tx gas limit to L1 max', {
423
+ requested: gasLimit,
424
+ capped: maxGas,
425
+ });
426
+ gasLimit = maxGas;
427
+ }
268
428
  const txTimeoutAts = gasConfigs.map(g => g?.txTimeoutAt).filter((g): g is Date => g !== undefined);
269
429
  const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map(g => g.getTime()))) : undefined; // earliest
270
430
  const txConfig: RequestWithExpiry['gasConfig'] = { gasLimit, txTimeoutAt };
@@ -274,19 +434,36 @@ export class SequencerPublisher {
274
434
  validRequests.sort((a, b) => compareActions(a.action, b.action));
275
435
 
276
436
  try {
437
+ // Capture context for failed tx backup before sending
438
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
439
+ const multicallData = encodeFunctionData({
440
+ abi: multicall3Abi,
441
+ functionName: 'aggregate3',
442
+ args: [
443
+ validRequests.map(r => ({
444
+ target: r.request.to!,
445
+ callData: r.request.data!,
446
+ allowFailure: true,
447
+ })),
448
+ ],
449
+ });
450
+ const blobDataHex = blobConfig?.blobs?.map(b => toHex(b)) as Hex[] | undefined;
451
+
452
+ const txContext = { multicallData, blobData: blobDataHex, l1BlockNumber };
453
+
277
454
  this.log.debug('Forwarding transactions', {
278
455
  validRequests: validRequests.map(request => request.action),
279
456
  txConfig,
280
457
  });
281
- const result = await Multicall3.forward(
282
- validRequests.map(request => request.request),
283
- this.l1TxUtils,
284
- txConfig,
285
- blobConfig,
286
- this.rollupContract.address,
287
- this.log,
458
+ const result = await this.forwardWithPublisherRotation(validRequests, txConfig, blobConfig);
459
+ if (result === undefined) {
460
+ return undefined;
461
+ }
462
+ const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(
463
+ validRequests,
464
+ result,
465
+ txContext,
288
466
  );
289
- const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(validRequests, result);
290
467
  return { result, expiredActions, sentActions: validActions, successfulActions, failedActions };
291
468
  } catch (err) {
292
469
  const viemError = formatViemError(err);
@@ -304,16 +481,88 @@ export class SequencerPublisher {
304
481
  }
305
482
  }
306
483
 
484
+ /**
485
+ * Forwards transactions via Multicall3, rotating to the next available publisher if a send
486
+ * failure occurs (i.e. the tx never reached the chain).
487
+ * On-chain reverts and simulation errors are returned as-is without rotation.
488
+ */
489
+ private async forwardWithPublisherRotation(
490
+ validRequests: RequestWithExpiry[],
491
+ txConfig: RequestWithExpiry['gasConfig'],
492
+ blobConfig: L1BlobInputs | undefined,
493
+ ) {
494
+ const triedAddresses: EthAddress[] = [];
495
+ let currentPublisher = this.l1TxUtils;
496
+
497
+ while (true) {
498
+ triedAddresses.push(currentPublisher.getSenderAddress());
499
+ try {
500
+ const result = await Multicall3.forward(
501
+ validRequests.map(r => r.request),
502
+ currentPublisher,
503
+ txConfig,
504
+ blobConfig,
505
+ this.rollupContract.address,
506
+ this.log,
507
+ );
508
+ this.l1TxUtils = currentPublisher;
509
+ return result;
510
+ } catch (err) {
511
+ if (err instanceof TimeoutError) {
512
+ throw err;
513
+ }
514
+ const viemError = formatViemError(err);
515
+ if (!this.getNextPublisher) {
516
+ this.log.error('Failed to publish bundled transactions', viemError);
517
+ return undefined;
518
+ }
519
+ this.log.warn(
520
+ `Publisher ${currentPublisher.getSenderAddress()} failed to send, rotating to next publisher`,
521
+ viemError,
522
+ );
523
+ const nextPublisher = await this.getNextPublisher([...triedAddresses]);
524
+ if (!nextPublisher) {
525
+ this.log.error('All available publishers exhausted, failed to publish bundled transactions');
526
+ return undefined;
527
+ }
528
+ currentPublisher = nextPublisher;
529
+ }
530
+ }
531
+ }
532
+
307
533
  private callbackBundledTransactions(
308
534
  requests: RequestWithExpiry[],
309
- result?: { receipt: TransactionReceipt } | FormattedViemError,
535
+ result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError | undefined,
536
+ txContext: { multicallData: Hex; blobData?: Hex[]; l1BlockNumber: bigint },
310
537
  ) {
311
538
  const actionsListStr = requests.map(r => r.action).join(', ');
312
539
  if (result instanceof FormattedViemError) {
313
540
  this.log.error(`Failed to publish bundled transactions (${actionsListStr})`, result);
541
+ this.backupFailedTx({
542
+ id: keccak256(txContext.multicallData),
543
+ failureType: 'send-error',
544
+ request: { to: MULTI_CALL_3_ADDRESS, data: txContext.multicallData },
545
+ blobData: txContext.blobData,
546
+ l1BlockNumber: txContext.l1BlockNumber.toString(),
547
+ error: { message: result.message, name: result.name },
548
+ context: {
549
+ actions: requests.map(r => r.action),
550
+ requests: requests.map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
551
+ sender: this.getSenderAddress().toString(),
552
+ },
553
+ });
314
554
  return { failedActions: requests.map(r => r.action) };
315
555
  } else {
316
- this.log.verbose(`Published bundled transactions (${actionsListStr})`, { result, requests });
556
+ this.log.verbose(`Published bundled transactions (${actionsListStr})`, {
557
+ result,
558
+ requests: requests.map(r => ({
559
+ ...r,
560
+ // Avoid logging large blob data
561
+ blobConfig: r.blobConfig
562
+ ? { ...r.blobConfig, blobs: r.blobConfig.blobs.map(b => ({ size: trimmedBytesLength(b) })) }
563
+ : undefined,
564
+ })),
565
+ });
317
566
  const successfulActions: Action[] = [];
318
567
  const failedActions: Action[] = [];
319
568
  for (const request of requests) {
@@ -323,26 +572,54 @@ export class SequencerPublisher {
323
572
  failedActions.push(request.action);
324
573
  }
325
574
  }
575
+ // Single backup for the whole reverted tx
576
+ if (failedActions.length > 0 && result?.receipt?.status === 'reverted') {
577
+ this.backupFailedTx({
578
+ id: result.receipt.transactionHash,
579
+ failureType: 'revert',
580
+ request: { to: MULTI_CALL_3_ADDRESS, data: txContext.multicallData },
581
+ blobData: txContext.blobData,
582
+ l1BlockNumber: result.receipt.blockNumber.toString(),
583
+ receipt: {
584
+ transactionHash: result.receipt.transactionHash,
585
+ blockNumber: result.receipt.blockNumber.toString(),
586
+ gasUsed: (result.receipt.gasUsed ?? 0n).toString(),
587
+ status: 'reverted',
588
+ },
589
+ error: { message: result.errorMsg ?? 'Transaction reverted' },
590
+ context: {
591
+ actions: failedActions,
592
+ requests: requests
593
+ .filter(r => failedActions.includes(r.action))
594
+ .map(r => ({ action: r.action, to: r.request.to! as Hex, data: r.request.data! })),
595
+ sender: this.getSenderAddress().toString(),
596
+ },
597
+ });
598
+ }
326
599
  return { successfulActions, failedActions };
327
600
  }
328
601
  }
329
602
 
330
603
  /**
331
- * @notice Will call `canProposeAtNextEthBlock` to make sure that it is possible to propose
604
+ * @notice Will call `canProposeAt` to make sure that it is possible to propose
332
605
  * @param tipArchive - The archive to check
333
606
  * @returns The slot and block number if it is possible to propose, undefined otherwise
334
607
  */
335
- public canProposeAtNextEthBlock(
608
+ public async canProposeAt(
336
609
  tipArchive: Fr,
337
610
  msgSender: EthAddress,
338
- opts: { forcePendingBlockNumber?: number } = {},
611
+ opts: { forcePendingCheckpointNumber?: CheckpointNumber; pipelined?: boolean } = {},
339
612
  ) {
340
613
  // TODO: #14291 - should loop through multiple keys to check if any of them can propose
341
614
  const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive'];
342
615
 
616
+ const pipelined = opts.pipelined ?? this.epochCache.isProposerPipeliningEnabled();
617
+ const slotOffset = pipelined ? this.aztecSlotDuration : 0n;
618
+ const nextL1SlotTs = (await this.getNextL1SlotTimestampWithL1Floor()) + slotOffset;
619
+
343
620
  return this.rollupContract
344
- .canProposeAtNextEthBlock(tipArchive.toBuffer(), msgSender.toString(), Number(this.ethereumSlotDuration), {
345
- forcePendingCheckpointNumber: opts.forcePendingBlockNumber,
621
+ .canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, {
622
+ forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber,
346
623
  })
347
624
  .catch(err => {
348
625
  if (err instanceof FormattedViemError && ignoredErrors.find(e => err.message.includes(e))) {
@@ -355,13 +632,18 @@ export class SequencerPublisher {
355
632
  return undefined;
356
633
  });
357
634
  }
635
+
358
636
  /**
359
637
  * @notice Will simulate `validateHeader` to make sure that the block header is valid
360
638
  * @dev This is a convenience function that can be used by the sequencer to validate a "partial" header.
361
639
  * It will throw if the block header is invalid.
362
640
  * @param header - The block header to validate
363
641
  */
364
- public async validateBlockHeader(header: CheckpointHeader, opts?: { forcePendingBlockNumber: number | undefined }) {
642
+ @trackSpan('SequencerPublisher.validateBlockHeader')
643
+ public async validateBlockHeader(
644
+ header: CheckpointHeader,
645
+ opts?: { forcePendingCheckpointNumber: CheckpointNumber | undefined },
646
+ ): Promise<void> {
365
647
  const flags = { ignoreDA: true, ignoreSignatures: true };
366
648
 
367
649
  const args = [
@@ -370,12 +652,14 @@ export class SequencerPublisher {
370
652
  [], // no signers
371
653
  Signature.empty().toViemSignature(),
372
654
  `0x${'0'.repeat(64)}`, // 32 empty bytes
373
- header.contentCommitment.blobsHash.toString(),
655
+ header.blobsHash.toString(),
374
656
  flags,
375
657
  ] as const;
376
658
 
377
- const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration);
378
- const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride(opts?.forcePendingBlockNumber);
659
+ const ts = await this.getNextL1SlotTimestampWithL1Floor();
660
+ const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride(
661
+ opts?.forcePendingCheckpointNumber,
662
+ );
379
663
  let balance = 0n;
380
664
  if (this.config.fishermanMode) {
381
665
  // In fisherman mode, we can't know where the proposer is publishing from
@@ -402,77 +686,109 @@ export class SequencerPublisher {
402
686
  }
403
687
 
404
688
  /**
405
- * Simulate making a call to invalidate a block with invalid attestations. Returns undefined if no need to invalidate.
406
- * @param block - The block to invalidate and the criteria for invalidation (as returned by the archiver)
689
+ * Simulate making a call to invalidate a checkpoint with invalid attestations. Returns undefined if no need to invalidate.
690
+ * @param validationResult - The validation result indicating which checkpoint to invalidate (as returned by the archiver)
407
691
  */
408
- public async simulateInvalidateBlock(
409
- validationResult: ValidateBlockResult,
410
- ): Promise<InvalidateBlockRequest | undefined> {
692
+ public async simulateInvalidateCheckpoint(
693
+ validationResult: ValidateCheckpointResult,
694
+ ): Promise<InvalidateCheckpointRequest | undefined> {
411
695
  if (validationResult.valid) {
412
696
  return undefined;
413
697
  }
414
698
 
415
- const { reason, block } = validationResult;
416
- const blockNumber = block.blockNumber;
417
- const logData = { ...block, reason };
699
+ const { reason, checkpoint } = validationResult;
700
+ const checkpointNumber = checkpoint.checkpointNumber;
701
+ const logData = { ...checkpoint, reason };
418
702
 
419
- const currentBlockNumber = await this.rollupContract.getCheckpointNumber();
420
- if (currentBlockNumber < validationResult.block.blockNumber) {
703
+ const currentCheckpointNumber = await this.rollupContract.getCheckpointNumber();
704
+ if (currentCheckpointNumber < checkpointNumber) {
421
705
  this.log.verbose(
422
- `Skipping block ${blockNumber} invalidation since it has already been removed from the pending chain`,
423
- { currentBlockNumber, ...logData },
706
+ `Skipping checkpoint ${checkpointNumber} invalidation since it has already been removed from the pending chain`,
707
+ { currentCheckpointNumber, ...logData },
424
708
  );
425
709
  return undefined;
426
710
  }
427
711
 
428
- const request = this.buildInvalidateBlockRequest(validationResult);
429
- this.log.debug(`Simulating invalidate block ${blockNumber}`, { ...logData, request });
712
+ const request = this.buildInvalidateCheckpointRequest(validationResult);
713
+ this.log.debug(`Simulating invalidate checkpoint ${checkpointNumber}`, { ...logData, request });
714
+
715
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
430
716
 
431
717
  try {
432
- const { gasUsed } = await this.l1TxUtils.simulate(request, undefined, undefined, ErrorsAbi);
433
- this.log.verbose(`Simulation for invalidate block ${blockNumber} succeeded`, { ...logData, request, gasUsed });
718
+ const { gasUsed } = await this.l1TxUtils.simulate(
719
+ request,
720
+ undefined,
721
+ undefined,
722
+ mergeAbis([request.abi ?? [], ErrorsAbi]),
723
+ );
724
+ this.log.verbose(`Simulation for invalidate checkpoint ${checkpointNumber} succeeded`, {
725
+ ...logData,
726
+ request,
727
+ gasUsed,
728
+ });
434
729
 
435
- return { request, gasUsed, blockNumber, forcePendingBlockNumber: blockNumber - 1, reason };
730
+ return {
731
+ request,
732
+ gasUsed,
733
+ checkpointNumber,
734
+ forcePendingCheckpointNumber: CheckpointNumber(checkpointNumber - 1),
735
+ reason,
736
+ };
436
737
  } catch (err) {
437
738
  const viemError = formatViemError(err);
438
739
 
439
- // If the error is due to the block not being in the pending chain, and it was indeed removed by someone else,
440
- // we can safely ignore it and return undefined so we go ahead with block building.
441
- if (viemError.message?.includes('Rollup__BlockNotInPendingChain')) {
740
+ // If the error is due to the checkpoint not being in the pending chain, and it was indeed removed by someone else,
741
+ // we can safely ignore it and return undefined so we go ahead with checkpoint building.
742
+ if (viemError.message?.includes('Rollup__CheckpointNotInPendingChain')) {
442
743
  this.log.verbose(
443
- `Simulation for invalidate block ${blockNumber} failed due to block not being in pending chain`,
744
+ `Simulation for invalidate checkpoint ${checkpointNumber} failed due to checkpoint not being in pending chain`,
444
745
  { ...logData, request, error: viemError.message },
445
746
  );
446
- const latestPendingBlockNumber = await this.rollupContract.getCheckpointNumber();
447
- if (latestPendingBlockNumber < blockNumber) {
448
- this.log.verbose(`Block number ${blockNumber} has already been invalidated`, { ...logData });
747
+ const latestPendingCheckpointNumber = await this.rollupContract.getCheckpointNumber();
748
+ if (latestPendingCheckpointNumber < checkpointNumber) {
749
+ this.log.verbose(`Checkpoint ${checkpointNumber} has already been invalidated`, { ...logData });
449
750
  return undefined;
450
751
  } else {
451
752
  this.log.error(
452
- `Simulation for invalidate ${blockNumber} failed and it is still in pending chain`,
753
+ `Simulation for invalidate checkpoint ${checkpointNumber} failed and it is still in pending chain`,
453
754
  viemError,
454
755
  logData,
455
756
  );
456
- throw new Error(`Failed to simulate invalidate block ${blockNumber} while it is still in pending chain`, {
457
- cause: viemError,
458
- });
757
+ throw new Error(
758
+ `Failed to simulate invalidate checkpoint ${checkpointNumber} while it is still in pending chain`,
759
+ {
760
+ cause: viemError,
761
+ },
762
+ );
459
763
  }
460
764
  }
461
765
 
462
- // Otherwise, throw. We cannot build the next block if we cannot invalidate the previous one.
463
- this.log.error(`Simulation for invalidate block ${blockNumber} failed`, viemError, logData);
464
- throw new Error(`Failed to simulate invalidate block ${blockNumber}`, { cause: viemError });
766
+ // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
767
+ this.log.error(`Simulation for invalidate checkpoint ${checkpointNumber} failed`, viemError, logData);
768
+ this.backupFailedTx({
769
+ id: keccak256(request.data!),
770
+ failureType: 'simulation',
771
+ request: { to: request.to!, data: request.data!, value: request.value?.toString() },
772
+ l1BlockNumber: l1BlockNumber.toString(),
773
+ error: { message: viemError.message, name: viemError.name },
774
+ context: {
775
+ actions: [`invalidate-${reason}`],
776
+ checkpointNumber,
777
+ sender: this.getSenderAddress().toString(),
778
+ },
779
+ });
780
+ throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, { cause: viemError });
465
781
  }
466
782
  }
467
783
 
468
- private buildInvalidateBlockRequest(validationResult: ValidateBlockResult) {
784
+ private buildInvalidateCheckpointRequest(validationResult: ValidateCheckpointResult) {
469
785
  if (validationResult.valid) {
470
- throw new Error('Cannot invalidate a valid block');
786
+ throw new Error('Cannot invalidate a valid checkpoint');
471
787
  }
472
788
 
473
- const { block, committee, reason } = validationResult;
474
- const logData = { ...block, reason };
475
- this.log.debug(`Simulating invalidate block ${block.blockNumber}`, logData);
789
+ const { checkpoint, committee, reason } = validationResult;
790
+ const logData = { ...checkpoint, reason };
791
+ this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
476
792
 
477
793
  const attestationsAndSigners = new CommitteeAttestationsAndSigners(
478
794
  validationResult.attestations,
@@ -480,14 +796,14 @@ export class SequencerPublisher {
480
796
 
481
797
  if (reason === 'invalid-attestation') {
482
798
  return this.rollupContract.buildInvalidateBadAttestationRequest(
483
- block.blockNumber,
799
+ checkpoint.checkpointNumber,
484
800
  attestationsAndSigners,
485
801
  committee,
486
802
  validationResult.invalidIndex,
487
803
  );
488
804
  } else if (reason === 'insufficient-attestations') {
489
805
  return this.rollupContract.buildInvalidateInsufficientAttestationsRequest(
490
- block.blockNumber,
806
+ checkpoint.checkpointNumber,
491
807
  attestationsAndSigners,
492
808
  committee,
493
809
  );
@@ -497,47 +813,27 @@ export class SequencerPublisher {
497
813
  }
498
814
  }
499
815
 
500
- /**
501
- * @notice Will simulate `propose` to make sure that the block is valid for submission
502
- *
503
- * @dev Throws if unable to propose
504
- *
505
- * @param block - The block to propose
506
- * @param attestationData - The block's attestation data
507
- *
508
- */
509
- public async validateBlockForSubmission(
510
- block: L2Block,
816
+ /** Simulates `propose` to make sure that the checkpoint is valid for submission */
817
+ @trackSpan('SequencerPublisher.validateCheckpointForSubmission')
818
+ public async validateCheckpointForSubmission(
819
+ checkpoint: Checkpoint,
511
820
  attestationsAndSigners: CommitteeAttestationsAndSigners,
512
821
  attestationsAndSignersSignature: Signature,
513
- options: { forcePendingBlockNumber?: number },
822
+ options: { forcePendingCheckpointNumber?: CheckpointNumber },
514
823
  ): Promise<bigint> {
515
- const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration);
516
-
517
- // If we have no attestations, we still need to provide the empty attestations
518
- // so that the committee is recalculated correctly
519
- const ignoreSignatures = attestationsAndSigners.attestations.length === 0;
520
- if (ignoreSignatures) {
521
- const { committee } = await this.epochCache.getCommittee(block.header.globalVariables.slotNumber);
522
- if (!committee) {
523
- this.log.warn(`No committee found for slot ${block.header.globalVariables.slotNumber}`);
524
- throw new Error(`No committee found for slot ${block.header.globalVariables.slotNumber}`);
525
- }
526
- attestationsAndSigners.attestations = committee.map(committeeMember =>
527
- CommitteeAttestation.fromAddress(committeeMember),
528
- );
529
- }
530
-
531
- const blobFields = block.getCheckpointBlobFields();
532
- const blobs = getBlobsPerL1Block(blobFields);
824
+ // Anchor the simulation timestamp to the checkpoint's own slot start time
825
+ // rather than the current L1 block timestamp, which may overshoot into the next slot if the build ran late.
826
+ const ts = checkpoint.header.timestamp;
827
+ const blobFields = checkpoint.toBlobFields();
828
+ const blobs = await getBlobsPerL1Block(blobFields);
533
829
  const blobInput = getPrefixedEthBlobCommitments(blobs);
534
830
 
535
831
  const args = [
536
832
  {
537
- header: block.getCheckpointHeader().toViem(),
538
- archive: toHex(block.archive.root.toBuffer()),
833
+ header: checkpoint.header.toViem(),
834
+ archive: toHex(checkpoint.archive.root.toBuffer()),
539
835
  oracleInput: {
540
- feeAssetPriceModifier: 0n,
836
+ feeAssetPriceModifier: checkpoint.feeAssetPriceModifier,
541
837
  },
542
838
  },
543
839
  attestationsAndSigners.getPackedAttestations(),
@@ -573,10 +869,45 @@ export class SequencerPublisher {
573
869
  const round = await base.computeRound(slotNumber);
574
870
  const roundInfo = await base.getRoundInfo(this.rollupContract.address, round);
575
871
 
872
+ if (roundInfo.quorumReached) {
873
+ return false;
874
+ }
875
+
576
876
  if (roundInfo.lastSignalSlot >= slotNumber) {
577
877
  return false;
578
878
  }
579
879
 
880
+ if (await this.isPayloadEmpty(payload)) {
881
+ this.log.warn(`Skipping vote cast for payload with empty code`);
882
+ return false;
883
+ }
884
+
885
+ // Check if payload was already submitted to governance
886
+ const cacheKey = payload.toString();
887
+ if (!this.payloadProposedCache.has(cacheKey)) {
888
+ try {
889
+ const l1StartBlock = await this.rollupContract.getL1StartBlock();
890
+ const proposed = await retry(
891
+ () => base.hasPayloadBeenProposed(payload.toString(), l1StartBlock),
892
+ 'Check if payload was proposed',
893
+ makeBackoff([0, 1, 2]),
894
+ this.log,
895
+ true,
896
+ );
897
+ if (proposed) {
898
+ this.payloadProposedCache.add(cacheKey);
899
+ }
900
+ } catch (err) {
901
+ this.log.warn(`Failed to check if payload ${payload} was proposed after retries, skipping signal`, err);
902
+ return false;
903
+ }
904
+ }
905
+
906
+ if (this.payloadProposedCache.has(cacheKey)) {
907
+ this.log.info(`Payload ${payload} was already proposed to governance, stopping signals`);
908
+ return false;
909
+ }
910
+
580
911
  const cachedLastVote = this.lastActions[signalType];
581
912
  this.lastActions[signalType] = slotNumber;
582
913
  const action = signalType;
@@ -595,11 +926,26 @@ export class SequencerPublisher {
595
926
  lastValidL2Slot: slotNumber,
596
927
  });
597
928
 
929
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
930
+
598
931
  try {
599
- await this.l1TxUtils.simulate(request, { time: timestamp }, [], ErrorsAbi);
932
+ await this.l1TxUtils.simulate(request, { time: timestamp }, [], mergeAbis([request.abi ?? [], ErrorsAbi]));
600
933
  this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, { request });
601
934
  } catch (err) {
602
- this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, err);
935
+ const viemError = formatViemError(err);
936
+ this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, viemError);
937
+ this.backupFailedTx({
938
+ id: keccak256(request.data!),
939
+ failureType: 'simulation',
940
+ request: { to: request.to!, data: request.data!, value: request.value?.toString() },
941
+ l1BlockNumber: l1BlockNumber.toString(),
942
+ error: { message: viemError.message, name: viemError.name },
943
+ context: {
944
+ actions: [action],
945
+ slot: slotNumber,
946
+ sender: this.getSenderAddress().toString(),
947
+ },
948
+ });
603
949
  // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself
604
950
  }
605
951
 
@@ -619,14 +965,14 @@ export class SequencerPublisher {
619
965
  const logData = { ...result, slotNumber, round, payload: payload.toString() };
620
966
  if (!success) {
621
967
  this.log.error(
622
- `Signaling in [${action}] for ${payload} at slot ${slotNumber} in round ${round} failed`,
968
+ `Signaling in ${action} for ${payload} at slot ${slotNumber} in round ${round} failed`,
623
969
  logData,
624
970
  );
625
971
  this.lastActions[signalType] = cachedLastVote;
626
972
  return false;
627
973
  } else {
628
974
  this.log.info(
629
- `Signaling in [${action}] for ${payload} at slot ${slotNumber} in round ${round} succeeded`,
975
+ `Signaling in ${action} for ${payload} at slot ${slotNumber} in round ${round} succeeded`,
630
976
  logData,
631
977
  );
632
978
  return true;
@@ -636,6 +982,17 @@ export class SequencerPublisher {
636
982
  return true;
637
983
  }
638
984
 
985
+ private async isPayloadEmpty(payload: EthAddress): Promise<boolean> {
986
+ const key = payload.toString();
987
+ const cached = this.isPayloadEmptyCache.get(key);
988
+ if (cached) {
989
+ return cached;
990
+ }
991
+ const isEmpty = !(await this.l1TxUtils.getCode(payload));
992
+ this.isPayloadEmptyCache.set(key, isEmpty);
993
+ return isEmpty;
994
+ }
995
+
639
996
  /**
640
997
  * Enqueues a governance castSignal transaction to cast a signal for a given slot number.
641
998
  * @param slotNumber - The slot number to cast a signal for.
@@ -783,30 +1140,25 @@ export class SequencerPublisher {
783
1140
  return true;
784
1141
  }
785
1142
 
786
- /**
787
- * Proposes a L2 block on L1.
788
- *
789
- * @param block - L2 block to propose.
790
- * @returns True if the tx has been enqueued, throws otherwise. See #9315
791
- */
792
- public async enqueueProposeL2Block(
793
- block: L2Block,
1143
+ /** Simulates and enqueues a proposal for a checkpoint on L1 */
1144
+ public async enqueueProposeCheckpoint(
1145
+ checkpoint: Checkpoint,
794
1146
  attestationsAndSigners: CommitteeAttestationsAndSigners,
795
1147
  attestationsAndSignersSignature: Signature,
796
- opts: { txTimeoutAt?: Date; forcePendingBlockNumber?: number } = {},
797
- ): Promise<boolean> {
798
- const checkpointHeader = block.getCheckpointHeader();
1148
+ opts: { txTimeoutAt?: Date; forcePendingCheckpointNumber?: CheckpointNumber } = {},
1149
+ ): Promise<void> {
1150
+ const checkpointHeader = checkpoint.header;
799
1151
 
800
- const blobFields = block.getCheckpointBlobFields();
801
- const blobs = getBlobsPerL1Block(blobFields);
1152
+ const blobFields = checkpoint.toBlobFields();
1153
+ const blobs = await getBlobsPerL1Block(blobFields);
802
1154
 
803
- const proposeTxArgs = {
1155
+ const proposeTxArgs: L1ProcessArgs = {
804
1156
  header: checkpointHeader,
805
- archive: block.archive.root.toBuffer(),
806
- body: block.body.toBuffer(),
1157
+ archive: checkpoint.archive.root.toBuffer(),
807
1158
  blobs,
808
1159
  attestationsAndSigners,
809
1160
  attestationsAndSignersSignature,
1161
+ feeAssetPriceModifier: checkpoint.feeAssetPriceModifier,
810
1162
  };
811
1163
 
812
1164
  let ts: bigint;
@@ -817,22 +1169,29 @@ export class SequencerPublisher {
817
1169
  // By simulation issue, I mean the fact that the block.timestamp is equal to the last block, not the next, which
818
1170
  // make time consistency checks break.
819
1171
  // TODO(palla): Check whether we're validating twice, once here and once within addProposeTx, since we call simulateProposeTx in both places.
820
- ts = await this.validateBlockForSubmission(block, attestationsAndSigners, attestationsAndSignersSignature, opts);
1172
+ ts = await this.validateCheckpointForSubmission(
1173
+ checkpoint,
1174
+ attestationsAndSigners,
1175
+ attestationsAndSignersSignature,
1176
+ opts,
1177
+ );
821
1178
  } catch (err: any) {
822
- this.log.error(`Block validation failed. ${err instanceof Error ? err.message : 'No error message'}`, err, {
823
- ...block.getStats(),
824
- slotNumber: block.header.globalVariables.slotNumber,
825
- forcePendingBlockNumber: opts.forcePendingBlockNumber,
1179
+ this.log.error(`Checkpoint validation failed. ${err instanceof Error ? err.message : 'No error message'}`, err, {
1180
+ ...checkpoint.getStats(),
1181
+ slotNumber: checkpoint.header.slotNumber,
1182
+ forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber,
826
1183
  });
827
1184
  throw err;
828
1185
  }
829
1186
 
830
- this.log.verbose(`Enqueuing block propose transaction`, { ...block.toBlockInfo(), ...opts });
831
- await this.addProposeTx(block, proposeTxArgs, opts, ts);
832
- return true;
1187
+ this.log.verbose(`Enqueuing checkpoint propose transaction`, { ...checkpoint.toCheckpointInfo(), ...opts });
1188
+ await this.addProposeTx(checkpoint, proposeTxArgs, opts, ts);
833
1189
  }
834
1190
 
835
- public enqueueInvalidateBlock(request: InvalidateBlockRequest | undefined, opts: { txTimeoutAt?: Date } = {}) {
1191
+ public enqueueInvalidateCheckpoint(
1192
+ request: InvalidateCheckpointRequest | undefined,
1193
+ opts: { txTimeoutAt?: Date } = {},
1194
+ ) {
836
1195
  if (!request) {
837
1196
  return;
838
1197
  }
@@ -840,9 +1199,9 @@ export class SequencerPublisher {
840
1199
  // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
841
1200
  const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil((Number(request.gasUsed) * 64) / 63)));
842
1201
 
843
- const { gasUsed, blockNumber } = request;
844
- const logData = { gasUsed, blockNumber, gasLimit, opts };
845
- this.log.verbose(`Enqueuing invalidate block request`, logData);
1202
+ const { gasUsed, checkpointNumber } = request;
1203
+ const logData = { gasUsed, checkpointNumber, gasLimit, opts };
1204
+ this.log.verbose(`Enqueuing invalidate checkpoint request`, logData);
846
1205
  this.addRequest({
847
1206
  action: `invalidate-by-${request.reason}`,
848
1207
  request: request.request,
@@ -855,9 +1214,9 @@ export class SequencerPublisher {
855
1214
  result.receipt.status === 'success' &&
856
1215
  tryExtractEvent(result.receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointInvalidated');
857
1216
  if (!success) {
858
- this.log.warn(`Invalidate block ${request.blockNumber} failed`, { ...result, ...logData });
1217
+ this.log.warn(`Invalidate checkpoint ${request.checkpointNumber} failed`, { ...result, ...logData });
859
1218
  } else {
860
- this.log.info(`Invalidate block ${request.blockNumber} succeeded`, { ...result, ...logData });
1219
+ this.log.info(`Invalidate checkpoint ${request.checkpointNumber} succeeded`, { ...result, ...logData });
861
1220
  }
862
1221
  return !!success;
863
1222
  },
@@ -882,13 +1241,30 @@ export class SequencerPublisher {
882
1241
 
883
1242
  this.log.debug(`Simulating ${action} for slot ${slotNumber}`, logData);
884
1243
 
1244
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1245
+
885
1246
  let gasUsed: bigint;
1247
+ const simulateAbi = mergeAbis([request.abi ?? [], ErrorsAbi]);
886
1248
  try {
887
- ({ gasUsed } = await this.l1TxUtils.simulate(request, { time: timestamp }, [], ErrorsAbi)); // TODO(palla/slash): Check the timestamp logic
1249
+ ({ gasUsed } = await this.l1TxUtils.simulate(request, { time: timestamp }, [], simulateAbi)); // TODO(palla/slash): Check the timestamp logic
888
1250
  this.log.verbose(`Simulation for ${action} succeeded`, { ...logData, request, gasUsed });
889
1251
  } catch (err) {
890
- const viemError = formatViemError(err);
1252
+ const viemError = formatViemError(err, simulateAbi);
891
1253
  this.log.error(`Simulation for ${action} at ${slotNumber} failed`, viemError, logData);
1254
+
1255
+ this.backupFailedTx({
1256
+ id: keccak256(request.data!),
1257
+ failureType: 'simulation',
1258
+ request: { to: request.to!, data: request.data!, value: request.value?.toString() },
1259
+ l1BlockNumber: l1BlockNumber.toString(),
1260
+ error: { message: viemError.message, name: viemError.name },
1261
+ context: {
1262
+ actions: [action],
1263
+ slot: slotNumber,
1264
+ sender: this.getSenderAddress().toString(),
1265
+ },
1266
+ });
1267
+
892
1268
  return false;
893
1269
  }
894
1270
 
@@ -896,10 +1272,14 @@ export class SequencerPublisher {
896
1272
  const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil((Number(gasUsed) * 64) / 63)));
897
1273
  logData.gasLimit = gasLimit;
898
1274
 
1275
+ // Store the ABI used for simulation on the request so Multicall3.forward can decode errors
1276
+ // when the tx is sent and a revert is diagnosed via simulation.
1277
+ const requestWithAbi = { ...request, abi: simulateAbi };
1278
+
899
1279
  this.log.debug(`Enqueuing ${action}`, logData);
900
1280
  this.addRequest({
901
1281
  action,
902
- request,
1282
+ request: requestWithAbi,
903
1283
  gasConfig: { gasLimit },
904
1284
  lastValidL2Slot: slotNumber,
905
1285
  checkSuccess: (_req, result) => {
@@ -936,7 +1316,7 @@ export class SequencerPublisher {
936
1316
  private async prepareProposeTx(
937
1317
  encodedData: L1ProcessArgs,
938
1318
  timestamp: bigint,
939
- options: { forcePendingBlockNumber?: number },
1319
+ options: { forcePendingCheckpointNumber?: CheckpointNumber },
940
1320
  ) {
941
1321
  const kzg = Blob.getViemKzgInstance();
942
1322
  const blobInput = getPrefixedEthBlobCommitments(encodedData.blobs);
@@ -968,9 +1348,27 @@ export class SequencerPublisher {
968
1348
  kzg,
969
1349
  },
970
1350
  )
971
- .catch(err => {
972
- const { message, metaMessages } = formatViemError(err);
973
- this.log.error(`Failed to validate blobs`, message, { metaMessages });
1351
+ .catch(async err => {
1352
+ const viemError = formatViemError(err);
1353
+ this.log.error(`Failed to validate blobs`, viemError.message, { metaMessages: viemError.metaMessages });
1354
+ const validateBlobsData = encodeFunctionData({
1355
+ abi: RollupAbi,
1356
+ functionName: 'validateBlobs',
1357
+ args: [blobInput],
1358
+ });
1359
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1360
+ this.backupFailedTx({
1361
+ id: keccak256(validateBlobsData),
1362
+ failureType: 'simulation',
1363
+ request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1364
+ blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1365
+ l1BlockNumber: l1BlockNumber.toString(),
1366
+ error: { message: viemError.message, name: viemError.name },
1367
+ context: {
1368
+ actions: ['validate-blobs'],
1369
+ sender: this.getSenderAddress().toString(),
1370
+ },
1371
+ });
974
1372
  throw new Error('Failed to validate blobs');
975
1373
  });
976
1374
  }
@@ -981,8 +1379,7 @@ export class SequencerPublisher {
981
1379
  header: encodedData.header.toViem(),
982
1380
  archive: toHex(encodedData.archive),
983
1381
  oracleInput: {
984
- // We are currently not modifying these. See #9963
985
- feeAssetPriceModifier: 0n,
1382
+ feeAssetPriceModifier: encodedData.feeAssetPriceModifier,
986
1383
  },
987
1384
  },
988
1385
  encodedData.attestationsAndSigners.getPackedAttestations(),
@@ -1008,7 +1405,7 @@ export class SequencerPublisher {
1008
1405
  readonly header: ViemHeader;
1009
1406
  readonly archive: `0x${string}`;
1010
1407
  readonly oracleInput: {
1011
- readonly feeAssetPriceModifier: 0n;
1408
+ readonly feeAssetPriceModifier: bigint;
1012
1409
  };
1013
1410
  },
1014
1411
  ViemCommitteeAttestations,
@@ -1017,7 +1414,7 @@ export class SequencerPublisher {
1017
1414
  `0x${string}`,
1018
1415
  ],
1019
1416
  timestamp: bigint,
1020
- options: { forcePendingBlockNumber?: number },
1417
+ options: { forcePendingCheckpointNumber?: CheckpointNumber },
1021
1418
  ) {
1022
1419
  const rollupData = encodeFunctionData({
1023
1420
  abi: RollupAbi,
@@ -1025,10 +1422,10 @@ export class SequencerPublisher {
1025
1422
  args,
1026
1423
  });
1027
1424
 
1028
- // override the pending block number if requested
1029
- const forcePendingBlockNumberStateDiff = (
1030
- options.forcePendingBlockNumber !== undefined
1031
- ? await this.rollupContract.makePendingCheckpointNumberOverride(options.forcePendingBlockNumber)
1425
+ // override the pending checkpoint number if requested
1426
+ const forcePendingCheckpointNumberStateDiff = (
1427
+ options.forcePendingCheckpointNumber !== undefined
1428
+ ? await this.rollupContract.makePendingCheckpointNumberOverride(options.forcePendingCheckpointNumber)
1032
1429
  : []
1033
1430
  ).flatMap(override => override.stateDiff ?? []);
1034
1431
 
@@ -1038,7 +1435,7 @@ export class SequencerPublisher {
1038
1435
  // @note we override checkBlob to false since blobs are not part simulate()
1039
1436
  stateDiff: [
1040
1437
  { slot: toPaddedHex(RollupContract.checkBlobStorageSlot, true), value: toPaddedHex(0n, true) },
1041
- ...forcePendingBlockNumberStateDiff,
1438
+ ...forcePendingCheckpointNumberStateDiff,
1042
1439
  ],
1043
1440
  },
1044
1441
  ];
@@ -1050,25 +1447,27 @@ export class SequencerPublisher {
1050
1447
  });
1051
1448
  }
1052
1449
 
1450
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1451
+
1053
1452
  const simulationResult = await this.l1TxUtils
1054
1453
  .simulate(
1055
1454
  {
1056
1455
  to: this.rollupContract.address,
1057
1456
  data: rollupData,
1058
- gas: SequencerPublisher.PROPOSE_GAS_GUESS,
1457
+ gas: MAX_L1_TX_LIMIT,
1059
1458
  ...(this.proposerAddressForSimulation && { from: this.proposerAddressForSimulation.toString() }),
1060
1459
  },
1061
1460
  {
1062
1461
  // @note we add 1n to the timestamp because geth implementation doesn't like simulation timestamp to be equal to the current block timestamp
1063
1462
  time: timestamp + 1n,
1064
1463
  // @note reth should have a 30m gas limit per block but throws errors that this tx is beyond limit so we increase here
1065
- gasLimit: SequencerPublisher.PROPOSE_GAS_GUESS * 2n,
1464
+ gasLimit: MAX_L1_TX_LIMIT * 2n,
1066
1465
  },
1067
1466
  stateOverrides,
1068
1467
  RollupAbi,
1069
1468
  {
1070
1469
  // @note fallback gas estimate to use if the node doesn't support simulation API
1071
- fallbackGasEstimate: SequencerPublisher.PROPOSE_GAS_GUESS,
1470
+ fallbackGasEstimate: MAX_L1_TX_LIMIT,
1072
1471
  },
1073
1472
  )
1074
1473
  .catch(err => {
@@ -1078,11 +1477,23 @@ export class SequencerPublisher {
1078
1477
  this.log.debug(`Ignoring expected ValidatorSelection__MissingProposerSignature error in fisherman mode`);
1079
1478
  // Return a minimal simulation result with the fallback gas estimate
1080
1479
  return {
1081
- gasUsed: SequencerPublisher.PROPOSE_GAS_GUESS,
1480
+ gasUsed: MAX_L1_TX_LIMIT,
1082
1481
  logs: [],
1083
1482
  };
1084
1483
  }
1085
1484
  this.log.error(`Failed to simulate propose tx`, viemError);
1485
+ this.backupFailedTx({
1486
+ id: keccak256(rollupData),
1487
+ failureType: 'simulation',
1488
+ request: { to: this.rollupContract.address, data: rollupData },
1489
+ l1BlockNumber: l1BlockNumber.toString(),
1490
+ error: { message: viemError.message, name: viemError.name },
1491
+ context: {
1492
+ actions: ['propose'],
1493
+ slot: Number(args[0].header.slotNumber),
1494
+ sender: this.getSenderAddress().toString(),
1495
+ },
1496
+ });
1086
1497
  throw err;
1087
1498
  });
1088
1499
 
@@ -1090,11 +1501,12 @@ export class SequencerPublisher {
1090
1501
  }
1091
1502
 
1092
1503
  private async addProposeTx(
1093
- block: L2Block,
1504
+ checkpoint: Checkpoint,
1094
1505
  encodedData: L1ProcessArgs,
1095
- opts: { txTimeoutAt?: Date; forcePendingBlockNumber?: number } = {},
1506
+ opts: { txTimeoutAt?: Date; forcePendingCheckpointNumber?: CheckpointNumber } = {},
1096
1507
  timestamp: bigint,
1097
1508
  ): Promise<void> {
1509
+ const slot = checkpoint.header.slotNumber;
1098
1510
  const timer = new Timer();
1099
1511
  const kzg = Blob.getViemKzgInstance();
1100
1512
  const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx(
@@ -1109,11 +1521,13 @@ export class SequencerPublisher {
1109
1521
  SequencerPublisher.MULTICALL_OVERHEAD_GAS_GUESS, // We issue the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1110
1522
  );
1111
1523
 
1112
- // Send the blobs to the blob sink preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
1113
- // tx fails but it does get mined. We make sure that the blobs are sent to the blob sink regardless of the tx outcome.
1114
- void this.blobSinkClient.sendBlobsToBlobSink(encodedData.blobs).catch(_err => {
1115
- this.log.error('Failed to send blobs to blob sink');
1116
- });
1524
+ // Send the blobs to the blob client preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
1525
+ // tx fails but it does get mined. We make sure that the blobs are sent to the blob client regardless of the tx outcome.
1526
+ void Promise.resolve().then(() =>
1527
+ this.blobClient.sendBlobsToFilestore(encodedData.blobs).catch(_err => {
1528
+ this.log.error('Failed to send blobs to blob client');
1529
+ }),
1530
+ );
1117
1531
 
1118
1532
  return this.addRequest({
1119
1533
  action: 'propose',
@@ -1121,7 +1535,7 @@ export class SequencerPublisher {
1121
1535
  to: this.rollupContract.address,
1122
1536
  data: rollupData,
1123
1537
  },
1124
- lastValidL2Slot: block.header.globalVariables.slotNumber,
1538
+ lastValidL2Slot: checkpoint.header.slotNumber,
1125
1539
  gasConfig: { ...opts, gasLimit },
1126
1540
  blobConfig: {
1127
1541
  blobs: encodedData.blobs.map(b => b.data),
@@ -1136,11 +1550,12 @@ export class SequencerPublisher {
1136
1550
  receipt &&
1137
1551
  receipt.status === 'success' &&
1138
1552
  tryExtractEvent(receipt.logs, this.rollupContract.address, RollupAbi, 'CheckpointProposed');
1553
+
1139
1554
  if (success) {
1140
1555
  const endBlock = receipt.blockNumber;
1141
1556
  const inclusionBlocks = Number(endBlock - startBlock);
1142
1557
  const { calldataGas, calldataSize, sender } = stats!;
1143
- const publishStats: L1PublishBlockStats = {
1558
+ const publishStats: L1PublishCheckpointStats = {
1144
1559
  gasPrice: receipt.effectiveGasPrice,
1145
1560
  gasUsed: receipt.gasUsed,
1146
1561
  blobGasUsed: receipt.blobGasUsed ?? 0n,
@@ -1149,26 +1564,46 @@ export class SequencerPublisher {
1149
1564
  calldataGas,
1150
1565
  calldataSize,
1151
1566
  sender,
1152
- ...block.getStats(),
1567
+ ...checkpoint.getStats(),
1153
1568
  eventName: 'rollup-published-to-l1',
1154
1569
  blobCount: encodedData.blobs.length,
1155
1570
  inclusionBlocks,
1156
1571
  };
1157
- this.log.info(`Published L2 block to L1 rollup contract`, { ...stats, ...block.getStats(), ...receipt });
1572
+ this.log.info(`Published checkpoint ${checkpoint.number} at slot ${slot} to rollup contract`, {
1573
+ ...stats,
1574
+ ...checkpoint.getStats(),
1575
+ ...pick(receipt, 'transactionHash', 'blockHash'),
1576
+ });
1158
1577
  this.metrics.recordProcessBlockTx(timer.ms(), publishStats);
1159
1578
 
1160
1579
  return true;
1161
1580
  } else {
1162
1581
  this.metrics.recordFailedTx('process');
1163
- this.log.error(`Rollup process tx failed: ${errorMsg ?? 'no error message'}`, undefined, {
1164
- ...block.getStats(),
1165
- receipt,
1166
- txHash: receipt.transactionHash,
1167
- slotNumber: block.header.globalVariables.slotNumber,
1168
- });
1582
+ this.log.error(
1583
+ `Publishing checkpoint at slot ${slot} failed with ${errorMsg ?? 'no error message'}`,
1584
+ undefined,
1585
+ { ...checkpoint.getStats(), ...receipt },
1586
+ );
1169
1587
  return false;
1170
1588
  }
1171
1589
  },
1172
1590
  });
1173
1591
  }
1592
+
1593
+ /**
1594
+ * Returns the timestamp to use when simulating L1 proposal calls.
1595
+ * Uses the wall-clock-based next L1 slot boundary, but floors it with the latest L1 block timestamp
1596
+ * plus one slot duration. This prevents the sequencer from targeting a future L2 slot when the L1
1597
+ * chain hasn't caught up to the wall clock yet (e.g., the dateProvider is one L1 slot ahead of the
1598
+ * latest mined block), which would cause the propose tx to land in an L1 block with block.timestamp
1599
+ * still in the previous L2 slot.
1600
+ * TODO(palla): Properly fix by keeping dateProvider synced with anvil's chain time on every block.
1601
+ */
1602
+ private async getNextL1SlotTimestampWithL1Floor(): Promise<bigint> {
1603
+ const l1Constants = this.epochCache.getL1Constants();
1604
+ const fromWallClock = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants);
1605
+ const latestBlock = await this.l1TxUtils.client.getBlock();
1606
+ const fromL1Block = latestBlock.timestamp + BigInt(l1Constants.ethereumSlotDuration);
1607
+ return fromWallClock > fromL1Block ? fromWallClock : fromL1Block;
1608
+ }
1174
1609
  }