qt: perform 'fully spend' action with coin selection, keep separate from coin control when doing action.
What changed, and why it matters
This commit fixes a user-interface bug in the Electrum Bitcoin wallet's Qt desktop client. Previously, when a user chose to 'fully spend' selected coins to open a Lightning channel or perform a submarine swap, the wallet used the global 'coin control' setting to remember the coin selection. That caused the selected coins to remain locked in coin control after the dialog closed, and a background timer could keep re-creating transactions with the same already-signed coins, leading to an exception. The change passes the selected coins directly to the relevant dialogs instead of relying on coin control, and stops the timer when the dialog finishes. It is a reliability/usability fix rather than a remote-exploitable security vulnerability.
Treat as a normal bug-fix commit. Reviewers should verify that `get_coins` is consistently honored in all coin-selection paths and that `SwapDialog`'s timer is stopped in every close path, including error paths. No emergency response is warranted.
Security signals we found
State-management defect: selected UTXOs leaked into global coin control, causing unintended coin locking/retention
Exception path: background timer re-generated transactions with already-signed inputs after swap start
Fix pattern: explicit parameter passing replaces mutable shared state for coin selection
No input validation, deserialization, or cryptographic changes observed
Evidence from the diff
The patch refactors three Qt flows—submarine swaps, new Lightning channels, and on-chain sends from the UTXO list—so that the selected coin set is passed via a new get_coins callback parameter rather than being persisted through the wallet’s global coin-control state. It also wires SwapDialog.finished to self.timer.stop() to prevent the dialog’s periodic timer_actions from re-running after the dialog closes. The diff shows no cryptographic, network, or permission changes; the issue is a state-management defect in the GUI that could produce stale transaction attempts and exceptions.
Changed components
electrum/gui/qt/main_window.pyelectrum/gui/qt/new_channel_dialog.pyelectrum/gui/qt/swap_dialog.pyelectrum/gui/qt/utxo_list.pyInspect captured patch +38 / −26
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
index 63818cb..81e2437 100644
--- a/electrum/gui/qt/main_window.py
+++ b/electrum/gui/qt/main_window.py
@@ -1264,9 +1264,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
def run_swap_dialog(
self,
+ *,
is_reverse: Optional[bool] = None,
recv_amount_sat_or_max: Optional[Union[int, str]] = None,
channels: Optional[Sequence['Channel']] = None,
+ get_coins: Optional[Callable[..., Sequence[PartialTxInput]]] = None,
) -> bool:
if not self.network:
self.show_error(_("You are offline."))
@@ -1290,7 +1292,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
transport,
is_reverse=is_reverse,
recv_amount_sat_or_max=recv_amount_sat_or_max,
- channels=channels
+ channels=channels,
+ get_coins=get_coins
)
try:
return d.run(transport)
@@ -1484,17 +1487,18 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
msg = _('Signing transaction...')
WaitingDialog(self, msg, task, on_success, on_failure)
- def mktx_for_open_channel(self, *, funding_sat, node_id):
+ def mktx_for_open_channel(self, *, funding_sat, node_id, get_coins=None):
def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
assert base_tx is None
+ coins = get_coins() if get_coins else self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only)
return self.wallet.lnworker.mktx_for_open_channel(
- coins=self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only),
+ coins=coins,
funding_sat=funding_sat,
node_id=node_id,
fee_policy=fee_policy)
return make_tx
- def open_channel(self, connect_str, funding_sat, push_amt):
+ def open_channel(self, connect_str, funding_sat, *, push_amt=0, get_coins=None):
try:
node_id, rest = extract_nodeid(connect_str)
except ConnStringFormatError as e:
@@ -1505,7 +1509,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
if not self.question(msg):
return
# we need to know the fee before we broadcast, because the txid is required
- make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id)
+ make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id, get_coins=get_coins)
funding_tx, _, _ = self.confirm_tx_dialog(make_tx, funding_sat, context=TxEditorContext.CHANNEL_FUNDING)
if not funding_tx:
return
@@ -2027,7 +2031,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
msg = _('Do you want to create your first channel?') + '\n\n' + messages.MSG_LIGHTNING_WARNING
if not self.question(msg):
return
- d = NewChannelDialog(self, amount_sat, min_amount_sat)
+ d = NewChannelDialog(self, amount_sat=amount_sat, min_amount_sat=min_amount_sat)
return d.run()
def new_contact_dialog(self):
diff --git a/electrum/gui/qt/new_channel_dialog.py b/electrum/gui/qt/new_channel_dialog.py
index 85f575d..e8af1ea 100644
--- a/electrum/gui/qt/new_channel_dialog.py
+++ b/electrum/gui/qt/new_channel_dialog.py
@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Optional, Callable, Sequence
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QComboBox, QLineEdit, QHBoxLayout
import electrum_ecc as ecc
@@ -16,12 +16,20 @@ from .amountedit import BTCAmountEdit
from .my_treeview import create_toolbar_with_menu
if TYPE_CHECKING:
+ from electrum.transaction import PartialTxInput
from .main_window import ElectrumWindow
class NewChannelDialog(WindowModalDialog):
- def __init__(self, window: 'ElectrumWindow', amount_sat: Optional[int] = None, min_amount_sat: Optional[int] = None):
+ def __init__(
+ self,
+ window: 'ElectrumWindow',
+ *,
+ amount_sat: Optional[int] = None,
+ min_amount_sat: Optional[int] = None,
+ get_coins: Optional[Callable[..., Sequence['PartialTxInput']]] = None,
+ ):
WindowModalDialog.__init__(self, window, _('Open Channel'))
self.window = window
self.network = window.network
@@ -30,6 +38,7 @@ class NewChannelDialog(WindowModalDialog):
self.trampolines = hardcoded_trampoline_nodes()
self.trampoline_names = list(self.trampolines.keys())
self.min_amount_sat = min_amount_sat or MIN_FUNDING_SAT
+ self.get_coins = get_coins
vbox = QVBoxLayout(self)
toolbar, menu = create_toolbar_with_menu(self.config, '')
menu.addConfig(
@@ -141,7 +150,7 @@ class NewChannelDialog(WindowModalDialog):
if not self.max_button.isChecked():
return
dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True)
- make_tx = self.window.mktx_for_open_channel(funding_sat='!', node_id=dummy_nodeid)
+ make_tx = self.window.mktx_for_open_channel(funding_sat='!', node_id=dummy_nodeid, get_coins=self.get_coins)
try:
tx = make_tx(FeePolicy(self.config.FEE_POLICY))
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
@@ -176,5 +185,5 @@ class NewChannelDialog(WindowModalDialog):
connect_str = str(self.trampolines[name])
if not connect_str:
return
- self.window.open_channel(connect_str, funding_sat, 0)
+ self.window.open_channel(connect_str, funding_sat, get_coins=self.get_coins)
return True
diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py
index 6676205..01a9b79 100644
--- a/electrum/gui/qt/swap_dialog.py
+++ b/electrum/gui/qt/swap_dialog.py
@@ -11,7 +11,7 @@ from electrum_aionostr.util import from_nip19
from electrum.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled, trigger_callback
from electrum.bitcoin import DummyAddress
-from electrum.transaction import PartialTxOutput, PartialTransaction
+from electrum.transaction import PartialTxOutput, PartialTransaction, PartialTxInput
from electrum.fee_policy import FeePolicy
from electrum.submarine_swaps import NostrTransport
@@ -92,16 +92,16 @@ class SwapProvidersButton(QPushButton):
trigger_callback('swap_provider_changed')
-
class SwapDialog(WindowModalDialog, QtEventListener):
-
def __init__(
self,
window: 'ElectrumWindow',
transport: 'SwapServerTransport',
+ *,
is_reverse: Optional[bool] = None,
recv_amount_sat_or_max: Optional[Union[int, str]] = None, # sat or '!'
channels: Optional[Sequence['Channel']] = None,
+ get_coins: Optional[Callable[..., Sequence['PartialTxInput']]] = None,
):
WindowModalDialog.__init__(self, window, _('Submarine Swap'))
self.window = window
@@ -111,6 +111,8 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.network = window.network
self.channels = channels
self.is_reverse = is_reverse if is_reverse is not None else True
+ self.get_coins = get_coins
+
vbox = QVBoxLayout(self)
self.transport = transport
@@ -187,6 +189,8 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
+ self.finished.connect(self.on_finished)
+
self.fee_slider.update()
self.register_callbacks()
@@ -194,6 +198,9 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.unregister_callbacks()
event.accept()
+ def on_finished(self, *args):
+ self.timer.stop()
+
@qt_event_listener
def on_event_fee_histogram(self, *args):
self.update_send_receive()
@@ -425,7 +432,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
assert not self.is_reverse
if onchain_amount is None:
raise InvalidSwapParameters("onchain_amount is None")
- coins = self.window.get_coins()
+ coins = self.get_coins() if self.get_coins else self.window.get_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self.swap_manager.client_max_amount_forward_swap()
diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py
index 5d9c0e4..94c94e0 100644
--- a/electrum/gui/qt/utxo_list.py
+++ b/electrum/gui/qt/utxo_list.py
@@ -260,10 +260,7 @@ class UTXOList(MyTreeView):
def swap_coins(self, coins: list[PartialTxInput]) -> None:
assert coins, "no coins selected?"
- #self.clear_coincontrol()
- self.add_to_coincontrol(coins)
- self.main_window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max='!')
- self.clear_coincontrol()
+ self.main_window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max='!', get_coins=lambda *args, **kwargs: coins)
def can_open_channel(self, coins):
if self.wallet.lnworker is None:
@@ -274,9 +271,7 @@ class UTXOList(MyTreeView):
def open_channel_with_coins(self, coins: list[PartialTxInput]) -> None:
assert coins, "no coins selected?"
# todo : use a single dialog in new flow
- #self.clear_coincontrol()
- self.add_to_coincontrol(coins)
- d = NewChannelDialog(self.main_window)
+ d = NewChannelDialog(self.main_window, get_coins=lambda *args, **kwargs: coins)
d.max_button.setChecked(True)
d.max_button.setEnabled(False)
d.min_button.setEnabled(False)
@@ -284,7 +279,6 @@ class UTXOList(MyTreeView):
d.amount_e.setFrozen(True)
d.spend_max()
d.run()
- self.clear_coincontrol()
def clipboard_contains_address(self) -> bool:
text = self.main_window.app.clipboard().text()
@@ -297,10 +291,8 @@ class UTXOList(MyTreeView):
return
addr = self.main_window.app.clipboard().text()
outputs = [PartialTxOutput.from_address_and_value(addr, '!')]
- #self.clear_coincontrol()
- self.add_to_coincontrol(coins)
- self.main_window.send_tab.pay_onchain_dialog(outputs)
- self.clear_coincontrol()
+
+ self.main_window.send_tab.pay_onchain_dialog(outputs, get_coins=lambda *args, **kwargs: coins)
def on_double_click(self, idx):
outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(self.ROLE_PREVOUT_STR)
Why this scored 22/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.