From 8860a0cd46b359a5648402d83870f7ff957222fe Mon Sep 17 00:00:00 2001 From: Alex Potsides Date: Wed, 9 Feb 2022 14:13:41 +0000 Subject: [PATCH] feat: add peer store/records, and streams are just streams (#160) The peer store is used by other libp2p components, so split it out from libp2p so those components can break their dependencies on libp2p and still run their tests. Also makes `MuxedStream`s `Stream`s as the muxing should be invisible to the caller. --- packages/libp2p-connection/package.json | 1 + packages/libp2p-connection/src/index.ts | 18 +- .../test/compliance.spec.ts} | 12 +- packages/libp2p-connection/test/index.spec.ts | 4 +- packages/libp2p-connection/tsconfig.json | 3 + .../package.json | 36 +- .../src/stream-muxer/base-test.ts | 17 +- .../src/transport/utils/index.ts | 20 +- .../src/utils/mock-connection-gater.ts | 14 + .../src/utils/mock-connection.ts | 66 ++ .../src/utils/mock-multiaddr-connection.ts | 18 + .../src/utils/mock-muxer.ts | 43 + .../src/utils/mock-upgrader.ts | 33 + .../test/topology/mock-peer-store.ts | 27 - .../test/topology/multicodec-topology.spec.ts | 50 -- .../test/topology/topology.spec.ts | 23 - .../tsconfig.json | 3 - packages/libp2p-interfaces/package.json | 4 + .../libp2p-interfaces/src/connection/index.ts | 31 +- packages/libp2p-interfaces/src/dht/index.ts | 3 +- packages/libp2p-interfaces/src/index.ts | 13 + .../libp2p-interfaces/src/peer-store/index.ts | 234 ++++++ .../libp2p-interfaces/src/record/index.ts | 15 +- .../libp2p-interfaces/src/registrar/index.ts | 27 +- .../src/stream-muxer/index.ts | 24 +- .../libp2p-interfaces/src/transport/index.ts | 4 + packages/libp2p-interfaces/tsconfig.json | 5 - packages/libp2p-peer-id/package.json | 1 + packages/libp2p-peer-id/src/index.ts | 21 +- packages/libp2p-peer-record/LICENSE | 4 + packages/libp2p-peer-record/LICENSE-APACHE | 5 + packages/libp2p-peer-record/LICENSE-MIT | 19 + packages/libp2p-peer-record/README.md | 179 +++++ packages/libp2p-peer-record/package.json | 173 ++++ .../src/envelope/envelope.d.ts | 77 ++ .../src/envelope/envelope.js | 241 ++++++ .../src/envelope/envelope.proto | 19 + .../libp2p-peer-record/src/envelope/index.ts | 159 ++++ packages/libp2p-peer-record/src/errors.ts | 4 + packages/libp2p-peer-record/src/index.ts | 3 + .../src/peer-record/consts.js | 8 + .../src/peer-record/index.ts | 104 +++ .../src/peer-record/peer-record.d.ts | 133 ++++ .../src/peer-record/peer-record.js | 365 +++++++++ .../src/peer-record/peer-record.proto | 18 + .../libp2p-peer-record/test/envelope.spec.ts | 89 +++ .../test/peer-record.spec.ts | 155 ++++ packages/libp2p-peer-record/tsconfig.json | 28 + packages/libp2p-peer-store/LICENSE | 4 + packages/libp2p-peer-store/LICENSE-APACHE | 5 + packages/libp2p-peer-store/LICENSE-MIT | 19 + packages/libp2p-peer-store/README.md | 44 ++ packages/libp2p-peer-store/package.json | 163 ++++ packages/libp2p-peer-store/src/README.md | 145 ++++ .../libp2p-peer-store/src/address-book.ts | 332 ++++++++ packages/libp2p-peer-store/src/errors.ts | 5 + packages/libp2p-peer-store/src/index.ts | 123 +++ packages/libp2p-peer-store/src/key-book.ts | 117 +++ .../libp2p-peer-store/src/metadata-book.ts | 200 +++++ packages/libp2p-peer-store/src/pb/peer.d.ts | 222 ++++++ packages/libp2p-peer-store/src/pb/peer.js | 641 +++++++++++++++ packages/libp2p-peer-store/src/pb/peer.proto | 31 + packages/libp2p-peer-store/src/proto-book.ts | 204 +++++ packages/libp2p-peer-store/src/store.ts | 224 ++++++ .../test/address-book.spec.ts | 741 ++++++++++++++++++ .../libp2p-peer-store/test/key-book.spec.ts | 136 ++++ .../test/metadata-book.spec.ts | 374 +++++++++ .../libp2p-peer-store/test/peer-store.spec.ts | 235 ++++++ .../libp2p-peer-store/test/proto-book.spec.ts | 410 ++++++++++ packages/libp2p-peer-store/tsconfig.json | 26 + packages/libp2p-pubsub/package.json | 2 +- packages/libp2p-pubsub/src/peer-streams.ts | 12 +- .../{lifesycle.spec.ts => lifecycle.spec.ts} | 0 packages/libp2p-pubsub/test/utils/index.ts | 7 +- packages/libp2p-topology/package.json | 4 +- .../src/multicodec-topology.ts | 47 +- 76 files changed, 6798 insertions(+), 228 deletions(-) rename packages/{libp2p-interface-compliance-tests/test/connection/index.spec.ts => libp2p-connection/test/compliance.spec.ts} (85%) create mode 100644 packages/libp2p-interface-compliance-tests/src/utils/mock-connection-gater.ts create mode 100644 packages/libp2p-interface-compliance-tests/src/utils/mock-connection.ts create mode 100644 packages/libp2p-interface-compliance-tests/src/utils/mock-multiaddr-connection.ts create mode 100644 packages/libp2p-interface-compliance-tests/src/utils/mock-muxer.ts create mode 100644 packages/libp2p-interface-compliance-tests/src/utils/mock-upgrader.ts delete mode 100644 packages/libp2p-interface-compliance-tests/test/topology/mock-peer-store.ts delete mode 100644 packages/libp2p-interface-compliance-tests/test/topology/multicodec-topology.spec.ts delete mode 100644 packages/libp2p-interface-compliance-tests/test/topology/topology.spec.ts create mode 100644 packages/libp2p-interfaces/src/peer-store/index.ts create mode 100644 packages/libp2p-peer-record/LICENSE create mode 100644 packages/libp2p-peer-record/LICENSE-APACHE create mode 100644 packages/libp2p-peer-record/LICENSE-MIT create mode 100644 packages/libp2p-peer-record/README.md create mode 100644 packages/libp2p-peer-record/package.json create mode 100644 packages/libp2p-peer-record/src/envelope/envelope.d.ts create mode 100644 packages/libp2p-peer-record/src/envelope/envelope.js create mode 100644 packages/libp2p-peer-record/src/envelope/envelope.proto create mode 100644 packages/libp2p-peer-record/src/envelope/index.ts create mode 100644 packages/libp2p-peer-record/src/errors.ts create mode 100644 packages/libp2p-peer-record/src/index.ts create mode 100644 packages/libp2p-peer-record/src/peer-record/consts.js create mode 100644 packages/libp2p-peer-record/src/peer-record/index.ts create mode 100644 packages/libp2p-peer-record/src/peer-record/peer-record.d.ts create mode 100644 packages/libp2p-peer-record/src/peer-record/peer-record.js create mode 100644 packages/libp2p-peer-record/src/peer-record/peer-record.proto create mode 100644 packages/libp2p-peer-record/test/envelope.spec.ts create mode 100644 packages/libp2p-peer-record/test/peer-record.spec.ts create mode 100644 packages/libp2p-peer-record/tsconfig.json create mode 100644 packages/libp2p-peer-store/LICENSE create mode 100644 packages/libp2p-peer-store/LICENSE-APACHE create mode 100644 packages/libp2p-peer-store/LICENSE-MIT create mode 100644 packages/libp2p-peer-store/README.md create mode 100644 packages/libp2p-peer-store/package.json create mode 100644 packages/libp2p-peer-store/src/README.md create mode 100644 packages/libp2p-peer-store/src/address-book.ts create mode 100644 packages/libp2p-peer-store/src/errors.ts create mode 100644 packages/libp2p-peer-store/src/index.ts create mode 100644 packages/libp2p-peer-store/src/key-book.ts create mode 100644 packages/libp2p-peer-store/src/metadata-book.ts create mode 100644 packages/libp2p-peer-store/src/pb/peer.d.ts create mode 100644 packages/libp2p-peer-store/src/pb/peer.js create mode 100644 packages/libp2p-peer-store/src/pb/peer.proto create mode 100644 packages/libp2p-peer-store/src/proto-book.ts create mode 100644 packages/libp2p-peer-store/src/store.ts create mode 100644 packages/libp2p-peer-store/test/address-book.spec.ts create mode 100644 packages/libp2p-peer-store/test/key-book.spec.ts create mode 100644 packages/libp2p-peer-store/test/metadata-book.spec.ts create mode 100644 packages/libp2p-peer-store/test/peer-store.spec.ts create mode 100644 packages/libp2p-peer-store/test/proto-book.spec.ts create mode 100644 packages/libp2p-peer-store/tsconfig.json rename packages/libp2p-pubsub/test/{lifesycle.spec.ts => lifecycle.spec.ts} (100%) diff --git a/packages/libp2p-connection/package.json b/packages/libp2p-connection/package.json index 46193fba8..4e8eb1c91 100644 --- a/packages/libp2p-connection/package.json +++ b/packages/libp2p-connection/package.json @@ -159,6 +159,7 @@ "err-code": "^3.0.1" }, "devDependencies": { + "@libp2p/interface-compliance-tests": "^1.0.0", "@libp2p/peer-id-factory": "^1.0.0", "aegir": "^36.1.3" } diff --git a/packages/libp2p-connection/src/index.ts b/packages/libp2p-connection/src/index.ts index cf19d3280..d23f7b289 100644 --- a/packages/libp2p-connection/src/index.ts +++ b/packages/libp2p-connection/src/index.ts @@ -1,17 +1,11 @@ import type { Multiaddr } from '@multiformats/multiaddr' import errCode from 'err-code' import { OPEN, CLOSING, CLOSED } from '@libp2p/interfaces/connection/status' -import type { MuxedStream } from '@libp2p/interfaces/stream-muxer' -import type { ConnectionStat, StreamData } from '@libp2p/interfaces/connection' +import type { ConnectionStat, Metadata, ProtocolStream, Stream } from '@libp2p/interfaces/connection' import type { PeerId } from '@libp2p/interfaces/peer-id' const connectionSymbol = Symbol.for('@libp2p/interface-connection/connection') -export interface ProtocolStream { - protocol: string - stream: MuxedStream -} - interface ConnectionOptions { localAddr: Multiaddr remoteAddr: Multiaddr @@ -19,7 +13,7 @@ interface ConnectionOptions { remotePeer: PeerId newStream: (protocols: string[]) => Promise close: () => Promise - getStreams: () => MuxedStream[] + getStreams: () => Stream[] stat: ConnectionStat } @@ -69,11 +63,11 @@ export class Connection { /** * Reference to the getStreams function of the muxer */ - private readonly _getStreams: () => MuxedStream[] + private readonly _getStreams: () => Stream[] /** * Connection streams registry */ - public readonly registry: Map + public readonly registry: Map private _closing: boolean /** @@ -149,9 +143,9 @@ export class Connection { /** * Add a stream when it is opened to the registry */ - addStream (muxedStream: MuxedStream, streamData: StreamData) { + addStream (stream: Stream, metadata: Metadata) { // Add metadata for the stream - this.registry.set(muxedStream.id, streamData) + this.registry.set(stream.id, metadata) } /** diff --git a/packages/libp2p-interface-compliance-tests/test/connection/index.spec.ts b/packages/libp2p-connection/test/compliance.spec.ts similarity index 85% rename from packages/libp2p-interface-compliance-tests/test/connection/index.spec.ts rename to packages/libp2p-connection/test/compliance.spec.ts index 2ed3f5802..b0fd4b142 100644 --- a/packages/libp2p-interface-compliance-tests/test/connection/index.spec.ts +++ b/packages/libp2p-connection/test/compliance.spec.ts @@ -1,10 +1,10 @@ -import tests from '../../src/connection/index.js' -import { Connection } from '@libp2p/connection' -import peers from '../../src/utils/peers.js' +import tests from '@libp2p/interface-compliance-tests/connection' +import { Connection } from '../src/index.js' +import peers from '@libp2p/interface-compliance-tests/utils/peers' import * as PeerIdFactory from '@libp2p/peer-id-factory' import { Multiaddr } from '@multiformats/multiaddr' import { pair } from 'it-pair' -import type { MuxedStream } from '@libp2p/interfaces/stream-muxer' +import type { Stream } from '@libp2p/interfaces/connection' describe('compliance tests', () => { tests({ @@ -19,7 +19,7 @@ describe('compliance tests', () => { PeerIdFactory.createFromJSON(peers[0]), PeerIdFactory.createFromJSON(peers[1]) ]) - const openStreams: MuxedStream[] = [] + const openStreams: Stream[] = [] let streamId = 0 const connection = new Connection({ @@ -39,7 +39,7 @@ describe('compliance tests', () => { }, newStream: async (protocols) => { const id = `${streamId++}` - const stream: MuxedStream = { + const stream: Stream = { ...pair(), close: async () => { await stream.sink(async function * () {}()) diff --git a/packages/libp2p-connection/test/index.spec.ts b/packages/libp2p-connection/test/index.spec.ts index d54e77568..c96ac1fbb 100644 --- a/packages/libp2p-connection/test/index.spec.ts +++ b/packages/libp2p-connection/test/index.spec.ts @@ -2,7 +2,7 @@ import { Connection } from '../src/index.js' import * as PeerIdFactory from '@libp2p/peer-id-factory' import { pair } from 'it-pair' import { Multiaddr } from '@multiformats/multiaddr' -import type { MuxedStream } from '@libp2p/interfaces/stream-muxer' +import type { Stream } from '@libp2p/interfaces/connection' const peers = [{ id: 'QmNMMAqSxPetRS1cVMmutW5BCN1qQQyEr4u98kUvZjcfEw', @@ -55,7 +55,7 @@ describe('connection tests', () => { }, newStream: async (protocols) => { const id = `${streamId++}` - const stream: MuxedStream = { + const stream: Stream = { ...pair(), close: async () => await stream.sink(async function * () {}()), id, diff --git a/packages/libp2p-connection/tsconfig.json b/packages/libp2p-connection/tsconfig.json index b75443f5a..c05be1417 100644 --- a/packages/libp2p-connection/tsconfig.json +++ b/packages/libp2p-connection/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "../libp2p-peer-id-factory" + }, + { + "path": "../libp2p-interface-compliance-tests" } ] } diff --git a/packages/libp2p-interface-compliance-tests/package.json b/packages/libp2p-interface-compliance-tests/package.json index 0ca5f5a2d..f7afa6f28 100644 --- a/packages/libp2p-interface-compliance-tests/package.json +++ b/packages/libp2p-interface-compliance-tests/package.json @@ -73,8 +73,12 @@ "types": "./dist/src/stream-muxer/index.d.ts" }, "./topology": { - "import": "./dist/src/topology/index.js", - "types": "./dist/src/topology/index.d.ts" + "import": "./dist/src/topology/topology.js", + "types": "./dist/src/topology/topology.d.ts" + }, + "./topology/multicodec-toplogy": { + "import": "./dist/src/topology/multicodec-toplogy.js", + "types": "./dist/src/topology/multicodec-toplogy.d.ts" }, "./transport": { "import": "./dist/src/transport/index.js", @@ -84,6 +88,30 @@ "import": "./dist/src/transport/utils/index.js", "types": "./dist/src/transport/utils/index.d.ts" }, + "./utils/mock-connection": { + "import": "./dist/src/utils/mock-connection.js", + "types": "./dist/src/utils/mock-connection.d.ts" + }, + "./utils/mock-connection-gater": { + "import": "./dist/src/utils/mock-connection-gater.js", + "types": "./dist/src/utils/mock-connection-gater.d.ts" + }, + "./utils/mock-multiaddr-connection": { + "import": "./dist/src/utils/mock-multiaddr-connection.js", + "types": "./dist/src/utils/mock-multiaddr-connection.d.ts" + }, + "./utils/mock-muxer": { + "import": "./dist/src/utils/mock-muxer.js", + "types": "./dist/src/utils/mock-muxer.d.ts" + }, + "./utils/mock-peer-store": { + "import": "./dist/src/utils/mock-peer-store.js", + "types": "./dist/src/utils/mock-peer-store.d.ts" + }, + "./utils/mock-upgrader": { + "import": "./dist/src/utils/mock-upgrader.js", + "types": "./dist/src/utils/mock-upgrader.d.ts" + }, "./utils/peers": { "import": "./dist/src/utils/peers.js", "types": "./dist/src/utils/peers.d.ts" @@ -190,13 +218,11 @@ "test:electron-main": "npm run test -- -t electron-main" }, "dependencies": { - "@libp2p/connection": "^1.0.0", "@libp2p/crypto": "^0.22.2", "@libp2p/interfaces": "^1.0.0", "@libp2p/peer-id": "^1.0.0", "@libp2p/peer-id-factory": "^1.0.0", - "@libp2p/pubsub": "^1.0.0", - "@libp2p/topology": "^1.0.0", + "@libp2p/pubsub": "^1.1.0", "@multiformats/multiaddr": "^10.1.1", "abortable-iterator": "^4.0.0", "aegir": "^36.1.3", diff --git a/packages/libp2p-interface-compliance-tests/src/stream-muxer/base-test.ts b/packages/libp2p-interface-compliance-tests/src/stream-muxer/base-test.ts index b1fef890c..310abd0e7 100644 --- a/packages/libp2p-interface-compliance-tests/src/stream-muxer/base-test.ts +++ b/packages/libp2p-interface-compliance-tests/src/stream-muxer/base-test.ts @@ -10,7 +10,8 @@ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { isValidTick } from '../transport/utils/index.js' import type { DeferredPromise } from 'p-defer' import type { TestSetup } from '../index.js' -import type { Muxer, MuxerOptions, MuxedStream } from '@libp2p/interfaces/stream-muxer' +import type { Stream } from '@libp2p/interfaces/connection' +import type { Muxer, MuxerOptions } from '@libp2p/interfaces/stream-muxer' import type { Source, Duplex } from 'it-stream-types' async function drainAndClose (stream: Duplex) { @@ -22,8 +23,8 @@ export default (common: TestSetup) => { it('Open a stream from the dialer', async () => { const p = duplexPair() const dialer = await common.setup() - const onStreamPromise: DeferredPromise = defer() - const onStreamEndPromise: DeferredPromise = defer() + const onStreamPromise: DeferredPromise = defer() + const onStreamEndPromise: DeferredPromise = defer() const listener = await common.setup({ onStream: stream => { @@ -71,7 +72,7 @@ export default (common: TestSetup) => { it('Open a stream from the listener', async () => { const p = duplexPair() - const onStreamPromise: DeferredPromise = defer() + const onStreamPromise: DeferredPromise = defer() const dialer = await common.setup({ onStream: stream => { onStreamPromise.resolve(stream) @@ -99,8 +100,8 @@ export default (common: TestSetup) => { it('Open a stream on both sides', async () => { const p = duplexPair() - const onDialerStreamPromise: DeferredPromise = defer() - const onListenerStreamPromise: DeferredPromise = defer() + const onDialerStreamPromise: DeferredPromise = defer() + const onListenerStreamPromise: DeferredPromise = defer() const dialer = await common.setup({ onStream: stream => { onDialerStreamPromise.resolve(stream) @@ -134,8 +135,8 @@ export default (common: TestSetup) => { it('Open a stream on one side, write, open a stream on the other side', async () => { const toString = (source: Source) => map(source, (u) => uint8ArrayToString(u)) const p = duplexPair() - const onDialerStreamPromise: DeferredPromise = defer() - const onListenerStreamPromise: DeferredPromise = defer() + const onDialerStreamPromise: DeferredPromise = defer() + const onListenerStreamPromise: DeferredPromise = defer() const dialer = await common.setup({ onStream: stream => { onDialerStreamPromise.resolve(stream) diff --git a/packages/libp2p-interface-compliance-tests/src/transport/utils/index.ts b/packages/libp2p-interface-compliance-tests/src/transport/utils/index.ts index 2edd8f3e1..8c614f0a9 100644 --- a/packages/libp2p-interface-compliance-tests/src/transport/utils/index.ts +++ b/packages/libp2p-interface-compliance-tests/src/transport/utils/index.ts @@ -7,8 +7,8 @@ import drain from 'it-drain' import { Multiaddr } from '@multiformats/multiaddr' import { pipe } from 'it-pipe' import type { Upgrader, MultiaddrConnection } from '@libp2p/interfaces/transport' -import type { Connection, StreamData } from '@libp2p/interfaces/connection' -import type { MuxedStream, Muxer } from '@libp2p/interfaces/stream-muxer' +import type { Connection, Stream, Metadata, ProtocolStream } from '@libp2p/interfaces/connection' +import type { Muxer } from '@libp2p/interfaces/stream-muxer' import type { Duplex } from 'it-stream-types' /** @@ -46,7 +46,7 @@ export function mockMultiaddrConnection (source: Duplex): MultiaddrC export function mockMuxer (): Muxer { let streamId = 0 - let streams: MuxedStream[] = [] + let streams: Stream[] = [] const p = pushable() const muxer: Muxer = { @@ -61,7 +61,7 @@ export function mockMuxer (): Muxer { const echo = pair() const id = `${streamId++}` - const stream: MuxedStream = { + const stream: Stream = { id, sink: echo.sink, source: echo.source, @@ -116,7 +116,7 @@ async function createConnection (maConn: MultiaddrConnection, direction: 'inboun const remotePeerIdStr = remoteAddr.getPeerId() const remotePeer = remotePeerIdStr != null ? PeerId.fromString(remotePeerIdStr) : await PeerIdFactory.createEd25519PeerId() - const streams: MuxedStream[] = [] + const streams: Stream[] = [] let streamId = 0 const registry = new Map() @@ -140,13 +140,17 @@ async function createConnection (maConn: MultiaddrConnection, direction: 'inboun tags: [], streams, newStream: async (protocols) => { + if (!Array.isArray(protocols)) { + protocols = [protocols] + } + if (protocols.length === 0) { throw new Error('protocols must have a length') } const id = `${streamId++}` - const stream: MuxedStream = muxer.newStream(id) - const streamData = { + const stream: Stream = muxer.newStream(id) + const streamData: ProtocolStream = { protocol: protocols[0], stream } @@ -155,7 +159,7 @@ async function createConnection (maConn: MultiaddrConnection, direction: 'inboun return streamData }, - addStream: (muxedStream: MuxedStream, streamData: StreamData) => { + addStream: (stream: Stream, metadata: Metadata) => { }, removeStream: (id: string) => { diff --git a/packages/libp2p-interface-compliance-tests/src/utils/mock-connection-gater.ts b/packages/libp2p-interface-compliance-tests/src/utils/mock-connection-gater.ts new file mode 100644 index 000000000..1c7923e3a --- /dev/null +++ b/packages/libp2p-interface-compliance-tests/src/utils/mock-connection-gater.ts @@ -0,0 +1,14 @@ + +export function mockConnectionGater () { + return { + denyDialPeer: async () => await Promise.resolve(false), + denyDialMultiaddr: async () => await Promise.resolve(false), + denyInboundConnection: async () => await Promise.resolve(false), + denyOutboundConnection: async () => await Promise.resolve(false), + denyInboundEncryptedConnection: async () => await Promise.resolve(false), + denyOutboundEncryptedConnection: async () => await Promise.resolve(false), + denyInboundUpgradedConnection: async () => await Promise.resolve(false), + denyOutboundUpgradedConnection: async () => await Promise.resolve(false), + filterMultiaddrForPeer: async () => await Promise.resolve(true) + } +} diff --git a/packages/libp2p-interface-compliance-tests/src/utils/mock-connection.ts b/packages/libp2p-interface-compliance-tests/src/utils/mock-connection.ts new file mode 100644 index 000000000..ad70f5951 --- /dev/null +++ b/packages/libp2p-interface-compliance-tests/src/utils/mock-connection.ts @@ -0,0 +1,66 @@ +import { PeerId } from '@libp2p/peer-id' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { pipe } from 'it-pipe' +import type { MultiaddrConnection } from '@libp2p/interfaces/transport' +import type { Connection, Stream, Metadata, ProtocolStream } from '@libp2p/interfaces/connection' +import type { Muxer } from '@libp2p/interfaces/stream-muxer' + +export async function mockConnection (maConn: MultiaddrConnection, direction: 'inbound' | 'outbound', muxer: Muxer): Promise { + const remoteAddr = maConn.remoteAddr + const remotePeerIdStr = remoteAddr.getPeerId() + const remotePeer = remotePeerIdStr != null ? PeerId.fromString(remotePeerIdStr) : await createEd25519PeerId() + + const streams: Stream[] = [] + let streamId = 0 + + const registry = new Map() + + void pipe( + maConn, muxer, maConn + ) + + return { + id: 'mock-connection', + remoteAddr, + remotePeer, + stat: { + status: 'OPEN', + direction, + timeline: maConn.timeline, + multiplexer: 'test-multiplexer', + encryption: 'yes-yes-very-secure' + }, + registry, + tags: [], + streams, + newStream: async (protocols) => { + if (!Array.isArray(protocols)) { + protocols = [protocols] + } + + if (protocols.length === 0) { + throw new Error('protocols must have a length') + } + + const id = `${streamId++}` + const stream: Stream = muxer.newStream(id) + const streamData: ProtocolStream = { + protocol: protocols[0], + stream + } + + registry.set(id, streamData) + + return streamData + }, + addStream: (stream: Stream, metadata: Metadata) => { + + }, + removeStream: (id: string) => { + registry.delete(id) + }, + close: async () => { + await maConn.close() + } + } +} diff --git a/packages/libp2p-interface-compliance-tests/src/utils/mock-multiaddr-connection.ts b/packages/libp2p-interface-compliance-tests/src/utils/mock-multiaddr-connection.ts new file mode 100644 index 000000000..7e8144f0c --- /dev/null +++ b/packages/libp2p-interface-compliance-tests/src/utils/mock-multiaddr-connection.ts @@ -0,0 +1,18 @@ +import { Multiaddr } from '@multiformats/multiaddr' +import type { MultiaddrConnection } from '@libp2p/interfaces/transport' +import type { Duplex } from 'it-stream-types' + +export function mockMultiaddrConnection (source: Duplex): MultiaddrConnection { + const maConn: MultiaddrConnection = { + ...source, + async close () { + + }, + timeline: { + open: Date.now() + }, + remoteAddr: new Multiaddr('/ip4/127.0.0.1/tcp/4001') + } + + return maConn +} diff --git a/packages/libp2p-interface-compliance-tests/src/utils/mock-muxer.ts b/packages/libp2p-interface-compliance-tests/src/utils/mock-muxer.ts new file mode 100644 index 000000000..8526d4855 --- /dev/null +++ b/packages/libp2p-interface-compliance-tests/src/utils/mock-muxer.ts @@ -0,0 +1,43 @@ +import { pair } from 'it-pair' +import { pushable } from 'it-pushable' +import drain from 'it-drain' +import type { Stream } from '@libp2p/interfaces/connection' +import type { Muxer } from '@libp2p/interfaces/stream-muxer' + +export function mockMuxer (): Muxer { + let streamId = 0 + let streams: Stream[] = [] + const p = pushable() + + const muxer: Muxer = { + source: p, + sink: async (source) => { + await drain(source) + }, + get streams () { + return streams + }, + newStream: (name?: string) => { + const echo = pair() + + const id = `${streamId++}` + const stream: Stream = { + id, + sink: echo.sink, + source: echo.source, + close: () => { + streams = streams.filter(s => s !== stream) + }, + abort: () => {}, + reset: () => {}, + timeline: { + open: 0 + } + } + + return stream + } + } + + return muxer +} diff --git a/packages/libp2p-interface-compliance-tests/src/utils/mock-upgrader.ts b/packages/libp2p-interface-compliance-tests/src/utils/mock-upgrader.ts new file mode 100644 index 000000000..aa5879b2d --- /dev/null +++ b/packages/libp2p-interface-compliance-tests/src/utils/mock-upgrader.ts @@ -0,0 +1,33 @@ +import { expect } from 'aegir/utils/chai.js' +import { mockMuxer } from './mock-muxer.js' +import { mockConnection } from './mock-connection.js' +import type { Upgrader, MultiaddrConnection } from '@libp2p/interfaces/transport' +import type { Muxer } from '@libp2p/interfaces/stream-muxer' + +export interface MockUpgraderOptions { + muxer?: Muxer +} + +export function mockUpgrader (options: MockUpgraderOptions = {}) { + const ensureProps = (multiaddrConnection: MultiaddrConnection) => { + ['sink', 'source', 'remoteAddr', 'timeline', 'close'].forEach(prop => { + expect(multiaddrConnection).to.have.property(prop) + }) + return multiaddrConnection + } + + const muxer = options.muxer ?? mockMuxer() + + const upgrader: Upgrader = { + async upgradeOutbound (multiaddrConnection) { + ensureProps(multiaddrConnection) + return await mockConnection(multiaddrConnection, 'outbound', muxer) + }, + async upgradeInbound (multiaddrConnection) { + ensureProps(multiaddrConnection) + return await mockConnection(multiaddrConnection, 'inbound', muxer) + } + } + + return upgrader +} diff --git a/packages/libp2p-interface-compliance-tests/test/topology/mock-peer-store.ts b/packages/libp2p-interface-compliance-tests/test/topology/mock-peer-store.ts deleted file mode 100644 index a8d24abef..000000000 --- a/packages/libp2p-interface-compliance-tests/test/topology/mock-peer-store.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { EventEmitter } from 'events' -import type { PeerId } from '@libp2p/interfaces/peer-id' -import type { PeerData } from '@libp2p/interfaces/peer-data' -import type { ProtoBook, PeerStore } from '@libp2p/interfaces/registrar' - -export class MockPeerStore extends EventEmitter implements PeerStore { - public readonly peers: Map - public protoBook: ProtoBook - - constructor (peers: Map) { - super() - this.protoBook = { - get: () => ([]) - } - this.peers = peers - } - - get (peerId: PeerId) { - const peerData = this.peers.get(peerId.toString()) - - if (peerData == null) { - throw new Error('PeerData not found') - } - - return peerData - } -} diff --git a/packages/libp2p-interface-compliance-tests/test/topology/multicodec-topology.spec.ts b/packages/libp2p-interface-compliance-tests/test/topology/multicodec-topology.spec.ts deleted file mode 100644 index 7c3f793fa..000000000 --- a/packages/libp2p-interface-compliance-tests/test/topology/multicodec-topology.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'events' -import tests from '../../src/topology/multicodec-topology.js' -import { MulticodecTopology } from '@libp2p/topology/multicodec-topology' -import { MockPeerStore } from './mock-peer-store.js' - -describe('multicodec topology compliance tests', () => { - tests({ - async setup (properties) { - const multicodecs = ['/echo/1.0.0'] - const handlers = { - onConnect: () => { }, - onDisconnect: () => { } - } - - const topology = new MulticodecTopology({ - multicodecs, - handlers, - ...properties - }) - - const peers = new Map() - const peerStore = new MockPeerStore(peers) - const connectionManager = new EventEmitter() - - const registrar = { - peerStore, - connectionManager, - getConnection: () => { - return undefined - }, - handle: () => { - throw new Error('Not implemented') - }, - register: () => { - throw new Error('Not implemented') - }, - unregister: () => { - throw new Error('Not implemented') - } - } - - topology.registrar = registrar - - return topology - }, - async teardown () { - // cleanup resources created by setup() - } - }) -}) diff --git a/packages/libp2p-interface-compliance-tests/test/topology/topology.spec.ts b/packages/libp2p-interface-compliance-tests/test/topology/topology.spec.ts deleted file mode 100644 index cac6f5394..000000000 --- a/packages/libp2p-interface-compliance-tests/test/topology/topology.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import tests from '../../src/topology/topology.js' -import { Topology } from '@libp2p/topology' - -describe('topology compliance tests', () => { - tests({ - async setup (properties) { - const handlers = { - onConnect: () => { }, - onDisconnect: () => { } - } - - const topology = new Topology({ - handlers, - ...properties - }) - - return topology - }, - async teardown () { - // cleanup resources created by setup() - } - }) -}) diff --git a/packages/libp2p-interface-compliance-tests/tsconfig.json b/packages/libp2p-interface-compliance-tests/tsconfig.json index a657303f4..223af84bc 100644 --- a/packages/libp2p-interface-compliance-tests/tsconfig.json +++ b/packages/libp2p-interface-compliance-tests/tsconfig.json @@ -10,9 +10,6 @@ "test" ], "references": [ - { - "path": "../libp2p-connection" - }, { "path": "../libp2p-interfaces" }, diff --git a/packages/libp2p-interfaces/package.json b/packages/libp2p-interfaces/package.json index 1cb73305a..7b35dcd7f 100644 --- a/packages/libp2p-interfaces/package.json +++ b/packages/libp2p-interfaces/package.json @@ -96,6 +96,10 @@ "import": "./dist/src/peer-routing/index.js", "types": "./dist/src/peer-routing/index.d.ts" }, + "./peer-store": { + "import": "./dist/src/peer-store/index.js", + "types": "./dist/src/peer-store/index.d.ts" + }, "./pubsub": { "import": "./dist/src/pubsub/index.js", "types": "./dist/src/pubsub/index.d.ts" diff --git a/packages/libp2p-interfaces/src/connection/index.ts b/packages/libp2p-interfaces/src/connection/index.ts index a9884b228..cac1c1c86 100644 --- a/packages/libp2p-interfaces/src/connection/index.ts +++ b/packages/libp2p-interfaces/src/connection/index.ts @@ -1,7 +1,7 @@ import type { Multiaddr } from '@multiformats/multiaddr' import type { PeerId } from '../peer-id' -import type { MuxedStream } from '../stream-muxer' import type * as Status from './status.js' +import type { Duplex } from 'it-stream-types' export interface Timeline { open: number @@ -17,14 +17,29 @@ export interface ConnectionStat { status: keyof typeof Status } -export interface StreamData { +export interface Metadata { protocol: string metadata: Record } -export interface Stream { +/** + * A Stream is a data channel between two peers that + * can be written to and read from at both ends. + * + * It may be encrypted and multiplexed depending on the + * configuration of the nodes. + */ +export interface Stream extends Duplex { + close: () => void + abort: (err?: Error) => void + reset: () => void + timeline: Timeline + id: string +} + +export interface ProtocolStream { protocol: string - stream: MuxedStream + stream: Stream } /** @@ -38,12 +53,12 @@ export interface Connection { stat: ConnectionStat remoteAddr: Multiaddr remotePeer: PeerId - registry: Map + registry: Map tags: string[] - streams: MuxedStream[] + streams: Stream[] - newStream: (multicodecs: string[]) => Promise - addStream: (muxedStream: MuxedStream, streamData: StreamData) => void + newStream: (multicodecs: string[]) => Promise + addStream: (stream: Stream, data: Metadata) => void removeStream: (id: string) => void close: () => Promise } diff --git a/packages/libp2p-interfaces/src/dht/index.ts b/packages/libp2p-interfaces/src/dht/index.ts index 5a98ca10a..8fa151550 100644 --- a/packages/libp2p-interfaces/src/dht/index.ts +++ b/packages/libp2p-interfaces/src/dht/index.ts @@ -39,6 +39,7 @@ export interface DHTRecord { export interface QueryOptions extends AbortOptions { queryFuncTimeout?: number + minPeers?: number } /** @@ -134,7 +135,7 @@ export interface DHT { get: (key: Uint8Array, options?: QueryOptions) => AsyncIterable /** - * Find providers for a given CI + * Find providers of a given CID */ findProviders: (key: CID, options?: QueryOptions) => AsyncIterable diff --git a/packages/libp2p-interfaces/src/index.ts b/packages/libp2p-interfaces/src/index.ts index 69b213e60..52c92549d 100644 --- a/packages/libp2p-interfaces/src/index.ts +++ b/packages/libp2p-interfaces/src/index.ts @@ -1,3 +1,6 @@ +import type { PeerId } from './peer-id/index.js' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { Duplex } from 'it-stream-types' export interface AbortOptions { signal?: AbortSignal @@ -8,3 +11,13 @@ export interface Startable { stop: () => void | Promise isStarted: () => boolean } + +// Implemented by libp2p, should be moved to libp2p-interfaces eventually +export interface Dialer { + dialProtocol: (peer: PeerId, protocol: string, options?: { signal?: AbortSignal }) => Promise<{ stream: Duplex }> +} + +// Implemented by libp2p, should be moved to libp2p-interfaces eventually +export interface Addressable { + multiaddrs: Multiaddr[] +} diff --git a/packages/libp2p-interfaces/src/peer-store/index.ts b/packages/libp2p-interfaces/src/peer-store/index.ts new file mode 100644 index 000000000..116ad1708 --- /dev/null +++ b/packages/libp2p-interfaces/src/peer-store/index.ts @@ -0,0 +1,234 @@ +import type { PeerId } from '../peer-id/index.js' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { EventEmitter } from 'events' +import type { Envelope } from '../record/index.js' + +export interface Address { + /** + * Peer multiaddr + */ + multiaddr: Multiaddr + + /** + * Obtained from a signed peer record + */ + isCertified: boolean +} + +export interface Peer { + /** + * Peer's peer-id instance + */ + id: PeerId + + /** + * Peer's addresses containing its multiaddrs and metadata + */ + addresses: Address[] + + /** + * Peer's supported protocols + */ + protocols: string[] + + /** + * Peer's metadata map + */ + metadata: Map + + /** + * May be set if the key that this Peer has is an RSA key + */ + pubKey?: Uint8Array + + /** + * The last peer record envelope received + */ + peerRecordEnvelope?: Uint8Array +} + +export interface CertifiedRecord { + raw: Uint8Array + seqNumber: number +} + +export interface AddressBookEntry { + addresses: Address[] + record: CertifiedRecord +} + +export interface Book { + /** + * Get the known data of a peer + */ + get: (peerId: PeerId) => Promise + + /** + * Set the known data of a peer + */ + set: (peerId: PeerId, data: Type) => Promise + + /** + * Remove the known data of a peer + */ + delete: (peerId: PeerId) => Promise +} + +/** + * AddressBook containing a map of peerIdStr to Address. + */ +export interface AddressBook { + /** + * ConsumePeerRecord adds addresses from a signed peer record contained in a record envelope. + * This will return a boolean that indicates if the record was successfully processed and added + * into the AddressBook + */ + consumePeerRecord: (envelope: Envelope) => Promise + + /** + * Get the raw Envelope for a peer. Returns + * undefined if no Envelope is found + */ + getRawEnvelope: (peerId: PeerId) => Promise + + /** + * Get an Envelope containing a PeerRecord for the given peer. + * Returns undefined if no record exists. + */ + getPeerRecord: (peerId: PeerId) => Promise + + /** + * Add known addresses of a provided peer. + * If the peer is not known, it is set with the given addresses. + */ + add: (peerId: PeerId, multiaddrs: Multiaddr[]) => Promise + + /** + * Set the known addresses of a peer + */ + set: (peerId: PeerId, data: Multiaddr[]) => Promise + + /** + * Return the known addresses of a peer + */ + get: (peerId: PeerId) => Promise + + /** + * Remove stored addresses of a peer + */ + delete: (peerId: PeerId) => Promise + + /** + * Get the known multiaddrs for a given peer. All returned multiaddrs + * will include the encapsulated `PeerId` of the peer. + */ + getMultiaddrsForPeer: (peerId: PeerId, addressSorter?: (ms: Address[]) => Address[]) => Promise +} + +/** + * KeyBook containing a map of peerIdStr to their PeerId with public keys. + */ +export interface KeyBook { + /** + * Get the known data of a peer + */ + get: (peerId: PeerId) => Promise + + /** + * Set the known data of a peer + */ + set: (peerId: PeerId, data: Uint8Array) => Promise + + /** + * Remove the known data of a peer + */ + delete: (peerId: PeerId) => Promise +} + +/** + * MetadataBook containing a map of peerIdStr to their metadata Map. + */ +export interface MetadataBook extends Book> { + /** + * Set a specific metadata value + */ + setValue: (peerId: PeerId, key: string, value: Uint8Array) => Promise + + /** + * Get specific metadata value, if it exists + */ + getValue: (peerId: PeerId, key: string) => Promise + + /** + * Deletes the provided peer metadata key from the book + */ + deleteValue: (peerId: PeerId, key: string) => Promise +} + +/** + * ProtoBook containing a map of peerIdStr to supported protocols. + */ +export interface ProtoBook extends Book { + /** + * Adds known protocols of a provided peer. + * If the peer was not known before, it will be added. + */ + add: (peerId: PeerId, protocols: string[]) => Promise + + /** + * Removes known protocols of a provided peer. + * If the protocols did not exist before, nothing will be done. + */ + remove: (peerId: PeerId, protocols: string[]) => Promise +} + +export interface PeerProtocolsChangeEvent { + peerId: PeerId + protocols: string[] +} + +export interface PeerMultiaddrsChangeEvent { + peerId: PeerId + multiaddrs: Multiaddr[] +} + +export interface PeerPublicKeyChangeEvent { + peerId: PeerId + pubKey?: Uint8Array +} + +export interface PeerMetadataChangeEvent { + peerId: PeerId + metadata: Map +} + +export type EventName = 'peer' | 'change:protocols' | 'change:multiaddrs' | 'change:pubkey' | 'change:metadata' + +export interface PeerStoreEvents { + 'peer': (event: PeerId) => void + 'change:protocols': (event: PeerProtocolsChangeEvent) => void + 'change:multiaddrs': (event: PeerMultiaddrsChangeEvent) => void + 'change:pubkey': (event: PeerPublicKeyChangeEvent) => void + 'change:metadata': (event: PeerMetadataChangeEvent) => void +} + +export interface PeerStore extends EventEmitter { + addressBook: AddressBook + keyBook: KeyBook + metadataBook: MetadataBook + protoBook: ProtoBook + + getPeers: () => AsyncIterable + delete: (peerId: PeerId) => Promise + has: (peerId: PeerId) => Promise + get: (peerId: PeerId) => Promise + on: ( + event: U, listener: PeerStoreEvents[U] + ) => this + once: ( + event: U, listener: PeerStoreEvents[U] + ) => this + emit: ( + event: U, ...args: Parameters + ) => boolean +} diff --git a/packages/libp2p-interfaces/src/record/index.ts b/packages/libp2p-interfaces/src/record/index.ts index 38ad3e509..a5ce29ed8 100644 --- a/packages/libp2p-interfaces/src/record/index.ts +++ b/packages/libp2p-interfaces/src/record/index.ts @@ -1,3 +1,5 @@ +import type { PeerId } from '../peer-id/index.js' + /** * Record is the base implementation of a record that can be used as the payload of a libp2p envelope. */ @@ -17,5 +19,16 @@ export interface Record { /** * Verifies if the other provided Record is identical to this one. */ - equals: (other: unknown) => boolean + equals: (other: Record) => boolean +} + +export interface Envelope { + peerId: PeerId + payloadType: Uint8Array + payload: Uint8Array + signature: Uint8Array + + marshal: () => Uint8Array + validate: (domain: string) => Promise + equals: (other: Envelope) => boolean } diff --git a/packages/libp2p-interfaces/src/registrar/index.ts b/packages/libp2p-interfaces/src/registrar/index.ts index 5ce8ffd4f..c44a6b5a3 100644 --- a/packages/libp2p-interfaces/src/registrar/index.ts +++ b/packages/libp2p-interfaces/src/registrar/index.ts @@ -1,32 +1,17 @@ -import type { Connection } from '../connection' -import type { MuxedStream } from '../stream-muxer' +import type { Connection, Stream } from '../connection' import type { PeerId } from '../peer-id' -import type { PeerData } from '../peer-data' +import type { PeerStore } from '../peer-store' export interface IncomingStreamEvent { protocol: string - stream: MuxedStream + stream: Stream connection: Connection } -export interface ChangeProtocolsEvent { - peerId: PeerId - protocols: string[] -} - -export interface ProtoBook { - get: (peerId: PeerId) => string[] -} - -export interface PeerStore { - on: (event: 'change:protocols', handler: (event: ChangeProtocolsEvent) => void) => void - protoBook: ProtoBook - peers: Map - get: (peerId: PeerId) => PeerData -} - export interface Registrar { - handle: (multicodecs: string[], handler: (event: IncomingStreamEvent) => void) => void + handle: (multicodec: string | string[], handler: (event: IncomingStreamEvent) => void) => void + unhandle: (multicodec: string) => void + register: (topology: any) => string unregister: (id: string) => void getConnection: (peerId: PeerId) => Connection | undefined diff --git a/packages/libp2p-interfaces/src/stream-muxer/index.ts b/packages/libp2p-interfaces/src/stream-muxer/index.ts index 744885a60..006bc7382 100644 --- a/packages/libp2p-interfaces/src/stream-muxer/index.ts +++ b/packages/libp2p-interfaces/src/stream-muxer/index.ts @@ -1,4 +1,5 @@ import type { Duplex } from 'it-stream-types' +import type { Stream } from '../connection/index.js' export interface MuxerFactory { new (options: MuxerOptions): Muxer @@ -9,36 +10,23 @@ export interface MuxerFactory { * A libp2p stream muxer */ export interface Muxer extends Duplex { - readonly streams: MuxedStream[] + readonly streams: Stream[] /** * Initiate a new stream with the given name. If no name is - * provided, the id of th stream will be used. + * provided, the id of the stream will be used. */ - newStream: (name?: string) => MuxedStream + newStream: (name?: string) => Stream } export interface MuxerOptions { /** * A function called when receiving a new stream from the remote. */ - onStream?: (stream: MuxedStream) => void + onStream?: (stream: Stream) => void /** * A function called when a stream ends. */ - onStreamEnd?: (stream: MuxedStream) => void + onStreamEnd?: (stream: Stream) => void maxMsgSize?: number } - -export interface MuxedTimeline { - open: number - close?: number -} - -export interface MuxedStream extends Duplex { - close: () => void - abort: (err?: Error) => void - reset: () => void - timeline: MuxedTimeline - id: string -} diff --git a/packages/libp2p-interfaces/src/transport/index.ts b/packages/libp2p-interfaces/src/transport/index.ts index fbe072957..01a063964 100644 --- a/packages/libp2p-interfaces/src/transport/index.ts +++ b/packages/libp2p-interfaces/src/transport/index.ts @@ -79,3 +79,7 @@ export interface MultiaddrConnection extends Duplex { remoteAddr: Multiaddr timeline: MultiaddrConnectionTimeline } + +export interface ProtocolHandler { + (stream: Duplex, connection: Connection): void +} diff --git a/packages/libp2p-interfaces/tsconfig.json b/packages/libp2p-interfaces/tsconfig.json index 3fd0c254e..5b4acb638 100644 --- a/packages/libp2p-interfaces/tsconfig.json +++ b/packages/libp2p-interfaces/tsconfig.json @@ -7,10 +7,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../libp2p-peer-id" - } ] } diff --git a/packages/libp2p-peer-id/package.json b/packages/libp2p-peer-id/package.json index 103098147..a640be6cd 100644 --- a/packages/libp2p-peer-id/package.json +++ b/packages/libp2p-peer-id/package.json @@ -133,6 +133,7 @@ }, "dependencies": { "@libp2p/interfaces": "^1.0.0", + "err-code": "^3.0.1", "multiformats": "^9.4.5", "uint8arrays": "^3.0.0" }, diff --git a/packages/libp2p-peer-id/src/index.ts b/packages/libp2p-peer-id/src/index.ts index 7834f5e5f..eaa60a3dc 100644 --- a/packages/libp2p-peer-id/src/index.ts +++ b/packages/libp2p-peer-id/src/index.ts @@ -4,9 +4,10 @@ import { base58btc } from 'multiformats/bases/base58' import * as Digest from 'multiformats/hashes/digest' import { identity } from 'multiformats/hashes/identity' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { sha256 } from 'multiformats/hashes/sha2' +import errcode from 'err-code' import type { MultibaseDecoder, MultibaseEncoder } from 'multiformats/bases/interface' import type { MultihashDigest } from 'multiformats/hashes/interface' -import { sha256 } from 'multiformats/hashes/sha2' const baseDecoder = Object .values(bases) @@ -88,6 +89,24 @@ export class PeerId { } } + static fromPeerId (other: any) { + const err = errcode(new Error('Not a PeerId'), 'ERR_INVALID_PARAMETERS') + + if (other.type === 'RSA') { + return new RSAPeerId(other) + } + + if (other.type === 'Ed25519') { + return new Ed25519PeerId(other) + } + + if (other.type === 'secp256k1') { + return new Secp256k1PeerId(other) + } + + throw err + } + static fromString (str: string, decoder?: MultibaseDecoder) { decoder = decoder ?? baseDecoder diff --git a/packages/libp2p-peer-record/LICENSE b/packages/libp2p-peer-record/LICENSE new file mode 100644 index 000000000..20ce483c8 --- /dev/null +++ b/packages/libp2p-peer-record/LICENSE @@ -0,0 +1,4 @@ +This project is dual licensed under MIT and Apache-2.0. + +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/packages/libp2p-peer-record/LICENSE-APACHE b/packages/libp2p-peer-record/LICENSE-APACHE new file mode 100644 index 000000000..14478a3b6 --- /dev/null +++ b/packages/libp2p-peer-record/LICENSE-APACHE @@ -0,0 +1,5 @@ +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/packages/libp2p-peer-record/LICENSE-MIT b/packages/libp2p-peer-record/LICENSE-MIT new file mode 100644 index 000000000..72dc60d84 --- /dev/null +++ b/packages/libp2p-peer-record/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/libp2p-peer-record/README.md b/packages/libp2p-peer-record/README.md new file mode 100644 index 000000000..b6c6c9e41 --- /dev/null +++ b/packages/libp2p-peer-record/README.md @@ -0,0 +1,179 @@ +# libp2p-peer-record + +> Peer records are signed records that contain the address information of network peers + +## Table of Contents + +- [Description](#description) + - [Envelope](#envelope) +- [Usage](#usage) +- [Peer Record](#peer-record) + - [Usage](#usage-1) + - [Libp2p Flows](#libp2p-flows) + - [Self Record](#self-record) + - [Self record Updates](#self-record-updates) + - [Subsystem receiving a record](#subsystem-receiving-a-record) + - [Subsystem providing a record](#subsystem-providing-a-record) + - [Future Work](#future-work) +- [Example](#example) +- [Installation](#installation) +- [License](#license) + - [Contribution](#contribution) + +## Description + +Libp2p nodes need to store data in a public location (e.g. a DHT), or rely on potentially untrustworthy intermediaries to relay information over its lifetime. Accordingly, libp2p nodes need to be able to verify that the data came from a specific peer and that it hasn't been tampered with. + +### Envelope + +Libp2p provides an all-purpose data container called **envelope**. It was created to enable the distribution of verifiable records, which we can prove originated from the addressed peer itself. The envelope includes a signature of the data, so that its authenticity is verified. + +This envelope stores a marshaled record implementing the [interface-record](https://github.com/libp2p/js-libp2p-interfaces/tree/master/src/record). These Records are designed to be serialized to bytes and placed inside of the envelopes before being shared with other peers. + +You can read further about the envelope in [libp2p/specs#217](https://github.com/libp2p/specs/pull/217). + +## Usage + +- create an envelope with an instance of an [interface-record](https://github.com/libp2p/js-libp2p-interfaces/tree/master/src/record) implementation and prepare it for being exchanged: + +```js +// interface-record implementation example with the "libp2p-example" namespace +const Record = require('libp2p-interfaces/src/record') +const { fromString } = require('uint8arrays/from-string') + +class ExampleRecord extends Record { + constructor () { + super ('libp2p-example', fromString('0302', 'hex')) + } + + marshal () {} + + equals (other) {} +} + +ExampleRecord.createFromProtobuf = () => {} +``` + +```js +const Envelope = require('libp2p/src/record/envelop') +const ExampleRecord = require('./example-record') + +const rec = new ExampleRecord() +const e = await Envelope.seal(rec, peerId) +const wireData = e.marshal() +``` + +- consume a received envelope (`wireData`) and transform it back to a record: + +```js +const Envelope = require('libp2p/src/record/envelop') +const ExampleRecord = require('./example-record') + +const domain = 'libp2p-example' +let e + +try { + e = await Envelope.openAndCertify(wireData, domain) +} catch (err) {} + +const rec = ExampleRecord.createFromProtobuf(e.payload) +``` + +## Peer Record + +All libp2p nodes keep a `PeerStore`, that among other information stores a set of known addresses for each peer, which can come from a variety of sources. + +Libp2p peer records were created to enable the distribution of verifiable address records, which we can prove originated from the addressed peer itself. With such guarantees, libp2p is able to prioritize addresses based on their authenticity, with the most strict strategy being to only dial certified addresses (no strategies have been implemented at the time of writing). + +A peer record contains the peers' publicly reachable listen addresses, and may be extended in the future to contain additional metadata relevant to routing. It also contains a `seqNumber` field, a timestamp per the spec, so that we can verify the most recent record. + +You can read further about the Peer Record in [libp2p/specs#217](https://github.com/libp2p/specs/pull/217). + +### Usage + +- create a new Peer Record + +```js +const PeerRecord = require('libp2p/src/record/peer-record') + +const pr = new PeerRecord({ + peerId: node.peerId, + multiaddrs: node.multiaddrs +}) +``` + +- create a Peer Record from a protobuf + +```js +const PeerRecord = require('libp2p/src/record/peer-record') + +const pr = PeerRecord.createFromProtobuf(data) +``` + +### Libp2p Flows + +#### Self Record + +Once a libp2p node has started and is listening on a set of multiaddrs, its own peer record can be created. + +The identify service is responsible for creating the self record when the identify protocol kicks in for the first time. This record will be stored for future needs of the identify protocol when connecting with other peers. + +#### Self record Updates + +**_NOT_YET_IMPLEMENTED_** + +While creating peer records is fairly trivial, addresses are not static and might be modified at arbitrary times. This can happen via an Address Manager API, or even through AutoRelay/AutoNAT. + +When a libp2p node changes its listen addresses, the identify service will be informed. Once that happens, the identify service creates a new self record and stores it. With the new record, the identify push/delta protocol will be used to communicate this change to the connected peers. + +#### Subsystem receiving a record + +Considering that a node can discover other peers' addresses from a variety of sources, Libp2p Peerstore can differentiate the addresses that were obtained through a signed peer record. + +Once a record is received and its signature properly validated, its envelope is stored in the AddressBook in its byte representation. The `seqNumber` remains unmarshalled so that we can quickly compare it against incoming records to determine the most recent record. + +The AddressBook Addresses will be updated with the content of the envelope with a certified property. This allows other subsystems to identify the known certified addresses of a peer. + +#### Subsystem providing a record + +Libp2p subsystems that exchange other peers information will provide the envelope that they received by those peers. As a result, other peers can verify if the envelope was really created by the addressed peer. + +When a subsystem wants to provide a record, it will get it from the AddressBook, if it exists. Other subsystems are also able to provide the self record, since it is also stored in the AddressBook. + +### Future Work + +- Persistence only considering certified addresses? +- Peers may not know their own addresses. It's often impossible to automatically infer one's own public address, and peers may need to rely on third party peers to inform them of their observed public addresses. +- A peer may inadvertently or maliciously sign an address that they do not control. In other words, a signature isn't a guarantee that a given address is valid. +- Some addresses may be ambiguous. For example, addresses on a private subnet are valid within that subnet but are useless on the public internet. +- Once all these pieces are in place, we will also need a way to prioritize addresses based on their authenticity, that is, the dialer can prioritize self-certified addresses over addresses from an unknown origin. + - Modular dialer? (taken from go PR notes) + - With the modular dialer, users should easily be able to configure precedence. With dialer v1, anything we do to prioritise dials is gonna be spaghetti and adhoc. With the modular dialer, you’d be able to specify the order of dials when instantiating the pipeline. + - Multiple parallel dials. We already have the issue where new addresses aren't added to existing dials. + +## Example + +```JavaScript +import { trackedMap } from '@libp2p/tracked-map' + +const map = trackedMap({ metrics }) + +map.set('key', 'value') +``` + +## Installation + +```console +$ npm i @libp2p/peer-record +``` + +## License + +Licensed under either of + + * Apache 2.0, ([LICENSE-APACHE](LICENSE-APACHE) / http://www.apache.org/licenses/LICENSE-2.0) + * MIT ([LICENSE-MIT](LICENSE-MIT) / http://opensource.org/licenses/MIT) + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/packages/libp2p-peer-record/package.json b/packages/libp2p-peer-record/package.json new file mode 100644 index 000000000..7fe50430e --- /dev/null +++ b/packages/libp2p-peer-record/package.json @@ -0,0 +1,173 @@ +{ + "name": "@libp2p/peer-record", + "version": "0.0.0", + "description": "Used to transfer signed peer data across the network", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p-interfaces/tree/master/packages/libp2p-peer-record#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/libp2p/js-libp2p-interfaces.git" + }, + "bugs": { + "url": "https://github.com/libp2p/js-libp2p-interfaces/issues" + }, + "keywords": [ + "IPFS" + ], + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + }, + "type": "module", + "types": "./dist/src/index.d.ts", + "files": [ + "src", + "dist/src", + "!dist/test", + "!**/*.tsbuildinfo" + ], + "exports": { + ".": { + "import": "./dist/src/index.js" + } + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "sourceType": "module" + }, + "ignorePatterns": [ + "src/envelope/*.d.ts", + "src/envelope/envelope.js", + "src/peer-record/*.d.ts", + "src/peer-record/peer-record.js" + ] + }, + "release": { + "branches": [ + "master" + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { + "breaking": true, + "release": "major" + }, + { + "revert": true, + "release": "patch" + }, + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "chore", + "release": "patch" + }, + { + "type": "docs", + "release": "patch" + }, + { + "type": "test", + "release": "patch" + }, + { + "scope": "no-release", + "release": false + } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "chore", + "section": "Trivial Changes" + }, + { + "type": "docs", + "section": "Trivial Changes" + }, + { + "type": "test", + "section": "Tests" + } + ] + } + } + ], + "@semantic-release/changelog", + "@semantic-release/npm", + "@semantic-release/github", + "@semantic-release/git" + ] + }, + "scripts": { + "lint": "aegir lint", + "dep-check": "aegir dep-check dist/src/**/*.js", + "build": "tsc", + "postbuild": "npm run build:copy-proto-files", + "generate": "npm run generate:envelope && npm run generate:envelope-types && npm run generate:peer-record && npm run generate:peer-record-types", + "generate:envelope": "pbjs -t static-module -w es6 -r libp2p-peer-record-envelope --force-number --no-verify --no-delimited --no-create --no-beautify --no-defaults --lint eslint-disable -o src/envelope/envelope.js ./src/envelope/envelope.proto", + "generate:envelope-types": "pbts -o src/envelope/envelope.d.ts src/envelope/envelope.js", + "generate:peer-record": "pbjs -t static-module -w es6 -r libp2p-peer-record --force-number --no-verify --no-delimited --no-create --no-beautify --no-defaults --lint eslint-disable -o src/peer-record/peer-record.js ./src/peer-record/peer-record.proto", + "generate:peer-record-types": "pbts -o src/peer-record/peer-record.d.ts src/peer-record/peer-record.js", + "build:copy-proto-files": "cp src/envelope/envelope.js dist/src/envelope && cp src/envelope/*.d.ts dist/src/envelope && cp src/peer-record/peer-record.js dist/src/peer-record && cp src/peer-record/*.d.ts dist/src/peer-record", + "pretest": "npm run build", + "test": "aegir test -f ./dist/test", + "test:chrome": "npm run test -- -t browser", + "test:chrome-webworker": "npm run test -- -t webworker", + "test:firefox": "npm run test -- -t browser -- --browser firefox", + "test:firefox-webworker": "npm run test -- -t webworker -- --browser firefox", + "test:node": "npm run test -- -t node --cov", + "test:electron-main": "npm run test -- -t electron-main" + }, + "dependencies": { + "@libp2p/crypto": "^0.22.7", + "@libp2p/interfaces": "^1.2.0", + "@libp2p/logger": "^1.0.1", + "@libp2p/peer-id": "^1.0.4", + "@libp2p/utils": "^1.0.5", + "@multiformats/multiaddr": "^10.1.5", + "err-code": "^3.0.1", + "interface-datastore": "^6.1.0", + "it-all": "^1.0.6", + "it-filter": "^1.0.3", + "it-foreach": "^0.1.1", + "it-map": "^1.0.6", + "it-pipe": "^2.0.3", + "multiformats": "^9.6.3", + "protobufjs": "^6.10.2", + "uint8arrays": "^3.0.0", + "varint": "^6.0.0" + }, + "devDependencies": { + "@libp2p/interface-compliance-tests": "^1.0.8", + "@libp2p/peer-id-factory": "^1.0.3", + "@types/varint": "^6.0.0", + "aegir": "^36.1.3", + "sinon": "^13.0.1" + } +} diff --git a/packages/libp2p-peer-record/src/envelope/envelope.d.ts b/packages/libp2p-peer-record/src/envelope/envelope.d.ts new file mode 100644 index 000000000..440590c14 --- /dev/null +++ b/packages/libp2p-peer-record/src/envelope/envelope.d.ts @@ -0,0 +1,77 @@ +import * as $protobuf from "protobufjs"; +/** Properties of an Envelope. */ +export interface IEnvelope { + + /** Envelope publicKey */ + publicKey?: (Uint8Array|null); + + /** Envelope payloadType */ + payloadType?: (Uint8Array|null); + + /** Envelope payload */ + payload?: (Uint8Array|null); + + /** Envelope signature */ + signature?: (Uint8Array|null); +} + +/** Represents an Envelope. */ +export class Envelope implements IEnvelope { + + /** + * Constructs a new Envelope. + * @param [p] Properties to set + */ + constructor(p?: IEnvelope); + + /** Envelope publicKey. */ + public publicKey: Uint8Array; + + /** Envelope payloadType. */ + public payloadType: Uint8Array; + + /** Envelope payload. */ + public payload: Uint8Array; + + /** Envelope signature. */ + public signature: Uint8Array; + + /** + * Encodes the specified Envelope message. Does not implicitly {@link Envelope.verify|verify} messages. + * @param m Envelope message or plain object to encode + * @param [w] Writer to encode to + * @returns Writer + */ + public static encode(m: IEnvelope, w?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an Envelope message from the specified reader or buffer. + * @param r Reader or buffer to decode from + * @param [l] Message length if known beforehand + * @returns Envelope + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): Envelope; + + /** + * Creates an Envelope message from a plain object. Also converts values to their respective internal types. + * @param d Plain object + * @returns Envelope + */ + public static fromObject(d: { [k: string]: any }): Envelope; + + /** + * Creates a plain object from an Envelope message. Also converts values to other types if specified. + * @param m Envelope + * @param [o] Conversion options + * @returns Plain object + */ + public static toObject(m: Envelope, o?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Envelope to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; +} diff --git a/packages/libp2p-peer-record/src/envelope/envelope.js b/packages/libp2p-peer-record/src/envelope/envelope.js new file mode 100644 index 000000000..9a21dc901 --- /dev/null +++ b/packages/libp2p-peer-record/src/envelope/envelope.js @@ -0,0 +1,241 @@ +/*eslint-disable*/ +import $protobuf from "protobufjs/minimal.js"; + +// Common aliases +const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util; + +// Exported root namespace +const $root = $protobuf.roots["libp2p-peer-record-envelope"] || ($protobuf.roots["libp2p-peer-record-envelope"] = {}); + +export const Envelope = $root.Envelope = (() => { + + /** + * Properties of an Envelope. + * @exports IEnvelope + * @interface IEnvelope + * @property {Uint8Array|null} [publicKey] Envelope publicKey + * @property {Uint8Array|null} [payloadType] Envelope payloadType + * @property {Uint8Array|null} [payload] Envelope payload + * @property {Uint8Array|null} [signature] Envelope signature + */ + + /** + * Constructs a new Envelope. + * @exports Envelope + * @classdesc Represents an Envelope. + * @implements IEnvelope + * @constructor + * @param {IEnvelope=} [p] Properties to set + */ + function Envelope(p) { + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + /** + * Envelope publicKey. + * @member {Uint8Array} publicKey + * @memberof Envelope + * @instance + */ + Envelope.prototype.publicKey = $util.newBuffer([]); + + /** + * Envelope payloadType. + * @member {Uint8Array} payloadType + * @memberof Envelope + * @instance + */ + Envelope.prototype.payloadType = $util.newBuffer([]); + + /** + * Envelope payload. + * @member {Uint8Array} payload + * @memberof Envelope + * @instance + */ + Envelope.prototype.payload = $util.newBuffer([]); + + /** + * Envelope signature. + * @member {Uint8Array} signature + * @memberof Envelope + * @instance + */ + Envelope.prototype.signature = $util.newBuffer([]); + + /** + * Encodes the specified Envelope message. Does not implicitly {@link Envelope.verify|verify} messages. + * @function encode + * @memberof Envelope + * @static + * @param {IEnvelope} m Envelope message or plain object to encode + * @param {$protobuf.Writer} [w] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + Envelope.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.publicKey != null && Object.hasOwnProperty.call(m, "publicKey")) + w.uint32(10).bytes(m.publicKey); + if (m.payloadType != null && Object.hasOwnProperty.call(m, "payloadType")) + w.uint32(18).bytes(m.payloadType); + if (m.payload != null && Object.hasOwnProperty.call(m, "payload")) + w.uint32(26).bytes(m.payload); + if (m.signature != null && Object.hasOwnProperty.call(m, "signature")) + w.uint32(42).bytes(m.signature); + return w; + }; + + /** + * Decodes an Envelope message from the specified reader or buffer. + * @function decode + * @memberof Envelope + * @static + * @param {$protobuf.Reader|Uint8Array} r Reader or buffer to decode from + * @param {number} [l] Message length if known beforehand + * @returns {Envelope} Envelope + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + Envelope.decode = function decode(r, l) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.Envelope(); + while (r.pos < c) { + var t = r.uint32(); + switch (t >>> 3) { + case 1: + m.publicKey = r.bytes(); + break; + case 2: + m.payloadType = r.bytes(); + break; + case 3: + m.payload = r.bytes(); + break; + case 5: + m.signature = r.bytes(); + break; + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + /** + * Creates an Envelope message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Envelope + * @static + * @param {Object.} d Plain object + * @returns {Envelope} Envelope + */ + Envelope.fromObject = function fromObject(d) { + if (d instanceof $root.Envelope) + return d; + var m = new $root.Envelope(); + if (d.publicKey != null) { + if (typeof d.publicKey === "string") + $util.base64.decode(d.publicKey, m.publicKey = $util.newBuffer($util.base64.length(d.publicKey)), 0); + else if (d.publicKey.length) + m.publicKey = d.publicKey; + } + if (d.payloadType != null) { + if (typeof d.payloadType === "string") + $util.base64.decode(d.payloadType, m.payloadType = $util.newBuffer($util.base64.length(d.payloadType)), 0); + else if (d.payloadType.length) + m.payloadType = d.payloadType; + } + if (d.payload != null) { + if (typeof d.payload === "string") + $util.base64.decode(d.payload, m.payload = $util.newBuffer($util.base64.length(d.payload)), 0); + else if (d.payload.length) + m.payload = d.payload; + } + if (d.signature != null) { + if (typeof d.signature === "string") + $util.base64.decode(d.signature, m.signature = $util.newBuffer($util.base64.length(d.signature)), 0); + else if (d.signature.length) + m.signature = d.signature; + } + return m; + }; + + /** + * Creates a plain object from an Envelope message. Also converts values to other types if specified. + * @function toObject + * @memberof Envelope + * @static + * @param {Envelope} m Envelope + * @param {$protobuf.IConversionOptions} [o] Conversion options + * @returns {Object.} Plain object + */ + Envelope.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.defaults) { + if (o.bytes === String) + d.publicKey = ""; + else { + d.publicKey = []; + if (o.bytes !== Array) + d.publicKey = $util.newBuffer(d.publicKey); + } + if (o.bytes === String) + d.payloadType = ""; + else { + d.payloadType = []; + if (o.bytes !== Array) + d.payloadType = $util.newBuffer(d.payloadType); + } + if (o.bytes === String) + d.payload = ""; + else { + d.payload = []; + if (o.bytes !== Array) + d.payload = $util.newBuffer(d.payload); + } + if (o.bytes === String) + d.signature = ""; + else { + d.signature = []; + if (o.bytes !== Array) + d.signature = $util.newBuffer(d.signature); + } + } + if (m.publicKey != null && m.hasOwnProperty("publicKey")) { + d.publicKey = o.bytes === String ? $util.base64.encode(m.publicKey, 0, m.publicKey.length) : o.bytes === Array ? Array.prototype.slice.call(m.publicKey) : m.publicKey; + } + if (m.payloadType != null && m.hasOwnProperty("payloadType")) { + d.payloadType = o.bytes === String ? $util.base64.encode(m.payloadType, 0, m.payloadType.length) : o.bytes === Array ? Array.prototype.slice.call(m.payloadType) : m.payloadType; + } + if (m.payload != null && m.hasOwnProperty("payload")) { + d.payload = o.bytes === String ? $util.base64.encode(m.payload, 0, m.payload.length) : o.bytes === Array ? Array.prototype.slice.call(m.payload) : m.payload; + } + if (m.signature != null && m.hasOwnProperty("signature")) { + d.signature = o.bytes === String ? $util.base64.encode(m.signature, 0, m.signature.length) : o.bytes === Array ? Array.prototype.slice.call(m.signature) : m.signature; + } + return d; + }; + + /** + * Converts this Envelope to JSON. + * @function toJSON + * @memberof Envelope + * @instance + * @returns {Object.} JSON object + */ + Envelope.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return Envelope; +})(); + +export { $root as default }; diff --git a/packages/libp2p-peer-record/src/envelope/envelope.proto b/packages/libp2p-peer-record/src/envelope/envelope.proto new file mode 100644 index 000000000..5b80cf504 --- /dev/null +++ b/packages/libp2p-peer-record/src/envelope/envelope.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +message Envelope { + // public_key is the public key of the keypair the enclosed payload was + // signed with. + bytes public_key = 1; + + // payload_type encodes the type of payload, so that it can be deserialized + // deterministically. + bytes payload_type = 2; + + // payload is the actual payload carried inside this envelope. + bytes payload = 3; + + // signature is the signature produced by the private key corresponding to + // the enclosed public key, over the payload, prefixing a domain string for + // additional security. + bytes signature = 5; +} \ No newline at end of file diff --git a/packages/libp2p-peer-record/src/envelope/index.ts b/packages/libp2p-peer-record/src/envelope/index.ts new file mode 100644 index 000000000..4dba02870 --- /dev/null +++ b/packages/libp2p-peer-record/src/envelope/index.ts @@ -0,0 +1,159 @@ +import errCode from 'err-code' +import { concat as uint8arraysConcat } from 'uint8arrays/concat' +import { fromString as uint8arraysFromString } from 'uint8arrays/from-string' +import { unmarshalPrivateKey, unmarshalPublicKey } from '@libp2p/crypto/keys' +import varint from 'varint' +import { equals as uint8arraysEquals } from 'uint8arrays/equals' +import { codes } from '../errors.js' +import { Envelope as Protobuf } from './envelope.js' +import { PeerId } from '@libp2p/peer-id' +import type { Record, Envelope } from '@libp2p/interfaces/record' + +export interface EnvelopeOptions { + peerId: PeerId + payloadType: Uint8Array + payload: Uint8Array + signature: Uint8Array +} + +export class RecordEnvelope implements Envelope { + /** + * Unmarshal a serialized Envelope protobuf message + */ + static createFromProtobuf = async (data: Uint8Array) => { + const envelopeData = Protobuf.decode(data) + const peerId = await PeerId.fromKeys(envelopeData.publicKey) + + return new RecordEnvelope({ + peerId, + payloadType: envelopeData.payloadType, + payload: envelopeData.payload, + signature: envelopeData.signature + }) + } + + /** + * Seal marshals the given Record, places the marshaled bytes inside an Envelope + * and signs it with the given peerId's private key + */ + static seal = async (record: Record, peerId: PeerId) => { + const domain = record.domain + const payloadType = record.codec + const payload = record.marshal() + + const signData = formatSignaturePayload(domain, payloadType, payload) + + if (peerId.privateKey == null) { + throw new Error('Missing private key') + } + + const key = await unmarshalPrivateKey(peerId.privateKey) + const signature = await key.sign(signData) + + return new RecordEnvelope({ + peerId, + payloadType, + payload, + signature + }) + } + + /** + * Open and certify a given marshalled envelope. + * Data is unmarshalled and the signature validated for the given domain. + */ + static openAndCertify = async (data: Uint8Array, domain: string) => { + const envelope = await RecordEnvelope.createFromProtobuf(data) + const valid = await envelope.validate(domain) + + if (!valid) { + throw errCode(new Error('envelope signature is not valid for the given domain'), codes.ERR_SIGNATURE_NOT_VALID) + } + + return envelope + } + + public peerId: PeerId + public payloadType: Uint8Array + public payload: Uint8Array + public signature: Uint8Array + public marshaled?: Uint8Array + + /** + * The Envelope is responsible for keeping an arbitrary signed record + * by a libp2p peer. + */ + constructor (options: EnvelopeOptions) { + const { peerId, payloadType, payload, signature } = options + + this.peerId = peerId + this.payloadType = payloadType + this.payload = payload + this.signature = signature + } + + /** + * Marshal the envelope content + */ + marshal () { + if (this.marshaled == null) { + this.marshaled = Protobuf.encode({ + publicKey: this.peerId.publicKey, + payloadType: this.payloadType, + payload: this.payload, + signature: this.signature + }).finish() + } + + return this.marshaled + } + + /** + * Verifies if the other Envelope is identical to this one + */ + equals (other: Envelope) { + return uint8arraysEquals(this.marshal(), other.marshal()) + } + + /** + * Validate envelope data signature for the given domain + */ + async validate (domain: string) { + const signData = formatSignaturePayload(domain, this.payloadType, this.payload) + + if (this.peerId.publicKey == null) { + throw new Error('Missing public key') + } + + const key = unmarshalPublicKey(this.peerId.publicKey) + + return await key.verify(signData, this.signature) + } +} + +/** + * Helper function that prepares a Uint8Array to sign or verify a signature + */ +const formatSignaturePayload = (domain: string, payloadType: Uint8Array, payload: Uint8Array) => { + // When signing, a peer will prepare a Uint8Array by concatenating the following: + // - The length of the domain separation string string in bytes + // - The domain separation string, encoded as UTF-8 + // - The length of the payload_type field in bytes + // - The value of the payload_type field + // - The length of the payload field in bytes + // - The value of the payload field + + const domainUint8Array = uint8arraysFromString(domain) + const domainLength = varint.encode(domainUint8Array.byteLength) + const payloadTypeLength = varint.encode(payloadType.length) + const payloadLength = varint.encode(payload.length) + + return uint8arraysConcat([ + new Uint8Array(domainLength), + domainUint8Array, + new Uint8Array(payloadTypeLength), + payloadType, + new Uint8Array(payloadLength), + payload + ]) +} diff --git a/packages/libp2p-peer-record/src/errors.ts b/packages/libp2p-peer-record/src/errors.ts new file mode 100644 index 000000000..0c09e34d6 --- /dev/null +++ b/packages/libp2p-peer-record/src/errors.ts @@ -0,0 +1,4 @@ + +export const codes = { + ERR_SIGNATURE_NOT_VALID: 'ERR_SIGNATURE_NOT_VALID' +} diff --git a/packages/libp2p-peer-record/src/index.ts b/packages/libp2p-peer-record/src/index.ts new file mode 100644 index 000000000..c05f140aa --- /dev/null +++ b/packages/libp2p-peer-record/src/index.ts @@ -0,0 +1,3 @@ + +export { RecordEnvelope } from './envelope/index.js' +export { PeerRecord } from './peer-record/index.js' diff --git a/packages/libp2p-peer-record/src/peer-record/consts.js b/packages/libp2p-peer-record/src/peer-record/consts.js new file mode 100644 index 000000000..8f862e33b --- /dev/null +++ b/packages/libp2p-peer-record/src/peer-record/consts.js @@ -0,0 +1,8 @@ + +// The domain string used for peer records contained in a Envelope. +export const ENVELOPE_DOMAIN_PEER_RECORD = 'libp2p-peer-record' + +// The type hint used to identify peer records in a Envelope. +// Defined in https://github.com/multiformats/multicodec/blob/master/table.csv +// with name "libp2p-peer-record" +export const ENVELOPE_PAYLOAD_TYPE_PEER_RECORD = Uint8Array.from([3, 1]) diff --git a/packages/libp2p-peer-record/src/peer-record/index.ts b/packages/libp2p-peer-record/src/peer-record/index.ts new file mode 100644 index 000000000..80fb03634 --- /dev/null +++ b/packages/libp2p-peer-record/src/peer-record/index.ts @@ -0,0 +1,104 @@ +import { Multiaddr } from '@multiformats/multiaddr' +import { PeerId } from '@libp2p/peer-id' +import { arrayEquals } from '@libp2p/utils/array-equals' +import { PeerRecord as Protobuf } from './peer-record.js' +import { + ENVELOPE_DOMAIN_PEER_RECORD, + ENVELOPE_PAYLOAD_TYPE_PEER_RECORD +} from './consts.js' + +export interface PeerRecordOptions { + peerId: PeerId + + /** + * Addresses of the associated peer. + */ + multiaddrs?: Multiaddr[] + + /** + * Monotonically-increasing sequence counter that's used to order PeerRecords in time. + */ + seqNumber?: number +} + +/** + * The PeerRecord is used for distributing peer routing records across the network. + * It contains the peer's reachable listen addresses. + */ +export class PeerRecord { + /** + * Unmarshal Peer Record Protobuf. + * + * @param {Uint8Array} buf - marshaled peer record. + * @returns {PeerRecord} + */ + static createFromProtobuf = (buf: Uint8Array) => { + const peerRecord = Protobuf.decode(buf) + const peerId = PeerId.fromBytes(peerRecord.peerId) + const multiaddrs = (peerRecord.addresses ?? []).map((a) => new Multiaddr(a.multiaddr)) + const seqNumber = Number(peerRecord.seq) + + return new PeerRecord({ peerId, multiaddrs, seqNumber }) + } + + static DOMAIN = ENVELOPE_DOMAIN_PEER_RECORD + static CODEC = ENVELOPE_PAYLOAD_TYPE_PEER_RECORD + + public peerId: PeerId + public multiaddrs: Multiaddr[] + public seqNumber: number + public domain = PeerRecord.DOMAIN + public codec = PeerRecord.CODEC + private marshaled?: Uint8Array + + constructor (options: PeerRecordOptions) { + const { peerId, multiaddrs, seqNumber } = options + + this.peerId = peerId + this.multiaddrs = multiaddrs ?? [] + this.seqNumber = seqNumber ?? Date.now() + } + + /** + * Marshal a record to be used in an envelope + */ + marshal () { + if (this.marshaled == null) { + this.marshaled = Protobuf.encode({ + peerId: this.peerId.toBytes(), + seq: this.seqNumber, + addresses: this.multiaddrs.map((m) => ({ + multiaddr: m.bytes + })) + }).finish() + } + + return this.marshaled + } + + /** + * Returns true if `this` record equals the `other` + */ + equals (other: unknown) { + if (!(other instanceof PeerRecord)) { + return false + } + + // Validate PeerId + if (!this.peerId.equals(other.peerId)) { + return false + } + + // Validate seqNumber + if (this.seqNumber !== other.seqNumber) { + return false + } + + // Validate multiaddrs + if (!arrayEquals(this.multiaddrs, other.multiaddrs)) { + return false + } + + return true + } +} diff --git a/packages/libp2p-peer-record/src/peer-record/peer-record.d.ts b/packages/libp2p-peer-record/src/peer-record/peer-record.d.ts new file mode 100644 index 000000000..a851b5330 --- /dev/null +++ b/packages/libp2p-peer-record/src/peer-record/peer-record.d.ts @@ -0,0 +1,133 @@ +import * as $protobuf from "protobufjs"; +/** Properties of a PeerRecord. */ +export interface IPeerRecord { + + /** PeerRecord peerId */ + peerId?: (Uint8Array|null); + + /** PeerRecord seq */ + seq?: (number|null); + + /** PeerRecord addresses */ + addresses?: (PeerRecord.IAddressInfo[]|null); +} + +/** Represents a PeerRecord. */ +export class PeerRecord implements IPeerRecord { + + /** + * Constructs a new PeerRecord. + * @param [p] Properties to set + */ + constructor(p?: IPeerRecord); + + /** PeerRecord peerId. */ + public peerId: Uint8Array; + + /** PeerRecord seq. */ + public seq: number; + + /** PeerRecord addresses. */ + public addresses: PeerRecord.IAddressInfo[]; + + /** + * Encodes the specified PeerRecord message. Does not implicitly {@link PeerRecord.verify|verify} messages. + * @param m PeerRecord message or plain object to encode + * @param [w] Writer to encode to + * @returns Writer + */ + public static encode(m: IPeerRecord, w?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a PeerRecord message from the specified reader or buffer. + * @param r Reader or buffer to decode from + * @param [l] Message length if known beforehand + * @returns PeerRecord + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): PeerRecord; + + /** + * Creates a PeerRecord message from a plain object. Also converts values to their respective internal types. + * @param d Plain object + * @returns PeerRecord + */ + public static fromObject(d: { [k: string]: any }): PeerRecord; + + /** + * Creates a plain object from a PeerRecord message. Also converts values to other types if specified. + * @param m PeerRecord + * @param [o] Conversion options + * @returns Plain object + */ + public static toObject(m: PeerRecord, o?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this PeerRecord to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; +} + +export namespace PeerRecord { + + /** Properties of an AddressInfo. */ + interface IAddressInfo { + + /** AddressInfo multiaddr */ + multiaddr?: (Uint8Array|null); + } + + /** Represents an AddressInfo. */ + class AddressInfo implements IAddressInfo { + + /** + * Constructs a new AddressInfo. + * @param [p] Properties to set + */ + constructor(p?: PeerRecord.IAddressInfo); + + /** AddressInfo multiaddr. */ + public multiaddr: Uint8Array; + + /** + * Encodes the specified AddressInfo message. Does not implicitly {@link PeerRecord.AddressInfo.verify|verify} messages. + * @param m AddressInfo message or plain object to encode + * @param [w] Writer to encode to + * @returns Writer + */ + public static encode(m: PeerRecord.IAddressInfo, w?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an AddressInfo message from the specified reader or buffer. + * @param r Reader or buffer to decode from + * @param [l] Message length if known beforehand + * @returns AddressInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): PeerRecord.AddressInfo; + + /** + * Creates an AddressInfo message from a plain object. Also converts values to their respective internal types. + * @param d Plain object + * @returns AddressInfo + */ + public static fromObject(d: { [k: string]: any }): PeerRecord.AddressInfo; + + /** + * Creates a plain object from an AddressInfo message. Also converts values to other types if specified. + * @param m AddressInfo + * @param [o] Conversion options + * @returns Plain object + */ + public static toObject(m: PeerRecord.AddressInfo, o?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this AddressInfo to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + } +} diff --git a/packages/libp2p-peer-record/src/peer-record/peer-record.js b/packages/libp2p-peer-record/src/peer-record/peer-record.js new file mode 100644 index 000000000..6a05c202d --- /dev/null +++ b/packages/libp2p-peer-record/src/peer-record/peer-record.js @@ -0,0 +1,365 @@ +/*eslint-disable*/ +import $protobuf from "protobufjs/minimal.js"; + +// Common aliases +const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util; + +// Exported root namespace +const $root = $protobuf.roots["libp2p-peer-record"] || ($protobuf.roots["libp2p-peer-record"] = {}); + +export const PeerRecord = $root.PeerRecord = (() => { + + /** + * Properties of a PeerRecord. + * @exports IPeerRecord + * @interface IPeerRecord + * @property {Uint8Array|null} [peerId] PeerRecord peerId + * @property {number|null} [seq] PeerRecord seq + * @property {Array.|null} [addresses] PeerRecord addresses + */ + + /** + * Constructs a new PeerRecord. + * @exports PeerRecord + * @classdesc Represents a PeerRecord. + * @implements IPeerRecord + * @constructor + * @param {IPeerRecord=} [p] Properties to set + */ + function PeerRecord(p) { + this.addresses = []; + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + /** + * PeerRecord peerId. + * @member {Uint8Array} peerId + * @memberof PeerRecord + * @instance + */ + PeerRecord.prototype.peerId = $util.newBuffer([]); + + /** + * PeerRecord seq. + * @member {number} seq + * @memberof PeerRecord + * @instance + */ + PeerRecord.prototype.seq = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * PeerRecord addresses. + * @member {Array.} addresses + * @memberof PeerRecord + * @instance + */ + PeerRecord.prototype.addresses = $util.emptyArray; + + /** + * Encodes the specified PeerRecord message. Does not implicitly {@link PeerRecord.verify|verify} messages. + * @function encode + * @memberof PeerRecord + * @static + * @param {IPeerRecord} m PeerRecord message or plain object to encode + * @param {$protobuf.Writer} [w] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + PeerRecord.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.peerId != null && Object.hasOwnProperty.call(m, "peerId")) + w.uint32(10).bytes(m.peerId); + if (m.seq != null && Object.hasOwnProperty.call(m, "seq")) + w.uint32(16).uint64(m.seq); + if (m.addresses != null && m.addresses.length) { + for (var i = 0; i < m.addresses.length; ++i) + $root.PeerRecord.AddressInfo.encode(m.addresses[i], w.uint32(26).fork()).ldelim(); + } + return w; + }; + + /** + * Decodes a PeerRecord message from the specified reader or buffer. + * @function decode + * @memberof PeerRecord + * @static + * @param {$protobuf.Reader|Uint8Array} r Reader or buffer to decode from + * @param {number} [l] Message length if known beforehand + * @returns {PeerRecord} PeerRecord + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + PeerRecord.decode = function decode(r, l) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.PeerRecord(); + while (r.pos < c) { + var t = r.uint32(); + switch (t >>> 3) { + case 1: + m.peerId = r.bytes(); + break; + case 2: + m.seq = r.uint64(); + break; + case 3: + if (!(m.addresses && m.addresses.length)) + m.addresses = []; + m.addresses.push($root.PeerRecord.AddressInfo.decode(r, r.uint32())); + break; + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + /** + * Creates a PeerRecord message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof PeerRecord + * @static + * @param {Object.} d Plain object + * @returns {PeerRecord} PeerRecord + */ + PeerRecord.fromObject = function fromObject(d) { + if (d instanceof $root.PeerRecord) + return d; + var m = new $root.PeerRecord(); + if (d.peerId != null) { + if (typeof d.peerId === "string") + $util.base64.decode(d.peerId, m.peerId = $util.newBuffer($util.base64.length(d.peerId)), 0); + else if (d.peerId.length) + m.peerId = d.peerId; + } + if (d.seq != null) { + if ($util.Long) + (m.seq = $util.Long.fromValue(d.seq)).unsigned = true; + else if (typeof d.seq === "string") + m.seq = parseInt(d.seq, 10); + else if (typeof d.seq === "number") + m.seq = d.seq; + else if (typeof d.seq === "object") + m.seq = new $util.LongBits(d.seq.low >>> 0, d.seq.high >>> 0).toNumber(true); + } + if (d.addresses) { + if (!Array.isArray(d.addresses)) + throw TypeError(".PeerRecord.addresses: array expected"); + m.addresses = []; + for (var i = 0; i < d.addresses.length; ++i) { + if (typeof d.addresses[i] !== "object") + throw TypeError(".PeerRecord.addresses: object expected"); + m.addresses[i] = $root.PeerRecord.AddressInfo.fromObject(d.addresses[i]); + } + } + return m; + }; + + /** + * Creates a plain object from a PeerRecord message. Also converts values to other types if specified. + * @function toObject + * @memberof PeerRecord + * @static + * @param {PeerRecord} m PeerRecord + * @param {$protobuf.IConversionOptions} [o] Conversion options + * @returns {Object.} Plain object + */ + PeerRecord.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.arrays || o.defaults) { + d.addresses = []; + } + if (o.defaults) { + if (o.bytes === String) + d.peerId = ""; + else { + d.peerId = []; + if (o.bytes !== Array) + d.peerId = $util.newBuffer(d.peerId); + } + if ($util.Long) { + var n = new $util.Long(0, 0, true); + d.seq = o.longs === String ? n.toString() : o.longs === Number ? n.toNumber() : n; + } else + d.seq = o.longs === String ? "0" : 0; + } + if (m.peerId != null && m.hasOwnProperty("peerId")) { + d.peerId = o.bytes === String ? $util.base64.encode(m.peerId, 0, m.peerId.length) : o.bytes === Array ? Array.prototype.slice.call(m.peerId) : m.peerId; + } + if (m.seq != null && m.hasOwnProperty("seq")) { + if (typeof m.seq === "number") + d.seq = o.longs === String ? String(m.seq) : m.seq; + else + d.seq = o.longs === String ? $util.Long.prototype.toString.call(m.seq) : o.longs === Number ? new $util.LongBits(m.seq.low >>> 0, m.seq.high >>> 0).toNumber(true) : m.seq; + } + if (m.addresses && m.addresses.length) { + d.addresses = []; + for (var j = 0; j < m.addresses.length; ++j) { + d.addresses[j] = $root.PeerRecord.AddressInfo.toObject(m.addresses[j], o); + } + } + return d; + }; + + /** + * Converts this PeerRecord to JSON. + * @function toJSON + * @memberof PeerRecord + * @instance + * @returns {Object.} JSON object + */ + PeerRecord.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + PeerRecord.AddressInfo = (function() { + + /** + * Properties of an AddressInfo. + * @memberof PeerRecord + * @interface IAddressInfo + * @property {Uint8Array|null} [multiaddr] AddressInfo multiaddr + */ + + /** + * Constructs a new AddressInfo. + * @memberof PeerRecord + * @classdesc Represents an AddressInfo. + * @implements IAddressInfo + * @constructor + * @param {PeerRecord.IAddressInfo=} [p] Properties to set + */ + function AddressInfo(p) { + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + /** + * AddressInfo multiaddr. + * @member {Uint8Array} multiaddr + * @memberof PeerRecord.AddressInfo + * @instance + */ + AddressInfo.prototype.multiaddr = $util.newBuffer([]); + + /** + * Encodes the specified AddressInfo message. Does not implicitly {@link PeerRecord.AddressInfo.verify|verify} messages. + * @function encode + * @memberof PeerRecord.AddressInfo + * @static + * @param {PeerRecord.IAddressInfo} m AddressInfo message or plain object to encode + * @param {$protobuf.Writer} [w] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + AddressInfo.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.multiaddr != null && Object.hasOwnProperty.call(m, "multiaddr")) + w.uint32(10).bytes(m.multiaddr); + return w; + }; + + /** + * Decodes an AddressInfo message from the specified reader or buffer. + * @function decode + * @memberof PeerRecord.AddressInfo + * @static + * @param {$protobuf.Reader|Uint8Array} r Reader or buffer to decode from + * @param {number} [l] Message length if known beforehand + * @returns {PeerRecord.AddressInfo} AddressInfo + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + AddressInfo.decode = function decode(r, l) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.PeerRecord.AddressInfo(); + while (r.pos < c) { + var t = r.uint32(); + switch (t >>> 3) { + case 1: + m.multiaddr = r.bytes(); + break; + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + /** + * Creates an AddressInfo message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof PeerRecord.AddressInfo + * @static + * @param {Object.} d Plain object + * @returns {PeerRecord.AddressInfo} AddressInfo + */ + AddressInfo.fromObject = function fromObject(d) { + if (d instanceof $root.PeerRecord.AddressInfo) + return d; + var m = new $root.PeerRecord.AddressInfo(); + if (d.multiaddr != null) { + if (typeof d.multiaddr === "string") + $util.base64.decode(d.multiaddr, m.multiaddr = $util.newBuffer($util.base64.length(d.multiaddr)), 0); + else if (d.multiaddr.length) + m.multiaddr = d.multiaddr; + } + return m; + }; + + /** + * Creates a plain object from an AddressInfo message. Also converts values to other types if specified. + * @function toObject + * @memberof PeerRecord.AddressInfo + * @static + * @param {PeerRecord.AddressInfo} m AddressInfo + * @param {$protobuf.IConversionOptions} [o] Conversion options + * @returns {Object.} Plain object + */ + AddressInfo.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.defaults) { + if (o.bytes === String) + d.multiaddr = ""; + else { + d.multiaddr = []; + if (o.bytes !== Array) + d.multiaddr = $util.newBuffer(d.multiaddr); + } + } + if (m.multiaddr != null && m.hasOwnProperty("multiaddr")) { + d.multiaddr = o.bytes === String ? $util.base64.encode(m.multiaddr, 0, m.multiaddr.length) : o.bytes === Array ? Array.prototype.slice.call(m.multiaddr) : m.multiaddr; + } + return d; + }; + + /** + * Converts this AddressInfo to JSON. + * @function toJSON + * @memberof PeerRecord.AddressInfo + * @instance + * @returns {Object.} JSON object + */ + AddressInfo.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return AddressInfo; + })(); + + return PeerRecord; +})(); + +export { $root as default }; diff --git a/packages/libp2p-peer-record/src/peer-record/peer-record.proto b/packages/libp2p-peer-record/src/peer-record/peer-record.proto new file mode 100644 index 000000000..6b740dc80 --- /dev/null +++ b/packages/libp2p-peer-record/src/peer-record/peer-record.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +message PeerRecord { + // AddressInfo is a wrapper around a binary multiaddr. It is defined as a + // separate message to allow us to add per-address metadata in the future. + message AddressInfo { + bytes multiaddr = 1; + } + + // peer_id contains a libp2p peer id in its binary representation. + bytes peer_id = 1; + + // seq contains a monotonically-increasing sequence counter to order PeerRecords in time. + uint64 seq = 2; + + // addresses is a list of public listen addresses for the peer. + repeated AddressInfo addresses = 3; +} \ No newline at end of file diff --git a/packages/libp2p-peer-record/test/envelope.spec.ts b/packages/libp2p-peer-record/test/envelope.spec.ts new file mode 100644 index 000000000..9054a2e24 --- /dev/null +++ b/packages/libp2p-peer-record/test/envelope.spec.ts @@ -0,0 +1,89 @@ +import { expect } from 'aegir/utils/chai.js' +import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' +import { equals as uint8arrayEquals } from 'uint8arrays/equals' +import { RecordEnvelope } from '../src/envelope/index.js' +import { codes as ErrorCodes } from '../src/errors.js' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { Record } from '@libp2p/interfaces/record' +import type { PeerId } from '@libp2p/interfaces/peer-id' + +const domain = 'libp2p-testing' +const codec = uint8arrayFromString('/libp2p/testdata') + +class TestRecord implements Record { + public domain: string + public codec: Uint8Array + public data: string + + constructor (data: string) { + this.domain = domain + this.codec = codec + this.data = data + } + + marshal () { + return uint8arrayFromString(this.data) + } + + equals (other: Record) { + return uint8arrayEquals(this.marshal(), other.marshal()) + } +} + +describe('Envelope', () => { + const payloadType = codec + let peerId: PeerId + let testRecord: TestRecord + + before(async () => { + peerId = await createEd25519PeerId() + testRecord = new TestRecord('test-data') + }) + + it('creates an envelope with a random key', () => { + const payload = testRecord.marshal() + const signature = uint8arrayFromString(Math.random().toString(36).substring(7)) + + const envelope = new RecordEnvelope({ + peerId, + payloadType, + payload, + signature + }) + + expect(envelope).to.exist() + expect(envelope.peerId.equals(peerId)).to.eql(true) + expect(envelope.payloadType).to.equalBytes(payloadType) + expect(envelope.payload).to.equalBytes(payload) + expect(envelope.signature).to.equalBytes(signature) + }) + + it('can seal a record', async () => { + const envelope = await RecordEnvelope.seal(testRecord, peerId) + expect(envelope).to.exist() + expect(envelope.peerId.equals(peerId)).to.eql(true) + expect(envelope.payloadType).to.eql(payloadType) + expect(envelope.payload).to.exist() + expect(envelope.signature).to.exist() + }) + + it('can open and verify a sealed record', async () => { + const envelope = await RecordEnvelope.seal(testRecord, peerId) + const rawEnvelope = envelope.marshal() + + const unmarshalledEnvelope = await RecordEnvelope.openAndCertify(rawEnvelope, testRecord.domain) + expect(unmarshalledEnvelope).to.exist() + + const equals = envelope.equals(unmarshalledEnvelope) + expect(equals).to.eql(true) + }) + + it('throw on open and verify when a different domain is used', async () => { + const envelope = await RecordEnvelope.seal(testRecord, peerId) + const rawEnvelope = envelope.marshal() + + await expect(RecordEnvelope.openAndCertify(rawEnvelope, '/bad-domain')) + .to.eventually.be.rejected() + .and.to.have.property('code', ErrorCodes.ERR_SIGNATURE_NOT_VALID) + }) +}) diff --git a/packages/libp2p-peer-record/test/peer-record.spec.ts b/packages/libp2p-peer-record/test/peer-record.spec.ts new file mode 100644 index 000000000..92e2fa3e6 --- /dev/null +++ b/packages/libp2p-peer-record/test/peer-record.spec.ts @@ -0,0 +1,155 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/utils/chai.js' +import tests from '@libp2p/interface-compliance-tests/record' +import { Multiaddr } from '@multiformats/multiaddr' +import { PeerId } from '@libp2p/peer-id' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { RecordEnvelope } from '../src/envelope/index.js' +import { PeerRecord } from '../src/peer-record/index.js' +import { unmarshalPrivateKey } from '@libp2p/crypto/keys' + +describe('interface-record compliance', () => { + tests({ + async setup () { + const peerId = await createEd25519PeerId() + return new PeerRecord({ peerId }) + }, + async teardown () { + // cleanup resources created by setup() + } + }) +}) + +describe('PeerRecord', () => { + let peerId: PeerId + + before(async () => { + peerId = await createEd25519PeerId() + }) + + it('de/serializes the same as a go record', async () => { + const privKey = Uint8Array.from([8, 1, 18, 64, 133, 251, 231, 43, 96, 100, 40, 144, 4, 165, 49, 249, 103, 137, 141, 245, 49, 158, 224, 41, 146, 253, 216, 64, 33, 250, 80, 82, 67, 75, 246, 238, 17, 187, 163, 237, 23, 33, 148, 140, 239, 180, 229, 11, 10, 11, 181, 202, 216, 166, 181, 45, 199, 177, 164, 15, 79, 102, 82, 16, 92, 145, 226, 196]) + const rawEnvelope = Uint8Array.from([10, 36, 8, 1, 18, 32, 17, 187, 163, 237, 23, 33, 148, 140, 239, 180, 229, 11, 10, 11, 181, 202, 216, 166, 181, 45, 199, 177, 164, 15, 79, 102, 82, 16, 92, 145, 226, 196, 18, 2, 3, 1, 26, 170, 1, 10, 38, 0, 36, 8, 1, 18, 32, 17, 187, 163, 237, 23, 33, 148, 140, 239, 180, 229, 11, 10, 11, 181, 202, 216, 166, 181, 45, 199, 177, 164, 15, 79, 102, 82, 16, 92, 145, 226, 196, 16, 216, 184, 224, 191, 147, 145, 182, 151, 22, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 0, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 1, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 2, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 3, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 4, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 5, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 6, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 7, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 8, 26, 10, 10, 8, 4, 1, 2, 3, 4, 6, 0, 9, 42, 64, 177, 151, 247, 107, 159, 40, 138, 242, 180, 103, 254, 102, 111, 119, 68, 118, 40, 112, 73, 180, 36, 183, 57, 117, 200, 134, 14, 251, 2, 55, 45, 2, 106, 121, 149, 132, 84, 26, 215, 47, 38, 84, 52, 100, 133, 188, 163, 236, 227, 100, 98, 183, 209, 177, 57, 28, 141, 39, 109, 196, 171, 139, 202, 11]) + const key = await unmarshalPrivateKey(privKey) + const peerId = await PeerId.fromKeys(key.public.bytes, key.bytes) + + const env = await RecordEnvelope.openAndCertify(rawEnvelope, PeerRecord.DOMAIN) + expect(peerId.equals(env.peerId)) + + const record = PeerRecord.createFromProtobuf(env.payload) + + // The payload isn't going to match because of how the protobuf encodes uint64 values + // They are marshalled correctly on both sides, but will be off by 1 value + // Signatures will still be validated + const jsEnv = await RecordEnvelope.seal(record, peerId) + expect(env.payloadType).to.eql(jsEnv.payloadType) + }) + + it('creates a peer record with peerId', () => { + const peerRecord = new PeerRecord({ peerId }) + + expect(peerRecord).to.exist() + expect(peerRecord.peerId).to.exist() + expect(peerRecord.multiaddrs).to.exist() + expect(peerRecord.multiaddrs).to.have.lengthOf(0) + expect(peerRecord.seqNumber).to.exist() + }) + + it('creates a peer record with provided data', () => { + const multiaddrs = [ + new Multiaddr('/ip4/127.0.0.1/tcp/2000') + ] + const seqNumber = Date.now() + const peerRecord = new PeerRecord({ peerId, multiaddrs, seqNumber }) + + expect(peerRecord).to.exist() + expect(peerRecord.peerId).to.exist() + expect(peerRecord.multiaddrs).to.exist() + expect(peerRecord.multiaddrs).to.eql(multiaddrs) + expect(peerRecord.seqNumber).to.exist() + expect(peerRecord.seqNumber).to.eql(seqNumber) + }) + + it('marshals and unmarshals a peer record', () => { + const multiaddrs = [ + new Multiaddr('/ip4/127.0.0.1/tcp/2000') + ] + const seqNumber = Date.now() + const peerRecord = new PeerRecord({ peerId, multiaddrs, seqNumber }) + + // Marshal + const rawData = peerRecord.marshal() + expect(rawData).to.exist() + + // Unmarshal + const unmarshalPeerRecord = PeerRecord.createFromProtobuf(rawData) + expect(unmarshalPeerRecord).to.exist() + + const equals = peerRecord.equals(unmarshalPeerRecord) + expect(equals).to.eql(true) + }) + + it('equals returns false if the peer record has a different peerId', async () => { + const peerRecord0 = new PeerRecord({ peerId }) + + const peerId1 = await createEd25519PeerId() + const peerRecord1 = new PeerRecord({ peerId: peerId1 }) + + const equals = peerRecord0.equals(peerRecord1) + expect(equals).to.eql(false) + }) + + it('equals returns false if the peer record has a different seqNumber', () => { + const ts0 = Date.now() + const peerRecord0 = new PeerRecord({ peerId, seqNumber: ts0 }) + + const ts1 = ts0 + 20 + const peerRecord1 = new PeerRecord({ peerId, seqNumber: ts1 }) + + const equals = peerRecord0.equals(peerRecord1) + expect(equals).to.eql(false) + }) + + it('equals returns false if the peer record has a different multiaddrs', () => { + const multiaddrs = [ + new Multiaddr('/ip4/127.0.0.1/tcp/2000') + ] + const peerRecord0 = new PeerRecord({ peerId, multiaddrs }) + + const multiaddrs1 = [ + new Multiaddr('/ip4/127.0.0.1/tcp/2001') + ] + const peerRecord1 = new PeerRecord({ peerId, multiaddrs: multiaddrs1 }) + + const equals = peerRecord0.equals(peerRecord1) + expect(equals).to.eql(false) + }) +}) + +describe('PeerRecord inside Envelope', () => { + let peerId: PeerId + let peerRecord: PeerRecord + + before(async () => { + peerId = await createEd25519PeerId() + const multiaddrs = [ + new Multiaddr('/ip4/127.0.0.1/tcp/2000') + ] + const seqNumber = Date.now() + peerRecord = new PeerRecord({ peerId, multiaddrs, seqNumber }) + }) + + it('creates an envelope with the PeerRecord and can unmarshal it', async () => { + const e = await RecordEnvelope.seal(peerRecord, peerId) + const byteE = e.marshal() + + const decodedE = await RecordEnvelope.openAndCertify(byteE, PeerRecord.DOMAIN) + expect(decodedE).to.exist() + + const decodedPeerRecord = PeerRecord.createFromProtobuf(decodedE.payload) + + const equals = peerRecord.equals(decodedPeerRecord) + expect(equals).to.eql(true) + }) +}) diff --git a/packages/libp2p-peer-record/tsconfig.json b/packages/libp2p-peer-record/tsconfig.json new file mode 100644 index 000000000..ee76f2336 --- /dev/null +++ b/packages/libp2p-peer-record/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist", + "emitDeclarationOnly": false, + "module": "ES2020" + }, + "include": [ + "src", + "test" + ], + "exclude": [ + // exclude generated files + "src/envelope/envelope.js", + "src/peer-record/peer-record.js" + ], + "references": [ + { + "path": "../libp2p-interfaces" + }, + { + "path": "../libp2p-logger" + }, + { + "path": "../libp2p-peer-id-factory" + } + ] +} diff --git a/packages/libp2p-peer-store/LICENSE b/packages/libp2p-peer-store/LICENSE new file mode 100644 index 000000000..20ce483c8 --- /dev/null +++ b/packages/libp2p-peer-store/LICENSE @@ -0,0 +1,4 @@ +This project is dual licensed under MIT and Apache-2.0. + +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/packages/libp2p-peer-store/LICENSE-APACHE b/packages/libp2p-peer-store/LICENSE-APACHE new file mode 100644 index 000000000..14478a3b6 --- /dev/null +++ b/packages/libp2p-peer-store/LICENSE-APACHE @@ -0,0 +1,5 @@ +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/packages/libp2p-peer-store/LICENSE-MIT b/packages/libp2p-peer-store/LICENSE-MIT new file mode 100644 index 000000000..72dc60d84 --- /dev/null +++ b/packages/libp2p-peer-store/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/libp2p-peer-store/README.md b/packages/libp2p-peer-store/README.md new file mode 100644 index 000000000..588c13600 --- /dev/null +++ b/packages/libp2p-peer-store/README.md @@ -0,0 +1,44 @@ +# libp2p-tracked-map + +> allows tracking metrics in libp2p + +## Table of Contents + +- [Description](#description) +- [Example](#example) +- [Installation](#installation) +- [License](#license) + - [Contribution](#contribution) + +## Description + +A map that reports it's size to the libp2p [Metrics](https://github.com/libp2p/js-libp2p-interfaces/tree/master/packages/libp2p-interfaces/src/metrics#readme) system. + +If metrics are disabled a regular map is used. + +## Example + +```JavaScript +import { trackedMap } from '@libp2p/tracked-map' + +const map = trackedMap({ metrics }) + +map.set('key', 'value') +``` + +## Installation + +```console +$ npm i @libp2p/tracked-map +``` + +## License + +Licensed under either of + + * Apache 2.0, ([LICENSE-APACHE](LICENSE-APACHE) / http://www.apache.org/licenses/LICENSE-2.0) + * MIT ([LICENSE-MIT](LICENSE-MIT) / http://opensource.org/licenses/MIT) + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/packages/libp2p-peer-store/package.json b/packages/libp2p-peer-store/package.json new file mode 100644 index 000000000..374986a86 --- /dev/null +++ b/packages/libp2p-peer-store/package.json @@ -0,0 +1,163 @@ +{ + "name": "@libp2p/peer-store", + "version": "0.0.0", + "description": "Stores information about peers libp2p knows on the network", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p-interfaces/tree/master/packages/libp2p-peer-store#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/libp2p/js-libp2p-interfaces.git" + }, + "bugs": { + "url": "https://github.com/libp2p/js-libp2p-interfaces/issues" + }, + "keywords": [ + "IPFS" + ], + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + }, + "type": "module", + "types": "./dist/src/index.d.ts", + "files": [ + "src", + "dist/src", + "!dist/test", + "!**/*.tsbuildinfo" + ], + "exports": { + ".": { + "import": "./dist/src/index.js" + } + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "sourceType": "module" + }, + "ignorePatterns": [ + "src/pb/*.d.ts", + "src/pb/peer.js" + ] + }, + "release": { + "branches": [ + "master" + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { + "breaking": true, + "release": "major" + }, + { + "revert": true, + "release": "patch" + }, + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "chore", + "release": "patch" + }, + { + "type": "docs", + "release": "patch" + }, + { + "type": "test", + "release": "patch" + }, + { + "scope": "no-release", + "release": false + } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "chore", + "section": "Trivial Changes" + }, + { + "type": "docs", + "section": "Trivial Changes" + }, + { + "type": "test", + "section": "Tests" + } + ] + } + } + ], + "@semantic-release/changelog", + "@semantic-release/npm", + "@semantic-release/github", + "@semantic-release/git" + ] + }, + "scripts": { + "lint": "aegir lint", + "dep-check": "aegir dep-check dist/src/**/*.js", + "build": "tsc", + "postbuild": "npm run build:copy-proto-files", + "generate": "npm run generate:proto && npm run generate:proto-types && tsc", + "generate:proto": "pbjs -t static-module -w es6 -r libp2p-peer-store --force-number --no-verify --no-delimited --no-create --no-beautify --no-defaults --lint eslint-disable -o src/pb/peer.js ./src/pb/peer.proto", + "generate:proto-types": "pbts -o src/pb/peer.d.ts src/pb/peer.js", + "build:copy-proto-files": "mkdirp dist/src/pb && cp src/pb/*.js dist/src/pb && cp src/pb/*.d.ts dist/src/pb", + "pretest": "npm run build", + "test": "aegir test -f ./dist/test", + "test:chrome": "npm run test -- -t browser", + "test:chrome-webworker": "npm run test -- -t webworker", + "test:firefox": "npm run test -- -t browser -- --browser firefox", + "test:firefox-webworker": "npm run test -- -t webworker -- --browser firefox", + "test:node": "npm run test -- -t node --cov", + "test:electron-main": "npm run test -- -t electron-main" + }, + "dependencies": { + "@libp2p/interfaces": "^1.0.0", + "@libp2p/logger": "^1.0.1", + "@libp2p/peer-record": "^0.0.0", + "@multiformats/multiaddr": "^10.1.5", + "interface-datastore": "^6.1.0", + "it-all": "^1.0.6", + "it-filter": "^1.0.3", + "it-foreach": "^0.1.1", + "it-map": "^1.0.6", + "it-pipe": "^2.0.3", + "mortice": "^3.0.0", + "multiformats": "^9.6.3", + "protobufjs": "^6.10.2" + }, + "devDependencies": { + "aegir": "^36.1.3", + "datastore-core": "^7.0.1", + "sinon": "^13.0.1" + } +} diff --git a/packages/libp2p-peer-store/src/README.md b/packages/libp2p-peer-store/src/README.md new file mode 100644 index 000000000..9fc05326f --- /dev/null +++ b/packages/libp2p-peer-store/src/README.md @@ -0,0 +1,145 @@ +# PeerStore + +Libp2p's PeerStore is responsible for keeping an updated register with the relevant information of the known peers. It should be the single source of truth for all peer data, where a subsystem can learn about peers' data and where someone can listen for updates. The PeerStore comprises four main components: `addressBook`, `keyBook`, `protocolBook` and `metadataBook`. + +The PeerStore manages the high level operations on its inner books. Moreover, the PeerStore should be responsible for notifying interested parties of relevant events, through its Event Emitter. + +## Submitting records to the PeerStore + +Several libp2p subsystems will perform operations that might gather relevant information about peers. + +### Identify +- The Identify protocol automatically runs on every connection when multiplexing is enabled. The protocol will put the multiaddrs and protocols provided by the peer to the PeerStore. +- In the background, the Identify Service is also waiting for protocol change notifications of peers via the IdentifyPush protocol. Peers may leverage the `identify-push` message to communicate protocol changes to all connected peers, so that their PeerStore can be updated with the updated protocols. +- While it is currently not supported in js-libp2p, future iterations may also support the [IdentifyDelta protocol](https://github.com/libp2p/specs/pull/176). +- Taking into account that the Identify protocol records are directly from the peer, they should be considered the source of truth and weighted accordingly. + +### Peer Discovery +- Libp2p discovery protocols aim to discover new peers in the network. In a typical discovery protocol, addresses of the peer are discovered along with its peer id. Once this happens, a libp2p discovery protocol should emit a `peer` event with the information of the discovered peer and this information will be added to the PeerStore by libp2p. + +### Dialer +- Libp2p API supports dialing a peer given a `multiaddr`, and no prior knowledge of the peer. If the node is able to establish a connection with the peer, it and its multiaddr is added to the PeerStore. +- When a connection is being upgraded, more precisely after its encryption, or even in a discovery protocol, a libp2p node can get to know other parties public keys. In this scenario, libp2p will add the peer's public key to its `KeyBook`. + +### DHT +- On some DHT operations, such as finding providers for a given CID, nodes may exchange peer data as part of the query. This passive peer discovery should result in the DHT emitting the `peer` event in the same way [Peer Discovery](#peerdiscovery) does. + +## Retrieving records from the PeerStore + +When data in the PeerStore is updated the PeerStore will emit events based on the changes, to allow applications and other subsystems to take action on those changes. Any subsystem interested in these notifications should subscribe the [`PeerStore events`][peer-store-events]. + +### Peer +- Each time a new peer is discovered, the PeerStore should emit a [`peer` event][peer-store-events], so that interested parties can leverage this peer and establish a connection with it. + +### Protocols +- When the known protocols of a peer change, the PeerStore emits a [`change:protocols` event][peer-store-events]. + +### Multiaddrs +- When the known listening `multiaddrs` of a peer change, the PeerStore emits a [`change:multiaddrs` event][peer-store-events]. + +## PeerStore implementation + +The PeerStore wraps four main components: `addressBook`, `keyBook`, `protocolBook` and `metadataBook`. Moreover, it provides a high level API for those components, as well as data events. + +### Components + +#### Address Book + +The `addressBook` keeps the known multiaddrs of a peer. The multiaddrs of each peer may change over time and the Address Book must account for this. + +`Map` + +A `peerId.toString(base58btc)` identifier mapping to a `Address` object, which should have the following structure: + +```js +{ + multiaddr: +} +``` + +#### Key Book + +The `keyBook` tracks the public keys of the peers by keeping their [`PeerId`][peer-id]. + +`Map>` + +A `peerId.toString(base58btc)` identifier mapping to a `Set` of protocol identifier strings. + +#### Metadata Book + +The `metadataBook` keeps track of the known metadata of a peer. Its metadata is stored in a key value fashion, where a key identifier (`string`) represents a metadata value (`Uint8Array`). + +`Map>` + +A `peerId.toString(base58btc)` identifier mapping to the peer metadata Map. + +### API + +For the complete API documentation, you should check the [API.md](../../doc/API.md). + +Access to its underlying books: + +- `peerStore.addressBook.*` +- `peerStore.keyBook.*` +- `peerStore.metadataBook.*` +- `peerStore.protoBook.*` + +### Events + +- `peer` - emitted when a new peer is added. +- `change:multiaadrs` - emitted when a known peer has a different set of multiaddrs. +- `change:protocols` - emitted when a known peer supports a different set of protocols. +- `change:pubkey` - emitted when a peer's public key is known. +- `change:metadata` - emitted when known metadata of a peer changes. + +## Data Persistence + +The data stored in the PeerStore can be persisted if configured appropriately. Keeping a record of the peers already discovered by the peer, as well as their known data aims to improve the efficiency of peers joining the network after being offline. + +The libp2p node will need to receive a [datastore](https://github.com/ipfs/interface-datastore), in order to persist this data across restarts. A [datastore](https://github.com/ipfs/interface-datastore) stores its data in a key-value fashion. As a result, we need coherent keys so that we do not overwrite data. + +The PeerStore should not continuously update the datastore whenever data is changed. Instead, it should only store new data after reaching a certain threshold of "dirty" peers, as well as when the node is stopped, in order to batch writes to the datastore. + +The peer id will be appended to the datastore key for each data namespace. The namespaces were defined as follows: + +**AddressBook** + +All the known peer addresses are stored with a key pattern as follows: + +`/peers/addrs/` + +**ProtoBook** + +All the known peer protocols are stored with a key pattern as follows: + +`/peers/protos/` + +**KeyBook** + +All public keys are stored under the following pattern: + +` /peers/keys/` + +**MetadataBook** + +Metadata is stored under the following key pattern: + +`/peers/metadata//` + +## Future Considerations + +- If multiaddr TTLs are added, the PeerStore may schedule jobs to delete all addresses that exceed the TTL to prevent AddressBook bloating +- Further API methods will probably need to be added in the context of multiaddr validity and confidence. +- When improving libp2p configuration for specific runtimes, we should take into account the PeerStore recommended datastore. +- When improving libp2p configuration, we should think about a possible way of allowing the configuration of Bootstrap to be influenced by the persisted peers, as a way to decrease the load on Bootstrap nodes. + +[peer-id]: https://github.com/libp2p/js-peer-id +[peer-store-events]: ../../doc/API.md#libp2ppeerstore diff --git a/packages/libp2p-peer-store/src/address-book.ts b/packages/libp2p-peer-store/src/address-book.ts new file mode 100644 index 000000000..a0723d2c5 --- /dev/null +++ b/packages/libp2p-peer-store/src/address-book.ts @@ -0,0 +1,332 @@ +import { logger } from '@libp2p/logger' +import errcode from 'err-code' +import { Multiaddr } from '@multiformats/multiaddr' +import { codes } from './errors.js' +import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record' +import { pipe } from 'it-pipe' +import all from 'it-all' +import filter from 'it-filter' +import map from 'it-map' +import each from 'it-foreach' +import { base58btc } from 'multiformats/bases/base58' +import { PeerId } from '@libp2p/peer-id' +import type { PeerStore } from '@libp2p/interfaces/peer-store' +import type { Store } from './store.js' +import type { AddressFilter, AddressSorter } from './index.js' +import type { Envelope } from '@libp2p/interfaces/record' + +const log = logger('libp2p:peer-store:address-book') +const EVENT_NAME = 'change:multiaddrs' + +async function allowAll () { + return true +} + +export class PeerStoreAddressBook { + private readonly emit: PeerStore['emit'] + private readonly store: Store + private readonly addressFilter: AddressFilter + + constructor (emit: PeerStore['emit'], store: Store, addressFilter?: AddressFilter) { + this.emit = emit + this.store = store + this.addressFilter = addressFilter ?? allowAll + } + + /** + * ConsumePeerRecord adds addresses from a signed peer record contained in a record envelope. + * This will return a boolean that indicates if the record was successfully processed and added + * into the AddressBook. + */ + async consumePeerRecord (envelope: Envelope) { + log('consumePeerRecord await write lock') + const release = await this.store.lock.writeLock() + log('consumePeerRecord got write lock') + + let peerId + let updatedPeer + + try { + let peerRecord + try { + peerRecord = PeerRecord.createFromProtobuf(envelope.payload) + } catch (err: any) { + log.error('invalid peer record received') + return false + } + + peerId = peerRecord.peerId + const multiaddrs = peerRecord.multiaddrs + + // Verify peerId + if (!peerId.equals(envelope.peerId)) { + log('signing key does not match PeerId in the PeerRecord') + return false + } + + // ensure the record has multiaddrs + if (multiaddrs == null || multiaddrs.length === 0) { + return false + } + + if (await this.store.has(peerId)) { + const peer = await this.store.load(peerId) + + if (peer.peerRecordEnvelope != null) { + const storedEnvelope = await RecordEnvelope.createFromProtobuf(peer.peerRecordEnvelope) + const storedRecord = PeerRecord.createFromProtobuf(storedEnvelope.payload) + + // ensure seq is greater than, or equal to, the last received + if (storedRecord.seqNumber >= peerRecord.seqNumber) { + return false + } + } + } + + // Replace unsigned addresses by the new ones from the record + // TODO: Once we have ttls for the addresses, we should merge these in + updatedPeer = await this.store.patchOrCreate(peerId, { + addresses: await filterMultiaddrs(peerId, multiaddrs, this.addressFilter, true), + peerRecordEnvelope: envelope.marshal() + }) + + log(`stored provided peer record for ${peerRecord.peerId.toString(base58btc)}`) + } finally { + log('consumePeerRecord release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, multiaddrs: updatedPeer.addresses.map(({ multiaddr }) => multiaddr) }) + + return true + } + + async getRawEnvelope (peerId: PeerId) { + log('getRawEnvelope await read lock') + const release = await this.store.lock.readLock() + log('getRawEnvelope got read lock') + + try { + const peer = await this.store.load(peerId) + + return peer.peerRecordEnvelope + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('getRawEnvelope release read lock') + release() + } + } + + /** + * Get an Envelope containing a PeerRecord for the given peer. + * Returns undefined if no record exists. + */ + async getPeerRecord (peerId: PeerId) { + const raw = await this.getRawEnvelope(peerId) + + if (raw == null) { + return undefined + } + + return await RecordEnvelope.createFromProtobuf(raw) + } + + async get (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('get wait for read lock') + const release = await this.store.lock.readLock() + log('get got read lock') + + try { + const peer = await this.store.load(peerId) + + return peer.addresses + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('get release read lock') + release() + } + + return [] + } + + async set (peerId: PeerId, multiaddrs: Multiaddr[]) { + peerId = PeerId.fromPeerId(peerId) + + if (!Array.isArray(multiaddrs)) { + log.error('multiaddrs must be an array of Multiaddrs') + throw errcode(new Error('multiaddrs must be an array of Multiaddrs'), codes.ERR_INVALID_PARAMETERS) + } + + log('set await write lock') + const release = await this.store.lock.writeLock() + log('set got write lock') + + let hasPeer = false + let updatedPeer + + try { + const addresses = await filterMultiaddrs(peerId, multiaddrs, this.addressFilter) + + // No valid addresses found + if (addresses.length === 0) { + return + } + + try { + const peer = await this.store.load(peerId) + hasPeer = true + + if (new Set([ + ...addresses.map(({ multiaddr }) => multiaddr.toString()), + ...peer.addresses.map(({ multiaddr }) => multiaddr.toString()) + ]).size === peer.addresses.length && addresses.length === peer.addresses.length) { + // not changing anything, no need to update + return + } + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + updatedPeer = await this.store.patchOrCreate(peerId, { addresses }) + + log(`set multiaddrs for ${peerId.toString(base58btc)}`) + } finally { + log('set release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, multiaddrs: updatedPeer.addresses.map(addr => addr.multiaddr) }) + + // Notify the existence of a new peer + if (!hasPeer) { + this.emit('peer', peerId) + } + } + + async add (peerId: PeerId, multiaddrs: Multiaddr[]) { + peerId = PeerId.fromPeerId(peerId) + + if (!Array.isArray(multiaddrs)) { + log.error('multiaddrs must be an array of Multiaddrs') + throw errcode(new Error('multiaddrs must be an array of Multiaddrs'), codes.ERR_INVALID_PARAMETERS) + } + + log('add await write lock') + const release = await this.store.lock.writeLock() + log('add got write lock') + + let hasPeer + let updatedPeer + + try { + const addresses = await filterMultiaddrs(peerId, multiaddrs, this.addressFilter) + + // No valid addresses found + if (addresses.length === 0) { + return + } + + try { + const peer = await this.store.load(peerId) + hasPeer = true + + if (new Set([ + ...addresses.map(({ multiaddr }) => multiaddr.toString()), + ...peer.addresses.map(({ multiaddr }) => multiaddr.toString()) + ]).size === peer.addresses.length) { + return + } + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + updatedPeer = await this.store.mergeOrCreate(peerId, { addresses }) + + log(`added multiaddrs for ${peerId.toString(base58btc)}`) + } finally { + log('set release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, multiaddrs: updatedPeer.addresses.map(addr => addr.multiaddr) }) + + // Notify the existence of a new peer + if (hasPeer === true) { + this.emit('peer', peerId) + } + } + + async delete (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('delete await write lock') + const release = await this.store.lock.writeLock() + log('delete got write lock') + + let has + + try { + has = await this.store.has(peerId) + + await this.store.patchOrCreate(peerId, { + addresses: [] + }) + } finally { + log('delete release write lock') + release() + } + + if (has) { + this.emit(EVENT_NAME, { peerId, multiaddrs: [] }) + } + } + + async getMultiaddrsForPeer (peerId: PeerId, addressSorter: AddressSorter = (mas) => mas) { + const addresses = await this.get(peerId) + + return addressSorter( + addresses + ).map((address) => { + const multiaddr = address.multiaddr + const idString = multiaddr.getPeerId() + + if (idString === peerId.toString()) { + return multiaddr + } + + return multiaddr.encapsulate(`/p2p/${peerId.toString(base58btc)}`) + }) + } +} + +async function filterMultiaddrs (peerId: PeerId, multiaddrs: Multiaddr[], addressFilter: AddressFilter, isCertified: boolean = false) { + return await pipe( + multiaddrs, + (source) => each(source, (multiaddr) => { + if (!Multiaddr.isMultiaddr(multiaddr)) { + log.error('multiaddr must be an instance of Multiaddr') + throw errcode(new Error('multiaddr must be an instance of Multiaddr'), codes.ERR_INVALID_PARAMETERS) + } + }), + (source) => filter(source, async (multiaddr) => await addressFilter(peerId, multiaddr)), + (source) => map(source, (multiaddr) => { + return { + multiaddr: new Multiaddr(multiaddr.toString()), + isCertified + } + }), + async (source) => await all(source) + ) +} diff --git a/packages/libp2p-peer-store/src/errors.ts b/packages/libp2p-peer-store/src/errors.ts new file mode 100644 index 000000000..60efb244d --- /dev/null +++ b/packages/libp2p-peer-store/src/errors.ts @@ -0,0 +1,5 @@ + +export const codes = { + ERR_INVALID_PARAMETERS: 'ERR_INVALID_PARAMETERS', + ERR_NOT_FOUND: 'ERR_NOT_FOUND' +} diff --git a/packages/libp2p-peer-store/src/index.ts b/packages/libp2p-peer-store/src/index.ts new file mode 100644 index 000000000..b4564886d --- /dev/null +++ b/packages/libp2p-peer-store/src/index.ts @@ -0,0 +1,123 @@ +import { logger } from '@libp2p/logger' +import { EventEmitter } from 'events' +import { PeerStoreAddressBook } from './address-book.js' +import { PeerStoreKeyBook } from './key-book.js' +import { PeerStoreMetadataBook } from './metadata-book.js' +import { PeerStoreProtoBook } from './proto-book.js' +import { PersistentStore, Store } from './store.js' +import type { PeerStore, Address, AddressBook, KeyBook, MetadataBook, ProtoBook } from '@libp2p/interfaces/peer-store' +import type { PeerId } from '@libp2p/interfaces/peer-id' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { Datastore } from 'interface-datastore' +import { base58btc } from 'multiformats/bases/base58' + +const log = logger('libp2p:peer-store') + +export interface AddressFilter { + (peerId: PeerId, multiaddr: Multiaddr): Promise +} + +export interface AddressSorter { + (addresses: Address[]): Address[] +} + +export interface PeerStoreOptions { + peerId: PeerId + datastore: Datastore + addressFilter?: AddressFilter +} + +/** + * An implementation of PeerStore that stores data in a Datastore + */ +export class DefaultPeerStore extends EventEmitter implements PeerStore { + public addressBook: AddressBook + public keyBook: KeyBook + public metadataBook: MetadataBook + public protoBook: ProtoBook + + private readonly peerId: PeerId + private readonly store: Store + + constructor (options: PeerStoreOptions) { + super() + + const { peerId, datastore, addressFilter } = options + + this.peerId = peerId + this.store = new PersistentStore(datastore) + + this.addressBook = new PeerStoreAddressBook(this.emit.bind(this), this.store, addressFilter) + this.keyBook = new PeerStoreKeyBook(this.emit.bind(this), this.store) + this.metadataBook = new PeerStoreMetadataBook(this.emit.bind(this), this.store) + this.protoBook = new PeerStoreProtoBook(this.emit.bind(this), this.store) + } + + async * getPeers () { + log('getPeers await read lock') + const release = await this.store.lock.readLock() + log('getPeers got read lock') + + try { + for await (const peer of this.store.all()) { + if (peer.id.toString(base58btc) === this.peerId.toString(base58btc)) { + // Remove self peer if present + continue + } + + yield peer + } + } finally { + log('getPeers release read lock') + release() + } + } + + /** + * Delete the information of the given peer in every book + */ + async delete (peerId: PeerId) { + log('delete await write lock') + const release = await this.store.lock.writeLock() + log('delete got write lock') + + try { + await this.store.delete(peerId) + } finally { + log('delete release write lock') + release() + } + } + + /** + * Get the stored information of a given peer + */ + async get (peerId: PeerId) { + log('get await read lock') + const release = await this.store.lock.readLock() + log('get got read lock') + + try { + return await this.store.load(peerId) + } finally { + log('get release read lock') + release() + } + } + + /** + * Returns true if we have a record of the peer + */ + async has (peerId: PeerId) { + log('has await read lock') + const release = await this.store.lock.readLock() + log('has got read lock') + + try { + return await this.store.has(peerId) + } finally { + log('has release read lock') + release() + } + } +} diff --git a/packages/libp2p-peer-store/src/key-book.ts b/packages/libp2p-peer-store/src/key-book.ts new file mode 100644 index 000000000..90b25530e --- /dev/null +++ b/packages/libp2p-peer-store/src/key-book.ts @@ -0,0 +1,117 @@ +import { logger } from '@libp2p/logger' +import errcode from 'err-code' +import { codes } from './errors.js' +import { PeerId } from '@libp2p/peer-id' +import { equals as uint8arrayEquals } from 'uint8arrays/equals' +import type { Store } from './store.js' +import type { PeerStore, KeyBook } from '@libp2p/interfaces/src/peer-store' + +/** + * @typedef {import('./types').PeerStore} PeerStore + * @typedef {import('./types').KeyBook} KeyBook + * @typedef {import('libp2p-interfaces/src/keys/types').PublicKey} PublicKey + */ + +const log = logger('libp2p:peer-store:key-book') + +const EVENT_NAME = 'change:pubkey' + +export class PeerStoreKeyBook implements KeyBook { + private readonly emit: PeerStore['emit'] + private readonly store: Store + + /** + * The KeyBook is responsible for keeping the known public keys of a peer + */ + constructor (emit: PeerStore['emit'], store: Store) { + this.emit = emit + this.store = store + } + + /** + * Set the Peer public key + */ + async set (peerId: PeerId, publicKey: Uint8Array) { + peerId = PeerId.fromPeerId(peerId) + + if (!(publicKey instanceof Uint8Array)) { + log.error('publicKey must be an instance of Uint8Array to store data') + throw errcode(new Error('publicKey must be an instance of PublicKey'), codes.ERR_INVALID_PARAMETERS) + } + + log('set await write lock') + const release = await this.store.lock.writeLock() + log('set got write lock') + + let updatedKey = false + + try { + try { + const existing = await this.store.load(peerId) + + if ((existing.pubKey != null) && uint8arrayEquals(existing.pubKey, publicKey)) { + return + } + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + await this.store.patchOrCreate(peerId, { + pubKey: publicKey + }) + updatedKey = true + } finally { + log('set release write lock') + release() + } + + if (updatedKey) { + this.emit(EVENT_NAME, { peerId, pubKey: publicKey }) + } + } + + /** + * Get Public key of the given PeerId, if stored + */ + async get (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('get await write lock') + const release = await this.store.lock.readLock() + log('get got write lock') + + try { + const peer = await this.store.load(peerId) + + return peer.pubKey + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('get release write lock') + release() + } + } + + async delete (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('delete await write lock') + const release = await this.store.lock.writeLock() + log('delete got write lock') + + try { + await this.store.patchOrCreate(peerId, { + pubKey: undefined + }) + } finally { + log('delete release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, pubKey: undefined }) + } +} diff --git a/packages/libp2p-peer-store/src/metadata-book.ts b/packages/libp2p-peer-store/src/metadata-book.ts new file mode 100644 index 000000000..920592dd3 --- /dev/null +++ b/packages/libp2p-peer-store/src/metadata-book.ts @@ -0,0 +1,200 @@ +import { logger } from '@libp2p/logger' +import errcode from 'err-code' +import { codes } from './errors.js' +import { PeerId } from '@libp2p/peer-id' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import type { Store } from './store.js' +import type { PeerStore, MetadataBook } from '@libp2p/interfaces/src/peer-store' + +const log = logger('libp2p:peer-store:metadata-book') + +const EVENT_NAME = 'change:metadata' + +export class PeerStoreMetadataBook implements MetadataBook { + private readonly emit: PeerStore['emit'] + private readonly store: Store + + /** + * The MetadataBook is responsible for keeping metadata + * about known peers + */ + constructor (emit: PeerStore['emit'], store: Store) { + this.emit = emit + this.store = store + } + + /** + * Get the known data of a provided peer + */ + async get (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('get await read lock') + const release = await this.store.lock.readLock() + log('get got read lock') + + try { + const peer = await this.store.load(peerId) + + return peer.metadata + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('get release read lock') + release() + } + + return new Map() + } + + /** + * Get specific metadata value, if it exists + */ + async getValue (peerId: PeerId, key: string) { + peerId = PeerId.fromPeerId(peerId) + + log('getValue await read lock') + const release = await this.store.lock.readLock() + log('getValue got read lock') + + try { + const peer = await this.store.load(peerId) + + return peer.metadata.get(key) + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('getValue release write lock') + release() + } + } + + async set (peerId: PeerId, metadata: Map) { + peerId = PeerId.fromPeerId(peerId) + + if (!(metadata instanceof Map)) { + log.error('valid metadata must be provided to store data') + throw errcode(new Error('valid metadata must be provided'), codes.ERR_INVALID_PARAMETERS) + } + + log('set await write lock') + const release = await this.store.lock.writeLock() + log('set got write lock') + + try { + await this.store.mergeOrCreate(peerId, { + metadata + }) + } finally { + log('set release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, metadata }) + } + + /** + * Set metadata key and value of a provided peer + */ + async setValue (peerId: PeerId, key: string, value: Uint8Array) { + peerId = PeerId.fromPeerId(peerId) + + if (typeof key !== 'string' || !(value instanceof Uint8Array)) { + log.error('valid key and value must be provided to store data') + throw errcode(new Error('valid key and value must be provided'), codes.ERR_INVALID_PARAMETERS) + } + + log('setValue await write lock') + const release = await this.store.lock.writeLock() + log('setValue got write lock') + + let updatedPeer + + try { + try { + const existingPeer = await this.store.load(peerId) + const existingValue = existingPeer.metadata.get(key) + + if (existingValue != null && uint8ArrayEquals(value, existingValue)) { + return + } + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + updatedPeer = await this.store.mergeOrCreate(peerId, { + metadata: new Map([[key, value]]) + }) + } finally { + log('setValue release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, metadata: updatedPeer.metadata }) + } + + async delete (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('delete await write lock') + const release = await this.store.lock.writeLock() + log('delete got write lock') + + let has + + try { + has = await this.store.has(peerId) + + if (has) { + await this.store.patch(peerId, { + metadata: new Map() + }) + } + } finally { + log('delete release write lock') + release() + } + + if (has) { + this.emit(EVENT_NAME, { peerId, metadata: new Map() }) + } + } + + async deleteValue (peerId: PeerId, key: string) { + peerId = PeerId.fromPeerId(peerId) + + log('deleteValue await write lock') + const release = await this.store.lock.writeLock() + log('deleteValue got write lock') + + let metadata + + try { + const peer = await this.store.load(peerId) + metadata = peer.metadata + + metadata.delete(key) + + await this.store.patch(peerId, { + metadata + }) + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('deleteValue release write lock') + release() + } + + if (metadata != null) { + this.emit(EVENT_NAME, { peerId, metadata }) + } + } +} diff --git a/packages/libp2p-peer-store/src/pb/peer.d.ts b/packages/libp2p-peer-store/src/pb/peer.d.ts new file mode 100644 index 000000000..e6ccdfe6c --- /dev/null +++ b/packages/libp2p-peer-store/src/pb/peer.d.ts @@ -0,0 +1,222 @@ +import * as $protobuf from "protobufjs"; +/** Properties of a Peer. */ +export interface IPeer { + + /** Peer addresses */ + addresses?: (IAddress[]|null); + + /** Peer protocols */ + protocols?: (string[]|null); + + /** Peer metadata */ + metadata?: (IMetadata[]|null); + + /** Peer pubKey */ + pubKey?: (Uint8Array|null); + + /** Peer peerRecordEnvelope */ + peerRecordEnvelope?: (Uint8Array|null); +} + +/** Represents a Peer. */ +export class Peer implements IPeer { + + /** + * Constructs a new Peer. + * @param [p] Properties to set + */ + constructor(p?: IPeer); + + /** Peer addresses. */ + public addresses: IAddress[]; + + /** Peer protocols. */ + public protocols: string[]; + + /** Peer metadata. */ + public metadata: IMetadata[]; + + /** Peer pubKey. */ + public pubKey?: (Uint8Array|null); + + /** Peer peerRecordEnvelope. */ + public peerRecordEnvelope?: (Uint8Array|null); + + /** Peer _pubKey. */ + public _pubKey?: "pubKey"; + + /** Peer _peerRecordEnvelope. */ + public _peerRecordEnvelope?: "peerRecordEnvelope"; + + /** + * Encodes the specified Peer message. Does not implicitly {@link Peer.verify|verify} messages. + * @param m Peer message or plain object to encode + * @param [w] Writer to encode to + * @returns Writer + */ + public static encode(m: IPeer, w?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a Peer message from the specified reader or buffer. + * @param r Reader or buffer to decode from + * @param [l] Message length if known beforehand + * @returns Peer + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): Peer; + + /** + * Creates a Peer message from a plain object. Also converts values to their respective internal types. + * @param d Plain object + * @returns Peer + */ + public static fromObject(d: { [k: string]: any }): Peer; + + /** + * Creates a plain object from a Peer message. Also converts values to other types if specified. + * @param m Peer + * @param [o] Conversion options + * @returns Plain object + */ + public static toObject(m: Peer, o?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Peer to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; +} + +/** Properties of an Address. */ +export interface IAddress { + + /** Address multiaddr */ + multiaddr?: (Uint8Array|null); + + /** Address isCertified */ + isCertified?: (boolean|null); +} + +/** Represents an Address. */ +export class Address implements IAddress { + + /** + * Constructs a new Address. + * @param [p] Properties to set + */ + constructor(p?: IAddress); + + /** Address multiaddr. */ + public multiaddr: Uint8Array; + + /** Address isCertified. */ + public isCertified?: (boolean|null); + + /** Address _isCertified. */ + public _isCertified?: "isCertified"; + + /** + * Encodes the specified Address message. Does not implicitly {@link Address.verify|verify} messages. + * @param m Address message or plain object to encode + * @param [w] Writer to encode to + * @returns Writer + */ + public static encode(m: IAddress, w?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes an Address message from the specified reader or buffer. + * @param r Reader or buffer to decode from + * @param [l] Message length if known beforehand + * @returns Address + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): Address; + + /** + * Creates an Address message from a plain object. Also converts values to their respective internal types. + * @param d Plain object + * @returns Address + */ + public static fromObject(d: { [k: string]: any }): Address; + + /** + * Creates a plain object from an Address message. Also converts values to other types if specified. + * @param m Address + * @param [o] Conversion options + * @returns Plain object + */ + public static toObject(m: Address, o?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Address to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; +} + +/** Properties of a Metadata. */ +export interface IMetadata { + + /** Metadata key */ + key?: (string|null); + + /** Metadata value */ + value?: (Uint8Array|null); +} + +/** Represents a Metadata. */ +export class Metadata implements IMetadata { + + /** + * Constructs a new Metadata. + * @param [p] Properties to set + */ + constructor(p?: IMetadata); + + /** Metadata key. */ + public key: string; + + /** Metadata value. */ + public value: Uint8Array; + + /** + * Encodes the specified Metadata message. Does not implicitly {@link Metadata.verify|verify} messages. + * @param m Metadata message or plain object to encode + * @param [w] Writer to encode to + * @returns Writer + */ + public static encode(m: IMetadata, w?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a Metadata message from the specified reader or buffer. + * @param r Reader or buffer to decode from + * @param [l] Message length if known beforehand + * @returns Metadata + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): Metadata; + + /** + * Creates a Metadata message from a plain object. Also converts values to their respective internal types. + * @param d Plain object + * @returns Metadata + */ + public static fromObject(d: { [k: string]: any }): Metadata; + + /** + * Creates a plain object from a Metadata message. Also converts values to other types if specified. + * @param m Metadata + * @param [o] Conversion options + * @returns Plain object + */ + public static toObject(m: Metadata, o?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Metadata to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; +} diff --git a/packages/libp2p-peer-store/src/pb/peer.js b/packages/libp2p-peer-store/src/pb/peer.js new file mode 100644 index 000000000..01a2998ca --- /dev/null +++ b/packages/libp2p-peer-store/src/pb/peer.js @@ -0,0 +1,641 @@ +/*eslint-disable*/ +import $protobuf from "protobufjs/minimal.js"; + +// Common aliases +const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util; + +// Exported root namespace +const $root = $protobuf.roots["libp2p-peer-store"] || ($protobuf.roots["libp2p-peer-store"] = {}); + +export const Peer = $root.Peer = (() => { + + /** + * Properties of a Peer. + * @exports IPeer + * @interface IPeer + * @property {Array.|null} [addresses] Peer addresses + * @property {Array.|null} [protocols] Peer protocols + * @property {Array.|null} [metadata] Peer metadata + * @property {Uint8Array|null} [pubKey] Peer pubKey + * @property {Uint8Array|null} [peerRecordEnvelope] Peer peerRecordEnvelope + */ + + /** + * Constructs a new Peer. + * @exports Peer + * @classdesc Represents a Peer. + * @implements IPeer + * @constructor + * @param {IPeer=} [p] Properties to set + */ + function Peer(p) { + this.addresses = []; + this.protocols = []; + this.metadata = []; + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + /** + * Peer addresses. + * @member {Array.} addresses + * @memberof Peer + * @instance + */ + Peer.prototype.addresses = $util.emptyArray; + + /** + * Peer protocols. + * @member {Array.} protocols + * @memberof Peer + * @instance + */ + Peer.prototype.protocols = $util.emptyArray; + + /** + * Peer metadata. + * @member {Array.} metadata + * @memberof Peer + * @instance + */ + Peer.prototype.metadata = $util.emptyArray; + + /** + * Peer pubKey. + * @member {Uint8Array|null|undefined} pubKey + * @memberof Peer + * @instance + */ + Peer.prototype.pubKey = null; + + /** + * Peer peerRecordEnvelope. + * @member {Uint8Array|null|undefined} peerRecordEnvelope + * @memberof Peer + * @instance + */ + Peer.prototype.peerRecordEnvelope = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + /** + * Peer _pubKey. + * @member {"pubKey"|undefined} _pubKey + * @memberof Peer + * @instance + */ + Object.defineProperty(Peer.prototype, "_pubKey", { + get: $util.oneOfGetter($oneOfFields = ["pubKey"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Peer _peerRecordEnvelope. + * @member {"peerRecordEnvelope"|undefined} _peerRecordEnvelope + * @memberof Peer + * @instance + */ + Object.defineProperty(Peer.prototype, "_peerRecordEnvelope", { + get: $util.oneOfGetter($oneOfFields = ["peerRecordEnvelope"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Encodes the specified Peer message. Does not implicitly {@link Peer.verify|verify} messages. + * @function encode + * @memberof Peer + * @static + * @param {IPeer} m Peer message or plain object to encode + * @param {$protobuf.Writer} [w] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + Peer.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.addresses != null && m.addresses.length) { + for (var i = 0; i < m.addresses.length; ++i) + $root.Address.encode(m.addresses[i], w.uint32(10).fork()).ldelim(); + } + if (m.protocols != null && m.protocols.length) { + for (var i = 0; i < m.protocols.length; ++i) + w.uint32(18).string(m.protocols[i]); + } + if (m.metadata != null && m.metadata.length) { + for (var i = 0; i < m.metadata.length; ++i) + $root.Metadata.encode(m.metadata[i], w.uint32(26).fork()).ldelim(); + } + if (m.pubKey != null && Object.hasOwnProperty.call(m, "pubKey")) + w.uint32(34).bytes(m.pubKey); + if (m.peerRecordEnvelope != null && Object.hasOwnProperty.call(m, "peerRecordEnvelope")) + w.uint32(42).bytes(m.peerRecordEnvelope); + return w; + }; + + /** + * Decodes a Peer message from the specified reader or buffer. + * @function decode + * @memberof Peer + * @static + * @param {$protobuf.Reader|Uint8Array} r Reader or buffer to decode from + * @param {number} [l] Message length if known beforehand + * @returns {Peer} Peer + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + Peer.decode = function decode(r, l) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.Peer(); + while (r.pos < c) { + var t = r.uint32(); + switch (t >>> 3) { + case 1: + if (!(m.addresses && m.addresses.length)) + m.addresses = []; + m.addresses.push($root.Address.decode(r, r.uint32())); + break; + case 2: + if (!(m.protocols && m.protocols.length)) + m.protocols = []; + m.protocols.push(r.string()); + break; + case 3: + if (!(m.metadata && m.metadata.length)) + m.metadata = []; + m.metadata.push($root.Metadata.decode(r, r.uint32())); + break; + case 4: + m.pubKey = r.bytes(); + break; + case 5: + m.peerRecordEnvelope = r.bytes(); + break; + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + /** + * Creates a Peer message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Peer + * @static + * @param {Object.} d Plain object + * @returns {Peer} Peer + */ + Peer.fromObject = function fromObject(d) { + if (d instanceof $root.Peer) + return d; + var m = new $root.Peer(); + if (d.addresses) { + if (!Array.isArray(d.addresses)) + throw TypeError(".Peer.addresses: array expected"); + m.addresses = []; + for (var i = 0; i < d.addresses.length; ++i) { + if (typeof d.addresses[i] !== "object") + throw TypeError(".Peer.addresses: object expected"); + m.addresses[i] = $root.Address.fromObject(d.addresses[i]); + } + } + if (d.protocols) { + if (!Array.isArray(d.protocols)) + throw TypeError(".Peer.protocols: array expected"); + m.protocols = []; + for (var i = 0; i < d.protocols.length; ++i) { + m.protocols[i] = String(d.protocols[i]); + } + } + if (d.metadata) { + if (!Array.isArray(d.metadata)) + throw TypeError(".Peer.metadata: array expected"); + m.metadata = []; + for (var i = 0; i < d.metadata.length; ++i) { + if (typeof d.metadata[i] !== "object") + throw TypeError(".Peer.metadata: object expected"); + m.metadata[i] = $root.Metadata.fromObject(d.metadata[i]); + } + } + if (d.pubKey != null) { + if (typeof d.pubKey === "string") + $util.base64.decode(d.pubKey, m.pubKey = $util.newBuffer($util.base64.length(d.pubKey)), 0); + else if (d.pubKey.length) + m.pubKey = d.pubKey; + } + if (d.peerRecordEnvelope != null) { + if (typeof d.peerRecordEnvelope === "string") + $util.base64.decode(d.peerRecordEnvelope, m.peerRecordEnvelope = $util.newBuffer($util.base64.length(d.peerRecordEnvelope)), 0); + else if (d.peerRecordEnvelope.length) + m.peerRecordEnvelope = d.peerRecordEnvelope; + } + return m; + }; + + /** + * Creates a plain object from a Peer message. Also converts values to other types if specified. + * @function toObject + * @memberof Peer + * @static + * @param {Peer} m Peer + * @param {$protobuf.IConversionOptions} [o] Conversion options + * @returns {Object.} Plain object + */ + Peer.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.arrays || o.defaults) { + d.addresses = []; + d.protocols = []; + d.metadata = []; + } + if (m.addresses && m.addresses.length) { + d.addresses = []; + for (var j = 0; j < m.addresses.length; ++j) { + d.addresses[j] = $root.Address.toObject(m.addresses[j], o); + } + } + if (m.protocols && m.protocols.length) { + d.protocols = []; + for (var j = 0; j < m.protocols.length; ++j) { + d.protocols[j] = m.protocols[j]; + } + } + if (m.metadata && m.metadata.length) { + d.metadata = []; + for (var j = 0; j < m.metadata.length; ++j) { + d.metadata[j] = $root.Metadata.toObject(m.metadata[j], o); + } + } + if (m.pubKey != null && m.hasOwnProperty("pubKey")) { + d.pubKey = o.bytes === String ? $util.base64.encode(m.pubKey, 0, m.pubKey.length) : o.bytes === Array ? Array.prototype.slice.call(m.pubKey) : m.pubKey; + if (o.oneofs) + d._pubKey = "pubKey"; + } + if (m.peerRecordEnvelope != null && m.hasOwnProperty("peerRecordEnvelope")) { + d.peerRecordEnvelope = o.bytes === String ? $util.base64.encode(m.peerRecordEnvelope, 0, m.peerRecordEnvelope.length) : o.bytes === Array ? Array.prototype.slice.call(m.peerRecordEnvelope) : m.peerRecordEnvelope; + if (o.oneofs) + d._peerRecordEnvelope = "peerRecordEnvelope"; + } + return d; + }; + + /** + * Converts this Peer to JSON. + * @function toJSON + * @memberof Peer + * @instance + * @returns {Object.} JSON object + */ + Peer.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return Peer; +})(); + +export const Address = $root.Address = (() => { + + /** + * Properties of an Address. + * @exports IAddress + * @interface IAddress + * @property {Uint8Array|null} [multiaddr] Address multiaddr + * @property {boolean|null} [isCertified] Address isCertified + */ + + /** + * Constructs a new Address. + * @exports Address + * @classdesc Represents an Address. + * @implements IAddress + * @constructor + * @param {IAddress=} [p] Properties to set + */ + function Address(p) { + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + /** + * Address multiaddr. + * @member {Uint8Array} multiaddr + * @memberof Address + * @instance + */ + Address.prototype.multiaddr = $util.newBuffer([]); + + /** + * Address isCertified. + * @member {boolean|null|undefined} isCertified + * @memberof Address + * @instance + */ + Address.prototype.isCertified = null; + + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + /** + * Address _isCertified. + * @member {"isCertified"|undefined} _isCertified + * @memberof Address + * @instance + */ + Object.defineProperty(Address.prototype, "_isCertified", { + get: $util.oneOfGetter($oneOfFields = ["isCertified"]), + set: $util.oneOfSetter($oneOfFields) + }); + + /** + * Encodes the specified Address message. Does not implicitly {@link Address.verify|verify} messages. + * @function encode + * @memberof Address + * @static + * @param {IAddress} m Address message or plain object to encode + * @param {$protobuf.Writer} [w] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + Address.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.multiaddr != null && Object.hasOwnProperty.call(m, "multiaddr")) + w.uint32(10).bytes(m.multiaddr); + if (m.isCertified != null && Object.hasOwnProperty.call(m, "isCertified")) + w.uint32(16).bool(m.isCertified); + return w; + }; + + /** + * Decodes an Address message from the specified reader or buffer. + * @function decode + * @memberof Address + * @static + * @param {$protobuf.Reader|Uint8Array} r Reader or buffer to decode from + * @param {number} [l] Message length if known beforehand + * @returns {Address} Address + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + Address.decode = function decode(r, l) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.Address(); + while (r.pos < c) { + var t = r.uint32(); + switch (t >>> 3) { + case 1: + m.multiaddr = r.bytes(); + break; + case 2: + m.isCertified = r.bool(); + break; + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + /** + * Creates an Address message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Address + * @static + * @param {Object.} d Plain object + * @returns {Address} Address + */ + Address.fromObject = function fromObject(d) { + if (d instanceof $root.Address) + return d; + var m = new $root.Address(); + if (d.multiaddr != null) { + if (typeof d.multiaddr === "string") + $util.base64.decode(d.multiaddr, m.multiaddr = $util.newBuffer($util.base64.length(d.multiaddr)), 0); + else if (d.multiaddr.length) + m.multiaddr = d.multiaddr; + } + if (d.isCertified != null) { + m.isCertified = Boolean(d.isCertified); + } + return m; + }; + + /** + * Creates a plain object from an Address message. Also converts values to other types if specified. + * @function toObject + * @memberof Address + * @static + * @param {Address} m Address + * @param {$protobuf.IConversionOptions} [o] Conversion options + * @returns {Object.} Plain object + */ + Address.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.defaults) { + if (o.bytes === String) + d.multiaddr = ""; + else { + d.multiaddr = []; + if (o.bytes !== Array) + d.multiaddr = $util.newBuffer(d.multiaddr); + } + } + if (m.multiaddr != null && m.hasOwnProperty("multiaddr")) { + d.multiaddr = o.bytes === String ? $util.base64.encode(m.multiaddr, 0, m.multiaddr.length) : o.bytes === Array ? Array.prototype.slice.call(m.multiaddr) : m.multiaddr; + } + if (m.isCertified != null && m.hasOwnProperty("isCertified")) { + d.isCertified = m.isCertified; + if (o.oneofs) + d._isCertified = "isCertified"; + } + return d; + }; + + /** + * Converts this Address to JSON. + * @function toJSON + * @memberof Address + * @instance + * @returns {Object.} JSON object + */ + Address.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return Address; +})(); + +export const Metadata = $root.Metadata = (() => { + + /** + * Properties of a Metadata. + * @exports IMetadata + * @interface IMetadata + * @property {string|null} [key] Metadata key + * @property {Uint8Array|null} [value] Metadata value + */ + + /** + * Constructs a new Metadata. + * @exports Metadata + * @classdesc Represents a Metadata. + * @implements IMetadata + * @constructor + * @param {IMetadata=} [p] Properties to set + */ + function Metadata(p) { + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + /** + * Metadata key. + * @member {string} key + * @memberof Metadata + * @instance + */ + Metadata.prototype.key = ""; + + /** + * Metadata value. + * @member {Uint8Array} value + * @memberof Metadata + * @instance + */ + Metadata.prototype.value = $util.newBuffer([]); + + /** + * Encodes the specified Metadata message. Does not implicitly {@link Metadata.verify|verify} messages. + * @function encode + * @memberof Metadata + * @static + * @param {IMetadata} m Metadata message or plain object to encode + * @param {$protobuf.Writer} [w] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + Metadata.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.key != null && Object.hasOwnProperty.call(m, "key")) + w.uint32(10).string(m.key); + if (m.value != null && Object.hasOwnProperty.call(m, "value")) + w.uint32(18).bytes(m.value); + return w; + }; + + /** + * Decodes a Metadata message from the specified reader or buffer. + * @function decode + * @memberof Metadata + * @static + * @param {$protobuf.Reader|Uint8Array} r Reader or buffer to decode from + * @param {number} [l] Message length if known beforehand + * @returns {Metadata} Metadata + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + Metadata.decode = function decode(r, l) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.Metadata(); + while (r.pos < c) { + var t = r.uint32(); + switch (t >>> 3) { + case 1: + m.key = r.string(); + break; + case 2: + m.value = r.bytes(); + break; + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + /** + * Creates a Metadata message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof Metadata + * @static + * @param {Object.} d Plain object + * @returns {Metadata} Metadata + */ + Metadata.fromObject = function fromObject(d) { + if (d instanceof $root.Metadata) + return d; + var m = new $root.Metadata(); + if (d.key != null) { + m.key = String(d.key); + } + if (d.value != null) { + if (typeof d.value === "string") + $util.base64.decode(d.value, m.value = $util.newBuffer($util.base64.length(d.value)), 0); + else if (d.value.length) + m.value = d.value; + } + return m; + }; + + /** + * Creates a plain object from a Metadata message. Also converts values to other types if specified. + * @function toObject + * @memberof Metadata + * @static + * @param {Metadata} m Metadata + * @param {$protobuf.IConversionOptions} [o] Conversion options + * @returns {Object.} Plain object + */ + Metadata.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.defaults) { + d.key = ""; + if (o.bytes === String) + d.value = ""; + else { + d.value = []; + if (o.bytes !== Array) + d.value = $util.newBuffer(d.value); + } + } + if (m.key != null && m.hasOwnProperty("key")) { + d.key = m.key; + } + if (m.value != null && m.hasOwnProperty("value")) { + d.value = o.bytes === String ? $util.base64.encode(m.value, 0, m.value.length) : o.bytes === Array ? Array.prototype.slice.call(m.value) : m.value; + } + return d; + }; + + /** + * Converts this Metadata to JSON. + * @function toJSON + * @memberof Metadata + * @instance + * @returns {Object.} JSON object + */ + Metadata.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return Metadata; +})(); + +export { $root as default }; diff --git a/packages/libp2p-peer-store/src/pb/peer.proto b/packages/libp2p-peer-store/src/pb/peer.proto new file mode 100644 index 000000000..1c9cc166c --- /dev/null +++ b/packages/libp2p-peer-store/src/pb/peer.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +message Peer { + // Multiaddrs we know about + repeated Address addresses = 1; + + // The protocols the peer supports + repeated string protocols = 2; + + // Any peer metadata + repeated Metadata metadata = 3; + + // The public key of the peer + optional bytes pub_key = 4; + + // The most recently received signed PeerRecord + optional bytes peer_record_envelope = 5; +} + +// Address represents a single multiaddr +message Address { + bytes multiaddr = 1; + + // Flag to indicate if the address comes from a certified source + optional bool isCertified = 2; +} + +message Metadata { + string key = 1; + bytes value = 2; +} diff --git a/packages/libp2p-peer-store/src/proto-book.ts b/packages/libp2p-peer-store/src/proto-book.ts new file mode 100644 index 000000000..82f51ba08 --- /dev/null +++ b/packages/libp2p-peer-store/src/proto-book.ts @@ -0,0 +1,204 @@ +import { logger } from '@libp2p/logger' +import errcode from 'err-code' +import { codes } from './errors.js' +import { PeerId } from '@libp2p/peer-id' +import type { Store } from './store.js' +import type { PeerStore, ProtoBook } from '@libp2p/interfaces/src/peer-store' +import { base58btc } from 'multiformats/bases/base58' + +const log = logger('libp2p:peer-store:proto-book') + +const EVENT_NAME = 'change:protocols' + +export class PeerStoreProtoBook implements ProtoBook { + private readonly emit: PeerStore['emit'] + private readonly store: Store + + /** + * The ProtoBook is responsible for keeping the known supported + * protocols of a peer + */ + constructor (emit: PeerStore['emit'], store: Store) { + this.emit = emit + this.store = store + } + + async get (peerId: PeerId) { + log('get wait for read lock') + const release = await this.store.lock.readLock() + log('get got read lock') + + try { + const peer = await this.store.load(peerId) + + return peer.protocols + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('get release read lock') + release() + } + + return [] + } + + async set (peerId: PeerId, protocols: string[]) { + peerId = PeerId.fromPeerId(peerId) + + if (!Array.isArray(protocols)) { + log.error('protocols must be provided to store data') + throw errcode(new Error('protocols must be provided'), codes.ERR_INVALID_PARAMETERS) + } + + log('set await write lock') + const release = await this.store.lock.writeLock() + log('set got write lock') + + let updatedPeer + + try { + try { + const peer = await this.store.load(peerId) + + if (new Set([ + ...protocols + ]).size === peer.protocols.length) { + return + } + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + updatedPeer = await this.store.patchOrCreate(peerId, { + protocols + }) + + log(`stored provided protocols for ${peerId.toString(base58btc)}`) + } finally { + log('set release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, protocols: updatedPeer.protocols }) + } + + async add (peerId: PeerId, protocols: string[]) { + peerId = PeerId.fromPeerId(peerId) + + if (!Array.isArray(protocols)) { + log.error('protocols must be provided to store data') + throw errcode(new Error('protocols must be provided'), codes.ERR_INVALID_PARAMETERS) + } + + log('add await write lock') + const release = await this.store.lock.writeLock() + log('add got write lock') + + let updatedPeer + + try { + try { + const peer = await this.store.load(peerId) + + if (new Set([ + ...peer.protocols, + ...protocols + ]).size === peer.protocols.length) { + return + } + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + updatedPeer = await this.store.mergeOrCreate(peerId, { + protocols + }) + + log(`added provided protocols for ${peerId.toString(base58btc)}`) + } finally { + log('add release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, protocols: updatedPeer.protocols }) + } + + async remove (peerId: PeerId, protocols: string[]) { + peerId = PeerId.fromPeerId(peerId) + + if (!Array.isArray(protocols)) { + log.error('protocols must be provided to store data') + throw errcode(new Error('protocols must be provided'), codes.ERR_INVALID_PARAMETERS) + } + + log('remove await write lock') + const release = await this.store.lock.writeLock() + log('remove got write lock') + + let updatedPeer + + try { + try { + const peer = await this.store.load(peerId) + const protocolSet = new Set(peer.protocols) + + for (const protocol of protocols) { + protocolSet.delete(protocol) + } + + if (peer.protocols.length === protocolSet.size) { + return + } + + protocols = Array.from(protocolSet) + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } + + updatedPeer = await this.store.patchOrCreate(peerId, { + protocols + }) + } finally { + log('remove release write lock') + release() + } + + this.emit(EVENT_NAME, { peerId, protocols: updatedPeer.protocols }) + } + + async delete (peerId: PeerId) { + peerId = PeerId.fromPeerId(peerId) + + log('delete await write lock') + const release = await this.store.lock.writeLock() + log('delete got write lock') + let has + + try { + has = await this.store.has(peerId) + + await this.store.patchOrCreate(peerId, { + protocols: [] + }) + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + } finally { + log('delete release write lock') + release() + } + + if (has === true) { + this.emit(EVENT_NAME, { peerId, protocols: [] }) + } + } +} diff --git a/packages/libp2p-peer-store/src/store.ts b/packages/libp2p-peer-store/src/store.ts new file mode 100644 index 000000000..2050f593b --- /dev/null +++ b/packages/libp2p-peer-store/src/store.ts @@ -0,0 +1,224 @@ +import { logger } from '@libp2p/logger' +import { PeerId } from '@libp2p/peer-id' +import errcode from 'err-code' +import { codes } from './errors.js' +import { Key } from 'interface-datastore/key' +import { base32 } from 'multiformats/bases/base32' +import { Multiaddr } from '@multiformats/multiaddr' +import { Peer as PeerPB } from './pb/peer.js' +import mortice from 'mortice' +import { equals as uint8arrayEquals } from 'uint8arrays/equals' +import type { Peer } from '@libp2p/interfaces/peer-store' +import type { Datastore } from 'interface-datastore' + +const log = logger('libp2p:peer-store:store') + +const NAMESPACE_COMMON = '/peers/' + +export interface Store { + has: (peerId: PeerId) => Promise + save: (peer: Peer) => Promise + load: (peerId: PeerId) => Promise + delete: (peerId: PeerId) => Promise + merge: (peerId: PeerId, data: Partial) => Promise + mergeOrCreate: (peerId: PeerId, data: Partial) => Promise + patch: (peerId: PeerId, data: Partial) => Promise + patchOrCreate: (peerId: PeerId, data: Partial) => Promise + all: () => AsyncIterable + + lock: { + readLock: () => Promise<() => void> + writeLock: () => Promise<() => void> + } +} + +export class PersistentStore { + private readonly datastore: Datastore + public lock: any + + constructor (datastore: Datastore) { + this.datastore = datastore + this.lock = mortice({ + name: 'peer-store', + singleProcess: true + }) + } + + _peerIdToDatastoreKey (peerId: PeerId) { + if (peerId.type == null) { + log.error('peerId must be an instance of peer-id to store data') + throw errcode(new Error('peerId must be an instance of peer-id'), codes.ERR_INVALID_PARAMETERS) + } + + const b32key = peerId.toString(base32) + return new Key(`${NAMESPACE_COMMON}b${b32key}`) + } + + async has (peerId: PeerId) { + return await this.datastore.has(this._peerIdToDatastoreKey(peerId)) + } + + async delete (peerId: PeerId) { + await this.datastore.delete(this._peerIdToDatastoreKey(peerId)) + } + + async load (peerId: PeerId): Promise { + const buf = await this.datastore.get(this._peerIdToDatastoreKey(peerId)) + const peer = PeerPB.decode(buf) + const metadata = new Map() + + for (const meta of peer.metadata) { + metadata.set(meta.key, meta.value) + } + + return { + ...peer, + id: peerId, + addresses: peer.addresses.map(({ multiaddr, isCertified }) => ({ + multiaddr: new Multiaddr(multiaddr), + isCertified: isCertified ?? false + })), + metadata, + pubKey: peer.pubKey ?? undefined, + peerRecordEnvelope: peer.peerRecordEnvelope ?? undefined + } + } + + async save (peer: Peer) { + if (peer.pubKey != null && peer.id.publicKey != null && !uint8arrayEquals(peer.pubKey, peer.id.publicKey)) { + log.error('peer publicKey bytes do not match peer id publicKey bytes') + throw errcode(new Error('publicKey bytes do not match peer id publicKey bytes'), codes.ERR_INVALID_PARAMETERS) + } + + // dedupe addresses + const addressSet = new Set() + + const buf = PeerPB.encode({ + addresses: peer.addresses + .filter(address => { + if (addressSet.has(address.multiaddr.toString())) { + return false + } + + addressSet.add(address.multiaddr.toString()) + return true + }) + .sort((a, b) => { + return a.multiaddr.toString().localeCompare(b.multiaddr.toString()) + }) + .map(({ multiaddr, isCertified }) => ({ + multiaddr: multiaddr.bytes, + isCertified + })), + protocols: peer.protocols.sort(), + pubKey: peer.pubKey, + metadata: [...peer.metadata.keys()].sort().map(key => ({ key, value: peer.metadata.get(key) })), + peerRecordEnvelope: peer.peerRecordEnvelope + }).finish() + + await this.datastore.put(this._peerIdToDatastoreKey(peer.id), buf) + + return await this.load(peer.id) + } + + async patch (peerId: PeerId, data: Partial) { + const peer = await this.load(peerId) + + return await this._patch(peerId, data, peer) + } + + async patchOrCreate (peerId: PeerId, data: Partial) { + let peer: Peer + + try { + peer = await this.load(peerId) + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + + peer = { id: peerId, addresses: [], protocols: [], metadata: new Map() } + } + + return await this._patch(peerId, data, peer) + } + + async _patch (peerId: PeerId, data: Partial, peer: Peer) { + return await this.save({ + ...peer, + ...data, + id: peerId + }) + } + + async merge (peerId: PeerId, data: Partial) { + const peer = await this.load(peerId) + + return await this._merge(peerId, data, peer) + } + + async mergeOrCreate (peerId: PeerId, data: Partial) { + /** @type {Peer} */ + let peer + + try { + peer = await this.load(peerId) + } catch (err: any) { + if (err.code !== codes.ERR_NOT_FOUND) { + throw err + } + + peer = { id: peerId, addresses: [], protocols: [], metadata: new Map() } + } + + return await this._merge(peerId, data, peer) + } + + async _merge (peerId: PeerId, data: Partial, peer: Peer) { + // if the peer has certified addresses, use those in + // favour of the supplied versions + /** @type {Map} */ + const addresses = new Map() + + ;(data.addresses ?? []).forEach(addr => { + addresses.set(addr.multiaddr.toString(), addr.isCertified) + }) + + peer.addresses.forEach(({ multiaddr, isCertified }) => { + const addrStr = multiaddr.toString() + addresses.set(addrStr, Boolean(addresses.has(addrStr) ?? isCertified)) + }) + + return await this.save({ + id: peerId, + addresses: Array.from(addresses.entries()).map(([addrStr, isCertified]) => { + return { + multiaddr: new Multiaddr(addrStr), + isCertified + } + }), + protocols: Array.from(new Set([ + ...(peer.protocols ?? []), + ...(data.protocols ?? []) + ])), + metadata: new Map([ + ...(peer.metadata?.entries() ?? []), + ...(data.metadata?.entries() ?? []) + ]), + pubKey: data.pubKey ?? (peer != null ? peer.pubKey : undefined), + peerRecordEnvelope: data.peerRecordEnvelope ?? (peer != null ? peer.peerRecordEnvelope : undefined) + }) + } + + async * all () { + for await (const key of this.datastore.queryKeys({ + prefix: NAMESPACE_COMMON + })) { + // /peers/${peer-id-as-libp2p-key-cid-string-in-base-32} + const base32Str = key.toString().split('/')[2] + const buf = base32.decode(base32Str) + + yield this.load(PeerId.fromBytes(buf)) + } + } +} diff --git a/packages/libp2p-peer-store/test/address-book.spec.ts b/packages/libp2p-peer-store/test/address-book.spec.ts new file mode 100644 index 000000000..bdbd33ef4 --- /dev/null +++ b/packages/libp2p-peer-store/test/address-book.spec.ts @@ -0,0 +1,741 @@ +/* eslint-env mocha */ +/* eslint max-nested-callbacks: ["error", 6] */ + +import { expect } from 'aegir/utils/chai.js' +import { Multiaddr } from '@multiformats/multiaddr' +import { arrayEquals } from '@libp2p/utils/array-equals' +import { publicAddressesFirst } from '@libp2p/utils/address-sort' +import type { PeerId } from '@libp2p/interfaces/peer-id' +import pDefer from 'p-defer' +import { MemoryDatastore } from 'datastore-core/memory' +import { DefaultPeerStore } from '../src/index.js' +import { RecordEnvelope, PeerRecord } from '@libp2p/peer-record' +import { mockConnectionGater } from '@libp2p/interface-compliance-tests/utils/mock-connection-gater' +import { codes } from '../src/errors.js' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { PeerStore, AddressBook } from '@libp2p/interfaces/peer-store' +import { base58btc } from 'multiformats/bases/base58' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' + +/** + * @typedef {import('../../src/peer-store/types').PeerStore} PeerStore + * @typedef {import('../../src/peer-store/types').AddressBook} AddressBook + */ + +const addr1 = new Multiaddr('/ip4/127.0.0.1/tcp/8000') +const addr2 = new Multiaddr('/ip4/20.0.0.1/tcp/8001') +const addr3 = new Multiaddr('/ip4/127.0.0.1/tcp/8002') + +describe('addressBook', () => { + const connectionGater = mockConnectionGater() + let peerId: PeerId + + before(async () => { + peerId = await createEd25519PeerId() + }) + + describe('addressBook.set', () => { + let peerStore: PeerStore + let ab: AddressBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + afterEach(() => { + peerStore.removeAllListeners() + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await ab.set('invalid peerId') + } catch (err: any) { + expect(err).to.have.property('code', codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('throws invalid parameters error if no addresses provided', async () => { + try { + // @ts-expect-error invalid input + await ab.set(peerId) + } catch (err: any) { + expect(err).to.have.property('code', codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('no addresses should throw error') + }) + + it('throws invalid parameters error if invalid multiaddrs are provided', async () => { + try { + // @ts-expect-error invalid input + await ab.set(peerId, ['invalid multiaddr']) + } catch (err: any) { + expect(err).to.have.property('code', codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid multiaddrs should throw error') + }) + + it('replaces the stored content by default and emit change event', async () => { + const defer = pDefer() + const supportedMultiaddrs = [addr1, addr2] + + peerStore.once('change:multiaddrs', ({ peerId, multiaddrs }) => { + expect(peerId).to.exist() + expect(multiaddrs).to.eql(supportedMultiaddrs) + defer.resolve() + }) + + await ab.set(peerId, supportedMultiaddrs) + const addresses = await ab.get(peerId) + const multiaddrs = addresses.map((mi) => mi.multiaddr) + expect(multiaddrs).to.have.deep.members(supportedMultiaddrs) + + return await defer.promise + }) + + it('emits on set if not storing the exact same content', async () => { + const defer = pDefer() + + const supportedMultiaddrsA = [addr1, addr2] + const supportedMultiaddrsB = [addr2] + + let changeCounter = 0 + peerStore.on('change:multiaddrs', () => { + changeCounter++ + if (changeCounter > 1) { + defer.resolve() + } + }) + + // set 1 + await ab.set(peerId, supportedMultiaddrsA) + + // set 2 (same content) + await ab.set(peerId, supportedMultiaddrsB) + const addresses = await ab.get(peerId) + const multiaddrs = addresses.map((mi) => mi.multiaddr) + expect(multiaddrs).to.have.deep.members(supportedMultiaddrsB) + + await defer.promise + }) + + it('does not emit on set if it is storing the exact same content', async () => { + const defer = pDefer() + + const supportedMultiaddrs = [addr1, addr2] + + let changeCounter = 0 + peerStore.on('change:multiaddrs', () => { + changeCounter++ + if (changeCounter > 1) { + defer.reject() + } + }) + + // set 1 + await ab.set(peerId, supportedMultiaddrs) + + // set 2 (same content) + await ab.set(peerId, supportedMultiaddrs) + + // Wait 50ms for incorrect second event + setTimeout(() => { + defer.resolve() + }, 50) + + await defer.promise + }) + }) + + describe('addressBook.add', () => { + let peerStore: PeerStore + let ab: AddressBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + afterEach(() => { + peerStore.removeAllListeners() + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await ab.add('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('throws invalid parameters error if no addresses provided', async () => { + try { + // @ts-expect-error invalid input + await ab.add(peerId) + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('no addresses provided should throw error') + }) + + it('throws invalid parameters error if invalid multiaddrs are provided', async () => { + try { + // @ts-expect-error invalid input + await ab.add(peerId, ['invalid multiaddr']) + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid multiaddr should throw error') + }) + + it('does not emit event if no addresses are added', async () => { + const defer = pDefer() + + peerStore.on('peer', () => { + defer.reject() + }) + + await ab.add(peerId, []) + + // Wait 50ms for incorrect second event + setTimeout(() => { + defer.resolve() + }, 50) + + await defer.promise + }) + + it('adds the new content and emits change event', async () => { + const defer = pDefer() + + const supportedMultiaddrsA = [addr1, addr2] + const supportedMultiaddrsB = [addr3] + const finalMultiaddrs = supportedMultiaddrsA.concat(supportedMultiaddrsB) + + let changeTrigger = 2 + peerStore.on('change:multiaddrs', ({ multiaddrs }) => { + changeTrigger-- + if (changeTrigger === 0 && arrayEquals(multiaddrs, finalMultiaddrs)) { + defer.resolve() + } + }) + + // Replace + await ab.set(peerId, supportedMultiaddrsA) + let addresses = await ab.get(peerId) + let multiaddrs = addresses.map((mi) => mi.multiaddr) + expect(multiaddrs).to.have.deep.members(supportedMultiaddrsA) + + // Add + await ab.add(peerId, supportedMultiaddrsB) + addresses = await ab.get(peerId) + multiaddrs = addresses.map((mi) => mi.multiaddr) + expect(multiaddrs).to.have.deep.members(finalMultiaddrs) + + return await defer.promise + }) + + it('emits on add if the content to add not exists', async () => { + const defer = pDefer() + + const supportedMultiaddrsA = [addr1] + const supportedMultiaddrsB = [addr2] + const finalMultiaddrs = supportedMultiaddrsA.concat(supportedMultiaddrsB) + + let changeCounter = 0 + peerStore.on('change:multiaddrs', () => { + changeCounter++ + if (changeCounter > 1) { + defer.resolve() + } + }) + + // set 1 + await ab.set(peerId, supportedMultiaddrsA) + + // set 2 (content already existing) + await ab.add(peerId, supportedMultiaddrsB) + const addresses = await ab.get(peerId) + const multiaddrs = addresses.map((mi) => mi.multiaddr) + expect(multiaddrs).to.have.deep.members(finalMultiaddrs) + + await defer.promise + }) + + it('does not emit on add if the content to add already exists', async () => { + const defer = pDefer() + + const supportedMultiaddrsA = [addr1, addr2] + const supportedMultiaddrsB = [addr2] + + let changeCounter = 0 + peerStore.on('change:multiaddrs', () => { + changeCounter++ + if (changeCounter > 1) { + defer.reject() + } + }) + + // set 1 + await ab.set(peerId, supportedMultiaddrsA) + + // set 2 (content already existing) + await ab.add(peerId, supportedMultiaddrsB) + + // Wait 50ms for incorrect second event + setTimeout(() => { + defer.resolve() + }, 50) + + await defer.promise + }) + + it('does not add replicated content', async () => { + // set 1 + await ab.set(peerId, [addr1, addr1]) + + const addresses = await ab.get(peerId) + expect(addresses).to.have.lengthOf(1) + }) + }) + + describe('addressBook.get', () => { + let peerStore: PeerStore + let ab: AddressBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await ab.get('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('returns empty if no multiaddrs are known for the provided peer', async () => { + const addresses = await ab.get(peerId) + + expect(addresses).to.be.empty() + }) + + it('returns the multiaddrs stored', async () => { + const supportedMultiaddrs = [addr1, addr2] + + await ab.set(peerId, supportedMultiaddrs) + + const addresses = await ab.get(peerId) + const multiaddrs = addresses.map((mi) => mi.multiaddr) + expect(multiaddrs).to.have.deep.members(supportedMultiaddrs) + }) + }) + + describe('addressBook.getMultiaddrsForPeer', () => { + let peerStore: PeerStore + let ab: AddressBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await ab.getMultiaddrsForPeer('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('returns empty if no multiaddrs are known for the provided peer', async () => { + const addresses = await ab.getMultiaddrsForPeer(peerId) + + expect(addresses).to.be.empty() + }) + + it('returns the multiaddrs stored', async () => { + const supportedMultiaddrs = [addr1, addr2] + + await ab.set(peerId, supportedMultiaddrs) + + const multiaddrs = await ab.getMultiaddrsForPeer(peerId) + multiaddrs.forEach((m) => { + expect(m.getPeerId()).to.equal(peerId.toString(base58btc)) + }) + }) + + it('can sort multiaddrs providing a sorter', async () => { + const supportedMultiaddrs = [addr1, addr2] + await ab.set(peerId, supportedMultiaddrs) + + const multiaddrs = await ab.getMultiaddrsForPeer(peerId, publicAddressesFirst) + const sortedAddresses = publicAddressesFirst(supportedMultiaddrs.map((m) => ({ multiaddr: m, isCertified: false }))) + + multiaddrs.forEach((m, index) => { + expect(m.equals(sortedAddresses[index].multiaddr)) + }) + }) + }) + + describe('addressBook.delete', () => { + let peerStore: PeerStore + let ab: AddressBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await ab.delete('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('does not emit an event if no records exist for the peer', async () => { + const defer = pDefer() + + peerStore.on('change:multiaddrs', () => { + defer.reject() + }) + + await ab.delete(peerId) + + // Wait 50ms for incorrect invalid event + setTimeout(() => { + defer.resolve() + }, 50) + + return await defer.promise + }) + + it('emits an event if the record exists', async () => { + const defer = pDefer() + + const supportedMultiaddrs = [addr1, addr2] + await ab.set(peerId, supportedMultiaddrs) + + // Listen after set + peerStore.on('change:multiaddrs', ({ multiaddrs }) => { + expect(multiaddrs.length).to.eql(0) + defer.resolve() + }) + + await ab.delete(peerId) + + return await defer.promise + }) + }) + + describe('certified records', () => { + let peerStore: PeerStore + let ab: AddressBook + + describe('consumes a valid peer record and stores its data', () => { + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + it('no previous data in AddressBook', async () => { + const multiaddrs = [addr1, addr2] + const peerRecord = new PeerRecord({ + peerId, + multiaddrs + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + // consume peer record + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(true) + + // Validate AddressBook addresses + const addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(multiaddrs.length) + addrs.forEach((addr, index) => { + expect(addr.isCertified).to.eql(true) + expect(multiaddrs[index].equals(addr.multiaddr)).to.eql(true) + }) + }) + + it('emits change:multiaddrs event when adding multiaddrs', async () => { + const defer = pDefer() + const multiaddrs = [addr1, addr2] + const peerRecord = new PeerRecord({ + peerId, + multiaddrs + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + peerStore.once('change:multiaddrs', ({ peerId, multiaddrs }) => { + expect(peerId).to.exist() + expect(multiaddrs).to.eql(multiaddrs) + defer.resolve() + }) + + // consume peer record + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(true) + + return await defer.promise + }) + + it('emits change:multiaddrs event with same data currently in AddressBook (not certified)', async () => { + const defer = pDefer() + const multiaddrs = [addr1, addr2] + + // Set addressBook data + await ab.set(peerId, multiaddrs) + + // Validate data exists, but not certified + let addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(multiaddrs.length) + + addrs.forEach((addr, index) => { + expect(addr.isCertified).to.eql(false) + expect(multiaddrs[index].equals(addr.multiaddr)).to.eql(true) + }) + + // Create peer record + const peerRecord = new PeerRecord({ + peerId, + multiaddrs + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + peerStore.once('change:multiaddrs', ({ peerId, multiaddrs }) => { + expect(peerId).to.exist() + expect(multiaddrs).to.eql(multiaddrs) + defer.resolve() + }) + + // consume peer record + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(true) + + // Wait event + await defer.promise + + // Validate data exists and certified + addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(multiaddrs.length) + addrs.forEach((addr, index) => { + expect(addr.isCertified).to.eql(true) + expect(multiaddrs[index].equals(addr.multiaddr)).to.eql(true) + }) + }) + + it('emits change:multiaddrs event with previous partial data in AddressBook (not certified)', async () => { + const defer = pDefer() + const multiaddrs = [addr1, addr2] + + // Set addressBook data + await ab.set(peerId, [addr1]) + + // Validate data exists, but not certified + let addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(1) + expect(addrs[0].isCertified).to.eql(false) + expect(addrs[0].multiaddr.equals(addr1)).to.eql(true) + + // Create peer record + const peerRecord = new PeerRecord({ + peerId, + multiaddrs + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + peerStore.once('change:multiaddrs', ({ peerId, multiaddrs }) => { + expect(peerId).to.exist() + expect(multiaddrs).to.eql(multiaddrs) + defer.resolve() + }) + + // consume peer record + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(true) + + // Wait event + await defer.promise + + // Validate data exists and certified + addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(multiaddrs.length) + addrs.forEach((addr, index) => { + expect(addr.isCertified).to.eql(true) + expect(multiaddrs[index].equals(addr.multiaddr)).to.eql(true) + }) + }) + + it('with previous different data in AddressBook (not certified)', async () => { + const defer = pDefer() + const multiaddrsUncertified = [addr3] + const multiaddrsCertified = [addr1, addr2] + + // Set addressBook data + await ab.set(peerId, multiaddrsUncertified) + + // Validate data exists, but not certified + let addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(multiaddrsUncertified.length) + addrs.forEach((addr, index) => { + expect(addr.isCertified).to.eql(false) + expect(multiaddrsUncertified[index].equals(addr.multiaddr)).to.eql(true) + }) + + // Create peer record + const peerRecord = new PeerRecord({ + peerId, + multiaddrs: multiaddrsCertified + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + peerStore.once('change:multiaddrs', ({ peerId, multiaddrs }) => { + expect(peerId).to.exist() + expect(multiaddrs).to.eql(multiaddrs) + defer.resolve() + }) + + // consume peer record + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(true) + + // Wait event + await defer.promise + + // Validate data exists and certified + addrs = await ab.get(peerId) + expect(addrs).to.exist() + expect(addrs).to.have.lengthOf(multiaddrsCertified.length) + addrs.forEach((addr, index) => { + expect(addr.isCertified).to.eql(true) + expect(multiaddrsCertified[index].equals(addr.multiaddr)).to.eql(true) + }) + }) + }) + + describe('fails to consume invalid peer records', () => { + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + ab = peerStore.addressBook + }) + + it('invalid peer record', async () => { + const invalidEnvelope = { + payload: uint8ArrayFromString('invalid-peerRecord') + } + + // @ts-expect-error invalid input + const consumed = await ab.consumePeerRecord(invalidEnvelope) + expect(consumed).to.eql(false) + }) + + it('peer that created the envelope is not the same as the peer record', async () => { + const multiaddrs = [addr1, addr2] + + // Create peer record + const peerId2 = await createEd25519PeerId() + const peerRecord = new PeerRecord({ + peerId: peerId2, + multiaddrs + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(false) + }) + + it('does not store an outdated record', async () => { + const multiaddrs = [addr1, addr2] + const peerRecord1 = new PeerRecord({ + peerId, + multiaddrs, + seqNumber: Date.now() + }) + const peerRecord2 = new PeerRecord({ + peerId, + multiaddrs, + seqNumber: Date.now() - 1 + }) + const envelope1 = await RecordEnvelope.seal(peerRecord1, peerId) + const envelope2 = await RecordEnvelope.seal(peerRecord2, peerId) + + // Consume envelope1 (bigger seqNumber) + let consumed = await ab.consumePeerRecord(envelope1) + expect(consumed).to.eql(true) + + consumed = await ab.consumePeerRecord(envelope2) + expect(consumed).to.eql(false) + }) + + it('empty multiaddrs', async () => { + const peerRecord = new PeerRecord({ + peerId, + multiaddrs: [] + }) + const envelope = await RecordEnvelope.seal(peerRecord, peerId) + + const consumed = await ab.consumePeerRecord(envelope) + expect(consumed).to.eql(false) + }) + }) + }) +}) diff --git a/packages/libp2p-peer-store/test/key-book.spec.ts b/packages/libp2p-peer-store/test/key-book.spec.ts new file mode 100644 index 000000000..c8c00872c --- /dev/null +++ b/packages/libp2p-peer-store/test/key-book.spec.ts @@ -0,0 +1,136 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/utils/chai.js' +import sinon from 'sinon' +import { MemoryDatastore } from 'datastore-core/memory' +import { DefaultPeerStore } from '../src/index.js' +import pDefer from 'p-defer' +import { codes } from '../src/errors.js' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { PeerId } from '@libp2p/interfaces/peer-id' +import type { PeerStore, KeyBook } from '@libp2p/interfaces/peer-store' +import { base58btc } from 'multiformats/bases/base58' + +/** + * @typedef {import('../../src/peer-store/types').PeerStore} PeerStore + * @typedef {import('../../src/peer-store/types').KeyBook} KeyBook + * @typedef {import('peer-id')} PeerId + */ + +describe('keyBook', () => { + let peerId: PeerId + let peerStore: PeerStore + let kb: KeyBook + let datastore: MemoryDatastore + + beforeEach(async () => { + peerId = await createEd25519PeerId() + datastore = new MemoryDatastore() + peerStore = new DefaultPeerStore({ + peerId, + datastore + }) + kb = peerStore.keyBook + }) + + it('throws invalid parameters error if invalid PeerId is provided in set', async () => { + try { + // @ts-expect-error invalid input + await kb.set('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('throws invalid parameters error if invalid PeerId is provided in get', async () => { + try { + // @ts-expect-error invalid input + await kb.get('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('stores the peerId in the book and returns the public key', async () => { + if (peerId.publicKey == null) { + throw new Error('Public key was missing') + } + + // Set PeerId + await kb.set(peerId, peerId.publicKey) + + // Get public key + const pubKey = await kb.get(peerId) + expect(peerId.publicKey).to.equalBytes(pubKey) + }) + + it('should not store if already stored', async () => { + const spy = sinon.spy(datastore, 'put') + + if (peerId.publicKey == null) { + throw new Error('Public key was missing') + } + + // Set PeerId + await kb.set(peerId, peerId.publicKey) + await kb.set(peerId, peerId.publicKey) + + expect(spy).to.have.property('callCount', 1) + }) + + it('should emit an event when setting a key', async () => { + const defer = pDefer() + + peerStore.on('change:pubkey', ({ peerId: id, pubKey }) => { + if (peerId.publicKey == null) { + throw new Error('Public key was missing') + } + + expect(id.toString(base58btc)).to.equal(peerId.toString(base58btc)) + expect(pubKey).to.equalBytes(peerId.publicKey) + defer.resolve() + }) + + if (peerId.publicKey == null) { + throw new Error('Public key was missing') + } + + // Set PeerId + await kb.set(peerId, peerId.publicKey) + await defer.promise + }) + + it('should not set when key does not match', async () => { + const edKey = await createEd25519PeerId() + + if (peerId.publicKey == null) { + throw new Error('Public key was missing') + } + + // Set PeerId + await expect(kb.set(edKey, peerId.publicKey)).to.eventually.be.rejectedWith(/bytes do not match/) + }) + + it('should emit an event when deleting a key', async () => { + const defer = pDefer() + + if (peerId.publicKey == null) { + throw new Error('Public key was missing') + } + + await kb.set(peerId, peerId.publicKey) + + peerStore.on('change:pubkey', ({ peerId: id, pubKey }) => { + expect(id.toString(base58btc)).to.equal(peerId.toString(base58btc)) + expect(pubKey).to.be.undefined() + defer.resolve() + }) + + await kb.delete(peerId) + await defer.promise + }) +}) diff --git a/packages/libp2p-peer-store/test/metadata-book.spec.ts b/packages/libp2p-peer-store/test/metadata-book.spec.ts new file mode 100644 index 000000000..d1dfcde1e --- /dev/null +++ b/packages/libp2p-peer-store/test/metadata-book.spec.ts @@ -0,0 +1,374 @@ + +/* eslint-env mocha */ + +import { expect } from 'aegir/utils/chai.js' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { MemoryDatastore } from 'datastore-core/memory' +import pDefer from 'p-defer' +import { DefaultPeerStore } from '../src/index.js' +import { codes } from '../src/errors.js' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { PeerId } from '@libp2p/interfaces/peer-id' +import type { PeerStore, MetadataBook } from '@libp2p/interfaces/peer-store' + +describe('metadataBook', () => { + let peerId: PeerId + + before(async () => { + peerId = await createEd25519PeerId() + }) + + describe('metadataBook.set', () => { + let peerStore: PeerStore + let mb: MetadataBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + mb = peerStore.metadataBook + }) + + afterEach(() => { + peerStore.removeAllListeners() + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await mb.set('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('throws invalid parameters error if no metadata provided', async () => { + try { + // @ts-expect-error invalid input + await mb.set(peerId) + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('no key provided should throw error') + }) + + it('throws invalid parameters error if no value provided', async () => { + try { + // @ts-expect-error invalid input + await mb.setValue(peerId, 'location') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('no value provided should throw error') + }) + + it('throws invalid parameters error if value is not a buffer', async () => { + try { + // @ts-expect-error invalid input + await mb.setValue(peerId, 'location', 'mars') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid value provided should throw error') + }) + + it('stores the content and emit change event', async () => { + const defer = pDefer() + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('mars') + + peerStore.once('change:metadata', ({ peerId, metadata }) => { + expect(peerId).to.exist() + expect(metadata.get(metadataKey)).to.equalBytes(metadataValue) + defer.resolve() + }) + + await mb.setValue(peerId, metadataKey, metadataValue) + + const value = await mb.getValue(peerId, metadataKey) + expect(value).to.equalBytes(metadataValue) + + const peerMetadata = await mb.get(peerId) + expect(peerMetadata).to.exist() + expect(peerMetadata.get(metadataKey)).to.equalBytes(metadataValue) + + return await defer.promise + }) + + it('emits on set if not storing the exact same content', async () => { + const defer = pDefer() + const metadataKey = 'location' + const metadataValue1 = uint8ArrayFromString('mars') + const metadataValue2 = uint8ArrayFromString('saturn') + + let changeCounter = 0 + peerStore.on('change:metadata', () => { + changeCounter++ + if (changeCounter > 1) { + defer.resolve() + } + }) + + // set 1 + await mb.setValue(peerId, metadataKey, metadataValue1) + + // set 2 (same content) + await mb.setValue(peerId, metadataKey, metadataValue2) + + const value = await mb.getValue(peerId, metadataKey) + expect(value).to.equalBytes(metadataValue2) + + const peerMetadata = await mb.get(peerId) + expect(peerMetadata).to.exist() + expect(peerMetadata.get(metadataKey)).to.equalBytes(metadataValue2) + + return await defer.promise + }) + + it('does not emit on set if it is storing the exact same content', async () => { + const defer = pDefer() + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('mars') + + let changeCounter = 0 + peerStore.on('change:metadata', () => { + changeCounter++ + if (changeCounter > 1) { + defer.reject() + } + }) + + // set 1 + await mb.setValue(peerId, metadataKey, metadataValue) + + // set 2 (same content) + await mb.setValue(peerId, metadataKey, metadataValue) + + // Wait 50ms for incorrect second event + setTimeout(() => { + defer.resolve() + }, 50) + + return await defer.promise + }) + }) + + describe('metadataBook.get', () => { + let peerStore: PeerStore + let mb: MetadataBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + mb = peerStore.metadataBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await mb.get('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('returns empty if no metadata is known for the provided peer', async () => { + const metadata = await mb.get(peerId) + + expect(metadata).to.be.empty() + }) + + it('returns the metadata stored', async () => { + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('mars') + const metadata = new Map() + metadata.set(metadataKey, metadataValue) + + await mb.set(peerId, metadata) + + const peerMetadata = await mb.get(peerId) + expect(peerMetadata).to.exist() + expect(peerMetadata.get(metadataKey)).to.equalBytes(metadataValue) + }) + }) + + describe('metadataBook.getValue', () => { + let peerStore: PeerStore + let mb: MetadataBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + mb = peerStore.metadataBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await mb.getValue('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('returns undefined if no metadata is known for the provided peer', async () => { + const metadataKey = 'location' + const metadata = await mb.getValue(peerId, metadataKey) + + expect(metadata).to.not.exist() + }) + + it('returns the metadata value stored for the given key', async () => { + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('mars') + + await mb.setValue(peerId, metadataKey, metadataValue) + + const value = await mb.getValue(peerId, metadataKey) + expect(value).to.exist() + expect(value).to.equalBytes(metadataValue) + }) + + it('returns undefined if no metadata is known for the provided peer and key', async () => { + const metadataKey = 'location' + const metadataBadKey = 'nickname' + const metadataValue = uint8ArrayFromString('mars') + + await mb.setValue(peerId, metadataKey, metadataValue) + + const metadata = await mb.getValue(peerId, metadataBadKey) + expect(metadata).to.not.exist() + }) + }) + + describe('metadataBook.delete', () => { + let peerStore: PeerStore + let mb: MetadataBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + mb = peerStore.metadataBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await mb.delete('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('should not emit event if no records exist for the peer', async () => { + const defer = pDefer() + + peerStore.on('change:metadata', () => { + defer.reject() + }) + + await mb.delete(peerId) + + // Wait 50ms for incorrect invalid event + setTimeout(() => { + defer.resolve() + }, 50) + + return await defer.promise + }) + + it('should emit an event if the record exists for the peer', async () => { + const defer = pDefer() + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('mars') + + await mb.setValue(peerId, metadataKey, metadataValue) + + // Listen after set + peerStore.on('change:metadata', () => { + defer.resolve() + }) + + await mb.delete(peerId) + + return await defer.promise + }) + }) + + describe('metadataBook.deleteValue', () => { + let peerStore: PeerStore + let mb: MetadataBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + mb = peerStore.metadataBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + try { + // @ts-expect-error invalid input + await mb.deleteValue('invalid peerId') + } catch (err: any) { + expect(err.code).to.equal(codes.ERR_INVALID_PARAMETERS) + return + } + throw new Error('invalid peerId should throw error') + }) + + it('should not emit event if no records exist for the peer', async () => { + const defer = pDefer() + const metadataKey = 'location' + + peerStore.on('change:metadata', () => { + defer.reject() + }) + + await mb.deleteValue(peerId, metadataKey) + + // Wait 50ms for incorrect invalid event + setTimeout(() => { + defer.resolve() + }, 50) + + return await defer.promise + }) + + it('should emit event if a record exists for the peer', async () => { + const defer = pDefer() + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('mars') + + await mb.setValue(peerId, metadataKey, metadataValue) + + // Listen after set + peerStore.on('change:metadata', () => { + defer.resolve() + }) + + await mb.deleteValue(peerId, metadataKey) + + return await defer.promise + }) + }) +}) diff --git a/packages/libp2p-peer-store/test/peer-store.spec.ts b/packages/libp2p-peer-store/test/peer-store.spec.ts new file mode 100644 index 000000000..6bcd1b794 --- /dev/null +++ b/packages/libp2p-peer-store/test/peer-store.spec.ts @@ -0,0 +1,235 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/utils/chai.js' +import all from 'it-all' +import { DefaultPeerStore } from '../src/index.js' +import { Multiaddr } from '@multiformats/multiaddr' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { MemoryDatastore } from 'datastore-core/memory' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { PeerId } from '@libp2p/interfaces/peer-id' +import type { PeerStore } from '@libp2p/interfaces/peer-store' + +import { mockConnectionGater } from '@libp2p/interface-compliance-tests/utils/mock-connection-gater' +import { base58btc } from 'multiformats/bases/base58' + +const addr1 = new Multiaddr('/ip4/127.0.0.1/tcp/8000') +const addr2 = new Multiaddr('/ip4/127.0.0.1/tcp/8001') +const addr3 = new Multiaddr('/ip4/127.0.0.1/tcp/8002') +const addr4 = new Multiaddr('/ip4/127.0.0.1/tcp/8003') + +const proto1 = '/protocol1' +const proto2 = '/protocol2' +const proto3 = '/protocol3' + +/** + * @typedef {import('../../src/peer-store/types').PeerStore} PeerStore + */ + +describe('peer-store', () => { + const connectionGater = mockConnectionGater() + let peerIds: PeerId[] + before(async () => { + peerIds = await Promise.all([ + createEd25519PeerId(), + createEd25519PeerId(), + createEd25519PeerId(), + createEd25519PeerId(), + createEd25519PeerId() + ]) + }) + + describe('empty books', () => { + let peerStore: PeerStore + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId: peerIds[4], + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + }) + + it('has an empty map of peers', async () => { + const peers = await all(peerStore.getPeers()) + expect(peers.length).to.equal(0) + }) + + it('deletes a peerId', async () => { + await peerStore.addressBook.set(peerIds[0], [new Multiaddr('/ip4/127.0.0.1/tcp/4001')]) + await expect(peerStore.has(peerIds[0])).to.eventually.be.true() + await peerStore.delete(peerIds[0]) + await expect(peerStore.has(peerIds[0])).to.eventually.be.false() + }) + + it('sets the peer\'s public key to the KeyBook', async () => { + if (peerIds[0].publicKey == null) { + throw new Error('Public key was missing') + } + + await peerStore.keyBook.set(peerIds[0], peerIds[0].publicKey) + await expect(peerStore.keyBook.get(peerIds[0])).to.eventually.deep.equal(peerIds[0].publicKey) + }) + }) + + describe('previously populated books', () => { + let peerStore: PeerStore + + beforeEach(async () => { + peerStore = new DefaultPeerStore({ + peerId: peerIds[4], + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + + // Add peer0 with { addr1, addr2 } and { proto1 } + await peerStore.addressBook.set(peerIds[0], [addr1, addr2]) + await peerStore.protoBook.set(peerIds[0], [proto1]) + + // Add peer1 with { addr3 } and { proto2, proto3 } + await peerStore.addressBook.set(peerIds[1], [addr3]) + await peerStore.protoBook.set(peerIds[1], [proto2, proto3]) + + // Add peer2 with { addr4 } + await peerStore.addressBook.set(peerIds[2], [addr4]) + + // Add peer3 with { addr4 } and { proto2 } + await peerStore.addressBook.set(peerIds[3], [addr4]) + await peerStore.protoBook.set(peerIds[3], [proto2]) + }) + + it('has peers', async () => { + const peers = await all(peerStore.getPeers()) + + expect(peers.length).to.equal(4) + expect(peers.map(peer => peer.id.toString(base58btc))).to.have.members([ + peerIds[0].toString(base58btc), + peerIds[1].toString(base58btc), + peerIds[2].toString(base58btc), + peerIds[3].toString(base58btc) + ]) + }) + + it('deletes a stored peer', async () => { + await peerStore.delete(peerIds[0]) + + const peers = await all(peerStore.getPeers()) + expect(peers.length).to.equal(3) + expect(Array.from(peers.keys())).to.not.have.members([peerIds[0].toString(base58btc)]) + }) + + it('deletes a stored peer which is only on one book', async () => { + await peerStore.delete(peerIds[2]) + + const peers = await all(peerStore.getPeers()) + expect(peers.length).to.equal(3) + }) + + it('gets the stored information of a peer in all its books', async () => { + const peer = await peerStore.get(peerIds[0]) + expect(peer).to.exist() + expect(peer.protocols).to.have.members([proto1]) + + const peerMultiaddrs = peer.addresses.map((mi) => mi.multiaddr) + expect(peerMultiaddrs).to.have.deep.members([addr1, addr2]) + + expect(peer.id.toString(base58btc)).to.equal(peerIds[0].toString(base58btc)) + }) + + it('gets the stored information of a peer that is not present in all its books', async () => { + const peers = await peerStore.get(peerIds[2]) + expect(peers).to.exist() + expect(peers.protocols.length).to.eql(0) + + const peerMultiaddrs = peers.addresses.map((mi) => mi.multiaddr) + expect(peerMultiaddrs).to.have.deep.members([addr4]) + }) + + it('can find all the peers supporting a protocol', async () => { + const peerSupporting2 = [] + + for await (const peer of peerStore.getPeers()) { + if (peer.protocols.includes(proto2)) { + peerSupporting2.push(peer) + } + } + + expect(peerSupporting2.length).to.eql(2) + expect(peerSupporting2[0].id.toString(base58btc)).to.eql(peerIds[1].toString(base58btc)) + expect(peerSupporting2[1].id.toString(base58btc)).to.eql(peerIds[3].toString(base58btc)) + }) + + it('can find all the peers listening on a given address', async () => { + const peerListening4 = [] + + for await (const peer of peerStore.getPeers()) { + const multiaddrs = peer.addresses.map((mi) => mi.multiaddr.toString()) + + if (multiaddrs.includes(addr4.toString())) { + peerListening4.push(peer) + } + } + + expect(peerListening4.length).to.eql(2) + expect(peerListening4[0].id.toString(base58btc)).to.eql(peerIds[2].toString(base58btc)) + expect(peerListening4[1].id.toString(base58btc)).to.eql(peerIds[3].toString(base58btc)) + }) + }) + + describe('peerStore.getPeers', () => { + let peerStore: PeerStore + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId: peerIds[4], + datastore: new MemoryDatastore(), + addressFilter: connectionGater.filterMultiaddrForPeer + }) + }) + + it('returns peers if only addresses are known', async () => { + await peerStore.addressBook.set(peerIds[0], [addr1]) + + const peers = await all(peerStore.getPeers()) + expect(peers.length).to.equal(1) + + const peerData = peers[0] + expect(peerData).to.exist() + expect(peerData.id).to.exist() + expect(peerData.addresses).to.have.lengthOf(1) + expect(peerData.protocols).to.have.lengthOf(0) + expect(peerData.metadata).to.be.empty() + }) + + it('returns peers if only protocols are known', async () => { + await peerStore.protoBook.set(peerIds[0], [proto1]) + + const peers = await all(peerStore.getPeers()) + expect(peers.length).to.equal(1) + + const peerData = peers[0] + expect(peerData).to.exist() + expect(peerData.id).to.exist() + expect(peerData.addresses).to.have.lengthOf(0) + expect(peerData.protocols).to.have.lengthOf(1) + expect(peerData.metadata).to.be.empty() + }) + + it('returns peers if only metadata is known', async () => { + const metadataKey = 'location' + const metadataValue = uint8ArrayFromString('earth') + await peerStore.metadataBook.setValue(peerIds[0], metadataKey, metadataValue) + + const peers = await all(peerStore.getPeers()) + expect(peers.length).to.equal(1) + + const peerData = peers[0] + expect(peerData).to.exist() + expect(peerData.id).to.exist() + expect(peerData.addresses).to.have.lengthOf(0) + expect(peerData.protocols).to.have.lengthOf(0) + expect(peerData.metadata).to.exist() + expect(peerData.metadata.get(metadataKey)).to.equalBytes(metadataValue) + }) + }) +}) diff --git a/packages/libp2p-peer-store/test/proto-book.spec.ts b/packages/libp2p-peer-store/test/proto-book.spec.ts new file mode 100644 index 000000000..f3aec77f6 --- /dev/null +++ b/packages/libp2p-peer-store/test/proto-book.spec.ts @@ -0,0 +1,410 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/utils/chai.js' +import sinon from 'sinon' +import { MemoryDatastore } from 'datastore-core/memory' +import pDefer from 'p-defer' +import pWaitFor from 'p-wait-for' +import { DefaultPeerStore } from '../src/index.js' +import { codes } from '../src/errors.js' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { PeerId } from '@libp2p/interfaces/peer-id' +import type { PeerStore, ProtoBook } from '@libp2p/interfaces/peer-store' + +const arraysAreEqual = (a: string[], b: string[]) => { + if (a.length !== b.length) { + return false + } + + return a.sort().every((item, index) => b[index] === item) +} + +describe('protoBook', () => { + let peerId: PeerId + + before(async () => { + peerId = await createEd25519PeerId() + }) + + describe('protoBook.set', () => { + let peerStore: PeerStore + let pb: ProtoBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + pb = peerStore.protoBook + }) + + afterEach(() => { + peerStore.removeAllListeners() + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + // @ts-expect-error invalid input + await expect(pb.set('invalid peerId')).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('throws invalid parameters error if no protocols provided', async () => { + // @ts-expect-error invalid input + await expect(pb.set(peerId)).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('replaces the stored content by default and emit change event', async () => { + const defer = pDefer() + const supportedProtocols = ['protocol1', 'protocol2'] + + peerStore.once('change:protocols', ({ peerId, protocols }) => { + expect(peerId).to.exist() + expect(protocols).to.have.deep.members(supportedProtocols) + defer.resolve() + }) + + await pb.set(peerId, supportedProtocols) + const protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(supportedProtocols) + + await defer.promise + }) + + it('emits on set if not storing the exact same content', async () => { + const defer = pDefer() + + const supportedProtocolsA = ['protocol1', 'protocol2'] + const supportedProtocolsB = ['protocol2'] + + let changeCounter = 0 + peerStore.on('change:protocols', () => { + changeCounter++ + if (changeCounter > 1) { + defer.resolve() + } + }) + + // set 1 + await pb.set(peerId, supportedProtocolsA) + + // set 2 (same content) + await pb.set(peerId, supportedProtocolsB) + const protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(supportedProtocolsB) + + await defer.promise + }) + + it('does not emit on set if it is storing the exact same content', async () => { + const defer = pDefer() + + const supportedProtocols = ['protocol1', 'protocol2'] + + let changeCounter = 0 + peerStore.on('change:protocols', () => { + changeCounter++ + if (changeCounter > 1) { + defer.reject() + } + }) + + // set 1 + await pb.set(peerId, supportedProtocols) + + // set 2 (same content) + await pb.set(peerId, supportedProtocols) + + // Wait 50ms for incorrect second event + setTimeout(() => { + defer.resolve() + }, 50) + + return await defer.promise + }) + }) + + describe('protoBook.add', () => { + let peerStore: PeerStore + let pb: ProtoBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + pb = peerStore.protoBook + }) + + afterEach(() => { + peerStore.removeAllListeners() + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + // @ts-expect-error invalid input + await expect(pb.add('invalid peerId')).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('throws invalid parameters error if no protocols provided', async () => { + // @ts-expect-error invalid input + await expect(pb.add(peerId)).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('adds the new content and emits change event', async () => { + const defer = pDefer() + + const supportedProtocolsA = ['protocol1', 'protocol2'] + const supportedProtocolsB = ['protocol3'] + const finalProtocols = supportedProtocolsA.concat(supportedProtocolsB) + + let changeTrigger = 2 + peerStore.on('change:protocols', ({ protocols }) => { + changeTrigger-- + if (changeTrigger === 0 && arraysAreEqual(protocols, finalProtocols)) { + defer.resolve() + } + }) + + // Replace + await pb.set(peerId, supportedProtocolsA) + let protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(supportedProtocolsA) + + // Add + await pb.add(peerId, supportedProtocolsB) + protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(finalProtocols) + + return await defer.promise + }) + + it('emits on add if the content to add not exists', async () => { + const defer = pDefer() + + const supportedProtocolsA = ['protocol1'] + const supportedProtocolsB = ['protocol2'] + const finalProtocols = supportedProtocolsA.concat(supportedProtocolsB) + + let changeCounter = 0 + peerStore.on('change:protocols', () => { + changeCounter++ + if (changeCounter > 1) { + defer.resolve() + } + }) + + // set 1 + await pb.set(peerId, supportedProtocolsA) + + // set 2 (content already existing) + await pb.add(peerId, supportedProtocolsB) + const protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(finalProtocols) + + return await defer.promise + }) + + it('does not emit on add if the content to add already exists', async () => { + const defer = pDefer() + + const supportedProtocolsA = ['protocol1', 'protocol2'] + const supportedProtocolsB = ['protocol2'] + + let changeCounter = 0 + peerStore.on('change:protocols', () => { + changeCounter++ + if (changeCounter > 1) { + defer.reject() + } + }) + + // set 1 + await pb.set(peerId, supportedProtocolsA) + + // set 2 (content already existing) + await pb.add(peerId, supportedProtocolsB) + + // Wait 50ms for incorrect second event + setTimeout(() => { + defer.resolve() + }, 50) + + return await defer.promise + }) + }) + + describe('protoBook.remove', () => { + let peerStore: PeerStore + let pb: ProtoBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + pb = peerStore.protoBook + }) + + afterEach(() => { + peerStore.removeAllListeners() + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + // @ts-expect-error invalid input + await expect(pb.remove('invalid peerId')).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('throws invalid parameters error if no protocols provided', async () => { + // @ts-expect-error invalid input + await expect(pb.remove(peerId)).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('removes the given protocol and emits change event', async () => { + const spy = sinon.spy() + + const supportedProtocols = ['protocol1', 'protocol2'] + const removedProtocols = ['protocol1'] + const finalProtocols = supportedProtocols.filter(p => !removedProtocols.includes(p)) + + peerStore.on('change:protocols', spy) + + // Replace + await pb.set(peerId, supportedProtocols) + let protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(supportedProtocols) + + // Remove + await pb.remove(peerId, removedProtocols) + protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(finalProtocols) + + await pWaitFor(() => spy.callCount === 2) + + const [firstCallArgs] = spy.firstCall.args + const [secondCallArgs] = spy.secondCall.args + expect(arraysAreEqual(firstCallArgs.protocols, supportedProtocols)) + expect(arraysAreEqual(secondCallArgs.protocols, finalProtocols)) + }) + + it('emits on remove if the content changes', async () => { + const spy = sinon.spy() + + const supportedProtocols = ['protocol1', 'protocol2'] + const removedProtocols = ['protocol2'] + const finalProtocols = supportedProtocols.filter(p => !removedProtocols.includes(p)) + + peerStore.on('change:protocols', spy) + + // set + await pb.set(peerId, supportedProtocols) + + // remove (content already existing) + await pb.remove(peerId, removedProtocols) + const protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(finalProtocols) + + return await pWaitFor(() => spy.callCount === 2) + }) + + it('does not emit on remove if the content does not change', async () => { + const spy = sinon.spy() + + const supportedProtocols = ['protocol1', 'protocol2'] + const removedProtocols = ['protocol3'] + + peerStore.on('change:protocols', spy) + + // set + await pb.set(peerId, supportedProtocols) + + // remove + await pb.remove(peerId, removedProtocols) + + // Only one event + expect(spy.callCount).to.eql(1) + }) + }) + + describe('protoBook.get', () => { + let peerStore: PeerStore + let pb: ProtoBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + pb = peerStore.protoBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + // @ts-expect-error invalid input + await expect(pb.get('invalid peerId')).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('returns empty if no protocols are known for the provided peer', async () => { + const protocols = await pb.get(peerId) + + expect(protocols).to.be.empty() + }) + + it('returns the protocols stored', async () => { + const supportedProtocols = ['protocol1', 'protocol2'] + + await pb.set(peerId, supportedProtocols) + + const protocols = await pb.get(peerId) + expect(protocols).to.have.deep.members(supportedProtocols) + }) + }) + + describe('protoBook.delete', () => { + let peerStore: PeerStore + let pb: ProtoBook + + beforeEach(() => { + peerStore = new DefaultPeerStore({ + peerId, + datastore: new MemoryDatastore() + }) + pb = peerStore.protoBook + }) + + it('throws invalid parameters error if invalid PeerId is provided', async () => { + // @ts-expect-error invalid input + await expect(pb.delete('invalid peerId')).to.eventually.be.rejected().with.property('code', codes.ERR_INVALID_PARAMETERS) + }) + + it('should not emit event if no records exist for the peer', async () => { + const defer = pDefer() + + peerStore.on('change:protocols', () => { + defer.reject() + }) + + await pb.delete(peerId) + + // Wait 50ms for incorrect invalid event + setTimeout(() => { + defer.resolve() + }, 50) + + await defer.promise + }) + + it('should emit event if a record exists for the peer', async () => { + const defer = pDefer() + + const supportedProtocols = ['protocol1', 'protocol2'] + await pb.set(peerId, supportedProtocols) + + // Listen after set + peerStore.on('change:protocols', ({ protocols }) => { + expect(protocols.length).to.eql(0) + defer.resolve() + }) + + await pb.delete(peerId) + + await defer.promise + }) + }) +}) diff --git a/packages/libp2p-peer-store/tsconfig.json b/packages/libp2p-peer-store/tsconfig.json new file mode 100644 index 000000000..bec6fed0c --- /dev/null +++ b/packages/libp2p-peer-store/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist", + "emitDeclarationOnly": false, + "module": "ES2020" + }, + "include": [ + "src", + "test" + ], + "exclude": [ + "src/pb/peer.js" // exclude generated file + ], + "references": [ + { + "path": "../libp2p-interfaces" + }, + { + "path": "../libp2p-logger" + }, + { + "path": "../libp2p-peer-record" + } + ] +} diff --git a/packages/libp2p-pubsub/package.json b/packages/libp2p-pubsub/package.json index 6c230a710..894938b76 100644 --- a/packages/libp2p-pubsub/package.json +++ b/packages/libp2p-pubsub/package.json @@ -188,7 +188,7 @@ "dependencies": { "@libp2p/crypto": "^0.22.2", "@libp2p/interfaces": "^1.0.0", - "@libp2p/logger": "^0.0.0", + "@libp2p/logger": "^1.0.1", "@libp2p/peer-id": "^1.0.0", "@libp2p/peer-id-factory": "^1.0.0", "@libp2p/topology": "^1.0.0", diff --git a/packages/libp2p-pubsub/src/peer-streams.ts b/packages/libp2p-pubsub/src/peer-streams.ts index f5394761b..26bec6b48 100644 --- a/packages/libp2p-pubsub/src/peer-streams.ts +++ b/packages/libp2p-pubsub/src/peer-streams.ts @@ -5,7 +5,7 @@ import { pushable } from 'it-pushable' import { pipe } from 'it-pipe' import { abortableSource } from 'abortable-iterator' import type { PeerId } from '@libp2p/interfaces/peer-id' -import type { MuxedStream } from '@libp2p/interfaces/stream-muxer' +import type { Stream } from '@libp2p/interfaces/connection' import type { Pushable } from 'it-pushable' const log = logger('libp2p-pubsub:peer-streams') @@ -32,11 +32,11 @@ export class PeerStreams extends EventEmitter { /** * The raw outbound stream, as retrieved from conn.newStream */ - private _rawOutboundStream: MuxedStream | undefined + private _rawOutboundStream: Stream | undefined /** * The raw inbound stream, as retrieved from the callback from libp2p.handle */ - private _rawInboundStream: MuxedStream | undefined + private _rawInboundStream: Stream | undefined /** * An AbortController for controlled shutdown of the inbound stream */ @@ -81,7 +81,7 @@ export class PeerStreams extends EventEmitter { /** * Attach a raw inbound stream and setup a read stream */ - attachInboundStream (stream: MuxedStream) { + attachInboundStream (stream: Stream) { // Create and attach a new inbound stream // The inbound stream is: // - abortable, set to only return on abort, rather than throw @@ -103,7 +103,7 @@ export class PeerStreams extends EventEmitter { /** * Attach a raw outbound stream and setup a write stream */ - async attachOutboundStream (stream: MuxedStream) { + async attachOutboundStream (stream: Stream) { // If an outbound stream already exists, gently close it const _prevStream = this.outboundStream if (this.outboundStream != null) { @@ -115,7 +115,7 @@ export class PeerStreams extends EventEmitter { this.outboundStream = pushable({ onEnd: (shouldEmit) => { // close writable side of the stream - if ((this._rawOutboundStream?.reset) != null) { + if (this._rawOutboundStream != null && this._rawOutboundStream.reset != null) { // eslint-disable-line @typescript-eslint/prefer-optional-chain this._rawOutboundStream.reset() } diff --git a/packages/libp2p-pubsub/test/lifesycle.spec.ts b/packages/libp2p-pubsub/test/lifecycle.spec.ts similarity index 100% rename from packages/libp2p-pubsub/test/lifesycle.spec.ts rename to packages/libp2p-pubsub/test/lifecycle.spec.ts diff --git a/packages/libp2p-pubsub/test/utils/index.ts b/packages/libp2p-pubsub/test/utils/index.ts index e7a836ca2..8fe4e03bb 100644 --- a/packages/libp2p-pubsub/test/utils/index.ts +++ b/packages/libp2p-pubsub/test/utils/index.ts @@ -34,7 +34,11 @@ export const mockRegistrar = { export const createMockRegistrar = (registrarRecord: Map>) => { const registrar: Registrar = { - handle: (multicodecs: string[], handler) => { + handle: (multicodecs: string[] | string, handler) => { + if (!Array.isArray(multicodecs)) { + multicodecs = [multicodecs] + } + const rec = registrarRecord.get(multicodecs[0]) ?? {} registrarRecord.set(multicodecs[0], { @@ -66,6 +70,7 @@ export const createMockRegistrar = (registrarRecord: Map { throw new Error('Not implemented') }, + // @ts-expect-error use protobook type protoBook: { get: () => { throw new Error('Not implemented') diff --git a/packages/libp2p-topology/package.json b/packages/libp2p-topology/package.json index dde4ddef2..97d1b1817 100644 --- a/packages/libp2p-topology/package.json +++ b/packages/libp2p-topology/package.json @@ -147,8 +147,10 @@ }, "dependencies": { "@libp2p/interfaces": "^1.0.0", + "@libp2p/logger": "^1.0.1", "@multiformats/multiaddr": "^10.1.1", - "err-code": "^3.0.1" + "err-code": "^3.0.1", + "it-all": "^1.0.6" }, "devDependencies": { "aegir": "^36.1.3" diff --git a/packages/libp2p-topology/src/multicodec-topology.ts b/packages/libp2p-topology/src/multicodec-topology.ts index dbe3c493b..60a6a8f7e 100644 --- a/packages/libp2p-topology/src/multicodec-topology.ts +++ b/packages/libp2p-topology/src/multicodec-topology.ts @@ -1,10 +1,14 @@ import { Topology } from './index.js' +import all from 'it-all' +import { logger } from '@libp2p/logger' import type { PeerId } from '@libp2p/interfaces/peer-id' -import type { PeerData } from '@libp2p/interfaces/peer-data' +import type { Peer } from '@libp2p/interfaces/peer-store' import type { Connection } from '@libp2p/interfaces/connection' import type { Registrar } from '@libp2p/interfaces/registrar' import type { MulticodecTopologyOptions } from '@libp2p/interfaces/topology' +const log = logger('libp2p:topology:multicodec-topology') + interface ChangeProtocolsEvent { peerId: PeerId protocols: string[] @@ -36,7 +40,7 @@ export class MulticodecTopology extends Topology { return Boolean(multicodecTopologySymbol in other) } - set registrar (registrar: Registrar | undefined) { + async setRegistrar (registrar: Registrar | undefined) { if (registrar == null) { return } @@ -47,7 +51,7 @@ export class MulticodecTopology extends Topology { registrar.connectionManager.on('peer:connect', this._onPeerConnect.bind(this)) // Update topology peers - this._updatePeers(registrar.peerStore.peers.values()) + await this._updatePeers(registrar.peerStore.getPeers()) } get registrar () { @@ -56,11 +60,11 @@ export class MulticodecTopology extends Topology { /** * Update topology - * - * @param peerDatas */ - _updatePeers (peerDatas: Iterable) { - for (const { id, protocols } of peerDatas) { + async _updatePeers (peerDataIterable: Iterable | AsyncIterable) { + const peerDatas = await all(peerDataIterable) + + for await (const { id, protocols } of peerDatas) { if (this.multicodecs.filter(multicodec => protocols.includes(multicodec)).length > 0) { // Add the peer regardless of whether or not there is currently a connection this.peers.add(id.toString()) @@ -93,14 +97,19 @@ export class MulticodecTopology extends Topology { this._onDisconnect(peerId) } + let p: Promise | undefined + // New to protocol support for (const protocol of protocols) { if (this.multicodecs.includes(protocol)) { - const peerData = this._registrar.peerStore.get(peerId) - this._updatePeers([peerData]) - return + p = this._registrar.peerStore.get(peerId).then(async peerData => await this._updatePeers([peerData])) + break } } + + if (p != null) { + p.catch(err => log.error(err)) + } } /** @@ -112,15 +121,13 @@ export class MulticodecTopology extends Topology { } const peerId = connection.remotePeer - const protocols = this._registrar.peerStore.protoBook.get(peerId) - - if (protocols == null) { - return - } - - if (this.multicodecs.find(multicodec => protocols.includes(multicodec)) != null) { - this.peers.add(peerId.toString()) - this._onConnect(peerId, connection) - } + this._registrar.peerStore.protoBook.get(peerId) + .then(protocols => { + if (this.multicodecs.find(multicodec => protocols.includes(multicodec)) != null) { + this.peers.add(peerId.toString()) + this._onConnect(peerId, connection) + } + }) + .catch(err => log.error(err)) } }