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. 214
      electrum/gui/qt/paytoedit.py
  7. 456
      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,
BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME,
UserFacingException)
from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException
from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog
from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog

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

2
electrum/gui/qml/qeapp.py

@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica
from electrum import version, constants
from electrum.i18n import _
from electrum.logging import Logger, get_logger
from electrum.util import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue
from electrum.network import Network

4
electrum/gui/qt/__init__.py

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

5
electrum/gui/qt/main_window.py

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

214
electrum/gui/qt/paytoedit.py

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

456
electrum/gui/qt/send_tab.py

@ -5,8 +5,6 @@
import asyncio
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any
from urllib.parse import urlparse
from PyQt5.QtCore import pyqtSignal, QPoint
from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout,
QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton)
@ -15,15 +13,14 @@ from electrum import util, paymentrequest
from electrum import lnutil
from electrum.plugin import run_hook
from electrum.i18n import _
from electrum.util import (get_asyncio_loop, FailedToParsePaymentIdentifier,
InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds,
NoDynamicFeeEstimates, InvoiceError, parse_max_spend)
from electrum.util import get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates, InvoiceError, parse_max_spend
from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier, InvalidBitcoinURI
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.logging import Logger
from electrum.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit
@ -36,15 +33,9 @@ if TYPE_CHECKING:
class SendTab(QWidget, MessageBoxMixin, Logger):
payment_request_ok_signal = pyqtSignal()
payment_request_error_signal = pyqtSignal()
lnurl6_round1_signal = pyqtSignal(object, object)
lnurl6_round2_signal = pyqtSignal(object)
clear_send_tab_signal = pyqtSignal()
show_error_signal = pyqtSignal(str)
payment_request: Optional[paymentrequest.PaymentRequest]
_lnurl_data: Optional[LNURL6Data] = None
round_1_signal = pyqtSignal(object)
round_2_signal = pyqtSignal(object)
round_3_signal = pyqtSignal(object)
def __init__(self, window: 'ElectrumWindow'):
QWidget.__init__(self, window)
@ -60,8 +51,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.format_amount = window.format_amount
self.base_unit = window.base_unit
self.payto_URI = None
self.payment_request = None # type: Optional[paymentrequest.PaymentRequest]
self.payment_identifier = None
self.pending_invoice = None
# A 4-column grid layout. All the stretch is in the last column.
@ -84,9 +74,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
+ _("Integers weights can also be used in conjunction with '!', "
"e.g. set one amount to '2!' and another to '3!' to split your coins 40-60."))
payto_label = HelpLabel(_('Pay to'), msg)
grid.addWidget(payto_label, 1, 0)
grid.addWidget(self.payto_e.line_edit, 1, 1, 1, 4)
grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4)
grid.addWidget(payto_label, 0, 0)
grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4)
grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4)
#completer = QCompleter()
#completer.setCaseSensitivity(False)
@ -97,9 +87,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
+ _(
'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.')
description_label = HelpLabel(_('Description'), msg)
grid.addWidget(description_label, 2, 0)
grid.addWidget(description_label, 1, 0)
self.message_e = SizedFreezableLineEdit(width=600)
grid.addWidget(self.message_e, 2, 1, 1, 4)
grid.addWidget(self.message_e, 1, 1, 1, 4)
msg = _('Comment for recipient')
self.comment_label = HelpLabel(_('Comment'), msg)
grid.addWidget(self.comment_label, 2, 0)
self.comment_e = SizedFreezableLineEdit(width=600)
grid.addWidget(self.comment_e, 2, 1, 1, 4)
self.comment_label.hide()
self.comment_e.hide()
msg = (_('The amount to be received by the recipient.') + ' '
+ _('Fees are paid by the sender.') + '\n\n'
@ -129,11 +127,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
self.clear_button = EnterButton(_("Clear"), self.do_clear)
self.paste_button = QPushButton()
self.paste_button.clicked.connect(lambda: self.payto_e._on_input_btn(self.window.app.clipboard().text()))
self.paste_button.clicked.connect(self.do_paste)
self.paste_button.setIcon(read_QIcon('copy.png'))
self.paste_button.setToolTip(_('Paste invoice from clipboard'))
self.paste_button.setMaximumWidth(35)
grid.addWidget(self.paste_button, 1, 5)
grid.addWidget(self.paste_button, 0, 5)
buttons = QHBoxLayout()
buttons.addStretch(1)
@ -160,7 +158,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.invoice_list = InvoiceList(self)
self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('')
menu.addAction(read_QIcon(get_iconname_camera()), _("Read QR code with camera"), self.payto_e.on_qr_from_camera_input_btn)
menu.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), self.payto_e.on_qr_from_screenshot_input_btn)
menu.addAction(read_QIcon("file.png"), _("Read invoice from file"), self.payto_e.on_input_file)
@ -186,17 +183,33 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.invoice_list.update() # after parented and put into a layout, can update without flickering
run_hook('create_send_tab', grid)
self.payment_request_ok_signal.connect(self.payment_request_ok)
self.payment_request_error_signal.connect(self.payment_request_error)
self.lnurl6_round1_signal.connect(self.on_lnurl6_round1)
self.lnurl6_round2_signal.connect(self.on_lnurl6_round2)
self.clear_send_tab_signal.connect(self.do_clear)
self.show_error_signal.connect(self.show_error)
self.round_1_signal.connect(self.on_round_1)
self.round_2_signal.connect(self.on_round_2)
self.round_3_signal.connect(self.on_round_3)
def do_paste(self):
text = self.window.app.clipboard().text()
if not text:
return
self.set_payment_identifier(text)
def set_payment_identifier(self, text):
pi = PaymentIdentifier(self.config, self.window.contacts, text)
if pi.error:
self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error)
return
if pi.is_multiline():
self.payto_e.set_paytomany(True)
self.payto_e.text_edit.setText(text)
else:
self.payto_e.setTextNoCheck(text)
self.handle_payment_identifier(pi, can_use_network=True)
def spend_max(self):
if run_hook('abort_send', self):
return
outputs = self.payto_e.get_outputs(True)
amount = self.get_amount()
outputs = self.payment_identifier.get_onchain_outputs(amount)
if not outputs:
return
make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
@ -297,15 +310,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return self.format_amount_and_units(frozen_bal)
def do_clear(self):
self._lnurl_data = None
self.max_button.setChecked(False)
self.payment_request = None
self.payto_URI = None
self.payto_e.do_clear()
self.set_onchain(False)
for e in [self.message_e, self.amount_e]:
for w in [self.comment_e, self.comment_label]:
w.setVisible(False)
for e in [self.message_e, self.amount_e, self.fiat_send_e]:
e.setText('')
e.setFrozen(False)
self.set_field_style(e, None, False)
for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]:
e.setEnabled(True)
self.window.update_status()
@ -315,208 +327,101 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self._is_onchain = b
self.max_button.setEnabled(b)
def lock_amount(self, b: bool) -> None:
self.amount_e.setFrozen(b)
self.max_button.setEnabled(not b)
def prepare_for_send_tab_network_lookup(self):
self.window.show_send_tab()
self.payto_e.disable_checks = True
for e in [self.payto_e, self.message_e]:
e.setFrozen(True)
self.lock_amount(True)
#for e in [self.payto_e, self.message_e]:
self.payto_e.setFrozen(True)
for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False)
self.payto_e.setTextNoCheck(_("please wait..."))
def payment_request_ok(self):
pr = self.payment_request
if not pr:
return
invoice = Invoice.from_bip70_payreq(pr, height=0)
if self.wallet.get_invoice_status(invoice) == PR_PAID:
self.show_message("invoice already paid")
self.do_clear()
self.payment_request = None
return
self.payto_e.disable_checks = True
if not pr.has_expired():
self.payto_e.setGreen()
else:
self.payto_e.setExpired()
self.payto_e.setTextNoCheck(pr.get_requestor())
self.amount_e.setAmount(pr.get_amount())
self.message_e.setText(pr.get_memo())
self.set_onchain(True)
self.max_button.setEnabled(False)
# note: allow saving bip70 reqs, as we save them anyway when paying them
for btn in [self.send_button, self.clear_button, self.save_button]:
btn.setEnabled(True)
# signal to set fee
self.amount_e.textEdited.emit("")
def payment_request_error(self):
pr = self.payment_request
if not pr:
return
self.show_message(pr.error)
self.payment_request = None
def payment_request_error(self, error):
self.show_message(error)
self.do_clear()
def on_pr(self, request: 'paymentrequest.PaymentRequest'):
self.payment_request = request
if self.payment_request.verify(self.window.contacts):
self.payment_request_ok_signal.emit()
def set_field_style(self, w, text, validated):
from .util import ColorScheme
if validated is None:
style = ColorScheme.LIGHTBLUE.as_stylesheet(True)
elif validated is True:
style = ColorScheme.GREEN.as_stylesheet(True)
else:
self.payment_request_error_signal.emit()
def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True):
try:
url = decode_lnurl(lnurl)
except LnInvoiceException as e:
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
return
if not can_use_network:
return
async def f():
try:
lnurl_data = await request_lnurl(url)
except LNURLError as e:
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
self.clear_send_tab_signal.emit()
return
self.lnurl6_round1_signal.emit(lnurl_data, url)
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str):
self._lnurl_data = lnurl_data
domain = urlparse(url).netloc
self.payto_e.setTextNoCheck(f"invoice from lnurl")
self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}")
self.amount_e.setAmount(lnurl_data.min_sendable_sat)
self.amount_e.setFrozen(False)
for btn in [self.send_button, self.clear_button]:
btn.setEnabled(True)
self.set_onchain(False)
def set_bolt11(self, invoice: str):
"""Parse ln invoice, and prepare the send tab for it."""
try:
lnaddr = lndecode(invoice)
except LnInvoiceException as e:
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
return
except lnutil.IncompatibleOrInsaneFeatures as e:
self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
return
pubkey = lnaddr.pubkey.serialize().hex()
for k,v in lnaddr.tags:
if k == 'd':
description = v
break
style = ColorScheme.RED.as_stylesheet(True)
if text is not None:
w.setStyleSheet(style)
w.setReadOnly(True)
else:
description = ''
self.payto_e.setFrozen(True)
self.payto_e.setTextNoCheck(pubkey)
self.payto_e.lightning_invoice = invoice
if not self.message_e.text():
w.setStyleSheet('')
w.setReadOnly(False)
def update_fields(self, pi):
recipient, amount, description, comment, validated = pi.get_fields_for_GUI(self.wallet)
if recipient:
self.payto_e.setTextNoCheck(recipient)
elif pi.multiline_outputs:
self.payto_e.handle_multiline(pi.multiline_outputs)
if description:
self.message_e.setText(description)
if lnaddr.get_amount_sat() is not None:
self.amount_e.setAmount(lnaddr.get_amount_sat())
self.set_onchain(False)
def set_bip21(self, text: str, *, can_use_network: bool = True):
on_bip70_pr = self.on_pr if can_use_network else None
try:
out = util.parse_URI(text, on_bip70_pr)
except InvalidBitcoinURI as e:
self.show_error(_("Error parsing URI") + f":\n{e}")
return
self.payto_URI = out
r = out.get('r')
sig = out.get('sig')
name = out.get('name')
if (r or (name and sig)) and can_use_network:
self.prepare_for_send_tab_network_lookup()
return
address = out.get('address')
amount = out.get('amount')
label = out.get('label')
message = out.get('message')
lightning = out.get('lightning')
if lightning and (self.wallet.has_lightning() or not address):
self.handle_payment_identifier(lightning, can_use_network=can_use_network)
return
# use label as description (not BIP21 compliant)
if label and not message:
message = label
if address:
self.payto_e.setText(address)
if message:
self.message_e.setText(message)
if amount:
self.amount_e.setAmount(amount)
def handle_payment_identifier(self, text: str, *, can_use_network: bool = True):
"""Takes
Lightning identifiers:
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
Bitcoin identifiers:
* bitcoin-URI
and sets the sending screen.
"""
text = text.strip()
if not text:
for w in [self.comment_e, self.comment_label]:
w.setVisible(not bool(comment))
self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated)
self.set_field_style(self.message_e, description, validated)
self.set_field_style(self.amount_e, amount, validated)
self.set_field_style(self.fiat_send_e, amount, validated)
def handle_payment_identifier(self, pi, *, can_use_network: bool = True):
self.payment_identifier = pi
is_valid = pi.is_valid()
self.save_button.setEnabled(is_valid)
self.send_button.setEnabled(is_valid)
if not is_valid:
return
if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
if invoice_or_lnurl.startswith('lnurl'):
self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network)
else:
self.set_bolt11(invoice_or_lnurl)
elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'):
self.set_bip21(text, can_use_network=can_use_network)
else:
truncated_text = f"{text[:100]}..." if len(text) > 100 else text
raise FailedToParsePaymentIdentifier(f"Could not handle payment identifier:\n{truncated_text}")
self.update_fields(pi)
if can_use_network and pi.needs_round_1():
coro = pi.round_1(on_success=self.round_1_signal.emit)
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
self.prepare_for_send_tab_network_lookup()
# update fiat amount
self.amount_e.textEdited.emit("")
self.window.show_send_tab()
def on_round_1(self, pi):
if pi.error:
self.show_error(pi.error)
self.do_clear()
return
self.update_fields(pi)
for btn in [self.send_button, self.clear_button, self.save_button]:
btn.setEnabled(True)
def get_message(self):
return self.message_e.text()
def read_invoice(self) -> Optional[Invoice]:
if self.check_payto_line_and_show_errors():
return
try:
if not self._is_onchain:
invoice_str = self.payto_e.lightning_invoice
if not invoice_str:
return
invoice = Invoice.from_bech32(invoice_str)
if invoice.amount_msat is None:
amount_sat = self.get_amount()
if amount_sat:
invoice.amount_msat = int(amount_sat * 1000)
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
self.show_error(_('Lightning is disabled'))
return
return invoice
else:
outputs = self.read_outputs()
if self.check_onchain_outputs_and_show_errors(outputs):
return
message = self.message_e.text()
return self.wallet.create_invoice(
outputs=outputs,
message=message,
pr=self.payment_request,
URI=self.payto_URI)
except InvoiceError as e:
self.show_error(_('Error creating payment') + ':\n' + str(e))
amount_sat = self.read_amount()
if not amount_sat:
self.show_error(_('No amount'))
return
invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message())
#except Exception as e:
if not invoice:
self.show_error('error getting invoice' + pi.error)
return
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
self.show_error(_('Lightning is disabled'))
if self.wallet.get_invoice_status(invoice) == PR_PAID:
# fixme: this is only for bip70 and lightning
self.show_error(_('Invoice already paid'))
return
#if not invoice.is_lightning():
# if self.check_onchain_outputs_and_show_errors(outputs):
# return
return invoice
def do_save_invoice(self):
self.pending_invoice = self.read_invoice()
@ -536,41 +441,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
# must not be None
return self.amount_e.get_amount() or 0
def _lnurl_get_invoice(self) -> None:
assert self._lnurl_data
amount = self.get_amount()
if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat):
self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.')
return
async def f():
try:
invoice_data = await callback_lnurl(
self._lnurl_data.callback_url,
params={'amount': self.get_amount() * 1000},
)
except LNURLError as e:
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
self.clear_send_tab_signal.emit()
return
invoice = invoice_data.get('pr')
self.lnurl6_round2_signal.emit(invoice)
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
def on_lnurl6_round2(self, bolt11_invoice: str):
self._lnurl_data = None
invoice = Invoice.from_bech32(bolt11_invoice)
assert invoice.get_amount_sat() == self.get_amount(), (invoice.get_amount_sat(), self.get_amount())
def on_round_2(self, pi):
self.do_clear()
self.payto_e.setText(bolt11_invoice)
if pi.error:
self.show_error(pi.error)
self.do_clear()
return
self.update_fields(pi)
invoice = pi.get_invoice(self.wallet, self.get_amount(), self.get_message())
self.pending_invoice = invoice
self.do_pay_invoice(invoice)
def on_round_3(self):
pass
def do_pay_or_get_invoice(self):
if self._lnurl_data:
self._lnurl_get_invoice()
pi = self.payment_identifier
if pi.needs_round_2():
coro = pi.round_2(self.round_2_signal.emit, amount_sat=self.get_amount(), comment=self.message_e.text())
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) # TODO should be cancellable
self.prepare_for_send_tab_network_lookup()
return
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
@ -600,12 +490,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
else:
self.pay_onchain_dialog(invoice.outputs)
def read_outputs(self) -> List[PartialTxOutput]:
if self.payment_request:
outputs = self.payment_request.get_outputs()
else:
outputs = self.payto_e.get_outputs(self.max_button.isChecked())
return outputs
def read_amount(self) -> List[PartialTxOutput]:
is_max = self.max_button.isChecked()
amount = '!' if is_max else self.get_amount()
return amount
def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool:
"""Returns whether there are errors with outputs.
@ -629,34 +517,30 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
"""Returns whether there are errors.
Also shows error dialog to user if so.
"""
pr = self.payment_request
if pr:
if pr.has_expired():
self.show_error(_('Payment request has expired'))
return True
error = self.payment_identifier.get_error()
if error:
if not self.payment_identifier.is_multiline():
err = error
self.show_warning(
_("Failed to parse 'Pay to' line") + ":\n" +
f"{err.line_content[:40]}...\n\n"
f"{err.exc!r}")
else:
self.show_warning(
_("Invalid Lines found:") + "\n\n" + error)
#'\n'.join([_("Line #") +
# f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
# for err in errors]))
return True
if not pr:
errors = self.payto_e.get_errors()
if errors:
if len(errors) == 1 and not errors[0].is_multiline:
err = errors[0]
self.show_warning(_("Failed to parse 'Pay to' line") + ":\n" +
f"{err.line_content[:40]}...\n\n"
f"{err.exc!r}")
else:
self.show_warning(_("Invalid Lines found:") + "\n\n" +
'\n'.join([_("Line #") +
f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
for err in errors]))
if self.payment_identifier.warning:
msg += '\n' + _('Do you wish to continue?')
if not self.question(msg):
return True
if self.payto_e.is_alias and self.payto_e.validated is False:
alias = self.payto_e.toPlainText()
msg = _('WARNING: the alias "{}" could not be validated via an additional '
'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n'
msg += _('Do you wish to continue?')
if not self.question(msg):
return True
if self.payment_identifier.has_expired():
self.show_error(_('Payment request has expired'))
return True
return False # no errors
@ -740,9 +624,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def broadcast_thread():
# non-GUI thread
pr = self.payment_request
if pr and pr.has_expired():
self.payment_request = None
if self.payment_identifier.has_expired():
return False, _("Invoice has expired")
try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
@ -752,13 +634,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return False, repr(e)
# success
txid = tx.txid()
if pr:
self.payment_request = None
if self.payment_identifier.needs_round_3():
refund_address = self.wallet.get_receiving_address()
coro = pr.send_payment_and_receive_paymentack(tx.serialize(), refund_address)
fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
ack_status, ack_msg = fut.result(timeout=20)
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
coro = self.payment_identifier.round_3(tx.serialize(), refund_address)
asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
return True, txid
# Capture current TL window; override might be removed on return
@ -804,3 +683,4 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.payto_e.setFocus()
text = "\n".join([payto + ", 0" for payto in paytos])
self.payto_e.setText(text)
self.payto_e.setFocus()

1
electrum/gui/qt/util.py

@ -961,6 +961,7 @@ class ColorScheme:
YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
RED = ColorSchemeItem("#7c1111", "#f18c8c")
BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff")
DEFAULT = ColorSchemeItem("black", "white")
GRAY = ColorSchemeItem("gray", "gray")

2
electrum/invoices.py

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

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 .bip32 import BIP32Node
from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend
from .util import profiler, to_bytes, bfh, chunks, is_hex_str
from .payment_identifier import parse_max_spend
from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160,
hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr,
var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN,

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]
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

3
electrum/wallet.py

@ -57,7 +57,8 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore
format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates,
WalletFileException, BitcoinException,
InvalidPassword, format_time, timestamp_to_datetime, Satoshis,
Fiat, bfh, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex, parse_max_spend)
Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex)
from .payment_identifier import create_bip21_uri, parse_max_spend
from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE
from .bitcoin import COIN, TYPE_ADDRESS
from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold

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())
if self.notAfter <= now:
dt = datetime.utcfromtimestamp(time.mktime(self.notAfter))
raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).')
# for testnet
#raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).')
def getFingerprint(self):
return hashlib.sha1(self.bytes).digest()

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

Loading…
Cancel
Save