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): class SendTab(QWidget, MessageBoxMixin, Logger):
round_1_signal = pyqtSignal(object) resolve_done_signal = pyqtSignal(object)
round_2_signal = pyqtSignal(object) round_2_signal = pyqtSignal(object)
round_3_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 self.invoice_list.update() # after parented and put into a layout, can update without flickering
run_hook('create_send_tab', grid) 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_2_signal.connect(self.on_round_2)
self.round_3_signal.connect(self.on_round_3) self.round_3_signal.connect(self.on_round_3)
@ -194,16 +194,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.set_payment_identifier(text) self.set_payment_identifier(text)
def set_payment_identifier(self, text): def set_payment_identifier(self, text):
pi = PaymentIdentifier(self.wallet, text) self.payment_identifier = PaymentIdentifier(self.wallet, text)
if pi.error: if self.payment_identifier.error:
self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + pi.error) self.show_error(_('Clipboard text is not a valid payment identifier') + '\n' + self.payment_identifier.error)
return return
if pi.is_multiline(): if self.payment_identifier.is_multiline():
self.payto_e.set_paytomany(True) self.payto_e.set_paytomany(True)
self.payto_e.text_edit.setText(text) self.payto_e.text_edit.setText(text)
else: else:
self.payto_e.setTextNoCheck(text) 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): def spend_max(self):
if run_hook('abort_send', self): if run_hook('abort_send', self):
@ -355,45 +355,43 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
w.setStyleSheet('') w.setStyleSheet('')
w.setReadOnly(False) w.setReadOnly(False)
def update_fields(self, pi): def update_fields(self):
recipient, amount, description, comment, validated = pi.get_fields_for_GUI() recipient, amount, description, comment, validated = self.payment_identifier.get_fields_for_GUI()
if recipient: if recipient:
self.payto_e.setTextNoCheck(recipient) self.payto_e.setTextNoCheck(recipient)
elif pi.multiline_outputs: elif self.payment_identifier.multiline_outputs:
self.payto_e.handle_multiline(pi.multiline_outputs) self.payto_e.handle_multiline(self.payment_identifier.multiline_outputs)
if description: if description:
self.message_e.setText(description) self.message_e.setText(description)
if amount: if amount:
self.amount_e.setAmount(amount) self.amount_e.setAmount(amount)
for w in [self.comment_e, self.comment_label]: for w in [self.comment_e, self.comment_label]:
w.setVisible(not bool(comment)) 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.message_e, description, validated)
self.set_field_style(self.amount_e, amount, validated) self.set_field_style(self.amount_e, amount, validated)
self.set_field_style(self.fiat_send_e, amount, validated) self.set_field_style(self.fiat_send_e, amount, validated)
def handle_payment_identifier(self, pi, *, can_use_network: bool = True): def handle_payment_identifier(self, *, can_use_network: bool = True):
self.payment_identifier = pi is_valid = self.payment_identifier.is_valid()
is_valid = pi.is_valid()
self.save_button.setEnabled(is_valid) self.save_button.setEnabled(is_valid)
self.send_button.setEnabled(is_valid) self.send_button.setEnabled(is_valid)
if not is_valid: if not is_valid:
return return
self.update_fields(pi) self.update_fields()
if can_use_network and pi.needs_round_1(): if self.payment_identifier.need_resolve():
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() self.prepare_for_send_tab_network_lookup()
self.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit)
# update fiat amount # update fiat amount
self.amount_e.textEdited.emit("") self.amount_e.textEdited.emit("")
self.window.show_send_tab() self.window.show_send_tab()
def on_round_1(self, pi): def on_resolve_done(self, pi):
if pi.error: if self.payment_identifier.error:
self.show_error(pi.error) self.show_error(self.payment_identifier.error)
self.do_clear() self.do_clear()
return return
self.update_fields(pi) self.update_fields()
for btn in [self.send_button, self.clear_button, self.save_button]: for btn in [self.send_button, self.clear_button, self.save_button]:
btn.setEnabled(True) btn.setEnabled(True)

138
electrum/payment_identifier.py

@ -2,16 +2,18 @@ import asyncio
import urllib import urllib
import re import re
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from enum import IntEnum
from typing import NamedTuple, Optional, Callable, Any, Sequence, List, TYPE_CHECKING from typing import NamedTuple, Optional, Callable, Any, Sequence, List, TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
from . import bitcoin from . import bitcoin
from .contacts import AliasNotFoundException
from .i18n import _ from .i18n import _
from .logging import Logger from .logging import Logger
from .util import parse_max_spend, format_satoshis_plain from .util import parse_max_spend, format_satoshis_plain
from .util import get_asyncio_loop, log_exceptions from .util import get_asyncio_loop, log_exceptions
from .transaction import PartialTxOutput 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 .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script
from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnaddr import lndecode, LnDecodeException, LnInvoiceException
from .lnutil import IncompatibleOrInsaneFeatures 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' 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): class PaymentIdentifier(Logger):
""" """
Takes: Takes:
@ -187,11 +202,12 @@ class PaymentIdentifier(Logger):
* lightning-URI (containing bolt11 or lnurl) * lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice * bolt11 invoice
* lnurl * lnurl
* TODO: lightning address * lightning address
""" """
def __init__(self, wallet: 'Abstract_Wallet', text): def __init__(self, wallet: 'Abstract_Wallet', text):
Logger.__init__(self) Logger.__init__(self)
self._state = PaymentIdentifierState.EMPTY
self.wallet = wallet self.wallet = wallet
self.contacts = wallet.contacts if wallet is not None else None self.contacts = wallet.contacts if wallet is not None else None
self.config = wallet.config 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.bip21 = None
self.spk = None self.spk = None
# #
self.openalias = None self.emaillike = None
self.openalias_data = None self.openalias_data = None
self.lnaddress_data = None
# #
self.bip70 = None self.bip70 = None
self.bip70_data = None self.bip70_data = None
@ -214,8 +231,16 @@ class PaymentIdentifier(Logger):
self.lnurl = None self.lnurl = None
self.lnurl_data = None self.lnurl_data = None
# parse without network # parse without network
self.logger.debug(f'PI parsing...')
self.parse(text) 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): def is_valid(self):
return bool(self._type) return bool(self._type)
@ -228,9 +253,6 @@ class PaymentIdentifier(Logger):
def get_error(self) -> str: def get_error(self) -> str:
return self.error return self.error
def needs_round_1(self):
return self.bip70 or self.openalias or self.lnurl
def needs_round_2(self): def needs_round_2(self):
return self.lnurl and self.lnurl_data return self.lnurl and self.lnurl_data
@ -250,30 +272,93 @@ class PaymentIdentifier(Logger):
self._type = 'lnurl' self._type = 'lnurl'
try: try:
self.lnurl = decode_lnurl(invoice_or_lnurl) self.lnurl = decode_lnurl(invoice_or_lnurl)
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
except Exception as e: except Exception as e:
self.error = "Error parsing Lightning invoice" + f":\n{e}" self.error = "Error parsing Lightning invoice" + f":\n{e}"
self.set_state(PaymentIdentifierState.INVALID)
return return
else: else:
self._type = 'bolt11' self._type = 'bolt11'
self.bolt11 = invoice_or_lnurl self.bolt11 = invoice_or_lnurl
self.set_state(PaymentIdentifierState.AVAILABLE)
elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
try: try:
out = parse_bip21_URI(text) out = parse_bip21_URI(text)
except InvalidBitcoinURI as e: except InvalidBitcoinURI as e:
self.error = _("Error parsing URI") + f":\n{e}" self.error = _("Error parsing URI") + f":\n{e}"
self.set_state(PaymentIdentifierState.INVALID)
return return
self._type = 'bip21' self._type = 'bip21'
self.bip21 = out self.bip21 = out
self.bip70 = out.get('r') 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): elif scriptpubkey := self.parse_output(text):
self._type = 'spk' self._type = 'spk'
self.spk = scriptpubkey self.spk = scriptpubkey
self.set_state(PaymentIdentifierState.AVAILABLE)
elif re.match(RE_EMAIL, text): elif re.match(RE_EMAIL, text):
self._type = 'alias' self._type = 'alias'
self.openalias = text self.emaillike = text
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
elif self.error is None: elif self.error is None:
truncated_text = f"{text[:100]}..." if len(text) > 100 else text truncated_text = f"{text[:100]}..." if len(text) > 100 else text
self.error = FailedToParsePaymentIdentifier(f"Unknown payment identifier:\n{truncated_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): def get_onchain_outputs(self, amount):
if self.bip70: if self.bip70:
@ -377,14 +462,14 @@ class PaymentIdentifier(Logger):
validated = None validated = None
comment = "no comment" comment = "no comment"
if self.openalias and self.openalias_data: if self.emaillike and self.openalias_data:
address = self.openalias_data.get('address') address = self.openalias_data.get('address')
name = self.openalias_data.get('name') name = self.openalias_data.get('name')
recipient = self.openalias + ' <' + address + '>' recipient = self.emaillike + ' <' + address + '>'
validated = self.openalias_data.get('validated') validated = self.openalias_data.get('validated')
if not validated: if not validated:
self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' 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.payto_e.set_openalias(key=pi.openalias, data=oa_data)
#self.window.contact_list.update() #self.window.contact_list.update()
@ -464,7 +549,7 @@ class PaymentIdentifier(Logger):
return pubkey, amount, description return pubkey, amount, description
async def resolve_openalias(self) -> Optional[dict]: 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)): if not (('.' in key) and ('<' not in key) and (' ' not in key)):
return None return None
parts = key.split(sep=',') # assuming single line parts = key.split(sep=',') # assuming single line
@ -472,42 +557,19 @@ class PaymentIdentifier(Logger):
return None return None
try: try:
data = self.contacts.resolve(key) 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: except Exception as e:
self.logger.info(f'error resolving address/alias: {repr(e)}') self.logger.info(f'error resolving address/alias: {repr(e)}')
return None 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): def has_expired(self):
if self.bip70: if self.bip70:
return self.bip70_data.has_expired() return self.bip70_data.has_expired()
return False 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 @log_exceptions
async def round_2(self, on_success, amount_sat: int = None, comment: str = None): async def round_2(self, on_success, amount_sat: int = None, comment: str = None):
from .invoices import Invoice from .invoices import Invoice

Loading…
Cancel
Save