Browse Source

payment_identifier: define states, refactor round_1 into resolve stage

master
Sander van Grieken 3 years ago
parent
commit
508d1038d3
  1. 44
      electrum/gui/qt/send_tab.py
  2. 138
      electrum/payment_identifier.py

44
electrum/gui/qt/send_tab.py

@ -33,7 +33,7 @@ if TYPE_CHECKING:
class SendTab(QWidget, MessageBoxMixin, Logger):
round_1_signal = pyqtSignal(object)
resolve_done_signal = pyqtSignal(object)
round_2_signal = pyqtSignal(object)
round_3_signal = pyqtSignal(object)
@ -183,7 +183,7 @@ 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.round_1_signal.connect(self.on_round_1)
self.resolve_done_signal.connect(self.on_resolve_done)
self.round_2_signal.connect(self.on_round_2)
self.round_3_signal.connect(self.on_round_3)
@ -194,16 +194,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.set_payment_identifier(text)
def set_payment_identifier(self, text):
pi = PaymentIdentifier(self.wallet, text)
if pi.error:
self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error)
self.payment_identifier = PaymentIdentifier(self.wallet, text)
if self.payment_identifier.error:
self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error)
return
if pi.is_multiline():
if self.payment_identifier.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)
self.handle_payment_identifier(can_use_network=True)
def spend_max(self):
if run_hook('abort_send', self):
@ -355,45 +355,43 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
w.setStyleSheet('')
w.setReadOnly(False)
def update_fields(self, pi):
recipient, amount, description, comment, validated = pi.get_fields_for_GUI()
def update_fields(self):
recipient, amount, description, comment, validated = self.payment_identifier.get_fields_for_GUI()
if recipient:
self.payto_e.setTextNoCheck(recipient)
elif pi.multiline_outputs:
self.payto_e.handle_multiline(pi.multiline_outputs)
elif self.payment_identifier.multiline_outputs:
self.payto_e.handle_multiline(self.payment_identifier.multiline_outputs)
if description:
self.message_e.setText(description)
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.payto_e, recipient or self.payment_identifier.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, pi, *, can_use_network: bool = True):
self.payment_identifier = pi
is_valid = pi.is_valid()
def handle_payment_identifier(self, *, can_use_network: bool = True):
is_valid = self.payment_identifier.is_valid()
self.save_button.setEnabled(is_valid)
self.send_button.setEnabled(is_valid)
if not is_valid:
return
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.update_fields()
if self.payment_identifier.need_resolve():
self.prepare_for_send_tab_network_lookup()
self.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit)
# 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)
def on_resolve_done(self, pi):
if self.payment_identifier.error:
self.show_error(self.payment_identifier.error)
self.do_clear()
return
self.update_fields(pi)
self.update_fields()
for btn in [self.send_button, self.clear_button, self.save_button]:
btn.setEnabled(True)

138
electrum/payment_identifier.py

@ -2,16 +2,18 @@ import asyncio
import urllib
import re
from decimal import Decimal, InvalidOperation
from enum import IntEnum
from typing import NamedTuple, Optional, Callable, Any, Sequence, List, TYPE_CHECKING
from urllib.parse import urlparse
from . import bitcoin
from .contacts import AliasNotFoundException
from .i18n import _
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 .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data, lightning_address_to_url
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script
from .lnaddr import lndecode, LnDecodeException, LnInvoiceException
from .lnutil import IncompatibleOrInsaneFeatures
@ -177,6 +179,19 @@ 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 PaymentIdentifierState(IntEnum):
EMPTY = 0 # Initial state.
INVALID = 1 # Unrecognized PI
AVAILABLE = 2 # PI contains a payable destination
# payable means there's enough addressing information to submit to one
# of the channels Electrum supports (on-chain, lightning)
NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step
LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11
BIP70_VIA = 5 # PI contains a valid payment request that should have the tx submitted through bip70 gw
ERROR = 50 # generic error
NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccesful
class PaymentIdentifier(Logger):
"""
Takes:
@ -187,11 +202,12 @@ class PaymentIdentifier(Logger):
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
* TODO: lightning address
* lightning address
"""
def __init__(self, wallet: 'Abstract_Wallet', text):
Logger.__init__(self)
self._state = PaymentIdentifierState.EMPTY
self.wallet = wallet
self.contacts = wallet.contacts if wallet is not None else None
self.config = wallet.config if wallet is not None else None
@ -205,8 +221,9 @@ class PaymentIdentifier(Logger):
self.bip21 = None
self.spk = None
#
self.openalias = None
self.emaillike = None
self.openalias_data = None
self.lnaddress_data = None
#
self.bip70 = None
self.bip70_data = None
@ -214,8 +231,16 @@ class PaymentIdentifier(Logger):
self.lnurl = None
self.lnurl_data = None
# parse without network
self.logger.debug(f'PI parsing...')
self.parse(text)
def set_state(self, state: 'PaymentIdentifierState'):
self.logger.debug(f'PI state -> {state}')
self._state = state
def need_resolve(self):
return self._state == PaymentIdentifierState.NEED_RESOLVE
def is_valid(self):
return bool(self._type)
@ -228,9 +253,6 @@ class PaymentIdentifier(Logger):
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
@ -250,30 +272,93 @@ class PaymentIdentifier(Logger):
self._type = 'lnurl'
try:
self.lnurl = decode_lnurl(invoice_or_lnurl)
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
except Exception as e:
self.error = "Error parsing Lightning invoice" + f":\n{e}"
self.set_state(PaymentIdentifierState.INVALID)
return
else:
self._type = 'bolt11'
self.bolt11 = invoice_or_lnurl
self.set_state(PaymentIdentifierState.AVAILABLE)
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}"
self.set_state(PaymentIdentifierState.INVALID)
return
self._type = 'bip21'
self.bip21 = out
self.bip70 = out.get('r')
if self.bip70:
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
else:
self.set_state(PaymentIdentifierState.AVAILABLE)
elif scriptpubkey := self.parse_output(text):
self._type = 'spk'
self.spk = scriptpubkey
self.set_state(PaymentIdentifierState.AVAILABLE)
elif re.match(RE_EMAIL, text):
self._type = 'alias'
self.openalias = text
self.emaillike = text
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
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}")
self.set_state(PaymentIdentifierState.INVALID)
def resolve(self, *, on_finished: 'Callable'):
assert self._state == PaymentIdentifierState.NEED_RESOLVE
coro = self.do_resolve(on_finished=on_finished)
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
@log_exceptions
async def do_resolve(self, *, on_finished=None):
try:
if self.emaillike:
data = await self.resolve_openalias()
if data:
self.openalias_data = data # needed?
self.logger.debug(f'OA: {data!r}')
name = data.get('name')
address = data.get('address')
self.contacts[self.emaillike] = ('openalias', name)
if not 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.emaillike)
# this will set self.spk and update state
self.parse(address)
else:
lnurl = lightning_address_to_url(self.emaillike)
try:
data = await request_lnurl(lnurl)
self.lnurl = lnurl
self.lnurl_data = data
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
except LNURLError as e:
self.error = str(e)
self.set_state(PaymentIdentifierState.NOT_FOUND)
elif self.bip70:
from . import paymentrequest
data = await paymentrequest.get_payment_request(self.bip70)
self.bip70_data = data
self.set_state(PaymentIdentifierState.BIP70_VIA)
elif self.lnurl:
data = await request_lnurl(self.lnurl)
self.lnurl_data = data
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
else:
self.set_state(PaymentIdentifierState.ERROR)
return
except Exception as e:
self.error = f'{e!r}'
self.logger.error(self.error)
self.set_state(PaymentIdentifierState.ERROR)
finally:
if on_finished:
on_finished(self)
def get_onchain_outputs(self, amount):
if self.bip70:
@ -377,14 +462,14 @@ class PaymentIdentifier(Logger):
validated = None
comment = "no comment"
if self.openalias and self.openalias_data:
if self.emaillike and self.openalias_data:
address = self.openalias_data.get('address')
name = self.openalias_data.get('name')
recipient = self.openalias + ' <' + address + '>'
recipient = self.emaillike + ' <' + 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)
'security check, DNSSEC, and thus may not be correct.').format(self.emaillike)
#self.payto_e.set_openalias(key=pi.openalias, data=oa_data)
#self.window.contact_list.update()
@ -464,7 +549,7 @@ class PaymentIdentifier(Logger):
return pubkey, amount, description
async def resolve_openalias(self) -> Optional[dict]:
key = self.openalias
key = self.emaillike
if not (('.' in key) and ('<' not in key) and (' ' not in key)):
return None
parts = key.split(sep=',') # assuming single line
@ -472,42 +557,19 @@ class PaymentIdentifier(Logger):
return None
try:
data = self.contacts.resolve(key)
return data
except AliasNotFoundException as e:
self.logger.info(f'OpenAlias not found: {repr(e)}')
return None
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: str = None):
from .invoices import Invoice

Loading…
Cancel
Save