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 335e5cfe4..f6e6eddef 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -73,7 +73,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,\ @@ -1000,6 +1000,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.cTW.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) + m_item.setExpanded(True) + + 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): @@ -1618,6 +1718,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 @@ -1644,11 +1753,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_())