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.
198 lines
8.1 KiB
Python
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
|