suggest_splits: don't exclude single part configs for too small multi
part configs I noticed certain ln payments become very unreliable. These payments are ~21k sat, from gossip to gossip sender, with direct, unannounced channel. Due to the recent fix https://github.com/spesmilo/electrum/pull/9723 `LNPathFinder.get_shortest_path_hops()` will not use channels for the last hop of a route anymore that aren't also passed to it in `my_sending_channels`: ```python if edge_startnode == nodeA and my_sending_channels: # payment outgoing, on our channel if edge_channel_id not in my_sending_channels: continue ``` Conceptually this makes sense as we only want to send through `my_sending_channels`, however if the only channel between us and the receiver is a direct channel that we got from the r_tag and it's not passed in `my_sending_channel` it's not able to construct a route now. Previously this type of payment worked as `get_shortest_path_hops()` knew of the direct channel between us and `nodeB` from the invoices r_tag and then just used this channel as the route, even though it was (often) not contained in `my_sending_channels`. `my_sending_channels`, passed in `LNWallet.create_routes_for_payment()` is either a single channel or all our channels, depending on `is_multichan_mpp`: ```python for sc in split_configurations: is_multichan_mpp = len(sc.config.items()) > 1 ``` This causes the unreliable, random behavior as `LNWallet.suggest_splits()` is supposed to `exclude_single_part_payments` if the amount is > `MPP_SPLIT_PART_MINAMT_SAT` (5000 sat). As `mpp_split.py suggest_splits()` is selecting channels randomly, and then removes single part configs, it sometimes doesn't return a single configuration, as it removes single part splits, and also removes multi part splits if a part is below 10 000 sat: ```python if target_parts > 1 and config.is_any_amount_smaller_than_min_part_size(): continue ``` This will result in a fallback to allow single part payments: ```python split_configurations = get_splits() if not split_configurations and exclude_single_part_payments: exclude_single_part_payments = False split_configurations = get_splits() ``` Then the payment works as all our channels are passed as `my_sending_channels` to `LNWallet.create_routes_for_payment()`. However sometimes this fallback doesn't happen, because a few mpp configurations found in the first iteration of `suggest_splits` have been kept, e.g. [10500, 10500], but at the same time most others have been removed as they crossed the limit, e.g. [11001, 9999], (which happens sometimes with payments ~20k sat), this makes `suggest_splits` return very few usable channels/configurations (sometimes just one or two, even with way more available channels). This makes payments in this range unreliable as we do not retry to generate new split configurations if the following path finding fails with `NoPathFound()`, and there is no single part configuration that allows the path finding to access all channels. Also this does not only affect direct channel payments, but all gossip payments in this amount range. There seem to be multiple ways to fix this, i think one simple approach is to just disable `exclude_single_part_payments` if the splitting loop already begins to sort out configs on the second iteration (the first split), as this indicates that the amount may be too small to split within the given limits, and prevents the issue of having only few valid splits returned and not going into the fallback. However this also results in increased usage of single part payments.
This commit is contained in:
@@ -9,7 +9,7 @@ from collections import defaultdict
|
||||
import logging
|
||||
import concurrent
|
||||
from concurrent import futures
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from typing import Iterable, NamedTuple, Tuple, List, Dict
|
||||
|
||||
from aiorpcx import timeout_after, TaskTimeout
|
||||
@@ -45,6 +45,7 @@ from electrum.invoices import PR_PAID, PR_UNPAID, Invoice
|
||||
from electrum.interface import GracefulDisconnect
|
||||
from electrum.simple_config import SimpleConfig
|
||||
from electrum.fee_policy import FeeTimeEstimates, FEE_ETA_TARGETS
|
||||
from electrum.mpp_split import split_amount_normal
|
||||
|
||||
from .test_lnchannel import create_test_channels
|
||||
from .test_bitcoin import needs_test_with_all_chacha20_implementations
|
||||
@@ -323,7 +324,7 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
|
||||
is_forwarded_htlc = LNWallet.is_forwarded_htlc
|
||||
notify_upstream_peer = LNWallet.notify_upstream_peer
|
||||
_force_close_channel = LNWallet._force_close_channel
|
||||
suggest_splits = LNWallet.suggest_splits
|
||||
suggest_payment_splits = LNWallet.suggest_payment_splits
|
||||
register_hold_invoice = LNWallet.register_hold_invoice
|
||||
unregister_hold_invoice = LNWallet.unregister_hold_invoice
|
||||
add_payment_info_for_hold_invoice = LNWallet.add_payment_info_for_hold_invoice
|
||||
@@ -1556,6 +1557,55 @@ class TestPeerForwarding(TestPeer):
|
||||
print(f" {keys[a].pubkey.hex()}")
|
||||
return graph
|
||||
|
||||
async def test_payment_in_graph_with_direct_channel(self):
|
||||
"""Test payment over a direct channel where sender has multiple available channels."""
|
||||
graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['line_graph'])
|
||||
peers = graph.peers.values()
|
||||
# use same MPP_SPLIT_PART_FRACTION as in regular LNWallet
|
||||
graph.workers['bob'].MPP_SPLIT_PART_FRACTION = LNWallet.MPP_SPLIT_PART_FRACTION
|
||||
|
||||
# mock split_amount_normal so it's possible to test both cases, the amount getting sorted
|
||||
# out because one part is below the min size and the other case of both parts being just
|
||||
# above the min size, so no part is getting sorted out
|
||||
def mocked_split_amount_normal(total_amount: int, num_parts: int) -> List[int]:
|
||||
if num_parts == 2 and total_amount == 21_000_000: # test amount 21k sat
|
||||
# this will not get sorted out by suggest_splits
|
||||
return [10_500_000, 10_500_000]
|
||||
elif num_parts == 2 and total_amount == 21_000_001: # 2nd test case
|
||||
# this will get sorted out by suggest_splits
|
||||
return [11_000_002, 9_999_999]
|
||||
else:
|
||||
return split_amount_normal(total_amount, num_parts)
|
||||
|
||||
async def pay(lnaddr, pay_req):
|
||||
self.assertEqual(PR_UNPAID, graph.workers['alice'].get_payment_status(lnaddr.paymenthash))
|
||||
with mock.patch('electrum.mpp_split.split_amount_normal',
|
||||
side_effect=mocked_split_amount_normal):
|
||||
result, log = await graph.workers['bob'].pay_invoice(pay_req)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(PR_PAID, graph.workers['alice'].get_payment_status(lnaddr.paymenthash))
|
||||
|
||||
async def f():
|
||||
async with OldTaskGroup() as group:
|
||||
for peer in peers:
|
||||
await group.spawn(peer._message_loop())
|
||||
await group.spawn(peer.htlc_switch())
|
||||
for peer in peers:
|
||||
await peer.initialized
|
||||
for test in [21_000_000, 21_000_001]:
|
||||
lnaddr, pay_req = self.prepare_invoice(
|
||||
graph.workers['alice'],
|
||||
amount_msat=test,
|
||||
include_routing_hints=True,
|
||||
invoice_features=LnFeatures.BASIC_MPP_OPT
|
||||
| LnFeatures.PAYMENT_SECRET_REQ
|
||||
| LnFeatures.VAR_ONION_REQ
|
||||
)
|
||||
await pay(lnaddr, pay_req)
|
||||
raise PaymentDone()
|
||||
with self.assertRaises(PaymentDone):
|
||||
await f()
|
||||
|
||||
async def test_payment_multihop(self):
|
||||
graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['square_graph'])
|
||||
peers = graph.peers.values()
|
||||
|
||||
Reference in New Issue
Block a user