Browse Source

lnworker: rework num_sats_can_receive and routing_hints_for_invoice

follow-up https://github.com/spesmilo/electrum/pull/7818

- note it matters whether a sender pays us end-to-end-trampoline or just via legacy
  - consider: Alice has 0.1 BTC recv cap in chan1 and 1 BTC recv cap in chan2, both with border-node T1
    - if sender is paying e2e trampoline, it can realistically pay even ~1.1 BTC, as T1 can resplit the HTLCs
    - if sender is paying legacy, it will have a hard time trying to pay more than 1 BTC, in practice
      - although note if T1 has implemented non-strict-forwarding (see BOLT-04), achieving 1 BTC is easy,
        as T1 can redirect HTLCs (but cannot split them, in this case)
  - to make num_sats_can_receive realistic, it assumes the legacy case
- To calc num_sats_can_receive, we sort our channels in decreasing order of receive-capacities, iterate over them
  and calculate a running sum - we stop adding channels when the next chan's recv cap is small compared to
  the running total.
- When putting routing hints in an invoice, we do the same, with the added condition that we keep adding channels
  if their recv cap is larger than the invoice amount.
  - consider: Alice has 0.1 BTC recv cap in chan1 with Bob, and 1 BTC recv cap in chan2 with Carol
    - if Alice wants to recv 100 sats, it is useful to add hints for both channels into the invoice, for redundancy
    - if Alice wants to recv 0.9 BTC, it is questionable whether adding the smaller chan is useful - the code here won't add it
patch-4
SomberNight 3 years ago
parent
commit
dd5cb2a5c1
No known key found for this signature in database GPG Key ID: B33B5F232C6271E9
  1. 80
      electrum/lnworker.py
  2. 2
      electrum/tests/test_lnpeer.py

80
electrum/lnworker.py

@ -92,8 +92,6 @@ if TYPE_CHECKING:
SAVED_PR_STATUS = [PR_PAID, PR_UNPAID, PR_SCHEDULED] # status that are persisted SAVED_PR_STATUS = [PR_PAID, PR_UNPAID, PR_SCHEDULED] # status that are persisted
MPP_RECEIVE_CUTOFF = 0.2
NUM_PEERS_TARGET = 4 NUM_PEERS_TARGET = 4
# onchain channel backup data # onchain channel backup data
@ -1998,15 +1996,7 @@ class LNWallet(LNWorker):
def calc_routing_hints_for_invoice(self, amount_msat: Optional[int]): def calc_routing_hints_for_invoice(self, amount_msat: Optional[int]):
"""calculate routing hints (BOLT-11 'r' field)""" """calculate routing hints (BOLT-11 'r' field)"""
routing_hints = [] routing_hints = []
with self.lock: channels = list(self.get_channels_to_include_in_invoice(amount_msat))
nodes = self.border_nodes_that_can_receive(amount_msat)
channels = []
for c in self.channels.values():
if c.node_id in nodes:
channels.append(c)
# cap max channels to include to keep QR code reasonably scannable
channels = sorted(channels, key=lambda chan: (not chan.is_active(), -chan.available_to_spend(REMOTE)))
channels = channels[:15]
random.shuffle(channels) # let's not leak channel order random.shuffle(channels) # let's not leak channel order
scid_to_my_channels = {chan.short_channel_id: chan for chan in channels scid_to_my_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}
@ -2082,37 +2072,51 @@ class LNWallet(LNWorker):
can_send_minus_fees = max(0, can_send_minus_fees) can_send_minus_fees = max(0, can_send_minus_fees)
return Decimal(can_send_minus_fees) / 1000 return Decimal(can_send_minus_fees) / 1000
def border_nodes_that_can_receive(self, amount_msat=None): def get_channels_to_include_in_invoice(self, amount_msat=None) -> Sequence[Channel]:
# if amount_msat is None, use the max amount we can receive if not amount_msat: # assume we want to recv a large amt, e.g. finding max.
# amount_msat = float('inf')
# Filter out nodes that have very low receive capacity compared to invoice amt.
# Even with MPP, below a certain threshold, including these channels probably
# hurts more than help, as they lead to many failed attempts for the sender.
#
# We condider nodes instead of channels because both non-strict forwardring
# and trampoline end-to-end payments allow it
nodes_that_can_receive = defaultdict(int)
with self.lock: with self.lock:
for c in self.channels.values(): channels = list(self.channels.values())
if not c.is_active() or c.is_frozen_for_receiving(): # we exclude channels that cannot *right now* receive (e.g. peer offline)
continue channels = [chan for chan in channels
nodes_that_can_receive[c.node_id] += c.available_to_spend(REMOTE) if (chan.is_active() and not chan.is_frozen_for_receiving())]
while True: # Filter out nodes that have low receive capacity compared to invoice amt.
max_can_receive = sum(nodes_that_can_receive.values()) # Even with MPP, below a certain threshold, including these channels probably
receive_amount = amount_msat or max_can_receive # hurts more than help, as they lead to many failed attempts for the sender.
items = sorted(list(nodes_that_can_receive.items()), key=operator.itemgetter(1)) channels = sorted(channels, key=lambda chan: -chan.available_to_spend(REMOTE))
for node_id, v in items: selected_channels = []
if v < receive_amount * MPP_RECEIVE_CUTOFF: running_sum = 0
nodes_that_can_receive.pop(node_id) cutoff_factor = 0.2 # heuristic
# break immediately because max_can_receive needs to be recomputed for chan in channels:
recv_capacity = chan.available_to_spend(REMOTE)
chan_can_handle_payment_as_single_part = recv_capacity >= amount_msat
chan_small_compared_to_running_sum = recv_capacity < cutoff_factor * running_sum
if not chan_can_handle_payment_as_single_part and chan_small_compared_to_running_sum:
break break
else: running_sum += recv_capacity
break selected_channels.append(chan)
return nodes_that_can_receive channels = selected_channels
del selected_channels
# cap max channels to include to keep QR code reasonably scannable
channels = channels[:10]
return channels
def num_sats_can_receive(self) -> Decimal: def num_sats_can_receive(self) -> Decimal:
can_receive_nodes = self.border_nodes_that_can_receive(None) """Return a conservative estimate of max sat value we can realistically receive
can_receive_msat = sum(can_receive_nodes.values()) in a single payment. (MPP is allowed)
The theoretical max would be `sum(chan.available_to_spend(REMOTE) for chan in self.channels)`,
but that would require a sender using MPP to magically guess all our channel liquidities.
"""
with self.lock:
recv_channels = self.get_channels_to_include_in_invoice()
recv_chan_msats = [chan.available_to_spend(REMOTE) for chan in recv_channels]
if not recv_chan_msats:
return Decimal(0)
can_receive_msat = max(
max(recv_chan_msats), # single-part payment baseline
sum(recv_chan_msats) // 2, # heuristic for MPP
)
return Decimal(can_receive_msat) / 1000 return Decimal(can_receive_msat) / 1000
def num_sats_can_receive_no_mpp(self) -> Decimal: def num_sats_can_receive_no_mpp(self) -> Decimal:

2
electrum/tests/test_lnpeer.py

@ -243,7 +243,7 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]):
get_channel_by_id = LNWallet.get_channel_by_id get_channel_by_id = LNWallet.get_channel_by_id
channels_for_peer = LNWallet.channels_for_peer channels_for_peer = LNWallet.channels_for_peer
calc_routing_hints_for_invoice = LNWallet.calc_routing_hints_for_invoice calc_routing_hints_for_invoice = LNWallet.calc_routing_hints_for_invoice
border_nodes_that_can_receive = LNWallet.border_nodes_that_can_receive get_channels_to_include_in_invoice = LNWallet.get_channels_to_include_in_invoice
handle_error_code_from_failed_htlc = LNWallet.handle_error_code_from_failed_htlc handle_error_code_from_failed_htlc = LNWallet.handle_error_code_from_failed_htlc
is_trampoline_peer = LNWallet.is_trampoline_peer is_trampoline_peer = LNWallet.is_trampoline_peer
wait_for_received_pending_htlcs_to_get_removed = LNWallet.wait_for_received_pending_htlcs_to_get_removed wait_for_received_pending_htlcs_to_get_removed = LNWallet.wait_for_received_pending_htlcs_to_get_removed

Loading…
Cancel
Save