What changed, and why it matters
This commit changes how Electrum routes Lightning trampoline payments. When a trampoline forwarder cannot find a path, it can now return a list of alternative trampolines it knows, along with their fees, inside the payment failure message. The sender then uses that list to build a better route. The change also removes the old fixed two-trampoline-hop option and adjusts when fee/expiry errors are raised during forwarding. It is a protocol/routing improvement, not a clearly disclosed security fix.
Review as a normal feature/refactor commit. Specifically audit decode_next_trampolines for malformed-data handling and ensure untrusted fee/cltv values from failure messages are bounded before use in route/fee calculations. Verify that the reordering in _maybe_forward_trampoline does not allow fee-budget bypass for non-direct-channel cases.
Security signals we found
New network protocol field parsing (decode_next_trampolines) with length and ECC point validation
Failure-message data now influences route selection and fee budget allocation
Logic reordering in trampoline forwarding: direct-channel availability now gates minimum fee/cltv failure responses
Removal of hardcoded two-trampoline policy in favor of dynamically suggested peers
Evidence from the diff
The patch implements a ‘lazy trampoline’ mechanism. It adds encode_next_trampolines()/decode_next_trampolines() in trampoline.py to pack/unpack up to five alternative trampoline channel peers (node_id, base fee, proportional fee, cltv delta) into the data field of an UNKNOWN_NEXT_PEER onion failure. PaySession replaces use_two_trampolines with a next_trampolines dict populated from that failure data. create_trampoline_route() now uses those suggested next trampolines and their advertised fee policies to extend routes. _maybe_forward_trampoline() reorders logic so direct channels are checked before enforcing minimum fee/cltv budgets, and returns suggested trampolines only when the local node itself uses trampoline forwarding. Tests are updated to exercise the new lazy path.
Changed components
electrum/lnworker.py (PaySession, LNWallet.pay_to_node, _maybe_forward_trampoline)electrum/trampoline.py (route creation, next-trampoline encode/decode)tests/test_lnpeer.py (trampoline forwarding tests)Inspect captured patch +152 / −63
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 9fee73c..77484f6 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 594c87e..3d38de4 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 fcaf625..c78b8ef 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'])
Why this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.