Skip to content

cli: add max_cltv, max_fee_msat parameters to lnpay #10067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions electrum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@
)
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
Expand Down Expand Up @@ -1725,7 +1726,15 @@ async def decode_invoice(self, invoice: str):
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.
Expand All @@ -1734,18 +1743,36 @@ async def lnpay(self, invoice, timeout=120, password=None, wallet: Abstract_Wall

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().
# - it works when calling the CLI and there is also a daemon (online command)
# - 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,
Expand Down
58 changes: 43 additions & 15 deletions electrum/lnutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion electrum/lnworker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,7 @@ async def pay_invoice(
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)
Expand All @@ -1547,7 +1548,8 @@ async def pay_invoice(
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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_lnpeer.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ async def create_routes_from_invoice(self, amount_msat: int, decoded_invoice: Ln
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
Expand Down