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