From c7682ea67c54d2c26a68ce96208003fb1ffc915c Mon Sep 17 00:00:00 2001 From: Sasha <118575614+weboko@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:23:14 +0200 Subject: [PATCH] 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 --- .../connection_limiter.spec.ts | 1068 +++++++++++++++++ .../connection_manager/connection_limiter.ts | 201 ++++ .../connection_manager.spec.ts | 606 ++++++++++ .../connection_manager/connection_manager.ts | 793 ++---------- .../src/lib/connection_manager/dialer.spec.ts | 411 +++++++ .../core/src/lib/connection_manager/dialer.ts | 139 +++ .../discovery_dialer.spec.ts | 304 +++++ .../connection_manager/discovery_dialer.ts | 106 ++ .../keep_alive_manager.spec.ts | 583 +++++++++ .../connection_manager/keep_alive_manager.ts | 257 ++-- .../network_monitor.spec.ts | 431 +++++++ .../lib/connection_manager/network_monitor.ts | 112 ++ .../connection_manager/shard_reader.spec.ts | 327 +++++ .../lib/connection_manager/shard_reader.ts | 134 +++ .../src/lib/connection_manager/utils.spec.ts | 46 + .../core/src/lib/connection_manager/utils.ts | 28 +- packages/core/src/lib/filter/filter.ts | 1 - .../core/src/lib/light_push/light_push.ts | 6 +- packages/discovery/src/dns/constants.ts | 4 +- .../discovery/src/local-peer-cache/index.ts | 4 +- packages/discovery/src/peer-exchange/index.ts | 3 +- .../waku_peer_exchange_discovery.ts | 2 +- packages/interfaces/src/connection_manager.ts | 164 ++- packages/interfaces/src/message.ts | 1 + packages/interfaces/src/waku.ts | 29 +- packages/relay/src/create.ts | 9 +- packages/sdk/src/filter/filter.spec.ts | 6 +- packages/sdk/src/filter/filter.ts | 3 +- .../sdk/src/light_push/light_push.spec.ts | 5 +- packages/sdk/src/light_push/light_push.ts | 9 +- .../sdk/src/peer_manager/peer_manager.spec.ts | 2 +- packages/sdk/src/peer_manager/peer_manager.ts | 11 +- packages/sdk/src/store/store.ts | 8 +- packages/sdk/src/waku/utils.spec.ts | 28 +- packages/sdk/src/waku/utils.ts | 9 - packages/sdk/src/waku/waku.ts | 43 +- packages/tests/src/lib/runNodes.ts | 4 +- packages/tests/src/utils/nodes.ts | 6 +- .../connection_limiter.spec.ts | 168 +++ .../connection_state.spec.ts | 195 --- .../tests/connection-mananger/dialing.spec.ts | 152 +++ .../tests/connection-mananger/dials.spec.ts | 221 ---- .../discovery_dialer.spec.ts | 110 ++ .../tests/connection-mananger/events.spec.ts | 341 ------ .../tests/connection-mananger/methods.spec.ts | 332 ----- .../network_monitor.spec.ts | 358 ++++++ .../tests/tests/connection-mananger/utils.ts | 6 + packages/tests/tests/filter/push.node.spec.ts | 9 +- packages/tests/tests/multiaddr.node.spec.ts | 100 -- .../tests/tests/peer-exchange/index.spec.ts | 30 +- .../tests/sharding/peer_management.spec.ts | 8 +- .../tests/store/error_handling.node.spec.ts | 6 +- .../utils/src/common/sharding/index.spec.ts | 27 - packages/utils/src/common/sharding/index.ts | 19 +- 54 files changed, 5799 insertions(+), 2186 deletions(-) create mode 100644 packages/core/src/lib/connection_manager/connection_limiter.spec.ts create mode 100644 packages/core/src/lib/connection_manager/connection_limiter.ts create mode 100644 packages/core/src/lib/connection_manager/connection_manager.spec.ts create mode 100644 packages/core/src/lib/connection_manager/dialer.spec.ts create mode 100644 packages/core/src/lib/connection_manager/dialer.ts create mode 100644 packages/core/src/lib/connection_manager/discovery_dialer.spec.ts create mode 100644 packages/core/src/lib/connection_manager/discovery_dialer.ts create mode 100644 packages/core/src/lib/connection_manager/keep_alive_manager.spec.ts create mode 100644 packages/core/src/lib/connection_manager/network_monitor.spec.ts create mode 100644 packages/core/src/lib/connection_manager/network_monitor.ts create mode 100644 packages/core/src/lib/connection_manager/shard_reader.spec.ts create mode 100644 packages/core/src/lib/connection_manager/shard_reader.ts create mode 100644 packages/core/src/lib/connection_manager/utils.spec.ts create mode 100644 packages/tests/tests/connection-mananger/connection_limiter.spec.ts delete mode 100644 packages/tests/tests/connection-mananger/connection_state.spec.ts create mode 100644 packages/tests/tests/connection-mananger/dialing.spec.ts delete mode 100644 packages/tests/tests/connection-mananger/dials.spec.ts create mode 100644 packages/tests/tests/connection-mananger/discovery_dialer.spec.ts delete mode 100644 packages/tests/tests/connection-mananger/events.spec.ts delete mode 100644 packages/tests/tests/connection-mananger/methods.spec.ts create mode 100644 packages/tests/tests/connection-mananger/network_monitor.spec.ts create mode 100644 packages/tests/tests/connection-mananger/utils.ts delete mode 100644 packages/tests/tests/multiaddr.node.spec.ts diff --git a/packages/core/src/lib/connection_manager/connection_limiter.spec.ts b/packages/core/src/lib/connection_manager/connection_limiter.spec.ts new file mode 100644 index 0000000000..724356ccf5 --- /dev/null +++ b/packages/core/src/lib/connection_manager/connection_limiter.spec.ts @@ -0,0 +1,1068 @@ +import { type Connection, type Peer, type PeerId } from "@libp2p/interface"; +import { IWakuEventEmitter, Tags } from "@waku/interfaces"; +import { expect } from "chai"; +import sinon from "sinon"; + +import { ConnectionLimiter } from "./connection_limiter.js"; +import { Dialer } from "./dialer.js"; +import { NetworkMonitor } from "./network_monitor.js"; + +describe("ConnectionLimiter", () => { + let libp2p: any; + let events: IWakuEventEmitter; + let dialer: sinon.SinonStubbedInstance; + let networkMonitor: sinon.SinonStubbedInstance; + let connectionLimiter: ConnectionLimiter; + let mockPeerId: PeerId; + + let mockConnection: Connection; + + let mockPeer: Peer; + let mockPeer2: Peer; + + const createMockPeerId = (id: string): PeerId => + ({ + toString: () => id, + equals: (other: PeerId) => other.toString() === id + }) as PeerId; + + const createMockPeer = (id: string, tags: string[] = []): Peer => + ({ + id: createMockPeerId(id), + tags: new Map(tags.map((tag) => [tag, { value: 0 }])), + addresses: [], + protocols: [], + metadata: new Map(), + toString: () => id + }) as unknown as Peer; + + const createMockConnection = ( + peerId: PeerId, + tags: string[] = [] + ): Connection => + ({ + remotePeer: peerId, + tags + }) as Connection; + + const defaultOptions = { + maxBootstrapPeers: 2, + pingKeepAlive: 300, + relayKeepAlive: 300 + }; + + beforeEach(() => { + mockPeerId = createMockPeerId("12D3KooWTest1"); + + mockPeer = createMockPeer("12D3KooWTest1", [Tags.BOOTSTRAP]); + mockPeer2 = createMockPeer("12D3KooWTest2", []); + mockConnection = createMockConnection(mockPeerId, [Tags.BOOTSTRAP]); + + libp2p = { + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + dial: sinon.stub().resolves(), + hangUp: sinon.stub().resolves(), + getConnections: sinon.stub().returns([]), + peerStore: { + all: sinon.stub().resolves([]), + get: sinon.stub().resolves(mockPeer) + } + }; + + events = { + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + dispatchEvent: sinon.stub() + } as any; + + dialer = { + start: sinon.stub(), + stop: sinon.stub(), + dial: sinon.stub().resolves() + } as unknown as sinon.SinonStubbedInstance; + + networkMonitor = { + start: sinon.stub(), + stop: sinon.stub(), + isBrowserConnected: sinon.stub().returns(true), + isConnected: sinon.stub().returns(true), + isP2PConnected: sinon.stub().returns(true) + } as unknown as sinon.SinonStubbedInstance; + }); + + afterEach(() => { + if (connectionLimiter) { + connectionLimiter.stop(); + } + sinon.restore(); + }); + + describe("constructor", () => { + it("should create ConnectionLimiter with required options", () => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + + expect(connectionLimiter).to.be.instanceOf(ConnectionLimiter); + }); + + it("should store libp2p and options references", () => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + + expect(connectionLimiter).to.have.property("libp2p"); + expect(connectionLimiter).to.have.property("options"); + }); + }); + + describe("start", () => { + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + }); + + it("should dial peers from store on start", async () => { + const dialPeersStub = sinon.stub( + connectionLimiter, + "dialPeersFromStore" as any + ); + + connectionLimiter.start(); + + expect(dialPeersStub.calledOnce).to.be.true; + }); + + it("should add event listeners for waku:connection, peer connect and disconnect", () => { + connectionLimiter.start(); + + expect((events.addEventListener as sinon.SinonStub).calledOnce).to.be + .true; + expect( + (events.addEventListener as sinon.SinonStub).calledWith( + "waku:connection", + sinon.match.func + ) + ).to.be.true; + + 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", () => { + connectionLimiter.start(); + connectionLimiter.start(); + + expect((events.addEventListener as sinon.SinonStub).callCount).to.equal( + 2 + ); + expect(libp2p.addEventListener.callCount).to.equal(4); + }); + }); + + describe("stop", () => { + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + }); + + it("should remove event listeners", () => { + connectionLimiter.stop(); + + expect((events.removeEventListener as sinon.SinonStub).calledOnce).to.be + .true; + expect( + (events.removeEventListener as sinon.SinonStub).calledWith( + "waku:connection", + sinon.match.func + ) + ).to.be.true; + + 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 be safe to call multiple times", () => { + connectionLimiter.stop(); + connectionLimiter.stop(); + + expect( + (events.removeEventListener as sinon.SinonStub).callCount + ).to.equal(2); + expect(libp2p.removeEventListener.callCount).to.equal(4); + }); + }); + + describe("onWakuConnectionEvent", () => { + let eventHandler: () => void; + + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + + const addEventListenerStub = events.addEventListener as sinon.SinonStub; + eventHandler = addEventListenerStub.getCall(0).args[1]; + }); + + it("should dial peers from store when browser is connected", () => { + const dialPeersStub = sinon.stub( + connectionLimiter, + "dialPeersFromStore" as any + ); + networkMonitor.isBrowserConnected.returns(true); + + eventHandler(); + + expect(dialPeersStub.calledOnce).to.be.true; + }); + + it("should not dial peers from store when browser is not connected", () => { + const dialPeersStub = sinon.stub( + connectionLimiter, + "dialPeersFromStore" as any + ); + networkMonitor.isBrowserConnected.returns(false); + + eventHandler(); + + expect(dialPeersStub.called).to.be.false; + }); + }); + + describe("onConnectedEvent", () => { + let eventHandler: (event: CustomEvent) => Promise; + + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + + const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub; + eventHandler = addEventListenerStub.getCall(0).args[1]; + }); + + it("should handle connection event", async () => { + const mockEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await eventHandler(mockEvent); + + expect(libp2p.peerStore.get.calledWith(mockPeerId)).to.be.true; + }); + + it("should get tags for the connected peer", async () => { + const mockEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await eventHandler(mockEvent); + + expect(libp2p.peerStore.get.calledWith(mockPeerId)).to.be.true; + }); + + it("should do nothing if peer is not a bootstrap peer", async () => { + const nonBootstrapPeer = createMockPeer("12D3KooWNonBootstrap", []); + libp2p.peerStore.get.resolves(nonBootstrapPeer); + + const mockEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await eventHandler(mockEvent); + + expect(libp2p.hangUp.called).to.be.false; + }); + + it("should not hang up bootstrap peer if under limit", async () => { + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + const connectedBootstrapPeer = createMockPeer( + "12D3KooWConnectedBootstrap", + [Tags.BOOTSTRAP] + ); + + libp2p.getConnections.returns([mockConnection]); + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get + .withArgs(mockConnection.remotePeer) + .resolves(connectedBootstrapPeer); + + const mockEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await eventHandler(mockEvent); + + expect(libp2p.hangUp.called).to.be.false; + }); + + it("should hang up bootstrap peer if over limit", async () => { + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer1 = createMockPeer("12D3KooWBootstrap1", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer2 = createMockPeer("12D3KooWBootstrap2", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer3 = createMockPeer("12D3KooWBootstrap3", [ + Tags.BOOTSTRAP + ]); + + const peerId1 = createMockPeerId("peer1"); + const peerId2 = createMockPeerId("peer2"); + const peerId3 = createMockPeerId("peer3"); + + const bootstrapConnections = [ + createMockConnection(peerId1, [Tags.BOOTSTRAP]), + createMockConnection(peerId2, [Tags.BOOTSTRAP]), + createMockConnection(peerId3, [Tags.BOOTSTRAP]) + ]; + + libp2p.getConnections.returns(bootstrapConnections); + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get.withArgs(peerId1).resolves(bootstrapPeer1); + libp2p.peerStore.get.withArgs(peerId2).resolves(bootstrapPeer2); + libp2p.peerStore.get.withArgs(peerId3).resolves(bootstrapPeer3); + + const mockEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await eventHandler(mockEvent); + + expect(libp2p.hangUp.calledWith(mockPeerId)).to.be.true; + }); + + it("should handle errors in getTagsForPeer gracefully", async () => { + libp2p.peerStore.get.rejects(new Error("Peer not found")); + + const mockEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await eventHandler(mockEvent); + + expect(libp2p.hangUp.called).to.be.false; + }); + }); + + describe("onDisconnectedEvent", () => { + let eventHandler: () => Promise; + + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + + const addEventListenerStub = libp2p.addEventListener as sinon.SinonStub; + eventHandler = addEventListenerStub.getCall(1).args[1]; + }); + + it("should dial peers from store when no connections remain", async () => { + libp2p.getConnections.returns([]); + const dialPeersStub = sinon.stub( + connectionLimiter, + "dialPeersFromStore" as any + ); + + await eventHandler(); + + expect(dialPeersStub.calledOnce).to.be.true; + }); + + it("should do nothing when connections still exist", async () => { + libp2p.getConnections.returns([mockConnection]); + const dialPeersStub = sinon.stub( + connectionLimiter, + "dialPeersFromStore" as any + ); + + await eventHandler(); + + expect(dialPeersStub.called).to.be.false; + }); + }); + + describe("dialPeersFromStore", () => { + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + }); + + it("should get all peers from store", async () => { + libp2p.peerStore.all.resolves([mockPeer, mockPeer2]); + libp2p.getConnections.returns([]); + + await (connectionLimiter as any).dialPeersFromStore(); + + expect(libp2p.peerStore.all.calledOnce).to.be.true; + }); + + it("should filter out already connected peers", async () => { + libp2p.peerStore.all.resolves([mockPeer, mockPeer2]); + libp2p.getConnections.returns([mockConnection]); + + await (connectionLimiter as any).dialPeersFromStore(); + + expect(dialer.dial.calledOnce).to.be.true; + expect(dialer.dial.calledWith(mockPeer2.id)).to.be.true; + expect(dialer.dial.calledWith(mockPeer.id)).to.be.false; + }); + + it("should dial all remaining peers", async () => { + libp2p.peerStore.all.resolves([mockPeer, mockPeer2]); + libp2p.getConnections.returns([]); + + await (connectionLimiter as any).dialPeersFromStore(); + + expect(dialer.dial.calledTwice).to.be.true; + expect(dialer.dial.calledWith(mockPeer.id)).to.be.true; + expect(dialer.dial.calledWith(mockPeer2.id)).to.be.true; + }); + + it("should handle dial errors gracefully", async () => { + libp2p.peerStore.all.resolves([mockPeer]); + libp2p.getConnections.returns([]); + dialer.dial.rejects(new Error("Dial failed")); + + await (connectionLimiter as any).dialPeersFromStore(); + + expect(dialer.dial.calledOnce).to.be.true; + }); + + it("should handle case with no peers in store", async () => { + libp2p.peerStore.all.resolves([]); + libp2p.getConnections.returns([]); + + await (connectionLimiter as any).dialPeersFromStore(); + + expect(dialer.dial.called).to.be.false; + }); + + it("should handle case with all peers already connected", async () => { + libp2p.peerStore.all.resolves([mockPeer]); + libp2p.getConnections.returns([mockConnection]); + + await (connectionLimiter as any).dialPeersFromStore(); + + expect(dialer.dial.called).to.be.false; + }); + }); + + describe("getTagsForPeer", () => { + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + }); + + it("should return tags for existing peer", async () => { + const tags = await (connectionLimiter as any).getTagsForPeer(mockPeerId); + + expect(libp2p.peerStore.get.calledWith(mockPeerId)).to.be.true; + expect(tags).to.deep.equal([Tags.BOOTSTRAP]); + }); + + it("should return empty array for non-existent peer", async () => { + libp2p.peerStore.get.rejects(new Error("Peer not found")); + + const tags = await (connectionLimiter as any).getTagsForPeer(mockPeerId); + + expect(tags).to.deep.equal([]); + }); + + it("should handle peer store errors gracefully", async () => { + libp2p.peerStore.get.rejects(new Error("Database error")); + + const tags = await (connectionLimiter as any).getTagsForPeer(mockPeerId); + + expect(tags).to.deep.equal([]); + }); + + it("should convert tags map to array of keys", async () => { + const peerWithMultipleTags = createMockPeer("12D3KooWMultiTag", [ + Tags.BOOTSTRAP, + Tags.PEER_EXCHANGE + ]); + libp2p.peerStore.get.resolves(peerWithMultipleTags); + + const tags = await (connectionLimiter as any).getTagsForPeer(mockPeerId); + + expect(tags).to.include(Tags.BOOTSTRAP); + expect(tags).to.include(Tags.PEER_EXCHANGE); + }); + }); + + describe("getPeer", () => { + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + }); + + it("should return peer for existing peer", async () => { + const peer = await (connectionLimiter as any).getPeer(mockPeerId); + + expect(libp2p.peerStore.get.calledWith(mockPeerId)).to.be.true; + expect(peer).to.equal(mockPeer); + }); + + it("should return null for non-existent peer", async () => { + libp2p.peerStore.get.rejects(new Error("Peer not found")); + + const peer = await (connectionLimiter as any).getPeer(mockPeerId); + + expect(peer).to.be.null; + }); + + it("should handle peer store errors gracefully", async () => { + libp2p.peerStore.get.rejects(new Error("Database error")); + + const peer = await (connectionLimiter as any).getPeer(mockPeerId); + + expect(peer).to.be.null; + }); + }); + + describe("hasMoreThanMaxBootstrapConnections", () => { + beforeEach(() => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + }); + + it("should return false when no connections", async () => { + libp2p.getConnections.returns([]); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should return false when under bootstrap limit", async () => { + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + libp2p.getConnections.returns([mockConnection]); + libp2p.peerStore.get.resolves(bootstrapPeer); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should return false when at bootstrap limit", async () => { + const bootstrapPeer1 = createMockPeer("12D3KooWBootstrap1", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer2 = createMockPeer("12D3KooWBootstrap2", [ + Tags.BOOTSTRAP + ]); + const connection1 = createMockConnection(bootstrapPeer1.id, [ + Tags.BOOTSTRAP + ]); + const connection2 = createMockConnection(bootstrapPeer2.id, [ + Tags.BOOTSTRAP + ]); + + libp2p.getConnections.returns([connection1, connection2]); + libp2p.peerStore.get.withArgs(bootstrapPeer1.id).resolves(bootstrapPeer1); + libp2p.peerStore.get.withArgs(bootstrapPeer2.id).resolves(bootstrapPeer2); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should return true when over bootstrap limit", async () => { + const bootstrapPeer1 = createMockPeer("12D3KooWBootstrap1", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer2 = createMockPeer("12D3KooWBootstrap2", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer3 = createMockPeer("12D3KooWBootstrap3", [ + Tags.BOOTSTRAP + ]); + const connection1 = createMockConnection(bootstrapPeer1.id, [ + Tags.BOOTSTRAP + ]); + const connection2 = createMockConnection(bootstrapPeer2.id, [ + Tags.BOOTSTRAP + ]); + const connection3 = createMockConnection(bootstrapPeer3.id, [ + Tags.BOOTSTRAP + ]); + + libp2p.getConnections.returns([connection1, connection2, connection3]); + libp2p.peerStore.get.withArgs(bootstrapPeer1.id).resolves(bootstrapPeer1); + libp2p.peerStore.get.withArgs(bootstrapPeer2.id).resolves(bootstrapPeer2); + libp2p.peerStore.get.withArgs(bootstrapPeer3.id).resolves(bootstrapPeer3); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.true; + }); + + it("should return false when connections are non-bootstrap peers", async () => { + const nonBootstrapPeer1 = createMockPeer("12D3KooWNonBootstrap1", []); + const nonBootstrapPeer2 = createMockPeer("12D3KooWNonBootstrap2", []); + const connection1 = createMockConnection(nonBootstrapPeer1.id, []); + const connection2 = createMockConnection(nonBootstrapPeer2.id, []); + + libp2p.getConnections.returns([connection1, connection2]); + libp2p.peerStore.get + .withArgs(nonBootstrapPeer1.id) + .resolves(nonBootstrapPeer1); + libp2p.peerStore.get + .withArgs(nonBootstrapPeer2.id) + .resolves(nonBootstrapPeer2); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should handle mixed bootstrap and non-bootstrap peers", async () => { + const bootstrapPeer1 = createMockPeer("12D3KooWBootstrap1", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer2 = createMockPeer("12D3KooWBootstrap2", [ + Tags.BOOTSTRAP + ]); + const nonBootstrapPeer = createMockPeer("12D3KooWNonBootstrap", []); + const connection1 = createMockConnection(bootstrapPeer1.id, [ + Tags.BOOTSTRAP + ]); + const connection2 = createMockConnection(bootstrapPeer2.id, [ + Tags.BOOTSTRAP + ]); + const connection3 = createMockConnection(nonBootstrapPeer.id, []); + + libp2p.getConnections.returns([connection1, connection2, connection3]); + libp2p.peerStore.get.withArgs(bootstrapPeer1.id).resolves(bootstrapPeer1); + libp2p.peerStore.get.withArgs(bootstrapPeer2.id).resolves(bootstrapPeer2); + libp2p.peerStore.get + .withArgs(nonBootstrapPeer.id) + .resolves(nonBootstrapPeer); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should handle peer store errors gracefully", async () => { + libp2p.getConnections.returns([mockConnection]); + libp2p.peerStore.get.rejects(new Error("Peer store error")); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should handle null peers returned by getPeer", async () => { + const getPeerStub = sinon.stub(connectionLimiter, "getPeer" as any); + getPeerStub.resolves(null); + + libp2p.getConnections.returns([mockConnection]); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.false; + }); + + it("should work with custom bootstrap limits", async () => { + const customOptions = { + maxBootstrapPeers: 1, + pingKeepAlive: 300, + relayKeepAlive: 300 + }; + + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: customOptions + }); + + const bootstrapPeer1 = createMockPeer("12D3KooWBootstrap1", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer2 = createMockPeer("12D3KooWBootstrap2", [ + Tags.BOOTSTRAP + ]); + const connection1 = createMockConnection(bootstrapPeer1.id, [ + Tags.BOOTSTRAP + ]); + const connection2 = createMockConnection(bootstrapPeer2.id, [ + Tags.BOOTSTRAP + ]); + + libp2p.getConnections.returns([connection1, connection2]); + libp2p.peerStore.get.withArgs(bootstrapPeer1.id).resolves(bootstrapPeer1); + libp2p.peerStore.get.withArgs(bootstrapPeer2.id).resolves(bootstrapPeer2); + + const result = await ( + connectionLimiter as any + ).hasMoreThanMaxBootstrapConnections(); + + expect(result).to.be.true; + }); + }); + + describe("integration tests", () => { + it("should handle full lifecycle (start -> events -> stop)", async () => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + + connectionLimiter.start(); + expect((events.addEventListener as sinon.SinonStub).calledOnce).to.be + .true; + expect(libp2p.addEventListener.calledTwice).to.be.true; + + const connectEventHandler = libp2p.addEventListener.getCall(0).args[1]; + const connectEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + await connectEventHandler(connectEvent); + expect(libp2p.peerStore.get.calledWith(mockPeerId)).to.be.true; + + const disconnectEventHandler = libp2p.addEventListener.getCall(1).args[1]; + libp2p.getConnections.returns([]); + await disconnectEventHandler(); + expect(libp2p.peerStore.all.called).to.be.true; + + connectionLimiter.stop(); + expect((events.removeEventListener as sinon.SinonStub).calledOnce).to.be + .true; + expect(libp2p.removeEventListener.calledTwice).to.be.true; + }); + + it("should handle multiple bootstrap peers with different limits", async () => { + const customOptions = { + maxBootstrapPeers: 1, + pingKeepAlive: 300, + relayKeepAlive: 300 + }; + + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: customOptions + }); + connectionLimiter.start(); + + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer1 = createMockPeer("12D3KooWBootstrap1", [ + Tags.BOOTSTRAP + ]); + const bootstrapPeer2 = createMockPeer("12D3KooWBootstrap2", [ + Tags.BOOTSTRAP + ]); + + const peerId1 = createMockPeerId("peer1"); + const peerId2 = createMockPeerId("peer2"); + + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get.withArgs(peerId1).resolves(bootstrapPeer1); + libp2p.peerStore.get.withArgs(peerId2).resolves(bootstrapPeer2); + + libp2p.getConnections.returns([ + createMockConnection(peerId1, [Tags.BOOTSTRAP]), + createMockConnection(peerId2, [Tags.BOOTSTRAP]) + ]); + + const connectEventHandler = libp2p.addEventListener.getCall(0).args[1]; + const connectEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await connectEventHandler(connectEvent); + + expect(libp2p.hangUp.calledWith(mockPeerId)).to.be.true; + }); + + it("should handle bootstrap limit of 1 correctly", async () => { + const customOptions = { + maxBootstrapPeers: 1, + pingKeepAlive: 300, + relayKeepAlive: 300 + }; + + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: customOptions + }); + connectionLimiter.start(); + + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + const existingBootstrapPeer = createMockPeer( + "12D3KooWExistingBootstrap", + [Tags.BOOTSTRAP] + ); + const existingPeerId = createMockPeerId("existing"); + + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get + .withArgs(existingPeerId) + .resolves(existingBootstrapPeer); + + // Include the new peer in connections since peer:connect is fired after connection is established + libp2p.getConnections.returns([ + createMockConnection(existingPeerId, [Tags.BOOTSTRAP]), + createMockConnection(mockPeerId, [Tags.BOOTSTRAP]) + ]); + + const connectEventHandler = libp2p.addEventListener.getCall(0).args[1]; + const connectEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await connectEventHandler(connectEvent); + + expect(libp2p.hangUp.calledWith(mockPeerId)).to.be.true; + }); + + it("should handle high bootstrap limit correctly", async () => { + const customOptions = { + maxBootstrapPeers: 10, + pingKeepAlive: 300, + relayKeepAlive: 300 + }; + + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: customOptions + }); + connectionLimiter.start(); + + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + const existingBootstrapPeer = createMockPeer( + "12D3KooWExistingBootstrap", + [Tags.BOOTSTRAP] + ); + const existingPeerId = createMockPeerId("existing"); + + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get + .withArgs(existingPeerId) + .resolves(existingBootstrapPeer); + + libp2p.getConnections.returns([ + createMockConnection(existingPeerId, [Tags.BOOTSTRAP]) + ]); + + const connectEventHandler = libp2p.addEventListener.getCall(0).args[1]; + const connectEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await connectEventHandler(connectEvent); + + expect(libp2p.hangUp.called).to.be.false; + }); + + it("should handle mixed peer types with bootstrap limiting", async () => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + const existingBootstrapPeer = createMockPeer( + "12D3KooWExistingBootstrap", + [Tags.BOOTSTRAP] + ); + const nonBootstrapPeer = createMockPeer("12D3KooWNonBootstrap", []); + + const existingBootstrapPeerId = createMockPeerId("existing-bootstrap"); + const nonBootstrapPeerId = createMockPeerId("non-bootstrap"); + + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get + .withArgs(existingBootstrapPeerId) + .resolves(existingBootstrapPeer); + libp2p.peerStore.get + .withArgs(nonBootstrapPeerId) + .resolves(nonBootstrapPeer); + + libp2p.getConnections.returns([ + createMockConnection(existingBootstrapPeerId, [Tags.BOOTSTRAP]), + createMockConnection(nonBootstrapPeerId, []) + ]); + + const connectEventHandler = libp2p.addEventListener.getCall(0).args[1]; + const connectEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await connectEventHandler(connectEvent); + + expect(libp2p.hangUp.called).to.be.false; + }); + + it("should redial peers when all connections are lost", async () => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + + const disconnectEventHandler = libp2p.addEventListener.getCall(1).args[1]; + + libp2p.getConnections.returns([]); + libp2p.peerStore.all.resolves([mockPeer, mockPeer2]); + + await disconnectEventHandler(); + + expect(libp2p.peerStore.all.called).to.be.true; + expect(dialer.dial.calledTwice).to.be.true; + }); + + it("should handle peer store errors during connection limiting", async () => { + connectionLimiter = new ConnectionLimiter({ + libp2p, + events, + dialer, + networkMonitor, + options: defaultOptions + }); + connectionLimiter.start(); + + const bootstrapPeer = createMockPeer("12D3KooWBootstrap", [ + Tags.BOOTSTRAP + ]); + + libp2p.peerStore.get.withArgs(mockPeerId).resolves(bootstrapPeer); + libp2p.peerStore.get + .withArgs(mockConnection.remotePeer) + .rejects(new Error("Peer store error")); + + libp2p.getConnections.returns([mockConnection]); + + const connectEventHandler = libp2p.addEventListener.getCall(0).args[1]; + const connectEvent = new CustomEvent("peer:connect", { + detail: mockPeerId + }); + + await connectEventHandler(connectEvent); + + expect(libp2p.hangUp.called).to.be.false; + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/connection_limiter.ts b/packages/core/src/lib/connection_manager/connection_limiter.ts new file mode 100644 index 0000000000..f62d41229e --- /dev/null +++ b/packages/core/src/lib/connection_manager/connection_limiter.ts @@ -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 = (e: CustomEvent) => 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 + ); + + /** + * 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 + ); + } + + public stop(): void { + this.events.removeEventListener( + "waku:connection", + this.onWakuConnectionEvent + ); + + this.libp2p.removeEventListener( + "peer:connect", + this.onConnectedEvent as Libp2pEventHandler + ); + + this.libp2p.removeEventListener( + "peer:disconnect", + this.onDisconnectedEvent as Libp2pEventHandler + ); + } + + private onWakuConnectionEvent(): void { + if (this.networkMonitor.isBrowserConnected()) { + void this.dialPeersFromStore(); + } + } + + private async onConnectedEvent(evt: CustomEvent): Promise { + 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 { + if (this.libp2p.getConnections().length === 0) { + log.info(`No connections, dialing peers from store`); + await this.dialPeersFromStore(); + } + } + + private async dialPeersFromStore(): Promise { + 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 { + 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 { + 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 { + 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 []; + } + } +} diff --git a/packages/core/src/lib/connection_manager/connection_manager.spec.ts b/packages/core/src/lib/connection_manager/connection_manager.spec.ts new file mode 100644 index 0000000000..40c33a921b --- /dev/null +++ b/packages/core/src/lib/connection_manager/connection_manager.spec.ts @@ -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; + let mockDiscoveryDialer: sinon.SinonStubbedInstance; + let mockShardReader: sinon.SinonStubbedInstance; + let mockNetworkMonitor: sinon.SinonStubbedInstance; + let mockConnectionLimiter: sinon.SinonStubbedInstance; + + 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; + + mockDiscoveryDialer = { + start: sinon.stub(), + stop: sinon.stub() + } as unknown as sinon.SinonStubbedInstance; + + mockShardReader = { + isPeerOnTopic: sinon.stub().resolves(true) + } as unknown as sinon.SinonStubbedInstance; + + mockNetworkMonitor = { + start: sinon.stub(), + stop: sinon.stub(), + isConnected: sinon.stub().returns(true) + } as unknown as sinon.SinonStubbedInstance; + + mockConnectionLimiter = { + start: sinon.stub(), + stop: sinon.stub() + } as unknown as sinon.SinonStubbedInstance; + + // 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); + } + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/connection_manager.ts b/packages/core/src/lib/connection_manager/connection_manager.ts index 995bfdde15..9b5da9bf03 100644 --- a/packages/core/src/lib/connection_manager/connection_manager.ts +++ b/packages/core/src/lib/connection_manager/connection_manager.ts @@ -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; }; -export class ConnectionManager - extends TypedEventEmitter - 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 = new Map(); - private dialErrorsForPeer: Map = new Map(); - - private currentActiveParallelDialCount = 0; - private pendingPeerDialQueue: Array = []; - - 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 { - 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 { - 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 { + 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 { + 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 { 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 { - 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 { - this.startPeerDiscoveryListener(); - this.startPeerConnectionListener(); - this.startPeerDisconnectionListener(); - - this.startNetworkStatusListener(); + public async hasShardInfo(peerId: PeerId): Promise { + 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 { - 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 { - 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 { - 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 { - 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): 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): 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( - EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP, - { - detail: peerId - } - ) - ); - } - } else { - this.dispatchEvent( - new CustomEvent( - EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE, - { - detail: peerId - } - ) - ); - } - - this.setP2PNetworkConnected(); - })(); - }, - "peer:disconnect": (evt: CustomEvent): 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 { - 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 { - 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 { - const isBootstrap = (await this.getTagNamesForPeer(peerId)).includes( - Tags.BOOTSTRAP - ); - - this.dispatchEvent( - new CustomEvent( - 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 { - 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 { - 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 { - 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 { - 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(EConnectionStateEvents.CONNECTION_STATUS, { - detail: this.isConnected() - }) - ); + return this.shardReader.isPeerOnTopic(peerId, pubsubTopic); } } diff --git a/packages/core/src/lib/connection_manager/dialer.spec.ts b/packages/core/src/lib/connection_manager/dialer.spec.ts new file mode 100644 index 0000000000..8f2b0f1ea1 --- /dev/null +++ b/packages/core/src/lib/connection_manager/dialer.spec.ts @@ -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; + 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; + + 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((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(); + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/dialer.ts b/packages/core/src/lib/connection_manager/dialer.ts new file mode 100644 index 0000000000..a8f7cb34f3 --- /dev/null +++ b/packages/core/src/lib/connection_manager/dialer.ts @@ -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; +} + +export class Dialer implements IDialer { + private readonly libp2p: Libp2p; + private readonly shardReader: ShardReader; + + private dialingQueue: PeerId[] = []; + private dialHistory: Map = 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 { + 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 { + 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 { + 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 { + 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 + } + } +} diff --git a/packages/core/src/lib/connection_manager/discovery_dialer.spec.ts b/packages/core/src/lib/connection_manager/discovery_dialer.spec.ts new file mode 100644 index 0000000000..cefd2490c8 --- /dev/null +++ b/packages/core/src/lib/connection_manager/discovery_dialer.spec.ts @@ -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; + 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; + + 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) => Promise; + + 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) => Promise; + + 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; + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/discovery_dialer.ts b/packages/core/src/lib/connection_manager/discovery_dialer.ts new file mode 100644 index 0000000000..7c40003221 --- /dev/null +++ b/packages/core/src/lib/connection_manager/discovery_dialer.ts @@ -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 = (e: CustomEvent) => 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 + ); + } + + public stop(): void { + this.libp2p.removeEventListener( + "peer:discovery", + this.onPeerDiscovery as Libp2pEventHandler + ); + } + + private async onPeerDiscovery(event: CustomEvent): Promise { + 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 { + 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 { + try { + return await this.libp2p.peerStore.get(peerId); + } catch (error) { + log.error(`Error getting peer info for ${peerId}`, error); + return undefined; + } + } +} diff --git a/packages/core/src/lib/connection_manager/keep_alive_manager.spec.ts b/packages/core/src/lib/connection_manager/keep_alive_manager.spec.ts new file mode 100644 index 0000000000..3699be7967 --- /dev/null +++ b/packages/core/src/lib/connection_manager/keep_alive_manager.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/keep_alive_manager.ts b/packages/core/src/lib/connection_manager/keep_alive_manager.ts index 266a94da39..35c0800e5e 100644 --- a/packages/core/src/lib/connection_manager/keep_alive_manager.ts +++ b/packages/core/src/lib/connection_manager/keep_alive_manager.ts @@ -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> = new Map(); - private relayKeepAliveTimers: Map[]> = + private relayKeepAliveTimers: Map[]> = 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): 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): 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 { + 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 { + 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); + } } } diff --git a/packages/core/src/lib/connection_manager/network_monitor.spec.ts b/packages/core/src/lib/connection_manager/network_monitor.spec.ts new file mode 100644 index 0000000000..fb4359d6cf --- /dev/null +++ b/packages/core/src/lib/connection_manager/network_monitor.spec.ts @@ -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; + 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; + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/network_monitor.ts b/packages/core/src/lib/connection_manager/network_monitor.ts new file mode 100644 index 0000000000..9a518674e7 --- /dev/null +++ b/packages/core/src/lib/connection_manager/network_monitor.ts @@ -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("waku:connection", { + detail: this.isConnected() + }) + ); + } +} diff --git a/packages/core/src/lib/connection_manager/shard_reader.spec.ts b/packages/core/src/lib/connection_manager/shard_reader.spec.ts new file mode 100644 index 0000000000..843966f705 --- /dev/null +++ b/packages/core/src/lib/connection_manager/shard_reader.spec.ts @@ -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; + 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; + }); + }); +}); diff --git a/packages/core/src/lib/connection_manager/shard_reader.ts b/packages/core/src/lib/connection_manager/shard_reader.ts new file mode 100644 index 0000000000..b7b5a735b0 --- /dev/null +++ b/packages/core/src/lib/connection_manager/shard_reader.ts @@ -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; + isPeerOnNetwork(id: PeerId): Promise; + isPeerOnShard(id: PeerId, shard: SingleShardInfo): Promise; + isPeerOnTopic(id: PeerId, pubsubTopic: PubsubTopic): Promise; +} + +/** + * 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 { + 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 { + const shardInfo = await this.getShardInfo(id); + return !!shardInfo; + } + + public async isPeerOnTopic( + id: PeerId, + pubsubTopic: PubsubTopic + ): Promise { + 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 { + 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 { + 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 + }; + } +} diff --git a/packages/core/src/lib/connection_manager/utils.spec.ts b/packages/core/src/lib/connection_manager/utils.spec.ts new file mode 100644 index 0000000000..925f72b208 --- /dev/null +++ b/packages/core/src/lib/connection_manager/utils.spec.ts @@ -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" + ); + }); +}); diff --git a/packages/core/src/lib/connection_manager/utils.ts b/packages/core/src/lib/connection_manager/utils.ts index b994e1df7c..02fa68a2b6 100644 --- a/packages/core/src/lib/connection_manager/utils.ts +++ b/packages/core/src/lib/connection_manager/utils.ts @@ -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()!); +}; diff --git a/packages/core/src/lib/filter/filter.ts b/packages/core/src/lib/filter/filter.ts index 9306e8a09c..545b1b99b9 100644 --- a/packages/core/src/lib/filter/filter.ts +++ b/packages/core/src/lib/filter/filter.ts @@ -42,7 +42,6 @@ export class FilterCore { public constructor( private handleIncomingMessage: IncomingMessageHandler, - public readonly pubsubTopics: PubsubTopic[], libp2p: Libp2p ) { this.streamManager = new StreamManager( diff --git a/packages/core/src/lib/light_push/light_push.ts b/packages/core/src/lib/light_push/light_push.ts index 0dd1a9da3e..6c2430e5a5 100644 --- a/packages/core/src/lib/light_push/light_push.ts +++ b/packages/core/src/lib/light_push/light_push.ts @@ -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); } diff --git a/packages/discovery/src/dns/constants.ts b/packages/discovery/src/dns/constants.ts index af4d4b0c53..078be5264f 100644 --- a/packages/discovery/src/dns/constants.ts +++ b/packages/discovery/src/dns/constants.ts @@ -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; diff --git a/packages/discovery/src/local-peer-cache/index.ts b/packages/discovery/src/local-peer-cache/index.ts index cd9708a984..79df3d6f72 100644 --- a/packages/discovery/src/local-peer-cache/index.ts +++ b/packages/discovery/src/local-peer-cache/index.ts @@ -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; diff --git a/packages/discovery/src/peer-exchange/index.ts b/packages/discovery/src/peer-exchange/index.ts index 4c35cd8352..c89b609a2c 100644 --- a/packages/discovery/src/peer-exchange/index.ts +++ b/packages/discovery/src/peer-exchange/index.ts @@ -6,6 +6,5 @@ export { export { wakuPeerExchangeDiscovery, PeerExchangeDiscovery, - Options, - DEFAULT_PEER_EXCHANGE_TAG_NAME + Options } from "./waku_peer_exchange_discovery.js"; diff --git a/packages/discovery/src/peer-exchange/waku_peer_exchange_discovery.ts b/packages/discovery/src/peer-exchange/waku_peer_exchange_discovery.ts index 6c246b6169..9087f12c15 100644 --- a/packages/discovery/src/peer-exchange/waku_peer_exchange_discovery.ts +++ b/packages/discovery/src/peer-exchange/waku_peer_exchange_discovery.ts @@ -54,7 +54,7 @@ interface CustomDiscoveryEvent extends PeerDiscoveryEvents { "waku:peer-exchange:started": CustomEvent; } -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; diff --git a/packages/interfaces/src/connection_manager.ts b/packages/interfaces/src/connection_manager.ts index 8610acab95..8849a54e13 100644 --- a/packages/interfaces/src/connection_manager.ts +++ b/packages/interfaces/src/connection_manager.ts @@ -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; - [EPeersByDiscoveryEvents.PEER_DISCOVERY_PEER_EXCHANGE]: CustomEvent; - [EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP]: CustomEvent; - [EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE]: CustomEvent; -} - -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; -} - -export interface IConnectionManager - extends TypedEventEmitter { - pubsubTopics: PubsubTopic[]; - getConnectedPeers(codec?: string): Promise; - dropConnection(peerId: PeerId): Promise; - getPeersByDiscovery(): Promise; + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; } diff --git a/packages/interfaces/src/message.ts b/packages/interfaces/src/message.ts index f2aed10ad1..8c1ae1dd20 100644 --- a/packages/interfaces/src/message.ts +++ b/packages/interfaces/src/message.ts @@ -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; diff --git a/packages/interfaces/src/waku.ts b/packages/interfaces/src/waku.ts index 7bddbac622..9bc58f77e2 100644 --- a/packages/interfaces/src/waku.ts +++ b/packages/interfaces/src/waku.ts @@ -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; +} + +export type IWakuEventEmitter = TypedEventEmitter; + 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} `Promise` that will resolve to a `Stream` to a dialed peer + * @returns {Promise} `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; + /** + * Hang up a connection to a peer + * + * @param {PeerId | MultiaddrInput} peer information to use for hanging up + * + * @returns {Promise} `Promise` that will resolve to `true` if the connection is hung up, `false` otherwise + */ + hangUp(peer: PeerId | MultiaddrInput): Promise; + /** * Starts all services and components related to functionality of Waku node. * diff --git a/packages/relay/src/create.ts b/packages/relay/src/create.ts index 64a46890f4..49702ad356 100644 --- a/packages/relay/src/create.ts +++ b/packages/relay/src/create.ts @@ -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; } diff --git a/packages/sdk/src/filter/filter.spec.ts b/packages/sdk/src/filter/filter.spec.ts index aebdc39975..440303e56d 100644 --- a/packages/sdk/src/filter/filter.spec.ts +++ b/packages/sdk/src/filter/filter.spec.ts @@ -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 { diff --git a/packages/sdk/src/filter/filter.ts b/packages/sdk/src/filter/filter.ts index d0bbedfc3c..3fc144ff39 100644 --- a/packages/sdk/src/filter/filter.ts +++ b/packages/sdk/src/filter/filter.ts @@ -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( diff --git a/packages/sdk/src/light_push/light_push.spec.ts b/packages/sdk/src/light_push/light_push.spec.ts index a3fc88cf76..0d44a430f2 100644 --- a/packages/sdk/src/light_push/light_push.spec.ts +++ b/packages/sdk/src/light_push/light_push.spec.ts @@ -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 diff --git a/packages/sdk/src/light_push/light_push.ts b/packages/sdk/src/light_push/light_push.ts index 0aad09dec3..18719ec210 100644 --- a/packages/sdk/src/light_push/light_push.ts +++ b/packages/sdk/src/light_push/light_push.ts @@ -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: [ diff --git a/packages/sdk/src/peer_manager/peer_manager.spec.ts b/packages/sdk/src/peer_manager/peer_manager.spec.ts index 62274bcc32..3b0532533e 100644 --- a/packages/sdk/src/peer_manager/peer_manager.spec.ts +++ b/packages/sdk/src/peer_manager/peer_manager.spec.ts @@ -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, diff --git a/packages/sdk/src/peer_manager/peer_manager.ts b/packages/sdk/src/peer_manager/peer_manager.ts index e1000681e7..08c600160d 100644 --- a/packages/sdk/src/peer_manager/peer_manager.ts +++ b/packages/sdk/src/peer_manager/peer_manager.ts @@ -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 { - 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): Promise { diff --git a/packages/sdk/src/store/store.ts b/packages/sdk/src/store/store.ts index 56bd6ad378..874244a35a 100644 --- a/packages/sdk/src/store/store.ts +++ b/packages/sdk/src/store/store.ts @@ -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.` ); } diff --git a/packages/sdk/src/waku/utils.spec.ts b/packages/sdk/src/waku/utils.spec.ts index d3d3605c22..57ed1e495c 100644 --- a/packages/sdk/src/waku/utils.spec.ts +++ b/packages/sdk/src/waku/utils.spec.ts @@ -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 = { diff --git a/packages/sdk/src/waku/utils.ts b/packages/sdk/src/waku/utils.ts index dc391ec12b..76c99a6eeb 100644 --- a/packages/sdk/src/waku/utils.ts +++ b/packages/sdk/src/waku/utils.ts @@ -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 diff --git a/packages/sdk/src/waku/waku.ts b/packages/sdk/src/waku/waku.ts index 3fab15b7d0..6b35287bc6 100644 --- a/packages/sdk/src/waku/waku.ts +++ b/packages/sdk/src/waku/waku.ts @@ -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 { + log.info(`Hanging up peer:${peer?.toString()}.`); + + return this.connectionManager.hangUp(peer); } public async start(): Promise { @@ -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(); diff --git a/packages/tests/src/lib/runNodes.ts b/packages/tests/src/lib/runNodes.ts index 0c3b6fc6e0..09f13c9dbd 100644 --- a/packages/tests/src/lib/runNodes.ts +++ b/packages/tests/src/lib/runNodes.ts @@ -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 = { diff --git a/packages/tests/src/utils/nodes.ts b/packages/tests/src/utils/nodes.ts index dc56d4adf0..ef312f5868 100644 --- a/packages/tests/src/utils/nodes.ts +++ b/packages/tests/src/utils/nodes.ts @@ -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); diff --git a/packages/tests/tests/connection-mananger/connection_limiter.spec.ts b/packages/tests/tests/connection-mananger/connection_limiter.spec.ts new file mode 100644 index 0000000000..74b5207462 --- /dev/null +++ b/packages/tests/tests/connection-mananger/connection_limiter.spec.ts @@ -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" + ); + }); +}); diff --git a/packages/tests/tests/connection-mananger/connection_state.spec.ts b/packages/tests/tests/connection-mananger/connection_state.spec.ts deleted file mode 100644 index f84a526d74..0000000000 --- a/packages/tests/tests/connection-mananger/connection_state.spec.ts +++ /dev/null @@ -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((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((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((resolve) => { - waku1.connectionManager.addEventListener( - EConnectionStateEvents.CONNECTION_STATUS, - ({ detail: status }) => { - eventCount1++; - resolve(status); - } - ); - }); - - let eventCount2 = 0; - const connectionStatus2 = new Promise((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; - }); -}); diff --git a/packages/tests/tests/connection-mananger/dialing.spec.ts b/packages/tests/tests/connection-mananger/dialing.spec.ts new file mode 100644 index 0000000000..70c9a3e017 --- /dev/null +++ b/packages/tests/tests/connection-mananger/dialing.spec.ts @@ -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); + }); +}); diff --git a/packages/tests/tests/connection-mananger/dials.spec.ts b/packages/tests/tests/connection-mananger/dials.spec.ts deleted file mode 100644 index 3bcd5c7622..0000000000 --- a/packages/tests/tests/connection-mananger/dials.spec.ts +++ /dev/null @@ -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("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("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("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("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("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("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); - }); - }); - }); -}); diff --git a/packages/tests/tests/connection-mananger/discovery_dialer.spec.ts b/packages/tests/tests/connection-mananger/discovery_dialer.spec.ts new file mode 100644 index 0000000000..8d33b69715 --- /dev/null +++ b/packages/tests/tests/connection-mananger/discovery_dialer.spec.ts @@ -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); + }); +}); diff --git a/packages/tests/tests/connection-mananger/events.spec.ts b/packages/tests/tests/connection-mananger/events.spec.ts deleted file mode 100644 index 75f6d148a0..0000000000 --- a/packages/tests/tests/connection-mananger/events.spec.ts +++ /dev/null @@ -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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_DISCOVERY_BOOTSTRAP, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdBootstrap.toString()); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_DISCOVERY_PEER_EXCHANGE, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdPx.toString()); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdBootstrap.toString()); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_CONNECT_PEER_EXCHANGE, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdPx.toString()); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("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((resolve) => { - waku.connectionManager.addEventListener( - EConnectionStateEvents.CONNECTION_STATUS, - ({ detail: status }) => { - eventCount++; - resolve(status); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("peer:connect", { detail: peerIdPx }) - ); - waku.libp2p.dispatchEvent( - new CustomEvent("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((resolve) => { - waku.connectionManager.addEventListener( - EConnectionStateEvents.CONNECTION_STATUS, - ({ detail: status }) => { - resolve(status); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("peer:disconnect", { detail: peerIdPx }) - ); - waku.libp2p.dispatchEvent( - new CustomEvent("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((resolve) => { - waku.connectionManager.addEventListener( - EConnectionStateEvents.CONNECTION_STATUS, - ({ detail: status }) => { - eventCount++; - resolve(status); - } - ); - }); - - waku.libp2p.dispatchEvent( - new CustomEvent("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((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((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); - }); - }); -}); diff --git a/packages/tests/tests/connection-mananger/methods.spec.ts b/packages/tests/tests/connection-mananger/methods.spec.ts deleted file mode 100644 index 6bd54fd60d..0000000000 --- a/packages/tests/tests/connection-mananger/methods.spec.ts +++ /dev/null @@ -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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdBootstrap.toString()); - } - ); - }); - waku.libp2p.dispatchEvent( - new CustomEvent("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((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("peer:connect", { detail: peerIdBootstrap }) - ); - const timeoutPromise = new Promise((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("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("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("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("peer:connect", { detail: peerIdBootstrap }) - ); - - const peers_after = ( - 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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdBootstrap.toString()); - } - ); - }); - waku.connectionManager.dispatchEvent( - new CustomEvent(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((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((resolve) => { - waku.connectionManager.addEventListener( - EPeersByDiscoveryEvents.PEER_CONNECT_BOOTSTRAP, - ({ detail: receivedPeerId }) => { - resolve(receivedPeerId.toString() === peerIdBootstrap.toString()); - } - ); - }); - - waku.connectionManager.stop(); - waku.libp2p.dispatchEvent( - new CustomEvent("peer:connect", { detail: peerIdBootstrap }) - ); - - const timeoutPromise = new Promise((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); - }); -}); diff --git a/packages/tests/tests/connection-mananger/network_monitor.spec.ts b/packages/tests/tests/connection-mananger/network_monitor.spec.ts new file mode 100644 index 0000000000..9c73207cdb --- /dev/null +++ b/packages/tests/tests/connection-mananger/network_monitor.spec.ts @@ -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((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((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((resolve) => { + waku1.events.addEventListener("waku:connection", ({ detail: status }) => { + eventCount1++; + resolve(status); + }); + }); + + let eventCount2 = 0; + const connectionStatus2 = new Promise((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((resolve) => { + waku.events.addEventListener("waku:connection", ({ detail: status }) => { + eventCount++; + resolve(status); + }); + }); + + waku.libp2p.dispatchEvent( + new CustomEvent("peer:connect", { detail: peerIdPx }) + ); + waku.libp2p.dispatchEvent( + new CustomEvent("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((resolve) => { + waku.events.addEventListener("waku:connection", ({ detail: status }) => { + resolve(status); + }); + }); + + waku.libp2p.dispatchEvent( + new CustomEvent("peer:disconnect", { detail: peerIdPx }) + ); + waku.libp2p.dispatchEvent( + new CustomEvent("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((resolve) => { + waku.events.addEventListener("waku:connection", ({ detail: status }) => { + eventCount++; + resolve(status); + }); + }); + + waku.libp2p.dispatchEvent( + new CustomEvent("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((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((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); + }); +}); diff --git a/packages/tests/tests/connection-mananger/utils.ts b/packages/tests/tests/connection-mananger/utils.ts new file mode 100644 index 0000000000..2447e946a1 --- /dev/null +++ b/packages/tests/tests/connection-mananger/utils.ts @@ -0,0 +1,6 @@ +export const TestContentTopic = "/test/1/waku-light-push/utf8"; +export const ClusterId = 3; +export const TestShardInfo = { + contentTopics: [TestContentTopic], + clusterId: ClusterId +}; diff --git a/packages/tests/tests/filter/push.node.spec.ts b/packages/tests/tests/filter/push.node.spec.ts index 61b7d57065..3ecbf6a585 100644 --- a/packages/tests/tests/filter/push.node.spec.ts +++ b/packages/tests/tests/filter/push.node.spec.ts @@ -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); }); diff --git a/packages/tests/tests/multiaddr.node.spec.ts b/packages/tests/tests/multiaddr.node.spec.ts deleted file mode 100644 index d9f5e9a4a0..0000000000 --- a/packages/tests/tests/multiaddr.node.spec.ts +++ /dev/null @@ -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("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); - }); - }); -}); diff --git a/packages/tests/tests/peer-exchange/index.spec.ts b/packages/tests/tests/peer-exchange/index.spec.ts index 666b97c24f..57638756e3 100644 --- a/packages/tests/tests/peer-exchange/index.spec.ts +++ b/packages/tests/tests/peer-exchange/index.spec.ts @@ -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(); + await new Promise((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 = ( - 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(); await new Promise((resolve) => { diff --git a/packages/tests/tests/sharding/peer_management.spec.ts b/packages/tests/tests/sharding/peer_management.spec.ts index 6321a6919b..6b42aa4e90 100644 --- a/packages/tests/tests/sharding/peer_management.spec.ts +++ b/packages/tests/tests/sharding/peer_management.spec.ts @@ -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(); @@ -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(); @@ -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(); diff --git a/packages/tests/tests/store/error_handling.node.spec.ts b/packages/tests/tests/store/error_handling.node.spec.ts index 9a921163e2..e39ce3265b 100644 --- a/packages/tests/tests/store/error_handling.node.spec.ts +++ b/packages/tests/tests/store/error_handling.node.spec.ts @@ -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; diff --git a/packages/utils/src/common/sharding/index.spec.ts b/packages/utils/src/common/sharding/index.spec.ts index 59cec4b50a..4c8f854875 100644 --- a/packages/utils/src/common/sharding/index.spec.ts +++ b/packages/utils/src/common/sharding/index.spec.ts @@ -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", () => { diff --git a/packages/utils/src/common/sharding/index.ts b/packages/utils/src/common/sharding/index.ts index 9305b77b11..f70db904bf 100644 --- a/packages/utils/src/common/sharding/index.ts +++ b/packages/utils/src/common/sharding/index.ts @@ -67,6 +67,9 @@ export const singleShardInfosToShardInfo = ( }; }; +/** + * @deprecated will be removed, use cluster and shard comparison directly + */ export const shardInfoToPubsubTopics = ( shardInfo: Partial ): 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;