diff --git a/electrum/gui/icons/sweep.png b/electrum/gui/icons/sweep.png
new file mode 100644
index 000000000..faeb31417
Binary files /dev/null and b/electrum/gui/icons/sweep.png differ
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..d3f82523b
--- /dev/null
+++ b/electrum/gui/qml/components/SweepDialog.qml
@@ -0,0 +1,158 @@
+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/sweep.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
+ root.accept()
+ }
+ }
+
+ }
+
+ Bitcoin {
+ id: bitcoin
+ }
+}
diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml
index fb3a55d0a..5aa727f75 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/sweep.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 213ace0ae..05e1599c7 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
@@ -399,6 +399,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..6d86611ec 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,110 @@ 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):
+ addresses = self._wallet.wallet.get_unused_addresses()
+ if not addresses:
+ try:
+ addresses = self._wallet.wallet.get_receiving_addresses()
+ except AttributeError:
+ addresses = self._wallet.wallet.get_addresses()
+
+ assert len(addresses) > 0, 'no address in wallet to send to'
+ address = addresses[0]
+ assert self._wallet.wallet.adb.is_mine(address)
+
+ 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.debug(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'))