trampoline: handle edges with known fees in allocation
What changed, and why it matters
This commit fixes how Electrum calculates fees for trampoline Lightning payments when some route edges have known fees and others are unknown placeholders. Previously, the code ignored known fees and split the entire budget evenly among placeholder edges, which could either overpay or exceed the intended fee budget. The new code solves a small linear equation so the final total fee stays within budget. This is a correctness and fee-budget safety improvement, not a direct theft-of-funds bug, but getting it wrong could cause payments to fail or users to pay more than expected.
Review the new linear allocation math for off-by-one and floor behavior; add unit tests covering mixed known/placeholder edges, proportional fees, and edge cases where budget_remaining <= 0 or coeff <= 0; verify is_route_within_budget still catches residual rounding overruns.
Security signals we found
fee budget correctness fix
payment routing logic change
placeholder fee allocation now accounts for known edge fees
potential overpayment / fee budget exceedance in prior logic
Evidence from the diff
The patch refactors _allocate_fee_along_route into get_trampoline_budget and _allocate_fee_budget_among_route. It now distinguishes TrampolineEdge entries with PLACEHOLDER_FEE from those with known fee_base_msat/fee_proportional_millionths, models total route fee as total_fee_const + total_fee_coeff * x, and floors x to an integer msat value assigned to each placeholder edge. This ensures the realized trampoline fee respects the trampoline_fee_level share of PaymentFeeBudget when mixed known/placeholder edges exist.
Changed components
electrum/trampoline.pytrampoline route constructionPaymentFeeBudget handlingTrampolineEdge fee fieldsInspect captured patch +59 / −19
diff --git a/electrum/trampoline.py b/electrum/trampoline.py
index 1df250b..1904cc8 100644
--- a/electrum/trampoline.py
+++ b/electrum/trampoline.py
@@ -2,6 +2,7 @@ import io
import os
import random
import dataclasses
+from fractions import Fraction
from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any
from types import MappingProxyType
@@ -162,32 +163,65 @@ def _extend_trampoline_route(
node_features=trampoline_features))
-def _allocate_fee_along_route(
- route: List[TrampolineEdge],
- *,
- budget: PaymentFeeBudget,
- trampoline_fee_level: int,
-) -> None:
+def get_trampoline_budget(trampoline_fee_level: int, budget_msat: int) -> int:
# calculate budget_to_use, based on given max available "budget"
if trampoline_fee_level == 0:
- budget_to_use = 0
+ return 0
else:
assert trampoline_fee_level > 0
MAX_LEVEL = 6
if trampoline_fee_level > MAX_LEVEL:
raise FeeBudgetExceeded("highest trampoline fee level reached")
- budget_to_use = budget.fee_msat // (2 ** (MAX_LEVEL - trampoline_fee_level))
- _logger.debug(f"_allocate_fee_along_route(). {trampoline_fee_level=}, {budget.fee_msat=}, {budget_to_use=}")
- # replace placeholder fees
- for edge in route:
- assert edge.fee_base_msat in (0, PLACEHOLDER_FEE), edge.fee_base_msat
- assert edge.fee_proportional_millionths in (0, PLACEHOLDER_FEE), edge.fee_proportional_millionths
- edges_to_update = [
- edge for edge in route
- if edge.fee_base_msat == PLACEHOLDER_FEE]
- for edge in edges_to_update:
- edge.fee_base_msat = budget_to_use // len(edges_to_update)
+ return budget_msat // (2 ** (MAX_LEVEL - trampoline_fee_level))
+
+
+def _allocate_fee_budget_among_route(
+ route: Sequence[TrampolineEdge],
+ *,
+ usable_budget_msat: int,
+ amount_msat_for_dest: int,
+) -> int:
+ """
+ Assign trampoline base fee to PLACEHOLDER trampoline edges so the realized route fee stays
+ within the trampoline_fee_level's share of the payment budget (usable_budget_msat).
+
+ Let x be the placeholder base fee we solve for (equal for every placeholder edge).
+ Walking the route from destination back to source, every quantity is a linear function of x:
+ edge_fee(x) = edge_fee_const + edge_fee_coeff * x
+ amt_in(x) = amt_in_const + amt_in_coeff * x # amount entering the edge
+ total_fee(x) = total_fee_const + total_fee_coeff * x # sum of edge_fee over route
+ The constraint total_fee(x) <= usable_budget_msat gives
+ x = (usable_budget_msat - total_fee_const) / total_fee_coeff
+ which we floor to an integer msat.
+ """
+ placeholder_edges = [e for e in route[1:] if e.fee_base_msat == PLACEHOLDER_FEE]
+ known_edges = [e for e in route[1:] if e.fee_base_msat != PLACEHOLDER_FEE]
+ if not placeholder_edges:
+ return 0
+
+ amt_in_const = Fraction(amount_msat_for_dest)
+ for edge in reversed(known_edges): # only known_edges
+ amt_in_const += edge.fee_base_msat + amt_in_const * edge.fee_proportional_millionths / 1_000_000
+ budget_const = amt_in_const - amount_msat_for_dest
+ budget_remaining = Fraction(usable_budget_msat) - budget_const
+
+ coeff = Fraction(0)
+ for edge in reversed(route[1:]): # known_edges AND placeholder_edges
+ if edge.fee_base_msat == PLACEHOLDER_FEE:
+ coeff += Fraction(1)
+ else: # for a known-edge, allocate same small fee for each placeholder-edge later in path
+ coeff += coeff * edge.fee_proportional_millionths / 1_000_000
+
+ if budget_remaining <= 0 or coeff <= 0:
+ placeholder_fee = 0
+ else:
+ placeholder_fee_exact = budget_remaining / coeff
+ placeholder_fee = placeholder_fee_exact.numerator // placeholder_fee_exact.denominator # floor
+ _logger.debug(f"_allocate_fee_along_route: {placeholder_fee=}, placeholders={len(placeholder_edges)}")
+ for edge in placeholder_edges:
+ edge.fee_base_msat = placeholder_fee
edge.fee_proportional_millionths = 0
+ return placeholder_fee
def _choose_second_trampoline(
@@ -273,7 +307,13 @@ def create_trampoline_route(
_extend_trampoline_route(route, end_node=invoice_pubkey)
# replace placeholder fees in route
- _allocate_fee_along_route(route, budget=budget, trampoline_fee_level=trampoline_fee_level)
+ usable_budget_msat = get_trampoline_budget(trampoline_fee_level, budget.fee_msat)
+ _logger.debug(f"create_trampoline_route: {trampoline_fee_level=}, {usable_budget_msat=}")
+ _allocate_fee_budget_among_route(
+ route,
+ usable_budget_msat=usable_budget_msat,
+ amount_msat_for_dest=amount_msat,
+ )
# check that we can pay amount and fees
if not is_route_within_budget(
Why this scored 31/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.