From 929567382175536f519b6b267a489e4e04363d9e Mon Sep 17 00:00:00 2001 From: AdamISZ Date: Thu, 14 Mar 2019 12:38:11 +0100 Subject: [PATCH 1/2] Basic coin control. Wallet persists utxo metadata; currently only contains a field 'disabled' indexed by utxo. User can switch this on or off (enabled) via wallet-tool 'freeze' method. Disabled utxos will not be used in coin selection in any transaction. Wallet still displays all utxo balances in display method (and in GUI). Add tests of disabling to test_utxomanager Add Coins tab to Qt, with freeze/unfreeze feature. Coins tab shows updated utxo info txid:n, amt, address and enabled/disabled, which can be toggled from right click menu. --- jmclient/jmclient/__init__.py | 2 +- jmclient/jmclient/wallet.py | 91 ++++++++++++++++++++-- jmclient/jmclient/wallet_utils.py | 120 ++++++++++++++++++++++++++++-- jmclient/test/test_utxomanager.py | 38 +++++++++- jmclient/test/test_wallet.py | 16 ++++ scripts/add-utxo.py | 3 + scripts/joinmarket-qt.py | 114 +++++++++++++++++++++++++++- 7 files changed, 365 insertions(+), 19 deletions(-) diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 8d7fcf672..4f2b15899 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -45,7 +45,7 @@ from .wallet_utils import ( wallet_tool_main, wallet_generate_recover_bip39, open_wallet, open_test_wallet_maybe, create_wallet, get_wallet_cls, get_wallet_path, - wallet_display) + wallet_display, get_utxos_enabled_disabled) from .maker import Maker, P2EPMaker from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain # Set default logging handler to avoid "No handler found" warnings. diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 0152f7dba..8c8fa2d20 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -117,6 +117,7 @@ def wrapped(*args, **kwargs): class UTXOManager(object): STORAGE_KEY = b'utxo' + METADATA_KEY = b'meta' TXID_LEN = 32 def __init__(self, storage, merge_func): @@ -124,6 +125,11 @@ def __init__(self, storage, merge_func): self.selector = merge_func # {mixdexpth: {(txid, index): (path, value)}} self._utxo = None + # metadata kept as a separate key in the database + # for backwards compat; value as dict for forward-compat. + # format is {(txid, index): value-dict} with "disabled" + # as the only currently used key in the dict. + self._utxo_meta = None self._load_storage() assert self._utxo is not None @@ -135,6 +141,7 @@ def _load_storage(self): assert isinstance(self.storage.data[self.STORAGE_KEY], dict) self._utxo = collections.defaultdict(dict) + self._utxo_meta = collections.defaultdict(dict) for md, data in self.storage.data[self.STORAGE_KEY].items(): md = int(md) md_data = self._utxo[md] @@ -143,28 +150,46 @@ def _load_storage(self): index = int(utxo[self.TXID_LEN:]) md_data[(txid, index)] = value + # Wallets may not have any metadata + if self.METADATA_KEY in self.storage.data: + for utxo, value in self.storage.data[self.METADATA_KEY].items(): + txid = utxo[:self.TXID_LEN] + index = int(utxo[self.TXID_LEN:]) + self._utxo_meta[(txid, index)] = value + def save(self, write=True): new_data = {} self.storage.data[self.STORAGE_KEY] = new_data + for md, data in self._utxo.items(): md = _int_to_bytestr(md) new_data[md] = {} # storage keys must be bytes() for (txid, index), value in data.items(): new_data[md][txid + _int_to_bytestr(index)] = value + + new_meta_data = {} + self.storage.data[self.METADATA_KEY] = new_meta_data + for (txid, index), value in self._utxo_meta.items(): + new_meta_data[txid + _int_to_bytestr(index)] = value + if write: self.storage.save() def reset(self): self._utxo = collections.defaultdict(dict) - def have_utxo(self, txid, index): + def have_utxo(self, txid, index, include_disabled=True): + if not include_disabled and self.is_disabled(txid, index): + return False for md in self._utxo: if (txid, index) in self._utxo[md]: return md return False def remove_utxo(self, txid, index, mixdepth): + # currently does not remove metadata associated + # with this utxo assert isinstance(txid, bytes) assert len(txid) == self.TXID_LEN assert isinstance(index, numbers.Integral) @@ -173,6 +198,8 @@ def remove_utxo(self, txid, index, mixdepth): return self._utxo[mixdepth].pop((txid, index)) def add_utxo(self, txid, index, path, value, mixdepth): + # Assumed: that we add a utxo only if we want it enabled, + # so metadata is not currently added. assert isinstance(txid, bytes) assert len(txid) == self.TXID_LEN assert isinstance(index, numbers.Integral) @@ -181,22 +208,59 @@ def add_utxo(self, txid, index, path, value, mixdepth): self._utxo[mixdepth][(txid, index)] = (path, value) + def is_disabled(self, txid, index): + if not self._utxo_meta: + return False + if (txid, index) not in self._utxo_meta: + return False + if b'disabled' not in self._utxo_meta[(txid, index)]: + return False + if not self._utxo_meta[(txid, index)][b'disabled']: + return False + return True + + def disable_utxo(self, txid, index, disable=True): + assert isinstance(txid, bytes) + assert len(txid) == self.TXID_LEN + assert isinstance(index, numbers.Integral) + + if b'disabled' not in self._utxo_meta[(txid, index)]: + self._utxo_meta[(txid, index)] = {} + self._utxo_meta[(txid, index)][b'disabled'] = disable + + def enable_utxo(self, txid, index): + self.disable_utxo(txid, index, disable=False) + def select_utxos(self, mixdepth, amount, utxo_filter=(), select_fn=None): assert isinstance(mixdepth, numbers.Integral) utxos = self._utxo[mixdepth] + # do not select anything in the filter available = [{'utxo': utxo, 'value': val} for utxo, (addr, val) in utxos.items() if utxo not in utxo_filter] + # do not select anything disabled + available = [u for u in available if not self.is_disabled(*u['utxo'])] selector = select_fn or self.selector selected = selector(available, amount) return {s['utxo']: {'path': utxos[s['utxo']][0], 'value': utxos[s['utxo']][1]} for s in selected} - def get_balance_by_mixdepth(self, max_mixdepth=float('Inf')): + def get_balance_by_mixdepth(self, max_mixdepth=float('Inf'), + include_disabled=True): + """ By default this returns a dict of aggregated bitcoin + balance per mixdepth: {0: N sats, 1: M sats, ...} for all + currently available mixdepths. + If max_mixdepth is set it will return balances only up + to that mixdepth. + To get only enabled balance, set include_disabled=False. + """ balance_dict = collections.defaultdict(int) for mixdepth, utxomap in self._utxo.items(): if mixdepth > max_mixdepth: continue + if not include_disabled: + utxomap = {k: v for k, v in utxomap.items( + ) if not self.is_disabled(*k)} value = sum(x[1] for x in utxomap.values()) balance_dict[mixdepth] = value return balance_dict @@ -285,7 +349,7 @@ def save(self): """ Write data to associated storage object and trigger persistent update. """ - self._storage.save() + self._utxos.save() @classmethod def initialize(cls, storage, network, max_mixdepth=2, timestamp=None, @@ -610,17 +674,28 @@ def select_utxos_(self, mixdepth, amount, utxo_filter=None, return ret + def disable_utxo(self, txid, index, disable=True): + self._utxos.disable_utxo(txid, index, disable) + # make sure the utxo database is persisted + self.save() + + def toggle_disable_utxo(self, txid, index): + is_disabled = self._utxos.is_disabled(txid, index) + self.disable_utxo(txid, index, disable= not is_disabled) + def reset_utxos(self): self._utxos.reset() - def get_balance_by_mixdepth(self, verbose=True): + def get_balance_by_mixdepth(self, verbose=True, + include_disabled=False): """ Get available funds in each active mixdepth. - + By default ignores disabled utxos in calculation. returns: {mixdepth: value} """ # TODO: verbose - return self._utxos.get_balance_by_mixdepth(max_mixdepth=self.mixdepth) + return self._utxos.get_balance_by_mixdepth(max_mixdepth=self.mixdepth, + include_disabled=include_disabled) @deprecated def get_utxos_by_mixdepth(self, verbose=True): @@ -636,7 +711,7 @@ def get_utxos_by_mixdepth(self, verbose=True): utxos_conv[md][utxo_str] = data return utxos_conv - def get_utxos_by_mixdepth_(self): + def get_utxos_by_mixdepth_(self, include_disabled=False): """ Get all UTXOs for active mixdepths. @@ -651,6 +726,8 @@ def get_utxos_by_mixdepth_(self): if md > self.mixdepth: continue for utxo, (path, value) in data.items(): + if not include_disabled and self._utxos.is_disabled(*utxo): + continue script = self.get_script_path(path) script_utxos[md][utxo] = {'script': script, 'path': path, diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 96c2685ec..4f67a417e 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -18,6 +18,7 @@ LegacyWallet, SegwitWallet, is_native_segwit_mode) from jmbase.support import get_password, jmprint from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH +from .output import fmt_utxo import jmbitcoin as btc @@ -40,7 +41,8 @@ def get_wallettool_parser(): '(importprivkey) Adds privkeys to this wallet, privkeys are spaces or commas separated.\n' '(dumpprivkey) Export a single private key, specify an hd wallet path\n' '(signmessage) Sign a message with the private key from an address in \n' - 'the wallet. Use with -H and specify an HD wallet path for the address.') + 'the wallet. Use with -H and specify an HD wallet path for the address.\n' + '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.') parser = OptionParser(usage='usage: %prog [options] [wallet file] [method]', description=description) parser.add_option('-p', @@ -332,7 +334,8 @@ def get_imported_privkey_branch(wallet, m, showprivkey): addr = wallet.get_addr_path(path) script = wallet.get_script_path(path) balance = 0.0 - for data in wallet.get_utxos_by_mixdepth_()[m].values(): + for data in wallet.get_utxos_by_mixdepth_( + include_disabled=True)[m].values(): if script == data['script']: balance += data['value'] used = ('used' if balance > 0.0 else 'empty') @@ -407,7 +410,9 @@ def get_addr_status(addr_path, utxos, is_new, is_internal): return addr_balance, out_status acctlist = [] - utxos = wallet.get_utxos_by_mixdepth_() + # TODO - either optionally not show disabled utxos, or + # mark them differently in display (labels; colors) + utxos = wallet.get_utxos_by_mixdepth_(include_disabled=True) for m in range(wallet.mixdepth + 1): branchlist = [] for forchange in [0, 1]: @@ -816,12 +821,15 @@ def f(r, deposits, deposit_times, now, final_balance): jmprint('scipy not installed, unable to predict accumulation rate') jmprint('to add it to this virtualenv, use `pip install scipy`') - total_wallet_balance = sum(wallet.get_balance_by_mixdepth().values()) + # includes disabled utxos in accounting: + total_wallet_balance = sum(wallet.get_balance_by_mixdepth( + include_disabled=True).values()) if balance != total_wallet_balance: jmprint(('BUG ERROR: wallet balance (%s) does not match balance from ' + 'history (%s)') % (sat_to_str(total_wallet_balance), sat_to_str(balance))) - wallet_utxo_count = sum(map(len, wallet.get_utxos_by_mixdepth_().values())) + wallet_utxo_count = sum(map(len, wallet.get_utxos_by_mixdepth_( + include_disabled=True).values())) if utxo_count != wallet_utxo_count: jmprint(('BUG ERROR: wallet utxo count (%d) does not match utxo count from ' + 'history (%s)') % (wallet_utxo_count, utxo_count)) @@ -894,6 +902,104 @@ def wallet_signmessage(wallet, hdpath, message): retval = "Signature: {}\nTo verify this in Bitcoin Core".format(sig) return retval + " use the RPC command 'verifymessage'" +def display_utxos_for_disable_choice_default(utxos_enabled, utxos_disabled): + """ CLI implementation of the callback required as described in + wallet_disableutxo + """ + + def default_user_choice(umax): + jmprint("Choose an index 0 .. {} to freeze/unfreeze or " + "-1 to just quit.".format(umax)) + while True: + try: + ret = int(input()) + except ValueError: + jmprint("Invalid choice, must be an integer.", "error") + continue + if not isinstance(ret, int) or ret < -1 or ret > umax: + jmprint("Invalid choice, must be between: -1 and {}, " + "try again.".format(umax), "error") + continue + break + return ret + + def output_utxos(utxos, status, start=0): + for (txid, idx), v in utxos.items(): + value = v['value'] + jmprint("{:4}: {:68}: {} sats, -- {}".format( + start, fmt_utxo((txid, idx)), value, status)) + start += 1 + yield txid, idx + + ulist = list(output_utxos(utxos_disabled, 'FROZEN')) + disabled_max = len(ulist) - 1 + ulist.extend(output_utxos(utxos_enabled, 'NOT FROZEN', start=len(ulist))) + max_id = len(ulist) - 1 + chosen_idx = default_user_choice(max_id) + if chosen_idx == -1: + return None + # the return value 'disable' is the action we are going to take; + # so it should be true if the utxos is currently unfrozen/enabled. + disable = False if chosen_idx <= disabled_max else True + return ulist[chosen_idx], disable + +def get_utxos_enabled_disabled(wallet, md): + """ Returns dicts for enabled and disabled separately + """ + utxos_enabled = wallet.get_utxos_by_mixdepth_()[md] + utxos_all = wallet.get_utxos_by_mixdepth_(include_disabled=True)[md] + utxos_disabled_keyset = set(utxos_all).difference(set(utxos_enabled)) + utxos_disabled = {} + for u in utxos_disabled_keyset: + utxos_disabled[u] = utxos_all[u] + return utxos_enabled, utxos_disabled + +def wallet_freezeutxo(wallet, md, display_callback=None, info_callback=None): + """ Given a wallet and a mixdepth, display to the user + the set of available utxos, indexed by integer, and accept a choice + of index to "freeze", then commit this disabling to the wallet storage, + so that this disable-ment is persisted. Also allow unfreezing of a + chosen utxo which is currently frozen. + Callbacks for display and reporting can be specified in the keyword + arguments as explained below, otherwise default CLI is used. + + ** display_callback signature: + args: + 1. utxos_enabled ; dict of utxos as format in wallet.py. + 2. utxos_disabled ; as above, for disabled + returns: + 1.((txid(str), index(int)), disabled(bool)) of chosen utxo + for freezing/unfreezing, or None for no action/cancel. + ** info_callback signature: + args: + 1. message (str) + 2. type (str) ("info", "error" etc as per jmprint) + returns: None + """ + if display_callback is None: + display_callback = display_utxos_for_disable_choice_default + if info_callback is None: + info_callback = jmprint + if md is None: + info_callback("Specify the mixdepth with the -m flag", "error") + return "Failed" + utxos_enabled, utxos_disabled = get_utxos_enabled_disabled(wallet, md) + if utxos_disabled == {} and utxos_enabled == {}: + info_callback("The mixdepth: " + str(md) + \ + " contains no utxos to freeze/unfreeze.", "error") + return "Failed" + display_ret = display_callback(utxos_enabled, utxos_disabled) + if display_ret is None: + return "OK" + (txid, index), disable = display_ret + wallet.disable_utxo(txid, index, disable) + if disable: + info_callback("Utxo: {} is now frozen and unavailable for spending." + .format(fmt_utxo((txid, index)))) + else: + info_callback("Utxo: {} is now unfrozen and available for spending." + .format(fmt_utxo((txid, index)))) + return "Done" def get_wallet_type(): if is_segwit_mode(): @@ -1047,7 +1153,7 @@ def wallet_tool_main(wallet_root_path): noseed_methods = ['generate', 'recover'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', - 'history', 'showutxos'] + 'history', 'showutxos', 'freeze'] methods.extend(noseed_methods) noscan_methods = ['showseed', 'importprivkey', 'dumpprivkey', 'signmessage'] readonly_methods = ['display', 'displayall', 'summary', 'showseed', @@ -1120,6 +1226,8 @@ def wallet_tool_main(wallet_root_path): return "Key import completed." elif method == "signmessage": return wallet_signmessage(wallet, options.hd_path, args[2]) + elif method == "freeze": + return wallet_freezeutxo(wallet, options.mixdepth) #Testing (can port to test modules, TODO) diff --git a/jmclient/test/test_utxomanager.py b/jmclient/test/test_utxomanager.py index 09de34c24..b9bdecf4a 100644 --- a/jmclient/test/test_utxomanager.py +++ b/jmclient/test/test_utxomanager.py @@ -16,6 +16,12 @@ def select(unspent, value): def test_utxomanager_persist(setup_env_nodeps): + """ Tests that the utxo manager's data is correctly + persisted and can be recreated from storage. + This persistence is currently only used for metadata + (specifically, disabling coins for coin control). + """ + storage = MockStorage(None, 'wallet.jmdat', None, create=True) UTXOManager.initialize(storage) um = UTXOManager(storage, select) @@ -28,27 +34,45 @@ def test_utxomanager_persist(setup_env_nodeps): um.add_utxo(txid, index, path, value, mixdepth) um.add_utxo(txid, index+1, path, value, mixdepth+1) - + # the third utxo will be disabled and we'll check if + # the disablement persists in the storage across UM instances + um.add_utxo(txid, index+2, path, value, mixdepth+1) + um.disable_utxo(txid, index+2) um.save() + + # Remove and recreate the UM from the same storage. + del um um = UTXOManager(storage, select) assert um.have_utxo(txid, index) == mixdepth assert um.have_utxo(txid, index+1) == mixdepth + 1 - assert um.have_utxo(txid, index+2) == False + # The third should not be registered as present given flag: + assert um.have_utxo(txid, index+2, include_disabled=False) == False + # check is_disabled works: + assert not um.is_disabled(txid, index) + assert not um.is_disabled(txid, index+1) + assert um.is_disabled(txid, index+2) + # check re-enabling works + um.enable_utxo(txid, index+2) + assert not um.is_disabled(txid, index+2) + um.disable_utxo(txid, index+2) utxos = um.get_utxos_by_mixdepth() assert len(utxos[mixdepth]) == 1 - assert len(utxos[mixdepth+1]) == 1 + assert len(utxos[mixdepth+1]) == 2 assert len(utxos[mixdepth+2]) == 0 balances = um.get_balance_by_mixdepth() assert balances[mixdepth] == value - assert balances[mixdepth+1] == value + assert balances[mixdepth+1] == value * 2 um.remove_utxo(txid, index, mixdepth) assert um.have_utxo(txid, index) == False + # check that removing a utxo does not remove the metadata + um.remove_utxo(txid, index+2, mixdepth+1) + assert um.is_disabled(txid, index+2) um.save() del um @@ -87,6 +111,12 @@ def test_utxomanager_select(setup_env_nodeps): um.add_utxo(txid, index+1, path, value, mixdepth) assert len(um.select_utxos(mixdepth, value)) == 2 + # ensure that added utxos that are disabled do not + # get used by the selector + um.add_utxo(txid, index+2, path, value, mixdepth) + um.disable_utxo(txid, index+2) + assert len(um.select_utxos(mixdepth, value)) == 2 + @pytest.fixture def setup_env_nodeps(monkeypatch): diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index c6a2a3182..ee41bdfd3 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -333,6 +333,22 @@ def test_signing_simple(setup_wallet, wallet_cls, type_check): txout = jm_single().bc_interface.pushtx(btc.serialize(tx)) assert txout +def test_get_bbm(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + amount = 10**8 + num_tx = 3 + wallet = get_populated_wallet(amount, num_tx) + # disable a utxo and check we can correctly report + # balance with the disabled flag off: + utxo_1 = list(wallet._utxos.get_utxos_by_mixdepth()[0].keys())[0] + wallet.disable_utxo(*utxo_1) + balances = wallet.get_balance_by_mixdepth(include_disabled=True) + assert balances[0] == num_tx * amount + balances = wallet.get_balance_by_mixdepth() + assert balances[0] == (num_tx - 1) * amount + wallet.toggle_disable_utxo(*utxo_1) + balances = wallet.get_balance_by_mixdepth() + assert balances[0] == num_tx * amount def test_add_utxos(setup_wallet): jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') diff --git a/scripts/add-utxo.py b/scripts/add-utxo.py index 5e0f0d322..d61ad6f1e 100644 --- a/scripts/add-utxo.py +++ b/scripts/add-utxo.py @@ -182,6 +182,9 @@ def main(): while not jm_single().bc_interface.wallet_synced: sync_wallet(wallet, fast=options.fastsync) + # minor note: adding a utxo from an external wallet for commitments, we + # default to not allowing disabled utxos to avoid a privacy leak, so the + # user would have to explicitly enable. for md, utxos in wallet.get_utxos_by_mixdepth_().items(): for (txid, index), utxo in utxos.items(): txhex = binascii.hexlify(txid).decode('ascii') + ':' + str(index) diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 3ac15f91e..eba00b5b6 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -72,7 +72,7 @@ get_blockchain_interface_instance, direct_send,\ RegtestBitcoinCoreInterface, tumbler_taker_finished_update,\ get_tumble_log, restart_wait, tumbler_filter_orders_callback,\ - wallet_generate_recover_bip39, wallet_display + wallet_generate_recover_bip39, wallet_display, get_utxos_enabled_disabled from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ @@ -999,6 +999,106 @@ def create_menu(self, position): ','.join([str(item.text(_)) for _ in range(4)]))) menu.exec_(self.tHTW.viewport().mapToGlobal(position)) +class CoinsTab(QWidget): + + def __init__(self): + super(CoinsTab, self).__init__() + self.initUI() + + def initUI(self): + self.cTW = MyTreeWidget(self, self.create_menu, self.getHeaders()) + self.cTW.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.cTW.header().setSectionResizeMode(QHeaderView.Interactive) + self.cTW.header().setStretchLastSection(False) + self.cTW.on_update = self.updateUtxos + + vbox = QVBoxLayout() + self.setLayout(vbox) + vbox.setContentsMargins(0,0,0,0) + vbox.setSpacing(0) + vbox.addWidget(self.cTW) + self.updateUtxos() + self.show() + + def getHeaders(self): + '''Function included in case dynamic in future''' + return ['Txid:n', 'Amount in BTC', 'Address'] + + def updateUtxos(self): + """ Note that this refresh of the display only accesses in-process + utxo database (no sync e.g.) so can be immediate. + """ + self.cTW.clear() + def show_blank(): + m_item = QTreeWidgetItem(["No coins", "", ""]) + self.cTW.addChild(m_item) + self.show() + + if not w.wallet: + show_blank() + return + utxos_enabled = {} + utxos_disabled = {} + for i in range(jm_single().config.getint("GUI", "max_mix_depth")): + utxos_e, utxos_d = get_utxos_enabled_disabled(w.wallet, i) + if utxos_e != {}: + utxos_enabled[i] = utxos_e + if utxos_d != {}: + utxos_disabled[i] = utxos_d + if utxos_enabled == {} and utxos_disabled == {}: + show_blank() + return + + for i in range(jm_single().config.getint("GUI", "max_mix_depth")): + uem = utxos_enabled.get(i) + udm = utxos_disabled.get(i) + m_item = QTreeWidgetItem(["Mixdepth " + str(i), '', '']) + self.cTW.addChild(m_item) + for heading in ["NOT FROZEN", "FROZEN"]: + um = uem if heading == "NOT FROZEN" else udm + seq_item = QTreeWidgetItem([heading, '', '']) + m_item.addChild(seq_item) + seq_item.setExpanded(True) + if um is None: + item = QTreeWidgetItem(['None', '', '']) + seq_item.addChild(item) + else: + for k, v in um.items(): + # txid:index, btc, address + t = btc.safe_hexlify(k[0])+":"+str(k[1]) + s = "{0:.08f}".format(v['value']/1e8) + a = w.wallet.script_to_addr(v["script"]) + item = QTreeWidgetItem([t, s, a]) + item.setFont(0, QFont(MONOSPACE_FONT)) + #if rows[i][forchange][j][3] != 'new': + # item.setForeground(3, QBrush(QColor('red'))) + seq_item.addChild(item) + self.show() + + def toggle_utxo_disable(self, txid, idx): + txid_bytes = btc.safe_from_hex(txid) + w.wallet.toggle_disable_utxo(txid_bytes, idx) + self.updateUtxos() + + def create_menu(self, position): + item = self.cTW.currentItem() + if not item: + return + try: + txidn = item.text(0) + txid, idx = txidn.split(":") + assert len(txid) == 64 + idx = int(idx) + assert idx >= 0 + except: + return + + menu = QMenu() + menu.addAction("Freeze/un-freeze utxo (toggle)", + lambda: self.toggle_utxo_disable(txid, idx)) + menu.addAction("Copy transaction id to clipboard", + lambda: app.clipboard().setText(txid)) + menu.exec_(self.cTW.viewport().mapToGlobal(position)) class JMWalletTab(QWidget): @@ -1613,6 +1713,15 @@ def get_wallet_printout(wallet): update_config_for_gui() +def onTabChange(i): + """ Respond to change of tab. + """ + # TODO: hardcoded literal; + # note that this is needed for an auto-update + # of utxos on the Coins tab only atm. + if i == 4: + tabWidget.widget(4).updateUtxos() + #to allow testing of confirm/unconfirm callback for multiple txs if isinstance(jm_single().bc_interface, RegtestBitcoinCoreInterface): jm_single().bc_interface.tick_forward_chain_interval = 10 @@ -1639,11 +1748,14 @@ def get_wallet_printout(wallet): tabWidget.addTab(settingsTab, "Settings") tabWidget.addTab(SpendTab(), "Coinjoins") tabWidget.addTab(TxHistoryTab(), "Tx History") +tabWidget.addTab(CoinsTab(), "Coins") + w.resize(600, 500) suffix = ' - Testnet' if get_network() == 'testnet' else '' w.setWindowTitle(appWindowTitle + suffix) tabWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) w.setCentralWidget(tabWidget) +tabWidget.currentChanged.connect(onTabChange) w.show() reactor.runReturn() sys.exit(app.exec_()) From bfdf0b29b3fa277d4af2cb4485b01f7ee0c1d9ad Mon Sep 17 00:00:00 2001 From: AdamISZ Date: Fri, 29 Mar 2019 17:39:18 +0100 Subject: [PATCH 2/2] expand non-empty tree sections in Coins tab --- scripts/joinmarket-qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index eba00b5b6..db35ed41b 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -1032,7 +1032,7 @@ def updateUtxos(self): def show_blank(): m_item = QTreeWidgetItem(["No coins", "", ""]) self.cTW.addChild(m_item) - self.show() + self.cTW.show() if not w.wallet: show_blank() @@ -1073,7 +1073,7 @@ def show_blank(): #if rows[i][forchange][j][3] != 'new': # item.setForeground(3, QBrush(QColor('red'))) seq_item.addChild(item) - self.show() + m_item.setExpanded(True) def toggle_utxo_disable(self, txid, idx): txid_bytes = btc.safe_from_hex(txid)