From 4eccfdaa99aeb823ec4386cd28218a50ac7c2751 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 12 Aug 2025 12:02:30 +0200 Subject: [PATCH 1/5] wizard: add script and derivation to keystorewizard flow. fixes #10063 --- electrum/gui/qt/wizard/wallet.py | 1 - electrum/wizard.py | 11 ++++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index fd12408b2..bae3ef958 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -48,7 +48,6 @@ MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ + _("It also contains your master public key that allows watching your addresses.") - class QEKeystoreWizard(KeystoreWizard, QEAbstractWizard, MessageBoxMixin): _logger = get_logger(__name__) diff --git a/electrum/wizard.py b/electrum/wizard.py index 689454974..08b65dbe4 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -210,11 +210,16 @@ class KeystoreWizard(AbstractWizard): 'next': self.on_keystore_type }, 'enter_seed': { - 'next': 'enter_ext', - 'accept': lambda d: None if self.wants_ext(d) else self.update_keystore(d), - 'last': lambda d: not self.wants_ext(d), + 'next': lambda d: 'enter_ext' if self.wants_ext(d) else 'script_and_derivation', + 'accept': lambda d: None if (self.wants_ext(d) or self.needs_derivation_path(d)) else self.update_keystore(d), + 'last': lambda d: not self.wants_ext(d) and not self.needs_derivation_path(d), }, 'enter_ext': { + 'next': 'script_and_derivation', + 'accept': lambda d: None if self.needs_derivation_path(d) else self.update_keystore(d), + 'last': lambda d: not self.needs_derivation_path(d) + }, + 'script_and_derivation': { 'accept': self.update_keystore, 'last': True }, From 66c0fec1eaa4ebf4b75ec70b5395320150a2801c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 13 Aug 2025 11:40:08 +0200 Subject: [PATCH 2/5] qt: wizard: pass wallet_type to Keystore wizard via initial viewstate --- electrum/gui/qt/wallet_info_dialog.py | 5 ++++- electrum/gui/qt/wizard/wallet.py | 21 ++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qt/wallet_info_dialog.py b/electrum/gui/qt/wallet_info_dialog.py index 7ba9bb602..9d6a26c49 100644 --- a/electrum/gui/qt/wallet_info_dialog.py +++ b/electrum/gui/qt/wallet_info_dialog.py @@ -14,6 +14,7 @@ from PyQt6.QtWidgets import ( from electrum.plugin import run_hook from electrum.i18n import _ from electrum.wallet import Multisig_Wallet +from electrum.wizard import WizardViewState from .main_window import protected from electrum.gui.qt.wizard.wallet import QEKeystoreWizard @@ -192,7 +193,9 @@ class WalletInfoDialog(WindowModalDialog): self.window.gui_object.reload_windows() def enable_keystore(self, b: bool): - dialog = QEKeystoreWizard(self.window.config, self.window.wallet.wallet_type, self.window.gui_object.app, self.window.gui_object.plugins) + v = WizardViewState('keystore_type', {'wallet_type': self.window.wallet.wallet_type}, {}) + dialog = QEKeystoreWizard(config=self.window.config, app=self.window.gui_object.app, + plugins=self.window.gui_object.plugins, start_viewstate=v) result = dialog.run() if not result: return diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index bae3ef958..839d6a883 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -23,7 +23,7 @@ from .wizard import QEAbstractWizard, WizardComponent from electrum.logging import get_logger, Logger from electrum import WalletStorage, mnemonic, keystore from electrum.wallet_db import WalletDB -from electrum.wizard import NewWalletWizard, KeystoreWizard +from electrum.wizard import NewWalletWizard, KeystoreWizard, WizardViewState from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW @@ -51,10 +51,18 @@ MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ class QEKeystoreWizard(KeystoreWizard, QEAbstractWizard, MessageBoxMixin): _logger = get_logger(__name__) - def __init__(self, config: 'SimpleConfig', wallet_type: str, app: 'QElectrumApplication', plugins: 'Plugins', *, start_viewstate=None): + def __init__( + self, + *, + config: 'SimpleConfig', + app: 'QElectrumApplication', + plugins: 'Plugins', + start_viewstate: WizardViewState = None + ): + assert 'wallet_type' in start_viewstate.wizard_data, 'wallet_type required' + QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate) KeystoreWizard.__init__(self, plugins) - self._wallet_type = wallet_type self.window_title = _('Extend wallet keystore') # attach gui classes to views self.navmap_merge({ @@ -417,9 +425,7 @@ class WCKeystoreType(WalletWizardComponent): self.wizard_data['keystore_type'] = self.choice_w.selected_key - class WCExtendKeystore(WalletWizardComponent): - def __init__(self, parent, wizard): WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore')) message = _('What type of signing method do you want to add?') @@ -431,13 +437,10 @@ class WCExtendKeystore(WalletWizardComponent): self.layout().addWidget(self.choice_w) self.layout().addStretch(1) self._valid = True - self.wizard_data['wallet_type'] = self._wallet_type = wizard._wallet_type def apply(self): - self.wizard_data['wallet_type'] = self._wallet_type self.wizard_data['keystore_type'] = self.choice_w.selected_key - if multisig_type(self._wallet_type): - self.wizard_data['wallet_type'] = self._wallet_type = 'multisig' + if multisig_type(self.wizard_data['wallet_type']): self.wizard_data['multisig_participants'] = 2 self.wizard_data['multisig_signatures'] = 2 self.wizard_data['multisig_cosigner_data'] = {} From 0c5403b91e8000eb621efb9dd6e49241422d9cc5 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 13 Aug 2025 13:16:52 +0200 Subject: [PATCH 3/5] wizard: add initial tests for KeystoreWizard for electrum and bip39 seeds, hww --- tests/test_wizard.py | 155 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/tests/test_wizard.py b/tests/test_wizard.py index a494245f2..82a2fae97 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -2,9 +2,10 @@ import os from electrum import SimpleConfig from electrum.interface import ServerAddr +from electrum.keystore import bip44_derivation from electrum.network import NetworkParameters, ProxySettings from electrum.plugin import Plugins, DeviceInfo, Device -from electrum.wizard import ServerConnectWizard, NewWalletWizard, WizardViewState +from electrum.wizard import ServerConnectWizard, NewWalletWizard, WizardViewState, KeystoreWizard from electrum.daemon import Daemon from electrum.wallet import Abstract_Wallet from electrum import util @@ -121,7 +122,157 @@ class ServerConnectWizardTestCase(WizardTestCase): self.assertTrue(w._daemon.network.run_called) self.assertEqual(NetworkParameters(server=serverobj, proxy=None, auto_connect=False, oneserver=False), w._daemon.network.parameters) -# TODO KeystoreWizard ("enable keystore") + +class KeystoreWizardTestCase(WizardTestCase): + class TKeystoreWizard(KeystoreWizard): + def is_single_password(self): + """impl abstract reqd""" + return True + + class TNewWalletWizard(NewWalletWizard): + def is_single_password(self): + """impl abstract reqd""" + return True + + def _wizard_for(self, *, wallet_type: str = 'standard', hww: bool = False) -> tuple[KeystoreWizard, WizardViewState]: + w = KeystoreWizardTestCase.TKeystoreWizard(self.plugins) + v = w.start({'wallet_type': wallet_type}) + self.assertEqual('keystore_type', v.view) + d = v.wizard_data + if hww: + d.update({'keystore_type': 'hardware'}) + v = w.resolve_next(v.view, d) + self.assertEqual('choose_hardware_device', v.view) + else: + d.update({'keystore_type': 'haveseed'}) + v = w.resolve_next(v.view, d) + self.assertEqual('enter_seed', v.view) + + return w, v + + def _create_xpub_keystore_wallet(self, *, wallet_type: str = 'standard', xpub): + w = KeystoreWizardTestCase.TNewWalletWizard(DaemonMock(self.config), self.plugins) + wallet_path = self.wallet_path + d = { + 'wallet_type': wallet_type, + 'keystore_type': 'masterkey', + 'master_key': xpub, + 'password': None, + 'encrypt': False, + } + w.create_storage(wallet_path, d) + self.assertTrue(os.path.exists(wallet_path)) + wallet = Daemon._load_wallet(wallet_path, password=None, config=self.config) + return wallet + + async def test_haveseed_electrum(self): + w, v = self._wizard_for() + d = v.wizard_data + d.update({ + 'seed': '9dk', 'seed_type': 'segwit', 'seed_extend': False, 'seed_variant': 'electrum', + }) + self.assertTrue(w.is_last_view(v.view, d)) + w.resolve_next(v.view, d) + ks, ishww = w._result + self.assertFalse(ishww) + self.assertEqual(ks.xpub, 'zpub6nAZodjgiMNf9zzX1pTqd6ZVX61ax8azhUDnWRumKVUr1VYATVoqAuqv3qKsb8WJXjxei4wei2p4vnMG9RnpKnen2kmgdhvZUmug2NnHNsr') + + wallet = self._create_xpub_keystore_wallet(xpub='zpub6nAZodjgiMNf9zzX1pTqd6ZVX61ax8azhUDnWRumKVUr1VYATVoqAuqv3qKsb8WJXjxei4wei2p4vnMG9RnpKnen2kmgdhvZUmug2NnHNsr') + self.assertTrue(wallet.get_keystore().is_watching_only()) + wallet.enable_keystore(ks, ishww, None) + self.assertFalse(wallet.get_keystore().is_watching_only()) + + async def test_haveseed_ext_electrum(self): + w, v = self._wizard_for() + d = v.wizard_data + d.update({ + 'seed': '9dk', 'seed_type': 'segwit', 'seed_extend': True, 'seed_variant': 'electrum', + }) + self.assertFalse(w.is_last_view(v.view, d)) + v = w.resolve_next(v.view, d) + self.assertEqual('enter_ext', v.view) + d.update({'seed_extra_words': 'abc'}) + self.assertTrue(w.is_last_view(v.view, d)) + w.resolve_next(v.view, d) + ks, ishww = w._result + self.assertFalse(ishww) + self.assertEqual(ks.xpub, 'zpub6oLFCUpqxT8BUzy8g5miUuRofPZ46ZjjvZfcfH7qJanRM7aRYGpNX4uBGtcJRbgcKbi7dYkiiPw1GB2sc3SufyDcZskuQEWp5jBwbNcj1VL') + + wallet = self._create_xpub_keystore_wallet(xpub='zpub6oLFCUpqxT8BUzy8g5miUuRofPZ46ZjjvZfcfH7qJanRM7aRYGpNX4uBGtcJRbgcKbi7dYkiiPw1GB2sc3SufyDcZskuQEWp5jBwbNcj1VL') + self.assertTrue(wallet.get_keystore().is_watching_only()) + wallet.enable_keystore(ks, ishww, None) + self.assertFalse(wallet.get_keystore().is_watching_only()) + + async def test_haveseed_bip39(self): + w, v = self._wizard_for() + d = v.wizard_data + d.update({ + 'seed': '9dk', 'seed_type': 'bip39', 'seed_extend': False, 'seed_variant': 'bip39', + }) + self.assertFalse(w.is_last_view(v.view, d)) + v = w.resolve_next(v.view, d) + self.assertEqual('script_and_derivation', v.view) + d.update({'script_type': 'p2wpkh', 'derivation_path': 'm'}) + v = w.resolve_next(v.view, d) + ks, ishww = w._result + self.assertFalse(ishww) + self.assertEqual(ks.xpub, 'zpub6jftahH18ngZwMBBp7epRdBwPMPphfdy9gM6P4n5zFUXdfQJmsYfMNZoBnQMkAoBAiQYRyDQKdpxLYp6QuTrWbgmt6v1cxnFdesyiDSocAs') + + wallet = self._create_xpub_keystore_wallet(xpub='zpub6jftahH18ngZwMBBp7epRdBwPMPphfdy9gM6P4n5zFUXdfQJmsYfMNZoBnQMkAoBAiQYRyDQKdpxLYp6QuTrWbgmt6v1cxnFdesyiDSocAs') + self.assertTrue(wallet.get_keystore().is_watching_only()) + wallet.enable_keystore(ks, ishww, None) + self.assertFalse(wallet.get_keystore().is_watching_only()) + + async def test_haveseed_ext_bip39(self): + w, v = self._wizard_for() + d = v.wizard_data + d.update({ + 'seed': '9dk', 'seed_type': 'bip39', 'seed_extend': True, 'seed_variant': 'bip39', + }) + self.assertFalse(w.is_last_view(v.view, d)) + v = w.resolve_next(v.view, d) + self.assertEqual('enter_ext', v.view) + d.update({'seed_extra_words': 'abc'}) + v = w.resolve_next(v.view, d) + + self.assertEqual('script_and_derivation', v.view) + d.update({'script_type': 'p2wpkh', 'derivation_path': 'm'}) + v = w.resolve_next(v.view, d) + ks, ishww = w._result + self.assertFalse(ishww) + self.assertEqual(ks.xpub, 'zpub6jftahH18ngZwVNQQqNX9vgARaQRs5X89bPzjruSH2hgEBr1LRZN8reopYDALiKngTd8j5jUeGDipb68BXqjP6qMFsReLGwP6naDRvzVHxy') + + wallet = self._create_xpub_keystore_wallet(xpub='zpub6jftahH18ngZwVNQQqNX9vgARaQRs5X89bPzjruSH2hgEBr1LRZN8reopYDALiKngTd8j5jUeGDipb68BXqjP6qMFsReLGwP6naDRvzVHxy') + self.assertTrue(wallet.get_keystore().is_watching_only()) + wallet.enable_keystore(ks, ishww, None) + self.assertFalse(wallet.get_keystore().is_watching_only()) + + async def test_hww(self): + w, v = self._wizard_for(hww=True) + d = v.wizard_data + d.update({ + 'hardware_device': ( + 'trezor', + DeviceInfo( + device=Device(path='webusb:002:1', interface_number=-1, id_='webusb:002:1', product_key='Trezor', usage_page=0, transport_ui_string='webusb:002:1'), + label='trezor_unittests', initialized=True, exception=None, plugin_name='trezor', soft_device_id='088C3F260B66F60E15DE0FA5', model_name='Trezor T'))}) + v = w.resolve_next(v.view, d) + self.assertEqual('trezor_start', v.view) + d.update({ + 'script_type': 'p2wpkh', + 'derivation_path': bip44_derivation(0, bip43_purpose=84) + }) + v = w.resolve_next(v.view, d) + self.assertEqual('trezor_xpub', v.view) + d.update({ + 'hw_type': 'trezor', + 'master_key': 'zpub6jftahH18ngZwMBBp7epRdBwPMPphfdy9gM6P4n5zFUXdfQJmsYfMNZoBnQMkAoBAiQYRyDQKdpxLYp6QuTrWbgmt6v1cxnFdesyiDSocAs', + 'root_fingerprint': '', + 'label': 'test', + 'soft_device_id': '', + }) + self.assertTrue(w.is_last_view(v.view, d)) + class WalletWizardTestCase(WizardTestCase): From 0a2cd5fdadf20a11ad0325ebcc13879b12ada1dc Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 13 Aug 2025 15:48:28 +0200 Subject: [PATCH 4/5] hww: fix crash when disabling keystore for hww (was unimplemented for Hardware_Keystore) also preserve derivation path and root fingerprint for watch-only keystore. --- electrum/keystore.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/electrum/keystore.py b/electrum/keystore.py index 8d76536c9..13241f0e1 100644 --- a/electrum/keystore.py +++ b/electrum/keystore.py @@ -640,7 +640,11 @@ class BIP32_KeyStore(Xpub, Deterministic_KeyStore): self.xprv = d.get('xprv') def watching_only_keystore(self): - return BIP32_KeyStore({'xpub':self.xpub}) + return BIP32_KeyStore({ + 'xpub': self.xpub, + 'root_fingerprint': self.get_root_fingerprint(), + 'derivation_prefix': self.get_derivation_prefix(), + }) def format_seed(self, seed): return ' '.join(seed.split()) @@ -895,6 +899,13 @@ class Hardware_KeyStore(Xpub, KeyStore): self.handler = None # type: Optional[HardwareHandlerBase] run_hook('init_keystore', self) + def watching_only_keystore(self): + return BIP32_KeyStore({ + 'xpub': self.xpub, + 'root_fingerprint': self.get_root_fingerprint(), + 'derivation_prefix': self.get_derivation_prefix(), + }) + def set_label(self, label: Optional[str]) -> None: self.label = label @@ -914,13 +925,13 @@ class Hardware_KeyStore(Xpub, KeyStore): 'xpub': self.xpub, 'derivation': self.get_derivation_prefix(), 'root_fingerprint': self.get_root_fingerprint(), - 'label':self.label, + 'label': self.label, 'soft_device_id': self.soft_device_id, } def is_watching_only(self): - '''The wallet is not watching-only; the user will be prompted for - pin and passphrase as appropriate when needed.''' + """The wallet is not watching-only; the user will be prompted for + pin and passphrase as appropriate when needed.""" assert not self.has_seed() return False From e1d5d803e99430aa8e758fd40cf888c811a48e5b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 13 Aug 2025 16:16:00 +0200 Subject: [PATCH 5/5] wizard: fix missing 'wallet_password' and 'wallet_password_hardware' views on abstract KeystoreWizard (these were implicitly defined by the Qt subclass) and test wallet keystore enable. --- electrum/wizard.py | 6 ++++++ tests/test_wizard.py | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/electrum/wizard.py b/electrum/wizard.py index 08b65dbe4..ae624a16b 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -226,6 +226,12 @@ class KeystoreWizard(AbstractWizard): 'choose_hardware_device': { 'next': self.on_hardware_device, }, + 'wallet_password': { + 'last': True + }, + 'wallet_password_hardware': { + 'last': True + }, } def maybe_master_pubkey(self, wizard_data): diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 82a2fae97..203e37eb9 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -2,7 +2,7 @@ import os from electrum import SimpleConfig from electrum.interface import ServerAddr -from electrum.keystore import bip44_derivation +from electrum.keystore import bip44_derivation, Hardware_KeyStore from electrum.network import NetworkParameters, ProxySettings from electrum.plugin import Plugins, DeviceInfo, Device from electrum.wizard import ServerConnectWizard, NewWalletWizard, WizardViewState, KeystoreWizard @@ -266,12 +266,22 @@ class KeystoreWizardTestCase(WizardTestCase): self.assertEqual('trezor_xpub', v.view) d.update({ 'hw_type': 'trezor', - 'master_key': 'zpub6jftahH18ngZwMBBp7epRdBwPMPphfdy9gM6P4n5zFUXdfQJmsYfMNZoBnQMkAoBAiQYRyDQKdpxLYp6QuTrWbgmt6v1cxnFdesyiDSocAs', - 'root_fingerprint': '', + 'master_key': 'zpub6rakEaM5ps5UiQ2yhbWiEkd6ceJfmuzegwc62G4itMz8L7rRFRqh6y8bTCScXV6NfTMUhANYQnfqfBd9dYfBRKf4LD1Yyfc8UvwY1MtNKWs', + 'root_fingerprint': 'b3569ff0', 'label': 'test', - 'soft_device_id': '', + 'soft_device_id': '1', }) self.assertTrue(w.is_last_view(v.view, d)) + v = w.resolve_next(v.view, d) + + ks, ishww = w._result + self.assertTrue(ishww) + + wallet = self._create_xpub_keystore_wallet(xpub='zpub6rakEaM5ps5UiQ2yhbWiEkd6ceJfmuzegwc62G4itMz8L7rRFRqh6y8bTCScXV6NfTMUhANYQnfqfBd9dYfBRKf4LD1Yyfc8UvwY1MtNKWs') + self.assertTrue(wallet.get_keystore().is_watching_only()) + wallet.enable_keystore(ks, ishww, None) + self.assertFalse(wallet.get_keystore().is_watching_only()) + self.assertTrue(isinstance(wallet.get_keystore(), Hardware_KeyStore)) class WalletWizardTestCase(WizardTestCase):