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: