add hold invoice cli functionality
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import ast
|
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 .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet, BumpFeeStrategy, Imported_Wallet
|
||||||
from .address_synchronizer import TX_HEIGHT_LOCAL
|
from .address_synchronizer import TX_HEIGHT_LOCAL
|
||||||
from .mnemonic import Mnemonic
|
from .mnemonic import Mnemonic
|
||||||
from .lnutil import SENT, RECEIVED
|
|
||||||
from .lnutil import LnFeatures
|
|
||||||
from .lntransport import extract_nodeid
|
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 .plugin import run_hook, DeviceMgr, Plugins
|
||||||
from .version import ELECTRUM_VERSION
|
from .version import ELECTRUM_VERSION
|
||||||
from .simple_config import SimpleConfig
|
from .simple_config import SimpleConfig
|
||||||
@@ -77,6 +76,7 @@ from . import descriptor
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .network import Network
|
from .network import Network
|
||||||
from .daemon import Daemon
|
from .daemon import Daemon
|
||||||
|
from electrum.lnworker import PaymentInfo
|
||||||
|
|
||||||
|
|
||||||
known_commands = {} # type: Dict[str, Command]
|
known_commands = {} # type: Dict[str, Command]
|
||||||
@@ -995,7 +995,6 @@ class Commands(Logger):
|
|||||||
def get_year_timestamps(self, year:int):
|
def get_year_timestamps(self, year:int):
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if year:
|
if year:
|
||||||
import time
|
|
||||||
start_date = datetime.datetime(year, 1, 1)
|
start_date = datetime.datetime(year, 1, 1)
|
||||||
end_date = datetime.datetime(year+1, 1, 1)
|
end_date = datetime.datetime(year+1, 1, 1)
|
||||||
kwargs['from_timestamp'] = time.mktime(start_date.timetuple())
|
kwargs['from_timestamp'] = time.mktime(start_date.timetuple())
|
||||||
@@ -1349,6 +1348,134 @@ class Commands(Logger):
|
|||||||
req = wallet.get_request(key)
|
req = wallet.get_request(key)
|
||||||
return wallet.export_request(req)
|
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')
|
@command('w')
|
||||||
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
|
async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2307,9 +2307,11 @@ class LNWallet(LNWorker):
|
|||||||
if key in self.payment_info:
|
if key in self.payment_info:
|
||||||
amount_msat, direction, status = self.payment_info[key]
|
amount_msat, direction, status = self.payment_info[key]
|
||||||
return PaymentInfo(payment_hash, amount_msat, direction, status)
|
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):
|
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: Optional[int]):
|
||||||
info = PaymentInfo(payment_hash, lightning_amount_sat * 1000, RECEIVED, PR_UNPAID)
|
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)
|
self.save_payment_info(info, write_to_disk=False)
|
||||||
|
|
||||||
def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]):
|
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)
|
self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution)
|
||||||
|
|
||||||
def is_mpp_amount_reached(self, payment_key: bytes) -> bool:
|
def is_mpp_amount_reached(self, payment_key: bytes) -> bool:
|
||||||
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
|
amounts = self.get_mpp_amounts(payment_key)
|
||||||
if not mpp_status:
|
if amounts is None:
|
||||||
return False
|
return False
|
||||||
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
|
total, expected = amounts
|
||||||
return total >= mpp_status.expected_msat
|
return total >= expected
|
||||||
|
|
||||||
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
|
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
|
||||||
payment_key = self._get_payment_key(payment_hash)
|
payment_key = self._get_payment_key(payment_hash)
|
||||||
status = self.received_mpp_htlcs.get(payment_key.hex())
|
status = self.received_mpp_htlcs.get(payment_key.hex())
|
||||||
return status and status.resolution == RecvMPPResolution.ACCEPTED
|
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:
|
def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int:
|
||||||
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
|
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
|
||||||
if not mpp_status:
|
if not mpp_status:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from os import urandom
|
||||||
|
|
||||||
from electrum.commands import Commands, eval_bool
|
from electrum.commands import Commands, eval_bool
|
||||||
from electrum import storage, wallet
|
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.simple_config import SimpleConfig
|
||||||
from electrum.transaction import Transaction, TxOutput, tx_from_any
|
from electrum.transaction import Transaction, TxOutput, tx_from_any
|
||||||
from electrum.util import UserFacingException, NotEnoughFunds
|
from electrum.util import UserFacingException, NotEnoughFunds
|
||||||
|
from electrum.crypto import sha256
|
||||||
|
from electrum.lnaddr import lndecode
|
||||||
|
|
||||||
from . import ElectrumTestCase
|
from . import ElectrumTestCase
|
||||||
from .test_wallet_vertical import WalletIntegrityHelper
|
from .test_wallet_vertical import WalletIntegrityHelper
|
||||||
@@ -405,3 +408,78 @@ class TestCommandsTestnet(ElectrumTestCase):
|
|||||||
self.assertEqual({"good_keys": 1, "bad_keys": 2},
|
self.assertEqual({"good_keys": 1, "bad_keys": 2},
|
||||||
await cmds.importprivkey(privkeys2_str, wallet=wallet))
|
await cmds.importprivkey(privkeys2_str, wallet=wallet))
|
||||||
self.assertEqual(10, len(wallet.get_addresses()))
|
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)
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
|
|||||||
update_mpp_with_received_htlc = LNWallet.update_mpp_with_received_htlc
|
update_mpp_with_received_htlc = LNWallet.update_mpp_with_received_htlc
|
||||||
set_mpp_resolution = LNWallet.set_mpp_resolution
|
set_mpp_resolution = LNWallet.set_mpp_resolution
|
||||||
is_mpp_amount_reached = LNWallet.is_mpp_amount_reached
|
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
|
get_first_timestamp_of_mpp = LNWallet.get_first_timestamp_of_mpp
|
||||||
bundle_payments = LNWallet.bundle_payments
|
bundle_payments = LNWallet.bundle_payments
|
||||||
get_payment_bundle = LNWallet.get_payment_bundle
|
get_payment_bundle = LNWallet.get_payment_bundle
|
||||||
|
|||||||
Reference in New Issue
Block a user