diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 74f0b392f..0b345bfc8 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1478,6 +1478,7 @@ class Peer(Logger): invoice_features = payload["invoice_features"]["invoice_features"] invoice_routing_info = payload["invoice_routing_info"]["invoice_routing_info"] # TODO use invoice_routing_info + # TODO legacy mpp payment, use total_msat from trampoline onion else: self.logger.info('forward_trampoline: end-to-end') invoice_features = LnFeatures.BASIC_MPP_OPT diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 052f695f8..0c643e7e6 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -80,7 +80,7 @@ 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 -from .trampoline import create_trampoline_route_and_onion, TRAMPOLINE_FEES +from .trampoline import create_trampoline_route_and_onion, TRAMPOLINE_FEES, is_legacy_relay if TYPE_CHECKING: from .network import Network @@ -1157,8 +1157,13 @@ class LNWallet(LNWorker): raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') self.logs[payment_hash.hex()] = log = [] - trampoline_fee_levels = defaultdict(lambda: self.INITIAL_TRAMPOLINE_FEE_LEVEL) # type: DefaultDict[bytes, int] - use_two_trampolines = True # only used for pay to legacy + + # when encountering trampoline forwarding difficulties in the legacy case, we + # sometimes need to fall back to a single trampoline forwarder, at the expense + # of privacy + use_two_trampolines = True + + trampoline_fee_level = self.INITIAL_TRAMPOLINE_FEE_LEVEL amount_inflight = 0 # what we sent in htlcs (that receiver gets, without fees) while True: @@ -1177,7 +1182,7 @@ class LNWallet(LNWorker): full_path=full_path, payment_hash=payment_hash, payment_secret=payment_secret, - trampoline_fee_levels=trampoline_fee_levels, + trampoline_fee_level=trampoline_fee_level, use_two_trampolines=use_two_trampolines, fwd_trampoline_onion=fwd_trampoline_onion ) @@ -1236,8 +1241,10 @@ class LNWallet(LNWorker): # instead we should give feedback to create_routes_for_payment. 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_levels[erring_node_id] += 1 + # TODO: parse the node policy here (not returned by eclair yet) + # TODO: erring node is always the first trampoline even if second + # trampoline demands more fees, we can't influence this + trampoline_fee_level += 1 continue elif use_two_trampolines: use_two_trampolines = False @@ -1457,9 +1464,9 @@ class LNWallet(LNWorker): invoice_features: int, payment_hash, payment_secret, - trampoline_fee_levels: DefaultDict[bytes, int], + trampoline_fee_level: int, use_two_trampolines: bool, - fwd_trampoline_onion = None, + fwd_trampoline_onion=None, full_path: LNPaymentPath = None) -> AsyncGenerator[Tuple[LNPaymentRoute, int], None]: """Creates multiple routes for splitting a payment over the available @@ -1498,7 +1505,7 @@ class LNWallet(LNWorker): payment_hash=payment_hash, payment_secret=payment_secret, local_height=local_height, - trampoline_fee_levels=trampoline_fee_levels, + trampoline_fee_level=trampoline_fee_level, use_two_trampolines=use_two_trampolines) trampoline_payment_secret = os.urandom(32) trampoline_total_msat = amount_with_fees @@ -1541,14 +1548,13 @@ class LNWallet(LNWorker): self.logger.info(f"channels_with_funds: {channels_with_funds}") 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 + # in the case of a legacy payment, we don't allow splitting via different + # trampoline nodes, as currently no forwarder supports this + use_single_node, _ = is_legacy_relay(invoice_features, r_tags) split_configurations = suggest_splits( amount_msat, channels_with_funds, - exclude_multinode_payments=use_singe_node, + exclude_multinode_payments=use_single_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 @@ -1581,7 +1587,7 @@ class LNWallet(LNWorker): payment_hash=payment_hash, payment_secret=payment_secret, local_height=local_height, - trampoline_fee_levels=trampoline_fee_levels, + trampoline_fee_level=trampoline_fee_level, use_two_trampolines=use_two_trampolines) # node_features is only used to determine is_tlv per_trampoline_secret = os.urandom(32) diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index 7d1f5df81..760c53285 100644 --- a/electrum/tests/test_lnpeer.py +++ b/electrum/tests/test_lnpeer.py @@ -205,7 +205,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_levels=defaultdict(int), + trampoline_fee_level=0, use_two_trampolines=False, payment_hash=decoded_invoice.paymenthash, payment_secret=decoded_invoice.payment_secret, @@ -463,7 +463,8 @@ class TestPeer(TestCaseForTestnet): channels=channels, ) for a in workers: - print(f"{a} -> pubkey {keys[a].pubkey}") + print(f"{a:5s}: {keys[a].pubkey}") + print(f" {keys[a].pubkey.hex()}") return graph @staticmethod @@ -880,10 +881,13 @@ class TestPeer(TestCaseForTestnet): attempts=1, alice_uses_trampoline=False, bob_forwarding=True, - mpp_invoice=True + mpp_invoice=True, + disable_trampoline_receiving=False, ): if mpp_invoice: graph.workers['dave'].features |= LnFeatures.BASIC_MPP_OPT + if disable_trampoline_receiving: + graph.workers['dave'].features &= ~LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT if not bob_forwarding: graph.workers['bob'].enable_htlc_forwarding = False if alice_uses_trampoline: @@ -914,10 +918,12 @@ class TestPeer(TestCaseForTestnet): await asyncio.sleep(0.2) await group.spawn(pay(**kwargs)) - with self.assertRaises(NoPathFound): - run(f(fail_kwargs)) - with self.assertRaises(PaymentDone): - run(f(success_kwargs)) + if fail_kwargs: + with self.assertRaises(NoPathFound): + run(f(fail_kwargs)) + if success_kwargs: + with self.assertRaises(PaymentDone): + run(f(success_kwargs)) @needs_test_with_all_chacha20_implementations def test_payment_multipart_with_timeout(self): @@ -975,18 +981,37 @@ class TestPeer(TestCaseForTestnet): run(f()) @needs_test_with_all_chacha20_implementations - def test_payment_multipart_trampoline(self): - # single attempt will fail with insufficient trampoline fee + def test_payment_multipart_trampoline_e2e(self): + graph = self.prepare_chans_and_peers_in_graph(GRAPH_DEFINITIONS['square_graph']) + electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = { + graph.workers['bob'].name: LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers['bob'].node_keypair.pubkey), + graph.workers['carol'].name: LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers['carol'].node_keypair.pubkey), + } + try: + # end-to-end trampoline: we attempt + # * a payment with one trial: fails, because + # we need at least one trial because the initial fees are too low + # * a payment with several trials: should succeed + self._run_mpp( + graph, + fail_kwargs={'alice_uses_trampoline': True, 'attempts': 1}, + success_kwargs={'alice_uses_trampoline': True, 'attempts': 30}) + finally: + electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = {} + + @needs_test_with_all_chacha20_implementations + def test_payment_multipart_trampoline_legacy(self): graph = self.prepare_chans_and_peers_in_graph(GRAPH_DEFINITIONS['square_graph']) electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = { graph.workers['bob'].name: LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers['bob'].node_keypair.pubkey), graph.workers['carol'].name: LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers['carol'].node_keypair.pubkey), } try: + # trampoline-to-legacy: this is restricted, as there are no forwarders capable of doing this self._run_mpp( graph, - {'alice_uses_trampoline': True, 'attempts': 1}, - {'alice_uses_trampoline': True, 'attempts': 30}) + fail_kwargs={'alice_uses_trampoline': True, 'attempts': 30, 'disable_trampoline_receiving': True}, + success_kwargs={}) finally: electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = {} diff --git a/electrum/trampoline.py b/electrum/trampoline.py index b9a9127d9..7879427cd 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -2,7 +2,7 @@ import os import bitstring import random -from typing import Mapping, DefaultDict +from typing import Mapping, DefaultDict, Tuple, Optional, Dict, List from .logging import get_logger, Logger from .lnutil import LnFeatures @@ -63,6 +63,7 @@ TRAMPOLINE_NODES_MAINNET = { TRAMPOLINE_NODES_TESTNET = { 'endurance': LNPeerAddr(host='34.250.234.192', port=9735, pubkey=bytes.fromhex('03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134')), + 'Electrum trampoline': LNPeerAddr(host='lightning.electrum.org', port=9739, pubkey=bytes.fromhex('02bf82e22f99dcd7ac1de4aad5152ce48f0694c46ec582567f379e0adbf81e2d0f')), } TRAMPOLINE_NODES_SIGNET = { @@ -99,27 +100,22 @@ def encode_routing_info(r_tags): return result.tobytes() -def create_trampoline_route( - *, - amount_msat:int, - min_cltv_expiry:int, - invoice_pubkey:bytes, - invoice_features:int, - my_pubkey: bytes, - trampoline_node_id: bytes, # the first trampoline in the path; which we are directly connected to - r_tags, - 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 - is_legacy = True - r_tag_chosen_for_e2e_trampoline = None +def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, Optional[bytes]]: + """Returns if we deal with a legacy payment and gives back the possible last + trampoline pubkey. + """ invoice_features = LnFeatures(invoice_features) + # trampoline-supporting wallets: + # OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR: these are Phoenix/Eclair wallets + # OPTION_TRAMPOLINE_ROUTING_OPT: these are Electrum wallets if (invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT) or invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR)): - if not r_tags: # presumably the recipient has public channels - is_legacy = False - pubkey = trampoline_node_id + # If there are no r_tags (routing hints) included, the wallet doesn't have + # private channels and is probably directly connected to a trampoline node. + # Any trampoline node should be able to figure out a path to the receiver and + # we can use an e2e payment. + if not r_tags: + return False, None else: # - We choose one routing hint at random, and # use end-to-end trampoline if that node is a trampoline-forwarder (TF). @@ -129,98 +125,98 @@ def create_trampoline_route( # endpoints connected to T1 and T2, and sender only has send-capacity with T1, while # recipient only has recv-capacity with T2. singlehop_r_tags = [x for x in r_tags if len(x) == 1] - r_tag_chosen_for_e2e_trampoline = random.choice(singlehop_r_tags)[0] - pubkey, scid, feebase, feerate, cltv = r_tag_chosen_for_e2e_trampoline - is_legacy = not is_hardcoded_trampoline(pubkey) - # Temporary fix: until ACINQ uses a proper feature bit to detect Phoenix, - # they might try to open channels when payments fail. The ACINQ node does this - # if it is directly connected to the recipient but without enough sending capacity. - # They send a custom "pay-to-open-request", and wait 60+ sec for the recipient to respond. - # Effectively, they hold the HTLC for minutes before failing it. - # see: https://github.com/ACINQ/lightning-kmp/pull/237 - if pubkey == TRAMPOLINE_NODES_MAINNET['ACINQ'].pubkey: - is_legacy = True - use_two_trampolines = False - # fee level - trampoline_fee_level = trampoline_fee_levels[trampoline_node_id] + forwarder_pubkey = random.choice(singlehop_r_tags)[0][0] + if is_hardcoded_trampoline(forwarder_pubkey): + return False, forwarder_pubkey + # if trampoline receiving is not supported or the forwarder is not known as a trampoline, + # we send a legacy payment + return True, None + + +def trampoline_policy( + trampoline_fee_level: int, +) -> Dict: + """Return the fee policy for all trampoline nodes. + + Raises NoPathFound if the fee level is exhausted.""" + # TODO: ideally we want to use individual fee levels for each trampoline node, + # but because at the moment we can't attribute insufficient fee errors to + # downstream trampolines we need to use a global fee level here if trampoline_fee_level < len(TRAMPOLINE_FEES): - params = TRAMPOLINE_FEES[trampoline_fee_level] + return TRAMPOLINE_FEES[trampoline_fee_level] else: raise NoPathFound() - # add optional second trampoline - trampoline2 = None - if is_legacy and use_two_trampolines: - trampoline2_list = list(trampolines_by_id().keys()) - random.shuffle(trampoline2_list) - for node_id in trampoline2_list: - if node_id != trampoline_node_id: - trampoline2 = node_id - break - # node_features is only used to determine is_tlv + + +def extend_trampoline_route( + route: List, + start_node: bytes, + end_node: bytes, + trampoline_fee_level: int, + pay_fees=True +): + """Extends the route and modifies it in place.""" trampoline_features = LnFeatures.VAR_ONION_OPT - # hop to trampoline - route = [] - # trampoline hop + policy = trampoline_policy(trampoline_fee_level) route.append( TrampolineEdge( - start_node=my_pubkey, - end_node=trampoline_node_id, - fee_base_msat=params['fee_base_msat'], - fee_proportional_millionths=params['fee_proportional_millionths'], - cltv_expiry_delta=params['cltv_expiry_delta'], + start_node=start_node, + end_node=end_node, + fee_base_msat=policy['fee_base_msat'] if pay_fees else 0, + fee_proportional_millionths=policy['fee_proportional_millionths'] if pay_fees else 0, + cltv_expiry_delta=policy['cltv_expiry_delta'] if pay_fees else 0, node_features=trampoline_features)) - if trampoline2: - route.append( - TrampolineEdge( - start_node=trampoline_node_id, - end_node=trampoline2, - fee_base_msat=params['fee_base_msat'], - fee_proportional_millionths=params['fee_proportional_millionths'], - cltv_expiry_delta=params['cltv_expiry_delta'], - node_features=trampoline_features)) - # add routing info + + +def create_trampoline_route( + *, + amount_msat: int, + min_cltv_expiry: int, + invoice_pubkey: bytes, + invoice_features: int, + 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, + use_two_trampolines: bool +) -> LNPaymentRoute: + # we decide whether to convert to a legacy payment + is_legacy, second_trampoline_pubkey = is_legacy_relay(invoice_features, r_tags) + + # we build a route of trampoline hops and extend the route list in place + route = [] + + # our first trampoline hop is decided by the channel we use + extend_trampoline_route(route, my_pubkey, trampoline_node_id, trampoline_fee_level) + if is_legacy: + # we add another different trampoline hop for privacy + if use_two_trampolines: + trampolines = trampolines_by_id() + del trampolines[trampoline_node_id] + second_trampoline_pubkey = random.choice(list(trampolines.keys())) + extend_trampoline_route(route, trampoline_node_id, second_trampoline_pubkey, trampoline_fee_level) + + # the last trampoline onion must contain routing hints for the last trampoline + # node to find the recipient invoice_routing_info = encode_routing_info(r_tags) route[-1].invoice_routing_info = invoice_routing_info route[-1].invoice_features = invoice_features route[-1].outgoing_node_id = invoice_pubkey - else: # end-to-end trampoline - if r_tag_chosen_for_e2e_trampoline: - pubkey = r_tag_chosen_for_e2e_trampoline[0] - if route[-1].end_node != pubkey: - # We don't use the forwarding policy from the route hint, which - # is only valid for legacy forwarding. Trampoline forwarders require - # higher fees and cltv deltas. - trampoline_fee_level = trampoline_fee_levels[pubkey] - if trampoline_fee_level < len(TRAMPOLINE_FEES): - fee_policy = TRAMPOLINE_FEES[trampoline_fee_level] - route.append( - TrampolineEdge( - start_node=route[-1].end_node, - end_node=pubkey, - fee_base_msat=fee_policy['fee_base_msat'], - fee_proportional_millionths=fee_policy['fee_proportional_millionths'], - cltv_expiry_delta=fee_policy['cltv_expiry_delta'], - node_features=trampoline_features)) - - # Final edge (not part of the route if payment is legacy, but eclair requires an encrypted blob) - route.append( - TrampolineEdge( - start_node=route[-1].end_node, - end_node=invoice_pubkey, - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_expiry_delta=0, - node_features=trampoline_features)) + else: + if second_trampoline_pubkey and trampoline_node_id != second_trampoline_pubkey: + extend_trampoline_route(route, trampoline_node_id, second_trampoline_pubkey, trampoline_fee_level) + + # final edge (not part of the route if payment is legacy, but eclair requires an encrypted blob) + extend_trampoline_route(route, route[-1].end_node, invoice_pubkey, trampoline_fee_level, pay_fees=False) + # check that we can pay amount and fees for edge in route[::-1]: amount_msat += edge.fee_for_edge(amount_msat) if not is_route_sane_to_use(route, amount_msat, min_cltv_expiry): - raise NoPathFound() - _logger.info(f'created route with trampoline: fee_level={trampoline_fee_level}, is legacy: {is_legacy}') - _logger.info(f'first trampoline: {trampoline_node_id.hex()}') - _logger.info(f'second trampoline: {trampoline2.hex() if trampoline2 else None}') - _logger.info(f'params: {params}') + raise NoPathFound("We cannot afford to pay the fees.") + _logger.info(f'created route with trampoline fee level={trampoline_fee_level}, is legacy: {is_legacy}') + _logger.info(f'trampoline hops: {[hop.end_node.hex() for hop in route]}') return route @@ -276,8 +272,8 @@ def create_trampoline_route_and_onion( r_tags, payment_hash, payment_secret, - local_height:int, - trampoline_fee_levels: DefaultDict[bytes, int], + local_height: int, + trampoline_fee_level: int, use_two_trampolines: bool): # create route for the trampoline_onion trampoline_route = create_trampoline_route( @@ -288,7 +284,7 @@ def create_trampoline_route_and_onion( invoice_features=invoice_features, trampoline_node_id=node_id, r_tags=r_tags, - trampoline_fee_levels=trampoline_fee_levels, + trampoline_fee_level=trampoline_fee_level, use_two_trampolines=use_two_trampolines) # compute onion and fees final_cltv = local_height + min_cltv_expiry