Browse Source

qt: refactor send_tab, paytoedit

master
Sander van Grieken 3 years ago
parent
commit
bde066f9ce
  1. 1
      electrum/gui/qt/amountedit.py
  2. 187
      electrum/gui/qt/paytoedit.py
  3. 226
      electrum/gui/qt/send_tab.py
  4. 15
      electrum/gui/qt/util.py
  5. 156
      electrum/payment_identifier.py

1
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

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

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

15
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,

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

Loading…
Cancel
Save