diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml new file mode 100644 index 000000000..7ebeeaeb0 --- /dev/null +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -0,0 +1,118 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: dialog + + property Invoice invoice + + width: parent.width + height: parent.height + + title: qsTr('Invoice') + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('Type') + } + + Label { + text: invoice.invoiceType == Invoice.OnchainInvoice + ? qsTr('On-chain invoice') + : invoice.invoiceType == Invoice.LightningInvoice + ? qsTr('Lightning invoice') + : '' + Layout.fillWidth: true + } + + Label { + text: qsTr('Description') + } + + Label { + text: invoice.message + Layout.fillWidth: true + } + + Label { + text: qsTr('Amount to send') + } + + RowLayout { + Layout.fillWidth: true + Label { + font.bold: true + text: Config.formatSats(invoice.amount, false) + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + + Label { + id: fiatValue + Layout.fillWidth: true + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(invoice.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + } + } + + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + spacing: constants.paddingMedium + + Button { + text: qsTr('Cancel') + onClicked: dialog.close() + } + + Button { + text: qsTr('Save') +// enabled: invoice.invoiceType != Invoice.Invalid + enabled: invoice.invoiceType == Invoice.OnchainInvoice + onClicked: { + invoice.save_invoice() + dialog.close() + } + } + + Button { + text: qsTr('Pay now') + enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds + onClicked: { + console.log('pay now') + } + } + } + + } + +} diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 1751fbb03..5a464f579 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -4,11 +4,18 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls.Material 2.0 import QtQml.Models 2.1 +import org.electrum 1.0 + import "controls" Pane { id: rootItem + function clear() { + recipient.text = '' + amount.text = '' + } + GridLayout { id: form width: parent.width @@ -26,12 +33,16 @@ Pane { } TextArea { - id: address + id: recipient Layout.columnSpan: 2 Layout.fillWidth: true font.family: FixedFont wrapMode: Text.Wrap placeholderText: qsTr('Paste address or invoice') + onTextChanged: { + if (activeFocus) + invoice.recipient = text + } } RowLayout { @@ -40,7 +51,7 @@ Pane { icon.source: '../../icons/paste.png' icon.height: constants.iconSizeMedium icon.width: constants.iconSizeMedium - onClicked: address.text = AppController.clipboardToText() + onClicked: invoice.recipient = AppController.clipboardToText() } ToolButton { icon.source: '../../icons/qrcode.png' @@ -50,10 +61,7 @@ Pane { onClicked: { var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) page.onFound.connect(function() { - console.log('got ' + page.invoiceData) - address.text = page.invoiceData['address'] - amount.text = Config.satsToUnits(page.invoiceData['amount']) - description.text = page.invoiceData['message'] + invoice.recipient = page.scanData }) } } @@ -122,9 +130,9 @@ Pane { } TextField { - id: description + id: message font.family: FixedFont - placeholderText: qsTr('Description') + placeholderText: qsTr('Message') Layout.columnSpan: 3 Layout.fillWidth: true } @@ -136,24 +144,24 @@ Pane { Button { text: qsTr('Save') - enabled: false + enabled: invoice.invoiceType != Invoice.Invalid onClicked: { - console.log('TODO: save') + Daemon.currentWallet.create_invoice(recipient.text, amount.text, message.text) } } Button { text: qsTr('Pay now') - enabled: amount.text != '' && address.text != ''// TODO proper validation + enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds onClicked: { var f_amount = parseFloat(amount.text) if (isNaN(f_amount)) return var sats = Config.unitsToSats(amount.text).toString() var dialog = confirmPaymentDialog.createObject(app, { - 'address': address.text, + 'address': recipient.text, 'satoshis': sats, - 'message': description.text + 'message': message.text }) dialog.open() } @@ -224,6 +232,24 @@ Pane { } } + Component { + id: confirmPaymentDialog + ConfirmPaymentDialog {} + } + + Component { + id: confirmInvoiceDialog + ConfirmInvoiceDialog {} + } + + Connections { + target: Daemon.currentWallet + function onInvoiceStatusChanged(key, status) { + // TODO: status from? + //Daemon.currentWallet.invoiceModel.updateInvoice(key, status) + } + } + Connections { target: Daemon.fx function onQuotesUpdated() { @@ -240,4 +266,43 @@ Pane { FocusScope { id: parkFocus } } + Invoice { + id: invoice + wallet: Daemon.currentWallet + onValidationError: { + if (recipient.activeFocus) { + // no popups when editing + return + } + console.log(code + ' ' + message) + + var dialog = app.messageDialog.createObject(app, {'text': message }) + dialog.open() + rootItem.clear() + } + onValidationWarning: { + if (code == 'no_channels') { + var dialog = app.messageDialog.createObject(app, {'text': message }) + dialog.open() + // TODO: ask user to open a channel, if funds allow + // and maybe store invoice if expiry allows + } + } + onInvoiceTypeChanged: { + if (invoiceType == Invoice.Invalid) + return + // address only -> fill form fields + // else -> show invoice confirmation dialog + if (invoiceType == Invoice.OnchainOnlyAddress) + recipient.text = invoice.recipient + else { + var dialog = confirmInvoiceDialog.createObject(rootItem, {'invoice': invoice}) + dialog.open() + } + } + onInvoiceSaved: { + console.log('invoice got saved') + Daemon.currentWallet.invoiceModel.init_model() + } + } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 3cfe60464..07d3a63e6 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -100,24 +100,33 @@ Item { currentIndex: tabbar.currentIndex Item { - Receive { - id: receive + Loader { anchors.fill: parent + Receive { + id: receive + anchors.fill: parent + } } } Item { - History { - id: history + Loader { anchors.fill: parent + History { + id: history + anchors.fill: parent + } } } Item { enabled: !Daemon.currentWallet.isWatchOnly - Send { + Loader { anchors.fill: parent + Send { + anchors.fill: parent + } } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 6d3dc55ee..3107ee2ae 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -19,6 +19,7 @@ from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer +from .qeinvoice import QEInvoice notification = None @@ -115,6 +116,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser') qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') + qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py new file mode 100644 index 000000000..52c8cde0e --- /dev/null +++ b/electrum/gui/qml/qeinvoice.py @@ -0,0 +1,235 @@ +import asyncio +from datetime import datetime + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.keystore import bip39_is_checksum_valid +from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, + maybe_extract_bolt11_invoice) +from electrum.invoices import Invoice, OnchainInvoice, LNInvoice +from electrum.transaction import PartialTxOutput + +from .qewallet import QEWallet + +class QEInvoice(QObject): + + _logger = get_logger(__name__) + + class Type: + Invalid = -1 + OnchainOnlyAddress = 0 + OnchainInvoice = 1 + LightningInvoice = 2 + LightningAndOnchainInvoice = 3 + + Q_ENUMS(Type) + + _wallet = None + _invoiceType = Type.Invalid + _recipient = '' + _effectiveInvoice = None + _message = '' + _amount = 0 + + validationError = pyqtSignal([str,str], arguments=['code', 'message']) + validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceSaved = pyqtSignal() + + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + self.clear() + + invoiceTypeChanged = pyqtSignal() + @pyqtProperty(int, notify=invoiceTypeChanged) + def invoiceType(self): + return self._invoiceType + + # not a qt setter, don't let outside set state + def setInvoiceType(self, invoiceType: Type): + #if self._invoiceType != invoiceType: + self._invoiceType = invoiceType + self.invoiceTypeChanged.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() + + 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 + if recipient: + self.validateRecipient(recipient) + self.recipientChanged.emit() + + messageChanged = pyqtSignal() + @pyqtProperty(str, notify=messageChanged) + def message(self): + return self._message + + amountChanged = pyqtSignal() + @pyqtProperty('quint64', notify=amountChanged) + def amount(self): + return self._amount + + + @pyqtSlot() + def clear(self): + self.recipient = '' + self.invoiceSetsAmount = False + self.setInvoiceType(QEInvoice.Type.Invalid) + self._bip21 = None + + def setValidAddressOnly(self): + self._logger.debug('setValidAddressOnly') + self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) + self._effectiveInvoice = None ###TODO + + def setValidOnchainInvoice(self, invoice: OnchainInvoice): + self._logger.debug('setValidOnchainInvoice') + self.setInvoiceType(QEInvoice.Type.OnchainInvoice) + self._amount = invoice.get_amount_sat() + self.amountChanged.emit() + self._message = invoice.message + self.messageChanged.emit() + + self._effectiveInvoice = invoice + + def setValidLightningInvoice(self, invoice: LNInvoice): + self._logger.debug('setValidLightningInvoice') + self.setInvoiceType(QEInvoice.Type.LightningInvoice) + self._effectiveInvoice = invoice + + self._amount = int(invoice.get_amount_sat()) # TODO: float/str msat precision + self.amountChanged.emit() + self._message = invoice.message + self.messageChanged.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 + self.setValidAddressOnly() + 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 + try: + maybe_lightning_invoice = maybe_extract_bolt11_invoice(maybe_lightning_invoice) + lninvoice = LNInvoice.from_bech32(maybe_lightning_invoice) + except InvoiceError as e: + pass + + 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.clear() + return + else: + self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') + self.setValidOnchainInvoice(self._bip21['address']) + elif not self._wallet.wallet.lnworker.channels: + self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) + self.setValidLightningInvoice(lninvoice) + else: + self._logger.debug('flow without LN but having bip21 uri') + if 'amount' not in self._bip21: #TODO can we have amount-less invoices? + self.validationError.emit('no_amount', 'no amount in uri') + return + outputs = [PartialTxOutput.from_address_and_value(self._bip21['address'], self._bip21['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(invoice) + self.setValidOnchainInvoice(invoice) + + @pyqtSlot() + def save_invoice(self): + if not self._effectiveInvoice: + return + self._wallet.wallet.save_invoice(self._effectiveInvoice) + self.invoiceSaved.emit() + + @pyqtSlot(str, 'quint64', str) + def create_invoice(self, address: str, amount: int, message: str): + # create onchain invoice from user entered fields + # (any other type of invoice is created from parsing recipient) + self._logger.debug('saving invoice to %s' % address) + if not address: + self.invoiceCreateError.emit('fatal', _('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) + return + + if not bitcoin.is_address(address): + self.invoiceCreateError.emit('fatal', _('Invalid Bitcoin address')) + return + + if not self.amount: + self.invoiceCreateError.emit('fatal', _('Invalid amount')) + return + + + + # + if self.is_max: + amount = '!' + else: + try: + amount = self.app.get_amount(self.amount) + except: + self.app.show_error(_('Invalid amount') + ':\n' + self.amount) + return + diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 596949894..26c9a6bae 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -51,7 +51,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): invoices.append(item) self.clear() - self.beginInsertRows(QModelIndex(), 0, len(self.invoices) - 1) + self.beginInsertRows(QModelIndex(), 0, len(invoices) - 1) self.invoices = invoices self.endInsertRows() @@ -79,7 +79,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): i = 0 for item in self.invoices: if item['key'] == key: - invoice = self.get_invoice_for_key(key) #self.wallet.get_invoice(key) + invoice = self.get_invoice_for_key(key) item['status'] = status item['status_str'] = invoice.get_status_str(status) index = self.index(i,0) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index 3bd856e32..6d7204ffb 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -10,7 +10,7 @@ from PIL import Image, ImageQt from electrum.logging import get_logger from electrum.qrreader import get_qr_reader from electrum.i18n import _ - +from electrum.util import profiler class QEQRParser(QObject): def __init__(self, text=None, parent=None): @@ -123,6 +123,7 @@ class QEQRImageProvider(QQuickImageProvider): _logger = get_logger(__name__) + @profiler def requestImage(self, qstr, size): self._logger.debug('QR requested for %s' % qstr) qr = qrcode.QRCode(version=1, box_size=6, border=2) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 7413e4c20..bfcbc16d1 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -44,6 +44,9 @@ class QEWallet(QObject): requestStatusChanged = pyqtSignal([str,int], arguments=['key','status']) requestCreateSuccess = pyqtSignal() requestCreateError = pyqtSignal([str,str], arguments=['code','error']) + invoiceStatusChanged = pyqtSignal([str], arguments=['key']) + invoiceCreateSuccess = pyqtSignal() + invoiceCreateError = pyqtSignal([str,str], arguments=['code','error']) _network_signal = pyqtSignal(str, object) @@ -95,10 +98,15 @@ class QEWallet(QObject): if event == 'status': self.isUptodateChanged.emit() elif event == 'request_status': - wallet, addr, c = args + wallet, key, status = args if wallet == self.wallet: - self._logger.debug('request status %d for address %s' % (c, addr)) - self.requestStatusChanged.emit(addr, c) + self._logger.debug('request status %d for key %s' % (status, key)) + self.requestStatusChanged.emit(key, status) + elif event == 'invoice_status': + wallet, key = args + if wallet == self.wallet: + self._logger.debug('invoice status %d for key %s' % (c, key)) + self.invoiceStatusChanged.emit(key) elif event == 'new_transaction': wallet, tx = args if wallet == self.wallet: @@ -351,5 +359,5 @@ class QEWallet(QObject): @pyqtSlot('QString', result='QVariant') def get_invoice(self, key: str): - req = self.wallet.get_invoice(key) - return self._invoiceModel.invoice_to_model(req) + invoice = self.wallet.get_invoice(key) + return self._invoiceModel.invoice_to_model(invoice)