1
0

Merge pull request #10067 from f321x/max_cltv_lnpay

cli: add max_cltv, max_fee_msat parameters to lnpay
This commit is contained in:
ghost43
2025-08-01 15:14:44 +00:00
committed by GitHub
4 changed files with 78 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
@@ -1735,7 +1736,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.
@@ -1744,6 +1753,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().
@@ -1751,11 +1762,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.from_invoice_amount(
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

@@ -1947,24 +1947,52 @@ class PaymentFeeBudget(NamedTuple):
#num_htlc: int
@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:
def from_invoice_amount(
cls,
*,
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,
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: {millionths_orig}->{millionths}")
if cutoff != cutoff_orig:
f"clamped: {fee_millionths}->{millionths_clamped}")
if fee_cutoff_msat != cutoff_clamped:
_logger.warning(
f"PaymentFeeBudget. found insane fee cutoff in config. "
f"clamped: {cutoff_orig}->{cutoff}")
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 // 1_000_000
fee_msat = max(fee_msat, cutoff)
return PaymentFeeBudget(
fee_msat=fee_msat,
cltv=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE,
)
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.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

View File

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