lnworker: use config lightning fee for estimate
What changed, and why it matters
This commit fixes a bug in Electrum's Lightning wallet where the app guessed Lightning routing fees using hardcoded values. The old guess could be too low, causing 'Max' amount submarine swaps and similar payments to fail because the real fee later exceeded the reserved amount. The patch now uses the user's own configured maximum fee settings to make a more conservative estimate, so the wallet reserves enough for fees upfront.
No immediate security action required; this is a reliability/bug-fix improvement. Users relying on submarine swaps or max Lightning sends should benefit from fewer failed payments. Reviewers may want to verify the inverse math handles edge cases (e.g., very small amounts, zero config values) correctly.
Security signals we found
Fixes a functional bug in fee estimation that could cause payment failures
Replaces hardcoded fee constants with user-configured maximum fee budget
Adds clamping constants and inverse fee calculation to PaymentFeeBudget
Adds unit tests for the new reverse fee calculation
Evidence from the diff
The patch replaces LNWallet.fee_estimate(), which used hardcoded fee_base_msat=5000 and fee_proportional_millionths=500, with estimate_fee_reserve_for_total_amount(), which derives the fee reserve from PaymentFeeBudget.reverse_from_total_amount() using the user’s configured LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS and LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT (clamped). It also exposes the clamps as class constants and adds unit tests verifying the inverse calculation above and below the cutoff. Callers in num_sats_can_send(), _can_pay_with_submarine_swap(), _can_pay_with_rebalancing(), and num_sats_can_rebalance() are updated.
Changed components
electrum/lnworker.pyelectrum/lnutil.pytests/test_lnutil.pyInspect captured patch +77 / −17
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index 28f1f4e..0d30d37 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -2030,7 +2030,8 @@ class PaymentFeeBudget(NamedTuple):
# cltv-delta the destination wants for itself. (e.g. "min_final_cltv_delta" is excluded)
cltv: int # this is cltv-delta-like, no absolute heights here!
- #num_htlc: int
+ PAYMENT_FEE_CUTOFF_CLAMP = 10_000_000 # [0, 10k sat]
+ PAYMENT_FEE_MILLIONTHS_CLAMP = 250_000 # [0, 25%]
@classmethod
def from_invoice_amount(
@@ -2067,8 +2068,8 @@ class PaymentFeeBudget(NamedTuple):
fee_millionths = config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS
if fee_cutoff_msat is None:
fee_cutoff_msat = config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT
- millionths_clamped = min(max(0, fee_millionths), 250_000) # clamp into [0, 25%]
- cutoff_clamped = min(max(0, fee_cutoff_msat), 10_000_000) # clamp into [0, 10k sat]
+ millionths_clamped = min(max(0, fee_millionths), cls.PAYMENT_FEE_MILLIONTHS_CLAMP)
+ cutoff_clamped = min(max(0, fee_cutoff_msat), cls.PAYMENT_FEE_CUTOFF_CLAMP)
if fee_millionths != millionths_clamped:
_logger.warning(
f"PaymentFeeBudget. found insane fee millionths in config. "
@@ -2082,3 +2083,29 @@ class PaymentFeeBudget(NamedTuple):
fee_msat = invoice_amount_msat * millionths_clamped // 1_000_000
fee_msat = max(fee_msat, cutoff_clamped)
return fee_msat
+
+ @classmethod
+ def reverse_from_total_amount(cls, *, total_amount_msat: int, config: 'SimpleConfig') -> int:
+ """
+ Given the total amount (including fees) return the amount of fees
+ included assuming highest allowed from config fees are being used.
+
+ This allows to guess a fee that has to be reserved to reliably allow
+ doing a "Max" amount lightning send (e.g. for submarine swaps).
+ """
+ assert isinstance(total_amount_msat, int) and total_amount_msat >= 0, repr(total_amount_msat)
+
+ millionths_clamped = min(
+ max(0, config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS),
+ cls.PAYMENT_FEE_MILLIONTHS_CLAMP,
+ )
+ cutoff_clamped = min(
+ max(0, config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT),
+ cls.PAYMENT_FEE_CUTOFF_CLAMP,
+ )
+
+ # inverse of _calculate_fee_msat
+ amount_minus_fees = (total_amount_msat * 1_000_000) // (1_000_000 + millionths_clamped)
+ fees_msat = max(total_amount_msat - amount_minus_fees, cutoff_clamped)
+ fees_msat = min(fees_msat, total_amount_msat) # to handle (invalid?) inputs below cutoff_clamped
+ return fees_msat
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 347bcc0..d3074ab 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -22,6 +22,7 @@ from concurrent import futures
import urllib.parse
import itertools
import dataclasses
+from math import ceil
import aiohttp
import dns.asyncresolver
@@ -2950,15 +2951,17 @@ class LNWallet(LNWorker):
if self.channel_db or self.is_trampoline_peer(c.node_id):
yield c
- def fee_estimate(self, amount_sat):
- # Here we have to guess a fee, because some callers (submarine swaps)
- # use this method to initiate a payment, which would otherwise fail.
- fee_base_msat = 5000 # FIXME ehh.. there ought to be a better way...
- fee_proportional_millionths = 500 # FIXME
- # inverse of fee_for_edge_msat
- amount_msat = amount_sat * 1000
- amount_minus_fees = (amount_msat - fee_base_msat) * 1_000_000 // ( 1_000_000 + fee_proportional_millionths)
- return Decimal(amount_msat - amount_minus_fees) / 1000
+ def estimate_fee_reserve_for_total_amount(self, amount_sat: int | Decimal) -> int:
+ """
+ Estimate how much of the given amount needs to be reserved for
+ ln payment fees to reliably pay the remaining amount.
+ """
+ amount_msat = ceil(amount_sat * 1000) # round up to the next sat
+ fee_msat = PaymentFeeBudget.reverse_from_total_amount(
+ total_amount_msat=amount_msat,
+ config=self.config,
+ )
+ return ceil(Decimal(fee_msat) / 1000)
def num_sats_can_send(self, deltas=None) -> Decimal:
"""
@@ -2985,7 +2988,7 @@ class LNWallet(LNWorker):
can_send_dict[c.node_id] += send_capacity(c)
can_send = max(can_send_dict.values()) if can_send_dict else 0
can_send_sat = Decimal(can_send)/1000
- can_send_sat -= self.fee_estimate(can_send_sat)
+ can_send_sat -= self.estimate_fee_reserve_for_total_amount(can_send_sat)
return max(can_send_sat, 0)
def get_channels_for_receiving(
@@ -3083,7 +3086,7 @@ class LNWallet(LNWorker):
for chan in channels:
available_sat = chan.available_to_spend(LOCAL if direction == SENT else REMOTE) // 1000
delta = amount_sat - available_sat
- delta += self.fee_estimate(amount_sat)
+ delta += self.estimate_fee_reserve_for_total_amount(amount_sat)
# add safety margin
delta += delta // 100 + 1
if func(deltas={chan:delta}) >= amount_sat:
@@ -3106,7 +3109,7 @@ class LNWallet(LNWorker):
return False
for chan2, delta in suggestions:
# margin for fee caused by rebalancing
- delta += self.fee_estimate(amount_sat)
+ delta += self.estimate_fee_reserve_for_total_amount(amount_sat)
# find other channel or trampoline that can send delta
for chan1 in self.channels.values():
if chan1.is_frozen_for_sending() or not chan1.is_active():
@@ -3129,7 +3132,7 @@ class LNWallet(LNWorker):
def num_sats_can_rebalance(self, chan1, chan2):
# TODO: we should be able to spend 'max', with variable fee
n1 = chan1.available_to_spend(LOCAL)
- n1 -= self.fee_estimate(n1)
+ n1 -= self.estimate_fee_reserve_for_total_amount(n1)
n2 = chan2.available_to_spend(REMOTE)
amount_sat = min(n1, n2) // 1000
return amount_sat
diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py
index 944d8a8..944fcc8 100644
--- a/tests/test_lnutil.py
+++ b/tests/test_lnutil.py
@@ -10,7 +10,7 @@ from electrum.lnutil import (
derive_privkey, derive_pubkey, make_htlc_tx, extract_ctn_from_tx, get_compressed_pubkey_from_bech32,
ScriptHtlc, calc_fees_for_commitment_tx, UpdateAddHtlc, LnFeatures, ln_compare_features,
IncompatibleLightningFeatures, ChannelType, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat,
- ImportedChannelBackupStorage, list_enabled_ln_feature_bits
+ ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget,
)
from electrum.util import bfh, MyEncoder
from electrum.transaction import Transaction, PartialTransaction, Sighash
@@ -1121,3 +1121,33 @@ class TestLNUtil(ElectrumTestCase):
),
decoded_cb,
)
+
+ async def test_payment_fee_budget(self):
+ config = SimpleConfig()
+ # test value above cutoff
+ invoice_amount_msat = 1_000_000 * 1000
+ budget = PaymentFeeBudget.from_invoice_amount(
+ invoice_amount_msat=invoice_amount_msat,
+ config=config,
+ )
+ reversed_fee_msat = PaymentFeeBudget.reverse_from_total_amount(
+ total_amount_msat=invoice_amount_msat + budget.fee_msat,
+ config=config,
+ )
+ self.assertGreater(budget.fee_msat, config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT)
+ self.assertEqual(reversed_fee_msat, budget.fee_msat)
+
+ # test value below cutoff
+ invoice_amount_msat = 1000
+ budget = PaymentFeeBudget.from_invoice_amount(
+ invoice_amount_msat=invoice_amount_msat,
+ config=config,
+ )
+ reversed_fee_msat = PaymentFeeBudget.reverse_from_total_amount(
+ total_amount_msat=invoice_amount_msat + budget.fee_msat,
+ config=config,
+ )
+ self.assertEqual(budget.fee_msat, config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT)
+ self.assertEqual(reversed_fee_msat, budget.fee_msat)
+
+
Why this scored 20/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.