diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 86c19f0bf9d7..1a11ad30f85f 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -134,6 +134,8 @@ export function initializeForkChoiceFromFinalizedState( stateRoot: toRootHex(blockHeader.stateRoot), blockRoot: toRootHex(checkpoint.root), timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), @@ -230,6 +232,8 @@ export function initializeForkChoiceFromUnfinalizedState( blockRoot: headRoot, targetRoot: headRoot, timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), diff --git a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index a3874b240d5e..527af1afc6cd 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -67,6 +67,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, @@ -95,6 +97,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts index 44d577ee44b0..2129ca685a5e 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -81,6 +81,8 @@ describe("SeenPayloadEnvelopeInput", () => { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: blockRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index d5ee1595e637..bd5dbeb130ab 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -172,6 +172,8 @@ export const zeroProtoBlock: ProtoBlock = { unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/beacon-node/test/utils/typeGenerator.ts b/packages/beacon-node/test/utils/typeGenerator.ts index 7886df877050..7a2732782c0a 100644 --- a/packages/beacon-node/test/utils/typeGenerator.ts +++ b/packages/beacon-node/test/utils/typeGenerator.ts @@ -40,6 +40,8 @@ export function generateProtoBlock(overrides: Partial = {}): ProtoBl unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 1894a9b89b39..fed3ff461c1b 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -76,6 +76,8 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 19d0566fce39..1cec366293d3 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -571,32 +571,41 @@ export class ForkChoice implements IForkChoice { computeDeltasMetrics?.newVoteValidators.set(newVoteValidators); this.balances = newBalances; - /** - * The structure in line with deltas to propagate boost up the branch - * starting from the proposerIndex - */ - let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoost && this.proposerBoostRoot) { - const proposerBoostScore = - this.justifiedProposerBoostScore ?? - getCommitteeFraction(this.fcStore.justified.totalBalance, { - slotsPerEpoch: SLOTS_PER_EPOCH, - committeePercent: this.config.PROPOSER_SCORE_BOOST, - }); - proposerBoost = {root: this.proposerBoostRoot, score: proposerBoostScore}; - this.justifiedProposerBoostScore = proposerBoostScore; - } const currentSlot = this.fcStore.currentSlot; - this.protoArray.applyScoreChanges({ - attestationDeltas, - proposerBoost, + const checkpoints = { justifiedEpoch: this.fcStore.justified.checkpoint.epoch, justifiedRoot: this.fcStore.justified.checkpoint.rootHex, finalizedEpoch: this.fcStore.finalizedCheckpoint.epoch, finalizedRoot: this.fcStore.finalizedCheckpoint.rootHex, currentSlot, - }); + }; + + const boostedBlock = + this.opts?.proposerBoost && this.proposerBoostRoot ? this.getBlockHexDefaultStatus(this.proposerBoostRoot) : null; + + if (boostedBlock && isGloasBlock(boostedBlock)) { + // should_apply_proposer_boost judges the parent against the attestations known to the store, + // via is_head_weak (which also assumes equivocators' votes were already discounted before + // their balance is added back). Apply the attestation deltas first so the decision reads + // post-delta scores, then apply the boost in a second pass. + // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost + this.protoArray.applyScoreChanges({attestationDeltas, proposerBoost: null, ...checkpoints}); + const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : null; + this.protoArray.applyScoreChanges({ + attestationDeltas: new Array(this.protoArray.nodes.length).fill(0), + proposerBoost, + ...checkpoints, + }); + } else { + // Pre-gloas the boost is unconditional, so attestation deltas and boost deltas can propagate + // up the branch in a single pass + this.protoArray.applyScoreChanges({ + attestationDeltas, + proposerBoost: boostedBlock ? this.getProposerBoost() : null, + ...checkpoints, + }); + } // findHead returns the ProtoNode representing the head const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); @@ -817,6 +826,8 @@ export class ForkChoice implements IForkChoice { targetRoot: toRootHex(targetRoot), stateRoot: toRootHex(block.stateRoot), timeliness: isTimely, + ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec), + proposerIndex: block.proposerIndex, justifiedEpoch: stateJustifiedEpoch, justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root), @@ -1584,7 +1595,8 @@ export class ForkChoice implements IForkChoice { // 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 + // this is called for head or the boosted block's parent, both of which should always be + // in forkchoice, otherwise we have a serious error throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot}); } @@ -1604,7 +1616,7 @@ export class ForkChoice implements IForkChoice { // 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. + // Only ever called on the head or the boosted block's parent, so the state is always cached. // A miss is a broken invariant, not a recoverable state. if (state === null) { throw new ForkChoiceError({ @@ -1668,6 +1680,79 @@ export class ForkChoice implements IForkChoice { return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff; } + /** + * Return true if the block arrived before the PTC (payload-timeliness committee) deadline, + * ie. block_timeliness[PTC_TIMELINESS_INDEX]. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#modified-record_block_timeliness + * + * Child class can overwrite this for testing purpose. + */ + protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { + const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS); + return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs; + } + + /** + * Determine whether proposer boost should be applied to `proposerBoostRoot`. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost + * + * Pre-gloas blocks always receive the boost (unconditional, backward compatible). For gloas + * blocks the boost is withheld when the parent is a weak block from the previous slot and the + * proposer of that parent equivocated (published another PTC-timely block at the same slot). + */ + private shouldApplyProposerBoost(): boolean { + if (!this.proposerBoostRoot) { + return false; + } + + const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot); + // Pre-gloas blocks always get boost + if (!boostedBlock || !isGloasBlock(boostedBlock)) { + return true; + } + + const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot); + if (!parentBlock) { + return true; + } + + // Apply proposer boost if parent is not from the previous slot + if (parentBlock.slot + 1 < boostedBlock.slot) { + return true; + } + + // Apply proposer boost if parent is not weak + if (!this.isHeadWeak(parentBlock.blockRoot)) { + return true; + } + + // Parent is weak and from the previous slot: apply boost only if there are no early + // equivocations, ie. no other PTC-timely block at the parent's slot from the same proposer. + return !this.protoArray.hasEquivocatingBlock(parentBlock.proposerIndex, parentBlock.slot, parentBlock.blockRoot); + } + + /** + * The proposer boost to apply to `proposerBoostRoot`, propagated up the branch by + * applyScoreChanges() together with the deltas. + */ + private getProposerBoost(): {root: RootHex; score: number} | null { + if (!this.proposerBoostRoot) { + return null; + } + + const proposerBoostScore = + this.justifiedProposerBoostScore ?? + getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.PROPOSER_SCORE_BOOST, + }); + this.justifiedProposerBoostScore = proposerBoostScore; + + return {root: this.proposerBoostRoot, score: proposerBoostScore}; + } + /** * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time */ diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 11ba86a71f7e..3c58d88b016f 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -1,5 +1,5 @@ import {DataAvailabilityStatus} from "@lodestar/state-transition"; -import {Epoch, RootHex, Slot, UintNum64} from "@lodestar/types"; +import {Epoch, RootHex, Slot, UintNum64, ValidatorIndex} from "@lodestar/types"; // RootHex is a root as a hex string // Used for lightweight and easy comparison @@ -144,6 +144,13 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; + // Indicate whether block arrives before the PTC deadline + // Spec: gloas/fork-choice.md#modified-record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + ptcTimeliness: boolean; + + // The index of the block proposer. Used by should_apply_proposer_boost to detect proposer equivocations + proposerIndex: ValidatorIndex; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4ea61f6e7e0f..22ba58f54dd3 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,7 +1,7 @@ import {BitArray} from "@chainsafe/ssz"; import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params"; import {DataAvailabilityStatus, computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Epoch, RootHex, Slot} from "@lodestar/types"; +import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {bitCount, toRootHex} from "@lodestar/utils"; import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js"; import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; @@ -1983,6 +1983,32 @@ export class ProtoArray { return this.getNodeByIndex(nodeIndex); } + /** + * Return true if a block other than `excludeRoot` at `slot` was proposed by `proposerIndex` and + * is PTC-timely. Used by `should_apply_proposer_boost` to detect proposer equivocations. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost + * + * Iterates unique block roots (via the canonical variant) since `slot`, `proposerIndex` and + * `ptcTimeliness` are block-level properties identical across payload-status variants. + */ + hasEquivocatingBlock(proposerIndex: ValidatorIndex, slot: Slot, excludeRoot: RootHex): boolean { + for (const root of this.indices.keys()) { + if (root === excludeRoot) { + continue; + } + const nodeIndex = this.getDefaultNodeIndex(root); + if (nodeIndex === undefined) { + continue; + } + const node = this.nodes[nodeIndex]; + if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) { + return true; + } + } + return false; + } + /** * Return MUTABLE ProtoBlock for blockRoot with explicit payload status * diff --git a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts index 60c18d7d73fc..1979eb412c7d 100644 --- a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts +++ b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts @@ -22,9 +22,19 @@ describe("forkchoice updateHead", () => { runUpdateHeadBenchmark({initialValidatorCount: 600_000, initialBlockCount: 64, initialEquivocatedCount}); } + // A boosted gloas block splits applyScoreChanges in two passes (attestation deltas, then the + // should_apply_proposer_boost decision, then boost deltas); measure that overhead against the + // single-pass `vc 600000 bc 64 eq 0` row above + runUpdateHeadBenchmark({ + initialValidatorCount: 600_000, + initialBlockCount: 64, + initialEquivocatedCount: 0, + gloasBoosted: true, + }); + function runUpdateHeadBenchmark(opts: Opts): void { bench({ - id: `forkChoice updateHead vc ${opts.initialValidatorCount} bc ${opts.initialBlockCount} eq ${opts.initialEquivocatedCount}`, + id: `forkChoice updateHead vc ${opts.initialValidatorCount} bc ${opts.initialBlockCount} eq ${opts.initialEquivocatedCount}${opts.gloasBoosted ? " gloas boosted" : ""}`, before: () => { const forkChoice = initializeForkChoice(opts); @@ -43,7 +53,7 @@ describe("forkchoice updateHead", () => { }, beforeEach: (data) => { // Flip all votes every run - everyoneVotes(data.currentVoteIs1 ? data.vote2 : data.vote1, data.forkChoice); + everyoneVotes(data.currentVoteIs1 ? data.vote2 : data.vote1, data.forkChoice, opts); data.currentVoteIs1 = !data.currentVoteIs1; return data; }, @@ -54,9 +64,11 @@ describe("forkchoice updateHead", () => { } }); -function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice): void { +function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice, opts: Opts): void { const nextRoot = vote.blockRoot; + // gloas blocks only carry PENDING/EMPTY variants until the envelope is revealed + const payloadStatus = opts.gloasBoosted ? PayloadStatus.PENDING : PayloadStatus.FULL; for (let i = 0; i < forkChoice["balances"].length; i++) { - forkChoice["addLatestMessage"](i, vote.slot, nextRoot, PayloadStatus.FULL); + forkChoice["addLatestMessage"](i, vote.slot, nextRoot, payloadStatus); } } diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 3422d58dbf11..ad739dd966b1 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -12,6 +12,7 @@ import { ProtoBlock, } from "../../../src/index.js"; import {makeState} from "../../unit/forkChoice/fastConfirmationTestUtils.js"; +import {gloasConfig} from "../../unit/forkChoice/proposerHeadTestUtils.js"; const genesisSlot = 0; const genesisEpoch = 0; @@ -26,6 +27,8 @@ export type Opts = { initialValidatorCount: number; initialEquivocatedCount: number; fastConfirmation?: boolean; + /** Build a gloas chain (PENDING/EMPTY variants) and give the last block proposer boost */ + gloasBoosted?: boolean; }; export function initializeForkChoice(opts: Opts): ForkChoice { @@ -117,13 +120,23 @@ export function initializeForkChoice(opts: Opts): ForkChoice { stateGetter: () => stubState, }; - const forkchoice = new ForkChoice(config, fcStore, protoArr, opts.initialValidatorCount, null, { - fastConfirmation: opts.fastConfirmation, - }); + const gloasBoosted = opts.gloasBoosted === true; + const forkchoice = new ForkChoice( + gloasBoosted ? gloasConfig : config, + fcStore, + protoArr, + opts.initialValidatorCount, + null, + { + fastConfirmation: opts.fastConfirmation, + proposerBoost: gloasBoosted, + } + ); let parentBlockRoot = genesisRoot; + let blockRoot = genesisRoot; for (let slot = 1; slot < opts.initialBlockCount; slot++) { - const blockRoot = slotRoot(slot); + blockRoot = slotRoot(slot); // Set unrealizedJustifiedEpoch to 1 for blocks in epoch 1+ // so that headJustification.epoch + 1 >= currentEpoch passes in loop 2 const blockEpoch = Math.floor(slot / SLOTS_PER_EPOCH); @@ -145,19 +158,33 @@ export function initializeForkChoice(opts: Opts): ForkChoice { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: genesisRoot, - executionPayloadBlockHash: null, - executionStatus: ExecutionStatus.PreMerge, + executionPayloadBlockHash: gloasBoosted ? payloadHash(slot) : null, + executionStatus: gloasBoosted ? ExecutionStatus.Valid : ExecutionStatus.PreMerge, timeliness: false, - dataAvailabilityStatus: DataAvailabilityStatus.PreData, + ptcTimeliness: false, + proposerIndex: 0, + dataAvailabilityStatus: gloasBoosted ? DataAvailabilityStatus.Available : DataAvailabilityStatus.PreData, - parentBlockHash: null, + // The parent's own payload hash links this block to the parent's PENDING/EMPTY variant (the + // FULL variant only exists once the envelope is revealed, which this harness does not simulate) + parentBlockHash: gloasBoosted ? payloadHash(slot - 1) : null, payloadStatus: PayloadStatus.FULL, - }; + // ProtoBlock is a union over the execution fields; the conditionals lose the narrowing + } as ProtoBlock; protoArr.onBlock(block, block.slot, null); parentBlockRoot = blockRoot; } + if (gloasBoosted) { + // Boost the tip so every updateHead() takes the gloas two-pass applyScoreChanges path + forkchoice["proposerBoostRoot"] = blockRoot; + } + return forkchoice; } + +function payloadHash(slot: number): string { + return `0xpayload${slot}`; +} diff --git a/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts index d60a413196e2..02146ac9799a 100644 --- a/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts +++ b/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts @@ -53,6 +53,8 @@ export function makeBlock( parentBlockHash: null, payloadStatus: PayloadStatus.FULL, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, }; if (executionStatus === ExecutionStatus.PreMerge) { diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index e39ba24cde8b..3d0f5226539d 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -48,6 +48,8 @@ describe("Forkchoice", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, } as Omit, genesisSlot ); @@ -148,6 +150,8 @@ describe("Forkchoice", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 6826b1edeced..5894edf6782e 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -48,6 +48,8 @@ describe("Forkchoice / GetProposerHead", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, @@ -74,6 +76,8 @@ describe("Forkchoice / GetProposerHead", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -102,6 +106,8 @@ describe("Forkchoice / GetProposerHead", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts index 42ba1941eac6..c433ddeaedfa 100644 --- a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts +++ b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts @@ -39,7 +39,12 @@ export function getPayloadBlockHash(slot: Slot): RootHex { * 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 { +export function toProtoBlock( + slot: Slot, + parentRoot: RootHex, + isGloas: boolean, + overrides: Partial = {} +): ProtoBlock { return { slot, blockRoot: getBlockRoot(slot), @@ -57,6 +62,8 @@ export function toProtoBlock(slot: Slot, parentRoot: RootHex, isGloas: boolean): unrealizedFinalizedRoot: getBlockRoot(genesisSlot), timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: getPayloadBlockHash(slot), executionPayloadNumber: slot, @@ -66,7 +73,11 @@ export function toProtoBlock(slot: Slot, parentRoot: RootHex, isGloas: boolean): parentBlockHash: isGloas ? getPayloadBlockHash(slot - 1) : null, payloadStatus: PayloadStatus.FULL, - }; + + ...overrides, + // ProtoBlock is a union over the execution fields; spreading Partial loses the + // narrowing even though every base object here is the post-merge shape + } as ProtoBlock; } /** A state whose only slot committee is every validator, so equivocators always count */ diff --git a/packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts new file mode 100644 index 000000000000..96f9db6e74d4 --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts @@ -0,0 +1,202 @@ +import {describe, expect, it} from "vitest"; +import {IBeaconStateView} from "@lodestar/state-transition"; +import {RootHex, Slot, ValidatorIndex} from "@lodestar/types"; +import {ForkChoice, IForkChoiceStore, PayloadStatus, ProtoArray} from "../../../src/index.js"; +import {getBlockRoot} from "../../utils/index.js"; +import { + BALANCE_INCREMENT, + VALIDATOR_COUNT, + genesisEpoch, + genesisSlot, + getPayloadBlockHash, + gloasConfig, + headSlot, + makeStore, + parentSlot, + toProtoBlock, +} from "./proposerHeadTestUtils.js"; + +/** See isHeadWeak.test.ts: floor(floor((32 * 150) / 32) * 20 / 100) = 30 */ +const REORG_THRESHOLD = 30; +/** PROPOSER_SCORE_BOOST is 40%: floor(floor((32 * 150) / 32) * 40 / 100) = 60 */ +const BOOST_SCORE = 60; + +const PARENT_PROPOSER = 7; +const SIBLING_ROOT = "0xsibling"; + +/** A state whose slot committees are empty, so equivocator balances are never added back */ +function mockStateEmptyCommittee(): IBeaconStateView { + return { + getBeaconCommitteeCountPerSlot: () => 1, + getBeaconCommittee: () => Uint32Array.from([]), + effectiveBalanceIncrements: new Uint16Array(Array(VALIDATOR_COUNT).fill(BALANCE_INCREMENT)), + } as unknown as IBeaconStateView; +} + +function setBoostRoot(forkChoice: ForkChoice, root: RootHex): void { + (forkChoice as unknown as {proposerBoostRoot: RootHex | null}).proposerBoostRoot = root; +} + +/** Queue a not-yet-applied vote, as onAttestation would, so updateHead() computes its delta */ +function setPendingVote(forkChoice: ForkChoice, validatorIndex: ValidatorIndex, nodeIndex: number): void { + (forkChoice as unknown as {voteNextIndices: number[]}).voteNextIndices[validatorIndex] = nodeIndex; +} + +/** The boost currently held by a block = the gap between its total weight and its attestation score */ +function appliedBoost(protoArray: ProtoArray, root: RootHex): number { + const node = protoArray.getNode(root, PayloadStatus.PENDING); + if (node === undefined) throw Error(`missing PENDING node for ${root}`); + return node.weight - node.attestationScore; +} + +/** + * Build a gloas chain genesis -> parent -> child where the child holds proposer boost, plus an + * optional sibling of the parent (same slot) to act as the proposer equivocation. + * Votes passed here are applied to scores before the measured updateHead(), emulating state from a + * previous head update; use setPendingVote for votes the measured updateHead() itself must apply. + */ +function setup({ + sibling = null, + parentVotes = 0, + store = makeStore(), + childSlot = headSlot, +}: { + sibling?: {ptcTimeliness: boolean; proposerIndex?: ValidatorIndex} | null; + parentVotes?: number; + store?: IForkChoiceStore; + childSlot?: Slot; +} = {}): {forkChoice: ForkChoice; protoArray: ProtoArray; parentRoot: RootHex; childRoot: RootHex} { + const genesisRoot = getBlockRoot(genesisSlot); + const parentRoot = getBlockRoot(parentSlot); + const childRoot = getBlockRoot(childSlot); + + const protoArray = ProtoArray.initialize(toProtoBlock(genesisSlot, genesisRoot, false), genesisSlot); + protoArray.onBlock(toProtoBlock(parentSlot, genesisRoot, true, {proposerIndex: PARENT_PROPOSER}), parentSlot, null); + if (sibling) { + protoArray.onBlock( + toProtoBlock(parentSlot, genesisRoot, true, { + blockRoot: SIBLING_ROOT, + proposerIndex: sibling.proposerIndex ?? PARENT_PROPOSER, + ptcTimeliness: sibling.ptcTimeliness, + executionPayloadBlockHash: "0xpayload_sibling", + }), + parentSlot, + null + ); + } + protoArray.onBlock( + toProtoBlock(childSlot, parentRoot, true, {parentBlockHash: getPayloadBlockHash(parentSlot)}), + childSlot, + null + ); + + const forkChoice = new ForkChoice(gloasConfig, store, protoArray, VALIDATOR_COUNT, null, {proposerBoost: true}); + + if (parentVotes > 0) { + protoArray.applyScoreChanges({ + attestationDeltas: protoArray.nodes.map((node) => + node.blockRoot === parentRoot && node.payloadStatus === PayloadStatus.PENDING ? parentVotes : 0 + ), + proposerBoost: null, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot: store.currentSlot, + }); + } + + setBoostRoot(forkChoice, childRoot); + + return {forkChoice, protoArray, parentRoot, childRoot}; +} + +function pendingNodeIndex(protoArray: ProtoArray, root: RootHex): number { + const index = protoArray.nodes.findIndex( + (node) => node.blockRoot === root && node.payloadStatus === PayloadStatus.PENDING + ); + if (index < 0) throw Error(`missing PENDING node for ${root}`); + return index; +} + +describe("Forkchoice / shouldApplyProposerBoost", () => { + it("withholds boost when the parent is weak, adjacent, and its proposer equivocated PTC-timely", () => { + const {forkChoice, protoArray, childRoot} = setup({sibling: {ptcTimeliness: true}}); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(0); + }); + + it("applies boost when the equivocating sibling is not PTC-timely", () => { + const {forkChoice, protoArray, childRoot} = setup({sibling: {ptcTimeliness: false}}); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies boost when the sibling was proposed by a different proposer", () => { + const {forkChoice, protoArray, childRoot} = setup({ + sibling: {ptcTimeliness: true, proposerIndex: PARENT_PROPOSER + 1}, + }); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies boost when the parent is not weak", () => { + const {forkChoice, protoArray, childRoot} = setup({ + sibling: {ptcTimeliness: true}, + parentVotes: REORG_THRESHOLD, + }); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies boost when the parent is not from the previous slot", () => { + const {forkChoice, protoArray, childRoot} = setup({sibling: {ptcTimeliness: true}, childSlot: headSlot + 1}); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies this round's attestation deltas before deciding the boost", () => { + // The parent looks weak on the scores cached from the previous head update, but the vote + // pending in this very updateHead() carries it over the threshold. The boost decision must see + // the post-delta score and apply the boost, spec get_attestation_score being vote-current. + const {forkChoice, protoArray, parentRoot, childRoot} = setup({sibling: {ptcTimeliness: true}}); + setPendingVote(forkChoice, 0, pendingNodeIndex(protoArray, parentRoot)); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("removes a newly detected equivocator's vote before judging the parent weak", () => { + // Validator 0's vote is the parent's entire attestation score. Marking it equivocating and + // updating the head once must first discount the vote (parent score 150 -> 0), and with the + // equivocator outside the parent slot's committees nothing is added back: the parent is weak + // and the boost is withheld. Reading the stale pre-delta score would double-keep the vote and + // wrongly apply the boost. + const equivocatingIndices = new Set(); + const store = makeStore({equivocatingIndices, state: mockStateEmptyCommittee()}); + const {forkChoice, protoArray, parentRoot, childRoot} = setup({sibling: {ptcTimeliness: true}, store}); + + // First head update applies the vote, as a previous slot would have + setPendingVote(forkChoice, 0, pendingNodeIndex(protoArray, parentRoot)); + forkChoice.updateHead(); + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + + // Now the validator is discovered equivocating; the measured single updateHead() must both + // discount the vote and withhold the boost + equivocatingIndices.add(0); + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(0); + }); +}); diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index a270bc55e65e..ab1cc5a8e0ac 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -48,6 +48,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, @@ -74,6 +76,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -102,6 +106,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts b/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts index a238858f3d83..67fce63e67b5 100644 --- a/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts +++ b/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts @@ -37,6 +37,8 @@ function toProtoBlock(slot: number, blockRoot: RootHex, parentRoot: RootHex): Pr unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 446349cd246e..813545181249 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -120,6 +120,8 @@ function setupForkChoice(): ProtoArray { unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...executionData, diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index deb471d01592..bf129195a3fc 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -42,6 +42,8 @@ describe("getCommonAncestor", () => { unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -71,6 +73,8 @@ describe("getCommonAncestor", () => { unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts index 769f6c2ad45a..f5f4a4bcd485 100644 --- a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts @@ -26,6 +26,8 @@ function blockFields(overrides: { unrealizedFinalizedRoot: "0", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index d77fb776982e..13c43a3d406c 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -49,6 +49,8 @@ describe("Gloas Fork Choice", () => { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: genesisRoot, timeliness: true, + ptcTimeliness: true, + proposerIndex: 0, executionPayloadBlockHash: blockRoot, // Use blockRoot as execution hash executionPayloadNumber: slot, executionPayloadGasLimit: 30000000, diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index f23332f7a292..0de7f5f669cc 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -31,6 +31,8 @@ describe("ProtoArray", () => { unrealizedFinalizedRoot: stateRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -60,6 +62,8 @@ describe("ProtoArray", () => { unrealizedFinalizedRoot: stateRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -90,6 +94,8 @@ describe("ProtoArray", () => { unrealizedFinalizedRoot: stateRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData,