You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
195 lines
7.7 KiB
195 lines
7.7 KiB
import random
|
|
import math
|
|
from typing import List, Tuple, Dict, NamedTuple
|
|
from collections import defaultdict
|
|
|
|
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 a list of amounts
|
|
SplitConfig = Dict[Tuple[bytes, bytes], List[int]]
|
|
# maps a channel (channel_id, node_id) to the funds it has available
|
|
ChannelsFundsInfo = Dict[Tuple[bytes, bytes], int]
|
|
|
|
|
|
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 number_parts(config: SplitConfig) -> int:
|
|
return sum([len(v) for v in config.values() if sum(v)])
|
|
|
|
|
|
def number_nonzero_channels(config: SplitConfig) -> int:
|
|
return len([v for v in config.values() if sum(v)])
|
|
|
|
|
|
def number_nonzero_nodes(config: SplitConfig) -> int:
|
|
# using a set comprehension
|
|
return len({nodeid for (_, nodeid), amounts in config.items() if sum(amounts)})
|
|
|
|
|
|
def total_config_amount(config: SplitConfig) -> int:
|
|
return sum([sum(c) for c in config.values()])
|
|
|
|
|
|
def is_any_amount_smaller_than_min_part_size(config: SplitConfig) -> bool:
|
|
smaller = False
|
|
for amounts in config.values():
|
|
if any([amount < MIN_PART_SIZE_MSAT for amount in amounts]):
|
|
smaller |= True
|
|
return smaller
|
|
|
|
|
|
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 = [{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 number_nonzero_nodes(config) == 1]
|
|
|
|
|
|
def remove_single_part_configs(configs: List[SplitConfig]) -> List[SplitConfig]:
|
|
return [config for config in configs if number_parts(config) != 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 = total_config_amount(config)
|
|
|
|
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 = defaultdict(list) # type: 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 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 total_config_amount(config) != amount_msat:
|
|
raise NoPathFound('Cannot distribute payment over channels.')
|
|
if target_parts > 1 and is_any_amount_smaller_than_min_part_size(config):
|
|
continue
|
|
assert total_config_amount(config) == 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
|
|
|