1
0

Merge pull request #10319 from f321x/fix_lightning_num_sats_can_send

lnworker: use config lightning fee for estimate
This commit is contained in:
ghost43
2025-11-28 15:35:05 +00:00
committed by GitHub
4 changed files with 81 additions and 17 deletions

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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)