Browse Source

Merge pull request #7839 from SomberNight/202202_lnurl_2

add lnurl-pay (`LUD-06`) support
patch-4
ghost43 3 years ago
committed by GitHub
parent
commit
05226437bf
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      electrum/contacts.py
  2. 27
      electrum/gui/kivy/main_window.py
  3. 101
      electrum/gui/kivy/uix/screens.py
  4. 6
      electrum/gui/kivy/uix/ui_screens/send.kv
  5. 4
      electrum/gui/qt/__init__.py
  6. 2
      electrum/gui/qt/completion_text_edit.py
  7. 165
      electrum/gui/qt/main_window.py
  8. 107
      electrum/gui/qt/paytoedit.py
  9. 13
      electrum/gui/qt/qrtextedit.py
  10. 4
      electrum/gui/qt/util.py
  11. 108
      electrum/lnurl.py
  12. 2
      electrum/network.py
  13. 12
      electrum/tests/test_lnurl.py
  14. 10
      electrum/util.py
  15. 7
      run_electrum

4
electrum/contacts.py

@ -21,6 +21,8 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import re
from typing import Optional, Tuple
import dns
import threading
from dns.exception import DNSException
@ -106,7 +108,7 @@ class Contacts(dict, Logger):
t.daemon = True
t.start()
def resolve_openalias(self, url):
def resolve_openalias(self, url: str) -> Optional[Tuple[str, str, bool]]:
# support email-style addresses, per the OA standard
url = url.replace('@', '.')
try:

27
electrum/gui/kivy/main_window.py

@ -18,7 +18,7 @@ from electrum.plugin import run_hook
from electrum import util
from electrum.util import (profiler, InvalidPassword, send_exception_to_crash_reporter,
format_satoshis, format_satoshis_plain, format_fee_satoshis,
maybe_extract_bolt11_invoice, parse_max_spend)
parse_max_spend)
from electrum.util import EventListener, event_listener
from electrum.invoices import PR_PAID, PR_FAILED, Invoice
from electrum import blockchain
@ -235,10 +235,6 @@ class ElectrumWindow(App, Logger, EventListener):
def set_URI(self, uri):
self.send_screen.set_URI(uri)
@switch_to_send_screen
def set_ln_invoice(self, invoice):
self.send_screen.set_ln_invoice(invoice)
def on_new_intent(self, intent):
data = str(intent.getDataString())
scheme = str(intent.getScheme()).lower()
@ -481,22 +477,15 @@ class ElectrumWindow(App, Logger, EventListener):
self.send_screen.do_clear()
def on_qr(self, data: str):
from electrum.bitcoin import is_address
self.on_data_input(data)
def on_data_input(self, data: str) -> None:
"""on_qr / on_paste shared logic"""
data = data.strip()
if is_address(data):
self.set_URI(data)
return
if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
self.set_URI(data)
return
if data.lower().startswith('channel_backup:'):
self.import_channel_backup(data)
return
bolt11_invoice = maybe_extract_bolt11_invoice(data)
if bolt11_invoice is not None:
self.set_ln_invoice(bolt11_invoice)
return
# try to decode transaction
# try to decode as transaction
from electrum.transaction import tx_from_any
try:
tx = tx_from_any(data)
@ -505,8 +494,8 @@ class ElectrumWindow(App, Logger, EventListener):
if tx:
self.tx_dialog(tx)
return
# show error
self.show_error("Unable to decode QR data")
# try to decode as URI/address
self.set_URI(data)
def update_tab(self, name):
s = getattr(self, name + '_screen', None)

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

@ -2,6 +2,7 @@ import asyncio
from decimal import Decimal
import threading
from typing import TYPE_CHECKING, List, Optional, Dict, Any
from urllib.parse import urlparse
from kivy.app import App
from kivy.clock import Clock
@ -16,10 +17,12 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING,
pr_expiration_values, Invoice)
from electrum import bitcoin, constants
from electrum.transaction import tx_from_any, PartialTxOutput
from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice,
InvoiceError, format_time, parse_max_spend)
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.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from electrum.logging import Logger
from electrum.network import Network
from .dialogs.confirm_tx_dialog import ConfirmTxDialog
@ -162,22 +165,42 @@ class SendScreen(CScreen, Logger):
kvname = 'send'
payment_request = None # type: Optional[PaymentRequest]
parsed_URI = None
lnurl_data = None # type: Optional[LNURL6Data]
def __init__(self, **kwargs):
CScreen.__init__(self, **kwargs)
Logger.__init__(self)
self.is_max = False
# note: most the fields get declared in send.kv, this way they are kivy Properties
def set_URI(self, text: str):
"""Takes
Lightning identifiers:
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
Bitcoin identifiers:
* bitcoin-URI
* bitcoin address
and sets the sending screen.
TODO maybe rename method...
"""
if not self.app.wallet:
return
# interpret as lighting URI
bolt11_invoice = maybe_extract_bolt11_invoice(text)
if bolt11_invoice:
self.set_ln_invoice(bolt11_invoice)
# interpret as BIP21 URI
else:
text = text.strip()
if not text:
return
if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
if invoice_or_lnurl.startswith('lnurl'):
self.set_lnurl6(invoice_or_lnurl)
else:
self.set_bolt11(invoice_or_lnurl)
elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':') or bitcoin.is_address(text):
self.set_bip21(text)
else:
self.app.show_error(f"Failed to parse text: {text[:10]}...")
return
def set_bip21(self, text: str):
try:
@ -194,7 +217,7 @@ class SendScreen(CScreen, Logger):
self.payment_request = None
self.is_lightning = False
def set_ln_invoice(self, invoice: str):
def set_bolt11(self, invoice: str):
try:
invoice = str(invoice).lower()
lnaddr = lndecode(invoice)
@ -207,6 +230,20 @@ class SendScreen(CScreen, Logger):
self.payment_request = None
self.is_lightning = True
def set_lnurl6(self, lnurl: str):
url = decode_lnurl(lnurl)
domain = urlparse(url).netloc
# FIXME network request blocking GUI thread:
lnurl_data = Network.run_from_another_thread(request_lnurl(url))
if not lnurl_data:
return
self.lnurl_data = lnurl_data
self.address = "invoice from lnurl"
self.message = f"lnurl: {domain}: {lnurl_data.metadata_plaintext}"
self.amount = self.app.format_amount_and_units(lnurl_data.min_sendable_sat)
self.is_lightning = True
self.is_lnurl = True # `bool(self.lnurl_data)` should be equivalent, this is only here as it is a kivy Property
def update(self):
if self.app.wallet is None:
return
@ -263,6 +300,8 @@ class SendScreen(CScreen, Logger):
self.is_bip70 = False
self.parsed_URI = None
self.is_max = False
self.lnurl_data = None
self.is_lnurl = False
def set_request(self, pr: 'PaymentRequest'):
self.address = pr.get_requestor()
@ -277,21 +316,7 @@ class SendScreen(CScreen, Logger):
if not data:
self.app.show_info(_("Clipboard is empty"))
return
# try to decode as transaction
try:
tx = tx_from_any(data)
tx.deserialize()
except:
tx = None
if tx:
self.app.tx_dialog(tx)
return
# try to decode as URI/address
bolt11_invoice = maybe_extract_bolt11_invoice(data)
if bolt11_invoice is not None:
self.set_ln_invoice(bolt11_invoice)
else:
self.set_URI(data)
self.app.on_data_input(data)
def read_invoice(self):
address = str(self.address)
@ -340,7 +365,35 @@ class SendScreen(CScreen, Logger):
self.do_clear()
self.update()
def _lnurl_get_invoice(self) -> None:
assert self.lnurl_data
try:
amount = self.app.get_amount(self.amount)
except:
self.app.show_error(_('Invalid amount') + ':\n' + self.amount)
return
if not (self.lnurl_data.min_sendable_sat <= amount <= self.lnurl_data.max_sendable_sat):
self.app.show_error(f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.')
return
try:
# FIXME network request blocking GUI thread:
invoice_data = Network.run_from_another_thread(callback_lnurl(
self.lnurl_data.callback_url,
params={'amount': amount * 1000},
))
except LNURLError as e:
self.app.show_error(f"LNURL request encountered error: {e}")
self.do_clear()
return
invoice = invoice_data.get('pr')
self.set_bolt11(invoice)
self.lnurl_data = None
self.is_lnurl = False
def do_pay(self):
if self.lnurl_data:
self._lnurl_get_invoice()
return
invoice = self.read_invoice()
if not invoice:
return

6
electrum/gui/kivy/uix/ui_screens/send.kv

@ -69,6 +69,7 @@
message: ''
is_bip70: False
is_lightning: False
is_lnurl: False
is_locked: self.is_lightning or self.is_bip70
BoxLayout
padding: '12dp', '12dp', '12dp', '12dp'
@ -109,7 +110,7 @@
id: amount_e
default_text: _('Amount')
text: s.amount if s.amount else _('Amount')
disabled: root.is_bip70 or (root.is_lightning and s.amount)
disabled: root.is_bip70 or (root.is_lightning and s.amount and not root.is_lnurl)
on_release: Clock.schedule_once(lambda dt: app.amount_dialog(s, not root.is_lightning))
CardSeparator:
color: blue_bottom.foreground_color
@ -135,6 +136,7 @@
size_hint: 0.5, 1
on_release: s.do_save()
icon: f'atlas://{KIVY_GUI_PATH}/theming/atlas/light/save'
disabled: root.is_lnurl
IconButton:
size_hint: 0.5, 1
on_release: s.do_clear()
@ -149,7 +151,7 @@
size_hint: 1, 1
on_release: Clock.schedule_once(lambda dt: app.scan_qr(on_complete=app.on_qr))
Button:
text: _('Pay')
text: _('Pay') if not root.is_lnurl else _('Get Invoice')
size_hint: 1, 1
on_release: s.do_pay()
Widget:

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].pay_to_URI(event.url().toString())
self.windows[0].handle_payment_identifier(event.url().toString())
return True
return False
@ -383,7 +383,7 @@ class ElectrumGui(BaseElectrumGui, Logger):
self.start_new_window(path, uri=None, force_wizard=True)
return
if uri:
window.pay_to_URI(uri)
window.handle_payment_identifier(uri)
window.bring_to_top()
window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)

2
electrum/gui/qt/completion_text_edit.py

@ -81,6 +81,8 @@ class CompletionTextEdit(ButtonsTextEdit):
return
QPlainTextEdit.keyPressEvent(self, e)
if self.isReadOnly(): # if field became read-only *after* keyPress, exit now
return
ctrlOrShift = bool(e.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier))
if self.completer is None or (ctrlOrShift and not e.text()):

165
electrum/gui/qt/main_window.py

@ -38,6 +38,7 @@ import queue
import asyncio
from typing import Optional, TYPE_CHECKING, Sequence, List, Union, Dict, Set
import concurrent.futures
from urllib.parse import urlparse
from PyQt5.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont
from PyQt5.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal, QPoint
@ -57,12 +58,12 @@ from electrum import (keystore, ecc, constants, util, bitcoin, commands,
from electrum.bitcoin import COIN, is_address
from electrum.plugin import run_hook, BasePlugin
from electrum.i18n import _
from electrum.util import (format_time,
from electrum.util import (format_time, get_asyncio_loop,
UserCancelled, profiler,
bh2u, bfh, InvalidPassword,
UserFacingException,
get_new_wallet_name, send_exception_to_crash_reporter,
InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds,
InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds,
NoDynamicFeeEstimates,
AddTransactionException, BITCOIN_BIP21_URI_SCHEME,
InvoiceError, parse_max_spend)
@ -81,6 +82,7 @@ from electrum.simple_config import SimpleConfig
from electrum.logging import Logger
from electrum.lnutil import ln_dummy_address, extract_nodeid, ConnStringFormatError
from electrum.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from .exception_window import Exception_Hook
from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit, SizedFreezableLineEdit
@ -199,12 +201,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
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()
#ln_payment_attempt_signal = pyqtSignal(str)
computing_privkeys_signal = pyqtSignal()
show_privkeys_signal = pyqtSignal()
show_error_signal = pyqtSignal(str)
payment_request: Optional[paymentrequest.PaymentRequest]
_lnurl_data: Optional[LNURL6Data] = None
def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet):
QMainWindow.__init__(self)
@ -309,6 +315,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
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.history_list.setFocus(True)
@ -820,7 +830,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
d = self.network.get_donation_address()
if d:
host = self.network.get_parameters().server.host
self.pay_to_URI('bitcoin:%s?message=donation for %s'%(d, host))
self.handle_payment_identifier('bitcoin:%s?message=donation for %s' % (d, host))
else:
self.show_error(_('No donation address for this server'))
@ -912,8 +922,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
# this updates "synchronizing" progress
self.update_status()
# resolve aliases
# FIXME this is a blocking network call that has a timeout of 5 sec
self.payto_e.resolve()
# FIXME this might do blocking network calls that has a timeout of several seconds
self.payto_e.on_timer_check_text()
self.notify_transactions()
def format_amount(self, amount_sat, is_diff=False, whitespaces=False) -> str:
@ -1524,7 +1534,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
from .paytoedit import PayToEdit
self.amount_e = BTCAmountEdit(self.get_decimal_point)
self.payto_e = PayToEdit(self)
self.payto_e.addPasteButton()
msg = (_("Recipient of the funds.") + "\n\n"
+ _("You may enter a Bitcoin address, a label from your list of contacts "
"(a list of completions will be proposed), "
@ -1573,7 +1582,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
grid.addWidget(self.max_button, 3, 3)
self.save_button = EnterButton(_("Save"), self.do_save_invoice)
self.send_button = EnterButton(_("Pay") + "...", self.do_pay)
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
self.clear_button = EnterButton(_("Clear"), self.do_clear)
buttons = QHBoxLayout()
@ -1910,7 +1919,43 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.invoice_list.update()
self.pending_invoice = None
def do_pay(self):
def _lnurl_get_invoice(self) -> None:
assert self._lnurl_data
amount = self.amount_e.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.amount_e.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.set_bolt11(bolt11_invoice)
self.payto_e.setFrozen(True)
self.amount_e.setEnabled(False)
self.fiat_send_e.setEnabled(False)
for btn in [self.send_button, self.clear_button, self.save_button]:
btn.setEnabled(True)
self.send_button.restore_original_text()
self._lnurl_data = None
def do_pay_or_get_invoice(self):
if self._lnurl_data:
self._lnurl_get_invoice()
return
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
return
@ -2171,14 +2216,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.amount_e.setFrozen(b)
self.max_button.setEnabled(not b)
def prepare_for_payment_request(self):
def prepare_for_send_tab_network_lookup(self):
self.show_send_tab()
self.payto_e.is_pr = True
self.payto_e.disable_checks = True
for e in [self.payto_e, self.message_e]:
e.setFrozen(True)
self.lock_amount(True)
self.payto_e.setText(_("please wait..."))
return True
for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False)
self.payto_e.setTextNoCheck(_("please wait..."))
def delete_invoices(self, keys):
for key in keys:
@ -2195,14 +2241,18 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.do_clear()
self.payment_request = None
return
self.payto_e.is_pr = True
self.payto_e.disable_checks = True
if not pr.has_expired():
self.payto_e.setGreen()
else:
self.payto_e.setExpired()
self.payto_e.setText(pr.get_requestor())
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)
for btn in [self.send_button, self.clear_button]:
btn.setEnabled(True)
# signal to set fee
self.amount_e.textEdited.emit("")
@ -2215,14 +2265,46 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.do_clear()
def on_pr(self, request: 'paymentrequest.PaymentRequest'):
self.set_onchain(True)
self.payment_request = request
if self.payment_request.verify(self.contacts):
self.payment_request_ok_signal.emit()
else:
self.payment_request_error_signal.emit()
def set_ln_invoice(self, invoice: str):
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)
self.send_button.setText(_('Get Invoice'))
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)
@ -2238,9 +2320,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
else:
description = ''
self.payto_e.setFrozen(True)
self.payto_e.setText(pubkey)
self.payto_e.setTextNoCheck(pubkey)
self.payto_e.lightning_invoice = invoice
self.message_e.setText(description)
if not self.message_e.text():
self.message_e.setText(description)
if lnaddr.get_amount_sat() is not None:
self.amount_e.setAmount(lnaddr.get_amount_sat())
self.set_onchain(False)
@ -2249,9 +2332,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self._is_onchain = b
self.max_button.setEnabled(b)
def set_bip21(self, text: str):
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, self.on_pr)
out = util.parse_URI(text, on_bip70_pr)
except InvalidBitcoinURI as e:
self.show_error(_("Error parsing URI") + f":\n{e}")
return
@ -2259,8 +2343,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
r = out.get('r')
sig = out.get('sig')
name = out.get('name')
if r or (name and sig):
self.prepare_for_payment_request()
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')
@ -2268,7 +2352,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
message = out.get('message')
lightning = out.get('lightning')
if lightning:
self.set_ln_invoice(lightning)
self.handle_payment_identifier(lightning, can_use_network=can_use_network)
return
# use label as description (not BIP21 compliant)
if label and not message:
@ -2280,28 +2364,45 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
if amount:
self.amount_e.setAmount(amount)
def pay_to_URI(self, text: str):
def handle_payment_identifier(self, text: str, *, can_use_network: bool = True):
"""Takes
Lightning identifiers:
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
Bitcoin identifiers:
* bitcoin-URI
and sets the sending screen.
"""
text = text.strip()
if not text:
return
# first interpret as lightning invoice
bolt11_invoice = maybe_extract_bolt11_invoice(text)
if bolt11_invoice:
self.set_ln_invoice(bolt11_invoice)
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:
self.set_bip21(text)
raise ValueError("Could not handle payment identifier.")
# update fiat amount
self.amount_e.textEdited.emit("")
self.show_send_tab()
def do_clear(self):
self._lnurl_data = None
self.send_button.restore_original_text()
self.max_button.setChecked(False)
self.payment_request = None
self.payto_URI = None
self.payto_e.is_pr = False
self.payto_e.do_clear()
self.set_onchain(False)
for e in [self.payto_e, self.message_e, self.amount_e]:
for e in [self.message_e, self.amount_e]:
e.setText('')
e.setFrozen(False)
for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]:
e.setEnabled(True)
self.update_status()
run_hook('do_clear', self)
@ -3121,7 +3222,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return
# if the user scanned a bitcoin URI
if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
self.pay_to_URI(data)
self.handle_payment_identifier(data)
return
if data.lower().startswith('channel_backup:'):
self.import_channel_backup(data)

107
electrum/gui/qt/paytoedit.py

@ -31,11 +31,11 @@ from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING
from PyQt5.QtGui import QFontMetrics, QFont
from electrum import bitcoin
from electrum.util import bfh, maybe_extract_bolt11_invoice, BITCOIN_BIP21_URI_SCHEME, parse_max_spend
from electrum.util import bfh, parse_max_spend
from electrum.transaction import PartialTxOutput
from electrum.bitcoin import opcodes, construct_script
from electrum.logging import Logger
from electrum.lnaddr import LnDecodeException
from electrum.lnurl import LNURLError
from .qrtextedit import ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit
@ -63,9 +63,10 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
def __init__(self, win: 'ElectrumWindow'):
CompletionTextEdit.__init__(self)
ScanQRTextEdit.__init__(self, config=win.config)
ScanQRTextEdit.__init__(self, config=win.config, setText=self._on_input_btn)
Logger.__init__(self)
self.win = win
self.app = win.app
self.amount_edit = win.amount_e
self.setFont(QFont(MONOSPACE_FONT))
document = self.document()
@ -84,10 +85,11 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
self.c = None
self.textChanged.connect(self.check_text)
self.addPasteButton(setText=self._on_input_btn)
self.textChanged.connect(self._on_text_changed)
self.outputs = [] # type: List[PartialTxOutput]
self.errors = [] # type: List[PayToLineError]
self.is_pr = False
self.disable_checks = False
self.is_alias = False
self.update_size()
self.payto_scriptpubkey = None # type: Optional[bytes]
@ -99,6 +101,18 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.setStyleSheet(frozen_style if b else normal_style)
self.overlay_widget.setHidden(b)
def setTextNoCheck(self, text: str):
"""Sets the text, while also ensuring the new value will not be resolved/checked."""
self.previous_payto = text
self.setText(text)
def do_clear(self):
self.disable_checks = False
self.is_alias = False
self.setText('')
self.setFrozen(False)
self.setEnabled(True)
def setGreen(self):
self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
@ -157,9 +171,29 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
assert bitcoin.is_address(address)
return address
def check_text(self):
def _on_input_btn(self, text: str):
self.setText(text)
self._check_text(full_check=True)
def _on_text_changed(self):
if self.app.clipboard().text() == self.toPlainText():
# user likely pasted from clipboard
self._check_text(full_check=True)
else:
self._check_text(full_check=False)
def on_timer_check_text(self):
if self.hasFocus():
return
self._check_text(full_check=True)
def _check_text(self, *, full_check: bool):
if self.previous_payto == str(self.toPlainText()).strip():
return
if full_check:
self.previous_payto = str(self.toPlainText()).strip()
self.errors = []
if self.is_pr:
if self.disable_checks:
return
# filter out empty lines
lines = [i for i in self.lines() if i]
@ -170,14 +204,14 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
if len(lines) == 1:
data = lines[0]
# try bip21 URI
if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
self.win.pay_to_URI(data)
return
# try LN invoice
bolt11_invoice = maybe_extract_bolt11_invoice(data)
if bolt11_invoice is not None:
self.win.set_ln_invoice(bolt11_invoice)
try:
self.win.handle_payment_identifier(data, can_use_network=full_check)
except LNURLError as e:
self.logger.exception("")
self.win.show_error(e)
except ValueError:
pass
else:
return
# try "address, amount" on-chain format
try:
@ -195,6 +229,12 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.win.set_onchain(True)
self.win.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
else:
# there are multiple lines
self._parse_as_multiline(lines, raise_errors=False)
@ -256,7 +296,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
return len(self.lines()) > 1
def paytomany(self):
self.setText("\n\n\n")
self.setTextNoCheck("\n\n\n")
self.update_size()
def update_size(self):
@ -276,44 +316,34 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
# The scrollbar visibility can have changed so we update the overlay position here
self._updateOverlayPos()
def resolve(self):
self.is_alias = False
if self.hasFocus():
return
if self.is_multiline(): # only supports single line entries atm
return
if self.is_pr:
return
key = str(self.toPlainText())
def _resolve_openalias(self, text: str) -> Optional[dict]:
key = text
key = key.strip() # strip whitespaces
if key == self.previous_payto:
return
self.previous_payto = key
if not (('.' in key) and (not '<' in key) and (not ' ' in key)):
return
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
return None
try:
data = self.win.contacts.resolve(key)
except Exception as e:
self.logger.info(f'error resolving address/alias: {repr(e)}')
return
if not data:
return
self.is_alias = True
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)
self.previous_payto = new_url
self.setTextNoCheck(new_url)
#if self.win.config.get('openalias_autoadd') == 'checked':
self.win.contacts[key] = ('openalias', name)
self.win.contact_list.update()
self.setFrozen(True)
if data.get('type') == 'openalias':
self.validated = data.get('validated')
if self.validated:
@ -322,3 +352,4 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.setExpired()
else:
self.validated = None
return True

13
electrum/gui/qt/qrtextedit.py

@ -1,3 +1,5 @@
from typing import Callable
from electrum.i18n import _
from electrum.plugin import run_hook
from electrum.simple_config import SimpleConfig
@ -21,11 +23,16 @@ class ShowQRTextEdit(ButtonsTextEdit):
class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin):
def __init__(self, text="", allow_multi: bool = False, *, config: SimpleConfig):
def __init__(
self, text="", allow_multi: bool = False,
*,
config: SimpleConfig,
setText: Callable[[str], None] = None,
):
ButtonsTextEdit.__init__(self, text)
self.setReadOnly(False)
self.add_file_input_button(config=config, show_error=self.show_error)
self.add_qr_input_button(config=config, show_error=self.show_error, allow_multi=allow_multi)
self.add_file_input_button(config=config, show_error=self.show_error, setText=setText)
self.add_qr_input_button(config=config, show_error=self.show_error, allow_multi=allow_multi, setText=setText)
run_hook('scan_text_edit', self)
def contextMenuEvent(self, e):

4
electrum/gui/qt/util.py

@ -75,11 +75,15 @@ class EnterButton(QPushButton):
QPushButton.__init__(self, text)
self.func = func
self.clicked.connect(func)
self._orig_text = text
def keyPressEvent(self, e):
if e.key() in [Qt.Key_Return, Qt.Key_Enter]:
self.func()
def restore_original_text(self):
self.setText(self._orig_text)
class ThreadedButton(QPushButton):
def __init__(self, text, task, on_success=None, on_error=None):

108
electrum/lnurl.py

@ -0,0 +1,108 @@
"""Module for lnurl-related functionality."""
# https://github.com/sipa/bech32/tree/master/ref/python
# https://github.com/lnbits/lnurl
import asyncio
import json
from typing import Callable, Optional, NamedTuple, Any, TYPE_CHECKING
import re
import aiohttp.client_exceptions
from aiohttp import ClientResponse
from electrum.segwit_addr import bech32_decode, Encoding, convertbits
from electrum.lnaddr import LnDecodeException
from electrum.network import Network
if TYPE_CHECKING:
from collections.abc import Coroutine
class LNURLError(Exception):
pass
def decode_lnurl(lnurl: str) -> str:
"""Converts bech32 encoded lnurl to url."""
decoded_bech32 = bech32_decode(
lnurl, ignore_long_length=True
)
hrp = decoded_bech32.hrp
data = decoded_bech32.data
if decoded_bech32.encoding is None:
raise LnDecodeException("Bad bech32 checksum")
if decoded_bech32.encoding != Encoding.BECH32:
raise LnDecodeException("Bad bech32 encoding: must be using vanilla BECH32")
if not hrp.startswith("lnurl"):
raise LnDecodeException("Does not start with lnurl")
data = convertbits(data, 5, 8, False)
url = bytes(data).decode("utf-8")
return url
class LNURL6Data(NamedTuple):
callback_url: str
max_sendable_sat: int
min_sendable_sat: int
metadata_plaintext: str
#tag: str = "payRequest"
async def _request_lnurl(url: str) -> dict:
"""Requests payment data from a lnurl."""
try:
response = await Network.async_send_http_on_proxy("get", url, timeout=10)
except asyncio.TimeoutError as e:
raise LNURLError("Server did not reply in time.") from e
except aiohttp.client_exceptions.ClientError as e:
raise LNURLError(f"Client error: {e}") from e
# TODO: handling of specific client errors
response = json.loads(response)
if "metadata" in response:
response["metadata"] = json.loads(response["metadata"])
status = response.get("status")
if status and status == "ERROR":
raise LNURLError(f"LNURL request encountered an error: {response['reason']}")
return response
async def request_lnurl(url: str) -> Optional[LNURL6Data]:
lnurl_dict = await _request_lnurl(url)
tag = lnurl_dict.get('tag')
if tag != 'payRequest': # only LNURL6 is handled atm
return None
metadata = lnurl_dict.get('metadata')
metadata_plaintext = ""
for m in metadata:
if m[0] == 'text/plain':
metadata_plaintext = str(m[1])
data = LNURL6Data(
callback_url=lnurl_dict['callback'],
max_sendable_sat=int(lnurl_dict['maxSendable']) // 1000,
min_sendable_sat=int(lnurl_dict['minSendable']) // 1000,
metadata_plaintext=metadata_plaintext,
)
return data
async def callback_lnurl(url: str, params: dict) -> dict:
"""Requests an invoice from a lnurl supporting server."""
try:
response = await Network.async_send_http_on_proxy("get", url, params=params)
except aiohttp.client_exceptions.ClientError as e:
raise LNURLError(f"Client error: {e}") from e
# TODO: handling of specific errors
response = json.loads(response)
status = response.get("status")
if status and status == "ERROR":
raise LNURLError(f"LNURL request encountered an error: {response['reason']}")
return response
def lightning_address_to_url(address: str) -> Optional[str]:
"""Converts an email-type lightning address to a decoded lnurl.
see https://github.com/fiatjaf/lnurl-rfc/blob/luds/16.md
"""
if re.match(r"[^@]+@[^@]+\.[^@]+", address):
username, domain = address.split("@")
return f"https://{domain}/.well-known/lnurlp/{username}"

2
electrum/network.py

@ -1297,7 +1297,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
@classmethod
async def async_send_http_on_proxy(
cls, method: str, url: str, *,
params: str = None,
params: dict = None,
body: bytes = None,
json: dict = None,
headers=None,

12
electrum/tests/test_lnurl.py

@ -0,0 +1,12 @@
from unittest import TestCase
from electrum import lnurl
class TestLnurl(TestCase):
def test_decode(self):
LNURL = (
"LNURL1DP68GURN8GHJ7UM9WFMXJCM99E5K7TELWY7NXENRXVMRGDTZXSENJCM98PJNWXQ96S9"
)
url = lnurl.decode_lnurl(LNURL)
self.assertTrue("https://service.io/?q=3fc3645b439ce8e7", url)

10
electrum/util.py

@ -1065,7 +1065,7 @@ def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str],
return str(urllib.parse.urlunparse(p))
def maybe_extract_bolt11_invoice(data: str) -> Optional[str]:
def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
data = data.strip() # whitespaces
data = data.lower()
if data.startswith(LIGHTNING_URI_SCHEME + ':ln'):
@ -1076,6 +1076,14 @@ def maybe_extract_bolt11_invoice(data: str) -> Optional[str]:
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
# Python bug (http://bugs.python.org/issue1927) causes raw_input
# to be redirected improperly between stdin/stderr on Unix systems
#TODO: py3

7
run_electrum

@ -98,7 +98,7 @@ from electrum.wallet_db import WalletDB
from electrum.wallet import Wallet
from electrum.storage import WalletStorage
from electrum.util import print_msg, print_stderr, json_encode, json_decode, UserCancelled
from electrum.util import InvalidPassword, BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from electrum.util import InvalidPassword
from electrum.commands import get_parser, known_commands, Commands, config_variables
from electrum import daemon
from electrum import keystore
@ -362,10 +362,7 @@ def main():
# check uri
uri = config_options.get('url')
if uri and not (
uri.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':') or
uri.lower().startswith(LIGHTNING_URI_SCHEME + ':')
):
if uri and not util.is_uri(uri):
print_stderr('unknown command:', uri)
sys.exit(1)

Loading…
Cancel
Save