qml: implement bip39 account detection
This commit is contained in:
committed by
accumulator
parent
b40a608b74
commit
0e0c7980dd
142
electrum/gui/qml/components/BIP39RecoveryDialog.qml
Normal file
142
electrum/gui/qml/components/BIP39RecoveryDialog.qml
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Normal file
129
electrum/gui/qml/qebip39recovery.py
Normal file
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user