Skip to content

Commit a57b542

Browse files
committed
add hold invoice cli functionality
1 parent c5e9ef5 commit a57b542

File tree

3 files changed

+248
-12
lines changed

3 files changed

+248
-12
lines changed

electrum/commands.py

+116-5
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from .logging import Logger
4747
from .onion_message import create_blinded_path, send_onion_message_to
4848
from .util import (bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes,
49-
parse_max_spend, to_decimal, UserFacingException, InvalidPassword)
49+
parse_max_spend, to_decimal, UserFacingException, InvalidPassword, make_aiohttp_session)
5050

5151
from . import bitcoin
5252
from .bitcoin import is_address, hash_160, COIN
@@ -55,15 +55,13 @@
5555
from .transaction import (Transaction, multisig_script, TxOutput, PartialTransaction, PartialTxOutput,
5656
tx_from_any, PartialTxInput, TxOutpoint)
5757
from . import transaction
58-
from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED
58+
from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, pr_tooltips
5959
from .synchronizer import Notifier
6060
from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet, BumpFeeStrategy, Imported_Wallet
6161
from .address_synchronizer import TX_HEIGHT_LOCAL
6262
from .mnemonic import Mnemonic
63-
from .lnutil import SENT, RECEIVED
64-
from .lnutil import LnFeatures
6563
from .lntransport import extract_nodeid
66-
from .lnutil import channel_id_from_funding_tx
64+
from .lnutil import channel_id_from_funding_tx, LnFeatures, SENT, RECEIVED, MIN_FINAL_CLTV_DELTA_FOR_INVOICE
6765
from .plugin import run_hook, DeviceMgr, Plugins
6866
from .version import ELECTRUM_VERSION
6967
from .simple_config import SimpleConfig
@@ -1346,6 +1344,119 @@ 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+
callback_url = None,
1355+
wallet: Abstract_Wallet = None
1356+
) -> str:
1357+
"""
1358+
Create a lightning hold invoice for the given payment hash. Hold invoices have to get settled manually later.
1359+
The invoice has a final cltv delta of 147 blocks.
1360+
HTLCs will get failed if local_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: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)
1367+
"""
1368+
assert len(payment_hash) == 64, f"Invalid payment hash length: {len(payment_hash)} != 64"
1369+
assert payment_hash not in wallet.lnworker.payment_info, "Payment hash already in use!"
1370+
amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None
1371+
inbound_capacity = wallet.lnworker.num_sats_can_receive()
1372+
assert inbound_capacity > satoshis(amount or 0), \
1373+
f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment"
1374+
if callback_url:
1375+
assert callback_url.startswith("http"), "Callback URL must be http(s)://"
1376+
lnaddr, invoice = wallet.lnworker.get_bolt11_invoice(
1377+
payment_hash=bfh(payment_hash),
1378+
amount_msat=satoshis(amount) * 1000 if amount else None,
1379+
message=memo,
1380+
expiry=expiry,
1381+
min_final_cltv_expiry_delta=MIN_FINAL_CLTV_DELTA_FOR_INVOICE,
1382+
fallback_address=None
1383+
)
1384+
wallet.lnworker.add_payment_info_for_hold_invoice(
1385+
bfh(payment_hash),
1386+
satoshis(amount) if amount else None,
1387+
)
1388+
wallet.lnworker.register_cli_hold_invoice(payment_hash, callback_url)
1389+
result = json.dumps({
1390+
"invoice": invoice
1391+
}, indent=4, sort_keys=True)
1392+
return result
1393+
1394+
@command('wnl')
1395+
async def settle_hold_invoice(self, preimage, wallet: Abstract_Wallet = None) -> str:
1396+
"""
1397+
Settles lightning hold invoice with 'preimage'.
1398+
Doesn't wait for actual settlement of the HTLCs.
1399+
1400+
arg:str:preimage:Hex encoded preimage of the payment hash
1401+
"""
1402+
assert len(preimage) == 64, f"Invalid preimage length: {len(preimage)} != 64"
1403+
payment_hash: str = crypto.sha256(bfh(preimage)).hex()
1404+
assert payment_hash in wallet.lnworker.payment_info, \
1405+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1406+
assert wallet.lnworker.is_accepted_mpp(bfh(payment_hash)), \
1407+
f"MPP incomplete, cannot settle hold invoice with preimage {preimage}"
1408+
wallet.lnworker.save_preimage(bfh(payment_hash), preimage=bfh(preimage))
1409+
wallet.lnworker.unregister_hold_invoice(bfh(payment_hash))
1410+
result: str = json.dumps({
1411+
"settled": payment_hash
1412+
}, indent=4)
1413+
return result
1414+
1415+
@command('wnl')
1416+
async def cancel_hold_invoice(self, payment_hash, wallet: Abstract_Wallet = None) -> str:
1417+
"""
1418+
Cancels lightning hold invoice 'payment_hash'.
1419+
1420+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1421+
"""
1422+
assert payment_hash in wallet.lnworker.payment_info, \
1423+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1424+
assert payment_hash not in wallet.lnworker.preimages, \
1425+
f"Hold invoice already settled with preimage: {crypto.sha256(bfh(payment_hash)).hex()}"
1426+
# set to PR_UNPAID so it can get deleted
1427+
wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID)
1428+
wallet.lnworker.delete_payment_info(payment_hash)
1429+
wallet.lnworker.unregister_hold_invoice(bfh(payment_hash))
1430+
result: str = json.dumps({
1431+
"cancelled": payment_hash
1432+
}, indent=4)
1433+
return result
1434+
1435+
@command('wnl')
1436+
async def check_hold_invoice(self, payment_hash, wallet: Abstract_Wallet = None) -> str:
1437+
"""
1438+
Checks the status of a lightning hold invoice 'payment_hash'.
1439+
Possible states: unpaid, paid, settled, unknown (cancelled or not found)
1440+
1441+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1442+
"""
1443+
info = wallet.lnworker.get_payment_info(bfh(payment_hash))
1444+
amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
1445+
status = "unknown"
1446+
if info is None:
1447+
pass
1448+
elif info.status == PR_UNPAID or not wallet.lnworker.is_accepted_mpp(bfh(payment_hash)):
1449+
status = "unpaid"
1450+
elif info.status == PR_PAID and payment_hash not in wallet.lnworker.preimages:
1451+
status = "paid"
1452+
elif payment_hash in wallet.lnworker.preimages:
1453+
status = "settled"
1454+
result: str = json.dumps({
1455+
"status": status,
1456+
"amount_sat": amount_sat
1457+
}, indent=4)
1458+
return result
1459+
13491460
@command('w')
13501461
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
13511462
"""

electrum/lnworker.py

+55-7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from concurrent import futures
2121
import urllib.parse
2222
import itertools
23+
import json
2324

2425
import aiohttp
2526
import dns.resolver
@@ -62,7 +63,7 @@
6263
LnKeyFamily, LOCAL, REMOTE, MIN_FINAL_CLTV_DELTA_FOR_INVOICE, SENT, RECEIVED, HTLCOwner, UpdateAddHtlc, LnFeatures,
6364
ShortChannelID, HtlcLog, NoPathFound, InvalidGossipMsg, FeeBudgetExceeded, ImportedChannelBackupStorage,
6465
OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget,
65-
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT
66+
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, MIN_FINAL_CLTV_DELTA_ACCEPTED
6667
)
6768
from .lnonion import decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket
6869
from .lnmsg import decode_msg
@@ -885,6 +886,13 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv):
885886

886887
# payment_hash -> callback:
887888
self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]]
889+
self.cli_hold_invoice_callbacks = self.db.get_dict('cli_hold_invoice_cbs') # type: Dict[str, Tuple[Optional[str], int]] # payment_hash -> (callback_url, ts_created)
890+
for payment_hash, (callback_url, ts_created) in list(self.cli_hold_invoice_callbacks.items()):
891+
if ts_created < time.time() - MIN_FINAL_CLTV_DELTA_ACCEPTED * 10 * 60 * 4:
892+
# delete callbacks that are not going to get called anymore
893+
del self.cli_hold_invoice_callbacks[payment_hash]
894+
else: # re-register the callback
895+
self.register_cli_hold_invoice(payment_hash, callback_url)
888896
self.payment_bundles = [] # lists of hashes. todo:persist
889897

890898
self.nostr_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NOSTR_KEY)
@@ -2303,15 +2311,38 @@ def get_payment_info(self, payment_hash: bytes) -> Optional[PaymentInfo]:
23032311
amount_msat, direction, status = self.payment_info[key]
23042312
return PaymentInfo(payment_hash, amount_msat, direction, status)
23052313

2306-
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: int):
2307-
info = PaymentInfo(payment_hash, lightning_amount_sat * 1000, RECEIVED, PR_UNPAID)
2314+
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]):
2315+
amount = lightning_amount_sat * 1000 if lightning_amount_sat else None
2316+
info = PaymentInfo(payment_hash, amount, RECEIVED, PR_UNPAID)
23082317
self.save_payment_info(info, write_to_disk=False)
23092318

23102319
def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]):
23112320
self.hold_invoice_callbacks[payment_hash] = cb
23122321

23132322
def unregister_hold_invoice(self, payment_hash: bytes):
23142323
self.hold_invoice_callbacks.pop(payment_hash)
2324+
self.cli_hold_invoice_callbacks.pop(payment_hash.hex(), None)
2325+
2326+
def register_cli_hold_invoice(self, payment_hash: str, callback_url: Optional[str] = None) -> None:
2327+
async def cli_hold_invoice_callback(payment_hash_bytes: bytes):
2328+
"""Hold invoice callback for hold invoices registered via CLI."""
2329+
self.logger.info(f"Hold invoice {payment_hash_bytes.hex()} ready for settlement")
2330+
self.set_payment_status(payment_hash_bytes, PR_PAID)
2331+
if not callback_url:
2332+
return
2333+
amount_sat = (self.get_payment_mpp_amount_msat(payment_hash_bytes) or 0) // 1000
2334+
data = {
2335+
"payment_hash": payment_hash_bytes.hex(),
2336+
"amount_sat": amount_sat
2337+
}
2338+
try:
2339+
async with make_aiohttp_session(proxy=None) as s:
2340+
await s.post(callback_url, json=data, raise_for_status=False)
2341+
except Exception as e:
2342+
self.logger.info(f"hold invoice callback request to {callback_url} raised: {str(e)}")
2343+
self.register_hold_invoice(bfh(payment_hash), cli_hold_invoice_callback)
2344+
if payment_hash not in self.cli_hold_invoice_callbacks:
2345+
self.cli_hold_invoice_callbacks[payment_hash] = (callback_url, int(time.time()))
23152346

23162347
def save_payment_info(self, info: PaymentInfo, *, write_to_disk: bool = True) -> None:
23172348
key = info.payment_hash.hex()
@@ -2397,17 +2428,34 @@ def set_mpp_resolution(self, *, payment_key: bytes, resolution: RecvMPPResolutio
23972428
self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution)
23982429

23992430
def is_mpp_amount_reached(self, payment_key: bytes) -> bool:
2400-
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2401-
if not mpp_status:
2431+
amounts = self.get_mpp_amounts(payment_key)
2432+
if amounts is None:
24022433
return False
2403-
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2404-
return total >= mpp_status.expected_msat
2434+
total, expected = amounts
2435+
return total >= expected
24052436

24062437
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
24072438
payment_key = self._get_payment_key(payment_hash)
24082439
status = self.received_mpp_htlcs.get(payment_key.hex())
24092440
return status and status.resolution == RecvMPPResolution.ACCEPTED
24102441

2442+
def get_payment_mpp_amount_msat(self, payment_hash: bytes) -> Optional[int]:
2443+
"""Returns the received mpp amount for given payment hash."""
2444+
payment_key = self._get_payment_key(payment_hash)
2445+
amounts = self.get_mpp_amounts(payment_key)
2446+
if not amounts:
2447+
return None
2448+
total_msat, _ = amounts
2449+
return total_msat
2450+
2451+
def get_mpp_amounts(self, payment_key: bytes) -> Optional[Tuple[int, int]]:
2452+
"""Returns (total received amount, expected amount) or None."""
2453+
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2454+
if not mpp_status:
2455+
return None
2456+
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2457+
return total, mpp_status.expected_msat
2458+
24112459
def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int:
24122460
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
24132461
if not mpp_status:

tests/test_commands.py

+77
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import unittest
22
from unittest import mock
33
from decimal import Decimal
4+
from os import urandom
5+
from json import loads
46

57
from electrum.commands import Commands, eval_bool
68
from electrum import storage, wallet
@@ -9,6 +11,8 @@
911
from electrum.simple_config import SimpleConfig
1012
from electrum.transaction import Transaction, TxOutput, tx_from_any
1113
from electrum.util import UserFacingException, NotEnoughFunds
14+
from electrum.crypto import sha256
15+
from electrum.lnaddr import lndecode
1216

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

0 commit comments

Comments
 (0)