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

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,7 +23,7 @@ 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
28
  import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server';
28
29
  import {
@@ -31,6 +32,7 @@ import {
31
32
  computeInHashFromL1ToL2Messages,
32
33
  } from '@aztec/stdlib/messaging';
33
34
  import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
35
+ import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
34
36
  import { MerkleTreeId } from '@aztec/stdlib/trees';
35
37
  import type { CheckpointGlobalVariables, FailedTx, Tx } from '@aztec/stdlib/tx';
36
38
  import {
@@ -151,7 +153,49 @@ type BlockProposalSlotValidationResult =
151
153
  | { isValid: true }
152
154
  | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
153
155
 
154
- /** Handles block and checkpoint proposals for both validator and non-validator nodes. */
156
+ const MAX_TRACKED_INVALID_PROPOSAL_SLOTS = 1000;
157
+
158
+ /** Block-proposal validation failures that constitute a slashable invalid-block offense. */
159
+ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
160
+ 'state_mismatch',
161
+ 'failed_txs',
162
+ 'global_variables_mismatch',
163
+ 'invalid_proposal',
164
+ 'parent_block_wrong_slot',
165
+ 'in_hash_mismatch',
166
+ ];
167
+
168
+ /** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
169
+ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
170
+ CheckpointProposalValidationFailureReason,
171
+ boolean
172
+ > = {
173
+ // enabled
174
+ ['invalid_fee_asset_price_modifier']: true,
175
+ ['checkpoint_header_mismatch']: true,
176
+ // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
177
+ // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
178
+ ['archive_mismatch']: true,
179
+ ['out_hash_mismatch']: true,
180
+ ['no_blocks_for_slot']: true,
181
+ ['too_many_blocks_in_checkpoint']: true,
182
+ ['checkpoint_validation_failed']: true,
183
+ ['last_block_archive_mismatch']: true,
184
+
185
+ // disabled
186
+ ['invalid_signature']: false,
187
+ ['last_block_not_found']: false,
188
+ ['block_fetch_error']: false,
189
+ ['checkpoint_already_published']: false,
190
+ };
191
+
192
+ /**
193
+ * Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
194
+ * had a slashable invalid proposal or a proposal equivocation, exposing them via the
195
+ * `InvalidProposalSlotSource` interface consumed by the attested-invalid-proposal slashing watcher. The
196
+ * tracking is populated as a side effect of validating/re-executing proposals, so any node that re-executes
197
+ * proposals (the default) can serve it — not only validators.
198
+ */
155
199
  export class ProposalHandler {
156
200
  public readonly tracer: Tracer;
157
201
 
@@ -164,7 +208,7 @@ export class ProposalHandler {
164
208
  };
165
209
 
166
210
  /** Archiver reference for setting proposed checkpoints (pipelining). Set via register(). */
167
- private archiver?: Pick<Archiver, 'addProposedCheckpoint'>;
211
+ private archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>;
168
212
 
169
213
  /** Returns current validator addresses for own-proposal detection. Set via register(). */
170
214
  private getOwnValidatorAddresses?: () => string[];
@@ -174,6 +218,12 @@ export class ProposalHandler {
174
218
 
175
219
  private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;
176
220
 
221
+ /** Slots at which a slashable invalid block or checkpoint proposal was observed. */
222
+ private readonly slotsWithInvalidProposals = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
223
+
224
+ /** Slots at which a proposal equivocation was observed; suppresses attested-to-invalid-proposal slashing. */
225
+ private readonly slotsWithProposalEquivocation = FifoSet.withLimit<SlotNumber>(MAX_TRACKED_INVALID_PROPOSAL_SLOTS);
226
+
177
227
  constructor(
178
228
  private checkpointsBuilder: FullNodeCheckpointsBuilder,
179
229
  private worldState: WorldStateSynchronizer,
@@ -182,6 +232,7 @@ export class ProposalHandler {
182
232
  private txProvider: ITxProvider,
183
233
  private blockProposalValidator: BlockProposalValidator,
184
234
  private epochCache: EpochCache,
235
+ private timetable: ConsensusTimetable,
185
236
  private config: ValidatorClientFullConfig,
186
237
  private blobClient: BlobClientInterface,
187
238
  private reexecutionTracker: CheckpointReexecutionTracker,
@@ -219,6 +270,26 @@ export class ProposalHandler {
219
270
  this.reexecutionTracker.recordOutcome(slot, archive, 'valid', checkpointNumber);
220
271
  }
221
272
 
273
+ /** Whether a slashable invalid block or checkpoint proposal was observed at the given slot (InvalidProposalSlotSource). */
274
+ public hasInvalidProposals(slotNumber: SlotNumber): boolean {
275
+ return this.slotsWithInvalidProposals.has(slotNumber);
276
+ }
277
+
278
+ /** Whether a proposal equivocation was observed at the given slot (InvalidProposalSlotSource). */
279
+ public hasProposalEquivocation(slotNumber: SlotNumber): boolean {
280
+ return this.slotsWithProposalEquivocation.has(slotNumber);
281
+ }
282
+
283
+ /** Records a slot as having a slashable invalid proposal, for offense observers (sentinel/slasher watchers). */
284
+ public markInvalidProposalSlot(slotNumber: SlotNumber): void {
285
+ this.slotsWithInvalidProposals.add(slotNumber);
286
+ }
287
+
288
+ /** Records a slot as having a proposal equivocation, which suppresses attested-to-invalid-proposal slashing. */
289
+ public markProposalEquivocation(slotNumber: SlotNumber): void {
290
+ this.slotsWithProposalEquivocation.add(slotNumber);
291
+ }
292
+
222
293
  /**
223
294
  * Registers handlers for block and checkpoint proposals on the p2p client.
224
295
  * Records the p2p client so validation can inspect retained proposals.
@@ -230,7 +301,7 @@ export class ProposalHandler {
230
301
  register(
231
302
  p2pClient: P2P,
232
303
  shouldReexecute: boolean,
233
- archiver?: Pick<Archiver, 'addProposedCheckpoint'>,
304
+ archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getProposedCheckpointData'>,
234
305
  getOwnValidatorAddresses?: () => string[],
235
306
  ): ProposalHandler {
236
307
  this.p2pClient = p2pClient;
@@ -254,6 +325,18 @@ export class ProposalHandler {
254
325
  });
255
326
  return true;
256
327
  } else {
328
+ // Track invalid proposals / equivocations so offense observers (the attested-invalid-proposal
329
+ // watcher) work on non-validator nodes too. Validators populate these via their own handlers.
330
+ // Skip invalid-proposal marking while the escape hatch is open, matching the validator path,
331
+ // which intentionally disables invalid-block slashing then.
332
+ if (result.reason === 'checkpoint_proposal_equivocation') {
333
+ this.markProposalEquivocation(slotNumber);
334
+ } else if (
335
+ SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
336
+ !(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
337
+ ) {
338
+ this.markInvalidProposalSlot(slotNumber);
339
+ }
257
340
  this.log.warn(
258
341
  `Non-validator block proposal ${blockNumber} at slot ${slotNumber} failed processing with ${result.reason}`,
259
342
  { blockNumber: result.blockNumber, slotNumber, reason: result.reason },
@@ -268,6 +351,11 @@ export class ProposalHandler {
268
351
 
269
352
  p2pClient.registerBlockProposalHandler(blockHandler);
270
353
 
354
+ // p2p detects duplicate (equivocated) proposals without routing them through the handlers above, so mark
355
+ // the slot as equivocated here. This suppresses false-positive attested-to-invalid-proposal slashing on
356
+ // non-validator offense collectors. Validators overwrite this with their own richer handler.
357
+ p2pClient.registerDuplicateProposalCallback(info => this.markProposalEquivocation(info.slot));
358
+
271
359
  // All-nodes checkpoint proposal handler: validates, caches, and sets proposed checkpoint for pipelining.
272
360
  // Runs for all nodes (validators and non-validators). Validators get the cached result in the
273
361
  // validator-specific callback (attestToCheckpointProposal) which runs after this one.
@@ -296,25 +384,35 @@ export class ProposalHandler {
296
384
  return undefined;
297
385
  }
298
386
 
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.
387
+ // A proposal is "own" when it was signed by a validator key this node also owns. The true local
388
+ // proposer already built, validated, and stored this checkpoint before broadcasting, so a matching
389
+ // proposed checkpoint is already in its archiver skip the redundant re-validation. An HA peer that
390
+ // shares the proposer's keys sees the same "own" proposal over gossip but never built it, so it has
391
+ // nothing stored; it falls through to the normal validate-and-persist path below to hydrate the
392
+ // proposed-checkpoint metadata it needs to build the next slot on top of this checkpoint.
304
393
  const proposer = proposal.getSender();
305
394
  const ownAddresses = this.getOwnValidatorAddresses?.();
306
395
  const isOwnProposal = proposer && ownAddresses?.some(addr => addr === proposer.toString());
307
396
 
308
397
  if (isOwnProposal) {
309
- this.log.debug(`Skipping validation for own checkpoint proposal at slot ${proposal.slotNumber}`);
310
- return undefined;
398
+ const existing = await this.archiver?.getProposedCheckpointData({ slot: proposal.slotNumber });
399
+ if (existing?.archive.root.equals(proposal.archive)) {
400
+ this.log.debug(`Skipping sync for existing own checkpoint proposal at slot ${proposal.slotNumber}`);
401
+ return undefined;
402
+ }
311
403
  }
312
404
 
313
405
  const result = await this.handleCheckpointProposal(proposal, proposalInfo);
314
406
  if (!result.isValid) {
407
+ // Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
408
+ // work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
409
+ // failure callback below (idempotent).
410
+ if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
411
+ this.markInvalidProposalSlot(proposal.slotNumber);
412
+ }
315
413
  await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);
316
414
  } else if (this.archiver) {
317
- const set = await this.setProposedCheckpointFromValidation(proposal);
415
+ const set = await this.setProposedCheckpoint(proposal);
318
416
  if (set) {
319
417
  this.metrics?.recordCheckpointProposalToPipelinedStateDuration(pipeliningTimer.ms());
320
418
  }
@@ -337,7 +435,6 @@ export class ProposalHandler {
337
435
  ): Promise<BlockProposalValidationResult> {
338
436
  const slotNumber = proposal.slotNumber;
339
437
  const proposer = proposal.getSender();
340
- const config = this.checkpointsBuilder.getConfig();
341
438
 
342
439
  // Reject proposals with invalid signatures
343
440
  if (!proposer) {
@@ -405,8 +502,12 @@ export class ProposalHandler {
405
502
  : BlockNumber(parentBlock.header.getBlockNumber() + 1);
406
503
  proposalInfo.blockNumber = blockNumber;
407
504
 
408
- // Check that this block number does not exist already
409
- const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
505
+ // Check that this block number does not exist already. During a reorg the archiver can still hold a
506
+ // stale block at this number (a different archive, about to be pruned) while the proposal carries the
507
+ // rebuilt replacement; resolveExistingBlockAtNumber waits for the local prune in that case so the
508
+ // rebuilt block is processed in time to attest, rather than being permanently dropped on a bare
509
+ // number collision.
510
+ const existingBlock = await this.resolveExistingBlockAtNumber(blockNumber, proposal.archive, slotNumber);
410
511
  if (existingBlock) {
411
512
  this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo);
412
513
  return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
@@ -416,7 +517,7 @@ export class ProposalHandler {
416
517
  // 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
518
  const { txs, missingTxs } = await this.txProvider.getTxsForBlockProposal(proposal, blockNumber, {
418
519
  pinnedPeer: proposalSender,
419
- deadline: this.getReexecutionDeadline(slotNumber, config),
520
+ deadline: this.getReexecutionDeadline(slotNumber),
420
521
  });
421
522
 
422
523
  // Record the tx-collection outcome on the re-execution tracker
@@ -490,7 +591,7 @@ export class ProposalHandler {
490
591
  }
491
592
 
492
593
  // If we succeeded, push this block into the archiver (unless disabled)
493
- if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) {
594
+ if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) {
494
595
  await this.blockSource.addBlock(reexecutionResult.block);
495
596
  }
496
597
 
@@ -524,16 +625,14 @@ export class ProposalHandler {
524
625
 
525
626
  private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
526
627
  const parentArchive = proposal.blockHeader.lastArchive.root;
527
- const config = this.checkpointsBuilder.getConfig();
528
628
  const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
529
629
 
530
630
  if (parentArchive.equals(genesisArchiveRoot)) {
531
631
  return 'genesis';
532
632
  }
533
633
 
534
- const deadline = this.getReexecutionDeadline(proposal.slotNumber, config);
535
- const currentTime = this.dateProvider.now();
536
- const timeoutDurationMs = deadline.getTime() - currentTime;
634
+ const deadline = this.getReexecutionDeadline(proposal.slotNumber);
635
+ const timeoutDurationMs = deadline.getTime() - this.dateProvider.now();
537
636
 
538
637
  try {
539
638
  return (
@@ -544,7 +643,7 @@ export class ProposalHandler {
544
643
  () =>
545
644
  this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })),
546
645
  'force archiver sync',
547
- timeoutDurationMs / 1000,
646
+ { deadline, dateProvider: this.dateProvider },
548
647
  0.5,
549
648
  ))
550
649
  );
@@ -558,6 +657,63 @@ export class ProposalHandler {
558
657
  }
559
658
  }
560
659
 
660
+ /**
661
+ * Resolves whether a block genuinely already exists at `blockNumber`. Returns the existing block only if
662
+ * it is a true duplicate of the proposal (matching archive). During a reorg the archiver can still hold a
663
+ * stale fork at this number (different archive) that is about to be pruned; in that case this forces L1
664
+ * sync and waits, bounded by the re-execution deadline, for the prune to land, then returns `undefined` so
665
+ * the rebuilt block proposal can be processed in time to attest. If the prune does not complete before the
666
+ * deadline it returns the stale block, so the caller falls back to the safe `block_number_already_exists`
667
+ * rejection.
668
+ */
669
+ private async resolveExistingBlockAtNumber(
670
+ blockNumber: BlockNumber,
671
+ proposalArchive: Fr,
672
+ slotNumber: SlotNumber,
673
+ ): Promise<BlockData | undefined> {
674
+ const existingBlock = await this.blockSource.getBlockData({ number: blockNumber });
675
+ if (!existingBlock || existingBlock.archive.root.equals(proposalArchive)) {
676
+ return existingBlock;
677
+ }
678
+
679
+ // A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a
680
+ // genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal.
681
+ const deadline = this.getReexecutionDeadline(slotNumber);
682
+ if (deadline.getTime() - this.dateProvider.now() <= 0) {
683
+ return existingBlock;
684
+ }
685
+
686
+ this.log.warn(`Block number ${blockNumber} already exists, awaiting potential prune`, {
687
+ blockNumber,
688
+ existingArchive: existingBlock.archive.root.toString(),
689
+ proposalArchive: proposalArchive.toString(),
690
+ });
691
+
692
+ try {
693
+ const { block } = await retryUntil(
694
+ async () => {
695
+ await this.blockSource.syncImmediate();
696
+ const block = await this.blockSource.getBlockData({ number: blockNumber });
697
+ // Resolve once the existing block is gone (pruned) or has been replaced by one matching the
698
+ // proposal — the same condition as the early return above. A matching block is returned so the
699
+ // caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal
700
+ // be processed. Wrap in an object so the `undefined` case is still a truthy retry result.
701
+ return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined;
702
+ },
703
+ `prune of stale block ${blockNumber}`,
704
+ { deadline, dateProvider: this.dateProvider },
705
+ 0.5,
706
+ );
707
+ return block;
708
+ } catch (err) {
709
+ if (err instanceof TimeoutError) {
710
+ this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber });
711
+ return existingBlock;
712
+ }
713
+ throw err;
714
+ }
715
+ }
716
+
561
717
  private computeCheckpointNumber(
562
718
  proposal: BlockProposal,
563
719
  parentBlock: 'genesis' | BlockData,
@@ -681,15 +837,14 @@ export class ProposalHandler {
681
837
  return undefined;
682
838
  }
683
839
 
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);
840
+ /**
841
+ * Hard re-execution/validation deadline for any block or checkpoint proposal targeting `slotNumber`:
842
+ * the single consensus `attestation_deadline` (`target_slot_start + S - 2E`). This is the latest the
843
+ * checkpoint can land on L1 in the target slot; all nodes agree on it. Loosened from the previous
844
+ * next-wall-clock-slot-boundary bound (see the timetable spec / refactor notes).
845
+ */
846
+ private getReexecutionDeadline(slotNumber: SlotNumber): Date {
847
+ return new Date(this.timetable.getAttestationDeadline(slotNumber) * 1000);
693
848
  }
694
849
 
695
850
  private getReexecuteFailureReason(err: any): BlockProposalValidationFailureReason {
@@ -769,7 +924,7 @@ export class ProposalHandler {
769
924
  );
770
925
 
771
926
  // Build the new block
772
- const deadline = this.getReexecutionDeadline(slot, config);
927
+ const deadline = this.getReexecutionDeadline(slot);
773
928
  const maxBlockGas =
774
929
  this.config.validateMaxL2BlockGas !== undefined || this.config.validateMaxDABlockGas !== undefined
775
930
  ? new Gas(this.config.validateMaxDABlockGas ?? Infinity, this.config.validateMaxL2BlockGas ?? Infinity)
@@ -901,18 +1056,15 @@ export class ProposalHandler {
901
1056
  ): Promise<CheckpointProposalValidationResult> {
902
1057
  const slot = proposal.slotNumber;
903
1058
 
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
1059
+ // Block-sync/validation deadline = the single consensus attestation_deadline (target_slot_start + S
1060
+ // - 2E): the latest moment the proposer can submit this checkpoint and still have it land on L1 in
1061
+ // the target slot. Keeping validation/attestation alive until then lets validators keep attesting
1062
+ // right up to the proposer's real publish cutoff.
1063
+ const deadline = this.getReexecutionDeadline(slot);
1064
+
1065
+ // Wait for last block to sync by archive. The deadline is passed to retryUntil as an absolute date so
1066
+ // the remaining budget is derived from the date provider; a deadline already in the past times out
1067
+ // after a single attempt instead of looping (the immediate-timeout semantics of the deadline overload).
916
1068
  let lastBlockData;
917
1069
  try {
918
1070
  lastBlockData = await retryUntil(
@@ -921,7 +1073,7 @@ export class ProposalHandler {
921
1073
  return await this.blockSource.getBlockData({ archive: proposal.archive });
922
1074
  },
923
1075
  `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`,
924
- timeoutSeconds,
1076
+ { deadline, dateProvider: this.dateProvider },
925
1077
  0.5,
926
1078
  );
927
1079
  } catch (err) {
@@ -969,6 +1121,7 @@ export class ProposalHandler {
969
1121
  };
970
1122
  }
971
1123
 
1124
+ // Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
972
1125
  const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
973
1126
  if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
974
1127
  this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
@@ -1132,11 +1285,11 @@ export class ProposalHandler {
1132
1285
  }
1133
1286
 
1134
1287
  /**
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.
1288
+ * Derives proposed checkpoint data from validated blocks and sets it on the archiver, so this node can
1289
+ * pipeline building on top of the checkpoint. Does not retry, since validation already waited for the
1290
+ * last block to sync.
1138
1291
  */
1139
- private async setProposedCheckpointFromValidation(proposal: CheckpointProposalCore): Promise<boolean> {
1292
+ private async setProposedCheckpoint(proposal: CheckpointProposalCore): Promise<boolean> {
1140
1293
  if (!this.archiver) {
1141
1294
  return false;
1142
1295
  }