qt: MyTreeView: close menu if its context changes
What changed, and why it matters
This commit fixes a user-interface glitch in the Electrum desktop wallet's Qt GUI. If a user right-clicked an address or a coin (UTXO) to open a menu, and the wallet's background update refreshed the list while the menu was still open, the menu could remain visible even though the item it referred to no longer existed or had changed. The fix automatically closes such stale context menus. It is a usability/reliability fix rather than a cryptographic or network security flaw, but acting on a stale menu could in principle lead to user confusion or unintended transactions.
Treat as a low-severity UI hardening patch. Include in regular release notes; no urgent security advisory is warranted based on the supplied commit alone. If the project maintains a bug bounty or security tracker, it can be logged as a minor reliability issue. Users should upgrade as part of normal maintenance.
Security signals we found
UI state desynchronization between context menu and underlying wallet data
Potential for user action on a stale/removed UTXO or address
Fixes a reported issue (#10464) but commit message frames it as a UI bug, not a security vulnerability
No input validation, crypto, or network changes
Evidence from the diff
The patch introduces a shared menu lifecycle in MyTreeView: open_menu() stores the active QMenu and wraps menu.exec(), while close_menu() dismisses it. AddressList now computes a hash of the displayed address set and balances during update(); if it changes, close_menu() is called. UTXOList’s _maybe_reset_coincontrol() now also closes the menu if any currently selected (highlighted) UTXO disappears from the wallet. Other tree views are migrated to open_menu() for consistency. The referenced issue #10464 is described only as a bug report; no exploit details are present in the commit materials.
Changed components
electrum/gui/qt/my_treeview.pyelectrum/gui/qt/address_list.pyelectrum/gui/qt/utxo_list.pyelectrum/gui/qt/channels_list.pyelectrum/gui/qt/contact_list.pyelectrum/gui/qt/history_list.pyelectrum/gui/qt/invoice_list.pyelectrum/gui/qt/request_list.pyInspect captured patch +36 / −12
diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py
index 8836511..ee07704 100644
--- a/electrum/gui/qt/address_list.py
+++ b/electrum/gui/qt/address_list.py
@@ -100,6 +100,7 @@ class AddressList(MyTreeView):
editable_columns=[self.Columns.LABEL],
)
self.wallet = self.main_window.wallet
+ self._address_list_status = 0 # type: int
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSortingEnabled(True)
self.show_change = AddressTypeFilter.ALL # type: AddressTypeFilter
@@ -186,9 +187,9 @@ class AddressList(MyTreeView):
self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
self.std_model.clear()
self.refresh_headers()
- fx = self.main_window.fx
set_address = None
num_shown = 0
+ new_address_list_status = 0
self.addresses_beyond_gap_limit = self.wallet.get_all_known_addresses_beyond_gap_limit()
for address in addr_list:
c, u, x = self.wallet.get_addr_balance(address)
@@ -203,6 +204,7 @@ class AddressList(MyTreeView):
if self.show_used == AddressUsageStateFilter.FUNDED_OR_UNUSED and is_used_and_empty:
continue
num_shown += 1
+ new_address_list_status = hash((new_address_list_status, address, c, u, x, is_used_and_empty))
labels = [""] * len(self.Columns)
labels[self.Columns.ADDRESS] = address
address_item = [QStandardItem(e) for e in labels]
@@ -239,6 +241,9 @@ class AddressList(MyTreeView):
self.showColumn(self.Columns.FIAT_BALANCE)
else:
self.hideColumn(self.Columns.FIAT_BALANCE)
+ if self._address_list_status != new_address_list_status:
+ self._address_list_status = new_address_list_status
+ self.close_menu()
self.filter()
self.proxy.setDynamicSortFilter(True)
# update counter
@@ -343,7 +348,7 @@ class AddressList(MyTreeView):
menu.addAction(_("Add to coin control"), lambda: self.main_window.utxo_list.add_to_coincontrol(coins))
run_hook('receive_menu', menu, addrs, self.wallet)
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
def place_text_on_clipboard(self, text: str, *, title: str = None) -> None:
if is_address(text):
diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py
index 985e454..02e2982 100644
--- a/electrum/gui/qt/channels_list.py
+++ b/electrum/gui/qt/channels_list.py
@@ -281,7 +281,7 @@ class ChannelsList(MyTreeView):
menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
else:
menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id))
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
@QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel)
def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel):
diff --git a/electrum/gui/qt/contact_list.py b/electrum/gui/qt/contact_list.py
index e6f7de0..bc9d06b 100644
--- a/electrum/gui/qt/contact_list.py
+++ b/electrum/gui/qt/contact_list.py
@@ -101,7 +101,7 @@ class ContactList(MyTreeView):
menu.addAction(_("View on block explorer"), lambda: [webopen(u) for u in URLs])
run_hook('create_contact_menu', menu, selected_keys)
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
def update(self):
if self.maybe_defer_update():
diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py
index b3493e9..c1d44ff 100644
--- a/electrum/gui/qt/history_list.py
+++ b/electrum/gui/qt/history_list.py
@@ -799,7 +799,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
menu_invs.addAction(_("View invoice"), lambda inv=inv: self.main_window.show_onchain_invoice(inv))
if tx_URL:
menu.addAction(_("View on block explorer"), lambda: webopen(tx_URL))
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
def remove_local_tx(self, tx_hash: str):
num_child_txs = len(self.wallet.adb.get_depending_transactions(tx_hash))
diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py
index 0a56d93..2883d43 100644
--- a/electrum/gui/qt/invoice_list.py
+++ b/electrum/gui/qt/invoice_list.py
@@ -195,7 +195,7 @@ class InvoiceList(MyTreeView):
if log:
menu.addAction(_("View log"), lambda: self.show_log(key, log))
menu.addAction(_("Delete"), lambda: self.delete_invoices([key]))
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
def show_log(self, key, log: Sequence[HtlcLog]):
d = WindowModalDialog(self, _("Payment log"))
diff --git a/electrum/gui/qt/my_treeview.py b/electrum/gui/qt/my_treeview.py
index 6c26073..4998da0 100644
--- a/electrum/gui/qt/my_treeview.py
+++ b/electrum/gui/qt/my_treeview.py
@@ -256,12 +256,26 @@ class MyTreeView(QTreeView):
self._pending_update = False
self._forced_update = False
+ self._currently_open_menu = None # type: Optional[QMenu]
+
self._default_bg_brush = QStandardItem().background()
self.proxy = None # history, and address tabs use a proxy
def create_menu(self, position: QPoint) -> None:
pass
+ def open_menu(self, menu: QMenu, position) -> None:
+ try:
+ self._currently_open_menu = menu
+ menu.exec(self.viewport().mapToGlobal(position))
+ finally:
+ self._currently_open_menu = None
+
+ def close_menu(self):
+ if self._currently_open_menu:
+ self._currently_open_menu.close()
+ self._currently_open_menu = None
+
def set_editability(self, items):
for idx, i in enumerate(items):
i.setEditable(idx in self.editable_columns)
diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py
index a1de5a9..9e66fcc 100644
--- a/electrum/gui/qt/request_list.py
+++ b/electrum/gui/qt/request_list.py
@@ -209,7 +209,7 @@ class RequestList(MyTreeView):
# menu.addAction(_("View in web browser"), lambda: webopen(req['view_url']))
menu.addAction(_("Delete"), lambda: self.delete_requests([key]))
run_hook('receive_list_menu', self.main_window, menu, key)
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
def delete_requests(self, keys):
self.wallet.delete_requests(keys)
diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py
index 94f8fe0..d5abec6 100644
--- a/electrum/gui/qt/utxo_list.py
+++ b/electrum/gui/qt/utxo_list.py
@@ -230,12 +230,17 @@ class UTXOList(MyTreeView):
return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict
def _maybe_reset_coincontrol(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None:
- if not bool(self._spend_set):
+ if not self._spend_set and not self._currently_open_menu:
return
- # if we spent one of the selected UTXOs, just reset selection
utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos}
- if not all([prevout_str in utxo_set for prevout_str in self._spend_set]):
- self._spend_set.clear()
+ if self._currently_open_menu:
+ # if we spent one of the qt-highlighted UTXOs, close context-menu
+ if not all(prevout_str in utxo_set for prevout_str in self.get_selected_outpoints()):
+ self.close_menu()
+ if self._spend_set:
+ # if we spent one of the green-marked UTXOs, just reset selection
+ if not all([prevout_str in utxo_set for prevout_str in self._spend_set]):
+ self._spend_set.clear()
def can_swap_coins(self, coins):
# fixme: min and max_amounts are known only after first request
@@ -369,7 +374,7 @@ class UTXOList(MyTreeView):
act.setToolTip(MSG_FREEZE_ADDRESS)
run_hook('qt_utxo_menu', menu, coins, self.wallet)
- menu.exec(self.viewport().mapToGlobal(position))
+ self.open_menu(menu, position)
def get_filter_data_from_coordinate(self, row, col):
if col == self.Columns.OUTPOINT:
Why this scored 28/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.