def test_parse(self): xpub = "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13" hd_pub = HDPublicKey.parse(xpub) self.assertEqual(hd_pub.xpub(), xpub) xprv = "xprv9s21ZrQH143K25QhxbucbDDuQ4naNntJRi4KUfWT7xo4EKsHt2QJDu7KXp1A3u7Bi1j8ph3EGsZ9Xvz9dGuVrtHHs7pXeTzjuxBrCmmhgC6" hd_priv = HDPrivateKey.parse(xprv) self.assertEqual(hd_priv.xprv(), xprv)
def get_address(self, offset=0, is_change=False, sort_keys=True): """ If is_change=True, then we display change addresses. If is_change=False we display receive addresses. sort_keys is for expert users only and should be left as True """ assert type(is_change) is bool, is_change assert type(offset) is int and offset >= 0, offset sec_hexes_to_use = [] for key_record in self.key_records: hdpubkey = HDPublicKey.parse(key_record["xpub_parent"]) if is_change is True: account = key_record["account_index"] + 1 else: account = key_record["account_index"] leaf_xpub = hdpubkey.child(account).child(offset) sec_hexes_to_use.append(leaf_xpub.sec().hex()) commands = [number_to_op_code(self.quorum_m)] if sort_keys: # BIP67 lexicographical sorting for sortedmulti commands.extend([bytes.fromhex(x) for x in sorted(sec_hexes_to_use)]) else: commands.extend([bytes.fromhex(x) for x in sec_hexes_to_use]) commands.append(number_to_op_code(len(self.key_records))) commands.append(174) # OP_CHECKMULTISIG witness_script = WitnessScript(commands) redeem_script = P2WSHScriptPubKey(sha256(witness_script.raw_serialize())) return redeem_script.address(network=self.network)
def parse_partial_key_record(key_record_str): """ A partial key record will come from your Signer and include no references to change derivation. It will look something like this: [c7d0648a/48h/1h/0h/2h]tpubDEpefcgzY6ZyEV2uF4xcW2z8bZ3DNeWx9h2BcwcX973BHrmkQxJhpAXoSWZeHkmkiTtnUjfERsTDTVCcifW6po3PFR1JRjUUTJHvPpDqJhr """ key_record_re = re.match( r"\[([0-9a-f]{8})\*?(.*?)\]([0-9A-Za-z].*)", key_record_str ) if key_record_re is None: raise ValueError(f"Invalid key record: {key_record_str}") xfp, path, xpub = key_record_re.groups() # Note that we don't validate xfp because the regex already tells us it's hex path = "m" + path if not is_valid_bip32_path(path): raise ValueError(f"Invalid BIP32 path {path} in key record: {key_record_str}") try: pubkey_obj = HDPublicKey.parse(s=xpub) network = pubkey_obj.network except ValueError: raise ValueError(f"Invalid xpub {xpub} in key record: {key_record_str}") return { "xfp": xfp, "path": path, "xpub": xpub, "network": network, }
def test_zprv(self): mnemonic, priv = HDPrivateKey.generate(extra_entropy=1 << 128) for word in mnemonic.split(): self.assertTrue(word in BIP39) zprv = priv.xprv(version=bytes.fromhex("04b2430c")) self.assertTrue(zprv.startswith("zprv")) zpub = priv.pub.xpub(version=bytes.fromhex("04b24746")) self.assertTrue(zpub.startswith("zpub")) derived = HDPrivateKey.parse(zprv) self.assertEqual(zprv, derived.xprv(bytes.fromhex("04b2430c"))) mnemonic, priv = HDPrivateKey.generate(network="testnet") zprv = priv.xprv(bytes.fromhex("045f18bc")) self.assertTrue(zprv.startswith("vprv")) zpub = priv.pub.xpub(bytes.fromhex("045f1cf6")) self.assertTrue(zpub.startswith("vpub")) xpub = priv.pub.xpub(bytes.fromhex("043587cf")) self.assertTrue(xpub.startswith("tpub")) derived = HDPrivateKey.parse(zprv) self.assertEqual(zprv, derived.xprv(bytes.fromhex("045f18bc"))) derived_pub = HDPublicKey.parse(zpub) self.assertEqual(zpub, derived_pub.xpub(bytes.fromhex("045f1cf6"))) mnemonic, priv = HDPrivateKey.generate(network="signet") zprv = priv.xprv(bytes.fromhex("045f18bc")) self.assertTrue(zprv.startswith("vprv")) zpub = priv.pub.xpub(bytes.fromhex("045f1cf6")) self.assertTrue(zpub.startswith("vpub")) xpub = priv.pub.xpub(bytes.fromhex("043587cf")) self.assertTrue(xpub.startswith("tpub")) derived = HDPrivateKey.parse(zprv) self.assertEqual(zprv, derived.xprv(bytes.fromhex("045f18bc"))) derived_pub = HDPublicKey.parse(zpub) self.assertEqual(zpub, derived_pub.xpub(bytes.fromhex("045f1cf6"))) with self.assertRaises(ValueError): bad_zprv = encode_base58_checksum(b"\x00" * 78) HDPrivateKey.parse(bad_zprv) with self.assertRaises(ValueError): bad_zpub = encode_base58_checksum(b"\x00" * 78) HDPublicKey.parse(bad_zpub) with self.assertRaises(ValueError): derived_pub.child(1 << 31)
def test_vpub(self): # From https://seedpicker.net/calculator/last-word.html?network=testnet mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo buddy" path = "m/48'/1'/0'/2'" self.assertEqual( "669dce62", HDPrivateKey.from_mnemonic(mnemonic).fingerprint().hex() ) vpub = ( HDPrivateKey.from_mnemonic(mnemonic) .traverse(path) .xpub(bytes.fromhex("02575483")) ) want = "Vpub5mru9pEB9wFgRsCsYi4hcTvaqZ5p2xs2upUsN5Fig6nUrug2xkfPmbt2PfUS5QhDgCLctdkuQLmVnpN1j8a6RS9Mk53mbxi3Mx4HB6vCTWc" self.assertEqual(vpub, want) # Be sure that we can parse a vpub: hdpubkey_obj = HDPublicKey.parse(want) self.assertEqual(hdpubkey_obj.depth, 4)
def blind_xpub(starting_xpub, starting_path, secret_path): """ Blind a starting_xpub with a given (and unverifiable) path, using a secret path. Return the complete (combined) bip32 path, and """ starting_xpub_obj = HDPublicKey.parse(starting_xpub) # Note that we cannot verify the starting path, so it is essential that at least this safety check is accurate if starting_xpub_obj.depth != starting_path.count("/"): raise ValueError( f"starting_xpub_obj.depth {starting_xpub_obj.depth} != starting_path depth {starting_path.count('/')}" ) # This will automatically use the version byte that was parsed in the previous step blinded_child_xpub = starting_xpub_obj.traverse(secret_path).xpub() blinded_full_path = combine_bip32_paths(first_path=starting_path, second_path=secret_path) return { "blinded_child_xpub": blinded_child_xpub, "blinded_full_path": blinded_full_path, }
def parse_full_key_record(key_record_str): """ A full key record will come from your Coordinator and include a reference to an account index. It will look something like this: [c7d0648a/48h/1h/0h/2h]tpubDEpefcgzY6ZyEV2uF4xcW2z8bZ3DNeWx9h2BcwcX973BHrmkQxJhpAXoSWZeHkmkiTtnUjfERsTDTVCcifW6po3PFR1JRjUUTJHvPpDqJhr/0/* A full key record is basically a partial key record, with a trailing /{account-index}/* """ # Validate that it appears to be a full key record: parts = key_record_str.split("/") if parts[-1] != "*": raise ValueError( "Invalid full key record, does not end with a *: {key_record_str}" ) if not is_intable(parts[-2]): raise ValueError( "Invalid full key record, account index `{parts[-2]}` is not an int: {key_record_str}" ) # Now we strip off the trailing account index and *, and parse the rest as a partial key record partial_key_record_str = "/".join(parts[0 : len(parts) - 2]) to_return = parse_partial_key_record(key_record_str=partial_key_record_str) to_return["account_index"] = int(parts[-2]) to_return["xpub_parent"] = to_return.pop("xpub") try: parent_pubkey_obj = HDPublicKey.parse(s=to_return["xpub_parent"]) to_return["xpub_child"] = parent_pubkey_obj.child( index=to_return["account_index"] ).xpub() except ValueError: raise ValueError( f"Invalid parent xpub {to_return['xpub_parent']} in key record: {key_record_str}" ) return to_return
def _get_pubkeys_info_from_descriptor(descriptor): re_results = re.findall("wsh\(sortedmulti\((.*)\)\)", descriptor) # noqa: W605 parts = re_results[0].split(",") quorum_m = int(parts.pop(0)) quorum_n = len(parts) # remaining entries are pubkeys with fingerprint/path assert 0 < quorum_m <= quorum_n pubkey_dicts = [] for fragment in parts: pubkey_info = _re_pubkey_info_from_descriptor_fragment(fragment=fragment) parent_pubkey_obj = HDPublicKey.parse(pubkey_info["xpub"]) pubkey_info["parent_pubkey_obj"] = parent_pubkey_obj pubkey_info["child_pubkey_obj"] = parent_pubkey_obj.child( index=pubkey_info["idx"] ) pubkey_dicts.append(pubkey_info) # safety check all_pubkeys = [x["xpub"] for x in pubkey_dicts] assert ( len(set([x[:4] for x in all_pubkeys])) == 1 ), "ERROR: multiple conflicting networks in pubkeys: {}".format(all_pubkeys) xpub_prefix = all_pubkeys[0][:4] if xpub_prefix == "tpub": is_testnet = True elif xpub_prefix == "xpub": is_testnet = False else: raise Exception(f"Invalid xpub prefix: {xpub_prefix}") return { "is_testnet": is_testnet, "quorum_m": quorum_m, "quorum_n": quorum_n, "pubkey_dicts": pubkey_dicts, }
def test_prv_pub(self): tests = [ { "seed": bytes.fromhex("000102030405060708090a0b0c0d0e0f"), "paths": [ [ "m", "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi", ], [ "m/0'", "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw", "xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7", ], [ "m/0'/1", "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ", "xprv9wTYmMFdV23N2TdNG573QoEsfRrWKQgWeibmLntzniatZvR9BmLnvSxqu53Kw1UmYPxLgboyZQaXwTCg8MSY3H2EU4pWcQDnRnrVA1xe8fs", ], [ "m/0'/1/2'", "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5", "xprv9z4pot5VBttmtdRTWfWQmoH1taj2axGVzFqSb8C9xaxKymcFzXBDptWmT7FwuEzG3ryjH4ktypQSAewRiNMjANTtpgP4mLTj34bhnZX7UiM", ], [ "m/0'/1/2'/2", "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV", "xprvA2JDeKCSNNZky6uBCviVfJSKyQ1mDYahRjijr5idH2WwLsEd4Hsb2Tyh8RfQMuPh7f7RtyzTtdrbdqqsunu5Mm3wDvUAKRHSC34sJ7in334", ], [ "m/0'/1/2'/2/1000000000", "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy", "xprvA41z7zogVVwxVSgdKUHDy1SKmdb533PjDz7J6N6mV6uS3ze1ai8FHa8kmHScGpWmj4WggLyQjgPie1rFSruoUihUZREPSL39UNdE3BBDu76", ], ], }, { "seed": bytes.fromhex( "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" ), "paths": [ [ "m", "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB", "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U", ], [ "m/0", "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH", "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt", ], [ "m/0/2147483647'", "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a", "xprv9wSp6B7kry3Vj9m1zSnLvN3xH8RdsPP1Mh7fAaR7aRLcQMKTR2vidYEeEg2mUCTAwCd6vnxVrcjfy2kRgVsFawNzmjuHc2YmYRmagcEPdU9", ], [ "m/0/2147483647'/1", "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon", "xprv9zFnWC6h2cLgpmSA46vutJzBcfJ8yaJGg8cX1e5StJh45BBciYTRXSd25UEPVuesF9yog62tGAQtHjXajPPdbRCHuWS6T8XA2ECKADdw4Ef", ], [ "m/0/2147483647'/1/2147483646'", "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc", ], [ "m/0/2147483647'/1/2147483646'/2", "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt", "xprvA2nrNbFZABcdryreWet9Ea4LvTJcGsqrMzxHx98MMrotbir7yrKCEXw7nadnHM8Dq38EGfSh6dqA9QWTyefMLEcBYJUuekgW4BYPJcr9E7j", ], ], }, { "seed": bytes.fromhex( "4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be" ), "paths": [ [ "m", "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13", "xprv9s21ZrQH143K25QhxbucbDDuQ4naNntJRi4KUfWT7xo4EKsHt2QJDu7KXp1A3u7Bi1j8ph3EGsZ9Xvz9dGuVrtHHs7pXeTzjuxBrCmmhgC6", ], [ "m/0'", "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y", "xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L", ], ], }, ] for test in tests: seed = test["seed"] for path, xpub, xprv in test["paths"]: # test from seed private_key = HDPrivateKey.from_seed(seed).traverse(path) public_key = HDPublicKey.parse(xpub) self.assertEqual(private_key.xprv(), xprv) self.assertEqual(private_key.xpub(), public_key.xpub()) self.assertEqual(private_key.address(), public_key.address())
def test_full_wallet_functionality(self): """ Create a wallet, fund it, and spend it (validating on all devices first). Do this both with and without change. """ child_path = "m/0/0" base_path = "m/45'/0" xpubs = [ # Verified against electrum 2021-10 "tpubDAp5uHrhyCRnoQdMmF9xFU78bev55TKux39Viwn8VCnWywjTCBB3TFSA1i5i4scj8ZkD8RR37EZMPyqBwk4wD4VNpxyowgk2rQ7i8AdVauN", "tpubDBxHJMmnouRSes7kweA5wUp2Jm5F1xEPCG3fjYRnBSiQM7hjsp9gp8Wsag16A8yrpMgJkPX8iddXmwuxKaUnAWfdcGcuXbiATYeGXCKkEXK", "tpubDANTttKyukGYwXvhTmdCa6p1nHmsbFef2yRXb9b2JTvLGoekYk6ZG9oHXGHYJjTn5g3DijvQrZ4rH6EosYqpVngKiUx2jvWdPmq6ZSDr3ZE", ] xfps = ["9a6a2580", "8c7e2500", "0586b1ce"] pubkey_records = [] for cnt, seed_word in enumerate(("bacon", "flag", "gas")): seed_phrase = f"{seed_word} " * 24 hd_obj = HDPrivateKey.from_mnemonic(seed_phrase, network="testnet") # takes the form xfp, child_xpub, base_path pubkey_record = [ hd_obj.fingerprint().hex(), hd_obj.traverse(base_path).xpub(), base_path, ] pubkey_records.append(pubkey_record) self.assertEqual(pubkey_record, [xfps[cnt], xpubs[cnt], base_path]) pubkey_hexes = [ HDPublicKey.parse(tpub).traverse(child_path).sec().hex() for tpub in xpubs ] rs_obj = RedeemScript.create_p2sh_multisig(quorum_m=2, pubkey_hexes=pubkey_hexes, sort_keys=True) deposit_address = rs_obj.address(network="testnet") # We funded this testnet address in 2021-10: self.assertEqual(deposit_address, "2MzLzwPgo6ZTyPzkguNWKfFHgCXdZSJu9tL") input_dicts = [{ "quorum_m": 2, "path_dict": { # xfp: root_path "9a6a2580": "m/45'/0/0/0", "8c7e2500": "m/45'/0/0/0", "0586b1ce": "m/45'/0/0/0", }, "prev_tx_dict": { # This was the funding transaction send to 2MzLzwPgo6ZTyPzkguNWKfFHgCXdZSJu9tL "hex": "02000000000101045448b2faafc53a7586bd6a746a598d9a1cfc3dd548d8f8afd12c11c53c16740000000000feffffff02809a4b000000000017a914372becc78d20f8acb94059fa8d1e3219b67c1d9387a08601000000000017a9144de07b9788bc64143292f9610443a7f81cc5fc308702473044022059f2036e5b6da03e592863664082f957472dbb89330895d23ac66f94e3819f63022050d31d401cd86fe797c31ba7fb2a2cf9e12356bba8388897f4f522bc4999224f0121029c71554e111bb41463ad288b4808effef8136755da80d42aa2bcea12ed8a99bbba0e2000", "hash_hex": "838504ad934d97915d9ab58b0ece6900ed123abf3a51936563ba9d67b0e41fa4", "output_idx": 1, "output_sats": 100_000, }, }]
def create_multisig_psbt( public_key_records, input_dicts, output_dicts, fee_sats, script_type="p2sh", ): """ Helper method to create a multisig PSBT whose change can be validated. network (testnet/mainnet/signet) will be inferred from xpubs/tpubs. public_key_records are a list of entries that loom like this: [xfp_hex, xpub_b58, base_path] # TODO: turn this into a new object? """ if script_type != "p2sh": raise NotImplementedError(f"script_type {script_type} not yet implemented") # initialize variables network = None tx_lookup, pubkey_lookup, redeem_lookup, hd_pubs = {}, {}, {}, {} # Use a nested default dict for increased readability # It's possible (though nonstandard) for one xfp to have multiple public_key_records in a multisig wallet # https://stackoverflow.com/a/19189356 recursive_defaultdict = lambda: defaultdict(recursive_defaultdict) # noqa: E731 xfp_dict = recursive_defaultdict() # This at the child pubkey lookup that each input will traverse off of for xfp_hex, xpub_b58, base_path in public_key_records: hd_pubkey_obj = HDPublicKey.parse(xpub_b58) # We will use this dict/list structure for each input/ouput in the for-loops below xfp_dict[xfp_hex][base_path] = hd_pubkey_obj named_global_hd_pubkey_obj = NamedHDPublicKey.from_hd_pub( child_hd_pub=hd_pubkey_obj, xfp_hex=xfp_hex, # we're only going to base path level path=base_path, ) hd_pubs[named_global_hd_pubkey_obj.serialize()] = named_global_hd_pubkey_obj if network is None: # Set the initial value network = hd_pubkey_obj.network else: # Confirm it hasn't changed if network != hd_pubkey_obj.network: raise MixedNetwork( f"Mixed networks in public key records: {public_key_records}" ) tx_ins, total_input_sats = [], 0 for cnt, input_dict in enumerate(input_dicts): # Prev tx stuff prev_tx_dict = input_dict["prev_tx_dict"] prev_tx_obj = Tx.parse_hex(prev_tx_dict["hex"], network=network) tx_lookup[prev_tx_obj.hash()] = prev_tx_obj if prev_tx_dict["hash_hex"] != prev_tx_obj.hash().hex(): raise ValueError( f"Hash digest mismatch for input #{cnt}: {prev_tx_dict['hash_hex']} != {prev_tx_obj.hash().hex()}" ) if "path_dict" in input_dict: # Standard BIP67 unordered list of pubkeys (will be sorted lexicographically) iterator = input_dict["path_dict"].items() sort_keys = True elif "path_list" in input_dict: # Caller supplied ordering of pubkeys (will not be sorted) iterator = input_dict["path_list"] sort_keys = False else: raise RuntimeError( f"input_dict has no `path_dict` nor a `path_list`: {input_dict}" ) input_pubkey_hexes = [] for xfp_hex, root_path in iterator: # Get the correct xpub/path child_hd_pubkey = _safe_get_child_hdpubkey( xfp_dict=xfp_dict, xfp_hex=xfp_hex, root_path=root_path, cnt=cnt, ) input_pubkey_hexes.append(child_hd_pubkey.sec().hex()) # Enhance the PSBT named_hd_pubkey_obj = NamedHDPublicKey.from_hd_pub( child_hd_pub=child_hd_pubkey, xfp_hex=xfp_hex, path=root_path, ) # pubkey lookups needed for validation pubkey_lookup[named_hd_pubkey_obj.sec()] = named_hd_pubkey_obj utxo = prev_tx_obj.tx_outs[prev_tx_dict["output_idx"]] # Grab amount as developer safety check if prev_tx_dict["output_sats"] != utxo.amount: raise ValueError( f"Wrong number of sats for input #{cnt}! Expecting {prev_tx_dict['output_sats']} but got {utxo.amount}" ) total_input_sats += utxo.amount redeem_script = RedeemScript.create_p2sh_multisig( quorum_m=input_dict["quorum_m"], pubkey_hexes=input_pubkey_hexes, sort_keys=sort_keys, expected_addr=utxo.script_pubkey.address(network=network), expected_addr_network=network, ) # Confirm address matches previous ouput if redeem_script.address(network=network) != utxo.script_pubkey.address( network=network ): raise ValueError( f"Invalid redeem script for input #{cnt}. Expecting {redeem_script.address(network=network)} but got {utxo.script_pubkey.address(network=network)}" ) tx_in = TxIn(prev_tx=prev_tx_obj.hash(), prev_index=prev_tx_dict["output_idx"]) tx_ins.append(tx_in) # For enhancing the PSBT for HWWs: redeem_lookup[redeem_script.hash160()] = redeem_script tx_outs = [] for cnt, output_dict in enumerate(output_dicts): tx_out = TxOut( amount=output_dict["sats"], script_pubkey=address_to_script_pubkey(output_dict["address"]), ) tx_outs.append(tx_out) if output_dict.get("path_dict"): # This output claims to be change, so we must validate it here output_pubkey_hexes = [] for xfp_hex, root_path in output_dict["path_dict"].items(): child_hd_pubkey = _safe_get_child_hdpubkey( xfp_dict=xfp_dict, xfp_hex=xfp_hex, root_path=root_path, cnt=cnt, ) output_pubkey_hexes.append(child_hd_pubkey.sec().hex()) # Enhance the PSBT named_hd_pubkey_obj = NamedHDPublicKey.from_hd_pub( child_hd_pub=child_hd_pubkey, xfp_hex=xfp_hex, path=root_path, ) pubkey_lookup[named_hd_pubkey_obj.sec()] = named_hd_pubkey_obj redeem_script = RedeemScript.create_p2sh_multisig( quorum_m=output_dict["quorum_m"], pubkey_hexes=output_pubkey_hexes, # We intentionally only allow change addresses to be lexicographically sorted sort_keys=True, ) # Confirm address matches previous ouput if redeem_script.address(network=network) != output_dict["address"]: raise ValueError( f"Invalid redeem script for output #{cnt}. Expecting {redeem_script.address(network=network)} but got {output_dict['address']}" ) # For enhancing the PSBT for HWWs: redeem_lookup[redeem_script.hash160()] = redeem_script tx_obj = Tx( version=1, tx_ins=tx_ins, tx_outs=tx_outs, locktime=0, network=network, segwit=False, ) # Safety check to try and prevent footgun calculated_fee_sats = total_input_sats - sum([tx_out.amount for tx_out in tx_outs]) if fee_sats != calculated_fee_sats: raise ValueError( f"TX fee of {fee_sats} sats supplied != {calculated_fee_sats} sats calculated" ) return PSBT.create( tx_obj=tx_obj, validate=True, tx_lookup=tx_lookup, pubkey_lookup=pubkey_lookup, redeem_lookup=redeem_lookup, witness_lookup={}, hd_pubs=hd_pubs, )
def __init__( self, quorum_m, # m as in m-of-n key_records=[], # pubkeys required to sign checksum="", sort_key_records=True, ): if type(quorum_m) is not int or quorum_m < 1: raise ValueError(f"quorum_m must be a positive int: {quorum_m}") self.quorum_m = quorum_m if not key_records: raise ValueError("No key_records supplied") key_records_to_save, network = [], None for key_record in key_records: # TODO: does bitcoin core have a standard to enforce for h vs ' in bip32 path? path = key_record.get("path") if not is_valid_bip32_path(path): raise ValueError( f"Invalid BIP32 path `{path}` in key record: {key_record}" ) xfp_hex = key_record.get("xfp") if not is_valid_xfp_hex(xfp_hex): raise ValueError( f"Invalid hex fingerprint `{xfp_hex}` in key record: {key_record}" ) account_index = key_record.get("account_index") if type(account_index) is not int: raise ValueError( f"Invalid account index `{account_index}` in key record: {key_record}" ) xpub_parent = key_record.get("xpub_parent") try: hdpubkey_obj = HDPublicKey.parse(xpub_parent) # get rid of slip132 version byte (if it exists) as this will alter the checksum calculation hdpubkey_obj_attrs = vars(hdpubkey_obj) del hdpubkey_obj_attrs["pub_version"] del hdpubkey_obj_attrs["_raw"] xpub_to_use = HDPublicKey(**hdpubkey_obj_attrs).xpub() except ValueError: raise ValueError( f"Invalid xpub_parent `{xpub_parent}` in key record: {key_record}" ) if network is None: # This is the first key_record in our loop network = hdpubkey_obj.network else: # Validate that we haven't changed networks if hdpubkey_obj.network != network: raise ValueError( f"Network mismatch: network is set to {network} but xpub_parent is {xpub_parent}" ) key_records_to_save.append( { "path": path, "xfp": xfp_hex, "xpub_parent": xpub_to_use, "account_index": account_index, } ) if sort_key_records: # Sort lexicographically based on parent xpub key_records_to_save = sorted( key_records_to_save, key=lambda k: k["xpub_parent"] ) # Generate descriptor text (not part of above loop due to sort_key_records) descriptor_text = f"wsh(sortedmulti({quorum_m}" for kr in key_records_to_save: descriptor_text += f",[{kr['xfp']}{kr['path'][1:]}]{kr['xpub_parent']}/{kr['account_index']}/*" descriptor_text += "))" self.descriptor_text = descriptor_text self.key_records = key_records_to_save self.network = network calculated_checksum = calc_core_checksum(descriptor_text) if checksum: # test that it matches if calculated_checksum != checksum: raise ValueError( f"Calculated checksum `{calculated_checksum}` != supplied checksum `{checksum}`" ) self.checksum = calculated_checksum
def do_sign_transaction(self, arg): """ (Co)sign a multisig PSBT using 1 of your BIP39 seed phrases. Can also be used to just inspect a PSBT and not sign it. Note: This tool ONLY supports transactions with the following constraints: - We sign ALL inputs and they belong to the same multisig wallet (quorum + pubkeys). - There can only be 1 output (sweep transaction) or 2 outputs (spend + change). - If there is change, we validate it belongs to the same multisig wallet as all inputs. """ psbt_obj = _get_psbt_obj() if psbt_obj.hd_pubs: # if the PSBT included hd_pubs, then buidl will build the hdpubkey_map automatically from that hdpubkey_map = {} else: # ask for output descriptors to use to build hdpubkey_map print_blue( "PSBT doesn't include enough info to guess your account map (for validation)." ) p2wsh_sortedmulti_obj = _get_p2wsh_sortedmulti() hdpubkey_map = {} for key_record in p2wsh_sortedmulti_obj.key_records: hdpubkey_map[key_record["xfp"]] = HDPublicKey.parse( key_record["xpub_parent"]) psbt_described = psbt_obj.describe_basic_multisig( hdpubkey_map=hdpubkey_map, ) # Gather TX info and validate print_yellow(psbt_described["tx_summary_text"]) if _get_bool(prompt="In Depth Transaction View?", default=False): to_print = [] to_print.append("DETAILED VIEW") to_print.append(f"TXID: {psbt_obj.tx_obj.id()}") to_print.append("-" * 80) to_print.append(f"{len(psbt_described['inputs_desc'])} Input(s):") for cnt, input_desc in enumerate(psbt_described["inputs_desc"]): to_print.append(f" Input #{cnt}") for k, v in input_desc.items(): if k == "sats": # Comma separate ints val = f"{v:,}" else: val = v to_print.append(f" {k}: {val}") to_print.append("-" * 80) to_print.append( f"{len(psbt_described['outputs_desc'])} Output(s):") for cnt, output_desc in enumerate(psbt_described["outputs_desc"]): to_print.append(f" Output #{cnt}") for k, v in output_desc.items(): if k == "sats": # Comma separate ints val = f"{v:,}" else: val = v to_print.append(f" {k}: {val}") print_yellow("\n".join(to_print)) if not _get_bool(prompt="Sign this transaction?", default=True): print_yellow(f"Transaction {psbt_obj.tx_obj.id()} NOT signed") return hd_priv = _get_bip39_seed(network=psbt_obj.network) root_paths_for_seed = psbt_described["root_paths"][ hd_priv.fingerprint().hex()] private_keys = [ hd_priv.traverse(root_path).private_key for root_path in root_paths_for_seed ] if psbt_obj.sign_with_private_keys(private_keys) is True: print() print_yellow("Signed PSBT to broadcast:\n") print_green(psbt_obj.serialize_base64()) else: print_red("ERROR: Could NOT sign PSBT!") return