Browse Source

mpp_split: use single nodes for mpp payments over trampoline

patch-4
bitromortac 4 years ago
parent
commit
8828998093
No known key found for this signature in database GPG Key ID: 1965063FC13BEBE2
  1. 23
      electrum/lnworker.py
  2. 41
      electrum/mpp_split.py
  3. 4
      electrum/tests/test_lnpeer.py
  4. 21
      electrum/tests/test_mpp_split.py

23
electrum/lnworker.py

@ -1166,7 +1166,7 @@ class LNWallet(LNWorker):
if code == OnionFailureCode.MPP_TIMEOUT: if code == OnionFailureCode.MPP_TIMEOUT:
raise PaymentFailure(failure_msg.code_name()) raise PaymentFailure(failure_msg.code_name())
# trampoline # trampoline
if self.channel_db is None: if not self.channel_db:
if code == OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT: if code == OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT:
# todo: parse the node parameters here (not returned by eclair yet) # todo: parse the node parameters here (not returned by eclair yet)
trampoline_fee_level += 1 trampoline_fee_level += 1
@ -1415,21 +1415,24 @@ class LNWallet(LNWorker):
except NoPathFound: except NoPathFound:
if not invoice_features.supports(LnFeatures.BASIC_MPP_OPT): if not invoice_features.supports(LnFeatures.BASIC_MPP_OPT):
raise raise
channels_with_funds = dict([
(cid, int(chan.available_to_spend(HTLCOwner.LOCAL))) channels_with_funds = {(cid, chan.node_id): int(chan.available_to_spend(HTLCOwner.LOCAL))
for cid, chan in self._channels.items() if not chan.is_frozen_for_sending()]) for cid, chan in self._channels.items() if not chan.is_frozen_for_sending()}
self.logger.info(f"channels_with_funds: {channels_with_funds}") self.logger.info(f"channels_with_funds: {channels_with_funds}")
# Create split configurations that are rated according to our # for trampoline mpp payments we have to restrict ourselves to pay
# preference -funds = (low rating=high preference). # to a single node due to some incompatibility in Eclair, see:
split_configurations = suggest_splits(amount_msat, channels_with_funds) # 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)
self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations') self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations')
for s in split_configurations: for s in split_configurations:
self.logger.info(f"trying split configuration: {s[0].values()} rating: {s[1]}") self.logger.info(f"trying split configuration: {s[0].values()} rating: {s[1]}")
routes = [] routes = []
try: try:
if not self.channel_db: if not self.channel_db:
buckets = defaultdict(list) buckets = defaultdict(list)
for chan_id, part_amount_msat in s[0].items(): for (chan_id, _), part_amount_msat in s[0].items():
chan = self.channels[chan_id] chan = self.channels[chan_id]
if part_amount_msat: if part_amount_msat:
buckets[chan.node_id].append((chan_id, part_amount_msat)) buckets[chan.node_id].append((chan_id, part_amount_msat))
@ -1475,7 +1478,7 @@ class LNWallet(LNWorker):
self.logger.info('not enough margin to pay trampoline fee') self.logger.info('not enough margin to pay trampoline fee')
raise NoPathFound() raise NoPathFound()
else: else:
for chan_id, part_amount_msat in s[0].items(): for (chan_id, _), part_amount_msat in s[0].items():
if part_amount_msat: if part_amount_msat:
channel = self.channels[chan_id] channel = self.channels[chan_id]
route = self.create_route_for_payment( route = self.create_route_for_payment(
@ -1765,7 +1768,7 @@ class LNWallet(LNWorker):
self.logger.info(f"htlc_failed {failure_message}") self.logger.info(f"htlc_failed {failure_message}")
# check sent_buckets if we use trampoline # check sent_buckets if we use trampoline
if self.channel_db is None and payment_secret in self.sent_buckets: if not self.channel_db and payment_secret in self.sent_buckets:
amount_sent, amount_failed = self.sent_buckets[payment_secret] amount_sent, amount_failed = self.sent_buckets[payment_secret]
amount_failed += amount_receiver_msat amount_failed += amount_receiver_msat
self.sent_buckets[payment_secret] = amount_sent, amount_failed self.sent_buckets[payment_secret] = amount_sent, amount_failed

41
electrum/mpp_split.py

@ -1,6 +1,6 @@
import random import random
import math import math
from typing import List, Tuple, Optional, Sequence, Dict from typing import List, Tuple, Optional, Sequence, Dict, TYPE_CHECKING
from collections import defaultdict from collections import defaultdict
from .util import profiler from .util import profiler
@ -23,7 +23,7 @@ REDISTRIBUTE = 20
MAX_PARTS = 5 MAX_PARTS = 5
def unique_hierarchy(hierarchy: Dict[int, List[Dict[bytes, int]]]) -> Dict[int, List[Dict[bytes, int]]]: def unique_hierarchy(hierarchy: Dict[int, List[Dict[Tuple[bytes, bytes], int]]]) -> Dict[int, List[Dict[Tuple[bytes, bytes], int]]]:
new_hierarchy = defaultdict(list) new_hierarchy = defaultdict(list)
for number_parts, configs in hierarchy.items(): for number_parts, configs in hierarchy.items():
unique_configs = set() unique_configs = set()
@ -36,11 +36,26 @@ def unique_hierarchy(hierarchy: Dict[int, List[Dict[bytes, int]]]) -> Dict[int,
return new_hierarchy return new_hierarchy
def number_nonzero_parts(configuration: Dict[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
def number_nonzero_parts(configuration: Dict[Tuple[bytes, bytes], int]) -> int:
return len([v for v in configuration.values() if v]) return len([v for v in configuration.values() if v])
def create_starting_split_hierarchy(amount_msat: int, channels_with_funds: Dict[bytes, int]): def number_nonzero_nodes(configuration: Dict[Tuple[bytes, bytes], int]) -> int:
return len({nodeid for (_, nodeid), amount in configuration.items() if amount > 0})
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 """Distributes the amount to send to a single or more channels in several
ways (randomly).""" ways (randomly)."""
# TODO: find all possible starting configurations deterministically # TODO: find all possible starting configurations deterministically
@ -81,8 +96,8 @@ def balances_are_not_ok(proposed_balance_from, channel_from, proposed_balance_to
return check return check
def propose_new_configuration(channels_with_funds: Dict[bytes, int], configuration: Dict[bytes, int], 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[bytes, int]: amount_msat: int, preserve_number_parts=True) -> Dict[Tuple[bytes, bytes], int]:
"""Randomly alters a split configuration. If preserve_number_parts, the """Randomly alters a split configuration. If preserve_number_parts, the
configuration stays within the same class of number of splits.""" configuration stays within the same class of number of splits."""
@ -162,9 +177,13 @@ def propose_new_configuration(channels_with_funds: Dict[bytes, int], configurati
@profiler @profiler
def suggest_splits(amount_msat: int, channels_with_funds, exclude_single_parts=True) -> Sequence[Tuple[Dict[bytes, int], float]]: 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 """Creates split configurations for a payment over channels. Single channel
payments are excluded by default.""" payments are excluded by default. channels_with_funds is keyed by
(channelid, nodeid)."""
def rate_configuration(config: dict) -> float: def rate_configuration(config: dict) -> float:
"""Defines an objective function to rate a split configuration. """Defines an objective function to rate a split configuration.
@ -185,7 +204,7 @@ def suggest_splits(amount_msat: int, channels_with_funds, exclude_single_parts=T
return F return F
def rated_sorted_configurations(hierarchy: dict) -> Sequence[Tuple[Dict[bytes, int], float]]: def rated_sorted_configurations(hierarchy: dict) -> Sequence[Tuple[Dict[Tuple[bytes, bytes], int], float]]:
"""Cleans up duplicate splittings, rates and sorts them according to """Cleans up duplicate splittings, rates and sorts them according to
the rating. A lower rating is a better configuration.""" the rating. A lower rating is a better configuration."""
hierarchy = unique_hierarchy(hierarchy) hierarchy = unique_hierarchy(hierarchy)
@ -233,4 +252,8 @@ def suggest_splits(amount_msat: int, channels_with_funds, exclude_single_parts=T
except: except:
pass 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_sorted_configurations(split_hierarchy)

4
electrum/tests/test_lnpeer.py

@ -38,7 +38,7 @@ from electrum.invoices import PR_PAID, PR_UNPAID
from .test_lnchannel import create_test_channels from .test_lnchannel import create_test_channels
from .test_bitcoin import needs_test_with_all_chacha20_implementations from .test_bitcoin import needs_test_with_all_chacha20_implementations
from . import ElectrumTestCase from . import TestCaseForTestnet
def keypair(): def keypair():
priv = ECPrivkey.generate_random_key().get_secret_bytes() priv = ECPrivkey.generate_random_key().get_secret_bytes()
@ -303,7 +303,7 @@ class PaymentDone(Exception): pass
class TestSuccess(Exception): pass class TestSuccess(Exception): pass
class TestPeer(ElectrumTestCase): class TestPeer(TestCaseForTestnet):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

21
electrum/tests/test_mpp_split.py

@ -13,11 +13,12 @@ class TestMppSplit(ElectrumTestCase):
super().setUp() super().setUp()
# to make tests reproducible: # to make tests reproducible:
random.seed(0) random.seed(0)
# key tuple denotes (channel_id, node_id)
self.channels_with_funds = { self.channels_with_funds = {
0: 1_000_000_000, (0, 0): 1_000_000_000,
1: 500_000_000, (1, 1): 500_000_000,
2: 302_000_000, (2, 0): 302_000_000,
3: 101_000_000, (3, 2): 101_000_000,
} }
def tearDown(self): def tearDown(self):
@ -28,7 +29,7 @@ class TestMppSplit(ElectrumTestCase):
def test_suggest_splits(self): def test_suggest_splits(self):
with self.subTest(msg="do a payment with the maximal amount spendable over a single channel"): 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) splits = mpp_split.suggest_splits(1_000_000_000, self.channels_with_funds, exclude_single_parts=True)
self.assertEqual({0: 660_000_000, 1: 340_000_000, 2: 0, 3: 0}, splits[0][0]) self.assertEqual({(0, 0): 660_000_000, (1, 1): 340_000_000, (2, 0): 0, (3, 2): 0}, splits[0][0])
with self.subTest(msg="do a payment with a larger amount than what is supported by a single channel"): 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) splits = mpp_split.suggest_splits(1_100_000_000, self.channels_with_funds, exclude_single_parts=True)
@ -36,16 +37,22 @@ class TestMppSplit(ElectrumTestCase):
with self.subTest(msg="do a payment with the maximal amount spendable over all channels"): 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) 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]) 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])
with self.subTest(msg="do a payment with the amount supported by all channels"): 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) splits = mpp_split.suggest_splits(101_000_000, self.channels_with_funds, exclude_single_parts=False)
for s in splits[:4]: for s in splits[:4]:
self.assertEqual(1, mpp_split.number_nonzero_parts(s[0])) self.assertEqual(1, mpp_split.number_nonzero_parts(s[0]))
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])
for split in splits:
assert mpp_split.number_nonzero_nodes(split[0]) == 1
def test_saturation(self): def test_saturation(self):
"""Split configurations which spend the full amount in a channel should be avoided.""" """Split configurations which spend the full amount in a channel should be avoided."""
channels_with_funds = {0: 159_799_733_076, 1: 499_986_152_000} 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_parts=True)
uses_full_amount = False uses_full_amount = False

Loading…
Cancel
Save