payment_identifier: define states, refactor round_1 into resolve stage
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user