import threading import asyncio 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, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, 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) from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl from electrum.bitcoin import COIN from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval class QEInvoice(QObject): class Type: Invalid = -1 OnchainInvoice = 0 LightningInvoice = 1 LightningAndOnchainInvoice = 2 LNURLPayRequest = 3 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__) def __init__(self, parent=None): super().__init__(parent) self._wallet = None self._canSave = False self._canPay = False self._key = None 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() 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 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() def get_max_spendable_onchain(self): spendable = self._wallet.confirmedBalance.satsInt if not self._wallet.wallet.config.get('confirmed_only', False): spendable += self._wallet.unconfirmedBalance.satsInt return spendable def get_max_spendable_lightning(self): return self._wallet.wallet.lnworker.num_sats_can_send() class QEInvoiceParser(QEInvoice): _logger = get_logger(__name__) invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal([str], arguments=['key']) 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._invoiceType = QEInvoice.Type.Invalid self._recipient = '' self._effectiveInvoice = None self._amount = QEAmount() self._userinfo = '' self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.timeout.connect(self.updateStatusString) self.clear() @pyqtProperty(int, notify=invoiceChanged) def invoiceType(self): return self._invoiceType # not a qt setter, don't let outside set state def setInvoiceType(self, invoiceType: QEInvoice.Type): self._invoiceType = invoiceType recipientChanged = pyqtSignal() @pyqtProperty(str, notify=recipientChanged) def recipient(self): return self._recipient @recipient.setter def recipient(self, recipient: str): #if self._recipient != recipient: self.canPay = False self._recipient = recipient self._lnurlData = None if recipient: self.validateRecipient(recipient) self.recipientChanged.emit() @pyqtProperty('QVariantMap', notify=lnurlRetrieved) def lnurlData(self): return self._lnurlData @pyqtProperty(str, notify=invoiceChanged) def message(self): return self._effectiveInvoice.message 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 @amount.setter def amount(self, new_amount): self._logger.debug(f'set new amount {repr(new_amount)}') if self._effectiveInvoice: self._effectiveInvoice.amount_msat = '!' if new_amount.isMax else int(new_amount.satsInt * 1000) self.determine_can_pay() self.invoiceChanged.emit() @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('quint64', notify=invoiceChanged) def time(self): return self._effectiveInvoice.time if self._effectiveInvoice else 0 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 status_str(self): if not self._effectiveInvoice: return '' status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) return self._effectiveInvoice.get_status_str(status) # single address only, TODO: n outputs @pyqtProperty(str, notify=invoiceChanged) def address(self): return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' @pyqtProperty('QVariantMap', notify=invoiceChanged) def lnprops(self): if not self.invoiceType == QEInvoice.Type.LightningInvoice: return {} lnaddr = self._effectiveInvoice._lnaddr self._logger.debug(str(lnaddr)) self._logger.debug(str(lnaddr.get_routing_info('t'))) return { 'pubkey': lnaddr.pubkey.serialize().hex(), 'payment_hash': lnaddr.paymenthash.hex(), 't': '', #lnaddr.get_routing_info('t')[0][0].hex(), 'r': '' #lnaddr.get_routing_info('r')[0][0][0].hex() } @pyqtSlot() def clear(self): self.recipient = '' self.setInvoiceType(QEInvoice.Type.Invalid) self._bip21 = None self._lnurlData = None self.canSave = False self.canPay = False self.userinfo = '' self.invoiceChanged.emit() # don't parse the recipient string, but init qeinvoice from an invoice key # this should not emit validation signals @pyqtSlot(str) def initFromKey(self, key): self.clear() invoice = self._wallet.wallet.get_invoice(key) self._logger.debug(repr(invoice)) if invoice: self.set_effective_invoice(invoice) self.key = key def set_effective_invoice(self, invoice: Invoice): self._effectiveInvoice = invoice if invoice.is_lightning(): self.setInvoiceType(QEInvoice.Type.LightningInvoice) else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) self.canSave = True self.determine_can_pay() self.invoiceChanged.emit() self.statusChanged.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() @pyqtSlot() def updateStatusString(self): self.statusChanged.emit() self.set_status_timer() def determine_can_pay(self): self.canPay = False self.userinfo = '' if self.amount.isEmpty: # unspecified amount return if self.invoiceType == QEInvoice.Type.LightningInvoice: if self.status in [PR_UNPAID, PR_FAILED]: if self.get_max_spendable_lightning() >= self.amount.satsInt: lnaddr = self._effectiveInvoice._lnaddr if lnaddr.amount and self.amount.satsInt < lnaddr.amount * COIN: self.userinfo = _('Cannot pay less than the amount specified in the invoice') else: self.canPay = True else: self.userinfo = _('Insufficient balance') else: self.userinfo = { PR_EXPIRED: _('Invoice is expired'), PR_PAID: _('Invoice is already paid'), PR_INFLIGHT: _('Invoice is already being paid'), PR_ROUTING: _('Invoice is already being paid'), PR_UNKNOWN: _('Invoice has unknown status'), }[self.status] elif self.invoiceType == QEInvoice.Type.OnchainInvoice: if self.status in [PR_UNPAID, PR_FAILED]: if self.amount.isMax and self.get_max_spendable_onchain() > 0: # TODO: dust limit? self.canPay = True elif self.get_max_spendable_onchain() >= self.amount.satsInt: # TODO: dust limit? self.canPay = True else: self.userinfo = _('Insufficient balance') else: self.userinfo = { PR_EXPIRED: _('Invoice is expired'), PR_PAID: _('Invoice is already paid'), PR_UNCONFIRMED: _('Invoice is already paid'), PR_UNKNOWN: _('Invoice has unknown status'), }[self.status] 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 validateRecipient(self, recipient): if not recipient: self.setInvoiceType(QEInvoice.Type.Invalid) return maybe_lightning_invoice = recipient def _payment_request_resolved(request): self._logger.debug('resolved payment request') outputs = request.get_outputs() invoice = self.create_onchain_invoice(outputs, None, request, None) self.setValidOnchainInvoice(invoice) try: self._bip21 = parse_URI(recipient, _payment_request_resolved) if self._bip21: if 'r' in self._bip21 or ('name' in self._bip21 and 'sig' in self._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(self._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 self._bip21: maybe_lightning_invoice = self._bip21['lightning'] except InvalidBitcoinURI as e: self._bip21 = None self._logger.debug(repr(e)) 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)) if not lninvoice and not self._bip21: self.validationError.emit('unknown',_('Unknown invoice')) self.clear() return if lninvoice: if not self._wallet.wallet.has_lightning(): if not self._bip21: # TODO: lightning onchain fallback in ln invoice #self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet')) self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() # self.clear() return else: self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') self.setValidOnchainInvoice(self._bip21['address']) else: self.setValidLightningInvoice(lninvoice) if not self._wallet.wallet.lnworker.channels: self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) else: self.validationSuccess.emit() else: self._logger.debug('flow without LN but having bip21 uri') if 'amount' not in self._bip21: amount = 0 else: amount = self._bip21['amount'] outputs = [PartialTxOutput.from_address_and_value(self._bip21['address'], amount)] self._logger.debug(outputs) message = self._bip21['message'] if 'message' in self._bip21 else '' invoice = self.create_onchain_invoice(outputs, message, None, self._bip21) self._logger.debug(repr(invoice)) 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, self._wallet.wallet.network.asyncio_loop) self.on_lnurl(fut.result()) except Exception as e: self.validationError.emit('lnurl', repr(e)) threading.Thread(target=resolve_task).start() 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('quint64') @pyqtSlot('quint64', str) def lnurlGetInvoice(self, amount, comment=None): assert self._lnurlData 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, self._wallet.wallet.network.asyncio_loop) self.on_lnurl_invoice(fut.result()) except Exception as e: self.lnurlError.emit('lnurl', repr(e)) threading.Thread(target=fetch_invoice_task).start() def on_lnurl_invoice(self, invoice): self._logger.debug('on_lnurl_invoice') self._logger.debug(f'{repr(invoice)}') invoice = invoice['pr'] self.recipient = invoice @pyqtSlot() def save_invoice(self): self.canSave = False if not self._effectiveInvoice: return self.key = self._effectiveInvoice.get_id() if self._wallet.wallet.get_invoice(self.key): self._logger.info(f'invoice {self.key} already exists') else: self._wallet.wallet.save_invoice(self._effectiveInvoice) self._wallet.invoiceModel.addInvoice(self.key) self.invoiceSaved.emit(self.key) class QEUserEnteredPayment(QEInvoice): _logger = get_logger(__name__) validationError = pyqtSignal([str,str], arguments=['code','message']) invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) invoiceSaved = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self._amount = QEAmount() self.clear() recipientChanged = pyqtSignal() @pyqtProperty(str, notify=recipientChanged) def recipient(self): return self._recipient @recipient.setter def recipient(self, recipient: str): if self._recipient != recipient: self._recipient = recipient self.validate() self.recipientChanged.emit() messageChanged = pyqtSignal() @pyqtProperty(str, notify=messageChanged) def message(self): return self._message @message.setter def message(self, message): if self._message != message: self._message = message self.messageChanged.emit() amountChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=amountChanged) def amount(self): return self._amount @amount.setter def amount(self, amount): if self._amount != amount: self._amount.copyFrom(amount) self.validate() self.amountChanged.emit() def validate(self): self.canPay = False self.canSave = False self._logger.debug('validate') if not self._recipient: self.validationError.emit('recipient', _('Recipient not specified.')) return if not bitcoin.is_address(self._recipient): self.validationError.emit('recipient', _('Invalid Bitcoin address')) return if self._amount.isEmpty: self.validationError.emit('amount', _('Invalid amount')) return if self._amount.isMax: self.canPay = True else: self.canSave = True if self.get_max_spendable_onchain() >= self._amount.satsInt: self.canPay = True @pyqtSlot() def save_invoice(self): assert self.canSave assert not self._amount.isMax self._logger.debug('saving invoice to %s, amount=%s, message=%s' % (self._recipient, repr(self._amount), self._message)) inv_amt = self._amount.satsInt try: outputs = [PartialTxOutput.from_address_and_value(self._recipient, inv_amt)] self._logger.debug(repr(outputs)) invoice = self._wallet.wallet.create_invoice(outputs=outputs, message=self._message, pr=None, URI=None) except InvoiceError as e: self.invoiceCreateError.emit('fatal', _('Error creating payment') + ':\n' + str(e)) return self.key = invoice.get_id() self._wallet.wallet.save_invoice(invoice) self.invoiceSaved.emit() @pyqtSlot() def clear(self): self._recipient = None self._amount.clear() self._message = None self.canSave = False self.canPay = False