1#!/usr/local/bin/python3.8 2# 3# Electrum - lightweight Bitcoin client 4# Copyright (C) 2015 Thomas Voegtlin 5# 6# Permission is hereby granted, free of charge, to any person 7# obtaining a copy of this software and associated documentation files 8# (the "Software"), to deal in the Software without restriction, 9# including without limitation the rights to use, copy, modify, merge, 10# publish, distribute, sublicense, and/or sell copies of the Software, 11# and to permit persons to whom the Software is furnished to do so, 12# subject to the following conditions: 13# 14# The above copyright notice and this permission notice shall be 15# included in all copies or substantial portions of the Software. 16# 17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 21# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 22# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24# SOFTWARE. 25 26from typing import Optional, List, Dict, Sequence, Set 27from enum import IntEnum 28import copy 29 30from PyQt5.QtCore import Qt 31from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont 32from PyQt5.QtWidgets import QAbstractItemView, QMenu, QLabel, QHBoxLayout 33 34from electrum.i18n import _ 35from electrum.transaction import PartialTxInput 36 37from .util import MyTreeView, ColorScheme, MONOSPACE_FONT, EnterButton 38 39 40class UTXOList(MyTreeView): 41 _spend_set: Optional[Set[str]] # coins selected by the user to spend from 42 _utxo_dict: Dict[str, PartialTxInput] # coin name -> coin 43 44 class Columns(IntEnum): 45 OUTPOINT = 0 46 ADDRESS = 1 47 LABEL = 2 48 AMOUNT = 3 49 HEIGHT = 4 50 51 headers = { 52 Columns.ADDRESS: _('Address'), 53 Columns.LABEL: _('Label'), 54 Columns.AMOUNT: _('Amount'), 55 Columns.HEIGHT: _('Height'), 56 Columns.OUTPOINT: _('Output point'), 57 } 58 filter_columns = [Columns.ADDRESS, Columns.LABEL, Columns.OUTPOINT] 59 stretch_column = Columns.LABEL 60 61 ROLE_PREVOUT_STR = Qt.UserRole + 1000 62 63 def __init__(self, parent): 64 super().__init__(parent, self.create_menu, 65 stretch_column=self.stretch_column) 66 self._spend_set = None 67 self._utxo_dict = {} 68 self.wallet = self.parent.wallet 69 70 self.setModel(QStandardItemModel(self)) 71 self.setSelectionMode(QAbstractItemView.ExtendedSelection) 72 self.setSortingEnabled(True) 73 self.update() 74 75 def update(self): 76 # not calling maybe_defer_update() as it interferes with coincontrol status bar 77 utxos = self.wallet.get_utxos() 78 self._maybe_reset_spend_list(utxos) 79 self._utxo_dict = {} 80 self.model().clear() 81 self.update_headers(self.__class__.headers) 82 for idx, utxo in enumerate(utxos): 83 self.insert_utxo(idx, utxo) 84 self.filter() 85 # update coincontrol status bar 86 if self._spend_set is not None: 87 coins = [self._utxo_dict[x] for x in self._spend_set] 88 coins = self._filter_frozen_coins(coins) 89 amount = sum(x.value_sats() for x in coins) 90 amount_str = self.parent.format_amount_and_units(amount) 91 num_outputs_str = _("{} outputs available ({} total)").format(len(coins), len(utxos)) 92 self.parent.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}') 93 else: 94 self.parent.set_coincontrol_msg(None) 95 96 def insert_utxo(self, idx, utxo: PartialTxInput): 97 address = utxo.address 98 height = utxo.block_height 99 name = utxo.prevout.to_str() 100 name_short = utxo.prevout.txid.hex()[:16] + '...' + ":%d" % utxo.prevout.out_idx 101 self._utxo_dict[name] = utxo 102 label = self.wallet.get_label_for_txid(utxo.prevout.txid.hex()) or self.wallet.get_label(address) 103 amount = self.parent.format_amount(utxo.value_sats(), whitespaces=True) 104 labels = [name_short, address, label, amount, '%d'%height] 105 utxo_item = [QStandardItem(x) for x in labels] 106 self.set_editability(utxo_item) 107 utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_CLIPBOARD_DATA) 108 utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_PREVOUT_STR) 109 utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT)) 110 utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT)) 111 utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT)) 112 SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent') 113 if name in (self._spend_set or set()): 114 for col in utxo_item: 115 col.setBackground(ColorScheme.GREEN.as_color(True)) 116 if col != self.Columns.OUTPOINT: 117 col.setToolTip(SELECTED_TO_SPEND_TOOLTIP) 118 if self.wallet.is_frozen_address(address): 119 utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) 120 utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen')) 121 if self.wallet.is_frozen_coin(utxo): 122 utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True)) 123 utxo_item[self.Columns.OUTPOINT].setToolTip(f"{name}\n{_('Coin is frozen')}") 124 else: 125 tooltip = ("\n" + SELECTED_TO_SPEND_TOOLTIP) if name in (self._spend_set or set()) else "" 126 utxo_item[self.Columns.OUTPOINT].setToolTip(name + tooltip) 127 self.model().insertRow(idx, utxo_item) 128 129 def get_selected_outpoints(self) -> Optional[List[str]]: 130 if not self.model(): 131 return None 132 items = self.selected_in_column(self.Columns.OUTPOINT) 133 return [x.data(self.ROLE_PREVOUT_STR) for x in items] 134 135 def _filter_frozen_coins(self, coins: List[PartialTxInput]) -> List[PartialTxInput]: 136 coins = [utxo for utxo in coins 137 if (not self.wallet.is_frozen_address(utxo.address) and 138 not self.wallet.is_frozen_coin(utxo))] 139 return coins 140 141 def set_spend_list(self, coins: Optional[List[PartialTxInput]]): 142 if coins is not None: 143 coins = self._filter_frozen_coins(coins) 144 self._spend_set = {utxo.prevout.to_str() for utxo in coins} 145 else: 146 self._spend_set = None 147 self.update() 148 149 def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]: 150 if self._spend_set is None: 151 return None 152 utxos = [self._utxo_dict[x] for x in self._spend_set] 153 return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict 154 155 def _maybe_reset_spend_list(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None: 156 if self._spend_set is None: 157 return 158 # if we spent one of the selected UTXOs, just reset selection 159 utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos} 160 if not all([prevout_str in utxo_set for prevout_str in self._spend_set]): 161 self._spend_set = None 162 163 def create_menu(self, position): 164 selected = self.get_selected_outpoints() 165 if selected is None: 166 return 167 menu = QMenu() 168 menu.setSeparatorsCollapsible(True) # consecutive separators are merged together 169 coins = [self._utxo_dict[name] for name in selected] 170 if len(coins) == 0: 171 menu.addAction(_("Spend (select none)"), lambda: self.set_spend_list(coins)) 172 else: 173 menu.addAction(_("Spend"), lambda: self.set_spend_list(coins)) 174 175 if len(coins) == 1: 176 utxo = coins[0] 177 addr = utxo.address 178 txid = utxo.prevout.txid.hex() 179 # "Details" 180 tx = self.wallet.db.get_transaction(txid) 181 if tx: 182 label = self.wallet.get_label_for_txid(txid) 183 menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, tx_desc=label)) 184 # "Copy ..." 185 idx = self.indexAt(position) 186 if not idx.isValid(): 187 return 188 self.add_copy_menu(menu, idx) 189 # "Freeze coin" 190 if not self.wallet.is_frozen_coin(utxo): 191 menu.addAction(_("Freeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo], True)) 192 else: 193 menu.addSeparator() 194 menu.addAction(_("Coin is frozen"), lambda: None).setEnabled(False) 195 menu.addAction(_("Unfreeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo], False)) 196 menu.addSeparator() 197 # "Freeze address" 198 if not self.wallet.is_frozen_address(addr): 199 menu.addAction(_("Freeze Address"), lambda: self.parent.set_frozen_state_of_addresses([addr], True)) 200 else: 201 menu.addSeparator() 202 menu.addAction(_("Address is frozen"), lambda: None).setEnabled(False) 203 menu.addAction(_("Unfreeze Address"), lambda: self.parent.set_frozen_state_of_addresses([addr], False)) 204 menu.addSeparator() 205 elif len(coins) > 1: # multiple items selected 206 menu.addSeparator() 207 addrs = [utxo.address for utxo in coins] 208 is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins] 209 is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins] 210 if not all(is_coin_frozen): 211 menu.addAction(_("Freeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, True)) 212 if any(is_coin_frozen): 213 menu.addAction(_("Unfreeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, False)) 214 if not all(is_addr_frozen): 215 menu.addAction(_("Freeze Addresses"), lambda: self.parent.set_frozen_state_of_addresses(addrs, True)) 216 if any(is_addr_frozen): 217 menu.addAction(_("Unfreeze Addresses"), lambda: self.parent.set_frozen_state_of_addresses(addrs, False)) 218 219 menu.exec_(self.viewport().mapToGlobal(position)) 220 221 def get_filter_data_from_coordinate(self, row, col): 222 if col == self.Columns.OUTPOINT: 223 return self.get_role_data_from_coordinate(row, col, role=self.ROLE_PREVOUT_STR) 224 return super().get_filter_data_from_coordinate(row, col) 225