UNPKG

@node-dlc/wire

Version:
305 lines (277 loc) 10.6 kB
import { ShortChannelId } from '@node-dlc/common'; import { ILogger } from '@node-dlc/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 = 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 = 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; } } }