UNPKG

@node-lightning/wire

Version:
298 lines (271 loc) 11.3 kB
import { ShortChannelId } from "@node-lightning/core"; import { ILogger } from "@node-lightning/logger"; import { QueryChannelRangeMessage } from "../messages/QueryChannelRangeMessage"; import { ReplyChannelRangeMessage } from "../messages/ReplyChannelRangeMessage"; import { IMessageSender } from "../Peer"; import { GossipError, GossipErrorCode } from "./GossipError"; export enum ChannelRangeQueryState { Idle, Active, Complete, Failed, } /** * Performs a single query_channel_range operation and encapsulates the state * machine performed during the query operations. * * A single query_channel_range may be too large to fit in a single * reply_channel_range response. When this happens, there will be multiple * reply_channel_range responses to address the query_channel_range message. * * There are two modes of replies: Legacy LND and Standard. * * Standard was defined in changes to BOLT7 that were merged in with #730. These * changes clarified the meaning of the reply fields to ensure the recipient * of replies knows when the query has successfully completed. This includes: * * - a node must not submit a new query_channel_range until the full set of * reply_channel_range messages covering the range are received. * * - clarified the meaning of full_information (previously complete) to * indicate that the responding node contains complete information for the * chain_hash, not that the reply is complete. * * - enforces that when there are multiple reply_channel_range msgs, the * range of blocks must be strictly increasing until the full query range is * covered. This range may exceed (both below and above) the request range. * This means: * - the first reply first_blocknum must be <= requests first_blocknum * - subsequent first_blocknum will be strictly increasing from the * previous reply's first_blocknum+number_of_blocks * - the final first_blocknum+number_of_blocks must be >= the query's * first_blocknum+number_of_blocks * * This is functionality is a clarification from Legacy LND mode where * gossip_queries was implemented in a different manner. * * - full_information indicated a multipart message that was incomplete * - lack of short_channel_ids and full_information=false can be treated * as a failure condition */ export class ChannelRangeQuery { private _state: ChannelRangeQueryState; private _isLegacy = false; private _query: QueryChannelRangeMessage; private _results: ShortChannelId[] = []; private _error: GossipError; private _resolve: (scids: ShortChannelId[]) => void; private _reject: (reason: unknown) => void; constructor( readonly chainHash: Buffer, readonly messageSender: IMessageSender, readonly logger: ILogger, isLegacy: boolean = false, ) { this._state = ChannelRangeQueryState.Idle; this._isLegacy = isLegacy; } /** * Returns true if we detect this is using the legacy querying gossip_queries * mechanism that was originally implemented in LND. This code may be able to * be removed eventually. */ public get isLegacy(): boolean { return this._isLegacy; } /** * Gets the current state of the Query object */ public get state(): ChannelRangeQueryState { return this._state; } /** * Results found */ public get results(): ShortChannelId[] { return this._results; } /** * Gets the error that was encountered during processing */ public get error(): GossipError { return this._error; } /** * Handles a reply_channel_range message. May initiate a message * broadcast. */ public handleReplyChannelRange(msg: ReplyChannelRangeMessage): void { // check the incoming message to see if we need to transition to legacy // mode. If it is determined to be in legacy mode, we will switch the // strategy that is used to handle the reply. if (!this._isLegacy && this._isLegacyReply(msg)) { this._isLegacy = true; this.logger.info("using legacy LND query_channel_range technique"); } // handle the message according to which state the reply system is working if (this._isLegacy) { this._handleLegacyReply(msg); } else { this._handleReply(msg); } } /** * Resolves when the query has completed, otherwise it will reject on an * error. */ public queryRange( firstBlock: number = 0, numBlocks = 4294967295 - firstBlock, ): Promise<ShortChannelId[]> { // Construct a promise that will be resolved after all query logic has // succeeded or failed. This is a slightly different pattern in that // we use an private event emitter to signal completion or failure // asynchronously. We use those handlers to resolve the promise. As a // result, the external interface is very clean, but we can have // complicated internal operations return new Promise((resolve, reject) => { // transition the state to active this._state = ChannelRangeQueryState.Active; // send the query message and start the process this._sendQuery(firstBlock, numBlocks); // capture the promise methods so we can invoke them from // within our state machine. this._resolve = resolve; this._reject = reject; }); } /** * Idempotent method that marks the state machine failed * @param error */ private _transitionFailed(error: GossipError) { if (this._state !== ChannelRangeQueryState.Active) return; this._error = error; this._state = ChannelRangeQueryState.Failed; this._reject(error); } /** * Idempotent method that marks the state machine complete */ private _transitionComplete() { if (this._state !== ChannelRangeQueryState.Active) return; this._state = ChannelRangeQueryState.Complete; this._resolve(this._results); } /** * Constructs and sends the query message the remote peer. * @param firstBlock * @param numBlocks */ private _sendQuery(firstBlock: number, numBlocks: number) { this.logger.info( "sending query_channel_range start_block=%d end_block=%d", firstBlock, firstBlock + numBlocks - 1, ); // send message const msg = new QueryChannelRangeMessage(); msg.chainHash = this.chainHash; msg.firstBlocknum = firstBlock; msg.numberOfBlocks = numBlocks; this.messageSender.sendMessage(msg); // capture the active query to check reply if it is a legacy reply this._query = msg; } /** * Check if this has the signature of a legacy reply. We can detect this by * looking at a complete=false and that scids exist. * @param msg */ private _isLegacyReply(msg: ReplyChannelRangeMessage): boolean { return !msg.fullInformation && msg.shortChannelIds.length > 0; } /** * Handles a reply_channel_range message which ensures that the entire queried * range has been received. The responder can reply with pre-sized ranges * which means the reply range may not be the EXACT range requested but will * include the queried range. * * For a query range with first_blocknum and number_of_blocks arguments, * we can expect messages to have the following: * * - first reply first_blocknum <= requested first_blocknum * - intermediate replies sequentially ordered so that first_blocknum is the * first_blocknum + number_of_blocks from previous reply (strictly ordered) * - last reply has fist_blocknum + number_of_blocks >= the queries * first_blocknum + number_of_blocks * * This ordering allows us to know when a message is complete. If a reply has * full_information=false, then the remote peer does not maintain a * up-to-date information for the supplied chain_hash. * @param msg */ private _handleReply(msg: ReplyChannelRangeMessage) { this.logger.debug( "received reply_channel_range - full_info=%d start_block=%d end_block=%d scid_count=%d", msg.fullInformation, msg.firstBlocknum, msg.firstBlocknum + msg.numberOfBlocks - 1, msg.shortChannelIds.length, ); // enqueues any scids to be processed by a query_short_chan_id message if (msg.shortChannelIds.length) { this._results.push(...msg.shortChannelIds); } // The full_information flag should only return false when the remote peer // does not maintain up-to-date information for the request chain_hash if (!msg.fullInformation) { const error = new GossipError(GossipErrorCode.ReplyChannelRangeNoInformation, msg); this._transitionFailed(error); return; } // We can finished when we have received a reply that covers the full range // of requested data. We know the final block height will be the querie's // first_blocknum + number_of_blocks. const currentHeight = msg.firstBlocknum + msg.numberOfBlocks; const targetHeight = this._query.firstBlocknum + this._query.numberOfBlocks; if (currentHeight >= targetHeight) { this.logger.debug( "received final reply_channel_range height %d >= query_channel_range height %d", currentHeight, targetHeight, ); this._transitionComplete(); return; } } /** * Handles a reply_channel_range message using the legacy strategy. This * code will error if fullInformation=0 scids=[] and will be considered * complete when fullInformation=1. * @param msg */ private _handleLegacyReply(msg: ReplyChannelRangeMessage) { this.logger.debug( "received reply_channel_range - full_info=%d start_block=%d end_block=%d scid_count=%d", msg.fullInformation, msg.firstBlocknum, msg.firstBlocknum + msg.numberOfBlocks - 1, msg.shortChannelIds.length, ); // enqueues any scids to be processed by a query_short_chan_id message if (msg.shortChannelIds.length) { this.results.push(...msg.shortChannelIds); } // Check the complete flag and the existance of SCIDs. Unfortunately, // non-confirming implementations are incorrectly using the completion // flag to a multi-message reply. if (msg.fullInformation && !this.results.length) { const error = new GossipError(GossipErrorCode.ReplyChannelRangeNoInformation, msg); this._transitionFailed(error); return; } // If we see a fullInformation flag then we have received all parts of // the multipart message and are complete. if (msg.fullInformation) { this._transitionComplete(); return; } } }