1
0

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
This commit is contained in:
f321x
2025-07-21 14:13:21 +02:00
parent 8e5f29e890
commit 23fa50df88
3 changed files with 88 additions and 21 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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