URI: 
       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: