From 23fa50df889026365e65e385d0c1240190cb9611 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 21 Jul 2025 14:13:21 +0200 Subject: [PATCH 1/2] cli: add max_cltv and max_fee_msat parameter to lnpay Adds `max_cltv` and `max_fee_msat` parameters to the `lnpay` cli command which allow to specify the maximum total locktime of the payment and the maximum absolute fee budget. This is enabled by constructing a custom `PaymentFeeBudget` object in the lnpay command and passing it as argument to `LNWallet.pay_invoice()`. Allowing to specify a `max_cltv` value can be useful for certain usecases, e.g. see https://github.com/spesmilo/electrum/issues/10056. Closes #10056 --- electrum/commands.py | 35 +++++++++++++++++++--- electrum/lnutil.py | 70 ++++++++++++++++++++++++++++++++++---------- electrum/lnworker.py | 4 ++- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index b64bc8805..1a764067c 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -67,7 +67,8 @@ from .wallet import ( ) from .address_synchronizer import TX_HEIGHT_LOCAL from .mnemonic import Mnemonic -from .lnutil import channel_id_from_funding_tx, LnFeatures, SENT, MIN_FINAL_CLTV_DELTA_FOR_INVOICE +from .lnutil import (channel_id_from_funding_tx, LnFeatures, SENT, MIN_FINAL_CLTV_DELTA_FOR_INVOICE, + PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) from .plugin import run_hook, DeviceMgr, Plugins from .version import ELECTRUM_VERSION from .simple_config import SimpleConfig @@ -1725,7 +1726,15 @@ class Commands(Logger): return invoice.to_debug_json() @command('wnpl') - async def lnpay(self, invoice, timeout=120, password=None, wallet: Abstract_Wallet = None): + async def lnpay( + self, + invoice: str, + timeout: int = 120, + max_cltv: Optional[int] = None, + max_fee_msat: Optional[int] = None, + password=None, + wallet: Abstract_Wallet = None + ): """ Pay a lightning invoice Note: it is *not* safe to try paying the same invoice multiple times with a timeout. @@ -1734,6 +1743,8 @@ class Commands(Logger): arg:str:invoice:Lightning invoice (bolt 11) arg:int:timeout:Timeout in seconds (default=120) + arg:int:max_cltv:Maximum total time lock for the route (default=4032+invoice_final_cltv_delta) + arg:int:max_fee_msat:Maximum absolute fee budget for the payment (if unset, the default is a percentage fee based on config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS) """ # note: The "timeout" param works via black magic. # The CLI-parser stores it in the config, and the argname matches config.cv.CLI_TIMEOUT.key(). @@ -1741,11 +1752,27 @@ class Commands(Logger): # - FIXME it does NOT work when calling an offline command (-o) # - FIXME it does NOT work when calling RPC directly (e.g. curl) lnworker = wallet.lnworker - lnaddr = lnworker._check_bolt11_invoice(invoice) + lnaddr = lnworker._check_bolt11_invoice(invoice) # also checks if amount is given payment_hash = lnaddr.paymenthash invoice_obj = Invoice.from_bech32(invoice) + assert not max_fee_msat or max_fee_msat < max(invoice_obj.amount_msat // 2, 1_000_000), \ + f"{max_fee_msat=} > max(invoice amount msat / 2, 1_000_000)" wallet.save_invoice(invoice_obj) - success, log = await lnworker.pay_invoice(invoice_obj) + if max_cltv is not None: + # The cltv budget excludes the final cltv delta which is why it is deducted here + # so the whole used cltv is <= max_cltv + assert max_cltv <= NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, \ + f"{max_cltv=} > {NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE=}" + max_cltv_remaining = max_cltv - lnaddr.get_min_final_cltv_delta() + assert max_cltv_remaining > 0, f"{max_cltv=} - {lnaddr.get_min_final_cltv_delta()=} < 1" + max_cltv = max_cltv_remaining + budget = PaymentFeeBudget.custom( + config=wallet.config, + invoice_amount_msat=invoice_obj.amount_msat, + max_cltv_delta=max_cltv, + max_fee_msat=max_fee_msat, + ) + success, log = await lnworker.pay_invoice(invoice_obj, budget=budget) return { 'payment_hash': payment_hash.hex(), 'success': success, diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 70cea6aaf..72fc8173e 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1948,23 +1948,61 @@ class PaymentFeeBudget(NamedTuple): @classmethod def default(cls, *, invoice_amount_msat: int, config: 'SimpleConfig') -> 'PaymentFeeBudget': - millionths_orig = config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS - millionths = min(max(0, millionths_orig), 250_000) # clamp into [0, 25%] - cutoff_orig = config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT - cutoff = min(max(0, cutoff_orig), 10_000_000) # clamp into [0, 10k sat] - if millionths != millionths_orig: - _logger.warning( - f"PaymentFeeBudget. found insane fee millionths in config. " - f"clamped: {millionths_orig}->{millionths}") - if cutoff != cutoff_orig: - _logger.warning( - f"PaymentFeeBudget. found insane fee cutoff in config. " - f"clamped: {cutoff_orig}->{cutoff}") - # for small payments, fees <= constant cutoff are fine - # for large payments, the max fee is percentage-based - fee_msat = invoice_amount_msat * millionths // 1_000_000 - fee_msat = max(fee_msat, cutoff) + fee_msat = PaymentFeeBudget._calculate_fee_msat( + invoice_amount_msat=invoice_amount_msat, + config=config, + ) return PaymentFeeBudget( fee_msat=fee_msat, cltv=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, ) + + @classmethod + def custom( + cls, + config: 'SimpleConfig', + *, + invoice_amount_msat: int, + max_cltv_delta: Optional[int] = None, + max_fee_msat: Optional[int] = None, + ): + if max_fee_msat is None: + max_fee_msat = PaymentFeeBudget._calculate_fee_msat( + invoice_amount_msat=invoice_amount_msat, + config=config, + ) + if max_cltv_delta is None: + max_cltv_delta = NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE + assert max_cltv_delta > 0, max_cltv_delta + return PaymentFeeBudget( + fee_msat=max_fee_msat, + cltv=max_cltv_delta, + ) + + @classmethod + def _calculate_fee_msat(cls, + *, + invoice_amount_msat: int, + config: 'SimpleConfig', + fee_millionths: Optional[int] = None, + fee_cutoff_msat: Optional[int] = None, + ) -> int: + if fee_millionths is None: + 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] + if fee_millionths != millionths_clamped: + _logger.warning( + f"PaymentFeeBudget. found insane fee millionths in config. " + f"clamped: {fee_millionths}->{millionths_clamped}") + if fee_cutoff_msat != cutoff_clamped: + _logger.warning( + f"PaymentFeeBudget. found insane fee cutoff in config. " + f"clamped: {fee_cutoff_msat}->{cutoff_clamped}") + # for small payments, fees <= constant cutoff are fine + # for large payments, the max fee is percentage-based + fee_msat = invoice_amount_msat * millionths_clamped // 1_000_000 + fee_msat = max(fee_msat, cutoff_clamped) + return fee_msat diff --git a/electrum/lnworker.py b/electrum/lnworker.py index ccc737138..9b7f5affc 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1525,6 +1525,7 @@ class LNWallet(LNWorker): attempts: int = None, # used only in unit tests full_path: LNPaymentPath = None, channels: Optional[Sequence[Channel]] = None, + budget: Optional[PaymentFeeBudget] = None, ) -> Tuple[bool, List[HtlcLog]]: bolt11 = invoice.lightning_invoice lnaddr = self._check_bolt11_invoice(bolt11, amount_msat=amount_msat) @@ -1547,7 +1548,8 @@ class LNWallet(LNWorker): self.save_payment_info(info) self.wallet.set_label(key, lnaddr.get_description()) self.set_invoice_status(key, PR_INFLIGHT) - budget = PaymentFeeBudget.default(invoice_amount_msat=amount_to_pay, config=self.config) + if budget is None: + budget = PaymentFeeBudget.default(invoice_amount_msat=amount_to_pay, config=self.config) if attempts is None and self.uses_trampoline(): # we don't expect lots of failed htlcs with trampoline, so we can fail sooner attempts = 30 From 6ddc975a94fa22c49681ae496870743994b41f47 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 1 Aug 2025 15:06:33 +0000 Subject: [PATCH 2/2] follow-up prev: clean-up PaymentFeeBudget API --- electrum/commands.py | 2 +- electrum/lnutil.py | 20 +++++--------------- electrum/lnworker.py | 2 +- tests/test_lnpeer.py | 2 +- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 1a764067c..c07760530 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1766,7 +1766,7 @@ class Commands(Logger): max_cltv_remaining = max_cltv - lnaddr.get_min_final_cltv_delta() assert max_cltv_remaining > 0, f"{max_cltv=} - {lnaddr.get_min_final_cltv_delta()=} < 1" max_cltv = max_cltv_remaining - budget = PaymentFeeBudget.custom( + budget = PaymentFeeBudget.from_invoice_amount( config=wallet.config, invoice_amount_msat=invoice_obj.amount_msat, max_cltv_delta=max_cltv, diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 72fc8173e..3903f6979 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1947,25 +1947,14 @@ class PaymentFeeBudget(NamedTuple): #num_htlc: int @classmethod - def default(cls, *, invoice_amount_msat: int, config: 'SimpleConfig') -> 'PaymentFeeBudget': - fee_msat = PaymentFeeBudget._calculate_fee_msat( - invoice_amount_msat=invoice_amount_msat, - config=config, - ) - return PaymentFeeBudget( - fee_msat=fee_msat, - cltv=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, - ) - - @classmethod - def custom( + def from_invoice_amount( cls, - config: 'SimpleConfig', *, invoice_amount_msat: int, + config: 'SimpleConfig', max_cltv_delta: Optional[int] = None, max_fee_msat: Optional[int] = None, - ): + ) -> 'PaymentFeeBudget': if max_fee_msat is None: max_fee_msat = PaymentFeeBudget._calculate_fee_msat( invoice_amount_msat=invoice_amount_msat, @@ -1980,7 +1969,8 @@ class PaymentFeeBudget(NamedTuple): ) @classmethod - def _calculate_fee_msat(cls, + def _calculate_fee_msat( + cls, *, invoice_amount_msat: int, config: 'SimpleConfig', diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 9b7f5affc..6f5c26d04 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1549,7 +1549,7 @@ class LNWallet(LNWorker): self.wallet.set_label(key, lnaddr.get_description()) self.set_invoice_status(key, PR_INFLIGHT) if budget is None: - budget = PaymentFeeBudget.default(invoice_amount_msat=amount_to_pay, config=self.config) + budget = PaymentFeeBudget.from_invoice_amount(invoice_amount_msat=amount_to_pay, config=self.config) if attempts is None and self.uses_trampoline(): # we don't expect lots of failed htlcs with trampoline, so we can fail sooner attempts = 30 diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index d8a928011..9ffbaf61f 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -288,7 +288,7 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]): amount_msat=amount_msat, paysession=paysession, full_path=full_path, - budget=PaymentFeeBudget.default(invoice_amount_msat=amount_msat, config=self.config), + budget=PaymentFeeBudget.from_invoice_amount(invoice_amount_msat=amount_msat, config=self.config), )] get_payments = LNWallet.get_payments