Browse Source

payment_identifiers:

- this separates GUI from core handling
 - the PaymentIdentifier class handles network requests
 - the GUI is agnostic about the type of PI
master
ThomasV 3 years ago committed by Sander van Grieken
parent
commit
15eb765eac
  1. 5
      electrum/gui/kivy/main_window.py
  2. 6
      electrum/gui/kivy/uix/screens.py
  3. 2
      electrum/gui/qml/qeapp.py
  4. 4
      electrum/gui/qt/__init__.py
  5. 5
      electrum/gui/qt/main_window.py
  6. 212
      electrum/gui/qt/paytoedit.py
  7. 440
      electrum/gui/qt/send_tab.py
  8. 1
      electrum/gui/qt/util.py
  9. 2
      electrum/invoices.py
  10. 559
      electrum/payment_identifier.py
  11. 3
      electrum/transaction.py
  12. 169
      electrum/util.py
  13. 3
      electrum/wallet.py
  14. 3
      electrum/x509.py
  15. 5
      run_electrum

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.payment_identifier 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

6
electrum/gui/kivy/uix/screens.py

@ -18,8 +18,8 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING,
from electrum import bitcoin, constants from electrum import 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.payment_identifier import parse_bip21_URI, BITCOIN_BIP21_URI_SCHEME, maybe_extract_lightning_payment_identifier, InvalidBitcoinURI
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 +208,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.payment_identifier 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

4
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,7 @@ 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.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]:

5
electrum/gui/qt/main_window.py

@ -58,9 +58,10 @@ from electrum.i18n import _
from electrum.util import (format_time, get_asyncio_loop, from electrum.util import (format_time, get_asyncio_loop,
UserCancelled, profiler, UserCancelled, profiler,
bfh, InvalidPassword, bfh, InvalidPassword,
UserFacingException, FailedToParsePaymentIdentifier, UserFacingException,
get_new_wallet_name, send_exception_to_crash_reporter, get_new_wallet_name, send_exception_to_crash_reporter,
AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod) AddTransactionException, os_chmod)
from electrum.payment_identifier import FailedToParsePaymentIdentifier, BITCOIN_BIP21_URI_SCHEME
from electrum.invoices import PR_PAID, Invoice from electrum.invoices import PR_PAID, Invoice
from electrum.transaction import (Transaction, PartialTxInput, from electrum.transaction import (Transaction, PartialTxInput,
PartialTransaction, PartialTxOutput) PartialTransaction, PartialTxOutput)

212
electrum/gui/qt/paytoedit.py

@ -33,7 +33,8 @@ from PyQt5.QtGui import QFontMetrics, QFont
from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout
from electrum import bitcoin from electrum import bitcoin
from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier from electrum.util import parse_max_spend
from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier
from electrum.transaction import PartialTxOutput from electrum.transaction import PartialTxOutput
from electrum.bitcoin import opcodes, construct_script from electrum.bitcoin import opcodes, construct_script
from electrum.logging import Logger from electrum.logging import Logger
@ -49,20 +50,10 @@ if TYPE_CHECKING:
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):
line_content: str
exc: Exception
idx: int = 0 # index of line
is_multiline: bool = False
class ResizingTextEdit(QTextEdit): class ResizingTextEdit(QTextEdit):
def __init__(self): def __init__(self):
@ -109,12 +100,9 @@ class PayToEdit(Logger, GenericInputHandler):
self.amount_edit = self.send_tab.amount_e self.amount_edit = self.send_tab.amount_e
self.is_multiline = False self.is_multiline = False
self.outputs = [] # type: List[PartialTxOutput]
self.errors = [] # type: List[PayToLineError]
self.disable_checks = False self.disable_checks = False
self.is_alias = False self.is_alias = False
self.payto_scriptpubkey = None # type: Optional[bytes] self.payto_scriptpubkey = None # type: Optional[bytes]
self.lightning_invoice = None
self.previous_payto = '' self.previous_payto = ''
# editor methods # editor methods
self.setStyleSheet = self.editor.setStyleSheet self.setStyleSheet = self.editor.setStyleSheet
@ -180,6 +168,7 @@ class PayToEdit(Logger, GenericInputHandler):
self.setText(text) self.setText(text)
def do_clear(self): def do_clear(self):
self.is_multiline = False
self.set_paytomany(False) self.set_paytomany(False)
self.disable_checks = False self.disable_checks = False
self.is_alias = False self.is_alias = False
@ -194,58 +183,6 @@ class PayToEdit(Logger, GenericInputHandler):
def setExpired(self): def setExpired(self):
self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
def parse_address_and_amount(self, line) -> PartialTxOutput:
try:
x, y = line.split(',')
except ValueError:
raise Exception("expected two comma-separated values: (address, amount)") from None
scriptpubkey = self.parse_output(x)
amount = self.parse_amount(y)
return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
def parse_output(self, x) -> bytes:
try:
address = self.parse_address(x)
return bytes.fromhex(bitcoin.address_to_script(address))
except Exception:
pass
try:
script = self.parse_script(x)
return bytes.fromhex(script)
except Exception:
pass
raise Exception("Invalid address or script.")
def parse_script(self, x):
script = ''
for word in x.split():
if word[0:3] == 'OP_':
opcode_int = opcodes[word]
script += construct_script([opcode_int])
else:
bytes.fromhex(word) # to test it is hex data
script += construct_script([word])
return script
def parse_amount(self, x):
x = x.strip()
if not x:
raise Exception("Amount is empty")
if parse_max_spend(x):
return x
p = pow(10, self.amount_edit.decimal_point())
try:
return int(p * Decimal(x))
except decimal.InvalidOperation:
raise Exception("Invalid amount")
def parse_address(self, line):
r = line.strip()
m = re.match('^'+RE_ALIAS+'$', r)
address = str(m.group(2) if m else r)
assert bitcoin.is_address(address)
return address
def _on_input_btn(self, text: str): def _on_input_btn(self, text: str):
self.setText(text) self.setText(text)
@ -257,6 +194,7 @@ class PayToEdit(Logger, GenericInputHandler):
if self.is_multiline and not self._is_paytomany: if self.is_multiline and not self._is_paytomany:
self.set_paytomany(True) self.set_paytomany(True)
self.text_edit.setText(text) self.text_edit.setText(text)
self.text_edit.setFocus()
def on_timer_check_text(self): def on_timer_check_text(self):
if self.editor.hasFocus(): if self.editor.hasFocus():
@ -265,149 +203,33 @@ class PayToEdit(Logger, GenericInputHandler):
self._check_text(text, full_check=True) self._check_text(text, full_check=True)
def _check_text(self, text, *, full_check: bool): def _check_text(self, text, *, full_check: bool):
""" """ side effects: self.is_multiline """
side effects: self.is_multiline, self.errors, self.outputs text = str(text).strip()
""" if not text:
if self.previous_payto == str(text).strip(): return
if self.previous_payto == text:
return return
if full_check: if full_check:
self.previous_payto = str(text).strip() self.previous_payto = text
self.errors = []
errors = []
if self.disable_checks: if self.disable_checks:
return return
# filter out empty lines pi = PaymentIdentifier(self.config, self.win.contacts, text)
lines = text.split('\n') self.is_multiline = bool(pi.multiline_outputs)
lines = [i for i in lines if i] print('is_multiline', self.is_multiline)
self.is_multiline = len(lines)>1 self.send_tab.handle_payment_identifier(pi, can_use_network=full_check)
self.payto_scriptpubkey = None def handle_multiline(self, outputs):
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 total = 0
is_max = False is_max = False
for i, line in enumerate(lines): for output in outputs:
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): if parse_max_spend(output.value):
is_max = True is_max = True
else: else:
total += output.value total += output.value
if outputs:
self.send_tab.set_onchain(True) self.send_tab.set_onchain(True)
self.send_tab.max_button.setChecked(is_max) self.send_tab.max_button.setChecked(is_max)
self.outputs = outputs
self.payto_scriptpubkey = None
if self.send_tab.max_button.isChecked(): if self.send_tab.max_button.isChecked():
self.send_tab.spend_max() self.send_tab.spend_max()
else: else:
self.amount_edit.setAmount(total if outputs else None) self.amount_edit.setAmount(total if outputs else None)
self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs)) #self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs))
def get_errors(self) -> Sequence[PayToLineError]:
return self.errors
def get_destination_scriptpubkey(self) -> Optional[bytes]:
return self.payto_scriptpubkey
def get_outputs(self, is_max: bool) -> List[PartialTxOutput]:
if self.payto_scriptpubkey:
if is_max:
amount = '!'
else:
amount = self.send_tab.get_amount()
self.outputs = [PartialTxOutput(scriptpubkey=self.payto_scriptpubkey, value=amount)]
return self.outputs[:]
def _resolve_openalias(self, text: str) -> Optional[dict]:
key = text
key = key.strip() # strip whitespaces
if not (('.' in key) and ('<' not in key) and (' ' not in key)):
return None
parts = key.split(sep=',') # assuming single line
if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
return None
try:
data = self.win.contacts.resolve(key)
except Exception as e:
self.logger.info(f'error resolving address/alias: {repr(e)}')
return None
return data or None
def _set_openalias(self, *, key: str, data: dict) -> bool:
self.is_alias = True
self.setFrozen(True)
key = key.strip() # strip whitespaces
address = data.get('address')
name = data.get('name')
new_url = key + ' <' + address + '>'
self.setText(new_url)
#if self.win.config.get('openalias_autoadd') == 'checked':
self.win.contacts[key] = ('openalias', name)
self.win.contact_list.update()
if data.get('type') == 'openalias':
self.validated = data.get('validated')
if self.validated:
self.setGreen()
else:
self.setExpired()
else:
self.validated = None
return True

440
electrum/gui/qt/send_tab.py

@ -5,8 +5,6 @@
import asyncio 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, Any
from urllib.parse import urlparse
from PyQt5.QtCore import pyqtSignal, QPoint from PyQt5.QtCore import pyqtSignal, QPoint
from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout,
QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton)
@ -15,15 +13,14 @@ from electrum import util, paymentrequest
from electrum import lnutil from electrum import lnutil
from electrum.plugin import run_hook from electrum.plugin import run_hook
from electrum.i18n import _ from electrum.i18n import _
from electrum.util import (get_asyncio_loop, FailedToParsePaymentIdentifier,
InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds, from electrum.util import get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates, InvoiceError, parse_max_spend
NoDynamicFeeEstimates, InvoiceError, parse_max_spend) from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier, InvalidBitcoinURI
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.logging import Logger from electrum.logging import Logger
from electrum.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit
@ -36,15 +33,9 @@ if TYPE_CHECKING:
class SendTab(QWidget, MessageBoxMixin, Logger): class SendTab(QWidget, MessageBoxMixin, Logger):
payment_request_ok_signal = pyqtSignal() round_1_signal = pyqtSignal(object)
payment_request_error_signal = pyqtSignal() round_2_signal = pyqtSignal(object)
lnurl6_round1_signal = pyqtSignal(object, object) round_3_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)
@ -60,8 +51,7 @@ 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_identifier = 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 +74,9 @@ 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.line_edit, 0, 1, 1, 4)
grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4) grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4)
#completer = QCompleter() #completer = QCompleter()
#completer.setCaseSensitivity(False) #completer.setCaseSensitivity(False)
@ -97,9 +87,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
+ _( + _(
'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.') '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'
@ -129,11 +127,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
self.clear_button = EnterButton(_("Clear"), self.do_clear) 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)
buttons = QHBoxLayout() buttons = QHBoxLayout()
buttons.addStretch(1) buttons.addStretch(1)
@ -160,7 +158,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
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 +183,33 @@ 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.round_1_signal.connect(self.on_round_1)
self.payment_request_error_signal.connect(self.payment_request_error) self.round_2_signal.connect(self.on_round_2)
self.lnurl6_round1_signal.connect(self.on_lnurl6_round1) self.round_3_signal.connect(self.on_round_3)
self.lnurl6_round2_signal.connect(self.on_lnurl6_round2)
self.clear_send_tab_signal.connect(self.do_clear) def do_paste(self):
self.show_error_signal.connect(self.show_error) text = self.window.app.clipboard().text()
if not text:
return
self.set_payment_identifier(text)
def set_payment_identifier(self, text):
pi = PaymentIdentifier(self.config, self.window.contacts, text)
if pi.error:
self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error)
return
if pi.is_multiline():
self.payto_e.set_paytomany(True)
self.payto_e.text_edit.setText(text)
else:
self.payto_e.setTextNoCheck(text)
self.handle_payment_identifier(pi, can_use_network=True)
def spend_max(self): def spend_max(self):
if run_hook('abort_send', self): if run_hook('abort_send', self):
return return
outputs = self.payto_e.get_outputs(True) amount = self.get_amount()
outputs = self.payment_identifier.get_onchain_outputs(amount)
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(
@ -297,15 +310,14 @@ 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.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) self.set_onchain(False)
for e in [self.message_e, self.amount_e]: for w in [self.comment_e, self.comment_label]:
w.setVisible(False)
for e in [self.message_e, self.amount_e, self.fiat_send_e]:
e.setText('') e.setText('')
e.setFrozen(False) self.set_field_style(e, None, False)
for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]: for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]:
e.setEnabled(True) e.setEnabled(True)
self.window.update_status() self.window.update_status()
@ -315,208 +327,101 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self._is_onchain = b self._is_onchain = b
self.max_button.setEnabled(b) self.max_button.setEnabled(b)
def lock_amount(self, b: bool) -> None:
self.amount_e.setFrozen(b)
self.max_button.setEnabled(not b)
def prepare_for_send_tab_network_lookup(self): def prepare_for_send_tab_network_lookup(self):
self.window.show_send_tab() self.window.show_send_tab()
self.payto_e.disable_checks = True self.payto_e.disable_checks = True
for e in [self.payto_e, self.message_e]: #for e in [self.payto_e, self.message_e]:
e.setFrozen(True) self.payto_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.payto_e.setTextNoCheck(_("please wait..."))
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_style(self, w, text, validated):
self.payment_request = request from .util import ColorScheme
if self.payment_request.verify(self.window.contacts): if validated is None:
self.payment_request_ok_signal.emit() style = ColorScheme.LIGHTBLUE.as_stylesheet(True)
elif validated is True:
style = ColorScheme.GREEN.as_stylesheet(True)
else: else:
self.payment_request_error_signal.emit() style = ColorScheme.RED.as_stylesheet(True)
if text is not None:
def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True): w.setStyleSheet(style)
try: w.setReadOnly(True)
url = decode_lnurl(lnurl)
except LnInvoiceException as e:
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
return
if not can_use_network:
return
async def f():
try:
lnurl_data = await request_lnurl(url)
except LNURLError as e:
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
self.clear_send_tab_signal.emit()
return
self.lnurl6_round1_signal.emit(lnurl_data, url)
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str):
self._lnurl_data = lnurl_data
domain = urlparse(url).netloc
self.payto_e.setTextNoCheck(f"invoice from lnurl")
self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}")
self.amount_e.setAmount(lnurl_data.min_sendable_sat)
self.amount_e.setFrozen(False)
for btn in [self.send_button, self.clear_button]:
btn.setEnabled(True)
self.set_onchain(False)
def set_bolt11(self, invoice: str):
"""Parse ln invoice, and prepare the send tab for it."""
try:
lnaddr = lndecode(invoice)
except LnInvoiceException as e:
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
return
except lnutil.IncompatibleOrInsaneFeatures as e:
self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
return
pubkey = lnaddr.pubkey.serialize().hex()
for k,v in lnaddr.tags:
if k == 'd':
description = v
break
else: else:
description = '' w.setStyleSheet('')
self.payto_e.setFrozen(True) w.setReadOnly(False)
self.payto_e.setTextNoCheck(pubkey)
self.payto_e.lightning_invoice = invoice def update_fields(self, pi):
if not self.message_e.text(): recipient, amount, description, comment, validated = pi.get_fields_for_GUI(self.wallet)
if recipient:
self.payto_e.setTextNoCheck(recipient)
elif pi.multiline_outputs:
self.payto_e.handle_multiline(pi.multiline_outputs)
if description:
self.message_e.setText(description) self.message_e.setText(description)
if lnaddr.get_amount_sat() is not None:
self.amount_e.setAmount(lnaddr.get_amount_sat())
self.set_onchain(False)
def set_bip21(self, text: str, *, can_use_network: bool = True):
on_bip70_pr = self.on_pr if can_use_network else None
try:
out = util.parse_URI(text, on_bip70_pr)
except InvalidBitcoinURI as e:
self.show_error(_("Error parsing URI") + f":\n{e}")
return
self.payto_URI = out
r = out.get('r')
sig = out.get('sig')
name = out.get('name')
if (r or (name and sig)) and can_use_network:
self.prepare_for_send_tab_network_lookup()
return
address = out.get('address')
amount = out.get('amount')
label = out.get('label')
message = out.get('message')
lightning = out.get('lightning')
if lightning and (self.wallet.has_lightning() or not address):
self.handle_payment_identifier(lightning, can_use_network=can_use_network)
return
# use label as description (not BIP21 compliant)
if label and not message:
message = label
if address:
self.payto_e.setText(address)
if message:
self.message_e.setText(message)
if amount: if amount:
self.amount_e.setAmount(amount) self.amount_e.setAmount(amount)
for w in [self.comment_e, self.comment_label]:
def handle_payment_identifier(self, text: str, *, can_use_network: bool = True): w.setVisible(not bool(comment))
"""Takes self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated)
Lightning identifiers: self.set_field_style(self.message_e, description, validated)
* lightning-URI (containing bolt11 or lnurl) self.set_field_style(self.amount_e, amount, validated)
* bolt11 invoice self.set_field_style(self.fiat_send_e, amount, validated)
* lnurl
Bitcoin identifiers: def handle_payment_identifier(self, pi, *, can_use_network: bool = True):
* bitcoin-URI self.payment_identifier = pi
and sets the sending screen. is_valid = pi.is_valid()
""" self.save_button.setEnabled(is_valid)
text = text.strip() self.send_button.setEnabled(is_valid)
if not text: if not is_valid:
return return
if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): self.update_fields(pi)
if invoice_or_lnurl.startswith('lnurl'): if can_use_network and pi.needs_round_1():
self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network) coro = pi.round_1(on_success=self.round_1_signal.emit)
else: asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
self.set_bolt11(invoice_or_lnurl) self.prepare_for_send_tab_network_lookup()
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 # update fiat amount
self.amount_e.textEdited.emit("") self.amount_e.textEdited.emit("")
self.window.show_send_tab() self.window.show_send_tab()
def on_round_1(self, pi):
if pi.error:
self.show_error(pi.error)
self.do_clear()
return
self.update_fields(pi)
for btn in [self.send_button, self.clear_button, self.save_button]:
btn.setEnabled(True)
def get_message(self):
return self.message_e.text()
def read_invoice(self) -> Optional[Invoice]: 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
invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message())
#except Exception as e:
if not invoice:
self.show_error('error getting invoice' + pi.error)
return return
invoice = Invoice.from_bech32(invoice_str)
if invoice.amount_msat is None:
amount_sat = self.get_amount()
if amount_sat:
invoice.amount_msat = int(amount_sat * 1000)
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain(): 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:
# fixme: this is only for bip70 and lightning
self.show_error(_('Invoice already paid'))
return return
#if not invoice.is_lightning():
# if self.check_onchain_outputs_and_show_errors(outputs):
# return
return invoice return invoice
else:
outputs = self.read_outputs()
if self.check_onchain_outputs_and_show_errors(outputs):
return
message = self.message_e.text()
return self.wallet.create_invoice(
outputs=outputs,
message=message,
pr=self.payment_request,
URI=self.payto_URI)
except InvoiceError as e:
self.show_error(_('Error creating payment') + ':\n' + str(e))
def do_save_invoice(self): def do_save_invoice(self):
self.pending_invoice = self.read_invoice() self.pending_invoice = self.read_invoice()
@ -536,41 +441,26 @@ 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_round_2(self, pi):
assert self._lnurl_data self.do_clear()
amount = self.get_amount() if pi.error:
if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat): self.show_error(pi.error)
self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.')
return
async def f():
try:
invoice_data = await callback_lnurl(
self._lnurl_data.callback_url,
params={'amount': self.get_amount() * 1000},
)
except LNURLError as e:
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
self.clear_send_tab_signal.emit()
return
invoice = invoice_data.get('pr')
self.lnurl6_round2_signal.emit(invoice)
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
def on_lnurl6_round2(self, bolt11_invoice: str):
self._lnurl_data = None
invoice = Invoice.from_bech32(bolt11_invoice)
assert invoice.get_amount_sat() == self.get_amount(), (invoice.get_amount_sat(), self.get_amount())
self.do_clear() self.do_clear()
self.payto_e.setText(bolt11_invoice) return
self.update_fields(pi)
invoice = pi.get_invoice(self.wallet, self.get_amount(), self.get_message())
self.pending_invoice = invoice self.pending_invoice = invoice
self.do_pay_invoice(invoice) self.do_pay_invoice(invoice)
def on_round_3(self):
pass
def do_pay_or_get_invoice(self): def do_pay_or_get_invoice(self):
if self._lnurl_data: pi = self.payment_identifier
self._lnurl_get_invoice() if pi.needs_round_2():
coro = pi.round_2(self.round_2_signal.emit, amount_sat=self.get_amount(), comment=self.message_e.text())
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
return 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 +490,10 @@ 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: is_max = self.max_button.isChecked()
outputs = self.payment_request.get_outputs() amount = '!' if is_max else self.get_amount()
else: return amount
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,35 +517,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.payment_identifier.get_error()
if pr: if error:
if pr.has_expired(): if not self.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" +
if not pr:
errors = self.payto_e.get_errors()
if errors:
if len(errors) == 1 and not errors[0].is_multiline:
err = errors[0]
self.show_warning(_("Failed to parse 'Pay to' line") + ":\n" +
f"{err.line_content[:40]}...\n\n" f"{err.line_content[:40]}...\n\n"
f"{err.exc!r}") f"{err.exc!r}")
else: else:
self.show_warning(_("Invalid Lines found:") + "\n\n" + self.show_warning(
'\n'.join([_("Line #") + _("Invalid Lines found:") + "\n\n" + error)
f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})" #'\n'.join([_("Line #") +
for err in errors])) # 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.payment_identifier.warning:
alias = self.payto_e.toPlainText() msg += '\n' + _('Do you wish to continue?')
msg = _('WARNING: the alias "{}" could not be validated via an additional '
'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n'
msg += _('Do you wish to continue?')
if not self.question(msg): if not self.question(msg):
return True return True
if self.payment_identifier.has_expired():
self.show_error(_('Payment request has expired'))
return True
return False # no errors return False # no errors
def pay_lightning_invoice(self, invoice: Invoice): def pay_lightning_invoice(self, invoice: Invoice):
@ -740,9 +624,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def broadcast_thread(): def broadcast_thread():
# non-GUI thread # non-GUI thread
pr = self.payment_request if self.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 +634,10 @@ 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.payment_identifier.needs_round_3():
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) coro = self.payment_identifier.round_3(tx.serialize(), refund_address)
fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
ack_status, ack_msg = fut.result(timeout=20)
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
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
@ -804,3 +683,4 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
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.payto_e.setText(text) self.payto_e.setText(text)
self.payto_e.setFocus()

1
electrum/gui/qt/util.py

@ -961,6 +961,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 .payment_identifier 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:

559
electrum/payment_identifier.py

@ -0,0 +1,559 @@
import asyncio
import urllib
import re
from decimal import Decimal
from typing import NamedTuple, Optional, Callable, Any, Sequence
from urllib.parse import urlparse
from . import bitcoin
from .logging import Logger
from .util import parse_max_spend, format_satoshis_plain
from .util import get_asyncio_loop, log_exceptions
from .transaction import PartialTxOutput
from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from .lnaddr import lndecode, LnDecodeException, LnInvoiceException
from .lnutil import IncompatibleOrInsaneFeatures
def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
data = data.strip() # whitespaces
data = data.lower()
if data.startswith(LIGHTNING_URI_SCHEME + ':ln'):
cut_prefix = LIGHTNING_URI_SCHEME + ':'
data = data[len(cut_prefix):]
if data.startswith('ln'):
return data
return None
# URL decode
#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE)
#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x)
# note: when checking against these, use .lower() to support case-insensitivity
BITCOIN_BIP21_URI_SCHEME = 'bitcoin'
LIGHTNING_URI_SCHEME = 'lightning'
class InvalidBitcoinURI(Exception): pass
def parse_bip21_URI(uri: str) -> dict:
"""Raises InvalidBitcoinURI on malformed URI."""
if not isinstance(uri, str):
raise InvalidBitcoinURI(f"expected string, not {repr(uri)}")
if ':' not in uri:
if not bitcoin.is_address(uri):
raise InvalidBitcoinURI("Not a bitcoin address")
return {'address': uri}
u = urllib.parse.urlparse(uri)
if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME:
raise InvalidBitcoinURI("Not a bitcoin URI")
address = u.path
# python for android fails to parse query
if address.find('?') > 0:
address, query = u.path.split('?')
pq = urllib.parse.parse_qs(query)
else:
pq = urllib.parse.parse_qs(u.query)
for k, v in pq.items():
if len(v) != 1:
raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}')
out = {k: v[0] for k, v in pq.items()}
if address:
if not bitcoin.is_address(address):
raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}")
out['address'] = address
if 'amount' in out:
am = out['amount']
try:
m = re.match(r'([0-9.]+)X([0-9])', am)
if m:
k = int(m.group(2)) - 8
amount = Decimal(m.group(1)) * pow(Decimal(10), k)
else:
amount = Decimal(am) * COIN
if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC")
out['amount'] = int(amount)
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e
if 'message' in out:
out['message'] = out['message']
out['memo'] = out['message']
if 'time' in out:
try:
out['time'] = int(out['time'])
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e
if 'exp' in out:
try:
out['exp'] = int(out['exp'])
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e
if 'sig' in out:
try:
out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex()
except Exception as e:
raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e
if 'lightning' in out:
try:
lnaddr = lndecode(out['lightning'])
except LnDecodeException as e:
raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e
amount_sat = out.get('amount')
if amount_sat:
# allow small leeway due to msat precision
if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1:
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount")
address = out.get('address')
ln_fallback_addr = lnaddr.get_fallback_address()
if address and ln_fallback_addr:
if ln_fallback_addr != address:
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address")
return out
def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str],
*, extra_query_params: Optional[dict] = None) -> str:
if not bitcoin.is_address(addr):
return ""
if extra_query_params is None:
extra_query_params = {}
query = []
if amount_sat:
query.append('amount=%s'%format_satoshis_plain(amount_sat))
if message:
query.append('message=%s'%urllib.parse.quote(message))
for k, v in extra_query_params.items():
if not isinstance(k, str) or k != urllib.parse.quote(k):
raise Exception(f"illegal key for URI: {repr(k)}")
v = urllib.parse.quote(v)
query.append(f"{k}={v}")
p = urllib.parse.ParseResult(
scheme=BITCOIN_BIP21_URI_SCHEME,
netloc='',
path=addr,
params='',
query='&'.join(query),
fragment='',
)
return str(urllib.parse.urlunparse(p))
def is_uri(data: str) -> bool:
data = data.lower()
if (data.startswith(LIGHTNING_URI_SCHEME + ":") or
data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')):
return True
return False
class FailedToParsePaymentIdentifier(Exception):
pass
class PayToLineError(NamedTuple):
line_content: str
exc: Exception
idx: int = 0 # index of line
is_multiline: bool = False
RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b'
class PaymentIdentifier(Logger):
"""
Takes:
* bitcoin addresses or script
* paytomany csv
* openalias
* bip21 URI
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
"""
def __init__(self, config, contacts, text):
Logger.__init__(self)
self.contacts = contacts
self.config = config
self.text = text
self._type = None
self.error = None # if set, GUI should show error and stop
self.warning = None # if set, GUI should ask user if they want to proceed
# more than one of those may be set
self.multiline_outputs = None
self.bolt11 = None
self.bip21 = None
self.spk = None
#
self.openalias = None
self.openalias_data = None
#
self.bip70 = None
self.bip70_data = None
#
self.lnurl = None
self.lnurl_data = None
# parse without network
self.parse(text)
def is_valid(self):
return bool(self._type)
def is_lightning(self):
return self.lnurl or self.bolt11
def is_multiline(self):
return bool(self.multiline_outputs)
def get_error(self) -> str:
return self.error
def needs_round_1(self):
return self.bip70 or self.openalias or self.lnurl
def needs_round_2(self):
return self.lnurl and self.lnurl_data
def needs_round_3(self):
return self.bip70
def parse(self, text):
# parse text, set self._type and self.error
text = text.strip()
if not text:
return
if outputs:= self._parse_as_multiline(text):
self._type = 'multiline'
self.multiline_outputs = outputs
elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
if invoice_or_lnurl.startswith('lnurl'):
self._type = 'lnurl'
try:
self.lnurl = decode_lnurl(invoice_or_lnurl)
except Exception as e:
self.error = "Error parsing Lightning invoice" + f":\n{e}"
return
else:
self._type = 'bolt11'
self.bolt11 = invoice_or_lnurl
elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
try:
out = parse_bip21_URI(text)
except InvalidBitcoinURI as e:
self.error = _("Error parsing URI") + f":\n{e}"
return
self._type = 'bip21'
self.bip21 = out
self.bip70 = out.get('r')
elif scriptpubkey := self.parse_output(text):
self._type = 'spk'
self.spk = scriptpubkey
elif re.match(RE_EMAIL, text):
self._type = 'alias'
self.openalias = text
elif self.error is None:
truncated_text = f"{text[:100]}..." if len(text) > 100 else text
self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_text}")
def get_onchain_outputs(self, amount):
if self.bip70:
return self.bip70_data.get_outputs()
elif self.multiline_outputs:
return self.multiline_outputs
elif self.spk:
return [PartialTxOutput(scriptpubkey=self.spk, value=amount)]
elif self.bip21:
address = self.bip21.get('address')
scriptpubkey = self.parse_output(address)
return [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)]
else:
raise Exception('not onchain')
def _parse_as_multiline(self, text):
# filter out empty lines
lines = text.split('\n')
lines = [i for i in lines if i]
is_multiline = len(lines)>1
outputs = [] # type: List[PartialTxOutput]
errors = []
total = 0
is_max = False
for i, line in enumerate(lines):
try:
output = self.parse_address_and_amount(line)
except Exception as e:
errors.append(PayToLineError(
idx=i, line_content=line.strip(), exc=e, is_multiline=True))
continue
outputs.append(output)
if parse_max_spend(output.value):
is_max = True
else:
total += output.value
if is_multiline and errors:
self.error = str(errors) if errors else None
print(outputs, self.error)
return outputs
def parse_address_and_amount(self, line) -> 'PartialTxOutput':
try:
x, y = line.split(',')
except ValueError:
raise Exception("expected two comma-separated values: (address, amount)") from None
scriptpubkey = self.parse_output(x)
amount = self.parse_amount(y)
return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
def parse_output(self, x) -> bytes:
try:
address = self.parse_address(x)
return bytes.fromhex(bitcoin.address_to_script(address))
except Exception as e:
error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False)
try:
script = self.parse_script(x)
return bytes.fromhex(script)
except Exception as e:
#error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False)
pass
#raise Exception("Invalid address or script.")
#self.errors.append(error)
def parse_script(self, x):
script = ''
for word in x.split():
if word[0:3] == 'OP_':
opcode_int = opcodes[word]
script += construct_script([opcode_int])
else:
bytes.fromhex(word) # to test it is hex data
script += construct_script([word])
return script
def parse_amount(self, x):
x = x.strip()
if not x:
raise Exception("Amount is empty")
if parse_max_spend(x):
return x
p = pow(10, self.config.get_decimal_point())
try:
return int(p * Decimal(x))
except decimal.InvalidOperation:
raise Exception("Invalid amount")
def parse_address(self, line):
r = line.strip()
m = re.match('^'+RE_ALIAS+'$', r)
address = str(m.group(2) if m else r)
assert bitcoin.is_address(address)
return address
def get_fields_for_GUI(self, wallet):
""" sets self.error as side effect"""
recipient = None
amount = None
description = None
validated = None
comment = "no comment"
if self.openalias and self.openalias_data:
address = self.openalias_data.get('address')
name = self.openalias_data.get('name')
recipient = self.openalias + ' <' + address + '>'
validated = self.openalias_data.get('validated')
if not validated:
self.warning = _('WARNING: the alias "{}" could not be validated via an additional '
'security check, DNSSEC, and thus may not be correct.').format(self.openalias)
#self.payto_e.set_openalias(key=pi.openalias, data=oa_data)
#self.window.contact_list.update()
elif self.bolt11:
recipient, amount, description = self.get_bolt11_fields(self.bolt11)
elif self.lnurl and self.lnurl_data:
domain = urlparse(self.lnurl).netloc
#recipient = "invoice from lnurl"
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
#amount = self.lnurl_data.min_sendable_sat
amount = None
description = None
if self.lnurl_data.comment_allowed:
comment = None
elif self.bip70 and self.bip70_data:
pr = self.bip70_data
if pr.error:
self.error = pr.error
return
recipient = pr.get_requestor()
amount = pr.get_amount()
description = pr.get_memo()
validated = not pr.has_expired()
#self.set_onchain(True)
#self.max_button.setEnabled(False)
# note: allow saving bip70 reqs, as we save them anyway when paying them
#for btn in [self.send_button, self.clear_button, self.save_button]:
# btn.setEnabled(True)
# signal to set fee
#self.amount_e.textEdited.emit("")
elif self.spk:
recipient = self.text
amount = None
elif self.multiline_outputs:
pass
elif self.bip21:
recipient = self.bip21.get('address')
amount = self.bip21.get('amount')
label = self.bip21.get('label')
description = self.bip21.get('message')
# use label as description (not BIP21 compliant)
if label and not description:
description = label
lightning = self.bip21.get('lightning')
if lightning and wallet.has_lightning():
# maybe set self.bolt11?
recipient, amount, description = self.get_bolt11_fields(lightning)
if not amount:
amount_required = True
# todo: merge logic
return recipient, amount, description, comment, validated
def get_bolt11_fields(self, bolt11_invoice):
"""Parse ln invoice, and prepare the send tab for it."""
try:
lnaddr = lndecode(bolt11_invoice)
except LnInvoiceException as e:
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
return
except IncompatibleOrInsaneFeatures as e:
self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
return
pubkey = lnaddr.pubkey.serialize().hex()
for k,v in lnaddr.tags:
if k == 'd':
description = v
break
else:
description = ''
amount = lnaddr.get_amount_sat()
return pubkey, amount, description
async def resolve_openalias(self) -> Optional[dict]:
key = self.openalias
if not (('.' in key) and ('<' not in key) and (' ' not in key)):
return None
parts = key.split(sep=',') # assuming single line
if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
return None
try:
data = self.contacts.resolve(key)
except Exception as e:
self.logger.info(f'error resolving address/alias: {repr(e)}')
return None
if data:
name = data.get('name')
address = data.get('address')
self.contacts[key] = ('openalias', name)
# this will set self.spk
self.parse(address)
return data
def has_expired(self):
if self.bip70:
return self.bip70_data.has_expired()
return False
@log_exceptions
async def round_1(self, on_success):
if self.openalias:
data = await self.resolve_openalias()
self.openalias_data = data
if not self.openalias_data.get('validated'):
self.warning = _(
'WARNING: the alias "{}" could not be validated via an additional '
'security check, DNSSEC, and thus may not be correct.').format(self.openalias)
elif self.bip70:
from . import paymentrequest
data = await paymentrequest.get_payment_request(self.bip70)
self.bip70_data = data
elif self.lnurl:
data = await request_lnurl(self.lnurl)
self.lnurl_data = data
else:
return
on_success(self)
@log_exceptions
async def round_2(self, on_success, amount_sat:int=None, comment=None):
from .invoices import Invoice
if self.lnurl:
if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat):
self.error = f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.'
return
if self.lnurl_data.comment_allowed == 0:
comment = None
params = {'amount': amount_sat * 1000 }
if comment:
params['comment'] = comment
try:
invoice_data = await callback_lnurl(
self.lnurl_data.callback_url,
params=params,
)
except LNURLError as e:
self.error = f"LNURL request encountered error: {e}"
return
bolt11_invoice = invoice_data.get('pr')
#
invoice = Invoice.from_bech32(bolt11_invoice)
if invoice.get_amount_sat() != amount_sat:
raise Exception("lnurl returned invoice with wrong amount")
# this will change what is returned by get_fields_for_GUI
self.bolt11 = bolt11_invoice
on_success(self)
@log_exceptions
async def round_3(self, tx, refund_address, *, on_success):
if self.bip70:
ack_status, ack_msg = await self.bip70.send_payment_and_receive_paymentack(tx.serialize(), refund_address)
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
on_success(self)
def get_invoice(self, wallet, amount_sat, message):
# fixme: wallet not really needed, only height
from .invoices import Invoice
if self.is_lightning():
invoice_str = self.bolt11
if not invoice_str:
return
invoice = Invoice.from_bech32(invoice_str)
if invoice.amount_msat is None:
invoice.amount_msat = int(amount_sat * 1000)
return invoice
else:
outputs = self.get_onchain_outputs(amount_sat)
message = self.bip21.get('message') if self.bip21 else message
bip70_data = self.bip70_data if self.bip70 else None
return wallet.create_invoice(
outputs=outputs,
message=message,
pr=bip70_data,
URI=self.bip21)

3
electrum/transaction.py

@ -42,7 +42,8 @@ import copy
from . import ecc, bitcoin, constants, segwit_addr, bip32 from . import ecc, bitcoin, constants, segwit_addr, bip32
from .bip32 import BIP32Node from .bip32 import BIP32Node
from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend from .util import profiler, to_bytes, bfh, chunks, is_hex_str
from .payment_identifier import parse_max_spend
from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160,
hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr,
var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN,

169
electrum/util.py

@ -1009,177 +1009,8 @@ def block_explorer_URL(config: 'SimpleConfig', kind: str, item: str) -> Optional
url_parts = [explorer_url, kind_str, item] 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

3
electrum/wallet.py

@ -57,7 +57,8 @@ 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 .payment_identifier import create_bip21_uri, parse_max_spend
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

3
electrum/x509.py

@ -308,7 +308,8 @@ class X509(object):
raise CertificateError('Certificate has not entered its valid date range. (%s)' % self.get_common_name()) raise CertificateError('Certificate has not entered its valid date range. (%s)' % self.get_common_name())
if self.notAfter <= now: if self.notAfter <= now:
dt = datetime.utcfromtimestamp(time.mktime(self.notAfter)) dt = datetime.utcfromtimestamp(time.mktime(self.notAfter))
raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).') # for testnet
#raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).')
def getFingerprint(self): def getFingerprint(self):
return hashlib.sha1(self.bytes).digest() return hashlib.sha1(self.bytes).digest()

5
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,9 +365,9 @@ def main():
if not config_options.get('verbosity'): if not config_options.get('verbosity'):
warnings.simplefilter('ignore', DeprecationWarning) warnings.simplefilter('ignore', DeprecationWarning)
# check uri # check if we received a valid payment identifier
uri = config_options.get('url') uri = config_options.get('url')
if uri and not util.is_uri(uri): if uri and not PaymentIdentifier(None, None, uri).is_valid():
print_stderr('unknown command:', uri) print_stderr('unknown command:', uri)
sys.exit(1) sys.exit(1)

Loading…
Cancel
Save