create_routes_for_payment: allow trampoline forwarding without channel_db if there is a direct path
What changed, and why it matters
This commit changes how Electrum's Lightning wallet builds payment routes when using trampoline routing. It adds a shortcut: if the payment recipient is directly connected to the wallet via a channel, Electrum can create a direct route without needing the full network channel database. The change also moves the fee-budget check out of the general route builder so it applies to both direct and non-direct routes. There is no explicit security bug in the diff, but the change touches payment routing logic and fee checks, which are security-sensitive.
Review the new `create_direct_route()` helper to confirm it correctly handles channel state, policy validation, and edge cases such as inactive or force-closing channels. Verify that moving the fee-budget check does not bypass it for any code path that previously relied on `create_route_for_single_htlc()` enforcing the budget. Consider whether `is_direct_path` should also require that the direct channel has sufficient balance and is usable.
Security signals we found
Payment routing logic modified
Fee budget validation moved to a different call site
New direct route construction helper introduced
Trampoline routing branch behavior changed
No explicit vulnerability or exploit described in commit message
Evidence from the diff
The patch modifies LNWallet.create_routes_for_payment() in electrum/lnworker.py. It introduces is_direct_path to detect when all configured payment parts target the invoice pubkey directly. When trampoline mode is enabled and a direct path exists, it skips trampoline multi-part construction and uses a new create_direct_route() helper. create_direct_route() builds a single-edge route from the local node to the channel peer using the local channel policy. The fee-budget check (is_route_within_budget) is removed from create_route_for_single_htlc() and added inline in create_routes_for_payment() so it covers both direct and non-direct routes. The commit also removes min_final_cltv_delta and budget parameters from create_route_for_single_htlc().
Changed components
electrum/lnworker.pyLNWallet.create_routes_for_payment()LNWallet.create_route_for_single_htlc()LNWallet.create_direct_route()Lightning trampoline routingLightning fee budget checksInspect captured patch +44 / −14
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 9e90f38..9fee73c 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")
Why this scored 34/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.