From 3dfd254755e98396c1aebb5115d384af7b231b66 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sun, 20 Sep 2026 14:06:43 +0200 Subject: [PATCH 1/2] wizard: redirect flow if a 2fa seed is entered for a standard wallet. This fixes a regression introduced by the new wizard. Note that the reverse direction (standard seed entered for a 2FA wallet) is not redirected; I believe that is less of an issue. --- electrum/gui/qt/wizard/wallet.py | 2 +- electrum/wizard.py | 36 +++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index 401a459b7c6d..dc9815b3b898 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -622,7 +622,7 @@ def is_seed(self, x): # really only used for electrum seeds. bip39 and slip39 are validated in SeedWidget t = mnemonic.calc_seed_type(x) if self.wizard_data['wallet_type'] == 'standard': - return mnemonic.is_seed(x) and not mnemonic.is_any_2fa_seed_type(t) + return mnemonic.is_seed(x) and (self.wizard.supports_2fa() or not mnemonic.is_any_2fa_seed_type(t)) elif self.wizard_data['wallet_type'] == '2fa': return mnemonic.is_any_2fa_seed_type(t) else: diff --git a/electrum/wizard.py b/electrum/wizard.py index 6b2e7b65d2cd..abf99f5a03be 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -300,6 +300,9 @@ def is_multisig(self, wizard_data: dict) -> bool: def is_hardware(self, wizard_data: dict) -> bool: return wizard_data['keystore_type'] == 'hardware' + def supports_2fa(self) -> bool: + return False + 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' @@ -345,6 +348,9 @@ def validate_seed(self, seed: str, seed_variant: str, wallet_type: str) -> Tuple # 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 is_any_2fa_seed_type(seed_type): + # wizard will redirect + seed_valid = self.supports_2fa() 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']: @@ -433,10 +439,10 @@ def __init__(self, daemon: 'Daemon', plugins: 'Plugins'): 'last': lambda d: self.is_single_password() and not self.is_multisig(d) }, 'have_seed': { - 'next': lambda d: 'have_ext' if self.wants_ext(d) else self.on_have_or_confirm_seed(d), - 'accept': lambda d: None if self.wants_ext(d) else self.maybe_master_pubkey(d), + 'next': self.on_have_seed, + 'accept': self.on_accept_have_seed, 'last': lambda d: self.is_single_password() and not - (self.needs_derivation_path(d) or self.is_multisig(d) or self.wants_ext(d)), + (self.needs_derivation_path(d) or self.is_multisig(d) or self.wants_ext(d) or self.wants_2fa(d)), }, 'have_ext': { 'next': self.on_have_or_confirm_seed, @@ -530,6 +536,30 @@ def on_keystore_type(self, wizard_data: dict) -> str: 'hardware': 'choose_hardware_device' }.get(t) + def supports_2fa(self) -> bool: + return True + + def wants_2fa(self, wizard_data: dict) -> bool: + # True if the user entered a 2fa seed while restoring a wallet of type 'standard' + return (wizard_data['wallet_type'] == 'standard' + and is_any_2fa_seed_type(wizard_data.get('seed_type', '')) + and self.supports_2fa()) + + def on_have_seed(self, wizard_data: dict) -> str: + if wizard_data['wallet_type'] == '2fa': # redirected by on_accept_have_seed + return 'trustedcoin_have_ext' if self.wants_ext(wizard_data) else 'trustedcoin_keep_disable' + elif self.wants_ext(wizard_data): + return 'have_ext' + else: + return self.on_have_or_confirm_seed(wizard_data) + + def on_accept_have_seed(self, wizard_data: dict) -> None: + if self.wants_2fa(wizard_data): + wizard_data['wallet_type'] = '2fa' + return + if not self.wants_ext(wizard_data): + self.maybe_master_pubkey(wizard_data) + def on_have_or_confirm_seed(self, wizard_data: dict) -> str: if self.needs_derivation_path(wizard_data): return 'script_and_derivation' From 70b4d27cf55d284c41e1d22b5ff4ef684d36de91 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 24 Sep 2026 12:36:33 +0200 Subject: [PATCH 2/2] tests: wizard: cover standard -> 2fa redirection Add tests to cover the newly added standard restore to 2fa redirection. --- tests/test_wizard.py | 77 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 7c5b740a8611..76c4ae3903e6 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -42,6 +42,12 @@ def __init__(self, config: SimpleConfig): self.network = NetworkMock() +class TNewWalletWizard(NewWalletWizard): + def is_single_password(self): + """impl abstract reqd""" + return True + + class WizardTestCase(ElectrumTestCase): def setUp(self): @@ -133,11 +139,6 @@ 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) start_viewstate = WizardViewState('keystore_type', {'wallet_type': wallet_type}, {}) @@ -156,7 +157,7 @@ def _wizard_for(self, *, wallet_type: str = 'standard', hww: bool = False) -> tu return w, v def _create_xpub_keystore_wallet(self, *, wallet_type: str = 'standard', xpub): - w = KeystoreWizardTestCase.TNewWalletWizard(DaemonMock(self.config), self.plugins) + w = TNewWalletWizard(DaemonMock(self.config), self.plugins) wallet_path = self.wallet_path d = { 'wallet_type': wallet_type, @@ -262,6 +263,14 @@ async def test_haveseed_electrum__mismatching_seed(self): wallet.enable_keystore(ks, ishww, None) self.assertTrue("mismatching xpubs" in ctx.exception.args[0]) + async def test_haveseed_electrum__2fa_seed(self): + """unlike NewWalletWizard, KeystoreWizard cannot redirect to the 2fa flow, so 2fa seeds are invalid""" + w, v = self._wizard_for() + seed_valid, seed_type, *_ = w.validate_seed( + 'oblige basket safe educate whale bacon celery demand novel slice various awkward', 'electrum', 'standard') + self.assertFalse(seed_valid) + self.assertEqual('2fa_segwit', seed_type) + async def test_haveseed_electrum_oldseed(self): w, v = self._wizard_for() d = v.wizard_data @@ -439,9 +448,8 @@ def _wizard_for( name: str = "mywallet", wallet_type: str, ) -> NewWalletWizard: - w = NewWalletWizard(DaemonMock(self.config), self.plugins) - if wallet_type == '2fa': - w.plugins.get_plugin('trustedcoin').extend_wizard(w) + w = TNewWalletWizard(DaemonMock(self.config), self.plugins) + w.plugins.get_plugin('trustedcoin').extend_wizard(w) v_init = w.start() self.assertEqual('wallet_name', v_init.view) d = {'wallet_name': name} @@ -895,6 +903,57 @@ async def test_2fa_haveseed_passphrase(self): v = w.resolve_next(v.view, d) self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud") + async def test_2fa_haveseed_redirected_from_standard(self): + w = self._wizard_for(wallet_type='standard') + v = w._current + d = v.wizard_data + self.assertEqual('keystore_type', v.view) + + d.update({'keystore_type': 'haveseed'}) + v = w.resolve_next(v.view, d) + self.assertEqual('have_seed', v.view) + myseed = 'oblige basket safe educate whale bacon celery demand novel slice various awkward' + self.assertTrue(w.validate_seed(myseed, 'electrum', 'standard')[0]) + d.update({ + 'seed': myseed, + 'seed_type': '2fa_segwit', 'seed_extend': False, 'seed_variant': 'electrum', + }) + self.assertFalse(w.is_last_view(v.view, d)) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_keep_disable', v.view) + self.assertEqual('2fa', v.wizard_data['wallet_type']) + d.update({'trustedcoin_keepordisable': 'keep'}) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_tos', v.view) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_show_confirm_otp', v.view) + v = w.resolve_next(v.view, d) + wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94") + self.assertEqual('2fa', wallet.wallet_type) + + async def test_2fa_haveseed_redirected_from_standard_passphrase(self): + w = self._wizard_for(wallet_type='standard') + v = w._current + d = v.wizard_data + self.assertEqual('keystore_type', v.view) + + d.update({'keystore_type': 'haveseed'}) + v = w.resolve_next(v.view, d) + self.assertEqual('have_seed', v.view) + d.update({ + 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward', + 'seed_type': '2fa_segwit', 'seed_extend': True, 'seed_variant': 'electrum', + }) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_have_ext', v.view) + d.update({'seed_extra_words': UNICODE_HORROR}) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_keep_disable', v.view) + d.update({'trustedcoin_keepordisable': 'disable'}) + v = w.resolve_next(v.view, d) + wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud") + self.assertEqual('2fa', wallet.wallet_type) + async def test_create_standard_wallet_trezor(self): # bip39 seed for trezor: "history six okay anchor sheriff flock atom tomorrow foster aerobic eternal foam" w = self._wizard_for(wallet_type='standard')