@lodestar/fork-choice 1.41.0-dev.192806a314 → 1.41.0-dev.1ff95eaa7b

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