Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/api/src/beacon/routes/lodestar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
97 changes: 93 additions & 4 deletions packages/api/src/beacon/routes/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,87 @@ export const NodePeerType = new ContainerType(
);
export const NodePeersType = ArrayOf(NodePeerType);

export type NodePeer = ValueOf<typeof NodePeerType>;
export type NodePeers = ValueOf<typeof NodePeersType>;
export type NodePeer = ValueOf<typeof NodePeerType> & {
/**
* 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<string, unknown>;
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<typeof PeerCountType>;

export type FilterGetPeers = {
Expand Down Expand Up @@ -293,7 +369,16 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions<Endpo
schema: {query: {state: Schema.StringArray, direction: Schema.StringArray}},
},
resp: {
data: NodePeersType,
data: {
...JsonOnlyResponseCodec.data,
toJson: (data) => (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}),
Expand All @@ -316,7 +401,11 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions<Endpo
schema: {params: {peer_id: Schema.StringRequired}},
},
resp: {
data: NodePeerType,
data: {
...JsonOnlyResponseCodec.data,
toJson: (data) => nodePeerToJson(data as NodePeer),
fromJson: (json) => nodePeerFromJson(json),
},
meta: EmptyMetaCodec,
onlySupport: WireFormat.json,
},
Expand Down
116 changes: 116 additions & 0 deletions packages/beacon-node/src/api/impl/node/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +18,121 @@ 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;
}
}

/**
* 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
Expand Down
13 changes: 11 additions & 2 deletions packages/beacon-node/src/network/core/networkCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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";
Expand Down Expand Up @@ -473,9 +473,18 @@ 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;
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;

return {
...formatNodePeer(peerIdStr, connections),
agentVersion: peerData?.agentVersion ?? "NA",
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,
agentClient: String(peerData?.agentClient ?? "Unknown"),
Expand Down
Loading