Browse Source

Implement address labeling

master
Kristaps Kaupe 4 years ago
parent
commit
21c0e3e758
No known key found for this signature in database
GPG Key ID: 33E472FE870C7E5D
  1. 1
      README.md
  2. 3
      jmclient/jmclient/__init__.py
  3. 65
      jmclient/jmclient/wallet.py
  4. 49
      jmclient/jmclient/wallet_utils.py
  5. 29
      jmclient/test/test_wallet.py
  6. 48
      jmclient/test/test_walletutils.py
  7. 22
      scripts/joinmarket-qt.py

1
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)

3
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,

65
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

49
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)

29
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

48
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'))

22
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

Loading…
Cancel
Save