@chainsafe/libp2p-gossipsub
Version:
A typescript implementation of gossipsub
460 lines • 19.1 kB
JavaScript
import { RejectReason } from '../types.js';
import { MapDef } from '../utils/set.js';
import { computeScore } from './compute-score.js';
import { MessageDeliveries, DeliveryRecordStatus } from './message-deliveries.js';
import { validatePeerScoreParams } from './peer-score-params.js';
export class PeerScore {
params;
metrics;
/**
* Per-peer stats for score calculation
*/
peerStats = new Map();
/**
* IP colocation tracking; maps IP => set of peers.
*/
peerIPs = new MapDef(() => new Set());
/**
* Cache score up to decayInterval if topic stats are unchanged.
*/
scoreCache = new Map();
/**
* Recent message delivery timing/participants
*/
deliveryRecords = new MessageDeliveries();
_backgroundInterval;
scoreCacheValidityMs;
computeScore;
log;
constructor(params, metrics, componentLogger, opts) {
this.params = params;
this.metrics = metrics;
validatePeerScoreParams(params);
this.scoreCacheValidityMs = opts.scoreCacheValidityMs;
this.computeScore = opts.computeScore ?? computeScore;
this.log = componentLogger.forComponent('libp2p:gossipsub:score');
}
get size() {
return this.peerStats.size;
}
/**
* Start PeerScore instance
*/
start() {
if (this._backgroundInterval != null) {
this.log('Peer score already running');
return;
}
this._backgroundInterval = setInterval(() => { this.background(); }, this.params.decayInterval);
this.log('started');
}
/**
* Stop PeerScore instance
*/
stop() {
if (this._backgroundInterval == null) {
this.log('Peer score already stopped');
return;
}
clearInterval(this._backgroundInterval);
delete this._backgroundInterval;
this.peerIPs.clear();
this.peerStats.clear();
this.deliveryRecords.clear();
this.log('stopped');
}
/**
* Periodic maintenance
*/
background() {
this.refreshScores();
this.deliveryRecords.gc();
}
dumpPeerScoreStats() {
return Object.fromEntries(Array.from(this.peerStats.entries()).map(([peer, stats]) => [peer, stats]));
}
messageFirstSeenTimestampMs(msgIdStr) {
const drec = this.deliveryRecords.getRecord(msgIdStr);
return (drec != null) ? drec.firstSeenTsMs : null;
}
/**
* Decays scores, and purges score records for disconnected peers once their expiry has elapsed.
*/
refreshScores() {
const now = Date.now();
const decayToZero = this.params.decayToZero;
this.peerStats.forEach((pstats, id) => {
if (!pstats.connected) {
// has the retention period expired?
if (now > pstats.expire) {
// yes, throw it away (but clean up the IP tracking first)
this.removeIPsForPeer(id, pstats.knownIPs);
this.peerStats.delete(id);
this.scoreCache.delete(id);
}
// we don't decay retained scores, as the peer is not active.
// this way the peer cannot reset a negative score by simply disconnecting and reconnecting,
// unless the retention period has elapsed.
// similarly, a well behaved peer does not lose its score by getting disconnected.
return;
}
Object.entries(pstats.topics).forEach(([topic, tstats]) => {
const tparams = this.params.topics[topic];
if (tparams === undefined) {
// we are not scoring this topic
// should be unreachable, we only add scored topics to pstats
return;
}
// decay counters
tstats.firstMessageDeliveries *= tparams.firstMessageDeliveriesDecay;
if (tstats.firstMessageDeliveries < decayToZero) {
tstats.firstMessageDeliveries = 0;
}
tstats.meshMessageDeliveries *= tparams.meshMessageDeliveriesDecay;
if (tstats.meshMessageDeliveries < decayToZero) {
tstats.meshMessageDeliveries = 0;
}
tstats.meshFailurePenalty *= tparams.meshFailurePenaltyDecay;
if (tstats.meshFailurePenalty < decayToZero) {
tstats.meshFailurePenalty = 0;
}
tstats.invalidMessageDeliveries *= tparams.invalidMessageDeliveriesDecay;
if (tstats.invalidMessageDeliveries < decayToZero) {
tstats.invalidMessageDeliveries = 0;
}
// update mesh time and activate mesh message delivery parameter if need be
if (tstats.inMesh) {
tstats.meshTime = now - tstats.graftTime;
if (tstats.meshTime > tparams.meshMessageDeliveriesActivation) {
tstats.meshMessageDeliveriesActive = true;
}
}
});
// decay P7 counter
pstats.behaviourPenalty *= this.params.behaviourPenaltyDecay;
if (pstats.behaviourPenalty < decayToZero) {
pstats.behaviourPenalty = 0;
}
});
}
/**
* Return the score for a peer
*/
score(id) {
this.metrics?.scoreFnCalls.inc();
const pstats = this.peerStats.get(id);
if (pstats == null) {
return 0;
}
const now = Date.now();
const cacheEntry = this.scoreCache.get(id);
// Found cached score within validity period
if ((cacheEntry != null) && cacheEntry.cacheUntil > now) {
return cacheEntry.score;
}
this.metrics?.scoreFnRuns.inc();
const score = this.computeScore(id, pstats, this.params, this.peerIPs);
const cacheUntil = now + this.scoreCacheValidityMs;
if (cacheEntry != null) {
this.metrics?.scoreCachedDelta.observe(Math.abs(score - cacheEntry.score));
cacheEntry.score = score;
cacheEntry.cacheUntil = cacheUntil;
}
else {
this.scoreCache.set(id, { score, cacheUntil });
}
return score;
}
/**
* Apply a behavioural penalty to a peer
*/
addPenalty(id, penalty, penaltyLabel) {
const pstats = this.peerStats.get(id);
if (pstats != null) {
pstats.behaviourPenalty += penalty;
this.metrics?.onScorePenalty(penaltyLabel);
}
}
addPeer(id) {
// create peer stats (not including topic stats for each topic to be scored)
// topic stats will be added as needed
const pstats = {
connected: true,
expire: 0,
topics: {},
knownIPs: new Set(),
behaviourPenalty: 0
};
this.peerStats.set(id, pstats);
}
/** Adds a new IP to a peer, if the peer is not known the update is ignored */
addIP(id, ip) {
const pstats = this.peerStats.get(id);
if (pstats != null) {
pstats.knownIPs.add(ip);
}
this.peerIPs.getOrDefault(ip).add(id);
}
/** Remove peer association with IP */
removeIP(id, ip) {
const pstats = this.peerStats.get(id);
if (pstats != null) {
pstats.knownIPs.delete(ip);
}
const peersWithIP = this.peerIPs.get(ip);
if (peersWithIP != null) {
peersWithIP.delete(id);
if (peersWithIP.size === 0) {
this.peerIPs.delete(ip);
}
}
}
removePeer(id) {
const pstats = this.peerStats.get(id);
if (pstats == null) {
return;
}
// decide whether to retain the score; this currently only retains non-positive scores
// to dissuade attacks on the score function.
if (this.score(id) > 0) {
this.removeIPsForPeer(id, pstats.knownIPs);
this.peerStats.delete(id);
return;
}
// furthermore, when we decide to retain the score, the firstMessageDelivery counters are
// reset to 0 and mesh delivery penalties applied.
Object.entries(pstats.topics).forEach(([topic, tstats]) => {
tstats.firstMessageDeliveries = 0;
const threshold = this.params.topics[topic].meshMessageDeliveriesThreshold;
if (tstats.inMesh && tstats.meshMessageDeliveriesActive && tstats.meshMessageDeliveries < threshold) {
const deficit = threshold - tstats.meshMessageDeliveries;
tstats.meshFailurePenalty += deficit * deficit;
}
tstats.inMesh = false;
tstats.meshMessageDeliveriesActive = false;
});
pstats.connected = false;
pstats.expire = Date.now() + this.params.retainScore;
}
/** Handles scoring functionality as a peer GRAFTs to a topic. */
graft(id, topic) {
const pstats = this.peerStats.get(id);
if (pstats != null) {
const tstats = this.getPtopicStats(pstats, topic);
if (tstats != null) {
// if we are scoring the topic, update the mesh status.
tstats.inMesh = true;
tstats.graftTime = Date.now();
tstats.meshTime = 0;
tstats.meshMessageDeliveriesActive = false;
}
}
}
/** Handles scoring functionality as a peer PRUNEs from a topic. */
prune(id, topic) {
const pstats = this.peerStats.get(id);
if (pstats != null) {
const tstats = this.getPtopicStats(pstats, topic);
if (tstats != null) {
// sticky mesh delivery rate failure penalty
const threshold = this.params.topics[topic].meshMessageDeliveriesThreshold;
if (tstats.meshMessageDeliveriesActive && tstats.meshMessageDeliveries < threshold) {
const deficit = threshold - tstats.meshMessageDeliveries;
tstats.meshFailurePenalty += deficit * deficit;
}
tstats.meshMessageDeliveriesActive = false;
tstats.inMesh = false;
// TODO: Consider clearing score cache on important penalties
// this.scoreCache.delete(id)
}
}
}
validateMessage(msgIdStr) {
this.deliveryRecords.ensureRecord(msgIdStr);
}
deliverMessage(from, msgIdStr, topic) {
this.markFirstMessageDelivery(from, topic);
const drec = this.deliveryRecords.ensureRecord(msgIdStr);
const now = Date.now();
// defensive check that this is the first delivery trace -- delivery status should be unknown
if (drec.status !== DeliveryRecordStatus.unknown) {
this.log('unexpected delivery: message from %s was first seen %s ago and has delivery status %s', from, now - drec.firstSeenTsMs, DeliveryRecordStatus[drec.status]);
return;
}
// mark the message as valid and reward mesh peers that have already forwarded it to us
drec.status = DeliveryRecordStatus.valid;
drec.validated = now;
drec.peers.forEach((p) => {
// this check is to make sure a peer can't send us a message twice and get a double count
// if it is a first delivery.
if (p !== from.toString()) {
this.markDuplicateMessageDelivery(p, topic);
}
});
}
/**
* Similar to `rejectMessage` except does not require the message id or reason for an invalid message.
*/
rejectInvalidMessage(from, topic) {
this.markInvalidMessageDelivery(from, topic);
}
rejectMessage(from, msgIdStr, topic, reason) {
// eslint-disable-next-line default-case
switch (reason) {
// these messages are not tracked, but the peer is penalized as they are invalid
case RejectReason.Error:
this.markInvalidMessageDelivery(from, topic);
return;
// we ignore those messages, so do nothing.
case RejectReason.Blacklisted:
return;
// the rest are handled after record creation
}
const drec = this.deliveryRecords.ensureRecord(msgIdStr);
// defensive check that this is the first rejection -- delivery status should be unknown
if (drec.status !== DeliveryRecordStatus.unknown) {
this.log('unexpected rejection: message from %s was first seen %s ago and has delivery status %d', from, Date.now() - drec.firstSeenTsMs, DeliveryRecordStatus[drec.status]);
return;
}
if (reason === RejectReason.Ignore) {
// we were explicitly instructed by the validator to ignore the message but not penalize the peer
drec.status = DeliveryRecordStatus.ignored;
drec.peers.clear();
return;
}
// mark the message as invalid and penalize peers that have already forwarded it.
drec.status = DeliveryRecordStatus.invalid;
this.markInvalidMessageDelivery(from, topic);
drec.peers.forEach((p) => {
this.markInvalidMessageDelivery(p, topic);
});
// release the delivery time tracking map to free some memory early
drec.peers.clear();
}
duplicateMessage(from, msgIdStr, topic) {
const drec = this.deliveryRecords.ensureRecord(msgIdStr);
if (drec.peers.has(from)) {
// we have already seen this duplicate
return;
}
// eslint-disable-next-line default-case
switch (drec.status) {
case DeliveryRecordStatus.unknown:
// the message is being validated; track the peer delivery and wait for
// the Deliver/Reject/Ignore notification.
drec.peers.add(from);
break;
case DeliveryRecordStatus.valid:
// mark the peer delivery time to only count a duplicate delivery once.
drec.peers.add(from);
this.markDuplicateMessageDelivery(from, topic, drec.validated);
break;
case DeliveryRecordStatus.invalid:
// we no longer track delivery time
this.markInvalidMessageDelivery(from, topic);
break;
case DeliveryRecordStatus.ignored:
// the message was ignored; do nothing (we don't know if it was valid)
break;
}
}
/**
* Increments the "invalid message deliveries" counter for all scored topics the message is published in.
*/
markInvalidMessageDelivery(from, topic) {
const pstats = this.peerStats.get(from);
if (pstats != null) {
const tstats = this.getPtopicStats(pstats, topic);
if (tstats != null) {
tstats.invalidMessageDeliveries += 1;
}
}
}
/**
* Increments the "first message deliveries" counter for all scored topics the message is published in,
* as well as the "mesh message deliveries" counter, if the peer is in the mesh for the topic.
* Messages already known (with the seenCache) are counted with markDuplicateMessageDelivery()
*/
markFirstMessageDelivery(from, topic) {
const pstats = this.peerStats.get(from);
if (pstats != null) {
const tstats = this.getPtopicStats(pstats, topic);
if (tstats != null) {
let cap = this.params.topics[topic].firstMessageDeliveriesCap;
tstats.firstMessageDeliveries = Math.min(cap, tstats.firstMessageDeliveries + 1);
if (tstats.inMesh) {
cap = this.params.topics[topic].meshMessageDeliveriesCap;
tstats.meshMessageDeliveries = Math.min(cap, tstats.meshMessageDeliveries + 1);
}
}
}
}
/**
* Increments the "mesh message deliveries" counter for messages we've seen before,
* as long the message was received within the P3 window.
*/
markDuplicateMessageDelivery(from, topic, validatedTime) {
const pstats = this.peerStats.get(from);
if (pstats != null) {
const now = validatedTime !== undefined ? Date.now() : 0;
const tstats = this.getPtopicStats(pstats, topic);
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (tstats != null && tstats.inMesh) {
const tparams = this.params.topics[topic];
// check against the mesh delivery window -- if the validated time is passed as 0, then
// the message was received before we finished validation and thus falls within the mesh
// delivery window.
if (validatedTime !== undefined) {
const deliveryDelayMs = now - validatedTime;
const isLateDelivery = deliveryDelayMs > tparams.meshMessageDeliveriesWindow;
this.metrics?.onDuplicateMsgDelivery(topic, deliveryDelayMs, isLateDelivery);
if (isLateDelivery) {
return;
}
}
const cap = tparams.meshMessageDeliveriesCap;
tstats.meshMessageDeliveries = Math.min(cap, tstats.meshMessageDeliveries + 1);
}
}
}
/**
* Removes an IP list from the tracking list for a peer.
*/
removeIPsForPeer(id, ipsToRemove) {
for (const ipToRemove of ipsToRemove) {
const peerSet = this.peerIPs.get(ipToRemove);
if (peerSet != null) {
peerSet.delete(id);
if (peerSet.size === 0) {
this.peerIPs.delete(ipToRemove);
}
}
}
}
/**
* Returns topic stats if they exist, otherwise if the supplied parameters score the
* topic, inserts the default stats and returns a reference to those. If neither apply, returns None.
*/
getPtopicStats(pstats, topic) {
let topicStats = pstats.topics[topic];
if (topicStats !== undefined) {
return topicStats;
}
if (this.params.topics[topic] !== undefined) {
topicStats = {
inMesh: false,
graftTime: 0,
meshTime: 0,
firstMessageDeliveries: 0,
meshMessageDeliveries: 0,
meshMessageDeliveriesActive: false,
meshFailurePenalty: 0,
invalidMessageDeliveries: 0
};
pstats.topics[topic] = topicStats;
return topicStats;
}
return null;
}
}
//# sourceMappingURL=peer-score.js.map