diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 821de7536..250dc7b58 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -31,10 +31,6 @@ if TYPE_CHECKING: from .qeapp import ElectrumQmlApplication -class UncaughtException(Exception): - pass - - class ElectrumGui(Logger): @profiler diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index cb7bd1af3..a391a2525 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -13,142 +13,26 @@ Wizard { signal walletCreated - property alias path: walletdb.path - - // State transition functions. These functions are called when the 'Next' - // button is pressed. Depending on the data create the next page - // in the conversation. - - function walletnameDone(d) { - console.log('wallet name done') - var page = _loadNextComponent(components.wallettype, wizard_data) - page.next.connect(function() {wallettypeDone()}) - } - - function wallettypeDone(d) { - console.log('wallet type done') - var page = _loadNextComponent(components.keystore, wizard_data) - page.next.connect(function() {keystoretypeDone()}) - } - - function keystoretypeDone(d) { - console.log('keystore type done') - var page - switch(wizard_data['keystore_type']) { - case 'createseed': - page = _loadNextComponent(components.createseed, wizard_data) - page.next.connect(function() {createseedDone()}) - break - case 'haveseed': - page = _loadNextComponent(components.haveseed, wizard_data) - page.next.connect(function() {haveseedDone()}) - if (wizard_data['seed_type'] != 'bip39' && Daemon.singlePasswordEnabled) - page.last = true - break - case 'masterkey': - page = _loadNextComponent(components.havemasterkey, wizard_data) - page.next.connect(function() {havemasterkeyDone()}) - if (Daemon.singlePasswordEnabled) - page.last = true - break - } - } - - function createseedDone(d) { - console.log('create seed done') - var page = _loadNextComponent(components.confirmseed, wizard_data) - if (Daemon.singlePasswordEnabled) - page.last = true - else - page.next.connect(function() {confirmseedDone()}) - } - - function confirmseedDone(d) { - console.log('confirm seed done') - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - - function haveseedDone(d) { - console.log('have seed done') - if (wizard_data['seed_type'] == 'bip39') { - var page = _loadNextComponent(components.bip39refine, wizard_data) - if (Daemon.singlePasswordEnabled) - page.last = true - else - page.next.connect(function() {bip39refineDone()}) - } else { - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - } - - function bip39refineDone(d) { - console.log('bip39 refine done') - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - - function havemasterkeyDone(d) { - console.log('have master key done') - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - - Item { - id: components - property Component walletname: Component { - WCWalletName {} - } - - property Component wallettype: Component { - WCWalletType {} - } - - property Component keystore: Component { - WCKeystoreType {} - } - - property Component createseed: Component { - WCCreateSeed {} - } - - property Component haveseed: Component { - WCHaveSeed {} - } - - property Component confirmseed: Component { - WCConfirmSeed {} - } - - property Component bip39refine: Component { - WCBIP39Refine {} - } - - property Component havemasterkey: Component { - WCHaveMasterKey {} - } - - property Component walletpassword: Component { - WCWalletPassword {} - } - } + property string path + wiz: Daemon.newWalletWizard Component.onCompleted: { - _setWizardData({}) - var start = _loadNextComponent(components.walletname) - start.next.connect(function() {walletnameDone()}) + var view = wiz.start_wizard() + _loadNextComponent(view) } onAccepted: { console.log('Finished new wallet wizard') - walletdb.create_storage(wizard_data, Daemon.singlePasswordEnabled, Daemon.singlePassword) + wiz.createStorage(wizard_data, Daemon.singlePasswordEnabled, Daemon.singlePassword) } - WalletDB { - id: walletdb - onCreateSuccess: walletwizard.walletCreated() + Connections { + target: wiz + function onCreateSuccess() { + walletwizard.path = wiz.path + walletwizard.walletCreated() + } } } diff --git a/electrum/gui/qml/components/OtpDialog.qml b/electrum/gui/qml/components/OtpDialog.qml new file mode 100644 index 000000000..e971d4c24 --- /dev/null +++ b/electrum/gui/qml/components/OtpDialog.qml @@ -0,0 +1,92 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('Trustedcoin') + iconSource: '../../../icons/trustedcoin-status.png' + + property string otpauth + + property bool _waiting: false + property string _otpError + + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + focus: true + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Enter Authenticator code') + font.pixelSize: constants.fontSizeLarge + Layout.alignment: Qt.AlignHCenter + } + + TextField { + id: otpEdit + Layout.preferredWidth: fontMetrics.advanceWidth(passwordCharacter) * 6 + Layout.alignment: Qt.AlignHCenter + font.pixelSize: constants.fontSizeXXLarge + maximumLength: 6 + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly + echoMode: TextInput.Password + focus: true + onTextChanged: { + if (activeFocus) + _otpError = '' + } + } + + Label { + opacity: _otpError ? 1 : 0 + text: _otpError + color: constants.colorError + Layout.alignment: Qt.AlignHCenter + } + + Button { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr('Submit') + enabled: !_waiting + onClicked: { + _waiting = true + Daemon.currentWallet.submitOtp(otpEdit.text) + } + } + } + + Connections { + target: Daemon.currentWallet + function onOtpSuccess() { + _waiting = false + otpauth = otpEdit.text + dialog.accept() + } + function onOtpFailed(code, message) { + _waiting = false + _otpError = message + otpEdit.text = '' + } + } + + FontMetrics { + id: fontMetrics + font: otpEdit.font + } +} diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml index 0f80d47c7..f126a831f 100644 --- a/electrum/gui/qml/components/ServerConnectWizard.qml +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -11,6 +11,8 @@ Wizard { enter: null // disable transition + wiz: Daemon.serverConnectWizard + onAccepted: { var proxy = wizard_data['proxy'] if (proxy && proxy['enabled'] == true) { @@ -25,29 +27,7 @@ Wizard { } Component.onCompleted: { - var start = _loadNextComponent(autoconnect) - start.next.connect(function() {autoconnectDone()}) - } - - function autoconnectDone() { - var page = _loadNextComponent(proxyconfig, wizard_data) - page.next.connect(function() {proxyconfigDone()}) - } - - function proxyconfigDone() { - var page = _loadNextComponent(serverconfig, wizard_data) - } - - property Component autoconnect: Component { - WCAutoConnect {} - } - - property Component proxyconfig: Component { - WCProxyConfig {} + var view = wiz.start_wizard() + _loadNextComponent(view) } - - property Component serverconfig: Component { - WCServerConfig {} - } - } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index e7eb1455b..1ab844005 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -256,6 +256,19 @@ Item { } } + Connections { + target: Daemon.currentWallet + function onOtpRequested() { + console.log('OTP requested') + var dialog = otpDialog.createObject(mainView) + dialog.accepted.connect(function() { + console.log('accepted ' + dialog.otpauth) + Daemon.currentWallet.finish_otp(dialog.otpauth) + }) + dialog.open() + } + } + Component { id: sendDialog SendDialog { @@ -304,5 +317,15 @@ Item { onClosed: destroy() } } + + Component { + id: otpDialog + OtpDialog { + width: parent.width * 2/3 + anchors.centerIn: parent + + onClosed: destroy() + } + } } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 4b2954d90..583c3422d 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -129,7 +129,10 @@ Pane { Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } - Label { text: 'txinType'; color: Material.accentColor } + Label { text: 'wallet type'; color: Material.accentColor } + Label { text: Daemon.currentWallet.walletType } + + Label { text: 'txin Type'; color: Material.accentColor } Label { text: Daemon.currentWallet.txinType } Label { text: 'is deterministic'; color: Material.accentColor } @@ -148,11 +151,16 @@ Pane { Label { text: Daemon.currentWallet.isLightning } Label { text: 'has Seed'; color: Material.accentColor } - Label { text: Daemon.currentWallet.hasSeed; Layout.columnSpan: 3 } + Label { text: Daemon.currentWallet.hasSeed } - Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } + Label { + visible: Daemon.currentWallet.masterPubkey + Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor + } TextHighlightPane { + visible: Daemon.currentWallet.masterPubkey + Layout.columnSpan: 4 Layout.fillWidth: true padding: 0 diff --git a/electrum/gui/qml/components/wizard/WCAutoConnect.qml b/electrum/gui/qml/components/wizard/WCAutoConnect.qml index 9b15d533c..51c4625a7 100644 --- a/electrum/gui/qml/components/wizard/WCAutoConnect.qml +++ b/electrum/gui/qml/components/wizard/WCAutoConnect.qml @@ -1,14 +1,12 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 -import ".." import "../controls" WizardComponent { valid: true - last: serverconnectgroup.checkedButton.connecttype === 'auto' - onAccept: { + function apply() { wizard_data['autoconnect'] = serverconnectgroup.checkedButton.connecttype === 'auto' } @@ -22,17 +20,18 @@ WizardComponent { ButtonGroup { id: serverconnectgroup + onCheckedButtonChanged: checkIsLast() } RadioButton { ButtonGroup.group: serverconnectgroup property string connecttype: 'auto' text: qsTr('Auto connect') + checked: true } RadioButton { ButtonGroup.group: serverconnectgroup property string connecttype: 'manual' - checked: true text: qsTr('Select servers manually') } diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml index 27cb36dd4..2c4dc8ef2 100644 --- a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -10,10 +10,11 @@ import "../controls" WizardComponent { valid: false - onAccept: { + function apply() { wizard_data['script_type'] = scripttypegroup.checkedButton.scripttype wizard_data['derivation_path'] = derivationpathtext.text } + function getScriptTypePurposeDict() { return { 'p2pkh': 44, @@ -51,10 +52,9 @@ WizardComponent { clip:true interactive: height < contentHeight - GridLayout { + ColumnLayout { id: mainLayout width: parent.width - columns: 1 Label { text: qsTr('Script type and Derivation path') } Button { @@ -79,6 +79,7 @@ WizardComponent { text: qsTr('native segwit (p2wpkh)') } InfoTextArea { + Layout.preferredWidth: parent.width text: qsTr('You can override the suggested derivation path.') + ' ' + qsTr('If you are not sure what this is, leave this field unchanged.') } diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index 7c172842e..ee8c3ba1e 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -10,9 +10,8 @@ import "../controls" WizardComponent { valid: seedtext.text != '' - onAccept: { + function apply() { wizard_data['seed'] = seedtext.text - wizard_data['seed_type'] = 'segwit' wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' } @@ -73,11 +72,16 @@ WizardComponent { } Component.onCompleted : { setWarningText(12) - bitcoin.generate_seed() } } } + onReadyChanged: { + if (!ready) + return + bitcoin.generate_seed(wizard_data['seed_type']) + } + Bitcoin { id: bitcoin onGeneratedSeedChanged: { diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index 904432fde..f97abd7f1 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -11,7 +11,7 @@ WizardComponent { valid: false - onAccept: { + function apply() { wizard_data['master_key'] = masterkey_ta.text } diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index a5487cffa..2d140f98d 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -12,38 +12,39 @@ WizardComponent { id: root valid: false - onAccept: { + property bool is2fa: false + + function apply() { wizard_data['seed'] = seedtext.text + wizard_data['seed_variant'] = seed_variant.currentValue wizard_data['seed_type'] = bitcoin.seed_type wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' - wizard_data['seed_bip39'] = seed_type.getTypeCode() == 'BIP39' - wizard_data['seed_slip39'] = seed_type.getTypeCode() == 'SLIP39' } function setSeedTypeHelpText() { var t = { - 'Electrum': [ + 'electrum': [ qsTr('Electrum seeds are the default seed type.'), qsTr('If you are restoring from a seed previously created by Electrum, choose this option') ].join(' '), - 'BIP39': [ + 'bip39': [ qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), '

', qsTr('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.') ].join(' '), - 'SLIP39': [ + 'slip39': [ qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), '

', qsTr('However, we do not generate SLIP39 seeds.') ].join(' ') } - infotext.text = t[seed_type.currentText] + infotext.text = t[seed_variant.currentValue] } function checkValid() { - bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39') + bitcoin.verify_seed(seedtext.text, seed_variant.currentValue, wizard_data['wallet_type']) } Flickable { @@ -58,19 +59,25 @@ WizardComponent { columns: 2 Label { + visible: !is2fa text: qsTr('Seed Type') Layout.fillWidth: true } ComboBox { - id: seed_type - model: ['Electrum', 'BIP39'/*, 'SLIP39'*/] + id: seed_variant + visible: !is2fa + + textRole: 'text' + valueRole: 'value' + model: [ + { text: qsTr('Electrum'), value: 'electrum' }, + { text: qsTr('BIP39'), value: 'bip39' } + ] onActivated: { setSeedTypeHelpText() + checkIsLast() checkValid() } - function getTypeCode() { - return currentText - } } InfoTextArea { id: infotext @@ -91,7 +98,7 @@ WizardComponent { Rectangle { anchors.fill: contentText - color: 'green' + color: root.valid ? 'green' : 'red' border.color: Material.accentColor radius: 2 } @@ -148,4 +155,12 @@ WizardComponent { Component.onCompleted: { setSeedTypeHelpText() } + + onReadyChanged: { + if (!ready) + return + + if (wizard_data['wallet_type'] == '2fa') + root.is2fa = true + } } diff --git a/electrum/gui/qml/components/wizard/WCImport.qml b/electrum/gui/qml/components/wizard/WCImport.qml new file mode 100644 index 000000000..5158d3fd5 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCImport.qml @@ -0,0 +1,102 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../controls" + +WizardComponent { + id: root + + valid: false + + function apply() { + if (bitcoin.isAddressList(import_ta.text)) { + wizard_data['address_list'] = import_ta.text + } else if (bitcoin.isPrivateKeyList(import_ta.text)) { + wizard_data['private_key_list'] = import_ta.text + } + } + + function verify(text) { + return bitcoin.isAddressList(text) || bitcoin.isPrivateKeyList(text) + } + + ColumnLayout { + width: parent.width + + Label { text: qsTr('Import Bitcoin Addresses') } + + InfoTextArea { + text: qsTr('Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.') + } + + RowLayout { + TextArea { + id: import_ta + Layout.fillWidth: true + Layout.minimumHeight: 80 + focus: true + wrapMode: TextEdit.WrapAnywhere + onTextChanged: valid = verify(text) + } + ColumnLayout { + Layout.alignment: Qt.AlignTop + ToolButton { + icon.source: '../../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: { + if (verify(AppController.clipboardToText())) { + if (import_ta.text != '') + import_ta.text = import_ta.text + '\n' + import_ta.text = import_ta.text + AppController.clipboardToText() + } + } + } + ToolButton { + icon.source: '../../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var scan = qrscan.createObject(root) + scan.onFound.connect(function() { + if (verify(scan.scanData)) { + if (import_ta.text != '') + import_ta.text = import_ta.text + ',\n' + import_ta.text = import_ta.text + scan.scanData + } + scan.destroy() + }) + } + } + } + } + } + + Component { + id: qrscan + QRScan { + width: root.width + height: root.height + + ToolButton { + icon.source: '../../../icons/closebutton.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + anchors.right: parent.right + anchors.top: parent.top + onClicked: { + parent.destroy() + } + } + } + } + + Bitcoin { + id: bitcoin + } + +} diff --git a/electrum/gui/qml/components/wizard/WCKeystoreType.qml b/electrum/gui/qml/components/wizard/WCKeystoreType.qml index e8837fe9d..30cb41a9d 100644 --- a/electrum/gui/qml/components/wizard/WCKeystoreType.qml +++ b/electrum/gui/qml/components/wizard/WCKeystoreType.qml @@ -4,7 +4,7 @@ import QtQuick.Controls 2.1 WizardComponent { valid: keystoregroup.checkedButton !== null - onAccept: { + function apply() { wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype } diff --git a/electrum/gui/qml/components/wizard/WCProxyConfig.qml b/electrum/gui/qml/components/wizard/WCProxyConfig.qml index ddc0fc60d..2943e1b3d 100644 --- a/electrum/gui/qml/components/wizard/WCProxyConfig.qml +++ b/electrum/gui/qml/components/wizard/WCProxyConfig.qml @@ -3,7 +3,7 @@ import "../controls" WizardComponent { valid: true - onAccept: { + function apply() { wizard_data['proxy'] = pc.toProxyDict() } diff --git a/electrum/gui/qml/components/wizard/WCServerConfig.qml b/electrum/gui/qml/components/wizard/WCServerConfig.qml index b1a3afea5..939eb1f06 100644 --- a/electrum/gui/qml/components/wizard/WCServerConfig.qml +++ b/electrum/gui/qml/components/wizard/WCServerConfig.qml @@ -4,7 +4,7 @@ WizardComponent { valid: true last: true - onAccept: { + function apply() { wizard_data['oneserver'] = !sc.auto_server wizard_data['server'] = sc.address } diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml index 6f7cbf784..07d12f8bb 100644 --- a/electrum/gui/qml/components/wizard/WCWalletName.qml +++ b/electrum/gui/qml/components/wizard/WCWalletName.qml @@ -7,7 +7,7 @@ import org.electrum 1.0 WizardComponent { valid: wallet_name.text.length > 0 - onAccept: { + function apply() { wizard_data['wallet_name'] = wallet_name.text } diff --git a/electrum/gui/qml/components/wizard/WCWalletPassword.qml b/electrum/gui/qml/components/wizard/WCWalletPassword.qml index b6c55c69d..5e4921ac5 100644 --- a/electrum/gui/qml/components/wizard/WCWalletPassword.qml +++ b/electrum/gui/qml/components/wizard/WCWalletPassword.qml @@ -7,7 +7,7 @@ import "../controls" WizardComponent { valid: password1.text === password2.text && password1.text.length > 4 - onAccept: { + function apply() { wizard_data['password'] = password1.text wizard_data['encrypt'] = password1.text != '' } diff --git a/electrum/gui/qml/components/wizard/WCWalletType.qml b/electrum/gui/qml/components/wizard/WCWalletType.qml index e7c02dd61..a8fc832a4 100644 --- a/electrum/gui/qml/components/wizard/WCWalletType.qml +++ b/electrum/gui/qml/components/wizard/WCWalletType.qml @@ -4,8 +4,13 @@ import QtQuick.Controls 2.1 WizardComponent { valid: wallettypegroup.checkedButton !== null - onAccept: { + function apply() { wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype + if (wizard_data['wallet_type'] == 'standard') + wizard_data['seed_type'] = 'segwit' + else if (wizard_data['wallet_type'] == '2fa') + wizard_data['seed_type'] = '2fa_segwit' + // TODO: multisig } ButtonGroup { @@ -22,7 +27,6 @@ WizardComponent { text: qsTr('Standard Wallet') } RadioButton { - enabled: false ButtonGroup.group: wallettypegroup property string wallettype: '2fa' text: qsTr('Wallet with two-factor authentication') @@ -34,9 +38,8 @@ WizardComponent { text: qsTr('Multi-signature wallet') } RadioButton { - enabled: false ButtonGroup.group: wallettypegroup - property string wallettype: 'import' + property string wallettype: 'imported' text: qsTr('Import Bitcoin addresses or private keys') } } diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index 1caf02899..6a80d1fb4 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -11,7 +11,8 @@ Dialog { height: parent.height property var wizard_data - property alias pages : pages + property alias pages: pages + property QtObject wiz function _setWizardData(wdata) { wizard_data = {} @@ -24,12 +25,19 @@ Dialog { // Here we do some manual binding of page.valid -> pages.pagevalid and // page.last -> pages.lastpage to propagate the state without the binding // going stale. - function _loadNextComponent(comp, wdata={}) { + function _loadNextComponent(view, wdata={}) { // remove any existing pages after current page while (pages.contentChildren[pages.currentIndex+1]) { pages.takeItem(pages.currentIndex+1).destroy() } + var url = Qt.resolvedUrl(wiz.viewToComponent(view)) + console.log(url) + var comp = Qt.createComponent(url) + if (comp.status == Component.Error) { + console.log(comp.errorString()) + return null + } var page = comp.createObject(pages) page.validChanged.connect(function() { pages.pagevalid = page.valid @@ -37,6 +45,19 @@ Dialog { page.lastChanged.connect(function() { pages.lastpage = page.last } ) + page.next.connect(function() { + var newview = wiz.submit(page.wizard_data) + if (newview.view) { + console.log('next view: ' + newview.view) + var newpage = _loadNextComponent(newview.view, newview.wizard_data) + } else { + console.log('END') + } + }) + page.prev.connect(function() { + var wdata = wiz.prev() + console.log('prev view data: ' + JSON.stringify(wdata)) + }) Object.assign(page.wizard_data, wdata) // deep copy page.ready = true // signal page it can access wizard_data pages.pagevalid = page.valid @@ -58,10 +79,12 @@ Dialog { clip:true function prev() { + currentItem.prev() currentIndex = currentIndex - 1 _setWizardData(pages.contentChildren[currentIndex].wizard_data) pages.pagevalid = pages.contentChildren[currentIndex].valid pages.lastpage = pages.contentChildren[currentIndex].last + } function next() { diff --git a/electrum/gui/qml/components/wizard/WizardComponent.qml b/electrum/gui/qml/components/wizard/WizardComponent.qml index 798b7ad8d..25a9b7072 100644 --- a/electrum/gui/qml/components/wizard/WizardComponent.qml +++ b/electrum/gui/qml/components/wizard/WizardComponent.qml @@ -2,9 +2,25 @@ import QtQuick 2.0 Item { signal next + signal prev signal accept property var wizard_data : ({}) property bool valid property bool last: false property bool ready: false + + onAccept: { + apply() + } + + function apply() { } + function checkIsLast() { + apply() + last = wizard.wiz.isLast(wizard_data) + } + + Component.onCompleted: { + checkIsLast() + } + } diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index c82802fbc..1a8881bad 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -65,7 +65,7 @@ class QEAddressListModel(QAbstractListModel): def init_model(self): r_addresses = self.wallet.get_receiving_addresses() c_addresses = self.wallet.get_change_addresses() - n_addresses = len(r_addresses) + len(c_addresses) + n_addresses = len(r_addresses) + len(c_addresses) if self.wallet.use_change else 0 def insert_row(atype, alist, address, iaddr): item = self.addr_to_model(address) @@ -80,7 +80,7 @@ class QEAddressListModel(QAbstractListModel): insert_row('receive', self.receive_addresses, address, i) i = i + 1 i = 0 - for address in c_addresses: + for address in c_addresses if self.wallet.use_change else []: insert_row('change', self.change_addresses, address, i) i = i + 1 self.endInsertRows() diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 273ac8e03..c21aefee3 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -29,6 +29,7 @@ from .qechannelopener import QEChannelOpener from .qelnpaymentdetails import QELnPaymentDetails from .qechanneldetails import QEChannelDetails from .qeswaphelper import QESwapHelper +from .qewizard import QENewWalletWizard, QEServerConnectWizard notification = None @@ -217,6 +218,8 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QERequestDetails, 'org.electrum', 1, 0, 'RequestDetails') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') + qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'NewWalletWizard', 'NewWalletWizard can only be used as property') + qmlRegisterUncreatableType(QEServerConnectWizard, 'org.electrum', 1, 0, 'ServerConnectWizard', 'ServerConnectWizard can only be used as property') self.engine = QQmlApplicationEngine(parent=self) @@ -254,6 +257,8 @@ class ElectrumQmlApplication(QGuiApplication): 'protocol_version': version.PROTOCOL_VERSION }) + self.plugins.load_plugin('trustedcoin') + qInstallMessageHandler(self.message_handler) # get notified whether root QML document loads or not diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index b3f9cd82c..70dd6437f 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -10,6 +10,7 @@ from electrum.logging import get_logger from electrum.slip39 import decode_mnemonic, Slip39Error from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI, get_asyncio_loop from electrum.transaction import tx_from_any +from electrum.mnemonic import is_any_2fa_seed_type from .qetypes import QEAmount @@ -67,22 +68,18 @@ class QEBitcoin(QObject): asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), get_asyncio_loop()) - @pyqtSlot(str) - @pyqtSlot(str,bool,bool) - @pyqtSlot(str,bool,bool,str,str,str) - def verify_seed(self, seed, bip39=False, slip39=False, wallet_type='standard', language='en'): - self._logger.debug('bip39 ' + str(bip39)) - self._logger.debug('slip39 ' + str(slip39)) - + @pyqtSlot(str,str) + @pyqtSlot(str,str,str) + def verify_seed(self, seed, seed_variant, wallet_type='standard'): seed_type = '' seed_valid = False self.validationMessage = '' - if not (bip39 or slip39): + if seed_variant == 'electrum': seed_type = mnemonic.seed_type(seed) if seed_type != '': seed_valid = True - elif bip39: + elif seed_variant == 'bip39': is_checksum, is_wordlist = keystore.bip39_is_checksum_valid(seed) status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' self.validationMessage = 'BIP39 (%s)' % status @@ -90,8 +87,7 @@ class QEBitcoin(QObject): if is_checksum: seed_type = 'bip39' seed_valid = True - - elif slip39: # TODO: incomplete impl, this code only validates a single share. + elif seed_variant == 'slip39': # TODO: incomplete impl, this code only validates a single share. try: share = decode_mnemonic(seed) seed_type = 'slip39' @@ -99,10 +95,13 @@ class QEBitcoin(QObject): except Slip39Error as e: self.validationMessage = 'SLIP39: %s' % str(e) seed_valid = False # for now + else: + raise Exception(f'unknown seed variant {seed_variant}') - # cosigning seed - if wallet_type != 'standard' and seed_type not in ['standard', 'segwit']: - seed_type = '' + # check if seed matches wallet type + if wallet_type == '2fa' and not is_any_2fa_seed_type(seed_type): + seed_valid = False + elif wallet_type == 'standard' and seed_type not in ['old', 'standard', 'segwit', 'bip39']: seed_valid = False self.seedType = seed_type @@ -161,3 +160,12 @@ class QEBitcoin(QObject): return True except: return False + + @pyqtSlot(str, result=bool) + def isAddressList(self, csv: str): + return keystore.is_address_list(csv) + + @pyqtSlot(str, result=bool) + def isPrivateKeyList(self, csv: str): + return keystore.is_private_key_list(csv) + diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 7e5e2562c..5ae0e8232 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -14,6 +14,7 @@ from .auth import AuthMixin, auth_protect from .qefx import QEFX from .qewallet import QEWallet from .qewalletdb import QEWalletDB +from .qewizard import QENewWalletWizard, QEServerConnectWizard # wallet list model. supports both wallet basenames (wallet file basenames) # and whole Wallet instances (loaded wallets) @@ -121,16 +122,21 @@ class QEDaemon(AuthMixin, QObject): _loaded_wallets = QEWalletListModel() _available_wallets = None _current_wallet = None + _new_wallet_wizard = None + _server_connect_wizard = None _path = None _use_single_password = False _password = None - walletLoaded = pyqtSignal() - walletRequiresPassword = pyqtSignal() activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() - walletOpenError = pyqtSignal([str], arguments=["error"]) fxChanged = pyqtSignal() + newWalletWizardChanged = pyqtSignal() + serverConnectWizardChanged = pyqtSignal() + + walletLoaded = pyqtSignal() + walletRequiresPassword = pyqtSignal() + walletOpenError = pyqtSignal([str], arguments=["error"]) walletDeleteError = pyqtSignal([str,str], arguments=['code', 'message']) @pyqtSlot() @@ -157,7 +163,9 @@ class QEDaemon(AuthMixin, QObject): if not password: password = self._password - if self._path not in self.daemon._wallets: + wallet_already_open = self._path in self.daemon._wallets + + if not wallet_already_open: # pre-checks, let walletdb trigger any necessary user interactions self._walletdb.path = self._path self._walletdb.password = password @@ -168,9 +176,10 @@ class QEDaemon(AuthMixin, QObject): try: wallet = self.daemon.load_wallet(self._path, password) if wallet != None: - self._loaded_wallets.add_wallet(wallet_path=self._path, wallet=wallet) self._current_wallet = QEWallet.getInstanceFor(wallet) - self._current_wallet.password = password + if not wallet_already_open: + self._loaded_wallets.add_wallet(wallet_path=self._path, wallet=wallet) + self._current_wallet.password = password self.walletLoaded.emit() if self.daemon.config.get('single_password'): @@ -283,3 +292,16 @@ class QEDaemon(AuthMixin, QObject): self.daemon.update_password_for_directory(old_password=self._password, new_password=password) self._password = password + @pyqtProperty(QENewWalletWizard, notify=newWalletWizardChanged) + def newWalletWizard(self): + if not self._new_wallet_wizard: + self._new_wallet_wizard = QENewWalletWizard(self) + + return self._new_wallet_wizard + + @pyqtProperty(QEServerConnectWizard, notify=serverConnectWizardChanged) + def serverConnectWizard(self): + if not self._server_connect_wizard: + self._server_connect_wizard = QEServerConnectWizard(self) + + return self._server_connect_wizard diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 1d0138a6b..329bd8a73 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -3,6 +3,7 @@ import queue import threading import time from typing import Optional, TYPE_CHECKING +from functools import partial from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer @@ -13,6 +14,7 @@ from electrum.logging import get_logger from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.transaction import PartialTxOutput from electrum.util import (parse_max_spend, InvalidPassword, event_listener) +from electrum.plugin import run_hook from .auth import AuthMixin, auth_protect from .qeaddresslistmodel import QEAddressListModel @@ -63,6 +65,9 @@ class QEWallet(AuthMixin, QObject, QtEventListener): #broadcastSucceeded = pyqtSignal([str], arguments=['txid']) broadcastFailed = pyqtSignal([str,str,str], arguments=['txid','code','reason']) labelsUpdated = pyqtSignal() + otpRequested = pyqtSignal() + otpSuccess = pyqtSignal() + otpFailed = pyqtSignal([str,str], arguments=['code','message']) _network_signal = pyqtSignal(str, object) @@ -298,12 +303,18 @@ class QEWallet(AuthMixin, QObject, QtEventListener): def canHaveLightning(self): return self.wallet.can_have_lightning() + @pyqtProperty(str, notify=dataChanged) + def walletType(self): + return self.wallet.wallet_type + @pyqtProperty(bool, notify=dataChanged) def hasSeed(self): return self.wallet.has_seed() @pyqtProperty(str, notify=dataChanged) def txinType(self): + if self.wallet.wallet_type == 'imported': + return self.wallet.txin_type return self.wallet.get_txin_type(self.wallet.dummy_address()) @pyqtProperty(bool, notify=dataChanged) @@ -327,6 +338,9 @@ class QEWallet(AuthMixin, QObject, QtEventListener): keystores = self.wallet.get_keystores() if len(keystores) > 1: self._logger.debug('multiple keystores not supported yet') + if len(keystores) == 0: + self._logger.debug('no keystore') + return '' return keystores[0].get_derivation_prefix() @pyqtProperty(str, notify=dataChanged) @@ -413,6 +427,17 @@ class QEWallet(AuthMixin, QObject, QtEventListener): @auth_protect def sign(self, tx, *, broadcast: bool = False): + sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, partial(self.on_sign_complete, broadcast), + self.on_sign_failed) + if sign_hook: + self.do_sign(tx, False) + self._logger.debug('plugin needs to sign tx too') + sign_hook(tx) + return + + self.do_sign(tx, broadcast) + + def do_sign(self, tx, broadcast): tx = self.wallet.sign_transaction(tx, self.password) if tx is None: @@ -431,6 +456,23 @@ class QEWallet(AuthMixin, QObject, QtEventListener): if broadcast: self.broadcast(tx) + # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok + def on_sign_complete(self, broadcast, tx): + self.otpSuccess.emit() + if broadcast: + self.broadcast(tx) + + def on_sign_failed(self, error): + self.otpFailed.emit('error', error) + + def request_otp(self, on_submit): + self._otp_on_submit = on_submit + self.otpRequested.emit() + + @pyqtSlot(str) + def submitOtp(self, otp): + self._otp_on_submit(otp) + def broadcast(self, tx): assert tx.is_complete() @@ -544,7 +586,10 @@ class QEWallet(AuthMixin, QObject, QtEventListener): addr = None if self.wallet.config.get('bolt11_fallback', True): addr = self.wallet.get_unused_address() - # if addr is None, we ran out of addresses. for lightning enabled wallets, ignore for now + # if addr is None, we ran out of addresses + if addr is None: + # TODO: remove oldest unpaid request having a fallback address and try again + pass key = self.wallet.create_request(None, None, default_expiry, addr) else: key, addr = self.create_bitcoin_request(None, None, default_expiry, ignore_gap) diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 33a736117..52ff68c01 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -29,10 +29,8 @@ class QEWalletDB(QObject): requiresSplitChanged = pyqtSignal() splitFinished = pyqtSignal() readyChanged = pyqtSignal() - createError = pyqtSignal([str], arguments=["error"]) - createSuccess = pyqtSignal() invalidPassword = pyqtSignal() - + def reset(self): self._path = None self._needsPassword = False @@ -172,69 +170,3 @@ class QEWalletDB(QObject): self._ready = True self.readyChanged.emit() - @pyqtSlot('QJSValue',bool,str) - def create_storage(self, js_data, single_password_enabled, single_password): - self._logger.info('Creating wallet from wizard data') - data = js_data.toVariant() - self._logger.debug(str(data)) - - assert data['wallet_type'] == 'standard' # only standard wallets for now - - if single_password_enabled and single_password: - data['encrypt'] = True - data['password'] = single_password - - try: - path = os.path.join(os.path.dirname(self.daemon.config.get_wallet_path()), data['wallet_name']) - if os.path.exists(path): - raise Exception('file already exists at path') - storage = WalletStorage(path) - - if data['keystore_type'] in ['createseed', 'haveseed']: - if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-segwit - self._logger.debug('creating keystore from electrum seed') - k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') - elif data['seed_type'] == 'bip39': - self._logger.debug('creating keystore from bip39 seed') - root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) - derivation = normalize_bip32_derivation(data['derivation_path']) - script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' - k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) - else: - raise Exception('unsupported/unknown seed_type %s' % data['seed_type']) - elif data['keystore_type'] == 'masterkey': - k = keystore.from_master_key(data['master_key']) - has_xpub = isinstance(k, keystore.Xpub) - assert has_xpub - t1 = xpub_type(k.xpub) - if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: - raise Exception('wrong key type %s' % t1) - else: - raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) - - if data['encrypt']: - if k.may_have_password(): - k.update_password(None, data['password']) - storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) - - db = WalletDB('', manual_upgrades=False) - db.set_keystore_encryption(bool(data['password']) and data['encrypt']) - - db.put('wallet_type', data['wallet_type']) - if 'seed_type' in data: - db.put('seed_type', data['seed_type']) - db.put('keystore', k.dump()) - if k.can_have_deterministic_lightning_xprv(): - db.put('lightning_xprv', k.get_lightning_xprv(data['password'] if data['encrypt'] else None)) - - db.load_plugins() - db.write(storage) - - # minimally populate self after create - self._password = data['password'] - self.path = path - - self.createSuccess.emit() - except Exception as e: - self._logger.error(repr(e)) - self.createError.emit(str(e)) diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py new file mode 100644 index 000000000..1f5daa8f8 --- /dev/null +++ b/electrum/gui/qml/qewizard.py @@ -0,0 +1,115 @@ +import os + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtQml import QQmlApplicationEngine + +from electrum.logging import get_logger +from electrum.wizard import NewWalletWizard, ServerConnectWizard + +class QEAbstractWizard(QObject): + _logger = get_logger(__name__) + + def __init__(self, parent = None): + QObject.__init__(self, parent) + + @pyqtSlot(result=str) + def start_wizard(self): + self.start() + return self._current.view + + @pyqtSlot(str, result=str) + def viewToComponent(self, view): + return self.navmap[view]['gui'] + '.qml' + + @pyqtSlot('QJSValue', result='QVariant') + def submit(self, wizard_data): + wdata = wizard_data.toVariant() + self._logger.debug(str(wdata)) + view = self.resolve_next(self._current.view, wdata) + return { 'view': view.view, 'wizard_data': view.wizard_data } + + @pyqtSlot(result='QVariant') + def prev(self): + viewstate = self.resolve_prev() + return viewstate.wizard_data + + @pyqtSlot('QJSValue', result=bool) + def isLast(self, wizard_data): + wdata = wizard_data.toVariant() + return self.is_last_view(self._current.view, wdata) + + +class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): + + createError = pyqtSignal([str], arguments=["error"]) + createSuccess = pyqtSignal() + + def __init__(self, daemon, parent = None): + NewWalletWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, parent) + self._daemon = daemon + + # attach view names + self.navmap_merge({ + 'wallet_name': { 'gui': 'WCWalletName' }, + 'wallet_type': { 'gui': 'WCWalletType' }, + 'keystore_type': { 'gui': 'WCKeystoreType' }, + 'create_seed': { 'gui': 'WCCreateSeed' }, + 'confirm_seed': { 'gui': 'WCConfirmSeed' }, + 'have_seed': { 'gui': 'WCHaveSeed' }, + 'bip39_refine': { 'gui': 'WCBIP39Refine' }, + 'have_master_key': { 'gui': 'WCHaveMasterKey' }, + 'imported': { 'gui': 'WCImport' }, + 'wallet_password': { 'gui': 'WCWalletPassword' } + }) + + pathChanged = pyqtSignal() + @pyqtProperty(str, notify=pathChanged) + def path(self): + return self._path + + @path.setter + def path(self, path): + self._path = path + self.pathChanged.emit() + + def last_if_single_password(self, *args): + return self._daemon.singlePasswordEnabled + + @pyqtSlot('QJSValue', bool, str) + def createStorage(self, js_data, single_password_enabled, single_password): + self._logger.info('Creating wallet from wizard data') + data = js_data.toVariant() + self._logger.debug(str(data)) + + if single_password_enabled and single_password: + data['encrypt'] = True + data['password'] = single_password + + path = os.path.join(os.path.dirname(self._daemon.daemon.config.get_wallet_path()), data['wallet_name']) + + try: + self.create_storage(path, data) + + # minimally populate self after create + self._password = data['password'] + self.path = path + + self.createSuccess.emit() + except Exception as e: + self._logger.error(repr(e)) + self.createError.emit(str(e)) + +class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard): + + def __init__(self, daemon, parent = None): + ServerConnectWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, parent) + self._daemon = daemon + + # attach view names + self.navmap_merge({ + 'autoconnect': { 'gui': 'WCAutoConnect' }, + 'proxy_config': { 'gui': 'WCProxyConfig' }, + 'server_config': { 'gui': 'WCServerConfig' }, + }) diff --git a/electrum/plugins/qml_test/__init__.py b/electrum/plugins/qml_test/__init__.py deleted file mode 100644 index 62e390176..000000000 --- a/electrum/plugins/qml_test/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from electrum.i18n import _ - -fullname = 'QML Plugin Test' -description = '%s\n%s' % (_("Plugin to test QML integration from plugins."), _("Note: Used for development")) -available_for = ['qml'] diff --git a/electrum/plugins/qml_test/qml.py b/electrum/plugins/qml_test/qml.py deleted file mode 100644 index 0d5233812..000000000 --- a/electrum/plugins/qml_test/qml.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import TYPE_CHECKING -from PyQt5.QtQml import QQmlApplicationEngine -from electrum.plugin import hook, BasePlugin -from electrum.logging import get_logger - -if TYPE_CHECKING: - from electrum.gui.qml import ElectrumGui - -class Plugin(BasePlugin): - def __init__(self, parent, config, name): - BasePlugin.__init__(self, parent, config, name) - - @hook - def init_qml(self, gui: 'ElectrumGui'): - self.logger.debug('init_qml hook called') diff --git a/electrum/plugins/trustedcoin/__init__.py b/electrum/plugins/trustedcoin/__init__.py index 81ec4f2ce..e04ed9dc8 100644 --- a/electrum/plugins/trustedcoin/__init__.py +++ b/electrum/plugins/trustedcoin/__init__.py @@ -8,4 +8,4 @@ description = ''.join([ ]) requires_wallet_type = ['2fa'] registers_wallet_type = '2fa' -available_for = ['qt', 'cmdline', 'kivy'] +available_for = ['qt', 'cmdline', 'kivy', 'qml'] diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py new file mode 100644 index 000000000..85b7e11b0 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml.py @@ -0,0 +1,395 @@ +import threading +import socket +import base64 +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot + +from electrum.i18n import _ +from electrum.plugin import hook +from electrum.bip32 import xpub_type, BIP32Node +from electrum.util import UserFacingException +from electrum import keystore + +from electrum.gui.qml.qewallet import QEWallet +from electrum.gui.qml.plugins import PluginQObject + +from .trustedcoin import (TrustedCoinPlugin, server, ErrorConnectingServer, + MOBILE_DISCLAIMER, get_user_id, get_signing_xpub, + TrustedCoinException, make_xpub) + +if TYPE_CHECKING: + from electrum.gui.qml import ElectrumGui + from electrum.wallet import Abstract_Wallet + +class Plugin(TrustedCoinPlugin): + + class QSignalObject(PluginQObject): + canSignWithoutServerChanged = pyqtSignal() + _canSignWithoutServer = False + termsAndConditionsChanged = pyqtSignal() + _termsAndConditions = '' + termsAndConditionsErrorChanged = pyqtSignal() + _termsAndConditionsError = '' + otpError = pyqtSignal([str], arguments=['message']) + otpSuccess = pyqtSignal() + disclaimerChanged = pyqtSignal() + keystoreChanged = pyqtSignal() + otpSecretChanged = pyqtSignal() + _otpSecret = '' + shortIdChanged = pyqtSignal() + _shortId = '' + + _remoteKeyState = '' + remoteKeyStateChanged = pyqtSignal() + remoteKeyError = pyqtSignal([str], arguments=['message']) + + requestOtp = pyqtSignal() + + def __init__(self, plugin, parent): + super().__init__(plugin, parent) + + @pyqtProperty(str, notify=disclaimerChanged) + def disclaimer(self): + return '\n\n'.join(MOBILE_DISCLAIMER) + + @pyqtProperty(bool, notify=canSignWithoutServerChanged) + def canSignWithoutServer(self): + return self._canSignWithoutServer + + @pyqtProperty('QVariantMap', notify=keystoreChanged) + def keystore(self): + return self._keystore + + @pyqtProperty(str, notify=otpSecretChanged) + def otpSecret(self): + return self._otpSecret + + @pyqtProperty(str, notify=shortIdChanged) + def shortId(self): + return self._shortId + + @pyqtSlot(str) + def otpSubmit(self, otp): + self._plugin.on_otp(otp) + + @pyqtProperty(str, notify=termsAndConditionsChanged) + def termsAndConditions(self): + return self._termsAndConditions + + @pyqtProperty(str, notify=termsAndConditionsErrorChanged) + def termsAndConditionsError(self): + return self._termsAndConditionsError + + @pyqtProperty(str, notify=remoteKeyStateChanged) + def remoteKeyState(self): + return self._remoteKeyState + + @remoteKeyState.setter + def remoteKeyState(self, new_state): + if self._remoteKeyState != new_state: + self._remoteKeyState = new_state + self.remoteKeyStateChanged.emit() + + @pyqtSlot() + def fetchTermsAndConditions(self): + def fetch_task(): + try: + self.plugin.logger.debug('TOS') + tos = server.get_terms_of_service() + except ErrorConnectingServer as e: + self._termsAndConditionsError = _('Error connecting to server') + self.termsAndConditionsErrorChanged.emit() + except Exception as e: + self._termsAndConditionsError = '%s: %s' % (_('Error'), repr(e)) + self.termsAndConditionsErrorChanged.emit() + else: + self._termsAndConditions = tos + self.termsAndConditionsChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + t = threading.Thread(target=fetch_task) + t.daemon = True + t.start() + + @pyqtSlot(str) + def createKeystore(self, email): + self.remoteKeyState = '' + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() + def create_remote_key_task(): + try: + self.plugin.logger.debug('create remote key') + r = server.create(xpub1, xpub2, email) + + otp_secret = r['otp_secret'] + _xpub3 = r['xpubkey_cosigner'] + _id = r['id'] + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') + except TrustedCoinException as e: + if e.status_code == 409: + self.remoteKeyState = 'wallet_known' + self._shortId = short_id + self.shortIdChanged.emit() + else: + self.remoteKeyState = 'error' + self.logger.warning(str(e)) + self.remoteKeyError.emit(f'Service error: {str(e)}') + except (KeyError,TypeError) as e: # catch any assumptions + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + self.logger.error(str(e)) + else: + if short_id != _id: + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id)) + self.remoteKeyError.emit('Unexpected short_id') + return + if xpub3 != _xpub3: + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) + self.remoteKeyError.emit('Unexpected trustedcoin xpub3') + return + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + self._shortId = short_id + self.shortIdChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + + t = threading.Thread(target=create_remote_key_task) + t.daemon = True + t.start() + + @pyqtSlot() + def resetOtpSecret(self): + self.remoteKeyState = '' + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() + def reset_otp_task(): + try: + self.plugin.logger.debug('reset_otp') + r = server.get_challenge(short_id) + challenge = r.get('challenge') + message = 'TRUSTEDCOIN CHALLENGE: ' + challenge + def f(xprv): + rootnode = BIP32Node.from_xkey(xprv) + key = rootnode.subkey_at_private_derivation((0, 0)).eckey + sig = key.sign_message(message, True) + return base64.b64encode(sig).decode() + + signatures = [f(x) for x in [xprv1, xprv2]] + r = server.reset_auth(short_id, challenge, signatures) + otp_secret = r.get('otp_secret') + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') + except Exception as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + else: + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + + t = threading.Thread(target=reset_otp_task, daemon=True) + t.start() + + @pyqtSlot(str, int) + def checkOtp(self, short_id, otp): + def check_otp_task(): + try: + self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}') + server.auth(short_id, otp) + except TrustedCoinException as e: + if e.status_code == 400: # invalid OTP + self.plugin.logger.debug('Invalid one-time password.') + self.otpError.emit(_('Invalid one-time password.')) + else: + self.plugin.logger.error(str(e)) + self.otpError.emit(f'Service error: {str(e)}') + except Exception as e: + self.plugin.logger.error(str(e)) + self.otpError.emit(f'Error: {str(e)}') + else: + self.plugin.logger.debug('OTP verify success') + self.otpSuccess.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + t = threading.Thread(target=check_otp_task, daemon=True) + t.start() + + + def __init__(self, *args): + super().__init__(*args) + + @hook + def load_wallet(self, wallet: 'Abstract_Wallet'): + if not isinstance(wallet, self.wallet_class): + return + self.logger.debug(f'plugin enabled for wallet "{str(wallet)}"') + #wallet.handler_2fa = HandlerTwoFactor(self, window) + if wallet.can_sign_without_server(): + self.so._canSignWithoutServer = True + self.so.canSignWithoutServerChanged.emit() + + msg = ' '.join([ + _('This wallet was restored from seed, and it contains two master private keys.'), + _('Therefore, two-factor authentication is disabled.') + ]) + self.logger.info(msg) + #action = lambda: window.show_message(msg) + #else: + #action = partial(self.settings_dialog, window) + #button = StatusBarButton(read_QIcon("trustedcoin-status.png"), + #_("TrustedCoin"), action) + #window.statusBar().addPermanentWidget(button) + self.start_request_thread(wallet) + + @hook + def init_qml(self, gui: 'ElectrumGui'): + self.logger.debug(f'init_qml hook called, gui={str(type(gui))}') + self._app = gui.app + # important: QSignalObject needs to be parented, as keeping a ref + # in the plugin is not enough to avoid gc + self.so = Plugin.QSignalObject(self, self._app) + + # extend wizard + self.extend_wizard() + + def extend_wizard(self): + wizard = self._app.daemon.newWalletWizard + self.logger.debug(repr(wizard)) + views = { + 'trustedcoin_start': { + 'gui': '../../../../plugins/trustedcoin/qml/Disclaimer', + 'next': 'trustedcoin_choose_seed' + }, + 'trustedcoin_choose_seed': { + 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', + 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' + else 'trustedcoin_have_seed' + }, + 'trustedcoin_create_seed': { + 'gui': 'WCCreateSeed', + 'next': 'trustedcoin_confirm_seed' + }, + 'trustedcoin_confirm_seed': { + 'gui': 'WCConfirmSeed', + 'next': 'trustedcoin_tos_email' + }, + 'trustedcoin_have_seed': { + 'gui': 'WCHaveSeed', + 'next': 'trustedcoin_keep_disable' + }, + 'trustedcoin_keep_disable': { + 'gui': '../../../../plugins/trustedcoin/qml/KeepDisable', + 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_keepordisable'] != 'disable' + else 'wallet_password', + 'accept': self.recovery_disable, + 'last': lambda v,d: wizard.last_if_single_password() and d['trustedcoin_keepordisable'] == 'disable' + }, + 'trustedcoin_tos_email': { + 'gui': '../../../../plugins/trustedcoin/qml/Terms', + 'next': 'trustedcoin_show_confirm_otp' + }, + 'trustedcoin_show_confirm_otp': { + 'gui': '../../../../plugins/trustedcoin/qml/ShowConfirmOTP', + 'accept': self.on_accept_otp_secret, + 'next': 'wallet_password', + 'last': wizard.last_if_single_password + } + } + wizard.navmap_merge(views) + + + # combined create_keystore and create_remote_key pre + def create_keys(self): + wizard = self._app.daemon.newWalletWizard + wizard_data = wizard._current.wizard_data + + xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], wizard_data['seed_extra_words']) + + # NOTE: at this point, old style wizard creates a wallet file (w. password if set) and + # stores the keystores and wizard state, in order to separate offline seed creation + # and online retrieval of the OTP secret. For mobile, we don't do this, but + # for desktop the wizard should support this usecase. + + data = {'x1/': {'xpub': xpub1}, 'x2/': {'xpub': xpub2}} + + # Generate third key deterministically. + long_user_id, short_id = get_user_id(data) + xtype = xpub_type(xpub1) + xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) + + return (xprv1,xpub1,xprv2,xpub2,xpub3,short_id) + + def on_accept_otp_secret(self, wizard_data): + self.logger.debug('OTP secret accepted, creating keystores') + xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xpub(xpub2) + k3 = keystore.from_xpub(xpub3) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + wizard_data['x3/'] = k3.dump() + + def recovery_disable(self, wizard_data): + if wizard_data['trustedcoin_keepordisable'] != 'disable': + return + + self.logger.debug('2fa disabled, creating keystores') + xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xprv(xprv2) + k3 = keystore.from_xpub(xpub3) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + wizard_data['x3/'] = k3.dump() + + + # regular wallet prompt functions + + def prompt_user_for_otp(self, wallet, tx, on_success, on_failure): + self.logger.debug('prompt_user_for_otp') + self.on_success = on_success + self.on_failure = on_failure if on_failure else lambda x: self.logger.error(x) + self.wallet = wallet + self.tx = tx + qewallet = QEWallet.getInstanceFor(wallet) + qewallet.request_otp(self.on_otp) + + def on_otp(self, otp): + self.logger.debug(f'on_otp {otp} for tx {repr(self.tx)}') + try: + self.wallet.on_otp(self.tx, otp) + except UserFacingException as e: + self.on_failure(_('Invalid one-time password.')) + except TrustedCoinException as e: + if e.status_code == 400: # invalid OTP + self.on_failure(_('Invalid one-time password.')) + else: + self.on_failure(_('Error') + ':\n' + str(e)) + except Exception as e: + self.on_failure(_('Error') + ':\n' + str(e)) + else: + self.on_success(self.tx) diff --git a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml new file mode 100644 index 000000000..8665dee27 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml @@ -0,0 +1,38 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: keystoregroup.checkedButton !== null + + onAccept: { + wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype + } + + ButtonGroup { + id: keystoregroup + } + + ColumnLayout { + width: parent.width + Label { + text: qsTr('Do you want to create a new seed, or restore a wallet using an existing seed?') + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'createseed' + checked: true + text: qsTr('Create a new seed') + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'haveseed' + text: qsTr('I already have a seed') + } + } +} + diff --git a/electrum/plugins/trustedcoin/qml/Disclaimer.qml b/electrum/plugins/trustedcoin/qml/Disclaimer.qml new file mode 100644 index 000000000..b1a1f2884 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/Disclaimer.qml @@ -0,0 +1,27 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: true + + property QtObject plugin + + ColumnLayout { + width: parent.width + + Label { + Layout.preferredWidth: parent.width + text: plugin ? plugin.disclaimer : '' + wrapMode: Text.Wrap + } + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + } +} diff --git a/electrum/plugins/trustedcoin/qml/KeepDisable.qml b/electrum/plugins/trustedcoin/qml/KeepDisable.qml new file mode 100644 index 000000000..3e10d5175 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/KeepDisable.qml @@ -0,0 +1,35 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: keepordisablegroup.checkedButton + + function apply() { + wizard_data['trustedcoin_keepordisable'] = keepordisablegroup.checkedButton.keepordisable + } + + ButtonGroup { + id: keepordisablegroup + onCheckedButtonChanged: checkIsLast() + } + + ColumnLayout { + Label { + text: qsTr('Restore 2FA wallet') + } + RadioButton { + ButtonGroup.group: keepordisablegroup + property string keepordisable: 'keep' + checked: true + text: qsTr('Keep') + } + RadioButton { + ButtonGroup.group: keepordisablegroup + property string keepordisable: 'disable' + text: qsTr('Disable') + } + } +} diff --git a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml new file mode 100644 index 000000000..849111a12 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml @@ -0,0 +1,137 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" +import "../../../gui/qml/components/controls" + +WizardComponent { + valid: otpVerified + + property QtObject plugin + + property bool otpVerified: false + + function apply() { + wizard_data['trustedcoin_new_otp_secret'] = requestNewSecret.checked + } + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Authenticator secret') + } + + InfoTextArea { + id: errorBox + iconStyle: InfoTextArea.IconStyle.Error + visible: !otpVerified && plugin.remoteKeyState == 'error' + } + + InfoTextArea { + iconStyle: InfoTextArea.IconStyle.Warn + visible: plugin.remoteKeyState == 'wallet_known' + text: qsTr('This wallet is already registered with TrustedCoin. ') + + qsTr('To finalize wallet creation, please enter your Google Authenticator Code. ') + } + + QRImage { + Layout.alignment: Qt.AlignHCenter + visible: plugin.remoteKeyState == '' + qrdata: encodeURI('otpauth://totp/Electrum 2FA ' + wizard_data['wallet_name'] + + '?secret=' + plugin.otpSecret + '&digits=6') + render: plugin.otpSecret + } + + TextHighlightPane { + Layout.alignment: Qt.AlignHCenter + visible: plugin.otpSecret + Label { + text: plugin.otpSecret + font.family: FixedFont + font.bold: true + } + } + + Label { + visible: !otpVerified && plugin.otpSecret + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('Enter or scan into authenticator app. Then authenticate below') + } + + Label { + visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('If you still have your OTP secret, then authenticate below') + } + + TextField { + id: otp_auth + visible: !otpVerified && (plugin.otpSecret || plugin.remoteKeyState == 'wallet_known') + Layout.alignment: Qt.AlignHCenter + focus: true + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + onTextChanged: { + if (text.length >= 6) { + plugin.checkOtp(plugin.shortId, otp_auth.text) + text = '' + } + } + } + + Label { + visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('Otherwise, you can request your OTP secret from the server, by pressing the button below') + } + + Button { + Layout.alignment: Qt.AlignHCenter + visible: plugin.remoteKeyState == 'wallet_known' && !otpVerified + text: qsTr('Request OTP secret') + onClicked: plugin.resetOtpSecret() + } + + Image { + Layout.alignment: Qt.AlignHCenter + source: '../../../gui/icons/confirmed.png' + visible: otpVerified + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + } + } + + BusyIndicator { + anchors.centerIn: parent + visible: plugin ? plugin.busy : false + running: visible + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + plugin.createKeystore(wizard_data['2fa_email']) + otp_auth.forceActiveFocus() + } + + Connections { + target: plugin + function onOtpError(message) { + console.log('OTP verify error') + errorBox.text = message + } + function onOtpSuccess() { + console.log('OTP verify success') + otpVerified = true + } + function onRemoteKeyError(message) { + errorBox.text = message + } + } +} + diff --git a/electrum/plugins/trustedcoin/qml/Terms.qml b/electrum/plugins/trustedcoin/qml/Terms.qml new file mode 100644 index 000000000..d0e30ffa6 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/Terms.qml @@ -0,0 +1,67 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../../../gui/qml/components/wizard" +import "../../../gui/qml/components/controls" + +WizardComponent { + valid: !plugin ? false + : email.text.length > 0 // TODO: validate email address + && plugin.termsAndConditions + + property QtObject plugin + + onAccept: { + wizard_data['2fa_email'] = email.text + } + + ColumnLayout { + anchors.fill: parent + + Label { text: qsTr('Terms and conditions') } + + TextHighlightPane { + Layout.fillWidth: true + Layout.fillHeight: true + rightPadding: 0 + + Flickable { + anchors.fill: parent + contentHeight: termsText.height + clip: true + boundsBehavior: Flickable.StopAtBounds + + Label { + id: termsText + width: parent.width + rightPadding: constants.paddingSmall + wrapMode: Text.Wrap + text: plugin ? plugin.termsAndConditions : '' + } + ScrollIndicator.vertical: ScrollIndicator { } + } + + BusyIndicator { + anchors.centerIn: parent + visible: plugin ? plugin.busy : false + running: visible + } + } + + Label { text: qsTr('Email') } + + TextField { + id: email + Layout.fillWidth: true + placeholderText: qsTr('Enter your email address') + } + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + plugin.fetchTermsAndConditions() + } +} diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py index f5dfdb343..34a1be886 100644 --- a/electrum/plugins/trustedcoin/trustedcoin.py +++ b/electrum/plugins/trustedcoin/trustedcoin.py @@ -69,7 +69,7 @@ def get_billing_xpub(): return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU" -DISCLAIMER = [ +DESKTOP_DISCLAIMER = [ _("Two-factor authentication is a service provided by TrustedCoin. " "It uses a multi-signature wallet, where you own 2 of 3 keys. " "The third key is stored on a remote server that signs transactions on " @@ -86,8 +86,9 @@ DISCLAIMER = [ "To be safe from malware, you may want to do this on an offline " "computer, and move your wallet later to an online computer."), ] +DISCLAIMER = DESKTOP_DISCLAIMER -KIVY_DISCLAIMER = [ +MOBILE_DISCLAIMER = [ _("Two-factor authentication is a service provided by TrustedCoin. " "To use it, you must have a separate device with Google Authenticator."), _("This service uses a multi-signature wallet, where you own 2 of 3 keys. " @@ -98,6 +99,8 @@ KIVY_DISCLAIMER = [ "your funds at any time and at no cost, without the remote server, by " "using the 'restore wallet' option with your wallet seed."), ] +KIVY_DISCLAIMER = MOBILE_DISCLAIMER + RESTORE_MSG = _("Enter the seed for your 2-factor wallet:") class TrustedCoinException(Exception): diff --git a/electrum/wizard.py b/electrum/wizard.py new file mode 100644 index 000000000..8f8d6378c --- /dev/null +++ b/electrum/wizard.py @@ -0,0 +1,319 @@ +import copy +import os + +from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional, Union + +from electrum.logging import get_logger +from electrum.storage import WalletStorage, StorageEncryptionVersion +from electrum.wallet_db import WalletDB +from electrum.bip32 import normalize_bip32_derivation, xpub_type +from electrum import keystore +from electrum import bitcoin + +class WizardViewState(NamedTuple): + view: str + wizard_data: Dict[str, Any] + params: Dict[str, Any] + +class AbstractWizard: + # serve as a base for all UIs, so no qt + # encapsulate wizard state + # encapsulate navigation decisions, UI agnostic + # encapsulate stack, go backwards + # allow extend/override flow in subclasses e.g. + # - override: replace 'next' value to own fn + # - extend: add new keys to navmap, wire up flow by override + + _logger = get_logger(__name__) + + navmap = {} + + _current = WizardViewState(None, {}, {}) + _stack = [] # type: List[WizardViewState] + + def navmap_merge(self, additional_navmap): + # NOTE: only merges one level deep. Deeper dict levels will overwrite + for k,v in additional_navmap.items(): + if k in self.navmap: + self.navmap[k].update(v) + else: + self.navmap[k] = v + + # from current view and wizard_data, resolve the new view + # returns WizardViewState tuple (view name, wizard_data, view params) + # view name is the string id of the view in the nav map + # wizard data is the (stacked) wizard data dict containing user input and choices + # view params are transient, meant for extra configuration of a view (e.g. info + # msg in a generic choice dialog) + # exception: stay on this view + def resolve_next(self, view, wizard_data): + assert view + self._logger.debug(f'view={view}') + assert view in self.navmap + + nav = self.navmap[view] + + if 'accept' in nav: + # allow python scope to append to wizard_data before + # adding to stack or finishing + if callable(nav['accept']): + nav['accept'](wizard_data) + else: + self._logger.error(f'accept handler for view {view} not callable') + + if not 'next' in nav: + # finished + self.finished(wizard_data) + return (None, wizard_data, {}) + + nexteval = nav['next'] + # simple string based next view + if isinstance(nexteval, str): + new_view = WizardViewState(nexteval, wizard_data, {}) + else: + # handler fn based next view + nv = nexteval(wizard_data) + self._logger.debug(repr(nv)) + + # append wizard_data and params if not returned + if isinstance(nv, str): + new_view = WizardViewState(nv, wizard_data, {}) + elif len(nv) == 1: + new_view = WizardViewState(nv[0], wizard_data, {}) + elif len(nv) == 2: + new_view = WizardViewState(nv[0], nv[1], {}) + else: + new_view = nv + + self._stack.append(copy.deepcopy(self._current)) + self._current = new_view + + self._logger.debug(f'resolve_next view is {self._current.view}') + self._logger.debug('stack:' + repr(self._stack)) + + return new_view + + def resolve_prev(self): + prev_view = self._stack.pop() + self._logger.debug(f'resolve_prev view is {prev_view}') + self._logger.debug('stack:' + repr(self._stack)) + self._current = prev_view + return prev_view + + # check if this view is the final view + def is_last_view(self, view, wizard_data): + assert view + assert view in self.navmap + + nav = self.navmap[view] + + if not 'last' in nav: + return False + + lastnav = nav['last'] + # bool literal + if isinstance(lastnav, bool): + return lastnav + elif callable(lastnav): + # handler fn based + l = lastnav(view, wizard_data) + self._logger.debug(f'view "{view}" last: {l}') + return l + else: + raise Exception('last handler for view {view} is not callable nor a bool literal') + + def finished(self, wizard_data): + self._logger.debug('finished.') + + def reset(self): + self.stack = [] + self._current = WizardViewState(None, {}, {}) + +class NewWalletWizard(AbstractWizard): + + _logger = get_logger(__name__) + + def __init__(self, daemon): + self.navmap = { + 'wallet_name': { + 'next': 'wallet_type' + }, + 'wallet_type': { + 'next': self.on_wallet_type + }, + 'keystore_type': { + 'next': self.on_keystore_type + }, + 'create_seed': { + 'next': 'confirm_seed' + }, + 'confirm_seed': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'have_seed': { + 'next': self.on_have_seed, + 'last': self.last_if_single_password_and_not_bip39 + }, + 'bip39_refine': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'have_master_key': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'imported': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'wallet_password': { + 'last': True + } + } + self._daemon = daemon + + def start(self, initial_data = {}): + self.reset() + self._current = WizardViewState('wallet_name', initial_data, {}) + return self._current + + def last_if_single_password(self, view, wizard_data): + raise NotImplementedError() + + def last_if_single_password_and_not_bip39(self, view, wizard_data): + return self.last_if_single_password(view, wizard_data) and not wizard_data['seed_variant'] == 'bip39' + + def on_wallet_type(self, wizard_data): + t = wizard_data['wallet_type'] + return { + 'standard': 'keystore_type', + '2fa': 'trustedcoin_start', + 'imported': 'imported' + }.get(t) + + def on_keystore_type(self, wizard_data): + t = wizard_data['keystore_type'] + return { + 'createseed': 'create_seed', + 'haveseed': 'have_seed', + 'masterkey': 'have_master_key' + }.get(t) + + def on_have_seed(self, wizard_data): + if (wizard_data['seed_type'] == 'bip39'): + return 'bip39_refine' + else: + return 'wallet_password' + + def finished(self, wizard_data): + self._logger.debug('finished') + # override + + def create_storage(self, path, data): + # only standard and 2fa wallets for now + assert data['wallet_type'] in ['standard', '2fa', 'imported'] + + if os.path.exists(path): + raise Exception('file already exists at path') + storage = WalletStorage(path) + + k = None + if not 'keystore_type' in data: + assert data['wallet_type'] == 'imported' + addresses = {} + if 'private_key_list' in data: + k = keystore.Imported_KeyStore({}) + keys = keystore.get_private_keys(data['private_key_list']) + for pk in keys: + assert bitcoin.is_private_key(pk) + txin_type, pubkey = k.import_privkey(pk, None) + addr = bitcoin.pubkey_to_address(txin_type, pubkey) + addresses[addr] = {'type': txin_type, 'pubkey': pubkey} + elif 'address_list' in data: + for addr in data['address_list'].split(): + addresses[addr] = {} + elif data['keystore_type'] in ['createseed', 'haveseed']: + if data['seed_type'] in ['old', 'standard', 'segwit']: + self._logger.debug('creating keystore from electrum seed') + k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') + elif data['seed_type'] == 'bip39': + self._logger.debug('creating keystore from bip39 seed') + root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) + derivation = normalize_bip32_derivation(data['derivation_path']) + script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' + k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) + elif data['seed_type'] == '2fa_segwit': # TODO: legacy 2fa '2fa' + self._logger.debug('creating keystore from 2fa seed') + k = keystore.from_xprv(data['x1/']['xprv']) + else: + raise Exception('unsupported/unknown seed_type %s' % data['seed_type']) + elif data['keystore_type'] == 'masterkey': + k = keystore.from_master_key(data['master_key']) + has_xpub = isinstance(k, keystore.Xpub) + assert has_xpub + t1 = xpub_type(k.xpub) + if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: + raise Exception('wrong key type %s' % t1) + else: + raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) + + if data['encrypt']: + if k and k.may_have_password(): + k.update_password(None, data['password']) + storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) + + db = WalletDB('', manual_upgrades=False) + db.set_keystore_encryption(bool(data['password']) and data['encrypt']) + + db.put('wallet_type', data['wallet_type']) + if 'seed_type' in data: + db.put('seed_type', data['seed_type']) + + if data['wallet_type'] == 'standard': + db.put('keystore', k.dump()) + elif data['wallet_type'] == '2fa': + db.put('x1/', k.dump()) + if data['trustedcoin_keepordisable'] == 'disable': + k2 = keystore.from_xprv(data['x2/']['xprv']) + if data['encrypt'] and k2.may_have_password(): + k2.update_password(None, data['password']) + db.put('x2/', k2.dump()) + else: + db.put('x2/', data['x2/']) + db.put('x3/', data['x3/']) + db.put('use_trustedcoin', True) + elif data['wallet_type'] == 'imported': + if k: + db.put('keystore', k.dump()) + db.put('addresses', addresses) + + if k and k.can_have_deterministic_lightning_xprv(): + db.put('lightning_xprv', k.get_lightning_xprv(data['password'] if data['encrypt'] else None)) + + db.load_plugins() + db.write(storage) + +class ServerConnectWizard(AbstractWizard): + + _logger = get_logger(__name__) + + def __init__(self, daemon): + self.navmap = { + 'autoconnect': { + 'next': 'proxy_config', + 'last': lambda v,d: d['autoconnect'] + }, + 'proxy_config': { + 'next': 'server_config' + }, + 'server_config': { + 'last': True + } + } + self._daemon = daemon + + def start(self, initial_data = {}): + self.reset() + self._current = WizardViewState('autoconnect', initial_data, {}) + return self._current