payment_identifiers:
- this separates GUI from core handling - the PaymentIdentifier class handles network requests - the GUI is agnostic about the type of PI
This commit is contained in:
committed by
Sander van Grieken
parent
d83149f668
commit
15eb765eac
@@ -86,9 +86,8 @@ Label.register(
|
||||
)
|
||||
|
||||
|
||||
from electrum.util import (NoDynamicFeeEstimates, NotEnoughFunds,
|
||||
BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME,
|
||||
UserFacingException)
|
||||
from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException
|
||||
from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
|
||||
|
||||
from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog
|
||||
from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog
|
||||
|
||||
@@ -18,8 +18,8 @@ from electrum.invoices import (PR_DEFAULT_EXPIRATION_WHEN_CREATING,
|
||||
from electrum import bitcoin, constants
|
||||
from electrum import lnutil
|
||||
from electrum.transaction import tx_from_any, PartialTxOutput
|
||||
from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_lightning_payment_identifier,
|
||||
InvoiceError, format_time, parse_max_spend, BITCOIN_BIP21_URI_SCHEME)
|
||||
from electrum.util import TxMinedInfo, InvoiceError, format_time, parse_max_spend
|
||||
from electrum.payment_identifier import parse_bip21_URI, BITCOIN_BIP21_URI_SCHEME, maybe_extract_lightning_payment_identifier, InvalidBitcoinURI
|
||||
from electrum.lnaddr import lndecode, LnInvoiceException
|
||||
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
|
||||
from electrum.logging import Logger
|
||||
@@ -208,7 +208,7 @@ class SendScreen(CScreen, Logger):
|
||||
|
||||
def set_bip21(self, text: str):
|
||||
try:
|
||||
uri = parse_URI(text, self.app.on_pr, loop=self.app.asyncio_loop)
|
||||
uri = parse_bip21_URI(text) # bip70 not supported
|
||||
except InvalidBitcoinURI as e:
|
||||
self.app.show_info(_("Error parsing URI") + f":\n{e}")
|
||||
return
|
||||
|
||||
@@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica
|
||||
from electrum import version, constants
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.util import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
|
||||
from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
|
||||
from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue
|
||||
from electrum.network import Network
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ class OpenFileEventFilter(QObject):
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QtCore.QEvent.FileOpen:
|
||||
if len(self.windows) >= 1:
|
||||
self.windows[0].handle_payment_identifier(event.url().toString())
|
||||
self.windows[0].set_payment_identifier(event.url().toString())
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -393,7 +393,7 @@ class ElectrumGui(BaseElectrumGui, Logger):
|
||||
window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)
|
||||
window.activateWindow()
|
||||
if uri:
|
||||
window.handle_payment_identifier(uri)
|
||||
window.send_tab.set_payment_identifier(uri)
|
||||
return window
|
||||
|
||||
def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:
|
||||
|
||||
@@ -58,9 +58,10 @@ from electrum.i18n import _
|
||||
from electrum.util import (format_time, get_asyncio_loop,
|
||||
UserCancelled, profiler,
|
||||
bfh, InvalidPassword,
|
||||
UserFacingException, FailedToParsePaymentIdentifier,
|
||||
UserFacingException,
|
||||
get_new_wallet_name, send_exception_to_crash_reporter,
|
||||
AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod)
|
||||
AddTransactionException, os_chmod)
|
||||
from electrum.payment_identifier import FailedToParsePaymentIdentifier, BITCOIN_BIP21_URI_SCHEME
|
||||
from electrum.invoices import PR_PAID, Invoice
|
||||
from electrum.transaction import (Transaction, PartialTxInput,
|
||||
PartialTransaction, PartialTxOutput)
|
||||
|
||||
+18
-196
@@ -33,7 +33,8 @@ from PyQt5.QtGui import QFontMetrics, QFont
|
||||
from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout
|
||||
|
||||
from electrum import bitcoin
|
||||
from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier
|
||||
from electrum.util import parse_max_spend
|
||||
from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier
|
||||
from electrum.transaction import PartialTxOutput
|
||||
from electrum.bitcoin import opcodes, construct_script
|
||||
from electrum.logging import Logger
|
||||
@@ -49,20 +50,10 @@ if TYPE_CHECKING:
|
||||
from .send_tab import SendTab
|
||||
|
||||
|
||||
RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
|
||||
|
||||
frozen_style = "QWidget {border:none;}"
|
||||
normal_style = "QPlainTextEdit { }"
|
||||
|
||||
|
||||
class PayToLineError(NamedTuple):
|
||||
line_content: str
|
||||
exc: Exception
|
||||
idx: int = 0 # index of line
|
||||
is_multiline: bool = False
|
||||
|
||||
|
||||
|
||||
class ResizingTextEdit(QTextEdit):
|
||||
|
||||
def __init__(self):
|
||||
@@ -109,12 +100,9 @@ class PayToEdit(Logger, GenericInputHandler):
|
||||
self.amount_edit = self.send_tab.amount_e
|
||||
|
||||
self.is_multiline = False
|
||||
self.outputs = [] # type: List[PartialTxOutput]
|
||||
self.errors = [] # type: List[PayToLineError]
|
||||
self.disable_checks = False
|
||||
self.is_alias = False
|
||||
self.payto_scriptpubkey = None # type: Optional[bytes]
|
||||
self.lightning_invoice = None
|
||||
self.previous_payto = ''
|
||||
# editor methods
|
||||
self.setStyleSheet = self.editor.setStyleSheet
|
||||
@@ -180,6 +168,7 @@ class PayToEdit(Logger, GenericInputHandler):
|
||||
self.setText(text)
|
||||
|
||||
def do_clear(self):
|
||||
self.is_multiline = False
|
||||
self.set_paytomany(False)
|
||||
self.disable_checks = False
|
||||
self.is_alias = False
|
||||
@@ -194,58 +183,6 @@ class PayToEdit(Logger, GenericInputHandler):
|
||||
def setExpired(self):
|
||||
self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
|
||||
|
||||
def parse_address_and_amount(self, line) -> PartialTxOutput:
|
||||
try:
|
||||
x, y = line.split(',')
|
||||
except ValueError:
|
||||
raise Exception("expected two comma-separated values: (address, amount)") from None
|
||||
scriptpubkey = self.parse_output(x)
|
||||
amount = self.parse_amount(y)
|
||||
return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
|
||||
|
||||
def parse_output(self, x) -> bytes:
|
||||
try:
|
||||
address = self.parse_address(x)
|
||||
return bytes.fromhex(bitcoin.address_to_script(address))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
script = self.parse_script(x)
|
||||
return bytes.fromhex(script)
|
||||
except Exception:
|
||||
pass
|
||||
raise Exception("Invalid address or script.")
|
||||
|
||||
def parse_script(self, x):
|
||||
script = ''
|
||||
for word in x.split():
|
||||
if word[0:3] == 'OP_':
|
||||
opcode_int = opcodes[word]
|
||||
script += construct_script([opcode_int])
|
||||
else:
|
||||
bytes.fromhex(word) # to test it is hex data
|
||||
script += construct_script([word])
|
||||
return script
|
||||
|
||||
def parse_amount(self, x):
|
||||
x = x.strip()
|
||||
if not x:
|
||||
raise Exception("Amount is empty")
|
||||
if parse_max_spend(x):
|
||||
return x
|
||||
p = pow(10, self.amount_edit.decimal_point())
|
||||
try:
|
||||
return int(p * Decimal(x))
|
||||
except decimal.InvalidOperation:
|
||||
raise Exception("Invalid amount")
|
||||
|
||||
def parse_address(self, line):
|
||||
r = line.strip()
|
||||
m = re.match('^'+RE_ALIAS+'$', r)
|
||||
address = str(m.group(2) if m else r)
|
||||
assert bitcoin.is_address(address)
|
||||
return address
|
||||
|
||||
def _on_input_btn(self, text: str):
|
||||
self.setText(text)
|
||||
|
||||
@@ -257,6 +194,7 @@ class PayToEdit(Logger, GenericInputHandler):
|
||||
if self.is_multiline and not self._is_paytomany:
|
||||
self.set_paytomany(True)
|
||||
self.text_edit.setText(text)
|
||||
self.text_edit.setFocus()
|
||||
|
||||
def on_timer_check_text(self):
|
||||
if self.editor.hasFocus():
|
||||
@@ -265,149 +203,33 @@ class PayToEdit(Logger, GenericInputHandler):
|
||||
self._check_text(text, full_check=True)
|
||||
|
||||
def _check_text(self, text, *, full_check: bool):
|
||||
"""
|
||||
side effects: self.is_multiline, self.errors, self.outputs
|
||||
"""
|
||||
if self.previous_payto == str(text).strip():
|
||||
""" side effects: self.is_multiline """
|
||||
text = str(text).strip()
|
||||
if not text:
|
||||
return
|
||||
if self.previous_payto == text:
|
||||
return
|
||||
if full_check:
|
||||
self.previous_payto = str(text).strip()
|
||||
self.errors = []
|
||||
errors = []
|
||||
self.previous_payto = text
|
||||
if self.disable_checks:
|
||||
return
|
||||
# filter out empty lines
|
||||
lines = text.split('\n')
|
||||
lines = [i for i in lines if i]
|
||||
self.is_multiline = len(lines)>1
|
||||
pi = PaymentIdentifier(self.config, self.win.contacts, text)
|
||||
self.is_multiline = bool(pi.multiline_outputs)
|
||||
print('is_multiline', self.is_multiline)
|
||||
self.send_tab.handle_payment_identifier(pi, can_use_network=full_check)
|
||||
|
||||
self.payto_scriptpubkey = None
|
||||
self.lightning_invoice = None
|
||||
self.outputs = []
|
||||
|
||||
if len(lines) == 1:
|
||||
data = lines[0]
|
||||
try:
|
||||
self.send_tab.handle_payment_identifier(data, can_use_network=full_check)
|
||||
except LNURLError as e:
|
||||
self.logger.exception("")
|
||||
self.send_tab.show_error(e)
|
||||
except FailedToParsePaymentIdentifier:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
# try "address, amount" on-chain format
|
||||
try:
|
||||
self._parse_as_multiline(lines, raise_errors=True)
|
||||
except Exception as e:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
# try address/script
|
||||
try:
|
||||
self.payto_scriptpubkey = self.parse_output(data)
|
||||
except Exception as e:
|
||||
errors.append(PayToLineError(line_content=data, exc=e))
|
||||
else:
|
||||
self.send_tab.set_onchain(True)
|
||||
self.send_tab.lock_amount(False)
|
||||
return
|
||||
if full_check: # network requests # FIXME blocking GUI thread
|
||||
# try openalias
|
||||
oa_data = self._resolve_openalias(data)
|
||||
if oa_data:
|
||||
self._set_openalias(key=data, data=oa_data)
|
||||
return
|
||||
# all parsing attempts failed, so now expose the errors:
|
||||
if errors:
|
||||
self.errors = errors
|
||||
else:
|
||||
# there are multiple lines
|
||||
self._parse_as_multiline(lines, raise_errors=False)
|
||||
|
||||
|
||||
def _parse_as_multiline(self, lines, *, raise_errors: bool):
|
||||
outputs = [] # type: List[PartialTxOutput]
|
||||
def handle_multiline(self, outputs):
|
||||
total = 0
|
||||
is_max = False
|
||||
for i, line in enumerate(lines):
|
||||
try:
|
||||
output = self.parse_address_and_amount(line)
|
||||
except Exception as e:
|
||||
if raise_errors:
|
||||
raise
|
||||
else:
|
||||
self.errors.append(PayToLineError(
|
||||
idx=i, line_content=line.strip(), exc=e, is_multiline=True))
|
||||
continue
|
||||
outputs.append(output)
|
||||
for output in outputs:
|
||||
if parse_max_spend(output.value):
|
||||
is_max = True
|
||||
else:
|
||||
total += output.value
|
||||
if outputs:
|
||||
self.send_tab.set_onchain(True)
|
||||
|
||||
self.send_tab.set_onchain(True)
|
||||
self.send_tab.max_button.setChecked(is_max)
|
||||
self.outputs = outputs
|
||||
self.payto_scriptpubkey = None
|
||||
|
||||
if self.send_tab.max_button.isChecked():
|
||||
self.send_tab.spend_max()
|
||||
else:
|
||||
self.amount_edit.setAmount(total if outputs else None)
|
||||
self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs))
|
||||
|
||||
def get_errors(self) -> Sequence[PayToLineError]:
|
||||
return self.errors
|
||||
|
||||
def get_destination_scriptpubkey(self) -> Optional[bytes]:
|
||||
return self.payto_scriptpubkey
|
||||
|
||||
def get_outputs(self, is_max: bool) -> List[PartialTxOutput]:
|
||||
if self.payto_scriptpubkey:
|
||||
if is_max:
|
||||
amount = '!'
|
||||
else:
|
||||
amount = self.send_tab.get_amount()
|
||||
self.outputs = [PartialTxOutput(scriptpubkey=self.payto_scriptpubkey, value=amount)]
|
||||
|
||||
return self.outputs[:]
|
||||
|
||||
def _resolve_openalias(self, text: str) -> Optional[dict]:
|
||||
key = text
|
||||
key = key.strip() # strip whitespaces
|
||||
if not (('.' in key) and ('<' not in key) and (' ' not in key)):
|
||||
return None
|
||||
parts = key.split(sep=',') # assuming single line
|
||||
if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
|
||||
return None
|
||||
try:
|
||||
data = self.win.contacts.resolve(key)
|
||||
except Exception as e:
|
||||
self.logger.info(f'error resolving address/alias: {repr(e)}')
|
||||
return None
|
||||
return data or None
|
||||
|
||||
def _set_openalias(self, *, key: str, data: dict) -> bool:
|
||||
self.is_alias = True
|
||||
self.setFrozen(True)
|
||||
key = key.strip() # strip whitespaces
|
||||
address = data.get('address')
|
||||
name = data.get('name')
|
||||
new_url = key + ' <' + address + '>'
|
||||
self.setText(new_url)
|
||||
|
||||
#if self.win.config.get('openalias_autoadd') == 'checked':
|
||||
self.win.contacts[key] = ('openalias', name)
|
||||
self.win.contact_list.update()
|
||||
|
||||
if data.get('type') == 'openalias':
|
||||
self.validated = data.get('validated')
|
||||
if self.validated:
|
||||
self.setGreen()
|
||||
else:
|
||||
self.setExpired()
|
||||
else:
|
||||
self.validated = None
|
||||
return True
|
||||
#self.send_tab.lock_amount(self.send_tab.max_button.isChecked() or bool(outputs))
|
||||
|
||||
+168
-288
@@ -5,8 +5,6 @@
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, QPoint
|
||||
from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout,
|
||||
QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton)
|
||||
@@ -15,15 +13,14 @@ from electrum import util, paymentrequest
|
||||
from electrum import lnutil
|
||||
from electrum.plugin import run_hook
|
||||
from electrum.i18n import _
|
||||
from electrum.util import (get_asyncio_loop, FailedToParsePaymentIdentifier,
|
||||
InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, NotEnoughFunds,
|
||||
NoDynamicFeeEstimates, InvoiceError, parse_max_spend)
|
||||
|
||||
from electrum.util import get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates, InvoiceError, parse_max_spend
|
||||
from electrum.payment_identifier import PaymentIdentifier, FailedToParsePaymentIdentifier, InvalidBitcoinURI
|
||||
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
|
||||
|
||||
from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput
|
||||
from electrum.network import TxBroadcastError, BestEffortRequestFailed
|
||||
from electrum.logging import Logger
|
||||
from electrum.lnaddr import lndecode, LnInvoiceException
|
||||
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
|
||||
|
||||
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
|
||||
from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit
|
||||
@@ -36,15 +33,9 @@ if TYPE_CHECKING:
|
||||
|
||||
class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
|
||||
payment_request_ok_signal = pyqtSignal()
|
||||
payment_request_error_signal = pyqtSignal()
|
||||
lnurl6_round1_signal = pyqtSignal(object, object)
|
||||
lnurl6_round2_signal = pyqtSignal(object)
|
||||
clear_send_tab_signal = pyqtSignal()
|
||||
show_error_signal = pyqtSignal(str)
|
||||
|
||||
payment_request: Optional[paymentrequest.PaymentRequest]
|
||||
_lnurl_data: Optional[LNURL6Data] = None
|
||||
round_1_signal = pyqtSignal(object)
|
||||
round_2_signal = pyqtSignal(object)
|
||||
round_3_signal = pyqtSignal(object)
|
||||
|
||||
def __init__(self, window: 'ElectrumWindow'):
|
||||
QWidget.__init__(self, window)
|
||||
@@ -60,8 +51,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
self.format_amount = window.format_amount
|
||||
self.base_unit = window.base_unit
|
||||
|
||||
self.payto_URI = None
|
||||
self.payment_request = None # type: Optional[paymentrequest.PaymentRequest]
|
||||
self.payment_identifier = None
|
||||
self.pending_invoice = None
|
||||
|
||||
# A 4-column grid layout. All the stretch is in the last column.
|
||||
@@ -84,9 +74,9 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
+ _("Integers weights can also be used in conjunction with '!', "
|
||||
"e.g. set one amount to '2!' and another to '3!' to split your coins 40-60."))
|
||||
payto_label = HelpLabel(_('Pay to'), msg)
|
||||
grid.addWidget(payto_label, 1, 0)
|
||||
grid.addWidget(self.payto_e.line_edit, 1, 1, 1, 4)
|
||||
grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4)
|
||||
grid.addWidget(payto_label, 0, 0)
|
||||
grid.addWidget(self.payto_e.line_edit, 0, 1, 1, 4)
|
||||
grid.addWidget(self.payto_e.text_edit, 0, 1, 1, 4)
|
||||
|
||||
#completer = QCompleter()
|
||||
#completer.setCaseSensitivity(False)
|
||||
@@ -97,9 +87,17 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
+ _(
|
||||
'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.')
|
||||
description_label = HelpLabel(_('Description'), msg)
|
||||
grid.addWidget(description_label, 2, 0)
|
||||
grid.addWidget(description_label, 1, 0)
|
||||
self.message_e = SizedFreezableLineEdit(width=600)
|
||||
grid.addWidget(self.message_e, 2, 1, 1, 4)
|
||||
grid.addWidget(self.message_e, 1, 1, 1, 4)
|
||||
|
||||
msg = _('Comment for recipient')
|
||||
self.comment_label = HelpLabel(_('Comment'), msg)
|
||||
grid.addWidget(self.comment_label, 2, 0)
|
||||
self.comment_e = SizedFreezableLineEdit(width=600)
|
||||
grid.addWidget(self.comment_e, 2, 1, 1, 4)
|
||||
self.comment_label.hide()
|
||||
self.comment_e.hide()
|
||||
|
||||
msg = (_('The amount to be received by the recipient.') + ' '
|
||||
+ _('Fees are paid by the sender.') + '\n\n'
|
||||
@@ -129,11 +127,11 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
|
||||
self.clear_button = EnterButton(_("Clear"), self.do_clear)
|
||||
self.paste_button = QPushButton()
|
||||
self.paste_button.clicked.connect(lambda: self.payto_e._on_input_btn(self.window.app.clipboard().text()))
|
||||
self.paste_button.clicked.connect(self.do_paste)
|
||||
self.paste_button.setIcon(read_QIcon('copy.png'))
|
||||
self.paste_button.setToolTip(_('Paste invoice from clipboard'))
|
||||
self.paste_button.setMaximumWidth(35)
|
||||
grid.addWidget(self.paste_button, 1, 5)
|
||||
grid.addWidget(self.paste_button, 0, 5)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
@@ -160,7 +158,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
self.invoice_list = InvoiceList(self)
|
||||
self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('')
|
||||
|
||||
|
||||
menu.addAction(read_QIcon(get_iconname_camera()), _("Read QR code with camera"), self.payto_e.on_qr_from_camera_input_btn)
|
||||
menu.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), self.payto_e.on_qr_from_screenshot_input_btn)
|
||||
menu.addAction(read_QIcon("file.png"), _("Read invoice from file"), self.payto_e.on_input_file)
|
||||
@@ -186,17 +183,33 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
self.invoice_list.update() # after parented and put into a layout, can update without flickering
|
||||
run_hook('create_send_tab', grid)
|
||||
|
||||
self.payment_request_ok_signal.connect(self.payment_request_ok)
|
||||
self.payment_request_error_signal.connect(self.payment_request_error)
|
||||
self.lnurl6_round1_signal.connect(self.on_lnurl6_round1)
|
||||
self.lnurl6_round2_signal.connect(self.on_lnurl6_round2)
|
||||
self.clear_send_tab_signal.connect(self.do_clear)
|
||||
self.show_error_signal.connect(self.show_error)
|
||||
self.round_1_signal.connect(self.on_round_1)
|
||||
self.round_2_signal.connect(self.on_round_2)
|
||||
self.round_3_signal.connect(self.on_round_3)
|
||||
|
||||
def do_paste(self):
|
||||
text = self.window.app.clipboard().text()
|
||||
if not text:
|
||||
return
|
||||
self.set_payment_identifier(text)
|
||||
|
||||
def set_payment_identifier(self, text):
|
||||
pi = PaymentIdentifier(self.config, self.window.contacts, text)
|
||||
if pi.error:
|
||||
self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error)
|
||||
return
|
||||
if pi.is_multiline():
|
||||
self.payto_e.set_paytomany(True)
|
||||
self.payto_e.text_edit.setText(text)
|
||||
else:
|
||||
self.payto_e.setTextNoCheck(text)
|
||||
self.handle_payment_identifier(pi, can_use_network=True)
|
||||
|
||||
def spend_max(self):
|
||||
if run_hook('abort_send', self):
|
||||
return
|
||||
outputs = self.payto_e.get_outputs(True)
|
||||
amount = self.get_amount()
|
||||
outputs = self.payment_identifier.get_onchain_outputs(amount)
|
||||
if not outputs:
|
||||
return
|
||||
make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
|
||||
@@ -297,15 +310,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
return self.format_amount_and_units(frozen_bal)
|
||||
|
||||
def do_clear(self):
|
||||
self._lnurl_data = None
|
||||
self.max_button.setChecked(False)
|
||||
self.payment_request = None
|
||||
self.payto_URI = None
|
||||
self.payto_e.do_clear()
|
||||
self.set_onchain(False)
|
||||
for e in [self.message_e, self.amount_e]:
|
||||
for w in [self.comment_e, self.comment_label]:
|
||||
w.setVisible(False)
|
||||
for e in [self.message_e, self.amount_e, self.fiat_send_e]:
|
||||
e.setText('')
|
||||
e.setFrozen(False)
|
||||
self.set_field_style(e, None, False)
|
||||
for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]:
|
||||
e.setEnabled(True)
|
||||
self.window.update_status()
|
||||
@@ -315,208 +327,101 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
self._is_onchain = b
|
||||
self.max_button.setEnabled(b)
|
||||
|
||||
def lock_amount(self, b: bool) -> None:
|
||||
self.amount_e.setFrozen(b)
|
||||
self.max_button.setEnabled(not b)
|
||||
|
||||
def prepare_for_send_tab_network_lookup(self):
|
||||
self.window.show_send_tab()
|
||||
self.payto_e.disable_checks = True
|
||||
for e in [self.payto_e, self.message_e]:
|
||||
e.setFrozen(True)
|
||||
self.lock_amount(True)
|
||||
#for e in [self.payto_e, self.message_e]:
|
||||
self.payto_e.setFrozen(True)
|
||||
for btn in [self.save_button, self.send_button, self.clear_button]:
|
||||
btn.setEnabled(False)
|
||||
self.payto_e.setTextNoCheck(_("please wait..."))
|
||||
|
||||
def payment_request_ok(self):
|
||||
pr = self.payment_request
|
||||
if not pr:
|
||||
return
|
||||
invoice = Invoice.from_bip70_payreq(pr, height=0)
|
||||
if self.wallet.get_invoice_status(invoice) == PR_PAID:
|
||||
self.show_message("invoice already paid")
|
||||
self.do_clear()
|
||||
self.payment_request = None
|
||||
return
|
||||
self.payto_e.disable_checks = True
|
||||
if not pr.has_expired():
|
||||
self.payto_e.setGreen()
|
||||
else:
|
||||
self.payto_e.setExpired()
|
||||
self.payto_e.setTextNoCheck(pr.get_requestor())
|
||||
self.amount_e.setAmount(pr.get_amount())
|
||||
self.message_e.setText(pr.get_memo())
|
||||
self.set_onchain(True)
|
||||
self.max_button.setEnabled(False)
|
||||
# note: allow saving bip70 reqs, as we save them anyway when paying them
|
||||
for btn in [self.send_button, self.clear_button, self.save_button]:
|
||||
btn.setEnabled(True)
|
||||
# signal to set fee
|
||||
self.amount_e.textEdited.emit("")
|
||||
|
||||
def payment_request_error(self):
|
||||
pr = self.payment_request
|
||||
if not pr:
|
||||
return
|
||||
self.show_message(pr.error)
|
||||
self.payment_request = None
|
||||
def payment_request_error(self, error):
|
||||
self.show_message(error)
|
||||
self.do_clear()
|
||||
|
||||
def on_pr(self, request: 'paymentrequest.PaymentRequest'):
|
||||
self.payment_request = request
|
||||
if self.payment_request.verify(self.window.contacts):
|
||||
self.payment_request_ok_signal.emit()
|
||||
def set_field_style(self, w, text, validated):
|
||||
from .util import ColorScheme
|
||||
if validated is None:
|
||||
style = ColorScheme.LIGHTBLUE.as_stylesheet(True)
|
||||
elif validated is True:
|
||||
style = ColorScheme.GREEN.as_stylesheet(True)
|
||||
else:
|
||||
self.payment_request_error_signal.emit()
|
||||
|
||||
def set_lnurl6(self, lnurl: str, *, can_use_network: bool = True):
|
||||
try:
|
||||
url = decode_lnurl(lnurl)
|
||||
except LnInvoiceException as e:
|
||||
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
|
||||
return
|
||||
if not can_use_network:
|
||||
return
|
||||
|
||||
async def f():
|
||||
try:
|
||||
lnurl_data = await request_lnurl(url)
|
||||
except LNURLError as e:
|
||||
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
|
||||
self.clear_send_tab_signal.emit()
|
||||
return
|
||||
self.lnurl6_round1_signal.emit(lnurl_data, url)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
|
||||
self.prepare_for_send_tab_network_lookup()
|
||||
|
||||
def on_lnurl6_round1(self, lnurl_data: LNURL6Data, url: str):
|
||||
self._lnurl_data = lnurl_data
|
||||
domain = urlparse(url).netloc
|
||||
self.payto_e.setTextNoCheck(f"invoice from lnurl")
|
||||
self.message_e.setText(f"lnurl: {domain}: {lnurl_data.metadata_plaintext}")
|
||||
self.amount_e.setAmount(lnurl_data.min_sendable_sat)
|
||||
self.amount_e.setFrozen(False)
|
||||
for btn in [self.send_button, self.clear_button]:
|
||||
btn.setEnabled(True)
|
||||
self.set_onchain(False)
|
||||
|
||||
def set_bolt11(self, invoice: str):
|
||||
"""Parse ln invoice, and prepare the send tab for it."""
|
||||
try:
|
||||
lnaddr = lndecode(invoice)
|
||||
except LnInvoiceException as e:
|
||||
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
|
||||
return
|
||||
except lnutil.IncompatibleOrInsaneFeatures as e:
|
||||
self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
|
||||
return
|
||||
|
||||
pubkey = lnaddr.pubkey.serialize().hex()
|
||||
for k,v in lnaddr.tags:
|
||||
if k == 'd':
|
||||
description = v
|
||||
break
|
||||
style = ColorScheme.RED.as_stylesheet(True)
|
||||
if text is not None:
|
||||
w.setStyleSheet(style)
|
||||
w.setReadOnly(True)
|
||||
else:
|
||||
description = ''
|
||||
self.payto_e.setFrozen(True)
|
||||
self.payto_e.setTextNoCheck(pubkey)
|
||||
self.payto_e.lightning_invoice = invoice
|
||||
if not self.message_e.text():
|
||||
w.setStyleSheet('')
|
||||
w.setReadOnly(False)
|
||||
|
||||
def update_fields(self, pi):
|
||||
recipient, amount, description, comment, validated = pi.get_fields_for_GUI(self.wallet)
|
||||
if recipient:
|
||||
self.payto_e.setTextNoCheck(recipient)
|
||||
elif pi.multiline_outputs:
|
||||
self.payto_e.handle_multiline(pi.multiline_outputs)
|
||||
if description:
|
||||
self.message_e.setText(description)
|
||||
if lnaddr.get_amount_sat() is not None:
|
||||
self.amount_e.setAmount(lnaddr.get_amount_sat())
|
||||
self.set_onchain(False)
|
||||
|
||||
def set_bip21(self, text: str, *, can_use_network: bool = True):
|
||||
on_bip70_pr = self.on_pr if can_use_network else None
|
||||
try:
|
||||
out = util.parse_URI(text, on_bip70_pr)
|
||||
except InvalidBitcoinURI as e:
|
||||
self.show_error(_("Error parsing URI") + f":\n{e}")
|
||||
return
|
||||
self.payto_URI = out
|
||||
r = out.get('r')
|
||||
sig = out.get('sig')
|
||||
name = out.get('name')
|
||||
if (r or (name and sig)) and can_use_network:
|
||||
self.prepare_for_send_tab_network_lookup()
|
||||
return
|
||||
address = out.get('address')
|
||||
amount = out.get('amount')
|
||||
label = out.get('label')
|
||||
message = out.get('message')
|
||||
lightning = out.get('lightning')
|
||||
if lightning and (self.wallet.has_lightning() or not address):
|
||||
self.handle_payment_identifier(lightning, can_use_network=can_use_network)
|
||||
return
|
||||
# use label as description (not BIP21 compliant)
|
||||
if label and not message:
|
||||
message = label
|
||||
if address:
|
||||
self.payto_e.setText(address)
|
||||
if message:
|
||||
self.message_e.setText(message)
|
||||
if amount:
|
||||
self.amount_e.setAmount(amount)
|
||||
for w in [self.comment_e, self.comment_label]:
|
||||
w.setVisible(not bool(comment))
|
||||
self.set_field_style(self.payto_e, recipient or pi.multiline_outputs, validated)
|
||||
self.set_field_style(self.message_e, description, validated)
|
||||
self.set_field_style(self.amount_e, amount, validated)
|
||||
self.set_field_style(self.fiat_send_e, amount, validated)
|
||||
|
||||
def handle_payment_identifier(self, 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:
|
||||
def handle_payment_identifier(self, pi, *, can_use_network: bool = True):
|
||||
self.payment_identifier = pi
|
||||
is_valid = pi.is_valid()
|
||||
self.save_button.setEnabled(is_valid)
|
||||
self.send_button.setEnabled(is_valid)
|
||||
if not is_valid:
|
||||
return
|
||||
if invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
|
||||
if invoice_or_lnurl.startswith('lnurl'):
|
||||
self.set_lnurl6(invoice_or_lnurl, can_use_network=can_use_network)
|
||||
else:
|
||||
self.set_bolt11(invoice_or_lnurl)
|
||||
elif text.lower().startswith(util.BITCOIN_BIP21_URI_SCHEME + ':'):
|
||||
self.set_bip21(text, can_use_network=can_use_network)
|
||||
else:
|
||||
truncated_text = f"{text[:100]}..." if len(text) > 100 else text
|
||||
raise FailedToParsePaymentIdentifier(f"Could not handle payment identifier:\n{truncated_text}")
|
||||
self.update_fields(pi)
|
||||
if can_use_network and pi.needs_round_1():
|
||||
coro = pi.round_1(on_success=self.round_1_signal.emit)
|
||||
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
|
||||
self.prepare_for_send_tab_network_lookup()
|
||||
# update fiat amount
|
||||
self.amount_e.textEdited.emit("")
|
||||
self.window.show_send_tab()
|
||||
|
||||
def on_round_1(self, pi):
|
||||
if pi.error:
|
||||
self.show_error(pi.error)
|
||||
self.do_clear()
|
||||
return
|
||||
self.update_fields(pi)
|
||||
for btn in [self.send_button, self.clear_button, self.save_button]:
|
||||
btn.setEnabled(True)
|
||||
|
||||
def get_message(self):
|
||||
return self.message_e.text()
|
||||
def read_invoice(self) -> Optional[Invoice]:
|
||||
if self.check_payto_line_and_show_errors():
|
||||
return
|
||||
try:
|
||||
if not self._is_onchain:
|
||||
invoice_str = self.payto_e.lightning_invoice
|
||||
if not invoice_str:
|
||||
return
|
||||
invoice = Invoice.from_bech32(invoice_str)
|
||||
if invoice.amount_msat is None:
|
||||
amount_sat = self.get_amount()
|
||||
if amount_sat:
|
||||
invoice.amount_msat = int(amount_sat * 1000)
|
||||
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
|
||||
self.show_error(_('Lightning is disabled'))
|
||||
return
|
||||
return invoice
|
||||
else:
|
||||
outputs = self.read_outputs()
|
||||
if self.check_onchain_outputs_and_show_errors(outputs):
|
||||
return
|
||||
message = self.message_e.text()
|
||||
return self.wallet.create_invoice(
|
||||
outputs=outputs,
|
||||
message=message,
|
||||
pr=self.payment_request,
|
||||
URI=self.payto_URI)
|
||||
except InvoiceError as e:
|
||||
self.show_error(_('Error creating payment') + ':\n' + str(e))
|
||||
amount_sat = self.read_amount()
|
||||
if not amount_sat:
|
||||
self.show_error(_('No amount'))
|
||||
return
|
||||
|
||||
invoice = self.payment_identifier.get_invoice(self.wallet, amount_sat, self.get_message())
|
||||
#except Exception as e:
|
||||
if not invoice:
|
||||
self.show_error('error getting invoice' + pi.error)
|
||||
return
|
||||
if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
|
||||
self.show_error(_('Lightning is disabled'))
|
||||
if self.wallet.get_invoice_status(invoice) == PR_PAID:
|
||||
# fixme: this is only for bip70 and lightning
|
||||
self.show_error(_('Invoice already paid'))
|
||||
return
|
||||
#if not invoice.is_lightning():
|
||||
# if self.check_onchain_outputs_and_show_errors(outputs):
|
||||
# return
|
||||
return invoice
|
||||
|
||||
def do_save_invoice(self):
|
||||
self.pending_invoice = self.read_invoice()
|
||||
@@ -536,41 +441,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
# must not be None
|
||||
return self.amount_e.get_amount() or 0
|
||||
|
||||
def _lnurl_get_invoice(self) -> None:
|
||||
assert self._lnurl_data
|
||||
amount = self.get_amount()
|
||||
if not (self._lnurl_data.min_sendable_sat <= amount <= self._lnurl_data.max_sendable_sat):
|
||||
self.show_error(f'Amount must be between {self._lnurl_data.min_sendable_sat} and {self._lnurl_data.max_sendable_sat} sat.')
|
||||
return
|
||||
|
||||
async def f():
|
||||
try:
|
||||
invoice_data = await callback_lnurl(
|
||||
self._lnurl_data.callback_url,
|
||||
params={'amount': self.get_amount() * 1000},
|
||||
)
|
||||
except LNURLError as e:
|
||||
self.show_error_signal.emit(f"LNURL request encountered error: {e}")
|
||||
self.clear_send_tab_signal.emit()
|
||||
return
|
||||
invoice = invoice_data.get('pr')
|
||||
self.lnurl6_round2_signal.emit(invoice)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop()) # TODO should be cancellable
|
||||
self.prepare_for_send_tab_network_lookup()
|
||||
|
||||
def on_lnurl6_round2(self, bolt11_invoice: str):
|
||||
self._lnurl_data = None
|
||||
invoice = Invoice.from_bech32(bolt11_invoice)
|
||||
assert invoice.get_amount_sat() == self.get_amount(), (invoice.get_amount_sat(), self.get_amount())
|
||||
def on_round_2(self, pi):
|
||||
self.do_clear()
|
||||
self.payto_e.setText(bolt11_invoice)
|
||||
if pi.error:
|
||||
self.show_error(pi.error)
|
||||
self.do_clear()
|
||||
return
|
||||
self.update_fields(pi)
|
||||
invoice = pi.get_invoice(self.wallet, self.get_amount(), self.get_message())
|
||||
self.pending_invoice = invoice
|
||||
self.do_pay_invoice(invoice)
|
||||
|
||||
def on_round_3(self):
|
||||
pass
|
||||
|
||||
def do_pay_or_get_invoice(self):
|
||||
if self._lnurl_data:
|
||||
self._lnurl_get_invoice()
|
||||
pi = self.payment_identifier
|
||||
if pi.needs_round_2():
|
||||
coro = pi.round_2(self.round_2_signal.emit, amount_sat=self.get_amount(), comment=self.message_e.text())
|
||||
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) # TODO should be cancellable
|
||||
self.prepare_for_send_tab_network_lookup()
|
||||
return
|
||||
self.pending_invoice = self.read_invoice()
|
||||
if not self.pending_invoice:
|
||||
@@ -600,12 +490,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
else:
|
||||
self.pay_onchain_dialog(invoice.outputs)
|
||||
|
||||
def read_outputs(self) -> List[PartialTxOutput]:
|
||||
if self.payment_request:
|
||||
outputs = self.payment_request.get_outputs()
|
||||
else:
|
||||
outputs = self.payto_e.get_outputs(self.max_button.isChecked())
|
||||
return outputs
|
||||
def read_amount(self) -> List[PartialTxOutput]:
|
||||
is_max = self.max_button.isChecked()
|
||||
amount = '!' if is_max else self.get_amount()
|
||||
return amount
|
||||
|
||||
def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool:
|
||||
"""Returns whether there are errors with outputs.
|
||||
@@ -629,34 +517,30 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
"""Returns whether there are errors.
|
||||
Also shows error dialog to user if so.
|
||||
"""
|
||||
pr = self.payment_request
|
||||
if pr:
|
||||
if pr.has_expired():
|
||||
self.show_error(_('Payment request has expired'))
|
||||
error = self.payment_identifier.get_error()
|
||||
if error:
|
||||
if not self.payment_identifier.is_multiline():
|
||||
err = error
|
||||
self.show_warning(
|
||||
_("Failed to parse 'Pay to' line") + ":\n" +
|
||||
f"{err.line_content[:40]}...\n\n"
|
||||
f"{err.exc!r}")
|
||||
else:
|
||||
self.show_warning(
|
||||
_("Invalid Lines found:") + "\n\n" + error)
|
||||
#'\n'.join([_("Line #") +
|
||||
# f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
|
||||
# for err in errors]))
|
||||
return True
|
||||
|
||||
if self.payment_identifier.warning:
|
||||
msg += '\n' + _('Do you wish to continue?')
|
||||
if not self.question(msg):
|
||||
return True
|
||||
|
||||
if not pr:
|
||||
errors = self.payto_e.get_errors()
|
||||
if errors:
|
||||
if len(errors) == 1 and not errors[0].is_multiline:
|
||||
err = errors[0]
|
||||
self.show_warning(_("Failed to parse 'Pay to' line") + ":\n" +
|
||||
f"{err.line_content[:40]}...\n\n"
|
||||
f"{err.exc!r}")
|
||||
else:
|
||||
self.show_warning(_("Invalid Lines found:") + "\n\n" +
|
||||
'\n'.join([_("Line #") +
|
||||
f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
|
||||
for err in errors]))
|
||||
return True
|
||||
|
||||
if self.payto_e.is_alias and self.payto_e.validated is False:
|
||||
alias = self.payto_e.toPlainText()
|
||||
msg = _('WARNING: the alias "{}" could not be validated via an additional '
|
||||
'security check, DNSSEC, and thus may not be correct.').format(alias) + '\n'
|
||||
msg += _('Do you wish to continue?')
|
||||
if not self.question(msg):
|
||||
return True
|
||||
if self.payment_identifier.has_expired():
|
||||
self.show_error(_('Payment request has expired'))
|
||||
return True
|
||||
|
||||
return False # no errors
|
||||
|
||||
@@ -740,9 +624,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
|
||||
def broadcast_thread():
|
||||
# non-GUI thread
|
||||
pr = self.payment_request
|
||||
if pr and pr.has_expired():
|
||||
self.payment_request = None
|
||||
if self.payment_identifier.has_expired():
|
||||
return False, _("Invoice has expired")
|
||||
try:
|
||||
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
|
||||
@@ -752,13 +634,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
return False, repr(e)
|
||||
# success
|
||||
txid = tx.txid()
|
||||
if pr:
|
||||
self.payment_request = None
|
||||
if self.payment_identifier.needs_round_3():
|
||||
refund_address = self.wallet.get_receiving_address()
|
||||
coro = pr.send_payment_and_receive_paymentack(tx.serialize(), refund_address)
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
|
||||
ack_status, ack_msg = fut.result(timeout=20)
|
||||
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
|
||||
coro = self.payment_identifier.round_3(tx.serialize(), refund_address)
|
||||
asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
|
||||
return True, txid
|
||||
|
||||
# Capture current TL window; override might be removed on return
|
||||
@@ -804,3 +683,4 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
|
||||
self.payto_e.setFocus()
|
||||
text = "\n".join([payto + ", 0" for payto in paytos])
|
||||
self.payto_e.setText(text)
|
||||
self.payto_e.setFocus()
|
||||
|
||||
@@ -961,6 +961,7 @@ class ColorScheme:
|
||||
YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
|
||||
RED = ColorSchemeItem("#7c1111", "#f18c8c")
|
||||
BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
|
||||
LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff")
|
||||
DEFAULT = ColorSchemeItem("black", "white")
|
||||
GRAY = ColorSchemeItem("gray", "gray")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import attr
|
||||
from .json_db import StoredObject, stored_in
|
||||
from .i18n import _
|
||||
from .util import age, InvoiceError, format_satoshis
|
||||
from .payment_identifier import create_bip21_uri
|
||||
from .lnutil import hex_to_bytes
|
||||
from .lnaddr import lndecode, LnAddr
|
||||
from . import constants
|
||||
@@ -318,7 +319,6 @@ class Request(BaseInvoice):
|
||||
*,
|
||||
lightning_invoice: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
from electrum.util import create_bip21_uri
|
||||
addr = self.get_address()
|
||||
amount = self.get_amount_sat()
|
||||
if amount is not None:
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import asyncio
|
||||
import urllib
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import NamedTuple, Optional, Callable, Any, Sequence
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import bitcoin
|
||||
from .logging import Logger
|
||||
from .util import parse_max_spend, format_satoshis_plain
|
||||
from .util import get_asyncio_loop, log_exceptions
|
||||
from .transaction import PartialTxOutput
|
||||
from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data
|
||||
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
||||
from .lnaddr import lndecode, LnDecodeException, LnInvoiceException
|
||||
from .lnutil import IncompatibleOrInsaneFeatures
|
||||
|
||||
def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
|
||||
data = data.strip() # whitespaces
|
||||
data = data.lower()
|
||||
if data.startswith(LIGHTNING_URI_SCHEME + ':ln'):
|
||||
cut_prefix = LIGHTNING_URI_SCHEME + ':'
|
||||
data = data[len(cut_prefix):]
|
||||
if data.startswith('ln'):
|
||||
return data
|
||||
return None
|
||||
|
||||
# URL decode
|
||||
#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE)
|
||||
#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x)
|
||||
|
||||
|
||||
# note: when checking against these, use .lower() to support case-insensitivity
|
||||
BITCOIN_BIP21_URI_SCHEME = 'bitcoin'
|
||||
LIGHTNING_URI_SCHEME = 'lightning'
|
||||
|
||||
|
||||
class InvalidBitcoinURI(Exception): pass
|
||||
|
||||
|
||||
def parse_bip21_URI(uri: str) -> dict:
|
||||
"""Raises InvalidBitcoinURI on malformed URI."""
|
||||
|
||||
if not isinstance(uri, str):
|
||||
raise InvalidBitcoinURI(f"expected string, not {repr(uri)}")
|
||||
|
||||
if ':' not in uri:
|
||||
if not bitcoin.is_address(uri):
|
||||
raise InvalidBitcoinURI("Not a bitcoin address")
|
||||
return {'address': uri}
|
||||
|
||||
u = urllib.parse.urlparse(uri)
|
||||
if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME:
|
||||
raise InvalidBitcoinURI("Not a bitcoin URI")
|
||||
address = u.path
|
||||
|
||||
# python for android fails to parse query
|
||||
if address.find('?') > 0:
|
||||
address, query = u.path.split('?')
|
||||
pq = urllib.parse.parse_qs(query)
|
||||
else:
|
||||
pq = urllib.parse.parse_qs(u.query)
|
||||
|
||||
for k, v in pq.items():
|
||||
if len(v) != 1:
|
||||
raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}')
|
||||
|
||||
out = {k: v[0] for k, v in pq.items()}
|
||||
if address:
|
||||
if not bitcoin.is_address(address):
|
||||
raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}")
|
||||
out['address'] = address
|
||||
if 'amount' in out:
|
||||
am = out['amount']
|
||||
try:
|
||||
m = re.match(r'([0-9.]+)X([0-9])', am)
|
||||
if m:
|
||||
k = int(m.group(2)) - 8
|
||||
amount = Decimal(m.group(1)) * pow(Decimal(10), k)
|
||||
else:
|
||||
amount = Decimal(am) * COIN
|
||||
if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
|
||||
raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC")
|
||||
out['amount'] = int(amount)
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e
|
||||
if 'message' in out:
|
||||
out['message'] = out['message']
|
||||
out['memo'] = out['message']
|
||||
if 'time' in out:
|
||||
try:
|
||||
out['time'] = int(out['time'])
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e
|
||||
if 'exp' in out:
|
||||
try:
|
||||
out['exp'] = int(out['exp'])
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e
|
||||
if 'sig' in out:
|
||||
try:
|
||||
out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex()
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e
|
||||
if 'lightning' in out:
|
||||
try:
|
||||
lnaddr = lndecode(out['lightning'])
|
||||
except LnDecodeException as e:
|
||||
raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e
|
||||
amount_sat = out.get('amount')
|
||||
if amount_sat:
|
||||
# allow small leeway due to msat precision
|
||||
if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1:
|
||||
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount")
|
||||
address = out.get('address')
|
||||
ln_fallback_addr = lnaddr.get_fallback_address()
|
||||
if address and ln_fallback_addr:
|
||||
if ln_fallback_addr != address:
|
||||
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str],
|
||||
*, extra_query_params: Optional[dict] = None) -> str:
|
||||
if not bitcoin.is_address(addr):
|
||||
return ""
|
||||
if extra_query_params is None:
|
||||
extra_query_params = {}
|
||||
query = []
|
||||
if amount_sat:
|
||||
query.append('amount=%s'%format_satoshis_plain(amount_sat))
|
||||
if message:
|
||||
query.append('message=%s'%urllib.parse.quote(message))
|
||||
for k, v in extra_query_params.items():
|
||||
if not isinstance(k, str) or k != urllib.parse.quote(k):
|
||||
raise Exception(f"illegal key for URI: {repr(k)}")
|
||||
v = urllib.parse.quote(v)
|
||||
query.append(f"{k}={v}")
|
||||
p = urllib.parse.ParseResult(
|
||||
scheme=BITCOIN_BIP21_URI_SCHEME,
|
||||
netloc='',
|
||||
path=addr,
|
||||
params='',
|
||||
query='&'.join(query),
|
||||
fragment='',
|
||||
)
|
||||
return str(urllib.parse.urlunparse(p))
|
||||
|
||||
|
||||
|
||||
def is_uri(data: str) -> bool:
|
||||
data = data.lower()
|
||||
if (data.startswith(LIGHTNING_URI_SCHEME + ":") or
|
||||
data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
class FailedToParsePaymentIdentifier(Exception):
|
||||
pass
|
||||
|
||||
class PayToLineError(NamedTuple):
|
||||
line_content: str
|
||||
exc: Exception
|
||||
idx: int = 0 # index of line
|
||||
is_multiline: bool = False
|
||||
|
||||
RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
|
||||
RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b'
|
||||
|
||||
class PaymentIdentifier(Logger):
|
||||
"""
|
||||
Takes:
|
||||
* bitcoin addresses or script
|
||||
* paytomany csv
|
||||
* openalias
|
||||
* bip21 URI
|
||||
* lightning-URI (containing bolt11 or lnurl)
|
||||
* bolt11 invoice
|
||||
* lnurl
|
||||
"""
|
||||
|
||||
def __init__(self, config, contacts, text):
|
||||
Logger.__init__(self)
|
||||
self.contacts = contacts
|
||||
self.config = config
|
||||
self.text = text
|
||||
self._type = None
|
||||
self.error = None # if set, GUI should show error and stop
|
||||
self.warning = None # if set, GUI should ask user if they want to proceed
|
||||
# more than one of those may be set
|
||||
self.multiline_outputs = None
|
||||
self.bolt11 = None
|
||||
self.bip21 = None
|
||||
self.spk = None
|
||||
#
|
||||
self.openalias = None
|
||||
self.openalias_data = None
|
||||
#
|
||||
self.bip70 = None
|
||||
self.bip70_data = None
|
||||
#
|
||||
self.lnurl = None
|
||||
self.lnurl_data = None
|
||||
# parse without network
|
||||
self.parse(text)
|
||||
|
||||
def is_valid(self):
|
||||
return bool(self._type)
|
||||
|
||||
def is_lightning(self):
|
||||
return self.lnurl or self.bolt11
|
||||
|
||||
def is_multiline(self):
|
||||
return bool(self.multiline_outputs)
|
||||
|
||||
def get_error(self) -> str:
|
||||
return self.error
|
||||
|
||||
def needs_round_1(self):
|
||||
return self.bip70 or self.openalias or self.lnurl
|
||||
|
||||
def needs_round_2(self):
|
||||
return self.lnurl and self.lnurl_data
|
||||
|
||||
def needs_round_3(self):
|
||||
return self.bip70
|
||||
|
||||
def parse(self, text):
|
||||
# parse text, set self._type and self.error
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
if outputs:= self._parse_as_multiline(text):
|
||||
self._type = 'multiline'
|
||||
self.multiline_outputs = outputs
|
||||
elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
|
||||
if invoice_or_lnurl.startswith('lnurl'):
|
||||
self._type = 'lnurl'
|
||||
try:
|
||||
self.lnurl = decode_lnurl(invoice_or_lnurl)
|
||||
except Exception as e:
|
||||
self.error = "Error parsing Lightning invoice" + f":\n{e}"
|
||||
return
|
||||
else:
|
||||
self._type = 'bolt11'
|
||||
self.bolt11 = invoice_or_lnurl
|
||||
elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
|
||||
try:
|
||||
out = parse_bip21_URI(text)
|
||||
except InvalidBitcoinURI as e:
|
||||
self.error = _("Error parsing URI") + f":\n{e}"
|
||||
return
|
||||
self._type = 'bip21'
|
||||
self.bip21 = out
|
||||
self.bip70 = out.get('r')
|
||||
elif scriptpubkey := self.parse_output(text):
|
||||
self._type = 'spk'
|
||||
self.spk = scriptpubkey
|
||||
elif re.match(RE_EMAIL, text):
|
||||
self._type = 'alias'
|
||||
self.openalias = text
|
||||
elif self.error is None:
|
||||
truncated_text = f"{text[:100]}..." if len(text) > 100 else text
|
||||
self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_text}")
|
||||
|
||||
def get_onchain_outputs(self, amount):
|
||||
if self.bip70:
|
||||
return self.bip70_data.get_outputs()
|
||||
elif self.multiline_outputs:
|
||||
return self.multiline_outputs
|
||||
elif self.spk:
|
||||
return [PartialTxOutput(scriptpubkey=self.spk, value=amount)]
|
||||
elif self.bip21:
|
||||
address = self.bip21.get('address')
|
||||
scriptpubkey = self.parse_output(address)
|
||||
return [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)]
|
||||
else:
|
||||
raise Exception('not onchain')
|
||||
|
||||
def _parse_as_multiline(self, text):
|
||||
# filter out empty lines
|
||||
lines = text.split('\n')
|
||||
lines = [i for i in lines if i]
|
||||
is_multiline = len(lines)>1
|
||||
outputs = [] # type: List[PartialTxOutput]
|
||||
errors = []
|
||||
total = 0
|
||||
is_max = False
|
||||
for i, line in enumerate(lines):
|
||||
try:
|
||||
output = self.parse_address_and_amount(line)
|
||||
except Exception as e:
|
||||
errors.append(PayToLineError(
|
||||
idx=i, line_content=line.strip(), exc=e, is_multiline=True))
|
||||
continue
|
||||
outputs.append(output)
|
||||
if parse_max_spend(output.value):
|
||||
is_max = True
|
||||
else:
|
||||
total += output.value
|
||||
if is_multiline and errors:
|
||||
self.error = str(errors) if errors else None
|
||||
print(outputs, self.error)
|
||||
return outputs
|
||||
|
||||
def parse_address_and_amount(self, line) -> 'PartialTxOutput':
|
||||
try:
|
||||
x, y = line.split(',')
|
||||
except ValueError:
|
||||
raise Exception("expected two comma-separated values: (address, amount)") from None
|
||||
scriptpubkey = self.parse_output(x)
|
||||
amount = self.parse_amount(y)
|
||||
return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
|
||||
|
||||
def parse_output(self, x) -> bytes:
|
||||
try:
|
||||
address = self.parse_address(x)
|
||||
return bytes.fromhex(bitcoin.address_to_script(address))
|
||||
except Exception as e:
|
||||
error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False)
|
||||
try:
|
||||
script = self.parse_script(x)
|
||||
return bytes.fromhex(script)
|
||||
except Exception as e:
|
||||
#error = PayToLineError(idx=0, line_content=x, exc=e, is_multiline=False)
|
||||
pass
|
||||
#raise Exception("Invalid address or script.")
|
||||
#self.errors.append(error)
|
||||
|
||||
def parse_script(self, x):
|
||||
script = ''
|
||||
for word in x.split():
|
||||
if word[0:3] == 'OP_':
|
||||
opcode_int = opcodes[word]
|
||||
script += construct_script([opcode_int])
|
||||
else:
|
||||
bytes.fromhex(word) # to test it is hex data
|
||||
script += construct_script([word])
|
||||
return script
|
||||
|
||||
def parse_amount(self, x):
|
||||
x = x.strip()
|
||||
if not x:
|
||||
raise Exception("Amount is empty")
|
||||
if parse_max_spend(x):
|
||||
return x
|
||||
p = pow(10, self.config.get_decimal_point())
|
||||
try:
|
||||
return int(p * Decimal(x))
|
||||
except decimal.InvalidOperation:
|
||||
raise Exception("Invalid amount")
|
||||
|
||||
def parse_address(self, line):
|
||||
r = line.strip()
|
||||
m = re.match('^'+RE_ALIAS+'$', r)
|
||||
address = str(m.group(2) if m else r)
|
||||
assert bitcoin.is_address(address)
|
||||
return address
|
||||
|
||||
def get_fields_for_GUI(self, wallet):
|
||||
""" sets self.error as side effect"""
|
||||
recipient = None
|
||||
amount = None
|
||||
description = None
|
||||
validated = None
|
||||
comment = "no comment"
|
||||
|
||||
if self.openalias and self.openalias_data:
|
||||
address = self.openalias_data.get('address')
|
||||
name = self.openalias_data.get('name')
|
||||
recipient = self.openalias + ' <' + address + '>'
|
||||
validated = self.openalias_data.get('validated')
|
||||
if not validated:
|
||||
self.warning = _('WARNING: the alias "{}" could not be validated via an additional '
|
||||
'security check, DNSSEC, and thus may not be correct.').format(self.openalias)
|
||||
#self.payto_e.set_openalias(key=pi.openalias, data=oa_data)
|
||||
#self.window.contact_list.update()
|
||||
|
||||
elif self.bolt11:
|
||||
recipient, amount, description = self.get_bolt11_fields(self.bolt11)
|
||||
|
||||
elif self.lnurl and self.lnurl_data:
|
||||
domain = urlparse(self.lnurl).netloc
|
||||
#recipient = "invoice from lnurl"
|
||||
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
|
||||
#amount = self.lnurl_data.min_sendable_sat
|
||||
amount = None
|
||||
description = None
|
||||
if self.lnurl_data.comment_allowed:
|
||||
comment = None
|
||||
|
||||
elif self.bip70 and self.bip70_data:
|
||||
pr = self.bip70_data
|
||||
if pr.error:
|
||||
self.error = pr.error
|
||||
return
|
||||
recipient = pr.get_requestor()
|
||||
amount = pr.get_amount()
|
||||
description = pr.get_memo()
|
||||
validated = not pr.has_expired()
|
||||
#self.set_onchain(True)
|
||||
#self.max_button.setEnabled(False)
|
||||
# note: allow saving bip70 reqs, as we save them anyway when paying them
|
||||
#for btn in [self.send_button, self.clear_button, self.save_button]:
|
||||
# btn.setEnabled(True)
|
||||
# signal to set fee
|
||||
#self.amount_e.textEdited.emit("")
|
||||
|
||||
elif self.spk:
|
||||
recipient = self.text
|
||||
amount = None
|
||||
|
||||
elif self.multiline_outputs:
|
||||
pass
|
||||
|
||||
elif self.bip21:
|
||||
recipient = self.bip21.get('address')
|
||||
amount = self.bip21.get('amount')
|
||||
label = self.bip21.get('label')
|
||||
description = self.bip21.get('message')
|
||||
# use label as description (not BIP21 compliant)
|
||||
if label and not description:
|
||||
description = label
|
||||
lightning = self.bip21.get('lightning')
|
||||
if lightning and wallet.has_lightning():
|
||||
# maybe set self.bolt11?
|
||||
recipient, amount, description = self.get_bolt11_fields(lightning)
|
||||
if not amount:
|
||||
amount_required = True
|
||||
# todo: merge logic
|
||||
|
||||
return recipient, amount, description, comment, validated
|
||||
|
||||
def get_bolt11_fields(self, bolt11_invoice):
|
||||
"""Parse ln invoice, and prepare the send tab for it."""
|
||||
try:
|
||||
lnaddr = lndecode(bolt11_invoice)
|
||||
except LnInvoiceException as e:
|
||||
self.show_error(_("Error parsing Lightning invoice") + f":\n{e}")
|
||||
return
|
||||
except IncompatibleOrInsaneFeatures as e:
|
||||
self.show_error(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
|
||||
return
|
||||
pubkey = lnaddr.pubkey.serialize().hex()
|
||||
for k,v in lnaddr.tags:
|
||||
if k == 'd':
|
||||
description = v
|
||||
break
|
||||
else:
|
||||
description = ''
|
||||
amount = lnaddr.get_amount_sat()
|
||||
return pubkey, amount, description
|
||||
|
||||
async def resolve_openalias(self) -> Optional[dict]:
|
||||
key = self.openalias
|
||||
if not (('.' in key) and ('<' not in key) and (' ' not in key)):
|
||||
return None
|
||||
parts = key.split(sep=',') # assuming single line
|
||||
if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
|
||||
return None
|
||||
try:
|
||||
data = self.contacts.resolve(key)
|
||||
except Exception as e:
|
||||
self.logger.info(f'error resolving address/alias: {repr(e)}')
|
||||
return None
|
||||
if data:
|
||||
name = data.get('name')
|
||||
address = data.get('address')
|
||||
self.contacts[key] = ('openalias', name)
|
||||
# this will set self.spk
|
||||
self.parse(address)
|
||||
return data
|
||||
|
||||
def has_expired(self):
|
||||
if self.bip70:
|
||||
return self.bip70_data.has_expired()
|
||||
return False
|
||||
|
||||
@log_exceptions
|
||||
async def round_1(self, on_success):
|
||||
if self.openalias:
|
||||
data = await self.resolve_openalias()
|
||||
self.openalias_data = data
|
||||
if not self.openalias_data.get('validated'):
|
||||
self.warning = _(
|
||||
'WARNING: the alias "{}" could not be validated via an additional '
|
||||
'security check, DNSSEC, and thus may not be correct.').format(self.openalias)
|
||||
elif self.bip70:
|
||||
from . import paymentrequest
|
||||
data = await paymentrequest.get_payment_request(self.bip70)
|
||||
self.bip70_data = data
|
||||
elif self.lnurl:
|
||||
data = await request_lnurl(self.lnurl)
|
||||
self.lnurl_data = data
|
||||
else:
|
||||
return
|
||||
on_success(self)
|
||||
|
||||
@log_exceptions
|
||||
async def round_2(self, on_success, amount_sat:int=None, comment=None):
|
||||
from .invoices import Invoice
|
||||
if self.lnurl:
|
||||
if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat):
|
||||
self.error = f'Amount must be between {self.lnurl_data.min_sendable_sat} and {self.lnurl_data.max_sendable_sat} sat.'
|
||||
return
|
||||
if self.lnurl_data.comment_allowed == 0:
|
||||
comment = None
|
||||
params = {'amount': amount_sat * 1000 }
|
||||
if comment:
|
||||
params['comment'] = comment
|
||||
try:
|
||||
invoice_data = await callback_lnurl(
|
||||
self.lnurl_data.callback_url,
|
||||
params=params,
|
||||
)
|
||||
except LNURLError as e:
|
||||
self.error = f"LNURL request encountered error: {e}"
|
||||
return
|
||||
bolt11_invoice = invoice_data.get('pr')
|
||||
#
|
||||
invoice = Invoice.from_bech32(bolt11_invoice)
|
||||
if invoice.get_amount_sat() != amount_sat:
|
||||
raise Exception("lnurl returned invoice with wrong amount")
|
||||
# this will change what is returned by get_fields_for_GUI
|
||||
self.bolt11 = bolt11_invoice
|
||||
|
||||
on_success(self)
|
||||
|
||||
@log_exceptions
|
||||
async def round_3(self, tx, refund_address, *, on_success):
|
||||
if self.bip70:
|
||||
ack_status, ack_msg = await self.bip70.send_payment_and_receive_paymentack(tx.serialize(), refund_address)
|
||||
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
|
||||
on_success(self)
|
||||
|
||||
def get_invoice(self, wallet, amount_sat, message):
|
||||
# fixme: wallet not really needed, only height
|
||||
from .invoices import Invoice
|
||||
if self.is_lightning():
|
||||
invoice_str = self.bolt11
|
||||
if not invoice_str:
|
||||
return
|
||||
invoice = Invoice.from_bech32(invoice_str)
|
||||
if invoice.amount_msat is None:
|
||||
invoice.amount_msat = int(amount_sat * 1000)
|
||||
return invoice
|
||||
else:
|
||||
outputs = self.get_onchain_outputs(amount_sat)
|
||||
message = self.bip21.get('message') if self.bip21 else message
|
||||
bip70_data = self.bip70_data if self.bip70 else None
|
||||
return wallet.create_invoice(
|
||||
outputs=outputs,
|
||||
message=message,
|
||||
pr=bip70_data,
|
||||
URI=self.bip21)
|
||||
@@ -42,7 +42,8 @@ import copy
|
||||
|
||||
from . import ecc, bitcoin, constants, segwit_addr, bip32
|
||||
from .bip32 import BIP32Node
|
||||
from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend
|
||||
from .util import profiler, to_bytes, bfh, chunks, is_hex_str
|
||||
from .payment_identifier import parse_max_spend
|
||||
from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160,
|
||||
hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr,
|
||||
var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN,
|
||||
|
||||
@@ -1009,177 +1009,8 @@ def block_explorer_URL(config: 'SimpleConfig', kind: str, item: str) -> Optional
|
||||
url_parts = [explorer_url, kind_str, item]
|
||||
return ''.join(url_parts)
|
||||
|
||||
# URL decode
|
||||
#_ud = re.compile('%([0-9a-hA-H]{2})', re.MULTILINE)
|
||||
#urldecode = lambda x: _ud.sub(lambda m: chr(int(m.group(1), 16)), x)
|
||||
|
||||
|
||||
# note: when checking against these, use .lower() to support case-insensitivity
|
||||
BITCOIN_BIP21_URI_SCHEME = 'bitcoin'
|
||||
LIGHTNING_URI_SCHEME = 'lightning'
|
||||
|
||||
|
||||
class InvalidBitcoinURI(Exception): pass
|
||||
|
||||
|
||||
# TODO rename to parse_bip21_uri or similar
|
||||
def parse_URI(
|
||||
uri: str,
|
||||
on_pr: Callable[['PaymentRequest'], None] = None,
|
||||
*,
|
||||
loop: asyncio.AbstractEventLoop = None,
|
||||
) -> dict:
|
||||
"""Raises InvalidBitcoinURI on malformed URI."""
|
||||
from . import bitcoin
|
||||
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
||||
from .lnaddr import lndecode, LnDecodeException
|
||||
|
||||
if not isinstance(uri, str):
|
||||
raise InvalidBitcoinURI(f"expected string, not {repr(uri)}")
|
||||
|
||||
if ':' not in uri:
|
||||
if not bitcoin.is_address(uri):
|
||||
raise InvalidBitcoinURI("Not a bitcoin address")
|
||||
return {'address': uri}
|
||||
|
||||
u = urllib.parse.urlparse(uri)
|
||||
if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME:
|
||||
raise InvalidBitcoinURI("Not a bitcoin URI")
|
||||
address = u.path
|
||||
|
||||
# python for android fails to parse query
|
||||
if address.find('?') > 0:
|
||||
address, query = u.path.split('?')
|
||||
pq = urllib.parse.parse_qs(query)
|
||||
else:
|
||||
pq = urllib.parse.parse_qs(u.query)
|
||||
|
||||
for k, v in pq.items():
|
||||
if len(v) != 1:
|
||||
raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}')
|
||||
|
||||
out = {k: v[0] for k, v in pq.items()}
|
||||
if address:
|
||||
if not bitcoin.is_address(address):
|
||||
raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}")
|
||||
out['address'] = address
|
||||
if 'amount' in out:
|
||||
am = out['amount']
|
||||
try:
|
||||
m = re.match(r'([0-9.]+)X([0-9])', am)
|
||||
if m:
|
||||
k = int(m.group(2)) - 8
|
||||
amount = Decimal(m.group(1)) * pow(Decimal(10), k)
|
||||
else:
|
||||
amount = Decimal(am) * COIN
|
||||
if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
|
||||
raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC")
|
||||
out['amount'] = int(amount)
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e
|
||||
if 'message' in out:
|
||||
out['message'] = out['message']
|
||||
out['memo'] = out['message']
|
||||
if 'time' in out:
|
||||
try:
|
||||
out['time'] = int(out['time'])
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e
|
||||
if 'exp' in out:
|
||||
try:
|
||||
out['exp'] = int(out['exp'])
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e
|
||||
if 'sig' in out:
|
||||
try:
|
||||
out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex()
|
||||
except Exception as e:
|
||||
raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e
|
||||
if 'lightning' in out:
|
||||
try:
|
||||
lnaddr = lndecode(out['lightning'])
|
||||
except LnDecodeException as e:
|
||||
raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e
|
||||
amount_sat = out.get('amount')
|
||||
if amount_sat:
|
||||
# allow small leeway due to msat precision
|
||||
if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1:
|
||||
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount")
|
||||
address = out.get('address')
|
||||
ln_fallback_addr = lnaddr.get_fallback_address()
|
||||
if address and ln_fallback_addr:
|
||||
if ln_fallback_addr != address:
|
||||
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address")
|
||||
|
||||
r = out.get('r')
|
||||
sig = out.get('sig')
|
||||
name = out.get('name')
|
||||
if on_pr and (r or (name and sig)):
|
||||
@log_exceptions
|
||||
async def get_payment_request():
|
||||
from . import paymentrequest as pr
|
||||
if name and sig:
|
||||
s = pr.serialize_request(out).SerializeToString()
|
||||
request = pr.PaymentRequest(s)
|
||||
else:
|
||||
request = await pr.get_payment_request(r)
|
||||
if on_pr:
|
||||
on_pr(request)
|
||||
loop = loop or get_asyncio_loop()
|
||||
asyncio.run_coroutine_threadsafe(get_payment_request(), loop)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str],
|
||||
*, extra_query_params: Optional[dict] = None) -> str:
|
||||
from . import bitcoin
|
||||
if not bitcoin.is_address(addr):
|
||||
return ""
|
||||
if extra_query_params is None:
|
||||
extra_query_params = {}
|
||||
query = []
|
||||
if amount_sat:
|
||||
query.append('amount=%s'%format_satoshis_plain(amount_sat))
|
||||
if message:
|
||||
query.append('message=%s'%urllib.parse.quote(message))
|
||||
for k, v in extra_query_params.items():
|
||||
if not isinstance(k, str) or k != urllib.parse.quote(k):
|
||||
raise Exception(f"illegal key for URI: {repr(k)}")
|
||||
v = urllib.parse.quote(v)
|
||||
query.append(f"{k}={v}")
|
||||
p = urllib.parse.ParseResult(
|
||||
scheme=BITCOIN_BIP21_URI_SCHEME,
|
||||
netloc='',
|
||||
path=addr,
|
||||
params='',
|
||||
query='&'.join(query),
|
||||
fragment='',
|
||||
)
|
||||
return str(urllib.parse.urlunparse(p))
|
||||
|
||||
|
||||
def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
|
||||
data = data.strip() # whitespaces
|
||||
data = data.lower()
|
||||
if data.startswith(LIGHTNING_URI_SCHEME + ':ln'):
|
||||
cut_prefix = LIGHTNING_URI_SCHEME + ':'
|
||||
data = data[len(cut_prefix):]
|
||||
if data.startswith('ln'):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def is_uri(data: str) -> bool:
|
||||
data = data.lower()
|
||||
if (data.startswith(LIGHTNING_URI_SCHEME + ":") or
|
||||
data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FailedToParsePaymentIdentifier(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Python bug (http://bugs.python.org/issue1927) causes raw_input
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore
|
||||
format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates,
|
||||
WalletFileException, BitcoinException,
|
||||
InvalidPassword, format_time, timestamp_to_datetime, Satoshis,
|
||||
Fiat, bfh, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex, parse_max_spend)
|
||||
Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex)
|
||||
from .payment_identifier import create_bip21_uri, parse_max_spend
|
||||
from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE
|
||||
from .bitcoin import COIN, TYPE_ADDRESS
|
||||
from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold
|
||||
|
||||
+2
-1
@@ -308,7 +308,8 @@ class X509(object):
|
||||
raise CertificateError('Certificate has not entered its valid date range. (%s)' % self.get_common_name())
|
||||
if self.notAfter <= now:
|
||||
dt = datetime.utcfromtimestamp(time.mktime(self.notAfter))
|
||||
raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).')
|
||||
# for testnet
|
||||
#raise CertificateError(f'Certificate ({self.get_common_name()}) has expired (at {dt} UTC).')
|
||||
|
||||
def getFingerprint(self):
|
||||
return hashlib.sha1(self.bytes).digest()
|
||||
|
||||
+3
-2
@@ -93,6 +93,7 @@ sys._ELECTRUM_RUNNING_VIA_RUNELECTRUM = True # used by logging.py
|
||||
|
||||
from electrum.logging import get_logger, configure_logging # import logging submodule first
|
||||
from electrum import util
|
||||
from electrum.payment_identifier import PaymentIdentifier
|
||||
from electrum import constants
|
||||
from electrum import SimpleConfig
|
||||
from electrum.wallet_db import WalletDB
|
||||
@@ -364,9 +365,9 @@ def main():
|
||||
if not config_options.get('verbosity'):
|
||||
warnings.simplefilter('ignore', DeprecationWarning)
|
||||
|
||||
# check uri
|
||||
# check if we received a valid payment identifier
|
||||
uri = config_options.get('url')
|
||||
if uri and not util.is_uri(uri):
|
||||
if uri and not PaymentIdentifier(None, None, uri).is_valid():
|
||||
print_stderr('unknown command:', uri)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user