@node-lightning/wire
Version:
Lightning Network Wire Protocol
389 lines (334 loc) • 13.9 kB
text/typescript
import { BitField, HashByteOrder, ShortChannelId } from "@node-lightning/core";
import { OutPoint } from "@node-lightning/core";
import { ILogger } from "@node-lightning/logger";
import { EventEmitter } from "events";
import { ChannelAnnouncementMessage } from "../messages/ChannelAnnouncementMessage";
import { ExtendedChannelAnnouncementMessage } from "../messages/ExtendedChannelAnnouncementMessage";
import { IWireMessage } from "../messages/IWireMessage";
import { MessageType } from "../MessageType";
import { Peer } from "../Peer";
import { PeerHostRecord } from "../PeerHostRecord";
import { PeerState } from "../PeerState";
import { WireError, WireErrorCode } from "../WireError";
import { DnsPeerQuery } from "./DnsPeerQuery";
import { GossipFilter } from "./GossipFilter";
import { GossipPeer } from "./GossipPeer";
import { GossipRelay, IGossipRelay } from "./GossipRelay";
import { IGossipStore } from "./GossipStore";
import { IGossipFilterChainClient } from "./IGossipFilterChainClient";
export enum SyncState {
Unsynced,
Syncing,
Synced,
}
// tslint:disable-next-line: interface-name
export declare interface GossipManager {
on(event: "message", fn: (msg: IWireMessage) => void): this;
on(event: "error", fn: (err: Error) => void): this;
on(event: "flushed", fn: () => void): this;
off(event: "restored", fn: (block: number) => void): this;
off(event: "message", fn: (msg: IWireMessage) => void): this;
off(event: "error", fn: (err: Error) => void): this;
off(event: "flushed", fn: () => void): this;
}
/**
* GossipManager provides is a facade for many parts of gossip. It
* orchestrates for validating, storing, and emitting
* routing gossip traffic obtained by peers.
*/
export class GossipManager extends EventEmitter {
public blockHeight: number;
public started: boolean;
public syncState: SyncState;
public isSynchronizing: boolean;
public gossipRelay: IGossipRelay;
public dnsPeerQuery: DnsPeerQuery;
public readonly peers: Set<GossipPeer>;
public readonly logger: ILogger;
constructor(
logger: ILogger,
readonly gossipStore: IGossipStore,
readonly pendingStore: IGossipStore,
readonly chainClient?: IGossipFilterChainClient,
) {
super();
this.logger = logger.sub("gspmgr");
this.peers = new Set<GossipPeer>();
this.syncState = SyncState.Unsynced;
this.gossipRelay = new GossipRelay(logger.sub("gsprel"), 60000, 2000);
}
/**
* The number of peers managed by the PeerManager
*/
public get peerCount(): number {
return this.peers.size;
}
/**
* Starts the gossip manager. This method will load information
* from the gossip store, determine when the last information
* was obtained, validate the existing messages (to see if any
* channels have closed), and finally emit all messages that
* exist in the system.
*/
public async start() {
this.logger.info("starting gossip manager");
// wait for chain sync to complete
if (this.chainClient) {
this.logger.info("waiting for chain sync");
await this.chainClient.waitForSync();
this.logger.info("chain sync complete");
}
await this._restoreState();
// emit all restored messages
for await (const msg of this.allMessages()) {
this.emit("message", msg);
}
// start the gossip relay manager
this.gossipRelay.start();
// flag that the manager has now started
this.started = true;
}
/**
* Adds a new peer to the GossipManager and subscribes to events that will
* allow it to iteract with other sub-systems managed by the GossipManager.
*/
public addPeer(peer: Peer) {
if (!this.started) throw new WireError(WireErrorCode.gossipManagerNotStarted);
peer.on("ready", () => this._onPeerReady(peer));
if (peer.state === PeerState.Ready) {
this._onPeerReady(peer);
}
}
/**
* Uses a dns seed to discover and add peers to be managed by the GossipManager.
*/
public async bootstrapPeers(
ls: Buffer,
localFeatures: BitField<any>,
localChains: Buffer[],
logger: ILogger,
dnsSeed: string,
): Promise<void> {
if (!this.started) throw new WireError(WireErrorCode.gossipManagerNotStarted);
const peerRecords: PeerHostRecord[] = await this.dnsPeerQuery.query({ dnsSeed: dnsSeed });
for (const peerRecord of peerRecords) {
const peer = new Peer(ls, localFeatures, localChains, logger);
this.addPeer(peer);
peer.connect(peerRecord.publicKey, peerRecord.address, peerRecord.port);
}
}
/**
* Removes the channel from storage by the gossip manager. This
* will likely be called by a chain-monitoring service.
*/
public async removeChannel(scid: ShortChannelId) {
this.logger.debug("removing channel %s", scid.toString());
await this.gossipStore.deleteChannelAnnouncement(scid);
}
/**
* Removes the channel from storage by the gossip manager. This will
* likely be called by a chain-monitoring service.
* @param outpoint
*/
public async removeChannelByOutpoint(outpoint: OutPoint) {
const chanAnn = await this.gossipStore.findChannelAnnouncementByOutpoint(outpoint);
if (!chanAnn) return;
await this.removeChannel(chanAnn.shortChannelId);
}
/**
* Retrieves the valid chan_ann, chan_update, node_ann messages
* while making sure to not send duplicate node_ann messages.
*
* @remarks
* For now we are going to buffer messages into memory. We could
* return a stream and yield messages as they are streamed from
* the gossip_store.
*/
public async *allMessages(): AsyncGenerator<IWireMessage, void, unknown> {
this.logger.debug("fetching all messages");
// maintain a set of node ids that we have already seen so that
// we do no rebroadcast node announcements. This set stores the
// nodeid pubkey as a hex string, which through testing is the
// fastest way to perfrom set operations.
const seenNodeIds: Set<string> = new Set();
// obtain full list of channel announcements
const chanAnns = await this.gossipStore.findChannelAnnouncemnts();
for (const chanAnn of chanAnns) {
yield chanAnn;
// load and add the node1 channel_update
const update1 = await this.gossipStore.findChannelUpdate(chanAnn.shortChannelId, 0);
if (update1) yield update1;
// load and add the nod2 channel_update
const update2 = await this.gossipStore.findChannelUpdate(chanAnn.shortChannelId, 1);
if (update2) yield update2;
// optionally load node1 announcement
const nodeId1 = chanAnn.nodeId1.toString("hex");
if (!seenNodeIds.has(nodeId1)) {
seenNodeIds.add(nodeId1);
const nodeAnn = await this.gossipStore.findNodeAnnouncement(chanAnn.nodeId1);
if (nodeAnn) yield nodeAnn;
}
// optionally load node2 announcement
const nodeId2 = chanAnn.nodeId2.toString("hex");
if (!seenNodeIds.has(nodeId2)) {
seenNodeIds.add(nodeId2);
const nodeAnn = await this.gossipStore.findNodeAnnouncement(chanAnn.nodeId2);
if (nodeAnn) yield nodeAnn;
}
}
// Broadcast unattached node announcements. These may have been orphaned
// from previously closed channels, or if the node allows node_ann messages
// without channels.
const nodeAnns = await this.gossipStore.findNodeAnnouncements();
for (const nodeAnn of nodeAnns) {
if (!seenNodeIds.has(nodeAnn.nodeId.toString("hex"))) yield nodeAnn;
}
}
/**
* Handles when a peer has been added to the manager and it is finally
* ready and has negotiated the gossip technique.
* @param peer
*/
private _onPeerReady(peer: Peer) {
// Construct a gossip filter for use by the specific GossipPeer. This
// filter will be internally used by the GossipPeer to validate and
// capture gossip messages
const filter = new GossipFilter(this.gossipStore, this.pendingStore, this.chainClient);
// Construct the gossip Peer and add it to the collection of Peers
// that are currently being managed by the GossipPeer
const gossipPeer = new GossipPeer(peer, filter, this.logger);
// Attach events from the gossipPeer
gossipPeer.on("readable", this._onPeerReadable.bind(this, gossipPeer));
gossipPeer.on("gossip_error", this._onGossipError.bind(this));
// Add peer to the list of peers
this.peers.add(gossipPeer);
// Add event handler for a beer closing
peer.on("close", this._onPeerClose.bind(this, gossipPeer));
// Add the peer to the relay manager
this.gossipRelay.addPeer(gossipPeer);
// If we have not yet performed a full synchronization then we can
// perform the full gossip state restore from this node
if (this.syncState === SyncState.Unsynced) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this._syncPeer(gossipPeer);
}
// If we've already synced, simply enable gossip receiving for the peer
else {
gossipPeer.enableGossip();
}
}
/**
* Handles when a peer closes
* @param gossipPeer
*/
private _onPeerClose(gossipPeer: GossipPeer) {
if (this.gossipRelay) {
this.gossipRelay.removePeer(gossipPeer);
}
this.peers.delete(gossipPeer);
}
/**
* Handles when a peer becomes readable
* @param peer
*/
private _onPeerReadable(peer: GossipPeer) {
// eslint-disable-next-line no-constant-condition
while (true) {
const msg = peer.read() as IWireMessage;
if (msg) this._onGossipMessage(msg);
else return;
}
}
/**
* Handles receieved gossip messages
* @param msg
*/
private _onGossipMessage(msg: IWireMessage) {
if (msg.type === MessageType.ChannelAnnouncement) {
this.blockHeight = Math.max(
this.blockHeight,
(msg as ChannelAnnouncementMessage).shortChannelId.block,
);
}
// enqueue the message into the relayer
this.gossipRelay.enqueue(msg);
// emit the message generally
this.emit("message", msg);
}
/**
* Handles Gossip Errors
*/
private _onGossipError(err: Error) {
this.emit("error", err);
}
/**
* Synchronize the peer using the peer's synchronization mechanism.
* @param peer
*/
private async _syncPeer(peer: GossipPeer) {
// Disable gossip relay
this.gossipRelay.stop();
this.logger.trace("sync status now 'syncing'");
this.syncState = SyncState.Syncing;
try {
// perform synchronization
await peer.syncRange();
// finally transition to sync complete status
this.logger.trace("sync status now 'synced'");
this.syncState = SyncState.Synced;
// enable gossip for all the peers
this.logger.trace("enabling gossip for all peers");
for (const gossipPeer of this.peers) {
gossipPeer.enableGossip();
}
} catch (ex) {
// TODO select next peer
this.syncState = SyncState.Unsynced;
}
// Enable gossip relay now that sync is complete
this.gossipRelay.start();
}
private async _restoreState() {
this.logger.info("retrieving gossip state from store");
this.blockHeight = 0;
const chanAnns = await this.gossipStore.findChannelAnnouncemnts();
// find best block height
for (const chanAnn of chanAnns) {
this.blockHeight = Math.max(this.blockHeight, chanAnn.shortChannelId.block);
}
this.logger.info("highest block %d found from %d channels", this.blockHeight, chanAnns.length); // prettier-ignore
// validate all utxos
await this._validateUtxos(chanAnns);
}
private async _validateUtxos(chanAnns: ChannelAnnouncementMessage[]) {
if (!this.chainClient) {
this.logger.info("skipping utxo validation, no chain_client configured");
return;
}
const extChanAnnCount = chanAnns.reduce(
(acc, msg) => acc + (msg instanceof ExtendedChannelAnnouncementMessage ? 1 : 0),
0,
);
this.logger.info("validating %d utxos", extChanAnnCount);
if (!extChanAnnCount) return;
const oct = Math.trunc(extChanAnnCount / 8);
for (let i = 0; i < chanAnns.length; i++) {
const chanAnn = chanAnns[i];
if ((i + 1) % oct === 0) {
this.logger.info(
"validating utxos %s% complete",
(((i + 1) / extChanAnnCount) * 100).toFixed(2),
);
}
if (chanAnn instanceof ExtendedChannelAnnouncementMessage) {
const utxo = await this.chainClient.getUtxo(
chanAnn.outpoint.txid.toString(HashByteOrder.RPC),
chanAnn.outpoint.outputIndex,
);
if (!utxo) {
await this.removeChannel(chanAnn.shortChannelId);
}
}
}
}
}