diff --git a/buidl/descriptor.py b/buidl/descriptor.py index 2362943..07e0488 100644 --- a/buidl/descriptor.py +++ b/buidl/descriptor.py @@ -1,7 +1,7 @@ import re from buidl.hd import HDPublicKey, is_valid_bip32_path -from buidl.helper import sha256, uses_only_hex_chars +from buidl.helper import is_intable, sha256, uses_only_hex_chars from buidl.op import number_to_op_code from buidl.script import P2WSHScriptPubKey, WitnessScript @@ -67,41 +67,42 @@ def is_valid_xfp_hex(string): def parse_full_key_record(key_record_str): """ - A full key record will come from your Coordinator and include a reference to change derivation. + 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/*' + [c7d0648a/48h/1h/0h/2h]tpubDEpefcgzY6ZyEV2uF4xcW2z8bZ3DNeWx9h2BcwcX973BHrmkQxJhpAXoSWZeHkmkiTtnUjfERsTDTVCcifW6po3PFR1JRjUUTJHvPpDqJhr/0/* + + A full key record is basically a partial key record, with a trailing /{account-index}/* """ - key_record_re = re.match( - r"\[([0-9a-f]{8})(.*?)\]([0-9A-Za-z]+)\/([0-9]+?)\/\*", key_record_str - ) - if key_record_re is None: - raise ValueError(f"Invalid key record: {key_record_str}") + # 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}" + ) - xfp, path, xpub, index_str = key_record_re.groups() - # Note that we don't validate xfp/index because the regex already tells us they're composed of ints + # 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]) - index_int = int(index_str) - - path = "m" + path - if not is_valid_bip32_path(path): - raise ValueError(f"Invalid BIP32 path {path} in key record: {key_record_str}") + 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=xpub) - network = parent_pubkey_obj.network - xpub_child = parent_pubkey_obj.child(index=index_int).xpub() + 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 xpub {xpub} in key record: {key_record_str}") + raise ValueError( + f"Invalid parent xpub {to_return['xpub_parent']} in key record: {key_record_str}" + ) - return { - "xfp": xfp, - "path": path, - "xpub_parent": xpub, - "account_index": index_int, - "xpub_child": xpub_child, - "network": network, - } + return to_return def parse_partial_key_record(key_record_str): @@ -138,6 +139,17 @@ def parse_partial_key_record(key_record_str): } +def parse_any_key_record(key_record_str): + """ + Try to parse a key record as full key record, and if not parse as a partial key record + + """ + try: + return parse_full_key_record(key_record_str) + except ValueError: + return parse_partial_key_record(key_record_str) + + class P2WSHSortedMulti: # TODO: make an inheritable base descriptor class that this inherits from @@ -147,6 +159,7 @@ def __init__( 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}") @@ -156,7 +169,6 @@ def __init__( raise ValueError("No key_records supplied") key_records_to_save, network = [], None - descriptor_text = f"wsh(sortedmulti({quorum_m}" 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") @@ -209,14 +221,23 @@ def __init__( } ) - descriptor_text += f",[{key_record['xfp']}{key_record['path'][1:]}]{xpub_to_use}/{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: @@ -274,7 +295,12 @@ def parse(cls, output_record): f"Malformed threshold {quorum_m_int}-of-{len(key_records)} (m must be less than n) in {output_record}" ) - return cls(quorum_m=quorum_m_int, key_records=key_records, checksum=checksum) + return cls( + quorum_m=quorum_m_int, + key_records=key_records, + sort_key_records=False, + checksum=checksum, + ) def get_address(self, offset=0, is_change=False, sort_keys=True): """ diff --git a/buidl/psbt_helper.py b/buidl/psbt_helper.py index 4b5719f..de053ac 100644 --- a/buidl/psbt_helper.py +++ b/buidl/psbt_helper.py @@ -47,7 +47,7 @@ def create_p2sh_multisig_psbt( for xfp_hex, base_paths in xpubs_dict.items(): for base_path in base_paths: - hd_pubkey_obj = HDPublicKey.parse(base_path["xpub_hex"]) + hd_pubkey_obj = HDPublicKey.parse(base_path["xpub_b58"]) # We will use this dict/list structure for each input/ouput in the for-loops below xfp_dict[xfp_hex][base_path["base_path"]] = hd_pubkey_obj diff --git a/buidl/test/test_descriptor.py b/buidl/test/test_descriptor.py index 60b554e..38d9c92 100644 --- a/buidl/test/test_descriptor.py +++ b/buidl/test/test_descriptor.py @@ -212,7 +212,9 @@ def test_mixed_slip132_p2wsh_sortedmulti(self): "account_index": 0, }, ] - p2wsh_sortedmulti_obj = P2WSHSortedMulti(quorum_m, key_records) + p2wsh_sortedmulti_obj = P2WSHSortedMulti( + quorum_m, key_records, sort_key_records=False + ) # notice that both values are tpub, despite ingesting Vpub want = "wsh(sortedmulti(1,[aa917e75/48h/1h/0h/2h]tpubDEZRP2dRKoGRJnR9zn6EoLouYKbYyjFsxywgG7wMQwCDVkwNvoLhcX1rTQipYajmTAF82kJoKDiNCgD4wUPahACE7n1trMSm7QS8B3S1fdy/0/*,[2553c4b8/48h/1h/0h/2h/2046266013/1945465733/1801020214/1402692941]tpubDNVvpMhdGTmQg1AT6muju2eUWPXWWAtUSyc1EQ2MxJ2s97fMqFZQbpzQM4gU8bwzfFM7KBpSXRJ5v2Wu8sY2GF5ZpXm3qy8GLArZZNM1Wru/0/*))#0lfdttke" self.assertEqual(str(p2wsh_sortedmulti_obj), want) diff --git a/buidl/test/test_psbt_helper.py b/buidl/test/test_psbt_helper.py index 04b7727..e43cfc9 100644 --- a/buidl/test/test_psbt_helper.py +++ b/buidl/test/test_psbt_helper.py @@ -49,14 +49,14 @@ def test_sweep_1of2_p2sh(self): "e0c595c5": [ { # action x12 - "xpub_hex": "tpubDBnspiLZfrq1V7j1iuMxGiPsuHyy6e4QBnADwRrbH89AcnsUEMfWiAYXmSbMuNFsrMdnbQRDGGSM1AFGL6zUWNVSmwRavoJzdQBbZKLgLgd", + "xpub_b58": "tpubDBnspiLZfrq1V7j1iuMxGiPsuHyy6e4QBnADwRrbH89AcnsUEMfWiAYXmSbMuNFsrMdnbQRDGGSM1AFGL6zUWNVSmwRavoJzdQBbZKLgLgd", "base_path": "m/45h/0", } ], "838f3ff9": [ { # agent x12 - "xpub_hex": "tpubDAKJicb9Tkw34PFLEBUcbnH99twN3augmg7oYHHx9Aa9iodXmA4wtGEJr8h2XjJYqn2j1v5qHLjpWEe8aPihmC6jmsgomsuc9Zeh4ZushNk", + "xpub_b58": "tpubDAKJicb9Tkw34PFLEBUcbnH99twN3augmg7oYHHx9Aa9iodXmA4wtGEJr8h2XjJYqn2j1v5qHLjpWEe8aPihmC6jmsgomsuc9Zeh4ZushNk", "base_path": "m/45h/0", } ], @@ -141,14 +141,14 @@ def test_spend_1of2_with_change(self): "e0c595c5": [ { # action x12 - "xpub_hex": "tpubDBnspiLZfrq1V7j1iuMxGiPsuHyy6e4QBnADwRrbH89AcnsUEMfWiAYXmSbMuNFsrMdnbQRDGGSM1AFGL6zUWNVSmwRavoJzdQBbZKLgLgd", + "xpub_b58": "tpubDBnspiLZfrq1V7j1iuMxGiPsuHyy6e4QBnADwRrbH89AcnsUEMfWiAYXmSbMuNFsrMdnbQRDGGSM1AFGL6zUWNVSmwRavoJzdQBbZKLgLgd", "base_path": "m/45h/0", } ], "838f3ff9": [ { # agent x12 - "xpub_hex": "tpubDAKJicb9Tkw34PFLEBUcbnH99twN3augmg7oYHHx9Aa9iodXmA4wtGEJr8h2XjJYqn2j1v5qHLjpWEe8aPihmC6jmsgomsuc9Zeh4ZushNk", + "xpub_b58": "tpubDAKJicb9Tkw34PFLEBUcbnH99twN3augmg7oYHHx9Aa9iodXmA4wtGEJr8h2XjJYqn2j1v5qHLjpWEe8aPihmC6jmsgomsuc9Zeh4ZushNk", "base_path": "m/45h/0", } ], diff --git a/multiwallet.py b/multiwallet.py index 7043d81..bc96c7e 100755 --- a/multiwallet.py +++ b/multiwallet.py @@ -12,7 +12,7 @@ import buidl # noqa: F401 (used below with pkg_resources for versioning) from buidl.blinding import blind_xpub, secure_secret_path -from buidl.descriptor import P2WSHSortedMulti, parse_partial_key_record +from buidl.descriptor import P2WSHSortedMulti, parse_any_key_record from buidl.hd import ( calc_num_valid_seedpicker_checksums, calc_valid_seedpicker_checksums, @@ -116,6 +116,10 @@ def _get_bool(prompt, default=True): print_red("Please choose either y or n") +def _get_string(prompt, default=""): + return input(blue_fg(f"{prompt} [{default}]: ")).strip().lower() or default + + def _get_path_string(): while True: res = input(blue_fg('Path to use (should start with "m/"): ')).strip().lower() @@ -193,7 +197,7 @@ def complete(self, text, state): ##################################################################### -def get_p2wsh_sortedmulti(): +def _get_p2wsh_sortedmulti(): while True: output_record = input( blue_fg("Paste in your account map (AKA output record): ") @@ -233,7 +237,7 @@ def _get_bip39_firstwords(): all_words_valid = True for cnt, word in enumerate(fw.split()): if word not in BIP39: - print_red(f"Word #{cnt+1} ({word} is not a valid BIP39 word") + print_red(f"Word #{cnt+1} `{word}` is not a valid BIP39 word") all_words_valid = False if all_words_valid is False: continue @@ -294,13 +298,6 @@ def _get_psbt_obj(): return psbt_obj -def _abort(msg): - " Used because TX signing is complicated and we might bail after intial pasting of PSBT " - print_red("ABORTING WITHOUT SIGNING:\n") - print_red(msg) - return True - - def _get_bip39_seed(network): readline.parse_and_bind("tab: complete") old_completer = readline.get_completer() @@ -344,7 +341,7 @@ def _get_key_record(prompt): while True: key_record_str = input(key_record_prompt).strip() try: - return parse_partial_key_record(key_record_str=key_record_str) + return parse_any_key_record(key_record_str=key_record_str) except ValueError as e: print_red(f"Could not parse entry: {e}") continue @@ -397,11 +394,11 @@ def __init__(self, stdin=sys.stdin, stdout=sys.stdout): Cmd.__init__(self, stdin=stdin, stdout=stdout) def do_generate_seed(self, arg): - """Seedpicker implementation: calculate bitcoin public and private key information from BIP39 words you draw out of a hat""" + """Calculate bitcoin public and private key information from BIP39 words you draw out of a hat (using the seedpicker implementation)""" if not self.ADVANCED_MODE: _print_footgun_warning( - "This will enable passphrases, custom paths, and different checksum indices." + "This will enable passphrases, custom paths, slip132 version bytes, and different checksum indices." ) first_words = _get_bip39_firstwords() @@ -491,12 +488,7 @@ def do_generate_seed(self, arg): print_green(key_record) def do_create_output_descriptors(self, arg): - """Combine m-of-n key records to create an output descriptor (account map)""" - - if not self.ADVANCED_MODE: - _print_footgun_warning( - "This will enable custom account indices (instead of just defaulting to 0)." - ) + """Combine m-of-n public key records into a multisig output descriptor (account map)""" m_int = _get_int( prompt="How many signatures will be required to spend from this wallet?", @@ -506,29 +498,38 @@ def do_create_output_descriptors(self, arg): ) n_int = _get_int( prompt="How many total keys will be able to sign transaction from this wallet?", - default=m_int, + default=min(m_int + 1, 15), minimum=m_int, maximum=15, ) key_records = [] for cnt in range(n_int): - prompt = f"Enter key record #{cnt+1} (of {n_int}) in the format [deadbeef/path]xpub: " + prompt = f"Enter xpub key record #{cnt+1} (of {n_int}) in the format [deadbeef/path]xpub: " key_record_dict = _get_key_record(prompt) - key_record_dict["xpub_parent"] = key_record_dict.pop("xpub") - if self.ADVANCED_MODE: - account_index = _get_int( - prompt="Enter account index for this key record?", - default=0, - minimum=0, - maximum=100, - ) - key_record_dict["account_index"] = account_index - else: + if key_record_dict.get("account_index") is None: + # we have a partial dict key_record_dict["account_index"] = 0 + key_record_dict["xpub_parent"] = key_record_dict.pop("xpub") + # Safety check + if key_record_dict in key_records: + # TODO: make this more performant + print_red( + "ABORTING! Cannot use the same xpub key record twice in the same output descriptor." + ) + return key_records.append(key_record_dict) + sort_key_records = True + if self.ADVANCED_MODE: + sort_key_records = _get_bool( + "Sort parent xpubs? This does not affect child addresses for sortedmulti, but alters bitcoin core descriptor checksum.", + default=True, + ) + p2wsh_sortedmulti_obj = P2WSHSortedMulti( - quorum_m=m_int, key_records=key_records + quorum_m=m_int, + key_records=key_records, + sort_key_records=sort_key_records, ) print_yellow("Your output descriptors are:\n") print_green(str(p2wsh_sortedmulti_obj)) @@ -591,8 +592,17 @@ def do_blind_xpub(self, arg): ) print_yellow("\n".join(warning_msg)) - def do_recover_seed(self, arg): - """Recover a seed from Shamir shares per SLIP39""" + def do_shamir_recover_seed(self, arg): + """Recover a seed from Shamir shares using a SLIP39-like standard""" + # Prevent footgun-ing + if not self.ADVANCED_MODE: + warning_msg = ( + "This shamir share implementation is non-standard!\n" + "If you're trying to recover from SLIP39 seed generated elsewhere, it probably won't work." + ) + _print_footgun_warning(warning_msg) + return + share_mnemonics = [] while True: share_phrase = input( @@ -612,8 +622,18 @@ def do_recover_seed(self, arg): except (TypeError, ValueError, SyntaxError) as e: print_red(e) - def do_split_seed(self, arg): - """Split a seed to Shamir shares per SLIP39""" + def do_shamir_split_seed(self, arg): + """Split a seed to Shamir shares using a SLIP39-like standard""" + # Prevent footgun-ing + if not self.ADVANCED_MODE: + warning_msg = ( + "This shamir share implementation is non-standard!\n" + "It has the benefit of being reversible, meaning it accepts standard BIP39 seeds and can recreate them later from Shamir Shares, the SSSS protocol is not compatible with SLIP39." + "However, once you recover your original BIP39 seed phrase (not possible with SLIP39), you can put that into any hardware wallet." + ) + _print_footgun_warning(warning_msg) + return + mnemonic = input(blue_fg("Enter a BIP39 seed phrase: ")).strip() k = _get_int( "How many Shamir shares should be required to recover the seed phrase?", @@ -675,9 +695,9 @@ def do_split_seed(self, arg): prompt = f"You will need {k} of these {n} share phrases{additional} to recover your seed phrase:\n\n{share_mnemonics}" print_green(prompt) - def do_receive(self, arg): + def do_validate_address(self, arg): """Verify receive addresses for a multisig wallet using output descriptors (from Specter-Desktop)""" - p2wsh_sortedmulti_obj = get_p2wsh_sortedmulti() + p2wsh_sortedmulti_obj = _get_p2wsh_sortedmulti() limit = _get_int( prompt="Limit of addresses to display", # This is slow without libsecp256k1: @@ -710,16 +730,20 @@ def do_receive(self, arg): ) print_green(f"#{offset_to_use}: {address}") - def do_send(self, arg): - """Sign a multisig PSBT using 1 of your BIP39 seed phrases. Can also be used to just inspect a TX and not sign it.""" - # This tool only supports a TX with the following constraints: - # We sign ALL inputs and they have 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 has the same multisig wallet as the inputs we sign. + 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. + """ # Unfortunately, there is no way to validate change without having the hdpubkey_map # TODO: make version where users can enter this later (after manually approving the transaction)? - p2wsh_sortedmulti_obj = get_p2wsh_sortedmulti() + p2wsh_sortedmulti_obj = _get_p2wsh_sortedmulti() psbt_obj = _get_psbt_obj() hdpubkey_map = {} @@ -785,7 +809,8 @@ def do_send(self, arg): ] for xfp in sorted(psbt_described["inputs_desc"][0]["root_xfp_hexes"]): err.append(" " + xfp) - return _abort("\n".join(err)) + print_red(f"ABORTING WITHOUT SIGNING:\n {err}") + return private_keys = [ hd_priv.traverse(root_path).private_key for root_path in root_paths @@ -796,11 +821,34 @@ def do_send(self, arg): print_yellow("Signed PSBT to broadcast:\n") print_green(psbt_obj.serialize_base64()) else: - return _abort("PSBT was NOT signed") + print_red("ERROR: Could NOT sign PSBT!") + return + + def do_convert_descriptors_to_caravan(self, arg): + """ + Convert bitcoin core output descriptors to caravan import + """ + p2wsh_sortedmulti_obj = _get_p2wsh_sortedmulti() + + wallet_name = _get_string("Enter wallet name", default="p2wsh wallet") + + key_record_names = [] + if _get_bool("Give human name to each key record?", default=False): + for cnt, kr in enumerate(p2wsh_sortedmulti_obj.key_records): + kr_name = _get_string( + f"Enter key record name for {kr['xfp']}", default=f"Seed #{cnt+1}" + ) + key_record_names.append(kr_name) + + caravan_json = p2wsh_sortedmulti_obj.caravan_export( + wallet_name=wallet_name, key_record_names=key_record_names + ) + print_yellow("Output descriptor as Caravan import (save this to a file):") + print_green(caravan_json) - def do_advanced_mode(self, arg): + def do_toggle_advanced_mode(self, arg): """ - Toggle advanced mode features like passphrases, different BIP39 seed checksums, and non-standard BIP32 paths. + Toggle advanced mode features like passphrases, different BIP39 seed checksums, non-standard BIP32 paths, xpub blinding, shamir's secret sharing scheme, etc. WARNING: these features are for advanced users and could lead to loss of funds. """ if self.ADVANCED_MODE: @@ -810,7 +858,7 @@ def do_advanced_mode(self, arg): self.ADVANCED_MODE = True print_yellow("ADVANCED mode set, don't mess up!") - def do_debug(self, arg): + def do_version_info(self, arg): """Print program settings for debug purposes""" to_print = [ diff --git a/setup.py b/setup.py index 0307204..bade338 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="buidl", - version="0.2.21", + version="0.2.22", author="Example Author", author_email="author@example.com", description="An easy-to-use and fully featured bitcoin library written in pure python (no dependencies).", diff --git a/singlesweep.py b/singlesweep.py index be6a98d..1d28d03 100755 --- a/singlesweep.py +++ b/singlesweep.py @@ -112,13 +112,6 @@ def _get_psbt_obj(network): return psbt_obj -def _abort(msg): - " Used because TX signing is complicated and we might bail after intial pasting of PSBT " - print_red("ABORTING WITHOUT SIGNING:\n") - print_red(msg) - return True - - ##################################################################### # Command Line App Code Starts Here ##################################################################### @@ -158,7 +151,8 @@ def do_sweep(self, arg): try: psbt_described = psbt_obj.describe_p2pkh_sweep(privkey_obj=privkey_obj) except Exception as e: - return _abort(f"Could not describe PSBT: {e}") + print_red(f"ABORTING WITHOUT SIGNING, could not describe PSBT:\n{e}") + return # Gather TX info and validate print_yellow(psbt_described["tx_summary_text"]) @@ -213,7 +207,7 @@ def do_sweep(self, arg): for cnt, _ in enumerate(tx_obj.tx_ins): was_signed = tx_obj.sign_p2pkh(input_index=cnt, private_key=privkey_obj) if was_signed is not True: - return _abort("PSBT was NOT signed") + print_red("PSBT was NOT signed") print_yellow(f"SIGNED TX {tx_obj.hash().hex()} has the following hex:\n") print_green(tx_obj.serialize().hex()) @@ -226,7 +220,7 @@ def do_sweep(self, arg): ' - Electrum signing of a previously unsigned transaction: "Combine" > "Merge Signatures From"\n' ) - def do_debug(self, arg): + def do_version_info(self, arg): """Print program settings for debug purposes""" to_print = [ diff --git a/test_multiwallet.py b/test_multiwallet.py index 9d26f7d..0e1ee27 100644 --- a/test_multiwallet.py +++ b/test_multiwallet.py @@ -41,8 +41,8 @@ def setUp(self): self.child = pexpect.spawn("python3 multiwallet.py", timeout=2) self.expect("Welcome to multiwallet, the stateless multisig bitcoin wallet") - def test_debug(self): - self.child.sendline("debug") + def test_version_info(self): + self.child.sendline("version_info") self.expect("buidl Version: ") self.expect("Multiwallet Mode: ") self.expect("Python Version: ") @@ -77,25 +77,26 @@ def test_create_output_descriptors_blinded(self): self.child.sendline("create_output_descriptors") - # UGLY HACK - # Line reads: - # How many signatures will be required to spend from this wallet? - # But Github CI uses a very narrow terminal and only picks this part up: - self.expect("be required to spend from this wallet?") + # UGLY HACK - Github CI uses a narrow terminal and only displays part of this line: + full_text = "How many signatures will be required to spend from this wallet?" + self.expect(full_text[25:]) + self.child.sendline("1") - # Line reads: - # How many total keys will be able to sign transaction from this wallet? - # But Github CI uses a very narrow terminal and only picks this part up: - self.expect("to sign transaction from this wallet?") + # UGLY HACK - Github CI uses a narrow terminal and only displays part of this line: + self.expect( + "How many total keys will be able to sign transaction from this wallet?"[ + 33: + ] + ) self.child.sendline("2") - self.expect("Enter key record ") + self.expect("Enter xpub key record ") self.child.sendline( "[aa917e75/48h/1h/0h/2h]tpubDEZRP2dRKoGRJnR9zn6EoLouYKbYyjFsxywgG7wMQwCDVkwNvoLhcX1rTQipYajmTAF82kJoKDiNCgD4wUPahACE7n1trMSm7QS8B3S1fdy" ) - self.expect("Enter key record ") + self.expect("Enter xpub key record ") self.child.sendline( "[2553c4b8/48h/1h/0h/2h/2046266013/1945465733/1801020214/1402692941]tpubDNVvpMhdGTmQg1AT6muju2eUWPXWWAtUSyc1EQ2MxJ2s97fMqFZQbpzQM4gU8bwzfFM7KBpSXRJ5v2Wu8sY2GF5ZpXm3qy8GLArZZNM1Wru" ) @@ -108,25 +109,25 @@ def test_create_output_descriptors(self): self.child.sendline("create_output_descriptors") - # UGLY HACK - # Line reads: - # How many signatures will be required to spend from this wallet? - # But Github CI uses a very narrow terminal and only picks this part up: - self.expect("be required to spend from this wallet?") + # UGLY HACK - Github CI uses a narrow terminal and only displays part of this line: + full_text = "How many signatures will be required to spend from this wallet?" + self.expect(full_text[25:]) self.child.sendline("1") - # Line reads: - # How many total keys will be able to sign transaction from this wallet? - # But Github CI uses a very narrow terminal and only picks this part up: - self.expect("to sign transaction from this wallet?") + # UGLY HACK - Github CI uses a narrow terminal and only displays part of this line: + self.expect( + "How many total keys will be able to sign transaction from this wallet?"[ + 33: + ] + ) self.child.sendline("2") - self.expect("Enter key record ") + self.expect("Enter xpub key record ") self.child.sendline( "[aa917e75/48h/1h/0h/2h]tpubDEZRP2dRKoGRJnR9zn6EoLouYKbYyjFsxywgG7wMQwCDVkwNvoLhcX1rTQipYajmTAF82kJoKDiNCgD4wUPahACE7n1trMSm7QS8B3S1fdy" ) - self.expect("Enter key record ") + self.expect("Enter xpub key record ") self.child.sendline( "[2553c4b8/48h/1h/0h/2h]tpubDEiNuxUt4pKjKk7khdv9jfcS92R1WQD6Z3dwjyMFrYj2iMrYbk3xB5kjg6kL4P8SoWsQHpd378RCTrM7fsw4chnJKhE2kfbfc4BCPkVh6g9" ) @@ -137,7 +138,7 @@ def test_receive_addr(self): account_map = "wsh(sortedmulti(1,[aa917e75/48h/1h/0h/2h]tpubDEZRP2dRKoGRJnR9zn6EoLouYKbYyjFsxywgG7wMQwCDVkwNvoLhcX1rTQipYajmTAF82kJoKDiNCgD4wUPahACE7n1trMSm7QS8B3S1fdy/0/*,[2553c4b8/48h/1h/0h/2h]tpubDEiNuxUt4pKjKk7khdv9jfcS92R1WQD6Z3dwjyMFrYj2iMrYbk3xB5kjg6kL4P8SoWsQHpd378RCTrM7fsw4chnJKhE2kfbfc4BCPkVh6g9/0/*))#t0v98kwu" receive_addr = "tb1qtsvps7q8j5mn2qqfrujlrnwraelkptps5k595hn5d4tfq7mv644sfkkxps" - self.child.sendline("receive") + self.child.sendline("validate_address") self.expect("Paste in your account map (AKA output record") self.child.sendline(account_map) @@ -154,10 +155,10 @@ def test_change_addr(self): account_map = "wsh(sortedmulti(1,[aa917e75/48h/1h/0h/2h]tpubDEZRP2dRKoGRJnR9zn6EoLouYKbYyjFsxywgG7wMQwCDVkwNvoLhcX1rTQipYajmTAF82kJoKDiNCgD4wUPahACE7n1trMSm7QS8B3S1fdy/0/*,[2553c4b8/48h/1h/0h/2h]tpubDEiNuxUt4pKjKk7khdv9jfcS92R1WQD6Z3dwjyMFrYj2iMrYbk3xB5kjg6kL4P8SoWsQHpd378RCTrM7fsw4chnJKhE2kfbfc4BCPkVh6g9/0/*))#t0v98kwu" change_addr = "tb1qjcsz3nmscxdecksnrn5k9dxrj0g3f7xkuclk53aqu33lg06r0cks5l8ew8" - self.child.sendline("advanced_mode") + self.child.sendline("toggle_advanced_mode") self.expect("ADVANCED mode set") - self.child.sendline("receive") + self.child.sendline("validate_address") self.expect("Paste in your account map (AKA output record)") self.child.sendline(account_map) @@ -167,11 +168,12 @@ def test_change_addr(self): self.expect("Offset of addresses to display") self.child.sendline("0") - # UGLY HACK - # Line reads: - # Display receive addresses? `N` to display change addresses instead. [Y/n]: - # But Github CI uses a very narrow terminal and only picks this part up: - self.expect("to display change addresses instead") + # UGLY HACK - Github CI uses a narrow terminal and only displays part of this line: + self.expect( + "Display receive addresses? `N` to display change addresses instead. [Y/n]:"[ + 31: + ] + ) self.child.sendline("N") self.expect("1-of-2 Multisig Change Addresses") @@ -183,7 +185,7 @@ def test_sign_tx(self): seed_phrase = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo abstract" signed_psbt_b64 = "cHNidP8BAFICAAAAASqJ31Trzpdt/MCBc1rpqmJyTcrhHNgqYqmsoDzHoklrAQAAAAD+////AYcmAAAAAAAAFgAUVH5mMP/WhqzXEzUORHbh1WJ7TS4AAAAAAAEBKxAnAAAAAAAAIgAgW8ODIeZA3ep/uESxtEZmQlxl4Q0QWWbe4I7x3aHuEvAiAgI0eOoa6SLJeaxzFWRXvzgWElJmJgyZMSfbSZ7plUxF9kgwRQIhAKTtWurRx19SWBS0G50IkvEDqbdZG2Q0KuTPB3BRWUCoAiBBWAtAQdmL+uV7aMwcJIacsFtYzrGagkhf6ZfEySXPXgEBBYtRIQI0eOoa6SLJeaxzFWRXvzgWElJmJgyZMSfbSZ7plUxF9iECYYmlbj1NXorYlB1Ed7jOwa4nt+xwhePNaxnQW53o6lQhApaCK4Vcv04C6td57v3zGuHGrrVjXQEMKwKbbS8GHrkKIQLEV1INwWxsAYHEj/ElyUDHWQOxdbsfQzP2LT4IRZmWY1SuIgYCNHjqGukiyXmscxVkV784FhJSZiYMmTEn20me6ZVMRfYc99BAkDAAAIABAACAAAAAgAIAAIAAAAAABgAAACIGAmGJpW49TV6K2JQdRHe4zsGuJ7fscIXjzWsZ0Fud6OpUHDpStc0wAACAAQAAgAAAAIACAACAAAAAAAYAAAAiBgKWgiuFXL9OAurXee798xrhxq61Y10BDCsCm20vBh65ChwSmA7tMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgYCxFdSDcFsbAGBxI/xJclAx1kDsXW7H0Mz9i0+CEWZlmMcx9BkijAAAIABAACAAAAAgAIAAIAAAAAABgAAAAAA" - self.child.sendline("send") + self.child.sendline("sign_transaction") self.expect("Paste in your account map (AKA output record") self.child.sendline(account_map) @@ -216,6 +218,21 @@ def test_sign_tx(self): self.expect(signed_psbt_b64) + def test_caravan_output_descriptors(self): + descriptors = "wsh(sortedmulti(1,[aa917e75/48h/1h/0h/2h]tpubDEZRP2dRKoGRJnR9zn6EoLouYKbYyjFsxywgG7wMQwCDVkwNvoLhcX1rTQipYajmTAF82kJoKDiNCgD4wUPahACE7n1trMSm7QS8B3S1fdy/0/*,[2553c4b8/48h/1h/0h/2h]tpubDEiNuxUt4pKjKk7khdv9jfcS92R1WQD6Z3dwjyMFrYj2iMrYbk3xB5kjg6kL4P8SoWsQHpd378RCTrM7fsw4chnJKhE2kfbfc4BCPkVh6g9/0/*))#t0v98kwu" + self.child.sendline("convert_descriptors_to_caravan") + self.expect("Paste in your account map (AKA output record") + + self.child.sendline(descriptors) + self.expect("Enter wallet name [p2wsh wallet]: ") + + self.child.sendline("") + self.expect("Give human name to each key record? [y/N]:") + + self.child.sendline("N") + # This is a weak check that we didn't crash, we're not validating the response as it's too wide for GitHub CI terminal to be meaningful + self.expect("Output descriptor as Caravan import (save this to a file):") + def test_fail(self): # This has to take some seconds to fail mw = pexpect.spawn("python3 multiwallet.py", timeout=1) diff --git a/test_singlesweep.py b/test_singlesweep.py index d4d7654..f15e386 100644 --- a/test_singlesweep.py +++ b/test_singlesweep.py @@ -46,8 +46,8 @@ def setUp(self): "Welcome to singlesweep, a stateless single sig sweeper that works with WIF and PSBTs." ) - def test_debug(self): - self.child.sendline("debug") + def test_version_info(self): + self.child.sendline("version_info") self.expect("buidl Version: ") self.expect("Python Version: ") self.expect("Platform: ")