commit 52af40685e34ec1e76c8d0ee9ce85bcbd21d4ba9
parent 7b4bb19b349a6d0b164ef6c5c675bcaecb0eef71
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:
7 files changed, 153 insertions(+), 54 deletions(-)
diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py
@@ -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
@@ -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:
@@ -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():
@@ -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
diff --git a/electrum/commands.py b/electrum/commands.py
@@ -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):
@@ -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
diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py
@@ -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))
@@ -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))
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
@@ -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():
@@ -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
diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py
@@ -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
diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py
@@ -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'),
@@ -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)
@@ -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))
diff --git a/electrum/wallet.py b/electrum/wallet.py
@@ -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,
@@ -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', {})
@@ -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)
@@ -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:
@@ -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)
@@ -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: