From 0e0c7980ddc6e80ce774ba8c55352f741c376569 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 8 May 2023 13:36:45 +0200 Subject: [PATCH] qml: implement bip39 account detection --- .../qml/components/BIP39RecoveryDialog.qml | 142 ++++++++++++++++++ .../qml/components/wizard/WCBIP39Refine.qml | 36 ++++- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qebip39recovery.py | 129 ++++++++++++++++ electrum/gui/qml/util.py | 82 +++++++++- 5 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/components/BIP39RecoveryDialog.qml create mode 100644 electrum/gui/qml/qebip39recovery.py diff --git a/electrum/gui/qml/components/BIP39RecoveryDialog.qml b/electrum/gui/qml/components/BIP39RecoveryDialog.qml new file mode 100644 index 000000000..8585a0f04 --- /dev/null +++ b/electrum/gui/qml/components/BIP39RecoveryDialog.qml @@ -0,0 +1,142 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + title: qsTr("Detect BIP39 accounts") + + property string seed + property string seedExtraWords + property string walletType + + property string derivationPath + property string scriptType + + z: 1 // raise z so it also covers wizard dialog + + anchors.centerIn: parent + + padding: 0 + + width: parent.width * 4/5 + height: parent.height * 4/5 + + ColumnLayout { + id: rootLayout + width: parent.width + height: parent.height + + InfoTextArea { + Layout.fillWidth: true + Layout.margins: constants.paddingMedium + + text: bip39RecoveryListModel.state == Bip39RecoveryListModel.Scanning + ? qsTr('Scanning for accounts...') + : bip39RecoveryListModel.state == Bip39RecoveryListModel.Success + ? listview.count > 0 + ? qsTr('Choose an account to restore.') + : qsTr('No existing accounts found.') + : bip39RecoveryListModel.state == Bip39RecoveryListModel.Failed + ? qsTr('Recovery failed') + : qsTr('Recovery cancelled') + iconStyle: bip39RecoveryListModel.state == Bip39RecoveryListModel.Scanning + ? InfoTextArea.IconStyle.Spinner + : bip39RecoveryListModel.state == Bip39RecoveryListModel.Success + ? InfoTextArea.IconStyle.Info + : InfoTextArea.IconStyle.Error + } + + Frame { + id: accountsFrame + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: constants.paddingLarge + Layout.bottomMargin: constants.paddingLarge + Layout.leftMargin: constants.paddingMedium + Layout.rightMargin: constants.paddingMedium + + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: bip39RecoveryListModel + + delegate: ItemDelegate { + width: ListView.view.width + height: itemLayout.height + + onClicked: { + dialog.derivationPath = model.derivation_path + dialog.scriptType = model.script_type + dialog.doAccept() + } + + GridLayout { + id: itemLayout + columns: 2 + rowSpacing: 0 + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingMedium + rightMargin: constants.paddingMedium + } + + Label { + Layout.columnSpan: 2 + text: model.description + } + Label { + text: qsTr('script type') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: model.script_type + } + Label { + text: qsTr('derivation path') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: model.derivation_path + } + Item { + Layout.columnSpan: 2 + Layout.preferredHeight: constants.paddingLarge + Layout.preferredWidth: 1 + } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + } + + Bip39RecoveryListModel { + id: bip39RecoveryListModel + } + + Component.onCompleted: { + bip39RecoveryListModel.startScan(walletType, seed, seedExtraWords) + } +} diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml index 5ee048063..a55a89328 100644 --- a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -1,9 +1,11 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 import org.electrum 1.0 +import ".." import "../controls" WizardComponent { @@ -86,10 +88,33 @@ WizardComponent { Label { text: qsTr('Script type and Derivation path') } - Button { - text: qsTr('Detect Existing Accounts') - enabled: false + Pane { + Layout.alignment: Qt.AlignHCenter + padding: 0 visible: !isMultisig + + FlatButton { + text: qsTr('Detect Existing Accounts') + onClicked: { + var dialog = bip39recoveryDialog.createObject(mainLayout, { + walletType: wizard_data['wallet_type'], + seed: wizard_data['seed'], + seedExtraWords: wizard_data['seed_extra_words'] + }) + dialog.accepted.connect(function () { + // select matching script type button and set derivation path + for (var i = 0; i < scripttypegroup.buttons.length; i++) { + var btn = scripttypegroup.buttons[i] + if (btn.visible && btn.scripttype == dialog.scriptType) { + btn.checked = true + derivationpathtext.text = dialog.derivationPath + return + } + } + }) + dialog.open() + } + } } Label { @@ -157,6 +182,11 @@ WizardComponent { id: bitcoin } + Component { + id: bip39recoveryDialog + BIP39RecoveryDialog { } + } + Component.onCompleted: { isMultisig = wizard_data['wallet_type'] == 'multisig' if (isMultisig) { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index e670f1087..c4f3c29c4 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -40,6 +40,7 @@ from .qechanneldetails import QEChannelDetails from .qeswaphelper import QESwapHelper from .qewizard import QENewWalletWizard, QEServerConnectWizard from .qemodelfilter import QEFilterProxyModel +from .qebip39recovery import QEBip39RecoveryListModel if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -339,6 +340,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxRbfFeeBumper, 'org.electrum', 1, 0, 'TxRbfFeeBumper') qmlRegisterType(QETxCpfpFeeBumper, 'org.electrum', 1, 0, 'TxCpfpFeeBumper') qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller') + qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard', 'QNewWalletWizard can only be used as property') diff --git a/electrum/gui/qml/qebip39recovery.py b/electrum/gui/qml/qebip39recovery.py new file mode 100644 index 000000000..f5632e1a8 --- /dev/null +++ b/electrum/gui/qml/qebip39recovery.py @@ -0,0 +1,129 @@ +import asyncio +import concurrent + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex, Q_ENUMS + +from electrum import Network, keystore +from electrum.bip32 import BIP32Node +from electrum.bip39_recovery import account_discovery +from electrum.logging import get_logger + +from .util import TaskThread + +class QEBip39RecoveryListModel(QAbstractListModel): + _logger = get_logger(__name__) + + class State: + Idle = -1 + Scanning = 0 + Success = 1 + Failed = 2 + Cancelled = 3 + + Q_ENUMS(State) + + recoveryFailed = pyqtSignal() + stateChanged = pyqtSignal() + # userinfoChanged = pyqtSignal() + + # define listmodel rolemap + _ROLE_NAMES=('description', 'derivation_path', 'script_type') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + + def __init__(self, config, parent=None): + super().__init__(parent) + self._accounts = [] + self._thread = None + self._root_seed = None + self._state = QEBip39RecoveryListModel.State.Idle + # self._busy = False + # self._userinfo = '' + + def rowCount(self, index): + return len(self._accounts) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + account = self._accounts[index.row()] + role_index = role - Qt.UserRole + value = account[self._ROLE_NAMES[role_index]] + if isinstance(value, (bool, list, int, str)) or value is None: + return value + return str(value) + + def clear(self): + self.beginResetModel() + self._accounts = [] + self.endResetModel() + + # @pyqtProperty(str, notify=userinfoChanged) + # def userinfo(self): + # return self._userinfo + + @pyqtProperty(int, notify=stateChanged) + def state(self): + return self._state + + @state.setter + def state(self, state: State): + if state != self._state: + self._state = state + self.stateChanged.emit() + + @pyqtSlot(str, str) + @pyqtSlot(str, str, str) + def startScan(self, wallet_type: str, seed: str, seed_extra_words: str = None): + if not seed or not wallet_type: + return + + assert wallet_type == 'standard' + + self._root_seed = keystore.bip39_to_seed(seed, seed_extra_words) + + self.clear() + + self._thread = TaskThread(self) + network = Network.get_instance() + coro = account_discovery(network, self.get_account_xpub) + self.state = QEBip39RecoveryListModel.State.Scanning + fut = asyncio.run_coroutine_threadsafe(coro, network.asyncio_loop) + self._thread.add( + fut.result, + on_success=self.on_recovery_success, + on_error=self.on_recovery_error, + cancel=fut.cancel, + ) + + 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) + account_xpub = account_node.to_xpub() + return account_xpub + diff --git a/electrum/gui/qml/util.py b/electrum/gui/qml/util.py index 19db9c537..17e75186c 100644 --- a/electrum/gui/qml/util.py +++ b/electrum/gui/qml/util.py @@ -1,10 +1,16 @@ +import sys +import queue + from functools import wraps from time import time +from typing import Callable, Optional, NamedTuple -from PyQt5.QtCore import pyqtSignal +from PyQt5.QtCore import pyqtSignal, QThread +from electrum.logging import Logger from electrum.util import EventListener, event_listener + class QtEventListener(EventListener): qt_callback_signal = pyqtSignal(tuple) @@ -21,6 +27,7 @@ class QtEventListener(EventListener): func = args[0] return func(self, *args[1:]) + # decorator for members of the QtEventListener class def qt_event_listener(func): func = event_listener(func) @@ -29,6 +36,7 @@ def qt_event_listener(func): self.qt_callback_signal.emit( (func,) + args) return decorator + # return delay in msec when expiry time string should be updated # returns 0 when expired or expires > 1 day away (no updates needed) def status_update_timer_interval(exp): @@ -47,3 +55,75 @@ def status_update_timer_interval(exp): interval = 1000 * 60 * 60 return interval + +# TODO: copied from desktop client, this could be moved to a set of common code. +class TaskThread(QThread, Logger): + '''Thread that runs background tasks. Callbacks are guaranteed + to happen in the context of its parent.''' + + class Task(NamedTuple): + task: Callable + cb_success: Optional[Callable] + cb_done: Optional[Callable] + cb_error: Optional[Callable] + cancel: Optional[Callable] = None + + doneSig = pyqtSignal(object, object, object) + + def __init__(self, parent, on_error=None): + QThread.__init__(self, parent) + Logger.__init__(self) + self.on_error = on_error + self.tasks = queue.Queue() + self._cur_task = None # type: Optional[TaskThread.Task] + self._stopping = False + self.doneSig.connect(self.on_done) + self.start() + + def add(self, task, on_success=None, on_done=None, on_error=None, *, cancel=None): + if self._stopping: + self.logger.warning(f"stopping or already stopped but tried to add new task.") + return + on_error = on_error or self.on_error + task_ = TaskThread.Task(task, on_success, on_done, on_error, cancel=cancel) + self.tasks.put(task_) + + def run(self): + while True: + if self._stopping: + break + task = self.tasks.get() # type: TaskThread.Task + self._cur_task = task + if not task or self._stopping: + break + try: + result = task.task() + self.doneSig.emit(result, task.cb_done, task.cb_success) + except BaseException: + self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error) + + def on_done(self, result, cb_done, cb_result): + # This runs in the parent's thread. + if cb_done: + cb_done() + if cb_result: + cb_result(result) + + def stop(self): + self._stopping = True + # try to cancel currently running task now. + # if the task does not implement "cancel", we will have to wait until it finishes. + task = self._cur_task + if task and task.cancel: + task.cancel() + # cancel the remaining tasks in the queue + while True: + try: + task = self.tasks.get_nowait() + except queue.Empty: + break + if task and task.cancel: + task.cancel() + self.tasks.put(None) # in case the thread is still waiting on the queue + self.exit() + self.wait()