@aztec/sequencer-client 0.0.1-commit.24de95ac → 0.0.1-commit.2606882

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +1001 -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 +1792 -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 +795 -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 +1632 -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,497 +1,614 @@
1
- import { L2Block } from '@aztec/aztec.js/block';
2
- import { BLOBS_PER_BLOCK, FIELDS_PER_BLOB, INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
3
- import type { EpochCache } from '@aztec/epoch-cache';
4
- import { FormattedViemError, NoCommitteeError, type RollupContract } from '@aztec/ethereum';
5
- import { omit, pick } from '@aztec/foundation/collection';
6
- import { randomInt } from '@aztec/foundation/crypto';
1
+ import { getKzg } from '@aztec/blob-lib';
2
+ import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
3
+ import { NoCommitteeError, type RollupContract } from '@aztec/ethereum/contracts';
4
+ import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
5
+ import { merge, omit, pick } from '@aztec/foundation/collection';
6
+ import { Fr } from '@aztec/foundation/curves/bn254';
7
7
  import { EthAddress } from '@aztec/foundation/eth-address';
8
- import { Signature } from '@aztec/foundation/eth-signature';
9
- import { Fr } from '@aztec/foundation/fields';
10
- import { createLogger } from '@aztec/foundation/log';
8
+ import { type Logger, createLogger } from '@aztec/foundation/log';
11
9
  import { RunningPromise } from '@aztec/foundation/running-promise';
12
- import { type DateProvider, Timer } from '@aztec/foundation/timer';
13
- import { type TypedEventEmitter, unfreeze } from '@aztec/foundation/types';
10
+ import type { DateProvider } from '@aztec/foundation/timer';
11
+ import type { TypedEventEmitter } from '@aztec/foundation/types';
14
12
  import type { P2P } from '@aztec/p2p';
15
13
  import type { SlasherClientInterface } from '@aztec/slasher';
16
- import {
17
- CommitteeAttestation,
18
- CommitteeAttestationsAndSigners,
19
- type L2BlockSource,
20
- type ValidateBlockResult,
14
+ import type {
15
+ BlockData,
16
+ L2BlockSink,
17
+ L2BlockSource,
18
+ ProposedCheckpointSink,
19
+ ValidateCheckpointResult,
21
20
  } from '@aztec/stdlib/block';
22
- import { type L1RollupConstants, getSlotAtTimestamp, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
23
- import { Gas } from '@aztec/stdlib/gas';
21
+ import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
22
+ import type { ChainConfig } from '@aztec/stdlib/config';
23
+ import { getEpochAtSlot, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
24
24
  import {
25
- type IFullNodeBlockBuilder,
26
- type PublicProcessorLimits,
25
+ type ResolvedSequencerConfig,
26
+ type SequencerConfig,
27
27
  SequencerConfigSchema,
28
28
  type WorldStateSynchronizer,
29
29
  } from '@aztec/stdlib/interfaces/server';
30
30
  import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
31
- import type { BlockProposalOptions } from '@aztec/stdlib/p2p';
32
- import { orderAttestations } from '@aztec/stdlib/p2p';
33
- import { CheckpointHeader } from '@aztec/stdlib/rollup';
31
+ import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p';
34
32
  import { pickFromSchema } from '@aztec/stdlib/schemas';
35
- import type { L2BlockBuiltStats } from '@aztec/stdlib/stats';
36
- import { MerkleTreeId } from '@aztec/stdlib/trees';
37
- import { ContentCommitment, type FailedTx, GlobalVariables, Tx } from '@aztec/stdlib/tx';
38
- import { AttestationTimeoutError } from '@aztec/stdlib/validators';
33
+ import { MIN_EXECUTION_TIME } from '@aztec/stdlib/timetable';
39
34
  import { Attributes, type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
40
- import type { ValidatorClient } from '@aztec/validator-client';
35
+ import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client';
41
36
 
42
37
  import EventEmitter from 'node:events';
43
- import type { TypedDataDefinition } from 'viem';
44
38
 
39
+ import { DefaultSequencerConfig } from '../config.js';
45
40
  import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
46
41
  import type { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js';
47
- import type { Action, InvalidateBlockRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
48
- import type { SequencerConfig } from './config.js';
42
+ import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
43
+ import { buildCheckpointSimulationOverridesPlan } from './chain_state_overrides.js';
44
+ import { CheckpointProposalJob } from './checkpoint_proposal_job.js';
45
+ import { CheckpointProposalJobMetrics } from './checkpoint_proposal_job_metrics.js';
46
+ import { CheckpointVoter } from './checkpoint_voter.js';
49
47
  import { SequencerInterruptedError, SequencerTooSlowError } from './errors.js';
48
+ import type { SequencerEvents } from './events.js';
50
49
  import { SequencerMetrics } from './metrics.js';
51
50
  import { SequencerTimetable } from './timetable.js';
52
- import { SequencerState, type SequencerStateWithSlot } from './utils.js';
51
+ import type { SequencerRollupConstants } from './types.js';
52
+ import { SequencerState } from './utils.js';
53
53
 
54
54
  export { SequencerState };
55
55
 
56
- type SequencerRollupConstants = Pick<L1RollupConstants, 'ethereumSlotDuration' | 'l1GenesisTime' | 'slotDuration'>;
57
-
58
- export type SequencerEvents = {
59
- ['state-changed']: (args: {
60
- oldState: SequencerState;
61
- newState: SequencerState;
62
- secondsIntoSlot?: number;
63
- slotNumber?: bigint;
64
- }) => void;
65
- ['proposer-rollup-check-failed']: (args: { reason: string }) => void;
66
- ['tx-count-check-failed']: (args: { minTxs: number; availableTxs: number }) => void;
67
- ['block-build-failed']: (args: { reason: string }) => void;
68
- ['block-publish-failed']: (args: {
69
- successfulActions?: Action[];
70
- failedActions?: Action[];
71
- sentActions?: Action[];
72
- expiredActions?: Action[];
73
- }) => void;
74
- ['block-published']: (args: { blockNumber: number; slot: number }) => void;
56
+ /** Slot snapshot used to prepare a checkpoint proposal. */
57
+ type SequencerSlotContext = {
58
+ slot: SlotNumber;
59
+ targetSlot: SlotNumber;
60
+ epoch: EpochNumber;
61
+ targetEpoch: EpochNumber;
62
+ ts: bigint;
63
+ nowSeconds: bigint;
75
64
  };
76
65
 
77
66
  /**
78
67
  * Sequencer client
79
- * - Wins a period of time to become the sequencer (depending on finalized protocol).
80
- * - Chooses a set of txs from the tx pool to be in the rollup.
81
- * - Simulate the rollup of txs.
82
- * - Adds proof requests to the request pool (not for this milestone).
83
- * - Receives results to those proofs from the network (repeats as necessary) (not for this milestone).
84
- * - Publishes L1 tx(s) to the rollup contract via RollupPublisher.
68
+ * - Checks whether it is elected as proposer for the next slot
69
+ * - Builds multiple blocks and broadcasts them
70
+ * - Collects attestations for the checkpoint
71
+ * - Publishes the checkpoint to L1
72
+ * - Votes for proposals and slashes on L1
85
73
  */
86
74
  export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<SequencerEvents>) {
87
75
  private runningPromise?: RunningPromise;
88
- private pollingIntervalMs: number = 1000;
89
- private maxTxsPerBlock = 32;
90
- private minTxsPerBlock = 1;
91
- private maxL1TxInclusionTimeIntoSlot = 0;
92
76
  private state = SequencerState.STOPPED;
93
- private maxBlockSizeInBytes: number = 1024 * 1024;
94
- private maxBlockGas: Gas = new Gas(100e9, 100e9);
77
+ private stateSlotNumber: SlotNumber | undefined;
78
+ private stateEnteredAtMs = performance.now();
95
79
  private metrics: SequencerMetrics;
80
+ private checkpointProposalJobMetrics: CheckpointProposalJobMetrics;
81
+ private readonly stateLog: Logger;
82
+
83
+ /** The last slot for which we attempted to perform our voting duties with degraded block production */
84
+ private lastSlotForFallbackVote: SlotNumber | undefined;
85
+
86
+ /** The last slot for which we logged "no committee" warning, to avoid spam */
87
+ private lastSlotForNoCommitteeWarning: SlotNumber | undefined;
96
88
 
97
- private lastBlockPublished: L2Block | undefined;
89
+ /** The last slot for which we triggered a checkpoint proposal job, to prevent duplicate attempts. */
90
+ private lastSlotForCheckpointProposalJob: SlotNumber | undefined;
98
91
 
99
- private governanceProposerPayload: EthAddress | undefined;
92
+ /** Last successful checkpoint proposed */
93
+ private lastCheckpointProposed: Checkpoint | undefined;
100
94
 
101
- /** The last slot for which we attempted to vote when sync failed, to prevent duplicate attempts. */
102
- private lastSlotForVoteWhenSyncFailed: bigint | undefined;
95
+ /** The last epoch for which we logged strategy comparison in fisherman mode. */
96
+ private lastEpochForStrategyComparison: EpochNumber | undefined;
97
+
98
+ /** The last checkpoint proposal job, tracked so we can await its pending L1 submission during shutdown. */
99
+ protected lastCheckpointProposalJob: CheckpointProposalJob | undefined;
103
100
 
104
101
  /** The maximum number of seconds that the sequencer can be into a slot to transition to a particular state. */
105
102
  protected timetable!: SequencerTimetable;
106
- protected enforceTimeTable: boolean = false;
107
103
 
108
- // This shouldn't be here as this gets re-created each time we build/propose a block.
109
- // But we have a number of tests that abuse/rely on this class having a permanent publisher.
110
- // As long as those tests only configure a single publisher they will continue to work.
111
- // This will get re-assigned every time the sequencer goes to build a new block to a publisher that is valid
112
- // for the block proposer.
113
- protected publisher: SequencerPublisher | undefined;
104
+ /** Config for the sequencer */
105
+ protected config: ResolvedSequencerConfig = DefaultSequencerConfig;
106
+ private readonly signatureContext: CoordinationSignatureContext;
114
107
 
115
108
  constructor(
116
109
  protected publisherFactory: SequencerPublisherFactory,
117
- protected validatorClient: ValidatorClient | undefined, // During migration the validator client can be inactive
110
+ protected validatorClient: ValidatorClient,
118
111
  protected globalsBuilder: GlobalVariableBuilder,
119
112
  protected p2pClient: P2P,
120
113
  protected worldState: WorldStateSynchronizer,
121
114
  protected slasherClient: SlasherClientInterface | undefined,
122
- protected l2BlockSource: L2BlockSource,
115
+ protected l2BlockSource: L2BlockSource & L2BlockSink & ProposedCheckpointSink,
123
116
  protected l1ToL2MessageSource: L1ToL2MessageSource,
124
- protected blockBuilder: IFullNodeBlockBuilder,
117
+ protected checkpointsBuilder: FullNodeCheckpointsBuilder,
125
118
  protected l1Constants: SequencerRollupConstants,
126
119
  protected dateProvider: DateProvider,
127
120
  protected epochCache: EpochCache,
128
121
  protected rollupContract: RollupContract,
129
- protected config: SequencerConfig,
122
+ config: SequencerConfig & Pick<ChainConfig, 'l1ChainId' | 'rollupAddress'>,
130
123
  protected telemetry: TelemetryClient = getTelemetryClient(),
131
124
  protected log = createLogger('sequencer'),
132
125
  ) {
133
126
  super();
127
+ this.stateLog = log.createChild('state');
134
128
 
135
- this.metrics = new SequencerMetrics(telemetry, this.rollupContract, 'Sequencer');
136
- // Initialize config
137
- this.updateConfig(this.config);
138
- }
139
-
140
- get tracer(): Tracer {
141
- return this.metrics.tracer;
142
- }
143
-
144
- public getValidatorAddresses() {
145
- return this.validatorClient?.getValidatorAddresses();
146
- }
147
-
148
- public getConfig() {
149
- return this.config;
150
- }
151
-
152
- /**
153
- * Updates sequencer config by the defined values in the config on input.
154
- * @param config - New parameters.
155
- */
156
- public updateConfig(config: SequencerConfig) {
157
- this.log.info(
158
- `Sequencer config set`,
159
- omit(pickFromSchema(config, SequencerConfigSchema), 'txPublicSetupAllowList'),
160
- );
161
-
162
- if (config.transactionPollingIntervalMS !== undefined) {
163
- this.pollingIntervalMs = config.transactionPollingIntervalMS;
129
+ // Add [FISHERMAN] prefix to logger if in fisherman mode
130
+ if (config.fishermanMode) {
131
+ this.log = log.createChild('[FISHERMAN]');
164
132
  }
165
- if (config.maxTxsPerBlock !== undefined) {
166
- this.maxTxsPerBlock = config.maxTxsPerBlock;
167
- }
168
- if (config.minTxsPerBlock !== undefined) {
169
- this.minTxsPerBlock = config.minTxsPerBlock;
170
- }
171
- if (config.maxDABlockGas !== undefined) {
172
- this.maxBlockGas = new Gas(config.maxDABlockGas, this.maxBlockGas.l2Gas);
173
- }
174
- if (config.maxL2BlockGas !== undefined) {
175
- this.maxBlockGas = new Gas(this.maxBlockGas.daGas, config.maxL2BlockGas);
176
- }
177
- if (config.maxBlockSizeInBytes !== undefined) {
178
- this.maxBlockSizeInBytes = config.maxBlockSizeInBytes;
179
- }
180
- if (config.governanceProposerPayload) {
181
- this.governanceProposerPayload = config.governanceProposerPayload;
182
- }
183
- if (config.maxL1TxInclusionTimeIntoSlot !== undefined) {
184
- this.maxL1TxInclusionTimeIntoSlot = config.maxL1TxInclusionTimeIntoSlot;
185
- }
186
- if (config.enforceTimeTable !== undefined) {
187
- this.enforceTimeTable = config.enforceTimeTable;
188
- }
189
-
190
- this.setTimeTable();
191
133
 
192
- // TODO: Just read everything from the config object as needed instead of copying everything into local vars.
193
-
194
- // Update all values on this.config that are populated in the config object.
195
- Object.assign(this.config, config);
134
+ this.signatureContext = {
135
+ chainId: config.l1ChainId,
136
+ rollupAddress: config.rollupAddress,
137
+ };
138
+ this.metrics = new SequencerMetrics(telemetry, this.rollupContract, 'Sequencer');
139
+ this.checkpointProposalJobMetrics = new CheckpointProposalJobMetrics(telemetry);
140
+ this.updateConfig(config);
196
141
  }
197
142
 
198
- private setTimeTable() {
143
+ /** Updates sequencer config by the defined values and updates the timetable */
144
+ public updateConfig(config: Partial<SequencerConfig>) {
145
+ const filteredConfig = pickFromSchema(config, SequencerConfigSchema);
146
+ this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend'));
147
+ this.config = merge(this.config, filteredConfig);
148
+ const p2pPropagationTime = this.config.attestationPropagationTime;
199
149
  this.timetable = new SequencerTimetable(
200
150
  {
201
151
  ethereumSlotDuration: this.l1Constants.ethereumSlotDuration,
202
152
  aztecSlotDuration: this.aztecSlotDuration,
203
- maxL1TxInclusionTimeIntoSlot: this.maxL1TxInclusionTimeIntoSlot,
204
- attestationPropagationTime: this.config.attestationPropagationTime,
205
- enforce: this.enforceTimeTable,
153
+ l1PublishingTime: this.l1PublishingTime,
154
+ p2pPropagationTime,
155
+ blockDurationMs: this.config.blockDurationMs,
156
+ enforce: this.config.enforceTimeTable,
206
157
  },
207
158
  this.metrics,
208
159
  this.log,
209
160
  );
210
161
  }
211
162
 
212
- public async init() {
213
- this.publisher = (await this.publisherFactory.create(undefined)).publisher;
163
+ /** Initializes the sequencer (precomputes tables). Takes about 3s. */
164
+ public init() {
165
+ getKzg();
214
166
  }
215
167
 
216
- /**
217
- * Starts the sequencer and moves to IDLE state.
218
- */
168
+ /** Starts the sequencer and moves to IDLE state. */
219
169
  public start() {
220
- this.runningPromise = new RunningPromise(this.safeWork.bind(this), this.log, this.pollingIntervalMs);
170
+ this.runningPromise = new RunningPromise(
171
+ this.safeWork.bind(this),
172
+ this.log,
173
+ this.config.sequencerPollingIntervalMS,
174
+ );
221
175
  this.setState(SequencerState.IDLE, undefined, { force: true });
222
176
  this.runningPromise.start();
223
177
  this.log.info('Started sequencer');
224
178
  }
225
179
 
226
- /**
227
- * Stops the sequencer from processing txs and moves to STOPPED state.
228
- */
180
+ /** Triggers an immediate run of the sequencer, bypassing the polling interval. */
181
+ public trigger() {
182
+ return this.runningPromise?.trigger();
183
+ }
184
+
185
+ /** Stops the sequencer from building blocks and moves to STOPPED state. */
229
186
  public async stop(): Promise<void> {
230
187
  this.log.info(`Stopping sequencer`);
231
188
  this.setState(SequencerState.STOPPING, undefined, { force: true });
232
- this.publisher?.interrupt();
189
+ this.lastCheckpointProposalJob?.interrupt();
190
+ await this.publisherFactory.stopAll();
233
191
  await this.runningPromise?.stop();
192
+ await this.lastCheckpointProposalJob?.awaitPendingSubmission();
234
193
  this.setState(SequencerState.STOPPED, undefined, { force: true });
235
194
  this.log.info('Stopped sequencer');
236
195
  }
237
196
 
238
- /**
239
- * Returns the current state of the sequencer.
240
- * @returns An object with a state entry with one of SequencerState.
241
- */
197
+ /** Main sequencer loop with a try/catch */
198
+ protected async safeWork() {
199
+ try {
200
+ await this.work();
201
+ } catch (err) {
202
+ if (
203
+ err instanceof SequencerTooSlowError &&
204
+ [SequencerState.INITIALIZING_CHECKPOINT, SequencerState.PROPOSER_CHECK].includes(err.proposedState)
205
+ ) {
206
+ // No need to alert if we just didn't get to start in time
207
+ this.log.debug(err.message, { now: this.dateProvider.nowInSeconds() });
208
+ } else {
209
+ // Re-throw other errors
210
+ this.emit('checkpoint-error', { error: err as Error });
211
+ throw err;
212
+ }
213
+ } finally {
214
+ this.setState(SequencerState.IDLE, undefined);
215
+ }
216
+ }
217
+
218
+ /** Returns the current state of the sequencer. */
242
219
  public status() {
243
220
  return { state: this.state };
244
221
  }
245
222
 
246
223
  /**
247
- * @notice Performs most of the sequencer duties:
248
- * - Checks if we are up to date
249
- * - If we are and we are the sequencer, collect txs and build a block
250
- * - Collect attestations for the block
251
- * - Submit block
252
- * - If our block for some reason is not included, revert the state
224
+ * Main sequencer loop:
225
+ * - Checks if we are up to date
226
+ * - If we are and we are the sequencer, collect txs and build blocks
227
+ * - Build multiple blocks per slot when configured
228
+ * - Collect attestations for the final block
229
+ * - Submit checkpoint
253
230
  */
231
+ @trackSpan('Sequencer.work')
254
232
  protected async work() {
255
233
  this.setState(SequencerState.SYNCHRONIZING, undefined);
256
- const { slot, ts, now } = this.epochCache.getEpochAndSlotInNextL1Slot();
234
+ const { slot, targetSlot, epoch, targetEpoch, ts, nowSeconds } = this.getSlotContextInNextL1Slot();
257
235
 
258
- // Check we have not already published a block for this slot (cheapest check)
259
- if (this.lastBlockPublished && this.lastBlockPublished.header.getSlot() >= slot) {
260
- this.log.debug(
261
- `Cannot propose block at next L2 slot ${slot} since that slot was taken by our own block ${this.lastBlockPublished.number}`,
262
- );
236
+ // Check if we are synced and it's our slot, grab a publisher, check previous block invalidation, etc
237
+ const checkpointProposalJob = await this.prepareCheckpointProposal(
238
+ slot,
239
+ targetSlot,
240
+ epoch,
241
+ targetEpoch,
242
+ ts,
243
+ nowSeconds,
244
+ );
245
+ if (!checkpointProposalJob) {
263
246
  return;
264
247
  }
265
248
 
249
+ // Track the job so we can await its pending L1 submission during shutdown
250
+ this.lastCheckpointProposalJob = checkpointProposalJob;
251
+
252
+ // Execute the checkpoint proposal job
253
+ const checkpoint = await checkpointProposalJob.execute();
254
+
255
+ // Update last checkpoint proposed (currently unused)
256
+ if (checkpoint) {
257
+ this.lastCheckpointProposed = checkpoint;
258
+ }
259
+
260
+ // Log fee strategy comparison if on fisherman (uses target epoch since we mirror the proposer's perspective)
261
+ if (
262
+ this.config.fishermanMode &&
263
+ (this.lastEpochForStrategyComparison === undefined || targetEpoch > this.lastEpochForStrategyComparison)
264
+ ) {
265
+ this.logStrategyComparison(targetEpoch, checkpointProposalJob.getPublisher());
266
+ this.lastEpochForStrategyComparison = targetEpoch;
267
+ }
268
+
269
+ return checkpoint;
270
+ }
271
+
272
+ /** Returns slot and target slot from a single clock snapshot. */
273
+ protected getSlotContextInNextL1Slot(): SequencerSlotContext {
274
+ const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
275
+ const targetSlot = SlotNumber(slot + PROPOSER_PIPELINING_SLOT_OFFSET);
276
+ return { slot, targetSlot, epoch, targetEpoch: getEpochAtSlot(targetSlot, this.l1Constants), ts, nowSeconds };
277
+ }
278
+
279
+ /**
280
+ * Prepares the checkpoint proposal by performing all necessary checks and setup.
281
+ * This is the initial step in the main loop.
282
+ * @returns CheckpointProposalJob if successful, undefined if we are not yet synced or are not the proposer.
283
+ */
284
+ @trackSpan('Sequencer.prepareCheckpointProposal')
285
+ protected async prepareCheckpointProposal(
286
+ slot: SlotNumber,
287
+ targetSlot: SlotNumber,
288
+ epoch: EpochNumber,
289
+ targetEpoch: EpochNumber,
290
+ ts: bigint,
291
+ nowSeconds: bigint,
292
+ ): Promise<CheckpointProposalJob | undefined> {
293
+ // Check we have not already processed this target slot (cheapest check)
294
+ // We only check this if enforce timetable is set, since we want to keep processing the same slot if we are not
295
+ // running against actual time (eg when we use sandbox-style automining)
296
+ if (
297
+ this.lastSlotForCheckpointProposalJob &&
298
+ this.lastSlotForCheckpointProposalJob >= targetSlot &&
299
+ this.config.enforceTimeTable
300
+ ) {
301
+ this.log.trace(`Target slot ${targetSlot} has already been processed`);
302
+ return undefined;
303
+ }
304
+
305
+ // But if we have already proposed for this slot, then we definitely have to skip it, automining or not
306
+ if (this.lastCheckpointProposed && this.lastCheckpointProposed.header.slotNumber >= targetSlot) {
307
+ this.log.trace(
308
+ `Slot ${targetSlot} has already been published as checkpoint ${this.lastCheckpointProposed.number}`,
309
+ );
310
+ return undefined;
311
+ }
312
+
313
+ // Test-only: skip proposing for explicitly paused slots. Attestation paths run in the validator
314
+ // client and are not gated by this hook, so paused proposers still attest to others' proposals.
315
+ if (this.config.pauseProposingForSlots?.some(s => s === targetSlot)) {
316
+ this.log.warn(`Skipping proposal for paused slot ${targetSlot} (test-only pauseProposingForSlots hook)`, {
317
+ targetSlot,
318
+ });
319
+ return undefined;
320
+ }
321
+
266
322
  // Check all components are synced to latest as seen by the archiver (queries all subsystems)
267
323
  const syncedTo = await this.checkSync({ ts, slot });
268
324
  if (!syncedTo) {
269
- await this.tryVoteWhenSyncFails({ slot, ts });
270
- return;
325
+ await this.tryVoteWhenSyncFails({ slot, targetSlot, ts });
326
+ return undefined;
327
+ }
328
+
329
+ // If escape hatch is open for the target epoch, do not start checkpoint proposal work and do not attempt invalidations.
330
+ // Still perform governance/slashing voting (as proposer) once per slot.
331
+ // When pipelining, we check the target epoch (slot+1's epoch) since that's the epoch we're building for.
332
+ const isEscapeHatchOpen = await this.epochCache.isEscapeHatchOpen(targetEpoch);
333
+
334
+ if (isEscapeHatchOpen) {
335
+ this.setState(SequencerState.PROPOSER_CHECK, slot);
336
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
337
+ if (canPropose) {
338
+ await this.tryVoteWhenEscapeHatchOpen({ slot, targetSlot, proposer });
339
+ } else {
340
+ this.log.trace(`Escape hatch open but we are not proposer, skipping vote-only actions`, {
341
+ slot,
342
+ epoch,
343
+ proposer,
344
+ });
345
+ }
346
+ return undefined;
271
347
  }
272
348
 
273
- const chainTipArchive = syncedTo.archive;
274
- const newBlockNumber = syncedTo.blockNumber + 1;
275
-
276
- const syncLogData = {
277
- now,
278
- syncedToL1Ts: syncedTo.l1Timestamp,
279
- syncedToL2Slot: getSlotAtTimestamp(syncedTo.l1Timestamp, this.l1Constants),
280
- nextL2Slot: slot,
281
- nextL2SlotTs: ts,
282
- l1SlotDuration: this.l1Constants.ethereumSlotDuration,
283
- newBlockNumber,
349
+ // Next checkpoint follows from the last synced one
350
+ const checkpointNumber = CheckpointNumber(syncedTo.checkpointNumber + 1);
351
+
352
+ const logCtx = {
353
+ nowSeconds,
354
+ syncedToL2Slot: syncedTo.syncedL2Slot,
355
+ slot,
356
+ targetSlot,
357
+ slotTs: ts,
358
+ checkpointNumber,
284
359
  isPendingChainValid: pick(syncedTo.pendingChainValidationStatus, 'valid', 'reason', 'invalidIndex'),
285
360
  };
286
361
 
287
- // Check that we are a proposer for the next slot
362
+ // Check that we are a proposer for the target slot.
288
363
  this.setState(SequencerState.PROPOSER_CHECK, slot);
289
- const [canPropose, proposer] = await this.checkCanPropose(slot);
364
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
290
365
 
291
- // If we are not a proposer, check if we should invalidate a invalid block, and bail
366
+ // If we are not a proposer check if we should invalidate an invalid checkpoint, and bail
292
367
  if (!canPropose) {
293
- await this.considerInvalidatingBlock(syncedTo, slot);
294
- return;
368
+ await this.considerInvalidatingCheckpoint(syncedTo, slot);
369
+ return undefined;
295
370
  }
296
371
 
297
- // Check that the slot is not taken by a block already (should never happen, since only us can propose for this slot)
298
- if (syncedTo.block && syncedTo.block.header.getSlot() >= slot) {
372
+ // Guard: don't exceed 1-deep pipeline. Without a proposed checkpoint, we can only build
373
+ // confirmed + 1. With a proposed checkpoint, we can build confirmed + 2.
374
+ const confirmedCkpt = syncedTo.checkpointedCheckpointNumber;
375
+ if (checkpointNumber > confirmedCkpt + 2) {
376
+ this.log.verbose(
377
+ `Skipping slot ${targetSlot}: checkpoint ${checkpointNumber} exceeds max pipeline depth (confirmed=${confirmedCkpt})`,
378
+ );
379
+ return undefined;
380
+ }
381
+
382
+ // Check that the target slot is not taken by a block already (should never happen, since only us can propose for this slot)
383
+ if (syncedTo.blockData && syncedTo.blockData.header.getSlot() >= targetSlot) {
299
384
  this.log.warn(
300
- `Cannot propose block at next L2 slot ${slot} since that slot was taken by block ${syncedTo.blockNumber}`,
301
- { ...syncLogData, block: syncedTo.block.header.toInspect() },
385
+ `Cannot propose block at target slot ${targetSlot} since that slot was taken by block ${syncedTo.blockNumber}`,
386
+ { ...logCtx, block: syncedTo.blockData.header.toInspect() },
302
387
  );
303
- return;
388
+ this.metrics.recordCheckpointPrecheckFailed('slot_already_taken');
389
+ return undefined;
304
390
  }
305
391
 
306
392
  // We now need to get ourselves a publisher.
307
393
  // The returned attestor will be the one we provided if we provided one.
308
394
  // Otherwise it will be a valid attestor for the returned publisher.
309
- const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
395
+ // In fisherman mode, pass undefined to use the fisherman's own keystore instead of the actual proposer's
396
+ const proposerForPublisher = this.config.fishermanMode ? undefined : proposer;
397
+ const { attestorAddress, publisher } = await this.publisherFactory.create(proposerForPublisher);
310
398
  this.log.verbose(`Created publisher at address ${publisher.getSenderAddress()} for attestor ${attestorAddress}`);
311
- this.publisher = publisher;
312
399
 
313
- const coinbase = this.validatorClient!.getCoinbaseForAttestor(attestorAddress);
314
- const feeRecipient = this.validatorClient!.getFeeRecipientForAttestor(attestorAddress);
400
+ // Prepare invalidation request if the pending chain is invalid (returns undefined if no need).
401
+ // Only simulate invalidation when there's no proposed parent, since we assume the proposed parent
402
+ // will invalidate the currently invalid checkpoint on L1.
403
+ const invalidateCheckpoint =
404
+ syncedTo.hasProposedCheckpoint || syncedTo.pendingChainValidationStatus.valid
405
+ ? undefined
406
+ : await publisher.simulateInvalidateCheckpoint(syncedTo.pendingChainValidationStatus);
407
+
408
+ // Determine the correct archive and L1 state overrides for the canProposeAt check.
409
+ // The L1 contract reads archives[proposedCheckpointNumber] and compares it with the provided archive.
410
+ // When invalidating or pipelining, the local archive may differ from L1's, so we adjust accordingly.
411
+ let archiveForCheck = syncedTo.archive;
412
+
413
+ if (syncedTo.hasProposedCheckpoint) {
414
+ this.metrics.recordPipelineDepth(syncedTo.checkpointNumber - syncedTo.checkpointedCheckpointNumber);
415
+ this.log.verbose(
416
+ `Building on top of proposed checkpoint (pending=${syncedTo.proposedCheckpointData?.checkpointNumber}) for target slot ${targetSlot}`,
417
+ { targetSlot, parentCheckpointNumber: CheckpointNumber(checkpointNumber - 1) },
418
+ );
419
+ // Match what L1 will see at archives[pending] once the proposed parent lands: the parent's
420
+ // own archive root from the gossiped proposal. `syncedTo.archive` is the world-state-local
421
+ // view and can transiently diverge from the proposed parent (e.g. before the proposed
422
+ // parent's blocks have been applied locally); diverging here would cause the canProposeAt
423
+ // override to set archives[pending] to one value while we present another for comparison.
424
+ archiveForCheck = syncedTo.proposedCheckpointData!.archive.root;
425
+ } else if (invalidateCheckpoint) {
426
+ // After invalidation, L1 will roll back to checkpoint N-1. The archive at N-1 already
427
+ // exists on L1, so we just pass the matching archive (the lastArchive of the invalid checkpoint).
428
+ archiveForCheck = invalidateCheckpoint.lastArchive;
429
+ this.metrics.recordPipelineDepth(0);
430
+ } else {
431
+ this.metrics.recordPipelineDepth(0);
432
+ }
315
433
 
316
- // Prepare invalidation request if the pending chain is invalid (returns undefined if no need)
317
- const invalidateBlock = await publisher.simulateInvalidateBlock(syncedTo.pendingChainValidationStatus);
434
+ // Build the simulation plan: pending/proven override from pipelining or invalidation (or the
435
+ // current snapshot when neither applies, to short-circuit any pending prune in simulation),
436
+ // plus the parent checkpoint cell and fee header when pipelining.
437
+ const simulationOverridesPlan = await buildCheckpointSimulationOverridesPlan({
438
+ checkpointNumber,
439
+ proposedCheckpointData: syncedTo.hasProposedCheckpoint ? syncedTo.proposedCheckpointData : undefined,
440
+ invalidateToPendingCheckpointNumber: invalidateCheckpoint?.forcePendingCheckpointNumber,
441
+ checkpointedCheckpointNumber: syncedTo.checkpointedCheckpointNumber,
442
+ rollup: this.rollupContract,
443
+ signatureContext: this.signatureContext,
444
+ log: this.log,
445
+ });
318
446
 
319
- // Check with the rollup if we can indeed propose at the next L2 slot. This check should not fail
320
- // if all the previous checks are good, but we do it just in case.
321
- const canProposeCheck = await publisher.canProposeAtNextEthBlock(
322
- chainTipArchive,
447
+ // The plan always pins both pending/proven (to short-circuit `canPruneAtTime` in simulation),
448
+ // so `provenOverride` always reflects the assumed proven checkpoint we are pinning the
449
+ // simulation to. We additionally warn when the pin is load-bearing — i.e. when a prune would
450
+ // actually fire at the target slot without it — so observers can spot "we are building
451
+ // optimistically across a pruning boundary" in the logs.
452
+ const provenOverride = simulationOverridesPlan?.chainTipsOverride?.proven;
453
+ if (provenOverride !== undefined && (await this.l2BlockSource.isPruneDueAtSlot(targetSlot))) {
454
+ this.log.warn(
455
+ `Assuming proof for epoch ending at checkpoint ${provenOverride} lands by target slot ${targetSlot}`,
456
+ { checkpointNumber, slot, targetSlot, provenOverride },
457
+ );
458
+ }
459
+
460
+ this.emit('preparing-checkpoint', {
461
+ targetSlot,
462
+ checkpointNumber,
463
+ hadProposedParent: syncedTo.hasProposedCheckpoint,
464
+ provenOverride,
465
+ simulatedPending: simulationOverridesPlan?.chainTipsOverride?.pending,
466
+ });
467
+
468
+ const canProposeCheck = await publisher.canProposeAt(
469
+ archiveForCheck,
323
470
  proposer ?? EthAddress.ZERO,
324
- invalidateBlock,
471
+ simulationOverridesPlan,
325
472
  );
326
473
 
474
+ const proposeContext = {
475
+ hasProposedCheckpoint: syncedTo.hasProposedCheckpoint,
476
+ proposedCheckpointNumber: syncedTo.proposedCheckpointData?.checkpointNumber,
477
+ checkpointedCheckpointNumber: syncedTo.checkpointedCheckpointNumber,
478
+ isInvalidating: !!invalidateCheckpoint,
479
+ invalidatingCheckpointNumber: invalidateCheckpoint?.checkpointNumber,
480
+ archiveForCheck: archiveForCheck.toString(),
481
+ overridePendingCheckpointNumber: simulationOverridesPlan?.chainTipsOverride?.pending,
482
+ overrideArchive: simulationOverridesPlan?.pendingCheckpointState?.archive,
483
+ overrideFeeHeader: simulationOverridesPlan?.pendingCheckpointState?.feeHeader,
484
+ };
485
+
327
486
  if (canProposeCheck === undefined) {
328
487
  this.log.warn(
329
- `Cannot propose block ${newBlockNumber} at slot ${slot} due to failed rollup contract check`,
330
- syncLogData,
488
+ `Cannot propose checkpoint ${checkpointNumber} at slot ${slot} due to failed rollup contract check`,
489
+ { ...logCtx, ...proposeContext },
331
490
  );
332
- this.emit('proposer-rollup-check-failed', { reason: 'Rollup contract check failed' });
333
- return;
334
- } else if (canProposeCheck.slot !== slot) {
491
+ this.emit('proposer-rollup-check-failed', { reason: 'Rollup contract check failed', slot });
492
+ this.metrics.recordCheckpointPrecheckFailed('rollup_contract_check_failed');
493
+ return undefined;
494
+ }
495
+
496
+ if (canProposeCheck.slot !== targetSlot) {
335
497
  this.log.warn(
336
- `Cannot propose block due to slot mismatch with rollup contract (this can be caused by a clock out of sync). Expected slot ${slot} but got ${canProposeCheck.slot}.`,
337
- { ...syncLogData, rollup: canProposeCheck, newBlockNumber, expectedSlot: slot },
498
+ `Cannot propose block due to slot mismatch with rollup contract (this can be caused by a clock out of sync). Expected slot ${targetSlot} but got ${canProposeCheck.slot}.`,
499
+ { ...logCtx, ...proposeContext, rollup: canProposeCheck, expectedSlot: targetSlot },
338
500
  );
339
- this.emit('proposer-rollup-check-failed', { reason: 'Slot mismatch' });
340
- return;
341
- } else if (canProposeCheck.blockNumber !== BigInt(newBlockNumber)) {
501
+ this.emit('proposer-rollup-check-failed', { reason: 'Slot mismatch', slot });
502
+ this.metrics.recordCheckpointPrecheckFailed('slot_mismatch');
503
+ return undefined;
504
+ }
505
+
506
+ if (canProposeCheck.checkpointNumber !== checkpointNumber) {
342
507
  this.log.warn(
343
- `Cannot propose block due to block mismatch with rollup contract (this can be caused by a pending archiver sync). Expected block ${newBlockNumber} but got ${canProposeCheck.blockNumber}.`,
344
- { ...syncLogData, rollup: canProposeCheck, newBlockNumber, expectedSlot: slot },
508
+ `Cannot propose due to block mismatch with rollup contract (this can be caused by a pending archiver sync). Expected checkpoint ${checkpointNumber} but got ${canProposeCheck.checkpointNumber}.`,
509
+ { ...logCtx, ...proposeContext, rollup: canProposeCheck, expectedSlot: slot },
345
510
  );
346
- this.emit('proposer-rollup-check-failed', { reason: 'Block mismatch' });
347
- return;
511
+ this.emit('proposer-rollup-check-failed', { reason: 'Block mismatch', slot });
512
+ this.metrics.recordCheckpointPrecheckFailed('block_number_mismatch');
513
+ return undefined;
348
514
  }
349
515
 
350
- this.log.debug(`Can propose block ${newBlockNumber} at slot ${slot} as ${proposer}`, { ...syncLogData });
516
+ this.lastSlotForCheckpointProposalJob = targetSlot;
351
517
 
352
- const newGlobalVariables = await this.globalsBuilder.buildGlobalVariables(
353
- newBlockNumber,
354
- coinbase,
355
- feeRecipient,
356
- slot,
357
- );
358
-
359
- // Enqueue governance and slashing votes (returns promises that will be awaited later)
360
- const votesPromises = this.enqueueGovernanceAndSlashingVotes(
361
- publisher,
362
- attestorAddress,
363
- slot,
364
- newGlobalVariables.timestamp,
518
+ await this.p2pClient.prepareForSlot(targetSlot);
519
+ this.log.info(
520
+ `Preparing checkpoint proposal ${checkpointNumber} for target slot ${targetSlot} during wall-clock slot ${slot}`,
521
+ {
522
+ ...logCtx,
523
+ ...proposeContext,
524
+ proposer,
525
+ },
365
526
  );
366
527
 
367
- // Enqueues block invalidation
368
- if (invalidateBlock && !this.config.skipInvalidateBlockAsProposer) {
369
- publisher.enqueueInvalidateBlock(invalidateBlock);
370
- }
371
-
372
- // Actual block building
373
- this.setState(SequencerState.INITIALIZING_PROPOSAL, slot);
374
- const block: L2Block | undefined = await this.tryBuildBlockAndEnqueuePublish(
528
+ // Create and return the checkpoint proposal job
529
+ return this.createCheckpointProposalJob(
375
530
  slot,
531
+ targetSlot,
532
+ targetEpoch,
533
+ checkpointNumber,
534
+ syncedTo.blockNumber,
535
+ syncedTo.checkpointedCheckpointNumber,
376
536
  proposer,
377
- newBlockNumber,
378
537
  publisher,
379
- newGlobalVariables,
380
- chainTipArchive,
381
- invalidateBlock,
538
+ attestorAddress,
539
+ invalidateCheckpoint,
540
+ syncedTo.proposedCheckpointData,
382
541
  );
383
-
384
- // Wait until the voting promises have resolved, so all requests are enqueued
385
- await Promise.all(votesPromises);
386
-
387
- // And send the tx to L1
388
- const l1Response = await publisher.sendRequests();
389
- const proposedBlock = l1Response?.successfulActions.find(a => a === 'propose');
390
- if (proposedBlock) {
391
- this.lastBlockPublished = block;
392
- this.emit('block-published', { blockNumber: newBlockNumber, slot: Number(slot) });
393
- await this.metrics.incFilledSlot(publisher.getSenderAddress().toString(), coinbase);
394
- } else if (block) {
395
- this.emit('block-publish-failed', l1Response ?? {});
396
- }
397
-
398
- this.setState(SequencerState.IDLE, undefined);
399
542
  }
400
543
 
401
- /** Tries building a block proposal, and if successful, enqueues it for publishing. */
402
- private async tryBuildBlockAndEnqueuePublish(
403
- slot: bigint,
544
+ protected createCheckpointProposalJob(
545
+ slot: SlotNumber,
546
+ targetSlot: SlotNumber,
547
+ targetEpoch: EpochNumber,
548
+ checkpointNumber: CheckpointNumber,
549
+ syncedToBlockNumber: BlockNumber,
550
+ checkpointedCheckpointNumber: CheckpointNumber,
404
551
  proposer: EthAddress | undefined,
405
- newBlockNumber: number,
406
552
  publisher: SequencerPublisher,
407
- newGlobalVariables: GlobalVariables,
408
- chainTipArchive: Fr,
409
- invalidateBlock: InvalidateBlockRequest | undefined,
410
- ) {
411
- this.log.verbose(`Preparing proposal for block ${newBlockNumber} at slot ${slot}`, {
412
- proposer,
413
- publisher: publisher.getSenderAddress(),
414
- globalVariables: newGlobalVariables.toInspect(),
415
- chainTipArchive,
416
- blockNumber: newBlockNumber,
553
+ attestorAddress: EthAddress,
554
+ invalidateCheckpoint: InvalidateCheckpointRequest | undefined,
555
+ proposedCheckpointData?: ProposedCheckpointData,
556
+ ): CheckpointProposalJob {
557
+ return new CheckpointProposalJob(
417
558
  slot,
418
- });
419
-
420
- const proposalHeader = CheckpointHeader.from({
421
- ...newGlobalVariables,
422
- timestamp: newGlobalVariables.timestamp,
423
- lastArchiveRoot: chainTipArchive,
424
- contentCommitment: ContentCommitment.empty(),
425
- totalManaUsed: Fr.ZERO,
426
- });
427
-
428
- let block: L2Block | undefined;
429
-
430
- const pendingTxCount = await this.p2pClient.getPendingTxCount();
431
- if (pendingTxCount >= this.minTxsPerBlock) {
432
- // We don't fetch exactly maxTxsPerBlock txs here because we may not need all of them if we hit a limit before,
433
- // and also we may need to fetch more if we don't have enough valid txs.
434
- const pendingTxs = this.p2pClient.iteratePendingTxs();
435
- try {
436
- block = await this.buildBlockAndEnqueuePublish(
437
- pendingTxs,
438
- proposalHeader,
439
- newGlobalVariables,
440
- proposer,
441
- invalidateBlock,
442
- publisher,
443
- );
444
- } catch (err: any) {
445
- this.emit('block-build-failed', { reason: err.message });
446
- if (err instanceof FormattedViemError) {
447
- this.log.verbose(`Unable to build/enqueue block ${err.message}`);
448
- } else {
449
- this.log.error(`Error building/enqueuing block`, err, { blockNumber: newBlockNumber, slot });
450
- }
451
- }
452
- } else {
453
- this.log.verbose(
454
- `Not enough txs to build block ${newBlockNumber} at slot ${slot} (got ${pendingTxCount} txs, need ${this.minTxsPerBlock})`,
455
- { chainTipArchive, blockNumber: newBlockNumber, slot },
456
- );
457
- this.emit('tx-count-check-failed', { minTxs: this.minTxsPerBlock, availableTxs: pendingTxCount });
458
- }
459
- return block;
559
+ targetSlot,
560
+ targetEpoch,
561
+ checkpointNumber,
562
+ syncedToBlockNumber,
563
+ checkpointedCheckpointNumber,
564
+ proposer,
565
+ publisher,
566
+ attestorAddress,
567
+ invalidateCheckpoint,
568
+ this.validatorClient,
569
+ this.globalsBuilder,
570
+ this.p2pClient,
571
+ this.worldState,
572
+ this.l1ToL2MessageSource,
573
+ this.l2BlockSource,
574
+ this.checkpointsBuilder,
575
+ this.l2BlockSource,
576
+ this.l1Constants,
577
+ this.signatureContext,
578
+ this.config,
579
+ this.timetable,
580
+ this.slasherClient,
581
+ this.epochCache,
582
+ this.dateProvider,
583
+ this.metrics,
584
+ this.checkpointProposalJobMetrics.createRecorder(),
585
+ this,
586
+ (state, slot, timeReferenceSlot) => this.setState(state, slot, { timeReferenceSlot }),
587
+ this.tracer,
588
+ this.log.getBindings(),
589
+ proposedCheckpointData,
590
+ );
460
591
  }
461
592
 
462
- @trackSpan('Sequencer.work')
463
- protected async safeWork() {
464
- try {
465
- await this.work();
466
- } catch (err) {
467
- if (err instanceof SequencerTooSlowError) {
468
- // Log as warn only if we had to abort halfway through the block proposal
469
- const logLvl = [SequencerState.INITIALIZING_PROPOSAL, SequencerState.PROPOSER_CHECK].includes(err.proposedState)
470
- ? ('debug' as const)
471
- : ('warn' as const);
472
- this.log[logLvl](err.message, { now: this.dateProvider.nowInSeconds() });
473
- } else {
474
- // Re-throw other errors
475
- throw err;
476
- }
477
- } finally {
478
- this.setState(SequencerState.IDLE, undefined);
479
- }
593
+ /**
594
+ * Returns the current sequencer state.
595
+ */
596
+ public getState(): SequencerState {
597
+ return this.state;
480
598
  }
481
599
 
482
600
  /**
483
- * Sets the sequencer state and checks if we have enough time left in the slot to transition to the new state.
601
+ * Internal helper for setting the sequencer state and checks if we have enough time left in the slot to transition to the new state.
484
602
  * @param proposedState - The new state to transition to.
485
- * @param slotNumber - The current slot number.
603
+ * @param slotNumber - The logical slot associated with the new state.
486
604
  * @param force - Whether to force the transition even if the sequencer is stopped.
605
+ * @param timeReferenceSlot - The slot to use for deadline checks when it differs from the logical slot.
487
606
  */
488
- setState(proposedState: SequencerStateWithSlot, slotNumber: bigint, opts?: { force?: boolean }): void;
489
- setState(
490
- proposedState: Exclude<SequencerState, SequencerStateWithSlot>,
491
- slotNumber?: undefined,
492
- opts?: { force?: boolean },
493
- ): void;
494
- setState(proposedState: SequencerState, slotNumber: bigint | undefined, opts: { force?: boolean } = {}): void {
607
+ protected setState(
608
+ proposedState: SequencerState,
609
+ slotNumber: SlotNumber | undefined,
610
+ opts: { force?: boolean; timeReferenceSlot?: SlotNumber } = {},
611
+ ): void {
495
612
  if (this.state === SequencerState.STOPPING && proposedState !== SequencerState.STOPPED && !opts.force) {
496
613
  this.log.warn(`Cannot set sequencer to ${proposedState} as it is stopping.`);
497
614
  throw new SequencerInterruptedError();
@@ -501,324 +618,60 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
501
618
  return;
502
619
  }
503
620
  let secondsIntoSlot = undefined;
504
- if (slotNumber !== undefined) {
505
- secondsIntoSlot = this.getSecondsIntoSlot(slotNumber);
621
+ const timeReferenceSlot = opts.timeReferenceSlot ?? slotNumber;
622
+ if (timeReferenceSlot !== undefined) {
623
+ secondsIntoSlot = this.getSecondsIntoSlot(timeReferenceSlot);
506
624
  this.timetable.assertTimeLeft(proposedState, secondsIntoSlot);
507
625
  }
508
626
 
627
+ const oldState = this.state;
628
+ const oldStateSlotNumber = this.stateSlotNumber;
629
+ const stateChanged = proposedState !== oldState;
630
+ const transitionAtMs = performance.now();
631
+ const stateDurationMs = transitionAtMs - this.stateEnteredAtMs;
632
+
509
633
  const boringStates = [SequencerState.IDLE, SequencerState.SYNCHRONIZING];
510
634
  const logLevel =
511
- boringStates.includes(proposedState) && boringStates.includes(this.state)
512
- ? ('trace' as const)
513
- : ('debug' as const);
514
- this.log[logLevel](`Transitioning from ${this.state} to ${proposedState}`, { slotNumber, secondsIntoSlot });
635
+ boringStates.includes(proposedState) && boringStates.includes(oldState) ? ('trace' as const) : ('debug' as const);
636
+ this.stateLog[logLevel](`Transitioning from ${oldState} to ${proposedState}`, {
637
+ oldState,
638
+ newState: proposedState,
639
+ slotNumber,
640
+ timeReferenceSlot,
641
+ stateSlotNumber: oldStateSlotNumber,
642
+ secondsIntoSlot,
643
+ ...(stateChanged && { stateDurationMs: Math.ceil(stateDurationMs) }),
644
+ });
515
645
 
516
646
  this.emit('state-changed', {
517
- oldState: this.state,
647
+ oldState,
518
648
  newState: proposedState,
519
649
  secondsIntoSlot,
520
- slotNumber,
650
+ slot: slotNumber,
651
+ timeReferenceSlot,
521
652
  });
522
- this.state = proposedState;
523
- }
524
-
525
- private async dropFailedTxsFromP2P(failedTxs: FailedTx[]) {
526
- if (failedTxs.length === 0) {
527
- return;
528
- }
529
- const failedTxData = failedTxs.map(fail => fail.tx);
530
- const failedTxHashes = failedTxData.map(tx => tx.getTxHash());
531
- this.log.verbose(`Dropping failed txs ${failedTxHashes.join(', ')}`);
532
- await this.p2pClient.deleteTxs(failedTxHashes);
533
- }
534
-
535
- protected getBlockBuilderOptions(slot: number): PublicProcessorLimits {
536
- // Deadline for processing depends on whether we're proposing a block
537
- const secondsIntoSlot = this.getSecondsIntoSlot(slot);
538
- const processingEndTimeWithinSlot = this.timetable.getBlockProposalExecTimeEnd(secondsIntoSlot);
539
-
540
- // Deadline is only set if enforceTimeTable is enabled.
541
- const deadline = this.enforceTimeTable
542
- ? new Date((this.getSlotStartBuildTimestamp(slot) + processingEndTimeWithinSlot) * 1000)
543
- : undefined;
544
- return {
545
- maxTransactions: this.maxTxsPerBlock,
546
- maxBlockSize: this.maxBlockSizeInBytes,
547
- maxBlockGas: this.maxBlockGas,
548
- maxBlobFields: BLOBS_PER_BLOCK * FIELDS_PER_BLOB,
549
- deadline,
550
- };
551
- }
552
-
553
- /**
554
- * @notice Build and propose a block to the chain
555
- *
556
- * @dev MUST throw instead of exiting early to ensure that world-state
557
- * is being rolled back if the block is dropped.
558
- *
559
- * @param pendingTxs - Iterable of pending transactions to construct the block from
560
- * @param proposalHeader - The partial header constructed for the proposal
561
- * @param newGlobalVariables - The global variables for the new block
562
- * @param proposerAddress - The address of the proposer
563
- */
564
- @trackSpan('Sequencer.buildBlockAndEnqueuePublish', (_validTxs, _proposalHeader, newGlobalVariables) => ({
565
- [Attributes.BLOCK_NUMBER]: newGlobalVariables.blockNumber,
566
- }))
567
- private async buildBlockAndEnqueuePublish(
568
- pendingTxs: Iterable<Tx> | AsyncIterable<Tx>,
569
- proposalHeader: CheckpointHeader,
570
- newGlobalVariables: GlobalVariables,
571
- proposerAddress: EthAddress | undefined,
572
- invalidateBlock: InvalidateBlockRequest | undefined,
573
- publisher: SequencerPublisher,
574
- ): Promise<L2Block> {
575
- await publisher.validateBlockHeader(proposalHeader, invalidateBlock);
576
-
577
- const blockNumber = newGlobalVariables.blockNumber;
578
- const slot = proposalHeader.slotNumber.toBigInt();
579
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(blockNumber);
580
-
581
- const workTimer = new Timer();
582
- this.setState(SequencerState.CREATING_BLOCK, slot);
583
-
584
- try {
585
- const blockBuilderOptions = this.getBlockBuilderOptions(Number(slot));
586
- const buildBlockRes = await this.blockBuilder.buildBlock(
587
- pendingTxs,
588
- l1ToL2Messages,
589
- newGlobalVariables,
590
- blockBuilderOptions,
591
- );
592
- const { publicGas, block, publicProcessorDuration, numTxs, numMsgs, blockBuildingTimer, usedTxs, failedTxs } =
593
- buildBlockRes;
594
- const blockBuildDuration = workTimer.ms();
595
- await this.dropFailedTxsFromP2P(failedTxs);
596
-
597
- const minTxsPerBlock = this.minTxsPerBlock;
598
- if (numTxs < minTxsPerBlock) {
599
- this.log.warn(
600
- `Block ${blockNumber} has too few txs to be proposed (got ${numTxs} but required ${minTxsPerBlock})`,
601
- { slot, blockNumber, numTxs },
602
- );
603
- throw new Error(`Block has too few successful txs to be proposed`);
604
- }
605
-
606
- // TODO(@PhilWindle) We should probably periodically check for things like another
607
- // block being published before ours instead of just waiting on our block
608
- await publisher.validateBlockHeader(block.getCheckpointHeader(), invalidateBlock);
609
-
610
- const blockStats: L2BlockBuiltStats = {
611
- eventName: 'l2-block-built',
612
- creator: proposerAddress?.toString() ?? publisher.getSenderAddress().toString(),
613
- duration: workTimer.ms(),
614
- publicProcessDuration: publicProcessorDuration,
615
- rollupCircuitsDuration: blockBuildingTimer.ms(),
616
- ...block.getStats(),
617
- };
618
-
619
- const blockHash = await block.hash();
620
- const txHashes = block.body.txEffects.map(tx => tx.txHash);
621
- this.log.info(
622
- `Built block ${block.number} for slot ${slot} with ${numTxs} txs and ${numMsgs} messages. ${
623
- publicGas.l2Gas / workTimer.s()
624
- } mana/s`,
625
- {
626
- blockHash,
627
- globalVariables: block.header.globalVariables.toInspect(),
628
- txHashes,
629
- ...blockStats,
630
- },
631
- );
632
-
633
- this.log.debug('Collecting attestations');
634
- const attestations = await this.collectAttestations(block, usedTxs, proposerAddress);
635
- if (attestations !== undefined) {
636
- this.log.verbose(`Collected ${attestations.length} attestations`, { blockHash, blockNumber });
637
- }
638
-
639
- const attestationsAndSigners = new CommitteeAttestationsAndSigners(attestations ?? []);
640
- const attestationsAndSignersSignature = this.validatorClient
641
- ? await this.validatorClient.signAttestationsAndSigners(
642
- attestationsAndSigners,
643
- proposerAddress ?? publisher.getSenderAddress(),
644
- )
645
- : Signature.empty();
646
-
647
- await this.enqueuePublishL2Block(
648
- block,
649
- attestationsAndSigners,
650
- attestationsAndSignersSignature,
651
- invalidateBlock,
652
- publisher,
653
- );
654
- this.metrics.recordBuiltBlock(blockBuildDuration, publicGas.l2Gas);
655
- return block;
656
- } catch (err) {
657
- this.metrics.recordFailedBlock();
658
- throw err;
659
- }
660
- }
661
-
662
- @trackSpan('Sequencer.collectAttestations', (block, txHashes) => ({
663
- [Attributes.BLOCK_NUMBER]: block.number,
664
- [Attributes.BLOCK_ARCHIVE]: block.archive.toString(),
665
- [Attributes.BLOCK_TXS_COUNT]: txHashes.length,
666
- }))
667
- protected async collectAttestations(
668
- block: L2Block,
669
- txs: Tx[],
670
- proposerAddress: EthAddress | undefined,
671
- ): Promise<CommitteeAttestation[] | undefined> {
672
- const { committee } = await this.epochCache.getCommittee(block.header.getSlot());
673
-
674
- // We checked above that the committee is defined, so this should never happen.
675
- if (!committee) {
676
- throw new Error('No committee when collecting attestations');
677
- }
678
-
679
- if (committee.length === 0) {
680
- this.log.verbose(`Attesting committee is empty`);
681
- return undefined;
682
- } else {
683
- this.log.debug(`Attesting committee length is ${committee.length}`);
684
- }
685
-
686
- if (!this.validatorClient) {
687
- const msg = 'Missing validator client: Cannot collect attestations';
688
- this.log.error(msg);
689
- throw new Error(msg);
690
- }
691
-
692
- const numberOfRequiredAttestations = Math.floor((committee.length * 2) / 3) + 1;
693
-
694
- const slotNumber = block.header.globalVariables.slotNumber.toBigInt();
695
- this.setState(SequencerState.COLLECTING_ATTESTATIONS, slotNumber);
696
-
697
- this.log.debug('Creating block proposal for validators');
698
- const blockProposalOptions: BlockProposalOptions = {
699
- publishFullTxs: !!this.config.publishTxsWithProposals,
700
- broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal,
701
- };
702
- const proposal = await this.validatorClient.createBlockProposal(
703
- block.header.globalVariables.blockNumber,
704
- block.getCheckpointHeader(),
705
- block.archive.root,
706
- block.header.state,
707
- txs,
708
- proposerAddress,
709
- blockProposalOptions,
710
- );
711
-
712
- if (!proposal) {
713
- throw new Error(`Failed to create block proposal`);
714
- }
715
-
716
- if (this.config.skipCollectingAttestations) {
717
- this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
718
- const attestations = await this.validatorClient?.collectOwnAttestations(proposal);
719
- return orderAttestations(attestations ?? [], committee);
720
- }
721
-
722
- this.log.debug('Broadcasting block proposal to validators');
723
- await this.validatorClient.broadcastBlockProposal(proposal);
724
-
725
- const attestationTimeAllowed = this.enforceTimeTable
726
- ? this.timetable.getMaxAllowedTime(SequencerState.PUBLISHING_BLOCK)!
727
- : this.aztecSlotDuration;
728
-
729
- this.metrics.recordRequiredAttestations(numberOfRequiredAttestations, attestationTimeAllowed);
730
-
731
- const timer = new Timer();
732
- let collectedAttestationsCount: number = 0;
733
- try {
734
- const attestationDeadline = new Date(this.dateProvider.now() + attestationTimeAllowed * 1000);
735
- const attestations = await this.validatorClient.collectAttestations(
736
- proposal,
737
- numberOfRequiredAttestations,
738
- attestationDeadline,
739
- );
740
-
741
- collectedAttestationsCount = attestations.length;
742
-
743
- // note: the smart contract requires that the signatures are provided in the order of the committee
744
- const sorted = orderAttestations(attestations, committee);
745
- if (this.config.injectFakeAttestation) {
746
- const nonEmpty = sorted.filter(a => !a.signature.isEmpty());
747
- const randomIndex = randomInt(nonEmpty.length);
748
- this.log.warn(`Injecting fake attestation in block ${block.number}`);
749
- unfreeze(nonEmpty[randomIndex]).signature = Signature.random();
750
- }
751
- return sorted;
752
- } catch (err) {
753
- if (err && err instanceof AttestationTimeoutError) {
754
- collectedAttestationsCount = err.collectedCount;
755
- }
756
- throw err;
757
- } finally {
758
- this.metrics.recordCollectedAttestations(collectedAttestationsCount, timer.ms());
759
- }
760
- }
761
-
762
- /**
763
- * Publishes the L2Block to the rollup contract.
764
- * @param block - The L2Block to be published.
765
- */
766
- @trackSpan('Sequencer.enqueuePublishL2Block', block => ({
767
- [Attributes.BLOCK_NUMBER]: block.number,
768
- }))
769
- protected async enqueuePublishL2Block(
770
- block: L2Block,
771
- attestationsAndSigners: CommitteeAttestationsAndSigners,
772
- attestationsAndSignersSignature: Signature,
773
- invalidateBlock: InvalidateBlockRequest | undefined,
774
- publisher: SequencerPublisher,
775
- ): Promise<void> {
776
- // Publishes new block to the network and awaits the tx to be mined
777
- this.setState(SequencerState.PUBLISHING_BLOCK, block.header.globalVariables.slotNumber.toBigInt());
778
-
779
- // Time out tx at the end of the slot
780
- const slot = block.header.globalVariables.slotNumber.toNumber();
781
- const txTimeoutAt = new Date((this.getSlotStartBuildTimestamp(slot) + this.aztecSlotDuration) * 1000);
782
-
783
- const enqueued = await publisher.enqueueProposeL2Block(
784
- block,
785
- attestationsAndSigners,
786
- attestationsAndSignersSignature,
787
- {
788
- txTimeoutAt,
789
- forcePendingBlockNumber: invalidateBlock?.forcePendingBlockNumber,
790
- },
791
- );
792
-
793
- if (!enqueued) {
794
- throw new Error(`Failed to enqueue publish of block ${block.number}`);
653
+ if (stateChanged) {
654
+ this.metrics.recordStateDuration(stateDurationMs, oldState);
655
+ this.stateEnteredAtMs = transitionAtMs;
656
+ this.stateSlotNumber = slotNumber;
795
657
  }
658
+ this.state = proposedState;
796
659
  }
797
660
 
798
661
  /**
799
662
  * Returns whether all dependencies have caught up.
800
663
  * We don't check against the previous block submitted since it may have been reorg'd out.
801
664
  */
802
- protected async checkSync(args: { ts: bigint; slot: bigint }): Promise<
803
- | {
804
- block?: L2Block;
805
- blockNumber: number;
806
- archive: Fr;
807
- l1Timestamp: bigint;
808
- pendingChainValidationStatus: ValidateBlockResult;
809
- }
810
- | undefined
811
- > {
812
- // Check that the archiver and dependencies have synced to the previous L1 slot at least
813
- // TODO(#14766): Archiver reports L1 timestamp based on L1 blocks seen, which means that a missed L1 block will
814
- // cause the archiver L1 timestamp to fall behind, and cause this sequencer to start processing one L1 slot later.
815
- const l1Timestamp = await this.l2BlockSource.getL1Timestamp();
816
- const { slot, ts } = args;
817
- if (l1Timestamp === undefined || l1Timestamp + BigInt(this.l1Constants.ethereumSlotDuration) < ts) {
665
+ protected async checkSync(args: { ts: bigint; slot: SlotNumber }): Promise<SequencerSyncCheckResult | undefined> {
666
+ // Check that the archiver has fully synced the L2 slot before the one we want to propose in.
667
+ // The archiver reports sync progress via L1 block timestamps and synced checkpoint slots.
668
+ // See getSyncedL2SlotNumber for how missed L1 blocks are handled.
669
+ const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber();
670
+ const { slot } = args;
671
+ if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) {
818
672
  this.log.debug(`Cannot propose block at next L2 slot ${slot} due to pending sync from L1`, {
819
673
  slot,
820
- ts,
821
- l1Timestamp,
674
+ syncedL2Slot,
822
675
  });
823
676
  return undefined;
824
677
  }
@@ -828,111 +681,162 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
828
681
  number: syncSummary.latestBlockNumber,
829
682
  hash: syncSummary.latestBlockHash,
830
683
  })),
831
- this.l2BlockSource.getL2Tips().then(t => t.latest),
684
+ this.l2BlockSource
685
+ .getL2Tips()
686
+ .then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed, proposedCheckpoint: t.proposedCheckpoint })),
832
687
  this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block),
833
- this.l1ToL2MessageSource.getL2Tips().then(t => t.latest),
688
+ this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })),
834
689
  this.l2BlockSource.getPendingChainValidationStatus(),
690
+ this.l2BlockSource.getProposedCheckpointData(),
835
691
  ] as const);
836
692
 
837
- const [worldState, l2BlockSource, p2p, l1ToL2MessageSource, pendingChainValidationStatus] = syncedBlocks;
693
+ const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] =
694
+ syncedBlocks;
838
695
 
839
- // The archiver reports 'undefined' hash for the genesis block
840
- // because it doesn't have access to world state to compute it (facepalm)
841
696
  const result =
842
- l2BlockSource.hash === undefined
843
- ? worldState.number === 0 && p2p.number === 0 && l1ToL2MessageSource.number === 0
844
- : worldState.hash === l2BlockSource.hash &&
845
- p2p.hash === l2BlockSource.hash &&
846
- l1ToL2MessageSource.hash === l2BlockSource.hash;
697
+ worldState.hash === l2Tips.proposed.hash &&
698
+ p2p.hash === l2Tips.proposed.hash &&
699
+ l1ToL2MessageSourceTips.proposed.hash === l2Tips.proposed.hash &&
700
+ l1ToL2MessageSourceTips.checkpointed.block.hash === l2Tips.checkpointed.block.hash &&
701
+ l1ToL2MessageSourceTips.checkpointed.checkpoint.hash === l2Tips.checkpointed.checkpoint.hash;
847
702
 
848
703
  if (!result) {
849
- this.log.debug(`Sequencer sync check failed`, { worldState, l2BlockSource, p2p, l1ToL2MessageSource });
704
+ this.log.debug(`Sequencer sync check failed`, {
705
+ worldState,
706
+ l2BlockSource: l2Tips.proposed,
707
+ p2p,
708
+ l1ToL2MessageSourceTips,
709
+ });
850
710
  return undefined;
851
711
  }
852
712
 
853
- // Special case for genesis state
854
713
  const blockNumber = worldState.number;
855
- if (blockNumber < INITIAL_L2_BLOCK_NUM) {
856
- const archive = new Fr((await this.worldState.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root);
857
- return { blockNumber: INITIAL_L2_BLOCK_NUM - 1, archive, l1Timestamp, pendingChainValidationStatus };
714
+ const blockData = await this.l2BlockSource.getBlockData({ number: blockNumber });
715
+ if (!blockData) {
716
+ this.log.warn(`Sequencer sync check failed: failed to get L2 block data ${blockNumber} from the archiver`, {
717
+ blockNumber,
718
+ l2Tips,
719
+ syncedL2Slot,
720
+ ...args,
721
+ });
722
+ return undefined;
723
+ }
724
+
725
+ // Refuse to build a checkpoint on top of a proposed block whose enclosing checkpoint was never
726
+ // proposed. Under pipelining we may have received and reexecuted such a block locally — advancing
727
+ // our world-state tip past the checkpointed tip — while the proposing node never published the
728
+ // matching proposed checkpoint (e.g. it crashed before assembling it). Building on this orphan block
729
+ // would fork the chain off a tip no other node can follow. The archiver prunes these orphan blocks
730
+ // once their build slot ends; this guard is the correctness barrier during the grace window before.
731
+ if (
732
+ blockData.checkpointNumber > l2Tips.checkpointed.checkpoint.number &&
733
+ (l2Tips.proposedCheckpoint.checkpoint.number !== blockData.checkpointNumber ||
734
+ proposedCheckpointData?.checkpointNumber !== blockData.checkpointNumber)
735
+ ) {
736
+ const logCtx = {
737
+ blockCheckpointNumber: blockData.checkpointNumber,
738
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
739
+ proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
740
+ proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
741
+ blockNumber: blockData.header.getBlockNumber(),
742
+ blockSlot: blockData.header.getSlot(),
743
+ syncedL2Slot,
744
+ ...args,
745
+ };
746
+
747
+ // Under pipelining the block proposal for a checkpoint leads its checkpoint proposal by up to one
748
+ // slot, so a world-state tip sitting in an as-yet-unproposed checkpoint is the expected steady state
749
+ // until that checkpoint is due. Only treat it as abnormal — and warn — once the checkpoint is overdue
750
+ // by the same deadline the archiver uses to prune the orphan block (see pruneOrphanProposedBlocks).
751
+ // Before then this is normal pipelining and we wait it out quietly.
752
+ if (this.isProposedCheckpointOverdue(blockData.header.getSlot())) {
753
+ this.log.warn(`Sequencer sync check failed: proposed block has no matching proposed checkpoint`, logCtx);
754
+ } else {
755
+ this.log.debug(`Waiting for proposed checkpoint to catch up with reexecuted block`, logCtx);
756
+ }
757
+ return undefined;
758
+ }
759
+
760
+ const hasProposedCheckpoint = l2Tips.proposedCheckpoint.checkpoint.number > l2Tips.checkpointed.checkpoint.number;
761
+
762
+ // The l2Tips and proposedCheckpointData reads above come from independent archiver snapshots
763
+ // (a JS-side tips cache vs. a direct store read on `#proposedCheckpoints`). A concurrent archiver
764
+ // write that mutates both can be observed split, leaving us with `hasProposedCheckpoint=true` but
765
+ // no proposedCheckpointData (or one whose number doesn't match the tip). Refuse to proceed in that
766
+ // window — the next checkSync tick will see a coherent snapshot.
767
+ if (
768
+ hasProposedCheckpoint &&
769
+ (!proposedCheckpointData ||
770
+ proposedCheckpointData.checkpointNumber !== l2Tips.proposedCheckpoint.checkpoint.number)
771
+ ) {
772
+ this.log.warn(`Sequencer sync check failed: inconsistent proposed-checkpoint state`, {
773
+ proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
774
+ checkpointedTipNumber: l2Tips.checkpointed.checkpoint.number,
775
+ proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber,
776
+ syncedL2Slot,
777
+ ...args,
778
+ });
779
+ return undefined;
858
780
  }
859
781
 
860
- const block = await this.l2BlockSource.getBlock(blockNumber);
861
- if (!block) {
862
- // this shouldn't really happen because a moment ago we checked that all components were in sync
863
- this.log.error(`Failed to get L2 block ${blockNumber} from the archiver with all components in sync`);
782
+ // Check that the proposed checkpoint is indeed the parent of the checkpoint we'll be building
783
+ // The checkpoint number to build is derived as blockData.checkpointNumber + 1
784
+ if (proposedCheckpointData && proposedCheckpointData.checkpointNumber !== blockData.checkpointNumber) {
785
+ this.log.warn(`Sequencer sync check failed: proposed checkpoint number mismatch`, {
786
+ proposedCheckpointNumber: proposedCheckpointData.checkpointNumber,
787
+ blockCheckpointNumber: blockData.checkpointNumber,
788
+ syncedL2Slot,
789
+ ...args,
790
+ });
864
791
  return undefined;
865
792
  }
866
793
 
867
794
  return {
868
- block,
869
- blockNumber: block.number,
870
- archive: block.archive.root,
871
- l1Timestamp,
795
+ blockData,
796
+ blockNumber: blockData.header.getBlockNumber(),
797
+ checkpointNumber: blockData.checkpointNumber,
798
+ checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
799
+ archive: blockData.archive.root,
800
+ hasProposedCheckpoint,
801
+ proposedCheckpointData,
802
+ syncedL2Slot,
872
803
  pendingChainValidationStatus,
873
804
  };
874
805
  }
875
806
 
876
807
  /**
877
- * Enqueues governance and slashing votes with the publisher. Does not block.
878
- * @param publisher - The publisher to enqueue votes with
879
- * @param attestorAddress - The attestor address to use for signing
880
- * @param slot - The slot number
881
- * @param timestamp - The timestamp for the votes
882
- * @param context - Optional context for logging (e.g., block number)
883
- * @returns A tuple of [governanceEnqueued, slashingEnqueued]
808
+ * Whether the enclosing checkpoint of a reexecuted block is overdue: past the deadline by which a
809
+ * well-behaved proposer should have published it. Mirrors the archiver's orphan-prune deadline (the
810
+ * start of the slot after the block's build slot, plus a grace period) so the sequencer only warns
811
+ * about a missing proposed checkpoint once the archiver itself would prune the orphan block. The grace
812
+ * is derived from the block build duration the same way the archiver defaults it at node wiring.
884
813
  */
885
- protected enqueueGovernanceAndSlashingVotes(
886
- publisher: SequencerPublisher,
887
- attestorAddress: EthAddress,
888
- slot: bigint,
889
- timestamp: bigint,
890
- ): [Promise<boolean> | undefined, Promise<boolean> | undefined] {
891
- try {
892
- const signerFn = (msg: TypedDataDefinition) =>
893
- this.validatorClient!.signWithAddress(attestorAddress, msg).then(s => s.toString());
894
-
895
- const enqueueGovernancePromise =
896
- this.governanceProposerPayload && !this.governanceProposerPayload.isZero()
897
- ? publisher
898
- .enqueueGovernanceCastSignal(this.governanceProposerPayload, slot, timestamp, attestorAddress, signerFn)
899
- .catch(err => {
900
- this.log.error(`Error enqueuing governance vote`, err, { slot });
901
- return false;
902
- })
903
- : undefined;
904
-
905
- const enqueueSlashingPromise = this.slasherClient
906
- ? this.slasherClient
907
- .getProposerActions(slot)
908
- .then(actions => publisher.enqueueSlashingActions(actions, slot, timestamp, attestorAddress, signerFn))
909
- .catch(err => {
910
- this.log.error(`Error enqueuing slashing actions`, err, { slot });
911
- return false;
912
- })
913
- : undefined;
914
-
915
- return [enqueueGovernancePromise, enqueueSlashingPromise];
916
- } catch (err) {
917
- this.log.error(`Error enqueueing governance and slashing votes`, err);
918
- return [undefined, undefined];
919
- }
814
+ private isProposedCheckpointOverdue(blockSlot: SlotNumber): boolean {
815
+ const expectedBySlot = SlotNumber(Number(blockSlot) - PROPOSER_PIPELINING_SLOT_OFFSET + 1);
816
+ const graceSeconds =
817
+ this.config.blockDurationMs !== undefined ? Math.ceil(this.config.blockDurationMs / 1000) : MIN_EXECUTION_TIME;
818
+ const expectedByTime = getTimestampForSlot(expectedBySlot, this.l1Constants) + BigInt(graceSeconds);
819
+ return BigInt(this.dateProvider.nowInSeconds()) >= expectedByTime;
920
820
  }
921
821
 
922
822
  /**
923
823
  * Checks if we are the proposer for the next slot.
924
824
  * @returns True if we can propose, and the proposer address (undefined if anyone can propose)
925
825
  */
926
- protected async checkCanPropose(slot: bigint): Promise<[boolean, EthAddress | undefined]> {
826
+ protected async checkCanPropose(targetSlot: SlotNumber): Promise<[boolean, EthAddress | undefined]> {
927
827
  let proposer: EthAddress | undefined;
828
+
928
829
  try {
929
- proposer = await this.epochCache.getProposerAttesterAddressInSlot(slot);
830
+ proposer = await this.epochCache.getProposerAttesterAddressInSlot(targetSlot);
930
831
  } catch (e) {
931
832
  if (e instanceof NoCommitteeError) {
932
- this.log.warn(`Cannot propose at next L2 slot ${slot} since the committee does not exist on L1`);
833
+ if (this.lastSlotForNoCommitteeWarning !== targetSlot) {
834
+ this.lastSlotForNoCommitteeWarning = targetSlot;
835
+ this.log.warn(`Cannot propose at target slot ${targetSlot} since the committee does not exist on L1`);
836
+ }
933
837
  return [false, undefined];
934
838
  }
935
- this.log.error(`Error getting proposer for slot ${slot}`, e);
839
+ this.log.error(`Error getting proposer for target slot ${targetSlot}`, e);
936
840
  return [false, undefined];
937
841
  }
938
842
 
@@ -940,15 +844,27 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
940
844
  if (proposer === undefined) {
941
845
  return [true, undefined];
942
846
  }
847
+ // In fisherman mode, just return the current proposer
848
+ if (this.config.fishermanMode) {
849
+ return [true, proposer];
850
+ }
943
851
 
944
- const validatorAddresses = this.validatorClient!.getValidatorAddresses();
852
+ const validatorAddresses = this.validatorClient.getValidatorAddresses();
945
853
  const weAreProposer = validatorAddresses.some(addr => addr.equals(proposer));
946
854
 
947
855
  if (!weAreProposer) {
948
- this.log.debug(`Cannot propose at slot ${slot} since we are not a proposer`, { validatorAddresses, proposer });
856
+ this.log.debug(`Cannot propose at target slot ${targetSlot} since we are not a proposer`, {
857
+ targetSlot,
858
+ validatorAddresses,
859
+ proposer,
860
+ });
949
861
  return [false, proposer];
950
862
  }
951
863
 
864
+ this.log.info(`We are the proposer for pipeline slot ${targetSlot}`, {
865
+ targetSlot,
866
+ proposer,
867
+ });
952
868
  return [true, proposer];
953
869
  }
954
870
 
@@ -956,43 +872,44 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
956
872
  * Tries to vote on slashing actions and governance when the sync check fails but we're past the max time for initializing a proposal.
957
873
  * This allows the sequencer to participate in governance/slashing votes even when it cannot build blocks.
958
874
  */
959
- protected async tryVoteWhenSyncFails(args: { slot: bigint; ts: bigint }): Promise<void> {
960
- const { slot, ts } = args;
875
+ @trackSpan('Seqeuencer.tryVoteWhenSyncFails', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
876
+ protected async tryVoteWhenSyncFails(args: { slot: SlotNumber; targetSlot: SlotNumber; ts: bigint }): Promise<void> {
877
+ const { slot, targetSlot } = args;
961
878
 
962
879
  // Prevent duplicate attempts in the same slot
963
- if (this.lastSlotForVoteWhenSyncFailed === slot) {
964
- this.log.debug(`Already attempted to vote in slot ${slot} (skipping)`);
880
+ if (this.lastSlotForFallbackVote === slot) {
881
+ this.log.trace(`Already attempted to vote in slot ${slot} (skipping)`);
965
882
  return;
966
883
  }
967
884
 
968
885
  // Check if we're past the max time for initializing a proposal
969
886
  const secondsIntoSlot = this.getSecondsIntoSlot(slot);
970
- const maxAllowedTime = this.timetable.getMaxAllowedTime(SequencerState.INITIALIZING_PROPOSAL);
887
+ const maxAllowedTime = this.timetable.getMaxAllowedTime(SequencerState.INITIALIZING_CHECKPOINT);
971
888
 
972
889
  // If we haven't exceeded the time limit for initializing a proposal, don't proceed with voting
973
890
  // We use INITIALIZING_PROPOSAL time limit because if we're past that, we can't build a block anyway
974
891
  if (maxAllowedTime === undefined || secondsIntoSlot <= maxAllowedTime) {
975
- this.log.trace(`Not attempting to vote since there is still for block building`, {
892
+ this.log.trace(`Not attempting to vote since there is still time for block building`, {
976
893
  secondsIntoSlot,
977
894
  maxAllowedTime,
978
895
  });
979
896
  return;
980
897
  }
981
898
 
982
- this.log.debug(`Sync for slot ${slot} failed, checking for voting opportunities`, {
899
+ this.log.trace(`Sync for slot ${slot} failed, checking for voting opportunities`, {
983
900
  secondsIntoSlot,
984
901
  maxAllowedTime,
985
902
  });
986
903
 
987
904
  // Check if we're a proposer or proposal is open
988
- const [canPropose, proposer] = await this.checkCanPropose(slot);
905
+ const [canPropose, proposer] = await this.checkCanPropose(targetSlot);
989
906
  if (!canPropose) {
990
- this.log.debug(`Cannot vote in slot ${slot} since we are not a proposer`, { slot, proposer });
907
+ this.log.trace(`Cannot vote in slot ${slot} since we are not a proposer`, { slot, proposer });
991
908
  return;
992
909
  }
993
910
 
994
911
  // Mark this slot as attempted
995
- this.lastSlotForVoteWhenSyncFailed = slot;
912
+ this.lastSlotForFallbackVote = slot;
996
913
 
997
914
  // Get a publisher for voting
998
915
  const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
@@ -1002,17 +919,98 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1002
919
  slot,
1003
920
  });
1004
921
 
1005
- // Enqueue governance and slashing votes using the shared helper method
1006
- const votesPromises = this.enqueueGovernanceAndSlashingVotes(publisher, attestorAddress, slot, ts);
1007
- await Promise.all(votesPromises);
922
+ // Enqueue governance and slashing votes (voter uses the target slot for L1 submission)
923
+ const voter = new CheckpointVoter(
924
+ targetSlot,
925
+ publisher,
926
+ attestorAddress,
927
+ this.validatorClient,
928
+ this.slasherClient,
929
+ this.l1Constants,
930
+ this.config,
931
+ this.metrics,
932
+ this.log,
933
+ );
934
+ const votesPromises = voter.enqueueVotes();
935
+ const votes = await Promise.all(votesPromises);
1008
936
 
1009
- if (votesPromises.every(p => !p)) {
937
+ if (votes.every(p => !p)) {
1010
938
  this.log.debug(`No votes to enqueue for slot ${slot}`);
1011
939
  return;
1012
940
  }
1013
941
 
1014
942
  this.log.info(`Voting in slot ${slot} despite sync failure`, { slot });
1015
- await publisher.sendRequests();
943
+ // Votes are EIP-712-signed for `targetSlot` (the pipelined slot in which the multicall is
944
+ // expected to mine). Delay submission to the start of `targetSlot` so the tx mines in the
945
+ // slot the votes were signed for. We fire-and-forget so we don't block the sequencer's
946
+ // work loop while waiting for the target slot to start.
947
+ void publisher.sendRequestsAt(targetSlot).catch(err => {
948
+ this.log.error(`Failed to publish votes despite sync failure for slot ${slot}`, err, { slot });
949
+ });
950
+ }
951
+
952
+ /**
953
+ * Tries to vote on slashing actions and governance proposals when escape hatch is open.
954
+ * This allows the sequencer to participate in voting without performing checkpoint proposal work.
955
+ */
956
+ @trackSpan('Sequencer.tryVoteWhenEscapeHatchOpen', ({ slot }) => ({ [Attributes.SLOT_NUMBER]: slot }))
957
+ protected async tryVoteWhenEscapeHatchOpen(args: {
958
+ slot: SlotNumber;
959
+ targetSlot: SlotNumber;
960
+ proposer: EthAddress | undefined;
961
+ }): Promise<void> {
962
+ const { slot, targetSlot, proposer } = args;
963
+
964
+ // Prevent duplicate attempts in the same slot
965
+ if (this.lastSlotForFallbackVote === slot) {
966
+ this.log.trace(`Already attempted to vote in slot ${slot} (escape hatch open, skipping)`);
967
+ return;
968
+ }
969
+
970
+ // Mark this slot as attempted
971
+ this.lastSlotForFallbackVote = slot;
972
+
973
+ const { attestorAddress, publisher } = await this.publisherFactory.create(proposer);
974
+
975
+ this.log.debug(`Escape hatch open for slot ${slot}, attempting vote-only actions`, {
976
+ slot,
977
+ targetSlot,
978
+ attestorAddress,
979
+ });
980
+
981
+ // Under proposer pipelining, the multicall is expected to mine in `targetSlot` (slot + 1).
982
+ // Governance and slashing votes are EIP-712-signed against the slot they will mine in, and the
983
+ // L1 contract checks `msg.sender == getCurrentProposer()` using the mining slot. So we must
984
+ // sign for `targetSlot` and delay submission to the start of `targetSlot`.
985
+ const voter = new CheckpointVoter(
986
+ targetSlot,
987
+ publisher,
988
+ attestorAddress,
989
+ this.validatorClient,
990
+ this.slasherClient,
991
+ this.l1Constants,
992
+ this.config,
993
+ this.metrics,
994
+ this.log,
995
+ );
996
+
997
+ const votesPromises = voter.enqueueVotes();
998
+ const votes = await Promise.all(votesPromises);
999
+
1000
+ if (votes.every(p => !p)) {
1001
+ this.log.debug(`No votes to enqueue for slot ${slot} (escape hatch open)`);
1002
+ return;
1003
+ }
1004
+
1005
+ this.log.info(`Voting in slot ${slot} (escape hatch open)`, { slot, targetSlot });
1006
+ // Votes are EIP-712-signed for `targetSlot`. Delay submission to the start of `targetSlot` so
1007
+ // the multicall mines in the slot the votes were signed for; otherwise the L1 contract reads
1008
+ // `signaler = getCurrentProposer()` against the wrong slot and signature verification fails
1009
+ // silently inside Multicall3. Fire-and-forget so we don't block the sequencer's work loop while
1010
+ // waiting for the target slot to start, mirroring tryVoteWhenSyncFails.
1011
+ void publisher.sendRequestsAt(targetSlot).catch(err => {
1012
+ this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, { slot, targetSlot });
1013
+ });
1016
1014
  }
1017
1015
 
1018
1016
  /**
@@ -1021,28 +1019,27 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1021
1019
  * have the proposer try to invalidate, but if they fail, the sequencers in the committee are expected to try,
1022
1020
  * and if they fail, any sequencer will try as well.
1023
1021
  */
1024
- protected async considerInvalidatingBlock(
1025
- syncedTo: NonNullable<Awaited<ReturnType<Sequencer['checkSync']>>>,
1026
- currentSlot: bigint,
1022
+ protected async considerInvalidatingCheckpoint(
1023
+ syncedTo: SequencerSyncCheckResult,
1024
+ currentSlot: SlotNumber,
1027
1025
  ): Promise<void> {
1028
- const { pendingChainValidationStatus, l1Timestamp } = syncedTo;
1026
+ const { pendingChainValidationStatus, syncedL2Slot } = syncedTo;
1029
1027
  if (pendingChainValidationStatus.valid) {
1030
1028
  return;
1031
1029
  }
1032
1030
 
1033
- const { publisher } = await this.publisherFactory.create(undefined);
1034
- const invalidBlockNumber = pendingChainValidationStatus.block.blockNumber;
1035
- const invalidBlockTimestamp = pendingChainValidationStatus.block.timestamp;
1036
- const timeSinceChainInvalid = this.dateProvider.nowInSeconds() - Number(invalidBlockTimestamp);
1037
- const ourValidatorAddresses = this.validatorClient!.getValidatorAddresses();
1031
+ const invalidCheckpointNumber = pendingChainValidationStatus.checkpoint.checkpointNumber;
1032
+ const invalidCheckpointTimestamp = pendingChainValidationStatus.checkpoint.timestamp;
1033
+ const timeSinceChainInvalid = this.dateProvider.nowInSeconds() - Number(invalidCheckpointTimestamp);
1034
+ const ourValidatorAddresses = this.validatorClient.getValidatorAddresses();
1038
1035
 
1039
1036
  const { secondsBeforeInvalidatingBlockAsCommitteeMember, secondsBeforeInvalidatingBlockAsNonCommitteeMember } =
1040
1037
  this.config;
1041
1038
 
1042
1039
  const logData = {
1043
- invalidL1Timestamp: invalidBlockTimestamp,
1044
- l1Timestamp,
1045
- invalidBlock: pendingChainValidationStatus.block,
1040
+ invalidL1Timestamp: invalidCheckpointTimestamp,
1041
+ syncedL2Slot,
1042
+ invalidCheckpoint: pendingChainValidationStatus.checkpoint,
1046
1043
  secondsBeforeInvalidatingBlockAsCommitteeMember,
1047
1044
  secondsBeforeInvalidatingBlockAsNonCommitteeMember,
1048
1045
  ourValidatorAddresses,
@@ -1070,41 +1067,127 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
1070
1067
  return;
1071
1068
  }
1072
1069
 
1073
- const invalidateBlock = await publisher.simulateInvalidateBlock(pendingChainValidationStatus);
1074
- if (!invalidateBlock) {
1075
- this.log.warn(`Failed to simulate invalidate block`, logData);
1070
+ let validatorToUse: EthAddress;
1071
+ if (invalidateAsCommitteeMember) {
1072
+ // When invalidating as a committee member, use first validator that's actually in the committee
1073
+ const { committee } = await this.epochCache.getCommittee(currentSlot);
1074
+ if (committee) {
1075
+ const committeeSet = new Set(committee.map(addr => addr.toString()));
1076
+ validatorToUse =
1077
+ ourValidatorAddresses.find(addr => committeeSet.has(addr.toString())) ?? ourValidatorAddresses[0];
1078
+ } else {
1079
+ validatorToUse = ourValidatorAddresses[0];
1080
+ }
1081
+ } else {
1082
+ // When invalidating as a non-committee member, use the first validator
1083
+ validatorToUse = ourValidatorAddresses[0];
1084
+ }
1085
+
1086
+ const { publisher } = await this.publisherFactory.create(validatorToUse);
1087
+
1088
+ const invalidateCheckpoint = await publisher.simulateInvalidateCheckpoint(pendingChainValidationStatus);
1089
+ if (!invalidateCheckpoint) {
1090
+ this.log.warn(`Failed to simulate invalidate checkpoint`, logData);
1076
1091
  return;
1077
1092
  }
1078
1093
 
1079
1094
  this.log.info(
1080
1095
  invalidateAsCommitteeMember
1081
- ? `Invalidating block ${invalidBlockNumber} as committee member`
1082
- : `Invalidating block ${invalidBlockNumber} as non-committee member`,
1096
+ ? `Invalidating checkpoint ${invalidCheckpointNumber} as committee member`
1097
+ : `Invalidating checkpoint ${invalidCheckpointNumber} as non-committee member`,
1083
1098
  logData,
1084
1099
  );
1085
1100
 
1086
- publisher.enqueueInvalidateBlock(invalidateBlock);
1087
- await publisher.sendRequests();
1101
+ publisher.enqueueInvalidateCheckpoint(invalidateCheckpoint);
1102
+
1103
+ if (!this.config.fishermanMode) {
1104
+ await publisher.sendRequests();
1105
+ } else {
1106
+ this.log.info('Invalidating checkpoint in fisherman mode, clearing pending requests');
1107
+ publisher.clearPendingRequests();
1108
+ }
1109
+ }
1110
+
1111
+ private logStrategyComparison(epoch: EpochNumber, publisher: SequencerPublisher): void {
1112
+ const feeAnalyzer = publisher.getL1FeeAnalyzer();
1113
+ if (!feeAnalyzer) {
1114
+ return;
1115
+ }
1116
+
1117
+ const comparison = feeAnalyzer.getStrategyComparison();
1118
+ if (comparison.length === 0) {
1119
+ this.log.debug(`No strategy data available yet for epoch ${epoch}`);
1120
+ return;
1121
+ }
1122
+
1123
+ this.log.info(`L1 Fee Strategy Performance Report - End of Epoch ${epoch}`, {
1124
+ epoch: Number(epoch),
1125
+ totalAnalyses: comparison[0]?.totalAnalyses,
1126
+ strategies: comparison.map(s => ({
1127
+ id: s.strategyId,
1128
+ name: s.strategyName,
1129
+ inclusionRate: `${(s.inclusionRate * 100).toFixed(1)}%`,
1130
+ inclusionCount: `${s.inclusionCount}/${s.totalAnalyses}`,
1131
+ avgCostEth: s.avgEstimatedCostEth.toFixed(6),
1132
+ totalCostEth: s.totalEstimatedCostEth.toFixed(6),
1133
+ avgOverpaymentEth: s.avgOverpaymentEth.toFixed(6),
1134
+ totalOverpaymentEth: s.totalOverpaymentEth.toFixed(6),
1135
+ avgPriorityFeeDeltaGwei: s.avgPriorityFeeDeltaGwei.toFixed(2),
1136
+ })),
1137
+ });
1088
1138
  }
1089
1139
 
1090
- private getSlotStartBuildTimestamp(slotNumber: number | bigint): number {
1140
+ private getSlotStartBuildTimestamp(slotNumber: SlotNumber): number {
1091
1141
  return getSlotStartBuildTimestamp(slotNumber, this.l1Constants);
1092
1142
  }
1093
1143
 
1094
- private getSecondsIntoSlot(slotNumber: number | bigint): number {
1144
+ private getSecondsIntoSlot(slotNumber: SlotNumber): number {
1095
1145
  const slotStartTimestamp = this.getSlotStartBuildTimestamp(slotNumber);
1096
1146
  return Number((this.dateProvider.now() / 1000 - slotStartTimestamp).toFixed(3));
1097
1147
  }
1098
1148
 
1099
- get aztecSlotDuration() {
1149
+ public get aztecSlotDuration() {
1100
1150
  return this.l1Constants.slotDuration;
1101
1151
  }
1102
1152
 
1103
- get maxL2BlockGas(): number | undefined {
1153
+ public get maxL2BlockGas(): number | undefined {
1104
1154
  return this.config.maxL2BlockGas;
1105
1155
  }
1106
1156
 
1107
1157
  public getSlasherClient(): SlasherClientInterface | undefined {
1108
1158
  return this.slasherClient;
1109
1159
  }
1160
+
1161
+ public get tracer(): Tracer {
1162
+ return this.metrics.tracer;
1163
+ }
1164
+
1165
+ public getValidatorAddresses() {
1166
+ return this.validatorClient?.getValidatorAddresses();
1167
+ }
1168
+
1169
+ /** Updates the publisher factory's node keystore adapter after a keystore reload. */
1170
+ public updatePublisherNodeKeyStore(adapter: NodeKeystoreAdapter): void {
1171
+ this.publisherFactory.updateNodeKeyStore(adapter);
1172
+ }
1173
+
1174
+ public getConfig() {
1175
+ return this.config;
1176
+ }
1177
+
1178
+ private get l1PublishingTime(): number {
1179
+ return this.config.l1PublishingTime ?? this.l1Constants.ethereumSlotDuration;
1180
+ }
1110
1181
  }
1182
+
1183
+ type SequencerSyncCheckResult = {
1184
+ blockData?: BlockData;
1185
+ checkpointNumber: CheckpointNumber;
1186
+ checkpointedCheckpointNumber: CheckpointNumber;
1187
+ blockNumber: BlockNumber;
1188
+ archive: Fr;
1189
+ hasProposedCheckpoint: boolean;
1190
+ proposedCheckpointData?: ProposedCheckpointData;
1191
+ syncedL2Slot: SlotNumber;
1192
+ pendingChainValidationStatus: ValidateCheckpointResult;
1193
+ };