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
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
import static tech.pegasys.teku.infrastructure.http.RestApiConstants.CACHE_NONE;
import static tech.pegasys.teku.infrastructure.http.RestApiConstants.TAG_TEKU;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.RAW_DOUBLE_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.RAW_INTEGER_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.STRING_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.SerializableTypeDefinition.listOf;

import com.fasterxml.jackson.core.JsonProcessingException;
import io.javalin.http.Header;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.function.Function;
import tech.pegasys.teku.api.DataProvider;
import tech.pegasys.teku.api.NetworkDataProvider;
Expand All @@ -31,20 +34,29 @@
import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiEndpoint;
import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiRequest;
import tech.pegasys.teku.networking.eth2.peers.Eth2Peer;
import tech.pegasys.teku.networking.p2p.peer.Peer;
import tech.pegasys.teku.networking.p2p.reputation.ReputationManager;

public class GetPeersScore extends RestApiEndpoint {
public static final String ROUTE = "/teku/v1/nodes/peer_scores";
private final NetworkDataProvider network;

private static final SerializableTypeDefinition<Eth2Peer> PEER_TYPE =
SerializableTypeDefinition.object(Eth2Peer.class)
.withField("peer_id", STRING_TYPE, eth2Peer -> eth2Peer.getId().toBase58())
.withField("gossip_score", RAW_DOUBLE_TYPE, Peer::getGossipScore)
private static final SerializableTypeDefinition<LastAction> LAST_ACTION_TYPE =
SerializableTypeDefinition.object(LastAction.class)
.withField("reason", STRING_TYPE, LastAction::reason)
.withField("delta", RAW_DOUBLE_TYPE, LastAction::delta)
.withField("seconds_ago", RAW_INTEGER_TYPE, LastAction::secondsAgo)
.build();

private static final SerializableTypeDefinition<List<Eth2Peer>> RESPONSE_TYPE =
SerializableTypeDefinition.<List<Eth2Peer>>object()
private static final SerializableTypeDefinition<PeerScore> PEER_TYPE =
SerializableTypeDefinition.object(PeerScore.class)
.withField("peer_id", STRING_TYPE, PeerScore::peerId)
.withField("gossip_score", RAW_DOUBLE_TYPE, PeerScore::gossipScore)
.withOptionalField("reputation_score", RAW_INTEGER_TYPE, PeerScore::reputationScore)
.withOptionalField("last_action", LAST_ACTION_TYPE, PeerScore::lastAction)
.build();

private static final SerializableTypeDefinition<List<PeerScore>> RESPONSE_TYPE =
SerializableTypeDefinition.<List<PeerScore>>object()
.name("GetPeerScoresResponse")
.withField("data", listOf(PEER_TYPE), Function.identity())
.build();
Expand All @@ -68,6 +80,44 @@ public GetPeersScore(final DataProvider provider) {
@Override
public void handleRequest(final RestApiRequest request) throws JsonProcessingException {
request.header(Header.CACHE_CONTROL, CACHE_NONE);
request.respondOk(network.getPeerScores());
final ReputationManager reputationManager = network.getReputationManager();
final long nowMs = System.currentTimeMillis();
final List<PeerScore> data =
network.getPeerScores().stream().map(peer -> toPeerScore(peer, reputationManager, nowMs)).toList();
request.respondOk(data);
}

private static PeerScore toPeerScore(
final Eth2Peer peer, final ReputationManager reputationManager, final long nowMs) {
final Optional<Integer> reputationScore =
toBoxed(reputationManager.getReputationScore(peer.getId()));
final Optional<LastAction> lastAction =
reputationManager
.getLastAdjustment(peer.getId())
.map(
adjustment ->
new LastAction(
adjustment.reason(),
adjustment.delta(),
secondsSince(nowMs, adjustment.atMs())));
return new PeerScore(
peer.getId().toBase58(), peer.getGossipScore(), reputationScore, lastAction);
}

private static Optional<Integer> toBoxed(final OptionalInt value) {
return value.isPresent() ? Optional.of(value.getAsInt()) : Optional.empty();
}

private static int secondsSince(final long nowMs, final long thenMs) {
final long ageSeconds = Math.max(0L, (nowMs - thenMs) / 1000L);
return (int) Math.min(ageSeconds, Integer.MAX_VALUE);
}

record PeerScore(
String peerId,
Double gossipScore,
Optional<Integer> reputationScore,
Optional<LastAction> lastAction) {}

record LastAction(String reason, double delta, int secondsAgo) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import tech.pegasys.teku.api.DataProvider;
import tech.pegasys.teku.api.NetworkDataProvider;
import tech.pegasys.teku.api.peer.Eth2PeerWithEnr;
import tech.pegasys.teku.beaconrestapi.handlers.v1.node.GetPeers.PeerView;
import tech.pegasys.teku.infrastructure.json.types.SerializableTypeDefinition;
import tech.pegasys.teku.infrastructure.restapi.endpoints.EndpointMetadata;
import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiEndpoint;
Expand All @@ -35,8 +36,8 @@
public class GetPeerById extends RestApiEndpoint {
public static final String ROUTE = "/eth/v1/node/peers/{peer_id}";

private static final SerializableTypeDefinition<Eth2PeerWithEnr> PEERS_BY_ID_RESPONSE_TYPE =
SerializableTypeDefinition.object(Eth2PeerWithEnr.class)
private static final SerializableTypeDefinition<PeerView> PEERS_BY_ID_RESPONSE_TYPE =
SerializableTypeDefinition.object(PeerView.class)
.name("GetPeerResponse")
.withField("data", PEER_DATA_TYPE, Function.identity())
.build();
Expand Down Expand Up @@ -69,7 +70,7 @@ public void handleRequest(final RestApiRequest request) throws JsonProcessingExc
if (peer.isEmpty()) {
request.respondError(SC_NOT_FOUND, "Peer not found");
} else {
request.respondOk(peer.get());
request.respondOk(GetPeers.toPeerView(peer.get(), network.getReputationManager()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@

import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_OK;
import static tech.pegasys.teku.infrastructure.http.RestApiConstants.TAG_NODE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.RAW_DOUBLE_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.RAW_INTEGER_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.STRING_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.string;
import static tech.pegasys.teku.infrastructure.json.types.SerializableTypeDefinition.listOf;
import static tech.pegasys.teku.infrastructure.restapi.endpoints.CacheLength.NO_CACHE;

import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.function.Function;
import tech.pegasys.teku.api.DataProvider;
import tech.pegasys.teku.api.NetworkDataProvider;
Expand All @@ -34,6 +38,9 @@
import tech.pegasys.teku.infrastructure.restapi.endpoints.EndpointMetadata;
import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiEndpoint;
import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiRequest;
import tech.pegasys.teku.networking.eth2.peers.Eth2Peer;
import tech.pegasys.teku.networking.p2p.reputation.ReputationManager;
import tech.pegasys.teku.networking.p2p.reputation.ReputationManager.LastAdjustment;

public class GetPeers extends RestApiEndpoint {
public static final String ROUTE = "/eth/v1/node/peers";
Expand All @@ -44,42 +51,48 @@ public class GetPeers extends RestApiEndpoint {
private static final DeserializableTypeDefinition<Direction> DIRECTION_TYPE =
DeserializableTypeDefinition.enumOf(Direction.class);

static final SerializableTypeDefinition<Eth2PeerWithEnr> PEER_DATA_TYPE =
SerializableTypeDefinition.object(Eth2PeerWithEnr.class)
static final SerializableTypeDefinition<PeerView> PEER_DATA_TYPE =
SerializableTypeDefinition.object(PeerView.class)
.name("Peer")
.withField(
"peer_id",
string(
"Cryptographic hash of a peer’s public key. "
+ "'[Read more](https://docs.libp2p.io/concepts/peer-id/)",
"QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N"),
eth2Peer -> eth2Peer.peer().getId().toBase58())
view -> view.source().peer().getId().toBase58())
.withOptionalField(
"enr",
string(
"Ethereum node record. " + "[Read more](https://eips.ethereum.org/EIPS/eip-778)",
"enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrk"
+ "Tfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYp"
+ "Ma2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8"),
Eth2PeerWithEnr::enr)
view -> view.source().enr())
.withField(
"last_seen_p2p_address",
string(
"Multiaddr used in last peer connection. "
+ "[Read more](https://docs.libp2p.io/reference/glossary/#multiaddr)",
"/ip4/7.7.7.7/tcp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N"),
eth2Peer -> eth2Peer.peer().getAddress().toExternalForm())
view -> view.source().peer().getAddress().toExternalForm())
.withField(
"state",
STATE_TYPE,
eth2Peer -> eth2Peer.peer().isConnected() ? State.connected : State.disconnected)
view ->
view.source().peer().isConnected() ? State.connected : State.disconnected)
.withField(
"direction",
DIRECTION_TYPE,
eth2Peer ->
eth2Peer.peer().connectionInitiatedLocally()
view ->
view.source().peer().connectionInitiatedLocally()
? Direction.outbound
: Direction.inbound)
.withOptionalField("agent_version", STRING_TYPE, PeerView::agentVersion)
.withOptionalField("score", RAW_DOUBLE_TYPE, PeerView::score)
.withOptionalField("disconnect_reason", STRING_TYPE, PeerView::disconnectReason)
.withOptionalField(
"downscore_reasons", listOf(STRING_TYPE), PeerView::downscoreReasons)
.build();

private static final SerializableTypeDefinition<Integer> PEERS_META_TYPE =
Expand Down Expand Up @@ -119,19 +132,57 @@ public GetPeers(final DataProvider provider) {

@Override
public void handleRequest(final RestApiRequest request) throws JsonProcessingException {
request.respondOk(new PeersData(network.getEth2PeersWithEnr()), NO_CACHE);
final ReputationManager reputationManager = network.getReputationManager();
final List<PeerView> views =
network.getEth2PeersWithEnr().stream()
.map(source -> toPeerView(source, reputationManager))
.toList();
request.respondOk(new PeersData(views), NO_CACHE);
}

static PeerView toPeerView(
final Eth2PeerWithEnr source, final ReputationManager reputationManager) {
final Eth2Peer eth2Peer = source.peer();
final Optional<String> agentVersion = eth2Peer.getAgentVersion();
final OptionalInt reputationScore = reputationManager.getReputationScore(eth2Peer.getId());
final Optional<Double> score =
reputationScore.isPresent()
? Optional.of((double) reputationScore.getAsInt())
: Optional.empty();
final Optional<LastAdjustment> lastAdjustment =
reputationManager.getLastAdjustment(eth2Peer.getId());
// Per beacon-API spec, `disconnect_reason` MUST only be populated when the
// peer's `state` is `disconnected` or `disconnecting`. Teku exposes only
// `connected`/`disconnected` via `isConnected()`, so suppress for connected peers.
final Optional<String> disconnectReason =
eth2Peer.isConnected()
? Optional.empty()
: lastAdjustment.flatMap(
adj -> PeerScoreReasonMapper.mapDisconnectReason(adj.reason()));
final Optional<List<String>> downscoreReasons =
lastAdjustment
.flatMap(adj -> PeerScoreReasonMapper.mapDownscoreReason(adj.reason()))
.map(List::of);
return new PeerView(source, agentVersion, score, disconnectReason, downscoreReasons);
}

record PeerView(
Eth2PeerWithEnr source,
Optional<String> agentVersion,
Optional<Double> score,
Optional<String> disconnectReason,
Optional<List<String>> downscoreReasons) {}

static class PeersData {
private final List<Eth2PeerWithEnr> peers;
private final List<PeerView> peers;
private final Integer count;

PeersData(final List<Eth2PeerWithEnr> peers) {
PeersData(final List<PeerView> peers) {
this.peers = peers;
this.count = peers.size();
}

public List<Eth2PeerWithEnr> getPeers() {
public List<PeerView> getPeers() {
return peers;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright Consensys Software Inc., 2026
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

package tech.pegasys.teku.beaconrestapi.handlers.v1.node;

import java.util.Optional;
import java.util.Set;
import tech.pegasys.teku.networking.p2p.peer.DisconnectReason;
import tech.pegasys.teku.networking.p2p.reputation.ReputationAdjustment;

/**
* Maps Teku-internal reason identifiers (from {@link ReputationAdjustment} and {@link
* DisconnectReason}) onto the controlled vocabularies proposed for the simplified beacon-API peer
* scoring spec.
*
* <p>Teku's reputation adjustments are coarse (just LARGE/SMALL penalty/reward) and don't carry
* structured downscore reasons, so penalty adjustments map to {@code "unknown"}. Disconnect reasons
* map to the {@code PeerDisconnectReason} enum where there is a direct equivalent.
*/
final class PeerScoreReasonMapper {

private static final Set<String> REPUTATION_ADJUSTMENT_NAMES =
Set.of(
ReputationAdjustment.LARGE_PENALTY.name(),
ReputationAdjustment.SMALL_PENALTY.name(),
ReputationAdjustment.LARGE_REWARD.name(),
ReputationAdjustment.SMALL_REWARD.name());

private PeerScoreReasonMapper() {}

/**
* Map a raw Teku reason string (either a {@link ReputationAdjustment} name or a {@link
* DisconnectReason} name) onto the spec's {@code PeerScoreReason} vocabulary. Returns empty if
* the value is not a recognised reputation adjustment (e.g. it's a disconnect reason, which has
* its own mapping).
*/
static Optional<String> mapDownscoreReason(final String rawReason) {
if (rawReason == null) {
return Optional.empty();
}
if (!REPUTATION_ADJUSTMENT_NAMES.contains(rawReason)) {
return Optional.empty();
}
// Teku's reputation adjustments don't carry structured downscore context, so we surface
// "unknown" rather than guessing at a more specific code.
return Optional.of("unknown");
}

/**
* Map a raw Teku reason string onto the spec's {@code PeerDisconnectReason} vocabulary if it
* corresponds to a {@link DisconnectReason}, otherwise empty.
*/
static Optional<String> mapDisconnectReason(final String rawReason) {
if (rawReason == null) {
return Optional.empty();
}
final DisconnectReason reason;
try {
reason = DisconnectReason.valueOf(rawReason);
} catch (final IllegalArgumentException e) {
return Optional.empty();
}
return Optional.of(mapDisconnectReason(reason));
}

private static String mapDisconnectReason(final DisconnectReason reason) {
return switch (reason) {
case BAD_SCORE -> "bad_score";
case IRRELEVANT_NETWORK, UNABLE_TO_VERIFY_NETWORK -> "irrelevant_network";
case TOO_MANY_PEERS -> "too_many_peers";
case RATE_LIMITING -> "rate_limited";
case SHUTTING_DOWN -> "client_shutdown";
case REMOTE_FAULT, UNRESPONSIVE, NO_STATUS_RECEIVED, DUPLICATE_CONNECTION -> "io_error";
case BANNED -> "bad_score";
};
}
}
Loading
Loading