UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

474 lines • 21.5 kB
import { peerIdFromPrivateKey } from "@libp2p/peer-id"; import { multiaddr } from "@multiformats/multiaddr"; import { isForkPostFulu } from "@lodestar/params"; import { sszTypesFor } from "@lodestar/types"; import { formatNodePeer } from "../../api/impl/node/utils.js"; import { ClockEvent } from "../../util/clock.js"; import { CustodyConfig } from "../../util/dataColumns.js"; import { peerIdFromString, peerIdToString } from "../../util/peerId.js"; import { FORK_EPOCH_LOOKAHEAD, getActiveForkBoundaries } from "../forks.js"; import { Eth2Gossipsub, getCoreTopicsAtFork } from "../gossip/index.js"; import { getDataColumnSidecarTopics } from "../gossip/topic.js"; import { createNodeJsLibp2p } from "../libp2p/index.js"; import { MetadataController } from "../metadata.js"; import { PeerRpcScoreStore } from "../peers/index.js"; import { PeerManager } from "../peers/peerManager.js"; import { PeersData } from "../peers/peersData.js"; import { ReqRespBeaconNode } from "../reqresp/ReqRespBeaconNode.js"; import { LocalStatusCache } from "../statusCache.js"; import { AttnetsService } from "../subnets/attnetsService.js"; import { computeNodeId } from "../subnets/interface.js"; import { SyncnetsService } from "../subnets/syncnetsService.js"; import { getConnectionsMap } from "../util.js"; import { createNetworkCoreMetrics } from "./metrics.js"; /** * This class is meant to work both: * - In a libp2p worker * - In the main thread * * libp2p holds the reference to the TCP transport socket. libp2p is in a worker, what components * must be in a worker too? * - MetadataController: Read by ReqRespBeaconNode, written by AttnetsService + SyncnetsService * - PeerRpcScoreStore * - ReqRespBeaconNode: Must be in worker, depends on libp2p * - Eth2Gossipsub: Must be in worker, depends on libp2p * - AttnetsService * - SyncnetsService * - PeerManager * - NetworkProcessor: Must be in the main thread, depends on chain */ export class NetworkCore { // Internal modules libp2p; attnetsService; syncnetsService; peerManager; networkConfig; peersData; reqResp; gossip; // TODO: Review if here is best place, and best architecture metadata; logger; config; clock; statusCache; metrics; opts; // Internal state forkBoundariesByEpoch = new Map(); closed = false; constructor(modules) { this.libp2p = modules.libp2p; this.gossip = modules.gossip; this.reqResp = modules.reqResp; this.attnetsService = modules.attnetsService; this.syncnetsService = modules.syncnetsService; this.peerManager = modules.peerManager; this.networkConfig = modules.networkConfig; this.peersData = modules.peersData; this.metadata = modules.metadata; this.logger = modules.logger; this.config = modules.config; this.clock = modules.clock; this.statusCache = modules.statusCache; this.metrics = modules.metrics; this.opts = modules.opts; this.clock.on(ClockEvent.epoch, this.onEpoch); } static async init({ opts, config, privateKey, peerStoreDir, logger, metricsRegistry, events, clock, getReqRespHandler, activeValidatorCount, initialStatus, initialCustodyGroupCount, }) { const libp2p = await createNodeJsLibp2p(privateKey, opts, { peerStoreDir, metrics: Boolean(metricsRegistry), metricsRegistry: metricsRegistry ?? undefined, }); const metrics = metricsRegistry ? createNetworkCoreMetrics(metricsRegistry) : null; const peersData = new PeersData(); const peerRpcScores = new PeerRpcScoreStore(opts, metrics, logger); const statusCache = new LocalStatusCache(initialStatus); // Bind discv5's ENR to local metadata // resolve circular dependency by setting `discv5` variable after the peer manager is instantiated let discv5; const onMetadataSetValue = function onMetadataSetValue(key, value) { discv5?.setEnrValue(key, value).catch((e) => logger.error("error on setEnrValue", { key }, e)); }; const peerId = peerIdFromPrivateKey(privateKey); const nodeId = computeNodeId(peerId); const networkConfig = { nodeId, config, custodyConfig: new CustodyConfig({ nodeId, config, initialCustodyGroupCount }), }; const metadata = new MetadataController({}, { networkConfig, logger, onSetValue: onMetadataSetValue }); const reqResp = new ReqRespBeaconNode({ config, libp2p, metadata, peerRpcScores, logger, events, metrics, peersData, statusCache, getHandler: getReqRespHandler, }, opts); const gossip = new Eth2Gossipsub(opts, { networkConfig, libp2p, logger, metricsRegister: metricsRegistry, eth2Context: { activeValidatorCount, currentSlot: clock.currentSlot, currentEpoch: clock.currentEpoch, }, peersData, events, }); // Note: should not be necessary, already called in createNodeJsLibp2p() await libp2p.start(); await reqResp.start(); // should be called before AttnetsService constructor so that node subscribe to deterministic attnet topics await gossip.start(); const attnetsService = new AttnetsService(config, clock, gossip, metadata, logger, metrics, networkConfig.nodeId, opts); const syncnetsService = new SyncnetsService(config, clock, gossip, metadata, logger, metrics, opts); const peerManager = await PeerManager.init({ privateKey, libp2p, gossip, reqResp, attnetsService, syncnetsService, logger, metrics, clock, peerRpcScores, events, networkConfig, peersData, statusCache, }, opts); // Network spec decides version changes based on clock epoch, not head epoch const boundary = config.getForkBoundaryAtEpoch(clock.currentEpoch); // Register only ReqResp protocols relevant to clock's epoch reqResp.registerProtocolsAtBoundary(boundary); // Bind discv5's ENR to local metadata // biome-ignore lint/complexity/useLiteralKeys: `discovery` is a private attribute discv5 = peerManager["discovery"]?.discv5; // Initialize ENR with clock's fork metadata.upstreamValues(clock.currentEpoch); return new NetworkCore({ libp2p, reqResp, gossip, attnetsService, syncnetsService, peerManager, networkConfig, peersData, metadata, logger, config, clock, statusCache, metrics, opts, }); } /** Destroy this instance. Can only be called once. */ async close() { if (this.closed) return; this.clock.off(ClockEvent.epoch, this.onEpoch); // Must goodbye and disconnect before stopping libp2p await this.peerManager.goodbyeAndDisconnectAllPeers(); this.logger.debug("network sent goodbye to all peers"); await this.peerManager.close(); this.logger.debug("network peerManager closed"); await this.gossip.stop(); this.logger.debug("network gossip closed"); await this.reqResp.stop(); await this.reqResp.unregisterAllProtocols(); this.logger.debug("network reqResp closed"); this.attnetsService.close(); this.syncnetsService.close(); await this.libp2p.stop(); this.logger.debug("network lib2p closed"); this.closed = true; } getNetworkConfig() { return this.networkConfig; } async scrapeMetrics() { return [ (await this.metrics?.register.metrics()) ?? "", // biome-ignore lint/complexity/useLiteralKeys: `discovery` is a private attribute (await this.peerManager["discovery"]?.discv5.scrapeMetrics()) ?? "", ] .filter((str) => str.length > 0) .join("\n\n"); } async updateStatus(status) { this.statusCache.update(status); } async reportPeer(peer, action, actionName) { this.peerManager.reportPeer(peerIdFromString(peer), action, actionName); } async reStatusPeers(peers) { this.peerManager.reStatusPeers(peers); } /** * Request att subnets up `toSlot`. Network will ensure to mantain some peers for each */ async prepareBeaconCommitteeSubnets(subscriptions) { this.attnetsService.addCommitteeSubscriptions(subscriptions); if (subscriptions.length > 0) this.peerManager.onCommitteeSubscriptions(); } async prepareSyncCommitteeSubnets(subscriptions) { this.syncnetsService.addCommitteeSubscriptions(subscriptions); if (subscriptions.length > 0) this.peerManager.onCommitteeSubscriptions(); } /** * Subscribe to all gossip events. Safe to call multiple times */ async subscribeGossipCoreTopics() { if (!(await this.isSubscribedToGossipCoreTopics())) { this.logger.info("Subscribed gossip core topics"); } for (const boundary of getActiveForkBoundaries(this.config, this.clock.currentEpoch)) { this.subscribeCoreTopicsAtBoundary(this.networkConfig, boundary); } } /** * Unsubscribe from all gossip events. Safe to call multiple times */ async unsubscribeGossipCoreTopics() { for (const boundary of this.forkBoundariesByEpoch.values()) { this.unsubscribeCoreTopicsAtBoundary(this.networkConfig, boundary); } } async isSubscribedToGossipCoreTopics() { return this.forkBoundariesByEpoch.size > 0; } sendReqRespRequest(data) { const peerId = peerIdFromString(data.peerId); return this.reqResp.sendRequestWithoutEncoding(peerId, data.method, data.versions, data.requestData); } async publishGossip(topic, data, opts) { const { recipients } = await this.gossip.publish(topic, data, opts); return recipients.length; } /** * Handler of ChainEvent.updateTargetCustodyGroupCount event * Updates the target custody group count in the network config and metadata. * Also subscribes to new data_column_sidecar subnet topics for the new custody group count. */ async setTargetGroupCount(count) { this.networkConfig.custodyConfig.updateTargetCustodyGroupCount(count); this.metadata.custodyGroupCount = count; // cannot call subscribeGossipCoreTopics() because we subsribed to core topics already // we only need to subscribe to more data_column_sidecar topics const dataColumnSubnetTopics = getDataColumnSidecarTopics(this.networkConfig); const activeBoundaries = getActiveForkBoundaries(this.config, this.clock.currentEpoch); for (const boundary of activeBoundaries) { for (const topic of dataColumnSubnetTopics) { // there are existing subscriptions for old subnets, in that case gossipsub will just ignore this.gossip.subscribeTopic({ ...topic, boundary }); } } } // REST API queries async getNetworkIdentity() { // biome-ignore lint/complexity/useLiteralKeys: `discovery` is a private attribute const enr = await this.peerManager["discovery"]?.discv5.enr(); // enr.getFullMultiaddr can counterintuitively return undefined near startup if the enr.ip or enr.ip6 is not set. // Eventually, the enr will be updated with the correct ip after discv5 runs for a while. // Node's addresses on which is listening for discv5 requests. // The example provided by the beacon-APIs show a _full_ multiaddr, ie including the peer id, so we include it. const discoveryAddresses = [ (await enr?.getFullMultiaddr("udp"))?.toString(), (await enr?.getFullMultiaddr("udp6"))?.toString(), ].filter((addr) => Boolean(addr)); // Node's addresses on which eth2 RPC requests are served. const p2pAddresses = [ // It is useful to include listen multiaddrs even if they likely aren't public IPs // This means that we will always return some multiaddrs ...this.libp2p.getMultiaddrs().map((ma) => ma.toString()), (await enr?.getFullMultiaddr("tcp"))?.toString(), (await enr?.getFullMultiaddr("tcp6"))?.toString(), (await enr?.getFullMultiaddr("quic"))?.toString(), (await enr?.getFullMultiaddr("quic6"))?.toString(), ].filter((addr) => Boolean(addr)); return { peerId: peerIdToString(this.libp2p.peerId), enr: enr?.encodeTxt() || "", discoveryAddresses, p2pAddresses, metadata: this.metadata.json, }; } getConnectionsByPeer() { const m = new Map(); for (const [k, v] of getConnectionsMap(this.libp2p).entries()) { m.set(k, v.value); } return m; } async getConnectedPeers() { return this.peerManager.getConnectedPeerIds().map(peerIdToString); } async getConnectedPeerCount() { return this.peerManager.getConnectedPeerIds().length; } // Debug async connectToPeer(peerIdStr, multiaddrStrArr) { const peer = peerIdFromString(peerIdStr); await this.libp2p.peerStore.merge(peer, { multiaddrs: multiaddrStrArr.map(multiaddr) }); await this.libp2p.dial(peer); } async disconnectPeer(peerIdStr) { await this.libp2p.hangUp(peerIdFromString(peerIdStr)); } async addDirectPeer(peer) { return this.gossip.addDirectPeer(peer); } async removeDirectPeer(peerIdStr) { return this.gossip.removeDirectPeer(peerIdStr); } async getDirectPeers() { return this.gossip.getDirectPeers(); } _dumpPeer(peerIdStr, connections) { const peerData = this.peersData.connectedPeers.get(peerIdStr); const fork = this.config.getForkName(this.clock.currentSlot); if (isForkPostFulu(fork) && peerData?.status) { peerData.status.earliestAvailableSlot = peerData.status.earliestAvailableSlot ?? 0; } return { ...formatNodePeer(peerIdStr, connections), agentVersion: peerData?.agentVersion ?? "NA", status: peerData?.status ? sszTypesFor(fork).Status.toJson(peerData.status) : null, metadata: peerData?.metadata ? sszTypesFor(fork).Metadata.toJson(peerData.metadata) : null, agentClient: String(peerData?.agentClient ?? "Unknown"), lastReceivedMsgUnixTsMs: peerData?.lastReceivedMsgUnixTsMs ?? 0, lastStatusUnixTsMs: peerData?.lastStatusUnixTsMs ?? 0, connectedUnixTsMs: peerData?.connectedUnixTsMs ?? 0, }; } async dumpPeer(peerIdStr) { const connections = this.getConnectionsByPeer().get(peerIdStr); return connections ? this._dumpPeer(peerIdStr, connections) : undefined; } async dumpPeers() { return Array.from(this.getConnectionsByPeer().entries()).map(([peerIdStr, connections]) => this._dumpPeer(peerIdStr, connections)); } async dumpPeerScoreStats() { return this.peerManager.dumpPeerScoreStats(); } async dumpGossipPeerScoreStats() { return this.gossip.dumpPeerScoreStats(); } async dumpDiscv5KadValues() { // biome-ignore lint/complexity/useLiteralKeys: `discovery` is a private attribute return (await this.peerManager["discovery"]?.discv5?.kadValues())?.map((enr) => enr.encodeTxt()) ?? []; } async dumpMeshPeers() { const meshPeers = {}; for (const topic of this.gossip.getTopics()) { meshPeers[topic] = this.gossip.getMeshPeers(topic); } return meshPeers; } async writeNetworkThreadProfile() { throw new Error("Method not implemented, please configure network thread"); } async writeDiscv5Profile(durationMs, dirpath) { // biome-ignore lint/complexity/useLiteralKeys: `discovery` is a private attribute return this.peerManager["discovery"]?.discv5.writeProfile(durationMs, dirpath) ?? "no discv5"; } writeNetworkHeapSnapshot() { throw new Error("Method not implemented, please configure network thread"); } writeDiscv5HeapSnapshot(prefix, dirpath) { // biome-ignore lint/complexity/useLiteralKeys: `discovery` is a private attribute return this.peerManager["discovery"]?.discv5.writeHeapSnapshot(prefix, dirpath) ?? Promise.resolve("no discv5"); } /** * Handle subscriptions through fork boundary transitions, @see FORK_EPOCH_LOOKAHEAD */ onEpoch = async (epoch) => { try { // Compute prev and next fork boundary shifted, so next boundary is still next at forkEpoch + FORK_EPOCH_LOOKAHEAD const activeBoundaries = getActiveForkBoundaries(this.config, epoch); for (let i = 0; i < activeBoundaries.length; i++) { // Only when a new fork boundary is scheduled post this one if (activeBoundaries[i + 1] !== undefined) { const prevBoundary = activeBoundaries[i]; const nextBoundary = activeBoundaries[i + 1]; const nextBoundaryEpoch = nextBoundary.epoch; // Before fork boundary transition if (epoch === nextBoundaryEpoch - FORK_EPOCH_LOOKAHEAD) { // Don't subscribe to new fork boundary if the node is not subscribed to any topic if (await this.isSubscribedToGossipCoreTopics()) { this.subscribeCoreTopicsAtBoundary(this.networkConfig, nextBoundary); this.logger.info("Subscribing gossip topics for next fork boundary", nextBoundary); } else { this.logger.info("Skipping subscribing gossip topics for next fork boundary", nextBoundary); } this.attnetsService.subscribeSubnetsNextBoundary(nextBoundary); this.syncnetsService.subscribeSubnetsNextBoundary(nextBoundary); } // On fork boundary transition if (epoch === nextBoundaryEpoch) { // updateEth2Field() MUST be called with clock epoch, onEpoch event is emitted in response to clock events const { forkDigest } = this.metadata.updateEth2Field(epoch); // Update local status to reflect the new fork digest, otherwise we will disconnect peers that re-status us // right after the fork transition due to incompatible forks as our fork digest is stale since we only // update it once we import a new head or when emitting update status event. this.statusCache.update({ ...this.statusCache.get(), forkDigest }); this.reqResp.registerProtocolsAtBoundary(nextBoundary); } // After fork boundary transition if (epoch === nextBoundaryEpoch + FORK_EPOCH_LOOKAHEAD) { this.logger.info("Unsubscribing gossip topics of previous fork boundary", prevBoundary); this.unsubscribeCoreTopicsAtBoundary(this.networkConfig, prevBoundary); this.attnetsService.unsubscribeSubnetsPrevBoundary(prevBoundary); this.syncnetsService.unsubscribeSubnetsPrevBoundary(prevBoundary); } } } } catch (e) { this.logger.error("Error on BeaconGossipHandler.onEpoch", { epoch }, e); } }; subscribeCoreTopicsAtBoundary(networkConfig, boundary) { if (this.forkBoundariesByEpoch.has(boundary.epoch)) return; this.forkBoundariesByEpoch.set(boundary.epoch, boundary); const { subscribeAllSubnets, disableLightClientServer } = this.opts; for (const topic of getCoreTopicsAtFork(networkConfig, boundary.fork, { subscribeAllSubnets, disableLightClientServer, })) { this.gossip.subscribeTopic({ ...topic, boundary }); } } unsubscribeCoreTopicsAtBoundary(networkConfig, boundary) { if (!this.forkBoundariesByEpoch.has(boundary.epoch)) return; this.forkBoundariesByEpoch.delete(boundary.epoch); const { subscribeAllSubnets, disableLightClientServer } = this.opts; for (const topic of getCoreTopicsAtFork(networkConfig, boundary.fork, { subscribeAllSubnets, disableLightClientServer, })) { this.gossip.unsubscribeTopic({ ...topic, boundary }); } } } //# sourceMappingURL=networkCore.js.map