Merge pull request #10606 from f321x/trampoline_route_fees

trampoline: handle edges with known fees during route edge fee allocation
This commit is contained in:
ghost43
2026-04-24 14:46:40 +00:00
committed by GitHub
2 changed files with 277 additions and 26 deletions
+60 -19
View File
@@ -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,66 @@ 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.
(note: the code is equivalent to the above description but has been simplified a bit)
"""
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
amt_in_coeff = Fraction(0)
for edge in reversed(route[1:]): # known_edges AND placeholder_edges
if edge.fee_base_msat == PLACEHOLDER_FEE:
amt_in_coeff += Fraction(1)
else: # for a known-edge, allocate same small fee for each placeholder-edge later in path
amt_in_coeff += amt_in_coeff * edge.fee_proportional_millionths / 1_000_000
if budget_remaining <= 0 or amt_in_coeff <= 0:
placeholder_fee = 0
else:
placeholder_fee_exact = budget_remaining / amt_in_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 +308,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(
+217 -7
View File
@@ -1,18 +1,16 @@
from math import inf
import random
import unittest
import tempfile
import shutil
import asyncio
from math import inf
from typing import Optional
from os import urandom
from types import MappingProxyType
from electrum import util
from electrum.channel_db import NodeInfo
from electrum.onion_message import is_onion_message_node
from electrum.trampoline import create_trampoline_onion
from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE,
get_trampoline_budget)
from electrum.util import bfh
from electrum.lnutil import ShortChannelID, LnFeatures
from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget
from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet,
process_onion_packet, _decode_onion_error, decode_onion_error,
OnionFailureCode)
@@ -551,3 +549,215 @@ class Test_LNRouter(ElectrumTestCase):
invoice_amount_msat=amount_to_send,
node_filter=is_onion_message_node)
self.assertIsNone(path)
def _tramp_edge(start: str, end: str, *, fee_base=PLACEHOLDER_FEE, fee_prop=PLACEHOLDER_FEE, cltv=576) -> TrampolineEdge:
return TrampolineEdge(
start_node=node(start),
end_node=node(end),
short_channel_id=ShortChannelID.from_str("0x0x0"),
fee_base_msat=fee_base,
fee_proportional_millionths=fee_prop,
cltv_delta=cltv,
node_features=LnFeatures.VAR_ONION_OPT,
)
class TestAllocateFeeBudget(ElectrumTestCase):
"""Tests for _allocate_fee_budget_among_route (backward-walk allocator)."""
AMOUNT = 1_000_000 # 1000 sat
BUDGET = PaymentFeeBudget(fee_msat=10_000, cltv=144) # 10 sat
def _allocate(self, route, *, amount=None, budget=None, level=6):
budget = budget.fee_msat if budget else self.BUDGET.fee_msat
budget_to_use = get_trampoline_budget(level, budget)
return _allocate_fee_budget_among_route(
route,
usable_budget_msat=budget_to_use,
amount_msat_for_dest=amount if amount is not None else self.AMOUNT,
)
def _realized_fee(self, route, amount=None) -> int:
amt = amount if amount is not None else self.AMOUNT
for edge in reversed(route[1:]):
amt += edge.fee_for_edge(amt)
return amt - (amount if amount is not None else self.AMOUNT)
def test_all_placeholders_matches_even_split(self):
# Route: me -> a -> b -> c (c is receiver). route[1:] has 2 placeholders.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b'),
_tramp_edge('b', 'c'),
]
self._allocate(route)
# At level 6, usable_budget = BUDGET.fee_msat = 10_000; split across 2.
self.assertEqual(5_000, route[1].fee_base_msat)
self.assertEqual(5_000, route[2].fee_base_msat)
self.assertEqual(0, route[1].fee_proportional_millionths)
self.assertEqual(0, route[2].fee_proportional_millionths)
self.assertLessEqual(self._realized_fee(route), self.BUDGET.fee_msat)
def test_known_fee_base_only_deducted_from_budget(self):
# a->b is KNOWN with fee_base=2000, fee_prop=0; b->c is PLACEHOLDER.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b', fee_base=2_000, fee_prop=0),
_tramp_edge('b', 'c'),
]
self._allocate(route)
# Placeholder gets the full remaining budget (one placeholder, no proportional amplification).
self.assertEqual(8_000, route[2].fee_base_msat)
self.assertLessEqual(self._realized_fee(route), self.BUDGET.fee_msat)
def test_known_proportional_amplifies_placeholder_cost(self):
# a->b is KNOWN with fee_prop=10% (100_000); b->c is PLACEHOLDER.
# Any fee f placed at b->c flows through a->b, which charges 10% on top.
# Budget check: total_fee_const + (1 + 0.1) * placeholder_fee <= 10_000
# total_fee_const = 0 + amount * 0.1 = 100_000 msat. That already exceeds budget,
# so placeholder_fee must be 0 and realized fee > budget (caller's
# is_route_within_budget will catch it).
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b', fee_base=0, fee_prop=100_000),
_tramp_edge('b', 'c'),
]
self._allocate(route)
self.assertEqual(0, route[2].fee_base_msat)
def test_known_proportional_small_fits_budget(self):
# Smaller proportional fee that leaves room. a->b: fee_prop = 1%.
# total_fee_const(amount=1_000_000) = 10_000 (== budget) without placeholders.
# With smaller proportional (0.5%), total_fee_const = 5_000,
# leaving budget_residual = 5_000.
# total_fee_coeff = 1.005, so placeholder_fee = 5_000 / 1.005 ≈ 4975.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b', fee_base=0, fee_prop=5_000), # 0.5%
_tramp_edge('b', 'c'),
]
self._allocate(route)
f = route[2].fee_base_msat
self.assertGreater(f, 0)
self.assertLessEqual(self._realized_fee(route), self.BUDGET.fee_msat)
def test_known_fee_exceeds_budget_yields_zero_placeholder(self):
# Known edge with fee_base > entire budget. Placeholder gets 0 instead
# of going negative.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b', fee_base=50_000, fee_prop=0), # way over budget
_tramp_edge('b', 'c'),
]
self._allocate(route)
self.assertEqual(0, route[2].fee_base_msat)
self.assertEqual(0, route[2].fee_proportional_millionths)
def test_no_placeholders_noop(self):
# All edges KNOWN: allocator does nothing.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b', fee_base=1_000, fee_prop=0),
_tramp_edge('b', 'c', fee_base=2_000, fee_prop=0),
]
self._allocate(route)
self.assertEqual(1_000, route[1].fee_base_msat)
self.assertEqual(2_000, route[2].fee_base_msat)
def test_amount_at_each_hop(self):
# invoice amt: 1000k msat
# budget: 10k msat
#
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b'),
_tramp_edge('b', 'c', fee_base=1_000, fee_prop=1500),
_tramp_edge('c', 'd'),
_tramp_edge('d', 'e', fee_base=1_000, fee_prop=1500),
]
self._allocate(route)
amount = self.AMOUNT
amounts_from_destination = iter((
1000000, # amount for recipient (d -> e)
1002500, # c -> d
1004996, # b -> c
1007503, # a -> b
1009999 # us -> a
))
for edge in reversed(route[1:]):
self.assertEqual(amount, next(amounts_from_destination))
amount += edge.fee_for_edge(amount)
self.assertEqual([0, 2496, 1000, 2496, 1000], [e.fee_base_msat for e in route])
@unittest.skip(reason="is a bit slow")
def test_fuzz(self):
invoice_amount = 1_000_000
for round_ in range(10**5):
budget = PaymentFeeBudget(fee_msat=random.randint(1000, 10*invoice_amount), cltv=144)
route = [
_tramp_edge('x', 'x', fee_base=0, fee_prop=0),
]
num_edges = random.randint(1, 10)
fee_base_min = random.randint(1000, 50000)
fee_base_max = random.randint(fee_base_min, 50000)
fee_prop_min = random.randint(1000, 50000)
fee_prop_max = random.randint(fee_prop_min, 50000)
for e in range(num_edges):
if random.random() < 0.5:
route.append(_tramp_edge('x', 'x'))
else:
fee_base = random.randint(fee_base_min, fee_base_max)
fee_prop = random.randint(fee_prop_min, fee_prop_max)
route.append(_tramp_edge('x', 'x', fee_base=fee_base, fee_prop=fee_prop))
placeholder_fee = self._allocate(route, amount=invoice_amount, budget=budget)
actual_fees = []
fwd_amt = invoice_amount
for e in route[::-1]:
actual_fee = e.fee_for_edge(fwd_amt)
fwd_amt += actual_fee
actual_fees.append(actual_fee)
actual_fees = actual_fees[::-1]
# checks
no_solution = placeholder_fee == 0
solution_is_exact = (budget.fee_msat - num_edges <= sum(actual_fees) <= budget.fee_msat)
assert no_solution or solution_is_exact
def test_level_zero_gives_zero_placeholders(self):
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b'),
_tramp_edge('b', 'c'),
]
self._allocate(route, level=0)
self.assertEqual(0, route[1].fee_base_msat)
self.assertEqual(0, route[2].fee_base_msat)
def test_placeholder_upstream_of_known(self):
# Placeholder is UPSTREAM of known edge -> known edge's amount depends on placeholder fee.
# Route: me -> a -> b -> c. a->b PLACEHOLDER, b->c KNOWN (fee_base=1000, fee_prop=10_000 = 1%).
# Going backwards: first process b->c (known): total_fee_const = 1000 + 1_000_000 * 0.01 = 11_000.
# That alone exceeds budget 10_000; placeholder gets 0.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b'),
_tramp_edge('b', 'c', fee_base=1_000, fee_prop=10_000),
]
self._allocate(route)
self.assertEqual(0, route[1].fee_base_msat)
def test_placeholder_upstream_of_small_known_fits(self):
# Same shape but small known fee so placeholder gets a positive fee.
# b->c known: fee_base=500, fee_prop=0. total_fee_const = 500.
# budget_residual = 9500. a->b placeholder sees
# total_fee_coeff=1 (no upstream known), so placeholder_fee = 9500.
route = [
_tramp_edge('m', 'a', fee_base=0, fee_prop=0, cltv=0),
_tramp_edge('a', 'b'),
_tramp_edge('b', 'c', fee_base=500, fee_prop=0),
]
self._allocate(route)
self.assertEqual(9_500, route[1].fee_base_msat)
self.assertLessEqual(self._realized_fee(route), self.BUDGET.fee_msat)