@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
1,231 lines (1,085 loc) • 67.1 kB
text/typescript
import {routes} from "@lodestar/api";
import {ChainForkConfig} from "@lodestar/config";
import {ForkSeq} from "@lodestar/params";
import {RequestError, RequestErrorCode} from "@lodestar/reqresp";
import {computeTimeAtSlot} from "@lodestar/state-transition";
import {RootHex, gloas} from "@lodestar/types";
import {Logger, fromHex, prettyPrintIndices, pruneSetToMax, sleep, toRootHex} from "@lodestar/utils";
import {isBlockInputBlobs, isBlockInputColumns} from "../chain/blocks/blockInput/blockInput.js";
import {BlockInputSource, IBlockInput} from "../chain/blocks/blockInput/types.js";
import {PayloadError, PayloadErrorCode} from "../chain/blocks/importExecutionPayload.js";
import {PayloadEnvelopeInput, PayloadEnvelopeInputSource} from "../chain/blocks/payloadEnvelopeInput/index.js";
import {BlockError, BlockErrorCode} from "../chain/errors/index.js";
import {ChainEvent, ChainEventData, IBeaconChain} from "../chain/index.js";
import {validateGloasBlockDataColumnSidecars} from "../chain/validation/dataColumnSidecar.js";
import {validateGossipExecutionPayloadEnvelope} from "../chain/validation/executionPayloadEnvelope.js";
import {Metrics} from "../metrics/index.js";
import {INetwork, NetworkEvent, NetworkEventData, prettyPrintPeerIdStr} from "../network/index.js";
import {PeerSyncMeta} from "../network/peers/peersData.js";
import {PeerIdStr} from "../util/peerId.js";
import {shuffle} from "../util/shuffle.js";
import {sortBy} from "../util/sortBy.js";
import {wrapError} from "../util/wrapError.js";
import {MAX_CONCURRENT_REQUESTS} from "./constants.js";
import {SyncOptions} from "./options.js";
import {
BlockInputSyncCacheItem,
PayloadSyncCacheItem,
PendingBlockInput,
PendingBlockInputStatus,
PendingBlockType,
PendingPayloadEnvelope,
PendingPayloadInput,
PendingPayloadInputStatus,
PendingPayloadRootHex,
getBlockInputSyncCacheItemRootHex,
getBlockInputSyncCacheItemSlot,
getPayloadSyncCacheItemRootHex,
getPayloadSyncCacheItemSlot,
isPendingBlockInput,
isPendingPayloadEnvelope,
isPendingPayloadInput,
} from "./types.js";
import {DownloadByRootError, downloadByRoot} from "./utils/downloadByRoot.js";
import {getAllDescendantBlocks, getUnknownAndAncestorBlocks} from "./utils/pendingBlocksTree.js";
import {getRateLimitedUntilMs} from "./utils/rateLimit.js";
const MAX_ATTEMPTS_PER_BLOCK = 5;
const MAX_KNOWN_BAD_BLOCKS = 500;
const MAX_PENDING_BLOCKS = 100;
type AdvancePendingBlockResult =
| "ready"
| "queued_block"
| "queued_parent_block"
| "queued_parent_payload"
| "blocked"
| "removed";
enum FetchResult {
SuccessResolved = "success_resolved",
SuccessMissingParent = "success_missing_parent",
SuccessLate = "success_late",
FailureTriedAllPeers = "failure_tried_all_peers",
FailureMaxAttempts = "failure_max_attempts",
}
class UnknownBlockRateLimitedError extends Error {
constructor(message: string) {
super(message);
this.name = "UnknownBlockRateLimitedError";
}
}
/**
* BlockInputSync is a class that handles ReqResp to find blocks and data related to a specific blockRoot. The
* blockRoot may have been found via object gossip, or the API. Gossip objects that can trigger a search are block,
* blobs, columns, attestations, etc. In the case of blocks and data this is generally during the current slot but
* can also be for items that are received late but are not fully verified and thus not in fork-choice (old blocks on
* an unknown fork). It can also be triggered via an attestation (or sync committee message or any other item that
* gets gossiped) that references a blockRoot that is not in fork-choice. In rare (and realistically should not happen)
* situations it can get triggered via the API when the validator attempts to publish a block, attestation, aggregate
* and proof or a sync committee contribution that has unknown information included (parentRoot for instance).
*
* The goal of the class is to make sure that all information that is necessary for import into fork-choice is pulled
* from peers so that the block and data can be processed, and thus the object that triggered the search can be
* referenced and validated.
*
* The most common case for this search is a set of block/data that comes across gossip for the current slot, during
* normal chain operation, but not everything was received before the gossip cutoff window happens so it is necessary
* to pull remaining data via req/resp so that fork-choice can be updated prior to making an attestation for the
* current slot.
*
* Event sources for old UnknownBlock
*
* - publishBlock
* - gossipHandlers
* - searchUnknownBlock
* = produceSyncCommitteeContribution
* = validateGossipFnRetryUnknownRoot
* * submitPoolAttestationsV2
* * publishAggregateAndProofsV2
* = onPendingGossipsubMessage
* * NetworkEvent.pendingGossipsubMessage
* - onGossipsubMessage
*/
export class BlockInputSync {
/**
* block RootHex -> PendingBlock. To avoid finding same root at the same time
*/
private readonly pendingBlocks = new Map<RootHex, BlockInputSyncCacheItem>();
// Payload sync is keyed by beacon block root as well, so block and payload queues can unblock each other.
private readonly pendingPayloads = new Map<RootHex, PayloadSyncCacheItem>();
private readonly knownBadBlocks = new Set<RootHex>();
private readonly maxPendingBlocks;
private subscribedToNetworkEvents = false;
private peerBalancer: UnknownBlockPeerBalancer;
private rateLimitBackoffTimeout: NodeJS.Timeout | undefined;
constructor(
private readonly config: ChainForkConfig,
private readonly network: INetwork,
private readonly chain: IBeaconChain,
private readonly logger: Logger,
private readonly metrics: Metrics | null,
private readonly opts?: SyncOptions
) {
this.maxPendingBlocks = opts?.maxPendingBlocks ?? MAX_PENDING_BLOCKS;
this.peerBalancer = new UnknownBlockPeerBalancer();
if (metrics) {
metrics.blockInputSync.pendingBlocks.addCollect(() =>
metrics.blockInputSync.pendingBlocks.set(this.pendingBlocks.size)
);
metrics.blockInputSync.pendingPayloads.addCollect(() =>
metrics.blockInputSync.pendingPayloads.set(this.pendingPayloads.size)
);
metrics.blockInputSync.knownBadBlocks.addCollect(() =>
metrics.blockInputSync.knownBadBlocks.set(this.knownBadBlocks.size)
);
}
}
subscribeToNetwork(): void {
if (this.opts?.disableBlockInputSync) {
this.logger.verbose("BlockInputSync disabled by disableBlockInputSync option.");
return;
}
// cannot chain to the above if or the log will be incorrect
if (!this.subscribedToNetworkEvents) {
this.logger.verbose("BlockInputSync enabled.");
this.chain.emitter.on(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot);
this.chain.emitter.on(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownEnvelopeBlockRoot);
this.chain.emitter.on(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput);
this.chain.emitter.on(ChainEvent.incompletePayloadEnvelope, this.onIncompletePayloadEnvelope);
this.chain.emitter.on(ChainEvent.blockUnknownParent, this.onUnknownParent);
this.chain.emitter.on(routes.events.EventType.block, this.onBlockImported);
this.chain.emitter.on(routes.events.EventType.executionPayload, this.onPayloadImported);
this.network.events.on(NetworkEvent.peerConnected, this.onPeerConnected);
this.network.events.on(NetworkEvent.peerDisconnected, this.onPeerDisconnected);
this.subscribedToNetworkEvents = true;
}
}
unsubscribeFromNetwork(): void {
this.logger.verbose("BlockInputSync disabled.");
this.clearRateLimitBackoffTimer();
this.chain.emitter.off(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot);
this.chain.emitter.off(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownEnvelopeBlockRoot);
this.chain.emitter.off(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput);
this.chain.emitter.off(ChainEvent.incompletePayloadEnvelope, this.onIncompletePayloadEnvelope);
this.chain.emitter.off(ChainEvent.blockUnknownParent, this.onUnknownParent);
this.chain.emitter.off(routes.events.EventType.block, this.onBlockImported);
this.chain.emitter.off(routes.events.EventType.executionPayload, this.onPayloadImported);
this.network.events.off(NetworkEvent.peerConnected, this.onPeerConnected);
this.network.events.off(NetworkEvent.peerDisconnected, this.onPeerDisconnected);
this.subscribedToNetworkEvents = false;
}
close(): void {
this.unsubscribeFromNetwork();
}
isSubscribedToNetwork(): boolean {
return this.subscribedToNetworkEvents;
}
/**
* Process an unknownBlock event and register the block in `pendingBlocks` Map.
*/
private onUnknownBlockRoot = (data: ChainEventData[ChainEvent.unknownBlockRoot]): void => {
try {
this.addByRootHex(data.rootHex, data.peer);
this.triggerUnknownBlockSearch();
this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_BLOCK_ROOT});
this.metrics?.blockInputSync.source.inc({source: data.source});
} catch (e) {
this.logger.debug("Error handling unknownBlockRoot event", {}, e as Error);
}
};
/**
* Process an unknownBlockInput event and register the block in `pendingBlocks` Map.
*/
private onIncompleteBlockInput = (data: ChainEventData[ChainEvent.incompleteBlockInput]): void => {
try {
this.addByBlockInput(data.blockInput, data.peer);
this.triggerUnknownBlockSearch();
this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.INCOMPLETE_BLOCK_INPUT});
this.metrics?.blockInputSync.source.inc({source: data.source});
} catch (e) {
this.logger.debug("Error handling incompleteBlockInput event", {}, e as Error);
}
};
private onUnknownEnvelopeBlockRoot = (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]): void => {
try {
this.addByPayloadRootHex(data.rootHex, data.peer);
this.triggerUnknownBlockSearch();
this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_DATA});
this.metrics?.blockInputSync.source.inc({source: data.source});
} catch (e) {
this.logger.debug("Error handling unknownEnvelopeBlockRoot event", {}, e as Error);
}
};
private onIncompletePayloadEnvelope = (data: ChainEventData[ChainEvent.incompletePayloadEnvelope]): void => {
try {
this.addByPayloadInput(data.payloadInput, data.peer);
this.triggerUnknownBlockSearch();
this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_DATA});
this.metrics?.blockInputSync.source.inc({source: data.source});
} catch (e) {
this.logger.debug("Error handling incompletePayloadEnvelope event", {}, e as Error);
}
};
/**
* Process an unknownBlockParent event and register the block in `pendingBlocks` Map.
*/
private onUnknownParent = (data: ChainEventData[ChainEvent.blockUnknownParent]): void => {
try {
const missingDependency = this.getMissingBlockDependency(data.blockInput);
if (missingDependency.kind === "invalidParentPayload") {
this.addByBlockInput(data.blockInput, data.peer);
const pendingBlock = this.pendingBlocks.get(data.blockInput.blockRootHex);
if (pendingBlock && isPendingBlockInput(pendingBlock)) {
this.logger.debug("Ignoring block with conflicting parent payload hash", {
slot: pendingBlock.blockInput.slot,
root: pendingBlock.blockInput.blockRootHex,
parentRoot: missingDependency.parentRootHex,
parentBlockHash: missingDependency.parentBlockHashHex,
});
this.removeAndDownScoreAllDescendants(pendingBlock);
}
return;
}
if (missingDependency.kind === "parentPayload") {
this.addByPayloadRootHex(missingDependency.rootHex, data.peer);
} else if (missingDependency.kind === "parentBlock") {
this.addByRootHex(missingDependency.rootHex, data.peer);
}
this.addByBlockInput(data.blockInput, data.peer);
this.triggerUnknownBlockSearch();
this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_PARENT});
this.metrics?.blockInputSync.source.inc({source: data.source});
} catch (e) {
this.logger.debug("Error handling unknownParent event", {}, e as Error);
}
};
private onBlockImported = (): void => {
if (this.pendingPayloads.size > 0) {
this.triggerUnknownBlockSearch();
}
};
private onPayloadImported = ({
blockRoot,
}: routes.events.EventData[routes.events.EventType.executionPayload]): void => {
this.pendingPayloads.delete(blockRoot);
this.triggerUnknownBlockSearch();
};
private addByRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): boolean => {
let pendingBlock = this.pendingBlocks.get(rootHex);
let added = false;
if (!pendingBlock) {
pendingBlock = {
status: PendingBlockInputStatus.pending,
rootHex: rootHex,
peerIdStrings: new Set(),
timeAddedSec: Date.now() / 1000,
};
this.pendingBlocks.set(rootHex, pendingBlock);
added = true;
this.logger.verbose("Added new rootHex to BlockInputSync.pendingBlocks", {
root: pendingBlock.rootHex,
peerIdStr: peerIdStr ?? "unknown peer",
});
}
if (peerIdStr) {
pendingBlock.peerIdStrings.add(peerIdStr);
}
// TODO: check this prune methodology
// Limit pending blocks to prevent DOS attacks that cause OOM
const prunedItemCount = pruneSetToMax(this.pendingBlocks, this.maxPendingBlocks);
if (prunedItemCount > 0) {
this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingBlocks`);
}
return added;
};
private addByBlockInput = (blockInput: IBlockInput, peerIdStr?: string): void => {
let pendingBlock = this.pendingBlocks.get(blockInput.blockRootHex);
// if entry is missing or was added via rootHex and now we have more complete information overwrite
// the existing information with the more complete cache entry
if (!pendingBlock || !isPendingBlockInput(pendingBlock)) {
pendingBlock = {
// can be added via unknown parent and we may already have full block input. need to check and set correctly
// so we pull the data if its missing or handle the block correctly in getIncompleteAndAncestorBlocks
status: blockInput.hasBlockAndAllData() ? PendingBlockInputStatus.downloaded : PendingBlockInputStatus.pending,
blockInput,
peerIdStrings: new Set(),
timeAddedSec: Date.now() / 1000,
};
this.pendingBlocks.set(blockInput.blockRootHex, pendingBlock);
this.logger.verbose("Added blockInput to BlockInputSync.pendingBlocks", pendingBlock.blockInput.getLogMeta());
}
if (peerIdStr) {
pendingBlock.peerIdStrings.add(peerIdStr);
}
// TODO: check this prune methodology
// Limit pending blocks to prevent DOS attacks that cause OOM
const prunedItemCount = pruneSetToMax(this.pendingBlocks, this.maxPendingBlocks);
if (prunedItemCount > 0) {
this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingBlocks`);
}
};
private addByPayloadRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): boolean => {
let pendingPayload = this.pendingPayloads.get(rootHex);
let added = false;
if (!pendingPayload) {
pendingPayload = {
status: PendingPayloadInputStatus.pending,
rootHex,
peerIdStrings: new Set(),
timeAddedSec: Date.now() / 1000,
};
this.pendingPayloads.set(rootHex, pendingPayload);
added = true;
this.logger.verbose("Added new payload rootHex to BlockInputSync.pendingPayloads", {
root: rootHex,
peerIdStr: peerIdStr ?? "unknown peer",
});
}
if (peerIdStr) {
pendingPayload.peerIdStrings.add(peerIdStr);
}
const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks);
if (prunedItemCount > 0) {
this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`);
}
return added;
};
private addByPayloadInput = (
payloadInput: PayloadEnvelopeInput,
peerIdStr?: PeerIdStr,
envelope?: gloas.SignedExecutionPayloadEnvelope
): void => {
const pendingPayload = this.toPendingPayloadInput(
payloadInput,
this.pendingPayloads.get(payloadInput.blockRootHex),
envelope
);
if (peerIdStr) {
pendingPayload.peerIdStrings.add(peerIdStr);
}
this.pendingPayloads.set(payloadInput.blockRootHex, pendingPayload);
const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks);
if (prunedItemCount > 0) {
this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`);
}
};
private onPeerConnected = (data: NetworkEventData[NetworkEvent.peerConnected]): void => {
try {
const peerId = data.peer;
const peerSyncMeta = this.network.getConnectedPeerSyncMeta(peerId);
this.peerBalancer.onPeerConnected(data.peer, peerSyncMeta);
this.triggerUnknownBlockSearch();
} catch (e) {
this.logger.debug("Error handling peerConnected event", {}, e as Error);
}
};
private onPeerDisconnected = (data: NetworkEventData[NetworkEvent.peerDisconnected]): void => {
const peerId = data.peer;
this.peerBalancer.onPeerDisconnected(peerId);
this.scheduleRateLimitBackoffRetry();
};
/**
* Post-gloas, a locally complete block can still be blocked on its parent's execution payload lineage.
* Distinguish which dependency is missing so the scheduler can enqueue the right follow-up work.
*/
private getMissingBlockDependency(
blockInput: IBlockInput
):
| {kind: "ready"}
| {kind: "block" | "parentBlock" | "parentPayload"; rootHex: RootHex}
| {kind: "invalidParentPayload"; parentRootHex: RootHex; parentBlockHashHex: RootHex} {
const parentRootHex = blockInput.parentRootHex;
if (!this.chain.forkChoice.hasBlockHex(parentRootHex)) {
return {kind: "parentBlock", rootHex: parentRootHex};
}
if (!blockInput.hasBlock()) {
return {kind: "block", rootHex: blockInput.blockRootHex};
}
if (this.config.getForkSeq(blockInput.slot) < ForkSeq.gloas) {
return {kind: "ready"};
}
const block = blockInput.getBlock() as gloas.SignedBeaconBlock;
const parentBlockHashHex = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash);
if (this.chain.forkChoice.getBlockHexAndBlockHash(parentRootHex, parentBlockHashHex) !== null) {
return {kind: "ready"};
}
if (this.chain.forkChoice.hasPayloadHexUnsafe(parentRootHex)) {
return {kind: "invalidParentPayload", parentRootHex, parentBlockHashHex};
}
const parentPayloadInput = this.chain.seenPayloadEnvelopeInputCache.get(parentRootHex);
if (parentPayloadInput) {
if (parentPayloadInput.getBlockHashHex() === parentBlockHashHex) {
return {kind: "parentPayload", rootHex: parentRootHex};
}
return {kind: "invalidParentPayload", parentRootHex, parentBlockHashHex};
}
return {kind: "parentPayload", rootHex: parentRootHex};
}
private advancePendingBlock(pendingBlock: PendingBlockInput): AdvancePendingBlockResult {
const missingDependency = this.getMissingBlockDependency(pendingBlock.blockInput);
switch (missingDependency.kind) {
case "ready":
return "ready";
case "block":
pendingBlock.status = PendingBlockInputStatus.pending;
return "queued_block";
case "parentBlock": {
let added = this.addByRootHex(missingDependency.rootHex);
for (const peerIdStr of pendingBlock.peerIdStrings) {
added = this.addByRootHex(missingDependency.rootHex, peerIdStr) || added;
}
return added ? "queued_parent_block" : "blocked";
}
case "parentPayload": {
let added = this.addByPayloadRootHex(missingDependency.rootHex);
for (const peerIdStr of pendingBlock.peerIdStrings) {
added = this.addByPayloadRootHex(missingDependency.rootHex, peerIdStr) || added;
}
return added ? "queued_parent_payload" : "blocked";
}
case "invalidParentPayload":
this.logger.debug("Removing block with conflicting parent payload hash", {
slot: pendingBlock.blockInput.slot,
root: pendingBlock.blockInput.blockRootHex,
parentRoot: missingDependency.parentRootHex,
parentBlockHash: missingDependency.parentBlockHashHex,
});
this.removeAndDownScoreAllDescendants(pendingBlock);
return "removed";
}
}
private toPendingPayloadInput(
payloadInput: PayloadEnvelopeInput,
previous?: PayloadSyncCacheItem,
envelope?: gloas.SignedExecutionPayloadEnvelope
): PendingPayloadInput {
// Normalize every payload queueing path into the same cache shape while preserving first-seen
// timing and peer provenance from any earlier by-root or envelope-only entry.
const queuedEnvelope = envelope ?? (previous && isPendingPayloadEnvelope(previous) ? previous.envelope : undefined);
if (queuedEnvelope && !payloadInput.hasPayloadEnvelope()) {
payloadInput.addPayloadEnvelope({
envelope: queuedEnvelope,
source: PayloadEnvelopeInputSource.byRoot,
seenTimestampSec: Date.now() / 1000,
});
}
return {
status: payloadInput.isComplete() ? PendingPayloadInputStatus.downloaded : PendingPayloadInputStatus.pending,
payloadInput,
timeAddedSec: previous?.timeAddedSec ?? Date.now() / 1000,
timeSyncedSec: payloadInput.isComplete() ? Date.now() / 1000 : undefined,
peerIdStrings: new Set(previous?.peerIdStrings ?? []),
};
}
/**
* Gather tip parent blocks with unknown parent and do a search for all of them
*/
private triggerUnknownBlockSearch = (): void => {
// Cheap early stop to prevent calling the network.getConnectedPeers()
if (!this.subscribedToNetworkEvents || (this.pendingBlocks.size === 0 && this.pendingPayloads.size === 0)) {
return;
}
// If the node loses all peers with pending unknown blocks or payloads, the sync will stall
const connectedPeers = this.network.getConnectedPeers();
const hasConnectedPeers = connectedPeers.length > 0;
const {unknowns, ancestors} = getUnknownAndAncestorBlocks(this.pendingBlocks);
let processedBlocks = 0;
let shouldRerunBlockSearch = false;
for (const block of ancestors) {
const advanceResult = this.advancePendingBlock(block);
switch (advanceResult) {
case "ready":
processedBlocks++;
this.processReadyBlock(block).catch((e) => {
this.logger.debug("Unexpected error - process old downloaded block", {}, e);
});
break;
case "queued_block":
case "queued_parent_block":
shouldRerunBlockSearch = true;
break;
case "queued_parent_payload":
case "blocked":
case "removed":
break;
}
}
if (unknowns.length > 0) {
if (!hasConnectedPeers) {
this.logger.debug("No connected peers, skipping unknown block download.");
} else {
// Most of the time there is exactly 1 unknown block
for (const block of unknowns) {
this.downloadBlock(block).catch((e) => {
this.logger.debug("Unexpected error - downloadBlock", {root: getBlockInputSyncCacheItemRootHex(block)}, e);
});
}
}
} else if (ancestors.length > 0) {
// It's rare when there is no unknown block
// see https://github.com/ChainSafe/lodestar/issues/5649#issuecomment-1594213550
this.logger.verbose("No unknown block, process ancestor downloaded blocks", {
pendingBlocks: this.pendingBlocks.size,
ancestorBlocks: ancestors.length,
processedBlocks,
});
}
// Blocks can unblock payloads and payloads can unblock blocks, so every scheduler pass services both queues.
for (const payload of Array.from(this.pendingPayloads.values())) {
if (isPendingPayloadInput(payload) && payload.status === PendingPayloadInputStatus.downloaded) {
this.processPayload(payload).catch((e) => {
this.logger.debug("Unexpected error - process downloaded payload", {}, e);
});
continue;
}
if (isPendingPayloadEnvelope(payload)) {
this.reconcilePayloadEnvelope(payload).catch((e) => {
this.logger.debug("Unexpected error - reconcile pending payload envelope", {}, e);
});
continue;
}
if (!hasConnectedPeers) {
this.logger.debug("No connected peers, skipping unknown payload download.", {
root: getPayloadSyncCacheItemRootHex(payload),
});
continue;
}
this.downloadPayload(payload).catch((e) => {
this.logger.debug("Unexpected error - downloadPayload", {root: getPayloadSyncCacheItemRootHex(payload)}, e);
});
}
if (shouldRerunBlockSearch) {
this.triggerUnknownBlockSearch();
}
};
private scheduleRateLimitBackoffRetry(): void {
this.clearRateLimitBackoffTimer();
if (!this.subscribedToNetworkEvents || (this.pendingBlocks.size === 0 && this.pendingPayloads.size === 0)) {
return;
}
const now = Date.now();
const retryAt = this.peerBalancer.getNextRateLimitRetryAt();
if (retryAt === null) {
return;
}
this.rateLimitBackoffTimeout = setTimeout(
() => {
this.rateLimitBackoffTimeout = undefined;
this.triggerUnknownBlockSearch();
this.scheduleRateLimitBackoffRetry();
},
Math.max(0, retryAt - now)
);
}
private clearRateLimitBackoffTimer(): void {
if (this.rateLimitBackoffTimeout !== undefined) {
clearTimeout(this.rateLimitBackoffTimeout);
this.rateLimitBackoffTimeout = undefined;
}
}
private async downloadBlock(block: BlockInputSyncCacheItem): Promise<void> {
if (block.status !== PendingBlockInputStatus.pending) {
return;
}
const rootHex = getBlockInputSyncCacheItemRootHex(block);
const logCtx = {
slot: getBlockInputSyncCacheItemSlot(block),
root: rootHex,
pendingBlocks: this.pendingBlocks.size,
};
this.logger.verbose("BlockInputSync.downloadBlock()", logCtx);
block.status = PendingBlockInputStatus.fetching;
const res = await wrapError(this.fetchBlockInput(block));
if (!res.err) {
this.metrics?.blockInputSync.downloadedBlocksSuccess.inc();
const pending = res.result;
this.pendingBlocks.set(pending.blockInput.blockRootHex, pending);
const blockSlot = pending.blockInput.slot;
const finalizedSlot = this.chain.forkChoice.getFinalizedBlock().slot;
const delaySec = Date.now() / 1000 - computeTimeAtSlot(this.config, blockSlot, this.chain.genesisTime);
this.metrics?.blockInputSync.elapsedTimeTillReceived.observe(delaySec);
const parentInForkChoice = this.chain.forkChoice.hasBlockHex(pending.blockInput.parentRootHex);
const logCtx2 = {
...logCtx,
slot: blockSlot,
parentInForkChoice,
};
this.logger.verbose("Downloaded unknown block", logCtx2);
if (parentInForkChoice) {
// If the direct parent is already in fork choice, let the block state machine decide if
// the next step is block import, parent payload download, or branch removal.
const advanceResult = this.advancePendingBlock(pending);
switch (advanceResult) {
case "ready":
this.processReadyBlock(pending).catch((e) => {
this.logger.debug("Unexpected error - process newly downloaded block", logCtx2, e);
});
break;
case "queued_block":
case "queued_parent_block":
case "queued_parent_payload":
this.triggerUnknownBlockSearch();
break;
case "blocked":
case "removed":
break;
}
} else if (blockSlot <= finalizedSlot) {
// the common ancestor of the downloading chain and canonical chain should be at least the finalized slot and
// we should found it through forkchoice. If not, we should penalize all peers sending us this block chain
// 0 - 1 - ... - n - finalizedSlot
// \
// parent 1 - parent 2 - ... - unknownParent block
this.logger.debug("Downloaded block is before finalized slot", {
...logCtx2,
finalizedSlot,
});
this.removeAndDownScoreAllDescendants(block);
} else {
this.onUnknownBlockRoot({rootHex: pending.blockInput.parentRootHex, source: BlockInputSource.byRoot});
}
} else {
if (res.err instanceof UnknownBlockRateLimitedError) {
const pendingBlock = this.pendingBlocks.get(rootHex);
if (pendingBlock) {
pendingBlock.status = PendingBlockInputStatus.pending;
}
this.logger.debug("Deferring unknown block download due to peer rate limit", logCtx, res.err);
this.scheduleRateLimitBackoffRetry();
return;
}
this.metrics?.blockInputSync.downloadedBlocksError.inc();
this.logger.debug("Ignoring unknown block root after many failed downloads", logCtx, res.err);
this.removeAndDownScoreAllDescendants(block);
}
}
/**
* Import a block that has already passed the local dependency checks in BlockInputSync.
* On error, remove and downscore descendants as appropriate for the failure type.
*/
private async processReadyBlock(pendingBlock: PendingBlockInput): Promise<void> {
if (pendingBlock.status !== PendingBlockInputStatus.downloaded) {
return;
}
pendingBlock.status = PendingBlockInputStatus.processing;
// this prevents unbundling attack
// see https://lighthouse-blog.sigmaprime.io/mev-unbundling-rpc.html
const {slot: blockSlot, proposerIndex} = pendingBlock.blockInput.getBlock().message;
const fork = this.config.getForkName(blockSlot);
const proposerBoostWindowMs = this.config.getAttestationDueMs(fork);
if (
this.chain.clock.msFromSlot(blockSlot) < proposerBoostWindowMs &&
this.chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)
) {
// proposer is known by a gossip block already, wait a bit to make sure this block is not
// eligible for proposer boost to prevent unbundling attack
this.logger.verbose("Avoid proposer boost for this block of known proposer", {
slot: blockSlot,
root: pendingBlock.blockInput.blockRootHex,
proposerIndex,
});
await sleep(proposerBoostWindowMs);
}
// At gossip time, it's critical to keep a good number of mesh peers.
// To do that, the Gossip Job Wait Time should be consistently <3s to avoid the behavior penalties in gossip
// Gossip Job Wait Time depends on the BLS Job Wait Time
// so `blsVerifyOnMainThread = true`: we want to verify signatures immediately without affecting the bls thread pool.
// otherwise we can't utilize bls thread pool capacity and Gossip Job Wait Time can't be kept low consistently.
// See https://github.com/ChainSafe/lodestar/issues/3792
const res = await wrapError(
this.chain.processBlock(pendingBlock.blockInput, {
ignoreIfKnown: true,
// there could be finalized/head sync at the same time so we need to ignore if finalized
// see https://github.com/ChainSafe/lodestar/issues/5650
ignoreIfFinalized: true,
blsVerifyOnMainThread: true,
})
);
if (res.err) this.metrics?.blockInputSync.processedBlocksError.inc();
else this.metrics?.blockInputSync.processedBlocksSuccess.inc();
if (!res.err) {
// no need to update status to "processed", delete anyway
this.pendingBlocks.delete(pendingBlock.blockInput.blockRootHex);
// Re-enter the scheduler so descendants blocked on either parent blocks or parent payloads
// are advanced through the same dependency checks as every other pending item.
this.triggerUnknownBlockSearch();
} else {
const errorData = {slot: pendingBlock.blockInput.slot, root: pendingBlock.blockInput.blockRootHex};
if (res.err instanceof BlockError) {
switch (res.err.type.code) {
// This cases are already handled with `{ignoreIfKnown: true}`
// case BlockErrorCode.ALREADY_KNOWN:
// case BlockErrorCode.GENESIS_BLOCK:
case BlockErrorCode.PARENT_UNKNOWN:
case BlockErrorCode.PRESTATE_MISSING:
// Should not happen, mark as downloaded to try again latter
this.logger.debug("Attempted to process block but its parent was still unknown", errorData, res.err);
pendingBlock.status = PendingBlockInputStatus.downloaded;
break;
case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN:
this.logger.error(
"processReadyBlock() hit unexpected parent payload dependency after readiness checks",
{
...errorData,
parentRoot: pendingBlock.blockInput.parentRootHex,
parentBlockHash: res.err.type.parentBlockHash,
},
res.err
);
pendingBlock.status = PendingBlockInputStatus.downloaded;
break;
case BlockErrorCode.EXECUTION_ENGINE_ERROR:
// Removing the block(s) without penalizing the peers, hoping for EL to
// recover on a latter download + verify attempt
this.removeAllDescendants(pendingBlock);
break;
default:
// Block is not correct with respect to our chain. Log error loudly
this.logger.debug("Error processing block from unknown parent sync", errorData, res.err);
this.removeAndDownScoreAllDescendants(pendingBlock);
}
}
// Probably a queue error or something unwanted happened, mark as pending to try again latter
else {
this.logger.debug("Unknown error processing block from unknown block sync", errorData, res.err);
pendingBlock.status = PendingBlockInputStatus.downloaded;
}
}
}
/**
* Reconcile an envelope-first payload entry once the block import path has seeded its
* PayloadEnvelopeInput. This may queue block download, validate the speculative envelope, or
* downgrade back to by-root fetching when the cached envelope does not match the imported block.
*/
private async reconcilePayloadEnvelope(pendingPayload: PendingPayloadEnvelope): Promise<void> {
const rootHex = getPayloadSyncCacheItemRootHex(pendingPayload);
if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) {
this.pendingPayloads.delete(rootHex);
return;
}
const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(rootHex);
if (!payloadInput) {
if (!this.chain.forkChoice.hasBlockHex(rootHex)) {
// Column commitments live on the block body, so an envelope-only entry has to pull the block first.
if (!this.pendingBlocks.has(rootHex)) {
this.addByRootHex(rootHex);
}
const pendingBlock = this.pendingBlocks.get(rootHex);
if (pendingBlock && this.network.getConnectedPeers().length > 0) {
await this.downloadBlock(pendingBlock);
}
} else {
this.logger.debug("Missing PayloadEnvelopeInput for known block while reconciling payload envelope", {
root: rootHex,
});
}
return;
}
if (!payloadInput.hasPayloadEnvelope()) {
const validationResult = await wrapError(
validateGossipExecutionPayloadEnvelope(this.chain, pendingPayload.envelope)
);
if (validationResult.err) {
this.logger.debug(
"Pending payload envelope failed validation after block import, refetching by root",
{slot: pendingPayload.envelope.message.payload.slotNumber, root: rootHex},
validationResult.err
);
const pendingPayloadByRoot: PendingPayloadRootHex = {
status: PendingPayloadInputStatus.pending,
rootHex,
timeAddedSec: pendingPayload.timeAddedSec,
peerIdStrings: new Set(pendingPayload.peerIdStrings),
};
this.pendingPayloads.set(rootHex, pendingPayloadByRoot);
if (this.network.getConnectedPeers().length > 0) {
await this.downloadPayload(pendingPayloadByRoot);
}
return;
}
}
const upgradedPayload = this.toPendingPayloadInput(payloadInput, pendingPayload, pendingPayload.envelope);
this.pendingPayloads.set(rootHex, upgradedPayload);
if (upgradedPayload.status === PendingPayloadInputStatus.downloaded) {
await this.processPayload(upgradedPayload);
return;
}
await this.downloadPayload(upgradedPayload);
}
private async downloadPayload(payload: PayloadSyncCacheItem): Promise<void> {
if (isPendingPayloadEnvelope(payload)) {
await this.reconcilePayloadEnvelope(payload);
return;
}
const rootHex = getPayloadSyncCacheItemRootHex(payload);
if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) {
this.pendingPayloads.delete(rootHex);
return;
}
if (payload.status !== PendingPayloadInputStatus.pending) {
return;
}
const logCtx = {
slot: getPayloadSyncCacheItemSlot(payload),
root: rootHex,
pendingPayloads: this.pendingPayloads.size,
};
this.logger.verbose("BlockInputSync.downloadPayload()", logCtx);
payload.status = PendingPayloadInputStatus.fetching;
const res = await wrapError(this.fetchPayloadInput(payload));
if (!res.err) {
const pendingPayload = res.result;
this.pendingPayloads.set(getPayloadSyncCacheItemRootHex(pendingPayload), pendingPayload);
if (isPendingPayloadEnvelope(pendingPayload)) {
await this.reconcilePayloadEnvelope(pendingPayload);
} else if (pendingPayload.status === PendingPayloadInputStatus.downloaded) {
await this.processPayload(pendingPayload);
}
return;
}
this.logger.debug("Ignoring unknown payload root after failed download", logCtx, res.err);
if (!isPendingPayloadEnvelope(payload)) {
payload.status = PendingPayloadInputStatus.pending;
}
}
private async processPayload(pendingPayload: PendingPayloadInput): Promise<void> {
const rootHex = pendingPayload.payloadInput.blockRootHex;
const logCtx = {slot: pendingPayload.payloadInput.slot, root: rootHex};
if (pendingPayload.status !== PendingPayloadInputStatus.downloaded) {
this.logger.debug("Skipping payload processing before payload input is downloaded", {
...logCtx,
status: pendingPayload.status,
});
return;
}
if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) {
this.logger.debug("Payload already imported while processing unknown payload", logCtx);
this.pendingPayloads.delete(rootHex);
return;
}
if (!this.chain.forkChoice.hasBlockHex(rootHex)) {
this.logger.debug("Payload input is ready before its block is in fork choice", logCtx);
const added = this.addByRootHex(rootHex);
pendingPayload.status = PendingPayloadInputStatus.downloaded;
if (added) {
this.triggerUnknownBlockSearch();
}
return;
}
pendingPayload.status = PendingPayloadInputStatus.processing;
const res = await wrapError(this.chain.processExecutionPayload(pendingPayload.payloadInput));
if (!res.err) {
this.logger.debug("Processed payload from unknown sync", logCtx);
this.pendingPayloads.delete(rootHex);
this.triggerUnknownBlockSearch();
return;
}
if (res.err instanceof PayloadError) {
switch (res.err.type.code) {
case PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE:
// Payload sync discovered the block dependency before the block queue did. Re-enqueue the
// block and keep the payload ready so the scheduler can retry once the block reaches fork choice.
if (this.addByRootHex(rootHex)) {
this.triggerUnknownBlockSearch();
}
// Keep the payload out of any synchronous requeue pass; a later scheduler pass will retry it.
pendingPayload.status = PendingPayloadInputStatus.downloaded;
break;
case PayloadErrorCode.EXECUTION_ENGINE_ERROR:
this.logger.debug("Execution engine error while processing payload from unknown sync", logCtx, res.err);
pendingPayload.status = PendingPayloadInputStatus.downloaded;
break;
case PayloadErrorCode.EXECUTION_ENGINE_INVALID:
case PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR:
case PayloadErrorCode.INVALID_SIGNATURE:
// TODO GLOAS: Decide how invalid payload inputs should eventually leave memory without
// reintroducing envelope replacement / recreation flows.
this.logger.debug("Error processing payload from unknown sync", logCtx, res.err);
this.removePendingPayloadAndDescendants(rootHex);
break;
default:
this.logger.debug("Error processing payload from unknown sync", logCtx, res.err);
this.pendingPayloads.delete(rootHex);
}
return;
}
this.logger.debug("Unknown error processing payload from unknown sync", logCtx, res.err);
pendingPayload.status = PendingPayloadInputStatus.downloaded;
}
/**
* Download payload material keyed by beacon block root. Unlike block download, payload sync may
* already have a locally cached envelope or partial columns, so each attempt starts from local state
* and only asks peers for the remaining pieces.
*/
private async fetchPayloadInput(
cacheItem: PendingPayloadInput | PendingPayloadRootHex
): Promise<PendingPayloadInput | PendingPayloadEnvelope> {
const rootHex = getPayloadSyncCacheItemRootHex(cacheItem);
const blockRoot = fromHex(rootHex);
const excludedPeers = new Set<PeerIdStr>();
let slot = getPayloadSyncCacheItemSlot(cacheItem);
let payloadInput = isPendingPayloadInput(cacheItem)
? cacheItem.payloadInput
: this.chain.seenPayloadEnvelopeInputCache.get(rootHex);
let envelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : undefined;
let i = 0;
let deferredByRateLimit = false;
while (i++ < this.getMaxDownloadAttempts()) {
const pendingColumns = payloadInput?.hasAllData()
? new Set<number>()
: new Set(payloadInput?.getMissingSampledColumnMeta().missing ?? []);
const peerMeta = this.peerBalancer.bestPeerForPendingColumns(pendingColumns, excludedPeers);
if (peerMeta === null) {
if (this.peerBalancer.getNextRateLimitRetryAt(pendingColumns, excludedPeers) !== null) {
throw new UnknownBlockRateLimitedError(
`Error fetching payload by root slot=${slot} root=${rootHex} after ${i}: peers with needed columns are rate-limited`
);
}
throw Error(
`Error fetching payload by root slot=${slot} root=${rootHex} after ${i}: cannot find peer with needed columns=${prettyPrintIndices(Array.from(pendingColumns))}`
);
}
const {peerId, client: peerClient} = peerMeta;
cacheItem.peerIdStrings.add(peerId);
try {
if (!envelope) {
envelope = await this.fetchExecutionPayloadEnvelope(peerId, blockRoot, rootHex);
slot = envelope.message.payload.slotNumber;
}
payloadInput ??= this.chain.seenPayloadEnvelopeInputCache.get(rootHex);
if (!payloadInput) {
if (this.chain.forkChoice.hasBlockHex(rootHex)) {
throw new Error(`Missing PayloadEnvelopeInput for known block ${rootHex}`);
}
// Keep the validated envelope around, but wait for the block body before turning it into a full payload input.
return {
status: PendingPayloadInputStatus.waitingForBlock,
envelope,
timeAddedSec: cacheItem.timeAddedSec,
peerIdStrings: cacheItem.peerIdStrings,
};
}
if (!payloadInput.hasPayloadEnvelope()) {
await validateGossipExecutionPayloadEnvelope(this.chain, envelope);
}
let pendingPayload = this.toPendingPayloadInput(payloadInput, cacheItem, envelope);
if (!pendingPayload.payloadInput.hasAllData()) {
const missing = pendingPayload.payloadInput.getMissingSampledColumnMeta().missing;
if (missing.length > 0) {
const columnSidecars = await this.fetchPayloadColumns(peerMeta, pendingPayload.payloadInput, missing);
const seenTimestampSec = Date.now() / 1000;
for (const columnSidecar of columnSidecars) {
if (pendingPayload.payloadInput.hasColumn(columnSidecar.index)) {
continue;
}
pendingPayload.payloadInput.addColumn({
columnSidecar,
source: PayloadEnvelopeInputSource.byRoot,
seenTimestampSec,
peerIdStr: peerId,
});
}
pendingPayload = this.toPendingPayloadInput(pendingPayload.payloadInput, pendingPayload);
}
}
this.logger.verbose("BlockInputSync.fetchPayloadInput: successful download", {
slot,
rootHex,
peerId,
peerClient,
hasPayload: pendingPayload.payloadInput.hasPayloadEnvelope(),
hasAllData: pendingPayload.payloadInput.hasAllData(),
});
if (pendingPayload.status === PendingPayloadInputStatus.downloaded) {
return pendingPayload;
}
cacheItem = pendingPayload;
payloadInput = pendingPayload.payloadInput;
} catch (e) {
this.logger.debug(
"Error downloading payload in BlockInputSync.fetchPayloadInput",
{slot, rootHex, attempt: i, peer: peerId, peerClient},
e as Error
);
const rateLimitedUntilMs = getRateLimitedUntilMs(e);
if (rateLimitedUntilMs !== null) {
deferredByRateLimit = true;
this.peerBalancer.onRateLimited(peerId, rateLimitedUntilMs);
this.scheduleRateLimitBackoffRetry();
} else if (e instanceof RequestError) {
switch (e.type.code) {
case RequestErrorCode.REQUEST_RATE_LIMITED:
case RequestErrorCode.REQUEST_TIMEOUT:
break;
default:
excludedPeers.add(peerId);
break;
}
} else {
excludedPeers.add(peerId);
}
} finally {
this.peerBalancer.onRequestCompleted(peerId);
}
}
if (deferredByRateLimit && this.peerBalancer.getNextRateLimitRetryAt() !== null) {
throw new UnknownBlockRateLimitedError(
`Error fetching payload with slot=${slot} root=${rootHex} after ${i - 1} attempts: peers are rate-limited`
);
}
throw Error(`Error fetching payload with slot=${slot} root=${rootHex} after ${i - 1} attempts.`);
}
private async fetchExecutionPayloadEnvelope(
peerIdStr: PeerIdStr,
blockRoot: Uint8Array,
rootHex: RootHex
): Promise<gloas.SignedExecutionPayloadEnvelope> {
const response = await this.network.sendExecutionPayloadEnvelopesByRoot(peerIdStr, [blockRoot]);
const envelope = response.at(0);
if (!envelope) {
throw new Error(`Missing execution payload envelope for root=${rootHex}`);
}
const receivedRootHex = toRootHex(envelope.message.beaconBlockRoot);
if (receivedRootHex !== rootHex) {
throw new Error(`Execution payload envelope root mismatch requested=${rootHex} received=${receivedRootHex}`);
}
return envelope;
}
private async fetchPayloadColumns(
peerMeta: PeerSyncMeta,
payloadInput: PayloadEnvelopeInput,
missing: number[]
): Promise<gloas.DataColumnSidecar[]> {
const {peerId: peerIdStr} = peerMeta;
const peerColumns = new Set(peerMeta.custodyColumns ?? []);
const requestedColumns = missing.filter((columnIndex) => peerColumns.has(columnIndex));
if (requestedColumns.length === 0) {
return [];
}
const columnSidecars = (await this.network.sendDataColumnSidecarsByRoot(peerIdStr, [
{blockRoot: fromHex(payloadInput.blockRootHex), columns: requestedColumns},
])) as gloas.DataColumnSidecar[];
if (columnSidecars.length === 0) {
throw new Error(`No data column sidecars returned for payload root=${payloadInput.blockRootHex}`);
}
const requestedColumnsSet = new Set(requestedColumns);
const extraColumns = columnSidecars.filter((columnSidecar) => !requestedColumnsSet.has(columnSidecar.index));
if (extraColumns.length > 0) {
throw new Error(
`Received unexpected payload data columns indices=${prettyPrintIndices(extraColumns.map((column) => column.index))}`
);
}
// PayloadEnvelopeInput already carries the block slot, root, and commitments, so reuse the
// block-based Gloas validator rather than maintaining a second payload-specific variant.
await validateGloasBlockDataColumnSidecars(
payloadInput.slot,
fromHex(payloadInput.blockRootHex),
payloadInput.getBlobKzgCommitments(),
columnSidecars,
this.chain.metrics?.peerDas
);
return columnSidecars;
}
/**
* From a set of shuffled peers:
* - fetch the block
* - from deneb, fetch all missing blobs
* - from peerDAS, fetch sampled co