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

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