From df5c8c4c98adee8cd477d54afc4a98beb4100639 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Fri, 13 Feb 2026 12:19:09 +0100 Subject: [PATCH 1/3] create_routes_for_payment: allow trampoline forwarding without channel_db if there is a direct path --- electrum/lnworker.py | 58 +++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 9e90f389f..9fee73cb4 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -2401,7 +2401,10 @@ class LNWallet(Logger): self.logger.info(f"trying split configuration: {sc.config.values()} rating: {sc.rating}") routes = [] try: - if self.uses_trampoline(): + is_direct_path = all(node_id == paysession.invoice_pubkey for (chan_id, node_id) in sc.config.keys()) + if self.uses_trampoline() and not is_direct_path: + if fwd_trampoline_onion: + raise NoPathFound() per_trampoline_channel_amounts = defaultdict(list) # categorize by trampoline nodes for trampoline mpp construction for (chan_id, _), part_amounts_msat in sc.config.items(): @@ -2473,19 +2476,28 @@ class LNWallet(Logger): 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( + if is_direct_path: + route = self.create_direct_route( + amount_msat=part_amount_msat, + channel=channel, + ) + else: + assert not self.uses_trampoline() + route = await run_in_thread(partial( self.create_route_for_single_htlc, amount_msat=part_amount_msat, invoice_pubkey=paysession.invoice_pubkey, - min_final_cltv_delta=paysession.min_final_cltv_delta, r_tags=paysession.r_tags, invoice_features=paysession.invoice_features, my_sending_channels=[channel] if is_multichan_mpp else my_active_channels, full_path=full_path, - budget=budget._replace(fee_msat=budget.fee_msat // sc.config.number_parts()), - ) - ) + )) + if not is_route_within_budget( + route, budget=budget, + amount_msat_for_dest=amount_msat, + cltv_delta_for_dest=paysession.min_final_cltv_delta): + self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}") + raise FeeBudgetExceeded() shi = SentHtlcInfo( route=route, payment_secret_orig=paysession.payment_secret, @@ -2509,17 +2521,40 @@ class LNWallet(Logger): raise fee_related_error raise NoPathFound() + def create_direct_route( + self, *, + amount_msat: int, # that final receiver gets + channel: Channel, + ) -> LNPaymentRoute: + self.logger.info(f'create_direct_route {channel.node_id.hex()}') + my_sending_channels = {channel.short_channel_id: channel} + channel_policy = get_mychannel_policy( + short_channel_id=channel.short_channel_id, + node_id=self.node_keypair.pubkey, + my_channels=my_sending_channels) + fee_base_msat = channel_policy.fee_base_msat + fee_proportional_millionths = channel_policy.fee_proportional_millionths + cltv_delta = channel_policy.cltv_delta + route_edge = RouteEdge( + start_node=self.node_keypair.pubkey, + end_node=channel.node_id, + short_channel_id=channel.short_channel_id, + fee_base_msat=fee_base_msat, + fee_proportional_millionths=fee_proportional_millionths, + cltv_delta=cltv_delta, + node_features=0) + route = [route_edge] + return route + @profiler def create_route_for_single_htlc( self, *, amount_msat: int, # that final receiver gets invoice_pubkey: bytes, - min_final_cltv_delta: int, r_tags, invoice_features: int, my_sending_channels: List[Channel], full_path: Optional[LNPaymentPath], - budget: PaymentFeeBudget, ) -> LNPaymentRoute: my_sending_aliases = set(chan.get_local_scid_alias() for chan in my_sending_channels) @@ -2579,11 +2614,6 @@ class LNWallet(Logger): raise NoPathFound() from e if not route: raise NoPathFound() - if not is_route_within_budget( - route, budget=budget, amount_msat_for_dest=amount_msat, cltv_delta_for_dest=min_final_cltv_delta, - ): - self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}") - raise FeeBudgetExceeded() assert len(route) > 0 if route[-1].end_node != invoice_pubkey: raise LNPathInconsistent("last node_id != invoice pubkey") From f3a8dd61bb888668960713a104ab6cef775ab5bd Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 12 Feb 2026 10:38:51 +0100 Subject: [PATCH 2/3] lazy trampoline: If a trampoline forwarder fails to find a path, it may return a list of trampolines it knows how to reach, so that clients can add these trampolines to their route. The list of trampolines and fees is written in the error data of the 'update_fail_htlc' message. --- electrum/lnworker.py | 68 ++++++++++++++++++----------- electrum/trampoline.py | 97 +++++++++++++++++++++++++++++------------- tests/test_lnpeer.py | 50 ++++++++++++++++++---- 3 files changed, 152 insertions(+), 63 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 9fee73cb4..77484f6d0 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -90,7 +90,7 @@ from .submarine_swaps import SwapManager from .mpp_split import suggest_splits, SplitConfigRating from .trampoline import ( create_trampoline_route_and_onion, is_legacy_relay, trampolines_by_id, hardcoded_trampoline_nodes, - is_hardcoded_trampoline, decode_routing_info + is_hardcoded_trampoline, decode_routing_info, encode_next_trampolines, decode_next_trampolines ) if TYPE_CHECKING: @@ -860,7 +860,6 @@ class PaySession(Logger): amount_to_pay: int, # total payment amount final receiver will get invoice_pubkey: bytes, uses_trampoline: bool, # whether sender uses trampoline or gossip - use_two_trampolines: bool, # whether legacy payments will try to use two trampolines ): assert payment_hash assert payment_secret @@ -881,7 +880,7 @@ class PaySession(Logger): self.uses_trampoline = uses_trampoline self.trampoline_fee_level = initial_trampoline_fee_level self.failed_trampoline_routes = [] - self.use_two_trampolines = use_two_trampolines + self.next_trampolines = dict() # node_id -> next_trampoline -> tuple self._sent_buckets = dict() # psecret_bucket -> (amount_sent, amount_failed) self._amount_inflight = 0 # what we sent in htlcs (that receiver gets, without fees) @@ -904,7 +903,7 @@ class PaySession(Logger): else: self.logger.info(f'NOT raising trampoline fee level, already at {self.trampoline_fee_level}') - def handle_failed_trampoline_htlc(self, *, htlc_log: HtlcLog, failure_msg: OnionRoutingFailure): + def handle_failed_trampoline_htlc(self, *, node_id, htlc_log: HtlcLog, failure_msg: OnionRoutingFailure): # FIXME The trampoline nodes in the path are chosen randomly. # Some of the errors might depend on how we have chosen them. # Having more attempts is currently useful in part because of the randomness, @@ -919,18 +918,25 @@ class PaySession(Logger): # TODO: erring node is always the first trampoline even if second # trampoline demands more fees, we can't influence this self.maybe_raise_trampoline_fee(htlc_log) - elif self.use_two_trampolines: - self.use_two_trampolines = False elif failure_msg.code in ( OnionFailureCode.UNKNOWN_NEXT_PEER, OnionFailureCode.TEMPORARY_NODE_FAILURE): trampoline_route = htlc_log.route - r = [hop.end_node.hex() for hop in trampoline_route] + r = [] + for hop in trampoline_route: + r.append(hop.end_node.hex()) + if hop.end_node == node_id: + # we break at the node sending the error, so that + # _choose_next_trampoline can discard the last item + break self.logger.info(f'failed trampoline route: {r}') if r not in self.failed_trampoline_routes: self.failed_trampoline_routes.append(r) else: pass # maybe the route was reused between different MPP parts + if failure_msg.code == OnionFailureCode.UNKNOWN_NEXT_PEER: + self.next_trampolines[node_id] = decode_next_trampolines(failure_msg.data) + self.logger.info(f'received {self.next_trampolines[node_id]=}') else: raise PaymentFailure(failure_msg.code_name()) @@ -1928,6 +1934,7 @@ class LNWallet(Logger): log = self.logs[key] return success, log + @log_exceptions async def pay_to_node( self, *, node_pubkey: bytes, @@ -1965,12 +1972,6 @@ class LNWallet(Logger): amount_to_pay=amount_to_pay, invoice_pubkey=node_pubkey, uses_trampoline=self.uses_trampoline(), - # the config option to use two trampoline hops for legacy payments has been removed as - # the trampoline onion is too small (400 bytes) to accommodate two trampoline hops and - # routing hints, making the functionality unusable for payments that require routing hints. - # TODO: if you read this, the year is 2027 and there is no use for the second trampoline - # hop code anymore remove the code completely. - use_two_trampolines=False, ) self.logs[payment_hash.hex()] = log = [] # TODO incl payment_secret in key (re trampoline forwarding) @@ -2096,7 +2097,9 @@ class LNWallet(Logger): # trampoline if self.uses_trampoline(): paysession.handle_failed_trampoline_htlc( - htlc_log=htlc_log, failure_msg=failure_msg) + node_id=erring_node_id, + htlc_log=htlc_log, + failure_msg=failure_msg) else: self.handle_error_code_from_failed_htlc( route=route, sender_idx=sender_idx, failure_msg=failure_msg, amount=htlc_log.amount_msat) @@ -2427,7 +2430,7 @@ class LNWallet(Logger): payment_secret=paysession.payment_secret, local_height=local_height, trampoline_fee_level=paysession.trampoline_fee_level, - use_two_trampolines=paysession.use_two_trampolines, + next_trampolines=paysession.next_trampolines.get(trampoline_node_id, {}), failed_routes=paysession.failed_trampoline_routes, budget=budget._replace(fee_msat=budget.fee_msat // len(per_trampoline_channel_amounts)), ) @@ -4029,6 +4032,7 @@ class LNWallet(Logger): htlc_key = serialize_htlc_key(next_chan.get_scid_or_local_alias(), next_htlc.htlc_id) return htlc_key + @log_exceptions async def _maybe_forward_trampoline( self, *, payment_hash: bytes, @@ -4090,21 +4094,17 @@ class LNWallet(Logger): local_height_of_onion_creator = self.network.get_local_height() - 1 cltv_budget_for_rest_of_route = out_cltv_abs - local_height_of_onion_creator - if budget.fee_msat < 1000: - raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') - if budget.cltv < 576: - raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') - # do we have a connection to the node? + direct_channels = None next_peer = self.lnpeermgr.get_peer_by_pubkey(outgoing_node_id) - if next_peer and next_peer.accepts_zeroconf() and self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): - self.logger.info(f'JIT: found next_peer') + if next_peer: for next_chan in next_peer.channels.values(): if next_chan.can_pay(amt_to_forward): # todo: detect if we can do mpp - self.logger.info(f'jit: next_chan can pay') + direct_channels = [next_chan] break - else: + # open JIT channel + if not direct_channels and next_peer.accepts_zeroconf() and self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): scid_alias = self._scid_alias_of_node(next_peer.pubkey) route = [RouteEdge( start_node=next_peer.pubkey, @@ -4132,6 +4132,12 @@ class LNWallet(Logger): next_onion=next_onion) return + if not direct_channels: + if budget.fee_msat < 1000: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT, data=b'') + if budget.cltv < 576: + raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') + try: await self.pay_to_node( node_pubkey=outgoing_node_id, @@ -4145,6 +4151,7 @@ class LNWallet(Logger): budget=budget, attempts=100, fw_payment_key=fw_payment_key, + channels=direct_channels, ) except OnionRoutingFailure as e: raise @@ -4153,7 +4160,18 @@ class LNWallet(Logger): except PaymentFailure as e: self.logger.debug( f"maybe_forward_trampoline. PaymentFailure for {payment_hash.hex()=}, {payment_secret.hex()=}: {e!r}") - raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') + if self.uses_trampoline(): + # todo: use max fee & cltv if I have more than 1 channel to the same node + trampoline_channels = set( + [chan for chan in self.channels.values() + if chan.is_public() and chan.is_active() + and self.is_trampoline_peer(chan.node_id) + and chan.can_pay(amt_to_forward) + ]) + data = encode_next_trampolines(trampoline_channels) + else: + data = b'' + raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=data) def _maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(self, payment_hash: bytes) -> bool: """Returns True if the HTLC should be failed. diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 594c87e26..3d38de4ea 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -3,7 +3,7 @@ import os import random import dataclasses from fractions import Fraction -from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any +from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any, TYPE_CHECKING from types import MappingProxyType from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded @@ -17,6 +17,9 @@ from . import constants from .logging import get_logger from .util import random_shuffled_copy +if TYPE_CHECKING: + from .lnchannel import Channel + _logger = get_logger(__name__) @@ -42,6 +45,45 @@ TRAMPOLINE_NODES_SIGNET = { _TRAMPOLINE_NODES_UNITTESTS = {} # used in unit tests TRAMPOLINE_HOPS_MAX_DATA_SIZE = 500 +LAZY_TRAMPOLINE_MAGIC = b'lazy' +LAZY_TRAMPOLINE_VERSION = b'\x00' + +def encode_next_trampolines(trampoline_channels: Iterable['Channel']) -> bytes: + s = LAZY_TRAMPOLINE_MAGIC + LAZY_TRAMPOLINE_VERSION + # max 5 channels because of onion error size limit + for chan in list(trampoline_channels)[:5]: + s += chan.node_id + s += int.to_bytes(chan.forwarding_fee_base_msat, length=4, byteorder="big", signed=False) + s += int.to_bytes(chan.forwarding_fee_proportional_millionths, length=4, byteorder="big", signed=False) + s += int.to_bytes(chan.forwarding_cltv_delta, length=2, byteorder="big", signed=False) + return s + +def decode_next_trampolines(data: bytes) -> dict: + with io.BytesIO(bytes(data)) as s: + magic = s.read(4) + if magic != LAZY_TRAMPOLINE_MAGIC: + return {} + version = s.read(1) + if version != LAZY_TRAMPOLINE_VERSION: + return {} + next_trampolines = {} + while True: + node_id = s.read(33) + feebase = s.read(4) + feerate = s.read(4) + cltv = s.read(2) + if len(cltv) != 2: + break # EOF + feebase = int.from_bytes(feebase, byteorder="big") + feerate = int.from_bytes(feerate, byteorder="big") + cltv = int.from_bytes(cltv, byteorder="big") + # check that node_id is a valid point + try: + ecc.ECPubkey(node_id) + except ecc.InvalidECPointException: + continue + next_trampolines[node_id] = (feebase, feerate, cltv) + return next_trampolines def hardcoded_trampoline_nodes() -> Mapping[str, LNPeerAddr]: @@ -142,7 +184,7 @@ def _extend_trampoline_route( *, start_node: bytes = None, end_node: bytes, - pay_fees: bool = True, + fee_info: tuple = None, ): """Extends the route and modifies it in place.""" if start_node is None: @@ -153,13 +195,14 @@ def _extend_trampoline_route( # note: trampoline nodes are supposed to advertise their fee and cltv in node_update message. # However, in the temporary spec, they do not. # They also don't send their fee policy in the error message if we lowball the fee... + fee_base, fee_proportional, cltv_delta = fee_info if fee_info else (PLACEHOLDER_FEE, PLACEHOLDER_FEE, 576) route.append( TrampolineEdge( start_node=start_node, end_node=end_node, - fee_base_msat=PLACEHOLDER_FEE if pay_fees else 0, - fee_proportional_millionths=PLACEHOLDER_FEE if pay_fees else 0, - cltv_delta=576 if pay_fees else 0, + fee_base_msat=fee_base, + fee_proportional_millionths=fee_proportional, + cltv_delta=cltv_delta, node_features=trampoline_features)) @@ -225,7 +268,7 @@ def _allocate_fee_budget_among_route( return placeholder_fee -def _choose_second_trampoline( +def _choose_next_trampoline( my_trampoline: bytes, trampolines: Iterable[bytes], failed_routes: Iterable[Sequence[str]], @@ -235,7 +278,7 @@ def _choose_second_trampoline( trampolines.discard(my_trampoline) for r in failed_routes: if len(r) > 2: - t2 = bytes.fromhex(r[1]) + t2 = bytes.fromhex(r[-1]) if t2 in trampolines: trampolines.discard(t2) if not trampolines: @@ -253,7 +296,7 @@ def create_trampoline_route( my_trampoline: bytes, # the first trampoline in the path; which we are directly connected to r_tags, trampoline_fee_level: int, - use_two_trampolines: bool, + next_trampolines: dict, failed_routes: Iterable[Sequence[str]], budget: PaymentFeeBudget, ) -> LNPaymentTRoute: @@ -268,16 +311,15 @@ def create_trampoline_route( # our first trampoline hop is decided by the channel we use _extend_trampoline_route( - route, start_node=my_pubkey, end_node=my_trampoline, - pay_fees=False, + route, start_node=my_pubkey, end_node=my_trampoline, fee_info=(0, 0, 0) ) + next_trampolines.pop(my_pubkey, None) + next_trampolines_ids = list(next_trampolines.keys()) if is_legacy: - # we add another different trampoline hop for privacy - if use_two_trampolines: - trampolines = trampolines_by_id() - second_trampoline = _choose_second_trampoline(my_trampoline, list(trampolines.keys()), failed_routes) - _extend_trampoline_route(route, end_node=second_trampoline) + if next_trampolines: + trampoline_id = _choose_next_trampoline(my_trampoline, next_trampolines_ids, failed_routes) + _extend_trampoline_route(route, end_node=trampoline_id, fee_info = next_trampolines[trampoline_id]) # the last trampoline onion must contain routing hints for the last trampoline # node to find the recipient # Due to space constraints it is not guaranteed for all route hints to get included in the onion @@ -289,18 +331,15 @@ def create_trampoline_route( route[-1].invoice_features = invoice_features route[-1].outgoing_node_id = invoice_pubkey else: - if invoice_trampolines: - if my_trampoline in invoice_trampolines: - short_route = [my_trampoline.hex(), invoice_pubkey.hex()] - if short_route in failed_routes: - add_trampoline = True - else: - add_trampoline = False - else: - add_trampoline = True - if add_trampoline: - second_trampoline = _choose_second_trampoline(my_trampoline, invoice_trampolines, failed_routes) - _extend_trampoline_route(route, end_node=second_trampoline) + next_trampoline = my_trampoline + # maybe add second trampoline + if next_trampolines and next_trampoline not in invoice_trampolines: + next_trampoline = _choose_next_trampoline(next_trampoline, next_trampolines_ids, failed_routes) + _extend_trampoline_route(route, end_node=next_trampoline, fee_info=next_trampolines[next_trampoline]) + # maybe add invoice trampoline + if invoice_trampolines and next_trampoline not in invoice_trampolines: + invoice_trampoline = _choose_next_trampoline(next_trampoline, invoice_trampolines, failed_routes) + _extend_trampoline_route(route, end_node=invoice_trampoline) # Add final edge. note: eclair requires an encrypted t-onion blob even in legacy case. # Also needed for fees for last TF! @@ -417,7 +456,7 @@ def create_trampoline_route_and_onion( payment_secret: bytes, local_height: int, trampoline_fee_level: int, - use_two_trampolines: bool, + next_trampolines: dict, failed_routes: Iterable[Sequence[str]], budget: PaymentFeeBudget, ) -> Tuple[LNPaymentTRoute, OnionPacket, int, int]: @@ -431,7 +470,7 @@ def create_trampoline_route_and_onion( my_trampoline=node_id, r_tags=r_tags, trampoline_fee_level=trampoline_fee_level, - use_two_trampolines=use_two_trampolines, + next_trampolines=next_trampolines, failed_routes=failed_routes, budget=budget, ) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index fcaf62581..c78b8ef34 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -190,7 +190,6 @@ class MockLNWallet(LNWallet): amount_to_pay=amount_msat, invoice_pubkey=decoded_invoice.pubkey.serialize(), uses_trampoline=False, - use_two_trampolines=False, ) payment_key = decoded_invoice.paymenthash + decoded_invoice.payment_secret self._paysessions[payment_key] = paysession @@ -340,7 +339,6 @@ _GRAPH_DEFINITIONS = { }, 'config': { SimpleConfig.EXPERIMENTAL_LN_FORWARD_PAYMENTS: True, - SimpleConfig.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: True, }, }, 'carol': { @@ -357,7 +355,6 @@ _GRAPH_DEFINITIONS = { }, 'config': { SimpleConfig.EXPERIMENTAL_LN_FORWARD_PAYMENTS: True, - SimpleConfig.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: True, }, }, 'edward': { @@ -2671,7 +2668,8 @@ class TestPeerForwarding(TestPeer): attempts=2, sender_name="alice", destination_name="dave", - tf_names=("bob", "carol"), + trampoline_forwarders=("bob", "carol"), + trampoline_users=(), # sender is also a trampoline user ): sender_w = graph.workers[sender_name] @@ -2710,9 +2708,16 @@ class TestPeerForwarding(TestPeer): # declare routing nodes as trampoline nodes electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = {} - for tf_name in tf_names: - peer_addr = LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers[tf_name].node_keypair.pubkey) - electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS[graph.workers[tf_name].name] = peer_addr + for name in trampoline_forwarders: + user_w = graph.workers[name] + user_w.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True + peer_addr = LNPeerAddr(host="127.0.0.1", port=9735, pubkey=user_w.node_keypair.pubkey) + electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS[user_w.name] = peer_addr + + for user in trampoline_users: + user_w = graph.workers[user] + await self._activate_trampoline(user_w) + assert user_w.uses_trampoline() await f() @@ -2800,7 +2805,31 @@ class TestPeerForwarding(TestPeer): node1name='carol', node2name='dave') with self.assertRaises(PaymentDone): await self._run_trampoline_payment( - graph, sender_name='alice', destination_name='edward',tf_names=('bob', 'dave')) + graph, sender_name='alice', + destination_name='edward', + trampoline_forwarders=('bob', 'dave'), + ) + + async def test_payment_trampoline_e2e_lazy(self): + # alice -> T1_bob -> T2_carol -> T3_dave -> edward + graph_definition = self.GRAPH_DEFINITIONS['line_graph'] + graph = self.prepare_chans_and_peers_in_graph(graph_definition) + with self.assertRaises(NoPathFound): + await self._run_trampoline_payment( + graph, sender_name='alice', + destination_name='edward', + trampoline_forwarders=('bob', 'dave'), + trampoline_users=('alice', 'bob'), + attempts=3, # fails with only 2 + ) + with self.assertRaises(PaymentDone): + await self._run_trampoline_payment( + graph, sender_name='alice', + destination_name='edward', + trampoline_forwarders=('bob', 'carol', 'dave'), + trampoline_users=('alice', 'bob'), + attempts=3, # fails with only 2 + ) async def test_multi_trampoline_payment(self): """ @@ -2826,7 +2855,7 @@ class TestPeerForwarding(TestPeer): g, sender_name='alice', destination_name='dave', - tf_names=('bob', 'carol'), + trampoline_forwarders=('bob', 'carol'), attempts=30, # the default used in LNWallet.pay_invoice() ) @@ -2948,9 +2977,12 @@ class TestPeerForwarding(TestPeer): peers = graph.peers.values() if test_trampoline: + # trampoline forwarder + graph.workers['bob'].config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = { graph.workers['bob'].name: LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers['bob'].node_keypair.pubkey), } + # trampoline users await self._activate_trampoline(graph.workers['carol']) await self._activate_trampoline(graph.workers['alice']) From 187ea80688b58842f0f6c8eff9db0673d4643aa3 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 19 Mar 2026 09:28:19 +0100 Subject: [PATCH 3/3] lazy_trampoline: adapt unit test --- electrum/trampoline.py | 2 ++ tests/test_lnpeer.py | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 3d38de4ea..08d0cf28c 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -6,6 +6,8 @@ from fractions import Fraction from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any, TYPE_CHECKING from types import MappingProxyType +import electrum_ecc as ecc + from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded from .lnonion import ( calc_hops_data_for_payment, new_onion_packet, OnionPacket, PER_HOP_HMAC_SIZE diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index c78b8ef34..6686f6bc5 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -2598,14 +2598,8 @@ class TestPeerForwarding(TestPeer): 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), } - # 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 - with self.assertRaises(NoPathFound): - await self._run_mpp(graph, {'alice_uses_trampoline': True, 'attempts': 1}) with self.assertRaises(PaymentDone): - await self._run_mpp(graph,{'alice_uses_trampoline': True, 'attempts': 30}) + await self._run_mpp(graph,{'alice_uses_trampoline': True, 'attempts': 1}) async def test_payment_multipart_trampoline_legacy(self): graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['square_graph']) @@ -2803,11 +2797,22 @@ class TestPeerForwarding(TestPeer): inject_chan_into_gossipdb( channel_db=graph.workers['bob'].channel_db, graph=graph, node1name='carol', node2name='dave') + # end-to-end trampoline: we attempt + # * a payment with one trial: fails, because initial fees are too low + # * a payment with several trials: should succeed + with self.assertRaises(NoPathFound): + await self._run_trampoline_payment( + graph, sender_name='alice', + destination_name='edward', + trampoline_forwarders=('bob', 'dave'), + attempts=1, + ) with self.assertRaises(PaymentDone): await self._run_trampoline_payment( graph, sender_name='alice', destination_name='edward', trampoline_forwarders=('bob', 'dave'), + attempts=2, ) async def test_payment_trampoline_e2e_lazy(self):