@lodestar/fork-choice 1.41.0-dev.afd446235e → 1.41.0-dev.b90dff673d

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 +76 -20
  6. package/lib/forkChoice/forkChoice.d.ts.map +1 -1
  7. package/lib/forkChoice/forkChoice.js +304 -118
  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 +33 -3
  32. package/lib/protoArray/interface.d.ts.map +1 -1
  33. package/lib/protoArray/interface.js +31 -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 +763 -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 +387 -127
  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 +48 -3
  48. package/src/protoArray/protoArray.ts +896 -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
 
@@ -183,7 +403,7 @@ export class ProtoArray {
183
403
  // If the node has a parent, try to update its best-child and best-descendant.
184
404
  const parentIndex = node.parent;
185
405
  if (parentIndex !== undefined) {
186
- this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot);
406
+ this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot, proposerBoost?.root ?? null);
187
407
  }
188
408
  }
189
409
  // Update the previous proposer boost
@@ -195,9 +415,9 @@ export class ProtoArray {
195
415
  *
196
416
  * It is only sane to supply an undefined parent for the genesis block
197
417
  */
198
- onBlock(block: ProtoBlock, currentSlot: Slot): void {
418
+ onBlock(block: ProtoBlock, currentSlot: Slot, proposerBoostRoot: RootHex | null): void {
199
419
  // If the block is already known, simply ignore it
200
- if (this.indices.has(block.blockRoot)) {
420
+ if (this.hasBlock(block.blockRoot)) {
201
421
  return;
202
422
  }
203
423
  if (block.executionStatus === ExecutionStatus.Invalid) {
@@ -207,35 +427,301 @@ export class ProtoArray {
207
427
  });
208
428
  }
209
429
 
210
- const node: ProtoNode = {
211
- ...block,
212
- parent: this.indices.get(block.parentRoot),
430
+ if (isGloasBlock(block)) {
431
+ // Gloas: Create PENDING + EMPTY nodes with correct parent relationships
432
+ // Parent of new PENDING node = parent block's EMPTY or FULL (inter-block edge)
433
+ // Parent of new EMPTY node = own PENDING node (intra-block edge)
434
+
435
+ // For fork transition: if parent is pre-Gloas, point to parent's FULL
436
+ // Otherwise, determine which parent payload status this block extends
437
+ let parentIndex: number | undefined;
438
+
439
+ // Check if parent exists by getting variants array
440
+ const parentVariants = this.indices.get(block.parentRoot);
441
+ if (parentVariants != null) {
442
+ const anyParentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants;
443
+ const anyParentNode = this.nodes[anyParentIndex];
444
+
445
+ if (!isGloasBlock(anyParentNode)) {
446
+ // Fork transition: parent is pre-Gloas, so it only has FULL variant at variants[0]
447
+ parentIndex = anyParentIndex;
448
+ } else {
449
+ // Both blocks are Gloas: determine which parent payload status to extend
450
+ const parentPayloadStatus = this.getParentPayloadStatus(block);
451
+ parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus);
452
+ }
453
+ }
454
+ // else: parent doesn't exist, parentIndex remains undefined (orphan block)
455
+
456
+ // Create PENDING node
457
+ const pendingNode: ProtoNode = {
458
+ ...block,
459
+ parent: parentIndex, // Points to parent's EMPTY/FULL or FULL (for transition)
460
+ payloadStatus: PayloadStatus.PENDING,
461
+ weight: 0,
462
+ bestChild: undefined,
463
+ bestDescendant: undefined,
464
+ };
465
+
466
+ const pendingIndex = this.nodes.length;
467
+ this.nodes.push(pendingNode);
468
+
469
+ // Create EMPTY variant as a child of PENDING
470
+ const emptyNode: ProtoNode = {
471
+ ...block,
472
+ parent: pendingIndex, // Points to own PENDING
473
+ payloadStatus: PayloadStatus.EMPTY,
474
+ weight: 0,
475
+ bestChild: undefined,
476
+ bestDescendant: undefined,
477
+ };
478
+
479
+ const emptyIndex = this.nodes.length;
480
+ this.nodes.push(emptyNode);
481
+
482
+ // Store both variants in the indices array
483
+ // [PENDING, EMPTY, undefined] - FULL will be added later if payload arrives
484
+ this.indices.set(block.blockRoot, [pendingIndex, emptyIndex]);
485
+
486
+ // Update bestChild pointers
487
+ if (parentIndex !== undefined) {
488
+ this.maybeUpdateBestChildAndDescendant(parentIndex, pendingIndex, currentSlot, proposerBoostRoot);
489
+
490
+ if (pendingNode.executionStatus === ExecutionStatus.Valid) {
491
+ this.propagateValidExecutionStatusByIndex(parentIndex);
492
+ }
493
+ }
494
+
495
+ // Update bestChild for PENDING → EMPTY edge
496
+ this.maybeUpdateBestChildAndDescendant(pendingIndex, emptyIndex, currentSlot, proposerBoostRoot);
497
+
498
+ // Initialize PTC votes for this block (all false initially)
499
+ // Spec: gloas/fork-choice.md#modified-on_block (line 645)
500
+ this.ptcVotes.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE));
501
+ } else {
502
+ // Pre-Gloas: Only create FULL node (payload embedded in block)
503
+ const node: ProtoNode = {
504
+ ...block,
505
+ parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL),
506
+ payloadStatus: PayloadStatus.FULL,
507
+ weight: 0,
508
+ bestChild: undefined,
509
+ bestDescendant: undefined,
510
+ };
511
+
512
+ const nodeIndex = this.nodes.length;
513
+ this.nodes.push(node);
514
+
515
+ // Pre-Gloas: store FULL index instead of array
516
+ this.indices.set(block.blockRoot, nodeIndex);
517
+
518
+ // If this node is valid, lets propagate the valid status up the chain
519
+ // and throw error if we counter invalid, as this breaks consensus
520
+ if (node.parent !== undefined) {
521
+ this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot, proposerBoostRoot);
522
+
523
+ if (node.executionStatus === ExecutionStatus.Valid) {
524
+ this.propagateValidExecutionStatusByIndex(node.parent);
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Called when an execution payload is received for a block (Gloas only)
532
+ * Creates a FULL variant node as a sibling to the existing EMPTY variant
533
+ * Both EMPTY and FULL have parent = own PENDING node
534
+ *
535
+ * Spec: gloas/fork-choice.md (on_execution_payload event)
536
+ */
537
+ onExecutionPayload(
538
+ blockRoot: RootHex,
539
+ currentSlot: Slot,
540
+ executionPayloadBlockHash: RootHex,
541
+ executionPayloadNumber: number,
542
+ executionPayloadStateRoot: RootHex,
543
+ proposerBoostRoot: RootHex | null
544
+ ): void {
545
+ // First check if block exists
546
+ const variants = this.indices.get(blockRoot);
547
+ if (variants == null) {
548
+ // Equivalent to `assert envelope.beacon_block_root in store.block_states`
549
+ throw new ProtoArrayError({
550
+ code: ProtoArrayErrorCode.UNKNOWN_BLOCK,
551
+ root: blockRoot,
552
+ });
553
+ }
554
+
555
+ if (!Array.isArray(variants)) {
556
+ // Pre-gloas block should not be calling this method
557
+ throw new ProtoArrayError({
558
+ code: ProtoArrayErrorCode.PRE_GLOAS_BLOCK,
559
+ root: blockRoot,
560
+ });
561
+ }
562
+
563
+ // Check if FULL already exists for Gloas blocks
564
+ if (variants[PayloadStatus.FULL] !== undefined) {
565
+ return;
566
+ }
567
+
568
+ // Get PENDING node for Gloas blocks
569
+ const pendingIndex = variants[PayloadStatus.PENDING];
570
+ if (pendingIndex === undefined) {
571
+ throw new ProtoArrayError({
572
+ code: ProtoArrayErrorCode.UNKNOWN_BLOCK,
573
+ root: blockRoot,
574
+ });
575
+ }
576
+
577
+ const pendingNode = this.nodes[pendingIndex];
578
+ if (!pendingNode) {
579
+ throw new ProtoArrayError({
580
+ code: ProtoArrayErrorCode.INVALID_NODE_INDEX,
581
+ index: pendingIndex,
582
+ });
583
+ }
584
+
585
+ // Create FULL variant as a child of PENDING (sibling to EMPTY)
586
+ const fullNode: ProtoNode = {
587
+ ...pendingNode,
588
+ parent: pendingIndex, // Points to own PENDING (same as EMPTY)
589
+ payloadStatus: PayloadStatus.FULL,
213
590
  weight: 0,
214
591
  bestChild: undefined,
215
592
  bestDescendant: undefined,
593
+ executionStatus: ExecutionStatus.Valid,
594
+ executionPayloadBlockHash,
595
+ executionPayloadNumber,
596
+ stateRoot: executionPayloadStateRoot,
216
597
  };
217
598
 
218
- const nodeIndex = this.nodes.length;
599
+ const fullIndex = this.nodes.length;
600
+ this.nodes.push(fullNode);
219
601
 
220
- this.indices.set(node.blockRoot, nodeIndex);
221
- this.nodes.push(node);
602
+ // Add FULL variant to the indices array
603
+ variants[PayloadStatus.FULL] = fullIndex;
222
604
 
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);
605
+ // Update bestChild for PENDING node (may now prefer FULL over EMPTY)
606
+ this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot, proposerBoostRoot);
607
+ }
227
608
 
228
- if (node.executionStatus === ExecutionStatus.Valid) {
229
- this.propagateValidExecutionStatusByIndex(node.parent);
609
+ /**
610
+ * Update PTC votes for multiple validators attesting to a block
611
+ * Spec: gloas/fork-choice.md#new-on_payload_attestation_message
612
+ *
613
+ * @param blockRoot - The beacon block root being attested
614
+ * @param ptcIndices - Array of PTC committee indices that voted (0..PTC_SIZE-1)
615
+ * @param payloadPresent - Whether the validators attest the payload is present
616
+ */
617
+ notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void {
618
+ const votes = this.ptcVotes.get(blockRoot);
619
+ if (votes === undefined) {
620
+ // Block not found or not a Gloas block, ignore
621
+ return;
622
+ }
623
+
624
+ for (const ptcIndex of ptcIndices) {
625
+ if (ptcIndex < 0 || ptcIndex >= PTC_SIZE) {
626
+ throw new Error(`Invalid PTC index: ${ptcIndex}, must be 0..${PTC_SIZE - 1}`);
230
627
  }
628
+
629
+ votes.set(ptcIndex, payloadPresent);
231
630
  }
232
631
  }
233
632
 
633
+ /**
634
+ * Check if execution payload for a block is timely
635
+ * Spec: gloas/fork-choice.md#new-is_payload_timely
636
+ *
637
+ * Returns true if:
638
+ * 1. Block has PTC votes tracked
639
+ * 2. Payload is locally available (FULL variant exists in proto array)
640
+ * 3. More than PAYLOAD_TIMELY_THRESHOLD (>50% of PTC) members voted payload_present=true
641
+ *
642
+ * @param blockRoot - The beacon block root to check
643
+ */
644
+ isPayloadTimely(blockRoot: RootHex): boolean {
645
+ const votes = this.ptcVotes.get(blockRoot);
646
+ if (votes === undefined) {
647
+ // Block not found or not a Gloas block
648
+ return false;
649
+ }
650
+
651
+ // If payload is not locally available, it's not timely
652
+ // In our implementation, payload is locally available if proto array has FULL variant of the block
653
+ const fullNodeIndex = this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL);
654
+ if (fullNodeIndex === undefined) {
655
+ return false;
656
+ }
657
+
658
+ // Count votes for payload_present=true
659
+ const yesVotes = bitCount(votes.uint8Array);
660
+ return yesVotes > PAYLOAD_TIMELY_THRESHOLD;
661
+ }
662
+
663
+ /**
664
+ * Check if parent node is FULL
665
+ * Spec: gloas/fork-choice.md#new-is_parent_node_full
666
+ *
667
+ * Returns true if the parent payload status (determined by block.parentBlockHash) is FULL
668
+ */
669
+ isParentNodeFull(block: ProtoBlock): boolean {
670
+ return this.getParentPayloadStatus(block) === PayloadStatus.FULL;
671
+ }
672
+
673
+ /**
674
+ * Determine if we should extend the payload (prefer FULL over EMPTY)
675
+ * Spec: gloas/fork-choice.md#new-should_extend_payload
676
+ *
677
+ * Returns true if:
678
+ * 1. Payload is timely, OR
679
+ * 2. No proposer boost root (empty/zero hash), OR
680
+ * 3. Proposer boost root's parent is not this block, OR
681
+ * 4. Proposer boost root extends FULL parent
682
+ *
683
+ * @param blockRoot - The block root to check
684
+ * @param proposerBoostRoot - Current proposer boost root (from ForkChoice)
685
+ */
686
+ shouldExtendPayload(blockRoot: RootHex, proposerBoostRoot: RootHex | null): boolean {
687
+ // Condition 1: Payload is timely
688
+ if (this.isPayloadTimely(blockRoot)) {
689
+ return true;
690
+ }
691
+
692
+ // Condition 2: No proposer boost root
693
+ if (proposerBoostRoot === null || proposerBoostRoot === HEX_ZERO_HASH) {
694
+ return true;
695
+ }
696
+
697
+ // Get proposer boost block
698
+ // We don't care about variant here, just need proposer boost block info
699
+ const proposerBoostIndex = this.getDefaultNodeIndex(proposerBoostRoot);
700
+ const proposerBoostBlock = proposerBoostIndex !== undefined ? this.getNodeByIndex(proposerBoostIndex) : undefined;
701
+ if (!proposerBoostBlock) {
702
+ // Proposer boost block not found, default to extending payload
703
+ return true;
704
+ }
705
+
706
+ // Condition 3: Proposer boost root's parent is not this block
707
+ if (proposerBoostBlock.parentRoot !== blockRoot) {
708
+ return true;
709
+ }
710
+
711
+ // Condition 4: Proposer boost root extends FULL parent
712
+ if (this.isParentNodeFull(proposerBoostBlock)) {
713
+ return true;
714
+ }
715
+
716
+ return false;
717
+ }
718
+
234
719
  /**
235
720
  * Optimistic sync validate till validated latest hash, invalidate any descendant branch
236
721
  * if invalidate till hash provided. If consensus fails, this will invalidate entire
237
722
  * forkChoice which will throw on any call to findHead
238
723
  */
724
+ // TODO GLOAS: Review usage of this post-gloas
239
725
  validateLatestHash(execResponse: LVHExecResponse, currentSlot: Slot): void {
240
726
  // Look reverse because its highly likely node with latestValidExecHash is towards the
241
727
  // the leaves of the forkchoice
@@ -279,7 +765,8 @@ export class ProtoArray {
279
765
  // if its in fcU.
280
766
  //
281
767
  const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse;
282
- const invalidateFromParentIndex = this.indices.get(invalidateFromParentBlockRoot);
768
+ // TODO GLOAS: verify if getting the default/canonical node index is correct here
769
+ const invalidateFromParentIndex = this.getDefaultNodeIndex(invalidateFromParentBlockRoot);
283
770
  if (invalidateFromParentIndex === undefined) {
284
771
  throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`);
285
772
  }
@@ -317,6 +804,12 @@ export class ProtoArray {
317
804
  if (node.executionStatus === ExecutionStatus.PreMerge || node.executionStatus === ExecutionStatus.Valid) {
318
805
  break;
319
806
  }
807
+ // If PayloadSeparated, that means the node is either PENDING or EMPTY, there could be
808
+ // some ancestor still has syncing status.
809
+ if (node.executionStatus === ExecutionStatus.PayloadSeparated) {
810
+ nodeIndex = node.parent;
811
+ continue;
812
+ }
320
813
  this.validateNodeByIndex(nodeIndex);
321
814
  nodeIndex = node.parent;
322
815
  }
@@ -438,10 +931,46 @@ export class ProtoArray {
438
931
  return validNode;
439
932
  }
440
933
 
934
+ /**
935
+ * Get payload status tiebreaker for fork choice comparison
936
+ * Spec: gloas/fork-choice.md#new-get_payload_status_tiebreaker
937
+ *
938
+ * For PENDING nodes: always returns 0
939
+ * For EMPTY/FULL variants from slot n-1: implements tiebreaker logic based on should_extend_payload
940
+ * For older blocks: returns node.payloadStatus
941
+ *
942
+ * 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.
943
+ */
944
+ private getPayloadStatusTiebreaker(node: ProtoNode, currentSlot: Slot, proposerBoostRoot: RootHex | null): number {
945
+ // PENDING nodes always return PENDING (no tiebreaker needed)
946
+ // PENDING=0, EMPTY=1, FULL=2
947
+ if (node.payloadStatus === PayloadStatus.PENDING) {
948
+ return node.payloadStatus;
949
+ }
950
+
951
+ // For Gloas: check if from previous slot
952
+ if (node.slot + 1 !== currentSlot) {
953
+ return node.payloadStatus;
954
+ }
955
+
956
+ // For previous slot blocks in Gloas, decide between FULL and EMPTY
957
+ // based on should_extend_payload
958
+ if (node.payloadStatus === PayloadStatus.EMPTY) {
959
+ return PayloadStatus.EMPTY;
960
+ }
961
+ // FULL - check should_extend_payload
962
+ const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot);
963
+ return shouldExtend ? PayloadStatus.FULL : PayloadStatus.PENDING;
964
+ }
965
+
441
966
  /**
442
967
  * Follows the best-descendant links to find the best-block (i.e., head-block).
968
+ *
969
+ * Returns the ProtoNode representing the head.
970
+ * For pre-Gloas forks, only FULL variants exist (payload embedded).
971
+ * For Gloas, may return PENDING/EMPTY/FULL variants.
443
972
  */
444
- findHead(justifiedRoot: RootHex, currentSlot: Slot): RootHex {
973
+ findHead(justifiedRoot: RootHex, currentSlot: Slot): ProtoNode {
445
974
  if (this.lvhError) {
446
975
  throw new ProtoArrayError({
447
976
  code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE,
@@ -449,7 +978,8 @@ export class ProtoArray {
449
978
  });
450
979
  }
451
980
 
452
- const justifiedIndex = this.indices.get(justifiedRoot);
981
+ // Get canonical node: FULL for pre-Gloas, PENDING for Gloas
982
+ const justifiedIndex = this.getDefaultNodeIndex(justifiedRoot);
453
983
  if (justifiedIndex === undefined) {
454
984
  throw new ProtoArrayError({
455
985
  code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN,
@@ -501,7 +1031,7 @@ export class ProtoArray {
501
1031
  });
502
1032
  }
503
1033
 
504
- return bestNode.blockRoot;
1034
+ return bestNode;
505
1035
  }
506
1036
 
507
1037
  /**
@@ -520,26 +1050,40 @@ export class ProtoArray {
520
1050
  * - There is some internal error relating to invalid indices inside `this`.
521
1051
  */
522
1052
  maybePrune(finalizedRoot: RootHex): ProtoBlock[] {
523
- const finalizedIndex = this.indices.get(finalizedRoot);
524
- if (finalizedIndex === undefined) {
1053
+ const variants = this.indices.get(finalizedRoot);
1054
+ if (variants == null) {
525
1055
  throw new ProtoArrayError({
526
1056
  code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN,
527
1057
  root: finalizedRoot,
528
1058
  });
529
1059
  }
530
1060
 
1061
+ // Find the minimum index among all variants to ensure we don't prune too much
1062
+ const finalizedIndex = Array.isArray(variants)
1063
+ ? Math.min(...variants.filter((idx) => idx !== undefined))
1064
+ : variants;
1065
+
531
1066
  if (finalizedIndex < this.pruneThreshold) {
532
1067
  // Pruning at small numbers incurs more cost than benefit
533
1068
  return [];
534
1069
  }
535
1070
 
536
- // Remove the this.indices key/values for all the to-be-deleted nodes
537
- for (let nodeIndex = 0; nodeIndex < finalizedIndex; nodeIndex++) {
538
- const node = this.nodes[nodeIndex];
1071
+ // Collect all block roots that will be pruned
1072
+ const prunedRoots = new Set<RootHex>();
1073
+ for (let i = 0; i < finalizedIndex; i++) {
1074
+ const node = this.nodes[i];
539
1075
  if (node === undefined) {
540
- throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: nodeIndex});
1076
+ throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: i});
541
1077
  }
542
- this.indices.delete(node.blockRoot);
1078
+ prunedRoots.add(node.blockRoot);
1079
+ }
1080
+
1081
+ // Remove indices for pruned blocks and PTC votes
1082
+ for (const root of prunedRoots) {
1083
+ this.indices.delete(root);
1084
+ // Prune PTC votes for this block to prevent memory leak
1085
+ // Spec: gloas/fork-choice.md (implicit - finalized blocks don't need PTC votes)
1086
+ this.ptcVotes.delete(root);
543
1087
  }
544
1088
 
545
1089
  // Store nodes prior to finalization
@@ -547,15 +1091,35 @@ export class ProtoArray {
547
1091
  // Drop all the nodes prior to finalization
548
1092
  this.nodes = this.nodes.slice(finalizedIndex);
549
1093
 
550
- // Adjust the indices map
551
- for (const [key, value] of this.indices.entries()) {
552
- if (value < finalizedIndex) {
553
- throw new ProtoArrayError({
554
- code: ProtoArrayErrorCode.INDEX_OVERFLOW,
555
- value: "indices",
556
- });
1094
+ // Adjust the indices map - subtract finalizedIndex from all node indices
1095
+ for (const [root, variantIndices] of this.indices.entries()) {
1096
+ // Pre-Gloas: single index
1097
+ if (!Array.isArray(variantIndices)) {
1098
+ if (variantIndices < finalizedIndex) {
1099
+ throw new ProtoArrayError({
1100
+ code: ProtoArrayErrorCode.INDEX_OVERFLOW,
1101
+ value: "indices",
1102
+ });
1103
+ }
1104
+ this.indices.set(root, variantIndices - finalizedIndex);
1105
+ continue;
557
1106
  }
558
- this.indices.set(key, value - finalizedIndex);
1107
+
1108
+ // Post-Gloas: array of variant indices
1109
+ const adjustedVariants = variantIndices.map((variantIndex) => {
1110
+ if (variantIndex === undefined) {
1111
+ return undefined;
1112
+ }
1113
+
1114
+ if (variantIndex < finalizedIndex) {
1115
+ throw new ProtoArrayError({
1116
+ code: ProtoArrayErrorCode.INDEX_OVERFLOW,
1117
+ value: "indices",
1118
+ });
1119
+ }
1120
+ return variantIndex - finalizedIndex;
1121
+ });
1122
+ this.indices.set(root, adjustedVariants as GloasVariantIndices);
559
1123
  }
560
1124
 
561
1125
  // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes
@@ -604,7 +1168,13 @@ export class ProtoArray {
604
1168
  * - The child is not the best child but becomes the best child.
605
1169
  * - The child is not the best child and does not become the best child.
606
1170
  */
607
- maybeUpdateBestChildAndDescendant(parentIndex: number, childIndex: number, currentSlot: Slot): void {
1171
+
1172
+ maybeUpdateBestChildAndDescendant(
1173
+ parentIndex: number,
1174
+ childIndex: number,
1175
+ currentSlot: Slot,
1176
+ proposerBoostRoot: RootHex | null
1177
+ ): void {
608
1178
  const childNode = this.nodes[childIndex];
609
1179
  if (childNode === undefined) {
610
1180
  throw new ProtoArrayError({
@@ -634,54 +1204,90 @@ export class ProtoArray {
634
1204
 
635
1205
  let newChildAndDescendant: ChildAndDescendant;
636
1206
  const bestChildIndex = parentNode.bestChild;
637
- if (bestChildIndex !== undefined) {
638
- if (bestChildIndex === childIndex && !childLeadsToViableHead) {
639
- // the child is already the best-child of the parent but its not viable for the head
640
- // so remove it
641
- newChildAndDescendant = changeToNull;
642
- } else if (bestChildIndex === childIndex) {
643
- // the child is the best-child already
644
- // set it again to ensure that the best-descendent of the parent is updated
645
- newChildAndDescendant = changeToChild;
646
- } else {
647
- const bestChildNode = this.nodes[bestChildIndex];
648
- if (bestChildNode === undefined) {
649
- throw new ProtoArrayError({
650
- code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX,
651
- index: bestChildIndex,
652
- });
653
- }
1207
+ // biome-ignore lint/suspicious/noConfusingLabels: labeled block used for early exit from complex decision tree
1208
+ outer: {
1209
+ if (bestChildIndex !== undefined) {
1210
+ if (bestChildIndex === childIndex && !childLeadsToViableHead) {
1211
+ // the child is already the best-child of the parent but its not viable for the head
1212
+ // so remove it
1213
+ newChildAndDescendant = changeToNull;
1214
+ } else if (bestChildIndex === childIndex) {
1215
+ // the child is the best-child already
1216
+ // set it again to ensure that the best-descendent of the parent is updated
1217
+ newChildAndDescendant = changeToChild;
1218
+ } else {
1219
+ const bestChildNode = this.nodes[bestChildIndex];
1220
+ if (bestChildNode === undefined) {
1221
+ throw new ProtoArrayError({
1222
+ code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX,
1223
+ index: bestChildIndex,
1224
+ });
1225
+ }
654
1226
 
655
- const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot);
1227
+ const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot);
656
1228
 
657
- if (childLeadsToViableHead && !bestChildLeadsToViableHead) {
658
- // the child leads to a viable head, but the current best-child doesn't
659
- newChildAndDescendant = changeToChild;
660
- } else if (!childLeadsToViableHead && bestChildLeadsToViableHead) {
661
- // the best child leads to a viable head but the child doesn't
662
- newChildAndDescendant = noChange;
663
- } else if (childNode.weight === bestChildNode.weight) {
664
- // tie-breaker of equal weights by root
665
- if (childNode.blockRoot >= bestChildNode.blockRoot) {
1229
+ if (childLeadsToViableHead && !bestChildLeadsToViableHead) {
1230
+ // the child leads to a viable head, but the current best-child doesn't
666
1231
  newChildAndDescendant = changeToChild;
667
- } else {
1232
+ break outer;
1233
+ }
1234
+ if (!childLeadsToViableHead && bestChildLeadsToViableHead) {
1235
+ // the best child leads to a viable head but the child doesn't
668
1236
  newChildAndDescendant = noChange;
1237
+ break outer;
669
1238
  }
670
- } else {
671
- // choose the winner by weight
672
- if (childNode.weight >= bestChildNode.weight) {
1239
+ // Both nodes lead to viable heads (or both don't), need to pick winner
1240
+
1241
+ // Pre-fulu we pick whichever has higher weight, tie-breaker by root
1242
+ // Post-fulu we pick whichever has higher weight, then tie-breaker by root, then tie-breaker by `getPayloadStatusTiebreaker`
1243
+ // Gloas: nodes from previous slot (n-1) with EMPTY/FULL variant have weight hardcoded to 0.
1244
+ // https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442
1245
+ const childEffectiveWeight =
1246
+ !isGloasBlock(childNode) ||
1247
+ childNode.payloadStatus === PayloadStatus.PENDING ||
1248
+ childNode.slot + 1 !== currentSlot
1249
+ ? childNode.weight
1250
+ : 0;
1251
+ const bestChildEffectiveWeight =
1252
+ !isGloasBlock(bestChildNode) ||
1253
+ bestChildNode.payloadStatus === PayloadStatus.PENDING ||
1254
+ bestChildNode.slot + 1 !== currentSlot
1255
+ ? bestChildNode.weight
1256
+ : 0;
1257
+
1258
+ if (childEffectiveWeight !== bestChildEffectiveWeight) {
1259
+ // Different effective weights, choose the winner by weight
1260
+ newChildAndDescendant = childEffectiveWeight >= bestChildEffectiveWeight ? changeToChild : noChange;
1261
+ break outer;
1262
+ }
1263
+
1264
+ if (childNode.blockRoot !== bestChildNode.blockRoot) {
1265
+ // Different blocks, tie-breaker by root
1266
+ newChildAndDescendant = childNode.blockRoot >= bestChildNode.blockRoot ? changeToChild : noChange;
1267
+ break outer;
1268
+ }
1269
+
1270
+ // Same effective weight and same root — Gloas EMPTY vs FULL from n-1, tie-breaker by payload status
1271
+ // Note: pre-Gloas, each child node of a block has a unique root, so this point should not be reached
1272
+ const childTiebreaker = this.getPayloadStatusTiebreaker(childNode, currentSlot, proposerBoostRoot);
1273
+ const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, proposerBoostRoot);
1274
+
1275
+ if (childTiebreaker > bestChildTiebreaker) {
673
1276
  newChildAndDescendant = changeToChild;
1277
+ } else if (childTiebreaker < bestChildTiebreaker) {
1278
+ newChildAndDescendant = noChange;
674
1279
  } else {
1280
+ // Equal in all aspects, noChange
675
1281
  newChildAndDescendant = noChange;
676
1282
  }
677
1283
  }
1284
+ } else if (childLeadsToViableHead) {
1285
+ // There is no current best-child and the child is viable.
1286
+ newChildAndDescendant = changeToChild;
1287
+ } else {
1288
+ // There is no current best-child but the child is not viable.
1289
+ newChildAndDescendant = noChange;
678
1290
  }
679
- } else if (childLeadsToViableHead) {
680
- // There is no current best-child and the child is viable.
681
- newChildAndDescendant = changeToChild;
682
- } else {
683
- // There is no current best-child but the child is not viable.
684
- newChildAndDescendant = noChange;
685
1291
  }
686
1292
 
687
1293
  parentNode.bestChild = newChildAndDescendant[0];
@@ -761,13 +1367,14 @@ export class ProtoArray {
761
1367
  }
762
1368
 
763
1369
  const finalizedSlot = computeStartSlotAtEpoch(this.finalizedEpoch);
764
- return this.finalizedEpoch === 0 || this.finalizedRoot === this.getAncestorOrNull(node.blockRoot, finalizedSlot);
1370
+ const ancestorNode = this.getAncestorOrNull(node.blockRoot, finalizedSlot);
1371
+ return this.finalizedEpoch === 0 || (ancestorNode !== null && this.finalizedRoot === ancestorNode.blockRoot);
765
1372
  }
766
1373
 
767
1374
  /**
768
1375
  * Same to getAncestor but it may return null instead of throwing error
769
1376
  */
770
- getAncestorOrNull(blockRoot: RootHex, ancestorSlot: Slot): RootHex | null {
1377
+ getAncestorOrNull(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode | null {
771
1378
  try {
772
1379
  return this.getAncestor(blockRoot, ancestorSlot);
773
1380
  } catch (_) {
@@ -776,49 +1383,116 @@ export class ProtoArray {
776
1383
  }
777
1384
 
778
1385
  /**
779
- * Returns the block root of an ancestor of `blockRoot` at the given `slot`.
1386
+ * Returns the node identifier of an ancestor of `blockRoot` at the given `slot`.
780
1387
  * (Note: `slot` refers to the block that is *returned*, not the one that is supplied.)
781
1388
  *
782
1389
  * NOTE: May be expensive: potentially walks through the entire fork of head to finalized block
783
1390
  *
784
1391
  * ### Specification
785
1392
  *
786
- * Equivalent to:
1393
+ * Modified for Gloas to return node identifier instead of just root:
1394
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#modified-get_ancestor
787
1395
  *
788
- * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_ancestor
1396
+ * Pre-Gloas: Returns (root, PAYLOAD_STATUS_FULL)
1397
+ * Gloas: Returns (root, payloadStatus) based on actual node state
789
1398
  */
790
- getAncestor(blockRoot: RootHex, ancestorSlot: Slot): RootHex {
791
- const block = this.getBlock(blockRoot);
792
- if (!block) {
1399
+ getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode {
1400
+ // Get any variant to check the block (use variants[0])
1401
+ const variantOrArr = this.indices.get(blockRoot);
1402
+ if (variantOrArr == null) {
793
1403
  throw new ForkChoiceError({
794
1404
  code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK,
795
1405
  root: blockRoot,
796
1406
  });
797
1407
  }
798
1408
 
799
- if (block.slot > ancestorSlot) {
800
- // Search for a slot that is lte the target slot.
801
- // We check for lower slots to account for skip slots.
802
- for (const node of this.iterateAncestorNodes(blockRoot)) {
803
- if (node.slot <= ancestorSlot) {
804
- return node.blockRoot;
805
- }
1409
+ const blockIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr;
1410
+ const block = this.nodes[blockIndex];
1411
+
1412
+ // If block is at or before queried slot, return PENDING variant (or FULL for pre-Gloas)
1413
+ if (block.slot <= ancestorSlot) {
1414
+ // For pre-Gloas: only FULL exists at variants[0]
1415
+ // For Gloas: PENDING is at variants[0]
1416
+ return block;
1417
+ }
1418
+
1419
+ // Walk backwards through beacon blocks to find ancestor
1420
+ // Start with the parent of the current block
1421
+ let currentBlock = block;
1422
+ const parentVariants = this.indices.get(currentBlock.parentRoot);
1423
+ if (parentVariants == null) {
1424
+ throw new ForkChoiceError({
1425
+ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
1426
+ descendantRoot: blockRoot,
1427
+ ancestorSlot,
1428
+ });
1429
+ }
1430
+
1431
+ let parentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants;
1432
+ let parentBlock = this.nodes[parentIndex];
1433
+
1434
+ // Walk backwards while parent.slot > ancestorSlot
1435
+ while (parentBlock.slot > ancestorSlot) {
1436
+ currentBlock = parentBlock;
1437
+
1438
+ const nextParentVariants = this.indices.get(currentBlock.parentRoot);
1439
+ if (nextParentVariants == null) {
1440
+ throw new ForkChoiceError({
1441
+ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
1442
+ descendantRoot: blockRoot,
1443
+ ancestorSlot,
1444
+ });
806
1445
  }
1446
+
1447
+ parentIndex = Array.isArray(nextParentVariants) ? nextParentVariants[0] : nextParentVariants;
1448
+ parentBlock = this.nodes[parentIndex];
1449
+ }
1450
+
1451
+ // Now parentBlock.slot <= ancestorSlot
1452
+ // Return the parent with the correct payload status based on currentBlock
1453
+ if (!isGloasBlock(currentBlock)) {
1454
+ // Pre-Gloas: return FULL variant (only one that exists)
1455
+ return parentBlock;
1456
+ }
1457
+
1458
+ // Gloas: determine which parent variant (EMPTY or FULL) based on parent_block_hash
1459
+ const parentPayloadStatus = this.getParentPayloadStatus(currentBlock);
1460
+ const parentVariantIndex = this.getNodeIndexByRootAndStatus(currentBlock.parentRoot, parentPayloadStatus);
1461
+
1462
+ if (parentVariantIndex === undefined) {
807
1463
  throw new ForkChoiceError({
808
1464
  code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
809
1465
  descendantRoot: blockRoot,
810
1466
  ancestorSlot,
811
1467
  });
812
1468
  }
813
- // Root is older or equal than queried slot, thus a skip slot. Return most recent root prior to slot.
814
- return blockRoot;
1469
+
1470
+ return this.nodes[parentVariantIndex];
1471
+ }
1472
+
1473
+ /**
1474
+ * Get the parent node index for traversal
1475
+ * For Gloas blocks: returns the correct EMPTY/FULL variant based on parent payload status
1476
+ * For pre-Gloas blocks: returns the simple parent index
1477
+ * Returns undefined if parent doesn't exist or can't be found
1478
+ */
1479
+ private getParentNodeIndex(node: ProtoNode): number | undefined {
1480
+ if (isGloasBlock(node)) {
1481
+ // Use getParentPayloadStatus for Gloas blocks to get correct EMPTY/FULL variant
1482
+ const parentPayloadStatus = this.getParentPayloadStatus(node);
1483
+ return this.getNodeIndexByRootAndStatus(node.parentRoot, parentPayloadStatus);
1484
+ }
1485
+ // Simple parent traversal for pre-Gloas blocks (includes fork transition)
1486
+ return node.parent;
815
1487
  }
816
1488
 
817
1489
  /**
818
1490
  * Iterate from a block root backwards over nodes
1491
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1492
+ * For pre-Gloas blocks: returns FULL variants
819
1493
  */
820
- *iterateAncestorNodes(blockRoot: RootHex): IterableIterator<ProtoNode> {
821
- const startIndex = this.indices.get(blockRoot);
1494
+ *iterateAncestorNodes(blockRoot: RootHex, payloadStatus: PayloadStatus): IterableIterator<ProtoNode> {
1495
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
822
1496
  if (startIndex === undefined) {
823
1497
  return;
824
1498
  }
@@ -835,20 +1509,30 @@ export class ProtoArray {
835
1509
  }
836
1510
 
837
1511
  /**
838
- * Iterate from a block root backwards over nodes
1512
+ * Iterate from a node backwards over ancestor nodes
1513
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1514
+ * For pre-Gloas blocks: returns FULL variants
1515
+ * Handles fork transition from Gloas to pre-Gloas blocks
839
1516
  */
840
1517
  *iterateAncestorNodesFromNode(node: ProtoNode): IterableIterator<ProtoNode> {
841
1518
  while (node.parent !== undefined) {
842
- node = this.getNodeFromIndex(node.parent);
1519
+ const parentIndex = this.getParentNodeIndex(node);
1520
+ if (parentIndex === undefined) {
1521
+ break;
1522
+ }
1523
+
1524
+ node = this.nodes[parentIndex];
843
1525
  yield node;
844
1526
  }
845
1527
  }
846
1528
 
847
1529
  /**
848
1530
  * Get all nodes from a block root backwards
1531
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1532
+ * For pre-Gloas blocks: returns FULL variants
849
1533
  */
850
- getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] {
851
- const startIndex = this.indices.get(blockRoot);
1534
+ getAllAncestorNodes(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode[] {
1535
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
852
1536
  if (startIndex === undefined) {
853
1537
  return [];
854
1538
  }
@@ -861,10 +1545,20 @@ export class ProtoArray {
861
1545
  });
862
1546
  }
863
1547
 
864
- const nodes = [node];
1548
+ // Exclude PENDING variant from returned ancestors.
1549
+ const nodes: ProtoNode[] = [];
1550
+
1551
+ if (node.payloadStatus !== PayloadStatus.PENDING) {
1552
+ nodes.push(node);
1553
+ }
865
1554
 
866
1555
  while (node.parent !== undefined) {
867
- node = this.getNodeFromIndex(node.parent);
1556
+ const parentIndex = this.getParentNodeIndex(node);
1557
+ if (parentIndex === undefined) {
1558
+ break;
1559
+ }
1560
+
1561
+ node = this.nodes[parentIndex];
868
1562
  nodes.push(node);
869
1563
  }
870
1564
 
@@ -875,9 +1569,12 @@ export class ProtoArray {
875
1569
  * The opposite of iterateNodes.
876
1570
  * iterateNodes is to find ancestor nodes of a blockRoot.
877
1571
  * this is to find non-ancestor nodes of a blockRoot.
1572
+ *
1573
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1574
+ * For pre-Gloas blocks: returns FULL variants
878
1575
  */
879
- getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] {
880
- const startIndex = this.indices.get(blockRoot);
1576
+ getAllNonAncestorNodes(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode[] {
1577
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
881
1578
  if (startIndex === undefined) {
882
1579
  return [];
883
1580
  }
@@ -889,24 +1586,39 @@ export class ProtoArray {
889
1586
  index: startIndex,
890
1587
  });
891
1588
  }
1589
+
1590
+ // For both Gloas and pre-Gloas blocks
892
1591
  const result: ProtoNode[] = [];
893
1592
  let nodeIndex = startIndex;
894
1593
  while (node.parent !== undefined) {
895
- const parentIndex = node.parent;
896
- node = this.getNodeFromIndex(parentIndex);
897
- // nodes between nodeIndex and parentIndex means non-ancestor nodes
898
- result.push(...this.getNodesBetween(nodeIndex, parentIndex));
1594
+ const parentIndex = this.getParentNodeIndex(node);
1595
+ if (parentIndex === undefined) {
1596
+ break;
1597
+ }
1598
+
1599
+ node = this.nodes[parentIndex];
1600
+ // Collect non-ancestor nodes between current and parent
1601
+ // Filter to exclude PENDING nodes (FULL variant pre-gloas, EMPTY or FULL variant post-gloas)
1602
+ result.push(
1603
+ ...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)
1604
+ );
899
1605
  nodeIndex = parentIndex;
900
1606
  }
901
- result.push(...this.getNodesBetween(nodeIndex, 0));
1607
+ // Collect remaining nodes from nodeIndex to beginning
1608
+ result.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
902
1609
  return result;
903
1610
  }
904
1611
 
905
1612
  /**
906
1613
  * Returns both ancestor and non-ancestor nodes in a single traversal.
1614
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1615
+ * For pre-Gloas blocks: returns FULL variants
907
1616
  */
908
- getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} {
909
- const startIndex = this.indices.get(blockRoot);
1617
+ getAllAncestorAndNonAncestorNodes(
1618
+ blockRoot: RootHex,
1619
+ payloadStatus: PayloadStatus
1620
+ ): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} {
1621
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
910
1622
  if (startIndex === undefined) {
911
1623
  return {ancestors: [], nonAncestors: []};
912
1624
  }
@@ -922,39 +1634,73 @@ export class ProtoArray {
922
1634
  const ancestors: ProtoNode[] = [];
923
1635
  const nonAncestors: ProtoNode[] = [];
924
1636
 
1637
+ // Include starting node if it's not PENDING (i.e., pre-Gloas or EMPTY/FULL variant post-Gloas)
1638
+ if (node.payloadStatus !== PayloadStatus.PENDING) {
1639
+ ancestors.push(node);
1640
+ }
1641
+
925
1642
  let nodeIndex = startIndex;
926
1643
  while (node.parent !== undefined) {
927
- ancestors.push(node);
1644
+ const parentIndex = this.getParentNodeIndex(node);
1645
+ if (parentIndex === undefined) {
1646
+ break;
1647
+ }
928
1648
 
929
- const parentIndex = node.parent;
930
- node = this.getNodeFromIndex(parentIndex);
1649
+ node = this.nodes[parentIndex];
1650
+ ancestors.push(node);
931
1651
 
932
- // Nodes between nodeIndex and parentIndex are non-ancestor nodes
933
- nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex));
1652
+ // Collect non-ancestor nodes between current and parent
1653
+ // Filter to exclude PENDING nodes (include all FULL/EMPTY for both pre-Gloas and Gloas)
1654
+ nonAncestors.push(
1655
+ ...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)
1656
+ );
934
1657
  nodeIndex = parentIndex;
935
1658
  }
936
1659
 
937
- ancestors.push(node);
938
- nonAncestors.push(...this.getNodesBetween(nodeIndex, 0));
1660
+ // Collect remaining non-ancestor nodes from nodeIndex to beginning
1661
+ nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
939
1662
 
940
1663
  return {ancestors, nonAncestors};
941
1664
  }
942
1665
 
1666
+ /**
1667
+ * Check if a block exists in the proto array
1668
+ * Uses default variant (PENDING for Gloas, FULL for pre-Gloas)
1669
+ */
943
1670
  hasBlock(blockRoot: RootHex): boolean {
944
- return this.indices.has(blockRoot);
1671
+ return this.getDefaultNodeIndex(blockRoot) !== undefined;
945
1672
  }
946
1673
 
947
- getNode(blockRoot: RootHex): ProtoNode | undefined {
948
- const blockIndex = this.indices.get(blockRoot);
1674
+ /**
1675
+ * Return ProtoNode for blockRoot with explicit payload status
1676
+ *
1677
+ * @param blockRoot - The block root to look up
1678
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1679
+ * @returns The ProtoNode for the specified variant, or undefined if not found
1680
+ *
1681
+ * Note: Callers must explicitly specify which variant they need.
1682
+ * Use getDefaultVariant() to get the canonical variant for a block.
1683
+ */
1684
+ getNode(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode | undefined {
1685
+ const blockIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
949
1686
  if (blockIndex === undefined) {
950
1687
  return undefined;
951
1688
  }
952
1689
  return this.getNodeByIndex(blockIndex);
953
1690
  }
954
1691
 
955
- /** Return MUTABLE ProtoBlock for blockRoot (spreads properties) */
956
- getBlock(blockRoot: RootHex): ProtoBlock | undefined {
957
- const node = this.getNode(blockRoot);
1692
+ /**
1693
+ * Return MUTABLE ProtoBlock for blockRoot with explicit payload status
1694
+ *
1695
+ * @param blockRoot - The block root to look up
1696
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1697
+ * @returns The ProtoBlock for the specified variant (spreads properties), or undefined if not found
1698
+ *
1699
+ * Note: Callers must explicitly specify which variant they need.
1700
+ * Use getDefaultVariant() to get the canonical variant for a block.
1701
+ */
1702
+ getBlock(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock | undefined {
1703
+ const node = this.getNode(blockRoot, payloadStatus);
958
1704
  if (!node) {
959
1705
  return undefined;
960
1706
  }
@@ -963,9 +1709,19 @@ export class ProtoArray {
963
1709
  };
964
1710
  }
965
1711
 
966
- /** Return NON-MUTABLE ProtoBlock for blockRoot (does not spread properties) */
967
- getBlockReadonly(blockRoot: RootHex): ProtoBlock {
968
- const node = this.getNode(blockRoot);
1712
+ /**
1713
+ * Return NON-MUTABLE ProtoBlock for blockRoot with explicit payload status
1714
+ *
1715
+ * @param blockRoot - The block root to look up
1716
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1717
+ * @returns The ProtoBlock for the specified variant (does not spread properties)
1718
+ * @throws Error if block not found
1719
+ *
1720
+ * Note: Callers must explicitly specify which variant they need.
1721
+ * Use getDefaultVariant() to get the canonical variant for a block.
1722
+ */
1723
+ getBlockReadonly(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock {
1724
+ const node = this.getNode(blockRoot, payloadStatus);
969
1725
  if (!node) {
970
1726
  throw Error(`No block for root ${blockRoot}`);
971
1727
  }
@@ -975,23 +1731,28 @@ export class ProtoArray {
975
1731
  /**
976
1732
  * Returns `true` if the `descendantRoot` has an ancestor with `ancestorRoot`.
977
1733
  * Always returns `false` if either input roots are unknown.
978
- * Still returns `true` if `ancestorRoot` === `descendantRoot` (and the roots are known)
1734
+ * Still returns `true` if `ancestorRoot` === `descendantRoot` and payload statuses match.
979
1735
  */
980
- isDescendant(ancestorRoot: RootHex, descendantRoot: RootHex): boolean {
981
- const ancestorNode = this.getNode(ancestorRoot);
1736
+ isDescendant(
1737
+ ancestorRoot: RootHex,
1738
+ ancestorPayloadStatus: PayloadStatus,
1739
+ descendantRoot: RootHex,
1740
+ descendantPayloadStatus: PayloadStatus
1741
+ ): boolean {
1742
+ const ancestorNode = this.getNode(ancestorRoot, ancestorPayloadStatus);
982
1743
  if (!ancestorNode) {
983
1744
  return false;
984
1745
  }
985
1746
 
986
- if (ancestorRoot === descendantRoot) {
1747
+ if (ancestorRoot === descendantRoot && ancestorPayloadStatus === descendantPayloadStatus) {
987
1748
  return true;
988
1749
  }
989
1750
 
990
- for (const node of this.iterateAncestorNodes(descendantRoot)) {
1751
+ for (const node of this.iterateAncestorNodes(descendantRoot, descendantPayloadStatus)) {
991
1752
  if (node.slot < ancestorNode.slot) {
992
1753
  return false;
993
1754
  }
994
- if (node.blockRoot === ancestorNode.blockRoot) {
1755
+ if (node.blockRoot === ancestorNode.blockRoot && node.payloadStatus === ancestorNode.payloadStatus) {
995
1756
  return true;
996
1757
  }
997
1758
  }