From 3b028b06a015b5a952a716676a265e1555208eb4 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 4 Dec 2025 12:21:21 +0100 Subject: [PATCH 1/8] qml: enforce use of existing password for wallet creation When creating a new wallet in a Electrum instance with existing wallets this change forces the user to reuse a password of any existing wallet if `SimpleConfig.WALLET_USE_SINGLE_PASSWORD` is True. This prevents the amount of different passwords from increasing and guides the user towards a single wallet password (the intended default). --- electrum/daemon.py | 11 ++-- .../components/wizard/WCWalletPassword.qml | 64 +++++++++++++++++-- electrum/gui/qml/qeconfig.py | 9 +++ electrum/gui/qml/qedaemon.py | 33 +++++++++- electrum/simple_config.py | 2 +- run_electrum | 2 +- tests/test_daemon.py | 21 +++--- 7 files changed, 119 insertions(+), 23 deletions(-) diff --git a/electrum/daemon.py b/electrum/daemon.py index 3afa92a04..b1ab1e84d 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -663,11 +663,12 @@ class Daemon(Logger): asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result() @with_wallet_lock - def _check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool]: + def check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool, list[str]]: """Checks password against all wallets (in dir), returns whether they can be unified and whether they are already. If new_password is not None, update all wallet passwords to new_password. """ assert os.path.exists(wallet_dir), f"path {wallet_dir!r} does not exist" + succeeded = [] failed = [] is_unified = True for filename in os.listdir(wallet_dir): @@ -706,9 +707,11 @@ class Daemon(Logger): if new_password: self.logger.info(f'updating password for wallet: {path!r}') wallet.update_password(old_password_real, new_password, encrypt_storage=True) + succeeded.append(path) + can_be_unified = failed == [] is_unified = can_be_unified and is_unified - return can_be_unified, is_unified + return can_be_unified, is_unified, succeeded @with_wallet_lock def update_password_for_directory( @@ -724,13 +727,13 @@ class Daemon(Logger): return False if wallet_dir is None: wallet_dir = os.path.dirname(self.config.get_wallet_path()) - can_be_unified, is_unified = self._check_password_for_directory( + can_be_unified, is_unified, _ = self.check_password_for_directory( old_password=old_password, new_password=None, wallet_dir=wallet_dir) if not can_be_unified: return False if is_unified and old_password == new_password: return True - self._check_password_for_directory( + self.check_password_for_directory( old_password=old_password, new_password=new_password, wallet_dir=wallet_dir) return True diff --git a/electrum/gui/qml/components/wizard/WCWalletPassword.qml b/electrum/gui/qml/components/wizard/WCWalletPassword.qml index eafd6e7ee..b71bfefc9 100644 --- a/electrum/gui/qml/components/wizard/WCWalletPassword.qml +++ b/electrum/gui/qml/components/wizard/WCWalletPassword.qml @@ -5,37 +5,70 @@ import QtQuick.Controls.Material import "../controls" +// We will only end up here if Daemon.singlePasswordEnabled == False. +// If there are existing wallets, the user must reuse the password of one of them. +// This way they are guided towards password unification. +// NOTE: This also needs to be enforced when changing a wallets password. + WizardComponent { - valid: password1.text === password2.text && password1.text.length >= 6 + id: root + valid: isInputValid() + property bool enforceExistingPassword: Config.walletShouldUseSinglePassword && Daemon.availableWallets.rowCount() > 0 + property bool passwordMatchesAnyExisting: false function apply() { wizard_data['password'] = password1.text wizard_data['encrypt'] = password1.text != '' } + function isInputValid() { + if (password1.text == "") { + return false + } + if (enforceExistingPassword) { + return passwordMatchesAnyExisting + } + return password1.text === password2.text && password1.text.length >= 6 + } + + Timer { + id: passwordComparisonTimer + interval: 500 + repeat: false + onTriggered: { + root.passwordMatchesAnyExisting = Daemon.numWalletsWithPassword(password1.text) > 0 + } + } + ColumnLayout { anchors.fill: parent Label { Layout.fillWidth: true - text: Daemon.singlePasswordEnabled - ? qsTr('Enter password') - : qsTr('Enter password for %1').arg(wizard_data['wallet_name']) + text: !enforceExistingPassword ? qsTr('Enter password') : qsTr('Enter existing password') wrapMode: Text.Wrap } PasswordField { id: password1 + onTextChanged: { + if (enforceExistingPassword) { + root.passwordMatchesAnyExisting = false + passwordComparisonTimer.restart() + } + } } Label { text: qsTr('Enter password (again)') + visible: !enforceExistingPassword } PasswordField { id: password2 showReveal: false echoMode: password1.echoMode + visible: !enforceExistingPassword } RowLayout { @@ -44,7 +77,7 @@ WizardComponent { Layout.rightMargin: constants.paddingXLarge Layout.topMargin: constants.paddingXLarge - visible: password1.text != '' + visible: password1.text != '' && !enforceExistingPassword Label { Layout.rightMargin: constants.paddingLarge @@ -65,13 +98,30 @@ WizardComponent { InfoTextArea { Layout.alignment: Qt.AlignCenter text: qsTr('Passwords don\'t match') - visible: password1.text != password2.text + visible: (password1.text != password2.text) && !enforceExistingPassword iconStyle: InfoTextArea.IconStyle.Warn } InfoTextArea { Layout.alignment: Qt.AlignCenter text: qsTr('Password too short') - visible: (password1.text == password2.text) && !valid + visible: (password1.text == password2.text) && !valid && !enforceExistingPassword + iconStyle: InfoTextArea.IconStyle.Warn + } + InfoTextArea { + Layout.alignment: Qt.AlignCenter + Layout.fillWidth: true + visible: password1.text == "" && enforceExistingPassword + text: [ + qsTr("Use the password of any existing wallet."), + qsTr("Creating new wallets with different passwords is not supported.") + ].join("\n") + iconStyle: InfoTextArea.IconStyle.Info + } + InfoTextArea { + Layout.alignment: Qt.AlignCenter + Layout.fillWidth: true + visible: password1.text != "" && !valid && enforceExistingPassword + text: qsTr('Password does not match any existing wallets password.') iconStyle: InfoTextArea.IconStyle.Warn } } diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 38cd80636..9bf2a2997 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -331,6 +331,15 @@ class QEConfig(AuthMixin, QObject): self._lnutxoreserve = QEAmount(amount_sat=self.config.LN_UTXO_RESERVE) return self._lnutxoreserve + walletShouldUseSinglePasswordChanged = pyqtSignal() + @pyqtProperty(bool, notify=walletShouldUseSinglePasswordChanged) + def walletShouldUseSinglePassword(self): + """ + NOTE: this only indicates if we even want to use a single password, to check if we + actually use a single password the daemon needs to be checked. + """ + return self.config.WALLET_SHOULD_USE_SINGLE_PASSWORD + @pyqtSlot('qint64', result=str) @pyqtSlot(QEAmount, result=str) def formatSatsForEditing(self, satoshis): diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index f9af213e3..c31937d51 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -230,7 +230,7 @@ class QEDaemon(AuthMixin, QObject): if wallet is None: return - if self.daemon.config.WALLET_USE_SINGLE_PASSWORD: + if self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD: self._use_single_password = self.daemon.update_password_for_directory(old_password=local_password, new_password=local_password) self._password = local_password self.singlePasswordChanged.emit() @@ -318,9 +318,40 @@ class QEDaemon(AuthMixin, QObject): def fx(self): return self.qefx + @pyqtSlot(str, result=list) + def getWalletsUnlockableWithPassword(self, password: str) -> list[str]: + """ + Returns any wallet that can be unlocked with the given password. + Can be used as fallback to unlock another wallet the user entered a + password that doesn't work for the current wallet but might work for another one. + """ + wallet_dir = os.path.dirname(self.daemon.config.get_wallet_path()) + _, _, wallet_paths_can_unlock = self.daemon.check_password_for_directory( + old_password=password, + new_password=None, + wallet_dir=wallet_dir, + ) + if not wallet_paths_can_unlock: + return [] + self._logger.debug(f"getWalletsUnlockableWithPassword: can unlock {len(wallet_paths_can_unlock)} wallets") + return [str(path) for path in wallet_paths_can_unlock] + + @pyqtSlot(str, result=int) + def numWalletsWithPassword(self, password: str) -> int: + """Returns the number of wallets that can be unlocked with the given password""" + wallet_paths_can_unlock = self.getWalletsUnlockableWithPassword(password) + return len(wallet_paths_can_unlock) + singlePasswordChanged = pyqtSignal() @pyqtProperty(bool, notify=singlePasswordChanged) def singlePasswordEnabled(self): + """ + singlePasswordEnabled is False if: + a.) the user has no wallet (and password) yet + b.) the user has wallets with different passwords (legacy) + c.) all wallets are locked, we couldn't check yet if they all use the same password + d.) we are on desktop where different passwords are allowed + """ return self._use_single_password @pyqtProperty(str, notify=singlePasswordChanged) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 32638bda0..92b11defc 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -675,7 +675,7 @@ class SimpleConfig(Logger): ) WALLET_UNCONF_UTXO_FREEZE_THRESHOLD_SAT = ConfigVar('unconf_utxo_freeze_threshold', default=5_000, type_=int) WALLET_PAYREQ_EXPIRY_SECONDS = ConfigVar('request_expiry', default=invoices.PR_DEFAULT_EXPIRATION_WHEN_CREATING, type_=int) - WALLET_USE_SINGLE_PASSWORD = ConfigVar('single_password', default=False, type_=bool) + WALLET_SHOULD_USE_SINGLE_PASSWORD = ConfigVar('should_use_single_password', default=False, type_=bool) # note: 'use_change' and 'multiple_change' are per-wallet settings WALLET_SEND_CHANGE_TO_LIGHTNING = ConfigVar( 'send_change_to_lightning', default=False, type_=bool, diff --git a/run_electrum b/run_electrum index 3d75478c5..df45836e1 100755 --- a/run_electrum +++ b/run_electrum @@ -397,7 +397,7 @@ def main(): 'verbosity': '*' if util.is_android_debug_apk() else '', 'cmd': 'gui', SimpleConfig.GUI_NAME.key(): 'qml', - SimpleConfig.WALLET_USE_SINGLE_PASSWORD.key(): True, + SimpleConfig.WALLET_SHOULD_USE_SINGLE_PASSWORD.key(): True, } SimpleConfig.set_chain_config_opt_based_on_android_packagename(config_options) else: diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 08f53cf47..f5bbbade2 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -52,7 +52,7 @@ class TestUnifiedPassword(DaemonTestCase): def setUp(self): super().setUp() - self.config.WALLET_USE_SINGLE_PASSWORD = True + self.config.WALLET_SHOULD_USE_SINGLE_PASSWORD = True def _run_post_unif_sanity_checks(self, paths: Iterable[str], *, password: str): for path in paths: @@ -64,8 +64,11 @@ class TestUnifiedPassword(DaemonTestCase): self.assertTrue(w.has_keystore_encryption()) if w.has_seed(): self.assertIsInstance(w.get_seed(password), str) - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password=password, wallet_dir=self.wallet_dir) - self.assertEqual((True, True), (can_be_unified, is_unified)) + can_be_unified, is_unified, wallet_paths_can_unlock = self.daemon.check_password_for_directory( + old_password=password, + wallet_dir=self.wallet_dir, + ) + self.assertEqual((True, True, len(paths)), (can_be_unified, is_unified, len(wallet_paths_can_unlock))) # "cannot unify pw" tests ---> @@ -77,7 +80,7 @@ class TestUnifiedPassword(DaemonTestCase): with open(path2, "rb") as f: raw2_before = f.read() - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) + can_be_unified, is_unified, _ = self.daemon.check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) self.assertEqual((False, False), (can_be_unified, is_unified)) is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="123456") self.assertFalse(is_unified) @@ -100,7 +103,7 @@ class TestUnifiedPassword(DaemonTestCase): with open(path3, "rb") as f: raw3_before = f.read() - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) + can_be_unified, is_unified, _ = self.daemon.check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) self.assertEqual((False, False), (can_be_unified, is_unified)) is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="123456") self.assertFalse(is_unified) @@ -120,7 +123,7 @@ class TestUnifiedPassword(DaemonTestCase): async def test_can_unify_two_std_wallets_both_have_ks_and_sto_enc(self): path1 = self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True) path2 = self._restore_wallet_from_text("x8", password="123456", encrypt_file=True) - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) + can_be_unified, is_unified, _ = self.daemon.check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) self.assertEqual((True, True), (can_be_unified, is_unified)) is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="123456") self.assertTrue(is_unified) @@ -132,7 +135,7 @@ class TestUnifiedPassword(DaemonTestCase): with open(path2, "rb") as f: raw2_before = f.read() - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) + can_be_unified, is_unified, _ = self.daemon.check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) self.assertEqual((True, False), (can_be_unified, is_unified)) is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="123456") self.assertTrue(is_unified) @@ -145,7 +148,7 @@ class TestUnifiedPassword(DaemonTestCase): async def test_can_unify_two_std_wallets_one_without_password(self): path1 = self._restore_wallet_from_text("9dk", password=None) path2 = self._restore_wallet_from_text("x8", password="123456", encrypt_file=True) - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) + can_be_unified, is_unified, _ = self.daemon.check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) self.assertEqual((True, False), (can_be_unified, is_unified)) is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="123456") self.assertTrue(is_unified) @@ -179,7 +182,7 @@ class TestUnifiedPassword(DaemonTestCase): paths.append(self._restore_wallet_from_text(addrs, password="123456", encrypt_file=False)) paths.append(self._restore_wallet_from_text(addrs, password=None)) # do unification - can_be_unified, is_unified = self.daemon._check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) + can_be_unified, is_unified, _ = self.daemon.check_password_for_directory(old_password="123456", wallet_dir=self.wallet_dir) self.assertEqual((True, False), (can_be_unified, is_unified)) is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="123456") self.assertTrue(is_unified) From 378a9e61122ac669ad230fcddf8729af5f0cd27f Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 4 Dec 2025 15:37:09 +0100 Subject: [PATCH 2/8] qml: disable 'Create Wallet' before first unlock If the user has not unlocked any wallet yet and tries to create a new wallet in the overview a dialog will prompt them to first unlock an existing wallet in order to be able to create a new wallet. This ensures they remember at least one password so they can complete the wizard. The wizard will ask them for an existing password later and it would be annoying for the user to go through all steps (writing down the seed etc.) only to find out they need a password they don't remember. This way they can reinstall the app right before going through the wizard. --- electrum/gui/qml/components/WalletDetails.qml | 3 +++ electrum/gui/qml/components/Wallets.qml | 16 +++++++++++++++- electrum/gui/qml/qedaemon.py | 13 +++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/WalletDetails.qml b/electrum/gui/qml/components/WalletDetails.qml index ea1052063..85da196f5 100644 --- a/electrum/gui/qml/components/WalletDetails.qml +++ b/electrum/gui/qml/components/WalletDetails.qml @@ -528,6 +528,9 @@ Pane { }) dialog.accepted.connect(function() { var success = Daemon.currentWallet.setPassword(dialog.password) + if (success && Config.walletShouldUseSinglePassword) { + Daemon.singlePassword = dialog.password + } var done_dialog = app.messageDialog.createObject(app, { title: success ? qsTr('Success') : qsTr('Error'), iconSource: success diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 52dc31720..f9c9f84e3 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -121,7 +121,21 @@ Pane { Layout.fillWidth: true text: qsTr('Create Wallet') icon.source: '../../icons/add.png' - onClicked: rootItem.createWallet() + onClicked: { + if (Daemon.availableWallets.rowCount() > 0 && Config.walletShouldUseSinglePassword + && (!Daemon.singlePassword || Daemon.numWalletsWithPassword(Daemon.singlePassword) < 1)) { + // if the user has wallets but hasn't unlocked any wallet yet force them to do so. + // this ensures they know at least one wallets password and can complete the wizard + // where they will need to enter the password of an existing wallet. + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Wallet unlock required'), + text: qsTr("You have to unlock any existing wallet first before creating a new wallet."), + }) + dialog.open() + } else { + rootItem.createWallet() + } + } } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index c31937d51..72e618b0c 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -356,8 +356,21 @@ class QEDaemon(AuthMixin, QObject): @pyqtProperty(str, notify=singlePasswordChanged) def singlePassword(self): + """ + self._password is also set to the last loaded wallet password if we WANT a single password, + but don't actually have a single password yet. So singlePassword being set doesn't strictly + mean all wallets use the same password. + """ return self._password + @singlePassword.setter + def singlePassword(self, password: str): + assert password + assert self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD + if self._password != password: + self._password = password + self.singlePasswordChanged.emit() + @pyqtSlot(result=str) def suggestWalletName(self): # FIXME why not use util.get_new_wallet_name ? From 02abc0e6cdc9cb147d0ca0d1676574e241f858ac Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 18 Dec 2025 11:12:16 +0100 Subject: [PATCH 3/8] qml: enforce single password on password change If a qml user with non-uniform wallet passwords tries to change their wallet password this will force them to change it to a password that is used by at least one other wallet. This will guide them towards a single wallet password and prevents the number of different passwords from increasing. --- electrum/gui/qml/components/WalletDetails.qml | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qml/components/WalletDetails.qml b/electrum/gui/qml/components/WalletDetails.qml index 85da196f5..c0fddd99d 100644 --- a/electrum/gui/qml/components/WalletDetails.qml +++ b/electrum/gui/qml/components/WalletDetails.qml @@ -525,18 +525,33 @@ Pane { confirmPassword: true, title: qsTr('Enter new password'), infotext: qsTr('If you forget your password, you\'ll need to restore from seed. Please make sure you have your seed stored safely') + + (Daemon.availableWallets.rowCount() > 1 && Config.walletShouldUseSinglePassword + ? "\n\n" + qsTr('The new password needs to match the password of any other existing wallet.') + : "") }) dialog.accepted.connect(function() { - var success = Daemon.currentWallet.setPassword(dialog.password) - if (success && Config.walletShouldUseSinglePassword) { - Daemon.singlePassword = dialog.password + if (Config.walletShouldUseSinglePassword // android + && Daemon.availableWallets.rowCount() > 1 // has more than one wallet + && Daemon.numWalletsWithPassword(dialog.password) < 1 // no other wallet uses this new password + ) { + var success = false + var error_msg = [ + qsTr('You need to use the password of any other existing wallet.'), + qsTr('Using different wallet passwords is not supported.'), + ].join("\n") + } else { + var success = Daemon.currentWallet.setPassword(dialog.password) + if (success && Config.walletShouldUseSinglePassword) { + Daemon.singlePassword = dialog.password + } + var error_msg = qsTr('Password change failed') } var done_dialog = app.messageDialog.createObject(app, { title: success ? qsTr('Success') : qsTr('Error'), iconSource: success ? Qt.resolvedUrl('../../icons/info.png') : Qt.resolvedUrl('../../icons/warning.png'), - text: success ? qsTr('Password changed') : qsTr('Password change failed') + text: success ? qsTr('Password changed') : error_msg }) done_dialog.open() }) From 70084750ef81c5ed29edc3e42bb6ec6e829db9a7 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 16 Dec 2025 15:59:02 +0100 Subject: [PATCH 4/8] qml: show wallet list as root if no wallet is loaded Shows Wallets.qml as root if no wallet is loaded and removes the logic for no loaded wallet from the WalletMainView as WalletMainView won't be shown anymore without a Daemon.currentWallet. --- .../gui/qml/components/WalletMainView.qml | 86 +++++-------------- electrum/gui/qml/components/Wallets.qml | 6 +- electrum/gui/qml/components/main.qml | 14 ++- electrum/gui/qml/qedaemon.py | 2 +- 4 files changed, 39 insertions(+), 69 deletions(-) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index a9755ac81..c39cda5ed 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -11,10 +11,9 @@ import "controls" Item { id: mainView - property string title: Daemon.currentWallet ? Daemon.currentWallet.name : qsTr('no wallet loaded') + property string title: Daemon.currentWallet.name property var _sendDialog - property string _intentUri property string _request_amount property string _request_description @@ -188,7 +187,7 @@ Item { icon.source: '../../icons/wallet.png' action: Action { text: qsTr('Wallet details') - enabled: Daemon.currentWallet && app.stack.currentItem.objectName != 'WalletDetails' + enabled: app.stack.currentItem.objectName != 'WalletDetails' onTriggered: menu.openPage(Qt.resolvedUrl('WalletDetails.qml')) } } @@ -198,7 +197,7 @@ Item { action: Action { text: qsTr('Addresses/Coins'); onTriggered: menu.openPage(Qt.resolvedUrl('Addresses.qml')); - enabled: Daemon.currentWallet && app.stack.currentItem.objectName != 'Addresses' + enabled: app.stack.currentItem.objectName != 'Addresses' } } MenuItem { @@ -206,7 +205,7 @@ Item { icon.source: '../../icons/lightning.png' action: Action { text: qsTr('Channels'); - enabled: Daemon.currentWallet && Daemon.currentWallet.isLightning && app.stack.currentItem.objectName != 'Channels' + enabled: Daemon.currentWallet.isLightning && app.stack.currentItem.objectName != 'Channels' onTriggered: menu.openPage(Qt.resolvedUrl('Channels.qml')) } } @@ -285,62 +284,16 @@ Item { History { id: history - visible: Daemon.currentWallet Layout.fillWidth: true Layout.fillHeight: true } - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - Layout.fillHeight: true - spacing: 2*constants.paddingXLarge - visible: !Daemon.currentWallet - - Item { - Layout.fillHeight: true - } - Label { - Layout.alignment: Qt.AlignHCenter - text: qsTr('No wallet loaded') - font.pixelSize: constants.fontSizeXXLarge - } - - Pane { - Layout.alignment: Qt.AlignHCenter - padding: 0 - background: Rectangle { - color: Material.dialogColor - } - FlatButton { - text: qsTr('Open/Create Wallet') - icon.source: '../../icons/wallet.png' - onClicked: { - if (Daemon.availableWallets.rowCount() > 0) { - stack.push(Qt.resolvedUrl('Wallets.qml')) - } else { - var newww = app.newWalletWizard.createObject(app) - newww.walletCreated.connect(function() { - Daemon.availableWallets.reload() - // and load the new wallet - Daemon.loadWallet(newww.path, newww.wizard_data['password']) - }) - newww.open() - } - } - } - } - Item { - Layout.fillHeight: true - } - } - ButtonContainer { id: buttonContainer Layout.fillWidth: true FlatButton { id: receiveButton - visible: Daemon.currentWallet Layout.fillWidth: true Layout.preferredWidth: 1 icon.source: '../../icons/tab_receive.png' @@ -357,7 +310,6 @@ Item { } } FlatButton { - visible: Daemon.currentWallet Layout.fillWidth: true Layout.preferredWidth: 1 icon.source: '../../icons/tab_send.png' @@ -489,25 +441,22 @@ Item { } Connections { - target: AppController - function onUriReceived(uri) { - console.log('uri received: ' + uri) - if (!Daemon.currentWallet) { - console.log('No wallet open, deferring') - _intentUri = uri + target: Daemon + function onWalletLoaded() { + if (!Daemon.currentWallet) { // wallet got deleted + app.stack.replaceRoot('Wallets.qml') return } - piResolver.recipient = uri + infobanner.hide() // start hidden when switching wallets } } Connections { - target: Daemon - function onWalletLoaded() { - infobanner.hide() // start hidden when switching wallets - if (_intentUri) { - piResolver.recipient = _intentUri - _intentUri = '' + target: app + function onPendingIntentChanged() { + if (app.pendingIntent) { + piResolver.recipient = app.pendingIntent + app.pendingIntent = "" } } } @@ -819,5 +768,12 @@ Item { } } + Component.onCompleted: { + console.log("WalletMainView completed: ", Daemon.currentWallet.name) + if (app.pendingIntent) { + piResolver.recipient = app.pendingIntent + app.pendingIntent = "" + } + } } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index f9c9f84e3..b38fdf78a 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -143,7 +143,11 @@ Pane { target: Daemon function onWalletLoaded() { if (app.stack.currentItem.objectName == 'Wallets') - app.stack.pop() + if (app.stack.getRoot().objectName == 'Wallets') { + app.stack.replaceRoot('WalletMainView.qml') + } else { + app.stack.pop() + } } } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index cfdbf4eeb..bf0b0c969 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -38,6 +38,8 @@ ApplicationWindow property alias keyboardFreeZone: _keyboardFreeZone property alias infobanner: _infobanner + property string pendingIntent: "" + property variant activeDialogs: [] property var _exceptionDialog @@ -120,7 +122,7 @@ ApplicationWindow header: ToolBar { id: toolbar - + // Add top margin for status bar on Android when using edge-to-edge topPadding: app.statusBarHeight @@ -269,7 +271,7 @@ ApplicationWindow Layout.fillWidth: true initialItem: Component { - WalletMainView {} + Wallets {} } function getRoot() { @@ -282,6 +284,10 @@ ApplicationWindow mainStackView.push(item) } } + function replaceRoot(item_url) { + mainStackView.clear() + mainStackView.push(Qt.resolvedUrl(item_url)) + } } // Add bottom padding for navigation bar on Android when UI is edge-to-edge @@ -698,6 +704,10 @@ ApplicationWindow if (obj != null) app.pluginobjects[name] = obj } + function onUriReceived(uri) { + console.log('uri received (main): ' + uri) + app.pendingIntent = uri + } } function pluginsComponentsByName(comp_name) { diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 72e618b0c..acbe1a780 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -219,7 +219,7 @@ class QEDaemon(AuthMixin, QObject): except InvalidPassword: self.walletRequiresPassword.emit(self._name, self._path) except FileNotFoundError: - self.walletOpenError.emit(_('File not found')) + self.walletOpenError.emit(_('File not found') + f":\n{self._path}") except StorageReadWriteError: self.walletOpenError.emit(_('Could not read/write file')) except WalletFileException as e: From ba379b7da48adfd2863814686d96035cc4724269 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 16 Dec 2025 16:36:04 +0100 Subject: [PATCH 5/8] qml: LoadingWalletDialog: fix ressource leak When closing the OpenWalletDialog without unlocking a wallet the LoadingWalletDialog wouldn't get properly cleaned up as the LoadingWalletDialog.visible was never set true. This causes the connections to accumulate and the callbacks won't get unregistered after closing the LoadingWalletDialog again. --- electrum/gui/qml/components/LoadingWalletDialog.qml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/LoadingWalletDialog.qml b/electrum/gui/qml/components/LoadingWalletDialog.qml index 2969cdc4d..a680a8ef1 100644 --- a/electrum/gui/qml/components/LoadingWalletDialog.qml +++ b/electrum/gui/qml/components/LoadingWalletDialog.qml @@ -44,7 +44,13 @@ ElDialog { console.log('daemon loading ' + Daemon.loading) if (!Daemon.loading) { showTimer.stop() - dialog.close() + if (dialog.visible) { + dialog.close() + } else { + // if the dialog wasn't visible its onClosed callbacks don't get called, so it + // needs to be destroyed manually + Qt.callLater(function() { dialog.destroy() }) + } } } } From aee0f8fb54ea2b7b5d62faa33475646da5f2667c Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 17 Dec 2025 11:02:26 +0100 Subject: [PATCH 6/8] qml: OpenWalletDialog: load any wallet if password matches If the user has wallets with different passwords (non-unified pw) and enters a password on startup that fails to unlock the recently used wallet this change will automatically open any other wallet if there is another wallet that can be unlocked with this password. --- .../gui/qml/components/OpenWalletDialog.qml | 33 ++++++++++++++++++- electrum/gui/qml/components/main.qml | 8 ++++- electrum/gui/qml/qeconfig.py | 10 ++++++ electrum/gui/qml/qedaemon.py | 1 + electrum/simple_config.py | 2 ++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/OpenWalletDialog.qml b/electrum/gui/qml/components/OpenWalletDialog.qml index b6209dec5..51ec37e61 100644 --- a/electrum/gui/qml/components/OpenWalletDialog.qml +++ b/electrum/gui/qml/components/OpenWalletDialog.qml @@ -12,6 +12,7 @@ ElDialog { property string name property string path + property bool isStartup property bool _invalidPassword: false property bool _unlockClicked: false @@ -40,7 +41,7 @@ ElDialog { InfoTextArea { id: notice - text: Daemon.singlePasswordEnabled || !Daemon.currentWallet + text: Daemon.singlePasswordEnabled || isStartup ? qsTr('Please enter password') : qsTr('Wallet %1 requires password to unlock').arg(name) iconStyle: InfoTextArea.IconStyle.Warn @@ -94,9 +95,39 @@ ElDialog { Daemon.loadWallet(openwalletdialog.path, password.text) } + function maybeUnlockAnyOtherWallet() { + // try to open any other wallet with the password the user entered, hack to improve ux for + // users with non-unified wallet password. + // we should only fall back to opening a random wallet if: + // - the user did not select a specific wallet, otherwise this is confusing + // - there can be more than one password, otherwise this scan would be pointless + if (Daemon.availableWallets.rowCount() <= 1 || password.text === '') { + return false + } + if (Config.walletDidUseSinglePassword) { + // the last time the wallet was unlocked all wallets used the same password. + // trying to decrypt all of them now is most probably useless. + return false + } + if (!openwalletdialog.isStartup) { + return false // this dialog got opened because the user clicked on a specific wallet + } + let wallet_paths = Daemon.getWalletsUnlockableWithPassword(password.text) + if (wallet_paths && wallet_paths.length > 0) { + console.log('could not unlock recent wallet, falling back to: ' + wallet_paths[0]) + Daemon.loadWallet(wallet_paths[0], password.text) + return true + } + return false + } + Connections { target: Daemon function onWalletRequiresPassword() { + if (maybeUnlockAnyOtherWallet()) { + password.text = '' // reset pw so we cannot end up in a loop + return + } console.log('invalid password') _invalidPassword = true password.tf.forceActiveFocus() diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index bf0b0c969..c8d364525 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -634,12 +634,18 @@ ApplicationWindow } property var _opendialog: undefined + property var _opendialog_startup: true function showOpenWalletDialog(name, path) { if (_opendialog == undefined) { - _opendialog = openWalletDialog.createObject(app, { name: name, path: path }) + _opendialog = openWalletDialog.createObject(app, { + name: name, + path: path, + isStartup: _opendialog_startup, + }) _opendialog.closed.connect(function() { _opendialog = undefined + _opendialog_startup = false }) _opendialog.open() } diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 9bf2a2997..d0d4d79e1 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -340,6 +340,16 @@ class QEConfig(AuthMixin, QObject): """ return self.config.WALLET_SHOULD_USE_SINGLE_PASSWORD + walletDidUseSinglePasswordChanged = pyqtSignal() + @pyqtProperty(bool, notify=walletDidUseSinglePasswordChanged) + def walletDidUseSinglePassword(self): + """ + Allows to guess if this is a unified password instance without having + unlocked any wallet yet. Might be out of sync e.g. if wallet files get copied manually. + """ + # TODO: consider removing once encrypted wallet file headers are available + return self.config.WALLET_DID_USE_SINGLE_PASSWORD + @pyqtSlot('qint64', result=str) @pyqtSlot(QEAmount, result=str) def formatSatsForEditing(self, satoshis): diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index acbe1a780..2b1bfa35b 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -237,6 +237,7 @@ class QEDaemon(AuthMixin, QObject): self._logger.info(f'use single password: {self._use_single_password}') else: self._logger.info('use single password disabled by config') + self.daemon.config.WALLET_DID_USE_SINGLE_PASSWORD = self._use_single_password run_hook('load_wallet', wallet) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 92b11defc..a74818626 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -676,6 +676,8 @@ class SimpleConfig(Logger): WALLET_UNCONF_UTXO_FREEZE_THRESHOLD_SAT = ConfigVar('unconf_utxo_freeze_threshold', default=5_000, type_=int) WALLET_PAYREQ_EXPIRY_SECONDS = ConfigVar('request_expiry', default=invoices.PR_DEFAULT_EXPIRATION_WHEN_CREATING, type_=int) WALLET_SHOULD_USE_SINGLE_PASSWORD = ConfigVar('should_use_single_password', default=False, type_=bool) + # TODO: consider removing WALLET_DID_USE_SINGLE_PASSWORD once encrypted wallet file headers are available + WALLET_DID_USE_SINGLE_PASSWORD = ConfigVar('did_use_single_password', default=False, type_=bool) # note: 'use_change' and 'multiple_change' are per-wallet settings WALLET_SEND_CHANGE_TO_LIGHTNING = ConfigVar( 'send_change_to_lightning', default=False, type_=bool, From 6bb8afebe559c74ec8421ecf93d0c1d6ba198206 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 17 Dec 2025 16:58:25 +0100 Subject: [PATCH 7/8] qml: fix incorrect index when deleting wallet QEWalletListModel.remove_wallet was calling beginRemoveRows with i instead of remove as index, causing it to not delete the wallet from the list and the wallet list becoming broken after deleting a wallet. --- electrum/gui/qml/qedaemon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 2b1bfa35b..f76a8fbe3 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -98,7 +98,7 @@ class QEWalletListModel(QAbstractListModel): i += 1 if remove >= 0: - self.beginRemoveRows(QModelIndex(), i, i) + self.beginRemoveRows(QModelIndex(), remove, remove) self._wallets = wallets self.endRemoveRows() From 91b3a4a5dc40550b582af118d7b3ddc2b044ba7b Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 17 Dec 2025 17:17:56 +0100 Subject: [PATCH 8/8] daemon: reset CURRENT_WALLET if the wallet gets deleted Set SimpleConfig.CURRENT_WALLET = None if the wallet gets deleted, otherwise we try to open it on the next startup which will show an error message in QML. --- electrum/daemon.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electrum/daemon.py b/electrum/daemon.py index b1ab1e84d..9c5c9d2e4 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -562,6 +562,8 @@ class Daemon(Logger): if os.path.exists(path): os.unlink(path) self.update_recently_opened_wallets(path, remove=True) + if self.config.CURRENT_WALLET == path: + self.config.CURRENT_WALLET = None return True return False