From d49bb942d9366c45d700fa45de4177ba18a4f33e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:18:07 -0800 Subject: [PATCH 01/52] Pass gloas to protoArray --- .../beacon-node/src/chain/forkChoice/index.ts | 5 +++-- .../fork-choice/src/protoArray/protoArray.ts | 21 ++++++++++++++++++- .../fork-choice/test/perf/forkChoice/util.ts | 3 ++- .../test/unit/forkChoice/forkChoice.test.ts | 3 ++- .../unit/forkChoice/getProposerHead.test.ts | 2 +- .../shouldOverrideForkChoiceUpdate.test.ts | 2 +- .../protoArray/executionStatusUpdates.test.ts | 3 ++- .../unit/protoArray/getCommonAncestor.test.ts | 3 ++- .../test/unit/protoArray/protoArray.test.ts | 3 ++- 9 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index d83dc9949d7e..80d3fb7abef7 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -145,7 +145,8 @@ export function initializeForkChoiceFromFinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, }, - currentSlot + currentSlot, + config, ), state.validators.length, metrics, @@ -265,7 +266,7 @@ export function initializeForkChoiceFromUnfinalizedState( targetRoot: toRootHex(finalizedCheckpoint.root), }; - const protoArray = ProtoArray.initialize(finalizedBlock, currentSlot); + const protoArray = ProtoArray.initialize(finalizedBlock, currentSlot, config); protoArray.onBlock(justifiedBlock, currentSlot); protoArray.onBlock(parentBlock, currentSlot); protoArray.onBlock(headBlock, currentSlot); diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 50a994d46476..a5fc3364b815 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -25,33 +25,44 @@ export class ProtoArray { private previousProposerBoost: ProposerBoost | null = null; + /** + * First epoch of Gloas fork (when ePBS activates) + * Blocks with slot >= gloasForkSlot use ePBS (PENDING/EMPTY/FULL variants) + * Blocks with slot < gloasForkSlot use pre-Gloas (PENDING only) + */ + private gloasForkEpoch: Epoch; + constructor({ pruneThreshold, justifiedEpoch, justifiedRoot, finalizedEpoch, finalizedRoot, + config, }: { pruneThreshold: number; justifiedEpoch: Epoch; justifiedRoot: RootHex; finalizedEpoch: Epoch; finalizedRoot: RootHex; + config: {GLOAS_FORK_EPOCH: number}; }) { this.pruneThreshold = pruneThreshold; this.justifiedEpoch = justifiedEpoch; this.justifiedRoot = justifiedRoot; this.finalizedEpoch = finalizedEpoch; this.finalizedRoot = finalizedRoot; + this.gloasForkEpoch = config.GLOAS_FORK_EPOCH; } - static initialize(block: Omit, currentSlot: Slot): ProtoArray { + static initialize(block: Omit, currentSlot: Slot, config: {GLOAS_FORK_EPOCH: number}): ProtoArray { const protoArray = new ProtoArray({ pruneThreshold: DEFAULT_PRUNE_THRESHOLD, justifiedEpoch: block.justifiedEpoch, justifiedRoot: block.justifiedRoot, finalizedEpoch: block.finalizedEpoch, finalizedRoot: block.finalizedRoot, + config, }); protoArray.onBlock( { @@ -64,6 +75,14 @@ export class ProtoArray { return protoArray; } + /** + * Check if a block is in the Gloas fork (ePBS enabled) + */ + private isGloasBlock(block: {slot: Slot}): boolean { + return computeEpochAtSlot(block.slot) >= this.gloasForkEpoch; + } + + /** * Iterate backwards through the array, touching all nodes and their parents and potentially * the best-child of each parent. diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 5b72dd88b9f5..e77fad87f772 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -32,7 +32,8 @@ export function initializeForkChoice(opts: Opts): ForkChoice { executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, } as Omit, - genesisSlot + genesisSlot, + {GLOAS_FORK_EPOCH: Infinity} ); const balances = new Uint16Array(Array.from({length: opts.initialValidatorCount}, () => 32)); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 91f1fc8614e9..e0f95163f0e6 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -42,7 +42,8 @@ describe("Forkchoice", () => { executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, } as Omit, - genesisSlot + genesisSlot, + {GLOAS_FORK_EPOCH: Infinity} ); }); diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 0eb01f3580c3..3580c1fee9c7 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -211,7 +211,7 @@ describe("Forkchoice / GetProposerHead", () => { ]; beforeEach(() => { - protoArr = ProtoArray.initialize(genesisBlock, genesisSlot); + protoArr = ProtoArray.initialize(genesisBlock, genesisSlot, {GLOAS_FORK_EPOCH: Infinity}); }); for (const { diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index 803c5fcbd5bb..f84d434d1f07 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -178,7 +178,7 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { ]; beforeEach(() => { - protoArr = ProtoArray.initialize(genesisBlock, genesisSlot); + protoArr = ProtoArray.initialize(genesisBlock, genesisSlot, {GLOAS_FORK_EPOCH: Infinity}); }); for (const { diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 5e77b48a8a23..e74236bd5a08 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -76,7 +76,8 @@ function setupForkChoice(): ProtoArray { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, } as Omit, - 0 + 0, + {GLOAS_FORK_EPOCH: Infinity} ); for (const block of blocks) { diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index b6a3f50af7eb..40d5f6473727 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -46,7 +46,8 @@ describe("getCommonAncestor", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, }, - 0 + 0, + {GLOAS_FORK_EPOCH: Infinity} ); for (const block of blocks) { diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index 6be99553ba3f..4a77939e74ab 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -35,7 +35,8 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, }, - genesisSlot + genesisSlot, + {GLOAS_FORK_EPOCH: Infinity} ); // Add block that is a finalized descendant. From 23f01a7a68f9724549e73e01269d87dd358a3fe4 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:50:14 -0800 Subject: [PATCH 02/52] Interface --- .../fork-choice/src/forkChoice/interface.ts | 41 ++++++++++++++++--- packages/fork-choice/src/index.ts | 3 +- .../fork-choice/src/protoArray/interface.ts | 41 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 32d9b6c68f78..cf26c24f547b 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -4,7 +4,12 @@ import { EffectiveBalanceIncrements, } from "@lodestar/state-transition"; import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types"; -import {LVHExecResponse, MaybeValidExecutionStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; +import { + LVHExecResponse, + MaybeValidExecutionStatus, + ProtoBlock, + ProtoNode, +} from "../protoArray/interface.js"; import {UpdateAndGetHeadOpt} from "./forkChoice.js"; import {CheckpointWithHex} from "./store.js"; @@ -72,16 +77,18 @@ export interface IForkChoice { irrecoverableError?: Error; /** - * Returns the block root of an ancestor of `block_root` at the given `slot`. (Note: `slot` refers + * Returns the ancestor node of `block_root` at the given `slot`. (Note: `slot` refers * to the block that is *returned*, not the one that is supplied.) * * ## Specification * - * Equivalent to: + * Modified for Gloas to return ProtoNode instead of just root: + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#modified-get_ancestor * - * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_ancestor + * Pre-Gloas: Returns (root, PAYLOAD_STATUS_FULL) + * Gloas: Returns (root, payloadStatus) based on actual node state */ - getAncestor(blockRoot: RootHex, ancestorSlot: Slot): RootHex; + getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode; /** * Run the fork choice rule to determine the head. * @@ -169,6 +176,30 @@ export interface IForkChoice { * https://github.com/ethereum/consensus-specs/blob/v1.2.0-rc.3/specs/phase0/fork-choice.md#on_attester_slashing */ onAttesterSlashing(slashing: AttesterSlashing): void; + /** + * Process PTC (Payload Timeliness Committee) messages from a block + * Updates the PTC votes for the attested beacon block + * + * ## Specification + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.0/specs/gloas/fork-choice.md#new-notify_ptc_messages + * + * @param blockRoot - The beacon block root being attested + * @param ptcIndices - Array of PTC committee indices that voted + * @param payloadPresent - Whether validators attest the payload is present + */ + notifyPtcMessage(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void; + /** + * Notify fork choice that an execution payload has arrived (Gloas fork) + * Creates the FULL variant of a Gloas block when the payload becomes available + * + * ## Specification + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-on_execution_payload + * + * @param blockRoot - The beacon block root for which the payload arrived + */ + onExecutionPayload(blockRoot: RootHex): void; /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. */ diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index e476660ee9ad..e87ca098e540 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -29,6 +29,7 @@ export type { MaybeValidExecutionStatus, ProtoBlock, ProtoNode, + ProtoNodeKey, } from "./protoArray/interface.js"; -export {ExecutionStatus} from "./protoArray/interface.js"; +export {ExecutionStatus, PayloadStatus, protoNodeKey, generateProtoNodeKey} from "./protoArray/interface.js"; export {ProtoArray} from "./protoArray/protoArray.js"; diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 74b908cb1fc6..a084052a35a8 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -24,6 +24,35 @@ export enum ExecutionStatus { Invalid = "Invalid", } +/** + * Payload status for ePBS (Gloas fork) + * Spec: gloas/fork-choice.md#constants + */ +export enum PayloadStatus { + PENDING = 0, + EMPTY = 1, + FULL = 2, +} + +/** + * Unique key for indexing ProtoNodes in the fork choice tree + * Format: "${root}:${payloadStatus}" + * Used to identify specific variants (PENDING/EMPTY/FULL) of a block + */ +export type ProtoNodeKey = string; + +/** + * Helper to convert ProtoNode to a unique key for indexing + * Format: "${blockRoot}:${payloadStatus}" + */ +export function protoNodeKey(node: ProtoNode): ProtoNodeKey { + return `${node.blockRoot}:${node.payloadStatus}`; +} + +export function generateProtoNodeKey(root: RootHex, payloadStatus: PayloadStatus): ProtoNodeKey { + return `${root}:${payloadStatus}`; +} + export type LVHValidResponse = { executionStatus: ExecutionStatus.Valid; latestValidExecHash: RootHex; @@ -89,14 +118,26 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; + + /** + * Parent block hash from SignedExecutionPayloadBid (Gloas fork only) + * Extracted from: signedExecutionPayloadBid.message.parentBlockHash + * Used to determine if this block extends EMPTY or FULL parent variant + * Spec: gloas/fork-choice.md#new-get_parent_payload_status + */ + parentBlockHash?: RootHex; }; /** * A block root with additional metadata required to form a DAG * with vote weights and best blocks stored as metadata + * + * It is also used as ForkChoiceNode in fork choice spec */ export type ProtoNode = ProtoBlock & { parent?: number; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ + payloadStatus: PayloadStatus; weight: number; bestChild?: number; bestDescendant?: number; From a826cc99f54b589fd273b89e469f359911b1540a Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:04:51 -0800 Subject: [PATCH 03/52] Update store --- .../fork-choice/src/forkChoice/forkChoice.ts | 27 ++++++++++++++----- packages/types/src/utils/typeguards.ts | 12 ++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 779583d3f9b4..9ebd9e47fd8a 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -32,10 +32,12 @@ import {computeDeltas} from "../protoArray/computeDeltas.js"; import {ProtoArrayError, ProtoArrayErrorCode} from "../protoArray/errors.js"; import { ExecutionStatus, + generateProtoNodeKey, HEX_ZERO_HASH, LVHExecResponse, MaybeValidExecutionStatus, NULL_VOTE_INDEX, + PayloadStatus, ProtoBlock, ProtoNode, VoteIndex, @@ -93,18 +95,26 @@ export class ForkChoice implements IForkChoice { irrecoverableError?: Error; /** * Votes currently tracked in the protoArray. Instead of tracking a VoteTracker of currentIndex, nextIndex and epoch, - * we decompose the struct and track them in 3 separate arrays for performance reason. + * we decompose the struct and track them in separate arrays for performance reason. + * + * For Gloas (ePBS), LatestMessage tracks slot instead of epoch and includes payload_present flag. + * Spec: gloas/fork-choice.md#modified-latestmessage */ private readonly voteCurrentIndices: VoteIndex[]; private readonly voteNextIndices: VoteIndex[]; - private readonly voteNextEpochs: Epoch[]; + private readonly voteNextSlots: Slot[]; + private readonly voteNextPayloadStatus: PayloadStatus[]; + private readonly voteCurrentPayloadStatus: PayloadStatus[]; /** * Attestations that arrived at the current slot and must be queued for later processing. * NOT currently tracked in the protoArray + * + * Modified for Gloas to track PayloadStatus per validator. + * Maps: Slot -> BlockRoot -> ValidatorIndex -> PayloadStatus */ - private readonly queuedAttestations: MapDef>> = new MapDef( - () => new MapDef(() => new Set()) + private readonly queuedAttestations: MapDef>> = new MapDef( + () => new MapDef(() => new Map()) ); /** @@ -149,13 +159,16 @@ export class ForkChoice implements IForkChoice { this.voteCurrentIndices = new Array(validatorCount).fill(NULL_VOTE_INDEX); this.voteNextIndices = new Array(validatorCount).fill(NULL_VOTE_INDEX); // when compute deltas, we ignore epoch if voteNextIndex is NULL_VOTE_INDEX anyway - this.voteNextEpochs = new Array(validatorCount).fill(INIT_VOTE_EPOCH); + + this.voteNextSlots = new Array(validatorCount).fill(0); + this.voteNextPayloadStatus = new Array(validatorCount).fill(PayloadStatus.FULL); + this.voteCurrentPayloadStatus = new Array(validatorCount).fill(PayloadStatus.FULL); this.head = this.updateHead(); this.balances = this.fcStore.justified.balances; metrics?.forkChoice.votes.addCollect(() => { - metrics.forkChoice.votes.set(this.voteNextEpochs.length); + metrics.forkChoice.votes.set(this.voteNextSlots.length); metrics.forkChoice.queuedAttestations.set(this.queuedAttestationsPreviousSlot); metrics.forkChoice.validatedAttestationDatas.set(this.validatedAttestationDatas.size); metrics.forkChoice.balancesLength.set(this.balances.length); @@ -961,7 +974,7 @@ export class ForkChoice implements IForkChoice { prune(finalizedRoot: RootHex): ProtoBlock[] { const prunedNodes = this.protoArray.maybePrune(finalizedRoot); const prunedCount = prunedNodes.length; - for (let i = 0; i < this.voteNextEpochs.length; i++) { + for (let i = 0; i < this.voteNextSlots.length; i++) { const currentIndex = this.voteCurrentIndices[i]; if (currentIndex !== NULL_VOTE_INDEX) { diff --git a/packages/types/src/utils/typeguards.ts b/packages/types/src/utils/typeguards.ts index e77179efe97d..e4819e0dd7f5 100644 --- a/packages/types/src/utils/typeguards.ts +++ b/packages/types/src/utils/typeguards.ts @@ -1,4 +1,10 @@ -import {FINALIZED_ROOT_DEPTH_ELECTRA, ForkPostBellatrix, ForkPostDeneb, ForkPostElectra} from "@lodestar/params"; +import { + FINALIZED_ROOT_DEPTH_ELECTRA, + ForkPostBellatrix, + ForkPostDeneb, + ForkPostElectra, + ForkPostGloas, +} from "@lodestar/params"; import { Attestation, BeaconBlock, @@ -96,3 +102,7 @@ export function isELectraLightClientFinalityUpdate( updatePostElectra.finalityBranch.length === FINALIZED_ROOT_DEPTH_ELECTRA ); } + +export function isGloasBeaconBlock(block: BeaconBlock): block is BeaconBlock { + return (block.body as BeaconBlockBody).signedExecutionPayloadBid !== undefined; +} \ No newline at end of file From be363a325cfe518b13e8b600d22462f316ea9474 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:05:11 -0800 Subject: [PATCH 04/52] Update store --- packages/fork-choice/src/forkChoice/forkChoice.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 9ebd9e47fd8a..a144357daaf9 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -23,6 +23,7 @@ import { RootHex, Slot, ValidatorIndex, + isGloasBeaconBlock, phase0, ssz, } from "@lodestar/types"; @@ -72,7 +73,7 @@ export type UpdateAndGetHeadOpt = | {mode: UpdateHeadOpt.GetPredictedProposerHead; secFromSlot: number; slot: Slot}; // the initial vote epoch for all validators -const INIT_VOTE_EPOCH: Epoch = 0; +const INIT_VOTE_SLOT: Slot = 0; /** * Provides an implementation of "Ethereum Consensus -- Beacon Chain Fork Choice": From ab1afefd4f38c21d91d95392c6a2edc772618de9 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:08:34 -0800 Subject: [PATCH 05/52] ProtoArray to store nodes instead of roots --- .../fork-choice/src/forkChoice/forkChoice.ts | 8 +- .../fork-choice/src/protoArray/protoArray.ts | 140 ++++++++++++------ 2 files changed, 98 insertions(+), 50 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index a144357daaf9..2a973fe593b8 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -190,7 +190,7 @@ export class ForkChoice implements IForkChoice { * * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_ancestor */ - getAncestor(blockRoot: RootHex, ancestorSlot: Slot): RootHex { + getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode { return this.protoArray.getAncestor(blockRoot, ancestorSlot); } @@ -640,15 +640,15 @@ export class ForkChoice implements IForkChoice { } // Check block is a descendant of the finalized block at the checkpoint finalized slot. - const blockAncestorRoot = this.getAncestor(parentRootHex, finalizedSlot); + const blockAncestorNode = this.getAncestor(parentRootHex, finalizedSlot); const finalizedRoot = this.fcStore.finalizedCheckpoint.rootHex; - if (blockAncestorRoot !== finalizedRoot) { + if (blockAncestorNode.blockRoot !== finalizedRoot) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.INVALID_BLOCK, err: { code: InvalidBlockCode.NOT_FINALIZED_DESCENDANT, finalizedRoot, - blockAncestor: blockAncestorRoot, + blockAncestor: blockAncestorNode.blockRoot, }, }); } diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index a5fc3364b815..7eb25d9863b4 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -20,7 +20,16 @@ export class ProtoArray { finalizedEpoch: Epoch; finalizedRoot: RootHex; nodes: ProtoNode[] = []; - indices = new Map(); + /** + * Maps ForkChoiceNode key (root:payloadStatus) to node index + * + * Unified approach for both Fulu and Gloas: + * - Fulu (pre-Gloas): All nodes use payloadStatus = PAYLOAD_STATUS_FULL (payload embedded) + * - Gloas: Nodes can be PENDING/EMPTY/FULL based on payload availability + */ + indices = new Map(); + // Given a PENDING index, maps to its EMPTY and FULL variant indices + variantIndices = new MapDef>(() => new Map()); lvhError?: LVHExecError; private previousProposerBoost: ProposerBoost | null = null; @@ -82,6 +91,35 @@ export class ProtoArray { return computeEpochAtSlot(block.slot) >= this.gloasForkEpoch; } + /** + * Get node index for a node identifier + * Spec: gloas/fork-choice.md (helper for node lookup) + */ + getNodeIndex(node: ProtoNode): number | undefined { + return this.indices.get(protoNodeKey(node)); + } + + /** + * Get node index for a block root + * Pre-Gloas blocks only exist as FULL (payload embedded in block) + * Gloas blocks exist as PENDING/EMPTY/FULL variants + * + * Try FULL first (for pre-Gloas), fallback to PENDING (for Gloas) + */ + private getNodeIndexByRoot(root: RootHex): number | undefined { + // Try FULL first (pre-Gloas blocks are FULL) + const fullIndex = this.getNodeIndexByKey(generateProtoNodeKey(root, PayloadStatus.FULL)); + if (fullIndex !== undefined) { + return fullIndex; + } + // Fallback to PENDING (Gloas blocks have PENDING variant) + return this.getNodeIndexByKey(generateProtoNodeKey(root, PayloadStatus.PENDING)); + } + + getNodeIndexByKey(key: ProtoNodeKey): number | undefined { + return this.indices.get(key); + } + /** * Iterate backwards through the array, touching all nodes and their parents and potentially @@ -115,11 +153,11 @@ export class ProtoArray { finalizedRoot: RootHex; currentSlot: Slot; }): void { - if (deltas.length !== this.indices.size) { + if (deltas.length !== this.nodes.length) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_DELTA_LEN, deltas: deltas.length, - indices: this.indices.size, + indices: this.nodes.length, }); } @@ -216,7 +254,7 @@ export class ProtoArray { */ onBlock(block: ProtoBlock, currentSlot: Slot): void { // If the block is already known, simply ignore it - if (this.indices.has(block.blockRoot)) { + if (this.hasBlock(block.blockRoot)) { return; } if (block.executionStatus === ExecutionStatus.Invalid) { @@ -226,28 +264,30 @@ export class ProtoArray { }); } - const node: ProtoNode = { - ...block, - parent: this.indices.get(block.parentRoot), - weight: 0, - bestChild: undefined, - bestDescendant: undefined, - }; - - const nodeIndex = this.nodes.length; + // Pre-Gloas (Fulu): Only create FULL node (payload embedded in block) + const node: ProtoNode = { + ...block, + parent: this.getNodeIndexByRoot(block.parentRoot), + payloadStatus: PayloadStatus.FULL, + weight: 0, + bestChild: undefined, + bestDescendant: undefined, + }; - this.indices.set(node.blockRoot, nodeIndex); - this.nodes.push(node); + const nodeIndex = this.nodes.length; + const nodeKey = getProtoNodeKey(block.blockRoot, PayloadStatus.FULL); + this.indices.set(nodeKey, nodeIndex); + this.nodes.push(node); - // If this node is valid, lets propagate the valid status up the chain - // and throw error if we counter invalid, as this breaks consensus - if (node.parent !== undefined) { - this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot); + // If this node is valid, lets propagate the valid status up the chain + // and throw error if we counter invalid, as this breaks consensus + if (node.parent !== undefined) { + this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot); - if (node.executionStatus === ExecutionStatus.Valid) { - this.propagateValidExecutionStatusByIndex(node.parent); + if (node.executionStatus === ExecutionStatus.Valid) { + this.propagateValidExecutionStatusByIndex(node.parent); + } } - } } /** @@ -298,7 +338,7 @@ export class ProtoArray { // if its in fcU. // const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse; - const invalidateFromParentIndex = this.indices.get(invalidateFromParentBlockRoot); + const invalidateFromParentIndex = this.getNodeIndexByRoot(invalidateFromParentBlockRoot); if (invalidateFromParentIndex === undefined) { throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`); } @@ -459,8 +499,12 @@ export class ProtoArray { /** * Follows the best-descendant links to find the best-block (i.e., head-block). + * + * Returns the compound key (root:payloadStatus) to identify the exact node variant. + * For pre-Gloas forks, only FULL variants exist (payload embedded). + * For Gloas, may return PENDING/EMPTY/FULL variants. */ - findHead(justifiedRoot: RootHex, currentSlot: Slot): RootHex { + findHead(justifiedRoot: RootHex, currentSlot: Slot): ProtoNodeKey { if (this.lvhError) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, @@ -468,7 +512,7 @@ export class ProtoArray { }); } - const justifiedIndex = this.indices.get(justifiedRoot); + const justifiedIndex = this.getNodeIndexByRoot(justifiedRoot); if (justifiedIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN, @@ -520,7 +564,7 @@ export class ProtoArray { }); } - return bestNode.blockRoot; + return protoNodeKey(bestNode); } /** @@ -780,13 +824,14 @@ export class ProtoArray { } const finalizedSlot = computeStartSlotAtEpoch(this.finalizedEpoch); - return this.finalizedEpoch === 0 || this.finalizedRoot === this.getAncestorOrNull(node.blockRoot, finalizedSlot); + const ancestorNode = this.getAncestorOrNull(node.blockRoot, finalizedSlot); + return this.finalizedEpoch === 0 || (ancestorNode !== null && this.finalizedRoot === ancestorNode.blockRoot); } /** * Same to getAncestor but it may return null instead of throwing error */ - getAncestorOrNull(blockRoot: RootHex, ancestorSlot: Slot): RootHex | null { + getAncestorOrNull(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode | null { try { return this.getAncestor(blockRoot, ancestorSlot); } catch (_) { @@ -795,32 +840,34 @@ export class ProtoArray { } /** - * Returns the block root of an ancestor of `blockRoot` at the given `slot`. + * Returns the node identifier of an ancestor of `blockRoot` at the given `slot`. * (Note: `slot` refers to the block that is *returned*, not the one that is supplied.) * * NOTE: May be expensive: potentially walks through the entire fork of head to finalized block * * ### Specification * - * Equivalent to: + * Modified for Gloas to return node identifier instead of just root: + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#modified-get_ancestor * - * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_ancestor + * Pre-Gloas: Returns (root, PAYLOAD_STATUS_FULL) + * Gloas: Returns (root, payloadStatus) based on actual node state */ - getAncestor(blockRoot: RootHex, ancestorSlot: Slot): RootHex { - const block = this.getBlock(blockRoot); - if (!block) { + getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode { + const node = this.getNode(blockRoot); + if (!node) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot, }); } - if (block.slot > ancestorSlot) { + if (node.slot > ancestorSlot) { // Search for a slot that is lte the target slot. // We check for lower slots to account for skip slots. - for (const node of this.iterateAncestorNodes(blockRoot)) { - if (node.slot <= ancestorSlot) { - return node.blockRoot; + for (const ancestorNode of this.iterateAncestorNodes(blockRoot)) { + if (ancestorNode.slot <= ancestorSlot) { + return ancestorNode; } } throw new ForkChoiceError({ @@ -830,14 +877,14 @@ export class ProtoArray { }); } // Root is older or equal than queried slot, thus a skip slot. Return most recent root prior to slot. - return blockRoot; + return node; } /** * Iterate from a block root backwards over nodes */ *iterateAncestorNodes(blockRoot: RootHex): IterableIterator { - const startIndex = this.indices.get(blockRoot); + const startIndex = this.getNodeIndexByRoot(blockRoot); if (startIndex === undefined) { return; } @@ -867,7 +914,7 @@ export class ProtoArray { * Get all nodes from a block root backwards */ getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] { - const startIndex = this.indices.get(blockRoot); + const startIndex = this.getNodeIndexByRoot(blockRoot); if (startIndex === undefined) { return []; } @@ -896,7 +943,7 @@ export class ProtoArray { * this is to find non-ancestor nodes of a blockRoot. */ getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] { - const startIndex = this.indices.get(blockRoot); + const startIndex = this.getNodeIndexByRoot(blockRoot); if (startIndex === undefined) { return []; } @@ -925,7 +972,7 @@ export class ProtoArray { * Returns both ancestor and non-ancestor nodes in a single traversal. */ getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} { - const startIndex = this.indices.get(blockRoot); + const startIndex = this.getNodeIndexByRoot(blockRoot); if (startIndex === undefined) { return {ancestors: [], nonAncestors: []}; } @@ -960,11 +1007,11 @@ export class ProtoArray { } hasBlock(blockRoot: RootHex): boolean { - return this.indices.has(blockRoot); + return this.getNodeIndexByRoot(blockRoot) !== undefined; } getNode(blockRoot: RootHex): ProtoNode | undefined { - const blockIndex = this.indices.get(blockRoot); + const blockIndex = this.getNodeIndexByRoot(blockRoot); if (blockIndex === undefined) { return undefined; } @@ -1058,7 +1105,8 @@ export class ProtoArray { } length(): number { - return this.indices.size; + // Note: this is number of nodes and not number of unique block root + return this.indices.size;; } private getNodeFromIndex(index: number): ProtoNode { From a0d8e0b902c06c19a021c1fae66eacf0ef0334ba Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:10:15 -0800 Subject: [PATCH 06/52] PTC --- .../fork-choice/src/forkChoice/forkChoice.ts | 10 +++ .../fork-choice/src/protoArray/protoArray.ts | 64 +++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 2a973fe593b8..64e03e46593a 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -853,6 +853,16 @@ export class ForkChoice implements IForkChoice { } } + /** + * Process a PTC (Payload Timeliness Committee) message + * Updates the PTC votes for multiple validators attesting to a block + * Spec: gloas/fork-choice.md#new-on_payload_attestation_message + */ + notifyPtcMessage(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void { + this.protoArray.notifyPtcMessage(blockRoot, ptcIndices, payloadPresent); + } + + /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. * This should only be called once per slot because: diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 7eb25d9863b4..b4cbb4c100cb 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,10 +1,30 @@ -import {GENESIS_EPOCH} from "@lodestar/params"; -import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params"; +import { + computeEpochAtSlot, + computeStartSlotAtEpoch, +} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {MapDef, toRootHex} from "@lodestar/utils"; import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js"; import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; -import {ExecutionStatus, HEX_ZERO_HASH, LVHExecResponse, ProtoBlock, ProtoNode} from "./interface.js"; +import { + ExecutionStatus, + generateProtoNodeKey, + generateProtoNodeKey as getProtoNodeKey, + HEX_ZERO_HASH, + LVHExecResponse, + PayloadStatus, + ProtoBlock, + ProtoNode, + ProtoNodeKey, + protoNodeKey, +} from "./interface.js"; + +/** + * Threshold for payload timeliness (>50% of PTC must vote) + * Spec: gloas/fork-choice.md (PAYLOAD_TIMELY_THRESHOLD = PTC_SIZE // 2) + */ +const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2); export const DEFAULT_PRUNE_THRESHOLD = 0; type ProposerBoost = {root: RootHex; score: number}; @@ -41,6 +61,16 @@ export class ProtoArray { */ private gloasForkEpoch: Epoch; + /** + * PTC (Payload Timeliness Committee) votes per block + * Maps block root to boolean array of size PTC_SIZE (from params: 512 mainnet, 2 minimal) + * Spec: gloas/fork-choice.md#modified-store (line 148) + * + * ptcVote[blockRoot][i] = true if PTC member i voted payload_present=true + * Used by is_payload_timely() to determine if payload is timely + */ + private ptcVote = new Map(); + constructor({ pruneThreshold, justifiedEpoch, @@ -288,6 +318,32 @@ export class ProtoArray { this.propagateValidExecutionStatusByIndex(node.parent); } } + + /** + * Update PTC votes for multiple validators attesting to a block + * Spec: gloas/fork-choice.md#new-on_payload_attestation_message + * + * @param blockRoot - The beacon block root being attested + * @param ptcIndices - Array of PTC committee indices that voted (0..PTC_SIZE-1) + * @param payloadPresent - Whether the validators attest the payload is present + */ + notifyPtcMessage(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void { + const votes = this.ptcVote.get(blockRoot); + if (votes === undefined) { + // Block not found or not a Gloas block, ignore + return; + } + + for (const ptcIndex of ptcIndices) { + if (ptcIndex < 0 || ptcIndex >= PTC_SIZE) { + throw new Error(`Invalid PTC index: ${ptcIndex}, must be 0..${PTC_SIZE - 1}`); + } + + // Update the vote + votes[ptcIndex] = payloadPresent; + } + } + } /** From 64fbd0928682dbe470ee5396db5e6d6cbc3ea3c7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:11:17 -0800 Subject: [PATCH 07/52] import latest message --- .../fork-choice/src/forkChoice/forkChoice.ts | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 64e03e46593a..9462a9bea020 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -817,10 +817,36 @@ export class ForkChoice implements IForkChoice { this.validateOnAttestation(attestation, slot, blockRootHex, targetEpoch, attDataRoot, forceImport); + // Pre-gloas: payload is always present + // Post-gloas: + // - always add weight to PENDING + // - if message.slot > block.slot, it also add weights to FULL or EMPTY + let payloadStatus: PayloadStatus; + if (computeEpochAtSlot(slot) < this.config.GLOAS_FORK_EPOCH) { + payloadStatus = PayloadStatus.FULL; + } else { + // We need to retrieve block to compare slot + // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-is_supporting_vote + const block = this.getBlockHex(blockRootHex); + + // If slot > block.slot, we can determine FULL or EMPTY. Else always PENDING + if (block && slot > block.slot) { + if (attestationData.index === 1) { + payloadStatus = PayloadStatus.FULL; + } else if (attestationData.index === 0) { + payloadStatus = PayloadStatus.EMPTY; + } else { + payloadStatus = PayloadStatus.PENDING; + } + } else { + payloadStatus = PayloadStatus.PENDING; + } + } + if (slot < this.fcStore.currentSlot) { for (const validatorIndex of attestation.attestingIndices) { if (!this.fcStore.equivocatingIndices.has(validatorIndex)) { - this.addLatestMessage(validatorIndex, targetEpoch, blockRootHex); + this.addLatestMessage(validatorIndex, slot, blockRootHex, payloadStatus); } } } else { @@ -1473,27 +1499,48 @@ export class ForkChoice implements IForkChoice { /** * Add a validator's latest message to the tracked votes. * Always sync voteCurrentIndices and voteNextIndices so that it'll not throw in computeDeltas() + * + * Modified for Gloas to accept slot and payloadPresent. + * Spec: gloas/fork-choice.md#modified-update_latest_messages + * + * For backward compatibility with Fulu (pre-Gloas): + * - Accepts both epoch-derived and slot parameters + * - payloadPresent defaults to true for Fulu (payloads embedded in blocks) */ - private addLatestMessage(validatorIndex: ValidatorIndex, nextEpoch: Epoch, nextRoot: RootHex): void { + private addLatestMessage( + validatorIndex: ValidatorIndex, + nextSlot: Slot, + nextRoot: RootHex, + nextPayloadStatus: PayloadStatus, + ): void { // should not happen, attestation is validated before this step - const nextIndex = this.protoArray.indices.get(nextRoot); + // For pre-Gloas blocks: use FULL (payload embedded in block) + // For Gloas blocks: use PENDING (all Gloas blocks have PENDING variant) + const lookupStatus = computeEpochAtSlot(nextSlot) < this.config.GLOAS_FORK_EPOCH + ? PayloadStatus.FULL + : PayloadStatus.PENDING; + const key = generateProtoNodeKey(nextRoot, lookupStatus); + const nextIndex = this.protoArray.getNodeIndexByKey(key); if (nextIndex === undefined) { throw new Error(`Could not find proto index for nextRoot ${nextRoot}`); } // ensure there is no undefined entries in Votes arrays - if (this.voteNextEpochs.length < validatorIndex + 1) { - for (let i = this.voteNextEpochs.length; i < validatorIndex + 1; i++) { - this.voteNextEpochs[i] = INIT_VOTE_EPOCH; + if (this.voteNextSlots.length < validatorIndex + 1) { + for (let i = this.voteNextSlots.length; i < validatorIndex + 1; i++) { + this.voteNextSlots[i] = INIT_VOTE_SLOT; + this.voteNextPayloadStatus[i] = PayloadStatus.FULL; + this.voteCurrentPayloadStatus[i] = PayloadStatus.FULL; this.voteCurrentIndices[i] = this.voteNextIndices[i] = NULL_VOTE_INDEX; } } - const existingNextEpoch = this.voteNextEpochs[validatorIndex]; - if (existingNextEpoch === INIT_VOTE_EPOCH || nextEpoch > existingNextEpoch) { + const existingNextSlot = this.voteNextSlots[validatorIndex]; + if (existingNextSlot === INIT_VOTE_SLOT || computeEpochAtSlot(nextSlot) > computeEpochAtSlot(existingNextSlot)) { // nextIndex is transfered to currentIndex in computeDeltas() this.voteNextIndices[validatorIndex] = nextIndex; - this.voteNextEpochs[validatorIndex] = nextEpoch; + this.voteNextSlots[validatorIndex] = nextSlot; + this.voteNextPayloadStatus[validatorIndex] = nextPayloadStatus; } // else its an old vote, don't count it } @@ -1505,18 +1552,17 @@ export class ForkChoice implements IForkChoice { private processAttestationQueue(): void { const currentSlot = this.fcStore.currentSlot; for (const [slot, byRoot] of this.queuedAttestations.entries()) { - const targetEpoch = computeEpochAtSlot(slot); if (slot < currentSlot) { this.queuedAttestations.delete(slot); - for (const [blockRoot, validatorIndices] of byRoot.entries()) { + for (const [blockRoot, validatorVotes] of byRoot.entries()) { const blockRootHex = blockRoot; - for (const validatorIndex of validatorIndices) { + for (const [validatorIndex, payloadStatus] of validatorVotes.entries()) { // equivocatingIndices was checked in onAttestation - this.addLatestMessage(validatorIndex, targetEpoch, blockRootHex); + this.addLatestMessage(validatorIndex, slot, blockRootHex, payloadStatus); } if (slot === currentSlot - 1) { - this.queuedAttestationsPreviousSlot += validatorIndices.size; + this.queuedAttestationsPreviousSlot += validatorVotes.size; } } } else { From c0bb5f2802418440be243f6b58989c2bd5dc384e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:13:00 -0800 Subject: [PATCH 08/52] compute deltas --- .../fork-choice/src/forkChoice/forkChoice.ts | 5 ++- .../src/protoArray/computeDeltas.ts | 44 ++++++++++++++++--- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 9462a9bea020..f7be4a75b036 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -479,10 +479,13 @@ export class ForkChoice implements IForkChoice { } = computeDeltas( this.protoArray.nodes.length, this.voteCurrentIndices, + this.voteCurrentPayloadStatus, this.voteNextIndices, + this.voteNextPayloadStatus, oldBalances, newBalances, - this.fcStore.equivocatingIndices + this.fcStore.equivocatingIndices, + this.protoArray.variantIndices, ); timer?.(); diff --git a/packages/fork-choice/src/protoArray/computeDeltas.ts b/packages/fork-choice/src/protoArray/computeDeltas.ts index 844d620f9998..24c31b89a941 100644 --- a/packages/fork-choice/src/protoArray/computeDeltas.ts +++ b/packages/fork-choice/src/protoArray/computeDeltas.ts @@ -1,7 +1,7 @@ import {EffectiveBalanceIncrements} from "@lodestar/state-transition"; import {ValidatorIndex} from "@lodestar/types"; import {ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; -import {NULL_VOTE_INDEX, VoteIndex} from "./interface.js"; +import {NULL_VOTE_INDEX, PayloadStatus, VoteIndex} from "./interface.js"; // reuse arrays to avoid memory reallocation and gc const deltas = new Array(); @@ -30,10 +30,13 @@ export type DeltasResult = { export function computeDeltas( numProtoNodes: number, voteCurrentIndices: VoteIndex[], + voteCurrentPayloadStatus: PayloadStatus[], voteNextIndices: VoteIndex[], + voteNextPayloadStatus: PayloadStatus[], oldBalances: EffectiveBalanceIncrements, newBalances: EffectiveBalanceIncrements, - equivocatingIndices: Set + equivocatingIndices: Set, + variantIndices: Map>, ): DeltasResult { if (voteCurrentIndices.length !== voteNextIndices.length) { throw new Error( @@ -51,7 +54,8 @@ export function computeDeltas( // avoid creating new variables in the loop to potentially reduce GC pressure let oldBalance: number, newBalance: number; - let currentIndex: VoteIndex, nextIndex: VoteIndex; + let currentIndex: VoteIndex, nextIndex: VoteIndex, currentVariantIndex: number | undefined, nextVariantIndex: number | undefined; + let currentPayloadStatus: PayloadStatus, nextPayloadStatus: PayloadStatus; // sort equivocating indices to avoid Set.has() in the loop const equivocatingArray = Array.from(equivocatingIndices).sort((a, b) => a - b); let equivocatingIndex = 0; @@ -66,6 +70,14 @@ export function computeDeltas( for (let vIndex = 0; vIndex < voteNextIndices.length; vIndex++) { currentIndex = voteCurrentIndices[vIndex]; nextIndex = voteNextIndices[vIndex]; + + currentPayloadStatus = voteCurrentPayloadStatus[vIndex]; + nextPayloadStatus = voteNextPayloadStatus[vIndex]; + + // If status is pending, current or next variant index is undefined because variantIndices only tracks EMPTY and FULL + currentVariantIndex = variantIndices.get(currentIndex)?.get(currentPayloadStatus); + nextVariantIndex = variantIndices.get(nextIndex)?.get(nextPayloadStatus); + // There is no need to create a score change if the validator has never voted or both of their // votes are for the zero hash (genesis block) if (currentIndex === NULL_VOTE_INDEX && nextIndex === NULL_VOTE_INDEX) { @@ -94,6 +106,7 @@ export function computeDeltas( }); } deltas[currentIndex] -= oldBalance; + if (currentVariantIndex !== undefined) deltas[currentVariantIndex] -= oldBalance; } voteCurrentIndices[vIndex] = NULL_VOTE_INDEX; equivocatingIndex++; @@ -106,7 +119,15 @@ export function computeDeltas( continue; } - if (currentIndex !== nextIndex || oldBalance !== newBalance) { + const indexChanged = currentIndex !== nextIndex; + const payloadStatusChanged = currentPayloadStatus !== nextPayloadStatus; + const balanceChanged = oldBalance !== newBalance; + + // Pre-gloas: deduct old balance from current index, add new balance to next index + // Post-gloas: deduct old balance from current variant of current index, add new balance to next variant of next index. + // If variant index is undefined, it means the payload status is PENDING, so we update current/next index instead. + // It is possible that index did not change, but payload status changed (e.g., PENDING -> FULL for skipped slot) + if (indexChanged || payloadStatusChanged || balanceChanged) { // We ignore the vote if it is not known in `indices . // We assume that it is outside of our tree (ie: pre-finalization) and therefore not interesting if (currentIndex !== NULL_VOTE_INDEX) { @@ -116,7 +137,12 @@ export function computeDeltas( index: currentIndex, }); } - deltas[currentIndex] -= oldBalance; + + if (currentVariantIndex !== undefined) { + deltas[currentVariantIndex] -= oldBalance; + } else { + deltas[currentIndex] -= oldBalance; + } } // We ignore the vote if it is not known in `indices . @@ -128,9 +154,15 @@ export function computeDeltas( index: nextIndex, }); } - deltas[nextIndex] += newBalance; + + if (nextVariantIndex !== undefined) { + deltas[nextVariantIndex] += newBalance; + } else { + deltas[nextIndex] += newBalance; + } } voteCurrentIndices[vIndex] = nextIndex; + voteCurrentPayloadStatus[vIndex] = nextPayloadStatus; newVoteValidators++; } else { unchangedVoteValidators++; From 74ab002d5fad299e89462f09413edc53bf43ab02 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:13:51 -0800 Subject: [PATCH 09/52] onBlock --- .../fork-choice/src/forkChoice/forkChoice.ts | 5 ++ .../fork-choice/src/protoArray/protoArray.ts | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index f7be4a75b036..f4d26a24891a 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -771,6 +771,11 @@ export class ForkChoice implements IForkChoice { executionStatus: this.getPreMergeExecStatus(executionStatus), dataAvailabilityStatus: this.getPreMergeDataStatus(dataAvailabilityStatus), }), + + // Extract parentBlockHash for Gloas blocks (ePBS) + // Spec: gloas/fork-choice.md#new-get_parent_payload_status + // Gloas blocks have signedExecutionPayloadBid with parentBlockHash + parentBlockHash: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) : undefined, }; this.protoArray.onBlock(protoBlock, currentSlot); diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index b4cbb4c100cb..69857ebede5f 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -294,6 +294,76 @@ export class ProtoArray { }); } + const isGloas = this.isGloasBlock(block); + + if (isGloas) { + // Gloas: Create PENDING + EMPTY nodes with correct parent relationships + // Parent of new PENDING node = parent block's EMPTY or FULL (inter-block edge) + // Parent of new EMPTY node = own PENDING node (intra-block edge) + + // For fork transition: if parent is Fulu (pre-Gloas), point to parent's FULL + // Otherwise, determine which parent payload status this block extends + let parentIndex: number | undefined; + let key: ProtoNodeKey; + const parentNode = this.getNode(block.parentRoot); + + if (parentNode && !this.isGloasBlock(parentNode)) { + // Fork transition: parent is Fulu, so it only has FULL variant + key = getProtoNodeKey(block.parentRoot, PayloadStatus.FULL); + } else { + // Both blocks are Gloas: determine which parent payload status to extend + const parentPayloadStatus = this.getParentPayloadStatus(block); + key = getProtoNodeKey(block.parentRoot, parentPayloadStatus); + } + parentIndex = this.getNodeIndexByKey(key); + + // Create PENDING node + const pendingNode: ProtoNode = { + ...block, + parent: parentIndex, // Points to parent's EMPTY/FULL or FULL (for transition) + payloadStatus: PayloadStatus.PENDING, + weight: 0, + bestChild: undefined, + bestDescendant: undefined, + }; + + const pendingIndex = this.nodes.length; + const pendingKey = generateProtoNodeKey(block.blockRoot, PayloadStatus.PENDING); + this.indices.set(pendingKey, pendingIndex); + this.nodes.push(pendingNode); + + // Create EMPTY variant as a child of PENDING + const emptyNode: ProtoNode = { + ...block, + parent: pendingIndex, // Points to own PENDING + payloadStatus: PayloadStatus.EMPTY, + weight: 0, + bestChild: undefined, + bestDescendant: undefined, + }; + + const emptyIndex = this.nodes.length; + const emptyKey = generateProtoNodeKey(block.blockRoot, PayloadStatus.EMPTY); + this.indices.set(emptyKey, emptyIndex); + this.nodes.push(emptyNode); + this.variantIndices.getOrDefault(pendingIndex).set(PayloadStatus.EMPTY, emptyIndex); + + // Update bestChild pointers + if (parentIndex !== undefined) { + this.maybeUpdateBestChildAndDescendant(parentIndex, pendingIndex, currentSlot); + + if (pendingNode.executionStatus === ExecutionStatus.Valid) { + this.propagateValidExecutionStatusByIndex(parentIndex); + } + } + + // Update bestChild for PENDING → EMPTY edge + this.maybeUpdateBestChildAndDescendant(pendingIndex, emptyIndex, currentSlot); + + // Initialize PTC votes for this block (all false initially) + // Spec: gloas/fork-choice.md#modified-on_block (line 645) + this.ptcVote.set(block.blockRoot, new Array(PTC_SIZE).fill(false)); + } else { // Pre-Gloas (Fulu): Only create FULL node (payload embedded in block) const node: ProtoNode = { ...block, From 19d15ed4285a6a3f597d21d84dd74b85b4697362 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:15:23 -0800 Subject: [PATCH 10/52] Best child and decendant --- .../fork-choice/src/protoArray/protoArray.ts | 329 +++++++++++++++--- 1 file changed, 288 insertions(+), 41 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 69857ebede5f..def8183ea130 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -150,6 +150,46 @@ export class ProtoArray { return this.indices.get(key); } + /** + * Determine which parent payload status a block extends + * Spec: gloas/fork-choice.md#new-get_parent_payload_status + * + * Compares parent_block_hash in child's bid with executionPayloadBlockHash in parent: + * - Match → child extends FULL parent (parent has payload) + * - No match → child extends EMPTY parent (parent has no payload) + * + * For pre-Gloas blocks: always returns FULL (payloads embedded in block) + */ + private getParentPayloadStatus(block: ProtoBlock): PayloadStatus { + // Pre-Gloas blocks have payloads embedded, so parents are always FULL + if (!this.isGloasBlock(block)) { + return PayloadStatus.FULL; + } + + // Gloas block must have parentBlockHash from its SignedExecutionPayloadBid + const parentBlockHash = block.parentBlockHash; + if (!parentBlockHash) { + // If parentBlockHash is not provided, default to FULL + // This can only happen in fulu + return PayloadStatus.FULL; + } + + // Get parent node to compare execution payload hash + const parentNode = this.getNode(block.parentRoot); + if (!parentNode) { + // Parent not found, default to EMPTY + // TODO GLOAS: verify this + return PayloadStatus.EMPTY; + } + + // Compare parent_block_hash from child's bid with parent's execution payload hash + // Match means child extends FULL variant (parent has payload) + // No match means child extends EMPTY variant (parent has no payload) + const parentExecutionHash = parentNode.executionPayloadBlockHash; + return parentBlockHash === parentExecutionHash + ? PayloadStatus.FULL + : PayloadStatus.EMPTY; + } /** * Iterate backwards through the array, touching all nodes and their parents and potentially @@ -414,6 +454,92 @@ export class ProtoArray { } } + /** + * Check if execution payload for a block is timely + * Spec: gloas/fork-choice.md#new-is_payload_timely + * + * Returns true if: + * 1. Block has PTC votes tracked + * 2. Payload is locally available (in executionPayloadStates) + * 3. More than PAYLOAD_TIMELY_THRESHOLD (>50% of PTC) members voted payload_present=true + * + * @param blockRoot - The beacon block root to check + * @param executionPayloadStates - Map of blocks with available execution payloads + */ + isPayloadTimely(blockRoot: RootHex, executionPayloadStates?: Map): boolean { + const votes = this.ptcVote.get(blockRoot); + if (votes === undefined) { + // Block not found or not a Gloas block + return false; + } + + // If payload is not locally available, it's not timely + if (!executionPayloadStates?.has(blockRoot)) { + return false; + } + + // Count votes for payload_present=true + const yesVotes = votes.filter((v) => v).length; + return yesVotes > PAYLOAD_TIMELY_THRESHOLD; + } + + /** + * Check if parent node is FULL + * Spec: gloas/fork-choice.md#new-is_parent_node_full + * + * Returns true if the parent payload status (determined by block.parentBlockHash) is FULL + */ + isParentNodeFull(block: ProtoBlock): boolean { + return this.getParentPayloadStatus(block) === PayloadStatus.FULL; + } + + /** + * Determine if we should extend the payload (prefer FULL over EMPTY) + * Spec: gloas/fork-choice.md#new-should_extend_payload + * + * Returns true if: + * 1. Payload is timely, OR + * 2. No proposer boost root (empty/zero hash), OR + * 3. Proposer boost root's parent is not this block, OR + * 4. Proposer boost root extends FULL parent + * + * @param blockRoot - The block root to check + * @param proposerBoostRoot - Current proposer boost root (from ForkChoice) + * @param executionPayloadStates - Map of blocks with available execution payloads + */ + shouldExtendPayload( + blockRoot: RootHex, + proposerBoostRoot: RootHex | null, + executionPayloadStates?: Map + ): boolean { + // Condition 1: Payload is timely + if (this.isPayloadTimely(blockRoot, executionPayloadStates)) { + return true; + } + + // Condition 2: No proposer boost root + if (proposerBoostRoot === null || proposerBoostRoot === HEX_ZERO_HASH) { + return true; + } + + // Get proposer boost block + const proposerBoostBlock = this.getNode(proposerBoostRoot); + if (!proposerBoostBlock) { + // Proposer boost block not found, default to extending payload + return true; + } + + // Condition 3: Proposer boost root's parent is not this block + if (proposerBoostBlock.parentRoot !== blockRoot) { + return true; + } + + // Condition 4: Proposer boost root extends FULL parent + if (this.isParentNodeFull(proposerBoostBlock)) { + return true; + } + + return false; } /** @@ -623,6 +749,42 @@ export class ProtoArray { return validNode; } + /** + * Get payload status tiebreaker for fork choice comparison + * Spec: gloas/fork-choice.md#new-get_payload_status_tiebreaker + * + * For Fulu: always returns node.payloadStatus (PENDING) + * For Gloas: implements tiebreaker logic based on should_extend_payload + */ + private getPayloadStatusTiebreaker( + node: ProtoNode, + currentSlot: Slot, + proposerBoostRoot: RootHex | null, + executionPayloadStates?: Map + ): number { + + // For Fulu: simple return payload status + // PENDING=0, EMPTY=1, FULL=2 + if (node.payloadStatus === PayloadStatus.PENDING) { + return node.payloadStatus; + } + + // For Gloas: check if from previous slot + if (node.slot + 1 !== currentSlot) { + return node.payloadStatus; + } + + // For previous slot blocks in Gloas, decide between FULL and EMPTY + // based on should_extend_payload + if (node.payloadStatus === PayloadStatus.EMPTY) { + return 1; // EMPTY + } else { + // FULL - check should_extend_payload + const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot, executionPayloadStates); + return shouldExtend ? 2 : 1; // Return 2 if extending, else 1 to prefer EMPTY + } + } + /** * Follows the best-descendant links to find the best-block (i.e., head-block). * @@ -793,6 +955,45 @@ export class ProtoArray { * - The child is not the best child but becomes the best child. * - The child is not the best child and does not become the best child. */ + + /** + * Check if we're comparing EMPTY vs FULL variants of the same block from slot n or n-1. + * + * This is a special case where the spec requires using `get_payload_status_tiebreaker()` + * directly without weight comparison. + * + * Spec: gloas/fork-choice.md#modified-get_weight (lines 413-446) and + * gloas/fork-choice.md#get_payload_status_tiebreaker (lines 331-343) + * + * @returns true if this is the EMPTY vs FULL edge case, false otherwise + */ + private isEmptyVsFullEdgeCase( + childNode: ProtoNode, + bestChildNode: ProtoNode, + currentSlot: Slot + ): boolean { + // Check if both nodes are: + // 1. The same block root (different payload status variants) + // 2. Both EMPTY or FULL (not PENDING) + // 3. From slot n-1 or slot n (current slot or previous slot) + if (childNode.blockRoot !== bestChildNode.blockRoot) { + return false; // Different blocks + } + + const childIsEmptyOrFull = childNode.payloadStatus !== PayloadStatus.PENDING; + const bestChildIsEmptyOrFull = bestChildNode.payloadStatus !== PayloadStatus.PENDING; + + if (!childIsEmptyOrFull || !bestChildIsEmptyOrFull) { + return false; // At least one is PENDING + } + + // Check if from slot n-1 or slot n + const isFromPreviousSlot = childNode.slot + 1 === currentSlot; + const isFromCurrentSlot = childNode.slot === currentSlot; + + return isFromPreviousSlot || isFromCurrentSlot; + } + maybeUpdateBestChildAndDescendant(parentIndex: number, childIndex: number, currentSlot: Slot): void { const childNode = this.nodes[childIndex]; if (childNode === undefined) { @@ -823,56 +1024,102 @@ export class ProtoArray { let newChildAndDescendant: ChildAndDescendant; const bestChildIndex = parentNode.bestChild; - if (bestChildIndex !== undefined) { - if (bestChildIndex === childIndex && !childLeadsToViableHead) { - // the child is already the best-child of the parent but its not viable for the head - // so remove it - newChildAndDescendant = changeToNull; - } else if (bestChildIndex === childIndex) { - // the child is the best-child already - // set it again to ensure that the best-descendent of the parent is updated - newChildAndDescendant = changeToChild; - } else { - const bestChildNode = this.nodes[bestChildIndex]; - if (bestChildNode === undefined) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX, - index: bestChildIndex, - }); - } + blk: { + if (bestChildIndex !== undefined) { + if (bestChildIndex === childIndex && !childLeadsToViableHead) { + // the child is already the best-child of the parent but its not viable for the head + // so remove it + newChildAndDescendant = changeToNull; + } else if (bestChildIndex === childIndex) { + // the child is the best-child already + // set it again to ensure that the best-descendent of the parent is updated + newChildAndDescendant = changeToChild; + } else { + const bestChildNode = this.nodes[bestChildIndex]; + if (bestChildNode === undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_BEST_CHILD_INDEX, + index: bestChildIndex, + }); + } - const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot); + const bestChildLeadsToViableHead = this.nodeLeadsToViableHead(bestChildNode, currentSlot); - if (childLeadsToViableHead && !bestChildLeadsToViableHead) { - // the child leads to a viable head, but the current best-child doesn't - newChildAndDescendant = changeToChild; - } else if (!childLeadsToViableHead && bestChildLeadsToViableHead) { - // the best child leads to a viable head but the child doesn't - newChildAndDescendant = noChange; - } else if (childNode.weight === bestChildNode.weight) { - // tie-breaker of equal weights by root - if (childNode.blockRoot >= bestChildNode.blockRoot) { + if (childLeadsToViableHead && !bestChildLeadsToViableHead) { + // the child leads to a viable head, but the current best-child doesn't newChildAndDescendant = changeToChild; - } else { + break blk; + } else if (!childLeadsToViableHead && bestChildLeadsToViableHead) { + // the best child leads to a viable head but the child doesn't newChildAndDescendant = noChange; - } - } else { - // choose the winner by weight - if (childNode.weight >= bestChildNode.weight) { - newChildAndDescendant = changeToChild; + break blk; } else { - newChildAndDescendant = noChange; + // Both nodes lead to viable heads (or both don't), need to pick winner + + // Pre-fulu we pick whichever has higher weight, tie-breaker by root + // Post-fulu we pick whichever has higher weight, then tie-breaker by root, then tie-breaker by `getPayloadStatusTiebreaker` + // Edge case: when comparing EMPTY vs FULL variants of the same block from slot n-1 or n, weights are hardcoded to 0 + // https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442 + // in this case we use `get_payload_status_tiebreaker()` directly because weights(0) and roots are equal + + // Gloas: Check if this is the EMPTY vs FULL edge case for slot n or n-1 + // If true, skip weight and root comparison (weights are 0, roots are equal) + const isEdgeCase = this.isEmptyVsFullEdgeCase(childNode, bestChildNode, currentSlot); + + if (!isEdgeCase && childNode.weight !== bestChildNode.weight) { + // Different weights, choose the winner by weight + if (childNode.weight >= bestChildNode.weight) { + newChildAndDescendant = changeToChild; + } else { + newChildAndDescendant = noChange; + } + break blk; + } + + if (!isEdgeCase && childNode.blockRoot !== bestChildNode.blockRoot) { + // Different blocks, tie-breaker by root + if (childNode.blockRoot >= bestChildNode.blockRoot) { + newChildAndDescendant = changeToChild; + } else { + newChildAndDescendant = noChange; + } + break blk; + } + + // Same weight and same root (or edge case), tie-breaker by payload status + const childTiebreaker = this.getPayloadStatusTiebreaker( + childNode, + currentSlot, + null, // proposerBoostRoot + undefined // executionPayloadStates + ); + + const bestChildTiebreaker = this.getPayloadStatusTiebreaker( + bestChildNode, + currentSlot, + null, + undefined + ); + + if (childTiebreaker > bestChildTiebreaker) { + newChildAndDescendant = changeToChild; + } else if (childTiebreaker < bestChildTiebreaker) { + newChildAndDescendant = noChange; + } else { + // Equal in all aspects, noChange + newChildAndDescendant = noChange; + } } } + } else if (childLeadsToViableHead) { + // There is no current best-child and the child is viable. + newChildAndDescendant = changeToChild; + } else { + // There is no current best-child but the child is not viable. + newChildAndDescendant = noChange; } - } else if (childLeadsToViableHead) { - // There is no current best-child and the child is viable. - newChildAndDescendant = changeToChild; - } else { - // There is no current best-child but the child is not viable. - newChildAndDescendant = noChange; } - + parentNode.bestChild = newChildAndDescendant[0]; parentNode.bestDescendant = newChildAndDescendant[1]; } From fed7b93f3b6e76d55c59fe08686d6b6e043e3d2c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:15:50 -0800 Subject: [PATCH 11/52] onExecutionPayload --- .../fork-choice/src/forkChoice/forkChoice.ts | 8 +++ .../fork-choice/src/protoArray/protoArray.ts | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index f4d26a24891a..f878ea989a28 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -896,6 +896,14 @@ export class ForkChoice implements IForkChoice { this.protoArray.notifyPtcMessage(blockRoot, ptcIndices, payloadPresent); } + /** + * Notify fork choice that an execution payload has arrived (Gloas fork) + * Creates the FULL variant of a Gloas block when the payload becomes available + * Spec: gloas/fork-choice.md#new-on_execution_payload + */ + onExecutionPayload(blockRoot: RootHex): void { + this.protoArray.onExecutionPayload(blockRoot, this.fcStore.currentSlot); + } /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index def8183ea130..ec988e9814f6 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -428,6 +428,65 @@ export class ProtoArray { this.propagateValidExecutionStatusByIndex(node.parent); } } + } + } + + /** + * Called when an execution payload is received for a block (Gloas only) + * Creates a FULL variant node as a sibling to the existing EMPTY variant + * Both EMPTY and FULL have parent = own PENDING node + * + * Spec: gloas/fork-choice.md (on_execution_payload event) + */ + onExecutionPayload(blockRoot: RootHex, currentSlot: Slot): void { + // First find FULL variant. If it exists, nothing to do. Block is always full pre-fulu + const fullKey = getProtoNodeKey(blockRoot, PayloadStatus.FULL); + const existedFullIndex = this.getNodeIndexByKey(fullKey); + if (existedFullIndex !== undefined) { + const existedFullNode = this.nodes[existedFullIndex]; + if (existedFullNode) { + // Pre-Gloas: execution payloads are part of the block, no separate event + return; + } + } + + // Get PENDING node for Gloas blocks + const pendingKey = getProtoNodeKey(blockRoot, PayloadStatus.PENDING); + const pendingIndex = this.getNodeIndexByKey(pendingKey); + + if (pendingIndex === undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.UNKNOWN_BLOCK, + root: blockRoot, + }); + } + + const pendingNode = this.nodes[pendingIndex]; + if (!pendingNode) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_NODE_INDEX, + index: pendingIndex, + }); + } + + // Create FULL variant as a child of PENDING (sibling to EMPTY) + const fullNode: ProtoNode = { + ...pendingNode, + parent: pendingIndex, // Points to own PENDING (same as EMPTY) + payloadStatus: PayloadStatus.FULL, + weight: 0, + bestChild: undefined, + bestDescendant: undefined, + }; + + const fullIndex = this.nodes.length; + this.indices.set(fullKey, fullIndex); + this.variantIndices.getOrDefault(pendingIndex).set(PayloadStatus.FULL, fullIndex); + this.nodes.push(fullNode); + + // Update bestChild for PENDING node (may now prefer FULL over EMPTY) + this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot); + } /** * Update PTC votes for multiple validators attesting to a block From 0d8f97ee9b9f730f1aa4e7790c4175d09ea93365 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:16:10 -0800 Subject: [PATCH 12/52] prune --- .../fork-choice/src/protoArray/protoArray.ts | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index ec988e9814f6..74fbeb5988c3 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -930,26 +930,51 @@ export class ProtoArray { * - There is some internal error relating to invalid indices inside `this`. */ maybePrune(finalizedRoot: RootHex): ProtoBlock[] { - const finalizedIndex = this.indices.get(finalizedRoot); - if (finalizedIndex === undefined) { + const finalizedPendingKey = getProtoNodeKey(finalizedRoot, PayloadStatus.PENDING); + const finalizedFullKey = getProtoNodeKey(finalizedRoot, PayloadStatus.FULL); + + const finalizedPendingIndex = this.getNodeIndexByKey(finalizedPendingKey); + const finalizedFullIndex = this.getNodeIndexByKey(finalizedFullKey); + + // If finalizedRoot exists in pre-gloas, FULL will be defined and PENDING undefined + // If finalizedRoot exists in gloas, PENDING will be defined and FULL may or may not be defined + if (finalizedPendingIndex === undefined && finalizedFullIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN, root: finalizedRoot, }); } + // We take the minimum index of the two variants to ensure we don't prune too much + const finalizedIndex = Math.min( + finalizedPendingIndex !== undefined ? finalizedPendingIndex : Number.MAX_SAFE_INTEGER, + finalizedFullIndex !== undefined ? finalizedFullIndex : Number.MAX_SAFE_INTEGER + ); + if (finalizedIndex < this.pruneThreshold) { // Pruning at small numbers incurs more cost than benefit return []; } - // Remove the this.indices key/values for all the to-be-deleted nodes + // Remove the indices key/values for all the to-be-deleted nodes + // Also remove PTC votes for pruned blocks (Gloas) + // Also remove variants (EMPTY, FULL) of finalized blocks + const nodesToPrune = Array.from({ length: finalizedIndex + 1 }, (_, i) => i); + const variants = this.variantIndices.get(finalizedIndex); + if (variants) nodesToPrune.push(...variants.values()); + for (let nodeIndex = 0; nodeIndex < finalizedIndex; nodeIndex++) { const node = this.nodes[nodeIndex]; if (node === undefined) { throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: nodeIndex}); } - this.indices.delete(node.blockRoot); + const nodeKey = generateProtoNodeKey(node.blockRoot, node.payloadStatus); + this.indices.delete(nodeKey); + this.variantIndices.delete(nodeIndex); + + // Prune PTC votes for this block to prevent memory leak + // Spec: gloas/fork-choice.md (implicit - finalized blocks don't need PTC votes) + this.ptcVote.delete(node.blockRoot); } // Store nodes prior to finalization @@ -958,6 +983,7 @@ export class ProtoArray { this.nodes = this.nodes.slice(finalizedIndex); // Adjust the indices map + const newIndices = new Map(); for (const [key, value] of this.indices.entries()) { if (value < finalizedIndex) { throw new ProtoArrayError({ @@ -965,8 +991,30 @@ export class ProtoArray { value: "indices", }); } - this.indices.set(key, value - finalizedIndex); + newIndices.set(key, value - finalizedIndex); + } + this.indices = newIndices; + + // Adjust variantIndices map + const newVariantIndices = new MapDef>(() => new Map()); + for (const [pendingIndex, variants] of this.variantIndices.entries()) { + if (pendingIndex < finalizedIndex) { + // Skip - this PENDING node was pruned + continue; + } + const newPendingIndex = pendingIndex - finalizedIndex; + const newVariants = new Map(); + for (const [status, variantIndex] of variants.entries()) { + if (variantIndex >= finalizedIndex) { + newVariants.set(status, variantIndex - finalizedIndex); + } + // else: variant was pruned, don't include + } + if (newVariants.size > 0) { + newVariantIndices.set(newPendingIndex, newVariants); + } } + this.variantIndices = newVariantIndices; // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes for (let i = 0, len = this.nodes.length; i < len; i++) { From 60fa9746ce5494da16375c745e02eaf93ee94f11 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:16:19 -0800 Subject: [PATCH 13/52] Misc --- .../fork-choice/src/forkChoice/forkChoice.ts | 19 ++++++++++--------- packages/fork-choice/src/protoArray/errors.ts | 2 ++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index f878ea989a28..2323d419cf09 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -525,23 +525,24 @@ export class ForkChoice implements IForkChoice { currentSlot, }); - const headRoot = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); - const headIndex = this.protoArray.indices.get(headRoot); + // findHead returns compound key (root:payloadStatus) for Gloas, or (root:FULL) for pre-Gloas + const headKey = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); + const headIndex = this.protoArray.indices.get(headKey); if (headIndex === undefined) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: headRoot, + root: headKey, }); } - const headNode = this.protoArray.nodes[headIndex]; - if (headNode === undefined) { + const head = this.protoArray.nodes[headIndex]; + if (head === undefined) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: headRoot, + root: headKey, }); } - this.head = headNode; + this.head = head; return this.head; } @@ -865,10 +866,10 @@ export class ForkChoice implements IForkChoice { // Delay consideration in the fork choice until their slot is in the past. // ``` const byRoot = this.queuedAttestations.getOrDefault(slot); - const validatorIndices = byRoot.getOrDefault(blockRootHex); + const validatorVotes = byRoot.getOrDefault(blockRootHex); for (const validatorIndex of attestation.attestingIndices) { if (!this.fcStore.equivocatingIndices.has(validatorIndex)) { - validatorIndices.add(validatorIndex); + validatorVotes.set(validatorIndex, payloadStatus); } } } diff --git a/packages/fork-choice/src/protoArray/errors.ts b/packages/fork-choice/src/protoArray/errors.ts index 650fd1c0b244..f6635874a2ca 100644 --- a/packages/fork-choice/src/protoArray/errors.ts +++ b/packages/fork-choice/src/protoArray/errors.ts @@ -12,6 +12,7 @@ export type LVHExecError = {lvhCode: LVHExecErrorCode; blockRoot: RootHex; execH export enum ProtoArrayErrorCode { FINALIZED_NODE_UNKNOWN = "PROTO_ARRAY_ERROR_FINALIZED_NODE_UNKNOWN", JUSTIFIED_NODE_UNKNOWN = "PROTO_ARRAY_ERROR_JUSTIFIED_NODE_UNKNOWN", + UNKNOWN_BLOCK = "PROTO_ARRAY_ERROR_UNKNOWN_BLOCK", INVALID_FINALIZED_ROOT_CHANGE = "PROTO_ARRAY_ERROR_INVALID_FINALIZED_ROOT_CHANGE", INVALID_NODE_INDEX = "PROTO_ARRAY_ERROR_INVALID_NODE_INDEX", INVALID_PARENT_INDEX = "PROTO_ARRAY_ERROR_INVALID_PARENT_INDEX", @@ -32,6 +33,7 @@ export enum ProtoArrayErrorCode { export type ProtoArrayErrorType = | {code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN; root: RootHex} | {code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN; root: RootHex} + | {code: ProtoArrayErrorCode.UNKNOWN_BLOCK; root: RootHex} | {code: ProtoArrayErrorCode.INVALID_FINALIZED_ROOT_CHANGE} | {code: ProtoArrayErrorCode.INVALID_NODE_INDEX; index: number} | {code: ProtoArrayErrorCode.INVALID_PARENT_INDEX; index: number} From 372872619c77f79ed962aa1e65435059df866978 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:19:14 -0800 Subject: [PATCH 14/52] lint --- .../beacon-node/src/chain/forkChoice/index.ts | 2 +- .../fork-choice/src/forkChoice/forkChoice.ts | 15 +- .../fork-choice/src/forkChoice/interface.ts | 7 +- packages/fork-choice/src/index.ts | 2 +- .../src/protoArray/computeDeltas.ts | 7 +- .../fork-choice/src/protoArray/interface.ts | 2 +- .../fork-choice/src/protoArray/protoArray.ts | 140 ++++++++---------- packages/types/src/utils/typeguards.ts | 2 +- 8 files changed, 82 insertions(+), 95 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 80d3fb7abef7..aa6b5b521c03 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -146,7 +146,7 @@ export function initializeForkChoiceFromFinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, }, currentSlot, - config, + config ), state.validators.length, metrics, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 2323d419cf09..316ca0f1a1ee 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -33,7 +33,6 @@ import {computeDeltas} from "../protoArray/computeDeltas.js"; import {ProtoArrayError, ProtoArrayErrorCode} from "../protoArray/errors.js"; import { ExecutionStatus, - generateProtoNodeKey, HEX_ZERO_HASH, LVHExecResponse, MaybeValidExecutionStatus, @@ -42,6 +41,7 @@ import { ProtoBlock, ProtoNode, VoteIndex, + generateProtoNodeKey, } from "../protoArray/interface.js"; import {ProtoArray} from "../protoArray/protoArray.js"; import {ForkChoiceError, ForkChoiceErrorCode, InvalidAttestationCode, InvalidBlockCode} from "./errors.js"; @@ -485,7 +485,7 @@ export class ForkChoice implements IForkChoice { oldBalances, newBalances, this.fcStore.equivocatingIndices, - this.protoArray.variantIndices, + this.protoArray.variantIndices ); timer?.(); @@ -776,7 +776,9 @@ export class ForkChoice implements IForkChoice { // Extract parentBlockHash for Gloas blocks (ePBS) // Spec: gloas/fork-choice.md#new-get_parent_payload_status // Gloas blocks have signedExecutionPayloadBid with parentBlockHash - parentBlockHash: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) : undefined, + parentBlockHash: isGloasBeaconBlock(block) + ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) + : undefined, }; this.protoArray.onBlock(protoBlock, currentSlot); @@ -1528,14 +1530,13 @@ export class ForkChoice implements IForkChoice { validatorIndex: ValidatorIndex, nextSlot: Slot, nextRoot: RootHex, - nextPayloadStatus: PayloadStatus, + nextPayloadStatus: PayloadStatus ): void { // should not happen, attestation is validated before this step // For pre-Gloas blocks: use FULL (payload embedded in block) // For Gloas blocks: use PENDING (all Gloas blocks have PENDING variant) - const lookupStatus = computeEpochAtSlot(nextSlot) < this.config.GLOAS_FORK_EPOCH - ? PayloadStatus.FULL - : PayloadStatus.PENDING; + const lookupStatus = + computeEpochAtSlot(nextSlot) < this.config.GLOAS_FORK_EPOCH ? PayloadStatus.FULL : PayloadStatus.PENDING; const key = generateProtoNodeKey(nextRoot, lookupStatus); const nextIndex = this.protoArray.getNodeIndexByKey(key); if (nextIndex === undefined) { diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index cf26c24f547b..b3948b51a2cf 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -4,12 +4,7 @@ import { EffectiveBalanceIncrements, } from "@lodestar/state-transition"; import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types"; -import { - LVHExecResponse, - MaybeValidExecutionStatus, - ProtoBlock, - ProtoNode, -} from "../protoArray/interface.js"; +import {LVHExecResponse, MaybeValidExecutionStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; import {UpdateAndGetHeadOpt} from "./forkChoice.js"; import {CheckpointWithHex} from "./store.js"; diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index e87ca098e540..2e16637882b2 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -31,5 +31,5 @@ export type { ProtoNode, ProtoNodeKey, } from "./protoArray/interface.js"; -export {ExecutionStatus, PayloadStatus, protoNodeKey, generateProtoNodeKey} from "./protoArray/interface.js"; +export {ExecutionStatus, PayloadStatus, generateProtoNodeKey, protoNodeKey} from "./protoArray/interface.js"; export {ProtoArray} from "./protoArray/protoArray.js"; diff --git a/packages/fork-choice/src/protoArray/computeDeltas.ts b/packages/fork-choice/src/protoArray/computeDeltas.ts index 24c31b89a941..409090de417d 100644 --- a/packages/fork-choice/src/protoArray/computeDeltas.ts +++ b/packages/fork-choice/src/protoArray/computeDeltas.ts @@ -36,7 +36,7 @@ export function computeDeltas( oldBalances: EffectiveBalanceIncrements, newBalances: EffectiveBalanceIncrements, equivocatingIndices: Set, - variantIndices: Map>, + variantIndices: Map> ): DeltasResult { if (voteCurrentIndices.length !== voteNextIndices.length) { throw new Error( @@ -54,7 +54,10 @@ export function computeDeltas( // avoid creating new variables in the loop to potentially reduce GC pressure let oldBalance: number, newBalance: number; - let currentIndex: VoteIndex, nextIndex: VoteIndex, currentVariantIndex: number | undefined, nextVariantIndex: number | undefined; + let currentIndex: VoteIndex, + nextIndex: VoteIndex, + currentVariantIndex: number | undefined, + nextVariantIndex: number | undefined; let currentPayloadStatus: PayloadStatus, nextPayloadStatus: PayloadStatus; // sort equivocating indices to avoid Set.has() in the loop const equivocatingArray = Array.from(equivocatingIndices).sort((a, b) => a - b); diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index a084052a35a8..ee30a47d69a7 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -131,7 +131,7 @@ export type ProtoBlock = BlockExtraMeta & { /** * A block root with additional metadata required to form a DAG * with vote weights and best blocks stored as metadata - * + * * It is also used as ForkChoiceNode in fork choice spec */ export type ProtoNode = ProtoBlock & { diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 74fbeb5988c3..c2c549e1829f 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,22 +1,19 @@ import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params"; -import { - computeEpochAtSlot, - computeStartSlotAtEpoch, -} from "@lodestar/state-transition"; +import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot} from "@lodestar/types"; import {MapDef, toRootHex} from "@lodestar/utils"; import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js"; import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; import { ExecutionStatus, - generateProtoNodeKey, - generateProtoNodeKey as getProtoNodeKey, HEX_ZERO_HASH, LVHExecResponse, PayloadStatus, ProtoBlock, ProtoNode, ProtoNodeKey, + generateProtoNodeKey, + generateProtoNodeKey as getProtoNodeKey, protoNodeKey, } from "./interface.js"; @@ -94,7 +91,11 @@ export class ProtoArray { this.gloasForkEpoch = config.GLOAS_FORK_EPOCH; } - static initialize(block: Omit, currentSlot: Slot, config: {GLOAS_FORK_EPOCH: number}): ProtoArray { + static initialize( + block: Omit, + currentSlot: Slot, + config: {GLOAS_FORK_EPOCH: number} + ): ProtoArray { const protoArray = new ProtoArray({ pruneThreshold: DEFAULT_PRUNE_THRESHOLD, justifiedEpoch: block.justifiedEpoch, @@ -186,9 +187,7 @@ export class ProtoArray { // Match means child extends FULL variant (parent has payload) // No match means child extends EMPTY variant (parent has no payload) const parentExecutionHash = parentNode.executionPayloadBlockHash; - return parentBlockHash === parentExecutionHash - ? PayloadStatus.FULL - : PayloadStatus.EMPTY; + return parentBlockHash === parentExecutionHash ? PayloadStatus.FULL : PayloadStatus.EMPTY; } /** @@ -821,7 +820,6 @@ export class ProtoArray { proposerBoostRoot: RootHex | null, executionPayloadStates?: Map ): number { - // For Fulu: simple return payload status // PENDING=0, EMPTY=1, FULL=2 if (node.payloadStatus === PayloadStatus.PENDING) { @@ -837,11 +835,10 @@ export class ProtoArray { // based on should_extend_payload if (node.payloadStatus === PayloadStatus.EMPTY) { return 1; // EMPTY - } else { - // FULL - check should_extend_payload - const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot, executionPayloadStates); - return shouldExtend ? 2 : 1; // Return 2 if extending, else 1 to prefer EMPTY } + // FULL - check should_extend_payload + const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot, executionPayloadStates); + return shouldExtend ? 2 : 1; // Return 2 if extending, else 1 to prefer EMPTY } /** @@ -959,7 +956,7 @@ export class ProtoArray { // Remove the indices key/values for all the to-be-deleted nodes // Also remove PTC votes for pruned blocks (Gloas) // Also remove variants (EMPTY, FULL) of finalized blocks - const nodesToPrune = Array.from({ length: finalizedIndex + 1 }, (_, i) => i); + const nodesToPrune = Array.from({length: finalizedIndex + 1}, (_, i) => i); const variants = this.variantIndices.get(finalizedIndex); if (variants) nodesToPrune.push(...variants.values()); @@ -1074,11 +1071,7 @@ export class ProtoArray { * * @returns true if this is the EMPTY vs FULL edge case, false otherwise */ - private isEmptyVsFullEdgeCase( - childNode: ProtoNode, - bestChildNode: ProtoNode, - currentSlot: Slot - ): boolean { + private isEmptyVsFullEdgeCase(childNode: ProtoNode, bestChildNode: ProtoNode, currentSlot: Slot): boolean { // Check if both nodes are: // 1. The same block root (different payload status variants) // 2. Both EMPTY or FULL (not PENDING) @@ -1131,7 +1124,7 @@ export class ProtoArray { let newChildAndDescendant: ChildAndDescendant; const bestChildIndex = parentNode.bestChild; - blk: { + outer: { if (bestChildIndex !== undefined) { if (bestChildIndex === childIndex && !childLeadsToViableHead) { // the child is already the best-child of the parent but its not viable for the head @@ -1155,67 +1148,62 @@ export class ProtoArray { if (childLeadsToViableHead && !bestChildLeadsToViableHead) { // the child leads to a viable head, but the current best-child doesn't newChildAndDescendant = changeToChild; - break blk; - } else if (!childLeadsToViableHead && bestChildLeadsToViableHead) { + break outer; + } + if (!childLeadsToViableHead && bestChildLeadsToViableHead) { // the best child leads to a viable head but the child doesn't newChildAndDescendant = noChange; - break blk; - } else { - // Both nodes lead to viable heads (or both don't), need to pick winner - - // Pre-fulu we pick whichever has higher weight, tie-breaker by root - // Post-fulu we pick whichever has higher weight, then tie-breaker by root, then tie-breaker by `getPayloadStatusTiebreaker` - // Edge case: when comparing EMPTY vs FULL variants of the same block from slot n-1 or n, weights are hardcoded to 0 - // https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442 - // in this case we use `get_payload_status_tiebreaker()` directly because weights(0) and roots are equal - - // Gloas: Check if this is the EMPTY vs FULL edge case for slot n or n-1 - // If true, skip weight and root comparison (weights are 0, roots are equal) - const isEdgeCase = this.isEmptyVsFullEdgeCase(childNode, bestChildNode, currentSlot); - - if (!isEdgeCase && childNode.weight !== bestChildNode.weight) { - // Different weights, choose the winner by weight - if (childNode.weight >= bestChildNode.weight) { - newChildAndDescendant = changeToChild; - } else { - newChildAndDescendant = noChange; - } - break blk; - } + break outer; + } + // Both nodes lead to viable heads (or both don't), need to pick winner - if (!isEdgeCase && childNode.blockRoot !== bestChildNode.blockRoot) { - // Different blocks, tie-breaker by root - if (childNode.blockRoot >= bestChildNode.blockRoot) { - newChildAndDescendant = changeToChild; - } else { - newChildAndDescendant = noChange; - } - break blk; - } + // Pre-fulu we pick whichever has higher weight, tie-breaker by root + // Post-fulu we pick whichever has higher weight, then tie-breaker by root, then tie-breaker by `getPayloadStatusTiebreaker` + // Edge case: when comparing EMPTY vs FULL variants of the same block from slot n-1 or n, weights are hardcoded to 0 + // https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442 + // in this case we use `get_payload_status_tiebreaker()` directly because weights(0) and roots are equal - // Same weight and same root (or edge case), tie-breaker by payload status - const childTiebreaker = this.getPayloadStatusTiebreaker( - childNode, - currentSlot, - null, // proposerBoostRoot - undefined // executionPayloadStates - ); - - const bestChildTiebreaker = this.getPayloadStatusTiebreaker( - bestChildNode, - currentSlot, - null, - undefined - ); - - if (childTiebreaker > bestChildTiebreaker) { + // Gloas: Check if this is the EMPTY vs FULL edge case for slot n or n-1 + // If true, skip weight and root comparison (weights are 0, roots are equal) + const isEdgeCase = this.isEmptyVsFullEdgeCase(childNode, bestChildNode, currentSlot); + + if (!isEdgeCase && childNode.weight !== bestChildNode.weight) { + // Different weights, choose the winner by weight + if (childNode.weight >= bestChildNode.weight) { newChildAndDescendant = changeToChild; - } else if (childTiebreaker < bestChildTiebreaker) { + } else { newChildAndDescendant = noChange; + } + break outer; + } + + if (!isEdgeCase && childNode.blockRoot !== bestChildNode.blockRoot) { + // Different blocks, tie-breaker by root + if (childNode.blockRoot >= bestChildNode.blockRoot) { + newChildAndDescendant = changeToChild; } else { - // Equal in all aspects, noChange newChildAndDescendant = noChange; } + break outer; + } + + // Same weight and same root (or edge case), tie-breaker by payload status + const childTiebreaker = this.getPayloadStatusTiebreaker( + childNode, + currentSlot, + null, // proposerBoostRoot + undefined // executionPayloadStates + ); + + const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, null, undefined); + + if (childTiebreaker > bestChildTiebreaker) { + newChildAndDescendant = changeToChild; + } else if (childTiebreaker < bestChildTiebreaker) { + newChildAndDescendant = noChange; + } else { + // Equal in all aspects, noChange + newChildAndDescendant = noChange; } } } else if (childLeadsToViableHead) { @@ -1226,7 +1214,7 @@ export class ProtoArray { newChildAndDescendant = noChange; } } - + parentNode.bestChild = newChildAndDescendant[0]; parentNode.bestDescendant = newChildAndDescendant[1]; } @@ -1586,7 +1574,7 @@ export class ProtoArray { length(): number { // Note: this is number of nodes and not number of unique block root - return this.indices.size;; + return this.indices.size; } private getNodeFromIndex(index: number): ProtoNode { diff --git a/packages/types/src/utils/typeguards.ts b/packages/types/src/utils/typeguards.ts index e4819e0dd7f5..4e58ce34be16 100644 --- a/packages/types/src/utils/typeguards.ts +++ b/packages/types/src/utils/typeguards.ts @@ -105,4 +105,4 @@ export function isELectraLightClientFinalityUpdate( export function isGloasBeaconBlock(block: BeaconBlock): block is BeaconBlock { return (block.body as BeaconBlockBody).signedExecutionPayloadBid !== undefined; -} \ No newline at end of file +} From f3df981882ed53f65d4d4ded710ad28c5cc12a4f Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:19:21 -0800 Subject: [PATCH 15/52] Update tests --- ai/reference/consensus-specs | 1 + .../perf/protoArray/computeDeltas.test.ts | 13 +- .../test/unit/forkChoice/forkChoice.test.ts | 9 +- .../unit/protoArray/computeDeltas.test.ts | 73 +- .../protoArray/executionStatusUpdates.test.ts | 4 +- .../test/unit/protoArray/gloas.test.ts | 674 ++++++++++++++++++ 6 files changed, 758 insertions(+), 16 deletions(-) create mode 160000 ai/reference/consensus-specs create mode 100644 packages/fork-choice/test/unit/protoArray/gloas.test.ts diff --git a/ai/reference/consensus-specs b/ai/reference/consensus-specs new file mode 160000 index 000000000000..ef9ebec97f6d --- /dev/null +++ b/ai/reference/consensus-specs @@ -0,0 +1 @@ +Subproject commit ef9ebec97f6deb7ad586dfbb6eb417295c6853f1 diff --git a/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts b/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts index 8338bcd979c9..61ccdd3b6fd8 100644 --- a/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts +++ b/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts @@ -1,7 +1,7 @@ import {beforeAll, bench, describe} from "@chainsafe/benchmark"; import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsZeroed} from "@lodestar/state-transition"; import {computeDeltas} from "../../../src/protoArray/computeDeltas.js"; -import {NULL_VOTE_INDEX} from "../../../src/protoArray/interface.js"; +import {NULL_VOTE_INDEX, PayloadStatus} from "../../../src/protoArray/interface.js"; describe("computeDeltas", () => { let oldBalances: EffectiveBalanceIncrements; @@ -35,6 +35,8 @@ describe("computeDeltas", () => { inainactiveValidatorsPercentage === 0 ? null : Math.floor(1 / inainactiveValidatorsPercentage); const voteCurrentIndices = Array.from({length: numValidator}, () => NULL_VOTE_INDEX); const voteNextIndices = Array.from({length: numValidator}, () => NULL_VOTE_INDEX); + const voteCurrentPayloadStatus = Array.from({length: numValidator}, () => PayloadStatus.FULL); + const voteNextPayloadStatus = Array.from({length: numValidator}, () => PayloadStatus.FULL); bench({ id: `computeDeltas ${numValidator} validators ${inainactiveValidatorsPercentage * 100}% inactive`, beforeEach: () => { @@ -43,16 +45,19 @@ describe("computeDeltas", () => { voteCurrentIndices[i] = Math.floor(numProtoNode / 2); voteNextIndices[i] = Math.floor(numProtoNode / 2) + 1; } - return {voteCurrentIndices, voteNextIndices}; + return {voteCurrentIndices, voteCurrentPayloadStatus, voteNextIndices, voteNextPayloadStatus}; }, - fn: ({voteCurrentIndices, voteNextIndices}) => { + fn: ({voteCurrentIndices, voteCurrentPayloadStatus, voteNextIndices, voteNextPayloadStatus}) => { computeDeltas( numProtoNode, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set([1, 2, 3, 4, 5]) + new Set([1, 2, 3, 4, 5]), + new Map() ); }, }); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index e0f95163f0e6..788352d17cb7 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -125,7 +125,14 @@ describe("Forkchoice", () => { const summaries = forkchoice.getAllAncestorBlocks(getBlockRoot(genesisSlot + 1)); // there are 2 blocks in protoArray but iterateAncestorBlocks should only return non-finalized blocks expect(summaries).toHaveLength(1); - expect(summaries[0]).toEqual({...block, bestChild: undefined, bestDescendant: undefined, parent: 0, weight: 0}); + expect(summaries[0]).toEqual({ + ...block, + bestChild: undefined, + bestDescendant: undefined, + parent: 0, + weight: 0, + payloadStatus: 2, // Pre-Gloas blocks always have PAYLOAD_STATUS_FULL + }); }); it("getAllAncestorAndNonAncestorBlocks equals getAllAncestorBlocks + getAllNonAncestorBlocks", () => { diff --git a/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts b/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts index c81c826d9d3a..e1475170c9fa 100644 --- a/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it} from "vitest"; import {getEffectiveBalanceIncrementsZeroed} from "@lodestar/state-transition"; import {computeDeltas} from "../../../src/protoArray/computeDeltas.js"; -import {NULL_VOTE_INDEX} from "../../../src/protoArray/interface.js"; +import {NULL_VOTE_INDEX, PayloadStatus} from "../../../src/protoArray/interface.js"; describe("computeDeltas", () => { it("zero hash", () => { @@ -10,6 +10,8 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; + const voteCurrentPayloadStatus = []; + const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -17,6 +19,8 @@ describe("computeDeltas", () => { indices.set(i.toString(), i); voteCurrentIndices.push(0); voteNextIndices.push(0); + voteCurrentPayloadStatus.push(PayloadStatus.FULL); + voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = 0; newBalances[i] = 0; } @@ -24,10 +28,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(validatorCount); @@ -44,6 +51,8 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; + const voteCurrentPayloadStatus = []; + const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -51,6 +60,8 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(NULL_VOTE_INDEX); voteNextIndices.push(0); + voteCurrentPayloadStatus.push(PayloadStatus.FULL); + voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = balance; newBalances[i] = balance; } @@ -58,10 +69,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(validatorCount); @@ -82,6 +96,8 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; + const voteCurrentPayloadStatus = []; + const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -89,6 +105,8 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(NULL_VOTE_INDEX); voteNextIndices.push(i); + voteCurrentPayloadStatus.push(PayloadStatus.FULL); + voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = balance; newBalances[i] = balance; } @@ -96,10 +114,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(validatorCount); @@ -116,6 +137,8 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; + const voteCurrentPayloadStatus = []; + const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -123,6 +146,8 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(0); voteNextIndices.push(1); + voteCurrentPayloadStatus.push(PayloadStatus.FULL); + voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = balance; newBalances[i] = balance; } @@ -130,10 +155,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(validatorCount); @@ -159,6 +187,8 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; + const voteCurrentPayloadStatus = []; + const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -166,6 +196,8 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(0); voteNextIndices.push(1); + voteCurrentPayloadStatus.push(PayloadStatus.FULL); + voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = oldBalance; newBalances[i] = newBalance; } @@ -173,10 +205,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(validatorCount); @@ -203,6 +238,8 @@ describe("computeDeltas", () => { // Both validators move votes from block1 to block2 const voteCurrentIndices = Array.from({length: 2}, () => 0); const voteNextIndices = Array.from({length: 2}, () => 1); + const voteCurrentPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); + const voteNextPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); // There is only one validator in the old balances. const oldBalances = getEffectiveBalanceIncrementsZeroed(1); @@ -215,10 +252,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(2); @@ -242,6 +282,8 @@ describe("computeDeltas", () => { // Both validators move votes from block1 to block2 const voteCurrentIndices = Array.from({length: 2}, () => 0); const voteNextIndices = Array.from({length: 2}, () => 1); + const voteCurrentPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); + const voteNextPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); // There are two validators in the old balances. const oldBalances = getEffectiveBalanceIncrementsZeroed(2); oldBalances[0] = balance; @@ -253,10 +295,13 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, oldBalances, newBalances, - new Set() + new Set(), + new Map() ); expect(deltas.length).toEqual(2); @@ -281,6 +326,8 @@ describe("computeDeltas", () => { // Both validators move votes from block1 to block2 const voteCurrentIndices = Array.from({length: 2}, () => 0); const voteNextIndices = Array.from({length: 2}, () => 1); + const voteCurrentPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); + const voteNextPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); const balances = new Uint16Array([firstBalance, secondBalance]); // 1st validator is part of an attester slashing @@ -288,10 +335,13 @@ describe("computeDeltas", () => { let {deltas} = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, balances, balances, - equivocatingIndices + equivocatingIndices, + new Map() ); expect(deltas[0]).toBeWithMessage( -1 * (firstBalance + secondBalance), @@ -301,10 +351,13 @@ describe("computeDeltas", () => { deltas = computeDeltas( indices.size, voteCurrentIndices, + voteCurrentPayloadStatus, voteNextIndices, + voteNextPayloadStatus, balances, balances, - equivocatingIndices + equivocatingIndices, + new Map() ).deltas; expect(deltas).toEqualWithMessage([0, 0], "calling computeDeltas again should not have any affect on the weight"); }); diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index e74236bd5a08..dbc0cfa6b819 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -290,7 +290,9 @@ describe("executionStatus / invalidate all postmerge chain", () => { const fcHead = fc.findHead("0", 3); it("pre merge block should be the FC head", () => { - expect(fcHead).toBe("0"); + // findHead returns compound key "root:payloadStatus" + // For pre-Gloas blocks, this should be "0:2" (PAYLOAD_STATUS_FULL) + expect(fcHead).toBe("0:2"); }); }); diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts new file mode 100644 index 000000000000..d3f5eb2fa83b --- /dev/null +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -0,0 +1,674 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {PTC_SIZE} from "@lodestar/params"; +import {DataAvailabilityStatus, computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; +import { + ExecutionStatus, + PayloadStatus, + ProtoArray, + ProtoBlock, + ProtoNode, + generateProtoNodeKey, + protoNodeKey, +} from "../../../src/index.js"; + +describe("Gloas Fork Choice", () => { + const genesisEpoch = 0; + const gloasForkEpoch = 5; + const gloasForkSlot = computeStartSlotAtEpoch(gloasForkEpoch); + + const stateRoot = "0x00"; + const genesisRoot = "0x01"; + + /** + * Helper to get a specific node variant (PENDING/EMPTY/FULL) from ProtoArray + * Replacement for removed getForkChoiceNode() method + */ + function getNodeByPayloadStatus( + protoArray: ProtoArray, + blockRoot: RootHex, + payloadStatus: PayloadStatus + ): ProtoNode | undefined { + const key = protoNodeKey({blockRoot, payloadStatus} as any); + const index = (protoArray as any).indices.get(key); + if (index === undefined) return undefined; + return (protoArray as any).nodes[index]; + } + + function createTestBlock( + slot: number, + blockRoot: RootHex, + parentRoot: RootHex, + parentBlockHash?: RootHex + ): ProtoBlock { + return { + slot, + blockRoot, + parentRoot, + stateRoot, + targetRoot: genesisRoot, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + unrealizedJustifiedEpoch: genesisEpoch, + unrealizedJustifiedRoot: genesisRoot, + unrealizedFinalizedEpoch: genesisEpoch, + unrealizedFinalizedRoot: genesisRoot, + timeliness: true, + executionPayloadBlockHash: blockRoot, // Use blockRoot as execution hash + executionPayloadNumber: slot, + executionStatus: ExecutionStatus.Valid, + dataAvailabilityStatus: DataAvailabilityStatus.Available, + parentBlockHash, // For Gloas blocks + }; + } + + describe("ForkChoiceNode helpers", () => { + it("protoNodeKey() creates correct compound key", () => { + const key = protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.FULL} as any); + expect(key).toBe("0xabc:2"); + }); + + it("protoNodeKey() handles all payload statuses", () => { + expect(protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.PENDING} as any)).toBe("0xabc:0"); + expect(protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.EMPTY} as any)).toBe("0xabc:1"); + expect(protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.FULL} as any)).toBe("0xabc:2"); + }); + + it("generateProtoNodeKey() creates correct compound key", () => { + const key = generateProtoNodeKey("0xabc", PayloadStatus.FULL); + expect(key).toBe("0xabc:2"); + }); + + it("generateProtoNodeKey() handles all payload statuses", () => { + expect(generateProtoNodeKey("0xabc", PayloadStatus.PENDING)).toBe("0xabc:0"); + expect(generateProtoNodeKey("0xabc", PayloadStatus.EMPTY)).toBe("0xabc:1"); + expect(generateProtoNodeKey("0xabc", PayloadStatus.FULL)).toBe("0xabc:2"); + }); + + it("generateProtoNodeKey() and protoNodeKey() produce same output", () => { + const root = "0x123abc"; + + const pendingKey1 = protoNodeKey({blockRoot: root, payloadStatus: PayloadStatus.PENDING} as any); + const pendingKey2 = generateProtoNodeKey(root, PayloadStatus.PENDING); + expect(pendingKey1).toBe(pendingKey2); + + const emptyKey1 = protoNodeKey({blockRoot: root, payloadStatus: PayloadStatus.EMPTY} as any); + const emptyKey2 = generateProtoNodeKey(root, PayloadStatus.EMPTY); + expect(emptyKey1).toBe(emptyKey2); + + const fullKey1 = protoNodeKey({blockRoot: root, payloadStatus: PayloadStatus.FULL} as any); + const fullKey2 = generateProtoNodeKey(root, PayloadStatus.FULL); + expect(fullKey1).toBe(fullKey2); + }); + + it("generateProtoNodeKey() handles different root formats", () => { + // Short hex + expect(generateProtoNodeKey("0x1", PayloadStatus.PENDING)).toBe("0x1:0"); + + // Long hex (64 chars) + const longRoot = "0x" + "a".repeat(64); + expect(generateProtoNodeKey(longRoot, PayloadStatus.FULL)).toBe(`${longRoot}:2`); + + // Empty root edge case + expect(generateProtoNodeKey("0x", PayloadStatus.EMPTY)).toBe("0x:1"); + }); + }); + + describe("Pre-Gloas (Fulu) behavior", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + // Initialize with GLOAS_FORK_EPOCH = Infinity (never activate Gloas) + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: Infinity}, + }); + }); + + it("creates only FULL nodes for pre-Gloas blocks", () => { + const block = createTestBlock(1, "0x02", genesisRoot); + protoArray.onBlock(block, 1); + + // Should only have FULL variant + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + expect(fullNode).toBeDefined(); + expect(fullNode?.payloadStatus).toBe(PayloadStatus.FULL); + + // Should not have PENDING or EMPTY variants + const pendingNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING); + expect(pendingNode).toBeUndefined(); + + const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + expect(emptyNode).toBeUndefined(); + }); + + it("getNode() finds pre-Gloas blocks by root (FULL)", () => { + const block = createTestBlock(1, "0x02", genesisRoot); + protoArray.onBlock(block, 1); + + const node = protoArray.getNode("0x02"); + expect(node).toBeDefined(); + expect(node?.payloadStatus).toBe(PayloadStatus.FULL); + }); + + it("hasBlock() returns true for pre-Gloas blocks", () => { + const block = createTestBlock(1, "0x02", genesisRoot); + protoArray.onBlock(block, 1); + + expect(protoArray.hasBlock("0x02")).toBe(true); + expect(protoArray.hasBlock("0x99")).toBe(false); + }); + }); + + describe("Gloas fork activation", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, + }); + }); + + it("creates PENDING + EMPTY nodes for Gloas blocks", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Should have PENDING variant + const pendingNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING); + expect(pendingNode).toBeDefined(); + expect(pendingNode?.payloadStatus).toBe(PayloadStatus.PENDING); + + // Should have EMPTY variant + const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + expect(emptyNode).toBeDefined(); + expect(emptyNode?.payloadStatus).toBe(PayloadStatus.EMPTY); + + // Should not have FULL variant yet + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + expect(fullNode).toBeUndefined(); + }); + + it("EMPTY node has PENDING as parent", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + const pendingIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + + expect(emptyNode?.parent).toBe(pendingIndex); + }); + + it("initializes PTC votes for Gloas blocks", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // All PTC votes should be false initially + const isTimely = protoArray.isPayloadTimely("0x02"); + expect(isTimely).toBe(false); + }); + + it("does not create PENDING/EMPTY for pre-fork blocks", () => { + const block = createTestBlock(gloasForkSlot - 1, "0x02", genesisRoot); + protoArray.onBlock(block, gloasForkSlot - 1); + + // Should only have FULL (pre-Gloas behavior) + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + expect(fullNode).toBeDefined(); + + const pendingNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING); + expect(pendingNode).toBeUndefined(); + }); + }); + + describe("Fork transition (Fulu → Gloas)", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, + }); + }); + + it("first Gloas block points to FULL parent (Fulu block)", () => { + // Add pre-Gloas block + const fuluBlock = createTestBlock(gloasForkSlot - 1, "0x02", genesisRoot); + protoArray.onBlock(fuluBlock, gloasForkSlot - 1); + + // Add first Gloas block + const gloasBlock = createTestBlock(gloasForkSlot, "0x03", "0x02", "0x02"); + protoArray.onBlock(gloasBlock, gloasForkSlot); + + const gloasPendingNode = getNodeByPayloadStatus(protoArray, "0x03", PayloadStatus.PENDING); + const fuluFullIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.FULL} as any); + + // First Gloas block's PENDING should point to parent's FULL + expect(gloasPendingNode?.parent).toBe(fuluFullIndex); + }); + + it("getNode() finds blocks across fork transition", () => { + // Add pre-Gloas block + const fuluBlock = createTestBlock(gloasForkSlot - 1, "0x02", genesisRoot); + protoArray.onBlock(fuluBlock, gloasForkSlot - 1); + + // Add Gloas block + const gloasBlock = createTestBlock(gloasForkSlot, "0x03", "0x02", "0x02"); + protoArray.onBlock(gloasBlock, gloasForkSlot); + + // Should find both blocks + const fuluNode = protoArray.getNode("0x02"); + expect(fuluNode?.payloadStatus).toBe(PayloadStatus.FULL); + + const gloasNode = protoArray.getNode("0x03"); + expect(gloasNode?.payloadStatus).toBe(PayloadStatus.PENDING); + }); + }); + + describe("onExecutionPayload()", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, + }); + }); + + it("creates FULL variant when payload arrives", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // FULL should not exist yet + expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeUndefined(); + + // Call onExecutionPayload + protoArray.onExecutionPayload("0x02", gloasForkSlot); + + // FULL should now exist + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + expect(fullNode).toBeDefined(); + expect(fullNode?.payloadStatus).toBe(PayloadStatus.FULL); + }); + + it("FULL node has PENDING as parent", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + protoArray.onExecutionPayload("0x02", gloasForkSlot); + + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + const pendingIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + + expect(fullNode?.parent).toBe(pendingIndex); + }); + + it("is idempotent (calling twice does not create duplicate)", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot); + + // Should still only have one FULL node + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + expect(fullNode).toBeDefined(); + }); + + it("does nothing for pre-Gloas blocks", () => { + const block = createTestBlock(gloasForkSlot - 1, "0x02", genesisRoot); + protoArray.onBlock(block, gloasForkSlot - 1); + + // Pre-Gloas block already has FULL + expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); + + // Calling onExecutionPayload should be no-op + protoArray.onExecutionPayload("0x02", gloasForkSlot - 1); + + // Still just one FULL node + expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); + }); + + it("throws for unknown block", () => { + expect(() => protoArray.onExecutionPayload("0x99", gloasForkSlot)).toThrow(); + }); + }); + + describe("PTC (Payload Timeliness Committee)", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, + }); + }); + + it("notifyPtcMessage() updates votes for multiple validators", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Initially not timely (no votes) + expect(protoArray.isPayloadTimely("0x02")).toBe(false); + + // Vote yes from validators at indices 0, 1, 2 + protoArray.notifyPtcMessage("0x02", [0, 1, 2], true); + + // Still not timely (need >50% of PTC_SIZE) + expect(protoArray.isPayloadTimely("0x02")).toBe(false); + }); + + it("notifyPtcMessage() validates ptcIndex range", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + expect(() => protoArray.notifyPtcMessage("0x02", [-1], true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessage("0x02", [PTC_SIZE], true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessage("0x02", [PTC_SIZE + 1], true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessage("0x02", [0, 1, PTC_SIZE], true)).toThrow(/Invalid PTC index/); + }); + + it("notifyPtcMessage() handles unknown block gracefully", () => { + // Should not throw for unknown block + expect(() => protoArray.notifyPtcMessage("0x99", [0], true)).not.toThrow(); + }); + + it("isPayloadTimely() returns false when payload not locally available", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Vote yes from majority of PTC + const threshold = Math.floor(PTC_SIZE / 2) + 1; + const indices = Array.from({length: threshold}, (_, i) => i); + protoArray.notifyPtcMessage("0x02", indices, true); + + // Without executionPayloadStates, should return false + expect(protoArray.isPayloadTimely("0x02")).toBe(false); + + // With empty map, should return false + expect(protoArray.isPayloadTimely("0x02", new Map())).toBe(false); + }); + + it("isPayloadTimely() returns true when threshold met and payload available", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Create execution payload states map + const executionPayloadStates = new Map(); + executionPayloadStates.set("0x02", {}); + + // Vote yes from majority of PTC (>50%) + const threshold = Math.floor(PTC_SIZE / 2) + 1; + const indices = Array.from({length: threshold}, (_, i) => i); + protoArray.notifyPtcMessage("0x02", indices, true); + + // Should now be timely + expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(true); + }); + + it("isPayloadTimely() returns false when threshold not met", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + const executionPayloadStates = new Map(); + executionPayloadStates.set("0x02", {}); + + // Vote yes from exactly 50% (not >50%) + const threshold = Math.floor(PTC_SIZE / 2); + const indices = Array.from({length: threshold}, (_, i) => i); + protoArray.notifyPtcMessage("0x02", indices, true); + + // Should not be timely (need >50%, not >=50%) + expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(false); + }); + + it("isPayloadTimely() counts only 'true' votes", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + const executionPayloadStates = new Map(); + executionPayloadStates.set("0x02", {}); + + // Vote mixed yes/no + const threshold = Math.floor(PTC_SIZE / 2) + 1; + // Vote yes from indices 0..threshold-1 + const yesIndices = Array.from({length: threshold}, (_, i) => i); + protoArray.notifyPtcMessage("0x02", yesIndices, true); + // Vote no from indices threshold..PTC_SIZE-1 + const noIndices = Array.from({length: PTC_SIZE - threshold}, (_, i) => i + threshold); + protoArray.notifyPtcMessage("0x02", noIndices, false); + + // Should be timely (threshold met) + expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(true); + + // Change some yes votes to no + protoArray.notifyPtcMessage("0x02", [0, 1], false); + + // Should no longer be timely + expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(false); + }); + + it("isPayloadTimely() returns false for unknown block", () => { + expect(protoArray.isPayloadTimely("0x99")).toBe(false); + }); + + it("does not initialize PTC votes for pre-Gloas blocks", () => { + const block = createTestBlock(gloasForkSlot - 1, "0x02", genesisRoot); + protoArray.onBlock(block, gloasForkSlot - 1); + + // Pre-Gloas blocks should not have PTC tracking + expect(protoArray.isPayloadTimely("0x02")).toBe(false); + + // notifyPtcMessage should be no-op + expect(() => protoArray.notifyPtcMessage("0x02", [0], true)).not.toThrow(); + }); + }); + + describe("Parent relationships", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, + }); + }); + + it("intra-block: EMPTY/FULL variants have PENDING as parent", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot); + + const pendingIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + + expect(emptyNode?.parent).toBe(pendingIndex); + expect(fullNode?.parent).toBe(pendingIndex); + }); + + it("inter-block: new PENDING extends parent's EMPTY or FULL", () => { + // Block A + const blockA = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(blockA, gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot); + + // Block B extends A's FULL (parentBlockHash matches) + const blockB = createTestBlock(gloasForkSlot + 1, "0x03", "0x02", "0x02"); + protoArray.onBlock(blockB, gloasForkSlot + 1); + + const blockAPending = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + const blockAFull = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.FULL} as any); + const blockBPending = getNodeByPayloadStatus(protoArray, "0x03", PayloadStatus.PENDING); + + // Block B's PENDING should NOT point to A's PENDING + expect(blockBPending?.parent).not.toBe(blockAPending); + // Block B's PENDING should point to A's FULL (because parentBlockHash matches) + expect(blockBPending?.parent).toBe(blockAFull); + }); + }); + + describe("Explicit EMPTY vs FULL tiebreaker for recent slots", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, + }); + }); + + it("EMPTY vs FULL comparison uses explicit tiebreaker for slot n-1 blocks", () => { + const blockSlot = gloasForkSlot + 10; + const block = createTestBlock(blockSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, blockSlot); + protoArray.onExecutionPayload("0x02", blockSlot); + + const emptyIndex = protoArray.getNodeIndex({ + blockRoot: "0x02", + payloadStatus: PayloadStatus.EMPTY, + } as any)!; + const fullIndex = protoArray.getNodeIndex({ + blockRoot: "0x02", + payloadStatus: PayloadStatus.FULL, + } as any)!; + + // Give EMPTY more weight than FULL + const deltas = new Array(protoArray.length()).fill(0); + deltas[emptyIndex] = 200; + deltas[fullIndex] = 100; + + // Apply at currentSlot = blockSlot + 1 (makes block from slot n-1) + protoArray.applyScoreChanges({ + deltas, + proposerBoost: null, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot: blockSlot + 1, + }); + + const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + + // Both nodes should have accumulated their weights + expect(emptyNode?.weight).toBe(200); + expect(fullNode?.weight).toBe(100); + + // But when comparing for bestChild, the tiebreaker should be used + // (this is implicitly tested by the comparison logic, weights are ignored) + }); + + it("different blocks at slot n-1 still use weight comparison", () => { + const blockSlot = gloasForkSlot + 10; + + const blockA = createTestBlock(blockSlot, "0x02", genesisRoot, genesisRoot); + const blockB = createTestBlock(blockSlot, "0x03", genesisRoot, genesisRoot); + + protoArray.onBlock(blockA, blockSlot); + protoArray.onBlock(blockB, blockSlot); + + const emptyAIndex = protoArray.getNodeIndex({ + blockRoot: "0x02", + payloadStatus: PayloadStatus.EMPTY, + } as any)!; + const emptyBIndex = protoArray.getNodeIndex({ + blockRoot: "0x03", + payloadStatus: PayloadStatus.EMPTY, + } as any)!; + + // Give A more votes than B + const deltas = new Array(protoArray.length()).fill(0); + deltas[emptyAIndex] = 200; + deltas[emptyBIndex] = 100; + + protoArray.applyScoreChanges({ + deltas, + proposerBoost: null, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot: blockSlot + 1, + }); + + const emptyANode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + const emptyBNode = getNodeByPayloadStatus(protoArray, "0x03", PayloadStatus.EMPTY); + + // Different blocks should use weight comparison, not tiebreaker + expect(emptyANode?.weight).toBe(200); + expect(emptyBNode?.weight).toBe(100); + // Block A should be preferred due to higher weight + }); + + it("EMPTY vs FULL from older slots (n-2) uses weight comparison", () => { + const blockSlot = gloasForkSlot + 10; + const block = createTestBlock(blockSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, blockSlot); + protoArray.onExecutionPayload("0x02", blockSlot); + + const emptyIndex = protoArray.getNodeIndex({ + blockRoot: "0x02", + payloadStatus: PayloadStatus.EMPTY, + } as any)!; + const fullIndex = protoArray.getNodeIndex({ + blockRoot: "0x02", + payloadStatus: PayloadStatus.FULL, + } as any)!; + + const deltas = new Array(protoArray.length()).fill(0); + deltas[emptyIndex] = 100; + deltas[fullIndex] = 200; + + // currentSlot = blockSlot + 2, so block is from slot n-2 (not n-1) + protoArray.applyScoreChanges({ + deltas, + proposerBoost: null, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot: blockSlot + 2, + }); + + const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + + // Older blocks use weight comparison, not tiebreaker + expect(emptyNode?.weight).toBe(100); + expect(fullNode?.weight).toBe(200); + // FULL should be preferred due to higher weight + }); + }); +}); From b8bc7b3e3dc259434ddbc2f637cec5b22990310f Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:49:43 -0800 Subject: [PATCH 16/52] Address comment --- packages/fork-choice/src/protoArray/protoArray.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index c2c549e1829f..b2f734d35b95 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -960,7 +960,7 @@ export class ProtoArray { const variants = this.variantIndices.get(finalizedIndex); if (variants) nodesToPrune.push(...variants.values()); - for (let nodeIndex = 0; nodeIndex < finalizedIndex; nodeIndex++) { + for (const nodeIndex of nodesToPrune) { const node = this.nodes[nodeIndex]; if (node === undefined) { throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: nodeIndex}); From 36a213e9dd2336a5f617b8b235f21e1da50a90de Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 13 Jan 2026 07:26:10 -0800 Subject: [PATCH 17/52] check-types --- .../perf/chain/opPools/aggregatedAttestationPool.test.ts | 3 ++- .../fork-choice/test/perf/forkChoice/updateHead.test.ts | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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 612e4876209b..8320f104e043 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -68,7 +68,8 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, }, - originalState.slot + originalState.slot, + {GLOAS_FORK_EPOCH: Infinity} ); for (let slot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); slot < originalState.slot; slot++) { diff --git a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts index 9643d3f2e64b..54f9c9d53070 100644 --- a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts +++ b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts @@ -1,6 +1,5 @@ import {bench, describe} from "@chainsafe/benchmark"; -import {computeEpochAtSlot} from "@lodestar/state-transition"; -import {ForkChoice, ProtoBlock} from "../../../src/index.js"; +import {ForkChoice, PayloadStatus, ProtoBlock} from "../../../src/index.js"; import {Opts, initializeForkChoice} from "./util.js"; describe("forkchoice updateHead", () => { @@ -56,9 +55,8 @@ describe("forkchoice updateHead", () => { }); function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice): void { - const nextEpoch = computeEpochAtSlot(vote.slot); const nextRoot = vote.blockRoot; for (let i = 0; i < forkChoice["balances"].length; i++) { - forkChoice["addLatestMessage"](i, nextEpoch, nextRoot); + forkChoice["addLatestMessage"](i, vote.slot, nextRoot, PayloadStatus.FULL); } } From 7e91801cbcbfeff8c3b5c38b78d598f3b04bfb04 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:43:38 -0800 Subject: [PATCH 18/52] Partially address @twoeths's comment --- .../beacon-node/src/chain/forkChoice/index.ts | 10 ++++++ packages/beacon-node/test/utils/state.ts | 4 ++- .../fork-choice/src/forkChoice/forkChoice.ts | 25 ++++++++------ .../fork-choice/src/protoArray/interface.ts | 15 +++++++-- .../fork-choice/src/protoArray/protoArray.ts | 33 ++++--------------- .../fork-choice/test/perf/forkChoice/util.ts | 15 +++++++-- .../test/unit/forkChoice/forkChoice.test.ts | 7 ++-- .../unit/forkChoice/getProposerHead.test.ts | 20 +++++++++-- .../shouldOverrideForkChoiceUpdate.test.ts | 20 +++++++++-- .../protoArray/executionStatusUpdates.test.ts | 14 ++++++-- .../unit/protoArray/getCommonAncestor.test.ts | 11 +++++-- .../test/unit/protoArray/gloas.test.ts | 12 ++----- .../test/unit/protoArray/protoArray.test.ts | 14 ++++++-- 13 files changed, 132 insertions(+), 68 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index aa6b5b521c03..75600a253f49 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -4,6 +4,7 @@ import { ForkChoice, ForkChoiceStore, JustifiedBalancesGetter, + PayloadStatus, ProtoArray, ProtoBlock, ForkChoiceOpts as RawForkChoiceOpts, @@ -11,6 +12,7 @@ import { import {ZERO_HASH_HEX} from "@lodestar/params"; import { CachedBeaconStateAllForks, + CachedBeaconStateGloas, DataAvailabilityStatus, computeAnchorCheckpoint, computeEpochAtSlot, @@ -103,6 +105,8 @@ export function initializeForkChoiceFromFinalizedState( // production code use ForkChoice constructor directly const forkchoiceConstructor = opts.forkchoiceConstructor ?? ForkChoice; + const isForkPostGloas = (state as CachedBeaconStateGloas).latestBlockHash !== undefined; + return new forkchoiceConstructor( config, @@ -144,6 +148,8 @@ export function initializeForkChoiceFromFinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, + parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, + payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? }, currentSlot, config @@ -199,6 +205,8 @@ export function initializeForkChoiceFromUnfinalizedState( } ); + const isForkPostGloas = (unfinalizedState as CachedBeaconStateGloas).latestBlockHash !== undefined; + // this is the same to the finalized state const headBlock: ProtoBlock = { slot: blockHeader.slot, @@ -226,6 +234,8 @@ export function initializeForkChoiceFromUnfinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, + parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null, + payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? }; const parentSlot = blockHeader.slot - 1; diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index 64ee9d9514f9..829138ac482e 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -2,7 +2,7 @@ import {SecretKey} from "@chainsafe/blst"; import {PubkeyIndexMap} from "@chainsafe/pubkey-index-map"; import {ChainForkConfig, createBeaconConfig} from "@lodestar/config"; import {config as minimalConfig} from "@lodestar/config/default"; -import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {FAR_FUTURE_EPOCH, ForkName, ForkSeq, MAX_EFFECTIVE_BALANCE, SYNC_COMMITTEE_SIZE} from "@lodestar/params"; import { BeaconStateAllForks, @@ -179,4 +179,6 @@ export const zeroProtoBlock: ProtoBlock = { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 316ca0f1a1ee..773312ae9995 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -42,6 +42,7 @@ import { ProtoNode, VoteIndex, generateProtoNodeKey, + isGloasBlock, } from "../protoArray/interface.js"; import {ProtoArray} from "../protoArray/protoArray.js"; import {ForkChoiceError, ForkChoiceErrorCode, InvalidAttestationCode, InvalidBlockCode} from "./errors.js"; @@ -778,7 +779,8 @@ export class ForkChoice implements IForkChoice { // Gloas blocks have signedExecutionPayloadBid with parentBlockHash parentBlockHash: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) - : undefined, + : null, + payloadStatus: isGloasBeaconBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL, }; this.protoArray.onBlock(protoBlock, currentSlot); @@ -833,15 +835,15 @@ export class ForkChoice implements IForkChoice { // - always add weight to PENDING // - if message.slot > block.slot, it also add weights to FULL or EMPTY let payloadStatus: PayloadStatus; - if (computeEpochAtSlot(slot) < this.config.GLOAS_FORK_EPOCH) { - payloadStatus = PayloadStatus.FULL; - } else { - // We need to retrieve block to compare slot - // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-is_supporting_vote - const block = this.getBlockHex(blockRootHex); + // We need to retrieve block to check if it's Gloas and to compare slot + // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-is_supporting_vote + const block = this.getBlockHex(blockRootHex); + + if (block && isGloasBlock(block)) { + // Post-Gloas block: determine FULL/EMPTY/PENDING based on slot and committee index // If slot > block.slot, we can determine FULL or EMPTY. Else always PENDING - if (block && slot > block.slot) { + if (slot > block.slot) { if (attestationData.index === 1) { payloadStatus = PayloadStatus.FULL; } else if (attestationData.index === 0) { @@ -852,6 +854,9 @@ export class ForkChoice implements IForkChoice { } else { payloadStatus = PayloadStatus.PENDING; } + } else { + // Pre-Gloas block or block not found: always FULL + payloadStatus = PayloadStatus.FULL; } if (slot < this.fcStore.currentSlot) { @@ -1535,8 +1540,8 @@ export class ForkChoice implements IForkChoice { // should not happen, attestation is validated before this step // For pre-Gloas blocks: use FULL (payload embedded in block) // For Gloas blocks: use PENDING (all Gloas blocks have PENDING variant) - const lookupStatus = - computeEpochAtSlot(nextSlot) < this.config.GLOAS_FORK_EPOCH ? PayloadStatus.FULL : PayloadStatus.PENDING; + const block = this.getBlockHex(nextRoot); + const lookupStatus = block && isGloasBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL; const key = generateProtoNodeKey(nextRoot, lookupStatus); const nextIndex = this.protoArray.getNodeIndexByKey(key); if (nextIndex === undefined) { diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index ee30a47d69a7..18a86870a7fb 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -53,6 +53,13 @@ export function generateProtoNodeKey(root: RootHex, payloadStatus: PayloadStatus return `${root}:${payloadStatus}`; } +/** + * Check if a block is in the Gloas fork (ePBS enabled) + */ +export function isGloasBlock(block: ProtoBlock): boolean { + return block.parentBlockHash !== null; +} + export type LVHValidResponse = { executionStatus: ExecutionStatus.Valid; latestValidExecHash: RootHex; @@ -124,8 +131,12 @@ export type ProtoBlock = BlockExtraMeta & { * Extracted from: signedExecutionPayloadBid.message.parentBlockHash * Used to determine if this block extends EMPTY or FULL parent variant * Spec: gloas/fork-choice.md#new-get_parent_payload_status + * + * In pre-Gloas forks, this will be null */ - parentBlockHash?: RootHex; + parentBlockHash: RootHex | null; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ + payloadStatus: PayloadStatus; }; /** @@ -136,8 +147,6 @@ export type ProtoBlock = BlockExtraMeta & { */ export type ProtoNode = ProtoBlock & { parent?: number; - /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ - payloadStatus: PayloadStatus; weight: number; bestChild?: number; bestDescendant?: number; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index b2f734d35b95..770b102cfd1c 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -14,6 +14,7 @@ import { ProtoNodeKey, generateProtoNodeKey, generateProtoNodeKey as getProtoNodeKey, + isGloasBlock, protoNodeKey, } from "./interface.js"; @@ -51,13 +52,6 @@ export class ProtoArray { private previousProposerBoost: ProposerBoost | null = null; - /** - * First epoch of Gloas fork (when ePBS activates) - * Blocks with slot >= gloasForkSlot use ePBS (PENDING/EMPTY/FULL variants) - * Blocks with slot < gloasForkSlot use pre-Gloas (PENDING only) - */ - private gloasForkEpoch: Epoch; - /** * PTC (Payload Timeliness Committee) votes per block * Maps block root to boolean array of size PTC_SIZE (from params: 512 mainnet, 2 minimal) @@ -74,35 +68,27 @@ export class ProtoArray { justifiedRoot, finalizedEpoch, finalizedRoot, - config, }: { pruneThreshold: number; justifiedEpoch: Epoch; justifiedRoot: RootHex; finalizedEpoch: Epoch; finalizedRoot: RootHex; - config: {GLOAS_FORK_EPOCH: number}; }) { this.pruneThreshold = pruneThreshold; this.justifiedEpoch = justifiedEpoch; this.justifiedRoot = justifiedRoot; this.finalizedEpoch = finalizedEpoch; this.finalizedRoot = finalizedRoot; - this.gloasForkEpoch = config.GLOAS_FORK_EPOCH; } - static initialize( - block: Omit, - currentSlot: Slot, - config: {GLOAS_FORK_EPOCH: number} - ): ProtoArray { + static initialize(block: Omit, currentSlot: Slot): ProtoArray { const protoArray = new ProtoArray({ pruneThreshold: DEFAULT_PRUNE_THRESHOLD, justifiedEpoch: block.justifiedEpoch, justifiedRoot: block.justifiedRoot, finalizedEpoch: block.finalizedEpoch, finalizedRoot: block.finalizedRoot, - config, }); protoArray.onBlock( { @@ -115,13 +101,6 @@ export class ProtoArray { return protoArray; } - /** - * Check if a block is in the Gloas fork (ePBS enabled) - */ - private isGloasBlock(block: {slot: Slot}): boolean { - return computeEpochAtSlot(block.slot) >= this.gloasForkEpoch; - } - /** * Get node index for a node identifier * Spec: gloas/fork-choice.md (helper for node lookup) @@ -163,13 +142,13 @@ export class ProtoArray { */ private getParentPayloadStatus(block: ProtoBlock): PayloadStatus { // Pre-Gloas blocks have payloads embedded, so parents are always FULL - if (!this.isGloasBlock(block)) { + if (!isGloasBlock(block)) { return PayloadStatus.FULL; } // Gloas block must have parentBlockHash from its SignedExecutionPayloadBid const parentBlockHash = block.parentBlockHash; - if (!parentBlockHash) { + if (parentBlockHash === null) { // If parentBlockHash is not provided, default to FULL // This can only happen in fulu return PayloadStatus.FULL; @@ -333,7 +312,7 @@ export class ProtoArray { }); } - const isGloas = this.isGloasBlock(block); + const isGloas = isGloasBlock(block); if (isGloas) { // Gloas: Create PENDING + EMPTY nodes with correct parent relationships @@ -346,7 +325,7 @@ export class ProtoArray { let key: ProtoNodeKey; const parentNode = this.getNode(block.parentRoot); - if (parentNode && !this.isGloasBlock(parentNode)) { + if (parentNode && !isGloasBlock(parentNode)) { // Fork transition: parent is Fulu, so it only has FULL variant key = getProtoNodeKey(block.parentRoot, PayloadStatus.FULL); } else { diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index e77fad87f772..0dc546742827 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -2,7 +2,14 @@ import {fromHexString} from "@chainsafe/ssz"; import {config} from "@lodestar/config/default"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {computeTotalBalance} from "../../../src/forkChoice/store.js"; -import {ExecutionStatus, ForkChoice, IForkChoiceStore, ProtoArray, ProtoBlock} from "../../../src/index.js"; +import { + ExecutionStatus, + ForkChoice, + IForkChoiceStore, + PayloadStatus, + ProtoArray, + ProtoBlock, +} from "../../../src/index.js"; const genesisSlot = 0; const genesisEpoch = 0; @@ -32,8 +39,7 @@ export function initializeForkChoice(opts: Opts): ForkChoice { executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, } as Omit, - genesisSlot, - {GLOAS_FORK_EPOCH: Infinity} + genesisSlot ); const balances = new Uint16Array(Array.from({length: opts.initialValidatorCount}, () => 32)); @@ -81,6 +87,9 @@ export function initializeForkChoice(opts: Opts): ForkChoice { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; protoArr.onBlock(block, block.slot); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 788352d17cb7..ee164b673c6f 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -10,6 +10,7 @@ import { ExecutionStatus, ForkChoice, IForkChoiceStore, + PayloadStatus, ProtoArray, ProtoBlock, } from "../../../src/index.js"; @@ -42,8 +43,7 @@ describe("Forkchoice", () => { executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, } as Omit, - genesisSlot, - {GLOAS_FORK_EPOCH: Infinity} + genesisSlot ); }); @@ -105,6 +105,9 @@ describe("Forkchoice", () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; }; diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 3580c1fee9c7..69f7aca9dad9 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -6,7 +6,14 @@ import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {Slot} from "@lodestar/types"; import {toHex} from "@lodestar/utils"; import {NotReorgedReason} from "../../../src/forkChoice/interface.js"; -import {ExecutionStatus, ForkChoice, IForkChoiceStore, ProtoArray, ProtoBlock} from "../../../src/index.js"; +import { + ExecutionStatus, + ForkChoice, + IForkChoiceStore, + PayloadStatus, + ProtoArray, + ProtoBlock, +} from "../../../src/index.js"; import {getBlockRoot, getStateRoot} from "../../utils/index.js"; type ProtoBlockWithWeight = ProtoBlock & {weight: number}; // weight of the block itself @@ -42,6 +49,9 @@ describe("Forkchoice / GetProposerHead", () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -67,6 +77,9 @@ describe("Forkchoice / GetProposerHead", () => { weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -91,6 +104,9 @@ describe("Forkchoice / GetProposerHead", () => { timeliness: false, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; const fcStore: IForkChoiceStore = { @@ -211,7 +227,7 @@ describe("Forkchoice / GetProposerHead", () => { ]; beforeEach(() => { - protoArr = ProtoArray.initialize(genesisBlock, genesisSlot, {GLOAS_FORK_EPOCH: Infinity}); + protoArr = ProtoArray.initialize(genesisBlock, genesisSlot); }); for (const { diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index f84d434d1f07..a92602370e5a 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -6,7 +6,14 @@ import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {Slot} from "@lodestar/types"; import {toHex} from "@lodestar/utils"; import {NotReorgedReason} from "../../../src/forkChoice/interface.js"; -import {ExecutionStatus, ForkChoice, IForkChoiceStore, ProtoArray, ProtoBlock} from "../../../src/index.js"; +import { + ExecutionStatus, + ForkChoice, + IForkChoiceStore, + PayloadStatus, + ProtoArray, + ProtoBlock, +} from "../../../src/index.js"; import {getBlockRoot, getStateRoot} from "../../utils/index.js"; type ProtoBlockWithWeight = ProtoBlock & {weight: number}; // weight of the block itself @@ -42,6 +49,9 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -67,6 +77,9 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -91,6 +104,9 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { timeliness: false, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }; const fcStore: IForkChoiceStore = { @@ -178,7 +194,7 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { ]; beforeEach(() => { - protoArr = ProtoArray.initialize(genesisBlock, genesisSlot, {GLOAS_FORK_EPOCH: Infinity}); + protoArr = ProtoArray.initialize(genesisBlock, genesisSlot); }); for (const { diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index dbc0cfa6b819..9e630654264f 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -4,6 +4,7 @@ import { BlockExtraMeta, ExecutionStatus, MaybeValidExecutionStatus, + PayloadStatus, ProtoArray, ProtoBlock, } from "../../../src/index.js"; @@ -74,10 +75,14 @@ function setupForkChoice(): ProtoArray { finalizedEpoch: 0, finalizedRoot: "-", - ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, + dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, } as Omit, - 0, - {GLOAS_FORK_EPOCH: Infinity} + 0 ); for (const block of blocks) { @@ -116,6 +121,9 @@ function setupForkChoice(): ProtoArray { timeliness: false, ...executionData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }, block.slot ); diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index 40d5f6473727..36ce7306332f 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from "vitest"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; -import {ExecutionStatus, ProtoArray} from "../../../src/index.js"; +import {ExecutionStatus, PayloadStatus, ProtoArray} from "../../../src/index.js"; describe("getCommonAncestor", () => { const blocks: {slot: number; root: string; parent: string}[] = [ @@ -45,9 +45,11 @@ describe("getCommonAncestor", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }, - 0, - {GLOAS_FORK_EPOCH: Infinity} + 0 ); for (const block of blocks) { @@ -72,6 +74,9 @@ describe("getCommonAncestor", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }, block.slot ); diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index d3f5eb2fa83b..75bbbb471064 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -60,7 +60,8 @@ describe("Gloas Fork Choice", () => { executionPayloadNumber: slot, executionStatus: ExecutionStatus.Valid, dataAvailabilityStatus: DataAvailabilityStatus.Available, - parentBlockHash, // For Gloas blocks + parentBlockHash: parentBlockHash === undefined ? null : parentBlockHash, + payloadStatus: PayloadStatus.FULL, }; } @@ -120,14 +121,13 @@ describe("Gloas Fork Choice", () => { let protoArray: ProtoArray; beforeEach(() => { - // Initialize with GLOAS_FORK_EPOCH = Infinity (never activate Gloas) + // Test pre-Gloas behavior by creating blocks with parentBlockHash: null protoArray = new ProtoArray({ pruneThreshold: 0, justifiedEpoch: genesisEpoch, justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: Infinity}, }); }); @@ -176,7 +176,6 @@ describe("Gloas Fork Choice", () => { justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, }); }); @@ -241,7 +240,6 @@ describe("Gloas Fork Choice", () => { justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, }); }); @@ -289,7 +287,6 @@ describe("Gloas Fork Choice", () => { justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, }); }); @@ -362,7 +359,6 @@ describe("Gloas Fork Choice", () => { justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, }); }); @@ -496,7 +492,6 @@ describe("Gloas Fork Choice", () => { justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, }); }); @@ -544,7 +539,6 @@ describe("Gloas Fork Choice", () => { justifiedRoot: genesisRoot, finalizedEpoch: genesisEpoch, finalizedRoot: genesisRoot, - config: {GLOAS_FORK_EPOCH: gloasForkEpoch}, }); }); diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index 4a77939e74ab..4113b6ae7f84 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it} from "vitest"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; -import {ExecutionStatus, ProtoArray} from "../../../src/index.js"; +import {ExecutionStatus, PayloadStatus, ProtoArray} from "../../../src/index.js"; describe("ProtoArray", () => { it("finalized descendant", () => { @@ -34,9 +34,11 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }, - genesisSlot, - {GLOAS_FORK_EPOCH: Infinity} + genesisSlot ); // Add block that is a finalized descendant. @@ -61,6 +63,9 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }, genesisSlot + 1 ); @@ -87,6 +92,9 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, }, genesisSlot + 1 ); From c6aa6410401df0f00168fb3926a84f34fe00cfd7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:57:05 -0800 Subject: [PATCH 19/52] fix build --- packages/beacon-node/src/chain/forkChoice/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 75600a253f49..2062dbfbe8ab 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -151,8 +151,7 @@ export function initializeForkChoiceFromFinalizedState( parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? }, - currentSlot, - config + currentSlot ), state.validators.length, metrics, @@ -276,7 +275,7 @@ export function initializeForkChoiceFromUnfinalizedState( targetRoot: toRootHex(finalizedCheckpoint.root), }; - const protoArray = ProtoArray.initialize(finalizedBlock, currentSlot, config); + const protoArray = ProtoArray.initialize(finalizedBlock, currentSlot); protoArray.onBlock(justifiedBlock, currentSlot); protoArray.onBlock(parentBlock, currentSlot); protoArray.onBlock(headBlock, currentSlot); From 56e5c247cb43ea1462261028792ec62417400230 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:08:44 -0800 Subject: [PATCH 20/52] check-types --- .../perf/chain/opPools/aggregatedAttestationPool.test.ts | 9 +++++++-- .../beacon-node/test/utils/validationData/attestation.ts | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) 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 8320f104e043..890c107c4799 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -67,9 +67,11 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: 2, // PayloadStatus.FULL }, - originalState.slot, - {GLOAS_FORK_EPOCH: Infinity} + originalState.slot ); for (let slot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); slot < originalState.slot; slot++) { @@ -93,6 +95,9 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: 2, // PayloadStatus.FULL }, slot ); diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 798303e62887..bce6a0f4bdaf 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -76,8 +76,12 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { timeliness: false, - ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: 2, // PayloadStatus.FULL }; const shufflingCache = new ShufflingCache(null, null, {}, [ From 0bc18eb8673a7da1b016d67711642cb0e5d63572 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:54:42 -0800 Subject: [PATCH 21/52] Partial address @twoeths's comments --- .../fork-choice/src/forkChoice/forkChoice.ts | 52 ++----- packages/fork-choice/src/index.ts | 3 +- .../src/protoArray/computeDeltas.ts | 46 ++---- .../fork-choice/src/protoArray/interface.ts | 19 --- .../fork-choice/src/protoArray/protoArray.ts | 134 +++++++++--------- 5 files changed, 94 insertions(+), 160 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 773312ae9995..6c7c480ff5f4 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -41,7 +41,6 @@ import { ProtoBlock, ProtoNode, VoteIndex, - generateProtoNodeKey, isGloasBlock, } from "../protoArray/interface.js"; import {ProtoArray} from "../protoArray/protoArray.js"; @@ -101,12 +100,14 @@ export class ForkChoice implements IForkChoice { * * For Gloas (ePBS), LatestMessage tracks slot instead of epoch and includes payload_present flag. * Spec: gloas/fork-choice.md#modified-latestmessage + * + * IMPORTANT: voteCurrentIndices and voteNextIndices point to the EXACT variant node index. + * The payload status is encoded in the node index itself (different variants have different indices). + * For example, if a validator votes for the EMPTY variant, voteNextIndices[i] points to that specific EMPTY node. */ private readonly voteCurrentIndices: VoteIndex[]; private readonly voteNextIndices: VoteIndex[]; private readonly voteNextSlots: Slot[]; - private readonly voteNextPayloadStatus: PayloadStatus[]; - private readonly voteCurrentPayloadStatus: PayloadStatus[]; /** * Attestations that arrived at the current slot and must be queued for later processing. @@ -163,8 +164,6 @@ export class ForkChoice implements IForkChoice { // when compute deltas, we ignore epoch if voteNextIndex is NULL_VOTE_INDEX anyway this.voteNextSlots = new Array(validatorCount).fill(0); - this.voteNextPayloadStatus = new Array(validatorCount).fill(PayloadStatus.FULL); - this.voteCurrentPayloadStatus = new Array(validatorCount).fill(PayloadStatus.FULL); this.head = this.updateHead(); this.balances = this.fcStore.justified.balances; @@ -480,13 +479,10 @@ export class ForkChoice implements IForkChoice { } = computeDeltas( this.protoArray.nodes.length, this.voteCurrentIndices, - this.voteCurrentPayloadStatus, this.voteNextIndices, - this.voteNextPayloadStatus, oldBalances, newBalances, - this.fcStore.equivocatingIndices, - this.protoArray.variantIndices + this.fcStore.equivocatingIndices ); timer?.(); @@ -526,22 +522,8 @@ export class ForkChoice implements IForkChoice { currentSlot, }); - // findHead returns compound key (root:payloadStatus) for Gloas, or (root:FULL) for pre-Gloas - const headKey = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); - const headIndex = this.protoArray.indices.get(headKey); - if (headIndex === undefined) { - throw new ForkChoiceError({ - code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: headKey, - }); - } - const head = this.protoArray.nodes[headIndex]; - if (head === undefined) { - throw new ForkChoiceError({ - code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: headKey, - }); - } + // findHead returns the ProtoNode representing the head + const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); this.head = head; return this.head; @@ -1141,14 +1123,17 @@ export class ForkChoice implements IForkChoice { *forwardIterateDescendants(blockRoot: RootHex): IterableIterator { const rootsInChain = new Set([blockRoot]); - const blockIndex = this.protoArray.indices.get(blockRoot); - if (blockIndex === undefined) { + const blockVariants = this.protoArray.indices.get(blockRoot); + if (blockVariants === undefined) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot, }); } + // Find the minimum index among all variants to start iteration + const blockIndex = Math.min(...blockVariants.filter((idx) => idx !== undefined)); + for (let i = blockIndex + 1; i < this.protoArray.nodes.length; i++) { const node = this.protoArray.nodes[i]; if (rootsInChain.has(node.parentRoot)) { @@ -1538,22 +1523,16 @@ export class ForkChoice implements IForkChoice { nextPayloadStatus: PayloadStatus ): void { // should not happen, attestation is validated before this step - // For pre-Gloas blocks: use FULL (payload embedded in block) - // For Gloas blocks: use PENDING (all Gloas blocks have PENDING variant) - const block = this.getBlockHex(nextRoot); - const lookupStatus = block && isGloasBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL; - const key = generateProtoNodeKey(nextRoot, lookupStatus); - const nextIndex = this.protoArray.getNodeIndexByKey(key); + // Get the node index for the voted block + const nextIndex = this.protoArray.getNodeIndexByRootAndStatus(nextRoot, nextPayloadStatus); if (nextIndex === undefined) { - throw new Error(`Could not find proto index for nextRoot ${nextRoot}`); + throw new Error(`Could not find proto index for nextRoot ${nextRoot} with payloadStatus ${nextPayloadStatus}`); } // ensure there is no undefined entries in Votes arrays if (this.voteNextSlots.length < validatorIndex + 1) { for (let i = this.voteNextSlots.length; i < validatorIndex + 1; i++) { this.voteNextSlots[i] = INIT_VOTE_SLOT; - this.voteNextPayloadStatus[i] = PayloadStatus.FULL; - this.voteCurrentPayloadStatus[i] = PayloadStatus.FULL; this.voteCurrentIndices[i] = this.voteNextIndices[i] = NULL_VOTE_INDEX; } } @@ -1563,7 +1542,6 @@ export class ForkChoice implements IForkChoice { // nextIndex is transfered to currentIndex in computeDeltas() this.voteNextIndices[validatorIndex] = nextIndex; this.voteNextSlots[validatorIndex] = nextSlot; - this.voteNextPayloadStatus[validatorIndex] = nextPayloadStatus; } // else its an old vote, don't count it } diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index 2e16637882b2..39d42df73a96 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -29,7 +29,6 @@ export type { MaybeValidExecutionStatus, ProtoBlock, ProtoNode, - ProtoNodeKey, } from "./protoArray/interface.js"; -export {ExecutionStatus, PayloadStatus, generateProtoNodeKey, protoNodeKey} from "./protoArray/interface.js"; +export {ExecutionStatus, PayloadStatus} from "./protoArray/interface.js"; export {ProtoArray} from "./protoArray/protoArray.js"; diff --git a/packages/fork-choice/src/protoArray/computeDeltas.ts b/packages/fork-choice/src/protoArray/computeDeltas.ts index 409090de417d..a21c308caa46 100644 --- a/packages/fork-choice/src/protoArray/computeDeltas.ts +++ b/packages/fork-choice/src/protoArray/computeDeltas.ts @@ -1,7 +1,7 @@ import {EffectiveBalanceIncrements} from "@lodestar/state-transition"; import {ValidatorIndex} from "@lodestar/types"; import {ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; -import {NULL_VOTE_INDEX, PayloadStatus, VoteIndex} from "./interface.js"; +import {NULL_VOTE_INDEX, VoteIndex} from "./interface.js"; // reuse arrays to avoid memory reallocation and gc const deltas = new Array(); @@ -30,13 +30,10 @@ export type DeltasResult = { export function computeDeltas( numProtoNodes: number, voteCurrentIndices: VoteIndex[], - voteCurrentPayloadStatus: PayloadStatus[], voteNextIndices: VoteIndex[], - voteNextPayloadStatus: PayloadStatus[], oldBalances: EffectiveBalanceIncrements, newBalances: EffectiveBalanceIncrements, - equivocatingIndices: Set, - variantIndices: Map> + equivocatingIndices: Set ): DeltasResult { if (voteCurrentIndices.length !== voteNextIndices.length) { throw new Error( @@ -55,10 +52,7 @@ export function computeDeltas( // avoid creating new variables in the loop to potentially reduce GC pressure let oldBalance: number, newBalance: number; let currentIndex: VoteIndex, - nextIndex: VoteIndex, - currentVariantIndex: number | undefined, - nextVariantIndex: number | undefined; - let currentPayloadStatus: PayloadStatus, nextPayloadStatus: PayloadStatus; + nextIndex: VoteIndex; // sort equivocating indices to avoid Set.has() in the loop const equivocatingArray = Array.from(equivocatingIndices).sort((a, b) => a - b); let equivocatingIndex = 0; @@ -74,13 +68,6 @@ export function computeDeltas( currentIndex = voteCurrentIndices[vIndex]; nextIndex = voteNextIndices[vIndex]; - currentPayloadStatus = voteCurrentPayloadStatus[vIndex]; - nextPayloadStatus = voteNextPayloadStatus[vIndex]; - - // If status is pending, current or next variant index is undefined because variantIndices only tracks EMPTY and FULL - currentVariantIndex = variantIndices.get(currentIndex)?.get(currentPayloadStatus); - nextVariantIndex = variantIndices.get(nextIndex)?.get(nextPayloadStatus); - // There is no need to create a score change if the validator has never voted or both of their // votes are for the zero hash (genesis block) if (currentIndex === NULL_VOTE_INDEX && nextIndex === NULL_VOTE_INDEX) { @@ -109,7 +96,6 @@ export function computeDeltas( }); } deltas[currentIndex] -= oldBalance; - if (currentVariantIndex !== undefined) deltas[currentVariantIndex] -= oldBalance; } voteCurrentIndices[vIndex] = NULL_VOTE_INDEX; equivocatingIndex++; @@ -122,15 +108,10 @@ export function computeDeltas( continue; } - const indexChanged = currentIndex !== nextIndex; - const payloadStatusChanged = currentPayloadStatus !== nextPayloadStatus; - const balanceChanged = oldBalance !== newBalance; - - // Pre-gloas: deduct old balance from current index, add new balance to next index - // Post-gloas: deduct old balance from current variant of current index, add new balance to next variant of next index. - // If variant index is undefined, it means the payload status is PENDING, so we update current/next index instead. - // It is possible that index did not change, but payload status changed (e.g., PENDING -> FULL for skipped slot) - if (indexChanged || payloadStatusChanged || balanceChanged) { + // Deduct old balance from current index, add new balance to next index + // currentIndex and nextIndex already point to the correct node variants + // Note: If a validator changes from EMPTY to FULL variant of the same block, indexChanged will be true + if (currentIndex !== nextIndex || oldBalance !== newBalance) { // We ignore the vote if it is not known in `indices . // We assume that it is outside of our tree (ie: pre-finalization) and therefore not interesting if (currentIndex !== NULL_VOTE_INDEX) { @@ -141,11 +122,7 @@ export function computeDeltas( }); } - if (currentVariantIndex !== undefined) { - deltas[currentVariantIndex] -= oldBalance; - } else { - deltas[currentIndex] -= oldBalance; - } + deltas[currentIndex] -= oldBalance; } // We ignore the vote if it is not known in `indices . @@ -158,14 +135,9 @@ export function computeDeltas( }); } - if (nextVariantIndex !== undefined) { - deltas[nextVariantIndex] += newBalance; - } else { - deltas[nextIndex] += newBalance; - } + deltas[nextIndex] += newBalance; } voteCurrentIndices[vIndex] = nextIndex; - voteCurrentPayloadStatus[vIndex] = nextPayloadStatus; newVoteValidators++; } else { unchangedVoteValidators++; diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 18a86870a7fb..8a5867e2613e 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -34,25 +34,6 @@ export enum PayloadStatus { FULL = 2, } -/** - * Unique key for indexing ProtoNodes in the fork choice tree - * Format: "${root}:${payloadStatus}" - * Used to identify specific variants (PENDING/EMPTY/FULL) of a block - */ -export type ProtoNodeKey = string; - -/** - * Helper to convert ProtoNode to a unique key for indexing - * Format: "${blockRoot}:${payloadStatus}" - */ -export function protoNodeKey(node: ProtoNode): ProtoNodeKey { - return `${node.blockRoot}:${node.payloadStatus}`; -} - -export function generateProtoNodeKey(root: RootHex, payloadStatus: PayloadStatus): ProtoNodeKey { - return `${root}:${payloadStatus}`; -} - /** * Check if a block is in the Gloas fork (ePBS enabled) */ diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 770b102cfd1c..62f4ed7a30bf 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,7 +1,7 @@ import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params"; import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot} from "@lodestar/types"; -import {MapDef, toRootHex} from "@lodestar/utils"; +import {toRootHex} from "@lodestar/utils"; import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js"; import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; import { @@ -11,11 +11,7 @@ import { PayloadStatus, ProtoBlock, ProtoNode, - ProtoNodeKey, - generateProtoNodeKey, - generateProtoNodeKey as getProtoNodeKey, isGloasBlock, - protoNodeKey, } from "./interface.js"; /** @@ -39,15 +35,21 @@ export class ProtoArray { finalizedRoot: RootHex; nodes: ProtoNode[] = []; /** - * Maps ForkChoiceNode key (root:payloadStatus) to node index + * Maps block root to array of node indices for each payload status variant * - * Unified approach for both Fulu and Gloas: - * - Fulu (pre-Gloas): All nodes use payloadStatus = PAYLOAD_STATUS_FULL (payload embedded) - * - Gloas: Nodes can be PENDING/EMPTY/FULL based on payload availability + * Array structure: [PENDING, EMPTY, FULL] where indices correspond to PayloadStatus enum values + * - number[0] = PENDING variant index (PayloadStatus.PENDING = 0) + * - number[1] = EMPTY variant index (PayloadStatus.EMPTY = 1) + * - number[2] = FULL variant index (PayloadStatus.FULL = 2) + * + * Pre-Gloas: array length is 1, number[0] contains FULL index (for backward compatibility) + * Post-Gloas: array length is 2 or 3 + * - Length 2: [PENDING_INDEX, EMPTY_INDEX] when payload hasn't arrived yet + * - Length 3: [PENDING_INDEX, EMPTY_INDEX, FULL_INDEX] when payload has arrived + * + * Note: undefined array elements indicate that variant doesn't exist for this block */ - indices = new Map(); - // Given a PENDING index, maps to its EMPTY and FULL variant indices - variantIndices = new MapDef>(() => new Map()); + indices = new Map(); lvhError?: LVHExecError; private previousProposerBoost: ProposerBoost | null = null; @@ -102,32 +104,25 @@ export class ProtoArray { } /** - * Get node index for a node identifier - * Spec: gloas/fork-choice.md (helper for node lookup) - */ - getNodeIndex(node: ProtoNode): number | undefined { - return this.indices.get(protoNodeKey(node)); - } - - /** - * Get node index for a block root - * Pre-Gloas blocks only exist as FULL (payload embedded in block) - * Gloas blocks exist as PENDING/EMPTY/FULL variants + * Get node index for a block root and payload status + * + * For pre-Gloas blocks: always returns the FULL variant (variants[0]) + * For Gloas blocks: returns the specified payload status variant * - * Try FULL first (for pre-Gloas), fallback to PENDING (for Gloas) + * Usage guidelines: + * - Use PayloadStatus.FULL when you need the FULL variant specifically (e.g., pre-Gloas parent lookups) + * - Use PayloadStatus.PENDING when checking if a block exists (any variant) + * - For pre-Gloas: returns the FULL variant (only variant that exists) + * - For Gloas: returns the PENDING variant (always exists if block exists) */ - private getNodeIndexByRoot(root: RootHex): number | undefined { - // Try FULL first (pre-Gloas blocks are FULL) - const fullIndex = this.getNodeIndexByKey(generateProtoNodeKey(root, PayloadStatus.FULL)); - if (fullIndex !== undefined) { - return fullIndex; - } - // Fallback to PENDING (Gloas blocks have PENDING variant) - return this.getNodeIndexByKey(generateProtoNodeKey(root, PayloadStatus.PENDING)); - } - - getNodeIndexByKey(key: ProtoNodeKey): number | undefined { - return this.indices.get(key); + getNodeIndexByRootAndStatus(root: RootHex, payloadStatus: PayloadStatus): number | undefined { + const variants = this.indices.get(root); + if (!variants) { + return undefined; + } + // For pre-Gloas, variants[0] contains FULL index + // For post-Gloas, variants[payloadStatus] contains the index for that status + return variants.length === 1 ? variants[0] : variants[payloadStatus]; } /** @@ -319,21 +314,19 @@ export class ProtoArray { // Parent of new PENDING node = parent block's EMPTY or FULL (inter-block edge) // Parent of new EMPTY node = own PENDING node (intra-block edge) - // For fork transition: if parent is Fulu (pre-Gloas), point to parent's FULL + // For fork transition: if parent is pre-Gloas, point to parent's FULL // Otherwise, determine which parent payload status this block extends let parentIndex: number | undefined; - let key: ProtoNodeKey; const parentNode = this.getNode(block.parentRoot); if (parentNode && !isGloasBlock(parentNode)) { - // Fork transition: parent is Fulu, so it only has FULL variant - key = getProtoNodeKey(block.parentRoot, PayloadStatus.FULL); + // Fork transition: parent is pre-Gloas, so it only has FULL variant + parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL); } else { // Both blocks are Gloas: determine which parent payload status to extend const parentPayloadStatus = this.getParentPayloadStatus(block); - key = getProtoNodeKey(block.parentRoot, parentPayloadStatus); + parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus); } - parentIndex = this.getNodeIndexByKey(key); // Create PENDING node const pendingNode: ProtoNode = { @@ -346,8 +339,6 @@ export class ProtoArray { }; const pendingIndex = this.nodes.length; - const pendingKey = generateProtoNodeKey(block.blockRoot, PayloadStatus.PENDING); - this.indices.set(pendingKey, pendingIndex); this.nodes.push(pendingNode); // Create EMPTY variant as a child of PENDING @@ -361,10 +352,14 @@ export class ProtoArray { }; const emptyIndex = this.nodes.length; - const emptyKey = generateProtoNodeKey(block.blockRoot, PayloadStatus.EMPTY); - this.indices.set(emptyKey, emptyIndex); this.nodes.push(emptyNode); - this.variantIndices.getOrDefault(pendingIndex).set(PayloadStatus.EMPTY, emptyIndex); + + // Store both variants in the indices array + // [PENDING, EMPTY, undefined] - FULL will be added later if payload arrives + const variants: number[] = []; + variants[PayloadStatus.PENDING] = pendingIndex; + variants[PayloadStatus.EMPTY] = emptyIndex; + this.indices.set(block.blockRoot, variants); // Update bestChild pointers if (parentIndex !== undefined) { @@ -382,10 +377,10 @@ export class ProtoArray { // Spec: gloas/fork-choice.md#modified-on_block (line 645) this.ptcVote.set(block.blockRoot, new Array(PTC_SIZE).fill(false)); } else { - // Pre-Gloas (Fulu): Only create FULL node (payload embedded in block) + // Pre-Gloas: Only create FULL node (payload embedded in block) const node: ProtoNode = { ...block, - parent: this.getNodeIndexByRoot(block.parentRoot), + parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL), payloadStatus: PayloadStatus.FULL, weight: 0, bestChild: undefined, @@ -393,10 +388,12 @@ export class ProtoArray { }; const nodeIndex = this.nodes.length; - const nodeKey = getProtoNodeKey(block.blockRoot, PayloadStatus.FULL); - this.indices.set(nodeKey, nodeIndex); this.nodes.push(node); + // Store FULL variant in indices array + // Pre-Gloas: variants[0] contains FULL index + this.indices.set(block.blockRoot, [nodeIndex]); + // If this node is valid, lets propagate the valid status up the chain // and throw error if we counter invalid, as this breaks consensus if (node.parent !== undefined) { @@ -417,21 +414,27 @@ export class ProtoArray { * Spec: gloas/fork-choice.md (on_execution_payload event) */ onExecutionPayload(blockRoot: RootHex, currentSlot: Slot): void { - // First find FULL variant. If it exists, nothing to do. Block is always full pre-fulu - const fullKey = getProtoNodeKey(blockRoot, PayloadStatus.FULL); - const existedFullIndex = this.getNodeIndexByKey(fullKey); - if (existedFullIndex !== undefined) { - const existedFullNode = this.nodes[existedFullIndex]; - if (existedFullNode) { - // Pre-Gloas: execution payloads are part of the block, no separate event - return; - } + // First check if FULL variant already exists + const variants = this.indices.get(blockRoot); + if (!variants) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.UNKNOWN_BLOCK, + root: blockRoot, + }); } - // Get PENDING node for Gloas blocks - const pendingKey = getProtoNodeKey(blockRoot, PayloadStatus.PENDING); - const pendingIndex = this.getNodeIndexByKey(pendingKey); + // Pre-Gloas: variants[0] contains FULL, nothing to do + if (variants.length === 1) { + return; + } + + // Check if FULL already exists for Gloas blocks + if (variants[PayloadStatus.FULL] !== undefined) { + return; + } + // Get PENDING node for Gloas blocks + const pendingIndex = variants[PayloadStatus.PENDING]; if (pendingIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.UNKNOWN_BLOCK, @@ -458,10 +461,11 @@ export class ProtoArray { }; const fullIndex = this.nodes.length; - this.indices.set(fullKey, fullIndex); - this.variantIndices.getOrDefault(pendingIndex).set(PayloadStatus.FULL, fullIndex); this.nodes.push(fullNode); + // Add FULL variant to the indices array + variants[PayloadStatus.FULL] = fullIndex; + // Update bestChild for PENDING node (may now prefer FULL over EMPTY) this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot); } From 3ad24ca1d708d64f3a662b0b30aaa690537875c6 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:56:01 -0800 Subject: [PATCH 22/52] Remove unintended folders --- ai/reference/consensus-specs | 1 - 1 file changed, 1 deletion(-) delete mode 160000 ai/reference/consensus-specs diff --git a/ai/reference/consensus-specs b/ai/reference/consensus-specs deleted file mode 160000 index ef9ebec97f6d..000000000000 --- a/ai/reference/consensus-specs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ef9ebec97f6deb7ad586dfbb6eb417295c6853f1 From 6ae6ada46a672c6f15ce0e8f5ea372c394ea51cf Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:10:35 -0800 Subject: [PATCH 23/52] Partial address @twoeths's comments --- .../fork-choice/src/protoArray/protoArray.ts | 148 ++++++++---------- 1 file changed, 66 insertions(+), 82 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 62f4ed7a30bf..9751ef5b9e29 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -133,7 +133,7 @@ export class ProtoArray { * - Match → child extends FULL parent (parent has payload) * - No match → child extends EMPTY parent (parent has no payload) * - * For pre-Gloas blocks: always returns FULL (payloads embedded in block) + * For pre-Gloas blocks: always returns FULL */ private getParentPayloadStatus(block: ProtoBlock): PayloadStatus { // Pre-Gloas blocks have payloads embedded, so parents are always FULL @@ -144,23 +144,25 @@ export class ProtoArray { // Gloas block must have parentBlockHash from its SignedExecutionPayloadBid const parentBlockHash = block.parentBlockHash; if (parentBlockHash === null) { - // If parentBlockHash is not provided, default to FULL - // This can only happen in fulu + // should not happen for Gloas blocks return PayloadStatus.FULL; } // Get parent node to compare execution payload hash - const parentNode = this.getNode(block.parentRoot); - if (!parentNode) { - // Parent not found, default to EMPTY + // Use variants[0] which works for both pre-Gloas (FULL) and Gloas (PENDING) + const parentVariants = this.indices.get(block.parentRoot); + if (!parentVariants) { + // Parent not found // TODO GLOAS: verify this return PayloadStatus.EMPTY; } + const parentIndex = parentVariants[0]; + const parentExecutionHash = this.nodes[parentIndex].executionPayloadBlockHash; + // Compare parent_block_hash from child's bid with parent's execution payload hash // Match means child extends FULL variant (parent has payload) // No match means child extends EMPTY variant (parent has no payload) - const parentExecutionHash = parentNode.executionPayloadBlockHash; return parentBlockHash === parentExecutionHash ? PayloadStatus.FULL : PayloadStatus.EMPTY; } @@ -317,16 +319,23 @@ export class ProtoArray { // For fork transition: if parent is pre-Gloas, point to parent's FULL // Otherwise, determine which parent payload status this block extends let parentIndex: number | undefined; - const parentNode = this.getNode(block.parentRoot); - if (parentNode && !isGloasBlock(parentNode)) { - // Fork transition: parent is pre-Gloas, so it only has FULL variant - parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL); - } else { - // Both blocks are Gloas: determine which parent payload status to extend - const parentPayloadStatus = this.getParentPayloadStatus(block); - parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus); + // Check if parent exists by getting variants array + const parentVariants = this.indices.get(block.parentRoot); + if (parentVariants) { + const anyParentIndex = parentVariants[0]; + const anyParentNode = this.nodes[anyParentIndex]; + + if (!isGloasBlock(anyParentNode)) { + // Fork transition: parent is pre-Gloas, so it only has FULL variant + parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL); + } else { + // Both blocks are Gloas: determine which parent payload status to extend + const parentPayloadStatus = this.getParentPayloadStatus(block); + parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus); + } } + // else: parent doesn't exist, parentIndex remains undefined (orphan block) // Create PENDING node const pendingNode: ProtoNode = { @@ -794,8 +803,11 @@ export class ProtoArray { * Get payload status tiebreaker for fork choice comparison * Spec: gloas/fork-choice.md#new-get_payload_status_tiebreaker * - * For Fulu: always returns node.payloadStatus (PENDING) - * For Gloas: implements tiebreaker logic based on should_extend_payload + * For PENDING nodes: always returns 0 + * For EMPTY/FULL variants from slot n-1: implements tiebreaker logic based on should_extend_payload + * For older blocks: returns node.payloadStatus + * + * Note: pre-gloas logic won't reach here. Since it is impossible to have two nodes with same weight and root */ private getPayloadStatusTiebreaker( node: ProtoNode, @@ -803,7 +815,7 @@ export class ProtoArray { proposerBoostRoot: RootHex | null, executionPayloadStates?: Map ): number { - // For Fulu: simple return payload status + // PENDING nodes always return PENDING (no tiebreaker needed) // PENDING=0, EMPTY=1, FULL=2 if (node.payloadStatus === PayloadStatus.PENDING) { return node.payloadStatus; @@ -821,17 +833,17 @@ export class ProtoArray { } // FULL - check should_extend_payload const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot, executionPayloadStates); - return shouldExtend ? 2 : 1; // Return 2 if extending, else 1 to prefer EMPTY + return shouldExtend ? 2 : 0; // Return 2 if extending, else 0 } /** * Follows the best-descendant links to find the best-block (i.e., head-block). * - * Returns the compound key (root:payloadStatus) to identify the exact node variant. + * Returns the ProtoNode representing the head. * For pre-Gloas forks, only FULL variants exist (payload embedded). * For Gloas, may return PENDING/EMPTY/FULL variants. */ - findHead(justifiedRoot: RootHex, currentSlot: Slot): ProtoNodeKey { + findHead(justifiedRoot: RootHex, currentSlot: Slot): ProtoNode { if (this.lvhError) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, @@ -839,7 +851,7 @@ export class ProtoArray { }); } - const justifiedIndex = this.getNodeIndexByRoot(justifiedRoot); + const justifiedIndex = this.getNodeIndexByRootAndStatus(justifiedRoot, PayloadStatus.PENDING); if (justifiedIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN, @@ -910,51 +922,38 @@ export class ProtoArray { * - There is some internal error relating to invalid indices inside `this`. */ maybePrune(finalizedRoot: RootHex): ProtoBlock[] { - const finalizedPendingKey = getProtoNodeKey(finalizedRoot, PayloadStatus.PENDING); - const finalizedFullKey = getProtoNodeKey(finalizedRoot, PayloadStatus.FULL); - - const finalizedPendingIndex = this.getNodeIndexByKey(finalizedPendingKey); - const finalizedFullIndex = this.getNodeIndexByKey(finalizedFullKey); - - // If finalizedRoot exists in pre-gloas, FULL will be defined and PENDING undefined - // If finalizedRoot exists in gloas, PENDING will be defined and FULL may or may not be defined - if (finalizedPendingIndex === undefined && finalizedFullIndex === undefined) { + const variants = this.indices.get(finalizedRoot); + if (!variants) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN, root: finalizedRoot, }); } - // We take the minimum index of the two variants to ensure we don't prune too much - const finalizedIndex = Math.min( - finalizedPendingIndex !== undefined ? finalizedPendingIndex : Number.MAX_SAFE_INTEGER, - finalizedFullIndex !== undefined ? finalizedFullIndex : Number.MAX_SAFE_INTEGER - ); + // Find the minimum index among all variants to ensure we don't prune too much + const finalizedIndex = Math.min(...variants.filter((idx) => idx !== undefined)); if (finalizedIndex < this.pruneThreshold) { // Pruning at small numbers incurs more cost than benefit return []; } - // Remove the indices key/values for all the to-be-deleted nodes - // Also remove PTC votes for pruned blocks (Gloas) - // Also remove variants (EMPTY, FULL) of finalized blocks - const nodesToPrune = Array.from({length: finalizedIndex + 1}, (_, i) => i); - const variants = this.variantIndices.get(finalizedIndex); - if (variants) nodesToPrune.push(...variants.values()); - - for (const nodeIndex of nodesToPrune) { - const node = this.nodes[nodeIndex]; + // Collect all block roots that will be pruned + const prunedRoots = new Set(); + for (let i = 0; i <= finalizedIndex; i++) { + const node = this.nodes[i]; if (node === undefined) { - throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: nodeIndex}); + throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: i}); } - const nodeKey = generateProtoNodeKey(node.blockRoot, node.payloadStatus); - this.indices.delete(nodeKey); - this.variantIndices.delete(nodeIndex); + prunedRoots.add(node.blockRoot); + } + // Remove indices for pruned blocks and PTC votes + for (const root of prunedRoots) { + this.indices.delete(root); // Prune PTC votes for this block to prevent memory leak // Spec: gloas/fork-choice.md (implicit - finalized blocks don't need PTC votes) - this.ptcVote.delete(node.blockRoot); + this.ptcVote.delete(root); } // Store nodes prior to finalization @@ -962,39 +961,25 @@ export class ProtoArray { // Drop all the nodes prior to finalization this.nodes = this.nodes.slice(finalizedIndex); - // Adjust the indices map - const newIndices = new Map(); - for (const [key, value] of this.indices.entries()) { - if (value < finalizedIndex) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INDEX_OVERFLOW, - value: "indices", - }); - } - newIndices.set(key, value - finalizedIndex); - } - this.indices = newIndices; - - // Adjust variantIndices map - const newVariantIndices = new MapDef>(() => new Map()); - for (const [pendingIndex, variants] of this.variantIndices.entries()) { - if (pendingIndex < finalizedIndex) { - // Skip - this PENDING node was pruned - continue; - } - const newPendingIndex = pendingIndex - finalizedIndex; - const newVariants = new Map(); - for (const [status, variantIndex] of variants.entries()) { - if (variantIndex >= finalizedIndex) { - newVariants.set(status, variantIndex - finalizedIndex); + // Adjust the indices map - subtract finalizedIndex from all node indices + const newIndices = new Map(); + for (const [root, variantIndices] of this.indices.entries()) { + const adjustedVariants: number[] = []; + for (let i = 0; i < variantIndices.length; i++) { + const idx = variantIndices[i]; + if (idx !== undefined) { + if (idx < finalizedIndex) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INDEX_OVERFLOW, + value: "indices", + }); + } + adjustedVariants[i] = idx - finalizedIndex; } - // else: variant was pruned, don't include - } - if (newVariants.size > 0) { - newVariantIndices.set(newPendingIndex, newVariants); } + newIndices.set(root, adjustedVariants); } - this.variantIndices = newVariantIndices; + this.indices = newIndices; // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes for (let i = 0, len = this.nodes.length; i < len; i++) { @@ -1556,8 +1541,7 @@ export class ProtoArray { } length(): number { - // Note: this is number of nodes and not number of unique block root - return this.indices.size; + return this.indices.keys.length; } private getNodeFromIndex(index: number): ProtoNode { From 71b6635b2f85461e8da009234c534f761d416a55 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:58:36 -0800 Subject: [PATCH 24/52] Update getAncestor --- .../fork-choice/src/protoArray/protoArray.ts | 114 +++++++++++++----- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 9751ef5b9e29..f7b81e94d403 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -106,23 +106,17 @@ export class ProtoArray { /** * Get node index for a block root and payload status * - * For pre-Gloas blocks: always returns the FULL variant (variants[0]) - * For Gloas blocks: returns the specified payload status variant + * Returns the index for the specified payload status variant, or undefined if not found. * - * Usage guidelines: - * - Use PayloadStatus.FULL when you need the FULL variant specifically (e.g., pre-Gloas parent lookups) - * - Use PayloadStatus.PENDING when checking if a block exists (any variant) - * - For pre-Gloas: returns the FULL variant (only variant that exists) - * - For Gloas: returns the PENDING variant (always exists if block exists) + * Note: For pre-Gloas blocks, variants[0] contains FULL index. + * To access pre-Gloas blocks, use: this.indices.get(root)?.[0] */ getNodeIndexByRootAndStatus(root: RootHex, payloadStatus: PayloadStatus): number | undefined { const variants = this.indices.get(root); if (!variants) { return undefined; } - // For pre-Gloas, variants[0] contains FULL index - // For post-Gloas, variants[payloadStatus] contains the index for that status - return variants.length === 1 ? variants[0] : variants[payloadStatus]; + return variants[payloadStatus]; } /** @@ -327,8 +321,8 @@ export class ProtoArray { const anyParentNode = this.nodes[anyParentIndex]; if (!isGloasBlock(anyParentNode)) { - // Fork transition: parent is pre-Gloas, so it only has FULL variant - parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL); + // Fork transition: parent is pre-Gloas, so it only has FULL variant at variants[0] + parentIndex = anyParentIndex; } else { // Both blocks are Gloas: determine which parent payload status to extend const parentPayloadStatus = this.getParentPayloadStatus(block); @@ -597,6 +591,7 @@ export class ProtoArray { * if invalidate till hash provided. If consensus fails, this will invalidate entire * forkChoice which will throw on any call to findHead */ + // TODO GLOAS: Review usage of this post-gloas validateLatestHash(execResponse: LVHExecResponse, currentSlot: Slot): void { // Look reverse because its highly likely node with latestValidExecHash is towards the // the leaves of the forkchoice @@ -640,7 +635,8 @@ export class ProtoArray { // if its in fcU. // const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse; - const invalidateFromParentIndex = this.getNodeIndexByRoot(invalidateFromParentBlockRoot); + // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas + const invalidateFromParentIndex = this.indices.get(invalidateFromParentBlockRoot)?.[0]; if (invalidateFromParentIndex === undefined) { throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`); } @@ -851,7 +847,8 @@ export class ProtoArray { }); } - const justifiedIndex = this.getNodeIndexByRootAndStatus(justifiedRoot, PayloadStatus.PENDING); + // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas + const justifiedIndex = this.indices.get(justifiedRoot)?.[0]; if (justifiedIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN, @@ -903,7 +900,7 @@ export class ProtoArray { }); } - return protoNodeKey(bestNode); + return bestNode; } /** @@ -1290,37 +1287,85 @@ export class ProtoArray { * Gloas: Returns (root, payloadStatus) based on actual node state */ getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode { - const node = this.getNode(blockRoot); - if (!node) { + // Get any variant to check the block (use variants[0]) + const variants = this.indices.get(blockRoot); + if (!variants) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot, }); } - if (node.slot > ancestorSlot) { - // Search for a slot that is lte the target slot. - // We check for lower slots to account for skip slots. - for (const ancestorNode of this.iterateAncestorNodes(blockRoot)) { - if (ancestorNode.slot <= ancestorSlot) { - return ancestorNode; - } + const blockIndex = variants[0]; + const block = this.nodes[blockIndex]; + + // If block is at or before queried slot, return PENDING variant (or FULL for pre-Gloas) + if (block.slot <= ancestorSlot) { + // For pre-Gloas: only FULL exists at variants[0] + // For Gloas: PENDING is at variants[0] + return block; + } + + // Walk backwards through beacon blocks to find ancestor + // Start with the parent of the current block + let currentBlock = block; + const parentVariants = this.indices.get(currentBlock.parentRoot); + if (!parentVariants) { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, + descendantRoot: blockRoot, + ancestorSlot, + }); + } + + let parentIndex = parentVariants[0]; + let parentBlock = this.nodes[parentIndex]; + + // Walk backwards while parent.slot > ancestorSlot + while (parentBlock.slot > ancestorSlot) { + currentBlock = parentBlock; + + const nextParentVariants = this.indices.get(currentBlock.parentRoot); + if (!nextParentVariants) { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, + descendantRoot: blockRoot, + ancestorSlot, + }); } + + parentIndex = nextParentVariants[0]; + parentBlock = this.nodes[parentIndex]; + } + + // Now parentBlock.slot <= ancestorSlot + // Return the parent with the correct payload status based on currentBlock + if (!isGloasBlock(currentBlock)) { + // Pre-Gloas: return FULL variant (only one that exists) + return parentBlock; + } + + // Gloas: determine which parent variant (EMPTY or FULL) based on parent_block_hash + const parentPayloadStatus = this.getParentPayloadStatus(currentBlock); + const parentVariantIndex = this.getNodeIndexByRootAndStatus(currentBlock.parentRoot, parentPayloadStatus); + + if (parentVariantIndex === undefined) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, descendantRoot: blockRoot, ancestorSlot, }); } - // Root is older or equal than queried slot, thus a skip slot. Return most recent root prior to slot. - return node; + + return this.nodes[parentVariantIndex]; } /** * Iterate from a block root backwards over nodes */ *iterateAncestorNodes(blockRoot: RootHex): IterableIterator { - const startIndex = this.getNodeIndexByRoot(blockRoot); + // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas + const startIndex = this.indices.get(blockRoot)?.[0]; if (startIndex === undefined) { return; } @@ -1350,7 +1395,8 @@ export class ProtoArray { * Get all nodes from a block root backwards */ getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] { - const startIndex = this.getNodeIndexByRoot(blockRoot); + // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas + const startIndex = this.indices.get(blockRoot)?.[0]; if (startIndex === undefined) { return []; } @@ -1379,7 +1425,8 @@ export class ProtoArray { * this is to find non-ancestor nodes of a blockRoot. */ getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] { - const startIndex = this.getNodeIndexByRoot(blockRoot); + // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas + const startIndex = this.indices.get(blockRoot)?.[0]; if (startIndex === undefined) { return []; } @@ -1408,7 +1455,8 @@ export class ProtoArray { * Returns both ancestor and non-ancestor nodes in a single traversal. */ getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} { - const startIndex = this.getNodeIndexByRoot(blockRoot); + // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas + const startIndex = this.indices.get(blockRoot)?.[0]; if (startIndex === undefined) { return {ancestors: [], nonAncestors: []}; } @@ -1443,11 +1491,13 @@ export class ProtoArray { } hasBlock(blockRoot: RootHex): boolean { - return this.getNodeIndexByRoot(blockRoot) !== undefined; + return this.indices.has(blockRoot); } + // Return any ProtoNode for blockRoot. PENDING variant for Gloas, FULL variant for pre-Gloas + // TODO GLOAS: Review usages. getNode(blockRoot: RootHex): ProtoNode | undefined { - const blockIndex = this.getNodeIndexByRoot(blockRoot); + const blockIndex = this.indices.get(blockRoot)?.[0]; if (blockIndex === undefined) { return undefined; } From 8a89f5011f680740cc7f639a1b7e6bcc907632a7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:58:53 -0800 Subject: [PATCH 25/52] Update test --- .../perf/protoArray/computeDeltas.test.ts | 13 +- .../test/unit/forkChoice/forkChoice.test.ts | 5 + .../unit/protoArray/computeDeltas.test.ts | 73 ++------- .../protoArray/executionStatusUpdates.test.ts | 7 +- .../test/unit/protoArray/gloas.test.ts | 154 ++++++++---------- 5 files changed, 91 insertions(+), 161 deletions(-) diff --git a/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts b/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts index 61ccdd3b6fd8..8338bcd979c9 100644 --- a/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts +++ b/packages/fork-choice/test/perf/protoArray/computeDeltas.test.ts @@ -1,7 +1,7 @@ import {beforeAll, bench, describe} from "@chainsafe/benchmark"; import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsZeroed} from "@lodestar/state-transition"; import {computeDeltas} from "../../../src/protoArray/computeDeltas.js"; -import {NULL_VOTE_INDEX, PayloadStatus} from "../../../src/protoArray/interface.js"; +import {NULL_VOTE_INDEX} from "../../../src/protoArray/interface.js"; describe("computeDeltas", () => { let oldBalances: EffectiveBalanceIncrements; @@ -35,8 +35,6 @@ describe("computeDeltas", () => { inainactiveValidatorsPercentage === 0 ? null : Math.floor(1 / inainactiveValidatorsPercentage); const voteCurrentIndices = Array.from({length: numValidator}, () => NULL_VOTE_INDEX); const voteNextIndices = Array.from({length: numValidator}, () => NULL_VOTE_INDEX); - const voteCurrentPayloadStatus = Array.from({length: numValidator}, () => PayloadStatus.FULL); - const voteNextPayloadStatus = Array.from({length: numValidator}, () => PayloadStatus.FULL); bench({ id: `computeDeltas ${numValidator} validators ${inainactiveValidatorsPercentage * 100}% inactive`, beforeEach: () => { @@ -45,19 +43,16 @@ describe("computeDeltas", () => { voteCurrentIndices[i] = Math.floor(numProtoNode / 2); voteNextIndices[i] = Math.floor(numProtoNode / 2) + 1; } - return {voteCurrentIndices, voteCurrentPayloadStatus, voteNextIndices, voteNextPayloadStatus}; + return {voteCurrentIndices, voteNextIndices}; }, - fn: ({voteCurrentIndices, voteCurrentPayloadStatus, voteNextIndices, voteNextPayloadStatus}) => { + fn: ({voteCurrentIndices, voteNextIndices}) => { computeDeltas( numProtoNode, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set([1, 2, 3, 4, 5]), - new Map() + new Set([1, 2, 3, 4, 5]) ); }, }); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index ee164b673c6f..b1940645a480 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -42,6 +42,11 @@ describe("Forkchoice", () => { executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + // Pre-Gloas block fields (required to avoid being treated as Gloas) + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + timeliness: false, } as Omit, genesisSlot ); diff --git a/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts b/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts index e1475170c9fa..c81c826d9d3a 100644 --- a/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/computeDeltas.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it} from "vitest"; import {getEffectiveBalanceIncrementsZeroed} from "@lodestar/state-transition"; import {computeDeltas} from "../../../src/protoArray/computeDeltas.js"; -import {NULL_VOTE_INDEX, PayloadStatus} from "../../../src/protoArray/interface.js"; +import {NULL_VOTE_INDEX} from "../../../src/protoArray/interface.js"; describe("computeDeltas", () => { it("zero hash", () => { @@ -10,8 +10,6 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; - const voteCurrentPayloadStatus = []; - const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -19,8 +17,6 @@ describe("computeDeltas", () => { indices.set(i.toString(), i); voteCurrentIndices.push(0); voteNextIndices.push(0); - voteCurrentPayloadStatus.push(PayloadStatus.FULL); - voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = 0; newBalances[i] = 0; } @@ -28,13 +24,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(validatorCount); @@ -51,8 +44,6 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; - const voteCurrentPayloadStatus = []; - const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -60,8 +51,6 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(NULL_VOTE_INDEX); voteNextIndices.push(0); - voteCurrentPayloadStatus.push(PayloadStatus.FULL); - voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = balance; newBalances[i] = balance; } @@ -69,13 +58,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(validatorCount); @@ -96,8 +82,6 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; - const voteCurrentPayloadStatus = []; - const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -105,8 +89,6 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(NULL_VOTE_INDEX); voteNextIndices.push(i); - voteCurrentPayloadStatus.push(PayloadStatus.FULL); - voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = balance; newBalances[i] = balance; } @@ -114,13 +96,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(validatorCount); @@ -137,8 +116,6 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; - const voteCurrentPayloadStatus = []; - const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -146,8 +123,6 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(0); voteNextIndices.push(1); - voteCurrentPayloadStatus.push(PayloadStatus.FULL); - voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = balance; newBalances[i] = balance; } @@ -155,13 +130,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(validatorCount); @@ -187,8 +159,6 @@ describe("computeDeltas", () => { const indices = new Map(); const voteCurrentIndices = []; const voteNextIndices = []; - const voteCurrentPayloadStatus = []; - const voteNextPayloadStatus = []; const oldBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); const newBalances = getEffectiveBalanceIncrementsZeroed(validatorCount); @@ -196,8 +166,6 @@ describe("computeDeltas", () => { indices.set((i + 1).toString(), i); voteCurrentIndices.push(0); voteNextIndices.push(1); - voteCurrentPayloadStatus.push(PayloadStatus.FULL); - voteNextPayloadStatus.push(PayloadStatus.FULL); oldBalances[i] = oldBalance; newBalances[i] = newBalance; } @@ -205,13 +173,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(validatorCount); @@ -238,8 +203,6 @@ describe("computeDeltas", () => { // Both validators move votes from block1 to block2 const voteCurrentIndices = Array.from({length: 2}, () => 0); const voteNextIndices = Array.from({length: 2}, () => 1); - const voteCurrentPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); - const voteNextPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); // There is only one validator in the old balances. const oldBalances = getEffectiveBalanceIncrementsZeroed(1); @@ -252,13 +215,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(2); @@ -282,8 +242,6 @@ describe("computeDeltas", () => { // Both validators move votes from block1 to block2 const voteCurrentIndices = Array.from({length: 2}, () => 0); const voteNextIndices = Array.from({length: 2}, () => 1); - const voteCurrentPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); - const voteNextPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); // There are two validators in the old balances. const oldBalances = getEffectiveBalanceIncrementsZeroed(2); oldBalances[0] = balance; @@ -295,13 +253,10 @@ describe("computeDeltas", () => { const {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, oldBalances, newBalances, - new Set(), - new Map() + new Set() ); expect(deltas.length).toEqual(2); @@ -326,8 +281,6 @@ describe("computeDeltas", () => { // Both validators move votes from block1 to block2 const voteCurrentIndices = Array.from({length: 2}, () => 0); const voteNextIndices = Array.from({length: 2}, () => 1); - const voteCurrentPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); - const voteNextPayloadStatus = Array.from({length: 2}, () => PayloadStatus.FULL); const balances = new Uint16Array([firstBalance, secondBalance]); // 1st validator is part of an attester slashing @@ -335,13 +288,10 @@ describe("computeDeltas", () => { let {deltas} = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, balances, balances, - equivocatingIndices, - new Map() + equivocatingIndices ); expect(deltas[0]).toBeWithMessage( -1 * (firstBalance + secondBalance), @@ -351,13 +301,10 @@ describe("computeDeltas", () => { deltas = computeDeltas( indices.size, voteCurrentIndices, - voteCurrentPayloadStatus, voteNextIndices, - voteNextPayloadStatus, balances, balances, - equivocatingIndices, - new Map() + equivocatingIndices ).deltas; expect(deltas).toEqualWithMessage([0, 0], "calling computeDeltas again should not have any affect on the weight"); }); diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 9e630654264f..891c0c5be771 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -298,9 +298,10 @@ describe("executionStatus / invalidate all postmerge chain", () => { const fcHead = fc.findHead("0", 3); it("pre merge block should be the FC head", () => { - // findHead returns compound key "root:payloadStatus" - // For pre-Gloas blocks, this should be "0:2" (PAYLOAD_STATUS_FULL) - expect(fcHead).toBe("0:2"); + // findHead returns ProtoNode + // For pre-Gloas blocks, this should have blockRoot "0" and payloadStatus FULL (2) + expect(fcHead.blockRoot).toBe("0"); + expect(fcHead.payloadStatus).toBe(2); // PayloadStatus.FULL }); }); diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index 75bbbb471064..be0fba75d289 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -8,8 +8,6 @@ import { ProtoArray, ProtoBlock, ProtoNode, - generateProtoNodeKey, - protoNodeKey, } from "../../../src/index.js"; describe("Gloas Fork Choice", () => { @@ -29,8 +27,21 @@ describe("Gloas Fork Choice", () => { blockRoot: RootHex, payloadStatus: PayloadStatus ): ProtoNode | undefined { - const key = protoNodeKey({blockRoot, payloadStatus} as any); - const index = (protoArray as any).indices.get(key); + const variants = (protoArray as any).indices.get(blockRoot); + if (!variants) return undefined; + + // For pre-Gloas, variants[0] contains FULL index + if (variants.length === 1) { + // Pre-Gloas block only has FULL variant + // Only return if requested payloadStatus is FULL + if (payloadStatus === PayloadStatus.FULL) { + return (protoArray as any).nodes[variants[0]]; + } + return undefined; + } + + // For post-Gloas, variants[payloadStatus] contains the index for that status + const index = variants[payloadStatus]; if (index === undefined) return undefined; return (protoArray as any).nodes[index]; } @@ -65,55 +76,46 @@ describe("Gloas Fork Choice", () => { }; } - describe("ForkChoiceNode helpers", () => { - it("protoNodeKey() creates correct compound key", () => { - const key = protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.FULL} as any); - expect(key).toBe("0xabc:2"); - }); - - it("protoNodeKey() handles all payload statuses", () => { - expect(protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.PENDING} as any)).toBe("0xabc:0"); - expect(protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.EMPTY} as any)).toBe("0xabc:1"); - expect(protoNodeKey({blockRoot: "0xabc", payloadStatus: PayloadStatus.FULL} as any)).toBe("0xabc:2"); - }); - - it("generateProtoNodeKey() creates correct compound key", () => { - const key = generateProtoNodeKey("0xabc", PayloadStatus.FULL); - expect(key).toBe("0xabc:2"); - }); - - it("generateProtoNodeKey() handles all payload statuses", () => { - expect(generateProtoNodeKey("0xabc", PayloadStatus.PENDING)).toBe("0xabc:0"); - expect(generateProtoNodeKey("0xabc", PayloadStatus.EMPTY)).toBe("0xabc:1"); - expect(generateProtoNodeKey("0xabc", PayloadStatus.FULL)).toBe("0xabc:2"); - }); - - it("generateProtoNodeKey() and protoNodeKey() produce same output", () => { - const root = "0x123abc"; - - const pendingKey1 = protoNodeKey({blockRoot: root, payloadStatus: PayloadStatus.PENDING} as any); - const pendingKey2 = generateProtoNodeKey(root, PayloadStatus.PENDING); - expect(pendingKey1).toBe(pendingKey2); - - const emptyKey1 = protoNodeKey({blockRoot: root, payloadStatus: PayloadStatus.EMPTY} as any); - const emptyKey2 = generateProtoNodeKey(root, PayloadStatus.EMPTY); - expect(emptyKey1).toBe(emptyKey2); - - const fullKey1 = protoNodeKey({blockRoot: root, payloadStatus: PayloadStatus.FULL} as any); - const fullKey2 = generateProtoNodeKey(root, PayloadStatus.FULL); - expect(fullKey1).toBe(fullKey2); + describe("ProtoArray indices lookup", () => { + it("indices map stores variants correctly for pre-Gloas blocks", () => { + const protoArray = ProtoArray.initialize( + createTestBlock(0, genesisRoot, "0x00"), + 0 + ); + const variants = (protoArray as any).indices.get(genesisRoot); + expect(variants).toBeDefined(); + // Pre-Gloas: variants[0] contains FULL index + expect(variants.length).toBe(1); + expect(variants[0]).toBe(0); + }); + + it("getNodeByPayloadStatus() retrieves correct variants", () => { + const protoArray = ProtoArray.initialize( + createTestBlock(0, genesisRoot, "0x00"), + 0 + ); + const node = getNodeByPayloadStatus(protoArray, genesisRoot, PayloadStatus.FULL); + expect(node).toBeDefined(); + expect(node?.blockRoot).toBe(genesisRoot); + expect(node?.payloadStatus).toBe(PayloadStatus.FULL); }); - it("generateProtoNodeKey() handles different root formats", () => { - // Short hex - expect(generateProtoNodeKey("0x1", PayloadStatus.PENDING)).toBe("0x1:0"); + it("indices map stores multiple variants for Gloas blocks", () => { + const protoArray = ProtoArray.initialize( + createTestBlock(0, genesisRoot, "0x00"), + 0 + ); - // Long hex (64 chars) - const longRoot = "0x" + "a".repeat(64); - expect(generateProtoNodeKey(longRoot, PayloadStatus.FULL)).toBe(`${longRoot}:2`); + // Add a Gloas block + const gloasBlock = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(gloasBlock, gloasForkSlot); - // Empty root edge case - expect(generateProtoNodeKey("0x", PayloadStatus.EMPTY)).toBe("0x:1"); + const variants = (protoArray as any).indices.get("0x02"); + expect(variants).toBeDefined(); + // Gloas: variants[PENDING] and variants[EMPTY] should be defined + expect(variants[PayloadStatus.PENDING]).toBeDefined(); + expect(variants[PayloadStatus.EMPTY]).toBeDefined(); + expect(variants[PayloadStatus.FULL]).toBeUndefined(); }); }); @@ -203,7 +205,7 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, gloasForkSlot); const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); - const pendingIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); expect(emptyNode?.parent).toBe(pendingIndex); }); @@ -253,7 +255,7 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(gloasBlock, gloasForkSlot); const gloasPendingNode = getNodeByPayloadStatus(protoArray, "0x03", PayloadStatus.PENDING); - const fuluFullIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.FULL} as any); + const fuluFullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL); // First Gloas block's PENDING should point to parent's FULL expect(gloasPendingNode?.parent).toBe(fuluFullIndex); @@ -313,7 +315,7 @@ describe("Gloas Fork Choice", () => { protoArray.onExecutionPayload("0x02", gloasForkSlot); const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); - const pendingIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); expect(fullNode?.parent).toBe(pendingIndex); }); @@ -500,7 +502,7 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, gloasForkSlot); protoArray.onExecutionPayload("0x02", gloasForkSlot); - const pendingIndex = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); + const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); @@ -518,8 +520,8 @@ describe("Gloas Fork Choice", () => { const blockB = createTestBlock(gloasForkSlot + 1, "0x03", "0x02", "0x02"); protoArray.onBlock(blockB, gloasForkSlot + 1); - const blockAPending = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.PENDING} as any); - const blockAFull = protoArray.getNodeIndex({blockRoot: "0x02", payloadStatus: PayloadStatus.FULL} as any); + const blockAPending = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); + const blockAFull = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL); const blockBPending = getNodeByPayloadStatus(protoArray, "0x03", PayloadStatus.PENDING); // Block B's PENDING should NOT point to A's PENDING @@ -533,13 +535,11 @@ describe("Gloas Fork Choice", () => { let protoArray: ProtoArray; beforeEach(() => { - protoArray = new ProtoArray({ - pruneThreshold: 0, - justifiedEpoch: genesisEpoch, - justifiedRoot: genesisRoot, - finalizedEpoch: genesisEpoch, - finalizedRoot: genesisRoot, - }); + // Initialize with genesis block to avoid INVALID_PARENT_DELTA errors + protoArray = ProtoArray.initialize( + createTestBlock(0, genesisRoot, "0x00"), + 0 + ); }); it("EMPTY vs FULL comparison uses explicit tiebreaker for slot n-1 blocks", () => { @@ -548,14 +548,8 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, blockSlot); protoArray.onExecutionPayload("0x02", blockSlot); - const emptyIndex = protoArray.getNodeIndex({ - blockRoot: "0x02", - payloadStatus: PayloadStatus.EMPTY, - } as any)!; - const fullIndex = protoArray.getNodeIndex({ - blockRoot: "0x02", - payloadStatus: PayloadStatus.FULL, - } as any)!; + const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY)!; + const fullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL)!; // Give EMPTY more weight than FULL const deltas = new Array(protoArray.length()).fill(0); @@ -593,14 +587,8 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(blockA, blockSlot); protoArray.onBlock(blockB, blockSlot); - const emptyAIndex = protoArray.getNodeIndex({ - blockRoot: "0x02", - payloadStatus: PayloadStatus.EMPTY, - } as any)!; - const emptyBIndex = protoArray.getNodeIndex({ - blockRoot: "0x03", - payloadStatus: PayloadStatus.EMPTY, - } as any)!; + const emptyAIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY)!; + const emptyBIndex = protoArray.getNodeIndexByRootAndStatus("0x03", PayloadStatus.EMPTY)!; // Give A more votes than B const deltas = new Array(protoArray.length()).fill(0); @@ -632,14 +620,8 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, blockSlot); protoArray.onExecutionPayload("0x02", blockSlot); - const emptyIndex = protoArray.getNodeIndex({ - blockRoot: "0x02", - payloadStatus: PayloadStatus.EMPTY, - } as any)!; - const fullIndex = protoArray.getNodeIndex({ - blockRoot: "0x02", - payloadStatus: PayloadStatus.FULL, - } as any)!; + const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY)!; + const fullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL)!; const deltas = new Array(protoArray.length()).fill(0); deltas[emptyIndex] = 100; From b865485bf604d51ce70992f5004f6a4f9cfa3147 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:20:41 -0800 Subject: [PATCH 26/52] improve readability --- .../src/protoArray/computeDeltas.ts | 3 +- .../fork-choice/src/protoArray/protoArray.ts | 68 +++++++++++++------ 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/fork-choice/src/protoArray/computeDeltas.ts b/packages/fork-choice/src/protoArray/computeDeltas.ts index a21c308caa46..44db719846b8 100644 --- a/packages/fork-choice/src/protoArray/computeDeltas.ts +++ b/packages/fork-choice/src/protoArray/computeDeltas.ts @@ -51,8 +51,7 @@ export function computeDeltas( // avoid creating new variables in the loop to potentially reduce GC pressure let oldBalance: number, newBalance: number; - let currentIndex: VoteIndex, - nextIndex: VoteIndex; + let currentIndex: VoteIndex, nextIndex: VoteIndex; // sort equivocating indices to avoid Set.has() in the loop const equivocatingArray = Array.from(equivocatingIndices).sort((a, b) => a - b); let equivocatingIndex = 0; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index f7b81e94d403..69693eaa24f0 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -108,15 +108,35 @@ export class ProtoArray { * * Returns the index for the specified payload status variant, or undefined if not found. * - * Note: For pre-Gloas blocks, variants[0] contains FULL index. - * To access pre-Gloas blocks, use: this.indices.get(root)?.[0] + * If payloadStatus not provided: + * - Pre-Gloas blocks: returns FULL variant (canonical) + * - Gloas blocks: returns PENDING variant (canonical) + * + * If payloadStatus provided: + * - Pre-Gloas blocks: only FULL is valid, PENDING/EMPTY throw error + * - Gloas blocks: returns the specified variant */ - getNodeIndexByRootAndStatus(root: RootHex, payloadStatus: PayloadStatus): number | undefined { + getNodeIndexByRootAndStatus(root: RootHex, payloadStatus?: PayloadStatus): number | undefined { const variants = this.indices.get(root); if (!variants) { return undefined; } - return variants[payloadStatus]; + + // Pre-Gloas: only one variant exists (FULL at index 0) + if (variants.length === 1) { + // Return FULL variant if no status specified or FULL explicitly requested + if (payloadStatus === undefined || payloadStatus === PayloadStatus.FULL) { + return variants[0]; + } + // PENDING and EMPTY are invalid for pre-Gloas blocks + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_NODE_INDEX, + index: payloadStatus, + }); + } + + // Gloas: return the specified variant, or PENDING if not specified + return variants[payloadStatus ?? PayloadStatus.PENDING]; } /** @@ -635,8 +655,7 @@ export class ProtoArray { // if its in fcU. // const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse; - // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas - const invalidateFromParentIndex = this.indices.get(invalidateFromParentBlockRoot)?.[0]; + const invalidateFromParentIndex = this.getNodeIndexByRootAndStatus(invalidateFromParentBlockRoot); if (invalidateFromParentIndex === undefined) { throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`); } @@ -799,10 +818,10 @@ export class ProtoArray { * Get payload status tiebreaker for fork choice comparison * Spec: gloas/fork-choice.md#new-get_payload_status_tiebreaker * - * For PENDING nodes: always returns 0 + * For PENDING nodes: always returns 0 * For EMPTY/FULL variants from slot n-1: implements tiebreaker logic based on should_extend_payload * For older blocks: returns node.payloadStatus - * + * * Note: pre-gloas logic won't reach here. Since it is impossible to have two nodes with same weight and root */ private getPayloadStatusTiebreaker( @@ -847,8 +866,8 @@ export class ProtoArray { }); } - // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas - const justifiedIndex = this.indices.get(justifiedRoot)?.[0]; + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const justifiedIndex = this.getNodeIndexByRootAndStatus(justifiedRoot); if (justifiedIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN, @@ -1364,8 +1383,8 @@ export class ProtoArray { * Iterate from a block root backwards over nodes */ *iterateAncestorNodes(blockRoot: RootHex): IterableIterator { - // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas - const startIndex = this.indices.get(blockRoot)?.[0]; + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); if (startIndex === undefined) { return; } @@ -1395,8 +1414,8 @@ export class ProtoArray { * Get all nodes from a block root backwards */ getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] { - // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas - const startIndex = this.indices.get(blockRoot)?.[0]; + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); if (startIndex === undefined) { return []; } @@ -1425,8 +1444,8 @@ export class ProtoArray { * this is to find non-ancestor nodes of a blockRoot. */ getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] { - // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas - const startIndex = this.indices.get(blockRoot)?.[0]; + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); if (startIndex === undefined) { return []; } @@ -1455,8 +1474,8 @@ export class ProtoArray { * Returns both ancestor and non-ancestor nodes in a single traversal. */ getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} { - // Use variants[0]: FULL for pre-Gloas, PENDING for post-Gloas - const startIndex = this.indices.get(blockRoot)?.[0]; + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); if (startIndex === undefined) { return {ancestors: [], nonAncestors: []}; } @@ -1494,10 +1513,15 @@ export class ProtoArray { return this.indices.has(blockRoot); } - // Return any ProtoNode for blockRoot. PENDING variant for Gloas, FULL variant for pre-Gloas - // TODO GLOAS: Review usages. + /** + * Return canonical ProtoNode for blockRoot + * - Pre-Gloas: FULL variant + * - Gloas: PENDING variant + * + * TODO GLOAS: Review usages. + */ getNode(blockRoot: RootHex): ProtoNode | undefined { - const blockIndex = this.indices.get(blockRoot)?.[0]; + const blockIndex = this.getNodeIndexByRootAndStatus(blockRoot); if (blockIndex === undefined) { return undefined; } @@ -1591,7 +1615,7 @@ export class ProtoArray { } length(): number { - return this.indices.keys.length; + return this.indices.size; } private getNodeFromIndex(index: number): ProtoNode { From 756ac5770014123347f1e7f7f0cc28852034d3f0 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:44:02 -0800 Subject: [PATCH 27/52] Remove executionPayloadStates --- .../fork-choice/src/protoArray/protoArray.ts | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 69693eaa24f0..a0cdc3f297b4 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -437,9 +437,10 @@ export class ProtoArray { * Spec: gloas/fork-choice.md (on_execution_payload event) */ onExecutionPayload(blockRoot: RootHex, currentSlot: Slot): void { - // First check if FULL variant already exists + // First check if block exists const variants = this.indices.get(blockRoot); if (!variants) { + // Equivalent to `assert envelope.beacon_block_root in store.block_states` throw new ProtoArrayError({ code: ProtoArrayErrorCode.UNKNOWN_BLOCK, root: blockRoot, @@ -524,13 +525,12 @@ export class ProtoArray { * * Returns true if: * 1. Block has PTC votes tracked - * 2. Payload is locally available (in executionPayloadStates) + * 2. Payload is locally available (FULL variant exists in proto array) * 3. More than PAYLOAD_TIMELY_THRESHOLD (>50% of PTC) members voted payload_present=true * * @param blockRoot - The beacon block root to check - * @param executionPayloadStates - Map of blocks with available execution payloads */ - isPayloadTimely(blockRoot: RootHex, executionPayloadStates?: Map): boolean { + isPayloadTimely(blockRoot: RootHex): boolean { const votes = this.ptcVote.get(blockRoot); if (votes === undefined) { // Block not found or not a Gloas block @@ -538,7 +538,9 @@ export class ProtoArray { } // If payload is not locally available, it's not timely - if (!executionPayloadStates?.has(blockRoot)) { + // In our implementation, payload is locally available if proto array has FULL variant of the block + const fullNodeIndex = this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL); + if (fullNodeIndex === undefined) { return false; } @@ -569,15 +571,10 @@ export class ProtoArray { * * @param blockRoot - The block root to check * @param proposerBoostRoot - Current proposer boost root (from ForkChoice) - * @param executionPayloadStates - Map of blocks with available execution payloads */ - shouldExtendPayload( - blockRoot: RootHex, - proposerBoostRoot: RootHex | null, - executionPayloadStates?: Map - ): boolean { + shouldExtendPayload(blockRoot: RootHex, proposerBoostRoot: RootHex | null): boolean { // Condition 1: Payload is timely - if (this.isPayloadTimely(blockRoot, executionPayloadStates)) { + if (this.isPayloadTimely(blockRoot)) { return true; } @@ -824,12 +821,7 @@ export class ProtoArray { * * Note: pre-gloas logic won't reach here. Since it is impossible to have two nodes with same weight and root */ - private getPayloadStatusTiebreaker( - node: ProtoNode, - currentSlot: Slot, - proposerBoostRoot: RootHex | null, - executionPayloadStates?: Map - ): number { + private getPayloadStatusTiebreaker(node: ProtoNode, currentSlot: Slot, proposerBoostRoot: RootHex | null): number { // PENDING nodes always return PENDING (no tiebreaker needed) // PENDING=0, EMPTY=1, FULL=2 if (node.payloadStatus === PayloadStatus.PENDING) { @@ -847,7 +839,7 @@ export class ProtoArray { return 1; // EMPTY } // FULL - check should_extend_payload - const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot, executionPayloadStates); + const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot); return shouldExtend ? 2 : 0; // Return 2 if extending, else 0 } @@ -1175,11 +1167,10 @@ export class ProtoArray { const childTiebreaker = this.getPayloadStatusTiebreaker( childNode, currentSlot, - null, // proposerBoostRoot - undefined // executionPayloadStates + null // proposerBoostRoot ); - const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, null, undefined); + const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, null); if (childTiebreaker > bestChildTiebreaker) { newChildAndDescendant = changeToChild; From 7fab3c155f8ace499e402c0dfc638b18b369feaa Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:55:35 -0800 Subject: [PATCH 28/52] check-types --- .../test/unit/protoArray/gloas.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index be0fba75d289..f69097c54b3a 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -402,20 +402,16 @@ describe("Gloas Fork Choice", () => { const indices = Array.from({length: threshold}, (_, i) => i); protoArray.notifyPtcMessage("0x02", indices, true); - // Without executionPayloadStates, should return false + // Without execution payload (no FULL variant), should return false expect(protoArray.isPayloadTimely("0x02")).toBe(false); - - // With empty map, should return false - expect(protoArray.isPayloadTimely("0x02", new Map())).toBe(false); }); it("isPayloadTimely() returns true when threshold met and payload available", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot); - // Create execution payload states map - const executionPayloadStates = new Map(); - executionPayloadStates.set("0x02", {}); + // Make execution payload available by creating FULL variant + protoArray.onExecutionPayload("0x02", gloasForkSlot); // Vote yes from majority of PTC (>50%) const threshold = Math.floor(PTC_SIZE / 2) + 1; @@ -423,15 +419,15 @@ describe("Gloas Fork Choice", () => { protoArray.notifyPtcMessage("0x02", indices, true); // Should now be timely - expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(true); + expect(protoArray.isPayloadTimely("0x02")).toBe(true); }); it("isPayloadTimely() returns false when threshold not met", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot); - const executionPayloadStates = new Map(); - executionPayloadStates.set("0x02", {}); + // Make execution payload available by creating FULL variant + protoArray.onExecutionPayload("0x02", gloasForkSlot); // Vote yes from exactly 50% (not >50%) const threshold = Math.floor(PTC_SIZE / 2); @@ -439,15 +435,15 @@ describe("Gloas Fork Choice", () => { protoArray.notifyPtcMessage("0x02", indices, true); // Should not be timely (need >50%, not >=50%) - expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(false); + expect(protoArray.isPayloadTimely("0x02")).toBe(false); }); it("isPayloadTimely() counts only 'true' votes", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot); - const executionPayloadStates = new Map(); - executionPayloadStates.set("0x02", {}); + // Make execution payload available by creating FULL variant + protoArray.onExecutionPayload("0x02", gloasForkSlot); // Vote mixed yes/no const threshold = Math.floor(PTC_SIZE / 2) + 1; @@ -459,13 +455,13 @@ describe("Gloas Fork Choice", () => { protoArray.notifyPtcMessage("0x02", noIndices, false); // Should be timely (threshold met) - expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(true); + expect(protoArray.isPayloadTimely("0x02")).toBe(true); // Change some yes votes to no protoArray.notifyPtcMessage("0x02", [0, 1], false); // Should no longer be timely - expect(protoArray.isPayloadTimely("0x02", executionPayloadStates)).toBe(false); + expect(protoArray.isPayloadTimely("0x02")).toBe(false); }); it("isPayloadTimely() returns false for unknown block", () => { From cb3e4ef418dd3c8bbf3d0e15e782d197a7462786 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:00:30 -0800 Subject: [PATCH 29/52] Clean up access methods --- .../src/chain/blocks/importBlock.ts | 2 +- .../fork-choice/src/forkChoice/forkChoice.ts | 61 ++++--- .../fork-choice/src/forkChoice/interface.ts | 7 +- .../fork-choice/src/protoArray/protoArray.ts | 170 ++++++++++++++---- 4 files changed, 171 insertions(+), 69 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 2d5e8fbf50b7..6c120e39887f 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -344,7 +344,7 @@ export async function importBlock( // 3) Proposer boost reorg related flag is turned on (this is checked inside the function) // 4) Block meets the criteria of being re-orged out (this is also checked inside the function) const result = this.forkChoice.shouldOverrideForkChoiceUpdate( - blockSummary.blockRoot, + blockSummary, this.clock.secFromSlot(currentSlot), currentSlot ); diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 6c7c480ff5f4..370bcc645c2c 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -250,11 +250,10 @@ export class ForkChoice implements IForkChoice { // Return false otherwise. // Note when proposer boost reorg is disabled, it always returns false shouldOverrideForkChoiceUpdate( - blockRoot: RootHex, + headBlock: ProtoBlock, secFromSlot: number, currentSlot: Slot ): ShouldOverrideForkChoiceUpdateResult { - const headBlock = this.getBlockHex(blockRoot); if (headBlock === null) { // should not happen because this block just got imported. Fall back to no-reorg. return {shouldOverrideFcu: false, reason: NotReorgedReason.HeadBlockNotAvailable}; @@ -270,7 +269,7 @@ export class ForkChoice implements IForkChoice { return {shouldOverrideFcu: false, reason: NotReorgedReason.ProposerBoostReorgDisabled}; } - const parentBlock = this.protoArray.getBlock(headBlock.parentRoot); + const parentBlock = this.protoArray.getBlock(headBlock.parentRoot, this.protoArray.getParentPayloadStatus(headBlock)); const proposalSlot = headBlock.slot + 1; // No reorg if parentBlock isn't available @@ -295,7 +294,7 @@ export class ForkChoice implements IForkChoice { return {shouldOverrideFcu: false, reason: NotReorgedReason.ReorgMoreThanOneSlot}; } - this.logger?.verbose("Block is weak. Should override forkchoice update", {blockRoot, slot: currentSlot}); + this.logger?.verbose("Block is weak. Should override forkchoice update", {blockRoot: headBlock.blockRoot, slot: currentSlot}); return {shouldOverrideFcu: true, parentBlock}; } @@ -329,7 +328,7 @@ export class ForkChoice implements IForkChoice { } const blockRoot = headBlock.blockRoot; - const result = this.shouldOverrideForkChoiceUpdate(blockRoot, secFromSlot, currentSlot); + const result = this.shouldOverrideForkChoiceUpdate(headBlock, secFromSlot, currentSlot); if (result.shouldOverrideFcu) { this.logger?.verbose("Current head is weak. Predicting next block to be built on parent of head.", { @@ -376,7 +375,7 @@ export class ForkChoice implements IForkChoice { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ProposerBoostReorgDisabled}; } - const parentBlock = this.protoArray.getBlock(headBlock.parentRoot); + const parentBlock = this.protoArray.getBlock(headBlock.parentRoot, this.protoArray.getParentPayloadStatus(headBlock)); // No reorg if parentBlock isn't available if (parentBlock === undefined) { @@ -413,7 +412,7 @@ export class ForkChoice implements IForkChoice { slotsPerEpoch: SLOTS_PER_EPOCH, committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, }); - const headNode = this.protoArray.getNode(headBlock.blockRoot); + const headNode = this.protoArray.getNode(headBlock.blockRoot, headBlock.payloadStatus); // If headNode is unavailable, give up reorg if (headNode === undefined || headNode.weight >= reorgThreshold) { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.HeadBlockNotWeak}; @@ -425,7 +424,7 @@ export class ForkChoice implements IForkChoice { slotsPerEpoch: SLOTS_PER_EPOCH, committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD, }); - const parentNode = this.protoArray.getNode(parentBlock.blockRoot); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentBlock.payloadStatus); // If parentNode is unavailable, give up reorg if (parentNode === undefined || parentNode.weight <= parentThreshold) { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ParentBlockNotStrong}; @@ -586,7 +585,9 @@ export class ForkChoice implements IForkChoice { const {parentRoot, slot} = block; const parentRootHex = toRootHex(parentRoot); // Parent block must be known - const parentBlock = this.protoArray.getBlock(parentRootHex); + // We do not care about the variant here, we just need to find the parent block + const defaultStatus = this.protoArray.getDefaultVariant(parentRootHex); + const parentBlock = defaultStatus !== undefined ? this.protoArray.getBlock(parentRootHex, defaultStatus) : undefined; if (!parentBlock) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.INVALID_BLOCK, @@ -924,7 +925,7 @@ export class ForkChoice implements IForkChoice { /** Returns `true` if the block is known **and** a descendant of the finalized root. */ hasBlock(blockRoot: Root): boolean { return this.hasBlockHex(toRootHex(blockRoot)); - } + } /** Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ getBlock(blockRoot: Root): ProtoBlock | null { return this.getBlockHex(toRootHex(blockRoot)); @@ -932,9 +933,11 @@ export class ForkChoice implements IForkChoice { /** * Returns `true` if the block is known **and** a descendant of the finalized root. + * Uses default variant (PENDING for Gloas, FULL for pre-Gloas). */ hasBlockHex(blockRoot: RootHex): boolean { - const node = this.protoArray.getNode(blockRoot); + const defaultStatus = this.protoArray.getDefaultVariant(blockRoot); + const node = defaultStatus !== undefined ? this.protoArray.getNode(blockRoot, defaultStatus) : undefined; if (node === undefined) { return false; } @@ -960,7 +963,11 @@ export class ForkChoice implements IForkChoice { * Returns a MUTABLE `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ getBlockHex(blockRoot: RootHex): ProtoBlock | null { - const node = this.protoArray.getNode(blockRoot); + const defaultStatus = this.protoArray.getDefaultVariant(blockRoot); + if (defaultStatus === undefined) { + return null; + } + const node = this.protoArray.getNode(blockRoot, defaultStatus); if (!node) { return null; } @@ -975,22 +982,24 @@ export class ForkChoice implements IForkChoice { } getJustifiedBlock(): ProtoBlock { - const block = this.getBlockHex(this.fcStore.justified.checkpoint.rootHex); + const rootHex = this.fcStore.justified.checkpoint.rootHex; + const block = this.getBlockHex(rootHex); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: this.fcStore.justified.checkpoint.rootHex, + root: rootHex, }); } return block; } getFinalizedBlock(): ProtoBlock { - const block = this.getBlockHex(this.fcStore.finalizedCheckpoint.rootHex); + const rootHex = this.fcStore.finalizedCheckpoint.rootHex; + const block = this.getBlockHex(rootHex); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: this.fcStore.finalizedCheckpoint.rootHex, + root: rootHex, }); } return block; @@ -1163,8 +1172,8 @@ export class ForkChoice implements IForkChoice { /** Returns the distance of common ancestor of nodes to the max of the newNode and the prevNode. */ getCommonAncestorDepth(prevBlock: ProtoBlock, newBlock: ProtoBlock): AncestorResult { - const prevNode = this.protoArray.getNode(prevBlock.blockRoot); - const newNode = this.protoArray.getNode(newBlock.blockRoot); + const prevNode = this.protoArray.getNode(prevBlock.blockRoot, prevBlock.payloadStatus); + const newNode = this.protoArray.getNode(newBlock.blockRoot, newBlock.payloadStatus); if (!prevNode || !newNode) { return {code: AncestorStatus.BlockUnknown}; } @@ -1245,12 +1254,12 @@ export class ForkChoice implements IForkChoice { return block.parentRoot; } - block = - block.blockRoot === block.targetRoot - ? // For the first slot of the epoch, a block is it's own target - this.protoArray.getBlockReadonly(block.parentRoot) - : // else we can navigate much faster jumping to the target block - this.protoArray.getBlockReadonly(block.targetRoot); + const nextRoot = block.blockRoot === block.targetRoot ? block.parentRoot : block.targetRoot; + const defaultStatus = this.protoArray.getDefaultVariant(nextRoot); + if (defaultStatus === undefined) { + throw Error(`No block for root ${nextRoot}`); + } + block = this.protoArray.getBlockReadonly(nextRoot, defaultStatus); } throw Error(`Not found dependent root for block slot ${block.slot}, epoch difference ${epochDifference}`); @@ -1461,7 +1470,9 @@ export class ForkChoice implements IForkChoice { // // Attestations must be for a known block. If the block is unknown, we simply drop the // attestation and do not delay consideration for later. - const block = this.protoArray.getBlock(beaconBlockRootHex); + // We don't care which variant it is, just need to find the block + const defaultStatus = this.protoArray.getDefaultVariant(beaconBlockRootHex); + const block = defaultStatus !== undefined ? this.protoArray.getBlock(beaconBlockRootHex, defaultStatus) : undefined; if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.INVALID_ATTESTATION, diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index b3948b51a2cf..a58fe9d14bfb 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -4,7 +4,7 @@ import { EffectiveBalanceIncrements, } from "@lodestar/state-transition"; import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types"; -import {LVHExecResponse, MaybeValidExecutionStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; +import {LVHExecResponse, MaybeValidExecutionStatus, PayloadStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; import {UpdateAndGetHeadOpt} from "./forkChoice.js"; import {CheckpointWithHex} from "./store.js"; @@ -106,7 +106,7 @@ export interface IForkChoice { * called by `predictProposerHead()` during `prepareNextSlot()`. */ shouldOverrideForkChoiceUpdate( - blockRoot: RootHex, + headBlock: ProtoBlock, secFromSlot: number, currentSlot: Slot ): ShouldOverrideForkChoiceUpdateResult; @@ -215,9 +215,6 @@ export interface IForkChoice { hasBlockUnsafe(blockRoot: Root): boolean; hasBlockHexUnsafe(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; - /** - * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. - */ getBlock(blockRoot: Root): ProtoBlock | null; getBlockHex(blockRoot: RootHex): ProtoBlock | null; getFinalizedBlock(): ProtoBlock; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index a0cdc3f297b4..66b6a137a062 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -106,17 +106,17 @@ export class ProtoArray { /** * Get node index for a block root and payload status * - * Returns the index for the specified payload status variant, or undefined if not found. + * @param root - The block root to look up + * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL) + * @returns The node index for the specified variant, or undefined if not found * - * If payloadStatus not provided: - * - Pre-Gloas blocks: returns FULL variant (canonical) - * - Gloas blocks: returns PENDING variant (canonical) - * - * If payloadStatus provided: + * Behavior: * - Pre-Gloas blocks: only FULL is valid, PENDING/EMPTY throw error - * - Gloas blocks: returns the specified variant + * - Gloas blocks: returns the specified variant index, or undefined if that variant doesn't exist + * + * Note: payloadStatus is required. Use getDefaultVariant() to get the canonical variant. */ - getNodeIndexByRootAndStatus(root: RootHex, payloadStatus?: PayloadStatus): number | undefined { + getNodeIndexByRootAndStatus(root: RootHex, payloadStatus: PayloadStatus): number | undefined { const variants = this.indices.get(root); if (!variants) { return undefined; @@ -125,7 +125,7 @@ export class ProtoArray { // Pre-Gloas: only one variant exists (FULL at index 0) if (variants.length === 1) { // Return FULL variant if no status specified or FULL explicitly requested - if (payloadStatus === undefined || payloadStatus === PayloadStatus.FULL) { + if (payloadStatus === PayloadStatus.FULL) { return variants[0]; } // PENDING and EMPTY are invalid for pre-Gloas blocks @@ -136,7 +136,30 @@ export class ProtoArray { } // Gloas: return the specified variant, or PENDING if not specified - return variants[payloadStatus ?? PayloadStatus.PENDING]; + return variants[payloadStatus]; + } + + /** + * Get the default/canonical payload status for a block root + * - Pre-Gloas blocks: Returns FULL (payload embedded in block) + * - Gloas blocks: Returns PENDING (canonical variant) + * + * @param blockRoot - The block root to check + * @returns PayloadStatus.FULL for pre-Gloas, PayloadStatus.PENDING for Gloas, undefined if block not found + */ + getDefaultVariant(blockRoot: RootHex): PayloadStatus | undefined { + const variants = this.indices.get(blockRoot); + if (!variants) { + return undefined; + } + + // Pre-Gloas: only one variant exists (FULL) + if (variants.length === 1) { + return PayloadStatus.FULL; + } + + // Gloas: multiple variants exist, PENDING is canonical + return PayloadStatus.PENDING; } /** @@ -149,7 +172,7 @@ export class ProtoArray { * * For pre-Gloas blocks: always returns FULL */ - private getParentPayloadStatus(block: ProtoBlock): PayloadStatus { + getParentPayloadStatus(block: ProtoBlock): PayloadStatus { // Pre-Gloas blocks have payloads embedded, so parents are always FULL if (!isGloasBlock(block)) { return PayloadStatus.FULL; @@ -584,7 +607,9 @@ export class ProtoArray { } // Get proposer boost block - const proposerBoostBlock = this.getNode(proposerBoostRoot); + // We don't care about variant here, just need proposer boost block info + const defaultStatus = this.getDefaultVariant(proposerBoostRoot); + const proposerBoostBlock = defaultStatus !== undefined ? this.getNode(proposerBoostRoot, defaultStatus) : undefined; if (!proposerBoostBlock) { // Proposer boost block not found, default to extending payload return true; @@ -652,7 +677,9 @@ export class ProtoArray { // if its in fcU. // const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse; - const invalidateFromParentIndex = this.getNodeIndexByRootAndStatus(invalidateFromParentBlockRoot); + // TODO GLOAS: verify if getting default variant is correct here + const defaultStatus = this.getDefaultVariant(invalidateFromParentBlockRoot); + const invalidateFromParentIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(invalidateFromParentBlockRoot, defaultStatus) : undefined; if (invalidateFromParentIndex === undefined) { throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`); } @@ -859,7 +886,8 @@ export class ProtoArray { } // Get canonical node: FULL for pre-Gloas, PENDING for Gloas - const justifiedIndex = this.getNodeIndexByRootAndStatus(justifiedRoot); + const defaultStatus = this.getDefaultVariant(justifiedRoot); + const justifiedIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(justifiedRoot, defaultStatus) : undefined; if (justifiedIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN, @@ -1375,7 +1403,8 @@ export class ProtoArray { */ *iterateAncestorNodes(blockRoot: RootHex): IterableIterator { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas - const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); + const defaultStatus = this.getDefaultVariant(blockRoot); + const startIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return; } @@ -1406,7 +1435,8 @@ export class ProtoArray { */ getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas - const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); + const defaultStatus = this.getDefaultVariant(blockRoot); + const startIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return []; } @@ -1433,10 +1463,16 @@ export class ProtoArray { * The opposite of iterateNodes. * iterateNodes is to find ancestor nodes of a blockRoot. * this is to find non-ancestor nodes of a blockRoot. + * + * Only default variant (FULL pre-gloas and PENDING post-gloas) are returned */ getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas - const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); + const defaultStatus = this.getDefaultVariant(blockRoot); + if (defaultStatus === undefined) { + return []; + } + const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus); if (startIndex === undefined) { return []; } @@ -1451,22 +1487,35 @@ export class ProtoArray { const result: ProtoNode[] = []; let nodeIndex = startIndex; while (node.parent !== undefined) { - const parentIndex = node.parent; + const pIndex = node.parent; + const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; + const parentIndex = this.indices.get(parentRoot)?.[0]; + + if (parentIndex === undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, + index: pIndex, + }); + } + node = this.getNodeFromIndex(parentIndex); // nodes between nodeIndex and parentIndex means non-ancestor nodes - result.push(...this.getNodesBetween(nodeIndex, parentIndex)); + // Excludes all EMPTY/FULL variant post-gloas + result.push(...this.getNodesBetween(nodeIndex, parentIndex).filter(this.isDefaultVariant)); nodeIndex = parentIndex; } - result.push(...this.getNodesBetween(nodeIndex, 0)); + result.push(...this.getNodesBetween(nodeIndex, 0).filter(this.isDefaultVariant)); return result; } /** * Returns both ancestor and non-ancestor nodes in a single traversal. + * Only default variant (FULL pre-gloas and PENDING post-gloas) are returned */ getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas - const startIndex = this.getNodeIndexByRootAndStatus(blockRoot); + const defaultStatus = this.getDefaultVariant(blockRoot); + const startIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return {ancestors: [], nonAncestors: []}; } @@ -1486,42 +1535,74 @@ export class ProtoArray { while (node.parent !== undefined) { ancestors.push(node); - const parentIndex = node.parent; + // Parent could be FULL/EMPTY, need to use `indices` to look up PENDING variant of parent + const pIndex = node.parent; + const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; + const parentIndex = this.indices.get(parentRoot)?.[0]; + + if (parentIndex === undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, + index: pIndex, + }); + } node = this.getNodeFromIndex(parentIndex); // Nodes between nodeIndex and parentIndex are non-ancestor nodes - nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex)); + // Filter out all FULL/EMPTY variants post-gloas + nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex).filter(this.isDefaultVariant)); nodeIndex = parentIndex; } ancestors.push(node); - nonAncestors.push(...this.getNodesBetween(nodeIndex, 0)); + nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter(this.isDefaultVariant)); return {ancestors, nonAncestors}; } + /** + * Check if a block exists in the proto array + * Uses default variant (PENDING for Gloas, FULL for pre-Gloas) + */ hasBlock(blockRoot: RootHex): boolean { - return this.indices.has(blockRoot); + const defaultVariant = this.getDefaultVariant(blockRoot); + if (defaultVariant === undefined) { + return false; + } + const index = this.getNodeIndexByRootAndStatus(blockRoot, defaultVariant); + return index !== undefined; } /** - * Return canonical ProtoNode for blockRoot - * - Pre-Gloas: FULL variant - * - Gloas: PENDING variant + * Return ProtoNode for blockRoot with explicit payload status + * + * @param blockRoot - The block root to look up + * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL) + * @returns The ProtoNode for the specified variant, or undefined if not found * - * TODO GLOAS: Review usages. + * Note: Callers must explicitly specify which variant they need. + * Use getDefaultVariant() to get the canonical variant for a block. */ - getNode(blockRoot: RootHex): ProtoNode | undefined { - const blockIndex = this.getNodeIndexByRootAndStatus(blockRoot); + getNode(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoNode | undefined { + const blockIndex = this.getNodeIndexByRootAndStatus(blockRoot, payloadStatus); if (blockIndex === undefined) { return undefined; } return this.getNodeByIndex(blockIndex); } - /** Return MUTABLE ProtoBlock for blockRoot (spreads properties) */ - getBlock(blockRoot: RootHex): ProtoBlock | undefined { - const node = this.getNode(blockRoot); + /** + * Return MUTABLE ProtoBlock for blockRoot with explicit payload status + * + * @param blockRoot - The block root to look up + * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL) + * @returns The ProtoBlock for the specified variant (spreads properties), or undefined if not found + * + * Note: Callers must explicitly specify which variant they need. + * Use getDefaultVariant() to get the canonical variant for a block. + */ + getBlock(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock | undefined { + const node = this.getNode(blockRoot, payloadStatus); if (!node) { return undefined; } @@ -1530,9 +1611,19 @@ export class ProtoArray { }; } - /** Return NON-MUTABLE ProtoBlock for blockRoot (does not spread properties) */ - getBlockReadonly(blockRoot: RootHex): ProtoBlock { - const node = this.getNode(blockRoot); + /** + * Return NON-MUTABLE ProtoBlock for blockRoot with explicit payload status + * + * @param blockRoot - The block root to look up + * @param payloadStatus - The specific payload status variant (PENDING/EMPTY/FULL) + * @returns The ProtoBlock for the specified variant (does not spread properties) + * @throws Error if block not found + * + * Note: Callers must explicitly specify which variant they need. + * Use getDefaultVariant() to get the canonical variant for a block. + */ + getBlockReadonly(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock { + const node = this.getNode(blockRoot, payloadStatus); if (!node) { throw Error(`No block for root ${blockRoot}`); } @@ -1545,7 +1636,10 @@ export class ProtoArray { * Still returns `true` if `ancestorRoot` === `descendantRoot` (and the roots are known) */ isDescendant(ancestorRoot: RootHex, descendantRoot: RootHex): boolean { - const ancestorNode = this.getNode(ancestorRoot); + // We use the default variant (PENDING for Gloas, FULL for pre-Gloas) + // We cannot use FULL/EMPTY variants for Gloas because they may not be canonical + const defaultStatus = this.getDefaultVariant(ancestorRoot); + const ancestorNode = defaultStatus !== undefined ? this.getNode(ancestorRoot, defaultStatus) : undefined; if (!ancestorNode) { return false; } From a1d1c5909573a82d630ec7a93ec5cda77590e9b7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:02:34 -0800 Subject: [PATCH 30/52] Tree walk skip EMPTY/FULL variants --- .../fork-choice/src/protoArray/protoArray.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 66b6a137a062..23547c280f35 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1425,7 +1425,17 @@ export class ProtoArray { */ *iterateAncestorNodesFromNode(node: ProtoNode): IterableIterator { while (node.parent !== undefined) { - node = this.getNodeFromIndex(node.parent); + const pIndex = node.parent; + const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; + const parentIndex = this.indices.get(parentRoot)?.[0]; + + if (parentIndex === undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, + index: pIndex, + }); + } + node = this.getNodeFromIndex(parentIndex); yield node; } } @@ -1452,7 +1462,18 @@ export class ProtoArray { const nodes = [node]; while (node.parent !== undefined) { - node = this.getNodeFromIndex(node.parent); + const pIndex = node.parent; + const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; + const parentIndex = this.indices.get(parentRoot)?.[0]; + + if (parentIndex === undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, + index: pIndex, + }); + } + + node = this.getNodeFromIndex(parentIndex); nodes.push(node); } @@ -1734,4 +1755,8 @@ export class ProtoArray { } return result; } + + private isDefaultVariant(node: ProtoNode): boolean { + return node.payloadStatus === this.getDefaultVariant(node.blockRoot) + } } From ff558e35ea4d9cf0bbcd04a42e1c22bbb9c53a8b Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:12:25 -0800 Subject: [PATCH 31/52] fix types-check --- .../test/spec/presets/fork_choice.test.ts | 2 +- .../fork-choice/src/forkChoice/interface.ts | 2 +- .../fork-choice/src/protoArray/protoArray.ts | 6 +++--- .../shouldOverrideForkChoiceUpdate.test.ts | 2 +- .../protoArray/executionStatusUpdates.test.ts | 3 ++- .../unit/protoArray/getCommonAncestor.test.ts | 6 +++++- .../test/unit/protoArray/gloas.test.ts | 17 ++++++++++++----- 7 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index e8faa4d1afdf..31959d265586 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -420,7 +420,7 @@ const forkChoiceTest = if (step.checks.should_override_forkchoice_update) { const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); const result = chain.forkChoice.shouldOverrideForkChoiceUpdate( - head.blockRoot, + head, tickTime % (config.SLOT_DURATION_MS / 1000), currentSlot ); diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index a58fe9d14bfb..badad0247397 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -4,7 +4,7 @@ import { EffectiveBalanceIncrements, } from "@lodestar/state-transition"; import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types"; -import {LVHExecResponse, MaybeValidExecutionStatus, PayloadStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; +import {LVHExecResponse, MaybeValidExecutionStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; import {UpdateAndGetHeadOpt} from "./forkChoice.js"; import {CheckpointWithHex} from "./store.js"; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 23547c280f35..850de9d665fd 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1756,7 +1756,7 @@ export class ProtoArray { return result; } - private isDefaultVariant(node: ProtoNode): boolean { - return node.payloadStatus === this.getDefaultVariant(node.blockRoot) - } + private isDefaultVariant = (node: ProtoNode): boolean => { + return node.payloadStatus === this.getDefaultVariant(node.blockRoot); + }; } diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index a92602370e5a..3f9bc07dbd81 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -216,7 +216,7 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { proposerBoostReorg: true, }); - const result = forkChoice.shouldOverrideForkChoiceUpdate(headBlock.blockRoot, secFromSlot, currentSlot); + const result = forkChoice.shouldOverrideForkChoiceUpdate(headBlock, secFromSlot, currentSlot); expect(result.shouldOverrideFcu).toBe(expectReorg); diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 891c0c5be771..ead62c3354e2 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -433,7 +433,8 @@ function collectProtoarrayValidationStatus(fcArray: ProtoArray): ValidationTestC const expectedForkChoice: ValidationTestCase[] = []; for (const fcRoot of fcRoots) { - const fcNode = fcArray.getNode(fcRoot); + const defaultStatus = fcArray.getDefaultVariant(fcRoot); + const fcNode = defaultStatus !== undefined ? fcArray.getNode(fcRoot, defaultStatus) : undefined; const bestChild = fcNode?.bestChild !== undefined ? fcArray["getNodeFromIndex"](fcNode.bestChild).blockRoot : undefined; const bestDescendant = diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index 36ce7306332f..90dc30f625a9 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -85,7 +85,11 @@ describe("getCommonAncestor", () => { for (const {nodeA, nodeB, ancestor} of testCases) { it(`${nodeA} & ${nodeB} -> ${ancestor}`, () => { // biome-ignore lint/style/noNonNullAssertion: We know the node can not be null here - const ancestorNode = fc.getCommonAncestor(fc.getNode(nodeA)!, fc.getNode(nodeB)!); + const defaultStatusA = fc.getDefaultVariant(nodeA); + const defaultStatusB = fc.getDefaultVariant(nodeB); + const nodeAResult = defaultStatusA !== undefined ? fc.getNode(nodeA, defaultStatusA) : undefined; + const nodeBResult = defaultStatusB !== undefined ? fc.getNode(nodeB, defaultStatusB) : undefined; + const ancestorNode = fc.getCommonAncestor(nodeAResult!, nodeBResult!); if (ancestor) { expect(ancestorNode?.blockRoot).toBe(ancestor); } else { diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index f69097c54b3a..e20a4a55e1eb 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -154,7 +154,9 @@ describe("Gloas Fork Choice", () => { const block = createTestBlock(1, "0x02", genesisRoot); protoArray.onBlock(block, 1); - const node = protoArray.getNode("0x02"); + const defaultStatus = protoArray.getDefaultVariant("0x02"); + expect(defaultStatus).toBe(PayloadStatus.FULL); + const node = defaultStatus !== undefined ? protoArray.getNode("0x02", defaultStatus) : undefined; expect(node).toBeDefined(); expect(node?.payloadStatus).toBe(PayloadStatus.FULL); }); @@ -270,11 +272,15 @@ describe("Gloas Fork Choice", () => { const gloasBlock = createTestBlock(gloasForkSlot, "0x03", "0x02", "0x02"); protoArray.onBlock(gloasBlock, gloasForkSlot); - // Should find both blocks - const fuluNode = protoArray.getNode("0x02"); + // Should find both blocks with correct default variants + const fuluDefaultStatus = protoArray.getDefaultVariant("0x02"); + expect(fuluDefaultStatus).toBe(PayloadStatus.FULL); + const fuluNode = fuluDefaultStatus !== undefined ? protoArray.getNode("0x02", fuluDefaultStatus) : undefined; expect(fuluNode?.payloadStatus).toBe(PayloadStatus.FULL); - const gloasNode = protoArray.getNode("0x03"); + const gloasDefaultStatus = protoArray.getDefaultVariant("0x03"); + expect(gloasDefaultStatus).toBe(PayloadStatus.PENDING); + const gloasNode = gloasDefaultStatus !== undefined ? protoArray.getNode("0x03", gloasDefaultStatus) : undefined; expect(gloasNode?.payloadStatus).toBe(PayloadStatus.PENDING); }); }); @@ -587,7 +593,8 @@ describe("Gloas Fork Choice", () => { const emptyBIndex = protoArray.getNodeIndexByRootAndStatus("0x03", PayloadStatus.EMPTY)!; // Give A more votes than B - const deltas = new Array(protoArray.length()).fill(0); + // Note: Use nodes.length (not protoArray.length()) since Gloas blocks have multiple nodes per root + const deltas = new Array(protoArray.nodes.length).fill(0); deltas[emptyAIndex] = 200; deltas[emptyBIndex] = 100; From bdcc2950956b5ed43517d41674d5376c409f2fa5 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:13:32 -0800 Subject: [PATCH 32/52] lint --- .../fork-choice/src/forkChoice/forkChoice.ts | 20 +++++++++---- .../fork-choice/src/protoArray/protoArray.ts | 19 +++++++++---- .../unit/protoArray/getCommonAncestor.test.ts | 2 +- .../test/unit/protoArray/gloas.test.ts | 28 ++++--------------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 370bcc645c2c..724416dbb189 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -269,7 +269,10 @@ export class ForkChoice implements IForkChoice { return {shouldOverrideFcu: false, reason: NotReorgedReason.ProposerBoostReorgDisabled}; } - const parentBlock = this.protoArray.getBlock(headBlock.parentRoot, this.protoArray.getParentPayloadStatus(headBlock)); + const parentBlock = this.protoArray.getBlock( + headBlock.parentRoot, + this.protoArray.getParentPayloadStatus(headBlock) + ); const proposalSlot = headBlock.slot + 1; // No reorg if parentBlock isn't available @@ -294,7 +297,10 @@ export class ForkChoice implements IForkChoice { return {shouldOverrideFcu: false, reason: NotReorgedReason.ReorgMoreThanOneSlot}; } - this.logger?.verbose("Block is weak. Should override forkchoice update", {blockRoot: headBlock.blockRoot, slot: currentSlot}); + this.logger?.verbose("Block is weak. Should override forkchoice update", { + blockRoot: headBlock.blockRoot, + slot: currentSlot, + }); return {shouldOverrideFcu: true, parentBlock}; } @@ -375,7 +381,10 @@ export class ForkChoice implements IForkChoice { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ProposerBoostReorgDisabled}; } - const parentBlock = this.protoArray.getBlock(headBlock.parentRoot, this.protoArray.getParentPayloadStatus(headBlock)); + const parentBlock = this.protoArray.getBlock( + headBlock.parentRoot, + this.protoArray.getParentPayloadStatus(headBlock) + ); // No reorg if parentBlock isn't available if (parentBlock === undefined) { @@ -587,7 +596,8 @@ export class ForkChoice implements IForkChoice { // Parent block must be known // We do not care about the variant here, we just need to find the parent block const defaultStatus = this.protoArray.getDefaultVariant(parentRootHex); - const parentBlock = defaultStatus !== undefined ? this.protoArray.getBlock(parentRootHex, defaultStatus) : undefined; + const parentBlock = + defaultStatus !== undefined ? this.protoArray.getBlock(parentRootHex, defaultStatus) : undefined; if (!parentBlock) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.INVALID_BLOCK, @@ -925,7 +935,7 @@ export class ForkChoice implements IForkChoice { /** Returns `true` if the block is known **and** a descendant of the finalized root. */ hasBlock(blockRoot: Root): boolean { return this.hasBlockHex(toRootHex(blockRoot)); - } + } /** Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ getBlock(blockRoot: Root): ProtoBlock | null { return this.getBlockHex(toRootHex(blockRoot)); diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 850de9d665fd..276603bbea52 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -679,7 +679,10 @@ export class ProtoArray { const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse; // TODO GLOAS: verify if getting default variant is correct here const defaultStatus = this.getDefaultVariant(invalidateFromParentBlockRoot); - const invalidateFromParentIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(invalidateFromParentBlockRoot, defaultStatus) : undefined; + const invalidateFromParentIndex = + defaultStatus !== undefined + ? this.getNodeIndexByRootAndStatus(invalidateFromParentBlockRoot, defaultStatus) + : undefined; if (invalidateFromParentIndex === undefined) { throw Error(`Unable to find invalidateFromParentBlockRoot=${invalidateFromParentBlockRoot} in forkChoice`); } @@ -887,7 +890,8 @@ export class ProtoArray { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas const defaultStatus = this.getDefaultVariant(justifiedRoot); - const justifiedIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(justifiedRoot, defaultStatus) : undefined; + const justifiedIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(justifiedRoot, defaultStatus) : undefined; if (justifiedIndex === undefined) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN, @@ -1404,7 +1408,8 @@ export class ProtoArray { *iterateAncestorNodes(blockRoot: RootHex): IterableIterator { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas const defaultStatus = this.getDefaultVariant(blockRoot); - const startIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; + const startIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return; } @@ -1446,7 +1451,8 @@ export class ProtoArray { getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas const defaultStatus = this.getDefaultVariant(blockRoot); - const startIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; + const startIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return []; } @@ -1484,7 +1490,7 @@ export class ProtoArray { * The opposite of iterateNodes. * iterateNodes is to find ancestor nodes of a blockRoot. * this is to find non-ancestor nodes of a blockRoot. - * + * * Only default variant (FULL pre-gloas and PENDING post-gloas) are returned */ getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] { @@ -1536,7 +1542,8 @@ export class ProtoArray { getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas const defaultStatus = this.getDefaultVariant(blockRoot); - const startIndex = defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; + const startIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return {ancestors: [], nonAncestors: []}; } diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index 90dc30f625a9..56e3cdb55c5d 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -84,11 +84,11 @@ describe("getCommonAncestor", () => { for (const {nodeA, nodeB, ancestor} of testCases) { it(`${nodeA} & ${nodeB} -> ${ancestor}`, () => { - // biome-ignore lint/style/noNonNullAssertion: We know the node can not be null here const defaultStatusA = fc.getDefaultVariant(nodeA); const defaultStatusB = fc.getDefaultVariant(nodeB); const nodeAResult = defaultStatusA !== undefined ? fc.getNode(nodeA, defaultStatusA) : undefined; const nodeBResult = defaultStatusB !== undefined ? fc.getNode(nodeB, defaultStatusB) : undefined; + // biome-ignore lint/style/noNonNullAssertion: We know the node can not be null here const ancestorNode = fc.getCommonAncestor(nodeAResult!, nodeBResult!); if (ancestor) { expect(ancestorNode?.blockRoot).toBe(ancestor); diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index e20a4a55e1eb..721cdad59b82 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -2,13 +2,7 @@ import {beforeEach, describe, expect, it} from "vitest"; import {PTC_SIZE} from "@lodestar/params"; import {DataAvailabilityStatus, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; -import { - ExecutionStatus, - PayloadStatus, - ProtoArray, - ProtoBlock, - ProtoNode, -} from "../../../src/index.js"; +import {ExecutionStatus, PayloadStatus, ProtoArray, ProtoBlock, ProtoNode} from "../../../src/index.js"; describe("Gloas Fork Choice", () => { const genesisEpoch = 0; @@ -78,10 +72,7 @@ describe("Gloas Fork Choice", () => { describe("ProtoArray indices lookup", () => { it("indices map stores variants correctly for pre-Gloas blocks", () => { - const protoArray = ProtoArray.initialize( - createTestBlock(0, genesisRoot, "0x00"), - 0 - ); + const protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); const variants = (protoArray as any).indices.get(genesisRoot); expect(variants).toBeDefined(); // Pre-Gloas: variants[0] contains FULL index @@ -90,10 +81,7 @@ describe("Gloas Fork Choice", () => { }); it("getNodeByPayloadStatus() retrieves correct variants", () => { - const protoArray = ProtoArray.initialize( - createTestBlock(0, genesisRoot, "0x00"), - 0 - ); + const protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); const node = getNodeByPayloadStatus(protoArray, genesisRoot, PayloadStatus.FULL); expect(node).toBeDefined(); expect(node?.blockRoot).toBe(genesisRoot); @@ -101,10 +89,7 @@ describe("Gloas Fork Choice", () => { }); it("indices map stores multiple variants for Gloas blocks", () => { - const protoArray = ProtoArray.initialize( - createTestBlock(0, genesisRoot, "0x00"), - 0 - ); + const protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); // Add a Gloas block const gloasBlock = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); @@ -538,10 +523,7 @@ describe("Gloas Fork Choice", () => { beforeEach(() => { // Initialize with genesis block to avoid INVALID_PARENT_DELTA errors - protoArray = ProtoArray.initialize( - createTestBlock(0, genesisRoot, "0x00"), - 0 - ); + protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); }); it("EMPTY vs FULL comparison uses explicit tiebreaker for slot n-1 blocks", () => { From 777e461e7a526e552f1dde972180cffa973069b7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:45:50 -0800 Subject: [PATCH 33/52] reduce diff --- packages/fork-choice/src/forkChoice/interface.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index badad0247397..4ec458b4e1c0 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -215,6 +215,9 @@ export interface IForkChoice { hasBlockUnsafe(blockRoot: Root): boolean; hasBlockHexUnsafe(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; + /** + * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. + */ getBlock(blockRoot: Root): ProtoBlock | null; getBlockHex(blockRoot: RootHex): ProtoBlock | null; getFinalizedBlock(): ProtoBlock; From 3e3de40bc2d27023fe441235e5244295522404d7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:33:14 -0800 Subject: [PATCH 34/52] Fix unit tests --- .../fork-choice/src/forkChoice/interface.ts | 2 +- .../fork-choice/src/protoArray/protoArray.ts | 87 +++++++++---------- 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 4ec458b4e1c0..682c11c70eb7 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -216,7 +216,7 @@ export interface IForkChoice { hasBlockHexUnsafe(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** - * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. + * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ getBlock(blockRoot: Root): ProtoBlock | null; getBlockHex(blockRoot: RootHex): ProtoBlock | null; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 276603bbea52..b2798d15d6bf 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -980,7 +980,7 @@ export class ProtoArray { // Collect all block roots that will be pruned const prunedRoots = new Set(); - for (let i = 0; i <= finalizedIndex; i++) { + for (let i = 0; i < finalizedIndex; i++) { const node = this.nodes[i]; if (node === undefined) { throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index: i}); @@ -1430,18 +1430,14 @@ export class ProtoArray { */ *iterateAncestorNodesFromNode(node: ProtoNode): IterableIterator { while (node.parent !== undefined) { - const pIndex = node.parent; - const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; - const parentIndex = this.indices.get(parentRoot)?.[0]; + // Traverse to parent node + // Note: node.parent may point to EMPTY or FULL variant, but we only want to yield default variants + node = this.getNodeFromIndex(node.parent); - if (parentIndex === undefined) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, - index: pIndex, - }); + // Only yield default variants (PENDING for Gloas, FULL for pre-Gloas) + if (this.isDefaultVariant(node)) { + yield node; } - node = this.getNodeFromIndex(parentIndex); - yield node; } } @@ -1468,19 +1464,14 @@ export class ProtoArray { const nodes = [node]; while (node.parent !== undefined) { - const pIndex = node.parent; - const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; - const parentIndex = this.indices.get(parentRoot)?.[0]; + // Traverse to parent node + // Note: node.parent may point to EMPTY or FULL variant, but we only want to collect default variants + node = this.getNodeFromIndex(node.parent); - if (parentIndex === undefined) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, - index: pIndex, - }); + // Only collect default variants (PENDING for Gloas, FULL for pre-Gloas) + if (this.isDefaultVariant(node)) { + nodes.push(node); } - - node = this.getNodeFromIndex(parentIndex); - nodes.push(node); } return nodes; @@ -1514,18 +1505,16 @@ export class ProtoArray { const result: ProtoNode[] = []; let nodeIndex = startIndex; while (node.parent !== undefined) { - const pIndex = node.parent; - const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; - const parentIndex = this.indices.get(parentRoot)?.[0]; + // Traverse to parent - may point to any variant + const parentIndex = node.parent; + node = this.getNodeFromIndex(parentIndex); - if (parentIndex === undefined) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, - index: pIndex, - }); + if (!this.isDefaultVariant(node)) { + // Parent is non-default variant, need to find default variant + // Skip to next iteration to find the default variant + continue; } - node = this.getNodeFromIndex(parentIndex); // nodes between nodeIndex and parentIndex means non-ancestor nodes // Excludes all EMPTY/FULL variant post-gloas result.push(...this.getNodesBetween(nodeIndex, parentIndex).filter(this.isDefaultVariant)); @@ -1561,20 +1550,19 @@ export class ProtoArray { let nodeIndex = startIndex; while (node.parent !== undefined) { - ancestors.push(node); + // Only add default variants to ancestors + if (this.isDefaultVariant(node)) { + ancestors.push(node); + } - // Parent could be FULL/EMPTY, need to use `indices` to look up PENDING variant of parent - const pIndex = node.parent; - const parentRoot = this.getNodeFromIndex(pIndex).blockRoot; - const parentIndex = this.indices.get(parentRoot)?.[0]; + // Traverse to parent - may point to any variant + const parentIndex = node.parent; + node = this.getNodeFromIndex(parentIndex); - if (parentIndex === undefined) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INVALID_PARENT_INDEX, - index: pIndex, - }); + if (!this.isDefaultVariant(node)) { + // Parent is non-default variant, skip to next iteration to find default variant + continue; } - node = this.getNodeFromIndex(parentIndex); // Nodes between nodeIndex and parentIndex are non-ancestor nodes // Filter out all FULL/EMPTY variants post-gloas @@ -1582,7 +1570,10 @@ export class ProtoArray { nodeIndex = parentIndex; } - ancestors.push(node); + // Add final node if it's a default variant + if (this.isDefaultVariant(node)) { + ancestors.push(node); + } nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter(this.isDefaultVariant)); return {ancestors, nonAncestors}; @@ -1763,7 +1754,15 @@ export class ProtoArray { return result; } + /** + * Check if a node is a default variant (PENDING for Gloas, FULL for pre-Gloas) + * Determines this directly from the node's properties without looking up indices map + */ private isDefaultVariant = (node: ProtoNode): boolean => { - return node.payloadStatus === this.getDefaultVariant(node.blockRoot); + // For Gloas blocks (parentBlockHash !== null), default is PENDING + // For pre-Gloas blocks (parentBlockHash === null), default is FULL + return isGloasBlock(node) + ? node.payloadStatus === PayloadStatus.PENDING + : node.payloadStatus === PayloadStatus.FULL; }; } From eca79a3774858ac2c13b6b85c0e5fabddca5423d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:41:52 -0800 Subject: [PATCH 35/52] lint --- .../fork-choice/src/protoArray/protoArray.ts | 1 + .../test/unit/protoArray/gloas.test.ts | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index b2798d15d6bf..704080fa5852 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1132,6 +1132,7 @@ export class ProtoArray { let newChildAndDescendant: ChildAndDescendant; const bestChildIndex = parentNode.bestChild; + // biome-ignore lint/suspicious/noConfusingLabels: labeled block used for early exit from complex decision tree outer: { if (bestChildIndex !== undefined) { if (bestChildIndex === childIndex && !childLeadsToViableHead) { diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index 721cdad59b82..d722077c7823 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -532,8 +532,10 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, blockSlot); protoArray.onExecutionPayload("0x02", blockSlot); - const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY)!; - const fullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL)!; + const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY); + if (emptyIndex === undefined) throw new Error("Expected emptyIndex to exist"); + const fullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL); + if (fullIndex === undefined) throw new Error("Expected fullIndex to exist"); // Give EMPTY more weight than FULL const deltas = new Array(protoArray.length()).fill(0); @@ -571,8 +573,10 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(blockA, blockSlot); protoArray.onBlock(blockB, blockSlot); - const emptyAIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY)!; - const emptyBIndex = protoArray.getNodeIndexByRootAndStatus("0x03", PayloadStatus.EMPTY)!; + const emptyAIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY); + if (emptyAIndex === undefined) throw new Error("Expected emptyAIndex to exist"); + const emptyBIndex = protoArray.getNodeIndexByRootAndStatus("0x03", PayloadStatus.EMPTY); + if (emptyBIndex === undefined) throw new Error("Expected emptyBIndex to exist"); // Give A more votes than B // Note: Use nodes.length (not protoArray.length()) since Gloas blocks have multiple nodes per root @@ -605,8 +609,10 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, blockSlot); protoArray.onExecutionPayload("0x02", blockSlot); - const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY)!; - const fullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL)!; + const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY); + if (emptyIndex === undefined) throw new Error("Expected emptyIndex to exist"); + const fullIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL); + if (fullIndex === undefined) throw new Error("Expected fullIndex to exist"); const deltas = new Array(protoArray.length()).fill(0); deltas[emptyIndex] = 100; From 14eb5f7d82ba5845842e6732367ca6970e4e4224 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:39:07 -0800 Subject: [PATCH 36/52] Partially address @twoeths's comment --- .../fork-choice/src/forkChoice/forkChoice.ts | 35 +++++++++++++------ .../fork-choice/src/forkChoice/interface.ts | 10 ++++-- packages/fork-choice/src/protoArray/errors.ts | 4 ++- .../fork-choice/src/protoArray/protoArray.ts | 23 +++++++++--- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 724416dbb189..ed9cbe53d2d7 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -754,18 +754,25 @@ export class ForkChoice implements IForkChoice { unrealizedFinalizedEpoch: unrealizedFinalizedCheckpoint.epoch, unrealizedFinalizedRoot: unrealizedFinalizedCheckpoint.rootHex, - ...(isExecutionBlockBodyType(block.body) && isExecutionStateType(state) && isExecutionEnabled(state, block) + ...(isGloasBeaconBlock(block) ? { - executionPayloadBlockHash: toRootHex(block.body.executionPayload.blockHash), - executionPayloadNumber: block.body.executionPayload.blockNumber, - executionStatus: this.getPostMergeExecStatus(executionStatus), + executionPayloadBlockHash: toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash), // post-gloas, we don't know payload hash until we import execution payload. Set to parent payload hash for now + executionPayloadNumber: 0, // post-gloas, we don't know payload number until we import execution payload. Set to 0 for now + executionStatus: this.getPostMergeExecStatus(executionStatus), // TODO GLOAS: Need a new execution status to denote scenario where we are waiting for payload, or payload is never revealed. dataAvailabilityStatus, } - : { - executionPayloadBlockHash: null, - executionStatus: this.getPreMergeExecStatus(executionStatus), - dataAvailabilityStatus: this.getPreMergeDataStatus(dataAvailabilityStatus), - }), + : isExecutionBlockBodyType(block.body) && isExecutionStateType(state) && isExecutionEnabled(state, block) + ? { + executionPayloadBlockHash: toRootHex(block.body.executionPayload.blockHash), + executionPayloadNumber: block.body.executionPayload.blockNumber, + executionStatus: this.getPostMergeExecStatus(executionStatus), + dataAvailabilityStatus, + } + : { + executionPayloadBlockHash: null, + executionStatus: this.getPreMergeExecStatus(executionStatus), + dataAvailabilityStatus: this.getPreMergeDataStatus(dataAvailabilityStatus), + }), // Extract parentBlockHash for Gloas blocks (ePBS) // Spec: gloas/fork-choice.md#new-get_parent_payload_status @@ -902,8 +909,13 @@ export class ForkChoice implements IForkChoice { * Creates the FULL variant of a Gloas block when the payload becomes available * Spec: gloas/fork-choice.md#new-on_execution_payload */ - onExecutionPayload(blockRoot: RootHex): void { - this.protoArray.onExecutionPayload(blockRoot, this.fcStore.currentSlot); + onExecutionPayload(blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number): void { + this.protoArray.onExecutionPayload( + blockRoot, + this.fcStore.currentSlot, + executionPayloadBlockHash, + executionPayloadNumber + ); } /** @@ -1264,6 +1276,7 @@ export class ForkChoice implements IForkChoice { return block.parentRoot; } + // For the first slot of the epoch, a block is it's own target const nextRoot = block.blockRoot === block.targetRoot ? block.parentRoot : block.targetRoot; const defaultStatus = this.protoArray.getDefaultVariant(nextRoot); if (defaultStatus === undefined) { diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 682c11c70eb7..6728ccaf093b 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -4,7 +4,13 @@ import { EffectiveBalanceIncrements, } from "@lodestar/state-transition"; import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types"; -import {LVHExecResponse, MaybeValidExecutionStatus, ProtoBlock, ProtoNode} from "../protoArray/interface.js"; +import { + LVHExecResponse, + MaybeValidExecutionStatus, + PayloadStatus, + ProtoBlock, + ProtoNode, +} from "../protoArray/interface.js"; import {UpdateAndGetHeadOpt} from "./forkChoice.js"; import {CheckpointWithHex} from "./store.js"; @@ -194,7 +200,7 @@ export interface IForkChoice { * * @param blockRoot - The beacon block root for which the payload arrived */ - onExecutionPayload(blockRoot: RootHex): void; + onExecutionPayload(blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number): void; /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. */ diff --git a/packages/fork-choice/src/protoArray/errors.ts b/packages/fork-choice/src/protoArray/errors.ts index f6635874a2ca..fae807da058c 100644 --- a/packages/fork-choice/src/protoArray/errors.ts +++ b/packages/fork-choice/src/protoArray/errors.ts @@ -28,6 +28,7 @@ export enum ProtoArrayErrorCode { INVALID_BLOCK_EXECUTION_STATUS = "PROTO_ARRAY_INVALID_BLOCK_EXECUTION_STATUS", INVALID_JUSTIFIED_EXECUTION_STATUS = "PROTO_ARRAY_INVALID_JUSTIFIED_EXECUTION_STATUS", INVALID_LVH_EXECUTION_RESPONSE = "PROTO_ARRAY_INVALID_LVH_EXECUTION_RESPONSE", + PRE_GLOAS_BLOCK = "PROTO_ARRAY_ERROR_PRE_GLOAS_BLOCK", } export type ProtoArrayErrorType = @@ -56,6 +57,7 @@ export type ProtoArrayErrorType = } | {code: ProtoArrayErrorCode.INVALID_BLOCK_EXECUTION_STATUS; root: RootHex} | {code: ProtoArrayErrorCode.INVALID_JUSTIFIED_EXECUTION_STATUS; root: RootHex} - | ({code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE} & LVHExecError); + | ({code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE} & LVHExecError) + | {code: ProtoArrayErrorCode.PRE_GLOAS_BLOCK; root: RootHex}; export class ProtoArrayError extends LodestarError {} diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 704080fa5852..bdc078dc0e7b 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -190,8 +190,10 @@ export class ProtoArray { const parentVariants = this.indices.get(block.parentRoot); if (!parentVariants) { // Parent not found - // TODO GLOAS: verify this - return PayloadStatus.EMPTY; + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.UNKNOWN_BLOCK, + root: block.parentRoot, + }); } const parentIndex = parentVariants[0]; @@ -459,7 +461,12 @@ export class ProtoArray { * * Spec: gloas/fork-choice.md (on_execution_payload event) */ - onExecutionPayload(blockRoot: RootHex, currentSlot: Slot): void { + onExecutionPayload( + blockRoot: RootHex, + currentSlot: Slot, + executionPayloadBlockHash: RootHex, + executionPayloadNumber: number + ): void { // First check if block exists const variants = this.indices.get(blockRoot); if (!variants) { @@ -470,9 +477,12 @@ export class ProtoArray { }); } - // Pre-Gloas: variants[0] contains FULL, nothing to do if (variants.length === 1) { - return; + // Pre-gloas block should not be calling this method + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.PRE_GLOAS_BLOCK, + root: blockRoot, + }); } // Check if FULL already exists for Gloas blocks @@ -505,6 +515,9 @@ export class ProtoArray { weight: 0, bestChild: undefined, bestDescendant: undefined, + executionStatus: ExecutionStatus.Valid, // TODO GLOAS: Review execution status + executionPayloadBlockHash, + executionPayloadNumber, }; const fullIndex = this.nodes.length; From 818a2d9f0fcfee2fc211624b92419ac12751ed2e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:26:40 -0800 Subject: [PATCH 37/52] Update api --- .../src/api/impl/beacon/blocks/index.ts | 2 +- .../src/api/impl/validator/index.ts | 4 +- .../archiveStore/utils/updateBackfillRange.ts | 2 +- .../src/chain/blocks/importBlock.ts | 2 +- .../chain/blocks/verifyBlocksSanityChecks.ts | 2 +- packages/beacon-node/src/chain/chain.ts | 10 +-- .../opPools/aggregatedAttestationPool.ts | 2 +- .../beacon-node/src/chain/regen/queued.ts | 9 ++- packages/beacon-node/src/chain/regen/regen.ts | 14 +++- .../src/chain/validation/attestation.ts | 2 +- .../src/chain/validation/blobSidecar.ts | 4 +- .../beacon-node/src/chain/validation/block.ts | 4 +- .../src/chain/validation/dataColumnSidecar.ts | 2 +- .../reqresp/handlers/beaconBlocksByRoot.ts | 2 +- .../reqresp/handlers/blobSidecarsByRoot.ts | 2 +- .../handlers/dataColumnSidecarsByRoot.ts | 2 +- .../fork-choice/src/forkChoice/forkChoice.ts | 74 +++++++++++++++---- .../fork-choice/src/forkChoice/interface.ts | 17 ++++- .../fork-choice/src/protoArray/protoArray.ts | 4 +- 19 files changed, 115 insertions(+), 45 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 9748c88c50c7..43a172bf7008 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -191,7 +191,7 @@ export function getBeaconBlockApi({ case routes.beacon.BroadcastValidation.consensus: { // check if this beacon node produced the block else run validations if (!blockLocallyProduced) { - const parentBlock = chain.forkChoice.getBlock(signedBlock.message.parentRoot); + const parentBlock = chain.forkChoice.getBlockDefaultStatus(signedBlock.message.parentRoot); if (parentBlock === null) { chain.emitter.emit(ChainEvent.unknownParent, { blockInput: blockForImport, diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index f5ad6ef067f0..58512b8311e1 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -385,7 +385,7 @@ export function getValidatorApi( */ function notOnOptimisticBlockRoot(beaconBlockRoot: Root): void { - const protoBeaconBlock = chain.forkChoice.getBlock(beaconBlockRoot); + const protoBeaconBlock = chain.forkChoice.getBlockDefaultStatus(beaconBlockRoot); if (!protoBeaconBlock) { throw new ApiError(404, `Block not in forkChoice, beaconBlockRoot=${toRootHex(beaconBlockRoot)}`); } @@ -397,7 +397,7 @@ export function getValidatorApi( } function notOnOutOfRangeData(beaconBlockRoot: Root): void { - const protoBeaconBlock = chain.forkChoice.getBlock(beaconBlockRoot); + const protoBeaconBlock = chain.forkChoice.getBlockDefaultStatus(beaconBlockRoot); if (!protoBeaconBlock) { throw new ApiError(404, `Block not in forkChoice, beaconBlockRoot=${toRootHex(beaconBlockRoot)}`); } diff --git a/packages/beacon-node/src/chain/archiveStore/utils/updateBackfillRange.ts b/packages/beacon-node/src/chain/archiveStore/utils/updateBackfillRange.ts index 49ddc92b4941..1ae4d5540ace 100644 --- a/packages/beacon-node/src/chain/archiveStore/utils/updateBackfillRange.ts +++ b/packages/beacon-node/src/chain/archiveStore/utils/updateBackfillRange.ts @@ -20,7 +20,7 @@ export async function updateBackfillRange( try { // Mark the sequence in backfill db from finalized block's slot till anchor slot as // filled. - const finalizedBlockFC = chain.forkChoice.getBlockHex(finalized.rootHex); + const finalizedBlockFC = chain.forkChoice.getBlockHexDefaultStatus(finalized.rootHex); if (finalizedBlockFC && finalizedBlockFC.slot > chain.anchorStateLatestBlockSlot) { await db.backfilledRanges.put(finalizedBlockFC.slot, chain.anchorStateLatestBlockSlot); diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 8ac201e26bed..f334973e76db 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -440,7 +440,7 @@ export async function importBlock( this.metrics?.currentActiveValidators.set(activeValidatorsCount); this.metrics?.currentValidators.set({status: "active"}, activeValidatorsCount); - const parentBlockSummary = this.forkChoice.getBlock(checkpointState.latestBlockHeader.parentRoot); + const parentBlockSummary = this.forkChoice.getBlockDefaultStatus(checkpointState.latestBlockHeader.parentRoot); if (parentBlockSummary) { const justifiedCheckpoint = checkpointState.currentJustifiedCheckpoint; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts index 63e5c7b471c5..7d9b65f41042 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts @@ -90,7 +90,7 @@ export function verifyBlocksSanityChecks( } else { // When importing a block segment, only the first NON-IGNORED block must be known to the fork-choice. const parentRoot = toRootHex(block.message.parentRoot); - parentBlock = chain.forkChoice.getBlockHex(parentRoot); + parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (!parentBlock) { throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 8fb52cf57439..8fd57cf01cf9 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -565,7 +565,7 @@ export class BeaconChain implements IBeaconChain { ): Promise<{state: CachedBeaconStateAllForks | Uint8Array; executionOptimistic: boolean; finalized: boolean} | null> { if (opts?.allowRegen) { const state = await this.regen.getState(stateRoot, RegenCaller.restApi); - const block = this.forkChoice.getBlock(state.latestBlockHeader.hashTreeRoot()); + const block = this.forkChoice.getBlockDefaultStatus(state.latestBlockHeader.hashTreeRoot()); const finalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; return { state, @@ -581,7 +581,7 @@ export class BeaconChain implements IBeaconChain { // TODO: This is very inneficient for debug requests of serialized content, since it deserializes to serialize again const cachedStateCtx = this.regen.getStateSync(stateRoot); if (cachedStateCtx) { - const block = this.forkChoice.getBlock(cachedStateCtx.latestBlockHeader.hashTreeRoot()); + const block = this.forkChoice.getBlockDefaultStatus(cachedStateCtx.latestBlockHeader.hashTreeRoot()); const finalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; return { state: cachedStateCtx, @@ -615,7 +615,7 @@ export class BeaconChain implements IBeaconChain { // finalized or justified checkpoint states maynot be available with PersistentCheckpointStateCache, use getCheckpointStateOrBytes() api to get Uint8Array const cachedStateCtx = this.regen.getCheckpointStateSync(checkpoint); if (cachedStateCtx) { - const block = this.forkChoice.getBlock(cachedStateCtx.latestBlockHeader.hashTreeRoot()); + const block = this.forkChoice.getBlockDefaultStatus(cachedStateCtx.latestBlockHeader.hashTreeRoot()); const finalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; return { state: cachedStateCtx, @@ -632,7 +632,7 @@ export class BeaconChain implements IBeaconChain { ): Promise<{state: CachedBeaconStateAllForks | Uint8Array; executionOptimistic: boolean; finalized: boolean} | null> { const cachedStateCtx = await this.regen.getCheckpointStateOrBytes(checkpoint); if (cachedStateCtx) { - const block = this.forkChoice.getBlock(checkpoint.root); + const block = this.forkChoice.getBlockDefaultStatus(checkpoint.root); const finalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; return { state: cachedStateCtx, @@ -669,7 +669,7 @@ export class BeaconChain implements IBeaconChain { async getBlockByRoot( root: string ): Promise<{block: SignedBeaconBlock; executionOptimistic: boolean; finalized: boolean} | null> { - const block = this.forkChoice.getBlockHex(root); + const block = this.forkChoice.getBlockHexDefaultStatus(root); if (block) { const data = await this.db.block.get(fromHex(root)); if (data) { diff --git a/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts b/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts index 196b8d590fea..e19ec2dbe950 100644 --- a/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts +++ b/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts @@ -864,7 +864,7 @@ function isValidShuffling( // attestation's shuffling is the same as the current state's. // To account for skipped slots, find the first block at *or before* the pivot slot. const beaconBlockRootHex = blockRootHex; - const beaconBlock = forkChoice.getBlockHex(beaconBlockRootHex); + const beaconBlock = forkChoice.getBlockHexDefaultStatus(beaconBlockRootHex); if (!beaconBlock) { return InvalidAttestationData.BlockNotInForkChoice; } diff --git a/packages/beacon-node/src/chain/regen/queued.ts b/packages/beacon-node/src/chain/regen/queued.ts index fd5c6d7f0240..193b7abf5e50 100644 --- a/packages/beacon-node/src/chain/regen/queued.ts +++ b/packages/beacon-node/src/chain/regen/queued.ts @@ -1,7 +1,7 @@ import {routes} from "@lodestar/api"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; import {CachedBeaconStateAllForks, computeEpochAtSlot} from "@lodestar/state-transition"; -import {BeaconBlock, Epoch, RootHex, Slot, phase0} from "@lodestar/types"; +import {BeaconBlock, Epoch, RootHex, Slot, isGloasBeaconBlock, phase0} from "@lodestar/types"; import {Logger, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {JobItemQueue} from "../../util/queue/index.js"; @@ -89,7 +89,12 @@ export class QueuedStateRegenerator implements IStateRegenerator { */ getPreStateSync(block: BeaconBlock): CachedBeaconStateAllForks | null { const parentRoot = toRootHex(block.parentRoot); - const parentBlock = this.forkChoice.getBlockHex(parentRoot); + const parentBlock = isGloasBeaconBlock(block) + ? this.forkChoice.getBlockHexAndBlockHash( + parentRoot, + toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) + ) + : this.forkChoice.getBlockHexDefaultStatus(parentRoot); if (!parentBlock) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_FORKCHOICE, diff --git a/packages/beacon-node/src/chain/regen/regen.ts b/packages/beacon-node/src/chain/regen/regen.ts index 635b81b821b6..462bb671378c 100644 --- a/packages/beacon-node/src/chain/regen/regen.ts +++ b/packages/beacon-node/src/chain/regen/regen.ts @@ -11,7 +11,7 @@ import { processSlots, stateTransition, } from "@lodestar/state-transition"; -import {BeaconBlock, RootHex, SignedBeaconBlock, Slot, phase0} from "@lodestar/types"; +import {BeaconBlock, RootHex, SignedBeaconBlock, Slot, isGloasBeaconBlock, phase0} from "@lodestar/types"; import {Logger, fromHex, toRootHex} from "@lodestar/utils"; import {IBeaconDb} from "../../db/index.js"; import {Metrics} from "../../metrics/index.js"; @@ -56,7 +56,13 @@ export class StateRegenerator implements IStateRegeneratorInternal { opts: StateRegenerationOpts, regenCaller: RegenCaller ): Promise { - const parentBlock = this.modules.forkChoice.getBlock(block.parentRoot); + const parentRoot = toRootHex(block.parentRoot); + const parentBlock = isGloasBeaconBlock(block) + ? this.modules.forkChoice.getBlockHexAndBlockHash( + parentRoot, + toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) + ) + : this.modules.forkChoice.getBlockHexDefaultStatus(parentRoot); if (!parentBlock) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_FORKCHOICE, @@ -105,7 +111,9 @@ export class StateRegenerator implements IStateRegeneratorInternal { regenCaller: RegenCaller, allowDiskReload = false ): Promise { - const block = this.modules.forkChoice.getBlockHex(blockRoot); + // TODO GLOAS: This is wrong. We either need the caller to provide the variant or fork choice chooses the canonical variant + // Using getBlockHexDefaultStatus so the type is correct for now. And it doesnt break fulu + const block = this.modules.forkChoice.getBlockHexDefaultStatus(blockRoot); if (!block) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_FORKCHOICE, diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index 600d8b5ac15d..7fc917d536f2 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -736,7 +736,7 @@ export function getAttestationDataSigningRoot(config: BeaconConfig, data: phase0 function verifyHeadBlockIsKnown(chain: IBeaconChain, beaconBlockRoot: Root): ProtoBlock { // TODO (LH): Enforce a maximum skip distance for unaggregated attestations. - const headBlock = chain.forkChoice.getBlock(beaconBlockRoot); + const headBlock = chain.forkChoice.getBlockDefaultStatus(beaconBlockRoot); if (headBlock === null) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT, diff --git a/packages/beacon-node/src/chain/validation/blobSidecar.ts b/packages/beacon-node/src/chain/validation/blobSidecar.ts index b7f507d5fbca..109b03557b3b 100644 --- a/packages/beacon-node/src/chain/validation/blobSidecar.ts +++ b/packages/beacon-node/src/chain/validation/blobSidecar.ts @@ -78,7 +78,7 @@ export async function validateGossipBlobSidecar( // already know this block. const blockRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blobSidecar.signedBlockHeader.message); const blockHex = toRootHex(blockRoot); - if (chain.forkChoice.getBlockHex(blockHex) !== null) { + if (chain.forkChoice.getBlockHexDefaultStatus(blockHex) !== null) { throw new BlobSidecarGossipError(GossipAction.IGNORE, {code: BlobSidecarErrorCode.ALREADY_KNOWN, root: blockHex}); } @@ -89,7 +89,7 @@ export async function validateGossipBlobSidecar( // gossip and non-gossip sources) (a client MAY queue blocks for processing once the parent block is // retrieved). const parentRoot = toRootHex(blobSidecar.signedBlockHeader.message.parentRoot); - const parentBlock = chain.forkChoice.getBlockHex(parentRoot); + const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (parentBlock === null) { // If fork choice does *not* consider the parent to be a descendant of the finalized block, // then there are two more cases: diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 693c689b7edf..6d07629089c5 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -55,7 +55,7 @@ export async function validateGossipBlock( // check, we will load the parent and state from disk only to find out later that we // already know this block. const blockRoot = toRootHex(config.getForkTypes(blockSlot).BeaconBlock.hashTreeRoot(block)); - if (chain.forkChoice.getBlockHex(blockRoot) !== null) { + if (chain.forkChoice.getBlockHexDefaultStatus(blockRoot) !== null) { throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot}); } @@ -71,7 +71,7 @@ export async function validateGossipBlock( // [REJECT] The current finalized_checkpoint is an ancestor of block -- i.e. // get_ancestor(store, block.parent_root, compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)) == store.finalized_checkpoint.root const parentRoot = toRootHex(block.parentRoot); - const parentBlock = chain.forkChoice.getBlockHex(parentRoot); + const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (parentBlock === null) { // If fork choice does *not* consider the parent to be a descendant of the finalized block, // then there are two more cases: diff --git a/packages/beacon-node/src/chain/validation/dataColumnSidecar.ts b/packages/beacon-node/src/chain/validation/dataColumnSidecar.ts index e20565863699..59236e2219e9 100644 --- a/packages/beacon-node/src/chain/validation/dataColumnSidecar.ts +++ b/packages/beacon-node/src/chain/validation/dataColumnSidecar.ts @@ -73,7 +73,7 @@ export async function validateGossipDataColumnSidecar( // 6) [IGNORE] The sidecar's block's parent (defined by block_header.parent_root) has been seen (via gossip // or non-gossip sources) const parentRoot = toRootHex(blockHeader.parentRoot); - const parentBlock = chain.forkChoice.getBlockHex(parentRoot); + const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (parentBlock === null) { // If fork choice does *not* consider the parent to be a descendant of the finalized block, // then there are two more cases: diff --git a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByRoot.ts b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByRoot.ts index 211c03495809..46a4fd825e07 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByRoot.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByRoot.ts @@ -14,7 +14,7 @@ export async function* onBeaconBlocksByRoot( ): AsyncIterable { for (const blockRoot of requestBody) { const root = blockRoot; - const summary = chain.forkChoice.getBlock(root); + const summary = chain.forkChoice.getBlockDefaultStatus(root); let blockBytes: Uint8Array | null = null; // finalized block has summary in forkchoice but it stays in blockArchive db diff --git a/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts b/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts index 6416520a3ef6..515973e870aa 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts @@ -23,7 +23,7 @@ export async function* onBlobSidecarsByRoot( for (const blobIdentifier of requestBody) { const {blockRoot, index} = blobIdentifier; const blockRootHex = toRootHex(blockRoot); - const block = chain.forkChoice.getBlockHex(blockRootHex); + const block = chain.forkChoice.getBlockHexDefaultStatus(blockRootHex); // NOTE: Only support non-finalized blocks. // SPEC: Clients MUST support requesting blocks and sidecars since the latest finalized epoch. diff --git a/packages/beacon-node/src/network/reqresp/handlers/dataColumnSidecarsByRoot.ts b/packages/beacon-node/src/network/reqresp/handlers/dataColumnSidecarsByRoot.ts index 24c88fd499d9..16e4f865b7be 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/dataColumnSidecarsByRoot.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/dataColumnSidecarsByRoot.ts @@ -34,7 +34,7 @@ export async function* onDataColumnSidecarsByRoot( } const blockRootHex = toRootHex(blockRoot); - const block = chain.forkChoice.getBlockHex(blockRootHex); + const block = chain.forkChoice.getBlockHexDefaultStatus(blockRootHex); // If the block is not in fork choice, it may be finalized. Attempt to find its slot in block archive const slot = block ? block.slot : await db.blockArchive.getSlotByRoot(blockRoot); diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index ed9cbe53d2d7..74343911d095 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -838,7 +838,7 @@ export class ForkChoice implements IForkChoice { // We need to retrieve block to check if it's Gloas and to compare slot // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-is_supporting_vote - const block = this.getBlockHex(blockRootHex); + const block = this.getBlockHexDefaultStatus(blockRootHex); if (block && isGloasBlock(block)) { // Post-Gloas block: determine FULL/EMPTY/PENDING based on slot and committee index @@ -908,13 +908,21 @@ export class ForkChoice implements IForkChoice { * Notify fork choice that an execution payload has arrived (Gloas fork) * Creates the FULL variant of a Gloas block when the payload becomes available * Spec: gloas/fork-choice.md#new-on_execution_payload + * + * */ - onExecutionPayload(blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number): void { + onExecutionPayload( + blockRoot: RootHex, + executionPayloadBlockHash: RootHex, + executionPayloadNumber: number, + executionPayloadStateRoot: RootHex + ): void { this.protoArray.onExecutionPayload( blockRoot, this.fcStore.currentSlot, executionPayloadBlockHash, - executionPayloadNumber + executionPayloadNumber, + executionPayloadStateRoot ); } @@ -949,8 +957,12 @@ export class ForkChoice implements IForkChoice { return this.hasBlockHex(toRootHex(blockRoot)); } /** Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ - getBlock(blockRoot: Root): ProtoBlock | null { - return this.getBlockHex(toRootHex(blockRoot)); + getBlock(blockRoot: Root, payloadStatus: PayloadStatus): ProtoBlock | null { + return this.getBlockHex(toRootHex(blockRoot), payloadStatus); + } + + getBlockDefaultStatus(blockRoot: Root): ProtoBlock | null { + return this.getBlockHexDefaultStatus(toRootHex(blockRoot)); } /** @@ -984,12 +996,8 @@ export class ForkChoice implements IForkChoice { /** * Returns a MUTABLE `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ - getBlockHex(blockRoot: RootHex): ProtoBlock | null { - const defaultStatus = this.protoArray.getDefaultVariant(blockRoot); - if (defaultStatus === undefined) { - return null; - } - const node = this.protoArray.getNode(blockRoot, defaultStatus); + getBlockHex(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock | null { + const node = this.protoArray.getNode(blockRoot, payloadStatus); if (!node) { return null; } @@ -1003,9 +1011,41 @@ export class ForkChoice implements IForkChoice { }; } + getBlockHexDefaultStatus(blockRoot: RootHex): ProtoBlock | null { + const defaultStatus = this.protoArray.getDefaultVariant(blockRoot); + if (defaultStatus === undefined) { + return null; + } + + return this.getBlockHex(blockRoot, defaultStatus); + } + + /** + * Returns a `ProtoBlock` that has matching block root and block hash + */ + getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null { + const variantIndices = this.protoArray.indices.get(blockRoot); + if (variantIndices === undefined) { + return null; + } + + for (const variantIndex of variantIndices) { + const node = this.protoArray.nodes[variantIndex]; + if (node.executionPayloadBlockHash === blockHash) { + return node; + } + } + + return null; + } + getJustifiedBlock(): ProtoBlock { - const rootHex = this.fcStore.justified.checkpoint.rootHex; - const block = this.getBlockHex(rootHex); + const {rootHex, epoch} = this.fcStore.justified.checkpoint; + // Checkpoints for pre-gloas should be FULL variant, while post-gloas should be EMPTY variant + const block = this.getBlockHex( + rootHex, + epoch >= this.config.GLOAS_FORK_EPOCH ? PayloadStatus.EMPTY : PayloadStatus.FULL + ); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, @@ -1016,8 +1056,12 @@ export class ForkChoice implements IForkChoice { } getFinalizedBlock(): ProtoBlock { - const rootHex = this.fcStore.finalizedCheckpoint.rootHex; - const block = this.getBlockHex(rootHex); + const {rootHex, epoch} = this.fcStore.finalizedCheckpoint; + // Checkpoints for pre-gloas should be FULL variant, while post-gloas should be EMPTY variant + const block = this.getBlockHex( + rootHex, + epoch >= this.config.GLOAS_FORK_EPOCH ? PayloadStatus.EMPTY : PayloadStatus.FULL + ); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 6728ccaf093b..d036878ea536 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -199,8 +199,16 @@ export interface IForkChoice { * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/fork-choice.md#new-on_execution_payload * * @param blockRoot - The beacon block root for which the payload arrived + * @param executionPayloadBlockHash - The block hash of the execution payload + * @param executionPayloadNumber - The block number of the execution payload + * @param executionPayloadStateRoot - The execution payload state root ie. the root of post-state after processExecutionPayloadEnvelope() */ - onExecutionPayload(blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number): void; + onExecutionPayload( + blockRoot: RootHex, + executionPayloadBlockHash: RootHex, + executionPayloadNumber: number, + executionPayloadStateRoot: RootHex + ): void; /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. */ @@ -224,8 +232,11 @@ export interface IForkChoice { /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ - getBlock(blockRoot: Root): ProtoBlock | null; - getBlockHex(blockRoot: RootHex): ProtoBlock | null; + getBlock(blockRoot: Root, payloadStatus: PayloadStatus): ProtoBlock | null; + getBlockHex(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock | null; + getBlockDefaultStatus(blockRoot: Root): ProtoBlock | null; + getBlockHexDefaultStatus(blockRoot: RootHex): ProtoBlock | null; + getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null; getFinalizedBlock(): ProtoBlock; getJustifiedBlock(): ProtoBlock; getFinalizedCheckpointSlot(): Slot; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index bdc078dc0e7b..703ed0492351 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -465,7 +465,8 @@ export class ProtoArray { blockRoot: RootHex, currentSlot: Slot, executionPayloadBlockHash: RootHex, - executionPayloadNumber: number + executionPayloadNumber: number, + executionPayloadStateRoot: RootHex ): void { // First check if block exists const variants = this.indices.get(blockRoot); @@ -518,6 +519,7 @@ export class ProtoArray { executionStatus: ExecutionStatus.Valid, // TODO GLOAS: Review execution status executionPayloadBlockHash, executionPayloadNumber, + stateRoot: executionPayloadStateRoot, }; const fullIndex = this.nodes.length; From e39ae1187d479f98087c5fb347ef37fc64e88659 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:34:53 -0800 Subject: [PATCH 38/52] Fix check-types --- .../unit/chain/forkChoice/forkChoice.test.ts | 8 +++--- .../test/perf/forkChoice/updateHead.test.ts | 2 +- .../test/unit/forkChoice/forkChoice.test.ts | 2 +- .../test/unit/protoArray/gloas.test.ts | 26 +++++++++---------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts index f35e7be05188..b9ca0f658226 100644 --- a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts @@ -202,8 +202,8 @@ describe("LodestarForkChoice", () => { forkChoice.onBlock(block28.message, state28, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); expect(forkChoice.getAllAncestorBlocks(hashBlock(block16.message))).toHaveLength(3); expect(forkChoice.getAllAncestorBlocks(hashBlock(block24.message))).toHaveLength(5); - expect(forkChoice.getBlockHex(hashBlock(block08.message))).not.toBeNull(); - expect(forkChoice.getBlockHex(hashBlock(block12.message))).not.toBeNull(); + expect(forkChoice.getBlockHexDefaultStatus(hashBlock(block08.message))).not.toBeNull(); + expect(forkChoice.getBlockHexDefaultStatus(hashBlock(block12.message))).not.toBeNull(); expect(forkChoice.hasBlockHex(hashBlock(block08.message))).toBe(true); expect(forkChoice.hasBlockHex(hashBlock(block12.message))).toBe(true); forkChoice.onBlock(block32.message, state32, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); @@ -213,8 +213,8 @@ describe("LodestarForkChoice", () => { "getAllAncestorBlocks should not return finalized block" ); expect(forkChoice.getAllAncestorBlocks(hashBlock(block24.message))).toHaveLength(2); - expect(forkChoice.getBlockHex(hashBlock(block08.message))).toBe(null); - expect(forkChoice.getBlockHex(hashBlock(block12.message))).toBe(null); + expect(forkChoice.getBlockHexDefaultStatus(hashBlock(block08.message))).toBe(null); + expect(forkChoice.getBlockHexDefaultStatus(hashBlock(block12.message))).toBe(null); expect(forkChoice.hasBlockHex(hashBlock(block08.message))).toBe(false); expect(forkChoice.hasBlockHex(hashBlock(block12.message))).toBe(false); }); diff --git a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts index 54f9c9d53070..60c18d7d73fc 100644 --- a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts +++ b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts @@ -29,7 +29,7 @@ describe("forkchoice updateHead", () => { const forkChoice = initializeForkChoice(opts); const vote1 = forkChoice.updateHead(); - const vote2 = forkChoice.getBlockHex(vote1.parentRoot); + const vote2 = forkChoice.getBlockHexDefaultStatus(vote1.parentRoot); if (!vote2) throw Error("no vote2"); if (vote1.blockRoot === vote2.blockRoot) throw Error("blockRoot vote1 == vote2"); if (vote1.slot === vote2.slot) throw Error("slot vote1 == vote2"); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index b1940645a480..f65116f12d94 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -209,7 +209,7 @@ describe("Forkchoice", () => { const forkchoice = new ForkChoice(config, fcStore, protoArr, validatorCount, null); const blockRoot = getBlockRoot(atSlot); - const block = forkchoice.getBlockHex(blockRoot); + const block = forkchoice.getBlockHexDefaultStatus(blockRoot); if (!block) throw Error(`No block for blockRoot ${blockRoot}`); const expectedDependentRoot = getBlockRoot(pivotSlot); diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index d722077c7823..3ee9f20c50f8 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -291,7 +291,7 @@ describe("Gloas Fork Choice", () => { expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeUndefined(); // Call onExecutionPayload - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); // FULL should now exist const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); @@ -303,7 +303,7 @@ describe("Gloas Fork Choice", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot); - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); @@ -315,8 +315,8 @@ describe("Gloas Fork Choice", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot); - protoArray.onExecutionPayload("0x02", gloasForkSlot); - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); // Should still only have one FULL node const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); @@ -331,14 +331,14 @@ describe("Gloas Fork Choice", () => { expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); // Calling onExecutionPayload should be no-op - protoArray.onExecutionPayload("0x02", gloasForkSlot - 1); + protoArray.onExecutionPayload("0x02", gloasForkSlot - 1, "0x02", gloasForkSlot - 1, stateRoot); // Still just one FULL node expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); }); it("throws for unknown block", () => { - expect(() => protoArray.onExecutionPayload("0x99", gloasForkSlot)).toThrow(); + expect(() => protoArray.onExecutionPayload("0x99", gloasForkSlot, "0x99", gloasForkSlot, stateRoot)).toThrow(); }); }); @@ -402,7 +402,7 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, gloasForkSlot); // Make execution payload available by creating FULL variant - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); // Vote yes from majority of PTC (>50%) const threshold = Math.floor(PTC_SIZE / 2) + 1; @@ -418,7 +418,7 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, gloasForkSlot); // Make execution payload available by creating FULL variant - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); // Vote yes from exactly 50% (not >50%) const threshold = Math.floor(PTC_SIZE / 2); @@ -434,7 +434,7 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, gloasForkSlot); // Make execution payload available by creating FULL variant - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); // Vote mixed yes/no const threshold = Math.floor(PTC_SIZE / 2) + 1; @@ -487,7 +487,7 @@ describe("Gloas Fork Choice", () => { it("intra-block: EMPTY/FULL variants have PENDING as parent", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot); - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); @@ -501,7 +501,7 @@ describe("Gloas Fork Choice", () => { // Block A const blockA = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(blockA, gloasForkSlot); - protoArray.onExecutionPayload("0x02", gloasForkSlot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); // Block B extends A's FULL (parentBlockHash matches) const blockB = createTestBlock(gloasForkSlot + 1, "0x03", "0x02", "0x02"); @@ -530,7 +530,7 @@ describe("Gloas Fork Choice", () => { const blockSlot = gloasForkSlot + 10; const block = createTestBlock(blockSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, blockSlot); - protoArray.onExecutionPayload("0x02", blockSlot); + protoArray.onExecutionPayload("0x02", blockSlot, "0x02", blockSlot, stateRoot); const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY); if (emptyIndex === undefined) throw new Error("Expected emptyIndex to exist"); @@ -607,7 +607,7 @@ describe("Gloas Fork Choice", () => { const blockSlot = gloasForkSlot + 10; const block = createTestBlock(blockSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, blockSlot); - protoArray.onExecutionPayload("0x02", blockSlot); + protoArray.onExecutionPayload("0x02", blockSlot, "0x02", blockSlot, stateRoot); const emptyIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.EMPTY); if (emptyIndex === undefined) throw new Error("Expected emptyIndex to exist"); From ee1cc32ac438b4f152d362623c22d22acc231b70 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:50:05 -0800 Subject: [PATCH 39/52] Fix unit tests --- packages/fork-choice/test/unit/protoArray/gloas.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index 3ee9f20c50f8..97f75bc2e87e 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -323,18 +323,15 @@ describe("Gloas Fork Choice", () => { expect(fullNode).toBeDefined(); }); - it("does nothing for pre-Gloas blocks", () => { + it("throws for pre-Gloas blocks", () => { const block = createTestBlock(gloasForkSlot - 1, "0x02", genesisRoot); protoArray.onBlock(block, gloasForkSlot - 1); // Pre-Gloas block already has FULL expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); - // Calling onExecutionPayload should be no-op - protoArray.onExecutionPayload("0x02", gloasForkSlot - 1, "0x02", gloasForkSlot - 1, stateRoot); - - // Still just one FULL node - expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); + // Calling onExecutionPayload should throw for pre-Gloas blocks + expect(() => protoArray.onExecutionPayload("0x02", gloasForkSlot - 1, "0x02", gloasForkSlot - 1, stateRoot)).toThrow(); }); it("throws for unknown block", () => { From 638d15fe8251ce8ac59723e79a1f956e871e3ece Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:56:45 -0800 Subject: [PATCH 40/52] lint --- packages/fork-choice/test/unit/protoArray/gloas.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index 97f75bc2e87e..dae45b4f2306 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -331,7 +331,9 @@ describe("Gloas Fork Choice", () => { expect(getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL)).toBeDefined(); // Calling onExecutionPayload should throw for pre-Gloas blocks - expect(() => protoArray.onExecutionPayload("0x02", gloasForkSlot - 1, "0x02", gloasForkSlot - 1, stateRoot)).toThrow(); + expect(() => + protoArray.onExecutionPayload("0x02", gloasForkSlot - 1, "0x02", gloasForkSlot - 1, stateRoot) + ).toThrow(); }); it("throws for unknown block", () => { From acf48ee9f3e31cd6a9b5974382ac3b48e1569222 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:32:39 -0800 Subject: [PATCH 41/52] Fix tests --- .../test/mocks/mockedBeaconChain.ts | 3 ++ .../opPools/aggregatedAttestationPool.test.ts | 2 +- .../api/impl/validator/produceBlockV3.test.ts | 6 ++- .../blocks/verifyBlocksSanityChecks.test.ts | 6 +-- .../test/unit/chain/validation/block.test.ts | 40 +++++++++---------- .../test/utils/validationData/attestation.ts | 12 ++++++ 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 5b049e2a1f39..036429439098 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -52,6 +52,9 @@ vi.mock("@lodestar/fork-choice", async (importActual) => { getDependentRoot: vi.fn(), getBlockHex: vi.fn(), getBlock: vi.fn(), + getBlockDefaultStatus: vi.fn(), + getBlockHexDefaultStatus: vi.fn(), + getBlockHexAndBlockHash: vi.fn(), getAllAncestorBlocks: vi.fn(), getAllNonAncestorBlocks: vi.fn(), getAllAncestorAndNonAncestorBlocks: vi.fn(), diff --git a/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts index e9d58fa1145b..9ad1f66de838 100644 --- a/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts @@ -246,7 +246,7 @@ describe("AggregatedAttestationPool - get packed attestations - Electra", () => } } - forkchoiceStub.getBlockHex.mockReturnValue(generateProtoBlock()); + forkchoiceStub.getBlockHexDefaultStatus.mockReturnValue(generateProtoBlock()); forkchoiceStub.getDependentRoot.mockReturnValue(ZERO_HASH_HEX); const shufflingCache = new ShufflingCache(); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index 773e7930e42c..a4c182a5b83e 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -96,7 +96,7 @@ describe("api/validator - produceBlockV3", () => { blockRoot: toHexString(fullBlock.parentRoot), } as ProtoBlock); modules.chain.getProposerHead.mockReturnValue({blockRoot: toHexString(fullBlock.parentRoot)} as ProtoBlock); - modules.chain.forkChoice.getBlock.mockReturnValue(zeroProtoBlock); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(zeroProtoBlock); modules.chain.produceCommonBlockBody.mockResolvedValue({ attestations: fullBlock.body.attestations, attesterSlashings: fullBlock.body.attesterSlashings, @@ -188,7 +188,9 @@ describe("api/validator - produceBlockV3", () => { modules.chain.recomputeForkChoiceHead.mockReturnValue( generateProtoBlock({blockRoot: toHexString(parentBlockRoot)}) ); - modules.chain.forkChoice.getBlock.mockReturnValue(generateProtoBlock({blockRoot: toHexString(parentBlockRoot)})); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue( + generateProtoBlock({blockRoot: toHexString(parentBlockRoot)}) + ); modules.chain.produceBlock.mockResolvedValue({ block: fullBlock, executionPayloadValue, diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts index 48a83f18916b..6298c7e5e48e 100644 --- a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts @@ -29,11 +29,11 @@ describe("chain / blocks / verifyBlocksSanityChecks", () => { clock = new ClockStopped(currentSlot); modules = {config, forkChoice, clock, opts: {} as IChainOptions, blacklistedBlocks: new Map()}; // On first call, parentRoot is known - forkChoice.getBlockHex.mockReturnValue({} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValue({} as ProtoBlock); }); it("PARENT_UNKNOWN", () => { - forkChoice.getBlockHex.mockReturnValue(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.PARENT_UNKNOWN); }); @@ -180,7 +180,7 @@ function getForkChoice(knownBlocks: SignedBeaconBlock[], finalizedEpoch = 0): IF } return { - getBlockHex(blockRoot) { + getBlockHexDefaultStatus(blockRoot) { return blocks.get(blockRoot) ?? null; }, hasBlockHex(blockRoot) { diff --git a/packages/beacon-node/test/unit/chain/validation/block.test.ts b/packages/beacon-node/test/unit/chain/validation/block.test.ts index 29defe4c28fc..0139d2e59a00 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -38,7 +38,7 @@ describe("gossip block validation", () => { chain = getMockedBeaconChain({config}); vi.spyOn(chain.clock, "currentSlotWithGossipDisparity", "get").mockReturnValue(clockSlot); forkChoice = chain.forkChoice; - forkChoice.getBlockHex.mockReturnValue(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); chain.forkChoice = forkChoice; regen = chain.regen; @@ -80,7 +80,7 @@ describe("gossip block validation", () => { it("ALREADY_KNOWN", async () => { // Make the fork choice return a block summary for the proposed block - forkChoice.getBlockHex.mockReturnValue({} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValue({} as ProtoBlock); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, job, ForkName.phase0), @@ -100,9 +100,9 @@ describe("gossip block validation", () => { it("PARENT_UNKNOWN (fork-choice)", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Return not known for parent block too - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, job, ForkName.phase0), @@ -112,9 +112,9 @@ describe("gossip block validation", () => { it("TOO_MANY_SKIPPED_SLOTS", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Return parent block with 1 slot way back than maxSkipSlots - forkChoice.getBlockHex.mockReturnValueOnce({slot: block.slot - (maxSkipSlots + 1)} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: block.slot - (maxSkipSlots + 1)} as ProtoBlock); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, job, ForkName.phase0), @@ -124,9 +124,9 @@ describe("gossip block validation", () => { it("NOT_LATER_THAN_PARENT", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot + 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot + 1} as ProtoBlock); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, job, ForkName.phase0), @@ -136,9 +136,9 @@ describe("gossip block validation", () => { it("PARENT_UNKNOWN (regen)", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); // Regen not able to get the parent block state regen.getPreState.mockRejectedValue(undefined); @@ -150,9 +150,9 @@ describe("gossip block validation", () => { it("PROPOSAL_SIGNATURE_INVALID", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); // Regen returns some state regen.getPreState.mockResolvedValue(generateCachedState()); // BLS signature verifier returns invalid @@ -166,9 +166,9 @@ describe("gossip block validation", () => { it("INCORRECT_PROPOSER", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); // Regen returns some state const state = generateCachedState(); regen.getPreState.mockResolvedValue(state); @@ -185,9 +185,9 @@ describe("gossip block validation", () => { it("valid", async () => { // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); // Regen returns some state const state = generateCachedState(); regen.getPreState.mockResolvedValue(state); @@ -206,9 +206,9 @@ describe("gossip block validation", () => { () => new Uint8Array([0]) ); // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); // Regen returns some state const state = generateCachedState(); regen.getPreState.mockResolvedValue(state); @@ -232,9 +232,9 @@ describe("gossip block validation", () => { () => new Uint8Array([0]) ); // Return not known for proposed block - forkChoice.getBlockHex.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); // Returned parent block is latter than proposed block - forkChoice.getBlockHex.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); // Regen returns some state const state = generateCachedState(); regen.getPreState.mockResolvedValue(state); diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index bce6a0f4bdaf..eaf0f43b6503 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -108,6 +108,18 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { if (rootHex !== toHexString(beaconBlockRoot)) return null; return headBlock; }, + getBlockDefaultStatus: (root) => { + if (!ssz.Root.equals(root, beaconBlockRoot)) return null; + return headBlock; + }, + getBlockHexDefaultStatus: (rootHex) => { + if (rootHex !== toHexString(beaconBlockRoot)) return null; + return headBlock; + }, + getBlockHexAndBlockHash: (rootHex) => { + if (rootHex !== toHexString(beaconBlockRoot)) return null; + return headBlock; + }, getDependentRoot: () => state.epochCtx.currentDecisionRoot, } as Partial as IForkChoice; From aa7d204d19314ad82106f6ddf37fca2b817df12c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:34:46 -0800 Subject: [PATCH 42/52] Fix merge --- packages/beacon-node/src/chain/chain.ts | 2 +- .../src/chain/errors/executionPayloadEnvelope.ts | 4 ++-- packages/beacon-node/src/chain/forkChoice/index.ts | 4 ++-- .../beacon-node/src/chain/validation/aggregateAndProof.ts | 2 +- packages/beacon-node/src/chain/validation/attestation.ts | 2 +- .../src/chain/validation/executionPayloadBid.ts | 3 +-- .../src/chain/validation/executionPayloadEnvelope.ts | 8 ++++---- .../src/chain/validation/payloadAttestationMessage.ts | 3 +-- packages/fork-choice/src/forkChoice/forkChoice.ts | 2 +- packages/fork-choice/src/protoArray/interface.ts | 6 +++--- 10 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 003627eaf68d..5998dac7eecf 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -738,7 +738,7 @@ export class BeaconChain implements IBeaconChain { async getSerializedBlockByRoot( root: string ): Promise<{block: Uint8Array; executionOptimistic: boolean; finalized: boolean; slot: Slot} | null> { - const block = this.forkChoice.getBlockHex(root); + const block = this.forkChoice.getBlockHexDefaultStatus(root); if (block) { // Block found in fork-choice. // It may be in the block input cache, awaiting full DA reconstruction, check there first diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index 051f07e04ba1..1f3cd1a94b22 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -25,9 +25,9 @@ export type ExecutionPayloadEnvelopeErrorType = | { code: ExecutionPayloadEnvelopeErrorCode.BUILDER_INDEX_MISMATCH; envelopeBuilderIndex: BuilderIndex; - bidBuilderIndex: BuilderIndex; + bidBuilderIndex: BuilderIndex | null; } - | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; envelopeBlockHash: RootHex; bidBlockHash: RootHex} + | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; envelopeBlockHash: RootHex; bidBlockHash: RootHex | null} | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} | {code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL; blockRoot: RootHex}; diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index a0096ca53ef7..2e37024bd1c4 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -150,7 +150,7 @@ export function initializeForkChoiceFromFinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? builderIndex: isForkPostGloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null, - blockHashHex: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) : null, + blockHashFromBid: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) : null, parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, }, currentSlot @@ -237,7 +237,7 @@ export function initializeForkChoiceFromUnfinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? builderIndex: isForkPostGloas ? (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null, - blockHashHex: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) : null, + blockHashFromBid: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) : null, parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null, }; diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index b3c638b76d74..63f728862854 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -81,7 +81,7 @@ async function validateAggregateAndProof( }); } // [REJECT] `aggregate.data.index == 0` if `block.slot == aggregate.data.slot`. - const block = chain.forkChoice.getBlock(attData.beaconBlockRoot); + const block = chain.forkChoice.getBlockDefaultStatus(attData.beaconBlockRoot); // If block is unknown, we don't handle it here. It will throw error later on at `verifyHeadBlockAndTargetRoot()` if (block !== null && block.slot === attData.slot && attData.index !== 0) { diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index 8923724e6a4b..e98404ab73b7 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -304,7 +304,7 @@ async function validateAttestationNoSignatureCheck( } // [REJECT] `attestation.data.index == 0` if `block.slot == attestation.data.slot`. - const block = chain.forkChoice.getBlock(attData.beaconBlockRoot); + const block = chain.forkChoice.getBlockDefaultStatus(attData.beaconBlockRoot); // block being null will be handled by `verifyHeadBlockAndTargetRoot` if (block !== null && block.slot === attSlot && attData.index !== 0) { diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index e0e570443334..e4d579a25e4f 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -112,8 +112,7 @@ async function validateExecutionPayloadBid( // [IGNORE] `bid.parent_block_root` is the hash tree root of a known beacon // block in fork choice. - const block = chain.forkChoice.getBlock(bid.parentBlockRoot); - if (block === null) { + if (!chain.forkChoice.hasBlock(bid.parentBlockRoot)) { throw new ExecutionPayloadBidError(GossipAction.IGNORE, { code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT, parentBlockRoot: parentBlockRootHex, diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 9891ed54f647..90fee94b3832 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -36,7 +36,7 @@ async function validateExecutionPayloadEnvelope( // gossip or non-gossip sources) (a client MAY queue payload for processing once // the block is retrieved). // TODO GLOAS: Need to review this - const block = chain.forkChoice.getBlock(envelope.beaconBlockRoot); + const block = chain.forkChoice.getBlockDefaultStatus(envelope.beaconBlockRoot); if (block === null) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN, @@ -78,7 +78,7 @@ async function validateExecutionPayloadEnvelope( }); } - if (block.builderIndex === undefined || block.blockHashHex === undefined) { + if (block.builderIndex === undefined || block.blockHashFromBid === undefined) { // This indicates this block is a pre-gloas block which is wrong throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL, @@ -96,11 +96,11 @@ async function validateExecutionPayloadEnvelope( } // [REJECT] `payload.block_hash == bid.block_hash` - if (toRootHex(payload.blockHash) !== block.blockHashHex) { + if (toRootHex(payload.blockHash) !== block.blockHashFromBid) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, envelopeBlockHash: toRootHex(payload.blockHash), - bidBlockHash: block.blockHashHex, + bidBlockHash: block.blockHashFromBid, }); } diff --git a/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts b/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts index ca4d6f99014f..7ab88c9eda2e 100644 --- a/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts +++ b/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts @@ -59,8 +59,7 @@ async function validatePayloadAttestationMessage( // [IGNORE] The message's block `data.beacon_block_root` has been seen (via // gossip or non-gossip sources) (a client MAY queue attestation for processing // once the block is retrieved. Note a client might want to request payload after). - const block = chain.forkChoice.getBlock(data.beaconBlockRoot); - if (block === null) { + if (!chain.forkChoice.hasBlock(data.beaconBlockRoot)) { throw new PayloadAttestationError(GossipAction.IGNORE, { code: PayloadAttestationErrorCode.UNKNOWN_BLOCK_ROOT, blockRoot: toRootHex(data.beaconBlockRoot), diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 39e3fa6b5eff..7c353aa55ad4 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -776,7 +776,7 @@ export class ForkChoice implements IForkChoice { payloadStatus: isGloasBeaconBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL, builderIndex: isGloasBeaconBlock(block) ? block.body.signedExecutionPayloadBid.message.builderIndex : null, - blockHashHex: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) : null, + blockHashFromBid: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) : null, parentBlockHash: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) : null, diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 2ec8dc75b78f..1cb0381fec98 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -112,9 +112,9 @@ export type ProtoBlock = BlockExtraMeta & { // GLOAS: The followings are from bids. They are null in pre-gloas // Used for execution payload gossip validation - builderIndex?: number; - // Used for execution payload gossip validation - blockHashHex?: RootHex; + builderIndex: number | null; + // Used for execution payload gossip validation. Not to be confused with executionPayloadBlockHash + blockHashFromBid: RootHex | null; // Used to determine if this block extends EMPTY or FULL parent variant // Spec: gloas/fork-choice.md#new-get_parent_payload_status From 20588c8191f84c8472d9803abf02c89e51aaf04e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:06:48 -0800 Subject: [PATCH 43/52] fix ci --- .../src/chain/errors/executionPayloadEnvelope.ts | 6 +++++- packages/beacon-node/src/chain/forkChoice/index.ts | 12 +++++++++--- packages/beacon-node/test/utils/state.ts | 4 +++- packages/fork-choice/src/forkChoice/forkChoice.ts | 4 +++- packages/fork-choice/src/protoArray/interface.ts | 1 - 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index 1f3cd1a94b22..af3a040a8d17 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -27,7 +27,11 @@ export type ExecutionPayloadEnvelopeErrorType = envelopeBuilderIndex: BuilderIndex; bidBuilderIndex: BuilderIndex | null; } - | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; envelopeBlockHash: RootHex; bidBlockHash: RootHex | null} + | { + code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; + envelopeBlockHash: RootHex; + bidBlockHash: RootHex | null; + } | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} | {code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL; blockRoot: RootHex}; diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 2e37024bd1c4..091183e355c4 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -150,7 +150,9 @@ export function initializeForkChoiceFromFinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? builderIndex: isForkPostGloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null, - blockHashFromBid: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) : null, + blockHashFromBid: isForkPostGloas + ? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) + : null, parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, }, currentSlot @@ -236,8 +238,12 @@ export function initializeForkChoiceFromUnfinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? - builderIndex: isForkPostGloas ? (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null, - blockHashFromBid: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) : null, + builderIndex: isForkPostGloas + ? (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex + : null, + blockHashFromBid: isForkPostGloas + ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) + : null, parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null, }; diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index 829138ac482e..eb20c875740e 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -179,6 +179,8 @@ export const zeroProtoBlock: ProtoBlock = { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, - parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, + parentBlockHash: null, }; diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 7c353aa55ad4..75237914ac7a 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -776,7 +776,9 @@ export class ForkChoice implements IForkChoice { payloadStatus: isGloasBeaconBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL, builderIndex: isGloasBeaconBlock(block) ? block.body.signedExecutionPayloadBid.message.builderIndex : null, - blockHashFromBid: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) : null, + blockHashFromBid: isGloasBeaconBlock(block) + ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) + : null, parentBlockHash: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) : null, diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 1cb0381fec98..ff9ff5903b32 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -119,7 +119,6 @@ export type ProtoBlock = BlockExtraMeta & { // Used to determine if this block extends EMPTY or FULL parent variant // Spec: gloas/fork-choice.md#new-get_parent_payload_status parentBlockHash: RootHex | null; - }; /** From 8859b5bf2cec22895765705f37e7b4157c261ecb Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:08:32 -0800 Subject: [PATCH 44/52] check-types --- .../perf/chain/opPools/aggregatedAttestationPool.test.ts | 4 ++++ .../beacon-node/test/utils/validationData/attestation.ts | 2 ++ packages/fork-choice/test/perf/forkChoice/util.ts | 2 ++ .../fork-choice/test/unit/forkChoice/forkChoice.test.ts | 2 ++ .../test/unit/forkChoice/getProposerHead.test.ts | 6 ++++++ .../unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts | 6 ++++++ .../test/unit/protoArray/executionStatusUpdates.test.ts | 2 ++ .../test/unit/protoArray/getCommonAncestor.test.ts | 4 ++++ packages/fork-choice/test/unit/protoArray/gloas.test.ts | 2 ++ .../fork-choice/test/unit/protoArray/protoArray.test.ts | 6 ++++++ 10 files changed, 36 insertions(+) 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 2e680485c37c..02b3f6d7f3f8 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -71,6 +71,8 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL + builderIndex: null, + blockHashFromBid: null, }, originalState.slot ); @@ -99,6 +101,8 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL + builderIndex: null, + blockHashFromBid: null, }, slot ); diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index eaf0f43b6503..e8b8513884ba 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -82,6 +82,8 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL + builderIndex: null, + blockHashFromBid: null, }; const shufflingCache = new ShufflingCache(null, null, {}, [ diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 0dc546742827..72e4284b6e2b 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -90,6 +90,8 @@ export function initializeForkChoice(opts: Opts): ForkChoice { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; protoArr.onBlock(block, block.slot); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index f65116f12d94..7350f1ef2afc 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -113,6 +113,8 @@ describe("Forkchoice", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; }; diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 69f7aca9dad9..adcf0d0900c6 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -52,6 +52,8 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -80,6 +82,8 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -107,6 +111,8 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const fcStore: IForkChoiceStore = { diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index 3f9bc07dbd81..eafb549c3636 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -52,6 +52,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -80,6 +82,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -107,6 +111,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const fcStore: IForkChoiceStore = { diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index ead62c3354e2..f2eb43e5024e 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -124,6 +124,8 @@ function setupForkChoice(): ProtoArray { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, block.slot ); diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index 56e3cdb55c5d..8cd535b1166e 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -48,6 +48,8 @@ describe("getCommonAncestor", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, 0 ); @@ -77,6 +79,8 @@ describe("getCommonAncestor", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, block.slot ); diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index dae45b4f2306..faab014c38d0 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -67,6 +67,8 @@ describe("Gloas Fork Choice", () => { dataAvailabilityStatus: DataAvailabilityStatus.Available, parentBlockHash: parentBlockHash === undefined ? null : parentBlockHash, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; } diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index 4113b6ae7f84..8db8aa3e407d 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -37,6 +37,8 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, genesisSlot ); @@ -66,6 +68,8 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, genesisSlot + 1 ); @@ -95,6 +99,8 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, genesisSlot + 1 ); From ced6c41585df98d68718ba52d23245795735aa1d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:07:47 -0800 Subject: [PATCH 45/52] Address @twoeths's comments --- .../src/chain/blocks/importBlock.ts | 9 ++++++-- .../chain/blocks/verifyBlocksSanityChecks.ts | 9 ++++++-- .../beacon-node/src/chain/validation/block.ts | 9 ++++++-- .../fork-choice/src/forkChoice/forkChoice.ts | 23 ++++++++++++++++++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index d33997e4c164..6fc98bc8d175 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -20,7 +20,7 @@ import { isStartSlotOfEpoch, isStateValidatorsNodesPopulated, } from "@lodestar/state-transition"; -import {Attestation, BeaconBlock, altair, capella, electra, phase0, ssz} from "@lodestar/types"; +import {Attestation, BeaconBlock, altair, capella, electra, isGloasBeaconBlock, phase0, ssz} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; @@ -436,7 +436,12 @@ export async function importBlock( this.metrics?.currentActiveValidators.set(activeValidatorsCount); this.metrics?.currentValidators.set({status: "active"}, activeValidatorsCount); - const parentBlockSummary = this.forkChoice.getBlockDefaultStatus(checkpointState.latestBlockHeader.parentRoot); + const parentBlockSummary = isGloasBeaconBlock(block.message) + ? this.forkChoice.getBlockHexAndBlockHash( + toRootHex(checkpointState.latestBlockHeader.parentRoot), + toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ) + : this.forkChoice.getBlockDefaultStatus(checkpointState.latestBlockHeader.parentRoot); if (parentBlockSummary) { const justifiedCheckpoint = checkpointState.currentJustifiedCheckpoint; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts index 7d9b65f41042..02d686818d2a 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts @@ -1,7 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {RootHex, Slot} from "@lodestar/types"; +import {RootHex, Slot, isGloasBeaconBlock} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {IClock} from "../../util/clock.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; @@ -90,7 +90,12 @@ export function verifyBlocksSanityChecks( } else { // When importing a block segment, only the first NON-IGNORED block must be known to the fork-choice. const parentRoot = toRootHex(block.message.parentRoot); - parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); + parentBlock = isGloasBeaconBlock(block.message) + ? chain.forkChoice.getBlockHexAndBlockHash( + parentRoot, + toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ) + : chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (!parentBlock) { throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); } diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 6b700c8b5b42..a0f0f41ffce1 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -9,7 +9,7 @@ import { isExecutionEnabled, isExecutionStateType, } from "@lodestar/state-transition"; -import {SignedBeaconBlock, deneb} from "@lodestar/types"; +import {SignedBeaconBlock, deneb, isGloasBeaconBlock} from "@lodestar/types"; import {sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; @@ -71,7 +71,12 @@ export async function validateGossipBlock( // [REJECT] The current finalized_checkpoint is an ancestor of block -- i.e. // get_ancestor(store, block.parent_root, compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)) == store.finalized_checkpoint.root const parentRoot = toRootHex(block.parentRoot); - const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); + const parentBlock = isGloasBeaconBlock(block) + ? chain.forkChoice.getBlockHexAndBlockHash( + parentRoot, + toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) + ) + : chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (parentBlock === null) { // If fork choice does *not* consider the parent to be a descendant of the finalized block, // then there are two more cases: diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 75237914ac7a..520c91855e92 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -757,7 +757,28 @@ export class ForkChoice implements IForkChoice { ...(isGloasBeaconBlock(block) ? { executionPayloadBlockHash: toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash), // post-gloas, we don't know payload hash until we import execution payload. Set to parent payload hash for now - executionPayloadNumber: 0, // post-gloas, we don't know payload number until we import execution payload. Set to 0 for now + executionPayloadNumber: (() => { + // Determine parent's execution payload number based on which variant the block extends + const parentBlockHashFromBid = toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash); + + // If parent is pre-merge, return 0 + if (parentBlock.executionPayloadBlockHash === null) { + return 0; + } + + // If parent is pre-Gloas, it only has FULL variant + if (parentBlock.parentBlockHash === null) { + return parentBlock.executionPayloadNumber; + } + + // Parent is Gloas: get the variant that matches the parentBlockHash from bid + const parentVariant = this.getBlockHexAndBlockHash(parentRootHex, parentBlockHashFromBid); + if (parentVariant && parentVariant.executionPayloadBlockHash !== null) { + return parentVariant.executionPayloadNumber; + } + // Fallback to parent block's number (we know it's post-merge from check above) + return parentBlock.executionPayloadNumber; + })(), executionStatus: this.getPostMergeExecStatus(executionStatus), // TODO GLOAS: Need a new execution status to denote scenario where we are waiting for payload, or payload is never revealed. dataAvailabilityStatus, } From f9a12537ffb7ab8bc425d736248aa8d875508e98 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:08:39 -0800 Subject: [PATCH 46/52] chore: fork choice stores checkpoints with payload status (#8845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Summary of Changes ## Overview Updated fork choice to store and use payload status for both finalized and justified checkpoints. This ensures `getFinalizedBlock()` and `getJustifiedBlock()` return the correct block variant (EMPTY or FULL) based on checkpoint state. ## 1. Added `CheckpointWithPayload` Type **New type in `store.ts`:** ```typescript export type CheckpointWithPayload = CheckpointWithHex & {payloadStatus: PayloadStatus}; ``` This type extends `CheckpointWithHex` with a `payloadStatus` field to track whether the checkpoint uses EMPTY or FULL block variant. ## 2. Updated ForkChoiceStore **Constructor changes:** - Now takes `justifiedPayloadStatus` and `finalizedPayloadStatus` parameters - Stores both finalized and justified checkpoints as `CheckpointWithPayload` **Interface changes:** - `finalizedCheckpoint` and `unrealizedFinalizedCheckpoint` → `CheckpointWithPayload` - `justified` and `unrealizedJustified` → use `CheckpointWithPayload` (via renamed types) ## 3. Renamed Balance Types - `CheckpointHexWithBalance` → `CheckpointWithPayloadAndBalance` - `CheckpointHexWithTotalBalance` → `CheckpointWithPayloadAndTotalBalance` Both now use `CheckpointWithPayload` instead of `CheckpointWithHex`. ## 4. Added `getCheckpointPayloadStatus()` Helper **Purpose:** Determines payload status for a checkpoint by checking `state.executionPayloadAvailability` **Logic:** - Pre-Gloas: always returns `FULL` - Gloas: checks `state.executionPayloadAvailability` at checkpoint slot **Signature:** ```typescript export function getCheckpointPayloadStatus( state: CachedBeaconStateAllForks, checkpointEpoch: number ): PayloadStatus ``` ## 5. Updated `onBlock()` Processing **For justified checkpoint:** - Calls `getCheckpointPayloadStatus()` to compute payload status - Creates `CheckpointWithPayload` with computed status - Updates both realized and unrealized justified checkpoints **For finalized checkpoint:** - Calls `getCheckpointPayloadStatus()` to compute payload status - Creates `CheckpointWithPayload` with computed status - Updates both realized and unrealized finalized checkpoints ## 6. Updated Fork Choice Methods **`getFinalizedBlock()`:** - Now uses `this.fcStore.finalizedCheckpoint.payloadStatus` instead of always using `PayloadStatus.FULL` **`getJustifiedBlock()`:** - Now uses `this.fcStore.justified.checkpoint.payloadStatus` instead of always using `PayloadStatus.FULL` **`getFinalizedCheckpoint()` and `getJustifiedCheckpoint()`:** - Return type changed to `CheckpointWithPayload` ## 7. Updated Initialization **`initializeForkChoiceFromFinalizedState()`:** - Computes `justifiedPayloadStatus` using `getCheckpointPayloadStatus()` - Computes `finalizedPayloadStatus` using `getCheckpointPayloadStatus()` - Passes both to `ForkChoiceStore` constructor **`initializeForkChoiceFromUnfinalizedState()`:** - Computes payload status for both justified and finalized checkpoints - Passes both to `ForkChoiceStore` constructor --- packages/beacon-node/src/chain/emitter.ts | 6 +- .../beacon-node/src/chain/forkChoice/index.ts | 21 +++- .../opPools/aggregatedAttestationPool.test.ts | 26 ++++- .../blocks/verifyBlocksSanityChecks.test.ts | 16 ++- .../chain/seenCache/seenBlockInput.test.ts | 3 + .../test/unit/chain/validation/block.test.ts | 16 ++- .../fork-choice/src/forkChoice/forkChoice.ts | 107 +++++++++++++----- .../fork-choice/src/forkChoice/interface.ts | 8 +- packages/fork-choice/src/forkChoice/store.ts | 70 ++++++++---- packages/fork-choice/src/index.ts | 10 +- .../fork-choice/test/perf/forkChoice/util.ts | 28 ++++- .../test/unit/forkChoice/forkChoice.test.ts | 28 ++++- .../unit/forkChoice/getProposerHead.test.ts | 16 ++- .../shouldOverrideForkChoiceUpdate.test.ts | 16 ++- 14 files changed, 292 insertions(+), 79 deletions(-) diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index 9de32429069f..ba68352099ae 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -1,7 +1,7 @@ import {EventEmitter} from "node:events"; import {StrictEventEmitter} from "strict-event-emitter-types"; import {routes} from "@lodestar/api"; -import {CheckpointWithHex} from "@lodestar/fork-choice"; +import {CheckpointWithPayload} from "@lodestar/fork-choice"; import {CachedBeaconStateAllForks} from "@lodestar/state-transition"; import {RootHex, deneb, fulu, phase0} from "@lodestar/types"; import {PeerIdStr} from "../util/peerId.js"; @@ -83,8 +83,8 @@ export type ChainEventData = { export type IChainEvents = ApiEvents & { [ChainEvent.checkpoint]: (checkpoint: phase0.Checkpoint, state: CachedBeaconStateAllForks) => void; - [ChainEvent.forkChoiceJustified]: (checkpoint: CheckpointWithHex) => void; - [ChainEvent.forkChoiceFinalized]: (checkpoint: CheckpointWithHex) => void; + [ChainEvent.forkChoiceJustified]: (checkpoint: CheckpointWithPayload) => void; + [ChainEvent.forkChoiceFinalized]: (checkpoint: CheckpointWithPayload) => void; [ChainEvent.updateTargetCustodyGroupCount]: (targetGroupCount: number) => void; diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 091183e355c4..2b080934045e 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -8,6 +8,7 @@ import { ProtoArray, ProtoBlock, ForkChoiceOpts as RawForkChoiceOpts, + getCheckpointPayloadStatus, } from "@lodestar/fork-choice"; import {ZERO_HASH_HEX} from "@lodestar/params"; import { @@ -107,6 +108,12 @@ export function initializeForkChoiceFromFinalizedState( const isForkPostGloas = (state as CachedBeaconStateGloas).latestBlockHash !== undefined; + // Determine justified checkpoint payload status + const justifiedPayloadStatus = getCheckpointPayloadStatus(state, justifiedCheckpoint.epoch); + + // Determine finalized checkpoint payload status + const finalizedPayloadStatus = getCheckpointPayloadStatus(state, finalizedCheckpoint.epoch); + return new forkchoiceConstructor( config, @@ -116,6 +123,8 @@ export function initializeForkChoiceFromFinalizedState( finalizedCheckpoint, justifiedBalances, justifiedBalancesGetter, + justifiedPayloadStatus, + finalizedPayloadStatus, { onJustified: (cp) => emitter.emit(ChainEvent.forkChoiceJustified, cp), onFinalized: (cp) => emitter.emit(ChainEvent.forkChoiceFinalized, cp), @@ -196,20 +205,28 @@ export function initializeForkChoiceFromUnfinalizedState( // this is not the justified state, but there is no other ways to get justified balances const justifiedBalances = getEffectiveBalanceIncrementsZeroInactive(unfinalizedState); + + const isForkPostGloas = (unfinalizedState as CachedBeaconStateGloas).latestBlockHash !== undefined; + + // For unfinalized state, use getCheckpointPayloadStatus to determine the correct status. + // It checks state.execution_payload_availability to determine EMPTY vs FULL. + const justifiedPayloadStatus = getCheckpointPayloadStatus(unfinalizedState, justifiedCheckpoint.epoch); + const finalizedPayloadStatus = getCheckpointPayloadStatus(unfinalizedState, finalizedCheckpoint.epoch); + const store = new ForkChoiceStore( currentSlot, justifiedCheckpoint, finalizedCheckpoint, justifiedBalances, justifiedBalancesGetter, + justifiedPayloadStatus, + finalizedPayloadStatus, { onJustified: (cp) => emitter.emit(ChainEvent.forkChoiceJustified, cp), onFinalized: (cp) => emitter.emit(ChainEvent.forkChoiceFinalized, cp), } ); - const isForkPostGloas = (unfinalizedState as CachedBeaconStateGloas).latestBlockHash !== undefined; - // this is the same to the finalized state const headBlock: ProtoBlock = { slot: blockHeader.slot, 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 02b3f6d7f3f8..4817e5e61765 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -1,7 +1,7 @@ import {beforeAll, bench, describe} from "@chainsafe/benchmark"; import {BitArray, toHexString} from "@chainsafe/ssz"; import {createBeaconConfig, defaultChainConfig} from "@lodestar/config"; -import {ExecutionStatus, ForkChoice, IForkChoiceStore, ProtoArray} from "@lodestar/fork-choice"; +import {ExecutionStatus, ForkChoice, IForkChoiceStore, PayloadStatus, ProtoArray} from "@lodestar/fork-choice"; import {HISTORICAL_ROOTS_LIMIT, SLOTS_PER_EPOCH} from "@lodestar/params"; import { CachedBeaconStateAltair, @@ -116,16 +116,32 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { const fcStore: IForkChoiceStore = { currentSlot: originalState.slot, justified: { - checkpoint: {...justifiedCheckpoint, rootHex: toHexString(justifiedCheckpoint.root)}, + checkpoint: { + ...justifiedCheckpoint, + rootHex: toHexString(justifiedCheckpoint.root), + payloadStatus: PayloadStatus.FULL, + }, balances: originalState.epochCtx.effectiveBalanceIncrements, totalBalance, }, unrealizedJustified: { - checkpoint: {...justifiedCheckpoint, rootHex: toHexString(justifiedCheckpoint.root)}, + checkpoint: { + ...justifiedCheckpoint, + rootHex: toHexString(justifiedCheckpoint.root), + payloadStatus: PayloadStatus.FULL, + }, balances: originalState.epochCtx.effectiveBalanceIncrements, }, - finalizedCheckpoint: {...finalizedCheckpoint, rootHex: toHexString(finalizedCheckpoint.root)}, - unrealizedFinalizedCheckpoint: {...finalizedCheckpoint, rootHex: toHexString(finalizedCheckpoint.root)}, + finalizedCheckpoint: { + ...finalizedCheckpoint, + rootHex: toHexString(finalizedCheckpoint.root), + payloadStatus: PayloadStatus.FULL, + }, + unrealizedFinalizedCheckpoint: { + ...finalizedCheckpoint, + rootHex: toHexString(finalizedCheckpoint.root), + payloadStatus: PayloadStatus.FULL, + }, justifiedBalancesGetter: () => originalState.epochCtx.effectiveBalanceIncrements, equivocatingIndices: new Set(), }; diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts index 6298c7e5e48e..294ce7a58c58 100644 --- a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, it} from "vitest"; import {config} from "@lodestar/config/default"; -import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; +import {IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {SignedBeaconBlock, Slot, ssz} from "@lodestar/types"; import {toHex, toRootHex} from "@lodestar/utils"; @@ -25,7 +25,12 @@ describe("chain / blocks / verifyBlocksSanityChecks", () => { block.message.slot = currentSlot; forkChoice = getMockedBeaconChain().forkChoice; - forkChoice.getFinalizedCheckpoint.mockReturnValue({epoch: 0, root: Buffer.alloc(32), rootHex: ""}); + forkChoice.getFinalizedCheckpoint.mockReturnValue({ + epoch: 0, + root: Buffer.alloc(32), + rootHex: "", + payloadStatus: PayloadStatus.FULL, + }); clock = new ClockStopped(currentSlot); modules = {config, forkChoice, clock, opts: {} as IChainOptions, blacklistedBlocks: new Map()}; // On first call, parentRoot is known @@ -48,7 +53,12 @@ describe("chain / blocks / verifyBlocksSanityChecks", () => { }); it("WOULD_REVERT_FINALIZED_SLOT", () => { - forkChoice.getFinalizedCheckpoint.mockReturnValue({epoch: 5, root: Buffer.alloc(32), rootHex: ""}); + forkChoice.getFinalizedCheckpoint.mockReturnValue({ + epoch: 5, + root: Buffer.alloc(32), + rootHex: "", + payloadStatus: PayloadStatus.FULL, + }); expectThrowsLodestarError( () => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts index 1d82643f17ef..f49cbe7ca831 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts @@ -1,5 +1,6 @@ import {generateKeyPair} from "@libp2p/crypto/keys"; import {beforeEach, describe, expect, it} from "vitest"; +import {PayloadStatus} from "@lodestar/fork-choice"; import {ForkName, ForkPostFulu, ForkPreGloas} from "@lodestar/params"; import {signedBlockToSignedHeader} from "@lodestar/state-transition"; import {SignedBeaconBlock} from "@lodestar/types"; @@ -218,6 +219,7 @@ describe("SeenBlockInputCache", async () => { epoch: config.DENEB_FORK_EPOCH, root, rootHex, + payloadStatus: PayloadStatus.FULL, }); expect(cache.get(childRootHex)).toBeUndefined(); expect(cache.get(parentRootHex)).toBeUndefined(); @@ -228,6 +230,7 @@ describe("SeenBlockInputCache", async () => { epoch: config.CAPELLA_FORK_EPOCH, root, rootHex, + payloadStatus: PayloadStatus.FULL, }); expect(cache.get(childRootHex)).toBe(childBlockInput); expect(cache.get(parentRootHex)).toBe(parentBlockInput); diff --git a/packages/beacon-node/test/unit/chain/validation/block.test.ts b/packages/beacon-node/test/unit/chain/validation/block.test.ts index 0139d2e59a00..1f21051fdc97 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -1,7 +1,7 @@ import {Mock, Mocked, beforeEach, describe, it, vi} from "vitest"; import {createBeaconConfig, createChainForkConfig} from "@lodestar/config"; import {config as configDef} from "@lodestar/config/default"; -import {ProtoBlock} from "@lodestar/fork-choice"; +import {PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, ForkPostDeneb, ForkPreFulu} from "@lodestar/params"; import {SignedBeaconBlock, ssz} from "@lodestar/types"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; @@ -46,7 +46,12 @@ describe("gossip block validation", () => { verifySignature = chain.bls.verifySignatureSets; verifySignature.mockResolvedValue(true); - forkChoice.getFinalizedCheckpoint.mockReturnValue({epoch: 0, root: ZERO_HASH, rootHex: ""}); + forkChoice.getFinalizedCheckpoint.mockReturnValue({ + epoch: 0, + root: ZERO_HASH, + rootHex: "", + payloadStatus: PayloadStatus.FULL, + }); // Reset seen cache ( @@ -70,7 +75,12 @@ describe("gossip block validation", () => { it("WOULD_REVERT_FINALIZED_SLOT", async () => { // Set finalized epoch to be greater than block's epoch - forkChoice.getFinalizedCheckpoint.mockReturnValue({epoch: Infinity, root: ZERO_HASH, rootHex: ""}); + forkChoice.getFinalizedCheckpoint.mockReturnValue({ + epoch: Infinity, + root: ZERO_HASH, + rootHex: "", + payloadStatus: PayloadStatus.FULL, + }); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, job, ForkName.phase0), diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 520c91855e92..55de894e75f4 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1,7 +1,8 @@ import {ChainForkConfig} from "@lodestar/config"; -import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {ForkSeq, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import { CachedBeaconStateAllForks, + CachedBeaconStateGloas, DataAvailabilityStatus, EffectiveBalanceIncrements, ZERO_HASH, @@ -53,7 +54,7 @@ import { NotReorgedReason, ShouldOverrideForkChoiceUpdateResult, } from "./interface.js"; -import {CheckpointWithHex, IForkChoiceStore, JustifiedBalances, toCheckpointWithHex} from "./store.js"; +import {CheckpointWithPayload, IForkChoiceStore, JustifiedBalances, toCheckpointWithPayload} from "./store.js"; export type ForkChoiceOpts = { proposerBoost?: boolean; @@ -556,11 +557,11 @@ export class ForkChoice implements IForkChoice { return this.protoArray.nodes; } - getFinalizedCheckpoint(): CheckpointWithHex { + getFinalizedCheckpoint(): CheckpointWithPayload { return this.fcStore.finalizedCheckpoint; } - getJustifiedCheckpoint(): CheckpointWithHex { + getJustifiedCheckpoint(): CheckpointWithPayload { return this.fcStore.justified.checkpoint; } @@ -666,10 +667,15 @@ export class ForkChoice implements IForkChoice { this.proposerBoostRoot = blockRootHex; } - const justifiedCheckpoint = toCheckpointWithHex(state.currentJustifiedCheckpoint); - const finalizedCheckpoint = toCheckpointWithHex(state.finalizedCheckpoint); + // Get justified checkpoint with payload status for Gloas + const justifiedPayloadStatus = getCheckpointPayloadStatus(state, state.currentJustifiedCheckpoint.epoch); + const justifiedCheckpoint = toCheckpointWithPayload(state.currentJustifiedCheckpoint, justifiedPayloadStatus); const stateJustifiedEpoch = justifiedCheckpoint.epoch; + // Get finalized checkpoint with payload status for Gloas + const finalizedPayloadStatus = getCheckpointPayloadStatus(state, state.finalizedCheckpoint.epoch); + const finalizedCheckpoint = toCheckpointWithPayload(state.finalizedCheckpoint, finalizedPayloadStatus); + // Justified balances for `justifiedCheckpoint` are new to the fork-choice. Compute them on demand only if // the justified checkpoint changes this.updateCheckpoints(justifiedCheckpoint, finalizedCheckpoint, () => @@ -690,29 +696,57 @@ export class ForkChoice implements IForkChoice { // This is an optimization. It should reduce the amount of times we run // `process_justification_and_finalization` by approximately 1/3rd when the chain is // performing optimally. - let unrealizedJustifiedCheckpoint: CheckpointWithHex; - let unrealizedFinalizedCheckpoint: CheckpointWithHex; + let unrealizedJustifiedCheckpoint: CheckpointWithPayload; + let unrealizedFinalizedCheckpoint: CheckpointWithPayload; if (this.opts?.computeUnrealized) { if ( parentBlock.unrealizedJustifiedEpoch === blockEpoch && parentBlock.unrealizedFinalizedEpoch + 1 >= blockEpoch ) { // reuse from parent, happens at 1/3 last blocks of epoch as monitored in mainnet + // Get payload status for unrealized justified checkpoint + const unrealizedJustifiedPayloadStatus = getCheckpointPayloadStatus( + state, + parentBlock.unrealizedJustifiedEpoch + ); unrealizedJustifiedCheckpoint = { epoch: parentBlock.unrealizedJustifiedEpoch, root: fromHex(parentBlock.unrealizedJustifiedRoot), rootHex: parentBlock.unrealizedJustifiedRoot, + payloadStatus: unrealizedJustifiedPayloadStatus, }; + // Get payload status for unrealized finalized checkpoint + const unrealizedFinalizedPayloadStatus = getCheckpointPayloadStatus( + state, + parentBlock.unrealizedFinalizedEpoch + ); unrealizedFinalizedCheckpoint = { epoch: parentBlock.unrealizedFinalizedEpoch, root: fromHex(parentBlock.unrealizedFinalizedRoot), rootHex: parentBlock.unrealizedFinalizedRoot, + payloadStatus: unrealizedFinalizedPayloadStatus, }; } else { // compute new, happens 2/3 first blocks of epoch as monitored in mainnet const unrealized = computeUnrealizedCheckpoints(state); - unrealizedJustifiedCheckpoint = toCheckpointWithHex(unrealized.justifiedCheckpoint); - unrealizedFinalizedCheckpoint = toCheckpointWithHex(unrealized.finalizedCheckpoint); + // Get payload status for unrealized justified checkpoint + const unrealizedJustifiedPayloadStatus = getCheckpointPayloadStatus( + state, + unrealized.justifiedCheckpoint.epoch + ); + unrealizedJustifiedCheckpoint = toCheckpointWithPayload( + unrealized.justifiedCheckpoint, + unrealizedJustifiedPayloadStatus + ); + // Get payload status for unrealized finalized checkpoint + const unrealizedFinalizedPayloadStatus = getCheckpointPayloadStatus( + state, + unrealized.finalizedCheckpoint.epoch + ); + unrealizedFinalizedCheckpoint = toCheckpointWithPayload( + unrealized.finalizedCheckpoint, + unrealizedFinalizedPayloadStatus + ); } } else { unrealizedJustifiedCheckpoint = justifiedCheckpoint; @@ -1062,12 +1096,8 @@ export class ForkChoice implements IForkChoice { } getJustifiedBlock(): ProtoBlock { - const {rootHex, epoch} = this.fcStore.justified.checkpoint; - // Checkpoints for pre-gloas should be FULL variant, while post-gloas should be EMPTY variant - const block = this.getBlockHex( - rootHex, - epoch >= this.config.GLOAS_FORK_EPOCH ? PayloadStatus.EMPTY : PayloadStatus.FULL - ); + const {rootHex, payloadStatus} = this.fcStore.justified.checkpoint; + const block = this.getBlockHex(rootHex, payloadStatus); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, @@ -1078,12 +1108,8 @@ export class ForkChoice implements IForkChoice { } getFinalizedBlock(): ProtoBlock { - const {rootHex, epoch} = this.fcStore.finalizedCheckpoint; - // Checkpoints for pre-gloas should be FULL variant, while post-gloas should be EMPTY variant - const block = this.getBlockHex( - rootHex, - epoch >= this.config.GLOAS_FORK_EPOCH ? PayloadStatus.EMPTY : PayloadStatus.FULL - ); + const {rootHex, payloadStatus} = this.fcStore.finalizedCheckpoint; + const block = this.getBlockHex(rootHex, payloadStatus); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, @@ -1414,12 +1440,12 @@ export class ForkChoice implements IForkChoice { * * **`on_tick`** * May need the justified balances of: - * - unrealizedJustified: Already available in `CheckpointHexWithBalance` + * - unrealizedJustified: Already available in `CheckpointWithPayloadAndBalance` * Since this balances are already available the getter is just `() => balances`, without cache interaction */ private updateCheckpoints( - justifiedCheckpoint: CheckpointWithHex, - finalizedCheckpoint: CheckpointWithHex, + justifiedCheckpoint: CheckpointWithPayload, + finalizedCheckpoint: CheckpointWithPayload, getJustifiedBalances: () => JustifiedBalances ): void { // Update justified checkpoint. @@ -1439,8 +1465,8 @@ export class ForkChoice implements IForkChoice { * Update unrealized checkpoints in store if necessary */ private updateUnrealizedCheckpoints( - unrealizedJustifiedCheckpoint: CheckpointWithHex, - unrealizedFinalizedCheckpoint: CheckpointWithHex, + unrealizedJustifiedCheckpoint: CheckpointWithPayload, + unrealizedFinalizedCheckpoint: CheckpointWithPayload, getJustifiedBalances: () => JustifiedBalances ): void { if (unrealizedJustifiedCheckpoint.epoch > this.fcStore.unrealizedJustified.checkpoint.epoch) { @@ -1777,3 +1803,30 @@ export function getCommitteeFraction( const committeeWeight = Math.floor(justifiedTotalActiveBalanceByIncrement / config.slotsPerEpoch); return Math.floor((committeeWeight * config.committeePercent) / 100); } + +/** + * Get the payload status for a checkpoint. + * + * Pre-Gloas: always FULL (payload embedded in block) + * Gloas: determined by state.execution_payload_availability + * + * @param state - The state to check execution_payload_availability + * @param checkpointEpoch - The epoch of the checkpoint + */ +export function getCheckpointPayloadStatus(state: CachedBeaconStateAllForks, checkpointEpoch: number): PayloadStatus { + const fork = state.config.getForkSeq(state.slot); + + // Pre-Gloas: always FULL + if (fork < ForkSeq.gloas) { + return PayloadStatus.FULL; + } + + // For Gloas, check state.execution_payload_availability + // - For non-skipped slots at checkpoint: returns false (EMPTY) since payload hasn't arrived yet + // - For skipped slots at checkpoint: returns the actual availability status from state + const checkpointSlot = computeStartSlotAtEpoch(checkpointEpoch); + const gloasState = state as CachedBeaconStateGloas; + const payloadAvailable = gloasState.executionPayloadAvailability.get(checkpointSlot % SLOTS_PER_HISTORICAL_ROOT); + + return payloadAvailable ? PayloadStatus.FULL : PayloadStatus.EMPTY; +} diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index d036878ea536..0782322a3bb8 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -12,7 +12,7 @@ import { ProtoNode, } from "../protoArray/interface.js"; import {UpdateAndGetHeadOpt} from "./forkChoice.js"; -import {CheckpointWithHex} from "./store.js"; +import {CheckpointWithHex, CheckpointWithPayload} from "./store.js"; export type CheckpointHex = { epoch: Epoch; @@ -24,12 +24,12 @@ export type CheckpointsWithHex = { finalizedCheckpoint: CheckpointWithHex; }; -export type CheckpointHexWithBalance = { - checkpoint: CheckpointWithHex; +export type CheckpointWithPayloadAndBalance = { + checkpoint: CheckpointWithPayload; balances: EffectiveBalanceIncrements; }; -export type CheckpointHexWithTotalBalance = CheckpointHexWithBalance & { +export type CheckpointWithPayloadAndTotalBalance = CheckpointWithPayloadAndBalance & { totalBalance: number; }; diff --git a/packages/fork-choice/src/forkChoice/store.ts b/packages/fork-choice/src/forkChoice/store.ts index fda01689f96c..d71b75c64b37 100644 --- a/packages/fork-choice/src/forkChoice/store.ts +++ b/packages/fork-choice/src/forkChoice/store.ts @@ -1,7 +1,8 @@ import {CachedBeaconStateAllForks, EffectiveBalanceIncrements} from "@lodestar/state-transition"; import {RootHex, Slot, ValidatorIndex, phase0} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; -import {CheckpointHexWithBalance, CheckpointHexWithTotalBalance} from "./interface.js"; +import {PayloadStatus} from "../protoArray/interface.js"; +import {CheckpointWithPayloadAndBalance, CheckpointWithPayloadAndTotalBalance} from "./interface.js"; /** * Stores checkpoints in a hybrid format: @@ -10,6 +11,15 @@ import {CheckpointHexWithBalance, CheckpointHexWithTotalBalance} from "./interfa */ export type CheckpointWithHex = phase0.Checkpoint & {rootHex: RootHex}; +/** + * Checkpoint with payload status for Gloas fork choice. + * Used to track which variant (EMPTY or FULL) of the finalized/justified block to use. + * + * Pre-Gloas: payloadStatus is always FULL (payload embedded in block) + * Gloas: determined by state.execution_payload_availability + */ +export type CheckpointWithPayload = CheckpointWithHex & {payloadStatus: PayloadStatus}; + export type JustifiedBalances = EffectiveBalanceIncrements; /** @@ -37,11 +47,11 @@ export type JustifiedBalancesGetter = ( */ export interface IForkChoiceStore { currentSlot: Slot; - get justified(): CheckpointHexWithTotalBalance; - set justified(justified: CheckpointHexWithBalance); - unrealizedJustified: CheckpointHexWithBalance; - finalizedCheckpoint: CheckpointWithHex; - unrealizedFinalizedCheckpoint: CheckpointWithHex; + get justified(): CheckpointWithPayloadAndTotalBalance; + set justified(justified: CheckpointWithPayloadAndBalance); + unrealizedJustified: CheckpointWithPayloadAndBalance; + finalizedCheckpoint: CheckpointWithPayload; + unrealizedFinalizedCheckpoint: CheckpointWithPayload; justifiedBalancesGetter: JustifiedBalancesGetter; equivocatingIndices: Set; } @@ -50,10 +60,10 @@ export interface IForkChoiceStore { * IForkChoiceStore implementer which emits forkChoice events on updated justified and finalized checkpoints. */ export class ForkChoiceStore implements IForkChoiceStore { - private _justified: CheckpointHexWithTotalBalance; - unrealizedJustified: CheckpointHexWithBalance; - private _finalizedCheckpoint: CheckpointWithHex; - unrealizedFinalizedCheckpoint: CheckpointWithHex; + private _justified: CheckpointWithPayloadAndTotalBalance; + unrealizedJustified: CheckpointWithPayloadAndBalance; + private _finalizedCheckpoint: CheckpointWithPayload; + unrealizedFinalizedCheckpoint: CheckpointWithPayload; equivocatingIndices = new Set(); justifiedBalancesGetter: JustifiedBalancesGetter; currentSlot: Slot; @@ -64,37 +74,49 @@ export class ForkChoiceStore implements IForkChoiceStore { finalizedCheckpoint: phase0.Checkpoint, justifiedBalances: EffectiveBalanceIncrements, justifiedBalancesGetter: JustifiedBalancesGetter, + /** + * Payload status for justified checkpoint. + * Pre-Gloas: always FULL + * Gloas: determined by state.execution_payload_availability + */ + justifiedPayloadStatus: PayloadStatus, + /** + * Payload status for finalized checkpoint. + * Pre-Gloas: always FULL + * Gloas: determined by state.execution_payload_availability + */ + finalizedPayloadStatus: PayloadStatus, private readonly events?: { - onJustified: (cp: CheckpointWithHex) => void; - onFinalized: (cp: CheckpointWithHex) => void; + onJustified: (cp: CheckpointWithPayload) => void; + onFinalized: (cp: CheckpointWithPayload) => void; } ) { this.justifiedBalancesGetter = justifiedBalancesGetter; this.currentSlot = currentSlot; const justified = { - checkpoint: toCheckpointWithHex(justifiedCheckpoint), + checkpoint: toCheckpointWithPayload(justifiedCheckpoint, justifiedPayloadStatus), balances: justifiedBalances, totalBalance: computeTotalBalance(justifiedBalances), }; this._justified = justified; this.unrealizedJustified = justified; - this._finalizedCheckpoint = toCheckpointWithHex(finalizedCheckpoint); + this._finalizedCheckpoint = toCheckpointWithPayload(finalizedCheckpoint, finalizedPayloadStatus); this.unrealizedFinalizedCheckpoint = this._finalizedCheckpoint; } - get justified(): CheckpointHexWithTotalBalance { + get justified(): CheckpointWithPayloadAndTotalBalance { return this._justified; } - set justified(justified: CheckpointHexWithBalance) { + set justified(justified: CheckpointWithPayloadAndBalance) { this._justified = {...justified, totalBalance: computeTotalBalance(justified.balances)}; this.events?.onJustified(justified.checkpoint); } - get finalizedCheckpoint(): CheckpointWithHex { + get finalizedCheckpoint(): CheckpointWithPayload { return this._finalizedCheckpoint; } - set finalizedCheckpoint(checkpoint: CheckpointWithHex) { - const cp = toCheckpointWithHex(checkpoint); + set finalizedCheckpoint(checkpoint: CheckpointWithPayload) { + const cp = toCheckpointWithPayload(checkpoint, checkpoint.payloadStatus); this._finalizedCheckpoint = cp; this.events?.onFinalized(cp); } @@ -111,6 +133,16 @@ export function toCheckpointWithHex(checkpoint: phase0.Checkpoint): CheckpointWi }; } +export function toCheckpointWithPayload( + checkpoint: phase0.Checkpoint, + payloadStatus: PayloadStatus +): CheckpointWithPayload { + return { + ...toCheckpointWithHex(checkpoint), + payloadStatus, + }; +} + export function equalCheckpointWithHex(a: CheckpointWithHex, b: CheckpointWithHex): boolean { return a.epoch === b.epoch && a.rootHex === b.rootHex; } diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index 39d42df73a96..99246e5225f8 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -6,10 +6,17 @@ export { type InvalidBlock, InvalidBlockCode, } from "./forkChoice/errors.js"; -export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt} from "./forkChoice/forkChoice.js"; +export { + ForkChoice, + type ForkChoiceOpts, + UpdateHeadOpt, + getCheckpointPayloadStatus, +} from "./forkChoice/forkChoice.js"; export { type AncestorResult, AncestorStatus, + type CheckpointWithPayloadAndBalance, + type CheckpointWithPayloadAndTotalBalance, EpochDifference, type IForkChoice, NotReorgedReason, @@ -17,6 +24,7 @@ export { export * from "./forkChoice/safeBlocks.js"; export { type CheckpointWithHex, + type CheckpointWithPayload, ForkChoiceStore, type IForkChoiceStore, type JustifiedBalancesGetter, diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 72e4284b6e2b..59fc71630660 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -47,16 +47,36 @@ export function initializeForkChoice(opts: Opts): ForkChoice { const fcStore: IForkChoiceStore = { currentSlot: genesisSlot, justified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisRoot), rootHex: genesisRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisRoot), + rootHex: genesisRoot, + payloadStatus: PayloadStatus.FULL, + }, balances, totalBalance: computeTotalBalance(balances), }, unrealizedJustified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisRoot), rootHex: genesisRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisRoot), + rootHex: genesisRoot, + payloadStatus: PayloadStatus.FULL, + }, balances, }, - finalizedCheckpoint: {epoch: genesisEpoch, root: fromHexString(genesisRoot), rootHex: genesisRoot}, - unrealizedFinalizedCheckpoint: {epoch: genesisEpoch, root: fromHexString(genesisRoot), rootHex: genesisRoot}, + finalizedCheckpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisRoot), + rootHex: genesisRoot, + payloadStatus: PayloadStatus.FULL, + }, + unrealizedFinalizedCheckpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisRoot), + rootHex: genesisRoot, + payloadStatus: PayloadStatus.FULL, + }, justifiedBalancesGetter: () => balances, equivocatingIndices: new Set(Array.from({length: opts.initialEquivocatedCount}, (_, i) => i)), }; diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 7350f1ef2afc..5188498cfb59 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -55,16 +55,36 @@ describe("Forkchoice", () => { const fcStore: IForkChoiceStore = { currentSlot: genesisSlot + 1, justified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(finalizedRoot), rootHex: finalizedRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(finalizedRoot), + rootHex: finalizedRoot, + payloadStatus: PayloadStatus.FULL, + }, balances: new Uint16Array([32]), totalBalance: 32, }, unrealizedJustified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(finalizedRoot), rootHex: finalizedRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(finalizedRoot), + rootHex: finalizedRoot, + payloadStatus: PayloadStatus.FULL, + }, balances: new Uint16Array([32]), }, - finalizedCheckpoint: {epoch: genesisEpoch, root: fromHexString(finalizedRoot), rootHex: finalizedRoot}, - unrealizedFinalizedCheckpoint: {epoch: genesisEpoch, root: fromHexString(finalizedRoot), rootHex: finalizedRoot}, + finalizedCheckpoint: { + epoch: genesisEpoch, + root: fromHexString(finalizedRoot), + rootHex: finalizedRoot, + payloadStatus: PayloadStatus.FULL, + }, + unrealizedFinalizedCheckpoint: { + epoch: genesisEpoch, + root: fromHexString(finalizedRoot), + rootHex: finalizedRoot, + payloadStatus: PayloadStatus.FULL, + }, justifiedBalancesGetter: () => new Uint16Array([32]), equivocatingIndices: new Set(), }; diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index adcf0d0900c6..ef666a080a39 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -118,23 +118,35 @@ describe("Forkchoice / GetProposerHead", () => { const fcStore: IForkChoiceStore = { currentSlot: genesisSlot + 1, justified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisBlock.blockRoot), + rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, + }, balances: new Uint16Array(Array(32).fill(150)), totalBalance: 32 * 150, }, unrealizedJustified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisBlock.blockRoot), + rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, + }, balances: new Uint16Array(Array(32).fill(150)), }, finalizedCheckpoint: { epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, }, unrealizedFinalizedCheckpoint: { epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, }, justifiedBalancesGetter: () => new Uint16Array(Array(32).fill(150)), equivocatingIndices: new Set(), diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index eafb549c3636..7e0899fe9c46 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -118,23 +118,35 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { const fcStore: IForkChoiceStore = { currentSlot: genesisSlot + 1, justified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisBlock.blockRoot), + rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, + }, balances: new Uint16Array(Array(32).fill(150)), totalBalance: 32 * 150, }, unrealizedJustified: { - checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot}, + checkpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisBlock.blockRoot), + rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, + }, balances: new Uint16Array(Array(32).fill(150)), }, finalizedCheckpoint: { epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, }, unrealizedFinalizedCheckpoint: { epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot, + payloadStatus: PayloadStatus.FULL, }, justifiedBalancesGetter: () => new Uint16Array(Array(32).fill(150)), equivocatingIndices: new Set(), From 1dc21c4c94e007c7bcc8cb23eb1b7040c96cb1a9 Mon Sep 17 00:00:00 2001 From: twoeths <10568965+twoeths@users.noreply.github.com> Date: Thu, 5 Feb 2026 06:07:13 +0700 Subject: [PATCH 47/52] chore: model pre-gloas block index as number (#8858) **Motivation** - right now we model both pre-gloas and gloas variants as Array, which has some disadvantages: - array type is too general, it could be 1000 items while we only have 1/2/3 items - item 0 of pre-gloas block is FULL variant index, while item 0 of gloas blocks is PENDING variant index which is a little bit confusing to maintain - we check `lengh=1` as pre-gloas, which is correct based on the implementation but ideally we can do better typing to know it **Description** - model pre-gloas variant index as number - model gloas variants using tuple: `[number, number]` or `[number, number, number]` part of #8739 cc @ensi321 @nflaig --------- Co-authored-by: Tuyen Nguyen Co-authored-by: NC <17676176+ensi321@users.noreply.github.com> --- .../fork-choice/src/forkChoice/forkChoice.ts | 13 +- .../fork-choice/src/protoArray/protoArray.ts | 128 ++++++++++-------- .../test/unit/protoArray/gloas.test.ts | 46 +++---- 3 files changed, 104 insertions(+), 83 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 55de894e75f4..6cbaf235823b 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1085,6 +1085,13 @@ export class ForkChoice implements IForkChoice { return null; } + // Pre-Gloas + if (!Array.isArray(variantIndices)) { + const node = this.protoArray.nodes[variantIndices]; + return node.executionPayloadBlockHash === blockHash ? node : null; + } + + // Post-Gloas for (const variantIndex of variantIndices) { const node = this.protoArray.nodes[variantIndex]; if (node.executionPayloadBlockHash === blockHash) { @@ -1243,6 +1250,8 @@ export class ForkChoice implements IForkChoice { return this.protoArray.nodes; } + // TODO GLOAS: this function is ambiguous, consumer should also provide payload, or it should accept a ProtoBlock instead + // also consumer may want PENDING or EMPTY only *forwardIterateDescendants(blockRoot: RootHex): IterableIterator { const rootsInChain = new Set([blockRoot]); @@ -1255,7 +1264,9 @@ export class ForkChoice implements IForkChoice { } // Find the minimum index among all variants to start iteration - const blockIndex = Math.min(...blockVariants.filter((idx) => idx !== undefined)); + const blockIndex = Array.isArray(blockVariants) + ? Math.min(...blockVariants.filter((idx) => idx !== undefined)) + : blockVariants; for (let i = blockIndex + 1; i < this.protoArray.nodes.length; i++) { const node = this.protoArray.nodes[i]; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 703ed0492351..c786e49009cf 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -25,6 +25,16 @@ type ProposerBoost = {root: RootHex; score: number}; const ZERO_HASH_HEX = toRootHex(Buffer.alloc(32, 0)); +/** Pre-Gloas: single element, FULL index (for backward compatibility) */ +type PreGloasVariantIndex = number; +/** + * Post-Gloas: array length is 2 or 3 + * - Length 2: [PENDING_INDEX, EMPTY_INDEX] when payload hasn't arrived yet + * - Length 3: [PENDING_INDEX, EMPTY_INDEX, FULL_INDEX] when payload has arrived + */ +type GloasVariantIndices = [number, number] | [number, number, number]; +type VariantIndices = PreGloasVariantIndex | GloasVariantIndices; + export class ProtoArray { // Do not attempt to prune the tree unless it has at least this many nodes. // Small prunes simply waste time @@ -42,14 +52,9 @@ export class ProtoArray { * - number[1] = EMPTY variant index (PayloadStatus.EMPTY = 1) * - number[2] = FULL variant index (PayloadStatus.FULL = 2) * - * Pre-Gloas: array length is 1, number[0] contains FULL index (for backward compatibility) - * Post-Gloas: array length is 2 or 3 - * - Length 2: [PENDING_INDEX, EMPTY_INDEX] when payload hasn't arrived yet - * - Length 3: [PENDING_INDEX, EMPTY_INDEX, FULL_INDEX] when payload has arrived - * * Note: undefined array elements indicate that variant doesn't exist for this block */ - indices = new Map(); + indices = new Map(); lvhError?: LVHExecError; private previousProposerBoost: ProposerBoost | null = null; @@ -117,16 +122,16 @@ export class ProtoArray { * Note: payloadStatus is required. Use getDefaultVariant() to get the canonical variant. */ getNodeIndexByRootAndStatus(root: RootHex, payloadStatus: PayloadStatus): number | undefined { - const variants = this.indices.get(root); - if (!variants) { + const variantOrArr = this.indices.get(root); + if (variantOrArr == null) { return undefined; } - // Pre-Gloas: only one variant exists (FULL at index 0) - if (variants.length === 1) { + // Pre-Gloas: only FULL variant exists + if (!Array.isArray(variantOrArr)) { // Return FULL variant if no status specified or FULL explicitly requested if (payloadStatus === PayloadStatus.FULL) { - return variants[0]; + return variantOrArr; } // PENDING and EMPTY are invalid for pre-Gloas blocks throw new ProtoArrayError({ @@ -136,7 +141,7 @@ export class ProtoArray { } // Gloas: return the specified variant, or PENDING if not specified - return variants[payloadStatus]; + return variantOrArr[payloadStatus]; } /** @@ -148,13 +153,13 @@ export class ProtoArray { * @returns PayloadStatus.FULL for pre-Gloas, PayloadStatus.PENDING for Gloas, undefined if block not found */ getDefaultVariant(blockRoot: RootHex): PayloadStatus | undefined { - const variants = this.indices.get(blockRoot); - if (!variants) { + const variantOrArr = this.indices.get(blockRoot); + if (variantOrArr == null) { return undefined; } - // Pre-Gloas: only one variant exists (FULL) - if (variants.length === 1) { + // Pre-Gloas: only FULL variant exists + if (!Array.isArray(variantOrArr)) { return PayloadStatus.FULL; } @@ -179,16 +184,10 @@ export class ProtoArray { } // Gloas block must have parentBlockHash from its SignedExecutionPayloadBid - const parentBlockHash = block.parentBlockHash; - if (parentBlockHash === null) { - // should not happen for Gloas blocks - return PayloadStatus.FULL; - } - // Get parent node to compare execution payload hash // Use variants[0] which works for both pre-Gloas (FULL) and Gloas (PENDING) const parentVariants = this.indices.get(block.parentRoot); - if (!parentVariants) { + if (parentVariants == null) { // Parent not found throw new ProtoArrayError({ code: ProtoArrayErrorCode.UNKNOWN_BLOCK, @@ -196,6 +195,12 @@ export class ProtoArray { }); } + const parentBlockHash = block.parentBlockHash; + // Pre-Gloas blocks don't have parentBlockHash + if (parentBlockHash === null || !Array.isArray(parentVariants)) { + return PayloadStatus.FULL; + } + const parentIndex = parentVariants[0]; const parentExecutionHash = this.nodes[parentIndex].executionPayloadBlockHash; @@ -361,8 +366,8 @@ export class ProtoArray { // Check if parent exists by getting variants array const parentVariants = this.indices.get(block.parentRoot); - if (parentVariants) { - const anyParentIndex = parentVariants[0]; + if (parentVariants != null) { + const anyParentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants; const anyParentNode = this.nodes[anyParentIndex]; if (!isGloasBlock(anyParentNode)) { @@ -404,10 +409,7 @@ export class ProtoArray { // Store both variants in the indices array // [PENDING, EMPTY, undefined] - FULL will be added later if payload arrives - const variants: number[] = []; - variants[PayloadStatus.PENDING] = pendingIndex; - variants[PayloadStatus.EMPTY] = emptyIndex; - this.indices.set(block.blockRoot, variants); + this.indices.set(block.blockRoot, [pendingIndex, emptyIndex]); // Update bestChild pointers if (parentIndex !== undefined) { @@ -438,9 +440,8 @@ export class ProtoArray { const nodeIndex = this.nodes.length; this.nodes.push(node); - // Store FULL variant in indices array - // Pre-Gloas: variants[0] contains FULL index - this.indices.set(block.blockRoot, [nodeIndex]); + // Pre-Gloas: store FULL index instead of array + this.indices.set(block.blockRoot, nodeIndex); // If this node is valid, lets propagate the valid status up the chain // and throw error if we counter invalid, as this breaks consensus @@ -470,7 +471,7 @@ export class ProtoArray { ): void { // First check if block exists const variants = this.indices.get(blockRoot); - if (!variants) { + if (variants == null) { // Equivalent to `assert envelope.beacon_block_root in store.block_states` throw new ProtoArrayError({ code: ProtoArrayErrorCode.UNKNOWN_BLOCK, @@ -478,7 +479,7 @@ export class ProtoArray { }); } - if (variants.length === 1) { + if (!Array.isArray(variants)) { // Pre-gloas block should not be calling this method throw new ProtoArrayError({ code: ProtoArrayErrorCode.PRE_GLOAS_BLOCK, @@ -978,7 +979,7 @@ export class ProtoArray { */ maybePrune(finalizedRoot: RootHex): ProtoBlock[] { const variants = this.indices.get(finalizedRoot); - if (!variants) { + if (variants == null) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN, root: finalizedRoot, @@ -986,7 +987,9 @@ export class ProtoArray { } // Find the minimum index among all variants to ensure we don't prune too much - const finalizedIndex = Math.min(...variants.filter((idx) => idx !== undefined)); + const finalizedIndex = Array.isArray(variants) + ? Math.min(...variants.filter((idx) => idx !== undefined)) + : variants; if (finalizedIndex < this.pruneThreshold) { // Pruning at small numbers incurs more cost than benefit @@ -1017,24 +1020,35 @@ export class ProtoArray { this.nodes = this.nodes.slice(finalizedIndex); // Adjust the indices map - subtract finalizedIndex from all node indices - const newIndices = new Map(); for (const [root, variantIndices] of this.indices.entries()) { - const adjustedVariants: number[] = []; - for (let i = 0; i < variantIndices.length; i++) { - const idx = variantIndices[i]; - if (idx !== undefined) { - if (idx < finalizedIndex) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INDEX_OVERFLOW, - value: "indices", - }); - } - adjustedVariants[i] = idx - finalizedIndex; + // Pre-Gloas: single index + if (!Array.isArray(variantIndices)) { + if (variantIndices < finalizedIndex) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INDEX_OVERFLOW, + value: "indices", + }); } + this.indices.set(root, variantIndices - finalizedIndex); + continue; } - newIndices.set(root, adjustedVariants); + + // Post-Gloas: array of variant indices + const adjustedVariants = variantIndices.map((variantIndex) => { + if (variantIndex === undefined) { + return undefined; + } + + if (variantIndex < finalizedIndex) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INDEX_OVERFLOW, + value: "indices", + }); + } + return variantIndex - finalizedIndex; + }); + this.indices.set(root, adjustedVariants as GloasVariantIndices); } - this.indices = newIndices; // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes for (let i = 0, len = this.nodes.length; i < len; i++) { @@ -1346,15 +1360,15 @@ export class ProtoArray { */ getAncestor(blockRoot: RootHex, ancestorSlot: Slot): ProtoNode { // Get any variant to check the block (use variants[0]) - const variants = this.indices.get(blockRoot); - if (!variants) { + const variantOrArr = this.indices.get(blockRoot); + if (variantOrArr == null) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot, }); } - const blockIndex = variants[0]; + const blockIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; const block = this.nodes[blockIndex]; // If block is at or before queried slot, return PENDING variant (or FULL for pre-Gloas) @@ -1368,7 +1382,7 @@ export class ProtoArray { // Start with the parent of the current block let currentBlock = block; const parentVariants = this.indices.get(currentBlock.parentRoot); - if (!parentVariants) { + if (parentVariants == null) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, descendantRoot: blockRoot, @@ -1376,7 +1390,7 @@ export class ProtoArray { }); } - let parentIndex = parentVariants[0]; + let parentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants; let parentBlock = this.nodes[parentIndex]; // Walk backwards while parent.slot > ancestorSlot @@ -1384,7 +1398,7 @@ export class ProtoArray { currentBlock = parentBlock; const nextParentVariants = this.indices.get(currentBlock.parentRoot); - if (!nextParentVariants) { + if (nextParentVariants == null) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, descendantRoot: blockRoot, @@ -1392,7 +1406,7 @@ export class ProtoArray { }); } - parentIndex = nextParentVariants[0]; + parentIndex = Array.isArray(nextParentVariants) ? nextParentVariants[0] : nextParentVariants; parentBlock = this.nodes[parentIndex]; } diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index faab014c38d0..d14c2a04aa62 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -21,21 +21,22 @@ describe("Gloas Fork Choice", () => { blockRoot: RootHex, payloadStatus: PayloadStatus ): ProtoNode | undefined { - const variants = (protoArray as any).indices.get(blockRoot); - if (!variants) return undefined; - - // For pre-Gloas, variants[0] contains FULL index - if (variants.length === 1) { - // Pre-Gloas block only has FULL variant - // Only return if requested payloadStatus is FULL - if (payloadStatus === PayloadStatus.FULL) { - return (protoArray as any).nodes[variants[0]]; - } - return undefined; - } - - // For post-Gloas, variants[payloadStatus] contains the index for that status - const index = variants[payloadStatus]; + // const variants = (protoArray as any).indices.get(blockRoot); + // if (!variants) return undefined; + + // // For pre-Gloas, variants[0] contains FULL index + // if (variants.length === 1) { + // // Pre-Gloas block only has FULL variant + // // Only return if requested payloadStatus is FULL + // if (payloadStatus === PayloadStatus.FULL) { + // return (protoArray as any).nodes[variants[0]]; + // } + // return undefined; + // } + + // // For post-Gloas, variants[payloadStatus] contains the index for that status + // const index = variants[payloadStatus]; + const index = protoArray.getNodeIndexByRootAndStatus(blockRoot, payloadStatus); if (index === undefined) return undefined; return (protoArray as any).nodes[index]; } @@ -77,9 +78,8 @@ describe("Gloas Fork Choice", () => { const protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); const variants = (protoArray as any).indices.get(genesisRoot); expect(variants).toBeDefined(); - // Pre-Gloas: variants[0] contains FULL index - expect(variants.length).toBe(1); - expect(variants[0]).toBe(0); + // Pre-Gloas: variants is the FULL index + expect(variants).toBe(0); }); it("getNodeByPayloadStatus() retrieves correct variants", () => { @@ -130,11 +130,8 @@ describe("Gloas Fork Choice", () => { expect(fullNode?.payloadStatus).toBe(PayloadStatus.FULL); // Should not have PENDING or EMPTY variants - const pendingNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING); - expect(pendingNode).toBeUndefined(); - - const emptyNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY); - expect(emptyNode).toBeUndefined(); + expect(() => getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING)).toThrow(); + expect(() => getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY)).toThrow(); }); it("getNode() finds pre-Gloas blocks by root (FULL)", () => { @@ -216,8 +213,7 @@ describe("Gloas Fork Choice", () => { const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); expect(fullNode).toBeDefined(); - const pendingNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING); - expect(pendingNode).toBeUndefined(); + expect(() => getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING)).toThrow(); }); }); From c911e167c58c0f3341197898735fe774cc40a55b Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:09:29 -0800 Subject: [PATCH 48/52] address comments --- packages/fork-choice/src/forkChoice/errors.ts | 7 ++++- .../fork-choice/src/forkChoice/forkChoice.ts | 29 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/errors.ts b/packages/fork-choice/src/forkChoice/errors.ts index cd0e0cd25274..58e16ecc9bec 100644 --- a/packages/fork-choice/src/forkChoice/errors.ts +++ b/packages/fork-choice/src/forkChoice/errors.ts @@ -53,6 +53,10 @@ export enum InvalidAttestationCode { * Delay consideration in the fork choice until their slot is in the past. */ FUTURE_SLOT = "FUTURE_SLOT", + /** + * The attestation data index is invalid for a Gloas block (must be 0 or 1). + */ + INVALID_DATA_INDEX = "INVALID_DATA_INDEX", } export type InvalidAttestation = @@ -64,7 +68,8 @@ export type InvalidAttestation = | {code: InvalidAttestationCode.PAST_EPOCH; attestationEpoch: Epoch; currentEpoch: Epoch} | {code: InvalidAttestationCode.INVALID_TARGET; attestation: RootHex; local: RootHex} | {code: InvalidAttestationCode.ATTESTS_TO_FUTURE_BLOCK; block: Slot; attestation: Slot} - | {code: InvalidAttestationCode.FUTURE_SLOT; attestationSlot: Slot; latestPermissibleSlot: Slot}; + | {code: InvalidAttestationCode.FUTURE_SLOT; attestationSlot: Slot; latestPermissibleSlot: Slot} + | {code: InvalidAttestationCode.INVALID_DATA_INDEX; index: number}; export enum ForkChoiceErrorCode { INVALID_ATTESTATION = "FORKCHOICE_ERROR_INVALID_ATTESTATION", diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 6cbaf235823b..c9fab5c55544 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -905,7 +905,13 @@ export class ForkChoice implements IForkChoice { } else if (attestationData.index === 0) { payloadStatus = PayloadStatus.EMPTY; } else { - payloadStatus = PayloadStatus.PENDING; + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.INVALID_ATTESTATION, + err: { + code: InvalidAttestationCode.INVALID_DATA_INDEX, + index: attestationData.index, + }, + }); } } else { payloadStatus = PayloadStatus.PENDING; @@ -964,8 +970,6 @@ export class ForkChoice implements IForkChoice { * Notify fork choice that an execution payload has arrived (Gloas fork) * Creates the FULL variant of a Gloas block when the payload becomes available * Spec: gloas/fork-choice.md#new-on_execution_payload - * - * */ onExecutionPayload( blockRoot: RootHex, @@ -1067,6 +1071,14 @@ export class ForkChoice implements IForkChoice { }; } + /** + * Returns a `ProtoBlock` with the default variant for the given block root + * - Pre-Gloas blocks: returns FULL variant (only variant) + * - Gloas blocks: returns PENDING variant + * + * Use this when you need the canonical block reference regardless of payload status. + * For searching by execution payload hash and variant-specific info, use `getBlockHexAndBlockHash` instead. + */ getBlockHexDefaultStatus(blockRoot: RootHex): ProtoBlock | null { const defaultStatus = this.protoArray.getDefaultVariant(blockRoot); if (defaultStatus === undefined) { @@ -1639,6 +1651,17 @@ export class ForkChoice implements IForkChoice { }); } + // For Gloas blocks, attestation index must be 0 or 1 + if (isGloasBlock(block) && attestationData.index !== 0 && attestationData.index !== 1) { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.INVALID_ATTESTATION, + err: { + code: InvalidAttestationCode.INVALID_DATA_INDEX, + index: attestationData.index, + }, + }); + } + this.validatedAttestationDatas.add(attDataRoot); } From d6f911bd8540f1e2179eb465d6058afa0cafb806 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:14:16 -0800 Subject: [PATCH 49/52] address comments --- .../fork-choice/src/forkChoice/forkChoice.ts | 4 + .../fork-choice/src/protoArray/protoArray.ts | 139 ++++++++++-------- 2 files changed, 82 insertions(+), 61 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index c9fab5c55544..59ee6ef2ccb9 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1393,6 +1393,10 @@ export class ForkChoice implements IForkChoice { // For the first slot of the epoch, a block is it's own target const nextRoot = block.blockRoot === block.targetRoot ? block.parentRoot : block.targetRoot; + // Use default variant (PENDING for Gloas, FULL for pre-Gloas) + // For Gloas: we search for PENDING blocks because dependent root is determined by the block itself, + // not the payload. In state-transition, block parentage is independent of payload status, + // so linking by PENDING block in fork-choice is correct. const defaultStatus = this.protoArray.getDefaultVariant(nextRoot); if (defaultStatus === undefined) { throw Error(`No block for root ${nextRoot}`); diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index c786e49009cf..819252707576 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1432,8 +1432,26 @@ export class ProtoArray { return this.nodes[parentVariantIndex]; } + /** + * Get the parent node index for traversal + * For Gloas blocks: returns the correct EMPTY/FULL variant based on parent payload status + * For pre-Gloas blocks: returns the simple parent index + * Returns undefined if parent doesn't exist or can't be found + */ + private getParentNodeIndex(node: ProtoNode): number | undefined { + if (isGloasBlock(node)) { + // Use getParentPayloadStatus for Gloas blocks to get correct EMPTY/FULL variant + const parentPayloadStatus = this.getParentPayloadStatus(node); + return this.getNodeIndexByRootAndStatus(node.parentRoot, parentPayloadStatus); + } + // Simple parent traversal for pre-Gloas blocks (includes fork transition) + return node.parent; + } + /** * Iterate from a block root backwards over nodes + * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status + * For pre-Gloas blocks: returns FULL variants */ *iterateAncestorNodes(blockRoot: RootHex): IterableIterator { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas @@ -1456,23 +1474,27 @@ export class ProtoArray { } /** - * Iterate from a block root backwards over nodes + * Iterate from a node backwards over ancestor nodes + * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status + * For pre-Gloas blocks: returns FULL variants + * Handles fork transition from Gloas to pre-Gloas blocks */ *iterateAncestorNodesFromNode(node: ProtoNode): IterableIterator { while (node.parent !== undefined) { - // Traverse to parent node - // Note: node.parent may point to EMPTY or FULL variant, but we only want to yield default variants - node = this.getNodeFromIndex(node.parent); - - // Only yield default variants (PENDING for Gloas, FULL for pre-Gloas) - if (this.isDefaultVariant(node)) { - yield node; + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; } + + node = this.nodes[parentIndex]; + yield node; } } /** * Get all nodes from a block root backwards + * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status + * For pre-Gloas blocks: returns FULL variants */ getAllAncestorNodes(blockRoot: RootHex): ProtoNode[] { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas @@ -1491,17 +1513,23 @@ export class ProtoArray { }); } - const nodes = [node]; + // Include starting node if node is pre-gloas + // Reason why we exclude post-gloas is because node is always default variant (PENDING) + // which we want to exclude. + const nodes: ProtoNode[] = []; - while (node.parent !== undefined) { - // Traverse to parent node - // Note: node.parent may point to EMPTY or FULL variant, but we only want to collect default variants - node = this.getNodeFromIndex(node.parent); + if (!isGloasBlock(node)) { + nodes.push(node); + } - // Only collect default variants (PENDING for Gloas, FULL for pre-Gloas) - if (this.isDefaultVariant(node)) { - nodes.push(node); + while (node.parent !== undefined) { + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; } + + node = this.nodes[parentIndex]; + nodes.push(node); } return nodes; @@ -1512,7 +1540,8 @@ export class ProtoArray { * iterateNodes is to find ancestor nodes of a blockRoot. * this is to find non-ancestor nodes of a blockRoot. * - * Only default variant (FULL pre-gloas and PENDING post-gloas) are returned + * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status + * For pre-Gloas blocks: returns FULL variants */ getAllNonAncestorNodes(blockRoot: RootHex): ProtoNode[] { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas @@ -1532,31 +1561,33 @@ export class ProtoArray { index: startIndex, }); } + + // For both Gloas and pre-Gloas blocks const result: ProtoNode[] = []; let nodeIndex = startIndex; while (node.parent !== undefined) { - // Traverse to parent - may point to any variant - const parentIndex = node.parent; - node = this.getNodeFromIndex(parentIndex); - - if (!this.isDefaultVariant(node)) { - // Parent is non-default variant, need to find default variant - // Skip to next iteration to find the default variant - continue; + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; } - // nodes between nodeIndex and parentIndex means non-ancestor nodes - // Excludes all EMPTY/FULL variant post-gloas - result.push(...this.getNodesBetween(nodeIndex, parentIndex).filter(this.isDefaultVariant)); + node = this.nodes[parentIndex]; + // Collect non-ancestor nodes between current and parent + // Filter to exclude PENDING nodes (FULL variant pre-gloas, EMPTY or FULL variant post-gloas) + result.push( + ...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING) + ); nodeIndex = parentIndex; } - result.push(...this.getNodesBetween(nodeIndex, 0).filter(this.isDefaultVariant)); + // Collect remaining nodes from nodeIndex to beginning + result.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)); return result; } /** * Returns both ancestor and non-ancestor nodes in a single traversal. - * Only default variant (FULL pre-gloas and PENDING post-gloas) are returned + * For Gloas blocks: returns EMPTY/FULL variants (not PENDING) based on parent payload status + * For pre-Gloas blocks: returns FULL variants */ getAllAncestorAndNonAncestorNodes(blockRoot: RootHex): {ancestors: ProtoNode[]; nonAncestors: ProtoNode[]} { // Get canonical node: FULL for pre-Gloas, PENDING for Gloas @@ -1578,33 +1609,31 @@ export class ProtoArray { const ancestors: ProtoNode[] = []; const nonAncestors: ProtoNode[] = []; + // Include starting node if it's not PENDING (i.e., pre-Gloas or EMPTY/FULL variant post-Gloas) + if (node.payloadStatus !== PayloadStatus.PENDING) { + ancestors.push(node); + } + let nodeIndex = startIndex; while (node.parent !== undefined) { - // Only add default variants to ancestors - if (this.isDefaultVariant(node)) { - ancestors.push(node); + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; } - // Traverse to parent - may point to any variant - const parentIndex = node.parent; - node = this.getNodeFromIndex(parentIndex); - - if (!this.isDefaultVariant(node)) { - // Parent is non-default variant, skip to next iteration to find default variant - continue; - } + node = this.nodes[parentIndex]; + ancestors.push(node); - // Nodes between nodeIndex and parentIndex are non-ancestor nodes - // Filter out all FULL/EMPTY variants post-gloas - nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex).filter(this.isDefaultVariant)); + // Collect non-ancestor nodes between current and parent + // Filter to exclude PENDING nodes (include all FULL/EMPTY for both pre-Gloas and Gloas) + nonAncestors.push( + ...this.getNodesBetween(nodeIndex, parentIndex).filter((n) => n.payloadStatus !== PayloadStatus.PENDING) + ); nodeIndex = parentIndex; } - // Add final node if it's a default variant - if (this.isDefaultVariant(node)) { - ancestors.push(node); - } - nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter(this.isDefaultVariant)); + // Collect remaining non-ancestor nodes from nodeIndex to beginning + nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)); return {ancestors, nonAncestors}; } @@ -1783,16 +1812,4 @@ export class ProtoArray { } return result; } - - /** - * Check if a node is a default variant (PENDING for Gloas, FULL for pre-Gloas) - * Determines this directly from the node's properties without looking up indices map - */ - private isDefaultVariant = (node: ProtoNode): boolean => { - // For Gloas blocks (parentBlockHash !== null), default is PENDING - // For pre-Gloas blocks (parentBlockHash === null), default is FULL - return isGloasBlock(node) - ? node.payloadStatus === PayloadStatus.PENDING - : node.payloadStatus === PayloadStatus.FULL; - }; } From 330f23eb2bde3f7fc33d20993c67199203e16388 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:17:00 -0800 Subject: [PATCH 50/52] Update types --- packages/fork-choice/src/forkChoice/forkChoice.ts | 14 +++++++++----- packages/fork-choice/src/forkChoice/interface.ts | 4 ++-- packages/fork-choice/src/forkChoice/store.ts | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 59ee6ef2ccb9..4976a22748bd 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1103,11 +1103,15 @@ export class ForkChoice implements IForkChoice { return node.executionPayloadBlockHash === blockHash ? node : null; } - // Post-Gloas - for (const variantIndex of variantIndices) { - const node = this.protoArray.nodes[variantIndex]; - if (node.executionPayloadBlockHash === blockHash) { - return node; + // Post-Gloas: Prioritize FULL > EMPTY > PENDING + // EMPTY and PENDING have the same block hash (parent hash), so we prefer EMPTY over PENDING + for (const status of [PayloadStatus.FULL, PayloadStatus.EMPTY, PayloadStatus.PENDING]) { + const variantIndex = variantIndices[status]; + if (variantIndex !== undefined) { + const node = this.protoArray.nodes[variantIndex]; + if (node.executionPayloadBlockHash === blockHash) { + return node; + } } } diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 0782322a3bb8..8b74b44af856 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -124,8 +124,8 @@ export interface IForkChoice { * Retrieve all nodes for the debug API. */ getAllNodes(): ProtoNode[]; - getFinalizedCheckpoint(): CheckpointWithHex; - getJustifiedCheckpoint(): CheckpointWithHex; + getFinalizedCheckpoint(): CheckpointWithPayload; + getJustifiedCheckpoint(): CheckpointWithPayload; /** * Add `block` to the fork choice DAG. * diff --git a/packages/fork-choice/src/forkChoice/store.ts b/packages/fork-choice/src/forkChoice/store.ts index d71b75c64b37..8e817987d7e6 100644 --- a/packages/fork-choice/src/forkChoice/store.ts +++ b/packages/fork-choice/src/forkChoice/store.ts @@ -29,7 +29,7 @@ export type JustifiedBalances = EffectiveBalanceIncrements; * @param blockState state that declares justified checkpoint `checkpoint` */ export type JustifiedBalancesGetter = ( - checkpoint: CheckpointWithHex, + checkpoint: CheckpointWithPayload, blockState: CachedBeaconStateAllForks ) => JustifiedBalances; From 6e1493dd937415da9fff5161eb9ed2afc28ce2e9 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:28:32 -0800 Subject: [PATCH 51/52] check-types --- .../test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts index 294ce7a58c58..f1edb03d9789 100644 --- a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts @@ -197,7 +197,7 @@ function getForkChoice(knownBlocks: SignedBeaconBlock[], finalizedEpoch = 0): IF return blocks.has(blockRoot); }, getFinalizedCheckpoint() { - return {epoch: finalizedEpoch, root: Buffer.alloc(32), rootHex: ""}; + return {epoch: finalizedEpoch, root: Buffer.alloc(32), rootHex: "", payloadStatus: PayloadStatus.FULL}; }, } as Partial as IForkChoice; } From f6b589e257762ffa6d6e58ca977b643b9895c45b Mon Sep 17 00:00:00 2001 From: twoeths <10568965+twoeths@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:14:55 +0700 Subject: [PATCH 52/52] refactor: ePBS forkchoice getParentPayloadStatus() (#8869) **Motivation** - getParentPayloadStatus() is confusing and likely not correct right now, we can just leverage the `getBlockHexAndBlockHash()` instead **Description** - move `getBlockHexAndBlockHash()` from forkchoice to ProtoArray - one small change is not to check PENDING as it's not useful for beacon-node - refactor `getParentPayloadStatus()` to call `getBlockHexAndBlockHash()` - add `getParent()` to also call `getBlockHexAndBlockHash()` - `forkchoice.onBlock()` needs to find exact parent via `protoArray.getParent()` because we always have success state-transition before calling forkchoice - enhance error codes, more comments part of #8739 cc @ensi321 @nflaig --------- Co-authored-by: Tuyen Nguyen --- packages/fork-choice/src/forkChoice/errors.ts | 2 +- .../fork-choice/src/forkChoice/forkChoice.ts | 42 ++------ packages/fork-choice/src/protoArray/errors.ts | 2 + .../fork-choice/src/protoArray/interface.ts | 5 + .../fork-choice/src/protoArray/protoArray.ts | 96 ++++++++++++++----- .../test/unit/protoArray/gloas.test.ts | 12 +-- 6 files changed, 97 insertions(+), 62 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/errors.ts b/packages/fork-choice/src/forkChoice/errors.ts index 58e16ecc9bec..c79309b17547 100644 --- a/packages/fork-choice/src/forkChoice/errors.ts +++ b/packages/fork-choice/src/forkChoice/errors.ts @@ -9,7 +9,7 @@ export enum InvalidBlockCode { } export type InvalidBlock = - | {code: InvalidBlockCode.UNKNOWN_PARENT; root: RootHex} + | {code: InvalidBlockCode.UNKNOWN_PARENT; root: RootHex; hash: RootHex | null} | {code: InvalidBlockCode.FUTURE_SLOT; currentSlot: Slot; blockSlot: Slot} | {code: InvalidBlockCode.FINALIZED_SLOT; finalizedSlot: Slot; blockSlot: Slot} | {code: InvalidBlockCode.NOT_FINALIZED_DESCENDANT; finalizedRoot: RootHex; blockAncestor?: RootHex}; diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 4976a22748bd..4abf4265adf4 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -594,17 +594,18 @@ export class ForkChoice implements IForkChoice { ): ProtoBlock { const {parentRoot, slot} = block; const parentRootHex = toRootHex(parentRoot); - // Parent block must be known - // We do not care about the variant here, we just need to find the parent block - const defaultStatus = this.protoArray.getDefaultVariant(parentRootHex); - const parentBlock = - defaultStatus !== undefined ? this.protoArray.getBlock(parentRootHex, defaultStatus) : undefined; + // Parent block must be known because state_transition would have failed otherwise. + const parentHashHex = isGloasBeaconBlock(block) + ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) + : null; + const parentBlock = this.protoArray.getParent(parentRootHex, parentHashHex); if (!parentBlock) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.INVALID_BLOCK, err: { code: InvalidBlockCode.UNKNOWN_PARENT, root: parentRootHex, + hash: parentHashHex, }, }); } @@ -834,9 +835,7 @@ export class ForkChoice implements IForkChoice { blockHashFromBid: isGloasBeaconBlock(block) ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) : null, - parentBlockHash: isGloasBeaconBlock(block) - ? toRootHex(block.body.signedExecutionPayloadBid.message.parentBlockHash) - : null, + parentBlockHash: parentHashHex, }; this.protoArray.onBlock(protoBlock, currentSlot); @@ -1089,33 +1088,10 @@ export class ForkChoice implements IForkChoice { } /** - * Returns a `ProtoBlock` that has matching block root and block hash + * Returns EMPTY or FULL `ProtoBlock` that has matching block root and block hash */ getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null { - const variantIndices = this.protoArray.indices.get(blockRoot); - if (variantIndices === undefined) { - return null; - } - - // Pre-Gloas - if (!Array.isArray(variantIndices)) { - const node = this.protoArray.nodes[variantIndices]; - return node.executionPayloadBlockHash === blockHash ? node : null; - } - - // Post-Gloas: Prioritize FULL > EMPTY > PENDING - // EMPTY and PENDING have the same block hash (parent hash), so we prefer EMPTY over PENDING - for (const status of [PayloadStatus.FULL, PayloadStatus.EMPTY, PayloadStatus.PENDING]) { - const variantIndex = variantIndices[status]; - if (variantIndex !== undefined) { - const node = this.protoArray.nodes[variantIndex]; - if (node.executionPayloadBlockHash === blockHash) { - return node; - } - } - } - - return null; + return this.protoArray.getBlockHexAndBlockHash(blockRoot, blockHash); } getJustifiedBlock(): ProtoBlock { diff --git a/packages/fork-choice/src/protoArray/errors.ts b/packages/fork-choice/src/protoArray/errors.ts index fae807da058c..92b82242a4ac 100644 --- a/packages/fork-choice/src/protoArray/errors.ts +++ b/packages/fork-choice/src/protoArray/errors.ts @@ -13,6 +13,7 @@ export enum ProtoArrayErrorCode { FINALIZED_NODE_UNKNOWN = "PROTO_ARRAY_ERROR_FINALIZED_NODE_UNKNOWN", JUSTIFIED_NODE_UNKNOWN = "PROTO_ARRAY_ERROR_JUSTIFIED_NODE_UNKNOWN", UNKNOWN_BLOCK = "PROTO_ARRAY_ERROR_UNKNOWN_BLOCK", + UNKNOWN_PARENT_BLOCK = "PROTO_ARRAY_ERROR_UNKNOWN_PARENT_BLOCK", INVALID_FINALIZED_ROOT_CHANGE = "PROTO_ARRAY_ERROR_INVALID_FINALIZED_ROOT_CHANGE", INVALID_NODE_INDEX = "PROTO_ARRAY_ERROR_INVALID_NODE_INDEX", INVALID_PARENT_INDEX = "PROTO_ARRAY_ERROR_INVALID_PARENT_INDEX", @@ -35,6 +36,7 @@ export type ProtoArrayErrorType = | {code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN; root: RootHex} | {code: ProtoArrayErrorCode.JUSTIFIED_NODE_UNKNOWN; root: RootHex} | {code: ProtoArrayErrorCode.UNKNOWN_BLOCK; root: RootHex} + | {code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK; parentRoot: RootHex; parentHash: RootHex | null} | {code: ProtoArrayErrorCode.INVALID_FINALIZED_ROOT_CHANGE} | {code: ProtoArrayErrorCode.INVALID_NODE_INDEX; index: number} | {code: ProtoArrayErrorCode.INVALID_PARENT_INDEX; index: number} diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index ff9ff5903b32..a9347cc08c17 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -56,6 +56,11 @@ export type MaybeValidExecutionStatus = Exclude; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 819252707576..6be588b63ced 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -170,44 +170,96 @@ export class ProtoArray { /** * Determine which parent payload status a block extends * Spec: gloas/fork-choice.md#new-get_parent_payload_status + * def get_parent_payload_status(store: Store, block: BeaconBlock) -> PayloadStatus: + * parent = store.blocks[block.parent_root] + * parent_block_hash = block.body.signed_execution_payload_bid.message.parent_block_hash + * message_block_hash = parent.body.signed_execution_payload_bid.message.block_hash + * return PAYLOAD_STATUS_FULL if parent_block_hash == message_block_hash else PAYLOAD_STATUS_EMPTY * - * Compares parent_block_hash in child's bid with executionPayloadBlockHash in parent: - * - Match → child extends FULL parent (parent has payload) - * - No match → child extends EMPTY parent (parent has no payload) + * In lodestar forkchoice, we don't store the full bid, so we compares parent_block_hash in child's bid with executionPayloadBlockHash in parent: + * - If it matches EMPTY variant, return EMPTY + * - If it matches FULL variant, return FULL + * - If no match, throw UNKNOWN_PARENT_BLOCK error * * For pre-Gloas blocks: always returns FULL */ getParentPayloadStatus(block: ProtoBlock): PayloadStatus { // Pre-Gloas blocks have payloads embedded, so parents are always FULL - if (!isGloasBlock(block)) { + const {parentBlockHash} = block; + if (parentBlockHash === null) { return PayloadStatus.FULL; } - // Gloas block must have parentBlockHash from its SignedExecutionPayloadBid - // Get parent node to compare execution payload hash - // Use variants[0] which works for both pre-Gloas (FULL) and Gloas (PENDING) - const parentVariants = this.indices.get(block.parentRoot); - if (parentVariants == null) { - // Parent not found + const parentBlock = this.getBlockHexAndBlockHash(block.parentRoot, parentBlockHash); + if (parentBlock == null) { throw new ProtoArrayError({ - code: ProtoArrayErrorCode.UNKNOWN_BLOCK, - root: block.parentRoot, + code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK, + parentRoot: block.parentRoot, + parentHash: parentBlockHash, }); } - const parentBlockHash = block.parentBlockHash; - // Pre-Gloas blocks don't have parentBlockHash - if (parentBlockHash === null || !Array.isArray(parentVariants)) { - return PayloadStatus.FULL; + return parentBlock.payloadStatus; + } + + /** + * Return the parent `ProtoBlock` given its root and block hash. + */ + getParent(parentRoot: RootHex, parentBlockHash: RootHex | null): ProtoBlock | null { + // pre-gloas + if (parentBlockHash === null) { + const parentIndex = this.indices.get(parentRoot); + if (parentIndex === undefined) { + return null; + } + if (Array.isArray(parentIndex)) { + // Gloas block found when pre-gloas expected + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK, + parentRoot, + parentHash: parentBlockHash, + }); + } + return this.nodes[parentIndex] ?? null; + } + + // post-gloas + return this.getBlockHexAndBlockHash(parentRoot, parentBlockHash); + } + + /** + * Returns an EMPTY or FULL `ProtoBlock` that has matching block root and block hash + */ + getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null { + const variantIndices = this.indices.get(blockRoot); + if (variantIndices === undefined) { + return null; + } + + // Pre-Gloas + if (!Array.isArray(variantIndices)) { + const node = this.nodes[variantIndices]; + return node.executionPayloadBlockHash === blockHash ? node : null; + } + + // Post-Gloas, check empty and full variants + const fullNodeIndex = variantIndices[PayloadStatus.FULL]; + if (fullNodeIndex !== undefined) { + const fullNode = this.nodes[fullNodeIndex]; + if (fullNode && fullNode.executionPayloadBlockHash === blockHash) { + return fullNode; + } + } + + const emptyNode = this.nodes[variantIndices[PayloadStatus.EMPTY]]; + if (emptyNode && emptyNode.executionPayloadBlockHash === blockHash) { + return emptyNode; } - const parentIndex = parentVariants[0]; - const parentExecutionHash = this.nodes[parentIndex].executionPayloadBlockHash; + // PENDING is the same to EMPTY so not likely we can return it + // also it's only specific for fork-choice - // Compare parent_block_hash from child's bid with parent's execution payload hash - // Match means child extends FULL variant (parent has payload) - // No match means child extends EMPTY variant (parent has no payload) - return parentBlockHash === parentExecutionHash ? PayloadStatus.FULL : PayloadStatus.EMPTY; + return null; } /** diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index d14c2a04aa62..fb6d390e8670 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -496,17 +496,17 @@ describe("Gloas Fork Choice", () => { it("inter-block: new PENDING extends parent's EMPTY or FULL", () => { // Block A - const blockA = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + const blockA = createTestBlock(gloasForkSlot, "0x02Root", genesisRoot, genesisRoot); protoArray.onBlock(blockA, gloasForkSlot); - protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); + protoArray.onExecutionPayload("0x02Root", gloasForkSlot, "0x02Hash", gloasForkSlot, stateRoot); // Block B extends A's FULL (parentBlockHash matches) - const blockB = createTestBlock(gloasForkSlot + 1, "0x03", "0x02", "0x02"); + const blockB = createTestBlock(gloasForkSlot + 1, "0x03Root", "0x02Root", "0x02Hash"); protoArray.onBlock(blockB, gloasForkSlot + 1); - const blockAPending = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); - const blockAFull = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL); - const blockBPending = getNodeByPayloadStatus(protoArray, "0x03", PayloadStatus.PENDING); + const blockAPending = protoArray.getNodeIndexByRootAndStatus("0x02Root", PayloadStatus.PENDING); + const blockAFull = protoArray.getNodeIndexByRootAndStatus("0x02Root", PayloadStatus.FULL); + const blockBPending = getNodeByPayloadStatus(protoArray, "0x03Root", PayloadStatus.PENDING); // Block B's PENDING should NOT point to A's PENDING expect(blockBPending?.parent).not.toBe(blockAPending);