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-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! 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 @classmethod
def from_invoice_amount( def from_invoice_amount(
@@ -2067,8 +2068,8 @@ class PaymentFeeBudget(NamedTuple):
fee_millionths = config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS fee_millionths = config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS
if fee_cutoff_msat is None: if fee_cutoff_msat is None:
fee_cutoff_msat = config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT fee_cutoff_msat = config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT
millionths_clamped = min(max(0, fee_millionths), 250_000) # clamp into [0, 25%] millionths_clamped = min(max(0, fee_millionths), cls.PAYMENT_FEE_MILLIONTHS_CLAMP)
cutoff_clamped = min(max(0, fee_cutoff_msat), 10_000_000) # clamp into [0, 10k sat] cutoff_clamped = min(max(0, fee_cutoff_msat), cls.PAYMENT_FEE_CUTOFF_CLAMP)
if fee_millionths != millionths_clamped: if fee_millionths != millionths_clamped:
_logger.warning( _logger.warning(
f"PaymentFeeBudget. found insane fee millionths in config. " 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 = invoice_amount_msat * millionths_clamped // 1_000_000
fee_msat = max(fee_msat, cutoff_clamped) fee_msat = max(fee_msat, cutoff_clamped)
return fee_msat 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 urllib.parse
import itertools import itertools
import dataclasses import dataclasses
from math import ceil
import aiohttp import aiohttp
import dns.asyncresolver import dns.asyncresolver
@@ -2950,15 +2951,17 @@ class LNWallet(LNWorker):
if self.channel_db or self.is_trampoline_peer(c.node_id): if self.channel_db or self.is_trampoline_peer(c.node_id):
yield c yield c
def fee_estimate(self, amount_sat): def estimate_fee_reserve_for_total_amount(self, amount_sat: int | Decimal) -> int:
# Here we have to guess a fee, because some callers (submarine swaps) """
# use this method to initiate a payment, which would otherwise fail. Estimate how much of the given amount needs to be reserved for
fee_base_msat = 5000 # FIXME ehh.. there ought to be a better way... ln payment fees to reliably pay the remaining amount.
fee_proportional_millionths = 500 # FIXME """
# inverse of fee_for_edge_msat amount_msat = ceil(amount_sat * 1000) # round up to the next sat
amount_msat = amount_sat * 1000 fee_msat = PaymentFeeBudget.reverse_from_total_amount(
amount_minus_fees = (amount_msat - fee_base_msat) * 1_000_000 // ( 1_000_000 + fee_proportional_millionths) total_amount_msat=amount_msat,
return Decimal(amount_msat - amount_minus_fees) / 1000 config=self.config,
)
return ceil(Decimal(fee_msat) / 1000)
def num_sats_can_send(self, deltas=None) -> Decimal: 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_dict[c.node_id] += send_capacity(c)
can_send = max(can_send_dict.values()) if can_send_dict else 0 can_send = max(can_send_dict.values()) if can_send_dict else 0
can_send_sat = Decimal(can_send)/1000 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) return max(can_send_sat, 0)
def get_channels_for_receiving( def get_channels_for_receiving(
@@ -3083,7 +3086,7 @@ class LNWallet(LNWorker):
for chan in channels: for chan in channels:
available_sat = chan.available_to_spend(LOCAL if direction == SENT else REMOTE) // 1000 available_sat = chan.available_to_spend(LOCAL if direction == SENT else REMOTE) // 1000
delta = amount_sat - available_sat delta = amount_sat - available_sat
delta += self.fee_estimate(amount_sat) delta += self.estimate_fee_reserve_for_total_amount(amount_sat)
# add safety margin # add safety margin
delta += delta // 100 + 1 delta += delta // 100 + 1
if func(deltas={chan:delta}) >= amount_sat: if func(deltas={chan:delta}) >= amount_sat:
@@ -3106,7 +3109,7 @@ class LNWallet(LNWorker):
return False return False
for chan2, delta in suggestions: for chan2, delta in suggestions:
# margin for fee caused by rebalancing # 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 # find other channel or trampoline that can send delta
for chan1 in self.channels.values(): for chan1 in self.channels.values():
if chan1.is_frozen_for_sending() or not chan1.is_active(): 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): def num_sats_can_rebalance(self, chan1, chan2):
# TODO: we should be able to spend 'max', with variable fee # TODO: we should be able to spend 'max', with variable fee
n1 = chan1.available_to_spend(LOCAL) 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) n2 = chan2.available_to_spend(REMOTE)
amount_sat = min(n1, n2) // 1000 amount_sat = min(n1, n2) // 1000
return amount_sat 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, 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, ScriptHtlc, calc_fees_for_commitment_tx, UpdateAddHtlc, LnFeatures, ln_compare_features,
IncompatibleLightningFeatures, ChannelType, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat, 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.util import bfh, MyEncoder
from electrum.transaction import Transaction, PartialTransaction, Sighash from electrum.transaction import Transaction, PartialTransaction, Sighash
@@ -1121,3 +1121,33 @@ class TestLNUtil(ElectrumTestCase):
), ),
decoded_cb, 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)