Skip to content

Commit 4c870d3

Browse files
committed
add hold invoice cli functionality
1 parent 3eac741 commit 4c870d3

File tree

4 files changed

+258
-11
lines changed

4 files changed

+258
-11
lines changed

electrum/commands.py

+121-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
@@ -995,7 +994,6 @@ async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_
995994
def get_year_timestamps(self, year:int):
996995
kwargs = {}
997996
if year:
998-
import time
999997
start_date = datetime.datetime(year, 1, 1)
1000998
end_date = datetime.datetime(year+1, 1, 1)
1001999
kwargs['from_timestamp'] = time.mktime(start_date.timetuple())
@@ -1346,6 +1344,125 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force
13461344
req = wallet.get_request(key)
13471345
return wallet.export_request(req)
13481346

1347+
@command('wnl')
1348+
async def add_hold_invoice(
1349+
self,
1350+
payment_hash,
1351+
amount = None,
1352+
memo = "",
1353+
expiry = 3600,
1354+
min_final_cltv_expiry_delta = MIN_FINAL_CLTV_DELTA_FOR_INVOICE * 2,
1355+
callback_url = None,
1356+
wallet: Abstract_Wallet = None
1357+
) -> dict:
1358+
"""
1359+
Create a lightning hold invoice for the given payment hash. Hold invoices have to get settled manually later.
1360+
HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs.
1361+
1362+
arg:str:payment_hash:Hex encoded payment hash to be used for the invoice
1363+
arg:decimal:amount:Optional requested amount (in btc)
1364+
arg:str:memo:Optional description of the invoice
1365+
arg:int:expiry:Optional expiry in seconds (default: 3600s)
1366+
arg:int:min_final_cltv_expiry_delta:Optional min final cltv expiry delta (default: 294 blocks)
1367+
arg:str:callback_url:Optional callback URL for the invoice, if provided a POST request will be triggered once all htlcs arrived (doesn't use proxy)
1368+
"""
1369+
assert len(payment_hash) == 64, f"Invalid payment hash length: {len(payment_hash)} != 64"
1370+
assert payment_hash not in wallet.lnworker.payment_info, "Payment hash already in use!"
1371+
assert MIN_FINAL_CLTV_DELTA_FOR_INVOICE < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value"
1372+
amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None
1373+
inbound_capacity = wallet.lnworker.num_sats_can_receive()
1374+
assert inbound_capacity > satoshis(amount or 0), \
1375+
f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment"
1376+
if callback_url:
1377+
assert callback_url.startswith("http"), "Callback URL must be http(s)://"
1378+
1379+
lnaddr, invoice = wallet.lnworker.get_bolt11_invoice(
1380+
payment_hash=bfh(payment_hash),
1381+
amount_msat=satoshis(amount) * 1000 if amount else None,
1382+
message=memo,
1383+
expiry=expiry,
1384+
min_final_cltv_expiry_delta=min_final_cltv_expiry_delta,
1385+
fallback_address=None
1386+
)
1387+
wallet.lnworker.add_payment_info_for_hold_invoice(
1388+
bfh(payment_hash),
1389+
satoshis(amount) if amount else None,
1390+
)
1391+
# ts after which the callback will not get registered anymore on restart
1392+
cb_expiration_ts = int(time.time()) + min_final_cltv_expiry_delta * 10 * 60 * 2
1393+
wallet.lnworker.register_cli_hold_invoice(payment_hash, cb_expiration_ts, callback_url)
1394+
result = {
1395+
"invoice": invoice
1396+
}
1397+
return result
1398+
1399+
@command('wnl')
1400+
async def settle_hold_invoice(self, preimage, wallet: Abstract_Wallet = None) -> dict:
1401+
"""
1402+
Settles lightning hold invoice with 'preimage'.
1403+
Doesn't wait for actual settlement of the HTLCs.
1404+
1405+
arg:str:preimage:Hex encoded preimage of the payment hash
1406+
"""
1407+
assert len(preimage) == 64, f"Invalid preimage length: {len(preimage)} != 64"
1408+
payment_hash: str = crypto.sha256(bfh(preimage)).hex()
1409+
assert payment_hash in wallet.lnworker.payment_info, \
1410+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1411+
assert wallet.lnworker.is_accepted_mpp(bfh(payment_hash)), \
1412+
f"MPP incomplete, cannot settle hold invoice with preimage {preimage}"
1413+
wallet.lnworker.save_preimage(bfh(payment_hash), preimage=bfh(preimage))
1414+
wallet.lnworker.unregister_hold_invoice(bfh(payment_hash))
1415+
util.trigger_callback('wallet_updated', wallet)
1416+
result = {
1417+
"settled": payment_hash
1418+
}
1419+
return result
1420+
1421+
@command('wnl')
1422+
async def cancel_hold_invoice(self, payment_hash, wallet: Abstract_Wallet = None) -> dict:
1423+
"""
1424+
Cancels lightning hold invoice 'payment_hash'.
1425+
1426+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1427+
"""
1428+
assert payment_hash in wallet.lnworker.payment_info, \
1429+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1430+
assert payment_hash not in wallet.lnworker.preimages, \
1431+
f"Hold invoice already settled with preimage: {crypto.sha256(bfh(payment_hash)).hex()}"
1432+
# set to PR_UNPAID so it can get deleted
1433+
wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID)
1434+
wallet.lnworker.delete_payment_info(payment_hash)
1435+
wallet.lnworker.unregister_hold_invoice(bfh(payment_hash))
1436+
result = {
1437+
"cancelled": payment_hash
1438+
}
1439+
return result
1440+
1441+
@command('wnl')
1442+
async def check_hold_invoice(self, payment_hash, wallet: Abstract_Wallet = None) -> dict:
1443+
"""
1444+
Checks the status of a lightning hold invoice 'payment_hash'.
1445+
Possible states: unpaid, paid, settled, unknown (cancelled or not found)
1446+
1447+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1448+
"""
1449+
info = wallet.lnworker.get_payment_info(bfh(payment_hash))
1450+
amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
1451+
status = "unknown"
1452+
if info is None:
1453+
pass
1454+
elif info.status == PR_UNPAID or not wallet.lnworker.is_accepted_mpp(bfh(payment_hash)):
1455+
status = "unpaid"
1456+
elif info.status == PR_PAID and payment_hash not in wallet.lnworker.preimages:
1457+
status = "paid"
1458+
elif payment_hash in wallet.lnworker.preimages:
1459+
status = "settled"
1460+
result = {
1461+
"status": status,
1462+
"amount_sat": amount_sat
1463+
}
1464+
return result
1465+
13491466
@command('w')
13501467
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
13511468
"""

electrum/lnworker.py

+61-7
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
LnKeyFamily, LOCAL, REMOTE, MIN_FINAL_CLTV_DELTA_FOR_INVOICE, SENT, RECEIVED, HTLCOwner, UpdateAddHtlc, LnFeatures,
6363
ShortChannelID, HtlcLog, NoPathFound, InvalidGossipMsg, FeeBudgetExceeded, ImportedChannelBackupStorage,
6464
OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget,
65-
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT
65+
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, MIN_FINAL_CLTV_DELTA_ACCEPTED
6666
)
6767
from .lnonion import decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket
6868
from .lnmsg import decode_msg
@@ -885,6 +885,13 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv):
885885

886886
# payment_hash -> callback:
887887
self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]]
888+
self.cli_hold_invoice_callbacks = self.db.get_dict('cli_hold_invoice_cbs') # type: Dict[str, Tuple[Optional[str], int]] # payment_hash -> (callback_url, expiry_ts)
889+
for payment_hash, (callback_url, expiry_ts) in list(self.cli_hold_invoice_callbacks.items()):
890+
if expiry_ts < time.time():
891+
# delete old callbacks that are not going to get called anymore
892+
del self.cli_hold_invoice_callbacks[payment_hash]
893+
else: # re-register the callback
894+
self.register_cli_hold_invoice(payment_hash, expiry_ts, callback_url)
888895
self.payment_bundles = [] # lists of hashes. todo:persist
889896

890897
self.nostr_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NOSTR_KEY)
@@ -2308,15 +2315,45 @@ def get_payment_info(self, payment_hash: bytes) -> Optional[PaymentInfo]:
23082315
amount_msat, direction, status = self.payment_info[key]
23092316
return PaymentInfo(payment_hash, amount_msat, direction, status)
23102317

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)
2318+
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]):
2319+
amount = lightning_amount_sat * 1000 if lightning_amount_sat else None
2320+
info = PaymentInfo(payment_hash, amount, RECEIVED, PR_UNPAID)
23132321
self.save_payment_info(info, write_to_disk=False)
23142322

23152323
def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]):
23162324
self.hold_invoice_callbacks[payment_hash] = cb
23172325

23182326
def unregister_hold_invoice(self, payment_hash: bytes):
23192327
self.hold_invoice_callbacks.pop(payment_hash)
2328+
self.cli_hold_invoice_callbacks.pop(payment_hash.hex(), None)
2329+
2330+
def register_cli_hold_invoice(
2331+
self,
2332+
payment_hash: str,
2333+
expiry_ts: int,
2334+
callback_url: Optional[str] = None,
2335+
) -> None:
2336+
async def cli_hold_invoice_callback(payment_hash_bytes: bytes):
2337+
"""Hold invoice callback for hold invoices registered via CLI."""
2338+
self.logger.debug(f"Hold invoice {payment_hash_bytes.hex()} ready for settlement")
2339+
self.set_payment_status(payment_hash_bytes, PR_PAID)
2340+
if not callback_url:
2341+
return
2342+
amount_sat = (self.get_payment_mpp_amount_msat(payment_hash_bytes) or 0) // 1000
2343+
data = {
2344+
"payment_hash": payment_hash_bytes.hex(),
2345+
"amount_sat": amount_sat
2346+
}
2347+
try:
2348+
async with make_aiohttp_session(proxy=None) as s:
2349+
await s.post(callback_url, json=data, raise_for_status=False)
2350+
except Exception as e:
2351+
self.logger.debug(f"hold invoice callback request to {callback_url} raised: {str(e)}")
2352+
2353+
self.register_hold_invoice(bfh(payment_hash), cli_hold_invoice_callback)
2354+
if payment_hash not in self.cli_hold_invoice_callbacks:
2355+
# persist the callback so it can be re-registered after restart
2356+
self.cli_hold_invoice_callbacks[payment_hash] = (callback_url, expiry_ts)
23202357

23212358
def save_payment_info(self, info: PaymentInfo, *, write_to_disk: bool = True) -> None:
23222359
key = info.payment_hash.hex()
@@ -2402,17 +2439,34 @@ def set_mpp_resolution(self, *, payment_key: bytes, resolution: RecvMPPResolutio
24022439
self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution)
24032440

24042441
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:
2442+
amounts = self.get_mpp_amounts(payment_key)
2443+
if amounts is None:
24072444
return False
2408-
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2409-
return total >= mpp_status.expected_msat
2445+
total, expected = amounts
2446+
return total >= expected
24102447

24112448
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
24122449
payment_key = self._get_payment_key(payment_hash)
24132450
status = self.received_mpp_htlcs.get(payment_key.hex())
24142451
return status and status.resolution == RecvMPPResolution.ACCEPTED
24152452

2453+
def get_payment_mpp_amount_msat(self, payment_hash: bytes) -> Optional[int]:
2454+
"""Returns the received mpp amount for given payment hash."""
2455+
payment_key = self._get_payment_key(payment_hash)
2456+
amounts = self.get_mpp_amounts(payment_key)
2457+
if not amounts:
2458+
return None
2459+
total_msat, _ = amounts
2460+
return total_msat
2461+
2462+
def get_mpp_amounts(self, payment_key: bytes) -> Optional[Tuple[int, int]]:
2463+
"""Returns (total received amount, expected amount) or None."""
2464+
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2465+
if not mpp_status:
2466+
return None
2467+
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2468+
return total, mpp_status.expected_msat
2469+
24162470
def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int:
24172471
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
24182472
if not mpp_status:

tests/test_commands.py

+75
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,75 @@ 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+
payment_hash=payment_hash,
426+
amount=0.0001,
427+
memo="test",
428+
expiry=3500,
429+
wallet=wallet,
430+
)
431+
invoice = lndecode(invoice=result['invoice'])
432+
# no preimage should be generated or stored
433+
assert invoice.paymenthash.hex() == payment_hash
434+
assert payment_hash not in wallet.lnworker.preimages
435+
# a callback should get registered
436+
assert payment_hash in wallet.lnworker.cli_hold_invoice_callbacks
437+
assert payment_hash in wallet.lnworker.payment_info
438+
assert invoice.get_amount_sat() == 10000
439+
440+
cancel_result = await cmds.cancel_hold_invoice(
441+
payment_hash=payment_hash,
442+
wallet=wallet,
443+
)
444+
assert payment_hash not in wallet.lnworker.cli_hold_invoice_callbacks
445+
assert payment_hash not in wallet.lnworker.payment_info
446+
assert cancel_result['cancelled'] == payment_hash
447+
448+
with self.assertRaises(AssertionError):
449+
# settling a cancelled invoice should raise
450+
await cmds.settle_hold_invoice(
451+
preimage=preimage,
452+
wallet=wallet,
453+
)
454+
# cancelling an unknown invoice should raise an exception
455+
await cmds.cancel_hold_invoice(
456+
payment_hash=sha256(urandom(32)).hex(),
457+
wallet=wallet,
458+
)
459+
460+
# add another hold invoice
461+
preimage: bytes = sha256(urandom(32))
462+
payment_hash: str = sha256(preimage).hex()
463+
with mock.patch.object(wallet.lnworker, 'num_sats_can_receive', return_value=1000000):
464+
await cmds.add_hold_invoice(
465+
payment_hash=payment_hash,
466+
amount=0.0001,
467+
wallet=wallet,
468+
)
469+
470+
with mock.patch.object(wallet.lnworker, 'is_accepted_mpp', return_value=True):
471+
settle_result = await cmds.settle_hold_invoice(
472+
preimage=preimage.hex(),
473+
wallet=wallet,
474+
)
475+
assert settle_result['settled'] == payment_hash
476+
assert wallet.lnworker.preimages[payment_hash] == preimage.hex()
477+
assert payment_hash not in wallet.lnworker.cli_hold_invoice_callbacks
478+
assert bytes.fromhex(payment_hash) not in wallet.lnworker.hold_invoice_callbacks
479+
480+
with self.assertRaises(AssertionError):
481+
# cancelling a settled invoice should raise
482+
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)