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

spesmilo / electrum / 5735552722403328

16 May 2025 10:28AM UTC coverage: 59.722% (+0.002%) from 59.72%
5735552722403328

Pull #9833

CirrusCI

f321x
make lightning dns seed fetching async
Pull Request #9833: dns: use async dnspython interface

22 of 50 new or added lines in 7 files covered. (44.0%)

1107 existing lines in 11 files now uncovered.

21549 of 36082 relevant lines covered (59.72%)

2.39 hits per line

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

88.49
/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
4✔
27
from collections import defaultdict
4✔
28
from typing import Sequence, Tuple, Optional, Dict, TYPE_CHECKING, Set, Callable
4✔
29
import time
4✔
30
import threading
4✔
31
from threading import RLock
4✔
32
from math import inf
4✔
33

34
import attr
4✔
35

36
from .util import profiler, with_lock
4✔
37
from .logging import Logger
4✔
38
from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures,
4✔
39
                     NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, PaymentFeeBudget)
40
from .channel_db import ChannelDB, Policy, NodeInfo
4✔
41

42
if TYPE_CHECKING:
4✔
43
    from .lnchannel import Channel
×
44

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

49

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

55

56
class LNPathInconsistent(Exception): pass
4✔
57

58

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

63

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

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

75
@attr.s
4✔
76
class RouteEdge(PathEdge):
4✔
77
    fee_base_msat = attr.ib(type=int, kw_only=True)                # for start_node
4✔
78
    fee_proportional_millionths = attr.ib(type=int, kw_only=True)  # for start_node
4✔
79
    cltv_delta = attr.ib(type=int, kw_only=True)                   # for start_node
4✔
80
    node_features = attr.ib(type=int, kw_only=True, repr=lambda val: str(int(val)))  # note: for end_node!
4✔
81

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

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

109
    def has_feature_varonion(self) -> bool:
4✔
110
        features = LnFeatures(self.node_features)
×
111
        return features.supports(LnFeatures.VAR_ONION_OPT)
×
112

113
    def is_trampoline(self) -> bool:
4✔
114
        return False
×
115

116
@attr.s
4✔
117
class TrampolineEdge(RouteEdge):
4✔
118
    invoice_routing_info = attr.ib(type=Sequence[bytes], default=None)
4✔
119
    invoice_features = attr.ib(type=int, default=None)
4✔
120
    # this is re-defined from parent just to specify a default value:
121
    short_channel_id = attr.ib(default=ShortChannelID(8), repr=lambda val: str(val))
4✔
122

123
    def is_trampoline(self):
4✔
124
        return True
4✔
125

126

127
LNPaymentPath = Sequence[PathEdge]
4✔
128
LNPaymentRoute = Sequence[RouteEdge]
4✔
129
LNPaymentTRoute = Sequence[TrampolineEdge]
4✔
130

131

132
def is_route_within_budget(
4✔
133
    route: LNPaymentRoute,
134
    *,
135
    budget: PaymentFeeBudget,
136
    amount_msat_for_dest: int,  # that final receiver gets
137
    cltv_delta_for_dest: int,   # that final receiver gets
138
) -> bool:
139
    """Run some sanity checks on the whole route, before attempting to use it.
140
    called when we are paying; so e.g. lower cltv is better
141
    """
142
    if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH:
4✔
143
        return False
×
144
    amt = amount_msat_for_dest
4✔
145
    cltv_cost_of_route = 0  # excluding cltv_delta_for_dest
4✔
146
    for route_edge in reversed(route[1:]):
4✔
147
        amt += route_edge.fee_for_edge(amt)
4✔
148
        cltv_cost_of_route += route_edge.cltv_delta
4✔
149
    fee_cost = amt - amount_msat_for_dest
4✔
150
    # check against budget
151
    if cltv_cost_of_route > budget.cltv:
4✔
152
        return False
×
153
    if fee_cost > budget.fee_msat:
4✔
154
        return False
×
155
    # sanity check
156
    total_cltv_delta = cltv_cost_of_route + cltv_delta_for_dest
4✔
157
    if total_cltv_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE:
4✔
158
        return False
×
159
    return True
4✔
160

161

162
class LiquidityHint:
4✔
163
    """Encodes the amounts that can and cannot be sent over the direction of a
164
    channel.
165

166
    A LiquidityHint is the value of a dict, which is keyed to node ids and the
167
    channel.
168
    """
169
    def __init__(self):
4✔
170
        # use "can_send_forward + can_send_backward < cannot_send_forward + cannot_send_backward" as a sanity check?
171
        self._can_send_forward = None
4✔
172
        self._cannot_send_forward = None
4✔
173
        self._can_send_backward = None
4✔
174
        self._cannot_send_backward = None
4✔
175
        self.hint_timestamp = 0
4✔
176
        self._inflight_htlcs_forward = 0
4✔
177
        self._inflight_htlcs_backward = 0
4✔
178

179
    def is_hint_invalid(self) -> bool:
4✔
180
        now = int(time.time())
4✔
181
        return now - self.hint_timestamp > HINT_DURATION
4✔
182

183
    @property
4✔
184
    def can_send_forward(self):
4✔
185
        return None if self.is_hint_invalid() else self._can_send_forward
4✔
186

187
    @can_send_forward.setter
4✔
188
    def can_send_forward(self, amount):
4✔
189
        # we don't want to record less significant info
190
        # (sendable amount is lower than known sendable amount):
191
        if self._can_send_forward and self._can_send_forward > amount:
4✔
192
            return
4✔
193
        self._can_send_forward = amount
4✔
194
        # we make a sanity check that sendable amount is lower than not sendable amount
195
        if self._cannot_send_forward and self._can_send_forward > self._cannot_send_forward:
4✔
196
            self._cannot_send_forward = None
4✔
197

198
    @property
4✔
199
    def can_send_backward(self):
4✔
200
        return None if self.is_hint_invalid() else self._can_send_backward
4✔
201

202
    @can_send_backward.setter
4✔
203
    def can_send_backward(self, amount):
4✔
204
        if self._can_send_backward and self._can_send_backward > amount:
4✔
205
            return
×
206
        self._can_send_backward = amount
4✔
207
        if self._cannot_send_backward and self._can_send_backward > self._cannot_send_backward:
4✔
208
            self._cannot_send_backward = None
×
209

210
    @property
4✔
211
    def cannot_send_forward(self):
4✔
212
        return None if self.is_hint_invalid() else self._cannot_send_forward
4✔
213

214
    @cannot_send_forward.setter
4✔
215
    def cannot_send_forward(self, amount):
4✔
216
        # we don't want to record less significant info
217
        # (not sendable amount is higher than known not sendable amount):
218
        if self._cannot_send_forward and self._cannot_send_forward < amount:
4✔
219
            return
×
220
        self._cannot_send_forward = amount
4✔
221
        if self._can_send_forward and self._can_send_forward > self._cannot_send_forward:
4✔
222
            self._can_send_forward = None
×
223
        # if we can't send over the channel, we should be able to send in the
224
        # reverse direction
225
        self.can_send_backward = amount
4✔
226

227
    @property
4✔
228
    def cannot_send_backward(self):
4✔
229
        return None if self.is_hint_invalid() else self._cannot_send_backward
4✔
230

231
    @cannot_send_backward.setter
4✔
232
    def cannot_send_backward(self, amount):
4✔
UNCOV
233
        if self._cannot_send_backward and self._cannot_send_backward < amount:
3✔
234
            return
×
UNCOV
235
        self._cannot_send_backward = amount
3✔
UNCOV
236
        if self._can_send_backward and self._can_send_backward > self._cannot_send_backward:
3✔
237
            self._can_send_backward = None
×
UNCOV
238
        self.can_send_forward = amount
3✔
239

240
    def can_send(self, is_forward_direction: bool):
4✔
241
        # make info invalid after some time?
242
        if is_forward_direction:
4✔
243
            return self.can_send_forward
4✔
244
        else:
245
            return self.can_send_backward
4✔
246

247
    def cannot_send(self, is_forward_direction: bool):
4✔
248
        # make info invalid after some time?
249
        if is_forward_direction:
4✔
250
            return self.cannot_send_forward
4✔
251
        else:
252
            return self.cannot_send_backward
4✔
253

254
    def update_can_send(self, is_forward_direction: bool, amount: int):
4✔
255
        self.hint_timestamp = int(time.time())
4✔
256
        if is_forward_direction:
4✔
257
            self.can_send_forward = amount
4✔
258
        else:
259
            self.can_send_backward = amount
4✔
260

261
    def update_cannot_send(self, is_forward_direction: bool, amount: int):
4✔
262
        self.hint_timestamp = int(time.time())
4✔
263
        if is_forward_direction:
4✔
264
            self.cannot_send_forward = amount
4✔
265
        else:
UNCOV
266
            self.cannot_send_backward = amount
3✔
267

268
    def num_inflight_htlcs(self, is_forward_direction: bool) -> int:
4✔
269
        if is_forward_direction:
4✔
270
            return self._inflight_htlcs_forward
4✔
271
        else:
272
            return self._inflight_htlcs_backward
4✔
273

274
    def add_htlc(self, is_forward_direction: bool):
4✔
275
        if is_forward_direction:
4✔
276
            self._inflight_htlcs_forward += 1
4✔
277
        else:
278
            self._inflight_htlcs_backward += 1
4✔
279

280
    def remove_htlc(self, is_forward_direction: bool):
4✔
281
        if is_forward_direction:
4✔
282
            self._inflight_htlcs_forward = max(0, self._inflight_htlcs_forward - 1)
4✔
283
        else:
284
            self._inflight_htlcs_backward = max(0, self._inflight_htlcs_forward - 1)
4✔
285

286
    def __repr__(self):
4✔
287
        return f"forward: can send: {self._can_send_forward} msat, cannot send: {self._cannot_send_forward} msat, htlcs: {self._inflight_htlcs_forward}\n" \
×
288
               f"backward: can send: {self._can_send_backward} msat, cannot send: {self._cannot_send_backward} msat, htlcs: {self._inflight_htlcs_backward}\n"
289

290

291
class LiquidityHintMgr:
4✔
292
    """Implements liquidity hints for channels in the graph.
293

294
    This class can be used to update liquidity information about channels in the
295
    graph. Implements a penalty function for edge weighting in the pathfinding
296
    algorithm that favors channels which can route payments and penalizes
297
    channels that cannot.
298
    """
299
    # TODO: hints based on node pairs only (shadow channels, non-strict forwarding)?
300
    def __init__(self):
4✔
301
        self.lock = RLock()
4✔
302
        self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {}
4✔
303

304
    @with_lock
4✔
305
    def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint:
4✔
306
        hint = self._liquidity_hints.get(channel_id)
4✔
307
        if not hint:
4✔
308
            hint = LiquidityHint()
4✔
309
            self._liquidity_hints[channel_id] = hint
4✔
310
        return hint
4✔
311

312
    @with_lock
4✔
313
    def update_can_send(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int):
4✔
314
        hint = self.get_hint(channel_id)
4✔
315
        hint.update_can_send(node_from < node_to, amount)
4✔
316

317
    @with_lock
4✔
318
    def update_cannot_send(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int):
4✔
319
        hint = self.get_hint(channel_id)
4✔
320
        hint.update_cannot_send(node_from < node_to, amount)
4✔
321

322
    @with_lock
4✔
323
    def add_htlc(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID):
4✔
324
        hint = self.get_hint(channel_id)
4✔
325
        hint.add_htlc(node_from < node_to)
4✔
326

327
    @with_lock
4✔
328
    def remove_htlc(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID):
4✔
329
        hint = self.get_hint(channel_id)
4✔
330
        hint.remove_htlc(node_from < node_to)
4✔
331

332
    def penalty(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int) -> float:
4✔
333
        """Gives a penalty when sending from node1 to node2 over channel_id with an
334
        amount in units of millisatoshi.
335

336
        The penalty depends on the can_send and cannot_send values that was
337
        possibly recorded in previous payment attempts.
338

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

362
        if cannot_send is not None and amount >= cannot_send:
4✔
363
            return inf
4✔
364
        if can_send is not None and amount <= can_send:
4✔
365
            return 0
4✔
366
        success_fee = fee_for_edge_msat(amount, DEFAULT_PENALTY_BASE_MSAT, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH)
4✔
367
        inflight_htlc_fee = num_inflight_htlcs * success_fee
4✔
368
        return success_fee + inflight_htlc_fee
4✔
369

370
    @with_lock
4✔
371
    def reset_liquidity_hints(self):
4✔
372
        for k, v in self._liquidity_hints.items():
4✔
373
            v.hint_timestamp = 0
4✔
374

375
    def __repr__(self):
4✔
376
        string = "liquidity hints:\n"
×
377
        if self._liquidity_hints:
×
378
            for k, v in self._liquidity_hints.items():
×
379
                string += f"{k}: {v}\n"
×
380
        return string
×
381

382

383
class LNPathFinder(Logger):
4✔
384

385
    def __init__(self, channel_db: ChannelDB):
4✔
386
        Logger.__init__(self)
4✔
387
        self.channel_db = channel_db
4✔
388
        self.liquidity_hints = LiquidityHintMgr()
4✔
389
        self._edge_blacklist = dict()  # type: Dict[ShortChannelID, int]  # scid -> expiration
4✔
390
        self._blacklist_lock = threading.Lock()
4✔
391

392
    def _is_edge_blacklisted(self, short_channel_id: ShortChannelID, *, now: int) -> bool:
4✔
393
        blacklist_expiration = self._edge_blacklist.get(short_channel_id)
4✔
394
        if blacklist_expiration is None:
4✔
395
            return False
4✔
396
        if blacklist_expiration < now:
4✔
397
            return False
×
398
            # TODO rm expired entries from cache (note: perf vs thread-safety)
399
        return True
4✔
400

401
    def add_edge_to_blacklist(
4✔
402
        self,
403
        short_channel_id: ShortChannelID,
404
        *,
405
        now: int = None,
406
        duration: int = 3600,  # seconds
407
    ) -> None:
408
        if now is None:
4✔
409
            now = int(time.time())
4✔
410
        with self._blacklist_lock:
4✔
411
            blacklist_expiration = self._edge_blacklist.get(short_channel_id, 0)
4✔
412
            self._edge_blacklist[short_channel_id] = max(blacklist_expiration, now + duration)
4✔
413

414
    def clear_blacklist(self):
4✔
415
        with self._blacklist_lock:
×
416
            self._edge_blacklist = dict()
×
417

418
    def update_liquidity_hints(
4✔
419
            self,
420
            route: LNPaymentRoute,
421
            amount_msat: int,
422
            failing_channel: ShortChannelID=None
423
    ):
424
        # go through the route and record successes until the failing channel is reached,
425
        # for the failing channel, add a cannot_send liquidity hint
426
        # note: actual routable amounts are slightly different than reported here
427
        # as fees would need to be added
428
        for r in route:
4✔
429
            if r.short_channel_id != failing_channel:
4✔
430
                self.logger.info(f"report {r.short_channel_id} to be able to forward {amount_msat} msat")
4✔
431
                self.liquidity_hints.update_can_send(r.start_node, r.end_node, r.short_channel_id, amount_msat)
4✔
432
            else:
433
                self.logger.info(f"report {r.short_channel_id} to be unable to forward {amount_msat} msat")
4✔
434
                self.liquidity_hints.update_cannot_send(r.start_node, r.end_node, r.short_channel_id, amount_msat)
4✔
435
                break
4✔
436
        else:
437
            assert failing_channel is None
4✔
438

439
    def update_inflight_htlcs(self, route: LNPaymentRoute, add_htlcs: bool):
4✔
440
        self.logger.info(f"{'Adding' if add_htlcs else 'Removing'} inflight htlcs to graph (liquidity hints).")
4✔
441
        for r in route:
4✔
442
            if add_htlcs:
4✔
443
                self.liquidity_hints.add_htlc(r.start_node, r.end_node, r.short_channel_id)
4✔
444
            else:
445
                self.liquidity_hints.remove_htlc(r.start_node, r.end_node, r.short_channel_id)
4✔
446

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

526
    def get_shortest_path_hops(
4✔
527
            self,
528
            *,
529
            nodeA: bytes, # nodeA is expected to be our node id if channels are passed in my_sending_channels
530
            nodeB: bytes,
531
            invoice_amount_msat: int,
532
            my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
533
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
534
            node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None
535
    ) -> Dict[bytes, PathEdge]:
536
        # note: we don't lock self.channel_db, so while the path finding runs,
537
        #       the underlying graph could potentially change... (not good but maybe ~OK?)
538

539
        # if destination is filtered, there is no route
540
        if node_filter:
4✔
541
            node_info = self.channel_db.get_node_info_for_node_id(nodeB)
4✔
542
            if not node_filter(nodeB, node_info):
4✔
543
                return {}
4✔
544

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

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

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

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

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

641
        previous_hops = self.get_shortest_path_hops(
4✔
642
            nodeA=nodeA,
643
            nodeB=nodeB,
644
            invoice_amount_msat=invoice_amount_msat,
645
            my_sending_channels=my_sending_channels,
646
            private_route_edges=private_route_edges,
647
            node_filter=node_filter)
648

649
        if nodeA not in previous_hops:
4✔
650
            return None  # no path found
4✔
651

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

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

701
    def find_route(
4✔
702
            self,
703
            *,
704
            nodeA: bytes,
705
            nodeB: bytes,
706
            invoice_amount_msat: int,
707
            path: Optional[Sequence[PathEdge]] = None,
708
            my_sending_channels: Dict[ShortChannelID, 'Channel'] = None,
709
            private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
710
    ) -> Optional[LNPaymentRoute]:
711
        route = None
4✔
712
        if not path:
4✔
713
            path = self.find_path_for_payment(
4✔
714
                nodeA=nodeA,
715
                nodeB=nodeB,
716
                invoice_amount_msat=invoice_amount_msat,
717
                my_sending_channels=my_sending_channels,
718
                private_route_edges=private_route_edges)
719
        if path:
4✔
720
            route = self.create_route_from_path(
4✔
721
                path, my_channels=my_sending_channels, private_route_edges=private_route_edges)
722
        return route
4✔
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