feat!: re-architect connection manager (#2445)

* remove public pubsub field and redundant util

* add hangUp and improve dial operations, improve keepAliveManager and remove unused method, move utils and add tests

* improve public dial method to start keep alive checks

* move dial method

* implement discovery dialer

* implement discovery dialer with queue with tests

* add discovery dialer e2e tests, change local discovery log tag, update other tests

* remove comment

* add issue link, remove only

* implement shard reader component

* create evetns module, remove unused connection manager events and related tests

* implement network indicator

* implement connection limiter, change public API of connection manager, implement recovery strategy

* decouple keep alive maanger

* add connection manager js-doc

* refactor keep alive manager, cover with tests

* add tests for connection manager main facade

* add tests for connection limiter

* add e2e tests for connection manager modules
pass js-waku config during test node init
remove dns discovery for js-waku

* restructure dialing tests

* address last e2e tests

* address review

* add logging for main methods

* decouple pure dialer class, update network monitor with specific metrics

* remove console.log

* remove usage of protocols

* update sdk package tests

* add connect await promise

* add debug for e2e tests

* enable only packages tests

* use only one file

* revert debugging

* up interface for netwrok manager

* add logs

* add more logs

* add more logs

* add another logs

* remove .only

* remove log statements

* skip the test with follow up
This commit is contained in:
Sasha 2025-07-09 21:23:14 +02:00 committed by GitHub
parent bfda249aa6
commit c7682ea67c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 5799 additions and 2186 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,201 @@
import { Peer, PeerId } from "@libp2p/interface";
import {
ConnectionManagerOptions,
IWakuEventEmitter,
Libp2p,
Tags
} from "@waku/interfaces";
import { Logger } from "@waku/utils";
import { Dialer } from "./dialer.js";
import { NetworkMonitor } from "./network_monitor.js";
const log = new Logger("connection-limiter");
type Libp2pEventHandler<T> = (e: CustomEvent<T>) => void;
type ConnectionLimiterConstructorOptions = {
libp2p: Libp2p;
events: IWakuEventEmitter;
dialer: Dialer;
networkMonitor: NetworkMonitor;
options: ConnectionManagerOptions;
};
interface IConnectionLimiter {
start(): void;
stop(): void;
}
/**
* This class is responsible for limiting the number of connections to peers.
* It also dials all known peers because libp2p might have emitted `peer:discovery` before initialization
* and listen to `peer:connect` and `peer:disconnect` events to manage connections.
*/
export class ConnectionLimiter implements IConnectionLimiter {
private readonly libp2p: Libp2p;
private readonly events: IWakuEventEmitter;
private readonly networkMonitor: NetworkMonitor;
private readonly dialer: Dialer;
private readonly options: ConnectionManagerOptions;
public constructor(options: ConnectionLimiterConstructorOptions) {
this.libp2p = options.libp2p;
this.events = options.events;
this.networkMonitor = options.networkMonitor;
this.dialer = options.dialer;
this.options = options.options;
this.onWakuConnectionEvent = this.onWakuConnectionEvent.bind(this);
this.onConnectedEvent = this.onConnectedEvent.bind(this);
this.onDisconnectedEvent = this.onDisconnectedEvent.bind(this);
}
public start(): void {
// dial all known peers because libp2p might have emitted `peer:discovery` before initialization
void this.dialPeersFromStore();
this.events.addEventListener("waku:connection", this.onWakuConnectionEvent);
this.libp2p.addEventListener(
"peer:connect",
this.onConnectedEvent as Libp2pEventHandler<PeerId>
);
/**
* NOTE: Event is not being emitted on closing nor losing a connection.
* @see https://github.com/libp2p/js-libp2p/issues/939
* @see https://github.com/status-im/js-waku/issues/252
*
* >This event will be triggered anytime we are disconnected from another peer,
* >regardless of the circumstances of that disconnection.
* >If we happen to have multiple connections to a peer,
* >this event will **only** be triggered when the last connection is closed.
* @see https://github.com/libp2p/js-libp2p/blob/bad9e8c0ff58d60a78314077720c82ae331cc55b/doc/API.md?plain=1#L2100
*/
this.libp2p.addEventListener(
"peer:disconnect",
this.onDisconnectedEvent as Libp2pEventHandler<PeerId>
);
}
public stop(): void {
this.events.removeEventListener(
"waku:connection",
this.onWakuConnectionEvent
);
this.libp2p.removeEventListener(
"peer:connect",
this.onConnectedEvent as Libp2pEventHandler<PeerId>
);
this.libp2p.removeEventListener(
"peer:disconnect",
this.onDisconnectedEvent as Libp2pEventHandler<PeerId>
);
}
private onWakuConnectionEvent(): void {
if (this.networkMonitor.isBrowserConnected()) {
void this.dialPeersFromStore();
}
}
private async onConnectedEvent(evt: CustomEvent<PeerId>): Promise<void> {
log.info(`Connected to peer ${evt.detail.toString()}`);
const peerId = evt.detail;
const tags = await this.getTagsForPeer(peerId);
const isBootstrap = tags.includes(Tags.BOOTSTRAP);
if (!isBootstrap) {
log.info(
`Connected to peer ${peerId.toString()} is not a bootstrap peer`
);
return;
}
if (await this.hasMoreThanMaxBootstrapConnections()) {
log.info(
`Connected to peer ${peerId.toString()} and node has more than max bootstrap connections ${this.options.maxBootstrapPeers}. Dropping connection.`
);
await this.libp2p.hangUp(peerId);
}
}
private async onDisconnectedEvent(): Promise<void> {
if (this.libp2p.getConnections().length === 0) {
log.info(`No connections, dialing peers from store`);
await this.dialPeersFromStore();
}
}
private async dialPeersFromStore(): Promise<void> {
log.info(`Dialing peers from store`);
const allPeers = await this.libp2p.peerStore.all();
const allConnections = this.libp2p.getConnections();
log.info(
`Found ${allPeers.length} peers in store, and found ${allConnections.length} connections`
);
const promises = allPeers
.filter((p) => !allConnections.some((c) => c.remotePeer.equals(p.id)))
.map((p) => this.dialer.dial(p.id));
try {
log.info(`Dialing ${promises.length} peers from store`);
await Promise.all(promises);
log.info(`Dialed ${promises.length} peers from store`);
} catch (error) {
log.error(`Unexpected error while dialing peer store peers`, error);
}
}
private async hasMoreThanMaxBootstrapConnections(): Promise<boolean> {
try {
const peers = await Promise.all(
this.libp2p
.getConnections()
.map((conn) => conn.remotePeer)
.map((id) => this.getPeer(id))
);
const bootstrapPeers = peers.filter(
(peer) => peer && peer.tags.has(Tags.BOOTSTRAP)
);
return bootstrapPeers.length > this.options.maxBootstrapPeers;
} catch (error) {
log.error(
`Unexpected error while checking for bootstrap connections`,
error
);
return false;
}
}
private async getPeer(peerId: PeerId): Promise<Peer | null> {
try {
return await this.libp2p.peerStore.get(peerId);
} catch (error) {
log.error(`Failed to get peer ${peerId}, error: ${error}`);
return null;
}
}
private async getTagsForPeer(peerId: PeerId): Promise<string[]> {
try {
const peer = await this.libp2p.peerStore.get(peerId);
return Array.from(peer.tags.keys());
} catch (error) {
log.error(`Failed to get peer ${peerId}, error: ${error}`);
return [];
}
}
}

View File

@ -0,0 +1,606 @@
import { type Peer, type PeerId, type Stream } from "@libp2p/interface";
import { peerIdFromString } from "@libp2p/peer-id";
import { multiaddr, MultiaddrInput } from "@multiformats/multiaddr";
import {
IWakuEventEmitter,
Libp2p,
NetworkConfig,
PubsubTopic
} from "@waku/interfaces";
import { expect } from "chai";
import sinon from "sinon";
import { ConnectionLimiter } from "./connection_limiter.js";
import { ConnectionManager } from "./connection_manager.js";
import { DiscoveryDialer } from "./discovery_dialer.js";
import { KeepAliveManager } from "./keep_alive_manager.js";
import { NetworkMonitor } from "./network_monitor.js";
import { ShardReader } from "./shard_reader.js";
describe("ConnectionManager", () => {
let libp2p: Libp2p;
let events: IWakuEventEmitter;
let networkConfig: NetworkConfig;
let pubsubTopics: PubsubTopic[];
let relay: any;
let connectionManager: ConnectionManager;
let mockPeerId: PeerId;
let mockMultiaddr: MultiaddrInput;
let mockStream: Stream;
// Mock internal components
let mockKeepAliveManager: sinon.SinonStubbedInstance<KeepAliveManager>;
let mockDiscoveryDialer: sinon.SinonStubbedInstance<DiscoveryDialer>;
let mockShardReader: sinon.SinonStubbedInstance<ShardReader>;
let mockNetworkMonitor: sinon.SinonStubbedInstance<NetworkMonitor>;
let mockConnectionLimiter: sinon.SinonStubbedInstance<ConnectionLimiter>;
const createMockPeer = (
id: string,
protocols: string[] = [],
ping = 100
): Peer =>
({
id: peerIdFromString(id),
protocols,
metadata: new Map([["ping", new TextEncoder().encode(ping.toString())]]),
toString: () => id
}) as Peer;
beforeEach(() => {
// Create mock dependencies
libp2p = {
dialProtocol: sinon.stub().resolves({} as Stream),
hangUp: sinon.stub().resolves(),
getPeers: sinon.stub().returns([]),
peerStore: {
get: sinon.stub().resolves(null),
merge: sinon.stub().resolves()
}
} as unknown as Libp2p;
events = {
dispatchEvent: sinon.stub()
} as unknown as IWakuEventEmitter;
networkConfig = {
clusterId: 1,
shards: [0, 1]
} as NetworkConfig;
pubsubTopics = ["/waku/2/rs/1/0", "/waku/2/rs/1/1"];
relay = {
pubsubTopics,
getMeshPeers: sinon.stub().returns([])
};
// Create mock internal components
mockKeepAliveManager = {
start: sinon.stub(),
stop: sinon.stub()
} as unknown as sinon.SinonStubbedInstance<KeepAliveManager>;
mockDiscoveryDialer = {
start: sinon.stub(),
stop: sinon.stub()
} as unknown as sinon.SinonStubbedInstance<DiscoveryDialer>;
mockShardReader = {
isPeerOnTopic: sinon.stub().resolves(true)
} as unknown as sinon.SinonStubbedInstance<ShardReader>;
mockNetworkMonitor = {
start: sinon.stub(),
stop: sinon.stub(),
isConnected: sinon.stub().returns(true)
} as unknown as sinon.SinonStubbedInstance<NetworkMonitor>;
mockConnectionLimiter = {
start: sinon.stub(),
stop: sinon.stub()
} as unknown as sinon.SinonStubbedInstance<ConnectionLimiter>;
// Create test data
mockPeerId = peerIdFromString(
"12D3KooWPjceQuRaNMhcrLF6BaW69PdCXB95h6TBpFf9nAmcL8hE"
);
mockMultiaddr = multiaddr(
"/ip4/127.0.0.1/tcp/60000/p2p/12D3KooWPjceQuRaNMhcrLF6BaW69PdCXB95h6TBpFf9nAmcL8hE"
);
mockStream = {} as Stream;
// Mock the internal component prototype methods
sinon
.stub(KeepAliveManager.prototype, "start")
.callsFake(() => mockKeepAliveManager.start());
sinon
.stub(KeepAliveManager.prototype, "stop")
.callsFake(() => mockKeepAliveManager.stop());
sinon
.stub(DiscoveryDialer.prototype, "start")
.callsFake(() => mockDiscoveryDialer.start());
sinon
.stub(DiscoveryDialer.prototype, "stop")
.callsFake(() => mockDiscoveryDialer.stop());
sinon
.stub(ShardReader.prototype, "isPeerOnTopic")
.callsFake((peerId: PeerId, topic: string) =>
mockShardReader.isPeerOnTopic(peerId, topic)
);
sinon
.stub(NetworkMonitor.prototype, "start")
.callsFake(() => mockNetworkMonitor.start());
sinon
.stub(NetworkMonitor.prototype, "stop")
.callsFake(() => mockNetworkMonitor.stop());
sinon
.stub(NetworkMonitor.prototype, "isConnected")
.callsFake(() => mockNetworkMonitor.isConnected());
sinon
.stub(ConnectionLimiter.prototype, "start")
.callsFake(() => mockConnectionLimiter.start());
sinon
.stub(ConnectionLimiter.prototype, "stop")
.callsFake(() => mockConnectionLimiter.stop());
});
afterEach(() => {
sinon.restore();
});
describe("constructor", () => {
it("should create ConnectionManager with required options", () => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
expect(connectionManager).to.be.instanceOf(ConnectionManager);
});
it("should create ConnectionManager with relay", () => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig,
relay
});
expect(connectionManager).to.be.instanceOf(ConnectionManager);
});
it("should set default options when no config provided", () => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
expect(connectionManager).to.be.instanceOf(ConnectionManager);
// Default options are set internally and tested through behavior
});
it("should merge provided config with defaults", () => {
const customConfig = {
maxBootstrapPeers: 5,
pingKeepAlive: 120
};
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig,
config: customConfig
});
expect(connectionManager).to.be.instanceOf(ConnectionManager);
});
it("should create all internal components", () => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig,
relay
});
expect(connectionManager).to.be.instanceOf(ConnectionManager);
// Internal components are created and tested through their behavior
});
});
describe("start", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig,
relay
});
});
it("should start all internal components", () => {
connectionManager.start();
expect(mockNetworkMonitor.start.calledOnce).to.be.true;
expect(mockDiscoveryDialer.start.calledOnce).to.be.true;
expect(mockKeepAliveManager.start.calledOnce).to.be.true;
expect(mockConnectionLimiter.start.calledOnce).to.be.true;
});
it("should be safe to call multiple times", () => {
connectionManager.start();
connectionManager.start();
expect(mockNetworkMonitor.start.calledTwice).to.be.true;
expect(mockDiscoveryDialer.start.calledTwice).to.be.true;
expect(mockKeepAliveManager.start.calledTwice).to.be.true;
expect(mockConnectionLimiter.start.calledTwice).to.be.true;
});
});
describe("stop", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig,
relay
});
connectionManager.start();
});
it("should stop all internal components", () => {
connectionManager.stop();
expect(mockNetworkMonitor.stop.calledOnce).to.be.true;
expect(mockDiscoveryDialer.stop.calledOnce).to.be.true;
expect(mockKeepAliveManager.stop.calledOnce).to.be.true;
expect(mockConnectionLimiter.stop.calledOnce).to.be.true;
});
it("should be safe to call multiple times", () => {
connectionManager.stop();
connectionManager.stop();
expect(mockNetworkMonitor.stop.calledTwice).to.be.true;
expect(mockDiscoveryDialer.stop.calledTwice).to.be.true;
expect(mockKeepAliveManager.stop.calledTwice).to.be.true;
expect(mockConnectionLimiter.stop.calledTwice).to.be.true;
});
});
describe("isConnected", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
});
it("should delegate to networkMonitor.isConnected()", () => {
mockNetworkMonitor.isConnected.returns(true);
const result = connectionManager.isConnected();
expect(mockNetworkMonitor.isConnected.calledOnce).to.be.true;
expect(result).to.be.true;
});
it("should return false when network is not connected", () => {
mockNetworkMonitor.isConnected.returns(false);
const result = connectionManager.isConnected();
expect(mockNetworkMonitor.isConnected.calledOnce).to.be.true;
expect(result).to.be.false;
});
});
describe("dial", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
});
it("should dial with PeerId and return stream", async () => {
const protocolCodecs = ["/waku/2/store/1.0.0"];
const libp2pStub = libp2p.dialProtocol as sinon.SinonStub;
libp2pStub.resolves(mockStream);
const result = await connectionManager.dial(mockPeerId, protocolCodecs);
expect(libp2pStub.calledOnce).to.be.true;
expect(libp2pStub.calledWith(mockPeerId, protocolCodecs)).to.be.true;
expect(result).to.equal(mockStream);
});
it("should dial with multiaddr and return stream", async () => {
const protocolCodecs = ["/waku/2/store/1.0.0"];
const libp2pStub = libp2p.dialProtocol as sinon.SinonStub;
libp2pStub.resolves(mockStream);
const result = await connectionManager.dial(
mockMultiaddr,
protocolCodecs
);
expect(libp2pStub.calledOnce).to.be.true;
expect(result).to.equal(mockStream);
});
it("should handle dial errors", async () => {
const protocolCodecs = ["/waku/2/store/1.0.0"];
const libp2pStub = libp2p.dialProtocol as sinon.SinonStub;
const error = new Error("Dial failed");
libp2pStub.rejects(error);
try {
await connectionManager.dial(mockPeerId, protocolCodecs);
expect.fail("Should have thrown error");
} catch (e) {
expect(e).to.equal(error);
}
});
});
describe("hangUp", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
});
it("should hang up with PeerId and return true on success", async () => {
const libp2pStub = libp2p.hangUp as sinon.SinonStub;
libp2pStub.resolves();
const result = await connectionManager.hangUp(mockPeerId);
expect(libp2pStub.calledOnce).to.be.true;
expect(libp2pStub.calledWith(mockPeerId)).to.be.true;
expect(result).to.be.true;
});
it("should hang up with multiaddr and return true on success", async () => {
const libp2pStub = libp2p.hangUp as sinon.SinonStub;
libp2pStub.resolves();
const result = await connectionManager.hangUp(mockMultiaddr);
expect(libp2pStub.calledOnce).to.be.true;
expect(result).to.be.true;
});
it("should return false and handle errors gracefully", async () => {
const libp2pStub = libp2p.hangUp as sinon.SinonStub;
libp2pStub.rejects(new Error("Hang up failed"));
const result = await connectionManager.hangUp(mockPeerId);
expect(libp2pStub.calledOnce).to.be.true;
expect(result).to.be.false;
});
});
describe("getConnectedPeers", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
});
it("should return empty array when no peers connected", async () => {
const libp2pStub = libp2p.getPeers as sinon.SinonStub;
libp2pStub.returns([]);
const result = await connectionManager.getConnectedPeers();
expect(libp2pStub.calledOnce).to.be.true;
expect(result).to.deep.equal([]);
});
it("should return all connected peers without codec filter", async () => {
const peer1Id = "12D3KooWPjceQuRaNMhcrLF6BaW69PdCXB95h6TBpFf9nAmcL8hE";
const peer2Id = "12D3KooWNFmTNRsVfUJqGrRMzQiULd4fL2iRKGj4PpNm4F5BhvCw";
const mockPeerIds = [
peerIdFromString(peer1Id),
peerIdFromString(peer2Id)
];
const mockPeers = [
createMockPeer(peer1Id, ["/waku/2/relay/1.0.0"], 50),
createMockPeer(peer2Id, ["/waku/2/store/1.0.0"], 100)
];
const libp2pStub = libp2p.getPeers as sinon.SinonStub;
libp2pStub.returns(mockPeerIds);
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.onCall(0).resolves(mockPeers[0]);
peerStoreStub.onCall(1).resolves(mockPeers[1]);
const result = await connectionManager.getConnectedPeers();
expect(libp2pStub.calledOnce).to.be.true;
expect(peerStoreStub.calledTwice).to.be.true;
expect(result).to.have.length(2);
// Should be sorted by ping (peer1 has lower ping)
expect(result[0].id.toString()).to.equal(peer1Id);
expect(result[1].id.toString()).to.equal(peer2Id);
});
it("should filter peers by codec", async () => {
const peer1Id = "12D3KooWPjceQuRaNMhcrLF6BaW69PdCXB95h6TBpFf9nAmcL8hE";
const peer2Id = "12D3KooWNFmTNRsVfUJqGrRMzQiULd4fL2iRKGj4PpNm4F5BhvCw";
const mockPeerIds = [
peerIdFromString(peer1Id),
peerIdFromString(peer2Id)
];
const mockPeers = [
createMockPeer(peer1Id, ["/waku/2/relay/1.0.0"], 50),
createMockPeer(peer2Id, ["/waku/2/store/1.0.0"], 100)
];
const libp2pStub = libp2p.getPeers as sinon.SinonStub;
libp2pStub.returns(mockPeerIds);
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.onCall(0).resolves(mockPeers[0]);
peerStoreStub.onCall(1).resolves(mockPeers[1]);
const result = await connectionManager.getConnectedPeers(
"/waku/2/relay/1.0.0"
);
expect(result).to.have.length(1);
expect(result[0].id.toString()).to.equal(peer1Id);
});
it("should handle peerStore errors gracefully", async () => {
const peer1Id = "12D3KooWPjceQuRaNMhcrLF6BaW69PdCXB95h6TBpFf9nAmcL8hE";
const peer2Id = "12D3KooWNFmTNRsVfUJqGrRMzQiULd4fL2iRKGj4PpNm4F5BhvCw";
const mockPeerIds = [
peerIdFromString(peer1Id),
peerIdFromString(peer2Id)
];
const mockPeer = createMockPeer(peer2Id, ["/waku/2/store/1.0.0"], 100);
const libp2pStub = libp2p.getPeers as sinon.SinonStub;
libp2pStub.returns(mockPeerIds);
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.onCall(0).rejects(new Error("Peer not found"));
peerStoreStub.onCall(1).resolves(mockPeer);
const result = await connectionManager.getConnectedPeers();
expect(result).to.have.length(1);
expect(result[0].id.toString()).to.equal(peer2Id);
});
it("should sort peers by ping value", async () => {
const peer1Id = "12D3KooWPjceQuRaNMhcrLF6BaW69PdCXB95h6TBpFf9nAmcL8hE";
const peer2Id = "12D3KooWNFmTNRsVfUJqGrRMzQiULd4fL2iRKGj4PpNm4F5BhvCw";
const peer3Id = "12D3KooWMvU9HGhiEHDWYgJDnLj2Z4JHBQMdxFPgWTNKXjHDYKUW";
const mockPeerIds = [
peerIdFromString(peer1Id),
peerIdFromString(peer2Id),
peerIdFromString(peer3Id)
];
const mockPeers = [
createMockPeer(peer1Id, ["/waku/2/relay/1.0.0"], 200),
createMockPeer(peer2Id, ["/waku/2/store/1.0.0"], 50),
createMockPeer(peer3Id, ["/waku/2/filter/1.0.0"], 150)
];
const libp2pStub = libp2p.getPeers as sinon.SinonStub;
libp2pStub.returns(mockPeerIds);
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.onCall(0).resolves(mockPeers[0]);
peerStoreStub.onCall(1).resolves(mockPeers[1]);
peerStoreStub.onCall(2).resolves(mockPeers[2]);
const result = await connectionManager.getConnectedPeers();
expect(result).to.have.length(3);
// Should be sorted by ping: peer2 (50), peer3 (150), peer1 (200)
expect(result[0].id.toString()).to.equal(peer2Id);
expect(result[1].id.toString()).to.equal(peer3Id);
expect(result[2].id.toString()).to.equal(peer1Id);
});
});
describe("isTopicConfigured", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
});
it("should return true when topic is configured", () => {
const result = connectionManager.isTopicConfigured("/waku/2/rs/1/0");
expect(result).to.be.true;
});
it("should return false when topic is not configured", () => {
const result = connectionManager.isTopicConfigured("/waku/2/rs/1/99");
expect(result).to.be.false;
});
});
describe("isPeerOnTopic", () => {
beforeEach(() => {
connectionManager = new ConnectionManager({
libp2p,
events,
pubsubTopics,
networkConfig
});
});
it("should delegate to shardReader.isPeerOnTopic()", async () => {
const topic = "/waku/2/rs/1/0";
mockShardReader.isPeerOnTopic.resolves(true);
const result = await connectionManager.isPeerOnTopic(mockPeerId, topic);
expect(mockShardReader.isPeerOnTopic.calledOnce).to.be.true;
expect(mockShardReader.isPeerOnTopic.calledWith(mockPeerId, topic)).to.be
.true;
expect(result).to.be.true;
});
it("should return false when peer is not on topic", async () => {
const topic = "/waku/2/rs/1/0";
mockShardReader.isPeerOnTopic.resolves(false);
const result = await connectionManager.isPeerOnTopic(mockPeerId, topic);
expect(mockShardReader.isPeerOnTopic.calledOnce).to.be.true;
expect(result).to.be.false;
});
it("should handle shardReader errors", async () => {
const topic = "/waku/2/rs/1/0";
const error = new Error("Shard reader error");
mockShardReader.isPeerOnTopic.rejects(error);
try {
await connectionManager.isPeerOnTopic(mockPeerId, topic);
expect.fail("Should have thrown error");
} catch (e) {
expect(e).to.equal(error);
}
});
});
});

View File

@ -1,166 +1,58 @@
import {
type Connection,
isPeerId,
type Peer,
type PeerId,
type PeerInfo,
type Stream,
TypedEventEmitter
} from "@libp2p/interface";
import { Multiaddr, multiaddr, MultiaddrInput } from "@multiformats/multiaddr";
import { type Peer, type PeerId, type Stream } from "@libp2p/interface";
import { MultiaddrInput } from "@multiformats/multiaddr";
import {
ConnectionManagerOptions,
DiscoveryTrigger,
DNS_DISCOVERY_TAG,
EConnectionStateEvents,
EPeersByDiscoveryEvents,
IConnectionManager,
IConnectionStateEvents,
IPeersByDiscoveryEvents,
IRelay,
PeersByDiscoveryResult,
PubsubTopic,
ShardInfo
IWakuEventEmitter,
NetworkConfig,
PubsubTopic
} from "@waku/interfaces";
import { Libp2p, Tags } from "@waku/interfaces";
import { decodeRelayShard, shardInfoToPubsubTopics } from "@waku/utils";
import { Libp2p } from "@waku/interfaces";
import { Logger } from "@waku/utils";
import { ConnectionLimiter } from "./connection_limiter.js";
import { Dialer } from "./dialer.js";
import { DiscoveryDialer } from "./discovery_dialer.js";
import { KeepAliveManager } from "./keep_alive_manager.js";
import { getPeerPing } from "./utils.js";
import { NetworkMonitor } from "./network_monitor.js";
import { ShardReader } from "./shard_reader.js";
import { getPeerPing, mapToPeerId, mapToPeerIdOrMultiaddr } from "./utils.js";
const log = new Logger("connection-manager");
const DEFAULT_MAX_BOOTSTRAP_PEERS_ALLOWED = 1;
const DEFAULT_MAX_DIAL_ATTEMPTS_FOR_PEER = 3;
const DEFAULT_MAX_PARALLEL_DIALS = 3;
const DEFAULT_PING_KEEP_ALIVE_SEC = 5 * 60;
const DEFAULT_RELAY_KEEP_ALIVE_SEC = 5 * 60;
type ConnectionManagerConstructorOptions = {
libp2p: Libp2p;
events: IWakuEventEmitter;
pubsubTopics: PubsubTopic[];
networkConfig: NetworkConfig;
relay?: IRelay;
config?: Partial<ConnectionManagerOptions>;
};
export class ConnectionManager
extends TypedEventEmitter<IPeersByDiscoveryEvents & IConnectionStateEvents>
implements IConnectionManager
{
// TODO(weboko): make it private
public readonly pubsubTopics: PubsubTopic[];
export class ConnectionManager implements IConnectionManager {
private readonly pubsubTopics: PubsubTopic[];
private readonly keepAliveManager: KeepAliveManager;
private readonly discoveryDialer: DiscoveryDialer;
private readonly dialer: Dialer;
private readonly shardReader: ShardReader;
private readonly networkMonitor: NetworkMonitor;
private readonly connectionLimiter: ConnectionLimiter;
private keepAliveManager: KeepAliveManager;
private options: ConnectionManagerOptions;
private libp2p: Libp2p;
private dialAttemptsForPeer: Map<string, number> = new Map();
private dialErrorsForPeer: Map<string, any> = new Map();
private currentActiveParallelDialCount = 0;
private pendingPeerDialQueue: Array<PeerId> = [];
private isP2PNetworkConnected: boolean = false;
public isConnected(): boolean {
if (globalThis?.navigator && !globalThis?.navigator?.onLine) {
return false;
}
return this.isP2PNetworkConnected;
}
public stop(): void {
this.keepAliveManager.stopAll();
this.libp2p.removeEventListener(
"peer:connect",
this.onEventHandlers["peer:connect"]
);
this.libp2p.removeEventListener(
"peer:disconnect",
this.onEventHandlers["peer:disconnect"]
);
this.libp2p.removeEventListener(
"peer:discovery",
this.onEventHandlers["peer:discovery"]
);
this.stopNetworkStatusListener();
}
public async dropConnection(peerId: PeerId): Promise<void> {
try {
this.keepAliveManager.stop(peerId);
await this.libp2p.hangUp(peerId);
log.info(`Dropped connection with peer ${peerId.toString()}`);
} catch (error) {
log.error(
`Error dropping connection with peer ${peerId.toString()} - ${error}`
);
}
}
public async getPeersByDiscovery(): Promise<PeersByDiscoveryResult> {
const peersDiscovered = await this.libp2p.peerStore.all();
const peersConnected = this.libp2p
.getConnections()
.map((conn) => conn.remotePeer);
const peersDiscoveredByBootstrap: Peer[] = [];
const peersDiscoveredByPeerExchange: Peer[] = [];
const peersDiscoveredByLocal: Peer[] = [];
const peersConnectedByBootstrap: Peer[] = [];
const peersConnectedByPeerExchange: Peer[] = [];
const peersConnectedByLocal: Peer[] = [];
for (const peer of peersDiscovered) {
const tags = await this.getTagNamesForPeer(peer.id);
if (tags.includes(Tags.BOOTSTRAP)) {
peersDiscoveredByBootstrap.push(peer);
} else if (tags.includes(Tags.PEER_EXCHANGE)) {
peersDiscoveredByPeerExchange.push(peer);
} else if (tags.includes(Tags.LOCAL)) {
peersDiscoveredByLocal.push(peer);
}
}
for (const peerId of peersConnected) {
const peer = await this.libp2p.peerStore.get(peerId);
const tags = await this.getTagNamesForPeer(peerId);
if (tags.includes(Tags.BOOTSTRAP)) {
peersConnectedByBootstrap.push(peer);
} else if (tags.includes(Tags.PEER_EXCHANGE)) {
peersConnectedByPeerExchange.push(peer);
} else if (tags.includes(Tags.LOCAL)) {
peersConnectedByLocal.push(peer);
}
}
return {
DISCOVERED: {
[Tags.BOOTSTRAP]: peersDiscoveredByBootstrap,
[Tags.PEER_EXCHANGE]: peersDiscoveredByPeerExchange,
[Tags.LOCAL]: peersDiscoveredByLocal
},
CONNECTED: {
[Tags.BOOTSTRAP]: peersConnectedByBootstrap,
[Tags.PEER_EXCHANGE]: peersConnectedByPeerExchange,
[Tags.LOCAL]: peersConnectedByLocal
}
};
}
public constructor(options: ConnectionManagerConstructorOptions) {
super();
this.libp2p = options.libp2p;
this.pubsubTopics = options.pubsubTopics;
this.options = {
maxDialAttemptsForPeer: DEFAULT_MAX_DIAL_ATTEMPTS_FOR_PEER,
maxBootstrapPeersAllowed: DEFAULT_MAX_BOOTSTRAP_PEERS_ALLOWED,
maxParallelDials: DEFAULT_MAX_PARALLEL_DIALS,
maxBootstrapPeers: DEFAULT_MAX_BOOTSTRAP_PEERS_ALLOWED,
pingKeepAlive: DEFAULT_PING_KEEP_ALIVE_SEC,
relayKeepAlive: DEFAULT_RELAY_KEEP_ALIVE_SEC,
...options.config
@ -175,24 +67,92 @@ export class ConnectionManager
}
});
this.startEventListeners()
.then(() => log.info(`Connection Manager is now running`))
.catch((error) =>
log.error(`Unexpected error while running service`, error)
this.shardReader = new ShardReader({
libp2p: options.libp2p,
networkConfig: options.networkConfig
});
this.dialer = new Dialer({
libp2p: options.libp2p,
shardReader: this.shardReader
});
this.discoveryDialer = new DiscoveryDialer({
libp2p: options.libp2p,
dialer: this.dialer
});
this.networkMonitor = new NetworkMonitor({
libp2p: options.libp2p,
events: options.events
});
this.connectionLimiter = new ConnectionLimiter({
libp2p: options.libp2p,
events: options.events,
networkMonitor: this.networkMonitor,
dialer: this.dialer,
options: this.options
});
}
public start(): void {
this.networkMonitor.start();
this.discoveryDialer.start();
this.keepAliveManager.start();
this.connectionLimiter.start();
}
public stop(): void {
this.networkMonitor.stop();
this.discoveryDialer.stop();
this.keepAliveManager.stop();
this.connectionLimiter.stop();
}
public isConnected(): boolean {
return this.networkMonitor.isConnected();
}
public async dial(
peer: PeerId | MultiaddrInput,
protocolCodecs: string[]
): Promise<Stream> {
const ma = mapToPeerIdOrMultiaddr(peer);
log.info(`Dialing peer ${ma.toString()} with protocols ${protocolCodecs}`);
// must use libp2p directly instead of dialer because we need to dial the peer right away
const stream = await this.libp2p.dialProtocol(ma, protocolCodecs);
log.info(`Dialed peer ${ma.toString()} with protocols ${protocolCodecs}`);
return stream;
}
public async hangUp(peer: PeerId | MultiaddrInput): Promise<boolean> {
const peerId = mapToPeerId(peer);
try {
log.info(`Dropping connection with peer ${peerId.toString()}`);
await this.libp2p.hangUp(peerId);
log.info(`Dropped connection with peer ${peerId.toString()}`);
return true;
} catch (error) {
log.error(
`Error dropping connection with peer ${peerId.toString()} - ${error}`
);
// libp2p emits `peer:discovery` events during its initialization
// which means that before the ConnectionManager is initialized, some peers may have been discovered
// we will dial the peers in peerStore ONCE before we start to listen to the `peer:discovery` events within the ConnectionManager
this.dialPeerStorePeers().catch((error) =>
log.error(`Unexpected error while dialing peer store peers`, error)
);
return false;
}
}
public async getConnectedPeers(codec?: string): Promise<Peer[]> {
const peerIDs = this.libp2p.getPeers();
log.info(`Getting connected peers for codec ${codec}`);
if (peerIDs.length === 0) {
log.info(`No connected peers`);
return [];
}
@ -206,553 +166,28 @@ export class ConnectionManager
})
);
return peers
const result = peers
.filter((p) => !!p)
.filter((p) => (codec ? (p as Peer).protocols.includes(codec) : true))
.sort((left, right) => getPeerPing(left) - getPeerPing(right)) as Peer[];
log.info(`Found ${result.length} connected peers for codec ${codec}`);
return result;
}
private async dialPeerStorePeers(): Promise<void> {
const peerInfos = await this.libp2p.peerStore.all();
const dialPromises = [];
for (const peerInfo of peerInfos) {
if (
this.libp2p.getConnections().find((c) => c.remotePeer === peerInfo.id)
)
continue;
dialPromises.push(this.attemptDial(peerInfo.id));
}
try {
await Promise.all(dialPromises);
} catch (error) {
log.error(`Unexpected error while dialing peer store peers`, error);
}
public isTopicConfigured(pubsubTopic: PubsubTopic): boolean {
return this.pubsubTopics.includes(pubsubTopic);
}
private async startEventListeners(): Promise<void> {
this.startPeerDiscoveryListener();
this.startPeerConnectionListener();
this.startPeerDisconnectionListener();
this.startNetworkStatusListener();
public async hasShardInfo(peerId: PeerId): Promise<boolean> {
return this.shardReader.hasShardInfo(peerId);
}
/**
* Attempts to establish a connection with a peer and set up specified protocols.
* The method handles both PeerId and Multiaddr inputs, manages connection attempts,
* and maintains the connection state.
*
* The dialing process includes:
* 1. Converting input to dialable peer info
* 2. Managing parallel dial attempts
* 3. Attempting to establish protocol-specific connections
* 4. Handling connection failures and retries
* 5. Updating the peer store and connection state
*
* @param {PeerId | MultiaddrInput} peer - The peer to connect to, either as a PeerId or multiaddr
* @param {string[]} [protocolCodecs] - Optional array of protocol-specific codec strings to establish
* (e.g., for LightPush, Filter, Store protocols)
*
* @throws {Error} If the multiaddr is missing a peer ID
* @throws {Error} If the maximum dial attempts are reached and the peer cannot be dialed
* @throws {Error} If there's an error deleting an undialable peer from the peer store
*
* @example
* ```typescript
* // Dial using PeerId
* await connectionManager.dialPeer(peerId);
*
* // Dial using multiaddr with specific protocols
* await connectionManager.dialPeer(multiaddr, [
* "/vac/waku/relay/2.0.0",
* "/vac/waku/lightpush/2.0.0-beta1"
* ]);
* ```
*
* @remarks
* - The method implements exponential backoff through multiple dial attempts
* - Maintains a queue for parallel dial attempts (limited by maxParallelDials)
* - Integrates with the KeepAliveManager for connection maintenance
* - Updates the peer store and connection state after successful/failed attempts
* - If all dial attempts fail, triggers DNS discovery as a fallback
*/
public async dialPeer(peer: PeerId | MultiaddrInput): Promise<Connection> {
let connection: Connection | undefined;
let peerId: PeerId | undefined;
const peerDialInfo = this.getDialablePeerInfo(peer);
const peerIdStr = isPeerId(peerDialInfo)
? peerDialInfo.toString()
: peerDialInfo.getPeerId()!;
this.currentActiveParallelDialCount += 1;
let dialAttempt = 0;
while (dialAttempt < this.options.maxDialAttemptsForPeer) {
try {
log.info(`Dialing peer ${peerDialInfo} on attempt ${dialAttempt + 1}`);
connection = await this.libp2p.dial(peerDialInfo);
peerId = connection.remotePeer;
const tags = await this.getTagNamesForPeer(peerId);
// add tag to connection describing discovery mechanism
// don't add duplicate tags
this.libp2p.getConnections(peerId).forEach((conn) => {
conn.tags = Array.from(new Set([...conn.tags, ...tags]));
});
// instead of deleting the peer from the peer store, we set the dial attempt to -1
// this helps us keep track of peers that have been dialed before
this.dialAttemptsForPeer.set(peerId.toString(), -1);
// Dialing succeeded, break the loop
this.keepAliveManager.start(peerId);
break;
} catch (error) {
if (error instanceof AggregateError) {
// Handle AggregateError
log.error(`Error dialing peer ${peerIdStr} - ${error.errors}`);
} else {
// Handle generic error
log.error(
`Error dialing peer ${peerIdStr} - ${(error as any).message}`
);
}
this.dialErrorsForPeer.set(peerIdStr, error);
dialAttempt++;
this.dialAttemptsForPeer.set(peerIdStr, dialAttempt);
}
}
// Always decrease the active dial count and process the dial queue
this.currentActiveParallelDialCount--;
this.processDialQueue();
// If max dial attempts reached and dialing failed, delete the peer
if (dialAttempt === this.options.maxDialAttemptsForPeer) {
try {
const error = this.dialErrorsForPeer.get(peerIdStr);
if (error) {
let errorMessage;
if (error instanceof AggregateError) {
if (!error.errors) {
log.warn(`No errors array found for AggregateError`);
} else if (error.errors.length === 0) {
log.warn(`Errors array is empty for AggregateError`);
} else {
errorMessage = JSON.stringify(error.errors[0]);
}
} else {
errorMessage = error.message;
}
log.info(
`Deleting undialable peer ${peerIdStr} from peer store. Reason: ${errorMessage}`
);
}
this.dialErrorsForPeer.delete(peerIdStr);
if (peerId) {
await this.libp2p.peerStore.delete(peerId);
}
// if it was last available peer - attempt DNS discovery
await this.attemptDnsDiscovery();
} catch (error) {
throw new Error(
`Error deleting undialable peer ${peerIdStr} from peer store - ${error}`
);
}
}
if (!connection) {
throw new Error(`Failed to dial peer ${peerDialInfo}`);
}
return connection;
}
/**
* Dial a peer with specific protocols.
* This method is a raw proxy to the libp2p dialProtocol method.
* @param peer - The peer to connect to, either as a PeerId or multiaddr
* @param protocolCodecs - Optional array of protocol-specific codec strings to establish
* @returns A stream to the peer
*/
public async rawDialPeerWithProtocols(
peer: PeerId | MultiaddrInput,
protocolCodecs: string[]
): Promise<Stream> {
const peerDialInfo = this.getDialablePeerInfo(peer);
return await this.libp2p.dialProtocol(peerDialInfo, protocolCodecs);
}
/**
* Internal utility to extract a PeerId or Multiaddr from a peer input.
* This is used internally by the connection manager to handle different peer input formats.
* @internal
*/
private getDialablePeerInfo(
peer: PeerId | MultiaddrInput
): PeerId | Multiaddr {
if (isPeerId(peer)) {
return peer;
} else {
// peer is of MultiaddrInput type
const ma = multiaddr(peer);
const peerIdStr = ma.getPeerId();
if (!peerIdStr) {
throw new Error("Failed to dial multiaddr: missing peer ID");
}
return ma;
}
}
private async attemptDnsDiscovery(): Promise<void> {
if (this.libp2p.getConnections().length > 0) return;
if ((await this.libp2p.peerStore.all()).length > 0) return;
log.info("Attempting to trigger DNS discovery.");
const dnsDiscovery = Object.values(this.libp2p.components.components).find(
(v: unknown) => {
if (v && v.toString) {
return v.toString().includes(DNS_DISCOVERY_TAG);
}
return false;
}
) as DiscoveryTrigger;
if (!dnsDiscovery) return;
await dnsDiscovery.findPeers();
}
private processDialQueue(): void {
if (
this.pendingPeerDialQueue.length > 0 &&
this.currentActiveParallelDialCount < this.options.maxParallelDials
) {
const peerId = this.pendingPeerDialQueue.shift();
if (!peerId) return;
this.attemptDial(peerId).catch((error) => {
log.error(error);
});
}
}
private startPeerDiscoveryListener(): void {
this.libp2p.addEventListener(
"peer:discovery",
this.onEventHandlers["peer:discovery"]
);
}
private startPeerConnectionListener(): void {
this.libp2p.addEventListener(
"peer:connect",
this.onEventHandlers["peer:connect"]
);
}
private startPeerDisconnectionListener(): void {
// TODO: ensure that these following issues are updated and confirmed
/**
* NOTE: Event is not being emitted on closing nor losing a connection.
* @see https://github.com/libp2p/js-libp2p/issues/939
* @see https://github.com/status-im/js-waku/issues/252
*
* >This event will be triggered anytime we are disconnected from another peer,
* >regardless of the circumstances of that disconnection.
* >If we happen to have multiple connections to a peer,
* >this event will **only** be triggered when the last connection is closed.
* @see https://github.com/libp2p/js-libp2p/blob/bad9e8c0ff58d60a78314077720c82ae331cc55b/doc/API.md?plain=1#L2100
*/
this.libp2p.addEventListener(
"peer:disconnect",
this.onEventHandlers["peer:disconnect"]
);
}
public async attemptDial(peerId: PeerId): Promise<void> {
if (!(await this.shouldDialPeer(peerId))) return;
if (this.currentActiveParallelDialCount >= this.options.maxParallelDials) {
this.pendingPeerDialQueue.push(peerId);
return;
}
await this.dialPeer(peerId);
}
private onEventHandlers = {
"peer:discovery": (evt: CustomEvent<PeerInfo>): void => {
void (async () => {
const { id: peerId } = evt.detail;
await this.dispatchDiscoveryEvent(peerId);
try {
await this.attemptDial(peerId);
} catch (error) {
log.error(`Error dialing peer ${peerId.toString()} : ${error}`);
}
})();
},
"peer:connect": (evt: CustomEvent<PeerId>): void => {
void (async () => {
log.info(`Connected to peer ${evt.detail.toString()}`);
const peerId = evt.detail;
this.keepAliveManager.start(peerId);
const isBootstrap = (await this.getTagNamesForPeer(peerId)).includes(
Tags.BOOTSTRAP
);
if (isBootstrap) {
const bootstrapConnections = this.libp2p
.getConnections()
.filter((conn) => conn.tags.includes(Tags.BOOTSTRAP));
// If we have too many bootstrap connections, drop one
if (
bootstrapConnections.length > this.options.maxBootstrapPeersAllowed
) {
await this.dropConnection(peerId);
} else {
this.dispatchEvent(
new CustomEvent<PeerId>(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
{
detail: peerId
}
)
);
}
} else {
this.dispatchEvent(
new CustomEvent<PeerId>(
EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE,
{
detail: peerId
}
)
);
}
this.setP2PNetworkConnected();
})();
},
"peer:disconnect": (evt: CustomEvent<PeerId>): void => {
void (async () => {
this.keepAliveManager.stop(evt.detail);
this.setP2PNetworkDisconnected();
})();
},
"browser:network": (): void => {
this.dispatchWakuConnectionEvent();
}
};
/**
* Checks if the peer should be dialed based on the following conditions:
* 1. If the peer is already connected, don't dial
* 2. If the peer is not part of any of the configured pubsub topics, don't dial
* 3. If the peer is not dialable based on bootstrap status, don't dial
* 4. If the peer is already has an active dial attempt, or has been dialed before, don't dial it
* @returns true if the peer should be dialed, false otherwise
*/
private async shouldDialPeer(peerId: PeerId): Promise<boolean> {
const isConnected = this.libp2p.getConnections(peerId).length > 0;
if (isConnected) {
log.warn(`Already connected to peer ${peerId.toString()}. Not dialing.`);
return false;
}
const isSameShard = await this.isPeerOnSameShard(peerId);
if (!isSameShard) {
const shardInfo = await this.getPeerShardInfo(peerId);
log.warn(
`Discovered peer ${peerId.toString()} with ShardInfo ${shardInfo} is not part of any of the configured pubsub topics (${
this.pubsubTopics
}).
Not dialing.`
);
return false;
}
const isPreferredBasedOnBootstrap =
await this.isPeerDialableBasedOnBootstrapStatus(peerId);
if (!isPreferredBasedOnBootstrap) {
log.warn(
`Peer ${peerId.toString()} is not dialable based on bootstrap status. Not dialing.`
);
return false;
}
const hasBeenDialed = this.dialAttemptsForPeer.has(peerId.toString());
if (hasBeenDialed) {
log.warn(
`Peer ${peerId.toString()} has already been attempted dial before, or already has a dial attempt in progress, skipping dial`
);
return false;
}
return true;
}
/**
* Checks if the peer is dialable based on the following conditions:
* 1. If the peer is a bootstrap peer, it is only dialable if the number of current bootstrap connections is less than the max allowed.
* 2. If the peer is not a bootstrap peer
*/
private async isPeerDialableBasedOnBootstrapStatus(
peerId: PeerId
): Promise<boolean> {
const tagNames = await this.getTagNamesForPeer(peerId);
const isBootstrap = tagNames.some((tagName) => tagName === Tags.BOOTSTRAP);
if (!isBootstrap) {
return true;
}
const currentBootstrapConnections = this.libp2p
.getConnections()
.filter((conn) => {
return conn.tags.find((name) => name === Tags.BOOTSTRAP);
}).length;
return currentBootstrapConnections < this.options.maxBootstrapPeersAllowed;
}
private async dispatchDiscoveryEvent(peerId: PeerId): Promise<void> {
const isBootstrap = (await this.getTagNamesForPeer(peerId)).includes(
Tags.BOOTSTRAP
);
this.dispatchEvent(
new CustomEvent<PeerId>(
isBootstrap
? EPeersByDiscoveryEvents.PEER_DISCOVERY_BOOTSTRAP
: EPeersByDiscoveryEvents.PEER_DISCOVERY_PEER_EXCHANGE,
{
detail: peerId
}
)
);
}
/**
* Fetches the tag names for a given peer
*/
private async getTagNamesForPeer(peerId: PeerId): Promise<string[]> {
try {
const peer = await this.libp2p.peerStore.get(peerId);
return Array.from(peer.tags.keys());
} catch (error) {
log.error(`Failed to get peer ${peerId}, error: ${error}`);
return [];
}
}
public async isPeerOnSameShard(peerId: PeerId): Promise<boolean> {
const shardInfo = await this.getPeerShardInfo(peerId);
if (!shardInfo) {
return true;
}
const pubsubTopics = shardInfoToPubsubTopics(shardInfo);
const isTopicConfigured = pubsubTopics.some((topic) =>
this.pubsubTopics.includes(topic)
);
return isTopicConfigured;
}
public async isPeerOnPubsubTopic(
public async isPeerOnTopic(
peerId: PeerId,
pubsubTopic: string
): Promise<boolean> {
const shardInfo = await this.getPeerShardInfo(peerId);
if (!shardInfo) {
return true;
}
const pubsubTopics = shardInfoToPubsubTopics(shardInfo);
return pubsubTopics.some((t) => t === pubsubTopic);
}
private async getPeerShardInfo(
peerId: PeerId
): Promise<ShardInfo | undefined> {
const peer = await this.libp2p.peerStore.get(peerId);
const shardInfoBytes = peer.metadata.get("shardInfo");
if (!shardInfoBytes) return undefined;
return decodeRelayShard(shardInfoBytes);
}
private startNetworkStatusListener(): void {
try {
globalThis.addEventListener(
"online",
this.onEventHandlers["browser:network"]
);
globalThis.addEventListener(
"offline",
this.onEventHandlers["browser:network"]
);
} catch (err) {
log.error(`Failed to start network listener: ${err}`);
}
}
private stopNetworkStatusListener(): void {
try {
globalThis.removeEventListener(
"online",
this.onEventHandlers["browser:network"]
);
globalThis.removeEventListener(
"offline",
this.onEventHandlers["browser:network"]
);
} catch (err) {
log.error(`Failed to stop network listener: ${err}`);
}
}
private setP2PNetworkConnected(): void {
if (!this.isP2PNetworkConnected) {
this.isP2PNetworkConnected = true;
this.dispatchWakuConnectionEvent();
}
}
private setP2PNetworkDisconnected(): void {
if (
this.isP2PNetworkConnected &&
this.libp2p.getConnections().length === 0
) {
this.isP2PNetworkConnected = false;
this.dispatchWakuConnectionEvent();
}
}
private dispatchWakuConnectionEvent(): void {
this.dispatchEvent(
new CustomEvent<boolean>(EConnectionStateEvents.CONNECTION_STATUS, {
detail: this.isConnected()
})
);
return this.shardReader.isPeerOnTopic(peerId, pubsubTopic);
}
}

View File

@ -0,0 +1,411 @@
import { PeerId } from "@libp2p/interface";
import { Libp2p } from "@waku/interfaces";
import { expect } from "chai";
import sinon from "sinon";
import { Dialer } from "./dialer.js";
import { ShardReader } from "./shard_reader.js";
describe("Dialer", () => {
let libp2p: Libp2p;
let dialer: Dialer;
let mockShardReader: sinon.SinonStubbedInstance<ShardReader>;
let mockPeerId: PeerId;
let mockPeerId2: PeerId;
let clock: sinon.SinonFakeTimers;
const createMockPeerId = (id: string): PeerId =>
({
toString: () => id,
equals: (other: PeerId) => other.toString() === id
}) as PeerId;
beforeEach(() => {
libp2p = {
dial: sinon.stub().resolves(),
getPeers: sinon.stub().returns([])
} as unknown as Libp2p;
mockShardReader = {
hasShardInfo: sinon.stub().resolves(false),
isPeerOnNetwork: sinon.stub().resolves(true)
} as unknown as sinon.SinonStubbedInstance<ShardReader>;
mockPeerId = createMockPeerId("12D3KooWTest1");
mockPeerId2 = createMockPeerId("12D3KooWTest2");
clock = sinon.useFakeTimers();
});
afterEach(() => {
if (dialer) {
dialer.stop();
}
clock.restore();
sinon.restore();
});
describe("constructor", () => {
it("should create dialer with libp2p and shardReader", () => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
expect(dialer).to.be.instanceOf(Dialer);
});
});
describe("start", () => {
beforeEach(() => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
});
it("should start the dialing interval", () => {
dialer.start();
expect(clock.countTimers()).to.be.greaterThan(0);
});
it("should clear dial history on start", () => {
dialer.start();
void dialer.dial(mockPeerId);
dialer.stop();
dialer.start();
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resetHistory();
void dialer.dial(mockPeerId);
expect(dialStub.called).to.be.false;
});
it("should not create multiple intervals when called multiple times", () => {
dialer.start();
dialer.start();
expect(clock.countTimers()).to.equal(1);
});
});
describe("stop", () => {
beforeEach(() => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
dialer.start();
});
it("should clear the dialing interval", () => {
expect(clock.countTimers()).to.be.greaterThan(0);
dialer.stop();
expect(clock.countTimers()).to.equal(0);
});
it("should clear dial history on stop", () => {
dialer.stop();
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resetHistory();
dialer.start();
void dialer.dial(mockPeerId);
expect(dialStub.called).to.be.false;
});
it("should be safe to call multiple times", () => {
dialer.stop();
dialer.stop();
expect(clock.countTimers()).to.equal(0);
});
});
describe("dial", () => {
beforeEach(() => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
dialer.start();
});
it("should dial peer immediately when queue is empty", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
});
it("should add peer to queue when queue is not empty", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
void dialer.dial(mockPeerId);
dialStub.resetHistory();
await dialer.dial(mockPeerId2);
expect(dialStub.called).to.be.true;
expect(dialStub.calledWith(mockPeerId2)).to.be.true;
});
it("should skip peer when already connected", async () => {
const getPeersStub = libp2p.getPeers as sinon.SinonStub;
getPeersStub.returns([mockPeerId]);
const dialStub = libp2p.dial as sinon.SinonStub;
await dialer.dial(mockPeerId);
expect(dialStub.called).to.be.false;
});
it("should skip peer when dialed recently", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
dialStub.resetHistory();
clock.tick(5000);
await dialer.dial(mockPeerId);
expect(dialStub.called).to.be.true;
});
it("should allow redial after cooldown period", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
clock.tick(10001);
await dialer.dial(mockPeerId);
expect(dialStub.calledTwice).to.be.true;
});
it("should skip peer when not on same shard", async () => {
mockShardReader.hasShardInfo.resolves(true);
mockShardReader.isPeerOnNetwork.resolves(false);
const dialStub = libp2p.dial as sinon.SinonStub;
await dialer.dial(mockPeerId);
expect(dialStub.called).to.be.false;
expect(mockShardReader.hasShardInfo.calledWith(mockPeerId)).to.be.true;
expect(mockShardReader.isPeerOnNetwork.calledWith(mockPeerId)).to.be.true;
});
it("should dial peer when on same shard", async () => {
mockShardReader.hasShardInfo.resolves(true);
mockShardReader.isPeerOnNetwork.resolves(true);
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
expect(mockShardReader.hasShardInfo.calledWith(mockPeerId)).to.be.true;
expect(mockShardReader.isPeerOnNetwork.calledWith(mockPeerId)).to.be.true;
});
it("should dial peer when no shard info available", async () => {
mockShardReader.hasShardInfo.resolves(false);
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
expect(mockShardReader.hasShardInfo.calledWith(mockPeerId)).to.be.true;
expect(mockShardReader.isPeerOnNetwork.called).to.be.false;
});
it("should handle dial errors gracefully", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.rejects(new Error("Dial failed"));
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
});
});
describe("queue processing", () => {
beforeEach(() => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
dialer.start();
});
it("should process queue every 500ms", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
dialStub.resetHistory();
await dialer.dial(mockPeerId2);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId2)).to.be.true;
});
it("should process up to 3 peers at once", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
const mockPeerId3 = createMockPeerId("12D3KooWTest3");
const mockPeerId4 = createMockPeerId("12D3KooWTest4");
const mockPeerId5 = createMockPeerId("12D3KooWTest5");
dialStub.resolves();
await dialer.dial(mockPeerId);
await dialer.dial(mockPeerId2);
await dialer.dial(mockPeerId3);
await dialer.dial(mockPeerId4);
await dialer.dial(mockPeerId5);
expect(dialStub.callCount).to.equal(5);
expect(dialStub.calledWith(mockPeerId)).to.be.true;
expect(dialStub.calledWith(mockPeerId2)).to.be.true;
expect(dialStub.calledWith(mockPeerId3)).to.be.true;
expect(dialStub.calledWith(mockPeerId4)).to.be.true;
expect(dialStub.calledWith(mockPeerId5)).to.be.true;
});
it("should not process empty queue", () => {
const dialStub = libp2p.dial as sinon.SinonStub;
clock.tick(500);
expect(dialStub.called).to.be.false;
});
it("should handle queue processing errors gracefully", async () => {
const dialStub = libp2p.dial as sinon.SinonStub;
let resolveFirstDial: () => void;
const firstDialPromise = new Promise<void>((resolve) => {
resolveFirstDial = resolve;
});
dialStub.onFirstCall().returns(firstDialPromise);
dialStub.onSecondCall().rejects(new Error("Queue dial failed"));
const firstDialPromise2 = dialer.dial(mockPeerId);
await dialer.dial(mockPeerId2);
resolveFirstDial!();
await firstDialPromise2;
clock.tick(500);
await Promise.resolve();
expect(dialStub.calledTwice).to.be.true;
});
});
describe("shard reader integration", () => {
beforeEach(() => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
dialer.start();
});
it("should handle shard reader errors gracefully", async () => {
mockShardReader.hasShardInfo.rejects(new Error("Shard reader error"));
const dialStub = libp2p.dial as sinon.SinonStub;
await dialer.dial(mockPeerId);
expect(dialStub.called).to.be.false;
expect(mockShardReader.hasShardInfo.calledWith(mockPeerId)).to.be.true;
});
it("should handle network check errors gracefully", async () => {
mockShardReader.hasShardInfo.resolves(true);
mockShardReader.isPeerOnNetwork.rejects(new Error("Network check error"));
const dialStub = libp2p.dial as sinon.SinonStub;
await dialer.dial(mockPeerId);
expect(dialStub.called).to.be.false;
expect(mockShardReader.hasShardInfo.calledWith(mockPeerId)).to.be.true;
expect(mockShardReader.isPeerOnNetwork.calledWith(mockPeerId)).to.be.true;
});
});
describe("integration", () => {
it("should handle complete dial lifecycle", async () => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
dialer.start();
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
await dialer.dial(mockPeerId);
expect(dialStub.calledOnce).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
dialer.stop();
});
it("should handle multiple peers with different shard configurations", async () => {
dialer = new Dialer({
libp2p,
shardReader: mockShardReader
});
dialer.start();
const dialStub = libp2p.dial as sinon.SinonStub;
dialStub.resolves();
mockShardReader.hasShardInfo.withArgs(mockPeerId).resolves(true);
mockShardReader.isPeerOnNetwork.withArgs(mockPeerId).resolves(true);
mockShardReader.hasShardInfo.withArgs(mockPeerId2).resolves(false);
await dialer.dial(mockPeerId);
await dialer.dial(mockPeerId2);
expect(dialStub.calledTwice).to.be.true;
expect(dialStub.calledWith(mockPeerId)).to.be.true;
expect(dialStub.calledWith(mockPeerId2)).to.be.true;
dialer.stop();
});
});
});

View File

@ -0,0 +1,139 @@
import type { PeerId } from "@libp2p/interface";
import { Libp2p } from "@waku/interfaces";
import { Logger } from "@waku/utils";
import { ShardReader } from "./shard_reader.js";
const log = new Logger("dialer");
type DialerConstructorOptions = {
libp2p: Libp2p;
shardReader: ShardReader;
};
interface IDialer {
start(): void;
stop(): void;
dial(peerId: PeerId): Promise<void>;
}
export class Dialer implements IDialer {
private readonly libp2p: Libp2p;
private readonly shardReader: ShardReader;
private dialingQueue: PeerId[] = [];
private dialHistory: Map<string, number> = new Map();
private dialingInterval: NodeJS.Timeout | null = null;
private isProcessing = false;
public constructor(options: DialerConstructorOptions) {
this.libp2p = options.libp2p;
this.shardReader = options.shardReader;
}
public start(): void {
if (!this.dialingInterval) {
this.dialingInterval = setInterval(() => {
void this.processQueue();
}, 500);
}
this.dialHistory.clear();
}
public stop(): void {
if (this.dialingInterval) {
clearInterval(this.dialingInterval);
this.dialingInterval = null;
}
this.dialHistory.clear();
}
public async dial(peerId: PeerId): Promise<void> {
const shouldSkip = await this.shouldSkipPeer(peerId);
if (shouldSkip) {
log.info(`Skipping peer: ${peerId}`);
return;
}
// If queue is empty and we're not currently processing, dial immediately
if (this.dialingQueue.length === 0 && !this.isProcessing) {
await this.dialPeer(peerId);
} else {
// Add to queue
this.dialingQueue.push(peerId);
log.info(
`Added peer to dialing queue, queue size: ${this.dialingQueue.length}`
);
}
}
private async processQueue(): Promise<void> {
if (this.dialingQueue.length === 0 || this.isProcessing) return;
this.isProcessing = true;
try {
const peersToDial = this.dialingQueue.slice(0, 3);
this.dialingQueue = this.dialingQueue.slice(peersToDial.length);
log.info(
`Processing dial queue: dialing ${peersToDial.length} peers, ${this.dialingQueue.length} remaining in queue`
);
await Promise.all(peersToDial.map((peerId) => this.dialPeer(peerId)));
} finally {
this.isProcessing = false;
}
}
private async dialPeer(peerId: PeerId): Promise<void> {
try {
log.info(`Dialing peer from queue: ${peerId}`);
await this.libp2p.dial(peerId);
this.dialHistory.set(peerId.toString(), Date.now());
log.info(`Successfully dialed peer from queue: ${peerId}`);
} catch (error) {
log.error(`Error dialing peer ${peerId}`, error);
}
}
private async shouldSkipPeer(peerId: PeerId): Promise<boolean> {
const hasConnection = this.libp2p.getPeers().some((p) => p.equals(peerId));
if (hasConnection) {
log.info(`Skipping peer ${peerId} - already connected`);
return true;
}
const lastDialed = this.dialHistory.get(peerId.toString());
if (lastDialed && Date.now() - lastDialed < 10_000) {
log.info(
`Skipping peer ${peerId} - already dialed in the last 10 seconds`
);
return true;
}
try {
const hasShardInfo = await this.shardReader.hasShardInfo(peerId);
if (!hasShardInfo) {
log.info(`Skipping peer ${peerId} - no shard info`);
return false;
}
const isOnSameShard = await this.shardReader.isPeerOnNetwork(peerId);
if (!isOnSameShard) {
log.info(`Skipping peer ${peerId} - not on same shard`);
return true;
}
return false;
} catch (error) {
log.error(`Error checking shard info for peer ${peerId}`, error);
return true; // Skip peer when there's an error
}
}
}

View File

@ -0,0 +1,304 @@
import { PeerId, PeerInfo } from "@libp2p/interface";
import { expect } from "chai";
import { Libp2p } from "libp2p";
import sinon from "sinon";
import { Dialer } from "./dialer.js";
import { DiscoveryDialer } from "./discovery_dialer.js";
describe("DiscoveryDialer", () => {
let libp2p: Libp2p;
let discoveryDialer: DiscoveryDialer;
let dialer: sinon.SinonStubbedInstance<Dialer>;
let mockPeerId: PeerId;
let mockPeerInfo: PeerInfo;
beforeEach(() => {
libp2p = {
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
peerStore: {
get: sinon.stub().resolves(undefined),
save: sinon.stub().resolves(),
merge: sinon.stub().resolves()
}
} as unknown as Libp2p;
dialer = {
start: sinon.stub(),
stop: sinon.stub(),
dial: sinon.stub().resolves()
} as unknown as sinon.SinonStubbedInstance<Dialer>;
mockPeerId = {
toString: () => "mock-peer-id",
equals: (other: PeerId) => other.toString() === "mock-peer-id"
} as PeerId;
mockPeerInfo = {
id: mockPeerId,
multiaddrs: []
} as PeerInfo;
});
afterEach(() => {
if (discoveryDialer) {
discoveryDialer.stop();
}
sinon.restore();
});
describe("constructor", () => {
it("should create an instance with libp2p and dialer", () => {
discoveryDialer = new DiscoveryDialer({
libp2p,
dialer
});
expect(discoveryDialer).to.be.instanceOf(DiscoveryDialer);
});
});
describe("start", () => {
beforeEach(() => {
discoveryDialer = new DiscoveryDialer({
libp2p,
dialer
});
});
it("should add event listener for peer:discovery", () => {
discoveryDialer.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
expect(addEventListenerStub.calledOnce).to.be.true;
expect(
addEventListenerStub.calledWith("peer:discovery", sinon.match.func)
).to.be.true;
});
it("should be safe to call multiple times", () => {
discoveryDialer.start();
discoveryDialer.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
expect(addEventListenerStub.calledTwice).to.be.true;
});
});
describe("stop", () => {
beforeEach(() => {
discoveryDialer = new DiscoveryDialer({
libp2p,
dialer
});
discoveryDialer.start();
});
it("should remove event listener for peer:discovery", () => {
discoveryDialer.stop();
const removeEventListenerStub =
libp2p.removeEventListener as sinon.SinonStub;
expect(removeEventListenerStub.calledOnce).to.be.true;
expect(
removeEventListenerStub.calledWith("peer:discovery", sinon.match.func)
).to.be.true;
});
it("should be safe to call multiple times", () => {
discoveryDialer.stop();
discoveryDialer.stop();
const removeEventListenerStub =
libp2p.removeEventListener as sinon.SinonStub;
expect(removeEventListenerStub.calledTwice).to.be.true;
});
});
describe("peer discovery handling", () => {
let eventHandler: (event: CustomEvent<PeerInfo>) => Promise<void>;
beforeEach(() => {
discoveryDialer = new DiscoveryDialer({
libp2p,
dialer
});
discoveryDialer.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
eventHandler = addEventListenerStub.getCall(0).args[1];
});
it("should dial peer when peer is discovered", async () => {
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(undefined);
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfo
});
await eventHandler(mockEvent);
expect(dialer.dial.calledOnce).to.be.true;
expect(dialer.dial.calledWith(mockPeerId)).to.be.true;
});
it("should handle dial errors gracefully", async () => {
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(undefined);
dialer.dial.rejects(new Error("Dial failed"));
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfo
});
await eventHandler(mockEvent);
expect(dialer.dial.calledOnce).to.be.true;
expect(dialer.dial.calledWith(mockPeerId)).to.be.true;
});
it("should update peer store before dialing", async () => {
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(undefined);
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfo
});
await eventHandler(mockEvent);
expect(peerStoreStub.calledWith(mockPeerId)).to.be.true;
expect(dialer.dial.calledOnce).to.be.true;
});
it("should handle peer store errors gracefully", async () => {
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.rejects(new Error("Peer store error"));
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfo
});
await eventHandler(mockEvent);
expect(dialer.dial.calledOnce).to.be.true;
});
});
describe("updatePeerStore", () => {
let eventHandler: (event: CustomEvent<PeerInfo>) => Promise<void>;
beforeEach(() => {
discoveryDialer = new DiscoveryDialer({
libp2p,
dialer
});
discoveryDialer.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
eventHandler = addEventListenerStub.getCall(0).args[1];
});
it("should save new peer to store", async () => {
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(undefined);
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfo
});
await eventHandler(mockEvent);
expect((libp2p.peerStore.save as sinon.SinonStub).calledOnce).to.be.true;
expect(
(libp2p.peerStore.save as sinon.SinonStub).calledWith(mockPeerId, {
multiaddrs: mockPeerInfo.multiaddrs
})
).to.be.true;
});
it("should skip updating peer store if peer has same addresses", async () => {
// Set up mockPeerInfo with actual multiaddrs for this test
const mockMultiaddr = { equals: sinon.stub().returns(true) };
const mockPeerInfoWithAddr = {
id: mockPeerId,
multiaddrs: [mockMultiaddr]
} as unknown as PeerInfo;
const mockPeer = {
addresses: [{ multiaddr: mockMultiaddr }]
};
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(mockPeer);
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfoWithAddr
});
await eventHandler(mockEvent);
expect((libp2p.peerStore.save as sinon.SinonStub).called).to.be.false;
expect((libp2p.peerStore.merge as sinon.SinonStub).called).to.be.false;
});
it("should merge peer addresses if peer exists with different addresses", async () => {
// Set up mockPeerInfo with actual multiaddrs for this test
const mockMultiaddr = { equals: sinon.stub().returns(false) };
const mockPeerInfoWithAddr = {
id: mockPeerId,
multiaddrs: [mockMultiaddr]
} as unknown as PeerInfo;
const mockPeer = {
addresses: []
};
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(mockPeer);
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfoWithAddr
});
await eventHandler(mockEvent);
expect((libp2p.peerStore.merge as sinon.SinonStub).calledOnce).to.be.true;
expect(
(libp2p.peerStore.merge as sinon.SinonStub).calledWith(mockPeerId, {
multiaddrs: mockPeerInfoWithAddr.multiaddrs
})
).to.be.true;
});
});
describe("integration", () => {
it("should handle complete discovery-to-dial flow", async () => {
const peerStoreStub = libp2p.peerStore.get as sinon.SinonStub;
peerStoreStub.resolves(undefined);
discoveryDialer = new DiscoveryDialer({
libp2p,
dialer
});
discoveryDialer.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
const eventHandler = addEventListenerStub.getCall(0).args[1];
const mockEvent = new CustomEvent("peer:discovery", {
detail: mockPeerInfo
});
await eventHandler(mockEvent);
expect(dialer.dial.calledOnce).to.be.true;
expect(dialer.dial.calledWith(mockPeerId)).to.be.true;
discoveryDialer.stop();
const removeEventListenerStub =
libp2p.removeEventListener as sinon.SinonStub;
expect(removeEventListenerStub.called).to.be.true;
});
});
});

View File

@ -0,0 +1,106 @@
import { Peer, PeerId, PeerInfo } from "@libp2p/interface";
import { Multiaddr } from "@multiformats/multiaddr";
import { Logger } from "@waku/utils";
import { Libp2p } from "libp2p";
import { Dialer } from "./dialer.js";
type Libp2pEventHandler<T> = (e: CustomEvent<T>) => void;
type DiscoveryDialerConstructorOptions = {
libp2p: Libp2p;
dialer: Dialer;
};
interface IDiscoveryDialer {
start(): void;
stop(): void;
}
const log = new Logger("discovery-dialer");
/**
* This class is responsible for dialing peers that are discovered by the libp2p node.
* Managing limits for the peers is out of scope for this class.
* Dialing after discovery is needed to identify the peer and get all other information: metadata, protocols, etc.
*/
export class DiscoveryDialer implements IDiscoveryDialer {
private readonly libp2p: Libp2p;
private readonly dialer: Dialer;
public constructor(options: DiscoveryDialerConstructorOptions) {
this.libp2p = options.libp2p;
this.dialer = options.dialer;
this.onPeerDiscovery = this.onPeerDiscovery.bind(this);
}
public start(): void {
this.libp2p.addEventListener(
"peer:discovery",
this.onPeerDiscovery as Libp2pEventHandler<PeerInfo>
);
}
public stop(): void {
this.libp2p.removeEventListener(
"peer:discovery",
this.onPeerDiscovery as Libp2pEventHandler<PeerInfo>
);
}
private async onPeerDiscovery(event: CustomEvent<PeerInfo>): Promise<void> {
const peerId = event.detail.id;
log.info(`Discovered new peer: ${peerId}`);
try {
await this.updatePeerStore(peerId, event.detail.multiaddrs);
await this.dialer.dial(peerId);
} catch (error) {
log.error(`Error dialing peer ${peerId}`, error);
}
}
private async updatePeerStore(
peerId: PeerId,
multiaddrs: Multiaddr[]
): Promise<void> {
try {
log.info(`Updating peer store for ${peerId}`);
const peer = await this.getPeer(peerId);
if (!peer) {
log.info(`Peer ${peerId} not found in store, saving`);
await this.libp2p.peerStore.save(peerId, {
multiaddrs: multiaddrs
});
return;
}
const hasSameAddr = multiaddrs.every((addr) =>
peer.addresses.some((a) => a.multiaddr.equals(addr))
);
if (hasSameAddr) {
log.info(`Peer ${peerId} has same addresses in peer store, skipping`);
return;
}
log.info(`Merging peer ${peerId} addresses in peer store`);
await this.libp2p.peerStore.merge(peerId, {
multiaddrs: multiaddrs
});
} catch (error) {
log.error(`Error updating peer store for ${peerId}`, error);
}
}
private async getPeer(peerId: PeerId): Promise<Peer | undefined> {
try {
return await this.libp2p.peerStore.get(peerId);
} catch (error) {
log.error(`Error getting peer info for ${peerId}`, error);
return undefined;
}
}
}

View File

@ -0,0 +1,583 @@
import type { PeerId } from "@libp2p/interface";
import { expect } from "chai";
import sinon from "sinon";
import { KeepAliveManager } from "./keep_alive_manager.js";
describe("KeepAliveManager", () => {
let libp2p: any;
let relay: any;
let keepAliveManager: KeepAliveManager;
let mockPeerId: PeerId;
let mockPeerId2: PeerId;
let clock: sinon.SinonFakeTimers;
const createMockPeerId = (id: string): PeerId =>
({
toString: () => id,
equals: (other: PeerId) => other.toString() === id
}) as PeerId;
const defaultOptions = {
pingKeepAlive: 30,
relayKeepAlive: 60
};
beforeEach(() => {
clock = sinon.useFakeTimers();
mockPeerId = createMockPeerId("12D3KooWTest1");
mockPeerId2 = createMockPeerId("12D3KooWTest2");
libp2p = {
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
services: {
ping: {
ping: sinon.stub().resolves(100)
}
},
peerStore: {
merge: sinon.stub().resolves()
}
};
relay = {
pubsubTopics: ["/waku/2/rs/1/0", "/waku/2/rs/1/1"],
getMeshPeers: sinon.stub().returns(["12D3KooWTest1"]),
send: sinon.stub().resolves()
};
});
afterEach(() => {
if (keepAliveManager) {
keepAliveManager.stop();
}
clock.restore();
sinon.restore();
});
describe("constructor", () => {
it("should create KeepAliveManager with required options", () => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p
});
expect(keepAliveManager).to.be.instanceOf(KeepAliveManager);
});
it("should create KeepAliveManager with relay", () => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
expect(keepAliveManager).to.be.instanceOf(KeepAliveManager);
});
});
describe("start", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p
});
});
it("should add event listeners for peer connect and disconnect", () => {
keepAliveManager.start();
expect(libp2p.addEventListener.calledTwice).to.be.true;
expect(
libp2p.addEventListener.calledWith("peer:connect", sinon.match.func)
).to.be.true;
expect(
libp2p.addEventListener.calledWith("peer:disconnect", sinon.match.func)
).to.be.true;
});
it("should be safe to call multiple times", () => {
keepAliveManager.start();
keepAliveManager.start();
expect(libp2p.addEventListener.callCount).to.equal(4);
});
});
describe("stop", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
});
it("should remove event listeners", () => {
keepAliveManager.stop();
expect(libp2p.removeEventListener.calledTwice).to.be.true;
expect(
libp2p.removeEventListener.calledWith("peer:connect", sinon.match.func)
).to.be.true;
expect(
libp2p.removeEventListener.calledWith(
"peer:disconnect",
sinon.match.func
)
).to.be.true;
});
it("should clear all timers", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
const timersBeforeStop = clock.countTimers();
expect(timersBeforeStop).to.be.greaterThan(0);
keepAliveManager.stop();
expect(clock.countTimers()).to.equal(0);
});
it("should be safe to call multiple times", () => {
keepAliveManager.stop();
keepAliveManager.stop();
expect(libp2p.removeEventListener.callCount).to.equal(4);
});
});
describe("peer connect event handling", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
});
it("should start ping timers on peer connect", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.be.greaterThan(0);
});
it("should handle multiple peer connections", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent1 = new CustomEvent("peer:connect", {
detail: mockPeerId
});
const connectEvent2 = new CustomEvent("peer:connect", {
detail: mockPeerId2
});
peerConnectHandler(connectEvent1);
peerConnectHandler(connectEvent2);
expect(clock.countTimers()).to.be.greaterThan(1);
});
});
describe("peer disconnect event handling", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
});
it("should stop ping timers on peer disconnect", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const peerDisconnectHandler = libp2p.addEventListener.getCall(1).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
const timerCountAfterConnect = clock.countTimers();
expect(timerCountAfterConnect).to.be.greaterThan(0);
const disconnectEvent = new CustomEvent("peer:disconnect", {
detail: mockPeerId
});
peerDisconnectHandler(disconnectEvent);
expect(clock.countTimers()).to.be.lessThan(timerCountAfterConnect);
});
});
describe("ping timer management", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p
});
keepAliveManager.start();
});
it("should create ping timers when pingKeepAlive > 0", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.be.greaterThan(0);
});
it("should not create ping timers when pingKeepAlive = 0", () => {
keepAliveManager.stop();
keepAliveManager = new KeepAliveManager({
options: { pingKeepAlive: 0, relayKeepAlive: 0 },
libp2p
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(2).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(0);
});
it("should perform ping and update peer store on timer", async () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
clock.tick(defaultOptions.pingKeepAlive * 1000);
await clock.tickAsync(0);
sinon.assert.calledWith(libp2p.services.ping.ping, mockPeerId);
sinon.assert.calledWith(
libp2p.peerStore.merge,
mockPeerId,
sinon.match.object
);
});
it("should handle ping failures gracefully", async () => {
libp2p.services.ping.ping.rejects(new Error("Ping failed"));
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
clock.tick(defaultOptions.pingKeepAlive * 1000);
await clock.tickAsync(0);
sinon.assert.calledWith(libp2p.services.ping.ping, mockPeerId);
sinon.assert.notCalled(libp2p.peerStore.merge);
});
it("should handle peer store update failures gracefully", async () => {
libp2p.peerStore.merge.rejects(new Error("Peer store update failed"));
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
clock.tick(defaultOptions.pingKeepAlive * 1000);
await clock.tickAsync(0);
sinon.assert.calledWith(libp2p.services.ping.ping, mockPeerId);
sinon.assert.calledWith(
libp2p.peerStore.merge,
mockPeerId,
sinon.match.object
);
});
});
describe("relay timer management", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
});
it("should create relay timers when relay exists and relayKeepAlive > 0", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.be.greaterThan(1);
});
it("should not create relay timers when relayKeepAlive = 0", () => {
keepAliveManager.stop();
keepAliveManager = new KeepAliveManager({
options: { pingKeepAlive: 30, relayKeepAlive: 0 },
libp2p,
relay
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(2).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(1);
});
it("should not create relay timers when relay is not provided", () => {
keepAliveManager.stop();
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(2).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(1);
});
it("should create timers for each pubsub topic where peer is in mesh", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.be.greaterThan(relay.pubsubTopics.length);
});
it("should not create timers for topics where peer is not in mesh", () => {
relay.getMeshPeers.returns([]);
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(1);
});
it("should send relay ping messages on timer", async () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
clock.tick(defaultOptions.relayKeepAlive * 1000);
await clock.tickAsync(0);
sinon.assert.called(relay.send);
});
it("should handle relay send failures gracefully", async () => {
relay.send.rejects(new Error("Relay send failed"));
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
clock.tick(defaultOptions.relayKeepAlive * 1000);
await clock.tickAsync(0);
sinon.assert.called(relay.send);
});
});
describe("timer cleanup", () => {
beforeEach(() => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
});
it("should clear timers for specific peer on disconnect", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const peerDisconnectHandler = libp2p.addEventListener.getCall(1).args[1];
const connectEvent1 = new CustomEvent("peer:connect", {
detail: mockPeerId
});
const connectEvent2 = new CustomEvent("peer:connect", {
detail: mockPeerId2
});
peerConnectHandler(connectEvent1);
peerConnectHandler(connectEvent2);
const timerCountAfterConnect = clock.countTimers();
expect(timerCountAfterConnect).to.be.greaterThan(0);
const disconnectEvent = new CustomEvent("peer:disconnect", {
detail: mockPeerId
});
peerDisconnectHandler(disconnectEvent);
expect(clock.countTimers()).to.be.lessThan(timerCountAfterConnect);
expect(clock.countTimers()).to.be.greaterThan(0);
});
it("should handle disconnect when peer has no timers", () => {
const peerDisconnectHandler = libp2p.addEventListener.getCall(1).args[1];
const disconnectEvent = new CustomEvent("peer:disconnect", {
detail: mockPeerId
});
expect(() => peerDisconnectHandler(disconnectEvent)).to.not.throw();
});
it("should clear existing timers before creating new ones", () => {
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
const timerCountAfterFirst = clock.countTimers();
peerConnectHandler(connectEvent);
const timerCountAfterSecond = clock.countTimers();
expect(timerCountAfterSecond).to.equal(timerCountAfterFirst);
});
});
describe("edge cases", () => {
it("should handle empty pubsub topics", () => {
const emptyRelay = {
pubsubTopics: [],
getMeshPeers: sinon.stub().returns([]),
send: sinon.stub().resolves()
} as any;
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay: emptyRelay
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(1);
});
it("should handle all zero keep alive options", () => {
keepAliveManager = new KeepAliveManager({
options: { pingKeepAlive: 0, relayKeepAlive: 0 },
libp2p,
relay
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(0);
});
it("should handle peer not in mesh for all topics", () => {
relay.getMeshPeers.returns(["different-peer-id"]);
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.equal(1);
});
});
describe("integration", () => {
it("should handle complete peer lifecycle", async () => {
keepAliveManager = new KeepAliveManager({
options: defaultOptions,
libp2p,
relay
});
keepAliveManager.start();
const peerConnectHandler = libp2p.addEventListener.getCall(0).args[1];
const peerDisconnectHandler = libp2p.addEventListener.getCall(1).args[1];
const connectEvent = new CustomEvent("peer:connect", {
detail: mockPeerId
});
peerConnectHandler(connectEvent);
expect(clock.countTimers()).to.be.greaterThan(0);
clock.tick(
Math.max(defaultOptions.pingKeepAlive, defaultOptions.relayKeepAlive) *
1000
);
await clock.tickAsync(0);
sinon.assert.called(libp2p.services.ping.ping);
sinon.assert.called(relay.send);
const disconnectEvent = new CustomEvent("peer:disconnect", {
detail: mockPeerId
});
peerDisconnectHandler(disconnectEvent);
expect(clock.countTimers()).to.equal(0);
keepAliveManager.stop();
sinon.assert.called(libp2p.removeEventListener);
});
});
});

View File

@ -1,5 +1,5 @@
import type { PeerId } from "@libp2p/interface";
import type { IRelay, Libp2p, PeerIdStr } from "@waku/interfaces";
import type { IEncoder, IRelay, Libp2p } from "@waku/interfaces";
import { Logger, pubsubTopicToSingleShardInfo } from "@waku/utils";
import { utf8ToBytes } from "@waku/utils/bytes";
@ -19,7 +19,12 @@ type CreateKeepAliveManagerOptions = {
relay?: IRelay;
};
export class KeepAliveManager {
interface IKeepAliveManager {
start(): void;
stop(): void;
}
export class KeepAliveManager implements IKeepAliveManager {
private readonly relay?: IRelay;
private readonly libp2p: Libp2p;
@ -27,7 +32,7 @@ export class KeepAliveManager {
private pingKeepAliveTimers: Map<string, ReturnType<typeof setInterval>> =
new Map();
private relayKeepAliveTimers: Map<PeerId, ReturnType<typeof setInterval>[]> =
private relayKeepAliveTimers: Map<string, ReturnType<typeof setInterval>[]> =
new Map();
public constructor({
@ -38,122 +43,184 @@ export class KeepAliveManager {
this.options = options;
this.relay = relay;
this.libp2p = libp2p;
this.onPeerConnect = this.onPeerConnect.bind(this);
this.onPeerDisconnect = this.onPeerDisconnect.bind(this);
}
public start(peerId: PeerId): void {
// Just in case a timer already exists for this peer
this.stop(peerId);
const { pingKeepAlive: pingPeriodSecs, relayKeepAlive: relayPeriodSecs } =
this.options;
const peerIdStr = peerId.toString();
// Ping the peer every pingPeriodSecs seconds
// if pingPeriodSecs is 0, don't ping the peer
if (pingPeriodSecs !== 0) {
const interval = setInterval(() => {
void (async () => {
let ping: number;
try {
// ping the peer for keep alive
// also update the peer store with the latency
try {
ping = await this.libp2p.services.ping.ping(peerId);
log.info(`Ping succeeded (${peerIdStr})`, ping);
} catch (error) {
log.error(`Ping failed for peer (${peerIdStr}).
Next ping will be attempted in ${pingPeriodSecs} seconds.
`);
return;
}
try {
await this.libp2p.peerStore.merge(peerId, {
metadata: {
ping: utf8ToBytes(ping.toString())
}
});
} catch (e) {
log.error("Failed to update ping", e);
}
} catch (e) {
log.error(`Ping failed (${peerIdStr})`, e);
}
})();
}, pingPeriodSecs * 1000);
this.pingKeepAliveTimers.set(peerIdStr, interval);
}
const relay = this.relay;
if (relay && relayPeriodSecs !== 0) {
const intervals = this.scheduleRelayPings(
relay,
relayPeriodSecs,
peerId.toString()
);
this.relayKeepAliveTimers.set(peerId, intervals);
}
public start(): void {
this.libp2p.addEventListener("peer:connect", this.onPeerConnect);
this.libp2p.addEventListener("peer:disconnect", this.onPeerDisconnect);
}
public stop(peerId: PeerId): void {
const peerIdStr = peerId.toString();
public stop(): void {
this.libp2p.removeEventListener("peer:connect", this.onPeerConnect);
this.libp2p.removeEventListener("peer:disconnect", this.onPeerDisconnect);
if (this.pingKeepAliveTimers.has(peerIdStr)) {
clearInterval(this.pingKeepAliveTimers.get(peerIdStr));
this.pingKeepAliveTimers.delete(peerIdStr);
}
if (this.relayKeepAliveTimers.has(peerId)) {
this.relayKeepAliveTimers.get(peerId)?.map(clearInterval);
this.relayKeepAliveTimers.delete(peerId);
}
}
public stopAll(): void {
for (const timer of [
...Object.values(this.pingKeepAliveTimers),
...Object.values(this.relayKeepAliveTimers)
]) {
for (const timer of this.pingKeepAliveTimers.values()) {
clearInterval(timer);
}
for (const timerArray of this.relayKeepAliveTimers.values()) {
for (const timer of timerArray) {
clearInterval(timer);
}
}
this.pingKeepAliveTimers.clear();
this.relayKeepAliveTimers.clear();
}
public connectionsExist(): boolean {
return (
this.pingKeepAliveTimers.size > 0 || this.relayKeepAliveTimers.size > 0
);
private onPeerConnect(evt: CustomEvent<PeerId>): void {
const peerId = evt.detail;
this.startPingForPeer(peerId);
}
private scheduleRelayPings(
relay: IRelay,
relayPeriodSecs: number,
peerIdStr: PeerIdStr
): NodeJS.Timeout[] {
// send a ping message to each PubsubTopic the peer is part of
private onPeerDisconnect(evt: CustomEvent<PeerId>): void {
const peerId = evt.detail;
this.stopPingForPeer(peerId);
}
private startPingForPeer(peerId: PeerId): void {
// Just in case a timer already exists for this peer
this.stopPingForPeer(peerId);
this.startLibp2pPing(peerId);
this.startRelayPing(peerId);
}
private stopPingForPeer(peerId: PeerId): void {
this.stopLibp2pPing(peerId);
this.stopRelayPing(peerId);
}
private startLibp2pPing(peerId: PeerId): void {
if (this.options.pingKeepAlive === 0) {
log.warn(
`Ping keep alive is disabled pingKeepAlive:${this.options.pingKeepAlive}, skipping start for libp2p ping`
);
return;
}
const peerIdStr = peerId.toString();
if (this.pingKeepAliveTimers.has(peerIdStr)) {
log.warn(
`Ping already started for peer: ${peerIdStr}, skipping start for libp2p ping`
);
return;
}
const interval = setInterval(() => {
void this.pingLibp2p(peerId);
}, this.options.pingKeepAlive * 1000);
this.pingKeepAliveTimers.set(peerIdStr, interval);
}
private stopLibp2pPing(peerId: PeerId): void {
const peerIdStr = peerId.toString();
if (!this.pingKeepAliveTimers.has(peerIdStr)) {
log.warn(
`Ping not started for peer: ${peerIdStr}, skipping stop for ping`
);
return;
}
clearInterval(this.pingKeepAliveTimers.get(peerIdStr));
this.pingKeepAliveTimers.delete(peerIdStr);
}
private startRelayPing(peerId: PeerId): void {
if (!this.relay) {
return;
}
if (this.options.relayKeepAlive === 0) {
log.warn(
`Relay keep alive is disabled relayKeepAlive:${this.options.relayKeepAlive}, skipping start for relay ping`
);
return;
}
if (this.relayKeepAliveTimers.has(peerId.toString())) {
log.warn(
`Relay ping already started for peer: ${peerId.toString()}, skipping start for relay ping`
);
return;
}
const intervals: NodeJS.Timeout[] = [];
for (const topic of relay.pubsubTopics) {
const meshPeers = relay.getMeshPeers(topic);
if (!meshPeers.includes(peerIdStr)) continue;
for (const topic of this.relay.pubsubTopics) {
const meshPeers = this.relay.getMeshPeers(topic);
if (!meshPeers.includes(peerId.toString())) {
log.warn(
`Peer: ${peerId.toString()} is not in the mesh for topic: ${topic}, skipping start for relay ping`
);
continue;
}
const encoder = createEncoder({
pubsubTopicShardInfo: pubsubTopicToSingleShardInfo(topic),
contentTopic: RelayPingContentTopic,
ephemeral: true
});
const interval = setInterval(() => {
log.info("Sending Waku Relay ping message");
relay
.send(encoder, { payload: new Uint8Array([1]) })
.catch((e) => log.error("Failed to send relay ping", e));
}, relayPeriodSecs * 1000);
void this.pingRelay(encoder);
}, this.options.relayKeepAlive * 1000);
intervals.push(interval);
}
return intervals;
this.relayKeepAliveTimers.set(peerId.toString(), intervals);
}
private stopRelayPing(peerId: PeerId): void {
if (!this.relay) {
return;
}
const peerIdStr = peerId.toString();
if (!this.relayKeepAliveTimers.has(peerIdStr)) {
log.warn(
`Relay ping not started for peer: ${peerIdStr}, skipping stop for relay ping`
);
return;
}
this.relayKeepAliveTimers.get(peerIdStr)?.map(clearInterval);
this.relayKeepAliveTimers.delete(peerIdStr);
}
private async pingRelay(encoder: IEncoder): Promise<void> {
try {
log.info("Sending Waku Relay ping message");
await this.relay!.send(encoder, { payload: new Uint8Array([1]) });
} catch (e) {
log.error("Failed to send relay ping", e);
}
}
private async pingLibp2p(peerId: PeerId): Promise<void> {
try {
log.info(`Pinging libp2p peer (${peerId.toString()})`);
const ping = await this.libp2p.services.ping.ping(peerId);
log.info(`Ping succeeded (${peerId.toString()})`, ping);
await this.libp2p.peerStore.merge(peerId, {
metadata: {
ping: utf8ToBytes(ping.toString())
}
});
log.info(`Ping updated for peer (${peerId.toString()})`);
} catch (e) {
log.error(`Ping failed for peer (${peerId.toString()})`, e);
}
}
}

View File

@ -0,0 +1,431 @@
import { IWakuEventEmitter, Libp2p } from "@waku/interfaces";
import { expect } from "chai";
import sinon from "sinon";
import { NetworkMonitor } from "./network_monitor.js";
describe("NetworkMonitor", () => {
let libp2p: Libp2p;
let events: IWakuEventEmitter;
let networkMonitor: NetworkMonitor;
let originalGlobalThis: typeof globalThis;
let mockGlobalThis: {
addEventListener: sinon.SinonStub;
removeEventListener: sinon.SinonStub;
navigator: { onLine: boolean } | undefined;
};
beforeEach(() => {
libp2p = {
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
getConnections: sinon.stub().returns([])
} as unknown as Libp2p;
events = {
dispatchEvent: sinon.stub()
} as unknown as IWakuEventEmitter;
originalGlobalThis = globalThis;
mockGlobalThis = {
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
navigator: {
onLine: true
}
};
(global as unknown as { globalThis: typeof mockGlobalThis }).globalThis =
mockGlobalThis;
});
afterEach(() => {
if (networkMonitor) {
networkMonitor.stop();
}
(
global as unknown as { globalThis: typeof originalGlobalThis }
).globalThis = originalGlobalThis;
sinon.restore();
});
describe("constructor", () => {
it("should create NetworkMonitor with libp2p and events", () => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
expect(networkMonitor).to.be.instanceOf(NetworkMonitor);
});
it("should initialize with isNetworkConnected as false", () => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
expect(networkMonitor.isConnected()).to.be.false;
});
});
describe("start", () => {
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
});
it("should add event listeners to libp2p", () => {
networkMonitor.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
expect(addEventListenerStub.calledTwice).to.be.true;
expect(addEventListenerStub.calledWith("peer:connect", sinon.match.func))
.to.be.true;
expect(
addEventListenerStub.calledWith("peer:disconnect", sinon.match.func)
).to.be.true;
});
it("should add event listeners to globalThis", () => {
networkMonitor.start();
expect(mockGlobalThis.addEventListener.calledTwice).to.be.true;
expect(
mockGlobalThis.addEventListener.calledWith("online", sinon.match.func)
).to.be.true;
expect(
mockGlobalThis.addEventListener.calledWith("offline", sinon.match.func)
).to.be.true;
});
it("should handle errors when globalThis is not available", () => {
mockGlobalThis.addEventListener.throws(new Error("No globalThis"));
expect(() => networkMonitor.start()).to.not.throw();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
expect(addEventListenerStub.calledTwice).to.be.true;
});
});
describe("stop", () => {
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
networkMonitor.start();
});
it("should remove event listeners from libp2p", () => {
networkMonitor.stop();
const removeEventListenerStub =
libp2p.removeEventListener as sinon.SinonStub;
expect(removeEventListenerStub.calledTwice).to.be.true;
expect(
removeEventListenerStub.calledWith("peer:connect", sinon.match.func)
).to.be.true;
expect(
removeEventListenerStub.calledWith("peer:disconnect", sinon.match.func)
).to.be.true;
});
it("should remove event listeners from globalThis", () => {
networkMonitor.stop();
expect(mockGlobalThis.removeEventListener.calledTwice).to.be.true;
expect(
mockGlobalThis.removeEventListener.calledWith(
"online",
sinon.match.func
)
).to.be.true;
expect(
mockGlobalThis.removeEventListener.calledWith(
"offline",
sinon.match.func
)
).to.be.true;
});
it("should handle errors when removing globalThis listeners", () => {
mockGlobalThis.removeEventListener.throws(new Error("Remove failed"));
expect(() => networkMonitor.stop()).to.not.throw();
const removeEventListenerStub =
libp2p.removeEventListener as sinon.SinonStub;
expect(removeEventListenerStub.calledTwice).to.be.true;
});
});
describe("isConnected", () => {
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
});
it("should return false when navigator.onLine is false", () => {
if (mockGlobalThis.navigator) {
mockGlobalThis.navigator.onLine = false;
}
expect(networkMonitor.isConnected()).to.be.false;
});
it("should return false when navigator.onLine is true but network is not connected", () => {
if (mockGlobalThis.navigator) {
mockGlobalThis.navigator.onLine = true;
}
expect(networkMonitor.isConnected()).to.be.false;
});
it("should handle case when navigator is not available", () => {
mockGlobalThis.navigator = undefined;
expect(networkMonitor.isConnected()).to.be.false;
});
it("should handle case when globalThis is not available", () => {
(global as unknown as { globalThis: undefined }).globalThis = undefined;
expect(networkMonitor.isConnected()).to.be.false;
});
});
describe("peer connection events", () => {
let connectHandler: () => void;
let disconnectHandler: () => void;
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
networkMonitor.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
connectHandler = addEventListenerStub.getCall(0).args[1];
disconnectHandler = addEventListenerStub.getCall(1).args[1];
});
it("should handle peer connect event", () => {
expect(networkMonitor.isConnected()).to.be.false;
connectHandler();
expect(networkMonitor.isConnected()).to.be.true;
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
expect(dispatchEventStub.calledOnce).to.be.true;
});
it("should handle peer disconnect event when no connections remain", () => {
connectHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
dispatchEventStub.resetHistory();
const getConnectionsStub = libp2p.getConnections as sinon.SinonStub;
getConnectionsStub.returns([]);
disconnectHandler();
expect(networkMonitor.isConnected()).to.be.false;
expect(dispatchEventStub.calledOnce).to.be.true;
});
it("should not change state when connections remain after disconnect", () => {
connectHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
dispatchEventStub.resetHistory();
const getConnectionsStub = libp2p.getConnections as sinon.SinonStub;
getConnectionsStub.returns([{ id: "connection1" }]);
disconnectHandler();
expect(networkMonitor.isConnected()).to.be.true;
expect(dispatchEventStub.called).to.be.false;
});
it("should not dispatch event when already connected", () => {
connectHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
dispatchEventStub.resetHistory();
connectHandler();
expect(dispatchEventStub.called).to.be.false;
});
it("should not dispatch event when already disconnected", () => {
connectHandler();
const getConnectionsStub = libp2p.getConnections as sinon.SinonStub;
getConnectionsStub.returns([]);
disconnectHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
dispatchEventStub.resetHistory();
disconnectHandler();
expect(dispatchEventStub.called).to.be.false;
});
});
describe("browser online/offline events", () => {
let onlineHandler: () => void;
let offlineHandler: () => void;
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
networkMonitor.start();
onlineHandler = mockGlobalThis.addEventListener.getCall(0).args[1];
offlineHandler = mockGlobalThis.addEventListener.getCall(1).args[1];
});
it("should dispatch network event when browser goes online", () => {
if (mockGlobalThis.navigator) {
mockGlobalThis.navigator.onLine = true;
}
onlineHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
expect(dispatchEventStub.calledOnce).to.be.true;
});
it("should dispatch network event when browser goes offline", () => {
if (mockGlobalThis.navigator) {
mockGlobalThis.navigator.onLine = false;
}
offlineHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
expect(dispatchEventStub.calledOnce).to.be.true;
});
});
describe("dispatchNetworkEvent", () => {
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
});
it("should dispatch CustomEvent with correct type and detail", () => {
networkMonitor.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
const connectHandler = addEventListenerStub.getCall(0).args[1];
connectHandler();
const dispatchEventStub = events.dispatchEvent as sinon.SinonStub;
expect(dispatchEventStub.calledOnce).to.be.true;
const dispatchedEvent = dispatchEventStub.getCall(0)
.args[0] as CustomEvent<boolean>;
expect(dispatchedEvent).to.be.instanceOf(CustomEvent);
expect(dispatchedEvent.type).to.equal("waku:connection");
expect(dispatchedEvent.detail).to.be.true;
});
});
describe("error handling", () => {
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
});
it("should handle errors when getting connections", () => {
const getConnectionsStub = libp2p.getConnections as sinon.SinonStub;
getConnectionsStub.throws(new Error("Get connections failed"));
networkMonitor.start();
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
const connectHandler = addEventListenerStub.getCall(0).args[1];
const disconnectHandler = addEventListenerStub.getCall(1).args[1];
connectHandler();
expect(networkMonitor.isConnected()).to.be.true;
expect(() => disconnectHandler()).to.throw("Get connections failed");
});
it("should handle errors when accessing navigator", () => {
Object.defineProperty(mockGlobalThis, "navigator", {
get: () => {
throw new Error("Navigator access failed");
}
});
expect(networkMonitor.isConnected()).to.be.false;
});
});
describe("integration", () => {
beforeEach(() => {
networkMonitor = new NetworkMonitor({
libp2p,
events
});
networkMonitor.start();
});
it("should handle complete connection lifecycle", () => {
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
const connectHandler = addEventListenerStub.getCall(0).args[1];
const disconnectHandler = addEventListenerStub.getCall(1).args[1];
const getConnectionsStub = libp2p.getConnections as sinon.SinonStub;
expect(networkMonitor.isConnected()).to.be.false;
connectHandler();
expect(networkMonitor.isConnected()).to.be.true;
getConnectionsStub.returns([{ id: "other" }]);
disconnectHandler();
expect(networkMonitor.isConnected()).to.be.true;
getConnectionsStub.returns([]);
disconnectHandler();
expect(networkMonitor.isConnected()).to.be.false;
});
it("should handle browser offline state overriding peer connections", () => {
const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub;
const connectHandler = addEventListenerStub.getCall(0).args[1];
connectHandler();
expect(networkMonitor.isConnected()).to.be.true;
if (mockGlobalThis.navigator) {
mockGlobalThis.navigator.onLine = false;
}
expect(networkMonitor.isConnected()).to.be.false;
if (mockGlobalThis.navigator) {
mockGlobalThis.navigator.onLine = true;
}
expect(networkMonitor.isConnected()).to.be.true;
});
});
});

View File

@ -0,0 +1,112 @@
import { IWakuEventEmitter, Libp2p } from "@waku/interfaces";
type NetworkMonitorConstructorOptions = {
libp2p: Libp2p;
events: IWakuEventEmitter;
};
interface INetworkMonitor {
start(): void;
stop(): void;
isConnected(): boolean;
isP2PConnected(): boolean;
isBrowserConnected(): boolean;
}
export class NetworkMonitor implements INetworkMonitor {
private readonly libp2p: Libp2p;
private readonly events: IWakuEventEmitter;
private isNetworkConnected: boolean = false;
public constructor(options: NetworkMonitorConstructorOptions) {
this.libp2p = options.libp2p;
this.events = options.events;
this.onConnectedEvent = this.onConnectedEvent.bind(this);
this.onDisconnectedEvent = this.onDisconnectedEvent.bind(this);
this.dispatchNetworkEvent = this.dispatchNetworkEvent.bind(this);
}
public start(): void {
this.libp2p.addEventListener("peer:connect", this.onConnectedEvent);
this.libp2p.addEventListener("peer:disconnect", this.onDisconnectedEvent);
try {
globalThis.addEventListener("online", this.dispatchNetworkEvent);
globalThis.addEventListener("offline", this.dispatchNetworkEvent);
} catch (err) {
// ignore
}
}
public stop(): void {
this.libp2p.removeEventListener("peer:connect", this.onConnectedEvent);
this.libp2p.removeEventListener(
"peer:disconnect",
this.onDisconnectedEvent
);
try {
globalThis.removeEventListener("online", this.dispatchNetworkEvent);
globalThis.removeEventListener("offline", this.dispatchNetworkEvent);
} catch (err) {
// ignore
}
}
/**
* Returns true if the node is connected to the network via libp2p and browser.
*/
public isConnected(): boolean {
if (!this.isBrowserConnected()) {
return false;
}
return this.isP2PConnected();
}
/**
* Returns true if the node is connected to the network via libp2p.
*/
public isP2PConnected(): boolean {
return this.isNetworkConnected;
}
/**
* Returns true if the node is connected to the network via browser.
*/
public isBrowserConnected(): boolean {
try {
if (globalThis?.navigator && !globalThis?.navigator?.onLine) {
return false;
}
} catch (err) {
// ignore
}
return true;
}
private onConnectedEvent(): void {
if (!this.isNetworkConnected) {
this.isNetworkConnected = true;
this.dispatchNetworkEvent();
}
}
private onDisconnectedEvent(): void {
if (this.isNetworkConnected && this.libp2p.getConnections().length === 0) {
this.isNetworkConnected = false;
this.dispatchNetworkEvent();
}
}
private dispatchNetworkEvent(): void {
this.events.dispatchEvent(
new CustomEvent<boolean>("waku:connection", {
detail: this.isConnected()
})
);
}
}

View File

@ -0,0 +1,327 @@
import { PeerId } from "@libp2p/interface";
import {
NetworkConfig,
PubsubTopic,
ShardInfo,
SingleShardInfo
} from "@waku/interfaces";
import { contentTopicToShardIndex, encodeRelayShard } from "@waku/utils";
import { expect } from "chai";
import { Libp2p } from "libp2p";
import sinon from "sinon";
import { ShardReader } from "./shard_reader.js";
const createMockPeerId = (): PeerId => {
const mockPeerId = {
toString: () => "12D3KooWTest123",
equals: (other: PeerId) => other.toString() === "12D3KooWTest123"
};
return mockPeerId as unknown as PeerId;
};
describe("ShardReader", function () {
let mockLibp2p: sinon.SinonStubbedInstance<Libp2p>;
let mockPeerStore: any;
let shardReader: ShardReader;
let testPeerId: PeerId;
const testContentTopic = "/test/1/waku-light-push/utf8";
const testClusterId = 3;
const testShardIndex = contentTopicToShardIndex(testContentTopic);
const testNetworkConfig: NetworkConfig = {
contentTopics: [testContentTopic],
clusterId: testClusterId
};
const testShardInfo: ShardInfo = {
clusterId: testClusterId,
shards: [testShardIndex]
};
beforeEach(async function () {
testPeerId = createMockPeerId();
mockPeerStore = {
get: sinon.stub(),
save: sinon.stub(),
merge: sinon.stub()
};
mockLibp2p = {
peerStore: mockPeerStore
} as any;
shardReader = new ShardReader({
libp2p: mockLibp2p as any,
networkConfig: testNetworkConfig
});
});
afterEach(function () {
sinon.restore();
});
describe("constructor", function () {
it("should create ShardReader with contentTopics network config", function () {
const config: NetworkConfig = {
contentTopics: ["/test/1/waku-light-push/utf8"],
clusterId: 3
};
const reader = new ShardReader({
libp2p: mockLibp2p as any,
networkConfig: config
});
expect(reader).to.be.instanceOf(ShardReader);
});
it("should create ShardReader with shards network config", function () {
const config: NetworkConfig = {
clusterId: 3,
shards: [1, 2, 3]
};
const reader = new ShardReader({
libp2p: mockLibp2p as any,
networkConfig: config
});
expect(reader).to.be.instanceOf(ShardReader);
});
});
describe("isPeerOnNetwork", function () {
it("should return true when peer is on the same network", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.true;
sinon.assert.calledWith(mockPeerStore.get, testPeerId);
});
it("should return false when peer is on different cluster", async function () {
const differentClusterShardInfo: ShardInfo = {
clusterId: 5,
shards: [1, 2]
};
const shardInfoBytes = encodeRelayShard(differentClusterShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.false;
});
it("should return false when peer has no overlapping shards", async function () {
const noOverlapShardInfo: ShardInfo = {
clusterId: testClusterId,
shards: [testShardIndex + 100, testShardIndex + 200] // Use different shards
};
const shardInfoBytes = encodeRelayShard(noOverlapShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.false;
});
it("should return false when peer has no shard info", async function () {
const mockPeer = {
metadata: new Map()
};
mockPeerStore.get.resolves(mockPeer);
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.false;
});
it("should return false when peer is not found", async function () {
mockPeerStore.get.rejects(new Error("Peer not found"));
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.false;
});
});
describe("isPeerOnShard", function () {
it("should return true when peer is on the specified shard", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const shard: SingleShardInfo = {
clusterId: testClusterId,
shard: testShardIndex
};
const result = await shardReader.isPeerOnShard(testPeerId, shard);
expect(result).to.be.true;
});
it("should return false when peer is on different cluster", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const shard: SingleShardInfo = {
clusterId: 5,
shard: testShardIndex
};
const result = await shardReader.isPeerOnShard(testPeerId, shard);
expect(result).to.be.false;
});
it("should return false when peer is not on the specified shard", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const shard: SingleShardInfo = {
clusterId: testClusterId,
shard: testShardIndex + 100
};
const result = await shardReader.isPeerOnShard(testPeerId, shard);
expect(result).to.be.false;
});
it("should return false when shard info is undefined", async function () {
const shard: SingleShardInfo = {
clusterId: testClusterId,
shard: undefined
};
const result = await shardReader.isPeerOnShard(testPeerId, shard);
expect(result).to.be.false;
});
it("should return false when peer shard info is not found", async function () {
mockPeerStore.get.rejects(new Error("Peer not found"));
const shard: SingleShardInfo = {
clusterId: testClusterId,
shard: testShardIndex
};
const result = await shardReader.isPeerOnShard(testPeerId, shard);
expect(result).to.be.false;
});
});
describe("isPeerOnTopic", function () {
it("should return true when peer is on the pubsub topic shard", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const pubsubTopic: PubsubTopic = `/waku/2/rs/${testClusterId}/${testShardIndex}`;
const result = await shardReader.isPeerOnTopic(testPeerId, pubsubTopic);
expect(result).to.be.true;
});
it("should return false when peer is not on the pubsub topic shard", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const pubsubTopic: PubsubTopic = `/waku/2/rs/${testClusterId}/${testShardIndex + 100}`;
const result = await shardReader.isPeerOnTopic(testPeerId, pubsubTopic);
expect(result).to.be.false;
});
it("should return false when pubsub topic parsing fails", async function () {
const shardInfoBytes = encodeRelayShard(testShardInfo);
const mockPeer = {
metadata: new Map([["shardInfo", shardInfoBytes]])
};
mockPeerStore.get.resolves(mockPeer);
const invalidPubsubTopic: PubsubTopic = "/invalid/topic";
const result = await shardReader.isPeerOnTopic(
testPeerId,
invalidPubsubTopic
);
expect(result).to.be.false;
});
it("should return false when peer is not found", async function () {
mockPeerStore.get.rejects(new Error("Peer not found"));
const pubsubTopic: PubsubTopic = `/waku/2/rs/${testClusterId}/${testShardIndex}`;
const result = await shardReader.isPeerOnTopic(testPeerId, pubsubTopic);
expect(result).to.be.false;
});
});
describe("error handling", function () {
it("should handle errors gracefully when getting peer info", async function () {
mockPeerStore.get.rejects(new Error("Network error"));
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.false;
});
it("should handle corrupted shard info gracefully", async function () {
const mockPeer = {
metadata: new Map([["shardInfo", new Uint8Array([1, 2, 3])]])
};
mockPeerStore.get.resolves(mockPeer);
const result = await shardReader.isPeerOnNetwork(testPeerId);
expect(result).to.be.false;
});
});
});

View File

@ -0,0 +1,134 @@
import type { PeerId } from "@libp2p/interface";
import type {
NetworkConfig,
PubsubTopic,
ShardInfo,
SingleShardInfo,
StaticSharding
} from "@waku/interfaces";
import {
contentTopicToShardIndex,
decodeRelayShard,
Logger,
pubsubTopicToSingleShardInfo
} from "@waku/utils";
import { Libp2p } from "libp2p";
const log = new Logger("shard-reader");
type ShardReaderConstructorOptions = {
libp2p: Libp2p;
networkConfig: NetworkConfig;
};
interface IShardReader {
hasShardInfo(id: PeerId): Promise<boolean>;
isPeerOnNetwork(id: PeerId): Promise<boolean>;
isPeerOnShard(id: PeerId, shard: SingleShardInfo): Promise<boolean>;
isPeerOnTopic(id: PeerId, pubsubTopic: PubsubTopic): Promise<boolean>;
}
/**
* This class is responsible for reading the shard info from the libp2p peer store or from the current node's network config.
*/
export class ShardReader implements IShardReader {
private readonly libp2p: Libp2p;
private readonly staticShard: StaticSharding;
public constructor(options: ShardReaderConstructorOptions) {
this.libp2p = options.libp2p;
this.staticShard = this.getStaticShardFromNetworkConfig(
options.networkConfig
);
}
public async isPeerOnNetwork(id: PeerId): Promise<boolean> {
const shardInfo = await this.getShardInfo(id);
if (!shardInfo) {
return false;
}
const clusterMatch = shardInfo.clusterId === this.staticShard.clusterId;
const shardOverlap = this.staticShard.shards.some((s) =>
shardInfo.shards.includes(s)
);
return clusterMatch && shardOverlap;
}
public async hasShardInfo(id: PeerId): Promise<boolean> {
const shardInfo = await this.getShardInfo(id);
return !!shardInfo;
}
public async isPeerOnTopic(
id: PeerId,
pubsubTopic: PubsubTopic
): Promise<boolean> {
try {
const shardInfo = pubsubTopicToSingleShardInfo(pubsubTopic);
return await this.isPeerOnShard(id, shardInfo);
} catch (error) {
log.error(
`Error comparing pubsub topic ${pubsubTopic} with shard info for ${id}`,
error
);
return false;
}
}
public async isPeerOnShard(
id: PeerId,
shard: SingleShardInfo
): Promise<boolean> {
const peerShardInfo = await this.getShardInfo(id);
if (!peerShardInfo || shard.shard === undefined) {
return false;
}
return (
peerShardInfo.clusterId === shard.clusterId &&
peerShardInfo.shards.includes(shard.shard)
);
}
private async getShardInfo(id: PeerId): Promise<ShardInfo | undefined> {
try {
const peer = await this.libp2p.peerStore.get(id);
const shardInfoBytes = peer.metadata.get("shardInfo");
if (!shardInfoBytes) {
return undefined;
}
const decodedShardInfo = decodeRelayShard(shardInfoBytes);
return decodedShardInfo;
} catch (error) {
log.error(`Error getting shard info for ${id}`, error);
return undefined;
}
}
private getStaticShardFromNetworkConfig(
networkConfig: NetworkConfig
): StaticSharding {
if ("shards" in networkConfig) {
return networkConfig;
}
const shards = networkConfig.contentTopics.map((topic) =>
contentTopicToShardIndex(topic)
);
return {
clusterId: networkConfig.clusterId!,
shards
};
}
}

View File

@ -0,0 +1,46 @@
import { peerIdFromString } from "@libp2p/peer-id";
import { expect } from "chai";
import { mapToPeerId, mapToPeerIdOrMultiaddr } from "./utils.js";
describe("mapToPeerIdOrMultiaddr", () => {
it("should return PeerId when PeerId is provided", async () => {
const peerId = peerIdFromString(
"12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE"
);
const result = mapToPeerIdOrMultiaddr(peerId);
expect(result).to.equal(peerId);
});
it("should return Multiaddr when Multiaddr input is provided", () => {
const multiAddr =
"/ip4/127.0.0.1/tcp/8000/p2p/12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE";
const result = mapToPeerIdOrMultiaddr(multiAddr);
expect(result.toString()).to.equal(multiAddr);
});
});
describe("mapToPeerId", () => {
it("should return PeerId when PeerId is provided", async () => {
const peerId = peerIdFromString(
"12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE"
);
const result = mapToPeerId(peerId);
expect(result).to.equal(peerId);
expect(result.toString()).to.equal(peerId.toString());
});
it("should return PeerId when Multiaddr input is provided", () => {
const multiAddr =
"/ip4/127.0.0.1/tcp/8000/p2p/12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE";
const result = mapToPeerId(multiAddr);
expect(result.toString()).to.equal(
"12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE"
);
});
});

View File

@ -1,4 +1,6 @@
import type { Peer } from "@libp2p/interface";
import { isPeerId, type Peer, type PeerId } from "@libp2p/interface";
import { peerIdFromString } from "@libp2p/peer-id";
import { Multiaddr, multiaddr, MultiaddrInput } from "@multiformats/multiaddr";
import { bytesToUtf8 } from "@waku/utils/bytes";
/**
@ -23,3 +25,27 @@ export const getPeerPing = (peer: Peer | null): number => {
return -1;
}
};
/**
* Maps a PeerId or MultiaddrInput to a PeerId or Multiaddr.
* @param input - The PeerId or MultiaddrInput to map.
* @returns The PeerId or Multiaddr.
* @throws {Error} If the input is not a valid PeerId or MultiaddrInput.
*/
export const mapToPeerIdOrMultiaddr = (
input: PeerId | MultiaddrInput
): PeerId | Multiaddr => {
return isPeerId(input) ? input : multiaddr(input);
};
/**
* Maps a PeerId or MultiaddrInput to a PeerId.
* @param input - The PeerId or MultiaddrInput to map.
* @returns The PeerId.
* @throws {Error} If the input is not a valid PeerId or MultiaddrInput.
*/
export const mapToPeerId = (input: PeerId | MultiaddrInput): PeerId => {
return isPeerId(input)
? input
: peerIdFromString(multiaddr(input).getPeerId()!);
};

View File

@ -42,7 +42,6 @@ export class FilterCore {
public constructor(
private handleIncomingMessage: IncomingMessageHandler,
public readonly pubsubTopics: PubsubTopic[],
libp2p: Libp2p
) {
this.streamManager = new StreamManager(

View File

@ -5,7 +5,6 @@ import {
type IMessage,
type Libp2p,
ProtocolError,
PubsubTopic,
type ThisOrThat
} from "@waku/interfaces";
import { PushResponse } from "@waku/proto";
@ -36,10 +35,7 @@ export class LightPushCore {
public readonly multicodec = LightPushCodec;
public constructor(
public readonly pubsubTopics: PubsubTopic[],
libp2p: Libp2p
) {
public constructor(libp2p: Libp2p) {
this.streamManager = new StreamManager(LightPushCodec, libp2p.components);
}

View File

@ -1,4 +1,4 @@
import type { NodeCapabilityCount } from "@waku/interfaces";
import { type NodeCapabilityCount, Tags } from "@waku/interfaces";
/**
* The ENR tree for the different fleets.
@ -10,7 +10,7 @@ export const enrTree = {
TEST: "enrtree://AOGYWMBYOUIMOENHXCHILPKY3ZRFEULMFI4DOM442QSZ73TT2A7VI@test.waku.nodes.status.im"
};
export const DEFAULT_BOOTSTRAP_TAG_NAME = "bootstrap";
export const DEFAULT_BOOTSTRAP_TAG_NAME = Tags.BOOTSTRAP;
export const DEFAULT_BOOTSTRAP_TAG_VALUE = 50;
export const DEFAULT_BOOTSTRAP_TAG_TTL = 100_000_000;

View File

@ -15,7 +15,7 @@ import {
} from "@waku/interfaces";
import { getWsMultiaddrFromMultiaddrs, Logger } from "@waku/utils";
const log = new Logger("peer-exchange-discovery");
const log = new Logger("local-cache-discovery");
type LocalPeerCacheDiscoveryOptions = {
tagName?: string;
@ -23,7 +23,7 @@ type LocalPeerCacheDiscoveryOptions = {
tagTTL?: number;
};
export const DEFAULT_LOCAL_TAG_NAME = Tags.LOCAL;
const DEFAULT_LOCAL_TAG_NAME = Tags.LOCAL;
const DEFAULT_LOCAL_TAG_VALUE = 50;
const DEFAULT_LOCAL_TAG_TTL = 100_000_000;

View File

@ -6,6 +6,5 @@ export {
export {
wakuPeerExchangeDiscovery,
PeerExchangeDiscovery,
Options,
DEFAULT_PEER_EXCHANGE_TAG_NAME
Options
} from "./waku_peer_exchange_discovery.js";

View File

@ -54,7 +54,7 @@ interface CustomDiscoveryEvent extends PeerDiscoveryEvents {
"waku:peer-exchange:started": CustomEvent<boolean>;
}
export const DEFAULT_PEER_EXCHANGE_TAG_NAME = Tags.PEER_EXCHANGE;
const DEFAULT_PEER_EXCHANGE_TAG_NAME = Tags.PEER_EXCHANGE;
const DEFAULT_PEER_EXCHANGE_TAG_VALUE = 50;
const DEFAULT_PEER_EXCHANGE_TAG_TTL = 100_000_000;

View File

@ -1,6 +1,7 @@
import type { Peer, PeerId, TypedEventEmitter } from "@libp2p/interface";
import type { Peer, PeerId, Stream } from "@libp2p/interface";
import type { MultiaddrInput } from "@multiformats/multiaddr";
import { PubsubTopic } from "./misc.js";
import type { PubsubTopic } from "./misc.js";
export enum Tags {
BOOTSTRAP = "bootstrap",
@ -9,28 +10,13 @@ export enum Tags {
}
export type ConnectionManagerOptions = {
/**
* Number of attempts before a peer is considered non-dialable.
* This is used to not spam a peer with dial attempts when it is not dialable.
*
* @default 3
*/
maxDialAttemptsForPeer: number;
/**
* Max number of bootstrap peers allowed to be connected to initially.
* This is used to increase intention of dialing non-bootstrap peers, found using other discovery mechanisms (like Peer Exchange).
*
* @default 1
*/
maxBootstrapPeersAllowed: number;
/**
* Max number of parallel dials allowed.
*
* @default 3
*/
maxParallelDials: number;
maxBootstrapPeers: number;
/**
* Keep alive libp2p pings interval in seconds.
@ -47,47 +33,107 @@ export type ConnectionManagerOptions = {
relayKeepAlive: number;
};
export enum EPeersByDiscoveryEvents {
PEER_DISCOVERY_BOOTSTRAP = "peer:discovery:bootstrap",
PEER_DISCOVERY_PEER_EXCHANGE = "peer:discovery:peer-exchange",
PEER_CONNECT_BOOTSTRAP = "peer:connected:bootstrap",
PEER_CONNECT_PEER_EXCHANGE = "peer:connected:peer-exchange"
}
export interface IConnectionManager {
/**
* Starts network monitoring, dialing discovered peers, keep-alive management, and connection limiting.
*
* @example
* ```typescript
* const connectionManager = new ConnectionManager(options);
* connectionManager.start();
* ```
*/
start(): void;
export interface IPeersByDiscoveryEvents {
[EPeersByDiscoveryEvents.PEER_DISCOVERY_BOOTSTRAP]: CustomEvent<PeerId>;
[EPeersByDiscoveryEvents.PEER_DISCOVERY_PEER_EXCHANGE]: CustomEvent<PeerId>;
[EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP]: CustomEvent<PeerId>;
[EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE]: CustomEvent<PeerId>;
}
export interface PeersByDiscoveryResult {
DISCOVERED: {
[Tags.BOOTSTRAP]: Peer[];
[Tags.PEER_EXCHANGE]: Peer[];
[Tags.LOCAL]: Peer[];
};
CONNECTED: {
[Tags.BOOTSTRAP]: Peer[];
[Tags.PEER_EXCHANGE]: Peer[];
[Tags.LOCAL]: Peer[];
};
}
export enum EConnectionStateEvents {
CONNECTION_STATUS = "waku:connection"
}
export interface IConnectionStateEvents {
// true when online, false when offline
[EConnectionStateEvents.CONNECTION_STATUS]: CustomEvent<boolean>;
}
export interface IConnectionManager
extends TypedEventEmitter<IPeersByDiscoveryEvents & IConnectionStateEvents> {
pubsubTopics: PubsubTopic[];
getConnectedPeers(codec?: string): Promise<Peer[]>;
dropConnection(peerId: PeerId): Promise<void>;
getPeersByDiscovery(): Promise<PeersByDiscoveryResult>;
/**
* Stops network monitoring, discovery dialing, keep-alive management, and connection limiting.
*
* @example
* ```typescript
* connectionManager.stop();
* ```
*/
stop(): void;
/**
* Connects to a peer using specific protocol codecs.
* This is a direct proxy to libp2p's dialProtocol method.
*
* @param peer - The peer to connect to (PeerId or multiaddr)
* @param protocolCodecs - Array of protocol codec strings to establish
* @returns Promise resolving to a Stream connection to the peer
* @throws Error if the connection cannot be established
*
* @example
* ```typescript
* const stream = await connectionManager.dial(
* peerId,
* ["/vac/waku/store/2.0.0-beta4"]
* );
* ```
*/
dial(
peer: PeerId | MultiaddrInput,
protocolCodecs: string[]
): Promise<Stream>;
/**
* Terminates the connection to a specific peer.
*
* @param peer - The peer to disconnect from (PeerId or multiaddr)
* @returns Promise resolving to true if disconnection was successful, false otherwise
*
* @example
* ```typescript
* const success = await connectionManager.hangUp(peerId);
* if (success) {
* console.log("Peer disconnected successfully");
* }
* ```
*/
hangUp(peer: PeerId | MultiaddrInput): Promise<boolean>;
/**
* Retrieves a list of currently connected peers, optionally filtered by protocol codec.
* Results are sorted by ping time (lowest first).
*
* @param codec - Optional protocol codec to filter peers by
* @returns Promise resolving to an array of connected Peer objects
*
* @example
* ```typescript
* // Get all connected peers
* const allPeers = await connectionManager.getConnectedPeers();
*
* // Get peers supporting a specific protocol
* const storePeers = await connectionManager.getConnectedPeers(
* "/vac/waku/store/2.0.0-beta4"
* );
* ```
*/
getConnectedPeers(codec?: string): Promise<Peer[]>;
/**
* Checks if a specific pubsub topic is configured in the connection manager.
*
* @param pubsubTopic - The pubsub topic to check
* @returns True if the topic is configured, false otherwise
*
* @example
* ```typescript
* const isConfigured = connectionManager.isTopicConfigured("/waku/2/default-waku/proto");
* if (isConfigured) {
* console.log("Topic is configured");
* }
* ```
*/
isTopicConfigured(pubsubTopic: PubsubTopic): boolean;
/**
* Checks if a peer has shard info.
*
* @param peerId - The peer to check
* @returns Promise resolving to true if the peer has shard info, false otherwise
*/
hasShardInfo(peerId: PeerId): Promise<boolean>;
}

View File

@ -3,6 +3,7 @@ import type { ContentTopic, PubsubTopic } from "./misc.js";
export interface SingleShardInfo {
clusterId: number;
/**
* TODO: make shard required
* Specifying this field indicates to the encoder/decoder that static sharding must be used.
*/
shard?: number;

View File

@ -1,7 +1,11 @@
import type { Peer, PeerId, Stream } from "@libp2p/interface";
import type {
Peer,
PeerId,
Stream,
TypedEventEmitter
} from "@libp2p/interface";
import type { MultiaddrInput } from "@multiformats/multiaddr";
import type { IConnectionManager } from "./connection_manager.js";
import type { IFilter } from "./filter.js";
import type { IHealthIndicator } from "./health_indicator.js";
import type { Libp2p } from "./libp2p.js";
@ -30,15 +34,21 @@ export type CreateEncoderParams = CreateDecoderParams & {
ephemeral?: boolean;
};
export interface IWakuEvents {
"waku:connection": CustomEvent<boolean>;
}
export type IWakuEventEmitter = TypedEventEmitter<IWakuEvents>;
export interface IWaku {
libp2p: Libp2p;
relay?: IRelay;
store?: IStore;
filter?: IFilter;
lightPush?: ILightPush;
connectionManager: IConnectionManager;
health: IHealthIndicator;
events: IWakuEventEmitter;
/**
* Returns a unique identifier for a node on the network.
@ -66,7 +76,7 @@ export interface IWaku {
* @param {PeerId | MultiaddrInput} peer information to use for dialing
* @param {Protocols[]} [protocols] array of Waku protocols to be used for dialing. If no provided - will be derived from mounted protocols.
*
* @returns {Promise<Stream>} `Promise` that will resolve to a `Stream` to a dialed peer
* @returns {Promise<Stream>} `Promise` that will resolve to a `Stream` to a dialed peer and will reject if the connection fails
*
* @example
* ```typescript
@ -77,6 +87,15 @@ export interface IWaku {
*/
dial(peer: PeerId | MultiaddrInput, protocols?: Protocols[]): Promise<Stream>;
/**
* Hang up a connection to a peer
*
* @param {PeerId | MultiaddrInput} peer information to use for hanging up
*
* @returns {Promise<boolean>} `Promise` that will resolve to `true` if the connection is hung up, `false` otherwise
*/
hangUp(peer: PeerId | MultiaddrInput): Promise<boolean>;
/**
* Starts all services and components related to functionality of Waku node.
*

View File

@ -32,11 +32,18 @@ export async function createRelayNode(
libp2p
});
return new WakuNode(
const node = new WakuNode(
pubsubTopics,
options as CreateNodeOptions,
libp2p,
{},
relay
) as RelayNode;
// only if `false` is passed explicitly
if (options?.autoStart !== false) {
await node.start();
}
return node;
}

View File

@ -160,8 +160,10 @@ function mockLibp2p(): Libp2p {
function mockConnectionManager(): ConnectionManager {
return {
pubsubTopics: [PUBSUB_TOPIC]
} as ConnectionManager;
isTopicConfigured: sinon.stub().callsFake((topic: string) => {
return topic === PUBSUB_TOPIC;
})
} as unknown as ConnectionManager;
}
function mockPeerManager(): PeerManager {

View File

@ -39,7 +39,6 @@ export class Filter implements IFilter {
this.protocol = new FilterCore(
this.onIncomingMessage.bind(this),
params.connectionManager.pubsubTopics,
params.libp2p
);
}
@ -174,7 +173,7 @@ export class Filter implements IFilter {
private throwIfTopicNotSupported(pubsubTopic: string): void {
const supportedPubsubTopic =
this.connectionManager.pubsubTopics.includes(pubsubTopic);
this.connectionManager.isTopicConfigured(pubsubTopic);
if (!supportedPubsubTopic) {
throw Error(

View File

@ -169,8 +169,9 @@ type MockLightPushOptions = {
function mockLightPush(options: MockLightPushOptions): LightPush {
const lightPush = new LightPush({
connectionManager: {
pubsubTopics: options.pubsubTopics || [PUBSUB_TOPIC]
} as ConnectionManager,
isTopicConfigured: (topic: string) =>
(options.pubsubTopics || [PUBSUB_TOPIC]).includes(topic)
} as unknown as ConnectionManager,
peerManager: {
getPeers: () =>
options.libp2p

View File

@ -40,6 +40,7 @@ export class LightPush implements ILightPush {
private readonly config: LightPushProtocolOptions;
private readonly retryManager: RetryManager;
private readonly peerManager: PeerManager;
private readonly connectionManager: ConnectionManager;
private readonly protocol: LightPushCore;
public constructor(params: LightPushConstructorParams) {
@ -49,10 +50,8 @@ export class LightPush implements ILightPush {
} as LightPushProtocolOptions;
this.peerManager = params.peerManager;
this.protocol = new LightPushCore(
params.connectionManager.pubsubTopics,
params.libp2p
);
this.connectionManager = params.connectionManager;
this.protocol = new LightPushCore(params.libp2p);
this.retryManager = new RetryManager({
peerManager: params.peerManager,
retryIntervalMs: this.config.retryIntervalMs
@ -85,7 +84,7 @@ export class LightPush implements ILightPush {
log.info("send: attempting to send a message to pubsubTopic:", pubsubTopic);
if (!this.protocol.pubsubTopics.includes(pubsubTopic)) {
if (!this.connectionManager.isTopicConfigured(pubsubTopic)) {
return {
successes: [],
failures: [

View File

@ -61,7 +61,7 @@ describe("PeerManager", () => {
pubsubTopics: [TEST_PUBSUB_TOPIC],
getConnectedPeers: async () => peers,
getPeers: async () => peers,
isPeerOnPubsubTopic: async (_id: PeerId, _topic: string) => true
isPeerOnTopic: async (_id: PeerId, _topic: string) => true
} as unknown as IConnectionManager;
peerManager = new PeerManager({
libp2p,

View File

@ -114,7 +114,7 @@ export class PeerManager {
for (const peer of connectedPeers) {
const hasProtocol = this.hasPeerProtocol(peer, params.protocol);
const hasSamePubsub = await this.connectionManager.isPeerOnPubsubTopic(
const hasSamePubsub = await this.connectionManager.isPeerOnTopic(
peer.id,
params.pubsubTopic
);
@ -187,7 +187,14 @@ export class PeerManager {
id: PeerId,
pubsubTopic: string
): Promise<boolean> {
return this.connectionManager.isPeerOnPubsubTopic(id, pubsubTopic);
const hasShardInfo = await this.connectionManager.hasShardInfo(id);
// allow to use peers that we don't know information about yet
if (!hasShardInfo) {
return true;
}
return this.connectionManager.isPeerOnTopic(id, pubsubTopic);
}
private async onConnected(event: CustomEvent<IdentifyResult>): Promise<void> {

View File

@ -229,12 +229,12 @@ export class Store implements IStore {
}
const pubsubTopicForQuery = uniquePubsubTopicsInQuery[0];
const isPubsubSupported =
this.connectionManager.pubsubTopics.includes(pubsubTopicForQuery);
const isTopicSupported =
this.connectionManager.isTopicConfigured(pubsubTopicForQuery);
if (!isPubsubSupported) {
if (!isTopicSupported) {
throw new Error(
`Pubsub topic ${pubsubTopicForQuery} has not been configured on this instance. Configured topics are: ${this.connectionManager.pubsubTopics}`
`Pubsub topic ${pubsubTopicForQuery} has not been configured on this instance.`
);
}

View File

@ -1,38 +1,12 @@
import { peerIdFromString } from "@libp2p/peer-id";
import { DEFAULT_NUM_SHARDS, DefaultNetworkConfig } from "@waku/interfaces";
import { contentTopicToShardIndex } from "@waku/utils";
import { expect } from "chai";
import {
decoderParamsToShardInfo,
isShardCompatible,
mapToPeerIdOrMultiaddr
} from "./utils.js";
import { decoderParamsToShardInfo, isShardCompatible } from "./utils.js";
const TestContentTopic = "/test/1/waku-sdk/utf8";
describe("IWaku utils", () => {
describe("mapToPeerIdOrMultiaddr", () => {
it("should return PeerId when PeerId is provided", async () => {
const peerId = peerIdFromString(
"12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE"
);
const result = mapToPeerIdOrMultiaddr(peerId);
expect(result).to.equal(peerId);
});
it("should return Multiaddr when Multiaddr input is provided", () => {
const multiAddr =
"/ip4/127.0.0.1/tcp/8000/p2p/12D3KooWHFJGwBXD7ukXqKaQZYmV1U3xxN1XCNrgriSEyvkxf6nE";
const result = mapToPeerIdOrMultiaddr(multiAddr);
expect(result.toString()).to.equal(multiAddr);
});
});
describe("decoderParamsToShardInfo", () => {
it("should use provided shard info when available", () => {
const params = {

View File

@ -1,6 +1,3 @@
import { isPeerId } from "@libp2p/interface";
import type { PeerId } from "@libp2p/interface";
import { multiaddr, Multiaddr, MultiaddrInput } from "@multiformats/multiaddr";
import type {
CreateDecoderParams,
NetworkConfig,
@ -9,12 +6,6 @@ import type {
import { DEFAULT_NUM_SHARDS } from "@waku/interfaces";
import { contentTopicToShardIndex } from "@waku/utils";
export const mapToPeerIdOrMultiaddr = (
peerId: PeerId | MultiaddrInput
): PeerId | Multiaddr => {
return isPeerId(peerId) ? peerId : multiaddr(peerId);
};
export const decoderParamsToShardInfo = (
params: CreateDecoderParams,
networkConfig: NetworkConfig

View File

@ -1,5 +1,10 @@
import type { Peer, PeerId, Stream } from "@libp2p/interface";
import { MultiaddrInput } from "@multiformats/multiaddr";
import {
type Peer,
type PeerId,
type Stream,
TypedEventEmitter
} from "@libp2p/interface";
import type { MultiaddrInput } from "@multiformats/multiaddr";
import { ConnectionManager, createDecoder, createEncoder } from "@waku/core";
import type {
CreateDecoderParams,
@ -13,6 +18,7 @@ import type {
IRelay,
IStore,
IWaku,
IWakuEventEmitter,
Libp2p,
NetworkConfig,
PubsubTopic
@ -26,11 +32,7 @@ import { LightPush } from "../light_push/index.js";
import { PeerManager } from "../peer_manager/index.js";
import { Store } from "../store/index.js";
import {
decoderParamsToShardInfo,
isShardCompatible,
mapToPeerIdOrMultiaddr
} from "./utils.js";
import { decoderParamsToShardInfo, isShardCompatible } from "./utils.js";
import { waitForRemotePeer } from "./wait_for_remote_peer.js";
const log = new Logger("waku");
@ -47,19 +49,21 @@ export class WakuNode implements IWaku {
public store?: IStore;
public filter?: IFilter;
public lightPush?: ILightPush;
public connectionManager: ConnectionManager;
public health: HealthIndicator;
public readonly networkConfig: NetworkConfig;
public readonly health: HealthIndicator;
public readonly events: IWakuEventEmitter = new TypedEventEmitter();
private readonly networkConfig: NetworkConfig;
// needed to create a lock for async operations
private _nodeStateLock = false;
private _nodeStarted = false;
private readonly connectionManager: ConnectionManager;
private readonly peerManager: PeerManager;
public constructor(
public readonly pubsubTopics: PubsubTopic[],
pubsubTopics: PubsubTopic[],
options: CreateNodeOptions,
libp2p: Libp2p,
protocolsEnabled: ProtocolsEnabled,
@ -81,7 +85,9 @@ export class WakuNode implements IWaku {
this.connectionManager = new ConnectionManager({
libp2p,
relay: this.relay,
pubsubTopics: this.pubsubTopics,
events: this.events,
pubsubTopics: pubsubTopics,
networkConfig: this.networkConfig,
config: options?.connectionManager
});
@ -191,9 +197,15 @@ export class WakuNode implements IWaku {
}
}
const peerId = mapToPeerIdOrMultiaddr(peer);
log.info(`Dialing to ${peerId.toString()} with protocols ${_protocols}`);
return await this.connectionManager.rawDialPeerWithProtocols(peer, codecs);
log.info(`Dialing to ${peer?.toString()} with protocols ${_protocols}`);
return await this.connectionManager.dial(peer, codecs);
}
public async hangUp(peer: PeerId | MultiaddrInput): Promise<boolean> {
log.info(`Hanging up peer:${peer?.toString()}.`);
return this.connectionManager.hangUp(peer);
}
public async start(): Promise<void> {
@ -202,6 +214,7 @@ export class WakuNode implements IWaku {
this._nodeStateLock = true;
await this.libp2p.start();
this.connectionManager.start();
this.peerManager.start();
this.health.start();
this.lightPush?.start();

View File

@ -16,9 +16,9 @@ import { ServiceNode } from "./service_node.js";
export const log = new Logger("test:runNodes");
export const DEFAULT_DISCOVERIES_ENABLED = {
dns: true,
dns: false,
peerExchange: true,
localPeerCache: true
localPeerCache: false
};
type RunNodesOptions = {

View File

@ -24,7 +24,8 @@ export async function runMultipleNodes(
customArgs?: Args,
strictChecking: boolean = false,
numServiceNodes = 2,
withoutFilter = false
withoutFilter = false,
jsWakuParams: CreateNodeOptions = {}
): Promise<[ServiceNodesFleet, LightNode]> {
// create numServiceNodes nodes
const serviceNodes = await ServiceNodesFleet.createAndRun(
@ -43,7 +44,8 @@ export async function runMultipleNodes(
},
networkConfig,
lightPush: { numPeersToUse: numServiceNodes },
discovery: DEFAULT_DISCOVERIES_ENABLED
discovery: DEFAULT_DISCOVERIES_ENABLED,
...jsWakuParams
};
const waku = await createLightNode(wakuOptions);

View File

@ -0,0 +1,168 @@
import { LightNode, Tags } from "@waku/interfaces";
import { expect } from "chai";
import {
afterEachCustom,
beforeEachCustom,
runMultipleNodes,
ServiceNodesFleet,
teardownNodesWithRedundancy
} from "../../src/index.js";
import { TestShardInfo } from "./utils.js";
describe("Connection Limiter", function () {
let waku: LightNode;
let serviceNodes: ServiceNodesFleet;
beforeEachCustom(this, async () => {
[serviceNodes, waku] = await runMultipleNodes(
this.ctx,
TestShardInfo,
{ lightpush: true, filter: true, peerExchange: true },
false,
2,
true
);
});
afterEachCustom(this, async () => {
await teardownNodesWithRedundancy(serviceNodes, [waku]);
});
it("should dial all known peers when reached zero connections", async function () {
let peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
for (const node of serviceNodes.nodes) {
const addr = await node.getMultiaddrWithId();
await waku.hangUp(addr);
}
peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(0, "Connection should be dropped");
const connectPromise = new Promise((resolve) => {
waku.libp2p.addEventListener("peer:connect", (event) => {
resolve(event.detail);
});
});
await connectPromise;
peers = await waku.getConnectedPeers();
// checking for greater than 0 because in CI environment, nwaku not always accepts dial
expect(peers.length).to.be.greaterThan(
0,
"Connection should be established"
);
});
it("should discard bootstrap peers when has more than 1 (default limit)", async function () {
let peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
for (const node of serviceNodes.nodes) {
const peerId = await node.getPeerId();
await waku.libp2p.peerStore.patch(peerId, {
tags: new Map([[Tags.BOOTSTRAP, { value: 1 }]])
});
}
const disconnectPromise = new Promise((resolve) => {
waku.libp2p.addEventListener("peer:disconnect", (event) => {
resolve(event.detail);
});
});
// simulate connection to a peer
waku.libp2p.dispatchEvent(
new CustomEvent("peer:connect", {
detail: await serviceNodes.nodes[0].getPeerId()
})
);
await disconnectPromise;
peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length - 1,
"Should have only one peer dropped"
);
const bootstrapPeers = peers.filter((peer) =>
peer.tags.has(Tags.BOOTSTRAP)
);
expect(bootstrapPeers.length).to.equal(
1,
"Should have only one bootstrap peer"
);
});
it("should not discard bootstrap peers if under the limit", async function () {
this.timeout(15_000); // increase due to additional initialization
await teardownNodesWithRedundancy(serviceNodes, [waku]);
[serviceNodes, waku] = await runMultipleNodes(
this.ctx,
TestShardInfo,
{ lightpush: true, filter: true, peerExchange: true },
false,
2,
true,
{ connectionManager: { maxBootstrapPeers: 2 } }
);
let peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
for (const node of serviceNodes.nodes) {
const peerId = await node.getPeerId();
await waku.libp2p.peerStore.patch(peerId, {
tags: new Map([[Tags.BOOTSTRAP, { value: 1 }]])
});
}
const disconnectPromise = new Promise((resolve) => {
waku.libp2p.addEventListener("peer:disconnect", () => {
resolve(true);
});
setTimeout(() => resolve(false), 1000);
});
// simulate connection to a peer
waku.libp2p.dispatchEvent(
new CustomEvent("peer:connect", {
detail: await serviceNodes.nodes[0].getPeerId()
})
);
const hasDisconnected = await disconnectPromise;
expect(hasDisconnected).to.equal(false, "Should not disconnect");
peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Should have all peers"
);
const bootstrapPeers = peers.filter((peer) =>
peer.tags.has(Tags.BOOTSTRAP)
);
expect(bootstrapPeers.length).to.equal(
2,
"Should have only two bootstrap peers"
);
});
});

View File

@ -1,195 +0,0 @@
import { Multiaddr } from "@multiformats/multiaddr";
import { EConnectionStateEvents, LightNode, Protocols } from "@waku/interfaces";
import { createRelayNode } from "@waku/relay";
import { createLightNode } from "@waku/sdk";
import { expect } from "chai";
import {
afterEachCustom,
beforeEachCustom,
DefaultTestShardInfo,
delay,
NOISE_KEY_1
} from "../../src/index.js";
import {
makeLogFileName,
ServiceNode,
tearDownNodes
} from "../../src/index.js";
const TEST_TIMEOUT = 30_000;
describe("Connection state", function () {
this.timeout(TEST_TIMEOUT);
let waku: LightNode;
let nwaku1: ServiceNode;
let nwaku2: ServiceNode;
let nwaku1PeerId: Multiaddr;
let nwaku2PeerId: Multiaddr;
let navigatorMock: any;
let originalNavigator: any;
beforeEachCustom(this, async () => {
waku = await createLightNode({ networkConfig: DefaultTestShardInfo });
nwaku1 = new ServiceNode(makeLogFileName(this.ctx) + "1");
nwaku2 = new ServiceNode(makeLogFileName(this.ctx) + "2");
await nwaku1.start({ filter: true });
await nwaku2.start({ filter: true });
nwaku1PeerId = await nwaku1.getMultiaddrWithId();
nwaku2PeerId = await nwaku2.getMultiaddrWithId();
navigatorMock = { onLine: true };
Object.defineProperty(globalThis, "navigator", {
value: navigatorMock,
configurable: true,
writable: false
});
});
afterEachCustom(this, async () => {
await tearDownNodes([nwaku1, nwaku2], waku);
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
configurable: true,
writable: false
});
});
it("should emit `waku:online` event only when first peer is connected", async function () {
let eventCount = 0;
const connectionStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
eventCount++;
resolve(status);
}
);
});
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await delay(400);
expect(await connectionStatus).to.eq(true);
expect(eventCount).to.be.eq(1);
await waku.dial(nwaku2PeerId, [Protocols.Filter]);
await delay(400);
expect(eventCount).to.be.eq(1);
});
it("should emit `waku:offline` event only when all peers disconnect", async function () {
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await waku.dial(nwaku2PeerId, [Protocols.Filter]);
let eventCount = 0;
const connectionStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
eventCount++;
resolve(status);
}
);
});
await nwaku1.stop();
await delay(400);
expect(eventCount).to.be.eq(0);
await nwaku2.stop();
expect(await connectionStatus).to.eq(false);
expect(eventCount).to.be.eq(1);
});
it("`waku:online` between 2 js-waku relay nodes", async function () {
const waku1 = await createRelayNode({
staticNoiseKey: NOISE_KEY_1,
networkConfig: DefaultTestShardInfo
});
const waku2 = await createRelayNode({
libp2p: { addresses: { listen: ["/ip4/0.0.0.0/tcp/0/ws"] } },
networkConfig: DefaultTestShardInfo
});
let eventCount1 = 0;
const connectionStatus1 = new Promise<boolean>((resolve) => {
waku1.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
eventCount1++;
resolve(status);
}
);
});
let eventCount2 = 0;
const connectionStatus2 = new Promise<boolean>((resolve) => {
waku2.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
eventCount2++;
resolve(status);
}
);
});
await waku1.libp2p.peerStore.merge(waku2.libp2p.peerId, {
multiaddrs: waku2.libp2p.getMultiaddrs()
});
await Promise.all([waku1.dial(waku2.libp2p.peerId)]);
await delay(400);
expect(await connectionStatus1).to.eq(true);
expect(await connectionStatus2).to.eq(true);
expect(eventCount1).to.be.eq(1);
expect(eventCount2).to.be.eq(1);
});
it("isConnected should return true after first peer connects", async function () {
expect(waku.isConnected()).to.be.false;
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await delay(400);
expect(waku.isConnected()).to.be.true;
});
it("isConnected should return false after all peers disconnect", async function () {
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await waku.dial(nwaku2PeerId, [Protocols.Filter]);
await delay(250);
expect(waku.isConnected()).to.be.true;
await waku.libp2p.hangUp(nwaku1PeerId);
expect(waku.isConnected()).to.be.true;
await waku.libp2p.hangUp(nwaku2PeerId);
expect(waku.isConnected()).to.be.false;
});
it("isConnected return false after peer stops", async function () {
expect(waku.isConnected()).to.be.false;
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await delay(400);
expect(waku.isConnected()).to.be.true;
await nwaku1.stop();
await delay(400);
expect(waku.isConnected()).to.be.false;
});
it("isConnected between 2 js-waku relay nodes", async function () {
const waku1 = await createRelayNode({
staticNoiseKey: NOISE_KEY_1
});
const waku2 = await createRelayNode({
libp2p: { addresses: { listen: ["/ip4/0.0.0.0/tcp/0/ws"] } }
});
await waku1.libp2p.peerStore.merge(waku2.libp2p.peerId, {
multiaddrs: waku2.libp2p.getMultiaddrs()
});
await Promise.all([waku1.dial(waku2.libp2p.peerId)]);
await delay(400);
expect(waku1.isConnected()).to.be.true;
expect(waku2.isConnected()).to.be.true;
});
});

View File

@ -0,0 +1,152 @@
import { LightNode } from "@waku/interfaces";
import { expect } from "chai";
import type { Context } from "mocha";
import {
afterEachCustom,
beforeEachCustom,
runMultipleNodes,
ServiceNodesFleet,
teardownNodesWithRedundancy
} from "../../src/index.js";
import { TestShardInfo } from "./utils.js";
describe("Dialing", function () {
const ctx: Context = this.ctx;
let waku: LightNode;
let serviceNodes: ServiceNodesFleet;
beforeEachCustom(this, async () => {
[serviceNodes, waku] = await runMultipleNodes(
this.ctx,
TestShardInfo,
{ lightpush: true, filter: true, peerExchange: true },
false,
2,
true
);
await teardownNodesWithRedundancy(serviceNodes, []);
serviceNodes = await ServiceNodesFleet.createAndRun(
ctx,
2,
false,
TestShardInfo,
{
lightpush: true,
filter: true,
peerExchange: true
},
false
);
});
afterEachCustom(this, async () => {
await teardownNodesWithRedundancy(serviceNodes, [waku]);
});
it("should dial all peers on dial", async function () {
for (const node of serviceNodes.nodes) {
const addr = await node.getMultiaddrWithId();
await waku.dial(addr);
}
const peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
});
it("should drop connection to all peers on hangUp", async function () {
for (const node of serviceNodes.nodes) {
const addr = await node.getMultiaddrWithId();
await waku.dial(addr);
}
let peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
for (const node of serviceNodes.nodes) {
const peerId = await node.getPeerId();
await waku.hangUp(peerId);
}
peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(0, "Connection should be dropped");
});
it("should dial one peer on dial", async function () {
const addr = await serviceNodes.nodes[0].getMultiaddrWithId();
await waku.dial(addr);
const peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(1, "Connection should be established");
});
it("should drop connection to one peer on hangUp", async function () {
for (const node of serviceNodes.nodes) {
const addr = await node.getMultiaddrWithId();
await waku.dial(addr);
}
let peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
const peerId = await serviceNodes.nodes[0].getPeerId();
await waku.hangUp(peerId);
peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length - 1,
"Connection should be dropped"
);
});
it("should drop connection via multiaddr with hangUp", async function () {
for (const node of serviceNodes.nodes) {
const addr = await node.getMultiaddrWithId();
await waku.dial(addr);
}
let peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length,
"Connection should be established"
);
const addr = await serviceNodes.nodes[0].getMultiaddrWithId();
await waku.hangUp(addr);
peers = await waku.getConnectedPeers();
expect(peers.length).to.equal(
serviceNodes.nodes.length - 1,
"Connection should be dropped"
);
});
it("should be able to dial TLS multiaddrs", async function () {
let tlsWorks = true;
const multiaddr = `/ip4/127.0.0.1/tcp/30303/tls/ws`;
try {
// dummy multiaddr, doesn't have to be valid
await waku.dial(multiaddr);
} catch (error) {
if (error instanceof Error) {
expect(error.message).to.eq(`Could not connect to ${multiaddr}`);
tlsWorks = !error.message.includes("Unsupported protocol tls");
}
}
expect(tlsWorks).to.eq(true);
});
});

View File

@ -1,221 +0,0 @@
import { generateKeyPair } from "@libp2p/crypto/keys";
import type { PeerInfo } from "@libp2p/interface";
import { peerIdFromPrivateKey } from "@libp2p/peer-id";
import { LightNode, Tags } from "@waku/interfaces";
import { createLightNode } from "@waku/sdk";
import { expect } from "chai";
import sinon, { SinonSpy, SinonStub } from "sinon";
import { afterEachCustom, beforeEachCustom, delay } from "../../src/index.js";
import { tearDownNodes } from "../../src/index.js";
const DELAY_MS = 1_000;
const TEST_TIMEOUT = 20_000;
describe("Dials", function () {
this.timeout(TEST_TIMEOUT);
let dialPeerStub: SinonStub;
let getConnectionsStub: SinonStub;
let getTagNamesForPeerStub: SinonStub;
let isPeerOnSameShard: SinonStub;
let waku: LightNode;
beforeEachCustom(this, async () => {
waku = await createLightNode();
isPeerOnSameShard = sinon.stub(
waku.connectionManager as any,
"isPeerOnSameShard"
);
isPeerOnSameShard.resolves(true);
});
afterEachCustom(this, async () => {
await tearDownNodes([], waku);
isPeerOnSameShard.restore();
sinon.restore();
});
describe("attemptDial method", function () {
let attemptDialSpy: SinonSpy;
beforeEachCustom(this, async () => {
attemptDialSpy = sinon.spy(waku.connectionManager as any, "attemptDial");
});
afterEachCustom(this, async () => {
attemptDialSpy.restore();
});
it("should be called at least once on all `peer:discovery` events", async function () {
const totalPeerIds = 5;
for (let i = 1; i <= totalPeerIds; i++) {
const privateKey = await generateKeyPair("secp256k1");
const peerId = peerIdFromPrivateKey(privateKey);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: peerId,
multiaddrs: []
}
})
);
}
await delay(100);
expect(attemptDialSpy.callCount).to.be.greaterThanOrEqual(
totalPeerIds,
"attemptDial should be called at least once for each peer:discovery event"
);
});
});
describe("dialPeer method", function () {
let peerStoreHasStub: SinonStub;
let dialAttemptsForPeerHasStub: SinonStub;
beforeEachCustom(this, async () => {
getConnectionsStub = sinon.stub(
(waku.connectionManager as any).libp2p,
"getConnections"
);
getTagNamesForPeerStub = sinon.stub(
waku.connectionManager as any,
"getTagNamesForPeer"
);
dialPeerStub = sinon.stub(waku.connectionManager as any, "dialPeer");
peerStoreHasStub = sinon.stub(waku.libp2p.peerStore, "has");
dialAttemptsForPeerHasStub = sinon.stub(
(waku.connectionManager as any).dialAttemptsForPeer,
"has"
);
// simulate that the peer is not connected
getConnectionsStub.returns([]);
// simulate that the peer is a bootstrap peer
getTagNamesForPeerStub.resolves([Tags.BOOTSTRAP]);
// simulate that the peer is not in the peerStore
peerStoreHasStub.returns(false);
// simulate that the peer has not been dialed before
dialAttemptsForPeerHasStub.returns(false);
});
afterEachCustom(this, async () => {
dialPeerStub.restore();
getTagNamesForPeerStub.restore();
getConnectionsStub.restore();
peerStoreHasStub.restore();
dialAttemptsForPeerHasStub.restore();
});
describe("For bootstrap peers", function () {
it("should be called for bootstrap peers", async function () {
const privateKey = await generateKeyPair("secp256k1");
const bootstrapPeer = peerIdFromPrivateKey(privateKey);
// emit a peer:discovery event
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: { id: bootstrapPeer, multiaddrs: [] }
})
);
// wait for the async function calls within attemptDial to finish
await delay(DELAY_MS);
// check that dialPeer was called once
expect(dialPeerStub.callCount).to.equal(
1,
"dialPeer should be called for bootstrap peers"
);
});
it("should not be called more than DEFAULT_MAX_BOOTSTRAP_PEERS_ALLOWED times for bootstrap peers", async function () {
const privateKey = await generateKeyPair("secp256k1");
const bootstrapPeer = peerIdFromPrivateKey(privateKey);
// emit first peer:discovery event
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: bootstrapPeer,
multiaddrs: []
}
})
);
await delay(500);
// simulate that the peer is connected
getConnectionsStub.returns([{ tags: [{ name: Tags.BOOTSTRAP }] }]);
// emit multiple peer:discovery events
const totalBootstrapPeers = 5;
for (let i = 1; i <= totalBootstrapPeers; i++) {
await delay(500);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: bootstrapPeer,
multiaddrs: []
}
})
);
}
// check that dialPeer was called only once
expect(dialPeerStub.callCount).to.equal(
1,
"dialPeer should not be called more than once for bootstrap peers"
);
});
});
describe("For peer-exchange peers", function () {
it("should be called for peers with PEER_EXCHANGE tags", async function () {
const privateKey = await generateKeyPair("secp256k1");
const pxPeer = peerIdFromPrivateKey(privateKey);
// emit a peer:discovery event
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: pxPeer,
multiaddrs: []
}
})
);
// wait for the async function calls within attemptDial to finish
await delay(DELAY_MS);
// check that dialPeer was called once
expect(dialPeerStub.callCount).to.equal(
1,
"dialPeer should be called for peers with PEER_EXCHANGE tags"
);
});
it("should be called for every peer with PEER_EXCHANGE tags", async function () {
// emit multiple peer:discovery events
const totalPxPeers = 5;
for (let i = 0; i < totalPxPeers; i++) {
const privateKey = await generateKeyPair("secp256k1");
const pxPeer = peerIdFromPrivateKey(privateKey);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: pxPeer,
multiaddrs: []
}
})
);
await delay(500);
}
// check that dialPeer was called for each peer with PEER_EXCHANGE tags
expect(dialPeerStub.callCount).to.equal(totalPxPeers);
});
});
});
});

View File

@ -0,0 +1,110 @@
import { LightNode } from "@waku/interfaces";
import { expect } from "chai";
import type { Context } from "mocha";
import {
afterEachCustom,
beforeEachCustom,
delay,
runMultipleNodes,
ServiceNodesFleet,
teardownNodesWithRedundancy
} from "../../src/index.js";
import { TestShardInfo } from "./utils.js";
// TODO: investigate and re-enable in https://github.com/waku-org/js-waku/issues/2453
describe.skip("DiscoveryDialer", function () {
const ctx: Context = this.ctx;
let waku: LightNode;
let serviceNodes: ServiceNodesFleet;
beforeEachCustom(this, async () => {
[serviceNodes, waku] = await runMultipleNodes(
this.ctx,
TestShardInfo,
{ lightpush: true, filter: true, peerExchange: true },
false,
2,
true
);
await teardownNodesWithRedundancy(serviceNodes, []);
serviceNodes = await ServiceNodesFleet.createAndRun(
ctx,
2,
false,
TestShardInfo,
{
lightpush: true,
filter: true,
peerExchange: true
},
false
);
});
afterEachCustom(this, async () => {
await teardownNodesWithRedundancy(serviceNodes, waku);
});
it("should dial second nwaku node that was discovered", async function () {
const maddrs = await Promise.all(
serviceNodes.nodes.map((n) => n.getMultiaddrWithId())
);
expect(waku.isConnected(), "waku is connected").to.be.false;
expect(
await waku.getConnectedPeers(),
"waku has no connected peers"
).to.have.length(0);
const connectPromise = new Promise((resolve, reject) => {
waku.libp2p.addEventListener("peer:connect", () => {
resolve(true);
});
setTimeout(() => {
reject(new Error("Timeout waiting for peer:connect event"));
}, 1000);
});
try {
await waku.dial(maddrs[0]);
} catch (error) {
throw Error(error as string);
}
await connectPromise;
expect(waku.isConnected(), "waku is connected").to.be.true;
expect(
await waku.getConnectedPeers(),
"waku has one connected peer"
).to.have.length(1);
const secondPeerId = await serviceNodes.nodes[1].getPeerId();
const discoveryPromise = new Promise((resolve) => {
waku.libp2p.addEventListener("peer:discovery", (event) => {
if (event.detail.id.equals(secondPeerId)) {
resolve(true);
}
});
});
// TODO(weboko): investigate why peer-exchange discovery is not working https://github.com/waku-org/js-waku/issues/2446
await waku.libp2p.peerStore.save(secondPeerId, {
multiaddrs: [maddrs[1]]
});
await discoveryPromise;
await delay(500);
expect(
waku.libp2p.getConnections(),
"waku has two connections"
).to.have.length(2);
});
});

View File

@ -1,341 +0,0 @@
import { generateKeyPair } from "@libp2p/crypto/keys";
import type { PeerId, PeerInfo } from "@libp2p/interface";
import { TypedEventEmitter } from "@libp2p/interface";
import { peerIdFromPrivateKey } from "@libp2p/peer-id";
import {
EConnectionStateEvents,
EPeersByDiscoveryEvents,
LightNode,
Tags
} from "@waku/interfaces";
import { createLightNode } from "@waku/sdk";
import { expect } from "chai";
import { afterEachCustom, beforeEachCustom, delay } from "../../src/index.js";
import { tearDownNodes } from "../../src/index.js";
const TEST_TIMEOUT = 20_000;
describe("Events", function () {
let waku: LightNode;
this.timeout(TEST_TIMEOUT);
beforeEachCustom(this, async () => {
waku = await createLightNode();
});
afterEachCustom(this, async () => {
await tearDownNodes([], waku);
});
describe("peer:discovery", () => {
it("should emit `peer:discovery:bootstrap` event when a peer is discovered", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerDiscoveryBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_DISCOVERY_BOOTSTRAP,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: peerIdBootstrap,
multiaddrs: []
}
})
);
expect(await peerDiscoveryBootstrap).to.eq(true);
});
it("should emit `peer:discovery:peer-exchange` event when a peer is discovered", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdPx = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdPx, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
const peerDiscoveryPeerExchange = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_DISCOVERY_PEER_EXCHANGE,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdPx.toString());
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: peerIdPx,
multiaddrs: []
}
})
);
expect(await peerDiscoveryPeerExchange).to.eq(true);
});
});
describe("peer:connect", () => {
it("should emit `peer:connected:bootstrap` event when a peer is connected", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
expect(await peerConnectedBootstrap).to.eq(true);
});
it("should emit `peer:connected:peer-exchange` event when a peer is connected", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdPx = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdPx, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedPeerExchange = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdPx.toString());
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx })
);
expect(await peerConnectedPeerExchange).to.eq(true);
});
});
describe(EConnectionStateEvents.CONNECTION_STATUS, function () {
let navigatorMock: any;
let originalNavigator: any;
before(() => {
originalNavigator = global.navigator;
});
this.beforeEach(() => {
navigatorMock = { onLine: true };
Object.defineProperty(globalThis, "navigator", {
value: navigatorMock,
configurable: true,
writable: false
});
const eventEmmitter = new TypedEventEmitter();
globalThis.addEventListener =
eventEmmitter.addEventListener.bind(eventEmmitter);
globalThis.removeEventListener =
eventEmmitter.removeEventListener.bind(eventEmmitter);
globalThis.dispatchEvent =
eventEmmitter.dispatchEvent.bind(eventEmmitter);
});
this.afterEach(() => {
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
configurable: true,
writable: false
});
// @ts-expect-error: resetting set value
globalThis.addEventListener = undefined;
// @ts-expect-error: resetting set value
globalThis.removeEventListener = undefined;
// @ts-expect-error: resetting set value
globalThis.dispatchEvent = undefined;
});
it(`should emit events and trasition isConnected state when has peers or no peers`, async function () {
const privateKey1 = await generateKeyPair("secp256k1");
const privateKey2 = await generateKeyPair("secp256k1");
const peerIdPx = peerIdFromPrivateKey(privateKey1);
const peerIdPx2 = peerIdFromPrivateKey(privateKey2);
await waku.libp2p.peerStore.save(peerIdPx, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
await waku.libp2p.peerStore.save(peerIdPx2, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
let eventCount = 0;
const connectedStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
eventCount++;
resolve(status);
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx })
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx2 })
);
await delay(100);
expect(waku.isConnected()).to.be.true;
expect(await connectedStatus).to.eq(true);
expect(eventCount).to.be.eq(1);
const disconnectedStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
resolve(status);
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:disconnect", { detail: peerIdPx })
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:disconnect", { detail: peerIdPx2 })
);
expect(waku.isConnected()).to.be.false;
expect(await disconnectedStatus).to.eq(false);
expect(eventCount).to.be.eq(2);
});
it("should be online or offline if network state changed", async function () {
// have to recreate js-waku for it to pick up new globalThis
waku = await createLightNode();
const privateKey = await generateKeyPair("secp256k1");
const peerIdPx = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdPx, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
let eventCount = 0;
const connectedStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
eventCount++;
resolve(status);
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx })
);
await delay(100);
expect(waku.isConnected()).to.be.true;
expect(await connectedStatus).to.eq(true);
expect(eventCount).to.be.eq(1);
const disconnectedStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
resolve(status);
}
);
});
navigatorMock.onLine = false;
globalThis.dispatchEvent(new CustomEvent("offline"));
await delay(100);
expect(waku.isConnected()).to.be.false;
expect(await disconnectedStatus).to.eq(false);
expect(eventCount).to.be.eq(2);
const connectionRecoveredStatus = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EConnectionStateEvents.CONNECTION_STATUS,
({ detail: status }) => {
resolve(status);
}
);
});
navigatorMock.onLine = true;
globalThis.dispatchEvent(new CustomEvent("online"));
await delay(100);
expect(waku.isConnected()).to.be.true;
expect(await connectionRecoveredStatus).to.eq(true);
expect(eventCount).to.be.eq(3);
});
});
});

View File

@ -1,332 +0,0 @@
import { generateKeyPair } from "@libp2p/crypto/keys";
import type { PeerId } from "@libp2p/interface";
import { peerIdFromPrivateKey } from "@libp2p/peer-id";
import {
EPeersByDiscoveryEvents,
LightNode,
PeersByDiscoveryResult,
Tags
} from "@waku/interfaces";
import { createLightNode } from "@waku/sdk";
import { expect } from "chai";
import { afterEachCustom, beforeEachCustom, delay } from "../../src/index.js";
import { tearDownNodes } from "../../src/index.js";
const TEST_TIMEOUT = 20_000;
describe("Public methods", function () {
let waku: LightNode;
this.timeout(TEST_TIMEOUT);
beforeEachCustom(this, async () => {
waku = await createLightNode();
});
afterEachCustom(this, async () => {
await tearDownNodes([], waku);
});
it("addEventListener with correct event", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
expect(await peerConnectedBootstrap).to.eq(true);
});
it("addEventListener with wrong event", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
// setting PEER_CONNECT_PEER_EXCHANGE while the tag is BOOTSTRAP
EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
const timeoutPromise = new Promise<boolean>((resolve) =>
setTimeout(() => resolve(false), TEST_TIMEOUT - 100)
);
const result = await Promise.race([peerConnectedBootstrap, timeoutPromise]);
// If the timeout promise resolves first, the result will be false, and we expect it to be false (test passes)
// If the peerConnectedBootstrap resolves first, we expect its result to be true (which will now make the test fail if it's not true)
expect(result).to.eq(false);
});
it("removeEventListener with correct event", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
let wasCalled = false;
const eventListener = (event: CustomEvent): void => {
if (event.detail.toString() === peerIdBootstrap.toString()) {
wasCalled = true;
}
};
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
eventListener
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
await delay(200);
expect(wasCalled).to.eq(true);
wasCalled = false; // resetting flag back to false and remove the listener
waku.connectionManager.removeEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
eventListener
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
await delay(200);
expect(wasCalled).to.eq(false);
});
it("removeEventListener with wrong event", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
let wasCalled = false;
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
if (receivedPeerId.toString() === peerIdBootstrap.toString()) {
wasCalled = true;
}
}
);
waku.connectionManager.removeEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE,
({ detail: receivedPeerId }) => {
if (receivedPeerId.toString() === peerIdBootstrap.toString()) {
wasCalled = true;
}
}
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
await delay(200);
expect(wasCalled).to.eq(true);
});
it("getPeersByDiscovery", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
const peers_before = await waku.connectionManager.getPeersByDiscovery();
expect(peers_before.DISCOVERED[Tags.BOOTSTRAP]).to.deep.eq([]);
const ttl = 1200000;
const tag_value = 50;
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: tag_value,
ttl: ttl
}
}
});
const currentTime = Date.now(); // Get the current time at the point peer connect
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
const peers_after = <PeersByDiscoveryResult>(
await waku.connectionManager.getPeersByDiscovery()
);
const bootstrap_peer = peers_after.DISCOVERED[Tags.BOOTSTRAP];
expect(bootstrap_peer).to.not.deep.eq([]);
expect(bootstrap_peer[0].id.toString()).to.eq(peerIdBootstrap.toString());
expect(bootstrap_peer[0].tags.has("bootstrap")).to.be.true;
expect(bootstrap_peer[0].tags.get("bootstrap")!.value).to.equal(tag_value);
// Assert that the expiry is within the expected range, considering TTL
// Note: We allow a small margin for the execution time of the code
const marginOfError = 1000; // 1 second in milliseconds, adjust as needed
const expiry = (bootstrap_peer[0].tags.get("bootstrap") as any).expiry;
expect(Number(expiry)).to.be.closeTo(currentTime + ttl, marginOfError);
});
it("listenerCount", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
receivedPeerId.toString() === peerIdBootstrap.toString();
}
);
expect(
waku.connectionManager.listenerCount(
EPeersByDiscoveryEvents.PEER_DISCOVERY_BOOTSTRAP
)
).to.eq(0);
expect(
waku.connectionManager.listenerCount(
EPeersByDiscoveryEvents.PEER_DISCOVERY_PEER_EXCHANGE
)
).to.eq(0);
expect(
waku.connectionManager.listenerCount(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP
)
).to.eq(1);
expect(
waku.connectionManager.listenerCount(
EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE
)
).to.eq(0);
});
it("dispatchEvent via connectionManager", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.connectionManager.dispatchEvent(
new CustomEvent<PeerId>(EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP, {
detail: peerIdBootstrap
})
);
expect(await peerConnectedBootstrap).to.eq(true);
});
it("safeDispatchEvent", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.connectionManager.safeDispatchEvent(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
{ detail: peerIdBootstrap }
);
expect(await peerConnectedBootstrap).to.eq(true);
});
it("stop", async function () {
const privateKey = await generateKeyPair("secp256k1");
const peerIdBootstrap = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdBootstrap, {
tags: {
[Tags.BOOTSTRAP]: {
value: 50,
ttl: 1200000
}
}
});
const peerConnectedBootstrap = new Promise<boolean>((resolve) => {
waku.connectionManager.addEventListener(
EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP,
({ detail: receivedPeerId }) => {
resolve(receivedPeerId.toString() === peerIdBootstrap.toString());
}
);
});
waku.connectionManager.stop();
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdBootstrap })
);
const timeoutPromise = new Promise<boolean>((resolve) =>
setTimeout(() => resolve(false), TEST_TIMEOUT - 100)
);
const result = await Promise.race([peerConnectedBootstrap, timeoutPromise]);
// If the timeout promise resolves first, the result will be false, and we expect it to be false (test passes)
// If the peerConnectedBootstrap resolves first, we expect its result to be true (which will now make the test fail if it's not true)
expect(result).to.eq(false);
});
});

View File

@ -0,0 +1,358 @@
import { generateKeyPair } from "@libp2p/crypto/keys";
import type { PeerId } from "@libp2p/interface";
import { TypedEventEmitter } from "@libp2p/interface";
import { peerIdFromPrivateKey } from "@libp2p/peer-id";
import { Multiaddr } from "@multiformats/multiaddr";
import { LightNode, Protocols, Tags } from "@waku/interfaces";
import { createRelayNode } from "@waku/relay";
import { createLightNode } from "@waku/sdk";
import { expect } from "chai";
import {
afterEachCustom,
beforeEachCustom,
DefaultTestShardInfo,
delay,
NOISE_KEY_1
} from "../../src/index.js";
import {
makeLogFileName,
ServiceNode,
tearDownNodes
} from "../../src/index.js";
const TEST_TIMEOUT = 30_000;
describe("Connection state", function () {
this.timeout(TEST_TIMEOUT);
let waku: LightNode;
let nwaku1: ServiceNode;
let nwaku2: ServiceNode;
let nwaku1PeerId: Multiaddr;
let nwaku2PeerId: Multiaddr;
let navigatorMock: any;
let originalNavigator: any;
beforeEachCustom(this, async () => {
waku = await createLightNode({ networkConfig: DefaultTestShardInfo });
nwaku1 = new ServiceNode(makeLogFileName(this.ctx) + "1");
nwaku2 = new ServiceNode(makeLogFileName(this.ctx) + "2");
await nwaku1.start({ filter: true });
await nwaku2.start({ filter: true });
nwaku1PeerId = await nwaku1.getMultiaddrWithId();
nwaku2PeerId = await nwaku2.getMultiaddrWithId();
navigatorMock = { onLine: true };
Object.defineProperty(globalThis, "navigator", {
value: navigatorMock,
configurable: true,
writable: false
});
});
afterEachCustom(this, async () => {
await tearDownNodes([nwaku1, nwaku2], waku);
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
configurable: true,
writable: false
});
});
it("should emit `waku:online` event only when first peer is connected", async function () {
let eventCount = 0;
const connectionStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
eventCount++;
resolve(status);
});
});
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await delay(400);
expect(await connectionStatus).to.eq(true);
expect(eventCount).to.be.eq(1);
await waku.dial(nwaku2PeerId, [Protocols.Filter]);
await delay(400);
expect(eventCount).to.be.eq(1);
});
it("should emit `waku:offline` event only when all peers disconnect", async function () {
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await waku.dial(nwaku2PeerId, [Protocols.Filter]);
let eventCount = 0;
const connectionStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
eventCount++;
resolve(status);
});
});
await nwaku1.stop();
await delay(400);
expect(eventCount).to.be.eq(0);
await nwaku2.stop();
expect(await connectionStatus).to.eq(false);
expect(eventCount).to.be.eq(1);
});
it("`waku:online` between 2 js-waku relay nodes", async function () {
const waku1 = await createRelayNode({
staticNoiseKey: NOISE_KEY_1,
networkConfig: DefaultTestShardInfo
});
const waku2 = await createRelayNode({
libp2p: { addresses: { listen: ["/ip4/0.0.0.0/tcp/0/ws"] } },
networkConfig: DefaultTestShardInfo
});
let eventCount1 = 0;
const connectionStatus1 = new Promise<boolean>((resolve) => {
waku1.events.addEventListener("waku:connection", ({ detail: status }) => {
eventCount1++;
resolve(status);
});
});
let eventCount2 = 0;
const connectionStatus2 = new Promise<boolean>((resolve) => {
waku2.events.addEventListener("waku:connection", ({ detail: status }) => {
eventCount2++;
resolve(status);
});
});
await waku1.libp2p.peerStore.merge(waku2.peerId, {
multiaddrs: waku2.libp2p.getMultiaddrs()
});
await waku1.dial(waku2.peerId);
expect(await connectionStatus1).to.eq(true);
expect(await connectionStatus2).to.eq(true);
expect(eventCount1).to.be.eq(1);
expect(eventCount2).to.be.eq(1);
});
it("isConnected should return true after first peer connects", async function () {
expect(waku.isConnected()).to.be.false;
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await delay(400);
expect(waku.isConnected()).to.be.true;
});
it("isConnected should return false after all peers disconnect", async function () {
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await waku.dial(nwaku2PeerId, [Protocols.Filter]);
await delay(250);
expect(waku.isConnected()).to.be.true;
await waku.libp2p.hangUp(nwaku1PeerId);
expect(waku.isConnected()).to.be.true;
await waku.libp2p.hangUp(nwaku2PeerId);
expect(waku.isConnected()).to.be.false;
});
it("isConnected return false after peer stops", async function () {
expect(waku.isConnected()).to.be.false;
await waku.dial(nwaku1PeerId, [Protocols.Filter]);
await delay(400);
expect(waku.isConnected()).to.be.true;
await nwaku1.stop();
await delay(400);
expect(waku.isConnected()).to.be.false;
});
it("isConnected between 2 js-waku relay nodes", async function () {
const waku1 = await createRelayNode({
staticNoiseKey: NOISE_KEY_1
});
const waku2 = await createRelayNode({
libp2p: { addresses: { listen: ["/ip4/0.0.0.0/tcp/0/ws"] } }
});
await waku1.libp2p.peerStore.merge(waku2.libp2p.peerId, {
multiaddrs: waku2.libp2p.getMultiaddrs()
});
await Promise.all([waku1.dial(waku2.libp2p.peerId)]);
await delay(400);
expect(waku1.isConnected()).to.be.true;
expect(waku2.isConnected()).to.be.true;
});
});
describe("waku:connection", function () {
let navigatorMock: any;
let originalNavigator: any;
let waku: LightNode;
this.timeout(TEST_TIMEOUT);
beforeEachCustom(this, async () => {
waku = await createLightNode();
originalNavigator = global.navigator;
navigatorMock = { onLine: true };
Object.defineProperty(globalThis, "navigator", {
value: navigatorMock,
configurable: true,
writable: false
});
const eventEmmitter = new TypedEventEmitter();
globalThis.addEventListener =
eventEmmitter.addEventListener.bind(eventEmmitter);
globalThis.removeEventListener =
eventEmmitter.removeEventListener.bind(eventEmmitter);
globalThis.dispatchEvent = eventEmmitter.dispatchEvent.bind(eventEmmitter);
});
afterEachCustom(this, async () => {
await tearDownNodes([], waku);
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
configurable: true,
writable: false
});
// @ts-expect-error: resetting set value
globalThis.addEventListener = undefined;
// @ts-expect-error: resetting set value
globalThis.removeEventListener = undefined;
// @ts-expect-error: resetting set value
globalThis.dispatchEvent = undefined;
});
it(`should emit events and trasition isConnected state when has peers or no peers`, async function () {
const privateKey1 = await generateKeyPair("secp256k1");
const privateKey2 = await generateKeyPair("secp256k1");
const peerIdPx = peerIdFromPrivateKey(privateKey1);
const peerIdPx2 = peerIdFromPrivateKey(privateKey2);
await waku.libp2p.peerStore.save(peerIdPx, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
await waku.libp2p.peerStore.save(peerIdPx2, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
let eventCount = 0;
const connectedStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
eventCount++;
resolve(status);
});
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx })
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx2 })
);
await delay(100);
expect(waku.isConnected()).to.be.true;
expect(await connectedStatus).to.eq(true);
expect(eventCount).to.be.eq(1);
const disconnectedStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
resolve(status);
});
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:disconnect", { detail: peerIdPx })
);
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:disconnect", { detail: peerIdPx2 })
);
expect(waku.isConnected()).to.be.false;
expect(await disconnectedStatus).to.eq(false);
expect(eventCount).to.be.eq(2);
});
it("should be online or offline if network state changed", async function () {
// have to recreate js-waku for it to pick up new globalThis
waku = await createLightNode();
const privateKey = await generateKeyPair("secp256k1");
const peerIdPx = peerIdFromPrivateKey(privateKey);
await waku.libp2p.peerStore.save(peerIdPx, {
tags: {
[Tags.PEER_EXCHANGE]: {
value: 50,
ttl: 1200000
}
}
});
let eventCount = 0;
const connectedStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
eventCount++;
resolve(status);
});
});
waku.libp2p.dispatchEvent(
new CustomEvent<PeerId>("peer:connect", { detail: peerIdPx })
);
await delay(100);
expect(waku.isConnected()).to.be.true;
expect(await connectedStatus).to.eq(true);
expect(eventCount).to.be.eq(1);
const disconnectedStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
resolve(status);
});
});
navigatorMock.onLine = false;
globalThis.dispatchEvent(new CustomEvent("offline"));
await delay(100);
expect(waku.isConnected()).to.be.false;
expect(await disconnectedStatus).to.eq(false);
expect(eventCount).to.be.eq(2);
const connectionRecoveredStatus = new Promise<boolean>((resolve) => {
waku.events.addEventListener("waku:connection", ({ detail: status }) => {
resolve(status);
});
});
navigatorMock.onLine = true;
globalThis.dispatchEvent(new CustomEvent("online"));
await delay(100);
expect(waku.isConnected()).to.be.true;
expect(await connectionRecoveredStatus).to.eq(true);
expect(eventCount).to.be.eq(3);
});
});

View File

@ -0,0 +1,6 @@
export const TestContentTopic = "/test/1/waku-light-push/utf8";
export const ClusterId = 3;
export const TestShardInfo = {
contentTopics: [TestContentTopic],
clusterId: ClusterId
};

View File

@ -300,16 +300,21 @@ const runTests = (strictCheckNodes: boolean): void => {
TestShardInfo,
{
lightpush: true,
filter: true
filter: true,
peerExchange: true
},
false
);
callback = serviceNodes.messageCollector.callback;
let cnt = 0;
const peerConnectEvent = new Promise((resolve, reject) => {
waku.libp2p.addEventListener("peer:connect", (e) => {
resolve(e);
cnt += 1;
if (cnt === 2) {
resolve(e);
}
});
setTimeout(() => reject, 1000);
});

View File

@ -1,100 +0,0 @@
import type { PeerId } from "@libp2p/interface";
import type { PeerInfo } from "@libp2p/interface";
import { multiaddr } from "@multiformats/multiaddr";
import type { Multiaddr } from "@multiformats/multiaddr";
import type { IWaku } from "@waku/interfaces";
import { createLightNode } from "@waku/sdk";
import { expect } from "chai";
import Sinon, { SinonSpy, SinonStub } from "sinon";
import {
afterEachCustom,
beforeEachCustom,
delay,
makeLogFileName,
ServiceNode,
tearDownNodes
} from "../src/index.js";
describe("multiaddr: dialing", function () {
let waku: IWaku;
let nwaku: ServiceNode;
let dialPeerSpy: SinonSpy;
let isPeerOnSameShard: SinonStub;
afterEachCustom(this, async () => {
await tearDownNodes(nwaku, waku);
});
it("can dial TLS multiaddrs", async function () {
this.timeout(20_000);
let tlsWorks = true;
waku = await createLightNode();
await waku.start();
try {
// dummy multiaddr, doesn't have to be valid
await waku.dial(multiaddr(`/ip4/127.0.0.1/tcp/30303/tls/ws`));
} catch (error) {
if (error instanceof Error) {
// if the error is of tls unsupported, the test should fail
// for any other dial errors, the test should pass
if (error.message === "Unsupported protocol tls") {
tlsWorks = false;
}
}
}
expect(tlsWorks).to.eq(true);
});
describe("does not attempt the same peer discovered multiple times more than once", function () {
const PEER_DISCOVERY_COUNT = 3;
let peerId: PeerId;
let multiaddr: Multiaddr;
beforeEachCustom(this, async () => {
nwaku = new ServiceNode(makeLogFileName(this.ctx));
await nwaku.start();
waku = await createLightNode();
peerId = await nwaku.getPeerId();
multiaddr = await nwaku.getMultiaddrWithId();
isPeerOnSameShard = Sinon.stub(
waku.connectionManager as any,
"isPeerOnSameShard"
);
isPeerOnSameShard.resolves(true);
dialPeerSpy = Sinon.spy(waku.connectionManager as any, "dialPeer");
});
afterEachCustom(this, async () => {
dialPeerSpy.restore();
});
it("through manual discovery", async function () {
this.timeout(20_000);
const discoverPeer = (): void => {
waku.libp2p.dispatchEvent(
new CustomEvent<PeerInfo>("peer:discovery", {
detail: {
id: peerId,
multiaddrs: [multiaddr]
}
})
);
};
for (let i = 0; i < PEER_DISCOVERY_COUNT; i++) {
discoverPeer();
await delay(100);
}
expect(dialPeerSpy.callCount).to.eq(1);
});
});
});

View File

@ -1,7 +1,7 @@
import { bootstrap } from "@libp2p/bootstrap";
import type { PeerId } from "@libp2p/interface";
import { wakuPeerExchangeDiscovery } from "@waku/discovery";
import type { LightNode, PeersByDiscoveryResult } from "@waku/interfaces";
import type { LightNode } from "@waku/interfaces";
import { createLightNode, Tags } from "@waku/sdk";
import { Logger } from "@waku/utils";
import { expect } from "chai";
@ -25,7 +25,6 @@ describe("Peer Exchange", function () {
let nwaku2: ServiceNode;
let nwaku3: ServiceNode;
let dialPeerSpy: SinonSpy;
let nwaku1PeerId: PeerId;
beforeEachCustom(this, async () => {
nwaku1 = new ServiceNode(makeLogFileName(this.ctx) + "1");
@ -45,14 +44,13 @@ describe("Peer Exchange", function () {
discv5BootstrapNode: (await nwaku1.info()).enrUri,
relay: true
});
nwaku1PeerId = await nwaku1.getPeerId();
});
afterEachCustom(this, async () => {
await tearDownNodes([nwaku1, nwaku2, nwaku3], waku);
});
it("getPeersByDiscovery", async function () {
it("peer exchange sets tag", async function () {
waku = await createLightNode({
networkConfig: DefaultTestShardInfo,
libp2p: {
@ -63,8 +61,10 @@ describe("Peer Exchange", function () {
}
});
await waku.start();
dialPeerSpy = Sinon.spy((waku as any).connectionManager, "dialPeer");
dialPeerSpy = Sinon.spy((waku as any).libp2p, "dial");
const pxPeersDiscovered = new Set<PeerId>();
await new Promise<void>((resolve) => {
waku.libp2p.addEventListener("peer:discovery", (evt) => {
return void (async () => {
@ -80,23 +80,9 @@ describe("Peer Exchange", function () {
})();
});
});
expect(dialPeerSpy.callCount).to.equal(1);
const peers_after = <PeersByDiscoveryResult>(
await waku.connectionManager.getPeersByDiscovery()
);
const discovered_peer_exchange = peers_after.DISCOVERED[Tags.PEER_EXCHANGE];
const discovered_bootstram = peers_after.DISCOVERED[Tags.BOOTSTRAP];
const connected_peer_exchange = peers_after.CONNECTED[Tags.PEER_EXCHANGE];
const connected_bootstram = peers_after.CONNECTED[Tags.BOOTSTRAP];
expect(discovered_peer_exchange.length).to.eq(1);
expect(discovered_peer_exchange[0].id.toString()).to.eq(
nwaku1PeerId.toString()
);
expect(discovered_peer_exchange[0].tags.has("peer-exchange")).to.be.true;
expect(discovered_bootstram.length).to.eq(1);
expect(connected_peer_exchange.length).to.eq(0);
expect(connected_bootstram.length).to.eq(1);
expect(dialPeerSpy.callCount).to.equal(1);
expect(pxPeersDiscovered.size).to.equal(1);
});
// will be skipped until https://github.com/waku-org/js-waku/issues/1860 is fixed
@ -208,7 +194,7 @@ describe("Peer Exchange", function () {
}
});
await waku.start();
dialPeerSpy = Sinon.spy((waku as any).connectionManager, "dialPeer");
dialPeerSpy = Sinon.spy((waku as any).libp2p, "dial");
const pxPeersDiscovered = new Set<PeerId>();
await new Promise<void>((resolve) => {

View File

@ -93,7 +93,7 @@ describe("Static Sharding: Peer Management", function () {
await waku.start();
dialPeerSpy = Sinon.spy((waku as any).connectionManager, "dialPeer");
dialPeerSpy = Sinon.spy((waku as any).libp2p, "dial");
const pxPeersDiscovered = new Set<PeerId>();
@ -164,7 +164,7 @@ describe("Static Sharding: Peer Management", function () {
}
});
dialPeerSpy = Sinon.spy((waku as any).connectionManager, "dialPeer");
dialPeerSpy = Sinon.spy((waku as any).libp2p, "dial");
await waku.start();
@ -270,7 +270,7 @@ describe("Autosharding: Peer Management", function () {
await waku.start();
dialPeerSpy = Sinon.spy((waku as any).connectionManager, "dialPeer");
dialPeerSpy = Sinon.spy((waku as any).libp2p, "dial");
const pxPeersDiscovered = new Set<PeerId>();
@ -346,7 +346,7 @@ describe("Autosharding: Peer Management", function () {
}
});
dialPeerSpy = Sinon.spy((waku as any).connectionManager, "dialPeer");
dialPeerSpy = Sinon.spy((waku as any).libp2p, "dial");
await waku.start();

View File

@ -46,7 +46,7 @@ describe("Waku Store, error handling", function () {
if (
!(err instanceof Error) ||
!err.message.includes(
`Pubsub topic ${wrongDecoder.pubsubTopic} has not been configured on this instance. Configured topics are: ${TestDecoder.pubsubTopic}`
`Pubsub topic ${wrongDecoder.pubsubTopic} has not been configured on this instance.`
)
) {
throw err;
@ -110,7 +110,7 @@ describe("Waku Store, error handling", function () {
if (
!(err instanceof Error) ||
!err.message.includes(
`Pubsub topic ${wrongDecoder.pubsubTopic} has not been configured on this instance. Configured topics are: ${TestDecoder.pubsubTopic}`
`Pubsub topic ${wrongDecoder.pubsubTopic} has not been configured on this instance.`
)
) {
throw err;
@ -168,7 +168,7 @@ describe("Waku Store, error handling", function () {
if (
!(err instanceof Error) ||
!err.message.includes(
`Pubsub topic ${wrongDecoder.pubsubTopic} has not been configured on this instance. Configured topics are: ${TestDecoder.pubsubTopic}`
`Pubsub topic ${wrongDecoder.pubsubTopic} has not been configured on this instance.`
)
) {
throw err;

View File

@ -6,7 +6,6 @@ import {
contentTopicToPubsubTopic,
contentTopicToShardIndex,
determinePubsubTopic,
ensurePubsubTopicIsConfigured,
ensureShardingConfigured,
ensureValidContentTopic,
pubsubTopicToSingleShardInfo,
@ -357,32 +356,6 @@ describe("pubsubTopicToSingleShardInfo with various invalid formats", () => {
});
});
describe("ensurePubsubTopicIsConfigured", () => {
it("should not throw an error for a single configured topic", () => {
const topic = "/waku/2/rs/1/2";
const configuredTopics = [topic];
expect(() =>
ensurePubsubTopicIsConfigured(topic, configuredTopics)
).not.to.throw();
});
it("should not throw an error when the topic is within a list of configured topics", () => {
const topic = "/waku/2/rs/1/2";
const configuredTopics = ["/waku/2/rs/1/1", topic, "/waku/2/rs/1/3"];
expect(() =>
ensurePubsubTopicIsConfigured(topic, configuredTopics)
).not.to.throw();
});
it("should throw an error for an unconfigured topic", () => {
const topic = "/waku/2/rs/1/2";
const configuredTopics = ["/waku/2/rs/1/3"];
expect(() =>
ensurePubsubTopicIsConfigured(topic, configuredTopics)
).to.throw();
});
});
describe("determinePubsubTopic", () => {
const contentTopic = "/app/46/sometopic/someencoding";
it("should return the pubsub topic directly if a string is provided", () => {

View File

@ -67,6 +67,9 @@ export const singleShardInfosToShardInfo = (
};
};
/**
* @deprecated will be removed, use cluster and shard comparison directly
*/
export const shardInfoToPubsubTopics = (
shardInfo: Partial<NetworkConfig>
): PubsubTopic[] => {
@ -103,6 +106,9 @@ export const shardInfoToPubsubTopics = (
}
};
/**
* @deprecated will be removed
*/
export const pubsubTopicToSingleShardInfo = (
pubsubTopics: PubsubTopic
): SingleShardInfo => {
@ -161,19 +167,6 @@ export const pubsubTopicsToShardInfo = (
};
};
//TODO: move part of BaseProtocol instead of utils
// return `ProtocolError.TOPIC_NOT_CONFIGURED` instead of throwing
export function ensurePubsubTopicIsConfigured(
pubsubTopic: PubsubTopic,
configuredTopics: PubsubTopic[]
): void {
if (!configuredTopics.includes(pubsubTopic)) {
throw new Error(
`Pubsub topic ${pubsubTopic} has not been configured on this instance. Configured topics are: ${configuredTopics}. Please update your configuration by passing in the topic during Waku node instantiation.`
);
}
}
interface ContentTopic {
generation: number;
application: string;