@lodestar/fork-choice 1.42.0 → 1.43.0-dev.05a33e512f

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.
@@ -1,8 +1,7 @@
1
1
  import {EffectiveBalanceIncrements, IBeaconStateView} from "@lodestar/state-transition";
2
2
  import {RootHex, Slot, ValidatorIndex, phase0} from "@lodestar/types";
3
3
  import {toRootHex} from "@lodestar/utils";
4
- import {PayloadStatus} from "../protoArray/interface.js";
5
- import {CheckpointWithPayloadAndBalance, CheckpointWithPayloadAndTotalBalance} from "./interface.js";
4
+ import {CheckpointWithBalance, CheckpointWithTotalBalance} from "./interface.js";
6
5
 
7
6
  /**
8
7
  * Stores checkpoints in a hybrid format:
@@ -11,15 +10,6 @@ import {CheckpointWithPayloadAndBalance, CheckpointWithPayloadAndTotalBalance} f
11
10
  */
12
11
  export type CheckpointWithHex = phase0.Checkpoint & {rootHex: RootHex};
13
12
 
14
- /**
15
- * Checkpoint with payload status for Gloas fork choice.
16
- * Used to track which variant (EMPTY or FULL) of the finalized/justified block to use.
17
- *
18
- * Pre-Gloas: payloadStatus is always FULL (payload embedded in block)
19
- * Gloas: determined by state.execution_payload_availability
20
- */
21
- export type CheckpointWithPayloadStatus = CheckpointWithHex & {payloadStatus: PayloadStatus};
22
-
23
13
  export type JustifiedBalances = EffectiveBalanceIncrements;
24
14
 
25
15
  /**
@@ -29,7 +19,7 @@ export type JustifiedBalances = EffectiveBalanceIncrements;
29
19
  * @param blockState state that declares justified checkpoint `checkpoint`
30
20
  */
31
21
  export type JustifiedBalancesGetter = (
32
- checkpoint: CheckpointWithPayloadStatus,
22
+ checkpoint: CheckpointWithHex,
33
23
  blockState: IBeaconStateView
34
24
  ) => JustifiedBalances;
35
25
 
@@ -47,11 +37,11 @@ export type JustifiedBalancesGetter = (
47
37
  */
48
38
  export interface IForkChoiceStore {
49
39
  currentSlot: Slot;
50
- get justified(): CheckpointWithPayloadAndTotalBalance;
51
- set justified(justified: CheckpointWithPayloadAndBalance);
52
- unrealizedJustified: CheckpointWithPayloadAndBalance;
53
- finalizedCheckpoint: CheckpointWithPayloadStatus;
54
- unrealizedFinalizedCheckpoint: CheckpointWithPayloadStatus;
40
+ get justified(): CheckpointWithTotalBalance;
41
+ set justified(justified: CheckpointWithBalance);
42
+ unrealizedJustified: CheckpointWithBalance;
43
+ finalizedCheckpoint: CheckpointWithHex;
44
+ unrealizedFinalizedCheckpoint: CheckpointWithHex;
55
45
  justifiedBalancesGetter: JustifiedBalancesGetter;
56
46
  equivocatingIndices: Set<ValidatorIndex>;
57
47
  }
@@ -60,10 +50,10 @@ export interface IForkChoiceStore {
60
50
  * IForkChoiceStore implementer which emits forkChoice events on updated justified and finalized checkpoints.
61
51
  */
62
52
  export class ForkChoiceStore implements IForkChoiceStore {
63
- private _justified: CheckpointWithPayloadAndTotalBalance;
64
- unrealizedJustified: CheckpointWithPayloadAndBalance;
65
- private _finalizedCheckpoint: CheckpointWithPayloadStatus;
66
- unrealizedFinalizedCheckpoint: CheckpointWithPayloadStatus;
53
+ private _justified: CheckpointWithTotalBalance;
54
+ unrealizedJustified: CheckpointWithBalance;
55
+ private _finalizedCheckpoint: CheckpointWithHex;
56
+ unrealizedFinalizedCheckpoint: CheckpointWithHex;
67
57
  equivocatingIndices = new Set<ValidatorIndex>();
68
58
  justifiedBalancesGetter: JustifiedBalancesGetter;
69
59
  currentSlot: Slot;
@@ -74,49 +64,37 @@ export class ForkChoiceStore implements IForkChoiceStore {
74
64
  finalizedCheckpoint: phase0.Checkpoint,
75
65
  justifiedBalances: EffectiveBalanceIncrements,
76
66
  justifiedBalancesGetter: JustifiedBalancesGetter,
77
- /**
78
- * Payload status for justified checkpoint.
79
- * Pre-Gloas: always FULL
80
- * Gloas: determined by state.execution_payload_availability
81
- */
82
- justifiedPayloadStatus: PayloadStatus,
83
- /**
84
- * Payload status for finalized checkpoint.
85
- * Pre-Gloas: always FULL
86
- * Gloas: determined by state.execution_payload_availability
87
- */
88
- finalizedPayloadStatus: PayloadStatus,
89
67
  private readonly events?: {
90
- onJustified: (cp: CheckpointWithPayloadStatus) => void;
91
- onFinalized: (cp: CheckpointWithPayloadStatus) => void;
68
+ onJustified: (cp: CheckpointWithHex) => void;
69
+ onFinalized: (cp: CheckpointWithHex) => void;
92
70
  }
93
71
  ) {
94
72
  this.justifiedBalancesGetter = justifiedBalancesGetter;
95
73
  this.currentSlot = currentSlot;
96
74
  const justified = {
97
- checkpoint: toCheckpointWithPayload(justifiedCheckpoint, justifiedPayloadStatus),
75
+ checkpoint: toCheckpointWithHex(justifiedCheckpoint),
98
76
  balances: justifiedBalances,
99
77
  totalBalance: computeTotalBalance(justifiedBalances),
100
78
  };
101
79
  this._justified = justified;
102
80
  this.unrealizedJustified = justified;
103
- this._finalizedCheckpoint = toCheckpointWithPayload(finalizedCheckpoint, finalizedPayloadStatus);
81
+ this._finalizedCheckpoint = toCheckpointWithHex(finalizedCheckpoint);
104
82
  this.unrealizedFinalizedCheckpoint = this._finalizedCheckpoint;
105
83
  }
106
84
 
107
- get justified(): CheckpointWithPayloadAndTotalBalance {
85
+ get justified(): CheckpointWithTotalBalance {
108
86
  return this._justified;
109
87
  }
110
- set justified(justified: CheckpointWithPayloadAndBalance) {
88
+ set justified(justified: CheckpointWithBalance) {
111
89
  this._justified = {...justified, totalBalance: computeTotalBalance(justified.balances)};
112
90
  this.events?.onJustified(justified.checkpoint);
113
91
  }
114
92
 
115
- get finalizedCheckpoint(): CheckpointWithPayloadStatus {
93
+ get finalizedCheckpoint(): CheckpointWithHex {
116
94
  return this._finalizedCheckpoint;
117
95
  }
118
- set finalizedCheckpoint(checkpoint: CheckpointWithPayloadStatus) {
119
- const cp = toCheckpointWithPayload(checkpoint, checkpoint.payloadStatus);
96
+ set finalizedCheckpoint(checkpoint: CheckpointWithHex) {
97
+ const cp = toCheckpointWithHex(checkpoint);
120
98
  this._finalizedCheckpoint = cp;
121
99
  this.events?.onFinalized(cp);
122
100
  }
@@ -133,16 +111,6 @@ export function toCheckpointWithHex(checkpoint: phase0.Checkpoint): CheckpointWi
133
111
  };
134
112
  }
135
113
 
136
- export function toCheckpointWithPayload(
137
- checkpoint: phase0.Checkpoint,
138
- payloadStatus: PayloadStatus
139
- ): CheckpointWithPayloadStatus {
140
- return {
141
- ...toCheckpointWithHex(checkpoint),
142
- payloadStatus,
143
- };
144
- }
145
-
146
114
  export function equalCheckpointWithHex(a: CheckpointWithHex, b: CheckpointWithHex): boolean {
147
115
  return a.epoch === b.epoch && a.rootHex === b.rootHex;
148
116
  }
package/src/index.ts CHANGED
@@ -6,17 +6,12 @@ export {
6
6
  type InvalidBlock,
7
7
  InvalidBlockCode,
8
8
  } from "./forkChoice/errors.js";
9
- export {
10
- ForkChoice,
11
- type ForkChoiceOpts,
12
- UpdateHeadOpt,
13
- getCheckpointPayloadStatus,
14
- } from "./forkChoice/forkChoice.js";
9
+ export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt} from "./forkChoice/forkChoice.js";
15
10
  export {
16
11
  type AncestorResult,
17
12
  AncestorStatus,
18
- type CheckpointWithPayloadAndBalance,
19
- type CheckpointWithPayloadAndTotalBalance,
13
+ type CheckpointWithBalance,
14
+ type CheckpointWithTotalBalance,
20
15
  EpochDifference,
21
16
  type IForkChoice,
22
17
  NotReorgedReason,
@@ -24,7 +19,6 @@ export {
24
19
  export * from "./forkChoice/safeBlocks.js";
25
20
  export {
26
21
  type CheckpointWithHex,
27
- type CheckpointWithPayloadStatus,
28
22
  ForkChoiceStore,
29
23
  type IForkChoiceStore,
30
24
  type JustifiedBalancesGetter,
@@ -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";
@@ -108,6 +108,7 @@ export class ProtoArray {
108
108
  currentSlot,
109
109
  null
110
110
  );
111
+
111
112
  return protoArray;
112
113
  }
113
114
 
@@ -213,6 +214,11 @@ export class ProtoArray {
213
214
  return PayloadStatus.FULL;
214
215
  }
215
216
 
217
+ // Genesis block has no parent in the proto array
218
+ if (block.parentRoot === HEX_ZERO_HASH) {
219
+ return PayloadStatus.FULL;
220
+ }
221
+
216
222
  const parentBlock = this.getBlockHexAndBlockHash(block.parentRoot, parentBlockHash);
217
223
  if (parentBlock == null) {
218
224
  throw new ProtoArrayError({
@@ -251,38 +257,43 @@ export class ProtoArray {
251
257
  }
252
258
 
253
259
  /**
254
- * 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.
255
262
  */
256
- getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null {
263
+ getNodeIndexByRootAndBlockHash(blockRoot: RootHex, blockHash: RootHex): number | undefined {
257
264
  const variantIndices = this.indices.get(blockRoot);
258
265
  if (variantIndices === undefined) {
259
- return null;
266
+ return undefined;
260
267
  }
261
268
 
262
269
  // Pre-Gloas
263
270
  if (!Array.isArray(variantIndices)) {
264
- const node = this.nodes[variantIndices];
265
- return node.executionPayloadBlockHash === blockHash ? node : null;
271
+ return this.nodes[variantIndices].executionPayloadBlockHash === blockHash ? variantIndices : undefined;
266
272
  }
267
273
 
268
- // Post-Gloas, check empty and full variants
274
+ // Post-Gloas, prefer FULL then EMPTY
269
275
  const fullNodeIndex = variantIndices[PayloadStatus.FULL];
270
- if (fullNodeIndex !== undefined) {
271
- const fullNode = this.nodes[fullNodeIndex];
272
- if (fullNode && fullNode.executionPayloadBlockHash === blockHash) {
273
- return fullNode;
274
- }
276
+ if (fullNodeIndex !== undefined && this.nodes[fullNodeIndex].executionPayloadBlockHash === blockHash) {
277
+ return fullNodeIndex;
275
278
  }
276
279
 
277
- const emptyNode = this.nodes[variantIndices[PayloadStatus.EMPTY]];
278
- if (emptyNode && emptyNode.executionPayloadBlockHash === blockHash) {
279
- return emptyNode;
280
+ const emptyNodeIndex = variantIndices[PayloadStatus.EMPTY];
281
+ if (this.nodes[emptyNodeIndex].executionPayloadBlockHash === blockHash) {
282
+ return emptyNodeIndex;
280
283
  }
281
284
 
282
285
  // PENDING is the same to EMPTY so not likely we can return it
283
286
  // also it's only specific for fork-choice
284
287
 
285
- 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;
286
297
  }
287
298
 
288
299
  /**
@@ -354,9 +365,15 @@ export class ProtoArray {
354
365
  continue;
355
366
  }
356
367
 
357
- 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;
358
375
  const previousBoost =
359
- this.previousProposerBoost && this.previousProposerBoost.root === node.blockRoot
376
+ this.previousProposerBoost && this.previousProposerBoost.root === node.blockRoot && isBoostVariant
360
377
  ? this.previousProposerBoost.score
361
378
  : 0;
362
379
 
@@ -541,9 +558,9 @@ export class ProtoArray {
541
558
  currentSlot: Slot,
542
559
  executionPayloadBlockHash: RootHex,
543
560
  executionPayloadNumber: number,
544
- executionPayloadStateRoot: RootHex,
545
561
  proposerBoostRoot: RootHex | null,
546
- executionStatus: PayloadExecutionStatus
562
+ executionStatus: PayloadExecutionStatus,
563
+ dataAvailabilityStatus: DataAvailabilityStatus
547
564
  ): void {
548
565
  // First check if block exists
549
566
  const variants = this.indices.get(blockRoot);
@@ -585,7 +602,7 @@ export class ProtoArray {
585
602
  });
586
603
  }
587
604
 
588
- // Create FULL variant as a child of PENDING (sibling to EMPTY)
605
+ // Create FULL variant as a child of PENDING (sibling to EMPTY).
589
606
  const fullNode: ProtoNode = {
590
607
  ...pendingNode,
591
608
  parent: pendingIndex, // Points to own PENDING (same as EMPTY)
@@ -593,11 +610,10 @@ export class ProtoArray {
593
610
  weight: 0,
594
611
  bestChild: undefined,
595
612
  bestDescendant: undefined,
596
- // TODO GLOAS: handle optimistic sync
597
613
  executionStatus,
598
614
  executionPayloadBlockHash,
599
615
  executionPayloadNumber,
600
- stateRoot: executionPayloadStateRoot,
616
+ dataAvailabilityStatus,
601
617
  };
602
618
 
603
619
  const fullIndex = this.nodes.length;
@@ -606,6 +622,12 @@ export class ProtoArray {
606
622
  // Add FULL variant to the indices array
607
623
  variants[PayloadStatus.FULL] = fullIndex;
608
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
+
609
631
  // Update bestChild for PENDING node (may now prefer FULL over EMPTY)
610
632
  this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot, proposerBoostRoot);
611
633
  }
@@ -634,6 +656,16 @@ export class ProtoArray {
634
656
  }
635
657
  }
636
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
+
637
669
  /**
638
670
  * Check if execution payload for a block is timely
639
671
  * Spec: gloas/fork-choice.md#new-is_payload_timely
@@ -676,7 +708,7 @@ export class ProtoArray {
676
708
  * Determine if we should extend the payload (prefer FULL over EMPTY)
677
709
  * Spec: gloas/fork-choice.md#new-should_extend_payload
678
710
  *
679
- * Returns true if:
711
+ * Returns true if payload is verified (FULL variant exists) AND:
680
712
  * 1. Payload is timely, OR
681
713
  * 2. No proposer boost root (empty/zero hash), OR
682
714
  * 3. Proposer boost root's parent is not this block, OR
@@ -686,6 +718,10 @@ export class ProtoArray {
686
718
  * @param proposerBoostRoot - Current proposer boost root (from ForkChoice)
687
719
  */
688
720
  shouldExtendPayload(blockRoot: RootHex, proposerBoostRoot: RootHex | null): boolean {
721
+ if (!this.hasPayload(blockRoot)) {
722
+ return false;
723
+ }
724
+
689
725
  // Condition 1: Payload is timely
690
726
  if (this.isPayloadTimely(blockRoot)) {
691
727
  return true;
@@ -766,11 +802,15 @@ export class ProtoArray {
766
802
  // Mark chain ii) as Invalid if LVH is found and non null, else only invalidate invalid_payload
767
803
  // if its in fcU.
768
804
  //
769
- const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse;
770
- // TODO GLOAS: verify if getting the default/canonical node index is correct here
771
- const invalidateFromParentIndex = this.getDefaultNodeIndex(invalidateFromParentBlockRoot);
805
+ const {invalidateFromParentBlockRoot, invalidateFromParentBlockHash, latestValidExecHash} = execResponse;
806
+ const invalidateFromParentIndex = this.getNodeIndexByRootAndBlockHash(
807
+ invalidateFromParentBlockRoot,
808
+ invalidateFromParentBlockHash
809
+ );
772
810
  if (invalidateFromParentIndex === undefined) {
773
- throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`);
811
+ throw Error(
812
+ `Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} invalidateFromParentBlockHash=${invalidateFromParentBlockHash} in forkChoice`
813
+ );
774
814
  }
775
815
  const latestValidHashIndex =
776
816
  latestValidExecHash !== null ? this.getNodeIndexFromLVH(latestValidExecHash, invalidateFromParentIndex) : null;
@@ -806,12 +846,6 @@ export class ProtoArray {
806
846
  if (node.executionStatus === ExecutionStatus.PreMerge || node.executionStatus === ExecutionStatus.Valid) {
807
847
  break;
808
848
  }
809
- // If PayloadSeparated, that means the node is either PENDING or EMPTY, there could be
810
- // some ancestor still has syncing status.
811
- if (node.executionStatus === ExecutionStatus.PayloadSeparated) {
812
- nodeIndex = node.parent;
813
- continue;
814
- }
815
849
  this.validateNodeByIndex(nodeIndex);
816
850
  nodeIndex = node.parent;
817
851
  }
@@ -909,6 +943,13 @@ export class ProtoArray {
909
943
  invalidNode.executionStatus = ExecutionStatus.Invalid;
910
944
  invalidNode.bestChild = undefined;
911
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
+ }
912
953
 
913
954
  return invalidNode;
914
955
  }
@@ -930,6 +971,13 @@ export class ProtoArray {
930
971
  if (validNode.executionStatus === ExecutionStatus.Syncing) {
931
972
  validNode.executionStatus = ExecutionStatus.Valid;
932
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
+ }
933
981
  return validNode;
934
982
  }
935
983
 
@@ -1478,9 +1526,16 @@ export class ProtoArray {
1478
1526
  */
1479
1527
  private getParentNodeIndex(node: ProtoNode): number | undefined {
1480
1528
  if (isGloasBlock(node)) {
1481
- // Use getParentPayloadStatus for Gloas blocks to get correct EMPTY/FULL variant
1482
- const parentPayloadStatus = this.getParentPayloadStatus(node);
1483
- 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
+ }
1484
1539
  }
1485
1540
  // Simple parent traversal for pre-Gloas blocks (includes fork transition)
1486
1541
  return node.parent;
@@ -1634,10 +1689,9 @@ export class ProtoArray {
1634
1689
  const ancestors: ProtoNode[] = [];
1635
1690
  const nonAncestors: ProtoNode[] = [];
1636
1691
 
1637
- // Include starting node if it's not PENDING (i.e., pre-Gloas or EMPTY/FULL variant post-Gloas)
1638
- if (node.payloadStatus !== PayloadStatus.PENDING) {
1639
- ancestors.push(node);
1640
- }
1692
+ // caller of this method may pass default status
1693
+ // this is the only node that we accept PENDING
1694
+ ancestors.push(node);
1641
1695
 
1642
1696
  let nodeIndex = startIndex;
1643
1697
  while (node.parent !== undefined) {