lnworker: improve route creation
- Separates the trampoline and local routing multi-part payment cases. - Ask only for splits that don't send over a single channel (those have been tried already in the single-part case). - Makes sure that create_routes_for_payment only yields partial routes that belong to a single split configuration. - Tracks trampoline fee levels on a per node basis, previously, in the case of having two channels with a trampoline forwarder, the global fee level would have increased by two levels upon first try.
This commit is contained in:
@@ -8,7 +8,7 @@ from decimal import Decimal
|
||||
import random
|
||||
import time
|
||||
from typing import (Optional, Sequence, Tuple, List, Set, Dict, TYPE_CHECKING,
|
||||
NamedTuple, Union, Mapping, Any, Iterable, AsyncGenerator)
|
||||
NamedTuple, Union, Mapping, Any, Iterable, AsyncGenerator, DefaultDict)
|
||||
import threading
|
||||
import socket
|
||||
import aiohttp
|
||||
@@ -1148,7 +1148,7 @@ class LNWallet(LNWorker):
|
||||
raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'')
|
||||
|
||||
self.logs[payment_hash.hex()] = log = []
|
||||
trampoline_fee_level = self.INITIAL_TRAMPOLINE_FEE_LEVEL
|
||||
trampoline_fee_levels = defaultdict(lambda: self.INITIAL_TRAMPOLINE_FEE_LEVEL) # type: DefaultDict[bytes, int]
|
||||
use_two_trampolines = True # only used for pay to legacy
|
||||
|
||||
amount_inflight = 0 # what we sent in htlcs (that receiver gets, without fees)
|
||||
@@ -1168,7 +1168,7 @@ class LNWallet(LNWorker):
|
||||
full_path=full_path,
|
||||
payment_hash=payment_hash,
|
||||
payment_secret=payment_secret,
|
||||
trampoline_fee_level=trampoline_fee_level,
|
||||
trampoline_fee_levels=trampoline_fee_levels,
|
||||
use_two_trampolines=use_two_trampolines,
|
||||
fwd_trampoline_onion=fwd_trampoline_onion
|
||||
)
|
||||
@@ -1211,11 +1211,12 @@ class LNWallet(LNWorker):
|
||||
# if we get a channel update, we might retry the same route and amount
|
||||
route = htlc_log.route
|
||||
sender_idx = htlc_log.sender_idx
|
||||
erring_node_id = route[sender_idx].node_id
|
||||
failure_msg = htlc_log.failure_msg
|
||||
code, data = failure_msg.code, failure_msg.data
|
||||
self.logger.info(f"UPDATE_FAIL_HTLC. code={repr(code)}. "
|
||||
f"decoded_data={failure_msg.decode_data()}. data={data.hex()!r}")
|
||||
self.logger.info(f"error reported by {bh2u(route[sender_idx].node_id)}")
|
||||
self.logger.info(f"error reported by {bh2u(erring_node_id)}")
|
||||
if code == OnionFailureCode.MPP_TIMEOUT:
|
||||
raise PaymentFailure(failure_msg.code_name())
|
||||
# trampoline
|
||||
@@ -1227,7 +1228,7 @@ class LNWallet(LNWorker):
|
||||
if code in (OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT,
|
||||
OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON):
|
||||
# todo: parse the node parameters here (not returned by eclair yet)
|
||||
trampoline_fee_level += 1
|
||||
trampoline_fee_levels[erring_node_id] += 1
|
||||
continue
|
||||
elif use_two_trampolines:
|
||||
use_two_trampolines = False
|
||||
@@ -1447,7 +1448,7 @@ class LNWallet(LNWorker):
|
||||
invoice_features: int,
|
||||
payment_hash,
|
||||
payment_secret,
|
||||
trampoline_fee_level: int,
|
||||
trampoline_fee_levels: DefaultDict[bytes, int],
|
||||
use_two_trampolines: bool,
|
||||
fwd_trampoline_onion = None,
|
||||
full_path: LNPaymentPath = None) -> AsyncGenerator[Tuple[LNPaymentRoute, int], None]:
|
||||
@@ -1462,6 +1463,7 @@ class LNWallet(LNWorker):
|
||||
local_height = self.network.get_local_height()
|
||||
active_channels = [chan for chan in self.channels.values() if chan.is_active() and not chan.is_frozen_for_sending()]
|
||||
try:
|
||||
self.logger.info("trying single-part payment")
|
||||
# try to send over a single channel
|
||||
if not self.channel_db:
|
||||
for chan in active_channels:
|
||||
@@ -1486,7 +1488,7 @@ class LNWallet(LNWorker):
|
||||
payment_hash=payment_hash,
|
||||
payment_secret=payment_secret,
|
||||
local_height=local_height,
|
||||
trampoline_fee_level=trampoline_fee_level,
|
||||
trampoline_fee_levels=trampoline_fee_levels,
|
||||
use_two_trampolines=use_two_trampolines)
|
||||
trampoline_payment_secret = os.urandom(32)
|
||||
trampoline_total_msat = amount_with_fees
|
||||
@@ -1521,58 +1523,72 @@ class LNWallet(LNWorker):
|
||||
)
|
||||
yield route, amount_msat, final_total_msat, amount_msat, min_cltv_expiry, payment_secret, fwd_trampoline_onion
|
||||
except NoPathFound: # fall back to payment splitting
|
||||
self.logger.info("no path found, trying multi-part payment")
|
||||
if not invoice_features.supports(LnFeatures.BASIC_MPP_OPT):
|
||||
raise
|
||||
channels_with_funds = {
|
||||
(chan.channel_id, chan.node_id): int(chan.available_to_spend(HTLCOwner.LOCAL))
|
||||
for chan in active_channels}
|
||||
self.logger.info(f"channels_with_funds: {channels_with_funds}")
|
||||
# for trampoline mpp payments we have to restrict ourselves to pay
|
||||
# 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, exclude_multinode_payments=use_singe_node)
|
||||
self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations')
|
||||
|
||||
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)
|
||||
if not self.channel_db:
|
||||
# for trampoline mpp payments we have to restrict ourselves to pay
|
||||
# to a single node due to some incompatibility in Eclair, see:
|
||||
# https://github.com/ACINQ/eclair/issues/1723
|
||||
use_singe_node = constants.net is constants.BitcoinMainnet
|
||||
split_configurations = suggest_splits(
|
||||
amount_msat,
|
||||
channels_with_funds,
|
||||
exclude_multinode_payments=use_singe_node,
|
||||
exclude_single_part_payments=True,
|
||||
# we don't split within a channel when sending to a trampoline node,
|
||||
# the trampoline node will split for us
|
||||
exclude_single_channel_splits=True,
|
||||
)
|
||||
self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations')
|
||||
|
||||
for sc in split_configurations:
|
||||
try:
|
||||
self.logger.info(f"trying split configuration: {sc.config.values()} rating: {sc.rating}")
|
||||
per_trampoline_channel_amounts = defaultdict(list)
|
||||
# categorize by trampoline nodes for trampolin mpp construction
|
||||
for (chan_id, _), part_amounts_msat in sc.config.items():
|
||||
chan = self.channels[chan_id]
|
||||
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])
|
||||
trampoline_onion, bucket_amount_with_fees, bucket_cltv_delta = create_trampoline_route_and_onion(
|
||||
amount_msat=bucket_amount_msat,
|
||||
per_trampoline_channel_amounts[chan.node_id].append((chan_id, part_amount_msat))
|
||||
# for each trampoline forwarder, construct mpp trampoline
|
||||
routes = []
|
||||
for trampoline_node_id, trampoline_parts in per_trampoline_channel_amounts.items():
|
||||
per_trampoline_amount = sum([x[1] for x in trampoline_parts])
|
||||
trampoline_onion, per_trampoline_amount_with_fees, per_trampoline_cltv_delta = create_trampoline_route_and_onion(
|
||||
amount_msat=per_trampoline_amount,
|
||||
total_msat=final_total_msat,
|
||||
min_cltv_expiry=min_cltv_expiry,
|
||||
my_pubkey=self.node_keypair.pubkey,
|
||||
invoice_pubkey=invoice_pubkey,
|
||||
invoice_features=invoice_features,
|
||||
node_id=node_id,
|
||||
node_id=trampoline_node_id,
|
||||
r_tags=r_tags,
|
||||
payment_hash=payment_hash,
|
||||
payment_secret=payment_secret,
|
||||
local_height=local_height,
|
||||
trampoline_fee_level=trampoline_fee_level,
|
||||
trampoline_fee_levels=trampoline_fee_levels,
|
||||
use_two_trampolines=use_two_trampolines)
|
||||
# node_features is only used to determine is_tlv
|
||||
bucket_payment_secret = os.urandom(32)
|
||||
bucket_fees = bucket_amount_with_fees - bucket_amount_msat
|
||||
self.logger.info(f'bucket_fees {bucket_fees}')
|
||||
for chan_id, part_amount_msat in bucket:
|
||||
per_trampoline_secret = os.urandom(32)
|
||||
per_trampoline_fees = per_trampoline_amount_with_fees - per_trampoline_amount
|
||||
self.logger.info(f'per trampoline fees: {per_trampoline_fees}')
|
||||
for chan_id, part_amount_msat in trampoline_parts:
|
||||
chan = self.channels[chan_id]
|
||||
margin = chan.available_to_spend(LOCAL, strict=True) - part_amount_msat
|
||||
delta_fee = min(bucket_fees, margin)
|
||||
delta_fee = min(per_trampoline_fees, margin)
|
||||
# TODO: distribute trampoline fee over several channels?
|
||||
part_amount_msat_with_fees = part_amount_msat + delta_fee
|
||||
bucket_fees -= delta_fee
|
||||
per_trampoline_fees -= delta_fee
|
||||
route = [
|
||||
RouteEdge(
|
||||
start_node=self.node_keypair.pubkey,
|
||||
end_node=node_id,
|
||||
end_node=trampoline_node_id,
|
||||
short_channel_id=chan.short_channel_id,
|
||||
fee_base_msat=0,
|
||||
fee_proportional_millionths=0,
|
||||
@@ -1580,14 +1596,32 @@ class LNWallet(LNWorker):
|
||||
node_features=trampoline_features)
|
||||
]
|
||||
self.logger.info(f'adding route {part_amount_msat} {delta_fee} {margin}')
|
||||
yield route, part_amount_msat_with_fees, bucket_amount_with_fees, part_amount_msat, bucket_cltv_delta, bucket_payment_secret, trampoline_onion
|
||||
if bucket_fees != 0:
|
||||
routes.append((route, part_amount_msat_with_fees, per_trampoline_amount_with_fees, part_amount_msat, per_trampoline_cltv_delta, per_trampoline_secret, trampoline_onion))
|
||||
if per_trampoline_fees != 0:
|
||||
self.logger.info('not enough margin to pay trampoline fee')
|
||||
raise NoPathFound()
|
||||
else:
|
||||
for (chan_id, _), part_amounts_msat in sc.config.items():
|
||||
for part_amount_msat in part_amounts_msat:
|
||||
channel = self.channels[chan_id]
|
||||
for route in routes:
|
||||
yield route
|
||||
return
|
||||
except NoPathFound:
|
||||
continue
|
||||
else:
|
||||
split_configurations = suggest_splits(
|
||||
amount_msat,
|
||||
channels_with_funds,
|
||||
exclude_single_part_payments=True,
|
||||
)
|
||||
# We atomically loop through a split configuration. If there was
|
||||
# a failure to find a path for a single part, we give back control
|
||||
# after exhausting the split configuration.
|
||||
yielded_from_split_configuration = False
|
||||
self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations')
|
||||
for sc in split_configurations:
|
||||
self.logger.info(f"trying split configuration: {list(sc.config.values())} rating: {sc.rating}")
|
||||
for (chan_id, _), part_amounts_msat in sc.config.items():
|
||||
for part_amount_msat in part_amounts_msat:
|
||||
channel = self.channels[chan_id]
|
||||
try:
|
||||
route = await run_in_thread(
|
||||
partial(
|
||||
self.create_route_for_payment,
|
||||
@@ -1601,12 +1635,12 @@ 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(sc.config.values())} rating: {sc.rating}")
|
||||
break
|
||||
except NoPathFound:
|
||||
continue
|
||||
else:
|
||||
raise NoPathFound()
|
||||
yielded_from_split_configuration = True
|
||||
except NoPathFound:
|
||||
continue
|
||||
if yielded_from_split_configuration:
|
||||
return
|
||||
raise NoPathFound()
|
||||
|
||||
@profiler
|
||||
def create_route_for_payment(
|
||||
|
||||
@@ -86,6 +86,16 @@ def remove_single_part_configs(configs: List[SplitConfig]) -> List[SplitConfig]:
|
||||
return [config for config in configs if number_parts(config) != 1]
|
||||
|
||||
|
||||
def remove_single_channel_splits(configs: List[SplitConfig]) -> List[SplitConfig]:
|
||||
filtered = []
|
||||
for config in configs:
|
||||
for v in config.values():
|
||||
if len(v) > 1:
|
||||
continue
|
||||
filtered.append(config)
|
||||
return filtered
|
||||
|
||||
|
||||
def rate_config(
|
||||
config: SplitConfig,
|
||||
channels_with_funds: ChannelsFundsInfo) -> float:
|
||||
@@ -113,7 +123,8 @@ def rate_config(
|
||||
def suggest_splits(
|
||||
amount_msat: int, channels_with_funds: ChannelsFundsInfo,
|
||||
exclude_single_part_payments=False,
|
||||
exclude_multinode_payments=False
|
||||
exclude_multinode_payments=False,
|
||||
exclude_single_channel_splits=False
|
||||
) -> List[SplitConfigRating]:
|
||||
"""Breaks amount_msat into smaller pieces and distributes them over the
|
||||
channels according to the funds they can send.
|
||||
@@ -172,6 +183,9 @@ def suggest_splits(
|
||||
if exclude_single_part_payments:
|
||||
configs = remove_single_part_configs(configs)
|
||||
|
||||
if exclude_single_channel_splits:
|
||||
configs = remove_single_channel_splits(configs)
|
||||
|
||||
rated_configs = [SplitConfigRating(
|
||||
config=c,
|
||||
rating=rate_config(c, channels_with_funds)
|
||||
|
||||
@@ -204,7 +204,7 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]):
|
||||
min_cltv_expiry=decoded_invoice.get_min_final_cltv_expiry(),
|
||||
r_tags=decoded_invoice.get_routing_info('r'),
|
||||
invoice_features=decoded_invoice.get_features(),
|
||||
trampoline_fee_level=0,
|
||||
trampoline_fee_levels=defaultdict[int],
|
||||
use_two_trampolines=False,
|
||||
payment_hash=decoded_invoice.paymenthash,
|
||||
payment_secret=decoded_invoice.payment_secret,
|
||||
|
||||
@@ -122,3 +122,7 @@ class TestMppSplit(ElectrumTestCase):
|
||||
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)]))
|
||||
with self.subTest(msg="exclude all single channel splits"):
|
||||
mpp_split.PART_PENALTY = 0.3
|
||||
splits = mpp_split.suggest_splits(1_000_000_000, channels_with_funds, exclude_single_channel_splits=True)
|
||||
self.assertEqual(1, len(splits[0].config[(0, 0)]))
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import bitstring
|
||||
import random
|
||||
|
||||
from typing import Mapping
|
||||
from typing import Mapping, DefaultDict
|
||||
|
||||
from .logging import get_logger, Logger
|
||||
from .lnutil import LnFeatures
|
||||
@@ -107,7 +107,7 @@ def create_trampoline_route(
|
||||
my_pubkey: bytes,
|
||||
trampoline_node_id: bytes, # the first trampoline in the path; which we are directly connected to
|
||||
r_tags,
|
||||
trampoline_fee_level: int,
|
||||
trampoline_fee_levels: DefaultDict[bytes, int],
|
||||
use_two_trampolines: bool) -> LNPaymentRoute:
|
||||
|
||||
# figure out whether we can use end-to-end trampoline, or fallback to pay-to-legacy
|
||||
@@ -140,7 +140,8 @@ def create_trampoline_route(
|
||||
if pubkey == TRAMPOLINE_NODES_MAINNET['ACINQ'].pubkey:
|
||||
is_legacy = True
|
||||
use_two_trampolines = False
|
||||
# fee level. the same fee is used for all trampolines
|
||||
# fee level
|
||||
trampoline_fee_level = trampoline_fee_levels[trampoline_node_id]
|
||||
if trampoline_fee_level < len(TRAMPOLINE_FEES):
|
||||
params = TRAMPOLINE_FEES[trampoline_fee_level]
|
||||
else:
|
||||
@@ -269,7 +270,7 @@ def create_trampoline_route_and_onion(
|
||||
payment_hash,
|
||||
payment_secret,
|
||||
local_height:int,
|
||||
trampoline_fee_level: int,
|
||||
trampoline_fee_levels: DefaultDict[bytes, int],
|
||||
use_two_trampolines: bool):
|
||||
# create route for the trampoline_onion
|
||||
trampoline_route = create_trampoline_route(
|
||||
@@ -280,7 +281,7 @@ def create_trampoline_route_and_onion(
|
||||
invoice_features=invoice_features,
|
||||
trampoline_node_id=node_id,
|
||||
r_tags=r_tags,
|
||||
trampoline_fee_level=trampoline_fee_level,
|
||||
trampoline_fee_levels=trampoline_fee_levels,
|
||||
use_two_trampolines=use_two_trampolines)
|
||||
# compute onion and fees
|
||||
final_cltv = local_height + min_cltv_expiry
|
||||
|
||||
Reference in New Issue
Block a user