From 3c521f4ce39078de52862ddf0a341d55f9264dc6 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 8 Apr 2021 09:05:12 +0200 Subject: [PATCH] mpp_split: split algorithm with channel splits - The splitting algorithm is redesigned to use random distribution of subsplittings over channels. - Splittings can include multiple subamounts within a channel. - The single-channel splittings are implicitly activated once the liquidity hints don't support payments of large size. --- electrum/lnworker.py | 24 +- electrum/mpp_split.py | 402 +++++++++++++------------------ electrum/tests/test_mpp_split.py | 67 ++++-- 3 files changed, 217 insertions(+), 276 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 9a15731c0..e6bc3eb3e 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1457,10 +1457,6 @@ class LNWallet(LNWorker): We first try to conduct the payment over a single channel. If that fails and mpp is supported by the receiver, we will split the payment.""" - # It could happen that the pathfinding uses a channel - # in the graph multiple times, meaning we could exhaust - # its capacity. This could be dealt with by temporarily - # iteratively blacklisting channels for this mpp attempt. invoice_features = LnFeatures(invoice_features) trampoline_features = LnFeatures.VAR_ONION_OPT local_height = self.network.get_local_height() @@ -1510,7 +1506,7 @@ class LNWallet(LNWorker): break else: raise NoPathFound() - else: + else: # local single-part route computation route = await run_in_thread( partial( self.create_route_for_payment, @@ -1524,7 +1520,7 @@ class LNWallet(LNWorker): ) ) yield route, amount_msat, final_total_msat, amount_msat, min_cltv_expiry, payment_secret, fwd_trampoline_onion - except NoPathFound: + except NoPathFound: # fall back to payment splitting if not invoice_features.supports(LnFeatures.BASIC_MPP_OPT): raise channels_with_funds = { @@ -1535,17 +1531,17 @@ class LNWallet(LNWorker): # to a single node due to some incompatibility in Eclair, see: # https://github.com/ACINQ/eclair/issues/1723 use_singe_node = not self.channel_db and constants.net is constants.BitcoinMainnet - split_configurations = suggest_splits(amount_msat, channels_with_funds, single_node=use_singe_node) + split_configurations = suggest_splits(amount_msat, channels_with_funds, exclude_multinode_payments=use_singe_node) self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations') - for s in split_configurations: - self.logger.info(f"trying split configuration: {s[0].values()} rating: {s[1]}") + for sc in split_configurations: + self.logger.info(f"trying split configuration: {sc.config.values()} rating: {sc.rating}") try: if not self.channel_db: buckets = defaultdict(list) - for (chan_id, _), part_amount_msat in s[0].items(): + for (chan_id, _), part_amounts_msat in sc.config.items(): chan = self.channels[chan_id] - if part_amount_msat: + for part_amount_msat in part_amounts_msat: buckets[chan.node_id].append((chan_id, part_amount_msat)) for node_id, bucket in buckets.items(): bucket_amount_msat = sum([x[1] for x in bucket]) @@ -1589,8 +1585,8 @@ class LNWallet(LNWorker): self.logger.info('not enough margin to pay trampoline fee') raise NoPathFound() else: - for (chan_id, _), part_amount_msat in s[0].items(): - if part_amount_msat: + for (chan_id, _), part_amounts_msat in sc.config.items(): + for part_amount_msat in part_amounts_msat: channel = self.channels[chan_id] route = await run_in_thread( partial( @@ -1605,7 +1601,7 @@ class LNWallet(LNWorker): ) ) yield route, part_amount_msat, final_total_msat, part_amount_msat, min_cltv_expiry, payment_secret, fwd_trampoline_onion - self.logger.info(f"found acceptable split configuration: {list(s[0].values())} rating: {s[1]}") + self.logger.info(f"found acceptable split configuration: {list(sc.config.values())} rating: {sc.rating}") break except NoPathFound: continue diff --git a/electrum/mpp_split.py b/electrum/mpp_split.py index 36d6ffa0b..b7e07c938 100644 --- a/electrum/mpp_split.py +++ b/electrum/mpp_split.py @@ -1,259 +1,181 @@ import random import math -from typing import List, Tuple, Optional, Sequence, Dict, TYPE_CHECKING +from typing import List, Tuple, Dict, NamedTuple 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 +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 - -# these parameters determine the granularity of the newly suggested configurations -REDISTRIBUTION_FRACTION = 50 -SPLIT_FRACTION = 50 +RELATIVE_SPLIT_SPREAD = 0.3 # deviation from the mean when splitting amounts into parts # these parameters affect the computational work in the probabilistic algorithm -STARTING_CONFIGS = 50 -CANDIDATES_PER_LEVEL = 10 -REDISTRIBUTE = 20 - -# maximum number of parts for splitting -MAX_PARTS = 5 - - -def unique_hierarchy(hierarchy: Dict[int, List[Dict[Tuple[bytes, bytes], int]]]) -> Dict[int, List[Dict[Tuple[bytes, 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 sorted(unique_configs): - new_hierarchy[number_parts].append( - {t[0]: t[1] for t in unique_config}) - return new_hierarchy - - -def single_node_hierarchy(hierarchy: Dict[int, List[Dict[Tuple[bytes, bytes], int]]]) -> Dict[int, List[Dict[Tuple[bytes, bytes], int]]]: - new_hierarchy = defaultdict(list) - for number_parts, configs in hierarchy.items(): - for config in configs: - # determine number of nodes in configuration - if number_nonzero_nodes(config) > 1: - continue - new_hierarchy[number_parts].append(config) - return new_hierarchy +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 number_nonzero_parts(configuration: Dict[Tuple[bytes, bytes], int]) -> int: - return len([v for v in configuration.values() if v]) +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 number_nonzero_nodes(configuration: Dict[Tuple[bytes, bytes], int]) -> int: - return len({nodeid for (_, nodeid), amount in configuration.items() if amount > 0}) +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 create_starting_split_hierarchy(amount_msat: int, channels_with_funds: Dict[Tuple[bytes, 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 +def remove_multiple_nodes(configs: List[SplitConfig]) -> List[SplitConfig]: + return [config for config in configs if number_nonzero_nodes(config) == 1] - split_hierarchy = defaultdict(list) + +def remove_single_part_configs(configs: List[SplitConfig]) -> List[SplitConfig]: + return [config for config in configs if number_parts(config) != 1] + + +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 +) -> 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()) - 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[Tuple[bytes, bytes], int], configuration: Dict[Tuple[bytes, bytes], int], - amount_msat: int, preserve_number_parts=True) -> Dict[Tuple[bytes, 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: Dict[Tuple[bytes, bytes], int], - exclude_single_parts=True, single_node=False) \ - -> Sequence[Tuple[Dict[Tuple[bytes, bytes], int], float]]: - """Creates split configurations for a payment over channels. Single channel - payments are excluded by default. channels_with_funds is keyed by - (channelid, nodeid).""" - - 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 - total_amount = sum([v for v in config.values()]) - - for channel, amount in config.items(): - funds = channels_with_funds[channel] - if amount: - F += amount * amount / (total_amount * total_amount) # a penalty to favor distribution of amounts - F += PART_PENALTY * PART_PENALTY # a penalty for each part - decay = funds / EXHAUST_DECAY_FRACTION - F += math.exp((amount - funds) / decay) # a penalty for channel saturation - - return F - - def rated_sorted_configurations(hierarchy: dict) -> Sequence[Tuple[Dict[Tuple[bytes, 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 and 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 - - if single_node: - # we only take configurations that send to a single node - split_hierarchy = single_node_hierarchy(split_hierarchy) - - return rated_sorted_configurations(split_hierarchy) + # 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) + + 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 diff --git a/electrum/tests/test_mpp_split.py b/electrum/tests/test_mpp_split.py index 79d6a85c6..de0580e13 100644 --- a/electrum/tests/test_mpp_split.py +++ b/electrum/tests/test_mpp_split.py @@ -28,43 +28,58 @@ class TestMppSplit(ElectrumTestCase): 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, 0): 660_000_000, (1, 1): 340_000_000, (2, 0): 0, (3, 2): 0}, splits[0][0]) + splits = mpp_split.suggest_splits(1_000_000_000, self.channels_with_funds, exclude_single_part_payments=True) + self.assertEqual({ + (0, 0): [671_020_676], + (1, 1): [328_979_324], + (2, 0): [], + (3, 2): []}, + splits[0].config + ) 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(2, mpp_split.number_nonzero_parts(splits[0][0])) + splits = mpp_split.suggest_splits(1_100_000_000, self.channels_with_funds, exclude_single_part_payments=False) + self.assertEqual(2, mpp_split.number_parts(splits[0].config)) 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, 0): 1_000_000_000, (1, 1): 500_000_000, (2, 0): 302_000_000, (3, 2): 101_000_000}, splits[0][0]) + splits = mpp_split.suggest_splits( + sum(self.channels_with_funds.values()), self.channels_with_funds, exclude_single_part_payments=True) + self.assertEqual({ + (0, 0): [1_000_000_000], + (1, 1): [500_000_000], + (2, 0): [302_000_000], + (3, 2): [101_000_000]}, + splits[0].config + ) 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])) + splits = mpp_split.suggest_splits(101_000_000, self.channels_with_funds, exclude_single_part_payments=False) + for split in splits[:3]: + self.assertEqual(1, mpp_split.number_nonzero_channels(split.config)) + # due to exhaustion of the smallest channel, the algorithm favors + # a splitting of the parts into two + self.assertEqual(2, mpp_split.number_parts(splits[4].config)) def test_send_to_single_node(self): - splits = mpp_split.suggest_splits(1_000_000_000, self.channels_with_funds, exclude_single_parts=True, single_node=True) - self.assertEqual({(0, 0): 738_000_000, (1, 1): 0, (2, 0): 262_000_000, (3, 2): 0}, splits[0][0]) + splits = mpp_split.suggest_splits(1_000_000_000, self.channels_with_funds, exclude_single_part_payments=False, exclude_multinode_payments=True) for split in splits: - assert mpp_split.number_nonzero_nodes(split[0]) == 1 + assert mpp_split.number_nonzero_nodes(split.config) == 1 def test_saturation(self): """Split configurations which spend the full amount in a channel should be avoided.""" channels_with_funds = {(0, 0): 159_799_733_076, (1, 1): 499_986_152_000} - splits = mpp_split.suggest_splits(600_000_000_000, channels_with_funds, exclude_single_parts=True) + splits = mpp_split.suggest_splits(600_000_000_000, channels_with_funds, exclude_single_part_payments=True) uses_full_amount = False - for c, a in splits[0][0].items(): + for c, a in splits[0].config.items(): if a == channels_with_funds[c]: uses_full_amount |= True self.assertFalse(uses_full_amount) 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) + amount = mpp_split.MIN_PART_SIZE_MSAT // 2 + splits = mpp_split.suggest_splits(amount, self.channels_with_funds, exclude_single_part_payments=False) # we only get four configurations that end up spending the full amount # in a single channel self.assertEqual(4, len(splits)) @@ -77,25 +92,33 @@ class TestMppSplit(ElectrumTestCase): with self.subTest(msg="split payments with intermediate part penalty"): mpp_split.PART_PENALTY = 1.0 splits = mpp_split.suggest_splits(1_100_000_000, self.channels_with_funds) - self.assertEqual(2, mpp_split.number_nonzero_parts(splits[0][0])) + self.assertEqual(2, mpp_split.number_parts(splits[0].config)) 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(3, mpp_split.number_nonzero_parts(splits[0][0])) + self.assertEqual(4, mpp_split.number_parts(splits[0].config)) 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(4, mpp_split.number_nonzero_parts(splits[0][0])) + self.assertEqual(5, mpp_split.number_parts(splits[0].config)) def test_suggest_splits_single_channel(self): channels_with_funds = { - 0: 1_000_000_000, + (0, 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]) + splits = mpp_split.suggest_splits(1_000_000_000, channels_with_funds, exclude_single_part_payments=False) + self.assertEqual({(0, 0): [1_000_000_000]}, splits[0].config) 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)) + with self.subTest(msg="test sending a large amount over a single channel in chunks"): + mpp_split.PART_PENALTY = 0.5 + splits = mpp_split.suggest_splits(1_000_000_000, channels_with_funds, exclude_single_part_payments=False) + self.assertEqual(2, len(splits[0].config[(0, 0)])) + with self.subTest(msg="test sending a large amount over a single channel in chunks"): + mpp_split.PART_PENALTY = 0.3 + splits = mpp_split.suggest_splits(1_000_000_000, channels_with_funds, exclude_single_part_payments=False) + self.assertEqual(3, len(splits[0].config[(0, 0)]))