From 6f6fc1fc900d687afbfebb6d1ee17458669759fd Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 3 Feb 2026 21:15:22 +0000 Subject: [PATCH 01/13] feat(api): add runtime direct peer management endpoints Add HTTP API endpoints to manage GossipSub direct peers at runtime: - POST /eth/v1/lodestar/direct_peer?peer= Adds a direct peer. Accepts multiaddr with peer ID or ENR string. Returns {peerId: string | null} - DELETE /eth/v1/lodestar/direct_peer?peerId= Removes a direct peer by peer ID. Returns {removed: boolean} - GET /eth/v1/lodestar/direct_peers Lists current direct peer IDs. Returns string[] Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation, making them useful for trusted peer relationships that should persist regardless of scoring. This enables operators to dynamically add/remove direct peers without requiring a node restart, which is particularly useful for: - Adding trusted peers discovered during operation - Removing misbehaving peers from the direct list - Temporary mesh connections for debugging/testing --- packages/api/src/beacon/routes/lodestar.ts | 61 +++++++++++++++++++ .../src/api/impl/lodestar/index.ts | 14 +++++ .../src/network/core/networkCore.ts | 12 ++++ .../src/network/core/networkCoreWorker.ts | 3 + .../network/core/networkCoreWorkerHandler.ts | 9 +++ .../beacon-node/src/network/core/types.ts | 6 ++ .../src/network/gossip/gossipsub.ts | 53 ++++++++++++++++ packages/beacon-node/src/network/network.ts | 12 ++++ .../onWorker/dataSerialization.test.ts | 3 + 9 files changed, 173 insertions(+) diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 5622c9949ef5..1583ca244013 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -240,6 +240,41 @@ export type Endpoints = { EmptyResponseData, EmptyMeta >; + + /** + * Add a direct peer at runtime. + * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. + * Accepts either a multiaddr with peer ID or an ENR string. + */ + addDirectPeer: Endpoint< + // ⏎ + "POST", + {peer: string}, + {query: {peer: string}}, + {peerId: string | null}, + EmptyMeta + >; + + /** Remove a peer from direct peers */ + removeDirectPeer: Endpoint< + // ⏎ + "DELETE", + {peerId: string}, + {query: {peerId: string}}, + {removed: boolean}, + EmptyMeta + >; + + /** Get list of direct peer IDs */ + getDirectPeers: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + string[], + EmptyMeta + >; + /** Same to node api with new fields */ getPeers: Endpoint< "GET", @@ -443,6 +478,32 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({query: {peer}}), + parseReq: ({query}) => ({peer: query.peer}), + schema: {query: {peer: Schema.StringRequired}}, + }, + resp: JsonOnlyResponseCodec, + }, + removeDirectPeer: { + url: "/eth/v1/lodestar/direct_peer", + method: "DELETE", + req: { + writeReq: ({peerId}) => ({query: {peerId}}), + parseReq: ({query}) => ({peerId: query.peerId}), + schema: {query: {peerId: Schema.StringRequired}}, + }, + resp: JsonOnlyResponseCodec, + }, + getDirectPeers: { + url: "/eth/v1/lodestar/direct_peers", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, getPeers: { url: "/eth/v1/lodestar/peers", method: "GET", diff --git a/packages/beacon-node/src/api/impl/lodestar/index.ts b/packages/beacon-node/src/api/impl/lodestar/index.ts index ce5abcfc886e..d1335554867a 100644 --- a/packages/beacon-node/src/api/impl/lodestar/index.ts +++ b/packages/beacon-node/src/api/impl/lodestar/index.ts @@ -154,6 +154,20 @@ export function getLodestarApi({ await network.disconnectPeer(peerId); }, + async addDirectPeer({peer}) { + const peerId = await network.addDirectPeer(peer); + return {data: {peerId}}; + }, + + async removeDirectPeer({peerId}) { + const removed = await network.removeDirectPeer(peerId); + return {data: {removed}}; + }, + + async getDirectPeers() { + return {data: await network.getDirectPeers()}; + }, + async getPeers({state, direction}) { const peers = (await network.dumpPeers()).filter( (nodePeer) => diff --git a/packages/beacon-node/src/network/core/networkCore.ts b/packages/beacon-node/src/network/core/networkCore.ts index 06481890ca4e..515eb827871f 100644 --- a/packages/beacon-node/src/network/core/networkCore.ts +++ b/packages/beacon-node/src/network/core/networkCore.ts @@ -454,6 +454,18 @@ export class NetworkCore implements INetworkCore { await this.libp2p.hangUp(peerIdFromString(peerIdStr)); } + async addDirectPeer(peer: string): Promise { + return this.gossip.addDirectPeer(peer); + } + + async removeDirectPeer(peerIdStr: PeerIdStr): Promise { + return this.gossip.removeDirectPeer(peerIdStr); + } + + async getDirectPeers(): Promise { + return this.gossip.getDirectPeers(); + } + private _dumpPeer(peerIdStr: string, connections: Connection[]): routes.lodestar.LodestarNodePeer { const peerData = this.peersData.connectedPeers.get(peerIdStr); const fork = this.config.getForkName(this.clock.currentSlot); diff --git a/packages/beacon-node/src/network/core/networkCoreWorker.ts b/packages/beacon-node/src/network/core/networkCoreWorker.ts index e5cf1ba4dda8..8dd8229d5b70 100644 --- a/packages/beacon-node/src/network/core/networkCoreWorker.ts +++ b/packages/beacon-node/src/network/core/networkCoreWorker.ts @@ -153,6 +153,9 @@ const libp2pWorkerApi: NetworkWorkerApi = { getConnectedPeerCount: () => core.getConnectedPeerCount(), connectToPeer: (peer, multiaddr) => core.connectToPeer(peer, multiaddr), disconnectPeer: (peer) => core.disconnectPeer(peer), + addDirectPeer: (peer) => core.addDirectPeer(peer), + removeDirectPeer: (peerId) => core.removeDirectPeer(peerId), + getDirectPeers: () => core.getDirectPeers(), dumpPeers: () => core.dumpPeers(), dumpPeer: (peerIdStr) => core.dumpPeer(peerIdStr), dumpPeerScoreStats: () => core.dumpPeerScoreStats(), diff --git a/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts b/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts index 5ce810a30f6c..b8783716b8ab 100644 --- a/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts +++ b/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts @@ -247,6 +247,15 @@ export class WorkerNetworkCore implements INetworkCore { disconnectPeer(peer: PeerIdStr): Promise { return this.getApi().disconnectPeer(peer); } + addDirectPeer(peer: string): Promise { + return this.getApi().addDirectPeer(peer); + } + removeDirectPeer(peerId: PeerIdStr): Promise { + return this.getApi().removeDirectPeer(peerId); + } + getDirectPeers(): Promise { + return this.getApi().getDirectPeers(); + } dumpPeers(): Promise { return this.getApi().dumpPeers(); } diff --git a/packages/beacon-node/src/network/core/types.ts b/packages/beacon-node/src/network/core/types.ts index f372bc686778..c4332537b5c3 100644 --- a/packages/beacon-node/src/network/core/types.ts +++ b/packages/beacon-node/src/network/core/types.ts @@ -30,6 +30,12 @@ export interface INetworkCorePublic { // Debug connectToPeer(peer: PeerIdStr, multiaddr: MultiaddrStr[]): Promise; disconnectPeer(peer: PeerIdStr): Promise; + + // Direct peers management + addDirectPeer(peer: string): Promise; + removeDirectPeer(peerId: PeerIdStr): Promise; + getDirectPeers(): Promise; + dumpPeers(): Promise; dumpPeer(peerIdStr: PeerIdStr): Promise; dumpPeerScoreStats(): Promise; diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index bd38a93006e0..d1b2af348d76 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -87,6 +87,7 @@ export class Eth2Gossipsub extends GossipSub { private readonly logger: Logger; private readonly peersData: PeersData; private readonly events: NetworkEventBus; + private readonly libp2p: Libp2p; // Internal caches private readonly gossipTopicCache: GossipTopicCache; @@ -159,6 +160,7 @@ export class Eth2Gossipsub extends GossipSub { this.logger = logger; this.peersData = peersData; this.events = events; + this.libp2p = modules.libp2p; this.gossipTopicCache = gossipTopicCache; this.addEventListener("gossipsub:message", this.onGossipsubMessage.bind(this)); @@ -341,6 +343,57 @@ export class Eth2Gossipsub extends GossipSub { this.reportMessageValidationResult(data.msgId, data.propagationSource, data.acceptance); }); } + + /** + * Add a peer as a direct peer at runtime. + * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. + * + * @param peerStr - Either a multiaddr with peer ID or an ENR string + * @returns The peer ID string if successfully added, null if parsing failed + */ + addDirectPeer(peerStr: string): string | null { + const parsed = parseDirectPeers([peerStr], this.logger); + if (parsed.length === 0) { + return null; + } + + const {id: peerId, addrs} = parsed[0]; + const peerIdStr = peerId.toString(); + + // Add to direct peers set (this is public readonly on GossipSub parent class) + this.direct.add(peerIdStr); + + // Add addresses to peer store so we can connect + if (addrs.length > 0) { + this.libp2p.peerStore.merge(peerId, {multiaddrs: addrs}).catch((e) => { + this.logger.warn("Failed to add direct peer addresses to peer store", {peerId: peerIdStr}, e); + }); + } + + this.logger.info("Added direct peer via API", {peerId: peerIdStr}); + return peerIdStr; + } + + /** + * Remove a peer from direct peers. + * + * @param peerIdStr - The peer ID string to remove + * @returns true if the peer was removed, false if it wasn't a direct peer + */ + removeDirectPeer(peerIdStr: string): boolean { + const removed = this.direct.delete(peerIdStr); + if (removed) { + this.logger.info("Removed direct peer via API", {peerId: peerIdStr}); + } + return removed; + } + + /** + * Get list of current direct peer IDs. + */ + getDirectPeers(): string[] { + return Array.from(this.direct); + } } /** diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 6169102af4d5..6f30d674cad4 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -641,6 +641,18 @@ export class Network implements INetwork { return this.core.disconnectPeer(peer); } + addDirectPeer(peer: string): Promise { + return this.core.addDirectPeer(peer); + } + + removeDirectPeer(peerId: string): Promise { + return this.core.removeDirectPeer(peerId); + } + + getDirectPeers(): Promise { + return this.core.getDirectPeers(); + } + dumpPeer(peerIdStr: string): Promise { return this.core.dumpPeer(peerIdStr); } diff --git a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts index 35fdc57f4d56..9b38e8da98bb 100644 --- a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts +++ b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts @@ -118,6 +118,9 @@ describe("data serialization through worker boundary", () => { unsubscribeGossipCoreTopics: [], connectToPeer: [peerId, ["/ip4/1.2.3.4/tcp/13000"]], disconnectPeer: [peerId], + addDirectPeer: ["/ip4/1.2.3.4/tcp/13000/p2p/" + peerId], + removeDirectPeer: [peerId], + getDirectPeers: [], dumpPeers: [], dumpPeer: [peerId], dumpPeerScoreStats: [], From 9e45760bbf9dec0a09c182357f52b1e2781a5a0e Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 3 Feb 2026 21:19:55 +0000 Subject: [PATCH 02/13] fix: await peerStore.merge before adding to direct set Address review feedback - ensure addresses are stored before adding peer to direct set to avoid potential race conditions. --- .../src/network/gossip/gossipsub.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index d1b2af348d76..61e9f7e1bb22 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -349,9 +349,9 @@ export class Eth2Gossipsub extends GossipSub { * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. * * @param peerStr - Either a multiaddr with peer ID or an ENR string - * @returns The peer ID string if successfully added, null if parsing failed + * @returns The peer ID string if successfully added, null if parsing or address storage failed */ - addDirectPeer(peerStr: string): string | null { + async addDirectPeer(peerStr: string): Promise { const parsed = parseDirectPeers([peerStr], this.logger); if (parsed.length === 0) { return null; @@ -360,16 +360,19 @@ export class Eth2Gossipsub extends GossipSub { const {id: peerId, addrs} = parsed[0]; const peerIdStr = peerId.toString(); - // Add to direct peers set (this is public readonly on GossipSub parent class) - this.direct.add(peerIdStr); - - // Add addresses to peer store so we can connect + // Add addresses to peer store first so we can connect if (addrs.length > 0) { - this.libp2p.peerStore.merge(peerId, {multiaddrs: addrs}).catch((e) => { - this.logger.warn("Failed to add direct peer addresses to peer store", {peerId: peerIdStr}, e); - }); + try { + await this.libp2p.peerStore.merge(peerId, {multiaddrs: addrs}); + } catch (e) { + this.logger.warn("Failed to add direct peer addresses to peer store", {peerId: peerIdStr}, e as Error); + return null; + } } + // Add to direct peers set only after addresses are stored + this.direct.add(peerIdStr); + this.logger.info("Added direct peer via API", {peerId: peerIdStr}); return peerIdStr; } From 9a7b8434815f09d457385ab3532791dd9de78365 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 3 Feb 2026 21:34:04 +0000 Subject: [PATCH 03/13] chore: simplify JSDoc annotations per review feedback --- packages/beacon-node/src/network/gossip/gossipsub.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index 61e9f7e1bb22..4a461c287e7e 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -345,11 +345,8 @@ export class Eth2Gossipsub extends GossipSub { } /** - * Add a peer as a direct peer at runtime. + * Add a peer as a direct peer at runtime. Accepts multiaddr with peer ID or ENR string. * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. - * - * @param peerStr - Either a multiaddr with peer ID or an ENR string - * @returns The peer ID string if successfully added, null if parsing or address storage failed */ async addDirectPeer(peerStr: string): Promise { const parsed = parseDirectPeers([peerStr], this.logger); @@ -379,9 +376,6 @@ export class Eth2Gossipsub extends GossipSub { /** * Remove a peer from direct peers. - * - * @param peerIdStr - The peer ID string to remove - * @returns true if the peer was removed, false if it wasn't a direct peer */ removeDirectPeer(peerIdStr: string): boolean { const removed = this.direct.delete(peerIdStr); From 3e93224c9f290df7abc40456ce0c5713bee62f7e Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 3 Feb 2026 23:26:06 +0000 Subject: [PATCH 04/13] fix: remove jsdoc annotations per review --- packages/beacon-node/src/network/gossip/gossipsub.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index 4a461c287e7e..08ae9837f81d 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -344,10 +344,6 @@ export class Eth2Gossipsub extends GossipSub { }); } - /** - * Add a peer as a direct peer at runtime. Accepts multiaddr with peer ID or ENR string. - * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. - */ async addDirectPeer(peerStr: string): Promise { const parsed = parseDirectPeers([peerStr], this.logger); if (parsed.length === 0) { @@ -374,9 +370,6 @@ export class Eth2Gossipsub extends GossipSub { return peerIdStr; } - /** - * Remove a peer from direct peers. - */ removeDirectPeer(peerIdStr: string): boolean { const removed = this.direct.delete(peerIdStr); if (removed) { @@ -385,9 +378,6 @@ export class Eth2Gossipsub extends GossipSub { return removed; } - /** - * Get list of current direct peer IDs. - */ getDirectPeers(): string[] { return Array.from(this.direct); } From c0411133ec73ac93c6745c0ced22b8f015ceb3fd Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 3 Feb 2026 23:34:47 +0000 Subject: [PATCH 05/13] Revert "fix: remove jsdoc annotations per review" This reverts commit 3e93224c9f290df7abc40456ce0c5713bee62f7e. --- packages/beacon-node/src/network/gossip/gossipsub.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index 08ae9837f81d..4a461c287e7e 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -344,6 +344,10 @@ export class Eth2Gossipsub extends GossipSub { }); } + /** + * Add a peer as a direct peer at runtime. Accepts multiaddr with peer ID or ENR string. + * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. + */ async addDirectPeer(peerStr: string): Promise { const parsed = parseDirectPeers([peerStr], this.logger); if (parsed.length === 0) { @@ -370,6 +374,9 @@ export class Eth2Gossipsub extends GossipSub { return peerIdStr; } + /** + * Remove a peer from direct peers. + */ removeDirectPeer(peerIdStr: string): boolean { const removed = this.direct.delete(peerIdStr); if (removed) { @@ -378,6 +385,9 @@ export class Eth2Gossipsub extends GossipSub { return removed; } + /** + * Get list of current direct peer IDs. + */ getDirectPeers(): string[] { return Array.from(this.direct); } From 97d8df623fd026f36bd8ff7ed8021e1ed01a80f7 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 3 Feb 2026 23:39:01 +0000 Subject: [PATCH 06/13] fix: use plural /direct_peers URL for all endpoints per review Consistent with REST conventions - all operations on the direct_peers resource now use the plural form. --- packages/api/src/beacon/routes/lodestar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 1583ca244013..58b30f1ec334 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -479,7 +479,7 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({query: {peer}}), @@ -489,7 +489,7 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({query: {peerId}}), From 9c4bb4f3cb048d9c83ae5b5cf2a93bc406060de6 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 4 Feb 2026 09:33:13 +0000 Subject: [PATCH 07/13] fix: require addresses for direct peers, add DirectPeerInput type - Reject addDirectPeer if no addresses provided (cannot connect without them) - Add DirectPeerInput type alias to clarify that parameter accepts multiaddr or ENR --- packages/beacon-node/src/network/core/types.ts | 4 +++- .../src/network/gossip/gossipsub.ts | 18 +++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/network/core/types.ts b/packages/beacon-node/src/network/core/types.ts index c4332537b5c3..11c906af9e8e 100644 --- a/packages/beacon-node/src/network/core/types.ts +++ b/packages/beacon-node/src/network/core/types.ts @@ -12,6 +12,8 @@ import {OutgoingRequestArgs} from "../reqresp/types.js"; import {CommitteeSubscription} from "../subnets/interface.js"; export type MultiaddrStr = string; +/** A multiaddr with peer ID (e.g., /ip4/.../tcp/.../p2p/...) or ENR string (enr:-...) */ +export type DirectPeerInput = string; // Interface shared by main Network class, and all backends export interface INetworkCorePublic { @@ -32,7 +34,7 @@ export interface INetworkCorePublic { disconnectPeer(peer: PeerIdStr): Promise; // Direct peers management - addDirectPeer(peer: string): Promise; + addDirectPeer(peer: DirectPeerInput): Promise; removeDirectPeer(peerId: PeerIdStr): Promise; getDirectPeers(): Promise; diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index 4a461c287e7e..82563caeb024 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -357,14 +357,18 @@ export class Eth2Gossipsub extends GossipSub { const {id: peerId, addrs} = parsed[0]; const peerIdStr = peerId.toString(); + // Direct peers need addresses to connect - reject if none provided + if (addrs.length === 0) { + this.logger.warn("Cannot add direct peer without addresses", {peerId: peerIdStr}); + return null; + } + // Add addresses to peer store first so we can connect - if (addrs.length > 0) { - try { - await this.libp2p.peerStore.merge(peerId, {multiaddrs: addrs}); - } catch (e) { - this.logger.warn("Failed to add direct peer addresses to peer store", {peerId: peerIdStr}, e as Error); - return null; - } + try { + await this.libp2p.peerStore.merge(peerId, {multiaddrs: addrs}); + } catch (e) { + this.logger.warn("Failed to add direct peer addresses to peer store", {peerId: peerIdStr}, e as Error); + return null; } // Add to direct peers set only after addresses are stored From 48485fa0e5ce00faf3b097ea69f4432abc459288 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 5 Feb 2026 00:28:14 +0000 Subject: [PATCH 08/13] refactor: add DirectPeerInput type to api package Added DirectPeerInput type to @lodestar/api routes.lodestar for API consumers. Type is still defined in beacon-node for internal use until api package exports are updated to allow direct imports from routes modules. --- packages/api/src/beacon/routes/lodestar.ts | 3 +++ packages/beacon-node/src/network/core/types.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 58b30f1ec334..933413f86d27 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -54,6 +54,9 @@ export type GossipPeerScoreStat = { // + Other un-typed options }; +/** A multiaddr with peer ID (e.g., /ip4/.../tcp/.../p2p/...) or ENR string (enr:-...) */ +export type DirectPeerInput = string; + export type RegenQueueItem = { key: string; args: unknown; diff --git a/packages/beacon-node/src/network/core/types.ts b/packages/beacon-node/src/network/core/types.ts index 11c906af9e8e..94775761af8c 100644 --- a/packages/beacon-node/src/network/core/types.ts +++ b/packages/beacon-node/src/network/core/types.ts @@ -12,7 +12,10 @@ import {OutgoingRequestArgs} from "../reqresp/types.js"; import {CommitteeSubscription} from "../subnets/interface.js"; export type MultiaddrStr = string; -/** A multiaddr with peer ID (e.g., /ip4/.../tcp/.../p2p/...) or ENR string (enr:-...) */ +/** + * A multiaddr with peer ID (e.g., /ip4/.../tcp/.../p2p/...) or ENR string (enr:-...). + * Type definition also exists in @lodestar/api routes.lodestar for API consumers. + */ export type DirectPeerInput = string; // Interface shared by main Network class, and all backends From 6670995e3e666f5d69ecbe531ff01f2db8c533fc Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 5 Feb 2026 11:22:25 +0000 Subject: [PATCH 09/13] fix: throw ApiError instead of returning null from addDirectPeer --- packages/api/src/beacon/routes/lodestar.ts | 2 +- packages/beacon-node/src/api/impl/lodestar/index.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 933413f86d27..51b2b28394b0 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -254,7 +254,7 @@ export type Endpoints = { "POST", {peer: string}, {query: {peer: string}}, - {peerId: string | null}, + {peerId: string}, EmptyMeta >; diff --git a/packages/beacon-node/src/api/impl/lodestar/index.ts b/packages/beacon-node/src/api/impl/lodestar/index.ts index d1335554867a..9f94bdc1ad15 100644 --- a/packages/beacon-node/src/api/impl/lodestar/index.ts +++ b/packages/beacon-node/src/api/impl/lodestar/index.ts @@ -156,6 +156,9 @@ export function getLodestarApi({ async addDirectPeer({peer}) { const peerId = await network.addDirectPeer(peer); + if (peerId === null) { + throw new ApiError(400, `Failed to add direct peer: invalid peer address or ENR "${peer}"`); + } return {data: {peerId}}; }, From c56a480805e5a830a2ef29ab0d099d9f540a9466 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 5 Feb 2026 12:04:57 +0000 Subject: [PATCH 10/13] Review PR --- packages/api/src/beacon/routes/lodestar.ts | 15 ++++++++++++--- .../beacon-node/src/network/core/networkCore.ts | 2 +- .../src/network/core/networkCoreWorkerHandler.ts | 2 +- packages/beacon-node/src/network/core/types.ts | 7 +------ .../beacon-node/src/network/gossip/gossipsub.ts | 5 +++-- packages/beacon-node/src/network/network.ts | 2 +- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 51b2b28394b0..5236d3d40e38 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -54,8 +54,17 @@ export type GossipPeerScoreStat = { // + Other un-typed options }; -/** A multiaddr with peer ID (e.g., /ip4/.../tcp/.../p2p/...) or ENR string (enr:-...) */ -export type DirectPeerInput = string; +/** + * A multiaddr with peer ID or ENR string. + * + * Supported formats: + * - Multiaddr with peer ID: `/ip4/192.168.1.1/tcp/9000/p2p/16Uiu2HAmKLhW7...` + * - ENR: `enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOo...` + * + * For multiaddrs, the string must contain a /p2p/ component with the peer ID. + * For ENRs, the TCP multiaddr and peer ID are extracted from the encoded record. + */ +export type DirectPeer = string; export type RegenQueueItem = { key: string; @@ -252,7 +261,7 @@ export type Endpoints = { addDirectPeer: Endpoint< // ⏎ "POST", - {peer: string}, + {peer: DirectPeer}, {query: {peer: string}}, {peerId: string}, EmptyMeta diff --git a/packages/beacon-node/src/network/core/networkCore.ts b/packages/beacon-node/src/network/core/networkCore.ts index 515eb827871f..d5ec1f6e9abe 100644 --- a/packages/beacon-node/src/network/core/networkCore.ts +++ b/packages/beacon-node/src/network/core/networkCore.ts @@ -454,7 +454,7 @@ export class NetworkCore implements INetworkCore { await this.libp2p.hangUp(peerIdFromString(peerIdStr)); } - async addDirectPeer(peer: string): Promise { + async addDirectPeer(peer: routes.lodestar.DirectPeer): Promise { return this.gossip.addDirectPeer(peer); } diff --git a/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts b/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts index b8783716b8ab..194a7a9ea73a 100644 --- a/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts +++ b/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts @@ -247,7 +247,7 @@ export class WorkerNetworkCore implements INetworkCore { disconnectPeer(peer: PeerIdStr): Promise { return this.getApi().disconnectPeer(peer); } - addDirectPeer(peer: string): Promise { + addDirectPeer(peer: routes.lodestar.DirectPeer): Promise { return this.getApi().addDirectPeer(peer); } removeDirectPeer(peerId: PeerIdStr): Promise { diff --git a/packages/beacon-node/src/network/core/types.ts b/packages/beacon-node/src/network/core/types.ts index 94775761af8c..1763b2e6bd61 100644 --- a/packages/beacon-node/src/network/core/types.ts +++ b/packages/beacon-node/src/network/core/types.ts @@ -12,11 +12,6 @@ import {OutgoingRequestArgs} from "../reqresp/types.js"; import {CommitteeSubscription} from "../subnets/interface.js"; export type MultiaddrStr = string; -/** - * A multiaddr with peer ID (e.g., /ip4/.../tcp/.../p2p/...) or ENR string (enr:-...). - * Type definition also exists in @lodestar/api routes.lodestar for API consumers. - */ -export type DirectPeerInput = string; // Interface shared by main Network class, and all backends export interface INetworkCorePublic { @@ -37,7 +32,7 @@ export interface INetworkCorePublic { disconnectPeer(peer: PeerIdStr): Promise; // Direct peers management - addDirectPeer(peer: DirectPeerInput): Promise; + addDirectPeer(peer: routes.lodestar.DirectPeer): Promise; removeDirectPeer(peerId: PeerIdStr): Promise; getDirectPeers(): Promise; diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index 82563caeb024..1b4765fddca4 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -5,6 +5,7 @@ import {GossipSub, GossipsubEvents} from "@chainsafe/libp2p-gossipsub"; import {MetricsRegister, TopicLabel, TopicStrToLabel} from "@chainsafe/libp2p-gossipsub/metrics"; import {PeerScoreParams} from "@chainsafe/libp2p-gossipsub/score"; import {AddrInfo, SignaturePolicy, TopicStr} from "@chainsafe/libp2p-gossipsub/types"; +import {routes} from "@lodestar/api"; import {BeaconConfig, ForkBoundary} from "@lodestar/config"; import {ATTESTATION_SUBNET_COUNT, SLOTS_PER_EPOCH, SYNC_COMMITTEE_SUBNET_COUNT} from "@lodestar/params"; import {SubnetID} from "@lodestar/types"; @@ -348,7 +349,7 @@ export class Eth2Gossipsub extends GossipSub { * Add a peer as a direct peer at runtime. Accepts multiaddr with peer ID or ENR string. * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. */ - async addDirectPeer(peerStr: string): Promise { + async addDirectPeer(peerStr: routes.lodestar.DirectPeer): Promise { const parsed = parseDirectPeers([peerStr], this.logger); if (parsed.length === 0) { return null; @@ -460,7 +461,7 @@ function getForkBoundaryLabel(boundary: ForkBoundary): ForkBoundaryLabel { * For multiaddrs, the string must contain a /p2p/ component with the peer ID. * For ENRs, the TCP multiaddr and peer ID are extracted from the encoded record. */ -export function parseDirectPeers(directPeerStrs: string[], logger: Logger): AddrInfo[] { +export function parseDirectPeers(directPeerStrs: routes.lodestar.DirectPeer[], logger: Logger): AddrInfo[] { const directPeers: AddrInfo[] = []; for (const peerStr of directPeerStrs) { diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 6f30d674cad4..94e5b000909c 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -641,7 +641,7 @@ export class Network implements INetwork { return this.core.disconnectPeer(peer); } - addDirectPeer(peer: string): Promise { + addDirectPeer(peer: routes.lodestar.DirectPeer): Promise { return this.core.addDirectPeer(peer); } From 47a5e4f023665fc1fc37976b1704be6de0e80f38 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 5 Feb 2026 12:17:07 +0000 Subject: [PATCH 11/13] fix: prevent adding self as direct peer --- packages/beacon-node/src/network/gossip/gossipsub.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index 1b4765fddca4..2018591f781e 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -358,6 +358,12 @@ export class Eth2Gossipsub extends GossipSub { const {id: peerId, addrs} = parsed[0]; const peerIdStr = peerId.toString(); + // Prevent adding self as a direct peer + if (peerId.equals(this.libp2p.peerId)) { + this.logger.warn("Cannot add self as a direct peer", {peerId: peerIdStr}); + return null; + } + // Direct peers need addresses to connect - reject if none provided if (addrs.length === 0) { this.logger.warn("Cannot add direct peer without addresses", {peerId: peerIdStr}); From 96e478725e579d34908fb2a7e5523b2f60e8fbb1 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 5 Feb 2026 12:37:00 +0000 Subject: [PATCH 12/13] fix: add direct peer methods to dataSerialization test return types --- .../test/e2e/network/onWorker/dataSerialization.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts index 9b38e8da98bb..f8bddfd5df70 100644 --- a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts +++ b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts @@ -210,6 +210,9 @@ describe("data serialization through worker boundary", () => { writeDiscv5Profile: "", setAdvertisedGroupCount: null, setTargetGroupCount: null, + addDirectPeer: "QmTest", + removeDirectPeer: true, + getDirectPeers: ["QmTest1", "QmTest2"], }; type TestCase = {id: string; data: unknown; shouldFail?: boolean}; From 5221189074017eef72363bd52b7d4f857358bee6 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 5 Feb 2026 12:42:12 +0000 Subject: [PATCH 13/13] fix: use realistic peerId values in dataSerialization test --- .../test/e2e/network/onWorker/dataSerialization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts index f8bddfd5df70..cbef30901e57 100644 --- a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts +++ b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts @@ -210,9 +210,9 @@ describe("data serialization through worker boundary", () => { writeDiscv5Profile: "", setAdvertisedGroupCount: null, setTargetGroupCount: null, - addDirectPeer: "QmTest", + addDirectPeer: peerId, removeDirectPeer: true, - getDirectPeers: ["QmTest1", "QmTest2"], + getDirectPeers: [peerId], }; type TestCase = {id: string; data: unknown; shouldFail?: boolean};