@lodestar/fork-choice 1.45.0-dev.51a1c44b27 → 1.45.0-dev.6568180f96

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 (49) hide show
  1. package/lib/forkChoice/fastConfirmation/data.d.ts.map +1 -1
  2. package/lib/forkChoice/fastConfirmation/data.js +1 -0
  3. package/lib/forkChoice/fastConfirmation/data.js.map +1 -1
  4. package/lib/forkChoice/fastConfirmation/metrics.d.ts +1 -0
  5. package/lib/forkChoice/fastConfirmation/metrics.d.ts.map +1 -1
  6. package/lib/forkChoice/fastConfirmation/metrics.js +4 -0
  7. package/lib/forkChoice/fastConfirmation/metrics.js.map +1 -1
  8. package/lib/forkChoice/fastConfirmation/types.d.ts +3 -0
  9. package/lib/forkChoice/fastConfirmation/types.d.ts.map +1 -1
  10. package/lib/forkChoice/fastConfirmation/types.js.map +1 -1
  11. package/lib/forkChoice/fastConfirmation/utils.d.ts +3 -3
  12. package/lib/forkChoice/fastConfirmation/utils.d.ts.map +1 -1
  13. package/lib/forkChoice/fastConfirmation/utils.js +27 -19
  14. package/lib/forkChoice/fastConfirmation/utils.js.map +1 -1
  15. package/lib/forkChoice/forkChoice.d.ts +46 -7
  16. package/lib/forkChoice/forkChoice.d.ts.map +1 -1
  17. package/lib/forkChoice/forkChoice.js +178 -55
  18. package/lib/forkChoice/forkChoice.js.map +1 -1
  19. package/lib/forkChoice/interface.d.ts +18 -6
  20. package/lib/forkChoice/interface.d.ts.map +1 -1
  21. package/lib/forkChoice/interface.js +1 -1
  22. package/lib/forkChoice/interface.js.map +1 -1
  23. package/lib/index.d.ts +1 -1
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +1 -1
  26. package/lib/index.js.map +1 -1
  27. package/lib/metrics.d.ts +1 -0
  28. package/lib/metrics.d.ts.map +1 -1
  29. package/lib/protoArray/computeDeltas.d.ts +3 -3
  30. package/lib/protoArray/computeDeltas.d.ts.map +1 -1
  31. package/lib/protoArray/computeDeltas.js +31 -27
  32. package/lib/protoArray/computeDeltas.js.map +1 -1
  33. package/lib/protoArray/interface.d.ts +6 -0
  34. package/lib/protoArray/interface.d.ts.map +1 -1
  35. package/lib/protoArray/protoArray.d.ts +28 -3
  36. package/lib/protoArray/protoArray.d.ts.map +1 -1
  37. package/lib/protoArray/protoArray.js +106 -18
  38. package/lib/protoArray/protoArray.js.map +1 -1
  39. package/package.json +9 -9
  40. package/src/forkChoice/fastConfirmation/data.ts +1 -0
  41. package/src/forkChoice/fastConfirmation/metrics.ts +4 -0
  42. package/src/forkChoice/fastConfirmation/types.ts +4 -0
  43. package/src/forkChoice/fastConfirmation/utils.ts +39 -16
  44. package/src/forkChoice/forkChoice.ts +193 -57
  45. package/src/forkChoice/interface.ts +15 -16
  46. package/src/index.ts +1 -1
  47. package/src/protoArray/computeDeltas.ts +33 -29
  48. package/src/protoArray/interface.ts +6 -0
  49. package/src/protoArray/protoArray.ts +117 -22
@@ -18,6 +18,7 @@ import {
18
18
  FastConfirmationContext,
19
19
  FastConfirmationSnapshot,
20
20
  IFastConfirmationStore,
21
+ type TotalActiveBalanceCacheKey,
21
22
  } from "./types.ts";
22
23
 
23
24
  // Spec: adjust_committee_weight_estimate_to_ensure_safety
@@ -266,22 +267,34 @@ export function getPreviousBalanceSource(
266
267
  return getBalanceSource(store, cache, "previous");
267
268
  }
268
269
 
269
- export function getTotalActiveBalance(balanceSource: FastConfirmationBalanceSource): number {
270
- if (balanceSource.state) {
271
- return computeTotalBalance(balanceSource.state.getEffectiveBalanceIncrementsZeroInactive());
272
- }
273
- // Fallback balances come from the justified-balance path and already zero inactive
274
- // validators, so summing them gives the active justified total for this balance source.
275
- return computeTotalBalance(balanceSource.balances);
270
+ export function getTotalActiveBalance(
271
+ balanceSource: FastConfirmationBalanceSource,
272
+ cache: FastConfirmationCache
273
+ ): number {
274
+ // Invariant per balance source within a run, but read many times per is_one_confirmed evaluation
275
+ // across the epoch-boundary chain walk; memoize so the full validator-set scan runs once per run.
276
+ const key: TotalActiveBalanceCacheKey = balanceSource.state ?? balanceSource.balances;
277
+ const cached = cache.totalActiveBalanceByKey.get(key);
278
+ if (cached !== undefined) return cached;
279
+
280
+ const total = balanceSource.state
281
+ ? computeTotalBalance(balanceSource.state.getEffectiveBalanceIncrementsZeroInactive())
282
+ : // Fallback balances come from the justified-balance path and already zero inactive
283
+ // validators, so summing them gives the active justified total for this balance source.
284
+ computeTotalBalance(balanceSource.balances);
285
+
286
+ cache.totalActiveBalanceByKey.set(key, total);
287
+ return total;
276
288
  }
277
289
 
278
290
  export function estimateCommitteeWeightBetweenSlots(
279
291
  balanceSource: FastConfirmationBalanceSource,
292
+ cache: FastConfirmationCache,
280
293
  startSlot: Slot,
281
294
  endSlot: Slot
282
295
  ): number {
283
296
  if (startSlot > endSlot) return 0;
284
- const totalActiveBalance = getTotalActiveBalance(balanceSource);
297
+ const totalActiveBalance = getTotalActiveBalance(balanceSource, cache);
285
298
  const startEpoch = computeEpochAtSlot(startSlot);
286
299
  const endEpoch = computeEpochAtSlot(endSlot);
287
300
 
@@ -334,9 +347,10 @@ export function isFullValidatorSetCovered(startSlot: Slot, endSlot: Slot): boole
334
347
 
335
348
  export function computeProposerScore(
336
349
  ctx: FastConfirmationContext,
337
- balanceSource: FastConfirmationBalanceSource
350
+ balanceSource: FastConfirmationBalanceSource,
351
+ cache: FastConfirmationCache
338
352
  ): number {
339
- const totalActiveBalance = getTotalActiveBalance(balanceSource);
353
+ const totalActiveBalance = getTotalActiveBalance(balanceSource, cache);
340
354
  const committeeWeight = Math.floor(totalActiveBalance / SLOTS_PER_EPOCH);
341
355
  return Math.floor((committeeWeight * ctx.config.PROPOSER_SCORE_BOOST) / 100);
342
356
  }
@@ -469,7 +483,7 @@ export function computeAdversarialWeight(
469
483
  startSlot: Slot,
470
484
  endSlot: Slot
471
485
  ): number {
472
- const maximumWeight = estimateCommitteeWeightBetweenSlots(balanceSource, startSlot, endSlot);
486
+ const maximumWeight = estimateCommitteeWeightBetweenSlots(balanceSource, cache, startSlot, endSlot);
473
487
  // The spec uses raw Gwei and computes `maximum_weight // 100 * threshold`.
474
488
  // Lodestar carries effective-balance increments instead, so divide after multiplying to avoid
475
489
  // dropping the adversarial budget to zero for small validator sets/minimal presets.
@@ -564,9 +578,10 @@ export function computeSafetyThreshold(
564
578
  // Spec: compute_safety_threshold(store, block_root, balance_source)
565
579
  // Build the threshold from the same terms used in the paper/spec:
566
580
  // max possible committee support, proposer boost, empty-slot discount, and adversarial budget.
567
- const proposerScore = computeProposerScore(ctx, balanceSource);
581
+ const proposerScore = computeProposerScore(ctx, balanceSource, cache);
568
582
  const maximumSupport = estimateCommitteeWeightBetweenSlots(
569
583
  balanceSource,
584
+ cache,
570
585
  (parentBlock.slot + 1) as Slot,
571
586
  (currentSlot - 1) as Slot
572
587
  );
@@ -711,10 +726,12 @@ export function computeHonestFfgSupportForCurrentTarget(
711
726
  const currentEpoch = computeEpochAtSlot(currentSlot);
712
727
  const targetState = getCurrentEpochState(ctx, store, cache);
713
728
  if (!targetState) return 0;
714
- const totalActiveBalance = computeTotalBalance(targetState.getEffectiveBalanceIncrementsZeroInactive());
729
+ const targetBalanceSource = {state: targetState, balances: targetState.effectiveBalanceIncrements};
730
+ const totalActiveBalance = getTotalActiveBalance(targetBalanceSource, cache);
715
731
  const ffgSupport = getCurrentTargetScore(ctx, store, cache);
716
732
  const tillNowFFGWeight = estimateCommitteeWeightBetweenSlots(
717
- {state: targetState, balances: targetState.effectiveBalanceIncrements},
733
+ targetBalanceSource,
734
+ cache,
718
735
  computeStartSlotAtEpoch(currentEpoch),
719
736
  (currentSlot - 1) as Slot
720
737
  );
@@ -747,7 +764,10 @@ export function willNoConflictingCheckpointBeJustified(
747
764
  }
748
765
  const targetState = getCurrentEpochState(ctx, store, cache);
749
766
  if (!targetState) return false;
750
- const totalActiveBalance = computeTotalBalance(targetState.getEffectiveBalanceIncrementsZeroInactive());
767
+ const totalActiveBalance = getTotalActiveBalance(
768
+ {state: targetState, balances: targetState.effectiveBalanceIncrements},
769
+ cache
770
+ );
751
771
  const honestSupport = computeHonestFfgSupportForCurrentTarget(ctx, store, cache);
752
772
  return 3 * honestSupport > 1 * totalActiveBalance;
753
773
  }
@@ -759,7 +779,10 @@ export function willCurrentTargetBeJustified(
759
779
  ): boolean {
760
780
  const targetState = getCurrentEpochState(ctx, store, cache);
761
781
  if (!targetState) return false;
762
- const totalActiveBalance = computeTotalBalance(targetState.getEffectiveBalanceIncrementsZeroInactive());
782
+ const totalActiveBalance = getTotalActiveBalance(
783
+ {state: targetState, balances: targetState.effectiveBalanceIncrements},
784
+ cache
785
+ );
763
786
  const honestSupport = computeHonestFfgSupportForCurrentTarget(ctx, store, cache);
764
787
  return 3 * honestSupport >= 2 * totalActiveBalance;
765
788
  }
@@ -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,26 +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.6.1/specs/phase0/fork-choice.md#is_parent_strong
471
- const parentThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, {
472
- slotsPerEpoch: SLOTS_PER_EPOCH,
473
- committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD,
474
- });
475
- const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentBlock.payloadStatus);
476
- // If parentNode is unavailable, give up reorg
477
- if (parentNode === undefined || parentNode.weight <= parentThreshold) {
506
+ if (!this.isParentStrong(parentBlock.blockRoot)) {
478
507
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ParentBlockNotStrong};
479
508
  }
480
509
 
@@ -517,7 +546,7 @@ export class ForkChoice implements IForkChoice {
517
546
 
518
547
  const timer = computeDeltasMetrics?.duration.startTimer();
519
548
  const {
520
- deltas,
549
+ attestationDeltas,
521
550
  equivocatingValidators,
522
551
  oldInactiveValidators,
523
552
  newInactiveValidators,
@@ -533,8 +562,8 @@ export class ForkChoice implements IForkChoice {
533
562
  );
534
563
  timer?.();
535
564
 
536
- computeDeltasMetrics?.deltasCount.set(deltas.length);
537
- 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);
538
567
  computeDeltasMetrics?.equivocatingValidators.set(equivocatingValidators);
539
568
  computeDeltasMetrics?.oldInactiveValidators.set(oldInactiveValidators);
540
569
  computeDeltasMetrics?.newInactiveValidators.set(newInactiveValidators);
@@ -560,7 +589,7 @@ export class ForkChoice implements IForkChoice {
560
589
 
561
590
  const currentSlot = this.fcStore.currentSlot;
562
591
  this.protoArray.applyScoreChanges({
563
- deltas,
592
+ attestationDeltas,
564
593
  proposerBoost,
565
594
  justifiedEpoch: this.fcStore.justified.checkpoint.epoch,
566
595
  justifiedRoot: this.fcStore.justified.checkpoint.rootHex,
@@ -585,11 +614,31 @@ export class ForkChoice implements IForkChoice {
585
614
  return this.protoArray.nodes.filter((node) => node.slot > windowStart).length;
586
615
  }
587
616
 
617
+ getPayloadRevealCounts(fromSlot: Slot, toSlot: Slot): {blocksPresent: number; payloadsRevealed: number} {
618
+ return this.protoArray.getPayloadRevealCounts(fromSlot, toSlot);
619
+ }
620
+
588
621
  /** Very expensive function, iterates the entire ProtoArray. Called only in debug API */
589
622
  getHeads(): ProtoBlock[] {
590
623
  return this.protoArray.nodes.filter((node) => node.bestChild === undefined);
591
624
  }
592
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
+
593
642
  /** This is for the debug API only */
594
643
  getAllNodes(): ProtoNode[] {
595
644
  return this.protoArray.nodes;
@@ -628,11 +677,7 @@ export class ForkChoice implements IForkChoice {
628
677
  blockDelaySec: number,
629
678
  currentSlot: Slot,
630
679
  executionStatus: BlockExecutionStatus,
631
- dataAvailabilityStatus: DataAvailabilityStatus,
632
- // The expected proposer index on the canonical chain we are following.
633
- // Calculated by our head state. We use it as part of the proposer
634
- // boost decision making. No boost will be set if this is null.
635
- expectedProposerIndex: ValidatorIndex | null
680
+ dataAvailabilityStatus: DataAvailabilityStatus
636
681
  ): ProtoBlock {
637
682
  const {parentRoot, slot} = block;
638
683
  const parentRootHex = toRootHex(parentRoot);
@@ -707,8 +752,6 @@ export class ForkChoice implements IForkChoice {
707
752
  isTimely &&
708
753
  // only boost the first block we see
709
754
  this.proposerBoostRoot === null &&
710
- expectedProposerIndex !== null &&
711
- block.proposerIndex === expectedProposerIndex &&
712
755
  this.isProposerBoostSameDependentRoot(this.head.blockRoot, parentRootHex);
713
756
  // Candidate boost root used for protoArray.onBlock's best-child weighting. Committed to the
714
757
  // store only after the insertion succeeds.
@@ -901,14 +944,15 @@ export class ForkChoice implements IForkChoice {
901
944
 
902
945
  this.validateOnAttestation(attestation, slot, blockRootHex, targetEpoch, attDataRoot, forceImport);
903
946
 
904
- // Pre-gloas: payload is always present
947
+ // Determine which variant the attestation supports
948
+ //
949
+ // Pre-gloas: payload is always present, vote goes to FULL.
905
950
  // Post-gloas:
906
- // - always add weight to PENDING
907
- // - if message.slot > block.slot, it also add weights to FULL or EMPTY
951
+ // - block.slot < message.slot: EMPTY if data.index is 0 and FULL if data.index is 1.
952
+ // - else: PENDING
953
+ //
954
+ // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-get_supported_node
908
955
  let payloadStatus: PayloadStatus;
909
-
910
- // We need to retrieve block to check if it's Gloas and to compare slot
911
- // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-is_supporting_vote
912
956
  const block = this.getBlockHexDefaultStatus(blockRootHex);
913
957
 
914
958
  if (block && isGloasBlock(block)) {
@@ -1500,11 +1544,11 @@ export class ForkChoice implements IForkChoice {
1500
1544
  }
1501
1545
 
1502
1546
  /**
1503
- * Spec: phase0/fork-choice.md#update_proposer_boost_root (`is_same_dependent_root` condition,
1504
- * consensus-specs #5306). Proposer boost is only granted when the imported block shares the same
1505
- * proposer-shuffling dependent root for the current epoch as the canonical head computed before
1506
- * the block was imported. This withholds the boost from a block built on a different shuffling
1507
- * branch than the head.
1547
+ * Spec: phase0/fork-choice.md#update_proposer_boost_root (`is_same_dependent_root` condition, added in
1548
+ * https://github.com/ethereum/consensus-specs/pull/5306). Proposer boost is only granted when the imported
1549
+ * block shares the same proposer-shuffling dependent root for the current epoch as the canonical head
1550
+ * computed before the block was imported. This withholds the boost from a block built on a different
1551
+ * shuffling branch than the head.
1508
1552
  *
1509
1553
  * The block is not yet in the proto-array when this runs, so its dependent root is traced from
1510
1554
  * its parent
@@ -1527,6 +1571,93 @@ export class ForkChoice implements IForkChoice {
1527
1571
  return headDependentRoot === blockDependentRoot;
1528
1572
  }
1529
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
+
1530
1661
  /**
1531
1662
  * Return true if the block is timely for the current slot.
1532
1663
  * Child class can overwrite this for testing purpose.
@@ -1943,11 +2074,11 @@ export class ForkChoice implements IForkChoice {
1943
2074
  return {prelimProposerHead, prelimNotReorgedReason: NotReorgedReason.HeadBlockIsTimely};
1944
2075
  }
1945
2076
 
1946
- // No reorg if we are at epoch boundary where proposer shuffling could change
1947
- // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_shuffling_stable
1948
- const isShufflingStable = slot % SLOTS_PER_EPOCH !== 0;
1949
- if (!isShufflingStable) {
1950
- return {prelimProposerHead, prelimNotReorgedReason: NotReorgedReason.NotShufflingStable};
2077
+ // No reorg if we are at an epoch boundary
2078
+ // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/phase0/fork-choice.md#is_not_epoch_boundary
2079
+ const isAtEpochBoundary = slot % SLOTS_PER_EPOCH === 0;
2080
+ if (isAtEpochBoundary) {
2081
+ return {prelimProposerHead, prelimNotReorgedReason: NotReorgedReason.AtEpochBoundary};
1951
2082
  }
1952
2083
 
1953
2084
  // No reorg if headBlock and parentBlock are not ffg competitive
@@ -1979,9 +2110,23 @@ export class ForkChoice implements IForkChoice {
1979
2110
  }
1980
2111
 
1981
2112
  private runFastConfirmation(): void {
1982
- withObservedDuration(this.metrics?.fastConfirmation.totalDuration.startTimer(), () => {
1983
- if (!this.fastConfirmationRule || !this.fastConfirmationContext) return;
2113
+ const fastConfirmationRule = this.fastConfirmationRule;
2114
+ const fastConfirmationContext = this.fastConfirmationContext;
2115
+ if (!fastConfirmationRule || !fastConfirmationContext) return;
1984
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(), () => {
1985
2130
  try {
1986
2131
  withObservedDuration(
1987
2132
  this.metrics?.fastConfirmation.stepsDuration.startTimer({
@@ -1990,18 +2135,9 @@ export class ForkChoice implements IForkChoice {
1990
2135
  () => this.updateHead()
1991
2136
  );
1992
2137
 
1993
- const result = this.fastConfirmationRule.onSlotStartAfterPastAttestationsApplied(this.fastConfirmationContext);
2138
+ const result = fastConfirmationRule.onSlotStartAfterPastAttestationsApplied(fastConfirmationContext);
1994
2139
  this.fcStore.confirmedRoot = result.confirmedRoot;
1995
-
1996
- const confirmedBlock = this.getBlockHexDefaultStatus(result.confirmedRoot);
1997
- if (confirmedBlock === null) {
1998
- throw new Error(`Fast confirmation produced root not in protoArray: ${result.confirmedRoot}`);
1999
- }
2000
- this.fcStore.notifyFastConfirmation?.({
2001
- block: result.confirmedRoot,
2002
- slot: confirmedBlock.slot,
2003
- currentSlot: this.fcStore.currentSlot,
2004
- });
2140
+ this.notifyConfirmedRoot();
2005
2141
  } catch (err) {
2006
2142
  this.logger?.debug(
2007
2143
  "Fast confirmation failed",
@@ -1,14 +1,5 @@
1
1
  import {DataAvailabilityStatus, EffectiveBalanceIncrements, IBeaconStateView} from "@lodestar/state-transition";
2
- import {
3
- AttesterSlashing,
4
- BeaconBlock,
5
- Epoch,
6
- IndexedAttestation,
7
- Root,
8
- RootHex,
9
- Slot,
10
- ValidatorIndex,
11
- } from "@lodestar/types";
2
+ import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types";
12
3
  import {
13
4
  BlockExecutionStatus,
14
5
  LVHExecResponse,
@@ -62,7 +53,7 @@ export enum NotReorgedReason {
62
53
  HeadBlockIsTimely = "headBlockIsTimely",
63
54
  ParentBlockNotAvailable = "parentBlockNotAvailable",
64
55
  ProposerBoostReorgDisabled = "proposerBoostReorgDisabled",
65
- NotShufflingStable = "notShufflingStable",
56
+ AtEpochBoundary = "atEpochBoundary",
66
57
  NotFFGCompetitive = "notFFGCompetitive",
67
58
  ChainLongUnfinality = "chainLongUnfinality",
68
59
  ParentBlockDistanceMoreThanOneSlot = "parentBlockDistanceMoreThanOneSlot",
@@ -90,7 +81,7 @@ export interface IForkChoice {
90
81
  * ## Specification
91
82
  *
92
83
  * Modified for Gloas to return ProtoNode instead of just root:
93
- * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#modified-get_ancestor
84
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-get_ancestor
94
85
  *
95
86
  * Pre-Gloas: Returns (root, PAYLOAD_STATUS_FULL)
96
87
  * Gloas: Returns (root, payloadStatus) based on actual node state
@@ -109,6 +100,10 @@ export interface IForkChoice {
109
100
  getHead(): ProtoBlock;
110
101
  getConfirmedRoot(): RootHex;
111
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;
112
107
  updateAndGetHead(mode: UpdateAndGetHeadOpt): {
113
108
  head: ProtoBlock;
114
109
  isHeadTimely?: boolean;
@@ -160,8 +155,7 @@ export interface IForkChoice {
160
155
  blockDelaySec: number,
161
156
  currentSlot: Slot,
162
157
  executionStatus: BlockExecutionStatus,
163
- dataAvailabilityStatus: DataAvailabilityStatus,
164
- expectedProposerIndex: ValidatorIndex | null
158
+ dataAvailabilityStatus: DataAvailabilityStatus
165
159
  ): ProtoBlock;
166
160
  /**
167
161
  * Register `attestation` with the fork choice DAG so that it may influence future calls to `getHead`.
@@ -196,7 +190,7 @@ export interface IForkChoice {
196
190
  *
197
191
  * ## Specification
198
192
  *
199
- * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.0/specs/gloas/fork-choice.md#new-notify_ptc_messages
193
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#new-notify_ptc_messages
200
194
  */
201
195
  notifyPtcMessages(
202
196
  blockRoot: RootHex,
@@ -211,7 +205,7 @@ export interface IForkChoice {
211
205
  *
212
206
  * ## Specification
213
207
  *
214
- * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-on_execution_payload
208
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#new-on_execution_payload_envelope
215
209
  *
216
210
  * @param blockRoot - The beacon block root for which the payload arrived
217
211
  * @param executionPayloadBlockHash - The block hash of the execution payload
@@ -252,6 +246,11 @@ export interface IForkChoice {
252
246
  hasPayloadUnsafe(blockRoot: Root): boolean;
253
247
  hasPayloadHexUnsafe(blockRoot: RootHex): boolean;
254
248
  getSlotsPresent(windowStart: number): number;
249
+ /**
250
+ * Count gloas blocks with fromSlot <= slot <= toSlot and how many of them have a revealed
251
+ * payload (FULL variant exists). Used by the builder circuit breaker.
252
+ */
253
+ getPayloadRevealCounts(fromSlot: Slot, toSlot: Slot): {blocksPresent: number; payloadsRevealed: number};
255
254
  getPTCVotes(blockRootHex: RootHex): (boolean | null)[] | null;
256
255
  /** Raw PTC vote tallies for the debug fork choice endpoint; `null` for pre-Gloas roots. */
257
256
  getPTCVoteCounts(blockRootHex: RootHex): {
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,