UNPKG

@chainsafe/libp2p-gossipsub

Version:
460 lines 19.1 kB
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