mirror of
https://github.com/logos-messaging/js-waku.git
synced 2026-01-07 16:23:09 +00:00
feat(static-sharding): filter peer connections per shards (#1626)
* add interface for `ShardInfo` * enr: add deserialization logic & setup getters * add sharding related utils * utils: add shard<-> bytes conversion helpers * pass `pubSubTopics` to `Waku` * add `rs`/`rsv` details during discovery * connection-manager: discard irrelevant peers * add tests for static sharding - peer exchange * update `ConnectionManager` tests to account for topic validity * add js suffix to import * address some comments * move shardInfo encoding to ENR * test: update for new API * enr: add tests for serialisation & deserialisation * address comment * update test * move getPeershardInfo to ConnectionManager and return ShardInfo instead of bytes * update encoding and decoding relay shards to also factor for shards>64 * relay shard encoding decoding: use DataView and verbose spec tests * improve tests for relay shard encoding decoding * rm: only * improve log message for unconfigured pubsub topic * minor improvement * fix: buffer <> Uint8array problems with shard decoding * fix: test * rm: only
This commit is contained in:
parent
fe64da1881
commit
124a29ebba
4
package-lock.json
generated
4
package-lock.json
generated
@ -25763,6 +25763,7 @@
|
|||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^1.3.2",
|
"@noble/hashes": "^1.3.2",
|
||||||
|
"@waku/enr": "^0.0.17",
|
||||||
"@waku/interfaces": "0.0.18",
|
"@waku/interfaces": "0.0.18",
|
||||||
"@waku/proto": "0.0.5",
|
"@waku/proto": "0.0.5",
|
||||||
"@waku/utils": "0.0.11",
|
"@waku/utils": "0.0.11",
|
||||||
@ -25864,6 +25865,7 @@
|
|||||||
"@waku/interfaces": "0.0.18",
|
"@waku/interfaces": "0.0.18",
|
||||||
"chai": "^4.3.7",
|
"chai": "^4.3.7",
|
||||||
"cspell": "^7.3.2",
|
"cspell": "^7.3.2",
|
||||||
|
"fast-check": "^3.13.1",
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@ -29012,6 +29014,7 @@
|
|||||||
"@types/mocha": "^10.0.1",
|
"@types/mocha": "^10.0.1",
|
||||||
"@types/uuid": "^9.0.3",
|
"@types/uuid": "^9.0.3",
|
||||||
"@waku/build-utils": "*",
|
"@waku/build-utils": "*",
|
||||||
|
"@waku/enr": "^0.0.17",
|
||||||
"@waku/interfaces": "0.0.18",
|
"@waku/interfaces": "0.0.18",
|
||||||
"@waku/proto": "0.0.5",
|
"@waku/proto": "0.0.5",
|
||||||
"@waku/utils": "0.0.11",
|
"@waku/utils": "0.0.11",
|
||||||
@ -29116,6 +29119,7 @@
|
|||||||
"chai": "^4.3.7",
|
"chai": "^4.3.7",
|
||||||
"cspell": "^7.3.2",
|
"cspell": "^7.3.2",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
|
"fast-check": "^3.13.1",
|
||||||
"js-sha3": "^0.9.2",
|
"js-sha3": "^0.9.2",
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
|||||||
@ -73,6 +73,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^1.3.2",
|
"@noble/hashes": "^1.3.2",
|
||||||
|
"@waku/enr": "^0.0.17",
|
||||||
"@waku/interfaces": "0.0.18",
|
"@waku/interfaces": "0.0.18",
|
||||||
"@waku/proto": "0.0.5",
|
"@waku/proto": "0.0.5",
|
||||||
"@waku/utils": "0.0.11",
|
"@waku/utils": "0.0.11",
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { PeerId } from "@libp2p/interface/peer-id";
|
import type { PeerId } from "@libp2p/interface/peer-id";
|
||||||
import type { PeerInfo } from "@libp2p/interface/peer-info";
|
import type { PeerInfo } from "@libp2p/interface/peer-info";
|
||||||
import type { Peer } from "@libp2p/interface/peer-store";
|
import type { Peer } from "@libp2p/interface/peer-store";
|
||||||
|
import type { PeerStore } from "@libp2p/interface/peer-store";
|
||||||
import { CustomEvent, EventEmitter } from "@libp2p/interfaces/events";
|
import { CustomEvent, EventEmitter } from "@libp2p/interfaces/events";
|
||||||
|
import { decodeRelayShard } from "@waku/enr";
|
||||||
import {
|
import {
|
||||||
ConnectionManagerOptions,
|
ConnectionManagerOptions,
|
||||||
EPeersByDiscoveryEvents,
|
EPeersByDiscoveryEvents,
|
||||||
@ -9,9 +11,12 @@ import {
|
|||||||
IPeersByDiscoveryEvents,
|
IPeersByDiscoveryEvents,
|
||||||
IRelay,
|
IRelay,
|
||||||
KeepAliveOptions,
|
KeepAliveOptions,
|
||||||
PeersByDiscoveryResult
|
PeersByDiscoveryResult,
|
||||||
|
PubSubTopic,
|
||||||
|
ShardInfo
|
||||||
} from "@waku/interfaces";
|
} from "@waku/interfaces";
|
||||||
import { Libp2p, Tags } from "@waku/interfaces";
|
import { Libp2p, Tags } from "@waku/interfaces";
|
||||||
|
import { shardInfoToPubSubTopics } from "@waku/utils";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
|
|
||||||
import { KeepAliveManager } from "./keep_alive_manager.js";
|
import { KeepAliveManager } from "./keep_alive_manager.js";
|
||||||
@ -40,6 +45,7 @@ export class ConnectionManager
|
|||||||
peerId: string,
|
peerId: string,
|
||||||
libp2p: Libp2p,
|
libp2p: Libp2p,
|
||||||
keepAliveOptions: KeepAliveOptions,
|
keepAliveOptions: KeepAliveOptions,
|
||||||
|
pubSubTopics: PubSubTopic[],
|
||||||
relay?: IRelay,
|
relay?: IRelay,
|
||||||
options?: ConnectionManagerOptions
|
options?: ConnectionManagerOptions
|
||||||
): ConnectionManager {
|
): ConnectionManager {
|
||||||
@ -48,6 +54,7 @@ export class ConnectionManager
|
|||||||
instance = new ConnectionManager(
|
instance = new ConnectionManager(
|
||||||
libp2p,
|
libp2p,
|
||||||
keepAliveOptions,
|
keepAliveOptions,
|
||||||
|
pubSubTopics,
|
||||||
relay,
|
relay,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
@ -104,11 +111,13 @@ export class ConnectionManager
|
|||||||
private constructor(
|
private constructor(
|
||||||
libp2p: Libp2p,
|
libp2p: Libp2p,
|
||||||
keepAliveOptions: KeepAliveOptions,
|
keepAliveOptions: KeepAliveOptions,
|
||||||
|
private configuredPubSubTopics: PubSubTopic[],
|
||||||
relay?: IRelay,
|
relay?: IRelay,
|
||||||
options?: Partial<ConnectionManagerOptions>
|
options?: Partial<ConnectionManagerOptions>
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.libp2p = libp2p;
|
this.libp2p = libp2p;
|
||||||
|
this.configuredPubSubTopics = configuredPubSubTopics;
|
||||||
this.options = {
|
this.options = {
|
||||||
maxDialAttemptsForPeer: DEFAULT_MAX_DIAL_ATTEMPTS_FOR_PEER,
|
maxDialAttemptsForPeer: DEFAULT_MAX_DIAL_ATTEMPTS_FOR_PEER,
|
||||||
maxBootstrapPeersAllowed: DEFAULT_MAX_BOOTSTRAP_PEERS_ALLOWED,
|
maxBootstrapPeersAllowed: DEFAULT_MAX_BOOTSTRAP_PEERS_ALLOWED,
|
||||||
@ -314,6 +323,20 @@ export class ConnectionManager
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const { id: peerId } = evt.detail;
|
const { id: peerId } = evt.detail;
|
||||||
|
|
||||||
|
if (!(await this.isPeerTopicConfigured(peerId))) {
|
||||||
|
const shardInfo = await this.getPeerShardInfo(
|
||||||
|
peerId,
|
||||||
|
this.libp2p.peerStore
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
`Discovered peer ${peerId.toString()} with ShardInfo ${shardInfo} is not part of any of the configured pubsub topics (${
|
||||||
|
this.configuredPubSubTopics
|
||||||
|
}).
|
||||||
|
Not dialing.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isBootstrap = (await this.getTagNamesForPeer(peerId)).includes(
|
const isBootstrap = (await this.getTagNamesForPeer(peerId)).includes(
|
||||||
Tags.BOOTSTRAP
|
Tags.BOOTSTRAP
|
||||||
);
|
);
|
||||||
@ -430,4 +453,31 @@ export class ConnectionManager
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isPeerTopicConfigured(peerId: PeerId): Promise<boolean> {
|
||||||
|
const shardInfo = await this.getPeerShardInfo(
|
||||||
|
peerId,
|
||||||
|
this.libp2p.peerStore
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there's no shard information, simply return true
|
||||||
|
if (!shardInfo) return true;
|
||||||
|
|
||||||
|
const pubSubTopics = shardInfoToPubSubTopics(shardInfo);
|
||||||
|
|
||||||
|
const isTopicConfigured = pubSubTopics.some((topic) =>
|
||||||
|
this.configuredPubSubTopics.includes(topic)
|
||||||
|
);
|
||||||
|
return isTopicConfigured;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPeerShardInfo(
|
||||||
|
peerId: PeerId,
|
||||||
|
peerStore: PeerStore
|
||||||
|
): Promise<ShardInfo | undefined> {
|
||||||
|
const peer = await peerStore.get(peerId);
|
||||||
|
const shardInfoBytes = peer.metadata.get("shardInfo");
|
||||||
|
if (!shardInfoBytes) return undefined;
|
||||||
|
return decodeRelayShard(shardInfoBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type {
|
|||||||
IRelay,
|
IRelay,
|
||||||
IStore,
|
IStore,
|
||||||
Libp2p,
|
Libp2p,
|
||||||
|
PubSubTopic,
|
||||||
Waku
|
Waku
|
||||||
} from "@waku/interfaces";
|
} from "@waku/interfaces";
|
||||||
import { Protocols } from "@waku/interfaces";
|
import { Protocols } from "@waku/interfaces";
|
||||||
@ -52,6 +53,7 @@ export class WakuNode implements Waku {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
options: WakuOptions,
|
options: WakuOptions,
|
||||||
|
public readonly pubSubTopics: PubSubTopic[],
|
||||||
libp2p: Libp2p,
|
libp2p: Libp2p,
|
||||||
store?: (libp2p: Libp2p) => IStore,
|
store?: (libp2p: Libp2p) => IStore,
|
||||||
lightPush?: (libp2p: Libp2p) => ILightPush,
|
lightPush?: (libp2p: Libp2p) => ILightPush,
|
||||||
@ -86,6 +88,7 @@ export class WakuNode implements Waku {
|
|||||||
peerId,
|
peerId,
|
||||||
libp2p,
|
libp2p,
|
||||||
{ pingKeepAlive, relayKeepAlive },
|
{ pingKeepAlive, relayKeepAlive },
|
||||||
|
pubSubTopics,
|
||||||
this.relay
|
this.relay
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
} from "@libp2p/interface/peer-discovery";
|
} from "@libp2p/interface/peer-discovery";
|
||||||
import { peerDiscovery as symbol } from "@libp2p/interface/peer-discovery";
|
import { peerDiscovery as symbol } from "@libp2p/interface/peer-discovery";
|
||||||
import type { PeerInfo } from "@libp2p/interface/peer-info";
|
import type { PeerInfo } from "@libp2p/interface/peer-info";
|
||||||
|
import { encodeRelayShard } from "@waku/enr";
|
||||||
import type {
|
import type {
|
||||||
DnsDiscOptions,
|
DnsDiscOptions,
|
||||||
DnsDiscoveryComponents,
|
DnsDiscoveryComponents,
|
||||||
@ -72,18 +73,16 @@ export class PeerDiscoveryDns
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const peerInfo = peerEnr.peerInfo;
|
const { peerInfo, shardInfo } = peerEnr;
|
||||||
|
|
||||||
if (!peerInfo) {
|
if (!peerInfo) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsToUpdate = {
|
const tagsToUpdate = {
|
||||||
tags: {
|
[DEFAULT_BOOTSTRAP_TAG_NAME]: {
|
||||||
[DEFAULT_BOOTSTRAP_TAG_NAME]: {
|
value: this._options.tagValue ?? DEFAULT_BOOTSTRAP_TAG_VALUE,
|
||||||
value: this._options.tagValue ?? DEFAULT_BOOTSTRAP_TAG_VALUE,
|
ttl: this._options.tagTTL ?? DEFAULT_BOOTSTRAP_TAG_TTL
|
||||||
ttl: this._options.tagTTL ?? DEFAULT_BOOTSTRAP_TAG_TTL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -96,11 +95,20 @@ export class PeerDiscoveryDns
|
|||||||
|
|
||||||
if (!hasBootstrapTag) {
|
if (!hasBootstrapTag) {
|
||||||
isPeerChanged = true;
|
isPeerChanged = true;
|
||||||
await this._components.peerStore.merge(peerInfo.id, tagsToUpdate);
|
await this._components.peerStore.merge(peerInfo.id, {
|
||||||
|
tags: tagsToUpdate
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
isPeerChanged = true;
|
isPeerChanged = true;
|
||||||
await this._components.peerStore.save(peerInfo.id, tagsToUpdate);
|
await this._components.peerStore.save(peerInfo.id, {
|
||||||
|
tags: tagsToUpdate,
|
||||||
|
...(shardInfo && {
|
||||||
|
metadata: {
|
||||||
|
shardInfo: encodeRelayShard(shardInfo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPeerChanged) {
|
if (isPeerChanged) {
|
||||||
|
|||||||
@ -71,6 +71,7 @@
|
|||||||
"@waku/interfaces": "0.0.18",
|
"@waku/interfaces": "0.0.18",
|
||||||
"chai": "^4.3.7",
|
"chai": "^4.3.7",
|
||||||
"cspell": "^7.3.2",
|
"cspell": "^7.3.2",
|
||||||
|
"fast-check": "^3.13.1",
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import type {
|
|||||||
ENRValue,
|
ENRValue,
|
||||||
IEnr,
|
IEnr,
|
||||||
NodeId,
|
NodeId,
|
||||||
SequenceNumber
|
SequenceNumber,
|
||||||
|
ShardInfo
|
||||||
} from "@waku/interfaces";
|
} from "@waku/interfaces";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
|
|
||||||
@ -64,6 +65,13 @@ export class ENR extends RawEnr implements IEnr {
|
|||||||
protocol: TransportProtocol | TransportProtocolPerIpVersion
|
protocol: TransportProtocol | TransportProtocolPerIpVersion
|
||||||
) => Multiaddr | undefined = locationMultiaddrFromEnrFields.bind({}, this);
|
) => Multiaddr | undefined = locationMultiaddrFromEnrFields.bind({}, this);
|
||||||
|
|
||||||
|
get shardInfo(): ShardInfo | undefined {
|
||||||
|
if (this.rs && this.rsv) {
|
||||||
|
log("Warning: ENR contains both `rs` and `rsv` fields.");
|
||||||
|
}
|
||||||
|
return this.rs || this.rsv;
|
||||||
|
}
|
||||||
|
|
||||||
setLocationMultiaddr(multiaddr: Multiaddr): void {
|
setLocationMultiaddr(multiaddr: Multiaddr): void {
|
||||||
const protoNames = multiaddr.protoNames();
|
const protoNames = multiaddr.protoNames();
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -5,3 +5,4 @@ export * from "./enr.js";
|
|||||||
export * from "./peer_id.js";
|
export * from "./peer_id.js";
|
||||||
export * from "./waku2_codec.js";
|
export * from "./waku2_codec.js";
|
||||||
export * from "./crypto.js";
|
export * from "./crypto.js";
|
||||||
|
export * from "./relay_shard_codec.js";
|
||||||
|
|||||||
@ -3,11 +3,18 @@ import {
|
|||||||
convertToBytes,
|
convertToBytes,
|
||||||
convertToString
|
convertToString
|
||||||
} from "@multiformats/multiaddr/convert";
|
} from "@multiformats/multiaddr/convert";
|
||||||
import type { ENRKey, ENRValue, SequenceNumber, Waku2 } from "@waku/interfaces";
|
import type {
|
||||||
|
ENRKey,
|
||||||
|
ENRValue,
|
||||||
|
SequenceNumber,
|
||||||
|
ShardInfo,
|
||||||
|
Waku2
|
||||||
|
} from "@waku/interfaces";
|
||||||
import { bytesToUtf8 } from "@waku/utils/bytes";
|
import { bytesToUtf8 } from "@waku/utils/bytes";
|
||||||
|
|
||||||
import { ERR_INVALID_ID } from "./constants.js";
|
import { ERR_INVALID_ID } from "./constants.js";
|
||||||
import { decodeMultiaddrs, encodeMultiaddrs } from "./multiaddrs_codec.js";
|
import { decodeMultiaddrs, encodeMultiaddrs } from "./multiaddrs_codec.js";
|
||||||
|
import { decodeRelayShard } from "./relay_shard_codec.js";
|
||||||
import { decodeWaku2, encodeWaku2 } from "./waku2_codec.js";
|
import { decodeWaku2, encodeWaku2 } from "./waku2_codec.js";
|
||||||
|
|
||||||
export class RawEnr extends Map<ENRKey, ENRValue> {
|
export class RawEnr extends Map<ENRKey, ENRValue> {
|
||||||
@ -45,6 +52,18 @@ export class RawEnr extends Map<ENRKey, ENRValue> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get rs(): ShardInfo | undefined {
|
||||||
|
const rs = this.get("rs");
|
||||||
|
if (!rs) return undefined;
|
||||||
|
return decodeRelayShard(rs);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rsv(): ShardInfo | undefined {
|
||||||
|
const rsv = this.get("rsv");
|
||||||
|
if (!rsv) return undefined;
|
||||||
|
return decodeRelayShard(rsv);
|
||||||
|
}
|
||||||
|
|
||||||
get ip(): string | undefined {
|
get ip(): string | undefined {
|
||||||
return getStringValue(this, "ip", "ip4");
|
return getStringValue(this, "ip", "ip4");
|
||||||
}
|
}
|
||||||
|
|||||||
68
packages/enr/src/relay_shard_codec.spec.ts
Normal file
68
packages/enr/src/relay_shard_codec.spec.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { expect } from "chai";
|
||||||
|
import fc from "fast-check";
|
||||||
|
|
||||||
|
import { decodeRelayShard, encodeRelayShard } from "./relay_shard_codec.js";
|
||||||
|
|
||||||
|
describe("Relay Shard codec", () => {
|
||||||
|
// Boundary test case
|
||||||
|
it("should handle a minimal index list", () => {
|
||||||
|
const shardInfo = { cluster: 0, indexList: [0] };
|
||||||
|
const encoded = encodeRelayShard(shardInfo);
|
||||||
|
const decoded = decodeRelayShard(encoded);
|
||||||
|
expect(decoded).to.deep.equal(
|
||||||
|
shardInfo,
|
||||||
|
"Decoded shard info does not match the original for minimal index list"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Property-based test for rs format (Index List)
|
||||||
|
it("should correctly encode and decode relay shards using rs format (Index List)", () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.nat(65535), // cluster
|
||||||
|
fc
|
||||||
|
.array(fc.nat(1023), { minLength: 1, maxLength: 63 }) // indexList
|
||||||
|
.map((arr) => [...new Set(arr)].sort((a, b) => a - b)),
|
||||||
|
(cluster, indexList) => {
|
||||||
|
const shardInfo = { cluster, indexList };
|
||||||
|
const encoded = encodeRelayShard(shardInfo);
|
||||||
|
const decoded = decodeRelayShard(encoded);
|
||||||
|
|
||||||
|
expect(decoded).to.deep.equal(
|
||||||
|
shardInfo,
|
||||||
|
"Decoded shard info does not match the original for rs format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Property-based test for rsv format (Bit Vector)
|
||||||
|
it("should correctly encode and decode relay shards using rsv format (Bit Vector)", () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.nat(65535), // cluster
|
||||||
|
fc
|
||||||
|
.array(fc.nat(1023), { minLength: 64, maxLength: 1024 }) // indexList
|
||||||
|
.map((arr) => [...new Set(arr)].sort((a, b) => a - b)),
|
||||||
|
(cluster, indexList) => {
|
||||||
|
const shardInfo = { cluster, indexList };
|
||||||
|
const encoded = encodeRelayShard(shardInfo);
|
||||||
|
const decoded = decodeRelayShard(encoded);
|
||||||
|
|
||||||
|
expect(decoded).to.deep.equal(
|
||||||
|
shardInfo,
|
||||||
|
"Decoded shard info does not match the original for rsv format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling test case
|
||||||
|
it("should throw an error for insufficient data", () => {
|
||||||
|
expect(() => decodeRelayShard(new Uint8Array([0, 0]))).to.throw(
|
||||||
|
"Insufficient data"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
packages/enr/src/relay_shard_codec.ts
Normal file
60
packages/enr/src/relay_shard_codec.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { ShardInfo } from "@waku/interfaces";
|
||||||
|
|
||||||
|
export const decodeRelayShard = (bytes: Uint8Array): ShardInfo => {
|
||||||
|
// explicitly converting to Uint8Array to avoid Buffer
|
||||||
|
// https://github.com/libp2p/js-libp2p/issues/2146
|
||||||
|
bytes = new Uint8Array(bytes);
|
||||||
|
|
||||||
|
if (bytes.length < 3) throw new Error("Insufficient data");
|
||||||
|
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
const cluster = view.getUint16(0);
|
||||||
|
|
||||||
|
const indexList = [];
|
||||||
|
|
||||||
|
if (bytes.length === 130) {
|
||||||
|
// rsv format (Bit Vector)
|
||||||
|
for (let i = 0; i < 1024; i++) {
|
||||||
|
const byteIndex = Math.floor(i / 8) + 2; // Adjusted for the 2-byte cluster field
|
||||||
|
const bitIndex = 7 - (i % 8);
|
||||||
|
if (view.getUint8(byteIndex) & (1 << bitIndex)) {
|
||||||
|
indexList.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// rs format (Index List)
|
||||||
|
const numIndices = view.getUint8(2);
|
||||||
|
for (let i = 0, offset = 3; i < numIndices; i++, offset += 2) {
|
||||||
|
if (offset + 1 >= bytes.length) throw new Error("Unexpected end of data");
|
||||||
|
indexList.push(view.getUint16(offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cluster, indexList };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeRelayShard = (shardInfo: ShardInfo): Uint8Array => {
|
||||||
|
const { cluster, indexList } = shardInfo;
|
||||||
|
const totalLength = indexList.length >= 64 ? 130 : 3 + 2 * indexList.length;
|
||||||
|
const buffer = new ArrayBuffer(totalLength);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
view.setUint16(0, cluster);
|
||||||
|
|
||||||
|
if (indexList.length >= 64) {
|
||||||
|
// rsv format (Bit Vector)
|
||||||
|
for (const index of indexList) {
|
||||||
|
const byteIndex = Math.floor(index / 8) + 2; // Adjusted for the 2-byte cluster field
|
||||||
|
const bitIndex = 7 - (index % 8);
|
||||||
|
view.setUint8(byteIndex, view.getUint8(byteIndex) | (1 << bitIndex));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// rs format (Index List)
|
||||||
|
view.setUint8(2, indexList.length);
|
||||||
|
for (let i = 0, offset = 3; i < indexList.length; i++, offset += 2) {
|
||||||
|
view.setUint16(offset, indexList[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
};
|
||||||
@ -18,6 +18,11 @@ export interface Waku2 {
|
|||||||
lightPush: boolean;
|
lightPush: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShardInfo {
|
||||||
|
cluster: number;
|
||||||
|
indexList: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IEnr extends Map<ENRKey, ENRValue> {
|
export interface IEnr extends Map<ENRKey, ENRValue> {
|
||||||
nodeId?: NodeId;
|
nodeId?: NodeId;
|
||||||
peerId?: PeerId;
|
peerId?: PeerId;
|
||||||
@ -34,6 +39,7 @@ export interface IEnr extends Map<ENRKey, ENRValue> {
|
|||||||
multiaddrs?: Multiaddr[];
|
multiaddrs?: Multiaddr[];
|
||||||
waku2?: Waku2;
|
waku2?: Waku2;
|
||||||
peerInfo: PeerInfo | undefined;
|
peerInfo: PeerInfo | undefined;
|
||||||
|
shardInfo?: ShardInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated: use { @link IEnr.peerInfo } instead.
|
* @deprecated: use { @link IEnr.peerInfo } instead.
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type {
|
|||||||
import { peerDiscovery as symbol } from "@libp2p/interface/peer-discovery";
|
import { peerDiscovery as symbol } from "@libp2p/interface/peer-discovery";
|
||||||
import type { PeerId } from "@libp2p/interface/peer-id";
|
import type { PeerId } from "@libp2p/interface/peer-id";
|
||||||
import type { PeerInfo } from "@libp2p/interface/peer-info";
|
import type { PeerInfo } from "@libp2p/interface/peer-info";
|
||||||
|
import { encodeRelayShard } from "@waku/enr";
|
||||||
import { Libp2pComponents, Tags } from "@waku/interfaces";
|
import { Libp2pComponents, Tags } from "@waku/interfaces";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
|
|
||||||
@ -174,7 +175,7 @@ export class PeerExchangeDiscovery
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { peerId, peerInfo } = ENR;
|
const { peerId, peerInfo, shardInfo } = ENR;
|
||||||
if (!peerId || !peerInfo) {
|
if (!peerId || !peerInfo) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -191,7 +192,12 @@ export class PeerExchangeDiscovery
|
|||||||
value: this.options.tagValue ?? DEFAULT_PEER_EXCHANGE_TAG_VALUE,
|
value: this.options.tagValue ?? DEFAULT_PEER_EXCHANGE_TAG_VALUE,
|
||||||
ttl: this.options.tagTTL ?? DEFAULT_PEER_EXCHANGE_TAG_TTL
|
ttl: this.options.tagTTL ?? DEFAULT_PEER_EXCHANGE_TAG_TTL
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
...(shardInfo && {
|
||||||
|
metadata: {
|
||||||
|
shardInfo: encodeRelayShard(shardInfo)
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
log(`Discovered peer: ${peerId.toString()}`);
|
log(`Discovered peer: ${peerId.toString()}`);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { mplex } from "@libp2p/mplex";
|
|||||||
import { webSockets } from "@libp2p/websockets";
|
import { webSockets } from "@libp2p/websockets";
|
||||||
import { all as filterAll } from "@libp2p/websockets/filters";
|
import { all as filterAll } from "@libp2p/websockets/filters";
|
||||||
import {
|
import {
|
||||||
|
DefaultPubSubTopic,
|
||||||
DefaultUserAgent,
|
DefaultUserAgent,
|
||||||
wakuFilter,
|
wakuFilter,
|
||||||
wakuLightPush,
|
wakuLightPush,
|
||||||
@ -43,6 +44,12 @@ export { Libp2pComponents };
|
|||||||
export async function createLightNode(
|
export async function createLightNode(
|
||||||
options?: ProtocolCreateOptions & WakuOptions
|
options?: ProtocolCreateOptions & WakuOptions
|
||||||
): Promise<LightNode> {
|
): Promise<LightNode> {
|
||||||
|
options = options ?? {};
|
||||||
|
|
||||||
|
if (!options.pubSubTopics) {
|
||||||
|
options.pubSubTopics = [DefaultPubSubTopic];
|
||||||
|
}
|
||||||
|
|
||||||
const libp2pOptions = options?.libp2p ?? {};
|
const libp2pOptions = options?.libp2p ?? {};
|
||||||
const peerDiscovery = libp2pOptions.peerDiscovery ?? [];
|
const peerDiscovery = libp2pOptions.peerDiscovery ?? [];
|
||||||
if (options?.defaultBootstrap) {
|
if (options?.defaultBootstrap) {
|
||||||
@ -62,6 +69,7 @@ export async function createLightNode(
|
|||||||
|
|
||||||
return new WakuNode(
|
return new WakuNode(
|
||||||
options ?? {},
|
options ?? {},
|
||||||
|
options.pubSubTopics,
|
||||||
libp2p,
|
libp2p,
|
||||||
store,
|
store,
|
||||||
lightPush,
|
lightPush,
|
||||||
@ -76,6 +84,12 @@ export async function createLightNode(
|
|||||||
export async function createRelayNode(
|
export async function createRelayNode(
|
||||||
options?: ProtocolCreateOptions & WakuOptions & Partial<RelayCreateOptions>
|
options?: ProtocolCreateOptions & WakuOptions & Partial<RelayCreateOptions>
|
||||||
): Promise<RelayNode> {
|
): Promise<RelayNode> {
|
||||||
|
options = options ?? {};
|
||||||
|
|
||||||
|
if (!options.pubSubTopics) {
|
||||||
|
options.pubSubTopics = [DefaultPubSubTopic];
|
||||||
|
}
|
||||||
|
|
||||||
const libp2pOptions = options?.libp2p ?? {};
|
const libp2pOptions = options?.libp2p ?? {};
|
||||||
const peerDiscovery = libp2pOptions.peerDiscovery ?? [];
|
const peerDiscovery = libp2pOptions.peerDiscovery ?? [];
|
||||||
if (options?.defaultBootstrap) {
|
if (options?.defaultBootstrap) {
|
||||||
@ -92,7 +106,8 @@ export async function createRelayNode(
|
|||||||
const relay = wakuRelay(options);
|
const relay = wakuRelay(options);
|
||||||
|
|
||||||
return new WakuNode(
|
return new WakuNode(
|
||||||
options ?? {},
|
options,
|
||||||
|
options.pubSubTopics,
|
||||||
libp2p,
|
libp2p,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@ -117,6 +132,12 @@ export async function createRelayNode(
|
|||||||
export async function createFullNode(
|
export async function createFullNode(
|
||||||
options?: ProtocolCreateOptions & WakuOptions & Partial<RelayCreateOptions>
|
options?: ProtocolCreateOptions & WakuOptions & Partial<RelayCreateOptions>
|
||||||
): Promise<FullNode> {
|
): Promise<FullNode> {
|
||||||
|
options = options ?? {};
|
||||||
|
|
||||||
|
if (!options.pubSubTopics) {
|
||||||
|
options.pubSubTopics = [DefaultPubSubTopic];
|
||||||
|
}
|
||||||
|
|
||||||
const libp2pOptions = options?.libp2p ?? {};
|
const libp2pOptions = options?.libp2p ?? {};
|
||||||
const peerDiscovery = libp2pOptions.peerDiscovery ?? [];
|
const peerDiscovery = libp2pOptions.peerDiscovery ?? [];
|
||||||
if (options?.defaultBootstrap) {
|
if (options?.defaultBootstrap) {
|
||||||
@ -137,6 +158,7 @@ export async function createFullNode(
|
|||||||
|
|
||||||
return new WakuNode(
|
return new WakuNode(
|
||||||
options ?? {},
|
options ?? {},
|
||||||
|
options.pubSubTopics,
|
||||||
libp2p,
|
libp2p,
|
||||||
store,
|
store,
|
||||||
lightPush,
|
lightPush,
|
||||||
|
|||||||
@ -146,16 +146,23 @@ describe("ConnectionManager", function () {
|
|||||||
let dialPeerStub: SinonStub;
|
let dialPeerStub: SinonStub;
|
||||||
let getConnectionsStub: SinonStub;
|
let getConnectionsStub: SinonStub;
|
||||||
let getTagNamesForPeerStub: SinonStub;
|
let getTagNamesForPeerStub: SinonStub;
|
||||||
|
let isPeerTopicConfigured: SinonStub;
|
||||||
let waku: LightNode;
|
let waku: LightNode;
|
||||||
|
|
||||||
this.beforeEach(async function () {
|
this.beforeEach(async function () {
|
||||||
this.timeout(15000);
|
this.timeout(15000);
|
||||||
waku = await createLightNode();
|
waku = await createLightNode();
|
||||||
|
isPeerTopicConfigured = sinon.stub(
|
||||||
|
waku.connectionManager as any,
|
||||||
|
"isPeerTopicConfigured"
|
||||||
|
);
|
||||||
|
isPeerTopicConfigured.resolves(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
this.timeout(15000);
|
this.timeout(15000);
|
||||||
await waku.stop();
|
await waku.stop();
|
||||||
|
isPeerTopicConfigured.restore();
|
||||||
sinon.restore();
|
sinon.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
186
packages/tests/tests/sharding/peer_management.spec.ts
Normal file
186
packages/tests/tests/sharding/peer_management.spec.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { bootstrap } from "@libp2p/bootstrap";
|
||||||
|
import type { PeerId } from "@libp2p/interface/peer-id";
|
||||||
|
import { wakuPeerExchangeDiscovery } from "@waku/peer-exchange";
|
||||||
|
import { createLightNode, LightNode, Tags } from "@waku/sdk";
|
||||||
|
import chai, { expect } from "chai";
|
||||||
|
import chaiAsPromised from "chai-as-promised";
|
||||||
|
import Sinon, { SinonSpy } from "sinon";
|
||||||
|
|
||||||
|
import { delay } from "../../src/delay.js";
|
||||||
|
import { makeLogFileName } from "../../src/log_file.js";
|
||||||
|
import { NimGoNode } from "../../src/node/node.js";
|
||||||
|
|
||||||
|
chai.use(chaiAsPromised);
|
||||||
|
|
||||||
|
describe("Static Sharding: Peer Management", function () {
|
||||||
|
describe("Peer Exchange", function () {
|
||||||
|
let waku: LightNode;
|
||||||
|
let nwaku1: NimGoNode;
|
||||||
|
let nwaku2: NimGoNode;
|
||||||
|
let nwaku3: NimGoNode;
|
||||||
|
|
||||||
|
let attemptDialSpy: SinonSpy;
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
nwaku1 = new NimGoNode(makeLogFileName(this) + "1");
|
||||||
|
nwaku2 = new NimGoNode(makeLogFileName(this) + "2");
|
||||||
|
nwaku3 = new NimGoNode(makeLogFileName(this) + "3");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
this.timeout(5_000);
|
||||||
|
await nwaku1?.stop();
|
||||||
|
await nwaku2?.stop();
|
||||||
|
await nwaku3?.stop();
|
||||||
|
!!waku && waku.stop().catch((e) => console.log("Waku failed to stop", e));
|
||||||
|
|
||||||
|
attemptDialSpy && attemptDialSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all px service nodes subscribed to the shard topic should be dialed", async function () {
|
||||||
|
this.timeout(100_000);
|
||||||
|
|
||||||
|
const pubSubTopics = ["/waku/2/rs/18/2"];
|
||||||
|
|
||||||
|
await nwaku1.start({
|
||||||
|
topic: pubSubTopics,
|
||||||
|
discv5Discovery: true,
|
||||||
|
peerExchange: true,
|
||||||
|
relay: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const enr1 = (await nwaku1.info()).enrUri;
|
||||||
|
|
||||||
|
await nwaku2.start({
|
||||||
|
topic: pubSubTopics,
|
||||||
|
discv5Discovery: true,
|
||||||
|
peerExchange: true,
|
||||||
|
discv5BootstrapNode: enr1,
|
||||||
|
relay: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const enr2 = (await nwaku2.info()).enrUri;
|
||||||
|
|
||||||
|
await nwaku3.start({
|
||||||
|
topic: pubSubTopics,
|
||||||
|
discv5Discovery: true,
|
||||||
|
peerExchange: true,
|
||||||
|
discv5BootstrapNode: enr2,
|
||||||
|
relay: true
|
||||||
|
});
|
||||||
|
const nwaku3Ma = await nwaku3.getMultiaddrWithId();
|
||||||
|
|
||||||
|
waku = await createLightNode({
|
||||||
|
pubSubTopics,
|
||||||
|
libp2p: {
|
||||||
|
peerDiscovery: [
|
||||||
|
bootstrap({ list: [nwaku3Ma.toString()] }),
|
||||||
|
wakuPeerExchangeDiscovery()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await waku.start();
|
||||||
|
|
||||||
|
attemptDialSpy = Sinon.spy(
|
||||||
|
(waku as any).connectionManager,
|
||||||
|
"attemptDial"
|
||||||
|
);
|
||||||
|
|
||||||
|
const pxPeersDiscovered = new Set<PeerId>();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
waku.libp2p.addEventListener("peer:discovery", (evt) => {
|
||||||
|
return void (async () => {
|
||||||
|
const peerId = evt.detail.id;
|
||||||
|
const peer = await waku.libp2p.peerStore.get(peerId);
|
||||||
|
const tags = Array.from(peer.tags.keys());
|
||||||
|
if (tags.includes(Tags.PEER_EXCHANGE)) {
|
||||||
|
pxPeersDiscovered.add(peerId);
|
||||||
|
if (pxPeersDiscovered.size === 2) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
expect(attemptDialSpy.callCount).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("px service nodes not subscribed to the shard should not be dialed", async function () {
|
||||||
|
this.timeout(100_000);
|
||||||
|
const pubSubTopicsToDial = ["/waku/2/rs/18/2"];
|
||||||
|
const pubSubTopicsToIgnore = ["/waku/2/rs/18/3"];
|
||||||
|
|
||||||
|
// this service node is not subscribed to the shard
|
||||||
|
await nwaku1.start({
|
||||||
|
topic: pubSubTopicsToIgnore,
|
||||||
|
relay: true,
|
||||||
|
discv5Discovery: true,
|
||||||
|
peerExchange: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const enr1 = (await nwaku1.info()).enrUri;
|
||||||
|
|
||||||
|
await nwaku2.start({
|
||||||
|
topic: pubSubTopicsToDial,
|
||||||
|
relay: true,
|
||||||
|
discv5Discovery: true,
|
||||||
|
peerExchange: true,
|
||||||
|
discv5BootstrapNode: enr1
|
||||||
|
});
|
||||||
|
|
||||||
|
const enr2 = (await nwaku2.info()).enrUri;
|
||||||
|
|
||||||
|
await nwaku3.start({
|
||||||
|
relay: true,
|
||||||
|
discv5Discovery: true,
|
||||||
|
peerExchange: true,
|
||||||
|
discv5BootstrapNode: enr2
|
||||||
|
});
|
||||||
|
const nwaku3Ma = await nwaku3.getMultiaddrWithId();
|
||||||
|
|
||||||
|
waku = await createLightNode({
|
||||||
|
pubSubTopics: pubSubTopicsToDial,
|
||||||
|
libp2p: {
|
||||||
|
peerDiscovery: [
|
||||||
|
bootstrap({ list: [nwaku3Ma.toString()] }),
|
||||||
|
wakuPeerExchangeDiscovery()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
attemptDialSpy = Sinon.spy(
|
||||||
|
(waku as any).connectionManager,
|
||||||
|
"attemptDial"
|
||||||
|
);
|
||||||
|
|
||||||
|
await waku.start();
|
||||||
|
|
||||||
|
const pxPeersDiscovered = new Set<PeerId>();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
waku.libp2p.addEventListener("peer:discovery", (evt) => {
|
||||||
|
return void (async () => {
|
||||||
|
const peerId = evt.detail.id;
|
||||||
|
const peer = await waku.libp2p.peerStore.get(peerId);
|
||||||
|
const tags = Array.from(peer.tags.keys());
|
||||||
|
if (tags.includes(Tags.PEER_EXCHANGE)) {
|
||||||
|
pxPeersDiscovered.add(peerId);
|
||||||
|
if (pxPeersDiscovered.size === 1) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
expect(attemptDialSpy.callCount).to.equal(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,19 +1,16 @@
|
|||||||
import { createLightNode, LightNode, utf8ToBytes } from "@waku/sdk";
|
import { LightNode } from "@waku/interfaces";
|
||||||
import { createEncoder } from "@waku/sdk";
|
import { createEncoder, createLightNode, utf8ToBytes } from "@waku/sdk";
|
||||||
import chai, { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import chaiAsPromised from "chai-as-promised";
|
|
||||||
|
|
||||||
import { makeLogFileName } from "../src/log_file.js";
|
import { makeLogFileName } from "../../src/log_file.js";
|
||||||
import { NimGoNode } from "../src/node/node.js";
|
import { NimGoNode } from "../../src/node/node.js";
|
||||||
|
|
||||||
const PubSubTopic1 = "/waku/2/rs/0/2";
|
const PubSubTopic1 = "/waku/2/rs/0/2";
|
||||||
const PubSubTopic2 = "/waku/2/rs/0/3";
|
const PubSubTopic2 = "/waku/2/rs/0/3";
|
||||||
|
|
||||||
const ContentTopic = "/waku/2/content/test";
|
const ContentTopic = "/waku/2/content/test.js";
|
||||||
|
|
||||||
chai.use(chaiAsPromised);
|
describe("Static Sharding: Running Nodes", () => {
|
||||||
|
|
||||||
describe("Static Sharding", () => {
|
|
||||||
let waku: LightNode;
|
let waku: LightNode;
|
||||||
let nwaku: NimGoNode;
|
let nwaku: NimGoNode;
|
||||||
|
|
||||||
@ -1,4 +1,12 @@
|
|||||||
import type { PubSubTopic } from "@waku/interfaces";
|
import type { PubSubTopic, ShardInfo } from "@waku/interfaces";
|
||||||
|
|
||||||
|
export const shardInfoToPubSubTopics = (
|
||||||
|
shardInfo: ShardInfo
|
||||||
|
): PubSubTopic[] => {
|
||||||
|
return shardInfo.indexList.map(
|
||||||
|
(index) => `/waku/2/rs/${shardInfo.cluster}/${index}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function ensurePubsubTopicIsConfigured(
|
export function ensurePubsubTopicIsConfigured(
|
||||||
pubsubTopic: PubSubTopic,
|
pubsubTopic: PubSubTopic,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user