@aztec/validator-client 0.0.1-commit.2f68f620 → 0.0.1-commit.321f6a9

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.
@@ -2,7 +2,7 @@ import type { Archiver } from '@aztec/archiver';
2
2
  import type { BlobClientInterface } from '@aztec/blob-client/client';
3
3
  import { type Blob, encodeCheckpointBlobDataFromBlocks, getBlobsPerL1Block } from '@aztec/blob-lib';
4
4
  import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
5
- import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
5
+ import type { EpochCache } from '@aztec/epoch-cache';
6
6
  import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
7
7
  import {
8
8
  BlockNumber,
@@ -13,6 +13,7 @@ import {
13
13
  import { pick } from '@aztec/foundation/collection';
14
14
  import { Fr } from '@aztec/foundation/curves/bn254';
15
15
  import { TimeoutError } from '@aztec/foundation/error';
16
+ import { FifoSet } from '@aztec/foundation/fifo-set';
16
17
  import type { LogData } from '@aztec/foundation/log';
17
18
  import { createLogger } from '@aztec/foundation/log';
18
19
  import { retryUntil } from '@aztec/foundation/retry';
@@ -22,15 +23,21 @@ import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
22
23
  import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
23
24
  import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
24
25
  import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint';
25
- import { getEpochAtSlot, getLastL1SlotTimestampForL2Slot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
26
+ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
26
27
  import { Gas } from '@aztec/stdlib/gas';
27
- import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
28
+ import type {
29
+ ITxProvider,
30
+ MerkleTreeWriteOperations,
31
+ ValidatorClientFullConfig,
32
+ WorldStateSynchronizer,
33
+ } from '@aztec/stdlib/interfaces/server';
28
34
  import {
29
35
  type L1ToL2MessageSource,
30
36
  accumulateCheckpointOutHashes,
31
37
  computeInHashFromL1ToL2Messages,
32
38
  } from '@aztec/stdlib/messaging';
33
39
  import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
40
+ import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
34
41
  import { MerkleTreeId } from '@aztec/stdlib/trees';
35
42
  import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
36
43
  import {
@@ -89,10 +96,12 @@ export type CheckpointProposalValidationFailureReason =
89
96
  | 'invalid_fee_asset_price_modifier'
90
97
  | 'last_block_not_found'
91
98
  | 'block_fetch_error'
99
+ | 'world_state_not_synced'
92
100
  | 'checkpoint_already_published'
93
101
  | 'no_blocks_for_slot'
94
102
  | 'last_block_archive_mismatch'
95
103
  | 'too_many_blocks_in_checkpoint'
104
+ | 'initial_archive_mismatch'
96
105
  | 'checkpoint_header_mismatch'
97
106
  | 'archive_mismatch'
98
107
  | 'out_hash_mismatch'
@@ -113,6 +122,8 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
113
122
  checkpoint_already_published: undefined,
114
123
  last_block_not_found: 'unvalidated',
115
124
  block_fetch_error: 'unvalidated',
125
+ world_state_not_synced: 'unvalidated',
126
+ initial_archive_mismatch: 'unvalidated',
116
127
  no_blocks_for_slot: 'unvalidated',
117
128
  last_block_archive_mismatch: 'invalid',
118
129
  too_many_blocks_in_checkpoint: 'invalid',
@@ -151,7 +162,52 @@ type BlockProposalSlotValidationResult =
151
162
  | { isValid: true }
152
163
  | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
153
164
 
154
- /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
165
+ const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
166
+
167
+ /** Block-proposal validation failures that constitute a slashable invalid-block offense. */
168
+ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
169
+ 'state_mismatch',
170
+ 'failed_txs',
171
+ 'global_variables_mismatch',
172
+ 'invalid_proposal',
173
+ 'parent_block_wrong_slot',
174
+ 'in_hash_mismatch',
175
+ ];
176
+
177
+ /** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
178
+ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
179
+ CheckpointProposalValidationFailureReason,
180
+ boolean
181
+ > = {
182
+ // enabled
183
+ ['invalid_fee_asset_price_modifier']: true,
184
+ ['checkpoint_header_mismatch']: true,
185
+ // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
186
+ // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
187
+ ['archive_mismatch']: true,
188
+ ['out_hash_mismatch']: true,
189
+ ['no_blocks_for_slot']: true,
190
+ ['too_many_blocks_in_checkpoint']: true,
191
+ ['checkpoint_validation_failed']: true,
192
+ ['last_block_archive_mismatch']: true,
193
+
194
+ // disabled
195
+ ['invalid_signature']: false,
196
+ ['last_block_not_found']: false,
197
+ ['block_fetch_error']: false,
198
+ ['world_state_not_synced']: false,
199
+ // A reorg / divergent local chain, not a proposer offense (mirrors the block path's initial_state_mismatch).
200
+ ['initial_archive_mismatch']: false,
201
+ ['checkpoint_already_published']: false,
202
+ };
203
+
204
+ /**
205
+ * Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
206
+ * had a slashable invalid proposal or a proposal equivocation, exposing them via the
207
+ * `InvalidProposalSlotSource` interface consumed by the attested-invalid-proposal slashing watcher. The
208
+ * tracking is populated as a side effect of validating/re-executing proposals, so any node that re-executes
209
+ * proposals (the default) can serve it — not only validators.
210
+ */
155
211
  export class ProposalHandler {
156
212
  public readonly tracer: Tracer;
157
213
 
@@ -164,7 +220,7 @@ export class ProposalHandler {
164
220
  };
165
221
 
166
222
  /** Archiver reference for setting proposed checkpoints (pipelining). Set via register(). */
167
- private archiver?: Pick<Archiver, 'addProposedCheckpoint'>;
223
+ private archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>;
168
224
 
169
225
  /** Returns current validator addresses for own-proposal detection. Set via register(). */
170
226
  private getOwnValidatorAddresses?: () => string[];
@@ -174,6 +230,12 @@ export class ProposalHandler {
174
230
 
175
231
  private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
176
232
 
233
+ /** Slots at which a slashable invalid block or checkpoint proposal was observed. */
234
+ private readonly slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
235
+
236
+ /** Slots at which a proposal equivocation was observed; suppresses attested-to-invalid-proposal slashing. */
237
+ private readonly slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
238
+
177
239
  constructor(
178
240
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
179
241
  private worldState: WorldStateSynchronizer,
@@ -182,6 +244,7 @@ export class ProposalHandler {
182
244
  private txProvider: ITxProvider,
183
245
  private blockProposalValidator: BlockProposalValidator,
184
246
  private epochCache: EpochCache,
247
+ private timetable: ConsensusTimetable,
185
248
  private config: ValidatorClientFullConfig,
186
249
  private blobClient: BlobClientInterface,
187
250
  private reexecutionTracker: CheckpointReexecutionTracker,
@@ -219,6 +282,26 @@ export class ProposalHandler {
219
282
  this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
220
283
  }
221
284
 
285
+ /** Whether a slashable invalid block or checkpoint proposal was observed at the given slot (InvalidProposalSlotSource). */
286
+ public hasInvalidProposals(slotNumber: SlotNumber): boolean {
287
+ return this.slotsWithInvalidProposals.has(slotNumber);
288
+ }
289
+
290
+ /** Whether a proposal equivocation was observed at the given slot (InvalidProposalSlotSource). */
291
+ public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
292
+ return this.slotsWithProposalEquivocation.has(slotNumber);
293
+ }
294
+
295
+ /** Records a slot as having a slashable invalid proposal, for offense observers (sentinel/slasher watchers). */
296
+ public markInvalidProposalSlot(slotNumber: SlotNumber): void {
297
+ this.slotsWithInvalidProposals.add(slotNumber);
298
+ }
299
+
300
+ /** Records a slot as having a proposal equivocation, which suppresses attested-to-invalid-proposal slashing. */
301
+ public markProposalEquivocation(slotNumber: SlotNumber): void {
302
+ this.slotsWithProposalEquivocation.add(slotNumber);
303
+ }
304
+
222
305
  /**
223
306
  * Registers handlers for block and checkpoint proposals on the p2p client.
224
307
  * Records the p2p client so validation can inspect retained proposals.
@@ -230,7 +313,7 @@ export class ProposalHandler {
230
313
  register(
231
314
  p2pClient: P2P,
232
315
  shouldReexecute: boolean,
233
- archiver?: Pick<Archiver, 'addProposedCheckpoint'>,
316
+ archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>,
234
317
  getOwnValidatorAddresses?: () => string[],
235
318
  ): ProposalHandler {
236
319
  this.p2pClient = p2pClient;
@@ -254,6 +337,18 @@ export class ProposalHandler {
254
337
  });
255
338
  return true;
256
339
  } else {
340
+ // Track invalid proposals / equivocations so offense observers (the attested-invalid-proposal
341
+ // watcher) work on non-validator nodes too. Validators populate these via their own handlers.
342
+ // Skip invalid-proposal marking while the escape hatch is open, matching the validator path,
343
+ // which intentionally disables invalid-block slashing then.
344
+ if (result.reason === 'checkpoint_proposal_equivocation') {
345
+ this.markProposalEquivocation(slotNumber);
346
+ } else if (
347
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
348
+ !(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
349
+ ) {
350
+ this.markInvalidProposalSlot(slotNumber);
351
+ }
257
352
  this.log.warn(
258
353
  `Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
259
354
  { blockNumber: result.blockNumber, slotNumber, reason: result.reason },
@@ -268,6 +363,11 @@ export class ProposalHandler {
268
363
 
269
364
  p2pClient.registerBlockProposalHandler(blockHandler);
270
365
 
366
+ // p2p detects duplicate (equivocated) proposals without routing them through the handlers above, so mark
367
+ // the slot as equivocated here. This suppresses false-positive attested-to-invalid-proposal slashing on
368
+ // non-validator offense collectors. Validators overwrite this with their own richer handler.
369
+ p2pClient.registerDuplicateProposalCallback(info => this.markProposalEquivocation(info.slot));
370
+
271
371
  // All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
272
372
  // Runs for all nodes (validators and non-validators). Validators get the cached result in the
273
373
  // validator-specific callback (attestToCheckpointProposal) which runs after this one.
@@ -296,25 +396,35 @@ export class ProposalHandler {
296
396
  return undefined;
297
397
  }
298
398
 
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.
399
+ // A proposal is "own" when it was signed by a validator key this node also owns. The true local
400
+ // proposer already built, validated, and stored this checkpoint before broadcasting, so a matching
401
+ // proposed checkpoint is already in its archiver skip the redundant re-validation. An HA peer that
402
+ // shares the proposer's keys sees the same "own" proposal over gossip but never built it, so it has
403
+ // nothing stored; it falls through to the normal validate-and-persist path below to hydrate the
404
+ // proposed-checkpoint metadata it needs to build the next slot on top of this checkpoint.
304
405
  const proposer = proposal.getSender();
305
406
  const ownAddresses = this.getOwnValidatorAddresses?.();
306
407
  const isOwnProposal = proposer && ownAddresses?.some(addr => addr === proposer.toString());
307
408
 
308
409
  if (isOwnProposal) {
309
- this.log.debug(`Skipping validation for own checkpoint proposal at slot ${proposal.slotNumber}`);
310
- return undefined;
410
+ const existing = await this.archiver?.getProposedCheckpointData({ slot: proposal.slotNumber });
411
+ if (existing?.archive.root.equals(proposal.archive)) {
412
+ this.log.debug(`Skipping sync for existing own checkpoint proposal at slot ${proposal.slotNumber}`);
413
+ return undefined;
414
+ }
311
415
  }
312
416
 
313
417
  const result = await this.handleCheckpointProposal(proposal, proposalInfo);
314
418
  if (!result.isValid) {
419
+ // Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
420
+ // work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
421
+ // failure callback below (idempotent).
422
+ if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
423
+ this.markInvalidProposalSlot(proposal.slotNumber);
424
+ }
315
425
  await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
316
426
  } else if (this.archiver) {
317
- const set = await this.setProposedCheckpointFromValidation(proposal);
427
+ const set = await this.setProposedCheckpoint(proposal);
318
428
  if (set) {
319
429
  this.metrics?.recordCheckpointProposalToPipelinedStateDuration(pipeliningTimer.ms());
320
430
  }
@@ -337,7 +447,6 @@ export class ProposalHandler {
337
447
  ): Promise<BlockProposalValidationResult> {
338
448
  const slotNumber = proposal.slotNumber;
339
449
  const proposer = proposal.getSender();
340
- const config = this.checkpointsBuilder.getConfig();
341
450
 
342
451
  // Reject proposals with invalid signatures
343
452
  if (!proposer) {
@@ -405,8 +514,12 @@ export class ProposalHandler {
405
514
  : BlockNumber(parentBlock.header.getBlockNumber() + 1);
406
515
  proposalInfo.blockNumber = blockNumber;
407
516
 
408
- // Check that this block number does not exist already
409
- const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
517
+ // Check that this block number does not exist already. During a reorg the archiver can still hold a
518
+ // stale block at this number (a different archive, about to be pruned) while the proposal carries the
519
+ // rebuilt replacement; resolveExistingBlockAtNumber waits for the local prune in that case so the
520
+ // rebuilt block is processed in time to attest, rather than being permanently dropped on a bare
521
+ // number collision.
522
+ const existingBlock = await this.resolveExistingBlockAtNumber(blockNumber, proposal.archive, slotNumber);
410
523
  if (existingBlock) {
411
524
  this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
412
525
  return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
@@ -416,7 +529,7 @@ export class ProposalHandler {
416
529
  // and we do it even if we don't plan to re-execute the txs, so that we have them if another node needs them.
417
530
  const { txs, missingTxs } = await this.txProvider.getTxsForBlockProposal(proposal, blockNumber, {
418
531
  pinnedPeer: proposalSender,
419
- deadline: this.getReexecutionDeadline(slotNumber, config),
532
+ deadline: this.getReexecutionDeadline(slotNumber),
420
533
  });
421
534
 
422
535
  // Record the tx-collection outcome on the re-execution tracker
@@ -490,7 +603,7 @@ export class ProposalHandler {
490
603
  }
491
604
 
492
605
  // If we succeeded, push this block into the archiver (unless disabled)
493
- if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) {
606
+ if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) {
494
607
  await this.blockSource.addBlock(reexecutionResult.block);
495
608
  }
496
609
 
@@ -524,16 +637,14 @@ export class ProposalHandler {
524
637
 
525
638
  private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
526
639
  const parentArchive = proposal.blockHeader.lastArchive.root;
527
- const config = this.checkpointsBuilder.getConfig();
528
640
  const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
529
641
 
530
642
  if (parentArchive.equals(genesisArchiveRoot)) {
531
643
  return 'genesis';
532
644
  }
533
645
 
534
- const deadline = this.getReexecutionDeadline(proposal.slotNumber, config);
535
- const currentTime = this.dateProvider.now();
536
- const timeoutDurationMs = deadline.getTime() - currentTime;
646
+ const deadline = this.getReexecutionDeadline(proposal.slotNumber);
647
+ const timeoutDurationMs = deadline.getTime() - this.dateProvider.now();
537
648
 
538
649
  try {
539
650
  return (
@@ -544,7 +655,7 @@ export class ProposalHandler {
544
655
  () =>
545
656
  this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
546
657
  'force archiver sync',
547
- timeoutDurationMs / 1000,
658
+ { deadline, dateProvider: this.dateProvider },
548
659
  0.5,
549
660
  ))
550
661
  );
@@ -558,6 +669,63 @@ export class ProposalHandler {
558
669
  }
559
670
  }
560
671
 
672
+ /**
673
+ * Resolves whether a block genuinely already exists at `blockNumber`. Returns the existing block only if
674
+ * it is a true duplicate of the proposal (matching archive). During a reorg the archiver can still hold a
675
+ * stale fork at this number (different archive) that is about to be pruned; in that case this forces L1
676
+ * sync and waits, bounded by the re-execution deadline, for the prune to land, then returns `undefined` so
677
+ * the rebuilt block proposal can be processed in time to attest. If the prune does not complete before the
678
+ * deadline it returns the stale block, so the caller falls back to the safe `block_number_already_exists`
679
+ * rejection.
680
+ */
681
+ private async resolveExistingBlockAtNumber(
682
+ blockNumber: BlockNumber,
683
+ proposalArchive: Fr,
684
+ slotNumber: SlotNumber,
685
+ ): Promise<BlockData | undefined> {
686
+ const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
687
+ if (!existingBlock || existingBlock.archive.root.equals(proposalArchive)) {
688
+ return existingBlock;
689
+ }
690
+
691
+ // A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
692
+ // genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
693
+ const deadline = this.getReexecutionDeadline(slotNumber);
694
+ if (deadline.getTime() - this.dateProvider.now() <= 0) {
695
+ return existingBlock;
696
+ }
697
+
698
+ this.log.warn(`Block number ${blockNumber} already exists, awaiting potential prune`, {
699
+ blockNumber,
700
+ existingArchive: existingBlock.archive.root.toString(),
701
+ proposalArchive: proposalArchive.toString(),
702
+ });
703
+
704
+ try {
705
+ const { block } = await retryUntil(
706
+ async () => {
707
+ await this.blockSource.syncImmediate();
708
+ const block = await this.blockSource.getBlockData({ number: blockNumber });
709
+ // Resolve once the existing block is gone (pruned) or has been replaced by one matching the
710
+ // proposal — the same condition as the early return above. A matching block is returned so the
711
+ // caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
712
+ // be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
713
+ return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
714
+ },
715
+ `prune of stale block ${blockNumber}`,
716
+ { deadline, dateProvider: this.dateProvider },
717
+ 0.5,
718
+ );
719
+ return block;
720
+ } catch (err) {
721
+ if (err instanceof TimeoutError) {
722
+ this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
723
+ return existingBlock;
724
+ }
725
+ throw err;
726
+ }
727
+ }
728
+
561
729
  private computeCheckpointNumber(
562
730
  proposal: BlockProposal,
563
731
  parentBlock: 'genesis' | BlockData,
@@ -681,15 +849,14 @@ export class ProposalHandler {
681
849
  return undefined;
682
850
  }
683
851
 
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));
692
- return new Date(nextSlotTimestampSeconds * 1000);
852
+ /**
853
+ * Hard re-execution/validation deadline for any block or checkpoint proposal targeting `slotNumber`:
854
+ * the single consensus `attestation_deadline` (`target_slot_start + S - 2E`). This is the latest the
855
+ * checkpoint can land on L1 in the target slot; all nodes agree on it. Loosened from the previous
856
+ * next-wall-clock-slot-boundary bound (see the timetable spec / refactor notes).
857
+ */
858
+ private getReexecutionDeadline(slotNumber: SlotNumber): Date {
859
+ return new Date(this.timetable.getAttestationDeadline(slotNumber) * 1000);
693
860
  }
694
861
 
695
862
  private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
@@ -769,7 +936,7 @@ export class ProposalHandler {
769
936
  );
770
937
 
771
938
  // Build the new block
772
- const deadline = this.getReexecutionDeadline(slot, config);
939
+ const deadline = this.getReexecutionDeadline(slot);
773
940
  const maxBlockGas =
774
941
  this.config.validateMaxL2BlockGas !== undefined || this.config.validateMaxDABlockGas !== undefined
775
942
  ? new Gas(this.config.validateMaxDABlockGas ?? Infinity, this.config.validateMaxL2BlockGas ?? Infinity)
@@ -901,18 +1068,15 @@ export class ProposalHandler {
901
1068
  ): Promise<CheckpointProposalValidationResult> {
902
1069
  const slot = proposal.slotNumber;
903
1070
 
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));
914
-
915
- // Wait for last block to sync by archive
1071
+ // Block-sync/validation deadline = the single consensus attestation_deadline (target_slot_start + S
1072
+ // - 2E): the latest moment the proposer can submit this checkpoint and still have it land on L1 in
1073
+ // the target slot. Keeping validation/attestation alive until then lets validators keep attesting
1074
+ // right up to the proposer's real publish cutoff.
1075
+ const deadline = this.getReexecutionDeadline(slot);
1076
+
1077
+ // Wait for last block to sync by archive. The deadline is passed to retryUntil as an absolute date so
1078
+ // the remaining budget is derived from the date provider; a deadline already in the past times out
1079
+ // after a single attempt instead of looping (the immediate-timeout semantics of the deadline overload).
916
1080
  let lastBlockData;
917
1081
  try {
918
1082
  lastBlockData = await retryUntil(
@@ -921,7 +1085,7 @@ export class ProposalHandler {
921
1085
  return await this.blockSource.getBlockData({ archive: proposal.archive });
922
1086
  },
923
1087
  `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
924
- timeoutSeconds,
1088
+ { deadline, dateProvider: this.dateProvider },
925
1089
  0.5,
926
1090
  );
927
1091
  } catch (err) {
@@ -969,6 +1133,7 @@ export class ProposalHandler {
969
1133
  };
970
1134
  }
971
1135
 
1136
+ // Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
972
1137
  const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
973
1138
  if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
974
1139
  this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
@@ -1008,9 +1173,41 @@ export class ProposalHandler {
1008
1173
  log: this.log,
1009
1174
  });
1010
1175
 
1011
- // Fork world state at the block before the first block
1176
+ // Fork world state at the block before the first block. getFork syncs world state to the parent block
1177
+ // first (see its doc): the block source (archiver) can already hold the block while world state still
1178
+ // trails it by one, and forking a not-yet-applied block throws a raw tree error that would otherwise
1179
+ // escape as an uncaught gossipsub error. We pass the parent's expected block hash so the sync detects a
1180
+ // world-state reorg (undefined for the genesis parent, where no block exists to pin). On failure we map
1181
+ // to a clean validation result rather than letting it escape.
1012
1182
  const parentBlockNumber = BlockNumber(firstBlock.number - 1);
1013
- await using fork = await this.checkpointsBuilder.getFork(parentBlockNumber);
1183
+ let forkResult: MerkleTreeWriteOperations;
1184
+ try {
1185
+ const parentBlockHash = (await this.blockSource.getBlockData({ number: parentBlockNumber }))?.blockHash;
1186
+ forkResult = await this.checkpointsBuilder.getFork(parentBlockNumber, parentBlockHash);
1187
+ } catch (err) {
1188
+ this.log.warn(`Failed to fork world state at block ${parentBlockNumber} for checkpoint proposal`, {
1189
+ ...proposalInfo,
1190
+ parentBlockNumber,
1191
+ err,
1192
+ });
1193
+ return { isValid: false, reason: 'world_state_not_synced', checkpointNumber };
1194
+ }
1195
+ await using fork = forkResult;
1196
+
1197
+ // Verify the fork's archive root matches the checkpoint's expected starting archive (the archive after
1198
+ // the parent block). A mismatch means world state forked from a different chain than the proposal was
1199
+ // built on (e.g. a reorg), so recomputing the checkpoint against it would be meaningless. This mirrors
1200
+ // the block-proposal re-execution check and fails fast with a clean, non-slashable result instead of a
1201
+ // confusing downstream mismatch.
1202
+ const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
1203
+ if (!forkArchiveRoot.equals(proposal.checkpointHeader.lastArchiveRoot)) {
1204
+ this.log.warn(`Fork archive root does not match checkpoint proposal's last archive`, {
1205
+ ...proposalInfo,
1206
+ forkArchiveRoot: forkArchiveRoot.toString(),
1207
+ expectedLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
1208
+ });
1209
+ return { isValid: false, reason: 'initial_archive_mismatch', checkpointNumber };
1210
+ }
1014
1211
 
1015
1212
  // Create checkpoint builder with all existing blocks
1016
1213
  const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
@@ -1132,11 +1329,11 @@ export class ProposalHandler {
1132
1329
  }
1133
1330
 
1134
1331
  /**
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.
1332
+ * Derives proposed checkpoint data from validated blocks and sets it on the archiver, so this node can
1333
+ * pipeline building on top of the checkpoint. Does not retry, since validation already waited for the
1334
+ * last block to sync.
1138
1335
  */
1139
- private async setProposedCheckpointFromValidation(proposal: CheckpointProposalCore): Promise<boolean> {
1336
+ private async setProposedCheckpoint(proposal: CheckpointProposalCore): Promise<boolean> {
1140
1337
  if (!this.archiver) {
1141
1338
  return false;
1142
1339
  }