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