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.
 
 
 
 

632 lines
23 KiB

import threading
from typing import TYPE_CHECKING, Optional, Dict, Any
import asyncio
from urllib.parse import urlparse
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS, QTimer
from electrum.i18n import _
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.transaction import PartialTxOutput
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 (parse_bip21_URI, InvalidBitcoinURI, maybe_extract_lightning_payment_identifier,
PaymentIdentifier, PaymentIdentifierState)
from .qetypes import QEAmount
from .qewallet import QEWallet
from .util import status_update_timer_interval, QtEventListener, event_listener
class QEInvoice(QObject, QtEventListener):
class Type:
Invalid = -1
OnchainInvoice = 0
LightningInvoice = 1
LNURLPayRequest = 2
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__)
invoiceChanged = pyqtSignal()
invoiceSaved = pyqtSignal([str], arguments=['key'])
amountOverrideChanged = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._isSaved = False
self._canSave = False
self._canPay = False
self._key = None
self._invoiceType = QEInvoice.Type.Invalid
self._effectiveInvoice = None
self._userinfo = ''
self._lnprops = {}
self._amount = QEAmount()
self._amountOverride = QEAmount()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.updateStatusString)
self._amountOverride.valueChanged.connect(self._on_amountoverride_value_changed)
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
def on_destroy(self):
self.unregister_callbacks()
@event_listener
def on_event_payment_succeeded(self, wallet, key):
if wallet == self._wallet.wallet and key == self.key:
self.statusChanged.emit()
self.determine_can_pay()
self.userinfo = _('Paid!')
@event_listener
def on_event_payment_failed(self, wallet, key, reason):
if wallet == self._wallet.wallet and key == self.key:
self.statusChanged.emit()
self.determine_can_pay()
self.userinfo = _('Payment failed: ') + reason
@event_listener
def on_event_invoice_status(self, wallet, key, status):
if self._wallet and wallet == self._wallet.wallet and key == self.key:
self.update_userinfo()
self.determine_can_pay()
self.statusChanged.emit()
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()
@pyqtProperty(int, notify=invoiceChanged)
def invoiceType(self):
return self._invoiceType
# not a qt setter, don't let outside set state
def setInvoiceType(self, invoiceType: Type):
self._invoiceType = invoiceType
@pyqtProperty(str, notify=invoiceChanged)
def message(self):
return self._effectiveInvoice.message if self._effectiveInvoice else ''
@pyqtProperty('quint64', notify=invoiceChanged)
def time(self):
return self._effectiveInvoice.time if self._effectiveInvoice else 0
@pyqtProperty('quint64', notify=invoiceChanged)
def expiration(self):
return self._effectiveInvoice.exp if self._effectiveInvoice else 0
@pyqtProperty(str, notify=invoiceChanged)
def address(self):
return self._effectiveInvoice.get_address() if self._effectiveInvoice else ''
@pyqtProperty(QEAmount, notify=invoiceChanged)
def amount(self):
if not self._effectiveInvoice:
self._amount.clear()
return self._amount
self._amount.copyFrom(QEAmount(from_invoice=self._effectiveInvoice))
return self._amount
@pyqtProperty(QEAmount, notify=amountOverrideChanged)
def amountOverride(self):
return self._amountOverride
@amountOverride.setter
def amountOverride(self, new_amount):
self._logger.debug(f'set new override amount {repr(new_amount)}')
self._amountOverride.copyFrom(new_amount)
self.amountOverrideChanged.emit()
@pyqtSlot()
def _on_amountoverride_value_changed(self):
self.update_userinfo()
self.determine_can_pay()
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 statusString(self):
if not self._effectiveInvoice:
return ''
status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice)
return self._effectiveInvoice.get_status_str(status)
isSavedChanged = pyqtSignal()
@pyqtProperty(bool, notify=isSavedChanged)
def isSaved(self):
return self._isSaved
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
if self._effectiveInvoice and self._effectiveInvoice.get_id() == key:
return
invoice = self._wallet.wallet.get_invoice(key)
self._logger.debug(f'invoice from key {key}: {repr(invoice)}')
self.set_effective_invoice(invoice)
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()
@pyqtProperty('QVariantMap', notify=invoiceChanged)
def lnprops(self):
return self._lnprops
def set_lnprops(self):
self._lnprops = {}
if not self.invoiceType == QEInvoice.Type.LightningInvoice:
return
lnaddr = self._effectiveInvoice._lnaddr
ln_routing_info = lnaddr.get_routing_info('r')
self._logger.debug(str(ln_routing_info))
self._lnprops = {
'pubkey': lnaddr.pubkey.serialize().hex(),
'payment_hash': lnaddr.paymenthash.hex(),
'r': [{
'node': self.name_for_node_id(x[-1][0]),
'scid': format_short_channel_id(x[-1][1])
} for x in ln_routing_info] if ln_routing_info else []
}
def name_for_node_id(self, node_id):
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
if invoice is None:
self.setInvoiceType(QEInvoice.Type.Invalid)
else:
if invoice.is_lightning():
self.setInvoiceType(QEInvoice.Type.LightningInvoice)
else:
self.setInvoiceType(QEInvoice.Type.OnchainInvoice)
self._isSaved = self._wallet.wallet.get_invoice(invoice.get_id()) is not None
self.set_lnprops()
self.update_userinfo()
self.determine_can_pay()
self.invoiceChanged.emit()
self.statusChanged.emit()
self.isSavedChanged.emit()
self.set_status_timer()
def set_status_timer(self):
if self.status != PR_EXPIRED:
if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER:
interval = status_update_timer_interval(self.time + self.expiration)
if interval > 0:
self._timer.setInterval(interval) # msec
self._timer.start()
else:
self.update_userinfo()
self.determine_can_pay() # status went to PR_EXPIRED
@pyqtSlot()
def updateStatusString(self):
self.statusChanged.emit()
self.set_status_timer()
def update_userinfo(self):
self.userinfo = ''
if not self.amountOverride.isEmpty:
amount = self.amountOverride
else:
amount = self.amount
if self.amount.isEmpty:
self.userinfo = _('Enter the amount you want to send')
if amount.isEmpty and self.status == PR_UNPAID: # unspecified amount
return
if self.invoiceType == QEInvoice.Type.LightningInvoice:
if self.status in [PR_UNPAID, PR_FAILED]:
if self.get_max_spendable_lightning() >= amount.satsInt:
lnaddr = self._effectiveInvoice._lnaddr
if lnaddr.amount and amount.satsInt < lnaddr.amount * COIN:
self.userinfo = _('Cannot pay less than the amount specified in the invoice')
elif self.address and self.get_max_spendable_onchain() < amount.satsInt:
# TODO: validate address?
# TODO: subtract fee?
self.userinfo = _('Insufficient balance')
else:
self.userinfo = {
PR_EXPIRED: _('This invoice has expired'),
PR_PAID: _('This invoice was already paid'),
PR_INFLIGHT: _('Payment in progress...'),
PR_ROUTING: _('Payment in progress'),
PR_UNKNOWN: _('Invoice has unknown status'),
}[self.status]
elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
if self.status in [PR_UNPAID, PR_FAILED]:
if not ((amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt)):
self.userinfo = _('Insufficient balance')
else:
self.userinfo = {
PR_EXPIRED: _('This invoice has expired'),
PR_PAID: _('This invoice was already paid'),
PR_BROADCASTING: _('Payment in progress...') + ' (' + _('broadcasting') + ')',
PR_BROADCAST: _('Payment in progress...') + ' (' + _('broadcast successfully') + ')',
PR_UNCONFIRMED: _('Payment in progress...') + ' (' + _('waiting for confirmation') + ')',
PR_UNKNOWN: _('Invoice has unknown status'),
}[self.status]
def determine_can_pay(self):
self.canPay = False
self.canSave = False
if not self.amountOverride.isEmpty:
amount = self.amountOverride
else:
amount = self.amount
self.canSave = True
if amount.isEmpty and self.status == PR_UNPAID: # unspecified amount
return
if self.invoiceType == QEInvoice.Type.LightningInvoice:
if self.status in [PR_UNPAID, PR_FAILED]:
if self.get_max_spendable_lightning() >= amount.satsInt:
lnaddr = self._effectiveInvoice._lnaddr
if not (lnaddr.amount and amount.satsInt < lnaddr.amount * COIN):
self.canPay = True
elif self.address and self.get_max_spendable_onchain() > amount.satsInt:
# TODO: validate address?
# TODO: subtract fee?
self.canPay = True
elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
if self.status in [PR_UNPAID, PR_FAILED]:
if amount.isMax and self.get_max_spendable_onchain() > 0:
# TODO: dust limit?
self.canPay = True
elif self.get_max_spendable_onchain() >= amount.satsInt:
# TODO: subtract fee?
self.canPay = True
@pyqtSlot()
def payLightningInvoice(self):
if not self.canPay:
raise Exception('can not pay invoice, canPay is false')
if self.invoiceType != QEInvoice.Type.LightningInvoice:
raise Exception('payLightningInvoice can only pay lightning invoices')
if self.amount.isEmpty:
if self.amountOverride.isEmpty:
raise Exception('can not pay 0 amount')
# TODO: is update amount_msat for overrideAmount sufficient?
self._effectiveInvoice.amount_msat = self.amountOverride.satsInt * 1000
self._wallet.pay_lightning_invoice(self._effectiveInvoice)
def get_max_spendable_onchain(self):
spendable = self._wallet.confirmedBalance.satsInt
if not self._wallet.wallet.config.WALLET_SPEND_CONFIRMED_ONLY:
spendable += self._wallet.unconfirmedBalance.satsInt
return spendable
def get_max_spendable_lightning(self):
return self._wallet.wallet.lnworker.num_sats_can_send() if self._wallet.wallet.lnworker else 0
class QEInvoiceParser(QEInvoice):
_logger = get_logger(__name__)
validationSuccess = pyqtSignal()
validationWarning = pyqtSignal([str,str], arguments=['code', 'message'])
validationError = pyqtSignal([str,str], arguments=['code', 'message'])
invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message'])
lnurlRetrieved = pyqtSignal()
lnurlError = pyqtSignal([str,str], arguments=['code', 'message'])
def __init__(self, parent=None):
super().__init__(parent)
self._recipient = ''
self._pi = None
self.clear()
recipientChanged = pyqtSignal()
@pyqtProperty(str, notify=recipientChanged)
def recipient(self):
return self._recipient
@recipient.setter
def recipient(self, recipient: str):
self.canPay = False
self._recipient = recipient
self.amountOverride = QEAmount()
if recipient:
self.validateRecipient(recipient)
self.recipientChanged.emit()
@pyqtProperty('QVariantMap', notify=lnurlRetrieved)
def lnurlData(self):
return self._lnurlData
@pyqtProperty(bool, notify=lnurlRetrieved)
def isLnurlPay(self):
return self._lnurlData is not None
@pyqtSlot()
def clear(self):
self.recipient = ''
self.setInvoiceType(QEInvoice.Type.Invalid)
self._lnurlData = None
self.canSave = False
self.canPay = False
self.userinfo = ''
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 setValidLNURLPayRequest(self):
self._logger.debug('setValidLNURLPayRequest')
self.setInvoiceType(QEInvoice.Type.LNURLPayRequest)
self._effectiveInvoice = None
self.invoiceChanged.emit()
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 _bip70_payment_request_resolved(self, pr: 'PaymentRequest'):
self._logger.debug('resolved payment request')
if pr.verify(self._wallet.wallet.contacts):
invoice = Invoice.from_bip70_payreq(pr, height=0)
if self._wallet.wallet.get_invoice_status(invoice) == PR_PAID:
self.validationError.emit('unknown', _('Invoice already paid'))
elif pr.has_expired():
self.validationError.emit('unknown', _('Payment request has expired'))
else:
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
else:
self.validationError.emit('unknown', f"invoice error:\n{pr.error}")
def validateRecipient(self, recipient):
if not recipient:
self.setInvoiceType(QEInvoice.Type.Invalid)
return
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
if not self._pi.is_valid() or self._pi.type not in ['spk', 'bip21', 'bip70', 'bolt11', 'lnurl']:
self.validationError.emit('unknown', _('Unknown invoice'))
return
self._update_from_payment_identifier()
def _update_from_payment_identifier(self):
if self._pi.need_resolve():
self.resolve_pi()
return
if self._pi.type == 'lnurl':
self.on_lnurl(self._pi.lnurl_data)
return
if self._pi.type == 'bip70':
self._bip70_payment_request_resolved(self._pi.bip70_data)
return
if self._pi.is_available():
if self._pi.type == '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 == 'bolt11':
lninvoice = Invoice.from_bech32(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 == 'bip21':
if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11:
lninvoice = Invoice.from_bech32(self._pi.bolt11)
self.setValidLightningInvoice(lninvoice)
self.validationSuccess.emit()
else:
self._validateRecipient_bip21_onchain(self._pi.bip21)
def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None:
if 'amount' not in bip21:
amount = 0
else:
amount = bip21['amount']
outputs = [PartialTxOutput.from_address_and_value(bip21['address'], amount)]
self._logger.debug(outputs)
message = bip21['message'] if 'message' in bip21 else ''
invoice = self.create_onchain_invoice(outputs, message, None, bip21)
self._logger.debug(repr(invoice))
self.setValidOnchainInvoice(invoice)
self.validationSuccess.emit()
def resolve_pi(self):
assert self._pi.need_resolve()
def on_finished(pi):
if pi.is_error():
pass
else:
self._update_from_payment_identifier()
self._pi.resolve(on_finished=on_finished)
def on_lnurl(self, lnurldata):
self._logger.debug('on_lnurl')
self._logger.debug(f'{repr(lnurldata)}')
self._lnurlData = {
'domain': urlparse(lnurldata.callback_url).netloc,
'callback_url' : lnurldata.callback_url,
'min_sendable_sat': lnurldata.min_sendable_sat,
'max_sendable_sat': lnurldata.max_sendable_sat,
'metadata_plaintext': lnurldata.metadata_plaintext,
'comment_allowed': lnurldata.comment_allowed
}
self.setValidLNURLPayRequest()
self.lnurlRetrieved.emit()
@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['comment_allowed'] == 0:
comment = None
def on_finished(pi):
if pi.is_error():
if pi.is_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)
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
@pyqtSlot()
def saveInvoice(self):
if not self._effectiveInvoice:
return
if self.isSaved:
return
if not self._effectiveInvoice.amount_msat and not self.amountOverride.isEmpty:
if self.invoiceType == QEInvoice.Type.OnchainInvoice and self.amountOverride.isMax:
self._effectiveInvoice.amount_msat = '!'
else:
self._effectiveInvoice.amount_msat = self.amountOverride.satsInt * 1000
self.canSave = False
self._wallet.wallet.save_invoice(self._effectiveInvoice)
self.key = self._effectiveInvoice.get_id()
self._wallet.invoiceModel.addInvoice(self.key)
self.invoiceSaved.emit(self.key)