Skip to content

Commit 70a6cef

Browse files
committed
add hold invoice cli functionality
1 parent 2b68f65 commit 70a6cef

File tree

6 files changed

+236
-10
lines changed

6 files changed

+236
-10
lines changed

electrum/commands.py

+131-4
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import io
2626
import sys
2727
import datetime
28+
import time
2829
import argparse
2930
import json
3031
import ast
@@ -60,10 +61,8 @@
6061
from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet, BumpFeeStrategy, Imported_Wallet
6162
from .address_synchronizer import TX_HEIGHT_LOCAL
6263
from .mnemonic import Mnemonic
63-
from .lnutil import SENT, RECEIVED
64-
from .lnutil import LnFeatures
6564
from .lntransport import extract_nodeid
66-
from .lnutil import channel_id_from_funding_tx
65+
from .lnutil import channel_id_from_funding_tx, LnFeatures, SENT, RECEIVED, MIN_FINAL_CLTV_DELTA_FOR_INVOICE
6766
from .plugin import run_hook, DeviceMgr, Plugins
6867
from .version import ELECTRUM_VERSION
6968
from .simple_config import SimpleConfig
@@ -77,6 +76,7 @@
7776
if TYPE_CHECKING:
7877
from .network import Network
7978
from .daemon import Daemon
79+
from electrum.lnworker import PaymentInfo
8080

8181

8282
known_commands = {} # type: Dict[str, Command]
@@ -995,7 +995,6 @@ async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_
995995
def get_year_timestamps(self, year:int):
996996
kwargs = {}
997997
if year:
998-
import time
999998
start_date = datetime.datetime(year, 1, 1)
1000999
end_date = datetime.datetime(year+1, 1, 1)
10011000
kwargs['from_timestamp'] = time.mktime(start_date.timetuple())
@@ -1349,6 +1348,134 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force
13491348
req = wallet.get_request(key)
13501349
return wallet.export_request(req)
13511350

1351+
@command('wnl')
1352+
async def add_hold_invoice(
1353+
self,
1354+
preimage: str,
1355+
amount: Optional[Decimal] = None,
1356+
memo: str = "",
1357+
expiry: int = 3600,
1358+
min_final_cltv_expiry_delta: int = MIN_FINAL_CLTV_DELTA_FOR_INVOICE * 2,
1359+
wallet: Abstract_Wallet = None
1360+
) -> dict:
1361+
"""
1362+
Create a lightning hold invoice for the given preimage. Hold invoices have to get settled manually later.
1363+
HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs.
1364+
1365+
arg:str:preimage:Hex encoded preimage to be used for the invoice
1366+
arg:decimal:amount:Optional requested amount (in btc)
1367+
arg:str:memo:Optional description of the invoice
1368+
arg:int:expiry:Optional expiry in seconds (default: 3600s)
1369+
arg:int:min_final_cltv_expiry_delta:Optional min final cltv expiry delta (default: 294 blocks)
1370+
"""
1371+
assert len(preimage) == 64, f"Invalid preimage length: {len(preimage)} != 64"
1372+
payment_hash: str = crypto.sha256(bfh(preimage)).hex()
1373+
assert preimage not in wallet.lnworker.preimages, "Preimage has been used as payment hash already!"
1374+
assert payment_hash not in wallet.lnworker.preimages, "Preimage already in use!"
1375+
assert payment_hash not in wallet.lnworker.payment_info, "Payment hash already used!"
1376+
assert payment_hash not in wallet.lnworker.dont_settle_htlcs, "Payment hash already used!"
1377+
assert MIN_FINAL_CLTV_DELTA_FOR_INVOICE < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value"
1378+
amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None
1379+
inbound_capacity = wallet.lnworker.num_sats_can_receive()
1380+
assert inbound_capacity > satoshis(amount or 0), \
1381+
f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment"
1382+
1383+
lnaddr, invoice = wallet.lnworker.get_bolt11_invoice(
1384+
payment_hash=bfh(payment_hash),
1385+
amount_msat=satoshis(amount) * 1000 if amount else None,
1386+
message=memo,
1387+
expiry=expiry,
1388+
min_final_cltv_expiry_delta=min_final_cltv_expiry_delta,
1389+
fallback_address=None
1390+
)
1391+
wallet.lnworker.add_payment_info_for_hold_invoice(
1392+
bfh(payment_hash),
1393+
satoshis(amount) if amount else None,
1394+
)
1395+
wallet.lnworker.dont_settle_htlcs[payment_hash] = None
1396+
wallet.lnworker.save_preimage(bfh(payment_hash), bfh(preimage))
1397+
wallet.set_label(payment_hash, memo)
1398+
result = {
1399+
"invoice": invoice
1400+
}
1401+
return result
1402+
1403+
@command('wnl')
1404+
async def settle_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
1405+
"""
1406+
Settles lightning hold invoice 'payment_hash' using the stored preimage.
1407+
Doesn't block until actual settlement of the HTLCs.
1408+
1409+
arg:str:payment_hash:Hex encoded payment hash of the invoice to be settled
1410+
"""
1411+
assert len(payment_hash) == 64, f"Invalid payment_hash length: {len(payment_hash)} != 64"
1412+
assert payment_hash in wallet.lnworker.preimages, f"Couldn't find preimage for {payment_hash}"
1413+
assert payment_hash in wallet.lnworker.dont_settle_htlcs, "Is already settled!"
1414+
assert payment_hash in wallet.lnworker.payment_info, \
1415+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1416+
assert wallet.lnworker.is_accepted_mpp(bfh(payment_hash)), \
1417+
f"MPP incomplete, cannot settle hold invoice {payment_hash} yet"
1418+
del wallet.lnworker.dont_settle_htlcs[payment_hash]
1419+
util.trigger_callback('wallet_updated', wallet)
1420+
result = {
1421+
"settled": payment_hash
1422+
}
1423+
return result
1424+
1425+
@command('wnl')
1426+
async def cancel_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
1427+
"""
1428+
Cancels lightning hold invoice 'payment_hash'.
1429+
1430+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1431+
"""
1432+
assert payment_hash in wallet.lnworker.payment_info, \
1433+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1434+
assert payment_hash in wallet.lnworker.preimages, "Nothing to cancel, no known preimage."
1435+
assert payment_hash in wallet.lnworker.dont_settle_htlcs, "Is already settled!"
1436+
del wallet.lnworker.preimages[payment_hash]
1437+
# set to PR_UNPAID so it can get deleted
1438+
wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID)
1439+
wallet.lnworker.delete_payment_info(payment_hash)
1440+
wallet.set_label(payment_hash, None)
1441+
while wallet.lnworker.is_accepted_mpp(bfh(payment_hash)):
1442+
# wait until the htlcs got failed so the payment won't get settled accidentally in a race
1443+
await asyncio.sleep(0.1)
1444+
del wallet.lnworker.dont_settle_htlcs[payment_hash]
1445+
result = {
1446+
"cancelled": payment_hash
1447+
}
1448+
return result
1449+
1450+
@command('wnl')
1451+
async def check_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
1452+
"""
1453+
Checks the status of a lightning hold invoice 'payment_hash'.
1454+
Possible states: unpaid, paid, settled, unknown (cancelled or not found)
1455+
1456+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1457+
"""
1458+
assert len(payment_hash) == 64, f"Invalid payment_hash length: {len(payment_hash)} != 64"
1459+
info: Optional['PaymentInfo'] = wallet.lnworker.get_payment_info(bfh(payment_hash))
1460+
is_accepted_mpp: bool = wallet.lnworker.is_accepted_mpp(bfh(payment_hash))
1461+
amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
1462+
status = "unknown"
1463+
if info is None:
1464+
pass
1465+
elif not is_accepted_mpp:
1466+
status = "unpaid"
1467+
elif is_accepted_mpp and payment_hash in wallet.lnworker.dont_settle_htlcs:
1468+
status = "paid"
1469+
elif (payment_hash in wallet.lnworker.preimages
1470+
and payment_hash not in wallet.lnworker.dont_settle_htlcs
1471+
and is_accepted_mpp):
1472+
status = "settled"
1473+
result = {
1474+
"status": status,
1475+
"amount_sat": amount_sat
1476+
}
1477+
return result
1478+
13521479
@command('w')
13531480
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
13541481
"""

electrum/daemon.py

+1
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ def __init__(
420420
self.commands_server = CommandsServer(self, fd)
421421
asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.commands_server.run()), self.asyncio_loop)
422422

423+
423424
@log_exceptions
424425
async def _run(self):
425426
self.logger.info("starting taskgroup.")

electrum/lnworker.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -2307,9 +2307,11 @@ def get_payment_info(self, payment_hash: bytes) -> Optional[PaymentInfo]:
23072307
if key in self.payment_info:
23082308
amount_msat, direction, status = self.payment_info[key]
23092309
return PaymentInfo(payment_hash, amount_msat, direction, status)
2310+
return None
23102311

2311-
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: int):
2312-
info = PaymentInfo(payment_hash, lightning_amount_sat * 1000, RECEIVED, PR_UNPAID)
2312+
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]):
2313+
amount = lightning_amount_sat * 1000 if lightning_amount_sat else None
2314+
info = PaymentInfo(payment_hash, amount, RECEIVED, PR_UNPAID)
23132315
self.save_payment_info(info, write_to_disk=False)
23142316

23152317
def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]):
@@ -2402,17 +2404,34 @@ def set_mpp_resolution(self, *, payment_key: bytes, resolution: RecvMPPResolutio
24022404
self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution)
24032405

24042406
def is_mpp_amount_reached(self, payment_key: bytes) -> bool:
2405-
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2406-
if not mpp_status:
2407+
amounts = self.get_mpp_amounts(payment_key)
2408+
if amounts is None:
24072409
return False
2408-
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2409-
return total >= mpp_status.expected_msat
2410+
total, expected = amounts
2411+
return total >= expected
24102412

24112413
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
24122414
payment_key = self._get_payment_key(payment_hash)
24132415
status = self.received_mpp_htlcs.get(payment_key.hex())
24142416
return status and status.resolution == RecvMPPResolution.ACCEPTED
24152417

2418+
def get_payment_mpp_amount_msat(self, payment_hash: bytes) -> Optional[int]:
2419+
"""Returns the received mpp amount for given payment hash."""
2420+
payment_key = self._get_payment_key(payment_hash)
2421+
amounts = self.get_mpp_amounts(payment_key)
2422+
if not amounts:
2423+
return None
2424+
total_msat, _ = amounts
2425+
return total_msat
2426+
2427+
def get_mpp_amounts(self, payment_key: bytes) -> Optional[Tuple[int, int]]:
2428+
"""Returns (total received amount, expected amount) or None."""
2429+
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2430+
if not mpp_status:
2431+
return None
2432+
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2433+
return total, mpp_status.expected_msat
2434+
24162435
def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int:
24172436
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
24182437
if not mpp_status:

electrum/network.py

+8
Original file line numberDiff line numberDiff line change
@@ -1591,3 +1591,11 @@ async def prune_offline_servers(self, hostmap):
15911591
servers_dict = {k: v for k, v in hostmap.items()
15921592
if k in servers_replied}
15931593
return servers_dict
1594+
1595+
1596+
class IPCNotifier:
1597+
socket_file_path = "/tmp/electrum_notifications"
1598+
1599+
def __init__(self):
1600+
pass
1601+

tests/test_commands.py

+70
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import unittest
22
from unittest import mock
33
from decimal import Decimal
4+
from os import urandom
45

56
from electrum.commands import Commands, eval_bool
67
from electrum import storage, wallet
@@ -9,6 +10,8 @@
910
from electrum.simple_config import SimpleConfig
1011
from electrum.transaction import Transaction, TxOutput, tx_from_any
1112
from electrum.util import UserFacingException, NotEnoughFunds
13+
from electrum.crypto import sha256
14+
from electrum.lnaddr import lndecode
1215

1316
from . import ElectrumTestCase
1417
from .test_wallet_vertical import WalletIntegrityHelper
@@ -405,3 +408,70 @@ async def test_importprivkey(self, mock_save_db):
405408
self.assertEqual({"good_keys": 1, "bad_keys": 2},
406409
await cmds.importprivkey(privkeys2_str, wallet=wallet))
407410
self.assertEqual(10, len(wallet.get_addresses()))
411+
412+
@mock.patch.object(wallet.Abstract_Wallet, 'save_db')
413+
async def test_hold_invoice_commands(self, mock_save_db):
414+
wallet: Abstract_Wallet = restore_wallet_from_text(
415+
'disagree rug lemon bean unaware square alone beach tennis exhibit fix mimic',
416+
gap_limit=2,
417+
path='if_this_exists_mocking_failed_648151893',
418+
config=self.config)['wallet']
419+
420+
cmds = Commands(config=self.config)
421+
preimage: str = sha256(urandom(32)).hex()
422+
payment_hash: str = sha256(bytes.fromhex(preimage)).hex()
423+
with (mock.patch.object(wallet.lnworker, 'num_sats_can_receive', return_value=1000000)):
424+
result = await cmds.add_hold_invoice(
425+
preimage=preimage,
426+
amount=0.0001,
427+
memo="test",
428+
expiry=3500,
429+
wallet=wallet,
430+
)
431+
invoice = lndecode(invoice=result['invoice'])
432+
assert invoice.paymenthash.hex() == payment_hash
433+
assert payment_hash in wallet.lnworker.preimages
434+
assert payment_hash in wallet.lnworker.payment_info
435+
assert payment_hash in wallet.lnworker.dont_settle_htlcs
436+
assert invoice.get_amount_sat() == 10000
437+
438+
cancel_result = await cmds.cancel_hold_invoice(
439+
payment_hash=payment_hash,
440+
wallet=wallet,
441+
)
442+
assert payment_hash not in wallet.lnworker.payment_info
443+
assert cancel_result['cancelled'] == payment_hash
444+
445+
with self.assertRaises(AssertionError):
446+
# settling a cancelled invoice should raise
447+
await cmds.settle_hold_invoice(
448+
payment_hash=payment_hash,
449+
wallet=wallet,
450+
)
451+
# cancelling an unknown invoice should raise an exception
452+
await cmds.cancel_hold_invoice(
453+
payment_hash=sha256(urandom(32)).hex(),
454+
wallet=wallet,
455+
)
456+
457+
# add another hold invoice
458+
preimage: bytes = sha256(urandom(32))
459+
payment_hash: str = sha256(preimage).hex()
460+
with mock.patch.object(wallet.lnworker, 'num_sats_can_receive', return_value=1000000):
461+
await cmds.add_hold_invoice(
462+
preimage=preimage.hex(),
463+
amount=0.0001,
464+
wallet=wallet,
465+
)
466+
467+
with mock.patch.object(wallet.lnworker, 'is_accepted_mpp', return_value=True):
468+
settle_result = await cmds.settle_hold_invoice(
469+
payment_hash=payment_hash,
470+
wallet=wallet,
471+
)
472+
assert settle_result['settled'] == payment_hash
473+
assert wallet.lnworker.preimages[payment_hash] == preimage.hex()
474+
475+
with self.assertRaises(AssertionError):
476+
# cancelling a settled invoice should raise
477+
await cmds.cancel_hold_invoice(payment_hash=payment_hash, wallet=wallet)

tests/test_lnpeer.py

+1
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ async def create_routes_from_invoice(self, amount_msat: int, decoded_invoice: Ln
331331
update_mpp_with_received_htlc = LNWallet.update_mpp_with_received_htlc
332332
set_mpp_resolution = LNWallet.set_mpp_resolution
333333
is_mpp_amount_reached = LNWallet.is_mpp_amount_reached
334+
get_mpp_amounts = LNWallet.get_mpp_amounts
334335
get_first_timestamp_of_mpp = LNWallet.get_first_timestamp_of_mpp
335336
bundle_payments = LNWallet.bundle_payments
336337
get_payment_bundle = LNWallet.get_payment_bundle

0 commit comments

Comments
 (0)