@lodestar/fork-choice 1.47.0-dev.931ecbec99 → 1.47.0-dev.9ba9a5ce85

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.
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "bugs": {
12
12
  "url": "https://github.com/ChainSafe/lodestar/issues"
13
13
  },
14
- "version": "1.47.0-dev.931ecbec99",
14
+ "version": "1.47.0-dev.9ba9a5ce85",
15
15
  "type": "module",
16
16
  "exports": {
17
17
  ".": {
@@ -39,12 +39,12 @@
39
39
  "check-readme": "pnpm exec tsx ../../scripts/check_readme.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@chainsafe/ssz": "^1.6.2",
43
- "@lodestar/config": "^1.47.0-dev.931ecbec99",
44
- "@lodestar/params": "^1.47.0-dev.931ecbec99",
45
- "@lodestar/state-transition": "^1.47.0-dev.931ecbec99",
46
- "@lodestar/types": "^1.47.0-dev.931ecbec99",
47
- "@lodestar/utils": "^1.47.0-dev.931ecbec99"
42
+ "@chainsafe/ssz": "^1.6.3",
43
+ "@lodestar/config": "^1.47.0-dev.9ba9a5ce85",
44
+ "@lodestar/params": "^1.47.0-dev.9ba9a5ce85",
45
+ "@lodestar/state-transition": "^1.47.0-dev.9ba9a5ce85",
46
+ "@lodestar/types": "^1.47.0-dev.9ba9a5ce85",
47
+ "@lodestar/utils": "^1.47.0-dev.9ba9a5ce85"
48
48
  },
49
49
  "keywords": [
50
50
  "ethereum",
@@ -52,5 +52,5 @@
52
52
  "beacon",
53
53
  "blockchain"
54
54
  ],
55
- "gitHead": "b9a86b6966fa8fbfdcecff9c593f6c995df8b671"
55
+ "gitHead": "545a682a04e4c744708aa0f2c65f21a6762688c6"
56
56
  }
@@ -365,6 +365,21 @@ export class ForkChoice implements IForkChoice {
365
365
  return {shouldOverrideFcu: false, reason: NotReorgedReason.ParentBlockNotAvailable};
366
366
  }
367
367
 
368
+ const currentTimeOk =
369
+ headBlock.slot === currentSlot ||
370
+ (proposalSlot === currentSlot && this.isProposingOnTime(secFromSlot, currentSlot));
371
+
372
+ // Mirror the proposer equivocation branch of getProposerHead(). The head slot's attestations are
373
+ // still queued at this point so the head is assumed weak, same as for the regular branch below.
374
+ if (currentTimeOk && this.isProposerEquivocation(headBlock)) {
375
+ this.logger?.verbose("Head proposer equivocated. Should override forkchoice update", {
376
+ blockRoot: headBlock.blockRoot,
377
+ slot: currentSlot,
378
+ proposerIndex: headBlock.proposerIndex,
379
+ });
380
+ return {shouldOverrideFcu: true, parentBlock};
381
+ }
382
+
368
383
  const {prelimProposerHead, prelimNotReorgedReason} = this.getPreliminaryProposerHead(
369
384
  headBlock,
370
385
  parentBlock,
@@ -375,9 +390,6 @@ export class ForkChoice implements IForkChoice {
375
390
  return {shouldOverrideFcu: false, reason: prelimNotReorgedReason ?? NotReorgedReason.Unknown};
376
391
  }
377
392
 
378
- const currentTimeOk =
379
- headBlock.slot === currentSlot ||
380
- (proposalSlot === currentSlot && this.isProposingOnTime(secFromSlot, currentSlot));
381
393
  if (!currentTimeOk) {
382
394
  return {shouldOverrideFcu: false, reason: NotReorgedReason.ReorgMoreThanOneSlot};
383
395
  }
@@ -462,7 +474,8 @@ export class ForkChoice implements IForkChoice {
462
474
  * https://github.com/ethereum/consensus-specs/pull/3034 for info about proposer boost reorg
463
475
  * This function should only be called during block proposal and only be called after `updateHead()` in `updateAndGetHead()`
464
476
  *
465
- * Same as https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#get_proposer_head
477
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/phase0/fork-choice.md#get_proposer_head
478
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#modified-get_proposer_head
466
479
  */
467
480
  getProposerHead(
468
481
  headBlock: ProtoBlock,
@@ -493,6 +506,29 @@ export class ForkChoice implements IForkChoice {
493
506
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ParentBlockNotAvailable};
494
507
  }
495
508
 
509
+ // Half of single_slot_reorg check in the spec is done in getPreliminaryProposerHead()
510
+ const currentTimeOk = headBlock.slot + 1 === slot;
511
+ const isProposerBoostWornOff = this.proposerBoostRoot !== headBlock.blockRoot;
512
+
513
+ // Re-org more aggressively if there is a proposer equivocation in the previous slot, skipping the
514
+ // regular reorg conditions. Any known equivocation counts here, timely or not.
515
+ if (
516
+ currentTimeOk &&
517
+ isProposerBoostWornOff &&
518
+ this.isProposerEquivocation(headBlock) &&
519
+ this.isHeadWeak(headBlock.blockRoot)
520
+ ) {
521
+ this.logger?.verbose("Performing single-slot reorg to remove weak head of equivocating proposer", {
522
+ slot,
523
+ proposerHead: parentBlock.blockRoot,
524
+ weakHead: headBlock.blockRoot,
525
+ proposerIndex: headBlock.proposerIndex,
526
+ });
527
+ proposerHead = parentBlock;
528
+
529
+ return {proposerHead, isHeadTimely};
530
+ }
531
+
496
532
  const {prelimProposerHead, prelimNotReorgedReason} = this.getPreliminaryProposerHead(headBlock, parentBlock, slot);
497
533
 
498
534
  if (prelimProposerHead === headBlock && prelimNotReorgedReason !== undefined) {
@@ -505,14 +541,11 @@ export class ForkChoice implements IForkChoice {
505
541
  }
506
542
 
507
543
  // No reorg if attempted reorg is more than a single slot
508
- // Half of single_slot_reorg check in the spec is done in getPreliminaryProposerHead()
509
- const currentTimeOk = headBlock.slot + 1 === slot;
510
544
  if (!currentTimeOk) {
511
545
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ReorgMoreThanOneSlot};
512
546
  }
513
547
 
514
548
  // No reorg if proposer boost is still in effect
515
- const isProposerBoostWornOff = this.proposerBoostRoot !== headBlock.blockRoot;
516
549
  if (!isProposerBoostWornOff) {
517
550
  return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ProposerBoostNotWornOff};
518
551
  }
@@ -591,32 +624,48 @@ export class ForkChoice implements IForkChoice {
591
624
  computeDeltasMetrics?.newVoteValidators.set(newVoteValidators);
592
625
 
593
626
  this.balances = newBalances;
594
- /**
595
- * The structure in line with deltas to propagate boost up the branch
596
- * starting from the proposerIndex
597
- */
598
- let proposerBoost: {root: RootHex; score: bigint} | null = null;
599
- if (this.opts?.proposerBoost && this.proposerBoostRoot) {
600
- const proposerBoostScore =
601
- this.justifiedProposerBoostScore ??
602
- getCommitteeFraction(this.fcStore.justified.totalBalance, {
603
- slotsPerEpoch: SLOTS_PER_EPOCH,
604
- committeePercent: this.config.PROPOSER_SCORE_BOOST,
605
- });
606
- proposerBoost = {root: this.proposerBoostRoot, score: proposerBoostScore};
607
- this.justifiedProposerBoostScore = proposerBoostScore;
608
- }
609
627
 
610
628
  const currentSlot = this.fcStore.currentSlot;
611
- this.protoArray.applyScoreChanges({
612
- attestationDeltas,
613
- proposerBoost,
629
+ const checkpoints = {
614
630
  justifiedEpoch: this.fcStore.justified.checkpoint.epoch,
615
631
  justifiedRoot: this.fcStore.justified.checkpoint.rootHex,
616
632
  finalizedEpoch: this.fcStore.finalizedCheckpoint.epoch,
617
633
  finalizedRoot: this.fcStore.finalizedCheckpoint.rootHex,
618
634
  currentSlot,
619
- });
635
+ };
636
+
637
+ const boostedBlock =
638
+ this.opts?.proposerBoost && this.proposerBoostRoot ? this.getBlockHexDefaultStatus(this.proposerBoostRoot) : null;
639
+
640
+ if (boostedBlock && isGloasBlock(boostedBlock)) {
641
+ // should_apply_proposer_boost judges the parent against the attestations known to the store,
642
+ // via is_head_weak (which also assumes equivocators' votes were already discounted before
643
+ // their balance is added back). Apply the attestation deltas first so the decision reads
644
+ // post-delta scores, then apply the boost in a second pass.
645
+ // TODO GLOAS: applyScoreChanges() updates weights and best child/descendant in one call;
646
+ // splitting them would let the two passes update weights and recompute best child/descendant
647
+ // once at the end.
648
+ // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
649
+ this.protoArray.applyScoreChanges({attestationDeltas, proposerBoost: null, ...checkpoints});
650
+ const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : null;
651
+ // The first pass already rolled back the previous boost and left a coherent tree, so a
652
+ // withheld boost needs no second pass
653
+ if (proposerBoost !== null) {
654
+ this.protoArray.applyScoreChanges({
655
+ attestationDeltas: new Array<number>(this.protoArray.nodes.length).fill(0),
656
+ proposerBoost,
657
+ ...checkpoints,
658
+ });
659
+ }
660
+ } else {
661
+ // Pre-gloas the boost is unconditional, so attestation deltas and boost deltas can propagate
662
+ // up the branch in a single pass
663
+ this.protoArray.applyScoreChanges({
664
+ attestationDeltas,
665
+ proposerBoost: boostedBlock ? this.getProposerBoost() : null,
666
+ ...checkpoints,
667
+ });
668
+ }
620
669
 
621
670
  // findHead returns the ProtoNode representing the head
622
671
  const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot);
@@ -826,6 +875,8 @@ export class ForkChoice implements IForkChoice {
826
875
  targetRoot: toRootHex(targetRoot),
827
876
  stateRoot: toRootHex(block.stateRoot),
828
877
  timeliness: isTimely,
878
+ ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec),
879
+ proposerIndex: block.proposerIndex,
829
880
 
830
881
  justifiedEpoch: stateJustifiedEpoch,
831
882
  justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root),
@@ -1078,11 +1129,20 @@ export class ForkChoice implements IForkChoice {
1078
1129
  while (this.fcStore.currentSlot < currentSlot) {
1079
1130
  const previousSlot = this.fcStore.currentSlot;
1080
1131
  // Note: we are relying upon `onTick` to update `fcStore.time` to ensure we don't get stuck in a loop.
1081
- this.onTick(previousSlot + 1);
1132
+ const didUpdateCheckpoints = this.onTick(previousSlot + 1);
1082
1133
  this.queuedAttestationsPreviousSlot = 0;
1083
1134
  // Process any attestations that might now be eligible before running FCR for this slot.
1084
1135
  this.processAttestationQueue();
1085
- this.runFastConfirmation();
1136
+ const didRecomputeHead = this.runFastConfirmation();
1137
+
1138
+ // An epoch-boundary checkpoint pull-up can move the head's dependent root and stale the cached
1139
+ // head before block 0 of the new epoch is imported, making isProposerBoostSameDependentRoot()
1140
+ // wrong for that block. Recompute the head so it reflects the new checkpoint and the queued
1141
+ // votes — unless fast confirmation already did, to avoid a redundant head calculation.
1142
+ if (didUpdateCheckpoints && !didRecomputeHead) {
1143
+ this.updateHead();
1144
+ }
1145
+
1086
1146
  this.validatedAttestationDatas = new Set();
1087
1147
  }
1088
1148
  }
@@ -1593,7 +1653,8 @@ export class ForkChoice implements IForkChoice {
1593
1653
  // Only ever called on a block already in fork choice, so a miss is a broken invariant.
1594
1654
  const node = this.protoArray.getNodeDefaultStatus(blockRoot);
1595
1655
  if (node === undefined) {
1596
- // this is called for head so we should always have this in forkchoice, otherwise we have a serious error
1656
+ // this is called for head or the boosted block's parent, both of which should always be
1657
+ // in forkchoice, otherwise we have a serious error
1597
1658
  throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot});
1598
1659
  }
1599
1660
 
@@ -1613,7 +1674,7 @@ export class ForkChoice implements IForkChoice {
1613
1674
  // always 0. Return before fetching the state and walking the block's committees.
1614
1675
  if (equivocatingIndices.size > 0) {
1615
1676
  const state = this.fcStore.stateGetter({stateRoot: node.stateRoot});
1616
- // Only ever called on the head, so the state is always cached.
1677
+ // Only ever called on the head or the boosted block's parent, so the state is always cached.
1617
1678
  // A miss is a broken invariant, not a recoverable state.
1618
1679
  if (state === null) {
1619
1680
  throw new ForkChoiceError({
@@ -1676,6 +1737,99 @@ export class ForkChoice implements IForkChoice {
1676
1737
  return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff;
1677
1738
  }
1678
1739
 
1740
+ /**
1741
+ * Return true if the block arrived before the PTC (payload-timeliness committee) deadline,
1742
+ * ie. block_timeliness[PTC_TIMELINESS_INDEX]. should_apply_proposer_boost uses this as the
1743
+ * definition of an "early" proposer equivocation: only a sibling seen by the PTC deadline
1744
+ * proves the proposer equivocated soon enough that honest nodes could have withheld the boost.
1745
+ *
1746
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#modified-record_block_timeliness
1747
+ *
1748
+ * Child class can overwrite this for testing purpose.
1749
+ */
1750
+ protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
1751
+ const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS);
1752
+ return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs;
1753
+ }
1754
+
1755
+ /**
1756
+ * Determine whether proposer boost should be applied to `proposerBoostRoot`.
1757
+ *
1758
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
1759
+ *
1760
+ * Pre-gloas blocks always receive the boost (unconditional, backward compatible). For gloas
1761
+ * blocks the boost is withheld when the parent is a weak block from the previous slot and the
1762
+ * proposer of that parent equivocated (published another PTC-timely block at the same slot).
1763
+ */
1764
+ private shouldApplyProposerBoost(): boolean {
1765
+ if (!this.proposerBoostRoot) {
1766
+ return false;
1767
+ }
1768
+
1769
+ const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot);
1770
+ // Pre-gloas blocks always get boost
1771
+ if (!boostedBlock || !isGloasBlock(boostedBlock)) {
1772
+ return true;
1773
+ }
1774
+
1775
+ const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot);
1776
+ if (!parentBlock) {
1777
+ return true;
1778
+ }
1779
+
1780
+ // Apply proposer boost if parent is not from the previous slot
1781
+ if (parentBlock.slot + 1 < boostedBlock.slot) {
1782
+ return true;
1783
+ }
1784
+
1785
+ // Apply proposer boost if parent is not weak
1786
+ if (!this.isHeadWeak(parentBlock.blockRoot)) {
1787
+ return true;
1788
+ }
1789
+
1790
+ // Parent is weak and from the previous slot: apply boost only if there are no early
1791
+ // equivocations, ie. no other PTC-timely block at the parent's slot from the same proposer.
1792
+ return !this.protoArray.hasEquivocatingBlock(
1793
+ parentBlock.proposerIndex,
1794
+ parentBlock.slot,
1795
+ parentBlock.blockRoot,
1796
+ // Only a sibling seen before the PTC deadline counts. A late released one might not have been
1797
+ // seen by the proposer, so it cannot be expected to reorg it and is not denied the boost for it.
1798
+ true
1799
+ );
1800
+ }
1801
+
1802
+ /**
1803
+ * Return true if another block at the same slot from the same proposer is known to fork choice.
1804
+ *
1805
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/phase0/fork-choice.md#is_proposer_equivocation
1806
+ */
1807
+ private isProposerEquivocation(block: ProtoBlock): boolean {
1808
+ // Any known sibling counts, timely or not. Timeliness only matters for withholding the boost from
1809
+ // the next proposer, the reorg itself is safe to attempt whenever the equivocation is visible.
1810
+ return this.protoArray.hasEquivocatingBlock(block.proposerIndex, block.slot, block.blockRoot, false);
1811
+ }
1812
+
1813
+ /**
1814
+ * The proposer boost to apply to `proposerBoostRoot`, propagated up the branch by
1815
+ * applyScoreChanges() together with the deltas.
1816
+ */
1817
+ private getProposerBoost(): {root: RootHex; score: bigint} | null {
1818
+ if (!this.proposerBoostRoot) {
1819
+ return null;
1820
+ }
1821
+
1822
+ const proposerBoostScore =
1823
+ this.justifiedProposerBoostScore ??
1824
+ getCommitteeFraction(this.fcStore.justified.totalBalance, {
1825
+ slotsPerEpoch: SLOTS_PER_EPOCH,
1826
+ committeePercent: this.config.PROPOSER_SCORE_BOOST,
1827
+ });
1828
+ this.justifiedProposerBoostScore = proposerBoostScore;
1829
+
1830
+ return {root: this.proposerBoostRoot, score: proposerBoostScore};
1831
+ }
1832
+
1679
1833
  /**
1680
1834
  * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time
1681
1835
  */
@@ -1728,23 +1882,31 @@ export class ForkChoice implements IForkChoice {
1728
1882
  * May need the justified balances of:
1729
1883
  * - unrealizedJustified: Already available in `CheckpointWithBalance`
1730
1884
  * Since this balances are already available the getter is just `() => balances`, without cache interaction
1885
+ *
1886
+ * @returns Whether either checkpoint was updated.
1731
1887
  */
1732
1888
  private updateCheckpoints(
1733
1889
  justifiedCheckpoint: CheckpointWithHex,
1734
1890
  finalizedCheckpoint: CheckpointWithHex,
1735
1891
  getJustifiedBalances: () => JustifiedBalances
1736
- ): void {
1892
+ ): boolean {
1893
+ let updated = false;
1894
+
1737
1895
  // Update justified checkpoint.
1738
1896
  if (justifiedCheckpoint.epoch > this.fcStore.justified.checkpoint.epoch) {
1739
1897
  this.fcStore.justified = {checkpoint: justifiedCheckpoint, balances: getJustifiedBalances()};
1740
1898
  this.justifiedProposerBoostScore = null;
1899
+ updated = true;
1741
1900
  }
1742
1901
 
1743
1902
  // Update finalized checkpoint.
1744
1903
  if (finalizedCheckpoint.epoch > this.fcStore.finalizedCheckpoint.epoch) {
1745
1904
  this.fcStore.finalizedCheckpoint = finalizedCheckpoint;
1746
1905
  this.justifiedProposerBoostScore = null;
1906
+ updated = true;
1747
1907
  }
1908
+
1909
+ return updated;
1748
1910
  }
1749
1911
 
1750
1912
  /**
@@ -2030,8 +2192,10 @@ export class ForkChoice implements IForkChoice {
2030
2192
  * Equivalent to:
2031
2193
  *
2032
2194
  * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#on_tick
2195
+ *
2196
+ * @returns Whether an epoch-boundary checkpoint was updated.
2033
2197
  */
2034
- private onTick(time: Slot): void {
2198
+ private onTick(time: Slot): boolean {
2035
2199
  const previousSlot = this.fcStore.currentSlot;
2036
2200
 
2037
2201
  if (time > previousSlot + 1) {
@@ -2052,11 +2216,11 @@ export class ForkChoice implements IForkChoice {
2052
2216
 
2053
2217
  // Not a new epoch, return.
2054
2218
  if (computeSlotsSinceEpochStart(time) !== 0) {
2055
- return;
2219
+ return false;
2056
2220
  }
2057
2221
 
2058
- // If a new epoch, pull-up justification and finalization from previous epoch
2059
- this.updateCheckpoints(
2222
+ // If a new epoch, pull-up justification and finalization from previous epoch.
2223
+ return this.updateCheckpoints(
2060
2224
  this.fcStore.unrealizedJustified.checkpoint,
2061
2225
  this.fcStore.unrealizedFinalizedCheckpoint,
2062
2226
  () => this.fcStore.unrealizedJustified.balances
@@ -2115,10 +2279,11 @@ export class ForkChoice implements IForkChoice {
2115
2279
  return {prelimProposerHead};
2116
2280
  }
2117
2281
 
2118
- private runFastConfirmation(): void {
2282
+ /** Returns whether it recomputed the head, so the caller can avoid a redundant `updateHead()`. */
2283
+ private runFastConfirmation(): boolean {
2119
2284
  const fastConfirmationRule = this.fastConfirmationRule;
2120
2285
  const fastConfirmationContext = this.fastConfirmationContext;
2121
- if (!fastConfirmationRule || !fastConfirmationContext) return;
2286
+ if (!fastConfirmationRule || !fastConfirmationContext) return false;
2122
2287
 
2123
2288
  if (this.fastConfirmationPaused) {
2124
2289
  // Keep consumers on a safe, available root while the rule is paused
@@ -2129,7 +2294,7 @@ export class ForkChoice implements IForkChoice {
2129
2294
  // Runs outside the timed try/catch below; a throw would escape to the clock listener
2130
2295
  this.logger?.debug("Fast confirmation notify failed", {slot: this.fcStore.currentSlot}, err as Error);
2131
2296
  }
2132
- return;
2297
+ return false;
2133
2298
  }
2134
2299
 
2135
2300
  withObservedDuration(this.metrics?.fastConfirmation.totalDuration.startTimer(), () => {
@@ -2152,6 +2317,8 @@ export class ForkChoice implements IForkChoice {
2152
2317
  );
2153
2318
  }
2154
2319
  });
2320
+
2321
+ return true;
2155
2322
  }
2156
2323
 
2157
2324
  private createFastConfirmationContext(): FastConfirmationContext {
@@ -1,5 +1,5 @@
1
1
  import {DataAvailabilityStatus} from "@lodestar/state-transition";
2
- import {Epoch, RootHex, Slot, UintNum64} from "@lodestar/types";
2
+ import {Epoch, RootHex, Slot, UintNum64, ValidatorIndex} from "@lodestar/types";
3
3
 
4
4
  // RootHex is a root as a hex string
5
5
  // Used for lightweight and easy comparison
@@ -144,6 +144,13 @@ export type ProtoBlock = BlockExtraMeta & {
144
144
  // Indicate whether block arrives in a timely manner ie. before the 4 second mark
145
145
  timeliness: boolean;
146
146
 
147
+ // Indicate whether block arrives before the PTC deadline
148
+ // Spec: gloas/fork-choice.md#modified-record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX])
149
+ ptcTimeliness: boolean;
150
+
151
+ // The index of the block proposer. Used by should_apply_proposer_boost to detect proposer equivocations
152
+ proposerIndex: ValidatorIndex;
153
+
147
154
  /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */
148
155
  payloadStatus: PayloadStatus;
149
156
 
@@ -1,7 +1,7 @@
1
1
  import {BitArray} from "@chainsafe/ssz";
2
2
  import {EFFECTIVE_BALANCE_INCREMENT, GENESIS_EPOCH, GENESIS_SLOT, PTC_SIZE} from "@lodestar/params";
3
3
  import {DataAvailabilityStatus, computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition";
4
- import {Epoch, RootHex, Slot} from "@lodestar/types";
4
+ import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types";
5
5
  import {bitCount, toRootHex} from "@lodestar/utils";
6
6
  import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js";
7
7
  import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js";
@@ -2007,6 +2007,41 @@ export class ProtoArray {
2007
2007
  return this.getNodeByIndex(nodeIndex);
2008
2008
  }
2009
2009
 
2010
+ /**
2011
+ * Return true if a block other than `excludeRoot` at `slot` was proposed by `proposerIndex`.
2012
+ * `should_apply_proposer_boost` only counts PTC-timely blocks (`ptcTimelyOnly`), `is_proposer_equivocation`
2013
+ * counts any known block.
2014
+ *
2015
+ * Iterates unique block roots (via the canonical variant) since `slot`, `proposerIndex` and
2016
+ * `ptcTimeliness` are block-level properties identical across payload-status variants.
2017
+ */
2018
+ hasEquivocatingBlock(
2019
+ proposerIndex: ValidatorIndex,
2020
+ slot: Slot,
2021
+ excludeRoot: RootHex,
2022
+ ptcTimelyOnly: boolean
2023
+ ): boolean {
2024
+ for (const root of this.indices.keys()) {
2025
+ if (root === excludeRoot) {
2026
+ continue;
2027
+ }
2028
+ const nodeIndex = this.getDefaultNodeIndex(root);
2029
+ if (nodeIndex === undefined) {
2030
+ continue;
2031
+ }
2032
+ const node = this.nodes[nodeIndex];
2033
+ if (
2034
+ node !== undefined &&
2035
+ node.slot === slot &&
2036
+ node.proposerIndex === proposerIndex &&
2037
+ (node.ptcTimeliness || !ptcTimelyOnly)
2038
+ ) {
2039
+ return true;
2040
+ }
2041
+ }
2042
+ return false;
2043
+ }
2044
+
2010
2045
  /**
2011
2046
  * Return MUTABLE ProtoBlock for blockRoot with explicit payload status
2012
2047
  *