@aztec/sequencer-client 0.0.1-commit.b655e406 → 0.0.1-commit.b9865e97

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 (153) hide show
  1. package/README.md +281 -21
  2. package/dest/client/index.d.ts +1 -1
  3. package/dest/client/sequencer-client.d.ts +26 -16
  4. package/dest/client/sequencer-client.d.ts.map +1 -1
  5. package/dest/client/sequencer-client.js +78 -37
  6. package/dest/config.d.ts +37 -8
  7. package/dest/config.d.ts.map +1 -1
  8. package/dest/config.js +139 -48
  9. package/dest/global_variable_builder/fee_predictor.d.ts +37 -0
  10. package/dest/global_variable_builder/fee_predictor.d.ts.map +1 -0
  11. package/dest/global_variable_builder/fee_predictor.js +128 -0
  12. package/dest/global_variable_builder/fee_provider.d.ts +21 -0
  13. package/dest/global_variable_builder/fee_provider.d.ts.map +1 -0
  14. package/dest/global_variable_builder/fee_provider.js +58 -0
  15. package/dest/global_variable_builder/global_builder.d.ts +24 -20
  16. package/dest/global_variable_builder/global_builder.d.ts.map +1 -1
  17. package/dest/global_variable_builder/global_builder.js +44 -65
  18. package/dest/global_variable_builder/index.d.ts +4 -2
  19. package/dest/global_variable_builder/index.d.ts.map +1 -1
  20. package/dest/global_variable_builder/index.js +2 -0
  21. package/dest/index.d.ts +2 -3
  22. package/dest/index.d.ts.map +1 -1
  23. package/dest/index.js +1 -2
  24. package/dest/publisher/config.d.ts +55 -20
  25. package/dest/publisher/config.d.ts.map +1 -1
  26. package/dest/publisher/config.js +124 -34
  27. package/dest/publisher/index.d.ts +2 -1
  28. package/dest/publisher/index.d.ts.map +1 -1
  29. package/dest/publisher/l1_tx_failed_store/factory.d.ts +11 -0
  30. package/dest/publisher/l1_tx_failed_store/factory.d.ts.map +1 -0
  31. package/dest/publisher/l1_tx_failed_store/factory.js +22 -0
  32. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts +58 -0
  33. package/dest/publisher/l1_tx_failed_store/failed_tx_store.d.ts.map +1 -0
  34. package/dest/publisher/l1_tx_failed_store/failed_tx_store.js +1 -0
  35. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts +15 -0
  36. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.d.ts.map +1 -0
  37. package/dest/publisher/l1_tx_failed_store/file_store_failed_tx_store.js +34 -0
  38. package/dest/publisher/l1_tx_failed_store/index.d.ts +4 -0
  39. package/dest/publisher/l1_tx_failed_store/index.d.ts.map +1 -0
  40. package/dest/publisher/l1_tx_failed_store/index.js +2 -0
  41. package/dest/publisher/sequencer-bundle-simulator.d.ts +96 -0
  42. package/dest/publisher/sequencer-bundle-simulator.d.ts.map +1 -0
  43. package/dest/publisher/sequencer-bundle-simulator.js +198 -0
  44. package/dest/publisher/sequencer-publisher-factory.d.ts +15 -8
  45. package/dest/publisher/sequencer-publisher-factory.d.ts.map +1 -1
  46. package/dest/publisher/sequencer-publisher-factory.js +28 -4
  47. package/dest/publisher/sequencer-publisher-metrics.d.ts +3 -3
  48. package/dest/publisher/sequencer-publisher-metrics.d.ts.map +1 -1
  49. package/dest/publisher/sequencer-publisher-metrics.js +23 -86
  50. package/dest/publisher/sequencer-publisher.d.ts +137 -97
  51. package/dest/publisher/sequencer-publisher.d.ts.map +1 -1
  52. package/dest/publisher/sequencer-publisher.js +1004 -416
  53. package/dest/sequencer/automine/automine_factory.d.ts +54 -0
  54. package/dest/sequencer/automine/automine_factory.d.ts.map +1 -0
  55. package/dest/sequencer/automine/automine_factory.js +84 -0
  56. package/dest/sequencer/automine/automine_sequencer.d.ts +151 -0
  57. package/dest/sequencer/automine/automine_sequencer.d.ts.map +1 -0
  58. package/dest/sequencer/automine/automine_sequencer.js +509 -0
  59. package/dest/sequencer/chain_state_overrides.d.ts +61 -0
  60. package/dest/sequencer/chain_state_overrides.d.ts.map +1 -0
  61. package/dest/sequencer/chain_state_overrides.js +98 -0
  62. package/dest/sequencer/checkpoint_proposal_job.d.ts +150 -0
  63. package/dest/sequencer/checkpoint_proposal_job.d.ts.map +1 -0
  64. package/dest/sequencer/checkpoint_proposal_job.js +1793 -0
  65. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts +34 -0
  66. package/dest/sequencer/checkpoint_proposal_job_metrics.d.ts.map +1 -0
  67. package/dest/sequencer/checkpoint_proposal_job_metrics.js +72 -0
  68. package/dest/sequencer/checkpoint_voter.d.ts +34 -0
  69. package/dest/sequencer/checkpoint_voter.d.ts.map +1 -0
  70. package/dest/sequencer/checkpoint_voter.js +106 -0
  71. package/dest/sequencer/config.d.ts +3 -2
  72. package/dest/sequencer/config.d.ts.map +1 -1
  73. package/dest/sequencer/errors.d.ts +1 -1
  74. package/dest/sequencer/errors.d.ts.map +1 -1
  75. package/dest/sequencer/events.d.ts +92 -0
  76. package/dest/sequencer/events.d.ts.map +1 -0
  77. package/dest/sequencer/events.js +1 -0
  78. package/dest/sequencer/index.d.ts +6 -2
  79. package/dest/sequencer/index.d.ts.map +1 -1
  80. package/dest/sequencer/index.js +5 -1
  81. package/dest/sequencer/metrics.d.ts +48 -4
  82. package/dest/sequencer/metrics.d.ts.map +1 -1
  83. package/dest/sequencer/metrics.js +257 -50
  84. package/dest/sequencer/sequencer.d.ts +175 -144
  85. package/dest/sequencer/sequencer.d.ts.map +1 -1
  86. package/dest/sequencer/sequencer.js +1022 -567
  87. package/dest/sequencer/timetable.d.ts +64 -16
  88. package/dest/sequencer/timetable.d.ts.map +1 -1
  89. package/dest/sequencer/timetable.js +154 -64
  90. package/dest/sequencer/types.d.ts +3 -0
  91. package/dest/sequencer/types.d.ts.map +1 -0
  92. package/dest/sequencer/types.js +1 -0
  93. package/dest/sequencer/utils.d.ts +14 -8
  94. package/dest/sequencer/utils.d.ts.map +1 -1
  95. package/dest/sequencer/utils.js +7 -4
  96. package/dest/test/index.d.ts +6 -7
  97. package/dest/test/index.d.ts.map +1 -1
  98. package/dest/test/mock_checkpoint_builder.d.ts +95 -0
  99. package/dest/test/mock_checkpoint_builder.d.ts.map +1 -0
  100. package/dest/test/mock_checkpoint_builder.js +231 -0
  101. package/dest/test/utils.d.ts +53 -0
  102. package/dest/test/utils.d.ts.map +1 -0
  103. package/dest/test/utils.js +105 -0
  104. package/package.json +32 -30
  105. package/src/client/sequencer-client.ts +105 -60
  106. package/src/config.ts +163 -57
  107. package/src/global_variable_builder/README.md +44 -0
  108. package/src/global_variable_builder/fee_predictor.ts +172 -0
  109. package/src/global_variable_builder/fee_provider.ts +75 -0
  110. package/src/global_variable_builder/global_builder.ts +63 -92
  111. package/src/global_variable_builder/index.ts +3 -1
  112. package/src/index.ts +5 -2
  113. package/src/publisher/config.ts +174 -51
  114. package/src/publisher/index.ts +3 -0
  115. package/src/publisher/l1_tx_failed_store/factory.ts +32 -0
  116. package/src/publisher/l1_tx_failed_store/failed_tx_store.ts +57 -0
  117. package/src/publisher/l1_tx_failed_store/file_store_failed_tx_store.ts +46 -0
  118. package/src/publisher/l1_tx_failed_store/index.ts +3 -0
  119. package/src/publisher/sequencer-bundle-simulator.ts +254 -0
  120. package/src/publisher/sequencer-publisher-factory.ts +45 -14
  121. package/src/publisher/sequencer-publisher-metrics.ts +19 -71
  122. package/src/publisher/sequencer-publisher.ts +798 -538
  123. package/src/sequencer/README.md +243 -0
  124. package/src/sequencer/automine/README.md +46 -0
  125. package/src/sequencer/automine/automine_factory.ts +145 -0
  126. package/src/sequencer/automine/automine_sequencer.ts +595 -0
  127. package/src/sequencer/chain_state_overrides.ts +162 -0
  128. package/src/sequencer/checkpoint_proposal_job.ts +1633 -0
  129. package/src/sequencer/checkpoint_proposal_job_metrics.ts +128 -0
  130. package/src/sequencer/checkpoint_voter.ts +119 -0
  131. package/src/sequencer/config.ts +2 -1
  132. package/src/sequencer/events.ts +76 -0
  133. package/src/sequencer/index.ts +5 -1
  134. package/src/sequencer/metrics.ts +330 -62
  135. package/src/sequencer/sequencer.ts +831 -748
  136. package/src/sequencer/timetable.ts +181 -81
  137. package/src/sequencer/types.ts +6 -0
  138. package/src/sequencer/utils.ts +18 -9
  139. package/src/test/index.ts +5 -6
  140. package/src/test/mock_checkpoint_builder.ts +323 -0
  141. package/src/test/utils.ts +185 -0
  142. package/dest/sequencer/block_builder.d.ts +0 -27
  143. package/dest/sequencer/block_builder.d.ts.map +0 -1
  144. package/dest/sequencer/block_builder.js +0 -130
  145. package/dest/tx_validator/nullifier_cache.d.ts +0 -14
  146. package/dest/tx_validator/nullifier_cache.d.ts.map +0 -1
  147. package/dest/tx_validator/nullifier_cache.js +0 -24
  148. package/dest/tx_validator/tx_validator_factory.d.ts +0 -17
  149. package/dest/tx_validator/tx_validator_factory.d.ts.map +0 -1
  150. package/dest/tx_validator/tx_validator_factory.js +0 -53
  151. package/src/sequencer/block_builder.ts +0 -218
  152. package/src/tx_validator/nullifier_cache.ts +0 -30
  153. package/src/tx_validator/tx_validator_factory.ts +0 -132
@@ -1,64 +1,117 @@
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
- type EmpireSlashingProposerContract,
7
- FormattedViemError,
6
+ FeeAssetPriceOracle,
8
7
  type GovernanceProposerContract,
9
- type IEmpireBase,
8
+ MULTI_CALL_3_ADDRESS,
9
+ Multicall3,
10
+ MulticallForwarderRevertedError,
11
+ type RollupContract,
12
+ type SimulationOverridesPlan,
13
+ type SlashingProposerContract,
14
+ buildSimulationOverridesStateOverride,
15
+ } from '@aztec/ethereum/contracts';
16
+ import { type L1FeeAnalysisResult, L1FeeAnalyzer } from '@aztec/ethereum/l1-fee-analysis';
17
+ import {
10
18
  type L1BlobInputs,
11
- type L1ContractsConfig,
12
19
  type L1TxConfig,
13
20
  type L1TxRequest,
14
- MULTI_CALL_3_ADDRESS,
15
- Multicall3,
16
- RollupContract,
17
- type TallySlashingProposerContract,
21
+ type L1TxUtils,
22
+ MAX_L1_TX_LIMIT,
18
23
  type TransactionStats,
19
- type ViemCommitteeAttestations,
20
- type ViemHeader,
21
- type ViemStateReference,
24
+ WEI_CONST,
25
+ } from '@aztec/ethereum/l1-tx-utils';
26
+ import {
27
+ FormattedViemError,
22
28
  formatViemError,
29
+ mergeAbis,
30
+ tryDecodeRevertReason,
23
31
  tryExtractEvent,
24
- } from '@aztec/ethereum';
25
- import type { L1TxUtilsWithBlobs } from '@aztec/ethereum/l1-tx-utils-with-blobs';
26
- import { sumBigint } from '@aztec/foundation/bigint';
27
- import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer';
32
+ } from '@aztec/ethereum/utils';
33
+ import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
34
+ import { trimmedBytesLength } from '@aztec/foundation/buffer';
35
+ import { pick } from '@aztec/foundation/collection';
36
+ import type { Fr } from '@aztec/foundation/curves/bn254';
37
+ import { TimeoutError } from '@aztec/foundation/error';
28
38
  import { EthAddress } from '@aztec/foundation/eth-address';
29
- import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature';
30
- import type { Fr } from '@aztec/foundation/fields';
39
+ import { Signature } from '@aztec/foundation/eth-signature';
31
40
  import { type Logger, createLogger } from '@aztec/foundation/log';
41
+ import { InterruptibleSleep } from '@aztec/foundation/sleep';
32
42
  import { bufferToHex } from '@aztec/foundation/string';
33
- import { DateProvider, Timer } from '@aztec/foundation/timer';
34
- import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts';
43
+ import { type DateProvider, Timer } from '@aztec/foundation/timer';
44
+ import { EmpireBaseAbi, ErrorsAbi, RollupAbi, SlashingProposerAbi } from '@aztec/l1-artifacts';
35
45
  import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher';
36
- import { CommitteeAttestation, CommitteeAttestationsAndSigners, type ValidateBlockResult } from '@aztec/stdlib/block';
37
- import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts';
46
+ import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block';
47
+ import type { Checkpoint } from '@aztec/stdlib/checkpoint';
48
+ import {
49
+ getLastL1SlotTimestampForL2Slot,
50
+ getNextL1SlotTimestamp,
51
+ getTimestampForSlot,
52
+ } from '@aztec/stdlib/epoch-helpers';
38
53
  import type { CheckpointHeader } from '@aztec/stdlib/rollup';
39
- import type { L1PublishBlockStats } from '@aztec/stdlib/stats';
40
- import { StateReference } from '@aztec/stdlib/tx';
41
- import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client';
42
-
43
- import { type TransactionReceipt, type TypedDataDefinition, encodeFunctionData, toHex } from 'viem';
54
+ import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats';
55
+ import { type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
44
56
 
45
- import type { PublisherConfig, TxSenderConfig } from './config.js';
57
+ import {
58
+ type Abi,
59
+ type Hex,
60
+ type TransactionReceipt,
61
+ type TypedDataDefinition,
62
+ encodeFunctionData,
63
+ keccak256,
64
+ toHex,
65
+ } from 'viem';
66
+
67
+ import type { SequencerPublisherConfig } from './config.js';
68
+ import { type FailedL1Tx, type L1TxFailedStore, createL1TxFailedStore } from './l1_tx_failed_store/index.js';
69
+ import { type DroppedRequest, SequencerBundleSimulator } from './sequencer-bundle-simulator.js';
46
70
  import { SequencerPublisherMetrics } from './sequencer-publisher-metrics.js';
47
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
+
87
+ /** Result of a sendRequests call, returned by both sendRequests() and sendRequestsAt(). */
88
+ export type SendRequestsResult = {
89
+ /** The L1 transaction receipt from the bundled multicall. */
90
+ result: { receipt: TransactionReceipt };
91
+ /** Actions that expired (past their deadline) before the request was sent. */
92
+ expiredActions: Action[];
93
+ /** Actions that were included in the sent L1 transaction. */
94
+ sentActions: Action[];
95
+ /** Actions whose L1 simulation succeeded (subset of sentActions). */
96
+ successfulActions: Action[];
97
+ /** Actions whose L1 simulation failed (subset of sentActions). */
98
+ failedActions: Action[];
99
+ };
100
+
48
101
  /** Arguments to the process method of the rollup contract */
49
102
  type L1ProcessArgs = {
50
103
  /** The L2 block header. */
51
104
  header: CheckpointHeader;
52
105
  /** A root of the archive tree after the L2 block is applied. */
53
106
  archive: Buffer;
54
- /** State reference after the L2 block is applied. */
55
- stateReference: StateReference;
56
107
  /** L2 block blobs containing all tx effects. */
57
108
  blobs: Blob[];
58
109
  /** Attestations */
59
110
  attestationsAndSigners: CommitteeAttestationsAndSigners;
60
111
  /** Attestations and signers signature */
61
112
  attestationsAndSignersSignature: Signature;
113
+ /** The fee asset price modifier in basis points (from oracle) */
114
+ feeAssetPriceModifier: bigint;
62
115
  };
63
116
 
64
117
  export const Actions = [
@@ -66,34 +119,39 @@ export const Actions = [
66
119
  'invalidate-by-insufficient-attestations',
67
120
  'propose',
68
121
  'governance-signal',
69
- 'empire-slashing-signal',
70
- 'create-empire-payload',
71
- 'execute-empire-payload',
72
122
  'vote-offenses',
73
123
  'execute-slash',
74
124
  ] as const;
75
125
 
76
126
  export type Action = (typeof Actions)[number];
77
127
 
78
- type GovernanceSignalAction = Extract<Action, 'governance-signal' | 'empire-slashing-signal'>;
128
+ type GovernanceSignalAction = Extract<Action, 'governance-signal'>;
79
129
 
80
130
  // Sorting for actions such that invalidations go before proposals, and proposals go before votes
81
131
  export const compareActions = (a: Action, b: Action) => Actions.indexOf(a) - Actions.indexOf(b);
82
132
 
83
- export type InvalidateBlockRequest = {
133
+ export type InvalidateCheckpointRequest = {
84
134
  request: L1TxRequest;
85
135
  reason: 'invalid-attestation' | 'insufficient-attestations';
86
136
  gasUsed: bigint;
87
- blockNumber: number;
88
- forcePendingBlockNumber: number;
137
+ checkpointNumber: CheckpointNumber;
138
+ forcePendingCheckpointNumber: CheckpointNumber;
139
+ /** Archive at the rollback target checkpoint (checkpoint N-1). */
140
+ lastArchive: Fr;
141
+ };
142
+
143
+ type EnqueueProposeCheckpointOpts = {
144
+ txTimeoutAt?: Date;
89
145
  };
90
146
 
91
- interface RequestWithExpiry {
147
+ export interface RequestWithExpiry {
92
148
  action: Action;
93
149
  request: L1TxRequest;
94
- lastValidL2Slot: bigint;
150
+ lastValidL2Slot: SlotNumber;
95
151
  gasConfig?: Pick<L1TxConfig, 'txTimeoutAt' | 'gasLimit'>;
96
152
  blobConfig?: L1BlobInputs;
153
+ /** Gas consumed by validateBlobs; stashed for the bundle simulate at send time. */
154
+ blobEvaluationGas?: bigint;
97
155
  checkSuccess: (
98
156
  request: L1TxRequest,
99
157
  result?: { receipt: TransactionReceipt; stats?: TransactionStats; errorMsg?: string },
@@ -103,64 +161,82 @@ interface RequestWithExpiry {
103
161
  export class SequencerPublisher {
104
162
  private interrupted = false;
105
163
  private metrics: SequencerPublisherMetrics;
164
+ private bundleSimulator: SequencerBundleSimulator;
106
165
  public epochCache: EpochCache;
166
+ private failedTxStore?: Promise<L1TxFailedStore | undefined>;
107
167
 
108
- protected governanceLog = createLogger('sequencer:publisher:governance');
109
- 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]);
110
174
 
111
- protected lastActions: Partial<Record<Action, bigint>> = {};
175
+ protected lastActions: Partial<Record<Action, SlotNumber>> = {};
112
176
 
113
177
  protected log: Logger;
114
178
  protected ethereumSlotDuration: bigint;
179
+ protected aztecSlotDuration: bigint;
115
180
 
116
- private blobSinkClient: BlobSinkClientInterface;
117
- // @note - with blobs, the below estimate seems too large.
118
- // Total used for full block from int_l1_pub e2e test: 1m (of which 86k is 1x blob)
119
- // Total used for emptier block from above test: 429k (of which 84k is 1x blob)
120
- public static PROPOSE_GAS_GUESS: bigint = 12_000_000n;
181
+ /** Date provider for wall-clock time. */
182
+ private readonly dateProvider: DateProvider;
121
183
 
122
- // A CALL to a cold address is 2700 gas
123
- public static MULTICALL_OVERHEAD_GAS_GUESS = 5000n;
184
+ private blobClient: BlobClientInterface;
124
185
 
125
- // Gas report for VotingWithSigTest shows a max gas of 100k, but we've seen it cost 700k+ in testnet
126
- public static VOTE_GAS_GUESS: bigint = 800_000n;
186
+ /** Optional callback to obtain a replacement publisher when the current one fails to send. */
187
+ private getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise<L1TxUtils | undefined>;
127
188
 
128
- public l1TxUtils: L1TxUtilsWithBlobs;
189
+ /** L1 fee analyzer for fisherman mode */
190
+ private l1FeeAnalyzer?: L1FeeAnalyzer;
191
+
192
+ /** Fee asset price oracle for computing price modifiers from Uniswap V4 */
193
+ private feeAssetPriceOracle: FeeAssetPriceOracle;
194
+
195
+ /** Interruptible sleep used by sendRequestsAt to wait until a target timestamp. */
196
+ private readonly interruptibleSleep = new InterruptibleSleep();
197
+
198
+ public l1TxUtils: L1TxUtils;
129
199
  public rollupContract: RollupContract;
130
200
  public govProposerContract: GovernanceProposerContract;
131
- public slashingProposerContract: EmpireSlashingProposerContract | TallySlashingProposerContract | undefined;
132
- public slashFactoryContract: SlashFactoryContract;
201
+ public slashingProposerContract: SlashingProposerContract | undefined;
202
+
203
+ public readonly tracer: Tracer;
133
204
 
134
205
  protected requests: RequestWithExpiry[] = [];
135
206
 
136
207
  constructor(
137
- private config: TxSenderConfig & PublisherConfig & Pick<L1ContractsConfig, 'ethereumSlotDuration'>,
208
+ private config: Pick<SequencerPublisherConfig, 'fishermanMode' | 'l1TxFailedStore'> &
209
+ Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'> & { l1ChainId: number },
138
210
  deps: {
139
211
  telemetry?: TelemetryClient;
140
- blobSinkClient?: BlobSinkClientInterface;
141
- l1TxUtils: L1TxUtilsWithBlobs;
212
+ blobClient: BlobClientInterface;
213
+ l1TxUtils: L1TxUtils;
142
214
  rollupContract: RollupContract;
143
- slashingProposerContract: EmpireSlashingProposerContract | TallySlashingProposerContract | undefined;
215
+ slashingProposerContract: SlashingProposerContract | undefined;
144
216
  governanceProposerContract: GovernanceProposerContract;
145
- slashFactoryContract: SlashFactoryContract;
146
217
  epochCache: EpochCache;
147
218
  dateProvider: DateProvider;
148
219
  metrics: SequencerPublisherMetrics;
149
- lastActions: Partial<Record<Action, bigint>>;
220
+ lastActions: Partial<Record<Action, SlotNumber>>;
150
221
  log?: Logger;
222
+ getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise<L1TxUtils | undefined>;
151
223
  },
152
224
  ) {
153
225
  this.log = deps.log ?? createLogger('sequencer:publisher');
154
226
  this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration);
227
+ this.aztecSlotDuration = BigInt(config.aztecSlotDuration);
228
+ this.dateProvider = deps.dateProvider;
155
229
  this.epochCache = deps.epochCache;
156
230
  this.lastActions = deps.lastActions;
157
231
 
158
- this.blobSinkClient =
159
- deps.blobSinkClient ?? createBlobSinkClient(config, { logger: createLogger('sequencer:blob-sink:client') });
232
+ this.blobClient = deps.blobClient;
233
+ this.dateProvider = deps.dateProvider;
160
234
 
161
235
  const telemetry = deps.telemetry ?? getTelemetryClient();
162
236
  this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher');
237
+ this.tracer = telemetry.getTracer('SequencerPublisher');
163
238
  this.l1TxUtils = deps.l1TxUtils;
239
+ this.getNextPublisher = deps.getNextPublisher;
164
240
 
165
241
  this.rollupContract = deps.rollupContract;
166
242
 
@@ -172,42 +248,179 @@ export class SequencerPublisher {
172
248
  const newSlashingProposer = await this.rollupContract.getSlashingProposer();
173
249
  this.slashingProposerContract = newSlashingProposer;
174
250
  });
175
- this.slashFactoryContract = deps.slashFactoryContract;
251
+ // Initialize L1 fee analyzer for fisherman mode
252
+ if (config.fishermanMode) {
253
+ this.l1FeeAnalyzer = new L1FeeAnalyzer(
254
+ this.l1TxUtils.client,
255
+ deps.dateProvider,
256
+ this.log.createChild('fee-analyzer'),
257
+ );
258
+ }
259
+
260
+ // Initialize fee asset price oracle
261
+ this.feeAssetPriceOracle = new FeeAssetPriceOracle(
262
+ this.l1TxUtils.client,
263
+ this.rollupContract,
264
+ this.log.createChild('price-oracle'),
265
+ );
266
+
267
+ // Initialize failed L1 tx store (optional, for test networks)
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
+ });
276
+ }
277
+
278
+ /**
279
+ * Backs up a failed L1 transaction to the configured store for debugging.
280
+ * Does nothing if no store is configured.
281
+ */
282
+ private backupFailedTx(failedTx: Omit<FailedL1Tx, 'timestamp'>): void {
283
+ if (!this.failedTxStore) {
284
+ return;
285
+ }
286
+
287
+ const tx: FailedL1Tx = {
288
+ ...failedTx,
289
+ timestamp: Date.now(),
290
+ };
291
+
292
+ // Fire and forget - don't block on backup
293
+ void this.failedTxStore
294
+ .then(store => store?.saveFailedTx(tx))
295
+ .catch(err => {
296
+ this.log.warn(`Failed to backup failed L1 tx to store`, err);
297
+ });
176
298
  }
177
299
 
178
300
  public getRollupContract(): RollupContract {
179
301
  return this.rollupContract;
180
302
  }
181
303
 
304
+ /**
305
+ * Gets the fee asset price modifier from the oracle.
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.
311
+ */
312
+ public getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12?: bigint): Promise<bigint> {
313
+ return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12);
314
+ }
315
+
182
316
  public getSenderAddress() {
183
317
  return this.l1TxUtils.getSenderAddress();
184
318
  }
185
319
 
320
+ /**
321
+ * Gets the L1 fee analyzer instance (only available in fisherman mode)
322
+ */
323
+ public getL1FeeAnalyzer(): L1FeeAnalyzer | undefined {
324
+ return this.l1FeeAnalyzer;
325
+ }
326
+
186
327
  public addRequest(request: RequestWithExpiry) {
187
328
  this.requests.push(request);
188
329
  }
189
330
 
190
- public getCurrentL2Slot(): bigint {
191
- return this.epochCache.getEpochAndSlotNow().slot;
331
+ public getCurrentL2Slot(): SlotNumber {
332
+ return this.epochCache.getSlotNow();
333
+ }
334
+
335
+ /**
336
+ * Clears all pending requests without sending them.
337
+ */
338
+ public clearPendingRequests(): void {
339
+ const count = this.requests.length;
340
+ this.requests = [];
341
+ if (count > 0) {
342
+ this.log.debug(`Cleared ${count} pending request(s)`);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Analyzes L1 fees for the pending requests without sending them.
348
+ * This is used in fisherman mode to validate fee calculations.
349
+ * @param l2SlotNumber - The L2 slot number for this analysis
350
+ * @param onComplete - Optional callback to invoke when analysis completes (after block is mined)
351
+ * @returns The analysis result (incomplete until block mines), or undefined if no requests
352
+ */
353
+ public async analyzeL1Fees(
354
+ l2SlotNumber: SlotNumber,
355
+ onComplete?: (analysis: L1FeeAnalysisResult) => void,
356
+ ): Promise<L1FeeAnalysisResult | undefined> {
357
+ if (!this.l1FeeAnalyzer) {
358
+ this.log.warn('L1 fee analyzer not available (not in fisherman mode)');
359
+ return undefined;
360
+ }
361
+
362
+ const requestsToAnalyze = [...this.requests];
363
+ if (requestsToAnalyze.length === 0) {
364
+ this.log.debug('No requests to analyze for L1 fees');
365
+ return undefined;
366
+ }
367
+
368
+ // Extract blob config from requests (if any)
369
+ const blobConfigs = requestsToAnalyze.filter(request => request.blobConfig).map(request => request.blobConfig);
370
+ const blobConfig = blobConfigs[0];
371
+
372
+ // Get gas configs
373
+ const gasConfigs = requestsToAnalyze.filter(request => request.gasConfig).map(request => request.gasConfig);
374
+ const gasLimits = gasConfigs.map(g => g?.gasLimit).filter((g): g is bigint => g !== undefined);
375
+ const gasLimit = gasLimits.length > 0 ? gasLimits.reduce((sum, g) => sum + g, 0n) : 0n;
376
+
377
+ // Get the transaction requests
378
+ const l1Requests = requestsToAnalyze.map(r => r.request);
379
+
380
+ // Start the analysis
381
+ const analysisId = await this.l1FeeAnalyzer.startAnalysis(
382
+ l2SlotNumber,
383
+ gasLimit > 0n ? gasLimit : MAX_L1_TX_LIMIT,
384
+ l1Requests,
385
+ blobConfig,
386
+ onComplete,
387
+ );
388
+
389
+ this.log.info('Started L1 fee analysis', {
390
+ analysisId,
391
+ l2SlotNumber: l2SlotNumber.toString(),
392
+ requestCount: requestsToAnalyze.length,
393
+ hasBlobConfig: !!blobConfig,
394
+ gasLimit: gasLimit.toString(),
395
+ actions: requestsToAnalyze.map(r => r.action),
396
+ });
397
+
398
+ // Return the analysis result (will be incomplete until block mines)
399
+ return this.l1FeeAnalyzer.getAnalysis(analysisId);
192
400
  }
193
401
 
194
402
  /**
195
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.
196
408
  * @returns one of:
197
409
  * - A receipt and stats if the tx succeeded
198
410
  * - a receipt and errorMsg if it failed on L1
199
411
  * - undefined if no valid requests are found OR the tx failed to send.
200
412
  */
201
- public async sendRequests() {
413
+ @trackSpan('SequencerPublisher.sendRequests')
414
+ public async sendRequests(targetSlot?: SlotNumber): Promise<SendRequestsResult | undefined> {
202
415
  const requestsToProcess = [...this.requests];
203
416
  this.requests = [];
204
- if (this.interrupted) {
417
+
418
+ if (this.interrupted || requestsToProcess.length === 0) {
205
419
  return undefined;
206
420
  }
207
- const currentL2Slot = this.getCurrentL2Slot();
421
+ const currentL2Slot = targetSlot ?? this.getCurrentL2Slot();
208
422
  this.log.debug(`Sending requests on L2 slot ${currentL2Slot}`);
209
423
  const validRequests = requestsToProcess.filter(request => request.lastValidL2Slot >= currentL2Slot);
210
- const validActions = validRequests.map(x => x.action);
211
424
  const expiredActions = requestsToProcess
212
425
  .filter(request => request.lastValidL2Slot < currentL2Slot)
213
426
  .map(x => x.action);
@@ -230,44 +443,58 @@ export class SequencerPublisher {
230
443
  return undefined;
231
444
  }
232
445
 
233
- // @note - we can only have one blob config per bundle
234
- // find requests with gas and blob configs
235
- // See https://github.com/AztecProtocol/aztec-packages/issues/11513
236
- const gasConfigs = requestsToProcess.filter(request => request.gasConfig).map(request => request.gasConfig);
237
- const blobConfigs = requestsToProcess.filter(request => request.blobConfig).map(request => request.blobConfig);
238
-
239
- if (blobConfigs.length > 1) {
240
- throw new Error('Multiple blob configs found');
241
- }
242
-
243
- const blobConfig = blobConfigs[0];
244
-
245
- // Merge gasConfigs. Yields the sum of gasLimits, and the earliest txTimeoutAt, or undefined if no gasConfig sets them.
246
- const gasLimits = gasConfigs.map(g => g?.gasLimit).filter((g): g is bigint => g !== undefined);
247
- const gasLimit = gasLimits.length > 0 ? sumBigint(gasLimits) : undefined; // sum
446
+ // Collect earliest txTimeoutAt across all requests.
447
+ const gasConfigs = validRequests.filter(request => request.gasConfig).map(request => request.gasConfig);
248
448
  const txTimeoutAts = gasConfigs.map(g => g?.txTimeoutAt).filter((g): g is Date => g !== undefined);
249
- const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map(g => g.getTime()))) : undefined; // earliest
250
- const txConfig: RequestWithExpiry['gasConfig'] = { gasLimit, txTimeoutAt };
449
+ const txTimeoutAt = txTimeoutAts.length > 0 ? new Date(Math.min(...txTimeoutAts.map(g => g.getTime()))) : undefined;
251
450
 
252
451
  // Sort the requests so that proposals always go first
253
452
  // This ensures the committee gets precomputed correctly
254
453
  validRequests.sort((a, b) => compareActions(a.action, b.action));
255
454
 
256
455
  try {
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;
473
+
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 };
480
+
257
481
  this.log.debug('Forwarding transactions', {
258
- validRequests: validRequests.map(request => request.action),
482
+ requests: requests.map(request => request.action),
259
483
  txConfig,
260
484
  });
261
- const result = await Multicall3.forward(
262
- validRequests.map(request => request.request),
263
- this.l1TxUtils,
264
- txConfig,
265
- blobConfig,
266
- this.rollupContract.address,
267
- this.log,
268
- );
269
- const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(validRequests, result);
270
- return { result, expiredActions, sentActions: validActions, successfulActions, failedActions };
485
+ const result = await this.forwardWithPublisherRotation(requests, txConfig, blobConfig);
486
+ if (result === undefined) {
487
+ return undefined;
488
+ }
489
+ const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions(requests, result);
490
+ const allFailedActions = [...failedActions, ...droppedRequests.map(d => d.request.action)];
491
+ return {
492
+ result,
493
+ expiredActions,
494
+ sentActions: requests.map(x => x.action),
495
+ successfulActions,
496
+ failedActions: allFailedActions,
497
+ };
271
498
  } catch (err) {
272
499
  const viemError = formatViemError(err);
273
500
  this.log.error(`Failed to publish bundled transactions`, viemError);
@@ -284,47 +511,195 @@ export class SequencerPublisher {
284
511
  }
285
512
  }
286
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
+
548
+ /**
549
+ * Forwards transactions via Multicall3, rotating to the next available publisher if a send
550
+ * failure occurs (i.e. the tx never reached the chain).
551
+ * On-chain reverts and simulation errors are returned as-is without rotation.
552
+ */
553
+ private async forwardWithPublisherRotation(
554
+ validRequests: RequestWithExpiry[],
555
+ txConfig: RequestWithExpiry['gasConfig'],
556
+ blobConfig: L1BlobInputs | undefined,
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
+
563
+ const triedAddresses: EthAddress[] = [];
564
+ let currentPublisher = this.l1TxUtils;
565
+
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
+ }
573
+ triedAddresses.push(currentPublisher.getSenderAddress());
574
+
575
+ try {
576
+ const result = await Multicall3.forward(
577
+ validRequests.map(r => r.request),
578
+ currentPublisher,
579
+ txConfigWithGasLimit,
580
+ blobConfig,
581
+ { gasLimitRequired: true },
582
+ );
583
+ this.l1TxUtils = currentPublisher;
584
+ return result;
585
+ } catch (err) {
586
+ if (err instanceof TimeoutError) {
587
+ throw err;
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
+ }
595
+ const viemError = formatViemError(err);
596
+ if (!this.getNextPublisher) {
597
+ this.log.error('Failed to publish bundled transactions', viemError);
598
+ return undefined;
599
+ }
600
+ this.log.warn(
601
+ `Publisher ${currentPublisher.getSenderAddress()} failed to send, rotating to next publisher`,
602
+ viemError,
603
+ );
604
+ const nextPublisher = await this.getNextPublisher([...triedAddresses]);
605
+ if (!nextPublisher) {
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
+ );
611
+ return undefined;
612
+ }
613
+ currentPublisher = nextPublisher;
614
+ }
615
+ }
616
+ }
617
+
618
+ /*
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.
624
+ * Uses InterruptibleSleep so it can be cancelled via interrupt().
625
+ */
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;
633
+ if (this.interrupted) {
634
+ return undefined;
635
+ }
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);
643
+ }
644
+ if (this.interrupted) {
645
+ return undefined;
646
+ }
647
+ return this.sendRequests(targetSlot);
648
+ }
649
+
287
650
  private callbackBundledTransactions(
288
651
  requests: RequestWithExpiry[],
289
- result?: { receipt: TransactionReceipt } | FormattedViemError,
652
+ result: { receipt: TransactionReceipt; multicallData: Hex },
290
653
  ) {
291
654
  const actionsListStr = requests.map(r => r.action).join(', ');
292
- if (result instanceof FormattedViemError) {
293
- this.log.error(`Failed to publish bundled transactions (${actionsListStr})`, result);
294
- return { failedActions: requests.map(r => r.action) };
295
- } else {
296
- this.log.verbose(`Published bundled transactions (${actionsListStr})`, { result, requests });
297
- const successfulActions: Action[] = [];
298
- const failedActions: Action[] = [];
299
- for (const request of requests) {
300
- if (request.checkSuccess(request.request, result)) {
301
- successfulActions.push(request.action);
302
- } else {
303
- failedActions.push(request.action);
304
- }
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);
305
672
  }
306
- return { successfulActions, failedActions };
307
673
  }
674
+ return { successfulActions, failedActions };
308
675
  }
309
676
 
310
677
  /**
311
- * @notice Will call `canProposeAtNextEthBlock` to make sure that it is possible to propose
678
+ * @notice Will call `canProposeAt` to make sure that it is possible to propose
312
679
  * @param tipArchive - The archive to check
313
680
  * @returns The slot and block number if it is possible to propose, undefined otherwise
314
681
  */
315
- public canProposeAtNextEthBlock(
316
- tipArchive: Fr,
317
- msgSender: EthAddress,
318
- opts: { forcePendingBlockNumber?: number } = {},
319
- ) {
682
+ public async canProposeAt(tipArchive: Fr, msgSender: EthAddress, simulationOverridesPlan?: SimulationOverridesPlan) {
320
683
  // TODO: #14291 - should loop through multiple keys to check if any of them can propose
321
- 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'];
689
+
690
+ const slotOffset = this.aztecSlotDuration;
691
+ const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset;
322
692
 
323
693
  return this.rollupContract
324
- .canProposeAtNextEthBlock(tipArchive.toBuffer(), msgSender.toString(), this.ethereumSlotDuration, opts)
694
+ .canProposeAt(
695
+ tipArchive.toBuffer(),
696
+ msgSender.toString(),
697
+ nextL1SlotTs,
698
+ await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan),
699
+ )
325
700
  .catch(err => {
326
- if (err instanceof FormattedViemError && ignoredErrors.find(e => err.message.includes(e))) {
327
- 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))}`, {
328
703
  error: err.message,
329
704
  });
330
705
  } else {
@@ -333,132 +708,176 @@ export class SequencerPublisher {
333
708
  return undefined;
334
709
  });
335
710
  }
711
+
336
712
  /**
337
713
  * @notice Will simulate `validateHeader` to make sure that the block header is valid
338
714
  * @dev This is a convenience function that can be used by the sequencer to validate a "partial" header.
339
715
  * It will throw if the block header is invalid.
340
716
  * @param header - The block header to validate
341
717
  */
342
- public async validateBlockHeader(header: CheckpointHeader, opts?: { forcePendingBlockNumber: number | undefined }) {
718
+ @trackSpan('SequencerPublisher.validateBlockHeader')
719
+ public async validateBlockHeader(
720
+ header: CheckpointHeader,
721
+ simulationOverridesPlan?: SimulationOverridesPlan,
722
+ ): Promise<void> {
343
723
  const flags = { ignoreDA: true, ignoreSignatures: true };
344
724
 
345
725
  const args = [
346
726
  header.toViem(),
347
- CommitteeAttestationsAndSigners.empty().getPackedAttestations(),
727
+ CommitteeAttestationsAndSigners.packAttestations([]),
348
728
  [], // no signers
349
729
  Signature.empty().toViemSignature(),
350
730
  `0x${'0'.repeat(64)}`, // 32 empty bytes
351
- header.contentCommitment.blobsHash.toString(),
731
+ header.blobsHash.toString(),
352
732
  flags,
353
733
  ] as const;
354
734
 
355
- const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration);
735
+ const l1Constants = this.epochCache.getL1Constants();
736
+ const ts = getLastL1SlotTimestampForL2Slot(header.slotNumber, l1Constants);
737
+ const stateOverrides = await buildSimulationOverridesStateOverride(this.rollupContract, simulationOverridesPlan);
738
+ let balance = 0n;
739
+ if (this.config.fishermanMode) {
740
+ // In fisherman mode, we can't know where the proposer is publishing from
741
+ // so we just add sufficient balance to the multicall3 address
742
+ balance = 10n * WEI_CONST * WEI_CONST; // 10 ETH
743
+ } else {
744
+ balance = await this.l1TxUtils.getSenderBalance();
745
+ }
746
+ stateOverrides.push({
747
+ address: MULTI_CALL_3_ADDRESS,
748
+ balance,
749
+ });
356
750
 
357
- // use sender balance to simulate
358
- const balance = await this.l1TxUtils.getSenderBalance();
359
- this.log.debug(`Simulating validateHeader with balance: ${balance}`);
360
751
  await this.l1TxUtils.simulate(
361
752
  {
362
753
  to: this.rollupContract.address,
363
754
  data: encodeFunctionData({ abi: RollupAbi, functionName: 'validateHeaderWithAttestations', args }),
364
755
  from: MULTI_CALL_3_ADDRESS,
365
756
  },
366
- { time: ts + 1n },
367
- [
368
- { address: MULTI_CALL_3_ADDRESS, balance },
369
- ...(await this.rollupContract.makePendingBlockNumberOverride(opts?.forcePendingBlockNumber)),
370
- ],
757
+ { time: ts },
758
+ stateOverrides,
371
759
  );
372
760
  this.log.debug(`Simulated validateHeader`);
373
761
  }
374
762
 
375
763
  /**
376
- * Simulate making a call to invalidate a block with invalid attestations. Returns undefined if no need to invalidate.
377
- * @param block - The block to invalidate and the criteria for invalidation (as returned by the archiver)
764
+ * Simulate making a call to invalidate a checkpoint with invalid attestations. Returns undefined if no need to invalidate.
765
+ * @param validationResult - The validation result indicating which checkpoint to invalidate (as returned by the archiver)
378
766
  */
379
- public async simulateInvalidateBlock(
380
- validationResult: ValidateBlockResult,
381
- ): Promise<InvalidateBlockRequest | undefined> {
767
+ public async simulateInvalidateCheckpoint(
768
+ validationResult: ValidateCheckpointResult,
769
+ ): Promise<InvalidateCheckpointRequest | undefined> {
382
770
  if (validationResult.valid) {
383
771
  return undefined;
384
772
  }
385
773
 
386
- const { reason, block } = validationResult;
387
- const blockNumber = block.blockNumber;
388
- const logData = { ...block, reason };
774
+ const { reason, checkpoint } = validationResult;
775
+ const checkpointNumber = checkpoint.checkpointNumber;
776
+ const logData = { ...checkpoint, reason };
389
777
 
390
- const currentBlockNumber = await this.rollupContract.getBlockNumber();
391
- if (currentBlockNumber < validationResult.block.blockNumber) {
778
+ const currentCheckpointNumber = await this.rollupContract.getCheckpointNumber();
779
+ if (currentCheckpointNumber < checkpointNumber) {
392
780
  this.log.verbose(
393
- `Skipping block ${blockNumber} invalidation since it has already been removed from the pending chain`,
394
- { currentBlockNumber, ...logData },
781
+ `Skipping checkpoint ${checkpointNumber} invalidation since it has already been removed from the pending chain`,
782
+ { currentCheckpointNumber, ...logData },
395
783
  );
396
784
  return undefined;
397
785
  }
398
786
 
399
- const request = this.buildInvalidateBlockRequest(validationResult);
400
- this.log.debug(`Simulating invalidate block ${blockNumber}`, { ...logData, request });
787
+ const request = this.buildInvalidateCheckpointRequest(validationResult);
788
+ this.log.debug(`Simulating invalidate checkpoint ${checkpointNumber}`, { ...logData, request });
789
+
790
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
401
791
 
402
792
  try {
403
- const { gasUsed } = await this.l1TxUtils.simulate(request, undefined, undefined, ErrorsAbi);
404
- this.log.verbose(`Simulation for invalidate block ${blockNumber} succeeded`, { ...logData, request, gasUsed });
793
+ const { gasUsed } = await this.l1TxUtils.simulate(
794
+ request,
795
+ undefined,
796
+ undefined,
797
+ mergeAbis([request.abi ?? [], ErrorsAbi]),
798
+ );
799
+ this.log.verbose(`Simulation for invalidate checkpoint ${checkpointNumber} succeeded`, {
800
+ ...logData,
801
+ request,
802
+ gasUsed,
803
+ });
405
804
 
406
- return { request, gasUsed, blockNumber, forcePendingBlockNumber: blockNumber - 1, reason };
805
+ return {
806
+ request,
807
+ gasUsed,
808
+ checkpointNumber,
809
+ forcePendingCheckpointNumber: CheckpointNumber(checkpointNumber - 1),
810
+ lastArchive: validationResult.checkpoint.lastArchive,
811
+ reason,
812
+ };
407
813
  } catch (err) {
408
814
  const viemError = formatViemError(err);
409
815
 
410
- // If the error is due to the block not being in the pending chain, and it was indeed removed by someone else,
411
- // we can safely ignore it and return undefined so we go ahead with block building.
412
- if (viemError.message?.includes('Rollup__BlockNotInPendingChain')) {
816
+ // If the error is due to the checkpoint not being in the pending chain, and it was indeed removed by someone else,
817
+ // we can safely ignore it and return undefined so we go ahead with checkpoint building.
818
+ if (viemError.message?.includes('Rollup__CheckpointNotInPendingChain')) {
413
819
  this.log.verbose(
414
- `Simulation for invalidate block ${blockNumber} failed due to block not being in pending chain`,
820
+ `Simulation for invalidate checkpoint ${checkpointNumber} failed due to checkpoint not being in pending chain`,
415
821
  { ...logData, request, error: viemError.message },
416
822
  );
417
- const latestPendingBlockNumber = await this.rollupContract.getBlockNumber();
418
- if (latestPendingBlockNumber < blockNumber) {
419
- this.log.verbose(`Block number ${blockNumber} has already been invalidated`, { ...logData });
823
+ const latestProposedCheckpointNumber = await this.rollupContract.getCheckpointNumber();
824
+ if (latestProposedCheckpointNumber < checkpointNumber) {
825
+ this.log.verbose(`Checkpoint ${checkpointNumber} has already been invalidated`, { ...logData });
420
826
  return undefined;
421
827
  } else {
422
828
  this.log.error(
423
- `Simulation for invalidate ${blockNumber} failed and it is still in pending chain`,
829
+ `Simulation for invalidate checkpoint ${checkpointNumber} failed and it is still in pending chain`,
424
830
  viemError,
425
831
  logData,
426
832
  );
427
- throw new Error(`Failed to simulate invalidate block ${blockNumber} while it is still in pending chain`, {
428
- cause: viemError,
429
- });
833
+ throw new Error(
834
+ `Failed to simulate invalidate checkpoint ${checkpointNumber} while it is still in pending chain`,
835
+ {
836
+ cause: viemError,
837
+ },
838
+ );
430
839
  }
431
840
  }
432
841
 
433
- // Otherwise, throw. We cannot build the next block if we cannot invalidate the previous one.
434
- this.log.error(`Simulation for invalidate block ${blockNumber} failed`, viemError, logData);
435
- throw new Error(`Failed to simulate invalidate block ${blockNumber}`, { cause: viemError });
842
+ // Otherwise, throw. We cannot build the next checkpoint if we cannot invalidate the previous one.
843
+ this.log.error(`Simulation for invalidate checkpoint ${checkpointNumber} failed`, viemError, logData);
844
+ this.backupFailedTx({
845
+ id: keccak256(request.data!),
846
+ failureType: 'simulation',
847
+ request: { to: request.to!, data: request.data!, value: request.value?.toString() },
848
+ l1BlockNumber: l1BlockNumber.toString(),
849
+ error: { message: viemError.message, name: viemError.name },
850
+ context: {
851
+ actions: [`invalidate-${reason}`],
852
+ checkpointNumber,
853
+ sender: this.getSenderAddress().toString(),
854
+ },
855
+ });
856
+ throw new Error(`Failed to simulate invalidate checkpoint ${checkpointNumber}`, { cause: viemError });
436
857
  }
437
858
  }
438
859
 
439
- private buildInvalidateBlockRequest(validationResult: ValidateBlockResult) {
860
+ private buildInvalidateCheckpointRequest(validationResult: ValidateCheckpointResult) {
440
861
  if (validationResult.valid) {
441
- throw new Error('Cannot invalidate a valid block');
862
+ throw new Error('Cannot invalidate a valid checkpoint');
442
863
  }
443
864
 
444
- const { block, committee, reason } = validationResult;
445
- const logData = { ...block, reason };
446
- this.log.debug(`Simulating invalidate block ${block.blockNumber}`, logData);
865
+ const { checkpoint, committee, reason } = validationResult;
866
+ const logData = { ...checkpoint, reason };
867
+ this.log.debug(`Building invalidate checkpoint ${checkpoint.checkpointNumber} request`, logData);
447
868
 
448
- const attestationsAndSigners = new CommitteeAttestationsAndSigners(
449
- validationResult.attestations,
450
- ).getPackedAttestations();
869
+ const attestationsAndSigners = CommitteeAttestationsAndSigners.packAttestations(validationResult.attestations);
451
870
 
452
871
  if (reason === 'invalid-attestation') {
453
872
  return this.rollupContract.buildInvalidateBadAttestationRequest(
454
- block.blockNumber,
873
+ checkpoint.checkpointNumber,
455
874
  attestationsAndSigners,
456
875
  committee,
457
876
  validationResult.invalidIndex,
458
877
  );
459
878
  } else if (reason === 'insufficient-attestations') {
460
879
  return this.rollupContract.buildInvalidateInsufficientAttestationsRequest(
461
- block.blockNumber,
880
+ checkpoint.checkpointNumber,
462
881
  attestationsAndSigners,
463
882
  committee,
464
883
  );
@@ -468,66 +887,11 @@ export class SequencerPublisher {
468
887
  }
469
888
  }
470
889
 
471
- /**
472
- * @notice Will simulate `propose` to make sure that the block is valid for submission
473
- *
474
- * @dev Throws if unable to propose
475
- *
476
- * @param block - The block to propose
477
- * @param attestationData - The block's attestation data
478
- *
479
- */
480
- public async validateBlockForSubmission(
481
- block: L2Block,
482
- attestationsAndSigners: CommitteeAttestationsAndSigners,
483
- attestationsAndSignersSignature: Signature,
484
- options: { forcePendingBlockNumber?: number },
485
- ): Promise<bigint> {
486
- const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration);
487
-
488
- // If we have no attestations, we still need to provide the empty attestations
489
- // so that the committee is recalculated correctly
490
- const ignoreSignatures = attestationsAndSigners.attestations.length === 0;
491
- if (ignoreSignatures) {
492
- const { committee } = await this.epochCache.getCommittee(block.header.globalVariables.slotNumber.toBigInt());
493
- if (!committee) {
494
- this.log.warn(`No committee found for slot ${block.header.globalVariables.slotNumber.toBigInt()}`);
495
- throw new Error(`No committee found for slot ${block.header.globalVariables.slotNumber.toBigInt()}`);
496
- }
497
- attestationsAndSigners.attestations = committee.map(committeeMember =>
498
- CommitteeAttestation.fromAddress(committeeMember),
499
- );
500
- }
501
-
502
- const blobFields = block.getCheckpointBlobFields();
503
- const blobs = getBlobsPerL1Block(blobFields);
504
- const blobInput = getPrefixedEthBlobCommitments(blobs);
505
-
506
- const args = [
507
- {
508
- header: block.getCheckpointHeader().toViem(),
509
- archive: toHex(block.archive.root.toBuffer()),
510
- stateReference: block.header.state.toViem(),
511
- oracleInput: {
512
- feeAssetPriceModifier: 0n,
513
- },
514
- },
515
- attestationsAndSigners.getPackedAttestations(),
516
- attestationsAndSigners.getSigners().map(signer => signer.toString()),
517
- attestationsAndSignersSignature.toViemSignature(),
518
- blobInput,
519
- ] as const;
520
-
521
- await this.simulateProposeTx(args, ts, options);
522
- return ts;
523
- }
524
-
525
890
  private async enqueueCastSignalHelper(
526
- slotNumber: bigint,
527
- timestamp: bigint,
891
+ slotNumber: SlotNumber,
528
892
  signalType: GovernanceSignalAction,
529
893
  payload: EthAddress,
530
- base: IEmpireBase,
894
+ base: GovernanceProposerContract,
531
895
  signerAddress: EthAddress,
532
896
  signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>,
533
897
  ): Promise<boolean> {
@@ -545,10 +909,39 @@ export class SequencerPublisher {
545
909
  const round = await base.computeRound(slotNumber);
546
910
  const roundInfo = await base.getRoundInfo(this.rollupContract.address, round);
547
911
 
912
+ if (roundInfo.quorumReached) {
913
+ return false;
914
+ }
915
+
548
916
  if (roundInfo.lastSignalSlot >= slotNumber) {
549
917
  return false;
550
918
  }
551
919
 
920
+ if (await base.isPayloadEmpty(payload)) {
921
+ this.log.warn(`Skipping vote cast for payload with empty code`);
922
+ return false;
923
+ }
924
+
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);
938
+ }
939
+
940
+ if (proposed) {
941
+ this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`);
942
+ return false;
943
+ }
944
+
552
945
  const cachedLastVote = this.lastActions[signalType];
553
946
  this.lastActions[signalType] = slotNumber;
554
947
  const action = signalType;
@@ -567,38 +960,31 @@ export class SequencerPublisher {
567
960
  lastValidL2Slot: slotNumber,
568
961
  });
569
962
 
570
- try {
571
- await this.l1TxUtils.simulate(request, { time: timestamp }, [], ErrorsAbi);
572
- this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, { request });
573
- } catch (err) {
574
- this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, err);
575
- // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself
576
- }
577
-
578
963
  // TODO(palla/slash): All votes (governance and slashing) should txTimeoutAt at the end of the slot.
579
964
  this.addRequest({
580
- gasConfig: { gasLimit: SequencerPublisher.VOTE_GAS_GUESS },
581
965
  action,
582
966
  request,
583
967
  lastValidL2Slot: slotNumber,
584
968
  checkSuccess: (_request, result) => {
585
969
  const success =
586
970
  result &&
587
- result.receipt &&
588
- result.receipt.status === 'success' &&
589
- 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
+ });
590
976
 
591
977
  const logData = { ...result, slotNumber, round, payload: payload.toString() };
592
978
  if (!success) {
593
979
  this.log.error(
594
- `Signaling in [${action}] for ${payload} at slot ${slotNumber} in round ${round} failed`,
980
+ `Signaling in ${action} for ${payload} at slot ${slotNumber} in round ${round} failed`,
595
981
  logData,
596
982
  );
597
983
  this.lastActions[signalType] = cachedLastVote;
598
984
  return false;
599
985
  } else {
600
986
  this.log.info(
601
- `Signaling in [${action}] for ${payload} at slot ${slotNumber} in round ${round} succeeded`,
987
+ `Signaling in ${action} for ${payload} at slot ${slotNumber} in round ${round} succeeded`,
602
988
  logData,
603
989
  );
604
990
  return true;
@@ -611,19 +997,16 @@ export class SequencerPublisher {
611
997
  /**
612
998
  * Enqueues a governance castSignal transaction to cast a signal for a given slot number.
613
999
  * @param slotNumber - The slot number to cast a signal for.
614
- * @param timestamp - The timestamp of the slot to cast a signal for.
615
1000
  * @returns True if the signal was successfully enqueued, false otherwise.
616
1001
  */
617
1002
  public enqueueGovernanceCastSignal(
618
1003
  governancePayload: EthAddress,
619
- slotNumber: bigint,
620
- timestamp: bigint,
1004
+ slotNumber: SlotNumber,
621
1005
  signerAddress: EthAddress,
622
1006
  signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>,
623
1007
  ): Promise<boolean> {
624
1008
  return this.enqueueCastSignalHelper(
625
1009
  slotNumber,
626
- timestamp,
627
1010
  'governance-signal',
628
1011
  governancePayload,
629
1012
  this.govProposerContract,
@@ -635,8 +1018,7 @@ export class SequencerPublisher {
635
1018
  /** Enqueues all slashing actions as returned by the slasher client. */
636
1019
  public async enqueueSlashingActions(
637
1020
  actions: ProposerSlashAction[],
638
- slotNumber: bigint,
639
- timestamp: bigint,
1021
+ slotNumber: SlotNumber,
640
1022
  signerAddress: EthAddress,
641
1023
  signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>,
642
1024
  ): Promise<boolean> {
@@ -647,58 +1029,6 @@ export class SequencerPublisher {
647
1029
 
648
1030
  for (const action of actions) {
649
1031
  switch (action.type) {
650
- case 'vote-empire-payload': {
651
- if (this.slashingProposerContract?.type !== 'empire') {
652
- this.log.error('Cannot vote for empire payload on non-empire slashing contract');
653
- break;
654
- }
655
- this.log.debug(`Enqueuing slashing vote for payload ${action.payload} at slot ${slotNumber}`, {
656
- signerAddress,
657
- });
658
- await this.enqueueCastSignalHelper(
659
- slotNumber,
660
- timestamp,
661
- 'empire-slashing-signal',
662
- action.payload,
663
- this.slashingProposerContract,
664
- signerAddress,
665
- signer,
666
- );
667
- break;
668
- }
669
-
670
- case 'create-empire-payload': {
671
- this.log.debug(`Enqueuing slashing create payload at slot ${slotNumber}`, { slotNumber, signerAddress });
672
- const request = this.slashFactoryContract.buildCreatePayloadRequest(action.data);
673
- await this.simulateAndEnqueueRequest(
674
- 'create-empire-payload',
675
- request,
676
- (receipt: TransactionReceipt) =>
677
- !!this.slashFactoryContract.tryExtractSlashPayloadCreatedEvent(receipt.logs),
678
- slotNumber,
679
- timestamp,
680
- );
681
- break;
682
- }
683
-
684
- case 'execute-empire-payload': {
685
- this.log.debug(`Enqueuing slashing execute payload at slot ${slotNumber}`, { slotNumber, signerAddress });
686
- if (this.slashingProposerContract?.type !== 'empire') {
687
- this.log.error('Cannot execute slashing payload on non-empire slashing contract');
688
- return false;
689
- }
690
- const empireSlashingProposer = this.slashingProposerContract as EmpireSlashingProposerContract;
691
- const request = empireSlashingProposer.buildExecuteRoundRequest(action.round);
692
- await this.simulateAndEnqueueRequest(
693
- 'execute-empire-payload',
694
- request,
695
- (receipt: TransactionReceipt) => !!empireSlashingProposer.tryExtractPayloadSubmittedEvent(receipt.logs),
696
- slotNumber,
697
- timestamp,
698
- );
699
- break;
700
- }
701
-
702
1032
  case 'vote-offenses': {
703
1033
  this.log.debug(`Enqueuing slashing vote for ${action.votes.length} votes at slot ${slotNumber}`, {
704
1034
  slotNumber,
@@ -706,19 +1036,21 @@ export class SequencerPublisher {
706
1036
  votesCount: action.votes.length,
707
1037
  signerAddress,
708
1038
  });
709
- if (this.slashingProposerContract?.type !== 'tally') {
710
- this.log.error('Cannot vote for slashing offenses on non-tally slashing contract');
1039
+ if (!this.slashingProposerContract) {
1040
+ this.log.error('No slashing proposer contract available');
711
1041
  return false;
712
1042
  }
713
- const tallySlashingProposer = this.slashingProposerContract as TallySlashingProposerContract;
714
1043
  const votes = bufferToHex(encodeSlashConsensusVotes(action.votes));
715
- const request = await tallySlashingProposer.buildVoteRequestFromSigner(votes, slotNumber, signer);
716
- await this.simulateAndEnqueueRequest(
1044
+ const request = await this.slashingProposerContract.buildVoteRequestFromSigner(votes, slotNumber, signer);
1045
+ this.enqueueRequest(
717
1046
  'vote-offenses',
718
1047
  request,
719
- (receipt: TransactionReceipt) => !!tallySlashingProposer.tryExtractVoteCastEvent(receipt.logs),
1048
+ {
1049
+ address: this.slashingProposerContract.address.toString(),
1050
+ abi: SlashingProposerAbi,
1051
+ eventName: 'VoteCast',
1052
+ },
720
1053
  slotNumber,
721
- timestamp,
722
1054
  );
723
1055
  break;
724
1056
  }
@@ -729,18 +1061,23 @@ export class SequencerPublisher {
729
1061
  round: action.round,
730
1062
  signerAddress,
731
1063
  });
732
- if (this.slashingProposerContract?.type !== 'tally') {
733
- this.log.error('Cannot execute slashing offenses on non-tally slashing contract');
1064
+ if (!this.slashingProposerContract) {
1065
+ this.log.error('No slashing proposer contract available');
734
1066
  return false;
735
1067
  }
736
- const tallySlashingProposer = this.slashingProposerContract as TallySlashingProposerContract;
737
- const request = tallySlashingProposer.buildExecuteRoundRequest(action.round, action.committees);
738
- await this.simulateAndEnqueueRequest(
1068
+ const executeRequest = this.slashingProposerContract.buildExecuteRoundRequest(
1069
+ action.round,
1070
+ action.committees,
1071
+ );
1072
+ this.enqueueRequest(
739
1073
  'execute-slash',
740
- request,
741
- (receipt: TransactionReceipt) => !!tallySlashingProposer.tryExtractRoundExecutedEvent(receipt.logs),
1074
+ executeRequest,
1075
+ {
1076
+ address: this.slashingProposerContract.address.toString(),
1077
+ abi: SlashingProposerAbi,
1078
+ eventName: 'RoundExecuted',
1079
+ },
742
1080
  slotNumber,
743
- timestamp,
744
1081
  );
745
1082
  break;
746
1083
  }
@@ -755,133 +1092,98 @@ export class SequencerPublisher {
755
1092
  return true;
756
1093
  }
757
1094
 
758
- /**
759
- * Proposes a L2 block on L1.
760
- *
761
- * @param block - L2 block to propose.
762
- * @returns True if the tx has been enqueued, throws otherwise. See #9315
763
- */
764
- public async enqueueProposeL2Block(
765
- block: L2Block,
1095
+ /** Enqueues a proposal for a checkpoint on L1 */
1096
+ public async enqueueProposeCheckpoint(
1097
+ checkpoint: Checkpoint,
766
1098
  attestationsAndSigners: CommitteeAttestationsAndSigners,
767
1099
  attestationsAndSignersSignature: Signature,
768
- opts: { txTimeoutAt?: Date; forcePendingBlockNumber?: number } = {},
769
- ): Promise<boolean> {
770
- const checkpointHeader = block.getCheckpointHeader();
1100
+ opts: EnqueueProposeCheckpointOpts = {},
1101
+ ): Promise<void> {
1102
+ const checkpointHeader = checkpoint.header;
771
1103
 
772
- const blobFields = block.getCheckpointBlobFields();
773
- const blobs = getBlobsPerL1Block(blobFields);
1104
+ const blobFields = checkpoint.toBlobFields();
1105
+ const blobs = await getBlobsPerL1Block(blobFields);
774
1106
 
775
- const proposeTxArgs = {
1107
+ const proposeTxArgs: L1ProcessArgs = {
776
1108
  header: checkpointHeader,
777
- archive: block.archive.root.toBuffer(),
778
- stateReference: block.header.state,
779
- body: block.body.toBuffer(),
1109
+ archive: checkpoint.archive.root.toBuffer(),
780
1110
  blobs,
781
1111
  attestationsAndSigners,
782
1112
  attestationsAndSignersSignature,
1113
+ feeAssetPriceModifier: checkpoint.feeAssetPriceModifier,
783
1114
  };
784
1115
 
785
- let ts: bigint;
786
-
787
- try {
788
- // @note This will make sure that we are passing the checks for our header ASSUMING that the data is also made available
789
- // This means that we can avoid the simulation issues in later checks.
790
- // By simulation issue, I mean the fact that the block.timestamp is equal to the last block, not the next, which
791
- // make time consistency checks break.
792
- // TODO(palla): Check whether we're validating twice, once here and once within addProposeTx, since we call simulateProposeTx in both places.
793
- ts = await this.validateBlockForSubmission(block, attestationsAndSigners, attestationsAndSignersSignature, opts);
794
- } catch (err: any) {
795
- this.log.error(`Block validation failed. ${err instanceof Error ? err.message : 'No error message'}`, err, {
796
- ...block.getStats(),
797
- slotNumber: block.header.globalVariables.slotNumber.toBigInt(),
798
- forcePendingBlockNumber: opts.forcePendingBlockNumber,
799
- });
800
- throw err;
801
- }
802
-
803
- this.log.verbose(`Enqueuing block propose transaction`, { ...block.toBlockInfo(), ...opts });
804
- await this.addProposeTx(block, proposeTxArgs, opts, ts);
805
- return true;
1116
+ this.log.verbose(`Enqueuing checkpoint propose transaction`, {
1117
+ ...checkpoint.toCheckpointInfo(),
1118
+ txTimeoutAt: opts.txTimeoutAt,
1119
+ });
1120
+ await this.addProposeTx(checkpoint, proposeTxArgs, { txTimeoutAt: opts.txTimeoutAt });
806
1121
  }
807
1122
 
808
- public enqueueInvalidateBlock(request: InvalidateBlockRequest | undefined, opts: { txTimeoutAt?: Date } = {}) {
1123
+ public enqueueInvalidateCheckpoint(
1124
+ request: InvalidateCheckpointRequest | undefined,
1125
+ opts: { txTimeoutAt?: Date } = {},
1126
+ ) {
809
1127
  if (!request) {
810
1128
  return;
811
1129
  }
812
1130
 
813
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
814
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil((Number(request.gasUsed) * 64) / 63)));
815
-
816
- const { gasUsed, blockNumber } = request;
817
- const logData = { gasUsed, blockNumber, gasLimit, opts };
818
- this.log.verbose(`Enqueuing invalidate block request`, logData);
1131
+ const { gasUsed, checkpointNumber } = request;
1132
+ const logData = { gasUsed, checkpointNumber, opts };
1133
+ this.log.verbose(`Enqueuing invalidate checkpoint request`, logData);
819
1134
  this.addRequest({
820
1135
  action: `invalidate-by-${request.reason}`,
821
1136
  request: request.request,
822
- gasConfig: { gasLimit, txTimeoutAt: opts.txTimeoutAt },
823
- lastValidL2Slot: this.getCurrentL2Slot() + 2n,
1137
+ gasConfig: opts.txTimeoutAt ? { txTimeoutAt: opts.txTimeoutAt } : undefined,
1138
+ lastValidL2Slot: SlotNumber(this.getCurrentL2Slot() + 2),
824
1139
  checkSuccess: (_req, result) => {
825
1140
  const success =
826
1141
  result &&
827
- result.receipt &&
828
- result.receipt.status === 'success' &&
829
- tryExtractEvent(result.receipt.logs, this.rollupContract.address, RollupAbi, 'BlockInvalidated');
1142
+ extractEventSuccess(result.receipt, {
1143
+ address: this.rollupContract.address,
1144
+ abi: RollupAbi,
1145
+ eventName: 'CheckpointInvalidated',
1146
+ });
830
1147
  if (!success) {
831
- this.log.warn(`Invalidate block ${request.blockNumber} failed`, { ...result, ...logData });
1148
+ this.log.warn(`Invalidate checkpoint ${request.checkpointNumber} failed`, { ...result, ...logData });
832
1149
  } else {
833
- this.log.info(`Invalidate block ${request.blockNumber} succeeded`, { ...result, ...logData });
1150
+ this.log.info(`Invalidate checkpoint ${request.checkpointNumber} succeeded`, { ...result, ...logData });
834
1151
  }
835
1152
  return !!success;
836
1153
  },
837
1154
  });
838
1155
  }
839
1156
 
840
- 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(
841
1163
  action: Action,
842
1164
  request: L1TxRequest,
843
- checkSuccess: (receipt: TransactionReceipt) => boolean | undefined,
844
- slotNumber: bigint,
845
- timestamp: bigint,
846
- ) {
847
- const logData = { slotNumber, timestamp, gasLimit: undefined as bigint | undefined };
1165
+ eventOpts: { address: string; abi: Abi; eventName: string },
1166
+ slotNumber: SlotNumber,
1167
+ ): boolean {
848
1168
  if (this.lastActions[action] && this.lastActions[action] === slotNumber) {
849
1169
  this.log.debug(`Skipping duplicate action ${action} for slot ${slotNumber}`);
850
1170
  return false;
851
1171
  }
852
-
853
1172
  const cachedLastActionSlot = this.lastActions[action];
854
1173
  this.lastActions[action] = slotNumber;
855
1174
 
856
- this.log.debug(`Simulating ${action} for slot ${slotNumber}`, logData);
857
-
858
- let gasUsed: bigint;
859
- try {
860
- ({ gasUsed } = await this.l1TxUtils.simulate(request, { time: timestamp }, [], ErrorsAbi)); // TODO(palla/slash): Check the timestamp logic
861
- this.log.verbose(`Simulation for ${action} succeeded`, { ...logData, request, gasUsed });
862
- } catch (err) {
863
- const viemError = formatViemError(err);
864
- this.log.error(`Simulation for ${action} at ${slotNumber} failed`, viemError, logData);
865
- return false;
866
- }
867
-
868
- // We issued the simulation against the rollup contract, so we need to account for the overhead of the multicall3
869
- const gasLimit = this.l1TxUtils.bumpGasLimit(BigInt(Math.ceil((Number(gasUsed) * 64) / 63)));
870
- logData.gasLimit = gasLimit;
871
-
872
- this.log.debug(`Enqueuing ${action}`, logData);
1175
+ this.log.debug(`Enqueuing ${action}`, { slotNumber });
873
1176
  this.addRequest({
874
1177
  action,
875
1178
  request,
876
- gasConfig: { gasLimit },
877
1179
  lastValidL2Slot: slotNumber,
878
- checkSuccess: (_req, result) => {
879
- const success = result && result.receipt && result.receipt.status === 'success' && checkSuccess(result.receipt);
1180
+ checkSuccess: (_request, result) => {
1181
+ const success = result && extractEventSuccess(result.receipt, eventOpts);
880
1182
  if (!success) {
881
- this.log.warn(`Action ${action} at ${slotNumber} failed`, { ...result, ...logData });
1183
+ this.log.warn(`Action ${action} at ${slotNumber} failed`, { ...result, slotNumber });
882
1184
  this.lastActions[action] = cachedLastActionSlot;
883
1185
  } else {
884
- this.log.info(`Action ${action} at ${slotNumber} succeeded`, { ...result, ...logData });
1186
+ this.log.info(`Action ${action} at ${slotNumber} succeeded`, { ...result, slotNumber });
885
1187
  }
886
1188
  return !!success;
887
1189
  },
@@ -897,6 +1199,7 @@ export class SequencerPublisher {
897
1199
  */
898
1200
  public interrupt() {
899
1201
  this.interrupted = true;
1202
+ this.interruptibleSleep.interrupt();
900
1203
  this.l1TxUtils.interrupt();
901
1204
  }
902
1205
 
@@ -906,47 +1209,73 @@ export class SequencerPublisher {
906
1209
  this.l1TxUtils.restart();
907
1210
  }
908
1211
 
909
- private async prepareProposeTx(
910
- encodedData: L1ProcessArgs,
911
- timestamp: bigint,
912
- options: { forcePendingBlockNumber?: number },
913
- ) {
1212
+ private async prepareProposeTx(encodedData: L1ProcessArgs) {
914
1213
  const kzg = Blob.getViemKzgInstance();
915
1214
  const blobInput = getPrefixedEthBlobCommitments(encodedData.blobs);
916
1215
  this.log.debug('Validating blob input', { blobInput });
917
- const blobEvaluationGas = await this.l1TxUtils
918
- .estimateGas(
919
- this.getSenderAddress().toString(),
920
- {
921
- to: this.rollupContract.address,
922
- data: encodeFunctionData({
1216
+
1217
+ // Get blob evaluation gas
1218
+ let blobEvaluationGas: bigint;
1219
+ if (this.config.fishermanMode) {
1220
+ // In fisherman mode, we can't estimate blob gas because estimateGas doesn't support state overrides
1221
+ // Use a fixed estimate.
1222
+ blobEvaluationGas = BigInt(encodedData.blobs.length) * 21_000n;
1223
+ this.log.debug(`Using fixed blob evaluation gas estimate in fisherman mode: ${blobEvaluationGas}`);
1224
+ } else {
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.
1230
+ blobEvaluationGas = await this.l1TxUtils
1231
+ .estimateGas(
1232
+ this.getSenderAddress().toString(),
1233
+ {
1234
+ to: this.rollupContract.address,
1235
+ data: encodeFunctionData({
1236
+ abi: RollupAbi,
1237
+ functionName: 'validateBlobs',
1238
+ args: [blobInput],
1239
+ }),
1240
+ },
1241
+ {},
1242
+ {
1243
+ blobs: encodedData.blobs.map(b => b.data),
1244
+ kzg,
1245
+ },
1246
+ )
1247
+ .catch(async err => {
1248
+ const viemError = formatViemError(err);
1249
+ this.log.error(`Failed to validate blobs`, viemError.message, { metaMessages: viemError.metaMessages });
1250
+ const validateBlobsData = encodeFunctionData({
923
1251
  abi: RollupAbi,
924
1252
  functionName: 'validateBlobs',
925
1253
  args: [blobInput],
926
- }),
927
- },
928
- {},
929
- {
930
- blobs: encodedData.blobs.map(b => b.data),
931
- kzg,
932
- },
933
- )
934
- .catch(err => {
935
- const { message, metaMessages } = formatViemError(err);
936
- this.log.error(`Failed to validate blobs`, message, { metaMessages });
937
- throw new Error('Failed to validate blobs');
938
- });
939
-
1254
+ });
1255
+ const l1BlockNumber = await this.l1TxUtils.getBlockNumber();
1256
+ this.backupFailedTx({
1257
+ id: keccak256(validateBlobsData),
1258
+ failureType: 'simulation',
1259
+ request: { to: this.rollupContract.address as Hex, data: validateBlobsData },
1260
+ blobData: encodedData.blobs.map(b => toHex(b.data)) as Hex[],
1261
+ l1BlockNumber: l1BlockNumber.toString(),
1262
+ error: { message: viemError.message, name: viemError.name },
1263
+ context: {
1264
+ actions: ['validate-blobs'],
1265
+ sender: this.getSenderAddress().toString(),
1266
+ },
1267
+ });
1268
+ throw new Error('Failed to validate blobs');
1269
+ });
1270
+ }
940
1271
  const signers = encodedData.attestationsAndSigners.getSigners().map(signer => signer.toString());
941
1272
 
942
1273
  const args = [
943
1274
  {
944
1275
  header: encodedData.header.toViem(),
945
1276
  archive: toHex(encodedData.archive),
946
- stateReference: encodedData.stateReference.toViem(),
947
1277
  oracleInput: {
948
- // We are currently not modifying these. See #9963
949
- feeAssetPriceModifier: 0n,
1278
+ feeAssetPriceModifier: encodedData.feeAssetPriceModifier,
950
1279
  },
951
1280
  },
952
1281
  encodedData.attestationsAndSigners.getPackedAttestations(),
@@ -955,110 +1284,29 @@ export class SequencerPublisher {
955
1284
  blobInput,
956
1285
  ] as const;
957
1286
 
958
- const { rollupData, simulationResult } = await this.simulateProposeTx(args, timestamp, options);
959
-
960
- return { args, blobEvaluationGas, rollupData, simulationResult };
961
- }
962
-
963
- /**
964
- * Simulates the propose tx with eth_simulateV1
965
- * @param args - The propose tx args
966
- * @param timestamp - The timestamp to simulate proposal at
967
- * @returns The simulation result
968
- */
969
- private async simulateProposeTx(
970
- args: readonly [
971
- {
972
- readonly header: ViemHeader;
973
- readonly archive: `0x${string}`;
974
- readonly stateReference: ViemStateReference;
975
- readonly oracleInput: {
976
- readonly feeAssetPriceModifier: 0n;
977
- };
978
- },
979
- ViemCommitteeAttestations,
980
- `0x${string}`[], // Signers
981
- ViemSignature,
982
- `0x${string}`,
983
- ],
984
- timestamp: bigint,
985
- options: { forcePendingBlockNumber?: number },
986
- ) {
987
- const rollupData = encodeFunctionData({
988
- abi: RollupAbi,
989
- functionName: 'propose',
990
- args,
991
- });
992
-
993
- // override the pending block number if requested
994
- const forcePendingBlockNumberStateDiff = (
995
- options.forcePendingBlockNumber !== undefined
996
- ? await this.rollupContract.makePendingBlockNumberOverride(options.forcePendingBlockNumber)
997
- : []
998
- ).flatMap(override => override.stateDiff ?? []);
999
-
1000
- const simulationResult = await this.l1TxUtils
1001
- .simulate(
1002
- {
1003
- to: this.rollupContract.address,
1004
- data: rollupData,
1005
- gas: SequencerPublisher.PROPOSE_GAS_GUESS,
1006
- },
1007
- {
1008
- // @note we add 1n to the timestamp because geth implementation doesn't like simulation timestamp to be equal to the current block timestamp
1009
- time: timestamp + 1n,
1010
- // @note reth should have a 30m gas limit per block but throws errors that this tx is beyond limit so we increase here
1011
- gasLimit: SequencerPublisher.PROPOSE_GAS_GUESS * 2n,
1012
- },
1013
- [
1014
- {
1015
- address: this.rollupContract.address,
1016
- // @note we override checkBlob to false since blobs are not part simulate()
1017
- stateDiff: [
1018
- { slot: toPaddedHex(RollupContract.checkBlobStorageSlot, true), value: toPaddedHex(0n, true) },
1019
- ...forcePendingBlockNumberStateDiff,
1020
- ],
1021
- },
1022
- ],
1023
- RollupAbi,
1024
- {
1025
- // @note fallback gas estimate to use if the node doesn't support simulation API
1026
- fallbackGasEstimate: SequencerPublisher.PROPOSE_GAS_GUESS,
1027
- },
1028
- )
1029
- .catch(err => {
1030
- this.log.error(`Failed to simulate propose tx`, err);
1031
- throw err;
1032
- });
1287
+ const rollupData = encodeFunctionData({ abi: RollupAbi, functionName: 'propose', args });
1033
1288
 
1034
- return { rollupData, simulationResult };
1289
+ return { args, blobEvaluationGas, rollupData };
1035
1290
  }
1036
1291
 
1037
1292
  private async addProposeTx(
1038
- block: L2Block,
1293
+ checkpoint: Checkpoint,
1039
1294
  encodedData: L1ProcessArgs,
1040
- opts: { txTimeoutAt?: Date; forcePendingBlockNumber?: number } = {},
1041
- timestamp: bigint,
1295
+ opts: EnqueueProposeCheckpointOpts = {},
1042
1296
  ): Promise<void> {
1297
+ const slot = checkpoint.header.slotNumber;
1043
1298
  const timer = new Timer();
1044
1299
  const kzg = Blob.getViemKzgInstance();
1045
- const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx(
1046
- encodedData,
1047
- timestamp,
1048
- opts,
1049
- );
1300
+ const { rollupData, blobEvaluationGas } = await this.prepareProposeTx(encodedData);
1050
1301
  const startBlock = await this.l1TxUtils.getBlockNumber();
1051
- const gasLimit = this.l1TxUtils.bumpGasLimit(
1052
- BigInt(Math.ceil((Number(simulationResult.gasUsed) * 64) / 63)) +
1053
- blobEvaluationGas +
1054
- SequencerPublisher.MULTICALL_OVERHEAD_GAS_GUESS, // We issue the simulation against the rollup contract, so we need to account for the overhead of the multicall3
1055
- );
1056
1302
 
1057
- // Send the blobs to the blob sink preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
1058
- // tx fails but it does get mined. We make sure that the blobs are sent to the blob sink regardless of the tx outcome.
1059
- void this.blobSinkClient.sendBlobsToBlobSink(encodedData.blobs).catch(_err => {
1060
- this.log.error('Failed to send blobs to blob sink');
1061
- });
1303
+ // Send the blobs to the blob client preemptively. This helps in tests where the sequencer mistakingly thinks that the propose
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.
1305
+ void Promise.resolve().then(() =>
1306
+ this.blobClient.sendBlobsToFilestore(encodedData.blobs).catch(_err => {
1307
+ this.log.error('Failed to send blobs to blob client');
1308
+ }),
1309
+ );
1062
1310
 
1063
1311
  return this.addRequest({
1064
1312
  action: 'propose',
@@ -1066,8 +1314,9 @@ export class SequencerPublisher {
1066
1314
  to: this.rollupContract.address,
1067
1315
  data: rollupData,
1068
1316
  },
1069
- lastValidL2Slot: block.header.globalVariables.slotNumber.toBigInt(),
1070
- gasConfig: { ...opts, gasLimit },
1317
+ lastValidL2Slot: checkpoint.header.slotNumber,
1318
+ gasConfig: { txTimeoutAt: opts.txTimeoutAt, gasLimit: undefined },
1319
+ blobEvaluationGas,
1071
1320
  blobConfig: {
1072
1321
  blobs: encodedData.blobs.map(b => b.data),
1073
1322
  kzg,
@@ -1077,15 +1326,17 @@ export class SequencerPublisher {
1077
1326
  return false;
1078
1327
  }
1079
1328
  const { receipt, stats, errorMsg } = result;
1080
- const success =
1081
- receipt &&
1082
- receipt.status === 'success' &&
1083
- tryExtractEvent(receipt.logs, this.rollupContract.address, RollupAbi, 'L2BlockProposed');
1329
+ const success = extractEventSuccess(receipt, {
1330
+ address: this.rollupContract.address,
1331
+ abi: RollupAbi,
1332
+ eventName: 'CheckpointProposed',
1333
+ });
1334
+
1084
1335
  if (success) {
1085
1336
  const endBlock = receipt.blockNumber;
1086
1337
  const inclusionBlocks = Number(endBlock - startBlock);
1087
1338
  const { calldataGas, calldataSize, sender } = stats!;
1088
- const publishStats: L1PublishBlockStats = {
1339
+ const publishStats: L1PublishCheckpointStats = {
1089
1340
  gasPrice: receipt.effectiveGasPrice,
1090
1341
  gasUsed: receipt.gasUsed,
1091
1342
  blobGasUsed: receipt.blobGasUsed ?? 0n,
@@ -1094,26 +1345,35 @@ export class SequencerPublisher {
1094
1345
  calldataGas,
1095
1346
  calldataSize,
1096
1347
  sender,
1097
- ...block.getStats(),
1348
+ ...checkpoint.getStats(),
1098
1349
  eventName: 'rollup-published-to-l1',
1099
1350
  blobCount: encodedData.blobs.length,
1100
1351
  inclusionBlocks,
1101
1352
  };
1102
- this.log.info(`Published L2 block to L1 rollup contract`, { ...stats, ...block.getStats(), ...receipt });
1353
+ this.log.info(`Published checkpoint ${checkpoint.number} at slot ${slot} to rollup contract`, {
1354
+ ...stats,
1355
+ ...checkpoint.getStats(),
1356
+ ...pick(receipt, 'transactionHash', 'blockHash'),
1357
+ });
1103
1358
  this.metrics.recordProcessBlockTx(timer.ms(), publishStats);
1104
1359
 
1105
1360
  return true;
1106
1361
  } else {
1107
1362
  this.metrics.recordFailedTx('process');
1108
- this.log.error(`Rollup process tx failed: ${errorMsg ?? 'no error message'}`, undefined, {
1109
- ...block.getStats(),
1110
- receipt,
1111
- txHash: receipt.transactionHash,
1112
- slotNumber: block.header.globalVariables.slotNumber.toBigInt(),
1113
- });
1363
+ this.log.error(
1364
+ `Publishing checkpoint at slot ${slot} failed with ${errorMsg ?? 'no error message'}`,
1365
+ undefined,
1366
+ { ...checkpoint.getStats(), ...receipt },
1367
+ );
1114
1368
  return false;
1115
1369
  }
1116
1370
  },
1117
1371
  });
1118
1372
  }
1373
+
1374
+ /** Returns the timestamp of the next L1 slot boundary after now. */
1375
+ private getNextL1SlotTimestamp(): bigint {
1376
+ const l1Constants = this.epochCache.getL1Constants();
1377
+ return getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants);
1378
+ }
1119
1379
  }