All files / lib/gossip ChannelRangeQuery.ts

95.71% Statements 67/70
96.77% Branches 30/31
93.33% Functions 14/15
95.59% Lines 65/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305      1x     1x   1x 1x 1x 1x 1x                                                                                 1x   24x   24x           24x 24x 24x     24x 24x                 1x             11x             12x                                   22x           22x 8x   14x                                   24x   24x     24x       24x 24x                 6x 5x 5x 5x             8x 7x 7x                 24x             24x 24x 24x 24x 24x     24x                 14x                                               14x                 14x 10x         14x 4x       4x 4x           10x 10x 10x 6x         6x 6x                     8x                 8x 6x           8x 2x       2x 2x         6x 2x 2x        
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.
    Iif (!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;
    }
  }
}