From 762b1f644df1c04c96be2cd09f24c2ba679c2169 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Wed, 22 Apr 2020 17:20:04 +0100 Subject: [PATCH] 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)