UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

303 lines • 17.7 kB
import { ExecutionStatus, assertValidTerminalPowBlock, } from "@lodestar/fork-choice"; import { ForkSeq, SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY } from "@lodestar/params"; import { isExecutionBlockBodyType, isExecutionEnabled, isExecutionStateType, isMergeTransitionBlock as isMergeTransitionBlockFn, } from "@lodestar/state-transition"; import { ErrorAborted, toRootHex } from "@lodestar/utils"; import { ExecutionPayloadStatus } from "../../execution/engine/interface.js"; import { kzgCommitmentToVersionedHash } from "../../util/blobs.js"; import { BlockError, BlockErrorCode } from "../errors/index.js"; /** * Verifies 1 or more execution payloads from a linear sequence of blocks. * * Since the EL client must be aware of each parent, all payloads must be submitted in sequence. */ export async function verifyBlocksExecutionPayload(chain, parentBlock, blocks, preState0, signal, opts) { const executionStatuses = []; let mergeBlockFound = null; const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); const lastBlock = blocks.at(-1); // Error in the same way as verifyBlocksSanityChecks if empty blocks if (!lastBlock) { throw Error("Empty partiallyVerifiedBlocks"); } // For a block with SYNCING status (called optimistic block), it's okay to import with // SYNCING status as EL could switch into syncing // // 1. On initial startup/restart // 2. When some reorg might have occurred and EL doesn't has a parent root // (observed on devnets) // 3. Because of some unavailable (and potentially invalid) root but there is no way // of knowing if this is invalid/unavailable. For unavailable block, some proposer // will (sooner or later) build on the available parent head which will // eventually win in fork-choice as other validators vote on VALID blocks. // // Once EL catches up again and respond VALID, the fork choice will be updated which // will either validate or prune invalid blocks // // We need to track and keep updating if its safe to optimistically import these blocks. // The following is how we determine for a block if its safe: // // (but we need to modify this check for this segment of blocks because it checks if the // parent of any block imported in forkchoice is post-merge and currently we could only // have blocks[0]'s parent imported in the chain as this is no longer one by one verify + // import.) // // // When to import such blocks: // From: https://github.com/ethereum/consensus-specs/pull/2844 // A block MUST NOT be optimistically imported, unless either of the following // conditions are met: // // 1. Parent of the block has execution // // Since with the sync optimizations, the previous block might not have been in the // forkChoice yet, so the below check could fail for safeSlotsToImportOptimistically // // Luckily, we can depend on the preState0 to see if we are already post merge w.r.t // the blocks we are importing. // // Or in other words if // - block status is syncing // - and we are not in a post merge world and is parent is not optimistically safe // - and we are syncing close to the chain head i.e. clock slot // - and parent is optimistically safe // // then throw error // // // - if we haven't yet imported a post merge ancestor in forkchoice i.e. // - and we are syncing close to the clockSlot, i.e. merge Transition could be underway // // // 2. The current slot (as per the system clock) is at least // SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY ahead of the slot of the block being // imported. // This means that the merge transition could be underway and we can't afford to import // a block which is not fully validated as it could affect liveliness of the network. // // // For this segment of blocks: // We are optimistically safe with respect to this entire block segment if: // - all the blocks are way behind the current slot // - or we have already imported a post-merge parent of first block of this chain in forkchoice const currentSlot = chain.clock.currentSlot; const safeSlotsToImportOptimistically = opts.safeSlotsToImportOptimistically ?? SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY; let isOptimisticallySafe = parentBlock.executionStatus !== ExecutionStatus.PreMerge || lastBlock.message.slot + safeSlotsToImportOptimistically < currentSlot; 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, currentSlot); // If execError has happened, then we need to extract the segmentExecStatus and return if (verifyResponse.execError !== null) { return getSegmentErrorResponse({ verifyResponse, blockIndex }, parentBlock, blocks); } // 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 have // returned execError and loop would have been aborted if (executionStatus !== ExecutionStatus.PreMerge) { isOptimisticallySafe = true; } executionStatuses.push(executionStatus); const isMergeTransitionBlock = // If the merge block is found, stop the search as the isMergeTransitionBlockFn condition // will still evaluate to true for the following blocks leading to errors (while syncing) // as the preState0 still belongs to the pre state of the first block on segment mergeBlockFound === null && isExecutionStateType(preState0) && isExecutionBlockBodyType(block.message.body) && isMergeTransitionBlockFn(preState0, block.message.body); // If this is a merge transition block, check to ensure if it references // a valid terminal PoW block. // // However specs define this check to be run inside forkChoice's onBlock // (https://github.com/ethereum/consensus-specs/blob/dev/specs/bellatrix/fork-choice.md#on_block) // but we perform the check here (as inspired from the lighthouse impl) // // Reasons: // 1. If the block is not valid, we should fail early and not wait till // forkChoice import. // 2. It makes logical sense to pair it with the block validations and // deal it with the external services like eth1 tracker here than // in import block if (isMergeTransitionBlock) { const mergeBlock = block.message; const mergeBlockHash = toRootHex(chain.config.getForkTypes(mergeBlock.slot).BeaconBlock.hashTreeRoot(mergeBlock)); const powBlockRootHex = toRootHex(mergeBlock.body.executionPayload.parentHash); const powBlock = await chain.eth1.getPowBlock(powBlockRootHex).catch((error) => { // Lets just warn the user here, errors if any will be reported on // `assertValidTerminalPowBlock` checks chain.logger.warn("Error fetching terminal PoW block referred in the merge transition block", { powBlockHash: powBlockRootHex, mergeBlockHash }, error); return null; }); const powBlockParent = powBlock && (await chain.eth1.getPowBlock(powBlock.parentHash).catch((error) => { // Lets just warn the user here, errors if any will be reported on // `assertValidTerminalPowBlock` checks chain.logger.warn("Error fetching parent of the terminal PoW block referred in the merge transition block", { powBlockParentHash: powBlock.parentHash, powBlock: powBlockRootHex, mergeBlockHash }, error); return null; })); // executionStatus will never == ExecutionStatus.PreMerge if it's the mergeBlock. But gotta make TS happy =D if (executionStatus === ExecutionStatus.PreMerge) { throw Error("Merge block must not have executionStatus == PreMerge"); } assertValidTerminalPowBlock(chain.config, mergeBlock, { executionStatus, powBlock, powBlockParent }); // Valid execution payload, but may not be in a valid beacon chain block. Delay printing the POS ACTIVATED banner // to the end of the verify block routine, which confirms that this block is fully valid. mergeBlockFound = mergeBlock; } } const executionTime = Date.now(); if (blocks.length === 1 && opts.seenTimestampSec !== undefined && executionStatuses[0] === ExecutionStatus.Valid) { const recvToValidation = executionTime / 1000 - opts.seenTimestampSec; const validationTime = recvToValidation - recvToValLatency; chain.metrics?.gossipBlock.executionPayload.recvToValidation.observe(recvToValidation); chain.metrics?.gossipBlock.executionPayload.validationTime.observe(validationTime); chain.logger.debug("Verified execution payload", { slot: blocks[0].message.slot, recvToValLatency, recvToValidation, validationTime, }); } return { execAborted: null, executionStatuses, executionTime, mergeBlockFound, }; } /** * Verifies a single block execution payload by sending it to the EL client (via HTTP). */ export async function verifyBlockExecutionPayload(chain, block, preState0, opts, isOptimisticallySafe, currentSlot) { /** Not null if execution is enabled */ const executionPayloadEnabled = isExecutionStateType(preState0) && isExecutionBlockBodyType(block.message.body) && // Safe to use with a state previous to block's preState. isMergeComplete can only transition from false to true. // - If preState0 is after merge block: condition is true, and will always be true // - If preState0 is before merge block: the block could lie but then state transition function will throw above // It is kinda safe to send non-trusted payloads to the execution client because at most it can trigger sync. // TODO: If this becomes a problem, do some basic verification beforehand, like checking the proposer signature. isExecutionEnabled(preState0, block.message) ? block.message.body.executionPayload : null; if (!executionPayloadEnabled) { // isExecutionEnabled() -> false return { executionStatus: ExecutionStatus.PreMerge, execError: null }; } // TODO: Handle better notifyNewPayload() returning error is syncing const fork = chain.config.getForkName(block.message.slot); const versionedHashes = ForkSeq[fork] >= ForkSeq.deneb ? block.message.body.blobKzgCommitments.map(kzgCommitmentToVersionedHash) : undefined; const parentBlockRoot = ForkSeq[fork] >= ForkSeq.deneb ? block.message.parentRoot : undefined; const executionRequests = ForkSeq[fork] >= ForkSeq.electra ? block.message.body.executionRequests : undefined; const logCtx = { slot: block.message.slot, executionBlock: executionPayloadEnabled.blockNumber }; chain.logger.debug("Call engine api newPayload", logCtx); const execResult = await chain.executionEngine.notifyNewPayload(fork, executionPayloadEnabled, versionedHashes, parentBlockRoot, executionRequests); chain.logger.debug("Receive engine api newPayload result", { ...logCtx, status: execResult.status }); chain.metrics?.engineNotifyNewPayloadResult.inc({ result: execResult.status }); switch (execResult.status) { case ExecutionPayloadStatus.VALID: { const executionStatus = ExecutionStatus.Valid; const lvhResponse = { executionStatus, latestValidExecHash: execResult.latestValidHash }; return { executionStatus, lvhResponse, execError: null }; } case ExecutionPayloadStatus.INVALID: { const executionStatus = ExecutionStatus.Invalid; const lvhResponse = { executionStatus, latestValidExecHash: execResult.latestValidHash, invalidateFromParentBlockRoot: toRootHex(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 case ExecutionPayloadStatus.ACCEPTED: case ExecutionPayloadStatus.SYNCING: { // Check if the entire segment was deemed safe or, this block specifically itself if not in // the safeSlotsToImportOptimistically window of current slot, then we can import else // we need to throw and not import his block const safeSlotsToImportOptimistically = opts.safeSlotsToImportOptimistically ?? SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY; if (!isOptimisticallySafe && block.message.slot + safeSlotsToImportOptimistically >= currentSlot) { const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, execStatus: ExecutionPayloadStatus.UNSAFE_OPTIMISTIC_STATUS, errorMessage: `not safe to import ${execResult.status} payload within ${opts.safeSlotsToImportOptimistically} of currentSlot`, }); return { executionStatus: null, execError }; } return { executionStatus: ExecutionStatus.Syncing, execError: null }; } // If the block has is not valid, or it referenced an invalid terminal block then the // block is invalid, however it has no bearing on any forkChoice cleanup // // There can be other reasons for which EL failed some of the observed ones are // 1. Connection refused / can't connect to EL port // 2. EL Internal Error // 3. Geth sometimes gives invalid merkle root error which means invalid // but expects it to be handled in CL as of now. But we should log as warning // and give it as optimistic treatment and expect any other non-geth CL<>EL // combination to reject the invalid block and propose a block. // On kintsugi devnet, this has been observed to cause contiguous proposal failures // as the network is geth dominated, till a non geth node proposes and moves network // forward // For network/unreachable errors, an optimization can be added to replay these blocks // back. But for now, lets assume other mechanisms like unknown parent block of a future // child block will cause it to replay case ExecutionPayloadStatus.INVALID_BLOCK_HASH: case ExecutionPayloadStatus.ELERROR: case ExecutionPayloadStatus.UNAVAILABLE: { const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, execStatus: execResult.status, errorMessage: execResult.validationError, }); return { executionStatus: null, execError }; } } } function getSegmentErrorResponse({ verifyResponse, blockIndex }, parentBlock, blocks) { const { executionStatus, lvhResponse, execError } = verifyResponse; let invalidSegmentLVH = undefined; if (executionStatus === ExecutionStatus.Invalid && lvhResponse !== undefined && lvhResponse.latestValidExecHash !== null) { let lvhFound = false; for (let mayBeLVHIndex = blockIndex - 1; mayBeLVHIndex >= 0; mayBeLVHIndex--) { const block = blocks[mayBeLVHIndex]; if (toRootHex(block.message.body.executionPayload.blockHash) === lvhResponse.latestValidExecHash) { lvhFound = true; break; } } // If there is no valid in the segment then we have to propagate invalid response // in forkchoice as well if // - if the parentBlock is also not the lvh // - and parentBlock is not pre merge if (!lvhFound && parentBlock.executionStatus !== ExecutionStatus.PreMerge && parentBlock.executionPayloadBlockHash !== lvhResponse.latestValidExecHash) { invalidSegmentLVH = { executionStatus: ExecutionStatus.Invalid, latestValidExecHash: lvhResponse.latestValidExecHash, invalidateFromParentBlockRoot: parentBlock.blockRoot, }; } } const execAborted = { blockIndex, execError }; return { execAborted, invalidSegmentLVH }; } //# sourceMappingURL=verifyBlocksExecutionPayloads.js.map