From d91f490c0b95187ebf5f32ab63bf146a0356fb53 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 9 Apr 2026 14:11:30 +0200 Subject: [PATCH 1/6] qml: refactor ConfirmTxDialog; don't use accept signal to confirm payment as it implicitly closes the dialog. Instead emit `confirmed` signal and let the dialog initiator close the dialog when applicable. --- .../gui/qml/components/ConfirmTxDialog.qml | 4 +++- .../gui/qml/components/OpenChannelDialog.qml | 15 +++++++++---- .../gui/qml/components/WalletMainView.qml | 22 ++++++++++++------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 2faa088fae10..709b28f70732 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -18,6 +18,8 @@ ElDialog { property alias amountLabelText: amountLabel.text property alias sendButtonText: sendButton.text + signal confirmed + title: qsTr('Transaction Fee') iconSource: Qt.resolvedUrl('../../icons/question.png') @@ -284,7 +286,7 @@ ElDialog { : qsTr('Pay...') icon.source: '../../icons/confirmed.png' enabled: finalizer.valid - onClicked: doAccept() + onClicked: confirmed() } } } diff --git a/electrum/gui/qml/components/OpenChannelDialog.qml b/electrum/gui/qml/components/OpenChannelDialog.qml index a9b6f3d310b5..4b8442ef7e4e 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 55f73519db45..13f27f86026b 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() @@ -148,7 +150,7 @@ Item { ? 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 +166,7 @@ Item { } console.log("Sending sweep transaction") finalizerDialog.finalizer.send() + finalizerDialog.close() }) finalizerDialog.open() }) @@ -693,7 +696,7 @@ Item { } showExport(getSerializedTx(), msg) } - _confirmPaymentDialog.destroy() + _confirmPaymentDialog.close() } onSignError: (message) => { var dialog = app.messageDialog.createObject(mainView, { @@ -705,10 +708,11 @@ Item { } } - // 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() } } @@ -725,6 +729,8 @@ Item { canRbf: true privateKeys: _confirmSweepDialog.privateKeys } + + onClosed: destroy() } } From 6bbb20f7df5fce4839ae3fc2617742c0616e5141 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 23 Jan 2025 16:07:39 +0100 Subject: [PATCH 2/6] qml: qeconfig add property for send-change-to-lightning --- electrum/gui/qml/qeconfig.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index d4ca6b9d5aa0..33115750bcb4 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): From 5bb718d550c675f7c16df9ee0405692b513b3fe9 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 23 Jan 2025 16:26:19 +0100 Subject: [PATCH 3/6] qml: add send-to-lightning in ConfirmTxDialog --- .../gui/qml/components/ConfirmTxDialog.qml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 709b28f70732..3c55e3e7b4a0 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -205,6 +205,27 @@ ElDialog { helptext: Config.longDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING') } + ElCheckBox { + Layout.fillWidth: true + visible: 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: Daemon.currentWallet.isLightning && Daemon.currentWallet.lightningCanReceive.satsInt > 0 + heading: Config.shortDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') + helptext: Config.longDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') + } + } } From 222425041efb9c3ac315685d7deac7e8593c8802 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 25 Mar 2025 11:15:23 +0100 Subject: [PATCH 4/6] qml: add change-to-swap support to QETxFinalizer --- .../gui/qml/components/ConfirmTxDialog.qml | 12 ++ electrum/gui/qml/components/MessageDialog.qml | 3 + .../gui/qml/components/WalletMainView.qml | 32 ++++ electrum/gui/qml/qetxfinalizer.py | 154 +++++++++++++++++- 4 files changed, 193 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 3c55e3e7b4a0..af736d7ec174 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -165,6 +165,7 @@ ElDialog { id: optionslayout width: parent.width columns: 2 + rowSpacing: 0 ElCheckBox { Layout.fillWidth: true @@ -206,6 +207,7 @@ ElDialog { } ElCheckBox { + id: cb_send_change_to_lightning Layout.fillWidth: true visible: Daemon.currentWallet.isLightning && Daemon.currentWallet.lightningCanReceive.satsInt > 0 text: Config.shortDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') @@ -226,6 +228,16 @@ ElDialog { 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 + } + } } diff --git a/electrum/gui/qml/components/MessageDialog.qml b/electrum/gui/qml/components/MessageDialog.qml index 4452b3baaa90..2da6d8b93c51 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/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 13f27f86026b..f8aa94a4060d 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -675,6 +675,8 @@ Item { id: _confirmPaymentDialog title: qsTr('Confirm Payment') finalizer: TxFinalizer { + id: _txfinalizer + property var _swapwaitdialog wallet: Daemon.currentWallet canRbf: true onFinished: (signed, saved, complete) => { @@ -706,6 +708,36 @@ 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) + } } // NOTE: destroy-on-close was previously disabled due to the 'accept' signal implicitly diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index e3d0da18de1d..f45a13791232 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -1,18 +1,21 @@ +import asyncio import copy +from asyncio import Future from enum import IntEnum 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): @@ -155,7 +164,7 @@ def update(self): class TxFeeSlider(FeeSlider): def __init__(self, parent=None): - super().__init__(parent) + FeeSlider.__init__(self, parent) self._fee = QEAmount() self._feeRate = '' @@ -389,11 +398,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 +415,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 +426,23 @@ def __init__( self._effectiveAmount = QEAmount() self._extraFee = QEAmount() self._canRbf = False + self._swapStatusMsg = '' + 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) @@ -465,6 +497,16 @@ 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() + @profiler def make_tx(self, amount): self._logger.debug(f'make_tx amount={amount}') @@ -479,7 +521,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._config.WALLET_SEND_CHANGE_TO_LIGHTNING) self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) @@ -490,10 +533,19 @@ def update(self): self._logger.debug('wallet not set, ignoring update()') return + if self._wallet.wallet.has_lightning() and self._config.WALLET_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 self.config.WALLET_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 +613,97 @@ def signAndSend(self): self._logger.debug('no valid tx') 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) + + 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.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): From cdfcf88e560b354efdd92d49d0bd8ce20cc72d2b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 11 Jun 2026 09:56:27 +0200 Subject: [PATCH 5/6] qml: make finalizer options a flag, allowing to enable/disable certain finalizer options depending on the use-case --- .../gui/qml/components/ConfirmTxDialog.qml | 14 +++++-- .../gui/qml/components/WalletMainView.qml | 5 ++- electrum/gui/qml/qechannelopener.py | 1 + electrum/gui/qml/qetxfinalizer.py | 40 +++++++++++++++++-- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index af736d7ec174..f0e88991c5ae 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -14,7 +14,6 @@ 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 @@ -152,7 +151,7 @@ ElDialog { Layout.columnSpan: 2 labelText: qsTr('Options') color: Material.accentColor - visible: showOptions + visible: finalizer.txOptions } DialogHighlightPane { @@ -168,7 +167,9 @@ ElDialog { 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) { @@ -182,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) { @@ -202,6 +206,7 @@ ElDialog { } HelpButton { + visible: cb_output_rounding.visible heading: Config.shortDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING') helptext: Config.longDescFor('WALLET_COIN_CHOOSER_OUTPUT_ROUNDING') } @@ -209,7 +214,8 @@ ElDialog { ElCheckBox { id: cb_send_change_to_lightning Layout.fillWidth: true - visible: Daemon.currentWallet.isLightning && Daemon.currentWallet.lightningCanReceive.satsInt > 0 + 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) { @@ -223,7 +229,7 @@ ElDialog { } HelpButton { - visible: Daemon.currentWallet.isLightning && Daemon.currentWallet.lightningCanReceive.satsInt > 0 + visible: cb_send_change_to_lightning.visible heading: Config.shortDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') helptext: Config.longDescFor('WALLET_SEND_CHANGE_TO_LIGHTNING') } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index f8aa94a4060d..657051147331 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -144,7 +144,6 @@ 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...') @@ -679,6 +678,9 @@ Item { 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 @@ -760,6 +762,7 @@ 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 957aa30d2e1f..71ca296054ba 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/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index f45a13791232..8a1d73ee22fa 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -1,7 +1,7 @@ import asyncio import copy from asyncio import Future -from enum import IntEnum +from enum import IntEnum, IntFlag import threading from decimal import Decimal, InvalidOperation from typing import Optional, TYPE_CHECKING, Callable @@ -163,9 +163,19 @@ 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): FeeSlider.__init__(self, parent) + self._tx_options = TxFeeSlider.TxOptions.MULTIPLE_CHANGE | TxFeeSlider.TxOptions.OUTPUT_ROUNDING + self._fee = QEAmount() self._feeRate = '' self._userFee = '' @@ -179,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: @@ -507,6 +531,13 @@ def swapStatusMsg(self, swap_status_msg: str): 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}') @@ -522,7 +553,7 @@ def make_tx(self, amount): outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf, - send_change_to_lightning=self._config.WALLET_SEND_CHANGE_TO_LIGHTNING) + 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()))) @@ -533,7 +564,8 @@ def update(self): self._logger.debug('wallet not set, ignoring update()') return - if self._wallet.wallet.has_lightning() and self._config.WALLET_SEND_CHANGE_TO_LIGHTNING: + 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() @@ -542,7 +574,7 @@ def update(self): amount = '!' if self._amount.isMax else self._amount.satsInt tx = self.make_tx(amount=amount) msg = '' - if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING: + if send_change_to_lightning: msg = self.swap_manager.get_message_for_swap_change(self.swap_transport, tx) self.swapStatusMsg = msg From 708dc41b19484bf9da7c1893765b83123e0aec3a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Jul 2026 15:55:16 +0200 Subject: [PATCH 6/6] qml: add busy property on QETxFinalizer for guarding re-entry while background job is running --- .../gui/qml/components/ConfirmTxDialog.qml | 2 +- electrum/gui/qml/qetxfinalizer.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index f0e88991c5ae..f93215dae529 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -324,7 +324,7 @@ ElDialog { ? qsTr('Finalize...') : qsTr('Pay...') icon.source: '../../icons/confirmed.png' - enabled: finalizer.valid + enabled: finalizer.valid && !finalizer.busy onClicked: confirmed() } } diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 8a1d73ee22fa..11b15e462f7c 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -451,6 +451,7 @@ def __init__( 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) @@ -492,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): @@ -645,6 +657,10 @@ 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: @@ -662,6 +678,8 @@ 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) @@ -720,6 +738,7 @@ async def handle_swap_task(): 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())