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):