From 8ded84c79877771927546b958cfb45bb72f82b48 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 e73fb5126adf..686f136f8048 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -2353,7 +2353,10 @@ async def create_routes_for_payment( 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(): @@ -2425,19 +2428,28 @@ async def create_routes_for_payment( 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, @@ -2461,17 +2473,40 @@ async def create_routes_for_payment( 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) @@ -2531,11 +2566,6 @@ def create_route_for_single_htlc( 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 2d6fcfd0580f27bcb0e67b2c2e0266a341694cf7 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 | 92 +++++++++++++++++++++++++++++------------- tests/test_lnpeer.py | 50 ++++++++++++++++++----- 3 files changed, 147 insertions(+), 63 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 686f136f8048..17703497ca69 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -90,7 +90,7 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ def maybe_raise_trampoline_fee(self, htlc_log: HtlcLog): 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 @@ def handle_failed_trampoline_htlc(self, *, htlc_log: HtlcLog, failure_msg: Onion # 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()) @@ -1880,6 +1886,7 @@ async def pay_invoice( log = self.logs[key] return success, log + @log_exceptions async def pay_to_node( self, *, node_pubkey: bytes, @@ -1917,12 +1924,6 @@ async def pay_to_node( 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) @@ -2048,7 +2049,9 @@ async def _process_htlc_log( # 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) @@ -2379,7 +2382,7 @@ async def create_routes_for_payment( 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)), ) @@ -3974,6 +3977,7 @@ def log_fail_reason(reason: str): 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, @@ -4035,21 +4039,17 @@ async def _maybe_forward_trampoline( 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(): - 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(): scid_alias = self._scid_alias_of_node(next_peer.pubkey) route = [RouteEdge( start_node=next_peer.pubkey, @@ -4077,6 +4077,12 @@ async def _maybe_forward_trampoline( 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, @@ -4090,6 +4096,7 @@ async def _maybe_forward_trampoline( budget=budget, attempts=100, fw_payment_key=fw_payment_key, + channels=direct_channels, ) except OnionRoutingFailure as e: raise @@ -4098,7 +4105,18 @@ async def _maybe_forward_trampoline( 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 594c87e26111..d0a49812ef1a 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -3,7 +3,7 @@ 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 .logging import get_logger from .util import random_shuffled_copy +if TYPE_CHECKING: + from .lnchannel import Channel + _logger = get_logger(__name__) @@ -42,6 +45,40 @@ _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") + next_trampolines[node_id] = (feebase, feerate, cltv) + return next_trampolines def hardcoded_trampoline_nodes() -> Mapping[str, LNPeerAddr]: @@ -142,7 +179,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 +190,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 +263,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 +273,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 +291,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 +306,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 +326,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 +451,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 +465,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 6dc07e561790..e13bd2d26ac1 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -190,7 +190,6 @@ async def create_routes_from_invoice(self, amount_msat: int, decoded_invoice: Ln 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 @@ class PeerInTests(Peer): }, 'config': { SimpleConfig.EXPERIMENTAL_LN_FORWARD_PAYMENTS: True, - SimpleConfig.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: True, }, }, 'carol': { @@ -357,7 +355,6 @@ class PeerInTests(Peer): }, 'config': { SimpleConfig.EXPERIMENTAL_LN_FORWARD_PAYMENTS: True, - SimpleConfig.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: True, }, }, 'edward': { @@ -2622,7 +2619,8 @@ async def _run_trampoline_payment( 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] @@ -2661,9 +2659,16 @@ async def f(): # 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() @@ -2751,7 +2756,31 @@ async def test_payment_trampoline_e2e_alice_t1_carol_t2_edward(self): 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): """ @@ -2777,7 +2806,7 @@ async def test_multi_trampoline_payment(self): g, sender_name='alice', destination_name='dave', - tf_names=('bob', 'carol'), + trampoline_forwarders=('bob', 'carol'), attempts=30, # the default used in LNWallet.pay_invoice() ) @@ -2899,9 +2928,12 @@ async def run_test(test_trampoline): 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 98fd6c88ad542fa2fa8d8c11fd6a0e72cd1e80a6 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 --- tests/test_lnpeer.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index e13bd2d26ac1..136833efd94f 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -2549,14 +2549,8 @@ async def test_payment_multipart_trampoline_e2e(self): 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']) @@ -2754,11 +2748,22 @@ async def test_payment_trampoline_e2e_alice_t1_carol_t2_edward(self): 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):