From 15eb765eacf9d14049550e9253326d243518c0de Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sun, 19 Mar 2023 13:32:43 +0100 Subject: [PATCH 01/33] payment_identifiers: - this separates GUI from core handling - the PaymentIdentifier class handles network requests - the GUI is agnostic about the type of PI --- electrum/gui/kivy/main_window.py | 5 +- electrum/gui/kivy/uix/screens.py | 6 +- electrum/gui/qml/qeapp.py | 2 +- electrum/gui/qt/__init__.py | 4 +- electrum/gui/qt/main_window.py | 5 +- electrum/gui/qt/paytoedit.py | 214 +----------- electrum/gui/qt/send_tab.py | 456 ++++++++++--------------- electrum/gui/qt/util.py | 1 + electrum/invoices.py | 2 +- electrum/payment_identifier.py | 559 +++++++++++++++++++++++++++++++ electrum/transaction.py | 3 +- electrum/util.py | 169 ---------- electrum/wallet.py | 3 +- electrum/x509.py | 3 +- run_electrum | 5 +- 15 files changed, 767 insertions(+), 670 deletions(-) create mode 100644 electrum/payment_identifier.py diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index 6270436a2..c3bcba6a7 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -86,9 +86,8 @@ Label.register( ) -from electrum.util import (NoDynamicFeeEstimates, NotEnoughFunds, - BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME, - UserFacingException) +from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException +from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 23556ac92..73690c5af 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -18,8 +18,8 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING, from electrum import bitcoin, constants from electrum import lnutil from electrum.transaction import tx_from_any, PartialTxOutput -from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, - InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME) +from electrum.util import TxMinedInfo, InvoiceError, format_time, parse_max_spend +from electrum.payment_identifier import parse_bip21_URI, BITCOIN_BIP21_URI_SCHEME, maybe_extract_lightning_payment_identifier, InvalidBitcoinURI from electrum.lnaddr import lndecode, LnInvoiceException from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from electrum.logging import Logger @@ -208,7 +208,7 @@ class SendScreen(CScreen, Logger): def set_bip21(self, text: str): try: - uri = parse_URI(text, self.app.on_pr, loop=self.app.asyncio_loop) + uri = parse_bip21_URI(text) # bip70 not supported except InvalidBitcoinURI as e: self.app.show_info(_("Error parsing URI") + f":\n{e}") return diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index bf41bd932..24f861434 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica from electrum import version, constants from electrum.i18n import _ from electrum.logging import Logger, get_logger -from electrum.util import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue from electrum.network import Network diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index fdb54b98f..7891d1f21 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -87,7 +87,7 @@ class OpenFileEventFilter(QObject): def eventFilter(self, obj, event): if event.type() == QtCore.QEvent.FileOpen: if len(self.windows) >= 1: - self.windows[0].handle_payment_identifier(event.url().toString()) + self.windows[0].set_payment_identifier(event.url().toString()) return True return False @@ -393,7 +393,7 @@ class ElectrumGui(BaseElectrumGui, Logger): window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) window.activateWindow() if uri: - window.handle_payment_identifier(uri) + window.send_tab.set_payment_identifier(uri) return window def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index afaecfb68..8bb6d9c1e 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -58,9 +58,10 @@ from electrum.i18n import _ from electrum.util import (format_time, get_asyncio_loop, UserCancelled, profiler, bfh, InvalidPassword, - UserFacingException, FailedToParsePaymentIdentifier, + UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, - AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod) + AddTransactionException, os_chmod) +from electrum.payment_identifier import FailedToParsePaymentIdentifier, BITCOIN_BIP21_URI_SCHEME from electrum.invoices import PR_PAID, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 964a729da..5076b5292 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -33,7 +33,8 @@ from PyQt5.QtGui import QFontMetrics, QFont from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout from electrum import bitcoin -from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier +from electrum.util import parse_max_spend +from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier from electrum.transaction import PartialTxOutput from electrum.bitcoin import opcodes, construct_script from electrum.logging import Logger @@ -49,20 +50,10 @@ if TYPE_CHECKING: from .send_tab import SendTab -RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' - frozen_style = "QWidget {border:none;}" normal_style = "QPlainTextEdit { }" -class PayToLineError(NamedTuple): - line_content: str - exc: Exception - idx: int = 0 # index of line - is_multiline: bool = False - - - class ResizingTextEdit(QTextEdit): def __init__(self): @@ -109,12 +100,9 @@ class PayToEdit(Logger, GenericInputHandler): self.amount_edit = self.send_tab.amount_e self.is_multiline = False - self.outputs = [] # type: List[PartialTxOutput] - self.errors = [] # type: List[PayToLineError] self.disable_checks = False self.is_alias = False self.payto_scriptpubkey = None # type: Optional[bytes] - self.lightning_invoice = None self.previous_payto = '' # editor methods self.setStyleSheet = self.editor.setStyleSheet @@ -180,6 +168,7 @@ class PayToEdit(Logger, GenericInputHandler): self.setText(text) def do_clear(self): + self.is_multiline = False self.set_paytomany(False) self.disable_checks = False self.is_alias = False @@ -194,58 +183,6 @@ class PayToEdit(Logger, GenericInputHandler): def setExpired(self): self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) - def parse_address_and_amount(self, line) -> PartialTxOutput: - try: - x, y = line.split(',') - except ValueError: - raise Exception("expected two comma-separated values: (address, amount)") from None - scriptpubkey = self.parse_output(x) - amount = self.parse_amount(y) - return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount) - - def parse_output(self, x) -> bytes: - try: - address = self.parse_address(x) - return bytes.fromhex(bitcoin.address_to_script(address)) - except Exception: - pass - try: - script = self.parse_script(x) - return bytes.fromhex(script) - except Exception: - pass - raise Exception("Invalid address or script.") - - def parse_script(self, x): - script = '' - for word in x.split(): - if word[0:3] == 'OP_': - opcode_int = opcodes[word] - script += construct_script([opcode_int]) - else: - bytes.fromhex(word) # to test it is hex data - script += construct_script([word]) - return script - - def parse_amount(self, x): - x = x.strip() - if not x: - raise Exception("Amount is empty") - if parse_max_spend(x): - return x - p = pow(10, self.amount_edit.decimal_point()) - try: - return int(p * Decimal(x)) - except decimal.InvalidOperation: - raise Exception("Invalid amount") - - def parse_address(self, line): - r = line.strip() - m = re.match('^'+RE_ALIAS+'$', r) - address = str(m.group(2) if m else r) - assert bitcoin.is_address(address) - return address - def _on_input_btn(self, text: str): self.setText(text) @@ -257,6 +194,7 @@ class PayToEdit(Logger, GenericInputHandler): if self.is_multiline and not self._is_paytomany: self.set_paytomany(True) self.text_edit.setText(text) + self.text_edit.setFocus() def on_timer_check_text(self): if self.editor.hasFocus(): @@ -265,149 +203,33 @@ class PayToEdit(Logger, GenericInputHandler): self._check_text(text, full_check=True) def _check_text(self, text, *, full_check: bool): - """ - side effects: self.is_multiline, self.errors, self.outputs - """ - if self.previous_payto == str(text).strip(): + """ side effects: self.is_multiline """ + text = str(text).strip() + if not text: + return + if self.previous_payto == text: return if full_check: - self.previous_payto = str(text).strip() - self.errors = [] - errors = [] + self.previous_payto = text if self.disable_checks: return - # filter out empty lines - lines = text.split('\n') - lines = [i for i in lines if i] - self.is_multiline = len(lines)>1 - - self.payto_scriptpubkey = None - self.lightning_invoice = None - self.outputs = [] - - if len(lines) == 1: - data = lines[0] - try: - self.send_tab.handle_payment_identifier(data, can_use_network=full_check) - except LNURLError as e: - self.logger.exception("") - self.send_tab.show_error(e) - except FailedToParsePaymentIdentifier: - pass - else: - return - # try "address, amount" on-chain format - try: - self._parse_as_multiline(lines, raise_errors=True) - except Exception as e: - pass - else: - return - # try address/script - try: - self.payto_scriptpubkey = self.parse_output(data) - except Exception as e: - errors.append(PayToLineError(line_content=data, exc=e)) - else: - self.send_tab.set_onchain(True) - self.send_tab.lock_amount(False) - return - if full_check: # network requests # FIXME blocking GUI thread - # try openalias - oa_data = self._resolve_openalias(data) - if oa_data: - self._set_openalias(key=data, data=oa_data) - return - # all parsing attempts failed, so now expose the errors: - if errors: - self.errors = errors - else: - # there are multiple lines - self._parse_as_multiline(lines, raise_errors=False) + pi = PaymentIdentifier(self.config, self.win.contacts, text) + self.is_multiline = bool(pi.multiline_outputs) + print('is_multiline', self.is_multiline) + self.send_tab.handle_payment_identifier(pi, can_use_network=full_check) - - def _parse_as_multiline(self, lines, *, raise_errors: bool): - outputs = [] # type: List[PartialTxOutput] + def handle_multiline(self, outputs): total = 0 is_max = False - for i, line in enumerate(lines): - try: - output = self.parse_address_and_amount(line) - except Exception as e: - if raise_errors: - raise - else: - self.errors.append(PayToLineError( - idx=i, line_content=line.strip(), exc=e, is_multiline=True)) - continue - outputs.append(output) + for output in outputs: if parse_max_spend(output.value): is_max = True else: total += output.value - if outputs: - self.send_tab.set_onchain(True) - + self.send_tab.set_onchain(True) self.send_tab.max_button.setChecked(is_max) - self.outputs = outputs - self.payto_scriptpubkey = None - if self.send_tab.max_button.isChecked(): self.send_tab.spend_max() else: self.amount_edit.setAmount(total if outputs else None) - self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs)) - - def get_errors(self) -> Sequence[PayToLineError]: - return self.errors - - def get_destination_scriptpubkey(self) -> Optional[bytes]: - return self.payto_scriptpubkey - - def get_outputs(self, is_max: bool) -> List[PartialTxOutput]: - if self.payto_scriptpubkey: - if is_max: - amount = '!' - else: - amount = self.send_tab.get_amount() - self.outputs = [PartialTxOutput(scriptpubkey=self.payto_scriptpubkey, value=amount)] - - return self.outputs[:] - - def _resolve_openalias(self, text: str) -> Optional[dict]: - key = text - key = key.strip() # strip whitespaces - if not (('.' in key) and ('<' not in key) and (' ' not in key)): - return None - parts = key.split(sep=',') # assuming single line - if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): - return None - try: - data = self.win.contacts.resolve(key) - except Exception as e: - self.logger.info(f'error resolving address/alias: {repr(e)}') - return None - return data or None - - def _set_openalias(self, *, key: str, data: dict) -> bool: - self.is_alias = True - self.setFrozen(True) - key = key.strip() # strip whitespaces - address = data.get('address') - name = data.get('name') - new_url = key + ' <' + address + '>' - self.setText(new_url) - - #if self.win.config.get('openalias_autoadd') == 'checked': - self.win.contacts[key] = ('openalias', name) - self.win.contact_list.update() - - if data.get('type') == 'openalias': - self.validated = data.get('validated') - if self.validated: - self.setGreen() - else: - self.setExpired() - else: - self.validated = None - return True + #self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs)) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index dffa15d31..c07851daf 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -5,8 +5,6 @@ import asyncio from decimal import Decimal from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any -from urllib.parse import urlparse - from PyQt5.QtCore import pyqtSignal, QPoint from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) @@ -15,15 +13,14 @@ from electrum import util, paymentrequest from electrum import lnutil from electrum.plugin import run_hook from electrum.i18n import _ -from electrum.util import (get_asyncio_loop, FailedToParsePaymentIdentifier, - InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds, - NoDynamicFeeEstimates, InvoiceError, parse_max_spend) + +from electrum.util import get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates, InvoiceError, parse_max_spend +from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier, InvalidBitcoinURI from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST + from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.logging import Logger -from electrum.lnaddr import lndecode, LnInvoiceException -from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit @@ -36,15 +33,9 @@ if TYPE_CHECKING: class SendTab(QWidget, MessageBoxMixin, Logger): - payment_request_ok_signal = pyqtSignal() - payment_request_error_signal = pyqtSignal() - lnurl6_round1_signal = pyqtSignal(object, object) - lnurl6_round2_signal = pyqtSignal(object) - clear_send_tab_signal = pyqtSignal() - show_error_signal = pyqtSignal(str) - - payment_request: Optional[paymentrequest.PaymentRequest] - _lnurl_data: Optional[LNURL6Data] = None + round_1_signal = pyqtSignal(object) + round_2_signal = pyqtSignal(object) + round_3_signal = pyqtSignal(object) def __init__(self, window: 'ElectrumWindow'): QWidget.__init__(self, window) @@ -60,8 +51,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.format_amount = window.format_amount self.base_unit = window.base_unit - self.payto_URI = None - self.payment_request = None # type: Optional[paymentrequest.PaymentRequest] + self.payment_identifier = None self.pending_invoice = None # A 4-column grid layout. All the stretch is in the last column. @@ -84,9 +74,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): + _("Integers weights can also be used in conjunction with '!', " "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")) payto_label = HelpLabel(_('Pay to'), msg) - grid.addWidget(payto_label, 1, 0) - grid.addWidget(self.payto_e.line_edit, 1, 1, 1, 4) - grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4) + grid.addWidget(payto_label, 0, 0) + grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4) + grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4) #completer = QCompleter() #completer.setCaseSensitivity(False) @@ -97,9 +87,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger): + _( 'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.') description_label = HelpLabel(_('Description'), msg) - grid.addWidget(description_label, 2, 0) + grid.addWidget(description_label, 1, 0) self.message_e = SizedFreezableLineEdit(width=600) - grid.addWidget(self.message_e, 2, 1, 1, 4) + grid.addWidget(self.message_e, 1, 1, 1, 4) + + msg = _('Comment for recipient') + self.comment_label = HelpLabel(_('Comment'), msg) + grid.addWidget(self.comment_label, 2, 0) + self.comment_e = SizedFreezableLineEdit(width=600) + grid.addWidget(self.comment_e, 2, 1, 1, 4) + self.comment_label.hide() + self.comment_e.hide() msg = (_('The amount to be received by the recipient.') + ' ' + _('Fees are paid by the sender.') + '\n\n' @@ -129,11 +127,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) self.clear_button = EnterButton(_("Clear"), self.do_clear) self.paste_button = QPushButton() - self.paste_button.clicked.connect(lambda: self.payto_e._on_input_btn(self.window.app.clipboard().text())) + self.paste_button.clicked.connect(self.do_paste) self.paste_button.setIcon(read_QIcon('copy.png')) self.paste_button.setToolTip(_('Paste invoice from clipboard')) self.paste_button.setMaximumWidth(35) - grid.addWidget(self.paste_button, 1, 5) + grid.addWidget(self.paste_button, 0, 5) buttons = QHBoxLayout() buttons.addStretch(1) @@ -160,7 +158,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.invoice_list = InvoiceList(self) self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('') - menu.addAction(read_QIcon(get_iconname_camera()), _("Read QR code with camera"), self.payto_e.on_qr_from_camera_input_btn) menu.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), self.payto_e.on_qr_from_screenshot_input_btn) menu.addAction(read_QIcon("file.png"), _("Read invoice from file"), self.payto_e.on_input_file) @@ -186,17 +183,33 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.invoice_list.update() # after parented and put into a layout, can update without flickering run_hook('create_send_tab', grid) - self.payment_request_ok_signal.connect(self.payment_request_ok) - self.payment_request_error_signal.connect(self.payment_request_error) - self.lnurl6_round1_signal.connect(self.on_lnurl6_round1) - self.lnurl6_round2_signal.connect(self.on_lnurl6_round2) - self.clear_send_tab_signal.connect(self.do_clear) - self.show_error_signal.connect(self.show_error) + self.round_1_signal.connect(self.on_round_1) + self.round_2_signal.connect(self.on_round_2) + self.round_3_signal.connect(self.on_round_3) + + def do_paste(self): + text = self.window.app.clipboard().text() + if not text: + return + self.set_payment_identifier(text) + + def set_payment_identifier(self, text): + pi = PaymentIdentifier(self.config, self.window.contacts, text) + if pi.error: + self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error) + return + if pi.is_multiline(): + self.payto_e.set_paytomany(True) + self.payto_e.text_edit.setText(text) + else: + self.payto_e.setTextNoCheck(text) + self.handle_payment_identifier(pi, can_use_network=True) def spend_max(self): if run_hook('abort_send', self): return - outputs = self.payto_e.get_outputs(True) + amount = self.get_amount() + outputs = self.payment_identifier.get_onchain_outputs(amount) if not outputs: return make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction( @@ -297,15 +310,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.format_amount_and_units(frozen_bal) def do_clear(self): - self._lnurl_data = None self.max_button.setChecked(False) - self.payment_request = None - self.payto_URI = None self.payto_e.do_clear() self.set_onchain(False) - for e in [self.message_e, self.amount_e]: + for w in [self.comment_e, self.comment_label]: + w.setVisible(False) + for e in [self.message_e, self.amount_e, self.fiat_send_e]: e.setText('') - e.setFrozen(False) + self.set_field_style(e, None, False) for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: e.setEnabled(True) self.window.update_status() @@ -315,208 +327,101 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self._is_onchain = b self.max_button.setEnabled(b) - def lock_amount(self, b: bool) -> None: - self.amount_e.setFrozen(b) - self.max_button.setEnabled(not b) - def prepare_for_send_tab_network_lookup(self): self.window.show_send_tab() self.payto_e.disable_checks = True - for e in [self.payto_e, self.message_e]: - e.setFrozen(True) - self.lock_amount(True) + #for e in [self.payto_e, self.message_e]: + self.payto_e.setFrozen(True) for btn in [self.save_button, self.send_button, self.clear_button]: btn.setEnabled(False) self.payto_e.setTextNoCheck(_("please wait...")) - def payment_request_ok(self): - pr = self.payment_request - if not pr: - return - invoice = Invoice.from_bip70_payreq(pr, height=0) - if self.wallet.get_invoice_status(invoice) == PR_PAID: - self.show_message("invoice already paid") - self.do_clear() - self.payment_request = None - return - self.payto_e.disable_checks = True - if not pr.has_expired(): - self.payto_e.setGreen() - else: - self.payto_e.setExpired() - self.payto_e.setTextNoCheck(pr.get_requestor()) - self.amount_e.setAmount(pr.get_amount()) - self.message_e.setText(pr.get_memo()) - self.set_onchain(True) - self.max_button.setEnabled(False) - # note: allow saving bip70 reqs, as we save them anyway when paying them - for btn in [self.send_button, self.clear_button, self.save_button]: - btn.setEnabled(True) - # signal to set fee - self.amount_e.textEdited.emit("") - - def payment_request_error(self): - pr = self.payment_request - if not pr: - return - self.show_message(pr.error) - self.payment_request = None + def payment_request_error(self, error): + self.show_message(error) self.do_clear() - def on_pr(self, request: 'paymentrequest.PaymentRequest'): - self.payment_request = request - if self.payment_request.verify(self.window.contacts): - self.payment_request_ok_signal.emit() + def set_field_style(self, w, text, validated): + from .util import ColorScheme + if validated is None: + style = ColorScheme.LIGHTBLUE.as_stylesheet(True) + elif validated is True: + style = ColorScheme.GREEN.as_stylesheet(True) else: - self.payment_request_error_signal.emit() - - def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True): - try: - url = decode_lnurl(lnurl) - except LnInvoiceException as e: - self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") - return - if not can_use_network: - return - - async def f(): - try: - lnurl_data = await request_lnurl(url) - except LNURLError as e: - self.show_error_signal.emit(f"LNURL request encountered error: {e}") - self.clear_send_tab_signal.emit() - return - self.lnurl6_round1_signal.emit(lnurl_data, url) - - asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable - self.prepare_for_send_tab_network_lookup() - - def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str): - self._lnurl_data = lnurl_data - domain = urlparse(url).netloc - self.payto_e.setTextNoCheck(f"invoice from lnurl") - self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}") - self.amount_e.setAmount(lnurl_data.min_sendable_sat) - self.amount_e.setFrozen(False) - for btn in [self.send_button, self.clear_button]: - btn.setEnabled(True) - self.set_onchain(False) - - def set_bolt11(self, invoice: str): - """Parse ln invoice, and prepare the send tab for it.""" - try: - lnaddr = lndecode(invoice) - except LnInvoiceException as e: - self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") - return - except lnutil.IncompatibleOrInsaneFeatures as e: - self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") - return - - pubkey = lnaddr.pubkey.serialize().hex() - for k,v in lnaddr.tags: - if k == 'd': - description = v - break + style = ColorScheme.RED.as_stylesheet(True) + if text is not None: + w.setStyleSheet(style) + w.setReadOnly(True) else: - description = '' - self.payto_e.setFrozen(True) - self.payto_e.setTextNoCheck(pubkey) - self.payto_e.lightning_invoice = invoice - if not self.message_e.text(): + w.setStyleSheet('') + w.setReadOnly(False) + + def update_fields(self, pi): + recipient, amount, description, comment, validated = pi.get_fields_for_GUI(self.wallet) + if recipient: + self.payto_e.setTextNoCheck(recipient) + elif pi.multiline_outputs: + self.payto_e.handle_multiline(pi.multiline_outputs) + if description: self.message_e.setText(description) - if lnaddr.get_amount_sat() is not None: - self.amount_e.setAmount(lnaddr.get_amount_sat()) - self.set_onchain(False) - - def set_bip21(self, text: str, *, can_use_network: bool = True): - on_bip70_pr = self.on_pr if can_use_network else None - try: - out = util.parse_URI(text, on_bip70_pr) - except InvalidBitcoinURI as e: - self.show_error(_("Error parsing URI") + f":\n{e}") - return - self.payto_URI = out - r = out.get('r') - sig = out.get('sig') - name = out.get('name') - if (r or (name and sig)) and can_use_network: - self.prepare_for_send_tab_network_lookup() - return - address = out.get('address') - amount = out.get('amount') - label = out.get('label') - message = out.get('message') - lightning = out.get('lightning') - if lightning and (self.wallet.has_lightning() or not address): - self.handle_payment_identifier(lightning, can_use_network=can_use_network) - return - # use label as description (not BIP21 compliant) - if label and not message: - message = label - if address: - self.payto_e.setText(address) - if message: - self.message_e.setText(message) if amount: self.amount_e.setAmount(amount) - - def handle_payment_identifier(self, text: str, *, can_use_network: bool = True): - """Takes - Lightning identifiers: - * lightning-URI (containing bolt11 or lnurl) - * bolt11 invoice - * lnurl - Bitcoin identifiers: - * bitcoin-URI - and sets the sending screen. - """ - text = text.strip() - if not text: + for w in [self.comment_e, self.comment_label]: + w.setVisible(not bool(comment)) + self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated) + self.set_field_style(self.message_e, description, validated) + self.set_field_style(self.amount_e, amount, validated) + self.set_field_style(self.fiat_send_e, amount, validated) + + def handle_payment_identifier(self, pi, *, can_use_network: bool = True): + self.payment_identifier = pi + is_valid = pi.is_valid() + self.save_button.setEnabled(is_valid) + self.send_button.setEnabled(is_valid) + if not is_valid: return - if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): - if invoice_or_lnurl.startswith('lnurl'): - self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network) - else: - self.set_bolt11(invoice_or_lnurl) - elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'): - self.set_bip21(text, can_use_network=can_use_network) - else: - truncated_text = f"{text[:100]}..." if len(text) > 100 else text - raise FailedToParsePaymentIdentifier(f"Could not handle payment identifier:\n{truncated_text}") + self.update_fields(pi) + if can_use_network and pi.needs_round_1(): + coro = pi.round_1(on_success=self.round_1_signal.emit) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + self.prepare_for_send_tab_network_lookup() # update fiat amount self.amount_e.textEdited.emit("") self.window.show_send_tab() + def on_round_1(self, pi): + if pi.error: + self.show_error(pi.error) + self.do_clear() + return + self.update_fields(pi) + for btn in [self.send_button, self.clear_button, self.save_button]: + btn.setEnabled(True) + + def get_message(self): + return self.message_e.text() def read_invoice(self) -> Optional[Invoice]: if self.check_payto_line_and_show_errors(): return - try: - if not self._is_onchain: - invoice_str = self.payto_e.lightning_invoice - if not invoice_str: - return - invoice = Invoice.from_bech32(invoice_str) - if invoice.amount_msat is None: - amount_sat = self.get_amount() - if amount_sat: - invoice.amount_msat = int(amount_sat * 1000) - if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): - self.show_error(_('Lightning is disabled')) - return - return invoice - else: - outputs = self.read_outputs() - if self.check_onchain_outputs_and_show_errors(outputs): - return - message = self.message_e.text() - return self.wallet.create_invoice( - outputs=outputs, - message=message, - pr=self.payment_request, - URI=self.payto_URI) - except InvoiceError as e: - self.show_error(_('Error creating payment') + ':\n' + str(e)) + amount_sat = self.read_amount() + if not amount_sat: + self.show_error(_('No amount')) + return + + invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message()) + #except Exception as e: + if not invoice: + self.show_error('error getting invoice' + pi.error) + return + if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): + self.show_error(_('Lightning is disabled')) + if self.wallet.get_invoice_status(invoice) == PR_PAID: + # fixme: this is only for bip70 and lightning + self.show_error(_('Invoice already paid')) + return + #if not invoice.is_lightning(): + # if self.check_onchain_outputs_and_show_errors(outputs): + # return + return invoice def do_save_invoice(self): self.pending_invoice = self.read_invoice() @@ -536,41 +441,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # must not be None return self.amount_e.get_amount() or 0 - def _lnurl_get_invoice(self) -> None: - assert self._lnurl_data - amount = self.get_amount() - if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat): - self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.') - return - - async def f(): - try: - invoice_data = await callback_lnurl( - self._lnurl_data.callback_url, - params={'amount': self.get_amount() * 1000}, - ) - except LNURLError as e: - self.show_error_signal.emit(f"LNURL request encountered error: {e}") - self.clear_send_tab_signal.emit() - return - invoice = invoice_data.get('pr') - self.lnurl6_round2_signal.emit(invoice) - - asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable - self.prepare_for_send_tab_network_lookup() - - def on_lnurl6_round2(self, bolt11_invoice: str): - self._lnurl_data = None - invoice = Invoice.from_bech32(bolt11_invoice) - assert invoice.get_amount_sat() == self.get_amount(), (invoice.get_amount_sat(), self.get_amount()) + def on_round_2(self, pi): self.do_clear() - self.payto_e.setText(bolt11_invoice) + if pi.error: + self.show_error(pi.error) + self.do_clear() + return + self.update_fields(pi) + invoice = pi.get_invoice(self.wallet, self.get_amount(), self.get_message()) self.pending_invoice = invoice self.do_pay_invoice(invoice) + def on_round_3(self): + pass + def do_pay_or_get_invoice(self): - if self._lnurl_data: - self._lnurl_get_invoice() + pi = self.payment_identifier + if pi.needs_round_2(): + coro = pi.round_2(self.round_2_signal.emit, amount_sat=self.get_amount(), comment=self.message_e.text()) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) # TODO should be cancellable + self.prepare_for_send_tab_network_lookup() return self.pending_invoice = self.read_invoice() if not self.pending_invoice: @@ -600,12 +490,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): else: self.pay_onchain_dialog(invoice.outputs) - def read_outputs(self) -> List[PartialTxOutput]: - if self.payment_request: - outputs = self.payment_request.get_outputs() - else: - outputs = self.payto_e.get_outputs(self.max_button.isChecked()) - return outputs + def read_amount(self) -> List[PartialTxOutput]: + is_max = self.max_button.isChecked() + amount = '!' if is_max else self.get_amount() + return amount def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool: """Returns whether there are errors with outputs. @@ -629,34 +517,30 @@ class SendTab(QWidget, MessageBoxMixin, Logger): """Returns whether there are errors. Also shows error dialog to user if so. """ - pr = self.payment_request - if pr: - if pr.has_expired(): - self.show_error(_('Payment request has expired')) - return True + error = self.payment_identifier.get_error() + if error: + if not self.payment_identifier.is_multiline(): + err = error + self.show_warning( + _("Failed to parse 'Pay to' line") + ":\n" + + f"{err.line_content[:40]}...\n\n" + f"{err.exc!r}") + else: + self.show_warning( + _("Invalid Lines found:") + "\n\n" + error) + #'\n'.join([_("Line #") + + # f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})" + # for err in errors])) + return True - if not pr: - errors = self.payto_e.get_errors() - if errors: - if len(errors) == 1 and not errors[0].is_multiline: - err = errors[0] - self.show_warning(_("Failed to parse 'Pay to' line") + ":\n" + - f"{err.line_content[:40]}...\n\n" - f"{err.exc!r}") - else: - self.show_warning(_("Invalid Lines found:") + "\n\n" + - '\n'.join([_("Line #") + - f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})" - for err in errors])) + if self.payment_identifier.warning: + msg += '\n' + _('Do you wish to continue?') + if not self.question(msg): return True - if self.payto_e.is_alias and self.payto_e.validated is False: - alias = self.payto_e.toPlainText() - msg = _('WARNING: the alias "{}" could not be validated via an additional ' - 'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n' - msg += _('Do you wish to continue?') - if not self.question(msg): - return True + if self.payment_identifier.has_expired(): + self.show_error(_('Payment request has expired')) + return True return False # no errors @@ -740,9 +624,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def broadcast_thread(): # non-GUI thread - pr = self.payment_request - if pr and pr.has_expired(): - self.payment_request = None + if self.payment_identifier.has_expired(): return False, _("Invoice has expired") try: self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) @@ -752,13 +634,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return False, repr(e) # success txid = tx.txid() - if pr: - self.payment_request = None + if self.payment_identifier.needs_round_3(): refund_address = self.wallet.get_receiving_address() - coro = pr.send_payment_and_receive_paymentack(tx.serialize(), refund_address) - fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) - ack_status, ack_msg = fut.result(timeout=20) - self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") + coro = self.payment_identifier.round_3(tx.serialize(), refund_address) + asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) return True, txid # Capture current TL window; override might be removed on return @@ -804,3 +683,4 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.payto_e.setFocus() text = "\n".join([payto + ", 0" for payto in paytos]) self.payto_e.setText(text) + self.payto_e.setFocus() diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index a393c2bdb..1d5f5c2b9 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -961,6 +961,7 @@ class ColorScheme: YELLOW = ColorSchemeItem("#897b2a", "#ffff00") RED = ColorSchemeItem("#7c1111", "#f18c8c") BLUE = ColorSchemeItem("#123b7c", "#8cb3f2") + LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff") DEFAULT = ColorSchemeItem("black", "white") GRAY = ColorSchemeItem("gray", "gray") diff --git a/electrum/invoices.py b/electrum/invoices.py index 6f7300e36..61a90b781 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -7,6 +7,7 @@ import attr from .json_db import StoredObject, stored_in from .i18n import _ from .util import age, InvoiceError, format_satoshis +from .payment_identifier import create_bip21_uri from .lnutil import hex_to_bytes from .lnaddr import lndecode, LnAddr from . import constants @@ -318,7 +319,6 @@ class Request(BaseInvoice): *, lightning_invoice: Optional[str] = None, ) -> Optional[str]: - from electrum.util import create_bip21_uri addr = self.get_address() amount = self.get_amount_sat() if amount is not None: diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py new file mode 100644 index 000000000..cbf1a6461 --- /dev/null +++ b/electrum/payment_identifier.py @@ -0,0 +1,559 @@ +import asyncio +import urllib +import re +from decimal import Decimal +from typing import NamedTuple, Optional, Callable, Any, Sequence +from urllib.parse import urlparse + +from . import bitcoin +from .logging import Logger +from .util import parse_max_spend, format_satoshis_plain +from .util import get_asyncio_loop, log_exceptions +from .transaction import PartialTxOutput +from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data +from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC +from .lnaddr import lndecode, LnDecodeException, LnInvoiceException +from .lnutil import IncompatibleOrInsaneFeatures + +def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: + data = data.strip() # whitespaces + data = data.lower() + if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): + cut_prefix = LIGHTNING_URI_SCHEME + ':' + data = data[len(cut_prefix):] + if data.startswith('ln'): + 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' +LIGHTNING_URI_SCHEME = 'lightning' + + +class InvalidBitcoinURI(Exception): pass + + +def parse_bip21_URI(uri: str) -> dict: + """Raises InvalidBitcoinURI on malformed URI.""" + + if not isinstance(uri, str): + raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") + + if ':' not in uri: + if not bitcoin.is_address(uri): + raise InvalidBitcoinURI("Not a bitcoin address") + return {'address': uri} + + u = urllib.parse.urlparse(uri) + if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: + raise InvalidBitcoinURI("Not a bitcoin URI") + address = u.path + + # python for android fails to parse query + if address.find('?') > 0: + address, query = u.path.split('?') + pq = urllib.parse.parse_qs(query) + else: + pq = urllib.parse.parse_qs(u.query) + + for k, v in pq.items(): + if len(v) != 1: + raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') + + out = {k: v[0] for k, v in pq.items()} + if address: + if not bitcoin.is_address(address): + raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") + out['address'] = address + if 'amount' in out: + am = out['amount'] + try: + m = re.match(r'([0-9.]+)X([0-9])', am) + if m: + k = int(m.group(2)) - 8 + amount = Decimal(m.group(1)) * pow(Decimal(10), k) + else: + amount = Decimal(am) * COIN + if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: + raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") + out['amount'] = int(amount) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e + if 'message' in out: + out['message'] = out['message'] + out['memo'] = out['message'] + if 'time' in out: + try: + out['time'] = int(out['time']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e + if 'exp' in out: + try: + out['exp'] = int(out['exp']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e + if 'sig' in out: + try: + out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e + if 'lightning' in out: + try: + lnaddr = lndecode(out['lightning']) + except LnDecodeException as e: + raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e + amount_sat = out.get('amount') + if amount_sat: + # allow small leeway due to msat precision + if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") + address = out.get('address') + ln_fallback_addr = lnaddr.get_fallback_address() + if address and ln_fallback_addr: + if ln_fallback_addr != address: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") + + return out + + + +def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], + *, extra_query_params: Optional[dict] = None) -> str: + if not bitcoin.is_address(addr): + return "" + if extra_query_params is None: + extra_query_params = {} + query = [] + if amount_sat: + query.append('amount=%s'%format_satoshis_plain(amount_sat)) + if message: + query.append('message=%s'%urllib.parse.quote(message)) + for k, v in extra_query_params.items(): + if not isinstance(k, str) or k != urllib.parse.quote(k): + raise Exception(f"illegal key for URI: {repr(k)}") + v = urllib.parse.quote(v) + query.append(f"{k}={v}") + p = urllib.parse.ParseResult( + scheme=BITCOIN_BIP21_URI_SCHEME, + netloc='', + path=addr, + params='', + query='&'.join(query), + fragment='', + ) + return str(urllib.parse.urlunparse(p)) + + + +def is_uri(data: str) -> bool: + data = data.lower() + if (data.startswith(LIGHTNING_URI_SCHEME + ":") or + data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): + return True + return False + + + +class FailedToParsePaymentIdentifier(Exception): + pass + +class PayToLineError(NamedTuple): + line_content: str + exc: Exception + idx: int = 0 # index of line + is_multiline: bool = False + +RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' +RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' + +class PaymentIdentifier(Logger): + """ + Takes: + * bitcoin addresses or script + * paytomany csv + * openalias + * bip21 URI + * lightning-URI (containing bolt11 or lnurl) + * bolt11 invoice + * lnurl + """ + + def __init__(self, config, contacts, text): + Logger.__init__(self) + self.contacts = contacts + self.config = config + self.text = text + self._type = None + self.error = None # if set, GUI should show error and stop + self.warning = None # if set, GUI should ask user if they want to proceed + # more than one of those may be set + self.multiline_outputs = None + self.bolt11 = None + self.bip21 = None + self.spk = None + # + self.openalias = None + self.openalias_data = None + # + self.bip70 = None + self.bip70_data = None + # + self.lnurl = None + self.lnurl_data = None + # parse without network + self.parse(text) + + def is_valid(self): + return bool(self._type) + + def is_lightning(self): + return self.lnurl or self.bolt11 + + def is_multiline(self): + return bool(self.multiline_outputs) + + def get_error(self) -> str: + return self.error + + def needs_round_1(self): + return self.bip70 or self.openalias or self.lnurl + + def needs_round_2(self): + return self.lnurl and self.lnurl_data + + def needs_round_3(self): + return self.bip70 + + def parse(self, text): + # parse text, set self._type and self.error + text = text.strip() + if not text: + return + if outputs:= self._parse_as_multiline(text): + self._type = 'multiline' + self.multiline_outputs = outputs + elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): + if invoice_or_lnurl.startswith('lnurl'): + self._type = 'lnurl' + try: + self.lnurl = decode_lnurl(invoice_or_lnurl) + except Exception as e: + self.error = "Error parsing Lightning invoice" + f":\n{e}" + return + else: + self._type = 'bolt11' + self.bolt11 = invoice_or_lnurl + elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): + try: + out = parse_bip21_URI(text) + except InvalidBitcoinURI as e: + self.error = _("Error parsing URI") + f":\n{e}" + return + self._type = 'bip21' + self.bip21 = out + self.bip70 = out.get('r') + elif scriptpubkey := self.parse_output(text): + self._type = 'spk' + self.spk = scriptpubkey + elif re.match(RE_EMAIL, text): + self._type = 'alias' + self.openalias = text + elif self.error is None: + truncated_text = f"{text[:100]}..." if len(text) > 100 else text + self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_text}") + + def get_onchain_outputs(self, amount): + if self.bip70: + return self.bip70_data.get_outputs() + elif self.multiline_outputs: + return self.multiline_outputs + elif self.spk: + return [PartialTxOutput(scriptpubkey=self.spk, value=amount)] + elif self.bip21: + address = self.bip21.get('address') + scriptpubkey = self.parse_output(address) + return [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)] + else: + raise Exception('not onchain') + + def _parse_as_multiline(self, text): + # filter out empty lines + lines = text.split('\n') + lines = [i for i in lines if i] + is_multiline = len(lines)>1 + outputs = [] # type: List[PartialTxOutput] + errors = [] + total = 0 + is_max = False + for i, line in enumerate(lines): + try: + output = self.parse_address_and_amount(line) + except Exception as e: + errors.append(PayToLineError( + idx=i, line_content=line.strip(), exc=e, is_multiline=True)) + continue + outputs.append(output) + if parse_max_spend(output.value): + is_max = True + else: + total += output.value + if is_multiline and errors: + self.error = str(errors) if errors else None + print(outputs, self.error) + return outputs + + def parse_address_and_amount(self, line) -> 'PartialTxOutput': + try: + x, y = line.split(',') + except ValueError: + raise Exception("expected two comma-separated values: (address, amount)") from None + scriptpubkey = self.parse_output(x) + amount = self.parse_amount(y) + return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount) + + def parse_output(self, x) -> bytes: + try: + address = self.parse_address(x) + return bytes.fromhex(bitcoin.address_to_script(address)) + except Exception as e: + error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False) + try: + script = self.parse_script(x) + return bytes.fromhex(script) + except Exception as e: + #error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False) + pass + #raise Exception("Invalid address or script.") + #self.errors.append(error) + + def parse_script(self, x): + script = '' + for word in x.split(): + if word[0:3] == 'OP_': + opcode_int = opcodes[word] + script += construct_script([opcode_int]) + else: + bytes.fromhex(word) # to test it is hex data + script += construct_script([word]) + return script + + def parse_amount(self, x): + x = x.strip() + if not x: + raise Exception("Amount is empty") + if parse_max_spend(x): + return x + p = pow(10, self.config.get_decimal_point()) + try: + return int(p * Decimal(x)) + except decimal.InvalidOperation: + raise Exception("Invalid amount") + + def parse_address(self, line): + r = line.strip() + m = re.match('^'+RE_ALIAS+'$', r) + address = str(m.group(2) if m else r) + assert bitcoin.is_address(address) + return address + + def get_fields_for_GUI(self, wallet): + """ sets self.error as side effect""" + recipient = None + amount = None + description = None + validated = None + comment = "no comment" + + if self.openalias and self.openalias_data: + address = self.openalias_data.get('address') + name = self.openalias_data.get('name') + recipient = self.openalias + ' <' + address + '>' + validated = self.openalias_data.get('validated') + if not validated: + self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(self.openalias) + #self.payto_e.set_openalias(key=pi.openalias, data=oa_data) + #self.window.contact_list.update() + + elif self.bolt11: + recipient, amount, description = self.get_bolt11_fields(self.bolt11) + + elif self.lnurl and self.lnurl_data: + domain = urlparse(self.lnurl).netloc + #recipient = "invoice from lnurl" + recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>" + #amount = self.lnurl_data.min_sendable_sat + amount = None + description = None + if self.lnurl_data.comment_allowed: + comment = None + + elif self.bip70 and self.bip70_data: + pr = self.bip70_data + if pr.error: + self.error = pr.error + return + recipient = pr.get_requestor() + amount = pr.get_amount() + description = pr.get_memo() + validated = not pr.has_expired() + #self.set_onchain(True) + #self.max_button.setEnabled(False) + # note: allow saving bip70 reqs, as we save them anyway when paying them + #for btn in [self.send_button, self.clear_button, self.save_button]: + # btn.setEnabled(True) + # signal to set fee + #self.amount_e.textEdited.emit("") + + elif self.spk: + recipient = self.text + amount = None + + elif self.multiline_outputs: + pass + + elif self.bip21: + recipient = self.bip21.get('address') + amount = self.bip21.get('amount') + label = self.bip21.get('label') + description = self.bip21.get('message') + # use label as description (not BIP21 compliant) + if label and not description: + description = label + lightning = self.bip21.get('lightning') + if lightning and wallet.has_lightning(): + # maybe set self.bolt11? + recipient, amount, description = self.get_bolt11_fields(lightning) + if not amount: + amount_required = True + # todo: merge logic + + return recipient, amount, description, comment, validated + + def get_bolt11_fields(self, bolt11_invoice): + """Parse ln invoice, and prepare the send tab for it.""" + try: + lnaddr = lndecode(bolt11_invoice) + except LnInvoiceException as e: + self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") + return + except IncompatibleOrInsaneFeatures as e: + self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") + return + pubkey = lnaddr.pubkey.serialize().hex() + for k,v in lnaddr.tags: + if k == 'd': + description = v + break + else: + description = '' + amount = lnaddr.get_amount_sat() + return pubkey, amount, description + + async def resolve_openalias(self) -> Optional[dict]: + key = self.openalias + if not (('.' in key) and ('<' not in key) and (' ' not in key)): + return None + parts = key.split(sep=',') # assuming single line + if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): + return None + try: + data = self.contacts.resolve(key) + except Exception as e: + self.logger.info(f'error resolving address/alias: {repr(e)}') + return None + if data: + name = data.get('name') + address = data.get('address') + self.contacts[key] = ('openalias', name) + # this will set self.spk + self.parse(address) + return data + + def has_expired(self): + if self.bip70: + return self.bip70_data.has_expired() + return False + + @log_exceptions + async def round_1(self, on_success): + if self.openalias: + data = await self.resolve_openalias() + self.openalias_data = data + if not self.openalias_data.get('validated'): + self.warning = _( + 'WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(self.openalias) + elif self.bip70: + from . import paymentrequest + data = await paymentrequest.get_payment_request(self.bip70) + self.bip70_data = data + elif self.lnurl: + data = await request_lnurl(self.lnurl) + self.lnurl_data = data + else: + return + on_success(self) + + @log_exceptions + async def round_2(self, on_success, amount_sat:int=None, comment=None): + from .invoices import Invoice + if self.lnurl: + if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): + self.error = f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.' + 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, + ) + except LNURLError as e: + self.error = f"LNURL request encountered error: {e}" + 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") + # this will change what is returned by get_fields_for_GUI + self.bolt11 = bolt11_invoice + + on_success(self) + + @log_exceptions + async def round_3(self, tx, refund_address, *, on_success): + if self.bip70: + ack_status, ack_msg = await self.bip70.send_payment_and_receive_paymentack(tx.serialize(), refund_address) + self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") + on_success(self) + + def get_invoice(self, wallet, amount_sat, message): + # fixme: wallet not really needed, only height + from .invoices import Invoice + if self.is_lightning(): + invoice_str = self.bolt11 + if not invoice_str: + return + invoice = Invoice.from_bech32(invoice_str) + if invoice.amount_msat is None: + invoice.amount_msat = int(amount_sat * 1000) + return invoice + else: + outputs = self.get_onchain_outputs(amount_sat) + message = self.bip21.get('message') if self.bip21 else message + bip70_data = self.bip70_data if self.bip70 else None + return wallet.create_invoice( + outputs=outputs, + message=message, + pr=bip70_data, + URI=self.bip21) diff --git a/electrum/transaction.py b/electrum/transaction.py index 248642905..09fdf7ce4 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -42,7 +42,8 @@ import copy from . import ecc, bitcoin, constants, segwit_addr, bip32 from .bip32 import BIP32Node -from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend +from .util import profiler, to_bytes, bfh, chunks, is_hex_str +from .payment_identifier import parse_max_spend from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, diff --git a/electrum/util.py b/electrum/util.py index c31ba5711..e0da5441a 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1009,177 +1009,8 @@ def block_explorer_URL(config: 'SimpleConfig', kind: str, item: str) -> Optional url_parts = [explorer_url, kind_str, item] return ''.join(url_parts) -# 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' -LIGHTNING_URI_SCHEME = 'lightning' - - -class InvalidBitcoinURI(Exception): pass - - -# TODO rename to parse_bip21_uri or similar -def parse_URI( - uri: str, - on_pr: Callable[['PaymentRequest'], None] = None, - *, - loop: asyncio.AbstractEventLoop = None, -) -> dict: - """Raises InvalidBitcoinURI on malformed URI.""" - from . import bitcoin - from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC - from .lnaddr import lndecode, LnDecodeException - - if not isinstance(uri, str): - raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") - - if ':' not in uri: - if not bitcoin.is_address(uri): - raise InvalidBitcoinURI("Not a bitcoin address") - return {'address': uri} - - u = urllib.parse.urlparse(uri) - if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: - raise InvalidBitcoinURI("Not a bitcoin URI") - address = u.path - - # python for android fails to parse query - if address.find('?') > 0: - address, query = u.path.split('?') - pq = urllib.parse.parse_qs(query) - else: - pq = urllib.parse.parse_qs(u.query) - - for k, v in pq.items(): - if len(v) != 1: - raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') - - out = {k: v[0] for k, v in pq.items()} - if address: - if not bitcoin.is_address(address): - raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") - out['address'] = address - if 'amount' in out: - am = out['amount'] - try: - m = re.match(r'([0-9.]+)X([0-9])', am) - if m: - k = int(m.group(2)) - 8 - amount = Decimal(m.group(1)) * pow(Decimal(10), k) - else: - amount = Decimal(am) * COIN - if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: - raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") - out['amount'] = int(amount) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e - if 'message' in out: - out['message'] = out['message'] - out['memo'] = out['message'] - if 'time' in out: - try: - out['time'] = int(out['time']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e - if 'exp' in out: - try: - out['exp'] = int(out['exp']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e - if 'sig' in out: - try: - out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e - if 'lightning' in out: - try: - lnaddr = lndecode(out['lightning']) - except LnDecodeException as e: - raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e - amount_sat = out.get('amount') - if amount_sat: - # allow small leeway due to msat precision - if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") - address = out.get('address') - ln_fallback_addr = lnaddr.get_fallback_address() - if address and ln_fallback_addr: - if ln_fallback_addr != address: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") - - r = out.get('r') - sig = out.get('sig') - name = out.get('name') - if on_pr and (r or (name and sig)): - @log_exceptions - async def get_payment_request(): - from . import paymentrequest as pr - if name and sig: - s = pr.serialize_request(out).SerializeToString() - request = pr.PaymentRequest(s) - else: - request = await pr.get_payment_request(r) - if on_pr: - on_pr(request) - loop = loop or get_asyncio_loop() - asyncio.run_coroutine_threadsafe(get_payment_request(), loop) - - return out - - -def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], - *, extra_query_params: Optional[dict] = None) -> str: - from . import bitcoin - if not bitcoin.is_address(addr): - return "" - if extra_query_params is None: - extra_query_params = {} - query = [] - if amount_sat: - query.append('amount=%s'%format_satoshis_plain(amount_sat)) - if message: - query.append('message=%s'%urllib.parse.quote(message)) - for k, v in extra_query_params.items(): - if not isinstance(k, str) or k != urllib.parse.quote(k): - raise Exception(f"illegal key for URI: {repr(k)}") - v = urllib.parse.quote(v) - query.append(f"{k}={v}") - p = urllib.parse.ParseResult( - scheme=BITCOIN_BIP21_URI_SCHEME, - netloc='', - path=addr, - params='', - query='&'.join(query), - fragment='', - ) - return str(urllib.parse.urlunparse(p)) - - -def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: - data = data.strip() # whitespaces - data = data.lower() - if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): - cut_prefix = LIGHTNING_URI_SCHEME + ':' - data = data[len(cut_prefix):] - if data.startswith('ln'): - return data - return None - - -def is_uri(data: str) -> bool: - data = data.lower() - if (data.startswith(LIGHTNING_URI_SCHEME + ":") or - data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): - return True - return False - - -class FailedToParsePaymentIdentifier(Exception): - pass # Python bug (http://bugs.python.org/issue1927) causes raw_input diff --git a/electrum/wallet.py b/electrum/wallet.py index 6d17e755d..4376d8f77 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -57,7 +57,8 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates, WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, - Fiat, bfh, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex, parse_max_spend) + Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex) +from .payment_identifier import create_bip21_uri, parse_max_spend from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold diff --git a/electrum/x509.py b/electrum/x509.py index 68cf92b94..f0da646f6 100644 --- a/electrum/x509.py +++ b/electrum/x509.py @@ -308,7 +308,8 @@ class X509(object): raise CertificateError('Certificate has not entered its valid date range. (%s)' % self.get_common_name()) if self.notAfter <= now: dt = datetime.utcfromtimestamp(time.mktime(self.notAfter)) - raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') + # for testnet + #raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') def getFingerprint(self): return hashlib.sha1(self.bytes).digest() diff --git a/run_electrum b/run_electrum index d388de169..a0c95c728 100755 --- a/run_electrum +++ b/run_electrum @@ -93,6 +93,7 @@ sys._ELECTRUM_RUNNING_VIA_RUNELECTRUM = True # used by logging.py from electrum.logging import get_logger, configure_logging # import logging submodule first from electrum import util +from electrum.payment_identifier import PaymentIdentifier from electrum import constants from electrum import SimpleConfig from electrum.wallet_db import WalletDB @@ -364,9 +365,9 @@ def main(): if not config_options.get('verbosity'): warnings.simplefilter('ignore', DeprecationWarning) - # check uri + # check if we received a valid payment identifier uri = config_options.get('url') - if uri and not util.is_uri(uri): + if uri and not PaymentIdentifier(None, None, uri).is_valid(): print_stderr('unknown command:', uri) sys.exit(1) From 1e725b6baa17671c9561c9f3cdf36cd9559c3964 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 31 May 2023 16:30:00 +0200 Subject: [PATCH 02/33] break the cyclic dependency --- electrum/blockchain.py | 7 ++++--- electrum/network.py | 6 ++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/electrum/blockchain.py b/electrum/blockchain.py index 4cca1d282..2dd4982c1 100644 --- a/electrum/blockchain.py +++ b/electrum/blockchain.py @@ -23,16 +23,17 @@ import os import threading import time -from typing import Optional, Dict, Mapping, Sequence +from typing import Optional, Dict, Mapping, Sequence, TYPE_CHECKING from . import util from .bitcoin import hash_encode, int_to_hex, rev_hex from .crypto import sha256d from . import constants from .util import bfh, with_lock -from .simple_config import SimpleConfig from .logging import get_logger, Logger +if TYPE_CHECKING: + from .simple_config import SimpleConfig _logger = get_logger(__name__) @@ -181,7 +182,7 @@ class Blockchain(Logger): Manages blockchain headers and their verification """ - def __init__(self, config: SimpleConfig, forkpoint: int, parent: Optional['Blockchain'], + def __init__(self, config: 'SimpleConfig', forkpoint: int, parent: Optional['Blockchain'], forkpoint_hash: str, prev_hash: Optional[str]): assert isinstance(forkpoint_hash, str) and len(forkpoint_hash) == 64, forkpoint_hash assert (prev_hash is None) or (isinstance(prev_hash, str) and len(prev_hash) == 64), prev_hash diff --git a/electrum/network.py b/electrum/network.py index ab08adb49..47da82b69 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -59,7 +59,6 @@ from .interface import (Interface, PREFERRED_NETWORK_PROTOCOL, RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS, NetworkException, RequestCorrupted, ServerAddr) from .version import PROTOCOL_VERSION -from .simple_config import SimpleConfig from .i18n import _ from .logging import get_logger, Logger @@ -71,6 +70,7 @@ if TYPE_CHECKING: from .lnworker import LNGossip from .lnwatcher import WatchTower from .daemon import Daemon + from .simple_config import SimpleConfig _logger = get_logger(__name__) @@ -270,7 +270,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): local_watchtower: Optional['WatchTower'] = None path_finder: Optional['LNPathFinder'] = None - def __init__(self, config: SimpleConfig, *, daemon: 'Daemon' = None): + def __init__(self, config: 'SimpleConfig', *, daemon: 'Daemon' = None): global _INSTANCE assert _INSTANCE is None, "Network is a singleton!" _INSTANCE = self @@ -287,9 +287,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): self.asyncio_loop = util.get_asyncio_loop() assert self.asyncio_loop.is_running(), "event loop not running" - assert isinstance(config, SimpleConfig), f"config should be a SimpleConfig instead of {type(config)}" self.config = config - self.daemon = daemon blockchain.read_blockchains(self.config) From cbd388c2972cbfcbc004490e42306692e9889120 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 31 May 2023 16:42:15 +0200 Subject: [PATCH 03/33] fix flake8 issues (undefined references) --- electrum/gui/qt/send_tab.py | 10 ++++++---- electrum/payment_identifier.py | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index c07851daf..bde4847f2 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -399,6 +399,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def get_message(self): return self.message_e.text() + def read_invoice(self) -> Optional[Invoice]: if self.check_payto_line_and_show_errors(): return @@ -410,7 +411,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message()) #except Exception as e: if not invoice: - self.show_error('error getting invoice' + pi.error) + self.show_error('error getting invoice' + self.payment_identifier.error) return if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): self.show_error(_('Lightning is disabled')) @@ -533,9 +534,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # for err in errors])) return True - if self.payment_identifier.warning: - msg += '\n' + _('Do you wish to continue?') - if not self.question(msg): + warning = self.payment_identifier.warning + if warning: + warning += '\n' + _('Do you wish to continue?') + if not self.question(warning): return True if self.payment_identifier.has_expired(): diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index cbf1a6461..9172248d0 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -1,17 +1,18 @@ import asyncio import urllib import re -from decimal import Decimal +from decimal import Decimal, InvalidOperation from typing import NamedTuple, Optional, Callable, Any, Sequence from urllib.parse import urlparse from . import bitcoin +from .i18n import _ from .logging import Logger from .util import parse_max_spend, format_satoshis_plain from .util import get_asyncio_loop, log_exceptions from .transaction import PartialTxOutput from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data -from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC +from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures @@ -351,7 +352,7 @@ class PaymentIdentifier(Logger): p = pow(10, self.config.get_decimal_point()) try: return int(p * Decimal(x)) - except decimal.InvalidOperation: + except InvalidOperation: raise Exception("Invalid amount") def parse_address(self, line): From ac341d956537f658383beb009c0f59626b7b3d00 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 31 May 2023 17:26:40 +0200 Subject: [PATCH 04/33] whitespace, code style --- electrum/payment_identifier.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 9172248d0..dce52282a 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -2,7 +2,7 @@ import asyncio import urllib import re from decimal import Decimal, InvalidOperation -from typing import NamedTuple, Optional, Callable, Any, Sequence +from typing import NamedTuple, Optional, Callable, Any, Sequence, List from urllib.parse import urlparse from . import bitcoin @@ -16,6 +16,7 @@ from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_sc from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures + def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: data = data.strip() # whitespaces data = data.lower() @@ -36,7 +37,8 @@ BITCOIN_BIP21_URI_SCHEME = 'bitcoin' LIGHTNING_URI_SCHEME = 'lightning' -class InvalidBitcoinURI(Exception): pass +class InvalidBitcoinURI(Exception): + pass def parse_bip21_URI(uri: str) -> dict: @@ -122,7 +124,6 @@ def parse_bip21_URI(uri: str) -> dict: return out - def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], *, extra_query_params: Optional[dict] = None) -> str: if not bitcoin.is_address(addr): @@ -131,9 +132,9 @@ def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], extra_query_params = {} query = [] if amount_sat: - query.append('amount=%s'%format_satoshis_plain(amount_sat)) + query.append('amount=%s' % format_satoshis_plain(amount_sat)) if message: - query.append('message=%s'%urllib.parse.quote(message)) + query.append('message=%s' % urllib.parse.quote(message)) for k, v in extra_query_params.items(): if not isinstance(k, str) or k != urllib.parse.quote(k): raise Exception(f"illegal key for URI: {repr(k)}") @@ -145,12 +146,11 @@ def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], path=addr, params='', query='&'.join(query), - fragment='', + fragment='' ) return str(urllib.parse.urlunparse(p)) - def is_uri(data: str) -> bool: data = data.lower() if (data.startswith(LIGHTNING_URI_SCHEME + ":") or @@ -159,19 +159,21 @@ def is_uri(data: str) -> bool: return False - class FailedToParsePaymentIdentifier(Exception): pass + class PayToLineError(NamedTuple): line_content: str exc: Exception idx: int = 0 # index of line is_multiline: bool = False + RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' + class PaymentIdentifier(Logger): """ Takes: @@ -235,7 +237,7 @@ class PaymentIdentifier(Logger): text = text.strip() if not text: return - if outputs:= self._parse_as_multiline(text): + if outputs := self._parse_as_multiline(text): self._type = 'multiline' self.multiline_outputs = outputs elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): @@ -286,7 +288,7 @@ class PaymentIdentifier(Logger): # filter out empty lines lines = text.split('\n') lines = [i for i in lines if i] - is_multiline = len(lines)>1 + is_multiline = len(lines) > 1 outputs = [] # type: List[PartialTxOutput] errors = [] total = 0 @@ -357,7 +359,7 @@ class PaymentIdentifier(Logger): def parse_address(self, line): r = line.strip() - m = re.match('^'+RE_ALIAS+'$', r) + m = re.match('^' + RE_ALIAS + '$', r) address = str(m.group(2) if m else r) assert bitcoin.is_address(address) return address @@ -447,12 +449,12 @@ class PaymentIdentifier(Logger): self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") return pubkey = lnaddr.pubkey.serialize().hex() - for k,v in lnaddr.tags: + for k, v in lnaddr.tags: if k == 'd': description = v break else: - description = '' + description = '' amount = lnaddr.get_amount_sat() return pubkey, amount, description @@ -502,7 +504,7 @@ class PaymentIdentifier(Logger): on_success(self) @log_exceptions - async def round_2(self, on_success, amount_sat:int=None, comment=None): + async def round_2(self, on_success, amount_sat: int = None, comment: str = None): from .invoices import Invoice if self.lnurl: if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): @@ -510,7 +512,7 @@ class PaymentIdentifier(Logger): return if self.lnurl_data.comment_allowed == 0: comment = None - params = {'amount': amount_sat * 1000 } + params = {'amount': amount_sat * 1000} if comment: params['comment'] = comment try: From a2ca191de17a5aa19a32f0515faf789f544682d1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 31 May 2023 20:54:30 +0200 Subject: [PATCH 05/33] pass wallet to PaymentIdentifier instead of config and contacts --- electrum/gui/qt/paytoedit.py | 4 ++-- electrum/gui/qt/send_tab.py | 8 ++++---- electrum/payment_identifier.py | 24 ++++++++++++++---------- run_electrum | 2 +- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 5076b5292..18ac029b7 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -213,9 +213,9 @@ class PayToEdit(Logger, GenericInputHandler): self.previous_payto = text if self.disable_checks: return - pi = PaymentIdentifier(self.config, self.win.contacts, text) + pi = PaymentIdentifier(self.send_tab.wallet, text) self.is_multiline = bool(pi.multiline_outputs) - print('is_multiline', self.is_multiline) + self.logger.debug(f'is_multiline {self.is_multiline}') self.send_tab.handle_payment_identifier(pi, can_use_network=full_check) def handle_multiline(self, outputs): diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index bde4847f2..7473884ad 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -194,7 +194,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.set_payment_identifier(text) def set_payment_identifier(self, text): - pi = PaymentIdentifier(self.config, self.window.contacts, text) + pi = PaymentIdentifier(self.wallet, text) if pi.error: self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error) return @@ -356,7 +356,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): w.setReadOnly(False) def update_fields(self, pi): - recipient, amount, description, comment, validated = pi.get_fields_for_GUI(self.wallet) + recipient, amount, description, comment, validated = pi.get_fields_for_GUI() if recipient: self.payto_e.setTextNoCheck(recipient) elif pi.multiline_outputs: @@ -408,7 +408,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.show_error(_('No amount')) return - invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message()) + invoice = self.payment_identifier.get_invoice(amount_sat, self.get_message()) #except Exception as e: if not invoice: self.show_error('error getting invoice' + self.payment_identifier.error) @@ -449,7 +449,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.do_clear() return self.update_fields(pi) - invoice = pi.get_invoice(self.wallet, self.get_amount(), self.get_message()) + invoice = pi.get_invoice(self.get_amount(), self.get_message()) self.pending_invoice = invoice self.do_pay_invoice(invoice) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index dce52282a..d1b69f66b 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -2,7 +2,7 @@ import asyncio import urllib import re from decimal import Decimal, InvalidOperation -from typing import NamedTuple, Optional, Callable, Any, Sequence, List +from typing import NamedTuple, Optional, Callable, Any, Sequence, List, TYPE_CHECKING from urllib.parse import urlparse from . import bitcoin @@ -16,6 +16,9 @@ from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_sc from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures +if TYPE_CHECKING: + from .wallet import Abstract_Wallet + def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: data = data.strip() # whitespaces @@ -184,12 +187,14 @@ class PaymentIdentifier(Logger): * lightning-URI (containing bolt11 or lnurl) * bolt11 invoice * lnurl + * TODO: lightning address """ - def __init__(self, config, contacts, text): + def __init__(self, wallet: 'Abstract_Wallet', text): Logger.__init__(self) - self.contacts = contacts - self.config = config + self.wallet = wallet + self.contacts = wallet.contacts if wallet is not None else None + self.config = wallet.config if wallet is not None else None self.text = text self._type = None self.error = None # if set, GUI should show error and stop @@ -307,7 +312,7 @@ class PaymentIdentifier(Logger): total += output.value if is_multiline and errors: self.error = str(errors) if errors else None - print(outputs, self.error) + self.logger.debug(f'multiline: {outputs!r}, {self.error}') return outputs def parse_address_and_amount(self, line) -> 'PartialTxOutput': @@ -364,7 +369,7 @@ class PaymentIdentifier(Logger): assert bitcoin.is_address(address) return address - def get_fields_for_GUI(self, wallet): + def get_fields_for_GUI(self): """ sets self.error as side effect""" recipient = None amount = None @@ -429,7 +434,7 @@ class PaymentIdentifier(Logger): if label and not description: description = label lightning = self.bip21.get('lightning') - if lightning and wallet.has_lightning(): + if lightning and self.wallet.has_lightning(): # maybe set self.bolt11? recipient, amount, description = self.get_bolt11_fields(lightning) if not amount: @@ -540,8 +545,7 @@ class PaymentIdentifier(Logger): self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") on_success(self) - def get_invoice(self, wallet, amount_sat, message): - # fixme: wallet not really needed, only height + def get_invoice(self, amount_sat, message): from .invoices import Invoice if self.is_lightning(): invoice_str = self.bolt11 @@ -555,7 +559,7 @@ class PaymentIdentifier(Logger): outputs = self.get_onchain_outputs(amount_sat) message = self.bip21.get('message') if self.bip21 else message bip70_data = self.bip70_data if self.bip70 else None - return wallet.create_invoice( + return self.wallet.create_invoice( outputs=outputs, message=message, pr=bip70_data, diff --git a/run_electrum b/run_electrum index a0c95c728..46aca8a67 100755 --- a/run_electrum +++ b/run_electrum @@ -367,7 +367,7 @@ def main(): # check if we received a valid payment identifier uri = config_options.get('url') - if uri and not PaymentIdentifier(None, None, uri).is_valid(): + if uri and not PaymentIdentifier(None, uri).is_valid(): print_stderr('unknown command:', uri) sys.exit(1) From 3000b83ab5a226d56578f20d443c0a8cf8ac682e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 1 Jun 2023 14:51:38 +0200 Subject: [PATCH 06/33] contacts: use specific Exception when alias not found --- electrum/contacts.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electrum/contacts.py b/electrum/contacts.py index cc7906554..fd941bfc8 100644 --- a/electrum/contacts.py +++ b/electrum/contacts.py @@ -33,6 +33,11 @@ from .util import read_json_file, write_json_file, to_string from .logging import Logger from .util import trigger_callback + +class AliasNotFoundException(Exception): + pass + + class Contacts(dict, Logger): def __init__(self, db): @@ -94,7 +99,7 @@ class Contacts(dict, Logger): 'type': 'openalias', 'validated': validated } - raise Exception("Invalid Bitcoin address or alias", k) + raise AliasNotFoundException("Invalid Bitcoin address or alias", k) def fetch_openalias(self, config): self.alias_info = None From 508d1038d31d7392e9feb25bca0d78664dc3a4c3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 1 Jun 2023 14:53:44 +0200 Subject: [PATCH 07/33] payment_identifier: define states, refactor round_1 into resolve stage --- electrum/gui/qt/send_tab.py | 44 +++++------ electrum/payment_identifier.py | 138 ++++++++++++++++++++++++--------- 2 files changed, 121 insertions(+), 61 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 7473884ad..a7e4da776 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -33,7 +33,7 @@ if TYPE_CHECKING: class SendTab(QWidget, MessageBoxMixin, Logger): - round_1_signal = pyqtSignal(object) + resolve_done_signal = pyqtSignal(object) round_2_signal = pyqtSignal(object) round_3_signal = pyqtSignal(object) @@ -183,7 +183,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.invoice_list.update() # after parented and put into a layout, can update without flickering run_hook('create_send_tab', grid) - self.round_1_signal.connect(self.on_round_1) + self.resolve_done_signal.connect(self.on_resolve_done) self.round_2_signal.connect(self.on_round_2) self.round_3_signal.connect(self.on_round_3) @@ -194,16 +194,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.set_payment_identifier(text) def set_payment_identifier(self, text): - pi = PaymentIdentifier(self.wallet, text) - if pi.error: - self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error) + self.payment_identifier = PaymentIdentifier(self.wallet, text) + if self.payment_identifier.error: + self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error) return - if pi.is_multiline(): + if self.payment_identifier.is_multiline(): self.payto_e.set_paytomany(True) self.payto_e.text_edit.setText(text) else: self.payto_e.setTextNoCheck(text) - self.handle_payment_identifier(pi, can_use_network=True) + self.handle_payment_identifier(can_use_network=True) def spend_max(self): if run_hook('abort_send', self): @@ -355,45 +355,43 @@ class SendTab(QWidget, MessageBoxMixin, Logger): w.setStyleSheet('') w.setReadOnly(False) - def update_fields(self, pi): - recipient, amount, description, comment, validated = pi.get_fields_for_GUI() + def update_fields(self): + recipient, amount, description, comment, validated = self.payment_identifier.get_fields_for_GUI() if recipient: self.payto_e.setTextNoCheck(recipient) - elif pi.multiline_outputs: - self.payto_e.handle_multiline(pi.multiline_outputs) + elif self.payment_identifier.multiline_outputs: + self.payto_e.handle_multiline(self.payment_identifier.multiline_outputs) if description: self.message_e.setText(description) if amount: self.amount_e.setAmount(amount) for w in [self.comment_e, self.comment_label]: w.setVisible(not bool(comment)) - self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated) + self.set_field_style(self.payto_e, recipient or self.payment_identifier.multiline_outputs, validated) self.set_field_style(self.message_e, description, validated) self.set_field_style(self.amount_e, amount, validated) self.set_field_style(self.fiat_send_e, amount, validated) - def handle_payment_identifier(self, pi, *, can_use_network: bool = True): - self.payment_identifier = pi - is_valid = pi.is_valid() + def handle_payment_identifier(self, *, can_use_network: bool = True): + is_valid = self.payment_identifier.is_valid() self.save_button.setEnabled(is_valid) self.send_button.setEnabled(is_valid) if not is_valid: return - self.update_fields(pi) - if can_use_network and pi.needs_round_1(): - coro = pi.round_1(on_success=self.round_1_signal.emit) - asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + self.update_fields() + if self.payment_identifier.need_resolve(): self.prepare_for_send_tab_network_lookup() + self.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit) # update fiat amount self.amount_e.textEdited.emit("") self.window.show_send_tab() - def on_round_1(self, pi): - if pi.error: - self.show_error(pi.error) + def on_resolve_done(self, pi): + if self.payment_identifier.error: + self.show_error(self.payment_identifier.error) self.do_clear() return - self.update_fields(pi) + self.update_fields() for btn in [self.send_button, self.clear_button, self.save_button]: btn.setEnabled(True) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index d1b69f66b..1f42b107a 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -2,16 +2,18 @@ import asyncio import urllib import re from decimal import Decimal, InvalidOperation +from enum import IntEnum from typing import NamedTuple, Optional, Callable, Any, Sequence, List, TYPE_CHECKING from urllib.parse import urlparse from . import bitcoin +from .contacts import AliasNotFoundException from .i18n import _ from .logging import Logger from .util import parse_max_spend, format_satoshis_plain from .util import get_asyncio_loop, log_exceptions from .transaction import PartialTxOutput -from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data +from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data, lightning_address_to_url from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures @@ -177,6 +179,19 @@ RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' +class PaymentIdentifierState(IntEnum): + EMPTY = 0 # Initial state. + INVALID = 1 # Unrecognized PI + AVAILABLE = 2 # PI contains a payable destination + # payable means there's enough addressing information to submit to one + # of the channels Electrum supports (on-chain, lightning) + NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step + LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11 + BIP70_VIA = 5 # PI contains a valid payment request that should have the tx submitted through bip70 gw + ERROR = 50 # generic error + NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccesful + + class PaymentIdentifier(Logger): """ Takes: @@ -187,11 +202,12 @@ class PaymentIdentifier(Logger): * lightning-URI (containing bolt11 or lnurl) * bolt11 invoice * lnurl - * TODO: lightning address + * lightning address """ def __init__(self, wallet: 'Abstract_Wallet', text): Logger.__init__(self) + self._state = PaymentIdentifierState.EMPTY self.wallet = wallet self.contacts = wallet.contacts if wallet is not None else None self.config = wallet.config if wallet is not None else None @@ -205,8 +221,9 @@ class PaymentIdentifier(Logger): self.bip21 = None self.spk = None # - self.openalias = None + self.emaillike = None self.openalias_data = None + self.lnaddress_data = None # self.bip70 = None self.bip70_data = None @@ -214,8 +231,16 @@ class PaymentIdentifier(Logger): self.lnurl = None self.lnurl_data = None # parse without network + self.logger.debug(f'PI parsing...') self.parse(text) + def set_state(self, state: 'PaymentIdentifierState'): + self.logger.debug(f'PI state -> {state}') + self._state = state + + def need_resolve(self): + return self._state == PaymentIdentifierState.NEED_RESOLVE + def is_valid(self): return bool(self._type) @@ -228,9 +253,6 @@ class PaymentIdentifier(Logger): def get_error(self) -> str: return self.error - def needs_round_1(self): - return self.bip70 or self.openalias or self.lnurl - def needs_round_2(self): return self.lnurl and self.lnurl_data @@ -250,30 +272,93 @@ class PaymentIdentifier(Logger): self._type = 'lnurl' try: self.lnurl = decode_lnurl(invoice_or_lnurl) + self.set_state(PaymentIdentifierState.NEED_RESOLVE) except Exception as e: self.error = "Error parsing Lightning invoice" + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) return else: self._type = 'bolt11' self.bolt11 = invoice_or_lnurl + self.set_state(PaymentIdentifierState.AVAILABLE) elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): try: out = parse_bip21_URI(text) except InvalidBitcoinURI as e: self.error = _("Error parsing URI") + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) return self._type = 'bip21' self.bip21 = out self.bip70 = out.get('r') + if self.bip70: + self.set_state(PaymentIdentifierState.NEED_RESOLVE) + else: + self.set_state(PaymentIdentifierState.AVAILABLE) elif scriptpubkey := self.parse_output(text): self._type = 'spk' self.spk = scriptpubkey + self.set_state(PaymentIdentifierState.AVAILABLE) elif re.match(RE_EMAIL, text): self._type = 'alias' - self.openalias = text + self.emaillike = text + self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif self.error is None: truncated_text = f"{text[:100]}..." if len(text) > 100 else text self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_text}") + self.set_state(PaymentIdentifierState.INVALID) + + def resolve(self, *, on_finished: 'Callable'): + assert self._state == PaymentIdentifierState.NEED_RESOLVE + coro = self.do_resolve(on_finished=on_finished) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + + @log_exceptions + async def do_resolve(self, *, on_finished=None): + try: + if self.emaillike: + data = await self.resolve_openalias() + if data: + self.openalias_data = data # needed? + self.logger.debug(f'OA: {data!r}') + name = data.get('name') + address = data.get('address') + self.contacts[self.emaillike] = ('openalias', name) + if not data.get('validated'): + self.warning = _( + 'WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) + # this will set self.spk and update state + self.parse(address) + else: + lnurl = lightning_address_to_url(self.emaillike) + try: + data = await request_lnurl(lnurl) + self.lnurl = lnurl + self.lnurl_data = data + self.set_state(PaymentIdentifierState.LNURLP_FINALIZE) + except LNURLError as e: + self.error = str(e) + self.set_state(PaymentIdentifierState.NOT_FOUND) + elif self.bip70: + from . import paymentrequest + data = await paymentrequest.get_payment_request(self.bip70) + self.bip70_data = data + self.set_state(PaymentIdentifierState.BIP70_VIA) + elif self.lnurl: + data = await request_lnurl(self.lnurl) + self.lnurl_data = data + self.set_state(PaymentIdentifierState.LNURLP_FINALIZE) + else: + self.set_state(PaymentIdentifierState.ERROR) + return + except Exception as e: + self.error = f'{e!r}' + self.logger.error(self.error) + self.set_state(PaymentIdentifierState.ERROR) + finally: + if on_finished: + on_finished(self) def get_onchain_outputs(self, amount): if self.bip70: @@ -377,14 +462,14 @@ class PaymentIdentifier(Logger): validated = None comment = "no comment" - if self.openalias and self.openalias_data: + if self.emaillike and self.openalias_data: address = self.openalias_data.get('address') name = self.openalias_data.get('name') - recipient = self.openalias + ' <' + address + '>' + recipient = self.emaillike + ' <' + address + '>' validated = self.openalias_data.get('validated') if not validated: self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' - 'security check, DNSSEC, and thus may not be correct.').format(self.openalias) + 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) #self.payto_e.set_openalias(key=pi.openalias, data=oa_data) #self.window.contact_list.update() @@ -464,7 +549,7 @@ class PaymentIdentifier(Logger): return pubkey, amount, description async def resolve_openalias(self) -> Optional[dict]: - key = self.openalias + key = self.emaillike if not (('.' in key) and ('<' not in key) and (' ' not in key)): return None parts = key.split(sep=',') # assuming single line @@ -472,42 +557,19 @@ class PaymentIdentifier(Logger): return None try: data = self.contacts.resolve(key) + return data + except AliasNotFoundException as e: + self.logger.info(f'OpenAlias not found: {repr(e)}') + return None except Exception as e: self.logger.info(f'error resolving address/alias: {repr(e)}') return None - if data: - name = data.get('name') - address = data.get('address') - self.contacts[key] = ('openalias', name) - # this will set self.spk - self.parse(address) - return data def has_expired(self): if self.bip70: return self.bip70_data.has_expired() return False - @log_exceptions - async def round_1(self, on_success): - if self.openalias: - data = await self.resolve_openalias() - self.openalias_data = data - if not self.openalias_data.get('validated'): - self.warning = _( - 'WARNING: the alias "{}" could not be validated via an additional ' - 'security check, DNSSEC, and thus may not be correct.').format(self.openalias) - elif self.bip70: - from . import paymentrequest - data = await paymentrequest.get_payment_request(self.bip70) - self.bip70_data = data - elif self.lnurl: - data = await request_lnurl(self.lnurl) - self.lnurl_data = data - else: - return - on_success(self) - @log_exceptions async def round_2(self, on_success, amount_sat: int = None, comment: str = None): from .invoices import Invoice From 7601726d2921bdde34ad3b1619d49c9de576a1f1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 1 Jun 2023 18:16:42 +0200 Subject: [PATCH 08/33] payment_identifier: refactor round_2 to need_finalize/finalize stage --- electrum/gui/qt/main_window.py | 22 ++++--- electrum/gui/qt/paytoedit.py | 20 +++---- electrum/gui/qt/send_tab.py | 24 ++++---- electrum/payment_identifier.py | 101 ++++++++++++++++++--------------- 4 files changed, 86 insertions(+), 81 deletions(-) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 8bb6d9c1e..f4a357e8c 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -26,7 +26,6 @@ import sys import time import threading import os -import traceback import json import weakref import csv @@ -55,13 +54,10 @@ from electrum import (keystore, ecc, constants, util, bitcoin, commands, from electrum.bitcoin import COIN, is_address from electrum.plugin import run_hook, BasePlugin from electrum.i18n import _ -from electrum.util import (format_time, get_asyncio_loop, - UserCancelled, profiler, - bfh, InvalidPassword, - UserFacingException, - get_new_wallet_name, send_exception_to_crash_reporter, +from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword, + UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, AddTransactionException, os_chmod) -from electrum.payment_identifier import FailedToParsePaymentIdentifier, BITCOIN_BIP21_URI_SCHEME +from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, PaymentIdentifier from electrum.invoices import PR_PAID, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) @@ -1329,11 +1325,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): return None return clayout.selected_index() - def handle_payment_identifier(self, *args, **kwargs): - try: - self.send_tab.handle_payment_identifier(*args, **kwargs) - except FailedToParsePaymentIdentifier as e: - self.show_error(str(e)) + def handle_payment_identifier(self, text: str): + pi = PaymentIdentifier(self.wallet, text) + if pi.is_valid(): + self.send_tab.set_payment_identifier(text) + else: + if pi.error: + self.show_error(str(pi.error)) def set_frozen_state_of_addresses(self, addrs, freeze: bool): self.wallet.set_frozen_state_of_addresses(addrs, freeze) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 18ac029b7..0d5be55f0 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -23,22 +23,16 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import re -import decimal from functools import partial -from decimal import Decimal from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING from PyQt5.QtGui import QFontMetrics, QFont from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout -from electrum import bitcoin +from electrum.i18n import _ from electrum.util import parse_max_spend -from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier -from electrum.transaction import PartialTxOutput -from electrum.bitcoin import opcodes, construct_script +from electrum.payment_identifier import PaymentIdentifier from electrum.logging import Logger -from electrum.lnurl import LNURLError from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit @@ -214,9 +208,15 @@ class PayToEdit(Logger, GenericInputHandler): if self.disable_checks: return pi = PaymentIdentifier(self.send_tab.wallet, text) - self.is_multiline = bool(pi.multiline_outputs) + self.is_multiline = bool(pi.multiline_outputs) # TODO: why both is_multiline and set_paytomany(True)?? self.logger.debug(f'is_multiline {self.is_multiline}') - self.send_tab.handle_payment_identifier(pi, can_use_network=full_check) + if pi.is_valid(): + self.send_tab.set_payment_identifier(text) + else: + if not full_check and pi.error: + self.send_tab.show_error( + _('Clipboard text is not a valid payment identifier') + '\n' + str(pi.error)) + return def handle_multiline(self, outputs): total = 0 diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index a7e4da776..4c6e65451 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -4,21 +4,19 @@ import asyncio from decimal import Decimal -from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any +from typing import Optional, TYPE_CHECKING, Sequence, List, Callable from PyQt5.QtCore import pyqtSignal, QPoint from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) -from electrum import util, paymentrequest -from electrum import lnutil from electrum.plugin import run_hook from electrum.i18n import _ -from electrum.util import get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates, InvoiceError, parse_max_spend -from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier, InvalidBitcoinURI +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend +from electrum.payment_identifier import PaymentIdentifier from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST -from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput +from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.logging import Logger @@ -34,7 +32,7 @@ if TYPE_CHECKING: class SendTab(QWidget, MessageBoxMixin, Logger): resolve_done_signal = pyqtSignal(object) - round_2_signal = pyqtSignal(object) + finalize_done_signal = pyqtSignal(object) round_3_signal = pyqtSignal(object) def __init__(self, window: 'ElectrumWindow'): @@ -184,7 +182,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): run_hook('create_send_tab', grid) self.resolve_done_signal.connect(self.on_resolve_done) - self.round_2_signal.connect(self.on_round_2) + self.finalize_done_signal.connect(self.on_round_2) self.round_3_signal.connect(self.on_round_3) def do_paste(self): @@ -203,7 +201,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.payto_e.text_edit.setText(text) else: self.payto_e.setTextNoCheck(text) - self.handle_payment_identifier(can_use_network=True) + self._handle_payment_identifier(can_use_network=True) def spend_max(self): if run_hook('abort_send', self): @@ -372,7 +370,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.set_field_style(self.amount_e, amount, validated) self.set_field_style(self.fiat_send_e, amount, validated) - def handle_payment_identifier(self, *, can_use_network: bool = True): + def _handle_payment_identifier(self, *, can_use_network: bool = True): is_valid = self.payment_identifier.is_valid() self.save_button.setEnabled(is_valid) self.send_button.setEnabled(is_valid) @@ -456,10 +454,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def do_pay_or_get_invoice(self): pi = self.payment_identifier - if pi.needs_round_2(): - coro = pi.round_2(self.round_2_signal.emit, amount_sat=self.get_amount(), comment=self.message_e.text()) - asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) # TODO should be cancellable + if pi.need_finalize(): self.prepare_for_send_tab_network_lookup() + pi.finalize(amount_sat=self.get_amount(), comment=self.message_e.text(), + on_finished=self.finalize_done_signal.emit) return self.pending_invoice = self.read_invoice() if not self.pending_invoice: diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 1f42b107a..7374fea44 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -3,8 +3,7 @@ import urllib import re from decimal import Decimal, InvalidOperation from enum import IntEnum -from typing import NamedTuple, Optional, Callable, Any, Sequence, List, TYPE_CHECKING -from urllib.parse import urlparse +from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING from . import bitcoin from .contacts import AliasNotFoundException @@ -13,7 +12,7 @@ from .logging import Logger from .util import parse_max_spend, format_satoshis_plain from .util import get_asyncio_loop, log_exceptions from .transaction import PartialTxOutput -from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data, lightning_address_to_url +from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures @@ -164,10 +163,6 @@ def is_uri(data: str) -> bool: return False -class FailedToParsePaymentIdentifier(Exception): - pass - - class PayToLineError(NamedTuple): line_content: str exc: Exception @@ -223,7 +218,6 @@ class PaymentIdentifier(Logger): # self.emaillike = None self.openalias_data = None - self.lnaddress_data = None # self.bip70 = None self.bip70_data = None @@ -241,8 +235,11 @@ class PaymentIdentifier(Logger): def need_resolve(self): return self._state == PaymentIdentifierState.NEED_RESOLVE + def need_finalize(self): + return self._state == PaymentIdentifierState.LNURLP_FINALIZE + def is_valid(self): - return bool(self._type) + return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY] def is_lightning(self): return self.lnurl or self.bolt11 @@ -253,9 +250,6 @@ class PaymentIdentifier(Logger): def get_error(self) -> str: return self.error - def needs_round_2(self): - return self.lnurl and self.lnurl_data - def needs_round_3(self): return self.bip70 @@ -305,7 +299,7 @@ class PaymentIdentifier(Logger): self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif self.error is None: truncated_text = f"{text[:100]}..." if len(text) > 100 else text - self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_text}") + self.error = f"Unknown payment identifier:\n{truncated_text}" self.set_state(PaymentIdentifierState.INVALID) def resolve(self, *, on_finished: 'Callable'): @@ -353,8 +347,53 @@ class PaymentIdentifier(Logger): self.set_state(PaymentIdentifierState.ERROR) return except Exception as e: - self.error = f'{e!r}' - self.logger.error(self.error) + self.error = str(e) + self.logger.error(repr(e)) + self.set_state(PaymentIdentifierState.ERROR) + finally: + if on_finished: + on_finished(self) + + def finalize(self, *, amount_sat: int = 0, comment: str = None, on_finished: Callable = None): + assert self._state == PaymentIdentifierState.LNURLP_FINALIZE + coro = self.do_finalize(amount_sat, comment, on_finished=on_finished) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + + @log_exceptions + async def do_finalize(self, amount_sat: int = None, comment: str = None, on_finished: Callable = None): + from .invoices import Invoice + try: + if not self.lnurl_data: + raise Exception("Unexpected missing LNURL data") + + 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) + 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, + ) + except LNURLError as e: + self.error = f"LNURL request encountered error: {e}" + 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") + # this will change what is returned by get_fields_for_GUI + self.bolt11 = bolt11_invoice + self.set_state(PaymentIdentifierState.AVAILABLE) + except Exception as e: + self.error = str(e) + self.logger.error(repr(e)) self.set_state(PaymentIdentifierState.ERROR) finally: if on_finished: @@ -477,7 +516,7 @@ class PaymentIdentifier(Logger): recipient, amount, description = self.get_bolt11_fields(self.bolt11) elif self.lnurl and self.lnurl_data: - domain = urlparse(self.lnurl).netloc + domain = urllib.parse.urlparse(self.lnurl).netloc #recipient = "invoice from lnurl" recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>" #amount = self.lnurl_data.min_sendable_sat @@ -570,36 +609,6 @@ class PaymentIdentifier(Logger): return self.bip70_data.has_expired() return False - @log_exceptions - async def round_2(self, on_success, amount_sat: int = None, comment: str = None): - from .invoices import Invoice - if self.lnurl: - if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): - self.error = f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.' - 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, - ) - except LNURLError as e: - self.error = f"LNURL request encountered error: {e}" - 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") - # this will change what is returned by get_fields_for_GUI - self.bolt11 = bolt11_invoice - - on_success(self) - @log_exceptions async def round_3(self, tx, refund_address, *, on_success): if self.bip70: From b1925f8747c0e0597ca05e4d73b82466a3220501 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 2 Jun 2023 13:33:43 +0200 Subject: [PATCH 09/33] payment_identifier: refactor round_3 to need_merchant_notify/notify_merchant --- electrum/gui/qt/send_tab.py | 5 ++- electrum/payment_identifier.py | 57 ++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 4c6e65451..185479ad7 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -632,10 +632,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return False, repr(e) # success txid = tx.txid() - if self.payment_identifier.needs_round_3(): + if self.payment_identifier.need_merchant_notify(): refund_address = self.wallet.get_receiving_address() - coro = self.payment_identifier.round_3(tx.serialize(), refund_address) - asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) + self.payment_identifier.notify_merchant(tx=tx, refund_address=refund_address) return True, txid # Capture current TL window; override might be removed on return diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 7374fea44..7137f9397 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -182,9 +182,13 @@ class PaymentIdentifierState(IntEnum): # of the channels Electrum supports (on-chain, lightning) NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11 - BIP70_VIA = 5 # PI contains a valid payment request that should have the tx submitted through bip70 gw + MERCHANT_NOTIFY = 5 # PI contains a valid payment request and on-chain destination. It should notify + # the merchant payment processor of the tx after on-chain broadcast, + # and supply a refund address (bip70) + MERCHANT_ACK = 6 # PI notified merchant. nothing to be done. 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 class PaymentIdentifier(Logger): @@ -221,6 +225,8 @@ class PaymentIdentifier(Logger): # self.bip70 = None self.bip70_data = None + self.merchant_ack_status = None + self.merchant_ack_message = None # self.lnurl = None self.lnurl_data = None @@ -238,6 +244,9 @@ class PaymentIdentifier(Logger): def need_finalize(self): return self._state == PaymentIdentifierState.LNURLP_FINALIZE + def need_merchant_notify(self): + return self._state == PaymentIdentifierState.MERCHANT_NOTIFY + def is_valid(self): return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY] @@ -250,9 +259,6 @@ class PaymentIdentifier(Logger): def get_error(self) -> str: return self.error - def needs_round_3(self): - return self.bip70 - def parse(self, text): # parse text, set self._type and self.error text = text.strip() @@ -304,11 +310,11 @@ class PaymentIdentifier(Logger): def resolve(self, *, on_finished: 'Callable'): assert self._state == PaymentIdentifierState.NEED_RESOLVE - coro = self.do_resolve(on_finished=on_finished) + coro = self._do_resolve(on_finished=on_finished) asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) @log_exceptions - async def do_resolve(self, *, on_finished=None): + async def _do_resolve(self, *, on_finished=None): try: if self.emaillike: data = await self.resolve_openalias() @@ -338,7 +344,7 @@ class PaymentIdentifier(Logger): from . import paymentrequest data = await paymentrequest.get_payment_request(self.bip70) self.bip70_data = data - self.set_state(PaymentIdentifierState.BIP70_VIA) + self.set_state(PaymentIdentifierState.MERCHANT_NOTIFY) elif self.lnurl: data = await request_lnurl(self.lnurl) self.lnurl_data = data @@ -356,11 +362,11 @@ class PaymentIdentifier(Logger): def finalize(self, *, amount_sat: int = 0, comment: str = None, on_finished: Callable = None): assert self._state == PaymentIdentifierState.LNURLP_FINALIZE - coro = self.do_finalize(amount_sat, comment, on_finished=on_finished) + coro = self._do_finalize(amount_sat, comment, on_finished=on_finished) asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) @log_exceptions - async def do_finalize(self, amount_sat: int = None, comment: str = None, on_finished: Callable = None): + async def _do_finalize(self, amount_sat: int = None, comment: str = None, on_finished: Callable = None): from .invoices import Invoice try: if not self.lnurl_data: @@ -399,6 +405,32 @@ class PaymentIdentifier(Logger): if on_finished: on_finished(self) + def notify_merchant(self, *, tx: 'Transaction' = None, refund_address: str = None, on_finished: 'Callable' = None): + assert self._state == PaymentIdentifierState.MERCHANT_NOTIFY + assert tx + coro = self._do_notify_merchant(tx, refund_address, on_finished=on_finished) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + + @log_exceptions + async def _do_notify_merchant(self, tx, refund_address, *, on_finished: 'Callable'): + try: + if not self.bip70_data: + self.set_state(PaymentIdentifierState.ERROR) + return + + ack_status, ack_msg = await self.bip70_data.send_payment_and_receive_paymentack(tx.serialize(), refund_address) + self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") + self.merchant_ack_status = ack_status + self.merchant_ack_message = ack_msg + self.set_state(PaymentIdentifierState.MERCHANT_ACK) + except Exception as e: + self.error = str(e) + self.logger.error(repr(e)) + self.set_state(PaymentIdentifierState.MERCHANT_ERROR) + finally: + if on_finished: + on_finished(self) + def get_onchain_outputs(self, amount): if self.bip70: return self.bip70_data.get_outputs() @@ -609,13 +641,6 @@ class PaymentIdentifier(Logger): return self.bip70_data.has_expired() return False - @log_exceptions - async def round_3(self, tx, refund_address, *, on_success): - if self.bip70: - ack_status, ack_msg = await self.bip70.send_payment_and_receive_paymentack(tx.serialize(), refund_address) - self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") - on_success(self) - def get_invoice(self, amount_sat, message): from .invoices import Invoice if self.is_lightning(): From d9a43fa6ed6136581272081b9c98be58b0c37bfd Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 5 Jun 2023 11:54:57 +0200 Subject: [PATCH 10/33] refactor last callback, signals. remove timered validate, don't add invalid address/amount to outputs --- electrum/gui/qt/main_window.py | 2 +- electrum/gui/qt/paytoedit.py | 6 ------ electrum/gui/qt/send_tab.py | 25 +++++++++++++++++-------- electrum/payment_identifier.py | 19 ++++++++++++++----- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index f4a357e8c..9b9ff53d1 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -846,7 +846,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.update_status() # resolve aliases # FIXME this might do blocking network calls that has a timeout of several seconds - self.send_tab.payto_e.on_timer_check_text() + # self.send_tab.payto_e.on_timer_check_text() self.notify_transactions() def format_amount( diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 0d5be55f0..ad106f5c0 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -190,12 +190,6 @@ class PayToEdit(Logger, GenericInputHandler): self.text_edit.setText(text) self.text_edit.setFocus() - def on_timer_check_text(self): - if self.editor.hasFocus(): - return - text = self.toPlainText() - self._check_text(text, full_check=True) - def _check_text(self, text, *, full_check: bool): """ side effects: self.is_multiline """ text = str(text).strip() diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 185479ad7..6dc677d1e 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -33,7 +33,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): resolve_done_signal = pyqtSignal(object) finalize_done_signal = pyqtSignal(object) - round_3_signal = pyqtSignal(object) + notify_merchant_done_signal = pyqtSignal(object) def __init__(self, window: 'ElectrumWindow'): QWidget.__init__(self, window) @@ -182,8 +182,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger): run_hook('create_send_tab', grid) self.resolve_done_signal.connect(self.on_resolve_done) - self.finalize_done_signal.connect(self.on_round_2) - self.round_3_signal.connect(self.on_round_3) + self.finalize_done_signal.connect(self.on_finalize_done) + self.notify_merchant_done_signal.connect(self.on_notify_merchant_done) def do_paste(self): text = self.window.app.clipboard().text() @@ -438,7 +438,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # must not be None return self.amount_e.get_amount() or 0 - def on_round_2(self, pi): + def on_finalize_done(self, pi): self.do_clear() if pi.error: self.show_error(pi.error) @@ -449,9 +449,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.pending_invoice = invoice self.do_pay_invoice(invoice) - def on_round_3(self): - pass - def do_pay_or_get_invoice(self): pi = self.payment_identifier if pi.need_finalize(): @@ -634,7 +631,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): txid = tx.txid() if self.payment_identifier.need_merchant_notify(): refund_address = self.wallet.get_receiving_address() - self.payment_identifier.notify_merchant(tx=tx, refund_address=refund_address) + self.payment_identifier.notify_merchant( + tx=tx, + refund_address=refund_address, + on_finished=self.notify_merchant_done_signal.emit + ) return True, txid # Capture current TL window; override might be removed on return @@ -658,6 +659,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger): WaitingDialog(self, _('Broadcasting transaction...'), broadcast_thread, broadcast_done, self.window.on_error) + def on_notify_merchant_done(self, pi): + if pi.is_error(): + self.logger.debug(f'merchant notify error: {pi.get_error()}') + else: + self.logger.debug(f'merchant notify result: {pi.merchant_ack_status}: {pi.merchant_ack_message}') + # TODO: show user? if we broadcasted the tx succesfully, do we care? + # BitPay complains with a NAK if tx is RbF + def toggle_paytomany(self): self.payto_e.toggle_paytomany() if self.payto_e.is_paytomany(): diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 7137f9397..a87873c65 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -256,6 +256,9 @@ class PaymentIdentifier(Logger): def is_multiline(self): return bool(self.multiline_outputs) + def is_error(self) -> bool: + return self._state >= PaymentIdentifierState.ERROR + def get_error(self) -> str: return self.error @@ -267,6 +270,10 @@ class PaymentIdentifier(Logger): if outputs := self._parse_as_multiline(text): self._type = 'multiline' self.multiline_outputs = outputs + if self.error: + self.set_state(PaymentIdentifierState.INVALID) + else: + self.set_state(PaymentIdentifierState.AVAILABLE) elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): if invoice_or_lnurl.startswith('lnurl'): self._type = 'lnurl' @@ -457,15 +464,15 @@ class PaymentIdentifier(Logger): for i, line in enumerate(lines): try: output = self.parse_address_and_amount(line) + outputs.append(output) + if parse_max_spend(output.value): + is_max = True + else: + total += output.value except Exception as e: errors.append(PayToLineError( idx=i, line_content=line.strip(), exc=e, is_multiline=True)) continue - outputs.append(output) - if parse_max_spend(output.value): - is_max = True - else: - total += output.value if is_multiline and errors: self.error = str(errors) if errors else None self.logger.debug(f'multiline: {outputs!r}, {self.error}') @@ -477,6 +484,8 @@ class PaymentIdentifier(Logger): except ValueError: raise Exception("expected two comma-separated values: (address, amount)") from None scriptpubkey = self.parse_output(x) + if not scriptpubkey: + raise Exception('Invalid address') amount = self.parse_amount(y) return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount) From bde066f9ce50b305f86671a777f8206808be5f83 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 20 Jun 2023 20:54:31 +0200 Subject: [PATCH 11/33] qt: refactor send_tab, paytoedit --- electrum/gui/qt/amountedit.py | 1 - electrum/gui/qt/paytoedit.py | 187 ++++++++++++++------------- electrum/gui/qt/send_tab.py | 226 ++++++++++++++++++++------------- electrum/gui/qt/util.py | 15 ++- electrum/payment_identifier.py | 156 ++++++++++++++--------- 5 files changed, 341 insertions(+), 244 deletions(-) diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py index d4bc86c19..9bbb56784 100644 --- a/electrum/gui/qt/amountedit.py +++ b/electrum/gui/qt/amountedit.py @@ -13,7 +13,6 @@ from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_nam FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT) from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC - _NOT_GIVEN = object() # sentinel value diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index ad106f5c0..660139d46 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -26,6 +26,8 @@ from functools import partial from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING +from PyQt5.QtCore import Qt +from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtGui import QFontMetrics, QFont from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout @@ -48,6 +50,10 @@ frozen_style = "QWidget {border:none;}" normal_style = "QPlainTextEdit { }" +class InvalidPaymentIdentifier(Exception): + pass + + class ResizingTextEdit(QTextEdit): def __init__(self): @@ -63,113 +69,139 @@ class ResizingTextEdit(QTextEdit): self.verticalMargins += documentMargin * 2 self.heightMin = self.fontSpacing + self.verticalMargins self.heightMax = (self.fontSpacing * 10) + self.verticalMargins + self.single_line = True self.update_size() def update_size(self): docLineCount = self.document().lineCount() - docHeight = max(3, docLineCount) * self.fontSpacing + docHeight = max(1 if self.single_line else 3, docLineCount) * self.fontSpacing h = docHeight + self.verticalMargins h = min(max(h, self.heightMin), self.heightMax) self.setMinimumHeight(int(h)) self.setMaximumHeight(int(h)) - self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) + if self.single_line: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) + self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) +class PayToEdit(QObject, Logger, GenericInputHandler): -class PayToEdit(Logger, GenericInputHandler): + paymentIdentifierChanged = pyqtSignal() def __init__(self, send_tab: 'SendTab'): + QObject.__init__(self, parent=send_tab) Logger.__init__(self) GenericInputHandler.__init__(self) - self.line_edit = QLineEdit() + self.text_edit = ResizingTextEdit() - self.text_edit.hide() + self.text_edit.textChanged.connect(self._on_text_edit_text_changed) self._is_paytomany = False - for w in [self.line_edit, self.text_edit]: - w.setFont(QFont(MONOSPACE_FONT)) - w.textChanged.connect(self._on_text_changed) + self.text_edit.setFont(QFont(MONOSPACE_FONT)) self.send_tab = send_tab self.config = send_tab.config - self.win = send_tab.window self.app = QApplication.instance() - self.amount_edit = self.send_tab.amount_e + self.logger.debug(util.ColorScheme.RED.as_stylesheet(True)) self.is_multiline = False - self.disable_checks = False - self.is_alias = False + # self.is_alias = False self.payto_scriptpubkey = None # type: Optional[bytes] self.previous_payto = '' # editor methods - self.setStyleSheet = self.editor.setStyleSheet - self.setText = self.editor.setText - self.setEnabled = self.editor.setEnabled - self.setReadOnly = self.editor.setReadOnly - self.setFocus = self.editor.setFocus + self.setStyleSheet = self.text_edit.setStyleSheet + self.setText = self.text_edit.setText + self.setFocus = self.text_edit.setFocus + self.setToolTip = self.text_edit.setToolTip # button handlers self.on_qr_from_camera_input_btn = partial( self.input_qr_from_camera, config=self.config, allow_multi=False, - show_error=self.win.show_error, - setText=self._on_input_btn, - parent=self.win, + show_error=self.send_tab.show_error, + setText=self.try_payment_identifier, + parent=self.send_tab.window, ) self.on_qr_from_screenshot_input_btn = partial( self.input_qr_from_screenshot, allow_multi=False, - show_error=self.win.show_error, - setText=self._on_input_btn, + show_error=self.send_tab.show_error, + setText=self.try_payment_identifier, ) self.on_input_file = partial( self.input_file, config=self.config, - show_error=self.win.show_error, - setText=self._on_input_btn, + show_error=self.send_tab.show_error, + setText=self.try_payment_identifier, ) - # - self.line_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.line_edit, self) + self.text_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.text_edit, self) - @property - def editor(self): - return self.text_edit if self.is_paytomany() else self.line_edit + self.payment_identifier = None + + def set_text(self, text: str): + self.text_edit.setText(text) + + def update_editor(self): + if self.text_edit.toPlainText() != self.payment_identifier.text: + self.text_edit.setText(self.payment_identifier.text) + self.text_edit.single_line = not self.payment_identifier.is_multiline() + self.text_edit.update_size() + + '''set payment identifier only if valid, else exception''' + def try_payment_identifier(self, text): + text = text.strip() + pi = PaymentIdentifier(self.send_tab.wallet, text) + if not pi.is_valid(): + raise InvalidPaymentIdentifier('Invalid payment identifier') + self.set_payment_identifier(text) + + def set_payment_identifier(self, text): + text = text.strip() + if self.payment_identifier and self.payment_identifier.text == text: + # no change. + return + + self.payment_identifier = PaymentIdentifier(self.send_tab.wallet, text) + + # toggle to multiline if payment identifier is a multiline + self.is_multiline = self.payment_identifier.is_multiline() + self.logger.debug(f'is_multiline {self.is_multiline}') + if self.is_multiline and not self._is_paytomany: + self.set_paytomany(True) + + # if payment identifier gets set externally, we want to update the text_edit + # Note: this triggers the change handler, but we shortcut if it's the same payment identifier + self.update_editor() + + self.paymentIdentifierChanged.emit() def set_paytomany(self, b): - has_focus = self.editor.hasFocus() self._is_paytomany = b - self.line_edit.setVisible(not b) - self.text_edit.setVisible(b) + self.text_edit.single_line = not self._is_paytomany + self.text_edit.update_size() self.send_tab.paytomany_menu.setChecked(b) - if has_focus: - self.editor.setFocus() def toggle_paytomany(self): self.set_paytomany(not self._is_paytomany) - def toPlainText(self): - return self.text_edit.toPlainText() if self.is_paytomany() else self.line_edit.text() - def is_paytomany(self): return self._is_paytomany def setFrozen(self, b): - self.setReadOnly(b) + self.text_edit.setReadOnly(b) if not b: self.setStyleSheet(normal_style) - def setTextNoCheck(self, text: str): - """Sets the text, while also ensuring the new value will not be resolved/checked.""" - self.previous_payto = text - self.setText(text) + def isFrozen(self): + return self.text_edit.isReadOnly() def do_clear(self): self.is_multiline = False self.set_paytomany(False) - self.disable_checks = False - self.is_alias = False - self.line_edit.setText('') self.text_edit.setText('') - self.setFrozen(False) - self.setEnabled(True) + self.payment_identifier = None def setGreen(self): self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) @@ -177,53 +209,18 @@ class PayToEdit(Logger, GenericInputHandler): def setExpired(self): self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) - def _on_input_btn(self, text: str): - self.setText(text) - - def _on_text_changed(self): - text = self.toPlainText() - # False if user pasted from clipboard - full_check = self.app.clipboard().text() != text - self._check_text(text, full_check=full_check) - if self.is_multiline and not self._is_paytomany: - self.set_paytomany(True) - self.text_edit.setText(text) - self.text_edit.setFocus() + def _on_text_edit_text_changed(self): + self._handle_text_change(self.text_edit.toPlainText()) - def _check_text(self, text, *, full_check: bool): - """ side effects: self.is_multiline """ - text = str(text).strip() - if not text: + def _handle_text_change(self, text): + if self.isFrozen(): + # if editor is frozen, we ignore text changes as they might not be a payment identifier + # but a user friendly representation. return - if self.previous_payto == text: - return - if full_check: - self.previous_payto = text - if self.disable_checks: - return - pi = PaymentIdentifier(self.send_tab.wallet, text) - self.is_multiline = bool(pi.multiline_outputs) # TODO: why both is_multiline and set_paytomany(True)?? - self.logger.debug(f'is_multiline {self.is_multiline}') - if pi.is_valid(): - self.send_tab.set_payment_identifier(text) - else: - if not full_check and pi.error: - self.send_tab.show_error( - _('Clipboard text is not a valid payment identifier') + '\n' + str(pi.error)) - return - - def handle_multiline(self, outputs): - total = 0 - is_max = False - for output in outputs: - if parse_max_spend(output.value): - is_max = True - else: - total += output.value - self.send_tab.set_onchain(True) - self.send_tab.max_button.setChecked(is_max) - if self.send_tab.max_button.isChecked(): - self.send_tab.spend_max() - else: - self.amount_edit.setAmount(total if outputs else None) - #self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs)) + + self.set_payment_identifier(text) + if self.app.clipboard().text() and self.app.clipboard().text().strip() == self.payment_identifier.text: + # user pasted from clipboard + self.logger.debug('from clipboard') + if self.payment_identifier.error: + self.send_tab.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 6dc677d1e..a55db6bc2 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -6,14 +6,13 @@ import asyncio from decimal import Decimal from typing import Optional, TYPE_CHECKING, Sequence, List, Callable from PyQt5.QtCore import pyqtSignal, QPoint -from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, - QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) +from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout, + QWidget, QToolTip, QPushButton, QApplication) from electrum.plugin import run_hook from electrum.i18n import _ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend -from electrum.payment_identifier import PaymentIdentifier from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput @@ -21,8 +20,10 @@ from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.logging import Logger from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit -from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit -from .util import get_iconname_camera, get_iconname_qrcode, read_QIcon +from .paytoedit import InvalidPaymentIdentifier +from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, + char_width_in_lineedit, get_iconname_camera, get_iconname_qrcode, + read_QIcon) from .confirm_tx_dialog import ConfirmTxDialog if TYPE_CHECKING: @@ -38,7 +39,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def __init__(self, window: 'ElectrumWindow'): QWidget.__init__(self, window) Logger.__init__(self) - + self.app = QApplication.instance() self.window = window self.wallet = window.wallet self.fx = window.fx @@ -49,7 +50,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.format_amount = window.format_amount self.base_unit = window.base_unit - self.payment_identifier = None self.pending_invoice = None # A 4-column grid layout. All the stretch is in the last column. @@ -73,7 +73,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")) payto_label = HelpLabel(_('Pay to'), msg) grid.addWidget(payto_label, 0, 0) - grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4) + # grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4) grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4) #completer = QCompleter() @@ -119,11 +119,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): btn_width = 10 * char_width_in_lineedit() self.max_button.setFixedWidth(btn_width) self.max_button.setCheckable(True) + self.max_button.setEnabled(False) grid.addWidget(self.max_button, 3, 3) - self.save_button = EnterButton(_("Save"), self.do_save_invoice) - self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) - self.clear_button = EnterButton(_("Clear"), self.do_clear) self.paste_button = QPushButton() self.paste_button.clicked.connect(self.do_paste) self.paste_button.setIcon(read_QIcon('copy.png')) @@ -131,9 +129,15 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.paste_button.setMaximumWidth(35) grid.addWidget(self.paste_button, 0, 5) + self.save_button = EnterButton(_("Save"), self.do_save_invoice) + self.save_button.setEnabled(False) + self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) + self.send_button.setEnabled(False) + self.clear_button = EnterButton(_("Clear"), self.do_clear) + buttons = QHBoxLayout() buttons.addStretch(1) - #buttons.addWidget(self.paste_button) + buttons.addWidget(self.clear_button) buttons.addWidget(self.save_button) buttons.addWidget(self.send_button) @@ -143,14 +147,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def reset_max(text): self.max_button.setChecked(False) - enable = not bool(text) and not self.amount_e.isReadOnly() - # self.max_button.setEnabled(enable) + self.amount_e.textChanged.connect(self.on_amount_changed) self.amount_e.textEdited.connect(reset_max) self.fiat_send_e.textEdited.connect(reset_max) - self.set_onchain(False) - self.invoices_label = QLabel(_('Invoices')) from .invoice_list import InvoiceList self.invoice_list = InvoiceList(self) @@ -184,30 +185,33 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.resolve_done_signal.connect(self.on_resolve_done) self.finalize_done_signal.connect(self.on_finalize_done) self.notify_merchant_done_signal.connect(self.on_notify_merchant_done) + self.payto_e.paymentIdentifierChanged.connect(self._handle_payment_identifier) + + def on_amount_changed(self, text): + # FIXME: implement full valid amount check to enable/disable Pay button + pi_valid = self.payto_e.payment_identifier.is_valid() if self.payto_e.payment_identifier else False + self.send_button.setEnabled(bool(self.amount_e.get_amount()) and pi_valid) + def do_paste(self): - text = self.window.app.clipboard().text() - if not text: - return - self.set_payment_identifier(text) + try: + self.payto_e.try_payment_identifier(self.app.clipboard().text()) + except InvalidPaymentIdentifier as e: + self.show_error(_('Invalid payment identifier on clipboard')) def set_payment_identifier(self, text): - self.payment_identifier = PaymentIdentifier(self.wallet, text) - if self.payment_identifier.error: - self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error) - return - if self.payment_identifier.is_multiline(): - self.payto_e.set_paytomany(True) - self.payto_e.text_edit.setText(text) - else: - self.payto_e.setTextNoCheck(text) - self._handle_payment_identifier(can_use_network=True) + self.logger.debug('set_payment_identifier') + try: + self.payto_e.try_payment_identifier(text) + except InvalidPaymentIdentifier as e: + self.show_error(_('Invalid payment identifier')) def spend_max(self): + assert self.payto_e.payment_identifier is not None + assert self.payto_e.payment_identifier.type in ['spk', 'multiline'] if run_hook('abort_send', self): return - amount = self.get_amount() - outputs = self.payment_identifier.get_onchain_outputs(amount) + outputs = self.payto_e.payment_identifier.get_onchain_outputs('!') if not outputs: return make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction( @@ -296,9 +300,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): text = _("Not enough funds") frozen_str = self.get_frozen_balance_str() if frozen_str: - text += " ({} {})".format( - frozen_str, _("are frozen") - ) + text += " ({} {})".format(frozen_str, _("are frozen")) return text def get_frozen_balance_str(self) -> Optional[str]: @@ -308,31 +310,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.format_amount_and_units(frozen_bal) def do_clear(self): + self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False) self.max_button.setChecked(False) self.payto_e.do_clear() - self.set_onchain(False) for w in [self.comment_e, self.comment_label]: w.setVisible(False) for e in [self.message_e, self.amount_e, self.fiat_send_e]: e.setText('') self.set_field_style(e, None, False) - for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: - e.setEnabled(True) + for e in [self.save_button, self.send_button]: + e.setEnabled(False) self.window.update_status() run_hook('do_clear', self) - def set_onchain(self, b): - self._is_onchain = b - self.max_button.setEnabled(b) - def prepare_for_send_tab_network_lookup(self): self.window.show_send_tab() - self.payto_e.disable_checks = True #for e in [self.payto_e, self.message_e]: - self.payto_e.setFrozen(True) + # self.payto_e.setFrozen(True) for btn in [self.save_button, self.send_button, self.clear_button]: btn.setEnabled(False) - self.payto_e.setTextNoCheck(_("please wait...")) + # self.payto_e.setTextNoCheck(_("please wait...")) def payment_request_error(self, error): self.show_message(error) @@ -348,45 +345,90 @@ class SendTab(QWidget, MessageBoxMixin, Logger): style = ColorScheme.RED.as_stylesheet(True) if text is not None: w.setStyleSheet(style) - w.setReadOnly(True) else: w.setStyleSheet('') - w.setReadOnly(False) + + def lock_fields(self, *, + lock_recipient: Optional[bool] = None, + lock_amount: Optional[bool] = None, + lock_max: Optional[bool] = None, + lock_description: Optional[bool] = None + ) -> None: + self.logger.debug(f'locking fields, r={lock_recipient}, a={lock_amount}, m={lock_max}, d={lock_description}') + if lock_recipient is not None: + self.payto_e.setFrozen(lock_recipient) + if lock_amount is not None: + self.amount_e.setFrozen(lock_amount) + if lock_max is not None: + self.max_button.setEnabled(not lock_max) + if lock_description is not None: + self.message_e.setFrozen(lock_description) def update_fields(self): - recipient, amount, description, comment, validated = self.payment_identifier.get_fields_for_GUI() - if recipient: - self.payto_e.setTextNoCheck(recipient) - elif self.payment_identifier.multiline_outputs: - self.payto_e.handle_multiline(self.payment_identifier.multiline_outputs) - if description: - self.message_e.setText(description) - if amount: - self.amount_e.setAmount(amount) - for w in [self.comment_e, self.comment_label]: - w.setVisible(not bool(comment)) - self.set_field_style(self.payto_e, recipient or self.payment_identifier.multiline_outputs, validated) - self.set_field_style(self.message_e, description, validated) - self.set_field_style(self.amount_e, amount, validated) - self.set_field_style(self.fiat_send_e, amount, validated) - - def _handle_payment_identifier(self, *, can_use_network: bool = True): - is_valid = self.payment_identifier.is_valid() - self.save_button.setEnabled(is_valid) - self.send_button.setEnabled(is_valid) - if not is_valid: + pi = self.payto_e.payment_identifier + + if pi.is_multiline(): + self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False) + self.set_field_style(self.payto_e, pi.multiline_outputs, False if not pi.is_valid() else None) + self.save_button.setEnabled(pi.is_valid()) + self.send_button.setEnabled(pi.is_valid()) + if pi.is_valid(): + self.handle_multiline(pi.multiline_outputs) + else: + # self.payto_e.setToolTip('\n'.join(list(map(lambda x: f'{x.idx}: {x.line_content}', pi.get_error())))) + self.payto_e.setToolTip(pi.get_error()) return + + if not pi.is_valid(): + self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False) + self.save_button.setEnabled(False) + self.send_button.setEnabled(False) + return + + lock_recipient = pi.type != 'spk' + self.lock_fields(lock_recipient=lock_recipient, + lock_amount=pi.is_amount_locked(), + lock_max=pi.is_amount_locked(), + lock_description=False) + if lock_recipient: + recipient, amount, description, comment, validated = pi.get_fields_for_GUI() + if recipient: + self.payto_e.setText(recipient) + if description: + self.message_e.setText(description) + self.lock_fields(lock_description=True) + if amount: + self.amount_e.setAmount(amount) + for w in [self.comment_e, self.comment_label]: + w.setVisible(bool(comment)) + self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated) + self.set_field_style(self.message_e, description, validated) + self.set_field_style(self.amount_e, amount, validated) + self.set_field_style(self.fiat_send_e, amount, validated) + + self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired()) + self.save_button.setEnabled(True) + + def _handle_payment_identifier(self): + is_valid = self.payto_e.payment_identifier.is_valid() + self.logger.debug(f'handle PI, valid={is_valid}') + self.update_fields() - if self.payment_identifier.need_resolve(): + + if not is_valid: + self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}') + return + + if self.payto_e.payment_identifier.need_resolve(): self.prepare_for_send_tab_network_lookup() - self.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit) - # update fiat amount + self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit) + # update fiat amount (and reset max) self.amount_e.textEdited.emit("") self.window.show_send_tab() def on_resolve_done(self, pi): - if self.payment_identifier.error: - self.show_error(self.payment_identifier.error) + if self.payto_e.payment_identifier.error: + self.show_error(self.payto_e.payment_identifier.error) self.do_clear() return self.update_fields() @@ -404,10 +446,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.show_error(_('No amount')) return - invoice = self.payment_identifier.get_invoice(amount_sat, self.get_message()) + invoice = self.payto_e.payment_identifier.get_invoice(amount_sat, self.get_message()) #except Exception as e: if not invoice: - self.show_error('error getting invoice' + self.payment_identifier.error) + self.show_error('error getting invoice' + self.payto_e.payment_identifier.error) return if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): self.show_error(_('Lightning is disabled')) @@ -439,18 +481,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.amount_e.get_amount() or 0 def on_finalize_done(self, pi): - self.do_clear() if pi.error: self.show_error(pi.error) - self.do_clear() return - self.update_fields(pi) + self.update_fields() invoice = pi.get_invoice(self.get_amount(), self.get_message()) self.pending_invoice = invoice + self.logger.debug(f'after finalize invoice: {invoice!r}') self.do_pay_invoice(invoice) def do_pay_or_get_invoice(self): - pi = self.payment_identifier + pi = self.payto_e.payment_identifier if pi.need_finalize(): self.prepare_for_send_tab_network_lookup() pi.finalize(amount_sat=self.get_amount(), comment=self.message_e.text(), @@ -511,9 +552,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): """Returns whether there are errors. Also shows error dialog to user if so. """ - error = self.payment_identifier.get_error() + error = self.payto_e.payment_identifier.get_error() if error: - if not self.payment_identifier.is_multiline(): + if not self.payto_e.payment_identifier.is_multiline(): err = error self.show_warning( _("Failed to parse 'Pay to' line") + ":\n" + @@ -527,13 +568,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # for err in errors])) return True - warning = self.payment_identifier.warning + warning = self.payto_e.payment_identifier.warning if warning: warning += '\n' + _('Do you wish to continue?') if not self.question(warning): return True - if self.payment_identifier.has_expired(): + if self.payto_e.payment_identifier.has_expired(): self.show_error(_('Payment request has expired')) return True @@ -619,7 +660,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def broadcast_thread(): # non-GUI thread - if self.payment_identifier.has_expired(): + if self.payto_e.payment_identifier.has_expired(): return False, _("Invoice has expired") try: self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) @@ -629,9 +670,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return False, repr(e) # success txid = tx.txid() - if self.payment_identifier.need_merchant_notify(): + if self.payto_e.payment_identifier.need_merchant_notify(): refund_address = self.wallet.get_receiving_address() - self.payment_identifier.notify_merchant( + self.payto_e.payment_identifier.notify_merchant( tx=tx, refund_address=refund_address, on_finished=self.notify_merchant_done_signal.emit @@ -683,10 +724,23 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.window.show_send_tab() self.payto_e.do_clear() if len(paytos) == 1: + self.logger.debug('payto_e setText 1') self.payto_e.setText(paytos[0]) self.amount_e.setFocus() else: self.payto_e.setFocus() text = "\n".join([payto + ", 0" for payto in paytos]) + self.logger.debug('payto_e setText n') self.payto_e.setText(text) self.payto_e.setFocus() + + def handle_multiline(self, outputs): + total = 0 + for output in outputs: + if parse_max_spend(output.value): + self.max_button.setChecked(True) # TODO: remove and let spend_max set this? + self.spend_max() + return + else: + total += output.value + self.amount_e.setAmount(total if outputs else None) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 1d5f5c2b9..50bdacaa0 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -562,7 +562,10 @@ class GenericInputHandler: new_text = self.text() + data + '\n' else: new_text = data - setText(new_text) + try: + setText(new_text) + except Exception as e: + show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e)) from .qrreader import scan_qrcode if parent is None: @@ -599,7 +602,10 @@ class GenericInputHandler: new_text = self.text() + data + '\n' else: new_text = data - setText(new_text) + try: + setText(new_text) + except Exception as e: + show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e)) def input_file( self, @@ -628,7 +634,10 @@ class GenericInputHandler: except BaseException as e: show_error(_('Error opening file') + ':\n' + repr(e)) else: - setText(data) + try: + setText(data) + except Exception as e: + show_error(_('Invalid payment identifier in file') + ':\n' + repr(e)) def input_paste_from_clipboard( self, diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index a87873c65..3423397cb 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -1,4 +1,5 @@ import asyncio +import time import urllib import re from decimal import Decimal, InvalidOperation @@ -163,13 +164,6 @@ def is_uri(data: str) -> bool: return False -class PayToLineError(NamedTuple): - line_content: str - exc: Exception - idx: int = 0 # index of line - is_multiline: bool = False - - RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' @@ -210,12 +204,13 @@ class PaymentIdentifier(Logger): self.wallet = wallet self.contacts = wallet.contacts if wallet is not None else None self.config = wallet.config if wallet is not None else None - self.text = text + self.text = text.strip() self._type = None self.error = None # if set, GUI should show error and stop self.warning = None # if set, GUI should ask user if they want to proceed # more than one of those may be set self.multiline_outputs = None + self._is_max = False self.bolt11 = None self.bip21 = None self.spk = None @@ -234,8 +229,12 @@ class PaymentIdentifier(Logger): self.logger.debug(f'PI parsing...') self.parse(text) + @property + def type(self): + return self._type + def set_state(self, state: 'PaymentIdentifierState'): - self.logger.debug(f'PI state -> {state}') + self.logger.debug(f'PI state {self._state} -> {state}') self._state = state def need_resolve(self): @@ -256,6 +255,31 @@ class PaymentIdentifier(Logger): def is_multiline(self): return bool(self.multiline_outputs) + def is_multiline_max(self): + return self.is_multiline() and self._is_max + + def is_amount_locked(self): + if self._type == 'spk': + return False + elif self._type == 'bip21': + return bool(self.bip21.get('amount')) + elif self._type == 'bip70': + return True # TODO always given? + elif self._type == 'bolt11': + lnaddr = lndecode(self.bolt11) + return bool(lnaddr.amount) + elif self._type == 'lnurl': + # amount limits known after resolve, might be specific amount or locked to range + if self.need_resolve(): + self.logger.debug(f'lnurl r') + return True + if self.need_finalize(): + self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}') + return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat) + return True + elif self._type == 'multiline': + return True + def is_error(self) -> bool: return self._state >= PaymentIdentifierState.ERROR @@ -281,11 +305,21 @@ class PaymentIdentifier(Logger): self.lnurl = decode_lnurl(invoice_or_lnurl) self.set_state(PaymentIdentifierState.NEED_RESOLVE) except Exception as e: - self.error = "Error parsing Lightning invoice" + f":\n{e}" + self.error = _("Error parsing LNURL") + f":\n{e}" self.set_state(PaymentIdentifierState.INVALID) return else: self._type = 'bolt11' + try: + lndecode(invoice_or_lnurl) + except LnInvoiceException as e: + self.error = _("Error parsing Lightning invoice") + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) + return + except IncompatibleOrInsaneFeatures as e: + self.error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}" + self.set_state(PaymentIdentifierState.INVALID) + return self.bolt11 = invoice_or_lnurl self.set_state(PaymentIdentifierState.AVAILABLE) elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): @@ -295,19 +329,31 @@ class PaymentIdentifier(Logger): self.error = _("Error parsing URI") + f":\n{e}" self.set_state(PaymentIdentifierState.INVALID) return - self._type = 'bip21' self.bip21 = out self.bip70 = out.get('r') if self.bip70: + self._type = 'bip70' self.set_state(PaymentIdentifierState.NEED_RESOLVE) else: + self._type = 'bip21' + # check optional lightning in bip21, set self.bolt11 if valid + bolt11 = out.get('lightning') + if bolt11: + try: + lndecode(bolt11) + # if we get here, we have a usable bolt11 + self.bolt11 = bolt11 + except LnInvoiceException as e: + self.logger.debug(_("Error parsing Lightning invoice") + f":\n{e}") + except IncompatibleOrInsaneFeatures as e: + self.logger.debug(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") self.set_state(PaymentIdentifierState.AVAILABLE) elif scriptpubkey := self.parse_output(text): self._type = 'spk' self.spk = scriptpubkey self.set_state(PaymentIdentifierState.AVAILABLE) elif re.match(RE_EMAIL, text): - self._type = 'alias' + self._type = 'emaillike' self.emaillike = text self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif self.error is None: @@ -324,9 +370,10 @@ class PaymentIdentifier(Logger): async def _do_resolve(self, *, on_finished=None): try: if self.emaillike: + # TODO: parallel lookup? data = await self.resolve_openalias() if data: - self.openalias_data = data # needed? + self.openalias_data = data self.logger.debug(f'OA: {data!r}') name = data.get('name') address = data.get('address') @@ -335,8 +382,14 @@ class PaymentIdentifier(Logger): self.warning = _( 'WARNING: the alias "{}" could not be validated via an additional ' 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) - # this will set self.spk and update state - self.parse(address) + try: + scriptpubkey = self.parse_output(address) + self._type = 'openalias' + self.spk = scriptpubkey + self.set_state(PaymentIdentifierState.AVAILABLE) + except Exception as e: + self.error = str(e) + self.set_state(PaymentIdentifierState.NOT_FOUND) else: lnurl = lightning_address_to_url(self.emaillike) try: @@ -356,6 +409,7 @@ class PaymentIdentifier(Logger): data = await request_lnurl(self.lnurl) self.lnurl_data = data self.set_state(PaymentIdentifierState.LNURLP_FINALIZE) + self.logger.debug(f'LNURL data: {data!r}') else: self.set_state(PaymentIdentifierState.ERROR) return @@ -458,23 +512,22 @@ class PaymentIdentifier(Logger): lines = [i for i in lines if i] is_multiline = len(lines) > 1 outputs = [] # type: List[PartialTxOutput] - errors = [] + errors = '' total = 0 - is_max = False + self._is_max = False for i, line in enumerate(lines): try: output = self.parse_address_and_amount(line) outputs.append(output) if parse_max_spend(output.value): - is_max = True + self._is_max = True else: total += output.value except Exception as e: - errors.append(PayToLineError( - idx=i, line_content=line.strip(), exc=e, is_multiline=True)) + errors = f'{errors}line #{i}: {str(e)}\n' continue if is_multiline and errors: - self.error = str(errors) if errors else None + self.error = errors.strip() if errors else None self.logger.debug(f'multiline: {outputs!r}, {self.error}') return outputs @@ -494,15 +547,14 @@ class PaymentIdentifier(Logger): address = self.parse_address(x) return bytes.fromhex(bitcoin.address_to_script(address)) except Exception as e: - error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False) + pass try: script = self.parse_script(x) return bytes.fromhex(script) except Exception as e: - #error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False) pass - #raise Exception("Invalid address or script.") - #self.errors.append(error) + + raise Exception("Invalid address or script.") def parse_script(self, x): script = '' @@ -535,12 +587,11 @@ class PaymentIdentifier(Logger): return address def get_fields_for_GUI(self): - """ sets self.error as side effect""" recipient = None amount = None description = None validated = None - comment = "no comment" + comment = None if self.emaillike and self.openalias_data: address = self.openalias_data.get('address') @@ -550,21 +601,17 @@ class PaymentIdentifier(Logger): if not validated: self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) - #self.payto_e.set_openalias(key=pi.openalias, data=oa_data) - #self.window.contact_list.update() - elif self.bolt11: - recipient, amount, description = self.get_bolt11_fields(self.bolt11) + elif self.bolt11 and self.wallet.has_lightning(): + recipient, amount, description = self._get_bolt11_fields(self.bolt11) elif self.lnurl and self.lnurl_data: domain = urllib.parse.urlparse(self.lnurl).netloc - #recipient = "invoice from lnurl" recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>" - #amount = self.lnurl_data.min_sendable_sat - amount = None - description = None + amount = self.lnurl_data.min_sendable_sat if self.lnurl_data.min_sendable_sat else None + description = self.lnurl_data.metadata_plaintext if self.lnurl_data.comment_allowed: - comment = None + comment = self.lnurl_data.comment_allowed elif self.bip70 and self.bip70_data: pr = self.bip70_data @@ -575,8 +622,6 @@ class PaymentIdentifier(Logger): amount = pr.get_amount() description = pr.get_memo() validated = not pr.has_expired() - #self.set_onchain(True) - #self.max_button.setEnabled(False) # note: allow saving bip70 reqs, as we save them anyway when paying them #for btn in [self.send_button, self.clear_button, self.save_button]: # btn.setEnabled(True) @@ -584,8 +629,7 @@ class PaymentIdentifier(Logger): #self.amount_e.textEdited.emit("") elif self.spk: - recipient = self.text - amount = None + pass elif self.multiline_outputs: pass @@ -598,26 +642,12 @@ class PaymentIdentifier(Logger): # use label as description (not BIP21 compliant) if label and not description: description = label - lightning = self.bip21.get('lightning') - if lightning and self.wallet.has_lightning(): - # maybe set self.bolt11? - recipient, amount, description = self.get_bolt11_fields(lightning) - if not amount: - amount_required = True - # todo: merge logic return recipient, amount, description, comment, validated - def get_bolt11_fields(self, bolt11_invoice): + def _get_bolt11_fields(self, bolt11_invoice): """Parse ln invoice, and prepare the send tab for it.""" - try: - lnaddr = lndecode(bolt11_invoice) - except LnInvoiceException as e: - self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") - return - except IncompatibleOrInsaneFeatures as e: - self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") - return + lnaddr = lndecode(bolt11_invoice) # pubkey = lnaddr.pubkey.serialize().hex() for k, v in lnaddr.tags: if k == 'd': @@ -628,15 +658,17 @@ class PaymentIdentifier(Logger): amount = lnaddr.get_amount_sat() return pubkey, amount, description + # TODO: rename to resolve_emaillike to disambiguate async def resolve_openalias(self) -> Optional[dict]: key = self.emaillike - if not (('.' in key) and ('<' not in key) and (' ' not in key)): - return None + # TODO: below check needed? we already matched RE_EMAIL + # if not (('.' in key) and ('<' not in key) and (' ' not in key)): + # return None parts = key.split(sep=',') # assuming single line if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): return None try: - data = self.contacts.resolve(key) + data = self.contacts.resolve(key) # TODO: don't use contacts as delegate to resolve openalias, separate. return data except AliasNotFoundException as e: self.logger.info(f'OpenAlias not found: {repr(e)}') @@ -648,6 +680,12 @@ class PaymentIdentifier(Logger): def has_expired(self): if self.bip70: return self.bip70_data.has_expired() + elif self.bolt11: + lnaddr = lndecode(self.bolt11) + return lnaddr.is_expired() + elif self.bip21: + expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0 + return bool(expires) and expires < time.time() return False def get_invoice(self, amount_sat, message): From 915f66c0b8124426d71d5b580a44b2a16ac954c6 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 22 Jun 2023 14:36:33 +0200 Subject: [PATCH 12/33] payment_identifier: fix emaillike qt: validate on pushback timer, buttons enable/disable, cleanup --- electrum/gui/qt/paytoedit.py | 41 ++++++++++++++++---------- electrum/gui/qt/send_tab.py | 54 ++++++++++++++++------------------ electrum/payment_identifier.py | 22 +++++++++----- 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 660139d46..ac876407c 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -26,13 +26,12 @@ from functools import partial from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtGui import QFontMetrics, QFont -from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout +from PyQt5.QtWidgets import QApplication, QTextEdit, QVBoxLayout from electrum.i18n import _ -from electrum.util import parse_max_spend from electrum.payment_identifier import PaymentIdentifier from electrum.logging import Logger @@ -42,7 +41,6 @@ from . import util from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent if TYPE_CHECKING: - from .main_window import ElectrumWindow from .send_tab import SendTab @@ -97,7 +95,7 @@ class PayToEdit(QObject, Logger, GenericInputHandler): GenericInputHandler.__init__(self) self.text_edit = ResizingTextEdit() - self.text_edit.textChanged.connect(self._on_text_edit_text_changed) + self.text_edit.textChanged.connect(self._handle_text_change) self._is_paytomany = False self.text_edit.setFont(QFont(MONOSPACE_FONT)) self.send_tab = send_tab @@ -138,6 +136,11 @@ class PayToEdit(QObject, Logger, GenericInputHandler): self.text_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.text_edit, self) + self.edit_timer = QTimer(self) + self.edit_timer.setSingleShot(True) + self.edit_timer.setInterval(1000) + self.edit_timer.timeout.connect(self._on_edit_timer) + self.payment_identifier = None def set_text(self, text: str): @@ -167,7 +170,6 @@ class PayToEdit(QObject, Logger, GenericInputHandler): # toggle to multiline if payment identifier is a multiline self.is_multiline = self.payment_identifier.is_multiline() - self.logger.debug(f'is_multiline {self.is_multiline}') if self.is_multiline and not self._is_paytomany: self.set_paytomany(True) @@ -209,18 +211,25 @@ class PayToEdit(QObject, Logger, GenericInputHandler): def setExpired(self): self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) - def _on_text_edit_text_changed(self): - self._handle_text_change(self.text_edit.toPlainText()) - - def _handle_text_change(self, text): + def _handle_text_change(self): if self.isFrozen(): # if editor is frozen, we ignore text changes as they might not be a payment identifier # but a user friendly representation. return - self.set_payment_identifier(text) - if self.app.clipboard().text() and self.app.clipboard().text().strip() == self.payment_identifier.text: - # user pasted from clipboard - self.logger.debug('from clipboard') - if self.payment_identifier.error: - self.send_tab.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error) + # pushback timer if timer active or PI needs resolving + pi = PaymentIdentifier(self.send_tab.wallet, self.text_edit.toPlainText()) + if pi.need_resolve() or self.edit_timer.isActive(): + self.edit_timer.start() + else: + self.set_payment_identifier(self.text_edit.toPlainText()) + + # self.set_payment_identifier(text) + # if self.app.clipboard().text() and self.app.clipboard().text().strip() == self.payment_identifier.text: + # # user pasted from clipboard + # self.logger.debug('from clipboard') + # if self.payment_identifier.error: + # self.send_tab.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error) + + def _on_edit_timer(self): + self.set_payment_identifier(self.text_edit.toPlainText()) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index a55db6bc2..9c8b4bc49 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -2,22 +2,21 @@ # Distributed under the MIT software license, see the accompanying # file LICENCE or http://www.opensource.org/licenses/mit-license.php -import asyncio from decimal import Decimal from typing import Optional, TYPE_CHECKING, Sequence, List, Callable from PyQt5.QtCore import pyqtSignal, QPoint from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout, QWidget, QToolTip, QPushButton, QApplication) -from electrum.plugin import run_hook from electrum.i18n import _ +from electrum.logging import Logger +from electrum.plugin import run_hook from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST - from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput from electrum.network import TxBroadcastError, BestEffortRequestFailed -from electrum.logging import Logger +from electrum.payment_identifier import PaymentIdentifierState from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .paytoedit import InvalidPaymentIdentifier @@ -73,7 +72,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")) payto_label = HelpLabel(_('Pay to'), msg) grid.addWidget(payto_label, 0, 0) - # grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4) grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4) #completer = QCompleter() @@ -190,8 +188,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def on_amount_changed(self, text): # FIXME: implement full valid amount check to enable/disable Pay button pi_valid = self.payto_e.payment_identifier.is_valid() if self.payto_e.payment_identifier else False - self.send_button.setEnabled(bool(self.amount_e.get_amount()) and pi_valid) - + pi_error = self.payto_e.payment_identifier.is_error() if pi_valid else False + self.send_button.setEnabled(bool(self.amount_e.get_amount()) and pi_valid and not pi_error) def do_paste(self): try: @@ -324,7 +322,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): run_hook('do_clear', self) def prepare_for_send_tab_network_lookup(self): - self.window.show_send_tab() + self.window.show_send_tab() # FIXME why is this here #for e in [self.payto_e, self.message_e]: # self.payto_e.setFrozen(True) for btn in [self.save_button, self.send_button, self.clear_button]: @@ -367,16 +365,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def update_fields(self): pi = self.payto_e.payment_identifier + self.clear_button.setEnabled(True) + if pi.is_multiline(): self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False) - self.set_field_style(self.payto_e, pi.multiline_outputs, False if not pi.is_valid() else None) + self.set_field_style(self.payto_e, True if not pi.is_valid() else None, False) self.save_button.setEnabled(pi.is_valid()) self.send_button.setEnabled(pi.is_valid()) + self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '') if pi.is_valid(): self.handle_multiline(pi.multiline_outputs) - else: - # self.payto_e.setToolTip('\n'.join(list(map(lambda x: f'{x.idx}: {x.line_content}', pi.get_error())))) - self.payto_e.setToolTip(pi.get_error()) return if not pi.is_valid(): @@ -385,10 +383,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.send_button.setEnabled(False) return - lock_recipient = pi.type != 'spk' + lock_recipient = pi.type != 'spk' \ + and not (pi.type == 'emaillike' and pi.is_state(PaymentIdentifierState.NOT_FOUND)) + lock_max = pi.is_amount_locked() \ + or pi.type in ['bolt11', 'lnurl', 'lightningaddress'] self.lock_fields(lock_recipient=lock_recipient, lock_amount=pi.is_amount_locked(), - lock_max=pi.is_amount_locked(), + lock_max=lock_max, lock_description=False) if lock_recipient: recipient, amount, description, comment, validated = pi.get_fields_for_GUI() @@ -406,16 +407,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.set_field_style(self.amount_e, amount, validated) self.set_field_style(self.fiat_send_e, amount, validated) - self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired()) - self.save_button.setEnabled(True) + self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) + self.save_button.setEnabled(not pi.is_error()) def _handle_payment_identifier(self): - is_valid = self.payto_e.payment_identifier.is_valid() - self.logger.debug(f'handle PI, valid={is_valid}') - self.update_fields() - if not is_valid: + if not self.payto_e.payment_identifier.is_valid(): self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}') return @@ -424,16 +422,18 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit) # update fiat amount (and reset max) self.amount_e.textEdited.emit("") - self.window.show_send_tab() + self.window.show_send_tab() # FIXME: why is this here? def on_resolve_done(self, pi): - if self.payto_e.payment_identifier.error: - self.show_error(self.payto_e.payment_identifier.error) + # TODO: resolve can happen while typing, we don't want message dialogs to pop up + # currently we don't set error for emaillike recipients to avoid just that + if pi.error: + self.show_error(pi.error) self.do_clear() return self.update_fields() - for btn in [self.send_button, self.clear_button, self.save_button]: - btn.setEnabled(True) + # for btn in [self.send_button, self.clear_button, self.save_button]: + # btn.setEnabled(True) def get_message(self): return self.message_e.text() @@ -447,7 +447,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return invoice = self.payto_e.payment_identifier.get_invoice(amount_sat, self.get_message()) - #except Exception as e: if not invoice: self.show_error('error getting invoice' + self.payto_e.payment_identifier.error) return @@ -526,8 +525,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.pay_onchain_dialog(invoice.outputs) def read_amount(self) -> List[PartialTxOutput]: - is_max = self.max_button.isChecked() - amount = '!' if is_max else self.get_amount() + amount = '!' if self.max_button.isChecked() else self.get_amount() return amount def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool: diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 3423397cb..118669e07 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -225,8 +225,7 @@ class PaymentIdentifier(Logger): # self.lnurl = None self.lnurl_data = None - # parse without network - self.logger.debug(f'PI parsing...') + self.parse(text) @property @@ -237,6 +236,9 @@ class PaymentIdentifier(Logger): self.logger.debug(f'PI state {self._state} -> {state}') self._state = state + def is_state(self, state: 'PaymentIdentifierState'): + return self._state == state + def need_resolve(self): return self._state == PaymentIdentifierState.NEED_RESOLVE @@ -268,10 +270,9 @@ class PaymentIdentifier(Logger): elif self._type == 'bolt11': lnaddr = lndecode(self.bolt11) return bool(lnaddr.amount) - elif self._type == 'lnurl': + elif self._type == 'lnurl' or self._type == 'lightningaddress': # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): - self.logger.debug(f'lnurl r') return True if self.need_finalize(): self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}') @@ -279,6 +280,10 @@ class PaymentIdentifier(Logger): return True elif self._type == 'multiline': return True + elif self._type == 'emaillike': + return False + elif self._type == 'openalias': + return False def is_error(self) -> bool: return self._state >= PaymentIdentifierState.ERROR @@ -394,11 +399,15 @@ class PaymentIdentifier(Logger): lnurl = lightning_address_to_url(self.emaillike) try: data = await request_lnurl(lnurl) + self._type = 'lightningaddress' self.lnurl = lnurl self.lnurl_data = data self.set_state(PaymentIdentifierState.LNURLP_FINALIZE) except LNURLError as e: - self.error = str(e) + self.set_state(PaymentIdentifierState.NOT_FOUND) + except Exception as e: + # NOTE: any other exception is swallowed here (e.g. DNS error) + # as the user may be typing and we have an incomplete emaillike self.set_state(PaymentIdentifierState.NOT_FOUND) elif self.bip70: from . import paymentrequest @@ -554,7 +563,7 @@ class PaymentIdentifier(Logger): except Exception as e: pass - raise Exception("Invalid address or script.") + # raise Exception("Invalid address or script.") def parse_script(self, x): script = '' @@ -658,7 +667,6 @@ class PaymentIdentifier(Logger): amount = lnaddr.get_amount_sat() return pubkey, amount, description - # TODO: rename to resolve_emaillike to disambiguate async def resolve_openalias(self) -> Optional[dict]: key = self.emaillike # TODO: below check needed? we already matched RE_EMAIL From fc141c01826fce955e91cef930e3e0178fb39bc6 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 22 Jun 2023 20:20:31 +0200 Subject: [PATCH 13/33] payment_identfier: refactor qml and tests --- electrum/gui/qml/qeinvoice.py | 195 +++++++++++++-------------------- electrum/payment_identifier.py | 20 ++-- electrum/tests/test_util.py | 24 ++-- 3 files changed, 98 insertions(+), 141 deletions(-) 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')) From 74a1f38a8b79996d6e58c3428d844167122f213f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 10:01:10 +0200 Subject: [PATCH 14/33] payment identifier types as enum --- electrum/gui/qml/qeinvoice.py | 15 ++++++----- electrum/gui/qt/send_tab.py | 10 ++++---- electrum/payment_identifier.py | 47 +++++++++++++++++++++------------- electrum/x509.py | 3 +-- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index fcf475865..fa92706aa 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -17,7 +17,7 @@ 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) + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType) from .qetypes import QEAmount from .qewallet import QEWallet @@ -491,7 +491,8 @@ class QEInvoiceParser(QEInvoice): return self._pi = PaymentIdentifier(self._wallet.wallet, recipient) - if not self._pi.is_valid() or self._pi.type not in ['spk', 'bip21', 'bip70', 'bolt11', 'lnurl']: + 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 @@ -502,23 +503,23 @@ class QEInvoiceParser(QEInvoice): self.resolve_pi() return - if self._pi.type == 'lnurl': + if self._pi.type == PaymentIdentifierType.LNURLP: self.on_lnurl(self._pi.lnurl_data) return - if self._pi.type == 'bip70': + 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 == 'spk': + 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 == 'bolt11': + elif self._pi.type == PaymentIdentifierType.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', @@ -530,7 +531,7 @@ class QEInvoiceParser(QEInvoice): self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() - elif self._pi.type == 'bip21': + elif self._pi.type == PaymentIdentifierType.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) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 9c8b4bc49..7ee6cfcf8 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -16,7 +16,7 @@ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput from electrum.network import TxBroadcastError, BestEffortRequestFailed -from electrum.payment_identifier import PaymentIdentifierState +from electrum.payment_identifier import PaymentIdentifierState, PaymentIdentifierType from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .paytoedit import InvalidPaymentIdentifier @@ -206,7 +206,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def spend_max(self): assert self.payto_e.payment_identifier is not None - assert self.payto_e.payment_identifier.type in ['spk', 'multiline'] + assert self.payto_e.payment_identifier.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE] if run_hook('abort_send', self): return outputs = self.payto_e.payment_identifier.get_onchain_outputs('!') @@ -383,10 +383,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.send_button.setEnabled(False) return - lock_recipient = pi.type != 'spk' \ - and not (pi.type == 'emaillike' and pi.is_state(PaymentIdentifierState.NOT_FOUND)) + lock_recipient = pi.type != PaymentIdentifierType.SPK \ + and not (pi.type == PaymentIdentifierType.EMAILLIKE and pi.is_state(PaymentIdentifierState.NOT_FOUND)) lock_max = pi.is_amount_locked() \ - or pi.type in ['bolt11', 'lnurl', 'lightningaddress'] + or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] self.lock_fields(lock_recipient=lock_recipient, lock_amount=pi.is_amount_locked(), lock_max=lock_max, diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 4b9733b66..0f348809f 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -182,6 +182,17 @@ class PaymentIdentifierState(IntEnum): MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX INVALID_AMOUNT = 53 # Specified amount not accepted +class PaymentIdentifierType(IntEnum): + UNKNOWN = 0 + SPK = 1 + BIP21 = 2 + BIP70 = 3 + MULTILINE = 4 + BOLT11 = 5 + LNURLP = 6 + EMAILLIKE = 7 + OPENALIAS = 8 + LNADDR = 9 class PaymentIdentifier(Logger): """ @@ -203,7 +214,7 @@ class PaymentIdentifier(Logger): self.contacts = wallet.contacts if wallet is not None else None self.config = wallet.config if wallet is not None else None self.text = text.strip() - self._type = None + self._type = PaymentIdentifierType.UNKNOWN self.error = None # if set, GUI should show error and stop self.warning = None # if set, GUI should ask user if they want to proceed # more than one of those may be set @@ -262,16 +273,16 @@ class PaymentIdentifier(Logger): return self.is_multiline() and self._is_max def is_amount_locked(self): - if self._type == 'spk': + if self._type == PaymentIdentifierType.SPK: return False - elif self._type == 'bip21': + elif self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('amount')) - elif self._type == 'bip70': + elif self._type == PaymentIdentifierType.BIP70: return True # TODO always given? - elif self._type == 'bolt11': + elif self._type == PaymentIdentifierType.BOLT11: lnaddr = lndecode(self.bolt11) return bool(lnaddr.amount) - elif self._type == 'lnurl' or self._type == 'lightningaddress': + elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): return True @@ -279,11 +290,11 @@ class PaymentIdentifier(Logger): self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}') return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat) return True - elif self._type == 'multiline': + elif self._type == PaymentIdentifierType.MULTILINE: return True - elif self._type == 'emaillike': + elif self._type == PaymentIdentifierType.EMAILLIKE: return False - elif self._type == 'openalias': + elif self._type == PaymentIdentifierType.OPENALIAS: return False def is_error(self) -> bool: @@ -298,7 +309,7 @@ class PaymentIdentifier(Logger): if not text: return if outputs := self._parse_as_multiline(text): - self._type = 'multiline' + self._type = PaymentIdentifierType.MULTILINE self.multiline_outputs = outputs if self.error: self.set_state(PaymentIdentifierState.INVALID) @@ -306,7 +317,7 @@ class PaymentIdentifier(Logger): self.set_state(PaymentIdentifierState.AVAILABLE) elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): if invoice_or_lnurl.startswith('lnurl'): - self._type = 'lnurl' + self._type = PaymentIdentifierType.LNURLP try: self.lnurl = decode_lnurl(invoice_or_lnurl) self.set_state(PaymentIdentifierState.NEED_RESOLVE) @@ -315,7 +326,7 @@ class PaymentIdentifier(Logger): self.set_state(PaymentIdentifierState.INVALID) return else: - self._type = 'bolt11' + self._type = PaymentIdentifierType.BOLT11 try: lndecode(invoice_or_lnurl) except LnInvoiceException as e: @@ -338,10 +349,10 @@ class PaymentIdentifier(Logger): self.bip21 = out self.bip70 = out.get('r') if self.bip70: - self._type = 'bip70' + self._type = PaymentIdentifierType.BIP70 self.set_state(PaymentIdentifierState.NEED_RESOLVE) else: - self._type = 'bip21' + self._type = PaymentIdentifierType.BIP21 # check optional lightning in bip21, set self.bolt11 if valid bolt11 = out.get('lightning') if bolt11: @@ -355,11 +366,11 @@ class PaymentIdentifier(Logger): self.logger.debug(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") self.set_state(PaymentIdentifierState.AVAILABLE) elif scriptpubkey := self.parse_output(text): - self._type = 'spk' + self._type = PaymentIdentifierType.SPK self.spk = scriptpubkey self.set_state(PaymentIdentifierState.AVAILABLE) elif re.match(RE_EMAIL, text): - self._type = 'emaillike' + self._type = PaymentIdentifierType.EMAILLIKE self.emaillike = text self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif self.error is None: @@ -390,7 +401,7 @@ class PaymentIdentifier(Logger): 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) try: scriptpubkey = self.parse_output(address) - self._type = 'openalias' + self._type = PaymentIdentifierType.OPENALIAS self.spk = scriptpubkey self.set_state(PaymentIdentifierState.AVAILABLE) except Exception as e: @@ -400,7 +411,7 @@ class PaymentIdentifier(Logger): lnurl = lightning_address_to_url(self.emaillike) try: data = await request_lnurl(lnurl) - self._type = 'lightningaddress' + self._type = PaymentIdentifierType.LNADDR self.lnurl = lnurl self.lnurl_data = data self.set_state(PaymentIdentifierState.LNURLP_FINALIZE) diff --git a/electrum/x509.py b/electrum/x509.py index f0da646f6..68cf92b94 100644 --- a/electrum/x509.py +++ b/electrum/x509.py @@ -308,8 +308,7 @@ class X509(object): raise CertificateError('Certificate has not entered its valid date range. (%s)' % self.get_common_name()) if self.notAfter <= now: dt = datetime.utcfromtimestamp(time.mktime(self.notAfter)) - # for testnet - #raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') + raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') def getFingerprint(self): return hashlib.sha1(self.bytes).digest() From ca283a75d01ad3bcd7ad3deae6e4d7fc927cec15 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 10:18:49 +0200 Subject: [PATCH 15/33] qml: exclude non-address SPK from supported payment identifiers --- electrum/gui/qml/qeinvoice.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index fa92706aa..a7c437721 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -10,7 +10,7 @@ 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 +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 @@ -496,6 +496,12 @@ class QEInvoiceParser(QEInvoice): 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): From eed016bd7e450ffa0500f461d3401b64198d2ca8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 12:58:10 +0200 Subject: [PATCH 16/33] qt: move setting frozen styling to edit components themselves, fix re-enabling Clear button after finalize --- electrum/gui/qml/qeinvoice.py | 2 +- electrum/gui/qt/amountedit.py | 5 +++- electrum/gui/qt/paytoedit.py | 17 ++++---------- electrum/gui/qt/send_tab.py | 42 ++++++++++++---------------------- electrum/payment_identifier.py | 5 ++-- 5 files changed, 26 insertions(+), 45 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index a7c437721..e9e3a51b0 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -598,7 +598,7 @@ class QEInvoiceParser(QEInvoice): def on_finished(pi): if pi.is_error(): - if pi.is_state(PaymentIdentifierState.INVALID_AMOUNT): + if pi.state == PaymentIdentifierState.INVALID_AMOUNT: self.lnurlError.emit('amount', pi.get_error()) else: self.lnurlError.emit('lnurl', pi.get_error()) diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py index 9bbb56784..be1d20f86 100644 --- a/electrum/gui/qt/amountedit.py +++ b/electrum/gui/qt/amountedit.py @@ -21,9 +21,11 @@ class FreezableLineEdit(QLineEdit): def setFrozen(self, b): self.setReadOnly(b) - self.setFrame(not b) + self.setStyleSheet(ColorScheme.BLUE.as_stylesheet(True) if b else '') self.frozen.emit() + def isFrozen(self): + return self.isReadOnly() class SizedFreezableLineEdit(FreezableLineEdit): @@ -152,6 +154,7 @@ class BTCAmountEdit(AmountEdit): else: text = self._get_text_from_amount(amount_sat) self.setText(text) + self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?) self.repaint() # macOS hack for #6269 diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index ac876407c..9d5bd2513 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -38,7 +38,7 @@ from electrum.logging import Logger from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit from . import util -from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent +from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent, ColorScheme if TYPE_CHECKING: from .send_tab import SendTab @@ -102,9 +102,7 @@ class PayToEdit(QObject, Logger, GenericInputHandler): self.config = send_tab.config self.app = QApplication.instance() - self.logger.debug(util.ColorScheme.RED.as_stylesheet(True)) self.is_multiline = False - # self.is_alias = False self.payto_scriptpubkey = None # type: Optional[bytes] self.previous_payto = '' # editor methods @@ -193,8 +191,7 @@ class PayToEdit(QObject, Logger, GenericInputHandler): def setFrozen(self, b): self.text_edit.setReadOnly(b) - if not b: - self.setStyleSheet(normal_style) + self.text_edit.setStyleSheet(ColorScheme.BLUE.as_stylesheet(True) if b else '') def isFrozen(self): return self.text_edit.isReadOnly() @@ -224,12 +221,6 @@ class PayToEdit(QObject, Logger, GenericInputHandler): else: self.set_payment_identifier(self.text_edit.toPlainText()) - # self.set_payment_identifier(text) - # if self.app.clipboard().text() and self.app.clipboard().text().strip() == self.payment_identifier.text: - # # user pasted from clipboard - # self.logger.debug('from clipboard') - # if self.payment_identifier.error: - # self.send_tab.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error) - def _on_edit_timer(self): - self.set_payment_identifier(self.text_edit.toPlainText()) + if not self.isFrozen(): + self.set_payment_identifier(self.text_edit.toPlainText()) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 7ee6cfcf8..dd8eddc85 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -22,7 +22,7 @@ from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .paytoedit import InvalidPaymentIdentifier from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit, get_iconname_camera, get_iconname_qrcode, - read_QIcon) + read_QIcon, ColorScheme) from .confirm_tx_dialog import ConfirmTxDialog if TYPE_CHECKING: @@ -192,6 +192,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.send_button.setEnabled(bool(self.amount_e.get_amount()) and pi_valid and not pi_error) def do_paste(self): + self.logger.debug('do_paste') try: self.payto_e.try_payment_identifier(self.app.clipboard().text()) except InvalidPaymentIdentifier as e: @@ -308,6 +309,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.format_amount_and_units(frozen_bal) def do_clear(self): + self.logger.debug('do_clear') self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False) self.max_button.setChecked(False) self.payto_e.do_clear() @@ -315,7 +317,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): w.setVisible(False) for e in [self.message_e, self.amount_e, self.fiat_send_e]: e.setText('') - self.set_field_style(e, None, False) for e in [self.save_button, self.send_button]: e.setEnabled(False) self.window.update_status() @@ -333,18 +334,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.show_message(error) self.do_clear() - def set_field_style(self, w, text, validated): - from .util import ColorScheme - if validated is None: - style = ColorScheme.LIGHTBLUE.as_stylesheet(True) - elif validated is True: - style = ColorScheme.GREEN.as_stylesheet(True) - else: - style = ColorScheme.RED.as_stylesheet(True) - if text is not None: - w.setStyleSheet(style) - else: - w.setStyleSheet('') + def set_field_validated(self, w, *, validated: Optional[bool] = None): + if validated is not None: + w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True)) def lock_fields(self, *, lock_recipient: Optional[bool] = None, @@ -363,6 +355,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.message_e.setFrozen(lock_description) def update_fields(self): + self.logger.debug('update_fields') pi = self.payto_e.payment_identifier self.clear_button.setEnabled(True) @@ -384,11 +377,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return lock_recipient = pi.type != PaymentIdentifierType.SPK \ - and not (pi.type == PaymentIdentifierType.EMAILLIKE and pi.is_state(PaymentIdentifierState.NOT_FOUND)) - lock_max = pi.is_amount_locked() \ - or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] + and not (pi.type == PaymentIdentifierType.EMAILLIKE and pi.state in [PaymentIdentifierState.NOT_FOUND,PaymentIdentifierState.NEED_RESOLVE]) + lock_amount = pi.is_amount_locked() + lock_max = lock_amount or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] self.lock_fields(lock_recipient=lock_recipient, - lock_amount=pi.is_amount_locked(), + lock_amount=lock_amount, lock_max=lock_max, lock_description=False) if lock_recipient: @@ -402,10 +395,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.amount_e.setAmount(amount) for w in [self.comment_e, self.comment_label]: w.setVisible(bool(comment)) - self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated) - self.set_field_style(self.message_e, description, validated) - self.set_field_style(self.amount_e, amount, validated) - self.set_field_style(self.fiat_send_e, amount, validated) + self.set_field_validated(self.payto_e, validated=validated) self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) self.save_button.setEnabled(not pi.is_error()) @@ -420,20 +410,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger): if self.payto_e.payment_identifier.need_resolve(): self.prepare_for_send_tab_network_lookup() self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit) - # update fiat amount (and reset max) - self.amount_e.textEdited.emit("") - self.window.show_send_tab() # FIXME: why is this here? def on_resolve_done(self, pi): # TODO: resolve can happen while typing, we don't want message dialogs to pop up # currently we don't set error for emaillike recipients to avoid just that + self.logger.debug('payment identifier resolve done') if pi.error: self.show_error(pi.error) self.do_clear() return self.update_fields() - # for btn in [self.send_button, self.clear_button, self.save_button]: - # btn.setEnabled(True) def get_message(self): return self.message_e.text() @@ -480,10 +466,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.amount_e.get_amount() or 0 def on_finalize_done(self, pi): + self.update_fields() if pi.error: self.show_error(pi.error) return - self.update_fields() invoice = pi.get_invoice(self.get_amount(), self.get_message()) self.pending_invoice = invoice self.logger.debug(f'after finalize invoice: {invoice!r}') diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 0f348809f..f183360b0 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -245,8 +245,9 @@ class PaymentIdentifier(Logger): self.logger.debug(f'PI state {self._state} -> {state}') self._state = state - def is_state(self, state: 'PaymentIdentifierState'): - return self._state == state + @property + def state(self): + return self._state def need_resolve(self): return self._state == PaymentIdentifierState.NEED_RESOLVE From 3a1e5244b886b61e8db77561dabc9a34c7650e15 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 13:25:09 +0200 Subject: [PATCH 17/33] qt: fix enable/disable max button for openalias and restrict openalias to address only --- electrum/gui/qt/send_tab.py | 8 ++++++-- electrum/payment_identifier.py | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index dd8eddc85..5709c0a2c 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -207,7 +207,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def spend_max(self): assert self.payto_e.payment_identifier is not None - assert self.payto_e.payment_identifier.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE] + assert self.payto_e.payment_identifier.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, + PaymentIdentifierType.OPENALIAS] if run_hook('abort_send', self): return outputs = self.payto_e.payment_identifier.get_onchain_outputs('!') @@ -379,7 +380,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): lock_recipient = pi.type != PaymentIdentifierType.SPK \ and not (pi.type == PaymentIdentifierType.EMAILLIKE and pi.state in [PaymentIdentifierState.NOT_FOUND,PaymentIdentifierState.NEED_RESOLVE]) lock_amount = pi.is_amount_locked() - lock_max = lock_amount or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] + lock_max = lock_amount \ + or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, + PaymentIdentifierType.LNADDR, PaymentIdentifierType.EMAILLIKE] + self.lock_fields(lock_recipient=lock_recipient, lock_amount=lock_amount, lock_max=lock_max, diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index f183360b0..837780815 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -401,7 +401,8 @@ class PaymentIdentifier(Logger): 'WARNING: the alias "{}" could not be validated via an additional ' 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) try: - scriptpubkey = self.parse_output(address) + assert bitcoin.is_address(address) + scriptpubkey = bytes.fromhex(bitcoin.address_to_script(address)) self._type = PaymentIdentifierType.OPENALIAS self.spk = scriptpubkey self.set_state(PaymentIdentifierState.AVAILABLE) From 0cbf403f8bf460432fd44df66800c760c1290799 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 13:50:42 +0200 Subject: [PATCH 18/33] use NamedTuple for payment identifier gui fields --- electrum/gui/qt/send_tab.py | 18 +++++++++--------- electrum/payment_identifier.py | 20 +++++++++++++------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 5709c0a2c..71e93d832 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -389,17 +389,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger): lock_max=lock_max, lock_description=False) if lock_recipient: - recipient, amount, description, comment, validated = pi.get_fields_for_GUI() - if recipient: - self.payto_e.setText(recipient) - if description: - self.message_e.setText(description) + fields = pi.get_fields_for_GUI() + if fields.recipient: + self.payto_e.setText(fields.recipient) + if fields.description: + self.message_e.setText(fields.description) self.lock_fields(lock_description=True) - if amount: - self.amount_e.setAmount(amount) + if fields.amount: + self.amount_e.setAmount(fields.amount) for w in [self.comment_e, self.comment_label]: - w.setVisible(bool(comment)) - self.set_field_validated(self.payto_e, validated=validated) + w.setVisible(bool(fields.comment)) + self.set_field_validated(self.payto_e, validated=fields.validated) self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) self.save_button.setEnabled(not pi.is_error()) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 837780815..2dd35b2b2 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -182,6 +182,7 @@ class PaymentIdentifierState(IntEnum): MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX INVALID_AMOUNT = 53 # Specified amount not accepted + class PaymentIdentifierType(IntEnum): UNKNOWN = 0 SPK = 1 @@ -194,6 +195,15 @@ class PaymentIdentifierType(IntEnum): OPENALIAS = 8 LNADDR = 9 + +class FieldsForGUI(NamedTuple): + recipient: Optional[str] + amount: Optional[int] + description: Optional[str] + validated: Optional[bool] + comment: Optional[int] + + class PaymentIdentifier(Logger): """ Takes: @@ -610,7 +620,7 @@ class PaymentIdentifier(Logger): assert bitcoin.is_address(address) return address - def get_fields_for_GUI(self): + def get_fields_for_GUI(self) -> FieldsForGUI: recipient = None amount = None description = None @@ -646,11 +656,6 @@ class PaymentIdentifier(Logger): amount = pr.get_amount() description = pr.get_memo() validated = not pr.has_expired() - # note: allow saving bip70 reqs, as we save them anyway when paying them - #for btn in [self.send_button, self.clear_button, self.save_button]: - # btn.setEnabled(True) - # signal to set fee - #self.amount_e.textEdited.emit("") elif self.spk: pass @@ -667,7 +672,8 @@ class PaymentIdentifier(Logger): if label and not description: description = label - return recipient, amount, description, comment, validated + return FieldsForGUI(recipient=recipient, amount=amount, description=description, + comment=comment, validated=validated) def _get_bolt11_fields(self, bolt11_invoice): """Parse ln invoice, and prepare the send tab for it.""" From 5cc7948eeef926c07d198606f6b56a454365654c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 13:52:01 +0200 Subject: [PATCH 19/33] fix bip70 potentially not returning gui fields tuple --- electrum/payment_identifier.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 2dd35b2b2..878d85115 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -651,11 +651,11 @@ class PaymentIdentifier(Logger): pr = self.bip70_data if pr.error: self.error = pr.error - return - recipient = pr.get_requestor() - amount = pr.get_amount() - description = pr.get_memo() - validated = not pr.has_expired() + else: + recipient = pr.get_requestor() + amount = pr.get_amount() + description = pr.get_memo() + validated = not pr.has_expired() elif self.spk: pass From fbb37d6fae6c3db322142623096caf861dd191b3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 15:16:32 +0200 Subject: [PATCH 20/33] payment_identifier: add DOMAINLIKE payment identifier type, support domainlike -> openalias --- electrum/gui/qt/paytoedit.py | 2 +- electrum/gui/qt/send_tab.py | 8 ++++--- electrum/payment_identifier.py | 40 ++++++++++++++++++++-------------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 9d5bd2513..418cc9cd6 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -216,7 +216,7 @@ class PayToEdit(QObject, Logger, GenericInputHandler): # pushback timer if timer active or PI needs resolving pi = PaymentIdentifier(self.send_tab.wallet, self.text_edit.toPlainText()) - if pi.need_resolve() or self.edit_timer.isActive(): + if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive(): self.edit_timer.start() else: self.set_payment_identifier(self.text_edit.toPlainText()) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 71e93d832..67ab35b71 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -363,7 +363,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): if pi.is_multiline(): self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False) - self.set_field_style(self.payto_e, True if not pi.is_valid() else None, False) + self.set_field_validated(self.payto_e, validated=pi.is_valid()) # TODO: validated used differently here than openalias self.save_button.setEnabled(pi.is_valid()) self.send_button.setEnabled(pi.is_valid()) self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '') @@ -378,11 +378,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return lock_recipient = pi.type != PaymentIdentifierType.SPK \ - and not (pi.type == PaymentIdentifierType.EMAILLIKE and pi.state in [PaymentIdentifierState.NOT_FOUND,PaymentIdentifierState.NEED_RESOLVE]) + and not (pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE] \ + and pi.state in [PaymentIdentifierState.NOT_FOUND, PaymentIdentifierState.NEED_RESOLVE]) lock_amount = pi.is_amount_locked() lock_max = lock_amount \ or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, - PaymentIdentifierType.LNADDR, PaymentIdentifierType.EMAILLIKE] + PaymentIdentifierType.LNADDR, PaymentIdentifierType.EMAILLIKE, + PaymentIdentifierType.DOMAINLIKE] self.lock_fields(lock_recipient=lock_recipient, lock_amount=lock_amount, diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 878d85115..157042182 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -162,7 +162,8 @@ def is_uri(data: str) -> bool: RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' -RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' +RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b' +RE_DOMAIN = r'\b([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b' class PaymentIdentifierState(IntEnum): @@ -194,6 +195,7 @@ class PaymentIdentifierType(IntEnum): EMAILLIKE = 7 OPENALIAS = 8 LNADDR = 9 + DOMAINLIKE = 10 class FieldsForGUI(NamedTuple): @@ -235,6 +237,7 @@ class PaymentIdentifier(Logger): self.spk = None # self.emaillike = None + self.domainlike = None self.openalias_data = None # self.bip70 = None @@ -284,9 +287,7 @@ class PaymentIdentifier(Logger): return self.is_multiline() and self._is_max def is_amount_locked(self): - if self._type == PaymentIdentifierType.SPK: - return False - elif self._type == PaymentIdentifierType.BIP21: + if self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('amount')) elif self._type == PaymentIdentifierType.BIP70: return True # TODO always given? @@ -303,9 +304,7 @@ class PaymentIdentifier(Logger): return True elif self._type == PaymentIdentifierType.MULTILINE: return True - elif self._type == PaymentIdentifierType.EMAILLIKE: - return False - elif self._type == PaymentIdentifierType.OPENALIAS: + else: return False def is_error(self) -> bool: @@ -384,6 +383,10 @@ class PaymentIdentifier(Logger): self._type = PaymentIdentifierType.EMAILLIKE self.emaillike = text self.set_state(PaymentIdentifierState.NEED_RESOLVE) + elif re.match(RE_DOMAIN, text): + self._type = PaymentIdentifierType.DOMAINLIKE + self.domainlike = text + self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif self.error is None: truncated_text = f"{text[:100]}..." if len(text) > 100 else text self.error = f"Unknown payment identifier:\n{truncated_text}" @@ -397,7 +400,7 @@ class PaymentIdentifier(Logger): @log_exceptions async def _do_resolve(self, *, on_finished=None): try: - if self.emaillike: + if self.emaillike or self.domainlike: # TODO: parallel lookup? data = await self.resolve_openalias() if data: @@ -405,11 +408,12 @@ class PaymentIdentifier(Logger): self.logger.debug(f'OA: {data!r}') name = data.get('name') address = data.get('address') - self.contacts[self.emaillike] = ('openalias', name) + key = self.emaillike if self.emaillike else self.domainlike + self.contacts[key] = ('openalias', name) if not data.get('validated'): self.warning = _( 'WARNING: the alias "{}" could not be validated via an additional ' - 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) + 'security check, DNSSEC, and thus may not be correct.').format(key) try: assert bitcoin.is_address(address) scriptpubkey = bytes.fromhex(bitcoin.address_to_script(address)) @@ -419,7 +423,7 @@ class PaymentIdentifier(Logger): except Exception as e: self.error = str(e) self.set_state(PaymentIdentifierState.NOT_FOUND) - else: + elif self.emaillike: lnurl = lightning_address_to_url(self.emaillike) try: data = await request_lnurl(lnurl) @@ -433,6 +437,8 @@ class PaymentIdentifier(Logger): # NOTE: any other exception is swallowed here (e.g. DNS error) # as the user may be typing and we have an incomplete emaillike self.set_state(PaymentIdentifierState.NOT_FOUND) + else: + self.set_state(PaymentIdentifierState.NOT_FOUND) elif self.bip70: from . import paymentrequest data = await paymentrequest.get_payment_request(self.bip70) @@ -627,14 +633,16 @@ class PaymentIdentifier(Logger): validated = None comment = None - if self.emaillike and self.openalias_data: + if (self.emaillike or self.domainlike) and self.openalias_data: + key = self.emaillike if self.emaillike else self.domainlike address = self.openalias_data.get('address') name = self.openalias_data.get('name') - recipient = self.emaillike + ' <' + address + '>' + description = name + recipient = key + ' <' + address + '>' validated = self.openalias_data.get('validated') if not validated: self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' - 'security check, DNSSEC, and thus may not be correct.').format(self.emaillike) + 'security check, DNSSEC, and thus may not be correct.').format(key) elif self.bolt11 and self.wallet.has_lightning(): recipient, amount, description = self._get_bolt11_fields(self.bolt11) @@ -689,8 +697,8 @@ class PaymentIdentifier(Logger): return pubkey, amount, description async def resolve_openalias(self) -> Optional[dict]: - key = self.emaillike - # TODO: below check needed? we already matched RE_EMAIL + key = self.emaillike if self.emaillike else self.domainlike + # TODO: below check needed? we already matched RE_EMAIL/RE_DOMAIN # if not (('.' in key) and ('<' not in key) and (' ' not in key)): # return None parts = key.split(sep=',') # assuming single line From 6b57743c3ecc360d2a1ac78a4ae33407ddbc1ae6 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 17:21:57 +0200 Subject: [PATCH 21/33] send_tab: add LNURLp range as tooltip on amount field --- electrum/gui/qt/send_tab.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 67ab35b71..f20b4d871 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -316,10 +316,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.payto_e.do_clear() for w in [self.comment_e, self.comment_label]: w.setVisible(False) - for e in [self.message_e, self.amount_e, self.fiat_send_e]: - e.setText('') - for e in [self.save_button, self.send_button]: - e.setEnabled(False) + for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]: + w.setText('') + w.setToolTip('') + for w in [self.save_button, self.send_button]: + w.setEnabled(False) self.window.update_status() run_hook('do_clear', self) @@ -401,8 +402,19 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.amount_e.setAmount(fields.amount) for w in [self.comment_e, self.comment_label]: w.setVisible(bool(fields.comment)) + if fields.comment: + self.comment_e.setToolTip(_('Max comment length: %d characters') % fields.comment) self.set_field_validated(self.payto_e, validated=fields.validated) + # LNURLp amount range and comment tooltip + if pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] \ + and pi.state == PaymentIdentifierState.LNURLP_FINALIZE \ + and pi.lnurl_data.min_sendable_sat != pi.lnurl_data.max_sendable_sat: + self.amount_e.setToolTip(_('Amount must be between %d and %d sat.') \ + % (pi.lnurl_data.min_sendable_sat, pi.lnurl_data.max_sendable_sat)) + else: + self.amount_e.setToolTip('') + self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) self.save_button.setEnabled(not pi.is_error()) From 3df13b8ce46a6f7d2301fb7249b9826303d94fa0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 17:32:02 +0200 Subject: [PATCH 22/33] qt: disallow save of LNURLp/LnAddr --- electrum/gui/qt/send_tab.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index f20b4d871..5cdf4592d 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -406,7 +406,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.comment_e.setToolTip(_('Max comment length: %d characters') % fields.comment) self.set_field_validated(self.payto_e, validated=fields.validated) - # LNURLp amount range and comment tooltip + # LNURLp amount range if pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] \ and pi.state == PaymentIdentifierState.LNURLP_FINALIZE \ and pi.lnurl_data.min_sendable_sat != pi.lnurl_data.max_sendable_sat: @@ -416,7 +416,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.amount_e.setToolTip('') self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) - self.save_button.setEnabled(not pi.is_error()) + self.save_button.setEnabled(not pi.is_error() and not pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]) def _handle_payment_identifier(self): self.update_fields() From febb2222d462bc7894312532aecd7baf33354a91 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 26 Jun 2023 17:40:15 +0200 Subject: [PATCH 23/33] send_tab: simplify lock_max --- electrum/gui/qt/send_tab.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 5cdf4592d..ed99e4984 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -382,10 +382,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): and not (pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE] \ and pi.state in [PaymentIdentifierState.NOT_FOUND, PaymentIdentifierState.NEED_RESOLVE]) lock_amount = pi.is_amount_locked() - lock_max = lock_amount \ - or pi.type in [PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP, - PaymentIdentifierType.LNADDR, PaymentIdentifierType.EMAILLIKE, - PaymentIdentifierType.DOMAINLIKE] + lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21] self.lock_fields(lock_recipient=lock_recipient, lock_amount=lock_amount, @@ -416,7 +413,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.amount_e.setToolTip('') self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) - self.save_button.setEnabled(not pi.is_error() and not pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]) + self.save_button.setEnabled(not pi.is_error() and pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]) def _handle_payment_identifier(self): self.update_fields() From 49dab82efa2d785f49b5b83e800dfd6a33e3b680 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 27 Jun 2023 11:43:00 +0200 Subject: [PATCH 24/33] send_tab: add spinner for network lookup indication --- electrum/gui/icons/spinner.gif | Bin 0 -> 15209 bytes electrum/gui/qt/send_tab.py | 28 ++++++++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 electrum/gui/icons/spinner.gif diff --git a/electrum/gui/icons/spinner.gif b/electrum/gui/icons/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..0dbdd7819b8f558635edb04edadd2b2fe6b37e77 GIT binary patch literal 15209 zcmcKBc|6qn|M&5kF_; zH#K{Cc)kDaJw1Z1y<2;BZnm$dZ+&AOFNe?1%O4sXa&mEc{pPi`rFC6>9i2wk*3_Pz zo^9`FU;n%wdO9>EIYo7cYWcPDiLnVgTf64l&ELL#1O1~P@o#Kq`qox@y5{;j6qK02 zz=w#hLE#9HJP7~&1Hb>7AeN1mR9`Y)=t5V%f47%rPJgj=daj)TlKLuYjl@~RQ=}@N z`S4MkD-I)Tb0VA;-~?8sgdVNaOxoc_rMd9HMf`lDPv8R$PKE>rpFYI|jt{$>PGXbr ziOGpdBOy**EGP=$la|P+aFyhTMAb5U^Ykth7ABoW35#88HI25m1?c5!>|bg&Qj8ckfjLS-&I056K2~Jn!Z#Zoh1H5GiBZ9wuF2^pV$H@V%%B zxrt=Y(4<2~YU>-%u72k6+5>kA&+3c0RAN~FR_wH$`vCrFy!Hy>_L)-M3Gsk|JUb&q z`J+}(j%^BM!MR7e64mI%M!H7RcSa%H9j{Gm^dShe%*tWGCz^vaQ(=>GO}J-&4k?me zv9}(D$X2;@dB1S?(uljL&RllqDbx_6t6wjMJel;+bsmQyP>WG2FAVJ*NL!sm|5{C;1v+1@HTN?VVP%C2nOV^yd!uEb(Hl>hsDcooyVECX ziz!$&9{N;Dy!_Os9ITa55}3v4s}22_0L~4`x@JntPiDT|=)f(}FDZ%QDPAtn<+;r0 zO#NykBQA4XuA1P?Z+3u$5UP@m%sleIvY%T{j;|=2IArwXN>pC$RqlXk7EG?i8qhULA+Zooy3VcSQfSImN8iGleLJ;M{qD=z7t}bp+^j%WVvDvnkm<4G zjTk?8wpJ40IJiUZ$zNWR* zD(?h(dra>`6mJt!xK+{TPJJSw@|g=B%6zLPO4f%hwvV|y&m=Up6)SBgXi8LlU3^>a z;rN*F;GyOu2v=E_Zcd;@Ft}G^yPv-^PI+9YPwqtd#`k$E&J z3KDvdFYtlh_Vv{N>4?vp?C{4k7HSNl2^`fM zLxHjcXi$-K@^52}+_&ErQ_Gs0t}dX>{m0D$Oz8J3zE;<_Uqxp&IIc>0huje|WYfI< zD7HkUd^px?y?C6+RrXHqPT5z~g~tgitxH!|H@YTcv_lI^PF+a7^Nl6$ELuXHj}7Z8 zH#XpY@QGvVaAQ#Nag^twC&|ODZ&)+d_DdXws_viXOV-4?y}|S0(qdC+H$0r=!g@R~ zM>K8dTprMHzYq*+Cpp$htu;`SDt^eE`vz)SBG^cYL1do;t0WbORZAqAIUR(4t8i2E zFSfMcOWcK>gPyfuh>M(VgsH>2G40DJ9^O24nMNeaE^a^8zV6lzrzuF~nK0gshYotO z&3nb&wId*-qcQApASR9O8R^j=mK?#p$XYT7TaJA^9qJ!fYcs_|AH7>{Zx4-CeXvbe zg3L@;LI^Ho*CsQFb%_)viKz!3FeRE^IYQ$AJ?ylSvWO}Xy(_wSCLX=g<8mJr<;=J* zuM%D^5vNG?TW}C$c4#(hi(zguLGRepN-_;ZCNX3SQ3C1@Bis-r3!gLd|MOEg& ztdvHBo3!uFH?W!4QdyhGRF?aZ+I5gP&QOGGksyYFc{GyTG0NhH?R zMpIM5rCj7{p1U&!)OYgGaaTDfPAD9<6^!W3jhm{2vC`IB?|!7#LQ&N*z(eHz*sQ)qhO%rB{*BLcJ2DsbmHHufZn}|+ zu|j51I+qa=E;x~EK2HuFe})3TRN4LIn-e!&)c0gKOMt<)h&bQV+?1qb12&v@{N-r< zZQ-0xt1eRfkSiq-8Kj`n%F|N$wWd;Xmn+K*V{cT7%jMOzk=Pn;HL^tA>?}Rm za<`6+tFOziT<_ZOm=9NLKSfZ^Z+xsLX7<(${OmIqXxhTqV`A3IeP0xG$w^A|!|Xeo z<S(MGTNx*ut+d-PU9KUSmrf`SUm@8KmH3v<0pgn&S>*AHyk14Ghp@xnU{ENY3fd@YlOs+6b&kC7?bheV#BW{OG*HD9ncQeJrLwf4G z^TIKlemYK@;F-gu}XGvJdqD4R!cs6O$$Pr3cX zE;eX83f2eaBQwdO zBIoBAr7Gu_h!XT^oQ_%3LCu)GV~(a%h2{hbPBBN!&2pQ+3U-PuRPJw2KCl|>jOweF zDM+#wbBMDPXV;KI30?_#mJSoEqQhe^eJng)kX~{%9W4pQ9^`r*O^4YTq%Y)vnlIDC zO)Cg_xl)uV>8o1)m)oIkrfv&vv6GSR!8e)P+cHcG)?`tvi9)Z63!{BWVaEDX&r2iY zF$G!YeUxZtvTTFE*Np8GWy{mA*|&QnnHl5Rq--eos~iI060+m&?R3b4m6(?-7#`}p zHA=m#0=^;9sJV2uX5~~;=@{CgwMkP!h6FJV5qgW{iZXudGn`2Lwh< z+)MFp#rqN}BV^f?-!NLs-ppORX_kJt3Vzm1*uM1qfBG2sr>Qg>rw9UeYMIH**jBHS1d)$QE(JqD5k!v(|tymqgJGHd> z*gjJJ!>H3S!PPxh{8_Em&@UGc4H}2ZCfn=mawc@x)n2#CEqW7p{Bc++PilPh*`q5`5%6&ALSt#?Xie(-3)jm1VtXPZ9)h0QA;2|!Lj5$hS_JwC$~t|wB0`{{;F<@=LaOc$*qS`&i%YiCq*-;!2#|2C%} zc>-FRdpCCbNA=dlQ1et_&D-Qu)^8U+JXF|Ig~s*mzj=|dYqjbP3G7|$Yy7lH$JTpr zWG(qFotfc$&I}@`(j#3}68zR`QJ1whMo9zX9(P{Y0h0xd-2SbiT{1L)$gDxzUQg%} zyQW9{+O<-}6;~|ve8JAQ!;k%;VC&gFia887n}kr#3-7BE7sK77So*cu zP01r7FJ3^`TSk3=NidCfGb4|o+GH<-{4FO^6w5z}q02FmR@UJa4?s1M`c>TC0}R;4 zeh`;xOf~BB#Wuv#shrg$LV}%=#L}Q!6f2Rs!#!Qx4BHPz(;Z@<7_eIuq4?fhqpKrj3$r_<}_J*^z4YF~Ge`|T4) zYSbgnUe!WrH^?RCe4k4%wOwK6==_*0l`F5D*rk?eI(MF5%ve(zf)G^F4tWGz5+&zd zoCLR=uW=6+otV)R$w=pavsdUq?gGj1MyaEd!By>-9V6PB17VhLzzFYj*-5=8Y>5Ys zGxL*6M)(pb=T5g3EVAoA;Z%uA&EVm`Fe=MC)Dn@oF~kvP0@_9wV&=9H2g_0JD>O4` zP&$$bwr?TxRzx(; z82g8aI`%f6Q83nrcCbSWicc8+Em69bFA(Ad@!pz_r#i4wy}hqX$Vxk7gm|$Hpp(a? z>lW>bdEQly7@lx@E3(^pgQ#+dQt$41bHYRVAZ7rEy4TKsO0 z4^Y_T8|V;qqszP~K5g|(oum!<=NKBl+(7Q}Z;W|pxRxJ3){iSbn7+rVq!Amf%uMRG z<9pCJXsv+$PTTw98B;5ZQ&4#9TKyJ|qY?e3L&K@fP z3D~ZOxO1nI*GE&5T=;TbjJ(d36p&HcPoz-%z&gmJ@5=v zKnLA9&*BYE3Pm>4x6iX)SnEIY>MOT&AxXg2@+@3tzf`en2N@bCX&U2!j&~%{3x#!J zJo44`l}H6zsL!t)v~DC>7HNxrUSz%*jl0ZDdh%%zSw8)K9&v!hd9f_7gJ&dCS$6HU z0x4a!x#dB@_Q|} z!nn1m$Qd6V+-(UEgWArs&&%(gz&>pXa$+NZQu&)$;&K<(B&PiaG(O-}Ed&`~*J{Y`UFBTI_ag0< zK%-sNkm9~j2rA`?RaDtKGtBCk;Bh-8Xs)d!u9y?UzO$RZH|~DkJB6v$q5TJsEQ3?z zRs(L}V5UJ2V$Wn2SUyViYWkHaVh78i|?ry4p-$#R}P3K zltQt1&Zdo4?5L4^V$LMI?HT_=&bGsDdA1*4E0&qH-{JSZA_M0Re){EgnzVA8-S)_Z zT`M?N2DI{khEE|vTK$6NGx;q4ynp~)+;%n8`tC5=<;U_v`q$im%8ogqDU zu^dK;u%@}jFYG|3qD-J9IT=cOnVf~2sQ5#PlY-~)lVQ1_b8nL z8zt)P5*y+HJ~9vYSS!(XL8RiasJtPPbb5`qJUpw5m(>6n0YceBbHroVrCGq+jHycD z_Krpm^dhzJ1`*PuQ`~Rb^smwFjVo+S98y}qGhcH#GwOQ4<~JH{x0_4uayo8e_QdG$ zHGhL99=N>g++NXZWJsxMyV5~Ml2aR%mJf3=Pc>3;6@p|W6I_&^SM|C0Jd#mMnlme2 zyKlx=5ys2T!n_pw+H`@3d`G`Tr(6fVPT2)sNI z#8d9Vf0UpL!B?dhpS}*){R2kDzk(6Su_Pi1Sc^bF-I|I(PX%lQWCJwYin7391cU@O zAiyI)=+;^U%mjo4tOJ7XpSmoNZ@1PWP;&txfxQVpx|L>u%?J$7f3O*W(wg=s7)x^P zjZwOb1KfRD^~9n2KG5|9&bOo8Vh8e>oL-aK@T{z*u2#vz>{D z^`YC}!|44T#te>MU<}XuHyDMP+avuj3rwz#;^b|p7`|D zdlN=D;-Q)a0ONH4MxozfY~O@Y;J=5F{dXAKH(`|d6-LGN$3rY^mTLfv-hTt5%y$@t zHen3^e+FajpJ6Ox0bmqh*@BVnAHyi7HJty0!}*w$H*6CdR@BPhYOY_5J|P77*SMP} z^#^^e-%R#;S)`3*x>nI7y{d$ZcJBpS@Y)-CTAX{5DX+_p)J@k3Z`6GIKv~|dct4T$ z**~cQuX*CSWJs^4xnx>q(f)lKH~Z^e_)nCgcHIOtvoA%I@3;9@YGJf)YHB@Hd`0P< z{;`|z*`iYi*87VLGe3JwT3z=F()ME#+G(*{Pvh}D@snSXsr$HJcxPt$Iu}jYiDt<> zt5&WC*+xaG?@*DiEaiFXf@qBIY>)ctISUOx3hfekx~9GnAW6*i*Um0$2Sc|Lx*T|7 zW(r9##7JNp7=(Lv23@`_z#A2&a#xJ$O9)RZ6cm`K1L{3?(qw91s83gD_xcVESst6j z_0Nl^a%>flT41dsx20nt!F6tl5O}xe!`mO;^zKzCRAp{BQgAN*lUYl?FN6zee|F>e zv2T#BENi=Yc8R;mFQ@wX9_!>Xa}hvLNpc&fxvyb-)V5`7i!0~77*H`15ek{2p^~f4 zqg9_TCtuQ%164q5JSO*;FH_NUT{udK03x(NgeQH^jK^X=p8UiD#`nYNvhAT6{#PV? zL6Q5|j|&8KHFsty>iCXg6JhFf#RT&hjkr9Kjoiz(xD6yDEQ`a0DlEY}5zk|N%aCvRt~txQOJSi>UwD?A9ju+wkEjwQt{>`S&9ac}se2(`8GYBw<07-h)j`N^ zm9*U~7qbdQT3!df=d3HY%A&VRe<8PkO|mh;*SU-E%o(>(Q-1n`RmLh$-HllT1B~vq zn5Y<)^l}zc0vSCEdQzqLIpe1CqIc-4m6=kz!~HG6VxvX3fT`G^Ov`t~wK5T66m|Q# z`GwWyKnF1Q&k2fic=q#{feAGBatfGBO|GCkk-}F#2V*y9T6 zAJ@uF7&lb}c-*{M{$m(7GwQ$DSYl630x#MCHimE7D1>xR^rug7cK)!j1PuRSBkvCz z#RCj5-)-dmDWhTl8)Y{$ssPik85RG#jhy&DY!v@H8}WZ_qby*f)OQ>0{%oTx;a3~C zGHTZMj0*nAM)IbO3;)1IvI&0buWjVT8E<7&4hNgEu>ao1OSJ!BW7?*TOaE?T^xxTN z1lXtx*jWAl#755FZ3I&2_t7Z(tBneqHop81Hp>2gv9aKPu(9Yr*vRysN8`^nUIT38 zrv2a9co=LUzGWll|Jufk|1UObglt$Vm_DKRtOQ;|AAToqt@>EOL8Ap+tH+%nYEyHr z@47zw1NKu)tc_wup(XDV*xSuo%+k=?2$ZeXKqZm7g?EN%Q6WDl&r@*E6Dfu56QUh$d=LAH^yA1Lj39?nI5uf_OE zB52CsG2ms&RD|Y=`VN^lXP!U8KsqA{%uo!)tQZ8}sJ{Yh%MIIi*~B&ld5iZmG7M%% zW!1C?35?}~aG%&MYCcAQXCHnC@#Ab~+vk%0NWnTRNQe-r)zT{Wq}C4i#HcD{~jb)Zel;O)x$A<-l9c5OMAw;@E zy$dBEoNAfSQcJjbIGUQvAFY4K#-578STh#Lx)96Ll^n$c(8#Qtnh1l{{X?(R?)xn; zYoh^T{uC2@>aInRIh9MP1tPs{P(H~>_^}s z4`AeGRRw6=e8sa_Re{2~RaJpkJb;dyRTY?xe_2%lAc2QGKv>b?KEclNqpC_#f3K>r?^P85@~5hb-Kwfm*dJAu z_g~}jm#WIXRaLiu{M)Ju`{m9k1>6~bR8{LgS5>L+RTccxo$-IFs{g}y-1%2`Mv@8c zZ^q-#RTThoI}`VRtg6n0@8gmEm+|Nf`8ObM0#%jw=kZwaKUP)Ot*ROgf2bz@*HxAG zpO44itE$wmK+3H@wg$#y2>_(?-;778pQ`Hq|8hLS{J$)}WIESDw`ZDE<(ZhQ%2isltk?;Vr99(N>)y|=YrC~rU)R>&F=nLfg3pH5 z41mh)6nhn3YPN&4x9e1Q+cPOqZwZN;_fy%8k7;Rp+nPQIq_)nA?g}N|h~Lxxj9G&G zAf!bC^V<2j=@AbotZ1BrvH^bhMs%o1J_ z-e0&!<7;IN@1rmJQO}k^oqObhDzy~Sld0tmceJ7@;GyxTS1}aoDDScoqSQ!znjy}o ztY=_=p)#|KR!HXbPX zE|zZ~f>@O!ZUPdswZmxE8QlxDrtXnMVmu5~v1pV{)~--#H%sKcC-skr`-Wb!EB85a zh|flAHRkT(_9FUjSvLE4Ijh_FsQ55C_%}*F!9f5$g8zi zTmftaR`t5Z;&e_!xkHl<7>!FuOQ+I`MJ!Dc988=bDkM_g)f6YAy|PE$&gi<&Ir=j3 z7VUq890Tr*EE;U@@KRahUZiIpZOPMkZvF)xSC|`wm)X1{>u&7)gtYv->Lx!~XNKML7O>*%p4-Zhyw(gphL>`Jy+dHSMzer;2AXyuH7e_9 zGUa{nQAbM`C5G_(n0*R~1A`vTfq8eZy5e8SR^fRPOuQ}b&DO?!hY|*hno&-7exuPc zj_{Pk2e(_M5opG#6_EmJU{+*HWQj2qibjGF=2b$PFbtCnfxx_TX1HEK9QHewe=4oO zngs5Tz?$5AlmpaHAeC;eNnj}gUIJ1AOajXiP;>JZ3Czu{cR8C=64-^Ck8*w|Gb3wj zO#)4J^Q^gfhy*4npeC?T0W3EUnwu9%pxFKcJO84z0&iN4lQv5$)c8kf6*PCRIvqE0 zq4NipWxAVdavmvG{`@FstF->xn*6=Ae)=!gBuiY+B@5tDjyXQBJBn&&g*joS-o|hv<=s z!W(fzQn?rlG#Cn6ekH1X(C1l`omjbguvAz~bM(6=Q|#~+5hce&>l;L}tdqoH)+(*z z*vjd?%%m3Qdhu2ZS&yAj6K(40nU9>r8aqt1KV0LL@@7KUB{jS@^FgF(WTgiO%@hYz zG*$^sgr^Q?1+=bWBn3G}9?jg__{e?AK@k4}a!u_ZeTUaEu7(HHuRVS~b@r1=^;5(g z(@rw$JvejzuB>xkmtJ(Rq?`q&0V_gTSYi7{;A0YKcxm3=aYKKY7P&+}ZWy8H^(n@F zUkYQJOYpD&Hu{jfUn>*7$;LF?I3F_|*Z%ULV*(KrJ+Wm?R1GeRNr-|}8mEBljY@9!3nksNHKgTsp`Vwz z8>pK*m7Nzm?2Yb0tF=g_8tfH;Z`?np)ei5SR%Q5(PsgM~Uv-Qr=-!lTDQE_zl)fnu zL5a6`&K2?#Z|w~f8e*E^q}nw9c9P;-L#m)~w6CAjRL(Umz=_b;YY0mn#44@*4(#@E zo=iCDftP0duw2D5R6`u8Q8)Y&q;-8xjdnlKWH=wBI@8m7g>c6gViE0jX0BKy-&5tL zmV4IwVVhqb^@PcSE#CyPu3yB&u2)D_8zkpT4x6hm2_*(Iz z)-lX`W3VQ*ZgH^XN`=qN0YTf7=1~p!V5`DPpJA3JiQbp5ZO`(qw+ktILf`UASH6U2 z+@!Cg&M3wOUVobQ{%ow9g^$OY!uji88+y0pcpbvmbZc5<^HaYZyxGi`Ja?7sM3Ylew|;$($q5 z6FOs2i#@Z+pDSibY8FYtc4R(}{zSx`aRbD*Dk^(|BUq!^Vfu7+qYS2>aKbO_1av3? zQh*H~s}knfS&WcT@YEV9>pSHy-p*D?q+7L2?oTF*;epGg;$k?jU^R_P^LdmC?zrYg z%DYI%mM}q2y52^Y(w?T~7u$qos9ZP9X(9@^3!HQ!i#fmHgNaz7?Vkwnwq-s@3Pbh% z#eQLHZurFtazfw?i|CvFG=&a_aC39Am@ccq6EF*>PV)4&S5-ZQag0mEC^36FneCl! zVqjw^RpHBEgVNVzOsj~PGKWK(LyJ#37i>=H*pt3PA}Uhdp_1q%h^ZdAHnT)vR_2;) zvKQ?U^2`M5-nv6kCu#zuYDirW0V9)BG;T?9X_wYg$^8Vndv<%=oXqufE<<^Ss`HH( zgy=Q!uDJzKH&ijIu)ATt^%e9i9==DH_3mk-yK=(!jCR=`hzk!NQjsvVHQKh<-_nKj zD3@D>S?3IZrPmZh>G~50I#I`DXo#CZkQ{+h&F$6$FVaK~qX(;4?q{ne!>7V5`JKX{ zb&Q=imx*j>UuwU}YMCJn^1F<(zs|`2@Rg9w1LS62-CUBuB@$2)n2|s+-MT(*o*w^n zcl`OwAe%?WKYbJAA2|5)fZY5YkTK;?H%QsB%VgG}`B4mR5`khj@`8e~Wv!;rZ5$b$U7#HOmJ>@@f(JUYSHYfsL#5jB#<^F z!4s|<9eTpu@}VE9Q}!C|O0%<9iXDG=rDsk+vcZjHQYKa+6?Hj95K(NO`b0T{F|;bklF6qw#wDJKHl3KDTJlgQiq%--X{%Bl{Y4q+GGG)(;E5BZ@yz z;1CK7Wa|Lv{Zz(hy~;n1YBPkLFY+2UGtF*G?c@rvi?_k}|b=>r5Xrv} z&a{ze-@-B^;HS&Vha)_9S(A6oX5~lZQWWj7lUiJsb<=Vkm=mkOM(}*Y8GkjY0bPZJ zI@rB41u2fR!9oyF8>`j_aor2-?nSoe$=aXL6f2mzmEzvXD0Y~T_Q7Zv-=|f{RKw;d zK2a2Lg%BPWZiGyCVF}R9SX^o=L~)&->&h`qpgAUzmD~qdlPV&{5Qp1w7G_e&Md+B3CW(==vV$0BN@rZzJ0c%KxMFlzZk*?@ONE!B28z);ay6FK=fwzF(h?q;*C ze1^8Ds9iptxRgIya$~ZdufCTK?b^|mDcXznBQ}k|3p_-@i@hhRx)nOQnq^@N$n1|k zp48jK)2hSj3E*;i*~GQtr$hwqu_eCqJ8w~(X_)FE8fNp>aRFf>z^5S-P+N& zJHwKmwrp@@SV2|AtbIh8?RH+Lo?Bt+6fvZ94IM0<&&XxNUx7?I6+K363=|39I-qz~ zo=+^CTX;QSH)QAm^sV>fc+t!=McY#r+qQwV9!My5)FO{!&)qo27?@nwy)9gsq4{?I zMxbH(3B{AsP=ZnT*#nHXirqrYsaRD{#L=6~5XcL8$n z#)i77yiN1S5Is%ve8|edmZVH*&O$6dG3K}v`^Y%bvgQ5$k`vpZ6RR6dyR`T#5*%LY zPJI|;QIo@Z!2*B7nB;}G%|G{X8W(Si?nvt}!25-xM2W^t*Y{El0DPardtktBN`HhMMiQ zRF!I^B#Tqyud3L%zG8G{95HvbQL%3^Aaq3BU}1W?KBCMeee#57?L74&+r~AGru?$a`tHM{ejEuH4qy%68rClI8gf1uy^J zOP;XL*JT1Nu9y^z8~`2Pe$;LM=`#1yQK6UY(HXR;ONk)XTbGp0l$98LxR?9qf*vww zVMrrLu*MU;iZJkEao-rPgUZfolntoTdGz8PJO2Y8b*I^>V+rbU_nJ=bKETTlqjEep zBA?Rr;rHdWtTNs^+&Ul&PS2u2%s{nr6|uG_h_{fGMk!| z9VsAv!ev}I=iyb8>~BXLKBztN!nnbrOU`Flux^+pb?5b^BfsI(Dz7c99MQVxD2!US ze7O42oMYoa;{ldlEd@%OJ?}GdqXVqhS?;INd!-zQ;rw+Yo zQ_COWu63yy!Hlnor`oyt6GFFLI&H(V!jVuhz4mDF2)eVfv}EU+#8Ldg;Wk0~wv>YA zy)Q!Q-GByjn7?OQR}ohJ&5;hWT;$swgiN9Bq$#8|jl~aZd#8nM@^Bj6<@>JSv+%_E1vdvuO2;KDCqQo7aTnJw2&SKRP zwfaD960<$n_;8|e_O0rx@>(fYHd^7LN=fv4; Date: Tue, 27 Jun 2023 12:03:05 +0200 Subject: [PATCH 25/33] payment_identifier: move amount_range into payment_identifier --- electrum/gui/qt/send_tab.py | 8 +++----- electrum/payment_identifier.py | 9 +++++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index ee1af66d5..f83ca4040 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -418,11 +418,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.set_field_validated(self.payto_e, validated=fields.validated) # LNURLp amount range - if pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR] \ - and pi.state == PaymentIdentifierState.LNURLP_FINALIZE \ - and pi.lnurl_data.min_sendable_sat != pi.lnurl_data.max_sendable_sat: - self.amount_e.setToolTip(_('Amount must be between %d and %d sat.') \ - % (pi.lnurl_data.min_sendable_sat, pi.lnurl_data.max_sendable_sat)) + if fields.amount_range: + amin, amax = fields.amount_range + self.amount_e.setToolTip(_('Amount must be between %d and %d sat.') % (amin, amax)) else: self.amount_e.setToolTip('') diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 157042182..8667d9263 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -204,6 +204,7 @@ class FieldsForGUI(NamedTuple): description: Optional[str] validated: Optional[bool] comment: Optional[int] + amount_range: Optional[tuple[int, int]] class PaymentIdentifier(Logger): @@ -632,6 +633,7 @@ class PaymentIdentifier(Logger): description = None validated = None comment = None + amount_range = None if (self.emaillike or self.domainlike) and self.openalias_data: key = self.emaillike if self.emaillike else self.domainlike @@ -650,10 +652,13 @@ class PaymentIdentifier(Logger): elif self.lnurl and self.lnurl_data: domain = urllib.parse.urlparse(self.lnurl).netloc recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>" - amount = self.lnurl_data.min_sendable_sat if self.lnurl_data.min_sendable_sat else None description = self.lnurl_data.metadata_plaintext if self.lnurl_data.comment_allowed: comment = self.lnurl_data.comment_allowed + if self.lnurl_data.min_sendable_sat: + amount = self.lnurl_data.min_sendable_sat + if self.lnurl_data.min_sendable_sat != self.lnurl_data.max_sendable_sat: + amount_range = (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) elif self.bip70 and self.bip70_data: pr = self.bip70_data @@ -681,7 +686,7 @@ class PaymentIdentifier(Logger): description = label return FieldsForGUI(recipient=recipient, amount=amount, description=description, - comment=comment, validated=validated) + comment=comment, validated=validated, amount_range=amount_range) def _get_bolt11_fields(self, bolt11_invoice): """Parse ln invoice, and prepare the send tab for it.""" From 81544fdaedcd013ebff6f3b569f1fbd34774cf04 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 27 Jun 2023 12:12:23 +0200 Subject: [PATCH 26/33] send_tab: simplify lock_recipient check --- electrum/gui/qt/send_tab.py | 6 +++--- electrum/payment_identifier.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index f83ca4040..670111281 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -392,9 +392,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.send_button.setEnabled(False) return - lock_recipient = pi.type != PaymentIdentifierType.SPK \ - and not (pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE] \ - and pi.state in [PaymentIdentifierState.NOT_FOUND, PaymentIdentifierState.NEED_RESOLVE]) + lock_recipient = pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, + PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70, + PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve() lock_amount = pi.is_amount_locked() lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21] diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 8667d9263..a2619339a 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -298,7 +298,7 @@ class PaymentIdentifier(Logger): elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): - return True + return False if self.need_finalize(): self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}') return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat) From eeda06e7518ef242a1b6aedae342963066974c69 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 30 Jun 2023 11:44:13 +0200 Subject: [PATCH 27/33] payment_identifier: fix error path for bip70 --- electrum/payment_identifier.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index a2619339a..ddbe3dddc 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -4,7 +4,7 @@ import urllib import re from decimal import Decimal, InvalidOperation from enum import IntEnum -from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING +from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple from . import bitcoin from .contacts import AliasNotFoundException @@ -204,7 +204,7 @@ class FieldsForGUI(NamedTuple): description: Optional[str] validated: Optional[bool] comment: Optional[int] - amount_range: Optional[tuple[int, int]] + amount_range: Optional[Tuple[int, int]] class PaymentIdentifier(Logger): @@ -291,7 +291,7 @@ class PaymentIdentifier(Logger): if self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('amount')) elif self._type == PaymentIdentifierType.BIP70: - return True # TODO always given? + return not self.need_resolve() # always fixed after resolve? elif self._type == PaymentIdentifierType.BOLT11: lnaddr = lndecode(self.bolt11) return bool(lnaddr.amount) @@ -442,9 +442,13 @@ class PaymentIdentifier(Logger): self.set_state(PaymentIdentifierState.NOT_FOUND) elif self.bip70: from . import paymentrequest - data = await paymentrequest.get_payment_request(self.bip70) - self.bip70_data = data - self.set_state(PaymentIdentifierState.MERCHANT_NOTIFY) + pr = await paymentrequest.get_payment_request(self.bip70) + if not pr.error: + self.bip70_data = pr + self.set_state(PaymentIdentifierState.MERCHANT_NOTIFY) + else: + self.error = pr.error + self.set_state(PaymentIdentifierState.ERROR) elif self.lnurl: data = await request_lnurl(self.lnurl) self.lnurl_data = data From b6010aad0fe3559fd9f4831bff4efb7be189949f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 3 Jul 2023 13:59:57 +0200 Subject: [PATCH 28/33] paytoedit: promote to QWidget and encapsulate QLineEdit vs QTextEdit juggling --- electrum/gui/qt/paytoedit.py | 181 ++++++++++++++++++++++++----------- electrum/gui/qt/send_tab.py | 4 +- 2 files changed, 126 insertions(+), 59 deletions(-) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 418cc9cd6..320e75999 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -24,19 +24,16 @@ # SOFTWARE. from functools import partial -from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtCore import Qt, QTimer, QSize from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtGui import QFontMetrics, QFont -from PyQt5.QtWidgets import QApplication, QTextEdit, QVBoxLayout +from PyQt5.QtWidgets import QApplication, QTextEdit, QWidget, QLineEdit, QStackedLayout, QSizePolicy -from electrum.i18n import _ from electrum.payment_identifier import PaymentIdentifier from electrum.logging import Logger -from .qrtextedit import ScanQRTextEdit -from .completion_text_edit import CompletionTextEdit from . import util from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent, ColorScheme @@ -54,10 +51,15 @@ class InvalidPaymentIdentifier(Exception): class ResizingTextEdit(QTextEdit): + textReallyChanged = pyqtSignal() + resized = pyqtSignal() + def __init__(self): QTextEdit.__init__(self) + self._text = '' + self.setAcceptRichText(False) + self.textChanged.connect(self.on_text_changed) document = self.document() - document.contentsChanged.connect(self.update_size) fontMetrics = QFontMetrics(document.defaultFont()) self.fontSpacing = fontMetrics.lineSpacing() margins = self.contentsMargins() @@ -67,49 +69,81 @@ class ResizingTextEdit(QTextEdit): self.verticalMargins += documentMargin * 2 self.heightMin = self.fontSpacing + self.verticalMargins self.heightMax = (self.fontSpacing * 10) + self.verticalMargins - self.single_line = True self.update_size() + def on_text_changed(self): + # QTextEdit emits spurious textChanged events + if self.toPlainText() != self._text: + self._text = self.toPlainText() + self.textReallyChanged.emit() + self.update_size() + def update_size(self): docLineCount = self.document().lineCount() - docHeight = max(1 if self.single_line else 3, docLineCount) * self.fontSpacing + docHeight = max(3, docLineCount) * self.fontSpacing h = docHeight + self.verticalMargins h = min(max(h, self.heightMin), self.heightMax) self.setMinimumHeight(int(h)) self.setMaximumHeight(int(h)) - if self.single_line: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) - else: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) - self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) + self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + self.resized.emit() + + def sizeHint(self) -> QSize: + return QSize(0, self.minimumHeight()) -class PayToEdit(QObject, Logger, GenericInputHandler): +class PayToEdit(QWidget, Logger, GenericInputHandler): paymentIdentifierChanged = pyqtSignal() + textChanged = pyqtSignal() def __init__(self, send_tab: 'SendTab'): - QObject.__init__(self, parent=send_tab) + QWidget.__init__(self, parent=send_tab) Logger.__init__(self) GenericInputHandler.__init__(self) + self._text = '' + self._layout = QStackedLayout() + self.setLayout(self._layout) + + def text_edit_changed(): + text = self.text_edit.toPlainText() + if self._text != text: + # sync and emit + self._text = text + self.line_edit.setText(text) + self.textChanged.emit() + + def text_edit_resized(): + self.update_height() + + def line_edit_changed(): + text = self.line_edit.text() + if self._text != text: + # sync and emit + self._text = text + self.text_edit.setPlainText(text) + self.textChanged.emit() + + self.line_edit = QLineEdit() + self.line_edit.textChanged.connect(line_edit_changed) self.text_edit = ResizingTextEdit() - self.text_edit.textChanged.connect(self._handle_text_change) + self.text_edit.textReallyChanged.connect(text_edit_changed) + self.text_edit.resized.connect(text_edit_resized) + + self.textChanged.connect(self._handle_text_change) + + self._layout.addWidget(self.line_edit) + self._layout.addWidget(self.text_edit) + + self.multiline = False + self._is_paytomany = False self.text_edit.setFont(QFont(MONOSPACE_FONT)) self.send_tab = send_tab self.config = send_tab.config - self.app = QApplication.instance() - - self.is_multiline = False - self.payto_scriptpubkey = None # type: Optional[bytes] - self.previous_payto = '' - # editor methods - self.setStyleSheet = self.text_edit.setStyleSheet - self.setText = self.text_edit.setText - self.setFocus = self.text_edit.setFocus - self.setToolTip = self.text_edit.setToolTip + # button handlers self.on_qr_from_camera_input_btn = partial( self.input_qr_from_camera, @@ -141,24 +175,46 @@ class PayToEdit(QObject, Logger, GenericInputHandler): self.payment_identifier = None - def set_text(self, text: str): - self.text_edit.setText(text) + @property + def multiline(self): + return self._multiline + + @multiline.setter + def multiline(self, b: bool) -> None: + if b is None: + return + self._multiline = b + self._layout.setCurrentWidget(self.text_edit if b else self.line_edit) + self.update_height() + + def update_height(self) -> None: + h = self._layout.currentWidget().sizeHint().height() + self.setMaximumHeight(h) + + def setText(self, text: str) -> None: + if self._text != text: + self.line_edit.setText(text) + self.text_edit.setText(text) + + def setFocus(self, reason=None) -> None: + if self.multiline: + self.text_edit.setFocus(reason) + else: + self.line_edit.setFocus(reason) - def update_editor(self): - if self.text_edit.toPlainText() != self.payment_identifier.text: - self.text_edit.setText(self.payment_identifier.text) - self.text_edit.single_line = not self.payment_identifier.is_multiline() - self.text_edit.update_size() + def setToolTip(self, tt: str) -> None: + self.line_edit.setToolTip(tt) + self.text_edit.setToolTip(tt) '''set payment identifier only if valid, else exception''' - def try_payment_identifier(self, text): + def try_payment_identifier(self, text) -> None: text = text.strip() pi = PaymentIdentifier(self.send_tab.wallet, text) if not pi.is_valid(): raise InvalidPaymentIdentifier('Invalid payment identifier') self.set_payment_identifier(text) - def set_payment_identifier(self, text): + def set_payment_identifier(self, text) -> None: text = text.strip() if self.payment_identifier and self.payment_identifier.text == text: # no change. @@ -167,60 +223,69 @@ class PayToEdit(QObject, Logger, GenericInputHandler): self.payment_identifier = PaymentIdentifier(self.send_tab.wallet, text) # toggle to multiline if payment identifier is a multiline - self.is_multiline = self.payment_identifier.is_multiline() - if self.is_multiline and not self._is_paytomany: + if self.payment_identifier.is_multiline() and not self._is_paytomany: self.set_paytomany(True) - # if payment identifier gets set externally, we want to update the text_edit + # if payment identifier gets set externally, we want to update the edit control # Note: this triggers the change handler, but we shortcut if it's the same payment identifier - self.update_editor() + self.setText(text) self.paymentIdentifierChanged.emit() def set_paytomany(self, b): self._is_paytomany = b - self.text_edit.single_line = not self._is_paytomany - self.text_edit.update_size() + self.multiline = b self.send_tab.paytomany_menu.setChecked(b) - def toggle_paytomany(self): + def toggle_paytomany(self) -> None: self.set_paytomany(not self._is_paytomany) def is_paytomany(self): return self._is_paytomany - def setFrozen(self, b): + def setReadOnly(self, b: bool) -> None: + self.line_edit.setReadOnly(b) self.text_edit.setReadOnly(b) - self.text_edit.setStyleSheet(ColorScheme.BLUE.as_stylesheet(True) if b else '') + + def isReadOnly(self): + return self.line_edit.isReadOnly() + + def setStyleSheet(self, stylesheet: str) -> None: + self.line_edit.setStyleSheet(stylesheet) + self.text_edit.setStyleSheet(stylesheet) + + def setFrozen(self, b) -> None: + self.setReadOnly(b) + self.setStyleSheet(ColorScheme.BLUE.as_stylesheet(True) if b else '') def isFrozen(self): - return self.text_edit.isReadOnly() + return self.isReadOnly() - def do_clear(self): - self.is_multiline = False + def do_clear(self) -> None: self.set_paytomany(False) - self.text_edit.setText('') + self.setText('') + self.setToolTip('') self.payment_identifier = None - def setGreen(self): + def setGreen(self) -> None: self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) - def setExpired(self): + def setExpired(self) -> None: self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) - def _handle_text_change(self): + def _handle_text_change(self) -> None: if self.isFrozen(): # if editor is frozen, we ignore text changes as they might not be a payment identifier # but a user friendly representation. return # pushback timer if timer active or PI needs resolving - pi = PaymentIdentifier(self.send_tab.wallet, self.text_edit.toPlainText()) + pi = PaymentIdentifier(self.send_tab.wallet, self._text) if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive(): self.edit_timer.start() else: - self.set_payment_identifier(self.text_edit.toPlainText()) + self.set_payment_identifier(self._text) - def _on_edit_timer(self): + def _on_edit_timer(self) -> None: if not self.isFrozen(): - self.set_payment_identifier(self.text_edit.toPlainText()) + self.set_payment_identifier(self._text) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 670111281..94838c248 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -73,7 +73,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")) payto_label = HelpLabel(_('Pay to'), msg) grid.addWidget(payto_label, 0, 0) - grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4) + grid.addWidget(self.payto_e, 0, 1, 1, 4) #completer = QCompleter() #completer.setCaseSensitivity(False) @@ -339,6 +339,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger): for w in [self.save_button, self.send_button]: w.setEnabled(False) self.window.update_status() + self.paytomany_menu.setChecked(self.payto_e.multiline) + run_hook('do_clear', self) def prepare_for_send_tab_network_lookup(self): From 9b41bcf992104ae2e8f2a547bad8037f5f1b9e31 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Fri, 7 Jul 2023 17:05:08 +0200 Subject: [PATCH 29/33] setFrozen: use light blue, black text is difficult to read on a blue background --- electrum/gui/qt/amountedit.py | 2 +- electrum/gui/qt/paytoedit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py index be1d20f86..4e3073d08 100644 --- a/electrum/gui/qt/amountedit.py +++ b/electrum/gui/qt/amountedit.py @@ -21,7 +21,7 @@ class FreezableLineEdit(QLineEdit): def setFrozen(self, b): self.setReadOnly(b) - self.setStyleSheet(ColorScheme.BLUE.as_stylesheet(True) if b else '') + self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '') self.frozen.emit() def isFrozen(self): diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 320e75999..3fb8f659f 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -256,7 +256,7 @@ class PayToEdit(QWidget, Logger, GenericInputHandler): def setFrozen(self, b) -> None: self.setReadOnly(b) - self.setStyleSheet(ColorScheme.BLUE.as_stylesheet(True) if b else '') + self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '') def isFrozen(self): return self.isReadOnly() From db6779bf048bba6402c956b8a8be75cd6c7f4478 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 7 Jul 2023 20:48:29 +0200 Subject: [PATCH 30/33] qt: show send tab if payment identifier is passed on the cmdline --- electrum/gui/qt/__init__.py | 1 + run_electrum | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 7891d1f21..f09af5368 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -393,6 +393,7 @@ class ElectrumGui(BaseElectrumGui, Logger): window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) window.activateWindow() if uri: + window.show_send_tab() window.send_tab.set_payment_identifier(uri) return window diff --git a/run_electrum b/run_electrum index 46aca8a67..413e7b4b8 100755 --- a/run_electrum +++ b/run_electrum @@ -365,12 +365,6 @@ def main(): if not config_options.get('verbosity'): warnings.simplefilter('ignore', DeprecationWarning) - # check if we received a valid payment identifier - uri = config_options.get('url') - if uri and not PaymentIdentifier(None, uri).is_valid(): - print_stderr('unknown command:', uri) - sys.exit(1) - config = SimpleConfig(config_options) cmdname = config.get('cmd') @@ -398,6 +392,12 @@ def main(): elif config.get('signet'): constants.set_signet() + # check if we received a valid payment identifier + uri = config_options.get('url') + if uri and not PaymentIdentifier(None, uri).is_valid(): + print_stderr('unknown command:', uri) + sys.exit(1) + if cmdname == 'daemon' and config.get("detach"): # detect lockfile. # This is not as good as get_file_descriptor, but that would require the asyncio loop From 7f766f6dfbc551e02a91f2c9b3efaf2abbc49061 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 7 Jul 2023 22:54:46 +0200 Subject: [PATCH 31/33] payment_identifiers: also match local contacts --- electrum/contacts.py | 11 +++++++++++ electrum/payment_identifier.py | 23 ++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/electrum/contacts.py b/electrum/contacts.py index fd941bfc8..febfcd57d 100644 --- a/electrum/contacts.py +++ b/electrum/contacts.py @@ -101,6 +101,17 @@ class Contacts(dict, Logger): } raise AliasNotFoundException("Invalid Bitcoin address or alias", k) + def by_name(self, name): + for k in self.keys(): + _type, addr = self[k] + if addr.casefold() == name.casefold(): + return { + 'name': addr, + 'type': _type, + 'address': k + } + return None + def fetch_openalias(self, config): self.alias_info = None alias = config.OPENALIAS_ID diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index ddbe3dddc..939a7fd2a 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -380,6 +380,18 @@ class PaymentIdentifier(Logger): self._type = PaymentIdentifierType.SPK self.spk = scriptpubkey self.set_state(PaymentIdentifierState.AVAILABLE) + elif contact := self.contacts.by_name(text): + if contact['type'] == 'address': + self._type = PaymentIdentifierType.BIP21 + self.bip21 = { + 'address': contact['address'], + 'label': contact['name'] + } + self.set_state(PaymentIdentifierState.AVAILABLE) + elif contact['type'] == 'openalias': + self._type = PaymentIdentifierType.EMAILLIKE + self.emaillike = contact['address'] + self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif re.match(RE_EMAIL, text): self._type = PaymentIdentifierType.EMAILLIKE self.emaillike = text @@ -681,13 +693,14 @@ class PaymentIdentifier(Logger): pass elif self.bip21: - recipient = self.bip21.get('address') - amount = self.bip21.get('amount') label = self.bip21.get('label') + address = self.bip21.get('address') + recipient = f'{label} <{address}>' if label else address + amount = self.bip21.get('amount') description = self.bip21.get('message') - # use label as description (not BIP21 compliant) - if label and not description: - description = label + # TODO: use label as description? (not BIP21 compliant) + # if label and not description: + # description = label return FieldsForGUI(recipient=recipient, amount=amount, description=description, comment=comment, validated=validated, amount_range=amount_range) From f980bd97b5029ada1d796de5b12172b12dfe1926 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sat, 8 Jul 2023 12:16:43 +0200 Subject: [PATCH 32/33] payment_identifier: factor out bip21 functions to bip21.py to break cyclic dependencies, parse bolt11 only once, store invoice internally instead of bolt11 string add is_onchain method to indicate if payment identifier can be paid onchain --- electrum/bip21.py | 127 ++++++++++++++++++++++ electrum/gui/kivy/main_window.py | 2 +- electrum/gui/kivy/uix/screens.py | 3 +- electrum/gui/qml/qeapp.py | 2 +- electrum/gui/qml/qeinvoice.py | 8 +- electrum/gui/qt/main_window.py | 3 +- electrum/invoices.py | 2 +- electrum/payment_identifier.py | 175 ++++++------------------------- electrum/tests/test_util.py | 2 +- electrum/transaction.py | 3 +- electrum/wallet.py | 3 +- 11 files changed, 174 insertions(+), 156 deletions(-) create mode 100644 electrum/bip21.py diff --git a/electrum/bip21.py b/electrum/bip21.py new file mode 100644 index 000000000..bcf6bd361 --- /dev/null +++ b/electrum/bip21.py @@ -0,0 +1,127 @@ +import urllib +import re +from decimal import Decimal +from typing import Optional + +from . import bitcoin +from .util import format_satoshis_plain +from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC +from .lnaddr import lndecode, LnDecodeException + +# note: when checking against these, use .lower() to support case-insensitivity +BITCOIN_BIP21_URI_SCHEME = 'bitcoin' +LIGHTNING_URI_SCHEME = 'lightning' + + +class InvalidBitcoinURI(Exception): + pass + + +def parse_bip21_URI(uri: str) -> dict: + """Raises InvalidBitcoinURI on malformed URI.""" + + if not isinstance(uri, str): + raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") + + if ':' not in uri: + if not bitcoin.is_address(uri): + raise InvalidBitcoinURI("Not a bitcoin address") + return {'address': uri} + + u = urllib.parse.urlparse(uri) + if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: + raise InvalidBitcoinURI("Not a bitcoin URI") + address = u.path + + # python for android fails to parse query + if address.find('?') > 0: + address, query = u.path.split('?') + pq = urllib.parse.parse_qs(query) + else: + pq = urllib.parse.parse_qs(u.query) + + for k, v in pq.items(): + if len(v) != 1: + raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') + + out = {k: v[0] for k, v in pq.items()} + if address: + if not bitcoin.is_address(address): + raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") + out['address'] = address + if 'amount' in out: + am = out['amount'] + try: + m = re.match(r'([0-9.]+)X([0-9])', am) + if m: + k = int(m.group(2)) - 8 + amount = Decimal(m.group(1)) * pow(Decimal(10), k) + else: + amount = Decimal(am) * COIN + if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: + raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") + out['amount'] = int(amount) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e + if 'message' in out: + out['message'] = out['message'] + out['memo'] = out['message'] + if 'time' in out: + try: + out['time'] = int(out['time']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e + if 'exp' in out: + try: + out['exp'] = int(out['exp']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e + if 'sig' in out: + try: + out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e + if 'lightning' in out: + try: + lnaddr = lndecode(out['lightning']) + except LnDecodeException as e: + raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e + amount_sat = out.get('amount') + if amount_sat: + # allow small leeway due to msat precision + if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") + address = out.get('address') + ln_fallback_addr = lnaddr.get_fallback_address() + if address and ln_fallback_addr: + if ln_fallback_addr != address: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") + + return out + + +def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], + *, extra_query_params: Optional[dict] = None) -> str: + if not bitcoin.is_address(addr): + return "" + if extra_query_params is None: + extra_query_params = {} + query = [] + if amount_sat: + query.append('amount=%s' % format_satoshis_plain(amount_sat)) + if message: + query.append('message=%s' % urllib.parse.quote(message)) + for k, v in extra_query_params.items(): + if not isinstance(k, str) or k != urllib.parse.quote(k): + raise Exception(f"illegal key for URI: {repr(k)}") + v = urllib.parse.quote(v) + query.append(f"{k}={v}") + p = urllib.parse.ParseResult( + scheme=BITCOIN_BIP21_URI_SCHEME, + netloc='', + path=addr, + params='', + query='&'.join(query), + fragment='' + ) + return str(urllib.parse.urlunparse(p)) diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index c3bcba6a7..590e7b0fb 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -87,7 +87,7 @@ Label.register( from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException -from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 73690c5af..d5191b97a 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -19,7 +19,8 @@ from electrum import bitcoin, constants from electrum import lnutil from electrum.transaction import tx_from_any, PartialTxOutput from electrum.util import TxMinedInfo, InvoiceError, format_time, parse_max_spend -from electrum.payment_identifier import parse_bip21_URI, BITCOIN_BIP21_URI_SCHEME, maybe_extract_lightning_payment_identifier, InvalidBitcoinURI +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, parse_bip21_URI, InvalidBitcoinURI +from electrum.payment_identifier import maybe_extract_lightning_payment_identifier from electrum.lnaddr import lndecode, LnInvoiceException from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from electrum.logging import Logger diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 24f861434..bb2245c4f 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica from electrum import version, constants from electrum.i18n import _ from electrum.logging import Logger, get_logger -from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue from electrum.network import Network diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index e9e3a51b0..171959dbd 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -16,9 +16,9 @@ from electrum.lnutil import format_short_channel_id, IncompatibleOrInsaneFeature 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, +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 @@ -526,7 +526,7 @@ class QEInvoiceParser(QEInvoice): self.validationSuccess.emit() return elif self._pi.type == PaymentIdentifierType.BOLT11: - lninvoice = Invoice.from_bech32(self._pi.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.')) @@ -539,7 +539,7 @@ class QEInvoiceParser(QEInvoice): 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 = Invoice.from_bech32(self._pi.bolt11) + lninvoice = self._pi.bolt11 self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() else: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 9b9ff53d1..cf70c2f83 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -57,7 +57,8 @@ from electrum.i18n import _ from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword, UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, AddTransactionException, os_chmod) -from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, PaymentIdentifier +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME +from electrum.payment_identifier import PaymentIdentifier from electrum.invoices import PR_PAID, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) diff --git a/electrum/invoices.py b/electrum/invoices.py index 61a90b781..d5bf7bfdc 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -7,7 +7,7 @@ import attr from .json_db import StoredObject, stored_in from .i18n import _ from .util import age, InvoiceError, format_satoshis -from .payment_identifier import create_bip21_uri +from .bip21 import create_bip21_uri from .lnutil import hex_to_bytes from .lnaddr import lndecode, LnAddr from . import constants diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 939a7fd2a..9b7719d64 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -9,14 +9,16 @@ from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple from . import bitcoin from .contacts import AliasNotFoundException from .i18n import _ +from .invoices import Invoice from .logging import Logger -from .util import parse_max_spend, format_satoshis_plain +from .util import parse_max_spend, format_satoshis_plain, InvoiceError from .util import get_asyncio_loop, log_exceptions from .transaction import PartialTxOutput from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures +from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME if TYPE_CHECKING: from .wallet import Abstract_Wallet @@ -34,125 +36,6 @@ def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: return None -# note: when checking against these, use .lower() to support case-insensitivity -BITCOIN_BIP21_URI_SCHEME = 'bitcoin' -LIGHTNING_URI_SCHEME = 'lightning' - - -class InvalidBitcoinURI(Exception): - pass - - -def parse_bip21_URI(uri: str) -> dict: - """Raises InvalidBitcoinURI on malformed URI.""" - - if not isinstance(uri, str): - raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") - - if ':' not in uri: - if not bitcoin.is_address(uri): - raise InvalidBitcoinURI("Not a bitcoin address") - return {'address': uri} - - u = urllib.parse.urlparse(uri) - if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: - raise InvalidBitcoinURI("Not a bitcoin URI") - address = u.path - - # python for android fails to parse query - if address.find('?') > 0: - address, query = u.path.split('?') - pq = urllib.parse.parse_qs(query) - else: - pq = urllib.parse.parse_qs(u.query) - - for k, v in pq.items(): - if len(v) != 1: - raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') - - out = {k: v[0] for k, v in pq.items()} - if address: - if not bitcoin.is_address(address): - raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") - out['address'] = address - if 'amount' in out: - am = out['amount'] - try: - m = re.match(r'([0-9.]+)X([0-9])', am) - if m: - k = int(m.group(2)) - 8 - amount = Decimal(m.group(1)) * pow(Decimal(10), k) - else: - amount = Decimal(am) * COIN - if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: - raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") - out['amount'] = int(amount) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e - if 'message' in out: - out['message'] = out['message'] - out['memo'] = out['message'] - if 'time' in out: - try: - out['time'] = int(out['time']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e - if 'exp' in out: - try: - out['exp'] = int(out['exp']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e - if 'sig' in out: - try: - out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e - if 'lightning' in out: - try: - lnaddr = lndecode(out['lightning']) - except LnDecodeException as e: - raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e - amount_sat = out.get('amount') - if amount_sat: - # allow small leeway due to msat precision - if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") - address = out.get('address') - ln_fallback_addr = lnaddr.get_fallback_address() - if address and ln_fallback_addr: - if ln_fallback_addr != address: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") - - return out - - -def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], - *, extra_query_params: Optional[dict] = None) -> str: - if not bitcoin.is_address(addr): - return "" - if extra_query_params is None: - extra_query_params = {} - query = [] - if amount_sat: - query.append('amount=%s' % format_satoshis_plain(amount_sat)) - if message: - query.append('message=%s' % urllib.parse.quote(message)) - for k, v in extra_query_params.items(): - if not isinstance(k, str) or k != urllib.parse.quote(k): - raise Exception(f"illegal key for URI: {repr(k)}") - v = urllib.parse.quote(v) - query.append(f"{k}={v}") - p = urllib.parse.ParseResult( - scheme=BITCOIN_BIP21_URI_SCHEME, - netloc='', - path=addr, - params='', - query='&'.join(query), - fragment='' - ) - return str(urllib.parse.urlunparse(p)) - - def is_uri(data: str) -> bool: data = data.lower() if (data.startswith(LIGHTNING_URI_SCHEME + ":") or @@ -279,7 +162,14 @@ class PaymentIdentifier(Logger): return self._state in [PaymentIdentifierState.AVAILABLE] def is_lightning(self): - return self.lnurl or self.bolt11 + return bool(self.lnurl) or bool(self.bolt11) + + def is_onchain(self): + if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.BIP70, + PaymentIdentifierType.OPENALIAS]: + return True + if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]: + return bool(self.bolt11) and bool(self.bolt11.get_address()) def is_multiline(self): return bool(self.multiline_outputs) @@ -293,8 +183,7 @@ class PaymentIdentifier(Logger): elif self._type == PaymentIdentifierType.BIP70: return not self.need_resolve() # always fixed after resolve? elif self._type == PaymentIdentifierType.BOLT11: - lnaddr = lndecode(self.bolt11) - return bool(lnaddr.amount) + return bool(self.bolt11.get_amount_sat()) elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): @@ -339,16 +228,12 @@ class PaymentIdentifier(Logger): else: self._type = PaymentIdentifierType.BOLT11 try: - lndecode(invoice_or_lnurl) - except LnInvoiceException as e: - self.error = _("Error parsing Lightning invoice") + f":\n{e}" - self.set_state(PaymentIdentifierState.INVALID) - return - except IncompatibleOrInsaneFeatures as e: - self.error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}" + self.bolt11 = Invoice.from_bech32(invoice_or_lnurl) + except InvoiceError as e: + self.error = self._get_error_from_invoiceerror(e) self.set_state(PaymentIdentifierState.INVALID) + self.logger.debug(f'Exception cause {e.args!r}') return - self.bolt11 = invoice_or_lnurl self.set_state(PaymentIdentifierState.AVAILABLE) elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): try: @@ -643,6 +528,16 @@ class PaymentIdentifier(Logger): assert bitcoin.is_address(address) return address + def _get_error_from_invoiceerror(self, e: 'InvoiceError') -> str: + error = _("Error parsing Lightning invoice") + f":\n{e!r}" + if e.args and len(e.args): + arg = e.args[0] + if isinstance(arg, LnInvoiceException): + error = _("Error parsing Lightning invoice") + f":\n{e}" + elif isinstance(arg, IncompatibleOrInsaneFeatures): + error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}" + return error + def get_fields_for_GUI(self) -> FieldsForGUI: recipient = None amount = None @@ -662,8 +557,8 @@ class PaymentIdentifier(Logger): self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' 'security check, DNSSEC, and thus may not be correct.').format(key) - elif self.bolt11 and self.wallet.has_lightning(): - recipient, amount, description = self._get_bolt11_fields(self.bolt11) + elif self.bolt11: + recipient, amount, description = self._get_bolt11_fields() elif self.lnurl and self.lnurl_data: domain = urllib.parse.urlparse(self.lnurl).netloc @@ -705,9 +600,8 @@ class PaymentIdentifier(Logger): return FieldsForGUI(recipient=recipient, amount=amount, description=description, comment=comment, validated=validated, amount_range=amount_range) - def _get_bolt11_fields(self, bolt11_invoice): - """Parse ln invoice, and prepare the send tab for it.""" - lnaddr = lndecode(bolt11_invoice) # + def _get_bolt11_fields(self): + lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr pubkey = lnaddr.pubkey.serialize().hex() for k, v in lnaddr.tags: if k == 'd': @@ -740,20 +634,17 @@ class PaymentIdentifier(Logger): if self.bip70: return self.bip70_data.has_expired() elif self.bolt11: - lnaddr = lndecode(self.bolt11) - return lnaddr.is_expired() + return self.bolt11.has_expired() elif self.bip21: expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0 return bool(expires) and expires < time.time() return False def get_invoice(self, amount_sat, message): - from .invoices import Invoice if self.is_lightning(): - invoice_str = self.bolt11 - if not invoice_str: + invoice = self.bolt11 + if not invoice: return - invoice = Invoice.from_bech32(invoice_str) if invoice.amount_msat is None: invoice.amount_msat = int(amount_sat * 1000) return invoice diff --git a/electrum/tests/test_util.py b/electrum/tests/test_util.py index 6ff020710..1767bc8bf 100644 --- a/electrum/tests/test_util.py +++ b/electrum/tests/test_util.py @@ -5,7 +5,7 @@ from electrum import util 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 electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from . import ElectrumTestCase, as_testnet diff --git a/electrum/transaction.py b/electrum/transaction.py index 09fdf7ce4..248642905 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -42,8 +42,7 @@ import copy from . import ecc, bitcoin, constants, segwit_addr, bip32 from .bip32 import BIP32Node -from .util import profiler, to_bytes, bfh, chunks, is_hex_str -from .payment_identifier import parse_max_spend +from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, diff --git a/electrum/wallet.py b/electrum/wallet.py index 4376d8f77..65475b251 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -58,7 +58,6 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex) -from .payment_identifier import create_bip21_uri, parse_max_spend from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold @@ -66,7 +65,7 @@ from .crypto import sha256d from . import keystore from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK, AddressIndexGeneric, CannotDerivePubkey) -from .util import multisig_type +from .util import multisig_type, parse_max_spend from .storage import StorageEncryptionVersion, WalletStorage from .wallet_db import WalletDB from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32 From ae8c4f1281f972151e50f067390f8aa39601cdf7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sat, 8 Jul 2023 12:19:37 +0200 Subject: [PATCH 33/33] payment_identifier: check if payment identifier is usable and enable/disable Send/Pay buttons --- electrum/gui/qt/send_tab.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 94838c248..2dfe8be09 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -426,8 +426,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): else: self.amount_e.setToolTip('') - self.send_button.setEnabled(bool(self.amount_e.get_amount()) and not pi.has_expired() and not pi.is_error()) - self.save_button.setEnabled(not pi.is_error() and pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]) + pi_unusable = pi.is_error() or (not self.wallet.has_lightning() and not pi.is_onchain()) + + self.send_button.setEnabled(not pi_unusable and bool(self.amount_e.get_amount()) and not pi.has_expired()) + self.save_button.setEnabled(not pi_unusable and pi.type not in [PaymentIdentifierType.LNURLP, + PaymentIdentifierType.LNADDR]) def _handle_payment_identifier(self): self.update_fields()