diff --git a/electrum/lnworker.py b/electrum/lnworker.py index eecfe5a0f..bea6d20b4 100644 --- a/electrum/lnworker.py +++ b/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: diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index 4340f3131..b6bef11d7 100644 --- a/electrum/tests/test_lnpeer.py +++ b/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