tMerge pull request #7050 from bitromortac/mpp-send - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 9ea2c275cec7c5701d360a60f97a2670f0819b7a DIR parent 1c52203346f9049f6c3fe201f672960339d8273b HTML Author: ThomasV <thomasv@electrum.org> Date: Mon, 22 Feb 2021 13:51:59 +0100 Merge pull request #7050 from bitromortac/mpp-send Complete multipart payment sending support Diffstat: M electrum/lnonion.py | 10 ++++++++-- M electrum/lnpeer.py | 11 ++++++++--- M electrum/lnutil.py | 3 +++ M electrum/lnworker.py | 111 ++++++++++++++++++++++++++----- A electrum/mpp_split.py | 227 +++++++++++++++++++++++++++++++ M electrum/tests/test_lnpeer.py | 7 ++++++- A electrum/tests/test_mpp_split.py | 75 +++++++++++++++++++++++++++++++ 7 files changed, 423 insertions(+), 21 deletions(-) --- DIR diff --git a/electrum/lnonion.py b/electrum/lnonion.py t@@ -261,7 +261,7 @@ def new_onion_packet(payment_path_pubkeys: Sequence[bytes], session_key: bytes, hmac=next_hmac) -def calc_hops_data_for_payment(route: 'LNPaymentRoute', amount_msat: int, +def calc_hops_data_for_payment(route: 'LNPaymentRoute', amount_msat: int, total_msat: int, final_cltv: int, *, payment_secret: bytes = None) \ -> Tuple[List[OnionHopsDataSingle], int, int]: """Returns the hops_data to be used for constructing an onion packet, t@@ -277,8 +277,14 @@ def calc_hops_data_for_payment(route: 'LNPaymentRoute', amount_msat: int, "amt_to_forward": {"amt_to_forward": amt}, "outgoing_cltv_value": {"outgoing_cltv_value": cltv}, } + # for multipart payments we need to tell the reciever about the total and + # partial amounts if payment_secret is not None: - hop_payload["payment_data"] = {"payment_secret": payment_secret, "total_msat": amt} + hop_payload["payment_data"] = { + "payment_secret": payment_secret, + "total_msat": total_msat, + "amount_msat": amt + } hops_data = [OnionHopsDataSingle(is_tlv_payload=route[-1].has_feature_varonion(), payload=hop_payload)] # payloads, backwards from last hop (but excluding the first edge): DIR diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py t@@ -1196,7 +1196,7 @@ class Peer(Logger): self.send_message("commitment_signed", channel_id=chan.channel_id, signature=sig_64, num_htlcs=len(htlc_sigs), htlc_signature=b"".join(htlc_sigs)) def pay(self, *, route: 'LNPaymentRoute', chan: Channel, amount_msat: int, - payment_hash: bytes, min_final_cltv_expiry: int, + total_msat: int, payment_hash: bytes, min_final_cltv_expiry: int, payment_secret: bytes = None, fwd_trampoline_onion=None) -> UpdateAddHtlc: assert amount_msat > 0, "amount_msat is not greater zero" assert len(route) > 0 t@@ -1206,8 +1206,13 @@ class Peer(Logger): route[0].node_features |= self.features local_height = self.network.get_local_height() final_cltv = local_height + min_final_cltv_expiry - hops_data, amount_msat, cltv = calc_hops_data_for_payment(route, amount_msat, final_cltv, - payment_secret=payment_secret) + hops_data, amount_msat, cltv = calc_hops_data_for_payment( + route, + amount_msat, + total_msat, + final_cltv, + payment_secret=payment_secret + ) self.logger.info(f"lnpeer.pay len(route)={len(route)}") for i in range(len(route)): self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}") DIR diff --git a/electrum/lnutil.py b/electrum/lnutil.py t@@ -290,6 +290,9 @@ class UpfrontShutdownScriptViolation(RemoteMisbehaving): pass class NotFoundChanAnnouncementForUpdate(Exception): pass class PaymentFailure(UserFacingException): pass +class NoPathFound(PaymentFailure): + def __str__(self): + return _('No path found') # TODO make some of these values configurable? REDEEM_AFTER_DOUBLE_SPENT_DELAY = 30 DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -56,7 +56,8 @@ from .lnutil import (Outpoint, LNPeerAddr, MIN_FINAL_CLTV_EXPIRY_FOR_INVOICE, NUM_MAX_EDGES_IN_PAYMENT_PATH, SENT, RECEIVED, HTLCOwner, UpdateAddHtlc, Direction, LnFeatures, ShortChannelID, - HtlcLog, derive_payment_secret_from_payment_preimage) + HtlcLog, derive_payment_secret_from_payment_preimage, + NoPathFound) from .lnutil import ln_dummy_address, ln_compare_features, IncompatibleLightningFeatures from .lnrouter import TrampolineEdge from .transaction import PartialTxOutput, PartialTransaction, PartialTxInput t@@ -75,6 +76,7 @@ from .channel_db import UpdateStatus from .channel_db import get_mychannel_info, get_mychannel_policy from .submarine_swaps import SwapManager from .channel_db import ChannelInfo, Policy +from .mpp_split import suggest_splits if TYPE_CHECKING: from .network import Network t@@ -199,11 +201,6 @@ class PaymentInfo(NamedTuple): status: int -class NoPathFound(PaymentFailure): - def __str__(self): - return _('No path found') - - class ErrorAddingPeer(Exception): pass t@@ -1023,7 +1020,7 @@ class LNWallet(LNWorker): key = payment_hash.hex() payment_secret = lnaddr.payment_secret invoice_pubkey = lnaddr.pubkey.serialize() - invoice_features = lnaddr.get_tag('9') or 0 + invoice_features = LnFeatures(lnaddr.get_tag('9') or 0) r_tags = lnaddr.get_routing_info('r') t_tags = lnaddr.get_routing_info('t') amount_to_pay = lnaddr.get_amount_msat() t@@ -1094,7 +1091,7 @@ class LNWallet(LNWorker): routes = [(route, amount_to_send)] # 2. send htlcs for route, amount_msat in routes: - await self.pay_to_route(route, amount_msat, payment_hash, payment_secret, min_cltv_expiry, trampoline_onion) + await self.pay_to_route(route, amount_msat, amount_to_pay, payment_hash, payment_secret, min_cltv_expiry, trampoline_onion) amount_inflight += amount_msat util.trigger_callback('invoice_status', self.wallet, payment_hash.hex()) # 3. await a queue t@@ -1110,8 +1107,9 @@ class LNWallet(LNWorker): # if we get a channel update, we might retry the same route and amount self.handle_error_code_from_failed_htlc(htlc_log) - - async def pay_to_route(self, route: LNPaymentRoute, amount_msat:int, payment_hash:bytes, payment_secret:bytes, min_cltv_expiry:int, trampoline_onion:bytes =None): + async def pay_to_route(self, route: LNPaymentRoute, amount_msat: int, + total_msat: int, payment_hash: bytes, payment_secret: bytes, + min_cltv_expiry: int, trampoline_onion: bytes=None): # send a single htlc short_channel_id = route[0].short_channel_id chan = self.get_channel_by_short_id(short_channel_id) t@@ -1123,6 +1121,7 @@ class LNWallet(LNWorker): route=route, chan=chan, amount_msat=amount_msat, + total_msat=total_msat, payment_hash=payment_hash, min_final_cltv_expiry=min_cltv_expiry, payment_secret=payment_secret, t@@ -1379,6 +1378,16 @@ class LNWallet(LNWorker): node_features=trampoline_features)) return route + def channels_with_funds(self) -> Dict[bytes, int]: + """Determines a dict of channels (keyed by channel id in bytes) that + maps to their spendable amounts.""" + with self.lock: + channels = {} + for cid, chan in self._channels.items(): + spend_amount = int(chan.available_to_spend(HTLCOwner.LOCAL)) + channels[cid] = spend_amount + return channels + @profiler def create_routes_for_payment( self, t@@ -1387,11 +1396,83 @@ class LNWallet(LNWorker): min_cltv_expiry, r_tags, invoice_features, - *, full_path: LNPaymentPath = None) -> LNPaymentRoute: - # TODO: return multiples routes if we know that a single one will not work - # initially, try with less htlcs + *, full_path: LNPaymentPath = None) -> Sequence[Tuple[LNPaymentRoute, int]]: + """Creates multiple routes for splitting a payment over the available + private channels. + + 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.""" + + try: # to send over a single channel + routes = [self.create_route_for_payment( + amount_msat, + invoice_pubkey, + min_cltv_expiry, + r_tags, + invoice_features, + None, + full_path=full_path + )] + except NoPathFound: + if invoice_features & LnFeatures.BASIC_MPP_OPT: + # Create split configurations that are rated according to our + # preference (low rating=high preference). + split_configurations = suggest_splits( + amount_msat, + self.channels_with_funds() + ) + + self.logger.info("Created the following splitting configurations.") + for s in split_configurations: + self.logger.info(f"{s[0]} rating: {s[1]}") + + routes = [] + for s in split_configurations: + try: + for chanid, part_amount_msat in s[0].items(): + if part_amount_msat: + channel = self.channels[chanid] + # 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. + route, amt = self.create_route_for_payment( + part_amount_msat, + invoice_pubkey, + min_cltv_expiry, + r_tags, + invoice_features, + channel, + full_path=None + ) + routes.append((route, amt)) + break + except NoPathFound: + routes = [] + continue + else: + raise + + if not routes: + raise NoPathFound + else: + return routes + + def create_route_for_payment( + self, + amount_msat: int, + invoice_pubkey, + min_cltv_expiry, + r_tags, + invoice_features, + outgoing_channel: Channel = None, + *, full_path: Optional[LNPaymentPath]) -> Tuple[LNPaymentRoute, int]: route = None - channels = list(self.channels.values()) + # we can constrain the payment to a single outgoing channel + if outgoing_channel: + channels = [outgoing_channel] + else: + channels = list(self.channels.values()) scid_to_my_channels = {chan.short_channel_id: chan for chan in channels if chan.short_channel_id is not None} t@@ -1467,7 +1548,7 @@ class LNWallet(LNWorker): # add features from invoice route[-1].node_features |= invoice_features # return a list of routes - return [(route, amount_msat)] + return route, amount_msat def add_request(self, amount_sat, message, expiry) -> str: coro = self._add_request_coro(amount_sat, message, expiry) DIR diff --git a/electrum/mpp_split.py b/electrum/mpp_split.py t@@ -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) DIR diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py t@@ -175,6 +175,7 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): htlc_failed = LNWallet.htlc_failed save_preimage = LNWallet.save_preimage get_preimage = LNWallet.get_preimage + create_route_for_payment = LNWallet.create_route_for_payment create_routes_for_payment = LNWallet.create_routes_for_payment create_routes_from_invoice = LNWallet.create_routes_from_invoice _check_invoice = staticmethod(LNWallet._check_invoice) t@@ -189,6 +190,7 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): channels_for_peer = LNWallet.channels_for_peer _calc_routing_hints_for_invoice = LNWallet._calc_routing_hints_for_invoice handle_error_code_from_failed_htlc = LNWallet.handle_error_code_from_failed_htlc + channels_with_funds = LNWallet.channels_with_funds class MockTransport: t@@ -497,6 +499,7 @@ class TestPeer(ElectrumTestCase): route=route1, chan=alice_channel, amount_msat=lnaddr2.get_amount_msat(), + total_msat=lnaddr2.get_amount_msat(), payment_hash=lnaddr2.paymenthash, min_final_cltv_expiry=lnaddr2.get_min_final_cltv_expiry(), payment_secret=lnaddr2.payment_secret, t@@ -509,6 +512,7 @@ class TestPeer(ElectrumTestCase): route=route2, chan=bob_channel, amount_msat=lnaddr1.get_amount_msat(), + total_msat=lnaddr1.get_amount_msat(), payment_hash=lnaddr1.paymenthash, min_final_cltv_expiry=lnaddr1.get_min_final_cltv_expiry(), payment_secret=lnaddr1.payment_secret, t@@ -663,6 +667,7 @@ class TestPeer(ElectrumTestCase): htlc = p1.pay(route=route, chan=alice_channel, amount_msat=lnaddr.get_amount_msat(), + total_msat=lnaddr.get_amount_msat(), payment_hash=lnaddr.paymenthash, min_final_cltv_expiry=lnaddr.get_min_final_cltv_expiry(), payment_secret=lnaddr.payment_secret) t@@ -771,7 +776,7 @@ class TestPeer(ElectrumTestCase): min_cltv_expiry = lnaddr.get_min_final_cltv_expiry() payment_hash = lnaddr.paymenthash payment_secret = lnaddr.payment_secret - pay = w1.pay_to_route(route, amount_msat, payment_hash, payment_secret, min_cltv_expiry) + pay = w1.pay_to_route(route, amount_msat, amount_msat, payment_hash, payment_secret, min_cltv_expiry) await asyncio.gather(pay, p1._message_loop(), p2._message_loop(), p1.htlc_switch(), p2.htlc_switch()) with self.assertRaises(PaymentFailure): run(f()) DIR diff --git a/electrum/tests/test_mpp_split.py b/electrum/tests/test_mpp_split.py t@@ -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))