Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
19 changes: 16 additions & 3 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,22 @@ 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 nodePeer = formatNodePeer(peerIdStr, connections);
const lastDisconnect = this.peerManager.getLastDisconnect(peerIdStr);
const disconnectReason =
lastDisconnect && (nodePeer.state === "disconnected" || nodePeer.state === "disconnecting")
? mapDisconnectReason(lastDisconnect.code)
: undefined;
return {
...formatNodePeer(peerIdStr, connections),
agentVersion: peerData?.agentVersion ?? "NA",
...nodePeer,
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