diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 1df250b8e..594c87e26 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,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( diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py index 15a2e0446..acc8f40b6 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -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)