Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
433 changes: 433 additions & 0 deletions electrum/coinfilter.py

Large diffs are not rendered by default.

52 changes: 48 additions & 4 deletions electrum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,11 +492,14 @@ async def unlock(self, wallet: Abstract_Wallet = None, password=None):
async def listunspent(self, wallet: Abstract_Wallet = None):
"""List unspent outputs. Returns the list of unspent transaction
outputs in your wallet."""
coinfilter = wallet.coinfilter
coins = []
for txin in wallet.get_utxos():
d = txin.to_json()
v = d.pop("value_sats")
d["value"] = format_satoshis(v)
d["frozen"] = coinfilter.is_frozen_coin(txin) or coinfilter.is_frozen_address(txin.address)
d["selected"] = coinfilter.is_selected(txin)
coins.append(d)
return coins

Expand Down Expand Up @@ -681,6 +684,44 @@ async def unfreeze_utxo(self, coin: str, wallet: Abstract_Wallet = None):
wallet.set_frozen_state_of_coins([coin], False)
return True

@command('w')
async def select_utxo(self, coins, wallet: Abstract_Wallet = None):
"""
Add UTXOs to the coin control selection. While the selection is non-empty,
only the selected coins are used to fund new transactions.

arg:json:coins:list of outpoints, each in the <txid:index> format
"""
added = wallet.coinfilter.select_coins(coins, strict=True)
return sorted(added)

@command('w')
async def unselect_utxo(self, coins=None, wallet: Abstract_Wallet = None):
"""
Remove UTXOs from the coin control selection. Without arguments, clears
the whole selection and thus turns coin control off.

arg:json:coins:list of outpoints; omit to clear the entire selection
"""
coinfilter = wallet.coinfilter
if coins is None:
coinfilter.clear_selection()
return True
return sorted(coinfilter.deselect_coins(coins))

@command('w')
async def list_selected_utxos(self, wallet: Abstract_Wallet = None):
"""List the coin control selection, and whether it is currently active."""
coinfilter = wallet.coinfilter
status = coinfilter.get_coin_control_status()
return {
'active': status.is_active,
'selected': sorted(coinfilter.get_selection()),
'num_usable': status.num_usable,
'num_total': status.num_total,
'value': format_satoshis(status.value_sat),
}

@command('wp')
async def getprivatekeys(self, address, password=None, wallet: Abstract_Wallet = None):
"""
Expand Down Expand Up @@ -1023,9 +1064,11 @@ async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_
address = await self._resolver(address, wallet)
amount_sat = satoshis_or_max(amount)
final_outputs.append(PartialTxOutput.from_address_and_value(address, amount_sat))
coins = wallet.get_spendable_coins(domain_addr)
if domain_coins is not None:
coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)]
coins = wallet.coinfilter.get_coins_for_outpoints(
domain_coins, domain=domain_addr)
else:
coins = wallet.get_spendable_coins(domain_addr)
tx = wallet.make_unsigned_transaction(
outputs=final_outputs,
fee_policy=fee_policy,
Expand Down Expand Up @@ -1086,9 +1129,10 @@ async def bumpfee(self, tx, new_fee_rate, from_coins=None, decrease_payment=Fals
except transaction.SerializationError as e:
raise UserFacingException(f"Failed to deserialize transaction: {e}") from e
domain_coins = from_coins.split(',') if from_coins else None
coins = wallet.get_spendable_coins(None)
if domain_coins is not None:
coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)]
coins = wallet.coinfilter.get_coins_for_outpoints(domain_coins)
else:
coins = wallet.get_spendable_coins(None)
tx.add_info_from_wallet(wallet)
await tx.add_info_from_network(self.network)
new_tx = wallet.bump_fee(
Expand Down
6 changes: 6 additions & 0 deletions electrum/gui/qml/qeaddresslistmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ def on_event_labels_received(self, wallet, labels):
if wallet == self.wallet:
self.setDirty()

@qt_event_listener
def on_event_frozen_state_changed(self, wallet, addresses, outpoints):
# the 'held' role of both addresses and coins depends on the frozen state
if wallet == self.wallet:
self.setDirty()

def rowCount(self, index):
return len(self._items)

Expand Down
4 changes: 2 additions & 2 deletions electrum/gui/qml/qeinvoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,8 @@ def updateMaxAmount(self):
def calc_max(address):
try:
outputs = [PartialTxOutput(scriptpubkey=address_to_script(address), value='!')]
make_tx = lambda fee_policy, *, confirmed_only=False: self._wallet.wallet.make_unsigned_transaction(
coins=self._wallet.wallet.get_spendable_coins(None),
make_tx = lambda fee_policy, *, confirmed_only=None: self._wallet.wallet.make_unsigned_transaction(
coins=self._wallet.wallet.get_spendable_coins(None, confirmed_only=confirmed_only),
outputs=outputs,
fee_policy=fee_policy,
is_sweep=False)
Expand Down
6 changes: 6 additions & 0 deletions electrum/gui/qml/qewallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ def on_event_wallet_updated(self, wallet):
if not self.synchronizing:
self.historyModel.initModel() # refresh if dirty

@qt_event_listener
def on_event_frozen_state_changed(self, wallet, addresses, outpoints):
if wallet == self.wallet:
# frozenBalance, isLowReserve and the piechart all depend on the frozen set
self.balanceChanged.emit()

@event_listener
def on_event_channel(self, wallet, channel):
if wallet == self.wallet:
Expand Down
37 changes: 27 additions & 10 deletions electrum/gui/qt/address_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

import enum
from enum import IntEnum
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Optional, Sequence

from PyQt6.QtCore import Qt, QPersistentModelIndex, QModelIndex
from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
Expand All @@ -38,6 +38,8 @@
from electrum.wallet import InternalAddressCorruption
from electrum.simple_config import SimpleConfig

from electrum.gui.common_qt.util import QtEventListener, qt_event_listener

from .util import MONOSPACE_FONT, ColorScheme, webopen
from .my_treeview import MyTreeView, MySortModel
from ..messages import MSG_FREEZE_ADDRESS
Expand Down Expand Up @@ -77,7 +79,7 @@ def ui_text(self) -> str:
}[self]


class AddressList(MyTreeView):
class AddressList(MyTreeView, QtEventListener):

class Columns(MyTreeView.BaseColumnsEnum):
TYPE = enum.auto()
Expand Down Expand Up @@ -121,6 +123,18 @@ def __init__(self, main_window: 'ElectrumWindow'):
self.sortByColumn(self.Columns.TYPE, Qt.SortOrder.AscendingOrder)
if self.config:
self.configvar_show_toolbar = self.config.cv.GUI_QT_ADDRESSES_TAB_SHOW_TOOLBAR
self.register_callbacks()
self.destroyed.connect(lambda: self.unregister_callbacks())

@qt_event_listener
def on_event_frozen_state_changed(self, wallet, addresses, outpoints):
if wallet != self.wallet:
return
self.refresh_all() # frozen addresses are painted blue

def set_frozen_state_of_addresses(self, addrs: Sequence[str], freeze: bool) -> None:
self.wallet.set_frozen_state_of_addresses(addrs, freeze)
self.selectionModel().clearSelection()

def on_double_click(self, idx):
addr = self.get_role_data_for_current_item(col=0, role=self.ROLE_ADDRESS_STR)
Expand Down Expand Up @@ -328,24 +342,27 @@ def create_menu(self, position):
menu.addAction(_("View on block explorer"), lambda: webopen(addr_URL))

if not self.wallet.is_frozen_address(addr):
act = menu.addAction(_("Freeze"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
act = menu.addAction(_("Freeze"), lambda: self.set_frozen_state_of_addresses([addr], True))
else:
act = menu.addAction(_("Unfreeze"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
act = menu.addAction(_("Unfreeze"), lambda: self.set_frozen_state_of_addresses([addr], False))
act.setToolTip(MSG_FREEZE_ADDRESS)

else:
# multiple items selected
act = menu.addAction(_("Freeze"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
act = menu.addAction(_("Freeze"), lambda: self.set_frozen_state_of_addresses(addrs, True))
act.setToolTip(MSG_FREEZE_ADDRESS)
act = menu.addAction(_("Unfreeze"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
act = menu.addAction(_("Unfreeze"), lambda: self.set_frozen_state_of_addresses(addrs, False))
act.setToolTip(MSG_FREEZE_ADDRESS)

coins = self.wallet.get_spendable_coins(addrs)
coinfilter = self.wallet.coinfilter
# ignore coin_control, we want to know which coins *could* be added,
# independently of what is selected right now.
coins = coinfilter.get_spendable_coins(addrs, ignore_coin_control=True)
if coins:
if self.main_window.utxo_list.are_in_coincontrol(coins):
menu.addAction(_("Remove from coin control"), lambda: self.main_window.utxo_list.remove_from_coincontrol(coins))
if all(coinfilter.is_selected(utxo) for utxo in coins):
menu.addAction(_("Remove from coin control"), lambda: coinfilter.deselect_addresses(addrs))
else:
menu.addAction(_("Add to coin control"), lambda: self.main_window.utxo_list.add_to_coincontrol(coins))
menu.addAction(_("Add to coin control"), lambda: coinfilter.select_addresses(addrs))

run_hook('receive_menu', menu, addrs, self.wallet)
self.open_menu(menu, position)
Expand Down
51 changes: 26 additions & 25 deletions electrum/gui/qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,16 @@ def on_event_proxy_set(self, *args):
def on_event_recently_opened_wallets_update(self, *args):
self.update_recently_opened_menu()

@qt_event_listener
def on_event_coin_control_changed(self, wallet, *args):
if wallet == self.wallet:
self.update_coincontrol_bar()

@qt_event_listener
def on_event_frozen_state_changed(self, wallet, *args):
if wallet == self.wallet:
self.update_status() # updates frozen balance in piechart and coincontrol bar

def close_wallet(self):
if self.wallet:
self.logger.info(f'close_wallet {self.wallet.storage.get_path()}')
Expand Down Expand Up @@ -1112,6 +1122,8 @@ def update_status(self):
self.tasks_label.setText(name)
self.tasks_label.setVisible(num_tasks > 0)

self.update_coincontrol_bar()

def num_tasks(self):
# For the moment, all the coroutines in this set are outgoing LN payments,
# so we can use this to disable buttons for rebalance/swap suggestions
Expand Down Expand Up @@ -1410,18 +1422,7 @@ def on_event_payment_failed(self, wallet, key, reason):
self.notify(_('Payment failed') + '\n\n' + description + '\n\n' + reason)

def get_coins(self, **kwargs) -> Sequence[PartialTxInput]:
coins = self.get_manually_selected_coins()
if coins is not None:
return coins
else:
return self.wallet.get_spendable_coins(None, **kwargs)

def get_manually_selected_coins(self) -> Optional[Sequence[PartialTxInput]]:
"""Return a list of selected coins or None.
Note: None means selection is not being used,
while an empty sequence means the user specifically selected that.
"""
return self.utxo_list.get_spend_list()
return self.wallet.get_spendable_coins(None, **kwargs)

def broadcast_or_show(self, tx: Transaction, *, invoice: 'Invoice' = None):
if not tx.is_complete():
Expand Down Expand Up @@ -1569,18 +1570,6 @@ def handle_payment_identifier(self, text: str):
if pi.error:
self.show_error(str(pi.error))

def set_frozen_state_of_addresses(self, addrs, freeze: bool):
self.wallet.set_frozen_state_of_addresses(addrs, freeze)
self.address_list.refresh_all()
self.utxo_list.refresh_all()
self.address_list.selectionModel().clearSelection()

def set_frozen_state_of_coins(self, utxos: Sequence[PartialTxInput], freeze: bool):
utxos_str = {utxo.prevout.to_str() for utxo in utxos}
self.wallet.set_frozen_state_of_coins(utxos_str, freeze)
self.utxo_list.refresh_all()
self.utxo_list.selectionModel().clearSelection()

def create_list_tab(self, l):
w = QWidget()
w.searchable_list = l
Expand Down Expand Up @@ -1875,13 +1864,25 @@ def create_coincontrol_statusbar(self):
self.coincontrol_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
sb.addWidget(self.coincontrol_label)

clear_cc_button = EnterButton(_('Reset'), lambda: self.utxo_list.clear_coincontrol())
clear_cc_button = EnterButton(_('Reset'), lambda: self.wallet.coinfilter.clear_selection())
clear_cc_button.setStyleSheet("margin-right: 5px;")
sb.addPermanentWidget(clear_cc_button)

sb.setVisible(False)
return sb

def update_coincontrol_bar(self):
# get_coin_control_status walks all utxos, so shortcut the inactive case.
coinfilter = self.wallet.coinfilter
status = coinfilter.get_coin_control_status() if coinfilter.is_coin_control_active() else None
if status and status.is_active:
amount_str = self.format_amount_and_units(status.value_sat)
num_outputs_str = _("{} outputs available ({} total)").format(
status.num_usable, status.num_total)
self.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}')
else:
self.set_coincontrol_msg(None)

def set_coincontrol_msg(self, msg: Optional[str]) -> None:
if not msg:
self.coincontrol_label.setText("")
Expand Down
4 changes: 2 additions & 2 deletions electrum/gui/qt/send_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,9 @@ def spend_max(self):
outputs = pi.get_onchain_outputs('!')
if not outputs:
return
make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
make_tx = lambda fee_policy, *, confirmed_only=None: self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=self.window.get_coins(),
coins=self.window.get_coins(confirmed_only=confirmed_only),
outputs=outputs,
is_sweep=False)
try:
Expand Down
Loading