You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
503 lines
17 KiB
503 lines
17 KiB
import asyncio |
|
from datetime import datetime |
|
|
|
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS |
|
|
|
from electrum.logging import get_logger |
|
from electrum.i18n import _ |
|
from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, |
|
maybe_extract_lightning_payment_identifier) |
|
from electrum.invoices import Invoice |
|
from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, |
|
PR_FAILED,PR_ROUTING,PR_UNCONFIRMED,LN_EXPIRY_NEVER) |
|
from electrum.transaction import PartialTxOutput |
|
from electrum.lnaddr import lndecode |
|
from electrum import bitcoin |
|
from electrum import lnutil |
|
from electrum.lnaddr import LnInvoiceException |
|
|
|
from .qewallet import QEWallet |
|
from .qetypes import QEAmount |
|
|
|
class QEInvoice(QObject): |
|
class Type: |
|
Invalid = -1 |
|
OnchainOnlyAddress = 0 |
|
OnchainInvoice = 1 |
|
LightningInvoice = 2 |
|
LightningAndOnchainInvoice = 3 |
|
|
|
class Status: |
|
Unpaid = PR_UNPAID |
|
Expired = PR_EXPIRED |
|
Unknown = PR_UNKNOWN |
|
Paid = PR_PAID |
|
Inflight = PR_INFLIGHT |
|
Failed = PR_FAILED |
|
Routing = PR_ROUTING |
|
Unconfirmed = PR_UNCONFIRMED |
|
|
|
Q_ENUMS(Type) |
|
Q_ENUMS(Status) |
|
|
|
_logger = get_logger(__name__) |
|
|
|
_wallet = None |
|
_canSave = False |
|
_canPay = False |
|
_key = None |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent) |
|
|
|
walletChanged = pyqtSignal() |
|
@pyqtProperty(QEWallet, notify=walletChanged) |
|
def wallet(self): |
|
return self._wallet |
|
|
|
@wallet.setter |
|
def wallet(self, wallet: QEWallet): |
|
if self._wallet != wallet: |
|
self._wallet = wallet |
|
self.walletChanged.emit() |
|
|
|
canSaveChanged = pyqtSignal() |
|
@pyqtProperty(bool, notify=canSaveChanged) |
|
def canSave(self): |
|
return self._canSave |
|
|
|
@canSave.setter |
|
def canSave(self, canSave): |
|
if self._canSave != canSave: |
|
self._canSave = canSave |
|
self.canSaveChanged.emit() |
|
|
|
canPayChanged = pyqtSignal() |
|
@pyqtProperty(bool, notify=canPayChanged) |
|
def canPay(self): |
|
return self._canPay |
|
|
|
@canPay.setter |
|
def canPay(self, canPay): |
|
if self._canPay != canPay: |
|
self._canPay = canPay |
|
self.canPayChanged.emit() |
|
|
|
keyChanged = pyqtSignal() |
|
@pyqtProperty(str, notify=keyChanged) |
|
def key(self): |
|
return self._key |
|
|
|
@key.setter |
|
def key(self, key): |
|
if self._key != key: |
|
self._key = key |
|
self.keyChanged.emit() |
|
|
|
userinfoChanged = pyqtSignal() |
|
@pyqtProperty(str, notify=userinfoChanged) |
|
def userinfo(self): |
|
return self._userinfo |
|
|
|
@userinfo.setter |
|
def userinfo(self, userinfo): |
|
if self._userinfo != userinfo: |
|
self._userinfo = userinfo |
|
self.userinfoChanged.emit() |
|
|
|
def get_max_spendable_onchain(self): |
|
c, u, x = self._wallet.wallet.get_balance() |
|
#TODO determine real max |
|
return c |
|
|
|
|
|
class QEInvoiceParser(QEInvoice): |
|
|
|
_logger = get_logger(__name__) |
|
|
|
_invoiceType = QEInvoice.Type.Invalid |
|
_recipient = '' |
|
_effectiveInvoice = None |
|
_amount = QEAmount() |
|
_userinfo = '' |
|
|
|
invoiceChanged = pyqtSignal() |
|
invoiceSaved = pyqtSignal() |
|
|
|
validationSuccess = pyqtSignal() |
|
validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) |
|
validationError = pyqtSignal([str,str], arguments=['code', 'message']) |
|
|
|
invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent) |
|
self.clear() |
|
|
|
@pyqtProperty(int, notify=invoiceChanged) |
|
def invoiceType(self): |
|
return self._invoiceType |
|
|
|
# not a qt setter, don't let outside set state |
|
def setInvoiceType(self, invoiceType: QEInvoice.Type): |
|
self._invoiceType = invoiceType |
|
|
|
recipientChanged = pyqtSignal() |
|
@pyqtProperty(str, notify=recipientChanged) |
|
def recipient(self): |
|
return self._recipient |
|
|
|
@recipient.setter |
|
def recipient(self, recipient: str): |
|
#if self._recipient != recipient: |
|
self._recipient = recipient |
|
if recipient: |
|
self.validateRecipient(recipient) |
|
self.recipientChanged.emit() |
|
|
|
@pyqtProperty(str, notify=invoiceChanged) |
|
def message(self): |
|
return self._effectiveInvoice.message if self._effectiveInvoice else '' |
|
|
|
@pyqtProperty(QEAmount, notify=invoiceChanged) |
|
def amount(self): |
|
# store ref to QEAmount on instance, otherwise we get destroyed when going out of scope |
|
self._amount = QEAmount() |
|
if not self._effectiveInvoice: |
|
return self._amount |
|
self._amount = QEAmount(from_invoice=self._effectiveInvoice) |
|
return self._amount |
|
|
|
@pyqtProperty('quint64', notify=invoiceChanged) |
|
def expiration(self): |
|
return self._effectiveInvoice.exp if self._effectiveInvoice else 0 |
|
|
|
@pyqtProperty('quint64', notify=invoiceChanged) |
|
def time(self): |
|
return self._effectiveInvoice.time if self._effectiveInvoice else 0 |
|
|
|
statusChanged = pyqtSignal() |
|
@pyqtProperty(int, notify=statusChanged) |
|
def status(self): |
|
if not self._effectiveInvoice: |
|
return PR_UNKNOWN |
|
return self._wallet.wallet.get_invoice_status(self._effectiveInvoice) |
|
|
|
@pyqtProperty(str, notify=statusChanged) |
|
def status_str(self): |
|
if not self._effectiveInvoice: |
|
return '' |
|
status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) |
|
return self._effectiveInvoice.get_status_str(status) |
|
|
|
# single address only, TODO: n outputs |
|
@pyqtProperty(str, notify=invoiceChanged) |
|
def address(self): |
|
return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' |
|
|
|
@pyqtProperty('QVariantMap', notify=invoiceChanged) |
|
def lnprops(self): |
|
if not self.invoiceType == QEInvoice.Type.LightningInvoice: |
|
return {} |
|
lnaddr = self._effectiveInvoice._lnaddr |
|
self._logger.debug(str(lnaddr)) |
|
self._logger.debug(str(lnaddr.get_routing_info('t'))) |
|
return { |
|
'pubkey': lnaddr.pubkey.serialize().hex(), |
|
't': '', #lnaddr.get_routing_info('t')[0][0].hex(), |
|
'r': '' #lnaddr.get_routing_info('r')[0][0][0].hex() |
|
} |
|
|
|
@pyqtSlot() |
|
def clear(self): |
|
self.recipient = '' |
|
self.setInvoiceType(QEInvoice.Type.Invalid) |
|
self._bip21 = None |
|
self.canSave = False |
|
self.canPay = False |
|
self.userinfo = '' |
|
self.invoiceChanged.emit() |
|
|
|
# don't parse the recipient string, but init qeinvoice from an invoice key |
|
# this should not emit validation signals |
|
@pyqtSlot(str) |
|
def initFromKey(self, key): |
|
self.clear() |
|
invoice = self._wallet.wallet.get_invoice(key) |
|
self._logger.debug(repr(invoice)) |
|
if invoice: |
|
self.set_effective_invoice(invoice) |
|
self.key = key |
|
|
|
def set_effective_invoice(self, invoice: Invoice): |
|
self._effectiveInvoice = invoice |
|
|
|
if invoice.is_lightning(): |
|
self.setInvoiceType(QEInvoice.Type.LightningInvoice) |
|
else: |
|
self.setInvoiceType(QEInvoice.Type.OnchainInvoice) |
|
|
|
self.canSave = True |
|
|
|
self.determine_can_pay() |
|
|
|
self.invoiceChanged.emit() |
|
self.statusChanged.emit() |
|
|
|
def determine_can_pay(self): |
|
if self.invoiceType == QEInvoice.Type.LightningInvoice: |
|
if self.status in [PR_UNPAID, PR_FAILED]: |
|
if self.get_max_spendable_lightning() >= self.amount.satsInt: |
|
self.canPay = True |
|
else: |
|
self.userinfo = _('Can\'t pay, insufficient balance') |
|
else: |
|
self.userinfo = { |
|
PR_EXPIRED: _('Can\'t pay, invoice is expired'), |
|
PR_PAID: _('Can\'t pay, invoice is already paid'), |
|
PR_INFLIGHT: _('Can\'t pay, invoice is already being paid'), |
|
PR_ROUTING: _('Can\'t pay, invoice is already being paid'), |
|
PR_UNKNOWN: _('Can\'t pay, invoice has unknown status'), |
|
}[self.status] |
|
elif self.invoiceType == QEInvoice.Type.OnchainInvoice: |
|
if self.status in [PR_UNPAID, PR_FAILED]: |
|
if self.get_max_spendable_onchain() >= self.amount.satsInt: |
|
self.canPay = True |
|
else: |
|
self.userinfo = _('Can\'t pay, insufficient balance') |
|
else: |
|
self.userinfo = { |
|
PR_EXPIRED: _('Can\'t pay, invoice is expired'), |
|
PR_PAID: _('Can\'t pay, invoice is already paid'), |
|
PR_UNCONFIRMED: _('Can\'t pay, invoice is already paid'), |
|
PR_UNKNOWN: _('Can\'t pay, invoice has unknown status'), |
|
}[self.status] |
|
|
|
|
|
def get_max_spendable_lightning(self): |
|
return self._wallet.wallet.lnworker.num_sats_can_send() |
|
|
|
def setValidAddressOnly(self): |
|
self._logger.debug('setValidAddressOnly') |
|
self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) |
|
self._effectiveInvoice = None |
|
self.invoiceChanged.emit() |
|
|
|
def setValidOnchainInvoice(self, invoice: Invoice): |
|
self._logger.debug('setValidOnchainInvoice') |
|
if invoice.is_lightning(): |
|
raise Exception('unexpected LN invoice') |
|
self.set_effective_invoice(invoice) |
|
|
|
def setValidLightningInvoice(self, invoice: Invoice): |
|
self._logger.debug('setValidLightningInvoice') |
|
if not invoice.is_lightning(): |
|
raise Exception('unexpected Onchain invoice') |
|
self.set_effective_invoice(invoice) |
|
|
|
def create_onchain_invoice(self, outputs, message, payment_request, uri): |
|
return self._wallet.wallet.create_invoice( |
|
outputs=outputs, |
|
message=message, |
|
pr=payment_request, |
|
URI=uri |
|
) |
|
|
|
def validateRecipient(self, recipient): |
|
if not recipient: |
|
self.setInvoiceType(QEInvoice.Type.Invalid) |
|
return |
|
|
|
maybe_lightning_invoice = recipient |
|
|
|
def _payment_request_resolved(request): |
|
self._logger.debug('resolved payment request') |
|
outputs = request.get_outputs() |
|
invoice = self.create_onchain_invoice(outputs, None, request, None) |
|
self.setValidOnchainInvoice(invoice) |
|
|
|
try: |
|
self._bip21 = parse_URI(recipient, _payment_request_resolved) |
|
if self._bip21: |
|
if 'r' in self._bip21 or ('name' in self._bip21 and 'sig' in self._bip21): # TODO set flag in util? |
|
# let callback handle state |
|
return |
|
if ':' not in recipient: |
|
# address only |
|
self.setValidAddressOnly() |
|
self.validationSuccess.emit() |
|
return |
|
else: |
|
# fallback lightning invoice? |
|
if 'lightning' in self._bip21: |
|
maybe_lightning_invoice = self._bip21['lightning'] |
|
except InvalidBitcoinURI as e: |
|
self._bip21 = None |
|
self._logger.debug(repr(e)) |
|
|
|
lninvoice = None |
|
maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice) |
|
if maybe_lightning_invoice is not None: |
|
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 self._bip21: |
|
self.validationError.emit('unknown',_('Unknown invoice')) |
|
self.clear() |
|
return |
|
|
|
if lninvoice: |
|
if not self._wallet.wallet.has_lightning(): |
|
if not self._bip21: |
|
# TODO: lightning onchain fallback in ln invoice |
|
#self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet')) |
|
self.setValidLightningInvoice(lninvoice) |
|
self.clear() |
|
return |
|
else: |
|
self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') |
|
self.setValidOnchainInvoice(self._bip21['address']) |
|
else: |
|
self.setValidLightningInvoice(lninvoice) |
|
if not self._wallet.wallet.lnworker.channels: |
|
self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) |
|
else: |
|
self.validationSuccess.emit() |
|
else: |
|
self._logger.debug('flow without LN but having bip21 uri') |
|
if 'amount' not in self._bip21: #TODO can we have amount-less invoices? |
|
self.validationError.emit('no_amount', 'no amount in uri') |
|
return |
|
outputs = [PartialTxOutput.from_address_and_value(self._bip21['address'], self._bip21['amount'])] |
|
self._logger.debug(outputs) |
|
message = self._bip21['message'] if 'message' in self._bip21 else '' |
|
invoice = self.create_onchain_invoice(outputs, message, None, self._bip21) |
|
self._logger.debug(repr(invoice)) |
|
self.setValidOnchainInvoice(invoice) |
|
self.validationSuccess.emit() |
|
|
|
@pyqtSlot() |
|
def save_invoice(self): |
|
self.canSave = False |
|
if not self._effectiveInvoice: |
|
return |
|
# TODO detect duplicate? |
|
self.key = self._wallet.wallet.get_key_for_outgoing_invoice(self._effectiveInvoice) |
|
self._wallet.wallet.save_invoice(self._effectiveInvoice) |
|
self.invoiceSaved.emit() |
|
|
|
|
|
class QEUserEnteredPayment(QEInvoice): |
|
_logger = get_logger(__name__) |
|
|
|
_recipient = None |
|
_message = None |
|
_amount = QEAmount() |
|
|
|
validationError = pyqtSignal([str,str], arguments=['code','message']) |
|
invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) |
|
invoiceSaved = pyqtSignal() |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent) |
|
self.clear() |
|
|
|
recipientChanged = pyqtSignal() |
|
@pyqtProperty(str, notify=recipientChanged) |
|
def recipient(self): |
|
return self._recipient |
|
|
|
@recipient.setter |
|
def recipient(self, recipient: str): |
|
if self._recipient != recipient: |
|
self._recipient = recipient |
|
self.validate() |
|
self.recipientChanged.emit() |
|
|
|
messageChanged = pyqtSignal() |
|
@pyqtProperty(str, notify=messageChanged) |
|
def message(self): |
|
return self._message |
|
|
|
@message.setter |
|
def message(self, message): |
|
if self._message != message: |
|
self._message = message |
|
self.messageChanged.emit() |
|
|
|
amountChanged = pyqtSignal() |
|
@pyqtProperty(QEAmount, notify=amountChanged) |
|
def amount(self): |
|
return self._amount |
|
|
|
@amount.setter |
|
def amount(self, amount): |
|
if self._amount != amount: |
|
self._amount = amount |
|
self.validate() |
|
self.amountChanged.emit() |
|
|
|
|
|
def validate(self): |
|
self.canPay = False |
|
self.canSave = False |
|
self._logger.debug('validate') |
|
|
|
if not self._recipient: |
|
self.validationError.emit('recipient', _('Recipient not specified.')) |
|
return |
|
|
|
if not bitcoin.is_address(self._recipient): |
|
self.validationError.emit('recipient', _('Invalid Bitcoin address')) |
|
return |
|
|
|
if self._amount.isEmpty: |
|
self.validationError.emit('amount', _('Invalid amount')) |
|
return |
|
|
|
if self._amount.isMax: |
|
self.canPay = True |
|
else: |
|
self.canSave = True |
|
if self.get_max_spendable_onchain() >= self._amount.satsInt: |
|
self.canPay = True |
|
|
|
@pyqtSlot() |
|
def save_invoice(self): |
|
assert self.canSave |
|
assert not self._amount.isMax |
|
|
|
self._logger.debug('saving invoice to %s, amount=%s, message=%s' % (self._recipient, repr(self._amount), self._message)) |
|
|
|
inv_amt = self._amount.satsInt |
|
|
|
try: |
|
outputs = [PartialTxOutput.from_address_and_value(self._recipient, inv_amt)] |
|
self._logger.debug(repr(outputs)) |
|
invoice = self._wallet.wallet.create_invoice(outputs=outputs, message=self._message, pr=None, URI=None) |
|
except InvoiceError as e: |
|
self.invoiceCreateError.emit('fatal', _('Error creating payment') + ':\n' + str(e)) |
|
return |
|
|
|
self.key = self._wallet.wallet.get_key_for_outgoing_invoice(invoice) |
|
self._wallet.wallet.save_invoice(invoice) |
|
self.invoiceSaved.emit() |
|
|
|
@pyqtSlot() |
|
def clear(self): |
|
self._recipient = None |
|
self._amount = QEAmount() |
|
self._message = None |
|
self.canSave = False |
|
self.canPay = False
|
|
|