From 44c0e583d6d4cbfcf848bdce70124b7819f471a2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 17 Sep 2024 13:22:05 +0200 Subject: [PATCH] qml: SweepDialog --- .../gui/qml/components/ConfirmTxDialog.qml | 4 +- electrum/gui/qml/components/SweepDialog.qml | 159 ++++++++++++++++++ .../gui/qml/components/WalletMainView.qml | 55 ++++++ electrum/gui/qml/qeapp.py | 3 +- electrum/gui/qml/qetxfinalizer.py | 105 +++++++++++- 5 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/components/SweepDialog.qml diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 1f2e6d67b..ea9470cdc 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -14,6 +14,7 @@ ElDialog { required property Amount satoshis property string address property string message + property bool showOptions: true property alias amountLabelText: amountLabel.text property alias sendButtonText: sendButton.text @@ -142,12 +143,13 @@ ElDialog { Layout.columnSpan: 2 labelText: qsTr('Options') color: Material.accentColor + visible: showOptions } TextHighlightPane { Layout.columnSpan: 2 Layout.fillWidth: true - visible: !optionstoggle.collapsed + visible: optionstoggle.visible && !optionstoggle.collapsed height: optionslayout.height GridLayout { diff --git a/electrum/gui/qml/components/SweepDialog.qml b/electrum/gui/qml/components/SweepDialog.qml new file mode 100644 index 000000000..0e8c48d1e --- /dev/null +++ b/electrum/gui/qml/components/SweepDialog.qml @@ -0,0 +1,159 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import org.electrum + +import "controls" + +ElDialog { + id: root + + title: qsTr('Sweep private keys') + iconSource: Qt.resolvedUrl('../../icons/add.png') + + property bool valid: false + property string privateKeys + + width: parent.width + height: parent.height + padding: 0 + + function verifyPrivateKey(key) { + valid = false + validationtext.text = '' + key = key.trim() + + if (!key) { + return false + } + + if (!bitcoin.isPrivateKeyList(key)) { + validationtext.text = qsTr('Error: invalid private key(s)') + return false + } + + return valid = true + } + + function addPrivateKey(key) { + if (sweepkeys.text.includes(key)) + return + if (sweepkeys.text && !sweepkeys.text.endsWith('\n')) + sweepkeys.text = sweepkeys.text + '\n' + sweepkeys.text = sweepkeys.text + key + '\n' + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + ColumnLayout { + Layout.leftMargin: constants.paddingLarge + Layout.rightMargin: constants.paddingLarge + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + RowLayout { + Layout.fillWidth: true + TextHighlightPane { + Layout.fillWidth: true + Label { + text: qsTr('Enter the list of private keys to sweep into this wallet') + wrapMode: Text.Wrap + } + } + HelpButton { + heading: qsTr('Sweep private keys') + helptext: qsTr('This will create a transaction sending all funds associated with the private keys to the current wallet') + + '

' + qsTr('WIF keys are typed in Electrum, based on script type.') + '

' + + qsTr('A few examples') + ':
' + + 'p2pkh:KxZcY47uGp9a... \t-> 1DckmggQM...
' + + 'p2wpkh-p2sh:KxZcY47uGp9a... \t-> 3NhNeZQXF...
' + + 'p2wpkh:KxZcY47uGp9a... \t-> bc1q3fjfk...
' + } + } + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + ElTextArea { + id: sweepkeys + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 160 + font.family: FixedFont + wrapMode: TextEdit.WrapAnywhere + onTextChanged: { + if (anyActiveFocus) { + verifyPrivateKey(text) + } + } + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + background: PaneInsetBackground { + baseColor: constants.darkerDialogBackground + } + } + ColumnLayout { + Layout.alignment: Qt.AlignTop + ToolButton { + icon.source: '../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: { + if (verifyPrivateKey(AppController.clipboardToText())) + addPrivateKey(AppController.clipboardToText()) + } + } + ToolButton { + icon.source: '../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var dialog = app.scanDialog.createObject(app, { + hint: qsTr('Scan a private key') + }) + dialog.onFound.connect(function() { + if (verifyPrivateKey(dialog.scanData)) + addPrivateKey(dialog.scanData) + dialog.close() + }) + dialog.open() + } + } + } + } + + InfoTextArea { + id: validationtext + iconStyle: InfoTextArea.IconStyle.Warn + Layout.fillWidth: true + Layout.margins: constants.paddingMedium + visible: text + } + } + } + + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + enabled: valid + icon.source: '../../icons/tab_send.png' + text: qsTr('Sweep') + onClicked: { + console.log('sweeping') + root.privateKeys = sweepkeys.text + console.log(root.privateKeys) + root.accept() + } + } + + } + + Bitcoin { + id: bitcoin + } +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index fb3a55d0a..a360d6c6b 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -131,6 +131,25 @@ Item { Daemon.currentWallet.createRequest(qamt, _request_description, _request_expiry, lightning_only, reuse_address) } + function startSweep() { + var dialog = sweepDialog.createObject(app) + dialog.accepted.connect(function() { + var finalizerDialog = confirmSweepDialog.createObject(mainView, { + privateKeys: dialog.privateKeys, + message: qsTr('Sweep transaction'), + showOptions: false, + amountLabelText: qsTr('Total sweep amount'), + sendButtonText: qsTr('Sweep') + }) + finalizerDialog.accepted.connect(function() { + console.log("Sending sweep transaction") + finalizerDialog.finalizer.send() + }) + finalizerDialog.open() + }) + dialog.open() + } + property QtObject menu: Menu { id: menu @@ -187,6 +206,19 @@ Item { } } + MenuItem { + icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor + icon.source: '../../icons/add.png' + action: Action { + text: qsTr('Sweep key') + enabled: !Daemon.currentWallet.isWatchOnly // watchonly might be acceptable + onTriggered: { + startSweep() + menu.deselect() + } + } + } + MenuSeparator { } MenuItem { @@ -608,6 +640,22 @@ Item { } } + Component { + id: confirmSweepDialog + ConfirmTxDialog { + id: _confirmSweepDialog + + property string privateKeys + title: qsTr('Confirm Sweep') + satoshis: MAX + finalizer: SweepFinalizer { + wallet: Daemon.currentWallet + canRbf: true + privateKeys: _confirmSweepDialog.privateKeys + } + } + } + Component { id: lnurlPayDialog LnurlPayRequestDialog { @@ -635,5 +683,12 @@ Item { } } + Component { + id: sweepDialog + SweepDialog { + onClosed: destroy() + } + } + } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 692bc68cc..5b168b3a9 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -29,7 +29,7 @@ from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper from .qeqrscanner import QEQRScanner from .qebitcoin import QEBitcoin from .qefx import QEFX -from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller +from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer from .qeinvoice import QEInvoice, QEInvoiceParser from .qerequestdetails import QERequestDetails from .qetypes import QEAmount @@ -396,6 +396,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(QETxSweepFinalizer, 'org.electrum', 1, 0, 'SweepFinalizer') qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel') # TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6 diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 56548bef2..f9e8b1565 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -1,3 +1,5 @@ +import copy +import threading from decimal import Decimal from typing import Optional, TYPE_CHECKING from functools import partial @@ -7,8 +9,9 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger from electrum.i18n import _ from electrum.transaction import PartialTxOutput, PartialTransaction, Transaction, TxOutpoint -from electrum.util import NotEnoughFunds, profiler, quantize_feerate -from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy +from electrum.util import NotEnoughFunds, profiler, quantize_feerate, UserFacingException +from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations +from electrum import keystore from electrum.plugin import run_hook from .qewallet import QEWallet @@ -868,3 +871,101 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): @pyqtSlot(result=str) def getNewTx(self): return str(self._new_tx) + + +class QETxSweepFinalizer(QETxFinalizer): + _logger = get_logger(__name__) + + txinsRetrieved = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + + self._private_keys = '' + self._txins = None + self._amount = QEAmount(is_max=True) + + self.txinsRetrieved.connect(self.update) + + privateKeysChanged = pyqtSignal() + @pyqtProperty(str, notify=privateKeysChanged) + def privateKeys(self): + return self._private_keys + + @privateKeys.setter + def privateKeys(self, private_keys): + if self._private_keys != private_keys: + self._private_keys = private_keys + self.update_privkeys() + self.privateKeysChanged.emit() + + def make_sweep_tx(self): + address = self._wallet.wallet.get_unused_address() # TODO: dont fail + + coins, keypairs = copy.deepcopy(self._txins) + outputs = [PartialTxOutput.from_address_and_value(address, value='!')] + + tx = self._wallet.wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=None, rbf=self._rbf, is_sweep=True) + self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) + + tx.sign(keypairs) + return tx + + def update_privkeys(self): + privkeys = keystore.get_private_keys(self._private_keys) + + def fetch_privkeys_info(): + try: + self._txins = self._wallet.wallet.network.run_from_another_thread(sweep_preparations(privkeys, self._wallet.wallet.network)) + self._logger.info(f'txins {self._txins!r}') + except UserFacingException as e: + self.warning = str(e) + return + self.txinsRetrieved.emit() + + threading.Thread(target=fetch_privkeys_info, daemon=True).start() + + def update(self): + if not self._wallet: + self._logger.debug('wallet not set, ignoring update()') + return + if not self._private_keys: + self._logger.debug('private keys not set, ignoring update()') + return + + try: + # make unsigned transaction + tx = self.make_sweep_tx() + except Exception as e: + self._logger.error(str(e)) + self.warning = repr(e) + self._valid = False + self.validChanged.emit() + return + + self._tx = tx + + amount = tx.output_value() + + self._effectiveAmount.satsInt = amount + self.effectiveAmountChanged.emit() + + self.update_from_tx(tx) + + fee_warning_tuple = self._wallet.wallet.get_tx_fee_warning( + invoice_amt=amount, tx_size=tx.estimated_size(), fee=tx.get_fee()) + if fee_warning_tuple: + allow_send, long_warning, short_warning = fee_warning_tuple + self.warning = _('Warning') + ': ' + long_warning + else: + self.warning = '' + + self._valid = True + self.validChanged.emit() + + self.on_signed_tx(False, tx) + + @pyqtSlot() + def send(self): + self._wallet.broadcast(self._tx) + self._wallet.wallet.set_label(self._tx.txid(), _('Sweep transaction'))