import threading from typing import TYPE_CHECKING, Optional, Dict, Any import asyncio from urllib.parse import urlparse from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS, QTimer from electrum.i18n import _ 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.transaction import PartialTxOutput, TxOutput 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 (maybe_extract_lightning_payment_identifier, PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType) from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval, QtEventListener, event_listener class QEInvoice(QObject, QtEventListener): class Type: Invalid = -1 OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 class Status: Unpaid = PR_UNPAID Expired = PR_EXPIRED Unknown = PR_UNKNOWN Paid = PR_PAID Inflight = PR_INFLIGHT Failed = PR_FAILED Routing = PR_ROUTING Unconfirmed = PR_UNCONFIRMED Q_ENUMS(Type) Q_ENUMS(Status) _logger = get_logger(__name__) invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal([str], arguments=['key']) amountOverrideChanged = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self._wallet = None # type: Optional[QEWallet] self._isSaved = False self._canSave = False self._canPay = False self._key = None self._invoiceType = QEInvoice.Type.Invalid self._effectiveInvoice = None self._userinfo = '' self._lnprops = {} self._amount = QEAmount() self._amountOverride = QEAmount() self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.timeout.connect(self.updateStatusString) self._amountOverride.valueChanged.connect(self._on_amountoverride_value_changed) self.register_callbacks() self.destroyed.connect(lambda: self.on_destroy()) def on_destroy(self): self.unregister_callbacks() @event_listener def on_event_payment_succeeded(self, wallet, key): if wallet == self._wallet.wallet and key == self.key: self.statusChanged.emit() self.determine_can_pay() self.userinfo = _('Paid!') @event_listener def on_event_payment_failed(self, wallet, key, reason): if wallet == self._wallet.wallet and key == self.key: self.statusChanged.emit() self.determine_can_pay() self.userinfo = _('Payment failed: ') + reason @event_listener def on_event_invoice_status(self, wallet, key, status): if self._wallet and wallet == self._wallet.wallet and key == self.key: self.update_userinfo() self.determine_can_pay() self.statusChanged.emit() walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) def wallet(self): return self._wallet @wallet.setter def wallet(self, wallet: QEWallet): if self._wallet != wallet: self._wallet = wallet self.walletChanged.emit() @pyqtProperty(int, notify=invoiceChanged) def invoiceType(self): return self._invoiceType # not a qt setter, don't let outside set state def setInvoiceType(self, invoiceType: Type): self._invoiceType = invoiceType @pyqtProperty(str, notify=invoiceChanged) def message(self): return self._effectiveInvoice.message if self._effectiveInvoice else '' @pyqtProperty('quint64', notify=invoiceChanged) def time(self): return self._effectiveInvoice.time if self._effectiveInvoice else 0 @pyqtProperty('quint64', notify=invoiceChanged) def expiration(self): return self._effectiveInvoice.exp if self._effectiveInvoice else 0 @pyqtProperty(str, notify=invoiceChanged) def address(self): return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' @pyqtProperty(QEAmount, notify=invoiceChanged) def amount(self): if not self._effectiveInvoice: self._amount.clear() return self._amount self._amount.copyFrom(QEAmount(from_invoice=self._effectiveInvoice)) return self._amount @pyqtProperty(QEAmount, notify=amountOverrideChanged) def amountOverride(self): return self._amountOverride @amountOverride.setter def amountOverride(self, new_amount): self._logger.debug(f'set new override amount {repr(new_amount)}') self._amountOverride.copyFrom(new_amount) self.amountOverrideChanged.emit() @pyqtSlot() def _on_amountoverride_value_changed(self): self.update_userinfo() self.determine_can_pay() statusChanged = pyqtSignal() @pyqtProperty(int, notify=statusChanged) def status(self): if not self._effectiveInvoice: return PR_UNKNOWN return self._wallet.wallet.get_invoice_status(self._effectiveInvoice) @pyqtProperty(str, notify=statusChanged) def statusString(self): if not self._effectiveInvoice: return '' status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) return self._effectiveInvoice.get_status_str(status) isSavedChanged = pyqtSignal() @pyqtProperty(bool, notify=isSavedChanged) def isSaved(self): return self._isSaved canSaveChanged = pyqtSignal() @pyqtProperty(bool, notify=canSaveChanged) def canSave(self): return self._canSave @canSave.setter def canSave(self, canSave): if self._canSave != canSave: self._canSave = canSave self.canSaveChanged.emit() canPayChanged = pyqtSignal() @pyqtProperty(bool, notify=canPayChanged) def canPay(self): return self._canPay @canPay.setter def canPay(self, canPay): if self._canPay != canPay: self._canPay = canPay self.canPayChanged.emit() keyChanged = pyqtSignal() @pyqtProperty(str, notify=keyChanged) def key(self): return self._key @key.setter def key(self, key): if self._key != key: self._key = key if self._effectiveInvoice and self._effectiveInvoice.get_id() == key: return invoice = self._wallet.wallet.get_invoice(key) self._logger.debug(f'invoice from key {key}: {repr(invoice)}') self.set_effective_invoice(invoice) self.keyChanged.emit() userinfoChanged = pyqtSignal() @pyqtProperty(str, notify=userinfoChanged) def userinfo(self): return self._userinfo @userinfo.setter def userinfo(self, userinfo): if self._userinfo != userinfo: self._userinfo = userinfo self.userinfoChanged.emit() @pyqtProperty('QVariantMap', notify=invoiceChanged) def lnprops(self): return self._lnprops def set_lnprops(self): self._lnprops = {} if not self.invoiceType == QEInvoice.Type.LightningInvoice: return lnaddr = self._effectiveInvoice._lnaddr ln_routing_info = lnaddr.get_routing_info('r') self._logger.debug(str(ln_routing_info)) self._lnprops = { 'pubkey': lnaddr.pubkey.serialize().hex(), 'payment_hash': lnaddr.paymenthash.hex(), 'r': [{ 'node': self.name_for_node_id(x[-1][0]), 'scid': format_short_channel_id(x[-1][1]) } for x in ln_routing_info] if ln_routing_info else [] } def name_for_node_id(self, node_id): 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 if invoice is None: self.setInvoiceType(QEInvoice.Type.Invalid) else: if invoice.is_lightning(): self.setInvoiceType(QEInvoice.Type.LightningInvoice) else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) self._isSaved = self._wallet.wallet.get_invoice(invoice.get_id()) is not None self.set_lnprops() self.update_userinfo() self.determine_can_pay() self.invoiceChanged.emit() self.statusChanged.emit() self.isSavedChanged.emit() self.set_status_timer() def set_status_timer(self): if self.status != PR_EXPIRED: if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER: interval = status_update_timer_interval(self.time + self.expiration) if interval > 0: self._timer.setInterval(interval) # msec self._timer.start() else: self.update_userinfo() self.determine_can_pay() # status went to PR_EXPIRED @pyqtSlot() def updateStatusString(self): self.statusChanged.emit() self.set_status_timer() def update_userinfo(self): self.userinfo = '' if not self.amountOverride.isEmpty: amount = self.amountOverride else: amount = self.amount if self.amount.isEmpty: self.userinfo = _('Enter the amount you want to send') if amount.isEmpty and self.status == PR_UNPAID: # unspecified amount return if self.invoiceType == QEInvoice.Type.LightningInvoice: if self.status in [PR_UNPAID, PR_FAILED]: if self.get_max_spendable_lightning() >= amount.satsInt: lnaddr = self._effectiveInvoice._lnaddr if lnaddr.amount and amount.satsInt < lnaddr.amount * COIN: self.userinfo = _('Cannot pay less than the amount specified in the invoice') elif self.address and self.get_max_spendable_onchain() < amount.satsInt: # TODO: validate address? # TODO: subtract fee? self.userinfo = _('Insufficient balance') else: self.userinfo = { PR_EXPIRED: _('This invoice has expired'), PR_PAID: _('This invoice was already paid'), PR_INFLIGHT: _('Payment in progress...'), PR_ROUTING: _('Payment in progress'), PR_UNKNOWN: _('Invoice has unknown status'), }[self.status] elif self.invoiceType == QEInvoice.Type.OnchainInvoice: if self.status in [PR_UNPAID, PR_FAILED]: if not ((amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt)): self.userinfo = _('Insufficient balance') else: self.userinfo = { PR_EXPIRED: _('This invoice has expired'), PR_PAID: _('This invoice was already paid'), PR_BROADCASTING: _('Payment in progress...') + ' (' + _('broadcasting') + ')', PR_BROADCAST: _('Payment in progress...') + ' (' + _('broadcast successfully') + ')', PR_UNCONFIRMED: _('Payment in progress...') + ' (' + _('waiting for confirmation') + ')', PR_UNKNOWN: _('Invoice has unknown status'), }[self.status] def determine_can_pay(self): self.canPay = False self.canSave = False if not self.amountOverride.isEmpty: amount = self.amountOverride else: amount = self.amount self.canSave = True if amount.isEmpty and self.status == PR_UNPAID: # unspecified amount return if self.invoiceType == QEInvoice.Type.LightningInvoice: if self.status in [PR_UNPAID, PR_FAILED]: if self.get_max_spendable_lightning() >= amount.satsInt: lnaddr = self._effectiveInvoice._lnaddr if not (lnaddr.amount and amount.satsInt < lnaddr.amount * COIN): self.canPay = True elif self.address and self.get_max_spendable_onchain() > amount.satsInt: # TODO: validate address? # TODO: subtract fee? self.canPay = True elif self.invoiceType == QEInvoice.Type.OnchainInvoice: if self.status in [PR_UNPAID, PR_FAILED]: if amount.isMax and self.get_max_spendable_onchain() > 0: # TODO: dust limit? self.canPay = True elif self.get_max_spendable_onchain() >= amount.satsInt: # TODO: subtract fee? self.canPay = True @pyqtSlot() def payLightningInvoice(self): if not self.canPay: raise Exception('can not pay invoice, canPay is false') if self.invoiceType != QEInvoice.Type.LightningInvoice: raise Exception('payLightningInvoice can only pay lightning invoices') if self.amount.isEmpty: if self.amountOverride.isEmpty: raise Exception('can not pay 0 amount') # TODO: is update amount_msat for overrideAmount sufficient? self._effectiveInvoice.amount_msat = self.amountOverride.satsInt * 1000 self._wallet.pay_lightning_invoice(self._effectiveInvoice) def get_max_spendable_onchain(self): spendable = self._wallet.confirmedBalance.satsInt if not self._wallet.wallet.config.WALLET_SPEND_CONFIRMED_ONLY: spendable += self._wallet.unconfirmedBalance.satsInt return spendable def get_max_spendable_lightning(self): return self._wallet.wallet.lnworker.num_sats_can_send() if self._wallet.wallet.lnworker else 0 class QEInvoiceParser(QEInvoice): _logger = get_logger(__name__) validationSuccess = pyqtSignal() validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) validationError = pyqtSignal([str,str], arguments=['code', 'message']) invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) lnurlRetrieved = pyqtSignal() lnurlError = pyqtSignal([str,str], arguments=['code', 'message']) def __init__(self, parent=None): super().__init__(parent) self._recipient = '' self._pi = None self.clear() recipientChanged = pyqtSignal() @pyqtProperty(str, notify=recipientChanged) def recipient(self): return self._recipient @recipient.setter def recipient(self, recipient: str): self.canPay = False self._recipient = recipient self.amountOverride = QEAmount() if recipient: self.validateRecipient(recipient) self.recipientChanged.emit() @pyqtProperty('QVariantMap', notify=lnurlRetrieved) def lnurlData(self): return self._lnurlData @pyqtProperty(bool, notify=lnurlRetrieved) def isLnurlPay(self): return self._lnurlData is not None @pyqtSlot() def clear(self): self.recipient = '' self.setInvoiceType(QEInvoice.Type.Invalid) self._lnurlData = None self.canSave = False self.canPay = False self.userinfo = '' self.invoiceChanged.emit() def setValidOnchainInvoice(self, invoice: Invoice): self._logger.debug('setValidOnchainInvoice') if invoice.is_lightning(): raise Exception('unexpected LN invoice') self.set_effective_invoice(invoice) def setValidLightningInvoice(self, invoice: Invoice): self._logger.debug('setValidLightningInvoice') if not invoice.is_lightning(): raise Exception('unexpected Onchain invoice') self.set_effective_invoice(invoice) def setValidLNURLPayRequest(self): self._logger.debug('setValidLNURLPayRequest') self.setInvoiceType(QEInvoice.Type.LNURLPayRequest) self._effectiveInvoice = None self.invoiceChanged.emit() def create_onchain_invoice(self, outputs, message, payment_request, uri): return self._wallet.wallet.create_invoice( outputs=outputs, message=message, pr=payment_request, URI=uri ) def _bip70_payment_request_resolved(self, pr: 'PaymentRequest'): self._logger.debug('resolved payment request') if pr.verify(): invoice = Invoice.from_bip70_payreq(pr, height=0) if self._wallet.wallet.get_invoice_status(invoice) == PR_PAID: self.validationError.emit('unknown', _('Invoice already paid')) elif pr.has_expired(): self.validationError.emit('unknown', _('Payment request has expired')) else: self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() else: self.validationError.emit('unknown', f"invoice error:\n{pr.error}") def validateRecipient(self, recipient): if not recipient: self.setInvoiceType(QEInvoice.Type.Invalid) return self._pi = PaymentIdentifier(self._wallet.wallet, recipient) if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21, PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP]: self.validationError.emit('unknown', _('Unknown invoice')) return if self._pi.type == PaymentIdentifierType.SPK: txo = TxOutput(scriptpubkey=self._pi.spk, value=0) if not txo.address: self.validationError.emit('unknown', _('Unknown invoice')) return self._update_from_payment_identifier() def _update_from_payment_identifier(self): if self._pi.need_resolve(): self.resolve_pi() return if self._pi.type == PaymentIdentifierType.LNURLP: self.on_lnurl(self._pi.lnurl_data) return if self._pi.type == PaymentIdentifierType.BIP70: self._bip70_payment_request_resolved(self._pi.bip70_data) return if self._pi.is_available(): if self._pi.type == PaymentIdentifierType.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 == PaymentIdentifierType.BOLT11: lninvoice = 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 == PaymentIdentifierType.BIP21: if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11: lninvoice = self._pi.bolt11 self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() else: self._validateRecipient_bip21_onchain(self._pi.bip21) def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None: if 'amount' not in bip21: amount = 0 else: amount = bip21['amount'] outputs = [PartialTxOutput.from_address_and_value(bip21['address'], amount)] self._logger.debug(outputs) message = bip21['message'] if 'message' in bip21 else '' invoice = self.create_onchain_invoice(outputs, message, None, bip21) self._logger.debug(repr(invoice)) self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() def resolve_pi(self): assert self._pi.need_resolve() def on_finished(pi): if pi.is_error(): pass else: self._update_from_payment_identifier() self._pi.resolve(on_finished=on_finished) def on_lnurl(self, lnurldata): self._logger.debug('on_lnurl') self._logger.debug(f'{repr(lnurldata)}') self._lnurlData = { 'domain': urlparse(lnurldata.callback_url).netloc, 'callback_url' : lnurldata.callback_url, 'min_sendable_sat': lnurldata.min_sendable_sat, 'max_sendable_sat': lnurldata.max_sendable_sat, 'metadata_plaintext': lnurldata.metadata_plaintext, 'comment_allowed': lnurldata.comment_allowed } self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() @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['comment_allowed'] == 0: comment = None def on_finished(pi): if pi.is_error(): if pi.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) 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 @pyqtSlot() def saveInvoice(self): if not self._effectiveInvoice: return if self.isSaved: return if not self._effectiveInvoice.amount_msat and not self.amountOverride.isEmpty: if self.invoiceType == QEInvoice.Type.OnchainInvoice and self.amountOverride.isMax: self._effectiveInvoice.amount_msat = '!' else: self._effectiveInvoice.amount_msat = self.amountOverride.satsInt * 1000 self.canSave = False self._wallet.wallet.save_invoice(self._effectiveInvoice) self.key = self._effectiveInvoice.get_id() self._wallet.invoiceModel.addInvoice(self.key) self.invoiceSaved.emit(self.key)