Browse Source

Merge pull request #5152 from SomberNight/freeze_individual_utxos

Freeze individual UTXOs
regtest_lnd
ghost43 6 years ago
committed by GitHub
parent
commit
52af40685e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 42
      electrum/address_synchronizer.py
  2. 6
      electrum/commands.py
  3. 10
      electrum/gui/qt/address_list.py
  4. 15
      electrum/gui/qt/main_window.py
  5. 1
      electrum/gui/qt/util.py
  6. 76
      electrum/gui/qt/utxo_list.py
  7. 57
      electrum/wallet.py

42
electrum/address_synchronizer.py

@ -25,7 +25,7 @@ import threading
import asyncio import asyncio
import itertools import itertools
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Optional from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
from . import bitcoin from . import bitcoin
from .bitcoin import COINBASE_MATURITY, TYPE_ADDRESS, TYPE_PUBKEY 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()]) return sum([v for height, v, is_cb in received.values()])
@with_local_height_cached @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: """Return the balance of a bitcoin address:
confirmed and matured, unconfirmed, unmatured confirmed and matured, unconfirmed, unmatured
""" """
cached_value = self._get_addr_balance_cache.get(address) if not excluded_coins: # cache is only used if there are no excluded_coins
if cached_value: cached_value = self._get_addr_balance_cache.get(address)
return cached_value 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) received, sent = self.get_addr_io(address)
c = u = x = 0 c = u = x = 0
local_height = self.get_local_height() local_height = self.get_local_height()
for txo, (tx_height, v, is_cb) in received.items(): 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: if is_cb and tx_height + COINBASE_MATURITY > local_height:
x += v x += v
elif tx_height > 0: elif tx_height > 0:
@ -739,19 +745,21 @@ class AddressSynchronizer(PrintError):
u -= v u -= v
result = c, u, x result = c, u, x
# cache result. # cache result.
# Cache needs to be invalidated if a transaction is added to/ if not excluded_coins:
# removed from history; or on new blocks (maturity...) # Cache needs to be invalidated if a transaction is added to/
self._get_addr_balance_cache[address] = result # removed from history; or on new blocks (maturity...)
self._get_addr_balance_cache[address] = result
return result return result
@with_local_height_cached @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 = [] coins = []
if domain is None: if domain is None:
domain = self.get_addresses() domain = self.get_addresses()
domain = set(domain) domain = set(domain)
if excluded: if excluded_addresses:
domain = set(domain) - excluded domain = set(domain) - set(excluded_addresses)
for addr in domain: for addr in domain:
utxos = self.get_addr_utxo(addr) utxos = self.get_addr_utxo(addr)
for x in utxos.values(): for x in utxos.values():
@ -759,19 +767,23 @@ class AddressSynchronizer(PrintError):
continue continue
if nonlocal_only and x['height'] == TX_HEIGHT_LOCAL: if nonlocal_only and x['height'] == TX_HEIGHT_LOCAL:
continue 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 continue
coins.append(x) coins.append(x)
continue continue
return coins 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: if domain is None:
domain = self.get_addresses() 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 cc = uu = xx = 0
for addr in domain: 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 cc += c
uu += u uu += u
xx += x xx += x

6
electrum/commands.py

@ -309,12 +309,12 @@ class Commands:
@command('w') @command('w')
def freeze(self, address): def freeze(self, address):
"""Freeze address. Freeze the funds at one of your wallet\'s addresses""" """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') @command('w')
def unfreeze(self, address): def unfreeze(self, address):
"""Unfreeze address. Unfreeze the funds at one of your wallet\'s 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') @command('wp')
def getprivatekeys(self, address, password=None): 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.""" """List wallet addresses. Returns the list of all addresses in your wallet. Use optional arguments to filter the results."""
out = [] out = []
for addr in self.wallet.get_addresses(): 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 continue
if receiving and self.wallet.is_change(addr): if receiving and self.wallet.is_change(addr):
continue continue

10
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.TYPE].setBackground(ColorScheme.GREEN.as_color(True))
address_item[self.Columns.LABEL].setData(address, Qt.UserRole) address_item[self.Columns.LABEL].setData(address, Qt.UserRole)
# setup column 1 # 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)) address_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True))
if self.wallet.is_beyond_limit(address): if self.wallet.is_beyond_limit(address):
address_item[self.Columns.ADDRESS].setBackground(ColorScheme.RED.as_color(True)) address_item[self.Columns.ADDRESS].setBackground(ColorScheme.RED.as_color(True))
@ -213,12 +213,12 @@ class AddressList(MyTreeView):
if addr_URL: if addr_URL:
menu.addAction(_("View on block explorer"), lambda: webbrowser.open(addr_URL)) menu.addAction(_("View on block explorer"), lambda: webbrowser.open(addr_URL))
if not self.wallet.is_frozen(addr): if not self.wallet.is_frozen_address(addr):
menu.addAction(_("Freeze"), lambda: self.parent.set_frozen_state([addr], True)) menu.addAction(_("Freeze"), lambda: self.parent.set_frozen_state_of_addresses([addr], True))
else: 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: if coins:
menu.addAction(_("Spend from"), lambda: self.parent.spend_coins(coins)) menu.addAction(_("Spend from"), lambda: self.parent.spend_coins(coins))

15
electrum/gui/qt/main_window.py

@ -1314,10 +1314,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
if self.not_enough_funds: if self.not_enough_funds:
amt_color, fee_color = ColorScheme.RED, ColorScheme.RED amt_color, fee_color = ColorScheme.RED, ColorScheme.RED
feerate_color = ColorScheme.RED feerate_color = ColorScheme.RED
text = _( "Not enough funds" ) text = _("Not enough funds")
c, u, x = self.wallet.get_frozen_balance() c, u, x = self.wallet.get_frozen_balance()
if c+u+x: 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 # blue color denotes auto-filled values
elif self.fee_e.isModified(): elif self.fee_e.isModified():
@ -1850,12 +1852,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
self.update_status() self.update_status()
run_hook('do_clear', self) run_hook('do_clear', self)
def set_frozen_state(self, addrs, freeze): def set_frozen_state_of_addresses(self, addrs, freeze: bool):
self.wallet.set_frozen_state(addrs, freeze) self.wallet.set_frozen_state_of_addresses(addrs, freeze)
self.address_list.update() self.address_list.update()
self.utxo_list.update() self.utxo_list.update()
self.update_fee() 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): def create_list_tab(self, l, toolbar=None):
w = QWidget() w = QWidget()
w.searchable_list = l w.searchable_list = l

1
electrum/gui/qt/util.py

@ -715,6 +715,7 @@ class ColorScheme:
YELLOW = ColorSchemeItem("#897b2a", "#ffff00") YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
RED = ColorSchemeItem("#7c1111", "#f18c8c") RED = ColorSchemeItem("#7c1111", "#f18c8c")
BLUE = ColorSchemeItem("#123b7c", "#8cb3f2") BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
PURPLE = ColorSchemeItem("#8A2BE2", "#8A2BE2")
DEFAULT = ColorSchemeItem("black", "white") DEFAULT = ColorSchemeItem("black", "white")
@staticmethod @staticmethod

76
electrum/gui/qt/utxo_list.py

@ -37,11 +37,11 @@ from .util import MyTreeView, ColorScheme, MONOSPACE_FONT
class UTXOList(MyTreeView): class UTXOList(MyTreeView):
class Columns(IntEnum): class Columns(IntEnum):
ADDRESS = 0 OUTPOINT = 0
LABEL = 1 ADDRESS = 1
AMOUNT = 2 LABEL = 2
HEIGHT = 3 AMOUNT = 3
OUTPOINT = 4 HEIGHT = 4
headers = { headers = {
Columns.ADDRESS: _('Address'), Columns.ADDRESS: _('Address'),
@ -71,26 +71,31 @@ class UTXOList(MyTreeView):
self.insert_utxo(idx, x) self.insert_utxo(idx, x)
def insert_utxo(self, idx, x): def insert_utxo(self, idx, x):
address = x.get('address') address = x['address']
height = x.get('height') height = x.get('height')
name = x.get('prevout_hash') + ":%d"%x.get('prevout_n') 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 self.utxo_dict[name] = x
label = self.wallet.get_label(x.get('prevout_hash')) label = self.wallet.get_label(x.get('prevout_hash'))
amount = self.parent.format_amount(x['value'], whitespaces=True) 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] utxo_item = [QStandardItem(x) for x in labels]
self.set_editability(utxo_item) self.set_editability(utxo_item)
utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT))
utxo_item[self.Columns.AMOUNT].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.OUTPOINT].setFont(QFont(MONOSPACE_FONT))
utxo_item[self.Columns.ADDRESS].setData(name, Qt.UserRole) utxo_item[self.Columns.ADDRESS].setData(name, Qt.UserRole)
utxo_item[self.Columns.OUTPOINT].setToolTip(name) if self.wallet.is_frozen_address(address):
if self.wallet.is_frozen(address):
utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) 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) 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(): if not self.model():
return None return None
items = self.selected_in_column(self.Columns.ADDRESS) items = self.selected_in_column(self.Columns.ADDRESS)
@ -99,17 +104,58 @@ class UTXOList(MyTreeView):
return [x.data(Qt.UserRole) for x in items] return [x.data(Qt.UserRole) for x in items]
def create_menu(self, position): def create_menu(self, position):
selected = self.selected_column_0_user_roles() selected = self.get_selected_outpoints()
if not selected: if not selected:
return return
menu = QMenu() 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)) menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins))
if len(selected) == 1: assert len(coins) >= 1, len(coins)
txid = selected[0].split(':')[0] 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) tx = self.wallet.db.get_transaction(txid)
if tx: if tx:
label = self.wallet.get_label(txid) or None # Prefer None if empty (None hides the Description: field in the window) 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)) 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)) menu.exec_(self.viewport().mapToGlobal(position))

57
electrum/wallet.py

@ -38,7 +38,7 @@ import traceback
from functools import partial from functools import partial
from numbers import Number from numbers import Number
from decimal import Decimal 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 .i18n import _
from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler, from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler,
@ -204,7 +204,8 @@ class Abstract_Wallet(AddressSynchronizer):
self.use_change = storage.get('use_change', True) self.use_change = storage.get('use_change', True)
self.multiple_change = storage.get('multiple_change', False) self.multiple_change = storage.get('multiple_change', False)
self.labels = storage.get('labels', {}) 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.fiat_value = storage.get('fiat_value', {})
self.receive_requests = storage.get('payment_requests', {}) 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): def get_spendable_coins(self, domain, config, *, nonlocal_only=False):
confirmed_only = config.get('confirmed_only', False) confirmed_only = config.get('confirmed_only', False)
return self.get_utxos(domain, utxos = self.get_utxos(domain,
excluded=self.frozen_addresses, excluded_addresses=self.frozen_addresses,
mature=True, mature_only=True,
confirmed_only=confirmed_only, confirmed_only=confirmed_only,
nonlocal_only=nonlocal_only) nonlocal_only=nonlocal_only)
utxos = [utxo for utxo in utxos if not self.is_frozen_coin(utxo)]
return utxos
def dummy_address(self): def dummy_address(self):
return self.get_receiving_addresses()[0] return self.get_receiving_addresses()[0]
def get_frozen_balance(self): 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): def balance_at_timestamp(self, domain, target_timestamp):
h = self.get_history(domain) h = self.get_history(domain)
@ -737,12 +745,18 @@ class Abstract_Wallet(AddressSynchronizer):
self.sign_transaction(tx, password) self.sign_transaction(tx, password)
return tx return tx
def is_frozen(self, addr): def is_frozen_address(self, addr: str) -> bool:
return addr in self.frozen_addresses return addr in self.frozen_addresses
def set_frozen_state(self, addrs, freeze): def is_frozen_coin(self, utxo) -> bool:
'''Set frozen state of the addresses to FREEZE, True or False''' # 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): if all(self.is_mine(addr) for addr in addrs):
# FIXME take lock?
if freeze: if freeze:
self.frozen_addresses |= set(addrs) self.frozen_addresses |= set(addrs)
else: else:
@ -751,6 +765,25 @@ class Abstract_Wallet(AddressSynchronizer):
return True return True
return False 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_until_synchronized(self, callback=None):
def wait_for_wallet(): def wait_for_wallet():
self.set_up_to_date(False) self.set_up_to_date(False)
@ -1401,7 +1434,7 @@ class Imported_Wallet(Simple_Wallet):
self.db.remove_transaction(tx_hash) self.db.remove_transaction(tx_hash)
self.set_label(address, None) self.set_label(address, None)
self.remove_payment_request(address, {}) 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) pubkey = self.get_public_key(address)
self.db.remove_imported_address(address) self.db.remove_imported_address(address)
if pubkey: if pubkey:

Loading…
Cancel
Save