From d7cdd4aa7edbf6fd2bd71d14514ae32fff1159a0 Mon Sep 17 00:00:00 2001 From: tuyennhv Date: Tue, 29 Mar 2022 16:22:31 +0700 Subject: [PATCH] Compute score using lodestar score and gossipsub score (#3875) * Refactor PeerRpcScoreStore: add PeerScore class * Aggregate lodestarScore, gossipsubScore to compute final score * updateGossipsubScores util and unit test * Populate PeerScore on updateGossipsubScore * Fix peerManager e2e test * Fix test/sim/multiNodeSingleThread.test.ts --- packages/lodestar/package.json | 2 +- .../lodestar/src/network/gossip/gossipsub.ts | 5 +- packages/lodestar/src/network/network.ts | 1 + .../lodestar/src/network/peers/peerManager.ts | 25 ++- packages/lodestar/src/network/peers/score.ts | 169 ++++++++++++++---- .../e2e/network/peers/peerManager.test.ts | 3 +- .../test/sim/multiNodeSingleThread.test.ts | 14 +- .../test/unit/network/peers/score.test.ts | 46 ++++- yarn.lock | 4 +- 9 files changed, 221 insertions(+), 48 deletions(-) diff --git a/packages/lodestar/package.json b/packages/lodestar/package.json index b70c4d8bb45e..399361675133 100644 --- a/packages/lodestar/package.json +++ b/packages/lodestar/package.json @@ -96,7 +96,7 @@ "jwt-simple": "0.5.6", "libp2p": "^0.36.2", "libp2p-bootstrap": "^0.14.0", - "libp2p-gossipsub": "tuyennhv/js-libp2p-gossipsub#6a9965ecf095182c05808f2064f3a63d95ce707c", + "libp2p-gossipsub": "tuyennhv/js-libp2p-gossipsub#4b14a2640d23cbe3a8352cb9fd4b9ebb058f61f9", "libp2p-mdns": "^0.18.0", "libp2p-mplex": "^0.10.5", "libp2p-tcp": "^0.17.2", diff --git a/packages/lodestar/src/network/gossip/gossipsub.ts b/packages/lodestar/src/network/gossip/gossipsub.ts index d055727609c0..701fd87d2c6e 100644 --- a/packages/lodestar/src/network/gossip/gossipsub.ts +++ b/packages/lodestar/src/network/gossip/gossipsub.ts @@ -1,7 +1,7 @@ import Libp2p from "libp2p"; import Gossipsub from "libp2p-gossipsub"; import {GossipsubMessage, SignaturePolicy, TopicStr} from "libp2p-gossipsub/src/types"; -import {PeerScore} from "libp2p-gossipsub/src/score"; +import {PeerScore, PeerScoreParams} from "libp2p-gossipsub/src/score"; import PeerId from "peer-id"; import {AbortSignal} from "@chainsafe/abort-controller"; import {IBeaconConfig} from "@chainsafe/lodestar-config"; @@ -76,6 +76,7 @@ export type Eth2GossipsubOpts = { */ export class Eth2Gossipsub extends Gossipsub { readonly jobQueues: GossipJobQueues; + readonly scoreParams: Partial; private readonly config: IBeaconConfig; private readonly logger: ILogger; @@ -109,6 +110,7 @@ export class Eth2Gossipsub extends Gossipsub { metricsRegister: modules.metrics ? ((modules.metrics.register as unknown) as MetricsRegister) : null, metricsTopicStrToLabel: modules.metrics ? getMetricsTopicStrToLabel(modules.config) : undefined, }); + this.scoreParams = scoreParams; const {config, logger, metrics, signal, gossipHandlers} = modules; this.config = config; this.logger = logger; @@ -145,6 +147,7 @@ export class Eth2Gossipsub extends Gossipsub { this.logger.verbose("Publish to topic", {topic: topicStr}); const sszType = getGossipSSZType(topic); const messageData = (sszType.serialize as (object: GossipTypeMap[GossipType]) => Uint8Array)(object); + // TODO: log number of sent peers await this.publish(topicStr, messageData); } diff --git a/packages/lodestar/src/network/network.ts b/packages/lodestar/src/network/network.ts index 8cce93707903..b7cfe60c7f02 100644 --- a/packages/lodestar/src/network/network.ts +++ b/packages/lodestar/src/network/network.ts @@ -94,6 +94,7 @@ export class Network implements INetwork { { libp2p, reqResp: this.reqResp, + gossip: this.gossip, attnetsService: this.attnetsService, syncnetsService: this.syncnetsService, logger, diff --git a/packages/lodestar/src/network/peers/peerManager.ts b/packages/lodestar/src/network/peers/peerManager.ts index 2526406b2cd9..9e78cb1050c7 100644 --- a/packages/lodestar/src/network/peers/peerManager.ts +++ b/packages/lodestar/src/network/peers/peerManager.ts @@ -12,7 +12,7 @@ import {IReqResp, ReqRespMethod, RequestTypedContainer} from "../reqresp"; import {prettyPrintPeerId, getClientFromPeerStore} from "../util"; import {ISubnetsService} from "../subnets"; import {PeerDiscovery, SubnetDiscvQueryMs} from "./discover"; -import {IPeerRpcScoreStore, ScoreState} from "./score"; +import {IPeerRpcScoreStore, ScoreState, updateGossipsubScores} from "./score"; import { getConnectedPeerIds, hasSomeConnectedPeer, @@ -21,6 +21,7 @@ import { renderIrrelevantPeerType, } from "./utils"; import {SubnetType} from "../metadata"; +import {Eth2Gossipsub} from "../gossip/gossipsub"; /** heartbeat performs regular updates such as updating reputations and performing discovery requests */ const HEARTBEAT_INTERVAL_MS = 30 * 1000; @@ -34,6 +35,11 @@ const STATUS_INBOUND_GRACE_PERIOD = 15 * 1000; /** Internal interval to check PING and STATUS timeouts */ const CHECK_PING_STATUS_INTERVAL = 10 * 1000; +/** + * Relative factor of peers that are allowed to have a negative gossipsub score without penalizing them in lodestar. + */ +const ALLOWED_NEGATIVE_GOSSIPSUB_FACTOR = 0.1; + // TODO: // maxPeers and targetPeers should be dynamic on the num of validators connected // The Node should compute a recomended value every interval and log a warning @@ -64,6 +70,7 @@ export type PeerManagerModules = { logger: ILogger; metrics: IMetrics | null; reqResp: IReqResp; + gossip: Eth2Gossipsub; attnetsService: ISubnetsService; syncnetsService: ISubnetsService; chain: IBeaconChain; @@ -103,6 +110,7 @@ export class PeerManager { private logger: ILogger; private metrics: IMetrics | null; private reqResp: IReqResp; + private gossipsub: Eth2Gossipsub; private attnetsService: ISubnetsService; private syncnetsService: ISubnetsService; private chain: IBeaconChain; @@ -123,6 +131,7 @@ export class PeerManager { this.logger = modules.logger; this.metrics = modules.metrics; this.reqResp = modules.reqResp; + this.gossipsub = modules.gossip; this.attnetsService = modules.attnetsService; this.syncnetsService = modules.syncnetsService; this.chain = modules.chain; @@ -158,6 +167,10 @@ export class PeerManager { this.intervals = [ setInterval(this.pingAndStatusTimeouts.bind(this), CHECK_PING_STATUS_INTERVAL), setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL_MS), + setInterval( + this.updateGossipsubScores.bind(this), + this.gossipsub.scoreParams.decayInterval ?? HEARTBEAT_INTERVAL_MS + ), ]; } @@ -448,6 +461,16 @@ export class PeerManager { } } + private updateGossipsubScores(): void { + const gossipsubScores = new Map(); + for (const peerIdStr of this.connectedPeers.keys()) { + gossipsubScores.set(peerIdStr, this.gossipsub.getScore(peerIdStr)); + } + + const toIgnoreNegativePeers = Math.ceil(this.opts.targetPeers * ALLOWED_NEGATIVE_GOSSIPSUB_FACTOR); + updateGossipsubScores(this.peerRpcScores, gossipsubScores, toIgnoreNegativePeers); + } + private pingAndStatusTimeouts(): void { const now = Date.now(); const peersToStatus: PeerId[] = []; diff --git a/packages/lodestar/src/network/peers/score.ts b/packages/lodestar/src/network/peers/score.ts index 203e2a00114f..e47545f3dd07 100644 --- a/packages/lodestar/src/network/peers/score.ts +++ b/packages/lodestar/src/network/peers/score.ts @@ -1,5 +1,6 @@ import PeerId from "peer-id"; -import {pruneSetToMax} from "../../util/map"; +import {MapDef, pruneSetToMax} from "../../util/map"; +import {gossipScoreThresholds} from "../gossip/scoringParameters"; /** The default score for new peers */ const DEFAULT_SCORE = 0; @@ -7,6 +8,9 @@ const DEFAULT_SCORE = 0; const MIN_SCORE_BEFORE_DISCONNECT = -20; /** The minimum reputation before a peer is banned */ const MIN_SCORE_BEFORE_BAN = -50; +// If a peer has a lodestar score below this constant all other score parts will get ignored and +// the peer will get banned regardless of the other parts. +const MIN_LODESTAR_SCORE_BEFORE_BAN = -60.0; /** The maximum score a peer can obtain */ const MAX_SCORE = 100; /** The minimum score a peer can obtain */ @@ -20,6 +24,12 @@ const HALFLIFE_DECAY_MS = -Math.log(2) / SCORE_HALFLIFE_MS; const BANNED_BEFORE_DECAY_MS = 30 * 60 * 1000; /** Limit of entries in the scores map */ const MAX_ENTRIES = 1000; +/** + * We weight negative gossipsub scores in such a way that they never result in a disconnect by + * themselves. This "solves" the problem of non-decaying gossipsub scores for disconnected peers. + */ +const GOSSIPSUB_NEGATIVE_SCORE_WEIGHT = (MIN_SCORE_BEFORE_DISCONNECT + 1) / gossipScoreThresholds.graylistThreshold; +const GOSSIPSUB_POSITIVE_SCORE_WEIGHT = GOSSIPSUB_NEGATIVE_SCORE_WEIGHT; export enum PeerAction { /** Immediately ban peer */ @@ -70,6 +80,7 @@ export interface IPeerRpcScoreStore { getScoreState(peer: PeerId): ScoreState; applyAction(peer: PeerId, action: PeerAction, actionName?: string): void; update(): void; + updateGossipsubScore(peerId: PeerIdStr, newScore: number, ignore: boolean): void; } /** @@ -78,13 +89,11 @@ export interface IPeerRpcScoreStore { * The decay rate applies equally to positive and negative scores. */ export class PeerRpcScoreStore implements IPeerRpcScoreStore { - private readonly scores = new Map(); - private readonly lastUpdate = new Map(); - + private readonly scores = new MapDef(() => new PeerScore()); // TODO: Persist scores, at least BANNED status to disk getScore(peer: PeerId): number { - return this.scores.get(peer.toB58String()) ?? DEFAULT_SCORE; + return this.scores.get(peer.toB58String())?.getScore() ?? DEFAULT_SCORE; } getScoreState(peer: PeerId): ScoreState { @@ -92,7 +101,8 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore { } applyAction(peer: PeerId, action: PeerAction, actionName?: string): void { - this.add(peer, peerActionScore[action]); + const peerScore = this.scores.getOrDefault(peer.toB58String()); + peerScore.add(peerActionScore[action]); // TODO: Log action to debug + do metrics actionName; @@ -101,56 +111,151 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore { update(): void { // Bound size of data structures pruneSetToMax(this.scores, MAX_ENTRIES); - pruneSetToMax(this.lastUpdate, MAX_ENTRIES); - for (const [peerIdStr, prevScore] of this.scores) { - const newScore = this.decayScore(peerIdStr, prevScore); + for (const [peerIdStr, peerScore] of this.scores) { + const newScore = peerScore.update(); // Prune scores below threshold if (Math.abs(newScore) < SCORE_THRESHOLD) { this.scores.delete(peerIdStr); - this.lastUpdate.delete(peerIdStr); - } - - // If above threshold, persist decayed value - else { - this.scores.set(peerIdStr, newScore); } } } - private decayScore(peer: PeerIdStr, prevScore: number): number { + updateGossipsubScore(peerId: PeerIdStr, newScore: number, ignore: boolean): void { + const peerScore = this.scores.getOrDefault(peerId); + peerScore.updateGossipsubScore(newScore, ignore); + } +} + +/** + * Manage score of a peer. + */ +export class PeerScore { + private lodestarScore: number; + private gossipScore: number; + private ignoreNegativeGossipScore: boolean; + /** The final score, computed from the above */ + private score: number; + private lastUpdate: number; + + constructor() { + this.lodestarScore = DEFAULT_SCORE; + this.gossipScore = DEFAULT_SCORE; + this.score = DEFAULT_SCORE; + this.ignoreNegativeGossipScore = false; + this.lastUpdate = Date.now(); + } + + getScore(): number { + return this.score; + } + + add(scoreDelta: number): void { + let newScore = this.lodestarScore + scoreDelta; + if (newScore > MAX_SCORE) newScore = MAX_SCORE; + if (newScore < MIN_SCORE) newScore = MIN_SCORE; + + this.setLodestarScore(newScore); + } + + /** + * Applies time-based logic such as decay rates to the score. + * This function should be called periodically. + * + * Return the new score. + */ + update(): number { const nowMs = Date.now(); - const lastUpdate = this.lastUpdate.get(peer) ?? nowMs; // Decay the current score // Using exponential decay based on a constant half life. - const sinceLastUpdateMs = nowMs - lastUpdate; + const sinceLastUpdateMs = nowMs - this.lastUpdate; // If peer was banned, lastUpdate will be in the future - if (sinceLastUpdateMs > 0 && prevScore !== 0) { - this.lastUpdate.set(peer, nowMs); + if (sinceLastUpdateMs > 0 && this.lodestarScore !== 0) { + this.lastUpdate = nowMs; // e^(-ln(2)/HL*t) const decayFactor = Math.exp(HALFLIFE_DECAY_MS * sinceLastUpdateMs); - return prevScore * decayFactor; - } else { - return prevScore; + this.setLodestarScore(this.lodestarScore * decayFactor); } + + return this.lodestarScore; } - private add(peer: PeerId, scoreDelta: number): void { - const prevScore = this.getScore(peer); + updateGossipsubScore(newScore: number, ignore: boolean): void { + // we only update gossipsub if last_updated is in the past which means either the peer is + // not banned or the BANNED_BEFORE_DECAY time is over. + if (this.lastUpdate <= Date.now()) { + this.gossipScore = newScore; + this.ignoreNegativeGossipScore = ignore; + } + } - let newScore = this.decayScore(peer.toB58String(), prevScore) + scoreDelta; - if (newScore > MAX_SCORE) newScore = MAX_SCORE; - if (newScore < MIN_SCORE) newScore = MIN_SCORE; + /** + * Updating lodestarScore should always go through this method, + * so that we update this.score accordingly. + */ + private setLodestarScore(newScore: number): void { + this.lodestarScore = newScore; + this.updateState(); + } + + /** + * Compute the final score, ban peer if needed + */ + private updateState(): void { + const prevState = scoreToState(this.score); + this.recomputeScore(); + const newState = scoreToState(this.score); - const prevState = scoreToState(prevScore); - const newState = scoreToState(newScore); if (prevState !== ScoreState.Banned && newState === ScoreState.Banned) { // ban this peer for at least BANNED_BEFORE_DECAY_MS seconds - this.lastUpdate.set(peer.toB58String(), Date.now() + BANNED_BEFORE_DECAY_MS); + this.lastUpdate = Date.now() + BANNED_BEFORE_DECAY_MS; } + } - this.scores.set(peer.toB58String(), newScore); + /** + * Compute the final score + */ + private recomputeScore(): void { + this.score = this.lodestarScore; + if (this.score <= MIN_LODESTAR_SCORE_BEFORE_BAN) { + // ignore all other scores, i.e. do nothing here + return; + } + + if (this.gossipScore >= 0) { + this.score += this.gossipScore * GOSSIPSUB_POSITIVE_SCORE_WEIGHT; + } else if (!this.ignoreNegativeGossipScore) { + this.score += this.gossipScore * GOSSIPSUB_NEGATIVE_SCORE_WEIGHT; + } + } +} + +/** + * Utility to update gossipsub score of connected peers + */ +export function updateGossipsubScores( + peerRpcScores: IPeerRpcScoreStore, + gossipsubScores: Map, + toIgnoreNegativePeers: number +): void { + // sort by gossipsub score desc + const sortedPeerIds = Array.from(gossipsubScores.keys()).sort( + (a, b) => (gossipsubScores.get(b) ?? 0) - (gossipsubScores.get(a) ?? 0) + ); + for (const peerId of sortedPeerIds) { + const gossipsubScore = gossipsubScores.get(peerId); + if (gossipsubScore !== undefined) { + let ignore = false; + if (gossipsubScore < 0 && toIgnoreNegativePeers > 0) { + // We ignore the negative score for the best negative peers so that their + // gossipsub score can recover without getting disconnected. + ignore = true; + toIgnoreNegativePeers -= 1; + } + + peerRpcScores.updateGossipsubScore(peerId, gossipsubScore, ignore); + } } } diff --git a/packages/lodestar/test/e2e/network/peers/peerManager.test.ts b/packages/lodestar/test/e2e/network/peers/peerManager.test.ts index b904579e6ad6..a8d21be54dce 100644 --- a/packages/lodestar/test/e2e/network/peers/peerManager.test.ts +++ b/packages/lodestar/test/e2e/network/peers/peerManager.test.ts @@ -5,7 +5,7 @@ import {expect} from "chai"; import {config} from "@chainsafe/lodestar-config/default"; import {IReqResp, ReqRespMethod} from "../../../../src/network/reqresp"; import {PeerRpcScoreStore, PeerManager} from "../../../../src/network/peers"; -import {NetworkEvent, NetworkEventBus} from "../../../../src/network"; +import {Eth2Gossipsub, NetworkEvent, NetworkEventBus} from "../../../../src/network"; import {createNode, getAttnets, getSyncnets} from "../../../utils/network"; import {MockBeaconChain} from "../../../utils/mocks/chain/chain"; import {generateEmptySignedBlock} from "../../../utils/block"; @@ -82,6 +82,7 @@ describe("network / peers / PeerManager", function () { networkEventBus, attnetsService: mockSubnetsService, syncnetsService: mockSubnetsService, + gossip: ({getScore: () => 0, scoreParams: {decayInterval: 1000}} as unknown) as Eth2Gossipsub, }, { targetPeers: 30, diff --git a/packages/lodestar/test/sim/multiNodeSingleThread.test.ts b/packages/lodestar/test/sim/multiNodeSingleThread.test.ts index e2c6b516b23b..bcbb4a7afe3f 100644 --- a/packages/lodestar/test/sim/multiNodeSingleThread.test.ts +++ b/packages/lodestar/test/sim/multiNodeSingleThread.test.ts @@ -35,8 +35,14 @@ describe("Run multi node single thread interop validators (no eth1) until checkp {nodeCount: 4, validatorsPerNode: 8, event: ChainEvent.justified, altairForkEpoch: 2}, ]; - const afterEachCallbacks: (() => Promise | unknown)[] = []; - afterEach(() => Promise.all(afterEachCallbacks.splice(0, afterEachCallbacks.length))); + const afterEachCallbacks: (() => Promise)[] = []; + // afterEach(() => Promise.all(afterEachCallbacks.splice(0, afterEachCallbacks.length))); + this.afterEach(async () => { + // should call one by one instead of Promise.all() + for (const promise of afterEachCallbacks.splice(0, afterEachCallbacks.length)) { + await promise(); + } + }); // TODO test multiNode with remote; @@ -71,7 +77,6 @@ describe("Run multi node single thread interop validators (no eth1) until checkp genesisTime, logger, }); - afterEachCallbacks.push(() => bn.close()); const {validators: nodeValidators} = await getAndInitDevValidators({ node: bn, @@ -80,7 +85,7 @@ describe("Run multi node single thread interop validators (no eth1) until checkp startIndex: i * validatorsPerNode, testLoggerOpts, }); - afterEachCallbacks.push(() => validators.map((validator) => validator.stop())); + afterEachCallbacks.push(async () => await Promise.all(validators.map((validator) => validator.stop()))); loggers.push(logger); nodes.push(bn); @@ -91,6 +96,7 @@ describe("Run multi node single thread interop validators (no eth1) until checkp afterEachCallbacks.push(async () => { stopInfoTracker(); + await Promise.all(nodes.map((node) => node.close())); console.log("--- Stopped all nodes ---"); // Wait a bit for nodes to shutdown await sleep(3000); diff --git a/packages/lodestar/test/unit/network/peers/score.test.ts b/packages/lodestar/test/unit/network/peers/score.test.ts index 04cb0a35de54..8b6cc55e10f3 100644 --- a/packages/lodestar/test/unit/network/peers/score.test.ts +++ b/packages/lodestar/test/unit/network/peers/score.test.ts @@ -1,6 +1,7 @@ import {expect} from "chai"; import PeerId from "peer-id"; -import {PeerAction, ScoreState, PeerRpcScoreStore} from "../../../../src/network/peers/score"; +import sinon from "sinon"; +import {PeerAction, ScoreState, PeerRpcScoreStore, updateGossipsubScores} from "../../../../src/network/peers/score"; describe("simple block provider score tracking", function () { const peer = PeerId.createFromB58String("Qma9T5YraSnpRDZqRR4krcSJabThc8nwZuJV3LercPHufi"); @@ -8,7 +9,9 @@ describe("simple block provider score tracking", function () { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type function mockStore() { - return {scoreStore: new PeerRpcScoreStore()}; + const scoreStore = new PeerRpcScoreStore(); + const peerScores = scoreStore["scores"]; + return {scoreStore, peerScores}; } it("Should return default score, without any previous action", function () { @@ -40,17 +43,48 @@ describe("simple block provider score tracking", function () { ]; for (const [minScore, timeToDecay] of decayTimes) it(`Should decay MIN_SCORE to ${minScore} after ${timeToDecay} ms`, () => { - const {scoreStore} = mockStore(); - scoreStore["scores"].set(peer.toB58String(), MIN_SCORE); - scoreStore["lastUpdate"].set(peer.toB58String(), Date.now() - timeToDecay * factorForJsBadMath); + const {scoreStore, peerScores} = mockStore(); + const peerScore = peerScores.get(peer.toB58String()); + if (peerScore) { + peerScore["lastUpdate"] = Date.now() - timeToDecay * factorForJsBadMath; + peerScore["lodestarScore"] = MIN_SCORE; + } scoreStore.update(); expect(scoreStore.getScore(peer)).to.be.greaterThan(minScore); }); - it("should not go belove min score", function () { + it("should not go below min score", function () { const {scoreStore} = mockStore(); scoreStore.applyAction(peer, PeerAction.Fatal); scoreStore.applyAction(peer, PeerAction.Fatal); expect(scoreStore.getScore(peer)).to.be.gte(MIN_SCORE); }); }); + +describe("updateGossipsubScores", function () { + const sandbox = sinon.createSandbox(); + const peerRpcScoresStub = sandbox.createStubInstance(PeerRpcScoreStore); + + this.afterEach(() => { + sandbox.restore(); + }); + + it("should update gossipsub peer scores", () => { + updateGossipsubScores( + peerRpcScoresStub, + new Map([ + ["a", 10], + ["b", -10], + ["c", -20], + ["d", -5], + ]), + 2 + ); + expect(peerRpcScoresStub.updateGossipsubScore.calledWith("a", 10, false)).to.be.true; + // should ignore b d since they are 2 biggest negative scores + expect(peerRpcScoresStub.updateGossipsubScore.calledWith("b", -10, true)).to.be.true; + expect(peerRpcScoresStub.updateGossipsubScore.calledWith("d", -5, true)).to.be.true; + // should not ignore c as it's lowest negative scores + expect(peerRpcScoresStub.updateGossipsubScore.calledWith("c", -20, false)).to.be.true; + }); +}); diff --git a/yarn.lock b/yarn.lock index d8e1827cf2a2..b4e151694f86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7172,9 +7172,9 @@ libp2p-crypto@^0.21.2: protobufjs "^6.11.2" uint8arrays "^3.0.0" -libp2p-gossipsub@tuyennhv/js-libp2p-gossipsub#6a9965ecf095182c05808f2064f3a63d95ce707c: +libp2p-gossipsub@tuyennhv/js-libp2p-gossipsub#4b14a2640d23cbe3a8352cb9fd4b9ebb058f61f9: version "0.13.1" - resolved "https://codeload.github.com/tuyennhv/js-libp2p-gossipsub/tar.gz/6a9965ecf095182c05808f2064f3a63d95ce707c" + resolved "https://codeload.github.com/tuyennhv/js-libp2p-gossipsub/tar.gz/4b14a2640d23cbe3a8352cb9fd4b9ebb058f61f9" dependencies: "@types/debug" "^4.1.7" debug "^4.3.1"