diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 4d0b777670a3..277b309be6de 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -73,14 +73,15 @@ export async function processBlocks( // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. - const {postStates, executionStatuses, proposerBalanceDeltas} = await verifyBlocksInEpoch( + const {postStates, executionStatuses, proposerBalanceDeltas, segmentExecStatus} = await verifyBlocksInEpoch( chain, parentBlock, relevantBlocks, opts ); - const fullyVerifiedBlocks = relevantBlocks.map( + const relevantBlocksPostExec = relevantBlocks.slice(0, segmentExecStatus.mayBeValidTillIndex + 1); + const fullyVerifiedBlocks = relevantBlocksPostExec.map( (block, i): FullyVerifiedBlock => ({ block, postState: postStates[i], @@ -97,6 +98,16 @@ export async function processBlocks( // TODO: Consider batching importBlock too if it takes significant time await importBlock(chain, fullyVerifiedBlock, opts); } + + // last Valid LVH response should be processed first + if (segmentExecStatus.lastValidLHVResponse !== null) { + chain.forkChoice.validateLatestHash(segmentExecStatus.lastValidLHVResponse); + } + // If the exec eval was aborted because of invalid execution response, process + // the same + if (segmentExecStatus.execAborted !== null && segmentExecStatus.execAborted.lvhResponse !== undefined) { + chain.forkChoice.validateLatestHash(segmentExecStatus.execAborted.lvhResponse); + } } catch (e) { // above functions should only throw BlockError const err = getBlockError(e, blocks[0]); diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 2dea48010342..024c1ea5166e 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -1,5 +1,5 @@ import {CachedBeaconStateAllForks} from "@lodestar/state-transition"; -import {ExecutionStatus} from "@lodestar/fork-choice"; +import {MaybeValidExecutionStatus} from "@lodestar/fork-choice"; import {allForks, Slot} from "@lodestar/types"; export type ImportBlockOpts = { @@ -48,9 +48,10 @@ export type FullyVerifiedBlock = { parentBlockSlot: Slot; proposerBalanceDelta: number; /** - * If the execution payload couldnt be verified because of EL syncing status, used in optimistic sync or for merge block + * If the execution payload couldnt be verified because of EL syncing status, + * used in optimistic sync or for merge block */ - executionStatus: ExecutionStatus; + executionStatus: MaybeValidExecutionStatus; /** Seen timestamp seconds */ seenTimestampSec: number; }; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 580e10887e80..9c880bd041d3 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -1,7 +1,7 @@ import {CachedBeaconStateAllForks, computeEpochAtSlot} from "@lodestar/state-transition"; import {allForks, bellatrix} from "@lodestar/types"; import {toHexString} from "@chainsafe/ssz"; -import {IForkChoice, ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {IForkChoice, MaybeValidExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {IChainForkConfig} from "@lodestar/config"; import {ILogger} from "@lodestar/utils"; import {IMetrics} from "../../metrics/index.js"; @@ -16,7 +16,7 @@ import {ImportBlockOpts} from "./types.js"; import {POS_PANDA_MERGE_TRANSITION_BANNER} from "./utils/pandaMergeTransitionBanner.js"; import {verifyBlocksStateTransitionOnly} from "./verifyBlocksStateTransitionOnly.js"; import {verifyBlocksSignatures} from "./verifyBlocksSignatures.js"; -import {verifyBlocksExecutionPayload} from "./verifyBlocksExecutionPayloads.js"; +import {verifyBlocksExecutionPayload, SegmentExecStatus} from "./verifyBlocksExecutionPayloads.js"; export type VerifyBlockModules = { bls: IBlsVerifier; @@ -48,8 +48,9 @@ export async function verifyBlocksInEpoch( opts: BlockProcessOpts & ImportBlockOpts ): Promise<{ postStates: CachedBeaconStateAllForks[]; - executionStatuses: ExecutionStatus[]; + executionStatuses: MaybeValidExecutionStatus[]; proposerBalanceDeltas: number[]; + segmentExecStatus: SegmentExecStatus; }> { if (blocks.length === 0) { throw Error("Empty partiallyVerifiedBlocks"); @@ -80,7 +81,11 @@ export async function verifyBlocksInEpoch( const abortController = new AbortController(); try { - const [{postStates, proposerBalanceDeltas}, , {executionStatuses, mergeBlockFound}] = await Promise.all([ + const [ + {postStates, proposerBalanceDeltas}, + , + {executionStatuses, mergeBlockFound, segmentExecStatus}, + ] = await Promise.all([ // Run state transition only // TODO: Ensure it yields to allow flushing to workers and engine API verifyBlocksStateTransitionOnly(chain, preState0, blocks, abortController.signal, opts), @@ -92,13 +97,21 @@ export async function verifyBlocksInEpoch( verifyBlocksExecutionPayload(chain, parentBlock, blocks, preState0, abortController.signal, opts), ]); + if (segmentExecStatus.execAborted !== null) { + if (segmentExecStatus.mayBeValidTillIndex < 0) { + // If exec was aborted on the first block itself, throw an error as none of the blocks + // can be processed and added for forkchoice + throw segmentExecStatus.execAborted.execError; + } + } + if (mergeBlockFound !== null) { // merge block found and is fully valid = state transition + signatures + execution payload. // TODO: Will this banner be logged during syncing? logOnPowBlock(chain, mergeBlockFound); } - return {postStates, executionStatuses, proposerBalanceDeltas}; + return {postStates, executionStatuses, proposerBalanceDeltas, segmentExecStatus}; } finally { abortController.abort(); } diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index 4c5d65fa2c7d..450cb5340037 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -7,7 +7,15 @@ import { } from "@lodestar/state-transition"; import {bellatrix, allForks} from "@lodestar/types"; import {toHexString} from "@chainsafe/ssz"; -import {IForkChoice, ExecutionStatus, assertValidTerminalPowBlock, ProtoBlock} from "@lodestar/fork-choice"; +import { + IForkChoice, + assertValidTerminalPowBlock, + ProtoBlock, + ExecutionStatus, + MaybeValidExecutionStatus, + LVHValidResponse, + LVHInvalidResponse, +} from "@lodestar/fork-choice"; import {IChainForkConfig} from "@lodestar/config"; import {ErrorAborted, ILogger} from "@lodestar/utils"; import {IExecutionEngine} from "../../execution/engine/index.js"; @@ -26,6 +34,25 @@ type VerifyBlockModules = { config: IChainForkConfig; }; +type ExecAbortType = { + blockIndex: number; + execError: BlockError; + lvhResponse?: LVHInvalidResponse; +}; + +type VerifyExecutionResponse = + | {executionStatus: ExecutionStatus.Valid; lvhResponse: LVHValidResponse} + | {executionStatus: ExecutionStatus.Invalid; lvhResponse: LVHInvalidResponse; execError: BlockError} + | {executionStatus: ExecutionStatus.Syncing; lvhResponse?: LVHValidResponse} + | {executionStatus: ExecutionStatus.PreMerge} + | {executionStatus: null; execError: BlockError}; + +export type SegmentExecStatus = { + mayBeValidTillIndex: number; + lastValidLHVResponse: LVHValidResponse | null; + execAborted: ExecAbortType | null; +}; + /** * Verifies 1 or more execution payloads from a linear sequence of blocks. * @@ -38,9 +65,16 @@ export async function verifyBlocksExecutionPayload( preState0: CachedBeaconStateAllForks, signal: AbortSignal, opts: BlockProcessOpts -): Promise<{executionStatuses: ExecutionStatus[]; mergeBlockFound: bellatrix.BeaconBlock | null}> { - const executionStatuses: ExecutionStatus[] = []; +): Promise<{ + executionStatuses: MaybeValidExecutionStatus[]; + mergeBlockFound: bellatrix.BeaconBlock | null; + segmentExecStatus: SegmentExecStatus; +}> { + const executionStatuses: MaybeValidExecutionStatus[] = []; let mergeBlockFound: bellatrix.BeaconBlock | null = null; + let execAborted: ExecAbortType | null = null; + let lastValidLHVResponse: LVHValidResponse | null = null; + let mayBeValidTillIndex = -1; // Error in the same way as verifyBlocksSanityChecks if empty blocks if (blocks.length === 0) { @@ -112,14 +146,30 @@ export async function verifyBlocksExecutionPayload( parentBlock.executionStatus !== ExecutionStatus.PreMerge || lastBlock.message.slot + opts.safeSlotsToImportOptimistically < chain.clock.currentSlot; - for (const block of blocks) { + for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) { + const block = blocks[blockIndex]; // If blocks are invalid in consensus the main promise could resolve before this loop ends. // In that case stop sending blocks to execution engine if (signal.aborted) { throw new ErrorAborted("verifyBlockExecutionPayloads"); } + const verifyResponse = await verifyBlockExecutionPayload(chain, block, preState0, opts, isOptimisticallySafe); - const {executionStatus} = await verifyBlockExecutionPayload(chain, block, preState0, opts, isOptimisticallySafe); + // If we had an invalid status or we had a block error even before evaluating status + // then we need to set execAborted and break out of the loop as the child blocks + // can't be evaluated further and need to be rejected + if (verifyResponse.executionStatus === ExecutionStatus.Invalid || verifyResponse.executionStatus === null) { + const {execError} = verifyResponse; + const lvhResponse = + verifyResponse.executionStatus === ExecutionStatus.Invalid ? verifyResponse.lvhResponse : undefined; + execAborted = {blockIndex, execError, lvhResponse}; + break; + } else if (verifyResponse.executionStatus === ExecutionStatus.Valid) { + lastValidLHVResponse = verifyResponse.lvhResponse; + } + + // If we are here then its because executionStatus is one of MaybeValidExecutionStatus + const {executionStatus} = verifyResponse; // It becomes optimistically safe for following blocks if a post-merge block is deemed fit // for import. If it would not have been safe verifyBlockExecutionPayload would throw error // and we would not be here. @@ -187,9 +237,14 @@ export async function verifyBlocksExecutionPayload( // to the end of the verify block routine, which confirms that this block is fully valid. mergeBlockFound = mergeBlock; } + mayBeValidTillIndex++; } - return {executionStatuses, mergeBlockFound}; + return { + executionStatuses, + mergeBlockFound, + segmentExecStatus: {mayBeValidTillIndex, lastValidLHVResponse, execAborted}, + }; } /** @@ -201,7 +256,7 @@ export async function verifyBlockExecutionPayload( preState0: CachedBeaconStateAllForks, opts: BlockProcessOpts, isOptimisticallySafe: boolean -): Promise<{executionStatus: ExecutionStatus}> { +): Promise { /** Not null if execution is enabled */ const executionPayloadEnabled = isBellatrixStateType(preState0) && @@ -224,23 +279,25 @@ export async function verifyBlockExecutionPayload( const execResult = await chain.executionEngine.notifyNewPayload(executionPayloadEnabled); switch (execResult.status) { - case ExecutePayloadStatus.VALID: - chain.forkChoice.validateLatestHash(execResult.latestValidHash, null); - return {executionStatus: ExecutionStatus.Valid}; + case ExecutePayloadStatus.VALID: { + const executionStatus: ExecutionStatus.Valid = ExecutionStatus.Valid; + const lvhResponse = {executionStatus, latestValidExecHash: execResult.latestValidHash}; + return {executionStatus, lvhResponse}; + } case ExecutePayloadStatus.INVALID: { - // If the parentRoot is not same as latestValidHash, then the branch from latestValidHash - // to parentRoot needs to be invalidated - const parentHashHex = toHexString(block.message.parentRoot); - chain.forkChoice.validateLatestHash( - execResult.latestValidHash, - parentHashHex !== execResult.latestValidHash ? parentHashHex : null - ); - throw new BlockError(block, { + const executionStatus: ExecutionStatus.Invalid = ExecutionStatus.Invalid; + const lvhResponse = { + executionStatus, + latestValidExecHash: execResult.latestValidHash, + invalidateTillBlockHash: toHexString(block.message.parentRoot), + }; + const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, execStatus: execResult.status, errorMessage: execResult.validationError ?? "", }); + return {executionStatus, lvhResponse, execError}; } // Accepted and Syncing have the same treatment, as final validation of block is pending @@ -253,11 +310,12 @@ export async function verifyBlockExecutionPayload( !isOptimisticallySafe && block.message.slot + opts.safeSlotsToImportOptimistically >= chain.clock.currentSlot ) { - throw new BlockError(block, { + const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, execStatus: ExecutePayloadStatus.UNSAFE_OPTIMISTIC_STATUS, errorMessage: `not safe to import ${execResult.status} payload within ${opts.safeSlotsToImportOptimistically} of currentSlot`, }); + return {executionStatus: null, execError}; } return {executionStatus: ExecutionStatus.Syncing}; @@ -282,11 +340,13 @@ export async function verifyBlockExecutionPayload( case ExecutePayloadStatus.INVALID_BLOCK_HASH: case ExecutePayloadStatus.ELERROR: - case ExecutePayloadStatus.UNAVAILABLE: - throw new BlockError(block, { + case ExecutePayloadStatus.UNAVAILABLE: { + const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, execStatus: execResult.status, errorMessage: execResult.validationError, }); + return {executionStatus: null, execError}; + } } } diff --git a/packages/beacon-node/src/execution/engine/interface.ts b/packages/beacon-node/src/execution/engine/interface.ts index 051ade3465d8..ae6b5b207b1c 100644 --- a/packages/beacon-node/src/execution/engine/interface.ts +++ b/packages/beacon-node/src/execution/engine/interface.ts @@ -27,7 +27,7 @@ export enum ExecutePayloadStatus { export type ExecutePayloadResponse = | {status: ExecutePayloadStatus.SYNCING | ExecutePayloadStatus.ACCEPTED; latestValidHash: null; validationError: null} | {status: ExecutePayloadStatus.VALID; latestValidHash: RootHex; validationError: null} - | {status: ExecutePayloadStatus.INVALID; latestValidHash: RootHex; validationError: string | null} + | {status: ExecutePayloadStatus.INVALID; latestValidHash: RootHex | null; validationError: string | null} | { status: ExecutePayloadStatus.INVALID_BLOCK_HASH | ExecutePayloadStatus.ELERROR | ExecutePayloadStatus.UNAVAILABLE; latestValidHash: null; diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index ce8444cf9bb4..1bf7ea711c4f 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -22,7 +22,14 @@ import {computeUnrealizedCheckpoints} from "@lodestar/state-transition/epoch"; import {IChainConfig, IChainForkConfig} from "@lodestar/config"; import {computeDeltas} from "../protoArray/computeDeltas.js"; -import {HEX_ZERO_HASH, VoteTracker, ProtoBlock, ExecutionStatus} from "../protoArray/interface.js"; +import { + HEX_ZERO_HASH, + VoteTracker, + ProtoBlock, + ExecutionStatus, + MaybeValidExecutionStatus, + LVHExecResponse, +} from "../protoArray/interface.js"; import {ProtoArray} from "../protoArray/protoArray.js"; import {IForkChoiceMetrics} from "../metrics.js"; @@ -277,7 +284,7 @@ export class ForkChoice implements IForkChoice { state: CachedBeaconStateAllForks, blockDelaySec: number, currentSlot: Slot, - executionStatus: ExecutionStatus + executionStatus: MaybeValidExecutionStatus ): void { const {parentRoot, slot} = block; const parentRootHex = toHexString(parentRoot); @@ -743,14 +750,20 @@ export class ForkChoice implements IForkChoice { } /** - * Optimistic sync validate till validated latest hash, invalidate any decendant branch if invalidate till hash provided - * TODO: implementation: - * 1. verify is_merge_block if the mergeblock has not yet been validated - * 2. Throw critical error and exit if a block in finalized chain gets invalidated + * Optimistic sync validate till validated latest hash, invalidate any decendant + * branch if invalidate till hash provided + * + * Proxies to protoArray's validateLatestHash and could run extra validations for the + * justified's status as well as validate the terminal conditions if terminal block + * becomes valid */ - validateLatestHash(_latestValidHash: RootHex, _invalidateTillHash: RootHex | null): void { - // Silently ignore for now if all calls were valid - return; + validateLatestHash(execResponse: LVHExecResponse): void { + this.protoArray.validateLatestHash(execResponse); + + // Call findHead to validate that the forkChoice consensus has not broken down + // as it is possible for invalidation to invalidate the entire forkChoice if + // the consensus breaks down, which will cause findHead to throw + this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, this.fcStore.currentSlot); } /** @@ -790,13 +803,15 @@ export class ForkChoice implements IForkChoice { ); } - private getPreMergeExecStatus(executionStatus: ExecutionStatus): ExecutionStatus.PreMerge { + private getPreMergeExecStatus(executionStatus: MaybeValidExecutionStatus): ExecutionStatus.PreMerge { if (executionStatus !== ExecutionStatus.PreMerge) throw Error(`Invalid pre-merge execution status: expected: ${ExecutionStatus.PreMerge}, got ${executionStatus}`); return executionStatus; } - private getPostMergeExecStatus(executionStatus: ExecutionStatus): ExecutionStatus.Valid | ExecutionStatus.Syncing { + private getPostMergeExecStatus( + executionStatus: MaybeValidExecutionStatus + ): ExecutionStatus.Valid | ExecutionStatus.Syncing { if (executionStatus === ExecutionStatus.PreMerge) throw Error( `Invalid post-merge execution status: expected: ${ExecutionStatus.Syncing} or ${ExecutionStatus.Valid} , got ${executionStatus}` diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 5cc7fa5e6d03..d39367c9f3a9 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -1,7 +1,7 @@ import {EffectiveBalanceIncrements} from "@lodestar/state-transition"; import {CachedBeaconStateAllForks} from "@lodestar/state-transition"; import {Epoch, Slot, ValidatorIndex, phase0, allForks, Root, RootHex} from "@lodestar/types"; -import {ProtoBlock, ExecutionStatus} from "../protoArray/interface.js"; +import {ProtoBlock, MaybeValidExecutionStatus, LVHExecResponse} from "../protoArray/interface.js"; import {CheckpointWithHex} from "./store.js"; export type CheckpointHex = { @@ -70,7 +70,7 @@ export interface IForkChoice { state: CachedBeaconStateAllForks, blockDelaySec: number, currentSlot: Slot, - executionStatus: ExecutionStatus + executionStatus: MaybeValidExecutionStatus ): void; /** * Register `attestation` with the fork choice DAG so that it may influence future calls to `getHead`. @@ -154,7 +154,7 @@ export interface IForkChoice { /** * Optimistic sync validate till validated latest hash, invalidate any decendant branch if invalidated branch decendant provided */ - validateLatestHash(latestValidHash: RootHex, invalidateTillHash: RootHex | null): void; + validateLatestHash(execResponse: LVHExecResponse): void; /** Find attester dependent root of a block */ findAttesterDependentRoot(headBlockHash: Root): RootHex | null; } diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index 152124d207d8..9662508d674b 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -1,5 +1,13 @@ export {ProtoArray} from "./protoArray/protoArray.js"; -export {ProtoBlock, ProtoNode, ExecutionStatus} from "./protoArray/interface.js"; +export { + ProtoBlock, + ProtoNode, + ExecutionStatus, + MaybeValidExecutionStatus, + BlockExecution, + LVHValidResponse, + LVHInvalidResponse, +} from "./protoArray/interface.js"; export {ForkChoice, ForkChoiceOpts, assertValidTerminalPowBlock} from "./forkChoice/forkChoice.js"; export {IForkChoice, PowBlockHex} from "./forkChoice/interface.js"; diff --git a/packages/fork-choice/src/protoArray/errors.ts b/packages/fork-choice/src/protoArray/errors.ts index d7d8e7fb5b13..00e1e4f840ef 100644 --- a/packages/fork-choice/src/protoArray/errors.ts +++ b/packages/fork-choice/src/protoArray/errors.ts @@ -1,6 +1,13 @@ import {Epoch, RootHex} from "@lodestar/types"; import {LodestarError} from "@lodestar/utils"; +export enum LVHExecErrorCode { + ValidToInvalid = "ValidToInvalid", + InvalidToValid = "InvalidToValid", +} + +export type LVHExecError = {lvhCode: LVHExecErrorCode; blockRoot: RootHex; execHash: RootHex}; + export enum ProtoArrayErrorCode { FINALIZED_NODE_UNKNOWN = "PROTO_ARRAY_ERROR_FINALIZED_NODE_UNKNOWN", JUSTIFIED_NODE_UNKNOWN = "PROTO_ARRAY_ERROR_JUSTIFIED_NODE_UNKNOWN", @@ -16,6 +23,9 @@ export enum ProtoArrayErrorCode { INVALID_DELTA_LEN = "PROTO_ARRAY_ERROR_INVALID_DELTA_LEN", REVERTED_FINALIZED_EPOCH = "PROTO_ARRAY_ERROR_REVERTED_FINALIZED_EPOCH", INVALID_BEST_NODE = "PROTO_ARRAY_ERROR_INVALID_BEST_NODE", + 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", } export type ProtoArrayErrorType = @@ -40,7 +50,10 @@ export type ProtoArrayErrorType = headRoot: RootHex; headJustifiedEpoch: Epoch; headFinalizedEpoch: Epoch; - }; + } + | {code: ProtoArrayErrorCode.INVALID_BLOCK_EXECUTION_STATUS; root: RootHex} + | {code: ProtoArrayErrorCode.INVALID_JUSTIFIED_EXECUTION_STATUS; root: RootHex} + | ({code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE} & LVHExecError); export class ProtoArrayError extends LodestarError { constructor(type: ProtoArrayErrorType) { diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index d089622614bb..204191cbede5 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -17,10 +17,24 @@ export enum ExecutionStatus { Valid = "Valid", Syncing = "Syncing", PreMerge = "PreMerge", + Invalid = "Invalid", } -type BlockExecution = - | {executionPayloadBlockHash: RootHex; executionStatus: ExecutionStatus.Valid | ExecutionStatus.Syncing} +export type LVHValidResponse = { + executionStatus: ExecutionStatus.Valid; + latestValidExecHash: RootHex; +}; +export type LVHInvalidResponse = { + executionStatus: ExecutionStatus.Invalid; + latestValidExecHash: RootHex | null; + invalidateTillBlockHash: RootHex; +}; +export type LVHExecResponse = LVHValidResponse | LVHInvalidResponse; + +export type MaybeValidExecutionStatus = Exclude; + +export type BlockExecution = + | {executionPayloadBlockHash: RootHex; executionStatus: Exclude} | {executionPayloadBlockHash: null; executionStatus: ExecutionStatus.PreMerge}; /** * A block that is to be applied to the fork choice diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 1eb9fb8116b7..09cb966c437c 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,12 +1,16 @@ import {Epoch, RootHex, Slot} from "@lodestar/types"; import {computeEpochAtSlot} from "@lodestar/state-transition"; +import {toHexString} from "@chainsafe/ssz"; -import {ProtoBlock, ProtoNode, HEX_ZERO_HASH} from "./interface.js"; -import {ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; +import {ProtoBlock, ProtoNode, HEX_ZERO_HASH, ExecutionStatus, LVHExecResponse} from "./interface.js"; +import {ProtoArrayError, ProtoArrayErrorCode, LVHExecError, LVHExecErrorCode} from "./errors.js"; export const DEFAULT_PRUNE_THRESHOLD = 0; type ProposerBoost = {root: RootHex; score: number}; +const ZERO_HASH = Buffer.alloc(32, 0); +const ZERO_HASH_HEX = toHexString(ZERO_HASH); + export class ProtoArray { // Do not attempt to prune the tree unless it has at least this many nodes. // Small prunes simply waste time @@ -17,6 +21,7 @@ export class ProtoArray { finalizedRoot: RootHex; nodes: ProtoNode[] = []; indices = new Map(); + lvhError?: LVHExecError; private previousProposerBoost?: ProposerBoost | null = null; @@ -84,7 +89,7 @@ export class ProtoArray { currentSlot, }: { deltas: number[]; - proposerBoost: ProposerBoost | null; + proposerBoost?: ProposerBoost | null; justifiedEpoch: Epoch; justifiedRoot: RootHex; finalizedEpoch: Epoch; @@ -133,7 +138,13 @@ export class ProtoArray { this.previousProposerBoost && this.previousProposerBoost.root === node.blockRoot ? this.previousProposerBoost.score : 0; - const nodeDelta = deltas[nodeIndex] + currentBoost - previousBoost; + + // If this node's execution status has been marked invalid, then the weight of the node + // needs to be taken out of consideration + const nodeDelta = + node.executionStatus === ExecutionStatus.Invalid + ? -node.weight + : deltas[nodeIndex] + currentBoost - previousBoost; if (nodeDelta === undefined) { throw new ProtoArrayError({ @@ -194,6 +205,12 @@ export class ProtoArray { if (this.indices.has(block.blockRoot)) { return; } + if (block.executionStatus === ExecutionStatus.Invalid) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_BLOCK_EXECUTION_STATUS, + root: block.blockRoot, + }); + } const node: ProtoNode = { ...block, @@ -209,6 +226,12 @@ export class ProtoArray { this.nodes.push(node); let parentIndex = node.parent; + // 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.executionStatus === ExecutionStatus.Valid && parentIndex !== undefined) { + this.propagateValidExecutionStatusByIndex(parentIndex); + } + let n: ProtoNode | undefined = node; while (parentIndex !== undefined) { this.maybeUpdateBestChildAndDescendant(parentIndex, nodeIndex, currentSlot); @@ -218,10 +241,237 @@ export class ProtoArray { } } + /** + * Optimistic sync validate till validated latest hash, invalidate any decendant branch + * if invalidate till hash provided. If consensus fails, this will invalidate entire + * forkChoice which will throw on any call to findHead + */ + validateLatestHash(execResponse: LVHExecResponse): void { + // Look reverse because its highly likely node with latestValidExecHash is towards the + // the leaves of the forkchoice + // + // We can also implement the index to lookup for exec hash => proto block, but it + // still needs to be established properly (though is highly likely) than a unique + // exec hash maps to a unique beacon block. + // For more context on this please checkout the following conversation: + // https://github.com/ChainSafe/lodestar/pull/4182#discussion_r914770167 + + if (execResponse.executionStatus === ExecutionStatus.Valid) { + const {latestValidExecHash} = execResponse; + // We use -1 for denoting not found + let latestValidHashIndex = -1; + + for (let nodeIndex = this.nodes.length - 1; nodeIndex >= 0; nodeIndex--) { + if (this.nodes[nodeIndex].executionPayloadBlockHash === latestValidExecHash) { + latestValidHashIndex = nodeIndex; + // We found the block corresponding to latestValidHashIndex, exit the loop + break; + } + } + + // We are trying to be as forgiving as possible here because ideally latestValidHashIndex + // should be found in the forkchoice + if (latestValidHashIndex >= 0) { + this.propagateValidExecutionStatusByIndex(latestValidHashIndex); + } + } else { + // In case of invalidation, ideally: + // i) Find the invalid payload + // ii) Obtain a chain [LVH.child, LVH.child.child, ....., invalid_payload] + // iii) Obtain a chain [Last_known_valid_node, ...., LVH] + // + // Mark chain iii) as Valid if LVH is non null but right now LVH can be non null without + // gurranteing chain iii) to be valid: for e.g. in following scenario LVH can be returned + // as any of SYNCING: SYNCING, SYNCING, SYNCING, INVALID (due to simple check)/ + // So we currently ignore this chain and hope eventually it gets resolved + // + // Mark chain ii) as Invalid if LVH is found and non null, else only invalidate invalid_payload + // if its in fcU. + // + const {invalidateTillBlockHash, latestValidExecHash} = execResponse; + const invalidateTillIndex = this.indices.get(invalidateTillBlockHash); + if (invalidateTillIndex === undefined) { + throw Error(`Unable to find invalidateTillBlockHash=${invalidateTillBlockHash} in forkChoice`); + } + /** + * There are following invalidation scenarios for latestValidHashIndex + * 1. If the LVH is 0x00..00, then all the post merge ancestors of the invalidateTillIndex + * are invalid. + * + * If no such ancestor is in forkchoice, represented by -1, the entire chain gets + * invalidated and as consequence, the subtree. However the disjoint subtrees will + * still stay valid + * + * 2. If the LVH is found, so we just need to traverse up from invalidateTillIndex + * and invalidate parents. + * + * 3. If the LVH is null or not found, represented with latestValidHashIndex=undefined, + * then just invalidate the invalid_payload and bug out. + * + * Ideally in not found scenario we should invalidate the entire chain upwards, but + * it is possible (and observed in the testnets) that the EL was + * + * i) buggy: that the LVH was not really the parent of the invalid block, but on + * some side chain + * ii) lazy: that invalidation was result of simple check and the EL just responded + * with a bogus LVH + * + * So we will just invalidate the current payload and let future responses take care + * to be as robust as possible. + */ + let latestValidHashIndex: number | undefined = undefined; + if (latestValidExecHash !== null) { + let nodeIndex = this.nodes[invalidateTillIndex].parent; + + while (nodeIndex !== undefined && nodeIndex >= 0) { + const node = this.getNodeFromIndex(nodeIndex); + if ( + (node.executionStatus === ExecutionStatus.PreMerge && latestValidExecHash === ZERO_HASH_HEX) || + node.executionPayloadBlockHash === latestValidExecHash + ) { + latestValidHashIndex = nodeIndex; + break; + } + nodeIndex = node.parent; + } + + // Even if we haven't found the latestValidHashIndex, EL has signalled here that the + // entire chain is invalid with this special response and hence we can safely + // invalidate the chain and its subtree via setting latestValidHashIndex as -1 + if (latestValidHashIndex === undefined && latestValidExecHash === ZERO_HASH_HEX) { + latestValidHashIndex = -1; + } + } + + this.propagateInValidExecutionStatusByIndex(invalidateTillIndex, latestValidHashIndex); + } + } + + propagateValidExecutionStatusByIndex(validNodeIndex: number): void { + let node: ProtoNode | undefined = this.getNodeFromIndex(validNodeIndex); + // propagate till we keep encountering syncing status + while ( + node !== undefined && + node.executionStatus !== ExecutionStatus.Valid && + node.executionStatus !== ExecutionStatus.PreMerge + ) { + switch (node.executionStatus) { + case ExecutionStatus.Invalid: + // This is a catastrophe, and indicates consensus failure and a non + // recoverable damage. There is no further processing that can be done. + // Just assign error for marking proto-array perma damaged and throw! + this.lvhError = { + lvhCode: LVHExecErrorCode.InvalidToValid, + blockRoot: node.blockRoot, + execHash: node.executionPayloadBlockHash, + }; + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, + ...this.lvhError, + }); + + case ExecutionStatus.Syncing: + // If the node is valid and its parent is marked syncing, we need to + // propagate the execution status up + node.executionStatus = ExecutionStatus.Valid; + break; + + default: + } + + node = node.parent !== undefined ? this.getNodeByIndex(node.parent) : undefined; + } + } + + /** + * Do a two pass invalidation: + * 1. we go up and mark all nodes invalid and then + * 2. we need do iterate down and mark all children of invalid nodes invalid + * + * latestValidHashIndex === undefined implies invalidate only invalidateTillIndex + * latestValidHashIndex === -1 implies invalidate all post merge blocks + * latestValidHashIndex >=0 implies invalidate the chain upwards from invalidateTillIndex + */ + + propagateInValidExecutionStatusByIndex(invalidateTillIndex: number, latestValidHashIndex: number | undefined): void { + let invalidateIndex: number = invalidateTillIndex; + // If latestValidHashIndex is undefined, i.e. we didn't find the LVH, only + // invalidate invalidateTillIndex + const invalidateUntilIndex = latestValidHashIndex ?? invalidateTillIndex - 1; + + while (invalidateIndex > invalidateUntilIndex) { + const invalidNode = this.getNodeFromIndex(invalidateIndex); + + if (invalidNode.executionStatus === ExecutionStatus.PreMerge) { + // This is also problematic case since lvh should have come as `0x00..00` with forkchoice + // passing us correct latestValidHashIndex. But we can be forgiving here and breakout + // if we reach pre-merge + break; + } else if (invalidNode.executionStatus === ExecutionStatus.Valid) { + // This is a catastrophe, and indicates consensus failure and a non + // recoverable damage. There is no further processing that can be done. + // Just assign error for marking proto-array perma damaged and throw! + this.lvhError = { + lvhCode: LVHExecErrorCode.ValidToInvalid, + blockRoot: invalidNode.blockRoot, + execHash: invalidNode.executionPayloadBlockHash, + }; + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, + ...this.lvhError, + }); + } + + invalidNode.executionStatus = ExecutionStatus.Invalid; + if (invalidNode.parent === undefined) { + // We have invalidated the entire chain in forkchoice, so time to exit and + // move to phase 2 + break; + } + invalidateIndex = invalidNode.parent; + } + + // Pass 2: mark all children of invalid nodes as invalid + for (let nodeIndex = 0; nodeIndex < this.nodes.length; nodeIndex++) { + const node = this.getNodeFromIndex(nodeIndex); + const parent = node.parent !== undefined ? this.getNodeByIndex(node.parent) : undefined; + // Only invalidate if this is post merge, and either parent is invalid or the + // concensus has failed + if (node.executionStatus !== ExecutionStatus.PreMerge && parent?.executionStatus === ExecutionStatus.Invalid) { + if (node.executionStatus === ExecutionStatus.Valid) { + // This is a catastrophe, and indicates consensus failure and a non + // recoverable damage. There is no further processing that can be done. + // Just assign error for marking proto-array perma damaged and throw! + this.lvhError = { + lvhCode: LVHExecErrorCode.ValidToInvalid, + blockRoot: node.blockRoot, + execHash: node.executionPayloadBlockHash, + }; + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, + ...this.lvhError, + }); + } + node.executionStatus = ExecutionStatus.Invalid; + node.bestChild = undefined; + node.bestDescendant = undefined; + } + } + + // update the forkchoice + } + /** * Follows the best-descendant links to find the best-block (i.e., head-block). */ findHead(justifiedRoot: RootHex, currentSlot: Slot): RootHex { + if (this.lvhError !== undefined) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_LVH_EXECUTION_RESPONSE, + ...this.lvhError, + }); + } + const justifiedIndex = this.indices.get(justifiedRoot); if (justifiedIndex === undefined) { throw new ProtoArrayError({ @@ -238,6 +488,13 @@ export class ProtoArray { }); } + if (justifiedNode.executionStatus === ExecutionStatus.Invalid) { + throw new ProtoArrayError({ + code: ProtoArrayErrorCode.INVALID_JUSTIFIED_EXECUTION_STATUS, + root: justifiedNode.blockRoot, + }); + } + const bestDescendantIndex = justifiedNode.bestDescendant ?? justifiedIndex; const bestNode = this.nodes[bestDescendantIndex]; @@ -486,6 +743,9 @@ export class ProtoArray { * head. */ nodeIsViableForHead(node: ProtoNode, currentSlot: Slot): boolean { + // If node has invalid executionStatus, it can't be a viable head + if (node.executionStatus === ExecutionStatus.Invalid) return false; + // If block is from a previous epoch, filter using unrealized justification & finalization information // If block is from the current epoch, filter using the head state's justification & finalization information const isFromPrevEpoch = computeEpochAtSlot(node.slot) < computeEpochAtSlot(currentSlot); @@ -684,7 +944,7 @@ export class ProtoArray { return this.indices.size; } - private getNodeFromIndex(index: number): ProtoNode { + getNodeFromIndex(index: number): ProtoNode { const node = this.nodes[index]; if (node === undefined) { throw new ProtoArrayError({code: ProtoArrayErrorCode.INVALID_NODE_INDEX, index}); diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts new file mode 100644 index 000000000000..da546471a305 --- /dev/null +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -0,0 +1,606 @@ +import {expect} from "chai"; +import {ProtoArray, ExecutionStatus, MaybeValidExecutionStatus, BlockExecution} from "../../../src/index.js"; +import {LVHExecErrorCode} from "../../../src/protoArray/errors.js"; + +type ValidationTestCase = { + root: string; + bestChild?: string; + bestDescendant?: string; + executionStatus: ExecutionStatus | undefined; +}; + +type TestBlock = {slot: number; root: string; parent: string; executionStatus: MaybeValidExecutionStatus}; +const blocks: TestBlock[] = [ + {slot: 1, root: "1A", parent: "0", executionStatus: ExecutionStatus.Syncing}, + {slot: 2, root: "2A", parent: "1A", executionStatus: ExecutionStatus.Syncing}, + {slot: 3, root: "3A", parent: "2A", executionStatus: ExecutionStatus.Syncing}, + {slot: 2, root: "2B", parent: "1A", executionStatus: ExecutionStatus.Syncing}, + {slot: 2, root: "3B", parent: "2B", executionStatus: ExecutionStatus.Syncing}, + {slot: 2, root: "2C", parent: "none", executionStatus: ExecutionStatus.Syncing}, + {slot: 3, root: "3C", parent: "2C", executionStatus: ExecutionStatus.Syncing}, +]; +const fcRoots: string[] = ["0"]; +for (const block of blocks) { + fcRoots.push(block.root); +} + +const expectedPreValidationFC: ValidationTestCase[] = [ + { + root: "0", + bestChild: "1A", + bestDescendant: "3B", + executionStatus: ExecutionStatus.PreMerge, + }, + {root: "1A", bestChild: "2B", bestDescendant: "3B", executionStatus: ExecutionStatus.Syncing}, + {root: "2A", bestChild: "3A", bestDescendant: "3A", executionStatus: ExecutionStatus.Syncing}, + {root: "3A", bestChild: undefined, bestDescendant: undefined, executionStatus: ExecutionStatus.Syncing}, + {root: "2B", bestChild: "3B", bestDescendant: "3B", executionStatus: ExecutionStatus.Syncing}, + {root: "3B", bestChild: undefined, bestDescendant: undefined, executionStatus: ExecutionStatus.Syncing}, + {root: "2C", bestChild: "3C", bestDescendant: "3C", executionStatus: ExecutionStatus.Syncing}, + {root: "3C", bestChild: undefined, bestDescendant: undefined, executionStatus: ExecutionStatus.Syncing}, +]; + +/** + * Set up the following forkchoice (~~ parent not in forkchoice, possibly bogus/NA) + * + * 0 (PreMerge) <- 1A (Syncing) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Syncing) <- 3B (Syncing) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + +function setupForkChoice(): ProtoArray { + const fc = ProtoArray.initialize({ + slot: 0, + stateRoot: "-", + parentRoot: "-", + blockRoot: "0", + + justifiedEpoch: 0, + justifiedRoot: "-", + finalizedEpoch: 0, + finalizedRoot: "-", + + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, + }); + + for (const block of blocks) { + const executionData = (block.executionStatus === ExecutionStatus.PreMerge + ? {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge} + : {executionPayloadBlockHash: block.root, executionStatus: block.executionStatus}) as BlockExecution; + fc.onBlock({ + slot: block.slot, + blockRoot: block.root, + parentRoot: block.parent, + stateRoot: "-", + targetRoot: "-", + + justifiedEpoch: 0, + justifiedRoot: "-", + finalizedEpoch: 0, + finalizedRoot: "-", + + ...executionData, + }); + } + + const deltas = Array.from({length: fc.nodes.length}, () => 0); + fc.applyScoreChanges({ + deltas, + proposerBoost: null, + justifiedEpoch: 0, + justifiedRoot: "-", + finalizedEpoch: 0, + finalizedRoot: "-", + }); + return fc; +} + +describe("executionStatus / normal updates", () => { + const fc = setupForkChoice(); + + /** + * 0 (PreMerge) <- 1A (Syncing) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Syncing) <- 3B (Syncing) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + const preValidation = collectProtoarrayValidationStatus(fc); + it("preValidation forkchoice setup should be correct", () => { + expect(preValidation).to.be.deep.equal(expectedPreValidationFC); + }); + + /** + * Invalidate 3C with LVH on 2C which stays in Syncing + * + * 0 (PreMerge) <- 1A (Syncing) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Syncing) <- 3B (Syncing) + * ~~ 2C (Syncing) <- 3C (Invalid) + */ + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: "2C", + invalidateTillBlockHash: "3C", + }); + + const invalidate3CValidate2CForkChoice = collectProtoarrayValidationStatus(fc); + it("correcly invalidate 3C and validate 2C only", () => { + expect(invalidate3CValidate2CForkChoice).to.be.deep.equal([ + { + root: "0", + bestChild: "1A", + bestDescendant: "3B", + executionStatus: ExecutionStatus.PreMerge, + }, + { + root: "1A", + bestChild: "2B", + bestDescendant: "3B", + executionStatus: "Syncing", + }, + { + root: "2A", + bestChild: "3A", + bestDescendant: "3A", + executionStatus: "Syncing", + }, + { + root: "3A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "2B", + bestChild: "3B", + bestDescendant: "3B", + executionStatus: "Syncing", + }, + { + root: "3B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "2C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "3C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + ]); + }); + + /** + * Validate 3B, 2B, 1A (premerge) + * + * 0 (PreMerge) <- 1A (Valid) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Valid) <- 3B (Valid) + * ~~ 2C (Syncing) <- 3C (Invalid) + */ + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Valid, + latestValidExecHash: "3B", + invalidateTillBlockHash: null, + }); + const validate3B2B1A = collectProtoarrayValidationStatus(fc); + it("Validate 3B, 2B, 1A", () => { + expect(validate3B2B1A).to.be.deep.equal([ + { + root: "0", + bestChild: "1A", + bestDescendant: "3B", + executionStatus: ExecutionStatus.PreMerge, + }, + { + root: "1A", + bestChild: "2B", + bestDescendant: "3B", + executionStatus: "Valid", + }, + { + root: "2A", + bestChild: "3A", + bestDescendant: "3A", + executionStatus: "Syncing", + }, + { + root: "3A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "2B", + bestChild: "3B", + bestDescendant: "3B", + executionStatus: "Valid", + }, + { + root: "3B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Valid", + }, + { + root: "2C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "3C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + ]); + }); + + /** + * Invalidate 3A, 2A with 2A loosing its bestChild, bestDescendant + * + * 0 (PreMerge) <- 1A (Valid) <- 2A (Invalid) <- 3A (Invalid) + * ^- 2B (Valid) <- 3B (Valid) + * ~~ 2C (Syncing) <- 3C (Invalid) + */ + + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: "1A", + invalidateTillBlockHash: "3A", + }); + const invalidate3A2A = collectProtoarrayValidationStatus(fc); + it("Invalidate 3A, 2A with 2A loosing its bestChild, bestDescendant", () => { + expect(invalidate3A2A).to.be.deep.equal([ + { + root: "0", + bestChild: "1A", + bestDescendant: "3B", + executionStatus: ExecutionStatus.PreMerge, + }, + { + root: "1A", + bestChild: "2B", + bestDescendant: "3B", + executionStatus: "Valid", + }, + { + root: "2A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "3A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2B", + bestChild: "3B", + bestDescendant: "3B", + executionStatus: "Valid", + }, + { + root: "3B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Valid", + }, + { + root: "2C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "3C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + ]); + }); +}); + +describe("executionStatus / invalidate all postmerge chain", () => { + const fc = setupForkChoice(); + + /** + * Set up the following forkchoice (~~ parent not in forkchoice, possibly bogus/NA) + * + * 0 (PreMerge) <- 1A (Syncing) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Syncing) <- 3B (Syncing) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + const preValidation = collectProtoarrayValidationStatus(fc); + it("preValidation forkchoice setup should be correct", () => { + expect(preValidation).to.be.deep.equal(expectedPreValidationFC); + }); + + /** + * All post merge blocks should be invalidated except Cs + * + * 0 (PreMerge) <- 1A (Invalid) <- 2A (Invalid) <- 3A (Invalid) + * ^- 2B (Invalid) <- 3B (Invalid) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + invalidateTillBlockHash: "3B", + }); + const postMergeInvalidated = collectProtoarrayValidationStatus(fc); + it("all post merge blocks should be invalidated except Cs", () => { + expect(postMergeInvalidated).to.be.deep.equal([ + { + root: "0", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: ExecutionStatus.PreMerge, + }, + { + root: "1A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "3A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "3B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2C", + bestChild: "3C", + bestDescendant: "3C", + executionStatus: "Syncing", + }, + { + root: "3C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + ]); + }); + + const fcHead = fc.findHead("0"); + it("pre merge block should be the FC head", () => { + expect(fcHead).to.be.equal("0"); + }); +}); + +describe("executionStatus / poision forkchoice if we invalidate previous valid", () => { + const fc = setupForkChoice(); + + /** + * Set up the following forkchoice (~~ parent not in forkchoice, possibly bogus/NA) + * + * 0 (PreMerge) <- 1A (Syncing) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Syncing) <- 3B (Syncing) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + const preValidation = collectProtoarrayValidationStatus(fc); + it("preValidation forkchoice setup should be correct", () => { + expect(preValidation).to.be.deep.equal(expectedPreValidationFC); + }); + + /** + * Validate 3B, 2B, 1A (premerge) + * + * 0 (PreMerge) <- 1A (Valid) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Valid) <- 3B (Valid) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Valid, + latestValidExecHash: "3B", + invalidateTillBlockHash: null, + }); + const validate3B2B1A = collectProtoarrayValidationStatus(fc); + it("Validate 3B, 2B, 1A", () => { + expect(validate3B2B1A).to.be.deep.equal([ + { + root: "0", + bestChild: "1A", + bestDescendant: "3B", + executionStatus: ExecutionStatus.PreMerge, + }, + { + root: "1A", + bestChild: "2B", + bestDescendant: "3B", + executionStatus: "Valid", + }, + { + root: "2A", + bestChild: "3A", + bestDescendant: "3A", + executionStatus: "Syncing", + }, + { + root: "3A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + { + root: "2B", + bestChild: "3B", + bestDescendant: "3B", + executionStatus: "Valid", + }, + { + root: "3B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Valid", + }, + { + root: "2C", + bestChild: "3C", + bestDescendant: "3C", + executionStatus: "Syncing", + }, + { + root: "3C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + ]); + }); + + it("protoarray should be poisioned with a buggy LVH response", () => { + expect(() => + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + invalidateTillBlockHash: "3A", + }) + ).to.throw(Error); + + expect(fc.lvhError).to.be.deep.equal({lvhCode: LVHExecErrorCode.ValidToInvalid, blockRoot: "1A", execHash: "1A"}); + expect(() => fc.findHead("0")).to.throw(Error); + }); +}); + +describe("executionStatus / poision forkchoice if we validate previous invalid", () => { + const fc = setupForkChoice(); + + /** + * Set up the following forkchoice (~~ parent not in forkchoice, possibly bogus/NA) + * + * 0 (PreMerge) <- 1A (Syncing) <- 2A (Syncing) <- 3A (Syncing) + * ^- 2B (Syncing) <- 3B (Syncing) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + const preValidation = collectProtoarrayValidationStatus(fc); + it("preValidation forkchoice setup should be correct", () => { + expect(preValidation).to.be.deep.equal(expectedPreValidationFC); + }); + + /** + * Invalidate 3B, 2B, 1A + * + * 0 (PreMerge) <- 1A (Invalid) <- 2A (Invalid) <- 3A (Invalid) + * ^- 2B (Invalid) <- 3B (Invalid) + * ~~ 2C (Syncing) <- 3C (Syncing) + */ + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + invalidateTillBlockHash: "3B", + }); + const validate3B2B1A = collectProtoarrayValidationStatus(fc); + it("Inalidate 3B, 2B, 1A", () => { + expect(validate3B2B1A).to.be.deep.equal([ + { + root: "0", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: ExecutionStatus.PreMerge, + }, + { + root: "1A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "3A", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "3B", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Invalid", + }, + { + root: "2C", + bestChild: "3C", + bestDescendant: "3C", + executionStatus: "Syncing", + }, + { + root: "3C", + bestChild: undefined, + bestDescendant: undefined, + executionStatus: "Syncing", + }, + ]); + }); + + it("protoarray should be poisioned with a buggy LVH response", () => { + expect(() => + fc.validateLatestHash({ + executionStatus: ExecutionStatus.Valid, + latestValidExecHash: "2A", + invalidateTillBlockHash: null, + }) + ).to.throw(Error); + + expect(fc.lvhError).to.be.deep.equal({lvhCode: LVHExecErrorCode.InvalidToValid, blockRoot: "2A", execHash: "2A"}); + expect(() => fc.findHead("0")).to.throw(Error); + }); +}); + +function collectProtoarrayValidationStatus(fcArray: ProtoArray): ValidationTestCase[] { + const expectedForkChoice: ValidationTestCase[] = []; + + for (const fcRoot of fcRoots) { + const fcNode = fcArray.getNode(fcRoot); + const bestChild = + fcNode?.bestChild !== undefined ? fcArray.getNodeFromIndex(fcNode.bestChild).blockRoot : undefined; + const bestDescendant = + fcNode?.bestDescendant !== undefined ? fcArray.getNodeFromIndex(fcNode.bestDescendant).blockRoot : undefined; + expectedForkChoice.push({ + root: fcRoot, + bestChild, + bestDescendant, + executionStatus: fcNode?.executionStatus, + }); + } + return expectedForkChoice; +}