1
0
Files
electrum/electrum/mpp_split.py
f321x e8171ca7c6 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.
2025-05-15 10:26:33 +02:00

198 lines
8.1 KiB
Python

import random
import math
from typing import List, Tuple, Dict, NamedTuple
from .lnutil import NoPathFound
PART_PENALTY = 1.0 # 1.0 results in avoiding splits
MIN_PART_SIZE_MSAT = 10_000_000 # we don't want to split indefinitely
EXHAUST_DECAY_FRACTION = 10 # fraction of the local balance that should be reserved if possible
RELATIVE_SPLIT_SPREAD = 0.3 # deviation from the mean when splitting amounts into parts
# these parameters affect the computational work in the probabilistic algorithm
CANDIDATES_PER_LEVEL = 20
MAX_PARTS = 5 # maximum number of parts for splitting
# maps a channel (channel_id, node_id) to the funds it has available
ChannelsFundsInfo = Dict[Tuple[bytes, bytes], int]
class SplitConfig(dict, Dict[Tuple[bytes, bytes], List[int]]):
"""maps a channel (channel_id, node_id) to a list of amounts"""
def number_parts(self) -> int:
return sum([len(v) for v in self.values() if sum(v)])
def number_nonzero_channels(self) -> int:
return len([v for v in self.values() if sum(v)])
def number_nonzero_nodes(self) -> int:
# using a set comprehension
return len({nodeid for (_, nodeid), amounts in self.items() if sum(amounts)})
def total_config_amount(self) -> int:
return sum([sum(c) for c in self.values()])
def is_any_amount_smaller_than_min_part_size(self) -> bool:
smaller = False
for amounts in self.values():
if any([amount < MIN_PART_SIZE_MSAT for amount in amounts]):
smaller |= True
return smaller
class SplitConfigRating(NamedTuple):
config: SplitConfig
rating: float
def split_amount_normal(total_amount: int, num_parts: int) -> List[int]:
"""Splits an amount into about `num_parts` parts, where the parts are split
randomly (normally distributed around amount/num_parts with certain spread)."""
parts = []
avg_amount = total_amount / num_parts
# roughly reach total_amount
while total_amount - sum(parts) > avg_amount:
amount_to_add = int(abs(random.gauss(avg_amount, RELATIVE_SPLIT_SPREAD * avg_amount)))
if sum(parts) + amount_to_add < total_amount:
parts.append(amount_to_add)
# add what's missing
parts.append(total_amount - sum(parts))
return parts
def remove_duplicates(configs: List[SplitConfig]) -> List[SplitConfig]:
unique_configs = set()
for config in configs:
# sort keys and values
config_sorted_values = {k: sorted(v) for k, v in config.items()}
config_sorted_keys = {k: config_sorted_values[k] for k in sorted(config_sorted_values.keys())}
hashable_config = tuple((c, tuple(sorted(config[c]))) for c in config_sorted_keys)
unique_configs.add(hashable_config)
unique_configs = [SplitConfig({c[0]: list(c[1]) for c in config}) for config in unique_configs]
return unique_configs
def remove_multiple_nodes(configs: List[SplitConfig]) -> List[SplitConfig]:
return [config for config in configs if config.number_nonzero_nodes() == 1]
def remove_single_part_configs(configs: List[SplitConfig]) -> List[SplitConfig]:
return [config for config in configs if config.number_parts() != 1]
def remove_single_channel_splits(configs: List[SplitConfig]) -> List[SplitConfig]:
filtered = []
for config in configs:
for v in config.values():
if len(v) > 1:
continue
filtered.append(config)
return filtered
def rate_config(
config: SplitConfig,
channels_with_funds: ChannelsFundsInfo) -> float:
"""Defines an objective function to rate a configuration.
We calculate the normalized L2 norm for a configuration and
add a part penalty for each nonzero amount. The consequence is that
amounts that are equally distributed and have less parts are rated
lowest (best). A penalty depending on the total amount sent over a channel
counteracts channel exhaustion."""
rating = 0
total_amount = config.total_config_amount()
for channel, amounts in config.items():
funds = channels_with_funds[channel]
if amounts:
for amount in amounts:
rating += amount * amount / (total_amount * total_amount) # penalty to favor equal distribution of amounts
rating += PART_PENALTY * PART_PENALTY # penalty for each part
decay = funds / EXHAUST_DECAY_FRACTION
rating += math.exp((sum(amounts) - funds) / decay) # penalty for channel exhaustion
return rating
def suggest_splits(
amount_msat: int, channels_with_funds: ChannelsFundsInfo,
exclude_single_part_payments=False,
exclude_multinode_payments=False,
exclude_single_channel_splits=False
) -> List[SplitConfigRating]:
"""Breaks amount_msat into smaller pieces and distributes them over the
channels according to the funds they can send.
Individual channels may be assigned multiple parts. The split configurations
are returned in sorted order, from best to worst rating.
Single part payments can be excluded, since they represent legacy payments.
Split configurations that send via multiple nodes can be excluded as well.
"""
configs = []
channels_order = list(channels_with_funds.keys())
# generate multiple configurations to get more configurations (there is randomness in this loop)
for _ in range(CANDIDATES_PER_LEVEL):
# we want to have configurations with no splitting to many splittings
for target_parts in range(1, MAX_PARTS):
config = SplitConfig()
# randomly split amount into target_parts chunks
split_amounts = split_amount_normal(amount_msat, target_parts)
# randomly distribute amounts over channels
for amount in split_amounts:
random.shuffle(channels_order)
# we check each channel and try to put the funds inside, break if we succeed
for c in channels_order:
if c not in config:
config[c] = []
if sum(config[c]) + amount <= channels_with_funds[c]:
config[c].append(amount)
break
# if we don't succeed to put the amount anywhere,
# we try to fill up channels and put the rest somewhere else
else:
distribute_amount = amount
for c in channels_order:
funds_left = channels_with_funds[c] - sum(config[c])
# it would be good to not fill the full channel if possible
add_amount = min(funds_left, distribute_amount)
config[c].append(add_amount)
distribute_amount -= add_amount
if distribute_amount == 0:
break
if config.total_config_amount() != amount_msat:
raise NoPathFound('Cannot distribute payment over channels.')
if target_parts > 1 and config.is_any_amount_smaller_than_min_part_size():
if target_parts == 2:
# if there are already too small parts at the first split excluding single
# part payments may return only few configurations, this will allow single part
# payments for more payments, if they are too small to split
exclude_single_part_payments = False
continue
assert config.total_config_amount() == amount_msat
configs.append(config)
configs = remove_duplicates(configs)
# we only take configurations that send via a single node (but there can be multiple parts)
if exclude_multinode_payments:
configs = remove_multiple_nodes(configs)
if exclude_single_part_payments:
configs = remove_single_part_configs(configs)
if exclude_single_channel_splits:
configs = remove_single_channel_splits(configs)
rated_configs = [SplitConfigRating(
config=c,
rating=rate_config(c, channels_with_funds)
) for c in configs]
rated_configs.sort(key=lambda x: x.rating)
return rated_configs