UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

486 lines • 25.7 kB
import { ForkSeq, isForkPostAltair, isForkPostBellatrix } from "@lodestar/params"; import { computeTimeAtSlot, getCurrentEpoch, getExpectedWithdrawals, getRandaoMix, isMergeTransitionComplete, } from "@lodestar/state-transition"; import { ssz, sszTypesFor, } from "@lodestar/types"; import { sleep, toHex, toPubkeyHex, toRootHex } from "@lodestar/utils"; import { ZERO_HASH, ZERO_HASH_HEX } from "../../constants/index.js"; import { numToQuantity } from "../../eth1/provider/utils.js"; import { getExpectedGasLimit, } from "../../execution/index.js"; import { fromGraffitiBytes } from "../../util/graffiti.js"; import { validateBlobsAndKzgCommitments } from "./validateBlobsAndKzgCommitments.js"; // Time to provide the EL to generate a payload from new payload id const PAYLOAD_GENERATION_TIME_MS = 500; export var PayloadPreparationType; (function (PayloadPreparationType) { PayloadPreparationType["Fresh"] = "Fresh"; PayloadPreparationType["Cached"] = "Cached"; PayloadPreparationType["Reorged"] = "Reorged"; PayloadPreparationType["Blinded"] = "Blinded"; })(PayloadPreparationType || (PayloadPreparationType = {})); /** * Block production steps tracked in metrics */ export var BlockProductionStep; (function (BlockProductionStep) { BlockProductionStep["proposerSlashing"] = "proposerSlashing"; BlockProductionStep["attesterSlashings"] = "attesterSlashings"; BlockProductionStep["voluntaryExits"] = "voluntaryExits"; BlockProductionStep["blsToExecutionChanges"] = "blsToExecutionChanges"; BlockProductionStep["attestations"] = "attestations"; BlockProductionStep["eth1DataAndDeposits"] = "eth1DataAndDeposits"; BlockProductionStep["syncAggregate"] = "syncAggregate"; BlockProductionStep["executionPayload"] = "executionPayload"; })(BlockProductionStep || (BlockProductionStep = {})); export var BlockType; (function (BlockType) { BlockType["Full"] = "Full"; BlockType["Blinded"] = "Blinded"; })(BlockType || (BlockType = {})); export var BlobsResultType; (function (BlobsResultType) { BlobsResultType[BlobsResultType["preDeneb"] = 0] = "preDeneb"; BlobsResultType[BlobsResultType["produced"] = 1] = "produced"; BlobsResultType[BlobsResultType["blinded"] = 2] = "blinded"; })(BlobsResultType || (BlobsResultType = {})); export async function produceBlockBody(blockType, currentState, blockAttr) { const { slot: blockSlot, feeRecipient: requestedFeeRecipient, parentBlockRoot, proposerIndex, proposerPubKey, commonBlockBodyPromise, } = blockAttr; // Type-safe for blobs variable. Translate 'null' value into 'preDeneb' enum // TODO: Not ideal, but better than just using null. // TODO: Does not guarantee that preDeneb enum goes with a preDeneb block let blobsResult; let executionPayloadValue; let blockBody; // even though shouldOverrideBuilder is relevant for the engine response, for simplicity of typing // we just return it undefined for the builder which anyway doesn't get consumed downstream let shouldOverrideBuilder; const fork = currentState.config.getForkName(blockSlot); const logMeta = { fork, blockType, slot: blockSlot, }; this.logger.verbose("Producing beacon block body", logMeta); if (isForkPostBellatrix(fork)) { const safeBlockHash = this.forkChoice.getJustifiedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; const feeRecipient = requestedFeeRecipient ?? this.beaconProposerCache.getOrDefault(proposerIndex); const feeRecipientType = requestedFeeRecipient ? "requested" : this.beaconProposerCache.get(proposerIndex) ? "cached" : "default"; Object.assign(logMeta, { feeRecipientType, feeRecipient }); if (blockType === BlockType.Blinded) { if (!this.executionBuilder) throw Error("Execution Builder not available"); const executionBuilder = this.executionBuilder; const builderPromise = (async () => { const endExecutionPayloadHeader = this.metrics?.builderBlockProductionTimeSteps.startTimer(); // This path will not be used in the production, but is here just for merge mock // tests because merge-mock requires an fcU to be issued prior to fetch payload // header. if (executionBuilder.issueLocalFcUWithFeeRecipient !== undefined) { await prepareExecutionPayload(this, this.logger, fork, parentBlockRoot, safeBlockHash, finalizedBlockHash ?? ZERO_HASH_HEX, currentState, executionBuilder.issueLocalFcUWithFeeRecipient); } // For MeV boost integration, this is where the execution header will be // fetched from the payload id and a blinded block will be produced instead of // fullblock for the validator to sign this.logger.verbose("Fetching execution payload header from builder", { slot: blockSlot, proposerPubKey: toHex(proposerPubKey), }); const headerRes = await prepareExecutionPayloadHeader(this, fork, currentState, proposerPubKey); endExecutionPayloadHeader?.({ step: BlockProductionStep.executionPayload, }); return headerRes; })(); const [builderRes, commonBlockBody] = await Promise.all([ builderPromise, commonBlockBodyPromise ?? produceCommonBlockBody.call(this, blockType, currentState, blockAttr), ]); blockBody = Object.assign({}, commonBlockBody); blockBody.executionPayloadHeader = builderRes.header; executionPayloadValue = builderRes.executionPayloadValue; const fetchedTime = Date.now() / 1000 - computeTimeAtSlot(this.config, blockSlot, this.genesisTime); const prepType = PayloadPreparationType.Blinded; this.metrics?.blockPayload.payloadFetchedTime.observe({ prepType }, fetchedTime); this.logger.verbose("Fetched execution payload header from builder", { slot: blockSlot, executionPayloadValue, prepType, fetchedTime, }); const targetGasLimit = executionBuilder.getValidatorRegistration(proposerPubKey)?.gasLimit; if (!targetGasLimit) { // This should only happen if cache was cleared due to restart of beacon node this.logger.warn("Failed to get validator registration, could not check header gas limit", { slot: blockSlot, proposerIndex, proposerPubKey: toPubkeyHex(proposerPubKey), }); } else { const headerGasLimit = builderRes.header.gasLimit; const parentGasLimit = currentState.latestExecutionPayloadHeader.gasLimit; const expectedGasLimit = getExpectedGasLimit(parentGasLimit, targetGasLimit); const lowerBound = Math.min(parentGasLimit, expectedGasLimit); const upperBound = Math.max(parentGasLimit, expectedGasLimit); if (headerGasLimit < lowerBound || headerGasLimit > upperBound) { throw Error(`Header gas limit ${headerGasLimit} is outside of acceptable range [${lowerBound}, ${upperBound}]`); } if (headerGasLimit !== expectedGasLimit) { this.logger.warn("Header gas limit does not match expected value", { slot: blockSlot, headerGasLimit, expectedGasLimit, parentGasLimit, targetGasLimit, }); } } if (ForkSeq[fork] >= ForkSeq.deneb) { const { blobKzgCommitments } = builderRes; if (blobKzgCommitments === undefined) { throw Error(`Invalid builder getHeader response for fork=${fork}, missing blobKzgCommitments`); } blockBody.blobKzgCommitments = blobKzgCommitments; blobsResult = { type: BlobsResultType.blinded }; Object.assign(logMeta, { blobs: blobKzgCommitments.length }); } else { blobsResult = { type: BlobsResultType.preDeneb }; } if (ForkSeq[fork] >= ForkSeq.electra) { const { executionRequests } = builderRes; if (executionRequests === undefined) { throw Error(`Invalid builder getHeader response for fork=${fork}, missing executionRequests`); } blockBody.executionRequests = executionRequests; } } // blockType === BlockType.Full else { const enginePromise = (async () => { const endExecutionPayload = this.metrics?.executionBlockProductionTimeSteps.startTimer(); this.logger.verbose("Preparing execution payload from engine", { slot: blockSlot, parentBlockRoot: toRootHex(parentBlockRoot), feeRecipient, }); // https://github.com/ethereum/consensus-specs/blob/dev/specs/deneb/validator.md#constructing-the-beaconblockbody const prepareRes = await prepareExecutionPayload(this, this.logger, fork, parentBlockRoot, safeBlockHash, finalizedBlockHash ?? ZERO_HASH_HEX, currentState, feeRecipient); if (prepareRes.isPremerge) { return { ...prepareRes, executionPayload: sszTypesFor(fork).ExecutionPayload.defaultValue(), executionPayloadValue: BigInt(0), }; } const { prepType, payloadId } = prepareRes; Object.assign(logMeta, { executionPayloadPrepType: prepType }); if (prepType !== PayloadPreparationType.Cached) { // Wait for 500ms to allow EL to add some txs to the payload // the pitfalls of this have been put forward here, but 500ms delay for block proposal // seems marginal even with unhealthy network // // See: https://discord.com/channels/595666850260713488/892088344438255616/1009882079632314469 await sleep(PAYLOAD_GENERATION_TIME_MS); } this.logger.verbose("Fetching execution payload from engine", { slot: blockSlot, payloadId }); const payloadRes = await this.executionEngine.getPayload(fork, payloadId); endExecutionPayload?.({ step: BlockProductionStep.executionPayload, }); return { ...prepareRes, ...payloadRes }; })().catch((e) => { // catch payload fetch here, because there is still a recovery path possible if we // are pre-merge. We don't care the same for builder segment as the execution block // will takeover if the builder flow was activated and errors this.metrics?.blockPayload.payloadFetchErrors.inc(); if (!isMergeTransitionComplete(currentState)) { this.logger?.warn("Fetch payload from the execution failed, however since we are still pre-merge proceeding with an empty one.", {}, e); // ok we don't have an execution payload here, so we can assign an empty one // if pre-merge return { isPremerge: true, executionPayload: sszTypesFor(fork).ExecutionPayload.defaultValue(), executionPayloadValue: BigInt(0), }; } // since merge transition is complete, we need a valid payload even if with an // empty (transactions) one. defaultValue isn't gonna cut it! throw e; }); const [engineRes, commonBlockBody] = await Promise.all([ enginePromise, commonBlockBodyPromise ?? produceCommonBlockBody.call(this, blockType, currentState, blockAttr), ]); blockBody = Object.assign({}, commonBlockBody); if (engineRes.isPremerge) { blockBody.executionPayload = engineRes.executionPayload; blobsResult = { type: BlobsResultType.preDeneb }; executionPayloadValue = engineRes.executionPayloadValue; } else { const { prepType, payloadId, executionPayload, blobsBundle, executionRequests } = engineRes; shouldOverrideBuilder = engineRes.shouldOverrideBuilder; blockBody.executionPayload = executionPayload; executionPayloadValue = engineRes.executionPayloadValue; Object.assign(logMeta, { transactions: executionPayload.transactions.length, shouldOverrideBuilder }); const fetchedTime = Date.now() / 1000 - computeTimeAtSlot(this.config, blockSlot, this.genesisTime); this.metrics?.blockPayload.payloadFetchedTime.observe({ prepType }, fetchedTime); this.logger.verbose("Fetched execution payload from engine", { slot: blockSlot, executionPayloadValue, prepType, payloadId, fetchedTime, executionHeadBlockHash: toRootHex(engineRes.executionPayload.blockHash), }); if (executionPayload.transactions.length === 0) { this.metrics?.blockPayload.emptyPayloads.inc({ prepType }); } if (ForkSeq[fork] >= ForkSeq.deneb) { if (blobsBundle === undefined) { throw Error(`Missing blobsBundle response from getPayload at fork=${fork}`); } if (this.opts.sanityCheckExecutionEngineBlobs) { validateBlobsAndKzgCommitments(executionPayload, blobsBundle); } blockBody.blobKzgCommitments = blobsBundle.commitments; const blockHash = toRootHex(executionPayload.blockHash); const contents = { kzgProofs: blobsBundle.proofs, blobs: blobsBundle.blobs }; blobsResult = { type: BlobsResultType.produced, contents, blockHash }; Object.assign(logMeta, { blobs: blobsBundle.commitments.length }); } else { blobsResult = { type: BlobsResultType.preDeneb }; } if (ForkSeq[fork] >= ForkSeq.electra) { if (executionRequests === undefined) { throw Error(`Missing executionRequests response from getPayload at fork=${fork}`); } blockBody.executionRequests = executionRequests; } } } } else { const commonBlockBody = await (commonBlockBodyPromise ?? produceCommonBlockBody.call(this, blockType, currentState, blockAttr)); blockBody = Object.assign({}, commonBlockBody); blobsResult = { type: BlobsResultType.preDeneb }; executionPayloadValue = BigInt(0); } const { graffiti, attestations, deposits, voluntaryExits, attesterSlashings, proposerSlashings } = blockBody; Object.assign(logMeta, { graffiti: fromGraffitiBytes(graffiti), attestations: attestations.length, deposits: deposits.length, voluntaryExits: voluntaryExits.length, attesterSlashings: attesterSlashings.length, proposerSlashings: proposerSlashings.length, }); if (isForkPostAltair(fork)) { const { syncAggregate } = blockBody; Object.assign(logMeta, { syncAggregateParticipants: syncAggregate.syncCommitteeBits.getTrueBitIndexes().length, }); } if (ForkSeq[fork] >= ForkSeq.capella) { const { blsToExecutionChanges, executionPayload } = blockBody; Object.assign(logMeta, { blsToExecutionChanges: blsToExecutionChanges.length, }); // withdrawals are only available in full body if (blockType === BlockType.Full) { Object.assign(logMeta, { withdrawals: executionPayload.withdrawals.length, }); } } Object.assign(logMeta, { executionPayloadValue }); this.logger.verbose("Produced beacon block body", logMeta); return { body: blockBody, blobs: blobsResult, executionPayloadValue, shouldOverrideBuilder }; } /** * Produce ExecutionPayload for pre-merge, merge, and post-merge. * * Expects `eth1MergeBlockFinder` to be actively searching for blocks well in advance to being called. * * @returns PayloadId = pow block found, null = pow NOT found */ export async function prepareExecutionPayload(chain, logger, fork, parentBlockRoot, safeBlockHash, finalizedBlockHash, state, suggestedFeeRecipient) { const parentHashRes = await getExecutionPayloadParentHash(chain, state); if (parentHashRes.isPremerge) { // Return null only if the execution is pre-merge return { isPremerge: true }; } const { parentHash } = parentHashRes; const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); const prevRandao = getRandaoMix(state, state.epochCtx.epoch); const payloadIdCached = chain.executionEngine.payloadIdCache.get({ headBlockHash: toRootHex(parentHash), finalizedBlockHash, timestamp: numToQuantity(timestamp), prevRandao: toHex(prevRandao), suggestedFeeRecipient, }); // prepareExecutionPayload will throw error via notifyForkchoiceUpdate if // the EL returns Syncing on this request to prepare a payload // TODO: Handle only this case, DO NOT put a generic try / catch that discards all errors let payloadId; let prepType; if (payloadIdCached) { payloadId = payloadIdCached; prepType = PayloadPreparationType.Cached; } else { // If there was a payload assigned to this timestamp, it would imply that there some sort // of payload reorg, i.e. head, fee recipient or any other fcu param changed if (chain.executionEngine.payloadIdCache.hasPayload({ timestamp: numToQuantity(timestamp) })) { prepType = PayloadPreparationType.Reorged; } else { prepType = PayloadPreparationType.Fresh; } const attributes = preparePayloadAttributes(fork, chain, { prepareState: state, prepareSlot: state.slot, parentBlockRoot, feeRecipient: suggestedFeeRecipient, }); payloadId = await chain.executionEngine.notifyForkchoiceUpdate(fork, toRootHex(parentHash), safeBlockHash, finalizedBlockHash, attributes); logger.verbose("Prepared payload id from execution engine", { payloadId }); } // Should never happen, notifyForkchoiceUpdate() with payload attributes always // returns payloadId if (payloadId === null) { throw Error("notifyForkchoiceUpdate returned payloadId null"); } // We are only returning payloadId here because prepareExecutionPayload is also called from // prepareNextSlot, which is an advance call to execution engine to start building payload // Actual payload isn't produced till getPayload is called. return { isPremerge: false, payloadId, prepType }; } async function prepareExecutionPayloadHeader(chain, fork, state, proposerPubKey) { if (!chain.executionBuilder) { throw Error("executionBuilder required"); } const parentHashRes = await getExecutionPayloadParentHash(chain, state); if (parentHashRes.isPremerge) { throw Error("Execution builder disabled pre-merge"); } const { parentHash } = parentHashRes; return chain.executionBuilder.getHeader(fork, state.slot, parentHash, proposerPubKey); } export async function getExecutionPayloadParentHash(chain, state) { // Use different POW block hash parent for block production based on merge status. // Returned value of null == using an empty ExecutionPayload value if (isMergeTransitionComplete(state)) { // Post-merge, normal payload return { isPremerge: false, parentHash: state.latestExecutionPayloadHeader.blockHash }; } if (!ssz.Root.equals(chain.config.TERMINAL_BLOCK_HASH, ZERO_HASH) && getCurrentEpoch(state) < chain.config.TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH) { throw new Error(`InvalidMergeTBH epoch: expected >= ${chain.config.TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH}, actual: ${getCurrentEpoch(state)}`); } const terminalPowBlockHash = await chain.eth1.getTerminalPowBlock(); if (terminalPowBlockHash === null) { // Pre-merge, no prepare payload call is needed return { isPremerge: true }; } // Signify merge via producing on top of the last PoW block return { isPremerge: false, parentHash: terminalPowBlockHash }; } export async function getPayloadAttributesForSSE(fork, chain, { prepareState, prepareSlot, parentBlockRoot, feeRecipient, }) { const parentHashRes = await getExecutionPayloadParentHash(chain, prepareState); if (!parentHashRes.isPremerge) { const { parentHash } = parentHashRes; const payloadAttributes = preparePayloadAttributes(fork, chain, { prepareState, prepareSlot, parentBlockRoot, feeRecipient, }); const ssePayloadAttributes = { proposerIndex: prepareState.epochCtx.getBeaconProposer(prepareSlot), proposalSlot: prepareSlot, parentBlockNumber: prepareState.latestExecutionPayloadHeader.blockNumber, parentBlockRoot, parentBlockHash: parentHash, payloadAttributes, }; return ssePayloadAttributes; } throw Error("The execution is still pre-merge"); } function preparePayloadAttributes(fork, chain, { prepareState, prepareSlot, parentBlockRoot, feeRecipient, }) { const timestamp = computeTimeAtSlot(chain.config, prepareSlot, prepareState.genesisTime); const prevRandao = getRandaoMix(prepareState, prepareState.epochCtx.epoch); const payloadAttributes = { timestamp, prevRandao, suggestedFeeRecipient: feeRecipient, }; if (ForkSeq[fork] >= ForkSeq.capella) { // withdrawals logic is now fork aware as it changes on electra fork post capella payloadAttributes.withdrawals = getExpectedWithdrawals(ForkSeq[fork], prepareState).withdrawals; } if (ForkSeq[fork] >= ForkSeq.deneb) { payloadAttributes.parentBeaconBlockRoot = parentBlockRoot; } return payloadAttributes; } export async function produceCommonBlockBody(blockType, currentState, { randaoReveal, graffiti, slot, parentSlot, parentBlockRoot, }) { const stepsMetrics = blockType === BlockType.Full ? this.metrics?.executionBlockProductionTimeSteps : this.metrics?.builderBlockProductionTimeSteps; const fork = currentState.config.getForkName(slot); // TODO: // Iterate through the naive aggregation pool and ensure all the attestations from there // are included in the operation pool. // for (const attestation of db.attestationPool.getAll()) { // try { // opPool.insertAttestation(attestation); // } catch (e) { // // Don't stop block production if there's an error, just create a log. // logger.error("Attestation did not transfer to op pool", {}, e); // } // } const [attesterSlashings, proposerSlashings, voluntaryExits, blsToExecutionChanges] = this.opPool.getSlashingsAndExits(currentState, blockType, this.metrics); const endAttestations = stepsMetrics?.startTimer(); const attestations = this.aggregatedAttestationPool.getAttestationsForBlock(fork, this.forkChoice, currentState); endAttestations?.({ step: BlockProductionStep.attestations, }); const endEth1DataAndDeposits = stepsMetrics?.startTimer(); const { eth1Data, deposits } = await this.eth1.getEth1DataAndDeposits(currentState); endEth1DataAndDeposits?.({ step: BlockProductionStep.eth1DataAndDeposits, }); const blockBody = { randaoReveal, graffiti, eth1Data, proposerSlashings, attesterSlashings, attestations, deposits, voluntaryExits, }; if (ForkSeq[fork] >= ForkSeq.capella) { blockBody.blsToExecutionChanges = blsToExecutionChanges; } const endSyncAggregate = stepsMetrics?.startTimer(); if (ForkSeq[fork] >= ForkSeq.altair) { const syncAggregate = this.syncContributionAndProofPool.getAggregate(parentSlot, parentBlockRoot); this.metrics?.production.producedSyncAggregateParticipants.observe(syncAggregate.syncCommitteeBits.getTrueBitIndexes().length); blockBody.syncAggregate = syncAggregate; } endSyncAggregate?.({ step: BlockProductionStep.syncAggregate, }); return blockBody; } //# sourceMappingURL=produceBlockBody.js.map