• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spesmilo / electrum / 5304010765238272

17 Aug 2023 02:17PM UTC coverage: 59.027% (+0.02%) from 59.008%
5304010765238272

Pull #8493

CirrusCI

ecdsa
storage.append: fail if the file length is not what we expect
Pull Request #8493: partial-writes using jsonpatch

165 of 165 new or added lines in 9 files covered. (100.0%)

18653 of 31601 relevant lines covered (59.03%)

2.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.76
/electrum/lnrouter.py
1
# -*- coding: utf-8 -*-
2
#
3
# Electrum - lightweight Bitcoin client
4
# Copyright (C) 2018 The Electrum developers
5
#
6
# Permission is hereby granted, free of charge, to any person
7
# obtaining a copy of this software and associated documentation files
8
# (the "Software"), to deal in the Software without restriction,
9
# including without limitation the rights to use, copy, modify, merge,
10
# publish, distribute, sublicense, and/or sell copies of the Software,
11
# and to permit persons to whom the Software is furnished to do so,
12
# subject to the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be
15
# included in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
# SOFTWARE.
25

26
import queue
5✔
27
from collections import defaultdict
5✔
28
from typing import Sequence, Tuple, Optional, Dict, TYPE_CHECKING, Set
5✔
29
import time
5✔
30
import threading
5✔
31
from threading import RLock
5✔
32
import attr
5✔
33
from math import inf
5✔
34

35
from .util import profiler, with_lock
5✔
36
from .logging import Logger
5✔
37
from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures,
5✔
38
                     NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE)
39
from .channel_db import ChannelDB, Policy, NodeInfo
5✔
40

41
if TYPE_CHECKING:
5✔
42
    from .lnchannel import Channel
×
43

44
DEFAULT_PENALTY_BASE_MSAT = 500  # how much base fee we apply for unknown sending capability of a channel
5✔
45
DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100  # how much relative fee we apply for unknown sending capability of a channel
5✔
46
HINT_DURATION = 3600  # how long (in seconds) a liquidity hint remains valid
5✔
47

48

49
class NoChannelPolicy(Exception):
5✔
50
    def __init__(self, short_channel_id: bytes):
5✔
51
        short_channel_id = ShortChannelID.normalize(short_channel_id)
×
52
        super().__init__(f'cannot find channel policy for short_channel_id: {short_channel_id}')
×
53

54

55
class LNPathInconsistent(Exception): pass
5✔
56

57

58
def fee_for_edge_msat(forwarded_amount_msat: int, fee_base_msat: int, fee_proportional_millionths: int) -> int:
5✔
59
    return fee_base_msat \
5✔
60
           + (forwarded_amount_msat * fee_proportional_millionths // 1_000_000)
61

62

63
@attr.s(slots=True)
5✔
64
class PathEdge:
5✔
65
    start_node = attr.ib(type=bytes, kw_only=True, repr=lambda val: val.hex())
5✔
66
    end_node = attr.ib(type=bytes, kw_only=True, repr=lambda val: val.hex())
5✔
67
    short_channel_id = attr.ib(type=ShortChannelID, kw_only=True, repr=lambda val: str(val))
5✔
68

69
    @property
5✔
70
    def node_id(self) -> bytes:
5✔
71
        # legacy compat  # TODO rm
72
        return self.end_node
5✔
73

74
@attr.s
5✔
75
class RouteEdge(PathEdge):
5✔
76
    fee_base_msat = attr.ib(type=int, kw_only=True)
5✔
77
    fee_proportional_millionths = attr.ib(type=int, kw_only=True)
5✔
78
    cltv_expiry_delta = attr.ib(type=int, kw_only=True)
5✔
79
    node_features = attr.ib(type=int, kw_only=True, repr=lambda val: str(int(val)))  # note: for end node!
5✔
80

81
    def fee_for_edge(self, amount_msat: int) -> int:
5✔
82
        return fee_for_edge_msat(forwarded_amount_msat=amount_msat,
5✔
83
                                 fee_base_msat=self.fee_base_msat,
84
                                 fee_proportional_millionths=self.fee_proportional_millionths)
85

86
    @classmethod
5✔
87
    def from_channel_policy(
5✔
88
            cls,
89
            *,
90
            channel_policy: 'Policy',
91
            short_channel_id: bytes,
92
            start_node: bytes,
93
            end_node: bytes,
94
            node_info: Optional[NodeInfo],  # for end_node
95
    ) -> 'RouteEdge':
96
        assert isinstance(short_channel_id, bytes)
5✔
97
        assert type(start_node) is bytes
5✔
98
        assert type(end_node) is bytes
5✔
99
        return RouteEdge(
5✔
100
            start_node=start_node,
101
            end_node=end_node,
102
            short_channel_id=ShortChannelID.normalize(short_channel_id),
103
            fee_base_msat=channel_policy.fee_base_msat,
104
            fee_proportional_millionths=channel_policy.fee_proportional_millionths,
105
            cltv_expiry_delta=channel_policy.cltv_expiry_delta,
106
            node_features=node_info.features if node_info else 0)
107

108
    def is_sane_to_use(self, amount_msat: int) -> bool:
5✔
109
        # TODO revise ad-hoc heuristics
110
        # cltv cannot be more than 2 weeks
111
        if self.cltv_expiry_delta > 14 * 144:
5✔
112
            return False
×
113
        total_fee = self.fee_for_edge(amount_msat)
5✔
114
        if not is_fee_sane(total_fee, payment_amount_msat=amount_msat):
5✔
115
            return False
×
116
        return True
5✔
117

118
    def has_feature_varonion(self) -> bool:
5✔
119
        features = LnFeatures(self.node_features)
×
120
        return features.supports(LnFeatures.VAR_ONION_OPT)
×
121

122
    def is_trampoline(self) -> bool:
5✔
123
        return False
5✔
124

125
@attr.s
5✔
126
class TrampolineEdge(RouteEdge):
5✔
127
    invoice_routing_info = attr.ib(type=bytes, default=None)
5✔
128
    invoice_features = attr.ib(type=int, default=None)
5✔
129
    # this is re-defined from parent just to specify a default value:
130
    short_channel_id = attr.ib(default=ShortChannelID(8), repr=lambda val: str(val))
5✔
131

132
    def is_trampoline(self):
5✔
133
        return True
5✔
134

135

136
LNPaymentPath = Sequence[PathEdge]
5✔
137
LNPaymentRoute = Sequence[RouteEdge]
5✔
138

139

140
def is_route_sane_to_use(route: LNPaymentRoute, invoice_amount_msat: int, min_final_cltv_expiry: int) -> bool:
5✔
141
    """Run some sanity checks on the whole route, before attempting to use it.
142
    called when we are paying; so e.g. lower cltv is better
143
    """
144
    if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH:
5✔
145
        return False
×
146
    amt = invoice_amount_msat
5✔
147
    cltv = min_final_cltv_expiry
5✔
148
    for route_edge in reversed(route[1:]):
5✔
149
        if not route_edge.is_sane_to_use(amt): return False
5✔
150
        amt += route_edge.fee_for_edge(amt)
5✔
151
        cltv += route_edge.cltv_expiry_delta
5✔
152
    total_fee = amt - invoice_amount_msat
5✔
153
    # TODO revise ad-hoc heuristics
154
    if cltv > NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE:
5✔
155
        return False
×
156
    # FIXME in case of MPP, the fee checks are done independently for each part,
157
    #       which is ok for the proportional checks but not for the absolute ones.
158
    #       This is not that big of a deal though as we don't split into *too many* parts.
159
    if not is_fee_sane(total_fee, payment_amount_msat=invoice_amount_msat):
5✔
160
        return False
×
161
    return True
5✔
162

163

164
def is_fee_sane(fee_msat: int, *, payment_amount_msat: int) -> bool:
5✔
165
    # fees <= 5 sat are fine
166
    if fee_msat <= 5_000:
5✔
167
        return True
5✔
168
    # fees <= 1 % of payment are fine
169
    if 100 * fee_msat <= payment_amount_msat:
5✔
170
        return True
5✔
171
    return False
×
172

173

174
class LiquidityHint:
5✔
175
    """Encodes the amounts that can and cannot be sent over the direction of a
176
    channel.
177

178
    A LiquidityHint is the value of a dict, which is keyed to node ids and the
179
    channel.
180
    """
181
    def __init__(self):
5✔
182
        # use "can_send_forward + can_send_backward < cannot_send_forward + cannot_send_backward" as a sanity check?
183
        self._can_send_forward = None
5✔
184
        self._cannot_send_forward = None
5✔
185
        self._can_send_backward = None
5✔
186
        self._cannot_send_backward = None
5✔
187
        self.hint_timestamp = 0
5✔
188
        self._inflight_htlcs_forward = 0
5✔
189
        self._inflight_htlcs_backward = 0
5✔
190

191
    def is_hint_invalid(self) -> bool:
5✔
192
        now = int(time.time())
5✔
193
        return now - self.hint_timestamp > HINT_DURATION
5✔
194

195
    @property
5✔
196
    def can_send_forward(self):
5✔
197
        return None if self.is_hint_invalid() else self._can_send_forward
5✔
198

199
    @can_send_forward.setter
5✔
200
    def can_send_forward(self, amount):
5✔
201
        # we don't want to record less significant info
202
        # (sendable amount is lower than known sendable amount):
203
        if self._can_send_forward and self._can_send_forward > amount:
5✔
204
            return
5✔
205
        self._can_send_forward = amount
5✔
206
        # we make a sanity check that sendable amount is lower than not sendable amount
207
        if self._cannot_send_forward and self._can_send_forward > self._cannot_send_forward:
5✔
208
            self._cannot_send_forward = None
5✔
209

210
    @property
5✔
211
    def can_send_backward(self):
5✔
212
        return None if self.is_hint_invalid() else self._can_send_backward
5✔
213

214
    @can_send_backward.setter
5✔
215
    def can_send_backward(self, amount):
5✔
216
        if self._can_send_backward and self._can_send_backward > amount:
5✔
217
            return
×
218
        self._can_send_backward = amount
5✔
219
        if self._cannot_send_backward and self._can_send_backward > self._cannot_send_backward:
5✔
220
            self._cannot_send_backward = None
×
221

222
    @property
5✔
223
    def cannot_send_forward(self):
5✔
224
        return None if self.is_hint_invalid() else self._cannot_send_forward
5✔
225

226
    @cannot_send_forward.setter
5✔
227
    def cannot_send_forward(self, amount):
5✔
228
        # we don't want to record less significant info
229
        # (not sendable amount is higher than known not sendable amount):
230
        if self._cannot_send_forward and self._cannot_send_forward < amount:
5✔
231
            return
×
232
        self._cannot_send_forward = amount
5✔
233
        if self._can_send_forward and self._can_send_forward > self._cannot_send_forward:
5✔
234
            self._can_send_forward = None
×
235
        # if we can't send over the channel, we should be able to send in the
236
        # reverse direction
237
        self.can_send_backward = amount
5✔
238

239
    @property
5✔
240
    def cannot_send_backward(self):
5✔
241
        return None if self.is_hint_invalid() else self._cannot_send_backward
5✔
242

243
    @cannot_send_backward.setter
5✔
244
    def cannot_send_backward(self, amount):
5✔
245
        if self._cannot_send_backward and self._cannot_send_backward < amount:
3✔
246
            return
×
247
        self._cannot_send_backward = amount
3✔
248
        if self._can_send_backward and self._can_send_backward > self._cannot_send_backward:
3✔
249
            self._can_send_backward = None
×
250
        self.can_send_forward = amount
3✔
251

252
    def can_send(self, is_forward_direction: bool):
5✔
253
        # make info invalid after some time?
254
        if is_forward_direction:
5✔
255
            return self.can_send_forward
5✔
256
        else:
257
            return self.can_send_backward
5✔
258

259
    def cannot_send(self, is_forward_direction: bool):
5✔
260
        # make info invalid after some time?
261
        if is_forward_direction:
5✔
262
            return self.cannot_send_forward
5✔
263
        else:
264
            return self.cannot_send_backward
5✔
265

266
    def update_can_send(self, is_forward_direction: bool, amount: int):
5✔
267
        self.hint_timestamp = int(time.time())
5✔
268
        if is_forward_direction:
5✔
269
            self.can_send_forward = amount
5✔
270
        else:
271
            self.can_send_backward = amount
5✔
272

273
    def update_cannot_send(self, is_forward_direction: bool, amount: int):
5✔
274
        self.hint_timestamp = int(time.time())
5✔
275
        if is_forward_direction:
5✔
276
            self.cannot_send_forward = amount
5✔
277
        else:
278
            self.cannot_send_backward = amount
3✔
279

280
    def num_inflight_htlcs(self, is_forward_direction: bool) -> int:
5✔
281
        if is_forward_direction:
5✔
282
            return self._inflight_htlcs_forward
5✔
283
        else:
284
            return self._inflight_htlcs_backward
5✔
285

286
    def add_htlc(self, is_forward_direction: bool):
5✔
287
        if is_forward_direction:
5✔
288
            self._inflight_htlcs_forward += 1
5✔
289
        else:
290
            self._inflight_htlcs_backward += 1
5✔
291

292
    def remove_htlc(self, is_forward_direction: bool):
5✔
293
        if is_forward_direction:
5✔
294
            self._inflight_htlcs_forward = max(0, self._inflight_htlcs_forward - 1)
5✔
295
        else:
296
            self._inflight_htlcs_backward = max(0, self._inflight_htlcs_forward - 1)
5✔
297

298
    def __repr__(self):
5✔
299
        return f"forward: can send: {self._can_send_forward} msat, cannot send: {self._cannot_send_forward} msat, htlcs: {self._inflight_htlcs_forward}\n" \
×
300
               f"backward: can send: {self._can_send_backward} msat, cannot send: {self._cannot_send_backward} msat, htlcs: {self._inflight_htlcs_backward}\n"
301

302

303
class LiquidityHintMgr:
5✔
304
    """Implements liquidity hints for channels in the graph.
305

306
    This class can be used to update liquidity information about channels in the
307
    graph. Implements a penalty function for edge weighting in the pathfinding
308
    algorithm that favors channels which can route payments and penalizes
309
    channels that cannot.
310
    """
311
    # TODO: hints based on node pairs only (shadow channels, non-strict forwarding)?
312
    def __init__(self):
5✔
313
        self.lock = RLock()
5✔
314
        self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {}
5✔
315

316
    @with_lock
5✔
317
    def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint:
5✔
318
        hint = self._liquidity_hints.get(channel_id)
5✔
319
        if not hint:
5✔
320
            hint = LiquidityHint()
5✔
321
            self._liquidity_hints[channel_id] = hint
5✔
322
        return hint
5✔
323

324
    @with_lock
5✔
325
    def update_can_send(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int):
5✔
326
        hint = self.get_hint(channel_id)
5✔
327
        hint.update_can_send(node_from < node_to, amount)
5✔
328

329
    @with_lock
5✔
330
    def update_cannot_send(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int):
5✔
331
        hint = self.get_hint(channel_id)
5✔
332
        hint.update_cannot_send(node_from < node_to, amount)
5✔
333

334
    @with_lock
5✔
335
    def add_htlc(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID):
5✔
336
        hint = self.get_hint(channel_id)
5✔
337
        hint.add_htlc(node_from < node_to)
5✔
338

339
    @with_lock
5✔
340
    def remove_htlc(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID):
5✔
341
        hint = self.get_hint(channel_id)
5✔
342
        hint.remove_htlc(node_from < node_to)
5✔
343

344
    def penalty(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int) -> float:
5✔
345
        """Gives a penalty when sending from node1 to node2 over channel_id with an
346
        amount in units of millisatoshi.
347

348
        The penalty depends on the can_send and cannot_send values that was
349
        possibly recorded in previous payment attempts.
350

351
        A channel that can send an amount is assigned a penalty of zero, a
352
        channel that cannot send an amount is assigned an infinite penalty.
353
        If the sending amount lies between can_send and cannot_send, there's
354
        uncertainty and we give a default penalty. The default penalty
355
        serves the function of giving a positive offset (the Dijkstra
356
        algorithm doesn't work with negative weights), from which we can discount
357
        from. There is a competition between low-fee channels and channels where
358
        we know with some certainty that they can support a payment. The penalty
359
        ultimately boils down to: how much more fees do we want to pay for
360
        certainty of payment success? This can be tuned via DEFAULT_PENALTY_BASE_MSAT
361
        and DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH. A base _and_ relative penalty
362
        was chosen such that the penalty will be able to compete with the regular
363
        base and relative fees.
364
        """
365
        # we only evaluate hints here, so use dict get (to not create many hints with self.get_hint)
366
        hint = self._liquidity_hints.get(channel_id)
5✔
367
        if not hint:
5✔
368
            can_send, cannot_send, num_inflight_htlcs = None, None, 0
5✔
369
        else:
370
            can_send = hint.can_send(node_from < node_to)
5✔
371
            cannot_send = hint.cannot_send(node_from < node_to)
5✔
372
            num_inflight_htlcs = hint.num_inflight_htlcs(node_from < node_to)
5✔
373

374
        if cannot_send is not None and amount >= cannot_send:
5✔
375
            return inf
5✔
376
        if can_send is not None and amount <= can_send:
5✔
377
            return 0
5✔
378
        success_fee = fee_for_edge_msat(amount, DEFAULT_PENALTY_BASE_MSAT, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH)
5✔
379
        inflight_htlc_fee = num_inflight_htlcs * success_fee
5✔
380
        return success_fee + inflight_htlc_fee
5✔
381

382
    @with_lock
5✔
383
    def reset_liquidity_hints(self):
5✔
384
        for k, v in self._liquidity_hints.items():
5✔
385
            v.hint_timestamp = 0
5✔
386

387
    def __repr__(self):
5✔
388
        string = "liquidity hints:\n"
×
389
        if self._liquidity_hints:
×
390
            for k, v in self._liquidity_hints.items():
×
391
                string += f"{k}: {v}\n"
×
392
        return string
×
393

394

395
class LNPathFinder(Logger):
5✔
396

397
    def __init__(self, channel_db: ChannelDB):
5✔
398
        Logger.__init__(self)
5✔
399
        self.channel_db = channel_db
5✔
400
        self.liquidity_hints = LiquidityHintMgr()
5✔
401
        self._edge_blacklist = dict()  # type: Dict[ShortChannelID, int]  # scid -> expiration
5✔
402
        self._blacklist_lock = threading.Lock()
5✔
403

404
    def _is_edge_blacklisted(self, short_channel_id: ShortChannelID, *, now: int) -> bool:
5✔
405
        blacklist_expiration = self._edge_blacklist.get(short_channel_id)
5✔
406
        if blacklist_expiration is None:
5✔
407
            return False
5✔
408
        if blacklist_expiration < now:
5✔
409
            return False
×
410
            # TODO rm expired entries from cache (note: perf vs thread-safety)
411
        return True
5✔
412

413
    def add_edge_to_blacklist(
5✔
414
        self,
415
        short_channel_id: ShortChannelID,
416
        *,
417
        now: int = None,
418
        duration: int = 3600,  # seconds
419
    ) -> None:
420
        if now is None:
5✔
421
            now = int(time.time())
5✔
422
        with self._blacklist_lock:
5✔
423
            blacklist_expiration = self._edge_blacklist.get(short_channel_id, 0)
5✔
424
            self._edge_blacklist[short_channel_id] = max(blacklist_expiration, now + duration)
5✔
425

426
    def clear_blacklist(self):
5✔
427
        with self._blacklist_lock:
×
428
            self._edge_blacklist = dict()
×
429

430
    def update_liquidity_hints(
5✔
431
            self,
432
            route: LNPaymentRoute,
433
            amount_msat: int,
434
            failing_channel: ShortChannelID=None
435
    ):
436
        # go through the route and record successes until the failing channel is reached,
437
        # for the failing channel, add a cannot_send liquidity hint
438
        # note: actual routable amounts are slightly different than reported here
439
        # as fees would need to be added
440
        for r in route:
5✔
441
            if r.short_channel_id != failing_channel:
5✔
442
                self.logger.info(f"report {r.short_channel_id} to be able to forward {amount_msat} msat")
5✔
443
                self.liquidity_hints.update_can_send(r.start_node, r.end_node, r.short_channel_id, amount_msat)
5✔
444
            else:
445
                self.logger.info(f"report {r.short_channel_id} to be unable to forward {amount_msat} msat")
5✔
446
                self.liquidity_hints.update_cannot_send(r.start_node, r.end_node, r.short_channel_id, amount_msat)
5✔
447
                break
5✔
448
        else:
449
            assert failing_channel is None
5✔
450

451
    def update_inflight_htlcs(self, route: LNPaymentRoute, add_htlcs: bool):
5✔
452
        self.logger.info(f"{'Adding' if add_htlcs else 'Removing'} inflight htlcs to graph (liquidity hints).")
5✔
453
        for r in route:
5✔
454
            if add_htlcs:
5✔
455
                self.liquidity_hints.add_htlc(r.start_node, r.end_node, r.short_channel_id)
5✔
456
            else:
457
                self.liquidity_hints.remove_htlc(r.start_node, r.end_node, r.short_channel_id)
5✔
458

459
    def _edge_cost(
5✔
460
            self,
461
            *,
462
            short_channel_id: ShortChannelID,
463
            start_node: bytes,
464
            end_node: bytes,
465
            payment_amt_msat: int,
466
            ignore_costs=False,
467
            is_mine=False,
468
            my_channels: Dict[ShortChannelID, 'Channel'] = None,
469
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
470
            now: int,  # unix ts
471
    ) -> Tuple[float, int]:
472
        """Heuristic cost (distance metric) of going through a channel.
473
        Returns (heuristic_cost, fee_for_edge_msat).
474
        """
475
        if self._is_edge_blacklisted(short_channel_id, now=now):
5✔
476
            return float('inf'), 0
×
477
        if private_route_edges is None:
5✔
478
            private_route_edges = {}
5✔
479
        channel_info = self.channel_db.get_channel_info(
5✔
480
            short_channel_id, my_channels=my_channels, private_route_edges=private_route_edges)
481
        if channel_info is None:
5✔
482
            return float('inf'), 0
×
483
        channel_policy = self.channel_db.get_policy_for_node(
5✔
484
            short_channel_id, start_node, my_channels=my_channels, private_route_edges=private_route_edges, now=now)
485
        if channel_policy is None:
5✔
486
            return float('inf'), 0
×
487
        # channels that did not publish both policies often return temporary channel failure
488
        channel_policy_backwards = self.channel_db.get_policy_for_node(
5✔
489
            short_channel_id, end_node, my_channels=my_channels, private_route_edges=private_route_edges, now=now)
490
        if (channel_policy_backwards is None
5✔
491
                and not is_mine
492
                and short_channel_id not in private_route_edges):
493
            return float('inf'), 0
×
494
        if channel_policy.is_disabled():
5✔
495
            return float('inf'), 0
×
496
        if payment_amt_msat < channel_policy.htlc_minimum_msat:
5✔
497
            return float('inf'), 0  # payment amount too little
×
498
        if channel_info.capacity_sat is not None and \
5✔
499
                payment_amt_msat // 1000 > channel_info.capacity_sat:
500
            return float('inf'), 0  # payment amount too large
×
501
        if channel_policy.htlc_maximum_msat is not None and \
5✔
502
                payment_amt_msat > channel_policy.htlc_maximum_msat:
503
            return float('inf'), 0  # payment amount too large
×
504
        route_edge = private_route_edges.get(short_channel_id, None)
5✔
505
        if route_edge is None:
5✔
506
            node_info = self.channel_db.get_node_info_for_node_id(node_id=end_node)
5✔
507
            if node_info:
5✔
508
                # it's ok if we are missing the node_announcement (node_info) for this node,
509
                # but if we have it, we enforce that they support var_onion_optin
510
                node_features = LnFeatures(node_info.features)
×
511
                if not node_features.supports(LnFeatures.VAR_ONION_OPT):  # note: this is kind of slow. could be cached.
×
512
                    return float('inf'), 0
×
513
            route_edge = RouteEdge.from_channel_policy(
5✔
514
                channel_policy=channel_policy,
515
                short_channel_id=short_channel_id,
516
                start_node=start_node,
517
                end_node=end_node,
518
                node_info=node_info)
519
        if not route_edge.is_sane_to_use(payment_amt_msat):
5✔
520
            return float('inf'), 0  # thanks but no thanks
×
521
        # Distance metric notes:  # TODO constants are ad-hoc
522
        # ( somewhat based on https://github.com/lightningnetwork/lnd/pull/1358 )
523
        # - Edges have a base cost. (more edges -> less likely none will fail)
524
        # - The larger the payment amount, and the longer the CLTV,
525
        #   the more irritating it is if the HTLC gets stuck.
526
        # - Paying lower fees is better. :)
527
        if ignore_costs:
5✔
528
            return DEFAULT_PENALTY_BASE_MSAT, 0
5✔
529
        fee_msat = route_edge.fee_for_edge(payment_amt_msat)
5✔
530
        cltv_cost = route_edge.cltv_expiry_delta * payment_amt_msat * 15 / 1_000_000_000
5✔
531
        # the liquidty penalty takes care we favor edges that should be able to forward
532
        # the payment and penalize edges that cannot
533
        liquidity_penalty = self.liquidity_hints.penalty(start_node, end_node, short_channel_id, payment_amt_msat)
5✔
534
        overall_cost = fee_msat + cltv_cost + liquidity_penalty
5✔
535
        return overall_cost, fee_msat
5✔
536

537
    def get_shortest_path_hops(
5✔
538
            self,
539
            *,
540
            nodeA: bytes,
541
            nodeB: bytes,
542
            invoice_amount_msat: int,
543
            my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
544
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
545
    ) -> Dict[bytes, PathEdge]:
546
        # note: we don't lock self.channel_db, so while the path finding runs,
547
        #       the underlying graph could potentially change... (not good but maybe ~OK?)
548

549
        # run Dijkstra
550
        # The search is run in the REVERSE direction, from nodeB to nodeA,
551
        # to properly calculate compound routing fees.
552
        distance_from_start = defaultdict(lambda: float('inf'))
5✔
553
        distance_from_start[nodeB] = 0
5✔
554
        previous_hops = {}  # type: Dict[bytes, PathEdge]
5✔
555
        nodes_to_explore = queue.PriorityQueue()
5✔
556
        nodes_to_explore.put((0, invoice_amount_msat, nodeB))  # order of fields (in tuple) matters!
5✔
557
        now = int(time.time())
5✔
558

559
        # main loop of search
560
        while nodes_to_explore.qsize() > 0:
5✔
561
            dist_to_edge_endnode, amount_msat, edge_endnode = nodes_to_explore.get()
5✔
562
            if edge_endnode == nodeA and previous_hops:  # previous_hops check for circular paths
5✔
563
                self.logger.info("found a path")
5✔
564
                break
5✔
565
            if dist_to_edge_endnode != distance_from_start[edge_endnode]:
5✔
566
                # queue.PriorityQueue does not implement decrease_priority,
567
                # so instead of decreasing priorities, we add items again into the queue.
568
                # so there are duplicates in the queue, that we discard now:
569
                continue
×
570

571
            if nodeA == nodeB:  # we want circular paths
5✔
572
                if not previous_hops:  # in the first node exploration step, we only take receiving channels
×
573
                    channels_for_endnode = self.channel_db.get_channels_for_node(
×
574
                        edge_endnode, my_channels={}, private_route_edges=private_route_edges)
575
                else:  # in the next steps, we only take sending channels
576
                    channels_for_endnode = self.channel_db.get_channels_for_node(
×
577
                        edge_endnode, my_channels=my_sending_channels, private_route_edges={})
578
            else:
579
                channels_for_endnode = self.channel_db.get_channels_for_node(
5✔
580
                    edge_endnode, my_channels=my_sending_channels, private_route_edges=private_route_edges)
581

582
            for edge_channel_id in channels_for_endnode:
5✔
583
                assert isinstance(edge_channel_id, bytes)
5✔
584
                if self._is_edge_blacklisted(edge_channel_id, now=now):
5✔
585
                    continue
5✔
586
                channel_info = self.channel_db.get_channel_info(
5✔
587
                    edge_channel_id, my_channels=my_sending_channels, private_route_edges=private_route_edges)
588
                if channel_info is None:
5✔
589
                    continue
×
590
                edge_startnode = channel_info.node2_id if channel_info.node1_id == edge_endnode else channel_info.node1_id
5✔
591
                is_mine = edge_channel_id in my_sending_channels
5✔
592
                if is_mine:
5✔
593
                    if edge_startnode == nodeA:  # payment outgoing, on our channel
5✔
594
                        if not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True):
5✔
595
                            continue
×
596
                edge_cost, fee_for_edge_msat = self._edge_cost(
5✔
597
                    short_channel_id=edge_channel_id,
598
                    start_node=edge_startnode,
599
                    end_node=edge_endnode,
600
                    payment_amt_msat=amount_msat,
601
                    ignore_costs=(edge_startnode == nodeA),
602
                    is_mine=is_mine,
603
                    my_channels=my_sending_channels,
604
                    private_route_edges=private_route_edges,
605
                    now=now,
606
                )
607
                alt_dist_to_neighbour = distance_from_start[edge_endnode] + edge_cost
5✔
608
                if alt_dist_to_neighbour < distance_from_start[edge_startnode]:
5✔
609
                    distance_from_start[edge_startnode] = alt_dist_to_neighbour
5✔
610
                    previous_hops[edge_startnode] = PathEdge(
5✔
611
                        start_node=edge_startnode,
612
                        end_node=edge_endnode,
613
                        short_channel_id=ShortChannelID(edge_channel_id))
614
                    amount_to_forward_msat = amount_msat + fee_for_edge_msat
5✔
615
                    nodes_to_explore.put((alt_dist_to_neighbour, amount_to_forward_msat, edge_startnode))
5✔
616
            # for circular paths, we already explored the end node, but this
617
            # is also our start node, so set it to unexplored
618
            if edge_endnode == nodeB and nodeA == nodeB:
5✔
619
                distance_from_start[edge_endnode] = float('inf')
×
620
        return previous_hops
5✔
621

622
    @profiler
5✔
623
    def find_path_for_payment(
5✔
624
            self,
625
            *,
626
            nodeA: bytes,
627
            nodeB: bytes,
628
            invoice_amount_msat: int,
629
            my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
630
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
631
    ) -> Optional[LNPaymentPath]:
632
        """Return a path from nodeA to nodeB."""
633
        assert type(nodeA) is bytes
5✔
634
        assert type(nodeB) is bytes
5✔
635
        assert type(invoice_amount_msat) is int
5✔
636
        if my_sending_channels is None:
5✔
637
            my_sending_channels = {}
5✔
638

639
        previous_hops = self.get_shortest_path_hops(
5✔
640
            nodeA=nodeA,
641
            nodeB=nodeB,
642
            invoice_amount_msat=invoice_amount_msat,
643
            my_sending_channels=my_sending_channels,
644
            private_route_edges=private_route_edges)
645

646
        if nodeA not in previous_hops:
5✔
647
            return None  # no path found
5✔
648

649
        # backtrack from search_end (nodeA) to search_start (nodeB)
650
        # FIXME paths cannot be longer than 20 edges (onion packet)...
651
        edge_startnode = nodeA
5✔
652
        path = []
5✔
653
        while edge_startnode != nodeB or not path:  # second condition for circular paths
5✔
654
            edge = previous_hops[edge_startnode]
5✔
655
            path += [edge]
5✔
656
            edge_startnode = edge.node_id
5✔
657
        return path
5✔
658

659
    def create_route_from_path(
5✔
660
            self,
661
            path: Optional[LNPaymentPath],
662
            *,
663
            my_channels: Dict[ShortChannelID, 'Channel'] = None,
664
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
665
    ) -> LNPaymentRoute:
666
        if path is None:
5✔
667
            raise Exception('cannot create route from None path')
×
668
        if private_route_edges is None:
5✔
669
            private_route_edges = {}
5✔
670
        route = []
5✔
671
        prev_end_node = path[0].start_node
5✔
672
        for path_edge in path:
5✔
673
            short_channel_id = path_edge.short_channel_id
5✔
674
            _endnodes = self.channel_db.get_endnodes_for_chan(short_channel_id, my_channels=my_channels)
5✔
675
            if _endnodes and sorted(_endnodes) != sorted([path_edge.start_node, path_edge.end_node]):
5✔
676
                raise LNPathInconsistent("endpoints of edge inconsistent with short_channel_id")
5✔
677
            if path_edge.start_node != prev_end_node:
5✔
678
                raise LNPathInconsistent("edges do not chain together")
×
679
            route_edge = private_route_edges.get(short_channel_id, None)
5✔
680
            if route_edge is None:
5✔
681
                channel_policy = self.channel_db.get_policy_for_node(
5✔
682
                    short_channel_id=short_channel_id,
683
                    node_id=path_edge.start_node,
684
                    my_channels=my_channels)
685
                if channel_policy is None:
5✔
686
                    raise NoChannelPolicy(short_channel_id)
×
687
                node_info = self.channel_db.get_node_info_for_node_id(node_id=path_edge.end_node)
5✔
688
                route_edge = RouteEdge.from_channel_policy(
5✔
689
                    channel_policy=channel_policy,
690
                    short_channel_id=short_channel_id,
691
                    start_node=path_edge.start_node,
692
                    end_node=path_edge.end_node,
693
                    node_info=node_info)
694
            route.append(route_edge)
5✔
695
            prev_end_node = path_edge.end_node
5✔
696
        return route
5✔
697

698
    def find_route(
5✔
699
            self,
700
            *,
701
            nodeA: bytes,
702
            nodeB: bytes,
703
            invoice_amount_msat: int,
704
            path = None,
705
            my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
706
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
707
    ) -> Optional[LNPaymentRoute]:
708
        route = None
5✔
709
        if not path:
5✔
710
            path = self.find_path_for_payment(
5✔
711
                nodeA=nodeA,
712
                nodeB=nodeB,
713
                invoice_amount_msat=invoice_amount_msat,
714
                my_sending_channels=my_sending_channels,
715
                private_route_edges=private_route_edges)
716
        if path:
5✔
717
            route = self.create_route_from_path(
5✔
718
                path, my_channels=my_sending_channels, private_route_edges=private_route_edges)
719
        return route
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc