1
0

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.
This commit is contained in:
f321x
2025-11-24 11:01:51 +01:00
parent 3f45c41981
commit af4dc24d87
3 changed files with 77 additions and 17 deletions

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)