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 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
import re import re
from typing import Optional, Tuple
import dns import dns
import threading import threading
from dns.exception import DNSException from dns.exception import DNSException
@ -106,7 +108,7 @@ class Contacts(dict, Logger):
t.daemon = True t.daemon = True
t.start() 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 # support email-style addresses, per the OA standard
url = url.replace('@', '.') url = url.replace('@', '.')
try: try:

27
electrum/gui/kivy/main_window.py

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

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

@ -2,6 +2,7 @@ import asyncio
from decimal import Decimal from decimal import Decimal
import threading import threading
from typing import TYPE_CHECKING, List, Optional, Dict, Any from typing import TYPE_CHECKING, List, Optional, Dict, Any
from urllib.parse import urlparse
from kivy.app import App from kivy.app import App
from kivy.clock import Clock from kivy.clock import Clock
@ -16,10 +17,12 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING,
pr_expiration_values, Invoice) pr_expiration_values, Invoice)
from electrum import bitcoin, constants from electrum import bitcoin, constants
from electrum.transaction import tx_from_any, PartialTxOutput from electrum.transaction import tx_from_any, PartialTxOutput
from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice, from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier,
InvoiceError, format_time, parse_max_spend) InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME)
from electrum.lnaddr import lndecode, LnInvoiceException from electrum.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from electrum.logging import Logger from electrum.logging import Logger
from electrum.network import Network
from .dialogs.confirm_tx_dialog import ConfirmTxDialog from .dialogs.confirm_tx_dialog import ConfirmTxDialog
@ -162,22 +165,42 @@ class SendScreen(CScreen, Logger):
kvname = 'send' kvname = 'send'
payment_request = None # type: Optional[PaymentRequest] payment_request = None # type: Optional[PaymentRequest]
parsed_URI = None parsed_URI = None
lnurl_data = None # type: Optional[LNURL6Data]
def __init__(self, **kwargs): def __init__(self, **kwargs):
CScreen.__init__(self, **kwargs) CScreen.__init__(self, **kwargs)
Logger.__init__(self) Logger.__init__(self)
self.is_max = False 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): 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: if not self.app.wallet:
return return
# interpret as lighting URI text = text.strip()
bolt11_invoice = maybe_extract_bolt11_invoice(text) if not text:
if bolt11_invoice: return
self.set_ln_invoice(bolt11_invoice) if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
# interpret as BIP21 URI if invoice_or_lnurl.startswith('lnurl'):
else: 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) self.set_bip21(text)
else:
self.app.show_error(f"Failed to parse text: {text[:10]}...")
return
def set_bip21(self, text: str): def set_bip21(self, text: str):
try: try:
@ -194,7 +217,7 @@ class SendScreen(CScreen, Logger):
self.payment_request = None self.payment_request = None
self.is_lightning = False self.is_lightning = False
def set_ln_invoice(self, invoice: str): def set_bolt11(self, invoice: str):
try: try:
invoice = str(invoice).lower() invoice = str(invoice).lower()
lnaddr = lndecode(invoice) lnaddr = lndecode(invoice)
@ -207,6 +230,20 @@ class SendScreen(CScreen, Logger):
self.payment_request = None self.payment_request = None
self.is_lightning = True 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): def update(self):
if self.app.wallet is None: if self.app.wallet is None:
return return
@ -263,6 +300,8 @@ class SendScreen(CScreen, Logger):
self.is_bip70 = False self.is_bip70 = False
self.parsed_URI = None self.parsed_URI = None
self.is_max = False self.is_max = False
self.lnurl_data = None
self.is_lnurl = False
def set_request(self, pr: 'PaymentRequest'): def set_request(self, pr: 'PaymentRequest'):
self.address = pr.get_requestor() self.address = pr.get_requestor()
@ -277,21 +316,7 @@ class SendScreen(CScreen, Logger):
if not data: if not data:
self.app.show_info(_("Clipboard is empty")) self.app.show_info(_("Clipboard is empty"))
return return
# try to decode as transaction self.app.on_data_input(data)
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)
def read_invoice(self): def read_invoice(self):
address = str(self.address) address = str(self.address)
@ -340,7 +365,35 @@ class SendScreen(CScreen, Logger):
self.do_clear() self.do_clear()
self.update() 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): def do_pay(self):
if self.lnurl_data:
self._lnurl_get_invoice()
return
invoice = self.read_invoice() invoice = self.read_invoice()
if not invoice: if not invoice:
return return

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

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

4
electrum/gui/qt/__init__.py

@ -87,7 +87,7 @@ class OpenFileEventFilter(QObject):
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FileOpen: if event.type() == QtCore.QEvent.FileOpen:
if len(self.windows) >= 1: if len(self.windows) >= 1:
self.windows[0].pay_to_URI(event.url().toString()) self.windows[0].handle_payment_identifier(event.url().toString())
return True return True
return False return False
@ -383,7 +383,7 @@ class ElectrumGui(BaseElectrumGui, Logger):
self.start_new_window(path, uri=None, force_wizard=True) self.start_new_window(path, uri=None, force_wizard=True)
return return
if uri: if uri:
window.pay_to_URI(uri) window.handle_payment_identifier(uri)
window.bring_to_top() window.bring_to_top()
window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) 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 return
QPlainTextEdit.keyPressEvent(self, e) 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)) ctrlOrShift = bool(e.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier))
if self.completer is None or (ctrlOrShift and not e.text()): 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 import asyncio
from typing import Optional, TYPE_CHECKING, Sequence, List, Union, Dict, Set from typing import Optional, TYPE_CHECKING, Sequence, List, Union, Dict, Set
import concurrent.futures import concurrent.futures
from urllib.parse import urlparse
from PyQt5.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont from PyQt5.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont
from PyQt5.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal, QPoint 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.bitcoin import COIN, is_address
from electrum.plugin import run_hook, BasePlugin from electrum.plugin import run_hook, BasePlugin
from electrum.i18n import _ from electrum.i18n import _
from electrum.util import (format_time, from electrum.util import (format_time, get_asyncio_loop,
UserCancelled, profiler, UserCancelled, profiler,
bh2u, bfh, InvalidPassword, bh2u, bfh, InvalidPassword,
UserFacingException, UserFacingException,
get_new_wallet_name, send_exception_to_crash_reporter, get_new_wallet_name, send_exception_to_crash_reporter,
InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds, InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds,
NoDynamicFeeEstimates, NoDynamicFeeEstimates,
AddTransactionException, BITCOIN_BIP21_URI_SCHEME, AddTransactionException, BITCOIN_BIP21_URI_SCHEME,
InvoiceError, parse_max_spend) InvoiceError, parse_max_spend)
@ -81,6 +82,7 @@ from electrum.simple_config import SimpleConfig
from electrum.logging import Logger from electrum.logging import Logger
from electrum.lnutil import ln_dummy_address, extract_nodeid, ConnStringFormatError from electrum.lnutil import ln_dummy_address, extract_nodeid, ConnStringFormatError
from electrum.lnaddr import lndecode, LnInvoiceException from electrum.lnaddr import lndecode, LnInvoiceException
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
from .exception_window import Exception_Hook from .exception_window import Exception_Hook
from .amountedit import AmountEdit, BTCAmountEdit, FreezableLineEdit, FeerateEdit, SizedFreezableLineEdit 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_ok_signal = pyqtSignal()
payment_request_error_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) #ln_payment_attempt_signal = pyqtSignal(str)
computing_privkeys_signal = pyqtSignal() computing_privkeys_signal = pyqtSignal()
show_privkeys_signal = pyqtSignal() show_privkeys_signal = pyqtSignal()
show_error_signal = pyqtSignal(str) show_error_signal = pyqtSignal(str)
payment_request: Optional[paymentrequest.PaymentRequest] payment_request: Optional[paymentrequest.PaymentRequest]
_lnurl_data: Optional[LNURL6Data] = None
def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet): def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet):
QMainWindow.__init__(self) 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_ok_signal.connect(self.payment_request_ok)
self.payment_request_error_signal.connect(self.payment_request_error) 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.show_error_signal.connect(self.show_error)
self.history_list.setFocus(True) self.history_list.setFocus(True)
@ -820,7 +830,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
d = self.network.get_donation_address() d = self.network.get_donation_address()
if d: if d:
host = self.network.get_parameters().server.host 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: else:
self.show_error(_('No donation address for this server')) self.show_error(_('No donation address for this server'))
@ -912,8 +922,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
# this updates "synchronizing" progress # this updates "synchronizing" progress
self.update_status() self.update_status()
# resolve aliases # resolve aliases
# FIXME this is a blocking network call that has a timeout of 5 sec # FIXME this might do blocking network calls that has a timeout of several seconds
self.payto_e.resolve() self.payto_e.on_timer_check_text()
self.notify_transactions() self.notify_transactions()
def format_amount(self, amount_sat, is_diff=False, whitespaces=False) -> str: 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 from .paytoedit import PayToEdit
self.amount_e = BTCAmountEdit(self.get_decimal_point) self.amount_e = BTCAmountEdit(self.get_decimal_point)
self.payto_e = PayToEdit(self) self.payto_e = PayToEdit(self)
self.payto_e.addPasteButton()
msg = (_("Recipient of the funds.") + "\n\n" msg = (_("Recipient of the funds.") + "\n\n"
+ _("You may enter a Bitcoin address, a label from your list of contacts " + _("You may enter a Bitcoin address, a label from your list of contacts "
"(a list of completions will be proposed), " "(a list of completions will be proposed), "
@ -1573,7 +1582,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
grid.addWidget(self.max_button, 3, 3) grid.addWidget(self.max_button, 3, 3)
self.save_button = EnterButton(_("Save"), self.do_save_invoice) 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) self.clear_button = EnterButton(_("Clear"), self.do_clear)
buttons = QHBoxLayout() buttons = QHBoxLayout()
@ -1910,7 +1919,43 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.invoice_list.update() self.invoice_list.update()
self.pending_invoice = None 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() self.pending_invoice = self.read_invoice()
if not self.pending_invoice: if not self.pending_invoice:
return return
@ -2171,14 +2216,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.amount_e.setFrozen(b) self.amount_e.setFrozen(b)
self.max_button.setEnabled(not 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.show_send_tab()
self.payto_e.is_pr = True self.payto_e.disable_checks = True
for e in [self.payto_e, self.message_e]: for e in [self.payto_e, self.message_e]:
e.setFrozen(True) e.setFrozen(True)
self.lock_amount(True) self.lock_amount(True)
self.payto_e.setText(_("please wait...")) for btn in [self.save_button, self.send_button, self.clear_button]:
return True btn.setEnabled(False)
self.payto_e.setTextNoCheck(_("please wait..."))
def delete_invoices(self, keys): def delete_invoices(self, keys):
for key in keys: for key in keys:
@ -2195,14 +2241,18 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.do_clear() self.do_clear()
self.payment_request = None self.payment_request = None
return return
self.payto_e.is_pr = True self.payto_e.disable_checks = True
if not pr.has_expired(): if not pr.has_expired():
self.payto_e.setGreen() self.payto_e.setGreen()
else: else:
self.payto_e.setExpired() 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.amount_e.setAmount(pr.get_amount())
self.message_e.setText(pr.get_memo()) 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 # signal to set fee
self.amount_e.textEdited.emit("") self.amount_e.textEdited.emit("")
@ -2215,14 +2265,46 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.do_clear() self.do_clear()
def on_pr(self, request: 'paymentrequest.PaymentRequest'): def on_pr(self, request: 'paymentrequest.PaymentRequest'):
self.set_onchain(True)
self.payment_request = request self.payment_request = request
if self.payment_request.verify(self.contacts): if self.payment_request.verify(self.contacts):
self.payment_request_ok_signal.emit() self.payment_request_ok_signal.emit()
else: else:
self.payment_request_error_signal.emit() 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.""" """Parse ln invoice, and prepare the send tab for it."""
try: try:
lnaddr = lndecode(invoice) lnaddr = lndecode(invoice)
@ -2238,9 +2320,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
else: else:
description = '' description = ''
self.payto_e.setFrozen(True) self.payto_e.setFrozen(True)
self.payto_e.setText(pubkey) self.payto_e.setTextNoCheck(pubkey)
self.payto_e.lightning_invoice = invoice 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: if lnaddr.get_amount_sat() is not None:
self.amount_e.setAmount(lnaddr.get_amount_sat()) self.amount_e.setAmount(lnaddr.get_amount_sat())
self.set_onchain(False) self.set_onchain(False)
@ -2249,9 +2332,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self._is_onchain = b self._is_onchain = b
self.max_button.setEnabled(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: try:
out = util.parse_URI(text, self.on_pr) out = util.parse_URI(text, on_bip70_pr)
except InvalidBitcoinURI as e: except InvalidBitcoinURI as e:
self.show_error(_("Error parsing URI") + f":\n{e}") self.show_error(_("Error parsing URI") + f":\n{e}")
return return
@ -2259,8 +2343,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
r = out.get('r') r = out.get('r')
sig = out.get('sig') sig = out.get('sig')
name = out.get('name') name = out.get('name')
if r or (name and sig): if (r or (name and sig)) and can_use_network:
self.prepare_for_payment_request() self.prepare_for_send_tab_network_lookup()
return return
address = out.get('address') address = out.get('address')
amount = out.get('amount') amount = out.get('amount')
@ -2268,7 +2352,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
message = out.get('message') message = out.get('message')
lightning = out.get('lightning') lightning = out.get('lightning')
if lightning: if lightning:
self.set_ln_invoice(lightning) self.handle_payment_identifier(lightning, can_use_network=can_use_network)
return return
# use label as description (not BIP21 compliant) # use label as description (not BIP21 compliant)
if label and not message: if label and not message:
@ -2280,28 +2364,45 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
if amount: if amount:
self.amount_e.setAmount(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: if not text:
return return
# first interpret as lightning invoice if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
bolt11_invoice = maybe_extract_bolt11_invoice(text) if invoice_or_lnurl.startswith('lnurl'):
if bolt11_invoice: self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network)
self.set_ln_invoice(bolt11_invoice) 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: else:
self.set_bip21(text) raise ValueError("Could not handle payment identifier.")
# update fiat amount # update fiat amount
self.amount_e.textEdited.emit("") self.amount_e.textEdited.emit("")
self.show_send_tab() self.show_send_tab()
def do_clear(self): def do_clear(self):
self._lnurl_data = None
self.send_button.restore_original_text()
self.max_button.setChecked(False) self.max_button.setChecked(False)
self.payment_request = None self.payment_request = None
self.payto_URI = None self.payto_URI = None
self.payto_e.is_pr = False self.payto_e.do_clear()
self.set_onchain(False) 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.setText('')
e.setFrozen(False) 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() self.update_status()
run_hook('do_clear', self) run_hook('do_clear', self)
@ -3121,7 +3222,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return return
# if the user scanned a bitcoin URI # if the user scanned a bitcoin URI
if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
self.pay_to_URI(data) self.handle_payment_identifier(data)
return return
if data.lower().startswith('channel_backup:'): if data.lower().startswith('channel_backup:'):
self.import_channel_backup(data) 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 PyQt5.QtGui import QFontMetrics, QFont
from electrum import bitcoin 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.transaction import PartialTxOutput
from electrum.bitcoin import opcodes, construct_script from electrum.bitcoin import opcodes, construct_script
from electrum.logging import Logger from electrum.logging import Logger
from electrum.lnaddr import LnDecodeException from electrum.lnurl import LNURLError
from .qrtextedit import ScanQRTextEdit from .qrtextedit import ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit from .completion_text_edit import CompletionTextEdit
@ -63,9 +63,10 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
def __init__(self, win: 'ElectrumWindow'): def __init__(self, win: 'ElectrumWindow'):
CompletionTextEdit.__init__(self) CompletionTextEdit.__init__(self)
ScanQRTextEdit.__init__(self, config=win.config) ScanQRTextEdit.__init__(self, config=win.config, setText=self._on_input_btn)
Logger.__init__(self) Logger.__init__(self)
self.win = win self.win = win
self.app = win.app
self.amount_edit = win.amount_e self.amount_edit = win.amount_e
self.setFont(QFont(MONOSPACE_FONT)) self.setFont(QFont(MONOSPACE_FONT))
document = self.document() document = self.document()
@ -84,10 +85,11 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.heightMax = (self.fontSpacing * 10) + self.verticalMargins self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
self.c = None 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.outputs = [] # type: List[PartialTxOutput]
self.errors = [] # type: List[PayToLineError] self.errors = [] # type: List[PayToLineError]
self.is_pr = False self.disable_checks = False
self.is_alias = False self.is_alias = False
self.update_size() self.update_size()
self.payto_scriptpubkey = None # type: Optional[bytes] 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.setStyleSheet(frozen_style if b else normal_style)
self.overlay_widget.setHidden(b) 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): def setGreen(self):
self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True)) self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
@ -157,9 +171,29 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
assert bitcoin.is_address(address) assert bitcoin.is_address(address)
return 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 = [] self.errors = []
if self.is_pr: if self.disable_checks:
return return
# filter out empty lines # filter out empty lines
lines = [i for i in self.lines() if i] lines = [i for i in self.lines() if i]
@ -170,14 +204,14 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
if len(lines) == 1: if len(lines) == 1:
data = lines[0] data = lines[0]
# try bip21 URI try:
if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): self.win.handle_payment_identifier(data, can_use_network=full_check)
self.win.pay_to_URI(data) except LNURLError as e:
return self.logger.exception("")
# try LN invoice self.win.show_error(e)
bolt11_invoice = maybe_extract_bolt11_invoice(data) except ValueError:
if bolt11_invoice is not None: pass
self.win.set_ln_invoice(bolt11_invoice) else:
return return
# try "address, amount" on-chain format # try "address, amount" on-chain format
try: try:
@ -195,6 +229,12 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.win.set_onchain(True) self.win.set_onchain(True)
self.win.lock_amount(False) self.win.lock_amount(False)
return 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: else:
# there are multiple lines # there are multiple lines
self._parse_as_multiline(lines, raise_errors=False) self._parse_as_multiline(lines, raise_errors=False)
@ -256,7 +296,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
return len(self.lines()) > 1 return len(self.lines()) > 1
def paytomany(self): def paytomany(self):
self.setText("\n\n\n") self.setTextNoCheck("\n\n\n")
self.update_size() self.update_size()
def update_size(self): 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 # The scrollbar visibility can have changed so we update the overlay position here
self._updateOverlayPos() self._updateOverlayPos()
def resolve(self): def _resolve_openalias(self, text: str) -> Optional[dict]:
self.is_alias = False key = text
if self.hasFocus():
return
if self.is_multiline(): # only supports single line entries atm
return
if self.is_pr:
return
key = str(self.toPlainText())
key = key.strip() # strip whitespaces key = key.strip() # strip whitespaces
if key == self.previous_payto: if not (('.' in key) and ('<' not in key) and (' ' not in key)):
return return None
self.previous_payto = key
if not (('.' in key) and (not '<' in key) and (not ' ' in key)):
return
parts = key.split(sep=',') # assuming single line parts = key.split(sep=',') # assuming single line
if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
return return None
try: try:
data = self.win.contacts.resolve(key) data = self.win.contacts.resolve(key)
except Exception as e: except Exception as e:
self.logger.info(f'error resolving address/alias: {repr(e)}') self.logger.info(f'error resolving address/alias: {repr(e)}')
return return None
if not data: return data or None
return
self.is_alias = True
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') address = data.get('address')
name = data.get('name') name = data.get('name')
new_url = key + ' <' + address + '>' new_url = key + ' <' + address + '>'
self.setText(new_url) self.setTextNoCheck(new_url)
self.previous_payto = new_url
#if self.win.config.get('openalias_autoadd') == 'checked': #if self.win.config.get('openalias_autoadd') == 'checked':
self.win.contacts[key] = ('openalias', name) self.win.contacts[key] = ('openalias', name)
self.win.contact_list.update() self.win.contact_list.update()
self.setFrozen(True)
if data.get('type') == 'openalias': if data.get('type') == 'openalias':
self.validated = data.get('validated') self.validated = data.get('validated')
if self.validated: if self.validated:
@ -322,3 +352,4 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
self.setExpired() self.setExpired()
else: else:
self.validated = None 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.i18n import _
from electrum.plugin import run_hook from electrum.plugin import run_hook
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
@ -21,11 +23,16 @@ class ShowQRTextEdit(ButtonsTextEdit):
class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin): 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) ButtonsTextEdit.__init__(self, text)
self.setReadOnly(False) self.setReadOnly(False)
self.add_file_input_button(config=config, show_error=self.show_error) 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) self.add_qr_input_button(config=config, show_error=self.show_error, allow_multi=allow_multi, setText=setText)
run_hook('scan_text_edit', self) run_hook('scan_text_edit', self)
def contextMenuEvent(self, e): def contextMenuEvent(self, e):

4
electrum/gui/qt/util.py

@ -75,11 +75,15 @@ class EnterButton(QPushButton):
QPushButton.__init__(self, text) QPushButton.__init__(self, text)
self.func = func self.func = func
self.clicked.connect(func) self.clicked.connect(func)
self._orig_text = text
def keyPressEvent(self, e): def keyPressEvent(self, e):
if e.key() in [Qt.Key_Return, Qt.Key_Enter]: if e.key() in [Qt.Key_Return, Qt.Key_Enter]:
self.func() self.func()
def restore_original_text(self):
self.setText(self._orig_text)
class ThreadedButton(QPushButton): class ThreadedButton(QPushButton):
def __init__(self, text, task, on_success=None, on_error=None): 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 @classmethod
async def async_send_http_on_proxy( async def async_send_http_on_proxy(
cls, method: str, url: str, *, cls, method: str, url: str, *,
params: str = None, params: dict = None,
body: bytes = None, body: bytes = None,
json: dict = None, json: dict = None,
headers=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)) 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.strip() # whitespaces
data = data.lower() data = data.lower()
if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): if data.startswith(LIGHTNING_URI_SCHEME + ':ln'):
@ -1076,6 +1076,14 @@ def maybe_extract_bolt11_invoice(data: str) -> Optional[str]:
return None 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 # Python bug (http://bugs.python.org/issue1927) causes raw_input
# to be redirected improperly between stdin/stderr on Unix systems # to be redirected improperly between stdin/stderr on Unix systems
#TODO: py3 #TODO: py3

7
run_electrum

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

Loading…
Cancel
Save