diff --git a/electrum/bip21.py b/electrum/bip21.py new file mode 100644 index 000000000..bcf6bd361 --- /dev/null +++ b/electrum/bip21.py @@ -0,0 +1,127 @@ +import urllib +import re +from decimal import Decimal +from typing import Optional + +from . import bitcoin +from .util import format_satoshis_plain +from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC +from .lnaddr import lndecode, LnDecodeException + +# 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)) diff --git a/electrum/blockchain.py b/electrum/blockchain.py index 4cca1d282..2dd4982c1 100644 --- a/electrum/blockchain.py +++ b/electrum/blockchain.py @@ -23,16 +23,17 @@ import os import threading import time -from typing import Optional, Dict, Mapping, Sequence +from typing import Optional, Dict, Mapping, Sequence, TYPE_CHECKING from . import util from .bitcoin import hash_encode, int_to_hex, rev_hex from .crypto import sha256d from . import constants from .util import bfh, with_lock -from .simple_config import SimpleConfig from .logging import get_logger, Logger +if TYPE_CHECKING: + from .simple_config import SimpleConfig _logger = get_logger(__name__) @@ -181,7 +182,7 @@ class Blockchain(Logger): Manages blockchain headers and their verification """ - def __init__(self, config: SimpleConfig, forkpoint: int, parent: Optional['Blockchain'], + def __init__(self, config: 'SimpleConfig', forkpoint: int, parent: Optional['Blockchain'], forkpoint_hash: str, prev_hash: Optional[str]): assert isinstance(forkpoint_hash, str) and len(forkpoint_hash) == 64, forkpoint_hash assert (prev_hash is None) or (isinstance(prev_hash, str) and len(prev_hash) == 64), prev_hash diff --git a/electrum/contacts.py b/electrum/contacts.py index cc7906554..febfcd57d 100644 --- a/electrum/contacts.py +++ b/electrum/contacts.py @@ -33,6 +33,11 @@ from .util import read_json_file, write_json_file, to_string from .logging import Logger from .util import trigger_callback + +class AliasNotFoundException(Exception): + pass + + class Contacts(dict, Logger): def __init__(self, db): @@ -94,7 +99,18 @@ class Contacts(dict, Logger): 'type': 'openalias', 'validated': validated } - raise Exception("Invalid Bitcoin address or alias", k) + raise AliasNotFoundException("Invalid Bitcoin address or alias", k) + + def by_name(self, name): + for k in self.keys(): + _type, addr = self[k] + if addr.casefold() == name.casefold(): + return { + 'name': addr, + 'type': _type, + 'address': k + } + return None def fetch_openalias(self, config): self.alias_info = None diff --git a/electrum/gui/icons/spinner.gif b/electrum/gui/icons/spinner.gif new file mode 100644 index 000000000..0dbdd7819 Binary files /dev/null and b/electrum/gui/icons/spinner.gif differ diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index 6270436a2..590e7b0fb 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.bip21 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..d5191b97a 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -18,8 +18,9 @@ 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.bip21 import BITCOIN_BIP21_URI_SCHEME, parse_bip21_URI, InvalidBitcoinURI +from electrum.payment_identifier import maybe_extract_lightning_payment_identifier 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 +209,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..bb2245c4f 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.bip21 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/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index a6f2a395c..171959dbd 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -5,22 +5,20 @@ from urllib.parse import urlparse from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS, QTimer -from electrum import bitcoin -from electrum import lnutil from electrum.i18n import _ -from electrum.invoices import Invoice -from electrum.invoices import (PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, +from electrum.logging import get_logger +from electrum.invoices import (Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER) from electrum.lnaddr import LnInvoiceException -from electrum.logging import get_logger -from electrum.transaction import PartialTxOutput -from electrum.util import (parse_URI, InvalidBitcoinURI, InvoiceError, - maybe_extract_lightning_payment_identifier, get_asyncio_loop) -from electrum.lnutil import format_short_channel_id +from electrum.transaction import PartialTxOutput, TxOutput +from electrum.util import InvoiceError, get_asyncio_loop +from electrum.lnutil import format_short_channel_id, IncompatibleOrInsaneFeatures from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl from electrum.bitcoin import COIN from electrum.paymentrequest import PaymentRequest - +from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType) +from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval, QtEventListener, event_listener @@ -249,7 +247,8 @@ class QEInvoice(QObject, QtEventListener): } def name_for_node_id(self, node_id): - return self._wallet.wallet.lnworker.get_node_alias(node_id) or node_id.hex() + lnworker = self._wallet.wallet.lnworker + return (lnworker.get_node_alias(node_id) if lnworker else None) or node_id.hex() def set_effective_invoice(self, invoice: Invoice): self._effectiveInvoice = invoice @@ -406,13 +405,11 @@ class QEInvoiceParser(QEInvoice): lnurlRetrieved = pyqtSignal() lnurlError = pyqtSignal([str,str], arguments=['code', 'message']) - _bip70PrResolvedSignal = pyqtSignal([PaymentRequest], arguments=['pr']) - def __init__(self, parent=None): super().__init__(parent) self._recipient = '' - self._bip70PrResolvedSignal.connect(self._bip70_payment_request_resolved) + self._pi = None self.clear() @@ -493,79 +490,61 @@ class QEInvoiceParser(QEInvoice): self.setInvoiceType(QEInvoice.Type.Invalid) return - maybe_lightning_invoice = recipient + self._pi = PaymentIdentifier(self._wallet.wallet, recipient) + if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21, + PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP]: + self.validationError.emit('unknown', _('Unknown invoice')) + return - try: - bip21 = parse_URI(recipient, lambda pr: self._bip70PrResolvedSignal.emit(pr)) - if bip21: - if 'r' in bip21 or ('name' in bip21 and 'sig' in bip21): # TODO set flag in util? - # let callback handle state - return - if ':' not in recipient: - # address only - # create bare invoice - outputs = [PartialTxOutput.from_address_and_value(bip21['address'], 0)] - invoice = self.create_onchain_invoice(outputs, None, None, None) - self._logger.debug(repr(invoice)) - self.setValidOnchainInvoice(invoice) - self.validationSuccess.emit() - return - else: - # fallback lightning invoice? - if 'lightning' in bip21: - maybe_lightning_invoice = bip21['lightning'] - except InvalidBitcoinURI as e: - bip21 = None - - lninvoice = None - maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice) - if maybe_lightning_invoice is not None: - if maybe_lightning_invoice.startswith('lnurl'): - self.resolve_lnurl(maybe_lightning_invoice) + if self._pi.type == PaymentIdentifierType.SPK: + txo = TxOutput(scriptpubkey=self._pi.spk, value=0) + if not txo.address: + self.validationError.emit('unknown', _('Unknown invoice')) return - try: - lninvoice = Invoice.from_bech32(maybe_lightning_invoice) - except InvoiceError as e: - e2 = e.__cause__ - if isinstance(e2, LnInvoiceException): - self.validationError.emit('unknown', _("Error parsing Lightning invoice") + f":\n{e2}") - self.clear() - return - if isinstance(e2, lnutil.IncompatibleOrInsaneFeatures): - self.validationError.emit('unknown', _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e2!r}") - self.clear() - return - self._logger.exception(repr(e)) - if not lninvoice and not bip21: - self.validationError.emit('unknown',_('Unknown invoice')) - self.clear() + self._update_from_payment_identifier() + + def _update_from_payment_identifier(self): + if self._pi.need_resolve(): + self.resolve_pi() return - if lninvoice: - if not self._wallet.wallet.has_lightning(): - if not bip21: - if lninvoice.get_address(): - self.setValidLightningInvoice(lninvoice) - self.validationSuccess.emit() - else: - self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) - else: - self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') - self._validateRecipient_bip21_onchain(bip21) - else: - if not self._wallet.wallet.lnworker.channels: - if bip21 and 'address' in bip21: - self._logger.debug('flow where invoice has both LN and onchain, we have LN enabled but no channels') - self._validateRecipient_bip21_onchain(bip21) - else: - self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) - else: + if self._pi.type == PaymentIdentifierType.LNURLP: + self.on_lnurl(self._pi.lnurl_data) + return + + if self._pi.type == PaymentIdentifierType.BIP70: + self._bip70_payment_request_resolved(self._pi.bip70_data) + return + + if self._pi.is_available(): + if self._pi.type == PaymentIdentifierType.SPK: + outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)] + invoice = self.create_onchain_invoice(outputs, None, None, None) + self._logger.debug(repr(invoice)) + self.setValidOnchainInvoice(invoice) + self.validationSuccess.emit() + return + elif self._pi.type == PaymentIdentifierType.BOLT11: + lninvoice = self._pi.bolt11 + if not self._wallet.wallet.has_lightning() and not lninvoice.get_address(): + self.validationError.emit('no_lightning', + _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) + return + if self._wallet.wallet.lnworker and not self._wallet.wallet.lnworker.channels: + self.validationWarning.emit('no_channels', + _('Detected valid Lightning invoice, but there are no open channels')) + + self.setValidLightningInvoice(lninvoice) + self.validationSuccess.emit() + elif self._pi.type == PaymentIdentifierType.BIP21: + if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11: + lninvoice = self._pi.bolt11 self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() - else: - self._logger.debug('flow without LN but having bip21 uri') - self._validateRecipient_bip21_onchain(bip21) + else: + self._validateRecipient_bip21_onchain(self._pi.bip21) + def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None: if 'amount' not in bip21: @@ -580,20 +559,15 @@ class QEInvoiceParser(QEInvoice): self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() - def resolve_lnurl(self, lnurl): - self._logger.debug('resolve_lnurl') - url = decode_lnurl(lnurl) - self._logger.debug(f'{repr(url)}') - - def resolve_task(): - try: - coro = request_lnurl(url) - fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) - self.on_lnurl(fut.result()) - except Exception as e: - self.validationError.emit('lnurl', repr(e)) + def resolve_pi(self): + assert self._pi.need_resolve() + def on_finished(pi): + if pi.is_error(): + pass + else: + self._update_from_payment_identifier() - threading.Thread(target=resolve_task, daemon=True).start() + self._pi.resolve(on_finished=on_finished) def on_lnurl(self, lnurldata): self._logger.debug('on_lnurl') @@ -610,49 +584,39 @@ class QEInvoiceParser(QEInvoice): self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() - @pyqtSlot('quint64') - @pyqtSlot('quint64', str) - def lnurlGetInvoice(self, amount, comment=None): + @pyqtSlot() + @pyqtSlot(str) + def lnurlGetInvoice(self, comment=None): assert self._lnurlData + assert self._pi.need_finalize() self._logger.debug(f'{repr(self._lnurlData)}') amount = self.amountOverride.satsInt - if self.lnurlData['min_sendable_sat'] != 0: - try: - assert amount >= self.lnurlData['min_sendable_sat'] - assert amount <= self.lnurlData['max_sendable_sat'] - except Exception: - self.lnurlError.emit('amount', _('Amount out of bounds')) - return if self._lnurlData['comment_allowed'] == 0: comment = None - self._logger.debug(f'fetching callback url {self._lnurlData["callback_url"]}') - def fetch_invoice_task(): - try: - params = { 'amount': amount * 1000 } - if comment: - params['comment'] = comment - coro = callback_lnurl(self._lnurlData['callback_url'], params) - fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) - self.on_lnurl_invoice(amount, fut.result()) - except Exception as e: - self._logger.error(repr(e)) - self.lnurlError.emit('lnurl', str(e)) - - threading.Thread(target=fetch_invoice_task, daemon=True).start() + def on_finished(pi): + if pi.is_error(): + if pi.state == PaymentIdentifierState.INVALID_AMOUNT: + self.lnurlError.emit('amount', pi.get_error()) + else: + self.lnurlError.emit('lnurl', pi.get_error()) + else: + self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11) + + self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished) def on_lnurl_invoice(self, orig_amount, invoice): self._logger.debug('on_lnurl_invoice') self._logger.debug(f'{repr(invoice)}') # assure no shenanigans with the bolt11 invoice we get back - lninvoice = Invoice.from_bech32(invoice['pr']) - if orig_amount * 1000 != lninvoice.amount_msat: + lninvoice = Invoice.from_bech32(invoice) + if orig_amount * 1000 != lninvoice.amount_msat: # TODO msat precision can cause trouble here raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount') - self.recipient = invoice['pr'] + self.recipient = invoice @pyqtSlot() def saveInvoice(self): diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index fdb54b98f..f09af5368 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,8 @@ class ElectrumGui(BaseElectrumGui, Logger): window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) window.activateWindow() if uri: - window.handle_payment_identifier(uri) + window.show_send_tab() + 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/amountedit.py b/electrum/gui/qt/amountedit.py index d4bc86c19..4e3073d08 100644 --- a/electrum/gui/qt/amountedit.py +++ b/electrum/gui/qt/amountedit.py @@ -13,7 +13,6 @@ from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_nam FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT) from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC - _NOT_GIVEN = object() # sentinel value @@ -22,9 +21,11 @@ class FreezableLineEdit(QLineEdit): def setFrozen(self, b): self.setReadOnly(b) - self.setFrame(not b) + self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '') self.frozen.emit() + def isFrozen(self): + return self.isReadOnly() class SizedFreezableLineEdit(FreezableLineEdit): @@ -153,6 +154,7 @@ class BTCAmountEdit(AmountEdit): else: text = self._get_text_from_amount(amount_sat) self.setText(text) + self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?) self.repaint() # macOS hack for #6269 diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index afaecfb68..cf70c2f83 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,12 +54,11 @@ 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, FailedToParsePaymentIdentifier, - get_new_wallet_name, send_exception_to_crash_reporter, - AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod) +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.bip21 import BITCOIN_BIP21_URI_SCHEME +from electrum.payment_identifier import PaymentIdentifier from electrum.invoices import PR_PAID, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) @@ -849,7 +847,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.update_status() # resolve aliases # FIXME this might do blocking network calls that has a timeout of several seconds - self.send_tab.payto_e.on_timer_check_text() + # self.send_tab.payto_e.on_timer_check_text() self.notify_transactions() def format_amount( @@ -1328,11 +1326,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 964a729da..3fb8f659f 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -23,52 +23,43 @@ # 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 typing import Optional, TYPE_CHECKING +from PyQt5.QtCore import Qt, QTimer, QSize +from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtGui import QFontMetrics, QFont -from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout +from PyQt5.QtWidgets import QApplication, QTextEdit, QWidget, QLineEdit, QStackedLayout, QSizePolicy -from electrum import bitcoin -from electrum.util import parse_max_spend, 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 from . import util -from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent +from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent, ColorScheme if TYPE_CHECKING: - from .main_window import ElectrumWindow 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 InvalidPaymentIdentifier(Exception): + pass class ResizingTextEdit(QTextEdit): + textReallyChanged = pyqtSignal() + resized = pyqtSignal() + def __init__(self): QTextEdit.__init__(self) + self._text = '' + self.setAcceptRichText(False) + self.textChanged.connect(self.on_text_changed) document = self.document() - document.contentsChanged.connect(self.update_size) fontMetrics = QFontMetrics(document.defaultFont()) self.fontSpacing = fontMetrics.lineSpacing() margins = self.contentsMargins() @@ -80,6 +71,13 @@ class ResizingTextEdit(QTextEdit): self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.update_size() + def on_text_changed(self): + # QTextEdit emits spurious textChanged events + if self.toPlainText() != self._text: + self._text = self.toPlainText() + self.textReallyChanged.emit() + self.update_size() + def update_size(self): docLineCount = self.document().lineCount() docHeight = max(3, docLineCount) * self.fontSpacing @@ -87,327 +85,207 @@ class ResizingTextEdit(QTextEdit): h = min(max(h, self.heightMin), self.heightMax) self.setMinimumHeight(int(h)) self.setMaximumHeight(int(h)) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) + self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + self.resized.emit() + + def sizeHint(self) -> QSize: + return QSize(0, self.minimumHeight()) -class PayToEdit(Logger, GenericInputHandler): +class PayToEdit(QWidget, Logger, GenericInputHandler): + paymentIdentifierChanged = pyqtSignal() + textChanged = pyqtSignal() def __init__(self, send_tab: 'SendTab'): + QWidget.__init__(self, parent=send_tab) Logger.__init__(self) GenericInputHandler.__init__(self) + + self._text = '' + self._layout = QStackedLayout() + self.setLayout(self._layout) + + def text_edit_changed(): + text = self.text_edit.toPlainText() + if self._text != text: + # sync and emit + self._text = text + self.line_edit.setText(text) + self.textChanged.emit() + + def text_edit_resized(): + self.update_height() + + def line_edit_changed(): + text = self.line_edit.text() + if self._text != text: + # sync and emit + self._text = text + self.text_edit.setPlainText(text) + self.textChanged.emit() + self.line_edit = QLineEdit() + self.line_edit.textChanged.connect(line_edit_changed) self.text_edit = ResizingTextEdit() - self.text_edit.hide() + self.text_edit.textReallyChanged.connect(text_edit_changed) + self.text_edit.resized.connect(text_edit_resized) + + self.textChanged.connect(self._handle_text_change) + + self._layout.addWidget(self.line_edit) + self._layout.addWidget(self.text_edit) + + self.multiline = False + 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.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 - self.setText = self.editor.setText - self.setEnabled = self.editor.setEnabled - self.setReadOnly = self.editor.setReadOnly - self.setFocus = self.editor.setFocus + # 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) + self.edit_timer = QTimer(self) + self.edit_timer.setSingleShot(True) + self.edit_timer.setInterval(1000) + self.edit_timer.timeout.connect(self._on_edit_timer) + + self.payment_identifier = None + @property - def editor(self): - return self.text_edit if self.is_paytomany() else self.line_edit + def multiline(self): + return self._multiline + + @multiline.setter + def multiline(self, b: bool) -> None: + if b is None: + return + self._multiline = b + self._layout.setCurrentWidget(self.text_edit if b else self.line_edit) + self.update_height() + + def update_height(self) -> None: + h = self._layout.currentWidget().sizeHint().height() + self.setMaximumHeight(h) + + def setText(self, text: str) -> None: + if self._text != text: + self.line_edit.setText(text) + self.text_edit.setText(text) + + def setFocus(self, reason=None) -> None: + if self.multiline: + self.text_edit.setFocus(reason) + else: + self.line_edit.setFocus(reason) + + def setToolTip(self, tt: str) -> None: + self.line_edit.setToolTip(tt) + self.text_edit.setToolTip(tt) + + '''set payment identifier only if valid, else exception''' + def try_payment_identifier(self, text) -> None: + 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) -> None: + 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 + if self.payment_identifier.is_multiline() and not self._is_paytomany: + self.set_paytomany(True) + + # if payment identifier gets set externally, we want to update the edit control + # Note: this triggers the change handler, but we shortcut if it's the same payment identifier + self.setText(text) + + 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.multiline = b self.send_tab.paytomany_menu.setChecked(b) - if has_focus: - self.editor.setFocus() - def toggle_paytomany(self): + def toggle_paytomany(self) -> None: 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): + def setReadOnly(self, b: bool) -> None: + self.line_edit.setReadOnly(b) + self.text_edit.setReadOnly(b) + + def isReadOnly(self): + return self.line_edit.isReadOnly() + + def setStyleSheet(self, stylesheet: str) -> None: + self.line_edit.setStyleSheet(stylesheet) + self.text_edit.setStyleSheet(stylesheet) + + def setFrozen(self, b) -> None: self.setReadOnly(b) - if not b: - self.setStyleSheet(normal_style) + self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '') - 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.isReadOnly() - def do_clear(self): + def do_clear(self) -> None: 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) - - def setGreen(self): + self.setText('') + self.setToolTip('') + self.payment_identifier = None + + def setGreen(self) -> None: self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) - def setExpired(self): + def setExpired(self) -> None: 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) - - 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) - - def on_timer_check_text(self): - if self.editor.hasFocus(): - return - text = self.toPlainText() - 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(): + def _handle_text_change(self) -> None: + 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 full_check: - self.previous_payto = str(text).strip() - self.errors = [] - errors = [] - 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) - - - def _parse_as_multiline(self, lines, *, raise_errors: bool): - outputs = [] # type: List[PartialTxOutput] - 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) - if parse_max_spend(output.value): - is_max = True - else: - total += output.value - if outputs: - 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() + + # pushback timer if timer active or PI needs resolving + pi = PaymentIdentifier(self.send_tab.wallet, self._text) + if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive(): + self.edit_timer.start() else: - self.validated = None - return True + self.set_payment_identifier(self._text) + + def _on_edit_timer(self) -> None: + if not self.isFrozen(): + self.set_payment_identifier(self._text) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index dffa15d31..2dfe8be09 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -2,32 +2,28 @@ # Distributed under the MIT software license, see the accompanying # file LICENCE or http://www.opensource.org/licenses/mit-license.php -import asyncio from decimal import Decimal -from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any -from urllib.parse import urlparse +from typing import Optional, TYPE_CHECKING, Sequence, List, Callable +from PyQt5.QtCore import pyqtSignal, QPoint, QSize, Qt +from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout, + QWidget, QToolTip, QPushButton, QApplication) +from PyQt5.QtGui import QMovie, QColor -from PyQt5.QtCore import pyqtSignal, QPoint -from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, - QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) +from electrum.i18n import _ +from electrum.logging import Logger -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 NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend 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 -from electrum.lnaddr import lndecode, LnInvoiceException -from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data +from electrum.payment_identifier import PaymentIdentifierState, PaymentIdentifierType 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, ColorScheme, icon_path) from .confirm_tx_dialog import ConfirmTxDialog if TYPE_CHECKING: @@ -36,20 +32,14 @@ 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 + resolve_done_signal = pyqtSignal(object) + finalize_done_signal = pyqtSignal(object) + notify_merchant_done_signal = pyqtSignal(object) 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 @@ -60,8 +50,6 @@ 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.pending_invoice = None # A 4-column grid layout. All the stretch is in the last column. @@ -84,9 +72,8 @@ 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, 0, 1, 1, 4) #completer = QCompleter() #completer.setCaseSensitivity(False) @@ -97,9 +84,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' @@ -123,21 +118,34 @@ 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(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) + + self.spinner = QMovie(icon_path('spinner.gif')) + self.spinner.setScaledSize(QSize(24, 24)) + self.spinner.setBackgroundColor(QColor('black')) + self.spinner_l = QLabel() + self.spinner_l.setMargin(5) + self.spinner_l.setVisible(False) + self.spinner_l.setMovie(self.spinner) + grid.addWidget(self.spinner_l, 0, 1, 1, 4, Qt.AlignRight) + + 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) @@ -147,20 +155,16 @@ 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) 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 +190,45 @@ 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.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 showSpinner(self, b): + self.spinner_l.setVisible(b) + if b: + self.spinner.start() + else: + self.spinner.stop() + + 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 + pi_error = self.payto_e.payment_identifier.is_error() if pi_valid else False + self.send_button.setEnabled(bool(self.amount_e.get_amount()) and pi_valid and not pi_error) + + def do_paste(self): + self.logger.debug('do_paste') + 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.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 [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, + PaymentIdentifierType.OPENALIAS] if run_hook('abort_send', self): return - outputs = self.payto_e.get_outputs(True) + 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( @@ -285,9 +317,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]: @@ -297,226 +327,158 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.format_amount_and_units(frozen_bal) def do_clear(self): - self._lnurl_data = None + self.logger.debug('do_clear') + self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False) 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]: - e.setText('') - e.setFrozen(False) - for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: - e.setEnabled(True) + for w in [self.comment_e, self.comment_label]: + w.setVisible(False) + for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]: + w.setText('') + w.setToolTip('') + for w in [self.save_button, self.send_button]: + w.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) + self.paytomany_menu.setChecked(self.payto_e.multiline) - def lock_amount(self, b: bool) -> None: - self.amount_e.setFrozen(b) - self.max_button.setEnabled(not b) + run_hook('do_clear', self) 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 btn in [self.save_button, self.send_button, self.clear_button]: btn.setEnabled(False) - self.payto_e.setTextNoCheck(_("please wait...")) + self.showSpinner(True) - 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() - else: - self.payment_request_error_signal.emit() + def set_field_validated(self, w, *, validated: Optional[bool] = None): + if validated is not None: + w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True)) - 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: + 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): + self.logger.debug('update_fields') + pi = self.payto_e.payment_identifier + + self.clear_button.setEnabled(True) + + if pi.is_multiline(): + self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False) + self.set_field_validated(self.payto_e, validated=pi.is_valid()) # TODO: validated used differently here than openalias + self.save_button.setEnabled(pi.is_valid()) + self.send_button.setEnabled(pi.is_valid()) + self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '') + if pi.is_valid(): + self.handle_multiline(pi.multiline_outputs) 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}") + 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 - pubkey = lnaddr.pubkey.serialize().hex() - for k,v in lnaddr.tags: - if k == 'd': - description = v - break - else: - description = '' - self.payto_e.setFrozen(True) - self.payto_e.setTextNoCheck(pubkey) - self.payto_e.lightning_invoice = invoice - if not self.message_e.text(): - 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}") + lock_recipient = pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, + PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70, + PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve() + lock_amount = pi.is_amount_locked() + lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21] + + self.lock_fields(lock_recipient=lock_recipient, + lock_amount=lock_amount, + lock_max=lock_max, + lock_description=False) + if lock_recipient: + fields = pi.get_fields_for_GUI() + if fields.recipient: + self.payto_e.setText(fields.recipient) + if fields.description: + self.message_e.setText(fields.description) + self.lock_fields(lock_description=True) + if fields.amount: + self.amount_e.setAmount(fields.amount) + for w in [self.comment_e, self.comment_label]: + w.setVisible(bool(fields.comment)) + if fields.comment: + self.comment_e.setToolTip(_('Max comment length: %d characters') % fields.comment) + self.set_field_validated(self.payto_e, validated=fields.validated) + + # LNURLp amount range + if fields.amount_range: + amin, amax = fields.amount_range + self.amount_e.setToolTip(_('Amount must be between %d and %d sat.') % (amin, amax)) + else: + self.amount_e.setToolTip('') + + pi_unusable = pi.is_error() or (not self.wallet.has_lightning() and not pi.is_onchain()) + + self.send_button.setEnabled(not pi_unusable and bool(self.amount_e.get_amount()) and not pi.has_expired()) + self.save_button.setEnabled(not pi_unusable and pi.type not in [PaymentIdentifierType.LNURLP, + PaymentIdentifierType.LNADDR]) + + def _handle_payment_identifier(self): + self.update_fields() + + if not self.payto_e.payment_identifier.is_valid(): + self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}') 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: + + if self.payto_e.payment_identifier.need_resolve(): self.prepare_for_send_tab_network_lookup() + self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit) + + def on_resolve_done(self, pi): + # TODO: resolve can happen while typing, we don't want message dialogs to pop up + # currently we don't set error for emaillike recipients to avoid just that + self.logger.debug('payment identifier resolve done') + self.showSpinner(False) + if pi.error: + self.show_error(pi.error) + self.do_clear() 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: - 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}") - # update fiat amount - self.amount_e.textEdited.emit("") - self.window.show_send_tab() + self.update_fields() + + 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.payto_e.payment_identifier.get_invoice(amount_sat, self.get_message()) + if not invoice: + 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')) + 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 +498,23 @@ 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.') + def on_finalize_done(self, pi): + self.showSpinner(False) + self.update_fields() + if pi.error: + self.show_error(pi.error) 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()) - self.do_clear() - self.payto_e.setText(bolt11_invoice) + 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): - if self._lnurl_data: - self._lnurl_get_invoice() + 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(), + on_finished=self.finalize_done_signal.emit) return self.pending_invoice = self.read_invoice() if not self.pending_invoice: @@ -600,12 +544,9 @@ 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]: + amount = '!' if self.max_button.isChecked() 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 +570,31 @@ 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.payto_e.payment_identifier.get_error() + if error: + if not self.payto_e.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])) + 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.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.payto_e.payment_identifier.has_expired(): + self.show_error(_('Payment request has expired')) + return True return False # no errors @@ -740,9 +678,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.payto_e.payment_identifier.has_expired(): return False, _("Invoice has expired") try: self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) @@ -752,13 +688,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return False, repr(e) # success txid = tx.txid() - if pr: - self.payment_request = None + if self.payto_e.payment_identifier.need_merchant_notify(): 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}") + self.payto_e.payment_identifier.notify_merchant( + tx=tx, + refund_address=refund_address, + on_finished=self.notify_merchant_done_signal.emit + ) return True, txid # Capture current TL window; override might be removed on return @@ -782,6 +718,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger): WaitingDialog(self, _('Broadcasting transaction...'), broadcast_thread, broadcast_done, self.window.on_error) + def on_notify_merchant_done(self, pi): + if pi.is_error(): + self.logger.debug(f'merchant notify error: {pi.get_error()}') + else: + self.logger.debug(f'merchant notify result: {pi.merchant_ack_status}: {pi.merchant_ack_message}') + # TODO: show user? if we broadcasted the tx succesfully, do we care? + # BitPay complains with a NAK if tx is RbF + def toggle_paytomany(self): self.payto_e.toggle_paytomany() if self.payto_e.is_paytomany(): @@ -798,9 +742,23 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.window.show_send_tab() self.payto_e.do_clear() if len(paytos) == 1: + self.logger.debug('payto_e setText 1') self.payto_e.setText(paytos[0]) self.amount_e.setFocus() else: self.payto_e.setFocus() text = "\n".join([payto + ", 0" for payto in paytos]) + self.logger.debug('payto_e setText n') self.payto_e.setText(text) + self.payto_e.setFocus() + + def handle_multiline(self, outputs): + total = 0 + for output in outputs: + if parse_max_spend(output.value): + self.max_button.setChecked(True) # TODO: remove and let spend_max set this? + self.spend_max() + return + else: + total += output.value + self.amount_e.setAmount(total if outputs else None) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index a393c2bdb..50bdacaa0 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -562,7 +562,10 @@ class GenericInputHandler: new_text = self.text() + data + '\n' else: new_text = data - setText(new_text) + try: + setText(new_text) + except Exception as e: + show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e)) from .qrreader import scan_qrcode if parent is None: @@ -599,7 +602,10 @@ class GenericInputHandler: new_text = self.text() + data + '\n' else: new_text = data - setText(new_text) + try: + setText(new_text) + except Exception as e: + show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e)) def input_file( self, @@ -628,7 +634,10 @@ class GenericInputHandler: except BaseException as e: show_error(_('Error opening file') + ':\n' + repr(e)) else: - setText(data) + try: + setText(data) + except Exception as e: + show_error(_('Invalid payment identifier in file') + ':\n' + repr(e)) def input_paste_from_clipboard( self, @@ -961,6 +970,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..d5bf7bfdc 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 .bip21 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/network.py b/electrum/network.py index ab08adb49..47da82b69 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -59,7 +59,6 @@ from .interface import (Interface, PREFERRED_NETWORK_PROTOCOL, RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS, NetworkException, RequestCorrupted, ServerAddr) from .version import PROTOCOL_VERSION -from .simple_config import SimpleConfig from .i18n import _ from .logging import get_logger, Logger @@ -71,6 +70,7 @@ if TYPE_CHECKING: from .lnworker import LNGossip from .lnwatcher import WatchTower from .daemon import Daemon + from .simple_config import SimpleConfig _logger = get_logger(__name__) @@ -270,7 +270,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): local_watchtower: Optional['WatchTower'] = None path_finder: Optional['LNPathFinder'] = None - def __init__(self, config: SimpleConfig, *, daemon: 'Daemon' = None): + def __init__(self, config: 'SimpleConfig', *, daemon: 'Daemon' = None): global _INSTANCE assert _INSTANCE is None, "Network is a singleton!" _INSTANCE = self @@ -287,9 +287,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): self.asyncio_loop = util.get_asyncio_loop() assert self.asyncio_loop.is_running(), "event loop not running" - assert isinstance(config, SimpleConfig), f"config should be a SimpleConfig instead of {type(config)}" self.config = config - self.daemon = daemon blockchain.read_blockchains(self.config) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py new file mode 100644 index 000000000..9b7719d64 --- /dev/null +++ b/electrum/payment_identifier.py @@ -0,0 +1,659 @@ +import asyncio +import time +import urllib +import re +from decimal import Decimal, InvalidOperation +from enum import IntEnum +from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple + +from . import bitcoin +from .contacts import AliasNotFoundException +from .i18n import _ +from .invoices import Invoice +from .logging import Logger +from .util import parse_max_spend, format_satoshis_plain, InvoiceError +from .util import get_asyncio_loop, log_exceptions +from .transaction import PartialTxOutput +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 +from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME + +if TYPE_CHECKING: + from .wallet import Abstract_Wallet + from .transaction import Transaction + + +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 + + +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' +RE_DOMAIN = r'\b([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b' + + +class PaymentIdentifierState(IntEnum): + EMPTY = 0 # Initial state. + INVALID = 1 # Unrecognized PI + AVAILABLE = 2 # PI contains a payable destination + # payable means there's enough addressing information to submit to one + # of the channels Electrum supports (on-chain, lightning) + NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step + LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11 + MERCHANT_NOTIFY = 5 # PI contains a valid payment request and on-chain destination. It should notify + # the merchant payment processor of the tx after on-chain broadcast, + # and supply a refund address (bip70) + MERCHANT_ACK = 6 # PI notified merchant. nothing to be done. + ERROR = 50 # generic error + NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccesful + MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX + INVALID_AMOUNT = 53 # Specified amount not accepted + + +class PaymentIdentifierType(IntEnum): + UNKNOWN = 0 + SPK = 1 + BIP21 = 2 + BIP70 = 3 + MULTILINE = 4 + BOLT11 = 5 + LNURLP = 6 + EMAILLIKE = 7 + OPENALIAS = 8 + LNADDR = 9 + DOMAINLIKE = 10 + + +class FieldsForGUI(NamedTuple): + recipient: Optional[str] + amount: Optional[int] + description: Optional[str] + validated: Optional[bool] + comment: Optional[int] + amount_range: Optional[Tuple[int, int]] + + +class PaymentIdentifier(Logger): + """ + Takes: + * bitcoin addresses or script + * paytomany csv + * openalias + * bip21 URI + * lightning-URI (containing bolt11 or lnurl) + * bolt11 invoice + * lnurl + * lightning address + """ + + def __init__(self, wallet: 'Abstract_Wallet', text): + Logger.__init__(self) + self._state = PaymentIdentifierState.EMPTY + 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.strip() + self._type = PaymentIdentifierType.UNKNOWN + 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 + # + self.emaillike = None + self.domainlike = None + self.openalias_data = None + # + self.bip70 = None + self.bip70_data = None + self.merchant_ack_status = None + self.merchant_ack_message = None + # + self.lnurl = None + self.lnurl_data = None + + self.parse(text) + + @property + def type(self): + return self._type + + def set_state(self, state: 'PaymentIdentifierState'): + self.logger.debug(f'PI state {self._state} -> {state}') + self._state = state + + @property + def state(self): + return self._state + + def need_resolve(self): + return self._state == PaymentIdentifierState.NEED_RESOLVE + + def need_finalize(self): + return self._state == PaymentIdentifierState.LNURLP_FINALIZE + + def need_merchant_notify(self): + return self._state == PaymentIdentifierState.MERCHANT_NOTIFY + + def is_valid(self): + return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY] + + def is_available(self): + return self._state in [PaymentIdentifierState.AVAILABLE] + + def is_lightning(self): + return bool(self.lnurl) or bool(self.bolt11) + + def is_onchain(self): + if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.BIP70, + PaymentIdentifierType.OPENALIAS]: + return True + if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]: + return bool(self.bolt11) and bool(self.bolt11.get_address()) + + 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 == PaymentIdentifierType.BIP21: + return bool(self.bip21.get('amount')) + elif self._type == PaymentIdentifierType.BIP70: + return not self.need_resolve() # always fixed after resolve? + elif self._type == PaymentIdentifierType.BOLT11: + return bool(self.bolt11.get_amount_sat()) + elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: + # amount limits known after resolve, might be specific amount or locked to range + if self.need_resolve(): + return False + 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 == PaymentIdentifierType.MULTILINE: + return True + else: + return False + + def is_error(self) -> bool: + return self._state >= PaymentIdentifierState.ERROR + + def get_error(self) -> str: + return self.error + + 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 = PaymentIdentifierType.MULTILINE + self.multiline_outputs = outputs + if self.error: + self.set_state(PaymentIdentifierState.INVALID) + else: + self.set_state(PaymentIdentifierState.AVAILABLE) + elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): + if invoice_or_lnurl.startswith('lnurl'): + self._type = PaymentIdentifierType.LNURLP + try: + self.lnurl = decode_lnurl(invoice_or_lnurl) + self.set_state(PaymentIdentifierState.NEED_RESOLVE) + except Exception as e: + self.error = _("Error parsing LNURL") + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) + return + else: + self._type = PaymentIdentifierType.BOLT11 + try: + self.bolt11 = Invoice.from_bech32(invoice_or_lnurl) + except InvoiceError as e: + self.error = self._get_error_from_invoiceerror(e) + self.set_state(PaymentIdentifierState.INVALID) + self.logger.debug(f'Exception cause {e.args!r}') + return + self.set_state(PaymentIdentifierState.AVAILABLE) + 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}" + self.set_state(PaymentIdentifierState.INVALID) + return + self.bip21 = out + self.bip70 = out.get('r') + if self.bip70: + self._type = PaymentIdentifierType.BIP70 + self.set_state(PaymentIdentifierState.NEED_RESOLVE) + else: + self._type = PaymentIdentifierType.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 = PaymentIdentifierType.SPK + self.spk = scriptpubkey + self.set_state(PaymentIdentifierState.AVAILABLE) + elif contact := self.contacts.by_name(text): + if contact['type'] == 'address': + self._type = PaymentIdentifierType.BIP21 + self.bip21 = { + 'address': contact['address'], + 'label': contact['name'] + } + self.set_state(PaymentIdentifierState.AVAILABLE) + elif contact['type'] == 'openalias': + self._type = PaymentIdentifierType.EMAILLIKE + self.emaillike = contact['address'] + self.set_state(PaymentIdentifierState.NEED_RESOLVE) + elif re.match(RE_EMAIL, text): + self._type = PaymentIdentifierType.EMAILLIKE + self.emaillike = text + self.set_state(PaymentIdentifierState.NEED_RESOLVE) + elif re.match(RE_DOMAIN, text): + self._type = PaymentIdentifierType.DOMAINLIKE + self.domainlike = text + self.set_state(PaymentIdentifierState.NEED_RESOLVE) + elif self.error is None: + truncated_text = f"{text[:100]}..." if len(text) > 100 else text + self.error = f"Unknown payment identifier:\n{truncated_text}" + self.set_state(PaymentIdentifierState.INVALID) + + def resolve(self, *, on_finished: 'Callable'): + assert self._state == PaymentIdentifierState.NEED_RESOLVE + coro = self._do_resolve(on_finished=on_finished) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + + @log_exceptions + async def _do_resolve(self, *, on_finished=None): + try: + if self.emaillike or self.domainlike: + # TODO: parallel lookup? + data = await self.resolve_openalias() + if data: + self.openalias_data = data + self.logger.debug(f'OA: {data!r}') + name = data.get('name') + address = data.get('address') + key = self.emaillike if self.emaillike else self.domainlike + self.contacts[key] = ('openalias', name) + if not 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(key) + try: + assert bitcoin.is_address(address) + scriptpubkey = bytes.fromhex(bitcoin.address_to_script(address)) + self._type = PaymentIdentifierType.OPENALIAS + self.spk = scriptpubkey + self.set_state(PaymentIdentifierState.AVAILABLE) + except Exception as e: + self.error = str(e) + self.set_state(PaymentIdentifierState.NOT_FOUND) + elif self.emaillike: + lnurl = lightning_address_to_url(self.emaillike) + try: + data = await request_lnurl(lnurl) + self._type = PaymentIdentifierType.LNADDR + self.lnurl = lnurl + self.lnurl_data = data + self.set_state(PaymentIdentifierState.LNURLP_FINALIZE) + except LNURLError as e: + self.set_state(PaymentIdentifierState.NOT_FOUND) + except Exception as e: + # NOTE: any other exception is swallowed here (e.g. DNS error) + # as the user may be typing and we have an incomplete emaillike + self.set_state(PaymentIdentifierState.NOT_FOUND) + else: + self.set_state(PaymentIdentifierState.NOT_FOUND) + elif self.bip70: + from . import paymentrequest + pr = await paymentrequest.get_payment_request(self.bip70) + if not pr.error: + self.bip70_data = pr + self.set_state(PaymentIdentifierState.MERCHANT_NOTIFY) + else: + self.error = pr.error + self.set_state(PaymentIdentifierState.ERROR) + elif self.lnurl: + 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 + except Exception as e: + 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) + self.set_state(PaymentIdentifierState.INVALID_AMOUNT) + 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}" + self.set_state(PaymentIdentifierState.ERROR) + 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: + on_finished(self) + + def notify_merchant(self, *, tx: 'Transaction' = None, refund_address: str = None, on_finished: 'Callable' = None): + assert self._state == PaymentIdentifierState.MERCHANT_NOTIFY + assert tx + coro = self._do_notify_merchant(tx, refund_address, on_finished=on_finished) + asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) + + @log_exceptions + async def _do_notify_merchant(self, tx, refund_address, *, on_finished: 'Callable'): + try: + if not self.bip70_data: + self.set_state(PaymentIdentifierState.ERROR) + return + + ack_status, ack_msg = await self.bip70_data.send_payment_and_receive_paymentack(tx.serialize(), refund_address) + self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") + self.merchant_ack_status = ack_status + self.merchant_ack_message = ack_msg + self.set_state(PaymentIdentifierState.MERCHANT_ACK) + except Exception as e: + self.error = str(e) + self.logger.error(repr(e)) + self.set_state(PaymentIdentifierState.MERCHANT_ERROR) + finally: + if on_finished: + on_finished(self) + + 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 + 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): + self._is_max = True + else: + total += output.value + except Exception as e: + errors = f'{errors}line #{i}: {str(e)}\n' + continue + if is_multiline and errors: + self.error = errors.strip() if errors else None + self.logger.debug(f'multiline: {outputs!r}, {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) + if not scriptpubkey: + raise Exception('Invalid address') + 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: + pass + try: + script = self.parse_script(x) + return bytes.fromhex(script) + except Exception as e: + 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.config.get_decimal_point()) + try: + return int(p * Decimal(x)) + except 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_error_from_invoiceerror(self, e: 'InvoiceError') -> str: + error = _("Error parsing Lightning invoice") + f":\n{e!r}" + if e.args and len(e.args): + arg = e.args[0] + if isinstance(arg, LnInvoiceException): + error = _("Error parsing Lightning invoice") + f":\n{e}" + elif isinstance(arg, IncompatibleOrInsaneFeatures): + error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}" + return error + + def get_fields_for_GUI(self) -> FieldsForGUI: + recipient = None + amount = None + description = None + validated = None + comment = None + amount_range = None + + if (self.emaillike or self.domainlike) and self.openalias_data: + key = self.emaillike if self.emaillike else self.domainlike + address = self.openalias_data.get('address') + name = self.openalias_data.get('name') + description = name + recipient = key + ' <' + 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(key) + + elif self.bolt11: + recipient, amount, description = self._get_bolt11_fields() + + elif self.lnurl and self.lnurl_data: + domain = urllib.parse.urlparse(self.lnurl).netloc + recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>" + description = self.lnurl_data.metadata_plaintext + if self.lnurl_data.comment_allowed: + comment = self.lnurl_data.comment_allowed + if self.lnurl_data.min_sendable_sat: + amount = self.lnurl_data.min_sendable_sat + if self.lnurl_data.min_sendable_sat != self.lnurl_data.max_sendable_sat: + amount_range = (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) + + elif self.bip70 and self.bip70_data: + pr = self.bip70_data + if pr.error: + self.error = pr.error + else: + recipient = pr.get_requestor() + amount = pr.get_amount() + description = pr.get_memo() + validated = not pr.has_expired() + + elif self.spk: + pass + + elif self.multiline_outputs: + pass + + elif self.bip21: + label = self.bip21.get('label') + address = self.bip21.get('address') + recipient = f'{label} <{address}>' if label else address + amount = self.bip21.get('amount') + description = self.bip21.get('message') + # TODO: use label as description? (not BIP21 compliant) + # if label and not description: + # description = label + + return FieldsForGUI(recipient=recipient, amount=amount, description=description, + comment=comment, validated=validated, amount_range=amount_range) + + def _get_bolt11_fields(self): + lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr + 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.emaillike if self.emaillike else self.domainlike + # TODO: below check needed? we already matched RE_EMAIL/RE_DOMAIN + # 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) # 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)}') + return None + except Exception as e: + self.logger.info(f'error resolving address/alias: {repr(e)}') + return None + + def has_expired(self): + if self.bip70: + return self.bip70_data.has_expired() + elif self.bolt11: + return self.bolt11.has_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): + if self.is_lightning(): + invoice = self.bolt11 + if not invoice: + return + 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 self.wallet.create_invoice( + outputs=outputs, + message=message, + pr=bip70_data, + URI=self.bip21) diff --git a/electrum/tests/test_util.py b/electrum/tests/test_util.py index 9013d6afd..1767bc8bf 100644 --- a/electrum/tests/test_util.py +++ b/electrum/tests/test_util.py @@ -2,12 +2,10 @@ from datetime import datetime from decimal import Decimal from electrum import util -from electrum.util import (format_satoshis, format_fee_satoshis, parse_URI, - is_hash256_str, chunks, is_ip_address, list_enabled_bits, - format_satoshis_plain, is_private_netaddress, is_hex_str, - is_integer, is_non_negative_integer, is_int_or_float, - is_non_negative_int_or_float, is_subpath, InvalidBitcoinURI) - +from electrum.util import (format_satoshis, format_fee_satoshis, is_hash256_str, chunks, is_ip_address, + list_enabled_bits, format_satoshis_plain, is_private_netaddress, is_hex_str, + is_integer, is_non_negative_integer, is_int_or_float, is_non_negative_int_or_float) +from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from . import ElectrumTestCase, as_testnet @@ -102,7 +100,7 @@ class TestUtil(ElectrumTestCase): self.assertEqual("0.01234", format_satoshis_plain(1234, decimal_point=5)) def _do_test_parse_URI(self, uri, expected): - result = parse_URI(uri) + result = parse_bip21_URI(uri) self.assertEqual(expected, result) def test_parse_URI_address(self): @@ -143,13 +141,13 @@ class TestUtil(ElectrumTestCase): {'r': 'http://domain.tld/page?h=2a8628fc2fbe'}) def test_parse_URI_invalid_address(self): - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:invalidaddress') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:invalidaddress') def test_parse_URI_invalid(self): - self.assertRaises(InvalidBitcoinURI, parse_URI, 'notbitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'notbitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma') def test_parse_URI_parameter_pollution(self): - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&amount=30.0') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&amount=30.0') @as_testnet def test_parse_URI_lightning_consistency(self): @@ -174,11 +172,11 @@ class TestUtil(ElectrumTestCase): 'memo': 'test266', 'message': 'test266'}) # bip21 uri that includes "lightning" key. LN part has fallback address BUT it mismatches the top-level address - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qvu0c9xme0ul3gzx4nzqdgxsu25acuk9wvsj2j2?amount=0.0007&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qvu0c9xme0ul3gzx4nzqdgxsu25acuk9wvsj2j2?amount=0.0007&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') # bip21 uri that includes "lightning" key. top-level amount mismatches LN amount - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql') # bip21 uri that includes "lightning" key with garbage unparseable value - self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd') + self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd') def test_is_hash256_str(self): self.assertTrue(is_hash256_str('09a4c03e3bdf83bbe3955f907ee52da4fc12f4813d459bc75228b64ad08617c7')) diff --git a/electrum/util.py b/electrum/util.py index 8312f1bd0..16396462b 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -1010,177 +1010,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..65475b251 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -57,7 +57,7 @@ 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 .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 @@ -65,7 +65,7 @@ from .crypto import sha256d from . import keystore from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK, AddressIndexGeneric, CannotDerivePubkey) -from .util import multisig_type +from .util import multisig_type, parse_max_spend from .storage import StorageEncryptionVersion, WalletStorage from .wallet_db import WalletDB from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32 diff --git a/run_electrum b/run_electrum index d388de169..413e7b4b8 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,12 +365,6 @@ def main(): if not config_options.get('verbosity'): warnings.simplefilter('ignore', DeprecationWarning) - # check uri - uri = config_options.get('url') - if uri and not util.is_uri(uri): - print_stderr('unknown command:', uri) - sys.exit(1) - config = SimpleConfig(config_options) cmdname = config.get('cmd') @@ -397,6 +392,12 @@ def main(): elif config.get('signet'): constants.set_signet() + # check if we received a valid payment identifier + uri = config_options.get('url') + if uri and not PaymentIdentifier(None, uri).is_valid(): + print_stderr('unknown command:', uri) + sys.exit(1) + if cmdname == 'daemon' and config.get("detach"): # detect lockfile. # This is not as good as get_file_descriptor, but that would require the asyncio loop