diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index dc94d9421..259121839 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -1,30 +1,43 @@ import os -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject -from PyQt5.QtQml import QQmlApplicationEngine +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Qt, QTimer +from PyQt5.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget, QFileDialog +from electrum.daemon import Daemon +from electrum.i18n import _ +from electrum.storage import StorageReadWriteError +from electrum.util import WalletFileException, get_new_wallet_name +from electrum.wallet import wallet_types +from .wizard import QEAbstractWizard, WizardComponent from electrum.logging import get_logger -from electrum import mnemonic -from electrum.wizard import NewWalletWizard, ServerConnectWizard +from electrum import WalletStorage, mnemonic +from electrum.wizard import NewWalletWizard +from ..installwizard import MSG_PASSPHRASE_WARN_ISSUE4566 +from ..seed_dialog import SeedLayout +from ..util import ChoicesLayout, PasswordLineEdit, char_width_in_lineedit, WWLabel class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): + _logger = get_logger(__name__) - createError = pyqtSignal([str], arguments=["error"]) - createSuccess = pyqtSignal() + # createError = pyqtSignal([str], arguments=["error"]) + # createSuccess = pyqtSignal() - def __init__(self, daemon, parent = None): + def __init__(self, config: 'SimpleConfig', app: QApplication, daemon: Daemon, path, parent=None): NewWalletWizard.__init__(self, daemon) - QEAbstractWizard.__init__(self, parent) + QEAbstractWizard.__init__(self, config, app, parent) self._daemon = daemon + self._path = path # attach view names and accept handlers self.navmap_merge({ - 'wallet_name': { 'gui': 'WCWalletName' }, - 'wallet_type': { 'gui': 'WCWalletType' }, - 'keystore_type': { 'gui': 'WCKeystoreType' }, - 'create_seed': { 'gui': 'WCCreateSeed' }, - 'confirm_seed': { 'gui': 'WCConfirmSeed' }, + 'wallet_name': { 'gui': WCWalletName }, + 'wallet_type': { 'gui': WCWalletType }, + 'keystore_type': { 'gui': WCKeystoreType }, + 'create_seed': { 'gui': WCCreateSeed }, + 'create_ext': { 'gui': WCCreateExt }, + 'confirm_seed': { 'gui': WCConfirmSeed }, + 'confirm_ext': { 'gui': WCConfirmExt }, 'have_seed': { 'gui': 'WCHaveSeed' }, 'bip39_refine': { 'gui': 'WCBIP39Refine' }, 'have_master_key': { 'gui': 'WCHaveMasterKey' }, @@ -37,54 +50,353 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): 'wallet_password': { 'gui': 'WCWalletPassword' } }) - pathChanged = pyqtSignal() - @pyqtProperty(str, notify=pathChanged) - def path(self): - return self._path - - @path.setter - def path(self, path): - self._path = path - self.pathChanged.emit() + # insert seed extension entry/confirm as separate views + self.navmap_merge({ + 'create_seed': { + 'next': lambda d: 'create_ext' if d['seed_extend'] else 'confirm_seed' + }, + 'create_ext': { + 'next': 'confirm_seed', + }, + 'confirm_seed': { + 'next': lambda d: 'confirm_ext' if d['seed_extend'] else self.on_have_or_confirm_seed(d), + 'accept': lambda d: None if d['seed_extend'] else self.maybe_master_pubkey(d), + }, + 'confirm_ext': { + 'next': self.on_have_or_confirm_seed, + 'accept': self.maybe_master_pubkey, + } + }) + # pathChanged = pyqtSignal() + # @pyqtProperty(str, notify=pathChanged) + # def path(self): + # return self._path + # + # @path.setter + # def path(self, path): + # self._path = path + # self.pathChanged.emit() + # def is_single_password(self): - return self._daemon.singlePasswordEnabled - - @pyqtSlot('QJSValue', result=bool) - def hasDuplicateMasterKeys(self, js_data): - self._logger.info('Checking for duplicate masterkeys') - data = js_data.toVariant() - return self.has_duplicate_masterkeys(data) - - @pyqtSlot('QJSValue', result=bool) - def hasHeterogeneousMasterKeys(self, js_data): - self._logger.info('Checking for heterogeneous masterkeys') - data = js_data.toVariant() - return self.has_heterogeneous_masterkeys(data) - - @pyqtSlot(str, str, result=bool) - def isMatchingSeed(self, seed, seed_again): - return mnemonic.is_matching_seed(seed=seed, seed_again=seed_again) - - @pyqtSlot('QJSValue', bool, str) - def createStorage(self, js_data, single_password_enabled, single_password): - self._logger.info('Creating wallet from wizard data') - data = js_data.toVariant() - - if single_password_enabled and single_password: - data['encrypt'] = True - data['password'] = single_password - - path = os.path.join(os.path.dirname(self._daemon.daemon.config.get_wallet_path()), data['wallet_name']) - - try: - self.create_storage(path, data) - - # minimally populate self after create - self._password = data['password'] - self.path = path - - self.createSuccess.emit() - except Exception as e: - self._logger.error(f"createStorage errored: {e!r}") - self.createError.emit(str(e)) + # TODO: also take into account if possible with existing set of wallets. see qedaemon.py + return self._daemon.config.WALLET_USE_SINGLE_PASSWORD + + # @pyqtSlot('QJSValue', result=bool) + # def hasDuplicateMasterKeys(self, js_data): + # self._logger.info('Checking for duplicate masterkeys') + # data = js_data.toVariant() + # return self.has_duplicate_masterkeys(data) + # + # @pyqtSlot('QJSValue', result=bool) + # def hasHeterogeneousMasterKeys(self, js_data): + # self._logger.info('Checking for heterogeneous masterkeys') + # data = js_data.toVariant() + # return self.has_heterogeneous_masterkeys(data) + # + # @pyqtSlot(str, str, result=bool) + # def isMatchingSeed(self, seed, seed_again): + # return mnemonic.is_matching_seed(seed=seed, seed_again=seed_again) + # + # @pyqtSlot('QJSValue', bool, str) + # def createStorage(self, js_data, single_password_enabled, single_password): + # self._logger.info('Creating wallet from wizard data') + # data = js_data.toVariant() + # + # if single_password_enabled and single_password: + # data['encrypt'] = True + # data['password'] = single_password + # + # path = os.path.join(os.path.dirname(self._daemon.daemon.config.get_wallet_path()), data['wallet_name']) + # + # try: + # self.create_storage(path, data) + # + # # minimally populate self after create + # self._password = data['password'] + # self.path = path + # + # self.createSuccess.emit() + # except Exception as e: + # self._logger.error(f"createStorage errored: {e!r}") + # self.createError.emit(str(e)) + + +class WCWalletName(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Electrum wallet')) + + path = wizard._path + + if os.path.isdir(path): + raise Exception("wallet path cannot point to a directory") + + hbox = QHBoxLayout() + hbox.addWidget(QLabel(_('Wallet') + ':')) + self.name_e = QLineEdit() + hbox.addWidget(self.name_e) + button = QPushButton(_('Choose...')) + hbox.addWidget(button) + self.layout().addLayout(hbox) + + msg_label = WWLabel('') + self.layout().addWidget(msg_label) + hbox2 = QHBoxLayout() + pw_e = PasswordLineEdit('', self) + pw_e.setFixedWidth(17 * char_width_in_lineedit()) + pw_label = QLabel(_('Password') + ':') + hbox2.addWidget(pw_label) + hbox2.addWidget(pw_e) + hbox2.addStretch() + self.layout().addLayout(hbox2) + + self.layout().addSpacing(50) + vbox_create_new = QVBoxLayout() + vbox_create_new.addWidget(QLabel(_('Alternatively') + ':'), alignment=Qt.AlignLeft) + button_create_new = QPushButton(_('Create New Wallet')) + button_create_new.setMinimumWidth(120) + vbox_create_new.addWidget(button_create_new, alignment=Qt.AlignLeft) + widget_create_new = QWidget() + widget_create_new.setLayout(vbox_create_new) + vbox_create_new.setContentsMargins(0, 0, 0, 0) + self.layout().addWidget(widget_create_new) + + temp_storage = None # type: Optional[WalletStorage] + wallet_folder = os.path.dirname(path) + + def on_choose(): + path, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", wallet_folder) + if path: + self.name_e.setText(path) + + def on_filename(filename): + # FIXME? "filename" might contain ".." (etc) and hence sketchy path traversals are possible + nonlocal temp_storage + temp_storage = None + msg = None + if filename: + path = os.path.join(wallet_folder, filename) + # wallet_from_memory = get_wallet_from_daemon(path) + wallet_from_memory = self.wizard._daemon.get_wallet(path) + try: + if wallet_from_memory: + temp_storage = wallet_from_memory.storage # type: Optional[WalletStorage] + else: + temp_storage = WalletStorage(path) + except (StorageReadWriteError, WalletFileException) as e: + msg = _('Cannot read file') + f'\n{repr(e)}' + except Exception as e: + self.logger.exception('') + msg = _('Cannot read file') + f'\n{repr(e)}' + else: + msg = "" + # self.next_button.setEnabled(temp_storage is not None) + self.valid = temp_storage is not None + user_needs_to_enter_password = False + if temp_storage: + if not temp_storage.file_exists(): + msg =_("This file does not exist.") + '\n' \ + + _("Press 'Next' to create this wallet, or choose another file.") + elif not wallet_from_memory: + if temp_storage.is_encrypted_with_user_pw(): + msg = _("This file is encrypted with a password.") + '\n' \ + + _('Enter your password or choose another file.') + user_needs_to_enter_password = True + elif temp_storage.is_encrypted_with_hw_device(): + msg = _("This file is encrypted using a hardware device.") + '\n' \ + + _("Press 'Next' to choose device to decrypt.") + else: + msg = _("Press 'Next' to open this wallet.") + else: + msg = _("This file is already open in memory.") + "\n" \ + + _("Press 'Next' to create/focus window.") + if msg is None: + msg = _('Cannot read file') + msg_label.setText(msg) + widget_create_new.setVisible(bool(temp_storage and temp_storage.file_exists())) + if user_needs_to_enter_password: + pw_label.show() + pw_e.show() + pw_e.setFocus() + else: + pw_label.hide() + pw_e.hide() + + button.clicked.connect(on_choose) + button_create_new.clicked.connect( + lambda: self.name_e.setText(get_new_wallet_name(wallet_folder))) # FIXME get_new_wallet_name might raise + self.name_e.textChanged.connect(on_filename) + self.name_e.setText(os.path.basename(path)) + + def apply(self): + self.wizard_data['wallet_name'] = self.name_e.text() + + +class WCWalletType(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Create new wallet')) + message = _('What kind of wallet do you want to create?') + wallet_kinds = [ + ('standard', _('Standard wallet')), + ('2fa', _('Wallet with two-factor authentication')), + ('multisig', _('Multi-signature wallet')), + ('imported', _('Import Bitcoin addresses or private keys')), + ] + choices = [pair for pair in wallet_kinds if pair[0] in wallet_types] + + self.c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + self.clayout = ChoicesLayout(message, c_titles) + self.layout().addLayout(self.clayout.layout()) + self._valid = True + + def apply(self): + self.wizard_data['wallet_type'] = self.c_values[self.clayout.selected_index()] + + +class WCKeystoreType(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Keystore')) + message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?') + choices = [ + ('createseed', _('Create a new seed')), + ('haveseed', _('I already have a seed')), + ('masterkey', _('Use a master key')), + ('hardware', _('Use a hardware device')) + ] + + self.c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + self.clayout = ChoicesLayout(message, c_titles) + self.layout().addLayout(self.clayout.layout()) + self._valid = True + + def apply(self): + self.wizard_data['keystore_type'] = self.c_values[self.clayout.selected_index()] + + +class WCCreateSeed(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Wallet Seed')) + self._busy = True + self.seed_type = 'standard' if self.wizard.config.WIZARD_DONT_CREATE_SEGWIT else 'segwit' + self.slayout = None + self.seed = None + QTimer.singleShot(100, self.create_seed) + + def apply(self): + if self.slayout: + self.wizard_data['seed'] = self.seed + self.wizard_data['seed_type'] = self.seed_type + self.wizard_data['seed_extend'] = self.slayout.is_ext + self.wizard_data['seed_variant'] = 'electrum' + + def create_seed(self): + self.busy = True + self.seed = mnemonic.Mnemonic('en').make_seed(seed_type=self.seed_type) + + self.slayout = SeedLayout( + title=_('Your wallet generation seed is:'), + seed=self.seed, + options=['ext'], + msg=True, + parent=self, + config=self.wizard.config, + ) + self.layout().addLayout(self.slayout) + self.busy = False + self.valid = True + + +class WCConfirmSeed(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Confirm Seed')) + message = ' '.join([ + _('Your seed is important!'), + _('If you lose your seed, your money will be permanently lost.'), + _('To make sure that you have properly saved your seed, please retype it here.') + ]) + + self.layout().addWidget(QLabel(message)) + + self._valid = True + + def apply(self): + pass + + +class WCCreateExt(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Seed Extension')) + + message = '\n'.join([ + _('You may extend your seed with custom words.'), + _('Your seed extension must be saved together with your seed.'), + ]) + warning = '\n'.join([ + _('Note that this is NOT your encryption password.'), + _('If you do not know what this is, leave this field empty.'), + ]) + + self.ext_edit = SeedExtensionEdit(self, message=message, warning=warning) + self.ext_edit.textEdited.connect(self.on_text_edited) + self.layout().addWidget(self.ext_edit) + + def on_text_edited(self, text): + self.ext_edit.warn_issue4566 = self.wizard_data['keystore_type'] == 'haveseed' and \ + self.wizard_data['seed_type'] == 'bip39' + self.valid = len(text) > 0 + + def apply(self): + self.wizard_data['seed_extra_words'] = self.ext_edit.text() + + +class WCConfirmExt(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Confirm Seed Extension')) + message = '\n'.join([ + _('Your seed extension must be saved together with your seed.'), + _('Please type it here.'), + ]) + self.ext_edit = SeedExtensionEdit(self, message=message) + self.ext_edit.textEdited.connect(self.on_text_edited) + self.layout().addWidget(self.ext_edit) + + def on_text_edited(self, text): + self.valid = text == self.wizard_data['seed_extra_words'] + + def apply(self): + pass + + +class SeedExtensionEdit(QWidget): + def __init__(self, parent, *, message: str = None, warning: str = None, warn_issue4566: bool = False): + super().__init__(parent) + + self.warn_issue4566 = warn_issue4566 + + layout = QVBoxLayout() + self.setLayout(layout) + + if message: + layout.addWidget(WWLabel(message)) + + self.line = QLineEdit() + layout.addWidget(self.line) + + def f(text): + if self.warn_issue4566: + text_whitespace_normalised = ' '.join(text.split()) + warn_issue4566_label.setVisible(text != text_whitespace_normalised) + self.line.textEdited.connect(f) + + if warning: + layout.addWidget(WWLabel(warning)) + + warn_issue4566_label = WWLabel(MSG_PASSPHRASE_WARN_ISSUE4566) + warn_issue4566_label.setVisible(False) + layout.addWidget(warn_issue4566_label) + + # expose textEdited signal and text() func to widget + self.textEdited = self.line.textEdited + self.text = self.line.text