Browse Source

Merge pull request #8560 from accumulator/qtwizard

new wizard Qt desktop client
master
ThomasV 2 years ago committed by GitHub
parent
commit
8be3c4dadd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 738
      electrum/base_wizard.py
  2. 0
      electrum/gui/common_qt/__init__.py
  3. 6
      electrum/gui/common_qt/plugins.py
  4. 4
      electrum/gui/qml/components/WalletMainView.qml
  5. 8
      electrum/gui/qml/components/wizard/WCCosignerKeystore.qml
  6. 2
      electrum/gui/qml/components/wizard/WCCreateSeed.qml
  7. 3
      electrum/gui/qml/components/wizard/WCHaveSeed.qml
  8. 0
      electrum/gui/qml/components/wizard/WCScriptAndDerivation.qml
  9. 8
      electrum/gui/qml/qeapp.py
  10. 14
      electrum/gui/qml/qedaemon.py
  11. 3
      electrum/gui/qml/qewalletdb.py
  12. 26
      electrum/gui/qml/qewizard.py
  13. 101
      electrum/gui/qt/__init__.py
  14. 797
      electrum/gui/qt/installwizard.py
  15. 2
      electrum/gui/qt/main_window.py
  16. 181
      electrum/gui/qt/network_dialog.py
  17. 3
      electrum/gui/qt/password_dialog.py
  18. 13
      electrum/gui/qt/seed_dialog.py
  19. 54
      electrum/gui/qt/util.py
  20. 0
      electrum/gui/qt/wizard/__init__.py
  21. 93
      electrum/gui/qt/wizard/server_connect.py
  22. 1401
      electrum/gui/qt/wizard/wallet.py
  23. 287
      electrum/gui/qt/wizard/wizard.py
  24. 2
      electrum/keystore.py
  25. 88
      electrum/plugins/bitbox02/bitbox02.py
  26. 93
      electrum/plugins/bitbox02/qt.py
  27. 70
      electrum/plugins/coldcard/coldcard.py
  28. 24
      electrum/plugins/coldcard/qt.py
  29. 110
      electrum/plugins/digitalbitbox/digitalbitbox.py
  30. 80
      electrum/plugins/digitalbitbox/qt.py
  31. 80
      electrum/plugins/hw_wallet/plugin.py
  32. 24
      electrum/plugins/hw_wallet/qt.py
  33. 50
      electrum/plugins/jade/jade.py
  34. 31
      electrum/plugins/jade/qt.py
  35. 108
      electrum/plugins/keepkey/keepkey.py
  36. 230
      electrum/plugins/keepkey/qt.py
  37. 2
      electrum/plugins/labels/qml.py
  38. 45
      electrum/plugins/ledger/ledger.py
  39. 24
      electrum/plugins/ledger/qt.py
  40. 3
      electrum/plugins/safe_t/clientbase.py
  41. 222
      electrum/plugins/safe_t/qt.py
  42. 108
      electrum/plugins/safe_t/safe_t.py
  43. 249
      electrum/plugins/trezor/qt.py
  44. 114
      electrum/plugins/trezor/trezor.py
  45. 257
      electrum/plugins/trustedcoin/common_qt.py
  46. 324
      electrum/plugins/trustedcoin/qml.py
  47. 420
      electrum/plugins/trustedcoin/qt.py
  48. 295
      electrum/plugins/trustedcoin/trustedcoin.py
  49. 479
      electrum/wizard.py

738
electrum/base_wizard.py

@ -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()

0
electrum/gui/common_qt/__init__.py

6
electrum/gui/qml/plugins.py → 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 @@ class PluginQObject(QObject):
@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()

4
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) {

8
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')
}
}

2
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

3
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

0
electrum/gui/qml/components/wizard/WCBIP39Refine.qml → electrum/gui/qml/components/wizard/WCScriptAndDerivation.qml

8
electrum/gui/qml/qeapp.py

@ -5,10 +5,9 @@ import os
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 @@ if 'ANDROID_DATA' in os.environ:
notification = None
class QEAppController(BaseCrashReporter, QObject):
_dummy = pyqtSignal()
userNotify = pyqtSignal(str, str)
@ -319,6 +319,7 @@ class QEAppController(BaseCrashReporter, QObject):
self._secureWindow = secure
self.secureWindowChanged.emit()
class ElectrumQmlApplication(QGuiApplication):
_valid = True
@ -376,7 +377,7 @@ class ElectrumQmlApplication(QGuiApplication):
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 @@ class ElectrumQmlApplication(QGuiApplication):
return
self.logger.warning(file)
class Exception_Hook(QObject, Logger):
_report_exception = pyqtSignal(object, object, object, object)

14
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 PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
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 .qewallet import QEWallet
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 @@ class QEWalletListModel(QAbstractListModel):
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 @@ class QEDaemon(AuthMixin, QObject):
@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

3
electrum/gui/qml/qewalletdb.py

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING
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 @@ class QEWalletDB(QObject):
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')

26
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 @@ class QEAbstractWizard(QObject):
@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 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard):
'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 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard):
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 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard):
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 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard):
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({

101
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 @@ except Exception as e:
"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 @@ except ImportError as e:
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.logging import Logger
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 @@ class ElectrumGui(BaseElectrumGui, Logger):
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 @@ class ElectrumGui(BaseElectrumGui, Logger):
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 @@ class ElectrumGui(BaseElectrumGui, Logger):
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 @@ class ElectrumGui(BaseElectrumGui, Logger):
self.init_network()
except UserCancelled:
return
except GoBack:
return
except Exception as e:
self.logger.exception('')
return

797
electrum/gui/qt/installwizard.py

@ -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<self.m else Qt.gray)
qp.drawPie(self.R, alpha, alpha2)
qp.end()
def wizard_dialog(func):
def func_wrapper(*args, **kwargs):
run_next = kwargs['run_next']
wizard = args[0] # type: InstallWizard
while True:
#wizard.logger.debug(f"dialog stack. len: {len(wizard._stack)}. stack: {wizard._stack}")
wizard.back_button.setText(_('Back') if wizard.can_go_back() else _('Cancel'))
# current dialog
try:
out = func(*args, **kwargs)
if type(out) is not tuple:
out = (out,)
except GoBack:
if not wizard.can_go_back():
wizard.close()
raise UserCancelled
else:
# to go back from the current dialog, we just let the caller unroll the stack:
raise
# next dialog
try:
while True:
try:
run_next(*out)
except ReRunDialog:
# restore state, and then let the loop re-run next
wizard.go_back(rerun_previous=False)
else:
break
except GoBack as e:
# to go back from the next dialog, we ask the wizard to restore state
wizard.go_back(rerun_previous=False)
# and we re-run the current dialog
if wizard.can_go_back():
# also rerun any calculations that might have populated the inputs to the current dialog,
# by going back to just after the *previous* dialog finished
raise ReRunDialog() from e
else:
continue
else:
break
return func_wrapper
class WalletAlreadyOpenInMemory(Exception):
def __init__(self, wallet: Abstract_Wallet):
super().__init__()
self.wallet = wallet
# WindowModalDialog must come first as it overrides show_error
class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
accept_signal = pyqtSignal()
def __init__(self, config: 'SimpleConfig', app: QApplication, plugins: 'Plugins', *, gui_object: 'ElectrumGui'):
QDialog.__init__(self, None)
BaseWizard.__init__(self, config, plugins)
self.setWindowTitle('Electrum - ' + _('Install Wizard'))
self.app = app
self.config = config
self.gui_thread = gui_object.gui_thread
self.setMinimumSize(600, 400)
self.accept_signal.connect(self.accept)
self.title = QLabel()
self.main_widget = QWidget()
self.back_button = QPushButton(_("Back"), self)
self.back_button.setText(_('Back') if self.can_go_back() else _('Cancel'))
self.next_button = QPushButton(_("Next"), self)
self.next_button.setDefault(True)
self.logo = QLabel()
self.please_wait = QLabel(_("Please wait..."))
self.please_wait.setAlignment(Qt.AlignCenter)
self.icon_filename = None
self.loop = QEventLoop()
self.rejected.connect(lambda: self.loop.exit(0))
self.back_button.clicked.connect(lambda: self.loop.exit(1))
self.next_button.clicked.connect(lambda: self.loop.exit(2))
outer_vbox = QVBoxLayout(self)
inner_vbox = QVBoxLayout()
inner_vbox.addWidget(self.title)
inner_vbox.addWidget(self.main_widget)
inner_vbox.addStretch(1)
inner_vbox.addWidget(self.please_wait)
inner_vbox.addStretch(1)
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.set_icon('electrum.png')
self.show()
self.raise_()
self.refresh_gui() # Need for QT on MacOSX. Lame.
def select_storage(self, path, get_wallet_from_daemon) -> 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("<b>%s</b>"%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)

2
electrum/gui/qt/main_window.py

@ -90,7 +90,7 @@ from .util import (read_QIcon, ColorScheme, text_dialog, icon_path, WaitingDialo
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

181
electrum/gui/qt/network_dialog.py

@ -95,9 +95,12 @@ class NodesListWidget(QTreeWidget):
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 @@ class NodesListWidget(QTreeWidget):
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 @@ class NodesListWidget(QTreeWidget):
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 @@ class NodesListWidget(QTreeWidget):
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 @@ class NodesListWidget(QTreeWidget):
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 @@ class NetworkChoiceLayout(object):
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 @@ class NetworkChoiceLayout(object):
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 @@ class TorDetector(QThread):
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))

3
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 @@ class PasswordLayout(object):
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

13
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 @@ if TYPE_CHECKING:
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([
"<p>",
@ -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 @@ class SeedLayout(QVBoxLayout):
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,

54
electrum/gui/qt/util.py

@ -38,7 +38,6 @@ from electrum.qrreader import MissingQrDetectionLib
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 @@ class ChoicesLayout(object):
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 @@ class ImageGraphicsEffect(QObject):
return result
class QtEventListener(EventListener):
qt_callback_signal = QtCore.pyqtSignal(tuple)
def register_callbacks(self):
@ -1302,13 +1342,17 @@ class QtEventListener(EventListener):
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)

0
electrum/gui/qt/wizard/__init__.py

93
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()

1401
electrum/gui/qt/wizard/wallet.py

File diff suppressed because it is too large Load Diff

287
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'<b>{page.title}</b>' 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

2
electrum/keystore.py

@ -58,6 +58,8 @@ if TYPE_CHECKING:
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

88
electrum/plugins/bitbox02/bitbox02.py

@ -9,13 +9,11 @@ from electrum import bip32, constants
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 @@ import electrum.ecc as ecc
from ..hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase
if TYPE_CHECKING:
from electrum.wizard import NewWalletWizard
_logger = get_logger(__name__)
@ -31,13 +31,8 @@ _logger = get_logger(__name__)
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 @@ except ImportError as e:
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 @@ class BitBox02Client(HardwareClientBase):
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 @@ class BitBox02Client(HardwareClientBase):
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 @@ class BitBox02Client(HardwareClientBase):
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 @@ class BitBox02Client(HardwareClientBase):
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 @@ class BitBox02Client(HardwareClientBase):
)
return signature
class BitBox02_KeyStore(Hardware_KeyStore):
hw_type = "bitbox02"
device = "BitBox02"
@ -597,7 +596,6 @@ class BitBox02_KeyStore(Hardware_KeyStore):
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 @@ class BitBox02_KeyStore(Hardware_KeyStore):
try:
self.handler.show_message("Authorize Transaction...")
client.sign_transaction(self, tx, self.handler.get_wallet())
finally:
self.handler.finished()
@ -636,6 +633,7 @@ class BitBox02_KeyStore(Hardware_KeyStore):
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 @@ class BitBox02Plugin(HW_PluginBase):
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 @@ class BitBox02Plugin(HW_PluginBase):
# 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)

93
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 @@ class Plugin(BitBox02Plugin, QtPluginBase):
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 @@ class BitBox02_Handler(QtHandlerBase):
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 @@ class BitBox02_Handler(QtHandlerBase):
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()

70
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.keystore import Hardware_KeyStore, KeyStoreWithMPK
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 @@ CKCC_SIMULATED_PID = CKCC_PID ^ 0x55aa
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 @@ class CKCCClient(HardwareClientBase):
# 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 '<CKCCClient: xfp=%s label=%r>' % (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 @@ class CKCCClient(HardwareClientBase):
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 @@ class CKCCClient(HardwareClientBase):
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 @@ class CKCCClient(HardwareClientBase):
_('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 @@ class Coldcard_KeyStore(Hardware_KeyStore):
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 @@ class ColdcardPlugin(HW_PluginBase):
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 @@ class ColdcardPlugin(HW_PluginBase):
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)

24
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 @@ class Plugin(ColdcardPlugin, QtPluginBase):
# - 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")

110
electrum/plugins/digitalbitbox/digitalbitbox.py

@ -15,6 +15,7 @@ import struct
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.transaction import Transaction, PartialTransaction, PartialTxInput
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 @@ except ImportError as e:
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
else:
return password.encode('utf8')
def password_dialog(self, msg):
while True:
password = self.handler.get_passphrase(msg, False)
@ -172,7 +181,7 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
]
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 @@ class DigitalBitbox_Client(HardwareClientBase):
]
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_Client(HardwareClientBase):
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 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore):
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 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore):
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 @@ class DigitalBitboxPlugin(HW_PluginBase):
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 @@ class DigitalBitboxPlugin(HW_PluginBase):
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 @@ class DigitalBitboxPlugin(HW_PluginBase):
_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 @@ class DigitalBitboxPlugin(HW_PluginBase):
"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)

80
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 @@ class Plugin(DigitalBitboxPlugin, QtPluginBase):
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()

80
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 @@ class HW_PluginBase(BasePlugin):
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 @@ class HW_PluginBase(BasePlugin):
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 @@ class HW_PluginBase(BasePlugin):
"""
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 @@ class HardwareClientBase:
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 @@ class HardwareClientBase:
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 @@ class HardwareClientBase:
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 @@ class OutdatedHwFirmwareException(UserFacingException):
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

24
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.password_dialog import PasswordLayout, PW_PASSPHRASE
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 @@ from .plugin import OutdatedHwFirmwareException, HW_PluginBase, HardwareHandlerB
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 @@ class QtHandlerBase(HardwareHandlerBase, QObject, Logger):
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 @@ class QtPluginBase(object):
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 @@ class QtPluginBase(object):
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 @@ class QtPluginBase(object):
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 @@ class QtPluginBase(object):
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 @@ class QtPluginBase(object):
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()

50
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.keystore import Hardware_KeyStore
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 @@ class JadePlugin(HW_PluginBase):
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 @@ class JadePlugin(HW_PluginBase):
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)

31
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 @@ class Plugin(JadePlugin, QtPluginBase):
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')

108
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 @@ from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validat
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 @@ class KeepKeyPlugin(HW_PluginBase):
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 @@ class KeepKeyPlugin(HW_PluginBase):
)
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 @@ class KeepKeyPlugin(HW_PluginBase):
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)

230
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 @@ CHARACTER_RECOVERY = (
"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 @@ class CharacterDialog(WindowModalDialog):
class QtHandler(QtHandlerBase):
char_signal = pyqtSignal(object)
pin_signal = pyqtSignal(object, object)
close_char_dialog_signal = pyqtSignal()
@ -188,7 +195,6 @@ class QtHandler(QtHandlerBase):
self.done.set()
class QtPlugin(QtPluginBase):
# Derived classes must provide the following class-static variables:
# icon_file
@ -215,87 +221,93 @@ class QtPlugin(QtPluginBase):
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 @@ class Plugin(KeepKeyPlugin, QtPlugin):
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 @@ class SettingsDialog(WindowModalDialog):
# 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

2
electrum/plugins/labels/qml.py

@ -6,7 +6,7 @@ from electrum.i18n import _
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

45
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 electrum.wallet import Standard_Wallet
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 @@ class LedgerPlugin(HW_PluginBase):
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 @@ class LedgerPlugin(HW_PluginBase):
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)

24
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 @@ class Plugin(LedgerPlugin, QtPluginBase):
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)

3
electrum/plugins/safe_t/clientbase.py

@ -117,6 +117,9 @@ class SafeTClientBase(HardwareClientBase, GuiMixin, Logger):
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)

222
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 @@ from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton,
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 @@ class QtPlugin(QtPluginBase):
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 @@ class Plugin(SafeTPlugin, QtPlugin):
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 @@ class SettingsDialog(WindowModalDialog):
# 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

108
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 @@ class SafeTPlugin(HW_PluginBase):
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 @@ class SafeTPlugin(HW_PluginBase):
)
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 @@ class SafeTPlugin(HW_PluginBase):
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)

249
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 @@ class QtPlugin(QtPluginBase):
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 @@ class QtPlugin(QtPluginBase):
# 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 @@ class QtPlugin(QtPluginBase):
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 @@ class QtPlugin(QtPluginBase):
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 @@ class QtPlugin(QtPluginBase):
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 @@ class QtPlugin(QtPluginBase):
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 @@ class Plugin(TrezorPlugin, QtPlugin):
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 @@ class SettingsDialog(WindowModalDialog):
# 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

114
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 @@ class TrezorPlugin(HW_PluginBase):
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 @@ class TrezorPlugin(HW_PluginBase):
)
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 @@ class TrezorPlugin(HW_PluginBase):
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)

257
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()

324
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 @@ class Plugin(TrustedCoinPlugin):
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 @@ class Plugin(TrustedCoinPlugin):
qewallet = QEWallet.getInstanceFor(wallet)
qewallet.billingInfoChanged.emit()
self.so.updateBillingInfo(wallet)

420
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 @@ class Plugin(TrustedCoinPlugin):
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 @@ class Plugin(TrustedCoinPlugin):
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()

295
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.wallet import Multisig_Wallet, Deterministic_Wallet
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 @@ MOBILE_DISCLAIMER = [
"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 @@ class TrustedCoinCosignerClient(Logger):
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 get_user_id(db):
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 @@ class TrustedCoinPlugin(BasePlugin):
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 @@ class TrustedCoinPlugin(BasePlugin):
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 @@ class TrustedCoinPlugin(BasePlugin):
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()

479
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 @@ class AbstractWizard:
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 @@ class AbstractWizard:
# 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 @@ class AbstractWizard:
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 @@ class AbstractWizard:
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] = '<sensitive value removed>'
sanitize(_stack_item)
result[item] = '<sensitive value removed>'
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 @@ class NewWalletWizard(AbstractWizard):
'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 @@ class NewWalletWizard(AbstractWizard):
'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 @@ class NewWalletWizard(AbstractWizard):
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 @@ class NewWalletWizard(AbstractWizard):
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 @@ class NewWalletWizard(AbstractWizard):
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 @@ class NewWalletWizard(AbstractWizard):
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 @@ class NewWalletWizard(AbstractWizard):
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 @@ class NewWalletWizard(AbstractWizard):
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()

Loading…
Cancel
Save