@lodestar/fork-choice 1.50.0-dev.1f129c79a1 → 1.50.0-dev.20062ed60e

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 (43) hide show
  1. package/lib/forkChoice/fastConfirmation/types.d.ts +3 -1
  2. package/lib/forkChoice/fastConfirmation/types.d.ts.map +1 -1
  3. package/lib/forkChoice/fastConfirmation/utils.d.ts +2 -2
  4. package/lib/forkChoice/fastConfirmation/utils.d.ts.map +1 -1
  5. package/lib/forkChoice/fastConfirmation/utils.js +8 -3
  6. package/lib/forkChoice/fastConfirmation/utils.js.map +1 -1
  7. package/lib/forkChoice/forkChoice.d.ts +2 -1
  8. package/lib/forkChoice/forkChoice.d.ts.map +1 -1
  9. package/lib/forkChoice/forkChoice.js +43 -5
  10. package/lib/forkChoice/forkChoice.js.map +1 -1
  11. package/lib/forkChoice/interface.d.ts +9 -1
  12. package/lib/forkChoice/interface.d.ts.map +1 -1
  13. package/lib/forkChoice/interface.js.map +1 -1
  14. package/lib/forkChoice/store.d.ts +4 -1
  15. package/lib/forkChoice/store.d.ts.map +1 -1
  16. package/lib/forkChoice/store.js +3 -0
  17. package/lib/forkChoice/store.js.map +1 -1
  18. package/lib/index.d.ts +3 -3
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +1 -1
  21. package/lib/index.js.map +1 -1
  22. package/lib/metrics.d.ts +3 -0
  23. package/lib/metrics.d.ts.map +1 -1
  24. package/lib/metrics.js +5 -0
  25. package/lib/metrics.js.map +1 -1
  26. package/lib/protoArray/interface.d.ts +15 -0
  27. package/lib/protoArray/interface.d.ts.map +1 -1
  28. package/lib/protoArray/interface.js +13 -0
  29. package/lib/protoArray/interface.js.map +1 -1
  30. package/lib/protoArray/protoArray.d.ts +18 -2
  31. package/lib/protoArray/protoArray.d.ts.map +1 -1
  32. package/lib/protoArray/protoArray.js +57 -3
  33. package/lib/protoArray/protoArray.js.map +1 -1
  34. package/package.json +8 -8
  35. package/src/forkChoice/fastConfirmation/types.ts +3 -2
  36. package/src/forkChoice/fastConfirmation/utils.ts +12 -5
  37. package/src/forkChoice/forkChoice.ts +47 -4
  38. package/src/forkChoice/interface.ts +6 -0
  39. package/src/forkChoice/store.ts +7 -1
  40. package/src/index.ts +3 -1
  41. package/src/metrics.ts +5 -0
  42. package/src/protoArray/interface.ts +21 -0
  43. package/src/protoArray/protoArray.ts +64 -2
@@ -45,7 +45,9 @@ import {
45
45
  PayloadStatus,
46
46
  ProtoBlock,
47
47
  ProtoNode,
48
+ PtcQuorum,
48
49
  VoteIndex,
50
+ getPtcVerdict,
49
51
  isGloasBlock,
50
52
  } from "../protoArray/interface.js";
51
53
  import {ProtoArray} from "../protoArray/protoArray.js";
@@ -646,7 +648,12 @@ export class ForkChoice implements IForkChoice {
646
648
  // splitting them would let the two passes update weights and recompute best child/descendant
647
649
  // once at the end.
648
650
  // 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});
651
+ this.protoArray.applyScoreChanges({
652
+ attestationDeltas,
653
+ proposerBoost: null,
654
+ proposerBoostRoot: this.proposerBoostRoot,
655
+ ...checkpoints,
656
+ });
650
657
  const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : null;
651
658
  // The first pass already rolled back the previous boost and left a coherent tree, so a
652
659
  // withheld boost needs no second pass
@@ -1090,7 +1097,20 @@ export class ForkChoice implements IForkChoice {
1090
1097
  payloadPresent: boolean,
1091
1098
  blobDataAvailable: boolean
1092
1099
  ): void {
1100
+ const before = this.protoArray.getPtcQuorum(blockRoot);
1093
1101
  this.protoArray.notifyPtcMessages(blockRoot, slot, ptcIndices, payloadPresent, blobDataAvailable);
1102
+ const after = this.protoArray.getPtcQuorum(blockRoot);
1103
+ if (before === null || after === null) {
1104
+ return;
1105
+ }
1106
+ const verdict = getPtcVerdict(after);
1107
+ if (verdict === null || verdict === getPtcVerdict(before)) {
1108
+ return;
1109
+ }
1110
+
1111
+ this.metrics?.forkChoice.ptcQuorum.inc({verdict: verdict ? "true" : "false"});
1112
+ this.logger?.verbose("PTC quorum reached", {slot, blockRoot, verdict, ...after});
1113
+ this.fcStore.notifyPtcQuorum?.({blockRoot, slot, verdict, ...after});
1094
1114
  }
1095
1115
 
1096
1116
  /**
@@ -1223,6 +1243,10 @@ export class ForkChoice implements IForkChoice {
1223
1243
  return this.protoArray.getPTCVoteCounts(blockRootHex);
1224
1244
  }
1225
1245
 
1246
+ getPtcQuorum(blockRootHex: RootHex): PtcQuorum | null {
1247
+ return this.protoArray.getPtcQuorum(blockRootHex);
1248
+ }
1249
+
1226
1250
  getPayloadTimelinessVotes(blockRootHex: RootHex): (boolean | null)[] | null {
1227
1251
  return this.protoArray.getPayloadTimelinessVotes(blockRootHex);
1228
1252
  }
@@ -1750,7 +1774,10 @@ export class ForkChoice implements IForkChoice {
1750
1774
  * Child class can overwrite this for testing purpose.
1751
1775
  */
1752
1776
  protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
1753
- const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS);
1777
+ const ptcThresholdMs = this.config.getSlotComponentDurationMs(
1778
+ this.config.getForkName(block.slot),
1779
+ this.config.PAYLOAD_ATTESTATION_DUE_BPS
1780
+ );
1754
1781
  return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs;
1755
1782
  }
1756
1783
 
@@ -2163,7 +2190,11 @@ export class ForkChoice implements IForkChoice {
2163
2190
  }
2164
2191
 
2165
2192
  const existingNextSlot = this.voteNextSlots[validatorIndex];
2166
- if (existingNextSlot === INIT_VOTE_SLOT || computeEpochAtSlot(nextSlot) > computeEpochAtSlot(existingNextSlot)) {
2193
+ // Pre-Gloas a vote is only replaced by one from a later epoch, from Gloas by one from a later slot
2194
+ const isNewerVote = isForkPostGloas(this.config.getForkName(nextSlot))
2195
+ ? nextSlot > existingNextSlot
2196
+ : computeEpochAtSlot(nextSlot) > computeEpochAtSlot(existingNextSlot);
2197
+ if (existingNextSlot === INIT_VOTE_SLOT || isNewerVote) {
2167
2198
  // nextIndex is transfered to currentIndex in computeDeltas()
2168
2199
  this.voteNextIndices[validatorIndex] = nextIndex;
2169
2200
  this.voteNextSlots[validatorIndex] = nextSlot;
@@ -2384,9 +2415,21 @@ export class ForkChoice implements IForkChoice {
2384
2415
  if (nextIndex === undefined || nextIndex === NULL_VOTE_INDEX) {
2385
2416
  return null;
2386
2417
  }
2418
+ // The vote is tracked by node index, so the node already is the message's supported node
2387
2419
  const node = this.protoArray.nodes[nextIndex];
2388
2420
  if (!node) return null;
2389
- return {root: node.blockRoot, epoch: computeEpochAtSlot(this.voteNextSlots[validatorIndex])};
2421
+ return {
2422
+ root: node.blockRoot,
2423
+ payloadStatus: node.payloadStatus,
2424
+ epoch: computeEpochAtSlot(this.voteNextSlots[validatorIndex]),
2425
+ };
2426
+ },
2427
+ getParentNodePayloadStatus: (blockRoot: RootHex) => {
2428
+ const nodeIndex = this.protoArray.getDefaultNodeIndex(blockRoot);
2429
+ if (nodeIndex === undefined) return null;
2430
+ const parentIndex = this.protoArray.nodes[nodeIndex]?.parent;
2431
+ if (parentIndex === undefined) return null;
2432
+ return this.protoArray.nodes[parentIndex]?.payloadStatus ?? null;
2390
2433
  },
2391
2434
  getUnrealizedJustified: () => ({
2392
2435
  checkpoint: this.fcStore.unrealizedJustified.checkpoint,
@@ -7,6 +7,7 @@ import {
7
7
  PayloadStatus,
8
8
  ProtoBlock,
9
9
  ProtoNode,
10
+ PtcQuorum,
10
11
  } from "../protoArray/interface.js";
11
12
  import {IFastConfirmationSpecStore} from "./fastConfirmation/types.js";
12
13
  import {UpdateAndGetHeadOpt} from "./forkChoice.js";
@@ -31,6 +32,9 @@ export type CheckpointWithTotalBalance = CheckpointWithBalance & {
31
32
  totalBalance: number;
32
33
  };
33
34
 
35
+ /** Emitted when the PTC verdict on a block is decided or flips, see getPtcVerdict */
36
+ export type PtcQuorumEvent = PtcQuorum & {blockRoot: RootHex; slot: Slot; verdict: boolean};
37
+
34
38
  export enum EpochDifference {
35
39
  current = 0,
36
40
  previous = 1,
@@ -259,6 +263,8 @@ export interface IForkChoice {
259
263
  payloadPresentCount: number;
260
264
  dataAvailableCount: number;
261
265
  } | null;
266
+ /** Current PTC majority per vote field; `null` for pre-Gloas roots */
267
+ getPtcQuorum(blockRootHex: RootHex): PtcQuorum | null;
262
268
  getPayloadTimelinessVotes(blockRootHex: RootHex): (boolean | null)[] | null;
263
269
  getPayloadDataAvailabilityVotes(blockRootHex: RootHex): (boolean | null)[] | null;
264
270
 
@@ -2,7 +2,7 @@ import {EffectiveBalanceIncrements, IBeaconStateView} from "@lodestar/state-tran
2
2
  import {RootHex, Slot, ValidatorIndex, phase0} from "@lodestar/types";
3
3
  import {toRootHex} from "@lodestar/utils";
4
4
  import {ForkChoiceStateGetter, IFastConfirmationStore} from "./fastConfirmation/types.js";
5
- import {CheckpointWithBalance, CheckpointWithTotalBalance} from "./interface.js";
5
+ import {CheckpointWithBalance, CheckpointWithTotalBalance, PtcQuorumEvent} from "./interface.js";
6
6
 
7
7
  /**
8
8
  * Stores checkpoints in a hybrid format:
@@ -46,6 +46,7 @@ export interface IForkChoiceStore extends IFastConfirmationStore {
46
46
  justifiedBalancesGetter: JustifiedBalancesGetter;
47
47
  equivocatingIndices: Set<ValidatorIndex>;
48
48
  notifyFastConfirmation?(data: {block: RootHex; slot: Slot; currentSlot: Slot}): void;
49
+ notifyPtcQuorum?(data: PtcQuorumEvent): void;
49
50
  }
50
51
 
51
52
  /**
@@ -85,6 +86,7 @@ export class ForkChoiceStore implements IForkChoiceStore {
85
86
  onJustified: (cp: CheckpointWithHex) => void;
86
87
  onFinalized: (cp: CheckpointWithHex) => void;
87
88
  onFastConfirmation?: (data: {block: RootHex; slot: Slot; currentSlot: Slot}) => void;
89
+ onPtcQuorum?: (data: PtcQuorumEvent) => void;
88
90
  }
89
91
  ) {
90
92
  this.justifiedBalancesGetter = justifiedBalancesGetter;
@@ -142,6 +144,10 @@ export class ForkChoiceStore implements IForkChoiceStore {
142
144
  notifyFastConfirmation(data: {block: RootHex; slot: Slot; currentSlot: Slot}): void {
143
145
  this.events?.onFastConfirmation?.(data);
144
146
  }
147
+
148
+ notifyPtcQuorum(data: PtcQuorumEvent): void {
149
+ this.events?.onPtcQuorum?.(data);
150
+ }
145
151
  }
146
152
 
147
153
  export function toCheckpointWithHex(checkpoint: phase0.Checkpoint): CheckpointWithHex {
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  EpochDifference,
27
27
  type IForkChoice,
28
28
  NotReorgedReason,
29
+ type PtcQuorumEvent,
29
30
  } from "./forkChoice/interface.js";
30
31
  export * from "./forkChoice/safeBlocks.js";
31
32
  export {
@@ -43,6 +44,7 @@ export type {
43
44
  PayloadExecutionStatus,
44
45
  ProtoBlock,
45
46
  ProtoNode,
47
+ PtcQuorum,
46
48
  } from "./protoArray/interface.js";
47
- export {ExecutionStatus, PayloadStatus, isGloasBlock} from "./protoArray/interface.js";
49
+ export {ExecutionStatus, PayloadStatus, getPtcVerdict, isGloasBlock} from "./protoArray/interface.js";
48
50
  export {ProtoArray} from "./protoArray/protoArray.js";
package/src/metrics.ts CHANGED
@@ -68,6 +68,11 @@ export function getForkChoiceMetrics(register: MetricsRegisterExtra) {
68
68
  help: "Reason why the current head is not re-orged out",
69
69
  labelNames: ["reason"],
70
70
  }),
71
+ ptcQuorum: register.counter<{verdict: "true" | "false"}>({
72
+ name: "beacon_fork_choice_ptc_quorum_total",
73
+ help: "Count of decided PTC verdicts, true once majorities voted present and available, false once a majority voted either against",
74
+ labelNames: ["verdict"],
75
+ }),
71
76
  computeDeltas: {
72
77
  duration: register.histogram({
73
78
  name: "beacon_fork_choice_compute_deltas_seconds",
@@ -180,3 +180,24 @@ export type ProtoNode = ProtoBlock & {
180
180
  bestChild?: number;
181
181
  bestDescendant?: number;
182
182
  };
183
+
184
+ /**
185
+ * PTC majority per PayloadAttestationData field: `true` or `false` once more than PTC_SIZE // 2
186
+ * members voted that way, `null` while neither outcome has a majority.
187
+ */
188
+ export type PtcQuorum = {
189
+ payloadPresent: boolean | null;
190
+ blobDataAvailable: boolean | null;
191
+ };
192
+
193
+ /**
194
+ * The PTC verdict fork choice acts on: `false` once either field has a majority against the
195
+ * payload, `true` once both have a majority for it, `null` while undecided. A single majority
196
+ * against is final on its own because should_extend_payload needs both in favour and
197
+ * should_build_on_full moves the proposer onto EMPTY on either.
198
+ */
199
+ export function getPtcVerdict({payloadPresent, blobDataAvailable}: PtcQuorum): boolean | null {
200
+ if (payloadPresent === false || blobDataAvailable === false) return false;
201
+ if (payloadPresent === true && blobDataAvailable === true) return true;
202
+ return null;
203
+ }
@@ -13,6 +13,7 @@ import {
13
13
  PayloadStatus,
14
14
  ProtoBlock,
15
15
  ProtoNode,
16
+ PtcQuorum,
16
17
  isGloasBlock,
17
18
  } from "./interface.js";
18
19
 
@@ -52,6 +53,12 @@ export function countNoVotes(attended: BitArray, yes: BitArray): number {
52
53
  return count;
53
54
  }
54
55
 
56
+ function majorityVote(attended: BitArray, yes: BitArray, threshold: number): boolean | null {
57
+ if (bitCount(yes.uint8Array) > threshold) return true;
58
+ if (countNoVotes(attended, yes) > threshold) return false;
59
+ return null;
60
+ }
61
+
55
62
  export const DEFAULT_PRUNE_THRESHOLD = 0;
56
63
  type ProposerBoost = {root: RootHex; score: bigint};
57
64
 
@@ -90,6 +97,8 @@ export class ProtoArray {
90
97
  lvhError?: LVHExecError;
91
98
 
92
99
  private previousProposerBoost: ProposerBoost | null = null;
100
+ /** Root read by shouldExtendPayload(), set even while the boost weight is withheld */
101
+ private proposerBoostRoot: RootHex | null = null;
93
102
 
94
103
  /**
95
104
  * PTC (Payload Timeliness Committee) votes per block as bitvectors
@@ -357,6 +366,7 @@ export class ProtoArray {
357
366
  applyScoreChanges({
358
367
  attestationDeltas,
359
368
  proposerBoost,
369
+ proposerBoostRoot,
360
370
  justifiedEpoch,
361
371
  justifiedRoot,
362
372
  finalizedEpoch,
@@ -365,6 +375,8 @@ export class ProtoArray {
365
375
  }: {
366
376
  attestationDeltas: number[];
367
377
  proposerBoost: ProposerBoost | null;
378
+ /** Defaults to the boosted root. Pass it explicitly when the boost weight is withheld */
379
+ proposerBoostRoot?: RootHex | null;
368
380
  justifiedEpoch: Epoch;
369
381
  justifiedRoot: RootHex;
370
382
  finalizedEpoch: Epoch;
@@ -463,7 +475,9 @@ export class ProtoArray {
463
475
  // We _must_ perform these functions separate from the weight-updating loop above to ensure
464
476
  // that we have a fully coherent set of weights before updating parent
465
477
  // best-child/descendant.
466
- const proposerBoostRoot = proposerBoost?.root ?? null;
478
+ // should_extend_payload reads store.proposer_boost_root even when should_apply_proposer_boost
479
+ // withholds the boost weight, so the tiebreak root is tracked apart from the applied boost
480
+ this.proposerBoostRoot = proposerBoostRoot ?? proposerBoost?.root ?? null;
467
481
  for (let nodeIndex = this.nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
468
482
  const node = this.nodes[nodeIndex];
469
483
  if (node === undefined) {
@@ -476,7 +490,7 @@ export class ProtoArray {
476
490
  // If the node has a parent, try to update its best-child and best-descendant.
477
491
  const parentIndex = node.parent;
478
492
  if (parentIndex !== undefined) {
479
- this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot, proposerBoostRoot);
493
+ this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot, this.proposerBoostRoot);
480
494
  }
481
495
  }
482
496
  // Update the previous proposer boost
@@ -801,6 +815,23 @@ export class ProtoArray {
801
815
  };
802
816
  }
803
817
 
818
+ /**
819
+ * PTC majority per vote field from the raw tallies, regardless of whether the payload is locally
820
+ * available. Returns `null` for pre-Gloas (or pruned) roots, which have no vote maps.
821
+ */
822
+ getPtcQuorum(blockRoot: RootHex): PtcQuorum | null {
823
+ const attended = this.ptcAttested.get(blockRoot);
824
+ const timelinessVotes = this.payloadTimelinessVotes.get(blockRoot);
825
+ const daVotes = this.payloadDataAvailabilityVotes.get(blockRoot);
826
+ if (attended === undefined || timelinessVotes === undefined || daVotes === undefined) {
827
+ return null;
828
+ }
829
+ return {
830
+ payloadPresent: majorityVote(attended, timelinessVotes, PAYLOAD_TIMELY_THRESHOLD),
831
+ blobDataAvailable: majorityVote(attended, daVotes, DATA_AVAILABILITY_TIMELY_THRESHOLD),
832
+ };
833
+ }
834
+
804
835
  getPreviousProposerBoostRoot(): RootHex {
805
836
  return this.previousProposerBoost?.root ?? HEX_ZERO_HASH;
806
837
  }
@@ -1043,6 +1074,8 @@ export class ProtoArray {
1043
1074
 
1044
1075
  private propagateValidExecutionStatusByIndex(validNodeIndex: number): void {
1045
1076
  let nodeIndex: number | undefined = validNodeIndex;
1077
+ // Payloads that transitioned to VALID, their gloas dependents are validated below
1078
+ const validatedPayloadHashes = new Set<RootHex>();
1046
1079
  // propagate till we keep encountering syncing status
1047
1080
  while (nodeIndex !== undefined) {
1048
1081
  const node = this.getNodeFromIndex(nodeIndex);
@@ -1050,8 +1083,36 @@ export class ProtoArray {
1050
1083
  break;
1051
1084
  }
1052
1085
  this.validateNodeByIndex(nodeIndex);
1086
+ if (node.payloadStatus === PayloadStatus.FULL && node.executionPayloadBlockHash !== null) {
1087
+ validatedPayloadHashes.add(node.executionPayloadBlockHash);
1088
+ }
1053
1089
  nodeIndex = node.parent;
1054
1090
  }
1091
+
1092
+ if (validatedPayloadHashes.size > 0) {
1093
+ this.validatePayloadDependents(validatedPayloadHashes);
1094
+ }
1095
+ }
1096
+
1097
+ /**
1098
+ * Gloas PENDING and EMPTY variants have no payload of their own, they inherit the execution status of
1099
+ * the payload they build on, which is their `executionPayloadBlockHash`. Once that payload is VALID, all
1100
+ * of them are, not only the ones on the chain the validation walked up (e.g. siblings, or blocks
1101
+ * building on a sibling's EMPTY variant).
1102
+ */
1103
+ private validatePayloadDependents(payloadHashes: Set<RootHex>): void {
1104
+ for (let nodeIndex = 0; nodeIndex < this.nodes.length; nodeIndex++) {
1105
+ const node = this.nodes[nodeIndex];
1106
+ if (
1107
+ node.payloadStatus === PayloadStatus.PENDING &&
1108
+ node.executionStatus === ExecutionStatus.Syncing &&
1109
+ node.executionPayloadBlockHash !== null &&
1110
+ payloadHashes.has(node.executionPayloadBlockHash)
1111
+ ) {
1112
+ // Also flips the sibling EMPTY variant
1113
+ this.validateNodeByIndex(nodeIndex);
1114
+ }
1115
+ }
1055
1116
  }
1056
1117
 
1057
1118
  /**
@@ -1092,6 +1153,7 @@ export class ProtoArray {
1092
1153
  this.applyScoreChanges({
1093
1154
  attestationDeltas: Array.from({length: this.nodes.length}, () => 0),
1094
1155
  proposerBoost: this.previousProposerBoost,
1156
+ proposerBoostRoot: this.proposerBoostRoot,
1095
1157
  justifiedEpoch: this.justifiedEpoch,
1096
1158
  justifiedRoot: this.justifiedRoot,
1097
1159
  finalizedEpoch: this.finalizedEpoch,