From d83806f7004309c24c5ccc000839fdbb62d8cfb1 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Thu, 14 May 2026 09:50:43 +0200 Subject: [PATCH 1/3] feat(rest): expose application-level peer score on /eth/v1/node/peers Adds an optional `score` field to the `RestNodePeer` payload returned by `GET /eth/v1/node/peers` and `GET /eth/v1/node/peers/{peer_id}`, populated from `Peer.score` (Nimbus's internal application-level peer score). This field is not part of the beacon-API specification and is emitted alongside the existing `agent`/`proto` extensions. It is marked `Opt[int]` so JSON output remains backwards-compatible (omitted when not set), in line with `omitOptionalFields = true` for `RestJson`. --- beacon_chain/rpc/rest_node_api.nim | 13 ++++++++----- beacon_chain/spec/eth2_apis/rest_types.nim | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/beacon_chain/rpc/rest_node_api.nim b/beacon_chain/rpc/rest_node_api.nim index ef52d6eee4..41b59b21e0 100644 --- a/beacon_chain/rpc/rest_node_api.nim +++ b/beacon_chain/rpc/rest_node_api.nim @@ -203,9 +203,10 @@ proc installNodeApiHandlers*(router: var RestRouter, node: BeaconNode) = last_seen_p2p_address: getLastSeenAddress(node, peer.peerId), state: peer.connectionState.toString(), direction: peer.direction.toString(), - # Fields `agent` and `proto` are not part of specification + # Fields `agent`, `proto` and `score` are not part of specification agent: node.network.switch.peerStore[AgentBook][peer.peerId], - proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId] + proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId], + score: Opt.some(peer.score) ) res.add(peer) RestApiResponse.jsonResponseWMeta(res, (count: RestNumeric(len(res)))) @@ -247,9 +248,11 @@ proc installNodeApiHandlers*(router: var RestRouter, node: BeaconNode) = state: peer.connectionState.toString(), direction: peer.direction.toString(), agent: node.network.switch.peerStore[AgentBook][peer.peerId], - # Fields `agent` and `proto` are not part of specification - proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId] - # Fields `agent` and `proto` are not part of specification + # Fields `agent`, `proto` and `score` are not part of specification + proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId], + # Fields `agent`, `proto` and `score` are not part of specification + score: Opt.some(peer.score) + # Fields `agent`, `proto` and `score` are not part of specification ) ) diff --git a/beacon_chain/spec/eth2_apis/rest_types.nim b/beacon_chain/spec/eth2_apis/rest_types.nim index 0b94fa17d6..2fa3047d3f 100644 --- a/beacon_chain/spec/eth2_apis/rest_types.nim +++ b/beacon_chain/spec/eth2_apis/rest_types.nim @@ -256,6 +256,7 @@ type direction*: string agent*: string # This is not part of specification proto*: string # This is not part of specification + score*: Opt[int] # This is not part of specification RestSyncPeer* = object peer_id*: string From e202dd10cea12389134aca5a46e2a25aad407a74 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Thu, 14 May 2026 11:17:23 +0200 Subject: [PATCH 2/3] feat(rest): expose disconnect_reason on /eth/v1/node/peers Extends the `RestNodePeer` payload returned by `GET /eth/v1/node/peers` and `GET /eth/v1/node/peers/{peer_id}` with an optional `disconnect_reason` field. Population is driven by two new fields on the `Peer` object - `lastDisconnectReason` and `lastDisconnectAt` - that are written inside `Peer.disconnect()`. Centralising the write at the disconnect choke point captures every existing call site (~20) without per-site edits. `mapDisconnectReason` translates Nimbus's internal `DisconnectionReason` enum into a small controlled vocabulary aimed at cross-client consumption: ClientShutDown -> client_shutdown IrrelevantNetwork -> irrelevant_network FaultOrError -> io_error PeerScoreLow -> bad_score The field is `Opt[string]` so JSON output stays backwards compatible (omitted while never set) under `omitOptionalFields = true` for `RestJson`. --- beacon_chain/networking/eth2_network.nim | 4 +++ beacon_chain/rpc/rest_node_api.nim | 41 ++++++++++++++++++---- beacon_chain/spec/eth2_apis/rest_types.nim | 1 + 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/beacon_chain/networking/eth2_network.nim b/beacon_chain/networking/eth2_network.nim index 287b377a02..7779b7cca3 100644 --- a/beacon_chain/networking/eth2_network.nim +++ b/beacon_chain/networking/eth2_network.nim @@ -129,6 +129,8 @@ type direction*: PeerType disconnectedFut: Future[void] statistics*: SyncResponseStats + lastDisconnectReason*: Opt[DisconnectionReason] + lastDisconnectAt*: Opt[chronos.Moment] PeerAddr* = object peerId*: PeerId @@ -569,6 +571,8 @@ proc disconnect*(peer: Peer, reason: DisconnectionReason, # we currently don't - the fact that we're disconnecting is obvious and the # reason already known (wrong network is known from status message) or doesn't # greatly matter for the listening side (since it can't be trusted anyway) + peer.lastDisconnectReason = Opt.some(reason) + peer.lastDisconnectAt = Opt.some(chronos.Moment.now()) try: if peer.connectionState notin {Disconnecting, Disconnected}: peer.connectionState = Disconnecting diff --git a/beacon_chain/rpc/rest_node_api.nim b/beacon_chain/rpc/rest_node_api.nim index 41b59b21e0..e4d23e1e43 100644 --- a/beacon_chain/rpc/rest_node_api.nim +++ b/beacon_chain/rpc/rest_node_api.nim @@ -109,6 +109,19 @@ proc toString(direction: PeerType): string = of PeerType.Outgoing: "outbound" +proc mapDisconnectReason(reason: DisconnectionReason): string = + # Maps Nimbus's internal `DisconnectionReason` enum onto the controlled + # vocabulary proposed for `/eth/v1/node/peers` `disconnect_reason`. + case reason + of ClientShutDown: + "client_shutdown" + of IrrelevantNetwork: + "irrelevant_network" + of FaultOrError: + "io_error" + of PeerScoreLow: + "bad_score" + proc getLastSeenAddress(node: BeaconNode, id: PeerId): string = let address = node.network.switch.peerStore[LastSeenBook][id].valueOr: @@ -203,10 +216,16 @@ proc installNodeApiHandlers*(router: var RestRouter, node: BeaconNode) = last_seen_p2p_address: getLastSeenAddress(node, peer.peerId), state: peer.connectionState.toString(), direction: peer.direction.toString(), - # Fields `agent`, `proto` and `score` are not part of specification + # Fields `agent`, `proto`, `score` and `disconnect_reason` are not + # part of specification agent: node.network.switch.peerStore[AgentBook][peer.peerId], proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId], - score: Opt.some(peer.score) + score: Opt.some(peer.score), + disconnect_reason: + if peer.lastDisconnectReason.isSome(): + Opt.some(mapDisconnectReason(peer.lastDisconnectReason.get())) + else: + Opt.none(string) ) res.add(peer) RestApiResponse.jsonResponseWMeta(res, (count: RestNumeric(len(res)))) @@ -248,11 +267,21 @@ proc installNodeApiHandlers*(router: var RestRouter, node: BeaconNode) = state: peer.connectionState.toString(), direction: peer.direction.toString(), agent: node.network.switch.peerStore[AgentBook][peer.peerId], - # Fields `agent`, `proto` and `score` are not part of specification + # Fields `agent`, `proto`, `score` and `disconnect_reason` are not + # part of specification proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId], - # Fields `agent`, `proto` and `score` are not part of specification - score: Opt.some(peer.score) - # Fields `agent`, `proto` and `score` are not part of specification + # Fields `agent`, `proto`, `score` and `disconnect_reason` are not + # part of specification + score: Opt.some(peer.score), + # Fields `agent`, `proto`, `score` and `disconnect_reason` are not + # part of specification + disconnect_reason: + if peer.lastDisconnectReason.isSome(): + Opt.some(mapDisconnectReason(peer.lastDisconnectReason.get())) + else: + Opt.none(string) + # Fields `agent`, `proto`, `score` and `disconnect_reason` are not + # part of specification ) ) diff --git a/beacon_chain/spec/eth2_apis/rest_types.nim b/beacon_chain/spec/eth2_apis/rest_types.nim index 2fa3047d3f..3fbf4e3481 100644 --- a/beacon_chain/spec/eth2_apis/rest_types.nim +++ b/beacon_chain/spec/eth2_apis/rest_types.nim @@ -257,6 +257,7 @@ type agent*: string # This is not part of specification proto*: string # This is not part of specification score*: Opt[int] # This is not part of specification + disconnect_reason*: Opt[string] # This is not part of specification RestSyncPeer* = object peer_id*: string From 416f44e4b3bba48a31d722b00240d2af5788e293 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 15 May 2026 10:03:15 +0200 Subject: [PATCH 3/3] fix(rest): 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`. Gate the `peer.lastDisconnectReason` lookup in both the list and single-peer handlers on `peer.connectionState` so the field is omitted (Opt.none) for connected/connecting peers. --- beacon_chain/rpc/rest_node_api.nim | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/beacon_chain/rpc/rest_node_api.nim b/beacon_chain/rpc/rest_node_api.nim index e4d23e1e43..0243e6330f 100644 --- a/beacon_chain/rpc/rest_node_api.nim +++ b/beacon_chain/rpc/rest_node_api.nim @@ -221,8 +221,12 @@ proc installNodeApiHandlers*(router: var RestRouter, node: BeaconNode) = agent: node.network.switch.peerStore[AgentBook][peer.peerId], proto: node.network.switch.peerStore[ProtoVersionBook][peer.peerId], score: Opt.some(peer.score), + # Per beacon-API spec, `disconnect_reason` MUST only be populated + # when `state` is `disconnected` or `disconnecting`. disconnect_reason: - if peer.lastDisconnectReason.isSome(): + if peer.connectionState in + {ConnectionState.Disconnected, ConnectionState.Disconnecting} and + peer.lastDisconnectReason.isSome(): Opt.some(mapDisconnectReason(peer.lastDisconnectReason.get())) else: Opt.none(string) @@ -276,7 +280,11 @@ proc installNodeApiHandlers*(router: var RestRouter, node: BeaconNode) = # Fields `agent`, `proto`, `score` and `disconnect_reason` are not # part of specification disconnect_reason: - if peer.lastDisconnectReason.isSome(): + # Per beacon-API spec, `disconnect_reason` MUST only be populated + # when `state` is `disconnected` or `disconnecting`. + if peer.connectionState in + {ConnectionState.Disconnected, ConnectionState.Disconnecting} and + peer.lastDisconnectReason.isSome(): Opt.some(mapDisconnectReason(peer.lastDisconnectReason.get())) else: Opt.none(string)