Repository: spesmilo/electrum

Issue:
Show time til htlc expires when checking hodl invoice
### Description

When creating a hodl invoice with electrum, I am able to specify a minimum CLTV value, which is great. But since it's a minimum rather than an exact value, the htlc that ends up being created may have a higher value, e.g. it may be for 150 blocks if my minimum was set to 100 (because 150 is above 100, which is only a minimum).

I'd like to know how long I "really" have when building software that relies on getting a transaction through before the htlc expires, e.g. in a submarine swap or a proxy service. It works to just set my "outgoing" CLTV for a value below the "incoming" payment's minimum CLTV, but then every interaction assumes we are stuck at the minimum bound, which can lead to unnecessary payment cancellations in cases where I *really* had more time, but just didn't know it.

To fix this, I'd like it if the check_hold_invoice command not only told me that a hold invoice *has* a pending htlc, but *also* told me the blockheight when that pending htlc expires. With that info, whenever it is above my minimum, it offers me an extra "buffer" when an app requires taking some sort of action before that blockheight is reached.


Discussion:

Patch:
diff --git a/electrum/commands.py b/electrum/commands.py
index f85d15eee20f..703687b574a2 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -1465,7 +1465,13 @@ async def cancel_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet =
     async def check_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
         """
         Checks the status of a lightning hold invoice 'payment_hash'.
-        Possible states: unpaid, paid, settled, unknown (cancelled or not found)
+        Returns: {
+            "status": unpaid | paid | settled | unknown (cancelled or not found),
+            "received_amount_sat": currently received amount (pending htlcs or final after settling),
+            "invoice_amount_sat": Invoice amount, Optional (only if invoice is found),
+            "closest_htlc_expiry_height": Closest absolute expiry height of all received htlcs
+            (Note: HTLCs will get failed automatically if block_height + 144 > htlc_expiry_height)
+        }
 
         arg:str:payment_hash:Payment hash in hex of the hold invoice
         """
@@ -1473,24 +1479,28 @@ async def check_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet =
         info: Optional['PaymentInfo'] = wallet.lnworker.get_payment_info(bfh(payment_hash))
         is_accepted_mpp: bool = wallet.lnworker.is_accepted_mpp(bfh(payment_hash))
         amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
-        status = "unknown"
+        result = {
+            "status": "unknown",
+            "received_amount_sat": amount_sat,
+        }
         if info is None:
             pass
         elif not is_accepted_mpp and not wallet.lnworker.get_preimage_hex(payment_hash):
             # is_accepted_mpp is False for settled payments
-            status = "unpaid"
+            result["status"] = "unpaid"
         elif is_accepted_mpp and payment_hash in wallet.lnworker.dont_settle_htlcs:
-            status = "paid"
+            result["status"] = "paid"
+            payment_key: str = wallet.lnworker._get_payment_key(bfh(payment_hash)).hex()
+            htlc_status = wallet.lnworker.received_mpp_htlcs[payment_key]
+            result["closest_htlc_expiry_height"] = min(
+                htlc.cltv_abs for _, htlc in htlc_status.htlc_set
+            )
         elif wallet.lnworker.get_preimage_hex(payment_hash) is not None \
                 and payment_hash not in wallet.lnworker.dont_settle_htlcs:
-            status = "settled"
+            result["status"] = "settled"
             plist = wallet.lnworker.get_payments(status='settled')[bfh(payment_hash)]
             _dir, amount_msat, _fee, _ts = wallet.lnworker.get_payment_value(info, plist)
-            amount_sat = amount_msat // 1000
-        result = {
-            "status": status,
-            "received_amount_sat": amount_sat,
-        }
+            result["received_amount_sat"] = amount_msat // 1000
         if info is not None:
             result["invoice_amount_sat"] = (info.amount_msat or 0) // 1000
         return result


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 6ef5d1dad6a1..a4448fb7f673 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -6,6 +6,7 @@
 
 from electrum.commands import Commands, eval_bool
 from electrum import storage, wallet
+from electrum.lnworker import RecvMPPResolution
 from electrum.wallet import restore_wallet_from_text, Abstract_Wallet
 from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED
 from electrum.simple_config import SimpleConfig
@@ -491,11 +492,22 @@ async def test_hold_invoice_commands(self, mock_save_db):
                 wallet=wallet,
             )
 
-        with mock.patch.object(wallet.lnworker, 'is_accepted_mpp', return_value=True), \
-                mock.patch.object(wallet.lnworker, 'get_payment_mpp_amount_msat', return_value=10_000 * 1000):
+        mock_htlc1 = mock.Mock()
+        mock_htlc1.cltv_abs = 800_000
+        mock_htlc1.amount_msat = 4_500_000
+        mock_htlc2 = mock.Mock()
+        mock_htlc2.cltv_abs = 800_144
+        mock_htlc2.amount_msat = 5_500_000
+        mock_htlc_status = mock.Mock()
+        mock_htlc_status.htlc_set = [(None, mock_htlc1), (None, mock_htlc2)]
+        mock_htlc_status.resolution = RecvMPPResolution.ACCEPTED
+
+        payment_key = wallet.lnworker._get_payment_key(bytes.fromhex(payment_hash)).hex()
+        with mock.patch.dict(wallet.lnworker.received_mpp_htlcs, {payment_key: mock_htlc_status}):
             status: dict = await cmds.check_hold_invoice(payment_hash=payment_hash, wallet=wallet)
             assert status['status'] == 'paid'
             assert status['received_amount_sat'] == 10000
+            assert status['closest_htlc_expiry_height'] == 800_000
 
             settle_result = await cmds.settle_hold_invoice(
                 preimage=preimage.hex(),