1
0
Files
electrum/electrum/gui/qml/qewallet.py
Sander van Grieken 0bc8460005 qml: don't initialize instance variables on class scope for non-singletons
(this somehow escaped attention before, as most objects usually don't have multiple instances,
unless multiple wallets are open at the same time.)
Also, move all signal declarations, class constants and variables to the top of class definitions.
2023-01-12 13:09:21 +01:00

720 lines
28 KiB
Python

import asyncio
import queue
import threading
import time
from typing import Optional, TYPE_CHECKING
from functools import partial
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
from electrum import bitcoin
from electrum.i18n import _
from electrum.invoices import InvoiceError, PR_DEFAULT_EXPIRATION_WHEN_CREATING, PR_PAID
from electrum.logging import get_logger
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.transaction import PartialTxOutput
from electrum.util import (parse_max_spend, InvalidPassword, event_listener)
from electrum.plugin import run_hook
from electrum.wallet import Multisig_Wallet
from electrum.crypto import pw_decode_with_version_and_mac
from .auth import AuthMixin, auth_protect
from .qeaddresslistmodel import QEAddressListModel
from .qechannellistmodel import QEChannelListModel
from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel
from .qetransactionlistmodel import QETransactionListModel
from .qetypes import QEAmount
from .util import QtEventListener, qt_event_listener
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
class QEWallet(AuthMixin, QObject, QtEventListener):
__instances = []
# this factory method should be used to instantiate QEWallet
# so we have only one QEWallet for each electrum.wallet
@classmethod
def getInstanceFor(cls, wallet):
for i in cls.__instances:
if i.wallet == wallet:
return i
i = QEWallet(wallet)
cls.__instances.append(i)
return i
_logger = get_logger(__name__)
# emitted when wallet wants to display a user notification
# actual presentation should be handled on app or window level
userNotify = pyqtSignal(object, object)
# shared signal for many static wallet properties
dataChanged = pyqtSignal()
isUptodateChanged = pyqtSignal()
requestStatusChanged = pyqtSignal([str,int], arguments=['key','status'])
requestCreateSuccess = pyqtSignal([str], arguments=['key'])
requestCreateError = pyqtSignal([str,str], arguments=['code','error'])
invoiceStatusChanged = pyqtSignal([str,int], arguments=['key','status'])
invoiceCreateSuccess = pyqtSignal()
invoiceCreateError = pyqtSignal([str,str], arguments=['code','error'])
paymentSucceeded = pyqtSignal([str], arguments=['key'])
paymentFailed = pyqtSignal([str,str], arguments=['key','reason'])
requestNewPassword = pyqtSignal()
transactionSigned = pyqtSignal([str], arguments=['txid'])
broadcastSucceeded = pyqtSignal([str], arguments=['txid'])
broadcastFailed = pyqtSignal([str,str,str], arguments=['txid','code','reason'])
importChannelBackupFailed = pyqtSignal([str], arguments=['message'])
labelsUpdated = pyqtSignal()
otpRequested = pyqtSignal()
otpSuccess = pyqtSignal()
otpFailed = pyqtSignal([str,str], arguments=['code','message'])
_network_signal = pyqtSignal(str, object)
def __init__(self, wallet: 'Abstract_Wallet', parent=None):
super().__init__(parent)
self.wallet = wallet
self._isUpToDate = False
self._synchronizing = False
self._synchronizing_progress = ''
self._historyModel = None
self._addressModel = None
self._requestModel = None
self._invoiceModel = None
self._channelModel = None
self._lightningbalance = QEAmount()
self._confirmedbalance = QEAmount()
self._unconfirmedbalance = QEAmount()
self._frozenbalance = QEAmount()
self._totalbalance = QEAmount()
self._lightningcanreceive = QEAmount()
self._lightningcansend = QEAmount()
self.tx_notification_queue = queue.Queue()
self.tx_notification_last_time = 0
self.notification_timer = QTimer(self)
self.notification_timer.setSingleShot(False)
self.notification_timer.setInterval(500) # msec
self.notification_timer.timeout.connect(self.notify_transactions)
# To avoid leaking references to "self" that prevent the
# window from being GC-ed when closed, callbacks should be
# methods of this class only, and specifically not be
# partials, lambdas or methods of subobjects. Hence...
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
@pyqtProperty(bool, notify=isUptodateChanged)
def isUptodate(self):
return self._isUpToDate
synchronizingChanged = pyqtSignal()
@pyqtProperty(bool, notify=synchronizingChanged)
def synchronizing(self):
return self._synchronizing
@synchronizing.setter
def synchronizing(self, synchronizing):
if self._synchronizing != synchronizing:
self._synchronizing = synchronizing
self.synchronizingChanged.emit()
synchronizingProgressChanged = pyqtSignal()
@pyqtProperty(str, notify=synchronizingProgressChanged)
def synchronizing_progress(self):
return self._synchronizing_progress
@synchronizing_progress.setter
def synchronizing_progress(self, progress):
if self._synchronizing_progress != progress:
self._synchronizing_progress = progress
self.synchronizingProgressChanged.emit()
@event_listener
def on_event_status(self):
self._logger.debug('status')
uptodate = self.wallet.is_up_to_date()
if self._isUpToDate != uptodate:
self._isUpToDate = uptodate
self.isUptodateChanged.emit()
if self.wallet.network.is_connected():
server_height = self.wallet.network.get_server_height()
server_lag = self.wallet.network.get_local_height() - server_height
# Server height can be 0 after switching to a new server
# until we get a headers subscription request response.
# Display the synchronizing message in that case.
if not self._isUpToDate or server_height == 0:
num_sent, num_answered = self.wallet.adb.get_history_sync_state_details()
self.synchronizing_progress = ("{} ({}/{})"
.format(_("Synchronizing..."), num_answered, num_sent))
self.synchronizing = True
else:
self.synchronizing_progress = ''
self.synchronizing = False
@qt_event_listener
def on_event_request_status(self, wallet, key, status):
if wallet == self.wallet:
self._logger.debug('request status %d for key %s' % (status, key))
self.requestStatusChanged.emit(key, status)
if status == PR_PAID:
# might be new incoming LN payment, update history
# TODO: only update if it was paid over lightning,
# and even then, we can probably just add the payment instead
# of recreating the whole history (expensive)
self.historyModel.init_model()
@event_listener
def on_event_invoice_status(self, wallet, key, status):
if wallet == self.wallet:
self._logger.debug(f'invoice status update for key {key} to {status}')
self.invoiceStatusChanged.emit(key, status)
@qt_event_listener
def on_event_new_transaction(self, wallet, tx):
if wallet == self.wallet:
self._logger.info(f'new transaction {tx.txid()}')
self.add_tx_notification(tx)
self.addressModel.setDirty()
self.historyModel.init_model() # TODO: be less dramatic
@event_listener
def on_event_wallet_updated(self, wallet):
if wallet == self.wallet:
self._logger.debug('wallet %s updated' % str(wallet))
self.balanceChanged.emit()
@event_listener
def on_event_channel(self, wallet, channel):
if wallet == self.wallet:
self.balanceChanged.emit()
@event_listener
def on_event_channels_updated(self, wallet):
if wallet == self.wallet:
self.balanceChanged.emit()
@qt_event_listener
def on_event_payment_succeeded(self, wallet, key):
if wallet == self.wallet:
self.paymentSucceeded.emit(key)
self.historyModel.init_model() # TODO: be less dramatic
@event_listener
def on_event_payment_failed(self, wallet, key, reason):
if wallet == self.wallet:
self.paymentFailed.emit(key, reason)
def on_destroy(self):
self.unregister_callbacks()
def add_tx_notification(self, tx):
self._logger.debug('new transaction event')
self.tx_notification_queue.put(tx)
if not self.notification_timer.isActive():
self._logger.debug('starting wallet notification timer')
self.notification_timer.start()
def notify_transactions(self):
if self.tx_notification_queue.qsize() == 0:
self._logger.debug('queue empty, stopping wallet notification timer')
self.notification_timer.stop()
return
if not self.wallet.is_up_to_date():
return # no notifications while syncing
now = time.time()
rate_limit = 20 # seconds
if self.tx_notification_last_time + rate_limit > now:
return
self.tx_notification_last_time = now
self._logger.info("Notifying app about new transactions")
txns = []
while True:
try:
txns.append(self.tx_notification_queue.get_nowait())
except queue.Empty:
break
config = self.wallet.config
# Combine the transactions if there are at least three
if len(txns) >= 3:
total_amount = 0
for tx in txns:
tx_wallet_delta = self.wallet.get_wallet_delta(tx)
if not tx_wallet_delta.is_relevant:
continue
total_amount += tx_wallet_delta.delta
self.userNotify.emit(self.wallet, _("{} new transactions: Total amount received in the new transactions {}").format(len(txns), config.format_amount_and_units(total_amount)))
else:
for tx in txns:
tx_wallet_delta = self.wallet.get_wallet_delta(tx)
if not tx_wallet_delta.is_relevant:
continue
self.userNotify.emit(self.wallet,
_("New transaction: {}").format(config.format_amount_and_units(tx_wallet_delta.delta)))
historyModelChanged = pyqtSignal()
@pyqtProperty(QETransactionListModel, notify=historyModelChanged)
def historyModel(self):
if self._historyModel is None:
self._historyModel = QETransactionListModel(self.wallet)
return self._historyModel
addressModelChanged = pyqtSignal()
@pyqtProperty(QEAddressListModel, notify=addressModelChanged)
def addressModel(self):
if self._addressModel is None:
self._addressModel = QEAddressListModel(self.wallet)
return self._addressModel
requestModelChanged = pyqtSignal()
@pyqtProperty(QERequestListModel, notify=requestModelChanged)
def requestModel(self):
if self._requestModel is None:
self._requestModel = QERequestListModel(self.wallet)
return self._requestModel
invoiceModelChanged = pyqtSignal()
@pyqtProperty(QEInvoiceListModel, notify=invoiceModelChanged)
def invoiceModel(self):
if self._invoiceModel is None:
self._invoiceModel = QEInvoiceListModel(self.wallet)
return self._invoiceModel
channelModelChanged = pyqtSignal()
@pyqtProperty(QEChannelListModel, notify=channelModelChanged)
def channelModel(self):
if self._channelModel is None:
self._channelModel = QEChannelListModel(self.wallet)
return self._channelModel
nameChanged = pyqtSignal()
@pyqtProperty(str, notify=nameChanged)
def name(self):
return self.wallet.basename()
isLightningChanged = pyqtSignal()
@pyqtProperty(bool, notify=isLightningChanged)
def isLightning(self):
return bool(self.wallet.lnworker)
billingInfoChanged = pyqtSignal()
@pyqtProperty('QVariantMap', notify=billingInfoChanged)
def billingInfo(self):
return {} if self.wallet.wallet_type != '2fa' else self.wallet.billing_info
@pyqtProperty(bool, notify=dataChanged)
def canHaveLightning(self):
return self.wallet.can_have_lightning()
@pyqtProperty(str, notify=dataChanged)
def walletType(self):
return self.wallet.wallet_type
@pyqtProperty(bool, notify=dataChanged)
def hasSeed(self):
return self.wallet.has_seed()
@pyqtProperty(str, notify=dataChanged)
def seed(self):
try:
return self.wallet.get_seed(self.password)
except:
return ''
@pyqtProperty(str, notify=dataChanged)
def txinType(self):
if self.wallet.wallet_type == 'imported':
return self.wallet.txin_type
return self.wallet.get_txin_type(self.wallet.dummy_address())
@pyqtProperty(bool, notify=dataChanged)
def isWatchOnly(self):
return self.wallet.is_watching_only()
@pyqtProperty(bool, notify=dataChanged)
def isDeterministic(self):
return self.wallet.is_deterministic()
@pyqtProperty(bool, notify=dataChanged)
def isEncrypted(self):
return self.wallet.storage.is_encrypted()
@pyqtProperty(bool, notify=dataChanged)
def isHardware(self):
return self.wallet.storage.is_encrypted_with_hw_device()
@pyqtProperty('QVariantList', notify=dataChanged)
def keystores(self):
result = []
for k in self.wallet.get_keystores():
result.append({
'derivation_prefix': k.get_derivation_prefix() or '',
'master_pubkey': k.get_master_public_key() or '',
'fingerprint': k.get_root_fingerprint() or '',
'watch_only': k.is_watching_only()
})
return result
@pyqtProperty(str, notify=dataChanged)
def lightningNodePubkey(self):
return self.wallet.lnworker.node_keypair.pubkey.hex() if self.wallet.lnworker else ''
@pyqtProperty(str, notify=dataChanged)
def derivationPrefix(self):
keystores = self.wallet.get_keystores()
if len(keystores) > 1:
self._logger.debug('multiple keystores not supported yet')
if len(keystores) == 0:
self._logger.debug('no keystore')
return ''
if not self.isDeterministic:
return ''
return keystores[0].get_derivation_prefix()
@pyqtProperty(str, notify=dataChanged)
def masterPubkey(self):
return self.wallet.get_master_public_key()
@pyqtProperty(bool, notify=dataChanged)
def canSignWithoutServer(self):
return self.wallet.can_sign_without_server() if self.wallet.wallet_type == '2fa' else True
@pyqtProperty(bool, notify=dataChanged)
def canSignWithoutCosigner(self):
if isinstance(self.wallet, Multisig_Wallet):
if self.wallet.wallet_type == '2fa': # 2fa is multisig, but it handles cosigning itself
return True
return self.wallet.m == 1
return True
balanceChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=balanceChanged)
def frozenBalance(self):
c, u, x = self.wallet.get_frozen_balance()
self._frozenbalance.satsInt = c+x
return self._frozenbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def unconfirmedBalance(self):
self._unconfirmedbalance.satsInt = self.wallet.get_balance()[1]
return self._unconfirmedbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def confirmedBalance(self):
c, u, x = self.wallet.get_balance()
self._confirmedbalance.satsInt = c+x
return self._confirmedbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningBalance(self):
if self.isLightning:
self._lightningbalance.satsInt = int(self.wallet.lnworker.get_balance())
# else:
# self._lightningbalance.satsInt = 0
return self._lightningbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def totalBalance(self):
total = self.confirmedBalance.satsInt + self.lightningBalance.satsInt
self._totalbalance.satsInt = total
return self._totalbalance
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningCanSend(self):
if self.isLightning:
self._lightningcansend.satsInt = int(self.wallet.lnworker.num_sats_can_send())
return self._lightningcansend
@pyqtProperty(QEAmount, notify=balanceChanged)
def lightningCanReceive(self):
if self.isLightning:
self._lightningcanreceive.satsInt = int(self.wallet.lnworker.num_sats_can_receive())
return self._lightningcanreceive
@pyqtSlot()
def enableLightning(self):
self.wallet.init_lightning(password=None) # TODO pass password if needed
self.isLightningChanged.emit()
@pyqtSlot(str, int, int, bool)
def send_onchain(self, address, amount, fee=None, rbf=False):
self._logger.info('send_onchain: %s %d' % (address,amount))
coins = self.wallet.get_spendable_coins(None)
if not bitcoin.is_address(address):
self._logger.warning('Invalid Bitcoin Address: ' + address)
return False
outputs = [PartialTxOutput.from_address_and_value(address, amount)]
self._logger.info(str(outputs))
output_values = [x.value for x in outputs]
if any(parse_max_spend(outval) for outval in output_values):
output_value = '!'
else:
output_value = sum(output_values)
self._logger.info(str(output_value))
# see qt/confirm_tx_dialog qt/main_window
tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None)
self._logger.info(str(tx.to_json()))
tx.set_rbf(True)
self.sign(tx, broadcast=True)
@auth_protect
def sign(self, tx, *, broadcast: bool = False):
sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, partial(self.on_sign_complete, broadcast),
self.on_sign_failed)
if sign_hook:
self.do_sign(tx, False)
self._logger.debug('plugin needs to sign tx too')
sign_hook(tx)
return
self.do_sign(tx, broadcast)
def do_sign(self, tx, broadcast):
tx = self.wallet.sign_transaction(tx, self.password)
if tx is None:
self._logger.info('did not sign')
return
txid = tx.txid()
self._logger.debug(f'do_sign(), txid={txid}')
self.transactionSigned.emit(txid)
if not tx.is_complete():
self._logger.debug('tx not complete')
return
if broadcast:
self.broadcast(tx)
# this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
def on_sign_complete(self, broadcast, tx):
self.otpSuccess.emit()
if broadcast:
self.broadcast(tx)
def on_sign_failed(self, error):
self.otpFailed.emit('error', error)
def request_otp(self, on_submit):
self._otp_on_submit = on_submit
self.otpRequested.emit()
@pyqtSlot(str)
def submitOtp(self, otp):
self._otp_on_submit(otp)
def broadcast(self, tx):
assert tx.is_complete()
def broadcast_thread():
try:
self._logger.info('running broadcast in thread')
self.wallet.network.run_from_another_thread(self.wallet.network.broadcast_transaction(tx))
except TxBroadcastError as e:
self._logger.error(repr(e))
self.broadcastFailed.emit(tx.txid(),'',repr(e))
except BestEffortRequestFailed as e:
self._logger.error(repr(e))
self.broadcastFailed.emit(tx.txid(),'',repr(e))
else:
self._logger.info('broadcast success')
self.broadcastSucceeded.emit(tx.txid())
self.historyModel.requestRefresh.emit() # via qt thread
threading.Thread(target=broadcast_thread).start()
#TODO: properly catch server side errors, e.g. bad-txns-inputs-missingorspent
paymentAuthRejected = pyqtSignal()
def ln_auth_rejected(self):
self.paymentAuthRejected.emit()
@pyqtSlot(str)
@auth_protect(reject='ln_auth_rejected')
def pay_lightning_invoice(self, invoice_key):
self._logger.debug('about to pay LN')
invoice = self.wallet.get_invoice(invoice_key)
assert(invoice)
assert(invoice.lightning_invoice)
amount_msat = invoice.get_amount_msat()
def pay_thread():
try:
coro = self.wallet.lnworker.pay_invoice(invoice.lightning_invoice, amount_msat=amount_msat)
fut = asyncio.run_coroutine_threadsafe(coro, self.wallet.network.asyncio_loop)
fut.result()
except Exception as e:
self.paymentFailed.emit(invoice.get_id(), repr(e))
threading.Thread(target=pay_thread).start()
def create_bitcoin_request(self, amount: int, message: str, expiration: int, ignore_gap: bool) -> Optional[str]:
addr = self.wallet.get_unused_address()
if addr is None:
if not self.wallet.is_deterministic(): # imported wallet
# TODO implement
return
#msg = [
#_('No more addresses in your wallet.'), ' ',
#_('You are using a non-deterministic wallet, which cannot create new addresses.'), ' ',
#_('If you want to create new addresses, use a deterministic wallet instead.'), '\n\n',
#_('Creating a new payment request will reuse one of your addresses and overwrite an existing request. Continue anyway?'),
#]
#if not self.question(''.join(msg)):
#return
#addr = self.wallet.get_receiving_address()
else: # deterministic wallet
if not ignore_gap:
self.requestCreateError.emit('gaplimit',_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?"))
return
addr = self.wallet.create_new_address(False)
req_key = self.wallet.create_request(amount, message, expiration, addr)
#try:
#self.wallet.add_payment_request(req)
#except Exception as e:
#self.logger.exception('Error adding payment request')
#self.requestCreateError.emit('fatal',_('Error adding payment request') + ':\n' + repr(e))
#else:
## TODO: check this flow. Only if alias is defined in config. OpenAlias?
#pass
##self.sign_payment_request(addr)
return req_key, addr
@pyqtSlot(QEAmount, str, int)
@pyqtSlot(QEAmount, str, int, bool)
@pyqtSlot(QEAmount, str, int, bool, bool)
def createRequest(self, amount: QEAmount, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False):
# TODO: unify this method and create_bitcoin_request
try:
if is_lightning:
if not self.wallet.lnworker.channels:
self.requestCreateError.emit('fatal',_("You need to open a Lightning channel first."))
return
# TODO maybe show a warning if amount exceeds lnworker.num_sats_can_receive (as in kivy)
# TODO fallback address robustness
addr = self.wallet.get_unused_address()
key = self.wallet.create_request(amount.satsInt, message, expiration, addr)
else:
key, addr = self.create_bitcoin_request(amount.satsInt, message, expiration, ignore_gap)
if not key:
return
self.addressModel.init_model()
except InvoiceError as e:
self.requestCreateError.emit('fatal',_('Error creating payment request') + ':\n' + str(e))
return
assert key is not None
self.requestModel.add_invoice(self.wallet.get_request(key))
self.requestCreateSuccess.emit(key)
@pyqtSlot()
@pyqtSlot(bool)
def createDefaultRequest(self, ignore_gap: bool = False):
try:
default_expiry = self.wallet.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING)
if self.wallet.lnworker and self.wallet.lnworker.channels:
addr = self.wallet.get_unused_address()
# if addr is None, we ran out of addresses
if addr is None:
# TODO: remove oldest unpaid request having a fallback address and try again
pass
key = self.wallet.create_request(None, None, default_expiry, addr)
else:
key, addr = self.create_bitcoin_request(None, None, default_expiry, ignore_gap)
if not key:
return
except InvoiceError as e:
self.requestCreateError.emit('fatal',_('Error creating payment request') + ':\n' + str(e))
return
assert key is not None
self.requestModel.add_invoice(self.wallet.get_request(key))
self.requestCreateSuccess.emit(key)
@pyqtSlot(str)
def delete_request(self, key: str):
self._logger.debug('delete req %s' % key)
self.wallet.delete_request(key)
self.requestModel.delete_invoice(key)
@pyqtSlot(str, result='QVariant')
def get_request(self, key: str):
return self.requestModel.get_model_invoice(key)
@pyqtSlot(str)
def delete_invoice(self, key: str):
self._logger.debug('delete inv %s' % key)
self.wallet.delete_invoice(key)
self.invoiceModel.delete_invoice(key)
@pyqtSlot(str, result='QVariant')
def get_invoice(self, key: str):
return self.invoiceModel.get_model_invoice(key)
@pyqtSlot(str, result=bool)
def verify_password(self, password):
try:
self.wallet.storage.check_password(password)
return True
except InvalidPassword as e:
return False
@pyqtSlot(str)
def set_password(self, password):
storage = self.wallet.storage
# HW wallet not supported yet
if storage.is_encrypted_with_hw_device():
return
try:
self.wallet.update_password(self.password, password, encrypt_storage=True)
self.password = password
except InvalidPassword as e:
self._logger.exception(repr(e))
@pyqtSlot(str)
def importAddresses(self, addresslist):
self.wallet.import_addresses(addresslist.split())
@pyqtSlot(str)
def importPrivateKeys(self, keyslist):
self.wallet.import_private_keys(keyslist.split(), self.password)
@pyqtSlot(str)
def importChannelBackup(self, backup_str):
try:
self.wallet.lnworker.import_channel_backup(backup_str)
except Exception as e:
self._logger.debug(f'could not import channel backup: {repr(e)}')
self.importChannelBackupFailed.emit(f'Failed to import backup:\n\n{str(e)}')
@pyqtSlot(str, result=bool)
def isValidChannelBackup(self, backup_str):
try:
assert backup_str.startswith('channel_backup:')
encrypted = backup_str[15:]
xpub = self.wallet.get_fingerprint()
decrypted = pw_decode_with_version_and_mac(encrypted, xpub)
return True
except Exception as e:
return False