Merge pull request #11092 from f321x/qml_address_history_labels
What changed, and why it matters
This commit fixes a UI bug in Electrum's QML (mobile-style) interface where transaction and address labels could become stale or fail to update after editing. It replaces a fragile manual callback system with a proper event listener so the wallet's label-change notifications automatically refresh the displayed labels. There is no direct security vulnerability here; it is a correctness and usability fix for label synchronization.
Treat as a normal bug-fix merge. No urgent security action required. Reviewers may want to verify that the new `label_changed` callback is always unregistered and that `get_txids_with_label_derived_from` handles all expected key types safely, but the patch includes tests covering the main cases.
Security signals we found
No security-relevant signals in diff: no privilege escalation, injection, cryptographic, or authentication changes.
Fixes a UI consistency bug (stale labels) that could mislead users but does not enable direct exploitation.
Adds event-listener lifecycle management (register/unregister on destroy) to prevent dangling callbacks and potential use-after-free patterns.
Refactors label normalization (newline stripping) into a single helper, reducing risk of inconsistent handling.
Evidence from the diff
The patch refactors label update propagation in the QML GUI. Previously, detail pages exposed a detailsChanged signal and a label alias, and callers manually wired those to historyModel.updateTxLabel(). This was error-prone and caused stale labels. The new approach: (1) wallet.py now emits a label_changed callback from _set_label, with normalized newline handling; (2) QEAddressDetails, QEAddressListModel, and QETransactionListModel register as QtEventListeners and react to label_changed, updating only affected rows via precomputed position maps; (3) QML pages no longer expose detailsChanged/label aliases, and callers no longer manually connect signals. Tests are added/updated to verify label propagation for addresses, outpoints, txids, spent-outpoint-derived labels, and group rows. The change also fixes a bug where get_label_for_address could return None instead of ''.
Changed components
electrum/gui/qml/components/LightningPaymentDetails.qmlelectrum/gui/qml/components/TxDetails.qmlelectrum/gui/qml/components/WalletMainView.qmlelectrum/gui/qml/components/controls/HistoryItemDelegate.qmlelectrum/gui/qml/qeaddressdetails.pyelectrum/gui/qml/qeaddresslistmodel.pyelectrum/gui/qml/qetransactionlistmodel.pyelectrum/wallet.pytests/qml/qt_util.pytests/qml/test_qml_qeaddresslistmodel.pytests/qml/test_qml_qetransactionlistmodel.pytests/qml/test_qml_qetxdetails.pyInspect captured patch +334 / −84
### electrum/gui/qml/components/LightningPaymentDetails.qml
@@ -13,9 +13,6 @@ Pane {
height: parent.height
property string key
- property alias label: lnpaymentdetails.label
-
- signal detailsChanged
Flickable {
anchors.fill: parent
@@ -216,7 +213,6 @@ Pane {
id: lnpaymentdetails
wallet: Daemon.currentWallet
key: root.key
- onLabelChanged: root.detailsChanged()
}
}
### electrum/gui/qml/components/TxDetails.qml
@@ -15,9 +15,7 @@ Pane {
property string txid
property string rawtx
- property alias label: txdetails.label
- signal detailsChanged
signal closed
function close() {
@@ -496,7 +494,6 @@ Pane {
TxDetails {
id: txdetails
wallet: Daemon.currentWallet
- onLabelChanged: root.detailsChanged()
onConfirmRemoveLocalTx: (message) => {
var dialog = app.messageDialog.createObject(app, { text: message, yesno: true })
dialog.accepted.connect(function() {
### electrum/gui/qml/components/WalletMainView.qml
@@ -580,21 +580,10 @@ Item {
onRequestPaid: {
close()
- var capturedHistoryModel = Daemon.currentWallet.historyModel
if (isLightning) {
- var page = app.stack.push(Qt.resolvedUrl('LightningPaymentDetails.qml'), {'key': key})
- var capturedKey = key
- page.detailsChanged.connect(function() {
- capturedHistoryModel.updateTxLabel(capturedKey, page.label)
- }
- )
+ app.stack.push(Qt.resolvedUrl('LightningPaymentDetails.qml'), {'key': key})
} else {
- let paidTxid = getPaidTxid()
- var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {'txid': paidTxid})
- page.detailsChanged.connect(function() {
- capturedHistoryModel.updateTxLabel(paidTxid, page.label)
- }
- )
+ app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {'txid': getPaidTxid()})
}
}
onClosed: destroy()
### electrum/gui/qml/components/controls/HistoryItemDelegate.qml
@@ -44,17 +44,9 @@ Item {
onClicked: {
if (model.lightning) {
- var page = app.stack.push(Qt.resolvedUrl('../LightningPaymentDetails.qml'), {'key': model.key})
- page.detailsChanged.connect(function() {
- // update listmodel when details change
- visualModel.model.updateTxLabel(model.key, page.label)
- })
+ app.stack.push(Qt.resolvedUrl('../LightningPaymentDetails.qml'), {'key': model.key})
} else {
- var page = app.stack.push(Qt.resolvedUrl('../TxDetails.qml'), {'txid': model.key})
- page.detailsChanged.connect(function() {
- // update listmodel when details change
- visualModel.model.updateTxLabel(model.key, page.label)
- })
+ app.stack.push(Qt.resolvedUrl('../TxDetails.qml'), {'txid': model.key})
}
}
### electrum/gui/qml/qeaddressdetails.py
@@ -3,13 +3,15 @@
from electrum.logging import get_logger
from electrum.util import UserFacingException
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
from .auth import auth_protect, AuthMixin
from .qetransactionlistmodel import QETransactionListModel
from .qetypes import QEAmount
from .qewallet import QEWallet
-class QEAddressDetails(AuthMixin, QObject):
+class QEAddressDetails(AuthMixin, QObject, QtEventListener):
_logger = get_logger(__name__)
detailsChanged = pyqtSignal()
@@ -34,6 +36,21 @@ def __init__(self, parent=None):
self._historyModel = None
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @qt_event_listener
+ def on_event_label_changed(self, wallet, name, text):
+ if self._wallet and self._wallet.wallet == wallet and self._address == name:
+ if text is None: # label cleared, fall back to derived label
+ text = wallet.get_label_for_address(self._address) or ''
+ if text != self._label:
+ self._label = text
+ self.labelChanged.emit()
+
walletChanged = pyqtSignal()
@pyqtProperty(QVariant, notify=walletChanged)
def wallet(self) -> QEWallet:
### electrum/gui/qml/qeaddresslistmodel.py
@@ -5,13 +5,13 @@
from electrum.logging import get_logger
from electrum.util import Satoshis
+from electrum.bitcoin import is_address
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from .qeconfig import QEConfig
from .qetypes import QEAmount
-
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
from electrum.transaction import PartialTxInput
@@ -120,6 +120,8 @@ def __init__(self, wallet: 'Abstract_Wallet', parent=None):
super().__init__(parent)
self.wallet = wallet
self._items = []
+ self._address_positions: dict[str, int] = {} # address -> row index in _items
+ self._coin_positions: dict[str, list[int]] = {} # funding txid -> row indices of its coins in _items
self._filterModel = None
self.register_callbacks()
@@ -133,11 +135,6 @@ def __init__(self, wallet: 'Abstract_Wallet', parent=None):
def on_destroy(self):
self.unregister_callbacks()
- @qt_event_listener
- def on_event_labels_received(self, wallet, labels):
- if wallet == self.wallet:
- self.setDirty()
-
@qt_event_listener
def on_event_removed_transaction(self, wallet, tx):
if self.wallet == wallet:
@@ -148,6 +145,16 @@ def on_event_new_transaction(self, wallet, tx):
if self.wallet == wallet:
self.setDirty()
+ @qt_event_listener
+ def on_event_label_changed(self, wallet, name, text):
+ if self.wallet == wallet:
+ if self.wallet.is_mine(name):
+ self.updateAddress(name)
+ # a coin shows the label of its funding tx: name itself, or a tx whose label derives
+ # from name (e.g. via one of its output addresses, or an outpoint it creates or spends)
+ txids = {name} | self.wallet.get_txids_with_label_derived_from(name)
+ self.updateCoin(txids=txids)
+
def rowCount(self, index):
return len(self._items)
@@ -170,8 +177,18 @@ def data(self, index, role):
def clear(self):
self.beginResetModel()
self._items = []
+ self._update_positions()
self.endResetModel()
+ def _update_positions(self):
+ self._address_positions = {}
+ self._coin_positions = {}
+ for i, item in enumerate(self._items):
+ if item.get('outpoint'):
+ self._coin_positions.setdefault(item['txid'], []).append(i)
+ else:
+ self._address_positions[item['address']] = i
+
def addr_to_model(self, addrtype: str, addridx: int, address: str):
c, u, x = self.wallet.get_addr_balance(address)
item = {
@@ -238,6 +255,7 @@ def insert_address(atype, address, addridx):
else:
for i, address in enumerate(r_addresses):
insert_address('imported', address, i)
+ self._update_positions()
self.endInsertRows()
self._dirty = False
@@ -247,13 +265,11 @@ def insert_address(atype, address, addridx):
@pyqtSlot(str)
def updateAddress(self, address):
- for i, a in enumerate(self._items):
- if a['address'] == address:
- self.do_update(i, a)
- return
+ if (i := self._address_positions.get(address)) is not None:
+ self.do_update(i, self._items[i])
@pyqtSlot(str)
- def deleteAddress(self, address):
+ def deleteAddress(self, address: str):
first = -1
last = -1
for i, a in enumerate(self._items):
@@ -265,13 +281,16 @@ def deleteAddress(self, address):
return
self.beginRemoveRows(QModelIndex(), first, last)
self._items = self._items[0:first] + self._items[last+1:]
+ self._update_positions()
self.endRemoveRows()
- def updateCoin(self, outpoint):
- for i, a in enumerate(self._items):
- if a.get('outpoint') == outpoint:
- self.do_update(i, a)
- return
+ def updateCoin(self, *, outpoint: str = None, txids: set[str] = None):
+ if outpoint is not None:
+ txids = {outpoint.split(':')[0]}
+ for txid in txids:
+ for i in self._coin_positions.get(txid, []):
+ if outpoint is None or self._items[i]['outpoint'] == outpoint:
+ self.do_update(i, self._items[i])
def do_update(self, modelindex, modelitem):
mi = self.createIndex(modelindex, 0)
@@ -297,7 +316,7 @@ def setFrozenForItems(self, freeze: bool, items: List[str]):
if len(coins):
self.wallet.set_frozen_state_of_coins(coins, freeze)
for coin in coins:
- self.updateCoin(coin)
+ self.updateCoin(outpoint=coin)
addresses = list(filter(lambda x: ':' not in x, items))
if len(addresses):
self.wallet.set_frozen_state_of_addresses(addresses, freeze)
### electrum/gui/qml/qetransactionlistmodel.py
@@ -13,6 +13,7 @@
from .qetypes import QEAmount
+
if TYPE_CHECKING:
from electrum.wallet import Abstract_Wallet
@@ -37,7 +38,7 @@ def __init__(self, wallet: 'Abstract_Wallet', parent=None, *, onchain_domain=Non
self.include_lightning = include_lightning
self.tx_history: list[dict] = []
- self._tx_positions: dict[str, int] = {} # txid -> row index in tx_history
+ self._tx_positions: dict[str, int] = {} # txid/payment_hash -> row index in tx_history
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
@@ -84,9 +85,21 @@ def on_event_fee_histogram(self, histogram):
self.dataChanged.emit(index, index, roles)
@qt_event_listener
- def on_event_labels_received(self, wallet, labels):
- if wallet == self.wallet:
- self.initModel(True) # TODO: be less dramatic
+ def on_event_label_changed(self, wallet: 'Abstract_Wallet', key: str, label: str | None):
+ if wallet != self.wallet:
+ return
+ if (i := self._tx_positions.get(key)) is None:
+ # not a row key, but rows may have a label derived from it (e.g. an address label)
+ for txid in self.wallet.get_txids_with_label_derived_from(key):
+ if txid in self._tx_positions:
+ self.updateTxLabel(txid, self.wallet.get_label_for_txid(txid) or '')
+ return
+ if label is None: # label cleared, fall back to derived label
+ if self.tx_history[i]['lightning']:
+ label = self.wallet.get_label_for_rhash(key)
+ else:
+ label = self.wallet.get_label_for_txid(key)
+ self.updateTxLabel(key, label or '')
@qt_event_listener
def on_event_new_transaction(self, wallet, tx):
@@ -268,15 +281,20 @@ def initModel(self, force: bool = False):
onchain_domain=self.onchain_domain,
include_lightning=self.include_lightning,
)
- txs = []
- for key, tx in history.items():
- txs.append(self.tx_to_model(tx))
self.clear()
+
+ txs = []
+ for i, (key, tx) in enumerate(reversed(history.items())):
+ m = self.tx_to_model(tx)
+ txs.append(m)
+ if 'txid' in m:
+ self._tx_positions[m['txid']] = i
+ elif 'payment_hash' in m:
+ self._tx_positions[m['payment_hash']] = i
+
self.beginInsertRows(QModelIndex(), 0, len(txs) - 1)
self.tx_history = txs
- self.tx_history.reverse()
- self._tx_positions = {tx['txid']: i for i, tx in enumerate(self.tx_history) if 'txid' in tx}
self.endInsertRows()
self.countChanged.emit()
@@ -317,12 +335,16 @@ def _update_future_txitem(self, tx_item_idx: int):
@pyqtSlot(str, str)
def updateTxLabel(self, key, label):
- for i, tx in enumerate(self.tx_history):
- if tx['key'] == key:
- tx['label'] = label
- index = self.index(i, 0)
- self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']])
- return
+ if (i := self._tx_positions.get(key)) is None:
+ return
+
+ tx = self.tx_history[i]
+ if 'children' in tx: # group row, shows the group label (see wallet.get_full_history), not a tx label
+ return
+ tx['label'] = label
+
+ index = self.index(i, 0)
+ self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']])
@pyqtSlot(int)
def updateBlockchainHeight(self, height):
### electrum/wallet.py
@@ -783,29 +783,31 @@ def _calc_unused_change_addresses(self) -> Sequence[str]:
def is_deterministic(self) -> bool:
return self.keystore.is_deterministic()
- def _set_label(self, key: str, value: Optional[str]) -> None:
- with self.lock:
- if value is None:
- self._labels.pop(key, None)
- else:
- self._labels[key] = value
+ def _normalize_label(self, text):
+ return text.replace("\n", " ") if text else None
- def set_label(self, name: str, text: str | None = None) -> bool:
- if not name:
- return False
+ def _set_label(self, key: str, value: Optional[str]) -> bool:
+ """update label without triggering set_label hook"""
changed = False
+ if not key:
+ return False
+ value = self._normalize_label(value)
with self.lock:
- old_text = self._labels.get(name)
- if text:
- text = text.replace("\n", " ")
- if old_text != text:
- self._labels[name] = text
- changed = True
- else:
- if old_text is not None:
- self._labels.pop(name)
+ old = self._labels.get(key)
+ if value is None:
+ if old is not None:
+ self._labels.pop(key, None)
changed = True
+ elif value != old:
+ self._labels[key] = value
+ changed = True
if changed:
+ util.trigger_callback('label_changed', self, key, value)
+ return changed
+
+ def set_label(self, name: str, text: str | None = None) -> bool:
+ text = self._normalize_label(text)
+ if changed := self._set_label(name, text):
run_hook('set_label', self, name, text)
return changed
@@ -1744,7 +1746,7 @@ def _get_label(self, key: str) -> str:
def get_label_for_address(self, addr: str) -> str:
label = self._labels.get(addr) or ''
if not label and (request := self.get_request_by_addr(addr)):
- label = request.get_message()
+ label = request.get_message() or ''
return label
def set_default_label(self, key: str, value: str):
@@ -1798,6 +1800,18 @@ def _get_default_label_for_txid(self, tx_hash: str) -> str:
# labels.append(label)
return ', '.join(labels)
+ def get_txids_with_label_derived_from(self, key: str) -> Set[str]:
+ """Returns the txids whose default label (see _get_default_label_for_txid)
+ can depend on the label of key, where key is an address or an outpoint.
+ """
+ if is_address(key):
+ return set(self.adb.get_address_history(key).keys())
+ if ':' in key:
+ txid, n = key.split(':', 1)
+ spender = self.db.get_spent_outpoint(txid, n)
+ return {txid, spender} if spender else {txid}
+ return set()
+
def _get_default_label_for_rhash(self, rhash: str) -> str:
req = self.get_request(rhash)
return req.get_message() if req else ''
### tests/qml/qt_util.py
@@ -5,7 +5,12 @@
from functools import wraps, partial
from unittest import SkipTest
-from PyQt6.QtCore import QCoreApplication, QMetaObject, Qt, pyqtSlot, QObject
+from PyQt6 import sip
+from PyQt6.QtCore import QCoreApplication, QEvent, QMetaObject, Qt, pyqtSlot, QObject
+
+from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED
+from electrum.bitcoin import address_to_script
+from electrum.transaction import Transaction
class TestQCoreApplication(QCoreApplication):
@@ -100,3 +105,37 @@ def decorator(self, *args):
finally:
self._testcase_event.set()
return decorator
+
+
+def create_qcoreapplication(testcase_cls: type[unittest.TestCase]) -> None:
+ """Creates a QCoreApplication for a test class that uses QObjects without
+ running a Qt event loop, unless there is one already.
+ Without one, deleted QObjects never free PyQt's slot proxies, see delete_qobject.
+ """
+ if QCoreApplication.instance() is None:
+ saved_locale = locale.setlocale(locale.LC_ALL)
+ app = QCoreApplication([])
+ locale.setlocale(locale.LC_ALL, saved_locale) # changed by QCoreApplication
+ testcase_cls.addClassCleanup(sip.delete, app)
+
+
+def delete_qobject(obj: QObject) -> None:
+ # delete explicitly: if the GC deletes it, its destroyed handler might run on a half-cleared object
+ sip.delete(obj)
+ # free PyQt's slot proxies of the deleted object, they hold references to it (and so e.g. to a wallet).
+ # The proxies only get scheduled for deletion by a posted event, hence the two calls.
+ QCoreApplication.sendPostedEvents()
+ QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
+
+
+def receive_incoming_tx(wallet, prevout_byte: str, outputs: list[tuple[str, int]]) -> str:
+ """Adds an unconfirmed tx with one dummy (not ismine) input to the wallet, returns its txid."""
+ outs = ''
+ for addr, value in outputs:
+ spk = address_to_script(addr)
+ outs += value.to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex()
+ tx = Transaction(
+ '02000000' + '01' + prevout_byte * 32 + '00000000' + '00' + 'fdffffff'
+ + f'{len(outputs):02x}' + outs + '00000000')
+ wallet.adb.receive_tx_callback(tx, tx_height=TX_HEIGHT_UNCONFIRMED)
+ return tx.txid()
### tests/qml/test_qml_qeaddresslistmodel.py
@@ -0,0 +1,92 @@
+import gc
+from unittest.mock import patch, MagicMock
+
+from electrum import SimpleConfig
+from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED
+from electrum.fee_policy import FixedFeePolicy
+from electrum.gui.qml.qeaddresslistmodel import QEAddressCoinListModel
+from electrum.gui.qml.qeconfig import QEConfig
+from electrum.transaction import PartialTxOutput
+
+from .. import ElectrumTestCase, restore_wallet_from_text__for_unittest
+from .qt_util import create_qcoreapplication, delete_qobject, receive_incoming_tx
+
+
+class TestQEAddressCoinListModelLabels(ElectrumTestCase):
+ TESTNET = True
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # free the QObjects (and the wallets they hold) before other tests check for lingering wallets
+ cls.addClassCleanup(gc.collect)
+ create_qcoreapplication(cls)
+
+ async def asyncSetUp(self):
+ await super().asyncSetUp()
+ config = SimpleConfig({'electrum_path': self.electrum_path})
+ self.wallet = restore_wallet_from_text__for_unittest(
+ 'bitter grass shiver impose acquire brush forget axis eager alone wine silver',
+ path=None, config=config, gap_limit=4)['wallet']
+ self.addr_a, self.addr_b, self.addr_c, self.addr_d = self.wallet.get_receiving_addresses()[:4]
+ # incoming tx X pays A (X:0) and B (X:1)
+ self.txid_x = receive_incoming_tx(self.wallet, '11', [(self.addr_a, 30_000), (self.addr_b, 20_000)])
+ # incoming tx Y pays D (Y:0), which tx S spends to C (S:0)
+ self.txid_y = receive_incoming_tx(self.wallet, '12', [(self.addr_d, 40_000)])
+ coins = [c for c in self.wallet.get_utxos() if c.prevout.to_str() == f'{self.txid_y}:0']
+ tx = self.wallet.make_unsigned_transaction(
+ coins=coins, outputs=[PartialTxOutput.from_address_and_value(self.addr_c, '!')],
+ fee_policy=FixedFeePolicy(1000))
+ self.wallet.sign_transaction(tx, password=None)
+ self.wallet.adb.receive_tx_callback(tx, tx_height=TX_HEIGHT_UNCONFIRMED)
+ self.txid_s = tx.txid()
+
+ with patch.object(QEConfig, 'instance', MagicMock()):
+ self.model = QEAddressCoinListModel(self.wallet)
+ self.addCleanup(delete_qobject, self.model)
+
+ def _label(self, *, outpoint: str = None, address: str = None) -> str:
+ for row, item in enumerate(self.model._items):
+ if (outpoint and item.get('outpoint') == outpoint) \
+ or (address and not item.get('outpoint') and item['address'] == address):
+ return self.model.data(self.model.index(row, 0), self.model._ROLE_RMAP['label'])
+ raise Exception(f'no row for {outpoint=} {address=}')
+
+ async def test_address_label_updates_coins_of_txs_paying_it(self):
+ self.wallet.set_label(self.addr_a, 'rent')
+ self.assertEqual('rent', self._label(address=self.addr_a))
+ self.assertEqual('rent', self._label(outpoint=f'{self.txid_x}:0'))
+ # the coin at B has the label of tx X too, which includes the label of A
+ self.assertEqual('rent', self._label(outpoint=f'{self.txid_x}:1'))
+
+ self.wallet.set_label(self.addr_b, 'food')
+ self.assertEqual('rent, food', self._label(outpoint=f'{self.txid_x}:0'))
+ self.assertEqual('rent, food', self._label(outpoint=f'{self.txid_x}:1'))
+
+ self.wallet.set_label(self.addr_a, None)
+ self.assertEqual('', self._label(address=self.addr_a))
+ self.assertEqual('food', self._label(outpoint=f'{self.txid_x}:0'))
+ self.assertEqual('food', self._label(outpoint=f'{self.txid_x}:1'))
+
+ async def test_outpoint_label_updates_coins_of_same_tx(self):
+ self.wallet.set_label(f'{self.txid_x}:0', 'rent')
+ self.assertEqual('rent', self._label(outpoint=f'{self.txid_x}:0'))
+ self.assertEqual('rent', self._label(outpoint=f'{self.txid_x}:1'))
+
+ async def test_spent_outpoint_label_updates_coins_of_spending_tx(self):
+ self.assertEqual('', self._label(outpoint=f'{self.txid_s}:0'))
+ self.wallet.set_label(f'{self.txid_y}:0', 'rent')
+ self.assertEqual('sweep rent', self._label(outpoint=f'{self.txid_s}:0'))
+
+ async def test_txid_label_updates_its_coins(self):
+ self.wallet.set_label(self.txid_x, 'salary')
+ self.assertEqual('salary', self._label(outpoint=f'{self.txid_x}:0'))
+ self.assertEqual('salary', self._label(outpoint=f'{self.txid_x}:1'))
+ self.assertEqual('', self._label(outpoint=f'{self.txid_s}:0'))
+
+ async def test_label_updates_rows_moved_by_deleting_an_address(self):
+ # removes the rows of A and of its coin X:0, the rows after them move up
+ self.model.deleteAddress(self.addr_a)
+ self.wallet.set_label(self.addr_b, 'food')
+ self.assertEqual('food', self._label(address=self.addr_b))
+ self.assertEqual('food', self._label(outpoint=f'{self.txid_x}:1'))
### tests/qml/test_qml_qetransactionlistmodel.py
@@ -1,11 +1,16 @@
+import gc
+import time
from datetime import datetime
from unittest.mock import patch
from PyQt6.QtCore import Qt
+from electrum import SimpleConfig
from electrum.gui.qml.qetransactionlistmodel import QETransactionListModel
+from electrum.util import LightningHistoryItem
-from .. import ElectrumTestCase
+from .. import ElectrumTestCase, restore_wallet_from_text__for_unittest
+from .qt_util import create_qcoreapplication, delete_qobject, receive_incoming_tx
class TestQETransactionListModel(ElectrumTestCase):
@@ -110,3 +115,67 @@ def test_format_date_by_section(self):
result = f('unknown_section', test_date)
self.assertEqual(result, '2023-06-15 14:30')
+
+class TestQETransactionListModelLabels(ElectrumTestCase):
+ TESTNET = True
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # free the QObjects (and the wallets they hold) before other tests check for lingering wallets
+ cls.addClassCleanup(gc.collect)
+ create_qcoreapplication(cls)
+
+ async def asyncSetUp(self):
+ await super().asyncSetUp()
+ config = SimpleConfig({'electrum_path': self.electrum_path})
+ self.wallet = restore_wallet_from_text__for_unittest(
+ 'bitter grass shiver impose acquire brush forget axis eager alone wine silver',
+ path=None, config=config, gap_limit=4)['wallet']
+ self.addr_a, self.addr_b = self.wallet.get_receiving_addresses()[:2]
+ self.txid_x = receive_incoming_tx(self.wallet, '11', [(self.addr_a, 30_000)])
+ # claim tx of a reverse swap, pays B
+ self.txid_claim = receive_incoming_tx(self.wallet, '13', [(self.addr_b, 60_000)])
+
+ # group the claim tx with the lightning payment of the swap, as SwapManager does it
+ lnworker = self.wallet.lnworker
+ payment_hash = 'ab' * 32
+ swap_groups = {self.txid_claim: {
+ 'group_id': self.txid_claim,
+ 'label': 'Claim transaction',
+ 'group_label': 'Reverse swap 0.0006 tBTC',
+ }}
+ lightning_history = {payment_hash: LightningHistoryItem(
+ type='payment', payment_hash=payment_hash, preimage=None, amount_msat=-61_000_000, fee_msat=0,
+ group_id=self.txid_claim, timestamp=int(time.time()), label='', direction=None)}
+ for patcher in (
+ patch.object(lnworker.swap_manager, 'get_groups_for_onchain_history', return_value=swap_groups),
+ patch.object(lnworker, 'get_lightning_history', return_value=lightning_history)):
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ self.model = QETransactionListModel(self.wallet)
+ self.addCleanup(delete_qobject, self.model)
+ self.assertEqual(2, len(self._row(self.txid_claim)['children']))
+ self.assertEqual('Reverse swap 0.0006 tBTC', self._row(self.txid_claim)['label'])
+
+ def _row(self, key: str) -> dict:
+ for item in self.model.tx_history:
+ if item['key'] == key:
+ return item
+ raise Exception(f'no row for {key=}')
+
+ async def test_address_label_updates_tx_label_derived_from_it(self):
+ self.wallet.set_label(self.addr_a, 'rent')
+ self.assertEqual('rent', self._row(self.txid_x)['label'])
+
+ async def test_address_label_keeps_group_label(self):
+ # the claim tx pays to B, so its label may derive from the label of B
+ self.wallet.set_label(self.addr_b, 'savings')
+ self.assertEqual('Reverse swap 0.0006 tBTC', self._row(self.txid_claim)['label'])
+
+ async def test_tx_label_keeps_group_label(self):
+ self.wallet.set_label(self.txid_claim, 'my claim')
+ self.assertEqual('Reverse swap 0.0006 tBTC', self._row(self.txid_claim)['label'])
+ self.wallet.set_label(self.txid_claim, None)
+ self.assertEqual('Reverse swap 0.0006 tBTC', self._row(self.txid_claim)['label'])
### tests/qml/test_qml_qetxdetails.py
@@ -9,6 +9,7 @@
from electrum.transaction import PartialTxOutput, Transaction
from .. import ElectrumTestCase, restore_wallet_from_text__for_unittest
+from .qt_util import create_qcoreapplication, delete_qobject
class TestTxDetails(ElectrumTestCase):
@@ -19,6 +20,7 @@ def setUpClass(cls):
super().setUpClass()
# free the QObjects (and the wallets they hold) before other tests check for lingering wallets
cls.addClassCleanup(gc.collect)
+ create_qcoreapplication(cls)
def _create_wallet_and_unsigned_tx_with_legacy_input(self, config):
wallet = restore_wallet_from_text__for_unittest(
@@ -43,8 +45,10 @@ async def test_remove_saved_tx_with_legacy_inputs(self):
wallet, tx = self._create_wallet_and_unsigned_tx_with_legacy_input(config)
qewallet = QEWallet(wallet)
+ self.addCleanup(delete_qobject, qewallet)
self.addCleanup(qewallet.unregister_callbacks)
txdetails = QETxDetails()
+ self.addCleanup(delete_qobject, txdetails)
self.addCleanup(txdetails.unregister_callbacks)
txdetails.wallet = qewallet
txdetails.rawtx = tx.serialize()Why this scored 18/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.