Browse Source

qml: implement bip39 account detection

master
Sander van Grieken 3 years ago committed by accumulator
parent
commit
0e0c7980dd
  1. 142
      electrum/gui/qml/components/BIP39RecoveryDialog.qml
  2. 36
      electrum/gui/qml/components/wizard/WCBIP39Refine.qml
  3. 2
      electrum/gui/qml/qeapp.py
  4. 129
      electrum/gui/qml/qebip39recovery.py
  5. 82
      electrum/gui/qml/util.py

142
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)
}
}

36
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) {

2
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')

129
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

82
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()

Loading…
Cancel
Save