@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
303 lines • 17.7 kB
JavaScript
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