diff --git a/packages/fork-choice/src/forkChoice/fastConfirmation/data.ts b/packages/fork-choice/src/forkChoice/fastConfirmation/data.ts index 701616fe6998..85572cab3c02 100644 --- a/packages/fork-choice/src/forkChoice/fastConfirmation/data.ts +++ b/packages/fork-choice/src/forkChoice/fastConfirmation/data.ts @@ -12,8 +12,6 @@ export function createFastConfirmationCache(): FastConfirmationCache { blockByRoot: new Map(), ancestorRoots: new Map(), committeeBySlot: new Map(), - isDescendantByRootPair: new Map(), - voteWeightBySource: new Map(), checkpointStateByKey: new Map(), }; } diff --git a/packages/fork-choice/src/forkChoice/fastConfirmation/types.ts b/packages/fork-choice/src/forkChoice/fastConfirmation/types.ts index d8a9123a4a15..3db966600854 100644 --- a/packages/fork-choice/src/forkChoice/fastConfirmation/types.ts +++ b/packages/fork-choice/src/forkChoice/fastConfirmation/types.ts @@ -7,6 +7,20 @@ import {CheckpointWithPayloadStatus} from "../store.ts"; export type FastConfirmationBalanceSource = { state: IBeaconStateView | null; balances: EffectiveBalanceIncrements; + /** + * Per-validator effective balance, pre-zeroed for both inactive and slashed + * validators at the balance source epoch. Mirrors Lighthouse's `unslashed_balance` + * and is the single filter used by the per-validator hot loop in + * `precomputeChainAttestationScores` — removing all beacon-state access from + * that path. + * + * When `state` is available at construction, this is populated via + * `state.getEffectiveBalanceIncrementsZeroInactive()`, which zeros both + * inactive and slashed entries. When `state` is null, this equals `balances` + * (the fallback path's justified balances — inactive-zeroed but NOT + * slashed-zeroed). + */ + unslashedActiveBalances: EffectiveBalanceIncrements; }; export type ForkChoiceStateGetter = ( @@ -69,13 +83,21 @@ export type FastConfirmationCache = { blockByRoot: Map; ancestorRoots: Map; committeeBySlot: Map>; - isDescendantByRootPair: Map; - /** voteRoot -> totalWeight, keyed by sourceKey ("current" | "previous") */ - voteWeightBySource: Map>; headState?: IBeaconStateView; checkpointStateByKey: Map; }; +/** + * Minimal read-only view of a ProtoArray node, sufficient for parent-walking + * in `precomputeChainAttestationScores`. Narrowed from `ProtoNode` so mocks + * can satisfy this shape without constructing a real ProtoArray. + */ +export type ProtoNodeReadView = { + readonly parent?: number; + readonly slot: Slot; + readonly blockRoot: RootHex; +}; + export type FastConfirmationContext = { config: { CONFIRMATION_BYZANTINE_THRESHOLD: number; @@ -91,6 +113,33 @@ export type FastConfirmationContext = { getFinalizedCheckpoint(): CheckpointWithPayloadStatus; getEquivocatingIndices(): Set; getTrackedVotesCount(): number; + + /** + * Returns all ProtoArray node indices for this root. + * - Pre-Gloas: `[fullIdx]` + * - Gloas, payload not yet observed: `[pendingIdx, emptyIdx]` + * - Gloas, payload observed: `[pendingIdx, emptyIdx, fullIdx]` + * - Unknown root: `[]` + * + * Used once per chain block by `precomputeChainAttestationScores` to build + * the `indexToPosition` map covering every variant of each chain block. + */ + getNodeIndices(root: RootHex): readonly number[]; + + /** + * Read-only view of ProtoArray nodes for parent-walking in the precompute + * hot loop. Hoisted once at the top of `precomputeChainAttestationScores` + * so the inner walk does plain array access with no function-call overhead + * (walk runs up to O(V × depth) times per precompute invocation). + */ + getProtoNodeView(): {readonly nodes: ReadonlyArray}; + + /** + * Read-only handle on `voteNextIndices`. Hoisted once at the top of + * `precomputeChainAttestationScores` for direct indexed access in the + * per-validator loop. `NULL_VOTE_INDEX` sentinel is preserved. + */ + getVoteNextIndices(): readonly number[]; }; export interface IFastConfirmationRule { diff --git a/packages/fork-choice/src/forkChoice/fastConfirmation/utils.ts b/packages/fork-choice/src/forkChoice/fastConfirmation/utils.ts index df0d033ce1bb..64e536e8a44b 100644 --- a/packages/fork-choice/src/forkChoice/fastConfirmation/utils.ts +++ b/packages/fork-choice/src/forkChoice/fastConfirmation/utils.ts @@ -9,7 +9,7 @@ import { } from "@lodestar/state-transition"; import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {Logger, fromHex} from "@lodestar/utils"; -import {PayloadStatus, ProtoBlock} from "../../protoArray/interface.ts"; +import {NULL_VOTE_INDEX, PayloadStatus, ProtoBlock} from "../../protoArray/interface.ts"; import {CheckpointWithPayloadStatus, computeTotalBalance, equalCheckpointWithHex} from "../store.ts"; import { FastConfirmationBalanceSource, @@ -17,6 +17,7 @@ import { FastConfirmationContext, FastConfirmationSnapshot, IFastConfirmationStore, + ProtoNodeReadView, } from "./types.ts"; const COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR = 5; @@ -198,22 +199,6 @@ function getSlotRangeParticipants( return participants; } -function isDescendantCached( - ctx: FastConfirmationContext, - cache: FastConfirmationCache, - ancestorRoot: RootHex, - descendantRoot: RootHex -): boolean { - const cacheKey = `${ancestorRoot}:${descendantRoot}`; - if (cache.isDescendantByRootPair.has(cacheKey)) { - return cache.isDescendantByRootPair.get(cacheKey) ?? false; - } - - const isDescendant = ctx.isDescendant(ancestorRoot, descendantRoot); - cache.isDescendantByRootPair.set(cacheKey, isDescendant); - return isDescendant; -} - export function getBalanceSource( store: IFastConfirmationStore, cache: FastConfirmationCache, @@ -226,9 +211,17 @@ export function getBalanceSource( const fallbackBalances = kind === "previous" ? store.previousEpochObservedJustifiedBalances : store.currentEpochObservedJustifiedBalances; const state = getCheckpointState(store, cache, checkpoint); + const balances = state?.effectiveBalanceIncrements ?? fallbackBalances; + // When state is available, `getEffectiveBalanceIncrementsZeroInactive()` zeros BOTH + // inactive and slashed validators (see packages/state-transition/src/util/balance.ts). + // That gives us Lighthouse's `unslashed_balance` semantics in one bulk-iteration pass. + // When state is null, fall back to `balances`, which zero inactive validators but not + // slashed. + const unslashedActiveBalances = state?.getEffectiveBalanceIncrementsZeroInactive() ?? fallbackBalances; return { state, - balances: state?.effectiveBalanceIncrements ?? fallbackBalances, + balances, + unslashedActiveBalances, }; } @@ -321,66 +314,144 @@ export function computeProposerScore( } /** - * Build vote weight map in a single pass over all active validators. - * Groups validators by their latest vote root, summing their balances. - * Cached per sourceKey ("current" | "previous"). + * Performance-optimised replacement for the spec's per-block `get_attestation_score`. + * https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/fast-confirmation.md#get_attestation_score + * + * Spec computes one block's score by iterating every validator and summing balances + * whose latest-message root descends from the block — O(V × depth) per block, called + * O(B) times per chain-walk loop → O(B × V × depth) total. + * + * This function flips the computation: one pass over validators that walks each vote's + * parent chain until it hits a block on the target canonical chain, accumulates a + * `scoreAtPosition` array, then does a single suffix sum. Total: O(V × depth + B). + * + * Lodestar-specific optimisation over Lighthouse: `voteNextIndices[i]` already holds + * the ProtoArray node index, so we skip Lighthouse's `indices.get(vote_root)` hash + * lookup per validator. + * + * Chain ordering is terminal-first (see `getAncestorRoots`): position 0 is the block + * directly after `terminalRoot`, position `len-1` is `chainTip`. `terminalRoot` itself + * is NOT in the chain array. */ -function ensureVoteMaps( +export function precomputeChainAttestationScores( ctx: FastConfirmationContext, cache: FastConfirmationCache, balanceSource: FastConfirmationBalanceSource, - sourceKey: "current" | "previous" -): void { - if (cache.voteWeightBySource.has(sourceKey)) return; + chainTip: RootHex, + terminalRoot: RootHex +): Map { + const scores = new Map(); - const voteMap = new Map(); - const balances = balanceSource.balances; - const state = balanceSource.state; - const activeIndices = state?.getCurrentShuffling().activeIndices ?? null; - const equivocating = ctx.getEquivocatingIndices(); + const chain = getAncestorRoots(ctx, cache, chainTip, terminalRoot); + if (chain.length === 0) return scores; - if (activeIndices !== null && state) { - for (const i of activeIndices) { - if (state.getValidator(i).slashed) continue; - if (equivocating.has(i)) continue; - const msg = ctx.getLatestMessage(i); - if (!msg) continue; - const weight = balances[i] ?? 0; - if (weight === 0) continue; - voteMap.set(msg.root, (voteMap.get(msg.root) ?? 0) + weight); - } - } else { - for (let i = 0; i < balances.length; i++) { - const weight = balances[i] ?? 0; - if (weight === 0) continue; - if (equivocating.has(i)) continue; - const msg = ctx.getLatestMessage(i); - if (!msg) continue; - voteMap.set(msg.root, (voteMap.get(msg.root) ?? 0) + weight); + const terminalBlock = getBlock(ctx, cache, terminalRoot); + if (!terminalBlock) return scores; + const terminalSlot = terminalBlock.slot; + + // Register every variant index of each chain block, so a vote that walks to + // any variant (pre-Gloas: just FULL; Gloas: PENDING/EMPTY/FULL) lands at the + // same chain position. This preserves the root-collapsing semantics of the + // old `isDescendant(blockRoot, voteRoot)` path. + const indexToPosition = new Map(); + for (let pos = 0; pos < chain.length; pos++) { + for (const nodeIdx of ctx.getNodeIndices(chain[pos])) { + indexToPosition.set(nodeIdx, pos); } } - cache.voteWeightBySource.set(sourceKey, voteMap); -} + const scoreAtPosition = new Array(chain.length).fill(0); -export function getAttestationScore( - ctx: FastConfirmationContext, - cache: FastConfirmationCache, - balanceSource: FastConfirmationBalanceSource, - blockRoot: RootHex, - sourceKey: "current" | "previous" -): number { - ensureVoteMaps(ctx, cache, balanceSource, sourceKey); - const voteMap = cache.voteWeightBySource.get(sourceKey) ?? new Map(); + const {nodes: protoNodes} = ctx.getProtoNodeView(); + const voteNextIndices = ctx.getVoteNextIndices(); + const unslashedActiveBalances = balanceSource.unslashedActiveBalances; + const equivocating = ctx.getEquivocatingIndices(); - let score = 0; - for (const [voteRoot, weight] of voteMap) { - if (isDescendantCached(ctx, cache, blockRoot, voteRoot)) { - score += weight; + // Fast-path cache for consecutive validators voting for the same node. + // Most validators on a healthy network vote for the current head, so the + // same `voteIdx` repeats → reuse the cached landing position and skip the + // parent-chain walk. `-1` is the sentinel for "this vote lands nowhere". + let lastVoteIdx = NULL_VOTE_INDEX; + let lastLandedPos = -1; + + for (let i = 0; i < voteNextIndices.length; i++) { + // Filters: + // - `unslashedActiveBalances[i] === 0` covers both inactive and slashed + // validators when state was available at balance-source construction; + // when state was null, the fallback preserves the null-state behavior + // (inactive zeroed, slashed NOT zeroed). + // - Equivocators are filtered here because latest-message balances + // should not count for validators with attester slashings. + // - `NULL_VOTE_INDEX` = validator never voted (or votes pruned past the + // finalized root). + const balance = unslashedActiveBalances[i] ?? 0; + if (balance === 0) continue; + if (equivocating.has(i)) continue; + const voteIdx = voteNextIndices[i]; + if (voteIdx === NULL_VOTE_INDEX) continue; + + let landedPos: number; + if (voteIdx === lastVoteIdx) { + // Fast path: same vote target as the previous validator. Skip the walk. + landedPos = lastLandedPos; + } else { + // Slow path: walk parent chain from the vote's node until we either + // land on a chain block, walk below the chain window, or run out of + // parents. + let cur: number | undefined = voteIdx; + landedPos = -1; + while (cur !== undefined) { + // Case 1 — landed. `cur` is a variant index of some `chain[pos]`. + // The suffix sum at the end of this function propagates this vote's + // contribution to every position closer to terminal. + const hit = indexToPosition.get(cur); + if (hit !== undefined) { + landedPos = hit; + break; + } + + const node: ProtoNodeReadView | undefined = protoNodes[cur]; + // Case 2 — defensive: malformed node index. `voteNextIndices` should + // always point into `protoArray.nodes`, so this shouldn't happen in + // production. Treat as "did not land". + if (node === undefined) break; + + // Case 3 — walked below the chain window. Chain invariant: + // `chain[i].slot > terminalSlot` for every i. Block parent-slot + // invariant: `parent.slot < node.slot` strictly. Once we cross below + // `terminalSlot`, no chain block is reachable. The vote is either + // terminalRoot itself, an ancestor of terminalRoot, or on a fork + // that diverged at or below terminalRoot's depth — equivalent to + // the old `isDescendant(chain[k], voteRoot) === false` for all k. + if (node.slot <= terminalSlot) break; + + // Case 4 — walk up. `node.parent` may be `undefined` for genesis; + // the next iteration's `cur !== undefined` check handles that exit. + cur = node.parent; + } + + lastVoteIdx = voteIdx; + lastLandedPos = landedPos; + } + + if (landedPos !== -1) { + scoreAtPosition[landedPos] += balance; } } - return score; + // Suffix sum over the chain. Chain is terminal-first (position 0 is closest + // to terminalRoot, position len-1 is chainTip). A vote landing at position j + // supports every chain[k] with k ≤ j, because chain[j] is a descendant of + // every chain[k] with k ≤ j by the terminal-first ordering. Iterating from + // tip (highest position) down to 0 and accumulating gives `scores[chain[k]]` + // = total weight of votes landing at positions k..len-1. + let running = 0; + for (let k = chain.length - 1; k >= 0; k--) { + running += scoreAtPosition[k]; + scores.set(chain[k], running); + } + + return scores; } export function getBlockSupportBetweenSlots( @@ -583,12 +654,24 @@ export function computeSafetyThreshold( return {threshold, proposerScore, maximumSupport, supportDiscount, adversarialWeight}; } -export function isOneConfirmed( +/** + * Spec: `is_one_confirmed(store, balance_source, block_root)`. + * https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/fast-confirmation.md#is_one_confirmed + * + * Difference from the spec: the attestation score is passed in as a precomputed + * parameter rather than computed inline via `get_attestation_score`. Callers obtain + * it from `precomputeChainAttestationScores`, which computes scores for every block + * on a chain in a single pass — O(V × depth + B) instead of the spec's O(B × V × depth). + * The rest of the logic (proposer score boost, support discount, adversarial weight, + * safety threshold) is identical to the spec. + */ +export function isOneConfirmedWithScore( ctx: FastConfirmationContext, store: IFastConfirmationStore, cache: FastConfirmationCache, balanceSource: FastConfirmationBalanceSource, blockRoot: RootHex, + attestationScore: number, sourceKey: "current" | "previous", logger?: Logger ): boolean { @@ -597,9 +680,6 @@ export function isOneConfirmed( const block = getBlock(ctx, cache, blockRoot); if (!block) return false; - // Spec: is_one_confirmed(store, balance_source, block_root) - // Compare actual support for this block against the computed LMD-GHOST safety threshold. - const support = getAttestationScore(ctx, cache, balanceSource, blockRoot, sourceKey); const {threshold, proposerScore, maximumSupport, supportDiscount, adversarialWeight} = computeSafetyThreshold( ctx, store, @@ -607,14 +687,14 @@ export function isOneConfirmed( balanceSource, blockRoot ); - const isConfirmed = support > threshold; + const isConfirmed = attestationScore > threshold; logger?.debug("Fast confirmation one-confirmed evaluation", { blockRoot, blockSlot: block.slot, currentSlot, sourceKey, - support, + support: attestationScore, threshold, proposerScore, maximumSupport, @@ -626,6 +706,20 @@ export function isOneConfirmed( return isConfirmed; } +/** + * Looks up the precomputed attestation score for a block on the chain that was + * passed to `precomputeChainAttestationScores`. Throws if the block is not in + * the map — a miss indicates the caller stepped outside the chain used for the + * precompute, which is a programming bug, not "no validators voted". + */ +export function getPrecomputedScoreOrThrow(scores: Map, blockRoot: RootHex): number { + const score = scores.get(blockRoot); + if (score === undefined) { + throw new Error(`Fast confirmation: attestation score not precomputed for blockRoot ${blockRoot}`); + } + return score; +} + export function getCurrentTarget(ctx: FastConfirmationContext): CheckpointWithPayloadStatus | null { const head = ctx.getHead().blockRoot; const currentEpoch = computeEpochAtSlot(ctx.getCurrentSlot()); @@ -709,7 +803,11 @@ export function computeHonestFfgSupportForCurrentTarget( const totalActiveBalance = computeTotalBalance(targetState.getEffectiveBalanceIncrementsZeroInactive()); const ffgSupport = getCurrentTargetScore(ctx, store, cache); const tillNowFFGWeight = estimateCommitteeWeightBetweenSlots( - {state: targetState, balances: targetState.effectiveBalanceIncrements}, + { + state: targetState, + balances: targetState.effectiveBalanceIncrements, + unslashedActiveBalances: targetState.getEffectiveBalanceIncrementsZeroInactive(), + }, computeStartSlotAtEpoch(currentEpoch), (currentSlot - 1) as Slot ); @@ -794,8 +892,12 @@ export function isConfirmedChainSafe( const chainRoots = getAncestorRoots(ctx, cache, confirmedRoot, startRoot); const previousBalanceSource = getPreviousBalanceSource(store, cache); + const chainScores = precomputeChainAttestationScores(ctx, cache, previousBalanceSource, confirmedRoot, startRoot); for (const root of chainRoots) { - if (!isOneConfirmed(ctx, store, cache, previousBalanceSource, root, "previous", logger)) { + const attestationScore = getPrecomputedScoreOrThrow(chainScores, root); + if ( + !isOneConfirmedWithScore(ctx, store, cache, previousBalanceSource, root, attestationScore, "previous", logger) + ) { logger?.debug("Fast confirmation chain-safety failed", { confirmedRoot, reason: "unconfirmed_block_in_chain", @@ -825,6 +927,22 @@ export function findLatestConfirmedDescendant( const headJustification = snapshot.headUnrealized ?? getUnrealizedJustification(ctx, cache, snapshot.headRoot); const currentBalanceSource = getCurrentBalanceSource(store, cache); + // Precompute per-chain attestation scores once. + // Scores are valid for both loops because loop 2's chain (head → newConfirmedRoot) + // is a tip-side prefix of loop 1's chain (head → latestConfirmedRoot). For any + // block B in loop 2's chain, the set of chain-descendants of B is identical in + // both chains, so score[B] is unchanged. A vote whose loop-1 landing position + // falls between newConfirmedRoot and latestConfirmedRoot contributes only to + // `score[ancestors of newConfirmedRoot]`, none of which loop 2 ever queries — + // so no over-counting either. + const chainScores = precomputeChainAttestationScores( + ctx, + cache, + currentBalanceSource, + snapshot.headRoot, + latestConfirmedRoot + ); + const confirmedBlock = getBlock(ctx, cache, confirmedRoot); const confirmedEpoch = confirmedBlock ? computeEpochAtSlot(confirmedBlock.slot) : null; const loop1Condition = @@ -870,7 +988,17 @@ export function findLatestConfirmedDescendant( }); break; } - const isConfirmed = isOneConfirmed(ctx, store, cache, currentBalanceSource, blockRoot, "current", logger); + const attestationScore = getPrecomputedScoreOrThrow(chainScores, blockRoot); + const isConfirmed = isOneConfirmedWithScore( + ctx, + store, + cache, + currentBalanceSource, + blockRoot, + attestationScore, + "current", + logger + ); if (!isConfirmed) { logger?.debug("Fast confirmation previous-epoch loop stopped", { reason: "block_not_one_confirmed", @@ -912,7 +1040,17 @@ export function findLatestConfirmedDescendant( break; } - const isConfirmed = isOneConfirmed(ctx, store, cache, currentBalanceSource, blockRoot, "current", logger); + const attestationScore = getPrecomputedScoreOrThrow(chainScores, blockRoot); + const isConfirmed = isOneConfirmedWithScore( + ctx, + store, + cache, + currentBalanceSource, + blockRoot, + attestationScore, + "current", + logger + ); if (!isConfirmed) { logger?.debug("Fast confirmation current-epoch loop stopped", { reason: "block_not_one_confirmed", diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index bc057802aa9f..50e7bb328cda 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1964,6 +1964,13 @@ export class ForkChoice implements IForkChoice { } return count; }, + getNodeIndices: (root: RootHex) => { + const entry = this.protoArray.indices.get(root); + if (entry === undefined) return []; + return typeof entry === "number" ? [entry] : entry; + }, + getProtoNodeView: () => ({nodes: this.protoArray.nodes}), + getVoteNextIndices: () => this.voteNextIndices, }; } } diff --git a/packages/fork-choice/test/unit/forkChoice/fastConfirmation.test.ts b/packages/fork-choice/test/unit/forkChoice/fastConfirmation.test.ts index faf84872cc65..bd9e3c963ba7 100644 --- a/packages/fork-choice/test/unit/forkChoice/fastConfirmation.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/fastConfirmation.test.ts @@ -13,7 +13,8 @@ import { findLatestConfirmedDescendant, getBlockSupportBetweenSlots, isConfirmedChainSafe, - isOneConfirmed, + isOneConfirmedWithScore, + precomputeChainAttestationScores, } from "../../../src/forkChoice/fastConfirmation/utils.js"; import { ZERO_ROOT, @@ -46,7 +47,11 @@ describe("fast confirmation", () => { ctx, store, createFastConfirmationCache(), - {state, balances: state.effectiveBalanceIncrements}, + { + state, + balances: state.effectiveBalanceIncrements, + unslashedActiveBalances: state.getEffectiveBalanceIncrementsZeroInactive(), + }, block.blockRoot ); @@ -60,50 +65,41 @@ describe("fast confirmation", () => { expect(adjustCommitteeWeightEstimateToEnsureSafety(1001)).toBe(1007); }); - it("isOneConfirmed returns true only when support exceeds the computed threshold", () => { + it("isOneConfirmedWithScore returns true only when support exceeds the computed threshold", () => { const block = makeBlock(1, ZERO_ROOT); const blocks = [makeBlock(0, ZERO_ROOT, {blockRoot: ZERO_ROOT}), block]; const state = makeState(100, 32, [1 as Slot]); const store = makeStore(ZERO_ROOT, ZERO_ROOT, ZERO_ROOT, 0, 0, ZERO_ROOT, block.blockRoot, state); + const balanceSource = { + state, + balances: state.effectiveBalanceIncrements, + unslashedActiveBalances: state.getEffectiveBalanceIncrementsZeroInactive(), + }; - const enoughSupportCtx = makeContext( - 2 as Slot, - block.blockRoot, - blocks, - latestMessagesFor(100, block.blockRoot, 0, 4), - {epoch: 0, rootHex: ZERO_ROOT}, - state - ); - const lowSupportCtx = makeContext( - 2 as Slot, - block.blockRoot, - blocks, - latestMessagesFor(100, block.blockRoot, 0, 2), - {epoch: 0, rootHex: ZERO_ROOT}, - state - ); - - expect( - isOneConfirmed( - enoughSupportCtx, - store, - createFastConfirmationCache(), - {state, balances: state.effectiveBalanceIncrements}, + const run = (voterCount: number): boolean => { + const ctx = makeContext( + 2 as Slot, block.blockRoot, - "current" - ) - ).toBe(true); - - expect( - isOneConfirmed( - lowSupportCtx, + blocks, + latestMessagesFor(100, block.blockRoot, 0, voterCount), + {epoch: 0, rootHex: ZERO_ROOT}, + state + ); + const cache = createFastConfirmationCache(); + const scores = precomputeChainAttestationScores(ctx, cache, balanceSource, block.blockRoot, ZERO_ROOT); + return isOneConfirmedWithScore( + ctx, store, - createFastConfirmationCache(), - {state, balances: state.effectiveBalanceIncrements}, + cache, + balanceSource, block.blockRoot, + scores.get(block.blockRoot) ?? 0, "current" - ) - ).toBe(false); + ); + }; + + expect(run(4)).toBe(true); // 4 of 100 validators voting gives enough support + expect(run(2)).toBe(false); // 2 of 100 is below threshold }); it("getBlockSupportBetweenSlots counts validators whose latest message is exactly the target block", () => { @@ -124,7 +120,11 @@ describe("fast confirmation", () => { ctx, store, createFastConfirmationCache(), - {state, balances: state.effectiveBalanceIncrements}, + { + state, + balances: state.effectiveBalanceIncrements, + unslashedActiveBalances: state.getEffectiveBalanceIncrementsZeroInactive(), + }, parent.blockRoot, 2 as Slot, 2 as Slot diff --git a/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts index 5f836d7d28f0..2f0dbf8344a4 100644 --- a/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts +++ b/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts @@ -8,6 +8,7 @@ import { IFastConfirmationStore, } from "../../../src/forkChoice/fastConfirmation/types.js"; import {ExecutionStatus, PayloadStatus, ProtoBlock} from "../../../src/index.js"; +import {NULL_VOTE_INDEX} from "../../../src/protoArray/interface.js"; export const ZERO_ROOT = rootFromNumber(0); @@ -68,12 +69,25 @@ export function makeState( const slashed = new Set(slashedIndices); const committees = new Map(committeeSlots.map((slot) => [slot, activeIndices])); + // Matches production `getEffectiveBalanceIncrementsZeroInactive`, which zeros + // BOTH inactive and slashed validators. The mock lives at the `activeIndices` + // level (no separate shuffling), so inactive-zero falls out for free; we only + // need to explicitly zero slashed indices. + const balancesZeroSlashed = + slashed.size > 0 + ? (() => { + const copy = new Uint16Array(balances); + for (const i of slashed) copy[i] = 0; + return copy; + })() + : balances; + return { slot: (committeeSlots.length > 0 ? Math.max(...committeeSlots) : 0) as Slot, epoch: 0, effectiveBalanceIncrements: balances, - getEffectiveBalanceIncrementsZeroInactive: () => balances, - getCurrentShuffling: () => ({activeIndices} as {activeIndices: Uint32Array}), + getEffectiveBalanceIncrementsZeroInactive: () => balancesZeroSlashed, + getCurrentShuffling: () => ({activeIndices}) as {activeIndices: Uint32Array}, getBeaconCommitteeCountPerSlot: () => 1, getBeaconCommittee: (slot: Slot) => committees.get(slot) ?? activeIndices, getValidator: (index: ValidatorIndex) => ({ @@ -134,6 +148,31 @@ export function makeContext( const blocksByRoot = new Map(blocks.map((block) => [block.blockRoot, block])); const equivocating = new Set(equivocatingIndices); + // Build a fake ProtoArray-like structure so the three new accessors can + // satisfy `precomputeChainAttestationScores` without a real ProtoArray. + // Assumes `blocks` is in topological order (ancestors before descendants), + // which all existing fixtures satisfy. + const nodeIndexByRoot = new Map(); + const protoNodes: {readonly parent?: number; readonly slot: Slot; readonly blockRoot: RootHex}[] = []; + for (const block of blocks) { + const parentIdx = nodeIndexByRoot.get(block.parentRoot); + const idx = protoNodes.length; + protoNodes.push({parent: parentIdx, slot: block.slot, blockRoot: block.blockRoot}); + nodeIndexByRoot.set(block.blockRoot, idx); + } + + // voteNextIndices: one entry per validator. Derive from latestMessages — + // validators without a latest message (or voting for a block we don't know + // about) get NULL_VOTE_INDEX. + const validatorCount = state.effectiveBalanceIncrements.length; + const voteNextIndices = new Array(validatorCount).fill(NULL_VOTE_INDEX); + for (const [vIdx, msg] of latestMessages) { + const nodeIdx = nodeIndexByRoot.get(msg.root); + if (nodeIdx !== undefined && vIdx < validatorCount) { + voteNextIndices[vIdx] = nodeIdx; + } + } + return { config: { CONFIRMATION_BYZANTINE_THRESHOLD: 25, @@ -169,6 +208,12 @@ export function makeContext( getFinalizedCheckpoint: () => checkpoint(0, ZERO_ROOT), getEquivocatingIndices: () => equivocating, getTrackedVotesCount: () => latestMessages.size, + getNodeIndices: (root: RootHex) => { + const idx = nodeIndexByRoot.get(root); + return idx === undefined ? [] : [idx]; + }, + getProtoNodeView: () => ({nodes: protoNodes}), + getVoteNextIndices: () => voteNextIndices, }; } diff --git a/packages/fork-choice/test/unit/forkChoice/precomputeChainAttestationScores.test.ts b/packages/fork-choice/test/unit/forkChoice/precomputeChainAttestationScores.test.ts new file mode 100644 index 000000000000..fc33309d5dda --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/precomputeChainAttestationScores.test.ts @@ -0,0 +1,341 @@ +import {describe, expect, it} from "vitest"; +import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; +import {createFastConfirmationCache} from "../../../src/forkChoice/fastConfirmation/data.js"; +import {FastConfirmationContext, ProtoNodeReadView} from "../../../src/forkChoice/fastConfirmation/types.js"; +import {getAncestorRoots, precomputeChainAttestationScores} from "../../../src/forkChoice/fastConfirmation/utils.js"; +import {ProtoBlock} from "../../../src/index.js"; +import {NULL_VOTE_INDEX} from "../../../src/protoArray/interface.js"; +import {ZERO_ROOT, makeBlock, makeContext, makeState, rootFromNumber} from "./fastConfirmationTestUtils.js"; + +/** + * Regression tests for `precomputeChainAttestationScores`. Each fixture pairs an + * input scenario with the exact per-block scores the algorithm should produce. + * Originally this matrix compared output against the pre-optimization + * `getAttestationScore` — now deleted — and the expected values here are the + * ones that comparison produced. + */ +describe("precomputeChainAttestationScores", () => { + type Fixture = { + name: string; + blocks: ProtoBlock[]; + validatorCount: number; + balancePerValidator: number; + committeeSlots: Slot[]; + slashedIndices?: ValidatorIndex[]; + equivocatingIndices?: ValidatorIndex[]; + latestMessages: Map; + chainTip: RootHex; + terminalRoot: RootHex; + headRoot: RootHex; + currentSlot: Slot; + /** Expected score per chain position, terminal-first. `undefined` means chain is empty. */ + expectedScores: number[] | undefined; + }; + + function buildLinearChain(n: number): ProtoBlock[] { + // chain: genesis (slot 0, ZERO_ROOT) ← block 1 ← block 2 ← … ← block n + const blocks: ProtoBlock[] = [makeBlock(0, ZERO_ROOT, {blockRoot: ZERO_ROOT})]; + for (let i = 1; i <= n; i++) { + blocks.push(makeBlock(i, blocks[i - 1].blockRoot)); + } + return blocks; + } + + function allVotingFor(count: number, root: RootHex, epoch: Epoch = 0) { + const out = new Map(); + for (let i = 0; i < count; i++) out.set(i, {root, epoch}); + return out; + } + + function fixtures(): Fixture[] { + const linear = buildLinearChain(3); + const tip = linear.at(-1) as ProtoBlock; + const mid = linear[2]; + const base = linear[1]; + + // For off-chain fork fixtures. + const forkChain: ProtoBlock[] = [...linear, makeBlock(2, base.blockRoot, {blockRoot: rootFromNumber(1000)})]; + const forkBlock = forkChain.at(-1) as ProtoBlock; + + return [ + { + // All 32 validators vote for tip. Suffix sum makes every chain position + // see the full 32 × 32 = 1024 weight. + name: "1) linear chain — all votes on tip", + blocks: linear, + validatorCount: 32, + balancePerValidator: 32, + committeeSlots: [tip.slot], + latestMessages: allVotingFor(32, tip.blockRoot), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + // Chain terminal-first: [base, mid, tip]; all see 32×32. + expectedScores: [1024, 1024, 1024], + }, + { + // 10 on tip, 10 on mid, 10 on base. Suffix sum: + // base = 10 (tip) + 10 (mid) + 10 (base) = 30 × 32 = 960 + // mid = 10 (tip) + 10 (mid) = 20 × 32 = 640 + // tip = 10 (tip) = 10 × 32 = 320 + name: "2) linear chain — votes spread across positions", + blocks: linear, + validatorCount: 30, + balancePerValidator: 32, + committeeSlots: [tip.slot], + latestMessages: new Map([ + ...Array.from({length: 10}, (_, i) => [i, {root: tip.blockRoot, epoch: 0}] as const), + ...Array.from({length: 10}, (_, i) => [i + 10, {root: mid.blockRoot, epoch: 0}] as const), + ...Array.from({length: 10}, (_, i) => [i + 20, {root: base.blockRoot, epoch: 0}] as const), + ]), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [960, 640, 320], + }, + { + // 10 on tip + 10 on forkBlock (a sibling of `mid` under `base`). + // Fork votes land at position 0 (base); tip votes land at position 2 (tip). + // base = 10 (tip) + 10 (fork) = 640 + // mid = 10 (tip) = 320 + // tip = 10 (tip) = 320 + name: "3) fork — off-chain votes land at the deepest shared ancestor", + blocks: forkChain, + validatorCount: 20, + balancePerValidator: 32, + committeeSlots: [tip.slot], + latestMessages: new Map([ + ...Array.from({length: 10}, (_, i) => [i, {root: tip.blockRoot, epoch: 0}] as const), + ...Array.from({length: 10}, (_, i) => [i + 10, {root: forkBlock.blockRoot, epoch: 0}] as const), + ]), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [640, 320, 320], + }, + { + // 5 equivocators out of 32; only 27 × 32 = 864 count. + name: "4) equivocators filtered", + blocks: linear, + validatorCount: 32, + balancePerValidator: 32, + committeeSlots: [tip.slot], + equivocatingIndices: [0, 1, 2, 3, 4], + latestMessages: allVotingFor(32, tip.blockRoot), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [864, 864, 864], + }, + { + // 5 slashed out of 32 — balance zeroed by getEffectiveBalanceIncrementsZeroInactive. + // 27 × 32 = 864. + name: "5) slashed filtered", + blocks: linear, + validatorCount: 32, + balancePerValidator: 32, + committeeSlots: [tip.slot], + slashedIndices: [0, 1, 2, 3, 4], + latestMessages: allVotingFor(32, tip.blockRoot), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [864, 864, 864], + }, + { + // Only half (16 of 32) have a latest message. The rest have + // voteNextIndices[i] === NULL_VOTE_INDEX and are skipped. + // 16 × 32 = 512. + name: "6) validators without latest message are skipped", + blocks: linear, + validatorCount: 32, + balancePerValidator: 32, + committeeSlots: [tip.slot], + latestMessages: allVotingFor(16, tip.blockRoot), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [512, 512, 512], + }, + { + // terminalRoot === chainTip → getAncestorRoots returns []; empty map. + name: "7) degenerate chain — terminalRoot equals chainTip", + blocks: linear, + validatorCount: 16, + balancePerValidator: 32, + committeeSlots: [tip.slot], + latestMessages: allVotingFor(16, tip.blockRoot), + chainTip: tip.blockRoot, + terminalRoot: tip.blockRoot, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: undefined, + }, + { + // Chain = [mid, tip] (terminal = base, excluded). forkBlock's parent is + // base (slot 1, === terminalSlot), so the walk from forkBlock hits + // base.slot <= terminalSlot and breaks without landing — fork votes are + // dropped. Tip votes land at tip (position 1). + // mid = 0 (nothing lands at mid) + 320 (from suffix of tip) = 320 + // tip = 320 + name: "8) shorter chain — fork votes break at terminalSlot", + blocks: forkChain, + validatorCount: 20, + balancePerValidator: 32, + committeeSlots: [tip.slot], + latestMessages: new Map([ + ...Array.from({length: 10}, (_, i) => [i, {root: tip.blockRoot, epoch: 0}] as const), + ...Array.from({length: 10}, (_, i) => [i + 10, {root: forkBlock.blockRoot, epoch: 0}] as const), + ]), + chainTip: tip.blockRoot, + terminalRoot: base.blockRoot, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [320, 320], + }, + { + // Indices 0,1 equivocating; 2,3 slashed. 4–13 vote tip, 14–18 vote mid, + // 19–23 vote base, 24–28 vote fork, 29–39 are NULL_VOTE_INDEX. + // Effective contributions (per-validator balance 32): + // tip: 10 validators at tip → 320 + // mid: 5 validators at mid → 160 + // base: 5 validators at base + 5 at fork (land at base) → 320 + // Suffix sum (terminal-first [base, mid, tip]): + // base = 320 + 160 + 320 = 800 + // mid = 320 + 160 = 480 + // tip = 320 = 320 + name: "9) mixed filters — equivocators + slashed + null votes + off-chain fork", + blocks: forkChain, + validatorCount: 40, + balancePerValidator: 32, + committeeSlots: [tip.slot], + equivocatingIndices: [0, 1], + slashedIndices: [2, 3], + latestMessages: new Map([ + [0, {root: tip.blockRoot, epoch: 0}], + [1, {root: tip.blockRoot, epoch: 0}], + [2, {root: tip.blockRoot, epoch: 0}], + [3, {root: tip.blockRoot, epoch: 0}], + ...Array.from({length: 10}, (_, i) => [i + 4, {root: tip.blockRoot, epoch: 0}] as const), + ...Array.from({length: 5}, (_, i) => [i + 14, {root: mid.blockRoot, epoch: 0}] as const), + ...Array.from({length: 5}, (_, i) => [i + 19, {root: base.blockRoot, epoch: 0}] as const), + ...Array.from({length: 5}, (_, i) => [i + 24, {root: forkBlock.blockRoot, epoch: 0}] as const), + ]), + chainTip: tip.blockRoot, + terminalRoot: ZERO_ROOT, + headRoot: tip.blockRoot, + currentSlot: (tip.slot + 1) as Slot, + expectedScores: [800, 480, 320], + }, + ]; + } + + for (const fixture of fixtures()) { + it(fixture.name, () => { + const state = makeState( + fixture.validatorCount, + fixture.balancePerValidator, + fixture.committeeSlots, + fixture.slashedIndices ?? [] + ); + const ctx = makeContext( + fixture.currentSlot, + fixture.headRoot, + fixture.blocks, + fixture.latestMessages, + {epoch: 0, rootHex: ZERO_ROOT}, + state, + fixture.equivocatingIndices ?? [] + ); + const balanceSource = { + state, + balances: state.effectiveBalanceIncrements, + unslashedActiveBalances: state.getEffectiveBalanceIncrementsZeroInactive(), + }; + const cache = createFastConfirmationCache(); + + const precomputed = precomputeChainAttestationScores( + ctx, + cache, + balanceSource, + fixture.chainTip, + fixture.terminalRoot + ); + + if (fixture.expectedScores === undefined) { + expect(precomputed.size).toBe(0); + return; + } + + const chain = getAncestorRoots(ctx, cache, fixture.chainTip, fixture.terminalRoot); + expect(chain.length, "chain length should match expected scores length").toBe(fixture.expectedScores.length); + for (let i = 0; i < chain.length; i++) { + expect(precomputed.get(chain[i]), `score at chain[${i}] (${chain[i]})`).toBe(fixture.expectedScores[i]); + } + }); + } + + it("Gloas variant collapse — vote for EMPTY variant lands at chain position registered for FULL", () => { + // Direct unit test (not via the makeContext mock) to exercise `getNodeIndices` + // returning multiple variant indices per root. Mimics a Gloas fixture where a + // validator voted for an EMPTY variant while the canonical chain block + // registers via FULL. + const chainTip: RootHex = rootFromNumber(10); + const terminalRoot: RootHex = rootFromNumber(1); + const terminalSlot = 1 as Slot; + + // ProtoArray-like nodes. Indices 0 = terminal (FULL), 1 = block@slot 2 PENDING, + // 2 = block@slot 2 EMPTY, 3 = block@slot 2 FULL. Chain passes through the block + // at slot 2; voter points to the EMPTY variant (idx 2). + const protoNodes: ProtoNodeReadView[] = [ + {parent: undefined, slot: terminalSlot, blockRoot: terminalRoot}, // terminal + {parent: 0, slot: 2 as Slot, blockRoot: chainTip}, // PENDING + {parent: 1, slot: 2 as Slot, blockRoot: chainTip}, // EMPTY + {parent: 1, slot: 2 as Slot, blockRoot: chainTip}, // FULL + ]; + const voteNextIndices = [2 /* validator 0 voted for EMPTY */, NULL_VOTE_INDEX]; + const unslashedActiveBalances = new Uint16Array([32, 32]); + + const unused = () => { + throw new Error("unused accessor called"); + }; + const ctx: FastConfirmationContext = { + config: {CONFIRMATION_BYZANTINE_THRESHOLD: 25, PROPOSER_SCORE_BOOST: 40}, + getCurrentSlot: () => 3 as Slot, + getHead: unused as unknown as FastConfirmationContext["getHead"], + getBlock: (root: RootHex) => + root === chainTip + ? ({slot: 2 as Slot, blockRoot: chainTip, parentRoot: terminalRoot} as ProtoBlock) + : root === terminalRoot + ? ({slot: terminalSlot, blockRoot: terminalRoot, parentRoot: ZERO_ROOT} as ProtoBlock) + : null, + getAncestor: unused as unknown as FastConfirmationContext["getAncestor"], + isDescendant: unused as unknown as FastConfirmationContext["isDescendant"], + getLatestMessage: unused as unknown as FastConfirmationContext["getLatestMessage"], + getUnrealizedJustified: unused as unknown as FastConfirmationContext["getUnrealizedJustified"], + getFinalizedCheckpoint: unused as unknown as FastConfirmationContext["getFinalizedCheckpoint"], + getEquivocatingIndices: () => new Set(), + getTrackedVotesCount: () => 1, + // The key accessor under test: chainTip maps to all three variant indices. + getNodeIndices: (root) => (root === chainTip ? [1, 2, 3] : []), + getProtoNodeView: () => ({nodes: protoNodes}), + getVoteNextIndices: () => voteNextIndices, + }; + const cache = createFastConfirmationCache(); + const balanceSource = {state: null, balances: unslashedActiveBalances, unslashedActiveBalances}; + + const precomputed = precomputeChainAttestationScores(ctx, cache, balanceSource, chainTip, terminalRoot); + + // Validator 0 voted for EMPTY variant of chainTip (node idx 2). Because + // `indexToPosition` registers all three variants of chainTip at position 0, + // the walk lands immediately at position 0, contributing balance 32 to + // the suffix-sum score for chainTip. + expect(precomputed.get(chainTip)).toBe(32); + }); +});