From 30d0c14e5ee941bca8477d417abce9e24e1a28da Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Wed, 13 May 2026 12:22:26 +0200 Subject: [PATCH 1/4] feat: surface last action name and delta in peer score stats Adds three fields to PeerScoreStat returned by GET /eth/v1/lodestar/lodestar_peer_score_stats: - lastActionName: the action label passed to reportPeer - lastActionDeltaScore: post-clamp score change of the last action - lastActionUnixMs: timestamp of the last action This lets external tooling display per-peer downscore reasons (e.g. "Lodestar downscored Lighthouse for invalid_request") without having to scrape Prometheus labels or logs. Captured in RealScore.add via an optional actionName parameter; only explicit reportPeer paths set it, so the gossipsub heartbeat decay path does not clobber the metadata. --- packages/api/src/beacon/routes/lodestar.ts | 6 ++++ .../src/network/peers/score/interface.ts | 8 ++++- .../src/network/peers/score/score.ts | 33 +++++++++++++++++-- .../src/network/peers/score/store.ts | 2 +- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index f29dd646d183..78bf24c19f29 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -61,6 +61,12 @@ export type PeerScoreStat = { ignoreNegativeGossipScore: boolean; score: number; lastUpdate: number; + /** Name of the most recent `reportPeer` action applied, or null if no action has been applied. */ + lastActionName: string | null; + /** Effective change to `lodestarScore` produced by the last action (post score clamp). */ + lastActionDeltaScore: number; + /** Unix timestamp (ms) at which the last action was applied. */ + lastActionUnixMs: number; }; export type GossipPeerScoreStat = { diff --git a/packages/beacon-node/src/network/peers/score/interface.ts b/packages/beacon-node/src/network/peers/score/interface.ts index 8eb1c53c5332..85b0500420d3 100644 --- a/packages/beacon-node/src/network/peers/score/interface.ts +++ b/packages/beacon-node/src/network/peers/score/interface.ts @@ -23,7 +23,7 @@ export interface IPeerScore { getScore(): number; getGossipScore(): number; isCoolingDown(): boolean; - add(scoreDelta: number): number; + add(scoreDelta: number, actionName?: string): number; update(): number; updateGossipsubScore(newScore: number, ignore: boolean): void; getStat(): PeerScoreStat; @@ -51,6 +51,12 @@ export type PeerScoreStat = { ignoreNegativeGossipScore: boolean; score: number; lastUpdate: number; + /** Name of the most recent `reportPeer` action applied, or null if no action has been applied. */ + lastActionName: string | null; + /** Effective change to `lodestarScore` produced by the last action (post score clamp). */ + lastActionDeltaScore: number; + /** Unix timestamp (ms) at which the last action was applied. */ + lastActionUnixMs: number; }; export enum PeerAction { diff --git a/packages/beacon-node/src/network/peers/score/score.ts b/packages/beacon-node/src/network/peers/score/score.ts index 307cce8bc8f6..ff6e9e196bfe 100644 --- a/packages/beacon-node/src/network/peers/score/score.ts +++ b/packages/beacon-node/src/network/peers/score/score.ts @@ -23,6 +23,15 @@ export class RealScore implements IPeerScore { /** The final score, computed from the above */ private score: number; private lastUpdate: number; + /** + * Tracks the most recent explicit `reportPeer` action applied to this peer. + * Updated only via `add()` (i.e. when an actionName is provided) and never by + * decay in `update()` or by gossipsub score sync, so it represents the last + * intentional scoring decision made by lodestar. + */ + private lastActionName: string | null; + private lastActionDeltaScore: number; + private lastActionUnixMs: number; constructor() { this.lodestarScore = DEFAULT_SCORE; @@ -30,6 +39,9 @@ export class RealScore implements IPeerScore { this.score = DEFAULT_SCORE; this.ignoreNegativeGossipScore = false; this.lastUpdate = Date.now(); + this.lastActionName = null; + this.lastActionDeltaScore = 0; + this.lastActionUnixMs = 0; } isCoolingDown(): boolean { @@ -44,12 +56,23 @@ export class RealScore implements IPeerScore { return this.gossipScore; } - add(scoreDelta: number): number { + add(scoreDelta: number, actionName?: string): number { + const preScore = this.lodestarScore; let newScore = this.lodestarScore + scoreDelta; if (newScore > MAX_SCORE) newScore = MAX_SCORE; if (newScore < MIN_SCORE) newScore = MIN_SCORE; this.setLodestarScore(newScore); + + // Only record metadata when an actionName is provided so that decay-style + // updates (which also funnel through setLodestarScore) cannot clobber the + // last explicit reportPeer reason. + if (actionName !== undefined) { + this.lastActionName = actionName; + this.lastActionDeltaScore = newScore - preScore; + this.lastActionUnixMs = Date.now(); + } + return newScore; } @@ -116,6 +139,9 @@ export class RealScore implements IPeerScore { ignoreNegativeGossipScore: this.ignoreNegativeGossipScore, score: this.score, lastUpdate: this.lastUpdate, + lastActionName: this.lastActionName, + lastActionDeltaScore: this.lastActionDeltaScore, + lastActionUnixMs: this.lastActionUnixMs, }; } @@ -174,7 +200,7 @@ export class MaxScore implements IPeerScore { return false; } - add(): number { + add(_scoreDelta: number, _actionName?: string): number { return DEFAULT_SCORE; } @@ -195,6 +221,9 @@ export class MaxScore implements IPeerScore { ignoreNegativeGossipScore: false, score: MAX_SCORE, lastUpdate: Date.now(), + lastActionName: null, + lastActionDeltaScore: 0, + lastActionUnixMs: 0, }; } } diff --git a/packages/beacon-node/src/network/peers/score/store.ts b/packages/beacon-node/src/network/peers/score/store.ts index d2c1e19d3b2b..0c29084e3bfc 100644 --- a/packages/beacon-node/src/network/peers/score/store.ts +++ b/packages/beacon-node/src/network/peers/score/store.ts @@ -58,7 +58,7 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore { applyAction(peer: PeerId, action: PeerAction, actionName: string): void { const peerScore = this.scores.getOrDefault(peer.toString()); const scoreChange = peerActionScore[action]; - const newScore = peerScore.add(scoreChange); + const newScore = peerScore.add(scoreChange, actionName); this.logger?.debug("peer score adjusted", {scoreChange, newScore, peerId: prettyPrintPeerId(peer), actionName}); this.metrics?.peersReportPeerCount.inc({reason: actionName}); From 8920c42e4f88c07faa9d4ccef88f2f7305ce86e4 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Thu, 14 May 2026 09:51:49 +0200 Subject: [PATCH 2/4] feat(beacon-api): expose score fields on /eth/v1/node/peers Adds four optional fields to the NodePeer payload returned by GET /eth/v1/node/peers and GET /eth/v1/node/peers/{peer_id}: - agent_version: libp2p identify agent string - score: the composite lodestar score for the peer - disconnect_reason: controlled vocab last-disconnect reason (always omitted by lodestar for now; left in the type for spec parity) - downscore_reasons: controlled vocab `PeerScoreReason` derived from the most recent reportPeer action recorded by PeerRpcScoreStore The fields are emitted as snake_case keys alongside the existing peer fields, mirroring the WIP beacon-API peer-scoring extension. They are omitted entirely when no data is available so existing consumers see no schema change. Implementation notes: - NodePeer keeps its SSZ container for the core spec fields and gains TS-only optional extras; getPeers/getPeer override toJson/fromJson to round-trip the extras without inventing new SSZ types. - PeerRpcScoreStore.getStatByPeerId surfaces the per-peer stat without inserting a default entry, so the read path stays side-effect free. - mapPeerScoreReason translates lodestar's reportPeer actionName strings (RequestErrorCode values + gossip/sync labels) to the controlled vocab so external tooling does not need to know lodestar-specific identifiers. --- packages/api/src/beacon/routes/node.ts | 97 ++++++++++++++++++- .../beacon-node/src/api/impl/node/utils.ts | 73 ++++++++++++++ .../src/network/core/networkCore.ts | 10 +- .../src/network/peers/peerManager.ts | 13 ++- .../src/network/peers/score/interface.ts | 7 ++ .../src/network/peers/score/store.ts | 14 ++- 6 files changed, 206 insertions(+), 8 deletions(-) diff --git a/packages/api/src/beacon/routes/node.ts b/packages/api/src/beacon/routes/node.ts index f6b3d77d0bff..e637d5dc89f2 100644 --- a/packages/api/src/beacon/routes/node.ts +++ b/packages/api/src/beacon/routes/node.ts @@ -80,11 +80,87 @@ export const NodePeerType = new ContainerType( ); export const NodePeersType = ArrayOf(NodePeerType); -export type NodePeer = ValueOf; -export type NodePeers = ValueOf; +export type NodePeer = ValueOf & { + /** + * libp2p `agentVersion` identify string for the peer (e.g. `Lighthouse/v5.4.0-...`). + * Optional per the beacon-API peer-scoring extension. + */ + agentVersion?: string; + /** + * Composite lodestar score for the peer at the time of the request. + * Optional per the beacon-API peer-scoring extension. + */ + score?: number; + /** + * Reason associated with the most recent disconnect, mapped to the controlled + * `PeerDisconnectReason` vocabulary. Omitted when no disconnect has been observed + * or the reason cannot be classified. + */ + disconnectReason?: string; + /** + * Reasons that contributed to the peer's most recent downscore, mapped to the + * controlled `PeerScoreReason` vocabulary. Omitted when no downscore action has + * been applied. Lodestar surfaces the most recent action only, so the array will + * typically contain a single entry. + */ + downscoreReasons?: string[]; +}; +export type NodePeers = NodePeer[]; export type PeersMeta = {count: number}; +/** Snake-case keys for the optional peer-scoring extension fields. */ +type NodePeerJsonExtras = { + agent_version?: string; + score?: number; + disconnect_reason?: string; + downscore_reasons?: string[]; +}; + +/** + * Serialize a NodePeer to JSON. Uses the SSZ container for the core fields + * (so the spec wire format is preserved) and appends the optional + * peer-scoring extension fields only when present. + */ +function nodePeerToJson(peer: NodePeer): unknown { + const json = NodePeerType.toJson(peer) as Record; + if (peer.agentVersion !== undefined) { + (json as NodePeerJsonExtras).agent_version = peer.agentVersion; + } + if (peer.score !== undefined) { + (json as NodePeerJsonExtras).score = peer.score; + } + if (peer.disconnectReason !== undefined) { + (json as NodePeerJsonExtras).disconnect_reason = peer.disconnectReason; + } + if (peer.downscoreReasons !== undefined) { + (json as NodePeerJsonExtras).downscore_reasons = peer.downscoreReasons; + } + return json; +} + +/** + * Inverse of nodePeerToJson. Lifts optional extension fields back onto the + * decoded NodePeer when present. + */ +function nodePeerFromJson(json: unknown): NodePeer { + const peer = NodePeerType.fromJson(json) as NodePeer; + const obj = json as NodePeerJsonExtras; + if (typeof obj.agent_version === "string") { + peer.agentVersion = obj.agent_version; + } + if (typeof obj.score === "number") { + peer.score = obj.score; + } + if (typeof obj.disconnect_reason === "string") { + peer.disconnectReason = obj.disconnect_reason; + } + if (Array.isArray(obj.downscore_reasons)) { + peer.downscoreReasons = obj.downscore_reasons.filter((s): s is string => typeof s === "string"); + } + return peer; +} + export type PeerCount = ValueOf; export type FilterGetPeers = { @@ -293,7 +369,16 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions (data as NodePeer[]).map(nodePeerToJson), + fromJson: (json) => { + if (!Array.isArray(json)) { + throw Error("JSON peers payload must be an array"); + } + return json.map(nodePeerFromJson); + }, + }, meta: { toJson: (d) => d, fromJson: (d) => ({count: (d as PeersMeta).count}), @@ -316,7 +401,11 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions nodePeerToJson(data as NodePeer), + fromJson: (json) => nodePeerFromJson(json), + }, meta: EmptyMetaCodec, onlySupport: WireFormat.json, }, diff --git a/packages/beacon-node/src/api/impl/node/utils.ts b/packages/beacon-node/src/api/impl/node/utils.ts index 1a852e4cad1c..d005a37d78e0 100644 --- a/packages/beacon-node/src/api/impl/node/utils.ts +++ b/packages/beacon-node/src/api/impl/node/utils.ts @@ -17,6 +17,79 @@ export function formatNodePeer(peerIdStr: string, connections: Connection[]): ro }; } +/** + * Controlled vocabulary for downscore reasons surfaced on the beacon API + * `/eth/v1/node/peers` extension. Mirrors the proposed `PeerScoreReason` enum + * so consumers don't have to know lodestar's internal `actionName` strings. + */ +export const PEER_SCORE_REASON = { + RpcInvalidRequest: "rpc_invalid_request", + RpcInvalidResponse: "rpc_invalid_response", + RpcRateLimited: "rpc_rate_limited", + RpcTimeout: "rpc_timeout", + RpcIoError: "rpc_io_error", + RpcBadBlocksByRange: "rpc_bad_blocks_by_range", + RpcBadBlocksByRoot: "rpc_bad_blocks_by_root", + GossipInvalidBlock: "gossip_invalid_block", + GossipInvalidAttestation: "gossip_invalid_attestation", + GossipInvalidBlobSidecar: "gossip_invalid_blob_sidecar", + GossipInvalidDataColumnSidecar: "gossip_invalid_data_column_sidecar", + SyncBadBatch: "sync_bad_batch", + StatusUnviableFork: "status_unviable_fork", + BehaviourPenalty: "behaviour_penalty", + Unknown: "unknown", +} as const; + +/** + * Map lodestar's native `reportPeer` action label to the controlled + * `PeerScoreReason` vocabulary. Returns "unknown" for actions we don't have + * an explicit mapping for so the API stays forward-compatible. + */ +export function mapPeerScoreReason(actionName: string | null): string { + if (actionName === null || actionName === "") return PEER_SCORE_REASON.Unknown; + + switch (actionName) { + case "REQUEST_ERROR_INVALID_REQUEST": + return PEER_SCORE_REASON.RpcInvalidRequest; + case "REQUEST_ERROR_INVALID_RESPONSE_SSZ": + return PEER_SCORE_REASON.RpcInvalidResponse; + case "REQUEST_ERROR_SERVER_ERROR": + case "RESOURCE_UNAVAILABLE_ERROR": + case "REQUEST_ERROR_UNKNOWN_ERROR_STATUS": + case "REQUEST_ERROR_EMPTY_RESPONSE": + case "SSZ_SNAPPY_ERROR_OVER_SSZ_MAX_SIZE": + return PEER_SCORE_REASON.RpcInvalidResponse; + case "REQUEST_ERROR_RATE_LIMITED": + case "REQUEST_ERROR_SELF_RATE_LIMITED": + case "RESPONSE_ERROR_RATE_LIMITED": + case "rate_limit_rpc": + return PEER_SCORE_REASON.RpcRateLimited; + case "REQUEST_ERROR_REQUEST_TIMEOUT": + case "REQUEST_ERROR_RESP_TIMEOUT": + case "REQUEST_ERROR_DIAL_TIMEOUT": + return PEER_SCORE_REASON.RpcTimeout; + case "REQUEST_ERROR_DIAL_ERROR": + case "REQUEST_ERROR_REQUEST_ERROR": + return PEER_SCORE_REASON.RpcIoError; + case "BAD_BLOCKS_BY_RANGE": + case "BadSyncBlocks": + return PEER_SCORE_REASON.RpcBadBlocksByRange; + case "BAD_BLOCKS_BY_ROOT": + case "BadBlockByRoot": + return PEER_SCORE_REASON.RpcBadBlocksByRoot; + case "BadGossipBlock": + return PEER_SCORE_REASON.GossipInvalidBlock; + case "SyncChainInvalidBatchSelf": + case "SyncChainInvalidBatchOther": + case "SyncChainMaxProcessingAttempts": + return PEER_SCORE_REASON.SyncBadBatch; + case "GOSSIPSUB_LOW": + return PEER_SCORE_REASON.BehaviourPenalty; + default: + return PEER_SCORE_REASON.Unknown; + } +} + /** * From a list of connections, get the most relevant of a peer * - The first open connection if any diff --git a/packages/beacon-node/src/network/core/networkCore.ts b/packages/beacon-node/src/network/core/networkCore.ts index 6e78e1acc3dc..93b4849d1cb2 100644 --- a/packages/beacon-node/src/network/core/networkCore.ts +++ b/packages/beacon-node/src/network/core/networkCore.ts @@ -9,7 +9,7 @@ import type {LoggerNode} from "@lodestar/logger/node"; import {isForkPostFulu} from "@lodestar/params"; import {ResponseIncoming} from "@lodestar/reqresp"; import {Epoch, Status, fulu, sszTypesFor} from "@lodestar/types"; -import {formatNodePeer} from "../../api/impl/node/utils.js"; +import {formatNodePeer, mapPeerScoreReason} from "../../api/impl/node/utils.js"; import {RegistryMetricCreator} from "../../metrics/index.js"; import {ClockEvent, IClock} from "../../util/clock.js"; import {CustodyConfig} from "../../util/dataColumns.js"; @@ -473,9 +473,15 @@ export class NetworkCore implements INetworkCore { (peerData.status as fulu.Status).earliestAvailableSlot = (peerData.status as fulu.Status).earliestAvailableSlot ?? 0; } + const scoreStat = this.peerManager.getPeerScoreStat(peerIdStr); + const agentVersion = peerData?.agentVersion ?? "NA"; + const downscoreReasons = + scoreStat && scoreStat.lastActionName !== null ? [mapPeerScoreReason(scoreStat.lastActionName)] : undefined; return { ...formatNodePeer(peerIdStr, connections), - agentVersion: peerData?.agentVersion ?? "NA", + agentVersion, + score: scoreStat ? scoreStat.score : undefined, + downscoreReasons, 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"), diff --git a/packages/beacon-node/src/network/peers/peerManager.ts b/packages/beacon-node/src/network/peers/peerManager.ts index ca8c8892dd6d..543eb5f65741 100644 --- a/packages/beacon-node/src/network/peers/peerManager.ts +++ b/packages/beacon-node/src/network/peers/peerManager.ts @@ -25,7 +25,14 @@ import {ClientKind, getKnownClientFromAgentVersion} from "./client.js"; import {PeerDiscovery, SubnetDiscvQueryMs} from "./discover.js"; import {PeerData, PeersData} from "./peersData.js"; import {NO_COOL_DOWN_APPLIED} from "./score/constants.js"; -import {IPeerRpcScoreStore, PeerAction, PeerScoreStats, ScoreState, updateGossipsubScores} from "./score/index.js"; +import { + IPeerRpcScoreStore, + PeerAction, + PeerScoreStat, + PeerScoreStats, + ScoreState, + updateGossipsubScores, +} from "./score/index.js"; import { assertPeerRelevance, getConnectedPeerIds, @@ -293,6 +300,10 @@ export class PeerManager { return this.peerRpcScores.dumpPeerScoreStats(); } + getPeerScoreStat(peerIdStr: PeerIdStr): PeerScoreStat | null { + return this.peerRpcScores.getStatByPeerId(peerIdStr); + } + /** * Must be called when network ReqResp receives incoming requests */ diff --git a/packages/beacon-node/src/network/peers/score/interface.ts b/packages/beacon-node/src/network/peers/score/interface.ts index 85b0500420d3..083c3ba52c68 100644 --- a/packages/beacon-node/src/network/peers/score/interface.ts +++ b/packages/beacon-node/src/network/peers/score/interface.ts @@ -13,6 +13,13 @@ export interface IPeerRpcScoreStore { getScoreState(peer: PeerId): ScoreState; isCoolingDown(peer: PeerIdStr): boolean; dumpPeerScoreStats(): PeerScoreStats; + /** + * Returns the score stat for a single peer, or null if no entry exists yet. + * Unlike getScore() this does not lazily create a default entry, so callers + * (e.g. read-only beacon API handlers) can introspect known peers without + * polluting the store. + */ + getStatByPeerId(peerIdStr: PeerIdStr): PeerScoreStat | null; applyAction(peer: PeerId, action: PeerAction, actionName: string): void; applyReconnectionCoolDown(peer: PeerIdStr, reason: GoodByeReasonCode): number; update(): void; diff --git a/packages/beacon-node/src/network/peers/score/store.ts b/packages/beacon-node/src/network/peers/score/store.ts index 0c29084e3bfc..9aee38500791 100644 --- a/packages/beacon-node/src/network/peers/score/store.ts +++ b/packages/beacon-node/src/network/peers/score/store.ts @@ -5,7 +5,15 @@ import {PeerIdStr} from "../../../util/peerId.js"; import {NetworkCoreMetrics} from "../../core/metrics.js"; import {prettyPrintPeerId} from "../../util.js"; import {DEFAULT_SCORE, MAX_ENTRIES, MAX_SCORE, MIN_SCORE, SCORE_THRESHOLD} from "./constants.js"; -import {IPeerRpcScoreStore, IPeerScore, PeerAction, PeerRpcScoreOpts, PeerScoreStats, ScoreState} from "./interface.js"; +import { + IPeerRpcScoreStore, + IPeerScore, + PeerAction, + PeerRpcScoreOpts, + PeerScoreStat, + PeerScoreStats, + ScoreState, +} from "./interface.js"; import {MaxScore, RealScore} from "./score.js"; import {scoreToState} from "./utils.js"; @@ -55,6 +63,10 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore { return Array.from(this.scores.entries()).map(([peerId, peerScore]) => ({peerId, ...peerScore.getStat()})); } + getStatByPeerId(peerIdStr: PeerIdStr): PeerScoreStat | null { + return this.scores.get(peerIdStr)?.getStat() ?? null; + } + applyAction(peer: PeerId, action: PeerAction, actionName: string): void { const peerScore = this.scores.getOrDefault(peer.toString()); const scoreChange = peerActionScore[action]; From 4ee529f8310e5d61ed32c69d1305bd57dcc5fda7 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Thu, 14 May 2026 09:56:53 +0200 Subject: [PATCH 3/4] feat(network): record per-peer goodbye reason for /eth/v1/node/peers disconnect_reason Adds a bounded `lastDisconnectByPeerId` map to `PeerManager` that captures the most recent goodbye code observed for each peer, both for outbound goodbyes (sent by lodestar via `goodbyeAndDisconnect`) and inbound ones (received via the GOODBYE RPC handler). Inbound disconnects without a goodbye (e.g. Nimbus) are recorded as `INBOUND_DISCONNECT` so the API surface is non-empty for those peers too. `networkCore._dumpPeer` now reads this via `peerManager.getLastDisconnect` and maps the `GoodByeReasonCode` to the controlled `PeerDisconnectReason` vocabulary (`client_shutdown`, `irrelevant_network`, `io_error`, `unviable_fork`, `too_many_peers`, `bad_score`, `inbound_disconnect`, `unknown`) before placing it on the `disconnect_reason` field added in the prior commit. The map is bounded to 1024 entries with FIFO eviction to cap memory usage while still surfacing recent disconnects for tooling that polls `/eth/v1/node/peers`. --- .../beacon-node/src/api/impl/node/utils.ts | 43 ++++++++++++++++ .../src/network/core/networkCore.ts | 5 +- .../src/network/peers/peerManager.ts | 49 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/node/utils.ts b/packages/beacon-node/src/api/impl/node/utils.ts index d005a37d78e0..6548ce1a193a 100644 --- a/packages/beacon-node/src/api/impl/node/utils.ts +++ b/packages/beacon-node/src/api/impl/node/utils.ts @@ -1,5 +1,6 @@ import type {Connection, ConnectionStatus} from "@libp2p/interface"; import {routes} from "@lodestar/api"; +import {GoodByeReasonCode} from "../../../constants/network.js"; /** * Format a list of connections from libp2p connections manager into the API's format NodePeer @@ -90,6 +91,48 @@ export function mapPeerScoreReason(actionName: string | null): string { } } +/** + * Controlled vocabulary for the most recent peer disconnect reason surfaced + * on the beacon API `/eth/v1/node/peers` extension. Mirrors the proposed + * `PeerDisconnectReason` enum. + */ +export const PEER_DISCONNECT_REASON = { + ClientShutdown: "client_shutdown", + IrrelevantNetwork: "irrelevant_network", + IoError: "io_error", + UnviableFork: "unviable_fork", + TooManyPeers: "too_many_peers", + BadScore: "bad_score", + InboundDisconnect: "inbound_disconnect", + Unknown: "unknown", +} as const; + +/** + * Map lodestar's `GoodByeReasonCode` to the controlled + * `PeerDisconnectReason` vocabulary. Returns "unknown" for codes we don't + * have an explicit mapping for so the API stays forward-compatible. + */ +export function mapDisconnectReason(code: GoodByeReasonCode | number): string { + switch (code) { + case GoodByeReasonCode.CLIENT_SHUTDOWN: + return PEER_DISCONNECT_REASON.ClientShutdown; + case GoodByeReasonCode.IRRELEVANT_NETWORK: + return PEER_DISCONNECT_REASON.IrrelevantNetwork; + case GoodByeReasonCode.ERROR: + return PEER_DISCONNECT_REASON.IoError; + case GoodByeReasonCode.TOO_MANY_PEERS: + return PEER_DISCONNECT_REASON.TooManyPeers; + case GoodByeReasonCode.SCORE_TOO_LOW: + case GoodByeReasonCode.BANNED: + return PEER_DISCONNECT_REASON.BadScore; + case GoodByeReasonCode.INBOUND_DISCONNECT: + return PEER_DISCONNECT_REASON.InboundDisconnect; + default: + if (code === 128) return PEER_DISCONNECT_REASON.UnviableFork; + return PEER_DISCONNECT_REASON.Unknown; + } +} + /** * From a list of connections, get the most relevant of a peer * - The first open connection if any diff --git a/packages/beacon-node/src/network/core/networkCore.ts b/packages/beacon-node/src/network/core/networkCore.ts index 93b4849d1cb2..a968ff946e0a 100644 --- a/packages/beacon-node/src/network/core/networkCore.ts +++ b/packages/beacon-node/src/network/core/networkCore.ts @@ -9,7 +9,7 @@ import type {LoggerNode} from "@lodestar/logger/node"; import {isForkPostFulu} from "@lodestar/params"; import {ResponseIncoming} from "@lodestar/reqresp"; import {Epoch, Status, fulu, sszTypesFor} from "@lodestar/types"; -import {formatNodePeer, mapPeerScoreReason} from "../../api/impl/node/utils.js"; +import {formatNodePeer, mapDisconnectReason, mapPeerScoreReason} from "../../api/impl/node/utils.js"; import {RegistryMetricCreator} from "../../metrics/index.js"; import {ClockEvent, IClock} from "../../util/clock.js"; import {CustodyConfig} from "../../util/dataColumns.js"; @@ -477,10 +477,13 @@ export class NetworkCore implements INetworkCore { const agentVersion = peerData?.agentVersion ?? "NA"; const downscoreReasons = scoreStat && scoreStat.lastActionName !== null ? [mapPeerScoreReason(scoreStat.lastActionName)] : undefined; + const lastDisconnect = this.peerManager.getLastDisconnect(peerIdStr); + const disconnectReason = lastDisconnect ? mapDisconnectReason(lastDisconnect.code) : undefined; return { ...formatNodePeer(peerIdStr, connections), agentVersion, score: scoreStat ? scoreStat.score : undefined, + disconnectReason, downscoreReasons, status: peerData?.status ? sszTypesFor(fork).Status.toJson(peerData.status) : null, metadata: peerData?.metadata ? sszTypesFor(fork).Metadata.toJson(peerData.metadata) : null, diff --git a/packages/beacon-node/src/network/peers/peerManager.ts b/packages/beacon-node/src/network/peers/peerManager.ts index 543eb5f65741..2a90a09e0bdc 100644 --- a/packages/beacon-node/src/network/peers/peerManager.ts +++ b/packages/beacon-node/src/network/peers/peerManager.ts @@ -75,6 +75,19 @@ const STARVATION_PRUNE_RATIO = 0.05; */ const ALLOWED_NEGATIVE_GOSSIPSUB_FACTOR = 0.1; +/** + * Maximum number of last-disconnect entries to keep in memory. FIFO eviction + * keeps the map bounded while still allowing observability of recent + * disconnects via the beacon API peer-scoring extension. + */ +const LAST_DISCONNECT_MAX_SIZE = 1024; + +export type LastDisconnectInfo = { + code: GoodByeReasonCode; + sentByUs: boolean; + at: number; +}; + // TODO: // maxPeers and targetPeers should be dynamic on the num of validators connected // The Node should compute a recommended value every interval and log a warning @@ -169,6 +182,13 @@ export class PeerManager { // A single map of connected peers with all necessary data to handle PINGs, STATUS, and metrics private connectedPeers: Map; + /** + * Tracks the most recent goodbye/disconnect reason per peer. Bounded with a + * FIFO eviction policy via insertion order in the Map. Retained across + * disconnects so the beacon API peer-scoring extension can surface a + * `disconnect_reason` for peers that are no longer connected. + */ + private lastDisconnectByPeerId: Map = new Map(); private opts: PeerManagerOpts; private intervals: NodeJS.Timeout[] = []; @@ -304,6 +324,29 @@ export class PeerManager { return this.peerRpcScores.getStatByPeerId(peerIdStr); } + /** + * Record the most recent goodbye reason for a peer. `sentByUs` is true when + * lodestar initiated the goodbye, false when the remote peer sent it to us. + * Bounded to `LAST_DISCONNECT_MAX_SIZE` entries with FIFO eviction. + */ + recordDisconnect(peerIdStr: PeerIdStr, code: GoodByeReasonCode, sentByUs: boolean): void { + if (this.lastDisconnectByPeerId.has(peerIdStr)) { + this.lastDisconnectByPeerId.delete(peerIdStr); + } else if (this.lastDisconnectByPeerId.size >= LAST_DISCONNECT_MAX_SIZE) { + const oldestKey = this.lastDisconnectByPeerId.keys().next().value; + if (oldestKey !== undefined) this.lastDisconnectByPeerId.delete(oldestKey); + } + this.lastDisconnectByPeerId.set(peerIdStr, {code, sentByUs, at: Date.now()}); + } + + /** + * Return the most recent disconnect record for a peer, or null if none has + * been observed (or it was evicted from the bounded map). + */ + getLastDisconnect(peerIdStr: PeerIdStr): LastDisconnectInfo | null { + return this.lastDisconnectByPeerId.get(peerIdStr) ?? null; + } + /** * Must be called when network ReqResp receives incoming requests */ @@ -400,6 +443,8 @@ export class PeerManager { this.metrics?.peerLongConnectionDisconnect.inc({reason}); } + this.recordDisconnect(peer.toString(), Number(goodbye) as GoodByeReasonCode, false); + void this.disconnect(peer); } @@ -851,6 +896,9 @@ export class PeerManager { const coolDownMin = this.peerRpcScores.applyReconnectionCoolDown(peerIdStr, GoodByeReasonCode.INBOUND_DISCONNECT); logMessage += ". Enforcing a reconnection cool-down period"; logContext.coolDownMin = coolDownMin; + if (!this.lastDisconnectByPeerId.has(peerIdStr)) { + this.recordDisconnect(peerIdStr, GoodByeReasonCode.INBOUND_DISCONNECT, false); + } } // remove the ping and status timer for the peer @@ -896,6 +944,7 @@ export class PeerManager { private async goodbyeAndDisconnect(peer: PeerId, goodbye: GoodByeReasonCode): Promise { const reason = GOODBYE_KNOWN_CODES[goodbye.toString()] || ""; const peerIdStr = peer.toString(); + this.recordDisconnect(peerIdStr, goodbye, true); try { this.metrics?.peerGoodbyeSent.inc({reason}); this.logger.debug("initiating goodbyeAndDisconnect peer", {reason, peerId: prettyPrintPeerId(peer)}); From 1f0d85047364b104f96cee3cf3338c61d2fe25f1 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 15 May 2026 10:03:07 +0200 Subject: [PATCH 4/4] fix(beacon-api): gate disconnect_reason on disconnected/disconnecting state Per the proposed beacon-API spec (https://github.com/ethereum/beacon-APIs/pull/606), `disconnect_reason` MUST only be populated when the peer's `state` is `disconnected` or `disconnecting`. Compute the node-peer view first and only attach a mapped `disconnectReason` when the resolved state matches. --- packages/beacon-node/src/network/core/networkCore.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/network/core/networkCore.ts b/packages/beacon-node/src/network/core/networkCore.ts index a968ff946e0a..3f914a2854a0 100644 --- a/packages/beacon-node/src/network/core/networkCore.ts +++ b/packages/beacon-node/src/network/core/networkCore.ts @@ -477,10 +477,14 @@ export class NetworkCore implements INetworkCore { const agentVersion = peerData?.agentVersion ?? "NA"; const downscoreReasons = scoreStat && scoreStat.lastActionName !== null ? [mapPeerScoreReason(scoreStat.lastActionName)] : undefined; + const nodePeer = formatNodePeer(peerIdStr, connections); const lastDisconnect = this.peerManager.getLastDisconnect(peerIdStr); - const disconnectReason = lastDisconnect ? mapDisconnectReason(lastDisconnect.code) : undefined; + const disconnectReason = + lastDisconnect && (nodePeer.state === "disconnected" || nodePeer.state === "disconnecting") + ? mapDisconnectReason(lastDisconnect.code) + : undefined; return { - ...formatNodePeer(peerIdStr, connections), + ...nodePeer, agentVersion, score: scoreStat ? scoreStat.score : undefined, disconnectReason,