Browse Source

Merge pull request #7152 from bitromortac/2103-liquidity-hints

Liquidity hints for pathfinding
patch-4
ghost43 4 years ago
committed by GitHub
parent
commit
0f83270053
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      electrum/address_synchronizer.py
  2. 8
      electrum/blockchain.py
  3. 6
      electrum/commands.py
  4. 8
      electrum/lnhtlc.py
  5. 252
      electrum/lnrouter.py
  6. 13
      electrum/lnutil.py
  7. 76
      electrum/lnworker.py
  8. 2
      electrum/network.py
  9. 37
      electrum/tests/test_lnpeer.py
  10. 277
      electrum/tests/test_lnrouter.py
  11. 12
      electrum/util.py

8
electrum/address_synchronizer.py

@ -32,7 +32,7 @@ from aiorpcx import TaskGroup
from . import bitcoin, util from . import bitcoin, util
from .bitcoin import COINBASE_MATURITY from .bitcoin import COINBASE_MATURITY
from .util import profiler, bfh, TxMinedInfo, UnrelatedTransactionException from .util import profiler, bfh, TxMinedInfo, UnrelatedTransactionException, with_lock
from .transaction import Transaction, TxOutput, TxInput, PartialTxInput, TxOutpoint, PartialTransaction from .transaction import Transaction, TxOutput, TxInput, PartialTxInput, TxOutpoint, PartialTransaction
from .synchronizer import Synchronizer from .synchronizer import Synchronizer
from .verifier import SPV from .verifier import SPV
@ -98,12 +98,6 @@ class AddressSynchronizer(Logger):
self.load_and_cleanup() self.load_and_cleanup()
def with_lock(func):
def func_wrapper(self: 'AddressSynchronizer', *args, **kwargs):
with self.lock:
return func(self, *args, **kwargs)
return func_wrapper
def with_transaction_lock(func): def with_transaction_lock(func):
def func_wrapper(self: 'AddressSynchronizer', *args, **kwargs): def func_wrapper(self: 'AddressSynchronizer', *args, **kwargs):
with self.transaction_lock: with self.transaction_lock:

8
electrum/blockchain.py

@ -29,7 +29,7 @@ from . import util
from .bitcoin import hash_encode, int_to_hex, rev_hex from .bitcoin import hash_encode, int_to_hex, rev_hex
from .crypto import sha256d from .crypto import sha256d
from . import constants from . import constants
from .util import bfh, bh2u from .util import bfh, bh2u, with_lock
from .simple_config import SimpleConfig from .simple_config import SimpleConfig
from .logging import get_logger, Logger from .logging import get_logger, Logger
@ -195,12 +195,6 @@ class Blockchain(Logger):
self.lock = threading.RLock() self.lock = threading.RLock()
self.update_size() self.update_size()
def with_lock(func):
def func_wrapper(self, *args, **kwargs):
with self.lock:
return func(self, *args, **kwargs)
return func_wrapper
@property @property
def checkpoints(self): def checkpoints(self):
return constants.net.CHECKPOINTS return constants.net.CHECKPOINTS

6
electrum/commands.py

@ -1092,7 +1092,11 @@ class Commands:
@command('n') @command('n')
async def clear_ln_blacklist(self): async def clear_ln_blacklist(self):
self.network.path_finder.blacklist.clear() self.network.path_finder.liquidity_hints.clear_blacklist()
@command('n')
async def reset_liquidity_hints(self):
self.network.path_finder.liquidity_hints.reset_liquidity_hints()
@command('w') @command('w')
async def list_invoices(self, wallet: Abstract_Wallet = None): async def list_invoices(self, wallet: Abstract_Wallet = None):

8
electrum/lnhtlc.py

@ -3,7 +3,7 @@ from typing import Optional, Sequence, Tuple, List, Dict, TYPE_CHECKING, Set
import threading import threading
from .lnutil import SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, UpdateAddHtlc, Direction, FeeUpdate from .lnutil import SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, UpdateAddHtlc, Direction, FeeUpdate
from .util import bh2u, bfh from .util import bh2u, bfh, with_lock
if TYPE_CHECKING: if TYPE_CHECKING:
from .json_db import StoredDict from .json_db import StoredDict
@ -50,12 +50,6 @@ class HTLCManager:
self._init_maybe_active_htlc_ids() self._init_maybe_active_htlc_ids()
def with_lock(func):
def func_wrapper(self, *args, **kwargs):
with self.lock:
return func(self, *args, **kwargs)
return func_wrapper
@with_lock @with_lock
def ctn_latest(self, sub: HTLCOwner) -> int: def ctn_latest(self, sub: HTLCOwner) -> int:
"""Return the ctn for the latest (newest that has a valid sig) ctx of sub""" """Return the ctn for the latest (newest that has a valid sig) ctx of sub"""

252
electrum/lnrouter.py

@ -27,9 +27,11 @@ import queue
from collections import defaultdict from collections import defaultdict
from typing import Sequence, List, Tuple, Optional, Dict, NamedTuple, TYPE_CHECKING, Set from typing import Sequence, List, Tuple, Optional, Dict, NamedTuple, TYPE_CHECKING, Set
import time import time
from threading import RLock
import attr import attr
from math import inf
from .util import bh2u, profiler from .util import bh2u, profiler, with_lock
from .logging import Logger from .logging import Logger
from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures, from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures,
NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE) NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE)
@ -38,6 +40,11 @@ from .channel_db import ChannelDB, Policy, NodeInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from .lnchannel import Channel from .lnchannel import Channel
DEFAULT_PENALTY_BASE_MSAT = 500 # how much base fee we apply for unknown sending capability of a channel
DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100 # how much relative fee we apply for unknown sending capability of a channel
BLACKLIST_DURATION = 3600 # how long (in seconds) a channel remains blacklisted
HINT_DURATION = 3600 # how long (in seconds) a liquidity hint remains valid
class NoChannelPolicy(Exception): class NoChannelPolicy(Exception):
def __init__(self, short_channel_id: bytes): def __init__(self, short_channel_id: bytes):
@ -161,12 +168,240 @@ def is_fee_sane(fee_msat: int, *, payment_amount_msat: int) -> bool:
return False return False
class LiquidityHint:
"""Encodes the amounts that can and cannot be sent over the direction of a
channel and whether the channel is blacklisted.
A LiquidityHint is the value of a dict, which is keyed to node ids and the
channel.
"""
def __init__(self):
# use "can_send_forward + can_send_backward < cannot_send_forward + cannot_send_backward" as a sanity check?
self._can_send_forward = None
self._cannot_send_forward = None
self._can_send_backward = None
self._cannot_send_backward = None
self.blacklist_timestamp = 0
self.hint_timestamp = 0
def is_hint_invalid(self) -> bool:
now = int(time.time())
return now - self.hint_timestamp > HINT_DURATION
@property
def can_send_forward(self):
return None if self.is_hint_invalid() else self._can_send_forward
@can_send_forward.setter
def can_send_forward(self, amount):
# we don't want to record less significant info
# (sendable amount is lower than known sendable amount):
if self._can_send_forward and self._can_send_forward > amount:
return
self._can_send_forward = amount
# we make a sanity check that sendable amount is lower than not sendable amount
if self._cannot_send_forward and self._can_send_forward > self._cannot_send_forward:
self._cannot_send_forward = None
@property
def can_send_backward(self):
return None if self.is_hint_invalid() else self._can_send_backward
@can_send_backward.setter
def can_send_backward(self, amount):
if self._can_send_backward and self._can_send_backward > amount:
return
self._can_send_backward = amount
if self._cannot_send_backward and self._can_send_backward > self._cannot_send_backward:
self._cannot_send_backward = None
@property
def cannot_send_forward(self):
return None if self.is_hint_invalid() else self._cannot_send_forward
@cannot_send_forward.setter
def cannot_send_forward(self, amount):
# we don't want to record less significant info
# (not sendable amount is higher than known not sendable amount):
if self._cannot_send_forward and self._cannot_send_forward < amount:
return
self._cannot_send_forward = amount
if self._can_send_forward and self._can_send_forward > self._cannot_send_forward:
self._can_send_forward = None
# if we can't send over the channel, we should be able to send in the
# reverse direction
self.can_send_backward = amount
@property
def cannot_send_backward(self):
return None if self.is_hint_invalid() else self._cannot_send_backward
@cannot_send_backward.setter
def cannot_send_backward(self, amount):
if self._cannot_send_backward and self._cannot_send_backward < amount:
return
self._cannot_send_backward = amount
if self._can_send_backward and self._can_send_backward > self._cannot_send_backward:
self._can_send_backward = None
self.can_send_forward = amount
def can_send(self, is_forward_direction: bool):
# make info invalid after some time?
if is_forward_direction:
return self.can_send_forward
else:
return self.can_send_backward
def cannot_send(self, is_forward_direction: bool):
# make info invalid after some time?
if is_forward_direction:
return self.cannot_send_forward
else:
return self.cannot_send_backward
def update_can_send(self, is_forward_direction: bool, amount: int):
self.hint_timestamp = int(time.time())
if is_forward_direction:
self.can_send_forward = amount
else:
self.can_send_backward = amount
def update_cannot_send(self, is_forward_direction: bool, amount: int):
self.hint_timestamp = int(time.time())
if is_forward_direction:
self.cannot_send_forward = amount
else:
self.cannot_send_backward = amount
def __repr__(self):
is_blacklisted = False if not self.blacklist_timestamp else int(time.time()) - self.blacklist_timestamp < BLACKLIST_DURATION
return f"forward: can send: {self._can_send_forward} msat, cannot send: {self._cannot_send_forward} msat, \n" \
f"backward: can send: {self._can_send_backward} msat, cannot send: {self._cannot_send_backward} msat, \n" \
f"blacklisted: {is_blacklisted}"
class LiquidityHintMgr:
"""Implements liquidity hints for channels in the graph.
This class can be used to update liquidity information about channels in the
graph. Implements a penalty function for edge weighting in the pathfinding
algorithm that favors channels which can route payments and penalizes
channels that cannot.
"""
# TODO: incorporate in-flight htlcs
# TODO: use timestamps for can/not_send to make them None after some time?
# TODO: hints based on node pairs only (shadow channels, non-strict forwarding)?
def __init__(self):
self.lock = RLock()
self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {}
@with_lock
def get_hint(self, channel_id: ShortChannelID):
hint = self._liquidity_hints.get(channel_id)
if not hint:
hint = LiquidityHint()
self._liquidity_hints[channel_id] = hint
return hint
@with_lock
def update_can_send(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int):
hint = self.get_hint(channel_id)
hint.update_can_send(node_from < node_to, amount)
@with_lock
def update_cannot_send(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int):
hint = self.get_hint(channel_id)
hint.update_cannot_send(node_from < node_to, amount)
def penalty(self, node_from: bytes, node_to: bytes, channel_id: ShortChannelID, amount: int) -> float:
"""Gives a penalty when sending from node1 to node2 over channel_id with an
amount in units of millisatoshi.
The penalty depends on the can_send and cannot_send values that was
possibly recorded in previous payment attempts.
A channel that can send an amount is assigned a penalty of zero, a
channel that cannot send an amount is assigned an infinite penalty.
If the sending amount lies between can_send and cannot_send, there's
uncertainty and we give a default penalty. The default penalty
serves the function of giving a positive offset (the Dijkstra
algorithm doesn't work with negative weights), from which we can discount
from. There is a competition between low-fee channels and channels where
we know with some certainty that they can support a payment. The penalty
ultimately boils down to: how much more fees do we want to pay for
certainty of payment success? This can be tuned via DEFAULT_PENALTY_BASE_MSAT
and DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH. A base _and_ relative penalty
was chosen such that the penalty will be able to compete with the regular
base and relative fees.
"""
# we only evaluate hints here, so use dict get (to not create many hints with self.get_hint)
hint = self._liquidity_hints.get(channel_id)
if not hint:
can_send, cannot_send = None, None
else:
can_send = hint.can_send(node_from < node_to)
cannot_send = hint.cannot_send(node_from < node_to)
if cannot_send is not None and amount >= cannot_send:
return inf
if can_send is not None and amount <= can_send:
return 0
return fee_for_edge_msat(amount, DEFAULT_PENALTY_BASE_MSAT, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH)
@with_lock
def add_to_blacklist(self, channel_id: ShortChannelID):
hint = self.get_hint(channel_id)
now = int(time.time())
hint.blacklist_timestamp = now
@with_lock
def get_blacklist(self) -> Set[ShortChannelID]:
now = int(time.time())
return set(k for k, v in self._liquidity_hints.items() if now - v.blacklist_timestamp < BLACKLIST_DURATION)
@with_lock
def clear_blacklist(self):
for k, v in self._liquidity_hints.items():
v.blacklist_timestamp = 0
@with_lock
def reset_liquidity_hints(self):
for k, v in self._liquidity_hints.items():
v.hint_timestamp = 0
def __repr__(self):
string = "liquidity hints:\n"
if self._liquidity_hints:
for k, v in self._liquidity_hints.items():
string += f"{k}: {v}\n"
return string
class LNPathFinder(Logger): class LNPathFinder(Logger):
def __init__(self, channel_db: ChannelDB): def __init__(self, channel_db: ChannelDB):
Logger.__init__(self) Logger.__init__(self)
self.channel_db = channel_db self.channel_db = channel_db
self.liquidity_hints = LiquidityHintMgr()
def update_liquidity_hints(
self,
route: LNPaymentRoute,
amount_msat: int,
failing_channel: ShortChannelID=None
):
# go through the route and record successes until the failing channel is reached,
# for the failing channel, add a cannot_send liquidity hint
# note: actual routable amounts are slightly different than reported here
# as fees would need to be added
for r in route:
if r.short_channel_id != failing_channel:
self.logger.info(f"report {r.short_channel_id} to be able to forward {amount_msat} msat")
self.liquidity_hints.update_can_send(r.start_node, r.end_node, r.short_channel_id, amount_msat)
else:
self.logger.info(f"report {r.short_channel_id} to be unable to forward {amount_msat} msat")
self.liquidity_hints.update_cannot_send(r.start_node, r.end_node, r.short_channel_id, amount_msat)
break
def _edge_cost( def _edge_cost(
self, self,
@ -221,19 +456,20 @@ class LNPathFinder(Logger):
node_info=node_info) node_info=node_info)
if not route_edge.is_sane_to_use(payment_amt_msat): if not route_edge.is_sane_to_use(payment_amt_msat):
return float('inf'), 0 # thanks but no thanks return float('inf'), 0 # thanks but no thanks
# Distance metric notes: # TODO constants are ad-hoc # Distance metric notes: # TODO constants are ad-hoc
# ( somewhat based on https://github.com/lightningnetwork/lnd/pull/1358 ) # ( somewhat based on https://github.com/lightningnetwork/lnd/pull/1358 )
# - Edges have a base cost. (more edges -> less likely none will fail) # - Edges have a base cost. (more edges -> less likely none will fail)
# - The larger the payment amount, and the longer the CLTV, # - The larger the payment amount, and the longer the CLTV,
# the more irritating it is if the HTLC gets stuck. # the more irritating it is if the HTLC gets stuck.
# - Paying lower fees is better. :) # - Paying lower fees is better. :)
base_cost = 500 # one more edge ~ paying 500 msat more fees
if ignore_costs: if ignore_costs:
return base_cost, 0 return DEFAULT_PENALTY_BASE_MSAT, 0
fee_msat = route_edge.fee_for_edge(payment_amt_msat) fee_msat = route_edge.fee_for_edge(payment_amt_msat)
cltv_cost = route_edge.cltv_expiry_delta * payment_amt_msat * 15 / 1_000_000_000 cltv_cost = route_edge.cltv_expiry_delta * payment_amt_msat * 15 / 1_000_000_000
overall_cost = base_cost + fee_msat + cltv_cost # the liquidty penalty takes care we favor edges that should be able to forward
# the payment and penalize edges that cannot
liquidity_penalty = self.liquidity_hints.penalty(start_node, end_node, short_channel_id, payment_amt_msat)
overall_cost = fee_msat + cltv_cost + liquidity_penalty
return overall_cost, fee_msat return overall_cost, fee_msat
def get_distances( def get_distances(
@ -243,7 +479,6 @@ class LNPathFinder(Logger):
nodeB: bytes, nodeB: bytes,
invoice_amount_msat: int, invoice_amount_msat: int,
my_channels: Dict[ShortChannelID, 'Channel'] = None, my_channels: Dict[ShortChannelID, 'Channel'] = None,
blacklist: Set[ShortChannelID] = None,
private_route_edges: Dict[ShortChannelID, RouteEdge] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
) -> Dict[bytes, PathEdge]: ) -> Dict[bytes, PathEdge]:
# note: we don't lock self.channel_db, so while the path finding runs, # note: we don't lock self.channel_db, so while the path finding runs,
@ -252,6 +487,7 @@ class LNPathFinder(Logger):
# run Dijkstra # run Dijkstra
# The search is run in the REVERSE direction, from nodeB to nodeA, # The search is run in the REVERSE direction, from nodeB to nodeA,
# to properly calculate compound routing fees. # to properly calculate compound routing fees.
blacklist = self.liquidity_hints.get_blacklist()
distance_from_start = defaultdict(lambda: float('inf')) distance_from_start = defaultdict(lambda: float('inf'))
distance_from_start[nodeB] = 0 distance_from_start[nodeB] = 0
prev_node = {} # type: Dict[bytes, PathEdge] prev_node = {} # type: Dict[bytes, PathEdge]
@ -316,7 +552,6 @@ class LNPathFinder(Logger):
nodeB: bytes, nodeB: bytes,
invoice_amount_msat: int, invoice_amount_msat: int,
my_channels: Dict[ShortChannelID, 'Channel'] = None, my_channels: Dict[ShortChannelID, 'Channel'] = None,
blacklist: Set[ShortChannelID] = None,
private_route_edges: Dict[ShortChannelID, RouteEdge] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
) -> Optional[LNPaymentPath]: ) -> Optional[LNPaymentPath]:
"""Return a path from nodeA to nodeB.""" """Return a path from nodeA to nodeB."""
@ -331,7 +566,6 @@ class LNPathFinder(Logger):
nodeB=nodeB, nodeB=nodeB,
invoice_amount_msat=invoice_amount_msat, invoice_amount_msat=invoice_amount_msat,
my_channels=my_channels, my_channels=my_channels,
blacklist=blacklist,
private_route_edges=private_route_edges) private_route_edges=private_route_edges)
if nodeA not in prev_node: if nodeA not in prev_node:
@ -394,7 +628,6 @@ class LNPathFinder(Logger):
invoice_amount_msat: int, invoice_amount_msat: int,
path = None, path = None,
my_channels: Dict[ShortChannelID, 'Channel'] = None, my_channels: Dict[ShortChannelID, 'Channel'] = None,
blacklist: Set[ShortChannelID] = None,
private_route_edges: Dict[ShortChannelID, RouteEdge] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None,
) -> Optional[LNPaymentRoute]: ) -> Optional[LNPaymentRoute]:
route = None route = None
@ -404,7 +637,6 @@ class LNPathFinder(Logger):
nodeB=nodeB, nodeB=nodeB,
invoice_amount_msat=invoice_amount_msat, invoice_amount_msat=invoice_amount_msat,
my_channels=my_channels, my_channels=my_channels,
blacklist=blacklist,
private_route_edges=private_route_edges) private_route_edges=private_route_edges)
if path: if path:
route = self.create_route_from_path( route = self.create_route_from_path(

13
electrum/lnutil.py

@ -1360,16 +1360,3 @@ class OnionFailureCodeMetaFlag(IntFlag):
UPDATE = 0x1000 UPDATE = 0x1000
class ChannelBlackList:
def __init__(self):
self.blacklist = dict() # short_chan_id -> timestamp
def add(self, short_channel_id: ShortChannelID):
now = int(time.time())
self.blacklist[short_channel_id] = now
def get_current_list(self) -> Set[ShortChannelID]:
BLACKLIST_DURATION = 3600
now = int(time.time())
return set(k for k, t in self.blacklist.items() if now - t < BLACKLIST_DURATION)

76
electrum/lnworker.py

@ -1210,6 +1210,11 @@ class LNWallet(LNWorker):
raise Exception(f"amount_inflight={amount_inflight} < 0") raise Exception(f"amount_inflight={amount_inflight} < 0")
log.append(htlc_log) log.append(htlc_log)
if htlc_log.success: if htlc_log.success:
# TODO: report every route to liquidity hints for mpp
# even in the case of success, we report channels of the
# route as being able to send the same amount in the future,
# as we assume to not know the capacity
self.network.path_finder.update_liquidity_hints(htlc_log.route, htlc_log.amount_msat)
return return
# htlc failed # htlc failed
if len(log) >= attempts: if len(log) >= attempts:
@ -1237,7 +1242,7 @@ class LNWallet(LNWorker):
raise PaymentFailure(failure_msg.code_name()) raise PaymentFailure(failure_msg.code_name())
else: else:
self.handle_error_code_from_failed_htlc( self.handle_error_code_from_failed_htlc(
route=route, sender_idx=sender_idx, failure_msg=failure_msg) route=route, sender_idx=sender_idx, failure_msg=failure_msg, amount=htlc_log.amount_msat)
async def pay_to_route( async def pay_to_route(
self, *, self, *,
@ -1283,7 +1288,8 @@ class LNWallet(LNWorker):
*, *,
route: LNPaymentRoute, route: LNPaymentRoute,
sender_idx: int, sender_idx: int,
failure_msg: OnionRoutingFailure) -> None: failure_msg: OnionRoutingFailure,
amount: int) -> None:
code, data = failure_msg.code, failure_msg.data code, data = failure_msg.code, failure_msg.data
# TODO can we use lnmsg.OnionWireSerializer here? # TODO can we use lnmsg.OnionWireSerializer here?
# TODO update onion_wire.csv # TODO update onion_wire.csv
@ -1296,40 +1302,53 @@ class LNWallet(LNWorker):
OnionFailureCode.EXPIRY_TOO_SOON: 0, OnionFailureCode.EXPIRY_TOO_SOON: 0,
OnionFailureCode.CHANNEL_DISABLED: 2, OnionFailureCode.CHANNEL_DISABLED: 2,
} }
blacklist = False
update = False # determine a fallback channel to blacklist if we don't get the erring
# channel via the payload
if sender_idx is None:
raise PaymentFailure(failure_msg.code_name())
try:
fallback_channel = route[sender_idx + 1].short_channel_id
node_from = route[sender_idx].start_node
node_to = route[sender_idx].end_node
except IndexError:
raise PaymentFailure(f'payment destination reported error: {failure_msg.code_name()}') from None
# TODO: handle unknown next peer?
# handle failure codes that include a channel update
if code in failure_codes: if code in failure_codes:
offset = failure_codes[code] offset = failure_codes[code]
channel_update_len = int.from_bytes(data[offset:offset+2], byteorder="big") channel_update_len = int.from_bytes(data[offset:offset+2], byteorder="big")
channel_update_as_received = data[offset+2: offset+2+channel_update_len] channel_update_as_received = data[offset+2: offset+2+channel_update_len]
payload = self._decode_channel_update_msg(channel_update_as_received) payload = self._decode_channel_update_msg(channel_update_as_received)
if payload is None: if payload is None:
self.logger.info(f'could not decode channel_update for failed htlc: {channel_update_as_received.hex()}') self.logger.info(f'could not decode channel_update for failed htlc: '
blacklist = True f'{channel_update_as_received.hex()}')
self.network.path_finder.channel_blacklist.add(fallback_channel)
else: else:
# apply the channel update or get blacklisted
blacklist, update = self._handle_chanupd_from_failed_htlc( blacklist, update = self._handle_chanupd_from_failed_htlc(
payload, route=route, sender_idx=sender_idx) payload, route=route, sender_idx=sender_idx)
else:
blacklist = True
if blacklist: # we interpret a temporary channel failure as a liquidity issue
# blacklist channel after reporter node # in the channel and update our liquidity hints accordingly
# TODO this should depend on the error (even more granularity) if code == OnionFailureCode.TEMPORARY_CHANNEL_FAILURE:
# also, we need finer blacklisting (directed edges; nodes) self.network.path_finder.update_liquidity_hints(
if sender_idx is None: route,
raise PaymentFailure(failure_msg.code_name()) amount,
try: failing_channel=ShortChannelID(payload['short_channel_id']))
short_chan_id = route[sender_idx + 1].short_channel_id elif blacklist:
except IndexError: self.network.path_finder.liquidity_hints.add_to_blacklist(
raise PaymentFailure(f'payment destination reported error: {failure_msg.code_name()}') from None payload['short_channel_id'])
# TODO: for MPP we need to save the amount for which
# we saw temporary channel failure # if we can't decide on some action, we are stuck
self.logger.info(f'blacklisting channel {short_chan_id}') if not (blacklist or update):
self.network.channel_blacklist.add(short_chan_id) raise PaymentFailure(failure_msg.code_name())
# we should not continue if we did not blacklist or update anything # for errors that do not include a channel update
if not (blacklist or update): else:
raise PaymentFailure(failure_msg.code_name()) self.network.path_finder.liquidity_hints.add_to_blacklist(fallback_channel)
def _handle_chanupd_from_failed_htlc(self, payload, *, route, sender_idx) -> Tuple[bool, bool]: def _handle_chanupd_from_failed_htlc(self, payload, *, route, sender_idx) -> Tuple[bool, bool]:
blacklist = False blacklist = False
@ -1599,7 +1618,6 @@ class LNWallet(LNWorker):
chan.short_channel_id: chan for chan in channels chan.short_channel_id: chan for chan in channels
if chan.short_channel_id is not None if chan.short_channel_id is not None
} }
blacklist = self.network.channel_blacklist.get_current_list()
# Collect all private edges from route hints. # Collect all private edges from route hints.
# Note: if some route hints are multiple edges long, and these paths cross each other, # Note: if some route hints are multiple edges long, and these paths cross each other,
# we allow our path finding to cross the paths; i.e. the route hints are not isolated. # we allow our path finding to cross the paths; i.e. the route hints are not isolated.
@ -1631,8 +1649,7 @@ class LNWallet(LNWorker):
fee_proportional_millionths=fee_proportional_millionths, fee_proportional_millionths=fee_proportional_millionths,
cltv_expiry_delta=cltv_expiry_delta, cltv_expiry_delta=cltv_expiry_delta,
node_features=node_info.features if node_info else 0) node_features=node_info.features if node_info else 0)
if route_edge.short_channel_id not in blacklist: private_route_edges[route_edge.short_channel_id] = route_edge
private_route_edges[route_edge.short_channel_id] = route_edge
start_node = end_node start_node = end_node
# now find a route, end to end: between us and the recipient # now find a route, end to end: between us and the recipient
try: try:
@ -1642,7 +1659,6 @@ class LNWallet(LNWorker):
invoice_amount_msat=amount_msat, invoice_amount_msat=amount_msat,
path=full_path, path=full_path,
my_channels=scid_to_my_channels, my_channels=scid_to_my_channels,
blacklist=blacklist,
private_route_edges=private_route_edges) private_route_edges=private_route_edges)
except NoChannelPolicy as e: except NoChannelPolicy as e:
raise NoPathFound() from e raise NoPathFound() from e

2
electrum/network.py

@ -62,7 +62,6 @@ from .version import PROTOCOL_VERSION
from .simple_config import SimpleConfig from .simple_config import SimpleConfig
from .i18n import _ from .i18n import _
from .logging import get_logger, Logger from .logging import get_logger, Logger
from .lnutil import ChannelBlackList
if TYPE_CHECKING: if TYPE_CHECKING:
from .channel_db import ChannelDB from .channel_db import ChannelDB
@ -350,7 +349,6 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
self._has_ever_managed_to_connect_to_server = False self._has_ever_managed_to_connect_to_server = False
# lightning network # lightning network
self.channel_blacklist = ChannelBlackList()
if self.config.get('run_watchtower', False): if self.config.get('run_watchtower', False):
from . import lnwatcher from . import lnwatcher
self.local_watchtower = lnwatcher.WatchTower(self) self.local_watchtower = lnwatcher.WatchTower(self)

37
electrum/tests/test_lnpeer.py

@ -33,7 +33,7 @@ from electrum import lnmsg
from electrum.logging import console_stderr_handler, Logger from electrum.logging import console_stderr_handler, Logger
from electrum.lnworker import PaymentInfo, RECEIVED from electrum.lnworker import PaymentInfo, RECEIVED
from electrum.lnonion import OnionFailureCode from electrum.lnonion import OnionFailureCode
from electrum.lnutil import ChannelBlackList, derive_payment_secret_from_payment_preimage from electrum.lnutil import derive_payment_secret_from_payment_preimage
from electrum.lnutil import LOCAL, REMOTE from electrum.lnutil import LOCAL, REMOTE
from electrum.invoices import PR_PAID, PR_UNPAID from electrum.invoices import PR_PAID, PR_UNPAID
@ -66,7 +66,6 @@ class MockNetwork:
self.path_finder = LNPathFinder(self.channel_db) self.path_finder = LNPathFinder(self.channel_db)
self.tx_queue = tx_queue self.tx_queue = tx_queue
self._blockchain = MockBlockchain() self._blockchain = MockBlockchain()
self.channel_blacklist = ChannelBlackList()
@property @property
def callback_lock(self): def callback_lock(self):
@ -807,7 +806,7 @@ class TestPeer(TestCaseForTestnet):
run(f()) run(f())
@needs_test_with_all_chacha20_implementations @needs_test_with_all_chacha20_implementations
def test_payment_with_temp_channel_failure(self): def test_payment_with_temp_channel_failure_and_liquidty_hints(self):
# prepare channels such that a temporary channel failure happens at c->d # prepare channels such that a temporary channel failure happens at c->d
funds_distribution = { funds_distribution = {
'ac': (200_000_000, 200_000_000), # low fees 'ac': (200_000_000, 200_000_000), # low fees
@ -815,12 +814,11 @@ class TestPeer(TestCaseForTestnet):
'ab': (200_000_000, 200_000_000), # high fees 'ab': (200_000_000, 200_000_000), # high fees
'bd': (200_000_000, 200_000_000), # high fees 'bd': (200_000_000, 200_000_000), # high fees
} }
# the payment happens in three attempts: # the payment happens in two attempts:
# 1. along ac->cd due to low fees with temp channel failure: # 1. along a->c->d due to low fees with temp channel failure:
# with chanupd: ORPHANED, private channel update # with chanupd: ORPHANED, private channel update
# 2. along ac->cd with temp channel failure: # c->d gets a liquidity hint and gets blocked
# with chanupd: ORPHANED, private channel update, but already received, channel gets blacklisted # 2. along a->b->d with success
# 3. along ab->bd with success
amount_to_pay = 100_000_000 amount_to_pay = 100_000_000
graph = self.prepare_chans_and_peers_in_square(funds_distribution) graph = self.prepare_chans_and_peers_in_square(funds_distribution)
peers = graph.all_peers() peers = graph.all_peers()
@ -828,9 +826,30 @@ class TestPeer(TestCaseForTestnet):
self.assertEqual(PR_UNPAID, graph.w_d.get_payment_status(lnaddr.paymenthash)) self.assertEqual(PR_UNPAID, graph.w_d.get_payment_status(lnaddr.paymenthash))
result, log = await graph.w_a.pay_invoice(pay_req, attempts=3) result, log = await graph.w_a.pay_invoice(pay_req, attempts=3)
self.assertTrue(result) self.assertTrue(result)
self.assertEqual(2, len(log))
self.assertEqual(PR_PAID, graph.w_d.get_payment_status(lnaddr.paymenthash)) self.assertEqual(PR_PAID, graph.w_d.get_payment_status(lnaddr.paymenthash))
self.assertEqual(OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, log[0].failure_msg.code) self.assertEqual(OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, log[0].failure_msg.code)
self.assertEqual(OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, log[1].failure_msg.code)
liquidity_hints = graph.w_a.network.path_finder.liquidity_hints
pubkey_a = graph.w_a.node_keypair.pubkey
pubkey_b = graph.w_b.node_keypair.pubkey
pubkey_c = graph.w_c.node_keypair.pubkey
pubkey_d = graph.w_d.node_keypair.pubkey
# check liquidity hints for failing route:
hint_ac = liquidity_hints.get_hint(graph.chan_ac.short_channel_id)
hint_cd = liquidity_hints.get_hint(graph.chan_cd.short_channel_id)
self.assertEqual(amount_to_pay, hint_ac.can_send(pubkey_a < pubkey_c))
self.assertEqual(None, hint_ac.cannot_send(pubkey_a < pubkey_c))
self.assertEqual(None, hint_cd.can_send(pubkey_c < pubkey_d))
self.assertEqual(amount_to_pay, hint_cd.cannot_send(pubkey_c < pubkey_d))
# check liquidity hints for successful route:
hint_ab = liquidity_hints.get_hint(graph.chan_ab.short_channel_id)
hint_bd = liquidity_hints.get_hint(graph.chan_bd.short_channel_id)
self.assertEqual(amount_to_pay, hint_ab.can_send(pubkey_a < pubkey_b))
self.assertEqual(None, hint_ab.cannot_send(pubkey_a < pubkey_b))
self.assertEqual(amount_to_pay, hint_bd.can_send(pubkey_b < pubkey_d))
self.assertEqual(None, hint_bd.cannot_send(pubkey_b < pubkey_d))
raise PaymentDone() raise PaymentDone()
async def f(): async def f():
async with TaskGroup() as group: async with TaskGroup() as group:

277
electrum/tests/test_lnrouter.py

@ -1,21 +1,31 @@
from math import inf
import unittest import unittest
import tempfile import tempfile
import shutil import shutil
import asyncio import asyncio
from electrum.util import bh2u, bfh, create_and_start_event_loop from electrum.util import bh2u, bfh, create_and_start_event_loop
from electrum.lnutil import ShortChannelID
from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet, from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet,
process_onion_packet, _decode_onion_error, decode_onion_error, process_onion_packet, _decode_onion_error, decode_onion_error,
OnionFailureCode, OnionPacket) OnionFailureCode, OnionPacket)
from electrum import bitcoin, lnrouter from electrum import bitcoin, lnrouter
from electrum.constants import BitcoinTestnet from electrum.constants import BitcoinTestnet
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
from electrum.lnrouter import PathEdge from electrum.lnrouter import PathEdge, LiquidityHintMgr, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH, DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat
from . import TestCaseForTestnet from . import TestCaseForTestnet
from .test_bitcoin import needs_test_with_all_chacha20_implementations from .test_bitcoin import needs_test_with_all_chacha20_implementations
def channel(number: int) -> ShortChannelID:
return ShortChannelID(bfh(format(number, '016x')))
def node(character: str) -> bytes:
return b'\x02' + f'{character}'.encode() * 32
class Test_LNRouter(TestCaseForTestnet): class Test_LNRouter(TestCaseForTestnet):
def setUp(self): def setUp(self):
@ -28,7 +38,24 @@ class Test_LNRouter(TestCaseForTestnet):
self._loop_thread.join(timeout=1) self._loop_thread.join(timeout=1)
super().tearDown() super().tearDown()
def test_find_path_for_payment(self): def prepare_graph(self):
"""
Network topology with channel ids:
3
A --- B
| 2/ |
6 | E | 1
| /5 \7 |
D --- C
4
valid routes from A -> E:
A -3-> B -2-> E
A -6-> D -5-> E
A -6-> D -4-> C -7-> E
A -3-> B -1-> C -7-> E
A -6-> D -4-> C -1-> B -2-> E
A -3-> B -1-> C -4-> D -5-> E
"""
class fake_network: class fake_network:
config = self.config config = self.config
asyncio_loop = asyncio.get_event_loop() asyncio_loop = asyncio.get_event_loop()
@ -37,67 +64,193 @@ class Test_LNRouter(TestCaseForTestnet):
interface = None interface = None
fake_network.channel_db = lnrouter.ChannelDB(fake_network()) fake_network.channel_db = lnrouter.ChannelDB(fake_network())
fake_network.channel_db.data_loaded.set() fake_network.channel_db.data_loaded.set()
cdb = fake_network.channel_db self.cdb = fake_network.channel_db
path_finder = lnrouter.LNPathFinder(cdb) self.path_finder = lnrouter.LNPathFinder(self.cdb)
self.assertEqual(cdb.num_channels, 0) self.assertEqual(self.cdb.num_channels, 0)
cdb.add_channel_announcements({'node_id_1': b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'node_id_2': b'\x02cccccccccccccccccccccccccccccccc', self.cdb.add_channel_announcements({
'bitcoin_key_1': b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'bitcoin_key_2': b'\x02cccccccccccccccccccccccccccccccc', 'node_id_1': node('b'), 'node_id_2': node('c'),
'short_channel_id': bfh('0000000000000001'), 'bitcoin_key_1': node('b'), 'bitcoin_key_2': node('c'),
'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'short_channel_id': channel(1),
'len': 0, 'features': b''}, trusted=True) 'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
self.assertEqual(cdb.num_channels, 1) 'len': 0, 'features': b''
cdb.add_channel_announcements({'node_id_1': b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'node_id_2': b'\x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', }, trusted=True)
'bitcoin_key_1': b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'bitcoin_key_2': b'\x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', self.assertEqual(self.cdb.num_channels, 1)
'short_channel_id': bfh('0000000000000002'), self.cdb.add_channel_announcements({
'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'node_id_1': node('b'), 'node_id_2': node('e'),
'len': 0, 'features': b''}, trusted=True) 'bitcoin_key_1': node('b'), 'bitcoin_key_2': node('e'),
cdb.add_channel_announcements({'node_id_1': b'\x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'node_id_2': b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'short_channel_id': channel(2),
'bitcoin_key_1': b'\x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bitcoin_key_2': b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
'short_channel_id': bfh('0000000000000003'), 'len': 0, 'features': b''
'chain_hash': BitcoinTestnet.rev_genesis_bytes(), }, trusted=True)
'len': 0, 'features': b''}, trusted=True) self.cdb.add_channel_announcements({
cdb.add_channel_announcements({'node_id_1': b'\x02cccccccccccccccccccccccccccccccc', 'node_id_2': b'\x02dddddddddddddddddddddddddddddddd', 'node_id_1': node('a'), 'node_id_2': node('b'),
'bitcoin_key_1': b'\x02cccccccccccccccccccccccccccccccc', 'bitcoin_key_2': b'\x02dddddddddddddddddddddddddddddddd', 'bitcoin_key_1': node('a'), 'bitcoin_key_2': node('b'),
'short_channel_id': bfh('0000000000000004'), 'short_channel_id': channel(3),
'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
'len': 0, 'features': b''}, trusted=True) 'len': 0, 'features': b''
cdb.add_channel_announcements({'node_id_1': b'\x02dddddddddddddddddddddddddddddddd', 'node_id_2': b'\x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', }, trusted=True)
'bitcoin_key_1': b'\x02dddddddddddddddddddddddddddddddd', 'bitcoin_key_2': b'\x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', self.cdb.add_channel_announcements({
'short_channel_id': bfh('0000000000000005'), 'node_id_1': node('c'), 'node_id_2': node('d'),
'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'bitcoin_key_1': node('c'), 'bitcoin_key_2': node('d'),
'len': 0, 'features': b''}, trusted=True) 'short_channel_id': channel(4),
cdb.add_channel_announcements({'node_id_1': b'\x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'node_id_2': b'\x02dddddddddddddddddddddddddddddddd', 'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
'bitcoin_key_1': b'\x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bitcoin_key_2': b'\x02dddddddddddddddddddddddddddddddd', 'len': 0, 'features': b''
'short_channel_id': bfh('0000000000000006'), }, trusted=True)
'chain_hash': BitcoinTestnet.rev_genesis_bytes(), self.cdb.add_channel_announcements({
'len': 0, 'features': b''}, trusted=True) 'node_id_1': node('d'), 'node_id_2': node('e'),
'bitcoin_key_1': node('d'), 'bitcoin_key_2': node('e'),
'short_channel_id': channel(5),
'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
'len': 0, 'features': b''
}, trusted=True)
self.cdb.add_channel_announcements({
'node_id_1': node('a'), 'node_id_2': node('d'),
'bitcoin_key_1': node('a'), 'bitcoin_key_2': node('d'),
'short_channel_id': channel(6),
'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
'len': 0, 'features': b''
}, trusted=True)
self.cdb.add_channel_announcements({
'node_id_1': node('c'), 'node_id_2': node('e'),
'bitcoin_key_1': node('c'), 'bitcoin_key_2': node('e'),
'short_channel_id': channel(7),
'chain_hash': BitcoinTestnet.rev_genesis_bytes(),
'len': 0, 'features': b''
}, trusted=True)
def add_chan_upd(payload): def add_chan_upd(payload):
cdb.add_channel_update(payload, verify=False) self.cdb.add_channel_update(payload, verify=False)
add_chan_upd({'short_channel_id': bfh('0000000000000001'), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(1), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000001'), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(1), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000002'), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 99, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(2), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 99, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000002'), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(2), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000003'), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(3), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000003'), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(3), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000004'), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(4), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000004'), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(4), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000005'), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(5), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000005'), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 999, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(5), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 999, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000006'), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 99999999, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(6), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 200, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
add_chan_upd({'short_channel_id': bfh('0000000000000006'), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0}) add_chan_upd({'short_channel_id': channel(6), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
path = path_finder.find_path_for_payment( add_chan_upd({'short_channel_id': channel(7), 'message_flags': b'\x00', 'channel_flags': b'\x00', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
nodeA=b'\x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', add_chan_upd({'short_channel_id': channel(7), 'message_flags': b'\x00', 'channel_flags': b'\x01', 'cltv_expiry_delta': 10, 'htlc_minimum_msat': 250, 'fee_base_msat': 100, 'fee_proportional_millionths': 150, 'chain_hash': BitcoinTestnet.rev_genesis_bytes(), 'timestamp': 0})
nodeB=b'\x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
invoice_amount_msat=100000) def test_find_path_for_payment(self):
self.assertEqual([PathEdge(start_node=b'\x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', end_node=b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', short_channel_id=bfh('0000000000000003')), self.prepare_graph()
PathEdge(start_node=b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', end_node=b'\x02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', short_channel_id=bfh('0000000000000002')), amount_to_send = 100000
], path)
route = path_finder.create_route_from_path(path) path = self.path_finder.find_path_for_payment(
self.assertEqual(b'\x02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', route[0].node_id) nodeA=node('a'),
self.assertEqual(bfh('0000000000000003'), route[0].short_channel_id) nodeB=node('e'),
invoice_amount_msat=amount_to_send)
cdb.stop() self.assertEqual([
asyncio.run_coroutine_threadsafe(cdb.stopped_event.wait(), self.asyncio_loop).result() PathEdge(start_node=node('a'), end_node=node('b'), short_channel_id=channel(3)),
PathEdge(start_node=node('b'), end_node=node('e'), short_channel_id=channel(2)),
], path)
route = self.path_finder.create_route_from_path(path)
self.assertEqual(node('b'), route[0].node_id)
self.assertEqual(channel(3), route[0].short_channel_id)
self.cdb.stop()
asyncio.run_coroutine_threadsafe(self.cdb.stopped_event.wait(), self.asyncio_loop).result()
def test_find_path_liquidity_hints_failure(self):
self.prepare_graph()
amount_to_send = 100000
"""
assume failure over channel 2, B -> E
A -3-> B |-2-> E
A -6-> D -5-> E <= chosen path
A -6-> D -4-> C -7-> E
A -3-> B -1-> C -7-> E
A -6-> D -4-> C -1-> B -2-> E
A -3-> B -1-> C -4-> D -5-> E
"""
self.path_finder.liquidity_hints.update_cannot_send(node('b'), node('e'), channel(2), amount_to_send - 1)
path = self.path_finder.find_path_for_payment(
nodeA=node('a'),
nodeB=node('e'),
invoice_amount_msat=amount_to_send)
self.assertEqual(channel(6), path[0].short_channel_id)
self.assertEqual(channel(5), path[1].short_channel_id)
"""
assume failure over channel 5, D -> E
A -3-> B |-2-> E
A -6-> D |-5-> E
A -6-> D -4-> C -7-> E
A -3-> B -1-> C -7-> E <= chosen path
A -6-> D -4-> C -1-> B |-2-> E
A -3-> B -1-> C -4-> D |-5-> E
"""
self.path_finder.liquidity_hints.update_cannot_send(node('d'), node('e'), channel(5), amount_to_send - 1)
path = self.path_finder.find_path_for_payment(
nodeA=node('a'),
nodeB=node('e'),
invoice_amount_msat=amount_to_send)
self.assertEqual(channel(3), path[0].short_channel_id)
self.assertEqual(channel(1), path[1].short_channel_id)
self.assertEqual(channel(7), path[2].short_channel_id)
"""
assume success over channel 4, D -> C
A -3-> B |-2-> E
A -6-> D |-5-> E
A -6-> D -4-> C -7-> E <= chosen path
A -3-> B -1-> C -7-> E
A -6-> D -4-> C -1-> B |-2-> E
A -3-> B -1-> C -4-> D |-5-> E
"""
self.path_finder.liquidity_hints.update_can_send(node('d'), node('c'), channel(4), amount_to_send + 1000)
path = self.path_finder.find_path_for_payment(
nodeA=node('a'),
nodeB=node('e'),
invoice_amount_msat=amount_to_send)
self.assertEqual(channel(6), path[0].short_channel_id)
self.assertEqual(channel(4), path[1].short_channel_id)
self.assertEqual(channel(7), path[2].short_channel_id)
self.cdb.stop()
asyncio.run_coroutine_threadsafe(self.cdb.stopped_event.wait(), self.asyncio_loop).result()
def test_liquidity_hints(self):
liquidity_hints = LiquidityHintMgr()
node_from = bytes(0)
node_to = bytes(1)
channel_id = ShortChannelID.from_components(0, 0, 0)
amount_to_send = 1_000_000
# check default penalty
self.assertEqual(
fee_for_edge_msat(amount_to_send, DEFAULT_PENALTY_BASE_MSAT, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH),
liquidity_hints.penalty(node_from, node_to, channel_id, amount_to_send)
)
liquidity_hints.update_can_send(node_from, node_to, channel_id, 1_000_000)
liquidity_hints.update_cannot_send(node_from, node_to, channel_id, 2_000_000)
hint = liquidity_hints.get_hint(channel_id)
self.assertEqual(1_000_000, hint.can_send(node_from < node_to))
self.assertEqual(None, hint.cannot_send(node_to < node_from))
self.assertEqual(2_000_000, hint.cannot_send(node_from < node_to))
# the can_send backward hint is set automatically
self.assertEqual(2_000_000, hint.can_send(node_to < node_from))
# check penalties
self.assertEqual(0., liquidity_hints.penalty(node_from, node_to, channel_id, 1_000_000))
self.assertEqual(650, liquidity_hints.penalty(node_from, node_to, channel_id, 1_500_000))
self.assertEqual(inf, liquidity_hints.penalty(node_from, node_to, channel_id, 2_000_000))
# test that we don't overwrite significant info with less significant info
liquidity_hints.update_can_send(node_from, node_to, channel_id, 500_000)
hint = liquidity_hints.get_hint(channel_id)
self.assertEqual(1_000_000, hint.can_send(node_from < node_to))
# test case when can_send > cannot_send
liquidity_hints.update_can_send(node_from, node_to, channel_id, 3_000_000)
hint = liquidity_hints.get_hint(channel_id)
self.assertEqual(3_000_000, hint.can_send(node_from < node_to))
self.assertEqual(None, hint.cannot_send(node_from < node_to))
@needs_test_with_all_chacha20_implementations @needs_test_with_all_chacha20_implementations
def test_new_onion_packet_legacy(self): def test_new_onion_packet_legacy(self):

12
electrum/util.py

@ -776,7 +776,7 @@ mainnet_block_explorers = {
'mempool.space': ('https://mempool.space/', 'mempool.space': ('https://mempool.space/',
{'tx': 'tx/', 'addr': 'address/'}), {'tx': 'tx/', 'addr': 'address/'}),
'mempool.emzy.de': ('https://mempool.emzy.de/', 'mempool.emzy.de': ('https://mempool.emzy.de/',
{'tx': 'tx/', 'addr': 'address/'}), {'tx': 'tx/', 'addr': 'address/'}),
'OXT.me': ('https://oxt.me/', 'OXT.me': ('https://oxt.me/',
{'tx': 'transaction/', 'addr': 'address/'}), {'tx': 'transaction/', 'addr': 'address/'}),
'smartbit.com.au': ('https://www.smartbit.com.au/', 'smartbit.com.au': ('https://www.smartbit.com.au/',
@ -797,7 +797,7 @@ testnet_block_explorers = {
'Blockstream.info': ('https://blockstream.info/testnet/', 'Blockstream.info': ('https://blockstream.info/testnet/',
{'tx': 'tx/', 'addr': 'address/'}), {'tx': 'tx/', 'addr': 'address/'}),
'mempool.space': ('https://mempool.space/testnet/', 'mempool.space': ('https://mempool.space/testnet/',
{'tx': 'tx/', 'addr': 'address/'}), {'tx': 'tx/', 'addr': 'address/'}),
'smartbit.com.au': ('https://testnet.smartbit.com.au/', 'smartbit.com.au': ('https://testnet.smartbit.com.au/',
{'tx': 'tx/', 'addr': 'address/'}), {'tx': 'tx/', 'addr': 'address/'}),
'system default': ('blockchain://000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943/', 'system default': ('blockchain://000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943/',
@ -1116,6 +1116,14 @@ def ignore_exceptions(func):
return wrapper return wrapper
def with_lock(func):
"""Decorator to enforce a lock on a function call."""
def func_wrapper(self, *args, **kwargs):
with self.lock:
return func(self, *args, **kwargs)
return func_wrapper
class TxMinedInfo(NamedTuple): class TxMinedInfo(NamedTuple):
height: int # height of block that mined tx height: int # height of block that mined tx
conf: Optional[int] = None # number of confirmations, SPV verified (None means unknown) conf: Optional[int] = None # number of confirmations, SPV verified (None means unknown)

Loading…
Cancel
Save