diff --git a/electrum/commands.py b/electrum/commands.py index e4601497a..9151219b0 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -25,6 +25,7 @@ import io import sys import datetime +import time import argparse import json import ast @@ -60,10 +61,8 @@ from .synchronizer import Notifier from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet, BumpFeeStrategy, Imported_Wallet from .address_synchronizer import TX_HEIGHT_LOCAL from .mnemonic import Mnemonic -from .lnutil import SENT, RECEIVED -from .lnutil import LnFeatures from .lntransport import extract_nodeid -from .lnutil import channel_id_from_funding_tx +from .lnutil import channel_id_from_funding_tx, LnFeatures, SENT, RECEIVED, MIN_FINAL_CLTV_DELTA_FOR_INVOICE from .plugin import run_hook, DeviceMgr, Plugins from .version import ELECTRUM_VERSION from .simple_config import SimpleConfig @@ -77,6 +76,7 @@ from . import descriptor if TYPE_CHECKING: from .network import Network from .daemon import Daemon + from electrum.lnworker import PaymentInfo known_commands = {} # type: Dict[str, Command] @@ -995,7 +995,6 @@ class Commands(Logger): def get_year_timestamps(self, year:int): kwargs = {} if year: - import time start_date = datetime.datetime(year, 1, 1) end_date = datetime.datetime(year+1, 1, 1) kwargs['from_timestamp'] = time.mktime(start_date.timetuple()) @@ -1349,6 +1348,134 @@ class Commands(Logger): req = wallet.get_request(key) return wallet.export_request(req) + @command('wnl') + async def add_hold_invoice( + self, + preimage: str, + amount: Optional[Decimal] = None, + memo: str = "", + expiry: int = 3600, + min_final_cltv_expiry_delta: int = MIN_FINAL_CLTV_DELTA_FOR_INVOICE * 2, + wallet: Abstract_Wallet = None + ) -> dict: + """ + Create a lightning hold invoice for the given preimage. Hold invoices have to get settled manually later. + HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs. + + arg:str:preimage:Hex encoded preimage to be used for the invoice + arg:decimal:amount:Optional requested amount (in btc) + arg:str:memo:Optional description of the invoice + arg:int:expiry:Optional expiry in seconds (default: 3600s) + arg:int:min_final_cltv_expiry_delta:Optional min final cltv expiry delta (default: 294 blocks) + """ + assert len(preimage) == 64, f"Invalid preimage length: {len(preimage)} != 64" + payment_hash: str = crypto.sha256(bfh(preimage)).hex() + assert preimage not in wallet.lnworker.preimages, "Preimage has been used as payment hash already!" + assert payment_hash not in wallet.lnworker.preimages, "Preimage already in use!" + assert payment_hash not in wallet.lnworker.payment_info, "Payment hash already used!" + assert payment_hash not in wallet.lnworker.dont_settle_htlcs, "Payment hash already used!" + assert MIN_FINAL_CLTV_DELTA_FOR_INVOICE < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value" + amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None + inbound_capacity = wallet.lnworker.num_sats_can_receive() + assert inbound_capacity > satoshis(amount or 0), \ + f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment" + + lnaddr, invoice = wallet.lnworker.get_bolt11_invoice( + payment_hash=bfh(payment_hash), + amount_msat=satoshis(amount) * 1000 if amount else None, + message=memo, + expiry=expiry, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + fallback_address=None + ) + wallet.lnworker.add_payment_info_for_hold_invoice( + bfh(payment_hash), + satoshis(amount) if amount else None, + ) + wallet.lnworker.dont_settle_htlcs[payment_hash] = None + wallet.lnworker.save_preimage(bfh(payment_hash), bfh(preimage)) + wallet.set_label(payment_hash, memo) + result = { + "invoice": invoice + } + return result + + @command('wnl') + async def settle_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict: + """ + Settles lightning hold invoice 'payment_hash' using the stored preimage. + Doesn't block until actual settlement of the HTLCs. + + arg:str:payment_hash:Hex encoded payment hash of the invoice to be settled + """ + assert len(payment_hash) == 64, f"Invalid payment_hash length: {len(payment_hash)} != 64" + assert payment_hash in wallet.lnworker.preimages, f"Couldn't find preimage for {payment_hash}" + assert payment_hash in wallet.lnworker.dont_settle_htlcs, "Is already settled!" + assert payment_hash in wallet.lnworker.payment_info, \ + f"Couldn't find lightning invoice for payment hash {payment_hash}" + assert wallet.lnworker.is_accepted_mpp(bfh(payment_hash)), \ + f"MPP incomplete, cannot settle hold invoice {payment_hash} yet" + del wallet.lnworker.dont_settle_htlcs[payment_hash] + util.trigger_callback('wallet_updated', wallet) + result = { + "settled": payment_hash + } + return result + + @command('wnl') + async def cancel_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict: + """ + Cancels lightning hold invoice 'payment_hash'. + + arg:str:payment_hash:Payment hash in hex of the hold invoice + """ + assert payment_hash in wallet.lnworker.payment_info, \ + f"Couldn't find lightning invoice for payment hash {payment_hash}" + assert payment_hash in wallet.lnworker.preimages, "Nothing to cancel, no known preimage." + assert payment_hash in wallet.lnworker.dont_settle_htlcs, "Is already settled!" + del wallet.lnworker.preimages[payment_hash] + # set to PR_UNPAID so it can get deleted + wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID) + wallet.lnworker.delete_payment_info(payment_hash) + wallet.set_label(payment_hash, None) + while wallet.lnworker.is_accepted_mpp(bfh(payment_hash)): + # wait until the htlcs got failed so the payment won't get settled accidentally in a race + await asyncio.sleep(0.1) + del wallet.lnworker.dont_settle_htlcs[payment_hash] + result = { + "cancelled": payment_hash + } + return result + + @command('wnl') + 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) + + arg:str:payment_hash:Payment hash in hex of the hold invoice + """ + assert len(payment_hash) == 64, f"Invalid payment_hash length: {len(payment_hash)} != 64" + 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" + if info is None: + pass + elif not is_accepted_mpp: + status = "unpaid" + elif is_accepted_mpp and payment_hash in wallet.lnworker.dont_settle_htlcs: + status = "paid" + elif (payment_hash in wallet.lnworker.preimages + and payment_hash not in wallet.lnworker.dont_settle_htlcs + and is_accepted_mpp): + status = "settled" + result = { + "status": status, + "amount_sat": amount_sat + } + return result + @command('w') async def addtransaction(self, tx, wallet: Abstract_Wallet = None): """ diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 7be4673a1..85e3bd681 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -2307,9 +2307,11 @@ class LNWallet(LNWorker): if key in self.payment_info: amount_msat, direction, status = self.payment_info[key] return PaymentInfo(payment_hash, amount_msat, direction, status) + return None - def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: int): - info = PaymentInfo(payment_hash, lightning_amount_sat * 1000, RECEIVED, PR_UNPAID) + def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]): + amount = lightning_amount_sat * 1000 if lightning_amount_sat else None + info = PaymentInfo(payment_hash, amount, RECEIVED, PR_UNPAID) self.save_payment_info(info, write_to_disk=False) def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]): @@ -2402,17 +2404,34 @@ class LNWallet(LNWorker): self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution) def is_mpp_amount_reached(self, payment_key: bytes) -> bool: - mpp_status = self.received_mpp_htlcs.get(payment_key.hex()) - if not mpp_status: + amounts = self.get_mpp_amounts(payment_key) + if amounts is None: return False - total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set]) - return total >= mpp_status.expected_msat + total, expected = amounts + return total >= expected def is_accepted_mpp(self, payment_hash: bytes) -> bool: payment_key = self._get_payment_key(payment_hash) status = self.received_mpp_htlcs.get(payment_key.hex()) return status and status.resolution == RecvMPPResolution.ACCEPTED + def get_payment_mpp_amount_msat(self, payment_hash: bytes) -> Optional[int]: + """Returns the received mpp amount for given payment hash.""" + payment_key = self._get_payment_key(payment_hash) + amounts = self.get_mpp_amounts(payment_key) + if not amounts: + return None + total_msat, _ = amounts + return total_msat + + def get_mpp_amounts(self, payment_key: bytes) -> Optional[Tuple[int, int]]: + """Returns (total received amount, expected amount) or None.""" + mpp_status = self.received_mpp_htlcs.get(payment_key.hex()) + if not mpp_status: + return None + total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set]) + return total, mpp_status.expected_msat + def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int: mpp_status = self.received_mpp_htlcs.get(payment_key.hex()) if not mpp_status: diff --git a/tests/test_commands.py b/tests/test_commands.py index ddde47718..9f73b7176 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,7 @@ import unittest from unittest import mock from decimal import Decimal +from os import urandom from electrum.commands import Commands, eval_bool from electrum import storage, wallet @@ -9,6 +10,8 @@ from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED from electrum.simple_config import SimpleConfig from electrum.transaction import Transaction, TxOutput, tx_from_any from electrum.util import UserFacingException, NotEnoughFunds +from electrum.crypto import sha256 +from electrum.lnaddr import lndecode from . import ElectrumTestCase from .test_wallet_vertical import WalletIntegrityHelper @@ -405,3 +408,78 @@ class TestCommandsTestnet(ElectrumTestCase): self.assertEqual({"good_keys": 1, "bad_keys": 2}, await cmds.importprivkey(privkeys2_str, wallet=wallet)) self.assertEqual(10, len(wallet.get_addresses())) + + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') + async def test_hold_invoice_commands(self, mock_save_db): + wallet: Abstract_Wallet = restore_wallet_from_text( + 'disagree rug lemon bean unaware square alone beach tennis exhibit fix mimic', + gap_limit=2, + path='if_this_exists_mocking_failed_648151893', + config=self.config)['wallet'] + + cmds = Commands(config=self.config) + preimage: str = sha256(urandom(32)).hex() + payment_hash: str = sha256(bytes.fromhex(preimage)).hex() + with (mock.patch.object(wallet.lnworker, 'num_sats_can_receive', return_value=1000000)): + result = await cmds.add_hold_invoice( + preimage=preimage, + amount=Decimal(0.0001), + memo="test", + expiry=3500, + wallet=wallet, + ) + invoice = lndecode(invoice=result['invoice']) + assert invoice.paymenthash.hex() == payment_hash + assert payment_hash in wallet.lnworker.preimages + assert payment_hash in wallet.lnworker.payment_info + assert payment_hash in wallet.lnworker.dont_settle_htlcs + assert invoice.get_amount_sat() == 10000 + + cancel_result = await cmds.cancel_hold_invoice( + payment_hash=payment_hash, + wallet=wallet, + ) + assert payment_hash not in wallet.lnworker.payment_info + assert payment_hash not in wallet.lnworker.dont_settle_htlcs + assert payment_hash not in wallet.lnworker.preimages + assert cancel_result['cancelled'] == payment_hash + + with self.assertRaises(AssertionError): + # settling a cancelled invoice should raise + await cmds.settle_hold_invoice( + payment_hash=payment_hash, + wallet=wallet, + ) + # cancelling an unknown invoice should raise + await cmds.cancel_hold_invoice( + payment_hash=sha256(urandom(32)).hex(), + wallet=wallet, + ) + + # add another hold invoice + preimage: bytes = sha256(urandom(32)) + payment_hash: str = sha256(preimage).hex() + with mock.patch.object(wallet.lnworker, 'num_sats_can_receive', return_value=1000000): + await cmds.add_hold_invoice( + preimage=preimage.hex(), + amount=Decimal(0.0001), + 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): + status: dict = await cmds.check_hold_invoice(payment_hash=payment_hash, wallet=wallet) + assert status['status'] == 'paid' + assert status['amount_sat'] == 10000 + + settle_result = await cmds.settle_hold_invoice( + payment_hash=payment_hash, + wallet=wallet, + ) + assert settle_result['settled'] == payment_hash + assert wallet.lnworker.preimages[payment_hash] == preimage.hex() + assert payment_hash not in wallet.lnworker.dont_settle_htlcs + + with self.assertRaises(AssertionError): + # cancelling a settled invoice should raise + await cmds.cancel_hold_invoice(payment_hash=payment_hash, wallet=wallet) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index b5624167f..24615fa9d 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -331,6 +331,7 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]): update_mpp_with_received_htlc = LNWallet.update_mpp_with_received_htlc set_mpp_resolution = LNWallet.set_mpp_resolution is_mpp_amount_reached = LNWallet.is_mpp_amount_reached + get_mpp_amounts = LNWallet.get_mpp_amounts get_first_timestamp_of_mpp = LNWallet.get_first_timestamp_of_mpp bundle_payments = LNWallet.bundle_payments get_payment_bundle = LNWallet.get_payment_bundle