diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 49041c4d1f83..d53028a0607d 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params"; import { DataAvailabilityStatus, EffectiveBalanceIncrements, @@ -455,30 +455,12 @@ export class ForkChoice implements IForkChoice { } // No reorg if headBlock is "not weak" ie. headBlock's weight exceeds (REORG_HEAD_WEIGHT_THRESHOLD = 20)% of total attester weight - // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_head_weak - const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { - slotsPerEpoch: SLOTS_PER_EPOCH, - committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, - }); - const headNode = this.protoArray.getNode(headBlock.blockRoot, headBlock.payloadStatus); - // If headNode is unavailable, give up reorg - if (headNode === undefined || headNode.weight >= reorgThreshold) { + if (!this.isHeadWeak(headBlock.blockRoot)) { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.HeadBlockNotWeak}; } // No reorg if parentBlock is "not strong" ie. parentBlock's weight is less than or equal to (REORG_PARENT_WEIGHT_THRESHOLD = 160)% of total attester weight - // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/phase0/fork-choice.md#is_parent_strong - // For Gloas: measure support for the parent beacon block root regardless of its payload status by - // looking up the PENDING variant. - // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-is_parent_strong - const parentThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { - slotsPerEpoch: SLOTS_PER_EPOCH, - committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD, - }); - const parentStrongVariant = isGloasBlock(parentBlock) ? PayloadStatus.PENDING : PayloadStatus.FULL; - const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentStrongVariant); - // If parentNode is unavailable, give up reorg - if (parentNode === undefined || parentNode.weight <= parentThreshold) { + if (!this.isParentStrong(parentBlock.blockRoot)) { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ParentBlockNotStrong}; } @@ -521,7 +503,7 @@ export class ForkChoice implements IForkChoice { const timer = computeDeltasMetrics?.duration.startTimer(); const { - deltas, + attestationDeltas, equivocatingValidators, oldInactiveValidators, newInactiveValidators, @@ -537,8 +519,8 @@ export class ForkChoice implements IForkChoice { ); timer?.(); - computeDeltasMetrics?.deltasCount.set(deltas.length); - computeDeltasMetrics?.zeroDeltasCount.set(deltas.filter((d) => d === 0).length); + computeDeltasMetrics?.deltasCount.set(attestationDeltas.length); + computeDeltasMetrics?.zeroDeltasCount.set(attestationDeltas.filter((d) => d === 0).length); computeDeltasMetrics?.equivocatingValidators.set(equivocatingValidators); computeDeltasMetrics?.oldInactiveValidators.set(oldInactiveValidators); computeDeltasMetrics?.newInactiveValidators.set(newInactiveValidators); @@ -564,7 +546,7 @@ export class ForkChoice implements IForkChoice { const currentSlot = this.fcStore.currentSlot; this.protoArray.applyScoreChanges({ - deltas, + attestationDeltas, proposerBoost, justifiedEpoch: this.fcStore.justified.checkpoint.epoch, justifiedRoot: this.fcStore.justified.checkpoint.rootHex, @@ -1546,6 +1528,93 @@ export class ForkChoice implements IForkChoice { return headDependentRoot === blockDependentRoot; } + /** + * Return true if the block is "weak" ie. its weight is below REORG_HEAD_WEIGHT_THRESHOLD of the + * total attester weight per slot. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/phase0/fork-choice.md#is_head_weak + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-is_head_weak + */ + private isHeadWeak(blockRoot: RootHex): boolean { + // The default variant is PENDING for gloas, FULL pre-gloas. PENDING is the variant gloas measures + // support on, ie. support for the beacon block root regardless of its payload status. + // Only ever called on a block already in fork choice, so a miss is a broken invariant. + const node = this.protoArray.getNodeDefaultStatus(blockRoot); + if (node === undefined) { + // this is called for head so we should always have this in forkchoice, otherwise we have a serious error + throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot}); + } + + const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, + }); + + if (!isForkPostGloas(this.config.getForkName(node.slot))) { + return node.weight < reorgThreshold; + } + + let headWeight = node.attestationScore; + + const {equivocatingIndices} = this.fcStore; + // Equivocators are extremely rare (none in normal operation), and with none the added weight is + // always 0. Return before fetching the state and walking the block's committees. + if (equivocatingIndices.size > 0) { + const state = this.fcStore.stateGetter({stateRoot: node.stateRoot}); + // Only ever called on the head, so the state is always cached. + // A miss is a broken invariant, not a recoverable state. + if (state === null) { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.BEACON_STATE_ERROR, + error: new Error(`Missing state for isHeadWeak, blockRoot=${blockRoot} stateRoot=${node.stateRoot}`), + }); + } + + const epoch = computeEpochAtSlot(node.slot); + for (let index = 0; index < state.getBeaconCommitteeCountPerSlot(epoch); index++) { + for (const validatorIndex of state.getBeaconCommittee(node.slot, index)) { + if (equivocatingIndices.has(validatorIndex)) { + // the spec specifies to use effective_balance of the justified state + let balance = this.fcStore.justified.balances[validatorIndex]; + if (!balance) { + // 0 (zeroed by getEffectiveBalanceIncrementsZeroInactive) or undefined (validator not in + // the justified state) - fall back to the head state's effective balance + balance = state.effectiveBalanceIncrements[validatorIndex]; + } + headWeight += balance; + } + } + } + } + + return headWeight < reorgThreshold; + } + + /** + * Return true if the parent block is "strong" ie. its weight exceeds REORG_PARENT_WEIGHT_THRESHOLD + * of the total attester weight per slot. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/phase0/fork-choice.md#is_parent_strong + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#modified-is_parent_strong + */ + private isParentStrong(parentRoot: RootHex): boolean { + const node = this.protoArray.getNodeDefaultStatus(parentRoot); + // If parentNode is unavailable, give up reorg + if (node === undefined) { + return false; + } + + const parentThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD, + }); + + // pre-gloas uses get_weight() (boost-inclusive), gloas uses get_attestation_score() (boost-excluded) + const parentWeight = isForkPostGloas(this.config.getForkName(node.slot)) ? node.attestationScore : node.weight; + + return parentWeight > parentThreshold; + } + /** * Return true if the block is timely for the current slot. * Child class can overwrite this for testing purpose. diff --git a/packages/fork-choice/src/protoArray/computeDeltas.ts b/packages/fork-choice/src/protoArray/computeDeltas.ts index c4f1741bbc6c..1045edd8d827 100644 --- a/packages/fork-choice/src/protoArray/computeDeltas.ts +++ b/packages/fork-choice/src/protoArray/computeDeltas.ts @@ -4,10 +4,10 @@ import {ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; import {NULL_VOTE_INDEX, VoteIndex} from "./interface.js"; // reuse arrays to avoid memory reallocation and gc -const deltas = new Array(); +const attestationDeltas = new Array(); export type DeltasResult = { - deltas: number[]; + attestationDeltas: number[]; equivocatingValidators: number; // inactive validators before beacon node started oldInactiveValidators: number; @@ -19,9 +19,9 @@ export type DeltasResult = { }; /** - * Returns a list of `deltas`, where there is one delta for each of the indices in `indices` + * Returns a list of `attestationDeltas`, where there is one delta for each of the indices in `indices` * - * The deltas are formed by a change between `oldBalances` and `newBalances`, and/or a change of vote in `votes`. + * The attestationDeltas are formed by a change between `oldBalances` and `newBalances`, and/or a change of vote in `votes`. * * ## Errors * @@ -46,8 +46,8 @@ export function computeDeltas( throw new Error(`numProtoNodes must be less than NULL_VOTE_INDEX: ${numProtoNodes} >= ${NULL_VOTE_INDEX}`); } - deltas.length = numProtoNodes; - deltas.fill(0); + attestationDeltas.length = numProtoNodes; + attestationDeltas.fill(0); // avoid creating new variables in the loop to potentially reduce GC pressure let oldBalance: number, newBalance: number; @@ -80,7 +80,7 @@ export function computeDeltas( }); } oldBalance = oldBalances[vIndex] ?? 0; - deltas[currentIndex] -= oldBalance; + attestationDeltas[currentIndex] -= oldBalance; } voteCurrentIndices[vIndex] = NULL_VOTE_INDEX; equivocatingIndex++; @@ -125,7 +125,7 @@ export function computeDeltas( }); } - deltas[currentIndex] -= oldBalance; + attestationDeltas[currentIndex] -= oldBalance; } // We ignore the vote if it is not known in `indices . @@ -138,7 +138,7 @@ export function computeDeltas( }); } - deltas[nextIndex] += newBalance; + attestationDeltas[nextIndex] += newBalance; } voteCurrentIndices[vIndex] = nextIndex; newVoteValidators++; @@ -147,13 +147,13 @@ export function computeDeltas( } } // end validator loop - if (deltas.length !== numProtoNodes) { - // deltas array could be growed in the loop, especially if we mistakenly set the [NULL_VOTE_INDEX] to it , just to be safe - throw new Error(`deltas length mismatch: expected ${numProtoNodes}, got ${deltas.length}`); + if (attestationDeltas.length !== numProtoNodes) { + // attestationDeltas array could be growed in the loop, especially if we mistakenly set the [NULL_VOTE_INDEX] to it , just to be safe + throw new Error(`attestationDeltas length mismatch: expected ${numProtoNodes}, got ${attestationDeltas.length}`); } return { - deltas, + attestationDeltas, equivocatingValidators, oldInactiveValidators, newInactiveValidators, diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index f6f60afb6ae2..11ba86a71f7e 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -160,7 +160,13 @@ export type ProtoBlock = BlockExtraMeta & { */ export type ProtoNode = ProtoBlock & { parent?: number; + /** Total weight, ie. attestationScore plus the proposer boost credited to this node */ weight: number; + /** + * Weight from attester votes only, excluding proposer boost. + * Spec: get_attestation_score + */ + attestationScore: number; bestChild?: number; bestDescendant?: number; }; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 6b76a4dfd539..4ea61f6e7e0f 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -27,6 +27,12 @@ const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2); */ const DATA_AVAILABILITY_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2); +/** + * Proposer boost deltas, back-propagated to the boosted node's ancestors in applyScoreChanges(). + * Reuse the array to avoid memory reallocation and gc, as computeDeltas does for attestation deltas. + */ +const boostDeltas = new Array(); + /** * popcount(attended AND NOT yes) — explicit False-vote count. * Excludes PTC members who didn't attest (the None state). @@ -348,7 +354,7 @@ export class ProtoArray { * - If required, update the parents best-descendant with the current node or its best-descendant. */ applyScoreChanges({ - deltas, + attestationDeltas, proposerBoost, justifiedEpoch, justifiedRoot, @@ -356,7 +362,7 @@ export class ProtoArray { finalizedRoot, currentSlot, }: { - deltas: number[]; + attestationDeltas: number[]; proposerBoost: ProposerBoost | null; justifiedEpoch: Epoch; justifiedRoot: RootHex; @@ -364,14 +370,17 @@ export class ProtoArray { finalizedRoot: RootHex; currentSlot: Slot; }): void { - if (deltas.length !== this.nodes.length) { + if (attestationDeltas.length !== this.nodes.length) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_DELTA_LEN, - deltas: deltas.length, + deltas: attestationDeltas.length, indices: this.nodes.length, }); } + boostDeltas.length = this.nodes.length; + boostDeltas.fill(0); + if ( justifiedEpoch !== this.justifiedEpoch || finalizedEpoch !== this.finalizedEpoch || @@ -416,18 +425,22 @@ export class ProtoArray { // If this node's execution status has been marked invalid, then the weight of the node // needs to be taken out of consideration after which the node weight will become 0 // for subsequent iterations of applyScoreChanges - const nodeDelta = - node.executionStatus === ExecutionStatus.Invalid - ? -node.weight - : deltas[nodeIndex] + currentBoost - previousBoost; - - // Apply the delta to the node - node.weight += nodeDelta; - - // Update the parent delta (if any) + const isInvalid = node.executionStatus === ExecutionStatus.Invalid; + const attestationDelta = isInvalid ? -node.attestationScore : attestationDeltas[nodeIndex]; + const boostDelta = isInvalid + ? // old boost = weight - attestationScore + -(node.weight - node.attestationScore) + : boostDeltas[nodeIndex] + currentBoost - previousBoost; + + // Apply the deltas to the node. Their sum is the node's total delta, so weight is unaffected + // by tracking the two scores apart. + node.attestationScore += attestationDelta; + node.weight += attestationDelta + boostDelta; + + // Update the parent deltas (if any) const parentIndex = node.parent; if (parentIndex !== undefined) { - const parentDelta = deltas[parentIndex]; + const parentDelta = attestationDeltas[parentIndex]; if (parentDelta === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_PARENT_DELTA, @@ -435,8 +448,9 @@ export class ProtoArray { }); } - // back-propagate the nodes delta to its parent - deltas[parentIndex] += nodeDelta; + // back-propagate the nodes deltas to its parent + attestationDeltas[parentIndex] += attestationDelta; + boostDeltas[parentIndex] += boostDelta; } } @@ -514,6 +528,7 @@ export class ProtoArray { parent: parentIndex, // Points to parent's EMPTY/FULL or FULL (for transition) payloadStatus: PayloadStatus.PENDING, weight: 0, + attestationScore: 0, bestChild: undefined, bestDescendant: undefined, }; @@ -527,6 +542,7 @@ export class ProtoArray { parent: pendingIndex, // Points to own PENDING payloadStatus: PayloadStatus.EMPTY, weight: 0, + attestationScore: 0, bestChild: undefined, bestDescendant: undefined, }; @@ -562,6 +578,7 @@ export class ProtoArray { parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL), payloadStatus: PayloadStatus.FULL, weight: 0, + attestationScore: 0, bestChild: undefined, bestDescendant: undefined, }; @@ -647,6 +664,7 @@ export class ProtoArray { parent: pendingIndex, // Points to own PENDING (same as EMPTY) payloadStatus: PayloadStatus.FULL, weight: 0, + attestationScore: 0, bestChild: undefined, bestDescendant: undefined, executionStatus, @@ -1052,7 +1070,7 @@ export class ProtoArray { // update the forkchoice as the invalidation can change the entire forkchoice DAG this.applyScoreChanges({ - deltas: Array.from({length: this.nodes.length}, () => 0), + attestationDeltas: Array.from({length: this.nodes.length}, () => 0), proposerBoost: this.previousProposerBoost, justifiedEpoch: this.justifiedEpoch, justifiedRoot: this.justifiedRoot, @@ -1952,6 +1970,19 @@ export class ProtoArray { return this.getNodeByIndex(blockIndex); } + /** + * Return ProtoNode for the default/canonical variant in a single hash lookup + * - Pre-Gloas blocks: the FULL variant + * - Gloas blocks: the PENDING variant + */ + getNodeDefaultStatus(blockRoot: RootHex): ProtoNode | undefined { + const nodeIndex = this.getDefaultNodeIndex(blockRoot); + if (nodeIndex === undefined) { + return undefined; + } + return this.getNodeByIndex(nodeIndex); + } + /** * Return MUTABLE ProtoBlock for blockRoot with explicit payload status * diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 36e646102adb..e39ba24cde8b 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -204,6 +204,7 @@ describe("Forkchoice", () => { bestDescendant: undefined, parent: 0, weight: 0, + attestationScore: 0, payloadStatus: 2, // Pre-Gloas blocks always have PAYLOAD_STATUS_FULL }); }); diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 5218841ceab4..6826b1edeced 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -276,7 +276,7 @@ describe("Forkchoice / GetProposerHead", () => { const currentSlot = proposalSlot ?? headBlock.slot + 1; const currentSecFromSlot = secFromSlot ?? 0; protoArr.applyScoreChanges({ - deltas: [0, parentBlock.weight, headBlock.weight], + attestationDeltas: [0, parentBlock.weight, headBlock.weight], proposerBoost: null, justifiedEpoch: genesisEpoch, justifiedRoot: genesisRoot, diff --git a/packages/fork-choice/test/unit/forkChoice/isHeadWeak.test.ts b/packages/fork-choice/test/unit/forkChoice/isHeadWeak.test.ts new file mode 100644 index 000000000000..b4d587f0df89 --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/isHeadWeak.test.ts @@ -0,0 +1,99 @@ +import {describe, expect, it} from "vitest"; +import {config as defaultConfig} from "@lodestar/config/default"; +import {RootHex} from "@lodestar/types"; +import {ForkChoiceError, ForkChoiceErrorCode} from "../../../src/forkChoice/errors.js"; +import {ForkChoice} from "../../../src/index.js"; +import {getBlockRoot} from "../../utils/index.js"; +import {gloasConfig, headSlot, makeStore, mockState, setup} from "./proposerHeadTestUtils.js"; + +/** + * REORG_HEAD_WEIGHT_THRESHOLD is 20% of the per-slot committee weight. With 32 validators of 150 + * effective balance increments each and SLOTS_PER_EPOCH = 32 (mainnet preset), the threshold is: + * floor(floor((32 * 150) / 32) * 20 / 100) = floor(150 * 20 / 100) = 30 + */ +const REORG_THRESHOLD = 30; + +/** isHeadWeak is private; it is only reachable from getProposerHead / shouldApplyProposerBoost */ +function isHeadWeak(forkChoice: ForkChoice, blockRoot: RootHex): boolean { + return (forkChoice as unknown as {isHeadWeak(blockRoot: RootHex): boolean}).isHeadWeak(blockRoot); +} + +describe("Forkchoice / isHeadWeak", () => { + describe("pre-gloas", () => { + it("is weak when the boost-inclusive weight is below the threshold", () => { + const {forkChoice, headRoot} = setup({isGloas: false, config: defaultConfig, headVotes: 10}); + expect(isHeadWeak(forkChoice, headRoot)).toBe(true); + }); + + it("is not weak when the boost-inclusive weight reaches the threshold", () => { + const {forkChoice, headRoot} = setup({isGloas: false, config: defaultConfig, headVotes: REORG_THRESHOLD}); + expect(isHeadWeak(forkChoice, headRoot)).toBe(false); + }); + + it("counts proposer boost towards the weight, per phase0 get_weight", () => { + // Attester votes alone are weak, but the boost carries the block over the threshold + const {forkChoice, headRoot} = setup({ + isGloas: false, + config: defaultConfig, + headVotes: 10, + proposerBoost: {root: getBlockRoot(headSlot), score: REORG_THRESHOLD}, + }); + expect(isHeadWeak(forkChoice, headRoot)).toBe(false); + }); + + it("ignores equivocating validators, which are gloas-only", () => { + const store = makeStore({equivocatingIndices: new Set([0, 1, 2]), state: mockState()}); + const {forkChoice, headRoot} = setup({isGloas: false, config: defaultConfig, headVotes: 10, store}); + expect(isHeadWeak(forkChoice, headRoot)).toBe(true); + }); + }); + + describe("gloas", () => { + it("excludes proposer boost from the weight, per gloas get_attestation_score", () => { + // Same numbers as the pre-gloas "counts proposer boost" case above, but gloas must ignore the + // boost and still report the head as weak. This is the circularity gloas is_head_weak avoids: + // gloas get_weight() gates the boost on should_apply_proposer_boost(), which calls this function. + const {forkChoice, headRoot} = setup({ + isGloas: true, + config: gloasConfig, + headVotes: 10, + proposerBoost: {root: getBlockRoot(headSlot), score: REORG_THRESHOLD}, + }); + expect(isHeadWeak(forkChoice, headRoot)).toBe(true); + }); + + it("is not weak when attester votes alone reach the threshold", () => { + const {forkChoice, headRoot} = setup({isGloas: true, config: gloasConfig, headVotes: REORG_THRESHOLD}); + expect(isHeadWeak(forkChoice, headRoot)).toBe(false); + }); + + it("adds back the weight of equivocating validators in the block's committees", () => { + // 10 attester votes is weak on its own, but 1 equivocating validator adds 150 back, clearing 120 + const store = makeStore({equivocatingIndices: new Set([0]), state: mockState()}); + const {forkChoice, headRoot} = setup({isGloas: true, config: gloasConfig, headVotes: 10, store}); + expect(isHeadWeak(forkChoice, headRoot)).toBe(false); + }); + + it("is weak when there are no equivocating validators to add back", () => { + const store = makeStore({equivocatingIndices: new Set(), state: mockState()}); + const {forkChoice, headRoot} = setup({isGloas: true, config: gloasConfig, headVotes: 10, store}); + expect(isHeadWeak(forkChoice, headRoot)).toBe(true); + }); + + it("throws when the state needed for the add-back is unavailable", () => { + // isHeadWeak only runs on the head, so the state is always cached + const store = makeStore({equivocatingIndices: new Set([0]), state: null}); + const {forkChoice, headRoot} = setup({isGloas: true, config: gloasConfig, headVotes: 10, store}); + expect(() => isHeadWeak(forkChoice, headRoot)).toThrow(ForkChoiceError); + }); + }); + + it("throws for a block that is not in fork choice", () => { + const {forkChoice} = setup({isGloas: false, config: defaultConfig, headVotes: 10}); + const unknownRoot = getBlockRoot(headSlot + 100); + + expect(() => isHeadWeak(forkChoice, unknownRoot)).toThrow( + new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: unknownRoot}) + ); + }); +}); diff --git a/packages/fork-choice/test/unit/forkChoice/isParentStrong.test.ts b/packages/fork-choice/test/unit/forkChoice/isParentStrong.test.ts new file mode 100644 index 000000000000..9529bcdd845f --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/isParentStrong.test.ts @@ -0,0 +1,86 @@ +import {describe, expect, it} from "vitest"; +import {config as defaultConfig} from "@lodestar/config/default"; +import {RootHex} from "@lodestar/types"; +import {ForkChoice} from "../../../src/index.js"; +import {getBlockRoot} from "../../utils/index.js"; +import {gloasConfig, headSlot, makeStore, mockState, setup} from "./proposerHeadTestUtils.js"; + +/** + * REORG_PARENT_WEIGHT_THRESHOLD is 160% of the per-slot committee weight. With 32 validators of 150 + * effective balance increments each and SLOTS_PER_EPOCH = 32 (mainnet preset), the threshold is: + * floor(floor((32 * 150) / 32) * 160 / 100) = floor(150 * 160 / 100) = 240 + */ +const PARENT_THRESHOLD = 240; + +/** isParentStrong is private; it is only reachable from getProposerHead */ +function isParentStrong(forkChoice: ForkChoice, parentRoot: RootHex): boolean { + return (forkChoice as unknown as {isParentStrong(parentRoot: RootHex): boolean}).isParentStrong(parentRoot); +} + +describe("Forkchoice / isParentStrong", () => { + describe("pre-gloas", () => { + it("is strong when the boost-inclusive weight exceeds the threshold", () => { + const {forkChoice, parentRoot} = setup({ + isGloas: false, + config: defaultConfig, + parentVotes: PARENT_THRESHOLD + 10, + }); + expect(isParentStrong(forkChoice, parentRoot)).toBe(true); + }); + + it("is not strong when the weight only reaches the threshold, per the spec's strict >", () => { + const {forkChoice, parentRoot} = setup({isGloas: false, config: defaultConfig, parentVotes: PARENT_THRESHOLD}); + expect(isParentStrong(forkChoice, parentRoot)).toBe(false); + }); + + it("counts proposer boost towards the weight, per phase0 get_weight", () => { + // Attester votes alone are below the threshold, but the boost back-propagates from the head + // and carries the parent over it + const {forkChoice, parentRoot} = setup({ + isGloas: false, + config: defaultConfig, + parentVotes: 100, + proposerBoost: {root: getBlockRoot(headSlot), score: 200}, + }); + expect(isParentStrong(forkChoice, parentRoot)).toBe(true); + }); + }); + + describe("gloas", () => { + it("excludes proposer boost from the weight, per gloas get_attestation_score", () => { + // Same numbers as the pre-gloas "counts proposer boost" case above, but gloas must ignore the + // boost and still report the parent as not strong + const {forkChoice, parentRoot} = setup({ + isGloas: true, + config: gloasConfig, + parentVotes: 100, + proposerBoost: {root: getBlockRoot(headSlot), score: 200}, + }); + expect(isParentStrong(forkChoice, parentRoot)).toBe(false); + }); + + it("is strong when attester votes alone exceed the threshold", () => { + const {forkChoice, parentRoot} = setup({ + isGloas: true, + config: gloasConfig, + parentVotes: PARENT_THRESHOLD + 10, + }); + expect(isParentStrong(forkChoice, parentRoot)).toBe(true); + }); + + it("does not add back the weight of equivocating validators, unlike is_head_weak", () => { + // 100 attester votes is below the threshold. is_head_weak would add 150 back for validator 0 and + // clear 240; is_parent_strong has no such loop, so the parent stays not strong. + const store = makeStore({equivocatingIndices: new Set([0]), state: mockState()}); + const {forkChoice, parentRoot} = setup({isGloas: true, config: gloasConfig, parentVotes: 100, store}); + expect(isParentStrong(forkChoice, parentRoot)).toBe(false); + }); + }); + + it("is not strong for a block that is not in fork choice, giving up the reorg rather than throwing", () => { + const {forkChoice} = setup({isGloas: false, config: defaultConfig, parentVotes: PARENT_THRESHOLD + 10}); + const unknownRoot = getBlockRoot(headSlot + 100); + + expect(isParentStrong(forkChoice, unknownRoot)).toBe(false); + }); +}); diff --git a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts new file mode 100644 index 000000000000..42ba1941eac6 --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts @@ -0,0 +1,170 @@ +import {fromHexString} from "@chainsafe/ssz"; +import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; +import {DataAvailabilityStatus, IBeaconStateView} from "@lodestar/state-transition"; +import {RootHex, Slot, ValidatorIndex} from "@lodestar/types"; +import { + ExecutionStatus, + ForkChoice, + IForkChoiceStore, + PayloadStatus, + ProtoArray, + ProtoBlock, +} from "../../../src/index.js"; +import {getBlockRoot, getStateRoot} from "../../utils/index.js"; + +export const VALIDATOR_COUNT = 32; +export const BALANCE_INCREMENT = 150; + +export const gloasConfig = createChainForkConfig({ + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, +}); + +export const genesisSlot = 0; +export const genesisEpoch = 0; +export const parentSlot = genesisSlot + 1; +export const headSlot = genesisSlot + 2; + +/** The execution payload block hash of a block, keyed off its slot. Blocks form a payload chain. */ +export function getPayloadBlockHash(slot: Slot): RootHex { + return `0xpayload${slot}`; +} + +/** + * isGloasBlock() keys off parentBlockHash being non-null, so `isGloas` decides whether the block + * carries the three payload variants (PENDING/EMPTY/FULL) or just FULL. + */ +export function toProtoBlock(slot: Slot, parentRoot: RootHex, isGloas: boolean): ProtoBlock { + return { + slot, + blockRoot: getBlockRoot(slot), + parentRoot, + stateRoot: getStateRoot(slot), + targetRoot: getBlockRoot(slot), + + justifiedEpoch: genesisEpoch, + justifiedRoot: getBlockRoot(genesisSlot), + finalizedEpoch: genesisEpoch, + finalizedRoot: getBlockRoot(genesisSlot), + unrealizedJustifiedEpoch: genesisEpoch, + unrealizedJustifiedRoot: getBlockRoot(genesisSlot), + unrealizedFinalizedEpoch: genesisEpoch, + unrealizedFinalizedRoot: getBlockRoot(genesisSlot), + + timeliness: false, + + executionPayloadBlockHash: getPayloadBlockHash(slot), + executionPayloadNumber: slot, + executionPayloadGasLimit: 30_000_000, + executionStatus: ExecutionStatus.Valid, + dataAvailabilityStatus: DataAvailabilityStatus.Available, + + parentBlockHash: isGloas ? getPayloadBlockHash(slot - 1) : null, + payloadStatus: PayloadStatus.FULL, + }; +} + +/** A state whose only slot committee is every validator, so equivocators always count */ +export function mockState(): IBeaconStateView { + const activeIndices = Uint32Array.from(Array.from({length: VALIDATOR_COUNT}, (_, i) => i)); + return { + getBeaconCommitteeCountPerSlot: () => 1, + getBeaconCommittee: () => activeIndices, + // is_head_weak reads the equivocators' balances off the head state, not fcStore.justified.balances, + // which is zeroed for them + effectiveBalanceIncrements: new Uint16Array(Array(VALIDATOR_COUNT).fill(BALANCE_INCREMENT)), + } as unknown as IBeaconStateView; +} + +export function makeStore({ + equivocatingIndices = new Set(), + state = null, +}: { + equivocatingIndices?: Set; + state?: IBeaconStateView | null; +} = {}): IForkChoiceStore { + const genesisRoot = getBlockRoot(genesisSlot); + const balances = new Uint16Array(Array(VALIDATOR_COUNT).fill(BALANCE_INCREMENT)); + const checkpoint = {epoch: genesisEpoch, root: fromHexString(genesisRoot), rootHex: genesisRoot}; + + return { + currentSlot: headSlot + 1, + justified: {checkpoint, balances, totalBalance: VALIDATOR_COUNT * BALANCE_INCREMENT}, + unrealizedJustified: {checkpoint, balances}, + finalizedCheckpoint: checkpoint, + unrealizedFinalizedCheckpoint: checkpoint, + justifiedBalancesGetter: () => balances, + equivocatingIndices, + confirmedRoot: genesisRoot, + previousEpochObservedJustifiedCheckpoint: checkpoint, + currentEpochObservedJustifiedCheckpoint: checkpoint, + previousEpochGreatestUnrealizedCheckpoint: checkpoint, + previousEpochObservedJustifiedBalances: balances, + currentEpochObservedJustifiedBalances: balances, + previousEpochGreatestUnrealizedBalances: balances, + previousSlotHead: genesisRoot, + currentSlotHead: genesisRoot, + stateGetter: () => state, + }; +} + +/** + * Build a genesis -> parent -> head chain, apply attester weight to the parent and/or head block, and + * optionally boost. Returns a ForkChoice ready for isHeadWeak() / isParentStrong(), plus both roots. + * + * Note attestation and boost deltas both back-propagate to ancestors, so the parent's weight includes + * `headVotes` and any boost applied to the head. + */ +export function setup({ + isGloas, + config, + headVotes = 0, + parentVotes = 0, + proposerBoost = null, + store = makeStore(), +}: { + isGloas: boolean; + config: ChainForkConfig; + headVotes?: number; + parentVotes?: number; + proposerBoost?: {root: RootHex; score: number} | null; + store?: IForkChoiceStore; +}): {forkChoice: ForkChoice; headRoot: RootHex; parentRoot: RootHex} { + const genesisRoot = getBlockRoot(genesisSlot); + const protoArray = ProtoArray.initialize(toProtoBlock(genesisSlot, genesisRoot, false), genesisSlot); + protoArray.onBlock(toProtoBlock(parentSlot, genesisRoot, isGloas), parentSlot, null); + protoArray.onBlock(toProtoBlock(headSlot, getBlockRoot(parentSlot), isGloas), headSlot, null); + + // The ForkChoice constructor calls updateHead(), which re-runs applyScoreChanges with whatever boost + // it holds (none). Build it first, then apply the scores, so the boost under test survives. + const forkChoice = new ForkChoice(config, store, protoArray, VALIDATOR_COUNT, null); + + const headRoot = getBlockRoot(headSlot); + const parentRoot = getBlockRoot(parentSlot); + const votesByRoot = new Map([ + [headRoot, headVotes], + [parentRoot, parentVotes], + ]); + const canonicalVariant = isGloas ? PayloadStatus.PENDING : PayloadStatus.FULL; + const attestationDeltas = protoArray.nodes.map((node) => + // credit the votes to each block's canonical (boostable) variant only + node.payloadStatus === canonicalVariant ? (votesByRoot.get(node.blockRoot) ?? 0) : 0 + ); + + protoArray.applyScoreChanges({ + attestationDeltas, + proposerBoost, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot: headSlot + 1, + }); + + return {forkChoice, headRoot, parentRoot}; +} diff --git a/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts b/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts new file mode 100644 index 000000000000..a238858f3d83 --- /dev/null +++ b/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts @@ -0,0 +1,295 @@ +import {describe, expect, it} from "vitest"; +import {DataAvailabilityStatus} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; +import {ExecutionStatus, PayloadStatus, ProtoArray, ProtoBlock} from "../../../src/index.js"; +import {HEX_ZERO_HASH} from "../../../src/protoArray/interface.js"; + +/** + * Chain layout, "2B" forks off "1A" so it is never an ancestor of "3A": + * + * 0 - 1A - 2A - 3A + * \ + * 2B + */ +const ANCHOR = "0"; +const CHAIN: {slot: number; root: RootHex; parent: RootHex}[] = [ + {slot: 1, root: "1A", parent: ANCHOR}, + {slot: 2, root: "2A", parent: "1A"}, + {slot: 3, root: "3A", parent: "2A"}, + {slot: 2, root: "2B", parent: "1A"}, +]; + +function toProtoBlock(slot: number, blockRoot: RootHex, parentRoot: RootHex): ProtoBlock { + return { + slot, + blockRoot, + parentRoot, + stateRoot: "-", + targetRoot: "-", + + justifiedEpoch: 0, + justifiedRoot: "-", + finalizedEpoch: 0, + finalizedRoot: "-", + unrealizedJustifiedEpoch: 0, + unrealizedJustifiedRoot: "-", + unrealizedFinalizedEpoch: 0, + unrealizedFinalizedRoot: "-", + + timeliness: false, + + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, + dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + }; +} + +function setupProtoArray(): ProtoArray { + const protoArray = ProtoArray.initialize(toProtoBlock(0, ANCHOR, "-"), 0); + for (const {slot, root, parent} of CHAIN) { + protoArray.onBlock(toProtoBlock(slot, root, parent), slot, null); + } + return protoArray; +} + +type ProposerBoost = {root: RootHex; score: number} | null; + +/** Mirror of a ProtoNode, driven by the naive oracle */ +type OracleNode = {blockRoot: RootHex; parent?: number; weight: number; invalid?: boolean}; + +/** + * applyScoreChanges() reads executionStatus straight off the node, so flip it in place. Driving this + * through validateLatestHash() would exercise the LVH plumbing instead, which executionStatusUpdates + * .test.ts already covers. + */ +function markInvalid(protoArray: ProtoArray, oracleNodes: OracleNode[], blockRoot: RootHex): void { + for (const node of protoArray.nodes) { + if (node.blockRoot === blockRoot) { + (node as {executionStatus: ExecutionStatus}).executionStatus = ExecutionStatus.Invalid; + } + } + for (const node of oracleNodes) { + if (node.blockRoot === blockRoot) { + node.invalid = true; + } + } +} + +/** A round of score changes: the deltas fed to applyScoreChanges plus the boost in effect */ +type Round = {deltas: number[]; proposerBoost: ProposerBoost}; + +function applyScoreChanges(protoArray: ProtoArray, {deltas, proposerBoost}: Round): void { + protoArray.applyScoreChanges({ + attestationDeltas: deltas, + proposerBoost, + justifiedEpoch: 0, + justifiedRoot: "-", + finalizedEpoch: 0, + finalizedRoot: "-", + currentSlot: 3, + }); +} + +/** node weights keyed by blockRoot */ +function weights(protoArray: ProtoArray): Record { + return Object.fromEntries(protoArray.nodes.map((node) => [node.blockRoot, node.weight])); +} + +function attestationScores(protoArray: ProtoArray): Record { + return Object.fromEntries(protoArray.nodes.map((node) => [node.blockRoot, node.attestationScore])); +} + +describe("ProtoArray attestationScore", () => { + /** + * The implementation before attestationScore was split out: a single delta channel carrying both + * attester votes and proposer boost. Kept as an oracle: splitting the channels must not move `weight`. + */ + function naiveApplyScoreChanges( + nodes: OracleNode[], + deltas: number[], + proposerBoost: ProposerBoost, + previousProposerBoost: ProposerBoost + ): void { + for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) { + const node = nodes[nodeIndex]; + if (node.blockRoot === HEX_ZERO_HASH) continue; + + const currentBoost = proposerBoost && proposerBoost.root === node.blockRoot ? proposerBoost.score : 0; + const previousBoost = + previousProposerBoost && previousProposerBoost.root === node.blockRoot ? previousProposerBoost.score : 0; + + // an invalid node drops its whole weight, boost included, and stays at 0 from then on + const nodeDelta = node.invalid ? -node.weight : deltas[nodeIndex] + currentBoost - previousBoost; + node.weight += nodeDelta; + + if (node.parent !== undefined) { + deltas[node.parent] += nodeDelta; + } + } + } + + it("weight matches the pre-split implementation over a sequence of attestations and boosts", () => { + const protoArray = setupProtoArray(); + // Mirror of protoArray.nodes, driven by the naive oracle + const oracleNodes: OracleNode[] = protoArray.nodes.map((node) => ({ + blockRoot: node.blockRoot, + parent: node.parent, + weight: 0, + })); + + // Deterministic pseudo-random deltas, so a failure is reproducible + let seed = 42; + const nextDelta = (): number => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return (seed % 21) - 10; + }; + + const boostable: ProposerBoost[] = [ + null, + {root: "3A", score: 100}, + {root: "2A", score: 100}, + // boost moves to a sibling fork: the case previousBoost has to cancel on the old branch + {root: "2B", score: 100}, + {root: "3A", score: 70}, + null, + ]; + + // 3A is invalidated while it still carries a boost, so the round it goes invalid must drop both + // its attester votes and its boost, and every later round must leave it at 0 + const invalidateBeforeRound: Record = {4: "3A"}; + + let previousProposerBoost: ProposerBoost = null; + for (const [round, proposerBoost] of boostable.entries()) { + const toInvalidate = invalidateBeforeRound[round]; + if (toInvalidate !== undefined) { + markInvalid(protoArray, oracleNodes, toInvalidate); + } + + const deltas = protoArray.nodes.map(() => nextDelta()); + + // applyScoreChanges mutates the deltas array in place, so give each side its own copy + applyScoreChanges(protoArray, {deltas: [...deltas], proposerBoost}); + naiveApplyScoreChanges(oracleNodes, [...deltas], proposerBoost, previousProposerBoost); + previousProposerBoost = proposerBoost; + + for (const oracleNode of oracleNodes) { + expect(weights(protoArray)[oracleNode.blockRoot]).toBeWithMessage( + oracleNode.weight, + `weight of ${oracleNode.blockRoot} must match the pre-split implementation` + ); + } + } + }); + + it("excludes proposer boost from attestationScore on the boosted node and all its ancestors", () => { + const protoArray = setupProtoArray(); + + // One attester vote on 3A, which back-propagates to its ancestors 2A, 1A and the anchor + const deltas = protoArray.nodes.map((node) => (node.blockRoot === "3A" ? 10 : 0)); + applyScoreChanges(protoArray, {deltas, proposerBoost: null}); + + expect(attestationScores(protoArray)).toEqual({[ANCHOR]: 10, "1A": 10, "2A": 10, "3A": 10, "2B": 0}); + expect(weights(protoArray)).toEqual({[ANCHOR]: 10, "1A": 10, "2A": 10, "3A": 10, "2B": 0}); + + // Boost 3A. It is credited to 3A and to every ancestor, but attestationScore must not move. + applyScoreChanges(protoArray, { + deltas: protoArray.nodes.map(() => 0), + proposerBoost: {root: "3A", score: 100}, + }); + + expect(attestationScores(protoArray)).toEqualWithMessage( + {[ANCHOR]: 10, "1A": 10, "2A": 10, "3A": 10, "2B": 0}, + "boost must not leak into attestationScore, on the boosted node or its ancestors" + ); + expect(weights(protoArray)).toEqualWithMessage( + {[ANCHOR]: 110, "1A": 110, "2A": 110, "3A": 110, "2B": 0}, + "boost is credited to the boosted node and every ancestor" + ); + }); + + it("does not credit boost to a node that is not an ancestor of the boosted node", () => { + const protoArray = setupProtoArray(); + + applyScoreChanges(protoArray, { + deltas: protoArray.nodes.map(() => 0), + proposerBoost: {root: "3A", score: 100}, + }); + + // 2B forks off 1A, so it is not an ancestor of 3A + expect(weights(protoArray)["2B"]).toBe(0); + expect(attestationScores(protoArray)["2B"]).toBe(0); + }); + + it("zeroes both channels of an invalid node and unwinds both from its ancestors", () => { + const protoArray = setupProtoArray(); + + // 10 votes on 3A and 5 on 2B, plus a boost on 3A + const deltas = protoArray.nodes.map((node) => (node.blockRoot === "3A" ? 10 : node.blockRoot === "2B" ? 5 : 0)); + applyScoreChanges(protoArray, {deltas, proposerBoost: {root: "3A", score: 100}}); + + expect(attestationScores(protoArray)).toEqual({[ANCHOR]: 15, "1A": 15, "2A": 10, "3A": 10, "2B": 5}); + expect(weights(protoArray)).toEqual({[ANCHOR]: 115, "1A": 115, "2A": 110, "3A": 110, "2B": 5}); + + // 3A goes invalid while still boosted. The invalid branch derives the boost to remove as + // `weight - attestationScore`, so both channels have to unwind on 3A and on 2A/1A/anchor. + markInvalid(protoArray, [], "3A"); + applyScoreChanges(protoArray, { + deltas: protoArray.nodes.map(() => 0), + proposerBoost: {root: "3A", score: 100}, + }); + + expect(attestationScores(protoArray)).toEqualWithMessage( + {[ANCHOR]: 5, "1A": 5, "2A": 0, "3A": 0, "2B": 5}, + "the invalid node's attester votes must be removed from it and from every ancestor" + ); + expect(weights(protoArray)).toEqualWithMessage( + {[ANCHOR]: 5, "1A": 5, "2A": 0, "3A": 0, "2B": 5}, + "the invalid node's boost must be removed alongside its votes, leaving only 2B's votes" + ); + }); + + it("keeps an invalid node at zero on later rounds without double-unwinding its boost", () => { + const protoArray = setupProtoArray(); + + const deltas = protoArray.nodes.map((node) => (node.blockRoot === "3A" ? 10 : node.blockRoot === "2B" ? 5 : 0)); + applyScoreChanges(protoArray, {deltas, proposerBoost: {root: "3A", score: 100}}); + + markInvalid(protoArray, [], "3A"); + applyScoreChanges(protoArray, { + deltas: protoArray.nodes.map(() => 0), + proposerBoost: {root: "3A", score: 100}, + }); + + // The boost is cleared on the next tick. 3A is still invalid, so its deltas must compute to 0 + // rather than removing the already-removed boost a second time. + applyScoreChanges(protoArray, {deltas: protoArray.nodes.map(() => 0), proposerBoost: null}); + + expect(attestationScores(protoArray)).toEqual({[ANCHOR]: 5, "1A": 5, "2A": 0, "3A": 0, "2B": 5}); + expect(weights(protoArray)).toEqualWithMessage( + {[ANCHOR]: 5, "1A": 5, "2A": 0, "3A": 0, "2B": 5}, + "clearing the boost must not unwind it a second time through the invalid node" + ); + }); + + it("weight returns to attestationScore once the boost is cleared", () => { + const protoArray = setupProtoArray(); + + const deltas = protoArray.nodes.map((node) => (node.blockRoot === "3A" ? 10 : 0)); + applyScoreChanges(protoArray, {deltas, proposerBoost: {root: "3A", score: 100}}); + expect(weights(protoArray)["3A"]).toBe(110); + + // onTick clears proposerBoostRoot at the start of a slot, so the next round applies a null boost + applyScoreChanges(protoArray, {deltas: protoArray.nodes.map(() => 0), proposerBoost: null}); + + for (const node of protoArray.nodes) { + expect(node.weight).toBeWithMessage( + node.attestationScore, + `boost must be fully unwound from ${node.blockRoot}, leaving only attester votes` + ); + } + expect(weights(protoArray)).toEqual({[ANCHOR]: 10, "1A": 10, "2A": 10, "3A": 10, "2B": 0}); + }); +}); diff --git a/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts b/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts index 4583a6afa813..ae51a57ced63 100644 --- a/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts @@ -21,7 +21,7 @@ describe("computeDeltas", () => { newBalances[i] = 0; } - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -55,7 +55,7 @@ describe("computeDeltas", () => { newBalances[i] = balance; } - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -93,7 +93,7 @@ describe("computeDeltas", () => { newBalances[i] = balance; } - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -127,7 +127,7 @@ describe("computeDeltas", () => { newBalances[i] = balance; } - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -170,7 +170,7 @@ describe("computeDeltas", () => { newBalances[i] = newBalance; } - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -212,7 +212,7 @@ describe("computeDeltas", () => { newBalances[0] = balance; newBalances[1] = balance; - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -250,7 +250,7 @@ describe("computeDeltas", () => { const newBalances = getEffectiveBalanceIncrementsZeroed(1); newBalances[0] = balance; - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -285,7 +285,7 @@ describe("computeDeltas", () => { const balances = new Uint16Array([firstBalance, secondBalance]); // 1st validator is part of an attester slashing const equivocatingIndices = new Set([0]); - let {deltas} = computeDeltas( + let {attestationDeltas: deltas} = computeDeltas( indices.size, voteCurrentIndices, voteNextIndices, @@ -305,7 +305,7 @@ describe("computeDeltas", () => { balances, balances, equivocatingIndices - ).deltas; + ).attestationDeltas; expect(deltas).toEqualWithMessage([0, 0], "calling computeDeltas again should not have any affect on the weight"); }); @@ -324,7 +324,7 @@ describe("computeDeltas", () => { // Both V0 and V1 are in the (add-only) equivocating set const equivocatingIndices = new Set([0, 1]); - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( numProtoNodes, voteCurrentIndices, voteNextIndices, @@ -355,7 +355,7 @@ describe("computeDeltas", () => { const equivocatingIndices = new Set([0, 1, 2]); - const {deltas} = computeDeltas( + const {attestationDeltas: deltas} = computeDeltas( numProtoNodes, voteCurrentIndices, voteNextIndices, diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 75f7e959ac4f..446349cd246e 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -133,7 +133,7 @@ function setupForkChoice(): ProtoArray { const deltas = Array.from({length: fc.nodes.length}, () => 0); fc.applyScoreChanges({ - deltas, + attestationDeltas: deltas, proposerBoost: null, justifiedEpoch: 0, justifiedRoot: "-", diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index e8349f4ea426..deb471d01592 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -102,7 +102,7 @@ describe("getCommonAncestor", () => { const lastSlot = blocks.reverse()[0].slot; const deltas = Array.from({length: fc.nodes.length}, () => 0); fc.applyScoreChanges({ - deltas, + attestationDeltas: deltas, proposerBoost: {root: blocks.at(-1)?.root as string, score: 34}, justifiedEpoch: 0, justifiedRoot: "-", @@ -114,7 +114,7 @@ describe("getCommonAncestor", () => { const deltasNew = Array.from({length: fc.nodes.length}, () => 0); fc.applyScoreChanges({ - deltas: deltasNew, + attestationDeltas: deltasNew, proposerBoost: {root: blocks.at(-1)?.root as string, score: 34}, justifiedEpoch: 0, justifiedRoot: "-", diff --git a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts index 6cf67cd99250..769f6c2ad45a 100644 --- a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts @@ -55,7 +55,7 @@ describe("ProtoArray.getViableHeads", () => { // Non-leaf "2" must be excluded; genesis "1" has viable children => excluded fc.applyScoreChanges({ - deltas: [0, 0, 30, 12], + attestationDeltas: [0, 0, 30, 12], proposerBoost: null, justifiedEpoch: 0, justifiedRoot: "0", diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index 111207058370..d77fb776982e 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -1341,7 +1341,7 @@ describe("Gloas Fork Choice", () => { // Apply at currentSlot = blockSlot + 1 (makes block from slot n-1) protoArray.applyScoreChanges({ - deltas, + attestationDeltas: deltas, proposerBoost: null, justifiedEpoch: genesisEpoch, justifiedRoot: genesisRoot, @@ -1382,7 +1382,7 @@ describe("Gloas Fork Choice", () => { deltas[emptyBIndex] = 100; protoArray.applyScoreChanges({ - deltas, + attestationDeltas: deltas, proposerBoost: null, justifiedEpoch: genesisEpoch, justifiedRoot: genesisRoot, @@ -1426,7 +1426,7 @@ describe("Gloas Fork Choice", () => { // currentSlot = blockSlot + 2, so block is from slot n-2 (not n-1) protoArray.applyScoreChanges({ - deltas, + attestationDeltas: deltas, proposerBoost: null, justifiedEpoch: genesisEpoch, justifiedRoot: genesisRoot, diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 18508bd952b3..6333ecd07e0b 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -369,7 +369,6 @@ exceptions: - has_compounding_withdrawal_credential#gloas - is_ancestor#gloas - is_head_late#gloas - - is_head_weak#gloas - is_parent_node_full#gloas - is_previous_slot_payload_decision#gloas - is_valid_proposal_slot#gloas diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 57447c916454..3cba2573fa0f 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -6982,7 +6982,7 @@ - name: is_head_weak#phase0 sources: - file: packages/fork-choice/src/forkChoice/forkChoice.ts - search: "// https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_head_weak" + search: "* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/phase0/fork-choice.md#is_head_weak" spec: | def is_head_weak(store: Store, head_root: Root) -> bool: @@ -6993,7 +6993,9 @@ - name: is_head_weak#gloas - sources: [] + sources: + - file: packages/fork-choice/src/forkChoice/forkChoice.ts + search: "* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.11/specs/gloas/fork-choice.md#modified-is_head_weak" spec: | def is_head_weak(store: Store, head_root: Root) -> bool: @@ -7162,7 +7164,7 @@ - name: is_parent_strong#phase0 sources: - file: packages/fork-choice/src/forkChoice/forkChoice.ts - search: "const parentStrongVariant = isGloasBlock(parentBlock) ? PayloadStatus.PENDING : PayloadStatus.FULL;" + search: "* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/phase0/fork-choice.md#is_parent_strong" spec: | def is_parent_strong(store: Store, root: Root) -> bool: @@ -7177,7 +7179,7 @@ - name: is_parent_strong#gloas sources: - file: packages/fork-choice/src/forkChoice/forkChoice.ts - search: "const parentStrongVariant = isGloasBlock(parentBlock) ? PayloadStatus.PENDING : PayloadStatus.FULL;" + search: "* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#modified-is_parent_strong" spec: | def is_parent_strong(store: Store, root: Root) -> bool: