@lodestar/fork-choice 1.51.0-dev.c6e566f915 → 1.51.0-dev.cda05d18f1

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.
@@ -86,8 +86,8 @@ export enum UpdateHeadOpt {
86
86
 
87
87
  export type UpdateAndGetHeadOpt =
88
88
  | {mode: UpdateHeadOpt.GetCanonicalHead}
89
- | {mode: UpdateHeadOpt.GetProposerHead; secFromSlot: number; slot: Slot}
90
- | {mode: UpdateHeadOpt.GetPredictedProposerHead; secFromSlot: number; slot: Slot};
89
+ | {mode: UpdateHeadOpt.GetProposerHead; msFromSlot: number; slot: Slot}
90
+ | {mode: UpdateHeadOpt.GetPredictedProposerHead; msFromSlot: number; slot: Slot};
91
91
 
92
92
  // the initial vote epoch for all validators
93
93
  const INIT_VOTE_SLOT: Slot = 0;
@@ -315,13 +315,13 @@ export class ForkChoice implements IForkChoice {
315
315
  const canonicalHeadBlock = mode === UpdateHeadOpt.GetPredictedProposerHead ? this.getHead() : this.updateHead();
316
316
  switch (mode) {
317
317
  case UpdateHeadOpt.GetPredictedProposerHead:
318
- return {head: this.predictProposerHead(canonicalHeadBlock, opt.secFromSlot, opt.slot)};
318
+ return {head: this.predictProposerHead(canonicalHeadBlock, opt.msFromSlot, opt.slot)};
319
319
  case UpdateHeadOpt.GetProposerHead: {
320
320
  const {
321
321
  proposerHead: head,
322
322
  isHeadTimely,
323
323
  notReorgedReason,
324
- } = this.getProposerHead(canonicalHeadBlock, opt.secFromSlot, opt.slot);
324
+ } = this.getProposerHead(canonicalHeadBlock, opt.msFromSlot, opt.slot);
325
325
  return {head, isHeadTimely, notReorgedReason};
326
326
  }
327
327
  case UpdateHeadOpt.GetCanonicalHead:
@@ -338,7 +338,7 @@ export class ForkChoice implements IForkChoice {
338
338
  // Note when proposer boost reorg is disabled, it always returns false
339
339
  shouldOverrideForkChoiceUpdate(
340
340
  headBlock: ProtoBlock,
341
- secFromSlot: number,
341
+ msFromSlot: number,
342
342
  currentSlot: Slot
343
343
  ): ShouldOverrideForkChoiceUpdateResult {
344
344
  if (headBlock === null) {
@@ -369,7 +369,7 @@ export class ForkChoice implements IForkChoice {
369
369
 
370
370
  const currentTimeOk =
371
371
  headBlock.slot === currentSlot ||
372
- (proposalSlot === currentSlot && this.isProposingOnTime(secFromSlot, currentSlot));
372
+ (proposalSlot === currentSlot && this.isProposingOnTime(msFromSlot, currentSlot));
373
373
 
374
374
  // Mirror the proposer equivocation branch of getProposerHead(). The head slot's attestations are
375
375
  // still queued at this point so the head is assumed weak, same as for the regular branch below.
@@ -455,7 +455,7 @@ export class ForkChoice implements IForkChoice {
455
455
  * By calling this function, we assume we are the proposer of next slot
456
456
  *
457
457
  */
458
- predictProposerHead(headBlock: ProtoBlock, secFromSlot: number, currentSlot: Slot): ProtoBlock {
458
+ predictProposerHead(headBlock: ProtoBlock, msFromSlot: number, currentSlot: Slot): ProtoBlock {
459
459
  const {proposerBoost, proposerBoostReorg} = this.opts ?? {};
460
460
  // Skip re-org attempt if proposer boost (reorg) are disabled
461
461
  if (!proposerBoost || !proposerBoostReorg) {
@@ -468,7 +468,7 @@ export class ForkChoice implements IForkChoice {
468
468
  }
469
469
 
470
470
  const blockRoot = headBlock.blockRoot;
471
- const result = this.shouldOverrideForkChoiceUpdate(headBlock, secFromSlot, currentSlot);
471
+ const result = this.shouldOverrideForkChoiceUpdate(headBlock, msFromSlot, currentSlot);
472
472
 
473
473
  if (result.shouldOverrideFcu) {
474
474
  this.logger?.verbose("Current head is weak. Predicting next block to be built on parent of head.", {
@@ -499,7 +499,7 @@ export class ForkChoice implements IForkChoice {
499
499
  */
500
500
  getProposerHead(
501
501
  headBlock: ProtoBlock,
502
- secFromSlot: number,
502
+ msFromSlot: number,
503
503
  slot: Slot
504
504
  ): {proposerHead: ProtoBlock; isHeadTimely: boolean; notReorgedReason?: NotReorgedReason} {
505
505
  const isHeadTimely = headBlock.timeliness;
@@ -556,7 +556,7 @@ export class ForkChoice implements IForkChoice {
556
556
  }
557
557
 
558
558
  // Only re-org if we are proposing on-time
559
- if (!this.isProposingOnTime(secFromSlot, slot)) {
559
+ if (!this.isProposingOnTime(msFromSlot, slot)) {
560
560
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.NotProposingOnTime};
561
561
  }
562
562
 
@@ -693,7 +693,20 @@ export class ForkChoice implements IForkChoice {
693
693
  }
694
694
 
695
695
  // findHead returns the ProtoNode representing the head
696
- const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot);
696
+ let head: ProtoNode;
697
+ try {
698
+ head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot);
699
+ } catch (e) {
700
+ // A poisoned tree never recovers, shut the node down instead of failing every head computation
701
+ if (
702
+ e instanceof ProtoArrayError &&
703
+ (e.type.code === ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE ||
704
+ e.type.code === ProtoArrayErrorCode.INVALID_JUSTIFIED_EXECUTION_STATUS)
705
+ ) {
706
+ this.irrecoverableError = e;
707
+ }
708
+ throw e;
709
+ }
697
710
 
698
711
  this.head = head;
699
712
  return this.head;
@@ -753,12 +766,15 @@ export class ForkChoice implements IForkChoice {
753
766
  *
754
767
  * `justifiedBalances` balances of justified state which is updated synchronously.
755
768
  * This ensures that the forkchoice is never out of sync.
769
+ *
770
+ * A block whose parent payload chain was invalidated is rejected. `executionStatus` is ignored for a gloas
771
+ * block: it has no payload of its own and inherits the status of the parent variant it builds on.
756
772
  */
757
773
  onBlock(
758
774
  block: BeaconBlock,
759
775
  state: IBeaconStateView,
760
- receiveDelaySec: number,
761
- importDelaySec: number,
776
+ receiveDelayMs: number,
777
+ importDelayMs: number,
762
778
  currentSlot: Slot,
763
779
  executionStatus: BlockExecutionStatus,
764
780
  dataAvailabilityStatus: DataAvailabilityStatus
@@ -781,6 +797,19 @@ export class ForkChoice implements IForkChoice {
781
797
  });
782
798
  }
783
799
 
800
+ // An execution engine verdict can invalidate the parent while this block is being verified. Inserting the
801
+ // block would put a viable node below an invalidated one, or poison the tree if this block is VALID
802
+ if (parentBlock.executionStatus === ExecutionStatus.Invalid) {
803
+ throw new ForkChoiceError({
804
+ code: ForkChoiceErrorCode.INVALID_BLOCK,
805
+ err: {
806
+ code: InvalidBlockCode.PARENT_EXECUTION_INVALID,
807
+ root: parentRootHex,
808
+ hash: parentHashHex,
809
+ },
810
+ });
811
+ }
812
+
784
813
  // Blocks cannot be in the future. If they are, their consideration must be delayed until
785
814
  // the are in the past.
786
815
  //
@@ -830,7 +859,7 @@ export class ForkChoice implements IForkChoice {
830
859
  // Decide whether this block should receive proposer boost, and whether the block is timely.
831
860
  // The store field `this.proposerBoostRoot` and `updateCheckpoints()` are mutated only after
832
861
  // `protoArray.onBlock()` succeeds
833
- const isTimely = this.isBlockReceivedTimely(block, receiveDelaySec);
862
+ const isTimely = this.isBlockReceivedTimely(block, receiveDelayMs);
834
863
  const isProposerBoostBlock =
835
864
  this.opts?.proposerBoost === true &&
836
865
  isTimely &&
@@ -849,14 +878,17 @@ export class ForkChoice implements IForkChoice {
849
878
  const blockEpoch = computeEpochAtSlot(slot);
850
879
 
851
880
  // same logic to compute_pulled_up_tip in the spec, making it inline because of reusing variables
852
- // If the parent checkpoints are already at the same epoch as the block being imported,
853
- // it's impossible for the unrealized checkpoints to differ from the parent's. This
854
- // holds true because:
881
+ // If the block has no slashings and the parent checkpoints are already at the same epoch as the
882
+ // block being imported, it's impossible for the unrealized checkpoints to differ from the parent's.
883
+ // This holds true because:
855
884
  //
856
885
  // 1. A child block cannot have lower FFG checkpoints than its parent.
857
886
  // 2. A block in epoch `N` cannot contain attestations which would justify an epoch higher than `N`.
858
887
  // 3. A block in epoch `N` cannot contain attestations which would finalize an epoch higher than `N - 1`.
859
888
  //
889
+ // Slashings are excluded because they can reduce the unslashed target balance of the child state
890
+ // and therefore lower its unrealized checkpoints.
891
+ //
860
892
  // This is an optimization. It should reduce the amount of times we run
861
893
  // `process_justification_and_finalization` by approximately 1/3rd when the chain is
862
894
  // performing optimally.
@@ -864,6 +896,8 @@ export class ForkChoice implements IForkChoice {
864
896
  let unrealizedFinalizedCheckpoint: CheckpointWithHex;
865
897
  if (this.opts?.computeUnrealized) {
866
898
  if (
899
+ block.body.proposerSlashings.length === 0 &&
900
+ block.body.attesterSlashings.length === 0 &&
867
901
  parentBlock.unrealizedJustifiedEpoch === blockEpoch &&
868
902
  parentBlock.unrealizedFinalizedEpoch + 1 >= blockEpoch
869
903
  ) {
@@ -901,8 +935,8 @@ export class ForkChoice implements IForkChoice {
901
935
  targetRoot: toRootHex(targetRoot),
902
936
  stateRoot: toRootHex(block.stateRoot),
903
937
  timeliness: isTimely,
904
- ptcTimeliness: this.isBlockPtcTimely(block, receiveDelaySec),
905
- importedTimely: this.isBlockImportedTimely(block, importDelaySec),
938
+ ptcTimeliness: this.isBlockPtcTimely(block, receiveDelayMs),
939
+ importedTimely: this.isBlockImportedTimely(block, importDelayMs),
906
940
  proposerIndex: block.proposerIndex,
907
941
 
908
942
  justifiedEpoch: stateJustifiedEpoch,
@@ -937,7 +971,9 @@ export class ForkChoice implements IForkChoice {
937
971
  executionPayloadBlockHash: parentBlockHashFromBid,
938
972
  executionPayloadNumber: parentMeta.number,
939
973
  executionPayloadGasLimit: parentMeta.gasLimit,
940
- executionStatus: this.getPostMergeExecStatus(executionStatus),
974
+ // The parent's default (PENDING) variant does not reflect a SYNCING parent payload, inheriting
975
+ // from the variant matching the bid's parent block hash keeps that payload from being marked VALID
976
+ executionStatus: this.getPostMergeExecStatus(parentBlock.executionStatus),
941
977
  dataAvailabilityStatus,
942
978
  };
943
979
  })()
@@ -998,14 +1034,8 @@ export class ForkChoice implements IForkChoice {
998
1034
  *
999
1035
  * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#on_attestation
1000
1036
  *
1001
- * It only approximates the specification since it does not perform
1002
- * `is_valid_indexed_attestation` since that should already have been called upstream and it's
1003
- * too expensive to call again.
1004
- *
1005
- * ## Notes:
1006
- *
1007
- * The supplied `attestation` **must** pass the `in_valid_indexed_attestation` function as it
1008
- * will not be run here.
1037
+ * Does not run `is_valid_indexed_attestation`. The caller must ensure all of its
1038
+ * validity requirements except index ordering: `attestingIndices` may be unsorted.
1009
1039
  */
1010
1040
  onAttestation(attestation: IndexedAttestation, attDataRoot: string, forceImport?: boolean): void {
1011
1041
  // Ignore any attestations to the zero hash.
@@ -1144,6 +1174,19 @@ export class ForkChoice implements IForkChoice {
1144
1174
  executionStatus: PayloadExecutionStatus,
1145
1175
  dataAvailabilityStatus: DataAvailabilityStatus
1146
1176
  ): void {
1177
+ // Same guard as onBlock: the PENDING variant carries the status of the payload chain this block builds on
1178
+ const pendingBlock = this.getBlockHexDefaultStatus(blockRoot);
1179
+ if (pendingBlock?.executionStatus === ExecutionStatus.Invalid) {
1180
+ throw new ForkChoiceError({
1181
+ code: ForkChoiceErrorCode.INVALID_BLOCK,
1182
+ err: {
1183
+ code: InvalidBlockCode.PARENT_EXECUTION_INVALID,
1184
+ root: pendingBlock.parentRoot,
1185
+ hash: pendingBlock.executionPayloadBlockHash,
1186
+ },
1187
+ });
1188
+ }
1189
+
1147
1190
  this.protoArray.onExecutionPayload(
1148
1191
  blockRoot,
1149
1192
  this.fcStore.currentSlot,
@@ -1432,7 +1475,9 @@ export class ForkChoice implements IForkChoice {
1432
1475
  *
1433
1476
  * `ancestors` is the raw walk and includes the previous finalized block as its last element —
1434
1477
  * callers that don't want the boundary should slice it off themselves.
1435
- * Post-gloas for each block root, it returns exactly one variant of it.
1478
+ * For Gloas parent blocks, `ancestors` contains the selected EMPTY or FULL variant.
1479
+ * The other variant, if present in the traversed range, goes into `nonAncestors` with the same root.
1480
+ * Exclude roots present in `ancestors` before pruning by block root.
1436
1481
  */
1437
1482
  getAllAncestorAndNonAncestorBlocks(
1438
1483
  blockRoot: RootHex,
@@ -1540,7 +1585,9 @@ export class ForkChoice implements IForkChoice {
1540
1585
 
1541
1586
  /** Very expensive function, iterates the entire ProtoArray. TODO: Is this function even necessary? */
1542
1587
  getBlockSummariesByParentRoot(parentRoot: RootHex): ProtoBlock[] {
1543
- return this.protoArray.nodes.filter((node) => node.parentRoot === parentRoot);
1588
+ return this.protoArray.nodes.filter(
1589
+ (node) => node.parentRoot === parentRoot && (!isGloasBlock(node) || node.payloadStatus === PayloadStatus.PENDING)
1590
+ );
1544
1591
  }
1545
1592
 
1546
1593
  /** Very expensive function, iterates the entire ProtoArray. TODO: Is this function even necessary? */
@@ -1549,7 +1596,7 @@ export class ForkChoice implements IForkChoice {
1549
1596
  const blocksAtSlot: ProtoBlock[] = [];
1550
1597
  for (let i = 0, len = nodes.length; i < len; i++) {
1551
1598
  const node = nodes[i];
1552
- if (node.slot === slot) {
1599
+ if (node.slot === slot && (!isGloasBlock(node) || node.payloadStatus === PayloadStatus.PENDING)) {
1553
1600
  blocksAtSlot.push(node);
1554
1601
  }
1555
1602
  }
@@ -1586,13 +1633,14 @@ export class ForkChoice implements IForkChoice {
1586
1633
  * justified's status as well as validate the terminal conditions if terminal block
1587
1634
  * becomes valid
1588
1635
  */
1589
- validateLatestHash(execResponse: LVHExecResponse): void {
1636
+ validateLatestHash(execResponse: LVHExecResponse, opts?: {strict?: boolean}): boolean {
1590
1637
  try {
1591
- this.protoArray.validateLatestHash(execResponse, this.fcStore.currentSlot);
1638
+ return this.protoArray.validateLatestHash(execResponse, this.fcStore.currentSlot, opts);
1592
1639
  } catch (e) {
1593
1640
  if (e instanceof ProtoArrayError && e.type.code === ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE) {
1594
1641
  this.irrecoverableError = e;
1595
1642
  }
1643
+ return false;
1596
1644
  }
1597
1645
  }
1598
1646
 
@@ -1775,9 +1823,9 @@ export class ForkChoice implements IForkChoice {
1775
1823
  * Return true if the block is timely for the current slot.
1776
1824
  * Child class can overwrite this for testing purpose.
1777
1825
  */
1778
- protected isBlockReceivedTimely(block: BeaconBlock, blockDelaySec: number): boolean {
1826
+ protected isBlockReceivedTimely(block: BeaconBlock, blockDelayMs: number): boolean {
1779
1827
  const fork = this.config.getForkName(block.slot);
1780
- const isBeforeLateBlockCutoff = blockDelaySec * 1000 < this.config.getAttestationDueMs(fork);
1828
+ const isBeforeLateBlockCutoff = blockDelayMs < this.config.getAttestationDueMs(fork);
1781
1829
  return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff;
1782
1830
  }
1783
1831
 
@@ -1791,12 +1839,12 @@ export class ForkChoice implements IForkChoice {
1791
1839
  *
1792
1840
  * Child class can overwrite this for testing purpose.
1793
1841
  */
1794
- protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
1842
+ protected isBlockPtcTimely(block: BeaconBlock, blockDelayMs: number): boolean {
1795
1843
  const ptcThresholdMs = this.config.getSlotComponentDurationMs(
1796
1844
  this.config.getForkName(block.slot),
1797
1845
  this.config.PAYLOAD_ATTESTATION_DUE_BPS
1798
1846
  );
1799
- return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs;
1847
+ return this.fcStore.currentSlot === block.slot && blockDelayMs < ptcThresholdMs;
1800
1848
  }
1801
1849
 
1802
1850
  /**
@@ -1804,9 +1852,9 @@ export class ForkChoice implements IForkChoice {
1804
1852
  * block was imported in a timely manner for its own slot.
1805
1853
  * This is not part of the spec, we use this to determine late canonical blocks.
1806
1854
  */
1807
- protected isBlockImportedTimely(block: BeaconBlock, importDelaySec: number): boolean {
1855
+ protected isBlockImportedTimely(block: BeaconBlock, importDelayMs: number): boolean {
1808
1856
  const fork = this.config.getForkName(block.slot);
1809
- const isBeforeLateBlockCutoff = importDelaySec * 1000 < this.config.getAttestationDueMs(fork);
1857
+ const isBeforeLateBlockCutoff = importDelayMs < this.config.getAttestationDueMs(fork);
1810
1858
  return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff;
1811
1859
  }
1812
1860
 
@@ -1891,10 +1939,10 @@ export class ForkChoice implements IForkChoice {
1891
1939
  /**
1892
1940
  * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time
1893
1941
  */
1894
- private isProposingOnTime(secFromSlot: number, slot: Slot): boolean {
1942
+ private isProposingOnTime(msFromSlot: number, slot: Slot): boolean {
1895
1943
  const fork = this.config.getForkName(slot);
1896
1944
  const proposerReorgCutoff = this.config.getProposerReorgCutoffMs(fork);
1897
- return secFromSlot * 1000 <= proposerReorgCutoff;
1945
+ return msFromSlot <= proposerReorgCutoff;
1898
1946
  }
1899
1947
 
1900
1948
  private getPreMergeExecStatus(executionStatus: BlockExecutionStatus): ExecutionStatus.PreMerge {
@@ -123,7 +123,7 @@ export interface IForkChoice {
123
123
  */
124
124
  shouldOverrideForkChoiceUpdate(
125
125
  headBlock: ProtoBlock,
126
- secFromSlot: number,
126
+ msFromSlot: number,
127
127
  currentSlot: Slot
128
128
  ): ShouldOverrideForkChoiceUpdateResult;
129
129
  /**
@@ -155,12 +155,15 @@ export interface IForkChoice {
155
155
  * ## Notes:
156
156
  *
157
157
  * The supplied block **must** pass the `state_transition` function as it will not be run here.
158
+ *
159
+ * Throws `InvalidBlockCode.PARENT_EXECUTION_INVALID` if the parent payload chain was invalidated.
160
+ * `executionStatus` is ignored for a gloas block, it inherits the status of the parent variant it builds on.
158
161
  */
159
162
  onBlock(
160
163
  block: BeaconBlock,
161
164
  state: IBeaconStateView,
162
- receiveDelaySec: number,
163
- importDelaySec: number,
165
+ receiveDelayMs: number,
166
+ importDelayMs: number,
164
167
  currentSlot: Slot,
165
168
  executionStatus: BlockExecutionStatus,
166
169
  dataAvailabilityStatus: DataAvailabilityStatus
@@ -350,14 +353,17 @@ export interface IForkChoice {
350
353
  * `CheckpointWithHex` / finalized root without a specific payload-status variant in mind.
351
354
  */
352
355
  forwardIterateDescendantsDefaultStatus(blockRoot: RootHex): IterableIterator<ProtoBlock>;
356
+ /** One summary per block, its default variant (FULL pre-Gloas, PENDING for Gloas) */
353
357
  getBlockSummariesByParentRoot(parentRoot: RootHex): ProtoBlock[];
358
+ /** One summary per block, its default variant (FULL pre-Gloas, PENDING for Gloas) */
354
359
  getBlockSummariesAtSlot(slot: Slot): ProtoBlock[];
355
360
  /** Returns the distance of common ancestor of nodes to the max of the newNode and the prevNode. */
356
361
  getCommonAncestorDepth(prevBlock: ProtoBlock, newBlock: ProtoBlock): AncestorResult;
357
362
  /**
358
- * Optimistic sync validate till validated latest hash, invalidate any decendant branch if invalidated branch decendant provided
363
+ * Apply an execution engine verdict with a latest valid hash, see `ProtoArray.validateLatestHash`.
364
+ * Returns whether any execution status changed. A consensus failure is recorded in `irrecoverableError`.
359
365
  */
360
- validateLatestHash(execResponse: LVHExecResponse): void;
366
+ validateLatestHash(execResponse: LVHExecResponse, opts?: {strict?: boolean}): boolean;
361
367
 
362
368
  /**
363
369
  * A dependent root is the block root of the last block before the state transition that decided a specific shuffling
package/src/metrics.ts CHANGED
@@ -32,6 +32,11 @@ export function getForkChoiceMetrics(register: MetricsRegisterExtra) {
32
32
  name: "beacon_fork_choice_changed_head_total",
33
33
  help: "Count of occasions fork choice has found a new head",
34
34
  }),
35
+ executionInvalidations: register.gauge<{source: string}>({
36
+ name: "beacon_fork_choice_execution_invalidations_total",
37
+ help: "Count of execution engine INVALID verdicts applied to fork choice, by verdict source",
38
+ labelNames: ["source"],
39
+ }),
35
40
  reorg: register.gauge({
36
41
  name: "beacon_fork_choice_reorg_total",
37
42
  help: "Count of occasions fork choice has switched to a different chain",
@@ -55,6 +55,8 @@ export function isGloasBlock(block: ProtoBlock): block is ProtoBlock & {parentBl
55
55
  export type LVHValidResponse = {
56
56
  executionStatus: ExecutionStatus.Valid;
57
57
  latestValidExecHash: RootHex;
58
+ /** Resolves the exact node to validate when several nodes carry the same payload hash (gloas variants) */
59
+ blockRoot?: RootHex;
58
60
  };
59
61
  export type LVHInvalidResponse = {
60
62
  executionStatus: ExecutionStatus.Invalid;
@@ -1019,12 +1019,23 @@ export class ProtoArray {
1019
1019
  }
1020
1020
 
1021
1021
  /**
1022
- * Optimistic sync validate till validated latest hash, invalidate any descendant branch
1023
- * if invalidate till hash provided. If consensus fails, this will invalidate entire
1024
- * forkChoice which will throw on any call to findHead
1022
+ * Apply an execution engine verdict with a latest valid hash (LVH) to the tree. VALID flips Syncing nodes up to the
1023
+ * LVH node. INVALID invalidates the node identified by `invalidateFromParentBlockRoot/Hash` and its ancestors up to
1024
+ * the LVH node (exclusive), then every descendant. Returns whether any status changed.
1025
+ *
1026
+ * A null or unknown LVH means the engine only vouches for the queried payload being invalid: the queried node and
1027
+ * the nodes that inherit that payload (gloas PENDING and EMPTY variants) are invalidated, nothing above them.
1028
+ *
1029
+ * `strict` keeps the consensus failure semantics for verdicts about a payload the engine just processed: a VALID
1030
+ * or PreMerge node on the invalidated chain poisons the tree. Verdicts from forkchoiceUpdated or about an ancestor
1031
+ * chain are applied with `strict: false`: a node the engine already validated is never invalidated and the justified
1032
+ * node is never crossed. If the walk to the LVH meets either, the LVH is treated as unknown.
1025
1033
  */
1026
- // TODO GLOAS: Review usage of this post-gloas
1027
- validateLatestHash(execResponse: LVHExecResponse, currentSlot: Slot): void {
1034
+ validateLatestHash(
1035
+ execResponse: LVHExecResponse,
1036
+ currentSlot: Slot,
1037
+ {strict = true}: {strict?: boolean} = {}
1038
+ ): boolean {
1028
1039
  // Look reverse because its highly likely node with latestValidExecHash is towards the
1029
1040
  // the leaves of the forkchoice
1030
1041
  //
@@ -1035,71 +1046,128 @@ export class ProtoArray {
1035
1046
  // https://github.com/ChainSafe/lodestar/pull/4182#discussion_r914770167
1036
1047
 
1037
1048
  if (execResponse.executionStatus === ExecutionStatus.Valid) {
1038
- const {latestValidExecHash} = execResponse;
1049
+ const {latestValidExecHash, blockRoot} = execResponse;
1039
1050
  // We use -1 for denoting not found
1040
1051
  let latestValidHashIndex = -1;
1041
1052
 
1042
- for (let nodeIndex = this.nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
1043
- if (this.nodes[nodeIndex].executionPayloadBlockHash === latestValidExecHash) {
1044
- latestValidHashIndex = nodeIndex;
1045
- // We found the block corresponding to latestValidHashIndex, exit the loop
1046
- break;
1053
+ if (blockRoot !== undefined) {
1054
+ latestValidHashIndex = this.getNodeIndexByRootAndBlockHash(blockRoot, latestValidExecHash) ?? -1;
1055
+ } else {
1056
+ for (let nodeIndex = this.nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
1057
+ if (this.nodes[nodeIndex].executionPayloadBlockHash === latestValidExecHash) {
1058
+ latestValidHashIndex = nodeIndex;
1059
+ // We found the block corresponding to latestValidHashIndex, exit the loop
1060
+ break;
1061
+ }
1047
1062
  }
1048
1063
  }
1049
1064
 
1050
1065
  // We are trying to be as forgiving as possible here because ideally latestValidHashIndex
1051
1066
  // should be found in the forkchoice
1052
- if (latestValidHashIndex >= 0) {
1053
- this.propagateValidExecutionStatusByIndex(latestValidHashIndex);
1067
+ if (latestValidHashIndex < 0) {
1068
+ return false;
1054
1069
  }
1055
- } else {
1056
- // In case of invalidation, ideally:
1057
- // i) Find the invalid payload
1058
- // ii) Obtain a chain [LVH.child, LVH.child.child, ....., invalid_payload]
1059
- // iii) Obtain a chain [Last_known_valid_node, ...., LVH]
1060
- //
1061
- // Mark chain iii) as Valid if LVH is non null but right now LVH can be non null without
1062
- // gurranteing chain iii) to be valid: for e.g. in following scenario LVH can be returned
1063
- // as any of SYNCING: SYNCING, SYNCING, SYNCING, INVALID (due to simple check)/
1064
- // So we currently ignore this chain and hope eventually it gets resolved
1065
- //
1066
- // Mark chain ii) as Invalid if LVH is found and non null, else only invalidate invalid_payload
1067
- // if its in fcU.
1068
- //
1069
- const {invalidateFromParentBlockRoot, invalidateFromParentBlockHash, latestValidExecHash} = execResponse;
1070
- const invalidateFromParentIndex = this.getNodeIndexByRootAndBlockHash(
1071
- invalidateFromParentBlockRoot,
1072
- invalidateFromParentBlockHash
1073
- );
1074
- if (invalidateFromParentIndex === undefined) {
1075
- throw Error(
1076
- `Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} invalidateFromParentBlockHash=${invalidateFromParentBlockHash} in forkChoice`
1077
- );
1070
+ const {executionStatus} = this.nodes[latestValidHashIndex];
1071
+ if (executionStatus === ExecutionStatus.Valid || executionStatus === ExecutionStatus.PreMerge) {
1072
+ return false;
1073
+ }
1074
+ if (!strict) {
1075
+ // The walk up flips every Syncing ancestor, a lenient verdict must not contradict an invalidated one
1076
+ let nodeIndex: number | undefined = latestValidHashIndex;
1077
+ while (nodeIndex !== undefined) {
1078
+ const node: ProtoNode = this.nodes[nodeIndex];
1079
+ if (node.executionStatus === ExecutionStatus.Valid || node.executionStatus === ExecutionStatus.PreMerge) {
1080
+ break;
1081
+ }
1082
+ if (node.executionStatus === ExecutionStatus.Invalid) {
1083
+ return false;
1084
+ }
1085
+ nodeIndex = node.parent;
1086
+ }
1078
1087
  }
1079
- const latestValidHashIndex =
1080
- latestValidExecHash !== null ? this.getNodeIndexFromLVH(latestValidExecHash, invalidateFromParentIndex) : null;
1081
- if (latestValidHashIndex === null) {
1082
- /**
1083
- * The LVH (latest valid hash) is null or not found.
1084
- *
1085
- * The spec gives an allowance for the EL being able to return a nullish LVH if it could not
1086
- * "determine" one. There are two interpretations:
1087
- *
1088
- * - "the LVH is unknown" - simply throw and move on. We can't determine which chain to invalidate
1089
- * since we don't know which ancestor is valid.
1090
- *
1091
- * - "the LVH doesn't exist" - this means that the entire ancestor chain is invalid, and should
1092
- * be marked as such.
1093
- *
1094
- * The more robust approach is to treat nullish LVH as "the LVH is unknown" rather than
1095
- * "the LVH doesn't exist". The alternative means that we will poison a valid chain when the
1096
- * EL is lazy (or buggy) with its LVH response.
1097
- */
1088
+ this.propagateValidExecutionStatusByIndex(latestValidHashIndex);
1089
+ return true;
1090
+ }
1091
+
1092
+ // In case of invalidation, ideally:
1093
+ // i) Find the invalid payload
1094
+ // ii) Obtain a chain [LVH.child, LVH.child.child, ....., invalid_payload]
1095
+ // iii) Obtain a chain [Last_known_valid_node, ...., LVH]
1096
+ //
1097
+ // Mark chain iii) as Valid if LVH is non null but right now LVH can be non null without
1098
+ // gurranteing chain iii) to be valid: for e.g. in following scenario LVH can be returned
1099
+ // as any of SYNCING: SYNCING, SYNCING, SYNCING, INVALID (due to simple check)/
1100
+ // So we currently ignore this chain and hope eventually it gets resolved
1101
+ //
1102
+ // Mark chain ii) as Invalid if LVH is found and non null, else only invalidate invalid_payload
1103
+ // if its in fcU.
1104
+ //
1105
+ const {invalidateFromParentBlockRoot, invalidateFromParentBlockHash, latestValidExecHash} = execResponse;
1106
+ const invalidateFromParentIndex = this.getNodeIndexByRootAndBlockHash(
1107
+ invalidateFromParentBlockRoot,
1108
+ invalidateFromParentBlockHash
1109
+ );
1110
+ if (invalidateFromParentIndex === undefined) {
1111
+ throw Error(
1112
+ `Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} invalidateFromParentBlockHash=${invalidateFromParentBlockHash} in forkChoice`
1113
+ );
1114
+ }
1115
+ if (!strict && this.nodes[invalidateFromParentIndex].executionStatus !== ExecutionStatus.Syncing) {
1116
+ return false;
1117
+ }
1118
+
1119
+ let latestValidHashIndex =
1120
+ latestValidExecHash !== null ? this.getNodeIndexFromLVH(latestValidExecHash, invalidateFromParentIndex) : null;
1121
+ if (latestValidHashIndex === null) {
1122
+ if (strict) {
1123
+ // The strict from node is the parent of the rejected payload and may be valid, never guess here
1098
1124
  throw Error(`Unable to find latestValidExecHash=${latestValidExecHash} in the forkchoice`);
1099
1125
  }
1126
+ latestValidHashIndex = this.getUnknownLVHIndex(invalidateFromParentIndex);
1127
+ }
1128
+
1129
+ if (!strict) {
1130
+ // Never invalidate the justified block, the EMPTY variant sharing its chain, or anything at or below it in
1131
+ // the tree. The justified block's own payload (FULL variant) is not part of the checkpoint
1132
+ const justifiedIndex = this.getDefaultNodeIndex(this.justifiedRoot) ?? -1;
1133
+ let nodeIndex: number | undefined = invalidateFromParentIndex;
1134
+ while (nodeIndex !== undefined && nodeIndex > latestValidHashIndex) {
1135
+ const node: ProtoNode = this.nodes[nodeIndex];
1136
+ if (
1137
+ node.executionStatus !== ExecutionStatus.Syncing ||
1138
+ nodeIndex <= justifiedIndex ||
1139
+ (node.blockRoot === this.justifiedRoot && node.payloadStatus === PayloadStatus.EMPTY)
1140
+ ) {
1141
+ // Conflicting LVH: fall back to the unknown LVH bound, but never cross this node
1142
+ latestValidHashIndex = Math.max(nodeIndex, this.getUnknownLVHIndex(invalidateFromParentIndex));
1143
+ break;
1144
+ }
1145
+ nodeIndex = node.parent;
1146
+ }
1147
+ }
1100
1148
 
1101
- this.propagateInValidExecutionStatusByIndex(invalidateFromParentIndex, latestValidHashIndex, currentSlot);
1149
+ if (invalidateFromParentIndex <= latestValidHashIndex) {
1150
+ return false;
1102
1151
  }
1152
+
1153
+ this.propagateInValidExecutionStatusByIndex(invalidateFromParentIndex, latestValidHashIndex, currentSlot);
1154
+ return true;
1155
+ }
1156
+
1157
+ /**
1158
+ * Upper bound (exclusive) for invalidating from `fromIndex` when the engine gave no usable LVH: the parent of the
1159
+ * last ancestor that still inherits the queried payload. Pre-gloas this is the parent of `fromIndex`. For a gloas
1160
+ * EMPTY or PENDING node it reaches up to the FULL node that owns the payload. -1 when the chain reaches the root.
1161
+ */
1162
+ private getUnknownLVHIndex(fromIndex: number): number {
1163
+ const payloadBlockHash = this.nodes[fromIndex].executionPayloadBlockHash;
1164
+ let nodeIndex = fromIndex;
1165
+ let parentIndex = this.nodes[nodeIndex].parent;
1166
+ while (parentIndex !== undefined && this.nodes[parentIndex].executionPayloadBlockHash === payloadBlockHash) {
1167
+ nodeIndex = parentIndex;
1168
+ parentIndex = this.nodes[nodeIndex].parent;
1169
+ }
1170
+ return parentIndex ?? -1;
1103
1171
  }
1104
1172
 
1105
1173
  private propagateValidExecutionStatusByIndex(validNodeIndex: number): void {
@@ -1295,6 +1363,15 @@ export class ProtoArray {
1295
1363
 
1296
1364
  // For Gloas: check if from previous slot
1297
1365
  if (node.slot + 1 !== currentSlot) {
1366
+ // During the block's own slot a payload known to not satisfy the inclusion list
1367
+ // constraints ranks below its EMPTY variant, as it will from the next slot on
1368
+ if (
1369
+ node.slot === currentSlot &&
1370
+ node.payloadStatus === PayloadStatus.FULL &&
1371
+ this.payloadInclusionListSatisfaction.get(node.blockRoot) === false
1372
+ ) {
1373
+ return PayloadStatus.PENDING;
1374
+ }
1298
1375
  return node.payloadStatus;
1299
1376
  }
1300
1377