@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
937 lines (936 loc) • 72.5 kB
JavaScript
import { routes } from "@lodestar/api";
import { ExecutionStatus, PayloadStatus } from "@lodestar/fork-choice";
import { BUILDER_INDEX_SELF_BUILD, ForkName, ForkSeq, GENESIS_SLOT, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SYNC_COMMITTEE_SUBNET_SIZE, isForkPostBellatrix, isForkPostDeneb, isForkPostElectra, isForkPostGloas, } from "@lodestar/params";
import { DataAvailabilityStatus, beaconBlockToBlinded, calculateCommitteeAssignments, computeEpochAtSlot, computeStartSlotAtEpoch, computeTimeAtSlot, getCurrentSlot, isStatePostAltair, isStatePostGloas, proposerShufflingDecisionRoot, } from "@lodestar/state-transition";
import { ProducedBlockSource, getValidatorStatus, 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 { BlockInputSource } from "../../../chain/blocks/blockInput/types.js";
import { AttestationError, AttestationErrorCode, GossipAction, SyncCommitteeError, SyncCommitteeErrorCode, } from "../../../chain/errors/index.js";
import { ChainEvent } from "../../../chain/index.js";
import { PREPARE_NEXT_SLOT_BPS } from "../../../chain/prepareNextSlot.js";
import { BlockType } from "../../../chain/produceBlock/index.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 { BuilderStatus, NoBidReceived } from "../../../execution/builder/http.js";
import { validateGossipFnRetryUnknownRoot } from "../../../network/processor/gossipHandlers.js";
import { SyncState } from "../../../sync/index.js";
import { callInNextEventLoop } from "../../../util/eventLoop.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, IndexedError, 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.
*
* TODO GLOAS: re-evaluate cutoff timing
*/
const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000;
/** Overall timeout for execution and block production apis */
const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000;
export { EngineBlockSelectionReason };
/**
* Engine block selection reasons tracked in metrics / logs
*/
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 = {}));
export { BuilderBlockSelectionReason };
/**
* Builder block selection reasons tracked in metrics / logs
*/
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.
* For very fast networks, reduce clock disparity to half a slot.
*/
const MAX_API_CLOCK_DISPARITY_SEC = Math.min(config.MAXIMUM_GOSSIP_CLOCK_DISPARITY / 1000, config.SLOT_DURATION_MS / 2000);
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 === GENESIS_SLOT) {
genesisBlockRoot = state.computeAnchorCheckpoint().checkpoint.root;
}
else if (state.slot < SLOTS_PER_HISTORICAL_ROOT) {
genesisBlockRoot = state.getBlockRootAtSlot(GENESIS_SLOT);
}
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 = computeTimeAtSlot(config, slot, chain.genesisTime);
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.SLOT_DURATION_MS) / 1000;
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.getBlockDefaultStatus(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.getBlockDefaultStatus(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, parentBlock, }) {
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("External builder not configured");
}
switch (chain.executionBuilder.status) {
case BuilderStatus.disabled:
throw Error("External builder disabled due to failed status check");
case BuilderStatus.circuitBreaker:
throw Error("External builder circuit breaker is activated");
case BuilderStatus.enabled:
// continue
break;
}
let timer;
try {
timer = metrics?.blockProductionTime.startTimer();
const { block, executionPayloadValue, consensusBlockValue } = await chain.produceBlindedBlock({
slot,
parentBlock,
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 produceEngineBlockContents(slot, randaoReveal, graffiti, { feeRecipient, strictFeeRecipientCheck, commonBlockBodyPromise, parentBlock, }) {
const source = ProducedBlockSource.engine;
metrics?.blockProductionRequests.inc({ source });
let timer;
try {
timer = metrics?.blockProductionTime.startTimer();
const { block, executionPayloadValue, consensusBlockValue, shouldOverrideBuilder } = await chain.produceBlock({
slot,
parentBlock,
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)));
const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block));
logger.verbose("Produced execution block", {
slot,
executionPayloadValue,
consensusBlockValue,
root: blockRoot,
});
if (chain.opts.persistProducedBlocks) {
void chain.persistBlock(block, "produced_engine_block");
}
if (isForkPostDeneb(version)) {
const produceResult = chain.blockProductionCache.get(blockRoot);
if (produceResult === undefined) {
throw Error("production result missing in cache");
}
if (!isForkPostDeneb(produceResult.fork)) {
throw Error("production result is for pre-deneb fork");
}
if (produceResult.type !== BlockType.Full) {
throw Error("production result is not full block");
}
const blobsBundle = produceResult.blobsBundle;
return {
data: {
block,
blobs: blobsBundle.blobs,
kzgProofs: blobsBundle.proofs,
},
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 parentBlock = chain.getProposerHead(slot);
const { blockRoot: parentBlockRootHex, slot: parentSlot } = parentBlock;
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,
parentBlock,
})
: Promise.reject(new Error("Builder disabled"));
const enginePromise = isEngineEnabled
? produceEngineBlockContents(slot, randaoReveal, graffitiBytes, {
feeRecipient,
strictFeeRecipientCheck,
commonBlockBodyPromise,
parentBlock,
}).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 - chain.clock.msFromSlot(slot));
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,
});
// Ensure builder and engine HTTP requests are sent before starting common block body production
// by deferring the call to next event loop iteration, allowing pending I/O operations like
// HTTP requests to be processed first and sent out early in slot.
callInNextEventLoop(() => {
logger.verbose("Producing common block body", loggerContext);
const commonBlockBodyStartedAt = Date.now();
chain
.produceCommonBlockBody({
slot,
parentBlock,
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 blockProductionRacePromise;
if (builder.status === "pending" && engine.status === "pending") {
throw Error("Builder and engine both failed to produce the block within timeout");
}
if (builder.status === "pending" && !isEngineEnabled) {
throw Error("Builder failed to produce the block within timeout");
}
if (engine.status === "pending" && !isBuilderEnabled) {
throw Error("Engine 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({
builderExecutionPayloadValue: builder.value.executionPayloadValue,
engineExecutionPayloadValue: engine.value.executionPayloadValue,
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);
const fork = ForkSeq[meta.version];
if (opts.blindedLocal === true && fork >= ForkSeq.bellatrix && fork < ForkSeq.gloas) {
if (meta.executionPayloadBlinded) {
return { data, meta };
}
const { block } = data;
const blindedBlock = beaconBlockToBlinded(config, block);
return {
data: blindedBlock,
meta: { ...meta, executionPayloadBlinded: true },
};
}
return { data, meta };
},
async produceBlockV4({ slot, randaoReveal, graffiti, feeRecipient }) {
const fork = config.getForkName(slot);
if (!isForkPostGloas(fork)) {
throw new ApiError(400, `produceBlockV4 not supported for pre-gloas fork=${fork}`);
}
notWhileSyncing();
await waitForSlot(slot);
// TODO GLOAS: support producing blocks from builder bids
const source = ProducedBlockSource.engine;
// TODO GLOAS: needs to be updated after fork choice changes are merged
const parentBlock = chain.getProposerHead(slot);
const { blockRoot: parentBlockRootHex, slot: parentSlot } = parentBlock;
const parentBlockRoot = fromHex(parentBlockRootHex);
notOnOutOfRangeData(parentBlockRoot);
metrics?.blockProductionSlotDelta.set(slot - parentSlot);
metrics?.blockProductionRequests.inc({ source });
const graffitiBytes = toGraffitiBytes(graffiti ?? getDefaultGraffiti(getLodestarClientVersion(), chain.executionEngine.clientVersion, {}));
const commonBlockBodyPromise = chain.produceCommonBlockBody({
slot,
parentBlock,
randaoReveal,
graffiti: graffitiBytes,
});
let timer;
try {
timer = metrics?.blockProductionTime.startTimer();
const { block, executionPayloadValue, consensusBlockValue } = await chain.produceBlock({
slot,
parentBlock,
randaoReveal,
graffiti: graffitiBytes,
feeRecipient,
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)));
const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block));
logger.verbose("Produced block", {
slot,
executionPayloadValue,
consensusBlockValue,
root: blockRoot,
});
if (chain.opts.persistProducedBlocks) {
void chain.persistBlock(block, "produced_engine_block");
}
return {
data: block,
meta: {
version: fork,
consensusBlockValue,
},
};
}
finally {
timer?.({ source });
}
},
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);
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.
headState.getBlockRootAtSlot(slot);
let index;
if (isForkPostGloas(fork)) {
const canonicalBlock = chain.forkChoice.getCanonicalBlockByRoot(beaconBlockRoot);
if (!canonicalBlock) {
// This should never happen
throw Error(`Block not found in fork choice for slot=${slot}, root=${toRootHex(beaconBlockRoot)}`);
}
// After Gloas, attestation.data.index signals payload status in fork-choice:
// - 0 = EMPTY / not present, 1 = FULL / present
// - same-slot attestations must always use index = 0
if (canonicalBlock.slot !== slot) {
index = canonicalBlock.payloadStatus === PayloadStatus.FULL ? 1 : 0;
}
else {
index = 0;
}
}
else 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 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
: headState.getBlockRootAtSlot(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 },
},
};
},
async producePayloadAttestationData({ slot }) {
const fork = config.getForkName(slot);
if (!isForkPostGloas(fork)) {
throw new ApiError(400, `producePayloadAttestationData is not supported before Gloas fork=${fork}`);
}
notWhileSyncing();
await waitForSlot(slot);
const block = chain.forkChoice.getCanonicalBlockClosestLteSlot(slot);
if (!block) {
throw new ApiError(404, `No canonical block found at or before slot=${slot}`);
}
const blockIsForSlot = block.slot === slot;
const payloadInput = chain.seenPayloadEnvelopeInputCache.get(block.blockRoot);
const payloadPresent = blockIsForSlot && (payloadInput?.hasPayloadEnvelope() ?? false);
const blobDataAvailable = blockIsForSlot && (payloadInput?.hasAllData() ?? false);
return {
data: {
beaconBlockRoot: fromHex(block.blockRoot),
slot,
payloadPresent,
blobDataAvailable,
},
meta: { version: fork },
};
},
/**
* 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.searchUnknownBlock({ slot, root: rootHex }, BlockInputSource.api);
// 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 }, _context, opts) {
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 prepareNextSlotLookAheadMs = config.SLOT_DURATION_MS - config.getSlotComponentDurationMs(PREPARE_NEXT_SLOT_BPS);
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);
state = res.state instanceof Uint8Array ? chain.getHeadState().loadOtherState(res.state) : res.state;
if (state.epoch !== epoch) {
throw Error(`Loaded state epoch ${state.epoch} does not match requested epoch ${epoch}`);
}
}
}
const stateEpoch = state.epoch;
let indexes = [];
switch (epoch) {
case stateEpoch:
indexes = state.currentProposers;
break;
case stateEpoch + 1: {
// make sure shuffling is calculated and ready for next call to calculate nextProposers
const nextEpoch = state.epoch + 1;
await chain.shufflingCache.get(nextEpoch, state.nextDecisionRoot);
// Requesting duties for next epoch is allowed since they can be predicted with high probabilities.
// @see `epochCtx.getBeaconProposersNextEpoch` JSDocs for rationale.
indexes = state.nextProposers;
break;
}
case stateEpoch - 1: {
const indexesPrevEpoch = state.previousProposers;
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, 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 =