From 503139148459be46af48d7ff28b654bbaab5ed95 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 25 Apr 2022 15:55:34 +0200 Subject: [PATCH] add a QEAmount type for storing/passing BTC amounts in the widest sense from a UI perspective. Stores sats, millisats (LN), whether MAX amount is requested etc some refactor QEInvoice type and Send page --- .../qml/components/ConfirmInvoiceDialog.qml | 19 ++++- electrum/gui/qml/components/Send.qml | 11 ++- electrum/gui/qml/qeapp.py | 5 +- electrum/gui/qml/qebitcoin.py | 6 +- electrum/gui/qml/qeconfig.py | 8 +- electrum/gui/qml/qefx.py | 33 ++++++-- electrum/gui/qml/qeinvoice.py | 80 ++++++++++++------- electrum/gui/qml/qeinvoicelistmodel.py | 8 +- electrum/gui/qml/qetransactionlistmodel.py | 7 ++ electrum/gui/qml/qetypes.py | 55 +++++++++++++ 10 files changed, 184 insertions(+), 48 deletions(-) create mode 100644 electrum/gui/qml/qetypes.py diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml index 7ebeeaeb0..f1c4bf3ef 100644 --- a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -11,6 +11,7 @@ Dialog { id: dialog property Invoice invoice + property string invoice_key width: parent.width height: parent.height @@ -84,9 +85,20 @@ Dialog { } } + Label { + text: qsTr('Expiration') + visible: true + } + + Label { + id: expiration + text: invoice.time + invoice.expiration + } + RowLayout { Layout.columnSpan: 2 - Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + Layout.fillHeight: true spacing: constants.paddingMedium Button { @@ -115,4 +127,9 @@ Dialog { } + Component.onCompleted: { + if (invoice_key != '') { + invoice.initFromKey(invoice_key) + } + } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 5a464f579..14eb92ee9 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -213,7 +213,12 @@ Pane { model: DelegateModel { id: delegateModel model: Daemon.currentWallet.invoiceModel - delegate: InvoiceDelegate {} + delegate: InvoiceDelegate { + onClicked: { + var dialog = confirmInvoiceDialog.createObject(app, {'invoice' : invoice, 'invoice_key': model.key}) + dialog.open() + } + } } remove: Transition { @@ -288,9 +293,7 @@ Pane { // and maybe store invoice if expiry allows } } - onInvoiceTypeChanged: { - if (invoiceType == Invoice.Invalid) - return + onValidationSuccess: { // address only -> fill form fields // else -> show invoice confirmation dialog if (invoiceType == Invoice.OnchainOnlyAddress) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 3107ee2ae..aa7722e8b 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -5,7 +5,7 @@ import os from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QLocale, qInstallMessageHandler, QTimer from PyQt5.QtGui import QGuiApplication, QFontDatabase -from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine +from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine from electrum.logging import Logger, get_logger from electrum import version @@ -20,6 +20,7 @@ from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer from .qeinvoice import QEInvoice +from .qetypes import QEAmount notification = None @@ -118,6 +119,8 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') + qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') + self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index d1f6b681c..e001a509e 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -10,6 +10,8 @@ from electrum.slip39 import decode_mnemonic, Slip39Error from electrum import mnemonic from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI +from .qetypes import QEAmount + class QEBitcoin(QObject): def __init__(self, config, parent=None): super().__init__(parent) @@ -121,11 +123,11 @@ class QEBitcoin(QObject): except InvalidBitcoinURI as e: return { 'error': str(e) } - @pyqtSlot(str, 'qint64', str, int, int, result=str) + @pyqtSlot(str, QEAmount, str, int, int, result=str) def create_uri(self, address, satoshis, message, timestamp, expiry): extra_params = {} if expiry: extra_params['time'] = str(timestamp) extra_params['exp'] = str(expiry) - return create_bip21_uri(address, satoshis, message, extra_query_params=extra_params) + return create_bip21_uri(address, satoshis.satsInt, message, extra_query_params=extra_params) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index f385c3ba4..411eac7c3 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -5,6 +5,8 @@ from decimal import Decimal from electrum.logging import get_logger from electrum.util import DECIMAL_POINT_DEFAULT +from .qetypes import QEAmount + class QEConfig(QObject): def __init__(self, config, parent=None): super().__init__(parent) @@ -70,7 +72,11 @@ class QEConfig(QObject): @pyqtSlot('qint64', result=str) @pyqtSlot('qint64', bool, result=str) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) def formatSats(self, satoshis, with_unit=False): + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt if with_unit: return self.config.format_amount_and_units(satoshis) else: @@ -85,11 +91,11 @@ class QEConfig(QObject): @pyqtSlot(str, result='qint64') def unitsToSats(self, unitAmount): - # returns amt in satoshis try: x = Decimal(unitAmount) except: return 0 + # scale it to max allowed precision, make it an int max_prec_amount = int(pow(10, self.max_precision()) * x) # if the max precision is simply what unit conversion allows, just return diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index abe2f87f6..66a634f87 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -9,6 +9,8 @@ from electrum.simple_config import SimpleConfig from electrum.util import register_callback from electrum.bitcoin import COIN +from .qetypes import QEAmount + class QEFX(QObject): def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None): super().__init__(parent) @@ -88,25 +90,40 @@ class QEFX(QObject): @pyqtSlot(str, result=str) @pyqtSlot(str, bool, result=str) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) def fiatValue(self, satoshis, plain=True): rate = self.fx.exchange_rate() - try: - sd = Decimal(satoshis) - if sd == 0: + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + else: + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + except: return '' - except: - return '' if plain: return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), False) else: return self.fx.value_str(satoshis, rate) @pyqtSlot(str, str, result=str) + @pyqtSlot(str, str, bool, result=str) + @pyqtSlot(QEAmount, str, result=str) + @pyqtSlot(QEAmount, str, bool, result=str) def fiatValueHistoric(self, satoshis, timestamp, plain=True): - try: - sd = Decimal(satoshis) - if sd == 0: + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + else: + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + except: return '' + + try: td = Decimal(timestamp) if td == 0: return '' diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 52c8cde0e..1dda2e42f 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -12,6 +12,7 @@ from electrum.invoices import Invoice, OnchainInvoice, LNInvoice from electrum.transaction import PartialTxOutput from .qewallet import QEWallet +from .qetypes import QEAmount class QEInvoice(QObject): @@ -30,28 +31,26 @@ class QEInvoice(QObject): _invoiceType = Type.Invalid _recipient = '' _effectiveInvoice = None - _message = '' - _amount = 0 - validationError = pyqtSignal([str,str], arguments=['code', 'message']) - validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal() + validationSuccess = pyqtSignal() + validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + validationError = pyqtSignal([str,str], arguments=['code', 'message']) + def __init__(self, config, parent=None): super().__init__(parent) self.config = config self.clear() - invoiceTypeChanged = pyqtSignal() - @pyqtProperty(int, notify=invoiceTypeChanged) + @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): - #if self._invoiceType != invoiceType: - self._invoiceType = invoiceType - self.invoiceTypeChanged.emit() + self._invoiceType = invoiceType walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) @@ -77,16 +76,29 @@ class QEInvoice(QObject): self.validateRecipient(recipient) self.recipientChanged.emit() - messageChanged = pyqtSignal() - @pyqtProperty(str, notify=messageChanged) + @pyqtProperty(str, notify=invoiceChanged) def message(self): - return self._message + return self._effectiveInvoice.message if self._effectiveInvoice else '' - amountChanged = pyqtSignal() - @pyqtProperty('quint64', notify=amountChanged) + @pyqtProperty(QEAmount, notify=invoiceChanged) def amount(self): + # store ref to QEAmount on instance, otherwise we get destroyed when going out of scope + self._amount = QEAmount() # + if not self._effectiveInvoice: + return self._amount + sats = self._effectiveInvoice.get_amount_sat() + if not sats: + return self._amount + self._amount = QEAmount(amount_sat=sats) return self._amount + @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 @pyqtSlot() def clear(self): @@ -94,31 +106,38 @@ class QEInvoice(QObject): self.invoiceSetsAmount = False self.setInvoiceType(QEInvoice.Type.Invalid) self._bip21 = None + 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): + invoice = self._wallet.wallet.get_invoice(key) + self._logger.debug(repr(invoice)) + if invoice: + self.set_effective_invoice(invoice) + + 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.invoiceChanged.emit() def setValidAddressOnly(self): self._logger.debug('setValidAddressOnly') self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) self._effectiveInvoice = None ###TODO + self.invoiceChanged.emit() 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 + self.set_effective_invoice(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() + self.set_effective_invoice(invoice) def create_onchain_invoice(self, outputs, message, payment_request, uri): return self._wallet.wallet.create_invoice( @@ -150,6 +169,7 @@ class QEInvoice(QObject): if ':' not in recipient: # address only self.setValidAddressOnly() + self.validationSuccess.emit() return else: # fallback lightning invoice? @@ -194,13 +214,15 @@ class QEInvoice(QObject): 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._logger.debug(repr(invoice)) self.setValidOnchainInvoice(invoice) + self.validationSuccess.emit() @pyqtSlot() def save_invoice(self): if not self._effectiveInvoice: return + # TODO detect duplicate? self._wallet.wallet.save_invoice(self._effectiveInvoice) self.invoiceSaved.emit() diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 26c9a6bae..00b5d339c 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -7,6 +7,8 @@ from electrum.logging import get_logger from electrum.util import Satoshis, format_time from electrum.invoices import Invoice +from .qetypes import QEAmount + class QEAbstractInvoiceListModel(QAbstractListModel): _logger = get_logger(__name__) @@ -35,6 +37,8 @@ class QEAbstractInvoiceListModel(QAbstractListModel): return value if isinstance(value, Satoshis): return value.value + if isinstance(value, QEAmount): + return value return str(value) def clear(self): @@ -111,7 +115,7 @@ class QEInvoiceListModel(QEAbstractInvoiceListModel): item = self.wallet.export_invoice(invoice) item['type'] = invoice.type # 0=onchain, 2=LN item['date'] = format_time(item['timestamp']) - item['amount'] = invoice.get_amount_sat() + item['amount'] = QEAmount(amount_sat=invoice.get_amount_sat()) if invoice.type == 0: item['key'] = invoice.id elif invoice.type == 2: @@ -136,7 +140,7 @@ class QERequestListModel(QEAbstractInvoiceListModel): item['key'] = self.wallet.get_key_for_receive_request(req) item['type'] = req.type # 0=onchain, 2=LN item['date'] = format_time(item['timestamp']) - item['amount'] = req.get_amount_sat() + item['amount'] = QEAmount(amount_sat=req.get_amount_sat()) return item diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 453e1a85b..f4243a16a 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -6,6 +6,8 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.logging import get_logger from electrum.util import Satoshis, TxMinedInfo +from .qetypes import QEAmount + class QETransactionListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) @@ -36,6 +38,8 @@ class QETransactionListModel(QAbstractListModel): return value if isinstance(value, Satoshis): return value.value + if isinstance(value, QEAmount): + return value return str(value) def clear(self): @@ -48,6 +52,9 @@ class QETransactionListModel(QAbstractListModel): for output in item['outputs']: output['value'] = output['value'].value + item['bc_value'] = QEAmount(amount_sat=item['bc_value'].value) + item['bc_balance'] = QEAmount(amount_sat=item['bc_balance'].value) + # newly arriving txs have no (block) timestamp # TODO? if not item['timestamp']: diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py new file mode 100644 index 000000000..c2f207189 --- /dev/null +++ b/electrum/gui/qml/qetypes.py @@ -0,0 +1,55 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.util import profiler + +# container for satoshi amounts that can be passed around more +# easily between python, QML-property and QML-javascript contexts +# QML 'int' is 32 bit signed, so overflows on satoshi amounts +# QML 'quint64' and 'qint64' can be used, but this breaks +# down when passing through property bindings +# should also capture millisats amounts and MAX/'!' indicators +# and (unformatted) string representations + +class QEAmount(QObject): + _logger = get_logger(__name__) + + def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, parent=None): + super().__init__(parent) + self._amount_sat = amount_sat + self._amount_msat = amount_msat + self._is_max = is_max + + valueChanged = pyqtSignal() + + @pyqtProperty('qint64', notify=valueChanged) + def satsInt(self): + return self._amount_sat + + @pyqtProperty('qint64', notify=valueChanged) + def msatsInt(self): + return self._amount_msat + + @pyqtProperty(str, notify=valueChanged) + def satsStr(self): + return str(self._amount_sat) + + @pyqtProperty(str, notify=valueChanged) + def msatsStr(self): + return str(self._amount_msat) + + @pyqtProperty(bool, notify=valueChanged) + def isMax(self): + return self._is_max + + def __eq__(self, other): + self._logger.debug('__eq__') + if isinstance(other, QEAmount): + return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max + elif isinstance(other, int): + return self._amount_sat == other + elif isinstance(other, str): + return self.satsStr == other + + return False