Skip to content

feat(beacon-api): expose peer-scoring fields on /eth/v1/node/peers - #9373

Draft
barnabasbusa wants to merge 4 commits into
ChainSafe:unstablefrom
barnabasbusa:bbusa/peer-scores-last-action
Draft

feat(beacon-api): expose peer-scoring fields on /eth/v1/node/peers#9373
barnabasbusa wants to merge 4 commits into
ChainSafe:unstablefrom
barnabasbusa:bbusa/peer-scores-last-action

Conversation

@barnabasbusa

Copy link
Copy Markdown
Contributor

Summary

Extends /eth/v1/node/peers (and /eth/v1/node/peers/{peer_id}) with four optional fields per a simplified beacon-API spec extension currently under discussion:

  • agent_version — from libp2p peerstore
  • score — composite score via PeerRpcScoreStore.getScore(peerId)
  • disconnect_reason — from new bounded lastDisconnectByPeerId map on PeerManager
  • downscore_reasons — single-element array derived from existing lastActionName

Spec proposal

ethereum/beacon-APIs#606

What's in this PR

  1. API route schema (packages/api/src/beacon/routes/node.ts) — extends NodePeer with 4 optional fields plus custom nodePeerToJson / nodePeerFromJson round-tripping (snake_case wire names).
  2. PeerRpcScoreStore extension (packages/beacon-node/src/network/peers/score/{interface,store}.ts) — non-mutating getStatByPeerId().
  3. Disconnect tracking (packages/beacon-node/src/network/peers/peerManager.ts) — bounded lastDisconnectByPeerId map (FIFO, 1024) recorded on outbound goodbyeAndDisconnect, inbound onGoodbye, and bare libp2p onLibp2pPeerDisconnect (covers peers that drop without goodbye).
  4. Vocab translators (packages/beacon-node/src/api/impl/node/utils.ts) — mapPeerScoreReason and mapDisconnectReason.
  5. Population in _dumpPeer (packages/beacon-node/src/network/core/networkCore.ts).

Note on inbound_disconnect

This implementation surfaces a non-spec value inbound_disconnect when the peer drops the connection without sending goodbye. Worth discussing in #606 whether this should be added to the controlled vocab.

Coordinated implementations

Part of a coordinated multi-client effort — see ethereum/beacon-APIs#606 for the other five client PRs.

Status

Draft. pnpm build + pnpm check-types pass. Test suite not yet exercised end-to-end against the new fields; opening for cross-client coordination first.

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.
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.
…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`.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the peer-scoring extension for the beacon API, adding fields such as agent_version, score, disconnect_reason, and downscore_reasons to the /eth/v1/node/peers endpoint. The feedback identifies a logic issue where stale disconnection reasons are not cleared upon peer reconnection, which could lead to the API reporting outdated information. Additionally, it is suggested to only surface the disconnect_reason for peers that are not currently connected to improve the clarity of the response.

Comment on lines +899 to +901
if (!this.lastDisconnectByPeerId.has(peerIdStr)) {
this.recordDisconnect(peerIdStr, GoodByeReasonCode.INBOUND_DISCONNECT, false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a logic issue here because lastDisconnectByPeerId is not cleared when a peer reconnects.

  1. If a peer previously disconnected with a specific reason (e.g., TOO_MANY_PEERS), that entry remains in the map.
  2. If the peer reconnects and later drops the connection without a goodbye (an inbound disconnect), the check !this.lastDisconnectByPeerId.has(peerIdStr) will be false because of the stale entry from the previous session.
  3. Consequently, the new INBOUND_DISCONNECT will not be recorded, and the API will continue to report the old reason.

To fix this, this.lastDisconnectByPeerId.delete(peerIdStr) should be called in trackLibp2pConnection when a new peer session starts.

const downscoreReasons =
scoreStat && scoreStat.lastActionName !== null ? [mapPeerScoreReason(scoreStat.lastActionName)] : undefined;
const lastDisconnect = this.peerManager.getLastDisconnect(peerIdStr);
const disconnectReason = lastDisconnect ? mapDisconnectReason(lastDisconnect.code) : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Surfacing a disconnect_reason for a peer that is currently connected is confusing, as it would represent a reason from a previous session. It is better to only include this field when the peer is not in a connected state.

    const isConnected = connections.some((c) => c.status === "open");
    const disconnectReason = !isConnected && lastDisconnect ? mapDisconnectReason(lastDisconnect.code) : undefined;

… state

Per the proposed beacon-API spec
(ethereum/beacon-APIs#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants