Files
purple-electrumwallet/electrum/gui/qml/qedaemon.py
T

466 lines
18 KiB
Python
Raw Normal View History

2023-09-25 15:35:47 +02:00
import base64
2021-04-06 14:13:51 +02:00
import os
2023-02-22 14:17:57 +01:00
import threading
2023-08-14 16:24:48 +02:00
from typing import TYPE_CHECKING
2021-04-06 14:13:51 +02:00
2023-07-14 13:51:08 +02:00
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
2021-04-06 14:13:51 +02:00
from electrum.i18n import _
2021-04-06 14:13:51 +02:00
from electrum.logging import get_logger
from electrum.util import WalletFileException, standardize_path, InvalidPassword, send_exception_to_crash_reporter
from electrum.plugin import run_hook
from electrum.lnchannel import ChannelState
2023-09-25 15:35:47 +02:00
from electrum.bitcoin import is_address
2024-06-17 11:20:37 +02:00
from electrum.bitcoin import verify_usermessage_with_address
from electrum.storage import StorageReadWriteError
2021-04-06 14:13:51 +02:00
2022-07-27 10:23:35 +02:00
from .auth import AuthMixin, auth_protect
from .qefx import QEFX
2021-04-06 14:13:51 +02:00
from .qewallet import QEWallet
2025-05-12 13:42:41 +02:00
from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard
2021-04-06 14:13:51 +02:00
2023-08-14 16:24:48 +02:00
if TYPE_CHECKING:
from electrum.daemon import Daemon
from electrum.plugin import Plugins
2021-04-06 14:13:51 +02:00
# wallet list model. supports both wallet basenames (wallet file basenames)
# and whole Wallet instances (loaded wallets)
from .util import check_password_strength
2021-04-06 14:13:51 +02:00
class QEWalletListModel(QAbstractListModel):
_logger = get_logger(__name__)
# define listmodel rolemap
2023-09-22 16:34:28 +02:00
_ROLE_NAMES= ('name', 'path', 'active')
2023-07-14 13:51:08 +02:00
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
2021-04-06 14:13:51 +02:00
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
def __init__(self, daemon: 'Daemon', parent=None):
QAbstractListModel.__init__(self, parent)
self.daemon = daemon
2023-09-22 16:34:28 +02:00
self._wallets = []
self.reload()
2021-04-06 14:13:51 +02:00
def rowCount(self, index):
2023-09-22 16:34:28 +02:00
return len(self._wallets)
2021-04-06 14:13:51 +02:00
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
2023-09-22 16:34:28 +02:00
(wallet_name, wallet_path) = self._wallets[index.row()]
2023-07-14 13:51:08 +02:00
role_index = role - Qt.ItemDataRole.UserRole
2021-04-06 14:13:51 +02:00
role_name = self._ROLE_NAMES[role_index]
if role_name == 'name':
return wallet_name
if role_name == 'path':
return wallet_path
2021-04-06 14:13:51 +02:00
if role_name == 'active':
return self.daemon.get_wallet(wallet_path) is not None
2021-04-06 14:13:51 +02:00
@pyqtSlot()
def reload(self):
self._logger.debug('enumerating available wallets')
self.beginResetModel()
2023-09-22 16:34:28 +02:00
self._wallets = []
self.endResetModel()
available = []
wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path())
with os.scandir(wallet_folder) as it:
for i in it:
if i.is_file() and not i.name.startswith('.'):
available.append(i.path)
for path in sorted(available):
wallet = self.daemon.get_wallet(path)
2024-02-05 21:25:13 +01:00
self.add_wallet(wallet_path=path)
def add_wallet(self, wallet_path):
2023-09-22 16:34:28 +02:00
self.beginInsertRows(QModelIndex(), len(self._wallets), len(self._wallets))
wallet_name = os.path.basename(wallet_path)
wallet_path = standardize_path(wallet_path)
item = (wallet_name, wallet_path)
2023-09-22 16:34:28 +02:00
self._wallets.append(item)
2022-10-31 16:13:22 +00:00
self.endInsertRows()
2021-04-06 14:13:51 +02:00
def remove_wallet(self, path):
i = 0
wallets = []
remove = -1
2023-09-22 16:34:28 +02:00
for wallet_name, wallet_path in self._wallets:
if wallet_path == path:
remove = i
else:
wallets.append((wallet_name, wallet_path))
i += 1
if remove >= 0:
self.beginRemoveRows(QModelIndex(), remove, remove)
2023-09-22 16:34:28 +02:00
self._wallets = wallets
self.endRemoveRows()
@pyqtSlot(str, result=bool)
def wallet_name_exists(self, name):
2023-09-22 16:34:28 +02:00
for wallet_name, wallet_path in self._wallets:
if name == wallet_name:
return True
return False
@pyqtSlot(str)
def updateWallet(self, path):
i = 0
2023-09-22 16:34:28 +02:00
for wallet_name, wallet_path in self._wallets:
if wallet_path == path:
mi = self.createIndex(i, i)
self.dataChanged.emit(mi, mi, self._ROLE_KEYS)
return
i += 1
2023-08-14 16:24:48 +02:00
class QEDaemon(AuthMixin, QObject):
2025-04-09 13:44:26 +02:00
instance = None # type: Optional[QEDaemon]
2021-04-06 14:13:51 +02:00
_logger = get_logger(__name__)
_available_wallets = None
2022-03-10 12:25:18 +01:00
_current_wallet = None
_new_wallet_wizard = None
2025-05-12 13:42:41 +02:00
_terms_of_use_wizard = None
_server_connect_wizard = None
2022-03-10 12:25:18 +01:00
_path = None
_name = None
2022-07-06 11:10:00 +02:00
_use_single_password = False
_password = None
_loading = False
2023-02-22 14:17:57 +01:00
_backendWalletLoaded = pyqtSignal([str], arguments=['password'])
availableWalletsChanged = pyqtSignal()
fxChanged = pyqtSignal()
newWalletWizardChanged = pyqtSignal()
2025-05-12 13:42:41 +02:00
termsOfUseWizardChanged = pyqtSignal()
serverConnectWizardChanged = pyqtSignal()
loadingChanged = pyqtSignal()
requestNewPassword = pyqtSignal()
2024-02-05 21:25:13 +01:00
walletLoaded = pyqtSignal([str, str], arguments=['name', 'path'])
walletRequiresPassword = pyqtSignal([str, str], arguments=['name', 'path'])
walletOpenError = pyqtSignal([str], arguments=["error"])
2024-02-05 21:25:13 +01:00
walletDeleteError = pyqtSignal([str, str], arguments=['code', 'message'])
2023-08-14 16:24:48 +02:00
def __init__(self, daemon: 'Daemon', plugins: 'Plugins', parent=None):
super().__init__(parent)
2025-04-09 13:44:26 +02:00
if QEDaemon.instance:
raise RuntimeError('There should only be one QEDaemon instance')
QEDaemon.instance = self
self.daemon = daemon
2023-08-14 16:24:48 +02:00
self.plugins = plugins
self.qefx = QEFX(daemon.fx, daemon.config)
2023-02-22 14:17:57 +01:00
self._backendWalletLoaded.connect(self._on_backend_wallet_loaded)
@pyqtSlot()
def passwordValidityCheck(self):
if not self._walletdb._validPassword:
self.walletRequiresPassword.emit(self._name, self._path)
2021-04-06 14:13:51 +02:00
@pyqtSlot()
@pyqtSlot(str)
@pyqtSlot(str, str)
def loadWallet(self, path=None, password=None):
if self._loading:
return
self._loading = True
2022-10-31 16:13:22 +00:00
if path is None:
2023-09-22 16:34:28 +02:00
self._path = self.daemon.config.get('wallet_path') # command line -w option
if self._path is None:
self._path = self.daemon.config.CURRENT_WALLET
else:
self._path = path
if self._path is None:
self._loading = False
return
self.loadingChanged.emit()
2022-07-12 16:49:07 +02:00
self._path = standardize_path(self._path)
self._name = os.path.basename(self._path)
self._logger.debug('load wallet ' + str(self._path))
# password unification helper:
# - if pw not given (None), try pw of current wallet.
# - but "" empty str passwords are kept as-is, to open passwordless wallets
if password is None:
password = self._password
# map explicit empty str password to None. the backend disallows empty str passwords.
if password == '':
password = None
wallet_already_open = self.daemon.get_wallet(self._path)
if wallet_already_open is not None:
password = QEWallet.getInstanceFor(wallet_already_open).password
2023-02-22 14:17:57 +01:00
def load_wallet_task():
success = False
2023-02-22 14:17:57 +01:00
try:
2023-09-22 16:34:28 +02:00
local_password = password # need this in local scope
wallet = None
try:
wallet = self.daemon.load_wallet(
self._path,
password=local_password,
upgrade=True,
# might have a keystore password, but unencrypted storage. we want to prompt for pw even then:
force_check_password=True,
)
except InvalidPassword:
self.walletRequiresPassword.emit(self._name, self._path)
except FileNotFoundError:
self.walletOpenError.emit(_('File not found') + f":\n{self._path}")
except StorageReadWriteError:
self.walletOpenError.emit(_('Could not read/write file'))
except WalletFileException as e:
self.walletOpenError.emit(_('Could not open wallet: {}').format(str(e)))
if e.should_report_crash:
send_exception_to_crash_reporter(e)
2023-02-22 14:17:57 +01:00
if wallet is None:
return
2022-07-06 11:10:00 +02:00
if self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD:
self._use_single_password = self._update_password_for_directory_and_unlock_wallets(old_password=local_password, new_password=local_password)
2025-11-30 20:04:32 +01:00
if not self._use_single_password and self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION:
# we need to disable biometric auth if the user creates wallets with different passwords as
# we only store one encrypted password which is not associated to a specific wallet
self._logger.warning(f"disabling biometric authentication, not in single password mode")
self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False
self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = ''
self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = ''
self._password = local_password
self.singlePasswordChanged.emit()
self._logger.info(f'use single password: {self._use_single_password}')
else:
self._logger.info('use single password disabled by config')
self.daemon.config.WALLET_DID_USE_SINGLE_PASSWORD = self._use_single_password
2022-07-06 11:10:00 +02:00
run_hook('load_wallet', wallet)
2023-02-22 14:17:57 +01:00
success = True
self._backendWalletLoaded.emit(local_password)
finally:
if not success: # if successful, _loading guard will be reset by _on_backend_wallet_loaded
self._loading = False
self.loadingChanged.emit()
2023-02-22 14:17:57 +01:00
threading.Thread(target=load_wallet_task, daemon=False).start()
2023-02-22 14:17:57 +01:00
@pyqtSlot()
@pyqtSlot(str)
2023-09-22 16:34:28 +02:00
def _on_backend_wallet_loaded(self, password=None):
2023-02-22 14:17:57 +01:00
self._logger.debug('_on_backend_wallet_loaded')
wallet = self.daemon.get_wallet(self._path)
assert wallet is not None
2023-02-22 14:17:57 +01:00
self._current_wallet = QEWallet.getInstanceFor(wallet)
self.availableWallets.updateWallet(self._path)
2025-01-17 12:15:24 +01:00
wallet.unlock(password or None) # not conditional on wallet.requires_unlock in qml, as
# the auth wrapper doesn't pass the entered password, but instead we rely on the password in memory
self._loading = False
self.loadingChanged.emit()
2023-02-22 14:17:57 +01:00
self.walletLoaded.emit(self._name, self._path)
@pyqtSlot(QEWallet)
@pyqtSlot(QEWallet, bool)
@pyqtSlot(QEWallet, bool, bool)
def checkThenDeleteWallet(self, wallet, confirm_requests=False, confirm_balance=False):
if wallet.wallet.lnworker:
lnchannels = wallet.wallet.lnworker.get_channel_objects()
if any([channel.get_state() != ChannelState.REDEEMED and not channel.is_backup() for channel in lnchannels.values()]):
self.walletDeleteError.emit('unclosed_channels', _('There are still channels that are not fully closed'))
return
num_requests = len(wallet.wallet.get_unpaid_requests())
if num_requests > 0 and not confirm_requests:
self.walletDeleteError.emit('unpaid_requests', _('There are still unpaid requests. Really delete?'))
return
c, u, x = wallet.wallet.get_balance()
if c+u+x > 0 and not wallet.wallet.is_watching_only() and not confirm_balance:
self.walletDeleteError.emit('balance', _('There are still coins present in this wallet. Really delete?'))
return
self.delete_wallet(wallet)
@auth_protect(message=_('Really delete this wallet?'))
def delete_wallet(self, wallet):
path = standardize_path(wallet.wallet.storage.path)
self._logger.debug('deleting wallet with path %s' % path)
2022-06-21 14:11:03 +02:00
self._current_wallet = None
# TODO walletLoaded signal is confusing
2023-03-16 20:23:29 +01:00
self.walletLoaded.emit(None, None)
if not self.daemon.delete_wallet(path):
self.walletDeleteError.emit('error', _('Problem deleting wallet'))
return
self.availableWallets.remove_wallet(path)
@pyqtProperty(bool, notify=loadingChanged)
def loading(self):
return self._loading
@pyqtProperty(QEWallet, notify=walletLoaded)
2021-04-06 14:13:51 +02:00
def currentWallet(self):
return self._current_wallet
@pyqtProperty(QEWalletListModel, notify=availableWalletsChanged)
2021-04-06 14:13:51 +02:00
def availableWallets(self):
if not self._available_wallets:
self._available_wallets = QEWalletListModel(self.daemon)
return self._available_wallets
2022-04-04 13:21:05 +02:00
@pyqtProperty(QEFX, notify=fxChanged)
def fx(self):
return self.qefx
@pyqtSlot(str, result=list)
def getWalletsUnlockableWithPassword(self, password: str) -> list[str]:
"""
Returns any wallet that can be unlocked with the given password.
Can be used as fallback to unlock another wallet the user entered a
password that doesn't work for the current wallet but might work for another one.
"""
wallet_dir = os.path.dirname(self.daemon.config.get_wallet_path())
_, _, wallet_paths_can_unlock = self.daemon.check_password_for_directory(
old_password=password,
new_password=None,
wallet_dir=wallet_dir,
)
if not wallet_paths_can_unlock:
return []
self._logger.debug(f"getWalletsUnlockableWithPassword: can unlock {len(wallet_paths_can_unlock)} wallets")
return [str(path) for path in wallet_paths_can_unlock]
@pyqtSlot(str, result=int)
def numWalletsWithPassword(self, password: str) -> int:
"""Returns the number of wallets that can be unlocked with the given password"""
wallet_paths_can_unlock = self.getWalletsUnlockableWithPassword(password)
return len(wallet_paths_can_unlock)
singlePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=singlePasswordChanged)
def singlePasswordEnabled(self):
"""
singlePasswordEnabled is False if:
a.) the user has no wallet (and password) yet
b.) the user has wallets with different passwords (legacy)
c.) all wallets are locked, we couldn't check yet if they all use the same password
d.) we are on desktop where different passwords are allowed
"""
return self._use_single_password
@pyqtProperty(str, notify=singlePasswordChanged)
def singlePassword(self):
"""
self._password is also set to the last loaded wallet password if we WANT a single password,
but don't actually have a single password yet. So singlePassword being set doesn't strictly
mean all wallets use the same password.
"""
return self._password
@singlePassword.setter
def singlePassword(self, password: str):
assert password
assert self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
if self._password != password:
self._password = password
self.singlePasswordChanged.emit()
@pyqtSlot(result=str)
def suggestWalletName(self):
2023-04-05 14:13:40 +00:00
# FIXME why not use util.get_new_wallet_name ?
i = 1
while self.availableWallets.wallet_name_exists(f'wallet_{i}'):
i = i + 1
return f'wallet_{i}'
2022-07-06 11:10:00 +02:00
@pyqtSlot()
2026-01-15 11:30:09 +01:00
@auth_protect(method='wallet_password_only')
def startChangePassword(self):
2022-07-06 11:10:00 +02:00
if self._use_single_password:
self.requestNewPassword.emit()
else:
self.currentWallet.requestNewPassword.emit()
@pyqtSlot(str, result=bool)
def setPassword(self, password):
2022-07-06 11:10:00 +02:00
assert self._use_single_password
assert password
if not self._update_password_for_directory_and_unlock_wallets(old_password=self._password, new_password=password):
return False
2022-07-12 16:49:07 +02:00
self._password = password
return True
2022-07-12 16:49:07 +02:00
def _update_password_for_directory_and_unlock_wallets(self, *, old_password, new_password):
# note: this assumes all wallet files are in a single directory.
# change wallet passwords:
ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password)
# If some wallets just had their password changed, they got "locked" by wallet.update_password().
# If the password is not unified yet, other loaded wallets might still be unlocked.
# restore the invariant that all loaded wallets in qml must be unlocked:
for w in self.daemon.get_wallets().values():
if not w.is_unlocked():
w.unlock(new_password)
assert w.is_unlocked()
return ret
@pyqtProperty(QENewWalletWizard, notify=newWalletWizardChanged)
def newWalletWizard(self):
if not self._new_wallet_wizard:
2023-08-14 16:24:48 +02:00
self._new_wallet_wizard = QENewWalletWizard(self, self.plugins)
return self._new_wallet_wizard
@pyqtProperty(QEServerConnectWizard, notify=serverConnectWizardChanged)
def serverConnectWizard(self):
if not self._server_connect_wizard:
self._server_connect_wizard = QEServerConnectWizard(self)
return self._server_connect_wizard
2025-05-12 13:42:41 +02:00
@pyqtProperty(QETermsOfUseWizard, notify=termsOfUseWizardChanged)
def termsOfUseWizard(self):
if not self._terms_of_use_wizard:
self._terms_of_use_wizard = QETermsOfUseWizard(self)
return self._terms_of_use_wizard
@pyqtSlot()
def startNetwork(self):
self.daemon.start_network()
2023-09-25 15:35:47 +02:00
@pyqtSlot(str, str, str, result=bool)
def verifyMessage(self, address, message, signature):
address = address.strip()
message = message.strip().encode('utf-8')
if not is_address(address):
return False
try:
# This can throw on invalid base64
2025-06-03 17:50:43 +00:00
sig = base64.b64decode(str(signature.strip()), validate=True)
2024-04-11 15:13:41 +00:00
verified = verify_usermessage_with_address(address, sig, message)
2023-09-25 15:35:47 +02:00
except Exception as e:
verified = False
return verified
@pyqtSlot(str, result=int)
def passwordStrength(self, password):
if len(password) == 0:
return 0
return check_password_strength(password)[0]