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
MPP_RECEIVE_CUTOFF = 0.2
NUM_PEERS_TARGET = 4
# onchain channel backup data
@ -1998,15 +1996,7 @@ class LNWallet(LNWorker):
def calc_routing_hints_for_invoice(self, amount_msat: Optional[int]):
"""calculate routing hints (BOLT-11 'r' field)"""
routing_hints = []
with self.lock:
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]
channels = list(self.get_channels_to_include_in_invoice(amount_msat))
random.shuffle(channels) # let's not leak channel order
scid_to_my_channels = {chan.short_channel_id: chan for chan in channels
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)
return Decimal(can_send_minus_fees) / 1000
def border_nodes_that_can_receive(self, amount_msat=None):
# if amount_msat is None, use the max amount we can receive
#
# 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)
def get_channels_to_include_in_invoice(self, amount_msat=None) -> Sequence[Channel]:
if not amount_msat: # assume we want to recv a large amt, e.g. finding max.
amount_msat = float('inf')
with self.lock:
for c in self.channels.values():
if not c.is_active() or c.is_frozen_for_receiving():
continue
nodes_that_can_receive[c.node_id] += c.available_to_spend(REMOTE)
while True:
max_can_receive = sum(nodes_that_can_receive.values())
receive_amount = amount_msat or max_can_receive
items = sorted(list(nodes_that_can_receive.items()), key=operator.itemgetter(1))
for node_id, v in items:
if v < receive_amount * MPP_RECEIVE_CUTOFF:
nodes_that_can_receive.pop(node_id)
# break immediately because max_can_receive needs to be recomputed
channels = list(self.channels.values())
# we exclude channels that cannot *right now* receive (e.g. peer offline)
channels = [chan for chan in channels
if (chan.is_active() and not chan.is_frozen_for_receiving())]
# Filter out nodes that have 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.
channels = sorted(channels, key=lambda chan: -chan.available_to_spend(REMOTE))
selected_channels = []
running_sum = 0
cutoff_factor = 0.2 # heuristic
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
else:
break
return nodes_that_can_receive
running_sum += recv_capacity
selected_channels.append(chan)
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:
can_receive_nodes = self.border_nodes_that_can_receive(None)
can_receive_msat = sum(can_receive_nodes.values())
"""Return a conservative estimate of max sat value we can realistically receive
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
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
channels_for_peer = LNWallet.channels_for_peer
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
is_trampoline_peer = LNWallet.is_trampoline_peer
wait_for_received_pending_htlcs_to_get_removed = LNWallet.wait_for_received_pending_htlcs_to_get_removed

Loading…
Cancel
Save