Merge pull request #7839 from SomberNight/202202_lnurl_2
add lnurl-pay (`LUD-06`) support
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
Normal file
108
electrum/lnurl.py
Normal file
@@ -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}"
|
||||
@@ -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
Normal file
12
electrum/tests/test_lnurl.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user