diff --git a/README.md b/README.md index 757da35..2b2f5ce 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ For a quick introduction to Joinmarket you can watch [this demonstration](https: * GUI to support Taker role, including tumbler/automated coinjoin sequence. * PayJoin - [BIP78](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) to pay users of other wallets (e.g. merchants), as well as between two compatible wallet users (Joinmarket, Wasabi, others). This is a way to boost fungibility/privacy while paying. * Protection from [forced address reuse](https://en.bitcoin.it/wiki/Privacy#Forced_address_reuse) attacks. +* Address labeling ### Quickstart - RECOMMENDED INSTALLATION METHOD (Linux and macOS only) diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 1a900fa..a07db96 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -15,7 +15,8 @@ from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportW BIP39WalletMixin, BIP32Wallet, BIP49Wallet, LegacyWallet, SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, SegwitWalletFidelityBonds, - UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime) + UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime, + UnknownAddressForLabel) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) from .cryptoengine import (BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH, EngineError, diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index c77e439..4c3597a 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -42,6 +42,12 @@ class WalletError(Exception): pass +class UnknownAddressForLabel(Exception): + + def __init__(self, addr): + super().__init__('Unknown address for this wallet: ' + addr) + + class Mnemonic(MnemonicParent): @classmethod def detect_language(cls, code): @@ -276,6 +282,43 @@ class UTXOManager(object): self.selector is o.selector +class AddressLabelsManager(object): + STORAGE_KEY = b'addr_labels' + + def __init__(self, storage): + self.storage = storage + self._addr_labels = None + self._load_storage() + assert self._addr_labels is not None + + @classmethod + def initialize(cls, storage): + storage.data[cls.STORAGE_KEY] = {} + + def _load_storage(self): + if self.STORAGE_KEY in self.storage.data: + self._addr_labels = self.storage.data[self.STORAGE_KEY] + else: + self._addr_labels = {} + + def save(self): + self.storage.data[self.STORAGE_KEY] = self._addr_labels + + def get_label(self, address): + address = bytes(address, 'ascii') + if address in self._addr_labels: + return self._addr_labels[address].decode() + else: + return None + + def set_label(self, address, label): + address = bytes(address, 'ascii') + if label: + self._addr_labels[address] = bytes(label, 'utf-8') + elif address in self._addr_labels: + del self._addr_labels[address] + + class BaseWallet(object): TYPE = None @@ -303,6 +346,7 @@ class BaseWallet(object): self.gap_limit = gap_limit self._storage = storage self._utxos = None + self._addr_labels = None # highest mixdepth ever used in wallet, important for synching self.max_mixdepth = None # effective maximum mixdepth to be used by joinmarket @@ -350,6 +394,7 @@ class BaseWallet(object): .format(self.TYPE)) self.network = self._storage.data[b'network'].decode('ascii') self._utxos = UTXOManager(self._storage, self.merge_algorithm) + self._addr_labels = AddressLabelsManager(self._storage) def get_storage_location(self): """ Return the location of the @@ -364,6 +409,7 @@ class BaseWallet(object): Write data to associated storage object and trigger persistent update. """ self._utxos.save() + self._addr_labels.save() @classmethod def initialize(cls, storage, network, max_mixdepth=2, timestamp=None, @@ -395,6 +441,7 @@ class BaseWallet(object): storage.data[b'wallet_type'] = cls.TYPE UTXOManager.initialize(storage) + AddressLabelsManager.initialize(storage) if write: storage.save() @@ -784,10 +831,12 @@ class BaseWallet(object): continue script = self.get_script_from_path(path) addr = self.get_address_from_path(path) + label = self.get_address_label(addr) script_utxos[md][utxo] = {'script': script, 'path': path, 'value': value, - 'address': addr} + 'address': addr, + 'label': label} if includeheight: script_utxos[md][utxo]['height'] = height return script_utxos @@ -1048,12 +1097,26 @@ class BaseWallet(object): return False return True + def set_address_label(self, addr, label): + if self.is_known_addr(addr): + self._addr_labels.set_label(addr, label) + self.save() + else: + raise UnknownAddressForLabel(addr) + + def get_address_label(self, addr): + if self.is_known_addr(addr): + return self._addr_labels.get_label(addr) + else: + raise UnknownAddressForLabel(addr) + def close(self): self._storage.close() def __del__(self): self.close() + class PSBTWalletMixin(object): """ Mixin for BaseWallet to provide BIP174 diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 649541f..6122ad5 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -52,6 +52,7 @@ The method is one of the following: (addtxoutproof) Add a tx out proof as metadata to a burner transaction. Specify path with -H and proof which is output of Bitcoin Core\'s RPC call gettxoutproof. (createwatchonly) Create a watch-only fidelity bond wallet. +(setlabel) Set the label associated with the given address. """ parser = OptionParser(usage='usage: %prog [options] [wallet file] [method] [args..]', description=description, formatter=IndentedHelpFormatterWithNL()) @@ -146,7 +147,8 @@ class WalletViewBase(object): class WalletViewEntry(WalletViewBase): def __init__(self, wallet_path_repr, account, address_type, aindex, addr, amounts, - used = 'new', serclass=str, priv=None, custom_separator=None): + used = 'new', serclass=str, priv=None, custom_separator=None, + label=None): super().__init__(wallet_path_repr, serclass=serclass, custom_separator=custom_separator) self.account = account @@ -162,6 +164,7 @@ class WalletViewEntry(WalletViewBase): #note no validation here self.private_key = priv self.used = used + self.label = label def get_balance(self, include_unconf=True): """Overwrites base class since no children @@ -174,8 +177,11 @@ class WalletViewEntry(WalletViewBase): left = self.serialize_wallet_position() addr = self.serialize_address() amounts = self.serialize_amounts() + used = self.serialize_used() + label = self.serialize_label() extradata = self.serialize_extra_data() - return self.serclass(self.separator.join([left, addr, amounts, extradata])) + return self.serclass(self.separator.join([ + left, addr, amounts, used, label, extradata])) def serialize_wallet_position(self): return self.wallet_path_repr.ljust(20) @@ -191,11 +197,20 @@ class WalletViewEntry(WalletViewBase): "not yet implemented.") return self.serclass("{0:.08f}".format(self.unconfirmed_amount/1e8)) + def serialize_used(self): + return self.serclass(self.used) + + def serialize_label(self): + if self.label: + return self.serclass(self.label) + else: + return self.serclass("") + def serialize_extra_data(self): - ed = self.used if self.private_key: - ed += self.separator + self.serclass(self.private_key) - return self.serclass(ed) + return self.serclass(self.private_key) + else: + return self.serclass("") class WalletViewEntryBurnOutput(WalletViewEntry): # balance in burn outputs shouldnt be counted @@ -368,7 +383,9 @@ def wallet_showutxos(wallet_service, showprivkey): tries = podle.get_podle_tries(u, key, max_tries) tries_remaining = max(0, max_tries - tries) mixdepth = wallet_service.wallet.get_details(av['path'])[0] - unsp[us] = {'address': av['address'], 'value': av['value'], + unsp[us] = {'address': av['address'], + 'label': av['label'] if av['label'] else "", + 'value': av['value'], 'tries': tries, 'tries_remaining': tries_remaining, 'external': False, 'mixdepth': mixdepth, @@ -389,7 +406,7 @@ def wallet_showutxos(wallet_service, showprivkey): unsp[us] = {'tries': tries, 'tries_remaining': tries_remaining, 'external': True} - return json.dumps(unsp, indent=4) + return json.dumps(unsp, indent=4, ensure_ascii=False) def wallet_display(wallet_service, showprivkey, displayall=False, @@ -453,6 +470,7 @@ def wallet_display(wallet_service, showprivkey, displayall=False, for k in range(unused_index + wallet_service.gap_limit): path = wallet_service.get_path(m, address_type, k) addr = wallet_service.get_address_from_path(path) + label = wallet_service.get_address_label(addr) balance, used = get_addr_status( path, utxos[m], k >= unused_index, address_type) if showprivkey: @@ -463,7 +481,7 @@ def wallet_display(wallet_service, showprivkey, displayall=False, (used == 'new' and address_type == 0)): entrylist.append(WalletViewEntry( wallet_service.get_path_repr(path), m, address_type, k, addr, - [balance, balance], priv=privkey, used=used)) + [balance, balance], priv=privkey, used=used, label=label)) wallet_service.set_next_index(m, address_type, unused_index) path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) branchlist.append(WalletViewBranch(path, m, address_type, entrylist, @@ -476,6 +494,7 @@ def wallet_display(wallet_service, showprivkey, displayall=False, for timenumber in range(FidelityBondMixin.TIMENUMBER_COUNT): path = wallet_service.get_path(m, address_type, timenumber) addr = wallet_service.get_address_from_path(path) + label = wallet_service.get_address_label(addr) timelock = datetime.utcfromtimestamp(0) + timedelta(seconds=path[-1]) balance = sum([utxodata["value"] for utxo, utxodata in @@ -488,7 +507,8 @@ def wallet_display(wallet_service, showprivkey, displayall=False, if displayall or balance > 0: entrylist.append(WalletViewEntry( wallet_service.get_path_repr(path), m, address_type, k, - addr, [balance, balance], priv=privkey, used=status)) + addr, [balance, balance], priv=privkey, used=status, + label=label)) xpub_key = wallet_service.get_bip32_pub_export(m, address_type) path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) branchlist.append(WalletViewBranch(path, m, address_type, entrylist, @@ -1459,7 +1479,7 @@ def wallet_tool_main(wallet_root_path): noseed_methods = ['generate', 'recover', 'createwatchonly'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', 'history', 'showutxos', 'freeze', 'gettimelockaddress', - 'addtxoutproof', 'changepass'] + 'addtxoutproof', 'changepass', 'setlabel'] methods.extend(noseed_methods) noscan_methods = ['showseed', 'importprivkey', 'dumpprivkey', 'signmessage', 'changepass'] @@ -1584,6 +1604,15 @@ def wallet_tool_main(wallet_root_path): jmprint("args: [master public key]", "error") sys.exit(EXIT_ARGERROR) return wallet_createwatchonly(wallet_root_path, args[1]) + elif method == "setlabel": + if len(args) < 4: + jmprint("args: address label", "error") + sys.exit(EXIT_ARGERROR) + wallet.set_address_label(args[2], args[3]) + if args[3]: + return "Address label set" + else: + return "Address label removed" else: parser.error("Unknown wallet-tool method: " + method) sys.exit(EXIT_ARGERROR) diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index 5e36583..255d3d2 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -13,9 +13,12 @@ from jmclient import load_test_config, jm_single, BaseWallet, \ VolatileStorage, get_network, cryptoengine, WalletError,\ SegwitWallet, WalletService, SegwitWalletFidelityBonds,\ create_wallet, open_test_wallet_maybe, \ - FidelityBondMixin, FidelityBondWatchonlyWallet, wallet_gettimelockaddress + FidelityBondMixin, FidelityBondWatchonlyWallet,\ + wallet_gettimelockaddress, UnknownAddressForLabel from test_blockchaininterface import sync_test_wallet from freezegun import freeze_time +from bitcointx.wallet import CCoinAddressError + testdir = os.path.dirname(os.path.realpath(__file__)) @@ -557,6 +560,30 @@ def test_remove_old_utxos(setup_wallet): assert len(balances) == wallet.max_mixdepth + 1 +def test_address_labels(setup_wallet): + wallet = get_populated_wallet(num=2) + addr1 = wallet.get_internal_addr(0) + addr2 = wallet.get_internal_addr(1) + assert wallet.get_address_label(addr2) is None + assert wallet.get_address_label(addr2) is None + wallet.set_address_label(addr1, "test") + # utf-8 characters here are on purpose, to test utf-8 encoding / decoding + wallet.set_address_label(addr2, "glāžšķūņu rūķīši") + assert wallet.get_address_label(addr1) == "test" + assert wallet.get_address_label(addr2) == "glāžšķūņu rūķīši" + wallet.set_address_label(addr1, "") + wallet.set_address_label(addr2, None) + assert wallet.get_address_label(addr2) is None + assert wallet.get_address_label(addr2) is None + with pytest.raises(UnknownAddressForLabel): + wallet.get_address_label("2MzY5yyonUY7zpHspg7jB7WQs1uJxKafQe4") + wallet.set_address_label("2MzY5yyonUY7zpHspg7jB7WQs1uJxKafQe4", + "test") + with pytest.raises(CCoinAddressError): + wallet.get_address_label("badaddress") + wallet.set_address_label("badaddress", "test") + + def test_initialize_twice(setup_wallet): wallet = get_populated_wallet(num=0) storage = wallet._storage diff --git a/jmclient/test/test_walletutils.py b/jmclient/test/test_walletutils.py index 0329c96..cf9ac3a 100644 --- a/jmclient/test/test_walletutils.py +++ b/jmclient/test/test_walletutils.py @@ -64,44 +64,44 @@ def test_walletview(): 'JM wallet\n' 'mixdepth\t0\n' 'external addresses\tm/0\txpubDUMMYXPUB0\n' - 'm/0 \tDUMMYADDRESS0\t0.00000000\tnew\n' - 'm/0 \tDUMMYADDRESS1\t0.10000000\tnew\n' - 'm/0 \tDUMMYADDRESS2\t0.20000000\tnew\n' - 'm/0 \tDUMMYADDRESS3\t0.30000000\tnew\n' + 'm/0 \tDUMMYADDRESS0\t0.00000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS1\t0.10000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS2\t0.20000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS3\t0.30000000\tnew\t\t\n' 'Balance:\t0.60000000\n' 'internal addresses\tm/0\txpubDUMMYXPUB1\n' - 'm/0 \tDUMMYADDRESS0\t0.00000000\tnew\n' - 'm/0 \tDUMMYADDRESS1\t0.10000000\tnew\n' - 'm/0 \tDUMMYADDRESS2\t0.20000000\tnew\n' - 'm/0 \tDUMMYADDRESS3\t0.30000000\tnew\n' + 'm/0 \tDUMMYADDRESS0\t0.00000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS1\t0.10000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS2\t0.20000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS3\t0.30000000\tnew\t\t\n' 'Balance:\t0.60000000\n' 'Balance for mixdepth 0:\t1.20000000\n' 'mixdepth\t1\n' 'external addresses\tm/0\txpubDUMMYXPUB1\n' - 'm/0 \tDUMMYADDRESS1\t0.00000000\tnew\n' - 'm/0 \tDUMMYADDRESS2\t0.10000000\tnew\n' - 'm/0 \tDUMMYADDRESS3\t0.20000000\tnew\n' - 'm/0 \tDUMMYADDRESS4\t0.30000000\tnew\n' + 'm/0 \tDUMMYADDRESS1\t0.00000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS2\t0.10000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS3\t0.20000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS4\t0.30000000\tnew\t\t\n' 'Balance:\t0.60000000\n' 'internal addresses\tm/0\txpubDUMMYXPUB2\n' - 'm/0 \tDUMMYADDRESS1\t0.00000000\tnew\n' - 'm/0 \tDUMMYADDRESS2\t0.10000000\tnew\n' - 'm/0 \tDUMMYADDRESS3\t0.20000000\tnew\n' - 'm/0 \tDUMMYADDRESS4\t0.30000000\tnew\n' + 'm/0 \tDUMMYADDRESS1\t0.00000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS2\t0.10000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS3\t0.20000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS4\t0.30000000\tnew\t\t\n' 'Balance:\t0.60000000\n' 'Balance for mixdepth 1:\t1.20000000\n' 'mixdepth\t2\n' 'external addresses\tm/0\txpubDUMMYXPUB2\n' - 'm/0 \tDUMMYADDRESS2\t0.00000000\tnew\n' - 'm/0 \tDUMMYADDRESS3\t0.10000000\tnew\n' - 'm/0 \tDUMMYADDRESS4\t0.20000000\tnew\n' - 'm/0 \tDUMMYADDRESS5\t0.30000000\tnew\n' + 'm/0 \tDUMMYADDRESS2\t0.00000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS3\t0.10000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS4\t0.20000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS5\t0.30000000\tnew\t\t\n' 'Balance:\t0.60000000\n' 'internal addresses\tm/0\txpubDUMMYXPUB3\n' - 'm/0 \tDUMMYADDRESS2\t0.00000000\tnew\n' - 'm/0 \tDUMMYADDRESS3\t0.10000000\tnew\n' - 'm/0 \tDUMMYADDRESS4\t0.20000000\tnew\n' - 'm/0 \tDUMMYADDRESS5\t0.30000000\tnew\n' + 'm/0 \tDUMMYADDRESS2\t0.00000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS3\t0.10000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS4\t0.20000000\tnew\t\t\n' + 'm/0 \tDUMMYADDRESS5\t0.30000000\tnew\t\t\n' 'Balance:\t0.60000000\n' 'Balance for mixdepth 2:\t1.20000000\n' 'Total balance:\t3.60000000')) diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 7f26c05..6709e0e 100755 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -1305,7 +1305,7 @@ class CoinsTab(QWidget): def getHeaders(self): '''Function included in case dynamic in future''' - return ['Txid:n', 'Amount in BTC', 'Address'] + return ['Txid:n', 'Amount in BTC', 'Address', 'Label'] def updateUtxos(self): """ Note that this refresh of the display only accesses in-process @@ -1313,7 +1313,7 @@ class CoinsTab(QWidget): """ self.cTW.clear() def show_blank(): - m_item = QTreeWidgetItem(["No coins", "", ""]) + m_item = QTreeWidgetItem(["No coins", "", "", ""]) self.cTW.addChild(m_item) self.cTW.show() @@ -1335,15 +1335,15 @@ class CoinsTab(QWidget): 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), '', '']) + 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, '', '']) + seq_item = QTreeWidgetItem([heading, '', '', '']) m_item.addChild(seq_item) seq_item.setExpanded(True) if um is None: - item = QTreeWidgetItem(['None', '', '']) + item = QTreeWidgetItem(['None', '', '', '']) seq_item.addChild(item) else: for k, v in um.items(): @@ -1353,7 +1353,7 @@ class CoinsTab(QWidget): assert success s = "{0:.08f}".format(v['value']/1e8) a = mainWindow.wallet_service.script_to_addr(v["script"]) - item = QTreeWidgetItem([t, s, a]) + item = QTreeWidgetItem([t, s, a, v["label"]]) item.setFont(0, QFont(MONOSPACE_FONT)) #if rows[i][forchange][j][3] != 'new': # item.setForeground(3, QBrush(QColor('red'))) @@ -1430,7 +1430,7 @@ class JMWalletTab(QWidget): def getHeaders(self): '''Function included in case dynamic in future''' - return ['Address', 'Index', 'Balance', 'Used/New'] + return ['Address', 'Index', 'Balance', 'Used/New', 'Label'] def create_menu(self, position): item = self.walletTree.currentItem() @@ -1451,6 +1451,9 @@ class JMWalletTab(QWidget): menu.addAction("Copy address to clipboard", lambda: app.clipboard().setText(txt), shortcut=QKeySequence(QKeySequence.Copy)) + if item.text(4): + menu.addAction("Copy label to clipboard", + lambda: app.clipboard().setText(item.text(4))) # Show QR code option only for new addresses to avoid address reuse if item.text(3) == "new": menu.addAction("Show QR code", @@ -2242,7 +2245,7 @@ def get_wallet_printout(wallet_service): We retrieve a WalletView abstraction, and iterate over sub-objects to arrange the per-mixdepth and per-address lists. The format of the returned data is: - rows: is of format [[[addr,index,bal,used],[addr,...]]*5, + rows: is of format [[[addr,index,bal,used,label],[addr,...]]*5, [[addr, index,..], [addr, index..]]*5] mbalances: is a simple array of 5 mixdepth balances xpubs: [[xpubext, xpubint], ...] @@ -2263,7 +2266,8 @@ def get_wallet_printout(wallet_service): rows[-1][i].append([entry.serialize_address(), entry.serialize_wallet_position(), entry.serialize_amounts(), - entry.serialize_extra_data()]) + entry.serialize_used(), + entry.serialize_label()]) # in case the wallet is not yet synced, don't return an incorrect # 0 balance, but signal incompleteness: total_bal = walletview.get_fmt_balance() if wallet_service.synced else None