@lodestar/fork-choice 1.44.0-dev.1d0e0b9081 → 1.44.0-dev.2defe36dd3

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.
@@ -318,6 +318,11 @@ export class ForkChoice implements IForkChoice {
318
318
  return this.protoArray.shouldExtendPayload(blockRoot, this.proposerBoostRoot);
319
319
  }
320
320
 
321
+ /** Spec: should_build_on_full(store, head) */
322
+ shouldBuildOnFull(head: ProtoBlock, slot: Slot): boolean {
323
+ return this.protoArray.shouldBuildOnFull(head, slot);
324
+ }
325
+
321
326
  /**
322
327
  * To predict the proposer head of the next slot. That is, to predict if proposer-boost-reorg could happen.
323
328
  * Reason why we can't be certain is because information of the head block is not fully available yet
@@ -596,7 +601,11 @@ export class ForkChoice implements IForkChoice {
596
601
  blockDelaySec: number,
597
602
  currentSlot: Slot,
598
603
  executionStatus: BlockExecutionStatus,
599
- dataAvailabilityStatus: DataAvailabilityStatus
604
+ dataAvailabilityStatus: DataAvailabilityStatus,
605
+ // The expected proposer index on the canonical chain we are following.
606
+ // Calculated by our head state. We use it as part of the proposer
607
+ // boost decision making. No boost will be set if this is null.
608
+ expectedProposerIndex: ValidatorIndex | null
600
609
  ): ProtoBlock {
601
610
  const {parentRoot, slot} = block;
602
611
  const parentRootHex = toRootHex(parentRoot);
@@ -669,7 +678,9 @@ export class ForkChoice implements IForkChoice {
669
678
  this.opts?.proposerBoost &&
670
679
  isTimely &&
671
680
  // only boost the first block we see
672
- this.proposerBoostRoot === null
681
+ this.proposerBoostRoot === null &&
682
+ expectedProposerIndex !== null &&
683
+ block.proposerIndex === expectedProposerIndex
673
684
  ) {
674
685
  this.proposerBoostRoot = blockRootHex;
675
686
  }
@@ -764,33 +775,32 @@ export class ForkChoice implements IForkChoice {
764
775
  unrealizedFinalizedRoot: unrealizedFinalizedCheckpoint.rootHex,
765
776
 
766
777
  ...(isGloasBeaconBlock(block)
767
- ? {
768
- executionPayloadBlockHash: toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash), // post-gloas, we don't know payload hash until we import execution payload. Set to parent payload hash for now
769
- executionPayloadNumber: (() => {
770
- // Determine parent's execution payload number based on which variant the block extends
771
- const parentBlockHashFromBid = toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash);
772
-
773
- // If parent is pre-merge, return 0
774
- if (parentBlock.executionPayloadBlockHash === null) {
775
- return 0;
776
- }
777
-
778
- // If parent is pre-Gloas, it only has FULL variant
779
- if (parentBlock.parentBlockHash === null) {
780
- return parentBlock.executionPayloadNumber;
781
- }
782
-
783
- // Parent is Gloas: get the variant that matches the parentBlockHash from bid
784
- const parentVariant = this.getBlockHexAndBlockHash(parentRootHex, parentBlockHashFromBid);
785
- if (parentVariant && parentVariant.executionPayloadBlockHash !== null) {
786
- return parentVariant.executionPayloadNumber;
787
- }
788
- // Fallback to parent block's number (we know it's post-merge from check above)
789
- return parentBlock.executionPayloadNumber;
790
- })(),
791
- executionStatus: this.getPostMergeExecStatus(executionStatus),
792
- dataAvailabilityStatus,
793
- }
778
+ ? (() => {
779
+ // post-gloas, we don't know payload hash until we import execution payload. Set to
780
+ // parent payload hash for now, along with the gas limit/number of that parent payload
781
+ // (which is what bids built on top of this block will reference until a payload arrives).
782
+ // we also use parent hash to pass to EL via fcu
783
+ // see https://github.com/ethereum/consensus-specs/pull/5197
784
+ const parentBlockHashFromBid = toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash);
785
+
786
+ // Inherit parent payload's (number, gasLimit) for the PENDING/EMPTY variants.
787
+ // `parentBlock` is already the variant matching `parentBlockHashFromBid` —
788
+ // `getParent` (called above) resolves Gloas parents via
789
+ // `getBlockHexAndBlockHash(parentRoot, parentBlockHash)`, and pre-Gloas parents
790
+ // have a single variant. Pre-merge parents have null payload hash and zero values.
791
+ const parentMeta: {number: number; gasLimit: number} =
792
+ parentBlock.executionPayloadBlockHash === null
793
+ ? {number: 0, gasLimit: 0}
794
+ : {number: parentBlock.executionPayloadNumber, gasLimit: parentBlock.executionPayloadGasLimit};
795
+
796
+ return {
797
+ executionPayloadBlockHash: parentBlockHashFromBid,
798
+ executionPayloadNumber: parentMeta.number,
799
+ executionPayloadGasLimit: parentMeta.gasLimit,
800
+ executionStatus: this.getPostMergeExecStatus(executionStatus),
801
+ dataAvailabilityStatus,
802
+ };
803
+ })()
794
804
  : isExecutionBlockBodyType(block.body) &&
795
805
  isStatePostBellatrix(state) &&
796
806
  state.isExecutionStateType &&
@@ -798,6 +808,7 @@ export class ForkChoice implements IForkChoice {
798
808
  ? {
799
809
  executionPayloadBlockHash: toRootHex(block.body.executionPayload.blockHash),
800
810
  executionPayloadNumber: block.body.executionPayload.blockNumber,
811
+ executionPayloadGasLimit: block.body.executionPayload.gasLimit,
801
812
  executionStatus: this.getPostMergeExecStatus(executionStatus),
802
813
  dataAvailabilityStatus,
803
814
  }
@@ -934,8 +945,14 @@ export class ForkChoice implements IForkChoice {
934
945
  * Updates the PTC votes for multiple validators attesting to a block
935
946
  * Spec: gloas/fork-choice.md#new-on_payload_attestation_message
936
947
  */
937
- notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void {
938
- this.protoArray.notifyPtcMessages(blockRoot, ptcIndices, payloadPresent);
948
+ notifyPtcMessages(
949
+ blockRoot: RootHex,
950
+ slot: Slot,
951
+ ptcIndices: number[],
952
+ payloadPresent: boolean,
953
+ blobDataAvailable: boolean
954
+ ): void {
955
+ this.protoArray.notifyPtcMessages(blockRoot, slot, ptcIndices, payloadPresent, blobDataAvailable);
939
956
  }
940
957
 
941
958
  /**
@@ -947,6 +964,7 @@ export class ForkChoice implements IForkChoice {
947
964
  blockRoot: RootHex,
948
965
  executionPayloadBlockHash: RootHex,
949
966
  executionPayloadNumber: number,
967
+ executionPayloadGasLimit: number,
950
968
  executionStatus: PayloadExecutionStatus,
951
969
  dataAvailabilityStatus: DataAvailabilityStatus
952
970
  ): void {
@@ -955,6 +973,7 @@ export class ForkChoice implements IForkChoice {
955
973
  this.fcStore.currentSlot,
956
974
  executionPayloadBlockHash,
957
975
  executionPayloadNumber,
976
+ executionPayloadGasLimit,
958
977
  this.proposerBoostRoot,
959
978
  executionStatus,
960
979
  dataAvailabilityStatus
@@ -1,5 +1,14 @@
1
1
  import {DataAvailabilityStatus, EffectiveBalanceIncrements, IBeaconStateView} from "@lodestar/state-transition";
2
- import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types";
2
+ import {
3
+ AttesterSlashing,
4
+ BeaconBlock,
5
+ Epoch,
6
+ IndexedAttestation,
7
+ Root,
8
+ RootHex,
9
+ Slot,
10
+ ValidatorIndex,
11
+ } from "@lodestar/types";
3
12
  import {
4
13
  BlockExecutionStatus,
5
14
  LVHExecResponse,
@@ -145,7 +154,8 @@ export interface IForkChoice {
145
154
  blockDelaySec: number,
146
155
  currentSlot: Slot,
147
156
  executionStatus: BlockExecutionStatus,
148
- dataAvailabilityStatus: DataAvailabilityStatus
157
+ dataAvailabilityStatus: DataAvailabilityStatus,
158
+ expectedProposerIndex: ValidatorIndex | null
149
159
  ): ProtoBlock;
150
160
  /**
151
161
  * Register `attestation` with the fork choice DAG so that it may influence future calls to `getHead`.
@@ -181,12 +191,14 @@ export interface IForkChoice {
181
191
  * ## Specification
182
192
  *
183
193
  * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.0/specs/gloas/fork-choice.md#new-notify_ptc_messages
184
- *
185
- * @param blockRoot - The beacon block root being attested
186
- * @param ptcIndices - Array of PTC committee indices that voted
187
- * @param payloadPresent - Whether validators attest the payload is present
188
194
  */
189
- notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void;
195
+ notifyPtcMessages(
196
+ blockRoot: RootHex,
197
+ slot: Slot,
198
+ ptcIndices: number[],
199
+ payloadPresent: boolean,
200
+ blobDataAvailable: boolean
201
+ ): void;
190
202
  /**
191
203
  * Notify fork choice that an execution payload has arrived (Gloas fork)
192
204
  * Creates the FULL variant of a Gloas block when the payload becomes available
@@ -198,11 +210,13 @@ export interface IForkChoice {
198
210
  * @param blockRoot - The beacon block root for which the payload arrived
199
211
  * @param executionPayloadBlockHash - The block hash of the execution payload
200
212
  * @param executionPayloadNumber - The block number of the execution payload
213
+ * @param executionPayloadGasLimit - The gas limit of the execution payload
201
214
  */
202
215
  onExecutionPayload(
203
216
  blockRoot: RootHex,
204
217
  executionPayloadBlockHash: RootHex,
205
218
  executionPayloadNumber: number,
219
+ executionPayloadGasLimit: number,
206
220
  executionStatus: PayloadExecutionStatus,
207
221
  dataAvailabilityStatus: DataAvailabilityStatus
208
222
  ): void;
@@ -242,6 +256,8 @@ export interface IForkChoice {
242
256
  getBlockHexDefaultStatus(blockRoot: RootHex): ProtoBlock | null;
243
257
  getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null;
244
258
  shouldExtendPayload(blockRoot: RootHex): boolean;
259
+ /** Spec: should_build_on_full(store, head) */
260
+ shouldBuildOnFull(head: ProtoBlock, slot: Slot): boolean;
245
261
  getFinalizedBlock(): ProtoBlock;
246
262
  getJustifiedBlock(): ProtoBlock;
247
263
  getFinalizedCheckpointSlot(): Slot;
@@ -87,6 +87,14 @@ export type BlockExtraMeta =
87
87
  // - payload block hash for FULL variant
88
88
  executionPayloadBlockHash: RootHex;
89
89
  executionPayloadNumber: UintNum64;
90
+ // Gas limit of the executed payload identified by executionPayloadBlockHash. Set on
91
+ // pre-Gloas blocks (from block.body.executionPayload.gasLimit) and on Gloas variants:
92
+ // - PENDING/EMPTY: inherited from the parent payload that the bid commits to extend
93
+ // (matches executionPayloadBlockHash, which also points to that parent payload)
94
+ // - FULL: the actual delivered payload's gasLimit (set in onExecutionPayload)
95
+ // Consumers (e.g. Gloas bid gas-limit validation) can read this without re-deriving from
96
+ // state.
97
+ executionPayloadGasLimit: UintNum64;
90
98
  executionStatus: Exclude<ExecutionStatus, ExecutionStatus.PreMerge>;
91
99
  dataAvailabilityStatus: DataAvailabilityStatus;
92
100
  }
@@ -21,6 +21,29 @@ import {
21
21
  * Spec: gloas/fork-choice.md (PAYLOAD_TIMELY_THRESHOLD = PTC_SIZE // 2)
22
22
  */
23
23
  const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2);
24
+ /**
25
+ * Threshold for blob data availability via PTC vote
26
+ * Spec: gloas/fork-choice.md (DATA_AVAILABILITY_TIMELY_THRESHOLD = PTC_SIZE // 2)
27
+ */
28
+ const DATA_AVAILABILITY_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2);
29
+
30
+ /**
31
+ * popcount(attended AND NOT yes) — explicit False-vote count.
32
+ * Excludes PTC members who didn't attest (the None state).
33
+ */
34
+ export function countNoVotes(attended: BitArray, yes: BitArray): number {
35
+ const a = attended.uint8Array;
36
+ const y = yes.uint8Array;
37
+ let count = 0;
38
+ for (let i = 0; i < a.length; i++) {
39
+ let byte = a[i] & ~y[i] & 0xff;
40
+ while (byte) {
41
+ byte &= byte - 1;
42
+ count++;
43
+ }
44
+ }
45
+ return count;
46
+ }
24
47
 
25
48
  export const DEFAULT_PRUNE_THRESHOLD = 0;
26
49
  type ProposerBoost = {root: RootHex; score: number};
@@ -64,12 +87,25 @@ export class ProtoArray {
64
87
  /**
65
88
  * PTC (Payload Timeliness Committee) votes per block as bitvectors
66
89
  * Maps block root to BitArray of PTC_SIZE bits (512 mainnet, 2 minimal)
67
- * Spec: gloas/fork-choice.md#modified-store (line 148)
90
+ * Spec: gloas/fork-choice.md#modified-store (payload_timeliness_vote)
91
+ *
92
+ * Bit i = PTC member i voted payloadPresent=true (timeliness YES vote)
93
+ */
94
+ private payloadTimelinessVotes = new Map<RootHex, BitArray>();
95
+ /**
96
+ * Blob data availability votes per block.
97
+ * Spec: gloas/fork-choice.md#modified-store (payload_data_availability_vote)
68
98
  *
69
- * Bit i is set if PTC member i voted payload_present=true
70
- * Used by is_payload_timely() to determine if payload is timely
99
+ * Bit i = PTC member i voted blobDataAvailable=true (DA YES vote)
71
100
  */
72
- private ptcVotes = new Map<RootHex, BitArray>();
101
+ private payloadDataAvailabilityVotes = new Map<RootHex, BitArray>();
102
+ /**
103
+ * Tracks which PTC members have attested at all (any payload_status).
104
+ * Without this, we cannot tell "didn't vote" (None) from "voted false" —
105
+ * a distinction required by payload_timeliness/payload_data_availability
106
+ * when called with the negative parameter value.
107
+ */
108
+ private ptcAttested = new Map<RootHex, BitArray>();
73
109
 
74
110
  constructor({
75
111
  pruneThreshold,
@@ -514,9 +550,11 @@ export class ProtoArray {
514
550
  // Update bestChild for PENDING → EMPTY edge
515
551
  this.maybeUpdateBestChildAndDescendant(pendingIndex, emptyIndex, currentSlot, proposerBoostRoot);
516
552
 
517
- // Initialize PTC votes for this block (all false initially)
518
- // Spec: gloas/fork-choice.md#modified-on_block (line 645)
519
- this.ptcVotes.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE));
553
+ // Initialize PTC vote bitvectors for this block.
554
+ // Spec: gloas/fork-choice.md#modified-on_block
555
+ this.payloadTimelinessVotes.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE));
556
+ this.ptcAttested.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE));
557
+ this.payloadDataAvailabilityVotes.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE));
520
558
  } else {
521
559
  // Pre-Gloas: Only create FULL node (payload embedded in block)
522
560
  const node: ProtoNode = {
@@ -558,6 +596,7 @@ export class ProtoArray {
558
596
  currentSlot: Slot,
559
597
  executionPayloadBlockHash: RootHex,
560
598
  executionPayloadNumber: number,
599
+ executionPayloadGasLimit: number,
561
600
  proposerBoostRoot: RootHex | null,
562
601
  executionStatus: PayloadExecutionStatus,
563
602
  dataAvailabilityStatus: DataAvailabilityStatus
@@ -613,6 +652,7 @@ export class ProtoArray {
613
652
  executionStatus,
614
653
  executionPayloadBlockHash,
615
654
  executionPayloadNumber,
655
+ executionPayloadGasLimit,
616
656
  dataAvailabilityStatus,
617
657
  };
618
658
 
@@ -634,30 +674,42 @@ export class ProtoArray {
634
674
 
635
675
  /**
636
676
  * Update PTC votes for multiple validators attesting to a block
637
- * Spec: gloas/fork-choice.md#new-on_payload_attestation_message
638
- *
639
- * @param blockRoot - The beacon block root being attested
640
- * @param ptcIndices - Array of PTC committee indices that voted (0..PTC_SIZE-1)
641
- * @param payloadPresent - Whether the validators attest the payload is present
677
+ * Spec: gloas/fork-choice.md#new-notify_ptc_messages
642
678
  */
643
- notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void {
644
- const votes = this.ptcVotes.get(blockRoot);
645
- if (votes === undefined) {
679
+ notifyPtcMessages(
680
+ blockRoot: RootHex,
681
+ slot: Slot,
682
+ ptcIndices: number[],
683
+ payloadPresent: boolean,
684
+ blobDataAvailable: boolean
685
+ ): void {
686
+ const votes = this.payloadTimelinessVotes.get(blockRoot);
687
+ const attended = this.ptcAttested.get(blockRoot);
688
+ const daVotes = this.payloadDataAvailabilityVotes.get(blockRoot);
689
+ if (votes === undefined || attended === undefined || daVotes === undefined) {
646
690
  // Block not found or not a Gloas block, ignore
647
691
  return;
648
692
  }
649
693
 
694
+ // PTC votes can only change the vote for their assigned beacon block, return early otherwise
695
+ const nodeIndex = this.getDefaultNodeIndex(blockRoot);
696
+ const node = nodeIndex !== undefined ? this.getNodeByIndex(nodeIndex) : undefined;
697
+ if (node === undefined || node.slot !== slot) {
698
+ return;
699
+ }
700
+
650
701
  for (const ptcIndex of ptcIndices) {
651
702
  if (ptcIndex < 0 || ptcIndex >= PTC_SIZE) {
652
703
  throw new Error(`Invalid PTC index: ${ptcIndex}, must be 0..${PTC_SIZE - 1}`);
653
704
  }
654
-
655
705
  votes.set(ptcIndex, payloadPresent);
706
+ daVotes.set(ptcIndex, blobDataAvailable);
707
+ attended.set(ptcIndex, true);
656
708
  }
657
709
  }
658
710
 
659
711
  getPTCVotes(blockRootHex: RootHex): BitArray | null {
660
- const votes = this.ptcVotes.get(blockRootHex);
712
+ const votes = this.payloadTimelinessVotes.get(blockRootHex);
661
713
  if (votes === undefined) {
662
714
  // Block not found or not a Gloas block
663
715
  return null;
@@ -667,31 +719,66 @@ export class ProtoArray {
667
719
  }
668
720
 
669
721
  /**
670
- * Check if execution payload for a block is timely
671
- * Spec: gloas/fork-choice.md#new-is_payload_timely
672
- *
673
- * Returns true if:
674
- * 1. Block has PTC votes tracked
675
- * 2. Payload is locally available (FULL variant exists in proto array)
676
- * 3. More than PAYLOAD_TIMELY_THRESHOLD (>50% of PTC) members voted payload_present=true
677
- *
678
- * @param blockRoot - The beacon block root to check
722
+ * Spec: payload_timeliness(store, root, timely=True)
679
723
  */
680
724
  isPayloadTimely(blockRoot: RootHex): boolean {
681
- const votes = this.ptcVotes.get(blockRoot);
682
- if (votes === undefined) {
683
- // Block not found or not a Gloas block
684
- return false;
685
- }
725
+ const votes = this.payloadTimelinessVotes.get(blockRoot);
726
+ if (votes === undefined) return false;
727
+ if (!this.hasPayload(blockRoot)) return false;
728
+ return bitCount(votes.uint8Array) > PAYLOAD_TIMELY_THRESHOLD;
729
+ }
686
730
 
687
- // If payload is not locally available, it's not timely
688
- if (!this.hasPayload(blockRoot)) {
689
- return false;
731
+ /**
732
+ * Spec: payload_timeliness(store, root, timely=False)
733
+ */
734
+ isPayloadNotTimely(blockRoot: RootHex): boolean {
735
+ const votes = this.payloadTimelinessVotes.get(blockRoot);
736
+ const attended = this.ptcAttested.get(blockRoot);
737
+ if (votes === undefined || attended === undefined) return false;
738
+ // Spec: not verified locally → returns `not False = True`
739
+ if (!this.hasPayload(blockRoot)) return true;
740
+ return countNoVotes(attended, votes) > PAYLOAD_TIMELY_THRESHOLD;
741
+ }
742
+
743
+ /**
744
+ * Spec: payload_data_availability(store, root, available=True)
745
+ */
746
+ isPayloadDataAvailable(blockRoot: RootHex): boolean {
747
+ const daVotes = this.payloadDataAvailabilityVotes.get(blockRoot);
748
+ if (daVotes === undefined) return false;
749
+ if (!this.hasPayload(blockRoot)) return false;
750
+ return bitCount(daVotes.uint8Array) > DATA_AVAILABILITY_TIMELY_THRESHOLD;
751
+ }
752
+
753
+ /**
754
+ * Spec: payload_data_availability(store, root, available=False)
755
+ */
756
+ isPayloadDataNotAvailable(blockRoot: RootHex): boolean {
757
+ const daVotes = this.payloadDataAvailabilityVotes.get(blockRoot);
758
+ const attended = this.ptcAttested.get(blockRoot);
759
+ if (daVotes === undefined || attended === undefined) return false;
760
+ // Spec: not verified locally → returns `not False = True`
761
+ if (!this.hasPayload(blockRoot)) return true;
762
+ return countNoVotes(attended, daVotes) > DATA_AVAILABILITY_TIMELY_THRESHOLD;
763
+ }
764
+
765
+ /**
766
+ * Spec: should_build_on_full(store, head)
767
+ *
768
+ * The proposer is forced to build on the EMPTY variant (effectively reorging)
769
+ * when the PTC majority voted that the blob data is not available.
770
+ */
771
+ shouldBuildOnFull(head: ProtoBlock, slot: Slot): boolean {
772
+ if (head.payloadStatus === PayloadStatus.PENDING) {
773
+ throw new Error("shouldBuildOnFull called with PENDING head");
690
774
  }
775
+ if (head.payloadStatus === PayloadStatus.EMPTY) return false;
776
+
777
+ // The PTC data availability view is only consulted for a head from the previous slot.
778
+ // For an earlier head the empty/full variant has already been resolved by weight in getHead.
779
+ if (head.slot + 1 !== slot) return true;
691
780
 
692
- // Count votes for payload_present=true
693
- const yesVotes = bitCount(votes.uint8Array);
694
- return yesVotes > PAYLOAD_TIMELY_THRESHOLD;
781
+ return !this.isPayloadDataNotAvailable(head.blockRoot);
695
782
  }
696
783
 
697
784
  /**
@@ -709,7 +796,7 @@ export class ProtoArray {
709
796
  * Spec: gloas/fork-choice.md#new-should_extend_payload
710
797
  *
711
798
  * Returns true if payload is verified (FULL variant exists) AND:
712
- * 1. Payload is timely, OR
799
+ * 1. Payload is timely AND data is available, OR
713
800
  * 2. No proposer boost root (empty/zero hash), OR
714
801
  * 3. Proposer boost root's parent is not this block, OR
715
802
  * 4. Proposer boost root extends FULL parent
@@ -722,8 +809,8 @@ export class ProtoArray {
722
809
  return false;
723
810
  }
724
811
 
725
- // Condition 1: Payload is timely
726
- if (this.isPayloadTimely(blockRoot)) {
812
+ // Condition 1: Payload is timely AND data is available
813
+ if (this.isPayloadTimely(blockRoot) && this.isPayloadDataAvailable(blockRoot)) {
727
814
  return true;
728
815
  }
729
816
 
@@ -1131,7 +1218,9 @@ export class ProtoArray {
1131
1218
  this.indices.delete(root);
1132
1219
  // Prune PTC votes for this block to prevent memory leak
1133
1220
  // Spec: gloas/fork-choice.md (implicit - finalized blocks don't need PTC votes)
1134
- this.ptcVotes.delete(root);
1221
+ this.payloadTimelinessVotes.delete(root);
1222
+ this.ptcAttested.delete(root);
1223
+ this.payloadDataAvailabilityVotes.delete(root);
1135
1224
  }
1136
1225
 
1137
1226
  // Store nodes prior to finalization