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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
CANDIDATES_PER_LEVEL = 20
|
||||
MAX_PARTS = 5 # maximum number of parts for splitting
|
||||
|
||||
|
||||
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
|
||||
# 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]
|
||||
|
||||
|
||||
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
|
||||
class SplitConfigRating(NamedTuple):
|
||||
config: SplitConfig
|
||||
rating: float
|
||||
|
||||
|
||||
def number_nonzero_parts(configuration: Dict[Tuple[bytes, bytes], int]) -> int:
|
||||
return len([v for v in configuration.values() if v])
|
||||
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_nonzero_nodes(configuration: Dict[Tuple[bytes, bytes], int]) -> int:
|
||||
return len({nodeid for (_, nodeid), amount in configuration.items() if amount > 0})
|
||||
def number_parts(config: SplitConfig) -> int:
|
||||
return sum([len(v) for v in config.values() if sum(v)])
|
||||
|
||||
|
||||
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 number_nonzero_channels(config: SplitConfig) -> int:
|
||||
return len([v for v in config.values() if sum(v)])
|
||||
|
||||
split_hierarchy = defaultdict(list)
|
||||
|
||||
def number_nonzero_nodes(config: SplitConfig) -> int:
|
||||
# using a set comprehension
|
||||
return len({nodeid for (_, nodeid), amounts in config.items() if sum(amounts)})
|
||||
|
||||
|
||||
def total_config_amount(config: SplitConfig) -> int:
|
||||
return sum([sum(c) for c in config.values()])
|
||||
|
||||
|
||||
def is_any_amount_smaller_than_min_part_size(config: SplitConfig) -> bool:
|
||||
smaller = False
|
||||
for amounts in config.values():
|
||||
if any([amount < MIN_PART_SIZE_MSAT for amount in amounts]):
|
||||
smaller |= True
|
||||
return smaller
|
||||
|
||||
|
||||
def remove_duplicates(configs: List[SplitConfig]) -> List[SplitConfig]:
|
||||
unique_configs = set()
|
||||
for config in configs:
|
||||
# sort keys and values
|
||||
config_sorted_values = {k: sorted(v) for k, v in config.items()}
|
||||
config_sorted_keys = {k: config_sorted_values[k] for k in sorted(config_sorted_values.keys())}
|
||||
hashable_config = tuple((c, tuple(sorted(config[c]))) for c in config_sorted_keys)
|
||||
unique_configs.add(hashable_config)
|
||||
unique_configs = [{c[0]: list(c[1]) for c in config} for config in unique_configs]
|
||||
return unique_configs
|
||||
|
||||
|
||||
def remove_multiple_nodes(configs: List[SplitConfig]) -> List[SplitConfig]:
|
||||
return [config for config in configs if number_nonzero_nodes(config) == 1]
|
||||
|
||||
|
||||
def remove_single_part_configs(configs: List[SplitConfig]) -> List[SplitConfig]:
|
||||
return [config for config in configs if number_parts(config) != 1]
|
||||
|
||||
|
||||
def 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)
|
||||
# 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
|
||||
|
||||
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)
|
||||
# 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)
|
||||
|
||||
return unique_hierarchy(split_hierarchy)
|
||||
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)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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)
|
||||
return rated_configs
|
||||
|
||||
@@ -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)]))
|
||||
|
||||
Reference in New Issue
Block a user