diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 2faa088fae1..f93215dae52 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -14,10 +14,11 @@ ElDialog { required property var satoshis // type: Amount property string address property string message - property bool showOptions: true property alias amountLabelText: amountLabel.text property alias sendButtonText: sendButton.text + signal confirmed + title: qsTr('Transaction Fee') iconSource: Qt.resolvedUrl('../../icons/question.png') @@ -150,7 +151,7 @@ ElDialog { Layout.columnSpan: 2 labelText: qsTr('Options') color: Material.accentColor - visible: showOptions + visible: finalizer.txOptions } DialogHighlightPane { @@ -163,9 +164,12 @@ ElDialog { id: optionslayout width: parent.width columns: 2 + rowSpacing: 0 ElCheckBox { + id: cb_multiple_change Layout.fillWidth: true + visible: finalizer.txOptions & TxFinalizer.TxOptions.MULTIPLE_CHANGE text: qsTr('Use multiple change addresses') onCheckedChanged: { if (activeFocus) { @@ -179,13 +183,16 @@ ElDialog { } HelpButton { + visible: cb_multiple_change.visible heading: qsTr('Use multiple change addresses') helptext: [qsTr('In some cases, use up to 3 change addresses in order to break up large coin amounts and obfuscate the recipient address.'), qsTr('This may result in higher transactions fees.')].join(' ') } ElCheckBox { + id: cb_output_rounding Layout.fillWidth: true + visible: finalizer.txOptions & TxFinalizer.TxOptions.OUTPUT_ROUNDING text: Config.shortDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING') onCheckedChanged: { if (activeFocus) { @@ -199,10 +206,44 @@ ElDialog { } HelpButton { + visible: cb_output_rounding.visible heading: Config.shortDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING') helptext: Config.longDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING') } + ElCheckBox { + id: cb_send_change_to_lightning + Layout.fillWidth: true + visible: finalizer.txOptions & TxFinalizer.TxOptions.SEND_CHANGE_TO_LIGHTNING + && Daemon.currentWallet.isLightning && Daemon.currentWallet.lightningCanReceive.satsInt > 0 + text: Config.shortDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') + onCheckedChanged: { + if (activeFocus) { + Config.sendChangeToLightning = checked + finalizer.doUpdate() + } + } + Component.onCompleted: { + checked = Config.sendChangeToLightning + } + } + + HelpButton { + visible: cb_send_change_to_lightning.visible + heading: Config.shortDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') + helptext: Config.longDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') + } + + Label { + visible: cb_send_change_to_lightning.visible && cb_send_change_to_lightning.checked + color: constants.mutedForeground + font.pixelSize: constants.fontSizeSmall + text: finalizer.swapStatusMsg + Layout.topMargin: -constants.paddingSmall + Layout.leftMargin: cb_send_change_to_lightning.contentItem.leftPadding + + cb_send_change_to_lightning.padding + } + } } @@ -283,8 +324,8 @@ ElDialog { ? qsTr('Finalize...') : qsTr('Pay...') icon.source: '../../icons/confirmed.png' - enabled: finalizer.valid - onClicked: doAccept() + enabled: finalizer.valid && !finalizer.busy + onClicked: confirmed() } } } diff --git a/electrum/gui/qml/components/MessageDialog.qml b/electrum/gui/qml/components/MessageDialog.qml index 4452b3baaa9..2da6d8b93c5 100644 --- a/electrum/gui/qml/components/MessageDialog.qml +++ b/electrum/gui/qml/components/MessageDialog.qml @@ -15,6 +15,8 @@ ElDialog { property bool yesno: false property alias text: message.text property bool richText: false + property alias buttonText: primaryButton.text + property alias buttonIcon: primaryButton.icon.source z: 1 // raise z so it also covers dialogs using overlay as parent @@ -55,6 +57,7 @@ ElDialog { } FlatButton { + id: primaryButton Layout.fillWidth: true textUnderIcon: false text: qsTr('Ok') diff --git a/electrum/gui/qml/components/OpenChannelDialog.qml b/electrum/gui/qml/components/OpenChannelDialog.qml index a9b6f3d310b..4b8442ef7e4 100644 --- a/electrum/gui/qml/components/OpenChannelDialog.qml +++ b/electrum/gui/qml/components/OpenChannelDialog.qml @@ -18,6 +18,8 @@ ElDialog { width: parent.width height: parent.height + property var _openerConfirmTxDialog: null + ColumnLayout { anchors.fill: parent spacing: 0 @@ -255,14 +257,18 @@ ElDialog { Component { id: confirmOpenChannelDialog ConfirmTxDialog { + id: _confirmOpenChannelDialog amountLabelText: qsTr('Channel capacity') sendButtonText: qsTr('Open Channel') finalizer: channelopener.finalizer + + onClosed: destroy() } } ChannelOpener { id: channelopener + wallet: Daemon.currentWallet onAuthRequired: (method, authMessage) => { app.handleAuthRequired(channelopener, method, authMessage) @@ -288,13 +294,13 @@ ElDialog { }) } onFinalizerChanged: { - var dialog = confirmOpenChannelDialog.createObject(app, { + _openerConfirmTxDialog = confirmOpenChannelDialog.createObject(app, { satoshis: channelopener.amount }) - dialog.accepted.connect(function() { - dialog.finalizer.signAndSend() + _openerConfirmTxDialog.confirmed.connect(function() { + _openerConfirmTxDialog.finalizer.signAndSend() }) - dialog.open() + _openerConfirmTxDialog.open() } onChannelOpening: (peer) => { console.log('Channel is opening') @@ -318,6 +324,7 @@ ElDialog { if (!has_onchain_backup) { app.channelOpenProgressDialog.channelBackup = channelopener.channelBackup(cid) } + _openerConfirmTxDialog.close() // TODO: handle incomplete TX root.close() } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 55f73519db4..65705114733 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -112,10 +112,12 @@ Item { message: invoice.message }) var canComplete = !Daemon.currentWallet.isWatchOnly && Daemon.currentWallet.canSignWithoutCosigner - dialog.accepted.connect(function() { + dialog.confirmed.connect(function() { if (invoice.canSave) - if (!invoice.saveInvoice()) + if (!invoice.saveInvoice()) { + dialog.close() return + } if (!canComplete) { if (Daemon.currentWallet.isWatchOnly) { dialog.finalizer.saveOrShow() @@ -142,13 +144,12 @@ Item { var finalizerDialog = confirmSweepDialog.createObject(mainView, { privateKeys: dialog.privateKeys, message: qsTr('Sweep transaction'), - showOptions: false, amountLabelText: qsTr('Total sweep amount'), sendButtonText: Daemon.currentWallet.isWatchOnly ? qsTr('Sweep...') : qsTr('Sweep') }) - finalizerDialog.accepted.connect(function() { + finalizerDialog.confirmed.connect(function() { if (Daemon.currentWallet.isWatchOnly) { var confirmdialog = app.messageDialog.createObject(mainView, { title: qsTr('Confirm Sweep'), @@ -164,6 +165,7 @@ Item { } console.log("Sending sweep transaction") finalizerDialog.finalizer.send() + finalizerDialog.close() }) finalizerDialog.open() }) @@ -672,8 +674,13 @@ Item { id: _confirmPaymentDialog title: qsTr('Confirm Payment') finalizer: TxFinalizer { + id: _txfinalizer + property var _swapwaitdialog wallet: Daemon.currentWallet canRbf: true + txOptions: TxFinalizer.TxOptions.MULTIPLE_CHANGE + | TxFinalizer.TxOptions.OUTPUT_ROUNDING + | TxFinalizer.TxOptions.SEND_CHANGE_TO_LIGHTNING onFinished: (signed, saved, complete) => { if (!complete) { var msg @@ -693,7 +700,7 @@ Item { } showExport(getSerializedTx(), msg) } - _confirmPaymentDialog.destroy() + _confirmPaymentDialog.close() } onSignError: (message) => { var dialog = app.messageDialog.createObject(mainView, { @@ -703,12 +710,43 @@ Item { }) dialog.open() } + onSwapError: (message) => { + if (_swapwaitdialog) + _swapwaitdialog.close() + var dialog = app.messageDialog.createObject(mainView, { + title: qsTr('Error'), + text: [qsTr('Could not swap change'), message].join('\n\n'), + iconSource: '../../../icons/warning.png' + }) + dialog.open() + } + onSwapStart: { + _swapwaitdialog = app.messageDialog.createObject(mainView, { + title: qsTr('Please wait...'), + text: [qsTr('waiting for lightning invoice'), ''].join('\n\n'), + iconSource: Qt.resolvedUrl('../../icons/info.png'), + buttonText: qsTr('Cancel'), + buttonIcon: Qt.resolvedUrl('../../icons/closebutton.png') + }) + _swapwaitdialog.accepted.connect(function() { + cancelSwap() + }) + _swapwaitdialog.open() + } + onSwapFunded: { + if (_swapwaitdialog) + _swapwaitdialog.close() + } + onAuthRequired: (method, authMessage) => { + app.handleAuthRequired(_txfinalizer, method, authMessage) + } } - // TODO: lingering confirmPaymentDialogs can raise exceptions in - // the child finalizer when currentWallet disappears, but we need - // it long enough for the finalizer to finish.. - // onClosed: destroy() + // NOTE: destroy-on-close was previously disabled due to the 'accept' signal implicitly + // closing the dialog, while the finalizer could still trigger a callback. + // ConfirmTxDialog now instead emits a custom 'confirmed' signal when user ok's, but + // keep in mind this requires explicit closing of the dialog afterwards + onClosed: destroy() } } @@ -724,7 +762,10 @@ Item { wallet: Daemon.currentWallet canRbf: true privateKeys: _confirmSweepDialog.privateKeys + txOptions: TxFinalizer.TxOptions.NONE } + + onClosed: destroy() } } diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index 957aa30d2e1..71ca296054b 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -216,6 +216,7 @@ def openChannel(self, confirm_backup_conflict=False): self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt) self._finalizer.canRbf = False + self._finalizer.txOptions = QETxFinalizer.TxOptions.MULTIPLE_CHANGE | QETxFinalizer.TxOptions.OUTPUT_ROUNDING self._finalizer.amount = self._amount self._finalizer.wallet = self._wallet self.finalizerChanged.emit() diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index d4ca6b9d5aa..33115750bcb 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -350,6 +350,17 @@ def walletDidUseSinglePassword(self): # TODO: consider removing once encrypted wallet file headers are available return self.config.WALLET_DID_USE_SINGLE_PASSWORD + sendChangeToLightningChanged = pyqtSignal() + @pyqtProperty(bool, notify=sendChangeToLightningChanged) + def sendChangeToLightning(self): + return self.config.WALLET_SEND_CHANGE_TO_LIGHTNING + + @sendChangeToLightning.setter + def sendChangeToLightning(self, sendChangeToLightning): + if sendChangeToLightning != self.config.WALLET_SEND_CHANGE_TO_LIGHTNING: + self.config.WALLET_SEND_CHANGE_TO_LIGHTNING = sendChangeToLightning + self.sendChangeToLightningChanged.emit() + @pyqtSlot('qint64', result=str) @pyqtSlot(QEAmount, result=str) def formatSatsForEditing(self, satoshis): diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index e3d0da18de1..11b15e462f7 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -1,18 +1,21 @@ +import asyncio import copy -from enum import IntEnum +from asyncio import Future +from enum import IntEnum, IntFlag import threading from decimal import Decimal, InvalidOperation from typing import Optional, TYPE_CHECKING, Callable from functools import partial -from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QVariant +from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QVariant, Qt from electrum.logging import get_logger from electrum.i18n import _ from electrum.bitcoin import DummyAddress from electrum.transaction import PartialTxOutput, PartialTransaction, Transaction, TxOutpoint from electrum.util import ( - NotEnoughFunds, profiler, quantize_feerate, UserFacingException, NoDynamicFeeEstimates, event_listener + NotEnoughFunds, profiler, quantize_feerate, UserFacingException, NoDynamicFeeEstimates, event_listener, + get_asyncio_loop ) from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations from electrum import keystore @@ -22,7 +25,9 @@ from electrum.gui import messages from electrum.gui.common_qt.util import QtEventListener +from electrum.gui.common_qt.swaps import SubmarineSwapMixin +from .auth import auth_protect, AuthMixin from .qewallet import QEWallet from .qetypes import QEAmount @@ -57,7 +62,7 @@ def from_fee_method(cls, fm: FeeMethod) -> 'FeeSlider.FSMethod': }[fm] def __init__(self, parent=None): - super().__init__(parent) + QObject.__init__(self, parent) self._wallet = None # type: Optional[QEWallet] self._sliderSteps = 0 @@ -78,9 +83,13 @@ def wallet(self, wallet: QEWallet): if self._wallet != wallet: self._wallet = wallet self._config = self._wallet.wallet.config + self.on_wallet_changed() self.read_config() self.walletChanged.emit() + def on_wallet_changed(self): + pass + sliderStepsChanged = pyqtSignal() @pyqtProperty(int, notify=sliderStepsChanged) def sliderSteps(self): @@ -154,8 +163,18 @@ def update(self): class TxFeeSlider(FeeSlider): + + @pyqtEnum + class TxOptions(IntFlag): + NONE = 0 + MULTIPLE_CHANGE = 1 + OUTPUT_ROUNDING = 2 + SEND_CHANGE_TO_LIGHTNING = 4 + def __init__(self, parent=None): - super().__init__(parent) + FeeSlider.__init__(self, parent) + + self._tx_options = TxFeeSlider.TxOptions.MULTIPLE_CHANGE | TxFeeSlider.TxOptions.OUTPUT_ROUNDING self._fee = QEAmount() self._feeRate = '' @@ -170,6 +189,20 @@ def __init__(self, parent=None): self._valid = False self._warning = '' + txOptionsChanged = pyqtSignal() + @pyqtProperty(int, notify=txOptionsChanged) + def txOptions(self) -> int: + return int(self._tx_options) + + @txOptions.setter + def txOptions(self, options: int): + options = TxFeeSlider.TxOptions(options) + if self._tx_options != options: + self._tx_options = options + self.txOptionsChanged.emit() + if self._wallet: # recompute (e.g. swap message) for the new option set + self.update() + feeChanged = pyqtSignal() @pyqtProperty(QVariant, notify=feeChanged) def fee(self) -> QEAmount: @@ -389,11 +422,15 @@ def save_config(self): super().save_config() -class QETxFinalizer(TxFeeSlider): +class QETxFinalizer(TxFeeSlider, AuthMixin, SubmarineSwapMixin): _logger = get_logger(__name__) finished = pyqtSignal([bool, bool, bool], arguments=['signed', 'saved', 'complete']) signError = pyqtSignal([str], arguments=['message']) + swapError = pyqtSignal([str], arguments=['message']) + swapStart = pyqtSignal() + swapFunded = pyqtSignal() + swapStatusMsgChanged = pyqtSignal() def __init__( self, @@ -402,7 +439,9 @@ def __init__( make_tx: Callable[[int, FeePolicy], PartialTransaction] = None, accept: Callable[[PartialTransaction], None] = None, ): - super().__init__(parent) + TxFeeSlider.__init__(self, parent) + SubmarineSwapMixin.__init__(self, None) + self.f_make_tx = make_tx self.f_accept = accept @@ -411,6 +450,24 @@ def __init__( self._effectiveAmount = QEAmount() self._extraFee = QEAmount() self._canRbf = False + self._swapStatusMsg = '' + self._busy = False + self.swap_task: Future = None + + self.swapAvailabilityChanged.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection) + + self.destroyed.connect(lambda: self.on_destroy()) + + def on_destroy(self): + self._logger.debug('on_destroy') + self.swap_transport_cleanup() + + def on_wallet_changed(self): + self.set_wallet_for_swap(self._wallet.wallet) + + def on_swap_availability_changed(self): + self._logger.debug('on_swap_availability_changed') + self.update() addressChanged = pyqtSignal() @pyqtProperty(str, notify=addressChanged) @@ -436,6 +493,17 @@ def amount(self, amount: QEAmount): self._amount.copyFrom(amount) self.amountChanged.emit() + busyChanged = pyqtSignal() + @pyqtProperty(bool, notify=busyChanged) + def busy(self) -> bool: + return self._busy + + @busy.setter + def busy(self, busy: bool): + if self._busy != busy: + self._busy = busy + self.busyChanged.emit() + effectiveAmountChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=effectiveAmountChanged) def effectiveAmount(self): @@ -465,6 +533,23 @@ def canRbf(self, canRbf): self.canRbfChanged.emit() self.rbf = self._canRbf # if we can RbF, we do RbF + @pyqtProperty(str, notify=swapStatusMsgChanged) + def swapStatusMsg(self): + return self._swapStatusMsg + + @swapStatusMsg.setter + def swapStatusMsg(self, swap_status_msg: str): + if self._swapStatusMsg != swap_status_msg: + self._swapStatusMsg = swap_status_msg + self.swapStatusMsgChanged.emit() + + def _send_change_to_lightning(self) -> bool: + # only honour the (persistent) config flag when this finalizer was set up to + # offer the option, so non-payment flows (channel open, sweep) never swap change + return (bool(self._tx_options & TxFeeSlider.TxOptions.SEND_CHANGE_TO_LIGHTNING) + and self._wallet.wallet.has_lightning() + and self._config.WALLET_SEND_CHANGE_TO_LIGHTNING) + @profiler def make_tx(self, amount): self._logger.debug(f'make_tx amount={amount}') @@ -479,7 +564,8 @@ def make_tx(self, amount): coins=coins, outputs=outputs, fee_policy=self._fee_policy, - rbf=self._rbf) + rbf=self._rbf, + send_change_to_lightning=self._send_change_to_lightning()) self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) @@ -490,10 +576,20 @@ def update(self): self._logger.debug('wallet not set, ignoring update()') return + send_change_to_lightning = self._send_change_to_lightning() + if send_change_to_lightning: + self._logger.debug('sending change to lightning') + self.prepare_swap_transport() + try: # make unsigned transaction amount = '!' if self._amount.isMax else self._amount.satsInt tx = self.make_tx(amount=amount) + msg = '' + if send_change_to_lightning: + msg = self.swap_manager.get_message_for_swap_change(self.swap_transport, tx) + self.swapStatusMsg = msg + except NotEnoughFunds: self.warning = self._wallet.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount=amount) self._valid = False @@ -561,11 +657,104 @@ def signAndSend(self): self._logger.debug('no valid tx') return + if self.busy: + self._logger.debug('busy') + return + + tx = self._tx + if self.f_accept: self.f_accept(self._tx) return - self._wallet.sign_and_broadcast(self._tx, on_success=partial(self.on_signed_tx, False), on_failure=self.on_sign_failed) + if tx.get_dummy_output(DummyAddress.SWAP): + self._send_with_swap_change(tx) + else: + self._wallet.sign_and_broadcast(tx, on_success=partial(self.on_signed_tx, False), + on_failure=self.on_sign_failed) + + @auth_protect(message=_('Sign and send on-chain transaction?')) + def _send_with_swap_change(self, tx): + assert self._wallet.wallet.lnworker + assert tx.get_dummy_output(DummyAddress.SWAP) + + self.busy = True + + async def handle_swap_task(): + try: + swap_dummy_output = tx.get_dummy_output(DummyAddress.SWAP) + swap_manager = self.swap_manager + transport = self.swap_transport + + try: + if not swap_manager.is_initialized.is_set(): + await asyncio.wait_for(swap_manager.is_initialized.wait(), timeout=5) + except Exception as e: + try: # finalizer might be destroyed at this point + self._logger.exception(e) + self.swapError.emit(str(e)) + except RuntimeError: + pass + return + + try: + self._logger.debug('request_swap_for_amount') + swap, invoice = await swap_manager.request_swap_for_amount( + transport=transport, onchain_amount=swap_dummy_output.value) + + tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address) + assert tx.get_dummy_output(DummyAddress.SWAP) is None + tx.swap_invoice = invoice + tx.swap_payment_hash = swap.payment_hash + except Exception as e: + self._logger.info(f'swap error while requesting swap from server: {str(e)}') + self.swapError.emit(str(e)) + return + + try: + if not self._wallet.wallet.sign_transaction(tx, self._wallet.password): + raise Exception('tx not signed') + self.swapStart.emit() + funding_txid = await swap_manager.wait_for_htlcs_and_broadcast( + transport=transport, swap=swap, invoice=tx.swap_invoice, tx=tx) + if not funding_txid: + # wait_for_htlcs_and_broadcast returns None (no exception) when the + # invoice expired before any HTLC arrived; don't report success. + raise Exception(_('Timed out waiting for the swap; the Lightning invoice expired.')) + else: + self._logger.debug(f'{funding_txid=}') + self.swapFunded.emit() + self.finished.emit(True, False, tx.is_complete()) # closes ConfirmTxDialog + except asyncio.CancelledError: + # cancelSwap() failed the swap and cancelled this task; + # don't emit success or error in that case. + self._logger.info('swap cancelled by user') + return + except Exception as e: + self._logger.exception('send change to lightning failed') + self.swapError.emit(str(e)) + return + + finally: + # ensures that swap_task is always set None if transport closes + self.swap_task = None + self.busy = False + + self.swap_task = asyncio.run_coroutine_threadsafe(handle_swap_task(), get_asyncio_loop()) + + @pyqtSlot() + def cancelSwap(self): + if not self.swap_task: + # swap already finished + return + swap_manager = self._wallet.wallet.lnworker.swap_manager + payment_hash = getattr(self._tx, 'swap_payment_hash', None) + swap = swap_manager.get_swap(payment_hash) if payment_hash else None + if swap: + swap_manager.cancel_normal_swap(swap) + self.swap_task.cancel() + # reset the current signed tx + self.update() @pyqtSlot() def sign(self):