tMerge pull request #5152 from SomberNight/freeze_individual_utxos - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 52af40685e34ec1e76c8d0ee9ce85bcbd21d4ba9 DIR parent 7b4bb19b349a6d0b164ef6c5c675bcaecb0eef71 HTML Author: ghost43 <somber.night@protonmail.com> Date: Mon, 11 Mar 2019 19:12:56 +0100 Merge pull request #5152 from SomberNight/freeze_individual_utxos Freeze individual UTXOs Diffstat: M electrum/address_synchronizer.py | 42 ++++++++++++++++++++----------- M electrum/commands.py | 6 +++--- M electrum/gui/qt/address_list.py | 10 +++++----- M electrum/gui/qt/main_window.py | 15 +++++++++++---- M electrum/gui/qt/util.py | 1 + M electrum/gui/qt/utxo_list.py | 76 +++++++++++++++++++++++++------ M electrum/wallet.py | 57 ++++++++++++++++++++++++------- 7 files changed, 153 insertions(+), 54 deletions(-) --- DIR diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py t@@ -25,7 +25,7 @@ import threading import asyncio import itertools from collections import defaultdict -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple from . import bitcoin from .bitcoin import COINBASE_MATURITY, TYPE_ADDRESS, TYPE_PUBKEY t@@ -715,17 +715,23 @@ class AddressSynchronizer(PrintError): return sum([v for height, v, is_cb in received.values()]) @with_local_height_cached - def get_addr_balance(self, address): + def get_addr_balance(self, address, *, excluded_coins: Set[str] = None): """Return the balance of a bitcoin address: confirmed and matured, unconfirmed, unmatured """ - cached_value = self._get_addr_balance_cache.get(address) - if cached_value: - return cached_value + if not excluded_coins: # cache is only used if there are no excluded_coins + cached_value = self._get_addr_balance_cache.get(address) + if cached_value: + return cached_value + if excluded_coins is None: + excluded_coins = set() + assert isinstance(excluded_coins, set), f"excluded_coins should be set, not {type(excluded_coins)}" received, sent = self.get_addr_io(address) c = u = x = 0 local_height = self.get_local_height() for txo, (tx_height, v, is_cb) in received.items(): + if txo in excluded_coins: + continue if is_cb and tx_height + COINBASE_MATURITY > local_height: x += v elif tx_height > 0: t@@ -739,19 +745,21 @@ class AddressSynchronizer(PrintError): u -= v result = c, u, x # cache result. - # Cache needs to be invalidated if a transaction is added to/ - # removed from history; or on new blocks (maturity...) - self._get_addr_balance_cache[address] = result + if not excluded_coins: + # Cache needs to be invalidated if a transaction is added to/ + # removed from history; or on new blocks (maturity...) + self._get_addr_balance_cache[address] = result return result @with_local_height_cached - def get_utxos(self, domain=None, excluded=None, mature=False, confirmed_only=False, nonlocal_only=False): + def get_utxos(self, domain=None, *, excluded_addresses=None, + mature_only: bool = False, confirmed_only: bool = False, nonlocal_only: bool = False): coins = [] if domain is None: domain = self.get_addresses() domain = set(domain) - if excluded: - domain = set(domain) - excluded + if excluded_addresses: + domain = set(domain) - set(excluded_addresses) for addr in domain: utxos = self.get_addr_utxo(addr) for x in utxos.values(): t@@ -759,19 +767,23 @@ class AddressSynchronizer(PrintError): continue if nonlocal_only and x['height'] == TX_HEIGHT_LOCAL: continue - if mature and x['coinbase'] and x['height'] + COINBASE_MATURITY > self.get_local_height(): + if mature_only and x['coinbase'] and x['height'] + COINBASE_MATURITY > self.get_local_height(): continue coins.append(x) continue return coins - def get_balance(self, domain=None): + def get_balance(self, domain=None, *, excluded_addresses: Set[str] = None, + excluded_coins: Set[str] = None) -> Tuple[int, int, int]: if domain is None: domain = self.get_addresses() - domain = set(domain) + if excluded_addresses is None: + excluded_addresses = set() + assert isinstance(excluded_addresses, set), f"excluded_addresses should be set, not {type(excluded_addresses)}" + domain = set(domain) - excluded_addresses cc = uu = xx = 0 for addr in domain: - c, u, x = self.get_addr_balance(addr) + c, u, x = self.get_addr_balance(addr, excluded_coins=excluded_coins) cc += c uu += u xx += x DIR diff --git a/electrum/commands.py b/electrum/commands.py t@@ -309,12 +309,12 @@ class Commands: @command('w') def freeze(self, address): """Freeze address. Freeze the funds at one of your wallet\'s addresses""" - return self.wallet.set_frozen_state([address], True) + return self.wallet.set_frozen_state_of_addresses([address], True) @command('w') def unfreeze(self, address): """Unfreeze address. Unfreeze the funds at one of your wallet\'s address""" - return self.wallet.set_frozen_state([address], False) + return self.wallet.set_frozen_state_of_addresses([address], False) @command('wp') def getprivatekeys(self, address, password=None): t@@ -547,7 +547,7 @@ class Commands: """List wallet addresses. Returns the list of all addresses in your wallet. Use optional arguments to filter the results.""" out = [] for addr in self.wallet.get_addresses(): - if frozen and not self.wallet.is_frozen(addr): + if frozen and not self.wallet.is_frozen_address(addr): continue if receiving and self.wallet.is_change(addr): continue DIR diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py t@@ -157,7 +157,7 @@ class AddressList(MyTreeView): address_item[self.Columns.TYPE].setBackground(ColorScheme.GREEN.as_color(True)) address_item[self.Columns.LABEL].setData(address, Qt.UserRole) # setup column 1 - if self.wallet.is_frozen(address): + if self.wallet.is_frozen_address(address): address_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) if self.wallet.is_beyond_limit(address): address_item[self.Columns.ADDRESS].setBackground(ColorScheme.RED.as_color(True)) t@@ -213,12 +213,12 @@ class AddressList(MyTreeView): if addr_URL: menu.addAction(_("View on block explorer"), lambda: webbrowser.open(addr_URL)) - if not self.wallet.is_frozen(addr): - menu.addAction(_("Freeze"), lambda: self.parent.set_frozen_state([addr], True)) + if not self.wallet.is_frozen_address(addr): + menu.addAction(_("Freeze"), lambda: self.parent.set_frozen_state_of_addresses([addr], True)) else: - menu.addAction(_("Unfreeze"), lambda: self.parent.set_frozen_state([addr], False)) + menu.addAction(_("Unfreeze"), lambda: self.parent.set_frozen_state_of_addresses([addr], False)) - coins = self.wallet.get_utxos(addrs) + coins = self.wallet.get_spendable_coins(addrs, config=self.config) if coins: menu.addAction(_("Spend from"), lambda: self.parent.spend_coins(coins)) DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py t@@ -1314,10 +1314,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if self.not_enough_funds: amt_color, fee_color = ColorScheme.RED, ColorScheme.RED feerate_color = ColorScheme.RED - text = _( "Not enough funds" ) + text = _("Not enough funds") c, u, x = self.wallet.get_frozen_balance() if c+u+x: - text += ' (' + self.format_amount(c+u+x).strip() + ' ' + self.base_unit() + ' ' +_("are frozen") + ')' + text += " ({} {} {})".format( + self.format_amount(c + u + x).strip(), self.base_unit(), _("are frozen") + ) # blue color denotes auto-filled values elif self.fee_e.isModified(): t@@ -1850,12 +1852,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.update_status() run_hook('do_clear', self) - def set_frozen_state(self, addrs, freeze): - self.wallet.set_frozen_state(addrs, freeze) + def set_frozen_state_of_addresses(self, addrs, freeze: bool): + self.wallet.set_frozen_state_of_addresses(addrs, freeze) self.address_list.update() self.utxo_list.update() self.update_fee() + def set_frozen_state_of_coins(self, utxos, freeze: bool): + self.wallet.set_frozen_state_of_coins(utxos, freeze) + self.utxo_list.update() + self.update_fee() + def create_list_tab(self, l, toolbar=None): w = QWidget() w.searchable_list = l DIR diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py t@@ -715,6 +715,7 @@ class ColorScheme: YELLOW = ColorSchemeItem("#897b2a", "#ffff00") RED = ColorSchemeItem("#7c1111", "#f18c8c") BLUE = ColorSchemeItem("#123b7c", "#8cb3f2") + PURPLE = ColorSchemeItem("#8A2BE2", "#8A2BE2") DEFAULT = ColorSchemeItem("black", "white") @staticmethod DIR diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py t@@ -37,11 +37,11 @@ from .util import MyTreeView, ColorScheme, MONOSPACE_FONT class UTXOList(MyTreeView): class Columns(IntEnum): - ADDRESS = 0 - LABEL = 1 - AMOUNT = 2 - HEIGHT = 3 - OUTPOINT = 4 + OUTPOINT = 0 + ADDRESS = 1 + LABEL = 2 + AMOUNT = 3 + HEIGHT = 4 headers = { Columns.ADDRESS: _('Address'), t@@ -71,26 +71,31 @@ class UTXOList(MyTreeView): self.insert_utxo(idx, x) def insert_utxo(self, idx, x): - address = x.get('address') + address = x['address'] height = x.get('height') name = x.get('prevout_hash') + ":%d"%x.get('prevout_n') - name_short = x.get('prevout_hash')[:10] + '...' + ":%d"%x.get('prevout_n') + name_short = x.get('prevout_hash')[:16] + '...' + ":%d"%x.get('prevout_n') self.utxo_dict[name] = x label = self.wallet.get_label(x.get('prevout_hash')) amount = self.parent.format_amount(x['value'], whitespaces=True) - labels = [address, label, amount, '%d'%height, name_short] + labels = [name_short, address, label, amount, '%d'%height] utxo_item = [QStandardItem(x) for x in labels] self.set_editability(utxo_item) utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.ADDRESS].setData(name, Qt.UserRole) - utxo_item[self.Columns.OUTPOINT].setToolTip(name) - if self.wallet.is_frozen(address): + if self.wallet.is_frozen_address(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(x): + utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True)) + utxo_item[self.Columns.OUTPOINT].setToolTip(f"{name}\n{_('Coin is frozen')}") + else: + utxo_item[self.Columns.OUTPOINT].setToolTip(name) self.model().insertRow(idx, utxo_item) - def selected_column_0_user_roles(self) -> Optional[List[str]]: + def get_selected_outpoints(self) -> Optional[List[str]]: if not self.model(): return None items = self.selected_in_column(self.Columns.ADDRESS) t@@ -99,17 +104,58 @@ class UTXOList(MyTreeView): return [x.data(Qt.UserRole) for x in items] def create_menu(self, position): - selected = self.selected_column_0_user_roles() + selected = self.get_selected_outpoints() if not selected: return menu = QMenu() - coins = (self.utxo_dict[name] for name in selected) + menu.setSeparatorsCollapsible(True) # consecutive separators are merged together + coins = [self.utxo_dict[name] for name in selected] menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins)) - if len(selected) == 1: - txid = selected[0].split(':')[0] + assert len(coins) >= 1, len(coins) + if len(coins) == 1: + utxo_dict = coins[0] + addr = utxo_dict['address'] + txid = utxo_dict['prevout_hash'] + # "Details" tx = self.wallet.db.get_transaction(txid) if tx: label = self.wallet.get_label(txid) or None # Prefer None if empty (None hides the Description: field in the window) menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, label)) + # "Copy ..." + idx = self.indexAt(position) + col = idx.column() + column_title = self.model().horizontalHeaderItem(col).text() + copy_text = self.model().itemFromIndex(idx).text() if col != self.Columns.OUTPOINT else selected[0] + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(copy_text)) + # "Freeze coin" + if not self.wallet.is_frozen_coin(utxo_dict): + menu.addAction(_("Freeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo_dict], True)) + else: + menu.addSeparator() + menu.addAction(_("Coin is frozen"), lambda: None).setEnabled(False) + menu.addAction(_("Unfreeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo_dict], False)) + menu.addSeparator() + # "Freeze address" + if not self.wallet.is_frozen_address(addr): + menu.addAction(_("Freeze Address"), lambda: self.parent.set_frozen_state_of_addresses([addr], True)) + else: + menu.addSeparator() + menu.addAction(_("Address is frozen"), lambda: None).setEnabled(False) + menu.addAction(_("Unfreeze Address"), lambda: self.parent.set_frozen_state_of_addresses([addr], False)) + menu.addSeparator() + else: + # multiple items selected + menu.addSeparator() + addrs = [utxo_dict['address'] for utxo_dict in coins] + is_coin_frozen = [self.wallet.is_frozen_coin(utxo_dict) for utxo_dict in coins] + is_addr_frozen = [self.wallet.is_frozen_address(utxo_dict['address']) for utxo_dict in coins] + if not all(is_coin_frozen): + menu.addAction(_("Freeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, True)) + if any(is_coin_frozen): + menu.addAction(_("Unfreeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, False)) + if not all(is_addr_frozen): + menu.addAction(_("Freeze Addresses"), lambda: self.parent.set_frozen_state_of_addresses(addrs, True)) + if any(is_addr_frozen): + menu.addAction(_("Unfreeze Addresses"), lambda: self.parent.set_frozen_state_of_addresses(addrs, False)) menu.exec_(self.viewport().mapToGlobal(position)) DIR diff --git a/electrum/wallet.py b/electrum/wallet.py t@@ -38,7 +38,7 @@ import traceback from functools import partial from numbers import Number from decimal import Decimal -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple, Union from .i18n import _ from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler, t@@ -204,7 +204,8 @@ class Abstract_Wallet(AddressSynchronizer): self.use_change = storage.get('use_change', True) self.multiple_change = storage.get('multiple_change', False) self.labels = storage.get('labels', {}) - self.frozen_addresses = set(storage.get('frozen_addresses',[])) + self.frozen_addresses = set(storage.get('frozen_addresses', [])) + self.frozen_coins = set(storage.get('frozen_coins', [])) # set of txid:vout strings self.fiat_value = storage.get('fiat_value', {}) self.receive_requests = storage.get('payment_requests', {}) t@@ -395,17 +396,24 @@ class Abstract_Wallet(AddressSynchronizer): def get_spendable_coins(self, domain, config, *, nonlocal_only=False): confirmed_only = config.get('confirmed_only', False) - return self.get_utxos(domain, - excluded=self.frozen_addresses, - mature=True, - confirmed_only=confirmed_only, - nonlocal_only=nonlocal_only) + utxos = self.get_utxos(domain, + excluded_addresses=self.frozen_addresses, + mature_only=True, + confirmed_only=confirmed_only, + nonlocal_only=nonlocal_only) + utxos = [utxo for utxo in utxos if not self.is_frozen_coin(utxo)] + return utxos def dummy_address(self): return self.get_receiving_addresses()[0] def get_frozen_balance(self): - return self.get_balance(self.frozen_addresses) + if not self.frozen_coins: # shortcut + return self.get_balance(self.frozen_addresses) + c1, u1, x1 = self.get_balance() + c2, u2, x2 = self.get_balance(excluded_addresses=self.frozen_addresses, + excluded_coins=self.frozen_coins) + return c1-c2, u1-u2, x1-x2 def balance_at_timestamp(self, domain, target_timestamp): h = self.get_history(domain) t@@ -737,12 +745,18 @@ class Abstract_Wallet(AddressSynchronizer): self.sign_transaction(tx, password) return tx - def is_frozen(self, addr): + def is_frozen_address(self, addr: str) -> bool: return addr in self.frozen_addresses - def set_frozen_state(self, addrs, freeze): - '''Set frozen state of the addresses to FREEZE, True or False''' + def is_frozen_coin(self, utxo) -> bool: + # utxo is either a txid:vout str, or a dict + utxo = self._utxo_str_from_utxo(utxo) + return utxo in self.frozen_coins + + def set_frozen_state_of_addresses(self, addrs, freeze: bool): + """Set frozen state of the addresses to FREEZE, True or False""" if all(self.is_mine(addr) for addr in addrs): + # FIXME take lock? if freeze: self.frozen_addresses |= set(addrs) else: t@@ -751,6 +765,25 @@ class Abstract_Wallet(AddressSynchronizer): return True return False + def set_frozen_state_of_coins(self, utxos, freeze: bool): + """Set frozen state of the utxos to FREEZE, True or False""" + utxos = {self._utxo_str_from_utxo(utxo) for utxo in utxos} + # FIXME take lock? + if freeze: + self.frozen_coins |= set(utxos) + else: + self.frozen_coins -= set(utxos) + self.storage.put('frozen_coins', list(self.frozen_coins)) + + @staticmethod + def _utxo_str_from_utxo(utxo: Union[dict, str]) -> str: + """Return a txid:vout str""" + if isinstance(utxo, dict): + return "{}:{}".format(utxo['prevout_hash'], utxo['prevout_n']) + assert isinstance(utxo, str), f"utxo should be a str, not {type(utxo)}" + # just assume it is already of the correct format + return utxo + def wait_until_synchronized(self, callback=None): def wait_for_wallet(): self.set_up_to_date(False) t@@ -1401,7 +1434,7 @@ class Imported_Wallet(Simple_Wallet): self.db.remove_transaction(tx_hash) self.set_label(address, None) self.remove_payment_request(address, {}) - self.set_frozen_state([address], False) + self.set_frozen_state_of_addresses([address], False) pubkey = self.get_public_key(address) self.db.remove_imported_address(address) if pubkey: