diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index a6f2a395c..fcf475865 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -5,21 +5,19 @@ from urllib.parse import urlparse from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS, QTimer -from electrum import bitcoin -from electrum import lnutil from electrum.i18n import _ -from electrum.invoices import Invoice -from electrum.invoices import (PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, +from electrum.logging import get_logger +from electrum.invoices import (Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER) from electrum.lnaddr import LnInvoiceException -from electrum.logging import get_logger from electrum.transaction import PartialTxOutput -from electrum.util import (parse_URI, InvalidBitcoinURI, InvoiceError, - maybe_extract_lightning_payment_identifier, get_asyncio_loop) -from electrum.lnutil import format_short_channel_id +from electrum.util import InvoiceError, get_asyncio_loop +from electrum.lnutil import format_short_channel_id, IncompatibleOrInsaneFeatures from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl from electrum.bitcoin import COIN from electrum.paymentrequest import PaymentRequest +from electrum.payment_identifier import (parse_bip21_URI, InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, + PaymentIdentifier, PaymentIdentifierState) from .qetypes import QEAmount from .qewallet import QEWallet @@ -249,7 +247,8 @@ class QEInvoice(QObject, QtEventListener): } def name_for_node_id(self, node_id): - return self._wallet.wallet.lnworker.get_node_alias(node_id) or node_id.hex() + lnworker = self._wallet.wallet.lnworker + return (lnworker.get_node_alias(node_id) if lnworker else None) or node_id.hex() def set_effective_invoice(self, invoice: Invoice): self._effectiveInvoice = invoice @@ -406,13 +405,11 @@ class QEInvoiceParser(QEInvoice): lnurlRetrieved = pyqtSignal() lnurlError = pyqtSignal([str,str], arguments=['code', 'message']) - _bip70PrResolvedSignal = pyqtSignal([PaymentRequest], arguments=['pr']) - def __init__(self, parent=None): super().__init__(parent) self._recipient = '' - self._bip70PrResolvedSignal.connect(self._bip70_payment_request_resolved) + self._pi = None self.clear() @@ -493,79 +490,54 @@ class QEInvoiceParser(QEInvoice): self.setInvoiceType(QEInvoice.Type.Invalid) return - maybe_lightning_invoice = recipient + self._pi = PaymentIdentifier(self._wallet.wallet, recipient) + if not self._pi.is_valid() or self._pi.type not in ['spk', 'bip21', 'bip70', 'bolt11', 'lnurl']: + self.validationError.emit('unknown', _('Unknown invoice')) + return - try: - bip21 = parse_URI(recipient, lambda pr: self._bip70PrResolvedSignal.emit(pr)) - if bip21: - if 'r' in bip21 or ('name' in bip21 and 'sig' in bip21): # TODO set flag in util? - # let callback handle state - return - if ':' not in recipient: - # address only - # create bare invoice - outputs = [PartialTxOutput.from_address_and_value(bip21['address'], 0)] - invoice = self.create_onchain_invoice(outputs, None, None, None) - self._logger.debug(repr(invoice)) - self.setValidOnchainInvoice(invoice) - self.validationSuccess.emit() - return - else: - # fallback lightning invoice? - if 'lightning' in bip21: - maybe_lightning_invoice = bip21['lightning'] - except InvalidBitcoinURI as e: - bip21 = None - - lninvoice = None - maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice) - if maybe_lightning_invoice is not None: - if maybe_lightning_invoice.startswith('lnurl'): - self.resolve_lnurl(maybe_lightning_invoice) - return - try: - lninvoice = Invoice.from_bech32(maybe_lightning_invoice) - except InvoiceError as e: - e2 = e.__cause__ - if isinstance(e2, LnInvoiceException): - self.validationError.emit('unknown', _("Error parsing Lightning invoice") + f":\n{e2}") - self.clear() - return - if isinstance(e2, lnutil.IncompatibleOrInsaneFeatures): - self.validationError.emit('unknown', _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e2!r}") - self.clear() - return - self._logger.exception(repr(e)) + self._update_from_payment_identifier() - if not lninvoice and not bip21: - self.validationError.emit('unknown',_('Unknown invoice')) - self.clear() + def _update_from_payment_identifier(self): + if self._pi.need_resolve(): + self.resolve_pi() return - if lninvoice: - if not self._wallet.wallet.has_lightning(): - if not bip21: - if lninvoice.get_address(): - self.setValidLightningInvoice(lninvoice) - self.validationSuccess.emit() - else: - self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) - else: - self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') - self._validateRecipient_bip21_onchain(bip21) - else: - if not self._wallet.wallet.lnworker.channels: - if bip21 and 'address' in bip21: - self._logger.debug('flow where invoice has both LN and onchain, we have LN enabled but no channels') - self._validateRecipient_bip21_onchain(bip21) - else: - self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) - else: + if self._pi.type == 'lnurl': + self.on_lnurl(self._pi.lnurl_data) + return + + if self._pi.type == 'bip70': + self._bip70_payment_request_resolved(self._pi.bip70_data) + return + + if self._pi.is_available(): + if self._pi.type == 'spk': + outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)] + invoice = self.create_onchain_invoice(outputs, None, None, None) + self._logger.debug(repr(invoice)) + self.setValidOnchainInvoice(invoice) + self.validationSuccess.emit() + return + elif self._pi.type == 'bolt11': + lninvoice = Invoice.from_bech32(self._pi.bolt11) + if not self._wallet.wallet.has_lightning() and not lninvoice.get_address(): + self.validationError.emit('no_lightning', + _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) + return + if self._wallet.wallet.lnworker and not self._wallet.wallet.lnworker.channels: + self.validationWarning.emit('no_channels', + _('Detected valid Lightning invoice, but there are no open channels')) + + self.setValidLightningInvoice(lninvoice) + self.validationSuccess.emit() + elif self._pi.type == 'bip21': + if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11: + lninvoice = Invoice.from_bech32(self._pi.bolt11) self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() - else: - self._logger.debug('flow without LN but having bip21 uri') - self._validateRecipient_bip21_onchain(bip21) + else: + self._validateRecipient_bip21_onchain(self._pi.bip21) + def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None: if 'amount' not in bip21: @@ -580,20 +552,15 @@ class QEInvoiceParser(QEInvoice): self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() - def resolve_lnurl(self, lnurl): - self._logger.debug('resolve_lnurl') - url = decode_lnurl(lnurl) - self._logger.debug(f'{repr(url)}') - - def resolve_task(): - try: - coro = request_lnurl(url) - fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) - self.on_lnurl(fut.result()) - except Exception as e: - self.validationError.emit('lnurl', repr(e)) + def resolve_pi(self): + assert self._pi.need_resolve() + def on_finished(pi): + if pi.is_error(): + pass + else: + self._update_from_payment_identifier() - threading.Thread(target=resolve_task, daemon=True).start() + self._pi.resolve(on_finished=on_finished) def on_lnurl(self, lnurldata): self._logger.debug('on_lnurl') @@ -610,49 +577,39 @@ class QEInvoiceParser(QEInvoice): self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() - @pyqtSlot('quint64') - @pyqtSlot('quint64', str) - def lnurlGetInvoice(self, amount, comment=None): + @pyqtSlot() + @pyqtSlot(str) + def lnurlGetInvoice(self, comment=None): assert self._lnurlData + assert self._pi.need_finalize() self._logger.debug(f'{repr(self._lnurlData)}') amount = self.amountOverride.satsInt - if self.lnurlData['min_sendable_sat'] != 0: - try: - assert amount >= self.lnurlData['min_sendable_sat'] - assert amount <= self.lnurlData['max_sendable_sat'] - except Exception: - self.lnurlError.emit('amount', _('Amount out of bounds')) - return if self._lnurlData['comment_allowed'] == 0: comment = None - self._logger.debug(f'fetching callback url {self._lnurlData["callback_url"]}') - def fetch_invoice_task(): - try: - params = { 'amount': amount * 1000 } - if comment: - params['comment'] = comment - coro = callback_lnurl(self._lnurlData['callback_url'], params) - fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) - self.on_lnurl_invoice(amount, fut.result()) - except Exception as e: - self._logger.error(repr(e)) - self.lnurlError.emit('lnurl', str(e)) - - threading.Thread(target=fetch_invoice_task, daemon=True).start() + def on_finished(pi): + if pi.is_error(): + if pi.is_state(PaymentIdentifierState.INVALID_AMOUNT): + self.lnurlError.emit('amount', pi.get_error()) + else: + self.lnurlError.emit('lnurl', pi.get_error()) + else: + self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11) + + self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished) def on_lnurl_invoice(self, orig_amount, invoice): self._logger.debug('on_lnurl_invoice') self._logger.debug(f'{repr(invoice)}') # assure no shenanigans with the bolt11 invoice we get back - lninvoice = Invoice.from_bech32(invoice['pr']) - if orig_amount * 1000 != lninvoice.amount_msat: + lninvoice = Invoice.from_bech32(invoice) + if orig_amount * 1000 != lninvoice.amount_msat: # TODO msat precision can cause trouble here raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount') - self.recipient = invoice['pr'] + self.recipient = invoice @pyqtSlot() def saveInvoice(self): diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 118669e07..4b9733b66 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -20,6 +20,7 @@ from .lnutil import IncompatibleOrInsaneFeatures if TYPE_CHECKING: from .wallet import Abstract_Wallet + from .transaction import Transaction def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: @@ -32,10 +33,6 @@ def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: return data return None -# URL decode -#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE) -#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x) - # note: when checking against these, use .lower() to support case-insensitivity BITCOIN_BIP21_URI_SCHEME = 'bitcoin' @@ -183,6 +180,7 @@ class PaymentIdentifierState(IntEnum): ERROR = 50 # generic error NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccesful MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX + INVALID_AMOUNT = 53 # Specified amount not accepted class PaymentIdentifier(Logger): @@ -251,6 +249,9 @@ class PaymentIdentifier(Logger): def is_valid(self): return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY] + def is_available(self): + return self._state in [PaymentIdentifierState.AVAILABLE] + def is_lightning(self): return self.lnurl or self.bolt11 @@ -445,22 +446,23 @@ class PaymentIdentifier(Logger): if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): self.error = _('Amount must be between %d and %d sat.') \ % (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) + self.set_state(PaymentIdentifierState.INVALID_AMOUNT) return + if self.lnurl_data.comment_allowed == 0: comment = None params = {'amount': amount_sat * 1000} if comment: params['comment'] = comment + try: - invoice_data = await callback_lnurl( - self.lnurl_data.callback_url, - params=params, - ) + invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params) except LNURLError as e: self.error = f"LNURL request encountered error: {e}" + self.set_state(PaymentIdentifierState.ERROR) return + bolt11_invoice = invoice_data.get('pr') - # invoice = Invoice.from_bech32(bolt11_invoice) if invoice.get_amount_sat() != amount_sat: raise Exception("lnurl returned invoice with wrong amount") diff --git a/electrum/tests/test_util.py b/electrum/tests/test_util.py index 9013d6afd..6ff020710 100644 --- a/electrum/tests/test_util.py +++ b/electrum/tests/test_util.py @@ -2,12 +2,10 @@ from datetime import datetime from decimal import Decimal from electrum import util -from electrum.util import (format_satoshis, format_fee_satoshis, parse_URI, - is_hash256_str, chunks, is_ip_address, list_enabled_bits, - format_satoshis_plain, is_private_netaddress, is_hex_str, - is_integer, is_non_negative_integer, is_int_or_float, - is_non_negative_int_or_float, is_subpath, InvalidBitcoinURI) - +from electrum.util import (format_satoshis, format_fee_satoshis, is_hash256_str, chunks, is_ip_address, + list_enabled_bits, format_satoshis_plain, is_private_netaddress, is_hex_str, + is_integer, is_non_negative_integer, is_int_or_float, is_non_negative_int_or_float) +from electrum.payment_identifier import parse_bip21_URI, InvalidBitcoinURI from . import ElectrumTestCase, as_testnet @@ -102,7 +100,7 @@ class TestUtil(ElectrumTestCase): self.assertEqual("0.01234", format_satoshis_plain(1234, decimal_point=5)) def _do_test_parse_URI(self, uri, expected): - result = parse_URI(uri) + result = parse_bip21_URI(uri) self.assertEqual(expected, result) def test_parse_URI_address(self): @@ -143,13 +141,13 @@ class TestUtil(ElectrumTestCase): {'r': 'http://domain.tld/page?h=2a8628fc2fbe'}) def test_parse_URI_invalid_address(self): - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:invalidaddress') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:invalidaddress') def test_parse_URI_invalid(self): - self.assertRaises(InvalidBitcoinURI, parse_URI, 'notbitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'notbitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma') def test_parse_URI_parameter_pollution(self): - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&amount=30.0') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&amount=30.0') @as_testnet def test_parse_URI_lightning_consistency(self): @@ -174,11 +172,11 @@ class TestUtil(ElectrumTestCase): 'memo': 'test266', 'message': 'test266'}) # bip21 uri that includes "lightning" key. LN part has fallback address BUT it mismatches the top-level address - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qvu0c9xme0ul3gzx4nzqdgxsu25acuk9wvsj2j2?amount=0.0007&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qvu0c9xme0ul3gzx4nzqdgxsu25acuk9wvsj2j2?amount=0.0007&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') # bip21 uri that includes "lightning" key. top-level amount mismatches LN amount - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') # bip21 uri that includes "lightning" key with garbage unparseable value - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd') def test_is_hash256_str(self): self.assertTrue(is_hash256_str('09a4c03e3bdf83bbe3955f907ee52da4fc12f4813d459bc75228b64ad08617c7'))