From af4dc24d87424f97e0661f1c10911d8ef2ea4ce5 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 24 Nov 2025 11:01:51 +0100 Subject: [PATCH 1/2] lnworker: use config lightning fee for estimate I was unable to do a "Max" amount submarine swap because the `fee_estimate` method used by `LNWallet.num_sats_can_send()` uses a hardcoded `fee_proportional_millionths` to estimate the fee for the lightning payment. When the actual fee determined later is higher than the estimated fee the payment fails as the channel is unable to add the htlc sum including the real fees as the amount exceeds the balance of the channel. Using the fees the maximum fees user has configured and estimate the potential fee as inverse of PaymentFeeBudget is more reliable/conservative as we definitely aren't going to pay more fees than this amount. --- electrum/lnutil.py | 33 ++++++++++++++++++++++++++++++--- electrum/lnworker.py | 29 ++++++++++++++++------------- tests/test_lnutil.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 28f1f4e36..0d30d3718 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 347bcc084..d3074abab 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 944d8a889..944fcc8e7 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) + + From c575abc8e2267b6787a5671e88abdcc00157f026 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 27 Nov 2025 13:24:39 +0100 Subject: [PATCH 2/2] qt: update tabs on exit of SettingsDialog Update the tabs after the SettingsDialog gets closed, some values might have to be updated according to the new configuration. --- electrum/gui/qt/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index f7c1ec965..41448840f 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2720,6 +2720,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): run_hook('close_settings_dialog') if d.need_restart: self.show_warning(_('Please restart Electrum to activate the new GUI settings'), title=_('Success')) + else: + # Some values might need to be updated if settings have changed. + # For example 'Can send' in the lightning tab will change if the fees config is changed. + self.refresh_tabs() def _show_closing_warnings(self) -> bool: """Show any closing warnings and return True if the user chose to quit anyway."""