@waku/discovery
Version:
Contains various discovery mechanisms: DNS Discovery (EIP-1459, Peer Exchange, Local Peer Cache Discovery.
318 lines (270 loc) • 8.79 kB
text/typescript
import { TypedEventEmitter } from "@libp2p/interface";
import { peerDiscoverySymbol as symbol } from "@libp2p/interface";
import type {
IdentifyResult,
PeerDiscovery,
PeerDiscoveryEvents,
PeerId,
PeerInfo
} from "@libp2p/interface";
import {
type Libp2pComponents,
type PeerExchangeQueryResult,
ShardInfo,
Tags
} from "@waku/interfaces";
import { decodeRelayShard, encodeRelayShard, Logger } from "@waku/utils";
import { PeerExchangeCodec, WakuPeerExchange } from "./waku_peer_exchange.js";
const log = new Logger("peer-exchange-discovery");
const DEFAULT_PEER_EXCHANGE_REQUEST_NODES = 10;
const DEFAULT_PEER_EXCHANGE_QUERY_INTERVAL_MS = 10 * 1000;
const DEFAULT_MAX_RETRIES = 3;
export interface Options {
/**
* Tag a bootstrap peer with this name before "discovering" it (default: 'bootstrap')
*/
tagName?: string;
/**
* The bootstrap peer tag will have this value (default: 50)
*/
tagValue?: number;
/**
* Cause the bootstrap peer tag to be removed after this number of ms (default: 2 minutes)
*/
tagTTL?: number;
/**
* The interval between queries to a peer (default: 10 seconds)
* The interval will increase by a factor of an incrementing number (starting at 1)
* until it reaches the maximum attempts before backoff
*/
queryInterval?: number;
/**
* The number of attempts before the queries to a peer are aborted (default: 3)
*/
maxRetries?: number;
}
interface CustomDiscoveryEvent extends PeerDiscoveryEvents {
"waku:peer-exchange:started": CustomEvent<boolean>;
}
export const DEFAULT_PEER_EXCHANGE_TAG_NAME = Tags.PEER_EXCHANGE;
const DEFAULT_PEER_EXCHANGE_TAG_VALUE = 50;
const DEFAULT_PEER_EXCHANGE_TAG_TTL = 100_000_000;
export class PeerExchangeDiscovery
extends TypedEventEmitter<CustomDiscoveryEvent>
implements PeerDiscovery
{
private readonly components: Libp2pComponents;
private readonly peerExchange: WakuPeerExchange;
private readonly options: Options;
private isStarted: boolean;
private queryingPeers: Set<string> = new Set();
private queryAttempts: Map<string, number> = new Map();
private readonly handleDiscoveredPeer = (
event: CustomEvent<IdentifyResult>
): void => {
const { protocols, peerId } = event.detail;
if (
!protocols.includes(PeerExchangeCodec) ||
this.queryingPeers.has(peerId.toString())
)
return;
this.queryingPeers.add(peerId.toString());
this.startRecurringQueries(peerId).catch((error) =>
log.error(`Error querying peer ${error}`)
);
};
public constructor(components: Libp2pComponents, options: Options = {}) {
super();
this.components = components;
this.peerExchange = new WakuPeerExchange(components);
this.options = options;
this.isStarted = false;
}
/**
* Start emitting events
*/
public start(): void {
if (this.isStarted) {
return;
}
this.dispatchEvent(
new CustomEvent("waku:peer-exchange:started", { detail: true })
);
log.info("Starting peer exchange node discovery, discovering peers");
// might be better to use "peer:identify" or "peer:update"
this.components.events.addEventListener(
"peer:identify",
this.handleDiscoveredPeer
);
}
/**
* Remove event listener
*/
public stop(): void {
if (!this.isStarted) return;
log.info("Stopping peer exchange node discovery");
this.isStarted = false;
this.queryingPeers.clear();
this.components.events.removeEventListener(
"peer:identify",
this.handleDiscoveredPeer
);
}
public get [symbol](): true {
return true;
}
public get [Symbol.toStringTag](): string {
return "@waku/peer-exchange";
}
private readonly startRecurringQueries = async (
peerId: PeerId
): Promise<void> => {
const peerIdStr = peerId.toString();
const {
queryInterval = DEFAULT_PEER_EXCHANGE_QUERY_INTERVAL_MS,
maxRetries = DEFAULT_MAX_RETRIES
} = this.options;
log.info(
`Querying peer: ${peerIdStr} (attempt ${
this.queryAttempts.get(peerIdStr) ?? 1
})`
);
await this.query(peerId);
const currentAttempt = this.queryAttempts.get(peerIdStr) ?? 1;
if (currentAttempt > maxRetries) {
this.abortQueriesForPeer(peerIdStr);
return;
}
setTimeout(() => {
this.queryAttempts.set(peerIdStr, currentAttempt + 1);
this.startRecurringQueries(peerId).catch((error) => {
log.error(`Error in startRecurringQueries: ${error}`);
});
}, queryInterval * currentAttempt);
};
private async query(peerId: PeerId): Promise<PeerExchangeQueryResult> {
const { error, peerInfos } = await this.peerExchange.query({
numPeers: DEFAULT_PEER_EXCHANGE_REQUEST_NODES,
peerId
});
if (error) {
log.error("Peer exchange query failed", error);
return { error, peerInfos: null };
}
for (const _peerInfo of peerInfos) {
const { ENR } = _peerInfo;
if (!ENR) {
log.warn("No ENR in peerInfo object, skipping");
continue;
}
const { peerId, peerInfo, shardInfo } = ENR;
if (!peerId || !peerInfo) {
continue;
}
const hasPeer = await this.components.peerStore.has(peerId);
if (hasPeer) {
const { hasMultiaddrDiff, hasShardDiff } = await this.checkPeerInfoDiff(
peerInfo,
shardInfo
);
if (hasMultiaddrDiff || hasShardDiff) {
log.info(
`Peer ${peerId.toString()} has updated multiaddrs or shardInfo, updating`
);
if (hasMultiaddrDiff) {
log.info(
`Peer ${peerId.toString()} has updated multiaddrs, updating`
);
await this.components.peerStore.patch(peerId, {
multiaddrs: peerInfo.multiaddrs
});
}
if (hasShardDiff && shardInfo) {
log.info(
`Peer ${peerId.toString()} has updated shardInfo, updating`
);
await this.components.peerStore.merge(peerId, {
metadata: {
shardInfo: encodeRelayShard(shardInfo)
}
});
this.dispatchEvent(
new CustomEvent<PeerInfo>("peer", {
detail: {
id: peerId,
multiaddrs: peerInfo.multiaddrs
}
})
);
}
continue;
}
}
// update the tags for the peer
await this.components.peerStore.save(peerId, {
tags: {
[DEFAULT_PEER_EXCHANGE_TAG_NAME]: {
value: this.options.tagValue ?? DEFAULT_PEER_EXCHANGE_TAG_VALUE,
ttl: this.options.tagTTL ?? DEFAULT_PEER_EXCHANGE_TAG_TTL
}
},
...(shardInfo && {
metadata: {
shardInfo: encodeRelayShard(shardInfo)
}
}),
...(peerInfo.multiaddrs && {
multiaddrs: peerInfo.multiaddrs
})
});
log.info(`Discovered peer: ${peerId.toString()}`);
this.dispatchEvent(
new CustomEvent<PeerInfo>("peer", {
detail: {
id: peerId,
multiaddrs: peerInfo.multiaddrs
}
})
);
}
return { error: null, peerInfos };
}
private abortQueriesForPeer(peerIdStr: string): void {
log.info(`Aborting queries for peer: ${peerIdStr}`);
this.queryingPeers.delete(peerIdStr);
this.queryAttempts.delete(peerIdStr);
}
private async checkPeerInfoDiff(
peerInfo: PeerInfo,
shardInfo?: ShardInfo
): Promise<{ hasMultiaddrDiff: boolean; hasShardDiff: boolean }> {
const { id: peerId } = peerInfo;
const peer = await this.components.peerStore.get(peerId);
const existingMultiaddrs = peer.addresses.map((a) =>
a.multiaddr.toString()
);
const newMultiaddrs = peerInfo.multiaddrs.map((ma) => ma.toString());
const hasMultiaddrDiff = existingMultiaddrs.some(
(ma) => !newMultiaddrs.includes(ma)
);
let hasShardDiff: boolean = false;
const existingShardInfoBytes = peer.metadata.get("shardInfo");
if (existingShardInfoBytes) {
const existingShardInfo = decodeRelayShard(existingShardInfoBytes);
if (existingShardInfo || shardInfo) {
hasShardDiff =
existingShardInfo.clusterId !== shardInfo?.clusterId ||
existingShardInfo.shards.some(
(shard) => !shardInfo?.shards.includes(shard)
);
}
}
return { hasMultiaddrDiff, hasShardDiff };
}
}
export function wakuPeerExchangeDiscovery(): (
components: Libp2pComponents
) => PeerExchangeDiscovery {
return (components: Libp2pComponents) =>
new PeerExchangeDiscovery(components);
}