@lodestar/fork-choice 1.43.0-dev.aef3645690 → 1.43.0-dev.b0e1143d06

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.
@@ -24,16 +24,15 @@ export type VoteIndex = number;
24
24
  * - Syncing: EL is syncing, payload validity unknown (optimistic sync)
25
25
  * - PreMerge: Block is from before The Merge, no execution payload exists
26
26
  * - Invalid: Execution payload was invalidated by the EL (post-import status)
27
- * - PayloadSeparated: Gloas beacon block without embedded execution payload.
28
- * The execution payload arrives separately via SignedExecutionPayloadEnvelope.
29
- * Gloas blocks WITH execution payload (FULL variant) use Valid/Invalid/Syncing.
27
+ *
28
+ * For gloas blocks the PENDING/EMPTY variants inherit `executionStatus` from the parent's chain
29
+ * (Valid/Syncing/PreMerge); the FULL variant carries the EL response for this block's own payload.
30
30
  */
31
31
  export enum ExecutionStatus {
32
32
  Valid = "Valid",
33
33
  Syncing = "Syncing",
34
34
  PreMerge = "PreMerge",
35
35
  Invalid = "Invalid",
36
- PayloadSeparated = "PayloadSeparated",
37
36
  }
38
37
 
39
38
  /**
@@ -61,19 +60,21 @@ export type LVHInvalidResponse = {
61
60
  executionStatus: ExecutionStatus.Invalid;
62
61
  latestValidExecHash: RootHex | null;
63
62
  invalidateFromParentBlockRoot: RootHex;
63
+ // EL block hash from invalid block's bid (gloas) or payload's parentHash (pre-gloas).
64
+ // Disambiguates which variant of the parent (FULL vs EMPTY) to invalidate from.
65
+ invalidateFromParentBlockHash: RootHex;
64
66
  };
65
67
  export type LVHExecResponse = LVHValidResponse | LVHInvalidResponse;
66
68
 
67
69
  /**
68
70
  * Any execution status that is not definitively invalid.
69
- * Pre-Gloas: Valid | Syncing | PreMerge
70
- * Post-Gloas: execution status must be PayloadSeparated (beacon block imported before its payload arrives via SignedExecutionPayloadEnvelope)
71
+ * Valid | Syncing | PreMerge
71
72
  */
72
73
  export type BlockExecutionStatus = Exclude<ExecutionStatus, ExecutionStatus.Invalid>;
73
74
 
74
75
  /**
75
76
  * Execution status for a block whose execution payload is present and has been submitted to the EL.
76
- * Used post-Gloas when transitioning a PayloadSeparated block to FULL via onExecutionPayload().
77
+ * Used post-Gloas when transitioning a PENDING block to FULL via onExecutionPayload().
77
78
  */
78
79
  export type PayloadExecutionStatus = ExecutionStatus.Valid | ExecutionStatus.Syncing;
79
80
 
@@ -1,6 +1,6 @@
1
1
  import {BitArray} from "@chainsafe/ssz";
2
2
  import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params";
3
- import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition";
3
+ import {DataAvailabilityStatus, computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition";
4
4
  import {Epoch, RootHex, Slot} from "@lodestar/types";
5
5
  import {bitCount, toRootHex} from "@lodestar/utils";
6
6
  import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js";
@@ -109,13 +109,6 @@ export class ProtoArray {
109
109
  null
110
110
  );
111
111
 
112
- // Anchor block PTC votes must be all-true per spec get_forkchoice_store:
113
- // payload_timeliness_vote={anchor_root: Vector[boolean, PTC_SIZE](True for _ in range(PTC_SIZE))}
114
- // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.4/specs/gloas/fork-choice.md#modified-get_forkchoice_store
115
- if (protoArray.ptcVotes.has(block.blockRoot)) {
116
- protoArray.ptcVotes.set(block.blockRoot, BitArray.fromBoolArray(Array.from({length: PTC_SIZE}, () => true)));
117
- }
118
-
119
112
  return protoArray;
120
113
  }
121
114
 
@@ -221,6 +214,11 @@ export class ProtoArray {
221
214
  return PayloadStatus.FULL;
222
215
  }
223
216
 
217
+ // Genesis block has no parent in the proto array
218
+ if (block.parentRoot === HEX_ZERO_HASH) {
219
+ return PayloadStatus.FULL;
220
+ }
221
+
224
222
  const parentBlock = this.getBlockHexAndBlockHash(block.parentRoot, parentBlockHash);
225
223
  if (parentBlock == null) {
226
224
  throw new ProtoArrayError({
@@ -259,38 +257,43 @@ export class ProtoArray {
259
257
  }
260
258
 
261
259
  /**
262
- * Returns an EMPTY or FULL `ProtoBlock` that has matching block root and block hash
260
+ * Returns the node index of an EMPTY or FULL variant matching block root and block hash.
261
+ * Pre-gloas: checks the single variant. Post-gloas: prefers FULL, falls back to EMPTY.
263
262
  */
264
- getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null {
263
+ getNodeIndexByRootAndBlockHash(blockRoot: RootHex, blockHash: RootHex): number | undefined {
265
264
  const variantIndices = this.indices.get(blockRoot);
266
265
  if (variantIndices === undefined) {
267
- return null;
266
+ return undefined;
268
267
  }
269
268
 
270
269
  // Pre-Gloas
271
270
  if (!Array.isArray(variantIndices)) {
272
- const node = this.nodes[variantIndices];
273
- return node.executionPayloadBlockHash === blockHash ? node : null;
271
+ return this.nodes[variantIndices].executionPayloadBlockHash === blockHash ? variantIndices : undefined;
274
272
  }
275
273
 
276
- // Post-Gloas, check empty and full variants
274
+ // Post-Gloas, prefer FULL then EMPTY
277
275
  const fullNodeIndex = variantIndices[PayloadStatus.FULL];
278
- if (fullNodeIndex !== undefined) {
279
- const fullNode = this.nodes[fullNodeIndex];
280
- if (fullNode && fullNode.executionPayloadBlockHash === blockHash) {
281
- return fullNode;
282
- }
276
+ if (fullNodeIndex !== undefined && this.nodes[fullNodeIndex].executionPayloadBlockHash === blockHash) {
277
+ return fullNodeIndex;
283
278
  }
284
279
 
285
- const emptyNode = this.nodes[variantIndices[PayloadStatus.EMPTY]];
286
- if (emptyNode && emptyNode.executionPayloadBlockHash === blockHash) {
287
- return emptyNode;
280
+ const emptyNodeIndex = variantIndices[PayloadStatus.EMPTY];
281
+ if (this.nodes[emptyNodeIndex].executionPayloadBlockHash === blockHash) {
282
+ return emptyNodeIndex;
288
283
  }
289
284
 
290
285
  // PENDING is the same to EMPTY so not likely we can return it
291
286
  // also it's only specific for fork-choice
292
287
 
293
- return null;
288
+ return undefined;
289
+ }
290
+
291
+ /**
292
+ * Returns an EMPTY or FULL `ProtoBlock` that has matching block root and block hash
293
+ */
294
+ getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null {
295
+ const idx = this.getNodeIndexByRootAndBlockHash(blockRoot, blockHash);
296
+ return idx !== undefined ? this.nodes[idx] : null;
294
297
  }
295
298
 
296
299
  /**
@@ -362,9 +365,15 @@ export class ProtoArray {
362
365
  continue;
363
366
  }
364
367
 
365
- const currentBoost = proposerBoost && proposerBoost.root === node.blockRoot ? proposerBoost.score : 0;
368
+ // For Gloas blocks, PENDING/EMPTY/FULL all share the same blockRoot.
369
+ // Only apply proposer boost to PENDING (for Gloas) or FULL (for pre-Gloas) — to avoid
370
+ // double-counting the boost across variants during delta back-propagation, and to keep
371
+ // the boost neutral with respect to EMPTY vs FULL selection.
372
+ const isBoostVariant = isGloasBlock(node) ? node.payloadStatus === PayloadStatus.PENDING : true; // pre-Gloas has only FULL, always boost
373
+ const currentBoost =
374
+ proposerBoost && proposerBoost.root === node.blockRoot && isBoostVariant ? proposerBoost.score : 0;
366
375
  const previousBoost =
367
- this.previousProposerBoost && this.previousProposerBoost.root === node.blockRoot
376
+ this.previousProposerBoost && this.previousProposerBoost.root === node.blockRoot && isBoostVariant
368
377
  ? this.previousProposerBoost.score
369
378
  : 0;
370
379
 
@@ -549,9 +558,9 @@ export class ProtoArray {
549
558
  currentSlot: Slot,
550
559
  executionPayloadBlockHash: RootHex,
551
560
  executionPayloadNumber: number,
552
- executionPayloadStateRoot: RootHex,
553
561
  proposerBoostRoot: RootHex | null,
554
- executionStatus: PayloadExecutionStatus
562
+ executionStatus: PayloadExecutionStatus,
563
+ dataAvailabilityStatus: DataAvailabilityStatus
555
564
  ): void {
556
565
  // First check if block exists
557
566
  const variants = this.indices.get(blockRoot);
@@ -593,7 +602,7 @@ export class ProtoArray {
593
602
  });
594
603
  }
595
604
 
596
- // Create FULL variant as a child of PENDING (sibling to EMPTY)
605
+ // Create FULL variant as a child of PENDING (sibling to EMPTY).
597
606
  const fullNode: ProtoNode = {
598
607
  ...pendingNode,
599
608
  parent: pendingIndex, // Points to own PENDING (same as EMPTY)
@@ -601,11 +610,10 @@ export class ProtoArray {
601
610
  weight: 0,
602
611
  bestChild: undefined,
603
612
  bestDescendant: undefined,
604
- // TODO GLOAS: handle optimistic sync
605
613
  executionStatus,
606
614
  executionPayloadBlockHash,
607
615
  executionPayloadNumber,
608
- stateRoot: executionPayloadStateRoot,
616
+ dataAvailabilityStatus,
609
617
  };
610
618
 
611
619
  const fullIndex = this.nodes.length;
@@ -614,6 +622,12 @@ export class ProtoArray {
614
622
  // Add FULL variant to the indices array
615
623
  variants[PayloadStatus.FULL] = fullIndex;
616
624
 
625
+ if (executionStatus === ExecutionStatus.Valid) {
626
+ // Walk up from FULL's parent (its own PENDING). FULL is already Valid; the loop breaks
627
+ // immediately if we start at FULL. Same pattern as pre-gloas onBlock at line ~533.
628
+ this.propagateValidExecutionStatusByIndex(pendingIndex);
629
+ }
630
+
617
631
  // Update bestChild for PENDING node (may now prefer FULL over EMPTY)
618
632
  this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot, proposerBoostRoot);
619
633
  }
@@ -642,6 +656,16 @@ export class ProtoArray {
642
656
  }
643
657
  }
644
658
 
659
+ getPTCVotes(blockRootHex: RootHex): BitArray | null {
660
+ const votes = this.ptcVotes.get(blockRootHex);
661
+ if (votes === undefined) {
662
+ // Block not found or not a Gloas block
663
+ return null;
664
+ }
665
+
666
+ return votes;
667
+ }
668
+
645
669
  /**
646
670
  * Check if execution payload for a block is timely
647
671
  * Spec: gloas/fork-choice.md#new-is_payload_timely
@@ -684,7 +708,7 @@ export class ProtoArray {
684
708
  * Determine if we should extend the payload (prefer FULL over EMPTY)
685
709
  * Spec: gloas/fork-choice.md#new-should_extend_payload
686
710
  *
687
- * Returns true if:
711
+ * Returns true if payload is verified (FULL variant exists) AND:
688
712
  * 1. Payload is timely, OR
689
713
  * 2. No proposer boost root (empty/zero hash), OR
690
714
  * 3. Proposer boost root's parent is not this block, OR
@@ -694,6 +718,10 @@ export class ProtoArray {
694
718
  * @param proposerBoostRoot - Current proposer boost root (from ForkChoice)
695
719
  */
696
720
  shouldExtendPayload(blockRoot: RootHex, proposerBoostRoot: RootHex | null): boolean {
721
+ if (!this.hasPayload(blockRoot)) {
722
+ return false;
723
+ }
724
+
697
725
  // Condition 1: Payload is timely
698
726
  if (this.isPayloadTimely(blockRoot)) {
699
727
  return true;
@@ -774,11 +802,15 @@ export class ProtoArray {
774
802
  // Mark chain ii) as Invalid if LVH is found and non null, else only invalidate invalid_payload
775
803
  // if its in fcU.
776
804
  //
777
- const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse;
778
- // TODO GLOAS: verify if getting the default/canonical node index is correct here
779
- const invalidateFromParentIndex = this.getDefaultNodeIndex(invalidateFromParentBlockRoot);
805
+ const {invalidateFromParentBlockRoot, invalidateFromParentBlockHash, latestValidExecHash} = execResponse;
806
+ const invalidateFromParentIndex = this.getNodeIndexByRootAndBlockHash(
807
+ invalidateFromParentBlockRoot,
808
+ invalidateFromParentBlockHash
809
+ );
780
810
  if (invalidateFromParentIndex === undefined) {
781
- throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`);
811
+ throw Error(
812
+ `Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} invalidateFromParentBlockHash=${invalidateFromParentBlockHash} in forkChoice`
813
+ );
782
814
  }
783
815
  const latestValidHashIndex =
784
816
  latestValidExecHash !== null ? this.getNodeIndexFromLVH(latestValidExecHash, invalidateFromParentIndex) : null;
@@ -814,12 +846,6 @@ export class ProtoArray {
814
846
  if (node.executionStatus === ExecutionStatus.PreMerge || node.executionStatus === ExecutionStatus.Valid) {
815
847
  break;
816
848
  }
817
- // If PayloadSeparated, that means the node is either PENDING or EMPTY, there could be
818
- // some ancestor still has syncing status.
819
- if (node.executionStatus === ExecutionStatus.PayloadSeparated) {
820
- nodeIndex = node.parent;
821
- continue;
822
- }
823
849
  this.validateNodeByIndex(nodeIndex);
824
850
  nodeIndex = node.parent;
825
851
  }
@@ -917,6 +943,13 @@ export class ProtoArray {
917
943
  invalidNode.executionStatus = ExecutionStatus.Invalid;
918
944
  invalidNode.bestChild = undefined;
919
945
  invalidNode.bestDescendant = undefined;
946
+ // Gloas: PENDING and sibling EMPTY share chain status, flip together
947
+ if (invalidNode.payloadStatus === PayloadStatus.PENDING) {
948
+ const variants = this.indices.get(invalidNode.blockRoot);
949
+ if (Array.isArray(variants)) {
950
+ this.invalidateNodeByIndex(variants[PayloadStatus.EMPTY]);
951
+ }
952
+ }
920
953
 
921
954
  return invalidNode;
922
955
  }
@@ -938,6 +971,13 @@ export class ProtoArray {
938
971
  if (validNode.executionStatus === ExecutionStatus.Syncing) {
939
972
  validNode.executionStatus = ExecutionStatus.Valid;
940
973
  }
974
+ // Gloas: PENDING and sibling EMPTY share chain status, flip together
975
+ if (validNode.payloadStatus === PayloadStatus.PENDING) {
976
+ const variants = this.indices.get(validNode.blockRoot);
977
+ if (Array.isArray(variants)) {
978
+ this.validateNodeByIndex(variants[PayloadStatus.EMPTY]);
979
+ }
980
+ }
941
981
  return validNode;
942
982
  }
943
983
 
@@ -1486,9 +1526,16 @@ export class ProtoArray {
1486
1526
  */
1487
1527
  private getParentNodeIndex(node: ProtoNode): number | undefined {
1488
1528
  if (isGloasBlock(node)) {
1489
- // Use getParentPayloadStatus for Gloas blocks to get correct EMPTY/FULL variant
1490
- const parentPayloadStatus = this.getParentPayloadStatus(node);
1491
- return this.getNodeIndexByRootAndStatus(node.parentRoot, parentPayloadStatus);
1529
+ // Traversal may reach the finalized ProtoBlock, should not throw error in that case
1530
+ try {
1531
+ const parentPayloadStatus = this.getParentPayloadStatus(node);
1532
+ return this.getNodeIndexByRootAndStatus(node.parentRoot, parentPayloadStatus);
1533
+ } catch (e) {
1534
+ if (e instanceof ProtoArrayError && e.type.code === ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK) {
1535
+ return undefined;
1536
+ }
1537
+ throw e;
1538
+ }
1492
1539
  }
1493
1540
  // Simple parent traversal for pre-Gloas blocks (includes fork transition)
1494
1541
  return node.parent;
@@ -1642,10 +1689,9 @@ export class ProtoArray {
1642
1689
  const ancestors: ProtoNode[] = [];
1643
1690
  const nonAncestors: ProtoNode[] = [];
1644
1691
 
1645
- // Include starting node if it's not PENDING (i.e., pre-Gloas or EMPTY/FULL variant post-Gloas)
1646
- if (node.payloadStatus !== PayloadStatus.PENDING) {
1647
- ancestors.push(node);
1648
- }
1692
+ // caller of this method may pass default status
1693
+ // this is the only node that we accept PENDING
1694
+ ancestors.push(node);
1649
1695
 
1650
1696
  let nodeIndex = startIndex;
1651
1697
  while (node.parent !== undefined) {