Browse Source

Merge pull request #8462 from spesmilo/payment_identifiers2

Payment identifiers
master
ThomasV 2 years ago committed by GitHub
parent
commit
5cf320b2c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 127
      electrum/bip21.py
  2. 7
      electrum/blockchain.py
  3. 18
      electrum/contacts.py
  4. BIN
      electrum/gui/icons/spinner.gif
  5. 5
      electrum/gui/kivy/main_window.py
  6. 7
      electrum/gui/kivy/uix/screens.py
  7. 2
      electrum/gui/qml/qeapp.py
  8. 204
      electrum/gui/qml/qeinvoice.py
  9. 5
      electrum/gui/qt/__init__.py
  10. 6
      electrum/gui/qt/amountedit.py
  11. 26
      electrum/gui/qt/main_window.py
  12. 474
      electrum/gui/qt/paytoedit.py
  13. 604
      electrum/gui/qt/send_tab.py
  14. 16
      electrum/gui/qt/util.py
  15. 2
      electrum/invoices.py
  16. 6
      electrum/network.py
  17. 659
      electrum/payment_identifier.py
  18. 24
      electrum/tests/test_util.py
  19. 169
      electrum/util.py
  20. 4
      electrum/wallet.py
  21. 13
      run_electrum

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

7
electrum/blockchain.py

@ -23,16 +23,17 @@
import os import os
import threading import threading
import time import time
from typing import Optional, Dict, Mapping, Sequence from typing import Optional, Dict, Mapping, Sequence, TYPE_CHECKING
from . import util from . import util
from .bitcoin import hash_encode, int_to_hex, rev_hex from .bitcoin import hash_encode, int_to_hex, rev_hex
from .crypto import sha256d from .crypto import sha256d
from . import constants from . import constants
from .util import bfh, with_lock from .util import bfh, with_lock
from .simple_config import SimpleConfig
from .logging import get_logger, Logger from .logging import get_logger, Logger
if TYPE_CHECKING:
from .simple_config import SimpleConfig
_logger = get_logger(__name__) _logger = get_logger(__name__)
@ -181,7 +182,7 @@ class Blockchain(Logger):
Manages blockchain headers and their verification 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]): forkpoint_hash: str, prev_hash: Optional[str]):
assert isinstance(forkpoint_hash, str) and len(forkpoint_hash) == 64, forkpoint_hash 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 assert (prev_hash is None) or (isinstance(prev_hash, str) and len(prev_hash) == 64), prev_hash

18
electrum/contacts.py

@ -33,6 +33,11 @@ from .util import read_json_file, write_json_file, to_string
from .logging import Logger from .logging import Logger
from .util import trigger_callback from .util import trigger_callback
class AliasNotFoundException(Exception):
pass
class Contacts(dict, Logger): class Contacts(dict, Logger):
def __init__(self, db): def __init__(self, db):
@ -94,7 +99,18 @@ class Contacts(dict, Logger):
'type': 'openalias', 'type': 'openalias',
'validated': validated '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): def fetch_openalias(self, config):
self.alias_info = None self.alias_info = None

BIN
electrum/gui/icons/spinner.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

5
electrum/gui/kivy/main_window.py

@ -86,9 +86,8 @@ Label.register(
) )
from electrum.util import (NoDynamicFeeEstimates, NotEnoughFunds, from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException
BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME, from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
UserFacingException)
from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog
from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog

7
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 bitcoin, constants
from electrum import lnutil from electrum import lnutil
from electrum.transaction import tx_from_any, PartialTxOutput from electrum.transaction import tx_from_any, PartialTxOutput
from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier, from electrum.util import TxMinedInfo, InvoiceError, format_time, parse_max_spend
InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME) 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.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from electrum.logging import Logger from electrum.logging import Logger
@ -208,7 +209,7 @@ class SendScreen(CScreen, Logger):
def set_bip21(self, text: str): def set_bip21(self, text: str):
try: 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: except InvalidBitcoinURI as e:
self.app.show_info(_("Error parsing URI") + f":\n{e}") self.app.show_info(_("Error parsing URI") + f":\n{e}")
return return

2
electrum/gui/qml/qeapp.py

@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica
from electrum import version, constants from electrum import version, constants
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import Logger, get_logger 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.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue
from electrum.network import Network from electrum.network import Network

204
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 PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS, QTimer
from electrum import bitcoin
from electrum import lnutil
from electrum.i18n import _ from electrum.i18n import _
from electrum.invoices import Invoice from electrum.logging import get_logger
from electrum.invoices import (PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, 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) PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER)
from electrum.lnaddr import LnInvoiceException from electrum.lnaddr import LnInvoiceException
from electrum.logging import get_logger from electrum.transaction import PartialTxOutput, TxOutput
from electrum.transaction import PartialTxOutput from electrum.util import InvoiceError, get_asyncio_loop
from electrum.util import (parse_URI, InvalidBitcoinURI, InvoiceError, from electrum.lnutil import format_short_channel_id, IncompatibleOrInsaneFeatures
maybe_extract_lightning_payment_identifier, get_asyncio_loop)
from electrum.lnutil import format_short_channel_id
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl
from electrum.bitcoin import COIN from electrum.bitcoin import COIN
from electrum.paymentrequest import PaymentRequest 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 .qetypes import QEAmount
from .qewallet import QEWallet from .qewallet import QEWallet
from .util import status_update_timer_interval, QtEventListener, event_listener 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): 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): def set_effective_invoice(self, invoice: Invoice):
self._effectiveInvoice = invoice self._effectiveInvoice = invoice
@ -406,13 +405,11 @@ class QEInvoiceParser(QEInvoice):
lnurlRetrieved = pyqtSignal() lnurlRetrieved = pyqtSignal()
lnurlError = pyqtSignal([str,str], arguments=['code', 'message']) lnurlError = pyqtSignal([str,str], arguments=['code', 'message'])
_bip70PrResolvedSignal = pyqtSignal([PaymentRequest], arguments=['pr'])
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._recipient = '' self._recipient = ''
self._bip70PrResolvedSignal.connect(self._bip70_payment_request_resolved) self._pi = None
self.clear() self.clear()
@ -493,79 +490,61 @@ class QEInvoiceParser(QEInvoice):
self.setInvoiceType(QEInvoice.Type.Invalid) self.setInvoiceType(QEInvoice.Type.Invalid)
return 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: if self._pi.type == PaymentIdentifierType.SPK:
bip21 = parse_URI(recipient, lambda pr: self._bip70PrResolvedSignal.emit(pr)) txo = TxOutput(scriptpubkey=self._pi.spk, value=0)
if bip21: if not txo.address:
if 'r' in bip21 or ('name' in bip21 and 'sig' in bip21): # TODO set flag in util? self.validationError.emit('unknown', _('Unknown invoice'))
# 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)
return 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._update_from_payment_identifier()
self.validationError.emit('unknown',_('Unknown invoice'))
self.clear() def _update_from_payment_identifier(self):
if self._pi.need_resolve():
self.resolve_pi()
return return
if lninvoice: if self._pi.type == PaymentIdentifierType.LNURLP:
if not self._wallet.wallet.has_lightning(): self.on_lnurl(self._pi.lnurl_data)
if not bip21: return
if lninvoice.get_address():
self.setValidLightningInvoice(lninvoice) if self._pi.type == PaymentIdentifierType.BIP70:
self.validationSuccess.emit() self._bip70_payment_request_resolved(self._pi.bip70_data)
else: return
self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.'))
else: if self._pi.is_available():
self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') if self._pi.type == PaymentIdentifierType.SPK:
self._validateRecipient_bip21_onchain(bip21) outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)]
else: invoice = self.create_onchain_invoice(outputs, None, None, None)
if not self._wallet.wallet.lnworker.channels: self._logger.debug(repr(invoice))
if bip21 and 'address' in bip21: self.setValidOnchainInvoice(invoice)
self._logger.debug('flow where invoice has both LN and onchain, we have LN enabled but no channels') self.validationSuccess.emit()
self._validateRecipient_bip21_onchain(bip21) return
else: elif self._pi.type == PaymentIdentifierType.BOLT11:
self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) lninvoice = self._pi.bolt11
else: 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.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit() self.validationSuccess.emit()
else: else:
self._logger.debug('flow without LN but having bip21 uri') self._validateRecipient_bip21_onchain(self._pi.bip21)
self._validateRecipient_bip21_onchain(bip21)
def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None: def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None:
if 'amount' not in bip21: if 'amount' not in bip21:
@ -580,20 +559,15 @@ class QEInvoiceParser(QEInvoice):
self.setValidOnchainInvoice(invoice) self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit() self.validationSuccess.emit()
def resolve_lnurl(self, lnurl): def resolve_pi(self):
self._logger.debug('resolve_lnurl') assert self._pi.need_resolve()
url = decode_lnurl(lnurl) def on_finished(pi):
self._logger.debug(f'{repr(url)}') if pi.is_error():
pass
def resolve_task(): else:
try: self._update_from_payment_identifier()
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))
threading.Thread(target=resolve_task, daemon=True).start() self._pi.resolve(on_finished=on_finished)
def on_lnurl(self, lnurldata): def on_lnurl(self, lnurldata):
self._logger.debug('on_lnurl') self._logger.debug('on_lnurl')
@ -610,49 +584,39 @@ class QEInvoiceParser(QEInvoice):
self.setValidLNURLPayRequest() self.setValidLNURLPayRequest()
self.lnurlRetrieved.emit() self.lnurlRetrieved.emit()
@pyqtSlot('quint64') @pyqtSlot()
@pyqtSlot('quint64', str) @pyqtSlot(str)
def lnurlGetInvoice(self, amount, comment=None): def lnurlGetInvoice(self, comment=None):
assert self._lnurlData assert self._lnurlData
assert self._pi.need_finalize()
self._logger.debug(f'{repr(self._lnurlData)}') self._logger.debug(f'{repr(self._lnurlData)}')
amount = self.amountOverride.satsInt 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: if self._lnurlData['comment_allowed'] == 0:
comment = None comment = None
self._logger.debug(f'fetching callback url {self._lnurlData["callback_url"]}') def on_finished(pi):
def fetch_invoice_task(): if pi.is_error():
try: if pi.state == PaymentIdentifierState.INVALID_AMOUNT:
params = { 'amount': amount * 1000 } self.lnurlError.emit('amount', pi.get_error())
if comment: else:
params['comment'] = comment self.lnurlError.emit('lnurl', pi.get_error())
coro = callback_lnurl(self._lnurlData['callback_url'], params) else:
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11)
self.on_lnurl_invoice(amount, fut.result())
except Exception as e: self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
self._logger.error(repr(e))
self.lnurlError.emit('lnurl', str(e))
threading.Thread(target=fetch_invoice_task, daemon=True).start()
def on_lnurl_invoice(self, orig_amount, invoice): def on_lnurl_invoice(self, orig_amount, invoice):
self._logger.debug('on_lnurl_invoice') self._logger.debug('on_lnurl_invoice')
self._logger.debug(f'{repr(invoice)}') self._logger.debug(f'{repr(invoice)}')
# assure no shenanigans with the bolt11 invoice we get back # assure no shenanigans with the bolt11 invoice we get back
lninvoice = Invoice.from_bech32(invoice['pr']) lninvoice = Invoice.from_bech32(invoice)
if orig_amount * 1000 != lninvoice.amount_msat: 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') raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount')
self.recipient = invoice['pr'] self.recipient = invoice
@pyqtSlot() @pyqtSlot()
def saveInvoice(self): def saveInvoice(self):

5
electrum/gui/qt/__init__.py

@ -87,7 +87,7 @@ class OpenFileEventFilter(QObject):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FileOpen: if event.type() == QtCore.QEvent.FileOpen:
if len(self.windows) >= 1: 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 True
return False return False
@ -393,7 +393,8 @@ class ElectrumGui(BaseElectrumGui, Logger):
window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)
window.activateWindow() window.activateWindow()
if uri: if uri:
window.handle_payment_identifier(uri) window.show_send_tab()
window.send_tab.set_payment_identifier(uri)
return window return window
def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]: def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:

6
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) FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT)
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
_NOT_GIVEN = object() # sentinel value _NOT_GIVEN = object() # sentinel value
@ -22,9 +21,11 @@ class FreezableLineEdit(QLineEdit):
def setFrozen(self, b): def setFrozen(self, b):
self.setReadOnly(b) self.setReadOnly(b)
self.setFrame(not b) self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
self.frozen.emit() self.frozen.emit()
def isFrozen(self):
return self.isReadOnly()
class SizedFreezableLineEdit(FreezableLineEdit): class SizedFreezableLineEdit(FreezableLineEdit):
@ -153,6 +154,7 @@ class BTCAmountEdit(AmountEdit):
else: else:
text = self._get_text_from_amount(amount_sat) text = self._get_text_from_amount(amount_sat)
self.setText(text) self.setText(text)
self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?)
self.repaint() # macOS hack for #6269 self.repaint() # macOS hack for #6269

26
electrum/gui/qt/main_window.py

@ -26,7 +26,6 @@ import sys
import time import time
import threading import threading
import os import os
import traceback
import json import json
import weakref import weakref
import csv import csv
@ -55,12 +54,11 @@ from electrum import (keystore, ecc, constants, util, bitcoin, commands,
from electrum.bitcoin import COIN, is_address from electrum.bitcoin import COIN, is_address
from electrum.plugin import run_hook, BasePlugin from electrum.plugin import run_hook, BasePlugin
from electrum.i18n import _ from electrum.i18n import _
from electrum.util import (format_time, get_asyncio_loop, from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword,
UserCancelled, profiler, UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter,
bfh, InvalidPassword, AddTransactionException, os_chmod)
UserFacingException, FailedToParsePaymentIdentifier, from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME
get_new_wallet_name, send_exception_to_crash_reporter, from electrum.payment_identifier import PaymentIdentifier
AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod)
from electrum.invoices import PR_PAID, Invoice from electrum.invoices import PR_PAID, Invoice
from electrum.transaction import (Transaction, PartialTxInput, from electrum.transaction import (Transaction, PartialTxInput,
PartialTransaction, PartialTxOutput) PartialTransaction, PartialTxOutput)
@ -849,7 +847,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.update_status() self.update_status()
# resolve aliases # resolve aliases
# FIXME this might do blocking network calls that has a timeout of several seconds # 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() self.notify_transactions()
def format_amount( def format_amount(
@ -1328,11 +1326,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return None return None
return clayout.selected_index() return clayout.selected_index()
def handle_payment_identifier(self, *args, **kwargs): def handle_payment_identifier(self, text: str):
try: pi = PaymentIdentifier(self.wallet, text)
self.send_tab.handle_payment_identifier(*args, **kwargs) if pi.is_valid():
except FailedToParsePaymentIdentifier as e: self.send_tab.set_payment_identifier(text)
self.show_error(str(e)) else:
if pi.error:
self.show_error(str(pi.error))
def set_frozen_state_of_addresses(self, addrs, freeze: bool): def set_frozen_state_of_addresses(self, addrs, freeze: bool):
self.wallet.set_frozen_state_of_addresses(addrs, freeze) self.wallet.set_frozen_state_of_addresses(addrs, freeze)

474
electrum/gui/qt/paytoedit.py

@ -23,52 +23,43 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
import re
import decimal
from functools import partial from functools import partial
from decimal import Decimal from typing import Optional, TYPE_CHECKING
from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING
from PyQt5.QtCore import Qt, QTimer, QSize
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtGui import QFontMetrics, QFont 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.payment_identifier import PaymentIdentifier
from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier
from electrum.transaction import PartialTxOutput
from electrum.bitcoin import opcodes, construct_script
from electrum.logging import Logger from electrum.logging import Logger
from electrum.lnurl import LNURLError
from .qrtextedit import ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit
from . import util from . import util
from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent, ColorScheme
if TYPE_CHECKING: if TYPE_CHECKING:
from .main_window import ElectrumWindow
from .send_tab import SendTab from .send_tab import SendTab
RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
frozen_style = "QWidget {border:none;}" frozen_style = "QWidget {border:none;}"
normal_style = "QPlainTextEdit { }" normal_style = "QPlainTextEdit { }"
class PayToLineError(NamedTuple): class InvalidPaymentIdentifier(Exception):
line_content: str pass
exc: Exception
idx: int = 0 # index of line
is_multiline: bool = False
class ResizingTextEdit(QTextEdit): class ResizingTextEdit(QTextEdit):
textReallyChanged = pyqtSignal()
resized = pyqtSignal()
def __init__(self): def __init__(self):
QTextEdit.__init__(self) QTextEdit.__init__(self)
self._text = ''
self.setAcceptRichText(False)
self.textChanged.connect(self.on_text_changed)
document = self.document() document = self.document()
document.contentsChanged.connect(self.update_size)
fontMetrics = QFontMetrics(document.defaultFont()) fontMetrics = QFontMetrics(document.defaultFont())
self.fontSpacing = fontMetrics.lineSpacing() self.fontSpacing = fontMetrics.lineSpacing()
margins = self.contentsMargins() margins = self.contentsMargins()
@ -80,6 +71,13 @@ class ResizingTextEdit(QTextEdit):
self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
self.update_size() 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): def update_size(self):
docLineCount = self.document().lineCount() docLineCount = self.document().lineCount()
docHeight = max(3, docLineCount) * self.fontSpacing docHeight = max(3, docLineCount) * self.fontSpacing
@ -87,327 +85,207 @@ class ResizingTextEdit(QTextEdit):
h = min(max(h, self.heightMin), self.heightMax) h = min(max(h, self.heightMin), self.heightMax)
self.setMinimumHeight(int(h)) self.setMinimumHeight(int(h))
self.setMaximumHeight(int(h)) self.setMaximumHeight(int(h))
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) 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'): def __init__(self, send_tab: 'SendTab'):
QWidget.__init__(self, parent=send_tab)
Logger.__init__(self) Logger.__init__(self)
GenericInputHandler.__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 = QLineEdit()
self.line_edit.textChanged.connect(line_edit_changed)
self.text_edit = ResizingTextEdit() 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 self._is_paytomany = False
for w in [self.line_edit, self.text_edit]: self.text_edit.setFont(QFont(MONOSPACE_FONT))
w.setFont(QFont(MONOSPACE_FONT))
w.textChanged.connect(self._on_text_changed)
self.send_tab = send_tab self.send_tab = send_tab
self.config = send_tab.config 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 # button handlers
self.on_qr_from_camera_input_btn = partial( self.on_qr_from_camera_input_btn = partial(
self.input_qr_from_camera, self.input_qr_from_camera,
config=self.config, config=self.config,
allow_multi=False, allow_multi=False,
show_error=self.win.show_error, show_error=self.send_tab.show_error,
setText=self._on_input_btn, setText=self.try_payment_identifier,
parent=self.win, parent=self.send_tab.window,
) )
self.on_qr_from_screenshot_input_btn = partial( self.on_qr_from_screenshot_input_btn = partial(
self.input_qr_from_screenshot, self.input_qr_from_screenshot,
allow_multi=False, allow_multi=False,
show_error=self.win.show_error, show_error=self.send_tab.show_error,
setText=self._on_input_btn, setText=self.try_payment_identifier,
) )
self.on_input_file = partial( self.on_input_file = partial(
self.input_file, self.input_file,
config=self.config, config=self.config,
show_error=self.win.show_error, show_error=self.send_tab.show_error,
setText=self._on_input_btn, 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.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 @property
def editor(self): def multiline(self):
return self.text_edit if self.is_paytomany() else self.line_edit 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): def set_paytomany(self, b):
has_focus = self.editor.hasFocus()
self._is_paytomany = b self._is_paytomany = b
self.line_edit.setVisible(not b) self.multiline = b
self.text_edit.setVisible(b)
self.send_tab.paytomany_menu.setChecked(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) 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): def is_paytomany(self):
return self._is_paytomany 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) self.setReadOnly(b)
if not b: self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
self.setStyleSheet(normal_style)
def setTextNoCheck(self, text: str): def isFrozen(self):
"""Sets the text, while also ensuring the new value will not be resolved/checked.""" return self.isReadOnly()
self.previous_payto = text
self.setText(text)
def do_clear(self): def do_clear(self) -> None:
self.set_paytomany(False) self.set_paytomany(False)
self.disable_checks = False self.setText('')
self.is_alias = False self.setToolTip('')
self.line_edit.setText('') self.payment_identifier = None
self.text_edit.setText('')
self.setFrozen(False) def setGreen(self) -> None:
self.setEnabled(True)
def setGreen(self):
self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
def setExpired(self): def setExpired(self) -> None:
self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
def parse_address_and_amount(self, line) -> PartialTxOutput: def _handle_text_change(self) -> None:
try: if self.isFrozen():
x, y = line.split(',') # if editor is frozen, we ignore text changes as they might not be a payment identifier
except ValueError: # but a user friendly representation.
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():
return return
if full_check:
self.previous_payto = str(text).strip() # pushback timer if timer active or PI needs resolving
self.errors = [] pi = PaymentIdentifier(self.send_tab.wallet, self._text)
errors = [] if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive():
if self.disable_checks: self.edit_timer.start()
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()
else: else:
self.validated = None self.set_payment_identifier(self._text)
return True
def _on_edit_timer(self) -> None:
if not self.isFrozen():
self.set_payment_identifier(self._text)

604
electrum/gui/qt/send_tab.py

@ -2,32 +2,28 @@
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php # file LICENCE or http://www.opensource.org/licenses/mit-license.php
import asyncio
from decimal import Decimal from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any from typing import Optional, TYPE_CHECKING, Sequence, List, Callable
from urllib.parse import urlparse 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 electrum.i18n import _
from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, from electrum.logging import Logger
QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton)
from electrum import util, paymentrequest
from electrum import lnutil
from electrum.plugin import run_hook from electrum.plugin import run_hook
from electrum.i18n import _ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend
from electrum.util import (get_asyncio_loop, FailedToParsePaymentIdentifier,
InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds,
NoDynamicFeeEstimates, InvoiceError, parse_max_spend)
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST 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.network import TxBroadcastError, BestEffortRequestFailed
from electrum.logging import Logger from electrum.payment_identifier import PaymentIdentifierState, PaymentIdentifierType
from electrum.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit from .paytoedit import InvalidPaymentIdentifier
from .util import get_iconname_camera, get_iconname_qrcode, read_QIcon 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 from .confirm_tx_dialog import ConfirmTxDialog
if TYPE_CHECKING: if TYPE_CHECKING:
@ -36,20 +32,14 @@ if TYPE_CHECKING:
class SendTab(QWidget, MessageBoxMixin, Logger): class SendTab(QWidget, MessageBoxMixin, Logger):
payment_request_ok_signal = pyqtSignal() resolve_done_signal = pyqtSignal(object)
payment_request_error_signal = pyqtSignal() finalize_done_signal = pyqtSignal(object)
lnurl6_round1_signal = pyqtSignal(object, object) notify_merchant_done_signal = pyqtSignal(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
def __init__(self, window: 'ElectrumWindow'): def __init__(self, window: 'ElectrumWindow'):
QWidget.__init__(self, window) QWidget.__init__(self, window)
Logger.__init__(self) Logger.__init__(self)
self.app = QApplication.instance()
self.window = window self.window = window
self.wallet = window.wallet self.wallet = window.wallet
self.fx = window.fx self.fx = window.fx
@ -60,8 +50,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.format_amount = window.format_amount self.format_amount = window.format_amount
self.base_unit = window.base_unit self.base_unit = window.base_unit
self.payto_URI = None
self.payment_request = None # type: Optional[paymentrequest.PaymentRequest]
self.pending_invoice = None self.pending_invoice = None
# A 4-column grid layout. All the stretch is in the last column. # 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 '!', " + _("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.")) "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60."))
payto_label = HelpLabel(_('Pay to'), msg) payto_label = HelpLabel(_('Pay to'), msg)
grid.addWidget(payto_label, 1, 0) grid.addWidget(payto_label, 0, 0)
grid.addWidget(self.payto_e.line_edit, 1, 1, 1, 4) grid.addWidget(self.payto_e, 0, 1, 1, 4)
grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4)
#completer = QCompleter() #completer = QCompleter()
#completer.setCaseSensitivity(False) #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.') '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) description_label = HelpLabel(_('Description'), msg)
grid.addWidget(description_label, 2, 0) grid.addWidget(description_label, 1, 0)
self.message_e = SizedFreezableLineEdit(width=600) 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.') + ' ' msg = (_('The amount to be received by the recipient.') + ' '
+ _('Fees are paid by the sender.') + '\n\n' + _('Fees are paid by the sender.') + '\n\n'
@ -123,21 +118,34 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
btn_width = 10 * char_width_in_lineedit() btn_width = 10 * char_width_in_lineedit()
self.max_button.setFixedWidth(btn_width) self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True) self.max_button.setCheckable(True)
self.max_button.setEnabled(False)
grid.addWidget(self.max_button, 3, 3) 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 = 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.setIcon(read_QIcon('copy.png'))
self.paste_button.setToolTip(_('Paste invoice from clipboard')) self.paste_button.setToolTip(_('Paste invoice from clipboard'))
self.paste_button.setMaximumWidth(35) 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 = QHBoxLayout()
buttons.addStretch(1) buttons.addStretch(1)
#buttons.addWidget(self.paste_button)
buttons.addWidget(self.clear_button) buttons.addWidget(self.clear_button)
buttons.addWidget(self.save_button) buttons.addWidget(self.save_button)
buttons.addWidget(self.send_button) buttons.addWidget(self.send_button)
@ -147,20 +155,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def reset_max(text): def reset_max(text):
self.max_button.setChecked(False) 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.amount_e.textEdited.connect(reset_max)
self.fiat_send_e.textEdited.connect(reset_max) self.fiat_send_e.textEdited.connect(reset_max)
self.set_onchain(False)
self.invoices_label = QLabel(_('Invoices')) self.invoices_label = QLabel(_('Invoices'))
from .invoice_list import InvoiceList from .invoice_list import InvoiceList
self.invoice_list = InvoiceList(self) self.invoice_list = InvoiceList(self)
self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('') 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(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("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) 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 self.invoice_list.update() # after parented and put into a layout, can update without flickering
run_hook('create_send_tab', grid) run_hook('create_send_tab', grid)
self.payment_request_ok_signal.connect(self.payment_request_ok) self.resolve_done_signal.connect(self.on_resolve_done)
self.payment_request_error_signal.connect(self.payment_request_error) self.finalize_done_signal.connect(self.on_finalize_done)
self.lnurl6_round1_signal.connect(self.on_lnurl6_round1) self.notify_merchant_done_signal.connect(self.on_notify_merchant_done)
self.lnurl6_round2_signal.connect(self.on_lnurl6_round2) self.payto_e.paymentIdentifierChanged.connect(self._handle_payment_identifier)
self.clear_send_tab_signal.connect(self.do_clear)
self.show_error_signal.connect(self.show_error) 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): 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): if run_hook('abort_send', self):
return return
outputs = self.payto_e.get_outputs(True) outputs = self.payto_e.payment_identifier.get_onchain_outputs('!')
if not outputs: if not outputs:
return return
make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction( 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") text = _("Not enough funds")
frozen_str = self.get_frozen_balance_str() frozen_str = self.get_frozen_balance_str()
if frozen_str: if frozen_str:
text += " ({} {})".format( text += " ({} {})".format(frozen_str, _("are frozen"))
frozen_str, _("are frozen")
)
return text return text
def get_frozen_balance_str(self) -> Optional[str]: 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) return self.format_amount_and_units(frozen_bal)
def do_clear(self): 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.max_button.setChecked(False)
self.payment_request = None
self.payto_URI = None
self.payto_e.do_clear() self.payto_e.do_clear()
self.set_onchain(False) for w in [self.comment_e, self.comment_label]:
for e in [self.message_e, self.amount_e]: w.setVisible(False)
e.setText('') for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]:
e.setFrozen(False) w.setText('')
for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: w.setToolTip('')
e.setEnabled(True) for w in [self.save_button, self.send_button]:
w.setEnabled(False)
self.window.update_status() self.window.update_status()
run_hook('do_clear', self) self.paytomany_menu.setChecked(self.payto_e.multiline)
def set_onchain(self, b):
self._is_onchain = b
self.max_button.setEnabled(b)
def lock_amount(self, b: bool) -> None: run_hook('do_clear', self)
self.amount_e.setFrozen(b)
self.max_button.setEnabled(not b)
def prepare_for_send_tab_network_lookup(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]: for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False) btn.setEnabled(False)
self.payto_e.setTextNoCheck(_("please wait...")) self.showSpinner(True)
def payment_request_ok(self): def payment_request_error(self, error):
pr = self.payment_request self.show_message(error)
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
self.do_clear() self.do_clear()
def on_pr(self, request: 'paymentrequest.PaymentRequest'): def set_field_validated(self, w, *, validated: Optional[bool] = None):
self.payment_request = request if validated is not None:
if self.payment_request.verify(self.window.contacts): w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True))
self.payment_request_ok_signal.emit()
else:
self.payment_request_error_signal.emit()
def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True): def lock_fields(self, *,
try: lock_recipient: Optional[bool] = None,
url = decode_lnurl(lnurl) lock_amount: Optional[bool] = None,
except LnInvoiceException as e: lock_max: Optional[bool] = None,
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}") lock_description: Optional[bool] = None
return ) -> None:
if not can_use_network: 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 return
async def f(): if not pi.is_valid():
try: self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
lnurl_data = await request_lnurl(url) self.save_button.setEnabled(False)
except LNURLError as e: self.send_button.setEnabled(False)
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
self.clear_send_tab_signal.emit()
return
self.lnurl6_round1_signal.emit(lnurl_data, url)
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str):
self._lnurl_data = lnurl_data
domain = urlparse(url).netloc
self.payto_e.setTextNoCheck(f"invoice from lnurl")
self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}")
self.amount_e.setAmount(lnurl_data.min_sendable_sat)
self.amount_e.setFrozen(False)
for btn in [self.send_button, self.clear_button]:
btn.setEnabled(True)
self.set_onchain(False)
def set_bolt11(self, invoice: str):
"""Parse ln invoice, and prepare the send tab for it."""
try:
lnaddr = lndecode(invoice)
except LnInvoiceException as e:
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
return
except lnutil.IncompatibleOrInsaneFeatures as e:
self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
return return
pubkey = lnaddr.pubkey.serialize().hex() lock_recipient = pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
for k,v in lnaddr.tags: PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70,
if k == 'd': PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
description = v lock_amount = pi.is_amount_locked()
break lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21]
else:
description = '' self.lock_fields(lock_recipient=lock_recipient,
self.payto_e.setFrozen(True) lock_amount=lock_amount,
self.payto_e.setTextNoCheck(pubkey) lock_max=lock_max,
self.payto_e.lightning_invoice = invoice lock_description=False)
if not self.message_e.text(): if lock_recipient:
self.message_e.setText(description) fields = pi.get_fields_for_GUI()
if lnaddr.get_amount_sat() is not None: if fields.recipient:
self.amount_e.setAmount(lnaddr.get_amount_sat()) self.payto_e.setText(fields.recipient)
self.set_onchain(False) if fields.description:
self.message_e.setText(fields.description)
def set_bip21(self, text: str, *, can_use_network: bool = True): self.lock_fields(lock_description=True)
on_bip70_pr = self.on_pr if can_use_network else None if fields.amount:
try: self.amount_e.setAmount(fields.amount)
out = util.parse_URI(text, on_bip70_pr) for w in [self.comment_e, self.comment_label]:
except InvalidBitcoinURI as e: w.setVisible(bool(fields.comment))
self.show_error(_("Error parsing URI") + f":\n{e}") 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 return
self.payto_URI = out
r = out.get('r') if self.payto_e.payment_identifier.need_resolve():
sig = out.get('sig')
name = out.get('name')
if (r or (name and sig)) and can_use_network:
self.prepare_for_send_tab_network_lookup() 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 return
address = out.get('address') self.update_fields()
amount = out.get('amount')
label = out.get('label') def get_message(self):
message = out.get('message') return self.message_e.text()
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()
def read_invoice(self) -> Optional[Invoice]: def read_invoice(self) -> Optional[Invoice]:
if self.check_payto_line_and_show_errors(): if self.check_payto_line_and_show_errors():
return return
try: amount_sat = self.read_amount()
if not self._is_onchain: if not amount_sat:
invoice_str = self.payto_e.lightning_invoice self.show_error(_('No amount'))
if not invoice_str: return
return
invoice = Invoice.from_bech32(invoice_str) invoice = self.payto_e.payment_identifier.get_invoice(amount_sat, self.get_message())
if invoice.amount_msat is None: if not invoice:
amount_sat = self.get_amount() self.show_error('error getting invoice' + self.payto_e.payment_identifier.error)
if amount_sat: return
invoice.amount_msat = int(amount_sat * 1000) if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): self.show_error(_('Lightning is disabled'))
self.show_error(_('Lightning is disabled')) if self.wallet.get_invoice_status(invoice) == PR_PAID:
return # fixme: this is only for bip70 and lightning
return invoice self.show_error(_('Invoice already paid'))
else: return
outputs = self.read_outputs() #if not invoice.is_lightning():
if self.check_onchain_outputs_and_show_errors(outputs): # if self.check_onchain_outputs_and_show_errors(outputs):
return # return
message = self.message_e.text() return invoice
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))
def do_save_invoice(self): def do_save_invoice(self):
self.pending_invoice = self.read_invoice() self.pending_invoice = self.read_invoice()
@ -536,41 +498,23 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
# must not be None # must not be None
return self.amount_e.get_amount() or 0 return self.amount_e.get_amount() or 0
def _lnurl_get_invoice(self) -> None: def on_finalize_done(self, pi):
assert self._lnurl_data self.showSpinner(False)
amount = self.get_amount() self.update_fields()
if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat): if pi.error:
self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.') self.show_error(pi.error)
return return
invoice = pi.get_invoice(self.get_amount(), self.get_message())
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)
self.pending_invoice = invoice self.pending_invoice = invoice
self.logger.debug(f'after finalize invoice: {invoice!r}')
self.do_pay_invoice(invoice) self.do_pay_invoice(invoice)
def do_pay_or_get_invoice(self): def do_pay_or_get_invoice(self):
if self._lnurl_data: pi = self.payto_e.payment_identifier
self._lnurl_get_invoice() 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 return
self.pending_invoice = self.read_invoice() self.pending_invoice = self.read_invoice()
if not self.pending_invoice: if not self.pending_invoice:
@ -600,12 +544,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
else: else:
self.pay_onchain_dialog(invoice.outputs) self.pay_onchain_dialog(invoice.outputs)
def read_outputs(self) -> List[PartialTxOutput]: def read_amount(self) -> List[PartialTxOutput]:
if self.payment_request: amount = '!' if self.max_button.isChecked() else self.get_amount()
outputs = self.payment_request.get_outputs() return amount
else:
outputs = self.payto_e.get_outputs(self.max_button.isChecked())
return outputs
def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool: def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool:
"""Returns whether there are errors with outputs. """Returns whether there are errors with outputs.
@ -629,34 +570,31 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
"""Returns whether there are errors. """Returns whether there are errors.
Also shows error dialog to user if so. Also shows error dialog to user if so.
""" """
pr = self.payment_request error = self.payto_e.payment_identifier.get_error()
if pr: if error:
if pr.has_expired(): if not self.payto_e.payment_identifier.is_multiline():
self.show_error(_('Payment request has expired')) err = error
return True 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: warning = self.payto_e.payment_identifier.warning
errors = self.payto_e.get_errors() if warning:
if errors: warning += '\n' + _('Do you wish to continue?')
if len(errors) == 1 and not errors[0].is_multiline: if not self.question(warning):
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]))
return True return True
if self.payto_e.is_alias and self.payto_e.validated is False: if self.payto_e.payment_identifier.has_expired():
alias = self.payto_e.toPlainText() self.show_error(_('Payment request has expired'))
msg = _('WARNING: the alias "{}" could not be validated via an additional ' return True
'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
return False # no errors return False # no errors
@ -740,9 +678,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def broadcast_thread(): def broadcast_thread():
# non-GUI thread # non-GUI thread
pr = self.payment_request if self.payto_e.payment_identifier.has_expired():
if pr and pr.has_expired():
self.payment_request = None
return False, _("Invoice has expired") return False, _("Invoice has expired")
try: try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
@ -752,13 +688,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return False, repr(e) return False, repr(e)
# success # success
txid = tx.txid() txid = tx.txid()
if pr: if self.payto_e.payment_identifier.need_merchant_notify():
self.payment_request = None
refund_address = self.wallet.get_receiving_address() refund_address = self.wallet.get_receiving_address()
coro = pr.send_payment_and_receive_paymentack(tx.serialize(), refund_address) self.payto_e.payment_identifier.notify_merchant(
fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) tx=tx,
ack_status, ack_msg = fut.result(timeout=20) refund_address=refund_address,
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") on_finished=self.notify_merchant_done_signal.emit
)
return True, txid return True, txid
# Capture current TL window; override might be removed on return # Capture current TL window; override might be removed on return
@ -782,6 +718,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
WaitingDialog(self, _('Broadcasting transaction...'), WaitingDialog(self, _('Broadcasting transaction...'),
broadcast_thread, broadcast_done, self.window.on_error) 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): def toggle_paytomany(self):
self.payto_e.toggle_paytomany() self.payto_e.toggle_paytomany()
if self.payto_e.is_paytomany(): if self.payto_e.is_paytomany():
@ -798,9 +742,23 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.window.show_send_tab() self.window.show_send_tab()
self.payto_e.do_clear() self.payto_e.do_clear()
if len(paytos) == 1: if len(paytos) == 1:
self.logger.debug('payto_e setText 1')
self.payto_e.setText(paytos[0]) self.payto_e.setText(paytos[0])
self.amount_e.setFocus() self.amount_e.setFocus()
else: else:
self.payto_e.setFocus() self.payto_e.setFocus()
text = "\n".join([payto + ", 0" for payto in paytos]) text = "\n".join([payto + ", 0" for payto in paytos])
self.logger.debug('payto_e setText n')
self.payto_e.setText(text) 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)

16
electrum/gui/qt/util.py

@ -562,7 +562,10 @@ class GenericInputHandler:
new_text = self.text() + data + '\n' new_text = self.text() + data + '\n'
else: else:
new_text = data 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 from .qrreader import scan_qrcode
if parent is None: if parent is None:
@ -599,7 +602,10 @@ class GenericInputHandler:
new_text = self.text() + data + '\n' new_text = self.text() + data + '\n'
else: else:
new_text = data 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( def input_file(
self, self,
@ -628,7 +634,10 @@ class GenericInputHandler:
except BaseException as e: except BaseException as e:
show_error(_('Error opening file') + ':\n' + repr(e)) show_error(_('Error opening file') + ':\n' + repr(e))
else: 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( def input_paste_from_clipboard(
self, self,
@ -961,6 +970,7 @@ class ColorScheme:
YELLOW = ColorSchemeItem("#897b2a", "#ffff00") YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
RED = ColorSchemeItem("#7c1111", "#f18c8c") RED = ColorSchemeItem("#7c1111", "#f18c8c")
BLUE = ColorSchemeItem("#123b7c", "#8cb3f2") BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff")
DEFAULT = ColorSchemeItem("black", "white") DEFAULT = ColorSchemeItem("black", "white")
GRAY = ColorSchemeItem("gray", "gray") GRAY = ColorSchemeItem("gray", "gray")

2
electrum/invoices.py

@ -7,6 +7,7 @@ import attr
from .json_db import StoredObject, stored_in from .json_db import StoredObject, stored_in
from .i18n import _ from .i18n import _
from .util import age, InvoiceError, format_satoshis from .util import age, InvoiceError, format_satoshis
from .bip21 import create_bip21_uri
from .lnutil import hex_to_bytes from .lnutil import hex_to_bytes
from .lnaddr import lndecode, LnAddr from .lnaddr import lndecode, LnAddr
from . import constants from . import constants
@ -318,7 +319,6 @@ class Request(BaseInvoice):
*, *,
lightning_invoice: Optional[str] = None, lightning_invoice: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
from electrum.util import create_bip21_uri
addr = self.get_address() addr = self.get_address()
amount = self.get_amount_sat() amount = self.get_amount_sat()
if amount is not None: if amount is not None:

6
electrum/network.py

@ -59,7 +59,6 @@ from .interface import (Interface, PREFERRED_NETWORK_PROTOCOL,
RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS, RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS,
NetworkException, RequestCorrupted, ServerAddr) NetworkException, RequestCorrupted, ServerAddr)
from .version import PROTOCOL_VERSION from .version import PROTOCOL_VERSION
from .simple_config import SimpleConfig
from .i18n import _ from .i18n import _
from .logging import get_logger, Logger from .logging import get_logger, Logger
@ -71,6 +70,7 @@ if TYPE_CHECKING:
from .lnworker import LNGossip from .lnworker import LNGossip
from .lnwatcher import WatchTower from .lnwatcher import WatchTower
from .daemon import Daemon from .daemon import Daemon
from .simple_config import SimpleConfig
_logger = get_logger(__name__) _logger = get_logger(__name__)
@ -270,7 +270,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
local_watchtower: Optional['WatchTower'] = None local_watchtower: Optional['WatchTower'] = None
path_finder: Optional['LNPathFinder'] = None path_finder: Optional['LNPathFinder'] = None
def __init__(self, config: SimpleConfig, *, daemon: 'Daemon' = None): def __init__(self, config: 'SimpleConfig', *, daemon: 'Daemon' = None):
global _INSTANCE global _INSTANCE
assert _INSTANCE is None, "Network is a singleton!" assert _INSTANCE is None, "Network is a singleton!"
_INSTANCE = self _INSTANCE = self
@ -287,9 +287,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
self.asyncio_loop = util.get_asyncio_loop() self.asyncio_loop = util.get_asyncio_loop()
assert self.asyncio_loop.is_running(), "event loop not running" 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.config = config
self.daemon = daemon self.daemon = daemon
blockchain.read_blockchains(self.config) blockchain.read_blockchains(self.config)

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

24
electrum/tests/test_util.py

@ -2,12 +2,10 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from electrum import util from electrum import util
from electrum.util import (format_satoshis, format_fee_satoshis, parse_URI, from electrum.util import (format_satoshis, format_fee_satoshis, is_hash256_str, chunks, is_ip_address,
is_hash256_str, chunks, is_ip_address, list_enabled_bits, list_enabled_bits, format_satoshis_plain, is_private_netaddress, is_hex_str,
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_integer, is_non_negative_integer, is_int_or_float, from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI
is_non_negative_int_or_float, is_subpath, InvalidBitcoinURI)
from . import ElectrumTestCase, as_testnet from . import ElectrumTestCase, as_testnet
@ -102,7 +100,7 @@ class TestUtil(ElectrumTestCase):
self.assertEqual("0.01234", format_satoshis_plain(1234, decimal_point=5)) self.assertEqual("0.01234", format_satoshis_plain(1234, decimal_point=5))
def _do_test_parse_URI(self, uri, expected): def _do_test_parse_URI(self, uri, expected):
result = parse_URI(uri) result = parse_bip21_URI(uri)
self.assertEqual(expected, result) self.assertEqual(expected, result)
def test_parse_URI_address(self): def test_parse_URI_address(self):
@ -143,13 +141,13 @@ class TestUtil(ElectrumTestCase):
{'r': 'http://domain.tld/page?h=2a8628fc2fbe'}) {'r': 'http://domain.tld/page?h=2a8628fc2fbe'})
def test_parse_URI_invalid_address(self): 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): 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): 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 @as_testnet
def test_parse_URI_lightning_consistency(self): def test_parse_URI_lightning_consistency(self):
@ -174,11 +172,11 @@ class TestUtil(ElectrumTestCase):
'memo': 'test266', 'memo': 'test266',
'message': 'test266'}) 'message': 'test266'})
# bip21 uri that includes "lightning" key. LN part has fallback address BUT it mismatches the top-level address # 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 # 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 # 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): def test_is_hash256_str(self):
self.assertTrue(is_hash256_str('09a4c03e3bdf83bbe3955f907ee52da4fc12f4813d459bc75228b64ad08617c7')) self.assertTrue(is_hash256_str('09a4c03e3bdf83bbe3955f907ee52da4fc12f4813d459bc75228b64ad08617c7'))

169
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] url_parts = [explorer_url, kind_str, item]
return ''.join(url_parts) 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 # Python bug (http://bugs.python.org/issue1927) causes raw_input

4
electrum/wallet.py

@ -57,7 +57,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore
format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates, format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates,
WalletFileException, BitcoinException, WalletFileException, BitcoinException,
InvalidPassword, format_time, timestamp_to_datetime, Satoshis, 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 .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE
from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import COIN, TYPE_ADDRESS
from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold 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 . import keystore
from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK, from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK,
AddressIndexGeneric, CannotDerivePubkey) AddressIndexGeneric, CannotDerivePubkey)
from .util import multisig_type from .util import multisig_type, parse_max_spend
from .storage import StorageEncryptionVersion, WalletStorage from .storage import StorageEncryptionVersion, WalletStorage
from .wallet_db import WalletDB from .wallet_db import WalletDB
from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32 from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32

13
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.logging import get_logger, configure_logging # import logging submodule first
from electrum import util from electrum import util
from electrum.payment_identifier import PaymentIdentifier
from electrum import constants from electrum import constants
from electrum import SimpleConfig from electrum import SimpleConfig
from electrum.wallet_db import WalletDB from electrum.wallet_db import WalletDB
@ -364,12 +365,6 @@ def main():
if not config_options.get('verbosity'): if not config_options.get('verbosity'):
warnings.simplefilter('ignore', DeprecationWarning) 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) config = SimpleConfig(config_options)
cmdname = config.get('cmd') cmdname = config.get('cmd')
@ -397,6 +392,12 @@ def main():
elif config.get('signet'): elif config.get('signet'):
constants.set_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"): if cmdname == 'daemon' and config.get("detach"):
# detect lockfile. # detect lockfile.
# This is not as good as get_file_descriptor, but that would require the asyncio loop # This is not as good as get_file_descriptor, but that would require the asyncio loop

Loading…
Cancel
Save