@lodestar/fork-choice 1.41.0-dev.bb33751bfd → 1.41.0-dev.be5acbb8f7

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.
Files changed (44) hide show
  1. package/lib/forkChoice/errors.d.ts +9 -1
  2. package/lib/forkChoice/errors.d.ts.map +1 -1
  3. package/lib/forkChoice/errors.js +4 -0
  4. package/lib/forkChoice/errors.js.map +1 -1
  5. package/lib/forkChoice/forkChoice.d.ts +73 -17
  6. package/lib/forkChoice/forkChoice.d.ts.map +1 -1
  7. package/lib/forkChoice/forkChoice.js +299 -116
  8. package/lib/forkChoice/forkChoice.js.map +1 -1
  9. package/lib/forkChoice/interface.d.ts +53 -20
  10. package/lib/forkChoice/interface.d.ts.map +1 -1
  11. package/lib/forkChoice/interface.js.map +1 -1
  12. package/lib/forkChoice/store.d.ts +40 -16
  13. package/lib/forkChoice/store.d.ts.map +1 -1
  14. package/lib/forkChoice/store.js +22 -4
  15. package/lib/forkChoice/store.js.map +1 -1
  16. package/lib/index.d.ts +4 -4
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +2 -2
  19. package/lib/index.js.map +1 -1
  20. package/lib/protoArray/computeDeltas.d.ts.map +1 -1
  21. package/lib/protoArray/computeDeltas.js +3 -0
  22. package/lib/protoArray/computeDeltas.js.map +1 -1
  23. package/lib/protoArray/errors.d.ts +15 -2
  24. package/lib/protoArray/errors.d.ts.map +1 -1
  25. package/lib/protoArray/errors.js +3 -0
  26. package/lib/protoArray/errors.js.map +1 -1
  27. package/lib/protoArray/interface.d.ts +20 -2
  28. package/lib/protoArray/interface.d.ts.map +1 -1
  29. package/lib/protoArray/interface.js +16 -0
  30. package/lib/protoArray/interface.js.map +1 -1
  31. package/lib/protoArray/protoArray.d.ts +217 -22
  32. package/lib/protoArray/protoArray.d.ts.map +1 -1
  33. package/lib/protoArray/protoArray.js +748 -133
  34. package/lib/protoArray/protoArray.js.map +1 -1
  35. package/package.json +7 -7
  36. package/src/forkChoice/errors.ts +7 -2
  37. package/src/forkChoice/forkChoice.ts +384 -126
  38. package/src/forkChoice/interface.ts +72 -20
  39. package/src/forkChoice/store.ts +52 -20
  40. package/src/index.ts +10 -2
  41. package/src/protoArray/computeDeltas.ts +6 -0
  42. package/src/protoArray/errors.ts +7 -1
  43. package/src/protoArray/interface.ts +36 -3
  44. package/src/protoArray/protoArray.ts +880 -134
@@ -1,16 +1,40 @@
1
- import {GENESIS_EPOCH} from "@lodestar/params";
1
+ import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params";
2
2
  import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition";
3
3
  import {Epoch, RootHex, Slot} from "@lodestar/types";
4
4
  import {toRootHex} from "@lodestar/utils";
5
5
  import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js";
6
6
  import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js";
7
- import {ExecutionStatus, HEX_ZERO_HASH, LVHExecResponse, ProtoBlock, ProtoNode} from "./interface.js";
7
+ import {
8
+ ExecutionStatus,
9
+ HEX_ZERO_HASH,
10
+ LVHExecResponse,
11
+ PayloadStatus,
12
+ ProtoBlock,
13
+ ProtoNode,
14
+ isGloasBlock,
15
+ } from "./interface.js";
16
+
17
+ /**
18
+ * Threshold for payload timeliness (>50% of PTC must vote)
19
+ * Spec: gloas/fork-choice.md (PAYLOAD_TIMELY_THRESHOLD = PTC_SIZE // 2)
20
+ */
21
+ const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2);
8
22
 
9
23
  export const DEFAULT_PRUNE_THRESHOLD = 0;
10
24
  type ProposerBoost = {root: RootHex; score: number};
11
25
 
12
26
  const ZERO_HASH_HEX = toRootHex(Buffer.alloc(32, 0));
13
27
 
28
+ /** Pre-Gloas: single element, FULL index (for backward compatibility) */
29
+ type PreGloasVariantIndex = number;
30
+ /**
31
+ * Post-Gloas: array length is 2 or 3
32
+ * - Length 2: [PENDING_INDEX, EMPTY_INDEX] when payload hasn't arrived yet
33
+ * - Length 3: [PENDING_INDEX, EMPTY_INDEX, FULL_INDEX] when payload has arrived
34
+ */
35
+ type GloasVariantIndices = [number, number] | [number, number, number];
36
+ type VariantIndices = PreGloasVariantIndex | GloasVariantIndices;
37
+
14
38
  export class ProtoArray {
15
39
  // Do not attempt to prune the tree unless it has at least this many nodes.
16
40
  // Small prunes simply waste time
@@ -20,11 +44,31 @@ export class ProtoArray {
20
44
  finalizedEpoch: Epoch;
21
45
  finalizedRoot: RootHex;
22
46
  nodes: ProtoNode[] = [];
23
- indices = new Map<RootHex, number>();
47
+ /**
48
+ * Maps block root to array of node indices for each payload status variant
49
+ *
50
+ * Array structure: [PENDING, EMPTY, FULL] where indices correspond to PayloadStatus enum values
51
+ * - number[0] = PENDING variant index (PayloadStatus.PENDING = 0)
52
+ * - number[1] = EMPTY variant index (PayloadStatus.EMPTY = 1)
53
+ * - number[2] = FULL variant index (PayloadStatus.FULL = 2)
54
+ *
55
+ * Note: undefined array elements indicate that variant doesn't exist for this block
56
+ */
57
+ indices = new Map<RootHex, VariantIndices>();
24
58
  lvhError?: LVHExecError;
25
59
 
26
60
  private previousProposerBoost: ProposerBoost | null = null;
27
61
 
62
+ /**
63
+ * PTC (Payload Timeliness Committee) votes per block
64
+ * Maps block root to boolean array of size PTC_SIZE (from params: 512 mainnet, 2 minimal)
65
+ * Spec: gloas/fork-choice.md#modified-store (line 148)
66
+ *
67
+ * ptcVotes[blockRoot][i] = true if PTC member i voted payload_present=true
68
+ * Used by is_payload_timely() to determine if payload is timely
69
+ */
70
+ private ptcVotes = new Map<RootHex, boolean[]>();
71
+
28
72
  constructor({
29
73
  pruneThreshold,
30
74
  justifiedEpoch,
@@ -59,11 +103,166 @@ export class ProtoArray {
59
103
  // We are using the blockROot as the targetRoot, since it always lies on an epoch boundary
60
104
  targetRoot: block.blockRoot,
61
105
  } as ProtoBlock,
62
- currentSlot
106
+ currentSlot,
107
+ null
63
108
  );
64
109
  return protoArray;
65
110
  }
66
111
 
112
+ /**
113
+ * Get node index for a block root and payload status
114
+ *
115
+ * @param root - The block root to look up
116
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
117
+ * @returns The node index for the specified variant, or undefined if not found
118
+ *
119
+ * Behavior:
120
+ * - Pre-Gloas blocks: only FULL is valid, PENDING/EMPTY throw error
121
+ * - Gloas blocks: returns the specified variant index, or undefined if that variant doesn't exist
122
+ *
123
+ * Note: payloadStatus is required. Use getDefaultVariant() to get the canonical variant.
124
+ */
125
+ getNodeIndexByRootAndStatus(root: RootHex, payloadStatus: PayloadStatus): number | undefined {
126
+ const variantOrArr = this.indices.get(root);
127
+ if (variantOrArr == null) {
128
+ return undefined;
129
+ }
130
+
131
+ // Pre-Gloas: only FULL variant exists
132
+ if (!Array.isArray(variantOrArr)) {
133
+ // Return FULL variant if no status specified or FULL explicitly requested
134
+ if (payloadStatus === PayloadStatus.FULL) {
135
+ return variantOrArr;
136
+ }
137
+ // PENDING and EMPTY are invalid for pre-Gloas blocks
138
+ throw new ProtoArrayError({
139
+ code: ProtoArrayErrorCode.INVALID_NODE_INDEX,
140
+ index: payloadStatus,
141
+ });
142
+ }
143
+
144
+ // Gloas: return the specified variant, or PENDING if not specified
145
+ return variantOrArr[payloadStatus];
146
+ }
147
+
148
+ /**
149
+ * Get the default/canonical payload status for a block root
150
+ * - Pre-Gloas blocks: Returns FULL (payload embedded in block)
151
+ * - Gloas blocks: Returns PENDING (canonical variant)
152
+ *
153
+ * @param blockRoot - The block root to check
154
+ * @returns PayloadStatus.FULL for pre-Gloas, PayloadStatus.PENDING for Gloas, undefined if block not found
155
+ */
156
+ getDefaultVariant(blockRoot: RootHex): PayloadStatus | undefined {
157
+ const variantOrArr = this.indices.get(blockRoot);
158
+ if (variantOrArr == null) {
159
+ return undefined;
160
+ }
161
+
162
+ // Pre-Gloas: only FULL variant exists
163
+ if (!Array.isArray(variantOrArr)) {
164
+ return PayloadStatus.FULL;
165
+ }
166
+
167
+ // Gloas: multiple variants exist, PENDING is canonical
168
+ return PayloadStatus.PENDING;
169
+ }
170
+
171
+ /**
172
+ * Determine which parent payload status a block extends
173
+ * Spec: gloas/fork-choice.md#new-get_parent_payload_status
174
+ * def get_parent_payload_status(store: Store, block: BeaconBlock) -> PayloadStatus:
175
+ * parent = store.blocks[block.parent_root]
176
+ * parent_block_hash = block.body.signed_execution_payload_bid.message.parent_block_hash
177
+ * message_block_hash = parent.body.signed_execution_payload_bid.message.block_hash
178
+ * return PAYLOAD_STATUS_FULL if parent_block_hash == message_block_hash else PAYLOAD_STATUS_EMPTY
179
+ *
180
+ * In lodestar forkchoice, we don't store the full bid, so we compares parent_block_hash in child's bid with executionPayloadBlockHash in parent:
181
+ * - If it matches EMPTY variant, return EMPTY
182
+ * - If it matches FULL variant, return FULL
183
+ * - If no match, throw UNKNOWN_PARENT_BLOCK error
184
+ *
185
+ * For pre-Gloas blocks: always returns FULL
186
+ */
187
+ getParentPayloadStatus(block: ProtoBlock): PayloadStatus {
188
+ // Pre-Gloas blocks have payloads embedded, so parents are always FULL
189
+ const {parentBlockHash} = block;
190
+ if (parentBlockHash === null) {
191
+ return PayloadStatus.FULL;
192
+ }
193
+
194
+ const parentBlock = this.getBlockHexAndBlockHash(block.parentRoot, parentBlockHash);
195
+ if (parentBlock == null) {
196
+ throw new ProtoArrayError({
197
+ code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK,
198
+ parentRoot: block.parentRoot,
199
+ parentHash: parentBlockHash,
200
+ });
201
+ }
202
+
203
+ return parentBlock.payloadStatus;
204
+ }
205
+
206
+ /**
207
+ * Return the parent `ProtoBlock` given its root and block hash.
208
+ */
209
+ getParent(parentRoot: RootHex, parentBlockHash: RootHex | null): ProtoBlock | null {
210
+ // pre-gloas
211
+ if (parentBlockHash === null) {
212
+ const parentIndex = this.indices.get(parentRoot);
213
+ if (parentIndex === undefined) {
214
+ return null;
215
+ }
216
+ if (Array.isArray(parentIndex)) {
217
+ // Gloas block found when pre-gloas expected
218
+ throw new ProtoArrayError({
219
+ code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK,
220
+ parentRoot,
221
+ parentHash: parentBlockHash,
222
+ });
223
+ }
224
+ return this.nodes[parentIndex] ?? null;
225
+ }
226
+
227
+ // post-gloas
228
+ return this.getBlockHexAndBlockHash(parentRoot, parentBlockHash);
229
+ }
230
+
231
+ /**
232
+ * Returns an EMPTY or FULL `ProtoBlock` that has matching block root and block hash
233
+ */
234
+ getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null {
235
+ const variantIndices = this.indices.get(blockRoot);
236
+ if (variantIndices === undefined) {
237
+ return null;
238
+ }
239
+
240
+ // Pre-Gloas
241
+ if (!Array.isArray(variantIndices)) {
242
+ const node = this.nodes[variantIndices];
243
+ return node.executionPayloadBlockHash === blockHash ? node : null;
244
+ }
245
+
246
+ // Post-Gloas, check empty and full variants
247
+ const fullNodeIndex = variantIndices[PayloadStatus.FULL];
248
+ if (fullNodeIndex !== undefined) {
249
+ const fullNode = this.nodes[fullNodeIndex];
250
+ if (fullNode && fullNode.executionPayloadBlockHash === blockHash) {
251
+ return fullNode;
252
+ }
253
+ }
254
+
255
+ const emptyNode = this.nodes[variantIndices[PayloadStatus.EMPTY]];
256
+ if (emptyNode && emptyNode.executionPayloadBlockHash === blockHash) {
257
+ return emptyNode;
258
+ }
259
+
260
+ // PENDING is the same to EMPTY so not likely we can return it
261
+ // also it's only specific for fork-choice
262
+
263
+ return null;
264
+ }
265
+
67
266
  /**
68
267
  * Iterate backwards through the array, touching all nodes and their parents and potentially
69
268
  * the best-child of each parent.
@@ -96,11 +295,11 @@ export class ProtoArray {
96
295
  finalizedRoot: RootHex;
97
296
  currentSlot: Slot;
98
297
  }): void {
99
- if (deltas.length !== this.indices.size) {
298
+ if (deltas.length !== this.nodes.length) {
100
299
  throw new ProtoArrayError({
101
300
  code: ProtoArrayErrorCode.INVALID_DELTA_LEN,
102
301
  deltas: deltas.length,
103
- indices: this.indices.size,
302
+ indices: this.nodes.length,
104
303
  });
105
304
  }
106
305
 
@@ -183,7 +382,7 @@ export class ProtoArray {
183
382
  // If the node has a parent, try to update its best-child and best-descendant.
184
383
  const parentIndex = node.parent;
185
384
  if (parentIndex !== undefined) {
186
- this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot);
385
+ this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot, proposerBoost?.root ?? null);
187
386
  }
188
387
  }
189
388
  // Update the previous proposer boost
@@ -195,9 +394,9 @@ export class ProtoArray {
195
394
  *
196
395
  * It is only sane to supply an undefined parent for the genesis block
197
396
  */
198
- onBlock(block: ProtoBlock, currentSlot: Slot): void {
397
+ onBlock(block: ProtoBlock, currentSlot: Slot, proposerBoostRoot: RootHex | null): void {
199
398
  // If the block is already known, simply ignore it
200
- if (this.indices.has(block.blockRoot)) {
399
+ if (this.hasBlock(block.blockRoot)) {
201
400
  return;
202
401
  }
203
402
  if (block.executionStatus === ExecutionStatus.Invalid) {
@@ -207,28 +406,294 @@ export class ProtoArray {
207
406
  });
208
407
  }
209
408
 
210
- const node: ProtoNode = {
211
- ...block,
212
- parent: this.indices.get(block.parentRoot),
409
+ if (isGloasBlock(block)) {
410
+ // Gloas: Create PENDING + EMPTY nodes with correct parent relationships
411
+ // Parent of new PENDING node = parent block's EMPTY or FULL (inter-block edge)
412
+ // Parent of new EMPTY node = own PENDING node (intra-block edge)
413
+
414
+ // For fork transition: if parent is pre-Gloas, point to parent's FULL
415
+ // Otherwise, determine which parent payload status this block extends
416
+ let parentIndex: number | undefined;
417
+
418
+ // Check if parent exists by getting variants array
419
+ const parentVariants = this.indices.get(block.parentRoot);
420
+ if (parentVariants != null) {
421
+ const anyParentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants;
422
+ const anyParentNode = this.nodes[anyParentIndex];
423
+
424
+ if (!isGloasBlock(anyParentNode)) {
425
+ // Fork transition: parent is pre-Gloas, so it only has FULL variant at variants[0]
426
+ parentIndex = anyParentIndex;
427
+ } else {
428
+ // Both blocks are Gloas: determine which parent payload status to extend
429
+ const parentPayloadStatus = this.getParentPayloadStatus(block);
430
+ parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus);
431
+ }
432
+ }
433
+ // else: parent doesn't exist, parentIndex remains undefined (orphan block)
434
+
435
+ // Create PENDING node
436
+ const pendingNode: ProtoNode = {
437
+ ...block,
438
+ parent: parentIndex, // Points to parent's EMPTY/FULL or FULL (for transition)
439
+ payloadStatus: PayloadStatus.PENDING,
440
+ weight: 0,
441
+ bestChild: undefined,
442
+ bestDescendant: undefined,
443
+ };
444
+
445
+ const pendingIndex = this.nodes.length;
446
+ this.nodes.push(pendingNode);
447
+
448
+ // Create EMPTY variant as a child of PENDING
449
+ const emptyNode: ProtoNode = {
450
+ ...block,
451
+ parent: pendingIndex, // Points to own PENDING
452
+ payloadStatus: PayloadStatus.EMPTY,
453
+ weight: 0,
454
+ bestChild: undefined,
455
+ bestDescendant: undefined,
456
+ };
457
+
458
+ const emptyIndex = this.nodes.length;
459
+ this.nodes.push(emptyNode);
460
+
461
+ // Store both variants in the indices array
462
+ // [PENDING, EMPTY, undefined] - FULL will be added later if payload arrives
463
+ this.indices.set(block.blockRoot, [pendingIndex, emptyIndex]);
464
+
465
+ // Update bestChild pointers
466
+ if (parentIndex !== undefined) {
467
+ this.maybeUpdateBestChildAndDescendant(parentIndex, pendingIndex, currentSlot, proposerBoostRoot);
468
+
469
+ if (pendingNode.executionStatus === ExecutionStatus.Valid) {
470
+ this.propagateValidExecutionStatusByIndex(parentIndex);
471
+ }
472
+ }
473
+
474
+ // Update bestChild for PENDING → EMPTY edge
475
+ this.maybeUpdateBestChildAndDescendant(pendingIndex, emptyIndex, currentSlot, proposerBoostRoot);
476
+
477
+ // Initialize PTC votes for this block (all false initially)
478
+ // Spec: gloas/fork-choice.md#modified-on_block (line 645)
479
+ this.ptcVotes.set(block.blockRoot, new Array(PTC_SIZE).fill(false));
480
+ } else {
481
+ // Pre-Gloas: Only create FULL node (payload embedded in block)
482
+ const node: ProtoNode = {
483
+ ...block,
484
+ parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL),
485
+ payloadStatus: PayloadStatus.FULL,
486
+ weight: 0,
487
+ bestChild: undefined,
488
+ bestDescendant: undefined,
489
+ };
490
+
491
+ const nodeIndex = this.nodes.length;
492
+ this.nodes.push(node);
493
+
494
+ // Pre-Gloas: store FULL index instead of array
495
+ this.indices.set(block.blockRoot, nodeIndex);
496
+
497
+ // If this node is valid, lets propagate the valid status up the chain
498
+ // and throw error if we counter invalid, as this breaks consensus
499
+ if (node.parent !== undefined) {
500
+ this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot, proposerBoostRoot);
501
+
502
+ if (node.executionStatus === ExecutionStatus.Valid) {
503
+ this.propagateValidExecutionStatusByIndex(node.parent);
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Called when an execution payload is received for a block (Gloas only)
511
+ * Creates a FULL variant node as a sibling to the existing EMPTY variant
512
+ * Both EMPTY and FULL have parent = own PENDING node
513
+ *
514
+ * Spec: gloas/fork-choice.md (on_execution_payload event)
515
+ */
516
+ onExecutionPayload(
517
+ blockRoot: RootHex,
518
+ currentSlot: Slot,
519
+ executionPayloadBlockHash: RootHex,
520
+ executionPayloadNumber: number,
521
+ executionPayloadStateRoot: RootHex,
522
+ proposerBoostRoot: RootHex | null
523
+ ): void {
524
+ // First check if block exists
525
+ const variants = this.indices.get(blockRoot);
526
+ if (variants == null) {
527
+ // Equivalent to `assert envelope.beacon_block_root in store.block_states`
528
+ throw new ProtoArrayError({
529
+ code: ProtoArrayErrorCode.UNKNOWN_BLOCK,
530
+ root: blockRoot,
531
+ });
532
+ }
533
+
534
+ if (!Array.isArray(variants)) {
535
+ // Pre-gloas block should not be calling this method
536
+ throw new ProtoArrayError({
537
+ code: ProtoArrayErrorCode.PRE_GLOAS_BLOCK,
538
+ root: blockRoot,
539
+ });
540
+ }
541
+
542
+ // Check if FULL already exists for Gloas blocks
543
+ if (variants[PayloadStatus.FULL] !== undefined) {
544
+ return;
545
+ }
546
+
547
+ // Get PENDING node for Gloas blocks
548
+ const pendingIndex = variants[PayloadStatus.PENDING];
549
+ if (pendingIndex === undefined) {
550
+ throw new ProtoArrayError({
551
+ code: ProtoArrayErrorCode.UNKNOWN_BLOCK,
552
+ root: blockRoot,
553
+ });
554
+ }
555
+
556
+ const pendingNode = this.nodes[pendingIndex];
557
+ if (!pendingNode) {
558
+ throw new ProtoArrayError({
559
+ code: ProtoArrayErrorCode.INVALID_NODE_INDEX,
560
+ index: pendingIndex,
561
+ });
562
+ }
563
+
564
+ // Create FULL variant as a child of PENDING (sibling to EMPTY)
565
+ const fullNode: ProtoNode = {
566
+ ...pendingNode,
567
+ parent: pendingIndex, // Points to own PENDING (same as EMPTY)
568
+ payloadStatus: PayloadStatus.FULL,
213
569
  weight: 0,
214
570
  bestChild: undefined,
215
571
  bestDescendant: undefined,
572
+ executionStatus: ExecutionStatus.Valid,
573
+ executionPayloadBlockHash,
574
+ executionPayloadNumber,
575
+ stateRoot: executionPayloadStateRoot,
216
576
  };
217
577
 
218
- const nodeIndex = this.nodes.length;
578
+ const fullIndex = this.nodes.length;
579
+ this.nodes.push(fullNode);
219
580
 
220
- this.indices.set(node.blockRoot, nodeIndex);
221
- this.nodes.push(node);
581
+ // Add FULL variant to the indices array
582
+ variants[PayloadStatus.FULL] = fullIndex;
222
583
 
223
- // If this node is valid, lets propagate the valid status up the chain
224
- // and throw error if we counter invalid, as this breaks consensus
225
- if (node.parent !== undefined) {
226
- this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot);
584
+ // Update bestChild for PENDING node (may now prefer FULL over EMPTY)
585
+ this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot, proposerBoostRoot);
586
+ }
227
587
 
228
- if (node.executionStatus === ExecutionStatus.Valid) {
229
- this.propagateValidExecutionStatusByIndex(node.parent);
588
+ /**
589
+ * Update PTC votes for multiple validators attesting to a block
590
+ * Spec: gloas/fork-choice.md#new-on_payload_attestation_message
591
+ *
592
+ * @param blockRoot - The beacon block root being attested
593
+ * @param ptcIndices - Array of PTC committee indices that voted (0..PTC_SIZE-1)
594
+ * @param payloadPresent - Whether the validators attest the payload is present
595
+ */
596
+ notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void {
597
+ const votes = this.ptcVotes.get(blockRoot);
598
+ if (votes === undefined) {
599
+ // Block not found or not a Gloas block, ignore
600
+ return;
601
+ }
602
+
603
+ for (const ptcIndex of ptcIndices) {
604
+ if (ptcIndex < 0 || ptcIndex >= PTC_SIZE) {
605
+ throw new Error(`Invalid PTC index: ${ptcIndex}, must be 0..${PTC_SIZE - 1}`);
230
606
  }
607
+
608
+ // Update the vote
609
+ votes[ptcIndex] = payloadPresent;
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Check if execution payload for a block is timely
615
+ * Spec: gloas/fork-choice.md#new-is_payload_timely
616
+ *
617
+ * Returns true if:
618
+ * 1. Block has PTC votes tracked
619
+ * 2. Payload is locally available (FULL variant exists in proto array)
620
+ * 3. More than PAYLOAD_TIMELY_THRESHOLD (>50% of PTC) members voted payload_present=true
621
+ *
622
+ * @param blockRoot - The beacon block root to check
623
+ */
624
+ isPayloadTimely(blockRoot: RootHex): boolean {
625
+ const votes = this.ptcVotes.get(blockRoot);
626
+ if (votes === undefined) {
627
+ // Block not found or not a Gloas block
628
+ return false;
629
+ }
630
+
631
+ // If payload is not locally available, it's not timely
632
+ // In our implementation, payload is locally available if proto array has FULL variant of the block
633
+ const fullNodeIndex = this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL);
634
+ if (fullNodeIndex === undefined) {
635
+ return false;
636
+ }
637
+
638
+ // Count votes for payload_present=true
639
+ const yesVotes = votes.filter((v) => v).length;
640
+ return yesVotes > PAYLOAD_TIMELY_THRESHOLD;
641
+ }
642
+
643
+ /**
644
+ * Check if parent node is FULL
645
+ * Spec: gloas/fork-choice.md#new-is_parent_node_full
646
+ *
647
+ * Returns true if the parent payload status (determined by block.parentBlockHash) is FULL
648
+ */
649
+ isParentNodeFull(block: ProtoBlock): boolean {
650
+ return this.getParentPayloadStatus(block) === PayloadStatus.FULL;
651
+ }
652
+
653
+ /**
654
+ * Determine if we should extend the payload (prefer FULL over EMPTY)
655
+ * Spec: gloas/fork-choice.md#new-should_extend_payload
656
+ *
657
+ * Returns true if:
658
+ * 1. Payload is timely, OR
659
+ * 2. No proposer boost root (empty/zero hash), OR
660
+ * 3. Proposer boost root's parent is not this block, OR
661
+ * 4. Proposer boost root extends FULL parent
662
+ *
663
+ * @param blockRoot - The block root to check
664
+ * @param proposerBoostRoot - Current proposer boost root (from ForkChoice)
665
+ */
666
+ shouldExtendPayload(blockRoot: RootHex, proposerBoostRoot: RootHex | null): boolean {
667
+ // Condition 1: Payload is timely
668
+ if (this.isPayloadTimely(blockRoot)) {
669
+ return true;
670
+ }
671
+
672
+ // Condition 2: No proposer boost root
673
+ if (proposerBoostRoot === null || proposerBoostRoot === HEX_ZERO_HASH) {
674
+ return true;
231
675
  }
676
+
677
+ // Get proposer boost block
678
+ // We don't care about variant here, just need proposer boost block info
679
+ const defaultStatus = this.getDefaultVariant(proposerBoostRoot);
680
+ const proposerBoostBlock = defaultStatus !== undefined ? this.getNode(proposerBoostRoot, defaultStatus) : undefined;
681
+ if (!proposerBoostBlock) {
682
+ // Proposer boost block not found, default to extending payload
683
+ return true;
684
+ }
685
+
686
+ // Condition 3: Proposer boost root's parent is not this block
687
+ if (proposerBoostBlock.parentRoot !== blockRoot) {
688
+ return true;
689
+ }
690
+
691
+ // Condition 4: Proposer boost root extends FULL parent
692
+ if (this.isParentNodeFull(proposerBoostBlock)) {
693
+ return true;
694
+ }
695
+
696
+ return false;
232
697
  }
233
698
 
234
699
  /**
@@ -236,6 +701,7 @@ export class ProtoArray {
236
701
  * if invalidate till hash provided. If consensus fails, this will invalidate entire
237
702
  * forkChoice which will throw on any call to findHead
238
703
  */
704
+ // TODO GLOAS: Review usage of this post-gloas
239
705
  validateLatestHash(execResponse: LVHExecResponse, currentSlot: Slot): void {
240
706
  // Look reverse because its highly likely node with latestValidExecHash is towards the
241
707
  // the leaves of the forkchoice
@@ -279,7 +745,12 @@ export class ProtoArray {
279
745
  // if its in fcU.
280
746
  //
281
747
  const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse;
282
- const invalidateFromParentIndex = this.indices.get(invalidateFromParentBlockRoot);
748
+ // TODO GLOAS: verify if getting default variant is correct here
749
+ const defaultStatus = this.getDefaultVariant(invalidateFromParentBlockRoot);
750
+ const invalidateFromParentIndex =
751
+ defaultStatus !== undefined
752
+ ? this.getNodeIndexByRootAndStatus(invalidateFromParentBlockRoot, defaultStatus)
753
+ : undefined;
283
754
  if (invalidateFromParentIndex === undefined) {
284
755
  throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`);
285
756
  }
@@ -444,10 +915,46 @@ export class ProtoArray {
444
915
  return validNode;
445
916
  }
446
917
 
918
+ /**
919
+ * Get payload status tiebreaker for fork choice comparison
920
+ * Spec: gloas/fork-choice.md#new-get_payload_status_tiebreaker
921
+ *
922
+ * For PENDING nodes: always returns 0
923
+ * For EMPTY/FULL variants from slot n-1: implements tiebreaker logic based on should_extend_payload
924
+ * For older blocks: returns node.payloadStatus
925
+ *
926
+ * Note: pre-gloas logic won't reach here. Pre-Gloas blocks have different roots, so they are always resolved by the weight and root tiebreaker before reaching here.
927
+ */
928
+ private getPayloadStatusTiebreaker(node: ProtoNode, currentSlot: Slot, proposerBoostRoot: RootHex | null): number {
929
+ // PENDING nodes always return PENDING (no tiebreaker needed)
930
+ // PENDING=0, EMPTY=1, FULL=2
931
+ if (node.payloadStatus === PayloadStatus.PENDING) {
932
+ return node.payloadStatus;
933
+ }
934
+
935
+ // For Gloas: check if from previous slot
936
+ if (node.slot + 1 !== currentSlot) {
937
+ return node.payloadStatus;
938
+ }
939
+
940
+ // For previous slot blocks in Gloas, decide between FULL and EMPTY
941
+ // based on should_extend_payload
942
+ if (node.payloadStatus === PayloadStatus.EMPTY) {
943
+ return PayloadStatus.EMPTY;
944
+ }
945
+ // FULL - check should_extend_payload
946
+ const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot);
947
+ return shouldExtend ? PayloadStatus.FULL : PayloadStatus.PENDING;
948
+ }
949
+
447
950
  /**
448
951
  * Follows the best-descendant links to find the best-block (i.e., head-block).
952
+ *
953
+ * Returns the ProtoNode representing the head.
954
+ * For pre-Gloas forks, only FULL variants exist (payload embedded).
955
+ * For Gloas, may return PENDING/EMPTY/FULL variants.
449
956
  */
450
- findHead(justifiedRoot: RootHex, currentSlot: Slot): RootHex {
957
+ findHead(justifiedRoot: RootHex, currentSlot: Slot): ProtoNode {
451
958
  if (this.lvhError) {
452
959
  throw new ProtoArrayError({
453
960
  code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE,
@@ -455,7 +962,10 @@ export class ProtoArray {
455
962
  });
456
963
  }
457
964
 
458
- const justifiedIndex = this.indices.get(justifiedRoot);
965
+ // Get canonical node: FULL for pre-Gloas, PENDING for Gloas
966
+ const defaultStatus = this.getDefaultVariant(justifiedRoot);
967
+ const justifiedIndex =
968
+ defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(justifiedRoot, defaultStatus) : undefined;
459
969
  if (justifiedIndex === undefined) {
460
970
  throw new ProtoArrayError({
461
971
  code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN,
@@ -507,7 +1017,7 @@ export class ProtoArray {
507
1017
  });
508
1018
  }
509
1019
 
510
- return bestNode.blockRoot;
1020
+ return bestNode;
511
1021
  }
512
1022
 
513
1023
  /**
@@ -526,26 +1036,40 @@ export class ProtoArray {
526
1036
  * - There is some internal error relating to invalid indices inside `this`.
527
1037
  */
528
1038
  maybePrune(finalizedRoot: RootHex): ProtoBlock[] {
529
- const finalizedIndex = this.indices.get(finalizedRoot);
530
- if (finalizedIndex === undefined) {
1039
+ const variants = this.indices.get(finalizedRoot);
1040
+ if (variants == null) {
531
1041
  throw new ProtoArrayError({
532
1042
  code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN,
533
1043
  root: finalizedRoot,
534
1044
  });
535
1045
  }
536
1046
 
1047
+ // Find the minimum index among all variants to ensure we don't prune too much
1048
+ const finalizedIndex = Array.isArray(variants)
1049
+ ? Math.min(...variants.filter((idx) => idx !== undefined))
1050
+ : variants;
1051
+
537
1052
  if (finalizedIndex < this.pruneThreshold) {
538
1053
  // Pruning at small numbers incurs more cost than benefit
539
1054
  return [];
540
1055
  }
541
1056
 
542
- // Remove the this.indices key/values for all the to-be-deleted nodes
543
- for (let nodeIndex = 0; nodeIndex < finalizedIndex; nodeIndex++) {
544
- const node = this.nodes[nodeIndex];
1057
+ // Collect all block roots that will be pruned
1058
+ const prunedRoots = new Set<RootHex>();
1059
+ for (let i = 0; i < finalizedIndex; i++) {
1060
+ const node = this.nodes[i];
545
1061
  if (node === undefined) {
546
- throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: nodeIndex});
1062
+ throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: i});
547
1063
  }
548
- this.indices.delete(node.blockRoot);
1064
+ prunedRoots.add(node.blockRoot);
1065
+ }
1066
+
1067
+ // Remove indices for pruned blocks and PTC votes
1068
+ for (const root of prunedRoots) {
1069
+ this.indices.delete(root);
1070
+ // Prune PTC votes for this block to prevent memory leak
1071
+ // Spec: gloas/fork-choice.md (implicit - finalized blocks don't need PTC votes)
1072
+ this.ptcVotes.delete(root);
549
1073
  }
550
1074
 
551
1075
  // Store nodes prior to finalization
@@ -553,15 +1077,35 @@ export class ProtoArray {
553
1077
  // Drop all the nodes prior to finalization
554
1078
  this.nodes = this.nodes.slice(finalizedIndex);
555
1079
 
556
- // Adjust the indices map
557
- for (const [key, value] of this.indices.entries()) {
558
- if (value < finalizedIndex) {
559
- throw new ProtoArrayError({
560
- code: ProtoArrayErrorCode.INDEX_OVERFLOW,
561
- value: "indices",
562
- });
1080
+ // Adjust the indices map - subtract finalizedIndex from all node indices
1081
+ for (const [root, variantIndices] of this.indices.entries()) {
1082
+ // Pre-Gloas: single index
1083
+ if (!Array.isArray(variantIndices)) {
1084
+ if (variantIndices < finalizedIndex) {
1085
+ throw new ProtoArrayError({
1086
+ code: ProtoArrayErrorCode.INDEX_OVERFLOW,
1087
+ value: "indices",
1088
+ });
1089
+ }
1090
+ this.indices.set(root, variantIndices - finalizedIndex);
1091
+ continue;
563
1092
  }
564
- this.indices.set(key, value - finalizedIndex);
1093
+
1094
+ // Post-Gloas: array of variant indices
1095
+ const adjustedVariants = variantIndices.map((variantIndex) => {
1096
+ if (variantIndex === undefined) {
1097
+ return undefined;
1098
+ }
1099
+
1100
+ if (variantIndex < finalizedIndex) {
1101
+ throw new ProtoArrayError({
1102
+ code: ProtoArrayErrorCode.INDEX_OVERFLOW,
1103
+ value: "indices",
1104
+ });
1105
+ }
1106
+ return variantIndex - finalizedIndex;
1107
+ });
1108
+ this.indices.set(root, adjustedVariants as GloasVariantIndices);
565
1109
  }
566
1110
 
567
1111
  // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes
@@ -610,7 +1154,13 @@ export class ProtoArray {
610
1154
  * - The child is not the best child but becomes the best child.
611
1155
  * - The child is not the best child and does not become the best child.
612
1156
  */
613
- maybeUpdateBestChildAndDescendant(parentIndex: number, childIndex: number, currentSlot: Slot): void {
1157
+
1158
+ maybeUpdateBestChildAndDescendant(
1159
+ parentIndex: number,
1160
+ childIndex: number,
1161
+ currentSlot: Slot,
1162
+ proposerBoostRoot: RootHex | null
1163
+ ): void {
614
1164
  const childNode = this.nodes[childIndex];
615
1165
  if (childNode === undefined) {
616
1166
  throw new ProtoArrayError({
@@ -640,54 +1190,90 @@ export class ProtoArray {
640
1190
 
641
1191
  let newChildAndDescendant: ChildAndDescendant;
642
1192
  const bestChildIndex = parentNode.bestChild;
643
- if (bestChildIndex !== undefined) {
644
- if (bestChildIndex === childIndex && !childLeadsToViableHead) {
645
- // the child is already the best-child of the parent but its not viable for the head
646
- // so remove it
647
- newChildAndDescendant = changeToNull;
648
- } else if (bestChildIndex === childIndex) {
649
- // the child is the best-child already
650
- // set it again to ensure that the best-descendent of the parent is updated
651
- newChildAndDescendant = changeToChild;
652
- } else {
653
- const bestChildNode = this.nodes[bestChildIndex];
654
- if (bestChildNode === undefined) {
655
- throw new ProtoArrayError({
656
- code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX,
657
- index: bestChildIndex,
658
- });
659
- }
1193
+ // biome-ignore lint/suspicious/noConfusingLabels: labeled block used for early exit from complex decision tree
1194
+ outer: {
1195
+ if (bestChildIndex !== undefined) {
1196
+ if (bestChildIndex === childIndex && !childLeadsToViableHead) {
1197
+ // the child is already the best-child of the parent but its not viable for the head
1198
+ // so remove it
1199
+ newChildAndDescendant = changeToNull;
1200
+ } else if (bestChildIndex === childIndex) {
1201
+ // the child is the best-child already
1202
+ // set it again to ensure that the best-descendent of the parent is updated
1203
+ newChildAndDescendant = changeToChild;
1204
+ } else {
1205
+ const bestChildNode = this.nodes[bestChildIndex];
1206
+ if (bestChildNode === undefined) {
1207
+ throw new ProtoArrayError({
1208
+ code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX,
1209
+ index: bestChildIndex,
1210
+ });
1211
+ }
660
1212
 
661
- const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot);
1213
+ const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot);
662
1214
 
663
- if (childLeadsToViableHead && !bestChildLeadsToViableHead) {
664
- // the child leads to a viable head, but the current best-child doesn't
665
- newChildAndDescendant = changeToChild;
666
- } else if (!childLeadsToViableHead && bestChildLeadsToViableHead) {
667
- // the best child leads to a viable head but the child doesn't
668
- newChildAndDescendant = noChange;
669
- } else if (childNode.weight === bestChildNode.weight) {
670
- // tie-breaker of equal weights by root
671
- if (childNode.blockRoot >= bestChildNode.blockRoot) {
1215
+ if (childLeadsToViableHead && !bestChildLeadsToViableHead) {
1216
+ // the child leads to a viable head, but the current best-child doesn't
672
1217
  newChildAndDescendant = changeToChild;
673
- } else {
1218
+ break outer;
1219
+ }
1220
+ if (!childLeadsToViableHead && bestChildLeadsToViableHead) {
1221
+ // the best child leads to a viable head but the child doesn't
674
1222
  newChildAndDescendant = noChange;
1223
+ break outer;
675
1224
  }
676
- } else {
677
- // choose the winner by weight
678
- if (childNode.weight >= bestChildNode.weight) {
1225
+ // Both nodes lead to viable heads (or both don't), need to pick winner
1226
+
1227
+ // Pre-fulu we pick whichever has higher weight, tie-breaker by root
1228
+ // Post-fulu we pick whichever has higher weight, then tie-breaker by root, then tie-breaker by `getPayloadStatusTiebreaker`
1229
+ // Gloas: nodes from previous slot (n-1) with EMPTY/FULL variant have weight hardcoded to 0.
1230
+ // https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442
1231
+ const childEffectiveWeight =
1232
+ !isGloasBlock(childNode) ||
1233
+ childNode.payloadStatus === PayloadStatus.PENDING ||
1234
+ childNode.slot + 1 !== currentSlot
1235
+ ? childNode.weight
1236
+ : 0;
1237
+ const bestChildEffectiveWeight =
1238
+ !isGloasBlock(bestChildNode) ||
1239
+ bestChildNode.payloadStatus === PayloadStatus.PENDING ||
1240
+ bestChildNode.slot + 1 !== currentSlot
1241
+ ? bestChildNode.weight
1242
+ : 0;
1243
+
1244
+ if (childEffectiveWeight !== bestChildEffectiveWeight) {
1245
+ // Different effective weights, choose the winner by weight
1246
+ newChildAndDescendant = childEffectiveWeight >= bestChildEffectiveWeight ? changeToChild : noChange;
1247
+ break outer;
1248
+ }
1249
+
1250
+ if (childNode.blockRoot !== bestChildNode.blockRoot) {
1251
+ // Different blocks, tie-breaker by root
1252
+ newChildAndDescendant = childNode.blockRoot >= bestChildNode.blockRoot ? changeToChild : noChange;
1253
+ break outer;
1254
+ }
1255
+
1256
+ // Same effective weight and same root — Gloas EMPTY vs FULL from n-1, tie-breaker by payload status
1257
+ // Note: pre-Gloas, each child node of a block has a unique root, so this point should not be reached
1258
+ const childTiebreaker = this.getPayloadStatusTiebreaker(childNode, currentSlot, proposerBoostRoot);
1259
+ const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, proposerBoostRoot);
1260
+
1261
+ if (childTiebreaker > bestChildTiebreaker) {
679
1262
  newChildAndDescendant = changeToChild;
1263
+ } else if (childTiebreaker < bestChildTiebreaker) {
1264
+ newChildAndDescendant = noChange;
680
1265
  } else {
1266
+ // Equal in all aspects, noChange
681
1267
  newChildAndDescendant = noChange;
682
1268
  }
683
1269
  }
1270
+ } else if (childLeadsToViableHead) {
1271
+ // There is no current best-child and the child is viable.
1272
+ newChildAndDescendant = changeToChild;
1273
+ } else {
1274
+ // There is no current best-child but the child is not viable.
1275
+ newChildAndDescendant = noChange;
684
1276
  }
685
- } else if (childLeadsToViableHead) {
686
- // There is no current best-child and the child is viable.
687
- newChildAndDescendant = changeToChild;
688
- } else {
689
- // There is no current best-child but the child is not viable.
690
- newChildAndDescendant = noChange;
691
1277
  }
692
1278
 
693
1279
  parentNode.bestChild = newChildAndDescendant[0];
@@ -767,13 +1353,14 @@ export class ProtoArray {
767
1353
  }
768
1354
 
769
1355
  const finalizedSlot = computeStartSlotAtEpoch(this.finalizedEpoch);
770
- return this.finalizedEpoch === 0 || this.finalizedRoot === this.getAncestorOrNull(node.blockRoot, finalizedSlot);
1356
+ const ancestorNode = this.getAncestorOrNull(node.blockRoot, finalizedSlot);
1357
+ return this.finalizedEpoch === 0 || (ancestorNode !== null && this.finalizedRoot === ancestorNode.blockRoot);
771
1358
  }
772
1359
 
773
1360
  /**
774
1361
  * Same to getAncestor but it may return null instead of throwing error
775
1362
  */
776
- getAncestorOrNull(blockRoot: RootHex, ancestorSlot: Slot): RootHex | null {
1363
+ getAncestorOrNull(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode | null {
777
1364
  try {
778
1365
  return this.getAncestor(blockRoot, ancestorSlot);
779
1366
  } catch (_) {
@@ -782,49 +1369,116 @@ export class ProtoArray {
782
1369
  }
783
1370
 
784
1371
  /**
785
- * Returns the block root of an ancestor of `blockRoot` at the given `slot`.
1372
+ * Returns the node identifier of an ancestor of `blockRoot` at the given `slot`.
786
1373
  * (Note: `slot` refers to the block that is *returned*, not the one that is supplied.)
787
1374
  *
788
1375
  * NOTE: May be expensive: potentially walks through the entire fork of head to finalized block
789
1376
  *
790
1377
  * ### Specification
791
1378
  *
792
- * Equivalent to:
1379
+ * Modified for Gloas to return node identifier instead of just root:
1380
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#modified-get_ancestor
793
1381
  *
794
- * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_ancestor
1382
+ * Pre-Gloas: Returns (root, PAYLOAD_STATUS_FULL)
1383
+ * Gloas: Returns (root, payloadStatus) based on actual node state
795
1384
  */
796
- getAncestor(blockRoot: RootHex, ancestorSlot: Slot): RootHex {
797
- const block = this.getBlock(blockRoot);
798
- if (!block) {
1385
+ getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode {
1386
+ // Get any variant to check the block (use variants[0])
1387
+ const variantOrArr = this.indices.get(blockRoot);
1388
+ if (variantOrArr == null) {
799
1389
  throw new ForkChoiceError({
800
1390
  code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK,
801
1391
  root: blockRoot,
802
1392
  });
803
1393
  }
804
1394
 
805
- if (block.slot > ancestorSlot) {
806
- // Search for a slot that is lte the target slot.
807
- // We check for lower slots to account for skip slots.
808
- for (const node of this.iterateAncestorNodes(blockRoot)) {
809
- if (node.slot <= ancestorSlot) {
810
- return node.blockRoot;
811
- }
1395
+ const blockIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr;
1396
+ const block = this.nodes[blockIndex];
1397
+
1398
+ // If block is at or before queried slot, return PENDING variant (or FULL for pre-Gloas)
1399
+ if (block.slot <= ancestorSlot) {
1400
+ // For pre-Gloas: only FULL exists at variants[0]
1401
+ // For Gloas: PENDING is at variants[0]
1402
+ return block;
1403
+ }
1404
+
1405
+ // Walk backwards through beacon blocks to find ancestor
1406
+ // Start with the parent of the current block
1407
+ let currentBlock = block;
1408
+ const parentVariants = this.indices.get(currentBlock.parentRoot);
1409
+ if (parentVariants == null) {
1410
+ throw new ForkChoiceError({
1411
+ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
1412
+ descendantRoot: blockRoot,
1413
+ ancestorSlot,
1414
+ });
1415
+ }
1416
+
1417
+ let parentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants;
1418
+ let parentBlock = this.nodes[parentIndex];
1419
+
1420
+ // Walk backwards while parent.slot > ancestorSlot
1421
+ while (parentBlock.slot > ancestorSlot) {
1422
+ currentBlock = parentBlock;
1423
+
1424
+ const nextParentVariants = this.indices.get(currentBlock.parentRoot);
1425
+ if (nextParentVariants == null) {
1426
+ throw new ForkChoiceError({
1427
+ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
1428
+ descendantRoot: blockRoot,
1429
+ ancestorSlot,
1430
+ });
812
1431
  }
1432
+
1433
+ parentIndex = Array.isArray(nextParentVariants) ? nextParentVariants[0] : nextParentVariants;
1434
+ parentBlock = this.nodes[parentIndex];
1435
+ }
1436
+
1437
+ // Now parentBlock.slot <= ancestorSlot
1438
+ // Return the parent with the correct payload status based on currentBlock
1439
+ if (!isGloasBlock(currentBlock)) {
1440
+ // Pre-Gloas: return FULL variant (only one that exists)
1441
+ return parentBlock;
1442
+ }
1443
+
1444
+ // Gloas: determine which parent variant (EMPTY or FULL) based on parent_block_hash
1445
+ const parentPayloadStatus = this.getParentPayloadStatus(currentBlock);
1446
+ const parentVariantIndex = this.getNodeIndexByRootAndStatus(currentBlock.parentRoot, parentPayloadStatus);
1447
+
1448
+ if (parentVariantIndex === undefined) {
813
1449
  throw new ForkChoiceError({
814
1450
  code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
815
1451
  descendantRoot: blockRoot,
816
1452
  ancestorSlot,
817
1453
  });
818
1454
  }
819
- // Root is older or equal than queried slot, thus a skip slot. Return most recent root prior to slot.
820
- return blockRoot;
1455
+
1456
+ return this.nodes[parentVariantIndex];
1457
+ }
1458
+
1459
+ /**
1460
+ * Get the parent node index for traversal
1461
+ * For Gloas blocks: returns the correct EMPTY/FULL variant based on parent payload status
1462
+ * For pre-Gloas blocks: returns the simple parent index
1463
+ * Returns undefined if parent doesn't exist or can't be found
1464
+ */
1465
+ private getParentNodeIndex(node: ProtoNode): number | undefined {
1466
+ if (isGloasBlock(node)) {
1467
+ // Use getParentPayloadStatus for Gloas blocks to get correct EMPTY/FULL variant
1468
+ const parentPayloadStatus = this.getParentPayloadStatus(node);
1469
+ return this.getNodeIndexByRootAndStatus(node.parentRoot, parentPayloadStatus);
1470
+ }
1471
+ // Simple parent traversal for pre-Gloas blocks (includes fork transition)
1472
+ return node.parent;
821
1473
  }
822
1474
 
823
1475
  /**
824
1476
  * Iterate from a block root backwards over nodes
1477
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1478
+ * For pre-Gloas blocks: returns FULL variants
825
1479
  */
826
- *iterateAncestorNodes(blockRoot: RootHex): IterableIterator<ProtoNode> {
827
- const startIndex = this.indices.get(blockRoot);
1480
+ *iterateAncestorNodes(blockRoot: RootHex, payloadStatus: PayloadStatus): IterableIterator<ProtoNode> {
1481
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
828
1482
  if (startIndex === undefined) {
829
1483
  return;
830
1484
  }
@@ -841,20 +1495,30 @@ export class ProtoArray {
841
1495
  }
842
1496
 
843
1497
  /**
844
- * Iterate from a block root backwards over nodes
1498
+ * Iterate from a node backwards over ancestor nodes
1499
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1500
+ * For pre-Gloas blocks: returns FULL variants
1501
+ * Handles fork transition from Gloas to pre-Gloas blocks
845
1502
  */
846
1503
  *iterateAncestorNodesFromNode(node: ProtoNode): IterableIterator<ProtoNode> {
847
1504
  while (node.parent !== undefined) {
848
- node = this.getNodeFromIndex(node.parent);
1505
+ const parentIndex = this.getParentNodeIndex(node);
1506
+ if (parentIndex === undefined) {
1507
+ break;
1508
+ }
1509
+
1510
+ node = this.nodes[parentIndex];
849
1511
  yield node;
850
1512
  }
851
1513
  }
852
1514
 
853
1515
  /**
854
1516
  * Get all nodes from a block root backwards
1517
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1518
+ * For pre-Gloas blocks: returns FULL variants
855
1519
  */
856
- getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] {
857
- const startIndex = this.indices.get(blockRoot);
1520
+ getAllAncestorNodes(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode[] {
1521
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
858
1522
  if (startIndex === undefined) {
859
1523
  return [];
860
1524
  }
@@ -867,10 +1531,20 @@ export class ProtoArray {
867
1531
  });
868
1532
  }
869
1533
 
870
- const nodes = [node];
1534
+ // Exclude PENDING variant from returned ancestors.
1535
+ const nodes: ProtoNode[] = [];
1536
+
1537
+ if (node.payloadStatus !== PayloadStatus.PENDING) {
1538
+ nodes.push(node);
1539
+ }
871
1540
 
872
1541
  while (node.parent !== undefined) {
873
- node = this.getNodeFromIndex(node.parent);
1542
+ const parentIndex = this.getParentNodeIndex(node);
1543
+ if (parentIndex === undefined) {
1544
+ break;
1545
+ }
1546
+
1547
+ node = this.nodes[parentIndex];
874
1548
  nodes.push(node);
875
1549
  }
876
1550
 
@@ -881,9 +1555,12 @@ export class ProtoArray {
881
1555
  * The opposite of iterateNodes.
882
1556
  * iterateNodes is to find ancestor nodes of a blockRoot.
883
1557
  * this is to find non-ancestor nodes of a blockRoot.
1558
+ *
1559
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1560
+ * For pre-Gloas blocks: returns FULL variants
884
1561
  */
885
- getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] {
886
- const startIndex = this.indices.get(blockRoot);
1562
+ getAllNonAncestorNodes(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode[] {
1563
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
887
1564
  if (startIndex === undefined) {
888
1565
  return [];
889
1566
  }
@@ -895,24 +1572,39 @@ export class ProtoArray {
895
1572
  index: startIndex,
896
1573
  });
897
1574
  }
1575
+
1576
+ // For both Gloas and pre-Gloas blocks
898
1577
  const result: ProtoNode[] = [];
899
1578
  let nodeIndex = startIndex;
900
1579
  while (node.parent !== undefined) {
901
- const parentIndex = node.parent;
902
- node = this.getNodeFromIndex(parentIndex);
903
- // nodes between nodeIndex and parentIndex means non-ancestor nodes
904
- result.push(...this.getNodesBetween(nodeIndex, parentIndex));
1580
+ const parentIndex = this.getParentNodeIndex(node);
1581
+ if (parentIndex === undefined) {
1582
+ break;
1583
+ }
1584
+
1585
+ node = this.nodes[parentIndex];
1586
+ // Collect non-ancestor nodes between current and parent
1587
+ // Filter to exclude PENDING nodes (FULL variant pre-gloas, EMPTY or FULL variant post-gloas)
1588
+ result.push(
1589
+ ...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)
1590
+ );
905
1591
  nodeIndex = parentIndex;
906
1592
  }
907
- result.push(...this.getNodesBetween(nodeIndex, 0));
1593
+ // Collect remaining nodes from nodeIndex to beginning
1594
+ result.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
908
1595
  return result;
909
1596
  }
910
1597
 
911
1598
  /**
912
1599
  * Returns both ancestor and non-ancestor nodes in a single traversal.
1600
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1601
+ * For pre-Gloas blocks: returns FULL variants
913
1602
  */
914
- getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} {
915
- const startIndex = this.indices.get(blockRoot);
1603
+ getAllAncestorAndNonAncestorNodes(
1604
+ blockRoot: RootHex,
1605
+ payloadStatus: PayloadStatus
1606
+ ): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} {
1607
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
916
1608
  if (startIndex === undefined) {
917
1609
  return {ancestors: [], nonAncestors: []};
918
1610
  }
@@ -928,39 +1620,78 @@ export class ProtoArray {
928
1620
  const ancestors: ProtoNode[] = [];
929
1621
  const nonAncestors: ProtoNode[] = [];
930
1622
 
1623
+ // Include starting node if it's not PENDING (i.e., pre-Gloas or EMPTY/FULL variant post-Gloas)
1624
+ if (node.payloadStatus !== PayloadStatus.PENDING) {
1625
+ ancestors.push(node);
1626
+ }
1627
+
931
1628
  let nodeIndex = startIndex;
932
1629
  while (node.parent !== undefined) {
933
- ancestors.push(node);
1630
+ const parentIndex = this.getParentNodeIndex(node);
1631
+ if (parentIndex === undefined) {
1632
+ break;
1633
+ }
934
1634
 
935
- const parentIndex = node.parent;
936
- node = this.getNodeFromIndex(parentIndex);
1635
+ node = this.nodes[parentIndex];
1636
+ ancestors.push(node);
937
1637
 
938
- // Nodes between nodeIndex and parentIndex are non-ancestor nodes
939
- nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex));
1638
+ // Collect non-ancestor nodes between current and parent
1639
+ // Filter to exclude PENDING nodes (include all FULL/EMPTY for both pre-Gloas and Gloas)
1640
+ nonAncestors.push(
1641
+ ...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)
1642
+ );
940
1643
  nodeIndex = parentIndex;
941
1644
  }
942
1645
 
943
- ancestors.push(node);
944
- nonAncestors.push(...this.getNodesBetween(nodeIndex, 0));
1646
+ // Collect remaining non-ancestor nodes from nodeIndex to beginning
1647
+ nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
945
1648
 
946
1649
  return {ancestors, nonAncestors};
947
1650
  }
948
1651
 
1652
+ /**
1653
+ * Check if a block exists in the proto array
1654
+ * Uses default variant (PENDING for Gloas, FULL for pre-Gloas)
1655
+ */
949
1656
  hasBlock(blockRoot: RootHex): boolean {
950
- return this.indices.has(blockRoot);
1657
+ const defaultVariant = this.getDefaultVariant(blockRoot);
1658
+ if (defaultVariant === undefined) {
1659
+ return false;
1660
+ }
1661
+ const index = this.getNodeIndexByRootAndStatus(blockRoot, defaultVariant);
1662
+ return index !== undefined;
951
1663
  }
952
1664
 
953
- getNode(blockRoot: RootHex): ProtoNode | undefined {
954
- const blockIndex = this.indices.get(blockRoot);
1665
+ /**
1666
+ * Return ProtoNode for blockRoot with explicit payload status
1667
+ *
1668
+ * @param blockRoot - The block root to look up
1669
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1670
+ * @returns The ProtoNode for the specified variant, or undefined if not found
1671
+ *
1672
+ * Note: Callers must explicitly specify which variant they need.
1673
+ * Use getDefaultVariant() to get the canonical variant for a block.
1674
+ */
1675
+ getNode(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode | undefined {
1676
+ const blockIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
955
1677
  if (blockIndex === undefined) {
956
1678
  return undefined;
957
1679
  }
958
1680
  return this.getNodeByIndex(blockIndex);
959
1681
  }
960
1682
 
961
- /** Return MUTABLE ProtoBlock for blockRoot (spreads properties) */
962
- getBlock(blockRoot: RootHex): ProtoBlock | undefined {
963
- const node = this.getNode(blockRoot);
1683
+ /**
1684
+ * Return MUTABLE ProtoBlock for blockRoot with explicit payload status
1685
+ *
1686
+ * @param blockRoot - The block root to look up
1687
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1688
+ * @returns The ProtoBlock for the specified variant (spreads properties), or undefined if not found
1689
+ *
1690
+ * Note: Callers must explicitly specify which variant they need.
1691
+ * Use getDefaultVariant() to get the canonical variant for a block.
1692
+ */
1693
+ getBlock(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock | undefined {
1694
+ const node = this.getNode(blockRoot, payloadStatus);
964
1695
  if (!node) {
965
1696
  return undefined;
966
1697
  }
@@ -969,9 +1700,19 @@ export class ProtoArray {
969
1700
  };
970
1701
  }
971
1702
 
972
- /** Return NON-MUTABLE ProtoBlock for blockRoot (does not spread properties) */
973
- getBlockReadonly(blockRoot: RootHex): ProtoBlock {
974
- const node = this.getNode(blockRoot);
1703
+ /**
1704
+ * Return NON-MUTABLE ProtoBlock for blockRoot with explicit payload status
1705
+ *
1706
+ * @param blockRoot - The block root to look up
1707
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1708
+ * @returns The ProtoBlock for the specified variant (does not spread properties)
1709
+ * @throws Error if block not found
1710
+ *
1711
+ * Note: Callers must explicitly specify which variant they need.
1712
+ * Use getDefaultVariant() to get the canonical variant for a block.
1713
+ */
1714
+ getBlockReadonly(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock {
1715
+ const node = this.getNode(blockRoot, payloadStatus);
975
1716
  if (!node) {
976
1717
  throw Error(`No block for root ${blockRoot}`);
977
1718
  }
@@ -981,23 +1722,28 @@ export class ProtoArray {
981
1722
  /**
982
1723
  * Returns `true` if the `descendantRoot` has an ancestor with `ancestorRoot`.
983
1724
  * Always returns `false` if either input roots are unknown.
984
- * Still returns `true` if `ancestorRoot` === `descendantRoot` (and the roots are known)
1725
+ * Still returns `true` if `ancestorRoot` === `descendantRoot` and payload statuses match.
985
1726
  */
986
- isDescendant(ancestorRoot: RootHex, descendantRoot: RootHex): boolean {
987
- const ancestorNode = this.getNode(ancestorRoot);
1727
+ isDescendant(
1728
+ ancestorRoot: RootHex,
1729
+ ancestorPayloadStatus: PayloadStatus,
1730
+ descendantRoot: RootHex,
1731
+ descendantPayloadStatus: PayloadStatus
1732
+ ): boolean {
1733
+ const ancestorNode = this.getNode(ancestorRoot, ancestorPayloadStatus);
988
1734
  if (!ancestorNode) {
989
1735
  return false;
990
1736
  }
991
1737
 
992
- if (ancestorRoot === descendantRoot) {
1738
+ if (ancestorRoot === descendantRoot && ancestorPayloadStatus === descendantPayloadStatus) {
993
1739
  return true;
994
1740
  }
995
1741
 
996
- for (const node of this.iterateAncestorNodes(descendantRoot)) {
1742
+ for (const node of this.iterateAncestorNodes(descendantRoot, descendantPayloadStatus)) {
997
1743
  if (node.slot < ancestorNode.slot) {
998
1744
  return false;
999
1745
  }
1000
- if (node.blockRoot === ancestorNode.blockRoot) {
1746
+ if (node.blockRoot === ancestorNode.blockRoot && node.payloadStatus === ancestorNode.payloadStatus) {
1001
1747
  return true;
1002
1748
  }
1003
1749
  }