From 53b056e5db1eca21f9a433bf3293a3de3c03dbd8 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Tue, 21 Jan 2020 01:34:51 +0000 Subject: [PATCH 01/16] Rename variable internal to address_type Previously an example of a BIP32 path would be: m/wallet-type'/mixdepth'/internal/index The 'internal' name referred to internal and external addresses (also called change and receive). The renaming to 'address_type' is in preparation to add more branches for timelocked addresses and burner outputs. The variable formally known as 'internal' is now no longer a boolean but always an integer. This almost-always seemlessly fits because the values False and Ture correspond to 0 and 1. The function _get_internal_type therefore has no purpose anymore. Delete it. --- jmclient/jmclient/wallet.py | 170 ++++++++++++++-------------- jmclient/jmclient/wallet_service.py | 20 ++-- jmclient/jmclient/wallet_utils.py | 47 ++++---- jmclient/test/test_wallet.py | 2 +- 4 files changed, 121 insertions(+), 118 deletions(-) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 7385aa1..3778c4a 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -299,6 +299,9 @@ class BaseWallet(object): _ENGINE = None + ADDRESS_TYPE_EXTERNAL = 0 + ADDRESS_TYPE_INTERNAL = 1 + def __init__(self, storage, gap_limit=6, merge_algorithm_name=None, mixdepth=None): # to be defined by inheriting classes @@ -446,9 +449,13 @@ class BaseWallet(object): privkey = self._get_priv_from_path(path)[0] return hexlify(privkey).decode('ascii') - def _get_addr_int_ext(self, internal, mixdepth): - script = self.get_internal_script(mixdepth) if internal else \ - self.get_external_script(mixdepth) + def _get_addr_int_ext(self, address_type, mixdepth): + if address_type == self.ADDRESS_TYPE_EXTERNAL: + script = self.get_external_script(mixdepth) + elif address_type == self.ADDRESS_TYPE_INTERNAL: + script = self.get_internal_script(mixdepth) + else: + assert 0 return self.script_to_addr(script) def get_external_addr(self, mixdepth): @@ -457,20 +464,20 @@ class BaseWallet(object): the wallet from other sources, or receiving payments or donations. JoinMarket will never generate these addresses for internal use. """ - return self._get_addr_int_ext(False, mixdepth) + return self._get_addr_int_ext(self.ADDRESS_TYPE_EXTERNAL, mixdepth) def get_internal_addr(self, mixdepth): """ Return an address for internal usage, as change addresses and when participating in transactions initiated by other parties. """ - return self._get_addr_int_ext(True, mixdepth) + return self._get_addr_int_ext(self.ADDRESS_TYPE_INTERNAL, mixdepth) def get_external_script(self, mixdepth): - return self.get_new_script(mixdepth, False) + return self.get_new_script(mixdepth, self.ADDRESS_TYPE_EXTERNAL) def get_internal_script(self, mixdepth): - return self.get_new_script(mixdepth, True) + return self.get_new_script(mixdepth, self.ADDRESS_TYPE_INTERNAL) @classmethod def addr_to_script(cls, addr): @@ -512,40 +519,40 @@ class BaseWallet(object): return cls._ENGINE.pubkey_has_script(pubkey, script) @deprecated - def get_key(self, mixdepth, internal, index): + def get_key(self, mixdepth, address_type, index): raise NotImplementedError() - def get_addr(self, mixdepth, internal, index): - script = self.get_script(mixdepth, internal, index) + def get_addr(self, mixdepth, address_type, index): + script = self.get_script(mixdepth, address_type, index) return self.script_to_addr(script) def get_address_from_path(self, path): script = self.get_script_from_path(path) return self.script_to_addr(script) - def get_new_addr(self, mixdepth, internal): + def get_new_addr(self, mixdepth, address_type): """ use get_external_addr/get_internal_addr """ - script = self.get_new_script(mixdepth, internal) + script = self.get_new_script(mixdepth, address_type) return self.script_to_addr(script) - def get_new_script(self, mixdepth, internal): + def get_new_script(self, mixdepth, address_type): raise NotImplementedError() - def get_wif(self, mixdepth, internal, index): - return self.get_wif_path(self.get_path(mixdepth, internal, index)) + def get_wif(self, mixdepth, address_type, index): + return self.get_wif_path(self.get_path(mixdepth, address_type, index)) def get_wif_path(self, path): priv, engine = self._get_priv_from_path(path) return engine.privkey_to_wif(priv) - def get_path(self, mixdepth=None, internal=None, index=None): + def get_path(self, mixdepth=None, address_type=None, index=None): raise NotImplementedError() def get_details(self, path): """ - Return mixdepth, internal, index for a given path + Return mixdepth, address_type, index for a given path args: path: wallet path @@ -814,8 +821,8 @@ class BaseWallet(object): """ raise NotImplementedError() - def get_script(self, mixdepth, internal, index): - path = self.get_path(mixdepth, internal, index) + def get_script(self, mixdepth, address_type, index): + path = self.get_path(mixdepth, address_type, index) return self.get_script_from_path(path) def _get_priv_from_path(self, path): @@ -843,7 +850,7 @@ class BaseWallet(object): """ raise NotImplementedError() - def get_next_unused_index(self, mixdepth, internal): + def get_next_unused_index(self, mixdepth, address_type): """ Get the next index for public scripts/addresses not yet handed out. @@ -952,13 +959,13 @@ class BaseWallet(object): assert script in self._script_map return self._script_map[script] - def set_next_index(self, mixdepth, internal, index, force=False): + def set_next_index(self, mixdepth, address_type, index, force=False): """ Set the next index to use when generating a new key pair. params: mixdepth: int - internal: 0/False or 1/True + address_type: 0 (external) or 1 (internal) index: int force: True if you know the wallet already knows all scripts up to (excluding) the given index @@ -969,10 +976,11 @@ class BaseWallet(object): def rewind_wallet_indices(self, used_indices, saved_indices): for md in used_indices: - for int_type in (0, 1): - index = max(used_indices[md][int_type], - saved_indices[md][int_type]) - self.set_next_index(md, int_type, index, force=True) + for address_type in (self.ADDRESS_TYPE_EXTERNAL, + self.ADDRESS_TYPE_INTERNAL): + index = max(used_indices[md][address_type], + saved_indices[md][address_type]) + self.set_next_index(md, address_type, index, force=True) def get_used_indices(self, addr_gen): """ Returns a dict of max used indices for each branch in @@ -985,12 +993,13 @@ class BaseWallet(object): for addr in addr_gen: if not self.is_known_addr(addr): continue - md, internal, index = self.get_details( + md, address_type, index = self.get_details( self.addr_to_path(addr)) - if internal not in (0, 1): - assert internal == 'imported' + if address_type not in (self.ADDRESS_TYPE_EXTERNAL, + self.ADDRESS_TYPE_INTERNAL): + assert address_type == 'imported' continue - indices[md][internal] = max(indices[md][internal], index + 1) + indices[md][address_type] = max(indices[md][address_type], index + 1) return indices @@ -1001,9 +1010,10 @@ class BaseWallet(object): cache.""" for md in used_indices: - for internal in (0, 1): - if used_indices[md][internal] >\ - max(self.get_next_unused_index(md, internal), 0): + for address_type in (self.ADDRESS_TYPE_EXTERNAL, + self.ADDRESS_TYPE_INTERNAL): + if used_indices[md][address_type] >\ + max(self.get_next_unused_index(md, address_type), 0): return False return True @@ -1265,13 +1275,14 @@ class BIP32Wallet(BaseWallet): _STORAGE_ENTROPY_KEY = b'entropy' _STORAGE_INDEX_CACHE = b'index_cache' BIP32_MAX_PATH_LEVEL = 2**31 - BIP32_EXT_ID = 0 - BIP32_INT_ID = 1 + BIP32_EXT_ID = BaseWallet.ADDRESS_TYPE_EXTERNAL + BIP32_INT_ID = BaseWallet.ADDRESS_TYPE_INTERNAL ENTROPY_BYTES = 16 def __init__(self, storage, **kwargs): self._entropy = None - # {mixdepth: {type: index}} with type being 0/1 for [non]-internal + # {mixdepth: {type: index}} with type being 0/1 corresponding + # to external/internal addresses self._index_cache = None # path is a tuple of BIP32 levels, # m is the master key's fingerprint @@ -1333,9 +1344,9 @@ class BIP32Wallet(BaseWallet): def _populate_script_map(self): for md in self._index_cache: - for int_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID): - for i in range(self._index_cache[md][int_type]): - path = self.get_path(md, int_type, i) + for address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID): + for i in range(self._index_cache[md][address_type]): + path = self.get_path(md, address_type, i) script = self.get_script_from_path(path) self._script_map[script] = path @@ -1372,43 +1383,42 @@ class BIP32Wallet(BaseWallet): if not self._is_my_bip32_path(path): raise WalletError("unable to get script for unknown key path") - md, int_type, index = self.get_details(path) + md, address_type, index = self.get_details(path) if not 0 <= md <= self.max_mixdepth: raise WalletError("Mixdepth outside of wallet's range.") - assert int_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID) + assert address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID) - current_index = self._index_cache[md][int_type] + current_index = self._index_cache[md][address_type] if index == current_index: - return self.get_new_script_override_disable(md, int_type) + return self.get_new_script_override_disable(md, address_type) priv, engine = self._get_priv_from_path(path) script = engine.privkey_to_script(priv) return script - def get_path(self, mixdepth=None, internal=None, index=None): + def get_path(self, mixdepth=None, address_type=None, index=None): if mixdepth is not None: assert isinstance(mixdepth, Integral) if not 0 <= mixdepth <= self.max_mixdepth: raise WalletError("Mixdepth outside of wallet's range.") - if internal is not None: + if address_type is not None: if mixdepth is None: - raise Exception("mixdepth must be set if internal is set") - int_type = self._get_internal_type(internal) + raise Exception("mixdepth must be set if address_type is set") if index is not None: assert isinstance(index, Integral) - if internal is None: - raise Exception("internal must be set if index is set") - assert index <= self._index_cache[mixdepth][int_type] + if address_type is None: + raise Exception("address_type must be set if index is set") + assert index <= self._index_cache[mixdepth][address_type] assert index < self.BIP32_MAX_PATH_LEVEL - return tuple(chain(self._get_bip32_export_path(mixdepth, internal), + return tuple(chain(self._get_bip32_export_path(mixdepth, address_type), (index,))) - return tuple(self._get_bip32_export_path(mixdepth, internal)) + return tuple(self._get_bip32_export_path(mixdepth, address_type)) def get_path_repr(self, path): path = list(path) @@ -1462,53 +1472,50 @@ class BIP32Wallet(BaseWallet): def _is_my_bip32_path(self, path): return path[0] == self._key_ident - def get_new_script(self, mixdepth, internal): + def get_new_script(self, mixdepth, address_type): if self.disable_new_scripts: raise RuntimeError("Obtaining new wallet addresses " + "disabled, due to nohistory mode") - return self.get_new_script_override_disable(mixdepth, internal) + return self.get_new_script_override_disable(mixdepth, address_type) - def get_new_script_override_disable(self, mixdepth, internal): + def get_new_script_override_disable(self, mixdepth, address_type): # This is called by get_script_from_path and calls back there. We need to # ensure all conditions match to avoid endless recursion. - int_type = self._get_internal_type(internal) - index = self._index_cache[mixdepth][int_type] - self._index_cache[mixdepth][int_type] += 1 - path = self.get_path(mixdepth, int_type, index) + index = self._index_cache[mixdepth][address_type] + self._index_cache[mixdepth][address_type] += 1 + path = self.get_path(mixdepth, address_type, index) script = self.get_script_from_path(path) self._script_map[script] = path return script - def get_script(self, mixdepth, internal, index): - path = self.get_path(mixdepth, internal, index) + def get_script(self, mixdepth, address_type, index): + path = self.get_path(mixdepth, address_type, index) return self.get_script_from_path(path) @deprecated - def get_key(self, mixdepth, internal, index): - int_type = self._get_internal_type(internal) - path = self.get_path(mixdepth, int_type, index) + def get_key(self, mixdepth, address_type, index): + path = self.get_path(mixdepth, address_type, index) priv = self._ENGINE.derive_bip32_privkey(self._master_key, path) return hexlify(priv).decode('ascii') - def get_bip32_priv_export(self, mixdepth=None, internal=None): - path = self._get_bip32_export_path(mixdepth, internal) + def get_bip32_priv_export(self, mixdepth=None, address_type=None): + path = self._get_bip32_export_path(mixdepth, address_type) return self._ENGINE.derive_bip32_priv_export(self._master_key, path) - def get_bip32_pub_export(self, mixdepth=None, internal=None): - path = self._get_bip32_export_path(mixdepth, internal) + def get_bip32_pub_export(self, mixdepth=None, address_type=None): + path = self._get_bip32_export_path(mixdepth, address_type) return self._ENGINE.derive_bip32_pub_export(self._master_key, path) - def _get_bip32_export_path(self, mixdepth=None, internal=None): + def _get_bip32_export_path(self, mixdepth=None, address_type=None): if mixdepth is None: - assert internal is None + assert address_type is None path = tuple() else: assert 0 <= mixdepth <= self.max_mixdepth - if internal is None: + if address_type is None: path = (self._get_bip32_mixdepth_path_level(mixdepth),) else: - int_type = self._get_internal_type(internal) - path = (self._get_bip32_mixdepth_path_level(mixdepth), int_type) + path = (self._get_bip32_mixdepth_path_level(mixdepth), address_type) return tuple(chain(self._get_bip32_base_path(), path)) @@ -1519,19 +1526,15 @@ class BIP32Wallet(BaseWallet): def _get_bip32_mixdepth_path_level(cls, mixdepth): return mixdepth - def _get_internal_type(self, is_internal): - return self.BIP32_INT_ID if is_internal else self.BIP32_EXT_ID - - def get_next_unused_index(self, mixdepth, internal): + def get_next_unused_index(self, mixdepth, address_type): assert 0 <= mixdepth <= self.max_mixdepth - int_type = self._get_internal_type(internal) - if self._index_cache[mixdepth][int_type] >= self.BIP32_MAX_PATH_LEVEL: + if self._index_cache[mixdepth][address_type] >= self.BIP32_MAX_PATH_LEVEL: # FIXME: theoretically this should work for up to # self.BIP32_MAX_PATH_LEVEL * 2, no? raise WalletError("All addresses used up, cannot generate new ones.") - return self._index_cache[mixdepth][int_type] + return self._index_cache[mixdepth][address_type] def get_mnemonic_words(self): return ' '.join(mn_encode(hexlify(self._entropy).decode('ascii'))), None @@ -1547,11 +1550,10 @@ class BIP32Wallet(BaseWallet): def get_wallet_id(self): return hexlify(self._key_ident).decode('ascii') - def set_next_index(self, mixdepth, internal, index, force=False): - int_type = self._get_internal_type(internal) - if not (force or index <= self._index_cache[mixdepth][int_type]): + def set_next_index(self, mixdepth, address_type, index, force=False): + if not (force or index <= self._index_cache[mixdepth][address_type]): raise Exception("cannot advance index without force=True") - self._index_cache[mixdepth][int_type] = index + self._index_cache[mixdepth][address_type] = index def get_details(self, path): if not self._is_my_bip32_path(path): diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index 459bd90..1d75ae9 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -721,16 +721,16 @@ class WalletService(Service): for md in range(self.max_mixdepth + 1): saved_indices[md] = [0, 0] - for internal in (0, 1): - next_unused = self.get_next_unused_index(md, internal) + for address_type in (0, 1): + next_unused = self.get_next_unused_index(md, address_type) for index in range(next_unused): - addresses.add(self.get_addr(md, internal, index)) + addresses.add(self.get_addr(md, address_type, index)) for index in range(self.gap_limit): - addresses.add(self.get_new_addr(md, internal)) + addresses.add(self.get_new_addr(md, address_type)) # reset the indices to the value we had before the # new address calls: - self.set_next_index(md, internal, next_unused) - saved_indices[md][internal] = next_unused + self.set_next_index(md, address_type, next_unused) + saved_indices[md][address_type] = next_unused # include any imported addresses for path in self.yield_imported_paths(md): addresses.add(self.get_address_from_path(path)) @@ -742,11 +742,11 @@ class WalletService(Service): addresses = set() for md in range(self.max_mixdepth + 1): - for internal in (True, False): - old_next = self.get_next_unused_index(md, internal) + for address_type in (1, 0): + old_next = self.get_next_unused_index(md, address_type) for index in range(gap_limit): - addresses.add(self.get_new_addr(md, internal)) - self.set_next_index(md, internal, old_next) + addresses.add(self.get_new_addr(md, address_type)) + self.set_next_index(md, address_type, old_next) return addresses diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index f2b2f55..6a1d4a7 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -162,13 +162,13 @@ class WalletViewBase(object): return "{0:.08f}".format(self.get_balance(include_unconf)) class WalletViewEntry(WalletViewBase): - def __init__(self, wallet_path_repr, account, forchange, aindex, addr, amounts, + def __init__(self, wallet_path_repr, account, address_type, aindex, addr, amounts, used = 'new', serclass=str, priv=None, custom_separator=None): super(WalletViewEntry, self).__init__(wallet_path_repr, serclass=serclass, custom_separator=custom_separator) self.account = account - assert forchange in [0, 1, -1] - self.forchange =forchange + assert address_type in [0, 1, -1] + self.address_type = address_type assert isinstance(aindex, Integral) assert aindex >= 0 self.aindex = aindex @@ -213,14 +213,14 @@ class WalletViewEntry(WalletViewBase): return self.serclass(ed) class WalletViewBranch(WalletViewBase): - def __init__(self, wallet_path_repr, account, forchange, branchentries=None, + def __init__(self, wallet_path_repr, account, address_type, branchentries=None, xpub=None, serclass=str, custom_separator=None): super(WalletViewBranch, self).__init__(wallet_path_repr, children=branchentries, serclass=serclass, custom_separator=custom_separator) self.account = account - assert forchange in [0, 1, -1] - self.forchange = forchange + assert address_type in [0, 1, -1] + self.address_type = address_type if xpub: assert xpub.startswith('xpub') or xpub.startswith('tpub') self.xpub = xpub if xpub else "" @@ -238,8 +238,8 @@ class WalletViewBranch(WalletViewBase): return self.serclass(entryseparator.join(lines)) def serialize_branch_header(self): - start = "external addresses" if self.forchange == 0 else "internal addresses" - if self.forchange == -1: + start = "external addresses" if self.address_type == 0 else "internal addresses" + if self.address_type == -1: start = "Imported keys" return self.serclass(self.separator.join([start, self.wallet_path_repr, self.xpub])) @@ -418,32 +418,33 @@ def wallet_display(wallet_service, showprivkey, displayall=False, utxos = wallet_service.get_utxos_by_mixdepth(include_disabled=True, hexfmt=False) for m in range(wallet_service.mixdepth + 1): branchlist = [] - for forchange in [0, 1]: + for address_type in [0, 1]: entrylist = [] - if forchange == 0: + if address_type == 0: # users would only want to hand out the xpub for externals - xpub_key = wallet_service.get_bip32_pub_export(m, forchange) + xpub_key = wallet_service.get_bip32_pub_export(m, address_type) else: xpub_key = "" - unused_index = wallet_service.get_next_unused_index(m, forchange) + unused_index = wallet_service.get_next_unused_index(m, address_type) for k in range(unused_index + wallet_service.gap_limit): - path = wallet_service.get_path(m, forchange, k) + path = wallet_service.get_path(m, address_type, k) addr = wallet_service.get_address_from_path(path) balance, used = get_addr_status( - path, utxos[m], k >= unused_index, forchange) + path, utxos[m], k >= unused_index, address_type) if showprivkey: privkey = wallet_service.get_wif_path(path) else: privkey = '' if (displayall or balance > 0 or - (used == 'new' and forchange == 0)): + (used == 'new' and address_type == 0)): entrylist.append(WalletViewEntry( - wallet_service.get_path_repr(path), m, forchange, k, addr, + wallet_service.get_path_repr(path), m, address_type, k, addr, [balance, balance], priv=privkey, used=used)) - wallet_service.set_next_index(m, forchange, unused_index) - path = wallet_service.get_path_repr(wallet_service.get_path(m, forchange)) - branchlist.append(WalletViewBranch(path, m, forchange, entrylist, + + 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, xpub=xpub_key)) ipb = get_imported_privkey_branch(wallet_service, m, showprivkey) if ipb: @@ -1290,15 +1291,15 @@ if __name__ == "__main__": acctlist = [] for a in accounts: branches = [] - for forchange in range(2): + for address_type in range(2): entries = [] for i in range(4): - entries.append(WalletViewEntry(rootpath, a, forchange, + entries.append(WalletViewEntry(rootpath, a, address_type, i, "DUMMYADDRESS"+str(i+a), [i*10000000, i*10000000])) branches.append(WalletViewBranch(rootpath, - a, forchange, branchentries=entries, - xpub="xpubDUMMYXPUB"+str(a+forchange))) + a, address_type, branchentries=entries, + xpub="xpubDUMMYXPUB"+str(a+address_type))) acctlist.append(WalletViewAccount(rootpath, a, branches=branches)) wallet = WalletView(rootpath + "/" + str(walletbranch), accounts=acctlist) diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index 97e2c50..f424d9f 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -520,7 +520,7 @@ def test_set_next_index(setup_wallet): def test_path_repr(setup_wallet): wallet = get_populated_wallet() - path = wallet.get_path(2, False, 0) + path = wallet.get_path(2, BIP32Wallet.ADDRESS_TYPE_EXTERNAL, 0) path_repr = wallet.get_path_repr(path) path_new = wallet.path_repr_to_path(path_repr) From d86df334c3cc4d91f70f725bbb4bc663f64b7c7b Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Thu, 23 Jan 2020 21:28:32 +0000 Subject: [PATCH 02/16] Rename functions which create multisig scripts --- jmbitcoin/jmbitcoin/secp256k1_transaction.py | 6 +++--- jmclient/test/test_tx_creation.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/jmbitcoin/jmbitcoin/secp256k1_transaction.py b/jmbitcoin/jmbitcoin/secp256k1_transaction.py index a4893cf..06ec8d3 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_transaction.py +++ b/jmbitcoin/jmbitcoin/secp256k1_transaction.py @@ -534,7 +534,7 @@ def pubkey_to_p2wpkh_address(pub): script = pubkey_to_p2wpkh_script(pub) return script_to_address(script) -def pubkeys_to_p2wsh_script(pubs): +def pubkeys_to_p2wsh_multisig_script(pubs): """ Given a list of N pubkeys, constructs an N of N multisig scriptPubKey of type pay-to-witness-script-hash. No other scripts than N-N multisig supported as of now. @@ -543,12 +543,12 @@ def pubkeys_to_p2wsh_script(pubs): script = mk_multisig_script(pubs, N) return P2WSH_PRE + bin_sha256(binascii.unhexlify(script)) -def pubkeys_to_p2wsh_address(pubs): +def pubkeys_to_p2wsh_multisig_address(pubs): """ Given a list of N pubkeys, constructs an N of N multisig address of type pay-to-witness-script-hash. No other scripts than N-N multisig supported as of now. """ - script = pubkeys_to_p2wsh_script(pubs) + script = pubkeys_to_p2wsh_multisig_script(pubs) return script_to_address(script) def deserialize_script(scriptinp): diff --git a/jmclient/test/test_tx_creation.py b/jmclient/test/test_tx_creation.py index bfce319..79275ca 100644 --- a/jmclient/test/test_tx_creation.py +++ b/jmclient/test/test_tx_creation.py @@ -244,8 +244,8 @@ def test_spend_p2wsh(setup_tx_creation): privs = [binascii.hexlify(priv).decode('ascii') for priv in privs] pubs = [bitcoin.privkey_to_pubkey(priv) for priv in privs] redeemScripts = [bitcoin.mk_multisig_script(pubs[i:i+2], 2) for i in [0, 2]] - scriptPubKeys = [bitcoin.pubkeys_to_p2wsh_script(pubs[i:i+2]) for i in [0, 2]] - addresses = [bitcoin.pubkeys_to_p2wsh_address(pubs[i:i+2]) for i in [0, 2]] + scriptPubKeys = [bitcoin.pubkeys_to_p2wsh_multisig_script(pubs[i:i+2]) for i in [0, 2]] + addresses = [bitcoin.pubkeys_to_p2wsh_multisig_address(pubs[i:i+2]) for i in [0, 2]] #pay into it wallet_service = make_wallets(1, [[3, 0, 0, 0, 0]], 3)[0]['wallet'] wallet_service.sync_wallet(fast=True) From ee70cd793e2d710c281709e6ee7df2a39cbdf7f3 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Sat, 1 Feb 2020 16:25:25 +0000 Subject: [PATCH 03/16] Add support for OP_CLTV timelock addresses The new functions implement creating fund freezing redeem scripts and transactions which spend from such scripts. Also there is a new test function. --- jmbitcoin/jmbitcoin/secp256k1_transaction.py | 72 +++++++++++++++----- jmclient/jmclient/cryptoengine.py | 2 +- jmclient/test/test_tx_creation.py | 56 ++++++++++++++- 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/jmbitcoin/jmbitcoin/secp256k1_transaction.py b/jmbitcoin/jmbitcoin/secp256k1_transaction.py index 06ec8d3..34df966 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_transaction.py +++ b/jmbitcoin/jmbitcoin/secp256k1_transaction.py @@ -10,6 +10,7 @@ import struct import random from jmbitcoin.secp256k1_main import * from jmbitcoin.bech32 import * +import jmbitcoin as btc P2PKH_PRE, P2PKH_POST = b'\x76\xa9\x14', b'\x88\xac' P2SH_P2WPKH_PRE, P2SH_P2WPKH_POST = b'\xa9\x14', b'\x87' @@ -541,7 +542,7 @@ def pubkeys_to_p2wsh_multisig_script(pubs): """ N = len(pubs) script = mk_multisig_script(pubs, N) - return P2WSH_PRE + bin_sha256(binascii.unhexlify(script)) + return redeem_script_to_p2wsh_script(script) def pubkeys_to_p2wsh_multisig_address(pubs): """ Given a list of N pubkeys, constructs an N of N @@ -551,6 +552,12 @@ def pubkeys_to_p2wsh_multisig_address(pubs): script = pubkeys_to_p2wsh_multisig_script(pubs) return script_to_address(script) +def redeem_script_to_p2wsh_script(redeem_script): + return P2WSH_PRE + bin_sha256(binascii.unhexlify(redeem_script)) + +def redeem_script_to_p2wsh_address(redeem_script, vbyte, witver=0): + return script_to_address(redeem_script_to_p2wsh_script(redeem_script), vbyte, witver) + def deserialize_script(scriptinp): """ Note that this is not used internally, in the jmbitcoin package, to deserialize() transactions; @@ -603,10 +610,14 @@ def deserialize_script(scriptinp): def serialize_script_unit(unit): if isinstance(unit, int): - if unit < 16: + if unit == 0: + return from_int_to_byte(unit) + elif unit < 16: return from_int_to_byte(unit + 80) - else: + elif unit < 256: return from_int_to_byte(unit) + else: + return b'\x04' + struct.pack(b">>>>>> c158543... only allow pubkey in bytes for mk_freeze_script() import jmbitcoin as bitcoin import pytest @@ -190,7 +192,7 @@ def test_spend_p2sh_utxos(setup_tx_creation): tx = bitcoin.mktx(ins, outs) sigs = [] for priv in privs[:2]: - sigs.append(bitcoin.multisign(tx, 0, script, binascii.hexlify(priv).decode('ascii'))) + sigs.append(bitcoin.get_p2sh_signature(tx, 0, script, binascii.hexlify(priv).decode('ascii'))) tx = bitcoin.apply_multisignatures(tx, 0, script, sigs) txid = jm_single().bc_interface.pushtx(tx) assert txid @@ -268,7 +270,7 @@ def test_spend_p2wsh(setup_tx_creation): sigs = [] for priv in privs[i*2:i*2+2]: # sign input j with each of 2 keys - sig = bitcoin.multisign(tx, i, redeemScripts[i], priv, amount=amount) + sig = bitcoin.get_p2sh_signature(tx, i, redeemScripts[i], priv, amount=amount) sigs.append(sig) # check that verify_tx_input correctly validates; assert bitcoin.verify_tx_input(tx, i, scriptPubKeys[i], sig, @@ -278,6 +280,56 @@ def test_spend_p2wsh(setup_tx_creation): txid = jm_single().bc_interface.pushtx(tx) assert txid +def ensure_bip65_activated(): + #on regtest bip65 activates on height 1351 + #https://github.com/bitcoin/bitcoin/blob/1d1f8bbf57118e01904448108a104e20f50d2544/src/chainparams.cpp#L262 + BIP65Height = 1351 + current_height = jm_single().bc_interface.rpc("getblockchaininfo", [])["blocks"] + until_bip65_activation = BIP65Height - current_height + 1 + if until_bip65_activation > 0: + jm_single().bc_interface.tick_forward_chain(until_bip65_activation) + +def test_spend_freeze_script(setup_tx_creation): + ensure_bip65_activated() + + wallet_service = make_wallets(1, [[3, 0, 0, 0, 0]], 3)[0]['wallet'] + wallet_service.sync_wallet(fast=True) + + mediantime = jm_single().bc_interface.rpc("getblockchaininfo", [])["mediantime"] + + timeoffset_success_tests = [(2, False), (-60*60*24*30, True), (60*60*24*30, False)] + + for timeoffset, required_success in timeoffset_success_tests: + #generate keypair + priv = "aa"*32 + "01" + pub = unhexlify(bitcoin.privkey_to_pubkey(priv)) + addr_locktime = mediantime + timeoffset + redeem_script = bitcoin.mk_freeze_script(pub, addr_locktime) + script_pub_key = bitcoin.redeem_script_to_p2wsh_script(redeem_script) + regtest_vbyte = 100 + addr = bitcoin.script_to_address(script_pub_key, vbyte=regtest_vbyte) + + #fund frozen funds address + amount = 100000000 + funding_ins_full = wallet_service.select_utxos(0, amount) + funding_txid = make_sign_and_push(funding_ins_full, wallet_service, amount, output_addr=addr) + assert funding_txid + + #spend frozen funds + frozen_in = funding_txid + ":0" + output_addr = wallet_service.get_internal_addr(1) + miner_fee = 5000 + outs = [{'value': amount - miner_fee, 'address': output_addr}] + tx = bitcoin.mktx([frozen_in], outs, locktime=addr_locktime+1) + i = 0 + sig = bitcoin.get_p2sh_signature(tx, i, redeem_script, priv, amount) + + assert bitcoin.verify_tx_input(tx, i, script_pub_key, sig, pub, + scriptCode=redeem_script, amount=amount) + tx = bitcoin.apply_freeze_signature(tx, i, redeem_script, sig) + push_success = jm_single().bc_interface.pushtx(tx) + + assert push_success == required_success @pytest.fixture(scope="module") def setup_tx_creation(): From dc715c9481e6a5299a552893e6597c5c34643c59 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Thu, 23 Jan 2020 21:16:35 +0000 Subject: [PATCH 04/16] Add timelock fidelity bond wallet sync and display Fidelity bond wallets are intended to be used when at a later date using fidelity bonds to greatly increase joinmarket's resistance to sybil attacks. This commit adds support for timelocked addresses. It allows users to optionally create wallet which support such addresses. The synchronization code is modified to also scan for timelocked addresses. The keypairs of the timelocked addresses go in the newly created 2nd address type, where before the zeroth index were receive addresses and first index was change. The locktime dates are fixed at the first of each month for the next 30 years. This means users dont need to remember any dates, and so just their seed phrase and wallet type will still be enough to recover all funds. Each keypair used for timelocking requires an additional 360 addresses to be scanned for, which isn't a problem for Bitcoin Core. Fidelity bonds are only stored in the zeroth mixdepth, as they are not used in repeated coinjoins so theres no point having them in multiple mixdepths. Timelocked addresses don't use the get_new_script() family of functions because they all assume that one index equals one address, and that therefore it's possible to ask for a "next" address. For timelocked addresses knowing the index is not enough to know the address, the timestamp must be known too. Also once one address made of (index, timestamp) is used you mustn't use that index and pubkey again, even though all the other timelocks for that index/pubkey are unused. This is for privacy reasons, as its equivalent to address reuse. --- jmbitcoin/jmbitcoin/secp256k1_main.py | 2 +- jmclient/jmclient/__init__.py | 2 +- jmclient/jmclient/cryptoengine.py | 60 ++++++- jmclient/jmclient/wallet.py | 215 ++++++++++++++++++++++++-- jmclient/jmclient/wallet_service.py | 28 ++++ jmclient/jmclient/wallet_utils.py | 131 +++++++++++++--- scripts/joinmarket-qt.py | 3 +- 7 files changed, 397 insertions(+), 44 deletions(-) diff --git a/jmbitcoin/jmbitcoin/secp256k1_main.py b/jmbitcoin/jmbitcoin/secp256k1_main.py index 931b424..e91fc59 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_main.py +++ b/jmbitcoin/jmbitcoin/secp256k1_main.py @@ -10,7 +10,7 @@ import coincurve as secp256k1 #Required only for PoDLE calculation: N = 115792089237316195423570985008687907852837564279074904382605163141518161494337 -BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f'} +BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f', "regtest": 100} BTC_P2SH_VBYTE = {"mainnet": b'\x05', "testnet": b'\xc4'} #Standard prefix for Bitcoin message signing. diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 04d5aa4..c00fe7e 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -13,7 +13,7 @@ from .old_mnemonic import mn_decode, mn_encode from .taker import Taker, P2EPTaker from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportWalletMixin, BIP39WalletMixin, BIP32Wallet, BIP49Wallet, LegacyWallet, - SegwitWallet, SegwitLegacyWallet, UTXOManager, + SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) diff --git a/jmclient/jmclient/cryptoengine.py b/jmclient/jmclient/cryptoengine.py index e537b8f..24ccfe9 100644 --- a/jmclient/jmclient/cryptoengine.py +++ b/jmclient/jmclient/cryptoengine.py @@ -5,10 +5,11 @@ from collections import OrderedDict import struct import jmbitcoin as btc -from .configure import get_network +from .configure import get_network, jm_single -TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N = range(4) +TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N, TYPE_TIMELOCK_P2WSH, \ + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS = range(6) NET_MAINNET, NET_TESTNET = range(2) NET_MAP = {'mainnet': NET_MAINNET, 'testnet': NET_TESTNET} WIF_PREFIX_MAP = {'mainnet': b'\x80', 'testnet': b'\xef'} @@ -283,8 +284,61 @@ class BTC_P2WPKH(BTCEngine): return btc.sign(btc.serialize(tx), index, privkey, hashcode=hashcode, amount=amount, native=True) +class BTC_Timelocked_P2WSH(BTCEngine): + + """ + In this class many instances of "privkey" or "pubkey" are actually tuples + of (privkey, timelock) or (pubkey, timelock) + """ + + @classproperty + def VBYTE(cls): + #slight hack here, network can be either "mainnet" or "testnet" + #but we need to distinguish between actual testnet and regtest + if get_network() == "mainnet": + return btc.BTC_P2PK_VBYTE["mainnet"] + else: + if jm_single().config.get("BLOCKCHAIN", "blockchain_source")\ + == "regtest": + return btc.BTC_P2PK_VBYTE["regtest"] + else: + assert get_network() == "testnet" + return btc.BTC_P2PK_VBYTE["testnet"] + + @classmethod + def privkey_to_script(cls, privkey_locktime): + privkey, locktime = privkey_locktime + pub = cls.privkey_to_pubkey(privkey) + return cls.pubkey_to_script((pub, locktime)) + + @classmethod + def pubkey_to_script(cls, pubkey_locktime): + redeem_script = cls.pubkey_to_script_code(pubkey_locktime) + return btc.redeem_script_to_p2wsh_script(redeem_script) + + @classmethod + def pubkey_to_script_code(cls, pubkey_locktime): + pubkey, locktime = pubkey_locktime + return btc.mk_freeze_script(pubkey, locktime) + + @classmethod + def privkey_to_wif(cls, privkey_locktime): + priv, locktime = privkey_locktime + return btc.bin_to_b58check(priv, cls.WIF_PREFIX) + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise Exception("not implemented yet") + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise RuntimeError("Cannot spend from watch-only wallets") + ENGINES = { TYPE_P2PKH: BTC_P2PKH, TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH, - TYPE_P2WPKH: BTC_P2WPKH + TYPE_P2WPKH: BTC_P2WPKH, + TYPE_TIMELOCK_P2WSH: BTC_Timelocked_P2WSH } diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 3778c4a..3f1dae5 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -7,6 +7,7 @@ import numbers import random from binascii import hexlify, unhexlify from datetime import datetime +from calendar import timegm from copy import deepcopy from mnemonic import Mnemonic as MnemonicParent from hashlib import sha256 @@ -20,7 +21,8 @@ from .blockchaininterface import INF_HEIGHT from .support import select_gradual, select_greedy, select_greediest, \ select from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH,\ - TYPE_P2WPKH, ENGINES + TYPE_P2WPKH, TYPE_TIMELOCK_P2WSH, TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS,\ + ENGINES from .support import get_random_bytes from . import mn_encode, mn_decode import jmbitcoin as btc @@ -559,7 +561,7 @@ class BaseWallet(object): returns: tuple (mixdepth, type, index) - type is one of 0, 1, 'imported' + type is one of 0, 1, 'imported', 2, 3 """ raise NotImplementedError() @@ -976,27 +978,28 @@ class BaseWallet(object): def rewind_wallet_indices(self, used_indices, saved_indices): for md in used_indices: - for address_type in (self.ADDRESS_TYPE_EXTERNAL, - self.ADDRESS_TYPE_INTERNAL): + for address_type in range(min(len(used_indices[md]), len(saved_indices[md]))): index = max(used_indices[md][address_type], saved_indices[md][address_type]) self.set_next_index(md, address_type, index, force=True) + def _get_default_used_indices(self): + return {x: [0, 0] for x in range(self.max_mixdepth + 1)} + def get_used_indices(self, addr_gen): """ Returns a dict of max used indices for each branch in the wallet, from the given addresses addr_gen, assuming that they are known to the wallet. """ - - indices = {x: [0, 0] for x in range(self.max_mixdepth + 1)} + indices = self._get_default_used_indices() for addr in addr_gen: if not self.is_known_addr(addr): continue md, address_type, index = self.get_details( self.addr_to_path(addr)) - if address_type not in (self.ADDRESS_TYPE_EXTERNAL, - self.ADDRESS_TYPE_INTERNAL): + if address_type not in (self.BIP32_EXT_ID, self.BIP32_INT_ID, + FidelityBondMixin.BIP32_TIMELOCK_ID, FidelityBondMixin.BIP32_BURN_ID): assert address_type == 'imported' continue indices[md][address_type] = max(indices[md][address_type], index + 1) @@ -1379,6 +1382,10 @@ class BIP32Wallet(BaseWallet): def _derive_bip32_master_key(cls, seed): return cls._ENGINE.derive_bip32_master_key(seed) + @classmethod + def _get_supported_address_types(cls): + return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID) + def get_script_from_path(self, path): if not self._is_my_bip32_path(path): raise WalletError("unable to get script for unknown key path") @@ -1387,11 +1394,14 @@ class BIP32Wallet(BaseWallet): if not 0 <= md <= self.max_mixdepth: raise WalletError("Mixdepth outside of wallet's range.") - assert address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID) + assert address_type in self._get_supported_address_types() current_index = self._index_cache[md][address_type] - if index == current_index: + if index == current_index \ + and address_type != FidelityBondMixin.BIP32_TIMELOCK_ID: + #special case for timelocked addresses because for them the + #concept of a "next address" cant be used return self.get_new_script_override_disable(md, address_type) priv, engine = self._get_priv_from_path(path) @@ -1481,9 +1491,16 @@ class BIP32Wallet(BaseWallet): def get_new_script_override_disable(self, mixdepth, address_type): # This is called by get_script_from_path and calls back there. We need to # ensure all conditions match to avoid endless recursion. + index = self.get_index_cache_and_increment(mixdepth, address_type) + return self.get_script_and_update_map(mixdepth, address_type, index) + + def get_index_cache_and_increment(self, mixdepth, address_type): index = self._index_cache[mixdepth][address_type] self._index_cache[mixdepth][address_type] += 1 - path = self.get_path(mixdepth, address_type, index) + return index + + def get_script_and_update_map(self, *args): + path = self.get_path(*args) script = self.get_script_from_path(path) self._script_map[script] = path return script @@ -1571,8 +1588,6 @@ class LegacyWallet(ImportWalletMixin, BIP32Wallet): def _get_bip32_base_path(self): return self._key_ident, 0 - - class BIP32PurposedWallet(BIP32Wallet): """ A class to encapsulate cases like BIP44, 49 and 84, all of which are derivatives @@ -1595,6 +1610,174 @@ class BIP32PurposedWallet(BIP32Wallet): return path[len(self._get_bip32_base_path())] - 2**31 +class FidelityBondMixin(object): + BIP32_TIMELOCK_ID = 2 + BIP32_BURN_ID = 3 + + """ + Explaination of time number + + incrementing time numbers (0, 1, 2, 3, 4... + will produce datetimes + suitable for timelocking (1st january, 1st april, 1st july .... + this greatly reduces the number of possible timelock values + and is helpful for recovery of funds because the wallet can search + the only addresses corresponding to timenumbers which are far fewer + + For example, if TIMENUMBER_UNIT = 2 (i.e. every time number is two months) + then there are 6 timelocks per year so just 600 possible + addresses per century per pubkey. Easily searchable when recovering a + wallet from seed phrase. Therefore the user doesn't need to store any + dates, the seed phrase is sufficent for recovery. + """ + #should be a factor of 12, the number of months in a year + TIMENUMBER_UNIT = 1 + + # all timelocks are 1st of the month at midnight + TIMELOCK_DAY_AND_SHORTER = (1, 0, 0, 0, 0) + TIMELOCK_EPOCH_YEAR = 2020 + TIMELOCK_EPOCH_MONTH = 1 #january + MONTHS_IN_YEAR = 12 + + TIMELOCK_ERA_YEARS = 30 + TIMENUMBERS_PER_PUBKEY = TIMELOCK_ERA_YEARS * MONTHS_IN_YEAR // TIMENUMBER_UNIT + + """ + As each pubkey corresponds to hundreds of addresses, to reduce load the + given gap limit will be reduced by this factor. Also these timelocked + addresses are never handed out to takers so there wont be a problem of + having many used addresses with no transactions on them. + """ + TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR = 6 + + #only one mixdepth will have fidelity bonds in it + FIDELITY_BOND_MIXDEPTH = 0 + + @classmethod + def _time_number_to_timestamp(cls, timenumber): + """ + converts a time number to a unix timestamp + """ + year = cls.TIMELOCK_EPOCH_YEAR + (timenumber*cls.TIMENUMBER_UNIT) // cls.MONTHS_IN_YEAR + month = cls.TIMELOCK_EPOCH_MONTH + (timenumber*cls.TIMENUMBER_UNIT) % cls.MONTHS_IN_YEAR + return timegm(datetime(year, month, *cls.TIMELOCK_DAY_AND_SHORTER).timetuple()) + + @classmethod + def timestamp_to_time_number(cls, timestamp): + """ + converts a datetime object to a time number + """ + dt = datetime.utcfromtimestamp(timestamp) + if (dt.month - cls.TIMELOCK_EPOCH_MONTH) % cls.TIMENUMBER_UNIT != 0: + raise ValueError() + day_and_shorter_tuple = (dt.day, dt.hour, dt.minute, dt.second, dt.microsecond) + if day_and_shorter_tuple != cls.TIMELOCK_DAY_AND_SHORTER: + raise ValueError() + timenumber = (dt.year - cls.TIMELOCK_EPOCH_YEAR)*(cls.MONTHS_IN_YEAR // + cls.TIMENUMBER_UNIT) + ((dt.month - cls.TIMELOCK_EPOCH_MONTH) // cls.TIMENUMBER_UNIT) + if timenumber < 0 or timenumber > cls.TIMENUMBERS_PER_PUBKEY: + raise ValueError("datetime out of range") + return timenumber + + @classmethod + def _is_timelocked_path(cls, path): + return len(path) > 4 and path[4] == cls.BIP32_TIMELOCK_ID + + def get_xpub_from_fidelity_bond_master_pub_key(cls, mpk): + if mpk.startswith(cls._BIP32_PUBKEY_PREFIX): + return mpk[len(cls._BIP32_PUBKEY_PREFIX):] + else: + return False + + def _get_key_ident(self): + first_path = self.get_path(0, 0) + priv, engine = self._get_priv_from_path(first_path) + pub = engine.privkey_to_pubkey(priv) + return sha256(sha256(pub).digest()).digest()[:3] + + def _populate_script_map(self): + super(FidelityBondMixin, self)._populate_script_map() + for md in self._index_cache: + address_type = self.BIP32_TIMELOCK_ID + for i in range(self._index_cache[md][address_type]): + for timenumber in range(self.TIMENUMBERS_PER_PUBKEY): + path = self.get_path(md, address_type, i, timenumber) + script = self.get_script_from_path(path) + self._script_map[script] = path + + @classmethod + def _get_supported_address_types(cls): + return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID, cls.BIP32_TIMELOCK_ID, cls.BIP32_BURN_ID) + + def _get_priv_from_path(self, path): + if self._is_timelocked_path(path): + key_path = path[:-1] + locktime = path[-1] + engine = ENGINES[TYPE_TIMELOCK_P2WSH] + privkey = engine.derive_bip32_privkey(self._master_key, key_path) + return (privkey, locktime), engine + else: + return super(FidelityBondMixin, self)._get_priv_from_path(path) + + def get_path(self, mixdepth=None, address_type=None, index=None, timenumber=None): + if address_type == None or address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID, + self.BIP32_BURN_ID) or index == None: + return super(FidelityBondMixin, self).get_path(mixdepth, address_type, index) + elif address_type == self.BIP32_TIMELOCK_ID: + assert timenumber != None + timestamp = self._time_number_to_timestamp(timenumber) + return tuple(chain(self._get_bip32_export_path(mixdepth, address_type), + (index, timestamp))) + else: + assert 0 + + """ + We define a new serialization of the bip32 path to include bip65 timelock addresses + Previously it was m/44'/1'/0'/3/0 + For a timelocked address it will be m/44'/1'/0'/3/0:13245432 + The timelock will be in unix format and added to the end with a colon ":" character + refering to the pubkey plus the timelock value which together are needed to create the address + """ + def get_path_repr(self, path): + if self._is_timelocked_path(path) and len(path) == 7: + return super(FidelityBondMixin, self).get_path_repr(path[:-1]) +\ + ":" + str(path[-1]) + else: + return super(FidelityBondMixin, self).get_path_repr(path) + + def path_repr_to_path(self, pathstr): + if pathstr.find(":") == -1: + return super(FidelityBondMixin, self).path_repr_to_path(pathstr) + else: + colon_chunks = pathstr.split(":") + if len(colon_chunks) != 2: + raise WalletError("Not a valid wallet timelock path: {}".format(pathstr)) + return tuple(chain( + super(FidelityBondMixin, self).path_repr_to_path(colon_chunks[0]), + (int(colon_chunks[1]),) + )) + + def get_details(self, path): + if self._is_timelocked_path(path): + return self._get_mixdepth_from_path(path), path[-3], path[-2] + else: + return super(FidelityBondMixin, self).get_details(path) + + def _get_default_used_indices(self): + return {x: [0, 0, 0, 0] for x in range(self.max_mixdepth + 1)} + + def get_script(self, mixdepth, address_type, index, timenumber=None): + path = self.get_path(mixdepth, address_type, index, timenumber) + return self.get_script_from_path(path) + + def get_addr(self, mixdepth, address_type, index, timenumber=None): + script = self.get_script(mixdepth, address_type, index, timenumber) + return self.script_to_addr(script) + +#class FidelityBondWatchonlyWallet(ImportWalletMixin, BIP39WalletMixin, FidelityBondMixin): + + + class BIP49Wallet(BIP32PurposedWallet): _PURPOSE = 2**31 + 49 _ENGINE = ENGINES[TYPE_P2SH_P2WPKH] @@ -1609,8 +1792,12 @@ class SegwitLegacyWallet(ImportWalletMixin, BIP39WalletMixin, BIP49Wallet): class SegwitWallet(ImportWalletMixin, BIP39WalletMixin, BIP84Wallet): TYPE = TYPE_P2WPKH +class SegwitLegacyWalletFidelityBonds(FidelityBondMixin, SegwitLegacyWallet): + TYPE = TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + WALLET_IMPLEMENTATIONS = { LegacyWallet.TYPE: LegacyWallet, SegwitLegacyWallet.TYPE: SegwitLegacyWallet, - SegwitWallet.TYPE: SegwitWallet + SegwitWallet.TYPE: SegwitWallet, + SegwitLegacyWalletFidelityBonds.TYPE: SegwitLegacyWalletFidelityBonds } diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index 1d75ae9..c21fc70 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -15,6 +15,7 @@ from jmclient.configure import jm_single, get_log from jmclient.output import fmt_tx_data from jmclient.blockchaininterface import (INF_HEIGHT, BitcoinCoreInterface, BitcoinCoreNoHistoryInterface) +from jmclient.wallet import FidelityBondMixin from jmbase.support import jmprint, EXIT_SUCCESS """Wallet service @@ -735,6 +736,22 @@ class WalletService(Service): for path in self.yield_imported_paths(md): addresses.add(self.get_address_from_path(path)) + if isinstance(self.wallet, FidelityBondMixin): + md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + saved_indices[md] += [0] + next_unused = self.get_next_unused_index(md, address_type) + for index in range(next_unused): + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + addresses.add(self.get_addr(md, address_type, index, timenumber)) + for index in range(self.gap_limit // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR): + index += next_unused + assert self.wallet.get_index_cache_and_increment(md, address_type) == index + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + self.wallet.get_script_and_update_map(md, address_type, index, timenumber) + addresses.add(self.get_addr(md, address_type, index, timenumber)) + self.wallet.set_next_index(md, address_type, next_unused) + return addresses, saved_indices def collect_addresses_gap(self, gap_limit=None): @@ -748,6 +765,17 @@ class WalletService(Service): addresses.add(self.get_new_addr(md, address_type)) self.set_next_index(md, address_type, old_next) + if isinstance(self.wallet, FidelityBondMixin): + md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + old_next = self.get_next_unused_index(md, address_type) + for ii in range(gap_limit // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR): + index = self.wallet.get_index_cache_and_increment(md, address_type) + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + self.wallet.get_script_and_update_map(md, address_type, index, timenumber) + addresses.add(self.get_addr(md, address_type, index, timenumber)) + self.set_next_index(md, address_type, old_next) + return addresses def get_external_addr(self, mixdepth): diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 6a1d4a7..7ff37cc 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -5,6 +5,7 @@ import sys import sqlite3 import binascii from datetime import datetime +from calendar import timegm from optparse import OptionParser from numbers import Integral from collections import Counter @@ -12,12 +13,13 @@ from itertools import islice from jmclient import (get_network, WALLET_IMPLEMENTATIONS, Storage, podle, jm_single, BitcoinCoreInterface, WalletError, VolatileStorage, StoragePasswordError, is_segwit_mode, SegwitLegacyWallet, - LegacyWallet, SegwitWallet, is_native_segwit_mode, load_program_config, - add_base_options, check_regtest) + LegacyWallet, SegwitWallet, FidelityBondMixin, is_native_segwit_mode, + load_program_config, add_base_options, check_regtest) from jmclient.wallet_service import WalletService from jmbase.support import get_password, jmprint, EXIT_FAILURE, EXIT_ARGERROR -from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH +from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, \ + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS from .output import fmt_utxo import jmbitcoin as btc @@ -42,8 +44,9 @@ def get_wallettool_parser(): '(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.\n' - '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.') - parser = OptionParser(usage='usage: %prog [options] [wallet file] [method]', + '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.\n' + '(gettimelockaddress) Obtain a timelocked address. Argument is locktime value as yyyy-mm. For example `2021-03`') + parser = OptionParser(usage='usage: %prog [options] [wallet file] [method] [args..]', description=description) add_base_options(parser) parser.add_option('-p', @@ -167,7 +170,9 @@ class WalletViewEntry(WalletViewBase): super(WalletViewEntry, self).__init__(wallet_path_repr, serclass=serclass, custom_separator=custom_separator) self.account = account - assert address_type in [0, 1, -1] + assert address_type in [SegwitWallet.BIP32_EXT_ID, + SegwitWallet.BIP32_INT_ID, -1, FidelityBondMixin.BIP32_TIMELOCK_ID, + FidelityBondMixin.BIP32_BURN_ID] self.address_type = address_type assert isinstance(aindex, Integral) assert aindex >= 0 @@ -219,7 +224,9 @@ class WalletViewBranch(WalletViewBase): serclass=serclass, custom_separator=custom_separator) self.account = account - assert address_type in [0, 1, -1] + assert address_type in [SegwitWallet.BIP32_EXT_ID, + SegwitWallet.BIP32_INT_ID, -1, FidelityBondMixin.BIP32_TIMELOCK_ID, + FidelityBondMixin.BIP32_BURN_ID] self.address_type = address_type if xpub: assert xpub.startswith('xpub') or xpub.startswith('tpub') @@ -441,11 +448,42 @@ def wallet_display(wallet_service, showprivkey, displayall=False, entrylist.append(WalletViewEntry( wallet_service.get_path_repr(path), m, address_type, k, addr, [balance, balance], priv=privkey, used=used)) - 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, xpub=xpub_key)) + + if m == FidelityBondMixin.FIDELITY_BOND_MIXDEPTH and \ + isinstance(wallet_service.wallet, FidelityBondMixin): + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + unused_index = wallet_service.get_next_unused_index(m, address_type) + timelocked_gaplimit = (wallet_service.wallet.gap_limit + // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR) + entrylist = [] + for k in range(unused_index + timelocked_gaplimit): + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + path = wallet_service.get_path(m, address_type, k, timenumber) + addr = wallet_service.get_address_from_path(path) + timelock = datetime.utcfromtimestamp(path[-1]) + + balance = sum([utxodata["value"] for utxo, utxodata in + iteritems(utxos[m]) if path == utxodata["path"]]) + status = timelock.strftime("%Y-%m-%d") + " [" + ( + "LOCKED" if datetime.now() < timelock else "UNLOCKED") + "]" + privkey = "" + if showprivkey: + privkey = wallet_service.get_wif_path(path) + 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)) + + #TODO fidelity bond master pub key is this, although it should include burner too + 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, + xpub=xpub_key)) + ipb = get_imported_privkey_branch(wallet_service, m, showprivkey) if ipb: branchlist.append(ipb) @@ -499,11 +537,19 @@ def cli_get_mnemonic_extension(): "info") return input("Enter mnemonic extension: ") +def cli_do_support_fidelity_bonds(): + uin = input("Would you like this wallet to support fidelity bonds? " + "write 'n' if you don't know what this is (y/n): ") + if len(uin) == 0 or uin[0] != 'y': + jmprint("Not supporting fidelity bonds", "info") + return False + else: + return True def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, display_seed_callback, enter_seed_callback, enter_wallet_password_callback, enter_wallet_file_name_callback, enter_if_use_seed_extension, - enter_seed_extension_callback, mixdepth=DEFAULT_MIXDEPTH): + enter_seed_extension_callback, enter_do_support_fidelity_bonds, mixdepth=DEFAULT_MIXDEPTH): entropy = None mnemonic_extension = None if method == "generate": @@ -537,7 +583,10 @@ def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, wallet_name = default_wallet_name wallet_path = os.path.join(walletspath, wallet_name) - wallet = create_wallet(wallet_path, password, mixdepth, + support_fidelity_bonds = enter_do_support_fidelity_bonds() + wallet_cls = get_wallet_cls(get_configured_wallet_type(support_fidelity_bonds)) + + wallet = create_wallet(wallet_path, password, mixdepth, wallet_cls, entropy=entropy, entropy_extension=mnemonic_extension) mnemonic, mnext = wallet.get_mnemonic_words() @@ -555,7 +604,7 @@ def wallet_generate_recover(method, walletspath, default_wallet_name, cli_display_user_words, cli_user_mnemonic_entry, cli_get_wallet_passphrase_check, cli_get_wallet_file_name, cli_do_use_mnemonic_extension, cli_get_mnemonic_extension, - mixdepth=mixdepth) + cli_do_support_fidelity_bonds, mixdepth=mixdepth) entropy = None if method == 'recover': @@ -1035,29 +1084,54 @@ def wallet_freezeutxo(wallet, md, display_callback=None, info_callback=None): .format(fmt_utxo((txid, index)))) return "Done" -def get_wallet_type(): + + +def wallet_gettimelockaddress(wallet_service, locktime_string): + if not isinstance(wallet_service.wallet, FidelityBondMixin): + jmprint("Error: not a fidelity bond wallet", "error") + return "" + + m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + index = wallet_service.get_next_unused_index(m, address_type) + lock_datetime = datetime.strptime(locktime_string, "%Y-%m") + timenumber = FidelityBondMixin.timestamp_to_time_number(timegm( + lock_datetime.timetuple())) + + path = wallet_service.get_path(m, address_type, index, timenumber) + jmprint("path = " + wallet_service.get_path_repr(path), "info") + jmprint("Coins sent to this address will be not be spendable until " + + lock_datetime.strftime("%B %Y") + ". Full date: " + + str(lock_datetime)) + addr = wallet_service.get_address_from_path(path) + return addr + +def get_configured_wallet_type(support_fidelity_bonds): + configured_type = TYPE_P2PKH if is_segwit_mode(): if is_native_segwit_mode(): - return TYPE_P2WPKH - return TYPE_P2SH_P2WPKH - return TYPE_P2PKH + configured_type = TYPE_P2WPKH + else: + configured_type = TYPE_P2SH_P2WPKH + if not support_fidelity_bonds: + return configured_type -def get_wallet_cls(wtype=None): - if wtype is None: - wtype = get_wallet_type() + if configured_type == TYPE_P2SH_P2WPKH: + return TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + else: + raise ValueError("Fidelity bonds not supported with the configured " + "options of segwit and native. Edit joinmarket.cfg") +def get_wallet_cls(wtype): cls = WALLET_IMPLEMENTATIONS.get(wtype) - if not cls: raise WalletError("No wallet implementation found for type {}." "".format(wtype)) return cls - -def create_wallet(path, password, max_mixdepth, wallet_cls=None, **kwargs): +def create_wallet(path, password, max_mixdepth, wallet_cls, **kwargs): storage = Storage(path, password, create=True) - wallet_cls = wallet_cls or get_wallet_cls() wallet_cls.initialize(storage, get_network(), max_mixdepth=max_mixdepth, **kwargs) storage.save() @@ -1195,11 +1269,12 @@ def wallet_tool_main(wallet_root_path): wallet_root_path = os.path.join(jm_single().datadir, wallet_root_path) noseed_methods = ['generate', 'recover'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', - 'history', 'showutxos', 'freeze'] + 'history', 'showutxos', 'freeze', 'gettimelockaddress'] methods.extend(noseed_methods) noscan_methods = ['showseed', 'importprivkey', 'dumpprivkey', 'signmessage'] readonly_methods = ['display', 'displayall', 'summary', 'showseed', - 'history', 'showutxos', 'dumpprivkey', 'signmessage'] + 'history', 'showutxos', 'dumpprivkey', 'signmessage', + 'gettimelockaddress'] if len(args) < 1: parser.error('Needs a wallet file or method') @@ -1273,9 +1348,17 @@ def wallet_tool_main(wallet_root_path): map_key_type(options.key_type)) return "Key import completed." elif method == "signmessage": + if len(args) < 3: + jmprint('Must provide message to sign', "error") + sys.exit(EXIT_ARGERROR) return wallet_signmessage(wallet_service, options.hd_path, args[2]) elif method == "freeze": return wallet_freezeutxo(wallet_service, options.mixdepth) + elif method == "gettimelockaddress": + if len(args) < 3: + jmprint('Must have locktime value yyyy-mm. For example 2021-03', "error") + sys.exit(EXIT_ARGERROR) + return wallet_gettimelockaddress(wallet_service, args[2]) else: parser.error("Unknown wallet-tool method: " + method) sys.exit(EXIT_ARGERROR) diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index a3bea2b..6764f88 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -1808,7 +1808,8 @@ class JMMainWindow(QMainWindow): enter_wallet_password_callback=self.getPassword, enter_wallet_file_name_callback=self.getWalletFileName, enter_if_use_seed_extension=self.promptUseMnemonicExtension, - enter_seed_extension_callback=self.promptInputMnemonicExtension) + enter_seed_extension_callback=self.promptInputMnemonicExtension, + enter_do_support_fidelity_bonds=lambda: False) if not success: JMQtMessageBox(self, "Failed to create new wallet file.", From 2271ce05d7a6ef7715d178a49ee23bf9b95ca82f Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Fri, 3 Apr 2020 20:04:17 +0100 Subject: [PATCH 05/16] Add support for burning coins with sendpayment Users burn coins by passing "BURN" as an address to sendpayment --- jmbitcoin/jmbitcoin/secp256k1_transaction.py | 7 +++ jmclient/jmclient/__init__.py | 5 +- jmclient/jmclient/cli_options.py | 2 +- jmclient/jmclient/configure.py | 5 ++ jmclient/jmclient/taker_utils.py | 66 ++++++++++++++++---- jmclient/jmclient/wallet.py | 10 +-- scripts/sendpayment.py | 11 +++- 7 files changed, 85 insertions(+), 21 deletions(-) diff --git a/jmbitcoin/jmbitcoin/secp256k1_transaction.py b/jmbitcoin/jmbitcoin/secp256k1_transaction.py index 34df966..b8982da 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_transaction.py +++ b/jmbitcoin/jmbitcoin/secp256k1_transaction.py @@ -670,6 +670,13 @@ def mk_freeze_script(pub, locktime): btc.OP_CHECKSIG] return binascii.hexlify(serialize_script(scr)).decode() +def mk_burn_script(data): + if not isinstance(data, bytes): + raise TypeError("data must be in bytes") + data = binascii.hexlify(data).decode() + scr = [btc.OP_RETURN, data] + return serialize_script(scr) + # Signing and verifying def verify_tx_input(tx, i, script, sig, pub, scriptCode=None, amount=None): diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index c00fe7e..14930ea 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -20,8 +20,9 @@ from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, from .cryptoengine import BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, EngineError from .configure import (load_test_config, load_program_config, get_p2pk_vbyte, jm_single, get_network, update_persist_config, - validate_address, get_irc_mchannels, get_blockchain_interface_instance, - get_p2sh_vbyte, set_config, is_segwit_mode, is_native_segwit_mode) + validate_address, is_burn_destination, get_irc_mchannels, + get_blockchain_interface_instance, get_p2sh_vbyte, set_config, is_segwit_mode, + is_native_segwit_mode) from .blockchaininterface import (BlockchainInterface, RegtestBitcoinCoreInterface, BitcoinCoreInterface) from .electruminterface import ElectrumInterface diff --git a/jmclient/jmclient/cli_options.py b/jmclient/jmclient/cli_options.py index 95dee6c..3e1e584 100644 --- a/jmclient/jmclient/cli_options.py +++ b/jmclient/jmclient/cli_options.py @@ -442,7 +442,7 @@ def get_tumbler_parser(): def get_sendpayment_parser(): parser = OptionParser( usage= - 'usage: %prog [options] wallet_file amount destaddr\n' + + 'usage: %prog [options] wallet_file amount destination\n' + ' %prog [options] wallet_file bitcoin_uri', description='Sends a single payment from a given mixing depth of your ' + diff --git a/jmclient/jmclient/configure.py b/jmclient/jmclient/configure.py index d7e321a..48372b2 100644 --- a/jmclient/jmclient/configure.py +++ b/jmclient/jmclient/configure.py @@ -405,6 +405,11 @@ def validate_address(addr): return True, 'address validated' +_BURN_DESTINATION = "BURN" + +def is_burn_destination(destination): + return destination == _BURN_DESTINATION + def donation_address(reusable_donation_pubkey=None): #pragma: no cover #Donation code currently disabled, so not tested. if not reusable_donation_pubkey: diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index e6fe31b..c5f958a 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -5,13 +5,16 @@ import os import sys import time import numbers +from binascii import hexlify + from jmbase import get_log, jmprint -from .configure import jm_single, validate_address +from .configure import jm_single, validate_address, is_burn_destination from .schedule import human_readable_schedule_entry, tweak_tumble_schedule,\ schedule_to_text -from .wallet import BaseWallet, estimate_tx_fee, compute_tx_locktime +from .wallet import BaseWallet, estimate_tx_fee, compute_tx_locktime, \ + FidelityBondMixin from jmbitcoin import deserialize, make_shuffled_tx, serialize, txhash,\ - amount_to_str + amount_to_str, mk_burn_script, bin_hash160 from jmbase.support import EXIT_SUCCESS log = get_log() @@ -20,7 +23,7 @@ Utility functions for tumbler-style takers; Currently re-used by CLI script tumbler.py and joinmarket-qt """ -def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, +def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, accept_callback=None, info_callback=None): """Send coins directly from one mixdepth to one destination address; does not need IRC. Sweep as for normal sendpayment (set amount=0). @@ -41,24 +44,65 @@ def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, The txid if transaction is pushed, False otherwise """ #Sanity checks - assert validate_address(destaddr)[0] + assert validate_address(destination)[0] or is_burn_destination(destination) assert isinstance(mixdepth, numbers.Integral) assert mixdepth >= 0 assert isinstance(amount, numbers.Integral) assert amount >=0 assert isinstance(wallet_service.wallet, BaseWallet) + if is_burn_destination(destination): + #Additional checks + if not isinstance(wallet_service.wallet, FidelityBondMixin): + log.error("Only fidelity bond wallets can burn coins") + return + if answeryes: + log.error("Burning coins not allowed without asking for confirmation") + return + if mixdepth != FidelityBondMixin.FIDELITY_BOND_MIXDEPTH: + log.error("Burning coins only allowed from mixdepth " + str( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH)) + return + if amount != 0: + log.error("Only sweeping allowed when burning coins, to keep the tx " + + "small. Tip: use the coin control feature to freeze utxos") + return + from pprint import pformat txtype = wallet_service.get_txtype() if amount == 0: utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth] if utxos == {}: log.error( - "There are no utxos in mixdepth: " + str(mixdepth) + ", quitting.") + "There are no available utxos in mixdepth: " + str(mixdepth) + ", quitting.") return + total_inputs_val = sum([va['value'] for u, va in iteritems(utxos)]) - fee_est = estimate_tx_fee(len(utxos), 1, txtype=txtype) - outs = [{"address": destaddr, "value": total_inputs_val - fee_est}] + + if is_burn_destination(destination): + if len(utxos) > 1: + log.error("Only one input allowed when burning coins, to keep " + + "the tx small. Tip: use the coin control feature to freeze utxos") + return + address_type = FidelityBondMixin.BIP32_BURN_ID + index = wallet_service.wallet.get_next_unused_index(mixdepth, address_type) + path = wallet_service.wallet.get_path(mixdepth, address_type, index) + privkey, engine = wallet_service.wallet._get_priv_from_path(path) + pubkey = engine.privkey_to_pubkey(privkey) + pubkeyhash = bin_hash160(pubkey) + + #size of burn output is slightly different from regular outputs + burn_script = mk_burn_script(pubkeyhash) #in hex + fee_est = estimate_tx_fee(len(utxos), 0, txtype=txtype, extra_bytes=len(burn_script)/2) + + outs = [{"script": burn_script, "value": total_inputs_val - fee_est}] + destination = "BURNER OUTPUT embedding pubkey at " \ + + wallet_service.wallet.get_path_repr(path) \ + + "\n\nWARNING: This transaction if broadcasted will PERMANENTLY DESTROY your bitcoins\n" + else: + #regular send (non-burn) + fee_est = estimate_tx_fee(len(utxos), 1, txtype=txtype) + outs = [{"address": destination, "value": total_inputs_val - fee_est}] else: #8 inputs to be conservative initial_fee_est = estimate_tx_fee(8,2, txtype=txtype) @@ -69,7 +113,7 @@ def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, fee_est = initial_fee_est total_inputs_val = sum([va['value'] for u, va in iteritems(utxos)]) changeval = total_inputs_val - fee_est - amount - outs = [{"value": amount, "address": destaddr}] + outs = [{"value": amount, "address": destination}] change_addr = wallet_service.get_internal_addr(mixdepth) outs.append({"value": changeval, "address": change_addr}) @@ -85,14 +129,14 @@ def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, log.info("In serialized form (for copy-paste):") log.info(tx) actual_amount = amount if amount != 0 else total_inputs_val - fee_est - log.info("Sends: " + amount_to_str(actual_amount) + " to address: " + destaddr) + log.info("Sends: " + amount_to_str(actual_amount) + " to destination: " + destination) if not answeryes: if not accept_callback: if input('Would you like to push to the network? (y/n):')[0] != 'y': log.info("You chose not to broadcast the transaction, quitting.") return False else: - accepted = accept_callback(pformat(txsigned), destaddr, actual_amount, + accepted = accept_callback(pformat(txsigned), destination, actual_amount, fee_est) if not accepted: return False diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 3f1dae5..499cc90 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -68,7 +68,7 @@ class Mnemonic(MnemonicParent): def detect_language(cls, code): return "english" -def estimate_tx_fee(ins, outs, txtype='p2pkh'): +def estimate_tx_fee(ins, outs, txtype='p2pkh', extra_bytes=0): '''Returns an estimate of the number of satoshis required for a transaction with the given number of inputs and outputs, based on information from the blockchain interface. @@ -81,13 +81,14 @@ def estimate_tx_fee(ins, outs, txtype='p2pkh'): raise ValueError("Estimated fee per kB greater than absurd value: " + \ str(absurd_fee) + ", quitting.") if txtype in ['p2pkh', 'p2shMofN']: - tx_estimated_bytes = btc.estimate_tx_size(ins, outs, txtype) + tx_estimated_bytes = btc.estimate_tx_size(ins, outs, txtype) + extra_bytes return int((tx_estimated_bytes * fee_per_kb)/Decimal(1000.0)) elif txtype in ['p2wpkh', 'p2sh-p2wpkh']: witness_estimate, non_witness_estimate = btc.estimate_tx_size( ins, outs, txtype) + non_witness_estimate += extra_bytes return int(int(( - non_witness_estimate + 0.25*witness_estimate)*fee_per_kb)/Decimal(1000.0)) + non_witness_estimate + 0.25*witness_estimate)*fee_per_kb)/Decimal(1000.0)) else: raise NotImplementedError("Txtype: " + txtype + " not implemented.") @@ -416,7 +417,8 @@ class BaseWallet(object): """ if self.TYPE == TYPE_P2PKH: return 'p2pkh' - elif self.TYPE == TYPE_P2SH_P2WPKH: + elif self.TYPE in (TYPE_P2SH_P2WPKH, + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS): return 'p2sh-p2wpkh' elif self.TYPE == TYPE_P2WPKH: return 'p2wpkh' diff --git a/scripts/sendpayment.py b/scripts/sendpayment.py index 5fc2c54..587c7ef 100644 --- a/scripts/sendpayment.py +++ b/scripts/sendpayment.py @@ -12,8 +12,8 @@ from twisted.internet import reactor import pprint from jmclient import Taker, P2EPTaker, load_program_config, get_schedule,\ - JMClientProtocolFactory, start_reactor, validate_address, jm_single,\ - estimate_tx_fee, direct_send, WalletService,\ + JMClientProtocolFactory, start_reactor, validate_address, is_burn_destination, \ + jm_single, estimate_tx_fee, direct_send, WalletService,\ open_test_wallet_maybe, get_wallet_path, NO_ROUNDING, \ get_sendpayment_parser, get_max_cj_fee_values, check_regtest from twisted.python.log import startLogging @@ -81,8 +81,13 @@ def main(): destaddr = args[2] mixdepth = options.mixdepth addr_valid, errormsg = validate_address(destaddr) - if not addr_valid: + command_to_burn = (is_burn_destination(destaddr) and sweeping and + options.makercount == 0 and not options.p2ep) + if not addr_valid and not command_to_burn: jmprint('ERROR: Address invalid. ' + errormsg, "error") + if is_burn_destination(destaddr): + jmprint("The required options for burning coins are zero makers" + + " (-N 0), sweeping (amount = 0) and not using P2EP", "info") sys.exit(EXIT_ARGERROR) if sweeping == False and amount < DUST_THRESHOLD: jmprint('ERROR: Amount ' + btc.amount_to_str(amount) + From 255d15562d4de5b36eb2d059fff35037a4439248 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Sun, 5 Apr 2020 18:29:32 +0100 Subject: [PATCH 06/16] Add merkle proof functions to BitcoinCoreInterface --- jmclient/jmclient/blockchaininterface.py | 25 +++++++++++++++ jmclient/jmclient/wallet_service.py | 40 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/jmclient/jmclient/blockchaininterface.py b/jmclient/jmclient/blockchaininterface.py index a01f55e..71e43ae 100644 --- a/jmclient/jmclient/blockchaininterface.py +++ b/jmclient/jmclient/blockchaininterface.py @@ -4,6 +4,7 @@ import random import sys import time from decimal import Decimal +import binascii from twisted.internet import reactor, task import jmbitcoin as btc @@ -406,6 +407,30 @@ class BitcoinCoreInterface(BlockchainInterface): except JsonRpcError: return self.rpc('getblock', [blockhash])['time'] + def get_tx_merkle_branch(self, txid, blockhash=None): + if not blockhash: + tx = self.rpc("gettransaction", [txid]) + if tx["confirmations"] < 1: + raise ValueError("Transaction not in block") + blockhash = tx["blockhash"] + try: + core_proof = self.rpc("gettxoutproof", [[txid], blockhash]) + except JsonRpcError: + raise ValueError("Block containing transaction is pruned") + return self.core_proof_to_merkle_branch(core_proof) + + def core_proof_to_merkle_branch(self, core_proof): + core_proof = binascii.unhexlify(core_proof) + #first 80 bytes of a proof given by core are just a block header + #so we can save space by replacing it with a 4-byte block height + return core_proof[80:] + + def verify_tx_merkle_branch(self, txid, block_height, merkle_branch): + block_hash = self.rpc("getblockhash", [block_height]) + core_proof = self.rpc("getblockheader", [block_hash, False]) + \ + binascii.hexlify(merkle_branch).decode() + ret = self.rpc("verifytxoutproof", [core_proof]) + return len(ret) == 1 and ret[0] == txid class RegtestBitcoinCoreMixin(): """ diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index c21fc70..0c92fb2 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -537,6 +537,46 @@ class WalletService(Service): jmprint(restart_msg, "important") sys.exit(EXIT_SUCCESS) + def sync_burner_outputs(self, burner_txes): + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + self.wallet.set_next_index(mixdepth, address_type, self.wallet.gap_limit, + force=True) + highest_used_index = 0 + + known_burner_outputs = self.wallet.get_burner_outputs() + for index in range(self.wallet.gap_limit): + path = self.wallet.get_path(mixdepth, address_type, index) + path_privkey, engine = self.wallet._get_priv_from_path(path) + path_pubkey = engine.privkey_to_pubkey(path_privkey) + path_pubkeyhash = btc.bin_hash160(path_pubkey) + + for burner_tx in burner_txes: + burner_pubkeyhash, gettx = burner_tx + if burner_pubkeyhash != path_pubkeyhash: + continue + highest_used_index = index + path_repr = self.wallet.get_path_repr(path) + if path_repr.encode() in known_burner_outputs: + continue + txid = gettx["txid"] + jlog.info("Found a burner transaction txid=" + txid + " path = " + + path_repr) + try: + merkle_branch = self.bci.get_tx_merkle_branch(txid, gettx["blockhash"]) + except ValueError as e: + jlog.warning(repr(e)) + jlog.warning("Merkle branch likely not available, use " + + "wallet-tool `addtxoutproof`") + merkle_branch = None + block_height = self.bci.rpc("getblockheader", [gettx["blockhash"]])["height"] + if merkle_branch: + assert self.bci.verify_tx_merkle_branch(txid, block_height, merkle_branch) + self.wallet.add_burner_output(path_repr, gettx["hex"], block_height, + merkle_branch, gettx["blockindex"]) + + self.wallet.set_next_index(mixdepth, address_type, highest_used_index + 1) + def sync_addresses(self): """ Triggered by use of --recoversync option in scripts, attempts a full scan of the blockchain without assuming From 97216d3db4f46fdf1a2ae233682f1f7c84cb4c58 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Sun, 5 Apr 2020 17:52:44 +0100 Subject: [PATCH 07/16] Sync burner outputs and display in wallet-tool --- jmclient/jmclient/wallet.py | 26 ++++++++++++++ jmclient/jmclient/wallet_service.py | 55 ++++++++++++++++++++++++---- jmclient/jmclient/wallet_utils.py | 56 +++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 499cc90..8251a3e 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1655,6 +1655,10 @@ class FidelityBondMixin(object): #only one mixdepth will have fidelity bonds in it FIDELITY_BOND_MIXDEPTH = 0 + MERKLE_BRANCH_UNAVAILABLE = b"mbu" + + _BURNER_OUTPUT_STORAGE_KEY = b"burner-out" + @classmethod def _time_number_to_timestamp(cls, timenumber): """ @@ -1776,6 +1780,28 @@ class FidelityBondMixin(object): script = self.get_script(mixdepth, address_type, index, timenumber) return self.script_to_addr(script) + def add_burner_output(self, path, txhex, block_height, merkle_branch, + block_index, write=True): + """ + merkle_branch = None means it was unavailable because of pruning + """ + if self._BURNER_OUTPUT_STORAGE_KEY not in self._storage.data: + self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY] = {} + path = path.encode() + txhex = unhexlify(txhex) + if not merkle_branch: + merkle_branch = self.MERKLE_BRANCH_UNAVAILABLE + self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY][path] = [txhex, + block_height, merkle_branch, block_index] + if write: + self._storage.save() + + def get_burner_outputs(self): + """ + Result is a dict {path: [txhex, blockheight, merkleproof, blockindex]} + """ + return self._storage.data.get(self._BURNER_OUTPUT_STORAGE_KEY, {}) + #class FidelityBondWatchonlyWallet(ImportWalletMixin, BIP39WalletMixin, FidelityBondMixin): diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index 0c92fb2..c7a2e28 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -5,6 +5,7 @@ import time import ast import binascii import sys +import itertools from decimal import Decimal from copy import deepcopy from twisted.internet import reactor @@ -17,6 +18,7 @@ from jmclient.blockchaininterface import (INF_HEIGHT, BitcoinCoreInterface, BitcoinCoreNoHistoryInterface) from jmclient.wallet import FidelityBondMixin from jmbase.support import jmprint, EXIT_SUCCESS +import jmbitcoin as btc """Wallet service The purpose of this independent service is to allow @@ -517,6 +519,19 @@ class WalletService(Service): # index: self.bci.import_addresses(self.collect_addresses_gap(), self.get_wallet_name(), self.restart_callback) + + if isinstance(self.wallet, FidelityBondMixin): + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + + burner_outputs = self.wallet.get_burner_outputs() + max_index = 0 + for path_repr in burner_outputs: + index = self.wallet.path_repr_to_path(path_repr.decode())[-1] + max_index = max(index+1, max_index) + self.wallet.set_next_index(mixdepth, address_type, max_index, + force=True) + self.synced = True def display_rescan_message_and_system_exit(self, restart_cb): @@ -543,14 +558,16 @@ class WalletService(Service): self.wallet.set_next_index(mixdepth, address_type, self.wallet.gap_limit, force=True) highest_used_index = 0 - known_burner_outputs = self.wallet.get_burner_outputs() - for index in range(self.wallet.gap_limit): + + index = -1 + while index - highest_used_index < self.wallet.gap_limit: + index += 1 + self.wallet.set_next_index(mixdepth, address_type, index, force=True) path = self.wallet.get_path(mixdepth, address_type, index) path_privkey, engine = self.wallet._get_priv_from_path(path) path_pubkey = engine.privkey_to_pubkey(path_privkey) path_pubkeyhash = btc.bin_hash160(path_pubkey) - for burner_tx in burner_txes: burner_pubkeyhash, gettx = burner_tx if burner_pubkeyhash != path_pubkeyhash: @@ -593,9 +610,35 @@ class WalletService(Service): self.display_rescan_message_and_system_exit(self.restart_callback) return - used_addresses_gen = (tx['address'] - for tx in self.bci._yield_transactions(wallet_name) - if tx['category'] == 'receive') + if isinstance(self.wallet, FidelityBondMixin): + tx_receive = [] + burner_txes = [] + for tx in self.bci._yield_transactions(wallet_name): + if tx['category'] == 'receive': + tx_receive.append(tx) + elif tx["category"] == "send": + gettx = self.bci.get_transaction(tx["txid"]) + txd = self.bci.get_deser_from_gettransaction(gettx) + if len(txd["outs"]) > 1: + continue + #must be mined into a block to sync + #otherwise there's no merkleproof or block index + if gettx["confirmations"] < 1: + continue + script = binascii.unhexlify(txd["outs"][0]["script"]) + if script[0] != 0x6a: #OP_RETURN + continue + pubkeyhash = script[2:] + burner_txes.append((pubkeyhash, gettx)) + + self.sync_burner_outputs(burner_txes) + used_addresses_gen = (tx["address"] for tx in tx_receive) + else: + #not fidelity bond wallet, significantly faster sync + used_addresses_gen = (tx['address'] + for tx in self.bci._yield_transactions(wallet_name) + if tx['category'] == 'receive') + used_indices = self.get_used_indices(used_addresses_gen) jlog.debug("got used indices: {}".format(used_indices)) gap_limit_used = not self.check_gap_indices(used_indices) diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 7ff37cc..c8355cf 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -217,6 +217,12 @@ class WalletViewEntry(WalletViewBase): ed += self.separator + self.serclass(self.private_key) return self.serclass(ed) +class WalletViewEntryBurnOutput(WalletViewEntry): + # balance in burn outputs shouldnt be counted + # towards the total balance + def get_balance(self, include_unconf=True): + return 0 + class WalletViewBranch(WalletViewBase): def __init__(self, wallet_path_repr, account, address_type, branchentries=None, xpub=None, serclass=str, custom_separator=None): @@ -261,7 +267,7 @@ class WalletViewAccount(WalletViewBase): self.account_name = account_name self.xpub = xpub if branches: - assert len(branches) in [2, 3] #3 if imported keys + assert len(branches) in [2, 3, 4] #3 if imported keys, 4 if fidelity bonds assert all([isinstance(x, WalletViewBranch) for x in branches]) self.branches = branches @@ -477,13 +483,54 @@ def wallet_display(wallet_service, showprivkey, displayall=False, entrylist.append(WalletViewEntry( wallet_service.get_path_repr(path), m, address_type, k, addr, [balance, balance], priv=privkey, used=status)) - #TODO fidelity bond master pub key is this, although it should include burner too 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, xpub=xpub_key)) + entrylist = [] + address_type = FidelityBondMixin.BIP32_BURN_ID + unused_index = wallet_service.get_next_unused_index(m, address_type) + burner_outputs = wallet_service.wallet.get_burner_outputs() + wallet_service.set_next_index(m, address_type, unused_index + + wallet_service.wallet.gap_limit, force=True) + for k in range(unused_index + wallet_service.wallet.gap_limit): + path = wallet_service.get_path(m, address_type, k) + path_repr = wallet_service.get_path_repr(path) + path_repr_b = path_repr.encode() + + privkey, engine = wallet_service._get_priv_from_path(path) + pubkey = engine.privkey_to_pubkey(privkey) + pubkeyhash = btc.bin_hash160(pubkey) + output = "BURN-" + binascii.hexlify(pubkeyhash).decode() + + balance = 0 + status = "no transaction" + if path_repr_b in burner_outputs: + txhex, blockheight, merkle_branch, blockindex = burner_outputs[path_repr_b] + txhex = binascii.hexlify(txhex).decode() + txd = btc.deserialize(txhex) + assert len(txd["outs"]) == 1 + balance = txd["outs"][0]["value"] + script = binascii.unhexlify(txd["outs"][0]["script"]) + assert script[0] == 0x6a #OP_RETURN + tx_pubkeyhash = script[2:] + assert tx_pubkeyhash == pubkeyhash + status = btc.txhash(txhex) + (" [NO MERKLE PROOF]" if + merkle_branch == FidelityBondMixin.MERKLE_BRANCH_UNAVAILABLE else "") + privkey = (wallet_service.get_wif_path(path) if showprivkey else "") + if displayall or balance > 0: + entrylist.append(WalletViewEntryBurnOutput(path_repr, m, + address_type, k, output, [balance, balance], + priv=privkey, used=status)) + wallet_service.set_next_index(m, address_type, unused_index) + + 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, + xpub=xpub_key)) + ipb = get_imported_privkey_branch(wallet_service, m, showprivkey) if ipb: branchlist.append(ipb) @@ -1294,6 +1341,11 @@ def wallet_tool_main(wallet_root_path): method = ('display' if len(args) == 1 else args[1].lower()) read_only = method in readonly_methods + #special case needed for fidelity bond burner outputs + #maybe theres a better way to do this + if options.recoversync: + read_only = False + wallet = open_test_wallet_maybe( wallet_path, seed, options.mixdepth, read_only=read_only, wallet_password_stdin=options.wallet_password_stdin, gap_limit=options.gaplimit) From a937c44efab00e4246f9f5887c0540c1d0bff5c2 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Tue, 7 Apr 2020 22:50:25 +0100 Subject: [PATCH 08/16] Add wallet-tool addtxoutproof method --- jmclient/jmclient/wallet.py | 5 +++++ jmclient/jmclient/wallet_utils.py | 33 +++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 8251a3e..320d14a 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1802,6 +1802,11 @@ class FidelityBondMixin(object): """ return self._storage.data.get(self._BURNER_OUTPUT_STORAGE_KEY, {}) + def set_burner_output_merkle_branch(self, path, merkle_branch): + path = path.encode() + self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY][path][2] = \ + merkle_branch + #class FidelityBondWatchonlyWallet(ImportWalletMixin, BIP39WalletMixin, FidelityBondMixin): diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index c8355cf..04c43a9 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -45,7 +45,9 @@ def get_wallettool_parser(): '(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.\n' '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.\n' - '(gettimelockaddress) Obtain a timelocked address. Argument is locktime value as yyyy-mm. For example `2021-03`') + '(gettimelockaddress) Obtain a timelocked address. Argument is locktime value as yyyy-mm. For example `2021-03`\n' + '(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') parser = OptionParser(usage='usage: %prog [options] [wallet file] [method] [args..]', description=description) add_base_options(parser) @@ -1153,6 +1155,27 @@ def wallet_gettimelockaddress(wallet_service, locktime_string): addr = wallet_service.get_address_from_path(path) return addr +def wallet_addtxoutproof(wallet_service, hdpath, txoutproof): + if not isinstance(wallet_service.wallet, FidelityBondMixin): + jmprint("Error: not a fidelity bond wallet", "error") + return "" + path = hdpath.encode() + if path not in wallet_service.wallet.get_burner_outputs(): + jmprint("Error: unknown burner transaction with on that path", "error") + return "" + txhex, block_height, old_merkle_branch, block_index = \ + wallet_service.wallet.get_burner_outputs()[path] + new_merkle_branch = jm_single().bc_interface.core_proof_to_merkle_branch(txoutproof) + txhex = binascii.hexlify(txhex).decode() + txid = btc.txhash(txhex) + if not jm_single().bc_interface.verify_tx_merkle_branch(txid, block_height, + new_merkle_branch): + jmprint("Error: tx out proof invalid", "error") + return "" + wallet_service.wallet.add_burner_output(hdpath, txhex, block_height, + new_merkle_branch, block_index) + return "Done" + def get_configured_wallet_type(support_fidelity_bonds): configured_type = TYPE_P2PKH if is_segwit_mode(): @@ -1316,7 +1339,7 @@ def wallet_tool_main(wallet_root_path): wallet_root_path = os.path.join(jm_single().datadir, wallet_root_path) noseed_methods = ['generate', 'recover'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', - 'history', 'showutxos', 'freeze', 'gettimelockaddress'] + 'history', 'showutxos', 'freeze', 'gettimelockaddress', 'addtxoutproof'] methods.extend(noseed_methods) noscan_methods = ['showseed', 'importprivkey', 'dumpprivkey', 'signmessage'] readonly_methods = ['display', 'displayall', 'summary', 'showseed', @@ -1411,6 +1434,12 @@ def wallet_tool_main(wallet_root_path): jmprint('Must have locktime value yyyy-mm. For example 2021-03', "error") sys.exit(EXIT_ARGERROR) return wallet_gettimelockaddress(wallet_service, args[2]) + elif method == "addtxoutproof": + if len(args) < 3: + jmprint('Must have txout proof, which is the output of Bitcoin ' + + 'Core\'s RPC call gettxoutproof', "error") + sys.exit(EXIT_ARGERROR) + return wallet_addtxoutproof(wallet_service, options.hd_path, args[2]) else: parser.error("Unknown wallet-tool method: " + method) sys.exit(EXIT_ARGERROR) From 762b1f644df1c04c96be2cd09f24c2ba679c2169 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Wed, 22 Apr 2020 17:20:04 +0100 Subject: [PATCH 09/16] Add watch only wallets for fidelity bonds Watch only wallets can now be created via wallet-tool. The wallets store a bip32 xpub key from which all the public keys are generated. Watch only wallets only store and display the zeroth mixdepth, which is the only one needed for fidelity bonds. The bip32 xpub key needed to create a watch only wallet is now specially highlighted in the wallet-tool display, this is to help users actually find it amongst all the other xpubs. The field key_ident in the wallet class was previously generated using private keys, which are not available in watch only wallets. So now for fidelity bond wallets key_ident is generated using a public key. Existing non-fidelity-bond wallets are unaffected --- .../jmbitcoin/secp256k1_deterministic.py | 2 + jmclient/jmclient/__init__.py | 4 +- jmclient/jmclient/cryptoengine.py | 64 ++++++++++++++++++- jmclient/jmclient/wallet.py | 56 +++++++++++++--- jmclient/jmclient/wallet_utils.py | 43 +++++++++++-- 5 files changed, 148 insertions(+), 21 deletions(-) diff --git a/jmbitcoin/jmbitcoin/secp256k1_deterministic.py b/jmbitcoin/jmbitcoin/secp256k1_deterministic.py index fea699f..1baad53 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_deterministic.py +++ b/jmbitcoin/jmbitcoin/secp256k1_deterministic.py @@ -65,6 +65,8 @@ def bip32_deserialize(data): def raw_bip32_privtopub(rawtuple): vbytes, depth, fingerprint, i, chaincode, key = rawtuple + if vbytes in PUBLIC: + return rawtuple newvbytes = MAINNET_PUBLIC if vbytes == MAINNET_PRIVATE else TESTNET_PUBLIC return (newvbytes, depth, fingerprint, i, chaincode, privtopub(key, False)) diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 14930ea..f982920 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -13,8 +13,8 @@ from .old_mnemonic import mn_decode, mn_encode from .taker import Taker, P2EPTaker from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportWalletMixin, BIP39WalletMixin, BIP32Wallet, BIP49Wallet, LegacyWallet, - SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, UTXOManager, - WALLET_IMPLEMENTATIONS, compute_tx_locktime) + SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, + UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) from .cryptoengine import BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, EngineError diff --git a/jmclient/jmclient/cryptoengine.py b/jmclient/jmclient/cryptoengine.py index 24ccfe9..800c301 100644 --- a/jmclient/jmclient/cryptoengine.py +++ b/jmclient/jmclient/cryptoengine.py @@ -7,9 +7,13 @@ import struct import jmbitcoin as btc from .configure import get_network, jm_single - +#NOTE: before fidelity bonds and watchonly wallet, each of these types corresponded +# to one wallet type and one engine, not anymore +#with fidelity bond wallets and watchonly fidelity bond wallet, the wallet class +# can have two engines, one for single-sig addresses and the other for timelocked addresses TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N, TYPE_TIMELOCK_P2WSH, \ - TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS = range(6) + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS, TYPE_WATCHONLY_FIDELITY_BONDS, \ + TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2SH_P2WPKH = range(9) NET_MAINNET, NET_TESTNET = range(2) NET_MAP = {'mainnet': NET_MAINNET, 'testnet': NET_TESTNET} WIF_PREFIX_MAP = {'mainnet': b'\x80', 'testnet': b'\xef'} @@ -130,6 +134,7 @@ class BTCEngine(object): @classmethod def derive_bip32_pub_export(cls, master_key, path): + #in the case of watchonly wallets this priv is actually a pubkey priv = cls._walk_bip32_path(master_key, path) return btc.bip32_serialize(btc.raw_bip32_privtopub(priv)) @@ -336,9 +341,62 @@ class BTC_Timelocked_P2WSH(BTCEngine): hashcode=btc.SIGHASH_ALL, **kwargs): raise RuntimeError("Cannot spend from watch-only wallets") +class BTC_Watchonly_Timelocked_P2WSH(BTC_Timelocked_P2WSH): + + @classmethod + def get_watchonly_path(cls, path): + #given path is something like "m/49'/1'/0'/0/0" + #but watchonly wallet already stores the xpub for "m/49'/1'/0'/" + #so to make this work we must chop off the first 3 elements + return path[3:] + + @classmethod + def derive_bip32_privkey(cls, master_key, path): + assert len(path) > 1 + return cls._walk_bip32_path(master_key, cls.get_watchonly_path( + path))[-1] + + @classmethod + def privkey_to_script(cls, pubkey_locktime): + pub, locktime = pubkey_locktime + return cls.pubkey_to_script((pub, locktime)) + + @classmethod + def privkey_to_wif(cls, privkey_locktime): + return "" + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise Exception("not implemented yet") + +class BTC_Watchonly_P2SH_P2WPKH(BTC_P2SH_P2WPKH): + + @classmethod + def derive_bip32_privkey(cls, master_key, path): + return BTC_Watchonly_Timelocked_P2WSH.derive_bip32_privkey(master_key, path) + + @classmethod + def privkey_to_wif(cls, privkey_locktime): + return BTC_Watchonly_Timelocked_P2WSH.privkey_to_wif(privkey_locktime) + + @staticmethod + def privkey_to_pubkey(privkey): + #in watchonly wallets there are no privkeys, so functions + # like _get_priv_from_path() actually return pubkeys and + # this function is a noop + return privkey + + @classmethod + def derive_bip32_pub_export(cls, master_key, path): + return super(BTC_Watchonly_P2SH_P2WPKH, cls).derive_bip32_pub_export( + master_key, BTC_Watchonly_Timelocked_P2WSH.get_watchonly_path(path)) + ENGINES = { TYPE_P2PKH: BTC_P2PKH, TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH, TYPE_P2WPKH: BTC_P2WPKH, - TYPE_TIMELOCK_P2WSH: BTC_Timelocked_P2WSH + TYPE_TIMELOCK_P2WSH: BTC_Timelocked_P2WSH, + TYPE_WATCHONLY_TIMELOCK_P2WSH: BTC_Watchonly_Timelocked_P2WSH, + TYPE_WATCHONLY_P2SH_P2WPKH: BTC_Watchonly_P2SH_P2WPKH } diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 320d14a..0136484 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -22,6 +22,7 @@ from .support import select_gradual, select_greedy, select_greediest, \ select from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH,\ TYPE_P2WPKH, TYPE_TIMELOCK_P2WSH, TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS,\ + TYPE_WATCHONLY_FIDELITY_BONDS, TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2SH_P2WPKH,\ ENGINES from .support import get_random_bytes from . import mn_encode, mn_decode @@ -1303,9 +1304,7 @@ class BIP32Wallet(BaseWallet): # used to verify paths for sanity checking and for wallet id creation self._key_ident = b'' # otherwise get_bip32_* won't work - self._key_ident = sha256(sha256( - self.get_bip32_priv_export(0, 0).encode('ascii')).digest())\ - .digest()[:3] + self._key_ident = self._get_key_ident() self._populate_script_map() self.disable_new_scripts = False @@ -1347,6 +1346,11 @@ class BIP32Wallet(BaseWallet): self.max_mixdepth = max(0, 0, *self._index_cache.keys()) + def _get_key_ident(self): + return sha256(sha256( + self.get_bip32_priv_export(0, 0).encode('ascii')).digest())\ + .digest()[:3] + def _populate_script_map(self): for md in self._index_cache: for address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID): @@ -1652,6 +1656,8 @@ class FidelityBondMixin(object): """ TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR = 6 + _TIMELOCK_ENGINE = ENGINES[TYPE_TIMELOCK_P2WSH] + #only one mixdepth will have fidelity bonds in it FIDELITY_BOND_MIXDEPTH = 0 @@ -1659,6 +1665,8 @@ class FidelityBondMixin(object): _BURNER_OUTPUT_STORAGE_KEY = b"burner-out" + _BIP32_PUBKEY_PREFIX = "fbonds-mpk-" + @classmethod def _time_number_to_timestamp(cls, timenumber): """ @@ -1701,6 +1709,13 @@ class FidelityBondMixin(object): pub = engine.privkey_to_pubkey(priv) return sha256(sha256(pub).digest()).digest()[:3] + @classmethod + def get_xpub_from_fidelity_bond_master_pub_key(cls, mpk): + if mpk.startswith(cls._BIP32_PUBKEY_PREFIX): + return mpk[len(cls._BIP32_PUBKEY_PREFIX):] + else: + return False + def _populate_script_map(self): super(FidelityBondMixin, self)._populate_script_map() for md in self._index_cache: @@ -1711,6 +1726,12 @@ class FidelityBondMixin(object): script = self.get_script_from_path(path) self._script_map[script] = path + def get_bip32_pub_export(self, mixdepth=None, address_type=None): + bip32_pub = super(FidelityBondMixin, self).get_bip32_pub_export(mixdepth, address_type) + if address_type == None and mixdepth == self.FIDELITY_BOND_MIXDEPTH: + bip32_pub = self._BIP32_PUBKEY_PREFIX + bip32_pub + return bip32_pub + @classmethod def _get_supported_address_types(cls): return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID, cls.BIP32_TIMELOCK_ID, cls.BIP32_BURN_ID) @@ -1719,7 +1740,7 @@ class FidelityBondMixin(object): if self._is_timelocked_path(path): key_path = path[:-1] locktime = path[-1] - engine = ENGINES[TYPE_TIMELOCK_P2WSH] + engine = self._TIMELOCK_ENGINE privkey = engine.derive_bip32_privkey(self._master_key, key_path) return (privkey, locktime), engine else: @@ -1807,10 +1828,6 @@ class FidelityBondMixin(object): self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY][path][2] = \ merkle_branch -#class FidelityBondWatchonlyWallet(ImportWalletMixin, BIP39WalletMixin, FidelityBondMixin): - - - class BIP49Wallet(BIP32PurposedWallet): _PURPOSE = 2**31 + 49 _ENGINE = ENGINES[TYPE_P2SH_P2WPKH] @@ -1828,9 +1845,30 @@ class SegwitWallet(ImportWalletMixin, BIP39WalletMixin, BIP84Wallet): class SegwitLegacyWalletFidelityBonds(FidelityBondMixin, SegwitLegacyWallet): TYPE = TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + +class FidelityBondWatchonlyWallet(FidelityBondMixin, BIP49Wallet): + TYPE = TYPE_WATCHONLY_FIDELITY_BONDS + _ENGINE = ENGINES[TYPE_WATCHONLY_P2SH_P2WPKH] + _TIMELOCK_ENGINE = ENGINES[TYPE_WATCHONLY_TIMELOCK_P2WSH] + + @classmethod + def _verify_entropy(cls, ent): + return ent[1:4] == b"pub" + + @classmethod + def _derive_bip32_master_key(cls, master_entropy): + return btc.bip32_deserialize(master_entropy.decode()) + + def _get_bip32_export_path(self, mixdepth=None, address_type=None): + path = super(FidelityBondWatchonlyWallet, self)._get_bip32_export_path( + mixdepth, address_type) + return path + + WALLET_IMPLEMENTATIONS = { LegacyWallet.TYPE: LegacyWallet, SegwitLegacyWallet.TYPE: SegwitLegacyWallet, SegwitWallet.TYPE: SegwitWallet, - SegwitLegacyWalletFidelityBonds.TYPE: SegwitLegacyWalletFidelityBonds + SegwitLegacyWalletFidelityBonds.TYPE: SegwitLegacyWalletFidelityBonds, + FidelityBondWatchonlyWallet.TYPE: FidelityBondWatchonlyWallet } diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 04c43a9..c972709 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -13,8 +13,8 @@ from itertools import islice from jmclient import (get_network, WALLET_IMPLEMENTATIONS, Storage, podle, jm_single, BitcoinCoreInterface, WalletError, VolatileStorage, StoragePasswordError, is_segwit_mode, SegwitLegacyWallet, - LegacyWallet, SegwitWallet, FidelityBondMixin, is_native_segwit_mode, - load_program_config, add_base_options, check_regtest) + LegacyWallet, SegwitWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, + is_native_segwit_mode, load_program_config, add_base_options, check_regtest) from jmclient.wallet_service import WalletService from jmbase.support import get_password, jmprint, EXIT_FAILURE, EXIT_ARGERROR @@ -47,7 +47,8 @@ def get_wallettool_parser(): '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.\n' '(gettimelockaddress) Obtain a timelocked address. Argument is locktime value as yyyy-mm. For example `2021-03`\n' '(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') + '-H and proof which is output of Bitcoin Core\'s RPC call gettxoutproof\n' + '(createwatchonly) Create a watch-only fidelity bond wallet') parser = OptionParser(usage='usage: %prog [options] [wallet file] [method] [args..]', description=description) add_base_options(parser) @@ -485,7 +486,6 @@ def wallet_display(wallet_service, showprivkey, displayall=False, entrylist.append(WalletViewEntry( wallet_service.get_path_repr(path), m, address_type, k, addr, [balance, balance], priv=privkey, used=status)) - #TODO fidelity bond master pub key is this, although it should include burner too 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, @@ -556,8 +556,8 @@ def cli_get_wallet_passphrase_check(): return False return password -def cli_get_wallet_file_name(): - return input('Input wallet file name (default: wallet.jmdat): ') +def cli_get_wallet_file_name(defaultname="wallet.jmdat"): + return input('Input wallet file name (default: ' + defaultname + '): ') def cli_display_user_words(words, mnemonic_extension): text = 'Write down this wallet recovery mnemonic\n\n' + words +'\n' @@ -1176,6 +1176,30 @@ def wallet_addtxoutproof(wallet_service, hdpath, txoutproof): new_merkle_branch, block_index) return "Done" +def wallet_createwatchonly(wallet_root_path, master_pub_key): + + wallet_name = cli_get_wallet_file_name(defaultname="watchonly.jmdat") + if not wallet_name: + DEFAULT_WATCHONLY_WALLET_NAME = "watchonly.jmdat" + wallet_name = DEFAULT_WATCHONLY_WALLET_NAME + + wallet_path = os.path.join(wallet_root_path, wallet_name) + + password = cli_get_wallet_passphrase_check() + if not password: + return "" + + entropy = FidelityBondMixin.get_xpub_from_fidelity_bond_master_pub_key(master_pub_key) + if not entropy: + jmprint("Error with provided master pub key", "error") + return "" + entropy = entropy.encode() + + wallet = create_wallet(wallet_path, password, + max_mixdepth=FidelityBondMixin.FIDELITY_BOND_MIXDEPTH, + wallet_cls=FidelityBondWatchonlyWallet, entropy=entropy) + return "Done" + def get_configured_wallet_type(support_fidelity_bonds): configured_type = TYPE_P2PKH if is_segwit_mode(): @@ -1337,7 +1361,7 @@ def wallet_tool_main(wallet_root_path): check_regtest(blockchain_start=False) # full path to the wallets/ subdirectory in the user data area: wallet_root_path = os.path.join(jm_single().datadir, wallet_root_path) - noseed_methods = ['generate', 'recover'] + noseed_methods = ['generate', 'recover', 'createwatchonly'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', 'history', 'showutxos', 'freeze', 'gettimelockaddress', 'addtxoutproof'] methods.extend(noseed_methods) @@ -1440,6 +1464,11 @@ def wallet_tool_main(wallet_root_path): + 'Core\'s RPC call gettxoutproof', "error") sys.exit(EXIT_ARGERROR) return wallet_addtxoutproof(wallet_service, options.hd_path, args[2]) + elif method == "createwatchonly": + if len(args) < 2: + jmprint("args: [master public key]", "error") + sys.exit(EXIT_ARGERROR) + return wallet_createwatchonly(wallet_root_path, args[1]) else: parser.error("Unknown wallet-tool method: " + method) sys.exit(EXIT_ARGERROR) From a0a0d28f4e4f69602656f2515907a34e72b1c30c Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Mon, 27 Apr 2020 15:37:46 +0100 Subject: [PATCH 10/16] Add support for spending timelocked UTXOs The cryptoengine class BTC_Timelocked_P2WSH now implements sign_transaction() which can be used to spend timelocked UTXOs. FidelityBondMixin.is_timelocked_path() is now used outside the class so its leading underscore has been removed. --- jmclient/jmclient/cryptoengine.py | 24 +++++++++++++++++------- jmclient/jmclient/taker_utils.py | 18 +++++++++++++++++- jmclient/jmclient/wallet.py | 8 ++++---- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/jmclient/jmclient/cryptoengine.py b/jmclient/jmclient/cryptoengine.py index 800c301..1e1ff22 100644 --- a/jmclient/jmclient/cryptoengine.py +++ b/jmclient/jmclient/cryptoengine.py @@ -332,14 +332,19 @@ class BTC_Timelocked_P2WSH(BTCEngine): return btc.bin_to_b58check(priv, cls.WIF_PREFIX) @classmethod - def sign_transaction(cls, tx, index, privkey, amount, + def sign_transaction(cls, tx, index, privkey_locktime, amount, hashcode=btc.SIGHASH_ALL, **kwargs): - raise Exception("not implemented yet") + assert amount is not None - @classmethod - def sign_transaction(cls, tx, index, privkey, amount, - hashcode=btc.SIGHASH_ALL, **kwargs): - raise RuntimeError("Cannot spend from watch-only wallets") + privkey, locktime = privkey_locktime + privkey = hexlify(privkey).decode() + pubkey = btc.privkey_to_pubkey(privkey) + pubkey = unhexlify(pubkey) + redeem_script = cls.pubkey_to_script_code((pubkey, locktime)) + tx = btc.serialize(tx) + sig = btc.get_p2sh_signature(tx, index, redeem_script, privkey, + amount) + return btc.apply_freeze_signature(tx, index, redeem_script, sig) class BTC_Watchonly_Timelocked_P2WSH(BTC_Timelocked_P2WSH): @@ -368,7 +373,7 @@ class BTC_Watchonly_Timelocked_P2WSH(BTC_Timelocked_P2WSH): @classmethod def sign_transaction(cls, tx, index, privkey, amount, hashcode=btc.SIGHASH_ALL, **kwargs): - raise Exception("not implemented yet") + raise RuntimeError("Cannot spend from watch-only wallets") class BTC_Watchonly_P2SH_P2WPKH(BTC_P2SH_P2WPKH): @@ -392,6 +397,11 @@ class BTC_Watchonly_P2SH_P2WPKH(BTC_P2SH_P2WPKH): return super(BTC_Watchonly_P2SH_P2WPKH, cls).derive_bip32_pub_export( master_key, BTC_Watchonly_Timelocked_P2WSH.get_watchonly_path(path)) + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise RuntimeError("Cannot spend from watch-only wallets") + ENGINES = { TYPE_P2PKH: BTC_P2PKH, TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH, diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index c5f958a..8160caa 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -117,12 +117,28 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, change_addr = wallet_service.get_internal_addr(mixdepth) outs.append({"value": changeval, "address": change_addr}) + #compute transaction locktime, has special case for spending timelocked coins + tx_locktime = compute_tx_locktime() + if mixdepth == FidelityBondMixin.FIDELITY_BOND_MIXDEPTH and \ + isinstance(wallet_service.wallet, FidelityBondMixin): + for outpoint, utxo in utxos.items(): + path = wallet_service.script_to_path( + wallet_service.addr_to_script(utxo["address"])) + if not FidelityBondMixin.is_timelocked_path(path): + continue + path_locktime = path[-1] + tx_locktime = max(tx_locktime, path_locktime+1) + #compute_tx_locktime() gives a locktime in terms of block height + #timelocked addresses use unix time instead + #OP_CHECKLOCKTIMEVERIFY can only compare like with like, so we + #must use unix time as the transaction locktime + #Now ready to construct transaction log.info("Using a fee of : " + amount_to_str(fee_est) + ".") if amount != 0: log.info("Using a change value of: " + amount_to_str(changeval) + ".") txsigned = sign_tx(wallet_service, make_shuffled_tx( - list(utxos.keys()), outs, False, 2, compute_tx_locktime()), utxos) + list(utxos.keys()), outs, False, 2, tx_locktime), utxos) log.info("Got signed transaction:\n") log.info(pformat(txsigned)) tx = serialize(txsigned) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 0136484..37a62fc 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1694,7 +1694,7 @@ class FidelityBondMixin(object): return timenumber @classmethod - def _is_timelocked_path(cls, path): + def is_timelocked_path(cls, path): return len(path) > 4 and path[4] == cls.BIP32_TIMELOCK_ID def get_xpub_from_fidelity_bond_master_pub_key(cls, mpk): @@ -1737,7 +1737,7 @@ class FidelityBondMixin(object): return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID, cls.BIP32_TIMELOCK_ID, cls.BIP32_BURN_ID) def _get_priv_from_path(self, path): - if self._is_timelocked_path(path): + if self.is_timelocked_path(path): key_path = path[:-1] locktime = path[-1] engine = self._TIMELOCK_ENGINE @@ -1766,7 +1766,7 @@ class FidelityBondMixin(object): refering to the pubkey plus the timelock value which together are needed to create the address """ def get_path_repr(self, path): - if self._is_timelocked_path(path) and len(path) == 7: + if self.is_timelocked_path(path) and len(path) == 7: return super(FidelityBondMixin, self).get_path_repr(path[:-1]) +\ ":" + str(path[-1]) else: @@ -1785,7 +1785,7 @@ class FidelityBondMixin(object): )) def get_details(self, path): - if self._is_timelocked_path(path): + if self.is_timelocked_path(path): return self._get_mixdepth_from_path(path), path[-3], path[-2] else: return super(FidelityBondMixin, self).get_details(path) From c70183bdac41f8219dd5a51e10fae9df6c356d8a Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Fri, 1 May 2020 22:12:52 +0100 Subject: [PATCH 11/16] Create tests for fidelity bond wallets --- jmclient/jmclient/__init__.py | 5 +- jmclient/jmclient/wallet.py | 2 + jmclient/jmclient/wallet_utils.py | 14 +-- jmclient/test/commontest.py | 10 ++ jmclient/test/test_tx_creation.py | 12 +-- jmclient/test/test_wallet.py | 161 +++++++++++++++++++++++++++++- 6 files changed, 182 insertions(+), 22 deletions(-) diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index f982920..f4dd264 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -13,7 +13,8 @@ from .old_mnemonic import mn_decode, mn_encode from .taker import Taker, P2EPTaker from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportWalletMixin, BIP39WalletMixin, BIP32Wallet, BIP49Wallet, LegacyWallet, - SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, + SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, + FidelityBondWatchonlyWallet, SegwitLegacyWalletFidelityBonds, UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) @@ -49,7 +50,7 @@ from .cli_options import (add_base_options, add_common_options, 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, get_utxos_enabled_disabled) + wallet_display, get_utxos_enabled_disabled, wallet_gettimelockaddress) from .wallet_service import WalletService from .maker import Maker, P2EPMaker from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 37a62fc..40b19ec 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1672,6 +1672,8 @@ class FidelityBondMixin(object): """ converts a time number to a unix timestamp """ + if not 0 <= timenumber < cls.TIMENUMBERS_PER_PUBKEY: + raise ValueError() year = cls.TIMELOCK_EPOCH_YEAR + (timenumber*cls.TIMENUMBER_UNIT) // cls.MONTHS_IN_YEAR month = cls.TIMELOCK_EPOCH_MONTH + (timenumber*cls.TIMENUMBER_UNIT) % cls.MONTHS_IN_YEAR return timegm(datetime(year, month, *cls.TIMELOCK_DAY_AND_SHORTER).timetuple()) diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index c972709..054dea1 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -1135,24 +1135,24 @@ def wallet_freezeutxo(wallet, md, display_callback=None, info_callback=None): -def wallet_gettimelockaddress(wallet_service, locktime_string): - if not isinstance(wallet_service.wallet, FidelityBondMixin): +def wallet_gettimelockaddress(wallet, locktime_string): + if not isinstance(wallet, FidelityBondMixin): jmprint("Error: not a fidelity bond wallet", "error") return "" m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH address_type = FidelityBondMixin.BIP32_TIMELOCK_ID - index = wallet_service.get_next_unused_index(m, address_type) + index = wallet.get_next_unused_index(m, address_type) lock_datetime = datetime.strptime(locktime_string, "%Y-%m") timenumber = FidelityBondMixin.timestamp_to_time_number(timegm( lock_datetime.timetuple())) - path = wallet_service.get_path(m, address_type, index, timenumber) - jmprint("path = " + wallet_service.get_path_repr(path), "info") + path = wallet.get_path(m, address_type, index, timenumber) + jmprint("path = " + wallet.get_path_repr(path), "info") jmprint("Coins sent to this address will be not be spendable until " + lock_datetime.strftime("%B %Y") + ". Full date: " + str(lock_datetime)) - addr = wallet_service.get_address_from_path(path) + addr = wallet.get_address_from_path(path) return addr def wallet_addtxoutproof(wallet_service, hdpath, txoutproof): @@ -1457,7 +1457,7 @@ def wallet_tool_main(wallet_root_path): if len(args) < 3: jmprint('Must have locktime value yyyy-mm. For example 2021-03', "error") sys.exit(EXIT_ARGERROR) - return wallet_gettimelockaddress(wallet_service, args[2]) + return wallet_gettimelockaddress(wallet_service.wallet, args[2]) elif method == "addtxoutproof": if len(args) < 3: jmprint('Must have txout proof, which is the output of Bitcoin ' diff --git a/jmclient/test/commontest.py b/jmclient/test/commontest.py index 463c97b..55395e0 100644 --- a/jmclient/test/commontest.py +++ b/jmclient/test/commontest.py @@ -209,3 +209,13 @@ def interact(process, inputs, expected): for i, inp in enumerate(inputs): process.expect(expected[i]) process.sendline(inp) + +def ensure_bip65_activated(): + #on regtest bip65 activates on height 1351 + #https://github.com/bitcoin/bitcoin/blob/1d1f8bbf57118e01904448108a104e20f50d2544/src/chainparams.cpp#L262 + BIP65Height = 1351 + current_height = jm_single().bc_interface.rpc("getblockchaininfo", [])["blocks"] + until_bip65_activation = BIP65Height - current_height + 1 + if until_bip65_activation > 0: + jm_single().bc_interface.tick_forward_chain(until_bip65_activation) + diff --git a/jmclient/test/test_tx_creation.py b/jmclient/test/test_tx_creation.py index bb30803..d251b43 100644 --- a/jmclient/test/test_tx_creation.py +++ b/jmclient/test/test_tx_creation.py @@ -5,9 +5,8 @@ network to check validity.''' import time import binascii import struct -from commontest import make_wallets, make_sign_and_push from binascii import unhexlify ->>>>>>> c158543... only allow pubkey in bytes for mk_freeze_script() +from commontest import make_wallets, make_sign_and_push, ensure_bip65_activated import jmbitcoin as bitcoin import pytest @@ -280,15 +279,6 @@ def test_spend_p2wsh(setup_tx_creation): txid = jm_single().bc_interface.pushtx(tx) assert txid -def ensure_bip65_activated(): - #on regtest bip65 activates on height 1351 - #https://github.com/bitcoin/bitcoin/blob/1d1f8bbf57118e01904448108a104e20f50d2544/src/chainparams.cpp#L262 - BIP65Height = 1351 - current_height = jm_single().bc_interface.rpc("getblockchaininfo", [])["blocks"] - until_bip65_activation = BIP65Height - current_height + 1 - if until_bip65_activation > 0: - jm_single().bc_interface.tick_forward_chain(until_bip65_activation) - def test_spend_freeze_script(setup_tx_creation): ensure_bip65_activated() diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index f424d9f..058c810 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -6,12 +6,13 @@ from binascii import hexlify, unhexlify import pytest import jmbitcoin as btc -from commontest import binarize_tx +from commontest import binarize_tx, ensure_bip65_activated from jmbase import get_log from jmclient import load_test_config, jm_single, \ SegwitLegacyWallet,BIP32Wallet, BIP49Wallet, LegacyWallet,\ VolatileStorage, get_network, cryptoengine, WalletError,\ - SegwitWallet, WalletService + SegwitWallet, WalletService, SegwitLegacyWalletFidelityBonds,\ + FidelityBondMixin, FidelityBondWatchonlyWallet, wallet_gettimelockaddress from test_blockchaininterface import sync_test_wallet testdir = os.path.dirname(os.path.realpath(__file__)) @@ -239,6 +240,72 @@ def test_bip32_addresses_p2sh_p2wpkh(setup_wallet, mixdepth, internal, index, ad assert wif == wallet.get_wif(mixdepth, internal, index) assert address == wallet.get_addr(mixdepth, internal, index) +@pytest.mark.parametrize('index,timenumber,address,wif', [ + [0, 0, 'bcrt1qndcqwedwa4lu77ryqpvp738d6p034a2fv8mufw3pw5smfcn39sgqpesn76', 'cST4g5R3mKp44K4J8PRVyys4XJu6EFavZyssq67PJKCnbhjdEdBY'], + [0, 50, 'bcrt1q73zhrfcu0ttkk4er9esrmvnpl6wpzhny5aly97jj9nw52agf8ncqjv8rda', 'cST4g5R3mKp44K4J8PRVyys4XJu6EFavZyssq67PJKCnbhjdEdBY'], + [5, 0, 'bcrt1qz5208jdm6399ja309ra28d0a34qlt0859u77uxc94v5mgk7auhtssau4pw', 'cRnUaBYTmyZURPe72YCrtvgxpBMvLKPZaCoXvKuWRPMryeJeAZx2'], + [9, 1, 'bcrt1qa7pd6qnadpmlm29vtvqnykalc34tr33eclaz7eeqal59n4gwr28qwnka2r', 'cQCxEPCWMwXVB16zCikDBTXMUccx6ioHQipPhYEp1euihkJUafyD'] +]) +def test_bip32_timelocked_addresses(setup_wallet, index, timenumber, address, wif): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=1) + wallet = SegwitLegacyWalletFidelityBonds(storage) + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + + #wallet needs to know about the script beforehand + wallet.get_script_and_update_map(mixdepth, address_type, index, timenumber) + + assert address == wallet.get_addr(mixdepth, address_type, index, timenumber) + assert wif == wallet.get_wif_path(wallet.get_path(mixdepth, address_type, index, timenumber)) + +@pytest.mark.parametrize('timenumber,locktime_string', [ + [0, "2020-01"], + [20, "2021-09"], + [100, "2028-05"], + [150, "2032-07"], + [350, "2049-03"] +]) +def test_gettimelockaddress_method(setup_wallet, timenumber, locktime_string): + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + index = wallet.get_next_unused_index(m, address_type) + script = wallet.get_script_and_update_map(m, address_type, index, + timenumber) + addr = wallet.script_to_addr(script) + + addr_from_method = wallet_gettimelockaddress(wallet, locktime_string) + + assert addr == addr_from_method + +@pytest.mark.parametrize('index,wif', [ + [0, 'cMg9eH3fW2JDSyggvXucjmECRwiheCMDo2Qik8y1keeYaxynzrYa'], + [9, 'cURA1Qgxhd7QnhhwxCnCHD4pZddVrJdu2BkTdzNaTp9owRSkUvPy'], + [50, 'cRTaHZ1eezb8s6xsT2V7EAevYToQMi7cxQD9vgFZzaJZDfhMhf3c'] +]) +def test_bip32_burn_keys(setup_wallet, index, wif): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=1) + wallet = SegwitLegacyWalletFidelityBonds(storage) + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + + #advance index_cache enough + wallet.set_next_index(mixdepth, address_type, index, force=True) + + assert wif == wallet.get_wif_path(wallet.get_path(mixdepth, address_type, index)) def test_import_key(setup_wallet): jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') @@ -331,6 +398,29 @@ def test_signing_simple(setup_wallet, wallet_cls, type_check): txout = jm_single().bc_interface.pushtx(btc.serialize(tx)) assert txout +def test_timelocked_output_signing(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + ensure_bip65_activated() + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + index = 0 + timenumber = 0 + script = wallet.get_script_and_update_map( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH, + FidelityBondMixin.BIP32_TIMELOCK_ID, index, timenumber) + utxo = fund_wallet_addr(wallet, wallet.script_to_addr(script)) + timestamp = wallet._time_number_to_timestamp(timenumber) + + tx = btc.deserialize(btc.mktx(['{}:{}'.format( + hexlify(utxo[0]).decode('ascii'), utxo[1])], + [btc.p2sh_scriptaddr(b"\x00",magicbyte=196) + ':' + str(10**8 - 9000)], + locktime=timestamp+1)) + tx = wallet.sign_tx(tx, {0: (script, 10**8)}) + 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 @@ -537,6 +627,37 @@ def test_path_repr_imported(setup_wallet): assert path_new == path +@pytest.mark.parametrize('timenumber,timestamp', [ + [0, 1577836800], + [50, 1709251200], + [300, 2366841600], + [400, None], #too far in the future + [-1, None] #before epoch +]) +def test_timenumber_to_timestamp(setup_wallet, timenumber, timestamp): + try: + implied_timestamp = FidelityBondMixin._time_number_to_timestamp( + timenumber) + assert implied_timestamp == timestamp + except ValueError: + #None means the timenumber is intentionally invalid + assert timestamp == None + +@pytest.mark.parametrize('timestamp,timenumber', [ + [1577836800, 0], + [1709251200, 50], + [2366841600, 300], + [1577836801, None], #not exactly midnight on first of month + [2629670400, None], #too far in future + [1575158400, None] #before epoch +]) +def test_timestamp_to_timenumber(setup_wallet, timestamp, timenumber): + try: + implied_timenumber = FidelityBondMixin.timestamp_to_time_number( + timestamp) + assert implied_timenumber == timenumber + except ValueError: + assert timenumber == None def test_wrong_wallet_cls(setup_wallet): storage = VolatileStorage() @@ -658,6 +779,42 @@ def test_wallet_mixdepth_decrease(setup_wallet): # because we explicitly ask for a specific mixdepth assert utxo in new_wallet.select_utxos_(max_mixdepth, 10**7) +def test_watchonly_wallet(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + paths = [ + "m/49'/1'/0'/0/0", + "m/49'/1'/0'/1/0", + "m/49'/1'/0'/2/0:1577836800", + "m/49'/1'/0'/2/0:2314051200" + ] + burn_path = "m/49'/1'/0'/3/0" + + scripts = [wallet.get_script_from_path(wallet.path_repr_to_path(path)) + for path in paths] + privkey, engine = wallet._get_priv_from_path(wallet.path_repr_to_path(burn_path)) + burn_pubkey = engine.privkey_to_pubkey(privkey) + + master_pub_key = wallet.get_bip32_pub_export( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH) + watchonly_storage = VolatileStorage() + entropy = FidelityBondMixin.get_xpub_from_fidelity_bond_master_pub_key( + master_pub_key).encode() + FidelityBondWatchonlyWallet.initialize(watchonly_storage, get_network(), + entropy=entropy) + watchonly_wallet = FidelityBondWatchonlyWallet(watchonly_storage) + + watchonly_scripts = [watchonly_wallet.get_script_from_path( + watchonly_wallet.path_repr_to_path(path)) for path in paths] + privkey, engine = wallet._get_priv_from_path(wallet.path_repr_to_path(burn_path)) + watchonly_burn_pubkey = engine.privkey_to_pubkey(privkey) + + for script, watchonly_script in zip(scripts, watchonly_scripts): + assert script == watchonly_script + assert burn_pubkey == watchonly_burn_pubkey @pytest.fixture(scope='module') def setup_wallet(): From ddb32ce2450853d1a9915ab71d1f93a8882bf0bb Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Sun, 10 May 2020 00:59:11 +0100 Subject: [PATCH 12/16] Rename functions to say "key" instead of "privkey" Watchonly wallets use pubkeys instead of privkeys, but in a bit of hack the functions previously called "_get_priv_from_path" would actually return public keys for watchonly wallets. This could have pretty terrible consequences one day, so functions like that have been renamed to use the word "key" instead, which could be either private or public. --- jmclient/jmclient/cryptoengine.py | 10 ++++---- jmclient/jmclient/taker_utils.py | 2 +- jmclient/jmclient/wallet.py | 38 ++++++++++++++--------------- jmclient/jmclient/wallet_service.py | 2 +- jmclient/jmclient/wallet_utils.py | 2 +- jmclient/test/test_wallet.py | 4 +-- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/jmclient/jmclient/cryptoengine.py b/jmclient/jmclient/cryptoengine.py index 1e1ff22..9b5d9f1 100644 --- a/jmclient/jmclient/cryptoengine.py +++ b/jmclient/jmclient/cryptoengine.py @@ -155,7 +155,7 @@ class BTCEngine(object): return key @classmethod - def privkey_to_script(cls, privkey): + def key_to_script(cls, privkey): pub = cls.privkey_to_pubkey(privkey) return cls.pubkey_to_script(pub) @@ -165,7 +165,7 @@ class BTCEngine(object): @classmethod def privkey_to_address(cls, privkey): - script = cls.privkey_to_script(privkey) + script = cls.key_to_script(privkey) return btc.script_to_address(script, cls.VBYTE) @classmethod @@ -311,7 +311,7 @@ class BTC_Timelocked_P2WSH(BTCEngine): return btc.BTC_P2PK_VBYTE["testnet"] @classmethod - def privkey_to_script(cls, privkey_locktime): + def key_to_script(cls, privkey_locktime): privkey, locktime = privkey_locktime pub = cls.privkey_to_pubkey(privkey) return cls.pubkey_to_script((pub, locktime)) @@ -362,7 +362,7 @@ class BTC_Watchonly_Timelocked_P2WSH(BTC_Timelocked_P2WSH): path))[-1] @classmethod - def privkey_to_script(cls, pubkey_locktime): + def key_to_script(cls, pubkey_locktime): pub, locktime = pubkey_locktime return cls.pubkey_to_script((pub, locktime)) @@ -388,7 +388,7 @@ class BTC_Watchonly_P2SH_P2WPKH(BTC_P2SH_P2WPKH): @staticmethod def privkey_to_pubkey(privkey): #in watchonly wallets there are no privkeys, so functions - # like _get_priv_from_path() actually return pubkeys and + # like _get_key_from_path() actually return pubkeys and # this function is a noop return privkey diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index 8160caa..239f11d 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -87,7 +87,7 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, address_type = FidelityBondMixin.BIP32_BURN_ID index = wallet_service.wallet.get_next_unused_index(mixdepth, address_type) path = wallet_service.wallet.get_path(mixdepth, address_type, index) - privkey, engine = wallet_service.wallet._get_priv_from_path(path) + privkey, engine = wallet_service.wallet._get_key_from_path(path) pubkey = engine.privkey_to_pubkey(privkey) pubkeyhash = bin_hash160(pubkey) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 40b19ec..9a2d6f8 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -439,7 +439,7 @@ class BaseWallet(object): for index, (script, amount) in scripts.items(): assert amount > 0 path = self.script_to_path(script) - privkey, engine = self._get_priv_from_path(path) + privkey, engine = self._get_key_from_path(path) tx = btc.deserialize(engine.sign_transaction(tx, index, privkey, amount, **kwargs)) return tx @@ -451,7 +451,7 @@ class BaseWallet(object): """ script = self._ENGINE.address_to_script(addr) path = self.script_to_path(script) - privkey = self._get_priv_from_path(path)[0] + privkey = self._get_key_from_path(path)[0] return hexlify(privkey).decode('ascii') def _get_addr_int_ext(self, address_type, mixdepth): @@ -499,7 +499,7 @@ class BaseWallet(object): def script_to_addr(self, script): assert self.is_known_script(script) path = self.script_to_path(script) - engine = self._get_priv_from_path(path)[1] + engine = self._get_key_from_path(path)[1] return engine.script_to_address(script) def get_script_code(self, script): @@ -511,7 +511,7 @@ class BaseWallet(object): For non-segwit wallets, raises EngineError. """ path = self.script_to_path(script) - priv, engine = self._get_priv_from_path(path) + priv, engine = self._get_key_from_path(path) pub = engine.privkey_to_pubkey(priv) return engine.pubkey_to_script_code(pub) @@ -549,7 +549,7 @@ class BaseWallet(object): return self.get_wif_path(self.get_path(mixdepth, address_type, index)) def get_wif_path(self, path): - priv, engine = self._get_priv_from_path(path) + priv, engine = self._get_key_from_path(path) return engine.privkey_to_wif(priv) def get_path(self, mixdepth=None, address_type=None, index=None): @@ -830,7 +830,7 @@ class BaseWallet(object): path = self.get_path(mixdepth, address_type, index) return self.get_script_from_path(path) - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): raise NotImplementedError() def get_path_repr(self, path): @@ -885,7 +885,7 @@ class BaseWallet(object): returns: signature as base64-encoded string """ - priv, engine = self._get_priv_from_path(path) + priv, engine = self._get_key_from_path(path) return engine.sign_message(priv, message) def get_wallet_name(self): @@ -1110,7 +1110,7 @@ class ImportWalletMixin(object): #key_type = key_type_wif if key_type_wif is not None else self.TYPE engine = self._ENGINES[key_type] - if engine.privkey_to_script(privkey) in self._script_map: + if engine.key_to_script(privkey) in self._script_map: raise WalletError("Cannot import key, already in wallet: {}" "".format(wif)) @@ -1155,7 +1155,7 @@ class ImportWalletMixin(object): engine = self._ENGINES[key_type] path = (self._IMPORTED_ROOT_PATH, mixdepth, index) - self._script_map[engine.privkey_to_script(privkey)] = path + self._script_map[engine.key_to_script(privkey)] = path return path @@ -1166,9 +1166,9 @@ class ImportWalletMixin(object): assert len(path) == 3 return path[1] - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): if not self._is_imported_path(path): - return super(ImportWalletMixin, self)._get_priv_from_path(path) + return super(ImportWalletMixin, self)._get_key_from_path(path) assert len(path) == 3 md, i = path[1], path[2] @@ -1220,8 +1220,8 @@ class ImportWalletMixin(object): if not self._is_imported_path(path): return super(ImportWalletMixin, self).get_script_from_path(path) - priv, engine = self._get_priv_from_path(path) - return engine.privkey_to_script(priv) + priv, engine = self._get_key_from_path(path) + return engine.key_to_script(priv) class BIP39WalletMixin(object): @@ -1410,8 +1410,8 @@ class BIP32Wallet(BaseWallet): #concept of a "next address" cant be used return self.get_new_script_override_disable(md, address_type) - priv, engine = self._get_priv_from_path(path) - script = engine.privkey_to_script(priv) + priv, engine = self._get_key_from_path(path) + script = engine.key_to_script(priv) return script @@ -1478,7 +1478,7 @@ class BIP32Wallet(BaseWallet): return path[len(self._get_bip32_base_path())] - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): if not self._is_my_bip32_path(path): raise WalletError("Invalid path, unknown root: {}".format(path)) @@ -1707,7 +1707,7 @@ class FidelityBondMixin(object): def _get_key_ident(self): first_path = self.get_path(0, 0) - priv, engine = self._get_priv_from_path(first_path) + priv, engine = self._get_key_from_path(first_path) pub = engine.privkey_to_pubkey(priv) return sha256(sha256(pub).digest()).digest()[:3] @@ -1738,7 +1738,7 @@ class FidelityBondMixin(object): def _get_supported_address_types(cls): return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID, cls.BIP32_TIMELOCK_ID, cls.BIP32_BURN_ID) - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): if self.is_timelocked_path(path): key_path = path[:-1] locktime = path[-1] @@ -1746,7 +1746,7 @@ class FidelityBondMixin(object): privkey = engine.derive_bip32_privkey(self._master_key, key_path) return (privkey, locktime), engine else: - return super(FidelityBondMixin, self)._get_priv_from_path(path) + return super(FidelityBondMixin, self)._get_key_from_path(path) def get_path(self, mixdepth=None, address_type=None, index=None, timenumber=None): if address_type == None or address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID, diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index c7a2e28..8d80643 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -565,7 +565,7 @@ class WalletService(Service): index += 1 self.wallet.set_next_index(mixdepth, address_type, index, force=True) path = self.wallet.get_path(mixdepth, address_type, index) - path_privkey, engine = self.wallet._get_priv_from_path(path) + path_privkey, engine = self.wallet._get_key_from_path(path) path_pubkey = engine.privkey_to_pubkey(path_privkey) path_pubkeyhash = btc.bin_hash160(path_pubkey) for burner_tx in burner_txes: diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 054dea1..a1686c3 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -502,7 +502,7 @@ def wallet_display(wallet_service, showprivkey, displayall=False, path_repr = wallet_service.get_path_repr(path) path_repr_b = path_repr.encode() - privkey, engine = wallet_service._get_priv_from_path(path) + privkey, engine = wallet_service._get_key_from_path(path) pubkey = engine.privkey_to_pubkey(privkey) pubkeyhash = btc.bin_hash160(pubkey) output = "BURN-" + binascii.hexlify(pubkeyhash).decode() diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index 058c810..4796c21 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -795,7 +795,7 @@ def test_watchonly_wallet(setup_wallet): scripts = [wallet.get_script_from_path(wallet.path_repr_to_path(path)) for path in paths] - privkey, engine = wallet._get_priv_from_path(wallet.path_repr_to_path(burn_path)) + privkey, engine = wallet._get_key_from_path(wallet.path_repr_to_path(burn_path)) burn_pubkey = engine.privkey_to_pubkey(privkey) master_pub_key = wallet.get_bip32_pub_export( @@ -809,7 +809,7 @@ def test_watchonly_wallet(setup_wallet): watchonly_scripts = [watchonly_wallet.get_script_from_path( watchonly_wallet.path_repr_to_path(path)) for path in paths] - privkey, engine = wallet._get_priv_from_path(wallet.path_repr_to_path(burn_path)) + privkey, engine = wallet._get_key_from_path(wallet.path_repr_to_path(burn_path)) watchonly_burn_pubkey = engine.privkey_to_pubkey(privkey) for script, watchonly_script in zip(scripts, watchonly_scripts): From 2860c4fc9fa254771228547bdb90fe762a994def Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Fri, 15 May 2020 17:47:41 +0100 Subject: [PATCH 13/16] Freeze timelocked UTXOs with locktimes in future Previously timelocked UTXOs would be returned by calls like select_utxo() and get_utxos_by_mixdepth(). This caused annoyances if trying to burn a single UTXO. It could also cause recently- unlocked coins to accidently get spent, perhaps co-spent with other coins. This commit fixes that by freezing UTXOs with the coin control feature whenever the wallet is sync'd. When the timelock of a coin passes the user must explicitly use coin control to spend it. --- jmclient/jmclient/wallet.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 9a2d6f8..94122f7 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1728,6 +1728,19 @@ class FidelityBondMixin(object): script = self.get_script_from_path(path) self._script_map[script] = path + def add_utxo(self, txid, index, script, value, height=None): + super(FidelityBondMixin, self).add_utxo(txid, index, script, value, + height) + #dont use coin control freeze if wallet readonly + if self._storage.read_only: + return + path = self.script_to_path(script) + if not self.is_timelocked_path(path): + return + if datetime.utcfromtimestamp(path[-1]) > datetime.now(): + #freeze utxo if its timelock is in the future + self.disable_utxo(txid, index, disable=True) + def get_bip32_pub_export(self, mixdepth=None, address_type=None): bip32_pub = super(FidelityBondMixin, self).get_bip32_pub_export(mixdepth, address_type) if address_type == None and mixdepth == self.FIDELITY_BOND_MIXDEPTH: From 14f086bfe485a79f555f6355d6d34932fa858f01 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Mon, 18 May 2020 02:01:45 +0100 Subject: [PATCH 14/16] Add usage guide for fidelity bond wallets --- docs/fidelity-bonds.md | 271 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/fidelity-bonds.md diff --git a/docs/fidelity-bonds.md b/docs/fidelity-bonds.md new file mode 100644 index 0000000..d6c2c64 --- /dev/null +++ b/docs/fidelity-bonds.md @@ -0,0 +1,271 @@ +# Fidelity bonds + +Fidelity bonds are a feature of JoinMarket which improves the resistance to +sybil attacks, and therefore improves the privacy of the system. + +## This feature is incomplete and so is disabled for now + +A fidelity bond is a mechanism where bitcoin value is deliberately sacrificed +to make a cryptographic identity expensive to obtain. The sacrifice is done in +a way that can be proven to a third party. Takers in JoinMarket will +have a higher probability to create coinjoins with makers who publish more +valuable fidelity bonds. This has the effect of making the system much more +expensive to sybil attack, because an attacker would have to sacrifice a lot of +value in order to be chosen very often by takers when creating coinjoin. + +As a maker you can take part in many more coinjoins and therefore earn more +fees if you sacrifice bitcoin value to create a fidelity bond. The most +practical way to create a fidelity bond is to send bitcoins to a time-locked +address which uses the opcode [OP_CHECKLOCKTIMEVERIFY](https://en.bitcoin.it/wiki/Timelock#CheckLockTimeVerify). +The valuable thing being sacrificed is the time-value-of-money. Note that a +long-term holder (or hodler) of bitcoins can buy time-locked fidelity bonds +essentially for free, assuming they never intended to transact with their coins +anyway. + +Another way to create fidelity bonds is to destroy coins by sending them to a +[OP_RETURN](https://en.bitcoin.it/wiki/Script#Provably_Unspendable.2FPrunable_Outputs) +output. + +The private keys to fidelity bonds can be kept in [cold storage](https://en.bitcoin.it/wiki/Cold_storage) +for added security. + +For a more detailed explanation of how fidelity bonds work see these documents: + +* [Design for improving JoinMarket's resistance to sybil attacks using fidelity +bonds](https://gist.github.com/chris-belcher/18ea0e6acdb885a2bfbdee43dcd6b5af/) + +* [Financial mathematics of JoinMarket fidelity bonds](https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b) + +#### Note on privacy + +Bitcoin outputs which create fidelity bonds will be published to the entire +world, so before and after creating them make sure the outputs are not linked +to your identity in any way. Perhaps mix with JoinMarket before and after. + +### Creating a JoinMarket wallet which supports fidelity bonds + +When generating a JoinMarket wallet on the command line, supporting versions +will offer an option to make the wallet support fidelity bonds. + + (jmvenv) $ python3 wallet-tool.py generate + Would you like to use a two-factor mnemonic recovery phrase? write 'n' if you don't know what this is (y/n): n + Not using mnemonic extension + Enter wallet file encryption passphrase: + Reenter wallet file encryption passphrase: + Input wallet file name (default: wallet.jmdat): testfidelity.jmdat + Would you like this wallet to support fidelity bonds? write 'n' if you don't know what this is (y/n): y + Write down this wallet recovery mnemonic + + evidence initial knee image inspire plug dad midnight blast awkward clean between + + Generated wallet OK + +As always, it is crucially important to write down the 12-word [seed phrase](https://en.bitcoin.it/wiki/Seed_phrase) +as a backup. It is also recommended to write down the name of the creating wallet +"JoinMarket" and that the fidelity bond option was enabled. Writing the wallet +creation date is also useful as it can help with rescanning. + +### Obtaining time-locked addresses + +The `wallet-tool.py` script supports a new method `gettimelockaddress` used for +obtaining time-locked addresses. If coins are sent to these addresses they will +be locked up until the timelock date passes. Only mixdepth zero can have +fidelity bonds in it. + +This example creates an address which locks any coins sent to it until April 2020. + + (jmvenv) $ python3 wallet-tool.py testfidelity.jmdat gettimelockaddress 2020-4 + Enter wallet decryption passphrase: + path = m/49'/1'/0'/2/3:1585699200 + Coins sent to this address will be not be spendable until April 2020. Full date: 2020-04-01 00:00:00 + bcrt1qrc2qu3m2l2spayu5kr0k0rnn9xgjz46zsxmruh87a3h3f5zmnkaqlfx7v5 + +If coins are sent to these addresses they will appear in the usual `wallet-tool.py` +display: + + (jmvenv) $ python3 wallet-tool.py -m 0 testfidelity.jmdat + Enter wallet decryption passphrase: + JM wallet + mixdepth 0 fbonds-mpk-tpubDDCbCPdf5wJVGYWB4mZr3E3Lys4NBcEKysrrUrLfhG6sekmrvs6KZNe4i5p5z3FyfwRmKMqB9NWEcEUiTS4LwqfrKPQzhKj6aLihu2EejaU + external addresses m/49'/1'/0'/0 tpubDEGdmPwmQRcZmGKhaudjch9Fgw4J5yP4bYw5B8LoSDkMdmhBxM4ndEQXHK4r1TPexGjLidxdpeEzsJcdXEe7khWToxCZuN6JiLzvUoHAki2 + m/49'/1'/0'/0/0 2N8jHuQaApgFtQ8UKxKbREAvNxKn4BGX4x2 0.00000000 new + m/49'/1'/0'/0/1 2Mx5CwDoNcuCT38EDmgenQxv9skHbZfXFdo 0.00000000 new + m/49'/1'/0'/0/2 2N1tNTTwNyucGGmfDWNVk3AUi3i5S8jVKqn 0.00000000 new + m/49'/1'/0'/0/3 2N8eBEU5wpWb6kS1gvbRgewtxsmXsMkShV6 0.00000000 new + m/49'/1'/0'/0/4 2MuHgeSgMsvkcn6aGNW2uk2UXP3xVVnkfh2 0.00000000 new + m/49'/1'/0'/0/5 2NA8d8um5KmBNNR8dadhbEDYGiTJPFCdjMB 0.00000000 new + Balance: 0.00000000 + internal addresses m/49'/1'/0'/1 + Balance: 0.00000000 + internal addresses m/49'/1'/0'/2 tpubDEGdmPwmQRcZrzjRmUFqXXyLdRedwxCWQviAFqDe6sXJeZzRNTwmwqMfxN6Ka3v7hEebstrU5kqUNoHsFKaA3RoB2vopL6kLHVo1EQq6USw + m/49'/1'/0'/2/0:1585699200 bcrt1qrc2qu3m2l2spayu5kr0k0rnn9xgjz46zsxmruh87a3h3f5zmnkaqlfx7v5 0.15000000 2020-04-01 [LOCKED] + Balance: 0.15000000 + Balance for mixdepth 0: 0.15000000 + Total balance: 0.15000000 + +#### Spending time-locked coins + +Once the time-lock of an address expires the coins can be spent with JoinMarket. + +Coins living on time-locked addresses are automatically frozen with +JoinMarket's coin control feature, so before spending you need to unfreeze the +coins using `python3 wallet-tool.py -m 0 freeze`. + +Once unfrozen and untimelocked the coins can be spent normally with the scripts +`sendpayment.py`, `tumber.py`, or yield generator. + +### Burning coins + +Coins can be burned with a special method of the `sendpayment.py` script. Set +the destination to be `BURN`. Transactions which burn coins must only have one +input and one output, so use coin control to freeze all coins in the zeroth +mixdepth except one. + + $ python3 sendpayment.py -N 0 testfidelity3.jmdat 0 BURN + User data location: . + 2020-04-07 20:45:25,658 [INFO] Using this min relay fee as tx fee floor: 1000 sat/vkB (1.0 sat/vB) + Enter wallet decryption passphrase: + 2020-04-07 20:46:50,449 [INFO] Estimated miner/tx fees for this coinjoin amount: 0.0% + 2020-04-07 20:46:50,452 [INFO] Using this min relay fee as tx fee floor: 1000 sat/vkB (1.0 sat/vB) + 2020-04-07 20:46:50,452 [INFO] Using a fee of : 0.00000200 BTC (200 sat). + 2020-04-07 20:46:50,454 [INFO] Got signed transaction: + + 2020-04-07 20:46:50,455 [INFO] {'ins': [{'outpoint': {'hash': '61d69b4e7abe0ef8a5a9cbabb05463259c3b497a142130a56f81a9259f048cb0', + 'index': 0}, + 'script': '160014295beb4eba9b35896683d5b5ff455ee2c646054c', + 'sequence': 4294967294, + 'txinwitness': ['3045022100939de908e30015c6b22d2c0f25153e395268466ce44eeeb4ec03a8920440e87b0220155d1c43dedb3fc2654205541bb2821dd5211180e2d7f93d67301470652830d401', + '03ec0f8b267f99ff5259195ce63813d58f38ffbaada894ce06af0c0303c74bbf82']}], + 'locktime': 1361, + 'outs': [{'script': '6a147631c805d8ad9239677b8d7530353fda3fec07ca', + 'value': 11999800}], + 'version': 2} + 2020-04-07 20:46:50,455 [INFO] In serialized form (for copy-paste): + 2020-04-07 20:46:50,455 [INFO] 02000000000101b08c049f25a9816fa53021147a493b9c256354b0abcba9a5f80ebe7a4e9bd6610000000017160014295beb4eba9b35896683d5b5ff455ee2c646054cfeffffff01381ab70000000000166a147631c805d8ad9239677b8d7530353fda3fec07ca02483045022100939de908e30015c6b22d2c0f25153e395268466ce44eeeb4ec03a8920440e87b0220155d1c43dedb3fc2654205541bb2821dd5211180e2d7f93d67301470652830d4012103ec0f8b267f99ff5259195ce63813d58f38ffbaada894ce06af0c0303c74bbf8251050000 + 2020-04-07 20:46:50,456 [INFO] Sends: 0.11999800 BTC (11999800 sat) to destination: BURNER OUTPUT embedding pubkey at m/49'/1'/0'/3/0 + + WARNING: This transaction if broadcasted will PERMANENTLY DESTROY your bitcoins + + Would you like to push to the network? (y/n):y + 2020-04-07 20:47:52,047 [DEBUG] rpc: sendrawtransaction ['02000000000101b08c049f25a9816fa53021147a493b9c256354b0abcba9a5f80ebe7a4e9bd6610000000017160014295beb4eba9b35896683d5b5ff455ee2c646054cfeffffff01381ab70000000000166a147631c805d8ad9239677b8d7530353fda3fec07ca02483045022100939de908e30015c6b22d2c0f25153e395268466ce44eeeb4ec03a8920440e87b0220155d1c43dedb3fc2654205541bb2821dd5211180e2d7f93d67301470652830d4012103ec0f8b267f99ff5259195ce63813d58f38ffbaada894ce06af0c0303c74bbf8251050000'] + 2020-04-07 20:47:52,049 [WARNING] Connection had broken pipe, attempting reconnect. + 2020-04-07 20:47:52,440 [INFO] Transaction sent: 656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 + done + +Embedded in the `OP_RETURN` output is the hash of a pubkey from the wallet. + +Now `OP_RETURN` outputs are not addresses, and because of technical reasons the +first time they are synchronized the flag `--recoversync` must be used. When +this is done the burn output will appear in the `wallet-tool.py` display. +`--recoversync` only needs to be used once, and after that the burner output is +saved in the `wallet.jmdat` file which can be accesses by all future +synchronizations. + + $ python3 wallet-tool.py --datadir=. --recoversync testfidelity2.jmdat + Enter wallet decryption passphrase: + 2020-04-07 23:09:54,644 [INFO] Found a burner transaction txid=656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 path = m/49'/1'/0'/3/0 + 2020-04-07 23:09:54,769 [WARNING] Merkle branch not available, use wallet-tool `addtxoutproof` + 2020-04-07 23:09:55,420 [INFO] Found a burner transaction txid=656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 path = m/49'/1'/0'/3/0 + 2020-04-07 23:09:55,422 [WARNING] Merkle branch not available, use wallet-tool `addtxoutproof` + JM wallet + mixdepth 0 fbonds-mpk-tpubDDCXgSpdxuVbXgzRYBggFeMRNeV9eH24jJuQNunyqwYtDFiB7ZS63LhXwHkf7o9ZcZW4qUz7uvD6yk4BkkF3bBPmJRPv7RBTEA5hHwEdV2f + external addresses m/49'/1'/0'/0 tpubDEJGorVywRb6LoLQbaqWZh2gYwpdZqViCNZ2ejB5kpBuUp16LHpK6ESFaJLixidtbmmjcDwVZ4QjnAbKmypfuGaEk3ifgonPv4vsugqgp9G + m/49'/1'/0'/0/2 2MviB2FfLKZjFb3W2dJ8kXcQBj3jqMJg7TL 0.00000000 new + m/49'/1'/0'/0/3 2MtsAQhE2u9VGV3aZ7XM4PzwWGHXr4PAhqP 0.00000000 new + m/49'/1'/0'/0/4 2N3iXNjS4vkTXzy5Jidnovc6FJeNQJx5Fnt 0.00000000 new + m/49'/1'/0'/0/5 2MtT4XAjwQQ7PBbrTxv7qcQMMmz4Rs5XrE3 0.00000000 new + m/49'/1'/0'/0/6 2NEuG23BQESuZTqSDtab9zYsd1Jb4KfMULB 0.00000000 new + m/49'/1'/0'/0/7 2N6NGJRX6KYQWtYWK8iHFuJNpZRs8NbUAC9 0.00000000 new + Balance: 0.00000000 + internal addresses m/49'/1'/0'/1 + Balance: 0.00000000 + internal addresses m/49'/1'/0'/2 tpubDEJGorVywRb6T34X7ZAEz9hQYn6CCEhrcFa8kA2mqNau2DvoggZP2QTtXRe8t9NSfMkx3ye8QDzqCE9gEqso6fw5ALk5xycWLFwTRLSqSUV + Balance: 0.00000000 + internal addresses m/49'/1'/0'/3 tpubDEJGorVywRb6V5em9Q7LFJ9eLEAEZmZxUdDmkknrKNUs7vKcCWPKwP8YPjuxFCCXk2F1wJnubNbmgbtWed5yiE3D1qxzLonVuXT6QEZPaof + m/49'/1'/0'/3/0 BURN-7631c805d8ad9239677b8d7530353fda3fec07ca 0.11999800 656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 [NO MERKLE PROOF] + Balance: 0.11999800 + Balance for mixdepth 0: 0.11999800 + Total balance: 0.11999800 + +#### Adding the merkle proof of a burn transaction if missing + +In order to prove a burn output exists, a merkle proof is needed. If the Core +node is pruned and the block deleted then JoinMarket will not be able to obtain +the merkle proof (as in the above example). In this case the proof can be +added separately. + +Any other unpruned Core node can trustlessly obtain the proof and give it to +the user with the RPC call `gettxoutproof`. + +First obtain the merkle proof: + + $ bitcoin-cli gettxoutproof "[\"656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7\"]" 4cce28f4eb1ea1762ec4ceb90529b3ab28c0423ac630c6292319e2b2712daada + 0000002056e4050f54084a1d6e6944b209cce76ebe2da4b37f3aa47ab6c612831d3220471015e80d3050cf0ee05b216036030fe3d4906221196943a30e828741ad4cfaeb05d98c5effff7f20010000000200000002a5910e5cf4e6cb6d55e1e2ca979987772a482e8a8f30b7a6cab8c5671f5c161df7b0540d63071d77de8b81f97e806ac4c9b6075991434087ccf2148f53b46b650105 + +Then add it to the JoinMarket wallet: + + $ python3 wallet-tool.py -H "m/49'/1'/0'/3/0" testfidelity2.jmdat addtxoutproof 0000002056e4050f54084a1d6e6944b209cce76ebe2da4b37f3aa47ab6c612831d3220471015e80d3050cf0ee05b216036030fe3d4906221196943a30e828741ad4cfaeb05d98c5effff7f20010000000200000002a5910e5cf4e6cb6d55e1e2ca979987772a482e8a8f30b7a6cab8c5671f5c161df7b0540d63071d77de8b81f97e806ac4c9b6075991434087ccf2148f53b46b650105 + Enter wallet decryption passphrase: + Done + +The `-H` flag must point to the path containing the burn output. + +Then synchronizing the wallet won't output the no-merkle-proof warning. + +### Creating watch-only fidelity bond wallets + +Fidelity bonds can be held on an offline computer in +[cold storage](https://en.bitcoin.it/wiki/Cold_storage). To do this we create +a watch-only fidelity bond wallet. + +When fidelity bonds are displayed in `wallet-tool.py`, their master public key +is highlighted with a prefix `fbonds-mpk-`. + +This master public key can be used to create a watch-only wallet using +`wallet-tool.py`. + + $ python3 wallet-tool.py createwatchonly fbonds-mpk-tpubDDCbCPdf5wJVGYWB4mZr3E3Lys4NBcEKysrrUrLfhG6sekmrvs6KZNe4i5p5z3FyfwRmKMqB9NWEcEUiTS4LwqfrKPQzhKj6aLihu2EejaU + Input wallet file name (default: watchonly.jmdat): watchfidelity.jmdat + Enter wallet file encryption passphrase: + Reenter wallet file encryption passphrase: + Done + +Then the wallet can be displayed like a regular wallet, although only the zeroth +mixdepth will be shown. + + $ python3 wallet-tool.py watchfidelity.jmdat + User data location: . + Enter wallet decryption passphrase: + JM wallet + mixdepth 0 fbonds-mpk-tpubDDCbCPdf5wJVGYWB4mZr3E3Lys4NBcEKysrrUrLfhG6sekmrvs6KZNe4i5p5z3FyfwRmKMqB9NWEcEUiTS4LwqfrKPQzhKj6aLihu2EejaU + external addresses m/49'/1'/0'/0 tpubDEGdmPwmQRcZmGKhaudjch9Fgw4J5yP4bYw5B8LoSDkMdmhBxM4ndEQXHK4r1TPexGjLidxdpeEzsJcdXEe7khWToxCZuN6JiLzvUoHAki2 + m/49'/1'/0'/0/0 2N8jHuQaApgFtQ8UKxKbREAvNxKn4BGX4x2 0.00000000 used + m/49'/1'/0'/0/1 2Mx5CwDoNcuCT38EDmgenQxv9skHbZfXFdo 0.00000000 new + m/49'/1'/0'/0/2 2N1tNTTwNyucGGmfDWNVk3AUi3i5S8jVKqn 0.00000000 new + m/49'/1'/0'/0/3 2N8eBEU5wpWb6kS1gvbRgewtxsmXsMkShV6 0.00000000 new + m/49'/1'/0'/0/4 2MuHgeSgMsvkcn6aGNW2uk2UXP3xVVnkfh2 0.00000000 new + m/49'/1'/0'/0/5 2NA8d8um5KmBNNR8dadhbEDYGiTJPFCdjMB 0.00000000 new + m/49'/1'/0'/0/6 2NG76BAHPccfyy6sH68EHrB9QJBycx3FKb6 0.00000000 new + Balance: 0.25000000 + internal addresses m/49'/1'/0'/1 + Balance: 0.00000000 + internal addresses m/49'/1'/0'/2 tpubDEGdmPwmQRcZrzjRmUFqXXyLdRedwxCWQviAFqDe6sXJeZzRNTwmwqMfxN6Ka3v7hEebstrU5kqUNoHsFKaA3RoB2vopL6kLHVo1EQq6USw + m/49'/1'/0'/0/3:1585699200 bcrt1qrc2qu3m2l2spayu5kr0k0rnn9xgjz46zsxmruh87a3h3f5zmnkaqlfx7v5 0.15000000 2020-04-01 [UNLOCKED] + Balance: 0.15000000 + internal addresses m/49'/1'/0'/3 tpubDEGdmPwmQRcZuX3uNrCouu5bRgp2GJcoQTvhkFAJMTA3yxhKmQyeGwecbnkms4DYmBhCJn2fGTuejTe3g8oyJW3qKcfB4b3Swj2hDk1h4Y2 + Balance: 0.00000000 + Balance for mixdepth 0: 0.15000000 + Total balance: 0.15000000 + +### BIP32 Paths + +Fidelity bond wallets extend the BIP32 path format to include the locktime +values. In this example we've got `m/49'/1'/0'/2/0:1583020800` where the +number after the colon is the locktime value in Unix time. + +This path can be passed to certain wallet methods like `dumpprivkey`. + + $ python3 wallet-tool.py -H "m/49'/1'/0'/2/0:1583020800" testfidelity.jmdat dumpprivkey + Enter wallet decryption passphrase: + cNEuE5ypNTxVFCyC5iH7u5AQTrddamcUHRPNweiLvmHUWd6XXDkz + From 869ef55ef9e52de63e63c7a83c767a793311384f Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Mon, 18 May 2020 11:45:45 +0100 Subject: [PATCH 15/16] Disable loading of fidelity bond wallets by Qt --- scripts/joinmarket-qt.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 6764f88..40bb521 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -76,7 +76,8 @@ from jmclient import load_program_config, get_network, update_persist_config,\ get_tumble_log, restart_wait, tumbler_filter_orders_callback,\ wallet_generate_recover_bip39, wallet_display, get_utxos_enabled_disabled,\ NO_ROUNDING, get_max_cj_fee_values, get_default_max_absolute_fee, \ - get_default_max_relative_fee, RetryableStorageError, add_base_options + get_default_max_relative_fee, RetryableStorageError, add_base_options, \ + FidelityBondMixin from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ @@ -1658,6 +1659,8 @@ class JMMainWindow(QMainWindow): return False # only used for GUI display on regtest: self.testwalletname = wallet.seed = str(firstarg) + if isinstance(wallet, FidelityBondMixin): + raise Exception("Fidelity bond wallets not supported by Qt") if 'listunspent_args' not in jm_single().config.options('POLICY'): jm_single().config.set('POLICY', 'listunspent_args', '[0]') assert wallet, "No wallet loaded" From 6b41b8b660c9e1aead9ff53679ffbb3e0d17267b Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Tue, 2 Jun 2020 21:30:48 +0100 Subject: [PATCH 16/16] Disable creation of fidelity bond wallets Leave disabled until the rest of the fidelity bond feature is created --- jmclient/jmclient/wallet_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index a1686c3..702ff08 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -632,7 +632,10 @@ def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, wallet_name = default_wallet_name wallet_path = os.path.join(walletspath, wallet_name) - support_fidelity_bonds = enter_do_support_fidelity_bonds() + # disable creating fidelity bond wallets for now until the + # rest of the fidelity bond feature is created + #support_fidelity_bonds = enter_do_support_fidelity_bonds() + support_fidelity_bonds = False wallet_cls = get_wallet_cls(get_configured_wallet_type(support_fidelity_bonds)) wallet = create_wallet(wallet_path, password, mixdepth, wallet_cls,