@lodestar/fork-choice 1.41.0-dev.aeb5a213ee → 1.41.0-dev.bb273175f2

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