@aztec-labs/validator-client 6.0.0-rc.1 → 6.0.0-rc.2

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.
@@ -82,6 +82,9 @@ export type BlockProposalValidationFailureReason =
82
82
  | 'invalid_signature'
83
83
  | 'invalid_proposal'
84
84
  | 'parent_block_not_found'
85
+ // The parent was found, but pruned from the local chain before validation finished, so a verdict read off the
86
+ // local chain may describe other blocks than the ones the proposal builds on. Never proposer misconduct.
87
+ | 'parent_block_pruned_during_validation'
85
88
  | 'parent_block_wrong_slot'
86
89
  // Streaming Inbox per-block acceptance failures.
87
90
  | StreamingBlockCheckReason
@@ -124,12 +127,17 @@ export type CheckpointProposalValidationFailureReason =
124
127
  | 'invalid_signature'
125
128
  | 'invalid_fee_asset_price_modifier'
126
129
  | 'last_block_not_found'
130
+ // The checkpoint's last block was found, but pruned from the local chain before validation finished, so a verdict
131
+ // read off the local chain may describe other blocks than the ones the proposal builds on. Never proposer misconduct.
132
+ | 'last_block_pruned_during_validation'
127
133
  | 'block_fetch_error'
128
134
  | 'world_state_not_synced'
129
135
  | 'checkpoint_already_published'
130
136
  | 'last_block_archive_mismatch'
131
137
  | 'too_many_blocks_in_checkpoint'
132
138
  | 'initial_archive_mismatch'
139
+ // The header's lastArchiveRoot is not the archive the checkpoint's first block builds on.
140
+ | 'last_archive_root_mismatch'
133
141
  | 'checkpoint_header_mismatch'
134
142
  | 'archive_mismatch'
135
143
  | 'out_hash_mismatch'
@@ -161,8 +169,8 @@ type CheckpointInboxPrefixReason = Extract<
161
169
  *
162
170
  * Matched on the error name rather than with `instanceof`, because the archiver package is only a dev dependency
163
171
  * here: the validator talks to its archiver through the `L2BlockSink` interface and must not take a runtime
164
- * dependency on the implementation to classify its errors. Both prefix outcomes are local-view disagreements, so
165
- * neither is in {@link SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT}.
172
+ * dependency on the implementation to classify its errors. Every reason returned here is a local-view disagreement,
173
+ * so none is in {@link SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT}.
166
174
  */
167
175
  function getInboxPrefixInsertFailureReason(err: unknown): StreamingBlockCheckReason | undefined {
168
176
  if (!(err instanceof Error)) {
@@ -174,8 +182,11 @@ function getInboxPrefixInsertFailureReason(err: unknown): StreamingBlockCheckRea
174
182
  case 'InboxPrefixNotSyncedError':
175
183
  case 'ProposedBlockParentNotFoundError':
176
184
  return 'inbox_prefix_unavailable';
185
+ // An insert-time consumption rewind means an L1 reorg landed between the deterministic metadata check (which
186
+ // already passed) and the insert, so it is a local-view disagreement, not proposer misconduct. Report it as the
187
+ // non-slashable prefix mismatch; the deterministic metadata check remains the only slashing evidence for a rewind.
177
188
  case 'InboxConsumptionRewindsError':
178
- return 'consumption_moves_backwards';
189
+ return 'inbox_prefix_mismatch';
179
190
  default:
180
191
  return undefined;
181
192
  }
@@ -195,9 +206,11 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
195
206
  invalid_fee_asset_price_modifier: 'invalid',
196
207
  checkpoint_already_published: undefined,
197
208
  last_block_not_found: 'unvalidated',
209
+ last_block_pruned_during_validation: 'unvalidated',
198
210
  block_fetch_error: 'unvalidated',
199
211
  world_state_not_synced: 'unvalidated',
200
212
  initial_archive_mismatch: 'unvalidated',
213
+ last_archive_root_mismatch: 'invalid',
201
214
  last_block_archive_mismatch: 'invalid',
202
215
  too_many_blocks_in_checkpoint: 'invalid',
203
216
  checkpoint_header_mismatch: 'invalid',
@@ -241,6 +254,9 @@ type CheckpointComputationResult =
241
254
  | { checkpointNumber: CheckpointNumber; reason?: undefined }
242
255
  | { checkpointNumber?: undefined; reason: 'invalid_proposal' | 'global_variables_mismatch' };
243
256
 
257
+ /** A block proposal's log context, completed with its block and checkpoint numbers as validation resolves them. */
258
+ type BlockProposalLogInfo = LogData & { blockNumber?: BlockNumber; checkpointNumber?: CheckpointNumber };
259
+
244
260
  type BlockProposalSlotValidationResult =
245
261
  | { isValid: true }
246
262
  | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' };
@@ -332,16 +348,45 @@ export type BlockProposalObservers = {
332
348
  ) => void;
333
349
  };
334
350
 
335
- /** Block-proposal validation failures that constitute a slashable invalid-block offense. */
336
- export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidationFailureReason[] = [
337
- 'state_mismatch',
338
- 'failed_txs',
339
- 'global_variables_mismatch',
340
- 'invalid_proposal',
341
- 'parent_block_wrong_slot',
342
- 'duplicate_txs',
343
- 'invalid_embedded_txs',
344
- ];
351
+ /**
352
+ * Block-proposal validation failures classified by whether they constitute a slashable invalid-block offense.
353
+ * A `Record` over the whole reason union, not a plain array, so a newly added reason - in particular one that reaches
354
+ * `BlockProposalValidationFailureReason` through `StreamingBlockCheckReason` - fails to compile until it is classified
355
+ * here, the way {@link SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT} already does for checkpoints.
356
+ */
357
+ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: Record<BlockProposalValidationFailureReason, boolean> = {
358
+ // enabled: deterministic offenses computed from the block's own content, so every honest node rejects them.
359
+ ['invalid_proposal']: true,
360
+ ['state_mismatch']: true,
361
+ ['failed_txs']: true,
362
+ ['global_variables_mismatch']: true,
363
+ ['parent_block_wrong_slot']: true,
364
+ ['duplicate_txs']: true,
365
+ ['invalid_embedded_txs']: true,
366
+ // Deterministic streaming-Inbox violations: computed from the block's own content by the metadata check, so a
367
+ // reject is the proposer's fault.
368
+ ['consumption_moves_backwards']: true,
369
+ ['bundle_over_block_cap']: true,
370
+ ['checkpoint_over_msg_cap']: true,
371
+
372
+ // disabled: local-view disagreements, timeouts, and this node's own inability to check - never proposer misconduct.
373
+ ['invalid_signature']: false,
374
+ ['parent_block_not_found']: false,
375
+ // A parent found once but pruned mid-validation; the verdict may describe another chain, not proposer misconduct.
376
+ ['parent_block_pruned_during_validation']: false,
377
+ // The local-view streaming reasons: a trailing archiver or an unfollowed reorg, including an insert-time
378
+ // consumption rewind reported as inbox_prefix_mismatch.
379
+ ['inbox_prefix_unavailable']: false,
380
+ ['inbox_prefix_mismatch']: false,
381
+ ['block_number_already_exists']: false,
382
+ ['txs_not_available']: false,
383
+ ['initial_state_mismatch']: false,
384
+ ['timeout']: false,
385
+ ['block_proposal_beyond_checkpoint']: false,
386
+ // Equivocation has its own offense path (markProposalEquivocation), so it is not a slashable invalid-block reason.
387
+ ['checkpoint_proposal_equivocation']: false,
388
+ ['unknown_error']: false,
389
+ };
345
390
 
346
391
  /** Checkpoint-proposal validation failures that constitute a slashable invalid-checkpoint offense. */
347
392
  export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
@@ -351,6 +396,8 @@ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
351
396
  // enabled
352
397
  ['invalid_fee_asset_price_modifier']: true,
353
398
  ['checkpoint_header_mismatch']: true,
399
+ // An unprovable header: the checkpoint circuit derives lastArchiveRoot from the first block's lastArchive.
400
+ ['last_archive_root_mismatch']: true,
354
401
  // These late mismatches should normally be caught by earlier checks, but if reached after validating the local
355
402
  // checkpoint inputs, the proposer-signed payload disagrees with deterministic recomputation.
356
403
  ['archive_mismatch']: true,
@@ -372,13 +419,30 @@ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
372
419
  ['inbox_endpoint_mismatch']: false,
373
420
  ['invalid_signature']: false,
374
421
  ['last_block_not_found']: false,
422
+ // The local chain was pruned under the validation, so a slashable verdict may describe blocks of another chain.
423
+ ['last_block_pruned_during_validation']: false,
375
424
  ['block_fetch_error']: false,
376
425
  ['world_state_not_synced']: false,
377
- // A reorg / divergent local chain, not a proposer offense (mirrors the block path's initial_state_mismatch).
426
+ // World state forked off the chain of the local archiver blocks being rebuilt (e.g. a reorg racing the fork), not
427
+ // a proposer offense (mirrors the block path's initial_state_mismatch).
378
428
  ['initial_archive_mismatch']: false,
379
429
  ['checkpoint_already_published']: false,
380
430
  };
381
431
 
432
+ /** Whether a block-proposal validation result is a rejection for a slashable reason. */
433
+ function isSlashableBlockProposalResult(
434
+ result: BlockProposalValidationResult,
435
+ ): result is BlockProposalValidationFailureResult {
436
+ return !result.isValid && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT[result.reason];
437
+ }
438
+
439
+ /** Whether a checkpoint-proposal validation result is a rejection for a slashable reason. */
440
+ function isSlashableCheckpointProposalResult(
441
+ result: CheckpointProposalValidationResult,
442
+ ): result is CheckpointProposalValidationFailureResult {
443
+ return !result.isValid && SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason];
444
+ }
445
+
382
446
  /**
383
447
  * Handles block and checkpoint proposals for both validator and non-validator nodes. Also tracks which slots
384
448
  * had a slashable invalid proposal or a proposal equivocation, exposing them via the
@@ -502,7 +566,7 @@ export class ProposalHandler {
502
566
  ...(await this.observedProposal(proposal)),
503
567
  accepted: result.isValid && !escapeHatchOpen,
504
568
  reason: result.isValid ? undefined : result.reason,
505
- slashable: !result.isValid && SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason),
569
+ slashable: isSlashableBlockProposalResult(result),
506
570
  escapeHatchOpen,
507
571
  });
508
572
  }
@@ -608,7 +672,7 @@ export class ProposalHandler {
608
672
  if (result.reason === 'checkpoint_proposal_equivocation') {
609
673
  this.markProposalEquivocation(slotNumber);
610
674
  } else if (
611
- SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT.includes(result.reason) &&
675
+ isSlashableBlockProposalResult(result) &&
612
676
  !(await this.epochCache.isEscapeHatchOpenAtSlot(slotNumber))
613
677
  ) {
614
678
  this.markInvalidProposalSlot(slotNumber);
@@ -692,7 +756,7 @@ export class ProposalHandler {
692
756
  // Track invalid checkpoint proposals so offense observers (the attested-invalid-proposal watcher)
693
757
  // work on non-validator nodes too. This handler runs for all nodes; validators also mark via the
694
758
  // failure callback below (idempotent).
695
- if (SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT[result.reason]) {
759
+ if (isSlashableCheckpointProposalResult(result)) {
696
760
  this.markInvalidProposalSlot(proposal.slotNumber);
697
761
  this.markInvalidCheckpointProposal(proposal.slotNumber, proposal.getPayloadHash());
698
762
  }
@@ -735,11 +799,11 @@ export class ProposalHandler {
735
799
  return { isValid: false, reason: 'invalid_signature' };
736
800
  }
737
801
 
738
- const proposalInfo = {
802
+ const proposalInfo: BlockProposalLogInfo = {
739
803
  ...proposal.toBlockInfo(),
740
804
  proposer: proposer.toString(),
741
- blockNumber: undefined as BlockNumber | undefined,
742
- checkpointNumber: undefined as CheckpointNumber | undefined,
805
+ blockNumber: undefined,
806
+ checkpointNumber: undefined,
743
807
  };
744
808
 
745
809
  this.log.info(`Processing proposal for slot ${slotNumber}`, {
@@ -788,6 +852,55 @@ export class ProposalHandler {
788
852
  return { isValid: false, reason: 'parent_block_not_found' };
789
853
  }
790
854
 
855
+ const result = await this.validateBlockProposalOnParent(
856
+ proposal,
857
+ parentBlock,
858
+ proposalSender,
859
+ shouldReexecute,
860
+ proposalInfo,
861
+ );
862
+ // A genesis parent cannot be pruned.
863
+ return parentBlock !== 'genesis' && isSlashableBlockProposalResult(result)
864
+ ? await this.checkParentStillLocal(proposal, result, proposalInfo)
865
+ : result;
866
+ }
867
+
868
+ /**
869
+ * Demotes a block-proposal rejection when the proposal's parent is no longer found by archive. The parent is
870
+ * fetched once, but later checks read the local chain by number, and the archiver can prune the parent and insert
871
+ * other blocks at the same numbers while they run: under pipelining, when a checkpoint lands on L1 differing from the
872
+ * version this node gossiped. A prune removes the pruned blocks' archive entries, and an archive root commits to the
873
+ * block's whole history, so a parent still found by archive means the chain below it is still its own ancestry. A
874
+ * parent that is gone means the verdict may rest on another chain, which says nothing about the proposer.
875
+ */
876
+ private async checkParentStillLocal(
877
+ proposal: BlockProposal,
878
+ result: BlockProposalValidationFailureResult,
879
+ proposalInfo: BlockProposalLogInfo,
880
+ ): Promise<BlockProposalValidationFailureResult> {
881
+ if ((await this.getParentBlock(proposal, { awaitSync: false })) !== undefined) {
882
+ return result;
883
+ }
884
+ this.log.warn(
885
+ `Parent block was pruned while validating the proposal, not attributing the rejection to its proposer`,
886
+ {
887
+ ...proposalInfo,
888
+ originalReason: result.reason,
889
+ },
890
+ );
891
+ return { ...result, reason: 'parent_block_pruned_during_validation' };
892
+ }
893
+
894
+ /** Validates a block proposal against the parent block it builds on, as fetched by the parent archive. */
895
+ private async validateBlockProposalOnParent(
896
+ proposal: ValidatedBlockProposal,
897
+ parentBlock: 'genesis' | BlockData,
898
+ proposalSender: PeerId,
899
+ shouldReexecute: boolean,
900
+ proposalInfo: BlockProposalLogInfo,
901
+ ): Promise<BlockProposalValidationResult> {
902
+ const slotNumber = proposal.slotNumber;
903
+
791
904
  // Check that the parent block's slot is not greater than the proposal's slot.
792
905
  if (parentBlock !== 'genesis' && parentBlock.header.getSlot() > slotNumber) {
793
906
  this.log.warn(`Parent block slot is greater than proposal slot, skipping processing`, {
@@ -1030,7 +1143,14 @@ export class ProposalHandler {
1030
1143
  }
1031
1144
  }
1032
1145
 
1033
- private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> {
1146
+ /**
1147
+ * Looks up the block a proposal builds on by its parent archive. Unless `awaitSync` is false, a parent that is not
1148
+ * local yet is waited for through forced archiver syncs until the slot's attestation deadline.
1149
+ */
1150
+ private async getParentBlock(
1151
+ proposal: BlockProposal,
1152
+ { awaitSync = true }: { awaitSync?: boolean } = {},
1153
+ ): Promise<'genesis' | BlockData | undefined> {
1034
1154
  const parentArchive = proposal.blockHeader.lastArchive.root;
1035
1155
  const { genesisArchiveRoot } = await this.blockSource.getGenesisValues();
1036
1156
 
@@ -1043,7 +1163,7 @@ export class ProposalHandler {
1043
1163
  if (parentBlock !== undefined) {
1044
1164
  return parentBlock;
1045
1165
  }
1046
- if (this.getReexecutionDeadline(proposal.slotNumber).getTime() - this.dateProvider.now() <= 0) {
1166
+ if (!awaitSync || this.getReexecutionDeadline(proposal.slotNumber).getTime() - this.dateProvider.now() <= 0) {
1047
1167
  return undefined;
1048
1168
  }
1049
1169
  const synced = await this.awaitLocalSync(proposal.slotNumber, 'force archiver sync', () =>
@@ -1302,6 +1422,10 @@ export class ProposalHandler {
1302
1422
  * never saw. The retry re-runs the whole read (metadata plus range) on each attempt rather than reusing a range
1303
1423
  * from an earlier view, because the parent and checkpoint-start counts it derives can move while a reorg is being
1304
1424
  * followed.
1425
+ *
1426
+ * Every attempt's unexpected error is kept, not only the first one's. A read that starts as ordinary sync lag and
1427
+ * later hits a store fault or a broken provider is the case worth reporting, and reporting only the first attempt
1428
+ * discards exactly that: the diagnosis a node whose retries ran out has to offer is its latest failure.
1305
1429
  */
1306
1430
  private async awaitStreamingBlockBundle(
1307
1431
  proposal: BlockProposal,
@@ -1309,9 +1433,16 @@ export class ProposalHandler {
1309
1433
  parentBlock: 'genesis' | BlockData,
1310
1434
  proposalInfo: LogData,
1311
1435
  ): Promise<StreamingBlockCheckResult> {
1436
+ let latestUnexpectedError: string | undefined;
1312
1437
  const readBundle = async (): Promise<StreamingBlockCheckResult> => {
1313
1438
  const metadata = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
1314
- return metadata.accepted ? readStreamingBlockBundle(this.l1ToL2MessageSource, metadata) : metadata;
1439
+ const result: StreamingBlockCheckResult = metadata.accepted
1440
+ ? await readStreamingBlockBundle(this.l1ToL2MessageSource, metadata)
1441
+ : metadata;
1442
+ if (!result.accepted && result.error !== undefined) {
1443
+ latestUnexpectedError = result.error;
1444
+ }
1445
+ return result;
1315
1446
  };
1316
1447
 
1317
1448
  const first = await readBundle();
@@ -1334,12 +1465,14 @@ export class ProposalHandler {
1334
1465
  reason: 'inbox_prefix_sync_timeout',
1335
1466
  firstReason: first.reason,
1336
1467
  // Set only when the message source failed for a reason the checks did not anticipate, rather than sync lag.
1337
- error: first.error,
1468
+ // The latest such failure, since a later store fault says more about why this node gave up than the first
1469
+ // attempt's lag does.
1470
+ error: latestUnexpectedError,
1338
1471
  slot: slotNumber,
1339
1472
  waitedMs: timer.ms(),
1340
1473
  ...proposalInfo,
1341
1474
  });
1342
- return first;
1475
+ return { ...first, error: latestUnexpectedError };
1343
1476
  }
1344
1477
  return resolved;
1345
1478
  }
@@ -1524,8 +1657,11 @@ export class ProposalHandler {
1524
1657
 
1525
1658
  /**
1526
1659
  * Reads the blocks of a slot as one snapshot for checkpoint validation, locating the checkpoint's last block by the
1527
- * signed archive. Undefined when the archive is not among the slot's blocks or the blocks do not chain onto each
1528
- * other, both of which are the archiver replacing or pruning blocks while they were read.
1660
+ * signed archive. Undefined when the archive is not among the slot's blocks, the blocks do not chain onto each
1661
+ * other, or they are not one checkpoint's blocks from its first one on. All of these are local state in motion:
1662
+ * the archiver replacing or pruning blocks while they were read, or skipping a block whose body it cannot load.
1663
+ * Checkpoint validation relies on `blocks[0]` being the checkpoint's first block to attribute a wrong
1664
+ * lastArchiveRoot to the proposer.
1529
1665
  */
1530
1666
  private async readCheckpointBlocksSnapshot(
1531
1667
  slot: SlotNumber,
@@ -1538,8 +1674,11 @@ export class ProposalHandler {
1538
1674
  }
1539
1675
  const contiguous = blocks.every(
1540
1676
  (block, i) =>
1541
- i === 0 ||
1542
- (block.number === blocks[i - 1].number + 1 && block.header.lastArchive.root.equals(blocks[i - 1].archive.root)),
1677
+ block.checkpointNumber === blocks[0].checkpointNumber &&
1678
+ block.indexWithinCheckpoint === i &&
1679
+ (i === 0 ||
1680
+ (block.number === blocks[i - 1].number + 1 &&
1681
+ block.header.lastArchive.root.equals(blocks[i - 1].archive.root))),
1543
1682
  );
1544
1683
  return contiguous ? { blocks, lastBlockIndex } : undefined;
1545
1684
  }
@@ -1770,7 +1909,10 @@ export class ProposalHandler {
1770
1909
  );
1771
1910
  result = { isValid: false, reason: 'invalid_fee_asset_price_modifier' };
1772
1911
  } else {
1773
- result = await this.validateCheckpointProposal(proposal, proposalInfo);
1912
+ const validation = await this.validateCheckpointProposal(proposal, proposalInfo);
1913
+ result = isSlashableCheckpointProposalResult(validation)
1914
+ ? await this.checkLastBlockStillLocal(proposal, validation, proposalInfo)
1915
+ : validation;
1774
1916
  }
1775
1917
 
1776
1918
  this.lastCheckpointValidationResult = { payloadHash, result };
@@ -1803,10 +1945,16 @@ export class ProposalHandler {
1803
1945
  // slot is a different question, and still records. A recorded `invalid` is protected for the slot outright:
1804
1946
  // the tracker keys its slot entry by slot alone, so an equivocating proposer whose second proposal this node
1805
1947
  // could not check would otherwise erase the first one's determination.
1948
+ //
1949
+ // The protected `valid` is matched by slot and archive as well as by checkpoint number, because the second look
1950
+ // can fail before the blocks are loaded and then carries no checkpoint number at all: pruning the block the
1951
+ // cached verdict rested on makes revalidation report `last_block_not_found`, which by checkpoint number alone
1952
+ // is indistinguishable from a first evaluation and would overwrite the verdict this node reached.
1806
1953
  const outcome = result.isValid ? ('valid' as const) : CHECKPOINT_VALIDATION_REASON_TO_OUTCOME[result.reason];
1807
1954
  const wouldForgetVerdict =
1808
1955
  outcome === 'unvalidated' &&
1809
1956
  (this.reexecutionTracker.getOutcomeForSlot(slot) === 'invalid' ||
1957
+ this.reexecutionTracker.hasValidOutcomeForSlot(slot, proposal.archive) ||
1810
1958
  (result.checkpointNumber !== undefined &&
1811
1959
  this.reexecutionTracker.hasReexecuted(result.checkpointNumber, proposal.archive)));
1812
1960
  if (outcome !== undefined && !wouldForgetVerdict) {
@@ -1915,12 +2063,47 @@ export class ProposalHandler {
1915
2063
  }
1916
2064
  }
1917
2065
 
1918
- /** The endpoint gate's own ceiling, narrowed by a slot whose attestation window is nearly spent. */
2066
+ /**
2067
+ * Demotes a checkpoint rejection when the checkpoint's last block is no longer found by the signed archive.
2068
+ * Validation reads the slot's blocks once, but reads the block before the checkpoint and the parent checkpoint by
2069
+ * number afterwards, and the archiver can prune those and insert other blocks at the same numbers while the
2070
+ * checkpoint is rebuilt. A prune removes the pruned blocks' archive entries, and the last block's archive commits to
2071
+ * its whole history, so a last block still found by archive means the chain below it is still its own ancestry.
2072
+ */
2073
+ private async checkLastBlockStillLocal(
2074
+ proposal: CheckpointProposalCore,
2075
+ result: CheckpointProposalValidationFailureResult,
2076
+ proposalInfo: LogData,
2077
+ ): Promise<CheckpointProposalValidationFailureResult> {
2078
+ if ((await this.blockSource.getBlockData({ archive: proposal.archive })) !== undefined) {
2079
+ return result;
2080
+ }
2081
+ this.log.warn(`Checkpoint's last block was pruned while validating the proposal, not attributing the rejection`, {
2082
+ ...proposalInfo,
2083
+ originalReason: result.reason,
2084
+ });
2085
+ return { ...result, reason: 'last_block_pruned_during_validation' };
2086
+ }
2087
+
2088
+ /**
2089
+ * The endpoint gate's own ceiling, narrowed by a slot whose attestation window is nearly spent.
2090
+ *
2091
+ * The floor survives past the attestation deadline on purpose, and is bounded at
2092
+ * {@link INBOX_ENDPOINT_CHECK_MIN_WINDOW_MS} for it: a late node still wants the content verdict for telemetry,
2093
+ * and reaching one costs one bounded local L1 read. What the floor must not buy is a signature, which is why
2094
+ * {@link getAttestationDeadline} is re-read at the signing boundary rather than here — a window is not a
2095
+ * permission to attest.
2096
+ */
1919
2097
  private getInboxEndpointWindowMs(slot: SlotNumber): number {
1920
2098
  const remainingMs = this.getReexecutionDeadline(slot).getTime() - this.dateProvider.now();
1921
2099
  return Math.min(INBOX_ENDPOINT_CHECK_WINDOW_MS, Math.max(INBOX_ENDPOINT_CHECK_MIN_WINDOW_MS, remainingMs));
1922
2100
  }
1923
2101
 
2102
+ /** The consensus attestation deadline for a slot: the last moment an attestation for it may be signed. */
2103
+ public getAttestationDeadline(slot: SlotNumber): Date {
2104
+ return this.getReexecutionDeadline(slot);
2105
+ }
2106
+
1924
2107
  /**
1925
2108
  * Validates a checkpoint proposal by building the full checkpoint and comparing it with the proposal.
1926
2109
  * @returns Validation result with isValid flag and reason if invalid.
@@ -1941,9 +2124,10 @@ export class ProposalHandler {
1941
2124
  // runs on: the block carrying the signed archive is the checkpoint's last block, and the slot's blocks before it
1942
2125
  // are the checkpoint's. Reading the last block by archive and the slot's blocks separately would let a local prune
1943
2126
  // between the two reads look like a proposer offense; a slot read without the signed archive, or one that is not
1944
- // a contiguous chain, is local state in motion and is retried. The deadline is passed to retryUntil as an
1945
- // absolute date so the remaining budget is derived from the date provider; a deadline already in the past times
1946
- // out after a single attempt instead of looping (the immediate-timeout semantics of the deadline overload).
2127
+ // a contiguous chain of one checkpoint's blocks starting at its first, is local state in motion and is retried.
2128
+ // The deadline is passed to retryUntil as an absolute date so the remaining budget is derived from the date
2129
+ // provider; a deadline already in the past times out after a single attempt instead of looping (the
2130
+ // immediate-timeout semantics of the deadline overload).
1947
2131
  let snapshot: CheckpointBlocksSnapshot | undefined;
1948
2132
  try {
1949
2133
  snapshot = await retryUntil(
@@ -2031,6 +2215,18 @@ export class ProposalHandler {
2031
2215
  const constants = this.extractCheckpointConstants(firstBlock);
2032
2216
  const checkpointNumber = firstBlock.checkpointNumber;
2033
2217
 
2218
+ // The rebuild derives the header's lastArchiveRoot from the first block's lastArchive (as does the checkpoint
2219
+ // circuit), so any other claimed value is an unprovable header; reject it before paying for the rebuild.
2220
+ const expectedLastArchiveRoot = firstBlock.header.lastArchive.root;
2221
+ if (!proposal.checkpointHeader.lastArchiveRoot.equals(expectedLastArchiveRoot)) {
2222
+ this.log.warn(`Checkpoint proposal's last archive root does not match its first block`, {
2223
+ ...proposalInfo,
2224
+ expectedLastArchiveRoot: expectedLastArchiveRoot.toString(),
2225
+ proposalLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
2226
+ });
2227
+ return { isValid: false, reason: 'last_archive_root_mismatch', checkpointNumber };
2228
+ }
2229
+
2034
2230
  // The checkpoint's Inbox consumption starts at the leaf count of the block before its first block. Without that
2035
2231
  // block the consumed bundle cannot be derived; an empty bundle would make a valid proposal fail its rolling-hash
2036
2232
  // recomputation and be classified as a proposer offense, so a missing parent is a local fetch failure instead.
@@ -2104,17 +2300,15 @@ export class ProposalHandler {
2104
2300
  }
2105
2301
  await using fork = forkResult;
2106
2302
 
2107
- // Verify the fork's archive root matches the checkpoint's expected starting archive (the archive after
2108
- // the parent block). A mismatch means world state forked from a different chain than the proposal was
2109
- // built on (e.g. a reorg), so recomputing the checkpoint against it would be meaningless. This mirrors
2110
- // the block-proposal re-execution check and fails fast with a clean, non-slashable result instead of a
2111
- // confusing downstream mismatch.
2303
+ // World state must be on the same chain as the archiver blocks being rebuilt; otherwise the rebuild would disagree
2304
+ // with an honest proposal. This is a local race (e.g. a reorg between the hash-pinned sync and the fork), never a
2305
+ // proposer offense. It mirrors the block-proposal re-execution check.
2112
2306
  const forkArchiveRoot = new Fr((await fork.getTreeInfo(MerkleTreeId.ARCHIVE)).root);
2113
- if (!forkArchiveRoot.equals(proposal.checkpointHeader.lastArchiveRoot)) {
2114
- this.log.warn(`Fork archive root does not match checkpoint proposal's last archive`, {
2307
+ if (!forkArchiveRoot.equals(expectedLastArchiveRoot)) {
2308
+ this.log.warn(`Fork archive root does not match the checkpoint's first block last archive`, {
2115
2309
  ...proposalInfo,
2116
2310
  forkArchiveRoot: forkArchiveRoot.toString(),
2117
- expectedLastArchiveRoot: proposal.checkpointHeader.lastArchiveRoot.toString(),
2311
+ expectedLastArchiveRoot: expectedLastArchiveRoot.toString(),
2118
2312
  });
2119
2313
  return { isValid: false, reason: 'initial_archive_mismatch', checkpointNumber };
2120
2314
  }
@@ -211,12 +211,21 @@ export async function readStreamingBlockBundle(
211
211
  }
212
212
 
213
213
  /**
214
- * Message fragments that identify an ordinary local-view shortfall from the archiver's range read: the range reaches
215
- * past what this node has synced, or its bounds are not a valid range at all. Matched on the message text rather than
216
- * on the error's class, since a JSON-RPC hop between the validator and its message source leaves the class behind
217
- * while preserving the message.
214
+ * The complete message forms of an ordinary local-view shortfall from the archiver's range read: the range reaches
215
+ * past what this node has synced, or its bounds are not a valid range at all. Matched on the message text rather
216
+ * than on the error's class, since a JSON-RPC hop between the validator and its message source leaves the class
217
+ * behind while preserving the message.
218
+ *
219
+ * Anchored to the whole message on purpose. A substring test also swallows anything that quotes one of these — a
220
+ * store or provider fault reported as `database unavailable: Inbox message range [3, 7) is not fully synced` is a
221
+ * failure the checks did not anticipate, and reporting it as ordinary sync lag loses the only diagnosis a validator
222
+ * that runs out of retries can offer. Matching is about which failures are *reported*, not which are accepted:
223
+ * every range-read failure stays non-punitive either way.
218
224
  */
219
- const EXPECTED_RANGE_READ_FAILURES = ['is not fully synced', 'Invalid Inbox leaf count range'];
225
+ const EXPECTED_RANGE_READ_FAILURES: readonly RegExp[] = [
226
+ /^Inbox message range \[-?\d+, -?\d+\) is not fully synced(: [\s\S]*)?$/,
227
+ /^Invalid Inbox leaf count range \[-?\d+, -?\d+\)$/,
228
+ ];
220
229
 
221
230
  /** Upper bound on the error text carried onto a result, so a verbose provider error cannot blow up a log record. */
222
231
  const MAX_REPORTED_ERROR_LENGTH = 200;
@@ -224,7 +233,7 @@ const MAX_REPORTED_ERROR_LENGTH = 200;
224
233
  /** Bounded text of a range-read failure the checks did not anticipate, or undefined for ordinary sync lag. */
225
234
  function unexpectedRangeReadError(err: unknown): string | undefined {
226
235
  const message = err instanceof Error ? err.message : String(err);
227
- return EXPECTED_RANGE_READ_FAILURES.some(fragment => message.includes(fragment))
236
+ return EXPECTED_RANGE_READ_FAILURES.some(form => form.test(message))
228
237
  ? undefined
229
238
  : message.slice(0, MAX_REPORTED_ERROR_LENGTH);
230
239
  }