Merge pull request #9203 from accumulator/qml_sweep_privkeys
qml: Sweep from privkeys
This commit is contained in:
BIN
electrum/gui/icons/sweep.png
Normal file
BIN
electrum/gui/icons/sweep.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -14,6 +14,7 @@ ElDialog {
|
|||||||
required property Amount satoshis
|
required property Amount satoshis
|
||||||
property string address
|
property string address
|
||||||
property string message
|
property string message
|
||||||
|
property bool showOptions: true
|
||||||
property alias amountLabelText: amountLabel.text
|
property alias amountLabelText: amountLabel.text
|
||||||
property alias sendButtonText: sendButton.text
|
property alias sendButtonText: sendButton.text
|
||||||
|
|
||||||
@@ -142,12 +143,13 @@ ElDialog {
|
|||||||
Layout.columnSpan: 2
|
Layout.columnSpan: 2
|
||||||
labelText: qsTr('Options')
|
labelText: qsTr('Options')
|
||||||
color: Material.accentColor
|
color: Material.accentColor
|
||||||
|
visible: showOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
TextHighlightPane {
|
TextHighlightPane {
|
||||||
Layout.columnSpan: 2
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: !optionstoggle.collapsed
|
visible: optionstoggle.visible && !optionstoggle.collapsed
|
||||||
height: optionslayout.height
|
height: optionslayout.height
|
||||||
|
|
||||||
GridLayout {
|
GridLayout {
|
||||||
|
|||||||
158
electrum/gui/qml/components/SweepDialog.qml
Normal file
158
electrum/gui/qml/components/SweepDialog.qml
Normal file
@@ -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') +
|
||||||
|
'<br/><br/>' + qsTr('WIF keys are typed in Electrum, based on script type.') + '<br/><br/>' +
|
||||||
|
qsTr('A few examples') + ':<br/>' +
|
||||||
|
'<tt><b>p2pkh</b>:KxZcY47uGp9a... \t-> 1DckmggQM...<br/>' +
|
||||||
|
'<b>p2wpkh-p2sh</b>:KxZcY47uGp9a... \t-> 3NhNeZQXF...<br/>' +
|
||||||
|
'<b>p2wpkh</b>:KxZcY47uGp9a... \t-> bc1q3fjfk...</tt>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,25 @@ Item {
|
|||||||
Daemon.currentWallet.createRequest(qamt, _request_description, _request_expiry, lightning_only, reuse_address)
|
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 {
|
property QtObject menu: Menu {
|
||||||
id: 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 { }
|
MenuSeparator { }
|
||||||
|
|
||||||
MenuItem {
|
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 {
|
Component {
|
||||||
id: lnurlPayDialog
|
id: lnurlPayDialog
|
||||||
LnurlPayRequestDialog {
|
LnurlPayRequestDialog {
|
||||||
@@ -635,5 +683,12 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: sweepDialog
|
||||||
|
SweepDialog {
|
||||||
|
onClosed: destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper
|
|||||||
from .qeqrscanner import QEQRScanner
|
from .qeqrscanner import QEQRScanner
|
||||||
from .qebitcoin import QEBitcoin
|
from .qebitcoin import QEBitcoin
|
||||||
from .qefx import QEFX
|
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 .qeinvoice import QEInvoice, QEInvoiceParser
|
||||||
from .qerequestdetails import QERequestDetails
|
from .qerequestdetails import QERequestDetails
|
||||||
from .qetypes import QEAmount
|
from .qetypes import QEAmount
|
||||||
@@ -399,6 +399,7 @@ class ElectrumQmlApplication(QGuiApplication):
|
|||||||
qmlRegisterType(QETxRbfFeeBumper, 'org.electrum', 1, 0, 'TxRbfFeeBumper')
|
qmlRegisterType(QETxRbfFeeBumper, 'org.electrum', 1, 0, 'TxRbfFeeBumper')
|
||||||
qmlRegisterType(QETxCpfpFeeBumper, 'org.electrum', 1, 0, 'TxCpfpFeeBumper')
|
qmlRegisterType(QETxCpfpFeeBumper, 'org.electrum', 1, 0, 'TxCpfpFeeBumper')
|
||||||
qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller')
|
qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller')
|
||||||
|
qmlRegisterType(QETxSweepFinalizer, 'org.electrum', 1, 0, 'SweepFinalizer')
|
||||||
qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel')
|
qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel')
|
||||||
|
|
||||||
# TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6
|
# TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import copy
|
||||||
|
import threading
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from functools import partial
|
from functools import partial
|
||||||
@@ -7,8 +9,9 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
|
|||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
from electrum.transaction import PartialTxOutput, PartialTransaction, Transaction, TxOutpoint
|
from electrum.transaction import PartialTxOutput, PartialTransaction, Transaction, TxOutpoint
|
||||||
from electrum.util import NotEnoughFunds, profiler, quantize_feerate
|
from electrum.util import NotEnoughFunds, profiler, quantize_feerate, UserFacingException
|
||||||
from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy
|
from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations
|
||||||
|
from electrum import keystore
|
||||||
from electrum.plugin import run_hook
|
from electrum.plugin import run_hook
|
||||||
|
|
||||||
from .qewallet import QEWallet
|
from .qewallet import QEWallet
|
||||||
@@ -868,3 +871,110 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin):
|
|||||||
@pyqtSlot(result=str)
|
@pyqtSlot(result=str)
|
||||||
def getNewTx(self):
|
def getNewTx(self):
|
||||||
return str(self._new_tx)
|
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'))
|
||||||
|
|||||||
Reference in New Issue
Block a user