@lodestar/fork-choice 1.47.0-dev.da8dfeabda → 1.47.0-dev.e73f1f5165

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.
@@ -591,32 +591,48 @@ export class ForkChoice implements IForkChoice {
591
591
  computeDeltasMetrics?.newVoteValidators.set(newVoteValidators);
592
592
 
593
593
  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
594
 
610
595
  const currentSlot = this.fcStore.currentSlot;
611
- this.protoArray.applyScoreChanges({
612
- attestationDeltas,
613
- proposerBoost,
596
+ const checkpoints = {
614
597
  justifiedEpoch: this.fcStore.justified.checkpoint.epoch,
615
598
  justifiedRoot: this.fcStore.justified.checkpoint.rootHex,
616
599
  finalizedEpoch: this.fcStore.finalizedCheckpoint.epoch,
617
600
  finalizedRoot: this.fcStore.finalizedCheckpoint.rootHex,
618
601
  currentSlot,
619
- });
602
+ };
603
+
604
+ const boostedBlock =
605
+ this.opts?.proposerBoost && this.proposerBoostRoot ? this.getBlockHexDefaultStatus(this.proposerBoostRoot) : null;
606
+
607
+ if (boostedBlock && isGloasBlock(boostedBlock)) {
608
+ // should_apply_proposer_boost judges the parent against the attestations known to the store,
609
+ // via is_head_weak (which also assumes equivocators' votes were already discounted before
610
+ // their balance is added back). Apply the attestation deltas first so the decision reads
611
+ // post-delta scores, then apply the boost in a second pass.
612
+ // TODO GLOAS: applyScoreChanges() updates weights and best child/descendant in one call;
613
+ // splitting them would let the two passes update weights and recompute best child/descendant
614
+ // once at the end.
615
+ // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
616
+ this.protoArray.applyScoreChanges({attestationDeltas, proposerBoost: null, ...checkpoints});
617
+ const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : null;
618
+ // The first pass already rolled back the previous boost and left a coherent tree, so a
619
+ // withheld boost needs no second pass
620
+ if (proposerBoost !== null) {
621
+ this.protoArray.applyScoreChanges({
622
+ attestationDeltas: new Array<number>(this.protoArray.nodes.length).fill(0),
623
+ proposerBoost,
624
+ ...checkpoints,
625
+ });
626
+ }
627
+ } else {
628
+ // Pre-gloas the boost is unconditional, so attestation deltas and boost deltas can propagate
629
+ // up the branch in a single pass
630
+ this.protoArray.applyScoreChanges({
631
+ attestationDeltas,
632
+ proposerBoost: boostedBlock ? this.getProposerBoost() : null,
633
+ ...checkpoints,
634
+ });
635
+ }
620
636
 
621
637
  // findHead returns the ProtoNode representing the head
622
638
  const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot);
@@ -826,6 +842,8 @@ export class ForkChoice implements IForkChoice {
826
842
  targetRoot: toRootHex(targetRoot),
827
843
  stateRoot: toRootHex(block.stateRoot),
828
844
  timeliness: isTimely,
845
+ ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec),
846
+ proposerIndex: block.proposerIndex,
829
847
 
830
848
  justifiedEpoch: stateJustifiedEpoch,
831
849
  justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root),
@@ -1078,11 +1096,20 @@ export class ForkChoice implements IForkChoice {
1078
1096
  while (this.fcStore.currentSlot < currentSlot) {
1079
1097
  const previousSlot = this.fcStore.currentSlot;
1080
1098
  // 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);
1099
+ const didUpdateCheckpoints = this.onTick(previousSlot + 1);
1082
1100
  this.queuedAttestationsPreviousSlot = 0;
1083
1101
  // Process any attestations that might now be eligible before running FCR for this slot.
1084
1102
  this.processAttestationQueue();
1085
- this.runFastConfirmation();
1103
+ const didRecomputeHead = this.runFastConfirmation();
1104
+
1105
+ // An epoch-boundary checkpoint pull-up can move the head's dependent root and stale the cached
1106
+ // head before block 0 of the new epoch is imported, making isProposerBoostSameDependentRoot()
1107
+ // wrong for that block. Recompute the head so it reflects the new checkpoint and the queued
1108
+ // votes — unless fast confirmation already did, to avoid a redundant head calculation.
1109
+ if (didUpdateCheckpoints && !didRecomputeHead) {
1110
+ this.updateHead();
1111
+ }
1112
+
1086
1113
  this.validatedAttestationDatas = new Set();
1087
1114
  }
1088
1115
  }
@@ -1593,7 +1620,8 @@ export class ForkChoice implements IForkChoice {
1593
1620
  // Only ever called on a block already in fork choice, so a miss is a broken invariant.
1594
1621
  const node = this.protoArray.getNodeDefaultStatus(blockRoot);
1595
1622
  if (node === undefined) {
1596
- // this is called for head so we should always have this in forkchoice, otherwise we have a serious error
1623
+ // this is called for head or the boosted block's parent, both of which should always be
1624
+ // in forkchoice, otherwise we have a serious error
1597
1625
  throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot});
1598
1626
  }
1599
1627
 
@@ -1613,7 +1641,7 @@ export class ForkChoice implements IForkChoice {
1613
1641
  // always 0. Return before fetching the state and walking the block's committees.
1614
1642
  if (equivocatingIndices.size > 0) {
1615
1643
  const state = this.fcStore.stateGetter({stateRoot: node.stateRoot});
1616
- // Only ever called on the head, so the state is always cached.
1644
+ // Only ever called on the head or the boosted block's parent, so the state is always cached.
1617
1645
  // A miss is a broken invariant, not a recoverable state.
1618
1646
  if (state === null) {
1619
1647
  throw new ForkChoiceError({
@@ -1676,6 +1704,81 @@ export class ForkChoice implements IForkChoice {
1676
1704
  return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff;
1677
1705
  }
1678
1706
 
1707
+ /**
1708
+ * Return true if the block arrived before the PTC (payload-timeliness committee) deadline,
1709
+ * ie. block_timeliness[PTC_TIMELINESS_INDEX]. should_apply_proposer_boost uses this as the
1710
+ * definition of an "early" proposer equivocation: only a sibling seen by the PTC deadline
1711
+ * proves the proposer equivocated soon enough that honest nodes could have withheld the boost.
1712
+ *
1713
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#modified-record_block_timeliness
1714
+ *
1715
+ * Child class can overwrite this for testing purpose.
1716
+ */
1717
+ protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
1718
+ const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS);
1719
+ return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs;
1720
+ }
1721
+
1722
+ /**
1723
+ * Determine whether proposer boost should be applied to `proposerBoostRoot`.
1724
+ *
1725
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
1726
+ *
1727
+ * Pre-gloas blocks always receive the boost (unconditional, backward compatible). For gloas
1728
+ * blocks the boost is withheld when the parent is a weak block from the previous slot and the
1729
+ * proposer of that parent equivocated (published another PTC-timely block at the same slot).
1730
+ */
1731
+ private shouldApplyProposerBoost(): boolean {
1732
+ if (!this.proposerBoostRoot) {
1733
+ return false;
1734
+ }
1735
+
1736
+ const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot);
1737
+ // Pre-gloas blocks always get boost
1738
+ if (!boostedBlock || !isGloasBlock(boostedBlock)) {
1739
+ return true;
1740
+ }
1741
+
1742
+ const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot);
1743
+ if (!parentBlock) {
1744
+ return true;
1745
+ }
1746
+
1747
+ // Apply proposer boost if parent is not from the previous slot
1748
+ if (parentBlock.slot + 1 < boostedBlock.slot) {
1749
+ return true;
1750
+ }
1751
+
1752
+ // Apply proposer boost if parent is not weak
1753
+ if (!this.isHeadWeak(parentBlock.blockRoot)) {
1754
+ return true;
1755
+ }
1756
+
1757
+ // Parent is weak and from the previous slot: apply boost only if there are no early
1758
+ // equivocations, ie. no other PTC-timely block at the parent's slot from the same proposer.
1759
+ return !this.protoArray.hasEquivocatingBlock(parentBlock.proposerIndex, parentBlock.slot, parentBlock.blockRoot);
1760
+ }
1761
+
1762
+ /**
1763
+ * The proposer boost to apply to `proposerBoostRoot`, propagated up the branch by
1764
+ * applyScoreChanges() together with the deltas.
1765
+ */
1766
+ private getProposerBoost(): {root: RootHex; score: bigint} | null {
1767
+ if (!this.proposerBoostRoot) {
1768
+ return null;
1769
+ }
1770
+
1771
+ const proposerBoostScore =
1772
+ this.justifiedProposerBoostScore ??
1773
+ getCommitteeFraction(this.fcStore.justified.totalBalance, {
1774
+ slotsPerEpoch: SLOTS_PER_EPOCH,
1775
+ committeePercent: this.config.PROPOSER_SCORE_BOOST,
1776
+ });
1777
+ this.justifiedProposerBoostScore = proposerBoostScore;
1778
+
1779
+ return {root: this.proposerBoostRoot, score: proposerBoostScore};
1780
+ }
1781
+
1679
1782
  /**
1680
1783
  * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time
1681
1784
  */
@@ -1728,23 +1831,31 @@ export class ForkChoice implements IForkChoice {
1728
1831
  * May need the justified balances of:
1729
1832
  * - unrealizedJustified: Already available in `CheckpointWithBalance`
1730
1833
  * Since this balances are already available the getter is just `() => balances`, without cache interaction
1834
+ *
1835
+ * @returns Whether either checkpoint was updated.
1731
1836
  */
1732
1837
  private updateCheckpoints(
1733
1838
  justifiedCheckpoint: CheckpointWithHex,
1734
1839
  finalizedCheckpoint: CheckpointWithHex,
1735
1840
  getJustifiedBalances: () => JustifiedBalances
1736
- ): void {
1841
+ ): boolean {
1842
+ let updated = false;
1843
+
1737
1844
  // Update justified checkpoint.
1738
1845
  if (justifiedCheckpoint.epoch > this.fcStore.justified.checkpoint.epoch) {
1739
1846
  this.fcStore.justified = {checkpoint: justifiedCheckpoint, balances: getJustifiedBalances()};
1740
1847
  this.justifiedProposerBoostScore = null;
1848
+ updated = true;
1741
1849
  }
1742
1850
 
1743
1851
  // Update finalized checkpoint.
1744
1852
  if (finalizedCheckpoint.epoch > this.fcStore.finalizedCheckpoint.epoch) {
1745
1853
  this.fcStore.finalizedCheckpoint = finalizedCheckpoint;
1746
1854
  this.justifiedProposerBoostScore = null;
1855
+ updated = true;
1747
1856
  }
1857
+
1858
+ return updated;
1748
1859
  }
1749
1860
 
1750
1861
  /**
@@ -2030,8 +2141,10 @@ export class ForkChoice implements IForkChoice {
2030
2141
  * Equivalent to:
2031
2142
  *
2032
2143
  * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#on_tick
2144
+ *
2145
+ * @returns Whether an epoch-boundary checkpoint was updated.
2033
2146
  */
2034
- private onTick(time: Slot): void {
2147
+ private onTick(time: Slot): boolean {
2035
2148
  const previousSlot = this.fcStore.currentSlot;
2036
2149
 
2037
2150
  if (time > previousSlot + 1) {
@@ -2052,11 +2165,11 @@ export class ForkChoice implements IForkChoice {
2052
2165
 
2053
2166
  // Not a new epoch, return.
2054
2167
  if (computeSlotsSinceEpochStart(time) !== 0) {
2055
- return;
2168
+ return false;
2056
2169
  }
2057
2170
 
2058
- // If a new epoch, pull-up justification and finalization from previous epoch
2059
- this.updateCheckpoints(
2171
+ // If a new epoch, pull-up justification and finalization from previous epoch.
2172
+ return this.updateCheckpoints(
2060
2173
  this.fcStore.unrealizedJustified.checkpoint,
2061
2174
  this.fcStore.unrealizedFinalizedCheckpoint,
2062
2175
  () => this.fcStore.unrealizedJustified.balances
@@ -2115,10 +2228,11 @@ export class ForkChoice implements IForkChoice {
2115
2228
  return {prelimProposerHead};
2116
2229
  }
2117
2230
 
2118
- private runFastConfirmation(): void {
2231
+ /** Returns whether it recomputed the head, so the caller can avoid a redundant `updateHead()`. */
2232
+ private runFastConfirmation(): boolean {
2119
2233
  const fastConfirmationRule = this.fastConfirmationRule;
2120
2234
  const fastConfirmationContext = this.fastConfirmationContext;
2121
- if (!fastConfirmationRule || !fastConfirmationContext) return;
2235
+ if (!fastConfirmationRule || !fastConfirmationContext) return false;
2122
2236
 
2123
2237
  if (this.fastConfirmationPaused) {
2124
2238
  // Keep consumers on a safe, available root while the rule is paused
@@ -2129,7 +2243,7 @@ export class ForkChoice implements IForkChoice {
2129
2243
  // Runs outside the timed try/catch below; a throw would escape to the clock listener
2130
2244
  this.logger?.debug("Fast confirmation notify failed", {slot: this.fcStore.currentSlot}, err as Error);
2131
2245
  }
2132
- return;
2246
+ return false;
2133
2247
  }
2134
2248
 
2135
2249
  withObservedDuration(this.metrics?.fastConfirmation.totalDuration.startTimer(), () => {
@@ -2152,6 +2266,8 @@ export class ForkChoice implements IForkChoice {
2152
2266
  );
2153
2267
  }
2154
2268
  });
2269
+
2270
+ return true;
2155
2271
  }
2156
2272
 
2157
2273
  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,32 @@ 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` and
2012
+ * is PTC-timely. Used by `should_apply_proposer_boost` to detect proposer equivocations.
2013
+ *
2014
+ * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
2015
+ *
2016
+ * Iterates unique block roots (via the canonical variant) since `slot`, `proposerIndex` and
2017
+ * `ptcTimeliness` are block-level properties identical across payload-status variants.
2018
+ */
2019
+ hasEquivocatingBlock(proposerIndex: ValidatorIndex, slot: Slot, excludeRoot: RootHex): boolean {
2020
+ for (const root of this.indices.keys()) {
2021
+ if (root === excludeRoot) {
2022
+ continue;
2023
+ }
2024
+ const nodeIndex = this.getDefaultNodeIndex(root);
2025
+ if (nodeIndex === undefined) {
2026
+ continue;
2027
+ }
2028
+ const node = this.nodes[nodeIndex];
2029
+ if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) {
2030
+ return true;
2031
+ }
2032
+ }
2033
+ return false;
2034
+ }
2035
+
2010
2036
  /**
2011
2037
  * Return MUTABLE ProtoBlock for blockRoot with explicit payload status
2012
2038
  *