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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
+ import type { Archiver } from '@aztec/archiver';
1
2
  import type { BlobClientInterface } from '@aztec/blob-client/client';
2
3
  import { type Blob, encodeCheckpointBlobDataFromBlocks, getBlobsPerL1Block } from '@aztec/blob-lib';
3
4
  import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
4
- import type { EpochCache } from '@aztec/epoch-cache';
5
+ import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
5
6
  import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
6
- import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
7
+ import {
8
+ BlockNumber,
9
+ CheckpointNumber,
10
+ type CheckpointProposalHash,
11
+ SlotNumber,
12
+ } from '@aztec/foundation/branded-types';
7
13
  import { pick } from '@aztec/foundation/collection';
8
14
  import { Fr } from '@aztec/foundation/curves/bn254';
9
15
  import { TimeoutError } from '@aztec/foundation/error';
@@ -14,8 +20,9 @@ import { DateProvider, Timer } from '@aztec/foundation/timer';
14
20
  import type { P2P, PeerId } from '@aztec/p2p';
15
21
  import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
16
22
  import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
17
- import { validateCheckpoint } from '@aztec/stdlib/checkpoint';
18
- import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
23
+ import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
24
+ import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint';
25
+ import { getEpochAtSlot, getLastL1SlotTimestampForL2Slot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
19
26
  import { Gas } from '@aztec/stdlib/gas';
20
27
  import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
21
28
  import {
@@ -23,7 +30,7 @@ import {
23
30
  accumulateCheckpointOutHashes,
24
31
  computeInHashFromL1ToL2Messages,
25
32
  } from '@aztec/stdlib/messaging';
26
- import type { BlockProposal, CheckpointProposalCore } from '@aztec/stdlib/p2p';
33
+ import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
27
34
  import { MerkleTreeId } from '@aztec/stdlib/trees';
28
35
  import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
29
36
  import {
@@ -39,9 +46,9 @@ import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
39
46
  import type { ValidatorMetrics } from './metrics.js';
40
47
 
41
48
  export type BlockProposalValidationFailureReason =
49
+ | 'invalid_signature'
42
50
  | 'invalid_proposal'
43
51
  | 'parent_block_not_found'
44
- | 'block_source_not_synced'
45
52
  | 'parent_block_wrong_slot'
46
53
  | 'in_hash_mismatch'
47
54
  | 'global_variables_mismatch'
@@ -51,6 +58,8 @@ export type BlockProposalValidationFailureReason =
51
58
  | 'failed_txs'
52
59
  | 'initial_state_mismatch'
53
60
  | 'timeout'
61
+ | 'block_proposal_beyond_checkpoint'
62
+ | 'checkpoint_proposal_equivocation'
54
63
  | 'unknown_error';
55
64
 
56
65
  type ReexecuteTransactionsResult = {
@@ -75,16 +84,96 @@ export type BlockProposalValidationFailureResult = {
75
84
 
76
85
  export type BlockProposalValidationResult = BlockProposalValidationSuccessResult | BlockProposalValidationFailureResult;
77
86
 
78
- export type CheckpointProposalValidationResult = { isValid: true } | { isValid: false; reason: string };
87
+ export type CheckpointProposalValidationFailureReason =
88
+ | 'invalid_signature'
89
+ | 'invalid_fee_asset_price_modifier'
90
+ | 'last_block_not_found'
91
+ | 'block_fetch_error'
92
+ | 'checkpoint_already_published'
93
+ | 'no_blocks_for_slot'
94
+ | 'last_block_archive_mismatch'
95
+ | 'too_many_blocks_in_checkpoint'
96
+ | 'checkpoint_header_mismatch'
97
+ | 'archive_mismatch'
98
+ | 'out_hash_mismatch'
99
+ | 'checkpoint_validation_failed';
100
+
101
+ /**
102
+ * Mapping from a checkpoint-proposal validation failure reason to the tracker outcome that
103
+ * `handleCheckpointProposal` should record. `undefined` means do not record (signature
104
+ * couldn't be verified, or the checkpoint is already on L1 so the question is moot).
105
+ */
106
+ /* eslint-disable camelcase */
107
+ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
108
+ CheckpointProposalValidationFailureReason,
109
+ ReexecutionOutcome | undefined
110
+ > = {
111
+ invalid_signature: undefined,
112
+ invalid_fee_asset_price_modifier: 'invalid',
113
+ checkpoint_already_published: undefined,
114
+ last_block_not_found: 'unvalidated',
115
+ block_fetch_error: 'unvalidated',
116
+ no_blocks_for_slot: 'unvalidated',
117
+ last_block_archive_mismatch: 'invalid',
118
+ too_many_blocks_in_checkpoint: 'invalid',
119
+ checkpoint_header_mismatch: 'invalid',
120
+ archive_mismatch: 'invalid',
121
+ out_hash_mismatch: 'invalid',
122
+ checkpoint_validation_failed: 'invalid',
123
+ };
124
+
125
+ export type CheckpointProposalValidationSuccessResult = {
126
+ isValid: true;
127
+ checkpointNumber: CheckpointNumber;
128
+ };
129
+
130
+ export type CheckpointProposalValidationFailureResult = {
131
+ isValid: false;
132
+ reason: CheckpointProposalValidationFailureReason;
133
+ checkpointNumber?: CheckpointNumber;
134
+ };
135
+
136
+ export type CheckpointProposalValidationResult =
137
+ | CheckpointProposalValidationSuccessResult
138
+ | CheckpointProposalValidationFailureResult;
139
+
140
+ export type CheckpointProposalValidationFailureCallback = (
141
+ proposal: CheckpointProposalCore,
142
+ result: CheckpointProposalValidationFailureResult,
143
+ proposalInfo: LogData,
144
+ ) => void | Promise<void>;
79
145
 
80
146
  type CheckpointComputationResult =
81
147
  | { checkpointNumber: CheckpointNumber; reason?: undefined }
82
148
  | { checkpointNumber?: undefined; reason: 'invalid_proposal' | 'global_variables_mismatch' };
83
149
 
150
+ type BlockProposalSlotValidationResult =
151
+ | { isValid: true }
152
+ | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
153
+
84
154
  /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
85
155
  export class ProposalHandler {
86
156
  public readonly tracer: Tracer;
87
157
 
158
+ /** Cached last checkpoint validation result to avoid double-validation on validator nodes.
159
+ * Keyed by signed-payload hash so two proposals at the same (slot, archive) but with a
160
+ * different `feeAssetPriceModifier` (or any other signed field) are validated independently. */
161
+ private lastCheckpointValidationResult?: {
162
+ payloadHash: CheckpointProposalHash;
163
+ result: CheckpointProposalValidationResult;
164
+ };
165
+
166
+ /** Archiver reference for setting proposed checkpoints (pipelining). Set via register(). */
167
+ private archiver?: Pick<Archiver, 'addProposedCheckpoint'>;
168
+
169
+ /** Returns current validator addresses for own-proposal detection. Set via register(). */
170
+ private getOwnValidatorAddresses?: () => string[];
171
+
172
+ /** P2P proposal pool access for deciding when retained proposals should block archiver processing. */
173
+ private p2pClient?: Pick<P2P, 'getProposalsForSlot'>;
174
+
175
+ private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
176
+
88
177
  constructor(
89
178
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
90
179
  private worldState: WorldStateSynchronizer,
@@ -95,6 +184,7 @@ export class ProposalHandler {
95
184
  private epochCache: EpochCache,
96
185
  private config: ValidatorClientFullConfig,
97
186
  private blobClient: BlobClientInterface,
187
+ private reexecutionTracker: CheckpointReexecutionTracker,
98
188
  private metrics?: ValidatorMetrics,
99
189
  private dateProvider: DateProvider = new DateProvider(),
100
190
  telemetry: TelemetryClient = getTelemetryClient(),
@@ -106,11 +196,47 @@ export class ProposalHandler {
106
196
  this.tracer = telemetry.getTracer('ProposalHandler');
107
197
  }
108
198
 
199
+ public updateConfig(config: Partial<ValidatorClientFullConfig>): void {
200
+ this.config = { ...this.config, ...config };
201
+ }
202
+
203
+ public setCheckpointProposalValidationFailureCallback(callback?: CheckpointProposalValidationFailureCallback): void {
204
+ this.checkpointProposalValidationFailureCallback = callback;
205
+ }
206
+
207
+ /**
208
+ * Records the proposer's own checkpoint proposal as a `valid` outcome in the re-execution
209
+ * tracker. Without this, the node's own checkpoint proposals never flow through
210
+ * `handleCheckpointProposal` (proposers don't validate their own proposals), so its sentinel
211
+ * sees no outcome for slots where it was the proposer and reports itself as inactive.
212
+ *
213
+ * `archive` should be the locally-computed archive (NOT the broadcast archive, which may have
214
+ * been deliberately corrupted in tests via `broadcastInvalidBlockProposal` /
215
+ * `broadcastInvalidCheckpointProposalOnly`). Recording the local archive correctly models the
216
+ * proposer's own view of its own work.
217
+ */
218
+ public recordOwnCheckpointProposalAsValid(slot: SlotNumber, archive: Fr, checkpointNumber: CheckpointNumber): void {
219
+ this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
220
+ }
221
+
109
222
  /**
110
- * Registers non-validator handlers for block and checkpoint proposals on the p2p client.
111
- * Block proposals are always registered. Checkpoint proposals are registered if the blob client can upload.
223
+ * Registers handlers for block and checkpoint proposals on the p2p client.
224
+ * Records the p2p client so validation can inspect retained proposals.
225
+ * Block proposals are registered for non-validator nodes (validators register their own enhanced handler).
226
+ * The all-nodes checkpoint proposal handler is always registered for validation, caching, and pipelining.
227
+ * @param archiver - Archiver reference for setting proposed checkpoints (pipelining)
228
+ * @param getOwnValidatorAddresses - Returns current validator addresses for own-proposal detection
112
229
  */
113
- register(p2pClient: P2P, shouldReexecute: boolean): ProposalHandler {
230
+ register(
231
+ p2pClient: P2P,
232
+ shouldReexecute: boolean,
233
+ archiver?: Pick<Archiver, 'addProposedCheckpoint'>,
234
+ getOwnValidatorAddresses?: () => string[],
235
+ ): ProposalHandler {
236
+ this.p2pClient = p2pClient;
237
+ this.archiver = archiver;
238
+ this.getOwnValidatorAddresses = getOwnValidatorAddresses;
239
+
114
240
  // Non-validator handler that processes or re-executes for monitoring but does not attest.
115
241
  // Returns boolean indicating whether the proposal was valid.
116
242
  const blockHandler = async (proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> => {
@@ -142,32 +268,64 @@ export class ProposalHandler {
142
268
 
143
269
  p2pClient.registerBlockProposalHandler(blockHandler);
144
270
 
145
- // Register checkpoint proposal handler if blob uploads are enabled and we are reexecuting
146
- if (this.blobClient.canUpload() && shouldReexecute) {
147
- const checkpointHandler = async (checkpoint: CheckpointProposalCore, _sender: PeerId) => {
148
- try {
149
- const proposalInfo = {
150
- proposalSlotNumber: checkpoint.slotNumber,
151
- archive: checkpoint.archive.toString(),
152
- proposer: checkpoint.getSender()?.toString(),
153
- };
154
- const result = await this.handleCheckpointProposal(checkpoint, proposalInfo);
155
- if (result.isValid) {
156
- this.log.info(`Non-validator checkpoint proposal at slot ${checkpoint.slotNumber} handled`, proposalInfo);
157
- } else {
158
- this.log.warn(
159
- `Non-validator checkpoint proposal at slot ${checkpoint.slotNumber} failed: ${result.reason}`,
160
- proposalInfo,
161
- );
271
+ // All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
272
+ // Runs for all nodes (validators and non-validators). Validators get the cached result in the
273
+ // validator-specific callback (attestToCheckpointProposal) which runs after this one.
274
+ const checkpointHandler = async (
275
+ proposal: CheckpointProposalCore,
276
+ _sender: PeerId,
277
+ ): Promise<CheckpointAttestation[] | undefined> => {
278
+ try {
279
+ const pipeliningTimer = new Timer();
280
+ const proposalInfo: LogData = {
281
+ slot: proposal.slotNumber,
282
+ archive: proposal.archive.toString(),
283
+ proposer: proposal.getSender()?.toString(),
284
+ };
285
+
286
+ if (this.config.skipCheckpointProposalValidation) {
287
+ this.log.warn(`Skipping checkpoint proposal validation for slot ${proposal.slotNumber}`, proposalInfo);
288
+ return undefined;
289
+ }
290
+
291
+ if (await this.epochCache.isEscapeHatchOpenAtSlot(proposal.slotNumber)) {
292
+ this.log.warn(
293
+ `Escape hatch open for slot ${proposal.slotNumber}, skipping checkpoint proposal validation`,
294
+ proposalInfo,
295
+ );
296
+ return undefined;
297
+ }
298
+
299
+ // For own proposals, skip validation and return: the proposer already built and validated the
300
+ // checkpoint, and the sequencer's checkpoint proposal job pushed the proposed checkpoint to the
301
+ // archiver from local data before broadcasting. Gossipsub doesn't echo our own messages back, so
302
+ // this branch is normally unreachable — it remains as defense if an own proposal arrives by some
303
+ // other path.
304
+ const proposer = proposal.getSender();
305
+ const ownAddresses = this.getOwnValidatorAddresses?.();
306
+ const isOwnProposal = proposer && ownAddresses?.some(addr => addr === proposer.toString());
307
+
308
+ if (isOwnProposal) {
309
+ this.log.debug(`Skipping validation for own checkpoint proposal at slot ${proposal.slotNumber}`);
310
+ return undefined;
311
+ }
312
+
313
+ const result = await this.handleCheckpointProposal(proposal, proposalInfo);
314
+ if (!result.isValid) {
315
+ await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
316
+ } else if (this.archiver) {
317
+ const set = await this.setProposedCheckpointFromValidation(proposal);
318
+ if (set) {
319
+ this.metrics?.recordCheckpointProposalToPipelinedStateDuration(pipeliningTimer.ms());
162
320
  }
163
- } catch (error) {
164
- this.log.error('Error processing checkpoint proposal in non-validator handler', error);
165
321
  }
166
- // Non-validators don't attest
167
- return undefined;
168
- };
169
- p2pClient.registerCheckpointProposalHandler(checkpointHandler);
170
- }
322
+ } catch (err) {
323
+ this.log.warn(`Error handling checkpoint proposal for slot ${proposal.slotNumber}`, { err });
324
+ }
325
+ return undefined;
326
+ };
327
+
328
+ p2pClient.registerAllNodesCheckpointProposalHandler(checkpointHandler);
171
329
 
172
330
  return this;
173
331
  }
@@ -184,7 +342,7 @@ export class ProposalHandler {
184
342
  // Reject proposals with invalid signatures
185
343
  if (!proposer) {
186
344
  this.log.warn(`Received proposal with invalid signature for slot ${slotNumber}`);
187
- return { isValid: false, reason: 'invalid_proposal' };
345
+ return { isValid: false, reason: 'invalid_signature' };
188
346
  }
189
347
 
190
348
  const proposalInfo = {
@@ -207,17 +365,20 @@ export class ProposalHandler {
207
365
  return { isValid: false, reason: 'invalid_proposal' };
208
366
  }
209
367
 
210
- // Ensure the block source is synced before checking for existing blocks,
211
- // since a pending checkpoint prune may remove blocks we'd otherwise find.
212
- // This affects mostly the block_number_already_exists check, since a pending
213
- // checkpoint prune could remove a block that would conflict with this proposal.
214
- // TODO(@Maddiaa0): This may break staggered slots.
215
- const blockSourceSync = await this.waitForBlockSourceSync(slotNumber);
216
- if (!blockSourceSync) {
217
- this.log.warn(`Block source is not synced, skipping processing`, proposalInfo);
218
- return { isValid: false, reason: 'block_source_not_synced' };
368
+ const retainedSlotValidation = await this.validateNewBlockInSlot(proposal);
369
+ if (!retainedSlotValidation.isValid) {
370
+ this.log.info(`Block proposal conflicts with retained proposals, skipping archiver processing`, {
371
+ ...proposalInfo,
372
+ indexWithinCheckpoint: proposal.indexWithinCheckpoint,
373
+ reason: retainedSlotValidation.reason,
374
+ });
375
+ return { isValid: false, blockNumber: proposal.blockNumber, reason: retainedSlotValidation.reason };
219
376
  }
220
377
 
378
+ // The proposer builds ahead of L1 submission under pipelining, so the block source won't have
379
+ // synced to the proposed slot yet. We deliberately do not wait for it to sync here, to avoid
380
+ // eating into the attestation window.
381
+
221
382
  // Check that the parent proposal is a block we know, otherwise reexecution would fail.
222
383
  // If we don't find it immediately, we keep retrying for a while; it may be we still
223
384
  // need to process other block proposals to get to it.
@@ -245,7 +406,7 @@ export class ProposalHandler {
245
406
  proposalInfo.blockNumber = blockNumber;
246
407
 
247
408
  // Check that this block number does not exist already
248
- const existingBlock = await this.blockSource.getBlockHeader(blockNumber);
409
+ const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
249
410
  if (existingBlock) {
250
411
  this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
251
412
  return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
@@ -258,6 +419,9 @@ export class ProposalHandler {
258
419
  deadline: this.getReexecutionDeadline(slotNumber, config),
259
420
  });
260
421
 
422
+ // Record the tx-collection outcome on the re-execution tracker
423
+ this.reexecutionTracker.recordTxsCollected(slotNumber, proposal.indexWithinCheckpoint, missingTxs.length === 0);
424
+
261
425
  // If reexecution is disabled, bail. We were just interested in triggering tx collection.
262
426
  if (!shouldReexecute) {
263
427
  this.log.info(
@@ -294,11 +458,18 @@ export class ProposalHandler {
294
458
  return { isValid: false, blockNumber, reason: 'txs_not_available' };
295
459
  }
296
460
 
297
- // Collect the out hashes of all the checkpoints before this one in the same epoch
461
+ // Collect the out hashes of all the checkpoints before this one in the same epoch.
462
+ // Mirror the proposer-side fallback: under pipelining the immediately-preceding cp may not
463
+ // yet be on L1, in which case the helper grafts the locally-known proposed cp's outHash.
298
464
  const epoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants());
299
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
300
- .filter(c => c.checkpointNumber < checkpointNumber)
301
- .map(c => c.checkpointOutHash);
465
+ const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({
466
+ blockSource: this.blockSource,
467
+ epoch,
468
+ checkpointNumber,
469
+ l1Constants: this.epochCache.getL1Constants(),
470
+ pipeliningEnabled: true,
471
+ log: this.log,
472
+ });
302
473
 
303
474
  // Try re-executing the transactions in the proposal if needed
304
475
  let reexecutionResult;
@@ -320,7 +491,7 @@ export class ProposalHandler {
320
491
 
321
492
  // If we succeeded, push this block into the archiver (unless disabled)
322
493
  if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) {
323
- await this.blockSource.addBlock(reexecutionResult?.block);
494
+ await this.blockSource.addBlock(reexecutionResult.block);
324
495
  }
325
496
 
326
497
  this.log.info(
@@ -331,9 +502,28 @@ export class ProposalHandler {
331
502
  return { isValid: true, blockNumber, reexecutionResult };
332
503
  }
333
504
 
505
+ private async validateNewBlockInSlot(blockProposal: BlockProposal): Promise<BlockProposalSlotValidationResult> {
506
+ if (!this.p2pClient) {
507
+ return { isValid: true };
508
+ }
509
+
510
+ const { blockProposals, checkpointProposals } = await this.p2pClient.getProposalsForSlot(blockProposal.slotNumber);
511
+
512
+ if (checkpointProposals.length === 0) {
513
+ return { isValid: true };
514
+ } else if (checkpointProposals.length > 1) {
515
+ return { isValid: false, reason: 'checkpoint_proposal_equivocation' };
516
+ } else {
517
+ const checkpointProposal = checkpointProposals[0];
518
+ const terminalBlock = blockProposals.find(block => block.archive.equals(checkpointProposal.archive));
519
+ return terminalBlock !== undefined && blockProposal.indexWithinCheckpoint > terminalBlock.indexWithinCheckpoint
520
+ ? { isValid: false, reason: 'block_proposal_beyond_checkpoint' }
521
+ : { isValid: true };
522
+ }
523
+ }
524
+
334
525
  private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
335
526
  const parentArchive = proposal.blockHeader.lastArchive.root;
336
- const slot = proposal.slotNumber;
337
527
  const config = this.checkpointsBuilder.getConfig();
338
528
  const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
339
529
 
@@ -341,17 +531,18 @@ export class ProposalHandler {
341
531
  return 'genesis';
342
532
  }
343
533
 
344
- const deadline = this.getReexecutionDeadline(slot, config);
534
+ const deadline = this.getReexecutionDeadline(proposal.slotNumber, config);
345
535
  const currentTime = this.dateProvider.now();
346
536
  const timeoutDurationMs = deadline.getTime() - currentTime;
347
537
 
348
538
  try {
349
539
  return (
350
- (await this.blockSource.getBlockDataByArchive(parentArchive)) ??
540
+ (await this.blockSource.getBlockData({ archive: parentArchive })) ??
351
541
  (timeoutDurationMs <= 0
352
542
  ? undefined
353
543
  : await retryUntil(
354
- () => this.blockSource.syncImmediate().then(() => this.blockSource.getBlockDataByArchive(parentArchive)),
544
+ () =>
545
+ this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
355
546
  'force archiver sync',
356
547
  timeoutDurationMs / 1000,
357
548
  0.5,
@@ -490,47 +681,17 @@ export class ProposalHandler {
490
681
  return undefined;
491
682
  }
492
683
 
493
- private getReexecutionDeadline(slot: SlotNumber, config: { l1GenesisTime: bigint; slotDuration: number }): Date {
494
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
684
+ private getReexecutionDeadline(
685
+ slotNumber: SlotNumber,
686
+ config: { l1GenesisTime: bigint; slotDuration: number },
687
+ ): Date {
688
+ // Under proposer pipelining, the proposal slot may be ahead of wall clock time.
689
+ // Reexecution budgets should still be bounded by the current slot we are in now.
690
+ const wallclockSlot = slotNumber - PROPOSER_PIPELINING_SLOT_OFFSET;
691
+ const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(wallclockSlot + 1), config));
495
692
  return new Date(nextSlotTimestampSeconds * 1000);
496
693
  }
497
694
 
498
- /** Waits for the block source to sync L1 data up to at least the slot before the given one. */
499
- private async waitForBlockSourceSync(slot: SlotNumber): Promise<boolean> {
500
- const deadline = this.getReexecutionDeadline(slot, this.checkpointsBuilder.getConfig());
501
- const timeoutMs = deadline.getTime() - this.dateProvider.now();
502
- if (slot === 0) {
503
- return true;
504
- }
505
-
506
- // Make a quick check before triggering an archiver sync
507
- const syncedSlot = await this.blockSource.getSyncedL2SlotNumber();
508
- if (syncedSlot !== undefined && syncedSlot + 1 >= slot) {
509
- return true;
510
- }
511
-
512
- try {
513
- // Trigger an immediate sync of the block source, and wait until it reports being synced to the required slot
514
- return await retryUntil(
515
- async () => {
516
- await this.blockSource.syncImmediate();
517
- const syncedSlot = await this.blockSource.getSyncedL2SlotNumber();
518
- return syncedSlot !== undefined && syncedSlot + 1 >= slot;
519
- },
520
- 'wait for block source sync',
521
- timeoutMs / 1000,
522
- 0.5,
523
- );
524
- } catch (err) {
525
- if (err instanceof TimeoutError) {
526
- this.log.warn(`Timed out waiting for block source to sync to slot ${slot}`);
527
- return false;
528
- } else {
529
- throw err;
530
- }
531
- }
532
- }
533
-
534
695
  private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
535
696
  if (err instanceof TransactionsNotAvailableError) {
536
697
  return 'txs_not_available';
@@ -560,7 +721,7 @@ export class ProposalHandler {
560
721
  // If we do not have all of the transactions, then we should fail
561
722
  if (txs.length !== txHashes.length) {
562
723
  const foundTxHashes = txs.map(tx => tx.getTxHash());
563
- const missingTxHashes = txHashes.filter(txHash => !foundTxHashes.includes(txHash));
724
+ const missingTxHashes = txHashes.filter(txHash => !foundTxHashes.some(h => h.equals(txHash)));
564
725
  throw new TransactionsNotAvailableError(missingTxHashes);
565
726
  }
566
727
 
@@ -672,27 +833,55 @@ export class ProposalHandler {
672
833
  }
673
834
 
674
835
  /**
675
- * Validates a checkpoint proposal and uploads blobs if configured.
676
- * Used by both non-validator nodes (via register) and the validator client (via delegation).
836
+ * Validates a checkpoint proposal, caches the result, and uploads blobs if configured.
837
+ * Returns a cached result if the same proposal (archive + slot) was already validated.
838
+ * Used by both the all-nodes callback (via register) and the validator client (via delegation).
677
839
  */
678
840
  async handleCheckpointProposal(
679
841
  proposal: CheckpointProposalCore,
680
842
  proposalInfo: LogData,
681
843
  ): Promise<CheckpointProposalValidationResult> {
844
+ const slot = proposal.slotNumber;
845
+ const payloadHash = proposal.getPayloadHash();
846
+
847
+ // Check cache: same signed-payload hash means we already validated this exact proposal.
848
+ if (this.lastCheckpointValidationResult && this.lastCheckpointValidationResult.payloadHash === payloadHash) {
849
+ this.log.debug(`Returning cached validation result for checkpoint proposal at slot ${slot}`, proposalInfo);
850
+ return this.lastCheckpointValidationResult.result;
851
+ }
852
+
682
853
  const proposer = proposal.getSender();
854
+ let result: CheckpointProposalValidationResult;
683
855
  if (!proposer) {
684
856
  this.log.warn(`Received checkpoint proposal with invalid signature for slot ${proposal.slotNumber}`);
685
- return { isValid: false, reason: 'invalid_signature' };
686
- }
687
-
688
- if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
857
+ result = { isValid: false as const, reason: 'invalid_signature' };
858
+ } else if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) {
689
859
  this.log.warn(
690
860
  `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${proposal.slotNumber}`,
691
861
  );
692
- return { isValid: false, reason: 'invalid_fee_asset_price_modifier' };
862
+ result = { isValid: false, reason: 'invalid_fee_asset_price_modifier' };
863
+ } else {
864
+ result = await this.validateCheckpointProposal(proposal, proposalInfo);
693
865
  }
694
866
 
695
- const result = await this.validateCheckpointProposal(proposal, proposalInfo);
867
+ this.lastCheckpointValidationResult = { payloadHash, result };
868
+
869
+ // Record the outcome on the re-execution tracker.
870
+ const outcome = result.isValid ? ('valid' as const) : CHECKPOINT_VALIDATION_REASON_TO_OUTCOME[result.reason];
871
+ if (outcome !== undefined) {
872
+ this.reexecutionTracker.recordOutcome(slot, proposal.archive, outcome, result.checkpointNumber);
873
+ }
874
+
875
+ // Drop tracker entries for checkpoints that have reached L1 finality.
876
+ try {
877
+ const tips = await this.blockSource.getL2Tips();
878
+ const finalizedCheckpointNumber = tips.finalized.checkpoint.number;
879
+ if (finalizedCheckpointNumber > 0) {
880
+ this.reexecutionTracker.removeBefore(CheckpointNumber(finalizedCheckpointNumber + 1));
881
+ }
882
+ } catch (err) {
883
+ this.log.error(`Error pruning reexecution tracker`, err, proposalInfo);
884
+ }
696
885
 
697
886
  // Upload blobs to filestore if validation passed (fire and forget)
698
887
  if (result.isValid) {
@@ -712,18 +901,24 @@ export class ProposalHandler {
712
901
  ): Promise<CheckpointProposalValidationResult> {
713
902
  const slot = proposal.slotNumber;
714
903
 
715
- // Timeout block syncing at the start of the next slot
716
- const config = this.checkpointsBuilder.getConfig();
717
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(slot + 1), config));
718
- const timeoutSeconds = Math.max(1, nextSlotTimestampSeconds - Math.floor(this.dateProvider.now() / 1000));
904
+ // Block-sync deadline = the L1 publish deadline, i.e. the latest moment the proposer can submit
905
+ // this checkpoint and still have it land on L1 in the target slot. That is 12s (one Ethereum
906
+ // slot) before the last L1 block of the target slot, which is later than the target-slot start
907
+ // used for block re-execution. Keeping validation/attestation alive until then lets validators
908
+ // keep attesting right up to the proposer's real publish cutoff.
909
+ const l1Constants = this.epochCache.getL1Constants();
910
+ const publishDeadlineSeconds =
911
+ Number(getLastL1SlotTimestampForL2Slot(slot, l1Constants)) - l1Constants.ethereumSlotDuration;
912
+ const deadline = new Date(publishDeadlineSeconds * 1000);
913
+ const timeoutSeconds = Math.max(1, Math.floor((deadline.getTime() - this.dateProvider.now()) / 1000));
719
914
 
720
915
  // Wait for last block to sync by archive
721
- let lastBlockHeader;
916
+ let lastBlockData;
722
917
  try {
723
- lastBlockHeader = await retryUntil(
918
+ lastBlockData = await retryUntil(
724
919
  async () => {
725
920
  await this.blockSource.syncImmediate();
726
- return this.blockSource.getBlockHeaderByArchive(proposal.archive);
921
+ return await this.blockSource.getBlockData({ archive: proposal.archive });
727
922
  },
728
923
  `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
729
924
  timeoutSeconds,
@@ -738,22 +933,54 @@ export class ProposalHandler {
738
933
  return { isValid: false, reason: 'block_fetch_error' };
739
934
  }
740
935
 
741
- if (!lastBlockHeader) {
936
+ if (!lastBlockData) {
742
937
  this.log.warn(`Last block not found for checkpoint proposal`, proposalInfo);
743
938
  return { isValid: false, reason: 'last_block_not_found' };
744
939
  }
745
940
 
941
+ // Refuse to attest if the block's enclosing checkpoint has already been published to L1.
942
+ const existingCheckpoint = await this.blockSource.getCheckpointData({ number: lastBlockData.checkpointNumber });
943
+ if (existingCheckpoint) {
944
+ this.log.warn(`Refusing to attest to checkpoint proposal whose checkpoint is already on L1`, {
945
+ ...proposalInfo,
946
+ checkpointNumber: lastBlockData.checkpointNumber,
947
+ });
948
+ return {
949
+ isValid: false,
950
+ reason: 'checkpoint_already_published',
951
+ checkpointNumber: lastBlockData.checkpointNumber,
952
+ };
953
+ }
954
+
746
955
  // Get all full blocks for the slot and checkpoint
747
956
  const blocks = await this.blockSource.getBlocksForSlot(slot);
748
957
  if (blocks.length === 0) {
749
958
  this.log.warn(`No blocks found for slot ${slot}`, proposalInfo);
750
- return { isValid: false, reason: 'no_blocks_for_slot' };
959
+ return { isValid: false, reason: 'no_blocks_for_slot', checkpointNumber: lastBlockData.checkpointNumber };
751
960
  }
752
961
 
753
962
  // Ensure the last block for this slot matches the archive in the checkpoint proposal
754
963
  if (!blocks.at(-1)?.archive.root.equals(proposal.archive)) {
755
964
  this.log.warn(`Last block archive mismatch for checkpoint proposal`, proposalInfo);
756
- return { isValid: false, reason: 'last_block_archive_mismatch' };
965
+ return {
966
+ isValid: false,
967
+ reason: 'last_block_archive_mismatch',
968
+ checkpointNumber: lastBlockData.checkpointNumber,
969
+ };
970
+ }
971
+
972
+ const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
973
+ if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
974
+ this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
975
+ ...proposalInfo,
976
+ blocksInProposal: blocks.length,
977
+ maxBlocksPerCheckpoint,
978
+ });
979
+ return {
980
+ isValid: false,
981
+ reason: 'too_many_blocks_in_checkpoint',
982
+ checkpointNumber: lastBlockData.checkpointNumber,
983
+ };
757
984
  }
758
985
 
759
986
  this.log.debug(`Found ${blocks.length} blocks for slot ${slot}`, {
@@ -769,87 +996,90 @@ export class ProposalHandler {
769
996
  // Get L1-to-L2 messages for this checkpoint
770
997
  const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
771
998
 
772
- // Collect the out hashes of all the checkpoints before this one in the same epoch
999
+ // Collect the out hashes of all the checkpoints before this one in the same epoch.
1000
+ // See note on the analogous block-proposal site: the helper handles pipelining lag.
773
1001
  const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants());
774
- const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch))
775
- .filter(c => c.checkpointNumber < checkpointNumber)
776
- .map(c => c.checkpointOutHash);
1002
+ const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({
1003
+ blockSource: this.blockSource,
1004
+ epoch,
1005
+ checkpointNumber,
1006
+ l1Constants: this.epochCache.getL1Constants(),
1007
+ pipeliningEnabled: true,
1008
+ log: this.log,
1009
+ });
777
1010
 
778
1011
  // Fork world state at the block before the first block
779
1012
  const parentBlockNumber = BlockNumber(firstBlock.number - 1);
780
- const fork = await this.worldState.fork(parentBlockNumber);
1013
+ await using fork = await this.checkpointsBuilder.getFork(parentBlockNumber);
781
1014
 
782
- try {
783
- // Create checkpoint builder with all existing blocks
784
- const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
785
- checkpointNumber,
786
- constants,
787
- proposal.feeAssetPriceModifier,
788
- l1ToL2Messages,
789
- previousCheckpointOutHashes,
790
- fork,
791
- blocks,
792
- this.log.getBindings(),
793
- );
1015
+ // Create checkpoint builder with all existing blocks
1016
+ const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
1017
+ checkpointNumber,
1018
+ constants,
1019
+ proposal.feeAssetPriceModifier,
1020
+ l1ToL2Messages,
1021
+ previousCheckpointOutHashes,
1022
+ fork,
1023
+ blocks,
1024
+ this.log.getBindings(),
1025
+ );
794
1026
 
795
- // Complete the checkpoint to get computed values
796
- const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
797
-
798
- // Compare checkpoint header with proposal
799
- if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
800
- this.log.warn(`Checkpoint header mismatch`, {
801
- ...proposalInfo,
802
- computed: computedCheckpoint.header.toInspect(),
803
- proposal: proposal.checkpointHeader.toInspect(),
804
- });
805
- return { isValid: false, reason: 'checkpoint_header_mismatch' };
806
- }
1027
+ // Complete the checkpoint to get computed values
1028
+ const computedCheckpoint = await checkpointBuilder.completeCheckpoint();
807
1029
 
808
- // Compare archive root with proposal
809
- if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
810
- this.log.warn(`Archive root mismatch`, {
811
- ...proposalInfo,
812
- computed: computedCheckpoint.archive.root.toString(),
813
- proposal: proposal.archive.toString(),
814
- });
815
- return { isValid: false, reason: 'archive_mismatch' };
816
- }
1030
+ // Compare checkpoint header with proposal
1031
+ if (!computedCheckpoint.header.equals(proposal.checkpointHeader)) {
1032
+ this.log.warn(`Checkpoint header mismatch`, {
1033
+ ...proposalInfo,
1034
+ computed: computedCheckpoint.header.toInspect(),
1035
+ proposal: proposal.checkpointHeader.toInspect(),
1036
+ });
1037
+ return { isValid: false, reason: 'checkpoint_header_mismatch', checkpointNumber };
1038
+ }
817
1039
 
818
- // Check that the accumulated epoch out hash matches the value in the proposal.
819
- // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
820
- const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
821
- const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
822
- const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
823
- if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
824
- this.log.warn(`Epoch out hash mismatch`, {
825
- proposalEpochOutHash: proposalEpochOutHash.toString(),
826
- computedEpochOutHash: computedEpochOutHash.toString(),
827
- checkpointOutHash: checkpointOutHash.toString(),
828
- previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
829
- ...proposalInfo,
830
- });
831
- return { isValid: false, reason: 'out_hash_mismatch' };
832
- }
1040
+ // Compare archive root with proposal
1041
+ if (!computedCheckpoint.archive.root.equals(proposal.archive)) {
1042
+ this.log.warn(`Archive root mismatch`, {
1043
+ ...proposalInfo,
1044
+ computed: computedCheckpoint.archive.root.toString(),
1045
+ proposal: proposal.archive.toString(),
1046
+ });
1047
+ return { isValid: false, reason: 'archive_mismatch', checkpointNumber };
1048
+ }
833
1049
 
834
- // Final round of validations on the checkpoint, just in case.
835
- try {
836
- validateCheckpoint(computedCheckpoint, {
837
- rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
838
- maxDABlockGas: this.config.validateMaxDABlockGas,
839
- maxL2BlockGas: this.config.validateMaxL2BlockGas,
840
- maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
841
- maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
842
- });
843
- } catch (err) {
844
- this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
845
- return { isValid: false, reason: 'checkpoint_validation_failed' };
846
- }
1050
+ // Check that the accumulated epoch out hash matches the value in the proposal.
1051
+ // The epoch out hash is the accumulated hash of all checkpoint out hashes in the epoch.
1052
+ const checkpointOutHash = computedCheckpoint.getCheckpointOutHash();
1053
+ const computedEpochOutHash = accumulateCheckpointOutHashes([...previousCheckpointOutHashes, checkpointOutHash]);
1054
+ const proposalEpochOutHash = proposal.checkpointHeader.epochOutHash;
1055
+ if (!computedEpochOutHash.equals(proposalEpochOutHash)) {
1056
+ this.log.warn(`Epoch out hash mismatch`, {
1057
+ proposalEpochOutHash: proposalEpochOutHash.toString(),
1058
+ computedEpochOutHash: computedEpochOutHash.toString(),
1059
+ checkpointOutHash: checkpointOutHash.toString(),
1060
+ previousCheckpointOutHashes: previousCheckpointOutHashes.map(h => h.toString()),
1061
+ ...proposalInfo,
1062
+ });
1063
+ return { isValid: false, reason: 'out_hash_mismatch', checkpointNumber };
1064
+ }
847
1065
 
848
- this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
849
- return { isValid: true };
850
- } finally {
851
- await fork.close();
1066
+ // Final round of validations on the checkpoint, just in case.
1067
+ try {
1068
+ validateCheckpoint(computedCheckpoint, {
1069
+ rollupManaLimit: this.checkpointsBuilder.getConfig().rollupManaLimit,
1070
+ maxDABlockGas: this.config.validateMaxDABlockGas,
1071
+ maxL2BlockGas: this.config.validateMaxL2BlockGas,
1072
+ maxTxsPerBlock: this.config.validateMaxTxsPerBlock,
1073
+ maxTxsPerCheckpoint: this.config.validateMaxTxsPerCheckpoint,
1074
+ });
1075
+ } catch (err) {
1076
+ this.log.warn(`Checkpoint validation failed: ${err}`, proposalInfo);
1077
+ return { isValid: false, reason: 'checkpoint_validation_failed', checkpointNumber };
852
1078
  }
1079
+
1080
+ this.log.verbose(`Checkpoint proposal validation successful for slot ${slot}`, proposalInfo);
1081
+
1082
+ return { isValid: true, checkpointNumber };
853
1083
  }
854
1084
 
855
1085
  /** Extracts checkpoint global variables from a block. */
@@ -876,7 +1106,7 @@ export class ProposalHandler {
876
1106
  /** Uploads blobs for a checkpoint to the filestore. */
877
1107
  protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise<void> {
878
1108
  try {
879
- const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive);
1109
+ const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header;
880
1110
  if (!lastBlockHeader) {
881
1111
  this.log.warn(`Failed to get last block header for blob upload`, proposalInfo);
882
1112
  return;
@@ -900,4 +1130,32 @@ export class ProposalHandler {
900
1130
  this.log.warn(`Failed to upload blobs for checkpoint: ${err}`, proposalInfo);
901
1131
  }
902
1132
  }
1133
+
1134
+ /**
1135
+ * Derives proposed checkpoint data from validated blocks and sets it on the archiver.
1136
+ * Used after successful validation of a foreign proposal.
1137
+ * Does not retry since we already waited for the block during validation.
1138
+ */
1139
+ private async setProposedCheckpointFromValidation(proposal: CheckpointProposalCore): Promise<boolean> {
1140
+ if (!this.archiver) {
1141
+ return false;
1142
+ }
1143
+ const blockData = await this.blockSource.getBlockData({ archive: proposal.archive });
1144
+ if (!blockData) {
1145
+ this.log.debug(`Block data not found for checkpoint proposal archive, cannot set proposed checkpoint`, {
1146
+ archive: proposal.archive.toString(),
1147
+ });
1148
+ return false;
1149
+ }
1150
+
1151
+ await this.archiver.addProposedCheckpoint({
1152
+ header: proposal.checkpointHeader,
1153
+ checkpointNumber: blockData.checkpointNumber,
1154
+ startBlock: BlockNumber(blockData.header.getBlockNumber() - blockData.indexWithinCheckpoint),
1155
+ blockCount: blockData.indexWithinCheckpoint + 1,
1156
+ totalManaUsed: proposal.checkpointHeader.totalManaUsed.toBigInt(),
1157
+ feeAssetPriceModifier: proposal.feeAssetPriceModifier,
1158
+ });
1159
+ return true;
1160
+ }
903
1161
  }