UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

921 lines (920 loc) • 62.2 kB
import { PubkeyIndexMap } from "@chainsafe/pubkey-index-map"; import { routes } from "@lodestar/api"; import { ExecutionStatus } from "@lodestar/fork-choice"; import { ForkSeq, GENESIS_SLOT, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SYNC_COMMITTEE_SUBNET_SIZE, isForkPostBellatrix, isForkPostDeneb, isForkPostElectra, } from "@lodestar/params"; import { DataAvailabilityStatus, attesterShufflingDecisionRoot, beaconBlockToBlinded, calculateCommitteeAssignments, computeEpochAtSlot, computeStartSlotAtEpoch, createCachedBeaconState, getBlockRootAtSlot, getCurrentSlot, loadState, proposerShufflingDecisionRoot, } from "@lodestar/state-transition"; import { ProducedBlockSource, getValidatorStatus, isBlockContents, ssz, } from "@lodestar/types"; import { TimeoutError, defer, formatWeiToEth, fromHex, prettyWeiToEth, resolveOrRacePromises, toHex, toRootHex, } from "@lodestar/utils"; import { MAX_BUILDER_BOOST_FACTOR } from "@lodestar/validator"; import { AttestationError, AttestationErrorCode, GossipAction, SyncCommitteeError, SyncCommitteeErrorCode, } from "../../../chain/errors/index.js"; import { ChainEvent } from "../../../chain/index.js"; import { SCHEDULER_LOOKAHEAD_FACTOR } from "../../../chain/prepareNextSlot.js"; import { RegenCaller } from "../../../chain/regen/index.js"; import { validateApiAggregateAndProof } from "../../../chain/validation/index.js"; import { validateSyncCommitteeGossipContributionAndProof } from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import { ZERO_HASH } from "../../../constants/index.js"; import { NoBidReceived } from "../../../execution/builder/http.js"; import { validateGossipFnRetryUnknownRoot } from "../../../network/processor/gossipHandlers.js"; import { SyncState } from "../../../sync/index.js"; import { isOptimisticBlock } from "../../../util/forkChoice.js"; import { getDefaultGraffiti, toGraffitiBytes } from "../../../util/graffiti.js"; import { getLodestarClientVersion } from "../../../util/metadata.js"; import { getStateResponseWithRegen } from "../beacon/state/utils.js"; import { ApiError, NodeIsSyncing, OnlySupportedByDVT } from "../errors.js"; import { computeSubnetForCommitteesAtSlot, getPubkeysForIndices, selectBlockProductionSource } from "./utils.js"; /** * If the node is within this many epochs from the head, we declare it to be synced regardless of * the network sync state. * * This helps prevent attacks where nodes can convince us that we're syncing some non-existent * finalized head. * * TODO: Lighthouse uses 8 for the attack described above. However, 8 kills Lodestar since validators * can trigger regen to fast-forward head state 8 epochs to be immediately invalidated as sync sets * a new head. Then the checkpoint state cache grows unbounded with very different states (because * they are 8 epochs apart) and causes an OOM. Research a proper solution once regen and the state * caches are better. */ export const SYNC_TOLERANCE_EPOCHS = 1; /** * Cutoff time to wait from start of the slot for execution and builder block production apis to resolve. * Post this time, race execution and builder to pick whatever resolves first. * * Empirically the builder block resolves in ~1 second, and execution block resolves in <500 ms. * A cutoff of 2 seconds gives enough time and if there are unexpected delays it ensures we publish * in time as proposals post 4 seconds into the slot will likely be orphaned due to proposer boost reorg. */ const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000; /** Overall timeout for execution and block production apis */ const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000; /** * Engine block selection reasons tracked in metrics / logs */ export var EngineBlockSelectionReason; (function (EngineBlockSelectionReason) { EngineBlockSelectionReason["BuilderDisabled"] = "builder_disabled"; EngineBlockSelectionReason["BuilderError"] = "builder_error"; EngineBlockSelectionReason["BuilderTimeout"] = "builder_timeout"; EngineBlockSelectionReason["BuilderPending"] = "builder_pending"; EngineBlockSelectionReason["BuilderNoBid"] = "builder_no_bid"; EngineBlockSelectionReason["BuilderCensorship"] = "builder_censorship"; EngineBlockSelectionReason["BlockValue"] = "block_value"; EngineBlockSelectionReason["EnginePreferred"] = "engine_preferred"; })(EngineBlockSelectionReason || (EngineBlockSelectionReason = {})); /** * Builder block selection reasons tracked in metrics / logs */ export var BuilderBlockSelectionReason; (function (BuilderBlockSelectionReason) { BuilderBlockSelectionReason["EngineDisabled"] = "engine_disabled"; BuilderBlockSelectionReason["EngineError"] = "engine_error"; BuilderBlockSelectionReason["EnginePending"] = "engine_pending"; BuilderBlockSelectionReason["BlockValue"] = "block_value"; BuilderBlockSelectionReason["BuilderPreferred"] = "builder_preferred"; })(BuilderBlockSelectionReason || (BuilderBlockSelectionReason = {})); /** * Server implementation for handling validator duties. * See `@lodestar/validator/src/api` for the client implementation). */ export function getValidatorApi(opts, { chain, config, logger, metrics, network, sync }) { let genesisBlockRoot = null; /** * Validator clock may be advanced from beacon's clock. If the validator requests a resource in a * future slot, wait some time instead of rejecting the request because it's in the future. * This value is the same to MAXIMUM_GOSSIP_CLOCK_DISPARITY_SEC. * For very fast networks, reduce clock disparity to half a slot. */ const MAX_API_CLOCK_DISPARITY_SEC = Math.min(0.5, config.SECONDS_PER_SLOT / 2); const MAX_API_CLOCK_DISPARITY_MS = MAX_API_CLOCK_DISPARITY_SEC * 1000; /** Compute and cache the genesis block root */ async function getGenesisBlockRoot(state) { if (!genesisBlockRoot) { // Close to genesis the genesis block may not be available in the DB if (state.slot < SLOTS_PER_HISTORICAL_ROOT) { genesisBlockRoot = state.blockRoots.get(0); } const blockRes = await chain.getCanonicalBlockAtSlot(GENESIS_SLOT); if (blockRes) { genesisBlockRoot = config .getForkTypes(blockRes.block.message.slot) .SignedBeaconBlock.hashTreeRoot(blockRes.block); } } // If for some reason the genesisBlockRoot is not able don't prevent validators from // proposing or attesting. If the genesisBlockRoot is wrong, at worst it may trigger a re-fetch of the duties return genesisBlockRoot || ZERO_HASH; } /** * If advancing the local clock `MAX_API_CLOCK_DISPARITY_MS` ticks to the requested slot, wait for its start * Prevents the validator from getting errors from the API if the clock is a bit advanced */ async function waitForSlot(slot) { if (slot <= 0) { return; } const slotStartSec = chain.genesisTime + slot * config.SECONDS_PER_SLOT; const msToSlot = slotStartSec * 1000 - Date.now(); if (msToSlot > MAX_API_CLOCK_DISPARITY_MS) { throw Error(`Requested slot ${slot} is in the future`); } if (msToSlot > 0) { await chain.clock.waitForSlot(slot); } // else, clock already in slot or slot is in the past } /** * If advancing the local clock `MAX_API_CLOCK_DISPARITY_MS` ticks to the next epoch, wait for slot 0 of the next epoch. * Prevents a validator from not being able to get the attestater duties correctly if the beacon and validator clocks are off */ async function waitForNextClosestEpoch() { const toNextEpochMs = msToNextEpoch(); if (toNextEpochMs > 0 && toNextEpochMs < MAX_API_CLOCK_DISPARITY_MS) { const nextEpoch = chain.clock.currentEpoch + 1; await chain.clock.waitForSlot(computeStartSlotAtEpoch(nextEpoch)); } } /** * Compute ms to the next epoch. */ function msToNextEpoch() { const nextEpoch = chain.clock.currentEpoch + 1; const secPerEpoch = SLOTS_PER_EPOCH * config.SECONDS_PER_SLOT; const nextEpochStartSec = chain.genesisTime + nextEpoch * secPerEpoch; return nextEpochStartSec * 1000 - Date.now(); } function currentEpochWithDisparity() { return computeEpochAtSlot(getCurrentSlot(config, chain.genesisTime - MAX_API_CLOCK_DISPARITY_SEC)); } function getBlockValueLogInfo(block, source) { const executionValue = block.executionPayloadValue; const consensusValue = block.consensusBlockValue; const totalValue = executionValue + consensusValue; if (source == null) { return { executionPayloadValue: prettyWeiToEth(executionValue), consensusBlockValue: prettyWeiToEth(consensusValue), blockTotalValue: prettyWeiToEth(totalValue), }; } if (source === ProducedBlockSource.builder) { return { builderExecutionPayloadValue: prettyWeiToEth(executionValue), builderConsensusBlockValue: prettyWeiToEth(consensusValue), builderBlockTotalValue: prettyWeiToEth(totalValue), }; } return { engineExecutionPayloadValue: prettyWeiToEth(executionValue), engineConsensusBlockValue: prettyWeiToEth(consensusValue), engineBlockTotalValue: prettyWeiToEth(totalValue), }; } /** * This function is called 1s before next epoch, usually at that time PrepareNextSlotScheduler finishes * so we should have checkpoint state, otherwise wait for up to the slot 1 of epoch. * slot epoch 0 1 * |------------|------------| * ^ ^ * | | * | | * | waitForCheckpointState (1s before slot 0 of epoch, wait until slot 1 of epoch) * | * prepareNextSlot (4s before next slot) */ async function waitForCheckpointState(cpHex) { const cpState = chain.regen.getCheckpointStateSync(cpHex); if (cpState) { return cpState; } const cp = { epoch: cpHex.epoch, root: fromHex(cpHex.rootHex), }; const slot0 = computeStartSlotAtEpoch(cp.epoch); // if not, wait for ChainEvent.checkpoint event until slot 1 of epoch let listener = null; const foundCPState = await Promise.race([ new Promise((resolve) => { listener = (eventCp) => { resolve(ssz.phase0.Checkpoint.equals(eventCp, cp)); }; chain.emitter.once(ChainEvent.checkpoint, listener); }), // in rare case, checkpoint state cache may happen up to 6s of slot 0 of epoch // so we wait for it until the slot 1 of epoch chain.clock.waitForSlot(slot0 + 1), ]); if (listener != null) { chain.emitter.off(ChainEvent.checkpoint, listener); } if (foundCPState === true) { return chain.regen.getCheckpointStateSync(cpHex); } return null; } /** * Reject any request while the node is syncing */ function notWhileSyncing() { // Consider node synced before or close to genesis if (chain.clock.currentSlot < SLOTS_PER_EPOCH) { return; } const syncState = sync.state; switch (syncState) { case SyncState.SyncingFinalized: case SyncState.SyncingHead: { const currentSlot = chain.clock.currentSlot; const headSlot = chain.forkChoice.getHead().slot; if (currentSlot - headSlot > SYNC_TOLERANCE_EPOCHS * SLOTS_PER_EPOCH) { throw new NodeIsSyncing(`headSlot ${headSlot} currentSlot ${currentSlot}`); } return; } case SyncState.Synced: return; case SyncState.Stalled: throw new NodeIsSyncing("waiting for peers"); } } /** * Post merge, the CL and EL could be out of step in the sync, and could result in * Syncing status of the chain head. To be precise: * 1. CL could be ahead of the EL, with the validity of head payload not yet verified * 2. CL could be on an invalid chain of execution blocks with a non-existent * or non-available parent that never syncs up * * Both the above scenarios could be problematic and hence validator shouldn't participate * or weigh its vote on a head till it resolves to a Valid execution status. * Following activities should be skipped on an Optimistic head (with Syncing status): * 1. Attestation if targetRoot is optimistic * 2. SyncCommitteeContribution if if the root for which to produce contribution is Optimistic. * 3. ProduceBlock if the parentRoot (chain's current head is optimistic). However this doesn't * need to be checked/aborted here as assembleBody would call EL's api for the latest * executionStatus of the parentRoot. If still not validated, produceBlock will throw error. * * TODO/PENDING: SyncCommitteeSignatures should also be aborted, the best way to address this * is still in flux and will be updated as and when other CL's figure this out. */ function notOnOptimisticBlockRoot(beaconBlockRoot) { const protoBeaconBlock = chain.forkChoice.getBlock(beaconBlockRoot); if (!protoBeaconBlock) { throw new ApiError(404, `Block not in forkChoice, beaconBlockRoot=${toRootHex(beaconBlockRoot)}`); } if (protoBeaconBlock.executionStatus === ExecutionStatus.Syncing) throw new NodeIsSyncing(`Block's execution payload not yet validated, executionPayloadBlockHash=${protoBeaconBlock.executionPayloadBlockHash} number=${protoBeaconBlock.executionPayloadNumber}`); } function notOnOutOfRangeData(beaconBlockRoot) { const protoBeaconBlock = chain.forkChoice.getBlock(beaconBlockRoot); if (!protoBeaconBlock) { throw new ApiError(404, `Block not in forkChoice, beaconBlockRoot=${toRootHex(beaconBlockRoot)}`); } if (protoBeaconBlock.dataAvailabilityStatus === DataAvailabilityStatus.OutOfRange) throw new NodeIsSyncing("Block's data is out of range and not validated"); } async function produceBuilderBlindedBlock(slot, randaoReveal, graffiti, // as of now fee recipient checks can not be performed because builder does not return bid recipient { commonBlockBodyPromise, parentBlockRoot, parentSlot, }) { const version = config.getForkName(slot); if (!isForkPostBellatrix(version)) { throw Error(`Invalid fork=${version} for produceBuilderBlindedBlock`); } const source = ProducedBlockSource.builder; metrics?.blockProductionRequests.inc({ source }); // Error early for builder if builder flow not active if (!chain.executionBuilder) { throw Error("Execution builder not set"); } if (!chain.executionBuilder.status) { throw Error("Execution builder disabled"); } let timer; try { timer = metrics?.blockProductionTime.startTimer(); const { block, executionPayloadValue, consensusBlockValue } = await chain.produceBlindedBlock({ slot, parentBlockRoot, parentSlot, randaoReveal, graffiti, commonBlockBodyPromise, }); metrics?.blockProductionSuccess.inc({ source }); metrics?.blockProductionNumAggregated.observe({ source }, block.body.attestations.length); metrics?.blockProductionConsensusBlockValue.observe({ source }, Number(formatWeiToEth(consensusBlockValue))); metrics?.blockProductionExecutionPayloadValue.observe({ source }, Number(formatWeiToEth(executionPayloadValue))); logger.verbose("Produced blinded block", { slot, executionPayloadValue, consensusBlockValue, root: toRootHex(config.getPostBellatrixForkTypes(slot).BlindedBeaconBlock.hashTreeRoot(block)), }); if (chain.opts.persistProducedBlocks) { void chain.persistBlock(block, "produced_builder_block"); } return { data: block, version, executionPayloadValue, consensusBlockValue }; } finally { if (timer) timer({ source }); } } async function produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, { feeRecipient, strictFeeRecipientCheck, commonBlockBodyPromise, parentBlockRoot, parentSlot, }) { const source = ProducedBlockSource.engine; metrics?.blockProductionRequests.inc({ source }); let timer; try { timer = metrics?.blockProductionTime.startTimer(); const { block, executionPayloadValue, consensusBlockValue, shouldOverrideBuilder } = await chain.produceBlock({ slot, parentBlockRoot, parentSlot, randaoReveal, graffiti, feeRecipient, commonBlockBodyPromise, }); const version = config.getForkName(block.slot); if (strictFeeRecipientCheck && feeRecipient && isForkPostBellatrix(version)) { const blockFeeRecipient = toHex(block.body.executionPayload.feeRecipient); if (blockFeeRecipient !== feeRecipient) { throw Error(`Invalid feeRecipient set in engine block expected=${feeRecipient} actual=${blockFeeRecipient}`); } } metrics?.blockProductionSuccess.inc({ source }); metrics?.blockProductionNumAggregated.observe({ source }, block.body.attestations.length); metrics?.blockProductionConsensusBlockValue.observe({ source }, Number(formatWeiToEth(consensusBlockValue))); metrics?.blockProductionExecutionPayloadValue.observe({ source }, Number(formatWeiToEth(executionPayloadValue))); logger.verbose("Produced execution block", { slot, executionPayloadValue, consensusBlockValue, root: toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block)), }); if (chain.opts.persistProducedBlocks) { void chain.persistBlock(block, "produced_engine_block"); } if (isForkPostDeneb(version)) { const blockHash = toRootHex(block.body.executionPayload.blockHash); const contents = chain.producedContentsCache.get(blockHash); if (contents === undefined) { throw Error("contents missing in cache"); } return { data: { block, ...contents }, version, executionPayloadValue, consensusBlockValue, shouldOverrideBuilder, }; } return { data: block, version, executionPayloadValue, consensusBlockValue, shouldOverrideBuilder }; } finally { if (timer) timer({ source }); } } async function produceEngineOrBuilderBlock(slot, randaoReveal, graffiti, // TODO deneb: skip randao verification _skipRandaoVerification, builderBoostFactor, { feeRecipient, builderSelection, strictFeeRecipientCheck } = {}) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot const { blockRoot: parentBlockRootHex, slot: parentSlot } = chain.getProposerHead(slot); const parentBlockRoot = fromHex(parentBlockRootHex); notOnOutOfRangeData(parentBlockRoot); metrics?.blockProductionSlotDelta.set(slot - parentSlot); const fork = config.getForkName(slot); // set some sensible opts // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled // and the actual selection will be determined using builderBoostFactor passed by the validator builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; builderBoostFactor = builderBoostFactor ?? BigInt(100); if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); } const isBuilderEnabled = ForkSeq[fork] >= ForkSeq.bellatrix && chain.executionBuilder !== undefined && builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; // At any point either the builder or execution or both flows should be active. // // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager // configurations could cause a validator pubkey to have builder disabled with builder selection builder only // (TODO: independently make sure such an options update is not successful for a validator pubkey) // // So if builder is disabled ignore builder selection of builder only if caused by user mistake // https://github.com/ChainSafe/lodestar/issues/6338 const isEngineEnabled = !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly; if (!isEngineEnabled && !isBuilderEnabled) { throw Error(`Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}`); } const graffitiBytes = toGraffitiBytes(graffiti ?? getDefaultGraffiti(getLodestarClientVersion(opts), chain.executionEngine.clientVersion, opts)); const loggerContext = { slot, parentSlot, parentBlockRoot: parentBlockRootHex, fork, builderSelection, isBuilderEnabled, isEngineEnabled, strictFeeRecipientCheck, // winston logger doesn't like bigint builderBoostFactor: `${builderBoostFactor}`, }; logger.verbose("Assembling block with produceEngineOrBuilderBlock", loggerContext); // Defer common block body production to make sure we sent async builder and engine requests before const deferredCommonBlockBody = defer(); const commonBlockBodyPromise = deferredCommonBlockBody.promise; // use abort controller to stop waiting for both block sources const controller = new AbortController(); // Start calls for building execution and builder blocks const builderPromise = isBuilderEnabled ? produceBuilderBlindedBlock(slot, randaoReveal, graffitiBytes, { feeRecipient, // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now strictFeeRecipientCheck: false, commonBlockBodyPromise, parentBlockRoot, parentSlot, }) : Promise.reject(new Error("Builder disabled")); const enginePromise = isEngineEnabled ? produceEngineFullBlockOrContents(slot, randaoReveal, graffitiBytes, { feeRecipient, strictFeeRecipientCheck, commonBlockBodyPromise, parentBlockRoot, parentSlot, }).then((engineBlock) => { // Once the engine returns a block, in the event of either: // - suspected builder censorship // - builder boost factor set to 0 or builder selection `executionalways` // we don't need to wait for builder block as engine block will always be selected if (engineBlock.shouldOverrideBuilder || builderBoostFactor === BigInt(0) || builderSelection === routes.validator.BuilderSelection.ExecutionAlways) { controller.abort(); } return engineBlock; }) : Promise.reject(new Error("Engine disabled")); // Calculate cutoff time based on start of the slot const cutoffMs = Math.max(0, BLOCK_PRODUCTION_RACE_CUTOFF_MS - Math.round(chain.clock.secFromSlot(slot) * 1000)); logger.verbose("Block production race (builder vs execution) starting", { ...loggerContext, cutoffMs, timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, }); const blockProductionRacePromise = resolveOrRacePromises([builderPromise, enginePromise], { resolveTimeoutMs: cutoffMs, raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, signal: controller.signal, }); logger.verbose("Producing common block body", loggerContext); const commonBlockBodyStartedAt = Date.now(); const produceCommonBlockBodyPromise = chain .produceCommonBlockBody({ slot, parentBlockRoot, parentSlot, randaoReveal, graffiti: graffitiBytes, }) .then((commonBlockBody) => { deferredCommonBlockBody.resolve(commonBlockBody); logger.verbose("Produced common block body", { ...loggerContext, durationMs: Date.now() - commonBlockBodyStartedAt, }); }) .catch(deferredCommonBlockBody.reject); const [[builder, engine]] = await Promise.all([blockProductionRacePromise, produceCommonBlockBodyPromise]); if (builder.status === "pending" && engine.status === "pending") { throw Error("Builder and engine both failed to produce the block within timeout"); } if (isEngineEnabled) { if (engine.status === "rejected") { logger.warn("Engine failed to produce the block", { ...loggerContext, durationMs: engine.durationMs, }, engine.reason); } else if (engine.status === "pending") { logger.warn("Engine failed to produce the block within cutoff time", { ...loggerContext, cutoffMs, }); } } if (isBuilderEnabled) { if (builder.status === "rejected") { if (builder.reason instanceof NoBidReceived) { logger.info("Builder did not provide a bid", { ...loggerContext, durationMs: builder.durationMs, }); } else { logger.warn("Builder failed to produce the block", { ...loggerContext, durationMs: builder.durationMs, }, builder.reason); } } else if (builder.status === "pending") { logger.warn("Builder failed to produce the block within cutoff time", { ...loggerContext, cutoffMs, }); } } if (builder.status === "rejected" && engine.status === "rejected") { throw Error(`${isBuilderEnabled && isEngineEnabled ? "Builder and engine both" : isBuilderEnabled ? "Builder" : "Engine"} failed to produce the block`); } // handle shouldOverrideBuilder separately if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) { logger.info("Selected engine block: censorship suspected in builder blocks", { ...loggerContext, durationMs: engine.durationMs, shouldOverrideBuilder: engine.value.shouldOverrideBuilder, ...getBlockValueLogInfo(engine.value), }); metrics?.blockProductionSelectionResults.inc({ source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BuilderCensorship, }); return { ...engine.value, executionPayloadBlinded: false, executionPayloadSource: ProducedBlockSource.engine }; } if (builder.status === "fulfilled" && engine.status !== "fulfilled") { const reason = isEngineEnabled === false ? BuilderBlockSelectionReason.EngineDisabled : engine.status === "pending" ? BuilderBlockSelectionReason.EnginePending : BuilderBlockSelectionReason.EngineError; logger.info("Selected builder block: no engine block produced", { reason, ...loggerContext, durationMs: builder.durationMs, ...getBlockValueLogInfo(builder.value), }); metrics?.blockProductionSelectionResults.inc({ source: ProducedBlockSource.builder, reason, }); return { ...builder.value, executionPayloadBlinded: true, executionPayloadSource: ProducedBlockSource.builder }; } if (engine.status === "fulfilled" && builder.status !== "fulfilled") { const reason = isBuilderEnabled === false ? EngineBlockSelectionReason.BuilderDisabled : builder.status === "pending" ? EngineBlockSelectionReason.BuilderPending : builder.reason instanceof NoBidReceived ? EngineBlockSelectionReason.BuilderNoBid : builder.reason instanceof TimeoutError ? EngineBlockSelectionReason.BuilderTimeout : EngineBlockSelectionReason.BuilderError; logger.info("Selected engine block: no builder block produced", { reason, ...loggerContext, durationMs: engine.durationMs, ...getBlockValueLogInfo(engine.value), }); metrics?.blockProductionSelectionResults.inc({ source: ProducedBlockSource.engine, reason, }); return { ...engine.value, executionPayloadBlinded: false, executionPayloadSource: ProducedBlockSource.engine }; } if (engine.status === "fulfilled" && builder.status === "fulfilled") { const result = selectBlockProductionSource({ builderBlockValue: builder.value.executionPayloadValue + builder.value.consensusBlockValue, engineBlockValue: engine.value.executionPayloadValue + engine.value.consensusBlockValue, builderBoostFactor, builderSelection, }); const executionPayloadSource = result.source; metrics?.blockProductionSelectionResults.inc(result); logger.info(`Selected ${executionPayloadSource} block`, { reason: result.reason, ...loggerContext, engineDurationMs: engine.durationMs, ...getBlockValueLogInfo(engine.value, ProducedBlockSource.engine), builderDurationMs: builder.durationMs, ...getBlockValueLogInfo(builder.value, ProducedBlockSource.builder), }); if (executionPayloadSource === ProducedBlockSource.engine) { return { ...engine.value, executionPayloadBlinded: false, executionPayloadSource, }; } return { ...builder.value, executionPayloadBlinded: true, executionPayloadSource, }; } throw Error("Unreachable error occurred during the builder and execution block production"); } return { async produceBlockV3({ slot, randaoReveal, graffiti, skipRandaoVerification, builderBoostFactor, ...opts }) { const { data, ...meta } = await produceEngineOrBuilderBlock(slot, randaoReveal, graffiti, skipRandaoVerification, builderBoostFactor, opts); if (opts.blindedLocal === true && ForkSeq[meta.version] >= ForkSeq.bellatrix) { if (meta.executionPayloadBlinded) { return { data, meta }; } if (isBlockContents(data)) { const { block } = data; const blindedBlock = beaconBlockToBlinded(config, block); return { data: blindedBlock, meta: { ...meta, executionPayloadBlinded: true }, }; } const blindedBlock = beaconBlockToBlinded(config, data); return { data: blindedBlock, meta: { ...meta, executionPayloadBlinded: true }, }; } return { data, meta }; }, async produceAttestationData({ committeeIndex, slot }) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot // This needs a state in the same epoch as `slot` such that state.currentJustifiedCheckpoint is correct. // Note: This may trigger an epoch transition if there skipped slots at the beginning of the epoch. const headState = chain.getHeadState(); const headSlot = headState.slot; const attEpoch = computeEpochAtSlot(slot); const headBlockRootHex = chain.forkChoice.getHead().blockRoot; const headBlockRoot = fromHex(headBlockRootHex); const fork = config.getForkName(slot); let index; if (isForkPostElectra(fork)) { index = 0; } else { if (committeeIndex === undefined) { throw new ApiError(400, `Committee index must be provided for pre-electra fork=${fork}`); } index = committeeIndex; } const beaconBlockRoot = slot >= headSlot ? // When attesting to the head slot or later, always use the head of the chain. headBlockRoot : // Permit attesting to slots *prior* to the current head. This is desirable when // the VC and BN are out-of-sync due to time issues or overloading. getBlockRootAtSlot(headState, slot); const targetSlot = computeStartSlotAtEpoch(attEpoch); const targetRoot = targetSlot >= headSlot ? // If the state is earlier than the target slot then the target *must* be the head block root. headBlockRoot : getBlockRootAtSlot(headState, targetSlot); // Check the execution status as validator shouldn't vote on an optimistic head // Check on target is sufficient as a valid target would imply a valid source notOnOptimisticBlockRoot(targetRoot); notOnOutOfRangeData(targetRoot); // To get the correct source we must get a state in the same epoch as the attestation's epoch. // An epoch transition may change state.currentJustifiedCheckpoint const attEpochState = await chain.getHeadStateAtEpoch(attEpoch, RegenCaller.produceAttestationData); // TODO confirm if the below is correct assertion // notOnOutOfRangeData(attEpochState.currentJustifiedCheckpoint.root); return { data: { slot, index, beaconBlockRoot, source: attEpochState.currentJustifiedCheckpoint, target: { epoch: attEpoch, root: targetRoot }, }, }; }, /** * GET `/eth/v1/validator/sync_committee_contribution` * * Requests that the beacon node produce a sync committee contribution. * * https://github.com/ethereum/beacon-APIs/pull/138 * * @param slot The slot for which a sync committee contribution should be created. * @param subcommitteeIndex The subcommittee index for which to produce the contribution. * @param beaconBlockRoot The block root for which to produce the contribution. */ async produceSyncCommitteeContribution({ slot, subcommitteeIndex, beaconBlockRoot }) { // when a validator is configured with multiple beacon node urls, this beaconBlockRoot may come from another beacon node // and it hasn't been in our forkchoice since we haven't seen / processing that block // see https://github.com/ChainSafe/lodestar/issues/5063 if (!chain.forkChoice.hasBlock(beaconBlockRoot)) { const rootHex = toRootHex(beaconBlockRoot); network.searchUnknownSlotRoot({ slot, root: rootHex }); // if result of this call is false, i.e. block hasn't seen after 1 slot then the below notOnOptimisticBlockRoot call will throw error await chain.waitForBlock(slot, rootHex); } // Check the execution status as validator shouldn't contribute on an optimistic head notOnOptimisticBlockRoot(beaconBlockRoot); notOnOutOfRangeData(beaconBlockRoot); const contribution = chain.syncCommitteeMessagePool.getContribution(subcommitteeIndex, slot, beaconBlockRoot); if (!contribution) { throw new ApiError(404, `No sync committee contribution for slot=${slot}, subnet=${subcommitteeIndex}, beaconBlockRoot=${toRootHex(beaconBlockRoot)}`); } metrics?.production.producedSyncContributionParticipants.observe(contribution.aggregationBits.getTrueBitIndexes().length); return { data: contribution }; }, async getProposerDuties({ epoch }) { notWhileSyncing(); // Early check that epoch is no more than current_epoch + 1, or allow for pre-genesis const currentEpoch = currentEpochWithDisparity(); const nextEpoch = currentEpoch + 1; if (currentEpoch >= 0 && epoch > nextEpoch) { throw new ApiError(400, `Requested epoch ${epoch} must not be more than one epoch in the future`); } const head = chain.forkChoice.getHead(); let state = undefined; const startSlot = computeStartSlotAtEpoch(epoch); const slotMs = config.SECONDS_PER_SLOT * 1000; const prepareNextSlotLookAheadMs = slotMs / SCHEDULER_LOOKAHEAD_FACTOR; const toNextEpochMs = msToNextEpoch(); // validators may request next epoch's duties when it's close to next epoch // this is to avoid missed block proposal due to 0 epoch look ahead if (epoch === nextEpoch && toNextEpochMs < prepareNextSlotLookAheadMs) { // wait for maximum 1 slot for cp state which is the timeout of validator api const cpState = await waitForCheckpointState({ rootHex: head.blockRoot, epoch }); if (cpState) { state = cpState; metrics?.duties.requestNextEpochProposalDutiesHit.inc(); } else { metrics?.duties.requestNextEpochProposalDutiesMiss.inc(); } } if (!state) { if (epoch >= currentEpoch - 1) { // Cached beacon state stores proposers for previous, current and next epoch. The // requested epoch is within that range, we can use the head state at current epoch state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.getDuties); } else { const res = await getStateResponseWithRegen(chain, startSlot); const stateViewDU = res.state instanceof Uint8Array ? loadState(config, chain.getHeadState(), res.state).state : res.state.clone(); state = createCachedBeaconState(stateViewDU, { config: chain.config, // Not required to compute proposers pubkey2index: new PubkeyIndexMap(), index2pubkey: [], }, { skipSyncPubkeys: true, skipSyncCommitteeCache: true }); if (state.epochCtx.epoch !== epoch) { throw Error(`Loaded state epoch ${state.epochCtx.epoch} does not match requested epoch ${epoch}`); } } } const stateEpoch = state.epochCtx.epoch; let indexes = []; switch (epoch) { case stateEpoch: indexes = state.epochCtx.getBeaconProposers(); break; case stateEpoch + 1: // make sure shuffling is calculated and ready for next call to calculate nextProposers await chain.shufflingCache.get(state.epochCtx.nextEpoch, state.epochCtx.nextDecisionRoot); // Requesting duties for next epoch is allowed since they can be predicted with high probabilities. // @see `epochCtx.getBeaconProposersNextEpoch` JSDocs for rationale. indexes = state.epochCtx.getBeaconProposersNextEpoch(); break; case stateEpoch - 1: { const indexesPrevEpoch = state.epochCtx.getBeaconProposersPrevEpoch(); if (indexesPrevEpoch === null) { // Should not happen as previous proposer duties should be initialized for head state // and if we load state from `Uint8Array` it will always be the state of requested epoch throw Error(`Proposer duties for previous epoch ${epoch} not yet initialized`); } indexes = indexesPrevEpoch; break; } default: // Should never happen, epoch is checked to be in bounds above throw Error(`Proposer duties for epoch ${epoch} not supported, current epoch ${stateEpoch}`); } // NOTE: this is the fastest way of getting compressed pubkeys. // See benchmark -> packages/lodestar/test/perf/api/impl/validator/attester.test.ts // After dropping the flat caches attached to the CachedBeaconState it's no longer available. // TODO: Add a flag to just send 0x00 as pubkeys since the Lodestar validator does not need them. const pubkeys = getPubkeysForIndices(state.validators, indexes); const duties = []; for (let i = 0; i < SLOTS_PER_EPOCH; i++) { duties.push({ slot: startSlot + i, validatorIndex: indexes[i], pubkey: pubkeys[i] }); } // Returns `null` on the one-off scenario where the genesis block decides its own shuffling. // It should be set to the latest block applied to `self` or the genesis block root. const dependentRoot = proposerShufflingDecisionRoot(state) || (await getGenesisBlockRoot(state)); return { data: duties, meta: { dependentRoot: toRootHex(dependentRoot), executionOptimistic: isOptimisticBlock(head), }, }; }, async getAttesterDuties({ epoch, indices }) { notWhileSyncing(); if (indices.length === 0) { throw new ApiError(400, "No validator to get attester duties"); } // May request for an epoch that's in the future await waitForNextClosestEpoch(); // should not compare to headEpoch in order to handle skipped slots // Check if the epoch is in the future after waiting for requested slot if (epoch > chain.clock.currentEpoch + 1) { throw new ApiError(400, "Cannot get duties for epoch more than one ahead"); } const head = chain.forkChoice.getHead(); const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.getDuties); // TODO: Determine what the current epoch would be if we fast-forward our system clock by // `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. // // Most of the time, `tolerantCurrentEpoch` will be equal to `currentEpoch`. However, during // the first `MAXIMUM_GOSSIP_CLOCK_DISPARITY` duration of the epoch `tolerantCurrentEpoch` // will equal `currentEpoch + 1` // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() const pubkeys = getPubkeysForIndices(state.validators, indices); const decisionRoot = state.epochCtx.getShufflingDecisionRoot(epoch); const shuffling = await chain.shufflingCache.get(epoch, decisionRoot); if (!shuffling) { throw new ApiError(500, `No shuffling found to calculate committee assignments for epoch: ${epoch} and decisionRoot: ${decisionRoot}`); } const committeeAssignments = calculateCommitteeAssignments(shuffling, indices); const duties = []; for (let i = 0, len = indices.length; i < len; i++) { const validatorIndex = indices[i]; const duty = committeeAssignments.get(validatorIndex); if (duty) { // Mutate existing object instead of re-creating another new object with spread operator // Should be faster and require less memory duty.pubkey = pubkeys[i]; duties.push(duty); } } const dependentRoot = attesterShufflingDecisionRoot(state, epoch) || (await getGenesisBlockRoot(state)); return { data: duties, meta: { dependentRoot: toRootHex(dependentRoot), executionOptimistic: isOptimisticBlock(head), }, }; }, /** * `POST /eth/v1/validator/duties/sync/{epoch}` * * Requests the beacon node to provide a set of sync committee duties for a particular epoch. * - Although pubkey can be inferred from the index we return it to keep this call analogous with the one that * fetches attester duties. * - `sync_committee_index` is the index of the validator in the sync committee. This can be used to infer the * subnet to which the contribution should be broadcast. Note, there can be multiple per validator. * * https://github.com/ethereum/beacon-APIs/pull/134 * * @param validatorIndices an array of the validator indices for which to obtain the duties. */ async getSyncCommitteeDuties({ epoch, indices }) { notWhileSyncing(); if (indices.length === 0) { throw new ApiError(400, "No validator to get attester duties"); } // May request for an epoch that's in the future await waitForNextClosestEpoch(); // sync committee duties have a lookahead of 1 day. Assuming the validator only requests duties for upcoming // epochs, the head state will very likely have the duties available for the requested epoch. // Note: does not support requesting past duties const head = chain.forkChoice.getHead(); const state = chain.getHeadState(); // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() const pubkeys = getPubkeysForIndices(state.validators, indices); // Ensures `epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD <= current_epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD + 1` const syncCommitteeCache = state.epochCtx.getIndexedSyncCommitteeAtEpoch(epoch); const syncCommitteeValidatorIndexMap = syncCommitteeCache.validatorIndexMap; const duties = []; for (let i = 0, len = indices.length; i < len; i++) { const validatorIndex = indices[i]; const validatorSyncCommitteeIndices = syncCommitteeValidatorIndexMap.get(validatorIndex); if (validatorSyncCommitteeIndices) { duties.push({ pubkey: pubkeys[i], validatorIndex, validatorSyncCommitteeIndices, }); } } return { data: duties, meta: { executionOptimistic: isOptimisticBlock(head) }, }; }, async getAggregatedAttestation({ attestationDataRoot, slot }) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot const dataRootHex = toRootHex(attestationDataRoot); const aggregate = chain.attestationPool.getAggregate(slot, dataRootHex, null);