A port of rskj's co.rsk.scoring package. Before this, rustock had one
comment mentioning "node reputation" and nothing behind it: a peer that fed
bad headers, stalled chunks or flooded the mempool was sidelined for a few
minutes and then welcomed back, with no ban, no operator control and no
visibility.
Every peer accumulates a counter per event type, keyed twice — once by
node id, once by IP address. A score and a goodReputation flag are derived
from those counters. Losing good reputation starts a punishment whose length
grows each time the same peer is punished again. When it expires the counters
reset and the peer is welcome back. Separately, an operator can ban an address
or a CIDR block outright; no amount of good behaviour lifts that.
rskj's own comment on recordEvent:
to collect events for the same node_id, but maybe with different address along the time. Or same address with different node id.
Scoring only by node id is defeated by regenerating a key, which costs nothing. Scoring only by address punishes everyone behind a NAT. Both, and either one can catch what the other misses.
This has a consequence for the code: most rustock callers hold only a node id,
and the address lives behind an async lock in PeerStore. ScoringService
therefore keeps a synchronous node-id → address map, updated when a session
starts and pruned when it ends, so record_peer(id, event) can reach both
keys. Without it the whole scheme quietly degrades to node ids.
public boolean hasGoodScore(PeerScoring scoring) {
//TODO(lsebrie): implement empty messages as responses so timeout can be handled as it should
return scoring.getEventCounter(EventType.INVALID_BLOCK) < 1 &&
scoring.getEventCounter(EventType.INVALID_MESSAGE) < 1 &&
scoring.getEventCounter(EventType.INVALID_HEADER) < 1;
}A peer can fail every handshake, time out every request and disconnect constantly without ever losing reputation. The exclusion of timeouts is an explicit TODO, not a decision — but reproducing it is still right: a node that punished on timeouts would exclude peers every rskj node still talks to, and disagreeing about peers is how a node ends up partitioned.
a_peer_that_is_only_unreliable_is_never_punished pins this from the other
direction, because the failure nobody notices is banning honest peers. It
runs 1,400 adversarially-shaped events — disconnections, timeouts, failed
handshakes, repeated and unexpected messages — and asserts nothing happens.
UNEXPECTED_MESSAGE, FAILED_HANDSHAKE, SUCCESSFUL_HANDSHAKE,
REPEATED_MESSAGE and TIMEOUT_MESSAGE fall through updateScoring's switch
with an empty body. They increment their counter and nothing else. So the
score is not a summary of the report — reading a score of 0 next to 400 failed
handshakes is correct, not a bug.
case INVALID_BLOCK: ...
if (score > 0) { score = 0; }
score--;A peer with a long good history is at −1 the instant it sends one invalid
block, exactly like a peer with no history. And in the other direction, the
increment branch is guarded by if (score >= 0), so a punished peer cannot
climb back out by being useful — only the punishment expiring clears it.
| initial | increment | maximum | rskj key | |
|---|---|---|---|---|
| node id | 12 min | +10% per repeat | none | scoring.nodes |
| address | 12 min | +10% per repeat | 6,000 min (~4.2 days) | scoring.addresses |
PunishmentCalculator.calculate grows the duration by the increment per
previous punishment, then multiplies by -score when the score is negative.
Two details are kept literally:
- the growth loop returns early at the maximum, before the score multiplier, so a capped punishment ignores the score entirely;
endPunishmentclears every counter and the score, but not the punishment counter — which is what makes the next punishment longer.
The asymmetry between the two rows is deliberate and worth stating: a node id is free to regenerate, so there is no cost to being wrong about one and no ceiling. An address may carry many honest peers behind a NAT, so being wrong about one is expensive and it is capped.
Punishment starts on the transition only. A peer already serving one is not re-punished by further events, so the counter — and therefore the next duration — grows once per episode rather than once per bad message.
recordEventAndStartPunishment counts the event first and refreshes the
reputation second:
peerScoring.updateScoring(event);
boolean hasBadReputationAlready = !peerScoring.refreshReputationAndPunishment();
if (hasBadReputationAlready) { return; }If the punishment has just expired, the refresh runs endPunishment, which
zeroes every counter — including the one just incremented. So a peer that
misbehaves once per punishment period never accumulates anything, on rskj or
here. Reproduced deliberately, and pinned by
the_first_event_after_an_expiry_is_swallowed_as_in_rskj.
The refresh has to happen on this path and not only when something asks about reputation. Otherwise a peer whose punishment expired while nobody was looking keeps stale counters, is never re-punished, and its punishment counter stops growing — so every later punishment is a first punishment.
sco_banAddress takes an address or a CIDR block. rskj's mask arithmetic is
(byte)(0xFF00 >> (cidr & 0x07)), which is zero exactly when the prefix is a
whole number of bytes; the same expression is used here so the two agree on
every prefix length, including the awkward ones like /20.
Two refusals are rskj's and are kept:
- loopback and wildcard addresses cannot be banned (
getAddressForBanthrows onisLoopbackAddress() || isAnyLocalAddress()), which stops an operator locking out their own tooling; /0is rejected (nbits <= 0), so nobody bans the internet by typo.
rskj's bans come from peer.bannedPeerIPs in the config file plus whatever
sco_banAddress added this process, and the second set is gone after a
restart. An operator who banned an abusive range at 3am did not mean "until
the next deploy".
Runtime bans are written to banned-peers.txt in the data directory and
reloaded at start-up. Plain text, one entry per line, # for comments, in
exactly the syntax sco_banAddress accepts — editable by hand and diffable.
The write goes to a temporary file and renames, so a crash mid-write leaves
the previous list rather than a truncated one, and a malformed line is
reported and skipped rather than discarding the rest of the file.
This is an addition to rskj's behaviour, not a divergence from it: a node with this file absent behaves exactly as rskj does.
Reputation is checked before the ECIES handshake. rskj checks inside
HandshakeHandler, after decoding. Checking earlier matters: the handshake is
secp256k1 ECDH plus Keccak, and a banned host must not be able to make this
node do that work by reconnecting in a loop.
Then, once the handshake has produced a node id, the id is checked too — that is the earliest it can be. A banned id is dropped before the session starts.
The outbound connector also skips banned and punished peers when choosing who to dial. Without that it spends its attempt budget on hosts that will be dropped on arrival, which is how a node ends up under its peer target while the discovery table looks full.
The existing round-local machinery in crates/sync/src/service.rs stays:
| question it answers | lifetime | |
|---|---|---|
sidelined / body_peer_strikes |
"do not assign this peer work in this round" | the round |
| peer scoring | "this peer has a history" | the process, and bans outlive it |
A peer sidelined once is not banned. A peer sidelined every round accumulates
a record an operator can see. Both fire from the same place: the stall and
strike paths now also call record_peer(..., TimeoutMessage).
| event | source |
|---|---|
SUCCESSFUL_HANDSHAKE |
session registration, with the address |
FAILED_HANDSHAKE |
inbound handshake error or timeout, address only (no node id exists yet) |
DISCONNECTION |
session end, clean or not |
TIMEOUT_MESSAGE |
a stalled header peer; a timed-out body request |
INVALID_HEADER |
a header chunk that fails to store — see below |
VALID_TRANSACTION / INVALID_TRANSACTION |
the transaction relay, per transaction |
INVALID_HEADER is a punishing event, and chunks are processed in skeleton
order, not arrival order: a chunk that arrives early waits in
PeerChunkTracker::buffered until the ones before it land. So the peer whose
response happens to unblock the queue is routinely not the peer that sent the
chunk that then fails to validate.
The buffer therefore carries the supplier alongside the headers, and
drain_ready returns it. Without that the only punishing event this node
produces would ban an arbitrary peer — the exact failure the scoring tests are
built around. drained_chunks_carry_the_peer_that_sent_them pins it.
INVALID_BLOCK and VALID_BLOCK are recorded, and the two are earned at
different moments on purpose.
INVALID_BLOCK at receipt. A body arrives with transactions and uncles and
nothing tying them to the header they were asked for — but the header commits
to both, so the tie is two hashes away. That check runs the moment the body
arrives, which is the only moment the supplying peer is unambiguous: by
the time a block reaches execution it has been through a store and a buffer,
and "who sent this" is gone. The mismatched body is not stored and the request
is left outstanding, so the ordinary retry path asks somebody else.
The charge follows whoever sent the answer rather than whoever was asked. That is what makes a body safely retriable from any peer: the body is good whatever its route, so blame has to follow the sender.
VALID_BLOCK on execution. A body that merely matches its header proves
the peer copied correctly, not that it gave us a real block. The credit waits
until the block executes to the state root its header claims. Provenance
survives that gap in one map keyed by block number, drained in one place — in
preference to threading a peer id through follow_buffer, which is the code
responsible for three separate mainnet stalls on 2026-09-24 and which this
does not need to touch. The map is capped, because a halt strands entries
above the executed head that will never be credited.
rskj records VALID_BLOCK from MessageVisitor.apply(BlockMessage), which is
validation rather than execution — so this node's credit is stricter than
rskj's, in the direction that costs a peer nothing and proves more.
| method | |
|---|---|
sco_banAddress(addr) |
an address or CIDR block; persisted |
sco_unbanAddress(addr) |
likewise |
sco_bannedAddresses() |
addresses and blocks in one list |
sco_peerList() |
every scored entry, by node id and address |
sco_clearPeerScoring(id) |
by address if it parses as one, else by node id; returns the peer list |
sco_reputationSummary() |
totals per counter, plus the good/bad split |
sco_isWelcome(addr) |
not rskj's — see below |
Three differences from rskj:
sco_banAddress returns true, not null. rskj's is void, and a
JSON-RPC result of null is indistinguishable from "the method did nothing".
Callers checking only for an error see no difference.
sco_clearPeerScoring does not resolve DNS. rskj tries
InetAddress.getByName(id) first, which resolves hostnames — so
sco_clearPeerScoring("example.com") would clear whatever that resolves to
today. Here only a literal address parses, and anything else is taken as a node
id.
sco_isWelcome(address) is an addition. rskj has no way to ask "would
this address be let in", so an operator testing a CIDR ban has to wait for the
peer to reconnect and read the log. It reports banned and welcome
separately, because welcome: false without banned: true means a punishment
is running — temporary — and the distinction is the first thing you want.
Counters, score, punishments and punishedUntil are JSON numbers, as
they are in rskj (PeerScoringInformation is a bean of ints and a long
through Jackson). Same trap as the trace_* namespace for a client assuming
hex everywhere.
Without scoring enabled, every method reports itself unavailable rather than answering from an empty table as though every peer were spotless.
| flag | config file | |
|---|---|---|
--banned-peers <ADDR_OR_CIDR> |
peers.banned_peers |
repeatable; rskj's peer.bannedPeerIPs |
--no-peer-punishment |
peers.no_peer_punishment |
rskj's scoring.punishmentEnabled = false |
banned_peers is the one flag where the config file and the command line are
combined rather than the command line winning. A standing ban list belongs
in the config file, and adding one more on the command line should not mean
retyping the rest.
With --no-peer-punishment the events are still counted and sco_peerList
still reports them — an operator can watch what would have been punished
before letting it punish.