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 7b7ab22c13f9..3ae12730b448 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -201,7 +201,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 18c52450c17e..3fd78180a698 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -386,7 +386,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)}`); } @@ -398,7 +398,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 cfb34865618d..6bd54b2ce7af 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"; @@ -340,7 +340,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 ); @@ -436,7 +436,12 @@ 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 = 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 63e5c7b471c5..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.getBlockHex(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/chain.ts b/packages/beacon-node/src/chain/chain.ts index 1c5bb212b8c1..090b68700141 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -592,7 +592,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, @@ -608,7 +608,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, @@ -642,7 +642,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, @@ -659,7 +659,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, @@ -703,7 +703,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) { // Block found in fork-choice. // It may be in the block input cache, awaiting full DA reconstruction, check there first @@ -727,7 +727,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/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/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index 051f07e04ba1..af3a040a8d17 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -25,9 +25,13 @@ export type ExecutionPayloadEnvelopeErrorType = | { code: ExecutionPayloadEnvelopeErrorCode.BUILDER_INDEX_MISMATCH; envelopeBuilderIndex: BuilderIndex; - bidBuilderIndex: BuilderIndex; + bidBuilderIndex: BuilderIndex | null; + } + | { + code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; + envelopeBlockHash: RootHex; + bidBlockHash: RootHex | null; } - | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; envelopeBlockHash: RootHex; bidBlockHash: RootHex} | {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 31548ddbf8f6..2b080934045e 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -4,9 +4,11 @@ import { ForkChoice, ForkChoiceStore, JustifiedBalancesGetter, + PayloadStatus, ProtoArray, ProtoBlock, ForkChoiceOpts as RawForkChoiceOpts, + getCheckpointPayloadStatus, } from "@lodestar/fork-choice"; import {ZERO_HASH_HEX} from "@lodestar/params"; import { @@ -104,6 +106,14 @@ export function initializeForkChoiceFromFinalizedState( // production code use ForkChoice constructor directly const forkchoiceConstructor = opts.forkchoiceConstructor ?? ForkChoice; + 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, @@ -113,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), @@ -145,15 +157,12 @@ export function initializeForkChoiceFromFinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, - ...(computeEpochAtSlot(blockHeader.slot) < state.config.GLOAS_FORK_EPOCH - ? { - builderIndex: undefined, - blockHashHex: undefined, - } - : { - builderIndex: (state as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex, - blockHashHex: toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash), - }), + 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, + parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, }, currentSlot ), @@ -196,12 +205,22 @@ 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), @@ -235,15 +254,14 @@ export function initializeForkChoiceFromUnfinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, - ...(computeEpochAtSlot(blockHeader.slot) < unfinalizedState.config.GLOAS_FORK_EPOCH - ? { - builderIndex: undefined, - blockHashHex: undefined, - } - : { - builderIndex: (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex, - blockHashHex: toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash), - }), + 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, + parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null, }; const parentSlot = blockHeader.slot - 1; 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 e99b06e78577..04011d53cbd1 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"; @@ -88,7 +88,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 7dfebb9af51d..6ad654280293 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} from "@lodestar/types"; +import {BeaconBlock, RootHex, SignedBeaconBlock, Slot, isGloasBeaconBlock} from "@lodestar/types"; import {Logger, fromHex, toRootHex} from "@lodestar/utils"; import {IBeaconDb} from "../../db/index.js"; import {Metrics} from "../../metrics/index.js"; @@ -58,7 +58,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, diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 162788f6caf1..6956e62ed392 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 fd49a8fb494d..45950b259f0b 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) { @@ -753,7 +753,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 e5864e61b86c..a871f1764f95 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 c8effa332c7c..7f8030450d02 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"; @@ -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,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.getBlockHex(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/beacon-node/src/chain/validation/dataColumnSidecar.ts b/packages/beacon-node/src/chain/validation/dataColumnSidecar.ts index c0ec963f007e..acf53d90942c 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/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 771f9d1e2842..58863dae5fc5 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -113,8 +113,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 33e7db7bbcb5..908a19528911 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/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts b/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts index 751f9d4980ee..3113a21ea703 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/blobSidecarsByRoot.ts @@ -20,7 +20,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 8e598ebe5a4d..aa51c73b1d97 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/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 2907e6da4081..39a98d95b6df 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/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index 9c04938cf492..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, @@ -68,6 +68,11 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: 2, // PayloadStatus.FULL + builderIndex: null, + blockHashFromBid: null, }, originalState.slot ); @@ -93,6 +98,11 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: 2, // PayloadStatus.FULL + builderIndex: null, + blockHashFromBid: null, }, slot ); @@ -106,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/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 621cd9c2bd50..498262674428 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/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 5c76c2599cc6..99ff85ff5da0 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: toRootHex(fullBlock.parentRoot), } as ProtoBlock); modules.chain.getProposerHead.mockReturnValue({blockRoot: toRootHex(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, @@ -185,7 +185,7 @@ describe("api/validator - produceBlockV3", () => { modules.chain.getProposerHead.mockReturnValue(parentBlock); modules.chain.recomputeForkChoiceHead.mockReturnValue(parentBlock); - modules.chain.forkChoice.getBlock.mockReturnValue(parentBlock); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(parentBlock); 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..f1edb03d9789 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,15 +25,20 @@ 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 - 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); }); @@ -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 @@ -180,14 +190,14 @@ function getForkChoice(knownBlocks: SignedBeaconBlock[], finalizedEpoch = 0): IF } return { - getBlockHex(blockRoot) { + getBlockHexDefaultStatus(blockRoot) { return blocks.get(blockRoot) ?? null; }, hasBlockHex(blockRoot) { 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; } 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/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 29defe4c28fc..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"; @@ -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; @@ -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), @@ -80,7 +90,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 +110,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 +122,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 +134,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 +146,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 +160,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 +176,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 +195,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 +216,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 +242,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/state.ts b/packages/beacon-node/test/utils/state.ts index 64ee9d9514f9..eb20c875740e 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,8 @@ export const zeroProtoBlock: ProtoBlock = { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, + parentBlockHash: null, }; diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 3932d38a38cb..61ad1190ef7a 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -76,8 +76,14 @@ 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 + builderIndex: null, + blockHashFromBid: null, }; const shufflingCache = new ShufflingCache(null, null, {}, [ @@ -104,6 +110,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; diff --git a/packages/fork-choice/src/forkChoice/errors.ts b/packages/fork-choice/src/forkChoice/errors.ts index cd0e0cd25274..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}; @@ -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 1d87d6608c40..4abf4265adf4 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, @@ -37,9 +38,11 @@ import { LVHExecResponse, MaybeValidExecutionStatus, NULL_VOTE_INDEX, + PayloadStatus, ProtoBlock, ProtoNode, VoteIndex, + isGloasBlock, } from "../protoArray/interface.js"; import {ProtoArray} from "../protoArray/protoArray.js"; import {ForkChoiceError, ForkChoiceErrorCode, InvalidAttestationCode, InvalidBlockCode} from "./errors.js"; @@ -51,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; @@ -71,7 +74,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": @@ -94,18 +97,28 @@ 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 + * + * 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 voteNextEpochs: Epoch[]; + private readonly voteNextSlots: Slot[]; /** * 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()) ); /** @@ -150,13 +163,14 @@ 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.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); @@ -177,7 +191,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); } @@ -237,11 +251,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}; @@ -257,7 +270,10 @@ 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 @@ -282,7 +298,10 @@ 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}; } @@ -316,7 +335,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.", { @@ -363,7 +382,10 @@ 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) { @@ -400,7 +422,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}; @@ -412,7 +434,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}; @@ -509,23 +531,10 @@ export class ForkChoice implements IForkChoice { currentSlot, }); - const headRoot = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); - const headIndex = this.protoArray.indices.get(headRoot); - if (headIndex === undefined) { - throw new ForkChoiceError({ - code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: headRoot, - }); - } - const headNode = this.protoArray.nodes[headIndex]; - if (headNode === undefined) { - throw new ForkChoiceError({ - code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: headRoot, - }); - } + // findHead returns the ProtoNode representing the head + const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); - this.head = headNode; + this.head = head; return this.head; } @@ -548,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; } @@ -585,14 +594,18 @@ export class ForkChoice implements IForkChoice { ): ProtoBlock { const {parentRoot, slot} = block; const parentRootHex = toRootHex(parentRoot); - // Parent block must be known - const parentBlock = this.protoArray.getBlock(parentRootHex); + // 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, }, }); } @@ -627,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, }, }); } @@ -655,10 +668,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, () => @@ -679,29 +697,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; @@ -743,27 +789,53 @@ export class ForkChoice implements IForkChoice { unrealizedFinalizedEpoch: unrealizedFinalizedCheckpoint.epoch, unrealizedFinalizedRoot: unrealizedFinalizedCheckpoint.rootHex, - ...(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), - }), ...(isGloasBeaconBlock(block) ? { - builderIndex: block.body.signedExecutionPayloadBid.message.builderIndex, - blockHashHex: toRootHex(block.body.signedExecutionPayloadBid.message.blockHash), + 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: (() => { + // 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, } - : { - builderIndex: undefined, - blockHashHex: undefined, - }), + : 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), + }), + + 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, + parentBlockHash: parentHashHex, }; this.protoArray.onBlock(protoBlock, currentSlot); @@ -813,10 +885,45 @@ 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; + + // 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.getBlockHexDefaultStatus(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 (slot > block.slot) { + if (attestationData.index === 1) { + payloadStatus = PayloadStatus.FULL; + } else if (attestationData.index === 0) { + payloadStatus = PayloadStatus.EMPTY; + } else { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.INVALID_ATTESTATION, + err: { + code: InvalidAttestationCode.INVALID_DATA_INDEX, + index: attestationData.index, + }, + }); + } + } else { + payloadStatus = PayloadStatus.PENDING; + } + } else { + // Pre-Gloas block or block not found: always FULL + payloadStatus = PayloadStatus.FULL; + } + 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 { @@ -827,10 +934,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); } } } @@ -849,6 +956,35 @@ 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); + } + + /** + * 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, + executionPayloadStateRoot: RootHex + ): void { + this.protoArray.onExecutionPayload( + blockRoot, + this.fcStore.currentSlot, + executionPayloadBlockHash, + executionPayloadNumber, + executionPayloadStateRoot + ); + } + /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. * This should only be called once per slot because: @@ -880,15 +1016,21 @@ 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)); } /** * 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; } @@ -913,8 +1055,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 node = this.protoArray.getNode(blockRoot); + getBlockHex(blockRoot: RootHex, payloadStatus: PayloadStatus): ProtoBlock | null { + const node = this.protoArray.getNode(blockRoot, payloadStatus); if (!node) { return null; } @@ -928,23 +1070,49 @@ 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) { + return null; + } + + return this.getBlockHex(blockRoot, defaultStatus); + } + + /** + * Returns EMPTY or FULL `ProtoBlock` that has matching block root and block hash + */ + getBlockHexAndBlockHash(blockRoot: RootHex, blockHash: RootHex): ProtoBlock | null { + return this.protoArray.getBlockHexAndBlockHash(blockRoot, blockHash); + } + getJustifiedBlock(): ProtoBlock { - const block = this.getBlockHex(this.fcStore.justified.checkpoint.rootHex); + const {rootHex, payloadStatus} = this.fcStore.justified.checkpoint; + const block = this.getBlockHex(rootHex, payloadStatus); 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, payloadStatus} = this.fcStore.finalizedCheckpoint; + const block = this.getBlockHex(rootHex, payloadStatus); if (!block) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, - root: this.fcStore.finalizedCheckpoint.rootHex, + root: rootHex, }); } return block; @@ -971,7 +1139,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) { @@ -1074,17 +1242,24 @@ 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]); - 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 = 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]; if (rootsInChain.has(node.parentRoot)) { @@ -1114,8 +1289,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}; } @@ -1196,12 +1371,17 @@ 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); + // 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}`); + } + block = this.protoArray.getBlockReadonly(nextRoot, defaultStatus); } throw Error(`Not found dependent root for block slot ${block.slot}, epoch difference ${epochDifference}`); @@ -1267,12 +1447,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. @@ -1292,8 +1472,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) { @@ -1412,7 +1592,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, @@ -1453,33 +1635,57 @@ 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); } /** * 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); + // 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.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.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; } // else its an old vote, don't count it } @@ -1491,18 +1697,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 { @@ -1616,3 +1821,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 32d9b6c68f78..8b74b44af856 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -4,9 +4,15 @@ 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"; +import {CheckpointWithHex, CheckpointWithPayload} from "./store.js"; export type CheckpointHex = { epoch: Epoch; @@ -18,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; }; @@ -72,16 +78,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. * @@ -104,7 +112,7 @@ export interface IForkChoice { * called by `predictProposerHead()` during `prepareNextSlot()`. */ shouldOverrideForkChoiceUpdate( - blockRoot: RootHex, + headBlock: ProtoBlock, secFromSlot: number, currentSlot: Slot ): ShouldOverrideForkChoiceUpdateResult; @@ -116,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. * @@ -169,6 +177,38 @@ 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 + * @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, + executionPayloadStateRoot: RootHex + ): void; /** * Call `onTick` for all slots between `fcStore.getCurrentSlot()` and the provided `currentSlot`. */ @@ -192,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/forkChoice/store.ts b/packages/fork-choice/src/forkChoice/store.ts index fda01689f96c..8e817987d7e6 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; /** @@ -19,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; @@ -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 e476660ee9ad..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, @@ -30,5 +38,5 @@ export type { ProtoBlock, ProtoNode, } from "./protoArray/interface.js"; -export {ExecutionStatus} 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 844d620f9998..44db719846b8 100644 --- a/packages/fork-choice/src/protoArray/computeDeltas.ts +++ b/packages/fork-choice/src/protoArray/computeDeltas.ts @@ -66,6 +66,7 @@ export function computeDeltas( for (let vIndex = 0; vIndex < voteNextIndices.length; vIndex++) { currentIndex = voteCurrentIndices[vIndex]; nextIndex = voteNextIndices[vIndex]; + // 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) { @@ -106,6 +107,9 @@ export function computeDeltas( continue; } + // 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 @@ -116,6 +120,7 @@ export function computeDeltas( index: currentIndex, }); } + deltas[currentIndex] -= oldBalance; } @@ -128,6 +133,7 @@ export function computeDeltas( index: nextIndex, }); } + deltas[nextIndex] += newBalance; } voteCurrentIndices[vIndex] = nextIndex; diff --git a/packages/fork-choice/src/protoArray/errors.ts b/packages/fork-choice/src/protoArray/errors.ts index 650fd1c0b244..92b82242a4ac 100644 --- a/packages/fork-choice/src/protoArray/errors.ts +++ b/packages/fork-choice/src/protoArray/errors.ts @@ -12,6 +12,8 @@ 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", + 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", @@ -27,11 +29,14 @@ 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 = | {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} @@ -54,6 +59,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/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 9c476b29a372..a9347cc08c17 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -24,6 +24,23 @@ 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, +} + +/** + * 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; @@ -39,6 +56,11 @@ export type MaybeValidExecutionStatus = Exclude; @@ -90,14 +112,25 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; - // GLOAS: The followings are from bids. Used for execution payload gossip validation - builderIndex?: number; - blockHashHex?: RootHex; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ + payloadStatus: PayloadStatus; + + // GLOAS: The followings are from bids. They are null in pre-gloas + // Used for execution payload gossip validation + 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 + parentBlockHash: RootHex | null; }; /** * 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; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 50a994d46476..6be588b63ced 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,16 +1,40 @@ -import {GENESIS_EPOCH} from "@lodestar/params"; +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 {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, + HEX_ZERO_HASH, + LVHExecResponse, + PayloadStatus, + ProtoBlock, + ProtoNode, + isGloasBlock, +} 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}; 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 @@ -20,11 +44,31 @@ export class ProtoArray { finalizedEpoch: Epoch; finalizedRoot: RootHex; nodes: ProtoNode[] = []; - indices = new Map(); + /** + * Maps block root to array of node indices for each payload status variant + * + * 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) + * + * Note: undefined array elements indicate that variant doesn't exist for this block + */ + indices = new Map(); lvhError?: LVHExecError; private previousProposerBoost: ProposerBoost | null = null; + /** + * 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, @@ -64,6 +108,160 @@ export class ProtoArray { return protoArray; } + /** + * Get node index for a block root and payload status + * + * @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 + * + * Behavior: + * - Pre-Gloas blocks: only FULL is valid, PENDING/EMPTY throw error + * - 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 { + const variantOrArr = this.indices.get(root); + if (variantOrArr == null) { + return undefined; + } + + // 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 variantOrArr; + } + // 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 variantOrArr[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 variantOrArr = this.indices.get(blockRoot); + if (variantOrArr == null) { + return undefined; + } + + // Pre-Gloas: only FULL variant exists + if (!Array.isArray(variantOrArr)) { + return PayloadStatus.FULL; + } + + // Gloas: multiple variants exist, PENDING is canonical + return PayloadStatus.PENDING; + } + + /** + * 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 + * + * 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 + const {parentBlockHash} = block; + if (parentBlockHash === null) { + return PayloadStatus.FULL; + } + + const parentBlock = this.getBlockHexAndBlockHash(block.parentRoot, parentBlockHash); + if (parentBlock == null) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.UNKNOWN_PARENT_BLOCK, + parentRoot: block.parentRoot, + parentHash: parentBlockHash, + }); + } + + 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; + } + + // PENDING is the same to EMPTY so not likely we can return it + // also it's only specific for fork-choice + + return null; + } + /** * Iterate backwards through the array, touching all nodes and their parents and potentially * the best-child of each parent. @@ -96,11 +294,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, }); } @@ -197,7 +395,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) { @@ -207,28 +405,295 @@ export class ProtoArray { }); } - const node: ProtoNode = { - ...block, - parent: this.indices.get(block.parentRoot), + const isGloas = 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 pre-Gloas, point to parent's FULL + // Otherwise, determine which parent payload status this block extends + let parentIndex: number | undefined; + + // Check if parent exists by getting variants array + const parentVariants = this.indices.get(block.parentRoot); + if (parentVariants != null) { + const anyParentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants; + const anyParentNode = this.nodes[anyParentIndex]; + + if (!isGloasBlock(anyParentNode)) { + // 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); + parentIndex = this.getNodeIndexByRootAndStatus(block.parentRoot, parentPayloadStatus); + } + } + // else: parent doesn't exist, parentIndex remains undefined (orphan block) + + // 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; + 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; + this.nodes.push(emptyNode); + + // Store both variants in the indices array + // [PENDING, EMPTY, undefined] - FULL will be added later if payload arrives + this.indices.set(block.blockRoot, [pendingIndex, 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: Only create FULL node (payload embedded in block) + const node: ProtoNode = { + ...block, + parent: this.getNodeIndexByRootAndStatus(block.parentRoot, PayloadStatus.FULL), + payloadStatus: PayloadStatus.FULL, + weight: 0, + bestChild: undefined, + bestDescendant: undefined, + }; + + const nodeIndex = this.nodes.length; + this.nodes.push(node); + + // 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 + if (node.parent !== undefined) { + this.maybeUpdateBestChildAndDescendant(node.parent, nodeIndex, currentSlot); + + if (node.executionStatus === ExecutionStatus.Valid) { + 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, + executionPayloadBlockHash: RootHex, + executionPayloadNumber: number, + executionPayloadStateRoot: RootHex + ): void { + // First check if block exists + const variants = this.indices.get(blockRoot); + if (variants == null) { + // Equivalent to `assert envelope.beacon_block_root in store.block_states` + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.UNKNOWN_BLOCK, + root: blockRoot, + }); + } + + if (!Array.isArray(variants)) { + // 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 + 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, + 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, + executionStatus: ExecutionStatus.Valid, // TODO GLOAS: Review execution status + executionPayloadBlockHash, + executionPayloadNumber, + stateRoot: executionPayloadStateRoot, }; - const nodeIndex = this.nodes.length; + const fullIndex = this.nodes.length; + this.nodes.push(fullNode); - this.indices.set(node.blockRoot, nodeIndex); - this.nodes.push(node); + // Add FULL variant to the indices array + variants[PayloadStatus.FULL] = fullIndex; - // 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); + // Update bestChild for PENDING node (may now prefer FULL over EMPTY) + this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot); + } - if (node.executionStatus === ExecutionStatus.Valid) { - 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; + } + } + + /** + * 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 (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 + */ + isPayloadTimely(blockRoot: RootHex): 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 + // 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; + } + + // 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) + */ + shouldExtendPayload(blockRoot: RootHex, proposerBoostRoot: RootHex | null): boolean { + // Condition 1: Payload is timely + if (this.isPayloadTimely(blockRoot)) { + return true; + } + + // Condition 2: No proposer boost root + if (proposerBoostRoot === null || proposerBoostRoot === HEX_ZERO_HASH) { + return true; + } + + // Get proposer boost block + // 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; + } + + // 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; } /** @@ -236,6 +701,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 @@ -279,7 +745,12 @@ export class ProtoArray { // if its in fcU. // const {invalidateFromParentBlockRoot, latestValidExecHash} = execResponse; - const invalidateFromParentIndex = this.indices.get(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`); } @@ -438,10 +909,46 @@ export class ProtoArray { return validNode; } + /** + * 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 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, 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) { + 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 + } + // FULL - check should_extend_payload + const shouldExtend = this.shouldExtendPayload(node.blockRoot, proposerBoostRoot); + 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 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): RootHex { + findHead(justifiedRoot: RootHex, currentSlot: Slot): ProtoNode { if (this.lvhError) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, @@ -449,7 +956,10 @@ export class ProtoArray { }); } - const justifiedIndex = this.indices.get(justifiedRoot); + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + 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, @@ -501,7 +1011,7 @@ export class ProtoArray { }); } - return bestNode.blockRoot; + return bestNode; } /** @@ -520,26 +1030,40 @@ 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 variants = this.indices.get(finalizedRoot); + if (variants == null) { throw new ProtoArrayError({ code: ProtoArrayErrorCode.FINALIZED_NODE_UNKNOWN, root: finalizedRoot, }); } + // Find the minimum index among all variants to ensure we don't prune too much + 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 return []; } - // Remove the this.indices key/values for all the to-be-deleted nodes - for (let nodeIndex = 0; nodeIndex < finalizedIndex; nodeIndex++) { - 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}); } - this.indices.delete(node.blockRoot); + 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(root); } // Store nodes prior to finalization @@ -547,15 +1071,35 @@ export class ProtoArray { // Drop all the nodes prior to finalization this.nodes = this.nodes.slice(finalizedIndex); - // Adjust the indices map - for (const [key, value] of this.indices.entries()) { - if (value < finalizedIndex) { - throw new ProtoArrayError({ - code: ProtoArrayErrorCode.INDEX_OVERFLOW, - value: "indices", - }); + // Adjust the indices map - subtract finalizedIndex from all node indices + for (const [root, variantIndices] of this.indices.entries()) { + // 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; } - this.indices.set(key, value - finalizedIndex); + + // 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); } // Iterate through all the existing nodes and adjust their indices to match the new layout of this.nodes @@ -604,6 +1148,41 @@ 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) { @@ -634,54 +1213,95 @@ 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, - }); - } + // biome-ignore lint/suspicious/noConfusingLabels: labeled block used for early exit from complex decision tree + 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 + // 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 outer; + } + if (!childLeadsToViableHead && bestChildLeadsToViableHead) { + // the best child leads to a viable head but the child doesn't newChildAndDescendant = noChange; + break outer; } - } else { - // choose the winner by weight - if (childNode.weight >= bestChildNode.weight) { + // 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 outer; + } + + if (!isEdgeCase && childNode.blockRoot !== bestChildNode.blockRoot) { + // Different blocks, tie-breaker by root + if (childNode.blockRoot >= bestChildNode.blockRoot) { + newChildAndDescendant = changeToChild; + } else { + 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 + ); + + const bestChildTiebreaker = this.getPayloadStatusTiebreaker(bestChildNode, currentSlot, null); + + 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]; @@ -761,13 +1381,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 (_) { @@ -776,49 +1397,119 @@ 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 { + // Get any variant to check the block (use variants[0]) + const variantOrArr = this.indices.get(blockRoot); + if (variantOrArr == null) { throw new ForkChoiceError({ code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot, }); } - if (block.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; - } + 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) + 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 == null) { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, + descendantRoot: blockRoot, + ancestorSlot, + }); + } + + let parentIndex = Array.isArray(parentVariants) ? parentVariants[0] : parentVariants; + 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 == null) { + throw new ForkChoiceError({ + code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR, + descendantRoot: blockRoot, + ancestorSlot, + }); } + + parentIndex = Array.isArray(nextParentVariants) ? nextParentVariants[0] : nextParentVariants; + 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 blockRoot; + + 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 { - const startIndex = this.indices.get(blockRoot); + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const defaultStatus = this.getDefaultVariant(blockRoot); + const startIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return; } @@ -835,20 +1526,33 @@ 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) { - node = this.getNodeFromIndex(node.parent); + 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[] { - const startIndex = this.indices.get(blockRoot); + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const defaultStatus = this.getDefaultVariant(blockRoot); + const startIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return []; } @@ -861,10 +1565,22 @@ 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[] = []; + + if (!isGloasBlock(node)) { + nodes.push(node); + } while (node.parent !== undefined) { - node = this.getNodeFromIndex(node.parent); + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; + } + + node = this.nodes[parentIndex]; nodes.push(node); } @@ -875,9 +1591,17 @@ 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. + * + * 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[] { - const startIndex = this.indices.get(blockRoot); + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const defaultStatus = this.getDefaultVariant(blockRoot); + if (defaultStatus === undefined) { + return []; + } + const startIndex = this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus); if (startIndex === undefined) { return []; } @@ -889,24 +1613,39 @@ export class ProtoArray { index: startIndex, }); } + + // For both Gloas and pre-Gloas blocks const result: ProtoNode[] = []; let nodeIndex = startIndex; while (node.parent !== undefined) { - const parentIndex = node.parent; - node = this.getNodeFromIndex(parentIndex); - // nodes between nodeIndex and parentIndex means non-ancestor nodes - result.push(...this.getNodesBetween(nodeIndex, parentIndex)); + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; + } + + 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)); + // 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. + * 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[]} { - const startIndex = this.indices.get(blockRoot); + // Get canonical node: FULL for pre-Gloas, PENDING for Gloas + const defaultStatus = this.getDefaultVariant(blockRoot); + const startIndex = + defaultStatus !== undefined ? this.getNodeIndexByRootAndStatus(blockRoot, defaultStatus) : undefined; if (startIndex === undefined) { return {ancestors: [], nonAncestors: []}; } @@ -922,39 +1661,78 @@ 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) { - ancestors.push(node); + const parentIndex = this.getParentNodeIndex(node); + if (parentIndex === undefined) { + break; + } - const parentIndex = node.parent; - node = this.getNodeFromIndex(parentIndex); + node = this.nodes[parentIndex]; + ancestors.push(node); - // Nodes between nodeIndex and parentIndex are non-ancestor nodes - nonAncestors.push(...this.getNodesBetween(nodeIndex, parentIndex)); + // 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; } - ancestors.push(node); - nonAncestors.push(...this.getNodesBetween(nodeIndex, 0)); + // Collect remaining non-ancestor nodes from nodeIndex to beginning + nonAncestors.push(...this.getNodesBetween(nodeIndex, 0).filter((n) => n.payloadStatus !== PayloadStatus.PENDING)); 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; } - getNode(blockRoot: RootHex): ProtoNode | undefined { - const blockIndex = this.indices.get(blockRoot); + /** + * 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 + * + * Note: Callers must explicitly specify which variant they need. + * Use getDefaultVariant() to get the canonical variant for a block. + */ + 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; } @@ -963,9 +1741,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}`); } @@ -978,7 +1766,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; } diff --git a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts index 9643d3f2e64b..60c18d7d73fc 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", () => { @@ -30,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"); @@ -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); } } diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 5b72dd88b9f5..59fc71630660 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; @@ -40,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)), }; @@ -80,6 +107,11 @@ export function initializeForkChoice(opts: Opts): ForkChoice { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + 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 91f1fc8614e9..5188498cfb59 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"; @@ -41,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 ); @@ -49,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(), }; @@ -104,6 +130,11 @@ describe("Forkchoice", () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; }; @@ -124,7 +155,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", () => { @@ -193,7 +231,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/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 0eb01f3580c3..ef666a080a39 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,11 @@ describe("Forkchoice / GetProposerHead", () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -67,6 +79,11 @@ describe("Forkchoice / GetProposerHead", () => { weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -91,28 +108,45 @@ describe("Forkchoice / GetProposerHead", () => { timeliness: false, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; 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 803c5fcbd5bb..7e0899fe9c46 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,11 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { timeliness: false, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -67,6 +79,11 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -91,28 +108,45 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { timeliness: false, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }; 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(), @@ -200,7 +234,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 5e77b48a8a23..f2eb43e5024e 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,7 +75,12 @@ 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 ); @@ -115,6 +121,11 @@ function setupForkChoice(): ProtoArray { timeliness: false, ...executionData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, block.slot ); @@ -289,7 +300,10 @@ 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 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 }); }); @@ -421,7 +435,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 b6a3f50af7eb..8cd535b1166e 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,6 +45,11 @@ describe("getCommonAncestor", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, 0 ); @@ -71,6 +76,11 @@ describe("getCommonAncestor", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, block.slot ); @@ -78,8 +88,12 @@ describe("getCommonAncestor", () => { for (const {nodeA, nodeB, ancestor} of testCases) { it(`${nodeA} & ${nodeB} -> ${ancestor}`, () => { + 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(fc.getNode(nodeA)!, fc.getNode(nodeB)!); + 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 new file mode 100644 index 000000000000..fb6d390e8670 --- /dev/null +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -0,0 +1,638 @@ +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"; + +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 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]; + } + + 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: parentBlockHash === undefined ? null : parentBlockHash, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, + }; + } + + 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 is the FULL index + expect(variants).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("indices map stores multiple variants for Gloas blocks", () => { + const protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); + + // Add a Gloas block + const gloasBlock = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(gloasBlock, gloasForkSlot); + + 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(); + }); + }); + + describe("Pre-Gloas (Fulu) behavior", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + // Test pre-Gloas behavior by creating blocks with parentBlockHash: null + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + }); + }); + + 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 + expect(() => getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING)).toThrow(); + expect(() => getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.EMPTY)).toThrow(); + }); + + it("getNode() finds pre-Gloas blocks by root (FULL)", () => { + const block = createTestBlock(1, "0x02", genesisRoot); + protoArray.onBlock(block, 1); + + 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); + }); + + 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, + }); + }); + + 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.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); + + 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(); + + expect(() => getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.PENDING)).toThrow(); + }); + }); + + describe("Fork transition (Fulu → Gloas)", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + }); + }); + + 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.getNodeIndexByRootAndStatus("0x02", PayloadStatus.FULL); + + // 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 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 gloasDefaultStatus = protoArray.getDefaultVariant("0x03"); + expect(gloasDefaultStatus).toBe(PayloadStatus.PENDING); + const gloasNode = gloasDefaultStatus !== undefined ? protoArray.getNode("0x03", gloasDefaultStatus) : undefined; + 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, + }); + }); + + 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, "0x02", gloasForkSlot, stateRoot); + + // 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, "0x02", gloasForkSlot, stateRoot); + + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); + + 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, "0x02", gloasForkSlot, stateRoot); + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); + + // Should still only have one FULL node + const fullNode = getNodeByPayloadStatus(protoArray, "0x02", PayloadStatus.FULL); + expect(fullNode).toBeDefined(); + }); + + 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 throw for pre-Gloas blocks + expect(() => + protoArray.onExecutionPayload("0x02", gloasForkSlot - 1, "0x02", gloasForkSlot - 1, stateRoot) + ).toThrow(); + }); + + it("throws for unknown block", () => { + expect(() => protoArray.onExecutionPayload("0x99", gloasForkSlot, "0x99", gloasForkSlot, stateRoot)).toThrow(); + }); + }); + + describe("PTC (Payload Timeliness Committee)", () => { + let protoArray: ProtoArray; + + beforeEach(() => { + protoArray = new ProtoArray({ + pruneThreshold: 0, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + }); + }); + + 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 execution payload (no FULL variant), should return false + expect(protoArray.isPayloadTimely("0x02")).toBe(false); + }); + + it("isPayloadTimely() returns true when threshold met and payload available", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Make execution payload available by creating FULL variant + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); + + // 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")).toBe(true); + }); + + it("isPayloadTimely() returns false when threshold not met", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Make execution payload available by creating FULL variant + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); + + // 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")).toBe(false); + }); + + it("isPayloadTimely() counts only 'true' votes", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot); + + // Make execution payload available by creating FULL variant + protoArray.onExecutionPayload("0x02", gloasForkSlot, "0x02", gloasForkSlot, stateRoot); + + // 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")).toBe(true); + + // Change some yes votes to no + protoArray.notifyPtcMessage("0x02", [0, 1], false); + + // Should no longer be timely + expect(protoArray.isPayloadTimely("0x02")).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, + }); + }); + + 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, "0x02", gloasForkSlot, stateRoot); + + const pendingIndex = protoArray.getNodeIndexByRootAndStatus("0x02", PayloadStatus.PENDING); + 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, "0x02Root", genesisRoot, genesisRoot); + protoArray.onBlock(blockA, gloasForkSlot); + protoArray.onExecutionPayload("0x02Root", gloasForkSlot, "0x02Hash", gloasForkSlot, stateRoot); + + // Block B extends A's FULL (parentBlockHash matches) + const blockB = createTestBlock(gloasForkSlot + 1, "0x03Root", "0x02Root", "0x02Hash"); + protoArray.onBlock(blockB, gloasForkSlot + 1); + + 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); + // 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(() => { + // 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", () => { + const blockSlot = gloasForkSlot + 10; + const block = createTestBlock(blockSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, 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"); + 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); + 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.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 + const deltas = new Array(protoArray.nodes.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, "0x02", blockSlot, stateRoot); + + 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; + 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 + }); + }); +}); diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index 6be99553ba3f..8db8aa3e407d 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,6 +34,11 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, genesisSlot ); @@ -60,6 +65,11 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, genesisSlot + 1 ); @@ -86,6 +96,11 @@ describe("ProtoArray", () => { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: PayloadStatus.FULL, + builderIndex: null, + blockHashFromBid: null, }, genesisSlot + 1 );