diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index 6270436a2..c3bcba6a7 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -86,9 +86,8 @@ Label.register( ) -from electrum.util import (NoDynamicFeeEstimates, NotEnoughFunds, - BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME, - UserFacingException) +from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException +from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 23556ac92..73690c5af 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -18,8 +18,8 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING, from electrum import bitcoin, constants from electrum import lnutil from electrum.transaction import tx_from_any, PartialTxOutput -from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, - InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME) +from electrum.util import TxMinedInfo, InvoiceError, format_time, parse_max_spend +from electrum.payment_identifier import parse_bip21_URI, BITCOIN_BIP21_URI_SCHEME, maybe_extract_lightning_payment_identifier, InvalidBitcoinURI from electrum.lnaddr import lndecode, LnInvoiceException from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from electrum.logging import Logger @@ -208,7 +208,7 @@ class SendScreen(CScreen, Logger): def set_bip21(self, text: str): try: - uri = parse_URI(text, self.app.on_pr, loop=self.app.asyncio_loop) + uri = parse_bip21_URI(text) # bip70 not supported except InvalidBitcoinURI as e: self.app.show_info(_("Error parsing URI") + f":\n{e}") return diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index bf41bd932..24f861434 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica from electrum import version, constants from electrum.i18n import _ from electrum.logging import Logger, get_logger -from electrum.util import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue from electrum.network import Network diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index fdb54b98f..7891d1f21 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -87,7 +87,7 @@ class OpenFileEventFilter(QObject): def eventFilter(self, obj, event): if event.type() == QtCore.QEvent.FileOpen: if len(self.windows) >= 1: - self.windows[0].handle_payment_identifier(event.url().toString()) + self.windows[0].set_payment_identifier(event.url().toString()) return True return False @@ -393,7 +393,7 @@ class ElectrumGui(BaseElectrumGui, Logger): window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) window.activateWindow() if uri: - window.handle_payment_identifier(uri) + window.send_tab.set_payment_identifier(uri) return window def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index afaecfb68..8bb6d9c1e 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -58,9 +58,10 @@ from electrum.i18n import _ from electrum.util import (format_time, get_asyncio_loop, UserCancelled, profiler, bfh, InvalidPassword, - UserFacingException, FailedToParsePaymentIdentifier, + UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, - AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod) + AddTransactionException, os_chmod) +from electrum.payment_identifier import FailedToParsePaymentIdentifier, BITCOIN_BIP21_URI_SCHEME from electrum.invoices import PR_PAID, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 964a729da..5076b5292 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -33,7 +33,8 @@ from PyQt5.QtGui import QFontMetrics, QFont from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout from electrum import bitcoin -from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier +from electrum.util import parse_max_spend +from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier from electrum.transaction import PartialTxOutput from electrum.bitcoin import opcodes, construct_script from electrum.logging import Logger @@ -49,20 +50,10 @@ if TYPE_CHECKING: from .send_tab import SendTab -RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' - frozen_style = "QWidget {border:none;}" normal_style = "QPlainTextEdit { }" -class PayToLineError(NamedTuple): - line_content: str - exc: Exception - idx: int = 0 # index of line - is_multiline: bool = False - - - class ResizingTextEdit(QTextEdit): def __init__(self): @@ -109,12 +100,9 @@ class PayToEdit(Logger, GenericInputHandler): self.amount_edit = self.send_tab.amount_e self.is_multiline = False - self.outputs = [] # type: List[PartialTxOutput] - self.errors = [] # type: List[PayToLineError] self.disable_checks = False self.is_alias = False self.payto_scriptpubkey = None # type: Optional[bytes] - self.lightning_invoice = None self.previous_payto = '' # editor methods self.setStyleSheet = self.editor.setStyleSheet @@ -180,6 +168,7 @@ class PayToEdit(Logger, GenericInputHandler): self.setText(text) def do_clear(self): + self.is_multiline = False self.set_paytomany(False) self.disable_checks = False self.is_alias = False @@ -194,58 +183,6 @@ class PayToEdit(Logger, GenericInputHandler): def setExpired(self): self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) - def parse_address_and_amount(self, line) -> PartialTxOutput: - try: - x, y = line.split(',') - except ValueError: - raise Exception("expected two comma-separated values: (address, amount)") from None - scriptpubkey = self.parse_output(x) - amount = self.parse_amount(y) - return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount) - - def parse_output(self, x) -> bytes: - try: - address = self.parse_address(x) - return bytes.fromhex(bitcoin.address_to_script(address)) - except Exception: - pass - try: - script = self.parse_script(x) - return bytes.fromhex(script) - except Exception: - pass - raise Exception("Invalid address or script.") - - def parse_script(self, x): - script = '' - for word in x.split(): - if word[0:3] == 'OP_': - opcode_int = opcodes[word] - script += construct_script([opcode_int]) - else: - bytes.fromhex(word) # to test it is hex data - script += construct_script([word]) - return script - - def parse_amount(self, x): - x = x.strip() - if not x: - raise Exception("Amount is empty") - if parse_max_spend(x): - return x - p = pow(10, self.amount_edit.decimal_point()) - try: - return int(p * Decimal(x)) - except decimal.InvalidOperation: - raise Exception("Invalid amount") - - def parse_address(self, line): - r = line.strip() - m = re.match('^'+RE_ALIAS+'$', r) - address = str(m.group(2) if m else r) - assert bitcoin.is_address(address) - return address - def _on_input_btn(self, text: str): self.setText(text) @@ -257,6 +194,7 @@ class PayToEdit(Logger, GenericInputHandler): if self.is_multiline and not self._is_paytomany: self.set_paytomany(True) self.text_edit.setText(text) + self.text_edit.setFocus() def on_timer_check_text(self): if self.editor.hasFocus(): @@ -265,149 +203,33 @@ class PayToEdit(Logger, GenericInputHandler): self._check_text(text, full_check=True) def _check_text(self, text, *, full_check: bool): - """ - side effects: self.is_multiline, self.errors, self.outputs - """ - if self.previous_payto == str(text).strip(): + """ side effects: self.is_multiline """ + text = str(text).strip() + if not text: + return + if self.previous_payto == text: return if full_check: - self.previous_payto = str(text).strip() - self.errors = [] - errors = [] + self.previous_payto = text if self.disable_checks: return - # filter out empty lines - lines = text.split('\n') - lines = [i for i in lines if i] - self.is_multiline = len(lines)>1 - - self.payto_scriptpubkey = None - self.lightning_invoice = None - self.outputs = [] - - if len(lines) == 1: - data = lines[0] - try: - self.send_tab.handle_payment_identifier(data, can_use_network=full_check) - except LNURLError as e: - self.logger.exception("") - self.send_tab.show_error(e) - except FailedToParsePaymentIdentifier: - pass - else: - return - # try "address, amount" on-chain format - try: - self._parse_as_multiline(lines, raise_errors=True) - except Exception as e: - pass - else: - return - # try address/script - try: - self.payto_scriptpubkey = self.parse_output(data) - except Exception as e: - errors.append(PayToLineError(line_content=data, exc=e)) - else: - self.send_tab.set_onchain(True) - self.send_tab.lock_amount(False) - return - if full_check: # network requests # FIXME blocking GUI thread - # try openalias - oa_data = self._resolve_openalias(data) - if oa_data: - self._set_openalias(key=data, data=oa_data) - return - # all parsing attempts failed, so now expose the errors: - if errors: - self.errors = errors - else: - # there are multiple lines - self._parse_as_multiline(lines, raise_errors=False) + pi = PaymentIdentifier(self.config, self.win.contacts, text) + self.is_multiline = bool(pi.multiline_outputs) + print('is_multiline', self.is_multiline) + self.send_tab.handle_payment_identifier(pi, can_use_network=full_check) - - def _parse_as_multiline(self, lines, *, raise_errors: bool): - outputs = [] # type: List[PartialTxOutput] + def handle_multiline(self, outputs): total = 0 is_max = False - for i, line in enumerate(lines): - try: - output = self.parse_address_and_amount(line) - except Exception as e: - if raise_errors: - raise - else: - self.errors.append(PayToLineError( - idx=i, line_content=line.strip(), exc=e, is_multiline=True)) - continue - outputs.append(output) + for output in outputs: if parse_max_spend(output.value): is_max = True else: total += output.value - if outputs: - self.send_tab.set_onchain(True) - + self.send_tab.set_onchain(True) self.send_tab.max_button.setChecked(is_max) - self.outputs = outputs - self.payto_scriptpubkey = None - if self.send_tab.max_button.isChecked(): self.send_tab.spend_max() else: self.amount_edit.setAmount(total if outputs else None) - self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs)) - - def get_errors(self) -> Sequence[PayToLineError]: - return self.errors - - def get_destination_scriptpubkey(self) -> Optional[bytes]: - return self.payto_scriptpubkey - - def get_outputs(self, is_max: bool) -> List[PartialTxOutput]: - if self.payto_scriptpubkey: - if is_max: - amount = '!' - else: - amount = self.send_tab.get_amount() - self.outputs = [PartialTxOutput(scriptpubkey=self.payto_scriptpubkey, value=amount)] - - return self.outputs[:] - - def _resolve_openalias(self, text: str) -> Optional[dict]: - key = text - key = key.strip() # strip whitespaces - if not (('.' in key) and ('<' not in key) and (' ' not in key)): - return None - parts = key.split(sep=',') # assuming single line - if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): - return None - try: - data = self.win.contacts.resolve(key) - except Exception as e: - self.logger.info(f'error resolving address/alias: {repr(e)}') - return None - return data or None - - def _set_openalias(self, *, key: str, data: dict) -> bool: - self.is_alias = True - self.setFrozen(True) - key = key.strip() # strip whitespaces - address = data.get('address') - name = data.get('name') - new_url = key + ' <' + address + '>' - self.setText(new_url) - - #if self.win.config.get('openalias_autoadd') == 'checked': - self.win.contacts[key] = ('openalias', name) - self.win.contact_list.update() - - if data.get('type') == 'openalias': - self.validated = data.get('validated') - if self.validated: - self.setGreen() - else: - self.setExpired() - else: - self.validated = None - return True + #self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs)) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index dffa15d31..c07851daf 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -5,8 +5,6 @@ import asyncio from decimal import Decimal from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any -from urllib.parse import urlparse - from PyQt5.QtCore import pyqtSignal, QPoint from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) @@ -15,15 +13,14 @@ from electrum import util, paymentrequest from electrum import lnutil from electrum.plugin import run_hook from electrum.i18n import _ -from electrum.util import (get_asyncio_loop, FailedToParsePaymentIdentifier, - InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds, - NoDynamicFeeEstimates, InvoiceError, parse_max_spend) + +from electrum.util import get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates, InvoiceError, parse_max_spend +from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier, InvalidBitcoinURI from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST + from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.logging import Logger -from electrum.lnaddr import lndecode, LnInvoiceException -from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit @@ -36,15 +33,9 @@ if TYPE_CHECKING: class SendTab(QWidget, MessageBoxMixin, Logger): - payment_request_ok_signal = pyqtSignal() - payment_request_error_signal = pyqtSignal() - lnurl6_round1_signal = pyqtSignal(object, object) - lnurl6_round2_signal = pyqtSignal(object) - clear_send_tab_signal = pyqtSignal() - show_error_signal = pyqtSignal(str) - - payment_request: Optional[paymentrequest.PaymentRequest] - _lnurl_data: Optional[LNURL6Data] = None + round_1_signal = pyqtSignal(object) + round_2_signal = pyqtSignal(object) + round_3_signal = pyqtSignal(object) def __init__(self, window: 'ElectrumWindow'): QWidget.__init__(self, window) @@ -60,8 +51,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.format_amount = window.format_amount self.base_unit = window.base_unit - self.payto_URI = None - self.payment_request = None # type: Optional[paymentrequest.PaymentRequest] + self.payment_identifier = None self.pending_invoice = None # A 4-column grid layout. All the stretch is in the last column. @@ -84,9 +74,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger): + _("Integers weights can also be used in conjunction with '!', " "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")) payto_label = HelpLabel(_('Pay to'), msg) - grid.addWidget(payto_label, 1, 0) - grid.addWidget(self.payto_e.line_edit, 1, 1, 1, 4) - grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4) + grid.addWidget(payto_label, 0, 0) + grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4) + grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4) #completer = QCompleter() #completer.setCaseSensitivity(False) @@ -97,9 +87,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger): + _( 'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.') description_label = HelpLabel(_('Description'), msg) - grid.addWidget(description_label, 2, 0) + grid.addWidget(description_label, 1, 0) self.message_e = SizedFreezableLineEdit(width=600) - grid.addWidget(self.message_e, 2, 1, 1, 4) + grid.addWidget(self.message_e, 1, 1, 1, 4) + + msg = _('Comment for recipient') + self.comment_label = HelpLabel(_('Comment'), msg) + grid.addWidget(self.comment_label, 2, 0) + self.comment_e = SizedFreezableLineEdit(width=600) + grid.addWidget(self.comment_e, 2, 1, 1, 4) + self.comment_label.hide() + self.comment_e.hide() msg = (_('The amount to be received by the recipient.') + ' ' + _('Fees are paid by the sender.') + '\n\n' @@ -129,11 +127,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) self.clear_button = EnterButton(_("Clear"), self.do_clear) self.paste_button = QPushButton() - self.paste_button.clicked.connect(lambda: self.payto_e._on_input_btn(self.window.app.clipboard().text())) + self.paste_button.clicked.connect(self.do_paste) self.paste_button.setIcon(read_QIcon('copy.png')) self.paste_button.setToolTip(_('Paste invoice from clipboard')) self.paste_button.setMaximumWidth(35) - grid.addWidget(self.paste_button, 1, 5) + grid.addWidget(self.paste_button, 0, 5) buttons = QHBoxLayout() buttons.addStretch(1) @@ -160,7 +158,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.invoice_list = InvoiceList(self) self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('') - menu.addAction(read_QIcon(get_iconname_camera()), _("Read QR code with camera"), self.payto_e.on_qr_from_camera_input_btn) menu.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), self.payto_e.on_qr_from_screenshot_input_btn) menu.addAction(read_QIcon("file.png"), _("Read invoice from file"), self.payto_e.on_input_file) @@ -186,17 +183,33 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.invoice_list.update() # after parented and put into a layout, can update without flickering run_hook('create_send_tab', grid) - self.payment_request_ok_signal.connect(self.payment_request_ok) - self.payment_request_error_signal.connect(self.payment_request_error) - self.lnurl6_round1_signal.connect(self.on_lnurl6_round1) - self.lnurl6_round2_signal.connect(self.on_lnurl6_round2) - self.clear_send_tab_signal.connect(self.do_clear) - self.show_error_signal.connect(self.show_error) + self.round_1_signal.connect(self.on_round_1) + self.round_2_signal.connect(self.on_round_2) + self.round_3_signal.connect(self.on_round_3) + + def do_paste(self): + text = self.window.app.clipboard().text() + if not text: + return + self.set_payment_identifier(text) + + def set_payment_identifier(self, text): + pi = PaymentIdentifier(self.config, self.window.contacts, text) + if pi.error: + self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error) + return + if pi.is_multiline(): + self.payto_e.set_paytomany(True) + self.payto_e.text_edit.setText(text) + else: + self.payto_e.setTextNoCheck(text) + self.handle_payment_identifier(pi, can_use_network=True) def spend_max(self): if run_hook('abort_send', self): return - outputs = self.payto_e.get_outputs(True) + amount = self.get_amount() + outputs = self.payment_identifier.get_onchain_outputs(amount) if not outputs: return make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction( @@ -297,15 +310,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.format_amount_and_units(frozen_bal) def do_clear(self): - self._lnurl_data = None self.max_button.setChecked(False) - self.payment_request = None - self.payto_URI = None self.payto_e.do_clear() self.set_onchain(False) - for e in [self.message_e, self.amount_e]: + for w in [self.comment_e, self.comment_label]: + w.setVisible(False) + for e in [self.message_e, self.amount_e, self.fiat_send_e]: e.setText('') - e.setFrozen(False) + self.set_field_style(e, None, False) for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: e.setEnabled(True) self.window.update_status() @@ -315,208 +327,101 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self._is_onchain = b self.max_button.setEnabled(b) - def lock_amount(self, b: bool) -> None: - self.amount_e.setFrozen(b) - self.max_button.setEnabled(not b) - def prepare_for_send_tab_network_lookup(self): self.window.show_send_tab() self.payto_e.disable_checks = True - for e in [self.payto_e, self.message_e]: - e.setFrozen(True) - self.lock_amount(True) + #for e in [self.payto_e, self.message_e]: + self.payto_e.setFrozen(True) for btn in [self.save_button, self.send_button, self.clear_button]: btn.setEnabled(False) self.payto_e.setTextNoCheck(_("please wait...")) - def payment_request_ok(self): - pr = self.payment_request - if not pr: - return - invoice = Invoice.from_bip70_payreq(pr, height=0) - if self.wallet.get_invoice_status(invoice) == PR_PAID: - self.show_message("invoice already paid") - self.do_clear() - self.payment_request = None - return - self.payto_e.disable_checks = True - if not pr.has_expired(): - self.payto_e.setGreen() - else: - self.payto_e.setExpired() - self.payto_e.setTextNoCheck(pr.get_requestor()) - self.amount_e.setAmount(pr.get_amount()) - self.message_e.setText(pr.get_memo()) - self.set_onchain(True) - self.max_button.setEnabled(False) - # note: allow saving bip70 reqs, as we save them anyway when paying them - for btn in [self.send_button, self.clear_button, self.save_button]: - btn.setEnabled(True) - # signal to set fee - self.amount_e.textEdited.emit("") - - def payment_request_error(self): - pr = self.payment_request - if not pr: - return - self.show_message(pr.error) - self.payment_request = None + def payment_request_error(self, error): + self.show_message(error) self.do_clear() - def on_pr(self, request: 'paymentrequest.PaymentRequest'): - self.payment_request = request - if self.payment_request.verify(self.window.contacts): - self.payment_request_ok_signal.emit() + def set_field_style(self, w, text, validated): + from .util import ColorScheme + if validated is None: + style = ColorScheme.LIGHTBLUE.as_stylesheet(True) + elif validated is True: + style = ColorScheme.GREEN.as_stylesheet(True) else: - self.payment_request_error_signal.emit() - - def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True): - try: - url = decode_lnurl(lnurl) - except LnInvoiceException as e: - self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") - return - if not can_use_network: - return - - async def f(): - try: - lnurl_data = await request_lnurl(url) - except LNURLError as e: - self.show_error_signal.emit(f"LNURL request encountered error: {e}") - self.clear_send_tab_signal.emit() - return - self.lnurl6_round1_signal.emit(lnurl_data, url) - - asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable - self.prepare_for_send_tab_network_lookup() - - def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str): - self._lnurl_data = lnurl_data - domain = urlparse(url).netloc - self.payto_e.setTextNoCheck(f"invoice from lnurl") - self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}") - self.amount_e.setAmount(lnurl_data.min_sendable_sat) - self.amount_e.setFrozen(False) - for btn in [self.send_button, self.clear_button]: - btn.setEnabled(True) - self.set_onchain(False) - - def set_bolt11(self, invoice: str): - """Parse ln invoice, and prepare the send tab for it.""" - try: - lnaddr = lndecode(invoice) - except LnInvoiceException as e: - self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") - return - except lnutil.IncompatibleOrInsaneFeatures as e: - self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") - return - - pubkey = lnaddr.pubkey.serialize().hex() - for k,v in lnaddr.tags: - if k == 'd': - description = v - break + style = ColorScheme.RED.as_stylesheet(True) + if text is not None: + w.setStyleSheet(style) + w.setReadOnly(True) else: - description = '' - self.payto_e.setFrozen(True) - self.payto_e.setTextNoCheck(pubkey) - self.payto_e.lightning_invoice = invoice - if not self.message_e.text(): + w.setStyleSheet('') + w.setReadOnly(False) + + def update_fields(self, pi): + recipient, amount, description, comment, validated = pi.get_fields_for_GUI(self.wallet) + if recipient: + self.payto_e.setTextNoCheck(recipient) + elif pi.multiline_outputs: + self.payto_e.handle_multiline(pi.multiline_outputs) + if description: self.message_e.setText(description) - if lnaddr.get_amount_sat() is not None: - self.amount_e.setAmount(lnaddr.get_amount_sat()) - self.set_onchain(False) - - def set_bip21(self, text: str, *, can_use_network: bool = True): - on_bip70_pr = self.on_pr if can_use_network else None - try: - out = util.parse_URI(text, on_bip70_pr) - except InvalidBitcoinURI as e: - self.show_error(_("Error parsing URI") + f":\n{e}") - return - self.payto_URI = out - r = out.get('r') - sig = out.get('sig') - name = out.get('name') - if (r or (name and sig)) and can_use_network: - self.prepare_for_send_tab_network_lookup() - return - address = out.get('address') - amount = out.get('amount') - label = out.get('label') - message = out.get('message') - lightning = out.get('lightning') - if lightning and (self.wallet.has_lightning() or not address): - self.handle_payment_identifier(lightning, can_use_network=can_use_network) - return - # use label as description (not BIP21 compliant) - if label and not message: - message = label - if address: - self.payto_e.setText(address) - if message: - self.message_e.setText(message) if amount: self.amount_e.setAmount(amount) - - def handle_payment_identifier(self, text: str, *, can_use_network: bool = True): - """Takes - Lightning identifiers: - * lightning-URI (containing bolt11 or lnurl) - * bolt11 invoice - * lnurl - Bitcoin identifiers: - * bitcoin-URI - and sets the sending screen. - """ - text = text.strip() - if not text: + for w in [self.comment_e, self.comment_label]: + w.setVisible(not bool(comment)) + self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated) + self.set_field_style(self.message_e, description, validated) + self.set_field_style(self.amount_e, amount, validated) + self.set_field_style(self.fiat_send_e, amount, validated) + + def handle_payment_identifier(self, pi, *, can_use_network: bool = True): + self.payment_identifier = pi + is_valid = pi.is_valid() + self.save_button.setEnabled(is_valid) + self.send_button.setEnabled(is_valid) + if not is_valid: return - if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): - if invoice_or_lnurl.startswith('lnurl'): - self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network) - else: - self.set_bolt11(invoice_or_lnurl) - elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'): - self.set_bip21(text, can_use_network=can_use_network) - else: - truncated_text = f"{text[:100]}..." if len(text) > 100 else text - raise FailedToParsePaymentIdentifier(f"Could not handle payment identifier:\n{truncated_text}") + self.update_fields(pi) + if can_use_network and pi.needs_round_1(): + coro = pi.round_1(on_success=self.round_1_signal.emit) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + self.prepare_for_send_tab_network_lookup() # update fiat amount self.amount_e.textEdited.emit("") self.window.show_send_tab() + def on_round_1(self, pi): + if pi.error: + self.show_error(pi.error) + self.do_clear() + return + self.update_fields(pi) + for btn in [self.send_button, self.clear_button, self.save_button]: + btn.setEnabled(True) + + def get_message(self): + return self.message_e.text() def read_invoice(self) -> Optional[Invoice]: if self.check_payto_line_and_show_errors(): return - try: - if not self._is_onchain: - invoice_str = self.payto_e.lightning_invoice - if not invoice_str: - return - invoice = Invoice.from_bech32(invoice_str) - if invoice.amount_msat is None: - amount_sat = self.get_amount() - if amount_sat: - invoice.amount_msat = int(amount_sat * 1000) - if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): - self.show_error(_('Lightning is disabled')) - return - return invoice - else: - outputs = self.read_outputs() - if self.check_onchain_outputs_and_show_errors(outputs): - return - message = self.message_e.text() - return self.wallet.create_invoice( - outputs=outputs, - message=message, - pr=self.payment_request, - URI=self.payto_URI) - except InvoiceError as e: - self.show_error(_('Error creating payment') + ':\n' + str(e)) + amount_sat = self.read_amount() + if not amount_sat: + self.show_error(_('No amount')) + return + + invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message()) + #except Exception as e: + if not invoice: + self.show_error('error getting invoice' + pi.error) + return + if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): + self.show_error(_('Lightning is disabled')) + if self.wallet.get_invoice_status(invoice) == PR_PAID: + # fixme: this is only for bip70 and lightning + self.show_error(_('Invoice already paid')) + return + #if not invoice.is_lightning(): + # if self.check_onchain_outputs_and_show_errors(outputs): + # return + return invoice def do_save_invoice(self): self.pending_invoice = self.read_invoice() @@ -536,41 +441,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # must not be None return self.amount_e.get_amount() or 0 - def _lnurl_get_invoice(self) -> None: - assert self._lnurl_data - amount = self.get_amount() - if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat): - self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.') - return - - async def f(): - try: - invoice_data = await callback_lnurl( - self._lnurl_data.callback_url, - params={'amount': self.get_amount() * 1000}, - ) - except LNURLError as e: - self.show_error_signal.emit(f"LNURL request encountered error: {e}") - self.clear_send_tab_signal.emit() - return - invoice = invoice_data.get('pr') - self.lnurl6_round2_signal.emit(invoice) - - asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable - self.prepare_for_send_tab_network_lookup() - - def on_lnurl6_round2(self, bolt11_invoice: str): - self._lnurl_data = None - invoice = Invoice.from_bech32(bolt11_invoice) - assert invoice.get_amount_sat() == self.get_amount(), (invoice.get_amount_sat(), self.get_amount()) + def on_round_2(self, pi): self.do_clear() - self.payto_e.setText(bolt11_invoice) + if pi.error: + self.show_error(pi.error) + self.do_clear() + return + self.update_fields(pi) + invoice = pi.get_invoice(self.wallet, self.get_amount(), self.get_message()) self.pending_invoice = invoice self.do_pay_invoice(invoice) + def on_round_3(self): + pass + def do_pay_or_get_invoice(self): - if self._lnurl_data: - self._lnurl_get_invoice() + pi = self.payment_identifier + if pi.needs_round_2(): + coro = pi.round_2(self.round_2_signal.emit, amount_sat=self.get_amount(), comment=self.message_e.text()) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) # TODO should be cancellable + self.prepare_for_send_tab_network_lookup() return self.pending_invoice = self.read_invoice() if not self.pending_invoice: @@ -600,12 +490,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): else: self.pay_onchain_dialog(invoice.outputs) - def read_outputs(self) -> List[PartialTxOutput]: - if self.payment_request: - outputs = self.payment_request.get_outputs() - else: - outputs = self.payto_e.get_outputs(self.max_button.isChecked()) - return outputs + def read_amount(self) -> List[PartialTxOutput]: + is_max = self.max_button.isChecked() + amount = '!' if is_max else self.get_amount() + return amount def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool: """Returns whether there are errors with outputs. @@ -629,34 +517,30 @@ class SendTab(QWidget, MessageBoxMixin, Logger): """Returns whether there are errors. Also shows error dialog to user if so. """ - pr = self.payment_request - if pr: - if pr.has_expired(): - self.show_error(_('Payment request has expired')) - return True + error = self.payment_identifier.get_error() + if error: + if not self.payment_identifier.is_multiline(): + err = error + self.show_warning( + _("Failed to parse 'Pay to' line") + ":\n" + + f"{err.line_content[:40]}...\n\n" + f"{err.exc!r}") + else: + self.show_warning( + _("Invalid Lines found:") + "\n\n" + error) + #'\n'.join([_("Line #") + + # f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})" + # for err in errors])) + return True - if not pr: - errors = self.payto_e.get_errors() - if errors: - if len(errors) == 1 and not errors[0].is_multiline: - err = errors[0] - self.show_warning(_("Failed to parse 'Pay to' line") + ":\n" + - f"{err.line_content[:40]}...\n\n" - f"{err.exc!r}") - else: - self.show_warning(_("Invalid Lines found:") + "\n\n" + - '\n'.join([_("Line #") + - f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})" - for err in errors])) + if self.payment_identifier.warning: + msg += '\n' + _('Do you wish to continue?') + if not self.question(msg): return True - if self.payto_e.is_alias and self.payto_e.validated is False: - alias = self.payto_e.toPlainText() - msg = _('WARNING: the alias "{}" could not be validated via an additional ' - 'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n' - msg += _('Do you wish to continue?') - if not self.question(msg): - return True + if self.payment_identifier.has_expired(): + self.show_error(_('Payment request has expired')) + return True return False # no errors @@ -740,9 +624,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def broadcast_thread(): # non-GUI thread - pr = self.payment_request - if pr and pr.has_expired(): - self.payment_request = None + if self.payment_identifier.has_expired(): return False, _("Invoice has expired") try: self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) @@ -752,13 +634,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return False, repr(e) # success txid = tx.txid() - if pr: - self.payment_request = None + if self.payment_identifier.needs_round_3(): refund_address = self.wallet.get_receiving_address() - coro = pr.send_payment_and_receive_paymentack(tx.serialize(), refund_address) - fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) - ack_status, ack_msg = fut.result(timeout=20) - self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") + coro = self.payment_identifier.round_3(tx.serialize(), refund_address) + asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) return True, txid # Capture current TL window; override might be removed on return @@ -804,3 +683,4 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.payto_e.setFocus() text = "\n".join([payto + ", 0" for payto in paytos]) self.payto_e.setText(text) + self.payto_e.setFocus() diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index a393c2bdb..1d5f5c2b9 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -961,6 +961,7 @@ class ColorScheme: YELLOW = ColorSchemeItem("#897b2a", "#ffff00") RED = ColorSchemeItem("#7c1111", "#f18c8c") BLUE = ColorSchemeItem("#123b7c", "#8cb3f2") + LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff") DEFAULT = ColorSchemeItem("black", "white") GRAY = ColorSchemeItem("gray", "gray") diff --git a/electrum/invoices.py b/electrum/invoices.py index 6f7300e36..61a90b781 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -7,6 +7,7 @@ import attr from .json_db import StoredObject, stored_in from .i18n import _ from .util import age, InvoiceError, format_satoshis +from .payment_identifier import create_bip21_uri from .lnutil import hex_to_bytes from .lnaddr import lndecode, LnAddr from . import constants @@ -318,7 +319,6 @@ class Request(BaseInvoice): *, lightning_invoice: Optional[str] = None, ) -> Optional[str]: - from electrum.util import create_bip21_uri addr = self.get_address() amount = self.get_amount_sat() if amount is not None: diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py new file mode 100644 index 000000000..cbf1a6461 --- /dev/null +++ b/electrum/payment_identifier.py @@ -0,0 +1,559 @@ +import asyncio +import urllib +import re +from decimal import Decimal +from typing import NamedTuple, Optional, Callable, Any, Sequence +from urllib.parse import urlparse + +from . import bitcoin +from .logging import Logger +from .util import parse_max_spend, format_satoshis_plain +from .util import get_asyncio_loop, log_exceptions +from .transaction import PartialTxOutput +from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data +from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC +from .lnaddr import lndecode, LnDecodeException, LnInvoiceException +from .lnutil import IncompatibleOrInsaneFeatures + +def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: + data = data.strip() # whitespaces + data = data.lower() + if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): + cut_prefix = LIGHTNING_URI_SCHEME + ':' + data = data[len(cut_prefix):] + if data.startswith('ln'): + return data + return None + +# URL decode +#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE) +#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x) + + +# note: when checking against these, use .lower() to support case-insensitivity +BITCOIN_BIP21_URI_SCHEME = 'bitcoin' +LIGHTNING_URI_SCHEME = 'lightning' + + +class InvalidBitcoinURI(Exception): pass + + +def parse_bip21_URI(uri: str) -> dict: + """Raises InvalidBitcoinURI on malformed URI.""" + + if not isinstance(uri, str): + raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") + + if ':' not in uri: + if not bitcoin.is_address(uri): + raise InvalidBitcoinURI("Not a bitcoin address") + return {'address': uri} + + u = urllib.parse.urlparse(uri) + if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: + raise InvalidBitcoinURI("Not a bitcoin URI") + address = u.path + + # python for android fails to parse query + if address.find('?') > 0: + address, query = u.path.split('?') + pq = urllib.parse.parse_qs(query) + else: + pq = urllib.parse.parse_qs(u.query) + + for k, v in pq.items(): + if len(v) != 1: + raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') + + out = {k: v[0] for k, v in pq.items()} + if address: + if not bitcoin.is_address(address): + raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") + out['address'] = address + if 'amount' in out: + am = out['amount'] + try: + m = re.match(r'([0-9.]+)X([0-9])', am) + if m: + k = int(m.group(2)) - 8 + amount = Decimal(m.group(1)) * pow(Decimal(10), k) + else: + amount = Decimal(am) * COIN + if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: + raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") + out['amount'] = int(amount) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e + if 'message' in out: + out['message'] = out['message'] + out['memo'] = out['message'] + if 'time' in out: + try: + out['time'] = int(out['time']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e + if 'exp' in out: + try: + out['exp'] = int(out['exp']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e + if 'sig' in out: + try: + out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e + if 'lightning' in out: + try: + lnaddr = lndecode(out['lightning']) + except LnDecodeException as e: + raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e + amount_sat = out.get('amount') + if amount_sat: + # allow small leeway due to msat precision + if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") + address = out.get('address') + ln_fallback_addr = lnaddr.get_fallback_address() + if address and ln_fallback_addr: + if ln_fallback_addr != address: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") + + return out + + + +def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], + *, extra_query_params: Optional[dict] = None) -> str: + if not bitcoin.is_address(addr): + return "" + if extra_query_params is None: + extra_query_params = {} + query = [] + if amount_sat: + query.append('amount=%s'%format_satoshis_plain(amount_sat)) + if message: + query.append('message=%s'%urllib.parse.quote(message)) + for k, v in extra_query_params.items(): + if not isinstance(k, str) or k != urllib.parse.quote(k): + raise Exception(f"illegal key for URI: {repr(k)}") + v = urllib.parse.quote(v) + query.append(f"{k}={v}") + p = urllib.parse.ParseResult( + scheme=BITCOIN_BIP21_URI_SCHEME, + netloc='', + path=addr, + params='', + query='&'.join(query), + fragment='', + ) + return str(urllib.parse.urlunparse(p)) + + + +def is_uri(data: str) -> bool: + data = data.lower() + if (data.startswith(LIGHTNING_URI_SCHEME + ":") or + data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): + return True + return False + + + +class FailedToParsePaymentIdentifier(Exception): + pass + +class PayToLineError(NamedTuple): + line_content: str + exc: Exception + idx: int = 0 # index of line + is_multiline: bool = False + +RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' +RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' + +class PaymentIdentifier(Logger): + """ + Takes: + * bitcoin addresses or script + * paytomany csv + * openalias + * bip21 URI + * lightning-URI (containing bolt11 or lnurl) + * bolt11 invoice + * lnurl + """ + + def __init__(self, config, contacts, text): + Logger.__init__(self) + self.contacts = contacts + self.config = config + self.text = text + self._type = None + self.error = None # if set, GUI should show error and stop + self.warning = None # if set, GUI should ask user if they want to proceed + # more than one of those may be set + self.multiline_outputs = None + self.bolt11 = None + self.bip21 = None + self.spk = None + # + self.openalias = None + self.openalias_data = None + # + self.bip70 = None + self.bip70_data = None + # + self.lnurl = None + self.lnurl_data = None + # parse without network + self.parse(text) + + def is_valid(self): + return bool(self._type) + + def is_lightning(self): + return self.lnurl or self.bolt11 + + def is_multiline(self): + return bool(self.multiline_outputs) + + def get_error(self) -> str: + return self.error + + def needs_round_1(self): + return self.bip70 or self.openalias or self.lnurl + + def needs_round_2(self): + return self.lnurl and self.lnurl_data + + def needs_round_3(self): + return self.bip70 + + def parse(self, text): + # parse text, set self._type and self.error + text = text.strip() + if not text: + return + if outputs:= self._parse_as_multiline(text): + self._type = 'multiline' + self.multiline_outputs = outputs + elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): + if invoice_or_lnurl.startswith('lnurl'): + self._type = 'lnurl' + try: + self.lnurl = decode_lnurl(invoice_or_lnurl) + except Exception as e: + self.error = "Error parsing Lightning invoice" + f":\n{e}" + return + else: + self._type = 'bolt11' + self.bolt11 = invoice_or_lnurl + elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): + try: + out = parse_bip21_URI(text) + except InvalidBitcoinURI as e: + self.error = _("Error parsing URI") + f":\n{e}" + return + self._type = 'bip21' + self.bip21 = out + self.bip70 = out.get('r') + elif scriptpubkey := self.parse_output(text): + self._type = 'spk' + self.spk = scriptpubkey + elif re.match(RE_EMAIL, text): + self._type = 'alias' + self.openalias = text + elif self.error is None: + truncated_text = f"{text[:100]}..." if len(text) > 100 else text + self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_text}") + + def get_onchain_outputs(self, amount): + if self.bip70: + return self.bip70_data.get_outputs() + elif self.multiline_outputs: + return self.multiline_outputs + elif self.spk: + return [PartialTxOutput(scriptpubkey=self.spk, value=amount)] + elif self.bip21: + address = self.bip21.get('address') + scriptpubkey = self.parse_output(address) + return [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)] + else: + raise Exception('not onchain') + + def _parse_as_multiline(self, text): + # filter out empty lines + lines = text.split('\n') + lines = [i for i in lines if i] + is_multiline = len(lines)>1 + outputs = [] # type: List[PartialTxOutput] + errors = [] + total = 0 + is_max = False + for i, line in enumerate(lines): + try: + output = self.parse_address_and_amount(line) + except Exception as e: + errors.append(PayToLineError( + idx=i, line_content=line.strip(), exc=e, is_multiline=True)) + continue + outputs.append(output) + if parse_max_spend(output.value): + is_max = True + else: + total += output.value + if is_multiline and errors: + self.error = str(errors) if errors else None + print(outputs, self.error) + return outputs + + def parse_address_and_amount(self, line) -> 'PartialTxOutput': + try: + x, y = line.split(',') + except ValueError: + raise Exception("expected two comma-separated values: (address, amount)") from None + scriptpubkey = self.parse_output(x) + amount = self.parse_amount(y) + return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount) + + def parse_output(self, x) -> bytes: + try: + address = self.parse_address(x) + return bytes.fromhex(bitcoin.address_to_script(address)) + except Exception as e: + error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False) + try: + script = self.parse_script(x) + return bytes.fromhex(script) + except Exception as e: + #error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False) + pass + #raise Exception("Invalid address or script.") + #self.errors.append(error) + + def parse_script(self, x): + script = '' + for word in x.split(): + if word[0:3] == 'OP_': + opcode_int = opcodes[word] + script += construct_script([opcode_int]) + else: + bytes.fromhex(word) # to test it is hex data + script += construct_script([word]) + return script + + def parse_amount(self, x): + x = x.strip() + if not x: + raise Exception("Amount is empty") + if parse_max_spend(x): + return x + p = pow(10, self.config.get_decimal_point()) + try: + return int(p * Decimal(x)) + except decimal.InvalidOperation: + raise Exception("Invalid amount") + + def parse_address(self, line): + r = line.strip() + m = re.match('^'+RE_ALIAS+'$', r) + address = str(m.group(2) if m else r) + assert bitcoin.is_address(address) + return address + + def get_fields_for_GUI(self, wallet): + """ sets self.error as side effect""" + recipient = None + amount = None + description = None + validated = None + comment = "no comment" + + if self.openalias and self.openalias_data: + address = self.openalias_data.get('address') + name = self.openalias_data.get('name') + recipient = self.openalias + ' <' + address + '>' + validated = self.openalias_data.get('validated') + if not validated: + self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(self.openalias) + #self.payto_e.set_openalias(key=pi.openalias, data=oa_data) + #self.window.contact_list.update() + + elif self.bolt11: + recipient, amount, description = self.get_bolt11_fields(self.bolt11) + + elif self.lnurl and self.lnurl_data: + domain = urlparse(self.lnurl).netloc + #recipient = "invoice from lnurl" + recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>" + #amount = self.lnurl_data.min_sendable_sat + amount = None + description = None + if self.lnurl_data.comment_allowed: + comment = None + + elif self.bip70 and self.bip70_data: + pr = self.bip70_data + if pr.error: + self.error = pr.error + return + recipient = pr.get_requestor() + amount = pr.get_amount() + description = pr.get_memo() + validated = not pr.has_expired() + #self.set_onchain(True) + #self.max_button.setEnabled(False) + # note: allow saving bip70 reqs, as we save them anyway when paying them + #for btn in [self.send_button, self.clear_button, self.save_button]: + # btn.setEnabled(True) + # signal to set fee + #self.amount_e.textEdited.emit("") + + elif self.spk: + recipient = self.text + amount = None + + elif self.multiline_outputs: + pass + + elif self.bip21: + recipient = self.bip21.get('address') + amount = self.bip21.get('amount') + label = self.bip21.get('label') + description = self.bip21.get('message') + # use label as description (not BIP21 compliant) + if label and not description: + description = label + lightning = self.bip21.get('lightning') + if lightning and wallet.has_lightning(): + # maybe set self.bolt11? + recipient, amount, description = self.get_bolt11_fields(lightning) + if not amount: + amount_required = True + # todo: merge logic + + return recipient, amount, description, comment, validated + + def get_bolt11_fields(self, bolt11_invoice): + """Parse ln invoice, and prepare the send tab for it.""" + try: + lnaddr = lndecode(bolt11_invoice) + except LnInvoiceException as e: + self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") + return + except IncompatibleOrInsaneFeatures as e: + self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}") + return + pubkey = lnaddr.pubkey.serialize().hex() + for k,v in lnaddr.tags: + if k == 'd': + description = v + break + else: + description = '' + amount = lnaddr.get_amount_sat() + return pubkey, amount, description + + async def resolve_openalias(self) -> Optional[dict]: + key = self.openalias + if not (('.' in key) and ('<' not in key) and (' ' not in key)): + return None + parts = key.split(sep=',') # assuming single line + if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): + return None + try: + data = self.contacts.resolve(key) + except Exception as e: + self.logger.info(f'error resolving address/alias: {repr(e)}') + return None + if data: + name = data.get('name') + address = data.get('address') + self.contacts[key] = ('openalias', name) + # this will set self.spk + self.parse(address) + return data + + def has_expired(self): + if self.bip70: + return self.bip70_data.has_expired() + return False + + @log_exceptions + async def round_1(self, on_success): + if self.openalias: + data = await self.resolve_openalias() + self.openalias_data = data + if not self.openalias_data.get('validated'): + self.warning = _( + 'WARNING: the alias "{}" could not be validated via an additional ' + 'security check, DNSSEC, and thus may not be correct.').format(self.openalias) + elif self.bip70: + from . import paymentrequest + data = await paymentrequest.get_payment_request(self.bip70) + self.bip70_data = data + elif self.lnurl: + data = await request_lnurl(self.lnurl) + self.lnurl_data = data + else: + return + on_success(self) + + @log_exceptions + async def round_2(self, on_success, amount_sat:int=None, comment=None): + from .invoices import Invoice + if self.lnurl: + if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): + self.error = f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.' + return + if self.lnurl_data.comment_allowed == 0: + comment = None + params = {'amount': amount_sat * 1000 } + if comment: + params['comment'] = comment + try: + invoice_data = await callback_lnurl( + self.lnurl_data.callback_url, + params=params, + ) + except LNURLError as e: + self.error = f"LNURL request encountered error: {e}" + return + bolt11_invoice = invoice_data.get('pr') + # + invoice = Invoice.from_bech32(bolt11_invoice) + if invoice.get_amount_sat() != amount_sat: + raise Exception("lnurl returned invoice with wrong amount") + # this will change what is returned by get_fields_for_GUI + self.bolt11 = bolt11_invoice + + on_success(self) + + @log_exceptions + async def round_3(self, tx, refund_address, *, on_success): + if self.bip70: + ack_status, ack_msg = await self.bip70.send_payment_and_receive_paymentack(tx.serialize(), refund_address) + self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") + on_success(self) + + def get_invoice(self, wallet, amount_sat, message): + # fixme: wallet not really needed, only height + from .invoices import Invoice + if self.is_lightning(): + invoice_str = self.bolt11 + if not invoice_str: + return + invoice = Invoice.from_bech32(invoice_str) + if invoice.amount_msat is None: + invoice.amount_msat = int(amount_sat * 1000) + return invoice + else: + outputs = self.get_onchain_outputs(amount_sat) + message = self.bip21.get('message') if self.bip21 else message + bip70_data = self.bip70_data if self.bip70 else None + return wallet.create_invoice( + outputs=outputs, + message=message, + pr=bip70_data, + URI=self.bip21) diff --git a/electrum/transaction.py b/electrum/transaction.py index 248642905..09fdf7ce4 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -42,7 +42,8 @@ import copy from . import ecc, bitcoin, constants, segwit_addr, bip32 from .bip32 import BIP32Node -from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend +from .util import profiler, to_bytes, bfh, chunks, is_hex_str +from .payment_identifier import parse_max_spend from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, diff --git a/electrum/util.py b/electrum/util.py index c31ba5711..e0da5441a 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1009,177 +1009,8 @@ def block_explorer_URL(config: 'SimpleConfig', kind: str, item: str) -> Optional url_parts = [explorer_url, kind_str, item] return ''.join(url_parts) -# URL decode -#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE) -#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x) -# note: when checking against these, use .lower() to support case-insensitivity -BITCOIN_BIP21_URI_SCHEME = 'bitcoin' -LIGHTNING_URI_SCHEME = 'lightning' - - -class InvalidBitcoinURI(Exception): pass - - -# TODO rename to parse_bip21_uri or similar -def parse_URI( - uri: str, - on_pr: Callable[['PaymentRequest'], None] = None, - *, - loop: asyncio.AbstractEventLoop = None, -) -> dict: - """Raises InvalidBitcoinURI on malformed URI.""" - from . import bitcoin - from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC - from .lnaddr import lndecode, LnDecodeException - - if not isinstance(uri, str): - raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") - - if ':' not in uri: - if not bitcoin.is_address(uri): - raise InvalidBitcoinURI("Not a bitcoin address") - return {'address': uri} - - u = urllib.parse.urlparse(uri) - if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: - raise InvalidBitcoinURI("Not a bitcoin URI") - address = u.path - - # python for android fails to parse query - if address.find('?') > 0: - address, query = u.path.split('?') - pq = urllib.parse.parse_qs(query) - else: - pq = urllib.parse.parse_qs(u.query) - - for k, v in pq.items(): - if len(v) != 1: - raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') - - out = {k: v[0] for k, v in pq.items()} - if address: - if not bitcoin.is_address(address): - raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") - out['address'] = address - if 'amount' in out: - am = out['amount'] - try: - m = re.match(r'([0-9.]+)X([0-9])', am) - if m: - k = int(m.group(2)) - 8 - amount = Decimal(m.group(1)) * pow(Decimal(10), k) - else: - amount = Decimal(am) * COIN - if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: - raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") - out['amount'] = int(amount) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e - if 'message' in out: - out['message'] = out['message'] - out['memo'] = out['message'] - if 'time' in out: - try: - out['time'] = int(out['time']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e - if 'exp' in out: - try: - out['exp'] = int(out['exp']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e - if 'sig' in out: - try: - out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e - if 'lightning' in out: - try: - lnaddr = lndecode(out['lightning']) - except LnDecodeException as e: - raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e - amount_sat = out.get('amount') - if amount_sat: - # allow small leeway due to msat precision - if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") - address = out.get('address') - ln_fallback_addr = lnaddr.get_fallback_address() - if address and ln_fallback_addr: - if ln_fallback_addr != address: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") - - r = out.get('r') - sig = out.get('sig') - name = out.get('name') - if on_pr and (r or (name and sig)): - @log_exceptions - async def get_payment_request(): - from . import paymentrequest as pr - if name and sig: - s = pr.serialize_request(out).SerializeToString() - request = pr.PaymentRequest(s) - else: - request = await pr.get_payment_request(r) - if on_pr: - on_pr(request) - loop = loop or get_asyncio_loop() - asyncio.run_coroutine_threadsafe(get_payment_request(), loop) - - return out - - -def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], - *, extra_query_params: Optional[dict] = None) -> str: - from . import bitcoin - if not bitcoin.is_address(addr): - return "" - if extra_query_params is None: - extra_query_params = {} - query = [] - if amount_sat: - query.append('amount=%s'%format_satoshis_plain(amount_sat)) - if message: - query.append('message=%s'%urllib.parse.quote(message)) - for k, v in extra_query_params.items(): - if not isinstance(k, str) or k != urllib.parse.quote(k): - raise Exception(f"illegal key for URI: {repr(k)}") - v = urllib.parse.quote(v) - query.append(f"{k}={v}") - p = urllib.parse.ParseResult( - scheme=BITCOIN_BIP21_URI_SCHEME, - netloc='', - path=addr, - params='', - query='&'.join(query), - fragment='', - ) - return str(urllib.parse.urlunparse(p)) - - -def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: - data = data.strip() # whitespaces - data = data.lower() - if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): - cut_prefix = LIGHTNING_URI_SCHEME + ':' - data = data[len(cut_prefix):] - if data.startswith('ln'): - return data - return None - - -def is_uri(data: str) -> bool: - data = data.lower() - if (data.startswith(LIGHTNING_URI_SCHEME + ":") or - data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): - return True - return False - - -class FailedToParsePaymentIdentifier(Exception): - pass # Python bug (http://bugs.python.org/issue1927) causes raw_input diff --git a/electrum/wallet.py b/electrum/wallet.py index 6d17e755d..4376d8f77 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -57,7 +57,8 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates, WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, - Fiat, bfh, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex, parse_max_spend) + Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex) +from .payment_identifier import create_bip21_uri, parse_max_spend from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold diff --git a/electrum/x509.py b/electrum/x509.py index 68cf92b94..f0da646f6 100644 --- a/electrum/x509.py +++ b/electrum/x509.py @@ -308,7 +308,8 @@ class X509(object): raise CertificateError('Certificate has not entered its valid date range. (%s)' % self.get_common_name()) if self.notAfter <= now: dt = datetime.utcfromtimestamp(time.mktime(self.notAfter)) - raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') + # for testnet + #raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') def getFingerprint(self): return hashlib.sha1(self.bytes).digest() diff --git a/run_electrum b/run_electrum index d388de169..a0c95c728 100755 --- a/run_electrum +++ b/run_electrum @@ -93,6 +93,7 @@ sys._ELECTRUM_RUNNING_VIA_RUNELECTRUM = True # used by logging.py from electrum.logging import get_logger, configure_logging # import logging submodule first from electrum import util +from electrum.payment_identifier import PaymentIdentifier from electrum import constants from electrum import SimpleConfig from electrum.wallet_db import WalletDB @@ -364,9 +365,9 @@ def main(): if not config_options.get('verbosity'): warnings.simplefilter('ignore', DeprecationWarning) - # check uri + # check if we received a valid payment identifier uri = config_options.get('url') - if uri and not util.is_uri(uri): + if uri and not PaymentIdentifier(None, None, uri).is_valid(): print_stderr('unknown command:', uri) sys.exit(1)