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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user