qml: refactor BIP39 account scan to asyncio tasks.
What changed, and why it matters
This commit is a code cleanup in Electrum's mobile-style QML wallet wizard. It replaces an extra background thread with a simpler asyncio-based callback for scanning BIP39 seed accounts. There is no indication this fixes a security bug; it appears to be a maintainability and lifecycle-handling improvement.
No security action required. Treat as normal code-quality/maintenance review.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors BIP39 account discovery in the QML GUI. Previously, account_discovery coroutine result was waited on via TaskThread wrapping Future.result(). Now the code schedules the coroutine with asyncio.run_coroutine_threadsafe, stores the Future, and uses Future.add_done_callback to emit a Qt signal handled on the GUI side. It also adds explicit cancelScan() and guards against stale results when a scan is restarted. A small QML change destroys the recovery dialog on close to avoid leaking it. No cryptographic, network, or trust-boundary changes are present.
Changed components
electrum/gui/qml/qebip39recovery.pyelectrum/gui/qml/components/wizard/WCScriptAndDerivation.qmlInspect captured patch +44 / −33
### electrum/gui/qml/components/wizard/WCScriptAndDerivation.qml
@@ -220,7 +220,9 @@ WizardComponent {
Component {
id: bip39recoveryDialog
- BIP39RecoveryDialog { }
+ BIP39RecoveryDialog {
+ onClosed: destroy()
+ }
}
Component.onCompleted: {
### electrum/gui/qml/qebip39recovery.py
@@ -1,6 +1,7 @@
import asyncio
-import concurrent
+from concurrent.futures import Future
from enum import IntEnum
+from typing import Optional
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, pyqtEnum
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
@@ -11,7 +12,7 @@
from electrum.logging import get_logger
from electrum.util import get_asyncio_loop
-from electrum.gui.common_qt.util import TaskThread
+from electrum.gui.common_qt.util import ignore_if_destroyed
class QEBip39RecoveryListModel(QAbstractListModel):
@@ -25,9 +26,10 @@ class State(IntEnum):
Failed = 2
Cancelled = 3
- recoveryFailed = pyqtSignal()
stateChanged = pyqtSignal()
+ _scanFinished = pyqtSignal(object)
+
# define listmodel rolemap
_ROLE_NAMES=('description', 'derivation_path', 'script_type')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
@@ -36,9 +38,11 @@ class State(IntEnum):
def __init__(self, config, parent=None):
super().__init__(parent)
self._accounts = []
- self._thread = None
self._root_seed = None
self._state = QEBip39RecoveryListModel.State.Idle
+ self._scan_fut: Optional[Future] = None
+
+ self._scanFinished.connect(self._on_scan_finished)
def rowCount(self, index):
return len(self._accounts)
@@ -77,45 +81,50 @@ def startScan(self, wallet_type: str, seed: str, seed_extra_words: str | None =
assert wallet_type == 'standard'
- self._root_seed = keystore.bip39_to_seed(seed, passphrase=seed_extra_words)
-
+ self.cancelScan()
self.clear()
- self._thread = TaskThread(self)
- network = Network.get_instance()
- coro = account_discovery(network, self.get_account_xpub)
+ self._root_seed = keystore.bip39_to_seed(seed, passphrase=seed_extra_words)
self.state = QEBip39RecoveryListModel.State.Scanning
+
+ coro = account_discovery(Network.get_instance(), self.get_account_xpub)
fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
- self._thread.add(
- fut.result,
- on_success=self.on_recovery_success,
- on_error=self.on_recovery_error,
- cancel=fut.cancel,
- )
+ self._scan_fut = fut
+
+ def on_done(fut: Future):
+ # called on the asyncio thread, or on the cancelling thread
+ with ignore_if_destroyed(self):
+ self._scanFinished.emit(fut)
+ fut.add_done_callback(on_done)
+ self.destroyed.connect(lambda: fut.cancel())
+
+ @pyqtSlot()
+ def cancelScan(self):
+ if self._scan_fut:
+ self._scan_fut.cancel()
+
+ @pyqtSlot(object)
+ def _on_scan_finished(self, fut: Future):
+ if fut is not self._scan_fut:
+ return # result of a scan that has since been restarted
+ self._scan_fut = None
+ if fut.cancelled():
+ self.state = QEBip39RecoveryListModel.State.Cancelled
+ return
+ if exc := fut.exception():
+ self._logger.error('recovery error', exc_info=exc)
+ self.state = QEBip39RecoveryListModel.State.Failed
+ return
+ for account in fut.result():
+ self.addAccount(account)
+ self.state = QEBip39RecoveryListModel.State.Success
def addAccount(self, account):
self._logger.debug(f'addAccount {account!r}')
self.beginInsertRows(QModelIndex(), len(self._accounts), len(self._accounts))
self._accounts.append(account)
self.endInsertRows()
- def on_recovery_success(self, accounts):
- self.state = QEBip39RecoveryListModel.State.Success
-
- for account in accounts:
- self.addAccount(account)
-
- self._thread.stop()
-
- def on_recovery_error(self, exc_info):
- e = exc_info[1]
- if isinstance(e, concurrent.futures.CancelledError):
- self.state = QEBip39RecoveryListModel.State.Cancelled
- return
- self._logger.error(f'recovery error', exc_info=exc_info)
- self.state = QEBip39RecoveryListModel.State.Failed
- self._thread.stop()
-
def get_account_xpub(self, account_path):
root_node = BIP32Node.from_rootseed(self._root_seed, xtype='standard')
account_node = root_node.subkey_at_private_derivation(account_path)Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.