Merge pull request #8204 from spesmilo/privacy_analysis
Privacy analysis
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
113
electrum/gui/qt/utxo_dialog.py
Normal file
113
electrum/gui/qt/utxo_dialog.py
Normal file
@@ -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 "<no label>"
|
||||
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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user