Skip to content

Commit 4a97a3b

Browse files
committed
add hold invoice cli functionality
1 parent 4ecf6ac commit 4a97a3b

File tree

2 files changed

+163
-11
lines changed

2 files changed

+163
-11
lines changed

electrum/commands.py

+114-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,117 @@ 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 already in use"
1370+
inbound_capacity = wallet.lnworker.num_sats_can_receive()
1371+
amount = amount if amount and amount > 0 else None # make amount >0 or None
1372+
assert inbound_capacity > satoshis(amount or 0), \
1373+
f"Not enough incoming 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.preimages[payment_hash] = preimage
1409+
result: str = json.dumps({
1410+
"settled": payment_hash
1411+
}, indent=4)
1412+
return result
1413+
1414+
@command('wnl')
1415+
async def cancel_hold_invoice(self, payment_hash, wallet: Abstract_Wallet = None) -> str:
1416+
"""
1417+
Cancels lightning hold invoice 'payment_hash'.
1418+
1419+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1420+
"""
1421+
assert payment_hash in wallet.lnworker.payment_info, \
1422+
f"Couldn't find lightning invoice for payment hash {payment_hash}"
1423+
assert payment_hash not in wallet.lnworker.preimages, \
1424+
f"Hold invoice already settled with preimage: {crypto.sha256(bfh(payment_hash)).hex()}"
1425+
wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID)
1426+
wallet.lnworker.delete_payment_info(payment_hash)
1427+
wallet.lnworker.unregister_hold_invoice(bfh(payment_hash))
1428+
result: str = json.dumps({
1429+
"cancelled": payment_hash
1430+
}, indent=4)
1431+
return result
1432+
1433+
@command('wnl')
1434+
async def check_hold_invoice(self, payment_hash, wallet: Abstract_Wallet = None) -> str:
1435+
"""
1436+
Checks the status of a lightning hold invoice 'payment_hash'.
1437+
Possible states: unpaid, paid, settled, unknown (cancelled or not found)
1438+
1439+
arg:str:payment_hash:Payment hash in hex of the hold invoice
1440+
"""
1441+
info = wallet.lnworker.get_payment_info(bfh(payment_hash))
1442+
amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
1443+
status = "unknown"
1444+
if info is None:
1445+
pass
1446+
elif info.status == PR_UNPAID or not wallet.lnworker.is_accepted_mpp(bfh(payment_hash)):
1447+
status = "unpaid"
1448+
elif info.status == PR_PAID and payment_hash not in wallet.lnworker.preimages:
1449+
status = "paid"
1450+
elif payment_hash in wallet.lnworker.preimages:
1451+
status = "settled"
1452+
result: str = json.dumps({
1453+
"status": status,
1454+
"amount_sat": amount_sat
1455+
}, indent=4)
1456+
return result
1457+
13491458
@command('w')
13501459
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
13511460
"""

electrum/lnworker.py

+49-6
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
@@ -885,6 +886,9 @@ 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_callbacks') # type: Dict[str, Optional[str]] # payment_hash -> url callback
890+
for payment_hash, callback_url in self.cli_hold_invoice_callbacks.items():
891+
self.register_cli_hold_invoice(payment_hash, callback_url)
888892
self.payment_bundles = [] # lists of hashes. todo:persist
889893

890894
self.nostr_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NOSTR_KEY)
@@ -2300,8 +2304,9 @@ def get_payment_info(self, payment_hash: bytes) -> Optional[PaymentInfo]:
23002304
amount_msat, direction, status = self.payment_info[key]
23012305
return PaymentInfo(payment_hash, amount_msat, direction, status)
23022306

2303-
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: int):
2304-
info = PaymentInfo(payment_hash, lightning_amount_sat * 1000, RECEIVED, PR_UNPAID)
2307+
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]):
2308+
amount = lightning_amount_sat * 1000 if lightning_amount_sat else None
2309+
info = PaymentInfo(payment_hash, amount, RECEIVED, PR_UNPAID)
23052310
self.save_payment_info(info, write_to_disk=False)
23062311

23072312
def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]):
@@ -2310,6 +2315,27 @@ def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Await
23102315
def unregister_hold_invoice(self, payment_hash: bytes):
23112316
self.hold_invoice_callbacks.pop(payment_hash)
23122317

2318+
def register_cli_hold_invoice(self, payment_hash: str, callback_url: Optional[str] = None):
2319+
async def cli_hold_invoice_callback(payment_hash_bytes: bytes):
2320+
"""Hold invoice callback for hold invoices registered via CLI."""
2321+
self.logger.info(f"Hold invoice {payment_hash_bytes.hex()} ready for settlement")
2322+
self.set_payment_status(payment_hash_bytes, PR_PAID)
2323+
if not callback_url:
2324+
return
2325+
amount_sat = (self.get_payment_mpp_amount_msat(payment_hash_bytes) or 0) // 1000
2326+
data = {
2327+
"payment_hash": payment_hash_bytes.hex(),
2328+
"amount_sat": amount_sat
2329+
}
2330+
try:
2331+
async with make_aiohttp_session(proxy=None) as s:
2332+
await s.post(callback_url, json=data, raise_for_status=False)
2333+
except Exception as e:
2334+
self.logger.info(f"hold invoice callback request to {callback_url} raised: {str(e)}")
2335+
self.register_hold_invoice(bfh(payment_hash), cli_hold_invoice_callback)
2336+
if payment_hash not in self.cli_hold_invoice_callbacks:
2337+
self.cli_hold_invoice_callbacks[payment_hash] = callback_url
2338+
23132339
def save_payment_info(self, info: PaymentInfo, *, write_to_disk: bool = True) -> None:
23142340
key = info.payment_hash.hex()
23152341
assert info.status in SAVED_PR_STATUS
@@ -2394,17 +2420,34 @@ def set_mpp_resolution(self, *, payment_key: bytes, resolution: RecvMPPResolutio
23942420
self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution)
23952421

23962422
def is_mpp_amount_reached(self, payment_key: bytes) -> bool:
2397-
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2398-
if not mpp_status:
2423+
amounts = self.get_mpp_amounts(payment_key)
2424+
if amounts is None:
23992425
return False
2400-
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2401-
return total >= mpp_status.expected_msat
2426+
total, expected = amounts
2427+
return total >= expected
24022428

24032429
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
24042430
payment_key = self._get_payment_key(payment_hash)
24052431
status = self.received_mpp_htlcs.get(payment_key.hex())
24062432
return status and status.resolution == RecvMPPResolution.ACCEPTED
24072433

2434+
def get_payment_mpp_amount_msat(self, payment_hash: bytes) -> Optional[int]:
2435+
"""Returns the received mpp amount for given payment hash."""
2436+
payment_key = self._get_payment_key(payment_hash)
2437+
amounts = self.get_mpp_amounts(payment_key)
2438+
if not amounts:
2439+
return None
2440+
total_msat, _ = amounts
2441+
return total_msat
2442+
2443+
def get_mpp_amounts(self, payment_key: bytes) -> Optional[Tuple[int, int]]:
2444+
"""Returns (total received amount, expected amount) or None."""
2445+
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
2446+
if not mpp_status:
2447+
return None
2448+
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
2449+
return total, mpp_status.expected_msat
2450+
24082451
def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int:
24092452
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
24102453
if not mpp_status:

0 commit comments

Comments
 (0)