bitromortac
4 years ago
2 changed files with 302 additions and 0 deletions
@ -0,0 +1,227 @@ |
|||
import random |
|||
from typing import List, Tuple, Optional, Sequence, Dict |
|||
from collections import defaultdict |
|||
from .util import profiler |
|||
from .lnutil import NoPathFound |
|||
|
|||
PART_PENALTY = 1.0 # 1.0 results in avoiding splits |
|||
MIN_PART_MSAT = 10_000_000 # we don't want to split indefinitely |
|||
|
|||
# these parameters determine the granularity of the newly suggested configurations |
|||
REDISTRIBUTION_FRACTION = 10 |
|||
SPLIT_FRACTION = 10 |
|||
|
|||
# these parameters affect the computational work in the probabilistic algorithm |
|||
STARTING_CONFIGS = 30 |
|||
CANDIDATES_PER_LEVEL = 20 |
|||
REDISTRIBUTE = 5 |
|||
|
|||
|
|||
def unique_hierarchy(hierarchy: Dict[int, List[Dict[bytes, int]]]) -> Dict[int, List[Dict[bytes, int]]]: |
|||
new_hierarchy = defaultdict(list) |
|||
for number_parts, configs in hierarchy.items(): |
|||
unique_configs = set() |
|||
for config in configs: |
|||
# config dict can be out of order, so sort, otherwise not unique |
|||
unique_configs.add(tuple((c, config[c]) for c in sorted(config.keys()))) |
|||
for unique_config in unique_configs: |
|||
new_hierarchy[number_parts].append( |
|||
{t[0]: t[1] for t in unique_config}) |
|||
return new_hierarchy |
|||
|
|||
|
|||
def number_nonzero_parts(configuration: Dict[bytes, int]): |
|||
return len([v for v in configuration.values() if v]) |
|||
|
|||
|
|||
def create_starting_split_hierarchy(amount_msat: int, channels_with_funds: Dict[bytes, int]): |
|||
"""Distributes the amount to send to a single or more channels in several |
|||
ways (randomly).""" |
|||
# TODO: find all possible starting configurations deterministically |
|||
# could try all permutations |
|||
|
|||
split_hierarchy = defaultdict(list) |
|||
channels_order = list(channels_with_funds.keys()) |
|||
|
|||
for _ in range(STARTING_CONFIGS): |
|||
# shuffle to have different starting points |
|||
random.shuffle(channels_order) |
|||
|
|||
configuration = {} |
|||
amount_added = 0 |
|||
for c in channels_order: |
|||
s = channels_with_funds[c] |
|||
if amount_added == amount_msat: |
|||
configuration[c] = 0 |
|||
else: |
|||
amount_to_add = amount_msat - amount_added |
|||
amt = min(s, amount_to_add) |
|||
configuration[c] = amt |
|||
amount_added += amt |
|||
if amount_added != amount_msat: |
|||
raise NoPathFound("Channels don't have enough sending capacity.") |
|||
split_hierarchy[number_nonzero_parts(configuration)].append(configuration) |
|||
|
|||
return unique_hierarchy(split_hierarchy) |
|||
|
|||
|
|||
def balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds): |
|||
check = ( |
|||
proposed_balance_to < MIN_PART_MSAT or |
|||
proposed_balance_to > channels_with_funds[channel_to] or |
|||
proposed_balance_from < MIN_PART_MSAT or |
|||
proposed_balance_from > channels_with_funds[channel_from] |
|||
) |
|||
return check |
|||
|
|||
|
|||
def propose_new_configuration(channels_with_funds: Dict[bytes, int], configuration: Dict[bytes, int], |
|||
amount_msat: int, preserve_number_parts=True) -> Dict[bytes, int]: |
|||
"""Randomly alters a split configuration. If preserve_number_parts, the |
|||
configuration stays within the same class of number of splits.""" |
|||
|
|||
# there are three basic operations to reach different split configurations: |
|||
# redistribute, split, swap |
|||
|
|||
def redistribute(config: dict): |
|||
# we redistribute the amount from a nonzero channel to a nonzero channel |
|||
redistribution_amount = amount_msat // REDISTRIBUTION_FRACTION |
|||
nonzero = [ck for ck, cv in config.items() if |
|||
cv >= redistribution_amount] |
|||
if len(nonzero) == 1: # we only have a single channel, so we can't redistribute |
|||
return config |
|||
|
|||
channel_from = random.choice(nonzero) |
|||
channel_to = random.choice(nonzero) |
|||
if channel_from == channel_to: |
|||
return config |
|||
proposed_balance_from = config[channel_from] - redistribution_amount |
|||
proposed_balance_to = config[channel_to] + redistribution_amount |
|||
if balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds): |
|||
return config |
|||
else: |
|||
config[channel_from] = proposed_balance_from |
|||
config[channel_to] = proposed_balance_to |
|||
assert sum([cv for cv in config.values()]) == amount_msat |
|||
return config |
|||
|
|||
def split(config: dict): |
|||
# we split off a certain amount from a nonzero channel and put it into a |
|||
# zero channel |
|||
nonzero = [ck for ck, cv in config.items() if cv != 0] |
|||
zero = [ck for ck, cv in config.items() if cv == 0] |
|||
try: |
|||
channel_from = random.choice(nonzero) |
|||
channel_to = random.choice(zero) |
|||
except IndexError: |
|||
return config |
|||
delta = config[channel_from] // SPLIT_FRACTION |
|||
proposed_balance_from = config[channel_from] - delta |
|||
proposed_balance_to = config[channel_to] + delta |
|||
if balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds): |
|||
return config |
|||
else: |
|||
config[channel_from] = proposed_balance_from |
|||
config[channel_to] = proposed_balance_to |
|||
assert sum([cv for cv in config.values()]) == amount_msat |
|||
return config |
|||
|
|||
def swap(config: dict): |
|||
# we swap the amounts from a single channel with another channel |
|||
nonzero = [ck for ck, cv in config.items() if cv != 0] |
|||
all = list(config.keys()) |
|||
|
|||
channel_from = random.choice(nonzero) |
|||
channel_to = random.choice(all) |
|||
|
|||
proposed_balance_to = config[channel_from] |
|||
proposed_balance_from = config[channel_to] |
|||
if balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to, channel_to, channels_with_funds): |
|||
return config |
|||
else: |
|||
config[channel_to] = proposed_balance_to |
|||
config[channel_from] = proposed_balance_from |
|||
return config |
|||
|
|||
initial_number_parts = number_nonzero_parts(configuration) |
|||
|
|||
for _ in range(REDISTRIBUTE): |
|||
configuration = redistribute(configuration) |
|||
if not preserve_number_parts and number_nonzero_parts( |
|||
configuration) == initial_number_parts: |
|||
configuration = split(configuration) |
|||
configuration = swap(configuration) |
|||
|
|||
return configuration |
|||
|
|||
|
|||
@profiler |
|||
def suggest_splits(amount_msat: int, channels_with_funds, exclude_single_parts=True) -> Sequence[Tuple[Dict[bytes, int], float]]: |
|||
"""Creates split configurations for a payment over channels. Single channel |
|||
payments are excluded by default.""" |
|||
def rate_configuration(config: dict) -> float: |
|||
"""Defines an objective function to rate a split configuration. |
|||
|
|||
We calculate the normalized L2 norm for a split 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.""" |
|||
F = 0 |
|||
amount = sum([v for v in config.values()]) |
|||
|
|||
for channel, value in config.items(): |
|||
if value: |
|||
value /= amount # normalize |
|||
F += value * value + PART_PENALTY * PART_PENALTY |
|||
return F |
|||
|
|||
def rated_sorted_configurations(hierarchy: dict) -> Sequence[Tuple[Dict[bytes, int], float]]: |
|||
"""Cleans up duplicate splittings, rates and sorts them according to |
|||
the rating. A lower rating is a better configuration.""" |
|||
hierarchy = unique_hierarchy(hierarchy) |
|||
rated_configs = [] |
|||
for level, configs in hierarchy.items(): |
|||
for config in configs: |
|||
rated_configs.append((config, rate_configuration(config))) |
|||
sorted_rated_configs = sorted(rated_configs, key=lambda c: c[1], reverse=False) |
|||
return sorted_rated_configs |
|||
|
|||
# create initial guesses |
|||
split_hierarchy = create_starting_split_hierarchy(amount_msat, channels_with_funds) |
|||
|
|||
# randomize initial guesses |
|||
MAX_PARTS = 5 |
|||
# generate splittings of different split levels up to number of channels |
|||
for level in range(2, min(MAX_PARTS, len(channels_with_funds) + 1)): |
|||
# generate a set of random configurations for each level |
|||
for _ in range(CANDIDATES_PER_LEVEL): |
|||
configurations = unique_hierarchy(split_hierarchy).get(level, None) |
|||
if configurations: # we have a splitting of the desired number of parts |
|||
configuration = random.choice(configurations) |
|||
# generate new splittings preserving the number of parts |
|||
configuration = propose_new_configuration( |
|||
channels_with_funds, configuration, amount_msat, |
|||
preserve_number_parts=True) |
|||
else: |
|||
# go one level lower and look for valid splittings, |
|||
# try to go one level higher by splitting a single outgoing amount |
|||
configurations = unique_hierarchy(split_hierarchy).get(level - 1, None) |
|||
if not configurations: |
|||
continue |
|||
configuration = random.choice(configurations) |
|||
# generate new splittings going one level higher in the number of parts |
|||
configuration = propose_new_configuration( |
|||
channels_with_funds, configuration, amount_msat, |
|||
preserve_number_parts=False) |
|||
|
|||
# add the newly found configuration (doesn't matter if nothing changed) |
|||
split_hierarchy[number_nonzero_parts(configuration)].append(configuration) |
|||
|
|||
if exclude_single_parts: |
|||
# we only want to return configurations that have at least two parts |
|||
try: |
|||
del split_hierarchy[1] |
|||
except: |
|||
pass |
|||
|
|||
return rated_sorted_configurations(split_hierarchy) |
@ -0,0 +1,75 @@ |
|||
import random |
|||
|
|||
import electrum.mpp_split as mpp_split # side effect for PART_PENALTY |
|||
from electrum.lnutil import NoPathFound |
|||
|
|||
from . import ElectrumTestCase |
|||
|
|||
PART_PENALTY = mpp_split.PART_PENALTY |
|||
|
|||
|
|||
class TestMppSplit(ElectrumTestCase): |
|||
def setUp(self): |
|||
super().setUp() |
|||
random.seed(0) # split should only weakly depend on the seed |
|||
# test is dependent on the python version used, here 3.8 |
|||
# undo side effect |
|||
mpp_split.PART_PENALTY = PART_PENALTY |
|||
self.channels_with_funds = { |
|||
0: 1_000_000_000, |
|||
1: 500_000_000, |
|||
2: 302_000_000, |
|||
3: 101_000_000, |
|||
} |
|||
|
|||
def test_suggest_splits(self): |
|||
with self.subTest(msg="do a payment with the maximal amount spendable over a single channel"): |
|||
splits = mpp_split.suggest_splits(1_000_000_000, self.channels_with_funds, exclude_single_parts=True) |
|||
self.assertEqual({0: 500_000_000, 1: 500_000_000, 2: 0, 3: 0}, splits[0][0]) |
|||
|
|||
with self.subTest(msg="do a payment with a larger amount than what is supported by a single channel"): |
|||
splits = mpp_split.suggest_splits(1_100_000_000, self.channels_with_funds, exclude_single_parts=True) |
|||
self.assertEqual({0: 798_000_000, 1: 0, 2: 302_000_000, 3: 0}, splits[0][0]) |
|||
self.assertEqual({0: 908_000_000, 1: 0, 2: 192_000_000, 3: 0}, splits[1][0]) |
|||
|
|||
with self.subTest(msg="do a payment with the maximal amount spendable over all channels"): |
|||
splits = mpp_split.suggest_splits(sum(self.channels_with_funds.values()), self.channels_with_funds, exclude_single_parts=True) |
|||
self.assertEqual({0: 1_000_000_000, 1: 500_000_000, 2: 302_000_000, 3: 101_000_000}, splits[0][0]) |
|||
|
|||
with self.subTest(msg="do a payment with the amount supported by all channels"): |
|||
splits = mpp_split.suggest_splits(101_000_000, self.channels_with_funds, exclude_single_parts=False) |
|||
for s in splits[:4]: |
|||
self.assertEqual(1, mpp_split.number_nonzero_parts(s[0])) |
|||
|
|||
def test_payment_below_min_part_size(self): |
|||
amount = mpp_split.MIN_PART_MSAT // 2 |
|||
splits = mpp_split.suggest_splits(amount, self.channels_with_funds, exclude_single_parts=False) |
|||
# we only get four configurations that end up spending the full amount |
|||
# in a single channel |
|||
self.assertEqual(4, len(splits)) |
|||
|
|||
def test_suggest_part_penalty(self): |
|||
"""Test is mainly for documentation purposes. |
|||
Decreasing the part penalty from 1.0 towards 0.0 leads to an increase |
|||
in the number of parts a payment is split. A configuration which has |
|||
about equally distributed amounts will result.""" |
|||
with self.subTest(msg="split payments with intermediate part penalty"): |
|||
mpp_split.PART_PENALTY = 0.3 |
|||
splits = mpp_split.suggest_splits(1_100_000_000, self.channels_with_funds) |
|||
self.assertEqual({0: 408_000_000, 1: 390_000_000, 2: 302_000_000, 3: 0}, splits[0][0]) |
|||
|
|||
with self.subTest(msg="split payments with no part penalty"): |
|||
mpp_split.PART_PENALTY = 0.0 |
|||
splits = mpp_split.suggest_splits(1_100_000_000, self.channels_with_funds) |
|||
self.assertEqual({0: 307_000_000, 1: 390_000_000, 2: 302_000_000, 3: 101_000_000}, splits[0][0]) |
|||
|
|||
def test_suggest_splits_single_channel(self): |
|||
channels_with_funds = { |
|||
0: 1_000_000_000, |
|||
} |
|||
|
|||
with self.subTest(msg="do a payment with the maximal amount spendable on a single channel"): |
|||
splits = mpp_split.suggest_splits(1_000_000_000, channels_with_funds, exclude_single_parts=False) |
|||
self.assertEqual({0: 1_000_000_000}, splits[0][0]) |
|||
with self.subTest(msg="test sending an amount greater than what we have available"): |
|||
self.assertRaises(NoPathFound, mpp_split.suggest_splits, *(1_100_000_000, channels_with_funds)) |
Loading…
Reference in new issue