From e4273e5ab981875231f6971e9702929cd3ca9956 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Fri, 6 Jan 2023 17:24:30 +0100 Subject: [PATCH] utxo privacy analysis: - add a new event, 'adb_removed_tx' - new wallet method: get_tx_parents - number of parents is shown in coins tab - detailed list of parents is shown in dialog --- electrum/address_synchronizer.py | 3 +- electrum/gui/qt/main_window.py | 5 ++ electrum/gui/qt/utxo_dialog.py | 113 +++++++++++++++++++++++++++++++ electrum/gui/qt/utxo_list.py | 17 +++-- electrum/wallet.py | 57 ++++++++++++++-- 5 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 electrum/gui/qt/utxo_dialog.py diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py index ccc9606dd..76a0255e2 100644 --- a/electrum/address_synchronizer.py +++ b/electrum/address_synchronizer.py @@ -349,7 +349,7 @@ class AddressSynchronizer(Logger, EventListener): self.db.add_transaction(tx_hash, tx) self.db.add_num_inputs_to_tx(tx_hash, len(tx.inputs())) if is_new: - util.trigger_callback('adb_added_tx', self, tx_hash) + util.trigger_callback('adb_added_tx', self, tx_hash, tx) return True def remove_transaction(self, tx_hash: str) -> None: @@ -401,6 +401,7 @@ class AddressSynchronizer(Logger, EventListener): scripthash = bitcoin.script_to_scripthash(txo.scriptpubkey.hex()) prevout = TxOutpoint(bfh(tx_hash), idx) self.db.remove_prevout_by_scripthash(scripthash, prevout=prevout, value=txo.value) + util.trigger_callback('adb_removed_tx', self, tx_hash, tx) def get_depending_transactions(self, tx_hash: str) -> Set[str]: """Returns all (grand-)children of tx_hash in this wallet.""" diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index dedb4f5c0..18590f936 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1065,6 +1065,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): d = address_dialog.AddressDialog(self, addr, parent=parent) d.exec_() + def show_utxo(self, utxo): + from . import utxo_dialog + d = utxo_dialog.UTXODialog(self, utxo) + d.exec_() + def show_channel_details(self, chan): from .channel_details import ChannelDetailsDialog ChannelDetailsDialog(self, chan).show() diff --git a/electrum/gui/qt/utxo_dialog.py b/electrum/gui/qt/utxo_dialog.py new file mode 100644 index 000000000..495197459 --- /dev/null +++ b/electrum/gui/qt/utxo_dialog.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2023 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtGui import QTextCharFormat, QFont +from PyQt5.QtWidgets import QVBoxLayout, QLabel, QTextBrowser + +from electrum.i18n import _ + +from .util import WindowModalDialog, ButtonsLineEdit, ShowQRLineEdit, ColorScheme, Buttons, CloseButton, MONOSPACE_FONT, WWLabel +from .history_list import HistoryList, HistoryModel +from .qrtextedit import ShowQRTextEdit + +if TYPE_CHECKING: + from .main_window import ElectrumWindow + +# todo: +# - edit label in tx detail window + + +class UTXODialog(WindowModalDialog): + + def __init__(self, window: 'ElectrumWindow', utxo): + WindowModalDialog.__init__(self, window, _("Coin Privacy Analysis")) + self.main_window = window + self.config = window.config + self.wallet = window.wallet + self.utxo = utxo + + txid = self.utxo.prevout.txid.hex() + parents = self.wallet.get_tx_parents(txid) + out = [] + for _txid, _list in sorted(parents.items()): + tx_height, tx_pos = self.wallet.adb.get_txpos(_txid) + label = self.wallet.get_label_for_txid(_txid) or "" + out.append((tx_height, tx_pos, _txid, label, _list)) + + self.parents_list = QTextBrowser() + self.parents_list.setOpenLinks(False) # disable automatic link opening + self.parents_list.anchorClicked.connect(self.open_tx) # send links to our handler + self.parents_list.setFont(QFont(MONOSPACE_FONT)) + self.parents_list.setReadOnly(True) + self.parents_list.setTextInteractionFlags(self.parents_list.textInteractionFlags() | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard) + self.parents_list.setMinimumWidth(900) + self.parents_list.setMinimumHeight(400) + self.parents_list.setLineWrapMode(QTextBrowser.NoWrap) + + cursor = self.parents_list.textCursor() + ext = QTextCharFormat() + + for tx_height, tx_pos, _txid, label, _list in reversed(sorted(out)): + key = "%dx%d"%(tx_height, tx_pos) if tx_pos >= 0 else _txid[0:8] + list_str = ','.join(filter(None, _list)) + lnk = QTextCharFormat() + lnk.setToolTip(_('Click to open, right-click for menu')) + lnk.setAnchorHref(_txid) + #lnk.setAnchorNames([a_name]) + lnk.setAnchor(True) + lnk.setUnderlineStyle(QTextCharFormat.SingleUnderline) + cursor.insertText(key, lnk) + cursor.insertText("\t", ext) + cursor.insertText("%-32s\t<- "%label[0:32], ext) + cursor.insertText(list_str, ext) + cursor.insertBlock() + + vbox = QVBoxLayout() + vbox.addWidget(QLabel(_("Output point") + ": " + str(self.utxo.short_id))) + vbox.addWidget(QLabel(_("Amount") + ": " + self.main_window.format_amount_and_units(self.utxo.value_sats()))) + vbox.addWidget(QLabel(_("This UTXO has {} parent transactions in your wallet").format(len(parents)))) + vbox.addWidget(self.parents_list) + msg = ' '.join([ + _("Note: This analysis only shows parent transactions, and does not take address reuse into consideration."), + _("If you reuse addresses, more links can be established between your transactions, that are not displayed here.") + ]) + vbox.addWidget(WWLabel(msg)) + vbox.addLayout(Buttons(CloseButton(self))) + self.setLayout(vbox) + # set cursor to top + cursor.setPosition(0) + self.parents_list.setTextCursor(cursor) + + def open_tx(self, txid): + if isinstance(txid, QUrl): + txid = txid.toString(QUrl.None_) + tx = self.wallet.adb.get_transaction(txid) + if not tx: + return + label = self.wallet.get_label_for_txid(txid) + self.main_window.show_transaction(tx, tx_desc=label) diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py index ba6415e17..f605f6077 100644 --- a/electrum/gui/qt/utxo_list.py +++ b/electrum/gui/qt/utxo_list.py @@ -49,10 +49,12 @@ class UTXOList(MyTreeView): ADDRESS = 1 LABEL = 2 AMOUNT = 3 + PARENTS = 4 headers = { Columns.OUTPOINT: _('Output point'), Columns.ADDRESS: _('Address'), + Columns.PARENTS: _('Parents'), Columns.LABEL: _('Label'), Columns.AMOUNT: _('Amount'), } @@ -87,14 +89,15 @@ class UTXOList(MyTreeView): name = utxo.prevout.to_str() self._utxo_dict[name] = utxo address = utxo.address - amount = self.parent.format_amount(utxo.value_sats(), whitespaces=True) - labels = [str(utxo.short_id), address, '', amount] + amount_str = self.parent.format_amount(utxo.value_sats(), whitespaces=True) + labels = [str(utxo.short_id), address, '', amount_str, ''] utxo_item = [QStandardItem(x) for x in labels] self.set_editability(utxo_item) utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_CLIPBOARD_DATA) utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_PREVOUT_STR) utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT)) + utxo_item[self.Columns.PARENTS].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT)) self.model().insertRow(idx, utxo_item) self.refresh_row(name, idx) @@ -117,8 +120,10 @@ class UTXOList(MyTreeView): assert row is not None utxo = self._utxo_dict[key] utxo_item = [self.std_model.item(row, col) for col in self.Columns] - address = utxo.address - label = self.wallet.get_label_for_txid(utxo.prevout.txid.hex()) or self.wallet.get_label_for_address(address) + txid = utxo.prevout.txid.hex() + parents = self.wallet.get_tx_parents(txid) + utxo_item[self.Columns.PARENTS].setText('%6s'%len(parents)) + label = self.wallet.get_label_for_txid(txid) or '' utxo_item[self.Columns.LABEL].setText(label) SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent') if key in self._spend_set: @@ -130,7 +135,7 @@ class UTXOList(MyTreeView): for col in utxo_item: col.setBackground(color) col.setToolTip(tooltip) - if self.wallet.is_frozen_address(address): + if self.wallet.is_frozen_address(utxo.address): utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen')) if self.wallet.is_frozen_coin(utxo): @@ -257,7 +262,7 @@ class UTXOList(MyTreeView): tx = self.wallet.adb.get_transaction(txid) if tx: label = self.wallet.get_label_for_txid(txid) - menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, tx_desc=label)) + menu.addAction(_("View parents"), lambda: self.parent.show_utxo(utxo)) # fully spend menu_spend = menu.addMenu(_("Fully spend") + '…') m = menu_spend.addAction(_("send to address in clipboard"), lambda: self.pay_to_clipboard_address(coins)) diff --git a/electrum/wallet.py b/electrum/wallet.py index dade7ef8d..c2812d2b8 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -317,6 +317,8 @@ class Abstract_Wallet(ABC, Logger, EventListener): self.adb.add_address(addr) self.lock = self.adb.lock self.transaction_lock = self.adb.transaction_lock + self._last_full_history = None + self._tx_parents_cache = {} self.taskgroup = OldTaskGroup() @@ -453,6 +455,16 @@ class Abstract_Wallet(ABC, Logger, EventListener): def is_up_to_date(self) -> bool: return self._up_to_date + def tx_is_related(self, tx): + is_mine = any([self.is_mine(out.address) for out in tx.outputs()]) + is_mine |= any([self.is_mine(self.adb.get_txin_address(txin)) for txin in tx.inputs()]) + return is_mine + + def clear_tx_parents_cache(self): + with self.lock, self.transaction_lock: + self._tx_parents_cache.clear() + self._last_full_history = None + @event_listener async def on_event_adb_set_up_to_date(self, adb): if self.adb != adb: @@ -473,21 +485,25 @@ class Abstract_Wallet(ABC, Logger, EventListener): self.logger.info(f'set_up_to_date: {up_to_date}') @event_listener - def on_event_adb_added_tx(self, adb, tx_hash): + def on_event_adb_added_tx(self, adb, tx_hash: str, tx: Transaction): if self.adb != adb: return - tx = self.db.get_transaction(tx_hash) - if not tx: - raise Exception(tx_hash) - is_mine = any([self.is_mine(out.address) for out in tx.outputs()]) - is_mine |= any([self.is_mine(self.adb.get_txin_address(txin)) for txin in tx.inputs()]) - if not is_mine: + if not self.tx_is_related(tx): return + self.clear_tx_parents_cache() if self.lnworker: self.lnworker.maybe_add_backup_from_tx(tx) self._update_invoices_and_reqs_touched_by_tx(tx_hash) util.trigger_callback('new_transaction', self, tx) + @event_listener + def on_event_adb_removed_tx(self, adb, txid: str, tx: Transaction): + if self.adb != adb: + return + if not self.tx_is_related(tx): + return + self.clear_tx_parents_cache() + @event_listener def on_event_adb_added_verified_tx(self, adb, tx_hash): if adb != self.adb: @@ -845,6 +861,33 @@ class Abstract_Wallet(ABC, Logger, EventListener): is_lightning_funding_tx=is_lightning_funding_tx, ) + def get_tx_parents(self, txid) -> Dict: + """ + recursively calls itself and returns a flat dict: + txid -> input_index -> prevout + note: this does not take into account address reuse + """ + if not self.is_up_to_date(): + return {} + if self._last_full_history is None: + self._last_full_history = self.get_full_history(None) + + with self.lock, self.transaction_lock: + result = self._tx_parents_cache.get(txid, None) + if result is not None: + return result + result = {} + parents = [] + tx = self.adb.get_transaction(txid) + for i, txin in enumerate(tx.inputs()): + parents.append(str(txin.short_id)) + _txid = txin.prevout.txid.hex() + if _txid in self._last_full_history.keys(): + result.update(self.get_tx_parents(_txid)) + result[txid] = parents + self._tx_parents_cache[txid] = result + return result + def get_balance(self, **kwargs): domain = self.get_addresses() return self.adb.get_balance(domain, **kwargs)