UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

527 lines • 30.2 kB
import { routes } from "@lodestar/api"; import { ForkSeq, isForkPostElectra } from "@lodestar/params"; import { computeTimeAtSlot } from "@lodestar/state-transition"; import { ssz, sszTypesFor, } from "@lodestar/types"; import { LogLevel, prettyBytes, toHex, toRootHex } from "@lodestar/utils"; import { BlobSidecarValidation, BlockInputType, GossipedInputType, } from "../../chain/blocks/types.js"; import { AttestationError, AttestationErrorCode, BlobSidecarErrorCode, BlobSidecarGossipError, BlockError, BlockErrorCode, BlockGossipError, GossipAction, GossipActionError, SyncCommitteeError, } from "../../chain/errors/index.js"; import { validateGossipBlobSidecar } from "../../chain/validation/blobSidecar.js"; import { toElectraSingleAttestation, validateGossipAggregateAndProof, validateGossipAttestationsSameAttData, validateGossipAttesterSlashing, validateGossipBlock, validateGossipBlsToExecutionChange, validateGossipProposerSlashing, validateGossipSyncCommittee, validateGossipVoluntaryExit, validateSyncCommitteeGossipContributionAndProof, } from "../../chain/validation/index.js"; import { validateLightClientFinalityUpdate } from "../../chain/validation/lightClientFinalityUpdate.js"; import { validateLightClientOptimisticUpdate } from "../../chain/validation/lightClientOptimisticUpdate.js"; import { OpSource } from "../../chain/validatorMonitor.js"; import { kzgCommitmentToVersionedHash } from "../../util/blobs.js"; import { NetworkEvent } from "../events.js"; import { GossipType, } from "../gossip/interface.js"; import { sszDeserialize } from "../gossip/topic.js"; import { PeerAction } from "../peers/index.js"; const MAX_UNKNOWN_BLOCK_ROOT_RETRIES = 1; const BLOCK_AVAILABILITY_CUTOFF_MS = 3_000; /** * Gossip handlers perform validation + handling in a single function. * - This gossip handlers MUST only be registered as validator functions. No handler is registered for any topic. * - All `chain/validation/*` functions MUST throw typed GossipActionError instances so they gossip action is captured * by `getGossipValidatorFn()` try catch block. * - This gossip handlers should not let any handling errors propagate to the caller. Only validation errors must be thrown. * * Note: `libp2p/js-libp2p-interfaces` would normally indicate to register separate validator functions and handler functions. * This approach is not suitable for us because: * - We do expensive processing on the object in the validator function that we need to re-use in the handler function. * - The validator function produces extra data that is needed for the handler function. Making this data available in * the handler function scope is hard to achieve without very hacky strategies * - Ethereum Consensus gossipsub protocol strictly defined a single topic for message */ export function getGossipHandlers(modules, options) { return { ...getSequentialHandlers(modules, options), ...getBatchHandlers(modules, options) }; } /** * Default handlers validate gossip messages one by one. * We only have a choice to do batch validation for beacon_attestation topic. */ function getSequentialHandlers(modules, options) { const { chain, config, metrics, events, logger, core } = modules; async function validateBeaconBlock(signedBlock, fork, peerIdStr, seenTimestampSec) { const slot = signedBlock.message.slot; const forkTypes = config.getForkTypes(slot); const blockRootHex = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); const blockShortHex = prettyBytes(blockRootHex); const delaySec = chain.clock.secFromSlot(slot, seenTimestampSec); const recvToValLatency = Date.now() / 1000 - seenTimestampSec; // always set block to seen cache for all forks so that we don't need to download it const blockInputRes = chain.seenGossipBlockInput.getGossipBlockInput(config, { type: GossipedInputType.block, signedBlock, }, metrics); const blockInput = blockInputRes.blockInput; // blockInput can't be returned null, improve by enforcing via return types if (blockInput.block === null) { throw Error(`Invalid null blockInput returned by getGossipBlockInput for type=${GossipedInputType.block} blockHex=${blockShortHex} slot=${slot}`); } const blockInputMeta = config.getForkSeq(signedBlock.message.slot) >= ForkSeq.deneb ? blockInputRes.blockInputMeta : {}; const logCtx = { slot: slot, root: blockShortHex, currentSlot: chain.clock.currentSlot, peerId: peerIdStr, delaySec, ...blockInputMeta, recvToValLatency, }; logger.debug("Received gossip block", { ...logCtx }); try { await validateGossipBlock(config, chain, signedBlock, fork); const recvToValidation = Date.now() / 1000 - seenTimestampSec; const validationTime = recvToValidation - recvToValLatency; metrics?.gossipBlock.gossipValidation.recvToValidation.observe(recvToValidation); metrics?.gossipBlock.gossipValidation.validationTime.observe(validationTime); logger.debug("Validated gossip block", { ...logCtx, recvToValidation, validationTime }); if (chain.emitter.listenerCount(routes.events.EventType.blockGossip)) { chain.emitter.emit(routes.events.EventType.blockGossip, { slot, block: blockRootHex }); } return blockInput; } catch (e) { if (e instanceof BlockGossipError) { // Don't trigger this yet if full block and blobs haven't arrived yet if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput !== null) { logger.debug("Gossip block has error", { slot, root: blockShortHex, code: e.type.code }); events.emit(NetworkEvent.unknownBlockParent, { blockInput, peer: peerIdStr }); } if (e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, `gossip_reject_slot_${slot}`); } } throw e; } } async function validateBeaconBlob(blobSidecar, subnet, peerIdStr, seenTimestampSec) { const blobBlockHeader = blobSidecar.signedBlockHeader.message; const slot = blobBlockHeader.slot; const fork = config.getForkName(slot); const blockRootHex = toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(blobBlockHeader)); const blockShortHex = prettyBytes(blockRootHex); const delaySec = chain.clock.secFromSlot(slot, seenTimestampSec); const recvToValLatency = Date.now() / 1000 - seenTimestampSec; const { blockInput, blockInputMeta } = chain.seenGossipBlockInput.getGossipBlockInput(config, { type: GossipedInputType.blob, blobSidecar, }, metrics); try { await validateGossipBlobSidecar(fork, chain, blobSidecar, subnet); const recvToValidation = Date.now() / 1000 - seenTimestampSec; const validationTime = recvToValidation - recvToValLatency; metrics?.gossipBlob.recvToValidation.observe(recvToValidation); metrics?.gossipBlob.validationTime.observe(validationTime); if (chain.emitter.listenerCount(routes.events.EventType.blobSidecar)) { chain.emitter.emit(routes.events.EventType.blobSidecar, { blockRoot: blockRootHex, slot, index: blobSidecar.index, kzgCommitment: toHex(blobSidecar.kzgCommitment), versionedHash: toHex(kzgCommitmentToVersionedHash(blobSidecar.kzgCommitment)), }); } logger.debug("Received gossip blob", { slot: slot, root: blockShortHex, currentSlot: chain.clock.currentSlot, peerId: peerIdStr, delaySec, subnet, ...blockInputMeta, recvToValLatency, recvToValidation, validationTime, }); return blockInput; } catch (e) { if (e instanceof BlobSidecarGossipError) { // Don't trigger this yet if full block and blobs haven't arrived yet if (e.type.code === BlobSidecarErrorCode.PARENT_UNKNOWN && blockInput.block !== null) { logger.debug("Gossip blob has error", { slot, root: blockShortHex, code: e.type.code }); events.emit(NetworkEvent.unknownBlockParent, { blockInput, peer: peerIdStr }); } if (e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(ssz.deneb.BlobSidecar, blobSidecar, `gossip_reject_slot_${slot}_index_${blobSidecar.index}`); } } throw e; } } function handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec) { const signedBlock = blockInput.block; // Handler - MUST NOT `await`, to allow validation result to be propagated const delaySec = seenTimestampSec - (chain.genesisTime + signedBlock.message.slot * config.SECONDS_PER_SLOT); metrics?.gossipBlock.elapsedTimeTillReceived.observe({ source: OpSource.gossip }, delaySec); chain.validatorMonitor?.registerBeaconBlock(OpSource.gossip, delaySec, signedBlock.message); // if blobs are not yet fully available start an aggressive blob pull if (blockInput.type === BlockInputType.dataPromise) { events.emit(NetworkEvent.unknownBlockInput, { blockInput, peer: peerIdStr }); } else if (blockInput.type === BlockInputType.availableData) { metrics?.blockInputFetchStats.totalDataAvailableBlockInputs.inc(); metrics?.blockInputFetchStats.totalDataAvailableBlockInputBlobs.inc(blockInput.blockData.blobs.length); } chain .processBlock(blockInput, { // block may be downloaded and processed by UnknownBlockSync ignoreIfKnown: true, // proposer signature already checked in validateBeaconBlock() validProposerSignature: true, // blobSidecars already checked in validateGossipBlobSidecars() validBlobSidecars: BlobSidecarValidation.Individual, // 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 blsVerifyOnMainThread: true, // to track block process steps seenTimestampSec, // gossip block is validated, we want to process it asap eagerPersistBlock: true, isGossipBlock: true, }) .then(() => { // Returns the delay between the start of `block.slot` and `current time` const delaySec = chain.clock.secFromSlot(signedBlock.message.slot); metrics?.gossipBlock.elapsedTimeTillProcessed.observe(delaySec); chain.seenGossipBlockInput.prune(); }) .catch((e) => { // Adjust verbosity based on error type let logLevel; if (e instanceof BlockError) { switch (e.type.code) { case BlockErrorCode.DATA_UNAVAILABLE: { const slot = signedBlock.message.slot; const forkTypes = config.getForkTypes(slot); const rootHex = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); events.emit(NetworkEvent.unknownBlock, { rootHex, peer: peerIdStr }); // Error is quite frequent and not critical logLevel = LogLevel.debug; break; } // ALREADY_KNOWN should not happen with ignoreIfKnown=true above // PARENT_UNKNOWN should not happen, we handled this in validateBeaconBlock() function above case BlockErrorCode.ALREADY_KNOWN: case BlockErrorCode.PARENT_UNKNOWN: case BlockErrorCode.PRESTATE_MISSING: case BlockErrorCode.EXECUTION_ENGINE_ERROR: // Errors might indicate an issue with our node or the connected EL client logLevel = LogLevel.error; break; default: // TODO: Should it use PeerId or string? core.reportPeer(peerIdStr, PeerAction.LowToleranceError, "BadGossipBlock"); // Misbehaving peer, but could highlight an issue in another client logLevel = LogLevel.warn; } } else { // Any unexpected error logLevel = LogLevel.error; } metrics?.gossipBlock.processBlockErrors.inc({ error: e instanceof BlockError ? e.type.code : "NOT_BLOCK_ERROR" }); logger[logLevel]("Error receiving block", { slot: signedBlock.message.slot, peer: peerIdStr }, e); chain.seenGossipBlockInput.prune(); }); } return { [GossipType.beacon_block]: async ({ gossipData, topic, peerIdStr, seenTimestampSec, }) => { const { serializedData } = gossipData; const signedBlock = sszDeserialize(topic, serializedData); const blockInput = await validateBeaconBlock(signedBlock, topic.boundary.fork, peerIdStr, seenTimestampSec); chain.serializedCache.set(signedBlock, serializedData); handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); }, [GossipType.blob_sidecar]: async ({ gossipData, topic, peerIdStr, seenTimestampSec, }) => { const { serializedData } = gossipData; const blobSidecar = sszDeserialize(topic, serializedData); const blobSlot = blobSidecar.signedBlockHeader.message.slot; const index = blobSidecar.index; if (config.getForkSeq(blobSlot) < ForkSeq.deneb) { throw new GossipActionError(GossipAction.REJECT, { code: "PRE_DENEB_BLOCK" }); } const blockInput = await validateBeaconBlob(blobSidecar, topic.subnet, peerIdStr, seenTimestampSec); if (blockInput.block !== null) { // we can just queue up the blockInput in the processor, but block gossip handler would have already // queued it up. // // handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); } else { // wait for the block to arrive till some cutoff else emit unknownBlockInput event chain.logger.debug("Block not yet available, racing with cutoff", { blobSlot, index }); const normalBlockInput = await raceWithCutoff(chain, blobSlot, blockInput.blockInputPromise, BLOCK_AVAILABILITY_CUTOFF_MS).catch((_e) => { return null; }); if (normalBlockInput !== null) { chain.logger.debug("Block corresponding to blob is now available for processing", { blobSlot, index }); // we can directly send it for processing but block gossip handler will queue it up anyway // if we see any issues later, we can send it to handleValidBeaconBlock // // handleValidBeaconBlock(normalBlockInput, peerIdStr, seenTimestampSec); // // however we can emit the event which will atleast add the peer to the list of peers to pull // data from if (normalBlockInput.type === BlockInputType.dataPromise) { events.emit(NetworkEvent.unknownBlockInput, { blockInput: normalBlockInput, peer: peerIdStr }); } } else { chain.logger.debug("Block not available till BLOCK_AVAILABILITY_CUTOFF_MS", { blobSlot, index }); events.emit(NetworkEvent.unknownBlockInput, { blockInput, peer: peerIdStr }); } } }, [GossipType.beacon_aggregate_and_proof]: async ({ gossipData, topic, seenTimestampSec, }) => { const { serializedData } = gossipData; let validationResult; const signedAggregateAndProof = sszDeserialize(topic, serializedData); const { fork } = topic.boundary; try { validationResult = await validateGossipAggregateAndProof(fork, chain, signedAggregateAndProof, serializedData); } catch (e) { if (e instanceof AttestationError && e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(sszTypesFor(fork).SignedAggregateAndProof, signedAggregateAndProof, "gossip_reject"); } throw e; } // Handler const { indexedAttestation, committeeIndices, attDataRootHex } = validationResult; chain.validatorMonitor?.registerGossipAggregatedAttestation(seenTimestampSec, signedAggregateAndProof, indexedAttestation); const aggregatedAttestation = signedAggregateAndProof.message.aggregate; const insertOutcome = chain.aggregatedAttestationPool.add(aggregatedAttestation, attDataRootHex, indexedAttestation.attestingIndices.length, committeeIndices); metrics?.opPool.aggregatedAttestationPool.gossipInsertOutcome.inc({ insertOutcome }); if (!options.dontSendGossipAttestationsToForkchoice) { try { chain.forkChoice.onAttestation(indexedAttestation, attDataRootHex); } catch (e) { logger.debug("Error adding gossip aggregated attestation to forkchoice", { slot: aggregatedAttestation.data.slot }, e); } } chain.emitter.emit(routes.events.EventType.attestation, signedAggregateAndProof.message.aggregate); }, [GossipType.attester_slashing]: async ({ gossipData, topic, }) => { const { serializedData } = gossipData; const { fork } = topic.boundary; const attesterSlashing = sszDeserialize(topic, serializedData); await validateGossipAttesterSlashing(chain, attesterSlashing); // Handler try { chain.opPool.insertAttesterSlashing(fork, attesterSlashing); chain.forkChoice.onAttesterSlashing(attesterSlashing); } catch (e) { logger.error("Error adding attesterSlashing to pool", {}, e); } chain.emitter.emit(routes.events.EventType.attesterSlashing, attesterSlashing); }, [GossipType.proposer_slashing]: async ({ gossipData, topic, }) => { const { serializedData } = gossipData; const proposerSlashing = sszDeserialize(topic, serializedData); await validateGossipProposerSlashing(chain, proposerSlashing); // Handler try { chain.opPool.insertProposerSlashing(proposerSlashing); } catch (e) { logger.error("Error adding attesterSlashing to pool", {}, e); } chain.emitter.emit(routes.events.EventType.proposerSlashing, proposerSlashing); }, [GossipType.voluntary_exit]: async ({ gossipData, topic }) => { const { serializedData } = gossipData; const voluntaryExit = sszDeserialize(topic, serializedData); await validateGossipVoluntaryExit(chain, voluntaryExit); // Handler try { chain.opPool.insertVoluntaryExit(voluntaryExit); } catch (e) { logger.error("Error adding voluntaryExit to pool", {}, e); } chain.emitter.emit(routes.events.EventType.voluntaryExit, voluntaryExit); }, [GossipType.sync_committee_contribution_and_proof]: async ({ gossipData, topic, }) => { const { serializedData } = gossipData; const contributionAndProof = sszDeserialize(topic, serializedData); const { syncCommitteeParticipantIndices } = await validateSyncCommitteeGossipContributionAndProof(chain, contributionAndProof).catch((e) => { if (e instanceof SyncCommitteeError && e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(ssz.altair.SignedContributionAndProof, contributionAndProof, "gossip_reject"); } throw e; }); // Handler chain.validatorMonitor?.registerGossipSyncContributionAndProof(contributionAndProof.message, syncCommitteeParticipantIndices); try { const insertOutcome = chain.syncContributionAndProofPool.add(contributionAndProof.message, syncCommitteeParticipantIndices.length); metrics?.opPool.syncContributionAndProofPool.gossipInsertOutcome.inc({ insertOutcome }); } catch (e) { logger.error("Error adding to contributionAndProof pool", {}, e); } chain.emitter.emit(routes.events.EventType.contributionAndProof, contributionAndProof); }, [GossipType.sync_committee]: async ({ gossipData, topic }) => { const { serializedData } = gossipData; const syncCommittee = sszDeserialize(topic, serializedData); const { subnet } = topic; let indexInSubcommittee = 0; try { indexInSubcommittee = (await validateGossipSyncCommittee(chain, syncCommittee, subnet)).indexInSubcommittee; } catch (e) { if (e instanceof SyncCommitteeError && e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(ssz.altair.SyncCommitteeMessage, syncCommittee, "gossip_reject"); } throw e; } // Handler try { const insertOutcome = chain.syncCommitteeMessagePool.add(subnet, syncCommittee, indexInSubcommittee); metrics?.opPool.syncCommitteeMessagePoolInsertOutcome.inc({ insertOutcome }); } catch (e) { logger.debug("Error adding to syncCommittee pool", { subnet }, e); } }, [GossipType.light_client_finality_update]: async ({ gossipData, topic, }) => { const { serializedData } = gossipData; const lightClientFinalityUpdate = sszDeserialize(topic, serializedData); validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); }, [GossipType.light_client_optimistic_update]: async ({ gossipData, topic, }) => { const { serializedData } = gossipData; const lightClientOptimisticUpdate = sszDeserialize(topic, serializedData); validateLightClientOptimisticUpdate(config, chain, lightClientOptimisticUpdate); }, // blsToExecutionChange is to be generated and validated against GENESIS_FORK_VERSION [GossipType.bls_to_execution_change]: async ({ gossipData, topic, }) => { const { serializedData } = gossipData; const blsToExecutionChange = sszDeserialize(topic, serializedData); await validateGossipBlsToExecutionChange(chain, blsToExecutionChange); // Handler try { chain.opPool.insertBlsToExecutionChange(blsToExecutionChange); } catch (e) { logger.error("Error adding blsToExecutionChange to pool", {}, e); } chain.emitter.emit(routes.events.EventType.blsToExecutionChange, blsToExecutionChange); }, }; } /** * For now, only beacon_attestation topic is batched. */ function getBatchHandlers(modules, options) { const { chain, metrics, logger, aggregatorTracker } = modules; return { [GossipType.beacon_attestation]: async (gossipHandlerParams) => { const results = []; const attestationCount = gossipHandlerParams.length; if (attestationCount === 0) { return results; } // all attestations should have same attestation data as filtered by network processor const { fork } = gossipHandlerParams[0].topic.boundary; const validationParams = gossipHandlerParams.map((param) => ({ attestation: null, serializedData: param.gossipData.serializedData, attSlot: param.gossipData.msgSlot, attDataBase64: param.gossipData.indexed, subnet: param.topic.subnet, })); const { results: validationResults, batchableBls } = await validateGossipAttestationsSameAttData(fork, chain, validationParams); for (const [i, validationResult] of validationResults.entries()) { if (validationResult.err) { results.push(validationResult.err); continue; } // null means no error results.push(null); // Handler const { indexedAttestation, attDataRootHex, attestation, committeeIndex, committeeValidatorIndex, committeeSize, } = validationResult.result; chain.validatorMonitor?.registerGossipUnaggregatedAttestation(gossipHandlerParams[i].seenTimestampSec, indexedAttestation); const { subnet } = validationResult.result; try { // Node may be subscribe to extra subnets (long-lived random subnets). For those, validate the messages // but don't add to attestation pool, to save CPU and RAM if (aggregatorTracker.shouldAggregate(subnet, indexedAttestation.data.slot)) { const insertOutcome = chain.attestationPool.add(committeeIndex, attestation, attDataRootHex, committeeValidatorIndex, committeeSize); metrics?.opPool.attestationPool.gossipInsertOutcome.inc({ insertOutcome }); } } catch (e) { logger.error("Error adding unaggregated attestation to pool", { subnet }, e); } if (!options.dontSendGossipAttestationsToForkchoice) { try { chain.forkChoice.onAttestation(indexedAttestation, attDataRootHex); } catch (e) { logger.debug("Error adding gossip unaggregated attestation to forkchoice", { subnet }, e); } } if (isForkPostElectra(fork)) { chain.emitter.emit(routes.events.EventType.singleAttestation, attestation); } else { chain.emitter.emit(routes.events.EventType.attestation, attestation); chain.emitter.emit(routes.events.EventType.singleAttestation, toElectraSingleAttestation(attestation, indexedAttestation.attestingIndices[0])); } } if (batchableBls) { metrics?.gossipAttestation.attestationBatchHistogram.observe(attestationCount); } else { metrics?.gossipAttestation.attestationNonBatchCount.inc(attestationCount); } return results; }, }; } /** * Retry a function if it throws error code UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT */ export async function validateGossipFnRetryUnknownRoot(fn, network, chain, slot, blockRoot) { let unknownBlockRootRetries = 0; while (true) { try { return await fn(); } catch (e) { if (e instanceof AttestationError && e.type.code === AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT) { if (unknownBlockRootRetries === 0) { // Trigger unknown block root search here const rootHex = toRootHex(blockRoot); network.searchUnknownSlotRoot({ slot, root: rootHex }); } if (unknownBlockRootRetries++ < MAX_UNKNOWN_BLOCK_ROOT_RETRIES) { const foundBlock = await chain.waitForBlock(slot, toRootHex(blockRoot)); // Returns true if the block was found on time. In that case, try to get it from the fork-choice again. // Otherwise, throw the error below. if (foundBlock) { continue; } } } throw e; } } } async function raceWithCutoff(chain, blockSlot, availabilityPromise, cutoffMsFromSlotStart) { const cutoffTimeMs = Math.max(computeTimeAtSlot(chain.config, blockSlot, chain.genesisTime) * 1000 + cutoffMsFromSlotStart - Date.now(), 0); const cutoffTimeout = new Promise((_resolve, reject) => setTimeout(reject, cutoffTimeMs)); await Promise.race([availabilityPromise, cutoffTimeout]); // we can only be here if availabilityPromise has resolved else an error will be thrown return availabilityPromise; } //# sourceMappingURL=gossipHandlers.js.map