@lodestar/fork-choice 1.41.0-dev.2074a31ba7 → 1.41.0-dev.21d4a81d4e

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