@lodestar/fork-choice 1.41.0-dev.167a02a7b9 → 1.41.0-dev.1ddf8e46a9

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 +757 -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 +890 -135
@@ -1,9 +1,15 @@
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
- import { toRootHex } from "@lodestar/utils";
4
+ import { bitCount, toRootHex } from "@lodestar/utils";
4
5
  import { ForkChoiceError, ForkChoiceErrorCode } from "../forkChoice/errors.js";
5
6
  import { LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode } from "./errors.js";
6
- import { ExecutionStatus, HEX_ZERO_HASH } from "./interface.js";
7
+ import { ExecutionStatus, HEX_ZERO_HASH, PayloadStatus, isGloasBlock, } from "./interface.js";
8
+ /**
9
+ * Threshold for payload timeliness (>50% of PTC must vote)
10
+ * Spec: gloas/fork-choice.md (PAYLOAD_TIMELY_THRESHOLD = PTC_SIZE // 2)
11
+ */
12
+ const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2);
7
13
  export const DEFAULT_PRUNE_THRESHOLD = 0;
8
14
  const ZERO_HASH_HEX = toRootHex(Buffer.alloc(32, 0));
9
15
  export class ProtoArray {
@@ -15,9 +21,28 @@ export class ProtoArray {
15
21
  finalizedEpoch;
16
22
  finalizedRoot;
17
23
  nodes = [];
24
+ /**
25
+ * Maps block root to array of node indices for each payload status variant
26
+ *
27
+ * Array structure: [PENDING, EMPTY, FULL] where indices correspond to PayloadStatus enum values
28
+ * - number[0] = PENDING variant index (PayloadStatus.PENDING = 0)
29
+ * - number[1] = EMPTY variant index (PayloadStatus.EMPTY = 1)
30
+ * - number[2] = FULL variant index (PayloadStatus.FULL = 2)
31
+ *
32
+ * Note: undefined array elements indicate that variant doesn't exist for this block
33
+ */
18
34
  indices = new Map();
19
35
  lvhError;
20
36
  previousProposerBoost = null;
37
+ /**
38
+ * PTC (Payload Timeliness Committee) votes per block as bitvectors
39
+ * Maps block root to BitArray of PTC_SIZE bits (512 mainnet, 2 minimal)
40
+ * Spec: gloas/fork-choice.md#modified-store (line 148)
41
+ *
42
+ * Bit i is set if PTC member i voted payload_present=true
43
+ * Used by is_payload_timely() to determine if payload is timely
44
+ */
45
+ ptcVotes = new Map();
21
46
  constructor({ pruneThreshold, justifiedEpoch, justifiedRoot, finalizedEpoch, finalizedRoot, }) {
22
47
  this.pruneThreshold = pruneThreshold;
23
48
  this.justifiedEpoch = justifiedEpoch;
@@ -37,9 +62,163 @@ export class ProtoArray {
37
62
  ...block,
38
63
  // We are using the blockROot as the targetRoot, since it always lies on an epoch boundary
39
64
  targetRoot: block.blockRoot,
40
- }, currentSlot);
65
+ }, currentSlot, null);
41
66
  return protoArray;
42
67
  }
68
+ /**
69
+ * Get node index for a block root and payload status
70
+ *
71
+ * @param root - The block root to look up
72
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
73
+ * @returns The node index for the specified variant, or undefined if not found
74
+ *
75
+ * Behavior:
76
+ * - Pre-Gloas blocks: only FULL is valid, PENDING/EMPTY throw error
77
+ * - Gloas blocks: returns the specified variant index, or undefined if that variant doesn't exist
78
+ *
79
+ * Note: payloadStatus is required. Use getDefaultVariant() to get the canonical variant.
80
+ */
81
+ getNodeIndexByRootAndStatus(root, payloadStatus) {
82
+ const variantOrArr = this.indices.get(root);
83
+ if (variantOrArr == null) {
84
+ return undefined;
85
+ }
86
+ // Pre-Gloas: only FULL variant exists
87
+ if (!Array.isArray(variantOrArr)) {
88
+ // Return FULL variant if no status specified or FULL explicitly requested
89
+ if (payloadStatus === PayloadStatus.FULL) {
90
+ return variantOrArr;
91
+ }
92
+ // PENDING and EMPTY are invalid for pre-Gloas blocks
93
+ throw new ProtoArrayError({
94
+ code: ProtoArrayErrorCode.INVALID_NODE_INDEX,
95
+ index: payloadStatus,
96
+ });
97
+ }
98
+ // Gloas: return the specified variant, or PENDING if not specified
99
+ return variantOrArr[payloadStatus];
100
+ }
101
+ /**
102
+ * Get the default/canonical payload status for a block root
103
+ * - Pre-Gloas blocks: Returns FULL (payload embedded in block)
104
+ * - Gloas blocks: Returns PENDING (canonical variant)
105
+ *
106
+ * @param blockRoot - The block root to check
107
+ * @returns PayloadStatus.FULL for pre-Gloas, PayloadStatus.PENDING for Gloas, undefined if block not found
108
+ */
109
+ getDefaultVariant(blockRoot) {
110
+ const variantOrArr = this.indices.get(blockRoot);
111
+ if (variantOrArr == null) {
112
+ return undefined;
113
+ }
114
+ // Pre-Gloas: only FULL variant exists
115
+ if (!Array.isArray(variantOrArr)) {
116
+ return PayloadStatus.FULL;
117
+ }
118
+ // Gloas: multiple variants exist, PENDING is canonical
119
+ return PayloadStatus.PENDING;
120
+ }
121
+ /**
122
+ * Get the node index for the default/canonical variant in a single hash lookup.
123
+ * - Pre-Gloas blocks: returns the FULL variant index
124
+ * - Gloas blocks: returns the PENDING variant index
125
+ */
126
+ getDefaultNodeIndex(blockRoot) {
127
+ const variantOrArr = this.indices.get(blockRoot);
128
+ if (variantOrArr == null) {
129
+ return undefined;
130
+ }
131
+ // Pre-Gloas: value is the index directly
132
+ if (!Array.isArray(variantOrArr)) {
133
+ return variantOrArr;
134
+ }
135
+ // Gloas: PENDING is the canonical variant
136
+ return variantOrArr[PayloadStatus.PENDING];
137
+ }
138
+ /**
139
+ * Determine which parent payload status a block extends
140
+ * Spec: gloas/fork-choice.md#new-get_parent_payload_status
141
+ * def get_parent_payload_status(store: Store, block: BeaconBlock) -> PayloadStatus:
142
+ * parent = store.blocks[block.parent_root]
143
+ * parent_block_hash = block.body.signed_execution_payload_bid.message.parent_block_hash
144
+ * message_block_hash = parent.body.signed_execution_payload_bid.message.block_hash
145
+ * return PAYLOAD_STATUS_FULL if parent_block_hash == message_block_hash else PAYLOAD_STATUS_EMPTY
146
+ *
147
+ * In lodestar forkchoice, we don't store the full bid, so we compares parent_block_hash in child's bid with executionPayloadBlockHash in parent:
148
+ * - If it matches EMPTY variant, return EMPTY
149
+ * - If it matches FULL variant, return FULL
150
+ * - If no match, throw UNKNOWN_PARENT_BLOCK error
151
+ *
152
+ * For pre-Gloas blocks: always returns FULL
153
+ */
154
+ getParentPayloadStatus(block) {
155
+ // Pre-Gloas blocks have payloads embedded, so parents are always FULL
156
+ const { parentBlockHash } = block;
157
+ if (parentBlockHash === null) {
158
+ return PayloadStatus.FULL;
159
+ }
160
+ const parentBlock = this.getBlockHexAndBlockHash(block.parentRoot, parentBlockHash);
161
+ if (parentBlock == null) {
162
+ throw new ProtoArrayError({
163
+ code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK,
164
+ parentRoot: block.parentRoot,
165
+ parentHash: parentBlockHash,
166
+ });
167
+ }
168
+ return parentBlock.payloadStatus;
169
+ }
170
+ /**
171
+ * Return the parent `ProtoBlock` given its root and block hash.
172
+ */
173
+ getParent(parentRoot, parentBlockHash) {
174
+ // pre-gloas
175
+ if (parentBlockHash === null) {
176
+ const parentIndex = this.indices.get(parentRoot);
177
+ if (parentIndex === undefined) {
178
+ return null;
179
+ }
180
+ if (Array.isArray(parentIndex)) {
181
+ // Gloas block found when pre-gloas expected
182
+ throw new ProtoArrayError({
183
+ code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK,
184
+ parentRoot,
185
+ parentHash: parentBlockHash,
186
+ });
187
+ }
188
+ return this.nodes[parentIndex] ?? null;
189
+ }
190
+ // post-gloas
191
+ return this.getBlockHexAndBlockHash(parentRoot, parentBlockHash);
192
+ }
193
+ /**
194
+ * Returns an EMPTY or FULL `ProtoBlock` that has matching block root and block hash
195
+ */
196
+ getBlockHexAndBlockHash(blockRoot, blockHash) {
197
+ const variantIndices = this.indices.get(blockRoot);
198
+ if (variantIndices === undefined) {
199
+ return null;
200
+ }
201
+ // Pre-Gloas
202
+ if (!Array.isArray(variantIndices)) {
203
+ const node = this.nodes[variantIndices];
204
+ return node.executionPayloadBlockHash === blockHash ? node : null;
205
+ }
206
+ // Post-Gloas, check empty and full variants
207
+ const fullNodeIndex = variantIndices[PayloadStatus.FULL];
208
+ if (fullNodeIndex !== undefined) {
209
+ const fullNode = this.nodes[fullNodeIndex];
210
+ if (fullNode && fullNode.executionPayloadBlockHash === blockHash) {
211
+ return fullNode;
212
+ }
213
+ }
214
+ const emptyNode = this.nodes[variantIndices[PayloadStatus.EMPTY]];
215
+ if (emptyNode && emptyNode.executionPayloadBlockHash === blockHash) {
216
+ return emptyNode;
217
+ }
218
+ // PENDING is the same to EMPTY so not likely we can return it
219
+ // also it's only specific for fork-choice
220
+ return null;
221
+ }
43
222
  /**
44
223
  * Iterate backwards through the array, touching all nodes and their parents and potentially
45
224
  * the best-child of each parent.
@@ -56,11 +235,11 @@ export class ProtoArray {
56
235
  * - If required, update the parents best-descendant with the current node or its best-descendant.
57
236
  */
58
237
  applyScoreChanges({ deltas, proposerBoost, justifiedEpoch, justifiedRoot, finalizedEpoch, finalizedRoot, currentSlot, }) {
59
- if (deltas.length !== this.indices.size) {
238
+ if (deltas.length !== this.nodes.length) {
60
239
  throw new ProtoArrayError({
61
240
  code: ProtoArrayErrorCode.INVALID_DELTA_LEN,
62
241
  deltas: deltas.length,
63
- indices: this.indices.size,
242
+ indices: this.nodes.length,
64
243
  });
65
244
  }
66
245
  if (justifiedEpoch !== this.justifiedEpoch ||
@@ -129,7 +308,7 @@ export class ProtoArray {
129
308
  // If the node has a parent, try to update its best-child and best-descendant.
130
309
  const parentIndex = node.parent;
131
310
  if (parentIndex !== undefined) {
132
- this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot);
311
+ this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot, proposerBoost?.root ?? null);
133
312
  }
134
313
  }
135
314
  // Update the previous proposer boost
@@ -140,9 +319,9 @@ export class ProtoArray {
140
319
  *
141
320
  * It is only sane to supply an undefined parent for the genesis block
142
321
  */
143
- onBlock(block, currentSlot) {
322
+ onBlock(block, currentSlot, proposerBoostRoot) {
144
323
  // If the block is already known, simply ignore it
145
- if (this.indices.has(block.blockRoot)) {
324
+ if (this.hasBlock(block.blockRoot)) {
146
325
  return;
147
326
  }
148
327
  if (block.executionStatus === ExecutionStatus.Invalid) {
@@ -151,30 +330,257 @@ export class ProtoArray {
151
330
  root: block.blockRoot,
152
331
  });
153
332
  }
154
- const node = {
155
- ...block,
156
- parent: this.indices.get(block.parentRoot),
333
+ if (isGloasBlock(block)) {
334
+ // Gloas: Create PENDING + EMPTY nodes with correct parent relationships
335
+ // Parent of new PENDING node = parent block's EMPTY or FULL (inter-block edge)
336
+ // Parent of new EMPTY node = own PENDING node (intra-block edge)
337
+ // For fork transition: if parent is pre-Gloas, point to parent's FULL
338
+ // Otherwise, determine which parent payload status this block extends
339
+ let parentIndex;
340
+ // Check if parent exists by getting variants array
341
+ const parentVariants = this.indices.get(block.parentRoot);
342
+ if (parentVariants != null) {
343
+ const anyParentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants;
344
+ const anyParentNode = this.nodes[anyParentIndex];
345
+ if (!isGloasBlock(anyParentNode)) {
346
+ // Fork transition: parent is pre-Gloas, so it only has FULL variant at variants[0]
347
+ parentIndex = anyParentIndex;
348
+ }
349
+ else {
350
+ // Both blocks are Gloas: determine which parent payload status to extend
351
+ const parentPayloadStatus = this.getParentPayloadStatus(block);
352
+ parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus);
353
+ }
354
+ }
355
+ // else: parent doesn't exist, parentIndex remains undefined (orphan block)
356
+ // Create PENDING node
357
+ const pendingNode = {
358
+ ...block,
359
+ parent: parentIndex, // Points to parent's EMPTY/FULL or FULL (for transition)
360
+ payloadStatus: PayloadStatus.PENDING,
361
+ weight: 0,
362
+ bestChild: undefined,
363
+ bestDescendant: undefined,
364
+ };
365
+ const pendingIndex = this.nodes.length;
366
+ this.nodes.push(pendingNode);
367
+ // Create EMPTY variant as a child of PENDING
368
+ const emptyNode = {
369
+ ...block,
370
+ parent: pendingIndex, // Points to own PENDING
371
+ payloadStatus: PayloadStatus.EMPTY,
372
+ weight: 0,
373
+ bestChild: undefined,
374
+ bestDescendant: undefined,
375
+ };
376
+ const emptyIndex = this.nodes.length;
377
+ this.nodes.push(emptyNode);
378
+ // Store both variants in the indices array
379
+ // [PENDING, EMPTY, undefined] - FULL will be added later if payload arrives
380
+ this.indices.set(block.blockRoot, [pendingIndex, emptyIndex]);
381
+ // Update bestChild pointers
382
+ if (parentIndex !== undefined) {
383
+ this.maybeUpdateBestChildAndDescendant(parentIndex, pendingIndex, currentSlot, proposerBoostRoot);
384
+ if (pendingNode.executionStatus === ExecutionStatus.Valid) {
385
+ this.propagateValidExecutionStatusByIndex(parentIndex);
386
+ }
387
+ }
388
+ // Update bestChild for PENDING → EMPTY edge
389
+ this.maybeUpdateBestChildAndDescendant(pendingIndex, emptyIndex, currentSlot, proposerBoostRoot);
390
+ // Initialize PTC votes for this block (all false initially)
391
+ // Spec: gloas/fork-choice.md#modified-on_block (line 645)
392
+ this.ptcVotes.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE));
393
+ }
394
+ else {
395
+ // Pre-Gloas: Only create FULL node (payload embedded in block)
396
+ const node = {
397
+ ...block,
398
+ parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL),
399
+ payloadStatus: PayloadStatus.FULL,
400
+ weight: 0,
401
+ bestChild: undefined,
402
+ bestDescendant: undefined,
403
+ };
404
+ const nodeIndex = this.nodes.length;
405
+ this.nodes.push(node);
406
+ // Pre-Gloas: store FULL index instead of array
407
+ this.indices.set(block.blockRoot, nodeIndex);
408
+ // If this node is valid, lets propagate the valid status up the chain
409
+ // and throw error if we counter invalid, as this breaks consensus
410
+ if (node.parent !== undefined) {
411
+ this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot, proposerBoostRoot);
412
+ if (node.executionStatus === ExecutionStatus.Valid) {
413
+ this.propagateValidExecutionStatusByIndex(node.parent);
414
+ }
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * Called when an execution payload is received for a block (Gloas only)
420
+ * Creates a FULL variant node as a sibling to the existing EMPTY variant
421
+ * Both EMPTY and FULL have parent = own PENDING node
422
+ *
423
+ * Spec: gloas/fork-choice.md (on_execution_payload event)
424
+ */
425
+ onExecutionPayload(blockRoot, currentSlot, executionPayloadBlockHash, executionPayloadNumber, executionPayloadStateRoot, proposerBoostRoot) {
426
+ // First check if block exists
427
+ const variants = this.indices.get(blockRoot);
428
+ if (variants == null) {
429
+ // Equivalent to `assert envelope.beacon_block_root in store.block_states`
430
+ throw new ProtoArrayError({
431
+ code: ProtoArrayErrorCode.UNKNOWN_BLOCK,
432
+ root: blockRoot,
433
+ });
434
+ }
435
+ if (!Array.isArray(variants)) {
436
+ // Pre-gloas block should not be calling this method
437
+ throw new ProtoArrayError({
438
+ code: ProtoArrayErrorCode.PRE_GLOAS_BLOCK,
439
+ root: blockRoot,
440
+ });
441
+ }
442
+ // Check if FULL already exists for Gloas blocks
443
+ if (variants[PayloadStatus.FULL] !== undefined) {
444
+ return;
445
+ }
446
+ // Get PENDING node for Gloas blocks
447
+ const pendingIndex = variants[PayloadStatus.PENDING];
448
+ if (pendingIndex === undefined) {
449
+ throw new ProtoArrayError({
450
+ code: ProtoArrayErrorCode.UNKNOWN_BLOCK,
451
+ root: blockRoot,
452
+ });
453
+ }
454
+ const pendingNode = this.nodes[pendingIndex];
455
+ if (!pendingNode) {
456
+ throw new ProtoArrayError({
457
+ code: ProtoArrayErrorCode.INVALID_NODE_INDEX,
458
+ index: pendingIndex,
459
+ });
460
+ }
461
+ // Create FULL variant as a child of PENDING (sibling to EMPTY)
462
+ const fullNode = {
463
+ ...pendingNode,
464
+ parent: pendingIndex, // Points to own PENDING (same as EMPTY)
465
+ payloadStatus: PayloadStatus.FULL,
157
466
  weight: 0,
158
467
  bestChild: undefined,
159
468
  bestDescendant: undefined,
469
+ executionStatus: ExecutionStatus.Valid,
470
+ executionPayloadBlockHash,
471
+ executionPayloadNumber,
472
+ stateRoot: executionPayloadStateRoot,
160
473
  };
161
- const nodeIndex = this.nodes.length;
162
- this.indices.set(node.blockRoot, nodeIndex);
163
- this.nodes.push(node);
164
- // If this node is valid, lets propagate the valid status up the chain
165
- // and throw error if we counter invalid, as this breaks consensus
166
- if (node.parent !== undefined) {
167
- this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot);
168
- if (node.executionStatus === ExecutionStatus.Valid) {
169
- this.propagateValidExecutionStatusByIndex(node.parent);
474
+ const fullIndex = this.nodes.length;
475
+ this.nodes.push(fullNode);
476
+ // Add FULL variant to the indices array
477
+ variants[PayloadStatus.FULL] = fullIndex;
478
+ // Update bestChild for PENDING node (may now prefer FULL over EMPTY)
479
+ this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot, proposerBoostRoot);
480
+ }
481
+ /**
482
+ * Update PTC votes for multiple validators attesting to a block
483
+ * Spec: gloas/fork-choice.md#new-on_payload_attestation_message
484
+ *
485
+ * @param blockRoot - The beacon block root being attested
486
+ * @param ptcIndices - Array of PTC committee indices that voted (0..PTC_SIZE-1)
487
+ * @param payloadPresent - Whether the validators attest the payload is present
488
+ */
489
+ notifyPtcMessages(blockRoot, ptcIndices, payloadPresent) {
490
+ const votes = this.ptcVotes.get(blockRoot);
491
+ if (votes === undefined) {
492
+ // Block not found or not a Gloas block, ignore
493
+ return;
494
+ }
495
+ for (const ptcIndex of ptcIndices) {
496
+ if (ptcIndex < 0 || ptcIndex >= PTC_SIZE) {
497
+ throw new Error(`Invalid PTC index: ${ptcIndex}, must be 0..${PTC_SIZE - 1}`);
170
498
  }
499
+ votes.set(ptcIndex, payloadPresent);
500
+ }
501
+ }
502
+ /**
503
+ * Check if execution payload for a block is timely
504
+ * Spec: gloas/fork-choice.md#new-is_payload_timely
505
+ *
506
+ * Returns true if:
507
+ * 1. Block has PTC votes tracked
508
+ * 2. Payload is locally available (FULL variant exists in proto array)
509
+ * 3. More than PAYLOAD_TIMELY_THRESHOLD (>50% of PTC) members voted payload_present=true
510
+ *
511
+ * @param blockRoot - The beacon block root to check
512
+ */
513
+ isPayloadTimely(blockRoot) {
514
+ const votes = this.ptcVotes.get(blockRoot);
515
+ if (votes === undefined) {
516
+ // Block not found or not a Gloas block
517
+ return false;
518
+ }
519
+ // If payload is not locally available, it's not timely
520
+ // In our implementation, payload is locally available if proto array has FULL variant of the block
521
+ const fullNodeIndex = this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL);
522
+ if (fullNodeIndex === undefined) {
523
+ return false;
171
524
  }
525
+ // Count votes for payload_present=true
526
+ const yesVotes = bitCount(votes.uint8Array);
527
+ return yesVotes > PAYLOAD_TIMELY_THRESHOLD;
528
+ }
529
+ /**
530
+ * Check if parent node is FULL
531
+ * Spec: gloas/fork-choice.md#new-is_parent_node_full
532
+ *
533
+ * Returns true if the parent payload status (determined by block.parentBlockHash) is FULL
534
+ */
535
+ isParentNodeFull(block) {
536
+ return this.getParentPayloadStatus(block) === PayloadStatus.FULL;
537
+ }
538
+ /**
539
+ * Determine if we should extend the payload (prefer FULL over EMPTY)
540
+ * Spec: gloas/fork-choice.md#new-should_extend_payload
541
+ *
542
+ * Returns true if:
543
+ * 1. Payload is timely, OR
544
+ * 2. No proposer boost root (empty/zero hash), OR
545
+ * 3. Proposer boost root's parent is not this block, OR
546
+ * 4. Proposer boost root extends FULL parent
547
+ *
548
+ * @param blockRoot - The block root to check
549
+ * @param proposerBoostRoot - Current proposer boost root (from ForkChoice)
550
+ */
551
+ shouldExtendPayload(blockRoot, proposerBoostRoot) {
552
+ // Condition 1: Payload is timely
553
+ if (this.isPayloadTimely(blockRoot)) {
554
+ return true;
555
+ }
556
+ // Condition 2: No proposer boost root
557
+ if (proposerBoostRoot === null || proposerBoostRoot === HEX_ZERO_HASH) {
558
+ return true;
559
+ }
560
+ // Get proposer boost block
561
+ // We don't care about variant here, just need proposer boost block info
562
+ const proposerBoostIndex = this.getDefaultNodeIndex(proposerBoostRoot);
563
+ const proposerBoostBlock = proposerBoostIndex !== undefined ? this.getNodeByIndex(proposerBoostIndex) : undefined;
564
+ if (!proposerBoostBlock) {
565
+ // Proposer boost block not found, default to extending payload
566
+ return true;
567
+ }
568
+ // Condition 3: Proposer boost root's parent is not this block
569
+ if (proposerBoostBlock.parentRoot !== blockRoot) {
570
+ return true;
571
+ }
572
+ // Condition 4: Proposer boost root extends FULL parent
573
+ if (this.isParentNodeFull(proposerBoostBlock)) {
574
+ return true;
575
+ }
576
+ return false;
172
577
  }
173
578
  /**
174
579
  * Optimistic sync validate till validated latest hash, invalidate any descendant branch
175
580
  * if invalidate till hash provided. If consensus fails, this will invalidate entire
176
581
  * forkChoice which will throw on any call to findHead
177
582
  */
583
+ // TODO GLOAS: Review usage of this post-gloas
178
584
  validateLatestHash(execResponse, currentSlot) {
179
585
  // Look reverse because its highly likely node with latestValidExecHash is towards the
180
586
  // the leaves of the forkchoice
@@ -216,7 +622,8 @@ export class ProtoArray {
216
622
  // if its in fcU.
217
623
  //
218
624
  const { invalidateFromParentBlockRoot, latestValidExecHash } = execResponse;
219
- const invalidateFromParentIndex = this.indices.get(invalidateFromParentBlockRoot);
625
+ // TODO GLOAS: verify if getting the default/canonical node index is correct here
626
+ const invalidateFromParentIndex = this.getDefaultNodeIndex(invalidateFromParentBlockRoot);
220
627
  if (invalidateFromParentIndex === undefined) {
221
628
  throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`);
222
629
  }
@@ -356,8 +763,41 @@ export class ProtoArray {
356
763
  }
357
764
  return validNode;
358
765
  }
766
+ /**
767
+ * Get payload status tiebreaker for fork choice comparison
768
+ * Spec: gloas/fork-choice.md#new-get_payload_status_tiebreaker
769
+ *
770
+ * For PENDING nodes: always returns 0
771
+ * For EMPTY/FULL variants from slot n-1: implements tiebreaker logic based on should_extend_payload
772
+ * For older blocks: returns node.payloadStatus
773
+ *
774
+ * 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.
775
+ */
776
+ getPayloadStatusTiebreaker(node, currentSlot, proposerBoostRoot) {
777
+ // PENDING nodes always return PENDING (no tiebreaker needed)
778
+ // PENDING=0, EMPTY=1, FULL=2
779
+ if (node.payloadStatus === PayloadStatus.PENDING) {
780
+ return node.payloadStatus;
781
+ }
782
+ // For Gloas: check if from previous slot
783
+ if (node.slot + 1 !== currentSlot) {
784
+ return node.payloadStatus;
785
+ }
786
+ // For previous slot blocks in Gloas, decide between FULL and EMPTY
787
+ // based on should_extend_payload
788
+ if (node.payloadStatus === PayloadStatus.EMPTY) {
789
+ return PayloadStatus.EMPTY;
790
+ }
791
+ // FULL - check should_extend_payload
792
+ const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot);
793
+ return shouldExtend ? PayloadStatus.FULL : PayloadStatus.PENDING;
794
+ }
359
795
  /**
360
796
  * Follows the best-descendant links to find the best-block (i.e., head-block).
797
+ *
798
+ * Returns the ProtoNode representing the head.
799
+ * For pre-Gloas forks, only FULL variants exist (payload embedded).
800
+ * For Gloas, may return PENDING/EMPTY/FULL variants.
361
801
  */
362
802
  findHead(justifiedRoot, currentSlot) {
363
803
  if (this.lvhError) {
@@ -366,7 +806,8 @@ export class ProtoArray {
366
806
  ...this.lvhError,
367
807
  });
368
808
  }
369
- const justifiedIndex = this.indices.get(justifiedRoot);
809
+ // Get canonical node: FULL for pre-Gloas, PENDING for Gloas
810
+ const justifiedIndex = this.getDefaultNodeIndex(justifiedRoot);
370
811
  if (justifiedIndex === undefined) {
371
812
  throw new ProtoArrayError({
372
813
  code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN,
@@ -412,7 +853,7 @@ export class ProtoArray {
412
853
  headFinalizedEpoch: justifiedNode.finalizedEpoch,
413
854
  });
414
855
  }
415
- return bestNode.blockRoot;
856
+ return bestNode;
416
857
  }
417
858
  /**
418
859
  * Update the tree with new finalization information. The tree is only actually pruned if both
@@ -430,38 +871,68 @@ export class ProtoArray {
430
871
  * - There is some internal error relating to invalid indices inside `this`.
431
872
  */
432
873
  maybePrune(finalizedRoot) {
433
- const finalizedIndex = this.indices.get(finalizedRoot);
434
- if (finalizedIndex === undefined) {
874
+ const variants = this.indices.get(finalizedRoot);
875
+ if (variants == null) {
435
876
  throw new ProtoArrayError({
436
877
  code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN,
437
878
  root: finalizedRoot,
438
879
  });
439
880
  }
881
+ // Find the minimum index among all variants to ensure we don't prune too much
882
+ const finalizedIndex = Array.isArray(variants)
883
+ ? Math.min(...variants.filter((idx) => idx !== undefined))
884
+ : variants;
440
885
  if (finalizedIndex < this.pruneThreshold) {
441
886
  // Pruning at small numbers incurs more cost than benefit
442
887
  return [];
443
888
  }
444
- // Remove the this.indices key/values for all the to-be-deleted nodes
445
- for (let nodeIndex = 0; nodeIndex < finalizedIndex; nodeIndex++) {
446
- const node = this.nodes[nodeIndex];
889
+ // Collect all block roots that will be pruned
890
+ const prunedRoots = new Set();
891
+ for (let i = 0; i < finalizedIndex; i++) {
892
+ const node = this.nodes[i];
447
893
  if (node === undefined) {
448
- throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: nodeIndex });
894
+ throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: i });
449
895
  }
450
- this.indices.delete(node.blockRoot);
896
+ prunedRoots.add(node.blockRoot);
897
+ }
898
+ // Remove indices for pruned blocks and PTC votes
899
+ for (const root of prunedRoots) {
900
+ this.indices.delete(root);
901
+ // Prune PTC votes for this block to prevent memory leak
902
+ // Spec: gloas/fork-choice.md (implicit - finalized blocks don't need PTC votes)
903
+ this.ptcVotes.delete(root);
451
904
  }
452
905
  // Store nodes prior to finalization
453
906
  const removed = this.nodes.slice(0, finalizedIndex);
454
907
  // Drop all the nodes prior to finalization
455
908
  this.nodes = this.nodes.slice(finalizedIndex);
456
- // Adjust the indices map
457
- for (const [key, value] of this.indices.entries()) {
458
- if (value < finalizedIndex) {
459
- throw new ProtoArrayError({
460
- code: ProtoArrayErrorCode.INDEX_OVERFLOW,
461
- value: "indices",
462
- });
909
+ // Adjust the indices map - subtract finalizedIndex from all node indices
910
+ for (const [root, variantIndices] of this.indices.entries()) {
911
+ // Pre-Gloas: single index
912
+ if (!Array.isArray(variantIndices)) {
913
+ if (variantIndices < finalizedIndex) {
914
+ throw new ProtoArrayError({
915
+ code: ProtoArrayErrorCode.INDEX_OVERFLOW,
916
+ value: "indices",
917
+ });
918
+ }
919
+ this.indices.set(root, variantIndices - finalizedIndex);
920
+ continue;
463
921
  }
464
- this.indices.set(key, value - finalizedIndex);
922
+ // Post-Gloas: array of variant indices
923
+ const adjustedVariants = variantIndices.map((variantIndex) => {
924
+ if (variantIndex === undefined) {
925
+ return undefined;
926
+ }
927
+ if (variantIndex < finalizedIndex) {
928
+ throw new ProtoArrayError({
929
+ code: ProtoArrayErrorCode.INDEX_OVERFLOW,
930
+ value: "indices",
931
+ });
932
+ }
933
+ return variantIndex - finalizedIndex;
934
+ });
935
+ this.indices.set(root, adjustedVariants);
465
936
  }
466
937
  // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes
467
938
  for (let i = 0, len = this.nodes.length; i < len; i++) {
@@ -508,7 +979,7 @@ export class ProtoArray {
508
979
  * - The child is not the best child but becomes the best child.
509
980
  * - The child is not the best child and does not become the best child.
510
981
  */
511
- maybeUpdateBestChildAndDescendant(parentIndex, childIndex, currentSlot) {
982
+ maybeUpdateBestChildAndDescendant(parentIndex, childIndex, currentSlot, proposerBoostRoot) {
512
983
  const childNode = this.nodes[childIndex];
513
984
  if (childNode === undefined) {
514
985
  throw new ProtoArrayError({
@@ -529,61 +1000,87 @@ export class ProtoArray {
529
1000
  const noChange = [parentNode.bestChild, parentNode.bestDescendant];
530
1001
  let newChildAndDescendant;
531
1002
  const bestChildIndex = parentNode.bestChild;
532
- if (bestChildIndex !== undefined) {
533
- if (bestChildIndex === childIndex && !childLeadsToViableHead) {
534
- // the child is already the best-child of the parent but its not viable for the head
535
- // so remove it
536
- newChildAndDescendant = changeToNull;
537
- }
538
- else if (bestChildIndex === childIndex) {
539
- // the child is the best-child already
540
- // set it again to ensure that the best-descendent of the parent is updated
541
- newChildAndDescendant = changeToChild;
542
- }
543
- else {
544
- const bestChildNode = this.nodes[bestChildIndex];
545
- if (bestChildNode === undefined) {
546
- throw new ProtoArrayError({
547
- code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX,
548
- index: bestChildIndex,
549
- });
1003
+ // biome-ignore lint/suspicious/noConfusingLabels: labeled block used for early exit from complex decision tree
1004
+ outer: {
1005
+ if (bestChildIndex !== undefined) {
1006
+ if (bestChildIndex === childIndex && !childLeadsToViableHead) {
1007
+ // the child is already the best-child of the parent but its not viable for the head
1008
+ // so remove it
1009
+ newChildAndDescendant = changeToNull;
550
1010
  }
551
- const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot);
552
- if (childLeadsToViableHead && !bestChildLeadsToViableHead) {
553
- // the child leads to a viable head, but the current best-child doesn't
1011
+ else if (bestChildIndex === childIndex) {
1012
+ // the child is the best-child already
1013
+ // set it again to ensure that the best-descendent of the parent is updated
554
1014
  newChildAndDescendant = changeToChild;
555
1015
  }
556
- else if (!childLeadsToViableHead && bestChildLeadsToViableHead) {
557
- // the best child leads to a viable head but the child doesn't
558
- newChildAndDescendant = noChange;
559
- }
560
- else if (childNode.weight === bestChildNode.weight) {
561
- // tie-breaker of equal weights by root
562
- if (childNode.blockRoot >= bestChildNode.blockRoot) {
1016
+ else {
1017
+ const bestChildNode = this.nodes[bestChildIndex];
1018
+ if (bestChildNode === undefined) {
1019
+ throw new ProtoArrayError({
1020
+ code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX,
1021
+ index: bestChildIndex,
1022
+ });
1023
+ }
1024
+ const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot);
1025
+ if (childLeadsToViableHead && !bestChildLeadsToViableHead) {
1026
+ // the child leads to a viable head, but the current best-child doesn't
563
1027
  newChildAndDescendant = changeToChild;
1028
+ break outer;
564
1029
  }
565
- else {
1030
+ if (!childLeadsToViableHead && bestChildLeadsToViableHead) {
1031
+ // the best child leads to a viable head but the child doesn't
566
1032
  newChildAndDescendant = noChange;
1033
+ break outer;
567
1034
  }
568
- }
569
- else {
570
- // choose the winner by weight
571
- if (childNode.weight >= bestChildNode.weight) {
1035
+ // Both nodes lead to viable heads (or both don't), need to pick winner
1036
+ // Pre-fulu we pick whichever has higher weight, tie-breaker by root
1037
+ // Post-fulu we pick whichever has higher weight, then tie-breaker by root, then tie-breaker by `getPayloadStatusTiebreaker`
1038
+ // Gloas: nodes from previous slot (n-1) with EMPTY/FULL variant have weight hardcoded to 0.
1039
+ // https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442
1040
+ const childEffectiveWeight = !isGloasBlock(childNode) ||
1041
+ childNode.payloadStatus === PayloadStatus.PENDING ||
1042
+ childNode.slot + 1 !== currentSlot
1043
+ ? childNode.weight
1044
+ : 0;
1045
+ const bestChildEffectiveWeight = !isGloasBlock(bestChildNode) ||
1046
+ bestChildNode.payloadStatus === PayloadStatus.PENDING ||
1047
+ bestChildNode.slot + 1 !== currentSlot
1048
+ ? bestChildNode.weight
1049
+ : 0;
1050
+ if (childEffectiveWeight !== bestChildEffectiveWeight) {
1051
+ // Different effective weights, choose the winner by weight
1052
+ newChildAndDescendant = childEffectiveWeight >= bestChildEffectiveWeight ? changeToChild : noChange;
1053
+ break outer;
1054
+ }
1055
+ if (childNode.blockRoot !== bestChildNode.blockRoot) {
1056
+ // Different blocks, tie-breaker by root
1057
+ newChildAndDescendant = childNode.blockRoot >= bestChildNode.blockRoot ? changeToChild : noChange;
1058
+ break outer;
1059
+ }
1060
+ // Same effective weight and same root — Gloas EMPTY vs FULL from n-1, tie-breaker by payload status
1061
+ // Note: pre-Gloas, each child node of a block has a unique root, so this point should not be reached
1062
+ const childTiebreaker = this.getPayloadStatusTiebreaker(childNode, currentSlot, proposerBoostRoot);
1063
+ const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, proposerBoostRoot);
1064
+ if (childTiebreaker > bestChildTiebreaker) {
572
1065
  newChildAndDescendant = changeToChild;
573
1066
  }
1067
+ else if (childTiebreaker < bestChildTiebreaker) {
1068
+ newChildAndDescendant = noChange;
1069
+ }
574
1070
  else {
1071
+ // Equal in all aspects, noChange
575
1072
  newChildAndDescendant = noChange;
576
1073
  }
577
1074
  }
578
1075
  }
579
- }
580
- else if (childLeadsToViableHead) {
581
- // There is no current best-child and the child is viable.
582
- newChildAndDescendant = changeToChild;
583
- }
584
- else {
585
- // There is no current best-child but the child is not viable.
586
- newChildAndDescendant = noChange;
1076
+ else if (childLeadsToViableHead) {
1077
+ // There is no current best-child and the child is viable.
1078
+ newChildAndDescendant = changeToChild;
1079
+ }
1080
+ else {
1081
+ // There is no current best-child but the child is not viable.
1082
+ newChildAndDescendant = noChange;
1083
+ }
587
1084
  }
588
1085
  parentNode.bestChild = newChildAndDescendant[0];
589
1086
  parentNode.bestDescendant = newChildAndDescendant[1];
@@ -652,7 +1149,8 @@ export class ProtoArray {
652
1149
  return true;
653
1150
  }
654
1151
  const finalizedSlot = computeStartSlotAtEpoch(this.finalizedEpoch);
655
- return this.finalizedEpoch === 0 || this.finalizedRoot === this.getAncestorOrNull(node.blockRoot, finalizedSlot);
1152
+ const ancestorNode = this.getAncestorOrNull(node.blockRoot, finalizedSlot);
1153
+ return this.finalizedEpoch === 0 || (ancestorNode !== null && this.finalizedRoot === ancestorNode.blockRoot);
656
1154
  }
657
1155
  /**
658
1156
  * Same to getAncestor but it may return null instead of throwing error
@@ -666,47 +1164,103 @@ export class ProtoArray {
666
1164
  }
667
1165
  }
668
1166
  /**
669
- * Returns the block root of an ancestor of `blockRoot` at the given `slot`.
1167
+ * Returns the node identifier of an ancestor of `blockRoot` at the given `slot`.
670
1168
  * (Note: `slot` refers to the block that is *returned*, not the one that is supplied.)
671
1169
  *
672
1170
  * NOTE: May be expensive: potentially walks through the entire fork of head to finalized block
673
1171
  *
674
1172
  * ### Specification
675
1173
  *
676
- * Equivalent to:
1174
+ * Modified for Gloas to return node identifier instead of just root:
1175
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#modified-get_ancestor
677
1176
  *
678
- * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_ancestor
1177
+ * Pre-Gloas: Returns (root, PAYLOAD_STATUS_FULL)
1178
+ * Gloas: Returns (root, payloadStatus) based on actual node state
679
1179
  */
680
1180
  getAncestor(blockRoot, ancestorSlot) {
681
- const block = this.getBlock(blockRoot);
682
- if (!block) {
1181
+ // Get any variant to check the block (use variants[0])
1182
+ const variantOrArr = this.indices.get(blockRoot);
1183
+ if (variantOrArr == null) {
683
1184
  throw new ForkChoiceError({
684
1185
  code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK,
685
1186
  root: blockRoot,
686
1187
  });
687
1188
  }
688
- if (block.slot > ancestorSlot) {
689
- // Search for a slot that is lte the target slot.
690
- // We check for lower slots to account for skip slots.
691
- for (const node of this.iterateAncestorNodes(blockRoot)) {
692
- if (node.slot <= ancestorSlot) {
693
- return node.blockRoot;
694
- }
1189
+ const blockIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr;
1190
+ const block = this.nodes[blockIndex];
1191
+ // If block is at or before queried slot, return PENDING variant (or FULL for pre-Gloas)
1192
+ if (block.slot <= ancestorSlot) {
1193
+ // For pre-Gloas: only FULL exists at variants[0]
1194
+ // For Gloas: PENDING is at variants[0]
1195
+ return block;
1196
+ }
1197
+ // Walk backwards through beacon blocks to find ancestor
1198
+ // Start with the parent of the current block
1199
+ let currentBlock = block;
1200
+ const parentVariants = this.indices.get(currentBlock.parentRoot);
1201
+ if (parentVariants == null) {
1202
+ throw new ForkChoiceError({
1203
+ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
1204
+ descendantRoot: blockRoot,
1205
+ ancestorSlot,
1206
+ });
1207
+ }
1208
+ let parentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants;
1209
+ let parentBlock = this.nodes[parentIndex];
1210
+ // Walk backwards while parent.slot > ancestorSlot
1211
+ while (parentBlock.slot > ancestorSlot) {
1212
+ currentBlock = parentBlock;
1213
+ const nextParentVariants = this.indices.get(currentBlock.parentRoot);
1214
+ if (nextParentVariants == null) {
1215
+ throw new ForkChoiceError({
1216
+ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
1217
+ descendantRoot: blockRoot,
1218
+ ancestorSlot,
1219
+ });
695
1220
  }
1221
+ parentIndex = Array.isArray(nextParentVariants) ? nextParentVariants[0] : nextParentVariants;
1222
+ parentBlock = this.nodes[parentIndex];
1223
+ }
1224
+ // Now parentBlock.slot <= ancestorSlot
1225
+ // Return the parent with the correct payload status based on currentBlock
1226
+ if (!isGloasBlock(currentBlock)) {
1227
+ // Pre-Gloas: return FULL variant (only one that exists)
1228
+ return parentBlock;
1229
+ }
1230
+ // Gloas: determine which parent variant (EMPTY or FULL) based on parent_block_hash
1231
+ const parentPayloadStatus = this.getParentPayloadStatus(currentBlock);
1232
+ const parentVariantIndex = this.getNodeIndexByRootAndStatus(currentBlock.parentRoot, parentPayloadStatus);
1233
+ if (parentVariantIndex === undefined) {
696
1234
  throw new ForkChoiceError({
697
1235
  code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR,
698
1236
  descendantRoot: blockRoot,
699
1237
  ancestorSlot,
700
1238
  });
701
1239
  }
702
- // Root is older or equal than queried slot, thus a skip slot. Return most recent root prior to slot.
703
- return blockRoot;
1240
+ return this.nodes[parentVariantIndex];
1241
+ }
1242
+ /**
1243
+ * Get the parent node index for traversal
1244
+ * For Gloas blocks: returns the correct EMPTY/FULL variant based on parent payload status
1245
+ * For pre-Gloas blocks: returns the simple parent index
1246
+ * Returns undefined if parent doesn't exist or can't be found
1247
+ */
1248
+ getParentNodeIndex(node) {
1249
+ if (isGloasBlock(node)) {
1250
+ // Use getParentPayloadStatus for Gloas blocks to get correct EMPTY/FULL variant
1251
+ const parentPayloadStatus = this.getParentPayloadStatus(node);
1252
+ return this.getNodeIndexByRootAndStatus(node.parentRoot, parentPayloadStatus);
1253
+ }
1254
+ // Simple parent traversal for pre-Gloas blocks (includes fork transition)
1255
+ return node.parent;
704
1256
  }
705
1257
  /**
706
1258
  * Iterate from a block root backwards over nodes
1259
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1260
+ * For pre-Gloas blocks: returns FULL variants
707
1261
  */
708
- *iterateAncestorNodes(blockRoot) {
709
- const startIndex = this.indices.get(blockRoot);
1262
+ *iterateAncestorNodes(blockRoot, payloadStatus) {
1263
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
710
1264
  if (startIndex === undefined) {
711
1265
  return;
712
1266
  }
@@ -720,19 +1274,28 @@ export class ProtoArray {
720
1274
  yield* this.iterateAncestorNodesFromNode(node);
721
1275
  }
722
1276
  /**
723
- * Iterate from a block root backwards over nodes
1277
+ * Iterate from a node backwards over ancestor nodes
1278
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1279
+ * For pre-Gloas blocks: returns FULL variants
1280
+ * Handles fork transition from Gloas to pre-Gloas blocks
724
1281
  */
725
1282
  *iterateAncestorNodesFromNode(node) {
726
1283
  while (node.parent !== undefined) {
727
- node = this.getNodeFromIndex(node.parent);
1284
+ const parentIndex = this.getParentNodeIndex(node);
1285
+ if (parentIndex === undefined) {
1286
+ break;
1287
+ }
1288
+ node = this.nodes[parentIndex];
728
1289
  yield node;
729
1290
  }
730
1291
  }
731
1292
  /**
732
1293
  * Get all nodes from a block root backwards
1294
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1295
+ * For pre-Gloas blocks: returns FULL variants
733
1296
  */
734
- getAllAncestorNodes(blockRoot) {
735
- const startIndex = this.indices.get(blockRoot);
1297
+ getAllAncestorNodes(blockRoot, payloadStatus) {
1298
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
736
1299
  if (startIndex === undefined) {
737
1300
  return [];
738
1301
  }
@@ -743,9 +1306,17 @@ export class ProtoArray {
743
1306
  index: startIndex,
744
1307
  });
745
1308
  }
746
- const nodes = [node];
1309
+ // Exclude PENDING variant from returned ancestors.
1310
+ const nodes = [];
1311
+ if (node.payloadStatus !== PayloadStatus.PENDING) {
1312
+ nodes.push(node);
1313
+ }
747
1314
  while (node.parent !== undefined) {
748
- node = this.getNodeFromIndex(node.parent);
1315
+ const parentIndex = this.getParentNodeIndex(node);
1316
+ if (parentIndex === undefined) {
1317
+ break;
1318
+ }
1319
+ node = this.nodes[parentIndex];
749
1320
  nodes.push(node);
750
1321
  }
751
1322
  return nodes;
@@ -754,9 +1325,12 @@ export class ProtoArray {
754
1325
  * The opposite of iterateNodes.
755
1326
  * iterateNodes is to find ancestor nodes of a blockRoot.
756
1327
  * this is to find non-ancestor nodes of a blockRoot.
1328
+ *
1329
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1330
+ * For pre-Gloas blocks: returns FULL variants
757
1331
  */
758
- getAllNonAncestorNodes(blockRoot) {
759
- const startIndex = this.indices.get(blockRoot);
1332
+ getAllNonAncestorNodes(blockRoot, payloadStatus) {
1333
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
760
1334
  if (startIndex === undefined) {
761
1335
  return [];
762
1336
  }
@@ -767,23 +1341,31 @@ export class ProtoArray {
767
1341
  index: startIndex,
768
1342
  });
769
1343
  }
1344
+ // For both Gloas and pre-Gloas blocks
770
1345
  const result = [];
771
1346
  let nodeIndex = startIndex;
772
1347
  while (node.parent !== undefined) {
773
- const parentIndex = node.parent;
774
- node = this.getNodeFromIndex(parentIndex);
775
- // nodes between nodeIndex and parentIndex means non-ancestor nodes
776
- result.push(...this.getNodesBetween(nodeIndex, parentIndex));
1348
+ const parentIndex = this.getParentNodeIndex(node);
1349
+ if (parentIndex === undefined) {
1350
+ break;
1351
+ }
1352
+ node = this.nodes[parentIndex];
1353
+ // Collect non-ancestor nodes between current and parent
1354
+ // Filter to exclude PENDING nodes (FULL variant pre-gloas, EMPTY or FULL variant post-gloas)
1355
+ result.push(...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
777
1356
  nodeIndex = parentIndex;
778
1357
  }
779
- result.push(...this.getNodesBetween(nodeIndex, 0));
1358
+ // Collect remaining nodes from nodeIndex to beginning
1359
+ result.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
780
1360
  return result;
781
1361
  }
782
1362
  /**
783
1363
  * Returns both ancestor and non-ancestor nodes in a single traversal.
1364
+ * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status
1365
+ * For pre-Gloas blocks: returns FULL variants
784
1366
  */
785
- getAllAncestorAndNonAncestorNodes(blockRoot) {
786
- const startIndex = this.indices.get(blockRoot);
1367
+ getAllAncestorAndNonAncestorNodes(blockRoot, payloadStatus) {
1368
+ const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
787
1369
  if (startIndex === undefined) {
788
1370
  return { ancestors: [], nonAncestors: [] };
789
1371
  }
@@ -796,32 +1378,63 @@ export class ProtoArray {
796
1378
  }
797
1379
  const ancestors = [];
798
1380
  const nonAncestors = [];
1381
+ // Include starting node if it's not PENDING (i.e., pre-Gloas or EMPTY/FULL variant post-Gloas)
1382
+ if (node.payloadStatus !== PayloadStatus.PENDING) {
1383
+ ancestors.push(node);
1384
+ }
799
1385
  let nodeIndex = startIndex;
800
1386
  while (node.parent !== undefined) {
1387
+ const parentIndex = this.getParentNodeIndex(node);
1388
+ if (parentIndex === undefined) {
1389
+ break;
1390
+ }
1391
+ node = this.nodes[parentIndex];
801
1392
  ancestors.push(node);
802
- const parentIndex = node.parent;
803
- node = this.getNodeFromIndex(parentIndex);
804
- // Nodes between nodeIndex and parentIndex are non-ancestor nodes
805
- nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex));
1393
+ // Collect non-ancestor nodes between current and parent
1394
+ // Filter to exclude PENDING nodes (include all FULL/EMPTY for both pre-Gloas and Gloas)
1395
+ nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
806
1396
  nodeIndex = parentIndex;
807
1397
  }
808
- ancestors.push(node);
809
- nonAncestors.push(...this.getNodesBetween(nodeIndex, 0));
1398
+ // Collect remaining non-ancestor nodes from nodeIndex to beginning
1399
+ nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING));
810
1400
  return { ancestors, nonAncestors };
811
1401
  }
1402
+ /**
1403
+ * Check if a block exists in the proto array
1404
+ * Uses default variant (PENDING for Gloas, FULL for pre-Gloas)
1405
+ */
812
1406
  hasBlock(blockRoot) {
813
- return this.indices.has(blockRoot);
1407
+ return this.getDefaultNodeIndex(blockRoot) !== undefined;
814
1408
  }
815
- getNode(blockRoot) {
816
- const blockIndex = this.indices.get(blockRoot);
1409
+ /**
1410
+ * Return ProtoNode for blockRoot with explicit payload status
1411
+ *
1412
+ * @param blockRoot - The block root to look up
1413
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1414
+ * @returns The ProtoNode for the specified variant, or undefined if not found
1415
+ *
1416
+ * Note: Callers must explicitly specify which variant they need.
1417
+ * Use getDefaultVariant() to get the canonical variant for a block.
1418
+ */
1419
+ getNode(blockRoot, payloadStatus) {
1420
+ const blockIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus);
817
1421
  if (blockIndex === undefined) {
818
1422
  return undefined;
819
1423
  }
820
1424
  return this.getNodeByIndex(blockIndex);
821
1425
  }
822
- /** Return MUTABLE ProtoBlock for blockRoot (spreads properties) */
823
- getBlock(blockRoot) {
824
- const node = this.getNode(blockRoot);
1426
+ /**
1427
+ * Return MUTABLE ProtoBlock for blockRoot with explicit payload status
1428
+ *
1429
+ * @param blockRoot - The block root to look up
1430
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1431
+ * @returns The ProtoBlock for the specified variant (spreads properties), or undefined if not found
1432
+ *
1433
+ * Note: Callers must explicitly specify which variant they need.
1434
+ * Use getDefaultVariant() to get the canonical variant for a block.
1435
+ */
1436
+ getBlock(blockRoot, payloadStatus) {
1437
+ const node = this.getNode(blockRoot, payloadStatus);
825
1438
  if (!node) {
826
1439
  return undefined;
827
1440
  }
@@ -829,9 +1442,19 @@ export class ProtoArray {
829
1442
  ...node,
830
1443
  };
831
1444
  }
832
- /** Return NON-MUTABLE ProtoBlock for blockRoot (does not spread properties) */
833
- getBlockReadonly(blockRoot) {
834
- const node = this.getNode(blockRoot);
1445
+ /**
1446
+ * Return NON-MUTABLE ProtoBlock for blockRoot with explicit payload status
1447
+ *
1448
+ * @param blockRoot - The block root to look up
1449
+ * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL)
1450
+ * @returns The ProtoBlock for the specified variant (does not spread properties)
1451
+ * @throws Error if block not found
1452
+ *
1453
+ * Note: Callers must explicitly specify which variant they need.
1454
+ * Use getDefaultVariant() to get the canonical variant for a block.
1455
+ */
1456
+ getBlockReadonly(blockRoot, payloadStatus) {
1457
+ const node = this.getNode(blockRoot, payloadStatus);
835
1458
  if (!node) {
836
1459
  throw Error(`No block for root ${blockRoot}`);
837
1460
  }
@@ -840,21 +1463,21 @@ export class ProtoArray {
840
1463
  /**
841
1464
  * Returns `true` if the `descendantRoot` has an ancestor with `ancestorRoot`.
842
1465
  * Always returns `false` if either input roots are unknown.
843
- * Still returns `true` if `ancestorRoot` === `descendantRoot` (and the roots are known)
1466
+ * Still returns `true` if `ancestorRoot` === `descendantRoot` and payload statuses match.
844
1467
  */
845
- isDescendant(ancestorRoot, descendantRoot) {
846
- const ancestorNode = this.getNode(ancestorRoot);
1468
+ isDescendant(ancestorRoot, ancestorPayloadStatus, descendantRoot, descendantPayloadStatus) {
1469
+ const ancestorNode = this.getNode(ancestorRoot, ancestorPayloadStatus);
847
1470
  if (!ancestorNode) {
848
1471
  return false;
849
1472
  }
850
- if (ancestorRoot === descendantRoot) {
1473
+ if (ancestorRoot === descendantRoot && ancestorPayloadStatus === descendantPayloadStatus) {
851
1474
  return true;
852
1475
  }
853
- for (const node of this.iterateAncestorNodes(descendantRoot)) {
1476
+ for (const node of this.iterateAncestorNodes(descendantRoot, descendantPayloadStatus)) {
854
1477
  if (node.slot < ancestorNode.slot) {
855
1478
  return false;
856
1479
  }
857
- if (node.blockRoot === ancestorNode.blockRoot) {
1480
+ if (node.blockRoot === ancestorNode.blockRoot && node.payloadStatus === ancestorNode.payloadStatus) {
858
1481
  return true;
859
1482
  }
860
1483
  }