@aztec/validator-client 0.0.1-commit.993d240 → 0.0.1-commit.9a89641

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.
@@ -13,27 +13,36 @@ 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';
19
20
  import { DateProvider, Timer } from '@aztec/foundation/timer';
21
+ import { isErrorClass } from '@aztec/foundation/types';
20
22
  import type { P2P, PeerId } from '@aztec/p2p';
21
23
  import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
22
24
  import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
23
25
  import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
24
26
  import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint';
25
- import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
27
+ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
26
28
  import { Gas } from '@aztec/stdlib/gas';
27
- import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
29
+ import type {
30
+ ITxProvider,
31
+ MerkleTreeWriteOperations,
32
+ ValidatorClientFullConfig,
33
+ WorldStateSynchronizer,
34
+ } from '@aztec/stdlib/interfaces/server';
28
35
  import {
29
36
  type L1ToL2MessageSource,
30
37
  accumulateCheckpointOutHashes,
31
38
  computeInHashFromL1ToL2Messages,
32
39
  } from '@aztec/stdlib/messaging';
33
40
  import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
41
+ import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
34
42
  import { MerkleTreeId } from '@aztec/stdlib/trees';
35
- import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
43
+ import type { CheckpointGlobalVariables, FailedTx, Tx, TxHash } from '@aztec/stdlib/tx';
36
44
  import {
45
+ InvalidBlockProposalTxsError,
37
46
  ReExFailedTxsError,
38
47
  ReExInitialStateMismatchError,
39
48
  ReExStateMismatchError,
@@ -49,12 +58,13 @@ export type BlockProposalValidationFailureReason =
49
58
  | 'invalid_signature'
50
59
  | 'invalid_proposal'
51
60
  | 'parent_block_not_found'
52
- | 'block_source_not_synced'
53
61
  | 'parent_block_wrong_slot'
54
62
  | 'in_hash_mismatch'
55
63
  | 'global_variables_mismatch'
56
64
  | 'block_number_already_exists'
57
65
  | 'txs_not_available'
66
+ | 'duplicate_txs'
67
+ | 'invalid_embedded_txs'
58
68
  | 'state_mismatch'
59
69
  | 'failed_txs'
60
70
  | 'initial_state_mismatch'
@@ -90,10 +100,12 @@ export type CheckpointProposalValidationFailureReason =
90
100
  | 'invalid_fee_asset_price_modifier'
91
101
  | 'last_block_not_found'
92
102
  | 'block_fetch_error'
103
+ | 'world_state_not_synced'
93
104
  | 'checkpoint_already_published'
94
105
  | 'no_blocks_for_slot'
95
106
  | 'last_block_archive_mismatch'
96
107
  | 'too_many_blocks_in_checkpoint'
108
+ | 'initial_archive_mismatch'
97
109
  | 'checkpoint_header_mismatch'
98
110
  | 'archive_mismatch'
99
111
  | 'out_hash_mismatch'
@@ -114,6 +126,8 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
114
126
  checkpoint_already_published: undefined,
115
127
  last_block_not_found: 'unvalidated',
116
128
  block_fetch_error: 'unvalidated',
129
+ world_state_not_synced: 'unvalidated',
130
+ initial_archive_mismatch: 'unvalidated',
117
131
  no_blocks_for_slot: 'unvalidated',
118
132
  last_block_archive_mismatch: 'invalid',
119
133
  too_many_blocks_in_checkpoint: 'invalid',
@@ -152,7 +166,54 @@ type BlockProposalSlotValidationResult =
152
166
  | { isValid: true }
153
167
  | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
154
168
 
155
- /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
169
+ const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
170
+
171
+ /** Block-proposal validation failures that constitute a slashable invalid-block offense. */
172
+ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
173
+ 'state_mismatch',
174
+ 'failed_txs',
175
+ 'global_variables_mismatch',
176
+ 'invalid_proposal',
177
+ 'parent_block_wrong_slot',
178
+ 'in_hash_mismatch',
179
+ 'duplicate_txs',
180
+ 'invalid_embedded_txs',
181
+ ];
182
+
183
+ /** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
184
+ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
185
+ CheckpointProposalValidationFailureReason,
186
+ boolean
187
+ > = {
188
+ // enabled
189
+ ['invalid_fee_asset_price_modifier']: true,
190
+ ['checkpoint_header_mismatch']: true,
191
+ // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
192
+ // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
193
+ ['archive_mismatch']: true,
194
+ ['out_hash_mismatch']: true,
195
+ ['no_blocks_for_slot']: true,
196
+ ['too_many_blocks_in_checkpoint']: true,
197
+ ['checkpoint_validation_failed']: true,
198
+ ['last_block_archive_mismatch']: true,
199
+
200
+ // disabled
201
+ ['invalid_signature']: false,
202
+ ['last_block_not_found']: false,
203
+ ['block_fetch_error']: false,
204
+ ['world_state_not_synced']: false,
205
+ // A reorg / divergent local chain, not a proposer offense (mirrors the block path's initial_state_mismatch).
206
+ ['initial_archive_mismatch']: false,
207
+ ['checkpoint_already_published']: false,
208
+ };
209
+
210
+ /**
211
+ * Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
212
+ * had a slashable invalid proposal or a proposal equivocation, exposing them via the
213
+ * `InvalidProposalSlotSource` interface consumed by the attested-invalid-proposal slashing watcher. The
214
+ * tracking is populated as a side effect of validating/re-executing proposals, so any node that re-executes
215
+ * proposals (the default) can serve it — not only validators.
216
+ */
156
217
  export class ProposalHandler {
157
218
  public readonly tracer: Tracer;
158
219
 
@@ -165,7 +226,7 @@ export class ProposalHandler {
165
226
  };
166
227
 
167
228
  /** Archiver reference for setting proposed checkpoints (pipelining). Set via register(). */
168
- private archiver?: Pick<Archiver, 'addProposedCheckpoint'>;
229
+ private archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>;
169
230
 
170
231
  /** Returns current validator addresses for own-proposal detection. Set via register(). */
171
232
  private getOwnValidatorAddresses?: () => string[];
@@ -175,6 +236,12 @@ export class ProposalHandler {
175
236
 
176
237
  private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
177
238
 
239
+ /** Slots at which a slashable invalid block or checkpoint proposal was observed. */
240
+ private readonly slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
241
+
242
+ /** Slots at which a proposal equivocation was observed; suppresses attested-to-invalid-proposal slashing. */
243
+ private readonly slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
244
+
178
245
  constructor(
179
246
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
180
247
  private worldState: WorldStateSynchronizer,
@@ -183,6 +250,7 @@ export class ProposalHandler {
183
250
  private txProvider: ITxProvider,
184
251
  private blockProposalValidator: BlockProposalValidator,
185
252
  private epochCache: EpochCache,
253
+ private timetable: ConsensusTimetable,
186
254
  private config: ValidatorClientFullConfig,
187
255
  private blobClient: BlobClientInterface,
188
256
  private reexecutionTracker: CheckpointReexecutionTracker,
@@ -220,6 +288,26 @@ export class ProposalHandler {
220
288
  this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
221
289
  }
222
290
 
291
+ /** Whether a slashable invalid block or checkpoint proposal was observed at the given slot (InvalidProposalSlotSource). */
292
+ public hasInvalidProposals(slotNumber: SlotNumber): boolean {
293
+ return this.slotsWithInvalidProposals.has(slotNumber);
294
+ }
295
+
296
+ /** Whether a proposal equivocation was observed at the given slot (InvalidProposalSlotSource). */
297
+ public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
298
+ return this.slotsWithProposalEquivocation.has(slotNumber);
299
+ }
300
+
301
+ /** Records a slot as having a slashable invalid proposal, for offense observers (sentinel/slasher watchers). */
302
+ public markInvalidProposalSlot(slotNumber: SlotNumber): void {
303
+ this.slotsWithInvalidProposals.add(slotNumber);
304
+ }
305
+
306
+ /** Records a slot as having a proposal equivocation, which suppresses attested-to-invalid-proposal slashing. */
307
+ public markProposalEquivocation(slotNumber: SlotNumber): void {
308
+ this.slotsWithProposalEquivocation.add(slotNumber);
309
+ }
310
+
223
311
  /**
224
312
  * Registers handlers for block and checkpoint proposals on the p2p client.
225
313
  * Records the p2p client so validation can inspect retained proposals.
@@ -231,7 +319,7 @@ export class ProposalHandler {
231
319
  register(
232
320
  p2pClient: P2P,
233
321
  shouldReexecute: boolean,
234
- archiver?: Pick<Archiver, 'addProposedCheckpoint'>,
322
+ archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>,
235
323
  getOwnValidatorAddresses?: () => string[],
236
324
  ): ProposalHandler {
237
325
  this.p2pClient = p2pClient;
@@ -255,6 +343,18 @@ export class ProposalHandler {
255
343
  });
256
344
  return true;
257
345
  } else {
346
+ // Track invalid proposals / equivocations so offense observers (the attested-invalid-proposal
347
+ // watcher) work on non-validator nodes too. Validators populate these via their own handlers.
348
+ // Skip invalid-proposal marking while the escape hatch is open, matching the validator path,
349
+ // which intentionally disables invalid-block slashing then.
350
+ if (result.reason === 'checkpoint_proposal_equivocation') {
351
+ this.markProposalEquivocation(slotNumber);
352
+ } else if (
353
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
354
+ !(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
355
+ ) {
356
+ this.markInvalidProposalSlot(slotNumber);
357
+ }
258
358
  this.log.warn(
259
359
  `Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
260
360
  { blockNumber: result.blockNumber, slotNumber, reason: result.reason },
@@ -269,6 +369,11 @@ export class ProposalHandler {
269
369
 
270
370
  p2pClient.registerBlockProposalHandler(blockHandler);
271
371
 
372
+ // p2p detects duplicate (equivocated) proposals without routing them through the handlers above, so mark
373
+ // the slot as equivocated here. This suppresses false-positive attested-to-invalid-proposal slashing on
374
+ // non-validator offense collectors. Validators overwrite this with their own richer handler.
375
+ p2pClient.registerDuplicateProposalCallback(info => this.markProposalEquivocation(info.slot));
376
+
272
377
  // All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
273
378
  // Runs for all nodes (validators and non-validators). Validators get the cached result in the
274
379
  // validator-specific callback (attestToCheckpointProposal) which runs after this one.
@@ -297,25 +402,35 @@ export class ProposalHandler {
297
402
  return undefined;
298
403
  }
299
404
 
300
- // For own proposals, skip validation and return: the proposer already built and validated the
301
- // checkpoint, and the sequencer's checkpoint proposal job pushed the proposed checkpoint to the
302
- // archiver from local data before broadcasting. Gossipsub doesn't echo our own messages back, so
303
- // this branch is normally unreachable it remains as defense if an own proposal arrives by some
304
- // other path.
405
+ // A proposal is "own" when it was signed by a validator key this node also owns. The true local
406
+ // proposer already built, validated, and stored this checkpoint before broadcasting, so a matching
407
+ // proposed checkpoint is already in its archiver skip the redundant re-validation. An HA peer that
408
+ // shares the proposer's keys sees the same "own" proposal over gossip but never built it, so it has
409
+ // nothing stored; it falls through to the normal validate-and-persist path below to hydrate the
410
+ // proposed-checkpoint metadata it needs to build the next slot on top of this checkpoint.
305
411
  const proposer = proposal.getSender();
306
412
  const ownAddresses = this.getOwnValidatorAddresses?.();
307
413
  const isOwnProposal = proposer && ownAddresses?.some(addr => addr === proposer.toString());
308
414
 
309
415
  if (isOwnProposal) {
310
- this.log.debug(`Skipping validation for own checkpoint proposal at slot ${proposal.slotNumber}`);
311
- return undefined;
416
+ const existing = await this.archiver?.getProposedCheckpointData({ slot: proposal.slotNumber });
417
+ if (existing?.archive.root.equals(proposal.archive)) {
418
+ this.log.debug(`Skipping sync for existing own checkpoint proposal at slot ${proposal.slotNumber}`);
419
+ return undefined;
420
+ }
312
421
  }
313
422
 
314
423
  const result = await this.handleCheckpointProposal(proposal, proposalInfo);
315
424
  if (!result.isValid) {
425
+ // Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
426
+ // work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
427
+ // failure callback below (idempotent).
428
+ if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
429
+ this.markInvalidProposalSlot(proposal.slotNumber);
430
+ }
316
431
  await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
317
- } else if (this.archiver && this.epochCache.isProposerPipeliningEnabled()) {
318
- const set = await this.setProposedCheckpointFromValidation(proposal);
432
+ } else if (this.archiver) {
433
+ const set = await this.setProposedCheckpoint(proposal);
319
434
  if (set) {
320
435
  this.metrics?.recordCheckpointProposalToPipelinedStateDuration(pipeliningTimer.ms());
321
436
  }
@@ -338,7 +453,6 @@ export class ProposalHandler {
338
453
  ): Promise<BlockProposalValidationResult> {
339
454
  const slotNumber = proposal.slotNumber;
340
455
  const proposer = proposal.getSender();
341
- const config = this.checkpointsBuilder.getConfig();
342
456
 
343
457
  // Reject proposals with invalid signatures
344
458
  if (!proposer) {
@@ -366,6 +480,20 @@ export class ProposalHandler {
366
480
  return { isValid: false, reason: 'invalid_proposal' };
367
481
  }
368
482
 
483
+ // A tx can only appear once in a block: the second copy would emit nullifiers already emitted by the
484
+ // first. This is not a relaying-peer fault, so it passes gossip validation and is classified here as
485
+ // proposer misbehavior. Tx collection also reconciles a deduplicated hash set against the full list,
486
+ // so it must not be handed a proposal with repeated hashes.
487
+ const uniqueTxHashes = new Set(proposal.txHashes.map(txHash => txHash.toString()));
488
+ if (uniqueTxHashes.size !== proposal.txHashes.length) {
489
+ this.log.warn(`Proposal lists duplicate tx hashes, skipping processing`, {
490
+ ...proposalInfo,
491
+ txCount: proposal.txHashes.length,
492
+ uniqueTxCount: uniqueTxHashes.size,
493
+ });
494
+ return { isValid: false, reason: 'duplicate_txs' };
495
+ }
496
+
369
497
  const retainedSlotValidation = await this.validateNewBlockInSlot(proposal);
370
498
  if (!retainedSlotValidation.isValid) {
371
499
  this.log.info(`Block proposal conflicts with retained proposals, skipping archiver processing`, {
@@ -376,20 +504,9 @@ export class ProposalHandler {
376
504
  return { isValid: false, blockNumber: proposal.blockNumber, reason: retainedSlotValidation.reason };
377
505
  }
378
506
 
379
- // Ensure the block source is synced before checking for existing blocks,
380
- // since a proposed checkpoint prune may remove blocks we'd otherwise find.
381
- // This affects mostly the block_number_already_exists check, since a pending
382
- // checkpoint prune could remove a block that would conflict with this proposal.
383
- // When pipelining is enabled, the proposer builds ahead of L1 submission, so the
384
- // block source won't have synced to the proposed slot yet. Skip the sync wait to
385
- // avoid eating into the attestation window.
386
- if (!this.epochCache.isProposerPipeliningEnabled()) {
387
- const blockSourceSync = await this.waitForBlockSourceSync(slotNumber);
388
- if (!blockSourceSync) {
389
- this.log.warn(`Block source is not synced, skipping processing`, proposalInfo);
390
- return { isValid: false, reason: 'block_source_not_synced' };
391
- }
392
- }
507
+ // The proposer builds ahead of L1 submission under pipelining, so the block source won't have
508
+ // synced to the proposed slot yet. We deliberately do not wait for it to sync here, to avoid
509
+ // eating into the attestation window.
393
510
 
394
511
  // Check that the parent proposal is a block we know, otherwise reexecution would fail.
395
512
  // If we don't find it immediately, we keep retrying for a while; it may be we still
@@ -417,8 +534,12 @@ export class ProposalHandler {
417
534
  : BlockNumber(parentBlock.header.getBlockNumber() + 1);
418
535
  proposalInfo.blockNumber = blockNumber;
419
536
 
420
- // Check that this block number does not exist already
421
- const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
537
+ // Check that this block number does not exist already. During a reorg the archiver can still hold a
538
+ // stale block at this number (a different archive, about to be pruned) while the proposal carries the
539
+ // rebuilt replacement; resolveExistingBlockAtNumber waits for the local prune in that case so the
540
+ // rebuilt block is processed in time to attest, rather than being permanently dropped on a bare
541
+ // number collision.
542
+ const existingBlock = await this.resolveExistingBlockAtNumber(blockNumber, proposal.archive, slotNumber);
422
543
  if (existingBlock) {
423
544
  this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
424
545
  return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
@@ -426,10 +547,11 @@ export class ProposalHandler {
426
547
 
427
548
  // Collect txs from the proposal. We start doing this as early as possible,
428
549
  // 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.
429
- const { txs, missingTxs } = await this.txProvider.getTxsForBlockProposal(proposal, blockNumber, {
430
- pinnedPeer: proposalSender,
431
- deadline: this.getReexecutionDeadline(slotNumber, config),
432
- });
550
+ const collected = await this.collectProposalTxs(proposal, blockNumber, proposalSender, proposalInfo);
551
+ if (collected === 'invalid_embedded_txs') {
552
+ return { isValid: false, blockNumber, reason: collected };
553
+ }
554
+ const { txs, missingTxs } = collected;
433
555
 
434
556
  // Record the tx-collection outcome on the re-execution tracker
435
557
  this.reexecutionTracker.recordTxsCollected(slotNumber, proposal.indexWithinCheckpoint, missingTxs.length === 0);
@@ -479,7 +601,7 @@ export class ProposalHandler {
479
601
  epoch,
480
602
  checkpointNumber,
481
603
  l1Constants: this.epochCache.getL1Constants(),
482
- pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(),
604
+ pipeliningEnabled: true,
483
605
  log: this.log,
484
606
  });
485
607
 
@@ -502,7 +624,7 @@ export class ProposalHandler {
502
624
  }
503
625
 
504
626
  // If we succeeded, push this block into the archiver (unless disabled)
505
- if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) {
627
+ if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) {
506
628
  await this.blockSource.addBlock(reexecutionResult.block);
507
629
  }
508
630
 
@@ -514,6 +636,36 @@ export class ProposalHandler {
514
636
  return { isValid: true, blockNumber, reexecutionResult };
515
637
  }
516
638
 
639
+ /**
640
+ * Collects the txs for a proposal, returning `invalid_embedded_txs` if the proposal carries a tx that fails
641
+ * minimum integrity validation. That is proposer misbehavior — the proposal signs both the tx hashes and the
642
+ * tx objects — so the caller turns it into an invalid-proposal result that reaches slashing and invalid-slot
643
+ * accounting, rather than letting it escape as an exception. Any other collection error is a local failure
644
+ * and keeps propagating.
645
+ */
646
+ private async collectProposalTxs(
647
+ proposal: BlockProposal,
648
+ blockNumber: BlockNumber,
649
+ proposalSender: PeerId,
650
+ proposalInfo: LogData,
651
+ ): Promise<{ txs: Tx[]; missingTxs: TxHash[] } | 'invalid_embedded_txs'> {
652
+ try {
653
+ return await this.txProvider.getTxsForBlockProposal(proposal, blockNumber, {
654
+ pinnedPeer: proposalSender,
655
+ deadline: this.getReexecutionDeadline(proposal.slotNumber),
656
+ });
657
+ } catch (error) {
658
+ if (!isErrorClass(error, InvalidBlockProposalTxsError)) {
659
+ throw error;
660
+ }
661
+ this.log.warn(`Block proposal carries ${error.invalidTxs.length} invalid txs`, {
662
+ ...proposalInfo,
663
+ invalidTxs: error.invalidTxs.map(({ txHash, reasons }) => ({ txHash: txHash.toString(), reasons })),
664
+ });
665
+ return 'invalid_embedded_txs';
666
+ }
667
+ }
668
+
517
669
  private async validateNewBlockInSlot(blockProposal: BlockProposal): Promise<BlockProposalSlotValidationResult> {
518
670
  if (!this.p2pClient) {
519
671
  return { isValid: true };
@@ -536,16 +688,14 @@ export class ProposalHandler {
536
688
 
537
689
  private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
538
690
  const parentArchive = proposal.blockHeader.lastArchive.root;
539
- const config = this.checkpointsBuilder.getConfig();
540
691
  const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
541
692
 
542
693
  if (parentArchive.equals(genesisArchiveRoot)) {
543
694
  return 'genesis';
544
695
  }
545
696
 
546
- const deadline = this.getReexecutionDeadline(proposal.slotNumber, config);
547
- const currentTime = this.dateProvider.now();
548
- const timeoutDurationMs = deadline.getTime() - currentTime;
697
+ const deadline = this.getReexecutionDeadline(proposal.slotNumber);
698
+ const timeoutDurationMs = deadline.getTime() - this.dateProvider.now();
549
699
 
550
700
  try {
551
701
  return (
@@ -556,7 +706,7 @@ export class ProposalHandler {
556
706
  () =>
557
707
  this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
558
708
  'force archiver sync',
559
- timeoutDurationMs / 1000,
709
+ { deadline, dateProvider: this.dateProvider },
560
710
  0.5,
561
711
  ))
562
712
  );
@@ -570,6 +720,63 @@ export class ProposalHandler {
570
720
  }
571
721
  }
572
722
 
723
+ /**
724
+ * Resolves whether a block genuinely already exists at `blockNumber`. Returns the existing block only if
725
+ * it is a true duplicate of the proposal (matching archive). During a reorg the archiver can still hold a
726
+ * stale fork at this number (different archive) that is about to be pruned; in that case this forces L1
727
+ * sync and waits, bounded by the re-execution deadline, for the prune to land, then returns `undefined` so
728
+ * the rebuilt block proposal can be processed in time to attest. If the prune does not complete before the
729
+ * deadline it returns the stale block, so the caller falls back to the safe `block_number_already_exists`
730
+ * rejection.
731
+ */
732
+ private async resolveExistingBlockAtNumber(
733
+ blockNumber: BlockNumber,
734
+ proposalArchive: Fr,
735
+ slotNumber: SlotNumber,
736
+ ): Promise<BlockData | undefined> {
737
+ const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
738
+ if (!existingBlock || existingBlock.archive.root.equals(proposalArchive)) {
739
+ return existingBlock;
740
+ }
741
+
742
+ // A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
743
+ // genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
744
+ const deadline = this.getReexecutionDeadline(slotNumber);
745
+ if (deadline.getTime() - this.dateProvider.now() <= 0) {
746
+ return existingBlock;
747
+ }
748
+
749
+ this.log.warn(`Block number ${blockNumber} already exists, awaiting potential prune`, {
750
+ blockNumber,
751
+ existingArchive: existingBlock.archive.root.toString(),
752
+ proposalArchive: proposalArchive.toString(),
753
+ });
754
+
755
+ try {
756
+ const { block } = await retryUntil(
757
+ async () => {
758
+ await this.blockSource.syncImmediate();
759
+ const block = await this.blockSource.getBlockData({ number: blockNumber });
760
+ // Resolve once the existing block is gone (pruned) or has been replaced by one matching the
761
+ // proposal — the same condition as the early return above. A matching block is returned so the
762
+ // caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
763
+ // be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
764
+ return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
765
+ },
766
+ `prune of stale block ${blockNumber}`,
767
+ { deadline, dateProvider: this.dateProvider },
768
+ 0.5,
769
+ );
770
+ return block;
771
+ } catch (err) {
772
+ if (err instanceof TimeoutError) {
773
+ this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
774
+ return existingBlock;
775
+ }
776
+ throw err;
777
+ }
778
+ }
779
+
573
780
  private computeCheckpointNumber(
574
781
  proposal: BlockProposal,
575
782
  parentBlock: 'genesis' | BlockData,
@@ -693,52 +900,14 @@ export class ProposalHandler {
693
900
  return undefined;
694
901
  }
695
902
 
696
- private getReexecutionDeadline(
697
- slotNumber: SlotNumber,
698
- config: { l1GenesisTime: bigint; slotDuration: number },
699
- ): Date {
700
- // Under proposer pipelining, the proposal slot may be ahead of wall clock time.
701
- // Reexecution budgets should still be bounded by the current slot we are in now.
702
- const wallclockSlot = slotNumber - this.epochCache.pipeliningOffset();
703
- const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(wallclockSlot + 1), config));
704
- return new Date(nextSlotTimestampSeconds * 1000);
705
- }
706
-
707
- /** Waits for the block source to sync L1 data up to at least the slot before the given one. */
708
- private async waitForBlockSourceSync(slot: SlotNumber): Promise<boolean> {
709
- const deadline = this.getReexecutionDeadline(slot, this.checkpointsBuilder.getConfig());
710
- const timeoutMs = deadline.getTime() - this.dateProvider.now();
711
- if (slot === 0) {
712
- return true;
713
- }
714
-
715
- // Make a quick check before triggering an archiver sync
716
- // If we are pipelining and have a pending checkpoint number stored, we will allow the block proposal to be for a slot further
717
- const syncedSlot = await this.blockSource.getSyncedL2SlotNumber();
718
- if (syncedSlot !== undefined && syncedSlot + 1 + this.epochCache.pipeliningOffset() >= slot) {
719
- return true;
720
- }
721
-
722
- try {
723
- // Trigger an immediate sync of the block source, and wait until it reports being synced to the required slot
724
- return await retryUntil(
725
- async () => {
726
- await this.blockSource.syncImmediate();
727
- const updatedSyncedSlot = await this.blockSource.getSyncedL2SlotNumber();
728
- return updatedSyncedSlot !== undefined && updatedSyncedSlot + 1 >= slot;
729
- },
730
- 'wait for block source sync',
731
- timeoutMs / 1000,
732
- 0.5,
733
- );
734
- } catch (err) {
735
- if (err instanceof TimeoutError) {
736
- this.log.warn(`Timed out waiting for block source to sync to slot ${slot}`);
737
- return false;
738
- } else {
739
- throw err;
740
- }
741
- }
903
+ /**
904
+ * Hard re-execution/validation deadline for any block or checkpoint proposal targeting `slotNumber`:
905
+ * the single consensus `attestation_deadline` (`target_slot_start + S - 2E`). This is the latest the
906
+ * checkpoint can land on L1 in the target slot; all nodes agree on it. Loosened from the previous
907
+ * next-wall-clock-slot-boundary bound (see the timetable spec / refactor notes).
908
+ */
909
+ private getReexecutionDeadline(slotNumber: SlotNumber): Date {
910
+ return new Date(this.timetable.getAttestationDeadline(slotNumber) * 1000);
742
911
  }
743
912
 
744
913
  private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
@@ -818,7 +987,7 @@ export class ProposalHandler {
818
987
  );
819
988
 
820
989
  // Build the new block
821
- const deadline = this.getReexecutionDeadline(slot, config);
990
+ const deadline = this.getReexecutionDeadline(slot);
822
991
  const maxBlockGas =
823
992
  this.config.validateMaxL2BlockGas !== undefined || this.config.validateMaxDABlockGas !== undefined
824
993
  ? new Gas(this.config.validateMaxDABlockGas ?? Infinity, this.config.validateMaxL2BlockGas ?? Infinity)
@@ -950,15 +1119,15 @@ export class ProposalHandler {
950
1119
  ): Promise<CheckpointProposalValidationResult> {
951
1120
  const slot = proposal.slotNumber;
952
1121
 
953
- // Block-sync deadline = the moment the proposer can no longer publish this checkpoint to L1.
954
- // With pipelining off that's the end of the proposal's own slot; with pipelining on the
955
- // proposal is built one slot ahead, so the publication deadline is the start of the target
956
- // slot. `getReexecutionDeadline` handles both cases.
957
- const config = this.checkpointsBuilder.getConfig();
958
- const deadline = this.getReexecutionDeadline(slot, config);
959
- const timeoutSeconds = Math.max(1, Math.floor((deadline.getTime() - this.dateProvider.now()) / 1000));
1122
+ // Block-sync/validation deadline = the single consensus attestation_deadline (target_slot_start + S
1123
+ // - 2E): the latest moment the proposer can submit this checkpoint and still have it land on L1 in
1124
+ // the target slot. Keeping validation/attestation alive until then lets validators keep attesting
1125
+ // right up to the proposer's real publish cutoff.
1126
+ const deadline = this.getReexecutionDeadline(slot);
960
1127
 
961
- // Wait for last block to sync by archive
1128
+ // Wait for last block to sync by archive. The deadline is passed to retryUntil as an absolute date so
1129
+ // the remaining budget is derived from the date provider; a deadline already in the past times out
1130
+ // after a single attempt instead of looping (the immediate-timeout semantics of the deadline overload).
962
1131
  let lastBlockData;
963
1132
  try {
964
1133
  lastBlockData = await retryUntil(
@@ -967,7 +1136,7 @@ export class ProposalHandler {
967
1136
  return await this.blockSource.getBlockData({ archive: proposal.archive });
968
1137
  },
969
1138
  `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
970
- timeoutSeconds,
1139
+ { deadline, dateProvider: this.dateProvider },
971
1140
  0.5,
972
1141
  );
973
1142
  } catch (err) {
@@ -1015,6 +1184,7 @@ export class ProposalHandler {
1015
1184
  };
1016
1185
  }
1017
1186
 
1187
+ // Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
1018
1188
  const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
1019
1189
  if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
1020
1190
  this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
@@ -1050,13 +1220,45 @@ export class ProposalHandler {
1050
1220
  epoch,
1051
1221
  checkpointNumber,
1052
1222
  l1Constants: this.epochCache.getL1Constants(),
1053
- pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(),
1223
+ pipeliningEnabled: true,
1054
1224
  log: this.log,
1055
1225
  });
1056
1226
 
1057
- // Fork world state at the block before the first block
1227
+ // Fork world state at the block before the first block. getFork syncs world state to the parent block
1228
+ // first (see its doc): the block source (archiver) can already hold the block while world state still
1229
+ // trails it by one, and forking a not-yet-applied block throws a raw tree error that would otherwise
1230
+ // escape as an uncaught gossipsub error. We pass the parent's expected block hash so the sync detects a
1231
+ // world-state reorg (undefined for the genesis parent, where no block exists to pin). On failure we map
1232
+ // to a clean validation result rather than letting it escape.
1058
1233
  const parentBlockNumber = BlockNumber(firstBlock.number - 1);
1059
- await using fork = await this.checkpointsBuilder.getFork(parentBlockNumber);
1234
+ let forkResult: MerkleTreeWriteOperations;
1235
+ try {
1236
+ const parentBlockHash = (await this.blockSource.getBlockData({ number: parentBlockNumber }))?.blockHash;
1237
+ forkResult = await this.checkpointsBuilder.getFork(parentBlockNumber, parentBlockHash);
1238
+ } catch (err) {
1239
+ this.log.warn(`Failed to fork world state at block ${parentBlockNumber} for checkpoint proposal`, {
1240
+ ...proposalInfo,
1241
+ parentBlockNumber,
1242
+ err,
1243
+ });
1244
+ return { isValid: false, reason: 'world_state_not_synced', checkpointNumber };
1245
+ }
1246
+ await using fork = forkResult;
1247
+
1248
+ // Verify the fork's archive root matches the checkpoint's expected starting archive (the archive after
1249
+ // the parent block). A mismatch means world state forked from a different chain than the proposal was
1250
+ // built on (e.g. a reorg), so recomputing the checkpoint against it would be meaningless. This mirrors
1251
+ // the block-proposal re-execution check and fails fast with a clean, non-slashable result instead of a
1252
+ // confusing downstream mismatch.
1253
+ const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
1254
+ if (!forkArchiveRoot.equals(proposal.checkpointHeader.lastArchiveRoot)) {
1255
+ this.log.warn(`Fork archive root does not match checkpoint proposal's last archive`, {
1256
+ ...proposalInfo,
1257
+ forkArchiveRoot: forkArchiveRoot.toString(),
1258
+ expectedLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
1259
+ });
1260
+ return { isValid: false, reason: 'initial_archive_mismatch', checkpointNumber };
1261
+ }
1060
1262
 
1061
1263
  // Create checkpoint builder with all existing blocks
1062
1264
  const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
@@ -1178,11 +1380,11 @@ export class ProposalHandler {
1178
1380
  }
1179
1381
 
1180
1382
  /**
1181
- * Derives proposed checkpoint data from validated blocks and sets it on the archiver.
1182
- * Used after successful validation of a foreign proposal.
1183
- * Does not retry since we already waited for the block during validation.
1383
+ * Derives proposed checkpoint data from validated blocks and sets it on the archiver, so this node can
1384
+ * pipeline building on top of the checkpoint. Does not retry, since validation already waited for the
1385
+ * last block to sync.
1184
1386
  */
1185
- private async setProposedCheckpointFromValidation(proposal: CheckpointProposalCore): Promise<boolean> {
1387
+ private async setProposedCheckpoint(proposal: CheckpointProposalCore): Promise<boolean> {
1186
1388
  if (!this.archiver) {
1187
1389
  return false;
1188
1390
  }