@chainsafe/libp2p-gossipsub
Version:
A typescript implementation of gossipsub
1,141 lines (1,140 loc) • 111 kB
JavaScript
import { TypedEventEmitter, StrictSign, StrictNoSign, TopicValidatorResult, serviceCapabilities, serviceDependencies } from '@libp2p/interface';
import { peerIdFromMultihash, peerIdFromString } from '@libp2p/peer-id';
import { encode } from 'it-length-prefixed';
import { pipe } from 'it-pipe';
import { pushable } from 'it-pushable';
import * as Digest from 'multiformats/hashes/digest';
import * as constants from './constants.js';
import { ACCEPT_FROM_WHITELIST_DURATION_MS, ACCEPT_FROM_WHITELIST_MAX_MESSAGES, ACCEPT_FROM_WHITELIST_THRESHOLD_SCORE, BACKOFF_SLACK } from './constants.js';
import { defaultDecodeRpcLimits } from './message/decodeRpc.js';
import { RPC } from './message/rpc.js';
import { MessageCache } from './message-cache.js';
import { ChurnReason, getMetrics, IHaveIgnoreReason, InclusionReason, ScorePenalty } from './metrics.js';
import { PeerScore, createPeerScoreParams, createPeerScoreThresholds } from './score/index.js';
import { computeAllPeersScoreWeights } from './score/scoreMetrics.js';
import { InboundStream, OutboundStream } from './stream.js';
import { IWantTracer } from './tracer.js';
import { ValidateError, MessageStatus, RejectReason, rejectReasonFromAcceptance } from './types.js';
import { buildRawMessage, validateToRawMessage } from './utils/buildRawMessage.js';
import { createGossipRpc, ensureControl } from './utils/create-gossip-rpc.js';
import { shuffle, messageIdToString } from './utils/index.js';
import { msgIdFnStrictNoSign, msgIdFnStrictSign } from './utils/msgIdFn.js';
import { multiaddrToIPStr } from './utils/multiaddr.js';
import { getPublishConfigFromPeerId } from './utils/publishConfig.js';
import { removeFirstNItemsFromSet, removeItemsFromSet } from './utils/set.js';
import { SimpleTimeCache } from './utils/time-cache.js';
export const multicodec = constants.GossipsubIDv12;
var GossipStatusCode;
(function (GossipStatusCode) {
GossipStatusCode[GossipStatusCode["started"] = 0] = "started";
GossipStatusCode[GossipStatusCode["stopped"] = 1] = "stopped";
})(GossipStatusCode || (GossipStatusCode = {}));
export class GossipSub extends TypedEventEmitter {
/**
* The signature policy to follow by default
*/
globalSignaturePolicy;
multicodecs = [constants.GossipsubIDv12, constants.GossipsubIDv11, constants.GossipsubIDv10];
publishConfig;
dataTransform;
// State
peers = new Map();
streamsInbound = new Map();
streamsOutbound = new Map();
/** Ensures outbound streams are created sequentially */
outboundInflightQueue = pushable({ objectMode: true });
/** Direct peers */
direct = new Set();
/** Floodsub peers */
floodsubPeers = new Set();
/** Cache of seen messages */
seenCache;
/**
* Map of peer id and AcceptRequestWhileListEntry
*/
acceptFromWhitelist = new Map();
/**
* Map of topics to which peers are subscribed to
*/
topics = new Map();
/**
* List of our subscriptions
*/
subscriptions = new Set();
/**
* Map of topic meshes
* topic => peer id set
*/
mesh = new Map();
/**
* Map of topics to set of peers. These mesh peers are the ones to which we are publishing without a topic membership
* topic => peer id set
*/
fanout = new Map();
/**
* Map of last publish time for fanout topics
* topic => last publish time
*/
fanoutLastpub = new Map();
/**
* Map of pending messages to gossip
* peer id => control messages
*/
gossip = new Map();
/**
* Map of control messages
* peer id => control message
*/
control = new Map();
/**
* Number of IHAVEs received from peer in the last heartbeat
*/
peerhave = new Map();
/** Number of messages we have asked from peer in the last heartbeat */
iasked = new Map();
/** Prune backoff map */
backoff = new Map();
/**
* Connection direction cache, marks peers with outbound connections
* peer id => direction
*/
outbound = new Map();
msgIdFn;
/**
* A fast message id function used for internal message de-duplication
*/
fastMsgIdFn;
msgIdToStrFn;
/** Maps fast message-id to canonical message-id */
fastMsgIdCache;
/**
* Short term cache for published message ids. This is used for penalizing peers sending
* our own messages back if the messages are anonymous or use a random author.
*/
publishedMessageIds;
/**
* A message cache that contains the messages for last few heartbeat ticks
*/
mcache;
/** Peer score tracking */
score;
/**
* Custom validator function per topic.
* Must return or resolve quickly (< 100ms) to prevent causing penalties for late messages.
* If you need to apply validation that may require longer times use `asyncValidation` option and callback the
* validation result through `Gossipsub.reportValidationResult`
*/
topicValidators = new Map();
/**
* Make this protected so child class may want to redirect to its own log.
*/
log;
/**
* Number of heartbeats since the beginning of time
* This allows us to amortize some resource cleanup -- eg: backoff cleanup
*/
heartbeatTicks = 0;
/**
* Tracks IHAVE/IWANT promises broken by peers
*/
gossipTracer;
/**
* Tracks IDONTWANT messages received by peers in the current heartbeat
*/
idontwantCounts = new Map();
/**
* Tracks IDONTWANT messages received by peers and the heartbeat they were received in
*
* idontwants are stored for `mcacheLength` heartbeats before being pruned,
* so this map is bounded by peerCount * idontwantMaxMessages * mcacheLength
*/
idontwants = new Map();
components;
directPeerInitial = null;
static multicodec = constants.GossipsubIDv12;
// Options
opts;
decodeRpcLimits;
metrics;
status = { code: GossipStatusCode.stopped };
maxInboundStreams;
maxOutboundStreams;
runOnLimitedConnection;
allowedTopics;
heartbeatTimer = null;
constructor(components, options = {}) {
super();
const opts = {
fallbackToFloodsub: true,
floodPublish: true,
batchPublish: false,
tagMeshPeers: true,
doPX: false,
directPeers: [],
D: constants.GossipsubD,
Dlo: constants.GossipsubDlo,
Dhi: constants.GossipsubDhi,
Dscore: constants.GossipsubDscore,
Dout: constants.GossipsubDout,
Dlazy: constants.GossipsubDlazy,
heartbeatInterval: constants.GossipsubHeartbeatInterval,
fanoutTTL: constants.GossipsubFanoutTTL,
mcacheLength: constants.GossipsubHistoryLength,
mcacheGossip: constants.GossipsubHistoryGossip,
seenTTL: constants.GossipsubSeenTTL,
gossipsubIWantFollowupMs: constants.GossipsubIWantFollowupTime,
prunePeers: constants.GossipsubPrunePeers,
pruneBackoff: constants.GossipsubPruneBackoff,
unsubcribeBackoff: constants.GossipsubUnsubscribeBackoff,
graftFloodThreshold: constants.GossipsubGraftFloodThreshold,
opportunisticGraftPeers: constants.GossipsubOpportunisticGraftPeers,
opportunisticGraftTicks: constants.GossipsubOpportunisticGraftTicks,
directConnectTicks: constants.GossipsubDirectConnectTicks,
gossipFactor: constants.GossipsubGossipFactor,
idontwantMinDataSize: constants.GossipsubIdontwantMinDataSize,
idontwantMaxMessages: constants.GossipsubIdontwantMaxMessages,
...options,
scoreParams: createPeerScoreParams(options.scoreParams),
scoreThresholds: createPeerScoreThresholds(options.scoreThresholds)
};
this.components = components;
this.decodeRpcLimits = opts.decodeRpcLimits ?? defaultDecodeRpcLimits;
this.globalSignaturePolicy = opts.globalSignaturePolicy ?? StrictSign;
// Also wants to get notified of peers connected using floodsub
if (opts.fallbackToFloodsub) {
this.multicodecs.push(constants.FloodsubID);
}
// From pubsub
this.log = components.logger.forComponent(opts.debugName ?? 'libp2p:gossipsub');
// Gossipsub
this.opts = opts;
this.direct = new Set(opts.directPeers.map((p) => p.id.toString()));
this.seenCache = new SimpleTimeCache({ validityMs: opts.seenTTL });
this.publishedMessageIds = new SimpleTimeCache({ validityMs: opts.seenTTL });
if (options.msgIdFn != null) {
// Use custom function
this.msgIdFn = options.msgIdFn;
}
else {
switch (this.globalSignaturePolicy) {
case StrictSign:
this.msgIdFn = msgIdFnStrictSign;
break;
case StrictNoSign:
this.msgIdFn = msgIdFnStrictNoSign;
break;
default:
throw new Error(`Invalid globalSignaturePolicy: ${this.globalSignaturePolicy}`);
}
}
if (options.fastMsgIdFn != null) {
this.fastMsgIdFn = options.fastMsgIdFn;
this.fastMsgIdCache = new SimpleTimeCache({ validityMs: opts.seenTTL });
}
// By default, gossipsub only provide a browser friendly function to convert Uint8Array message id to string.
this.msgIdToStrFn = options.msgIdToStrFn ?? messageIdToString;
this.mcache = options.messageCache ?? new MessageCache(opts.mcacheGossip, opts.mcacheLength, this.msgIdToStrFn);
if (options.dataTransform != null) {
this.dataTransform = options.dataTransform;
}
if (options.metricsRegister != null) {
if (options.metricsTopicStrToLabel == null) {
throw Error('Must set metricsTopicStrToLabel with metrics');
}
// in theory, each topic has its own meshMessageDeliveriesWindow param
// however in lodestar, we configure it mostly the same so just pick the max of positive ones
// (some topics have meshMessageDeliveriesWindow as 0)
const maxMeshMessageDeliveriesWindowMs = Math.max(...Object.values(opts.scoreParams.topics).map((topicParam) => topicParam.meshMessageDeliveriesWindow), constants.DEFAULT_METRIC_MESH_MESSAGE_DELIVERIES_WINDOWS);
const metrics = getMetrics(options.metricsRegister, options.metricsTopicStrToLabel, {
gossipPromiseExpireSec: this.opts.gossipsubIWantFollowupMs / 1000,
behaviourPenaltyThreshold: opts.scoreParams.behaviourPenaltyThreshold,
maxMeshMessageDeliveriesWindowSec: maxMeshMessageDeliveriesWindowMs / 1000
});
metrics.mcacheSize.addCollect(() => { this.onScrapeMetrics(metrics); });
for (const protocol of this.multicodecs) {
metrics.protocolsEnabled.set({ protocol }, 1);
}
this.metrics = metrics;
}
else {
this.metrics = null;
}
this.gossipTracer = new IWantTracer(this.opts.gossipsubIWantFollowupMs, this.msgIdToStrFn, this.metrics);
/**
* libp2p
*/
this.score = new PeerScore(this.opts.scoreParams, this.metrics, this.components.logger, {
scoreCacheValidityMs: opts.heartbeatInterval
});
this.maxInboundStreams = options.maxInboundStreams;
this.maxOutboundStreams = options.maxOutboundStreams;
this.runOnLimitedConnection = options.runOnLimitedConnection;
this.allowedTopics = (opts.allowedTopics != null) ? new Set(opts.allowedTopics) : null;
}
[Symbol.toStringTag] = '@chainsafe/libp2p-gossipsub';
[serviceCapabilities] = [
'@libp2p/pubsub'
];
[serviceDependencies] = [
'@libp2p/identify'
];
getPeers() {
return [...this.peers.values()];
}
isStarted() {
return this.status.code === GossipStatusCode.started;
}
// LIFECYCLE METHODS
/**
* Mounts the gossipsub protocol onto the libp2p node and sends our
* our subscriptions to every peer connected
*/
async start() {
// From pubsub
if (this.isStarted()) {
return;
}
this.log('starting');
this.publishConfig = getPublishConfigFromPeerId(this.globalSignaturePolicy, this.components.peerId, this.components.privateKey);
// Create the outbound inflight queue
// This ensures that outbound stream creation happens sequentially
this.outboundInflightQueue = pushable({ objectMode: true });
pipe(this.outboundInflightQueue, async (source) => {
for await (const { peerId, connection } of source) {
await this.createOutboundStream(peerId, connection);
}
}).catch((e) => { this.log.error('outbound inflight queue error', e); });
// set direct peer addresses in the address book
await Promise.all(this.opts.directPeers.map(async (p) => {
await this.components.peerStore.merge(p.id, {
multiaddrs: p.addrs
});
}));
const registrar = this.components.registrar;
// Incoming streams
// Called after a peer dials us
await Promise.all(this.multicodecs.map(async (multicodec) => registrar.handle(multicodec, this.onIncomingStream.bind(this), {
maxInboundStreams: this.maxInboundStreams,
maxOutboundStreams: this.maxOutboundStreams,
runOnLimitedConnection: this.runOnLimitedConnection
})));
// # How does Gossipsub interact with libp2p? Rough guide from Mar 2022
//
// ## Setup:
// Gossipsub requests libp2p to callback, TBD
//
// `this.libp2p.handle()` registers a handler for `/meshsub/1.1.0` and other Gossipsub protocols
// The handler callback is registered in libp2p Upgrader.protocols map.
//
// Upgrader receives an inbound connection from some transport and (`Upgrader.upgradeInbound`):
// - Adds encryption (NOISE in our case)
// - Multiplex stream
// - Create a muxer and register that for each new stream call Upgrader.protocols handler
//
// ## Topology
// - new instance of Topology (unlinked to libp2p) with handlers
// - registar.register(topology)
// register protocol with topology
// Topology callbacks called on connection manager changes
const topology = {
onConnect: this.onPeerConnected.bind(this),
onDisconnect: this.onPeerDisconnected.bind(this),
notifyOnLimitedConnection: this.runOnLimitedConnection
};
const registrarTopologyIds = await Promise.all(this.multicodecs.map(async (multicodec) => registrar.register(multicodec, topology)));
// Schedule to start heartbeat after `GossipsubHeartbeatInitialDelay`
const heartbeatTimeout = setTimeout(this.runHeartbeat, constants.GossipsubHeartbeatInitialDelay);
// Then, run heartbeat every `heartbeatInterval` offset by `GossipsubHeartbeatInitialDelay`
this.status = {
code: GossipStatusCode.started,
registrarTopologyIds,
heartbeatTimeout,
hearbeatStartMs: Date.now() + constants.GossipsubHeartbeatInitialDelay
};
this.score.start();
// connect to direct peers
this.directPeerInitial = setTimeout(() => {
Promise.resolve()
.then(async () => {
await Promise.all(Array.from(this.direct).map(async (id) => this.connect(id)));
})
.catch((err) => {
this.log(err);
});
}, constants.GossipsubDirectConnectInitialDelay);
if (this.opts.tagMeshPeers) {
this.addEventListener('gossipsub:graft', this.tagMeshPeer);
this.addEventListener('gossipsub:prune', this.untagMeshPeer);
}
this.log('started');
}
/**
* Unmounts the gossipsub protocol and shuts down every connection
*/
async stop() {
this.log('stopping');
// From pubsub
if (this.status.code !== GossipStatusCode.started) {
return;
}
const { registrarTopologyIds } = this.status;
this.status = { code: GossipStatusCode.stopped };
if (this.opts.tagMeshPeers) {
this.removeEventListener('gossipsub:graft', this.tagMeshPeer);
this.removeEventListener('gossipsub:prune', this.untagMeshPeer);
}
// unregister protocol and handlers
const registrar = this.components.registrar;
await Promise.all(this.multicodecs.map(async (multicodec) => registrar.unhandle(multicodec)));
registrarTopologyIds.forEach((id) => { registrar.unregister(id); });
this.outboundInflightQueue.end();
const closePromises = [];
for (const outboundStream of this.streamsOutbound.values()) {
closePromises.push(outboundStream.close());
}
this.streamsOutbound.clear();
for (const inboundStream of this.streamsInbound.values()) {
closePromises.push(inboundStream.close());
}
this.streamsInbound.clear();
await Promise.all(closePromises);
this.peers.clear();
this.subscriptions.clear();
// Gossipsub
if (this.heartbeatTimer != null) {
this.heartbeatTimer.cancel();
this.heartbeatTimer = null;
}
this.score.stop();
this.mesh.clear();
this.fanout.clear();
this.fanoutLastpub.clear();
this.gossip.clear();
this.control.clear();
this.peerhave.clear();
this.iasked.clear();
this.backoff.clear();
this.outbound.clear();
this.gossipTracer.clear();
this.seenCache.clear();
if (this.fastMsgIdCache != null)
this.fastMsgIdCache.clear();
if (this.directPeerInitial != null)
clearTimeout(this.directPeerInitial);
this.idontwantCounts.clear();
this.idontwants.clear();
this.log('stopped');
}
/** FOR DEBUG ONLY - Dump peer stats for all peers. Data is cloned, safe to mutate */
dumpPeerScoreStats() {
return this.score.dumpPeerScoreStats();
}
/**
* On an inbound stream opened
*/
onIncomingStream({ stream, connection }) {
if (!this.isStarted()) {
return;
}
const peerId = connection.remotePeer;
// add peer to router
this.addPeer(peerId, connection.direction, connection.remoteAddr);
// create inbound stream
this.createInboundStream(peerId, stream);
// attempt to create outbound stream
this.outboundInflightQueue.push({ peerId, connection });
}
/**
* Registrar notifies an established connection with pubsub protocol
*/
onPeerConnected(peerId, connection) {
this.metrics?.newConnectionCount.inc({ status: connection.status });
// libp2p may emit a closed connection and never issue peer:disconnect event
// see https://github.com/ChainSafe/js-libp2p-gossipsub/issues/398
if (!this.isStarted() || connection.status !== 'open') {
return;
}
this.addPeer(peerId, connection.direction, connection.remoteAddr);
this.outboundInflightQueue.push({ peerId, connection });
}
/**
* Registrar notifies a closing connection with pubsub protocol
*/
onPeerDisconnected(peerId) {
this.log('connection ended %p', peerId);
this.removePeer(peerId);
}
async createOutboundStream(peerId, connection) {
if (!this.isStarted()) {
return;
}
const id = peerId.toString();
if (!this.peers.has(id)) {
return;
}
// TODO make this behavior more robust
// This behavior is different than for inbound streams
// If an outbound stream already exists, don't create a new stream
if (this.streamsOutbound.has(id)) {
return;
}
try {
const stream = new OutboundStream(await connection.newStream(this.multicodecs, {
runOnLimitedConnection: this.runOnLimitedConnection
}), (e) => { this.log.error('outbound pipe error', e); }, { maxBufferSize: this.opts.maxOutboundBufferSize });
this.log('create outbound stream %p', peerId);
this.streamsOutbound.set(id, stream);
const protocol = stream.protocol;
if (protocol === constants.FloodsubID) {
this.floodsubPeers.add(id);
}
this.metrics?.peersPerProtocol.inc({ protocol }, 1);
// Immediately send own subscriptions via the newly attached stream
if (this.subscriptions.size > 0) {
this.log('send subscriptions to', id);
this.sendSubscriptions(id, Array.from(this.subscriptions), true);
}
}
catch (e) {
this.log.error('createOutboundStream error', e);
}
}
createInboundStream(peerId, stream) {
if (!this.isStarted()) {
return;
}
const id = peerId.toString();
if (!this.peers.has(id)) {
return;
}
// TODO make this behavior more robust
// This behavior is different than for outbound streams
// If a peer initiates a new inbound connection
// we assume that one is the new canonical inbound stream
const priorInboundStream = this.streamsInbound.get(id);
if (priorInboundStream !== undefined) {
this.log('replacing existing inbound steam %s', id);
priorInboundStream.close().catch((err) => { this.log.error(err); });
}
this.log('create inbound stream %s', id);
const inboundStream = new InboundStream(stream, { maxDataLength: this.opts.maxInboundDataLength });
this.streamsInbound.set(id, inboundStream);
this.pipePeerReadStream(peerId, inboundStream.source).catch((err) => { this.log(err); });
}
/**
* Add a peer to the router
*/
addPeer(peerId, direction, addr) {
const id = peerId.toString();
if (!this.peers.has(id)) {
this.log('new peer %p', peerId);
this.peers.set(id, peerId);
// Add to peer scoring
this.score.addPeer(id);
const currentIP = multiaddrToIPStr(addr);
if (currentIP !== null) {
this.score.addIP(id, currentIP);
}
else {
this.log('Added peer has no IP in current address %s %s', id, addr.toString());
}
// track the connection direction. Don't allow to unset outbound
if (!this.outbound.has(id)) {
this.outbound.set(id, direction === 'outbound');
}
}
}
/**
* Removes a peer from the router
*/
removePeer(peerId) {
const id = peerId.toString();
if (!this.peers.has(id)) {
return;
}
// delete peer
this.log('delete peer %p', peerId);
this.peers.delete(id);
const outboundStream = this.streamsOutbound.get(id);
const inboundStream = this.streamsInbound.get(id);
if (outboundStream != null) {
this.metrics?.peersPerProtocol.inc({ protocol: outboundStream.protocol }, -1);
}
// close streams
outboundStream?.close().catch((err) => { this.log.error(err); });
inboundStream?.close().catch((err) => { this.log.error(err); });
// remove streams
this.streamsOutbound.delete(id);
this.streamsInbound.delete(id);
// remove peer from topics map
for (const peers of this.topics.values()) {
peers.delete(id);
}
// Remove this peer from the mesh
for (const [topicStr, peers] of this.mesh) {
if (peers.delete(id)) {
this.metrics?.onRemoveFromMesh(topicStr, ChurnReason.Dc, 1);
}
}
// Remove this peer from the fanout
for (const peers of this.fanout.values()) {
peers.delete(id);
}
// Remove from floodsubPeers
this.floodsubPeers.delete(id);
// Remove from gossip mapping
this.gossip.delete(id);
// Remove from control mapping
this.control.delete(id);
// Remove from backoff mapping
this.outbound.delete(id);
// Remove from idontwant tracking
this.idontwantCounts.delete(id);
this.idontwants.delete(id);
// Remove from peer scoring
this.score.removePeer(id);
this.acceptFromWhitelist.delete(id);
}
// API METHODS
get started() {
return this.status.code === GossipStatusCode.started;
}
/**
* Get a the peer-ids in a topic mesh
*/
getMeshPeers(topic) {
const peersInTopic = this.mesh.get(topic);
return (peersInTopic != null) ? Array.from(peersInTopic) : [];
}
/**
* Get a list of the peer-ids that are subscribed to one topic.
*/
getSubscribers(topic) {
const peersInTopic = this.topics.get(topic);
return ((peersInTopic != null) ? Array.from(peersInTopic) : []).map((str) => this.peers.get(str) ?? peerIdFromString(str));
}
/**
* Get the list of topics which the peer is subscribed to.
*/
getTopics() {
return Array.from(this.subscriptions);
}
// TODO: Reviewing Pubsub API
// MESSAGE METHODS
/**
* Responsible for processing each RPC message received by other peers.
*/
async pipePeerReadStream(peerId, stream) {
try {
await pipe(stream, async (source) => {
for await (const data of source) {
try {
// TODO: Check max gossip message size, before decodeRpc()
const rpcBytes = data.subarray();
// Note: This function may throw, it must be wrapped in a try {} catch {} to prevent closing the stream.
// TODO: What should we do if the entire RPC is invalid?
const rpc = RPC.decode(rpcBytes, {
limits: {
subscriptions: this.decodeRpcLimits.maxSubscriptions,
messages: this.decodeRpcLimits.maxMessages,
control$: {
ihave: this.decodeRpcLimits.maxIhaveMessageIDs,
iwant: this.decodeRpcLimits.maxIwantMessageIDs,
graft: this.decodeRpcLimits.maxControlMessages,
prune: this.decodeRpcLimits.maxControlMessages,
prune$: {
peers: this.decodeRpcLimits.maxPeerInfos
},
idontwant: this.decodeRpcLimits.maxControlMessages,
idontwant$: {
messageIDs: this.decodeRpcLimits.maxIdontwantMessageIDs
}
}
}
});
this.metrics?.onRpcRecv(rpc, rpcBytes.length);
// Since processRpc may be overridden entirely in unsafe ways,
// the simplest/safest option here is to wrap in a function and capture all errors
// to prevent a top-level unhandled exception
// This processing of rpc messages should happen without awaiting full validation/execution of prior messages
if (this.opts.awaitRpcHandler) {
try {
await this.handleReceivedRpc(peerId, rpc);
}
catch (err) {
this.metrics?.onRpcRecvError();
this.log(err);
}
}
else {
this.handleReceivedRpc(peerId, rpc).catch((err) => {
this.metrics?.onRpcRecvError();
this.log(err);
});
}
}
catch (e) {
this.metrics?.onRpcDataError();
this.log(e);
}
}
});
}
catch (err) {
this.metrics?.onPeerReadStreamError();
this.handlePeerReadStreamError(err, peerId);
}
}
/**
* Handle error when read stream pipe throws, less of the functional use but more
* to for testing purposes to spy on the error handling
* */
handlePeerReadStreamError(err, peerId) {
this.log.error(err);
this.onPeerDisconnected(peerId);
}
/**
* Handles an rpc request from a peer
*/
async handleReceivedRpc(from, rpc) {
// Check if peer is graylisted in which case we ignore the event
if (!this.acceptFrom(from.toString())) {
this.log('received message from unacceptable peer %p', from);
this.metrics?.rpcRecvNotAccepted.inc();
return;
}
const subscriptions = (rpc.subscriptions != null) ? rpc.subscriptions.length : 0;
const messages = (rpc.messages != null) ? rpc.messages.length : 0;
let ihave = 0;
let iwant = 0;
let graft = 0;
let prune = 0;
if (rpc.control != null) {
if (rpc.control.ihave != null)
ihave = rpc.control.ihave.length;
if (rpc.control.iwant != null)
iwant = rpc.control.iwant.length;
if (rpc.control.graft != null)
graft = rpc.control.graft.length;
if (rpc.control.prune != null)
prune = rpc.control.prune.length;
}
this.log(`rpc.from ${from.toString()} subscriptions ${subscriptions} messages ${messages} ihave ${ihave} iwant ${iwant} graft ${graft} prune ${prune}`);
// Handle received subscriptions
if ((rpc.subscriptions != null) && rpc.subscriptions.length > 0) {
// update peer subscriptions
const subscriptions = [];
rpc.subscriptions.forEach((subOpt) => {
const topic = subOpt.topic;
const subscribe = subOpt.subscribe === true;
if (topic != null) {
if ((this.allowedTopics != null) && !this.allowedTopics.has(topic)) {
// Not allowed: subscription data-structures are not bounded by topic count
// TODO: Should apply behaviour penalties?
return;
}
this.handleReceivedSubscription(from, topic, subscribe);
subscriptions.push({ topic, subscribe });
}
});
this.safeDispatchEvent('subscription-change', {
detail: { peerId: from, subscriptions }
});
}
// Handle messages
// TODO: (up to limit)
for (const message of rpc.messages) {
if ((this.allowedTopics != null) && !this.allowedTopics.has(message.topic)) {
// Not allowed: message cache data-structures are not bounded by topic count
// TODO: Should apply behaviour penalties?
continue;
}
const handleReceivedMessagePromise = this.handleReceivedMessage(from, message)
// Should never throw, but handle just in case
.catch((err) => {
this.metrics?.onMsgRecvError(message.topic);
this.log(err);
});
if (this.opts.awaitRpcMessageHandler) {
await handleReceivedMessagePromise;
}
}
// Handle control messages
if (rpc.control != null) {
await this.handleControlMessage(from.toString(), rpc.control);
}
}
/**
* Handles a subscription change from a peer
*/
handleReceivedSubscription(from, topic, subscribe) {
this.log('subscription update from %p topic %s', from, topic);
let topicSet = this.topics.get(topic);
if (topicSet == null) {
topicSet = new Set();
this.topics.set(topic, topicSet);
}
if (subscribe) {
// subscribe peer to new topic
topicSet.add(from.toString());
}
else {
// unsubscribe from existing topic
topicSet.delete(from.toString());
}
// TODO: rust-libp2p has A LOT more logic here
}
/**
* Handles a newly received message from an RPC.
* May forward to all peers in the mesh.
*/
async handleReceivedMessage(from, rpcMsg) {
this.metrics?.onMsgRecvPreValidation(rpcMsg.topic);
const validationResult = await this.validateReceivedMessage(from, rpcMsg);
this.metrics?.onPrevalidationResult(rpcMsg.topic, validationResult.code);
const validationCode = validationResult.code;
switch (validationCode) {
case MessageStatus.duplicate:
// Report the duplicate
this.score.duplicateMessage(from.toString(), validationResult.msgIdStr, rpcMsg.topic);
// due to the collision of fastMsgIdFn, 2 different messages may end up the same fastMsgId
// so we need to also mark the duplicate message as delivered or the promise is not resolved
// and peer gets penalized. See https://github.com/ChainSafe/js-libp2p-gossipsub/pull/385
this.gossipTracer.deliverMessage(validationResult.msgIdStr, true);
this.mcache.observeDuplicate(validationResult.msgIdStr, from.toString());
return;
case MessageStatus.invalid:
// invalid messages received
// metrics.register_invalid_message(&raw_message.topic)
// Tell peer_score about reject
// Reject the original source, and any duplicates we've seen from other peers.
if (validationResult.msgIdStr != null) {
const msgIdStr = validationResult.msgIdStr;
this.score.rejectMessage(from.toString(), msgIdStr, rpcMsg.topic, validationResult.reason);
this.gossipTracer.rejectMessage(msgIdStr, validationResult.reason);
}
else {
this.score.rejectInvalidMessage(from.toString(), rpcMsg.topic);
}
this.metrics?.onMsgRecvInvalid(rpcMsg.topic, validationResult);
return;
case MessageStatus.valid:
// Tells score that message arrived (but is maybe not fully validated yet).
// Consider the message as delivered for gossip promises.
this.score.validateMessage(validationResult.messageId.msgIdStr);
this.gossipTracer.deliverMessage(validationResult.messageId.msgIdStr);
// Add the message to our memcache
// if no validation is required, mark the message as validated
this.mcache.put(validationResult.messageId, rpcMsg, !this.opts.asyncValidation);
// Dispatch the message to the user if we are subscribed to the topic
if (this.subscriptions.has(rpcMsg.topic)) {
const isFromSelf = this.components.peerId.equals(from);
if (!isFromSelf || this.opts.emitSelf) {
super.dispatchEvent(new CustomEvent('gossipsub:message', {
detail: {
propagationSource: from,
msgId: validationResult.messageId.msgIdStr,
msg: validationResult.msg
}
}));
// TODO: Add option to switch between emit per topic or all messages in one
super.dispatchEvent(new CustomEvent('message', { detail: validationResult.msg }));
}
}
// Forward the message to mesh peers, if no validation is required
// If asyncValidation is ON, expect the app layer to call reportMessageValidationResult(), then forward
if (!this.opts.asyncValidation) {
// TODO: in rust-libp2p
// .forward_msg(&msg_id, raw_message, Some(propagation_source))
this.forwardMessage(validationResult.messageId.msgIdStr, rpcMsg, from.toString());
}
break;
default:
throw new Error(`Invalid validation result: ${validationCode}`);
}
}
/**
* Handles a newly received message from an RPC.
* May forward to all peers in the mesh.
*/
async validateReceivedMessage(propagationSource, rpcMsg) {
// Fast message ID stuff
const fastMsgIdStr = this.fastMsgIdFn?.(rpcMsg);
const msgIdCached = fastMsgIdStr !== undefined ? this.fastMsgIdCache?.get(fastMsgIdStr) : undefined;
if (msgIdCached != null) {
// This message has been seen previously. Ignore it
return { code: MessageStatus.duplicate, msgIdStr: msgIdCached };
}
// Perform basic validation on message and convert to RawGossipsubMessage for fastMsgIdFn()
const validationResult = await validateToRawMessage(this.globalSignaturePolicy, rpcMsg);
if (!validationResult.valid) {
return { code: MessageStatus.invalid, reason: RejectReason.Error, error: validationResult.error };
}
const msg = validationResult.message;
// Try and perform the data transform to the message. If it fails, consider it invalid.
try {
if (this.dataTransform != null) {
msg.data = this.dataTransform.inboundTransform(rpcMsg.topic, msg.data);
}
}
catch (e) {
this.log('Invalid message, transform failed', e);
return { code: MessageStatus.invalid, reason: RejectReason.Error, error: ValidateError.TransformFailed };
}
// TODO: Check if message is from a blacklisted source or propagation origin
// - Reject any message from a blacklisted peer
// - Also reject any message that originated from a blacklisted peer
// - reject messages claiming to be from ourselves but not locally published
// Calculate the message id on the transformed data.
const msgId = await this.msgIdFn(msg);
const msgIdStr = this.msgIdToStrFn(msgId);
const messageId = { msgId, msgIdStr };
// Add the message to the duplicate caches
if (fastMsgIdStr !== undefined && (this.fastMsgIdCache != null)) {
const collision = this.fastMsgIdCache.put(fastMsgIdStr, msgIdStr);
if (collision) {
this.metrics?.fastMsgIdCacheCollision.inc();
}
}
if (this.seenCache.has(msgIdStr)) {
return { code: MessageStatus.duplicate, msgIdStr };
}
else {
this.seenCache.put(msgIdStr);
}
// possibly send IDONTWANTs to mesh peers
if ((rpcMsg.data?.length ?? 0) >= this.opts.idontwantMinDataSize) {
this.sendIDontWants(msgId, rpcMsg.topic, propagationSource.toString());
}
// (Optional) Provide custom validation here with dynamic validators per topic
// NOTE: This custom topicValidator() must resolve fast (< 100ms) to allow scores
// to not penalize peers for long validation times.
const topicValidator = this.topicValidators.get(rpcMsg.topic);
if (topicValidator != null) {
let acceptance;
// Use try {} catch {} in case topicValidator() is synchronous
try {
acceptance = await topicValidator(propagationSource, msg);
}
catch (e) {
const errCode = e.code;
if (errCode === constants.ERR_TOPIC_VALIDATOR_IGNORE)
acceptance = TopicValidatorResult.Ignore;
if (errCode === constants.ERR_TOPIC_VALIDATOR_REJECT)
acceptance = TopicValidatorResult.Reject;
else
acceptance = TopicValidatorResult.Ignore;
}
if (acceptance !== TopicValidatorResult.Accept) {
return { code: MessageStatus.invalid, reason: rejectReasonFromAcceptance(acceptance), msgIdStr };
}
}
return { code: MessageStatus.valid, messageId, msg };
}
/**
* Return score of a peer.
*/
getScore(peerId) {
return this.score.score(peerId);
}
/**
* Send an rpc object to a peer with subscriptions
*/
sendSubscriptions(toPeer, topics, subscribe) {
this.sendRpc(toPeer, {
subscriptions: topics.map((topic) => ({ topic, subscribe })),
messages: []
});
}
/**
* Handles an rpc control message from a peer
*/
async handleControlMessage(id, controlMsg) {
if (controlMsg === undefined) {
return;
}
const iwant = (controlMsg.ihave?.length > 0) ? this.handleIHave(id, controlMsg.ihave) : [];
const ihave = (controlMsg.iwant?.length > 0) ? this.handleIWant(id, controlMsg.iwant) : [];
const prune = (controlMsg.graft?.length > 0) ? await this.handleGraft(id, controlMsg.graft) : [];
(controlMsg.prune?.length > 0) && (await this.handlePrune(id, controlMsg.prune));
(controlMsg.idontwant?.length > 0) && this.handleIdontwant(id, controlMsg.idontwant);
if ((iwant.length === 0) && (ihave.length === 0) && (prune.length === 0)) {
return;
}
const sent = this.sendRpc(id, createGossipRpc(ihave, { iwant, prune }));
const iwantMessageIds = iwant[0]?.messageIDs;
if (iwantMessageIds != null) {
if (sent) {
this.gossipTracer.addPromise(id, iwantMessageIds);
}
else {
this.metrics?.iwantPromiseUntracked.inc(1);
}
}
}
/**
* Whether to accept a message from a peer
*/
acceptFrom(id) {
if (this.direct.has(id)) {
return true;
}
const now = Date.now();
const entry = this.acceptFromWhitelist.get(id);
if ((entry != null) && entry.messagesAccepted < ACCEPT_FROM_WHITELIST_MAX_MESSAGES && entry.acceptUntil >= now) {
entry.messagesAccepted += 1;
return true;
}
const score = this.score.score(id);
if (score >= ACCEPT_FROM_WHITELIST_THRESHOLD_SCORE) {
// peer is unlikely to be able to drop its score to `graylistThreshold`
// after 128 messages or 1s
this.acceptFromWhitelist.set(id, {
messagesAccepted: 0,
acceptUntil: now + ACCEPT_FROM_WHITELIST_DURATION_MS
});
}
else {
this.acceptFromWhitelist.delete(id);
}
return score >= this.opts.scoreThresholds.graylistThreshold;
}
/**
* Handles IHAVE messages
*/
handleIHave(id, ihave) {
if (ihave.length === 0) {
return [];
}
// we ignore IHAVE gossip from any peer whose score is below the gossips threshold
const score = this.score.score(id);
if (score < this.opts.scoreThresholds.gossipThreshold) {
this.log('IHAVE: ignoring peer %s with score below threshold [ score = %d ]', id, score);
this.metrics?.ihaveRcvIgnored.inc({ reason: IHaveIgnoreReason.LowScore });
return [];
}
// IHAVE flood protection
const peerhave = (this.peerhave.get(id) ?? 0) + 1;
this.peerhave.set(id, peerhave);
if (peerhave > constants.GossipsubMaxIHaveMessages) {
this.log('IHAVE: peer %s has advertised too many times (%d) within this heartbeat interval; ignoring', id, peerhave);
this.metrics?.ihaveRcvIgnored.inc({ reason: IHaveIgnoreReason.MaxIhave });
return [];
}
const iasked = this.iasked.get(id) ?? 0;
if (iasked >= constants.GossipsubMaxIHaveLength) {
this.log('IHAVE: peer %s has already advertised too many messages (%d); ignoring', id, iasked);
this.metrics?.ihaveRcvIgnored.inc({ reason: IHaveIgnoreReason.MaxIasked });
return [];
}
// string msgId => msgId
const iwant = new Map();
ihave.forEach(({ topicID, messageIDs }) => {
if (topicID == null || (messageIDs == null) || !this.mesh.has(topicID)) {
return;
}
let idonthave = 0;
messageIDs.forEach((msgId) => {
const msgIdStr = this.msgIdToStrFn(msgId);
if (!this.seenCache.has(msgIdStr)) {
iwant.set(msgIdStr, msgId);
idonthave++;
}
});
this.metrics?.onIhaveRcv(topicID, messageIDs.length, idonthave);
});
if (iwant.size === 0) {
return [];
}
let iask = iwant.size;
if (iask + iasked > constants.GossipsubMaxIHaveLength) {
iask = constants.GossipsubMaxIHaveLength - iasked;
}
this.log('IHAVE: Asking for %d out of %d messages from %s', iask, iwant.size, id);
let iwantList = Array.from(iwant.values());
// ask in random order
shuffle(iwantList);
// truncate to the messages we are actually asking for and update the iasked counter
iwantList = iwantList.slice(0, iask);
this.iasked.set(id, iasked + iask);
// do not add gossipTracer promise here until a successful sendRpc()
return [
{
messageIDs: iwantList
}
];
}
/**
* Handles IWANT messages
* Returns messages to send back to peer
*/
handleIWant(id, iwant) {
if (iwant.length === 0) {
return [];
}
// we don't respond to IWANT requests from any per whose score is below the gossip threshold
const score = this.score.score(id);
if (score < this.opts.scoreThresholds.gossipThreshold) {
this.log('IWANT: ignoring peer %s with score below threshold [score = %d]', id, score);
return [];
}
const ihave = new Map();
const iwantByTopic = new Map();
let iwantDonthave = 0;
iwant.forEach(({ messageIDs }) => {
messageIDs?.forEach((msgId) => {
const msgIdStr = this.msgIdToStrFn(msgId);
const entry = this.mcache.getWithIWantCount(msgIdStr, id);
if (entry == null) {
iwantDonthave++;
return;
}
iwantByTopic.set(entry.msg.topic, 1 + (iwantByTopic.get(entry.msg.topic) ?? 0));
if (entry.count > constants.GossipsubGossipRetransmission) {
this.log('IWANT: Peer %s has asked for message %s too many times: ignoring request', id, msgId);
return;
}
ihave.set(msgIdStr, entry.msg);
});
});
this.metrics?.onIwantRcv(iwantByTopic, iwantDonthave);
if (ihave.size === 0) {
this.log('IWANT: Could not provide any wanted messages to %s', id);
return [];
}
this.log('IWANT: Sending %d messages to %s', ihave.size, id);
return Array.from(ihave.values());
}
/**
* Handles Graft messages
*/
async handleGraft(id, graft) {
const prune = [];
const score = this.score.score(id);
const now = Date.now();
let doPX = this.opts.doPX;
graft.forEach(({ topicID }) => {
if (topicID == null) {
return;
}
const peersInMesh = this.mesh.get(topicID);
if (peersInMesh == null) {
// don't do PX when there is an unknown topic to avoid leaking our peers
doPX = false;
// spam hardening: ignore GRAFTs for unknown topics
return;
}
// check if peer is already in the mesh; if so do nothing
if (peersInMesh.has(id)) {
return;
}
const backoffExpiry = this.backoff.get(topicID)?.get(id);
// This if/else chain contains the various cases of valid (and semi-valid) GRAFTs
// Most of these cases result in a PRUNE immediately being sent in response
// we don't GRAFT to/from direct peers; complain loudly if this happens
if (this.direct.has(id)) {
this.log('GRAFT: ignoring request from direct peer %s', id);
// this is possibly a bug from a non-reciprical configuration; send a PRUNE
prune.push(topicID);
// but don't px
doPX = false;