URI: 
       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))