diff --git a/electrum/lnworker.py b/electrum/lnworker.py index e73fb5126ad..17703497ca6 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) @@ -2353,7 +2356,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(): @@ -2376,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)), ) @@ -2425,19 +2431,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 +2476,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 +2569,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") @@ -3944,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, @@ -4005,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, @@ -4047,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, @@ -4060,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 @@ -4068,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 594c87e2611..d0a49812ef1 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 6dc07e56179..136833efd94 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': { @@ -2552,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']) @@ -2622,7 +2613,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 +2653,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() @@ -2749,9 +2748,44 @@ 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',tf_names=('bob', 'dave')) + graph, sender_name='alice', + destination_name='edward', + trampoline_forwarders=('bob', 'dave'), + attempts=2, + ) + + 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 +2811,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 +2933,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'])