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