diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 28f1f4e36..0d30d3718 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -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 diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 347bcc084..d3074abab 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -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 diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py index 944d8a889..944fcc8e7 100644 --- a/tests/test_lnutil.py +++ b/tests/test_lnutil.py @@ -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) + +