mirror of
https://github.com/LBRYFoundation/LBRY-Vault.git
synced 2025-08-29 16:31:29 +00:00
Merge pull request #5152 from SomberNight/freeze_individual_utxos
Freeze individual UTXOs
This commit is contained in:
commit
52af40685e
7 changed files with 153 additions and 54 deletions
|
@ -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
|
||||
"""
|
||||
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.
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
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):
|
||||
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:
|
||||
|
|
Loading…
Add table
Reference in a new issue