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)