Browse Source

Merge pull request #8462 from spesmilo/payment_identifiers2

Payment identifiers
master
ThomasV 2 years ago committed by GitHub
parent
commit
5cf320b2c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 127
      electrum/bip21.py
  2. 7
      electrum/blockchain.py
  3. 18
      electrum/contacts.py
  4. BIN
      electrum/gui/icons/spinner.gif
  5. 5
      electrum/gui/kivy/main_window.py
  6. 7
      electrum/gui/kivy/uix/screens.py
  7. 2
      electrum/gui/qml/qeapp.py
  8. 204
      electrum/gui/qml/qeinvoice.py
  9. 5
      electrum/gui/qt/__init__.py
  10. 6
      electrum/gui/qt/amountedit.py
  11. 26
      electrum/gui/qt/main_window.py
  12. 474
      electrum/gui/qt/paytoedit.py
  13. 604
      electrum/gui/qt/send_tab.py
  14. 16
      electrum/gui/qt/util.py
  15. 2
      electrum/invoices.py
  16. 6
      electrum/network.py
  17. 659
      electrum/payment_identifier.py
  18. 24
      electrum/tests/test_util.py
  19. 169
      electrum/util.py
  20. 4
      electrum/wallet.py
  21. 13
      run_electrum

127
electrum/bip21.py

@ -0,0 +1,127 @@
import urllib
import re
from decimal import Decimal
from typing import Optional
from . import bitcoin
from .util import format_satoshis_plain
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from .lnaddr import lndecode, LnDecodeException
# 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))

7
electrum/blockchain.py

@ -23,16 +23,17 @@
import os
import threading
import time
from typing import Optional, Dict, Mapping, Sequence
from typing import Optional, Dict, Mapping, Sequence, TYPE_CHECKING
from . import util
from .bitcoin import hash_encode, int_to_hex, rev_hex
from .crypto import sha256d
from . import constants
from .util import bfh, with_lock
from .simple_config import SimpleConfig
from .logging import get_logger, Logger
if TYPE_CHECKING:
from .simple_config import SimpleConfig
_logger = get_logger(__name__)
@ -181,7 +182,7 @@ class Blockchain(Logger):
Manages blockchain headers and their verification
"""
def __init__(self, config: SimpleConfig, forkpoint: int, parent: Optional['Blockchain'],
def __init__(self, config: 'SimpleConfig', forkpoint: int, parent: Optional['Blockchain'],
forkpoint_hash: str, prev_hash: Optional[str]):
assert isinstance(forkpoint_hash, str) and len(forkpoint_hash) == 64, forkpoint_hash
assert (prev_hash is None) or (isinstance(prev_hash, str) and len(prev_hash) == 64), prev_hash

18
electrum/contacts.py

@ -33,6 +33,11 @@ from .util import read_json_file, write_json_file, to_string
from .logging import Logger
from .util import trigger_callback
class AliasNotFoundException(Exception):
pass
class Contacts(dict, Logger):
def __init__(self, db):
@ -94,7 +99,18 @@ class Contacts(dict, Logger):
'type': 'openalias',
'validated': validated
}
raise Exception("Invalid Bitcoin address or alias", k)
raise AliasNotFoundException("Invalid Bitcoin address or alias", k)
def by_name(self, name):
for k in self.keys():
_type, addr = self[k]
if addr.casefold() == name.casefold():
return {
'name': addr,
'type': _type,
'address': k
}
return None
def fetch_openalias(self, config):
self.alias_info = None

BIN
electrum/gui/icons/spinner.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

5
electrum/gui/kivy/main_window.py

@ -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.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog
from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog

7
electrum/gui/kivy/uix/screens.py

@ -18,8 +18,9 @@ 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.bip21 import BITCOIN_BIP21_URI_SCHEME, parse_bip21_URI, InvalidBitcoinURI
from electrum.payment_identifier import maybe_extract_lightning_payment_identifier
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 +209,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

2
electrum/gui/qml/qeapp.py

@ -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.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME
from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue
from electrum.network import Network

204
electrum/gui/qml/qeinvoice.py

@ -5,22 +5,20 @@ 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.transaction import PartialTxOutput, TxOutput
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 (maybe_extract_lightning_payment_identifier,
PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType)
from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI
from .qetypes import QEAmount
from .qewallet import QEWallet
from .util import status_update_timer_interval, QtEventListener, event_listener
@ -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,61 @@ 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 [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNURLP]:
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)
if self._pi.type == PaymentIdentifierType.SPK:
txo = TxOutput(scriptpubkey=self._pi.spk, value=0)
if not txo.address:
self.validationError.emit('unknown', _('Unknown 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))
if not lninvoice and not bip21:
self.validationError.emit('unknown',_('Unknown invoice'))
self.clear()
self._update_from_payment_identifier()
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 == PaymentIdentifierType.LNURLP:
self.on_lnurl(self._pi.lnurl_data)
return
if self._pi.type == PaymentIdentifierType.BIP70:
self._bip70_payment_request_resolved(self._pi.bip70_data)
return
if self._pi.is_available():
if self._pi.type == PaymentIdentifierType.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 == PaymentIdentifierType.BOLT11:
lninvoice = 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 == PaymentIdentifierType.BIP21:
if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11:
lninvoice = 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 +559,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 +584,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.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):

5
electrum/gui/qt/__init__.py

@ -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,8 @@ class ElectrumGui(BaseElectrumGui, Logger):
window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)
window.activateWindow()
if uri:
window.handle_payment_identifier(uri)
window.show_send_tab()
window.send_tab.set_payment_identifier(uri)
return window
def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:

6
electrum/gui/qt/amountedit.py

@ -13,7 +13,6 @@ from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_nam
FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT)
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
_NOT_GIVEN = object() # sentinel value
@ -22,9 +21,11 @@ class FreezableLineEdit(QLineEdit):
def setFrozen(self, b):
self.setReadOnly(b)
self.setFrame(not b)
self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
self.frozen.emit()
def isFrozen(self):
return self.isReadOnly()
class SizedFreezableLineEdit(FreezableLineEdit):
@ -153,6 +154,7 @@ class BTCAmountEdit(AmountEdit):
else:
text = self._get_text_from_amount(amount_sat)
self.setText(text)
self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?)
self.repaint() # macOS hack for #6269

26
electrum/gui/qt/main_window.py

@ -26,7 +26,6 @@ import sys
import time
import threading
import os
import traceback
import json
import weakref
import csv
@ -55,12 +54,11 @@ from electrum import (keystore, ecc, constants, util, bitcoin, commands,
from electrum.bitcoin import COIN, is_address
from electrum.plugin import run_hook, BasePlugin
from electrum.i18n import _
from electrum.util import (format_time, get_asyncio_loop,
UserCancelled, profiler,
bfh, InvalidPassword,
UserFacingException, FailedToParsePaymentIdentifier,
get_new_wallet_name, send_exception_to_crash_reporter,
AddTransactionException, BITCOIN_BIP21_URI_SCHEME, os_chmod)
from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword,
UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter,
AddTransactionException, os_chmod)
from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME
from electrum.payment_identifier import PaymentIdentifier
from electrum.invoices import PR_PAID, Invoice
from electrum.transaction import (Transaction, PartialTxInput,
PartialTransaction, PartialTxOutput)
@ -849,7 +847,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.update_status()
# resolve aliases
# FIXME this might do blocking network calls that has a timeout of several seconds
self.send_tab.payto_e.on_timer_check_text()
# self.send_tab.payto_e.on_timer_check_text()
self.notify_transactions()
def format_amount(
@ -1328,11 +1326,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return None
return clayout.selected_index()
def handle_payment_identifier(self, *args, **kwargs):
try:
self.send_tab.handle_payment_identifier(*args, **kwargs)
except FailedToParsePaymentIdentifier as e:
self.show_error(str(e))
def handle_payment_identifier(self, text: str):
pi = PaymentIdentifier(self.wallet, text)
if pi.is_valid():
self.send_tab.set_payment_identifier(text)
else:
if pi.error:
self.show_error(str(pi.error))
def set_frozen_state_of_addresses(self, addrs, freeze: bool):
self.wallet.set_frozen_state_of_addresses(addrs, freeze)

474
electrum/gui/qt/paytoedit.py

@ -23,52 +23,43 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import re
import decimal
from functools import partial
from decimal import Decimal
from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING
from PyQt5.QtCore import Qt, QTimer, QSize
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtGui import QFontMetrics, QFont
from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout
from PyQt5.QtWidgets import QApplication, QTextEdit, QWidget, QLineEdit, QStackedLayout, QSizePolicy
from electrum import bitcoin
from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier
from electrum.transaction import PartialTxOutput
from electrum.bitcoin import opcodes, construct_script
from electrum.payment_identifier import PaymentIdentifier
from electrum.logging import Logger
from electrum.lnurl import LNURLError
from .qrtextedit import ScanQRTextEdit
from .completion_text_edit import CompletionTextEdit
from . import util
from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent
from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent, ColorScheme
if TYPE_CHECKING:
from .main_window import ElectrumWindow
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 InvalidPaymentIdentifier(Exception):
pass
class ResizingTextEdit(QTextEdit):
textReallyChanged = pyqtSignal()
resized = pyqtSignal()
def __init__(self):
QTextEdit.__init__(self)
self._text = ''
self.setAcceptRichText(False)
self.textChanged.connect(self.on_text_changed)
document = self.document()
document.contentsChanged.connect(self.update_size)
fontMetrics = QFontMetrics(document.defaultFont())
self.fontSpacing = fontMetrics.lineSpacing()
margins = self.contentsMargins()
@ -80,6 +71,13 @@ class ResizingTextEdit(QTextEdit):
self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
self.update_size()
def on_text_changed(self):
# QTextEdit emits spurious textChanged events
if self.toPlainText() != self._text:
self._text = self.toPlainText()
self.textReallyChanged.emit()
self.update_size()
def update_size(self):
docLineCount = self.document().lineCount()
docHeight = max(3, docLineCount) * self.fontSpacing
@ -87,327 +85,207 @@ class ResizingTextEdit(QTextEdit):
h = min(max(h, self.heightMin), self.heightMax)
self.setMinimumHeight(int(h))
self.setMaximumHeight(int(h))
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax)
self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
self.resized.emit()
def sizeHint(self) -> QSize:
return QSize(0, self.minimumHeight())
class PayToEdit(Logger, GenericInputHandler):
class PayToEdit(QWidget, Logger, GenericInputHandler):
paymentIdentifierChanged = pyqtSignal()
textChanged = pyqtSignal()
def __init__(self, send_tab: 'SendTab'):
QWidget.__init__(self, parent=send_tab)
Logger.__init__(self)
GenericInputHandler.__init__(self)
self._text = ''
self._layout = QStackedLayout()
self.setLayout(self._layout)
def text_edit_changed():
text = self.text_edit.toPlainText()
if self._text != text:
# sync and emit
self._text = text
self.line_edit.setText(text)
self.textChanged.emit()
def text_edit_resized():
self.update_height()
def line_edit_changed():
text = self.line_edit.text()
if self._text != text:
# sync and emit
self._text = text
self.text_edit.setPlainText(text)
self.textChanged.emit()
self.line_edit = QLineEdit()
self.line_edit.textChanged.connect(line_edit_changed)
self.text_edit = ResizingTextEdit()
self.text_edit.hide()
self.text_edit.textReallyChanged.connect(text_edit_changed)
self.text_edit.resized.connect(text_edit_resized)
self.textChanged.connect(self._handle_text_change)
self._layout.addWidget(self.line_edit)
self._layout.addWidget(self.text_edit)
self.multiline = False
self._is_paytomany = False
for w in [self.line_edit, self.text_edit]:
w.setFont(QFont(MONOSPACE_FONT))
w.textChanged.connect(self._on_text_changed)
self.text_edit.setFont(QFont(MONOSPACE_FONT))
self.send_tab = send_tab
self.config = send_tab.config
self.win = send_tab.window
self.app = QApplication.instance()
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
self.setText = self.editor.setText
self.setEnabled = self.editor.setEnabled
self.setReadOnly = self.editor.setReadOnly
self.setFocus = self.editor.setFocus
# button handlers
self.on_qr_from_camera_input_btn = partial(
self.input_qr_from_camera,
config=self.config,
allow_multi=False,
show_error=self.win.show_error,
setText=self._on_input_btn,
parent=self.win,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
parent=self.send_tab.window,
)
self.on_qr_from_screenshot_input_btn = partial(
self.input_qr_from_screenshot,
allow_multi=False,
show_error=self.win.show_error,
setText=self._on_input_btn,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
)
self.on_input_file = partial(
self.input_file,
config=self.config,
show_error=self.win.show_error,
setText=self._on_input_btn,
show_error=self.send_tab.show_error,
setText=self.try_payment_identifier,
)
#
self.line_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.line_edit, self)
self.text_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.text_edit, self)
self.edit_timer = QTimer(self)
self.edit_timer.setSingleShot(True)
self.edit_timer.setInterval(1000)
self.edit_timer.timeout.connect(self._on_edit_timer)
self.payment_identifier = None
@property
def editor(self):
return self.text_edit if self.is_paytomany() else self.line_edit
def multiline(self):
return self._multiline
@multiline.setter
def multiline(self, b: bool) -> None:
if b is None:
return
self._multiline = b
self._layout.setCurrentWidget(self.text_edit if b else self.line_edit)
self.update_height()
def update_height(self) -> None:
h = self._layout.currentWidget().sizeHint().height()
self.setMaximumHeight(h)
def setText(self, text: str) -> None:
if self._text != text:
self.line_edit.setText(text)
self.text_edit.setText(text)
def setFocus(self, reason=None) -> None:
if self.multiline:
self.text_edit.setFocus(reason)
else:
self.line_edit.setFocus(reason)
def setToolTip(self, tt: str) -> None:
self.line_edit.setToolTip(tt)
self.text_edit.setToolTip(tt)
'''set payment identifier only if valid, else exception'''
def try_payment_identifier(self, text) -> None:
text = text.strip()
pi = PaymentIdentifier(self.send_tab.wallet, text)
if not pi.is_valid():
raise InvalidPaymentIdentifier('Invalid payment identifier')
self.set_payment_identifier(text)
def set_payment_identifier(self, text) -> None:
text = text.strip()
if self.payment_identifier and self.payment_identifier.text == text:
# no change.
return
self.payment_identifier = PaymentIdentifier(self.send_tab.wallet, text)
# toggle to multiline if payment identifier is a multiline
if self.payment_identifier.is_multiline() and not self._is_paytomany:
self.set_paytomany(True)
# if payment identifier gets set externally, we want to update the edit control
# Note: this triggers the change handler, but we shortcut if it's the same payment identifier
self.setText(text)
self.paymentIdentifierChanged.emit()
def set_paytomany(self, b):
has_focus = self.editor.hasFocus()
self._is_paytomany = b
self.line_edit.setVisible(not b)
self.text_edit.setVisible(b)
self.multiline = b
self.send_tab.paytomany_menu.setChecked(b)
if has_focus:
self.editor.setFocus()
def toggle_paytomany(self):
def toggle_paytomany(self) -> None:
self.set_paytomany(not self._is_paytomany)
def toPlainText(self):
return self.text_edit.toPlainText() if self.is_paytomany() else self.line_edit.text()
def is_paytomany(self):
return self._is_paytomany
def setFrozen(self, b):
def setReadOnly(self, b: bool) -> None:
self.line_edit.setReadOnly(b)
self.text_edit.setReadOnly(b)
def isReadOnly(self):
return self.line_edit.isReadOnly()
def setStyleSheet(self, stylesheet: str) -> None:
self.line_edit.setStyleSheet(stylesheet)
self.text_edit.setStyleSheet(stylesheet)
def setFrozen(self, b) -> None:
self.setReadOnly(b)
if not b:
self.setStyleSheet(normal_style)
self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
def setTextNoCheck(self, text: str):
"""Sets the text, while also ensuring the new value will not be resolved/checked."""
self.previous_payto = text
self.setText(text)
def isFrozen(self):
return self.isReadOnly()
def do_clear(self):
def do_clear(self) -> None:
self.set_paytomany(False)
self.disable_checks = False
self.is_alias = False
self.line_edit.setText('')
self.text_edit.setText('')
self.setFrozen(False)
self.setEnabled(True)
def setGreen(self):
self.setText('')
self.setToolTip('')
self.payment_identifier = None
def setGreen(self) -> None:
self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
def setExpired(self):
def setExpired(self) -> None:
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)
def _on_text_changed(self):
text = self.toPlainText()
# False if user pasted from clipboard
full_check = self.app.clipboard().text() != text
self._check_text(text, full_check=full_check)
if self.is_multiline and not self._is_paytomany:
self.set_paytomany(True)
self.text_edit.setText(text)
def on_timer_check_text(self):
if self.editor.hasFocus():
return
text = self.toPlainText()
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():
def _handle_text_change(self) -> None:
if self.isFrozen():
# if editor is frozen, we ignore text changes as they might not be a payment identifier
# but a user friendly representation.
return
if full_check:
self.previous_payto = str(text).strip()
self.errors = []
errors = []
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
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]
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)
if parse_max_spend(output.value):
is_max = True
else:
total += output.value
if outputs:
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()
# pushback timer if timer active or PI needs resolving
pi = PaymentIdentifier(self.send_tab.wallet, self._text)
if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive():
self.edit_timer.start()
else:
self.validated = None
return True
self.set_payment_identifier(self._text)
def _on_edit_timer(self) -> None:
if not self.isFrozen():
self.set_payment_identifier(self._text)

604
electrum/gui/qt/send_tab.py

@ -2,32 +2,28 @@
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import asyncio
from decimal import Decimal
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Any
from urllib.parse import urlparse
from typing import Optional, TYPE_CHECKING, Sequence, List, Callable
from PyQt5.QtCore import pyqtSignal, QPoint, QSize, Qt
from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
QWidget, QToolTip, QPushButton, QApplication)
from PyQt5.QtGui import QMovie, QColor
from PyQt5.QtCore import pyqtSignal, QPoint
from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout,
QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton)
from electrum.i18n import _
from electrum.logging import Logger
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 NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTransaction, PartialTxOutput
from electrum.transaction import Transaction, PartialTxInput, 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 electrum.payment_identifier import PaymentIdentifierState, PaymentIdentifierType
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit
from .util import get_iconname_camera, get_iconname_qrcode, read_QIcon
from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton,
char_width_in_lineedit, get_iconname_camera, get_iconname_qrcode,
read_QIcon, ColorScheme, icon_path)
from .confirm_tx_dialog import ConfirmTxDialog
if TYPE_CHECKING:
@ -36,20 +32,14 @@ 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
resolve_done_signal = pyqtSignal(object)
finalize_done_signal = pyqtSignal(object)
notify_merchant_done_signal = pyqtSignal(object)
def __init__(self, window: 'ElectrumWindow'):
QWidget.__init__(self, window)
Logger.__init__(self)
self.app = QApplication.instance()
self.window = window
self.wallet = window.wallet
self.fx = window.fx
@ -60,8 +50,6 @@ 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.pending_invoice = None
# A 4-column grid layout. All the stretch is in the last column.
@ -84,9 +72,8 @@ 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, 0, 1, 1, 4)
#completer = QCompleter()
#completer.setCaseSensitivity(False)
@ -97,9 +84,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'
@ -123,21 +118,34 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
btn_width = 10 * char_width_in_lineedit()
self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True)
self.max_button.setEnabled(False)
grid.addWidget(self.max_button, 3, 3)
self.save_button = EnterButton(_("Save"), self.do_save_invoice)
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)
self.spinner = QMovie(icon_path('spinner.gif'))
self.spinner.setScaledSize(QSize(24, 24))
self.spinner.setBackgroundColor(QColor('black'))
self.spinner_l = QLabel()
self.spinner_l.setMargin(5)
self.spinner_l.setVisible(False)
self.spinner_l.setMovie(self.spinner)
grid.addWidget(self.spinner_l, 0, 1, 1, 4, Qt.AlignRight)
self.save_button = EnterButton(_("Save"), self.do_save_invoice)
self.save_button.setEnabled(False)
self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
self.send_button.setEnabled(False)
self.clear_button = EnterButton(_("Clear"), self.do_clear)
buttons = QHBoxLayout()
buttons.addStretch(1)
#buttons.addWidget(self.paste_button)
buttons.addWidget(self.clear_button)
buttons.addWidget(self.save_button)
buttons.addWidget(self.send_button)
@ -147,20 +155,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def reset_max(text):
self.max_button.setChecked(False)
enable = not bool(text) and not self.amount_e.isReadOnly()
# self.max_button.setEnabled(enable)
self.amount_e.textChanged.connect(self.on_amount_changed)
self.amount_e.textEdited.connect(reset_max)
self.fiat_send_e.textEdited.connect(reset_max)
self.set_onchain(False)
self.invoices_label = QLabel(_('Invoices'))
from .invoice_list import InvoiceList
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 +190,45 @@ 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.resolve_done_signal.connect(self.on_resolve_done)
self.finalize_done_signal.connect(self.on_finalize_done)
self.notify_merchant_done_signal.connect(self.on_notify_merchant_done)
self.payto_e.paymentIdentifierChanged.connect(self._handle_payment_identifier)
def showSpinner(self, b):
self.spinner_l.setVisible(b)
if b:
self.spinner.start()
else:
self.spinner.stop()
def on_amount_changed(self, text):
# FIXME: implement full valid amount check to enable/disable Pay button
pi_valid = self.payto_e.payment_identifier.is_valid() if self.payto_e.payment_identifier else False
pi_error = self.payto_e.payment_identifier.is_error() if pi_valid else False
self.send_button.setEnabled(bool(self.amount_e.get_amount()) and pi_valid and not pi_error)
def do_paste(self):
self.logger.debug('do_paste')
try:
self.payto_e.try_payment_identifier(self.app.clipboard().text())
except InvalidPaymentIdentifier as e:
self.show_error(_('Invalid payment identifier on clipboard'))
def set_payment_identifier(self, text):
self.logger.debug('set_payment_identifier')
try:
self.payto_e.try_payment_identifier(text)
except InvalidPaymentIdentifier as e:
self.show_error(_('Invalid payment identifier'))
def spend_max(self):
assert self.payto_e.payment_identifier is not None
assert self.payto_e.payment_identifier.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE,
PaymentIdentifierType.OPENALIAS]
if run_hook('abort_send', self):
return
outputs = self.payto_e.get_outputs(True)
outputs = self.payto_e.payment_identifier.get_onchain_outputs('!')
if not outputs:
return
make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
@ -285,9 +317,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
text = _("Not enough funds")
frozen_str = self.get_frozen_balance_str()
if frozen_str:
text += " ({} {})".format(
frozen_str, _("are frozen")
)
text += " ({} {})".format(frozen_str, _("are frozen"))
return text
def get_frozen_balance_str(self) -> Optional[str]:
@ -297,226 +327,158 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return self.format_amount_and_units(frozen_bal)
def do_clear(self):
self._lnurl_data = None
self.logger.debug('do_clear')
self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
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]:
e.setText('')
e.setFrozen(False)
for e in [self.send_button, self.save_button, self.clear_button, self.amount_e, self.fiat_send_e]:
e.setEnabled(True)
for w in [self.comment_e, self.comment_label]:
w.setVisible(False)
for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]:
w.setText('')
w.setToolTip('')
for w in [self.save_button, self.send_button]:
w.setEnabled(False)
self.window.update_status()
run_hook('do_clear', self)
def set_onchain(self, b):
self._is_onchain = b
self.max_button.setEnabled(b)
self.paytomany_menu.setChecked(self.payto_e.multiline)
def lock_amount(self, b: bool) -> None:
self.amount_e.setFrozen(b)
self.max_button.setEnabled(not b)
run_hook('do_clear', self)
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 btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False)
self.payto_e.setTextNoCheck(_("please wait..."))
self.showSpinner(True)
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()
else:
self.payment_request_error_signal.emit()
def set_field_validated(self, w, *, validated: Optional[bool] = None):
if validated is not None:
w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True))
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:
def lock_fields(self, *,
lock_recipient: Optional[bool] = None,
lock_amount: Optional[bool] = None,
lock_max: Optional[bool] = None,
lock_description: Optional[bool] = None
) -> None:
self.logger.debug(f'locking fields, r={lock_recipient}, a={lock_amount}, m={lock_max}, d={lock_description}')
if lock_recipient is not None:
self.payto_e.setFrozen(lock_recipient)
if lock_amount is not None:
self.amount_e.setFrozen(lock_amount)
if lock_max is not None:
self.max_button.setEnabled(not lock_max)
if lock_description is not None:
self.message_e.setFrozen(lock_description)
def update_fields(self):
self.logger.debug('update_fields')
pi = self.payto_e.payment_identifier
self.clear_button.setEnabled(True)
if pi.is_multiline():
self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False)
self.set_field_validated(self.payto_e, validated=pi.is_valid()) # TODO: validated used differently here than openalias
self.save_button.setEnabled(pi.is_valid())
self.send_button.setEnabled(pi.is_valid())
self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '')
if pi.is_valid():
self.handle_multiline(pi.multiline_outputs)
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}")
if not pi.is_valid():
self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
self.save_button.setEnabled(False)
self.send_button.setEnabled(False)
return
pubkey = lnaddr.pubkey.serialize().hex()
for k,v in lnaddr.tags:
if k == 'd':
description = v
break
else:
description = ''
self.payto_e.setFrozen(True)
self.payto_e.setTextNoCheck(pubkey)
self.payto_e.lightning_invoice = invoice
if not self.message_e.text():
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}")
lock_recipient = pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70,
PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
lock_amount = pi.is_amount_locked()
lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21]
self.lock_fields(lock_recipient=lock_recipient,
lock_amount=lock_amount,
lock_max=lock_max,
lock_description=False)
if lock_recipient:
fields = pi.get_fields_for_GUI()
if fields.recipient:
self.payto_e.setText(fields.recipient)
if fields.description:
self.message_e.setText(fields.description)
self.lock_fields(lock_description=True)
if fields.amount:
self.amount_e.setAmount(fields.amount)
for w in [self.comment_e, self.comment_label]:
w.setVisible(bool(fields.comment))
if fields.comment:
self.comment_e.setToolTip(_('Max comment length: %d characters') % fields.comment)
self.set_field_validated(self.payto_e, validated=fields.validated)
# LNURLp amount range
if fields.amount_range:
amin, amax = fields.amount_range
self.amount_e.setToolTip(_('Amount must be between %d and %d sat.') % (amin, amax))
else:
self.amount_e.setToolTip('')
pi_unusable = pi.is_error() or (not self.wallet.has_lightning() and not pi.is_onchain())
self.send_button.setEnabled(not pi_unusable and bool(self.amount_e.get_amount()) and not pi.has_expired())
self.save_button.setEnabled(not pi_unusable and pi.type not in [PaymentIdentifierType.LNURLP,
PaymentIdentifierType.LNADDR])
def _handle_payment_identifier(self):
self.update_fields()
if not self.payto_e.payment_identifier.is_valid():
self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}')
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:
if self.payto_e.payment_identifier.need_resolve():
self.prepare_for_send_tab_network_lookup()
self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit)
def on_resolve_done(self, pi):
# TODO: resolve can happen while typing, we don't want message dialogs to pop up
# currently we don't set error for emaillike recipients to avoid just that
self.logger.debug('payment identifier resolve done')
self.showSpinner(False)
if pi.error:
self.show_error(pi.error)
self.do_clear()
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)
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:
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}")
# update fiat amount
self.amount_e.textEdited.emit("")
self.window.show_send_tab()
self.update_fields()
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.payto_e.payment_identifier.get_invoice(amount_sat, self.get_message())
if not invoice:
self.show_error('error getting invoice' + self.payto_e.payment_identifier.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 +498,23 @@ 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.')
def on_finalize_done(self, pi):
self.showSpinner(False)
self.update_fields()
if pi.error:
self.show_error(pi.error)
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())
self.do_clear()
self.payto_e.setText(bolt11_invoice)
invoice = pi.get_invoice(self.get_amount(), self.get_message())
self.pending_invoice = invoice
self.logger.debug(f'after finalize invoice: {invoice!r}')
self.do_pay_invoice(invoice)
def do_pay_or_get_invoice(self):
if self._lnurl_data:
self._lnurl_get_invoice()
pi = self.payto_e.payment_identifier
if pi.need_finalize():
self.prepare_for_send_tab_network_lookup()
pi.finalize(amount_sat=self.get_amount(), comment=self.message_e.text(),
on_finished=self.finalize_done_signal.emit)
return
self.pending_invoice = self.read_invoice()
if not self.pending_invoice:
@ -600,12 +544,9 @@ 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]:
amount = '!' if self.max_button.isChecked() 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 +570,31 @@ 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'))
return True
error = self.payto_e.payment_identifier.get_error()
if error:
if not self.payto_e.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 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]))
warning = self.payto_e.payment_identifier.warning
if warning:
warning += '\n' + _('Do you wish to continue?')
if not self.question(warning):
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.payto_e.payment_identifier.has_expired():
self.show_error(_('Payment request has expired'))
return True
return False # no errors
@ -740,9 +678,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.payto_e.payment_identifier.has_expired():
return False, _("Invoice has expired")
try:
self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
@ -752,13 +688,13 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return False, repr(e)
# success
txid = tx.txid()
if pr:
self.payment_request = None
if self.payto_e.payment_identifier.need_merchant_notify():
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}")
self.payto_e.payment_identifier.notify_merchant(
tx=tx,
refund_address=refund_address,
on_finished=self.notify_merchant_done_signal.emit
)
return True, txid
# Capture current TL window; override might be removed on return
@ -782,6 +718,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
WaitingDialog(self, _('Broadcasting transaction...'),
broadcast_thread, broadcast_done, self.window.on_error)
def on_notify_merchant_done(self, pi):
if pi.is_error():
self.logger.debug(f'merchant notify error: {pi.get_error()}')
else:
self.logger.debug(f'merchant notify result: {pi.merchant_ack_status}: {pi.merchant_ack_message}')
# TODO: show user? if we broadcasted the tx succesfully, do we care?
# BitPay complains with a NAK if tx is RbF
def toggle_paytomany(self):
self.payto_e.toggle_paytomany()
if self.payto_e.is_paytomany():
@ -798,9 +742,23 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.window.show_send_tab()
self.payto_e.do_clear()
if len(paytos) == 1:
self.logger.debug('payto_e setText 1')
self.payto_e.setText(paytos[0])
self.amount_e.setFocus()
else:
self.payto_e.setFocus()
text = "\n".join([payto + ", 0" for payto in paytos])
self.logger.debug('payto_e setText n')
self.payto_e.setText(text)
self.payto_e.setFocus()
def handle_multiline(self, outputs):
total = 0
for output in outputs:
if parse_max_spend(output.value):
self.max_button.setChecked(True) # TODO: remove and let spend_max set this?
self.spend_max()
return
else:
total += output.value
self.amount_e.setAmount(total if outputs else None)

16
electrum/gui/qt/util.py

@ -562,7 +562,10 @@ class GenericInputHandler:
new_text = self.text() + data + '\n'
else:
new_text = data
setText(new_text)
try:
setText(new_text)
except Exception as e:
show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
from .qrreader import scan_qrcode
if parent is None:
@ -599,7 +602,10 @@ class GenericInputHandler:
new_text = self.text() + data + '\n'
else:
new_text = data
setText(new_text)
try:
setText(new_text)
except Exception as e:
show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
def input_file(
self,
@ -628,7 +634,10 @@ class GenericInputHandler:
except BaseException as e:
show_error(_('Error opening file') + ':\n' + repr(e))
else:
setText(data)
try:
setText(data)
except Exception as e:
show_error(_('Invalid payment identifier in file') + ':\n' + repr(e))
def input_paste_from_clipboard(
self,
@ -961,6 +970,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")

2
electrum/invoices.py

@ -7,6 +7,7 @@ import attr
from .json_db import StoredObject, stored_in
from .i18n import _
from .util import age, InvoiceError, format_satoshis
from .bip21 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:

6
electrum/network.py

@ -59,7 +59,6 @@ from .interface import (Interface, PREFERRED_NETWORK_PROTOCOL,
RequestTimedOut, NetworkTimeout, BUCKET_NAME_OF_ONION_SERVERS,
NetworkException, RequestCorrupted, ServerAddr)
from .version import PROTOCOL_VERSION
from .simple_config import SimpleConfig
from .i18n import _
from .logging import get_logger, Logger
@ -71,6 +70,7 @@ if TYPE_CHECKING:
from .lnworker import LNGossip
from .lnwatcher import WatchTower
from .daemon import Daemon
from .simple_config import SimpleConfig
_logger = get_logger(__name__)
@ -270,7 +270,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
local_watchtower: Optional['WatchTower'] = None
path_finder: Optional['LNPathFinder'] = None
def __init__(self, config: SimpleConfig, *, daemon: 'Daemon' = None):
def __init__(self, config: 'SimpleConfig', *, daemon: 'Daemon' = None):
global _INSTANCE
assert _INSTANCE is None, "Network is a singleton!"
_INSTANCE = self
@ -287,9 +287,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
self.asyncio_loop = util.get_asyncio_loop()
assert self.asyncio_loop.is_running(), "event loop not running"
assert isinstance(config, SimpleConfig), f"config should be a SimpleConfig instead of {type(config)}"
self.config = config
self.daemon = daemon
blockchain.read_blockchains(self.config)

659
electrum/payment_identifier.py

@ -0,0 +1,659 @@
import asyncio
import time
import urllib
import re
from decimal import Decimal, InvalidOperation
from enum import IntEnum
from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple
from . import bitcoin
from .contacts import AliasNotFoundException
from .i18n import _
from .invoices import Invoice
from .logging import Logger
from .util import parse_max_spend, format_satoshis_plain, InvoiceError
from .util import get_asyncio_loop, log_exceptions
from .transaction import PartialTxOutput
from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, 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
from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME
if TYPE_CHECKING:
from .wallet import Abstract_Wallet
from .transaction import Transaction
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
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_DOMAIN = r'\b([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
MERCHANT_NOTIFY = 5 # PI contains a valid payment request and on-chain destination. It should notify
# the merchant payment processor of the tx after on-chain broadcast,
# and supply a refund address (bip70)
MERCHANT_ACK = 6 # PI notified merchant. nothing to be done.
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 PaymentIdentifierType(IntEnum):
UNKNOWN = 0
SPK = 1
BIP21 = 2
BIP70 = 3
MULTILINE = 4
BOLT11 = 5
LNURLP = 6
EMAILLIKE = 7
OPENALIAS = 8
LNADDR = 9
DOMAINLIKE = 10
class FieldsForGUI(NamedTuple):
recipient: Optional[str]
amount: Optional[int]
description: Optional[str]
validated: Optional[bool]
comment: Optional[int]
amount_range: Optional[Tuple[int, int]]
class PaymentIdentifier(Logger):
"""
Takes:
* bitcoin addresses or script
* paytomany csv
* openalias
* bip21 URI
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
* 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
self.text = text.strip()
self._type = PaymentIdentifierType.UNKNOWN
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._is_max = False
self.bolt11 = None
self.bip21 = None
self.spk = None
#
self.emaillike = None
self.domainlike = None
self.openalias_data = None
#
self.bip70 = None
self.bip70_data = None
self.merchant_ack_status = None
self.merchant_ack_message = None
#
self.lnurl = None
self.lnurl_data = None
self.parse(text)
@property
def type(self):
return self._type
def set_state(self, state: 'PaymentIdentifierState'):
self.logger.debug(f'PI state {self._state} -> {state}')
self._state = state
@property
def state(self):
return self._state
def need_resolve(self):
return self._state == PaymentIdentifierState.NEED_RESOLVE
def need_finalize(self):
return self._state == PaymentIdentifierState.LNURLP_FINALIZE
def need_merchant_notify(self):
return self._state == PaymentIdentifierState.MERCHANT_NOTIFY
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 bool(self.lnurl) or bool(self.bolt11)
def is_onchain(self):
if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.BIP70,
PaymentIdentifierType.OPENALIAS]:
return True
if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]:
return bool(self.bolt11) and bool(self.bolt11.get_address())
def is_multiline(self):
return bool(self.multiline_outputs)
def is_multiline_max(self):
return self.is_multiline() and self._is_max
def is_amount_locked(self):
if self._type == PaymentIdentifierType.BIP21:
return bool(self.bip21.get('amount'))
elif self._type == PaymentIdentifierType.BIP70:
return not self.need_resolve() # always fixed after resolve?
elif self._type == PaymentIdentifierType.BOLT11:
return bool(self.bolt11.get_amount_sat())
elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]:
# amount limits known after resolve, might be specific amount or locked to range
if self.need_resolve():
return False
if self.need_finalize():
self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}')
return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat)
return True
elif self._type == PaymentIdentifierType.MULTILINE:
return True
else:
return False
def is_error(self) -> bool:
return self._state >= PaymentIdentifierState.ERROR
def get_error(self) -> str:
return self.error
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 = PaymentIdentifierType.MULTILINE
self.multiline_outputs = outputs
if self.error:
self.set_state(PaymentIdentifierState.INVALID)
else:
self.set_state(PaymentIdentifierState.AVAILABLE)
elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
if invoice_or_lnurl.startswith('lnurl'):
self._type = PaymentIdentifierType.LNURLP
try:
self.lnurl = decode_lnurl(invoice_or_lnurl)
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
except Exception as e:
self.error = _("Error parsing LNURL") + f":\n{e}"
self.set_state(PaymentIdentifierState.INVALID)
return
else:
self._type = PaymentIdentifierType.BOLT11
try:
self.bolt11 = Invoice.from_bech32(invoice_or_lnurl)
except InvoiceError as e:
self.error = self._get_error_from_invoiceerror(e)
self.set_state(PaymentIdentifierState.INVALID)
self.logger.debug(f'Exception cause {e.args!r}')
return
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.bip21 = out
self.bip70 = out.get('r')
if self.bip70:
self._type = PaymentIdentifierType.BIP70
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
else:
self._type = PaymentIdentifierType.BIP21
# check optional lightning in bip21, set self.bolt11 if valid
bolt11 = out.get('lightning')
if bolt11:
try:
lndecode(bolt11)
# if we get here, we have a usable bolt11
self.bolt11 = bolt11
except LnInvoiceException as e:
self.logger.debug(_("Error parsing Lightning invoice") + f":\n{e}")
except IncompatibleOrInsaneFeatures as e:
self.logger.debug(_("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}")
self.set_state(PaymentIdentifierState.AVAILABLE)
elif scriptpubkey := self.parse_output(text):
self._type = PaymentIdentifierType.SPK
self.spk = scriptpubkey
self.set_state(PaymentIdentifierState.AVAILABLE)
elif contact := self.contacts.by_name(text):
if contact['type'] == 'address':
self._type = PaymentIdentifierType.BIP21
self.bip21 = {
'address': contact['address'],
'label': contact['name']
}
self.set_state(PaymentIdentifierState.AVAILABLE)
elif contact['type'] == 'openalias':
self._type = PaymentIdentifierType.EMAILLIKE
self.emaillike = contact['address']
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
elif re.match(RE_EMAIL, text):
self._type = PaymentIdentifierType.EMAILLIKE
self.emaillike = text
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
elif re.match(RE_DOMAIN, text):
self._type = PaymentIdentifierType.DOMAINLIKE
self.domainlike = 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 = 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 or self.domainlike:
# TODO: parallel lookup?
data = await self.resolve_openalias()
if data:
self.openalias_data = data
self.logger.debug(f'OA: {data!r}')
name = data.get('name')
address = data.get('address')
key = self.emaillike if self.emaillike else self.domainlike
self.contacts[key] = ('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(key)
try:
assert bitcoin.is_address(address)
scriptpubkey = bytes.fromhex(bitcoin.address_to_script(address))
self._type = PaymentIdentifierType.OPENALIAS
self.spk = scriptpubkey
self.set_state(PaymentIdentifierState.AVAILABLE)
except Exception as e:
self.error = str(e)
self.set_state(PaymentIdentifierState.NOT_FOUND)
elif self.emaillike:
lnurl = lightning_address_to_url(self.emaillike)
try:
data = await request_lnurl(lnurl)
self._type = PaymentIdentifierType.LNADDR
self.lnurl = lnurl
self.lnurl_data = data
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
except LNURLError as e:
self.set_state(PaymentIdentifierState.NOT_FOUND)
except Exception as e:
# NOTE: any other exception is swallowed here (e.g. DNS error)
# as the user may be typing and we have an incomplete emaillike
self.set_state(PaymentIdentifierState.NOT_FOUND)
else:
self.set_state(PaymentIdentifierState.NOT_FOUND)
elif self.bip70:
from . import paymentrequest
pr = await paymentrequest.get_payment_request(self.bip70)
if not pr.error:
self.bip70_data = pr
self.set_state(PaymentIdentifierState.MERCHANT_NOTIFY)
else:
self.error = pr.error
self.set_state(PaymentIdentifierState.ERROR)
elif self.lnurl:
data = await request_lnurl(self.lnurl)
self.lnurl_data = data
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
self.logger.debug(f'LNURL data: {data!r}')
else:
self.set_state(PaymentIdentifierState.ERROR)
return
except Exception as e:
self.error = str(e)
self.logger.error(repr(e))
self.set_state(PaymentIdentifierState.ERROR)
finally:
if on_finished:
on_finished(self)
def finalize(self, *, amount_sat: int = 0, comment: str = None, on_finished: Callable = None):
assert self._state == PaymentIdentifierState.LNURLP_FINALIZE
coro = self._do_finalize(amount_sat, comment, on_finished=on_finished)
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
@log_exceptions
async def _do_finalize(self, amount_sat: int = None, comment: str = None, on_finished: Callable = None):
from .invoices import Invoice
try:
if not self.lnurl_data:
raise Exception("Unexpected missing LNURL data")
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)
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")
# this will change what is returned by get_fields_for_GUI
self.bolt11 = bolt11_invoice
self.set_state(PaymentIdentifierState.AVAILABLE)
except Exception as e:
self.error = str(e)
self.logger.error(repr(e))
self.set_state(PaymentIdentifierState.ERROR)
finally:
if on_finished:
on_finished(self)
def notify_merchant(self, *, tx: 'Transaction' = None, refund_address: str = None, on_finished: 'Callable' = None):
assert self._state == PaymentIdentifierState.MERCHANT_NOTIFY
assert tx
coro = self._do_notify_merchant(tx, refund_address, on_finished=on_finished)
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
@log_exceptions
async def _do_notify_merchant(self, tx, refund_address, *, on_finished: 'Callable'):
try:
if not self.bip70_data:
self.set_state(PaymentIdentifierState.ERROR)
return
ack_status, ack_msg = await self.bip70_data.send_payment_and_receive_paymentack(tx.serialize(), refund_address)
self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
self.merchant_ack_status = ack_status
self.merchant_ack_message = ack_msg
self.set_state(PaymentIdentifierState.MERCHANT_ACK)
except Exception as e:
self.error = str(e)
self.logger.error(repr(e))
self.set_state(PaymentIdentifierState.MERCHANT_ERROR)
finally:
if on_finished:
on_finished(self)
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
self._is_max = False
for i, line in enumerate(lines):
try:
output = self.parse_address_and_amount(line)
outputs.append(output)
if parse_max_spend(output.value):
self._is_max = True
else:
total += output.value
except Exception as e:
errors = f'{errors}line #{i}: {str(e)}\n'
continue
if is_multiline and errors:
self.error = errors.strip() if errors else None
self.logger.debug(f'multiline: {outputs!r}, {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)
if not scriptpubkey:
raise Exception('Invalid address')
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:
pass
try:
script = self.parse_script(x)
return bytes.fromhex(script)
except Exception as e:
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.config.get_decimal_point())
try:
return int(p * Decimal(x))
except 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_error_from_invoiceerror(self, e: 'InvoiceError') -> str:
error = _("Error parsing Lightning invoice") + f":\n{e!r}"
if e.args and len(e.args):
arg = e.args[0]
if isinstance(arg, LnInvoiceException):
error = _("Error parsing Lightning invoice") + f":\n{e}"
elif isinstance(arg, IncompatibleOrInsaneFeatures):
error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}"
return error
def get_fields_for_GUI(self) -> FieldsForGUI:
recipient = None
amount = None
description = None
validated = None
comment = None
amount_range = None
if (self.emaillike or self.domainlike) and self.openalias_data:
key = self.emaillike if self.emaillike else self.domainlike
address = self.openalias_data.get('address')
name = self.openalias_data.get('name')
description = name
recipient = key + ' <' + 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(key)
elif self.bolt11:
recipient, amount, description = self._get_bolt11_fields()
elif self.lnurl and self.lnurl_data:
domain = urllib.parse.urlparse(self.lnurl).netloc
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
description = self.lnurl_data.metadata_plaintext
if self.lnurl_data.comment_allowed:
comment = self.lnurl_data.comment_allowed
if self.lnurl_data.min_sendable_sat:
amount = self.lnurl_data.min_sendable_sat
if self.lnurl_data.min_sendable_sat != self.lnurl_data.max_sendable_sat:
amount_range = (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat)
elif self.bip70 and self.bip70_data:
pr = self.bip70_data
if pr.error:
self.error = pr.error
else:
recipient = pr.get_requestor()
amount = pr.get_amount()
description = pr.get_memo()
validated = not pr.has_expired()
elif self.spk:
pass
elif self.multiline_outputs:
pass
elif self.bip21:
label = self.bip21.get('label')
address = self.bip21.get('address')
recipient = f'{label} <{address}>' if label else address
amount = self.bip21.get('amount')
description = self.bip21.get('message')
# TODO: use label as description? (not BIP21 compliant)
# if label and not description:
# description = label
return FieldsForGUI(recipient=recipient, amount=amount, description=description,
comment=comment, validated=validated, amount_range=amount_range)
def _get_bolt11_fields(self):
lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr
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.emaillike if self.emaillike else self.domainlike
# TODO: below check needed? we already matched RE_EMAIL/RE_DOMAIN
# 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) # TODO: don't use contacts as delegate to resolve openalias, separate.
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
def has_expired(self):
if self.bip70:
return self.bip70_data.has_expired()
elif self.bolt11:
return self.bolt11.has_expired()
elif self.bip21:
expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0
return bool(expires) and expires < time.time()
return False
def get_invoice(self, amount_sat, message):
if self.is_lightning():
invoice = self.bolt11
if not invoice:
return
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 self.wallet.create_invoice(
outputs=outputs,
message=message,
pr=bip70_data,
URI=self.bip21)

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.bip21 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'))

169
electrum/util.py

@ -1010,177 +1010,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

4
electrum/wallet.py

@ -57,7 +57,7 @@ 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 .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
@ -65,7 +65,7 @@ from .crypto import sha256d
from . import keystore
from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK,
AddressIndexGeneric, CannotDerivePubkey)
from .util import multisig_type
from .util import multisig_type, parse_max_spend
from .storage import StorageEncryptionVersion, WalletStorage
from .wallet_db import WalletDB
from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32

13
run_electrum

@ -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,12 +365,6 @@ def main():
if not config_options.get('verbosity'):
warnings.simplefilter('ignore', DeprecationWarning)
# check uri
uri = config_options.get('url')
if uri and not util.is_uri(uri):
print_stderr('unknown command:', uri)
sys.exit(1)
config = SimpleConfig(config_options)
cmdname = config.get('cmd')
@ -397,6 +392,12 @@ def main():
elif config.get('signet'):
constants.set_signet()
# check if we received a valid payment identifier
uri = config_options.get('url')
if uri and not PaymentIdentifier(None, uri).is_valid():
print_stderr('unknown command:', uri)
sys.exit(1)
if cmdname == 'daemon' and config.get("detach"):
# detect lockfile.
# This is not as good as get_file_descriptor, but that would require the asyncio loop

Loading…
Cancel
Save