@lodestar/fork-choice 1.45.0-rc.0 → 1.46.0-dev.8fc4e297d2

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 (34) hide show
  1. package/lib/forkChoice/fastConfirmation/metrics.d.ts +1 -0
  2. package/lib/forkChoice/fastConfirmation/metrics.d.ts.map +1 -1
  3. package/lib/forkChoice/fastConfirmation/metrics.js +4 -0
  4. package/lib/forkChoice/fastConfirmation/metrics.js.map +1 -1
  5. package/lib/forkChoice/forkChoice.d.ts +35 -0
  6. package/lib/forkChoice/forkChoice.d.ts.map +1 -1
  7. package/lib/forkChoice/forkChoice.js +157 -37
  8. package/lib/forkChoice/forkChoice.js.map +1 -1
  9. package/lib/forkChoice/interface.d.ts +4 -0
  10. package/lib/forkChoice/interface.d.ts.map +1 -1
  11. package/lib/index.d.ts +1 -1
  12. package/lib/index.d.ts.map +1 -1
  13. package/lib/index.js +1 -1
  14. package/lib/index.js.map +1 -1
  15. package/lib/metrics.d.ts +1 -0
  16. package/lib/metrics.d.ts.map +1 -1
  17. package/lib/protoArray/computeDeltas.d.ts +3 -3
  18. package/lib/protoArray/computeDeltas.d.ts.map +1 -1
  19. package/lib/protoArray/computeDeltas.js +12 -12
  20. package/lib/protoArray/computeDeltas.js.map +1 -1
  21. package/lib/protoArray/interface.d.ts +6 -0
  22. package/lib/protoArray/interface.d.ts.map +1 -1
  23. package/lib/protoArray/protoArray.d.ts +14 -2
  24. package/lib/protoArray/protoArray.d.ts.map +1 -1
  25. package/lib/protoArray/protoArray.js +76 -24
  26. package/lib/protoArray/protoArray.js.map +1 -1
  27. package/package.json +8 -8
  28. package/src/forkChoice/fastConfirmation/metrics.ts +4 -0
  29. package/src/forkChoice/forkChoice.ts +171 -38
  30. package/src/forkChoice/interface.ts +4 -0
  31. package/src/index.ts +1 -1
  32. package/src/protoArray/computeDeltas.ts +13 -13
  33. package/src/protoArray/interface.ts +6 -0
  34. package/src/protoArray/protoArray.ts +84 -28
@@ -1,5 +1,5 @@
1
1
  import {ChainForkConfig} from "@lodestar/config";
2
- import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params";
2
+ import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params";
3
3
  import {
4
4
  DataAvailabilityStatus,
5
5
  EffectiveBalanceIncrements,
@@ -152,6 +152,7 @@ export class ForkChoice implements IForkChoice {
152
152
  /** Optional fast confirmation rule implementation */
153
153
  private readonly fastConfirmationRule?: IFastConfirmationRule;
154
154
  private readonly fastConfirmationContext?: FastConfirmationContext;
155
+ private fastConfirmationPaused = false;
155
156
  /**
156
157
  * Instantiates a Fork Choice from some existing components
157
158
  *
@@ -180,6 +181,7 @@ export class ForkChoice implements IForkChoice {
180
181
  if (this.opts?.fastConfirmation) {
181
182
  this.fastConfirmationRule = new FastConfirmationRule(this.fcStore, metrics, this.logger);
182
183
  this.fastConfirmationContext = this.createFastConfirmationContext();
184
+ metrics?.fastConfirmation.paused.set(0);
183
185
  }
184
186
 
185
187
  metrics?.forkChoice.votes.addCollect(() => {
@@ -230,6 +232,47 @@ export class ForkChoice implements IForkChoice {
230
232
  return this.getBlockHexDefaultStatus(this.getConfirmedRoot());
231
233
  }
232
234
 
235
+ resumeFastConfirmation(): void {
236
+ this.toggleFastConfirmation(false);
237
+ }
238
+
239
+ pauseFastConfirmation(): void {
240
+ this.toggleFastConfirmation(true);
241
+ }
242
+
243
+ private toggleFastConfirmation(paused: boolean): void {
244
+ if (!this.fastConfirmationRule) return;
245
+ if (paused === this.fastConfirmationPaused) return;
246
+ this.fastConfirmationPaused = paused;
247
+ if (paused) {
248
+ // Pin immediately: block imports report the safe block hash to the EL before the next slot tick
249
+ this.fcStore.confirmedRoot = this.fcStore.finalizedCheckpoint.rootHex;
250
+ try {
251
+ this.notifyConfirmedRoot();
252
+ } catch (err) {
253
+ // Callers run in clock/network handler context with no catch above
254
+ this.logger?.debug("Fast confirmation notify failed", {slot: this.fcStore.currentSlot}, err as Error);
255
+ }
256
+ }
257
+ this.metrics?.fastConfirmation.paused.set(paused ? 1 : 0);
258
+ this.logger?.info(paused ? "Paused fast confirmation" : "Resumed fast confirmation", {
259
+ slot: this.fcStore.currentSlot,
260
+ });
261
+ }
262
+
263
+ private notifyConfirmedRoot(): void {
264
+ const confirmedRoot = this.fcStore.confirmedRoot;
265
+ const confirmedBlock = this.getBlockHexDefaultStatus(confirmedRoot);
266
+ if (confirmedBlock === null) {
267
+ throw new Error(`Fast confirmation produced root not in protoArray: ${confirmedRoot}`);
268
+ }
269
+ this.fcStore.notifyFastConfirmation?.({
270
+ block: confirmedRoot,
271
+ slot: confirmedBlock.slot,
272
+ currentSlot: this.fcStore.currentSlot,
273
+ });
274
+ }
275
+
233
276
  /**
234
277
  *
235
278
  * A multiplexer to wrap around the traditional `updateHead()` according to the scenario
@@ -455,30 +498,12 @@ export class ForkChoice implements IForkChoice {
455
498
  }
456
499
 
457
500
  // No reorg if headBlock is "not weak" ie. headBlock's weight exceeds (REORG_HEAD_WEIGHT_THRESHOLD = 20)% of total attester weight
458
- // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_head_weak
459
- const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, {
460
- slotsPerEpoch: SLOTS_PER_EPOCH,
461
- committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD,
462
- });
463
- const headNode = this.protoArray.getNode(headBlock.blockRoot, headBlock.payloadStatus);
464
- // If headNode is unavailable, give up reorg
465
- if (headNode === undefined || headNode.weight >= reorgThreshold) {
501
+ if (!this.isHeadWeak(headBlock.blockRoot)) {
466
502
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.HeadBlockNotWeak};
467
503
  }
468
504
 
469
505
  // No reorg if parentBlock is "not strong" ie. parentBlock's weight is less than or equal to (REORG_PARENT_WEIGHT_THRESHOLD = 160)% of total attester weight
470
- // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/phase0/fork-choice.md#is_parent_strong
471
- // For Gloas: measure support for the parent beacon block root regardless of its payload status by
472
- // looking up the PENDING variant.
473
- // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-is_parent_strong
474
- const parentThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, {
475
- slotsPerEpoch: SLOTS_PER_EPOCH,
476
- committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD,
477
- });
478
- const parentStrongVariant = isGloasBlock(parentBlock) ? PayloadStatus.PENDING : PayloadStatus.FULL;
479
- const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentStrongVariant);
480
- // If parentNode is unavailable, give up reorg
481
- if (parentNode === undefined || parentNode.weight <= parentThreshold) {
506
+ if (!this.isParentStrong(parentBlock.blockRoot)) {
482
507
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ParentBlockNotStrong};
483
508
  }
484
509
 
@@ -521,7 +546,7 @@ export class ForkChoice implements IForkChoice {
521
546
 
522
547
  const timer = computeDeltasMetrics?.duration.startTimer();
523
548
  const {
524
- deltas,
549
+ attestationDeltas,
525
550
  equivocatingValidators,
526
551
  oldInactiveValidators,
527
552
  newInactiveValidators,
@@ -537,8 +562,8 @@ export class ForkChoice implements IForkChoice {
537
562
  );
538
563
  timer?.();
539
564
 
540
- computeDeltasMetrics?.deltasCount.set(deltas.length);
541
- computeDeltasMetrics?.zeroDeltasCount.set(deltas.filter((d) => d === 0).length);
565
+ computeDeltasMetrics?.deltasCount.set(attestationDeltas.length);
566
+ computeDeltasMetrics?.zeroDeltasCount.set(attestationDeltas.filter((d) => d === 0).length);
542
567
  computeDeltasMetrics?.equivocatingValidators.set(equivocatingValidators);
543
568
  computeDeltasMetrics?.oldInactiveValidators.set(oldInactiveValidators);
544
569
  computeDeltasMetrics?.newInactiveValidators.set(newInactiveValidators);
@@ -564,7 +589,7 @@ export class ForkChoice implements IForkChoice {
564
589
 
565
590
  const currentSlot = this.fcStore.currentSlot;
566
591
  this.protoArray.applyScoreChanges({
567
- deltas,
592
+ attestationDeltas,
568
593
  proposerBoost,
569
594
  justifiedEpoch: this.fcStore.justified.checkpoint.epoch,
570
595
  justifiedRoot: this.fcStore.justified.checkpoint.rootHex,
@@ -598,6 +623,22 @@ export class ForkChoice implements IForkChoice {
598
623
  return this.protoArray.nodes.filter((node) => node.bestChild === undefined);
599
624
  }
600
625
 
626
+ /**
627
+ * weight is in EFFECTIVE_BALANCE_INCREMENTS not gwei.
628
+ * For compliance test use only
629
+ */
630
+ getViableHeads(): {root: RootHex; payloadStatus: PayloadStatus; weight: number}[] {
631
+ return this.protoArray.getViableHeads(this.fcStore.currentSlot);
632
+ }
633
+
634
+ /**
635
+ * The cached justified total active balance, in EFFECTIVE_BALANCE_INCREMENT units.
636
+ * For compliance test use only
637
+ */
638
+ getJustifiedTotalActiveBalanceByIncrement(): number {
639
+ return this.fcStore.justified.totalBalance;
640
+ }
641
+
601
642
  /** This is for the debug API only */
602
643
  getAllNodes(): ProtoNode[] {
603
644
  return this.protoArray.nodes;
@@ -1530,6 +1571,93 @@ export class ForkChoice implements IForkChoice {
1530
1571
  return headDependentRoot === blockDependentRoot;
1531
1572
  }
1532
1573
 
1574
+ /**
1575
+ * Return true if the block is "weak" ie. its weight is below REORG_HEAD_WEIGHT_THRESHOLD of the
1576
+ * total attester weight per slot.
1577
+ *
1578
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/phase0/fork-choice.md#is_head_weak
1579
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-is_head_weak
1580
+ */
1581
+ private isHeadWeak(blockRoot: RootHex): boolean {
1582
+ // The default variant is PENDING for gloas, FULL pre-gloas. PENDING is the variant gloas measures
1583
+ // support on, ie. support for the beacon block root regardless of its payload status.
1584
+ // Only ever called on a block already in fork choice, so a miss is a broken invariant.
1585
+ const node = this.protoArray.getNodeDefaultStatus(blockRoot);
1586
+ if (node === undefined) {
1587
+ // this is called for head so we should always have this in forkchoice, otherwise we have a serious error
1588
+ throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot});
1589
+ }
1590
+
1591
+ const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, {
1592
+ slotsPerEpoch: SLOTS_PER_EPOCH,
1593
+ committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD,
1594
+ });
1595
+
1596
+ if (!isForkPostGloas(this.config.getForkName(node.slot))) {
1597
+ return node.weight < reorgThreshold;
1598
+ }
1599
+
1600
+ let headWeight = node.attestationScore;
1601
+
1602
+ const {equivocatingIndices} = this.fcStore;
1603
+ // Equivocators are extremely rare (none in normal operation), and with none the added weight is
1604
+ // always 0. Return before fetching the state and walking the block's committees.
1605
+ if (equivocatingIndices.size > 0) {
1606
+ const state = this.fcStore.stateGetter({stateRoot: node.stateRoot});
1607
+ // Only ever called on the head, so the state is always cached.
1608
+ // A miss is a broken invariant, not a recoverable state.
1609
+ if (state === null) {
1610
+ throw new ForkChoiceError({
1611
+ code: ForkChoiceErrorCode.BEACON_STATE_ERROR,
1612
+ error: new Error(`Missing state for isHeadWeak, blockRoot=${blockRoot} stateRoot=${node.stateRoot}`),
1613
+ });
1614
+ }
1615
+
1616
+ const epoch = computeEpochAtSlot(node.slot);
1617
+ for (let index = 0; index < state.getBeaconCommitteeCountPerSlot(epoch); index++) {
1618
+ for (const validatorIndex of state.getBeaconCommittee(node.slot, index)) {
1619
+ if (equivocatingIndices.has(validatorIndex)) {
1620
+ // the spec specifies to use effective_balance of the justified state
1621
+ let balance = this.fcStore.justified.balances[validatorIndex];
1622
+ if (!balance) {
1623
+ // 0 (zeroed by getEffectiveBalanceIncrementsZeroInactive) or undefined (validator not in
1624
+ // the justified state) - fall back to the head state's effective balance
1625
+ balance = state.effectiveBalanceIncrements[validatorIndex];
1626
+ }
1627
+ headWeight += balance;
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ return headWeight < reorgThreshold;
1634
+ }
1635
+
1636
+ /**
1637
+ * Return true if the parent block is "strong" ie. its weight exceeds REORG_PARENT_WEIGHT_THRESHOLD
1638
+ * of the total attester weight per slot.
1639
+ *
1640
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/phase0/fork-choice.md#is_parent_strong
1641
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#modified-is_parent_strong
1642
+ */
1643
+ private isParentStrong(parentRoot: RootHex): boolean {
1644
+ const node = this.protoArray.getNodeDefaultStatus(parentRoot);
1645
+ // If parentNode is unavailable, give up reorg
1646
+ if (node === undefined) {
1647
+ return false;
1648
+ }
1649
+
1650
+ const parentThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, {
1651
+ slotsPerEpoch: SLOTS_PER_EPOCH,
1652
+ committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD,
1653
+ });
1654
+
1655
+ // pre-gloas uses get_weight() (boost-inclusive), gloas uses get_attestation_score() (boost-excluded)
1656
+ const parentWeight = isForkPostGloas(this.config.getForkName(node.slot)) ? node.attestationScore : node.weight;
1657
+
1658
+ return parentWeight > parentThreshold;
1659
+ }
1660
+
1533
1661
  /**
1534
1662
  * Return true if the block is timely for the current slot.
1535
1663
  * Child class can overwrite this for testing purpose.
@@ -1982,9 +2110,23 @@ export class ForkChoice implements IForkChoice {
1982
2110
  }
1983
2111
 
1984
2112
  private runFastConfirmation(): void {
1985
- withObservedDuration(this.metrics?.fastConfirmation.totalDuration.startTimer(), () => {
1986
- if (!this.fastConfirmationRule || !this.fastConfirmationContext) return;
2113
+ const fastConfirmationRule = this.fastConfirmationRule;
2114
+ const fastConfirmationContext = this.fastConfirmationContext;
2115
+ if (!fastConfirmationRule || !fastConfirmationContext) return;
1987
2116
 
2117
+ if (this.fastConfirmationPaused) {
2118
+ // Keep consumers on a safe, available root while the rule is paused
2119
+ this.fcStore.confirmedRoot = this.fcStore.finalizedCheckpoint.rootHex;
2120
+ try {
2121
+ this.notifyConfirmedRoot();
2122
+ } catch (err) {
2123
+ // Runs outside the timed try/catch below; a throw would escape to the clock listener
2124
+ this.logger?.debug("Fast confirmation notify failed", {slot: this.fcStore.currentSlot}, err as Error);
2125
+ }
2126
+ return;
2127
+ }
2128
+
2129
+ withObservedDuration(this.metrics?.fastConfirmation.totalDuration.startTimer(), () => {
1988
2130
  try {
1989
2131
  withObservedDuration(
1990
2132
  this.metrics?.fastConfirmation.stepsDuration.startTimer({
@@ -1993,18 +2135,9 @@ export class ForkChoice implements IForkChoice {
1993
2135
  () => this.updateHead()
1994
2136
  );
1995
2137
 
1996
- const result = this.fastConfirmationRule.onSlotStartAfterPastAttestationsApplied(this.fastConfirmationContext);
2138
+ const result = fastConfirmationRule.onSlotStartAfterPastAttestationsApplied(fastConfirmationContext);
1997
2139
  this.fcStore.confirmedRoot = result.confirmedRoot;
1998
-
1999
- const confirmedBlock = this.getBlockHexDefaultStatus(result.confirmedRoot);
2000
- if (confirmedBlock === null) {
2001
- throw new Error(`Fast confirmation produced root not in protoArray: ${result.confirmedRoot}`);
2002
- }
2003
- this.fcStore.notifyFastConfirmation?.({
2004
- block: result.confirmedRoot,
2005
- slot: confirmedBlock.slot,
2006
- currentSlot: this.fcStore.currentSlot,
2007
- });
2140
+ this.notifyConfirmedRoot();
2008
2141
  } catch (err) {
2009
2142
  this.logger?.debug(
2010
2143
  "Fast confirmation failed",
@@ -100,6 +100,10 @@ export interface IForkChoice {
100
100
  getHead(): ProtoBlock;
101
101
  getConfirmedRoot(): RootHex;
102
102
  getConfirmedBlock(): ProtoBlock | null;
103
+ /** Resume the fast confirmation rule; restarts from the finalized root on the next slot tick */
104
+ resumeFastConfirmation(): void;
105
+ /** Pause the fast confirmation rule (e.g. while syncing); pins the confirmed root to the finalized root */
106
+ pauseFastConfirmation(): void;
103
107
  updateAndGetHead(mode: UpdateAndGetHeadOpt): {
104
108
  head: ProtoBlock;
105
109
  isHeadTimely?: boolean;
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export {
17
17
  type IFastConfirmationStore,
18
18
  getFastConfirmationMetrics,
19
19
  } from "./forkChoice/fastConfirmation/fastConfirmationRule.ts";
20
- export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt} from "./forkChoice/forkChoice.js";
20
+ export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt, getCommitteeFraction} from "./forkChoice/forkChoice.js";
21
21
  export {
22
22
  type AncestorResult,
23
23
  AncestorStatus,
@@ -4,10 +4,10 @@ import {ProtoArrayError, ProtoArrayErrorCode} from "./errors.js";
4
4
  import {NULL_VOTE_INDEX, VoteIndex} from "./interface.js";
5
5
 
6
6
  // reuse arrays to avoid memory reallocation and gc
7
- const deltas = new Array<number>();
7
+ const attestationDeltas = new Array<number>();
8
8
 
9
9
  export type DeltasResult = {
10
- deltas: number[];
10
+ attestationDeltas: number[];
11
11
  equivocatingValidators: number;
12
12
  // inactive validators before beacon node started
13
13
  oldInactiveValidators: number;
@@ -19,9 +19,9 @@ export type DeltasResult = {
19
19
  };
20
20
 
21
21
  /**
22
- * Returns a list of `deltas`, where there is one delta for each of the indices in `indices`
22
+ * Returns a list of `attestationDeltas`, where there is one delta for each of the indices in `indices`
23
23
  *
24
- * The deltas are formed by a change between `oldBalances` and `newBalances`, and/or a change of vote in `votes`.
24
+ * The attestationDeltas are formed by a change between `oldBalances` and `newBalances`, and/or a change of vote in `votes`.
25
25
  *
26
26
  * ## Errors
27
27
  *
@@ -46,8 +46,8 @@ export function computeDeltas(
46
46
  throw new Error(`numProtoNodes must be less than NULL_VOTE_INDEX: ${numProtoNodes} >= ${NULL_VOTE_INDEX}`);
47
47
  }
48
48
 
49
- deltas.length = numProtoNodes;
50
- deltas.fill(0);
49
+ attestationDeltas.length = numProtoNodes;
50
+ attestationDeltas.fill(0);
51
51
 
52
52
  // avoid creating new variables in the loop to potentially reduce GC pressure
53
53
  let oldBalance: number, newBalance: number;
@@ -80,7 +80,7 @@ export function computeDeltas(
80
80
  });
81
81
  }
82
82
  oldBalance = oldBalances[vIndex] ?? 0;
83
- deltas[currentIndex] -= oldBalance;
83
+ attestationDeltas[currentIndex] -= oldBalance;
84
84
  }
85
85
  voteCurrentIndices[vIndex] = NULL_VOTE_INDEX;
86
86
  equivocatingIndex++;
@@ -125,7 +125,7 @@ export function computeDeltas(
125
125
  });
126
126
  }
127
127
 
128
- deltas[currentIndex] -= oldBalance;
128
+ attestationDeltas[currentIndex] -= oldBalance;
129
129
  }
130
130
 
131
131
  // We ignore the vote if it is not known in `indices .
@@ -138,7 +138,7 @@ export function computeDeltas(
138
138
  });
139
139
  }
140
140
 
141
- deltas[nextIndex] += newBalance;
141
+ attestationDeltas[nextIndex] += newBalance;
142
142
  }
143
143
  voteCurrentIndices[vIndex] = nextIndex;
144
144
  newVoteValidators++;
@@ -147,13 +147,13 @@ export function computeDeltas(
147
147
  }
148
148
  } // end validator loop
149
149
 
150
- if (deltas.length !== numProtoNodes) {
151
- // deltas array could be growed in the loop, especially if we mistakenly set the [NULL_VOTE_INDEX] to it , just to be safe
152
- throw new Error(`deltas length mismatch: expected ${numProtoNodes}, got ${deltas.length}`);
150
+ if (attestationDeltas.length !== numProtoNodes) {
151
+ // attestationDeltas array could be growed in the loop, especially if we mistakenly set the [NULL_VOTE_INDEX] to it , just to be safe
152
+ throw new Error(`attestationDeltas length mismatch: expected ${numProtoNodes}, got ${attestationDeltas.length}`);
153
153
  }
154
154
 
155
155
  return {
156
- deltas,
156
+ attestationDeltas,
157
157
  equivocatingValidators,
158
158
  oldInactiveValidators,
159
159
  newInactiveValidators,
@@ -160,7 +160,13 @@ export type ProtoBlock = BlockExtraMeta & {
160
160
  */
161
161
  export type ProtoNode = ProtoBlock & {
162
162
  parent?: number;
163
+ /** Total weight, ie. attestationScore plus the proposer boost credited to this node */
163
164
  weight: number;
165
+ /**
166
+ * Weight from attester votes only, excluding proposer boost.
167
+ * Spec: get_attestation_score
168
+ */
169
+ attestationScore: number;
164
170
  bestChild?: number;
165
171
  bestDescendant?: number;
166
172
  };
@@ -27,6 +27,12 @@ const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2);
27
27
  */
28
28
  const DATA_AVAILABILITY_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2);
29
29
 
30
+ /**
31
+ * Proposer boost deltas, back-propagated to the boosted node's ancestors in applyScoreChanges().
32
+ * Reuse the array to avoid memory reallocation and gc, as computeDeltas does for attestation deltas.
33
+ */
34
+ const boostDeltas = new Array<number>();
35
+
30
36
  /**
31
37
  * popcount(attended AND NOT yes) — explicit False-vote count.
32
38
  * Excludes PTC members who didn't attest (the None state).
@@ -348,7 +354,7 @@ export class ProtoArray {
348
354
  * - If required, update the parents best-descendant with the current node or its best-descendant.
349
355
  */
350
356
  applyScoreChanges({
351
- deltas,
357
+ attestationDeltas,
352
358
  proposerBoost,
353
359
  justifiedEpoch,
354
360
  justifiedRoot,
@@ -356,7 +362,7 @@ export class ProtoArray {
356
362
  finalizedRoot,
357
363
  currentSlot,
358
364
  }: {
359
- deltas: number[];
365
+ attestationDeltas: number[];
360
366
  proposerBoost: ProposerBoost | null;
361
367
  justifiedEpoch: Epoch;
362
368
  justifiedRoot: RootHex;
@@ -364,14 +370,17 @@ export class ProtoArray {
364
370
  finalizedRoot: RootHex;
365
371
  currentSlot: Slot;
366
372
  }): void {
367
- if (deltas.length !== this.nodes.length) {
373
+ if (attestationDeltas.length !== this.nodes.length) {
368
374
  throw new ProtoArrayError({
369
375
  code: ProtoArrayErrorCode.INVALID_DELTA_LEN,
370
- deltas: deltas.length,
376
+ deltas: attestationDeltas.length,
371
377
  indices: this.nodes.length,
372
378
  });
373
379
  }
374
380
 
381
+ boostDeltas.length = this.nodes.length;
382
+ boostDeltas.fill(0);
383
+
375
384
  if (
376
385
  justifiedEpoch !== this.justifiedEpoch ||
377
386
  finalizedEpoch !== this.finalizedEpoch ||
@@ -416,18 +425,22 @@ export class ProtoArray {
416
425
  // If this node's execution status has been marked invalid, then the weight of the node
417
426
  // needs to be taken out of consideration after which the node weight will become 0
418
427
  // for subsequent iterations of applyScoreChanges
419
- const nodeDelta =
420
- node.executionStatus === ExecutionStatus.Invalid
421
- ? -node.weight
422
- : deltas[nodeIndex] + currentBoost - previousBoost;
423
-
424
- // Apply the delta to the node
425
- node.weight += nodeDelta;
426
-
427
- // Update the parent delta (if any)
428
+ const isInvalid = node.executionStatus === ExecutionStatus.Invalid;
429
+ const attestationDelta = isInvalid ? -node.attestationScore : attestationDeltas[nodeIndex];
430
+ const boostDelta = isInvalid
431
+ ? // old boost = weight - attestationScore
432
+ -(node.weight - node.attestationScore)
433
+ : boostDeltas[nodeIndex] + currentBoost - previousBoost;
434
+
435
+ // Apply the deltas to the node. Their sum is the node's total delta, so weight is unaffected
436
+ // by tracking the two scores apart.
437
+ node.attestationScore += attestationDelta;
438
+ node.weight += attestationDelta + boostDelta;
439
+
440
+ // Update the parent deltas (if any)
428
441
  const parentIndex = node.parent;
429
442
  if (parentIndex !== undefined) {
430
- const parentDelta = deltas[parentIndex];
443
+ const parentDelta = attestationDeltas[parentIndex];
431
444
  if (parentDelta === undefined) {
432
445
  throw new ProtoArrayError({
433
446
  code: ProtoArrayErrorCode.INVALID_PARENT_DELTA,
@@ -435,8 +448,9 @@ export class ProtoArray {
435
448
  });
436
449
  }
437
450
 
438
- // back-propagate the nodes delta to its parent
439
- deltas[parentIndex] += nodeDelta;
451
+ // back-propagate the nodes deltas to its parent
452
+ attestationDeltas[parentIndex] += attestationDelta;
453
+ boostDeltas[parentIndex] += boostDelta;
440
454
  }
441
455
  }
442
456
 
@@ -514,6 +528,7 @@ export class ProtoArray {
514
528
  parent: parentIndex, // Points to parent's EMPTY/FULL or FULL (for transition)
515
529
  payloadStatus: PayloadStatus.PENDING,
516
530
  weight: 0,
531
+ attestationScore: 0,
517
532
  bestChild: undefined,
518
533
  bestDescendant: undefined,
519
534
  };
@@ -527,6 +542,7 @@ export class ProtoArray {
527
542
  parent: pendingIndex, // Points to own PENDING
528
543
  payloadStatus: PayloadStatus.EMPTY,
529
544
  weight: 0,
545
+ attestationScore: 0,
530
546
  bestChild: undefined,
531
547
  bestDescendant: undefined,
532
548
  };
@@ -562,6 +578,7 @@ export class ProtoArray {
562
578
  parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL),
563
579
  payloadStatus: PayloadStatus.FULL,
564
580
  weight: 0,
581
+ attestationScore: 0,
565
582
  bestChild: undefined,
566
583
  bestDescendant: undefined,
567
584
  };
@@ -647,6 +664,7 @@ export class ProtoArray {
647
664
  parent: pendingIndex, // Points to own PENDING (same as EMPTY)
648
665
  payloadStatus: PayloadStatus.FULL,
649
666
  weight: 0,
667
+ attestationScore: 0,
650
668
  bestChild: undefined,
651
669
  bestDescendant: undefined,
652
670
  executionStatus,
@@ -679,18 +697,11 @@ export class ProtoArray {
679
697
  getPayloadRevealCounts(fromSlot: Slot, toSlot: Slot): {blocksPresent: number; payloadsRevealed: number} {
680
698
  let blocksPresent = 0;
681
699
  let payloadsRevealed = 0;
682
- // Iterate backward as recent blocks are at the end of the nodes array. Only break on block
683
- // nodes below the window since FULL variants are appended late when the envelope is imported
684
- for (let i = this.nodes.length - 1; i >= 0; i--) {
685
- const node = this.nodes[i];
686
- if (node.slot < fromSlot) {
687
- if (isGloasBlock(node) && node.payloadStatus === PayloadStatus.FULL) {
688
- continue;
689
- }
690
- break;
691
- }
700
+ // Full scan, nodes are in import order not slot order (an old block can be imported after newer
701
+ // ones during sync or reorg resolution), so we cannot stop early on an out-of-window slot
702
+ for (const node of this.nodes) {
692
703
  // Count each gloas block once via its PENDING variant, pre-gloas nodes are FULL only
693
- if (node.slot > toSlot || node.payloadStatus !== PayloadStatus.PENDING) {
704
+ if (node.slot < fromSlot || node.slot > toSlot || node.payloadStatus !== PayloadStatus.PENDING) {
694
705
  continue;
695
706
  }
696
707
  blocksPresent++;
@@ -1059,7 +1070,7 @@ export class ProtoArray {
1059
1070
 
1060
1071
  // update the forkchoice as the invalidation can change the entire forkchoice DAG
1061
1072
  this.applyScoreChanges({
1062
- deltas: Array.from({length: this.nodes.length}, () => 0),
1073
+ attestationDeltas: Array.from({length: this.nodes.length}, () => 0),
1063
1074
  proposerBoost: this.previousProposerBoost,
1064
1075
  justifiedEpoch: this.justifiedEpoch,
1065
1076
  justifiedRoot: this.justifiedRoot,
@@ -1570,6 +1581,38 @@ export class ProtoArray {
1570
1581
  return correctJustified && correctFinalized;
1571
1582
  }
1572
1583
 
1584
+ /** Weights are in EFFECTIVE_BALANCE_INCREMENT units (NOT Gwei); callers scale as needed. */
1585
+ getViableHeads(currentSlot: Slot): {root: RootHex; payloadStatus: PayloadStatus; weight: number}[] {
1586
+ // Mirror the spec's `get_filtered_block_tree`, which is rooted at the store's justified
1587
+ // checkpoint: a viable head is a leaf (no viable descendant, i.e. `bestChild === undefined`)
1588
+ // that descends from the justified checkpoint block AND is itself viable for head. Iterating
1589
+ // all nodes without the justified-descendant filter would wrongly include FFG-viable leaves
1590
+ // that hang off the finalized checkpoint on a branch not under the current justified checkpoint.
1591
+ const justifiedVariant = this.getDefaultVariant(this.justifiedRoot);
1592
+ // Gloas payload-status variants of one blockRoot are distinct nodes in the spec's filtered
1593
+ // tree, identified by (root, payload_status, weight) — emit one entry per variant.
1594
+ const heads: {root: RootHex; payloadStatus: PayloadStatus; weight: number}[] = [];
1595
+ for (const node of this.nodes) {
1596
+ if (node.bestChild !== undefined || !this.nodeIsViableForHead(node, currentSlot)) {
1597
+ continue;
1598
+ }
1599
+ if (this.justifiedEpoch !== GENESIS_EPOCH) {
1600
+ // Same-root short-circuit: every payload-status variant of the justified block itself is
1601
+ // in the filtered tree, but `isDescendant` from the default (PENDING) variant does not
1602
+ // reach the sibling EMPTY/FULL variants of the same root.
1603
+ const descendsFromJustified =
1604
+ node.blockRoot === this.justifiedRoot ||
1605
+ (justifiedVariant !== undefined &&
1606
+ this.isDescendant(this.justifiedRoot, justifiedVariant, node.blockRoot, node.payloadStatus));
1607
+ if (!descendsFromJustified) {
1608
+ continue;
1609
+ }
1610
+ }
1611
+ heads.push({root: node.blockRoot, payloadStatus: node.payloadStatus, weight: node.weight});
1612
+ }
1613
+ return heads;
1614
+ }
1615
+
1573
1616
  /**
1574
1617
  * Return `true` if `node` is equal to or a descendant of the finalized node.
1575
1618
  * This function helps improve performance of nodeIsViableForHead a lot by avoiding
@@ -1927,6 +1970,19 @@ export class ProtoArray {
1927
1970
  return this.getNodeByIndex(blockIndex);
1928
1971
  }
1929
1972
 
1973
+ /**
1974
+ * Return ProtoNode for the default/canonical variant in a single hash lookup
1975
+ * - Pre-Gloas blocks: the FULL variant
1976
+ * - Gloas blocks: the PENDING variant
1977
+ */
1978
+ getNodeDefaultStatus(blockRoot: RootHex): ProtoNode | undefined {
1979
+ const nodeIndex = this.getDefaultNodeIndex(blockRoot);
1980
+ if (nodeIndex === undefined) {
1981
+ return undefined;
1982
+ }
1983
+ return this.getNodeByIndex(nodeIndex);
1984
+ }
1985
+
1930
1986
  /**
1931
1987
  * Return MUTABLE ProtoBlock for blockRoot with explicit payload status
1932
1988
  *