Example #1
0
 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)
Example #2
0
    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)
Example #3
0
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,
    }
Example #4
0
 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)
Example #5
0
    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)
Example #6
0
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,
    }
Example #7
0
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
Example #8
0
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,
    }
Example #9
0
 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())
Example #10
0
    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,
            },
        }]
Example #11
0
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,
    )
Example #12
0
    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
Example #13
0
    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