Browse Source

payment_identfier: refactor qml and tests

master
Sander van Grieken 3 years ago
parent
commit
fc141c0182
  1. 195
      electrum/gui/qml/qeinvoice.py
  2. 20
      electrum/payment_identifier.py
  3. 24
      electrum/tests/test_util.py

195
electrum/gui/qml/qeinvoice.py

@ -5,21 +5,19 @@ from urllib.parse import urlparse
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS, QTimer
from electrum import bitcoin
from electrum import lnutil
from electrum.i18n import _
from electrum.invoices import Invoice
from electrum.invoices import (PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT,
from electrum.logging import get_logger
from electrum.invoices import (Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT,
PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER)
from electrum.lnaddr import LnInvoiceException
from electrum.logging import get_logger
from electrum.transaction import PartialTxOutput
from electrum.util import (parse_URI, InvalidBitcoinURI, InvoiceError,
maybe_extract_lightning_payment_identifier, get_asyncio_loop)
from electrum.lnutil import format_short_channel_id
from electrum.util import InvoiceError, get_asyncio_loop
from electrum.lnutil import format_short_channel_id, IncompatibleOrInsaneFeatures
from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl
from electrum.bitcoin import COIN
from electrum.paymentrequest import PaymentRequest
from electrum.payment_identifier import (parse_bip21_URI, InvalidBitcoinURI, maybe_extract_lightning_payment_identifier,
PaymentIdentifier, PaymentIdentifierState)
from .qetypes import QEAmount
from .qewallet import QEWallet
@ -249,7 +247,8 @@ class QEInvoice(QObject, QtEventListener):
}
def name_for_node_id(self, node_id):
return self._wallet.wallet.lnworker.get_node_alias(node_id) or node_id.hex()
lnworker = self._wallet.wallet.lnworker
return (lnworker.get_node_alias(node_id) if lnworker else None) or node_id.hex()
def set_effective_invoice(self, invoice: Invoice):
self._effectiveInvoice = invoice
@ -406,13 +405,11 @@ class QEInvoiceParser(QEInvoice):
lnurlRetrieved = pyqtSignal()
lnurlError = pyqtSignal([str,str], arguments=['code', 'message'])
_bip70PrResolvedSignal = pyqtSignal([PaymentRequest], arguments=['pr'])
def __init__(self, parent=None):
super().__init__(parent)
self._recipient = ''
self._bip70PrResolvedSignal.connect(self._bip70_payment_request_resolved)
self._pi = None
self.clear()
@ -493,79 +490,54 @@ class QEInvoiceParser(QEInvoice):
self.setInvoiceType(QEInvoice.Type.Invalid)
return
maybe_lightning_invoice = recipient
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
if not self._pi.is_valid() or self._pi.type not in ['spk', 'bip21', 'bip70', 'bolt11', 'lnurl']:
self.validationError.emit('unknown', _('Unknown invoice'))
return
try:
bip21 = parse_URI(recipient, lambda pr: self._bip70PrResolvedSignal.emit(pr))
if bip21:
if 'r' in bip21 or ('name' in bip21 and 'sig' in bip21): # TODO set flag in util?
# let callback handle state
return
if ':' not in recipient:
# address only
# create bare invoice
outputs = [PartialTxOutput.from_address_and_value(bip21['address'], 0)]
invoice = self.create_onchain_invoice(outputs, None, None, None)
self._logger.debug(repr(invoice))
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
return
else:
# fallback lightning invoice?
if 'lightning' in bip21:
maybe_lightning_invoice = bip21['lightning']
except InvalidBitcoinURI as e:
bip21 = None
lninvoice = None
maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice)
if maybe_lightning_invoice is not None:
if maybe_lightning_invoice.startswith('lnurl'):
self.resolve_lnurl(maybe_lightning_invoice)
return
try:
lninvoice = Invoice.from_bech32(maybe_lightning_invoice)
except InvoiceError as e:
e2 = e.__cause__
if isinstance(e2, LnInvoiceException):
self.validationError.emit('unknown', _("Error parsing Lightning invoice") + f":\n{e2}")
self.clear()
return
if isinstance(e2, lnutil.IncompatibleOrInsaneFeatures):
self.validationError.emit('unknown', _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e2!r}")
self.clear()
return
self._logger.exception(repr(e))
self._update_from_payment_identifier()
if not lninvoice and not bip21:
self.validationError.emit('unknown',_('Unknown invoice'))
self.clear()
def _update_from_payment_identifier(self):
if self._pi.need_resolve():
self.resolve_pi()
return
if lninvoice:
if not self._wallet.wallet.has_lightning():
if not bip21:
if lninvoice.get_address():
self.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit()
else:
self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.'))
else:
self._logger.debug('flow with LN but not LN enabled AND having bip21 uri')
self._validateRecipient_bip21_onchain(bip21)
else:
if not self._wallet.wallet.lnworker.channels:
if bip21 and 'address' in bip21:
self._logger.debug('flow where invoice has both LN and onchain, we have LN enabled but no channels')
self._validateRecipient_bip21_onchain(bip21)
else:
self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels'))
else:
if self._pi.type == 'lnurl':
self.on_lnurl(self._pi.lnurl_data)
return
if self._pi.type == 'bip70':
self._bip70_payment_request_resolved(self._pi.bip70_data)
return
if self._pi.is_available():
if self._pi.type == 'spk':
outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)]
invoice = self.create_onchain_invoice(outputs, None, None, None)
self._logger.debug(repr(invoice))
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
return
elif self._pi.type == 'bolt11':
lninvoice = Invoice.from_bech32(self._pi.bolt11)
if not self._wallet.wallet.has_lightning() and not lninvoice.get_address():
self.validationError.emit('no_lightning',
_('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.'))
return
if self._wallet.wallet.lnworker and not self._wallet.wallet.lnworker.channels:
self.validationWarning.emit('no_channels',
_('Detected valid Lightning invoice, but there are no open channels'))
self.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit()
elif self._pi.type == 'bip21':
if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11:
lninvoice = Invoice.from_bech32(self._pi.bolt11)
self.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit()
else:
self._logger.debug('flow without LN but having bip21 uri')
self._validateRecipient_bip21_onchain(bip21)
else:
self._validateRecipient_bip21_onchain(self._pi.bip21)
def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None:
if 'amount' not in bip21:
@ -580,20 +552,15 @@ class QEInvoiceParser(QEInvoice):
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
def resolve_lnurl(self, lnurl):
self._logger.debug('resolve_lnurl')
url = decode_lnurl(lnurl)
self._logger.debug(f'{repr(url)}')
def resolve_task():
try:
coro = request_lnurl(url)
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
self.on_lnurl(fut.result())
except Exception as e:
self.validationError.emit('lnurl', repr(e))
def resolve_pi(self):
assert self._pi.need_resolve()
def on_finished(pi):
if pi.is_error():
pass
else:
self._update_from_payment_identifier()
threading.Thread(target=resolve_task, daemon=True).start()
self._pi.resolve(on_finished=on_finished)
def on_lnurl(self, lnurldata):
self._logger.debug('on_lnurl')
@ -610,49 +577,39 @@ class QEInvoiceParser(QEInvoice):
self.setValidLNURLPayRequest()
self.lnurlRetrieved.emit()
@pyqtSlot('quint64')
@pyqtSlot('quint64', str)
def lnurlGetInvoice(self, amount, comment=None):
@pyqtSlot()
@pyqtSlot(str)
def lnurlGetInvoice(self, comment=None):
assert self._lnurlData
assert self._pi.need_finalize()
self._logger.debug(f'{repr(self._lnurlData)}')
amount = self.amountOverride.satsInt
if self.lnurlData['min_sendable_sat'] != 0:
try:
assert amount >= self.lnurlData['min_sendable_sat']
assert amount <= self.lnurlData['max_sendable_sat']
except Exception:
self.lnurlError.emit('amount', _('Amount out of bounds'))
return
if self._lnurlData['comment_allowed'] == 0:
comment = None
self._logger.debug(f'fetching callback url {self._lnurlData["callback_url"]}')
def fetch_invoice_task():
try:
params = { 'amount': amount * 1000 }
if comment:
params['comment'] = comment
coro = callback_lnurl(self._lnurlData['callback_url'], params)
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
self.on_lnurl_invoice(amount, fut.result())
except Exception as e:
self._logger.error(repr(e))
self.lnurlError.emit('lnurl', str(e))
threading.Thread(target=fetch_invoice_task, daemon=True).start()
def on_finished(pi):
if pi.is_error():
if pi.is_state(PaymentIdentifierState.INVALID_AMOUNT):
self.lnurlError.emit('amount', pi.get_error())
else:
self.lnurlError.emit('lnurl', pi.get_error())
else:
self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11)
self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
def on_lnurl_invoice(self, orig_amount, invoice):
self._logger.debug('on_lnurl_invoice')
self._logger.debug(f'{repr(invoice)}')
# assure no shenanigans with the bolt11 invoice we get back
lninvoice = Invoice.from_bech32(invoice['pr'])
if orig_amount * 1000 != lninvoice.amount_msat:
lninvoice = Invoice.from_bech32(invoice)
if orig_amount * 1000 != lninvoice.amount_msat: # TODO msat precision can cause trouble here
raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount')
self.recipient = invoice['pr']
self.recipient = invoice
@pyqtSlot()
def saveInvoice(self):

20
electrum/payment_identifier.py

@ -20,6 +20,7 @@ from .lnutil import IncompatibleOrInsaneFeatures
if TYPE_CHECKING:
from .wallet import Abstract_Wallet
from .transaction import Transaction
def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
@ -32,10 +33,6 @@ def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
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'
@ -183,6 +180,7 @@ class PaymentIdentifierState(IntEnum):
ERROR = 50 # generic error
NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccesful
MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX
INVALID_AMOUNT = 53 # Specified amount not accepted
class PaymentIdentifier(Logger):
@ -251,6 +249,9 @@ class PaymentIdentifier(Logger):
def is_valid(self):
return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY]
def is_available(self):
return self._state in [PaymentIdentifierState.AVAILABLE]
def is_lightning(self):
return self.lnurl or self.bolt11
@ -445,22 +446,23 @@ class PaymentIdentifier(Logger):
if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat):
self.error = _('Amount must be between %d and %d sat.') \
% (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat)
self.set_state(PaymentIdentifierState.INVALID_AMOUNT)
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,
)
invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params)
except LNURLError as e:
self.error = f"LNURL request encountered error: {e}"
self.set_state(PaymentIdentifierState.ERROR)
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")

24
electrum/tests/test_util.py

@ -2,12 +2,10 @@ from datetime import datetime
from decimal import Decimal
from electrum import util
from electrum.util import (format_satoshis, format_fee_satoshis, parse_URI,
is_hash256_str, chunks, is_ip_address, list_enabled_bits,
format_satoshis_plain, is_private_netaddress, is_hex_str,
is_integer, is_non_negative_integer, is_int_or_float,
is_non_negative_int_or_float, is_subpath, InvalidBitcoinURI)
from electrum.util import (format_satoshis, format_fee_satoshis, is_hash256_str, chunks, is_ip_address,
list_enabled_bits, format_satoshis_plain, is_private_netaddress, is_hex_str,
is_integer, is_non_negative_integer, is_int_or_float, is_non_negative_int_or_float)
from electrum.payment_identifier import parse_bip21_URI, InvalidBitcoinURI
from . import ElectrumTestCase, as_testnet
@ -102,7 +100,7 @@ class TestUtil(ElectrumTestCase):
self.assertEqual("0.01234", format_satoshis_plain(1234, decimal_point=5))
def _do_test_parse_URI(self, uri, expected):
result = parse_URI(uri)
result = parse_bip21_URI(uri)
self.assertEqual(expected, result)
def test_parse_URI_address(self):
@ -143,13 +141,13 @@ class TestUtil(ElectrumTestCase):
{'r': 'http://domain.tld/page?h=2a8628fc2fbe'})
def test_parse_URI_invalid_address(self):
self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:invalidaddress')
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:invalidaddress')
def test_parse_URI_invalid(self):
self.assertRaises(InvalidBitcoinURI, parse_URI, 'notbitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma')
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'notbitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma')
def test_parse_URI_parameter_pollution(self):
self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&amount=30.0')
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?amount=0.0003&label=test&amount=30.0')
@as_testnet
def test_parse_URI_lightning_consistency(self):
@ -174,11 +172,11 @@ class TestUtil(ElectrumTestCase):
'memo': 'test266',
'message': 'test266'})
# bip21 uri that includes "lightning" key. LN part has fallback address BUT it mismatches the top-level address
self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qvu0c9xme0ul3gzx4nzqdgxsu25acuk9wvsj2j2?amount=0.0007&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql')
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qvu0c9xme0ul3gzx4nzqdgxsu25acuk9wvsj2j2?amount=0.0007&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql')
# bip21 uri that includes "lightning" key. top-level amount mismatches LN amount
self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql')
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdhkk8a597sn865rhap4h4jenjefdk7ssp5d9zjr96ezp89gsyenfse5f4jn9ls29p0awvp0zxlt6tpzn2m3j5qdqvw3jhxapjxcmqcqzynxq8zals8sq9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqfppqu5ua3szskclyd48wlfdwfd32j65phxy9vu8dmmk3u20u0e0yqw484xzn4hc3cux6kk2wenhw7zy0mseu9ntpk9l4fws2d46svzszrc6mqy535740ks9j22w67fw0x4dt8w2hhzspcqakql')
# bip21 uri that includes "lightning" key with garbage unparseable value
self.assertRaises(InvalidBitcoinURI, parse_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd')
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd')
def test_is_hash256_str(self):
self.assertTrue(is_hash256_str('09a4c03e3bdf83bbe3955f907ee52da4fc12f4813d459bc75228b64ad08617c7'))

Loading…
Cancel
Save