diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py deleted file mode 100644 index bde555e26f6d..000000000000 --- a/electrum/base_wizard.py +++ /dev/null @@ -1,738 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Electrum - lightweight Bitcoin client -# Copyright (C) 2016 Thomas Voegtlin -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation files -# (the "Software"), to deal in the Software without restriction, -# including without limitation the rights to use, copy, modify, merge, -# publish, distribute, sublicense, and/or sell copies of the Software, -# and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS -# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import os -import sys -import copy -import traceback -from functools import partial -from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional, Union - -from . import bitcoin -from . import keystore -from . import mnemonic -from .bip32 import is_bip32_derivation, xpub_type, normalize_bip32_derivation, BIP32Node -from .keystore import bip44_derivation, purpose48_derivation, Hardware_KeyStore, KeyStore, bip39_to_seed -from .wallet import (Imported_Wallet, Standard_Wallet, Multisig_Wallet, - wallet_types, Wallet, Abstract_Wallet) -from .storage import WalletStorage, StorageEncryptionVersion -from .wallet_db import WalletDB -from .i18n import _ -from .util import UserCancelled, InvalidPassword, WalletFileException, UserFacingException -from .simple_config import SimpleConfig -from .plugin import Plugins, HardwarePluginLibraryUnavailable -from .logging import Logger -from .plugins.hw_wallet.plugin import OutdatedHwFirmwareException, HW_PluginBase - -if TYPE_CHECKING: - from .plugin import DeviceInfo, BasePlugin - - -# hardware device setup purpose -HWD_SETUP_NEW_WALLET, HWD_SETUP_DECRYPT_WALLET = range(0, 2) - - -class ScriptTypeNotSupported(Exception): pass - - -class GoBack(Exception): pass - - -class ReRunDialog(Exception): pass - - -class ChooseHwDeviceAgain(Exception): pass - - -class WizardStackItem(NamedTuple): - action: Any - args: Any - kwargs: Dict[str, Any] - db_data: dict - - -class WizardWalletPasswordSetting(NamedTuple): - password: Optional[str] - encrypt_storage: bool - storage_enc_version: StorageEncryptionVersion - encrypt_keystore: bool - - -class BaseWizard(Logger): - - def __init__(self, config: SimpleConfig, plugins: Plugins): - super(BaseWizard, self).__init__() - Logger.__init__(self) - self.config = config - self.plugins = plugins - self.data = {} - self.pw_args = None # type: Optional[WizardWalletPasswordSetting] - self._stack = [] # type: List[WizardStackItem] - self.plugin = None # type: Optional[BasePlugin] - self.keystores = [] # type: List[KeyStore] - self.seed_type = None - - def set_icon(self, icon): - pass - - def run(self, *args, **kwargs): - action = args[0] - args = args[1:] - db_data = copy.deepcopy(self.data) - self._stack.append(WizardStackItem(action, args, kwargs, db_data)) - if not action: - return - if type(action) is tuple: - self.plugin, action = action - if self.plugin and hasattr(self.plugin, action): - f = getattr(self.plugin, action) - f(self, *args, **kwargs) - elif hasattr(self, action): - f = getattr(self, action) - f(*args, **kwargs) - else: - raise Exception("unknown action", action) - - def can_go_back(self): - return len(self._stack) > 1 - - def go_back(self, *, rerun_previous: bool = True) -> None: - if not self.can_go_back(): - return - # pop 'current' frame - self._stack.pop() - prev_frame = self._stack[-1] - # try to undo side effects since we last entered 'previous' frame - # FIXME only self.data is properly restored - self.data = copy.deepcopy(prev_frame.db_data) - - if rerun_previous: - # pop 'previous' frame - self._stack.pop() - # rerun 'previous' frame - self.run(prev_frame.action, *prev_frame.args, **prev_frame.kwargs) - - def reset_stack(self): - self._stack = [] - - def new(self): - title = _("Create new wallet") - message = '\n'.join([ - _("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.choice_dialog(title=title, message=message, choices=choices, run_next=self.on_wallet_type) - - def upgrade_db(self, storage, db): - exc = None # type: Optional[Exception] - def on_finished(): - if exc is None: - self.terminate(storage=storage, db=db) - else: - raise exc - def do_upgrade(): - nonlocal exc - try: - db.upgrade() - except Exception as e: - exc = e - self.waiting_dialog(do_upgrade, _('Upgrading wallet format...'), on_finished=on_finished) - - def run_task_without_blocking_gui(self, task, *, msg: str = None) -> Any: - """Perform a task in a thread without blocking the GUI. - Returns the result of 'task', or raises the same exception. - This method blocks until 'task' is finished. - """ - raise NotImplementedError() - - def load_2fa(self): - self.data['wallet_type'] = '2fa' - self.data['use_trustedcoin'] = True - self.plugin = self.plugins.load_plugin('trustedcoin') - - def on_wallet_type(self, choice): - self.data['wallet_type'] = self.wallet_type = choice - if choice == 'standard': - action = 'choose_keystore' - elif choice == 'multisig': - action = 'choose_multisig' - elif choice == '2fa': - self.load_2fa() - action = self.plugin.get_action(self.data) - elif choice == 'imported': - action = 'import_addresses_or_keys' - self.run(action) - - def choose_multisig(self): - def on_multisig(m, n): - multisig_type = "%dof%d" % (m, n) - self.data['wallet_type'] = multisig_type - self.n = n - self.run('choose_keystore') - self.multisig_dialog(run_next=on_multisig) - - def choose_keystore(self): - assert self.wallet_type in ['standard', 'multisig'] - i = len(self.keystores) - title = _('Add cosigner') + ' (%d of %d)'%(i+1, self.n) if self.wallet_type=='multisig' else _('Keystore') - if self.wallet_type =='standard' or i==0: - message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?') - choices = [ - ('choose_seed_type', _('Create a new seed')), - ('restore_from_seed', _('I already have a seed')), - ('restore_from_key', _('Use a master key')), - ('choose_hw_device', _('Use a hardware device')), - ] - else: - message = _('Add a cosigner to your multi-sig wallet') - choices = [ - ('restore_from_key', _('Enter cosigner key')), - ('restore_from_seed', _('Enter cosigner seed')), - ('choose_hw_device', _('Cosign with hardware device')), - ] - - self.choice_dialog(title=title, message=message, choices=choices, run_next=self.run) - - def import_addresses_or_keys(self): - v = lambda x: keystore.is_address_list(x) or keystore.is_private_key_list(x, raise_on_error=True) - title = _("Import Bitcoin Addresses") - message = _("Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.") - self.add_xpub_dialog(title=title, message=message, run_next=self.on_import, - is_valid=v, allow_multi=True, show_wif_help=True) - - def on_import(self, text): - # text is already sanitized by is_address_list and is_private_keys_list - if keystore.is_address_list(text): - self.data['addresses'] = {} - for addr in text.split(): - assert bitcoin.is_address(addr) - self.data['addresses'][addr] = {} - elif keystore.is_private_key_list(text): - self.data['addresses'] = {} - k = keystore.Imported_KeyStore({}) - keys = keystore.get_private_keys(text) - for pk in keys: - assert bitcoin.is_private_key(pk) - txin_type, pubkey = k.import_privkey(pk, None) - addr = bitcoin.pubkey_to_address(txin_type, pubkey) - self.data['addresses'][addr] = {'type':txin_type, 'pubkey':pubkey} - self.keystores.append(k) - else: - return self.terminate(aborted=True) - return self.run('create_wallet') - - def restore_from_key(self): - if self.wallet_type == 'standard': - v = keystore.is_master_key - title = _("Create keystore from a master key") - message = ' '.join([ - _("To create a watching-only wallet, please enter your master public key (xpub/ypub/zpub)."), - _("To create a spending wallet, please enter a master private key (xprv/yprv/zprv).") - ]) - self.add_xpub_dialog(title=title, message=message, run_next=self.on_restore_from_key, is_valid=v) - else: - i = len(self.keystores) + 1 - self.add_cosigner_dialog(index=i, run_next=self.on_restore_from_key, is_valid=keystore.is_bip32_key) - - def on_restore_from_key(self, text): - k = keystore.from_master_key(text) - self.on_keystore(k) - - def choose_hw_device(self, purpose=HWD_SETUP_NEW_WALLET, *, storage: WalletStorage = None): - while True: - try: - self._choose_hw_device(purpose=purpose, storage=storage) - except ChooseHwDeviceAgain: - pass - else: - break - - def _choose_hw_device(self, *, purpose, storage: WalletStorage = None): - title = _('Hardware Keystore') - # check available plugins - supported_plugins = self.plugins.get_hardware_support() - devices = [] # type: List[Tuple[str, DeviceInfo]] - devmgr = self.plugins.device_manager - debug_msg = '' - - def failed_getting_device_infos(name, e): - nonlocal debug_msg - err_str_oneline = ' // '.join(str(e).splitlines()) - self.logger.warning(f'error getting device infos for {name}: {err_str_oneline}') - indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True)) - debug_msg += f' {name}: (error getting device infos)\n{indented_error_msg}\n' - - # scan devices - try: - scanned_devices = self.run_task_without_blocking_gui(task=devmgr.scan_devices, - msg=_("Scanning devices...")) - except BaseException as e: - self.logger.info('error scanning devices: {}'.format(repr(e))) - debug_msg = ' {}:\n {}'.format(_('Error scanning devices'), e) - else: - for splugin in supported_plugins: - name, plugin = splugin.name, splugin.plugin - # plugin init errored? - if not plugin: - e = splugin.exception - indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True)) - debug_msg += f' {name}: (error during plugin init)\n' - debug_msg += ' {}\n'.format(_('You might have an incompatible library.')) - debug_msg += f'{indented_error_msg}\n' - continue - # see if plugin recognizes 'scanned_devices' - try: - # FIXME: side-effect: this sets client.handler - device_infos = devmgr.list_pairable_device_infos( - handler=None, plugin=plugin, devices=scanned_devices, include_failing_clients=True) - except HardwarePluginLibraryUnavailable as e: - failed_getting_device_infos(name, e) - continue - except BaseException as e: - self.logger.exception('') - failed_getting_device_infos(name, e) - continue - device_infos_failing = list(filter(lambda di: di.exception is not None, device_infos)) - for di in device_infos_failing: - failed_getting_device_infos(name, di.exception) - device_infos_working = list(filter(lambda di: di.exception is None, device_infos)) - devices += list(map(lambda x: (name, x), device_infos_working)) - if not debug_msg: - debug_msg = ' {}'.format(_('No exceptions encountered.')) - if not devices: - msg = (_('No hardware device detected.') + '\n' + - _('To trigger a rescan, press \'Next\'.') + '\n\n') - if sys.platform == 'win32': - msg += _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", ' - 'and do "Remove device". Then, plug your device again.') + '\n' - msg += _('While this is less than ideal, it might help if you run Electrum as Administrator.') + '\n' - else: - msg += _('On Linux, you might have to add a new permission to your udev rules.') + '\n' - msg += '\n\n' - msg += _('Debug message') + '\n' + debug_msg - self.confirm_dialog(title=title, message=msg, - run_next=lambda x: None) - raise ChooseHwDeviceAgain() - # select device - self.devices = devices - choices = [] - for name, info in devices: - state = _("initialized") if info.initialized else _("wiped") - label = info.label or _("An unnamed {}").format(name) - try: transport_str = info.device.transport_ui_string[:20] - except Exception: transport_str = 'unknown transport' - descr = f"{label} [{info.model_name or name}, {state}, {transport_str}]" - choices.append(((name, info), descr)) - msg = _('Select a device') + ':' - self.choice_dialog(title=title, message=msg, choices=choices, - run_next=lambda *args: self.on_device(*args, purpose=purpose, storage=storage)) - - def on_device(self, name, device_info: 'DeviceInfo', *, purpose, storage: WalletStorage = None): - self.plugin = self.plugins.get_plugin(name) - assert isinstance(self.plugin, HW_PluginBase) - devmgr = self.plugins.device_manager - try: - client = self.plugin.setup_device(device_info, self, purpose) - except OSError as e: - self.show_error(_('We encountered an error while connecting to your device:') - + '\n' + str(e) + '\n' - + _('To try to fix this, we will now re-pair with your device.') + '\n' - + _('Please try again.')) - devmgr.unpair_id(device_info.device.id_) - raise ChooseHwDeviceAgain() - except OutdatedHwFirmwareException as e: - if self.question(e.text_ignore_old_fw_and_continue(), title=_("Outdated device firmware")): - self.plugin.set_ignore_outdated_fw() - # will need to re-pair - devmgr.unpair_id(device_info.device.id_) - raise ChooseHwDeviceAgain() - except GoBack: - raise ChooseHwDeviceAgain() - except (UserCancelled, ReRunDialog): - raise - except UserFacingException as e: - self.show_error(str(e)) - raise ChooseHwDeviceAgain() - except BaseException as e: - self.logger.exception('') - self.show_error(str(e)) - raise ChooseHwDeviceAgain() - - if purpose == HWD_SETUP_NEW_WALLET: - def f(derivation, script_type): - derivation = normalize_bip32_derivation(derivation) - self.run('on_hw_derivation', name, device_info, derivation, script_type) - self.derivation_and_script_type_dialog(f) - elif purpose == HWD_SETUP_DECRYPT_WALLET: - password = client.get_password_for_storage_encryption() - try: - storage.decrypt(password) - except InvalidPassword: - # try to clear session so that user can type another passphrase - if hasattr(client, 'clear_session'): # FIXME not all hw wallet plugins have this - client.clear_session() - raise - else: - raise Exception('unknown purpose: %s' % purpose) - - def derivation_and_script_type_dialog(self, f, *, get_account_xpub=None): - message1 = _('Choose the type of addresses in your wallet.') - message2 = ' '.join([ - _('You can override the suggested derivation path.'), - _('If you are not sure what this is, leave this field unchanged.') - ]) - hide_choices = False - if self.wallet_type == 'multisig': - # There is no general standard for HD multisig. - # For legacy, this is partially compatible with BIP45; assumes index=0 - # For segwit, a custom path is used, as there is no standard at all. - default_choice_idx = 2 - choices = [ - ('standard', 'legacy multisig (p2sh)', normalize_bip32_derivation("m/45'/0")), - ('p2wsh-p2sh', 'p2sh-segwit multisig (p2wsh-p2sh)', purpose48_derivation(0, xtype='p2wsh-p2sh')), - ('p2wsh', 'native segwit multisig (p2wsh)', purpose48_derivation(0, xtype='p2wsh')), - ] - # if this is not the first cosigner, pre-select the expected script type, - # and hide the choices - script_type = self.get_script_type_of_wallet() - if script_type is not None: - script_types = [*zip(*choices)][0] - chosen_idx = script_types.index(script_type) - default_choice_idx = chosen_idx - hide_choices = True - else: - default_choice_idx = 2 - choices = [ - ('standard', 'legacy (p2pkh)', bip44_derivation(0, bip43_purpose=44)), - ('p2wpkh-p2sh', 'p2sh-segwit (p2wpkh-p2sh)', bip44_derivation(0, bip43_purpose=49)), - ('p2wpkh', 'native segwit (p2wpkh)', bip44_derivation(0, bip43_purpose=84)), - ] - while True: - try: - self.derivation_and_script_type_gui_specific_dialog( - run_next=f, - title=_('Script type and Derivation path'), - message1=message1, - message2=message2, - choices=choices, - test_text=is_bip32_derivation, - default_choice_idx=default_choice_idx, - get_account_xpub=get_account_xpub, - hide_choices=hide_choices, - ) - return - except ScriptTypeNotSupported as e: - self.show_error(e) - # let the user choose again - - def on_hw_derivation(self, name, device_info: 'DeviceInfo', derivation, xtype): - from .keystore import hardware_keystore - devmgr = self.plugins.device_manager - assert isinstance(self.plugin, HW_PluginBase) - try: - xpub = self.plugin.get_xpub(device_info.device.id_, derivation, xtype, self) - client = devmgr.client_by_id(device_info.device.id_, scan_now=False) - if not client: raise Exception("failed to find client for device id") - root_fingerprint = client.request_root_fingerprint_from_device() - label = client.label() # use this as device_info.label might be outdated! - soft_device_id = client.get_soft_device_id() # use this as device_info.device_id might be outdated! - except ScriptTypeNotSupported: - raise # this is handled in derivation_dialog - except BaseException as e: - self.logger.exception('') - self.show_error(e) - raise ChooseHwDeviceAgain() - d = { - 'type': 'hardware', - 'hw_type': name, - 'derivation': derivation, - 'root_fingerprint': root_fingerprint, - 'xpub': xpub, - 'label': label, - 'soft_device_id': soft_device_id, - } - try: - client.manipulate_keystore_dict_during_wizard_setup(d) - except Exception as e: - self.logger.exception('') - self.show_error(e) - raise ChooseHwDeviceAgain() - k = hardware_keystore(d) - self.on_keystore(k) - - def passphrase_dialog(self, run_next, is_restoring=False): - 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.'), - ]) - warn_issue4566 = is_restoring and self.seed_type == 'bip39' - self.line_dialog(title=title, message=message, warning=warning, - default='', test=lambda x:True, run_next=run_next, - warn_issue4566=warn_issue4566) - - def restore_from_seed(self): - self.opt_bip39 = True - self.opt_slip39 = True - self.opt_ext = True - is_cosigning_seed = lambda x: mnemonic.seed_type(x) in ['standard', 'segwit'] - test = mnemonic.is_seed if self.wallet_type == 'standard' else is_cosigning_seed - f = lambda *args: self.run('on_restore_seed', *args) - self.restore_seed_dialog(run_next=f, test=test) - - def on_restore_seed(self, seed, seed_type, is_ext): - self.seed_type = seed_type if seed_type != 'electrum' else mnemonic.seed_type(seed) - if self.seed_type == 'bip39': - def f(passphrase): - root_seed = bip39_to_seed(seed, passphrase) - self.on_restore_bip43(root_seed) - self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') - elif self.seed_type == 'slip39': - def f(passphrase): - root_seed = seed.decrypt(passphrase) - self.on_restore_bip43(root_seed) - self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') - elif self.seed_type in ['standard', 'segwit']: - f = lambda passphrase: self.run('create_keystore', seed, passphrase) - self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('') - elif self.seed_type == 'old': - self.run('create_keystore', seed, '') - elif mnemonic.is_any_2fa_seed_type(self.seed_type): - self.load_2fa() - self.run('on_restore_seed', seed, is_ext) - else: - raise Exception('Unknown seed type', self.seed_type) - - def on_restore_bip43(self, root_seed): - def f(derivation, script_type): - derivation = normalize_bip32_derivation(derivation) - self.run('on_bip43', root_seed, derivation, script_type) - if self.wallet_type == 'standard': - def get_account_xpub(account_path): - root_node = BIP32Node.from_rootseed(root_seed, xtype="standard") - account_node = root_node.subkey_at_private_derivation(account_path) - account_xpub = account_node.to_xpub() - return account_xpub - else: - get_account_xpub = None - self.derivation_and_script_type_dialog(f, get_account_xpub=get_account_xpub) - - def create_keystore(self, seed, passphrase): - k = keystore.from_seed(seed, passphrase, self.wallet_type == 'multisig') - if k.can_have_deterministic_lightning_xprv(): - self.data['lightning_xprv'] = k.get_lightning_xprv(None) - self.on_keystore(k) - - def on_bip43(self, root_seed, derivation, script_type): - k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script_type) - self.on_keystore(k) - - def get_script_type_of_wallet(self) -> Optional[str]: - if len(self.keystores) > 0: - ks = self.keystores[0] - if isinstance(ks, keystore.Xpub): - return xpub_type(ks.xpub) - return None - - def on_keystore(self, k: KeyStore): - has_xpub = isinstance(k, keystore.Xpub) - if has_xpub: - t1 = xpub_type(k.xpub) - if self.wallet_type == 'standard': - if has_xpub and t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: - self.show_error(_('Wrong key type') + ' %s'%t1) - self.run('choose_keystore') - return - self.keystores.append(k) - self.run('create_wallet') - elif self.wallet_type == 'multisig': - assert has_xpub - if t1 not in ['standard', 'p2wsh', 'p2wsh-p2sh']: - self.show_error(_('Wrong key type') + ' %s'%t1) - self.run('choose_keystore') - return - if k.xpub in map(lambda x: x.xpub, self.keystores): - self.show_error(_('Error: duplicate master public key')) - self.run('choose_keystore') - return - if len(self.keystores)>0: - t2 = xpub_type(self.keystores[0].xpub) - if t1 != t2: - self.show_error(_('Cannot add this cosigner:') + '\n' + "Their key type is '%s', we are '%s'"%(t1, t2)) - self.run('choose_keystore') - return - if len(self.keystores) == 0: - xpub = k.get_master_public_key() - self.reset_stack() - self.keystores.append(k) - self.run('show_xpub_and_add_cosigners', xpub) - return - self.reset_stack() - self.keystores.append(k) - if len(self.keystores) < self.n: - self.run('choose_keystore') - else: - self.run('create_wallet') - - def create_wallet(self): - encrypt_keystore = any(k.may_have_password() for k in self.keystores) - # note: the following condition ("if") is duplicated logic from - # wallet.get_available_storage_encryption_version() - if self.wallet_type == 'standard' and isinstance(self.keystores[0], Hardware_KeyStore): - # offer encrypting with a pw derived from the hw device - k = self.keystores[0] # type: Hardware_KeyStore - assert isinstance(self.plugin, HW_PluginBase) - try: - k.handler = self.plugin.create_handler(self) - password = k.get_password_for_storage_encryption() - except UserCancelled: - devmgr = self.plugins.device_manager - devmgr.unpair_pairing_code(k.pairing_code()) - raise ChooseHwDeviceAgain() - except BaseException as e: - self.logger.exception('') - self.show_error(str(e)) - raise ChooseHwDeviceAgain() - self.request_storage_encryption( - run_next=lambda encrypt_storage: self.on_password( - password, - encrypt_storage=encrypt_storage, - storage_enc_version=StorageEncryptionVersion.XPUB_PASSWORD, - encrypt_keystore=False)) - else: - # reset stack to disable 'back' button in password dialog - self.reset_stack() - # prompt the user to set an arbitrary password - self.request_password( - run_next=lambda password, encrypt_storage: self.on_password( - password, - encrypt_storage=encrypt_storage, - storage_enc_version=StorageEncryptionVersion.USER_PASSWORD, - encrypt_keystore=encrypt_keystore), - force_disable_encrypt_cb=not encrypt_keystore) - - def on_password(self, password, *, encrypt_storage: bool, - storage_enc_version=StorageEncryptionVersion.USER_PASSWORD, - encrypt_keystore: bool): - for k in self.keystores: - if k.may_have_password(): - k.update_password(None, password) - if self.wallet_type == 'standard': - self.data['seed_type'] = self.seed_type - keys = self.keystores[0].dump() - self.data['keystore'] = keys - elif self.wallet_type == 'multisig': - for i, k in enumerate(self.keystores): - self.data['x%d/'%(i+1)] = k.dump() - elif self.wallet_type == 'imported': - if len(self.keystores) > 0: - keys = self.keystores[0].dump() - self.data['keystore'] = keys - else: - raise Exception('Unknown wallet type') - self.pw_args = WizardWalletPasswordSetting(password=password, - encrypt_storage=encrypt_storage, - storage_enc_version=storage_enc_version, - encrypt_keystore=encrypt_keystore) - self.terminate() - - def create_storage(self, path) -> Tuple[WalletStorage, WalletDB]: - if os.path.exists(path): - raise Exception('file already exists at path') - assert self.pw_args, f"pw_args not set?!" - pw_args = self.pw_args - self.pw_args = None # clean-up so that it can get GC-ed - storage = WalletStorage(path) - if pw_args.encrypt_storage: - storage.set_password(pw_args.password, enc_version=pw_args.storage_enc_version) - db = WalletDB('', storage=storage, manual_upgrades=False) - db.set_keystore_encryption(bool(pw_args.password) and pw_args.encrypt_keystore) - for key, value in self.data.items(): - db.put(key, value) - db.load_plugins() - db.write() - return storage, db - - def terminate(self, *, storage: WalletStorage = None, - db: WalletDB = None, - aborted: bool = False) -> None: - raise NotImplementedError() # implemented by subclasses - - def show_xpub_and_add_cosigners(self, xpub): - self.show_xpub_dialog(xpub=xpub, run_next=lambda x: self.run('choose_keystore')) - - def choose_seed_type(self): - seed_type = 'standard' if self.config.WIZARD_DONT_CREATE_SEGWIT else 'segwit' - self.create_seed(seed_type) - - def create_seed(self, seed_type): - from . import mnemonic - self.seed_type = seed_type - seed = mnemonic.Mnemonic('en').make_seed(seed_type=self.seed_type) - self.opt_bip39 = False - self.opt_ext = True - self.opt_slip39 = False - f = lambda x: self.request_passphrase(seed, x) - self.show_seed_dialog(run_next=f, seed_text=seed) - - def request_passphrase(self, seed, opt_passphrase): - if opt_passphrase: - f = lambda x: self.confirm_seed(seed, x) - self.passphrase_dialog(run_next=f) - else: - self.run('confirm_seed', seed, '') - - def confirm_seed(self, seed, passphrase): - f = lambda x: self.confirm_passphrase(seed, passphrase) - self.confirm_seed_dialog( - run_next=f, - seed=seed if self.config.get('debug_seed') else '', - test=lambda x: mnemonic.is_matching_seed(seed=seed, seed_again=x), - ) - - def confirm_passphrase(self, seed, passphrase): - f = lambda x: self.run('create_keystore', seed, x) - if passphrase: - title = _('Confirm Seed Extension') - message = '\n'.join([ - _('Your seed extension must be saved together with your seed.'), - _('Please type it here.'), - ]) - self.line_dialog(run_next=f, title=title, message=message, default='', test=lambda x: x==passphrase) - else: - f('') - - def show_error(self, msg: Union[str, BaseException]) -> None: - raise NotImplementedError() diff --git a/electrum/gui/common_qt/__init__.py b/electrum/gui/common_qt/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/electrum/gui/qml/plugins.py b/electrum/gui/common_qt/plugins.py similarity index 90% rename from electrum/gui/qml/plugins.py rename to electrum/gui/common_qt/plugins.py index cee411a10bd7..ba2189dd25c1 100644 --- a/electrum/gui/qml/plugins.py +++ b/electrum/gui/common_qt/plugins.py @@ -1,8 +1,8 @@ -from PyQt5.QtCore import pyqtSignal, pyqtSlot, pyqtProperty, QObject +from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject -from electrum.i18n import _ from electrum.logging import get_logger + class PluginQObject(QObject): logger = get_logger(__name__) @@ -24,6 +24,8 @@ def name(self): return self._name @pyqtProperty(bool, notify=busyChanged) def busy(self): return self._busy + # below only used for QML, not compatible yet with Qt + @pyqtProperty(bool, notify=pluginEnabledChanged) def pluginEnabled(self): return self.plugin.is_enabled() diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index b594af707816..88f5a1779acd 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -317,10 +317,6 @@ Item { function onOtpRequested() { console.log('OTP requested') var dialog = otpDialog.createObject(mainView) - dialog.accepted.connect(function() { - console.log('accepted ' + dialog.otpauth) - Daemon.currentWallet.finish_otp(dialog.otpauth) - }) dialog.open() } function onBroadcastFailed(txid, code, message) { diff --git a/electrum/gui/qml/components/wizard/WCCosignerKeystore.qml b/electrum/gui/qml/components/wizard/WCCosignerKeystore.qml index c6d971c5ad37..46801a9b5efd 100644 --- a/electrum/gui/qml/components/wizard/WCCosignerKeystore.qml +++ b/electrum/gui/qml/components/wizard/WCCosignerKeystore.qml @@ -19,7 +19,9 @@ WizardComponent { function apply() { wizard_data['cosigner_keystore_type'] = keystoregroup.checkedButton.keystoretype wizard_data['multisig_current_cosigner'] = cosigner - wizard_data['multisig_cosigner_data'][cosigner.toString()] = {} + wizard_data['multisig_cosigner_data'][cosigner.toString()] = { + 'keystore_type': keystoregroup.checkedButton.keystoretype + } } ButtonGroup { @@ -80,13 +82,13 @@ WizardComponent { } ElRadioButton { ButtonGroup.group: keystoregroup - property string keystoretype: 'key' + property string keystoretype: 'masterkey' checked: true text: qsTr('Cosigner key') } ElRadioButton { ButtonGroup.group: keystoregroup - property string keystoretype: 'seed' + property string keystoretype: 'haveseed' text: qsTr('Cosigner seed') } } diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index ab15e26fa0f8..83b43f2ff459 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -9,7 +9,7 @@ import "../controls" WizardComponent { securePage: true - valid: seedtext.text != '' + valid: seedtext.text != '' && extendcb.checked ? customwordstext.text != '' : true function apply() { wizard_data['seed'] = seedtext.text diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index bd61daa86988..86da1d924124 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -68,6 +68,9 @@ WizardComponent { valid = false validationtext.text = '' + if (extendcb.checked && customwordstext.text == '') + return + var validSeed = bitcoin.verifySeed(seedtext.text, seed_variant_cb.currentValue, wizard_data['wallet_type']) if (!cosigner || !validSeed) { valid = validSeed diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCScriptAndDerivation.qml similarity index 100% rename from electrum/gui/qml/components/wizard/WCBIP39Refine.qml rename to electrum/gui/qml/components/wizard/WCScriptAndDerivation.qml diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 20de321b0ed4..99ac72123a64 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -5,10 +5,9 @@ import sys import html import threading -import asyncio from typing import TYPE_CHECKING, Set -from PyQt5.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject, QUrl, QLocale, +from PyQt5.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject, qInstallMessageHandler, QTimer, QSortFilterProxyModel) from PyQt5.QtGui import QGuiApplication, QFontDatabase from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine @@ -61,6 +60,7 @@ notification = None + class QEAppController(BaseCrashReporter, QObject): _dummy = pyqtSignal() userNotify = pyqtSignal(str, str) @@ -319,6 +319,7 @@ def secureWindow(self, secure): self._secureWindow = secure self.secureWindowChanged.emit() + class ElectrumQmlApplication(QGuiApplication): _valid = True @@ -376,7 +377,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: ' self.plugins = plugins self._qeconfig = QEConfig(config) self._qenetwork = QENetwork(daemon.network, self._qeconfig) - self.daemon = QEDaemon(daemon) + self.daemon = QEDaemon(daemon, self.plugins) self.appController = QEAppController(self, self.daemon, self.plugins) self._maxAmount = QEAmount(is_max=True) self.context.setContextProperty('AppController', self.appController) @@ -413,6 +414,7 @@ def message_handler(self, line, funct, file): return self.logger.warning(file) + class Exception_Hook(QObject, Logger): _report_exception = pyqtSignal(object, object, object, object) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 5c38e0b12bde..b728cbf36f77 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -1,5 +1,6 @@ import os import threading +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject @@ -7,10 +8,8 @@ from electrum.i18n import _ from electrum.logging import get_logger from electrum.util import WalletFileException, standardize_path -from electrum.wallet import Abstract_Wallet from electrum.plugin import run_hook from electrum.lnchannel import ChannelState -from electrum.daemon import Daemon from .auth import AuthMixin, auth_protect from .qefx import QEFX @@ -18,6 +17,11 @@ from .qewalletdb import QEWalletDB from .qewizard import QENewWalletWizard, QEServerConnectWizard +if TYPE_CHECKING: + from electrum.daemon import Daemon + from electrum.plugin import Plugins + + # wallet list model. supports both wallet basenames (wallet file basenames) # and whole Wallet instances (loaded wallets) class QEWalletListModel(QAbstractListModel): @@ -108,6 +112,7 @@ def updateWallet(self, path): return i += 1 + class QEDaemon(AuthMixin, QObject): _logger = get_logger(__name__) @@ -135,9 +140,10 @@ class QEDaemon(AuthMixin, QObject): walletOpenError = pyqtSignal([str], arguments=["error"]) walletDeleteError = pyqtSignal([str,str], arguments=['code', 'message']) - def __init__(self, daemon: 'Daemon', parent=None): + def __init__(self, daemon: 'Daemon', plugins: 'Plugins', parent=None): super().__init__(parent) self.daemon = daemon + self.plugins = plugins self.qefx = QEFX(daemon.fx, daemon.config) self._backendWalletLoaded.connect(self._on_backend_wallet_loaded) @@ -334,7 +340,7 @@ def setPassword(self, password): @pyqtProperty(QENewWalletWizard, notify=newWalletWizardChanged) def newWalletWizard(self): if not self._new_wallet_wizard: - self._new_wallet_wizard = QENewWalletWizard(self) + self._new_wallet_wizard = QENewWalletWizard(self, self.plugins) return self._new_wallet_wizard diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 28db8e96b3a4..4475a6c70146 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -3,6 +3,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from electrum.i18n import _ from electrum.logging import get_logger from electrum.storage import WalletStorage, StorageEncryptionVersion from electrum.wallet_db import WalletDB @@ -189,7 +190,7 @@ def _load_db(self): return if self._db.get_action(): self._logger.warning('action pending. QML version doesn\'t support continuation of wizard') - return + raise WalletFileException(_('This wallet has an action pending. This is currently not supported on mobile')) if self._db.requires_upgrade(): self._logger.warning('wallet requires upgrade, upgrading') diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index b5d16700e4a9..9884a881fd84 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -1,12 +1,16 @@ import os +from typing import TYPE_CHECKING from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject -from PyQt5.QtQml import QQmlApplicationEngine from electrum.logging import get_logger from electrum import mnemonic from electrum.wizard import NewWalletWizard, ServerConnectWizard +if TYPE_CHECKING: + from electrum.gui.qml.qedaemon import QEDaemon + from electrum.plugin import Plugins + class QEAbstractWizard(QObject): _logger = get_logger(__name__) @@ -26,7 +30,6 @@ def viewToComponent(self, view): @pyqtSlot('QJSValue', result='QVariant') def submit(self, wizard_data): wdata = wizard_data.toVariant() - self.log_state(wdata) view = self.resolve_next(self._current.view, wdata) return { 'view': view.view, 'wizard_data': view.wizard_data } @@ -46,10 +49,10 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): createError = pyqtSignal([str], arguments=["error"]) createSuccess = pyqtSignal() - def __init__(self, daemon, parent = None): - NewWalletWizard.__init__(self, daemon) + def __init__(self, daemon: 'QEDaemon', plugins: 'Plugins', parent = None): + NewWalletWizard.__init__(self, daemon.daemon, plugins) QEAbstractWizard.__init__(self, parent) - self._daemon = daemon + self._qedaemon = daemon # attach view names and accept handlers self.navmap_merge({ @@ -59,13 +62,13 @@ def __init__(self, daemon, parent = None): 'create_seed': { 'gui': 'WCCreateSeed' }, 'confirm_seed': { 'gui': 'WCConfirmSeed' }, 'have_seed': { 'gui': 'WCHaveSeed' }, - 'bip39_refine': { 'gui': 'WCBIP39Refine' }, + 'script_and_derivation': { 'gui': 'WCScriptAndDerivation' }, 'have_master_key': { 'gui': 'WCHaveMasterKey' }, 'multisig': { 'gui': 'WCMultisig' }, 'multisig_cosigner_keystore': { 'gui': 'WCCosignerKeystore' }, 'multisig_cosigner_key': { 'gui': 'WCHaveMasterKey' }, 'multisig_cosigner_seed': { 'gui': 'WCHaveSeed' }, - 'multisig_cosigner_bip39_refine': { 'gui': 'WCBIP39Refine' }, + 'multisig_cosigner_script_and_derivation': { 'gui': 'WCBIP39Refine' }, 'imported': { 'gui': 'WCImport' }, 'wallet_password': { 'gui': 'WCWalletPassword' } }) @@ -81,7 +84,7 @@ def path(self, path): self.pathChanged.emit() def is_single_password(self): - return self._daemon.singlePasswordEnabled + return self._qedaemon.singlePasswordEnabled @pyqtSlot('QJSValue', result=bool) def hasDuplicateMasterKeys(self, js_data): @@ -108,7 +111,7 @@ def createStorage(self, js_data, single_password_enabled, 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']) + path = os.path.join(os.path.dirname(self._qedaemon.daemon.config.get_wallet_path()), data['wallet_name']) try: self.create_storage(path, data) @@ -125,10 +128,9 @@ def createStorage(self, js_data, single_password_enabled, single_password): class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard): - def __init__(self, daemon, parent = None): - ServerConnectWizard.__init__(self, daemon) + def __init__(self, daemon: 'QEDaemon', parent=None): + ServerConnectWizard.__init__(self, daemon.daemon) QEAbstractWizard.__init__(self, parent) - self._daemon = daemon # attach view names self.navmap_merge({ diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index c84721f64da1..376a4a06faa4 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -26,11 +26,14 @@ import os import signal import sys -import traceback import threading from typing import Optional, TYPE_CHECKING, List, Sequence -from electrum import GuiImportError +from electrum import GuiImportError, WalletStorage +from .wizard.server_connect import QEServerConnectWizard +from .wizard.wallet import QENewWalletWizard +from electrum.wizard import WizardViewState +from electrum.keystore import load_keystore try: import PyQt5 @@ -41,8 +44,7 @@ "you may try 'sudo apt-get install python3-pyqt5'") from e from PyQt5.QtGui import QGuiApplication -from PyQt5.QtWidgets import (QApplication, QSystemTrayIcon, QWidget, QMenu, - QMessageBox) +from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox from PyQt5.QtCore import QObject, pyqtSignal, QTimer, Qt import PyQt5.QtCore as QtCore @@ -56,7 +58,6 @@ from electrum.i18n import _, set_language from electrum.plugin import run_hook -from electrum.base_wizard import GoBack from electrum.util import (UserCancelled, profiler, send_exception_to_crash_reporter, WalletFileException, BitcoinException, get_new_wallet_name) from electrum.wallet import Wallet, Abstract_Wallet @@ -65,8 +66,7 @@ from electrum.gui import BaseElectrumGui from electrum.simple_config import SimpleConfig -from .installwizard import InstallWizard, WalletAlreadyOpenInMemory -from .util import read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin +from .util import read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin, WWLabel from .main_window import ElectrumWindow from .network_dialog import NetworkDialog from .stylesheet_patcher import patch_qt_stylesheet @@ -148,6 +148,9 @@ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugin self._default_qtstylesheet = self.app.styleSheet() self.reload_app_stylesheet() + # always load 2fa + self.plugins.load_plugin('trustedcoin') + run_hook('init_qt', self) def _init_tray(self): @@ -398,26 +401,65 @@ def start_new_window( return window def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]: - wizard = InstallWizard(self.config, self.app, self.plugins, gui_object=self) - try: - path, storage = wizard.select_storage(path, self.daemon.get_wallet) - # storage is None if file does not exist - if storage is None: - wizard.path = path # needed by trustedcoin plugin - wizard.run('new') - storage, db = wizard.create_storage(path) - else: - db = WalletDB(storage.read(), storage=storage, manual_upgrades=False) - wizard.run_upgrades(storage, db) - except (UserCancelled, GoBack): - return - except WalletAlreadyOpenInMemory as e: - return e.wallet - finally: - wizard.terminate() - # return if wallet creation is not complete - if storage is None or db.get_action(): + wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path) + result = wizard.exec() + # TODO: use dialog.open() instead to avoid new event loop spawn? + self.logger.info(f'{result}') + if result == QENewWalletWizard.Rejected: + self.logger.info('ok bye bye') return + + d = wizard.get_wizard_data() + + if d['wallet_is_open']: + for window in self.windows: + if window.wallet.storage.path == d['wallet_name']: + return window.wallet + raise Exception('found by wizard but not here?!') + + if not d['wallet_exists']: + self.logger.info('about to create wallet') + wizard.create_storage() + if d['wallet_type'] == '2fa' and 'x3/' not in d: + return + wallet_file = wizard.path + else: + wallet_file = d['wallet_name'] + + storage = WalletStorage(wallet_file) + if storage.is_encrypted_with_user_pw() or storage.is_encrypted_with_hw_device(): + storage.decrypt(d['password']) + + db = WalletDB(storage.read(), storage=storage, manual_upgrades=True) + if db.requires_split() or db.requires_upgrade(): + try: + wizard.run_upgrades(db) + except UserCancelled: + return + + if action := db.get_action(): + # wallet creation is not complete, 2fa online phase + assert action[1] == 'accept_terms_of_use', 'only support for resuming trustedcoin split setup' + k1 = load_keystore(db, 'x1/') + if 'password' in d and d['password']: + xprv = k1.get_master_private_key(d['password']) + else: + xprv = db.get('x1/')['xprv'] + data = { + 'wallet_name': os.path.basename(wallet_file), + 'xprv1': xprv, + 'xpub1': db.get('x1/')['xpub'], + 'xpub2': db.get('x2/')['xpub'], + } + wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path, + start_viewstate=WizardViewState('trustedcoin_tos_email', data, {})) + result = wizard.exec() + if result == QENewWalletWizard.Rejected: + self.logger.info('ok bye bye') + return + db.put('x3/', wizard.get_wizard_data()['x3/']) + db.write() + wallet = Wallet(db, config=self.config) wallet.start_network(self.daemon.network) self.daemon.add_wallet(wallet) @@ -438,9 +480,8 @@ def init_network(self): if self.daemon.network: # first-start network-setup if not self.config.cv.NETWORK_AUTO_CONNECT.is_set(): - wizard = InstallWizard(self.config, self.app, self.plugins, gui_object=self) - wizard.init_network(self.daemon.network) - wizard.terminate() + dialog = QEServerConnectWizard(self.config, self.app, self.plugins, self.daemon) + dialog.exec() # start network self.daemon.start_network() @@ -457,8 +498,6 @@ def main(self): self.init_network() except UserCancelled: return - except GoBack: - return except Exception as e: self.logger.exception('') return diff --git a/electrum/gui/qt/installwizard.py b/electrum/gui/qt/installwizard.py deleted file mode 100644 index b875f4e3a446..000000000000 --- a/electrum/gui/qt/installwizard.py +++ /dev/null @@ -1,797 +0,0 @@ -# Copyright (C) 2018 The Electrum developers -# Distributed under the MIT software license, see the accompanying -# file LICENCE or http://www.opensource.org/licenses/mit-license.php - -import os -import json -import sys -import threading -import traceback -from typing import Tuple, List, Callable, NamedTuple, Optional, TYPE_CHECKING -from functools import partial - -from PyQt5.QtCore import QRect, QEventLoop, Qt, pyqtSignal -from PyQt5.QtGui import QPalette, QPen, QPainter, QPixmap -from PyQt5.QtWidgets import (QWidget, QDialog, QLabel, QHBoxLayout, QMessageBox, - QVBoxLayout, QLineEdit, QFileDialog, QPushButton, - QGridLayout, QSlider, QScrollArea, QApplication) - -from electrum.wallet import Wallet, Abstract_Wallet -from electrum.storage import WalletStorage, StorageReadWriteError -from electrum.util import UserCancelled, InvalidPassword, WalletFileException, get_new_wallet_name -from electrum.base_wizard import BaseWizard, HWD_SETUP_DECRYPT_WALLET, GoBack, ReRunDialog -from electrum.network import Network -from electrum.i18n import _ - -from .seed_dialog import SeedLayout, KeysLayout -from .network_dialog import NetworkChoiceLayout -from .util import (MessageBoxMixin, Buttons, icon_path, ChoicesLayout, WWLabel, - InfoButton, char_width_in_lineedit, PasswordLineEdit, font_height) -from .password_dialog import PasswordLayout, PasswordLayoutForHW, PW_NEW -from .bip39_recovery_dialog import Bip39RecoveryDialog -from electrum.plugin import run_hook, Plugins - -if TYPE_CHECKING: - from electrum.simple_config import SimpleConfig - from electrum.wallet_db import WalletDB - from . import ElectrumGui - - -MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\ - + _("Leave this field empty if you want to disable encryption.") -MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ - + _("Your wallet file does not contain secrets, mostly just metadata. ") \ - + _("It also contains your master public key that allows watching your addresses.") + '\n\n'\ - + _("Note: If you enable this setting, you will need your hardware device to open your wallet.") -WIF_HELP_TEXT = (_('WIF keys are typed in Electrum, based on script type.') + '\n\n' + - _('A few examples') + ':\n' + - 'p2pkh:KxZcY47uGp9a... \t-> 1DckmggQM...\n' + - 'p2wpkh-p2sh:KxZcY47uGp9a... \t-> 3NhNeZQXF...\n' + - 'p2wpkh:KxZcY47uGp9a... \t-> bc1q3fjfk...') -# note: full key is KxZcY47uGp9aVQAb6VVvuBs8SwHKgkSR2DbZUzjDzXf2N2GPhG9n -MSG_PASSPHRASE_WARN_ISSUE4566 = _("Warning") + ": "\ - + _("You have multiple consecutive whitespaces or leading/trailing " - "whitespaces in your passphrase.") + " " \ - + _("This is discouraged.") + " " \ - + _("Due to a bug, old versions of Electrum will NOT be creating the " - "same wallet as newer versions or other software.") - - -class CosignWidget(QWidget): - - def __init__(self, m, n): - QWidget.__init__(self) - self.size = max(120, 9 * font_height()) - self.R = QRect(0, 0, self.size, self.size) - self.setGeometry(self.R) - self.setMinimumHeight(self.size) - self.setMaximumHeight(self.size) - self.m = m - self.n = n - - def set_n(self, n): - self.n = n - self.update() - - def set_m(self, m): - self.m = m - self.update() - - def paintEvent(self, event): - bgcolor = self.palette().color(QPalette.Background) - pen = QPen(bgcolor, 7, Qt.SolidLine) - qp = QPainter() - qp.begin(self) - qp.setPen(pen) - qp.setRenderHint(QPainter.Antialiasing) - qp.setBrush(Qt.gray) - for i in range(self.n): - alpha = int(16* 360 * i/self.n) - alpha2 = int(16* 360 * 1/self.n) - qp.setBrush(Qt.green if i Tuple[str, Optional[WalletStorage]]: - if os.path.isdir(path): - raise Exception("wallet path cannot point to a directory") - - vbox = QVBoxLayout() - hbox = QHBoxLayout() - hbox.addWidget(QLabel(_('Wallet') + ':')) - name_e = QLineEdit() - hbox.addWidget(name_e) - button = QPushButton(_('Choose...')) - hbox.addWidget(button) - vbox.addLayout(hbox) - - msg_label = WWLabel('') - vbox.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() - vbox.addLayout(hbox2) - - vbox.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) - vbox.addWidget(widget_create_new) - - self.set_layout(vbox, title=_('Electrum wallet')) - - 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: - 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) - 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) - 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: name_e.setText(get_new_wallet_name(wallet_folder))) # FIXME get_new_wallet_name might raise - name_e.textChanged.connect(on_filename) - name_e.setText(os.path.basename(path)) - - def run_user_interaction_loop(): - while True: - if self.loop.exec_() != 2: # 2 = next - raise UserCancelled() - assert temp_storage - if temp_storage.file_exists() and not temp_storage.is_encrypted(): - break - if not temp_storage.file_exists(): - break - wallet_from_memory = get_wallet_from_daemon(temp_storage.path) - if wallet_from_memory: - raise WalletAlreadyOpenInMemory(wallet_from_memory) - if temp_storage.file_exists() and temp_storage.is_encrypted(): - if temp_storage.is_encrypted_with_user_pw(): - password = pw_e.text() - try: - temp_storage.decrypt(password) - break - except InvalidPassword as e: - self.show_message(title=_('Error'), msg=str(e)) - continue - except BaseException as e: - self.logger.exception('') - self.show_message(title=_('Error'), msg=repr(e)) - raise UserCancelled() - elif temp_storage.is_encrypted_with_hw_device(): - try: - self.run('choose_hw_device', HWD_SETUP_DECRYPT_WALLET, storage=temp_storage) - except InvalidPassword as e: - self.show_message(title=_('Error'), - msg=_('Failed to decrypt using this hardware device.') + '\n' + - _('If you use a passphrase, make sure it is correct.')) - self.reset_stack() - return self.select_storage(path, get_wallet_from_daemon) - except (UserCancelled, GoBack): - raise - except BaseException as e: - self.logger.exception('') - self.show_message(title=_('Error'), msg=repr(e)) - raise UserCancelled() - if temp_storage.is_past_initial_decryption(): - break - else: - raise UserCancelled() - else: - raise Exception('Unexpected encryption version') - - try: - run_user_interaction_loop() - finally: - try: - pw_e.clear() - except RuntimeError: # wrapped C/C++ object has been deleted. - pass # happens when decrypting with hw device - - return temp_storage.path, (temp_storage if temp_storage.file_exists() else None) - - def run_upgrades(self, storage: WalletStorage, db: 'WalletDB') -> None: - path = storage.path - if db.requires_split(): - self.hide() - msg = _("The wallet '{}' contains multiple accounts, which are no longer supported since Electrum 2.7.\n\n" - "Do you want to split your wallet into multiple files?").format(path) - if not self.question(msg): - return - file_list = db.split_accounts(path) - msg = _('Your accounts have been moved to') + ':\n' + '\n'.join(file_list) + '\n\n'+ _('Do you want to delete the old file') + ':\n' + path - if self.question(msg): - os.remove(path) - self.show_warning(_('The file was removed')) - # raise now, to avoid having the old storage opened - raise UserCancelled() - - action = db.get_action() - if action and db.requires_upgrade(): - raise WalletFileException('Incomplete wallet files cannot be upgraded.') - if action: - self.hide() - msg = _("The file '{}' contains an incompletely created wallet.\n" - "Do you want to complete its creation now?").format(path) - if not self.question(msg): - if self.question(_("Do you want to delete '{}'?").format(path)): - os.remove(path) - self.show_warning(_('The file was removed')) - return - self.show() - self.data = json.loads(storage.read()) - self.run(action) - for k, v in self.data.items(): - db.put(k, v) - db.write() - return - - if db.requires_upgrade(): - self.upgrade_db(storage, db) - - def on_error(self, exc_info): - if not isinstance(exc_info[1], UserCancelled): - self.logger.error("on_error", exc_info=exc_info) - self.show_error(str(exc_info[1])) - - def set_icon(self, filename): - prior_filename, self.icon_filename = self.icon_filename, filename - self.logo.setPixmap(QPixmap(icon_path(filename)) - .scaledToWidth(60, mode=Qt.SmoothTransformation)) - return prior_filename - - def set_layout(self, layout, title=None, next_enabled=True): - self.title.setText("%s"%title if title else "") - self.title.setVisible(bool(title)) - # Get rid of any prior layout by assigning it to a temporary widget - prior_layout = self.main_widget.layout() - if prior_layout: - QWidget().setLayout(prior_layout) - self.main_widget.setLayout(layout) - self.back_button.setEnabled(True) - self.next_button.setEnabled(next_enabled) - if next_enabled: - self.next_button.setFocus() - self.main_widget.setVisible(True) - self.please_wait.setVisible(False) - - def exec_layout(self, layout, title=None, raise_on_cancel=True, - next_enabled=True, focused_widget=None): - self.set_layout(layout, title, next_enabled) - if focused_widget: - focused_widget.setFocus() - result = self.loop.exec_() - if not result and raise_on_cancel: - raise UserCancelled() - if result == 1: - raise GoBack from None - self.title.setVisible(False) - self.back_button.setEnabled(False) - self.next_button.setEnabled(False) - self.main_widget.setVisible(False) - self.please_wait.setVisible(True) - self.refresh_gui() - return result - - def refresh_gui(self): - # For some reason, to refresh the GUI this needs to be called twice - self.app.processEvents() - self.app.processEvents() - - def remove_from_recently_open(self, filename): - self.config.remove_from_recently_open(filename) - - def text_input(self, title, message, is_valid, allow_multi=False): - slayout = KeysLayout(parent=self, header_layout=message, is_valid=is_valid, - allow_multi=allow_multi, config=self.config) - self.exec_layout(slayout, title, next_enabled=False) - return slayout.get_text() - - def seed_input(self, title, message, is_seed, options): - slayout = SeedLayout( - title=message, - is_seed=is_seed, - options=options, - parent=self, - config=self.config, - ) - self.exec_layout(slayout, title, next_enabled=False) - return slayout.get_seed(), slayout.seed_type, slayout.is_ext - - @wizard_dialog - def add_xpub_dialog(self, title, message, is_valid, run_next, allow_multi=False, show_wif_help=False): - header_layout = QHBoxLayout() - label = WWLabel(message) - label.setMinimumWidth(400) - header_layout.addWidget(label) - if show_wif_help: - header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignRight) - return self.text_input(title, header_layout, is_valid, allow_multi) - - @wizard_dialog - def add_cosigner_dialog(self, run_next, index, is_valid): - title = _("Add Cosigner") + " %d"%index - message = ' '.join([ - _('Please enter the master public key (xpub) of your cosigner.'), - _('Enter their master private key (xprv) if you want to be able to sign for them.') - ]) - return self.text_input(title, message, is_valid) - - @wizard_dialog - def restore_seed_dialog(self, run_next, test): - options = [] - if self.opt_ext: - options.append('ext') - if self.opt_bip39: - options.append('bip39') - if self.opt_slip39: - options.append('slip39') - title = _('Enter Seed') - message = _('Please enter your seed phrase in order to restore your wallet.') - return self.seed_input(title, message, test, options) - - @wizard_dialog - def confirm_seed_dialog(self, run_next, seed, test): - self.app.clipboard().clear() - 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.') - ]) - seed, seed_type, is_ext = self.seed_input(title, message, test, None) - return seed - - @wizard_dialog - def show_seed_dialog(self, run_next, seed_text): - title = _("Your wallet generation seed is:") - slayout = SeedLayout( - seed=seed_text, - title=title, - msg=True, - options=['ext'], - config=self.config, - ) - self.exec_layout(slayout) - return slayout.is_ext - - def pw_layout(self, msg, kind, force_disable_encrypt_cb): - pw_layout = PasswordLayout( - msg=msg, kind=kind, OK_button=self.next_button, - force_disable_encrypt_cb=force_disable_encrypt_cb) - pw_layout.encrypt_cb.setChecked(True) - try: - self.exec_layout(pw_layout.layout(), focused_widget=pw_layout.new_pw) - return pw_layout.new_password(), pw_layout.encrypt_cb.isChecked() - finally: - pw_layout.clear_password_fields() - - @wizard_dialog - def request_password(self, run_next, force_disable_encrypt_cb=False): - """Request the user enter a new password and confirm it. Return - the password or None for no password.""" - return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW, force_disable_encrypt_cb) - - @wizard_dialog - def request_storage_encryption(self, run_next): - playout = PasswordLayoutForHW(MSG_HW_STORAGE_ENCRYPTION) - playout.encrypt_cb.setChecked(True) - self.exec_layout(playout.layout()) - return playout.encrypt_cb.isChecked() - - @wizard_dialog - def confirm_dialog(self, title, message, run_next): - self.confirm(message, title) - - def confirm(self, message, title): - label = WWLabel(message) - vbox = QVBoxLayout() - vbox.addWidget(label) - self.exec_layout(vbox, title) - - @wizard_dialog - def action_dialog(self, action, run_next): - self.run(action) - - def terminate(self, **kwargs): - self.accept_signal.emit() - - def waiting_dialog(self, task, msg, on_finished=None): - label = WWLabel(msg) - vbox = QVBoxLayout() - vbox.addSpacing(100) - label.setMinimumWidth(300) - label.setAlignment(Qt.AlignCenter) - vbox.addWidget(label) - self.set_layout(vbox, next_enabled=False) - self.back_button.setEnabled(False) - - t = threading.Thread(target=task) - t.start() - while True: - t.join(1.0/60) - if t.is_alive(): - self.refresh_gui() - else: - break - if on_finished: - on_finished() - - def run_task_without_blocking_gui(self, task, *, msg=None): - assert self.gui_thread == threading.current_thread(), 'must be called from GUI thread' - if msg is None: - msg = _("Please wait...") - - exc = None # type: Optional[Exception] - res = None - def task_wrapper(): - nonlocal exc - nonlocal res - try: - res = task() - except Exception as e: - exc = e - self.waiting_dialog(task_wrapper, msg=msg) - if exc is None: - return res - else: - raise exc - - @wizard_dialog - def choice_dialog(self, title, message, choices, run_next): - c_values = [x[0] for x in choices] - c_titles = [x[1] for x in choices] - clayout = ChoicesLayout(message, c_titles) - vbox = QVBoxLayout() - vbox.addLayout(clayout.layout()) - self.exec_layout(vbox, title) - action = c_values[clayout.selected_index()] - return action - - def query_choice(self, msg, choices): - """called by hardware wallets""" - clayout = ChoicesLayout(msg, choices) - vbox = QVBoxLayout() - vbox.addLayout(clayout.layout()) - self.exec_layout(vbox, '') - return clayout.selected_index() - - @wizard_dialog - def derivation_and_script_type_gui_specific_dialog( - self, - *, - title: str, - message1: str, - choices: List[Tuple[str, str, str]], - hide_choices: bool = False, - message2: str, - test_text: Callable[[str], int], - run_next, - default_choice_idx: int = 0, - get_account_xpub=None, - ) -> Tuple[str, str]: - vbox = QVBoxLayout() - - if get_account_xpub: - button = QPushButton(_("Detect Existing Accounts")) - def on_account_select(account): - script_type = account["script_type"] - if script_type == "p2pkh": - script_type = "standard" - button_index = c_values.index(script_type) - button = clayout.group.buttons()[button_index] - button.setChecked(True) - line.setText(account["derivation_path"]) - button.clicked.connect(lambda: Bip39RecoveryDialog(self, get_account_xpub, on_account_select)) - vbox.addWidget(button, alignment=Qt.AlignLeft) - vbox.addWidget(QLabel(_("Or"))) - - c_values = [x[0] for x in choices] - c_titles = [x[1] for x in choices] - c_default_text = [x[2] for x in choices] - def on_choice_click(clayout): - idx = clayout.selected_index() - line.setText(c_default_text[idx]) - clayout = ChoicesLayout(message1, c_titles, on_choice_click, - checked_index=default_choice_idx) - if not hide_choices: - vbox.addLayout(clayout.layout()) - - vbox.addWidget(WWLabel(message2)) - - line = QLineEdit() - def on_text_change(text): - self.next_button.setEnabled(test_text(text)) - line.textEdited.connect(on_text_change) - on_choice_click(clayout) # set default text for "line" - vbox.addWidget(line) - - self.exec_layout(vbox, title) - choice = c_values[clayout.selected_index()] - return str(line.text()), choice - - @wizard_dialog - def line_dialog(self, run_next, title, message, default, test, warning='', - presets=(), warn_issue4566=False): - vbox = QVBoxLayout() - vbox.addWidget(WWLabel(message)) - line = QLineEdit() - line.setText(default) - def f(text): - self.next_button.setEnabled(test(text)) - if warn_issue4566: - text_whitespace_normalised = ' '.join(text.split()) - warn_issue4566_label.setVisible(text != text_whitespace_normalised) - line.textEdited.connect(f) - vbox.addWidget(line) - vbox.addWidget(WWLabel(warning)) - - warn_issue4566_label = WWLabel(MSG_PASSPHRASE_WARN_ISSUE4566) - warn_issue4566_label.setVisible(False) - vbox.addWidget(warn_issue4566_label) - - for preset in presets: - button = QPushButton(preset[0]) - button.clicked.connect(lambda __, text=preset[1]: line.setText(text)) - button.setMinimumWidth(150) - hbox = QHBoxLayout() - hbox.addWidget(button, alignment=Qt.AlignCenter) - vbox.addLayout(hbox) - - self.exec_layout(vbox, title, next_enabled=test(default)) - return line.text() - - @wizard_dialog - def show_xpub_dialog(self, xpub, run_next): - msg = ' '.join([ - _("Here is your master public key."), - _("Please share it with your cosigners.") - ]) - vbox = QVBoxLayout() - layout = SeedLayout( - xpub, - title=msg, - icon=False, - for_seed_words=False, - config=self.config, - ) - vbox.addLayout(layout.layout()) - self.exec_layout(vbox, _('Master Public Key')) - return None - - def init_network(self, network: 'Network'): - message = _("Electrum communicates with remote servers to get " - "information about your transactions and addresses. The " - "servers all fulfill the same purpose only differing in " - "hardware. In most cases you simply want to let Electrum " - "pick one at random. However if you prefer feel free to " - "select a server manually.") - choices = [_("Auto connect"), _("Select server manually")] - title = _("How do you want to connect to a server? ") - clayout = ChoicesLayout(message, choices) - self.back_button.setText(_('Cancel')) - self.exec_layout(clayout.layout(), title) - r = clayout.selected_index() - if r == 1: - nlayout = NetworkChoiceLayout(network, self.config, wizard=True) - if self.exec_layout(nlayout.layout()): - nlayout.accept() - self.config.NETWORK_AUTO_CONNECT = network.auto_connect - else: - network.auto_connect = True - self.config.NETWORK_AUTO_CONNECT = True - - @wizard_dialog - def multisig_dialog(self, run_next): - cw = CosignWidget(2, 2) - n_edit = QSlider(Qt.Horizontal, self) - m_edit = QSlider(Qt.Horizontal, self) - n_edit.setMinimum(2) - n_edit.setMaximum(15) - m_edit.setMinimum(1) - m_edit.setMaximum(2) - n_edit.setValue(2) - m_edit.setValue(2) - n_label = QLabel() - m_label = QLabel() - grid = QGridLayout() - grid.addWidget(n_label, 0, 0) - grid.addWidget(n_edit, 0, 1) - grid.addWidget(m_label, 1, 0) - grid.addWidget(m_edit, 1, 1) - def on_m(m): - m_label.setText(_('Require {0} signatures').format(m)) - cw.set_m(m) - backup_warning_label.setVisible(cw.m != cw.n) - def on_n(n): - n_label.setText(_('From {0} cosigners').format(n)) - cw.set_n(n) - m_edit.setMaximum(n) - backup_warning_label.setVisible(cw.m != cw.n) - n_edit.valueChanged.connect(on_n) - m_edit.valueChanged.connect(on_m) - vbox = QVBoxLayout() - vbox.addWidget(cw) - vbox.addWidget(WWLabel(_("Choose the number of signatures needed to unlock funds in your wallet:"))) - vbox.addLayout(grid) - vbox.addSpacing(2 * char_width_in_lineedit()) - backup_warning_label = WWLabel(_("Warning: to be able to restore a multisig wallet, " - "you should include the master public key for each cosigner " - "in all of your backups.")) - vbox.addWidget(backup_warning_label) - on_n(2) - on_m(2) - self.exec_layout(vbox, _("Multi-Signature Wallet")) - m = int(m_edit.value()) - n = int(n_edit.value()) - return (m, n) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 16e1ebcf095d..80f6540242f9 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -90,7 +90,7 @@ getOpenFileName, getSaveFileName, BlockingWaitingDialog, font_height) from .util import ButtonsLineEdit, ShowQRLineEdit from .util import QtEventListener, qt_event_listener, event_listener -from .installwizard import WIF_HELP_TEXT +from .wizard.wallet import WIF_HELP_TEXT from .history_list import HistoryList, HistoryModel from .update_checker import UpdateCheck, UpdateCheckThread from .channels_list import ChannelsList diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py index fb66903c0dc4..973f70bcadb6 100644 --- a/electrum/gui/qt/network_dialog.py +++ b/electrum/gui/qt/network_dialog.py @@ -95,9 +95,12 @@ class ItemType(IntEnum): DISCONNECTED_SERVER = 2 TOPLEVEL = 3 - def __init__(self, parent): + followServer = pyqtSignal([object], arguments=['server']) + followChain = pyqtSignal([str], arguments=['chain_id']) + setServer = pyqtSignal([str], arguments=['server']) + + def __init__(self): QTreeWidget.__init__(self) - self.parent = parent # type: NetworkChoiceLayout self.setHeaderLabels([_('Server'), _('Height')]) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.create_menu) @@ -110,16 +113,19 @@ def create_menu(self, position): menu = QMenu() if item_type == self.ItemType.CONNECTED_SERVER: server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr - menu.addAction(_("Use as server"), lambda: self.parent.follow_server(server)) + def do_follow_server(): + self.followServer.emit(server) + menu.addAction(_("Use as server"), do_follow_server) elif item_type == self.ItemType.DISCONNECTED_SERVER: server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr - def func(): - self.parent.server_e.setText(str(server)) - self.parent.set_server() - menu.addAction(_("Use as server"), func) + def do_set_server(): + self.setServer.emit(str(server)) + menu.addAction(_("Use as server"), do_set_server) elif item_type == self.ItemType.CHAIN: chain_id = item.data(0, self.CHAIN_ID_ROLE) - menu.addAction(_("Follow this branch"), lambda: self.parent.follow_branch(chain_id)) + def do_follow_chain(): + self.followChain.emit(chain_id) + menu.addAction(_("Follow this branch"), do_follow_chain) else: return menu.exec_(self.viewport().mapToGlobal(position)) @@ -136,9 +142,11 @@ def on_activated(self, item, column): pt.setX(50) self.customContextMenuRequested.emit(pt) - def update(self, *, network: Network, servers: dict, use_tor: bool): + def update(self, *, network: Network, servers: dict): self.clear() + use_tor = network.tor_proxy + # connected servers connected_servers_item = QTreeWidgetItem([_("Connected nodes"), '']) connected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL) @@ -146,7 +154,8 @@ def update(self, *, network: Network, servers: dict, use_tor: bool): n_chains = len(chains) for chain_id, interfaces in chains.items(): b = blockchain.blockchains.get(chain_id) - if b is None: continue + if b is None: + continue name = b.get_name() if n_chains > 1: x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()]) @@ -201,7 +210,9 @@ def update(self, *, network: Network, servers: dict, use_tor: bool): class NetworkChoiceLayout(object): - + # TODO consolidate to ProxyWidget+ServerWidget + # TODO TorDetector is unnecessary, Network tests socks5 peer and detects Tor + # TODO apply on editingFinished is not ideal, separate Apply button and on Close? def __init__(self, network: Network, config: 'SimpleConfig', wizard=False): self.network = network self.config = config @@ -304,7 +315,14 @@ def __init__(self, network: Network, config: 'SimpleConfig', wizard=False): self.split_label = QLabel('') grid.addWidget(self.split_label, 4, 0, 1, 3) - self.nodes_list_widget = NodesListWidget(self) + self.nodes_list_widget = NodesListWidget() + self.nodes_list_widget.followServer.connect(self.follow_server) + self.nodes_list_widget.followChain.connect(self.follow_branch) + + def do_set_server(server): + self.server_e.setText(server) + self.set_server() + self.nodes_list_widget.setServer.connect(do_set_server) grid.addWidget(self.nodes_list_widget, 6, 0, 1, 5) vbox = QVBoxLayout() @@ -361,8 +379,7 @@ def update(self): msg = '' self.split_label.setText(msg) self.nodes_list_widget.update(network=self.network, - servers=self.network.get_servers(), - use_tor=self.tor_cb.isChecked()) + servers=self.network.get_servers()) self.enable_set_server() def fill_in_proxy_settings(self): @@ -487,3 +504,139 @@ def stop(self): self._work_to_do_evt.set() self.exit() self.wait() + + +class ProxyWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + fixed_width_hostname = 24 * char_width_in_lineedit() + fixed_width_port = 6 * char_width_in_lineedit() + + grid = QGridLayout(self) + grid.setSpacing(8) + + # proxy setting. + self.proxy_cb = QCheckBox(_('Use proxy')) + self.proxy_mode = QComboBox() + self.proxy_mode.addItems(['SOCKS4', 'SOCKS5']) + self.proxy_mode.setCurrentIndex(1) + self.proxy_host = QLineEdit() + self.proxy_host.setFixedWidth(fixed_width_hostname) + self.proxy_port = QLineEdit() + self.proxy_port.setFixedWidth(fixed_width_port) + self.proxy_user = QLineEdit() + self.proxy_user.setPlaceholderText(_("Proxy user")) + self.proxy_password = PasswordLineEdit() + self.proxy_password.setPlaceholderText(_("Password")) + self.proxy_password.setFixedWidth(fixed_width_port) + + grid.addWidget(self.proxy_cb, 0, 0, 1, 3) + grid.addWidget(HelpButton(_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')), 0, 4) + grid.addWidget(self.proxy_mode, 1, 1) + grid.addWidget(self.proxy_host, 1, 2) + grid.addWidget(self.proxy_port, 1, 3) + grid.addWidget(self.proxy_user, 2, 2) + grid.addWidget(self.proxy_password, 2, 3) + + def get_proxy_settings(self): + return { + 'enabled': self.proxy_cb.isChecked(), + 'mode': ['socks4', 'socks5'][self.proxy_mode.currentIndex()], + 'host': self.proxy_host.text(), + 'port': self.proxy_port.text(), + 'user': self.proxy_user.text(), + 'password': self.proxy_password.text() + } + + +class ServerWidget(QWidget, QtEventListener): + def __init__(self, network, parent=None): + super().__init__(parent) + self.network = network + self.config = network.config + + fixed_width_hostname = 24 * char_width_in_lineedit() + fixed_width_port = 6 * char_width_in_lineedit() + + self.setLayout(QVBoxLayout()) + + grid = QGridLayout(self) + + msg = ' '.join([ + _("Electrum connects to several nodes in order to download block headers and find out the longest blockchain."), + _("This blockchain is used to verify the transactions sent by your transaction server.") + ]) + self.status_label = QLabel('') + grid.addWidget(QLabel(_('Status') + ':'), 0, 0) + grid.addWidget(self.status_label, 0, 1, 1, 3) + grid.addWidget(HelpButton(msg), 0, 4) + + self.autoconnect_cb = QCheckBox(_('Select server automatically')) + self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable()) + msg = ' '.join([ + _("If auto-connect is enabled, Electrum will always use a server that is on the longest blockchain."), + _("If it is disabled, you have to choose a server you want to use. Electrum will warn you if your server is lagging.") + ]) + grid.addWidget(self.autoconnect_cb, 1, 0, 1, 3) + grid.addWidget(HelpButton(msg), 1, 4) + + self.server_e = QLineEdit() + self.server_e.setFixedWidth(fixed_width_hostname + fixed_width_port) + msg = _("Electrum sends your wallet addresses to a single server, in order to receive your transaction history.") + grid.addWidget(QLabel(_('Server') + ':'), 2, 0) + grid.addWidget(self.server_e, 2, 1, 1, 3) + grid.addWidget(HelpButton(msg), 2, 4) + + self.height_label = QLabel('') + msg = _('This is the height of your local copy of the blockchain.') + grid.addWidget(QLabel(_('Blockchain') + ':'), 3, 0) + grid.addWidget(self.height_label, 3, 1) + grid.addWidget(HelpButton(msg), 3, 4) + + self.split_label = QLabel('') + grid.addWidget(self.split_label, 4, 0, 1, 3) + + self.layout().addLayout(grid) + + self.nodes_list_widget = NodesListWidget() + self.nodes_list_widget.followServer.connect(self.follow_server) + self.nodes_list_widget.followChain.connect(self.follow_branch) + + def do_set_server(server): + self.server_e.setText(server) + self.set_server() + self.nodes_list_widget.setServer.connect(do_set_server) + + + self.layout().addWidget(self.nodes_list_widget) + self.nodes_list_widget.update(network=self.network, + servers=self.network.get_servers()) + + self.register_callbacks() + self.destroyed.connect(lambda: self.unregister_callbacks()) + + @qt_event_listener + def on_event_network_updated(self): + self.nodes_list_widget.update(network=self.network, servers=self.network.get_servers()) + + def follow_branch(self, chain_id): + self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id)) + self.update() + + def follow_server(self, server: ServerAddr): + self.server_e.setText(str(server)) + self.network.run_from_another_thread(self.network.follow_chain_given_server(server)) + self.update() + + def set_server(self): + net_params = self.network.get_parameters() + try: + server = ServerAddr.from_str_with_inference(str(self.server_e.text())) + if not server: + raise Exception("failed to parse server") + except Exception: + return + net_params = net_params._replace(server=server, + auto_connect=self.autoconnect_cb.isChecked()) + self.network.run_from_another_thread(self.network.set_parameters(net_params)) diff --git a/electrum/gui/qt/password_dialog.py b/electrum/gui/qt/password_dialog.py index 2f2c9aed327a..fd4d4849b823 100644 --- a/electrum/gui/qt/password_dialog.py +++ b/electrum/gui/qt/password_dialog.py @@ -57,6 +57,8 @@ def check_password_strength(password): PW_NEW, PW_CHANGE, PW_PASSPHRASE = range(0, 3) +MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\ + + _("Leave this field empty if you want to disable encryption.") class PasswordLayout(object): @@ -134,6 +136,7 @@ def enable_OK(): and not force_disable_encrypt_cb) self.new_pw.textChanged.connect(enable_OK) self.conf_pw.textChanged.connect(enable_OK) + enable_OK() self.vbox = vbox diff --git a/electrum/gui/qt/seed_dialog.py b/electrum/gui/qt/seed_dialog.py index fd3022a63333..b282016f79d2 100644 --- a/electrum/gui/qt/seed_dialog.py +++ b/electrum/gui/qt/seed_dialog.py @@ -25,7 +25,7 @@ from typing import TYPE_CHECKING -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import (QVBoxLayout, QCheckBox, QHBoxLayout, QLineEdit, QLabel, QCompleter, QDialog, QStyledItemDelegate, @@ -46,6 +46,14 @@ from electrum.simple_config import SimpleConfig +MSG_PASSPHRASE_WARN_ISSUE4566 = _("Warning") + ": "\ + + _("You have multiple consecutive whitespaces or leading/trailing " + "whitespaces in your passphrase.") + " " \ + + _("This is discouraged.") + " " \ + + _("Due to a bug, old versions of Electrum will NOT be creating the " + "same wallet as newer versions or other software.") + + def seed_warning_msg(seed): return ''.join([ "

", @@ -64,6 +72,8 @@ def seed_warning_msg(seed): class SeedLayout(QVBoxLayout): + updated = pyqtSignal() + def seed_options(self): dialog = QDialog() dialog.setWindowTitle(_("Seed Options")) @@ -120,6 +130,7 @@ def f(choices_layout): return None self.is_ext = cb_ext.isChecked() if 'ext' in self.options else False self.seed_type = seed_type_values[clayout.selected_index()] if len(seed_types) >= 2 else 'electrum' + self.updated.emit() def __init__( self, diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 52025412c3c4..f5b8b681a9f7 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -38,7 +38,6 @@ if TYPE_CHECKING: from .main_window import ElectrumWindow - from .installwizard import InstallWizard from .paytoedit import PayToEdit from electrum.simple_config import SimpleConfig @@ -456,6 +455,50 @@ def layout(self): def selected_index(self): return self.group.checkedId() + +class ChoiceWidget(QWidget): + itemSelected = pyqtSignal([int], arguments=['index']) + + def __init__(self, *, message=None, choices=None, selected=None): + QWidget.__init__(self) + vbox = QVBoxLayout() + self.setLayout(vbox) + + if choices is None: + choices = [] + + self.selected_index = -1 + self.selected_item = None + self.choices = choices + + if message and len(message) > 50: + vbox.addWidget(WWLabel(message)) + message = "" + gb2 = QGroupBox(message) + vbox.addWidget(gb2) + vbox2 = QVBoxLayout() + gb2.setLayout(vbox2) + self.group = group = QButtonGroup() + assert isinstance(choices, list) + iterator = enumerate(choices) + for i, c in iterator: + button = QRadioButton(gb2) + button.setText(c[1]) + vbox2.addWidget(button) + group.addButton(button) + group.setId(button, i) + if (i == 0 and selected is None) or c[0] == selected: + self.selected_index = i + self.selected_item = c + button.setChecked(True) + group.buttonClicked.connect(self.on_selected) + + def on_selected(self, button): + self.selected_index = self.group.id(button) + self.selected_item = self.choices[self.selected_index] + self.itemSelected.emit(self.selected_index) + + def address_field(addresses): hbox = QHBoxLayout() address_e = QLineEdit() @@ -1291,10 +1334,7 @@ def apply(self, image: QImage): return result - - class QtEventListener(EventListener): - qt_callback_signal = QtCore.pyqtSignal(tuple) def register_callbacks(self): @@ -1302,13 +1342,17 @@ def register_callbacks(self): EventListener.register_callbacks(self) def unregister_callbacks(self): - self.qt_callback_signal.disconnect() + try: + self.qt_callback_signal.disconnect() + except RuntimeError: # wrapped Qt object might be deleted + pass EventListener.unregister_callbacks(self) def on_qt_callback_signal(self, args): func = args[0] return func(self, *args[1:]) + # decorator for members of the QtEventListener class def qt_event_listener(func): func = event_listener(func) diff --git a/electrum/gui/qt/wizard/__init__.py b/electrum/gui/qt/wizard/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/electrum/gui/qt/wizard/server_connect.py b/electrum/gui/qt/wizard/server_connect.py new file mode 100644 index 000000000000..01d943f58b02 --- /dev/null +++ b/electrum/gui/qt/wizard/server_connect.py @@ -0,0 +1,93 @@ +from typing import TYPE_CHECKING + +from electrum.i18n import _ +from electrum.wizard import ServerConnectWizard +from electrum.gui.qt.network_dialog import ProxyWidget, ServerWidget +from electrum.gui.qt.util import ChoiceWidget +from .wizard import QEAbstractWizard, WizardComponent + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + from electrum.plugin import Plugins + from electrum.daemon import Daemon + from electrum.gui.qt import QElectrumApplication + + +class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard): + + def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: 'Daemon', parent=None): + ServerConnectWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, config, app) + + self.setWindowTitle(_('Network and server configuration')) + + # attach gui classes + self.navmap_merge({ + 'autoconnect': { 'gui': WCAutoConnect }, + 'proxy_ask': { 'gui': WCProxyAsk }, + 'proxy_config': { 'gui': WCProxyConfig }, + 'server_config': { 'gui': WCServerConfig }, + }) + + +class WCAutoConnect(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_("How do you want to connect to a server? ")) + message = _("Electrum communicates with remote servers to get " + "information about your transactions and addresses. The " + "servers all fulfill the same purpose only differing in " + "hardware. In most cases you simply want to let Electrum " + "pick one at random. However if you prefer feel free to " + "select a server manually.") + choices = [('autoconnect', _("Auto connect")), + ('select', _("Select server manually"))] + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.choice_w.itemSelected.connect(self.on_updated) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + self._valid = True + + def apply(self): + self.wizard_data['autoconnect'] = (self.choice_w.selected_item[0] == 'autoconnect') + + +class WCProxyAsk(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_("Proxy")) + message = _("Do you use a local proxy service such as TOR to reach the internet?") + choices = [('yes', _("Yes")), + ('no', _("No"))] + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + self._valid = True + + def apply(self): + self.wizard_data['want_proxy'] = (self.choice_w.selected_item[0] == 'yes') + + +class WCProxyConfig(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_("Proxy")) + self.pw = ProxyWidget(self) + self.pw.proxy_cb.setChecked(True) + self.pw.proxy_host.setText('localhost') + self.pw.proxy_port.setText('9050') + self.layout().addWidget(self.pw) + self.layout().addStretch(1) + self._valid = True + + def apply(self): + self.wizard_data['proxy'] = self.pw.get_proxy_settings() + + +class WCServerConfig(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_("Server")) + self.sw = ServerWidget(wizard._daemon.network, self) + self.layout().addWidget(self.sw) + self._valid = True + + def apply(self): + self.wizard_data['autoconnect'] = self.sw.autoconnect_cb.isChecked() + self.wizard_data['server'] = self.sw.server_e.text() diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py new file mode 100644 index 000000000000..931c7d107846 --- /dev/null +++ b/electrum/gui/qt/wizard/wallet.py @@ -0,0 +1,1401 @@ +import os +import sys +import threading +import time + +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, QTimer, QRect, pyqtSignal +from PyQt5.QtGui import QPen, QPainter, QPalette +from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget, + QFileDialog, QSlider, QGridLayout, QDialog, QApplication) + +from electrum.bip32 import is_bip32_derivation, BIP32Node, normalize_bip32_derivation, xpub_type +from electrum.daemon import Daemon +from electrum.i18n import _ +from electrum.keystore import bip44_derivation, bip39_to_seed, purpose48_derivation, ScriptTypeNotSupported +from electrum.plugin import run_hook, HardwarePluginLibraryUnavailable +from electrum.storage import StorageReadWriteError +from electrum.util import WalletFileException, get_new_wallet_name, UserCancelled, UserFacingException, InvalidPassword +from electrum.wallet import wallet_types +from .wizard import QEAbstractWizard, WizardComponent +from electrum.logging import get_logger, Logger +from electrum import WalletStorage, mnemonic, keystore +from electrum.wizard import NewWalletWizard + +from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog +from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW +from electrum.gui.qt.seed_dialog import SeedLayout, MSG_PASSPHRASE_WARN_ISSUE4566, KeysLayout +from electrum.gui.qt.util import (PasswordLineEdit, char_width_in_lineedit, WWLabel, InfoButton, font_height, + ChoiceWidget, MessageBoxMixin, WindowModalDialog, ChoicesLayout, CancelButton, + Buttons, OkButton) + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + from electrum.plugin import Plugins + from electrum.daemon import Daemon + from electrum.wallet_db import WalletDB + from electrum.gui.qt import QElectrumApplication + +WIF_HELP_TEXT = (_('WIF keys are typed in Electrum, based on script type.') + '\n\n' + + _('A few examples') + ':\n' + + 'p2pkh:KxZcY47uGp9a... \t-> 1DckmggQM...\n' + + 'p2wpkh-p2sh:KxZcY47uGp9a... \t-> 3NhNeZQXF...\n' + + 'p2wpkh:KxZcY47uGp9a... \t-> bc1q3fjfk...') + +MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ + + _("Your wallet file does not contain secrets, mostly just metadata. ") \ + + _("It also contains your master public key that allows watching your addresses.") + '\n\n'\ + + _("Note: If you enable this setting, you will need your hardware device to open your wallet.") + + +class QENewWalletWizard(NewWalletWizard, QEAbstractWizard, MessageBoxMixin): + _logger = get_logger(__name__) + + def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: Daemon, path, *, start_viewstate=None): + NewWalletWizard.__init__(self, daemon, plugins) + QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate) + + self.setWindowTitle(_('Create/Restore wallet')) + + self._path = path + + # attach gui classes to views + self.navmap_merge({ + 'wallet_name': { 'gui': WCWalletName }, + 'wallet_type': { 'gui': WCWalletType }, + 'keystore_type': { 'gui': WCKeystoreType }, + 'create_seed': { 'gui': WCCreateSeed }, + 'confirm_seed': { 'gui': WCConfirmSeed }, + 'have_seed': { 'gui': WCHaveSeed }, + 'choose_hardware_device': { 'gui': WCChooseHWDevice }, + 'script_and_derivation': { 'gui': WCScriptAndDerivation}, + 'have_master_key': { 'gui': WCHaveMasterKey }, + 'multisig': { 'gui': WCMultisig }, + 'multisig_cosigner_keystore': { 'gui': WCCosignerKeystore }, + 'multisig_cosigner_key': { 'gui': WCHaveMasterKey }, + 'multisig_cosigner_seed': { 'gui': WCHaveSeed }, + 'multisig_cosigner_hardware': { 'gui': WCChooseHWDevice }, + 'multisig_cosigner_script_and_derivation': { 'gui': WCScriptAndDerivation}, + 'imported': { 'gui': WCImport }, + 'wallet_password': { 'gui': WCWalletPassword }, + 'wallet_password_hardware': { 'gui': WCWalletPasswordHardware } + }) + + # add open existing wallet from wizard, incl hw unlock + self.navmap_merge({ + 'wallet_name': { + 'next': lambda d: 'hw_unlock' if d['wallet_needs_hw_unlock'] else 'wallet_type', + 'last': lambda d: d['wallet_exists'] and not d['wallet_needs_hw_unlock'] + }, + 'hw_unlock': { + 'gui': WCChooseHWDevice, + 'next': lambda d: self.on_hardware_device(d, new_wallet=False) + } + }) + + # insert seed extension entry/confirm as separate views + self.navmap_merge({ + 'create_seed': { + 'next': lambda d: 'create_ext' if self.wants_ext(d) else 'confirm_seed' + }, + 'create_ext': { + 'next': 'confirm_seed', + 'gui': WCEnterExt + }, + 'confirm_seed': { + 'next': lambda d: 'confirm_ext' if self.wants_ext(d) else self.on_have_or_confirm_seed(d), + 'accept': lambda d: None if self.wants_ext(d) else self.maybe_master_pubkey(d) + }, + 'confirm_ext': { + 'next': self.on_have_or_confirm_seed, + 'accept': self.maybe_master_pubkey, + 'gui': WCConfirmExt + }, + 'have_seed': { + 'next': lambda d: 'have_ext' if self.wants_ext(d) else self.on_have_or_confirm_seed(d), + 'last': lambda d: self.is_single_password() and not + (self.needs_derivation_path(d) or self.is_multisig(d) or self.wants_ext(d)) + }, + 'have_ext': { + 'next': self.on_have_or_confirm_seed, + 'accept': self.maybe_master_pubkey, + 'gui': WCEnterExt + }, + 'multisig_cosigner_seed': { + 'next': lambda d: 'multisig_cosigner_have_ext' if self.wants_ext(d) else self.on_have_cosigner_seed(d), + 'last': lambda d: self.is_single_password() and self.last_cosigner(d) and not + (self.needs_derivation_path(d) or self.wants_ext(d)) + }, + 'multisig_cosigner_have_ext': { + 'next': self.on_have_cosigner_seed, + 'last': lambda d: self.is_single_password() and self.last_cosigner(d) and not self.needs_derivation_path(d), + 'gui': WCEnterExt + }, + }) + + run_hook('init_wallet_wizard', self) + + + @property + def path(self): + return self._path + + @path.setter + def path(self, path): + self._path = path + + def is_single_password(self): + # not supported on desktop + return False + + def create_storage(self, single_password: str = None): + self._logger.info('Creating wallet from wizard data') + data = self.get_wizard_data() + + path = os.path.join(os.path.dirname(self._daemon.config.get_wallet_path()), data['wallet_name']) + + super().create_storage(path, data) + + # minimally populate self after create + self._password = data['password'] + self.path = path + + def run_upgrades(self, db: 'WalletDB') -> None: + storage = db.storage + path = storage.path + if db.requires_split(): + msg = _( + "The wallet '{}' contains multiple accounts, which are no longer supported since Electrum 2.7.\n\n" + "Do you want to split your wallet into multiple files?").format(path) + if not self.question(msg): + return + file_list = db.split_accounts(path) + msg = _('Your accounts have been moved to') + ':\n' + '\n'.join(file_list) + '\n\n' + _( + 'Do you want to delete the old file') + ':\n' + path + if self.question(msg): + os.remove(path) + self.show_warning(_('The file was removed')) + # raise now, to avoid having the old storage opened + raise UserCancelled() + if db.requires_upgrade(): + self.waiting_dialog(db.upgrade, _('Upgrading wallet format...')) + + def is_finalized(self, wizard_data: dict) -> bool: + # check decryption of existing wallet and keep wizard open if incorrect. + + if not wizard_data['wallet_exists'] or wizard_data['wallet_is_open']: + return True + + wallet_file = wizard_data['wallet_name'] + + storage = WalletStorage(wallet_file) + if not storage.is_encrypted_with_user_pw() and not storage.is_encrypted_with_hw_device(): + return True + + try: + storage.decrypt(wizard_data['password']) + except InvalidPassword: + if storage.is_encrypted_with_hw_device(): + self.show_message('This hardware device could not decrypt this wallet. Is it the correct one?') + else: + self.show_message('Invalid password') + return False + + return True + + def waiting_dialog(self, task, msg, on_finished=None): + dialog = QDialog() + label = WWLabel(msg) + vbox = QVBoxLayout() + vbox.addSpacing(100) + label.setMinimumWidth(300) + label.setAlignment(Qt.AlignCenter) + vbox.addWidget(label) + vbox.addSpacing(100) + dialog.setLayout(vbox) + dialog.setModal(True) + + exc = None + + def task_wrap(task): + nonlocal exc + try: + task() + except Exception as e: + exc = e + + t = threading.Thread(target=task_wrap, args=(task,)) + t.start() + + dialog.show() + + while True: + QApplication.processEvents() + t.join(1.0/60) + if not t.is_alive(): + break + + dialog.close() + + if exc: + raise exc + + if on_finished: + on_finished() + + def query_choice(self, msg, choices, title=None, default_choice=None): + # Needed by QtHandler for hardware wallets + if title is None: + title = _('Question') + dialog = WindowModalDialog(self.top_level_window(), title=title) + dialog.setMinimumWidth(400) + clayout = ChoicesLayout(msg, choices, checked_index=default_choice) + vbox = QVBoxLayout(dialog) + vbox.addLayout(clayout.layout()) + cancel_button = CancelButton(dialog) + vbox.addLayout(Buttons(cancel_button, OkButton(dialog))) + cancel_button.setFocus() + if not dialog.exec_(): + return None + return clayout.selected_index() + + +class WCWalletName(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Electrum wallet')) + Logger.__init__(self) + + path = wizard._path + + if os.path.isdir(path): + raise Exception("wallet path cannot point to a directory") + + self.wallet_exists = False + self.wallet_is_open = False + self.wallet_needs_hw_unlock = False + + 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() + self.pw_e = PasswordLineEdit('', self) + self.pw_e.setFixedWidth(17 * char_width_in_lineedit()) + pw_label = QLabel(_('Password') + ':') + hbox2.addWidget(pw_label) + hbox2.addWidget(self.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) + self.layout().addStretch(1) + + 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 + self.wallet_exists = False + self.wallet_is_open = False + self.wallet_needs_hw_unlock = False + if filename: + path = os.path.join(wallet_folder, filename) + wallet_from_memory = self.wizard._daemon.get_wallet(path) + try: + if wallet_from_memory: + temp_storage = wallet_from_memory.storage # type: Optional[WalletStorage] + self.wallet_is_open = True + else: + temp_storage = WalletStorage(path) + self.wallet_exists = temp_storage.file_exists() + 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.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.") + self.wallet_needs_hw_unlock = True + else: + msg = _("Press 'Finish' to open this wallet.") + else: + msg = _("This file is already open in memory.") + "\n" \ + + _("Press 'Finish' 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() + self.pw_e.show() + if not self.name_e.hasFocus(): + self.pw_e.setFocus(True) + else: + pw_label.hide() + self.pw_e.hide() + self.on_updated() + + 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): + if self.wallet_exists: + # use full path + path = self.wizard._path + wallet_folder = os.path.dirname(path) + self.wizard_data['wallet_name'] = os.path.join(wallet_folder, self.name_e.text()) + else: + self.wizard_data['wallet_name'] = self.name_e.text() + self.wizard_data['wallet_exists'] = self.wallet_exists + self.wizard_data['wallet_is_open'] = self.wallet_is_open + self.wizard_data['password'] = self.pw_e.text() + self.wizard_data['wallet_needs_hw_unlock'] = self.wallet_needs_hw_unlock + + +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.choice_w = ChoiceWidget(message=message, choices=choices, selected='standard') + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + self._valid = True + + def apply(self): + self.wizard_data['wallet_type'] = self.choice_w.selected_item[0] + + +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.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + self._valid = True + + def apply(self): + self.wizard_data['keystore_type'] = self.choice_w.selected_item[0] + + +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 + + def on_ready(self): + if self.wizard_data['wallet_type'] == '2fa': + self.seed_type = '2fa_segwit' + QTimer.singleShot(1, 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' + self.wizard_data['seed_extra_words'] = '' # empty default + + 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.layout().addStretch(1) + 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(WWLabel(message)) + + # TODO: SeedLayout assumes too much in parent, refactor SeedLayout + # for now, fake parent.next_button.setEnabled + class Hack: + def setEnabled(self2, b): + self.valid = b + self.next_button = Hack() + + self.slayout = SeedLayout( + is_seed=lambda x: x == self.wizard_data['seed'], + parent=self, + config=self.wizard.config, + ) + self.layout().addLayout(self.slayout) + + wizard.app.clipboard().clear() + + def apply(self): + pass + + +class WCEnterExt(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Seed Extension')) + Logger.__init__(self) + + 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) + self.layout().addStretch(1) + + def on_text_edited(self, text): + # TODO also for cosigners? + self.ext_edit.warn_issue4566 = self.wizard_data['keystore_type'] == 'haveseed' and \ + self.wizard_data['seed_type'] == 'bip39' + self.validate() + + def validate(self): + self.apply() + text = self.ext_edit.text() + if len(text) == 0: + self.valid = False + return + + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + + if self.wizard_data['wallet_type'] == 'multisig': + if 'seed_variant' in cosigner_data and cosigner_data['seed_variant'] in ['bip39', 'slip39']: + # defer validation to when derivation path is known + self.valid = True + else: + if self.wizard.has_duplicate_masterkeys(self.wizard_data): + self.logger.debug('Duplicate master keys!') + # TODO: user feedback + self.valid = False + elif self.wizard.has_heterogeneous_masterkeys(self.wizard_data): + self.logger.debug('Heterogenous master keys!') + # TODO: user feedback + self.valid = False + else: + self.valid = True + else: + self.valid = True + + def apply(self): + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + cosigner_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) + self.layout().addStretch(1) + + def on_text_edited(self, text): + self.valid = text == self.wizard_data['seed_extra_words'] + + def apply(self): + pass + + +class WCHaveSeed(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Enter Seed')) + Logger.__init__(self) + + self.layout().addWidget(WWLabel(_('Please enter your seed phrase in order to restore your wallet.'))) + + # TODO: SeedLayout assumes too much in parent, refactor SeedLayout + # for now, fake parent.next_button.setEnabled + class Hack: + def setEnabled(self2, b): + if not b: + self.valid = b + else: + self.validate() + + self.next_button = Hack() + + def on_ready(self): + options = ['ext'] if self.wizard_data['wallet_type'] == '2fa' else ['ext', 'bip39', 'slip39'] + self.slayout = SeedLayout( + is_seed=self.is_seed, + options=options, + parent=self, + config=self.wizard.config, + ) + self.slayout.updated.connect(self.validate) + + self.layout().addLayout(self.slayout) + self.layout().addStretch(1) + + def is_seed(self, x): + if self.wizard_data['wallet_type'] == 'standard': + return mnemonic.is_seed(x) + elif self.wizard_data['wallet_type'] == '2fa': + return mnemonic.seed_type(x) in ['2fa', '2fa_segwit'] + else: + return mnemonic.seed_type(x) in ['standard', 'segwit'] + + def validate(self): + # precond: only call when SeedLayout deems seed a valid seed + seed = self.slayout.get_seed() + seed_variant = self.slayout.seed_type + wallet_type = self.wizard_data['wallet_type'] + seed_valid, seed_type, validation_message = self.wizard.validate_seed(seed, seed_variant, wallet_type) + + is_cosigner = self.wizard_data['wallet_type'] == 'multisig' and 'multisig_current_cosigner' in self.wizard_data + + if not is_cosigner or not seed_valid: + self.valid = seed_valid + return + + if seed_type in ['bip39', 'slip39'] or self.slayout.is_ext: + # defer validation to when derivation path and/or passphrase/ext is known + self.valid = True + else: + self.apply() + if self.wizard.has_duplicate_masterkeys(self.wizard_data): + self.logger.debug('Duplicate master keys!') + # TODO: user feedback + seed_valid = False + elif self.wizard.has_heterogeneous_masterkeys(self.wizard_data): + self.logger.debug('Heterogenous master keys!') + # TODO: user feedback + seed_valid = False + + self.valid = seed_valid + + def apply(self): + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + + cosigner_data['seed'] = self.slayout.get_seed() + cosigner_data['seed_variant'] = self.slayout.seed_type + if self.slayout.seed_type == 'electrum': + cosigner_data['seed_type'] = mnemonic.seed_type(self.slayout.get_seed()) + else: + cosigner_data['seed_type'] = self.slayout.seed_type + cosigner_data['seed_extend'] = self.slayout.is_ext + cosigner_data['seed_extra_words'] = '' # empty default + + +class WCScriptAndDerivation(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Script type and Derivation path')) + Logger.__init__(self) + + def on_ready(self): + message1 = _('Choose the type of addresses in your wallet.') + message2 = ' '.join([ + _('You can override the suggested derivation path.'), + _('If you are not sure what this is, leave this field unchanged.') + ]) + hide_choices = False + + if self.wizard_data['wallet_type'] == 'multisig': + choices = [ + # TODO: nicer to refactor 'standard' to 'p2sh', but backend wallet still uses 'standard' + ('standard', 'legacy multisig (p2sh)', normalize_bip32_derivation("m/45'/0")), + ('p2wsh-p2sh', 'p2sh-segwit multisig (p2wsh-p2sh)', purpose48_derivation(0, xtype='p2wsh-p2sh')), + ('p2wsh', 'native segwit multisig (p2wsh)', purpose48_derivation(0, xtype='p2wsh')), + ] + if 'multisig_current_cosigner' in self.wizard_data: + # get script type of first cosigner + ks = self.wizard.keystore_from_data(self.wizard_data['wallet_type'], self.wizard_data) + default_choice = xpub_type(ks.get_master_public_key()) + hide_choices = True + else: + default_choice = 'p2wsh' + else: + default_choice = 'p2wpkh' + choices = [ + # TODO: nicer to refactor 'standard' to 'p2pkh', but backend wallet still uses 'standard' + ('standard', 'legacy (p2pkh)', bip44_derivation(0, bip43_purpose=44)), + ('p2wpkh-p2sh', 'p2sh-segwit (p2wpkh-p2sh)', bip44_derivation(0, bip43_purpose=49)), + ('p2wpkh', 'native segwit (p2wpkh)', bip44_derivation(0, bip43_purpose=84)), + ] + + if self.wizard_data['wallet_type'] == 'standard' and not self.wizard_data['keystore_type'] == 'hardware': + button = QPushButton(_("Detect Existing Accounts")) + + passphrase = self.wizard_data['seed_extra_words'] if self.wizard_data['seed_extend'] else '' + if self.wizard_data['seed_variant'] == 'bip39': + root_seed = bip39_to_seed(self.wizard_data['seed'], passphrase) + elif self.wizard_data['seed_variant'] == 'slip39': + root_seed = self.wizard_data['seed'].decrypt(passphrase) + + def get_account_xpub(account_path): + root_node = BIP32Node.from_rootseed(root_seed, xtype="standard") + account_node = root_node.subkey_at_private_derivation(account_path) + account_xpub = account_node.to_xpub() + return account_xpub + + def on_account_select(account): + script_type = account["script_type"] + if script_type == "p2pkh": + script_type = "standard" + button_index = self.c_values.index(script_type) + button = self.clayout.group.buttons()[button_index] + button.setChecked(True) + self.derivation_path_edit.setText(account["derivation_path"]) + + button.clicked.connect(lambda: Bip39RecoveryDialog(self, get_account_xpub, on_account_select)) + self.layout().addWidget(button, alignment=Qt.AlignLeft) + self.layout().addWidget(QLabel(_("Or"))) + + def on_choice_click(index): + self.derivation_path_edit.setText(self.choice_w.selected_item[2]) + self.choice_w = ChoiceWidget(message=message1, choices=choices, selected=default_choice) + self.choice_w.itemSelected.connect(on_choice_click) + + if not hide_choices: + self.layout().addWidget(self.choice_w) + + self.layout().addWidget(WWLabel(message2)) + + self.derivation_path_edit = QLineEdit() + self.derivation_path_edit.textChanged.connect(self.validate) + self.layout().addWidget(self.derivation_path_edit) + + on_choice_click(self.choice_w.selected_index) # set default value for derivation path + + self.layout().addStretch(1) + + def validate(self): + self.apply() + + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + valid = is_bip32_derivation(cosigner_data['derivation_path']) + + if valid: + valid, error = self.wizard.check_multisig_constraints(self.wizard_data) + if not valid: + # TODO: user feedback + self.logger.error(error) + + self.valid = valid + + def apply(self): + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + cosigner_data['script_type'] = self.choice_w.selected_item[0] + cosigner_data['derivation_path'] = str(self.derivation_path_edit.text()) + + +class WCCosignerKeystore(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard) + + message = _('Add a cosigner to your multi-sig wallet') + choices = [ + ('masterkey', _('Enter cosigner key')), + ('haveseed', _('Enter cosigner seed')), + ('hardware', _('Cosign with hardware device')) + ] + + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + + self.cosigner = 0 + self.participants = 0 + + self._valid = True + + def on_ready(self): + self.participants = self.wizard_data['multisig_participants'] + # cosigner index is determined here and put on the wizard_data dict in apply() + # as this page is the start for each additional cosigner + self.cosigner = 2 + len(self.wizard_data['multisig_cosigner_data']) + + self.wizard_data['multisig_current_cosigner'] = self.cosigner + self.title = _("Add Cosigner {}").format(self.wizard_data['multisig_current_cosigner']) + + # different from old wizard: master public key for sharing is now shown on this page + self.layout().addSpacing(20) + self.layout().addWidget(WWLabel(_('Below is your master public key. Please share it with your cosigners'))) + slayout = SeedLayout( + self.wizard_data['multisig_master_pubkey'], + icon=False, + for_seed_words=False, + config=self.wizard.config, + ) + self.layout().addLayout(slayout) + self.layout().addStretch(1) + + def apply(self): + self.wizard_data['cosigner_keystore_type'] = self.choice_w.selected_item[0] + self.wizard_data['multisig_current_cosigner'] = self.cosigner + self.wizard_data['multisig_cosigner_data'][str(self.cosigner)] = { + 'keystore_type': self.choice_w.selected_item[0] + } + + +class WCHaveMasterKey(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Create keystore from a master key')) + + self.message_create = ' '.join([ + _("To create a watching-only wallet, please enter your master public key (xpub/ypub/zpub)."), + _("To create a spending wallet, please enter a master private key (xprv/yprv/zprv).") + ]) + self.message_cosign = ' '.join([ + _('Please enter the master public key (xpub) of your cosigner.'), + _('Enter their master private key (xprv) if you want to be able to sign for them.') + ]) + + self.header_layout = QHBoxLayout() + self.label = WWLabel() + self.label.setMinimumWidth(400) + self.header_layout.addWidget(self.label) + + # TODO: KeysLayout assumes too much in parent, refactor KeysLayout + # for now, fake parent.next_button.setEnabled + class Hack: + def setEnabled(self2, b): + self.valid = b + def setToolTip(self2, b): + pass + self.next_button = Hack() + + def on_ready(self): + # if self.wallet_type == 'standard': + # v = keystore.is_master_key + # self.add_xpub_dialog(title=title, message=message, run_next=self.on_restore_from_key, is_valid=v) + # else: + # i = len(self.keystores) + 1 + # self.add_cosigner_dialog(index=i, run_next=self.on_restore_from_key, is_valid=keystore.is_bip32_key) + if self.wizard_data['wallet_type'] == 'standard': + self.label.setText(self.message_create) + v = lambda x: bool(keystore.from_master_key(x)) + self.slayout = KeysLayout(parent=self, header_layout=self.header_layout, is_valid=v, + allow_multi=False, config=self.wizard.config) + self.layout().addLayout(self.slayout) + elif self.wizard_data['wallet_type'] == 'multisig': + if 'multisig_current_cosigner' in self.wizard_data: + self.title = _("Add Cosigner {}").format(self.wizard_data['multisig_current_cosigner']) + self.label.setText(self.message_cosign) + else: + self.wizard_data['multisig_current_cosigner'] = 0 + self.label.setText(self.message_create) + v = lambda x: keystore.is_bip32_key(x) + self.slayout = KeysLayout(parent=self, header_layout=self.header_layout, is_valid=v, + allow_multi=False, config=self.wizard.config) + self.layout().addLayout(self.slayout) + + def apply(self): + text = self.slayout.get_text() + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + cosigner_data['master_key'] = text + + +class WCMultisig(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Multi-Signature Wallet')) + + def on_m(m): + m_label.setText(_('Require {0} signatures').format(m)) + cw.set_m(m) + backup_warning_label.setVisible(cw.m != cw.n) + + def on_n(n): + n_label.setText(_('From {0} cosigners').format(n)) + cw.set_n(n) + m_edit.setMaximum(n) + backup_warning_label.setVisible(cw.m != cw.n) + + backup_warning_label = WWLabel(_('Warning: to be able to restore a multisig wallet, ' + 'you should include the master public key for each cosigner ' + 'in all of your backups.')) + + cw = CosignWidget(2, 2) + m_label = QLabel() + n_label = QLabel() + + m_edit = QSlider(Qt.Horizontal, self) + m_edit.setMinimum(1) + m_edit.setMaximum(2) + m_edit.setValue(2) + m_edit.valueChanged.connect(on_m) + on_m(m_edit.value()) + + n_edit = QSlider(Qt.Horizontal, self) + n_edit.setMinimum(2) + n_edit.setMaximum(15) + n_edit.setValue(2) + n_edit.valueChanged.connect(on_n) + on_n(n_edit.value()) + + grid = QGridLayout() + grid.addWidget(n_label, 0, 0) + grid.addWidget(n_edit, 0, 1) + grid.addWidget(m_label, 1, 0) + grid.addWidget(m_edit, 1, 1) + + self.layout().addWidget(cw) + self.layout().addWidget(WWLabel(_('Choose the number of signatures needed to unlock funds in your wallet:'))) + self.layout().addLayout(grid) + self.layout().addSpacing(2 * char_width_in_lineedit()) + self.layout().addWidget(backup_warning_label) + self.layout().addStretch(1) + + self.n_edit = n_edit + self.m_edit = m_edit + + self._valid = True + + def apply(self): + self.wizard_data['multisig_participants'] = int(self.n_edit.value()) + self.wizard_data['multisig_signatures'] = int(self.m_edit.value()) + self.wizard_data['multisig_cosigner_data'] = {} + + +class WCImport(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Import Bitcoin Addresses or Private Keys')) + message = _( + 'Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.') + header_layout = QHBoxLayout() + label = WWLabel(message) + label.setMinimumWidth(400) + header_layout.addWidget(label) + header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignRight) + + # TODO: KeysLayout assumes too much in parent, refactor KeysLayout + # for now, fake parent.next_button.setEnabled + class Hack: + def setEnabled(self2, b): + self.valid = b + def setToolTip(self2, b): + pass + self.next_button = Hack() + + v = lambda x: keystore.is_address_list(x) or keystore.is_private_key_list(x, raise_on_error=True) + self.slayout = KeysLayout(parent=self, header_layout=header_layout, is_valid=v, + allow_multi=True, config=self.wizard.config) + self.layout().addLayout(self.slayout) + + def apply(self): + text = self.slayout.get_text() + if keystore.is_address_list(text): + self.wizard_data['address_list'] = text + elif keystore.is_private_key_list(text): + self.wizard_data['private_key_list'] = text + + +class WCWalletPassword(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Wallet Password')) + + # TODO: PasswordLayout assumes a button, refactor PasswordLayout + # for now, fake next_button.setEnabled + class Hack: + def setEnabled(self2, b): + self.valid = b + self.next_button = Hack() + + self.pw_layout = PasswordLayout( + msg=MSG_ENTER_PASSWORD, + kind=PW_NEW, + OK_button=self.next_button, + # force_disable_encrypt_cb=force_disable_encrypt_cb + ) + self.pw_layout.encrypt_cb.setChecked(True) + self.layout().addLayout(self.pw_layout.layout()) + self.layout().addStretch(1) + + def apply(self): + self.wizard_data['password'] = self.pw_layout.new_password() + self.wizard_data['encrypt'] = self.pw_layout.encrypt_cb.isChecked() + + +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 + + +class CosignWidget(QWidget): + def __init__(self, m, n): + QWidget.__init__(self) + self.size = max(120, 9 * font_height()) + self.R = QRect(0, 0, self.size, self.size) + self.setGeometry(self.R) + self.setMinimumHeight(self.size) + self.setMaximumHeight(self.size) + self.m = m + self.n = n + + def set_n(self, n): + self.n = n + self.update() + + def set_m(self, m): + self.m = m + self.update() + + def paintEvent(self, event): + bgcolor = self.palette().color(QPalette.Background) + pen = QPen(bgcolor, 7, Qt.SolidLine) + qp = QPainter() + qp.begin(self) + qp.setPen(pen) + qp.setRenderHint(QPainter.Antialiasing) + qp.setBrush(Qt.gray) + for i in range(self.n): + alpha = int(16 * 360 * i/self.n) + alpha2 = int(16 * 360 * 1/self.n) + qp.setBrush(Qt.green if i < self.m else Qt.gray) + qp.drawPie(self.R, alpha, alpha2) + qp.end() + + +class WCChooseHWDevice(WizardComponent, Logger): + scanFailed = pyqtSignal([str, str], arguments=['code', 'message']) + scanComplete = pyqtSignal() + + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Choose Hardware Device')) + Logger.__init__(self) + self.scanFailed.connect(self.on_scan_failed) + self.scanComplete.connect(self.on_scan_complete) + self.plugins = wizard.plugins + + self.error_l = WWLabel() + self.error_l.setVisible(False) + + self.device_list = QWidget() + self.device_list_layout = QVBoxLayout() + self.device_list.setLayout(self.device_list_layout) + self.choice_w = None + + self.rescan_button = QPushButton(_('Rescan devices')) + self.rescan_button.clicked.connect(self.on_rescan) + + hbox = QHBoxLayout() + hbox.addStretch(1) + hbox.addWidget(self.rescan_button) + hbox.addStretch(1) + + self.layout().addWidget(self.error_l) + self.layout().addWidget(self.device_list) + self.layout().addStretch(1) + self.layout().addLayout(hbox) + self.layout().addStretch(1) + + self.c_values = [] + + def on_ready(self): + self.scan_devices() + + def on_rescan(self): + self.scan_devices() + + def on_scan_failed(self, code, message): + self.error_l.setText(message) + self.error_l.setVisible(True) + self.device_list.setVisible(False) + + self.valid = False + + def on_scan_complete(self): + self.error_l.setVisible(False) + self.device_list.setVisible(True) + + choices = [] + for name, info in self.devices: + state = _("initialized") if info.initialized else _("wiped") + label = info.label or _("An unnamed {}").format(name) + try: + transport_str = info.device.transport_ui_string[:20] + except Exception: + transport_str = 'unknown transport' + descr = f"{label} [{info.model_name or name}, {state}, {transport_str}]" + choices.append(((name, info), descr)) + msg = _('Select a device') + ':' + + if self.choice_w: + self.device_list_layout.removeWidget(self.choice_w) + + self.choice_w = ChoiceWidget(message=msg, choices=choices) + self.device_list_layout.addWidget(self.choice_w) + + self.valid = True + + if self.valid: + self.wizard.next_button.setFocus() + else: + self.rescan_button.setFocus() + + def scan_devices(self): + self.valid = False + self.busy_msg = _('Scanning devices...') + self.busy = True + + def scan_task(): + # check available plugins + supported_plugins = self.plugins.get_hardware_support() + devices = [] # type: List[Tuple[str, DeviceInfo]] + devmgr = self.plugins.device_manager + debug_msg = '' + + def failed_getting_device_infos(name, e): + nonlocal debug_msg + err_str_oneline = ' // '.join(str(e).splitlines()) + self.logger.warning(f'error getting device infos for {name}: {err_str_oneline}') + indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True)) + debug_msg += f' {name}: (error getting device infos)\n{indented_error_msg}\n' + + # scan devices + try: + # scanned_devices = self.run_task_without_blocking_gui(task=devmgr.scan_devices, + # msg=_("Scanning devices...")) + scanned_devices = devmgr.scan_devices() + except BaseException as e: + self.logger.info('error scanning devices: {}'.format(repr(e))) + debug_msg = ' {}:\n {}'.format(_('Error scanning devices'), e) + else: + for splugin in supported_plugins: + name, plugin = splugin.name, splugin.plugin + # plugin init errored? + if not plugin: + e = splugin.exception + indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True)) + debug_msg += f' {name}: (error during plugin init)\n' + debug_msg += ' {}\n'.format(_('You might have an incompatible library.')) + debug_msg += f'{indented_error_msg}\n' + continue + # see if plugin recognizes 'scanned_devices' + try: + # FIXME: side-effect: this sets client.handler + device_infos = devmgr.list_pairable_device_infos( + handler=None, plugin=plugin, devices=scanned_devices, include_failing_clients=True) + except HardwarePluginLibraryUnavailable as e: + failed_getting_device_infos(name, e) + continue + except BaseException as e: + self.logger.exception('') + failed_getting_device_infos(name, e) + continue + device_infos_failing = list(filter(lambda di: di.exception is not None, device_infos)) + for di in device_infos_failing: + failed_getting_device_infos(name, di.exception) + device_infos_working = list(filter(lambda di: di.exception is None, device_infos)) + devices += list(map(lambda x: (name, x), device_infos_working)) + if not debug_msg: + debug_msg = ' {}'.format(_('No exceptions encountered.')) + if not devices: + msg = (_('No hardware device detected.') + '\n\n') + if sys.platform == 'win32': + msg += _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", ' + 'and do "Remove device". Then, plug your device again.') + '\n' + msg += _('While this is less than ideal, it might help if you run Electrum as Administrator.') + '\n' + else: + msg += _('On Linux, you might have to add a new permission to your udev rules.') + '\n' + msg += '\n\n' + msg += _('Debug message') + '\n' + debug_msg + + self.scanFailed.emit('no_devices', msg) + self.busy = False + return + + # select device + self.devices = devices + self.scanComplete.emit() + self.busy = False + + t = threading.Thread(target=scan_task, daemon=True) + t.start() + + def apply(self): + if self.choice_w: + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + cosigner_data['hardware_device'] = self.choice_w.selected_item[0] + + +class WCWalletPasswordHardware(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Encrypt using hardware')) + self.plugins = wizard.plugins + + self.playout = PasswordLayoutForHW(MSG_HW_STORAGE_ENCRYPTION) + self.playout.encrypt_cb.setChecked(True) + self.layout().addLayout(self.playout.layout()) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['encrypt'] = self.playout.encrypt_cb.isChecked() + if self.playout.encrypt_cb.isChecked(): + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + # client.handler = self.plugin.create_handler(self.wizard) + self.wizard_data['password'] = client.get_password_for_storage_encryption() + + +class WCHWUnlock(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Unlocking hardware')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = None + self._busy = True + self.password = None + + self.ok_l = WWLabel(_('Hardware successfully unlocked')) + self.ok_l.setAlignment(Qt.AlignCenter) + self.layout().addWidget(self.ok_l) + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + self.plugin = self.plugins.get_plugin(_info.plugin_name) + self.title = _('Unlocking {} ({})').format(_info.model_name, _info.label) + + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def unlock_task(client): + try: + self.password = client.get_password_for_storage_encryption() + except Exception as e: + self.error = repr(e) # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully + self.logger.error(repr(e)) + self.busy = False + self.validate() + + t = threading.Thread(target=unlock_task, args=(client,), daemon=True) + t.start() + + def validate(self): + self.valid = False + if self.password and not self.error: + if not self.check_hw_decrypt(): + self.error = _('This hardware device could not decrypt this wallet. Is it the correct one?') + else: + self.apply() + self.valid = True + + if self.valid: + self.wizard.requestNext.emit() # via signal, so it triggers Next/Finish on GUI thread after on_updated() + + def check_hw_decrypt(self): + wallet_file = self.wizard_data['wallet_name'] + + storage = WalletStorage(wallet_file) + if not storage.is_encrypted_with_hw_device(): + return True + + try: + storage.decrypt(self.password) + except InvalidPassword: + return False + return True + + def apply(self): + if self.valid: + self.wizard_data['password'] = self.password + + +class WCHWXPub(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Retrieving extended public key from hardware')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = None + self._busy = True + + self.xpub = None + self.root_fingerprint = None + self.label = None + self.soft_device_id = None + + self.ok_l = WWLabel(_('Hardware keystore added to wallet')) + self.ok_l.setAlignment(Qt.AlignCenter) + self.layout().addWidget(self.ok_l) + + def on_ready(self): + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + _name, _info = cosigner_data['hardware_device'] + self.plugin = self.plugins.get_plugin(_info.plugin_name) + self.title = _('Retrieving extended public key from {} ({})').format(_info.model_name, _info.label) + + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + if not client.handler: + client.handler = self.plugin.create_handler(self.wizard) + + xtype = cosigner_data['script_type'] + derivation = cosigner_data['derivation_path'] + + def get_xpub_task(client, derivation, xtype): + try: + self.xpub = self.get_xpub_from_client(client, derivation, xtype) + self.root_fingerprint = client.request_root_fingerprint_from_device() + self.label = client.label() + self.soft_device_id = client.get_soft_device_id() + except UserFacingException as e: + self.error = str(e) + self.logger.error(repr(e)) + except Exception as e: + self.error = repr(e) # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully + self.logger.error(repr(e)) + self.logger.debug(f'Done retrieve xpub: {self.xpub}') + self.busy = False + self.validate() + + t = threading.Thread(target=get_xpub_task, args=(client, derivation, xtype), daemon=True) + t.start() + + def get_xpub_from_client(self, client, derivation, xtype): # override for HWW specific client if needed + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + _name, _info = cosigner_data['hardware_device'] + if xtype not in self.plugin.SUPPORTED_XTYPES: + raise ScriptTypeNotSupported(_('This type of script is not supported with {}').format(_info.model_name)) + return client.get_xpub(derivation, xtype) + + def validate(self): + if self.xpub and not self.error: + self.apply() + valid, error = self.wizard.check_multisig_constraints(self.wizard_data) + if not valid: + self.error = '\n'.join([ + _('Could not add hardware keystore to wallet'), + error + ]) + self.valid = valid + else: + self.valid = False + + if self.valid: + self.wizard.requestNext.emit() # via signal, so it triggers Next/Finish on GUI thread after on_updated() + + def apply(self): + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + _name, _info = cosigner_data['hardware_device'] + cosigner_data['hw_type'] = _info.plugin_name + cosigner_data['master_key'] = self.xpub + cosigner_data['root_fingerprint'] = self.root_fingerprint + cosigner_data['label'] = self.label + cosigner_data['soft_device_id'] = self.soft_device_id + + +class WCHWUninitialized(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Hardware not initialized')) + + def on_ready(self): + cosigner_data = self.wizard.current_cosigner(self.wizard_data) + _name, _info = cosigner_data['hardware_device'] + label = WWLabel(_('This {} is not initialized. Use manufacturer tooling to initialize the device.').format(_info.model_name)) + label.setAlignment(Qt.AlignCenter) + self.layout().addWidget(label) diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py new file mode 100644 index 000000000000..bcf0de044ce1 --- /dev/null +++ b/electrum/gui/qt/wizard/wizard.py @@ -0,0 +1,287 @@ +import copy +import threading +from abc import abstractmethod +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QSize +from PyQt5.QtGui import QPixmap +from PyQt5.QtWidgets import (QDialog, QPushButton, QWidget, QLabel, QVBoxLayout, QScrollArea, + QHBoxLayout, QLayout, QStackedWidget) + +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.gui.qt.util import Buttons, icon_path, MessageBoxMixin, WWLabel + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + from electrum.gui.qt import QElectrumApplication + from electrum.wizard import WizardViewState + + +class QEAbstractWizard(QDialog, MessageBoxMixin): + _logger = get_logger(__name__) + + requestNext = pyqtSignal() + requestPrev = pyqtSignal() + + def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', *, start_viewstate: 'WizardViewState' = None): + QDialog.__init__(self, None) + self.app = app + self.config = config + + # compat + self.gui_thread = threading.current_thread() + + self.setMinimumSize(600, 400) + + self.title = QLabel() + + self.main_widget = QStackedWidget(self) + + self.back_button = QPushButton(_("Back"), self) + self.back_button.clicked.connect(self.on_back_button_clicked) + self.next_button = QPushButton(_("Next"), self) + self.next_button.clicked.connect(self.on_next_button_clicked) + self.next_button.setDefault(True) + self.requestPrev.connect(self.on_back_button_clicked) + self.requestNext.connect(self.on_next_button_clicked) + self.logo = QLabel() + + please_wait_layout = QVBoxLayout() + please_wait_layout.addStretch(1) + self.please_wait_l = QLabel(_("Please wait...")) + self.please_wait_l.setAlignment(Qt.AlignCenter) + please_wait_layout.addWidget(self.please_wait_l) + please_wait_layout.addStretch(1) + self.please_wait = QWidget() + self.please_wait.setVisible(False) + self.please_wait.setLayout(please_wait_layout) + + error_layout = QVBoxLayout() + error_layout.addStretch(1) + # error_l = QLabel(_("Error!")) + # error_l.setAlignment(Qt.AlignCenter) + # error_layout.addWidget(error_l) + self.error_msg = WWLabel() + self.error_msg.setAlignment(Qt.AlignCenter) + error_layout.addWidget(self.error_msg) + error_layout.addStretch(1) + self.error = QWidget() + self.error.setVisible(False) + self.error.setLayout(error_layout) + + outer_vbox = QVBoxLayout(self) + inner_vbox = QVBoxLayout() + inner_vbox.addWidget(self.title) + inner_vbox.addWidget(self.main_widget) + inner_vbox.addWidget(self.please_wait) + inner_vbox.addWidget(self.error) + + scroll_widget = QWidget() + scroll_widget.setLayout(inner_vbox) + scroll = QScrollArea() + scroll.setFocusPolicy(Qt.NoFocus) + scroll.setWidget(scroll_widget) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setWidgetResizable(True) + icon_vbox = QVBoxLayout() + icon_vbox.addWidget(self.logo) + icon_vbox.addStretch(1) + hbox = QHBoxLayout() + hbox.addLayout(icon_vbox) + hbox.addSpacing(5) + hbox.addWidget(scroll) + hbox.setStretchFactor(scroll, 1) + outer_vbox.addLayout(hbox) + outer_vbox.addLayout(Buttons(self.back_button, self.next_button)) + + self.icon_filename = None + self.set_icon('electrum.png') + + self.start_viewstate = start_viewstate + + self.show() + self.raise_() + + QTimer.singleShot(40, self.strt) + + # TODO: re-test if needed on macOS + # self.refresh_gui() # Need for QT on MacOSX. Lame. + + # def refresh_gui(self): + # # For some reason, to refresh the GUI this needs to be called twice + # self.app.processEvents() + # self.app.processEvents() + + def sizeHint(self) -> QSize: + return QSize(800, 600) + + def strt(self): + if self.start_viewstate is not None: + viewstate = self._current = self.start_viewstate + else: + viewstate = self.start_wizard() + self.load_next_component(viewstate.view, viewstate.wizard_data) + + def load_next_component(self, view, wdata=None, params=None): + if wdata is None: + wdata = {} + if params is None: + params = {} + + comp = self.view_to_component(view) + try: + page = comp(self.main_widget, self) + except Exception as e: + self._logger.error(f'not a class: {comp!r}') + raise e + page.wizard_data = copy.deepcopy(wdata) + page.params = params + page.updated.connect(self.on_page_updated) + self._logger.debug(f'{page!r}') + + # add to stack and update wizard + self.main_widget.setCurrentIndex(self.main_widget.addWidget(page)) + page.on_ready() + page.apply() + self.update() + + @pyqtSlot(object) + def on_page_updated(self, page): + page.apply() + if page == self.main_widget.currentWidget(): + self.update() + + def set_icon(self, filename): + prior_filename, self.icon_filename = self.icon_filename, filename + self.logo.setPixmap(QPixmap(icon_path(filename)) + .scaledToWidth(60, mode=Qt.SmoothTransformation)) + return prior_filename + + def can_go_back(self) -> bool: + return len(self._stack) > 0 + + def update(self): + page = self.main_widget.currentWidget() + self.title.setText(f'{page.title}' if page.title else '') + self.back_button.setText(_('Back') if self.can_go_back() else _('Cancel')) + self.back_button.setEnabled(not page.busy) + self.next_button.setText(_('Next') if not self.is_last(page.wizard_data) else _('Finish')) + self.next_button.setEnabled(not page.busy and page.valid) + self.main_widget.setVisible(not page.busy and not bool(page.error)) + self.please_wait.setVisible(page.busy) + self.please_wait_l.setText(page.busy_msg if page.busy_msg else _("Please wait...")) + self.error_msg.setText(str(page.error)) + self.error.setVisible(not page.busy and bool(page.error)) + icon = page.params.get('icon', icon_path('electrum.png')) + if icon != self.icon_filename: + self.set_icon(icon) + + def on_back_button_clicked(self): + if self.can_go_back(): + self.prev() + widget = self.main_widget.currentWidget() + self.main_widget.removeWidget(widget) + widget.deleteLater() + self.update() + else: + self.close() + + def on_next_button_clicked(self): + page = self.main_widget.currentWidget() + page.apply() + wd = page.wizard_data.copy() + if self.is_last(wd): + self.submit(wd) + if self.is_finalized(wd): + self.accept() + else: + self.prev() # rollback the submit above + else: + next = self.submit(wd) + self.load_next_component(next.view, next.wizard_data, next.params) + + def start_wizard(self) -> 'WizardViewState': + self.start() + return self._current + + def view_to_component(self, view) -> QWidget: + return self.navmap[view]['gui'] + + def submit(self, wizard_data) -> dict: + wdata = wizard_data.copy() + view = self.resolve_next(self._current.view, wdata) + return view + + def prev(self) -> dict: + viewstate = self.resolve_prev() + return viewstate.wizard_data + + def is_last(self, wizard_data: dict) -> bool: + wdata = wizard_data.copy() + return self.is_last_view(self._current.view, wdata) + + def is_finalized(self, wizard_data: dict) -> bool: + ''' Final check before closing the wizard. ''' + return True + + +class WizardComponent(QWidget): + updated = pyqtSignal(object) + + def __init__(self, parent: QWidget, wizard: QEAbstractWizard, *, title: str = None, layout: QLayout = None): + super().__init__(parent) + self.setLayout(layout if layout else QVBoxLayout(self)) + self.wizard_data = {} + self.title = title if title is not None else 'No title' + self.busy_msg = '' + self.wizard = wizard + self._error = '' + self._valid = False + self._busy = False + + @property + def valid(self): + return self._valid + + @valid.setter + def valid(self, is_valid): + if self._valid != is_valid: + self._valid = is_valid + self.on_updated() + + @property + def busy(self): + return self._busy + + @busy.setter + def busy(self, is_busy): + if self._busy != is_busy: + self._busy = is_busy + self.on_updated() + + @property + def error(self): + return self._error + + @error.setter + def error(self, error): + if self._error != error: + self._error = error + self.on_updated() + + @abstractmethod + def apply(self): + # called to apply UI component values to wizard_data + pass + + def on_ready(self): + # called when wizard_data is available + pass + + @pyqtSlot() + def on_updated(self, *args): + try: + self.updated.emit(self) + except RuntimeError: + pass diff --git a/electrum/keystore.py b/electrum/keystore.py index fdab6f64469f..f4d4a2188334 100644 --- a/electrum/keystore.py +++ b/electrum/keystore.py @@ -58,6 +58,8 @@ class CannotDerivePubkey(Exception): pass +class ScriptTypeNotSupported(Exception): pass + def also_test_none_password(check_password_fn): """Decorator for check_password, simply to give a friendlier exception if diff --git a/electrum/plugins/bitbox02/bitbox02.py b/electrum/plugins/bitbox02/bitbox02.py index 320dd0e16bc5..a97e72bebd2e 100644 --- a/electrum/plugins/bitbox02/bitbox02.py +++ b/electrum/plugins/bitbox02/bitbox02.py @@ -9,13 +9,11 @@ from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore from electrum.transaction import PartialTransaction, Sighash -from electrum.wallet import Standard_Wallet, Multisig_Wallet, Deterministic_Wallet +from electrum.wallet import Multisig_Wallet, Deterministic_Wallet from electrum.util import UserFacingException -from electrum.base_wizard import ScriptTypeNotSupported, BaseWizard from electrum.logging import get_logger from electrum.plugin import Device, DeviceInfo, runs_in_hwd_thread from electrum.simple_config import SimpleConfig -from electrum.json_db import StoredDict from electrum.storage import get_derivation_used_for_hw_device_encryption from electrum.bitcoin import OnchainOutputType @@ -24,6 +22,8 @@ from ..hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase +if TYPE_CHECKING: + from electrum.wizard import NewWalletWizard _logger = get_logger(__name__) @@ -31,13 +31,8 @@ try: from bitbox02 import bitbox02 from bitbox02 import util - from bitbox02.communication import ( - devices, - HARDENED, - u2fhid, - bitbox_api_protocol, - FirmwareVersionOutdatedException, - ) + from bitbox02.communication import (devices, HARDENED, u2fhid, bitbox_api_protocol, + FirmwareVersionOutdatedException) requirements_ok = True except ImportError as e: if not (isinstance(e, ModuleNotFoundError) and e.name == 'bitbox02'): @@ -45,6 +40,10 @@ requirements_ok = False +class BitBox02NotInitialized(UserFacingException): + pass + + class BitBox02Client(HardwareClientBase): # handler is a BitBox02_Handler, importing it would lead to a circular dependency def __init__(self, handler: HardwareHandlerBase, device: Device, config: SimpleConfig, *, plugin: HW_PluginBase): @@ -72,6 +71,9 @@ def __init__(self, handler: HardwareHandlerBase, device: Device, config: SimpleC if self.bitbox_hid_info is None: raise Exception("No BitBox02 detected") + def device_model_name(self) -> Optional[str]: + return 'BitBox02' + def is_initialized(self) -> bool: return True @@ -116,7 +118,7 @@ def exists_remote_static_pubkey(pubkey: bytes) -> bool: bitbox02_config = self.config.get("bitbox02") noise_keys = bitbox02_config.get("remote_static_noise_keys") if noise_keys is not None: - if pubkey.hex() in [noise_key for noise_key in noise_keys]: + if pubkey.hex() in noise_keys: return True return False @@ -189,7 +191,7 @@ def set_app_static_privkey(self, privkey: bytes) -> None: def fail_if_not_initialized(self) -> None: assert self.bitbox02_device if not self.bitbox02_device.device_info()["initialized"]: - raise Exception( + raise BitBox02NotInitialized( "Please initialize the BitBox02 using the BitBox app first before using the BitBox02 in electrum" ) @@ -245,12 +247,8 @@ def get_xpub(self, bip32_path: str, xtype: str, *, display: bool = False) -> str else: raise Exception("invalid xtype:{}".format(xtype)) - return self.bitbox02_device.btc_xpub( - keypath=xpub_keypath, - xpub_type=out_type, - coin=coin_network, - display=display, - ) + return self.bitbox02_device.btc_xpub(keypath=xpub_keypath, xpub_type=out_type, coin=coin_network, + display=display) @runs_in_hwd_thread def label(self) -> str: @@ -562,6 +560,7 @@ def sign_message(self, keypath: str, message: bytes, script_type: str) -> bytes: ) return signature + class BitBox02_KeyStore(Hardware_KeyStore): hw_type = "bitbox02" device = "BitBox02" @@ -597,7 +596,6 @@ def sign_message(self, sequence, message, password, *, script_type=None): keypath = self.get_derivation_prefix() + "/%d/%d" % sequence return client.sign_message(keypath, message.encode("utf-8"), script_type) - @runs_in_hwd_thread def sign_transaction(self, tx: PartialTransaction, password: str): if tx.is_complete(): @@ -609,7 +607,6 @@ def sign_transaction(self, tx: PartialTransaction, password: str): try: self.handler.show_message("Authorize Transaction...") client.sign_transaction(self, tx, self.handler.get_wallet()) - finally: self.handler.finished() @@ -636,6 +633,7 @@ def show_address( self.logger.exception("") self.handler.show_error(e) + class BitBox02Plugin(HW_PluginBase): keystore_class = BitBox02_KeyStore minimum_library = (6, 2, 0) @@ -666,30 +664,6 @@ def get_library_version(self): def create_client(self, device, handler) -> BitBox02Client: return BitBox02Client(handler, device, self.config, plugin=self) - def setup_device( - self, device_info: DeviceInfo, wizard: BaseWizard, purpose: int - ): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - assert isinstance(client, BitBox02Client) - if client.bitbox02_device is None: - wizard.run_task_without_blocking_gui( - task=lambda client=client: client.pairing_dialog()) - client.fail_if_not_initialized() - return client - - def get_xpub( - self, device_id: str, derivation: str, xtype: str, wizard: BaseWizard - ): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported( - _("This type of script is not supported with {}: {}").format(self.device, xtype) - ) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - assert isinstance(client, BitBox02Client) - assert client.bitbox02_device is not None - return client.get_xpub(derivation, xtype) - @runs_in_hwd_thread def show_address( self, @@ -720,3 +694,29 @@ def create_device_from_hid_enumeration(self, d: dict, *, product_key) -> 'Device # distinguish devices. id_ = str(d['path']) return device._replace(id_=id_) + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + # Note: device_info.initialized for this hardware doesn't imply a seed is present, + # only that it has firmware installed + if new_wallet: + return 'bitbox02_start' if device_info.initialized else 'bitbox02_not_initialized' + else: + return 'bitbox02_unlock' + + # insert bitbox02 pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'bitbox02_start': { + 'next': 'bitbox02_xpub', + }, + 'bitbox02_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'bitbox02_not_initialized': {}, + 'bitbox02_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) diff --git a/electrum/plugins/bitbox02/qt.py b/electrum/plugins/bitbox02/qt.py index 50454b117a2f..4e8df221d00a 100644 --- a/electrum/plugins/bitbox02/qt.py +++ b/electrum/plugins/bitbox02/qt.py @@ -1,27 +1,23 @@ +import threading from functools import partial +from typing import TYPE_CHECKING -from PyQt5.QtWidgets import ( - QPushButton, - QLabel, - QVBoxLayout, - QLineEdit, - QHBoxLayout, -) - -from PyQt5.QtCore import Qt, QMetaObject, Q_RETURN_ARG, pyqtSlot - -from electrum.gui.qt.util import ( - WindowModalDialog, - OkButton, - ButtonsTextEdit, -) +from PyQt5.QtCore import Qt, QMetaObject, Q_RETURN_ARG, pyqtSlot, pyqtSignal +from PyQt5.QtWidgets import QLabel, QVBoxLayout, QLineEdit, QHBoxLayout from electrum.i18n import _ from electrum.plugin import hook +from electrum.util import UserCancelled, UserFacingException from .bitbox02 import BitBox02Plugin from ..hw_wallet.qt import QtHandlerBase, QtPluginBase -from ..hw_wallet.plugin import only_hook_if_libraries_available +from ..hw_wallet.plugin import only_hook_if_libraries_available, OperationCancelled + +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWUnlock, WCHWUninitialized, WCHWXPub +from electrum.gui.qt.util import WindowModalDialog, OkButton, ButtonsTextEdit + +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard class Plugin(BitBox02Plugin, QtPluginBase): @@ -64,6 +60,21 @@ def on_button_click(): device_name = "{} ({})".format(self.device, keystore.label) mpk_text.addButton("eye1.png", on_button_click, _("Show on {}").format(device_name)) + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert bitbox02 pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'bitbox02_start': {'gui': WCBitbox02ScriptAndDerivation}, + 'bitbox02_xpub': {'gui': WCHWXPub}, + 'bitbox02_not_initialized': {'gui': WCHWUninitialized}, + 'bitbox02_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + class BitBox02_Handler(QtHandlerBase): MESSAGE_DIALOG_TITLE = _("BitBox02 Status") @@ -72,12 +83,7 @@ def __init__(self, win): super(BitBox02_Handler, self).__init__(win, "BitBox02") def name_multisig_account(self): - return QMetaObject.invokeMethod( - self, - "_name_multisig_account", - Qt.BlockingQueuedConnection, - Q_RETURN_ARG(str), - ) + return QMetaObject.invokeMethod(self, "_name_multisig_account", Qt.BlockingQueuedConnection, Q_RETURN_ARG(str)) @pyqtSlot(result=str) def _name_multisig_account(self): @@ -105,3 +111,46 @@ def _name_multisig_account(self): dialog.setLayout(vbox) dialog.exec_() return name.text().strip() + + +class WCBitbox02ScriptAndDerivation(WCScriptAndDerivation): + def __init__(self, parent, wizard): + WCScriptAndDerivation.__init__(self, parent, wizard) + self._busy = True + self.title = '' + self.client = None + + def on_ready(self): + super().on_ready() + _name, _info = self.wizard_data['hardware_device'] + plugin = self.wizard.plugins.get_plugin(_info.plugin_name) + + device_id = _info.device.id_ + self.client = self.wizard.plugins.device_manager.client_by_id(device_id, scan_now=False) + if not self.client.handler: + self.client.handler = plugin.create_handler(self.wizard) + self.client.setupRunning = True + self.check_device() + + def check_device(self): + self.error = None + self.valid = False + self.busy = True + + def check_task(): + try: + self.client.pairing_dialog() + self.title = _('Script type and Derivation path') + self.valid = True + except (UserCancelled, OperationCancelled): + self.error = _('Cancelled') + self.wizard.requestPrev.emit() + except UserFacingException as e: + self.error = str(e) + except Exception as e: + self.error = repr(e) + finally: + self.busy = False + + t = threading.Thread(target=check_task, daemon=True) + t.start() diff --git a/electrum/plugins/coldcard/coldcard.py b/electrum/plugins/coldcard/coldcard.py index 972e6def0d79..9c802f10d1fe 100644 --- a/electrum/plugins/coldcard/coldcard.py +++ b/electrum/plugins/coldcard/coldcard.py @@ -2,8 +2,8 @@ # Coldcard Electrum plugin main code. # # -import os, time, io -import traceback +import os +import time from typing import TYPE_CHECKING, Optional import struct @@ -15,12 +15,14 @@ from electrum.transaction import PartialTransaction from electrum.wallet import Standard_Wallet, Multisig_Wallet, Abstract_Wallet from electrum.util import bfh, versiontuple, UserFacingException -from electrum.base_wizard import ScriptTypeNotSupported from electrum.logging import get_logger from ..hw_wallet import HW_PluginBase, HardwareClientBase from ..hw_wallet.plugin import LibraryFoundButUnusable, only_hook_if_libraries_available +if TYPE_CHECKING: + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard _logger = get_logger(__name__) @@ -57,7 +59,6 @@ def mitm_verify(self, sig, expect_xpub): class CKCCClient(HardwareClientBase): - def __init__(self, plugin, handler, dev_path, *, is_simulator=False): HardwareClientBase.__init__(self, plugin=plugin) self.device = plugin.device @@ -78,20 +79,22 @@ def __init__(self, plugin, handler, dev_path, *, is_simulator=False): # NOTE: MiTM test is delayed until we have a hint as to what XPUB we # should expect. It's also kinda slow. + def device_model_name(self) -> Optional[str]: + return 'Coldcard' + def __repr__(self): return '' % (xfp2str(self.dev.master_fingerprint), self.label()) @runs_in_hwd_thread - def verify_connection(self, expected_xfp: int, expected_xpub=None): + def verify_connection(self, expected_xfp: int, expected_xpub: str): ex = (expected_xfp, expected_xpub) if self._expected_device == ex: # all is as expected return - if expected_xpub is None: - expected_xpub = self.dev.master_xpub + assert expected_xpub if ((self._expected_device is not None) or (self.dev.master_fingerprint != expected_xfp) @@ -110,9 +113,6 @@ def verify_connection(self, expected_xfp: int, expected_xpub=None): self._expected_device = ex - if not getattr(self, 'ckcc_xpub', None): - self.ckcc_xpub = expected_xpub - _logger.info("Successfully verified against MiTM") def is_pairable(self): @@ -142,7 +142,7 @@ def label(self): return lab - def manipulate_keystore_dict_during_wizard_setup(self, d: dict): + def _get_ckcc_master_xpub_from_device(self): master_xpub = self.dev.master_xpub if master_xpub is not None: try: @@ -152,7 +152,7 @@ def manipulate_keystore_dict_during_wizard_setup(self, d: dict): _('Invalid xpub magic. Make sure your {} device is set to the correct chain.').format(self.device) + ' ' + _('You might have to unplug and plug it in again.') ) from None - d['ckcc_xpub'] = master_xpub + return master_xpub @runs_in_hwd_thread def has_usable_connection_with_device(self): @@ -269,6 +269,12 @@ def get_xfp_int(self) -> int: assert xfp is not None return xfp_int_from_xfp_bytes(bfh(xfp)) + def opportunistically_fill_in_missing_info_from_device(self, client: 'CKCCClient'): + super().opportunistically_fill_in_missing_info_from_device(client) + if self.ckcc_xpub is None: + self.ckcc_xpub = client._get_ckcc_master_xpub_from_device() + self.is_requesting_to_be_rewritten_to_wallet_file = True + def get_client(self, *args, **kwargs): # called when user tries to do something like view address, sign somthing. # - not called during probing/setup @@ -518,22 +524,6 @@ def create_client(self, device, handler): self.logger.exception('late failure connecting to device?') return None - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - return client - - def get_xpub(self, device_id, derivation, xtype, wizard): - # this seems to be part of the pairing process only, not during normal ops? - # base_wizard:on_hw_derivation - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - client.ping_check() - - xpub = client.get_xpub(derivation, xtype) - return xpub - @runs_in_hwd_thread def get_client(self, keystore, force_pair=True, *, devices=None, allow_user_interaction=True) -> Optional['CKCCClient']: @@ -612,6 +602,30 @@ def show_address(self, wallet, address, keystore: 'Coldcard_KeyStore' = None): keystore.handler.show_error(_('This function is only available for standard wallets when using {}.').format(self.device)) return + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: + return 'coldcard_start' if device_info.initialized else 'coldcard_not_initialized' + else: + return 'coldcard_unlock' + + # insert coldcard pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'coldcard_start': { + 'next': 'coldcard_xpub', + }, + 'coldcard_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'coldcard_not_initialized': {}, + 'coldcard_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) + def xfp_int_from_xfp_bytes(fp_bytes: bytes) -> int: return int.from_bytes(fp_bytes, byteorder="little", signed=False) diff --git a/electrum/plugins/coldcard/qt.py b/electrum/plugins/coldcard/qt.py index f94a970fee5d..d468962309dc 100644 --- a/electrum/plugins/coldcard/qt.py +++ b/electrum/plugins/coldcard/qt.py @@ -1,27 +1,28 @@ -import time, os from functools import partial -import copy +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import QPushButton, QLabel, QVBoxLayout, QWidget, QGridLayout from electrum.gui.qt.util import (WindowModalDialog, CloseButton, Buttons, getOpenFileName, getSaveFileName) -from electrum.gui.qt.transaction_dialog import TxDialog from electrum.gui.qt.main_window import ElectrumWindow from electrum.i18n import _ from electrum.plugin import hook from electrum.wallet import Multisig_Wallet -from electrum.transaction import PartialTransaction from .coldcard import ColdcardPlugin, xfp2str from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWXPub, WCHWUninitialized, WCHWUnlock +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard CC_DEBUG = False + class Plugin(ColdcardPlugin, QtPluginBase): icon_unpaired = "coldcard_unpaired.png" icon_paired = "coldcard.png" @@ -82,6 +83,21 @@ def show_settings_dialog(self, window, keystore): # - doesn't matter if device not connected, continue CKCCSettingsDialog(window, self, keystore).exec_() + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert coldcard pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'coldcard_start': {'gui': WCScriptAndDerivation}, + 'coldcard_xpub': {'gui': WCHWXPub}, + 'coldcard_not_initialized': {'gui': WCHWUninitialized}, + 'coldcard_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + class Coldcard_Handler(QtHandlerBase): MESSAGE_DIALOG_TITLE = _("Coldcard Status") diff --git a/electrum/plugins/digitalbitbox/digitalbitbox.py b/electrum/plugins/digitalbitbox/digitalbitbox.py index f5fd6f8ffabc..4440fd43c022 100644 --- a/electrum/plugins/digitalbitbox/digitalbitbox.py +++ b/electrum/plugins/digitalbitbox/digitalbitbox.py @@ -15,6 +15,7 @@ import sys import time import copy +from typing import TYPE_CHECKING, Optional from electrum.crypto import sha256d, EncodeAES_bytes, DecodeAES_bytes, hmac_oneshot from electrum.bitcoin import public_key_to_p2pkh @@ -29,13 +30,16 @@ from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore from electrum.util import to_string, UserCancelled, UserFacingException, bfh -from electrum.base_wizard import ScriptTypeNotSupported, HWD_SETUP_NEW_WALLET from electrum.network import Network from electrum.logging import get_logger from electrum.plugin import runs_in_hwd_thread, run_in_hwd_thread from ..hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase +from ..hw_wallet.plugin import OperationCancelled +if TYPE_CHECKING: + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard _logger = get_logger(__name__) @@ -47,11 +51,14 @@ DIGIBOX = False +class DeviceErased(UserFacingException): + pass # ---------------------------------------------------------------------------------- # USB HID interface # + def to_hexstr(s): return binascii.hexlify(s).decode('ascii') @@ -61,13 +68,14 @@ def derive_keys(x): h = hashlib.sha512(h).digest() return (h[:32],h[32:]) + MIN_MAJOR_VERSION = 5 ENCRYPTION_PRIVKEY_KEY = 'encryptionprivkey' CHANNEL_ID_KEY = 'comserverchannelid' -class DigitalBitbox_Client(HardwareClientBase): +class DigitalBitbox_Client(HardwareClientBase): def __init__(self, plugin, hidDevice): HardwareClientBase.__init__(self, plugin=plugin) self.dbb_hid = hidDevice @@ -75,7 +83,10 @@ def __init__(self, plugin, hidDevice): self.password = None self.isInitialized = False self.setupRunning = False - self.usbReportSize = 64 # firmware > v2.0.0 + self.usbReportSize = 64 # firmware > v2.0.0 + + def device_model_name(self) -> Optional[str]: + return 'Digital BitBox' @runs_in_hwd_thread def close(self): @@ -86,15 +97,12 @@ def close(self): pass self.opened = False - def is_pairable(self): return True - def is_initialized(self): return self.dbb_has_password() - def is_paired(self): return self.password is not None @@ -112,6 +120,10 @@ def _get_xpub(self, bip32_path: str): def get_xpub(self, bip32_path, xtype): assert xtype in self.plugin.SUPPORTED_XTYPES + + if is_all_public_derivation(bip32_path): + raise UserFacingException(_('This device does not reveal xpubs corresponding to non-hardened paths')) + reply = self._get_xpub(bip32_path) if reply: xpub = reply['xpub'] @@ -136,11 +148,9 @@ def dbb_has_password(self): return True return False - def stretch_key(self, key: bytes): return to_hexstr(hashlib.pbkdf2_hmac('sha512', key, b'Digital Bitbox', iterations = 20480)) - def backup_password_dialog(self): msg = _("Enter the password used when the backup was created:") while True: @@ -156,7 +166,6 @@ def backup_password_dialog(self): else: return password.encode('utf8') - def password_dialog(self, msg): while True: password = self.handler.get_passphrase(msg, False) @@ -172,7 +181,7 @@ def password_dialog(self, msg): self.password = password.encode('utf8') return True - def check_device_dialog(self): + def check_firmware_version(self): match = re.search(r'v([0-9])+\.[0-9]+\.[0-9]+', run_in_hwd_thread(self.dbb_hid.get_serial_number_string)) if match is None: @@ -180,6 +189,9 @@ def check_device_dialog(self): major_version = int(match.group(1)) if major_version < MIN_MAJOR_VERSION: raise Exception("Please upgrade to the newest firmware using the BitBox Desktop app: https://shiftcrypto.ch/start") + + def check_device_dialog(self): + self.check_firmware_version() # Set password if fresh device if self.password is None and not self.dbb_has_password(): if not self.setupRunning: @@ -224,29 +236,23 @@ def check_device_dialog(self): self.mobile_pairing_dialog() return self.isInitialized - def recover_or_erase_dialog(self): msg = _("The Digital Bitbox is already seeded. Choose an option:") + "\n" choices = [ (_("Create a wallet using the current seed")), - (_("Load a wallet from the micro SD card (the current seed is overwritten)")), (_("Erase the Digital Bitbox")) ] reply = self.handler.query_choice(msg, choices) if reply is None: - return # user cancelled - if reply == 2: + raise UserCancelled() + if reply == 1: self.dbb_erase() - elif reply == 1: - if not self.dbb_load_backup(): - return else: if self.hid_send_encrypt(b'{"device":"info"}')['device']['lock']: raise UserFacingException(_("Full 2FA enabled. This is not supported yet.")) # Use existing seed self.isInitialized = True - def seed_device_dialog(self): msg = _("Choose how to initialize your Digital Bitbox:") + "\n" choices = [ @@ -255,7 +261,7 @@ def seed_device_dialog(self): ] reply = self.handler.query_choice(msg, choices) if reply is None: - return # user cancelled + raise UserCancelled() if reply == 0: self.dbb_generate_wallet() else: @@ -295,7 +301,7 @@ def mobile_pairing_dialog(self): ] reply = self.handler.query_choice(_('Mobile pairing options'), choices) if reply is None: - return # user cancelled + raise UserCancelled() if reply == 0: if self.plugin.is_mobile_paired(): @@ -315,7 +321,6 @@ def dbb_generate_wallet(self): if 'error' in reply: raise UserFacingException(reply['error']['message']) - def dbb_erase(self): self.handler.show_message(_("Are you sure you want to erase the Digital Bitbox?") + "\n\n" + _("To continue, touch the Digital Bitbox's light for 3 seconds.") + "\n\n" + @@ -323,11 +328,12 @@ def dbb_erase(self): hid_reply = self.hid_send_encrypt(b'{"reset":"__ERASE__"}') self.handler.finished() if 'error' in hid_reply: + if hid_reply['error'].get('code') in (600, 601): + raise OperationCancelled() raise UserFacingException(hid_reply['error']['message']) else: self.password = None - raise UserFacingException('Device erased') - + raise DeviceErased('Device erased') def dbb_load_backup(self, show_msg=True): backups = self.hid_send_encrypt(b'{"backup":"list"}') @@ -335,10 +341,10 @@ def dbb_load_backup(self, show_msg=True): raise UserFacingException(backups['error']['message']) f = self.handler.query_choice(_("Choose a backup file:"), backups['backup']) if f is None: - return False # user cancelled + raise UserCancelled() key = self.backup_password_dialog() if key is None: - raise Exception('Canceled by user') + raise UserCancelled('No backup password provided') key = self.stretch_key(key) if show_msg: self.handler.show_message(_("Loading backup...") + "\n\n" + @@ -348,6 +354,8 @@ def dbb_load_backup(self, show_msg=True): hid_reply = self.hid_send_encrypt(msg) self.handler.finished() if 'error' in hid_reply: + if hid_reply['error'].get('code') in (600, 601): + raise OperationCancelled() raise UserFacingException(hid_reply['error']['message']) return True @@ -453,11 +461,9 @@ def __init__(self, d): def give_error(self, message): raise Exception(message) - def decrypt_message(self, pubkey, message, password): raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device)) - def sign_message(self, sequence, message, password, *, script_type=None): sig = None try: @@ -513,12 +519,10 @@ def sign_message(self, sequence, message, password, *, script_type=None): else: raise Exception(_("Could not sign message")) - except BaseException as e: self.give_error(e) return sig - def sign_transaction(self, tx, password): if tx.is_complete(): return @@ -687,7 +691,6 @@ def get_dbb_device(self, device): dev.open_path(device.path) return dev - def create_client(self, device, handler): if device.interface_number == 0 or device.usage_page == 0xffff: client = self.get_dbb_device(device) @@ -697,21 +700,9 @@ def create_client(self, device, handler): else: return None - - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - if purpose == HWD_SETUP_NEW_WALLET: - client.setupRunning = True - wizard.run_task_without_blocking_gui( - task=lambda: client.get_xpub("m/44'/0'", 'standard')) - return client - - def is_mobile_paired(self): return ENCRYPTION_PRIVKEY_KEY in self.digitalbitbox_config - def comserver_post_notification(self, payload, *, handler: 'HardwareHandlerBase'): assert self.is_mobile_paired(), "unexpected mobile pairing error" url = 'https://digitalbitbox.com/smartverification/index.php' @@ -728,18 +719,6 @@ def comserver_post_notification(self, payload, *, handler: 'HardwareHandlerBase' _logger.exception("") handler.show_error(repr(e)) # repr because str(Exception()) == '' - - def get_xpub(self, device_id, derivation, xtype, wizard): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - if is_all_public_derivation(derivation): - raise Exception(f"The {self.device} does not reveal xpubs corresponding to non-hardened paths. (path: {derivation})") - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - client.check_device_dialog() - xpub = client.get_xpub(derivation, xtype) - return xpub - - def get_client(self, keystore, force_pair=True, *, devices=None, allow_user_interaction=True): client = super().get_client(keystore, force_pair, @@ -771,3 +750,26 @@ def show_address(self, wallet, address, keystore=None): "echo": xpub['echo'], } self.comserver_post_notification(verify_request_payload, handler=keystore.handler) + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: + return 'dbitbox_start' + else: + return 'dbitbox_unlock' + + # insert digitalbitbox pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'dbitbox_start': { + 'next': 'dbitbox_xpub', + }, + 'dbitbox_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'dbitbox_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) diff --git a/electrum/plugins/digitalbitbox/qt.py b/electrum/plugins/digitalbitbox/qt.py index 95bf62c2a5a1..96f94d5018f9 100644 --- a/electrum/plugins/digitalbitbox/qt.py +++ b/electrum/plugins/digitalbitbox/qt.py @@ -1,12 +1,23 @@ +import threading from functools import partial +from typing import TYPE_CHECKING + +from PyQt5.QtCore import pyqtSignal from electrum.i18n import _ from electrum.plugin import hook from electrum.wallet import Standard_Wallet, Abstract_Wallet +from electrum.util import UserCancelled, UserFacingException + +from electrum.plugins.hw_wallet.qt import QtHandlerBase, QtPluginBase +from electrum.plugins.hw_wallet.plugin import only_hook_if_libraries_available, OperationCancelled + +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWXPub, WCHWUnlock -from ..hw_wallet.qt import QtHandlerBase, QtPluginBase -from ..hw_wallet.plugin import only_hook_if_libraries_available -from .digitalbitbox import DigitalBitboxPlugin +from .digitalbitbox import DigitalBitboxPlugin, DeviceErased + +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard class Plugin(DigitalBitboxPlugin, QtPluginBase): @@ -33,13 +44,74 @@ def receive_menu(self, menu, addrs, wallet: Abstract_Wallet): addr = addrs[0] if wallet.get_txin_type(addr) != 'p2pkh': return + def show_address(): keystore.thread.add(partial(self.show_address, wallet, addr, keystore)) menu.addAction(_("Show on {}").format(self.device), show_address) + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) -class DigitalBitbox_Handler(QtHandlerBase): + # insert digitalbitbox pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'dbitbox_start': {'gui': WCDigitalBitboxScriptAndDerivation}, + 'dbitbox_xpub': {'gui': WCHWXPub}, + 'dbitbox_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + +class DigitalBitbox_Handler(QtHandlerBase): def __init__(self, win): super(DigitalBitbox_Handler, self).__init__(win, 'Digital Bitbox') + + +class WCDigitalBitboxScriptAndDerivation(WCScriptAndDerivation): + requestRecheck = pyqtSignal() + + def __init__(self, parent, wizard): + WCScriptAndDerivation.__init__(self, parent, wizard) + self._busy = True + self.title = '' + self.client = None + + self.requestRecheck.connect(self.check_device) + + def on_ready(self): + super().on_ready() + _name, _info = self.wizard_data['hardware_device'] + plugin = self.wizard.plugins.get_plugin(_info.plugin_name) + + device_id = _info.device.id_ + self.client = self.wizard.plugins.device_manager.client_by_id(device_id, scan_now=False) + if not self.client.handler: + self.client.handler = plugin.create_handler(self.wizard) + self.client.setupRunning = True + self.check_device() + + def check_device(self): + self.error = None + self.busy = True + + def check_task(): + try: + self.client.check_device_dialog() + self.title = _('Script type and Derivation path') + self.valid = True + except (UserCancelled, OperationCancelled): + self.error = _('Cancelled') + self.wizard.requestPrev.emit() + except DeviceErased: + self.error = _('Device erased') + self.requestRecheck.emit() + except UserFacingException as e: + self.error = str(e) + finally: + self.busy = False + + t = threading.Thread(target=check_task, daemon=True) + t.start() diff --git a/electrum/plugins/hw_wallet/plugin.py b/electrum/plugins/hw_wallet/plugin.py index 4c347da804a8..fe62cf1642a2 100644 --- a/electrum/plugins/hw_wallet/plugin.py +++ b/electrum/plugins/hw_wallet/plugin.py @@ -23,29 +23,29 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from abc import abstractmethod, ABC +from typing import TYPE_CHECKING, Sequence, Optional, Type, Iterable, Any -from typing import TYPE_CHECKING, Dict, List, Union, Tuple, Sequence, Optional, Type, Iterable, Any -from functools import partial - -from electrum.plugin import (BasePlugin, hook, Device, DeviceMgr, DeviceInfo, +from electrum.plugin import (BasePlugin, hook, Device, DeviceMgr, assert_runs_in_hwd_thread, runs_in_hwd_thread) from electrum.i18n import _ from electrum.bitcoin import is_address, opcodes -from electrum.util import bfh, versiontuple, UserFacingException -from electrum.transaction import TxOutput, Transaction, PartialTransaction, PartialTxInput, PartialTxOutput +from electrum.util import versiontuple, UserFacingException +from electrum.transaction import TxOutput, PartialTransaction from electrum.bip32 import BIP32Node from electrum.storage import get_derivation_used_for_hw_device_encryption from electrum.keystore import Xpub, Hardware_KeyStore if TYPE_CHECKING: import threading + from electrum.plugin import DeviceInfo from electrum.wallet import Abstract_Wallet - from electrum.base_wizard import BaseWizard -class HW_PluginBase(BasePlugin): +class HW_PluginBase(BasePlugin, ABC): keystore_class: Type['Hardware_KeyStore'] libraries_available: bool + SUPPORTED_XTYPES = () # define supported library versions: minimum_library <= x < maximum_library minimum_library = (0,) @@ -90,25 +90,6 @@ def close_wallet(self, wallet: 'Abstract_Wallet'): if keystore.thread: keystore.thread.stop() - def scan_and_create_client_for_device(self, *, device_id: str, wizard: 'BaseWizard') -> 'HardwareClientBase': - devmgr = self.device_manager() - client = wizard.run_task_without_blocking_gui( - task=partial(devmgr.client_by_id, device_id)) - if client is None: - raise UserFacingException(_('Failed to create a client for this device.') + '\n' + - _('Make sure it is in the correct state.')) - client.handler = self.create_handler(wizard) - return client - - def setup_device(self, device_info: DeviceInfo, wizard: 'BaseWizard', purpose) -> 'HardwareClientBase': - """Called when creating a new wallet or when using the device to decrypt - an existing wallet. Select the device to use. If the device is - uninitialized, go through the initialization process. - - Runs in GUI thread. - """ - raise NotImplementedError() - def get_client(self, keystore: 'Hardware_KeyStore', force_pair: bool = True, *, devices: Sequence['Device'] = None, allow_user_interaction: bool = True) -> Optional['HardwareClientBase']: @@ -192,11 +173,8 @@ def create_client(self, device: 'Device', handler: Optional['HardwareHandlerBase']) -> Optional['HardwareClientBase']: raise NotImplementedError() - def get_xpub(self, device_id: str, derivation: str, xtype, wizard: 'BaseWizard') -> str: - raise NotImplementedError() - def create_handler(self, window) -> 'HardwareHandlerBase': - # note: in Qt GUI, 'window' is either an ElectrumWindow or an InstallWizard + # note: in Qt GUI, 'window' is either an ElectrumWindow or an QENewWalletWizard raise NotImplementedError() def can_recognize_device(self, device: Device) -> bool: @@ -205,9 +183,14 @@ def can_recognize_device(self, device: Device) -> bool: """ return device.product_key in self.DEVICE_IDS + @abstractmethod + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet: bool) -> str: + """Return view name for device + """ + pass -class HardwareClientBase: +class HardwareClientBase(ABC): handler = None # type: Optional['HardwareHandlerBase'] def __init__(self, *, plugin: 'HW_PluginBase'): @@ -217,18 +200,21 @@ def __init__(self, *, plugin: 'HW_PluginBase'): def device_manager(self) -> 'DeviceMgr': return self.plugin.device_manager() + @abstractmethod def is_pairable(self) -> bool: - raise NotImplementedError() + pass + @abstractmethod def close(self): - raise NotImplementedError() + pass - def timeout(self, cutoff) -> None: + def timeout(self, cutoff) -> None: # noqa: B027 pass + @abstractmethod def is_initialized(self) -> bool: """True if initialized, False if wiped.""" - raise NotImplementedError() + pass def label(self) -> Optional[str]: """The name given by the user to the device. @@ -251,11 +237,13 @@ def get_soft_device_id(self) -> Optional[str]: root_fp = self.request_root_fingerprint_from_device() return root_fp + @abstractmethod def has_usable_connection_with_device(self) -> bool: - raise NotImplementedError() + pass + @abstractmethod def get_xpub(self, bip32_path: str, xtype) -> str: - raise NotImplementedError() + pass @runs_in_hwd_thread def request_root_fingerprint_from_device(self) -> str: @@ -276,15 +264,9 @@ def get_password_for_storage_encryption(self) -> str: def device_model_name(self) -> Optional[str]: """Return the name of the model of this device, which might be displayed in the UI. E.g. for Trezor, "Trezor One" or "Trezor T". + If this method is not defined for a plugin, the plugin name is used as default """ - return None - - def manipulate_keystore_dict_during_wizard_setup(self, d: dict) -> None: - """Called during wallet creation in the wizard, before the keystore - is constructed for the first time. 'd' is the dict that will be - passed to the keystore constructor. - """ - pass + return self.plugin.name class HardwareHandlerBase: @@ -379,3 +361,9 @@ def text_ignore_old_fw_and_continue(self) -> str: return str(self) + "\n\n" + suffix else: return suffix + + +class OperationCancelled(UserFacingException): + """Emitted when an operation is cancelled by user on a HW device + """ + pass diff --git a/electrum/plugins/hw_wallet/qt.py b/electrum/plugins/hw_wallet/qt.py index c181900fe3bf..aedd16d6197b 100644 --- a/electrum/plugins/hw_wallet/qt.py +++ b/electrum/plugins/hw_wallet/qt.py @@ -26,7 +26,7 @@ import threading from functools import partial -from typing import TYPE_CHECKING, Union, Optional, Callable, Any +from typing import TYPE_CHECKING, Union, Optional from PyQt5.QtCore import QObject, pyqtSignal, Qt from PyQt5.QtWidgets import QVBoxLayout, QLineEdit, QHBoxLayout, QLabel @@ -35,13 +35,11 @@ from electrum.gui.qt.util import (read_QIcon, WWLabel, OkButton, WindowModalDialog, Buttons, CancelButton, TaskThread, char_width_in_lineedit, PasswordLineEdit) -from electrum.gui.qt.main_window import StatusBarButton, ElectrumWindow -from electrum.gui.qt.installwizard import InstallWizard +from electrum.gui.qt.main_window import StatusBarButton from electrum.i18n import _ from electrum.logging import Logger from electrum.util import UserCancelled, UserFacingException -from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from electrum.plugin import hook, DeviceUnpairableError from .plugin import OutdatedHwFirmwareException, HW_PluginBase, HardwareHandlerBase @@ -49,6 +47,8 @@ if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet from electrum.keystore import Hardware_KeyStore + from electrum.gui.qt import ElectrumWindow + from electrum.gui.qt.wizard.wallet import QENewWalletWizard # The trickiest thing about this handler was getting windows properly @@ -66,7 +66,7 @@ class QtHandlerBase(HardwareHandlerBase, QObject, Logger): yes_no_signal = pyqtSignal(object) status_signal = pyqtSignal(object) - def __init__(self, win: Union[ElectrumWindow, InstallWizard], device: str): + def __init__(self, win: Union['ElectrumWindow', 'QENewWalletWizard'], device: str): QObject.__init__(self) Logger.__init__(self) assert win.gui_thread == threading.current_thread(), 'must be called from GUI thread' @@ -209,7 +209,7 @@ def win_yes_no_question(self, msg): class QtPluginBase(object): @hook - def load_wallet(self: Union['QtPluginBase', HW_PluginBase], wallet: 'Abstract_Wallet', window: ElectrumWindow): + def load_wallet(self: Union['QtPluginBase', HW_PluginBase], wallet: 'Abstract_Wallet', window: 'ElectrumWindow'): relevant_keystores = [keystore for keystore in wallet.get_keystores() if isinstance(keystore, self.keystore_class)] if not relevant_keystores: @@ -237,14 +237,14 @@ def load_wallet(self: Union['QtPluginBase', HW_PluginBase], wallet: 'Abstract_Wa some_keystore = relevant_keystores[0] some_keystore.thread.add(trigger_pairings) - def _on_status_bar_button_click(self, *, window: ElectrumWindow, keystore: 'Hardware_KeyStore'): + def _on_status_bar_button_click(self, *, window: 'ElectrumWindow', keystore: 'Hardware_KeyStore'): try: self.show_settings_dialog(window=window, keystore=keystore) except (UserFacingException, UserCancelled) as e: exc_info = (type(e), e, e.__traceback__) self.on_task_thread_error(window=window, keystore=keystore, exc_info=exc_info) - def on_task_thread_error(self: Union['QtPluginBase', HW_PluginBase], window: ElectrumWindow, + def on_task_thread_error(self: Union['QtPluginBase', HW_PluginBase], window: 'ElectrumWindow', keystore: 'Hardware_KeyStore', exc_info): e = exc_info[1] if isinstance(e, OutdatedHwFirmwareException): @@ -261,7 +261,7 @@ def re_pair_device(): else: window.on_error(exc_info) - def choose_device(self: Union['QtPluginBase', HW_PluginBase], window: ElectrumWindow, + def choose_device(self: Union['QtPluginBase', HW_PluginBase], window: 'ElectrumWindow', keystore: 'Hardware_KeyStore') -> Optional[str]: '''This dialog box should be usable even if the user has forgotten their PIN or it is in bootloader mode.''' @@ -275,7 +275,7 @@ def choose_device(self: Union['QtPluginBase', HW_PluginBase], window: ElectrumWi device_id = info.device.id_ return device_id - def show_settings_dialog(self, window: ElectrumWindow, keystore: 'Hardware_KeyStore') -> None: + def show_settings_dialog(self, window: 'ElectrumWindow', keystore: 'Hardware_KeyStore') -> None: # default implementation (if no dialog): just try to connect to device def connect(): device_id = self.choose_device(window, keystore) @@ -283,7 +283,7 @@ def connect(): def add_show_address_on_hw_device_button_for_receive_addr(self, wallet: 'Abstract_Wallet', keystore: 'Hardware_KeyStore', - main_window: ElectrumWindow): + main_window: 'ElectrumWindow'): plugin = keystore.plugin receive_tab = main_window.receive_tab @@ -293,5 +293,5 @@ def show_address(): dev_name = f"{plugin.device} ({keystore.label})" receive_tab.toolbar_menu.addAction(read_QIcon("eye1.png"), _("Show address on {}").format(dev_name), show_address) - def create_handler(self, window: Union[ElectrumWindow, InstallWizard]) -> 'QtHandlerBase': + def create_handler(self, window: Union['ElectrumWindow', 'QENewWalletWizard']) -> 'QtHandlerBase': raise NotImplementedError() diff --git a/electrum/plugins/jade/jade.py b/electrum/plugins/jade/jade.py index c29004109b60..3d7d04220aef 100644 --- a/electrum/plugins/jade/jade.py +++ b/electrum/plugins/jade/jade.py @@ -1,7 +1,7 @@ import os import base64 import json -from typing import Optional +from typing import Optional, TYPE_CHECKING from electrum import bip32, constants from electrum.crypto import sha256 @@ -10,14 +10,16 @@ from electrum.transaction import Transaction from electrum.wallet import Multisig_Wallet from electrum.util import UserFacingException -from electrum.base_wizard import ScriptTypeNotSupported from electrum.logging import get_logger from electrum.plugin import runs_in_hwd_thread, Device from electrum.network import Network -from ..hw_wallet import HW_PluginBase, HardwareClientBase -from ..hw_wallet.plugin import OutdatedHwFirmwareException +from electrum.plugins.hw_wallet import HW_PluginBase, HardwareClientBase +from electrum.plugins.hw_wallet.plugin import OutdatedHwFirmwareException +if TYPE_CHECKING: + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard _logger = get_logger(__name__) @@ -436,22 +438,6 @@ def create_client(self, device, handler): return client - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - - # Call authenticate on hww to ensure unlocked and suitable for network - # May involve user entering PIN on (or even setting up!) hardware device - wizard.run_task_without_blocking_gui(task=lambda: client.authenticate()) - return client - - def get_xpub(self, device_id, derivation, xtype, wizard): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - xpub = client.get_xpub(derivation, xtype) - return xpub - def show_address(self, wallet, address, keystore=None): if keystore is None: keystore = wallet.get_keystore() @@ -476,3 +462,27 @@ def show_address(self, wallet, address, keystore=None): if hw_address != address: keystore.handler.show_error(_('The address generated by {} does not match!').format(self.device)) + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: + return 'jade_start' if device_info.initialized else 'jade_not_initialized' + else: + return 'jade_unlock' + + # insert jade pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'jade_start': { + 'next': 'jade_xpub', + }, + 'jade_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'jade_not_initialized': {}, + 'jade_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) diff --git a/electrum/plugins/jade/qt.py b/electrum/plugins/jade/qt.py index ee135f62d3b0..96a5c92d7906 100644 --- a/electrum/plugins/jade/qt.py +++ b/electrum/plugins/jade/qt.py @@ -1,16 +1,20 @@ from functools import partial +from typing import TYPE_CHECKING from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QLabel, QVBoxLayout from electrum.i18n import _ from electrum.plugin import hook from electrum.wallet import Standard_Wallet -from electrum.gui.qt.util import WindowModalDialog + +from electrum.plugins.hw_wallet.qt import QtHandlerBase, QtPluginBase +from electrum.plugins.hw_wallet import plugin +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWUnlock, WCHWXPub, WCHWUninitialized from .jade import JadePlugin -from ..hw_wallet.qt import QtHandlerBase, QtPluginBase -from ..hw_wallet.plugin import only_hook_if_libraries_available + +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard class Plugin(JadePlugin, QtPluginBase): @@ -20,7 +24,7 @@ class Plugin(JadePlugin, QtPluginBase): def create_handler(self, window): return Jade_Handler(window) - @only_hook_if_libraries_available + @plugin.only_hook_if_libraries_available @hook def receive_menu(self, menu, addrs, wallet): if type(wallet) is not Standard_Wallet: @@ -31,6 +35,22 @@ def show_address(): keystore.thread.add(partial(self.show_address, wallet, addrs[0])) menu.addAction(_("Show on Jade"), show_address) + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert jade pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'jade_start': {'gui': WCScriptAndDerivation}, + 'jade_xpub': {'gui': WCHWXPub}, + 'jade_not_initialized': {'gui': WCHWUninitialized}, + 'jade_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + + class Jade_Handler(QtHandlerBase): setup_signal = pyqtSignal() auth_signal = pyqtSignal(object, object) @@ -39,3 +59,4 @@ class Jade_Handler(QtHandlerBase): def __init__(self, win): super(Jade_Handler, self).__init__(win, 'Jade') + diff --git a/electrum/plugins/keepkey/keepkey.py b/electrum/plugins/keepkey/keepkey.py index aa09aeb979fb..6771786a91c4 100644 --- a/electrum/plugins/keepkey/keepkey.py +++ b/electrum/plugins/keepkey/keepkey.py @@ -1,17 +1,13 @@ -from binascii import hexlify, unhexlify -import traceback -import sys -from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING, Sequence +from typing import Optional, TYPE_CHECKING, Sequence -from electrum.util import bfh, UserCancelled, UserFacingException +from electrum.util import UserFacingException from electrum.bip32 import BIP32Node from electrum import descriptor from electrum import constants from electrum.i18n import _ -from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, Sighash +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, Sighash from electrum.keystore import Hardware_KeyStore from electrum.plugin import Device, runs_in_hwd_thread -from electrum.base_wizard import ScriptTypeNotSupported from ..hw_wallet import HW_PluginBase from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data @@ -19,6 +15,8 @@ if TYPE_CHECKING: import usb1 from .client import KeepKeyClient + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard # TREZOR initialization methods @@ -198,52 +196,8 @@ def get_client(self, keystore, force_pair=True, *, def get_coin_name(self): return "Testnet" if constants.net.TESTNET else "Bitcoin" - def initialize_device(self, device_id, wizard, handler): - # Initialization method - msg = _("Choose how you want to initialize your {}.\n\n" - "The first two methods are secure as no secret information " - "is entered into your computer.\n\n" - "For the last two methods you input secrets on your keyboard " - "and upload them to your {}, and so you should " - "only do those on a computer you know to be trustworthy " - "and free of malware." - ).format(self.device, self.device) - choices = [ - # Must be short as QT doesn't word-wrap radio button text - (TIM_NEW, _("Let the device generate a completely new seed randomly")), - (TIM_RECOVER, _("Recover from a seed you have previously written down")), - (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), - (TIM_PRIVKEY, _("Upload a master private key")) - ] - def f(method): - import threading - settings = self.request_trezor_init_settings(wizard, method, self.device) - t = threading.Thread(target=self._initialize_device_safe, args=(settings, method, device_id, wizard, handler)) - t.daemon = True - t.start() - exit_code = wizard.loop.exec_() - if exit_code != 0: - # this method (initialize_device) was called with the expectation - # of leaving the device in an initialized state when finishing. - # signal that this is not the case: - raise UserCancelled() - wizard.choice_dialog(title=_('Initialize Device'), message=msg, choices=choices, run_next=f) - - def _initialize_device_safe(self, settings, method, device_id, wizard, handler): - exit_code = 0 - try: - self._initialize_device(settings, method, device_id, wizard, handler) - except UserCancelled: - exit_code = 1 - except BaseException as e: - self.logger.exception('') - handler.show_error(repr(e)) - exit_code = 1 - finally: - wizard.loop.exit(exit_code) - @runs_in_hwd_thread - def _initialize_device(self, settings, method, device_id, wizard, handler): + def _initialize_device(self, settings, method, device_id, handler): item, label, pin_protection, passphrase_protection = settings language = 'english' @@ -282,24 +236,6 @@ def _make_node_path(self, xpub: str, address_n: Sequence[int]): ) return self.types.HDNodePathType(node=node, address_n=address_n) - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - if not device_info.initialized: - self.initialize_device(device_id, wizard, client.handler) - wizard.run_task_without_blocking_gui( - task=lambda: client.get_xpub("m", 'standard')) - client.used() - return client - - def get_xpub(self, device_id, derivation, xtype, wizard): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - xpub = client.get_xpub(derivation, xtype) - client.used() - return xpub - def get_keepkey_input_script_type(self, electrum_txin_type: str): if electrum_txin_type in ('p2wpkh', 'p2wsh'): return self.types.SPENDWITNESS @@ -488,3 +424,35 @@ def electrum_tx_to_txtype(self, tx: Optional[Transaction]): def get_tx(self, tx_hash): tx = self.prev_tx[tx_hash] return self.electrum_tx_to_txtype(tx) + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: + return 'keepkey_start' if device_info.initialized else 'keepkey_not_initialized' + else: + return 'keepkey_unlock' + + # insert keepkey pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'keepkey_start': { + 'next': 'keepkey_xpub', + }, + 'keepkey_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'keepkey_not_initialized': { + 'next': 'keepkey_choose_new_recover', + }, + 'keepkey_choose_new_recover': { + 'next': 'keepkey_do_init', + }, + 'keepkey_do_init': { + 'next': 'keepkey_start', + }, + 'keepkey_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) diff --git a/electrum/plugins/keepkey/qt.py b/electrum/plugins/keepkey/qt.py index 7ea74b372bc3..efeefdc13d03 100644 --- a/electrum/plugins/keepkey/qt.py +++ b/electrum/plugins/keepkey/qt.py @@ -1,22 +1,29 @@ -from functools import partial import threading +from functools import partial +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, QEventLoop, pyqtSignal, QRegExp from PyQt5.QtGui import QRegExpValidator from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton, QHBoxLayout, QButtonGroup, QGroupBox, QDialog, QTextEdit, QLineEdit, QRadioButton, QCheckBox, QWidget, - QMessageBox, QFileDialog, QSlider, QTabWidget) + QMessageBox, QSlider, QTabWidget) from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton, - OkButton, CloseButton) + OkButton, CloseButton, ChoiceWidget) from electrum.i18n import _ from electrum.plugin import hook +from electrum.logging import Logger from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available -from .keepkey import KeepKeyPlugin, TIM_NEW, TIM_RECOVER, TIM_MNEMONIC +from .keepkey import KeepKeyPlugin, TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY + +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWUnlock, WCHWXPub +from electrum.gui.qt.wizard.wizard import WizardComponent +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard PASSPHRASE_HELP_SHORT =_( "Passphrases allow you to access new wallets, each " @@ -43,6 +50,7 @@ "Press ENTER or the Seed Entered button once the last word in your " "seed is auto-completed.") + class CharacterButton(QPushButton): def __init__(self, text=None): QPushButton.__init__(self, text) @@ -134,7 +142,6 @@ def get_char(self, word_pos, character_pos): class QtHandler(QtHandlerBase): - char_signal = pyqtSignal(object) pin_signal = pyqtSignal(object, object) close_char_dialog_signal = pyqtSignal() @@ -188,7 +195,6 @@ def update_character_dialog(self, msg): self.done.set() - class QtPlugin(QtPluginBase): # Derived classes must provide the following class-static variables: # icon_file @@ -215,87 +221,93 @@ def show_dialog(device_id): SettingsDialog(window, self, keystore, device_id).exec_() keystore.thread.add(connect, on_success=show_dialog) - def request_trezor_init_settings(self, wizard, method, device): - vbox = QVBoxLayout() - next_enabled = True + +def clean_text(widget): + text = widget.toPlainText().strip() + return ' '.join(text.split()) + + +class KeepkeyInitLayout(QVBoxLayout): + validChanged = pyqtSignal([bool], arguments=['valid']) + + def __init__(self, method, device): + QVBoxLayout.__init__(self) + self.method = method + label = QLabel(_("Enter a label to name your device:")) - name = QLineEdit() + self.label_e = QLineEdit() hl = QHBoxLayout() hl.addWidget(label) - hl.addWidget(name) + hl.addWidget(self.label_e) hl.addStretch(1) - vbox.addLayout(hl) + self.addLayout(hl) - def clean_text(widget): - text = widget.toPlainText().strip() - return ' '.join(text.split()) - - if method in [TIM_NEW, TIM_RECOVER]: + if self.method in [TIM_NEW, TIM_RECOVER]: gb = QGroupBox() hbox1 = QHBoxLayout() gb.setLayout(hbox1) # KeepKey recovery doesn't need a word count - if method == TIM_NEW: - vbox.addWidget(gb) + if self.method == TIM_NEW: + self.addWidget(gb) gb.setTitle(_("Select your seed length:")) - bg = QButtonGroup() + self.bg = QButtonGroup() for i, count in enumerate([12, 18, 24]): rb = QRadioButton(gb) rb.setText(_("{} words").format(count)) - bg.addButton(rb) - bg.setId(rb, i) + self.bg.addButton(rb) + self.bg.setId(rb, i) hbox1.addWidget(rb) rb.setChecked(True) - cb_pin = QCheckBox(_('Enable PIN protection')) - cb_pin.setChecked(True) + self.cb_pin = QCheckBox(_('Enable PIN protection')) + self.cb_pin.setChecked(True) else: - text = QTextEdit() - text.setMaximumHeight(60) + self.text_e = QTextEdit() + self.text_e.setMaximumHeight(60) if method == TIM_MNEMONIC: msg = _("Enter your BIP39 mnemonic:") + # TODO: validation? else: msg = _("Enter the master private key beginning with xprv:") + def set_enabled(): from electrum.bip32 import is_xprv - wizard.next_button.setEnabled(is_xprv(clean_text(text))) - text.textChanged.connect(set_enabled) - next_enabled = False - - vbox.addWidget(QLabel(msg)) - vbox.addWidget(text) - pin = QLineEdit() - pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}'))) - pin.setMaximumWidth(100) + self.validChanged.emit(is_xprv(clean_text(self.text_e))) + self.text_e.textChanged.connect(set_enabled) + + self.addWidget(QLabel(msg)) + self.addWidget(self.text_e) + self.pin = QLineEdit() + self.pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}'))) + self.pin.setMaximumWidth(100) hbox_pin = QHBoxLayout() hbox_pin.addWidget(QLabel(_("Enter your PIN (digits 1-9):"))) - hbox_pin.addWidget(pin) + hbox_pin.addWidget(self.pin) hbox_pin.addStretch(1) if method in [TIM_NEW, TIM_RECOVER]: - vbox.addWidget(WWLabel(RECOMMEND_PIN)) - vbox.addWidget(cb_pin) + self.addWidget(WWLabel(RECOMMEND_PIN)) + self.addWidget(self.cb_pin) else: - vbox.addLayout(hbox_pin) + self.addLayout(hbox_pin) passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT) passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) passphrase_warning.setStyleSheet("color: red") - cb_phrase = QCheckBox(_('Enable passphrases')) - cb_phrase.setChecked(False) - vbox.addWidget(passphrase_msg) - vbox.addWidget(passphrase_warning) - vbox.addWidget(cb_phrase) - - wizard.exec_layout(vbox, next_enabled=next_enabled) - - if method in [TIM_NEW, TIM_RECOVER]: - item = bg.checkedId() - pin = cb_pin.isChecked() + self.cb_phrase = QCheckBox(_('Enable passphrases')) + self.cb_phrase.setChecked(False) + self.addWidget(passphrase_msg) + self.addWidget(passphrase_warning) + self.addWidget(self.cb_phrase) + + def get_settings(self): + if self.method in [TIM_NEW, TIM_RECOVER]: + item = self.bg.checkedId() + pin = self.cb_pin.isChecked() else: - item = ' '.join(str(clean_text(text)).split()) - pin = str(pin.text()) + item = ' '.join(str(clean_text(self.text_e)).split()) + pin = str(self.pin.text()) - return (item, name.text(), pin, cb_phrase.isChecked()) + return item, self.label_e.text(), pin, self.cb_phrase.isChecked() class Plugin(KeepKeyPlugin, QtPlugin): @@ -310,6 +322,23 @@ def pin_matrix_widget_class(self): from keepkeylib.qt.pinmatrix import PinMatrixWidget return PinMatrixWidget + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert keepkey pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'keepkey_start': {'gui': WCScriptAndDerivation}, + 'keepkey_xpub': {'gui': WCHWXPub}, + 'keepkey_not_initialized': {'gui': WCKeepkeyInitMethod}, + 'keepkey_choose_new_recover': {'gui': WCKeepkeyInitParams}, + 'keepkey_do_init': {'gui': WCKeepkeyInit}, + 'keepkey_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + class SettingsDialog(WindowModalDialog): '''This dialog doesn't require a device be paired with a wallet. @@ -571,3 +600,98 @@ def slider_released(): # Update information invoke_client(None) + + +class WCKeepkeyInitMethod(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('HW Setup')) + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + msg = _("Choose how you want to initialize your {}.\n\n" + "The first two methods are secure as no secret information " + "is entered into your computer.\n\n" + "For the last two methods you input secrets on your keyboard " + "and upload them to your {}, and so you should " + "only do those on a computer you know to be trustworthy " + "and free of malware." + ).format(_info.model_name, _info.model_name) + choices = [ + # Must be short as QT doesn't word-wrap radio button text + (TIM_NEW, _("Let the device generate a completely new seed randomly")), + (TIM_RECOVER, _("Recover from a seed you have previously written down")), + (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), + (TIM_PRIVKEY, _("Upload a master private key")) + ] + self.choice_w = ChoiceWidget(message=msg, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['keepkey_init'] = self.choice_w.selected_item[0] + + +class WCKeepkeyInitParams(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up keepkey')) + self.plugins = wizard.plugins + self._busy = True + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + self.settings_layout = KeepkeyInitLayout(self.wizard_data['keepkey_init'], _info.device.id_) + self.settings_layout.validChanged.connect(self.on_settings_valid_changed) + self.layout().addLayout(self.settings_layout) + self.layout().addStretch(1) + + self.valid = self.wizard_data['keepkey_init'] != TIM_PRIVKEY # TODO: only privkey is validated + self.busy = False + + def on_settings_valid_changed(self, is_valid: bool): + self.valid = is_valid + + def apply(self): + self.wizard_data['keepkey_settings'] = self.settings_layout.get_settings() + + +class WCKeepkeyInit(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up Keepkey')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('keepkey') + + self.layout().addWidget(WWLabel('Done')) + + self._busy = True + + def on_ready(self): + settings = self.wizard_data['keepkey_settings'] + method = self.wizard_data['keepkey_init'] + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def initialize_device_task(settings, method, device_id, handler): + try: + self.plugin._initialize_device(settings, method, device_id, handler) + self.logger.info('Done initialize device') + self.valid = True + self.wizard.requestNext.emit() # triggers Next GUI thread from event loop + except Exception as e: + self.valid = False + self.error = repr(e) + finally: + self.busy = False + + t = threading.Thread( + target=initialize_device_task, + args=(settings, method, device_id, client.handler), + daemon=True) + t.start() + + def apply(self): + pass diff --git a/electrum/plugins/labels/qml.py b/electrum/plugins/labels/qml.py index 4ad17263fa79..ffd970fdb3c8 100644 --- a/electrum/plugins/labels/qml.py +++ b/electrum/plugins/labels/qml.py @@ -6,7 +6,7 @@ from electrum.plugin import hook from electrum.gui.qml.qewallet import QEWallet -from electrum.gui.qml.plugins import PluginQObject +from electrum.gui.common_qt.plugins import PluginQObject from .labels import LabelsPlugin diff --git a/electrum/plugins/ledger/ledger.py b/electrum/plugins/ledger/ledger.py index 357a2017f9d9..fc042fdc8cfa 100644 --- a/electrum/plugins/ledger/ledger.py +++ b/electrum/plugins/ledger/ledger.py @@ -4,12 +4,10 @@ from abc import ABC, abstractmethod import base64 import hashlib -from typing import Dict, List, Optional, Sequence, Tuple - +from typing import Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING from electrum import bip32, constants, ecc from electrum import descriptor -from electrum.base_wizard import ScriptTypeNotSupported from electrum.bip32 import BIP32Node, convert_bip32_intpath_to_strpath, normalize_bip32_derivation from electrum.bitcoin import EncodeBase58Check, int_to_hex, is_b58_address, is_segwit_script_type, var_int from electrum.crypto import hash_160 @@ -24,6 +22,9 @@ from ..hw_wallet import HardwareClientBase, HW_PluginBase from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, validate_op_return_output, LibraryFoundButUnusable +if TYPE_CHECKING: + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard _logger = get_logger(__name__) @@ -1439,19 +1440,6 @@ def create_client(self, device, handler) -> Optional[Ledger_Client]: self.logger.info(f"cannot connect at {device.path} {e}", exc_info=e) return None - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client: Ledger_Client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - wizard.run_task_without_blocking_gui( - task=lambda: client.get_master_fingerprint()) - return client - - def get_xpub(self, device_id, derivation, xtype, wizard): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - return client.get_xpub(derivation, xtype) - @runs_in_hwd_thread def show_address(self, wallet, address, keystore=None): if keystore is None: @@ -1465,3 +1453,28 @@ def show_address(self, wallet, address, keystore=None): txin_type = wallet.get_txin_type(address) keystore.show_address(sequence, txin_type) + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: + return 'ledger_start' if device_info.initialized else 'ledger_not_initialized' + else: + return 'ledger_unlock' + + # insert ledger pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'ledger_start': { + 'next': 'ledger_xpub', + }, + 'ledger_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'ledger_not_initialized': {}, + 'ledger_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) + diff --git a/electrum/plugins/ledger/qt.py b/electrum/plugins/ledger/qt.py index 7ab98f3a3a91..b1014f77c0c3 100644 --- a/electrum/plugins/ledger/qt.py +++ b/electrum/plugins/ledger/qt.py @@ -1,16 +1,20 @@ from functools import partial +from typing import TYPE_CHECKING from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QInputDialog, QLabel, QVBoxLayout, QLineEdit +from PyQt5.QtWidgets import QInputDialog, QLineEdit from electrum.i18n import _ from electrum.plugin import hook from electrum.wallet import Standard_Wallet -from electrum.gui.qt.util import WindowModalDialog from .ledger import LedgerPlugin, Ledger_Client from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWUninitialized, WCHWUnlock, WCHWXPub + +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard class Plugin(LedgerPlugin, QtPluginBase): @@ -31,6 +35,22 @@ def show_address(): keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore=keystore)) menu.addAction(_("Show on Ledger"), show_address) + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert ledger pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'ledger_start': {'gui': WCScriptAndDerivation}, + 'ledger_xpub': {'gui': WCHWXPub}, + 'ledger_not_initialized': {'gui': WCHWUninitialized}, + 'ledger_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + + class Ledger_Handler(QtHandlerBase): setup_signal = pyqtSignal() auth_signal = pyqtSignal(object, object) diff --git a/electrum/plugins/safe_t/clientbase.py b/electrum/plugins/safe_t/clientbase.py index 9ff5361d343d..df03442e3ffe 100644 --- a/electrum/plugins/safe_t/clientbase.py +++ b/electrum/plugins/safe_t/clientbase.py @@ -117,6 +117,9 @@ def __init__(self, handler, plugin, proto): Logger.__init__(self) self.used() + def device_model_name(self) -> Optional[str]: + return 'Safe-T' + def __str__(self): return "%s/%s" % (self.label(), self.features.device_id) diff --git a/electrum/plugins/safe_t/qt.py b/electrum/plugins/safe_t/qt.py index 07d36ffd16da..1c29f234de7e 100644 --- a/electrum/plugins/safe_t/qt.py +++ b/electrum/plugins/safe_t/qt.py @@ -1,5 +1,6 @@ -from functools import partial import threading +from functools import partial +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, pyqtSignal, QRegExp from PyQt5.QtGui import QRegExpValidator @@ -9,14 +10,20 @@ QMessageBox, QFileDialog, QSlider, QTabWidget) from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton, - OkButton, CloseButton, getOpenFileName) + OkButton, CloseButton, getOpenFileName, ChoiceWidget) from electrum.i18n import _ from electrum.plugin import hook +from electrum.logging import Logger from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available -from .safe_t import SafeTPlugin, TIM_NEW, TIM_RECOVER, TIM_MNEMONIC +from .safe_t import SafeTPlugin, TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY + +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWUnlock, WCHWXPub +from electrum.gui.qt.wizard.wizard import WizardComponent +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard PASSPHRASE_HELP_SHORT =_( "Passphrases allow you to access new wallets, each " @@ -91,85 +98,92 @@ def show_dialog(device_id): SettingsDialog(window, self, keystore, device_id).exec_() keystore.thread.add(connect, on_success=show_dialog) - def request_safe_t_init_settings(self, wizard, method, device): - vbox = QVBoxLayout() - next_enabled = True + +def clean_text(widget): + text = widget.toPlainText().strip() + return ' '.join(text.split()) + + +class SafeTInitLayout(QVBoxLayout): + validChanged = pyqtSignal([bool], arguments=['valid']) + + def __init__(self, method, device): + super().__init__() + + self.method = method + label = QLabel(_("Enter a label to name your device:")) - name = QLineEdit() + self.label_e = QLineEdit() hl = QHBoxLayout() hl.addWidget(label) - hl.addWidget(name) + hl.addWidget(self.label_e) hl.addStretch(1) - vbox.addLayout(hl) - - def clean_text(widget): - text = widget.toPlainText().strip() - return ' '.join(text.split()) + self.addLayout(hl) if method in [TIM_NEW, TIM_RECOVER]: gb = QGroupBox() hbox1 = QHBoxLayout() gb.setLayout(hbox1) - vbox.addWidget(gb) + self.addWidget(gb) gb.setTitle(_("Select your seed length:")) - bg = QButtonGroup() + self.bg = QButtonGroup() for i, count in enumerate([12, 18, 24]): rb = QRadioButton(gb) rb.setText(_("{:d} words").format(count)) - bg.addButton(rb) - bg.setId(rb, i) + self.bg.addButton(rb) + self.bg.setId(rb, i) hbox1.addWidget(rb) rb.setChecked(True) - cb_pin = QCheckBox(_('Enable PIN protection')) - cb_pin.setChecked(True) + self.cb_pin = QCheckBox(_('Enable PIN protection')) + self.cb_pin.setChecked(True) else: - text = QTextEdit() - text.setMaximumHeight(60) + self.text_e = QTextEdit() + self.text_e.setMaximumHeight(60) if method == TIM_MNEMONIC: msg = _("Enter your BIP39 mnemonic:") + # TODO: no validation? else: msg = _("Enter the master private key beginning with xprv:") + def set_enabled(): from electrum.bip32 import is_xprv - wizard.next_button.setEnabled(is_xprv(clean_text(text))) - text.textChanged.connect(set_enabled) - next_enabled = False - - vbox.addWidget(QLabel(msg)) - vbox.addWidget(text) - pin = QLineEdit() - pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}'))) - pin.setMaximumWidth(100) + self.validChanged.emit(is_xprv(clean_text(self.text_e))) + self.text_e.textChanged.connect(set_enabled) + + self.addWidget(QLabel(msg)) + self.addWidget(self.text_e) + self.pin = QLineEdit() + self.pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}'))) + self.pin.setMaximumWidth(100) hbox_pin = QHBoxLayout() hbox_pin.addWidget(QLabel(_("Enter your PIN (digits 1-9):"))) - hbox_pin.addWidget(pin) + hbox_pin.addWidget(self.pin) hbox_pin.addStretch(1) if method in [TIM_NEW, TIM_RECOVER]: - vbox.addWidget(WWLabel(RECOMMEND_PIN)) - vbox.addWidget(cb_pin) + self.addWidget(WWLabel(RECOMMEND_PIN)) + self.addWidget(self.cb_pin) else: - vbox.addLayout(hbox_pin) + self.addLayout(hbox_pin) passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT) passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) passphrase_warning.setStyleSheet("color: red") - cb_phrase = QCheckBox(_('Enable passphrases')) - cb_phrase.setChecked(False) - vbox.addWidget(passphrase_msg) - vbox.addWidget(passphrase_warning) - vbox.addWidget(cb_phrase) - - wizard.exec_layout(vbox, next_enabled=next_enabled) - - if method in [TIM_NEW, TIM_RECOVER]: - item = bg.checkedId() - pin = cb_pin.isChecked() + self.cb_phrase = QCheckBox(_('Enable passphrases')) + self.cb_phrase.setChecked(False) + self.addWidget(passphrase_msg) + self.addWidget(passphrase_warning) + self.addWidget(self.cb_phrase) + + def get_settings(self): + if self.method in [TIM_NEW, TIM_RECOVER]: + item = self.bg.checkedId() + pin = self.cb_pin.isChecked() else: - item = ' '.join(str(clean_text(text)).split()) - pin = str(pin.text()) + item = ' '.join(str(clean_text(self.text_e)).split()) + pin = str(self.pin.text()) - return (item, name.text(), pin, cb_phrase.isChecked()) + return item, self.label_e.text(), pin, self.cb_phrase.isChecked() class Plugin(SafeTPlugin, QtPlugin): @@ -184,6 +198,23 @@ def pin_matrix_widget_class(self): from safetlib.qt.pinmatrix import PinMatrixWidget return PinMatrixWidget + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert safe_t pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'safet_start': {'gui': WCScriptAndDerivation}, + 'safet_xpub': {'gui': WCHWXPub}, + 'safet_not_initialized': {'gui': WCSafeTInitMethod}, + 'safet_choose_new_recover': {'gui': WCSafeTInitParams}, + 'safet_do_init': {'gui': WCSafeTInit}, + 'safet_unlock': {'gui': WCHWUnlock} + } + wizard.navmap_merge(views) + class SettingsDialog(WindowModalDialog): '''This dialog doesn't require a device be paired with a wallet. @@ -501,3 +532,98 @@ def slider_released(): # Update information invoke_client(None) + + +class WCSafeTInitMethod(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('HW Setup')) + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + msg = _("Choose how you want to initialize your {}.\n\n" + "The first two methods are secure as no secret information " + "is entered into your computer.\n\n" + "For the last two methods you input secrets on your keyboard " + "and upload them to your {}, and so you should " + "only do those on a computer you know to be trustworthy " + "and free of malware." + ).format(_info.model_name, _info.model_name) + choices = [ + # Must be short as QT doesn't word-wrap radio button text + (TIM_NEW, _("Let the device generate a completely new seed randomly")), + (TIM_RECOVER, _("Recover from a seed you have previously written down")), + (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), + (TIM_PRIVKEY, _("Upload a master private key")) + ] + self.choice_w = ChoiceWidget(message=msg, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['safe_t_init'] = self.choice_w.selected_item[0] + + +class WCSafeTInitParams(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up safe-t')) + self.plugins = wizard.plugins + self._busy = True + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + self.settings_layout = SafeTInitLayout(self.wizard_data['safe_t_init'], _info.device.id_) + self.settings_layout.validChanged.connect(self.on_settings_valid_changed) + self.layout().addLayout(self.settings_layout) + self.layout().addStretch(1) + + self.valid = self.wizard_data['safe_t_init'] != TIM_PRIVKEY + self.busy = False + + def on_settings_valid_changed(self, is_valid: bool): + self.valid = is_valid + + def apply(self): + self.wizard_data['safe_t_settings'] = self.settings_layout.get_settings() + + +class WCSafeTInit(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up safe-t')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('safe_t') + + self.layout().addWidget(WWLabel('Done')) + + self._busy = True + + def on_ready(self): + settings = self.wizard_data['safe_t_settings'] + method = self.wizard_data['safe_t_init'] + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def initialize_device_task(settings, method, device_id, handler): + try: + self.plugin._initialize_device(settings, method, device_id, handler) + self.logger.info('Done initialize device') + self.valid = True + self.wizard.requestNext.emit() # triggers Next GUI thread from event loop + except Exception as e: + self.valid = False + self.error = repr(e) + finally: + self.busy = False + + t = threading.Thread( + target=initialize_device_task, + args=(settings, method, device_id, client.handler), + daemon=True) + t.start() + + def apply(self): + pass diff --git a/electrum/plugins/safe_t/safe_t.py b/electrum/plugins/safe_t/safe_t.py index 850c00588149..a7981b317fa6 100644 --- a/electrum/plugins/safe_t/safe_t.py +++ b/electrum/plugins/safe_t/safe_t.py @@ -1,23 +1,21 @@ -from binascii import hexlify, unhexlify -import traceback -import sys -from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING, Sequence +from typing import Optional, TYPE_CHECKING, Sequence -from electrum.util import bfh, versiontuple, UserCancelled, UserFacingException +from electrum.util import UserFacingException from electrum.bip32 import BIP32Node from electrum import descriptor from electrum import constants from electrum.i18n import _ from electrum.plugin import Device, runs_in_hwd_thread -from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, Sighash +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, Sighash from electrum.keystore import Hardware_KeyStore -from electrum.base_wizard import ScriptTypeNotSupported from ..hw_wallet import HW_PluginBase from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data if TYPE_CHECKING: from .client import SafeTClient + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard # Safe-T mini initialization methods TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY = range(0, 4) @@ -156,52 +154,8 @@ def get_client(self, keystore, force_pair=True, *, def get_coin_name(self): return "Testnet" if constants.net.TESTNET else "Bitcoin" - def initialize_device(self, device_id, wizard, handler): - # Initialization method - msg = _("Choose how you want to initialize your {}.\n\n" - "The first two methods are secure as no secret information " - "is entered into your computer.\n\n" - "For the last two methods you input secrets on your keyboard " - "and upload them to your {}, and so you should " - "only do those on a computer you know to be trustworthy " - "and free of malware." - ).format(self.device, self.device) - choices = [ - # Must be short as QT doesn't word-wrap radio button text - (TIM_NEW, _("Let the device generate a completely new seed randomly")), - (TIM_RECOVER, _("Recover from a seed you have previously written down")), - (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), - (TIM_PRIVKEY, _("Upload a master private key")) - ] - def f(method): - import threading - settings = self.request_safe_t_init_settings(wizard, method, self.device) - t = threading.Thread(target=self._initialize_device_safe, args=(settings, method, device_id, wizard, handler)) - t.daemon = True - t.start() - exit_code = wizard.loop.exec_() - if exit_code != 0: - # this method (initialize_device) was called with the expectation - # of leaving the device in an initialized state when finishing. - # signal that this is not the case: - raise UserCancelled() - wizard.choice_dialog(title=_('Initialize Device'), message=msg, choices=choices, run_next=f) - - def _initialize_device_safe(self, settings, method, device_id, wizard, handler): - exit_code = 0 - try: - self._initialize_device(settings, method, device_id, wizard, handler) - except UserCancelled: - exit_code = 1 - except BaseException as e: - self.logger.exception('') - handler.show_error(repr(e)) - exit_code = 1 - finally: - wizard.loop.exit(exit_code) - @runs_in_hwd_thread - def _initialize_device(self, settings, method, device_id, wizard, handler): + def _initialize_device(self, settings, method, device_id, handler): item, label, pin_protection, passphrase_protection = settings if method == TIM_RECOVER: @@ -252,24 +206,6 @@ def _make_node_path(self, xpub: str, address_n: Sequence[int]): ) return self.types.HDNodePathType(node=node, address_n=address_n) - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - if not device_info.initialized: - self.initialize_device(device_id, wizard, client.handler) - wizard.run_task_without_blocking_gui( - task=lambda: client.get_xpub("m", 'standard')) - client.used() - return client - - def get_xpub(self, device_id, derivation, xtype, wizard): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - xpub = client.get_xpub(derivation, xtype) - client.used() - return xpub - def get_safet_input_script_type(self, electrum_txin_type: str): if electrum_txin_type in ('p2wpkh', 'p2wsh'): return self.types.InputScriptType.SPENDWITNESS @@ -460,3 +396,35 @@ def electrum_tx_to_txtype(self, tx: Optional[Transaction]): def get_tx(self, tx_hash): tx = self.prev_tx[tx_hash] return self.electrum_tx_to_txtype(tx) + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: + return 'safet_start' if device_info.initialized else 'safet_not_initialized' + else: + return 'safet_unlock' + + # insert safe_t pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'safet_start': { + 'next': 'safet_xpub', + }, + 'safet_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'safet_not_initialized': { + 'next': 'safet_choose_new_recover', + }, + 'safet_choose_new_recover': { + 'next': 'safet_do_init', + }, + 'safet_do_init': { + 'next': 'safet_start', + }, + 'safet_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) diff --git a/electrum/plugins/trezor/qt.py b/electrum/plugins/trezor/qt.py index a555dc626717..9e68b59ec94d 100644 --- a/electrum/plugins/trezor/qt.py +++ b/electrum/plugins/trezor/qt.py @@ -1,24 +1,33 @@ from functools import partial import threading +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, QEventLoop, pyqtSignal from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton, QHBoxLayout, QButtonGroup, QGroupBox, QDialog, QLineEdit, QRadioButton, QCheckBox, QWidget, - QMessageBox, QFileDialog, QSlider, QTabWidget) + QMessageBox, QSlider, QTabWidget) -from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton, - OkButton, CloseButton, PasswordLineEdit, getOpenFileName) from electrum.i18n import _ +from electrum.logging import Logger from electrum.plugin import hook +from electrum.keystore import ScriptTypeNotSupported + +from electrum.plugins.hw_wallet.qt import QtHandlerBase, QtPluginBase +from electrum.plugins.hw_wallet.plugin import only_hook_if_libraries_available, OutdatedHwFirmwareException + +from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton, + OkButton, CloseButton, PasswordLineEdit, getOpenFileName, ChoiceWidget) +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWUnlock, WCHWXPub +from electrum.gui.qt.wizard.wizard import WizardComponent -from ..hw_wallet.qt import QtHandlerBase, QtPluginBase -from ..hw_wallet.plugin import only_hook_if_libraries_available from .trezor import (TrezorPlugin, TIM_NEW, TIM_RECOVER, TrezorInitSettings, PASSPHRASE_ON_DEVICE, Capability, BackupType, RecoveryDeviceType) +if TYPE_CHECKING: + from electrum.gui.qt.wizard.wallet import QENewWalletWizard -PASSPHRASE_HELP_SHORT =_( +PASSPHRASE_HELP_SHORT = _( "Passphrases allow you to access new wallets, each " "hidden behind a particular case-sensitive passphrase.") PASSPHRASE_HELP = PASSPHRASE_HELP_SHORT + " " + _( @@ -254,11 +263,11 @@ def show_dialog(device_id): SettingsDialog(window, self, keystore, device_id).exec_() keystore.thread.add(connect, on_success=show_dialog) - def request_trezor_init_settings(self, wizard, method, device_id): - vbox = QVBoxLayout() - next_enabled = True - devmgr = self.device_manager() +class InitSettingsLayout(QVBoxLayout): + def __init__(self, devmgr, method, device_id) -> QVBoxLayout: + super().__init__() + client = devmgr.client_by_id(device_id) if not client: raise Exception(_("The device was disconnected.")) @@ -269,40 +278,40 @@ def request_trezor_init_settings(self, wizard, method, device_id): # label label = QLabel(_("Enter a label to name your device:")) - name = QLineEdit() + self.name = QLineEdit() hl = QHBoxLayout() hl.addWidget(label) - hl.addWidget(name) + hl.addWidget(self.name) hl.addStretch(1) - vbox.addLayout(hl) + self.addLayout(hl) # Backup type gb_backuptype = QGroupBox() hbox_backuptype = QHBoxLayout() gb_backuptype.setLayout(hbox_backuptype) - vbox.addWidget(gb_backuptype) + self.addWidget(gb_backuptype) gb_backuptype.setTitle(_('Select backup type:')) - bg_backuptype = QButtonGroup() + self.bg_backuptype = QButtonGroup() rb_single = QRadioButton(gb_backuptype) rb_single.setText(_('Single seed (BIP39)')) - bg_backuptype.addButton(rb_single) - bg_backuptype.setId(rb_single, BackupType.Bip39) + self.bg_backuptype.addButton(rb_single) + self.bg_backuptype.setId(rb_single, BackupType.Bip39) hbox_backuptype.addWidget(rb_single) rb_single.setChecked(True) rb_shamir = QRadioButton(gb_backuptype) rb_shamir.setText(_('Shamir')) - bg_backuptype.addButton(rb_shamir) - bg_backuptype.setId(rb_shamir, BackupType.Slip39_Basic) + self.bg_backuptype.addButton(rb_shamir) + self.bg_backuptype.setId(rb_shamir, BackupType.Slip39_Basic) hbox_backuptype.addWidget(rb_shamir) rb_shamir.setEnabled(Capability.Shamir in capabilities) rb_shamir.setVisible(False) # visible with "expert settings" rb_shamir_groups = QRadioButton(gb_backuptype) rb_shamir_groups.setText(_('Super Shamir')) - bg_backuptype.addButton(rb_shamir_groups) - bg_backuptype.setId(rb_shamir_groups, BackupType.Slip39_Advanced) + self.bg_backuptype.addButton(rb_shamir_groups) + self.bg_backuptype.setId(rb_shamir_groups, BackupType.Slip39_Advanced) hbox_backuptype.addWidget(rb_shamir_groups) rb_shamir_groups.setEnabled(Capability.ShamirGroups in capabilities) rb_shamir_groups.setVisible(False) # visible with "expert settings" @@ -313,15 +322,15 @@ def request_trezor_init_settings(self, wizard, method, device_id): gb_numwords = QGroupBox() hbox1 = QHBoxLayout() gb_numwords.setLayout(hbox1) - vbox.addWidget(gb_numwords) + self.addWidget(gb_numwords) gb_numwords.setTitle(_("Select seed/share length:")) - bg_numwords = QButtonGroup() + self.bg_numwords = QButtonGroup() for count in (12, 18, 20, 24, 33): rb = QRadioButton(gb_numwords) word_count_buttons[count] = rb rb.setText(_("{:d} words").format(count)) - bg_numwords.addButton(rb) - bg_numwords.setId(rb, count) + self.bg_numwords.addButton(rb) + self.bg_numwords.setId(rb, count) hbox1.addWidget(rb) rb.setChecked(True) @@ -348,7 +357,7 @@ def configure_word_counts(): for c, btn in word_count_buttons.items(): btn.setVisible(c in valid_word_counts) - bg_backuptype.buttonClicked.connect(configure_word_counts) + self.bg_backuptype.buttonClicked.connect(configure_word_counts) configure_word_counts() # set up conditional visibility: @@ -359,10 +368,10 @@ def configure_word_counts(): gb_numwords.setVisible(False) # PIN - cb_pin = QCheckBox(_('Enable PIN protection')) - cb_pin.setChecked(True) - vbox.addWidget(WWLabel(RECOMMEND_PIN)) - vbox.addWidget(cb_pin) + self.cb_pin = QCheckBox(_('Enable PIN protection')) + self.cb_pin.setChecked(True) + self.addWidget(WWLabel(RECOMMEND_PIN)) + self.addWidget(self.cb_pin) # "expert settings" button expert_vbox = QVBoxLayout() @@ -376,65 +385,65 @@ def show_expert_settings(): rb_shamir.setVisible(True) rb_shamir_groups.setVisible(True) expert_button.clicked.connect(show_expert_settings) - vbox.addWidget(expert_button) + self.addWidget(expert_button) # passphrase passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT) passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) passphrase_warning.setStyleSheet("color: red") - cb_phrase = QCheckBox(_('Enable passphrases')) - cb_phrase.setChecked(False) + self.cb_phrase = QCheckBox(_('Enable passphrases')) + self.cb_phrase.setChecked(False) expert_vbox.addWidget(passphrase_msg) expert_vbox.addWidget(passphrase_warning) - expert_vbox.addWidget(cb_phrase) + expert_vbox.addWidget(self.cb_phrase) # ask for recovery type (random word order OR matrix) - bg_rectype = None + self.bg_rectype = None if method == TIM_RECOVER and model == '1': gb_rectype = QGroupBox() hbox_rectype = QHBoxLayout() gb_rectype.setLayout(hbox_rectype) expert_vbox.addWidget(gb_rectype) gb_rectype.setTitle(_("Select recovery type:")) - bg_rectype = QButtonGroup() + self.bg_rectype = QButtonGroup() rb1 = QRadioButton(gb_rectype) rb1.setText(_('Scrambled words')) - bg_rectype.addButton(rb1) - bg_rectype.setId(rb1, RecoveryDeviceType.ScrambledWords) + self.bg_rectype.addButton(rb1) + self.bg_rectype.setId(rb1, RecoveryDeviceType.ScrambledWords) hbox_rectype.addWidget(rb1) rb1.setChecked(True) rb2 = QRadioButton(gb_rectype) rb2.setText(_('Matrix')) - bg_rectype.addButton(rb2) - bg_rectype.setId(rb2, RecoveryDeviceType.Matrix) + self.bg_rectype.addButton(rb2) + self.bg_rectype.setId(rb2, RecoveryDeviceType.Matrix) hbox_rectype.addWidget(rb2) # no backup - cb_no_backup = None + self.cb_no_backup = None if method == TIM_NEW: - cb_no_backup = QCheckBox(f'''{_('Enable seedless mode')}''') - cb_no_backup.setChecked(False) + self.cb_no_backup = QCheckBox(_('Enable seedless mode')) + self.cb_no_backup.setChecked(False) if (model == '1' and fw_version >= (1, 7, 1) or model == 'T' and fw_version >= (2, 0, 9)): - cb_no_backup.setToolTip(SEEDLESS_MODE_WARNING) + self.cb_no_backup.setToolTip(SEEDLESS_MODE_WARNING) else: - cb_no_backup.setEnabled(False) - cb_no_backup.setToolTip(_('Firmware version too old.')) - expert_vbox.addWidget(cb_no_backup) + self.cb_no_backup.setEnabled(False) + self.cb_no_backup.setToolTip(_('Firmware version too old.')) + expert_vbox.addWidget(self.cb_no_backup) - vbox.addWidget(expert_widget) - wizard.exec_layout(vbox, next_enabled=next_enabled) + self.addWidget(expert_widget) + def get_settings(self): return TrezorInitSettings( - word_count=bg_numwords.checkedId(), - label=name.text(), - pin_enabled=cb_pin.isChecked(), - passphrase_enabled=cb_phrase.isChecked(), - recovery_type=bg_rectype.checkedId() if bg_rectype else None, - backup_type=bg_backuptype.checkedId(), - no_backup=cb_no_backup.isChecked() if cb_no_backup else False, + word_count=self.bg_numwords.checkedId(), + label=self.name.text(), + pin_enabled=self.cb_pin.isChecked(), + passphrase_enabled=self.cb_phrase.isChecked(), + recovery_type=self.bg_rectype.checkedId() if self.bg_rectype else None, + backup_type=self.bg_backuptype.checkedId(), + no_backup=self.cb_no_backup.isChecked() if self.cb_no_backup else False, ) @@ -450,6 +459,23 @@ def pin_matrix_widget_class(self): from trezorlib.qt.pinmatrix import PinMatrixWidget return PinMatrixWidget + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + self.extend_wizard(wizard) + + # insert trezor pages in new wallet wizard + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'trezor_start': {'gui': WCScriptAndDerivation}, + 'trezor_xpub': {'gui': WCTrezorXPub}, + 'trezor_not_initialized': {'gui': WCTrezorInitMethod}, + 'trezor_choose_new_recover': {'gui': WCTrezorInitParams}, + 'trezor_do_init': {'gui': WCTrezorInit}, + 'trezor_unlock': {'gui': WCHWUnlock}, + } + wizard.navmap_merge(views) + class SettingsDialog(WindowModalDialog): '''This dialog doesn't require a device be paired with a wallet. @@ -767,3 +793,114 @@ def slider_released(): # Update information invoke_client(None) + + +class WCTrezorXPub(WCHWXPub): + def __init__(self, parent, wizard): + WCHWXPub.__init__(self, parent, wizard) + + def get_xpub_from_client(self, client, derivation, xtype): + _name, _info = self.wizard_data['hardware_device'] + if xtype not in self.plugin.SUPPORTED_XTYPES: + raise ScriptTypeNotSupported(_('This type of script is not supported with {}').format(_info.model_name)) + if not client.is_uptodate(): + msg = (_('Outdated {} firmware for device labelled {}. Please ' + 'download the updated firmware from {}') + .format(_info.model_name, _info.label, self.plugin.firmware_URL)) + raise OutdatedHwFirmwareException(msg) + return client.get_xpub(derivation, xtype, True) + + +class WCTrezorInitMethod(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('HW Setup')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = None + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + self.plugin = self.plugins.get_plugin(_info.plugin_name) + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + if not client.is_uptodate(): + msg = (_('Outdated {} firmware for device labelled {}. Please ' + 'download the updated firmware from {}') + .format(_info.model_name, _info.label, self.plugin.firmware_URL)) + self.error = msg + return + + message = _('Choose how you want to initialize your {}.').format(_info.model_name) + choices = [ + # Must be short as QT doesn't word-wrap radio button text + (TIM_NEW, _("Let the device generate a completely new seed randomly")), + (TIM_RECOVER, _("Recover from a seed you have previously written down")), + ] + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['trezor_init'] = self.choice_w.selected_item[0] + + +class WCTrezorInitParams(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up trezor')) + self.plugins = wizard.plugins + self._busy = True + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + self.settings_layout = InitSettingsLayout(self.plugins.device_manager, self.wizard_data['trezor_init'], _info.device.id_) + self.layout().addLayout(self.settings_layout) + self.layout().addStretch(1) + + self.valid = True + self.busy = False + + def apply(self): + self.wizard_data['trezor_settings'] = self.settings_layout.get_settings() + + +class WCTrezorInit(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up trezor')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('trezor') + + self.layout().addWidget(WWLabel('Done')) + + self._busy = True + + def on_ready(self): + settings = self.wizard_data['trezor_settings'] + method = self.wizard_data['trezor_init'] + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def initialize_device_task(settings, method, device_id, handler): + try: + self.plugin._initialize_device(settings, method, device_id, handler) + self.logger.info('Done initialize device') + self.valid = True + self.wizard.requestNext.emit() # triggers Next GUI thread from event loop + except Exception as e: + self.valid = False + self.error = repr(e) + finally: + self.busy = False + + t = threading.Thread( + target=initialize_device_task, + args=(settings, method, device_id, client.handler), + daemon=True) + t.start() + + def apply(self): + pass diff --git a/electrum/plugins/trezor/trezor.py b/electrum/plugins/trezor/trezor.py index 6184d860122b..ec2d95ba82c1 100644 --- a/electrum/plugins/trezor/trezor.py +++ b/electrum/plugins/trezor/trezor.py @@ -1,21 +1,22 @@ -import traceback -import sys -from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING, Sequence +from typing import NamedTuple, Any, Optional, TYPE_CHECKING, Sequence -from electrum.util import bfh, versiontuple, UserCancelled, UserFacingException +from electrum.util import bfh, UserCancelled, UserFacingException from electrum.bip32 import BIP32Node from electrum import descriptor from electrum import constants from electrum.i18n import _ from electrum.plugin import Device, runs_in_hwd_thread -from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, Sighash +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, Sighash from electrum.keystore import Hardware_KeyStore -from electrum.base_wizard import ScriptTypeNotSupported, HWD_SETUP_NEW_WALLET from electrum.logging import get_logger -from ..hw_wallet import HW_PluginBase -from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, - LibraryFoundButUnusable, OutdatedHwFirmwareException) +from electrum.plugins.hw_wallet import HW_PluginBase +from electrum.plugins.hw_wallet.plugin import is_any_tx_output_on_change_branch, \ + trezor_validate_op_return_output_and_get_data, LibraryFoundButUnusable, OutdatedHwFirmwareException + +if TYPE_CHECKING: + from electrum.plugin import DeviceInfo + from electrum.wizard import NewWalletWizard _logger = get_logger(__name__) @@ -212,43 +213,8 @@ def get_client(self, keystore, force_pair=True, *, def get_coin_name(self): return "Testnet" if constants.net.TESTNET else "Bitcoin" - def initialize_device(self, device_id, wizard, handler): - # Initialization method - msg = _("Choose how you want to initialize your {}.").format(self.device, self.device) - choices = [ - # Must be short as QT doesn't word-wrap radio button text - (TIM_NEW, _("Let the device generate a completely new seed randomly")), - (TIM_RECOVER, _("Recover from a seed you have previously written down")), - ] - def f(method): - import threading - settings = self.request_trezor_init_settings(wizard, method, device_id) - t = threading.Thread(target=self._initialize_device_safe, args=(settings, method, device_id, wizard, handler)) - t.daemon = True - t.start() - exit_code = wizard.loop.exec_() - if exit_code != 0: - # this method (initialize_device) was called with the expectation - # of leaving the device in an initialized state when finishing. - # signal that this is not the case: - raise UserCancelled() - wizard.choice_dialog(title=_('Initialize Device'), message=msg, choices=choices, run_next=f) - - def _initialize_device_safe(self, settings, method, device_id, wizard, handler): - exit_code = 0 - try: - self._initialize_device(settings, method, device_id, wizard, handler) - except UserCancelled: - exit_code = 1 - except BaseException as e: - self.logger.exception('') - handler.show_error(repr(e)) - exit_code = 1 - finally: - wizard.loop.exit(exit_code) - @runs_in_hwd_thread - def _initialize_device(self, settings: TrezorInitSettings, method, device_id, wizard, handler): + def _initialize_device(self, settings: TrezorInitSettings, method, device_id, handler): if method == TIM_RECOVER and settings.recovery_type == RecoveryDeviceType.ScrambledWords: handler.show_error(_( "You will be asked to enter 24 words regardless of your " @@ -295,32 +261,6 @@ def _make_node_path(self, xpub: str, address_n: Sequence[int]): ) return HDNodePathType(node=node, address_n=address_n) - def setup_device(self, device_info, wizard, purpose): - device_id = device_info.device.id_ - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - - if not client.is_uptodate(): - msg = (_('Outdated {} firmware for device labelled {}. Please ' - 'download the updated firmware from {}') - .format(self.device, client.label(), self.firmware_URL)) - raise OutdatedHwFirmwareException(msg) - - if not device_info.initialized: - self.initialize_device(device_id, wizard, client.handler) - is_creating_wallet = purpose == HWD_SETUP_NEW_WALLET - wizard.run_task_without_blocking_gui( - task=lambda: client.get_xpub('m', 'standard', creating=is_creating_wallet)) - client.used() - return client - - def get_xpub(self, device_id, derivation, xtype, wizard): - if xtype not in self.SUPPORTED_XTYPES: - raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) - client = self.scan_and_create_client_for_device(device_id=device_id, wizard=wizard) - xpub = client.get_xpub(derivation, xtype) - client.used() - return xpub - def get_trezor_input_script_type(self, electrum_txin_type: str): if electrum_txin_type in ('p2wpkh', 'p2wsh'): return InputScriptType.SPENDWITNESS @@ -524,3 +464,35 @@ def electrum_tx_to_txtype(self, tx: Optional[Transaction]): for o in tx.outputs() ] return t + + def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: + if new_wallet: # new wallet + return 'trezor_not_initialized' if not device_info.initialized else 'trezor_start' + else: # unlock existing wallet + return 'trezor_unlock' + + # insert trezor pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'trezor_start': { + 'next': 'trezor_xpub', + }, + 'trezor_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'accept': wizard.maybe_master_pubkey, + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'trezor_not_initialized': { + 'next': 'trezor_choose_new_recover', + }, + 'trezor_choose_new_recover': { + 'next': 'trezor_do_init', + }, + 'trezor_do_init': { + 'next': 'trezor_start', + }, + 'trezor_unlock': { + 'last': True + }, + } + wizard.navmap_merge(views) diff --git a/electrum/plugins/trustedcoin/common_qt.py b/electrum/plugins/trustedcoin/common_qt.py new file mode 100644 index 000000000000..1638666ef94a --- /dev/null +++ b/electrum/plugins/trustedcoin/common_qt.py @@ -0,0 +1,257 @@ +import threading +import socket +import base64 +from typing import TYPE_CHECKING + +from PyQt5.QtCore import pyqtSignal, pyqtProperty, pyqtSlot + +from electrum.i18n import _ +from electrum.bip32 import BIP32Node + +from .trustedcoin import (server, ErrorConnectingServer, MOBILE_DISCLAIMER, TrustedCoinException) +from electrum.gui.common_qt.plugins import PluginQObject + +if TYPE_CHECKING: + from electrum.wizard import NewWalletWizard + + +class TrustedcoinPluginQObject(PluginQObject): + canSignWithoutServerChanged = pyqtSignal() + termsAndConditionsRetrieved = pyqtSignal([str], arguments=['message']) + termsAndConditionsError = pyqtSignal([str], arguments=['message']) + otpError = pyqtSignal([str], arguments=['message']) + otpSuccess = pyqtSignal() + disclaimerChanged = pyqtSignal() + keystoreChanged = pyqtSignal() + otpSecretChanged = pyqtSignal() + shortIdChanged = pyqtSignal() + billingModelChanged = pyqtSignal() + + remoteKeyStateChanged = pyqtSignal() + remoteKeyError = pyqtSignal([str], arguments=['message']) + + requestOtp = pyqtSignal() + + def __init__(self, plugin, wizard: 'NewWalletWizard', parent): + super().__init__(plugin, parent) + self.wizard = wizard + self._canSignWithoutServer = False + self._otpSecret = '' + self._shortId = '' + self._billingModel = [] + self._remoteKeyState = '' + self._verifyingOtp = False + + @pyqtProperty(str, notify=disclaimerChanged) + def disclaimer(self): + return '\n\n'.join(MOBILE_DISCLAIMER) + + @pyqtProperty(bool, notify=canSignWithoutServerChanged) + def canSignWithoutServer(self): + return self._canSignWithoutServer + + @pyqtProperty('QVariantMap', notify=keystoreChanged) + def keystore(self): + return self._keystore + + @pyqtProperty(str, notify=otpSecretChanged) + def otpSecret(self): + return self._otpSecret + + @pyqtProperty(str, notify=shortIdChanged) + def shortId(self): + return self._shortId + + @pyqtSlot(str) + def otpSubmit(self, otp): + self._plugin.on_otp(otp) + + @pyqtProperty(str, notify=remoteKeyStateChanged) + def remoteKeyState(self): + return self._remoteKeyState + + @remoteKeyState.setter + def remoteKeyState(self, new_state): + if self._remoteKeyState != new_state: + self._remoteKeyState = new_state + self.remoteKeyStateChanged.emit() + + @pyqtProperty('QVariantList', notify=billingModelChanged) + def billingModel(self): + return self._billingModel + + def updateBillingInfo(self, wallet): + billingModel = [] + + price_per_tx = wallet.price_per_tx + for k, v in sorted(price_per_tx.items()): + if k == 1: + continue + item = { + 'text': 'Pay every %d transactions' % k, + 'value': k, + 'sats_per_tx': v / k + } + billingModel.append(item) + + self._billingModel = billingModel + self.billingModelChanged.emit() + + @pyqtSlot() + def fetchTermsAndConditions(self): + def fetch_task(): + try: + self.plugin.logger.debug('TOS') + tos = server.get_terms_of_service() + except ErrorConnectingServer as e: + self.termsAndConditionsError.emit(_('Error connecting to server')) + except Exception as e: + self.termsAndConditionsError.emit('%s: %s' % (_('Error'), repr(e))) + else: + self.termsAndConditionsRetrieved.emit(tos) + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + t = threading.Thread(target=fetch_task) + t.daemon = True + t.start() + + @pyqtSlot(str) + def createKeystore(self, email): + self.remoteKeyState = '' + self._otpSecret = '' + self.otpSecretChanged.emit() + + wizard_data = self.wizard.get_wizard_data() + + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data) + + def create_remote_key_task(): + try: + self.plugin.logger.debug('create remote key') + r = server.create(xpub1, xpub2, email) + + otp_secret = r['otp_secret'] + _xpub3 = r['xpubkey_cosigner'] + _id = r['id'] + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') + except TrustedCoinException as e: + if e.status_code == 409: + self.remoteKeyState = 'wallet_known' + self._shortId = short_id + self.shortIdChanged.emit() + else: + self.remoteKeyState = 'error' + self.logger.warning(str(e)) + self.remoteKeyError.emit(f'Service error: {str(e)}') + except (KeyError, TypeError) as e: # catch any assumptions + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + self.logger.error(str(e)) + else: + if short_id != _id: + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id)) + self.remoteKeyError.emit('Unexpected short_id') + return + if xpub3 != _xpub3: + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) + self.remoteKeyError.emit('Unexpected trustedcoin xpub3') + return + self.remoteKeyState = 'new' + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + self._shortId = short_id + self.shortIdChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + + t = threading.Thread(target=create_remote_key_task) + t.daemon = True + t.start() + + @pyqtSlot() + def resetOtpSecret(self): + self.remoteKeyState = '' + + wizard_data = self.wizard.get_wizard_data() + + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data) + + def reset_otp_task(): + try: + self.plugin.logger.debug('reset_otp') + r = server.get_challenge(short_id) + challenge = r.get('challenge') + message = 'TRUSTEDCOIN CHALLENGE: ' + challenge + + def f(xprv): + rootnode = BIP32Node.from_xkey(xprv) + key = rootnode.subkey_at_private_derivation((0, 0)).eckey + sig = key.sign_message(message, True) + return base64.b64encode(sig).decode() + + signatures = [f(x) for x in [xprv1, xprv2]] + r = server.reset_auth(short_id, challenge, signatures) + otp_secret = r.get('otp_secret') + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') + except Exception as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + else: + self.remoteKeyState = 'reset' + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + + t = threading.Thread(target=reset_otp_task, daemon=True) + t.start() + + @pyqtSlot(str, int) + def checkOtp(self, short_id, otp): + assert type(otp) is int # make sure this doesn't fail subtly + + def check_otp_task(): + try: + self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}') + server.auth(short_id, otp) + except TrustedCoinException as e: + if e.status_code == 400: # invalid OTP + self.plugin.logger.debug('Invalid one-time password.') + self.otpError.emit(_('Invalid one-time password.')) + else: + self.plugin.logger.error(str(e)) + self.otpError.emit(f'Service error: {str(e)}') + except Exception as e: + self.plugin.logger.error(str(e)) + self.otpError.emit(f'Error: {str(e)}') + else: + self.plugin.logger.debug('OTP verify success') + self.otpSuccess.emit() + finally: + self._busy = False + self.busyChanged.emit() + self._verifyingOtp = False + + self._verifyingOtp = True + self._busy = True + self.busyChanged.emit() + t = threading.Thread(target=check_otp_task, daemon=True) + t.start() diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index bb9412cc080d..56a03b4013b9 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,261 +1,22 @@ -import threading -import socket -import base64 from typing import TYPE_CHECKING -from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot - from electrum.i18n import _ from electrum.plugin import hook -from electrum.bip32 import xpub_type, BIP32Node from electrum.util import UserFacingException -from electrum import keystore from electrum.gui.qml.qewallet import QEWallet -from electrum.gui.qml.plugins import PluginQObject +from .common_qt import TrustedcoinPluginQObject -from .trustedcoin import (TrustedCoinPlugin, server, ErrorConnectingServer, - MOBILE_DISCLAIMER, get_user_id, get_signing_xpub, - TrustedCoinException, make_xpub) +from .trustedcoin import TrustedCoinPlugin, TrustedCoinException if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication from electrum.wallet import Abstract_Wallet + from electrum.wizard import NewWalletWizard class Plugin(TrustedCoinPlugin): - class QSignalObject(PluginQObject): - canSignWithoutServerChanged = pyqtSignal() - _canSignWithoutServer = False - termsAndConditionsRetrieved = pyqtSignal([str], arguments=['message']) - termsAndConditionsError = pyqtSignal([str], arguments=['message']) - otpError = pyqtSignal([str], arguments=['message']) - otpSuccess = pyqtSignal() - disclaimerChanged = pyqtSignal() - keystoreChanged = pyqtSignal() - otpSecretChanged = pyqtSignal() - _otpSecret = '' - shortIdChanged = pyqtSignal() - _shortId = '' - billingModelChanged = pyqtSignal() - _billingModel = [] - - _remoteKeyState = '' - remoteKeyStateChanged = pyqtSignal() - remoteKeyError = pyqtSignal([str], arguments=['message']) - - requestOtp = pyqtSignal() - - def __init__(self, plugin, parent): - super().__init__(plugin, parent) - - @pyqtProperty(str, notify=disclaimerChanged) - def disclaimer(self): - return '\n\n'.join(MOBILE_DISCLAIMER) - - @pyqtProperty(bool, notify=canSignWithoutServerChanged) - def canSignWithoutServer(self): - return self._canSignWithoutServer - - @pyqtProperty('QVariantMap', notify=keystoreChanged) - def keystore(self): - return self._keystore - - @pyqtProperty(str, notify=otpSecretChanged) - def otpSecret(self): - return self._otpSecret - - @pyqtProperty(str, notify=shortIdChanged) - def shortId(self): - return self._shortId - - @pyqtSlot(str) - def otpSubmit(self, otp): - self._plugin.on_otp(otp) - - @pyqtProperty(str, notify=remoteKeyStateChanged) - def remoteKeyState(self): - return self._remoteKeyState - - @remoteKeyState.setter - def remoteKeyState(self, new_state): - if self._remoteKeyState != new_state: - self._remoteKeyState = new_state - self.remoteKeyStateChanged.emit() - - @pyqtProperty('QVariantList', notify=billingModelChanged) - def billingModel(self): - return self._billingModel - - def updateBillingInfo(self, wallet): - billingModel = [] - - price_per_tx = wallet.price_per_tx - for k, v in sorted(price_per_tx.items()): - if k == 1: - continue - item = { - 'text': 'Pay every %d transactions' % k, - 'value': k, - 'sats_per_tx': v/k - } - billingModel.append(item) - - self._billingModel = billingModel - self.billingModelChanged.emit() - - @pyqtSlot() - def fetchTermsAndConditions(self): - def fetch_task(): - try: - self.plugin.logger.debug('TOS') - tos = server.get_terms_of_service() - except ErrorConnectingServer as e: - self.termsAndConditionsError.emit(_('Error connecting to server')) - except Exception as e: - self.termsAndConditionsError.emit('%s: %s' % (_('Error'), repr(e))) - else: - self.termsAndConditionsRetrieved.emit(tos) - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - t = threading.Thread(target=fetch_task) - t.daemon = True - t.start() - - @pyqtSlot(str) - def createKeystore(self, email): - self.remoteKeyState = '' - self._otpSecret = '' - self.otpSecretChanged.emit() - - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() - - def create_remote_key_task(): - try: - self.plugin.logger.debug('create remote key') - r = server.create(xpub1, xpub2, email) - - otp_secret = r['otp_secret'] - _xpub3 = r['xpubkey_cosigner'] - _id = r['id'] - except (socket.error, ErrorConnectingServer) as e: - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Network error: {str(e)}') - except TrustedCoinException as e: - if e.status_code == 409: - self.remoteKeyState = 'wallet_known' - self._shortId = short_id - self.shortIdChanged.emit() - else: - self.remoteKeyState = 'error' - self.logger.warning(str(e)) - self.remoteKeyError.emit(f'Service error: {str(e)}') - except (KeyError,TypeError) as e: # catch any assumptions - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Error: {str(e)}') - self.logger.error(str(e)) - else: - if short_id != _id: - self.remoteKeyState = 'error' - self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id)) - self.remoteKeyError.emit('Unexpected short_id') - return - if xpub3 != _xpub3: - self.remoteKeyState = 'error' - self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) - self.remoteKeyError.emit('Unexpected trustedcoin xpub3') - return - self.remoteKeyState = 'new' - self._otpSecret = otp_secret - self.otpSecretChanged.emit() - self._shortId = short_id - self.shortIdChanged.emit() - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - - t = threading.Thread(target=create_remote_key_task) - t.daemon = True - t.start() - - @pyqtSlot() - def resetOtpSecret(self): - self.remoteKeyState = '' - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() - - def reset_otp_task(): - try: - # TODO: move reset request to UI agnostic plugin section - self.plugin.logger.debug('reset_otp') - r = server.get_challenge(short_id) - challenge = r.get('challenge') - message = 'TRUSTEDCOIN CHALLENGE: ' + challenge - - def f(xprv): - rootnode = BIP32Node.from_xkey(xprv) - key = rootnode.subkey_at_private_derivation((0, 0)).eckey - sig = key.sign_message(message, True) - return base64.b64encode(sig).decode() - - signatures = [f(x) for x in [xprv1, xprv2]] - r = server.reset_auth(short_id, challenge, signatures) - otp_secret = r.get('otp_secret') - except (socket.error, ErrorConnectingServer) as e: - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Network error: {str(e)}') - except Exception as e: - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Error: {str(e)}') - else: - self.remoteKeyState = 'reset' - self._otpSecret = otp_secret - self.otpSecretChanged.emit() - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - - t = threading.Thread(target=reset_otp_task, daemon=True) - t.start() - - @pyqtSlot(str, int) - def checkOtp(self, short_id, otp): - def check_otp_task(): - try: - self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}') - server.auth(short_id, otp) - except TrustedCoinException as e: - if e.status_code == 400: # invalid OTP - self.plugin.logger.debug('Invalid one-time password.') - self.otpError.emit(_('Invalid one-time password.')) - else: - self.plugin.logger.error(str(e)) - self.otpError.emit(f'Service error: {str(e)}') - except Exception as e: - self.plugin.logger.error(str(e)) - self.otpError.emit(f'Error: {str(e)}') - else: - self.plugin.logger.debug('OTP verify success') - self.otpSuccess.emit() - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - t = threading.Thread(target=check_otp_task, daemon=True) - t.start() - def __init__(self, *args): super().__init__(*args) @@ -279,108 +40,48 @@ def load_wallet(self, wallet: 'Abstract_Wallet'): def init_qml(self, app: 'ElectrumQmlApplication'): self.logger.debug(f'init_qml hook called, gui={str(type(app))}') self._app = app - # important: QSignalObject needs to be parented, as keeping a ref + wizard = self._app.daemon.newWalletWizard + # important: TrustedcoinPluginQObject needs to be parented, as keeping a ref # in the plugin is not enough to avoid gc - self.so = Plugin.QSignalObject(self, self._app) - + # Note: storing the trustedcoin qt helper in the plugin is different from the desktop client, + # which stores the helper in the wizard object. As the mobile client only shows a single wizard + # at a time, this is ok for now. + self.so = TrustedcoinPluginQObject(self, wizard, self._app) # extend wizard - self.extend_wizard() + self.extend_wizard(wizard) # wizard support functions - def extend_wizard(self): - wizard = self._app.daemon.newWalletWizard - self.logger.debug(repr(wizard)) + def extend_wizard(self, wizard: 'NewWalletWizard'): + super().extend_wizard(wizard) views = { 'trustedcoin_start': { 'gui': '../../../../plugins/trustedcoin/qml/Disclaimer', - 'next': 'trustedcoin_choose_seed' }, 'trustedcoin_choose_seed': { 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', - 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' - else 'trustedcoin_have_seed' }, 'trustedcoin_create_seed': { 'gui': 'WCCreateSeed', - 'next': 'trustedcoin_confirm_seed' }, 'trustedcoin_confirm_seed': { 'gui': 'WCConfirmSeed', - 'next': 'trustedcoin_tos_email' }, 'trustedcoin_have_seed': { 'gui': 'WCHaveSeed', - 'next': 'trustedcoin_keep_disable' }, 'trustedcoin_keep_disable': { 'gui': '../../../../plugins/trustedcoin/qml/KeepDisable', - 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_keepordisable'] != 'disable' - else 'wallet_password', - 'accept': self.recovery_disable, - 'last': lambda v,d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' }, 'trustedcoin_tos_email': { 'gui': '../../../../plugins/trustedcoin/qml/Terms', - 'next': 'trustedcoin_show_confirm_otp' }, 'trustedcoin_show_confirm_otp': { 'gui': '../../../../plugins/trustedcoin/qml/ShowConfirmOTP', - 'accept': self.on_accept_otp_secret, - 'next': 'wallet_password', - 'last': lambda v,d: wizard.is_single_password() } } wizard.navmap_merge(views) - - # combined create_keystore and create_remote_key pre - def create_keys(self): - wizard = self._app.daemon.newWalletWizard - wizard_data = wizard._current.wizard_data - - xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], wizard_data['seed_extra_words']) - - # NOTE: at this point, old style wizard creates a wallet file (w. password if set) and - # stores the keystores and wizard state, in order to separate offline seed creation - # and online retrieval of the OTP secret. For mobile, we don't do this, but - # for desktop the wizard should support this usecase. - - data = {'x1/': {'xpub': xpub1}, 'x2/': {'xpub': xpub2}} - - # Generate third key deterministically. - long_user_id, short_id = get_user_id(data) - xtype = xpub_type(xpub1) - xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) - - return (xprv1,xpub1,xprv2,xpub2,xpub3,short_id) - - def on_accept_otp_secret(self, wizard_data): - self.logger.debug('OTP secret accepted, creating keystores') - xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xpub(xpub2) - k3 = keystore.from_xpub(xpub3) - - wizard_data['x1/'] = k1.dump() - wizard_data['x2/'] = k2.dump() - wizard_data['x3/'] = k3.dump() - - def recovery_disable(self, wizard_data): - if wizard_data['trustedcoin_keepordisable'] != 'disable': - return - - self.logger.debug('2fa disabled, creating keystores') - xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xprv(xprv2) - k3 = keystore.from_xpub(xpub3) - - wizard_data['x1/'] = k1.dump() - wizard_data['x2/'] = k2.dump() - wizard_data['x3/'] = k3.dump() - - # running wallet functions def prompt_user_for_otp(self, wallet, tx, on_success, on_failure): @@ -418,4 +119,3 @@ def billing_info_retrieved(self, wallet): qewallet = QEWallet.getInstanceFor(wallet) qewallet.billingInfoChanged.emit() self.so.updateBillingInfo(wallet) - diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py index 037e30f1eee0..b59a41fd66b8 100644 --- a/electrum/plugins/trustedcoin/qt.py +++ b/electrum/plugins/trustedcoin/qt.py @@ -25,32 +25,36 @@ from functools import partial import threading -import sys import os from typing import TYPE_CHECKING -from PyQt5.QtGui import QPixmap -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtGui import QPixmap, QMovie, QColor +from PyQt5.QtCore import QObject, pyqtSignal, QSize, Qt from PyQt5.QtWidgets import (QTextEdit, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout, - QRadioButton, QCheckBox, QLineEdit) + QRadioButton, QCheckBox, QLineEdit, QPushButton, QWidget) + +from electrum.i18n import _ +from electrum.plugin import hook +from electrum.util import is_valid_email +from electrum.logging import Logger, get_logger +from electrum import keystore from electrum.gui.qt.util import (read_QIcon, WindowModalDialog, WaitingDialog, OkButton, - CancelButton, Buttons, icon_path, WWLabel, CloseButton) + CancelButton, Buttons, icon_path, WWLabel, CloseButton, ColorScheme, + ChoiceWidget) from electrum.gui.qt.qrcodewidget import QRCodeWidget from electrum.gui.qt.amountedit import AmountEdit from electrum.gui.qt.main_window import StatusBarButton -from electrum.gui.qt.installwizard import InstallWizard -from electrum.i18n import _ -from electrum.plugin import hook -from electrum.util import is_valid_email -from electrum.logging import Logger -from electrum.base_wizard import GoBack, UserCancelled +from electrum.gui.qt.wizard.wallet import WCCreateSeed, WCConfirmSeed, WCHaveSeed, WCEnterExt, WCConfirmExt +from electrum.gui.qt.wizard.wizard import WizardComponent -from .trustedcoin import TrustedCoinPlugin, server +from .common_qt import TrustedcoinPluginQObject +from .trustedcoin import TrustedCoinPlugin, server, DISCLAIMER if TYPE_CHECKING: from electrum.gui.qt.main_window import ElectrumWindow from electrum.wallet import Abstract_Wallet + from electrum.gui.qt.wizard.wallet import QENewWalletWizard class TOS(QTextEdit): @@ -219,25 +223,6 @@ def on_click(b, k): vbox.addLayout(Buttons(CloseButton(d))) d.exec_() - def go_online_dialog(self, wizard: InstallWizard): - msg = [ - _("Your wallet file is: {}.").format(os.path.abspath(wizard.path)), - _("You need to be online in order to complete the creation of " - "your wallet. If you generated your seed on an offline " - 'computer, click on "{}" to close this window, move your ' - "wallet file to an online computer, and reopen it with " - "Electrum.").format(_('Cancel')), - _('If you are online, click on "{}" to continue.').format(_('Next')) - ] - msg = '\n\n'.join(msg) - wizard.reset_stack() - try: - wizard.confirm_dialog(title='', message=msg, run_next = lambda x: wizard.run('accept_terms_of_use')) - except (GoBack, UserCancelled): - # user clicked 'Cancel' and decided to move wallet file manually - storage, db = wizard.create_storage(wizard.path) - raise - def accept_terms_of_use(self, window): vbox = QVBoxLayout() vbox.addWidget(QLabel(_("Terms of Service"))) @@ -327,3 +312,376 @@ def set_enabled(): cb_lost.toggled.connect(set_enabled) window.exec_layout(vbox, next_enabled=False, raise_on_cancel=False) self.check_otp(window, short_id, otp_secret, xpub3, pw.get_amount(), cb_lost.isChecked()) + + @hook + def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): + wizard.trustedcoin_qhelper = TrustedcoinPluginQObject(self, wizard, None) + self.extend_wizard(wizard) + + def extend_wizard(self, wizard: 'QENewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'trustedcoin_start': { + 'gui': WCDisclaimer, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_choose_seed': { + 'gui': WCChooseSeed, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_create_seed': { + 'gui': WCCreateSeed, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_confirm_seed': { + 'gui': WCConfirmSeed, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_have_seed': { + 'gui': WCHaveSeed, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_keep_disable': { + 'gui': WCKeepDisable, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_tos_email': { + 'gui': WCTerms, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_show_confirm_otp': { + 'gui': WCShowConfirmOTP, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + } + } + wizard.navmap_merge(views) + + # modify default flow, insert seed extension entry/confirm as separate views + ext = { + 'trustedcoin_create_seed': { + 'next': lambda d: 'trustedcoin_create_ext' if wizard.wants_ext(d) else 'trustedcoin_confirm_seed' + }, + 'trustedcoin_create_ext': { + 'gui': WCEnterExt, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + 'next': 'trustedcoin_confirm_seed', + }, + 'trustedcoin_confirm_seed': { + 'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_tos_email' + }, + 'trustedcoin_confirm_ext': { + 'gui': WCConfirmExt, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + 'next': 'trustedcoin_tos_email', + }, + 'trustedcoin_have_seed': { + 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_keep_disable' + }, + 'trustedcoin_have_ext': { + 'gui': WCEnterExt, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + 'next': 'trustedcoin_keep_disable', + }, + } + wizard.navmap_merge(ext) + + # insert page offering choice to go online or continue on another system + ext_online = { + 'trustedcoin_continue_online': { + 'gui': WCContinueOnline, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_go_online'] else 'wallet_password', + 'accept': self.on_continue_online, + 'last': lambda d: not d['trustedcoin_go_online'] and wizard.is_single_password() + }, + 'trustedcoin_confirm_seed': { + 'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_continue_online' + }, + 'trustedcoin_confirm_ext': { + 'next': 'trustedcoin_continue_online', + }, + 'trustedcoin_keep_disable': { + 'next': lambda d: 'trustedcoin_continue_online' if d['trustedcoin_keepordisable'] != 'disable' + else 'wallet_password', + } + } + wizard.navmap_merge(ext_online) + + def on_continue_online(self, wizard_data): + if not wizard_data['trustedcoin_go_online']: + self.logger.debug('Staying offline, create keystores here') + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xpub(xpub2) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + + +class WCDisclaimer(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Disclaimer')) + + self.layout().addWidget(WWLabel('\n\n'.join(DISCLAIMER))) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + pass + + +class WCChooseSeed(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Create or restore')) + message = _('Do you want to create a new seed, or restore a wallet using an existing seed?') + choices = [ + ('createseed', _('Create a new seed')), + ('haveseed', _('I already have a seed')), + ] + + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['keystore_type'] = self.choice_w.selected_item[0] + + +class WCTerms(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Terms and conditions')) + self._has_tos = False + + def on_ready(self): + self.tos_e = TOS() + self.tos_e.setReadOnly(True) + self.layout().addWidget(self.tos_e) + + self.layout().addWidget(QLabel(_("Please enter your e-mail address"))) + self.email_e = QLineEdit() + self.email_e.textChanged.connect(self.validate) + self.layout().addWidget(self.email_e) + + self.fetch_terms_and_conditions() + + def fetch_terms_and_conditions(self): + self.wizard.trustedcoin_qhelper.busyChanged.connect(self.on_busy_changed) + self.wizard.trustedcoin_qhelper.termsAndConditionsRetrieved.connect(self.on_terms_retrieved) + self.wizard.trustedcoin_qhelper.termsAndConditionsError.connect(self.on_terms_error) + self.wizard.trustedcoin_qhelper.fetchTermsAndConditions() + + def on_busy_changed(self): + self.busy = self.wizard.trustedcoin_qhelper.busy + + def on_terms_retrieved(self, tos: str) -> None: + self._has_tos = True + self.tos_e.setText(tos) + self.email_e.setFocus(True) + self.validate() + + def on_terms_error(self, error: str) -> None: + self.error = error + + def validate(self): + if self._has_tos and self.email_e.text() != '': + self.valid = True + else: + self.valid = False + + def apply(self): + self.wizard_data['2fa_email'] = self.email_e.text() + + +class WCShowConfirmOTP(WizardComponent): + _logger = get_logger(__name__) + + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Authenticator secret')) + self._otp_verified = False + + self.new_otp = QWidget() + new_otp_layout = QVBoxLayout() + scanlabel = WWLabel(_('Enter or scan into authenticator app. Then authenticate below')) + new_otp_layout.addWidget(scanlabel) + self.qr = QRCodeWidget('') + new_otp_layout.addWidget(self.qr) + self.secretlabel = WWLabel() + new_otp_layout.addWidget(self.secretlabel) + self.new_otp.setLayout(new_otp_layout) + + self.exist_otp = QWidget() + exist_otp_layout = QVBoxLayout() + knownlabel = WWLabel(_('This wallet is already registered with TrustedCoin.')) + exist_otp_layout.addWidget(knownlabel) + knownsecretlabel = WWLabel(_('If you still have your OTP secret, then authenticate below to finalize wallet creation')) + exist_otp_layout.addWidget(knownsecretlabel) + self.exist_otp.setLayout(exist_otp_layout) + + self.authlabelnew = WWLabel(_('Then, enter your Google Authenticator code:')) + self.authlabelexist = WWLabel(_('Google Authenticator code:')) + + self.spinner = QMovie(icon_path('spinner.gif')) + self.spinner.setScaledSize(QSize(24, 24)) + self.spinner.setBackgroundColor(QColor('black')) + self.spinner_l = QLabel() + self.spinner_l.setMargin(5) + self.spinner_l.setVisible(False) + self.spinner_l.setMovie(self.spinner) + + self.otp_status_l = QLabel() + self.otp_status_l.setAlignment(Qt.AlignHCenter) + self.otp_status_l.setVisible(False) + + self.resetlabel = WWLabel(_('If you have lost your OTP secret, click the button below to request a new secret from the server.')) + self.button = QPushButton('Request OTP secret') + self.button.clicked.connect(self.on_request_otp) + + hbox = QHBoxLayout() + hbox.addWidget(self.authlabelnew) + hbox.addWidget(self.authlabelexist) + hbox.addStretch(1) + hbox.addWidget(self.spinner_l) + self.otp_e = AmountEdit(None, is_int=True) + self.otp_e.setFocus(True) + self.otp_e.setMaximumWidth(150) + self.otp_e.textEdited.connect(self.on_otp_edited) + hbox.addWidget(self.otp_e) + + self.layout().addWidget(self.new_otp) + self.layout().addWidget(self.exist_otp) + self.layout().addLayout(hbox) + self.layout().addWidget(self.otp_status_l) + self.layout().addWidget(self.resetlabel) + self.layout().addWidget(self.button) + self.layout().addStretch(1) + + def on_ready(self): + self.wizard.trustedcoin_qhelper.busyChanged.connect(self.on_busy_changed) + self.wizard.trustedcoin_qhelper.remoteKeyError.connect(self.on_remote_key_error) + self.wizard.trustedcoin_qhelper.otpSuccess.connect(self.on_otp_success) + self.wizard.trustedcoin_qhelper.otpError.connect(self.on_otp_error) + self.wizard.trustedcoin_qhelper.remoteKeyError.connect(self.on_remote_key_error) + + self.wizard.trustedcoin_qhelper.createKeystore(self.wizard_data['2fa_email']) + + def update(self): + is_new = bool(self.wizard.trustedcoin_qhelper.remoteKeyState != 'wallet_known') + self.new_otp.setVisible(is_new) + self.exist_otp.setVisible(not is_new) + self.authlabelnew.setVisible(is_new) + self.authlabelexist.setVisible(not is_new) + self.resetlabel.setVisible(not is_new and not self._otp_verified) + self.button.setVisible(not is_new and not self._otp_verified) + + if self.wizard.trustedcoin_qhelper.otpSecret: + self.secretlabel.setText(self.wizard.trustedcoin_qhelper.otpSecret) + uri = 'otpauth://totp/Electrum 2FA %s?secret=%s&digits=6' % ( + self.wizard_data['wallet_name'], self.wizard.trustedcoin_qhelper.otpSecret) + self.qr.setData(uri) + + def on_busy_changed(self): + if not self.wizard.trustedcoin_qhelper._verifyingOtp: + self.busy = self.wizard.trustedcoin_qhelper.busy + if not self.busy: + self.update() + + def on_remote_key_error(self, text): + self._logger.error(text) + self.error = text + + def on_request_otp(self): + self.otp_status_l.setVisible(False) + self.wizard.trustedcoin_qhelper.resetOtpSecret() + self.update() + + def on_otp_success(self): + self._otp_verified = True + self.otp_status_l.setText('Valid!') + self.otp_status_l.setVisible(True) + self.otp_status_l.setStyleSheet(ColorScheme.GREEN.as_stylesheet(False)) + self.setEnabled(True) + self.spinner_l.setVisible(False) + self.spinner.stop() + + self.valid = True + + def on_otp_error(self, message): + self.otp_status_l.setText(message) + self.otp_status_l.setVisible(True) + self.otp_status_l.setStyleSheet(ColorScheme.RED.as_stylesheet(False)) + self.setEnabled(True) + self.spinner_l.setVisible(False) + self.spinner.stop() + + def on_otp_edited(self): + self.otp_status_l.setVisible(False) + text = self.otp_e.text() + if len(text) == 6: + # verify otp + self.wizard.trustedcoin_qhelper.checkOtp(self.wizard.trustedcoin_qhelper.shortId, int(text)) + self.setEnabled(False) + self.spinner_l.setVisible(True) + self.spinner.start() + self.otp_e.setText('') + + def apply(self): + pass + + +class WCKeepDisable(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet')) + message = ' '.join([ + 'You are going to restore a wallet protected with two-factor authentication.', + 'Do you want to keep using two-factor authentication with this wallet,', + 'or do you want to disable it, and have two master private keys in your wallet?' + ]) + choices = [ + ('keep', _('Keep')), + ('disable', _('Disable')), + ] + + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['trustedcoin_keepordisable'] = self.choice_w.selected_item[0] + + +class WCContinueOnline(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Continue Online')) + + def on_ready(self): + path = os.path.join(os.path.dirname(self.wizard._daemon.config.get_wallet_path()), self.wizard_data['wallet_name']) + msg = [ + _("Your wallet file is: {}.").format(path), + _("You need to be online in order to complete the creation of " + "your wallet. If you want to continue online, keep the checkbox " + "checked and press Next."), + _("If you want this system to stay offline " + "and continue the completion of the wallet on an online system, " + "uncheck the checkbox and press Finish.") + ] + + self.layout().addWidget(WWLabel('\n\n'.join(msg))) + self.layout().addStretch(1) + + self.cb_online = QCheckBox(_('Go online to complete wallet creation')) + self.cb_online.setChecked(True) + self.cb_online.stateChanged.connect(self.on_updated) + # self.cb_online.setToolTip(_("Check this box to request a new secret. You will need to retype your seed.")) + self.layout().addWidget(self.cb_online) + self.layout().setAlignment(self.cb_online, Qt.AlignHCenter) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['trustedcoin_go_online'] = self.cb_online.isChecked() diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py index a090e30f9e49..ea35ea081655 100644 --- a/electrum/plugins/trustedcoin/trustedcoin.py +++ b/electrum/plugins/trustedcoin/trustedcoin.py @@ -22,14 +22,11 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import asyncio -import socket + import json -import base64 import time import hashlib -from collections import defaultdict -from typing import Dict, Union, Sequence, List +from typing import Dict, Union, Sequence, List, TYPE_CHECKING from urllib.parse import urljoin from urllib.parse import quote @@ -44,11 +41,12 @@ from electrum.i18n import _ from electrum.plugin import BasePlugin, hook from electrum.util import NotEnoughFunds, UserFacingException -from electrum.storage import StorageEncryptionVersion from electrum.network import Network -from electrum.base_wizard import BaseWizard, WizardWalletPasswordSetting from electrum.logging import Logger +if TYPE_CHECKING: + from electrum.wizard import NewWalletWizard + def get_signing_xpub(xtype): if not constants.net.TESTNET: @@ -62,6 +60,7 @@ def get_signing_xpub(xtype): node = BIP32Node.from_xkey(xpub) return node._replace(xtype=xtype).to_xpub() + def get_billing_xpub(): if constants.net.TESTNET: return "tpubD6NzVbkrYhZ4X11EJFTJujsYbUmVASAYY7gXsEt4sL97AMBdypiH1E9ZVTpdXXEy3Kj9Eqd1UkxdGtvDt5z23DKsh6211CfNJo8bLLyem5r" @@ -99,10 +98,10 @@ def get_billing_xpub(): "your funds at any time and at no cost, without the remote server, by " "using the 'restore wallet' option with your wallet seed."), ] -KIVY_DISCLAIMER = MOBILE_DISCLAIMER RESTORE_MSG = _("Enter the seed for your 2-factor wallet:") + class TrustedCoinException(Exception): def __init__(self, message, status_code=0): Exception.__init__(self, message) @@ -259,10 +258,9 @@ def transfer_credit(self, id, recipient, otp, signature_callback): server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION) -class Wallet_2fa(Multisig_Wallet): +class Wallet_2fa(Multisig_Wallet): plugin: 'TrustedCoinPlugin' - wallet_type = '2fa' def __init__(self, db, *, config): @@ -413,6 +411,7 @@ def make_long_id(xpub_hot, xpub_cold): short_id = hashlib.sha256(long_id).hexdigest() return long_id, short_id + def make_xpub(xpub, s) -> str: rootnode = BIP32Node.from_xkey(xpub) child_pubkey, child_chaincode = bip32._CKD_pub(parent_pubkey=rootnode.eckey.get_public_key_bytes(compressed=True), @@ -423,6 +422,7 @@ def make_xpub(xpub, s) -> str: chaincode=child_chaincode) return child_node.to_xpub() + def make_billing_address(wallet, num, addr_type): long_id, short_id = wallet.get_user_id() xpub = make_xpub(get_billing_xpub(), long_id) @@ -546,31 +546,6 @@ def make_seed(self, seed_type): def do_clear(self, window): window.wallet.is_billing = False - def show_disclaimer(self, wizard: BaseWizard): - wizard.set_icon('trustedcoin-wizard.png') - wizard.reset_stack() - wizard.confirm_dialog(title='Disclaimer', message='\n\n'.join(self.disclaimer_msg), run_next = lambda x: wizard.run('choose_seed')) - - def choose_seed(self, wizard): - title = _('Create or restore') - message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?') - choices = [ - ('choose_seed_type', _('Create a new seed')), - ('restore_wallet', _('I already have a seed')), - ] - wizard.choice_dialog(title=title, message=message, choices=choices, run_next=wizard.run) - - def choose_seed_type(self, wizard): - seed_type = '2fa' if self.config.WIZARD_DONT_CREATE_SEGWIT else '2fa_segwit' - self.create_seed(wizard, seed_type) - - def create_seed(self, wizard, seed_type): - seed = self.make_seed(seed_type) - f = lambda x: wizard.request_passphrase(seed, x) - wizard.opt_bip39 = False - wizard.opt_ext = True - wizard.show_seed_dialog(run_next=f, seed_text=seed) - @classmethod def get_xkeys(self, seed, t, passphrase, derivation): assert is_any_2fa_seed_type(t) @@ -608,171 +583,6 @@ def xkeys_from_seed(self, seed, passphrase): raise Exception(f'unexpected seed type: {t}') return xprv1, xpub1, xprv2, xpub2 - def create_keystore(self, wizard, seed, passphrase): - # this overloads the wizard's method - xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase) - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xpub(xpub2) - wizard.request_password(run_next=lambda pw, encrypt: self.on_password(wizard, pw, encrypt, k1, k2)) - - def on_password(self, wizard, password, encrypt_storage, k1, k2): - k1.update_password(None, password) - wizard.data['x1/'] = k1.dump() - wizard.data['x2/'] = k2.dump() - wizard.pw_args = WizardWalletPasswordSetting(password=password, - encrypt_storage=encrypt_storage, - storage_enc_version=StorageEncryptionVersion.USER_PASSWORD, - encrypt_keystore=bool(password)) - self.go_online_dialog(wizard) - - def restore_wallet(self, wizard): - wizard.opt_bip39 = False - wizard.opt_slip39 = False - wizard.opt_ext = True - title = _("Restore two-factor Wallet") - f = lambda seed, seed_type, is_ext: wizard.run('on_restore_seed', seed, is_ext) - wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed) - - def on_restore_seed(self, wizard, seed, is_ext): - f = lambda x: self.restore_choice(wizard, seed, x) - wizard.passphrase_dialog(run_next=f) if is_ext else f('') - - def restore_choice(self, wizard: BaseWizard, seed, passphrase): - wizard.set_icon('trustedcoin-wizard.png') - wizard.reset_stack() - title = _('Restore 2FA wallet') - msg = ' '.join([ - 'You are going to restore a wallet protected with two-factor authentication.', - 'Do you want to keep using two-factor authentication with this wallet,', - 'or do you want to disable it, and have two master private keys in your wallet?' - ]) - choices = [('keep', 'Keep'), ('disable', 'Disable')] - f = lambda x: self.on_choice(wizard, seed, passphrase, x) - wizard.choice_dialog(choices=choices, message=msg, title=title, run_next=f) - - def on_choice(self, wizard, seed, passphrase, x): - if x == 'disable': - f = lambda pw, encrypt: wizard.run('on_restore_pw', seed, passphrase, pw, encrypt) - wizard.request_password(run_next=f) - else: - self.create_keystore(wizard, seed, passphrase) - - def on_restore_pw(self, wizard, seed, passphrase, password, encrypt_storage): - xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase) - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xprv(xprv2) - k1.add_seed(seed) - k1.update_password(None, password) - k2.update_password(None, password) - wizard.data['x1/'] = k1.dump() - wizard.data['x2/'] = k2.dump() - long_user_id, short_id = get_user_id(wizard.data) - xtype = xpub_type(xpub1) - xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) - k3 = keystore.from_xpub(xpub3) - wizard.data['x3/'] = k3.dump() - wizard.pw_args = WizardWalletPasswordSetting(password=password, - encrypt_storage=encrypt_storage, - storage_enc_version=StorageEncryptionVersion.USER_PASSWORD, - encrypt_keystore=bool(password)) - wizard.terminate() - - def create_remote_key(self, email, wizard): - xpub1 = wizard.data['x1/']['xpub'] - xpub2 = wizard.data['x2/']['xpub'] - # Generate third key deterministically. - long_user_id, short_id = get_user_id(wizard.data) - xtype = xpub_type(xpub1) - xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) - # secret must be sent by the server - try: - r = server.create(xpub1, xpub2, email) - except (socket.error, ErrorConnectingServer): - wizard.show_message('Server not reachable, aborting') - wizard.terminate(aborted=True) - return - except TrustedCoinException as e: - if e.status_code == 409: - r = None - else: - wizard.show_message(str(e)) - return - if r is None: - otp_secret = None - else: - otp_secret = r.get('otp_secret') - if not otp_secret: - wizard.show_message(_('Error')) - return - _xpub3 = r['xpubkey_cosigner'] - _id = r['id'] - if short_id != _id: - wizard.show_message("unexpected trustedcoin short_id: expected {}, received {}" - .format(short_id, _id)) - return - if xpub3 != _xpub3: - wizard.show_message("unexpected trustedcoin xpub3: expected {}, received {}" - .format(xpub3, _xpub3)) - return - self.request_otp_dialog(wizard, short_id, otp_secret, xpub3) - - def check_otp(self, wizard, short_id, otp_secret, xpub3, otp, reset): - if otp: - self.do_auth(wizard, short_id, otp, xpub3) - elif reset: - wizard.opt_bip39 = False - wizard.opt_slip39 = False - wizard.opt_ext = True - f = lambda seed, seed_type, is_ext: wizard.run('on_reset_seed', short_id, seed, is_ext, xpub3) - wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed) - - def on_reset_seed(self, wizard, short_id, seed, is_ext, xpub3): - f = lambda passphrase: wizard.run('on_reset_auth', short_id, seed, passphrase, xpub3) - wizard.passphrase_dialog(run_next=f) if is_ext else f('') - - def do_auth(self, wizard, short_id, otp, xpub3): - try: - server.auth(short_id, otp) - except TrustedCoinException as e: - if e.status_code == 400: # invalid OTP - wizard.show_message(_('Invalid one-time password.')) - # ask again for otp - self.request_otp_dialog(wizard, short_id, None, xpub3) - else: - wizard.show_message(str(e)) - wizard.terminate(aborted=True) - except Exception as e: - wizard.show_message(repr(e)) - wizard.terminate(aborted=True) - else: - k3 = keystore.from_xpub(xpub3) - wizard.data['x3/'] = k3.dump() - wizard.data['use_trustedcoin'] = True - wizard.terminate() - - def on_reset_auth(self, wizard, short_id, seed, passphrase, xpub3): - xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase) - if (wizard.data['x1/']['xpub'] != xpub1 or - wizard.data['x2/']['xpub'] != xpub2): - wizard.show_message(_('Incorrect seed')) - return - r = server.get_challenge(short_id) - challenge = r.get('challenge') - message = 'TRUSTEDCOIN CHALLENGE: ' + challenge - def f(xprv): - rootnode = BIP32Node.from_xkey(xprv) - key = rootnode.subkey_at_private_derivation((0, 0)).eckey - sig = key.sign_message(message, True) - return base64.b64encode(sig).decode() - - signatures = [f(x) for x in [xprv1, xprv2]] - r = server.reset_auth(short_id, challenge, signatures) - new_secret = r.get('otp_secret') - if not new_secret: - wizard.show_message(_('Request rejected by server')) - return - self.request_otp_dialog(wizard, short_id, new_secret, xpub3) - @hook def get_action(self, db): if db.get('wallet_type') != '2fa': @@ -783,3 +593,88 @@ def get_action(self, db): return self, 'show_disclaimer' if not db.get('x3/'): return self, 'accept_terms_of_use' + + # insert trustedcoin pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + # wizard = self._app.daemon.newWalletWizard + # self.logger.debug(repr(wizard)) + views = { + 'trustedcoin_start': { + 'next': 'trustedcoin_choose_seed', + }, + 'trustedcoin_choose_seed': { + 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' + else 'trustedcoin_have_seed' + }, + 'trustedcoin_create_seed': { + 'next': 'trustedcoin_confirm_seed' + }, + 'trustedcoin_confirm_seed': { + 'next': 'trustedcoin_tos_email' + }, + 'trustedcoin_have_seed': { + 'next': 'trustedcoin_keep_disable' + }, + 'trustedcoin_keep_disable': { + 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_keepordisable'] != 'disable' + else 'wallet_password', + 'accept': self.recovery_disable, + 'last': lambda d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' + }, + 'trustedcoin_tos_email': { + 'next': 'trustedcoin_show_confirm_otp' + }, + 'trustedcoin_show_confirm_otp': { + 'accept': self.on_accept_otp_secret, + 'next': 'wallet_password', + 'last': lambda d: wizard.is_single_password() or 'xprv1' in d + } + } + wizard.navmap_merge(views) + + # combined create_keystore and create_remote_key pre + def create_keys(self, wizard_data): + # wizard = self._app.daemon.newWalletWizard + # wizard = self._wizard + # wizard_data = wizard._current.wizard_data + + if 'seed' not in wizard_data: + # online continuation + xprv1, xpub1, xprv2, xpub2 = (wizard_data['xprv1'], wizard_data['xpub1'], None, wizard_data['xpub2']) + else: + xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], wizard_data['seed_extra_words']) + + data = {'x1/': {'xpub': xpub1}, 'x2/': {'xpub': xpub2}} + + # Generate third key deterministically. + long_user_id, short_id = get_user_id(data) + xtype = xpub_type(xpub1) + xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) + + return xprv1, xpub1, xprv2, xpub2, xpub3, short_id + + def on_accept_otp_secret(self, wizard_data): + self.logger.debug('OTP secret accepted, creating keystores') + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xpub(xpub2) + k3 = keystore.from_xpub(xpub3) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + wizard_data['x3/'] = k3.dump() + + def recovery_disable(self, wizard_data): + if wizard_data['trustedcoin_keepordisable'] != 'disable': + return + + self.logger.debug('2fa disabled, creating keystores') + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xprv(xprv2) + k3 = keystore.from_xpub(xpub3) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + wizard_data['x3/'] = k3.dump() + diff --git a/electrum/wizard.py b/electrum/wizard.py index a50671c3cfe1..300a1641f4fa 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -1,22 +1,32 @@ import copy import os -from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional, Union +from typing import List, NamedTuple, Any, Dict, Optional, Tuple, TYPE_CHECKING +from electrum.i18n import _ +from electrum.interface import ServerAddr +from electrum.keystore import hardware_keystore from electrum.logging import get_logger +from electrum.plugin import run_hook +from electrum.slip39 import EncryptedSeed from electrum.storage import WalletStorage, StorageEncryptionVersion from electrum.wallet_db import WalletDB from electrum.bip32 import normalize_bip32_derivation, xpub_type -from electrum import keystore -from electrum import bitcoin +from electrum import keystore, mnemonic, bitcoin from electrum.mnemonic import is_any_2fa_seed_type +if TYPE_CHECKING: + from electrum.daemon import Daemon + from electrum.plugin import Plugins + from electrum.keystore import Hardware_KeyStore + class WizardViewState(NamedTuple): view: Optional[str] wizard_data: Dict[str, Any] params: Dict[str, Any] + class AbstractWizard: # serve as a base for all UIs, so no qt # encapsulate wizard state @@ -34,9 +44,9 @@ def __init__(self): self._current = WizardViewState(None, {}, {}) self._stack = [] # type: List[WizardViewState] - def navmap_merge(self, additional_navmap): + def navmap_merge(self, additional_navmap: dict): # NOTE: only merges one level deep. Deeper dict levels will overwrite - for k,v in additional_navmap.items(): + for k, v in additional_navmap.items(): if k in self.navmap: self.navmap[k].update(v) else: @@ -49,7 +59,7 @@ def navmap_merge(self, additional_navmap): # view params are transient, meant for extra configuration of a view (e.g. info # msg in a generic choice dialog) # exception: stay on this view - def resolve_next(self, view, wizard_data): + def resolve_next(self, view: str, wizard_data: dict) -> WizardViewState: assert view self._logger.debug(f'view={view}') assert view in self.navmap @@ -59,54 +69,63 @@ def resolve_next(self, view, wizard_data): if 'accept' in nav: # allow python scope to append to wizard_data before # adding to stack or finishing - if callable(nav['accept']): - nav['accept'](wizard_data) + view_accept = nav['accept'] + if callable(view_accept): + view_accept(wizard_data) else: - self._logger.error(f'accept handler for view {view} not callable') + raise Exception(f'accept handler for view {view} is not callable') + + # make a clone for next view + wizard_data = copy.deepcopy(wizard_data) if 'next' not in nav: - # finished - self.finished(wizard_data) - return (None, wizard_data, {}) - - nexteval = nav['next'] - # simple string based next view - if isinstance(nexteval, str): - new_view = WizardViewState(nexteval, wizard_data, {}) + new_view = WizardViewState(None, wizard_data, {}) else: - # handler fn based next view - nv = nexteval(wizard_data) - self._logger.debug(repr(nv)) - - # append wizard_data and params if not returned - if isinstance(nv, str): - new_view = WizardViewState(nv, wizard_data, {}) - elif len(nv) == 1: - new_view = WizardViewState(nv[0], wizard_data, {}) - elif len(nv) == 2: - new_view = WizardViewState(nv[0], nv[1], {}) + view_next = nav['next'] + if isinstance(view_next, str): + # string literal + new_view = WizardViewState(view_next, wizard_data, {}) + elif callable(view_next): + # handler fn based + nv = view_next(wizard_data) + self._logger.debug(repr(nv)) + + # append wizard_data and params if not returned + if isinstance(nv, str): + new_view = WizardViewState(nv, wizard_data, {}) + elif len(nv) == 1: + new_view = WizardViewState(nv[0], wizard_data, {}) + elif len(nv) == 2: + new_view = WizardViewState(nv[0], nv[1], {}) + else: + new_view = nv else: - new_view = nv + raise Exception(f'next handler for view {view} is not callable nor a string literal') + + if 'params' in self.navmap[new_view.view]: + params = self.navmap[new_view.view]['params'] + assert isinstance(params, dict), 'params is not a dict' + new_view.params.update(params) + + self._logger.debug(f'resolve_next view is {new_view.view}') self._stack.append(copy.deepcopy(self._current)) self._current = new_view - self._logger.debug(f'resolve_next view is {self._current.view}') - self.log_stack(self._stack) + self.log_stack() return new_view def resolve_prev(self): - prev_view = self._stack.pop() + self._current = self._stack.pop() - self._logger.debug(f'resolve_prev view is {prev_view}') - self.log_stack(self._stack) + self._logger.debug(f'resolve_prev view is "{self._current.view}"') + self.log_stack() - self._current = prev_view - return prev_view + return self._current # check if this view is the final view - def is_last_view(self, view, wizard_data): + def is_last_view(self, view: str, wizard_data: dict) -> bool: assert view assert view in self.navmap @@ -115,57 +134,59 @@ def is_last_view(self, view, wizard_data): if 'last' not in nav: return False - lastnav = nav['last'] - # bool literal - if isinstance(lastnav, bool): - return lastnav - elif callable(lastnav): + view_last = nav['last'] + if isinstance(view_last, bool): + # bool literal + self._logger.debug(f'view "{view}" last: {view_last}') + return view_last + elif callable(view_last): # handler fn based - l = lastnav(view, wizard_data) - self._logger.debug(f'view "{view}" last: {l}') - return l + is_last = view_last(wizard_data) + self._logger.debug(f'view "{view}" last: {is_last}') + return is_last else: raise Exception(f'last handler for view {view} is not callable nor a bool literal') - def finished(self, wizard_data): - self._logger.debug('finished.') - def reset(self): self._stack = [] self._current = WizardViewState(None, {}, {}) - def log_stack(self, _stack): + def log_stack(self): logstr = 'wizard stack:' - stack = copy.deepcopy(_stack) i = 0 - for item in stack: - self.sanitize_stack_item(item.wizard_data) - logstr += f'\n{i}: {repr(item.wizard_data)}' + for item in self._stack: + ssi = self.sanitize_stack_item(item.wizard_data) + logstr += f'\n{i}: {hex(id(item.wizard_data))} - {repr(ssi)}' i += 1 + sci = self.sanitize_stack_item(self._current.wizard_data) + logstr += f'\nc: {hex(id(self._current.wizard_data))} - {repr(sci)}' self._logger.debug(logstr) - def log_state(self, _current): - current = copy.deepcopy(_current) - self.sanitize_stack_item(current) - self._logger.debug(f'wizard current: {repr(current)}') - - def sanitize_stack_item(self, _stack_item): + def sanitize_stack_item(self, _stack_item) -> dict: sensitive_keys = ['seed', 'seed_extra_words', 'master_key', 'private_key_list', 'password'] + def sanitize(_dict): + result = {} for item in _dict: if isinstance(_dict[item], dict): - sanitize(_dict[item]) + result[item] = sanitize(_dict[item]) else: if item in sensitive_keys: - _dict[item] = '' - sanitize(_stack_item) + result[item] = '' + else: + result[item] = _dict[item] + return result + return sanitize(_stack_item) + + def get_wizard_data(self) -> dict: + return copy.deepcopy(self._current.wizard_data) class NewWalletWizard(AbstractWizard): _logger = get_logger(__name__) - def __init__(self, daemon): + def __init__(self, daemon: 'Daemon', plugins: 'Plugins'): AbstractWizard.__init__(self) self.navmap = { 'wallet_name': { @@ -183,68 +204,92 @@ def __init__(self, daemon): 'confirm_seed': { 'next': self.on_have_or_confirm_seed, 'accept': self.maybe_master_pubkey, - 'last': lambda v,d: self.is_single_password() and not self.is_multisig(d) + 'last': lambda d: self.is_single_password() and not self.is_multisig(d) }, 'have_seed': { 'next': self.on_have_or_confirm_seed, 'accept': self.maybe_master_pubkey, - 'last': lambda v,d: self.is_single_password() and not self.is_bip39_seed(d) and not self.is_multisig(d) + 'last': lambda d: self.is_single_password() and not + (self.needs_derivation_path(d) or self.is_multisig(d)) }, - 'bip39_refine': { - 'next': lambda d: 'wallet_password' if not self.is_multisig(d) else 'multisig_cosigner_keystore', + 'choose_hardware_device': { + 'next': self.on_hardware_device, + }, + 'script_and_derivation': { + 'next': lambda d: self.wallet_password_view(d) if not self.is_multisig(d) else 'multisig_cosigner_keystore', 'accept': self.maybe_master_pubkey, - 'last': lambda v,d: self.is_single_password() and not self.is_multisig(d) + 'last': lambda d: self.is_single_password() and not self.is_multisig(d) }, 'have_master_key': { - 'next': lambda d: 'wallet_password' if not self.is_multisig(d) else 'multisig_cosigner_keystore', + 'next': lambda d: self.wallet_password_view(d) if not self.is_multisig(d) else 'multisig_cosigner_keystore', 'accept': self.maybe_master_pubkey, - 'last': lambda v,d: self.is_single_password() and not self.is_multisig(d) + 'last': lambda d: self.is_single_password() and not self.is_multisig(d) }, 'multisig': { 'next': 'keystore_type' }, - 'multisig_cosigner_keystore': { # this view should set 'multisig_current_cosigner' + 'multisig_cosigner_keystore': { # this view should set 'multisig_current_cosigner' 'next': self.on_cosigner_keystore_type }, 'multisig_cosigner_key': { - 'next': lambda d: 'wallet_password' if self.has_all_cosigner_data(d) else 'multisig_cosigner_keystore', - 'last': lambda v,d: self.is_single_password() and self.has_all_cosigner_data(d) + 'next': lambda d: self.wallet_password_view(d) if self.last_cosigner(d) else 'multisig_cosigner_keystore', + 'last': lambda d: self.is_single_password() and self.last_cosigner(d) }, 'multisig_cosigner_seed': { 'next': self.on_have_cosigner_seed, - 'last': lambda v,d: self.is_single_password() and self.has_all_cosigner_data(d) + 'last': lambda d: self.is_single_password() and self.last_cosigner(d) and not self.needs_derivation_path(d) + }, + 'multisig_cosigner_hardware': { + 'next': self.on_hardware_device, }, - 'multisig_cosigner_bip39_refine': { - 'next': lambda d: 'wallet_password' if self.has_all_cosigner_data(d) else 'multisig_cosigner_keystore', - 'last': lambda v,d: self.is_single_password() and self.has_all_cosigner_data(d) + 'multisig_cosigner_script_and_derivation': { + 'next': lambda d: self.wallet_password_view(d) if self.last_cosigner(d) else 'multisig_cosigner_keystore', + 'last': lambda d: self.is_single_password() and self.last_cosigner(d) }, 'imported': { 'next': 'wallet_password', - 'last': lambda v,d: self.is_single_password() + 'last': lambda d: self.is_single_password() }, 'wallet_password': { 'last': True + }, + 'wallet_password_hardware': { + 'last': True } } self._daemon = daemon + self.plugins = plugins - def start(self, initial_data=None): + def start(self, initial_data: dict = None) -> WizardViewState: if initial_data is None: initial_data = {} self.reset() self._current = WizardViewState('wallet_name', initial_data, {}) return self._current - def is_single_password(self): + def is_single_password(self) -> bool: raise NotImplementedError() - def is_bip39_seed(self, wizard_data): - return wizard_data.get('seed_variant') == 'bip39' + # returns (sub)dict of current cosigner (or root if first) + def current_cosigner(self, wizard_data: dict) -> dict: + wdata = wizard_data + if wizard_data.get('wallet_type') == 'multisig' and 'multisig_current_cosigner' in wizard_data: + cosigner = wizard_data['multisig_current_cosigner'] + wdata = wizard_data['multisig_cosigner_data'][str(cosigner)] + return wdata + + def needs_derivation_path(self, wizard_data: dict) -> bool: + wdata = self.current_cosigner(wizard_data) + return 'seed_variant' in wdata and wdata['seed_variant'] in ['bip39', 'slip39'] - def is_multisig(self, wizard_data): + def wants_ext(self, wizard_data: dict) -> bool: + wdata = self.current_cosigner(wizard_data) + return 'seed_variant' in wdata and wdata['seed_extend'] + + def is_multisig(self, wizard_data: dict) -> bool: return wizard_data['wallet_type'] == 'multisig' - def on_wallet_type(self, wizard_data): + def on_wallet_type(self, wizard_data: dict) -> str: t = wizard_data['wallet_type'] return { 'standard': 'keystore_type', @@ -253,75 +298,88 @@ def on_wallet_type(self, wizard_data): 'imported': 'imported' }.get(t) - def on_keystore_type(self, wizard_data): + def on_keystore_type(self, wizard_data: dict) -> str: t = wizard_data['keystore_type'] return { 'createseed': 'create_seed', 'haveseed': 'have_seed', - 'masterkey': 'have_master_key' + 'masterkey': 'have_master_key', + 'hardware': 'choose_hardware_device' }.get(t) - def on_have_or_confirm_seed(self, wizard_data): - if self.is_bip39_seed(wizard_data): - return 'bip39_refine' + def is_hardware(self, wizard_data: dict) -> bool: + return wizard_data['keystore_type'] == 'hardware' + + def wallet_password_view(self, wizard_data: dict) -> str: + if self.is_hardware(wizard_data) and wizard_data['wallet_type'] == 'standard': + return 'wallet_password_hardware' + return 'wallet_password' + + def on_hardware_device(self, wizard_data: dict, new_wallet=True) -> str: + _type, _info = wizard_data['hardware_device'] + run_hook('init_wallet_wizard', self) # TODO: currently only used for hww, hook name might be confusing + plugin = self.plugins.get_plugin(_type) + return plugin.wizard_entry_for_device(_info, new_wallet=new_wallet) + + def on_have_or_confirm_seed(self, wizard_data: dict) -> str: + if self.needs_derivation_path(wizard_data): + return 'script_and_derivation' elif self.is_multisig(wizard_data): return 'multisig_cosigner_keystore' else: return 'wallet_password' - def maybe_master_pubkey(self, wizard_data): - self._logger.info('maybe_master_pubkey') - if self.is_bip39_seed(wizard_data) and 'derivation_path' not in wizard_data: - self._logger.info('maybe_master_pubkey2') + def maybe_master_pubkey(self, wizard_data: dict): + self._logger.debug('maybe_master_pubkey') + if self.needs_derivation_path(wizard_data) and 'derivation_path' not in wizard_data: + self._logger.debug('deferred, missing derivation_path') return wizard_data['multisig_master_pubkey'] = self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key() - def on_cosigner_keystore_type(self, wizard_data): + def on_cosigner_keystore_type(self, wizard_data: dict) -> str: t = wizard_data['cosigner_keystore_type'] return { - 'key': 'multisig_cosigner_key', - 'seed': 'multisig_cosigner_seed' + 'masterkey': 'multisig_cosigner_key', + 'haveseed': 'multisig_cosigner_seed', + 'hardware': 'multisig_cosigner_hardware' }.get(t) - def on_have_cosigner_seed(self, wizard_data): - current_cosigner_data = wizard_data['multisig_cosigner_data'][str(wizard_data['multisig_current_cosigner'])] - if self.has_all_cosigner_data(wizard_data): + def on_have_cosigner_seed(self, wizard_data: dict) -> str: + current_cosigner = self.current_cosigner(wizard_data) + if self.needs_derivation_path(wizard_data) and 'derivation_path' not in current_cosigner: + return 'multisig_cosigner_script_and_derivation' + elif self.last_cosigner(wizard_data): return 'wallet_password' - elif current_cosigner_data['seed_type'] == 'bip39' and 'derivation_path' not in current_cosigner_data: - return 'multisig_cosigner_bip39_refine' else: return 'multisig_cosigner_keystore' - def has_all_cosigner_data(self, wizard_data): - # number of items in multisig_cosigner_data is less than participants? - if len(wizard_data['multisig_cosigner_data']) < (wizard_data['multisig_participants'] - 1): - return False + def last_cosigner(self, wizard_data: dict) -> bool: + # check if we have the final number of cosigners. Doesn't check if cosigner data itself is complete + # (should be validated by wizardcomponents) + if not self.is_multisig(wizard_data): + return True - # if last cosigner uses bip39 seed, we still need derivation path - current_cosigner_data = wizard_data['multisig_cosigner_data'][str(wizard_data['multisig_current_cosigner'])] - if 'seed_type' in current_cosigner_data and current_cosigner_data['seed_type'] == 'bip39' and 'derivation_path' not in current_cosigner_data: + if len(wizard_data['multisig_cosigner_data']) < (wizard_data['multisig_participants'] - 1): return False return True - def has_duplicate_masterkeys(self, wizard_data) -> bool: + def has_duplicate_masterkeys(self, wizard_data: dict) -> bool: """Multisig wallets need distinct master keys. If True, need to prevent wallet-creation.""" - xpubs = [] - xpubs.append(self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key()) + xpubs = [self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key()] for cosigner in wizard_data['multisig_cosigner_data']: data = wizard_data['multisig_cosigner_data'][cosigner] xpubs.append(self.keystore_from_data(wizard_data['wallet_type'], data).get_master_public_key()) assert xpubs return len(xpubs) != len(set(xpubs)) - def has_heterogeneous_masterkeys(self, wizard_data) -> bool: + def has_heterogeneous_masterkeys(self, wizard_data: dict) -> bool: """Multisig wallets need homogeneous master keys. All master keys need to be bip32, and e.g. Ypub cannot be mixed with Zpub. If True, need to prevent wallet-creation. """ - xpubs = [] - xpubs.append(self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key()) + xpubs = [self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key()] for cosigner in wizard_data['multisig_cosigner_data']: data = wizard_data['multisig_cosigner_data'][cosigner] xpubs.append(self.keystore_from_data(wizard_data['wallet_type'], data).get_master_public_key()) @@ -339,8 +397,8 @@ def has_heterogeneous_masterkeys(self, wizard_data) -> bool: return True return False - def keystore_from_data(self, wallet_type, data): - if 'seed' in data: + def keystore_from_data(self, wallet_type: str, data: dict): + if data['keystore_type'] in ['createseed', 'haveseed'] and 'seed' in data: if data['seed_variant'] == 'electrum': return keystore.from_seed(data['seed'], data['seed_extra_words'], True) elif data['seed_variant'] == 'bip39': @@ -351,18 +409,104 @@ def keystore_from_data(self, wallet_type, data): else: script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' return keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) + elif data['seed_variant'] == 'slip39': + root_seed = data['seed'].decrypt(data['seed_extra_words']) + derivation = normalize_bip32_derivation(data['derivation_path']) + if wallet_type == 'multisig': + script = data['script_type'] if data['script_type'] != 'p2sh' else 'standard' + else: + script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' + return keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) else: raise Exception('Unsupported seed variant %s' % data['seed_variant']) - elif 'master_key' in data: + elif data['keystore_type'] == 'masterkey' and 'master_key' in data: return keystore.from_master_key(data['master_key']) + elif data['keystore_type'] == 'hardware': + return self.hw_keystore(data) else: raise Exception('no seed or master_key in data') - def finished(self, wizard_data): - self._logger.debug('finished') - # override + def is_current_cosigner_hardware(self, wizard_data: dict) -> bool: + cosigner_data = self.current_cosigner(wizard_data) + cosigner_is_hardware = cosigner_data == wizard_data and wizard_data['keystore_type'] == 'hardware' + if 'cosigner_keystore_type' in wizard_data and wizard_data['cosigner_keystore_type'] == 'hardware': + cosigner_is_hardware = True + return cosigner_is_hardware + + def check_multisig_constraints(self, wizard_data: dict) -> Tuple[bool, str]: + if not self.is_multisig(wizard_data): + return True, '' + + # current cosigner might be incomplete. In that case, return valid + cosigner_data = self.current_cosigner(wizard_data) + if self.needs_derivation_path(wizard_data): + if 'derivation_path' not in cosigner_data: + self.logger.debug('defer multisig check: missing derivation_path') + return True, '' + if self.wants_ext(wizard_data): + if 'seed_extra_words' not in cosigner_data: + self.logger.debug('defer multisig check: missing extra words') + return True, '' + if self.is_current_cosigner_hardware(wizard_data): + if 'master_key' not in cosigner_data: + self._logger.debug('defer multisig check: missing master_key') + return True, '' + + user_info = '' + + if self.has_duplicate_masterkeys(wizard_data): + self._logger.debug('Duplicate master keys!') + user_info = _('Duplicate master keys') + multisig_keys_valid = False + elif self.has_heterogeneous_masterkeys(wizard_data): + self._logger.debug('Heterogenous master keys!') + user_info = _('Heterogenous master keys') + multisig_keys_valid = False + else: + multisig_keys_valid = True + + return multisig_keys_valid, user_info + + def validate_seed(self, seed: str, seed_variant: str, wallet_type: str): + seed_type = '' + seed_valid = False + validation_message = '' + + if seed_variant == 'electrum': + seed_type = mnemonic.seed_type(seed) + if seed_type != '': + seed_valid = True + elif seed_variant == 'bip39': + is_checksum, is_wordlist = keystore.bip39_is_checksum_valid(seed) + status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' + validation_message = 'BIP39 (%s)' % status + + if is_checksum: + seed_type = 'bip39' + seed_valid = True + elif seed_variant == 'slip39': + # seed shares should be already validated by wizard page, we have a combined encrypted seed + if seed and isinstance(seed, EncryptedSeed): + seed_valid = True + seed_type = 'slip39' + else: + seed_valid = False + else: + raise Exception(f'unknown seed variant {seed_variant}') + + # check if seed matches wallet type + if wallet_type == '2fa' and not is_any_2fa_seed_type(seed_type): + seed_valid = False + elif wallet_type == 'standard' and seed_type not in ['old', 'standard', 'segwit', 'bip39', 'slip39']: + seed_valid = False + elif wallet_type == 'multisig' and seed_type not in ['standard', 'segwit', 'bip39', 'slip39']: + seed_valid = False + + self._logger.debug(f'seed verified: {seed_valid}, type={seed_type}, validation_message={validation_message}') - def create_storage(self, path, data): + return seed_valid, seed_type, validation_message + + def create_storage(self, path: str, data: dict): assert data['wallet_type'] in ['standard', '2fa', 'imported', 'multisig'] if os.path.exists(path): @@ -389,9 +533,12 @@ def create_storage(self, path, data): if data['seed_type'] in ['old', 'standard', 'segwit']: self._logger.debug('creating keystore from electrum seed') k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') - elif data['seed_type'] == 'bip39': - self._logger.debug('creating keystore from bip39 seed') - root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) + elif data['seed_type'] in ['bip39', 'slip39']: + self._logger.debug('creating keystore from %s seed' % data['seed_type']) + if data['seed_type'] == 'bip39': + root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) + else: + root_seed = data['seed'].decrypt(data['seed_extra_words']) derivation = normalize_bip32_derivation(data['derivation_path']) if data['wallet_type'] == 'multisig': script = data['script_type'] if data['script_type'] != 'p2sh' else 'standard' @@ -416,14 +563,29 @@ def create_storage(self, path, data): elif isinstance(k, keystore.Old_KeyStore): pass else: - raise Exception(f"unexpected keystore type: {type(keystore)}") + raise Exception(f'unexpected keystore type: {type(k)}') + elif data['keystore_type'] == 'hardware': + k = self.hw_keystore(data) + if isinstance(k, keystore.Xpub): # has xpub + t1 = xpub_type(k.xpub) + if data['wallet_type'] == 'multisig': + if t1 not in ['standard', 'p2wsh', 'p2wsh-p2sh']: + raise Exception('wrong key type %s' % t1) + else: + if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: + raise Exception('wrong key type %s' % t1) + else: + raise Exception(f'unexpected keystore type: {type(k)}') else: raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) if data['encrypt']: if k and k.may_have_password(): k.update_password(None, data['password']) - storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) + enc_version = StorageEncryptionVersion.USER_PASSWORD + if data.get('keystore_type') == 'hardware' and data['wallet_type'] == 'standard': + enc_version = StorageEncryptionVersion.XPUB_PASSWORD + storage.set_password(data['password'], enc_version=enc_version) db = WalletDB('', storage=storage, manual_upgrades=False) db.set_keystore_encryption(bool(data['password']) and data['encrypt']) @@ -436,27 +598,28 @@ def create_storage(self, path, data): db.put('keystore', k.dump()) elif data['wallet_type'] == '2fa': db.put('x1/', k.dump()) - if data['trustedcoin_keepordisable'] == 'disable': + if 'trustedcoin_keepordisable' in data and data['trustedcoin_keepordisable'] == 'disable': k2 = keystore.from_xprv(data['x2/']['xprv']) if data['encrypt'] and k2.may_have_password(): k2.update_password(None, data['password']) db.put('x2/', k2.dump()) else: db.put('x2/', data['x2/']) - db.put('x3/', data['x3/']) + if 'x3/' in data: + db.put('x3/', data['x3/']) db.put('use_trustedcoin', True) elif data['wallet_type'] == 'multisig': if not isinstance(k, keystore.Xpub): - raise Exception(f"unexpected keystore(main) type={type(k)} in multisig. not bip32.") + raise Exception(f'unexpected keystore(main) type={type(k)} in multisig. not bip32.') k_xpub_type = xpub_type(k.xpub) - db.put('wallet_type', '%dof%d' % (data['multisig_signatures'],data['multisig_participants'])) + db.put('wallet_type', '%dof%d' % (data['multisig_signatures'], data['multisig_participants'])) db.put('x1/', k.dump()) for cosigner in data['multisig_cosigner_data']: cosigner_keystore = self.keystore_from_data('multisig', data['multisig_cosigner_data'][cosigner]) if not isinstance(cosigner_keystore, keystore.Xpub): - raise Exception(f"unexpected keystore(cosigner) type={type(cosigner_keystore)} in multisig. not bip32.") + raise Exception(f'unexpected keystore(cosigner) type={type(cosigner_keystore)} in multisig. not bip32.') if k_xpub_type != xpub_type(cosigner_keystore.xpub): - raise Exception("multisig wallet needs to have homogeneous xpub types") + raise Exception('multisig wallet needs to have homogeneous xpub types') if data['encrypt'] and cosigner_keystore.may_have_password(): cosigner_keystore.update_password(None, data['password']) db.put(f'x{cosigner}/', cosigner_keystore.dump()) @@ -471,30 +634,74 @@ def create_storage(self, path, data): db.load_plugins() db.write() + def hw_keystore(self, data: dict) -> 'Hardware_KeyStore': + return hardware_keystore({ + 'type': 'hardware', + 'hw_type': data['hw_type'], + 'derivation': data['derivation_path'], + 'root_fingerprint': data['root_fingerprint'], + 'xpub': data['master_key'], + 'label': data['label'], + 'soft_device_id': data['soft_device_id'] + }) + + class ServerConnectWizard(AbstractWizard): _logger = get_logger(__name__) - def __init__(self, daemon): + def __init__(self, daemon: 'Daemon'): AbstractWizard.__init__(self) self.navmap = { 'autoconnect': { 'next': 'server_config', - 'last': lambda v,d: d['autoconnect'] + 'accept': self.do_configure_autoconnect, + 'last': lambda d: d['autoconnect'] }, 'proxy_ask': { 'next': lambda d: 'proxy_config' if d['want_proxy'] else 'autoconnect' }, 'proxy_config': { - 'next': 'autoconnect' + 'next': 'autoconnect', + 'accept': self.do_configure_proxy }, 'server_config': { + 'accept': self.do_configure_server, 'last': True } } self._daemon = daemon - def start(self, initial_data=None): + def do_configure_proxy(self, wizard_data: dict): + proxy_settings = wizard_data['proxy'] + if not self._daemon.network: + self._logger.debug('not configuring proxy, electrum config wants offline mode') + return + self._logger.debug(f'configuring proxy: {proxy_settings!r}') + net_params = self._daemon.network.get_parameters() + if not proxy_settings['enabled']: + proxy_settings = None + net_params = net_params._replace(proxy=proxy_settings) + self._daemon.network.run_from_another_thread(self._daemon.network.set_parameters(net_params)) + + def do_configure_server(self, wizard_data: dict): + self._logger.debug(f'configuring server: {wizard_data!r}') + net_params = self._daemon.network.get_parameters() + try: + server = ServerAddr.from_str_with_inference(wizard_data['server']) + if not server: + raise Exception('failed to parse server %s' % wizard_data['server']) + except Exception: + return + net_params = net_params._replace(server=server, auto_connect=wizard_data['autoconnect']) + self._daemon.network.run_from_another_thread(self._daemon.network.set_parameters(net_params)) + + def do_configure_autoconnect(self, wizard_data: dict): + self._logger.debug(f'configuring autoconnect: {wizard_data!r}') + if self._daemon.config.cv.NETWORK_AUTO_CONNECT.is_modifiable(): + self._daemon.config.NETWORK_AUTO_CONNECT = wizard_data['autoconnect'] + + def start(self, initial_data: dict = None) -> WizardViewState: if initial_data is None: initial_data = {} self.reset()