Repository: spesmilo/electrum

Issue:
Add support for setting max_cltv when using the lnpay command
### Description

The latest version of electrum added support for hodl invoices, which is wonderful. I want to create an lnproxy implementation that uses electrum as a backend. As a bit of background, how lnproxy works at a high level is this:

Suppose a user wants a sender to pay them money, but doesn't want to disclose their ln pubkey. The user creates a bolt11 invoice as usual and passes it to lnproxy. Lnproxy creates a hodl invoice locked to the same preimage, which only the ultimate recipient knows at this point, and presents it to the user (i.e. the recipient). The user gives that hodl invoice to the sender, who thus learns the proxy's pubkey instead of the recipient's. The sender pays the proxy's invoice. The proxy server cannot resolve the sender's payment because they do not have the preimage -- only the ultimate recipient knows it. Once the proxy server detects that they have a pending htlc from the sender, they "get" the preimage to that htlc by paying the recipient's original invoice. The recipient *can* resolve it, because they *do* have the preimage, and then the proxy uses the preimage to settle the pending hodl invoice that the sender created a pending htlc for.

In order to create an implementation of lnproxy, I need the proxy to set a "minimum" CLTV on their hodl invoice and a "maximum" CLTV on their outbound payment. This is important for the following reason: imagine I am Bob, a proxy between Alice and Carol. Alice tells me an invoice generated by Carol; I create a hodl invoice with the same payment hash, and Alice uses it to create an htlc locked to that payment hash in her channel with me. Then I "pay" the original invoice — i.e. I create an htlc in my channel with Carol.

If she settles that payment immediately, well and good, I use the preimage to settle Alice's payment to me. But what if my htlc with Carol expires "after" my htlc with Alice? Alice and Carol could collude to steal from me: Carol does *not* settle immediately, but waits til Alice's htlc expires; *then* Carol settles, giving me the preimage, but sadly, I can no longer settle Alice's payment to me, because her htlc expired.

To mitigate this, I need to ensure the cltv_expiry on the hodl_invoice I added is relatively high, so that it's hard for Carol's htlc to expire *after* it. This is easy to do in electrum, because the add_hold_invoice method has a parameter for this. However, I *also* need a way to set a maximum cltv on my outbound payment to Carol — otherwise they can ensure Carol's uses a *really* high min_cltv parameter so that sometimes Carol's htlc will *still* expire after Alice's, thus allowing them to steal.

LND solves this by having a parameter called cltv_expiry on their addholdinvoice method, and a parameter called "cltv_limit" on their sendPaymentV2 method. In combination, these let you set a high minimum cltv expiry on your hodl invoices while ensuring that your corresponding outbound payment has a max_cltv_limit that is lower than that, ensuring Alice and Carol cannot collude to steal from you.

Therefore, can you add a max_cltv parameter to the `lnpay` command? This would allow me to safely implement proxy software on top of electrum without the risk of colluders manipulating CLTV values to steal from the proxy.


Discussion:
ACK.

This does not sound too dificult to implement. `LNWallet.pay_invoice()` allocates a `PaymentFeeBudget` internally, and that budget can limit both the fees and the cltv:
https://github.com/spesmilo/electrum/blob/ba07ca3ab9d4118cde7bce4db22c212a8eafb1f1/electrum/lnworker.py#L1550
https://github.com/spesmilo/electrum/blob/ba07ca3ab9d4118cde7bce4db22c212a8eafb1f1/electrum/lnutil.py#L1940-L1945
> Therefore, can you add a max_cltv parameter to the lnpay command? 

To be clear, should this be an absolute height, or a delta?
In LND it's a relative value, e.g. if I set 300 as a max_cltv then I know that my outbound payment will at most be pending til current_blockheight + 300.



Patch:
diff --git a/electrum/commands.py b/electrum/commands.py
index b64bc8805434..c077605309ad 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -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
@@ -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.
@@ -1734,6 +1743,8 @@ 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().
@@ -1741,11 +1752,27 @@ async def lnpay(self, invoice, timeout=120, password=None, wallet: Abstract_Wall
         #       - 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,
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index 70cea6aaf0e1..3903f6979886 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -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
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index ccc737138082..6f5c26d041c6 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -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)
@@ -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


Test patch:
diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py
index d8a928011bd2..9ffbaf61f0f6 100644
--- a/tests/test_lnpeer.py
+++ b/tests/test_lnpeer.py
@@ -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