utxo_list.py (10211B)
1 #!/usr/bin/env python 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 26 from typing import Optional, List, Dict, Sequence, Set 27 from enum import IntEnum 28 import copy 29 30 from PyQt5.QtCore import Qt 31 from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont 32 from PyQt5.QtWidgets import QAbstractItemView, QMenu, QLabel, QHBoxLayout 33 34 from electrum.i18n import _ 35 from electrum.transaction import PartialTxInput 36 37 from .util import MyTreeView, ColorScheme, MONOSPACE_FONT, EnterButton 38 39 40 class 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 def __init__(self, parent): 62 super().__init__(parent, self.create_menu, 63 stretch_column=self.stretch_column, 64 editable_columns=[]) 65 self._spend_set = None 66 self._utxo_dict = {} 67 self.wallet = self.parent.wallet 68 69 self.setModel(QStandardItemModel(self)) 70 self.setSelectionMode(QAbstractItemView.ExtendedSelection) 71 self.setSortingEnabled(True) 72 self.update() 73 74 def update(self): 75 # not calling maybe_defer_update() as it interferes with coincontrol status bar 76 utxos = self.wallet.get_utxos() 77 self._maybe_reset_spend_list(utxos) 78 self._utxo_dict = {} 79 self.model().clear() 80 self.update_headers(self.__class__.headers) 81 for idx, utxo in enumerate(utxos): 82 self.insert_utxo(idx, utxo) 83 self.filter() 84 # update coincontrol status bar 85 if self._spend_set is not None: 86 coins = [self._utxo_dict[x] for x in self._spend_set] 87 coins = self._filter_frozen_coins(coins) 88 amount = sum(x.value_sats() for x in coins) 89 amount_str = self.parent.format_amount_and_units(amount) 90 num_outputs_str = _("{} outputs available ({} total)").format(len(coins), len(utxos)) 91 self.parent.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}') 92 else: 93 self.parent.set_coincontrol_msg(None) 94 95 def insert_utxo(self, idx, utxo: PartialTxInput): 96 address = utxo.address 97 height = utxo.block_height 98 name = utxo.prevout.to_str() 99 name_short = utxo.prevout.txid.hex()[:16] + '...' + ":%d" % utxo.prevout.out_idx 100 self._utxo_dict[name] = utxo 101 label = self.wallet.get_label_for_txid(utxo.prevout.txid.hex()) or self.wallet.get_label(address) 102 amount = self.parent.format_amount(utxo.value_sats(), whitespaces=True) 103 labels = [name_short, address, label, amount, '%d'%height] 104 utxo_item = [QStandardItem(x) for x in labels] 105 self.set_editability(utxo_item) 106 utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_CLIPBOARD_DATA) 107 utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT)) 108 utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT)) 109 utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT)) 110 utxo_item[self.Columns.ADDRESS].setData(name, Qt.UserRole) 111 SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent') 112 if name in (self._spend_set or set()): 113 for col in utxo_item: 114 col.setBackground(ColorScheme.GREEN.as_color(True)) 115 if col != self.Columns.OUTPOINT: 116 col.setToolTip(SELECTED_TO_SPEND_TOOLTIP) 117 if self.wallet.is_frozen_address(address): 118 utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) 119 utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen')) 120 if self.wallet.is_frozen_coin(utxo): 121 utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True)) 122 utxo_item[self.Columns.OUTPOINT].setToolTip(f"{name}\n{_('Coin is frozen')}") 123 else: 124 tooltip = ("\n" + SELECTED_TO_SPEND_TOOLTIP) if name in (self._spend_set or set()) else "" 125 utxo_item[self.Columns.OUTPOINT].setToolTip(name + tooltip) 126 self.model().insertRow(idx, utxo_item) 127 128 def get_selected_outpoints(self) -> Optional[List[str]]: 129 if not self.model(): 130 return None 131 items = self.selected_in_column(self.Columns.ADDRESS) 132 return [x.data(Qt.UserRole) for x in items] 133 134 def _filter_frozen_coins(self, coins: List[PartialTxInput]) -> List[PartialTxInput]: 135 coins = [utxo for utxo in coins 136 if (not self.wallet.is_frozen_address(utxo.address) and 137 not self.wallet.is_frozen_coin(utxo))] 138 return coins 139 140 def set_spend_list(self, coins: Optional[List[PartialTxInput]]): 141 if coins is not None: 142 coins = self._filter_frozen_coins(coins) 143 self._spend_set = {utxo.prevout.to_str() for utxo in coins} 144 else: 145 self._spend_set = None 146 self.update() 147 148 def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]: 149 if self._spend_set is None: 150 return None 151 utxos = [self._utxo_dict[x] for x in self._spend_set] 152 return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict 153 154 def _maybe_reset_spend_list(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None: 155 if self._spend_set is None: 156 return 157 # if we spent one of the selected UTXOs, just reset selection 158 utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos} 159 if not all([prevout_str in utxo_set for prevout_str in self._spend_set]): 160 self._spend_set = None 161 162 def create_menu(self, position): 163 selected = self.get_selected_outpoints() 164 if selected is None: 165 return 166 menu = QMenu() 167 menu.setSeparatorsCollapsible(True) # consecutive separators are merged together 168 coins = [self._utxo_dict[name] for name in selected] 169 if len(coins) == 0: 170 menu.addAction(_("Spend (select none)"), lambda: self.set_spend_list(coins)) 171 else: 172 menu.addAction(_("Spend"), lambda: self.set_spend_list(coins)) 173 174 if len(coins) == 1: 175 utxo = coins[0] 176 addr = utxo.address 177 txid = utxo.prevout.txid.hex() 178 # "Details" 179 tx = self.wallet.db.get_transaction(txid) 180 if tx: 181 label = self.wallet.get_label_for_txid(txid) 182 menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, tx_desc=label)) 183 # "Copy ..." 184 idx = self.indexAt(position) 185 if not idx.isValid(): 186 return 187 self.add_copy_menu(menu, idx) 188 # "Freeze coin" 189 if not self.wallet.is_frozen_coin(utxo): 190 menu.addAction(_("Freeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo], True)) 191 else: 192 menu.addSeparator() 193 menu.addAction(_("Coin is frozen"), lambda: None).setEnabled(False) 194 menu.addAction(_("Unfreeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo], False)) 195 menu.addSeparator() 196 # "Freeze address" 197 if not self.wallet.is_frozen_address(addr): 198 menu.addAction(_("Freeze Address"), lambda: self.parent.set_frozen_state_of_addresses([addr], True)) 199 else: 200 menu.addSeparator() 201 menu.addAction(_("Address is frozen"), lambda: None).setEnabled(False) 202 menu.addAction(_("Unfreeze Address"), lambda: self.parent.set_frozen_state_of_addresses([addr], False)) 203 menu.addSeparator() 204 elif len(coins) > 1: # multiple items selected 205 menu.addSeparator() 206 addrs = [utxo.address for utxo in coins] 207 is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins] 208 is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins] 209 if not all(is_coin_frozen): 210 menu.addAction(_("Freeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, True)) 211 if any(is_coin_frozen): 212 menu.addAction(_("Unfreeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, False)) 213 if not all(is_addr_frozen): 214 menu.addAction(_("Freeze Addresses"), lambda: self.parent.set_frozen_state_of_addresses(addrs, True)) 215 if any(is_addr_frozen): 216 menu.addAction(_("Unfreeze Addresses"), lambda: self.parent.set_frozen_state_of_addresses(addrs, False)) 217 218 menu.exec_(self.viewport().mapToGlobal(position))