48916f56bc
Also stop timer when dialog is finished, to avoid re-generating txs with the same input coin set, which results in an exception as these coins have signatures when the swap has started.
384 lines
18 KiB
Python
384 lines
18 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Electrum - lightweight Bitcoin client
|
|
# Copyright (C) 2015 Thomas Voegtlin
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation files
|
|
# (the "Software"), to deal in the Software without restriction,
|
|
# including without limitation the rights to use, copy, modify, merge,
|
|
# publish, distribute, sublicense, and/or sell copies of the Software,
|
|
# and to permit persons to whom the Software is furnished to do so,
|
|
# subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
from typing import Optional, List, Dict, Sequence, Set, TYPE_CHECKING
|
|
import enum
|
|
import copy
|
|
|
|
from PyQt6.QtCore import Qt
|
|
from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
|
|
from PyQt6.QtWidgets import QAbstractItemView, QMenu
|
|
|
|
from electrum.i18n import _
|
|
from electrum.bitcoin import is_address
|
|
from electrum.transaction import PartialTxInput, PartialTxOutput
|
|
from electrum.lnutil import MIN_FUNDING_SAT
|
|
from electrum.util import profiler
|
|
from electrum.plugin import run_hook
|
|
|
|
from .util import ColorScheme, MONOSPACE_FONT
|
|
from .my_treeview import MyTreeView, MySortModel
|
|
from .new_channel_dialog import NewChannelDialog
|
|
from ..messages import MSG_FREEZE_ADDRESS, MSG_FREEZE_COIN
|
|
|
|
if TYPE_CHECKING:
|
|
from .main_window import ElectrumWindow
|
|
|
|
|
|
class UTXOList(MyTreeView):
|
|
_spend_set: Set[str] # coins selected by the user to spend from
|
|
_utxo_dict: Dict[str, PartialTxInput] # coin name -> coin
|
|
|
|
class Columns(MyTreeView.BaseColumnsEnum):
|
|
OUTPOINT = enum.auto()
|
|
ADDRESS = enum.auto()
|
|
LABEL = enum.auto()
|
|
AMOUNT = enum.auto()
|
|
PARENTS = enum.auto()
|
|
|
|
headers = {
|
|
Columns.OUTPOINT: _('Output point'),
|
|
Columns.ADDRESS: _('Address'),
|
|
Columns.PARENTS: _('Parents'),
|
|
Columns.LABEL: _('Label'),
|
|
Columns.AMOUNT: _('Amount'),
|
|
}
|
|
filter_columns = [Columns.ADDRESS, Columns.LABEL, Columns.OUTPOINT]
|
|
stretch_column = Columns.LABEL
|
|
|
|
ROLE_PREVOUT_STR = Qt.ItemDataRole.UserRole + 1000
|
|
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1001
|
|
key_role = ROLE_PREVOUT_STR
|
|
|
|
def __init__(self, main_window: 'ElectrumWindow'):
|
|
super().__init__(
|
|
main_window=main_window,
|
|
stretch_column=self.stretch_column,
|
|
)
|
|
self._spend_set = set()
|
|
self._utxo_dict = {}
|
|
self.wallet = self.main_window.wallet
|
|
self.std_model = QStandardItemModel(self)
|
|
self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER)
|
|
self.proxy.setSourceModel(self.std_model)
|
|
self.setModel(self.proxy)
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
self.setSortingEnabled(True)
|
|
|
|
def create_toolbar(self, config):
|
|
toolbar, menu = self.create_toolbar_with_menu('')
|
|
self.num_coins_label = toolbar.itemAt(0).widget()
|
|
menu.addAction(_('Coin control'), lambda: self.add_selection_to_coincontrol())
|
|
|
|
def cb():
|
|
self.main_window.utxo_list.refresh_all() # for coin frozen status
|
|
self.main_window.update_status() # frozen balance
|
|
menu.addConfig(config.cv.WALLET_FREEZE_REUSED_ADDRESS_UTXOS, callback=cb)
|
|
return toolbar
|
|
|
|
@profiler(min_threshold=0.05)
|
|
def update(self):
|
|
# not calling maybe_defer_update() as it interferes with coincontrol status bar
|
|
self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
|
|
utxos = self.wallet.get_utxos()
|
|
self._maybe_reset_coincontrol(utxos)
|
|
self._utxo_dict = dict([(utxo.prevout.to_str(), utxo) for utxo in utxos])
|
|
self.std_model.clear()
|
|
self.update_headers(self.__class__.headers)
|
|
for idx, utxo in enumerate(utxos):
|
|
name = utxo.prevout.to_str()
|
|
labels = [""] * len(self.Columns)
|
|
amount_str = self.main_window.format_amount(
|
|
utxo.value_sats(), whitespaces=True)
|
|
amount_str_nots = self.main_window.format_amount(
|
|
utxo.value_sats(), whitespaces=False, add_thousands_sep=False)
|
|
labels[self.Columns.OUTPOINT] = str(utxo.short_id)
|
|
labels[self.Columns.ADDRESS] = utxo.address
|
|
labels[self.Columns.AMOUNT] = amount_str
|
|
utxo_item = [QStandardItem(x) for x in labels]
|
|
self.set_editability(utxo_item)
|
|
utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_PREVOUT_STR)
|
|
utxo_item[self.Columns.AMOUNT].setData(amount_str_nots, self.ROLE_CLIPBOARD_DATA)
|
|
utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT))
|
|
utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT))
|
|
utxo_item[self.Columns.PARENTS].setFont(QFont(MONOSPACE_FONT))
|
|
utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT))
|
|
self.std_model.insertRow(idx, utxo_item)
|
|
self.refresh_row(name, idx)
|
|
self.filter()
|
|
self.proxy.setDynamicSortFilter(True)
|
|
self.sortByColumn(self.Columns.OUTPOINT, Qt.SortOrder.DescendingOrder)
|
|
self.update_coincontrol_bar()
|
|
self.num_coins_label.setText(_('{} unspent transaction outputs').format(len(utxos)))
|
|
|
|
def update_coincontrol_bar(self):
|
|
# update coincontrol status bar
|
|
if bool(self._spend_set):
|
|
coins = [self._utxo_dict[x] for x in self._spend_set]
|
|
coins = self._filter_frozen_coins(coins)
|
|
amount = sum(x.value_sats() for x in coins)
|
|
amount_str = self.main_window.format_amount_and_units(amount)
|
|
num_outputs_str = _("{} outputs available ({} total)").format(len(coins), len(self._utxo_dict))
|
|
self.main_window.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}')
|
|
else:
|
|
self.main_window.set_coincontrol_msg(None)
|
|
|
|
def refresh_row(self, key, row):
|
|
assert row is not None
|
|
utxo = self._utxo_dict[key]
|
|
utxo_item = [self.std_model.item(row, col) for col in self.Columns]
|
|
txid = utxo.prevout.txid.hex()
|
|
num_parents = self.wallet.get_num_parents(txid)
|
|
utxo_item[self.Columns.PARENTS].setText('%6s'%num_parents if num_parents else '-')
|
|
label = self.wallet.get_label_for_txid(txid) or ''
|
|
utxo_item[self.Columns.LABEL].setText(label)
|
|
sort_key = (
|
|
self.wallet.adb.tx_height_to_sort_height(utxo.block_height), # sort by block height
|
|
str(utxo.short_id), # order inside block (if mined), or just txid
|
|
)
|
|
utxo_item[self.Columns.OUTPOINT].setData(sort_key, self.ROLE_SORT_ORDER)
|
|
SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent')
|
|
if key in self._spend_set:
|
|
tooltip = key + "\n" + SELECTED_TO_SPEND_TOOLTIP
|
|
color = ColorScheme.GREEN.as_color(True)
|
|
else:
|
|
tooltip = key
|
|
color = self._default_bg_brush
|
|
for col in utxo_item:
|
|
col.setBackground(color)
|
|
col.setToolTip(tooltip)
|
|
if self.wallet.is_frozen_address(utxo.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(utxo):
|
|
utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True))
|
|
utxo_item[self.Columns.OUTPOINT].setToolTip(f"{key}\n{_('Coin is frozen')}")
|
|
|
|
def get_selected_outpoints(self) -> List[str]:
|
|
if not self.model():
|
|
return []
|
|
items = self.selected_in_column(self.Columns.OUTPOINT)
|
|
return [x.data(self.ROLE_PREVOUT_STR) for x in items]
|
|
|
|
def _filter_frozen_coins(self, coins: List[PartialTxInput]) -> List[PartialTxInput]:
|
|
coins = [utxo for utxo in coins
|
|
if (not self.wallet.is_frozen_address(utxo.address) and
|
|
not self.wallet.is_frozen_coin(utxo))]
|
|
return coins
|
|
|
|
def are_in_coincontrol(self, coins: List[PartialTxInput]) -> bool:
|
|
return all([utxo.prevout.to_str() in self._spend_set for utxo in coins])
|
|
|
|
def add_to_coincontrol(self, coins: List[PartialTxInput]):
|
|
assert all(utxo.prevout.to_str() in self._utxo_dict for utxo in coins) # see issue 10206
|
|
coins = self._filter_frozen_coins(coins)
|
|
for utxo in coins:
|
|
self._spend_set.add(utxo.prevout.to_str())
|
|
self._refresh_coincontrol()
|
|
|
|
def remove_from_coincontrol(self, coins: List[PartialTxInput]):
|
|
for utxo in coins:
|
|
self._spend_set.remove(utxo.prevout.to_str())
|
|
self._refresh_coincontrol()
|
|
|
|
def clear_coincontrol(self):
|
|
self._spend_set.clear()
|
|
self._refresh_coincontrol()
|
|
|
|
def add_selection_to_coincontrol(self):
|
|
if bool(self._spend_set):
|
|
self.clear_coincontrol()
|
|
return
|
|
selected = self.get_selected_outpoints()
|
|
coins = [self._utxo_dict[name] for name in selected]
|
|
if not coins:
|
|
self.main_window.show_error(_('You need to select coins from the list first.\nUse ctrl+left mouse button to select multiple items'))
|
|
return
|
|
self.add_to_coincontrol(coins)
|
|
|
|
def _refresh_coincontrol(self):
|
|
self.refresh_all()
|
|
self.update_coincontrol_bar()
|
|
self.selectionModel().clearSelection()
|
|
|
|
def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]:
|
|
if not bool(self._spend_set):
|
|
return None
|
|
utxos = [self._utxo_dict[x] for x in self._spend_set]
|
|
return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict
|
|
|
|
def _maybe_reset_coincontrol(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None:
|
|
if not self._spend_set and not self._currently_open_menu:
|
|
return
|
|
utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos}
|
|
if self._currently_open_menu:
|
|
# if we spent one of the qt-highlighted UTXOs, close context-menu
|
|
if not all(prevout_str in utxo_set for prevout_str in self.get_selected_outpoints()):
|
|
self.close_menu()
|
|
if self._spend_set:
|
|
# if we spent one of the green-marked UTXOs, just reset selection
|
|
if not all([prevout_str in utxo_set for prevout_str in self._spend_set]):
|
|
self._spend_set.clear()
|
|
|
|
def can_swap_coins(self, coins):
|
|
# fixme: min and max_amounts are known only after first request
|
|
if self.wallet.lnworker is None:
|
|
return False
|
|
value = sum(x.value_sats() for x in coins)
|
|
min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
|
|
max_amount = self.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
|
|
if min_amount is None or max_amount is None:
|
|
# we need to fetch data from swap server
|
|
return True
|
|
if value < min_amount:
|
|
return False
|
|
if max_amount is None or value > max_amount:
|
|
return False
|
|
return True
|
|
|
|
def swap_coins(self, coins: list[PartialTxInput]) -> None:
|
|
assert coins, "no coins selected?"
|
|
self.main_window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max='!', get_coins=lambda *args, **kwargs: coins)
|
|
|
|
def can_open_channel(self, coins):
|
|
if self.wallet.lnworker is None:
|
|
return False
|
|
value = sum(x.value_sats() for x in coins)
|
|
return value >= MIN_FUNDING_SAT and value <= self.config.LIGHTNING_MAX_FUNDING_SAT
|
|
|
|
def open_channel_with_coins(self, coins: list[PartialTxInput]) -> None:
|
|
assert coins, "no coins selected?"
|
|
# todo : use a single dialog in new flow
|
|
d = NewChannelDialog(self.main_window, get_coins=lambda *args, **kwargs: coins)
|
|
d.max_button.setChecked(True)
|
|
d.max_button.setEnabled(False)
|
|
d.min_button.setEnabled(False)
|
|
d.clear_button.setEnabled(False)
|
|
d.amount_e.setFrozen(True)
|
|
d.spend_max()
|
|
d.run()
|
|
|
|
def clipboard_contains_address(self) -> bool:
|
|
text = self.main_window.app.clipboard().text()
|
|
return is_address(text)
|
|
|
|
def pay_to_clipboard_address(self, coins: list[PartialTxInput]) -> None:
|
|
assert coins, "no coins selected?"
|
|
if not self.clipboard_contains_address():
|
|
self.main_window.show_error(_('Clipboard doesn\'t contain a valid address'))
|
|
return
|
|
addr = self.main_window.app.clipboard().text()
|
|
outputs = [PartialTxOutput.from_address_and_value(addr, '!')]
|
|
|
|
self.main_window.send_tab.pay_onchain_dialog(outputs, get_coins=lambda *args, **kwargs: coins)
|
|
|
|
def on_double_click(self, idx):
|
|
outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(self.ROLE_PREVOUT_STR)
|
|
utxo = self._utxo_dict[outpoint]
|
|
self.main_window.show_utxo(utxo)
|
|
|
|
def create_menu(self, position):
|
|
selected = self.get_selected_outpoints()
|
|
coins = [self._utxo_dict[name] for name in selected]
|
|
|
|
if not coins:
|
|
return
|
|
|
|
unfrozen_coins = self._filter_frozen_coins(coins)
|
|
menu = QMenu()
|
|
menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
|
|
|
|
if len(coins) == 1:
|
|
idx = self.indexAt(position)
|
|
if not idx.isValid():
|
|
return
|
|
utxo = coins[0]
|
|
txid = utxo.prevout.txid.hex()
|
|
# "Details"
|
|
tx = self.wallet.adb.get_transaction(txid)
|
|
if tx:
|
|
label = self.wallet.get_label_for_txid(txid)
|
|
menu.addAction(_("Privacy analysis"), lambda: self.main_window.show_utxo(utxo))
|
|
cc = self.add_copy_menu(menu, idx)
|
|
cc.addAction(_("Long Output point"), lambda: self.place_text_on_clipboard(utxo.prevout.to_str(), title="Long Output point"))
|
|
# fully spend
|
|
m = menu_spend = menu.addMenu(_("Fully spend") + '…')
|
|
m.setEnabled(bool(unfrozen_coins))
|
|
m = menu_spend.addAction(_("send to address in clipboard"), lambda: self.pay_to_clipboard_address(unfrozen_coins))
|
|
m.setEnabled(self.clipboard_contains_address())
|
|
m = menu_spend.addAction(_("in new channel"), lambda: self.open_channel_with_coins(unfrozen_coins))
|
|
m.setEnabled(self.can_open_channel(unfrozen_coins))
|
|
m = menu_spend.addAction(_("in submarine swap"), lambda: self.swap_coins(unfrozen_coins))
|
|
m.setEnabled(self.can_swap_coins(unfrozen_coins))
|
|
# coin control
|
|
if self.are_in_coincontrol(coins):
|
|
menu.addAction(_("Remove from coin control"), lambda: self.remove_from_coincontrol(coins))
|
|
else:
|
|
m = menu.addAction(_("Add to coin control"), lambda: self.add_to_coincontrol(coins))
|
|
m.setEnabled(bool(unfrozen_coins))
|
|
# Freeze menu
|
|
if len(coins) == 1:
|
|
utxo = coins[0]
|
|
addr = utxo.address
|
|
menu_freeze = menu.addMenu(_("Freeze"))
|
|
menu_freeze.setToolTipsVisible(True)
|
|
if not self.wallet.is_frozen_coin(utxo):
|
|
act = menu_freeze.addAction(_("Freeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], True))
|
|
else:
|
|
act = menu_freeze.addAction(_("Unfreeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], False))
|
|
act.setToolTip(MSG_FREEZE_COIN)
|
|
if not self.wallet.is_frozen_address(addr):
|
|
act = menu_freeze.addAction(_("Freeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
|
|
else:
|
|
act = menu_freeze.addAction(_("Unfreeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
|
|
act.setToolTip(MSG_FREEZE_ADDRESS)
|
|
elif len(coins) > 1: # multiple items selected
|
|
menu.addSeparator()
|
|
addrs = [utxo.address for utxo in coins]
|
|
is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins]
|
|
is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins]
|
|
menu_freeze = menu.addMenu(_("Freeze"))
|
|
menu_freeze.setToolTipsVisible(True)
|
|
if not all(is_coin_frozen):
|
|
act = menu_freeze.addAction(_("Freeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, True))
|
|
act.setToolTip(MSG_FREEZE_COIN)
|
|
if any(is_coin_frozen):
|
|
act = menu_freeze.addAction(_("Unfreeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, False))
|
|
act.setToolTip(MSG_FREEZE_COIN)
|
|
if not all(is_addr_frozen):
|
|
act = menu_freeze.addAction(_("Freeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
|
|
act.setToolTip(MSG_FREEZE_ADDRESS)
|
|
if any(is_addr_frozen):
|
|
act = menu_freeze.addAction(_("Unfreeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
|
|
act.setToolTip(MSG_FREEZE_ADDRESS)
|
|
|
|
run_hook('qt_utxo_menu', menu, coins, self.wallet)
|
|
self.open_menu(menu, position)
|
|
|
|
def get_filter_data_from_coordinate(self, row, col):
|
|
if col == self.Columns.OUTPOINT:
|
|
return self.get_role_data_from_coordinate(row, col, role=self.ROLE_PREVOUT_STR)
|
|
return super().get_filter_data_from_coordinate(row, col)
|