5 changed files with 387 additions and 4 deletions
@ -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) |
||||
} |
||||
} |
||||
@ -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 |
||||
|
||||
Loading…
Reference in new issue