diff --git a/cli/signit.py b/cli/signit.py index fd7bc0b1e..ec100c9c8 100755 --- a/cli/signit.py +++ b/cli/signit.py @@ -319,7 +319,7 @@ def doit(keydir, outfn=None, build_dir=None, high_water=False, pubkey_num=pubkey_num, timestamp=timestamp(backdate) ) - assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH, hdr.firmware_length + assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH_MK4, hdr.firmware_length if hw_compat & MK_3_OK: # actual file length limited by size of SPI flash area reserved to txn data/uploads diff --git a/docs/limitations.md b/docs/limitations.md index c9c3cb5c4..5878b892b 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -128,7 +128,8 @@ We will summarize transaction outputs as "change" back into same wallet, however - `p2wsh-p2sh`: _redeemScript_ (which is: `0x00 + 0x20 + sha256(witnessScript)`), and _witnessScript_ (which contains the multisig script) - `p2wsh`: only _witnessScript_ (which contains the actual multisig script) - + - `p2tr`(keypath singlesig): no _redeemScript_, no _witnessScript_ and output key MUST commit to an unspendable script path as follows `Q = P + int(hashTapTweak(bytes(P)))G` + - `p2tr`(scriptpath multisig): _taproot_merkle_root_ and _leaf_script_ more info in docs/taproot.md # Derivation Paths diff --git a/docs/miniscript.md b/docs/miniscript.md new file mode 100644 index 000000000..a8a618c90 --- /dev/null +++ b/docs/miniscript.md @@ -0,0 +1,27 @@ +# Miniscript + +**COLDCARD®** Mk4 experimental `EDGE` versions +support Miniscript and MiniTapscript. + +## Import/Export + +* `Settings` -> `Miniscript` -> `Import from file` +* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) allowed for import +* `Settings` -> `Miniscript` -> `` -> `Descriptors` +* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) are exported +* export extended keys to participate in miniscript: + * `Advanced/Tools` -> `Export Wallet` -> `Generic JSON` + * `Settings` -> `Multisig Wallets` -> `Export XPUB` + +## Address Explorer + +Same as with basic multisig. After miniscript wallet is imported, +item with `` is added to `Address Explorer` menu. + + +## Limitations +* no duplicate keys in miniscript (at least change indexes in subderivation has to be different) +* subderivation may be omitted during the import - default `<0;1>/*` is implied +* only keys with key origin info `[xfp/p/a/t/h]xpub` +* maximum number of keys allowed in segwit v0 miniscript is 20 +* check MiniTapscript limitations in `docs/taproot.md` \ No newline at end of file diff --git a/docs/taproot.md b/docs/taproot.md new file mode 100644 index 000000000..62d1bc106 --- /dev/null +++ b/docs/taproot.md @@ -0,0 +1,75 @@ +# Taproot + +**COLDCARD®** Mk4 experimental `EDGE` versions +support Schnorr signatures ([BIP-0340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)), +Taproot ([BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)) +and Tapscript ([BIP-0342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)) support. + +## Output script (a.k.a address) generation + +If the spending conditions do not require a script path, the output key MUST commit to an unspendable script path. +`Q = P + int(hashTapTweak(bytes(P)))G` a.k.a internal key MUST be tweaked by `TapTweak` tagged hash of itself. If +the spending conditions require script path, internal key MUST be tweaked by `TapTweak` tagged hash of tree merkle root. + +Addresses in `Address Explorer` for `p2tr` are generated with above-mentioned methods. Outputs `scriptPubkeys` in PSBT +MUST be generated with above-mentoned methods to be considered change. + +## Allowed descriptors + +1. Single signature wallet without script path: `tr(key)` +2. Tapscript multisig with internal key and up to 8 leaf scripts: + * `tr(internal_key, sortedmulti_a(2,@0,@1))` + * `tr(internal_key, pk(@0))` + * `tr(internal_key, {sortedmulti_a(2,@0,@1),pk(@2)})` + * `tr(internal_key, {or_d(pk(@0),and_v(v:pkh(@1),older(1000))),pk(@2)})` + +## Provably unspendable internal key + +There are few methods to provide/generate provably unspendable internal key, if users wish to only use tapscript script path. + +1. **(recommended)** Origin-less extended key serialization with H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs) as BIP-32 key and random chaincode. + + `tr(xpub/<0:1>/*, sortedmulti_a(2,@0,@1))` which is the same thing as `tr(xpub, sortedmulti_a(2,@0,@1))` because `/<0;1>/*` is implied if not derivation path not provided. + +2. **(recommended)** Use `unspend(` [notation](https://gist.github.com/sipa/06c5c844df155d4e5044c2c8cac9c05e#unspendable-keys). Has to be ranged. + + `tr(unspend(77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76)/<0:1>/*, sortedmulti_a(2,@0,@1))` + +3. use **static** provably unspendable internal key H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs). + + `tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0, sortedmulti_a(2,@0,@1))` + +4. use COLDCARD specific placeholder `@` to let HWW pick a fresh integer r in the range 0...n-1 uniformly at random and use `H + rG` as internal key. COLDCARD will not store r and therefore user is not able to prove to other party how the key was generated and whether it is actually unspendable. + + `tr(r=@, sortedmulti_a(MofN))` + +5. pick a fresh integer r in the range 0...n-1 uniformly at random yourself and provide that in the descriptor. COLDCARD generates internal key with `H + rG`. It is possible to prove to other party that this internal key does not have a known discrete logarithm with respect to G by revealing r to a verifier who can then reconstruct how the internal key was created. + + `tr(r=77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76, sortedmulti_a(2,@0,@1))` + +Option 3. leaks the information that key path spending is not possible and therefore is not recommended privacy-wise. +Options 4. and 5. are problematic to some extent as internal key is static. Use recommended options 1. and 2. if the fact that internal key is unspendable should remain private. + + +## Limitations + +### Tapscript Limitations + +In current version only `TREE` of max depth 4 is allowed (max 8 leaf script allowed). +Taproot single leaf multisig has artificial limit of max 32 signers (M=N=32). +Number of keys in taptree is limited to 32. + +If Coldcard can sign by both key path and script path - key path has precedence. + +### PSBT Requirements + +PSBT provider MUST provide following Taproot specific input fields in PSBT: +1. `PSBT_IN_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes. +2. `PSBT_IN_TAP_INTERNAL_KEY` MUST match internal key provided in `PSBT_IN_TAP_BIP32_DERIVATION` +3. `PSBT_IN_TAP_MERKLE_ROOT` MUST be empty if there is no script path. Otherwise it MUST match what Coldcard can calculate from registered descriptor. +4. `PSBT_IN_TAP_LEAF_SCRIPT` MUST be specified if there is a script path. Currently MUST be of length 1 (only one script allowed) + +PSBT provider MUST provide following Taproot specific output fields in PSBT: +1. `PSBT_OUT_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes. +2. `PSBT_OUT_TAP_INTERNAL_KEY` must match internal key provided in `PSBT_OUT_TAP_BIP32_DERIVATION` +3. `PSBT_OUT_TAP_TREE` with depth, leaf version and script defined. Currently only one script is allowed. \ No newline at end of file diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md new file mode 100644 index 000000000..001e221a8 --- /dev/null +++ b/releases/EdgeChangeLog.md @@ -0,0 +1,45 @@ +# Change Log + +## Warning: Edge Version + +```diff +- This preview version of firmware has not yet been qualified +- and tested to the same standard as normal Coinkite products. +- It is recommended only for developers and early adopters +- for experimental use. DO NOT use for large Bitcoin amounts. +``` + +This lists the changes in the most recent EDGE firmware, for each hardware platform. + +# Shared Improvements - Both Mk4 and Q + +- Bugfix: Complex miniscript wallets with keys in policy that are not in strictly ascending order were incorrectly filled + upon load from settings. All users on versions `6.2.2X`+ needs to update. +- Bugfix: Single key miniscript descriptor support +- Enhancement: Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. +- Enhancement: Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. +- Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode +- Bugfix: Bless Firmware causes hanging progress bar +- Bugfix: Prevent yikes in ownership search +- Change: Do not allow to purge settings of current active tmp seed when deleting it from Seed Vault + + +# Mk4 Specific Changes + +## 6.3.4X - 2024-07-04 + +- all updates from `5.4.0` +- Enhancement: Export single sig descriptor with simple QR + + +# Q Specific Changes + +## 6.3.4QX - 2024-07-04 + +- all updates from version `1.3.0Q` +- Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. + + +# Release History + +- [`History-Edge.md`](History-Edge.md) diff --git a/releases/History-Edge.md b/releases/History-Edge.md new file mode 100644 index 000000000..810f9c0f9 --- /dev/null +++ b/releases/History-Edge.md @@ -0,0 +1,73 @@ +## Warning: Edge Version + +```diff +- This preview version of firmware has not yet been qualified +- and tested to the same standard as normal Coinkite products. +- It is recommended only for developers and early adopters +- for experimental use. DO NOT use for large Bitcoin amounts. +``` + +## 6.3.3X & 6.3.3QX Shared Improvements - Both Mk4 and Q (2024-07-04) + +- New Feature: Ranged provably unspendable keys and `unspend(` support for Taproot descriptors +- New Feature: Address ownership for miniscript and tapscript wallets +- Enhancement: Address explorer simplified UI for tapscript addresses +- Bugfix: Constant `AFC_BECH32M` incorrectly set `AFC_WRAPPED` and `AFC_BECH32`. +- Bugfix: Trying to set custom URL for NFC push transaction caused yikes + +### Mk4 Specific Changes + +- Bugfix: Fix yikes displaying BIP-85 WIF when both NFC and VDisk are OFF +- Bugfix: Fix inability to export change addresses when both NFC and Vdisk id OFF +- Bugfix: In BIP-39 words menu, show space character rather than Nokia-style placeholder + which could be confused for an underscore. + +### Q Specific Changes + +- Enhancement: Miniscript and (BB)Qr codes +- Bugfix: Properly clear LCD screen after simple QR code is shown + + +## 6.2.2X - 2024-01-18 + +- New Feature: Miniscript [USB interface](https://github.com/Coldcard/ckcc-protocol/blob/master/README.md#miniscript) +- New Feature: Named miniscript imports. Wrap descriptor in json + `{"name:"n0", "desc":""}` with `name` key to use this name instead of the + filename. Mostly usefull for USB and NFC imports that have no file, in which case name + was created from descriptor checksum. +- Enhancement: Allow keys with same origin, differentiated only by change index derivation + in miniscript descriptor. +- Enhancement: HSM `wallet` rule enabled for miniscript +- Enhancement: Add `msas` in to the `share_addrs` HSM [rule](https://coldcard.com/docs/hsm/rules/) + to be able to check miniscript addresses in HSM mode. +- Enhancement: HW Accelerated AES CTR for BSMS and passphrase saver +- Bugfix: Do not allow to import duplicate miniscript + wallets (thanks to [AnchorWatch](https://www.anchorwatch.com/)) +- Bugfix: Saving passphrase on SD Card caused a freeze that required reboot + +## 6.2.1X - 2023-10-26 + +- New Feature: Enroll Miniscript wallet via USB (requires ckcc `v1.4.0`) +- New Feature: Temporary Seed from COLDCARD encrypted backup +- Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. + If current active temporary seed is not saved yet, `Add current tmp` menu item is + present in Seed Vault menu. +- Reorg: `12 Words` menu option preferred on the top of the menu in all the seed menus +- Enhancement: Mainnet/Testnet separation. Only show wallets for current active chain. +- contains all the changes from the newest stable `5.2.0-mk4` firmware + +## 6.1.0X - 2023-06-20 + +- New Feature: Miniscript and MiniTapscript support (`docs/miniscript.md`) +- Enhancement: Tapscript up to 8 leafs +- Address explorer display refined slightly (cosmetic) + +## 6.0.0X - 2023-05-12 + +- New Feature: Taproot keyspend & Tapscript multisig `sortedmulti_a` (tree depth = 0) +- New Feature: Support BIP-0129 Bitcoin Secure Multisig Setup (BSMS). + Both Coordinator and Signer roles are supported. +- Enhancement: change Key Origin Information export format in multisig `addresses.csv` according to [BIP-0380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions) + `(m=0F056943)/m/48'/1'/0'/2'/0/0` --> `[0F056943/48'/1'/0'/2'/0/0]` +- Bugfix: correct `scriptPubkey` parsing for segwit v1-v16 +- Bugfix: do not infer segwit just by availability of `PSBT_IN_WITNESS_UTXO` in PSBT \ No newline at end of file diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 50dcaa013..d70720c1d 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -4,19 +4,25 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk4 and Q -# Mk4 Specific Changes - -- tbd +- Enhancement: Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. +- Enhancement: Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. +- Bugfix: Sometimes see a struck screen after _Verifying..._ in boot up sequence. + On Q, result is blank screen, on Mk4, result is three-dots screen. +- Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode +- Bugfix: Bless Firmware causes hanging progress bar +- Bugfix: Prevent yikes in ownership search +- Change: Do not allow to purge settings of current active tmp seed when deleting it from Seed Vault -## 5.4.? - 2024-??-?? +# Mk4 Specific Changes -- tbd +## 5.4.1 - 2024-??-?? +- Enhancement: Export single sig descriptor with simple QR # Q Specific Changes -## 1.3.?Q - 2024-??-?? +## 1.3.1Q - 2024-??-?? - Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. diff --git a/shared/actions.py b/shared/actions.py index 5194b6048..84b6ca89b 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -16,7 +16,7 @@ from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export from export import generate_unchained_export, generate_electrum_wallet from files import CardSlot, CardMissingError, needs_microsd -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, MAX_TXN_LEN_MK4 +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR, MAX_TXN_LEN_MK4 from glob import settings from pincodes import pa from menu import start_chooser, MenuSystem, MenuItem @@ -872,6 +872,14 @@ async def start_login_sequence(): # is early in boot process print("XFP save failed: %s" % exc) + # Version warning before HSM is offered + if version.is_edge and not ckcc.is_simulator(): + await ux_show_story( + "This preview version of firmware has not yet been qualified and " + "tested to the same standard as normal Coinkite products." + "\n\nIt is recommended only for developers and early adopters for experimental use. " + "DO NOT use for large Bitcoin amounts.", title="Edge Version") + dis.draw_status(xfp=settings.get('xfp')) # If HSM policy file is available, offer to start that, @@ -889,6 +897,14 @@ async def start_login_sequence(): await ar.interact() except: pass + if pa.is_deltamode(): + # pretend Secure Notes & Passwords is disabled + # pretend SeedVault is disabled + try: + settings.remove_key("secnap") + settings.master_set("seedvault", False) + except: pass + if version.has_nfc and settings.get('nfc', 0): # Maybe allow NFC now import nfc @@ -1014,7 +1030,7 @@ async def export_xpub(label, _2, item): path = "m" addr_fmt = AF_CLASSIC else: - remap = {44:0, 49:1, 84:2}[mode] + remap = {44:0, 49:1, 84:2,86:3}[mode] _, path, addr_fmt = chains.CommonDerivations[remap] path = path.format(account='{acct}', coin_type=chain.b44_cointype, change=0, idx=0)[:-4] @@ -1095,7 +1111,7 @@ def ss_descriptor_export_story(addition="", background="", acct=True): async def ss_descriptor_skeleton(_0, _1, item): # Export of descriptor data (wallet) int_ext, addition, f_pattern = None, "", "descriptor.txt" - allowed_af = [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH] + allowed_af = [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR] if item.arg: int_ext, allowed_af, ll, f_pattern = item.arg addition = " for " + ll @@ -1392,7 +1408,7 @@ async def wipe_filesystem(*A): if not await ux_confirm('''\ Erase internal filesystem and rebuild it. Resets contents of internal flash area \ used for settings, address search cache, and HSM config file. Does not affect funds, \ -or seed words but will reset settings used with other BIP-39 passphrases. \ +or seed words but will reset settings used with other temporary seeds & BIP-39 passphrases. \ Does not affect MicroSD card, if any.'''): return @@ -1731,7 +1747,7 @@ async def bless_flash(*a): pa.greenlight_firmware() # redraw our screen - dis.show() + dis.busy_bar(False) # includes dis.show() def is_psbt(filename): diff --git a/shared/address_explorer.py b/shared/address_explorer.py index c672e062b..2e7658393 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -8,27 +8,17 @@ from ux import ux_show_story, the_ux, ux_enter_bip32_index from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem -from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from multisig import MultisigWallet +from miniscript import MiniScriptWallet from uasyncio import sleep_ms from uhashlib import sha256 -from ubinascii import hexlify as b2a_hex from glob import settings from auth import write_sig_file -from utils import addr_fmt_label, censor_address +from utils import addr_fmt_label, truncate_address from charcodes import KEY_QR, KEY_NFC, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_HOME, KEY_LEFT, KEY_RIGHT from charcodes import KEY_CANCEL -def truncate_address(addr): - # Truncates address to width of screen, replacing middle chars - if not version.has_qwerty: - # - 16 chars screen width - # - but 2 lost at left (menu arrow, corner arrow) - # - want to show not truncated on right side - return addr[0:6] + '⋯' + addr[-6:] - else: - # tons of space on Q1 - return addr[0:12] + '⋯' + addr[-12:] class KeypathMenu(MenuSystem): def __init__(self, path=None, nl=0): @@ -41,6 +31,7 @@ def __init__(self, path=None, nl=0): MenuItem("m/44h/⋯", f=self.deeper), MenuItem("m/49h/⋯", f=self.deeper), MenuItem("m/84h/⋯", f=self.deeper), + MenuItem("m/86h/⋯", f=self.deeper), MenuItem("m/0/{idx}", menu=self.done), MenuItem("m/{idx}", menu=self.done), MenuItem("m", f=self.done), @@ -67,7 +58,7 @@ def __init__(self, path=None, nl=0): pl = p[0:p.rfind('/')].rfind('/') else: self.prefix = p # displayed on mk4 only - pl = len(p)-2 + pl = len(p)-2 for mi in items: mi.arg = mi.label mi.label = '⋯'+mi.label[pl:] @@ -112,9 +103,8 @@ class PickAddrFmtMenu(MenuSystem): def __init__(self, path, parent): self.parent = parent items = [ - MenuItem(addr_fmt_label(AF_CLASSIC), f=self.done, arg=(path, AF_CLASSIC)), - MenuItem(addr_fmt_label(AF_P2WPKH), f=self.done, arg=(path, AF_P2WPKH)), - MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=self.done, arg=(path, AF_P2WPKH_P2SH)), + MenuItem(addr_fmt_label(af), f=self.done, arg=(path, af)) + for af in [AF_CLASSIC, AF_P2WPKH, AF_P2TR, AF_P2WPKH_P2SH] ] super().__init__(items) if path.startswith("m/84h"): @@ -213,7 +203,11 @@ async def render(self): # if they have MS wallets, add those next for ms in MultisigWallet.iter_wallets(): if not ms.addr_fmt: continue - items.append(MenuItem(ms.name, f=self.pick_multisig, arg=ms)) + items.append(MenuItem(ms.name, f=self.pick_miniscript, arg=ms)) + + # if they have miniscript wallets, add those next + for msc in MiniScriptWallet.iter_wallets(): + items.append(MenuItem(msc.name, f=self.pick_miniscript, arg=msc)) else: items.append(MenuItem("Account: %d" % self.account_num, f=self.change_account)) @@ -245,10 +239,10 @@ async def pick_single(self, _1, _2, item): settings.put('axi', axi) # update last clicked address await self.show_n_addresses(path, addr_fmt, None) - async def pick_multisig(self, _1, _2, item): - ms_wallet = item.arg - settings.put('axi', item.label) # update last clicked address - await self.show_n_addresses(None, None, ms_wallet) + async def pick_miniscript(self, _1, _2, item): + msc_wallet = item.arg + settings.put('axi', item.label) # update last clicked address + await self.show_n_addresses(None, msc_wallet.addr_fmt, msc_wallet) async def make_custom(self, *a): # picking a custom derivation path: makes a tree of menus, with chance @@ -280,7 +274,7 @@ async def show_n_addresses(self, path, addr_fmt, ms_wallet, start=0, n=10, allow start = self.start - def make_msg(change=0): + def make_msg(change=0, start=start, n=n): # Build message and CTA about export, plus the actual addresses. if n: msg = "Addresses %d⋯%d:\n\n" % (start, min(start + n - 1, MAX_BIP32_IDX)) @@ -293,22 +287,7 @@ def make_msg(change=0): dis.fullscreen('Wait...') if ms_wallet: - # IMPORTANT safety feature: never show complete address - # but show enough they can verify addrs shown elsewhere. - # - makes a redeem script - # - converts into addr - # - assumes 0/0 is first address. - for idx, addr, paths, script in ms_wallet.yield_addresses(start, n, change): - addrs.append(censor_address(addr)) - - if idx == 0 and ms_wallet.N <= 4: - msg += '\n'.join(paths) + '\n =>\n' - else: - msg += '⋯/%d/%d =>\n' % (change, idx) - - msg += truncate_address(addr) + '\n\n' - dis.progress_sofar(idx-start+1, n) - + msg, addrs = ms_wallet.make_addresses_msg(msg, start, n, change) else: # single-signer wallets from wallet import MasterSingleSigWallet @@ -325,10 +304,9 @@ def make_msg(change=0): # export options k0 = 'to show change addresses' if allow_change and change == 0 else None export_msg, escape = export_prompt_builder('address summary file', - no_qr=bool(ms_wallet), key0=k0, - force_prompt=True) + key0=k0, force_prompt=True) if version.has_qwerty: - escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN + escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN+KEY_QR else: escape += "79" @@ -342,8 +320,8 @@ def make_msg(change=0): return msg, addrs, escape - msg, addrs, escape = make_msg() change = 0 + msg, addrs, escape = make_msg(change, start) while 1: ch = await ux_show_story(msg, escape=escape) @@ -365,14 +343,9 @@ def make_msg(change=0): elif choice == KEY_QR: # switch into a mode that shows them as QR codes - if ms_wallet: - # requires not multisig - continue - from ux import show_qr_codes is_alnum = bool(addr_fmt & (AFC_BECH32 | AFC_BECH32M)) await show_qr_codes(addrs, is_alnum, start) - continue elif NFC and (choice == KEY_NFC): @@ -408,7 +381,7 @@ def make_msg(change=0): else: continue # 3 in non-NFC mode - msg, addrs, escape = make_msg(change) + msg, addrs, escape = make_msg(change, start) def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, change=0): # Produce CSV file contents as a generator @@ -416,28 +389,13 @@ def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, cha from ownership import OWNERSHIP if ms_wallet: - # For multisig, include redeem script and derivation for each signer - yield '"' + '","'.join(['Index', 'Payment Address', 'Redeem Script'] - + ['Derivation (%d of %d)' % (i+1, ms_wallet.N) for i in range(ms_wallet.N)] - ) + '"\n' - if (start == 0) and (n > 100) and change in (0, 1): saver = OWNERSHIP.saver(ms_wallet, change, start) else: saver = None - for (idx, addr, derivs, script) in ms_wallet.yield_addresses(start, n, change_idx=change): - if saver: - saver(addr) - - # policy choice: never provide a complete multisig address to user. - addr = censor_address(addr) - - ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode()) - ln += '","'.join(derivs) - ln += '"\n' - - yield ln + for line in ms_wallet.generate_address_csv(start, n, change): + yield line if saver: saver(None) # close file @@ -496,7 +454,7 @@ async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num, dis.progress_sofar(idx, count or 1) sig_nice = None - if not ms_wallet: + if not ms_wallet and addr_fmt != AF_P2TR: derive = path.format(account=account_num, change=change, idx=start) # first addr sig_nice = write_sig_file([(h.digest(), fname)], derive, addr_fmt) diff --git a/shared/auth.py b/shared/auth.py index 8bf24ad57..721f721f9 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -8,7 +8,7 @@ from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex from uhashlib import sha256 -from public_constants import MSG_SIGNING_MAX_LENGTH, SUPPORTED_ADDR_FORMATS +from public_constants import MSG_SIGNING_MAX_LENGTH, SUPPORTED_ADDR_FORMATS, AF_P2TR from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P2WPKH_P2SH from public_constants import STXN_FLAGS_MASK, STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED from sffile import SFFile @@ -310,6 +310,10 @@ def __init__(self, text, subpath, addr_fmt, approved_cb=None): self.addr_fmt = parse_addr_fmt_str(addr_fmt) self.approved_cb = approved_cb + # temporary - no p2tr support + if self.addr_fmt == AF_P2TR: + raise ValueError("Unsupported address format: 'p2tr'") + from glob import dis dis.fullscreen('Wait...') @@ -1431,7 +1435,7 @@ def setup(self, ms, addr_fmt, xfp_paths, witdeem_script): # calculate all the pubkeys involved. self.subpath_help = ms.validate_script(witdeem_script, xfp_paths=xfp_paths) - self.address = ms.chain.p2sh_address(addr_fmt, witdeem_script) + self.address = chains.current_chain().p2sh_address(addr_fmt, witdeem_script) def get_msg(self): return '''\ @@ -1447,6 +1451,41 @@ def get_msg(self): {sp}'''.format(addr=self.address, name=self.ms.name, M=self.ms.M, N=self.ms.N, sp='\n\n'.join(self.subpath_help)) + +class ShowMiniscriptAddress(ShowAddressBase): + + def setup(self, msc, change, idx): + self.msc = msc + self.change = change + self.idx = idx + + d = self.msc.desc.derive(None, change=change).derive(idx) + self.address = chains.current_chain().render_address(d.script_pubkey()) + self.addr_fmt = self.msc.addr_fmt + + def get_msg(self): + return '''\ +{addr} +Wallet: + {name} + +Index: + {idx} + +Change: + {change}'''.format(addr=self.address, name=self.msc.name, idx=self.idx, change=bool(self.change)) + + +def start_show_miniscript_address(msc, change, index): + UserAuthorizedAction.check_busy(ShowAddressBase) + UserAuthorizedAction.active_request = ShowMiniscriptAddress(msc, change, index) + + # kill any menu stack, and put our thing at the top + abort_and_goto(UserAuthorizedAction.active_request) + + # provide the value back to attached desktop + return UserAuthorizedAction.active_request.address + def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script): # Show P2SH address to user, also returns it. # - first need to find appropriate multisig wallet associated @@ -1505,40 +1544,77 @@ def usb_show_address(addr_format, subpath): return active_request.address -class NewEnrollRequest(UserAuthorizedAction): - def __init__(self, ms): +class MiniscriptDeleteRequest(UserAuthorizedAction): + def __init__(self, msc): super().__init__() - self.wallet = ms - # self.result ... will be re-serialized xpub + self.wallet = msc async def interact(self): - from multisig import MultisigOutOfSpace + from miniscript import miniscript_delete + await miniscript_delete(self.wallet) + self.done() + + +def maybe_delete_miniscript(msc): + UserAuthorizedAction.cleanup() + UserAuthorizedAction.active_request = MiniscriptDeleteRequest(msc) + + # kill any menu stack, and put our thing at the top + abort_and_goto(UserAuthorizedAction.active_request) + +class NewMiniscriptEnrollRequest(UserAuthorizedAction): + def __init__(self, msc, bsms_index=None): + super().__init__() + self.wallet = msc + self.bsms_index = bsms_index + + async def interact(self): + from wallet import WalletOutOfSpace ms = self.wallet try: ch = await ms.confirm_import() - - if ch != 'y': + if ch not in ('y'+KEY_ENTER): # they don't want to! self.refused = True await ux_dramatic_pause("Refused.", 2) - except MultisigOutOfSpace: + elif self.bsms_index is not None: + # remove signer round 2 from settings after multisig import is approved by user + from bsms import BSMSSettings + BSMSSettings.signer_delete(self.bsms_index) + + except WalletOutOfSpace: return await self.failure('No space left') except BaseException as exc: self.failed = "Exception" sys.print_exception(exc) finally: - UserAuthorizedAction.cleanup() # because no results to store - self.pop_menu() + UserAuthorizedAction.cleanup() # because no results to store + if self.bsms_index is not None: + # bsms special case, get him back to multisig menu + from ux import the_ux, restore_menu + from multisig import MultisigMenu + while 1: + top = the_ux.top_of_stack() + if not top: break + if not isinstance(top, MultisigMenu): + the_ux.pop() + continue + break + restore_menu() + else: + self.pop_menu() -def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False): - # Offer to import (enroll) a new multisig wallet. Allow reject by user. + +def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, miniscript=False): + # Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user. from glob import dis from multisig import MultisigWallet + from miniscript import MiniScriptWallet UserAuthorizedAction.cleanup() - dis.fullscreen('Wait...') # needed + dis.fullscreen('Wait...') dis.busy_bar(True) try: @@ -1560,9 +1636,19 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False): # this call will raise on parsing errors, so let them rise up # and be shown on screen/over usb - ms = MultisigWallet.from_file(config, name=name) + if miniscript is None: + # autodetect + try: + msc = MiniScriptWallet.from_file(config, name=name) + except AssertionError: + msc = MultisigWallet.from_file(config, name=name) - UserAuthorizedAction.active_request = NewEnrollRequest(ms) + elif miniscript: + msc = MiniScriptWallet.from_file(config, name=name) + else: + msc = MultisigWallet.from_file(config, name=name) + + UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index) if ux_reset: # for USB case, and import from PSBT @@ -1573,9 +1659,9 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False): from ux import the_ux the_ux.push(UserAuthorizedAction.active_request) finally: - # always finish busy bar dis.busy_bar(False) + class FirmwareUpgradeRequest(UserAuthorizedAction): def __init__(self, hdr, length, hdr_check=False, psram_offset=None): super().__init__() diff --git a/shared/backups.py b/shared/backups.py index f83a2b41c..f61b17091 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -280,7 +280,7 @@ async def restore_tmp_from_dict_ll(vals): if not k[:8] == "setting.": continue key = k[8:] - if key in ["multisig"]: + if key in ["multisig", "miniscript"]: # whitelist settings.set(k, v) diff --git a/shared/bsms.py b/shared/bsms.py new file mode 100644 index 000000000..298520efd --- /dev/null +++ b/shared/bsms.py @@ -0,0 +1,1092 @@ + +# (c) Copyright 2022 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# bsms.py - Bitcoin Secure Multisig Setup: BIP-129 +# +# For faster testing... +# ./simulator.py --seq 99y3y4y +# +import ngu, os, stash, chains, aes256ctr, version +from ubinascii import b2a_base64, a2b_base64 +from ubinascii import unhexlify as a2b_hex +from ubinascii import hexlify as b2a_hex + +from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_CLASSIC, MAX_SIGNERS +from utils import xfp2str, problem_file_line +from menu import MenuSystem, MenuItem +from files import CardSlot, CardMissingError, needs_microsd +from ux import ux_show_story, ux_enter_number, restore_menu, ux_input_numbers, ux_input_text +from ux import the_ux, _import_prompt_builder, export_prompt_builder +from descriptor import Descriptor, Key, append_checksum +from miniscript import Sortedmulti, Number +from charcodes import KEY_NFC, KEY_QR + + +BSMS_VERSION = "BSMS 1.0" +ALLOWED_PATH_RESTRICTIONS = "/0/*,/1/*" + +ENCRYPTION_TYPES = { + "1": "STANDARD", + "2": "EXTENDED", + "3": "NO ENCRYPTION" +} + +class RejectAutoCollection(BaseException): + pass + +class BSMSOutOfSpace(RuntimeError): + # should not be a concern on Mk4 and later; just in case, handle well. + pass + +def exceptions_handler(f): + nice_name = " ".join(f.__name__.split("_")).replace("bsms", "BSMS") + async def new_func(*args): + try: + await f(*args) + except BaseException as e: + await ux_show_story(title="FAILURE", msg='%s\n\n%s failed\n%s' % (e, nice_name, problem_file_line(e))) + return new_func + + +def normalize_token(token_hex): + if token_hex[:2] in ["0x", "0X"]: + token_hex = token_hex[2:] # remove 0x prefix + return token_hex + + +def validate_token(token_hex): + if token_hex == "00": + return + try: + int(token_hex, 16) + except: + raise ValueError("Invalid token: %s" % token_hex) + if len(token_hex) not in [16, 32]: + raise ValueError("Invalid token length. Expected 64 or 128 bits (16 or 32 hex characters)") + + +def key_derivation_function(token_hex): + if token_hex == "00": + return + return ngu.hash.pbkdf2_sha512("No SPOF", a2b_hex(token_hex), 2048)[:32] + + +def hmac_key(key): + return ngu.hash.sha256s(key) + + +def msg_auth_code(key, token_hex, data): + msg_str = token_hex + data + msg_bytes = bytes(msg_str, "utf-8") + return ngu.hmac.hmac_sha256(key, msg_bytes) + + +def bsms_decrypt(key, data_bytes): + mac, ciphertext = data_bytes[:32], data_bytes[32:] + iv = mac[:16] + decrypt = aes256ctr.new(key, iv) + decrypted = decrypt.cipher(ciphertext) + try: + plaintext = decrypted.decode() + if not plaintext.startswith("BSMS"): + raise ValueError + return plaintext + except: + # failed decryption + return "" + + +def bsms_encrypt(key, token_hex, data_str): + hmac_k = hmac_key(key) + mac = msg_auth_code(hmac_k, token_hex, data_str) + iv = mac[:16] + encrypt = aes256ctr.new(key, iv) + ciphertext = encrypt.cipher(data_str) + + return mac + ciphertext + + +def signer_data_round1(token_hex, desc_type_key, key_description, sig_bytes=None): + result = "%s\n" % BSMS_VERSION + result += "%s\n" % token_hex + result += "%s\n" % desc_type_key + result += "%s" % key_description + + if sig_bytes: + sig = b2a_base64(sig_bytes).decode().strip() + result += "\n" + sig + + return result + + +def coordinator_data_round2(desc_template, addr, path_restrictions=ALLOWED_PATH_RESTRICTIONS): + result = "%s\n" % BSMS_VERSION + result += "%s\n" % desc_template + result += "%s\n" % path_restrictions + result += "%s" % addr + + return result + + +def token_summary(tokens): + if len(tokens) == 1: + return tokens[0] + + numbered_tokens = ["%d. %s" % (i, token) for i, token in enumerate(tokens, start=1)] + return "\n\n".join(numbered_tokens) + + +def coordinator_summary(M, N, addr_fmt, et, tokens): + addr_fmt_str = "p2wsh" if addr_fmt == AF_P2WSH else "p2sh-p2wsh" + summary = "%d of %d\n\n" % (M, N) + summary += "Address format:\n%s\n\n" % addr_fmt_str + summary += "Encryption type:\n%s\n\n" % ENCRYPTION_TYPES[et] + + if tokens: + summary += "Tokens:\n" + token_summary(tokens) + "\n\n" + + return summary + + +class BSMSSettings: + # keys in settings object + BSMS_SETTINGS = "bsms" + BSMS_SIGNER_SETTINGS = "s" + BSMS_COORD_SETTINGS = "c" + + @classmethod + def save(cls, updated_settings, orig): + try: + updated_settings.save() + except: + # back out change; no longer sure of NVRAM state + try: + updated_settings.set(cls.BSMS_SETTINGS, orig) + updated_settings.save() + except: + pass # give up on recovery + raise BSMSOutOfSpace + + @classmethod + def add(cls, who, value): + from glob import settings + + settings_bsms = settings.get(cls.BSMS_SETTINGS, {}) + orig = settings_bsms.copy() + if who in settings_bsms: + settings_bsms[who].append(value) + else: + settings_bsms[who] = [value] + + settings.set(cls.BSMS_SETTINGS, settings_bsms) + cls.save(settings, orig) + + @classmethod + def delete(cls, who, index): + from glob import settings + + settings_bsms = settings.get(cls.BSMS_SETTINGS, {}) + orig = settings_bsms.copy() + if who in settings_bsms: + try: + settings_bsms[who].pop(index) + settings.set(cls.BSMS_SETTINGS, settings_bsms) + cls.save(settings, orig) + except IndexError: + pass + + @classmethod + def signer_add(cls, token_hex): + cls.add(cls.BSMS_SIGNER_SETTINGS, token_hex) + + @classmethod + def coordinator_add(cls, config_tuple): + cls.add(cls.BSMS_COORD_SETTINGS, config_tuple) + + @classmethod + def signer_delete(cls, index): + cls.delete(cls.BSMS_SIGNER_SETTINGS, index) + + @classmethod + def coordinator_delete(cls, index): + cls.delete(cls.BSMS_COORD_SETTINGS, index) + + @classmethod + def get(cls): + from glob import settings + return settings.get(cls.BSMS_SETTINGS, {}) + + @classmethod + def get_signers(cls): + bsms = cls.get() + return bsms.get(cls.BSMS_SIGNER_SETTINGS, []) + + @classmethod + def get_coordinators(cls): + bsms = cls.get() + return bsms.get(cls.BSMS_COORD_SETTINGS, []) + + +class BSMSMenu(MenuSystem): + @classmethod + def construct(cls): + raise NotImplementedError + + def update_contents(self): + tmp = self.construct() + self.replace_items(tmp) + + +async def user_delete_signer_settings(menu, label, item): + index = item.arg + BSMSSettings.signer_delete(index) + the_ux.pop() + restore_menu() + +async def bsms_signer_detail(menu, label, item): + token_hex = BSMSSettings.get_signers()[item.arg] + # shoulf not raise here, as token is only saved if properly validated + token_dec = str(int(token_hex, 16)) + await ux_show_story("Token HEX:\n%s\n\nToken decimal:\n%s" % (token_hex, token_dec)) + + +async def bsms_coordinator_detail(menu, label, item): + M, N, addr_fmt, et, tokens = BSMSSettings.get_coordinators()[item.arg] + summary = coordinator_summary(M, N, addr_fmt, et, tokens) + await ux_show_story(title="SUMMARY", msg=summary) + + +async def make_bsms_signer_r2_menu(menu, label, item): + index = item.arg + rv = [ + MenuItem('Round 2', f=bsms_signer_round2, arg=index), + MenuItem('Detail', f=bsms_signer_detail, arg=index), + MenuItem('Delete', f=user_delete_signer_settings, arg=index), + ] + return rv + + +class BSMSSignerMenu(BSMSMenu): + @classmethod + def construct(cls): + # Dynamic + rv = [] + signers = BSMSSettings.get_signers() + if signers: + for i, token_hex in enumerate(signers): + label = "%d %s" % (i+1, token_hex[:4]) + rv.append(MenuItem('%s' % label, menu=make_bsms_signer_r2_menu, arg=i)) + rv.append(MenuItem('Round 1', f=bsms_signer_round1)) + + return rv + + +async def user_delete_coordinator_settings(menu, label, item): + index = item.arg + BSMSSettings.coordinator_delete(index) + the_ux.pop() + restore_menu() + + +async def make_bsms_coord_r2_menu(menu, label, item): + index = item.arg + rv = [ + MenuItem('Round 2', f=bsms_coordinator_round2, arg=index), + MenuItem('Detail', f=bsms_coordinator_detail, arg=index), + MenuItem('Delete', f=user_delete_coordinator_settings, arg=index), + ] + return rv + + +class BSMSCoordinatorMenu(BSMSMenu): + @classmethod + def construct(cls): + # Dynamic + rv = [] + coordinators = BSMSSettings.get_coordinators() + if coordinators: + for i, (M, N, addr_fmt, et, tokens) in enumerate(coordinators): + # only p2wsh and p2sh-p2wsh are allowed + if addr_fmt == AF_P2WSH: + af_str = "native" + else: + af_str = "nested" + label = "%d %dof%d_%s_%s" % (i+1, M, N, af_str, et) + rv.append(MenuItem('%s' % label, menu=make_bsms_coord_r2_menu, arg=i)) + rv.append(MenuItem('Create BSMS', f=bsms_coordinator_start)) + + return rv + + +async def make_ms_wallet_bsms_menu(menu, label, item): + from pincodes import pa + + if pa.is_secret_blank(): + await ux_show_story("You must have wallet seed before creating multisig wallets.") + return + + await ux_show_story( +"Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets. " +"On the next screen you choose your role in this process.\n\n" +"WARNING: BSMS is an EXPERIMENTAL and BETA feature which requires supporting implementations " +"on other signing devices to work properly. Please test the final wallet carefully " +"and report any problems to appropriate vendor. Deposit only small test amounts and verify " +"all co-signers can sign transactions before use.") + rv = [ + MenuItem('Signer', menu=make_bsms_signer_menu), + MenuItem('Coordinator', menu=make_bsms_coordinator_menu), + ] + return rv + + +async def make_bsms_signer_menu(menu, label, item): + rv = BSMSSignerMenu.construct() + return BSMSSignerMenu(rv) + + +async def make_bsms_coordinator_menu(menu, label, item): + rv = BSMSCoordinatorMenu.construct() + return BSMSCoordinatorMenu(rv) + + +async def decrypt_nfc_data(key, data): + try: + data_bytes = a2b_hex(data) + data = bsms_decrypt(key, data_bytes) + return data + except: + # will be offered another chance + return + +@exceptions_handler +async def bsms_coordinator_start(*a): + from glob import NFC, dis, settings + xfp = xfp2str(settings.get('xfp', 0)) + # M/N + N = await ux_enter_number('No. of signers?(N)', 15) + assert 2 <= N <= MAX_SIGNERS, "Number of co-signers must be 2-15" + + M = await ux_enter_number("Threshold? (M)", 15) + assert 1 <= M <= N, "M cannot be bigger than N (N=%d)" % N + + ch = await ux_show_story("Default address format is P2WSH.\n\n" + "Press (2) for P2SH-P2WSH instead.", escape='2') + if ch == 'y': + addr_fmt = AF_P2WSH + elif ch == '2': + addr_fmt = AF_P2WSH_P2SH + else: + return + + while 1: + encryption_type = await ux_show_story( + "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED," + " and (3) for no encryption", escape="123") + + if encryption_type == 'x': return + if encryption_type in "123": + break + + tokens = [] + if encryption_type == "2": + dis.fullscreen('Generating...') + for i in range(N): # each signer different 16 bytes (128bits) nonce/token + tokens.append(b2a_hex(ngu.random.bytes(16)).decode()) + dis.progress_bar_show(i / N) + elif encryption_type == "1": + tokens.append(b2a_hex(ngu.random.bytes(8)).decode()) # all signers same token + + summary = coordinator_summary(M, N, addr_fmt, encryption_type, tokens) + summary += "Press OK to continue, or X to cancel" + ch = await ux_show_story(title="SUMMARY", msg=summary) + if ch != "y": + return + + token_hex = "00" if not tokens else tokens[0] + ch = await ux_show_story("Press (1) to participate as co-signer in this BSMS " + "with current active key [%s] and token '%s'. " + "Press OK to continue normally." % (xfp, token_hex), escape="1") + export_tokens = tokens[:] + if ch == "1": + b4 = len(BSMSSettings.get_signers()) + await bsms_signer_round1(token_hex) + current = BSMSSettings.get_signers() + if len(current) > b4 and token_hex in current: + if encryption_type == "2": + # remove 0th token from the list as we already used that for self + # we do not need this token for export, but still need to store it in settings + export_tokens = tokens[1:] + + force_vdisk = False + title = "BSMS token file(s)" + prompt, escape = export_prompt_builder(title) + if tokens and prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == (KEY_NFC if version.has_qwerty else '3') and tokens: + force_vdisk = None + await NFC.share_text(token_summary(export_tokens)) + elif ch == "2": + force_vdisk = True + elif ch == '1': + force_vdisk = False + else: + return + + msg = "Success. Coordinator round 1 saved." + if tokens and force_vdisk is not None: + dis.fullscreen("Saving...") + f_pattern = "bsms" + f_names = [] + try: + with CardSlot(force_vdisk=force_vdisk) as card: + for i, token in enumerate(export_tokens, start=1): + f_name = "%s_%s.token" % (f_pattern, token[:4]) + fname, nice = card.pick_filename(f_name) + with open(fname, 'wt') as fd: + fd.write(token) + f_names.append(nice) + dis.progress_bar_show(i / len(tokens)) + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n\n' + str(e)) + return + msg = '''%s written.\n\nFiles:\n\n%s''' % (title, "\n\n".join(f_names)) + + BSMSSettings.coordinator_add((M, N, addr_fmt, encryption_type, tokens)) + await ux_show_story(msg) + restore_menu() + + +async def nfc_import_signer_round1_data(N, tkm, et, get_token_func): + from glob import NFC + + all_data = [] + for i in range(N): + token = get_token_func(i) + for attempt in range(2): + prompt = "Share co-signer #%d round-1 data" % (i + 1) + if et == "2": + prompt += " for token starting with %s" % token[:4] + ch = await ux_show_story(prompt) + if ch != "y": + return + + data = await NFC.read_bsms_data() + if et in "12": + encryption_key = key_derivation_function(token) + data = await decrypt_nfc_data(encryption_key, data) + if not data: + fail_msg = "Decryption failed for co-signer #%d" % (i + 1) + if et == "2": + fail_msg += " with token %s" % token[:4] + ch = await ux_show_story( + title="FAILURE", + msg=fail_msg + ". Try again?" if attempt == 0 else fail_msg) # second chance + if ch == "y" and attempt == 0: + continue + else: + return + tkm[token] = encryption_key + + all_data.append(data) + break # exit "second chance" loop + return all_data + +@exceptions_handler +async def bsms_coordinator_round2(menu, label, item): + import version as version_mod + from glob import NFC, dis + from actions import file_picker + from multisig import make_redeem_script + + bsms_settings_index = item.arg + chain = chains.current_chain() + + force_vdisk = False + + # this can be RAM intensive (max 15 F mapped to keys) + # => ((32 + 16) * 15) roughly (actually more with python overhead) + token_key_map = {} + + # choose correct values based on label (index in coordinator bsms settings) + M, N, addr_fmt, et, tokens = BSMSSettings.get_coordinators()[bsms_settings_index] + + def get_token(index): + if len(tokens) == 1 and et == "1": + token = tokens[0] + elif len(tokens) == N and et == "2": + token = tokens[index] + else: + token = "00" + return token + + is_encrypted = et in "12" and tokens + suffix = ".dat" if is_encrypted else ".txt" + mode = "rb" if is_encrypted else "rt" + prompt, escape = _import_prompt_builder("co-signer round 1 files", False, False) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == (KEY_NFC if version_mod.has_qwerty else '3'): + force_vdisk = None + r1_data = await nfc_import_signer_round1_data(N, token_key_map, et, get_token) + else: + if ch == "1": + force_vdisk = False + else: + force_vdisk = True + + if force_vdisk is not None: + # auto-collection attempt + r1_data = [] + try: + f_pattern = "bsms_sr1" + auto_msg = "Press OK to pick co-signer round 1 files manually, or press (1) to attempt auto-collection." + auto_msg += " For auto-collection to succeed all filenames have to start with '%s'" % f_pattern + auto_msg += " and end with extension '%s'." % suffix + if et == "2": # EXTENDED + auto_msg += (" In addition for EXTENDED encryption all files must contain first four characters of" + " respective token. For example '%s_af9f%s'." % (f_pattern, suffix)) + elif et == "3": # NO_ENCRYPTION + auto_msg += (" In addition for NO ENCRYPTION cases, number of files with above mentioned" + " pattern and suffix must equal number of signers (N).") + auto_msg += " If above is not respected auto-collection fails and defaults to manual selection of files." + ch = await ux_show_story(auto_msg, escape="1") + if ch == "x": return # exit + if ch == "y": raise RejectAutoCollection + # try autodiscovery first - if failed - default to manual input + dis.fullscreen("Collecting...") + file_names = [] + with CardSlot(force_vdisk=force_vdisk) as card: + f_list = os.listdir(card.mountpt) + f_list_len = len(f_list) + for i, name in enumerate(f_list, start=1): + if not card.is_dir(name) and f_pattern in name and name.endswith(suffix): + file_names.append(name) + dis.progress_bar_show(i / f_list_len) + file_names_len = len(file_names) + dis.fullscreen("Validating...") + if et == "1": + # can have multiple of these files - we will try to decrypt all that + # have above pattern. Those that fail will be ignored and at the end + # we check if we have correct num of files (num==N) + token = get_token(0) # STANDARD encryption has just one token + encryption_key = key_derivation_function(token) + token_key_map[token] = encryption_key + + with CardSlot(force_vdisk=force_vdisk) as card: + for i, fname in enumerate(file_names, start=1): + with open(card.abs_path(fname), mode) as f: + data = f.read() + data = bsms_decrypt(encryption_key, data) + if not data: + continue + + assert data.startswith("BSMS"), "Failure - not BSMS file?" + r1_data.append(data) + dis.progress_bar_show(i / file_names_len) + + elif et == "2": + with CardSlot(force_vdisk=force_vdisk) as card: + for i in range(N): + token = get_token(i) + for fname in file_names: + if token[:4] in fname: + with open(card.abs_path(fname), mode) as f: + data = f.read() + encryption_key = key_derivation_function(token) + data = bsms_decrypt(encryption_key, data) + + assert data, "Failed to decrypt %s with token %s" % (fname, token) + assert data.startswith("BSMS"), "Failure - not BSMS file?" + token_key_map[token] = encryption_key + r1_data.append(data) + + break + else: + assert False, "haven't find file for token %s" % token + + dis.progress_bar_show(i / N) + else: + assert file_names_len == N, "Need same number of files (%d) as co-signers(N=%d)"\ + % (file_names_len, N) + + with CardSlot(force_vdisk=force_vdisk) as card: + for i, fname in enumerate(file_names, start=1): + with open(card.abs_path(fname), mode) as f: + data = f.read() + assert data.startswith("BSMS"), "Failure - not BSMS file?" + r1_data.append(data) + dis.progress_bar_show(i / file_names_len) + + assert len(r1_data) == N, "No. of signer round 1 data auto-collected "\ + "does not equal number of signers (N)" + except BaseException as e: + if isinstance(e, RejectAutoCollection): + # raised when user manually chooses not to use auto-collection + msg_prefix = "" + else: + msg_prefix = "Auto-collection failed. Defaulting to manual selection of files. " + + # iterate over N and prompt user to choose correct files + for i in range(N): + token = get_token(i) + f_pick_msg = msg_prefix + f_pick_msg += 'Select co-signer #%d file containing round 1 data' % (i + 1) + if et == "2": + f_pick_msg += " for token starting with %s" % token[:4] + f_pick_msg += '. File extension has to be "%s"' % suffix + for attempt in range(2): # two chances to succeed + await ux_show_story(f_pick_msg) + fn = await file_picker(suffix=suffix, min_size=220, max_size=500, + force_vdisk=force_vdisk) + if not fn: return + + dis.fullscreen("Wait...") + with CardSlot(force_vdisk=force_vdisk) as card: + dis.progress_bar_show(0.1) + with open(fn, mode) as fd: + data = fd.read() + dis.progress_bar_show(0.3) + if is_encrypted: + encryption_key = key_derivation_function(token) + dis.progress_bar_show(0.6) + data = bsms_decrypt(encryption_key, data) + if not data: + fail_msg = "Decryption failed for co-signer #%d" % (i + 1) + if et == "2": + fail_msg += " with token %s" % token[:4] + ch = await ux_show_story(title="FAILURE", msg=fail_msg + + (" Try again?" if attempt == 0 else fail_msg)) + + if ch == "y" and attempt == 0: + continue + else: + return + + dis.progress_bar_show(0.9) + token_key_map[token] = encryption_key + + r1_data.append(data) + dis.progress_bar_show(1) + + break # break from "second chance loop" + + if not r1_data: + return + + keys = [] + dis.fullscreen("Validating...") + for i, data in enumerate(r1_data): + # divided in the loop with number of in-loop occurences of 'dis.progress_bar_show' (currently 5) + i_div_N = (i+1) / N + token = get_token(i) + assert data.startswith(BSMS_VERSION), "Incompatible BSMS version. Need %s got %s" % ( + BSMS_VERSION, data[:9] + ) + version, tok, key_exp, description, sig = data.strip().split("\n") + assert tok == token, "Token mismatch saved %s, received from signer %s" % (token, tok) + key = Key.from_string(key_exp) + dis.progress_bar_show(i_div_N / 4) + msg = signer_data_round1(token, key_exp, description) + digest = chain.hash_message(msg.encode()) + dis.progress_bar_show(i_div_N / 3) + _, recovered_pk = chains.verify_recover_pubkey(a2b_base64(sig), digest) + assert key.node.pubkey() == recovered_pk, "Recovered key from signature does not equal key provided. Wrong signature?" + dis.progress_bar_show(i_div_N / 2) + keys.append(key) + dis.progress_bar_show(i_div_N / 1) + + dis.fullscreen("Generating...") + miniscript = Sortedmulti(Number(M), *keys) + desc_obj = Descriptor(miniscript=miniscript) + desc_obj.set_from_addr_fmt(addr_fmt) + desc = desc_obj.to_string(checksum=False) + desc = desc.replace("<0;1>/*", "**") + if not is_encrypted: + # append checksum for unencrypted BSMS + desc = append_checksum(desc) + for i, ko in enumerate(keys): + ko.node.derive(0, False) # external is always first our coordinating "0/*,1/*" + dis.progress_bar_show(i / N) + + # TODO this can be done with .script_pubkey + script = make_redeem_script(M, [k.node for k in keys], 0) # first address + addr = chain.p2sh_address(addr_fmt, script) + # == + r2_data = coordinator_data_round2(desc, addr) + dis.progress_bar_show(1) + + force_vdisk = False + title = "BSMS descriptor template file(s)" + prompt, escape = export_prompt_builder(title) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == (KEY_NFC if version_mod.has_qwerty else '3'): + if et == "2": + for i, token in enumerate(tokens): + ch = await ux_show_story("Exporting data for co-signer #%d with token %s" + % (i+1, token[:4])) + if ch != "y": + return + data = bsms_encrypt(token_key_map[token], token, r2_data) + await NFC.share_text(b2a_hex(data).decode()) + elif et == "1": + token = get_token(0) + data = bsms_encrypt(token_key_map[token], token, r2_data) + await NFC.share_text(b2a_hex(data).decode()) + else: + await NFC.share_text(r2_data) + await ux_show_story("All done.") + return + elif ch == "2": + force_vdisk = True + elif ch == '1': + force_vdisk = False + else: + return + + def to_export_generator(): + # save memory + if et == "3": # NO_ENCRYPTION + yield None, r2_data + elif et == "1": # STANDARD + token = get_token(0) + yield token, bsms_encrypt(token_key_map[token], token, r2_data) + else: + # EXTENDED + for token in tokens: + yield token, bsms_encrypt(token_key_map[token], token, r2_data) + + dis.fullscreen("Saving...") + mode = "wb" if is_encrypted else "wt" + f_pattern = "bsms_cr2" + f_names = [] + try: + with CardSlot(force_vdisk=force_vdisk) as card: + for i, (token, data) in enumerate(to_export_generator(), start=1): + f_name = "%s%s%s" % (f_pattern, "_" + token[:4] if et == "2" else "", suffix) + fname, nice = card.pick_filename(f_name) + with open(fname, mode) as fd: + fd.write(data) + f_names.append(nice) + dis.progress_bar_show(i / (len(token_key_map) or 1)) + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n\n' + str(e)) + return + msg = '''%s written. Files:\n\n%s''' % (title, "\n\n".join(f_names)) + await ux_show_story(msg) + + +@exceptions_handler +async def bsms_signer_round1(*a): + from glob import dis, NFC, VD, settings + + shortcut = len(a) == 1 + token_int = None + if not shortcut: + prompt = "Press (1) to import token file from SD Card, (2) to input token manually" + prompt += ", (3) for unencrypted BSMS." + escape = "123" + if version.has_qwerty: + prompt += "%s to scan QR. " % KEY_QR + escape += KEY_QR + if NFC is not None: + prompt += " %s to import via NFC" % (KEY_NFC if version.has_qwerty else "(4)") + escape += KEY_NFC if version.has_qwerty else "4" + if VD is not None: + prompt += ", (6) to import from Virtual Disk" + escape += "6" + prompt += "." + + ch = await ux_show_story(prompt, escape=escape) + + if ch == '3': + token_hex = "00" + elif ch in "4"+KEY_NFC: + token_hex = await NFC.read_bsms_token() + elif ch == "2": + prompt = "To input token as hex press (1), as decimal press (2)" + escape = "12" + ch = await ux_show_story(prompt, escape=escape) + if ch == "1": + token_hex = await ux_input_text("", hex_only=True, scan_ok=True, + prompt="Hex Token") + elif ch == "2": + if version.has_qwerty: + token_int = await ux_input_text("", scan_ok=True, prompt="Decimal Token") + else: + token_int = await ux_input_numbers("", lambda: True) + token_hex = hex(int(token_int)) + else: + return + elif ch in "16": + from actions import file_picker + force_vdisk = (ch == '6') + + # pick a likely-looking file. + fn = await file_picker(suffix=".token", min_size=15, max_size=35, + force_vdisk=force_vdisk) + if not fn: return + + with CardSlot(force_vdisk=force_vdisk) as card: + with open(fn, 'rt') as fd: + token_hex = fd.read().strip() + else: + return + else: + token_hex = a[0] + + # will raise, exc catched in decorator, FAILURE msg provided + validate_token(token_hex) + token_hex = normalize_token(token_hex) + is_extended = (len(token_hex) == 32) + entered_msg = "%s\n\nhex:\n%s" % (token_int, token_hex) if token_int else token_hex + + if not shortcut: + ch = await ux_show_story("You have entered token:\n" + entered_msg + "\n\nIs token correct?") + if ch != "y": + return + + xfp = xfp2str(settings.get('xfp', 0)) + chain = chains.current_chain() + ch = await ux_show_story( +"Choose co-signer address format for correct SLIP derivation path. Default is 'unknown' as this " +"information may not be known at this point in BSMS. SLIP agnostic path will be chosen. " +"Press (1) for P2WSH. Press (2) for P2SH-P2WSH. " +"Correct SLIP path is completely unnecessary as descriptors (BIP-0380) are used.", + escape='12') + if ch == 'y': + pth_template = "m/129'/{coin}'/{acct_num}'" + af_str = "" + elif ch == '1': + pth_template = "m/48'/{coin}'/{acct_num}'/2'" + af_str = " P2WSH" + elif ch == '2': + pth_template = "m/48'/{coin}'/{acct_num}'/1'" + af_str = " P2SH-P2WSH" + else: + return + + acct_num = await ux_enter_number('Account Number:', 9999) or 0 + + # textual key description + key_description = "Coldcard signer%s account %d" % (af_str, acct_num) + ch = await ux_show_story( +"Choose key description. To continue with default, generated description: '%s' press OK." +"\n\nPress (1) for custom key description." % key_description, escape="1") + + if ch == "1": + key_description = await ux_input_text("", confirm_exit=False) or "" + + key_description_len = len(key_description) + assert key_description_len <= 80, "Key Description: 80 char max (was %d)" % key_description_len + + dis.fullscreen("Wait...") + + with stash.SensitiveValues() as sv: + dis.progress_bar_show(0.1) + + dd = pth_template.format(coin=chain.b44_cointype, acct_num=acct_num) + node = sv.derive_path(dd) + ext_key = chain.serialize_public(node) + + dis.progress_bar_show(0.25) + + desc_type_key = "[%s%s]%s" % (xfp, dd[1:], ext_key) + msg = signer_data_round1(token_hex, desc_type_key, key_description) + digest = chain.hash_message(msg.encode()) + sk = node.privkey() + sv.register(sk) + + dis.progress_bar_show(0.5) + + sig = ngu.secp256k1.sign(sk, digest, 0).to_bytes() + result_data = signer_data_round1(token_hex, desc_type_key, key_description, sig_bytes=sig) + + dis.progress_bar_show(.75) + + encryption_key = key_derivation_function(token_hex) + if encryption_key: + result_data = bsms_encrypt(encryption_key, token_hex, result_data) + + dis.progress_bar_show(1) + + # export round 1 file + force_vdisk = False + title = "BSMS signer round 1 file" + prompt, escape = export_prompt_builder(title) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == (KEY_NFC if version.has_qwerty else '3'): + force_vdisk = None + if isinstance(result_data, bytes): + result_data = b2a_hex(result_data).decode() + await NFC.share_text(result_data) + elif ch == "2": + force_vdisk = True + elif ch == '1': + force_vdisk = False + else: + return + + msg = "Success. Signer round 1 saved." + if force_vdisk is not None: + basename = "bsms_sr1%s" % "_" + token_hex[:4] if is_extended else "bsms_sr1" + f_pattern = basename + ".txt" if encryption_key is None else basename + ".dat" + # choose a filename + try: + with CardSlot(force_vdisk=force_vdisk) as card: + fname, nice = card.pick_filename(f_pattern) + with open(fname, 'wb') as fd: + if isinstance(result_data, str): + result_data = result_data.encode() + fd.write(result_data) + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n\n' + str(e)) + return + msg = '''%s written:\n\n%s''' % (title, nice) + BSMSSettings.signer_add(token_hex) + await ux_show_story(msg) + if not shortcut: + restore_menu() + + +@exceptions_handler +async def bsms_signer_round2(menu, label, item): + import version + from glob import NFC, dis, settings + from actions import file_picker + from auth import maybe_enroll_xpub + from multisig import make_redeem_script + + chain = chains.current_chain() + + # or xpub or tpub as we use descriptors (no SLIP132 allowed) + ext_key_prefix = "%spub" % chain.slip132[AF_CLASSIC].hint + force_vdisk = False + + # choose correct values based on label (index in signer bsms settings) + bsms_settings_index = item.arg + token = BSMSSettings.get_signers()[bsms_settings_index] + + decrypt_fail_msg = "Decryption with token %s failed." % token[:4] + is_encrypted = False if token == "00" else True + suffix = ".dat" if is_encrypted else ".txt" + mode = "rb" if is_encrypted else "rt" + + prompt, escape = _import_prompt_builder("descriptor template file", False, False) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + + if ch == (KEY_NFC if version.has_qwerty else '3'): + force_vdisk = None + desc_template_data = await NFC.read_bsms_data() + + if desc_template_data is None: + return + + if is_encrypted: + data_bytes = a2b_hex(desc_template_data) + encryption_key = key_derivation_function(token) + desc_template_data = bsms_decrypt(encryption_key, data_bytes) + assert desc_template_data, decrypt_fail_msg + else: + if ch == "1": + force_vdisk = False + else: + force_vdisk = True + + if force_vdisk is not None: + fn = await file_picker(suffix=suffix, min_size=200, max_size=10000, + force_vdisk=force_vdisk) + if not fn: return + + with CardSlot(force_vdisk=force_vdisk) as card: + with open(fn, mode) as fd: + desc_template_data = fd.read() + if is_encrypted: + encryption_key = key_derivation_function(token) + desc_template_data = bsms_decrypt(encryption_key, desc_template_data) + assert desc_template_data, decrypt_fail_msg + + dis.fullscreen("Validating...") + assert desc_template_data.startswith(BSMS_VERSION), \ + "Incompatible BSMS version. Need %s got %s" % (BSMS_VERSION, desc_template_data[:9]) + + dis.progress_bar_show(0.05) + version, desc_template, pth_restrictions, addr = desc_template_data.split("\n") + assert pth_restrictions == ALLOWED_PATH_RESTRICTIONS, \ + "Only '%s' allowed as path restrictions. Got %s" % ( + ALLOWED_PATH_RESTRICTIONS, pth_restrictions) + + # if checksum is provided we better verify it + # remove checksum as we need to replace /** + desc_template, csum = Descriptor.checksum_check(desc_template) + desc = desc_template.replace("/**", "/0/*") + + dis.progress_bar_show(0.1) + desc = append_checksum(desc) + + ms_name = "bsms_" + desc[-4:] + + desc_obj = Descriptor.from_string(desc) + assert desc_obj.is_sortedmulti, "sortedmulti required" + + dis.progress_bar_show(0.2) + + my_xfp = settings.get('xfp') + my_keys = [] + nodes = [] + progress_counter = 0.2 # last displayed progress + # (desired value after loop - last displayed progress) / N + progress_chunk = (0.5 - progress_counter) / len(desc_obj.miniscript.keys) + for key in desc_obj.keys: + if key.origin.cc_fp == my_xfp: + my_keys.append(key) + nodes.append(key.node) + progress_counter += progress_chunk + dis.progress_bar_show(progress_counter) + + num_my_keys = len(my_keys) + assert num_my_keys <= 1, "Multiple %s keys in descriptor (%d)" % (xfp2str(my_xfp), num_my_keys) + assert num_my_keys == 1, "My key %s missing in descriptor." % xfp2str(my_xfp) + + with stash.SensitiveValues() as sv: + node = sv.derive_path(my_keys[0].origin.str_derivation()) + ext_key = chain.serialize_public(node) + assert ext_key == my_keys[0].extended_public_key(), "My key %s missing in descriptor." % ext_key + + dis.progress_bar_show(0.55) + + # check address is correct + progress_counter = 0.55 # last displayed progress + # (desired value after loop - last displayed progress) / N + M, N = desc_obj.miniscript.m_n() + progress_chunk = (0.9 - progress_counter) / N + for node in nodes: + node.derive(0, False) # external is always first in our allowed path restrictions + progress_counter += progress_chunk + dis.progress_bar_show(progress_counter) + + script = make_redeem_script(M, nodes, 0) # first address + dis.progress_bar_show(0.95) + calc_addr = chain.p2sh_address(desc_obj.addr_fmt, script) + + assert calc_addr == addr, "Address mismatch! Calculated %s, got %s" % (calc_addr, addr) + + dis.progress_bar_show(1) + try: + maybe_enroll_xpub(config=desc, name=ms_name, bsms_index=bsms_settings_index) + # bsms_settings_signer_delete(bsms_settings_index) --> moved to auth.py to only be done if actually approved + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + +# EOF \ No newline at end of file diff --git a/shared/chains.py b/shared/chains.py index 26af1410f..b76de121b 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -8,7 +8,8 @@ from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR from public_constants import AF_P2SH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH from public_constants import AFC_PUBKEY, AFC_SEGWIT, AFC_BECH32, AFC_SCRIPT -from serializations import hash160, ser_compact_size, disassemble +from public_constants import TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK +from serializations import hash160, ser_compact_size, disassemble, ser_string from ucollections import namedtuple from opcodes import OP_RETURN, OP_1, OP_16 @@ -26,6 +27,27 @@ # - from # - also electrum source: electrum/lib/constants.py +def taptweak(internal_key, tweak=None): + # BIP 341 states: "If the spending conditions do not require a script path, + # the output key should commit to an unspendable script path instead of having no script path. + # This can be achieved by computing the output key point as: + # Q = P + int(hashTapTweak(bytes(P)))G." + actual_tweak = internal_key if tweak is None else internal_key + tweak + tweak = ngu.secp256k1.tagged_sha256(b"TapTweak", actual_tweak) + xo_pubkey = ngu.secp256k1.xonly_pubkey(internal_key) + xo_pubkey_tweaked = xo_pubkey.tweak_add(tweak) + return xo_pubkey_tweaked.to_bytes() + +def tapscript_serialize(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): + # leaf version is only 7 msb + lv = leaf_version % TAPROOT_LEAF_MASK + return bytes([lv]) + ser_string(script) + +def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): + return ngu.secp256k1.tagged_sha256(b"TapLeaf", + tapscript_serialize(script, leaf_version)) + + class ChainsBase: curve = 'secp256k1' @@ -110,23 +132,30 @@ def pubkey_to_address(cls, pubkey, addr_fmt): # - works only with single-key addresses assert not addr_fmt & AFC_SCRIPT - keyhash = ngu.hash.hash160(pubkey) - if addr_fmt == AF_CLASSIC: - script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC' - elif addr_fmt == AF_P2WPKH_P2SH: - redeem_script = b'\x00\x14' + keyhash - scripthash = ngu.hash.hash160(redeem_script) - script = b'\xA9\x14' + scripthash + b'\x87' - elif addr_fmt == AF_P2WPKH: - script = b'\x00\x14' + keyhash + if addr_fmt == AF_P2TR: + assert len(pubkey) == 32 # internal + script = b'\x51\x20' + taptweak(pubkey) else: - raise ValueError('bad address template: %s' % addr_fmt) + keyhash = ngu.hash.hash160(pubkey) + if addr_fmt == AF_CLASSIC: + script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC' + elif addr_fmt == AF_P2WPKH_P2SH: + redeem_script = b'\x00\x14' + keyhash + scripthash = ngu.hash.hash160(redeem_script) + script = b'\xA9\x14' + scripthash + b'\x87' + elif addr_fmt == AF_P2WPKH: + script = b'\x00\x14' + keyhash + else: + raise ValueError('bad address template: %s' % addr_fmt) return cls.render_address(script) @classmethod def address(cls, node, addr_fmt): # return a human-readable, properly formatted address + if addr_fmt == AF_P2TR: + xo_pk = node.pubkey()[1:] + return ngu.codecs.segwit_encode(cls.bech32_hrp, 1, taptweak(xo_pk)) if addr_fmt == AF_CLASSIC: # olde fashioned P2PKH @@ -299,6 +328,7 @@ class BitcoinMain(ChainsBase): AF_P2WPKH: Slip132Version(0x04b24746, 0x04b2430c, 'z'), AF_P2WSH_P2SH: Slip132Version(0x0295b43f, 0x0295b005, 'Y'), AF_P2WSH: Slip132Version(0x02aa7ed3, 0x02aa7a99, 'Z'), + AF_P2TR: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'), } bech32_hrp = 'bc' @@ -320,6 +350,7 @@ class BitcoinTestnet(BitcoinMain): AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'), AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'), AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'), + AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'), } bech32_hrp = 'tb' @@ -342,6 +373,7 @@ class BitcoinRegtest(BitcoinMain): AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'), AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'), AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'), + AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'), } bech32_hrp = 'bcrt' @@ -376,6 +408,13 @@ def current_chain(): return get_chain(chain) +def current_key_chain(): + c = current_chain() + if c == BitcoinRegtest: + # regtest has same extended keys as testnet + c = BitcoinTestnet + return c + # Overbuilt: will only be testnet and mainchain. AllChains = [BitcoinMain, BitcoinTestnet, BitcoinRegtest] @@ -403,6 +442,8 @@ def slip32_deserialize(xp): AF_P2WPKH_P2SH ), # generates 3xxx/2xxx p2sh-looking addresses ( 'BIP-84 (Native Segwit P2WPKH)', "m/84h/{coin_type}h/{account}h/{change}/{idx}", AF_P2WPKH ), # generates bc1 bech32 addresses + ('BIP-86 (Taproot Segwit P2TR)', "m/86h/{coin_type}h/{account}h/{change}/{idx}", + AF_P2TR), # generates bc1p bech32m addresses ] diff --git a/shared/decoders.py b/shared/decoders.py index 6903e37c7..6be9efaaf 100644 --- a/shared/decoders.py +++ b/shared/decoders.py @@ -194,11 +194,6 @@ def decode_short_text(got): # was something else. pass - # multisig descriptor - # multi( catches both multi( and sortedmulti( - if ("multi(" in got): - return 'multi', (got,) - if ("\n" in got) and ('pub' in got): # legacy multisig import/export format # [0-9a-fA-F]{8}\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]{107} @@ -214,6 +209,10 @@ def decode_short_text(got): if c > 1: return 'multi', (got,) + from descriptor import Descriptor + if Descriptor.is_descriptor(got): + return 'minisc', (got,) + # Things with newlines in them are not URL's # - working URLs are not >4k # - might be a story in text, etc. diff --git a/shared/desc_utils.py b/shared/desc_utils.py new file mode 100644 index 000000000..b0b1257b8 --- /dev/null +++ b/shared/desc_utils.py @@ -0,0 +1,558 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# Copyright (c) 2020 Stepan Snigirev MIT License embit/arguments.py +# +import ngu, chains, ustruct +from io import BytesIO +from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_CLASSIC, AF_P2TR +from binascii import unhexlify as a2b_hex +from binascii import hexlify as b2a_hex +from utils import keypath_to_str, str_to_keypath, swab32, xfp2str +from serializations import ser_compact_size + + +WILDCARD = "*" +PROVABLY_UNSPENDABLE = b'\x02P\x92\x9bt\xc1\xa0IT\xb7\x8bK`5\xe9z^\x07\x8aZ\x0f(\xec\x96\xd5G\xbf\xee\x9a\xce\x80:\xc0' + +INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def polymod(c, val): + c0 = c >> 35 + c = ((c & 0x7ffffffff) << 5) ^ val + if (c0 & 1): + c ^= 0xf5dee51989 + if (c0 & 2): + c ^= 0xa9fdca3312 + if (c0 & 4): + c ^= 0x1bab10e32d + if (c0 & 8): + c ^= 0x3706b1677a + if (c0 & 16): + c ^= 0x644d626ffd + + return c + +def descriptor_checksum(desc): + c = 1 + cls = 0 + clscount = 0 + for ch in desc: + pos = INPUT_CHARSET.find(ch) + if pos == -1: + raise ValueError(ch) + + c = polymod(c, pos & 31) + cls = cls * 3 + (pos >> 5) + clscount += 1 + if clscount == 3: + c = polymod(c, cls) + cls = 0 + clscount = 0 + + if clscount > 0: + c = polymod(c, cls) + for j in range(0, 8): + c = polymod(c, 0) + c ^= 1 + + rv = '' + for j in range(0, 8): + rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + + return rv + +def append_checksum(desc): + return desc + "#" + descriptor_checksum(desc) + + +def parse_desc_str(string): + """Remove comments, empty lines and strip line. Produce single line string""" + res = "" + for l in string.split("\n"): + strip_l = l.strip() + if not strip_l: + continue + if strip_l.startswith("#"): + continue + res += strip_l + return res + + +def multisig_descriptor_template(xpub, path, xfp, addr_fmt): + key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub) + if addr_fmt == AF_P2WSH_P2SH: + descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))" + elif addr_fmt == AF_P2WSH: + descriptor_template = "wsh(sortedmulti(M,%s,...))" + elif addr_fmt == AF_P2SH: + descriptor_template = "sh(sortedmulti(M,%s,...))" + elif addr_fmt == AF_P2TR: + # provably unspendable BIP-0341 + descriptor_template = "tr(" + b2a_hex(PROVABLY_UNSPENDABLE[1:]).decode() + ",sortedmulti_a(M,%s,...))" + else: + return None + descriptor_template = descriptor_template % key_exp + return descriptor_template + + +def read_until(s, chars=b",)(#"): + # TODO potential infinite loop + # what is the longest possible element? (proly some raw( but that is unsupported) + # + res = b"" + chunk = b"" + char = None + while True: + chunk = s.read(1) + if len(chunk) == 0: + return res, None + if chunk in chars: + return res, chunk + res += chunk + return res, None + + +class KeyOriginInfo: + def __init__(self, fingerprint: bytes, derivation: list): + self.fingerprint = fingerprint + self.derivation = derivation + self.cc_fp = swab32(int(b2a_hex(self.fingerprint).decode(), 16)) + + def __eq__(self, other): + return self.psbt_derivation() == other.psbt_derivation() + + def __hash__(self): + return hash(tuple(self.psbt_derivation())) + + def str_derivation(self): + return keypath_to_str(self.derivation, prefix='m/', skip=0) + + def psbt_derivation(self): + res = [self.cc_fp] + for i in self.derivation: + res.append(i) + return res + + @classmethod + def from_string(cls, s: str): + arr = s.split("/") + xfp = a2b_hex(arr[0]) + assert len(xfp) == 4 + arr[0] = "m" + path = "/".join(arr) + derivation = str_to_keypath(xfp, path)[1:] # ignoring xfp here, already stored + return cls(xfp, derivation) + + def __str__(self): + return "%s/%s" % (b2a_hex(self.fingerprint).decode(), + keypath_to_str(self.derivation, prefix='', skip=0).replace("'", "h")) + + +class KeyDerivationInfo: + + def __init__(self, indexes=None): + self.indexes = indexes + if self.indexes is None: + self.indexes = [[0, 1], WILDCARD] + self.multi_path_index = 0 + else: + self.multi_path_index = None + + @property + def is_int_ext(self): + if self.multi_path_index is not None: + return True + return False + + @property + def is_external(self): + if self.is_int_ext: + return True + elif self.indexes[-2] % 2 == 0: + return True + + return False + + @property + def branches(self): + if self.is_int_ext: + return self.indexes[self.multi_path_index] + else: + return [self.indexes[-2]] + + @classmethod + def from_string(cls, s): + fail_msg = "Cannot use hardened sub derivation path" + if not s: + return cls() + res = [] + mp = 0 + mpi = None + for idx, i in enumerate(s.split("/")): + start_i = i.find("<") + if start_i != -1: + end_i = s.find(">") + assert end_i + inner = s[start_i+1:end_i] + assert ";" in inner + inner_split = inner.split(";") + assert len(inner_split) == 2, "wrong multipath" + res.append([int(i) for i in inner_split]) + mp += 1 + mpi = idx + else: + if i == WILDCARD: + res.append(WILDCARD) + else: + assert "'" not in i, fail_msg + assert "h" not in i, fail_msg + res.append(int(i)) + + # only one allowed in subderivation + assert mp <= 1, "too many multipaths (%d)" % mp + + if res == [0, WILDCARD]: + obj = cls() + else: + assert len(res) == 2, "Key derivation too long" + assert res[-1] == WILDCARD, "All keys must be ranged" + obj = cls(res) + obj.multi_path_index = mpi + return obj + + def to_string(self, external=True, internal=True): + res = [] + for i in self.indexes: + if isinstance(i, list): + if internal is True and external is False: + i = str(i[1]) + elif internal is False and external is True: + i = str(i[0]) + else: + i = "<%d;%d>" % (i[0], i[1]) + else: + i = str(i) + res.append(i) + return "/".join(res) + + def to_int_list(self, branch_idx, idx): + assert branch_idx in self.indexes[0] + return [branch_idx, idx] + + +class Key: + def __init__(self, node, origin, derivation=None, taproot=False, chain_type=None): + self.origin = origin + self.node = node + self.derivation = derivation + self.taproot = taproot + self.chain_type = chain_type + + def __eq__(self, other): + return self.origin == other.origin \ + and self.derivation.indexes == other.derivation.indexes + + def __hash__(self): + return hash(self.to_string()) + + def __len__(self): + return 34 - int(self.taproot) # <33:sec> or <32:xonly> + + @property + def fingerprint(self): + return self.origin.fingerprint + + def serialize(self): + return self.key_bytes() + + def compile(self): + d = self.serialize() + return ser_compact_size(len(d)) + d + + @classmethod + def parse(cls, s): + first = s.read(1) + origin = None + if first == b"u": + s.seek(-1, 1) + return Unspend.parse(s) + + if first == b"[": + prefix, char = read_until(s, b"]") + if char != b"]": + raise ValueError("Invalid key - missing ] in key origin info") + origin = KeyOriginInfo.from_string(prefix.decode()) + else: + s.seek(-1, 1) + k, char = read_until(s, b",)/") + der = b"" + if char == b"/": + der, char = read_until(s, b"<,)") + if char == b"<": + der += b"<" + branch, char = read_until(s, b">") + if char is None: + raise ValueError("Failed reading the key, missing >") + der += branch + b">" + rest, char = read_until(s, b",)") + der += rest + if char is not None: + s.seek(-1, 1) + # parse key + node, chain_type = cls.parse_key(k) + der = KeyDerivationInfo.from_string(der.decode()) + return cls(node, origin, der, chain_type=chain_type) + + @classmethod + def parse_key(cls, key_str): + chain_type = None + if key_str[1:4].lower() == b"pub": + # extended key + # or xpub or tpub as we use descriptors (SLIP-132 NOT allowed) + hint = key_str[0:1].lower() + if hint == b"x": + chain_type = "BTC" + else: + assert hint == b"t", "no slip" + chain_type = "XTN" + node = ngu.hdnode.HDNode() + node.deserialize(key_str) + else: + # only unspendable keys can be bare pubkeys - for now + H = PROVABLY_UNSPENDABLE[1:] + if b"r=" in key_str: + _, r = key_str.split(b"=") + if r == b"@": + # pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG + kp = ngu.secp256k1.keypair() + else: + # H + rG where r is provided from user + r = a2b_hex(r) + assert len(r) == 32, "r != 32" + kp = ngu.secp256k1.keypair(r) + + H_xo = ngu.secp256k1.xonly_pubkey(H) + + node = H_xo.tweak_add(kp.xonly_pubkey().to_bytes()).to_bytes() + + elif a2b_hex(key_str) == H: + node = H + else: + node = a2b_hex(key_str) + + assert len(node) == 32, "invalid pk %d %s" % (len(node), node) + + return node, chain_type + + def derive(self, idx=None, change=False): + if isinstance(self.node, bytes): + return self + if isinstance(idx, list): + for i in idx: + mp_i = self.derivation.multi_path_index or 0 + if i in self.derivation.indexes[mp_i]: + idx = i + break + else: + assert False + + elif idx is None: + # derive according to key subderivation if any + if self.derivation is None: + idx = 1 if change else 0 + else: + if self.derivation.multi_path_index is not None: + ext, inter = self.derivation.indexes[self.derivation.multi_path_index] + idx = inter if change else ext + + new_node = self.node.copy() + new_node.derive(idx, False) + if self.origin: + origin = KeyOriginInfo(self.origin.fingerprint, self.origin.derivation + [idx]) + else: + fp = ustruct.pack('") + if char is None: + raise ValueError("Failed reading the key, missing >") + der += branch + b">" + rest, char = read_until(s, b",)") + der += rest + if char is not None: + s.seek(-1, 1) + + node = ngu.hdnode.HDNode().from_chaincode_pubkey(chain_code, + PROVABLY_UNSPENDABLE) + der = KeyDerivationInfo.from_string(der.decode()) + return cls(node, None, der, chain_type=None) + + def to_string(self, external=True, internal=True, subderiv=True): + res = "unspend(%s)" % b2a_hex(self.node.chain_code()).decode() + if self.derivation and subderiv: + res += "/" + self.derivation.to_string(external, internal) + + return res + + @property + def is_provably_unspendable(self): + return True + + +def fill_policy(policy, keys, external=True, internal=True): + orig_keys = [] + for k in keys: + if not isinstance(k, str): + k_orig = k.to_string(external, internal, subderiv=False) + else: + _idx = k.find("]") # end of key origin info - no more / expected besides subderivation + assert _idx != -1 + ek = k[_idx+1:].split("/")[0] + k_orig = k[:_idx+1] + ek + + if k_orig not in orig_keys: + orig_keys.append(k_orig) + + for i in range(len(orig_keys) - 1, -1, -1): + k = orig_keys[i] + ph = "@%d" % i + ph_len = len(ph) + while True: + ix = policy.find(ph) + if ix == -1: + break + + assert policy[ix+ph_len] == "/" + # subderivation is part of the policy + x = ix + ph_len + substr = policy[x:x+26] # 26 is the longest possible subderivation allowed "/<2147483647;2147483646>/*" + mp_start = substr.find("<") + assert mp_start != -1 + mp_end = substr.find(">") + mp = substr[mp_start:mp_end + 1] + _ext, _int = mp[1:-1].split(";") + if external and not internal: + sub = _ext + elif internal and not external: + sub = _int + else: + sub = None + if sub is not None: + policy = policy[:x + mp_start] + sub + policy[x + mp_end + 1:] + + x = policy[ix:ix + ph_len] + assert x == ph + policy = policy[:ix] + k + policy[ix + ph_len:] + + return policy + + +def taproot_tree_helper(scripts): + from miniscript import Miniscript + + if isinstance(scripts, Miniscript): + script = scripts.compile() + assert isinstance(script, bytes) + h = ngu.secp256k1.tagged_sha256(b"TapLeaf", chains.tapscript_serialize(script)) + return [(chains.TAPROOT_LEAF_TAPSCRIPT, script, bytes())], h + if len(scripts) == 1: + return taproot_tree_helper(scripts[0]) + + split_pos = len(scripts) // 2 + left, left_h = taproot_tree_helper(scripts[0:split_pos]) + right, right_h = taproot_tree_helper(scripts[split_pos:]) + left = [(version, script, control + right_h) for version, script, control in left] + right = [(version, script, control + left_h) for version, script, control in right] + if right_h < left_h: + right_h, left_h = left_h, right_h + h = ngu.secp256k1.tagged_sha256(b"TapBranch", left_h + right_h) + return left + right, h \ No newline at end of file diff --git a/shared/descriptor.py b/shared/descriptor.py index e8cf6835c..0f5d18475 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -1,256 +1,461 @@ -# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -# descriptor.py - Bitcoin Core's descriptors and their specialized checksums. +# Copyright (c) 2020 Stepan Snigirev MIT License embit/descriptor.py # -# Based on: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp -# -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH - -MULTI_FMT_TO_SCRIPT = { - AF_P2SH: "sh(%s)", - AF_P2WSH_P2SH: "sh(wsh(%s))", - AF_P2WSH: "wsh(%s)", - None: "wsh(%s)", - # hack for tests - "p2sh": "sh(%s)", - "p2sh-p2wsh": "sh(wsh(%s))", - "p2wsh-p2sh": "sh(wsh(%s))", - "p2wsh": "wsh(%s)", -} - -SINGLE_FMT_TO_SCRIPT = { - AF_P2WPKH: "wpkh(%s)", - AF_CLASSIC: "pkh(%s)", - AF_P2WPKH_P2SH: "sh(wpkh(%s))", - None: "wpkh(%s)", - "p2pkh": "pkh(%s)", - "p2wpkh": "wpkh(%s)", - "p2sh-p2wpkh": "sh(wpkh(%s))", - "p2wpkh-p2sh": "sh(wpkh(%s))", -} - -INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " -CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - -try: - from utils import xfp2str, str2xfp -except ModuleNotFoundError: - import struct - from binascii import unhexlify as a2b_hex - from binascii import hexlify as b2a_hex - # assuming not micro python - def xfp2str(xfp): - # Standardized way to show an xpub's fingerprint... it's a 4-byte string - # and not really an integer. Used to show as '0x%08x' but that's wrong endian. - return b2a_hex(struct.pack('> 35 - c = ((c & 0x7ffffffff) << 5) ^ val - if (c0 & 1): - c ^= 0xf5dee51989 - if (c0 & 2): - c ^= 0xa9fdca3312 - if (c0 & 4): - c ^= 0x1bab10e32d - if (c0 & 8): - c ^= 0x3706b1677a - if (c0 & 16): - c ^= 0x644d626ffd - - return c - -def descriptor_checksum(desc): - c = 1 - cls = 0 - clscount = 0 - for ch in desc: - pos = INPUT_CHARSET.find(ch) - if pos == -1: - raise ValueError(ch) - - c = polymod(c, pos & 31) - cls = cls * 3 + (pos >> 5) - clscount += 1 - if clscount == 3: - c = polymod(c, cls) - cls = 0 - clscount = 0 - - if clscount > 0: - c = polymod(c, cls) - for j in range(0, 8): - c = polymod(c, 0) - c ^= 1 - - rv = '' - for j in range(0, 8): - rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] - - return rv - -def append_checksum(desc): - return desc + "#" + descriptor_checksum(desc) - - -def parse_desc_str(string): - """Remove comments, empty lines and strip line. Produce single line string""" - res = "" - for l in string.split("\n"): - strip_l = l.strip() - if not strip_l: - continue - if strip_l.startswith("#"): - continue - res += strip_l - return res - - -def multisig_descriptor_template(xpub, path, xfp, addr_fmt): - key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub) - if addr_fmt == AF_P2WSH_P2SH: - descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))" - elif addr_fmt == AF_P2WSH: - descriptor_template = "wsh(sortedmulti(M,%s,...))" - elif addr_fmt == AF_P2SH: - descriptor_template = "sh(sortedmulti(M,%s,...))" - else: - return None - descriptor_template = descriptor_template % key_exp - return descriptor_template - - -class Descriptor: - __slots__ = ( - "keys", - "addr_fmt", - ) - - def __init__(self, keys, addr_fmt): +class Tapscript: + def __init__(self, tree=None, keys=None, policy=None): + self.tree = tree self.keys = keys - self.addr_fmt = addr_fmt + self.policy = policy + self._merkle_root = None @staticmethod - def checksum_check(desc_w_checksum , csum_required=False): - try: - desc, checksum = desc_w_checksum.split("#") - except ValueError: - if csum_required: - raise ValueError("Missing descriptor checksum") - return desc_w_checksum, None - calc_checksum = descriptor_checksum(desc) - if calc_checksum != checksum: - raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum)) - return desc, checksum + def iter_leaves(tree): + if isinstance(tree, Miniscript): + yield tree + else: + assert isinstance(tree, list) + for lv in tree: + yield from Tapscript.iter_leaves(lv) + + @property + def merkle_root(self): + if not self._merkle_root: + self.process_tree() + return self._merkle_root @staticmethod - def parse_key_orig_info(key): - # key origin info is required for our MultisigWallet - close_index = key.find("]") - if key[0] != "[" or close_index == -1: - raise ValueError("Key origin info is required for %s" % (key)) - key_orig_info = key[1:close_index] # remove brackets - key = key[close_index + 1:] - assert "/" in key_orig_info, "Malformed key derivation info" - return key_orig_info, key + def _derive(tree, idx, key_map, change=False): + if isinstance(tree, Miniscript): + return tree.derive(idx, key_map, change=change) + else: + if len(tree) == 1 and isinstance(tree[0], Miniscript): + return tree[0].derive(idx, key_map, change=change) + l, r = tree + return [Tapscript._derive(l, idx, key_map, change=change), + Tapscript._derive(r, idx, key_map, change=change)] + + def derive(self, idx=None, change=False): + derived_keys = OrderedDict() + for k in self.keys: + derived_keys[k] = k.derive(idx, change=change) + tree = Tapscript._derive(self.tree, idx, derived_keys, change=change) + return type(self)(tree, policy=self.policy, keys=list(derived_keys.values())) + + def process_tree(self): + info, mr = taproot_tree_helper(self.tree) + self._merkle_root = mr + return info, mr + + @classmethod + def read_from(cls, s): + num_leafs = 0 + depth = 0 + tapscript = [] + p0 = s.read(1) + if p0 != b"{": + # depth zero + s.seek(-1, 1) + alone = Miniscript.read_from(s, taproot=True) + alone.is_sane(taproot=True) + alone.verify() + tapscript.append(alone) + num_leafs += 1 + else: + assert p0 == b"{" + depth += 1 + itmp = None + itmp_p = None + while True: + p1 = s.read(1) + if p1 == b'': + break + elif p1 == b")": + s.seek(-1, 1) + break + elif p1 == b",": + continue + elif p1 == b"{": + if itmp is None: + itmp = [] + else: + if itmp_p: + itmp[itmp_p].append([]) + else: + itmp.append(([])) + itmp_p = -1 + + depth += 1 + continue + elif p1 == b"}": + depth -= 1 + if depth == 1: + tapscript.append(itmp) + itmp = None + + if depth <= 2: + itmp_p = None + continue + + s.seek(-1, 1) + item = Miniscript.read_from(s, taproot=True) + item.is_sane(taproot=True) + item.verify() + num_leafs += 1 + if itmp is None: + tapscript.append(item) + else: + if itmp_p and depth == 4: + itmp[itmp_p][itmp_p].append(item) + elif itmp_p: + itmp[itmp_p].append(item) + else: + itmp.append(item) + + assert num_leafs <= 8, "num_leafs > 8" + ts = cls(tapscript) + ts.parse_policy() + return ts + + def parse_policy(self): + self.policy, self.keys = self._parse_policy(self.tree, []) + orig_keys = OrderedDict() + for k in self.keys: + if k.origin not in orig_keys: + orig_keys[k.origin] = [] + orig_keys[k.origin].append(k) + for i, k_lst in enumerate(orig_keys.values()): + # always keep subderivation in policy string + self.policy = self.policy.replace(k_lst[0].to_string(subderiv=False), chr(64) + str(i)) @staticmethod - def parse_key_derivation_info(key): - invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed" - slash_split = key.split("/") - assert len(slash_split) > 1, invalid_subderiv_msg - if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]): - assert slash_split[-1] == "*", invalid_subderiv_msg - assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg - assert len(slash_split[1:]) == 2, invalid_subderiv_msg - return slash_split[0] + def _parse_policy(tree, all_keys): + if isinstance(tree, Miniscript): + keys, leaf_str = tree.keys, tree.to_string() + for k in keys: + if k not in all_keys: + all_keys.append(k) + + return leaf_str, all_keys else: - raise ValueError("Cannot use hardened sub derivation path") - - def checksum(self): - return descriptor_checksum(self._serialize()) - - def serialize_keys(self, internal=False, int_ext=False): - result = [] - for xfp, deriv, xpub in self.keys: - if deriv[0] == "m": - # get rid of 'm' - deriv = deriv[1:] - elif deriv[0] != "/": - # input "84'/0'/0'" would lack slash separtor with xfp - deriv = "/" + deriv - if not isinstance(xfp, str): - xfp = xfp2str(xfp) - koi = xfp + deriv - # normalize xpub to use h for hardened instead of ' - key_str = "[%s]%s" % (koi.lower(), xpub) - if int_ext: - key_str = key_str + "/" + "<0;1>" + "/" + "*" + assert isinstance(tree, list) + if len(tree) == 1 and isinstance(tree[0], Miniscript): + keys, leaf_str = tree[0].keys, tree[0].to_string() + for k in keys: + if k not in all_keys: + all_keys.append(k) + + return leaf_str, all_keys else: - key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"]) - result.append(key_str.replace("'", "h")) - return result + l, r = tree + ll, all_keys = Tapscript._parse_policy(l, all_keys) + rr, all_keys = Tapscript._parse_policy(r, all_keys) + return "{" + ll + "," + rr + "}", all_keys - def _serialize(self, internal=False, int_ext=False): - """Serialize without checksum""" - assert len(self.keys) == 1 # "Multiple keys for single signature script" - desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt] - inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0] - return desc_base % (inner) + @staticmethod + def script_tree(tree): + if isinstance(tree, Miniscript): + return b2a_hex(chains.tapscript_serialize(tree.compile())).decode() + else: + assert isinstance(tree, list) + if len(tree) == 1 and isinstance(tree[0], Miniscript): + return b2a_hex(chains.tapscript_serialize(tree[0].compile())).decode() + else: + l, r = tree + ll = Tapscript.script_tree(l) + rr = Tapscript.script_tree(r) + return "{" + ll + "," + rr + "}" - def serialize(self, internal=False, int_ext=False): - """Serialize with checksum""" - return append_checksum(self._serialize(internal=internal, int_ext=int_ext)) + def to_string(self, external=True, internal=True): + return fill_policy(self.policy, self.keys, external, internal) - @classmethod - def parse(cls, desc_w_checksum): - # remove garbage - desc_w_checksum = parse_desc_str(desc_w_checksum) - # check correct checksum - desc, checksum = cls.checksum_check(desc_w_checksum) - # legacy - if desc.startswith("pkh("): - addr_fmt = AF_CLASSIC - tmp_desc = desc.replace("pkh(", "") - tmp_desc = tmp_desc.rstrip(")") - - # native segwit - elif desc.startswith("wpkh("): - addr_fmt = AF_P2WPKH - tmp_desc = desc.replace("wpkh(", "") - tmp_desc = tmp_desc.rstrip(")") - - # wrapped segwit - elif desc.startswith("sh(wpkh("): - addr_fmt = AF_P2WPKH_P2SH - tmp_desc = desc.replace("sh(wpkh(", "") - tmp_desc = tmp_desc.rstrip("))") +class Descriptor: + def __init__(self, miniscript=None, sh=False, wsh=True, key=None, wpkh=True, + taproot=False, tapscript=None): + if key is None and miniscript is None: + raise DescriptorException("Provide either miniscript or a key") + + self.sh = sh + self.wsh = wsh + self.key = key + self.miniscript = miniscript + self.wpkh = wpkh + self.taproot = taproot + self.tapscript = tapscript + + if taproot: + if self.key: + self.key.taproot = True + for k in self.keys: + k.taproot = taproot + + def validate(self): + from glob import settings + if self.miniscript: + if self.is_basic_multisig: + assert len(self.keys) <= MAX_SIGNERS + else: + assert len(self.keys) <= 20 + self.miniscript.verify() + if self.miniscript.type != "B": + raise DescriptorException("Top level miniscript should be 'B'") + + has_mine = 0 + my_xfp = settings.get('xfp') + to_check = self.keys.copy() + if self.tapscript: + assert len(self.keys) <= MAX_TR_SIGNERS + assert self.key # internal key (would fail during parse) + if not self.key.is_provably_unspendable: + to_check += [self.key] else: - raise ValueError("Unsupported descriptor. Supported: pkh(), wpkh(), sh(wpkh()).") - - koi, key = cls.parse_key_orig_info(tmp_desc) - if key[0:4] not in ["tpub", "xpub"]: - raise ValueError("Only extended public keys are supported") + assert self.key is None and self.miniscript, "not miniscript" + + c = chains.current_key_chain().ctype + for k in to_check: + assert k.chain_type == c, "wrong chain" + xfp = k.origin.cc_fp + deriv = k.origin.str_derivation() + xpub = k.extended_public_key() + deriv = cleanup_deriv_path(deriv) + is_mine, _ = check_xpub(xfp, xpub, deriv, c, my_xfp, False) + if is_mine: + has_mine += 1 + + assert has_mine != 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper() + + def storage_policy(self): + if self.tapscript: + return self.tapscript.policy + + s = self.miniscript.to_string() + orig_keys = OrderedDict() + for k in self.keys: + if k.origin not in orig_keys: + orig_keys[k.origin] = [] + orig_keys[k.origin].append(k) + for i, k_lst in enumerate(orig_keys.values()): + s = s.replace(k_lst[0].to_string(subderiv=False), chr(64) + str(i)) + return s + + def ux_policy(self): + if self.tapscript: + return "Taproot tree keys:\n\n" + self.tapscript.policy + + return self.storage_policy() + + @property + def script_len(self): + if self.taproot: + return 34 # OP_1 <32:xonly> + if self.miniscript: + return len(self.miniscript) + if self.wpkh: + return 22 # 00 <20:pkh> + return 25 # OP_DUP OP_HASH160 <20:pkh> OP_EQUALVERIFY OP_CHECKSIG + + def xfp_paths(self): + res = [] + if self.taproot: + if self.key.origin: + # spendable internal key + res.append(self.key.origin.psbt_derivation()) + elif not isinstance(self.key.node, bytes): + if self.key.is_provably_unspendable: + res.append([swab32(self.key.node.my_fp())]) + + for k in self.keys: + if k.origin: + res.append(k.origin.psbt_derivation()) + return res - xpub = cls.parse_key_derivation_info(key) - xfp = str2xfp(koi[:8]) - origin_deriv = "m" + koi[8:] + @property + def is_wrapped(self): + return self.sh and self.is_segwit + + @property + def is_legacy(self): + return not (self.is_segwit or self.is_taproot) + + @property + def is_segwit(self): + return (self.wsh and self.miniscript) or (self.wpkh and self.key) or self.taproot + + @property + def is_pkh(self): + return self.key is not None and not self.taproot + + @property + def is_taproot(self): + return self.taproot + + @property + def is_basic_multisig(self): + return self.miniscript and self.miniscript.NAME in ["multi", "sortedmulti"] + + @property + def is_sortedmulti(self): + return self.is_basic_multisig and self.miniscript.NAME == "sortedmulti" + + @property + def keys(self): + if self.tapscript: + return self.tapscript.keys + elif self.key: + return [self.key] + return self.miniscript.keys + + @property + def addr_fmt(self): + if self.sh and not self.wsh: + af = AF_P2SH + elif self.wsh and not self.sh: + af = AF_P2WSH + elif self.sh and self.wsh: + af = AF_P2WSH_P2SH + elif self.taproot: + af = AF_P2TR + elif self.sh and self.wpkh: + af = AF_P2WPKH_P2SH + elif self.wpkh and not self.sh: + af = AF_P2WPKH + else: + af = AF_CLASSIC + return af + + def set_from_addr_fmt(self, addr_fmt): + self.taproot = False + self.wsh = False + self.wpkh = False + self.sh = False + if addr_fmt == AF_P2TR: + self.taproot = True + assert self.key + elif addr_fmt == AF_P2WPKH: + self.wpkh = True + self.miniscript = None + assert self.key + elif addr_fmt == AF_P2WPKH_P2SH: + self.wpkh = True + self.sh = True + self.miniscript = None + assert self.key + elif addr_fmt == AF_P2SH: + self.sh = True + assert self.miniscript + assert not self.key + elif addr_fmt == AF_P2WSH: + self.wsh = True + assert self.miniscript + assert not self.key + elif addr_fmt == AF_P2WSH_P2SH: + self.wsh = True + self.sh = True + assert self.miniscript + assert not self.key + else: + # AF_CLASSIC + assert self.key + assert not self.miniscript + + def scriptpubkey_type(self): + if self.is_taproot: + return "p2tr" + if self.sh: + return "p2sh" + if self.is_pkh: + if self.is_legacy: + return "p2pkh" + if self.is_segwit: + return "p2wpkh" + else: + return "p2wsh" + + def derive(self, idx=None, change=False): + if self.taproot: + return type(self)( + None, + self.sh, + self.wsh, + self.key.derive(idx, change=change), + self.wpkh, + self.taproot, + tapscript=self.tapscript.derive(idx, change=change), + ) + if self.miniscript: + return type(self)( + self.miniscript.derive(idx, change=change), + self.sh, + self.wsh, + None, + self.wpkh, + self.taproot, + tapscript=None, + ) + else: + return type(self)( + None, self.sh, self.wsh, + self.key.derive(idx, change=change), + self.wpkh, self.taproot, tapscript=None + ) + + def witness_script(self): + if self.wsh and self.miniscript is not None: + return self.miniscript.compile() + + def redeem_script(self): + if not self.sh: + return None + if self.miniscript: + if self.wsh: + return b"\x00\x20" + ngu.hash.sha256s(self.miniscript.compile()) + else: + return self.miniscript.compile() - return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt) + else: + return b"\x00\x14" + ngu.hash.hash160(self.key.node.pubkey()) + + def script_pubkey(self): + if self.taproot: + tweak = None + if self.tapscript: + tweak = self.tapscript.merkle_root + output_pubkey = chains.taptweak(self.key.serialize(), tweak) + return b"\x51\x20" + output_pubkey + if self.sh: + return b"\xa9\x14" + ngu.hash.hash160(self.redeem_script()) + b"\x87" + if self.wsh: + return b"\x00\x20" + ngu.hash.sha256s(self.witness_script()) + if self.miniscript: + return self.miniscript.compile() + if self.wpkh: + return b"\x00\x14" + ngu.hash.hash160(self.key.serialize()) + return b"\x76\xa9\x14" + ngu.hash.hash160(self.key.serialize()) + b"\x88\xac" @classmethod def is_descriptor(cls, desc_str): - # Quick method to guess whether this is a descriptor + """Quick method to guess whether this is a descriptor""" try: temp = parse_desc_str(desc_str) except: @@ -267,142 +472,142 @@ def is_descriptor(cls, desc_str): return True return False - def bitcoin_core_serialize(self, external_label=None): - # this will become legacy one day - # instead use <0;1> descriptor format - res = [] - for internal in [False, True]: - desc_obj = { - "desc": self.serialize(internal=internal), - "active": True, - "timestamp": "now", - "internal": internal, - "range": [0, 100], - } - if internal is False and external_label: - desc_obj["label"] = external_label - res.append(desc_obj) + @staticmethod + def checksum_check(desc_w_checksum, csum_required=False): + try: + desc, checksum = desc_w_checksum.split("#") + except ValueError: + if csum_required: + raise ValueError("Missing descriptor checksum") + return desc_w_checksum, None + calc_checksum = descriptor_checksum(desc) + if calc_checksum != checksum: + raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum)) + return desc, checksum + @classmethod + def from_string(cls, desc, checksum=False): + desc = parse_desc_str(desc) + desc, cs = cls.checksum_check(desc) + s = BytesIO(desc.encode()) + res = cls.read_from(s) + left = s.read() + if len(left) > 0: + raise ValueError("Unexpected characters after descriptor: %r" % left) + if checksum: + if cs is None: + _, cs = res.to_string().split("#") + return res, cs return res - -class MultisigDescriptor(Descriptor): - # only supprt with key derivation info - # only xpubs - # can be extended when needed - __slots__ = ( - "M", - "N", - "keys", - "addr_fmt", - "is_sorted" # whether to use sortedmulti() or multi() - ) - - def __init__(self, M, N, keys, addr_fmt, is_sorted=True): - self.M = M - self.N = N - self.is_sorted = is_sorted - super().__init__(keys, addr_fmt) - @classmethod - def parse(cls, desc_w_checksum): - # remove garbage - desc_w_checksum = parse_desc_str(desc_w_checksum) - # check correct checksum - desc, checksum = cls.checksum_check(desc_w_checksum) - is_sorted = "sortedmulti(" in desc - rplc = "sortedmulti(" if is_sorted else "multi(" - - # wrapped segwit - if desc.startswith("sh(wsh("+rplc): - addr_fmt = AF_P2WSH_P2SH - tmp_desc = desc.replace("sh(wsh("+rplc, "") - tmp_desc = tmp_desc.rstrip(")))") - - # native segwit - elif desc.startswith("wsh("+rplc): - addr_fmt = AF_P2WSH - tmp_desc = desc.replace("wsh("+rplc, "") - tmp_desc = tmp_desc.rstrip("))") - - # legacy - elif desc.startswith("sh("+rplc): - addr_fmt = AF_P2SH - tmp_desc = desc.replace("sh("+rplc, "") - tmp_desc = tmp_desc.rstrip("))") - + def read_from(cls, s, taproot=False): + start = s.read(8) + sh = False + wsh = False + wpkh = False + is_miniscript = True + internal_key = None + tapscript = None + if start.startswith(b"tr("): + is_miniscript = False # miniscript vs. tapscript (that can contain miniscripts in tree) + taproot = True + s.seek(-5, 1) + internal_key = Key.parse(s) # internal key is a must - also handles unspend( + internal_key.taproot = True + sep = s.read(1) + if sep == b")": + s.seek(-1, 1) + else: + assert sep == b"," + tapscript = Tapscript.read_from(s) + elif start.startswith(b"sh(wsh("): + sh = True + wsh = True + s.seek(-1, 1) + elif start.startswith(b"wsh("): + sh = False + wsh = True + s.seek(-4, 1) + elif start.startswith(b"sh(wpkh("): + is_miniscript = False + sh = True + wpkh = True + elif start.startswith(b"wpkh("): + is_miniscript = False + wpkh = True + s.seek(-3, 1) + elif start.startswith(b"pkh("): + is_miniscript = False + s.seek(-4, 1) + elif start.startswith(b"sh("): + sh = True + wsh = False + s.seek(-5, 1) else: - raise ValueError("Unsupported descriptor. Supported: sh(), sh(wsh()), wsh().") - - splitted = tmp_desc.split(",") - M, keys = int(splitted[0]), splitted[1:] - N = int(len(keys)) - if M > N: - raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N)) - - res_keys = [] - for key in keys: - koi, key = cls.parse_key_orig_info(key) - if key[0:4] not in ["tpub", "xpub"]: - raise ValueError("Only extended public keys are supported") - - xpub = cls.parse_key_derivation_info(key) - xfp = str2xfp(koi[:8]) - origin_deriv = "m" + koi[8:] - res_keys.append((xfp, origin_deriv, xpub)) - - return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt, is_sorted=is_sorted) - - def _serialize(self, internal=False, int_ext=False): - """Serialize without checksum""" - desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt] - _type = "sortedmulti" if self.is_sorted else "multi" - _type += "(%s)" - desc_base = desc_base % _type - assert len(self.keys) == self.N - inner = str(self.M) + "," + ",".join( - self.serialize_keys(internal=internal, int_ext=int_ext)) - - return desc_base % (inner) - - def pretty_serialize(self): - """Serialize in pretty and human-readable format""" - _type = "sortedmulti" if self.is_sorted else "multi" - res = "# Coldcard descriptor export\n" - if self.is_sorted: - res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n" + raise ValueError("Invalid descriptor") + + if is_miniscript: + miniscript = Miniscript.read_from(s) + miniscript.is_sane(taproot=False) + key = internal_key + nbrackets = int(sh) + int(wsh) + elif taproot: + miniscript = None + key = internal_key + nbrackets = 1 else: - res += ("# !!! DANGER: order of keys in descriptor MUST be preserved. " - "Correct order of keys is required to compose valid redeem/witness script.\n") - if self.addr_fmt == AF_P2SH: - res += "# bare multisig - p2sh\n" - res += "sh("+_type+"(\n%s\n))" - # native segwit - elif self.addr_fmt == AF_P2WSH: - res += "# native segwit - p2wsh\n" - res += "wsh("+_type+"(\n%s\n))" - - # wrapped segwit - elif self.addr_fmt == AF_P2WSH_P2SH: - res += "# wrapped segwit - p2sh-p2wsh\n" - res += "sh(wsh(" + _type + "(\n%s\n)))" + miniscript = None + key = Key.parse(s) + nbrackets = 1 + int(sh) + + end = s.read(nbrackets) + if end != b")" * nbrackets: + raise ValueError("Invalid descriptor") + o = cls(miniscript, sh=sh, wsh=wsh, key=key, wpkh=wpkh, + taproot=taproot, tapscript=tapscript) + o.validate() + return o + + def to_string(self, external=True, internal=True, checksum=True): + if self.taproot: + desc = "tr(%s" % self.key.to_string(external, internal) + if self.tapscript: + desc += "," + tree = self.tapscript.to_string(external, internal) + desc += tree + + desc = desc + ")" + return append_checksum(desc) + + if self.miniscript is not None: + res = self.miniscript.to_string(external, internal) + if self.wsh: + res = "wsh(%s)" % res else: - raise ValueError("Malformed descriptor") - - assert len(self.keys) == self.N - inner = "\t" + "# %d of %d (%s)\n" % ( - self.M, self.N, - "requires all participants to sign" if self.M == self.N else "threshold") - inner += "\t" + str(self.M) + ",\n" - ser_keys = self.serialize_keys() - for i, key_str in enumerate(ser_keys, start=1): - if i == self.N: - inner += "\t" + key_str + if self.wpkh: + res = "wpkh(%s)" % self.key.to_string(external, internal) else: - inner += "\t" + key_str + ",\n" + res = "pkh(%s)" % self.key.to_string(external, internal) + if self.sh: + res = "sh(%s)" % res - checksum = self.serialize().split("#")[1] + if checksum: + res = append_checksum(res) + return res - return (res % inner) + "#" + checksum + def bitcoin_core_serialize(self): + # this will become legacy one day + # instead use <0;1> descriptor format + res = [] + for external in (True, False): + desc_obj = { + "desc": self.to_string(external, not external), + "active": True, + "timestamp": "now", + "internal": not external, + "range": [0, 100], + } + res.append(desc_obj) -# EOF + return res diff --git a/shared/display.py b/shared/display.py index bf8196834..fab563a44 100644 --- a/shared/display.py +++ b/shared/display.py @@ -4,7 +4,7 @@ # import machine, uzlib, ckcc, utime from ssd1306 import SSD1306_SPI -from version import is_devmode +from version import is_devmode, is_edge import framebuf from graphics_mk4 import Graphics @@ -146,6 +146,12 @@ def scroll_bar(self, offset, count, per_page): self.text(-2, 21, 'D', font=FontTiny, invert=1) self.text(-2, 28, 'E', font=FontTiny, invert=1) self.text(-2, 35, 'V', font=FontTiny, invert=1) + elif is_edge: + self.dis.fill_rect(128 - 6, 19, 5, 26, 1) + self.text(-2, 20, 'E', font=FontTiny, invert=1) + self.text(-2, 27, 'D', font=FontTiny, invert=1) + self.text(-2, 33, 'G', font=FontTiny, invert=1) + self.text(-2, 39, 'E', font=FontTiny, invert=1) def fullscreen(self, msg, percent=None, line2=None): # show a simple message "fullscreen". diff --git a/shared/drv_entro.py b/shared/drv_entro.py index 3fad9dae4..91ce4cef8 100644 --- a/shared/drv_entro.py +++ b/shared/drv_entro.py @@ -56,32 +56,32 @@ async def drv_entro_start(*a): def bip85_derive(picked, index): # implement the core step of BIP85 from our master secret - + path = "m/83696968h/" if picked in (0,1,2): # BIP-39 seed phrases (we only support English) num_words = stash.SEED_LEN_OPTS[picked] width = (16, 24, 32)[picked] # of bytes - path = "m/83696968h/39h/0h/{num_words}h/{index}h".format(num_words=num_words, index=index) + path += "39h/0h/%dh/%dh" % (num_words, index) s_mode = 'words' elif picked == 3: - # HDSeed for Bitcoin Core: but really a WIF of a private key, can be used anywhere + # HDSeed for Bitcoin Core: but really a WIF of a private key s_mode = 'wif' - path = "m/83696968h/2h/{index}h".format(index=index) + path += "2h/%dh" % index width = 32 elif picked == 4: # New XPRV - path = "m/83696968h/32h/{index}h".format(index=index) + path += "32h/%dh" % index s_mode = 'xprv' width = 64 elif picked in (5, 6): width = 32 if picked == 5 else 64 - path = "m/83696968h/128169h/{width}h/{index}h".format(width=width, index=index) + path += "128169h/%dh/%dh" % (width, index) s_mode = 'hex' elif picked == 7: width = 64 # hardcoded width for now # b"pwd".hex() --> 707764 - path = "m/83696968h/707764h/{pwd_len}h/{index}h".format(pwd_len=BIP85_PWD_LEN, index=index) + path += "707764h/%dh/%dh" % (BIP85_PWD_LEN, index) s_mode = 'pw' else: raise ValueError(picked) diff --git a/shared/export.py b/shared/export.py index 5760cc9ff..2a41477e4 100644 --- a/shared/export.py +++ b/shared/export.py @@ -9,7 +9,7 @@ from ux import ux_show_story from glob import settings from auth import write_sig_file -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, AF_P2TR from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR from ownership import OWNERSHIP @@ -103,7 +103,7 @@ def generate_public_contents(): node = sv.derive_path(hard_sub, register=False) yield ("%s => %s\n" % (hard_sub, chain.serialize_public(node))) - if show_slip132 and addr_fmt != AF_CLASSIC and (addr_fmt in chain.slip132): + if show_slip132 and addr_fmt not in (AF_CLASSIC, AF_P2TR) and (addr_fmt in chain.slip132): yield ("%s => %s ##SLIP-132##\n" % ( hard_sub, chain.serialize_public(node, addr_fmt))) @@ -121,7 +121,8 @@ def generate_public_contents(): yield ('\n\n') from multisig import MultisigWallet - if MultisigWallet.exists(): + exists, exists_other_chain = MultisigWallet.exists() + if exists: yield '\n# Your Multisig Wallets\n\n' for ms in MultisigWallet.get_all(): @@ -133,14 +134,15 @@ def generate_public_contents(): yield fp.getvalue() del fp -async def write_text_file(fname_pattern, body, title, derive, addr_fmt): +async def write_text_file(fname_pattern, body, title, derive, addr_fmt, + force_prompt=False): # Export data as a text file. from glob import dis, NFC from files import CardSlot, CardMissingError, needs_microsd from ux import import_export_prompt choice = await import_export_prompt("%s file" % title, is_import=False, - no_qr=(not version.has_qwerty)) + force_prompt=force_prompt) # QR offered also on Mk4 if choice == KEY_CANCEL: return elif choice == KEY_QR: @@ -160,8 +162,10 @@ async def write_text_file(fname_pattern, body, title, derive, addr_fmt): with open(fname, 'wb') as fd: chunk_writer(fd, body) - h = ngu.hash.sha256s(body.encode()) - sig_nice = write_sig_file([(h, fname)], derive, addr_fmt) + sig_nice = None + if addr_fmt != AF_P2TR: + h = ngu.hash.sha256s(body.encode()) + sig_nice = write_sig_file([(h, fname)], derive, addr_fmt) except CardMissingError: await needs_microsd() @@ -170,8 +174,9 @@ async def write_text_file(fname_pattern, body, title, derive, addr_fmt): await ux_show_story('Failed to write!\n\n\n'+str(e)) return - msg = '%s file written:\n\n%s\n\n%s signature file written:\n\n%s' % (title, nice, title, - sig_nice) + msg = '%s file written:\n\n%s' % (title, nice) + if sig_nice: + msg += '\n\n%s signature file written:\n\n%s' % (title, sig_nice) await ux_show_story(msg) async def make_summary_file(fname_pattern='public.txt'): @@ -195,10 +200,11 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx # make the data examples = [] - imp_multi, imp_desc = generate_bitcoin_core_wallet(account_num, examples) + imp_multi, imp_desc, imp_desc_tr = generate_bitcoin_core_wallet(account_num, examples) imp_multi = ujson.dumps(imp_multi) imp_desc = ujson.dumps(imp_desc) + imp_desc_tr = ujson.dumps(imp_desc_tr) body = '''\ # Bitcoin Core Wallet Import File @@ -214,7 +220,10 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx The following command can be entered after opening Window -> Console in Bitcoin Core, or using bitcoin-cli: -importdescriptors '{imp_desc}' +p2wpkh: + importdescriptors '{imp_desc}' +p2tr: + importdescriptors '{imp_desc_tr}' > **NOTE** If your UTXO was created before generating `importdescriptors` command, you should adjust the value of `timestamp` before executing command in bitcoin core. By default it is set to `now` meaning do not rescan the blockchain. If approximate time of UTXO creation is known - adjust `timestamp` from `now` to UNIX epoch time. @@ -229,13 +238,15 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx ## Resulting Addresses (first 3) -'''.format(imp_multi=imp_multi, imp_desc=imp_desc, xfp=xfp, nb=chains.current_chain().name) +'''.format(imp_multi=imp_multi, imp_desc=imp_desc, imp_desc_tr=imp_desc_tr, + xfp=xfp, nb=chains.current_chain().name) body += '\n'.join('%s => %s' % t for t in examples) body += '\n' OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num) + OWNERSHIP.note_wallet_used(AF_P2TR, account_num) ch = chains.current_chain() derive = "84h/{coin_type}h/{account}h".format(account=account_num, coin_type=ch.b44_cointype) @@ -244,44 +255,65 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx def generate_bitcoin_core_wallet(account_num, example_addrs): # Generate the data for an RPC command to import keys into Bitcoin Core # - yields dicts for json purposes - from descriptor import Descriptor + from descriptor import Descriptor, Key chain = chains.current_chain() - derive = "84h/{coin_type}h/{account}h".format(account=account_num, - coin_type=chain.b44_cointype) - + derive_v0 = "84h/{coin_type}h/{account}h".format( + account=account_num, coin_type=chain.b44_cointype + ) + derive_v1 = "86h/{coin_type}h/{account}h".format( + account=account_num, coin_type=chain.b44_cointype + ) with stash.SensitiveValues() as sv: - prefix = sv.derive_path(derive) - xpub = chain.serialize_public(prefix) + prefix = sv.derive_path(derive_v0) + xpub_v0 = chain.serialize_public(prefix) for i in range(3): sp = '0/%d' % i node = sv.derive_path(sp, master=prefix) a = chain.address(node, AF_P2WPKH) - example_addrs.append( ('m/%s/%s' % (derive, sp), a) ) + example_addrs.append(('m/%s/%s' % (derive_v0, sp), a)) + + with stash.SensitiveValues() as sv: + prefix = sv.derive_path(derive_v1) + xpub_v1 = chain.serialize_public(prefix) + + for i in range(3): + sp = '0/%d' % i + node = sv.derive_path(sp, master=prefix) + a = chain.address(node, AF_P2TR) + example_addrs.append(('m/%s/%s' % (derive_v1, sp), a)) xfp = settings.get('xfp') - _, vers, _ = version.get_mpy_version() + key0 = Key.from_cc_data(xfp, derive_v0, xpub_v0) + desc_v0 = Descriptor(key=key0) + desc_v0.set_from_addr_fmt(AF_P2WPKH) + + key1 = Key.from_cc_data(xfp, derive_v1, xpub_v1) + desc_v1 = Descriptor(key=key1) + desc_v1.set_from_addr_fmt(AF_P2TR) OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num) + OWNERSHIP.note_wallet_used(AF_P2TR, account_num) - desc_obj = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=AF_P2WPKH) # for importmulti imm_list = [ { - 'desc': desc_obj.serialize(internal=internal), + 'desc': desc_v0.to_string(external, internal), 'range': [0, 1000], 'timestamp': 'now', 'internal': internal, 'keypool': True, 'watchonly': True } - for internal in [False, True] + for external, internal in [(True, False), (False, True)] ] # for importdescriptors - imd_list = desc_obj.bitcoin_core_serialize() - return imm_list, imd_list + imd_list = desc_v0.bitcoin_core_serialize() + imd_list_v1 = desc_v1.bitcoin_core_serialize() + return imm_list, imd_list, imd_list_v1 + def generate_wasabi_wallet(): # Generate the data for a JSON file which Wasabi can open directly as a new wallet. @@ -347,7 +379,8 @@ def generate_unchained_export(account_num=0): def generate_generic_export(account_num=0): # Generate data that other programers will use to import Coldcard (single-signer) - from descriptor import Descriptor, multisig_descriptor_template + from descriptor import Descriptor, Key + from desc_utils import multisig_descriptor_template chain = chains.current_chain() master_xfp = settings.get("xfp") @@ -361,12 +394,14 @@ def generate_generic_export(account_num=0): with stash.SensitiveValues() as sv: # each of these paths would have /{change}/{idx} in usage (not hardened) for name, deriv, fmt, atype, is_ms in [ - ( 'bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False ), - ( 'bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False ), # was "p2wpkh-p2sh" - ( 'bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False ), - ( 'bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True ), - ( 'bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True ), - ( 'bip45', "m/45h", AF_P2SH, 'p2sh', True ), + ('bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False), + ('bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False), # was "p2wpkh-p2sh" + ('bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False), + ('bip86', "m/86h/{ct}h/{acc}h", AF_P2TR, 'p2tr', False), + ('bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True), + ('bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True), + ('bip48_3', "m/48h/{ct}h/{acc}h/3h", AF_P2TR, 'p2tr', True), + ('bip45', "m/45h", AF_P2SH, 'p2sh', True), ]: if fmt == AF_P2SH and account_num: continue @@ -375,11 +410,14 @@ def generate_generic_export(account_num=0): node = sv.derive_path(dd) xfp = xfp2str(swab32(node.my_fp())) xp = chain.serialize_public(node, AF_CLASSIC) - zp = chain.serialize_public(node, fmt) if fmt != AF_CLASSIC else None + zp = chain.serialize_public(node, fmt) if fmt not in (AF_CLASSIC, AF_P2TR) else None if is_ms: desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt) else: - desc = Descriptor(keys=[(master_xfp, dd, xp)], addr_fmt=fmt).serialize(int_ext=True) + key = Key.from_cc_data(master_xfp, dd, xp) + desc_obj = Descriptor(key=key) + desc_obj.set_from_addr_fmt(fmt) + desc = desc_obj.to_string() OWNERSHIP.note_wallet_used(fmt, account_num) @@ -505,7 +543,7 @@ async def make_json_wallet(label, func, fname_pattern='new-wallet.json'): async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int_ext=True, fname_pattern="descriptor.txt"): - from descriptor import Descriptor + from descriptor import Descriptor, Key from glob import dis dis.fullscreen('Generating...') @@ -520,34 +558,41 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int mode = 84 elif addr_type == AF_P2WPKH_P2SH: mode = 49 + elif addr_type == AF_P2TR: + mode = 86 else: raise ValueError(addr_type) OWNERSHIP.note_wallet_used(addr_type, account_num) - derive = "m/{mode}h/{coin_type}h/{account}h".format(mode=mode, - account=account_num, coin_type=chain.b44_cointype) + derive = "m/{mode}h/{coin_type}h/{account}h".format( + mode=mode, account=account_num, coin_type=chain.b44_cointype + ) dis.progress_bar_show(0.2) with stash.SensitiveValues() as sv: dis.progress_bar_show(0.3) xpub = chain.serialize_public(sv.derive_path(derive)) dis.progress_bar_show(0.7) - desc = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=addr_type) + + key = Key.from_cc_data(xfp, derive, xpub) + desc = Descriptor(key=key) + desc.set_from_addr_fmt(addr_type) dis.progress_bar_show(0.8) if int_ext: # with <0;1> notation - body = desc.serialize(int_ext=True) + body = desc.to_string() else: # external descriptor # internal descriptor body = "%s\n%s" % ( - desc.serialize(internal=False), - desc.serialize(internal=True), + desc.to_string(internal=False), + desc.to_string(external=False), ) dis.progress_bar_show(1) - await write_text_file(fname_pattern, body, "Descriptor", derive + "/0/0", addr_type) + await write_text_file(fname_pattern, body, "Descriptor", derive + "/0/0", + addr_type, force_prompt=True) # EOF diff --git a/shared/flow.py b/shared/flow.py index e14745a0f..4b1e9488e 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -10,6 +10,7 @@ from choosers import * from mk4 import dev_enable_repl from multisig import make_multisig_menu, import_multisig_nfc +from miniscript import make_miniscript_menu from seed import make_ephemeral_seed_menu, make_seed_vault_menu, start_b39_pw from address_explorer import address_explore from drv_entro import drv_entro_start, password_entry @@ -138,6 +139,8 @@ async def goto_home(*a): MenuItem('Hardware On/Off', menu=HWTogglesMenu), NonDefaultMenuItem('Multisig Wallets', 'multisig', menu=make_multisig_menu, predicate=has_secrets), + NonDefaultMenuItem('Miniscript', 'miniscript', + menu=make_miniscript_menu, predicate=has_secrets), NonDefaultMenuItem('NFC Push Tx', 'ptxurl', menu=pushtx_setup_menu), MenuItem('Display Units', chooser=value_resolution_chooser), MenuItem('Max Network Fee', chooser=max_fee_chooser), @@ -156,7 +159,7 @@ async def goto_home(*a): data or filenames.'''), ToggleMenuItem('Menu Wrapping', 'wa', ['Default Off', 'Enable'], story='''When enabled, allows scrolling past menu top/bottom \ -(wrap around). By default, this is only happens in very large menus.'''), +(wrap around). By default, this only happens in very large menus.'''), ToggleMenuItem('Home Menu XFP', 'hmx', ['Only Tmp', 'Always Show'], story=('Forces display of XFP (seed fingerprint) ' 'at top of main menu. Normally, XFP is shown only when ' @@ -176,6 +179,7 @@ async def goto_home(*a): # xxxxxxxxxxxxxxxx MenuItem("Segwit (BIP-84)", f=export_xpub, arg=84), MenuItem("Classic (BIP-44)", f=export_xpub, arg=44), + MenuItem("Taproot/P2TR(86)", f=export_xpub, arg=86), MenuItem("P2WPKH/P2SH (49)", f=export_xpub, arg=49), MenuItem("Master XPUB", f=export_xpub, arg=0), MenuItem("Current XFP", f=export_xpub, arg=-1), @@ -291,7 +295,7 @@ async def goto_home(*a): "WARNING: Seed Vault is encrypted (AES-256-CTR) by your seed," " but not held directly inside secure elements. Backups are required" " after any change to vault! Recommended for experiments or temporary use."), - predicate=has_se_secrets), + predicate=has_real_secret), MenuItem('Perform Selftest', f=start_selftest), # little harmful MenuItem("Set High-Water", f=set_highwater), MenuItem('Wipe HSM Policy', f=wipe_hsm_policy, predicate=hsm_policy_available), @@ -352,7 +356,7 @@ async def goto_home(*a): MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu, shortcut='x'), # also inside FileMgmt MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp), MenuItem("File Management", menu=FileMgmtMenu), - NonDefaultMenuItem('Secure Notes & Passwords', 'notes', menu=make_notes_menu, + NonDefaultMenuItem('Secure Notes & Passwords', 'secnap', menu=make_notes_menu, predicate=version.has_qwerty), MenuItem('Derive Seed B85' if not version.has_qwerty else 'Derive Seeds (BIP-85)', f=drv_entro_start), @@ -424,7 +428,7 @@ async def goto_home(*a): MenuItem('Start HSM Mode', f=start_hsm_menu_item, predicate=hsm_policy_available), MenuItem("Address Explorer", menu=address_explore, shortcut='x'), MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n', - predicate=lambda: version.has_qwerty and (settings.get("notes", False) != False)), + predicate=lambda: version.has_qwerty and settings.get("secnap", False)), MenuItem('Type Passwords', f=password_entry, shortcut='t', predicate=lambda: settings.get("emu", False) and has_secrets()), MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v', diff --git a/shared/hsm.py b/shared/hsm.py index db538668c..7b9992079 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -4,16 +4,15 @@ # # Unattended signing of transactions and messages, subject to a set of rules. # -import stash, ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu, version -from sffile import SFFile +import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu from utils import problem_file_line, cleanup_deriv_path, match_deriv_path from pincodes import AE_LONG_SECRET_LEN from stash import blank_object from users import Users, MAX_NUMBER_USERS, calc_local_pincode from public_constants import MAX_USERNAME_LEN from multisig import MultisigWallet +from miniscript import MiniScriptWallet from ubinascii import hexlify as b2a_hex -from ubinascii import unhexlify as a2b_hex from uhashlib import sha256 from ucollections import OrderedDict from files import CardSlot, CardMissingError @@ -88,13 +87,13 @@ def pop_list(j, fld_name, cleanup_fcn=None): else: return [] -def pop_deriv_list(j, fld_name, extra_val=None): +def pop_deriv_list(j, fld_name, extra_vals=None): # expect a list of derivation paths, but also 'any' meaning accept all # - maybe also 'p2sh' as special value # - also, path can have n def cu(s): - if s.lower() == 'any': return s.lower() - if extra_val and s.lower() == extra_val: return s.lower() + if extra_vals and s.lower() in extra_vals: + return s.lower() try: return cleanup_deriv_path(s, allow_star=True) except: @@ -195,7 +194,7 @@ class ApprovalRule: # - users: list of authorized users # - min_users: how many of those are needed to approve # - local_conf: local user must also confirm w/ code - # - wallet: which multisig wallet to restrict to, or '1' for single signer only + # - wallet: which multisig/miniscript wallet to restrict to, or '1' for single signer only # - min_pct_self_transfer: minimum percentage of own input value that must go back to self # - patterns: list of transaction patterns to check for. Valid values: # * EQ_NUM_INS_OUTS: the number of inputs and outputs must be equal @@ -212,6 +211,7 @@ def check_user(u): return u self.index = idx+1 + self.ms_type = "multisig" self.per_period = pop_int(j, 'per_period', 0, MAX_SATS) self.max_amount = pop_int(j, 'max_amount', 0, MAX_SATS) self.users = pop_list(j, 'users', check_user) @@ -238,8 +238,11 @@ def check_user(u): # if specified, 'wallet' must be an existing multisig wallet's name if self.wallet and self.wallet != '1': - names = [ms.name for ms in MultisigWallet.get_all()] - assert self.wallet in names, "unknown MS wallet: "+self.wallet + ms_names = [ms.name for ms in MultisigWallet.get_all()] + msc_names = [msc.name for msc in MiniScriptWallet.get_all()] + assert self.wallet in (ms_names+msc_names), "unknown wallet: "+self.wallet + if self.wallet in msc_names: + self.ms_type = "miniscript" # patterns must be valid for p in self.patterns: @@ -283,9 +286,9 @@ def render(n): rv = 'Any amount' if self.wallet == '1': - rv += ' (non multisig)' + rv += ' (singlesig only)' elif self.wallet: - rv += ' from multisig wallet "%s"' % self.wallet + rv += ' from %s wallet "%s"' % (self.ms_type, self.wallet) if self.users: rv += ' may be authorized by ' @@ -328,10 +331,12 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain): # rule limited to one wallet if psbt.active_multisig: # if multisig signing, might need to match specific wallet name - assert self.wallet == psbt.active_multisig.name, 'wrong wallet' + assert self.wallet == psbt.active_multisig.name, 'wrong multisig wallet' + elif psbt.active_miniscript: + assert self.wallet == psbt.active_miniscript.name, 'wrong miniscript wallet' else: # non multisig, but does this rule apply to all wallets or single-singers - assert self.wallet == '1', 'not multisig' + assert self.wallet == '1', 'singlesig only' if self.max_amount is not None: assert total_out <= self.max_amount, 'amount exceeded' @@ -504,9 +509,9 @@ def load(self, j): self.warnings_ok = pop_bool(j, 'warnings_ok') # a list of paths we can accept for signing - self.msg_paths = pop_deriv_list(j, 'msg_paths') - self.share_xpubs = pop_deriv_list(j, 'share_xpubs') - self.share_addrs = pop_deriv_list(j, 'share_addrs', 'p2sh') + self.msg_paths = pop_deriv_list(j, 'msg_paths', ['any']) + self.share_xpubs = pop_deriv_list(j, 'share_xpubs', ['any']) + self.share_addrs = pop_deriv_list(j, 'share_addrs', ['p2sh', 'any', 'msas']) # free text shown at top self.notes = pop_string(j, 'notes', 1, 80) @@ -814,12 +819,15 @@ def approve_xpub_share(self, subpath): return match_deriv_path(self.share_xpubs, subpath) - def approve_address_share(self, subpath=None, is_p2sh=False): + def approve_address_share(self, subpath=None, is_p2sh=False, miniscript=False): # Are we allowing "show address" requests over USB? if not self.share_addrs: return False + if miniscript: + return ('msas' in self.share_addrs) + if is_p2sh: return ('p2sh' in self.share_addrs) @@ -894,6 +902,7 @@ async def approve_transaction(self, psbt, psbt_sha, story): # reject anything with warning, probably if psbt.warnings: + print(psbt.warnings) if self.warnings_ok: log.info("Txn has warnings, but policy is to accept anyway.") else: @@ -994,7 +1003,8 @@ def hsm_status_report(): rv['approval_wait'] = True rv['users'] = Users.list() - rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()] + rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()] \ + + [msc.name for msc in MiniScriptWallet.get_all()] rv['chain'] = settings.get('chain', 'BTC') diff --git a/shared/manifest.py b/shared/manifest.py index df9165ac9..b569eb3b5 100644 --- a/shared/manifest.py +++ b/shared/manifest.py @@ -6,12 +6,14 @@ 'address_explorer.py', 'auth.py', 'backups.py', + 'bsms.py', 'callgate.py', 'chains.py', 'choosers.py', 'compat7z.py', 'countdowns.py', 'descriptor.py', + 'desc_utils.py', 'dev_helper.py', 'display.py', 'drv_entro.py', @@ -26,6 +28,7 @@ 'login.py', 'main.py', 'menu.py', + 'miniscript.py', 'multisig.py', 'numpad.py', 'nvstore.py', diff --git a/shared/miniscript.py b/shared/miniscript.py new file mode 100644 index 000000000..e1f6595d4 --- /dev/null +++ b/shared/miniscript.py @@ -0,0 +1,1906 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# Copyright (c) 2020 Stepan Snigirev MIT License embit/miniscript.py +# +import ngu, ujson, uio, chains, ure, version +from ucollections import OrderedDict +from binascii import unhexlify as a2b_hex +from binascii import hexlify as b2a_hex +from serializations import ser_compact_size, ser_string +from desc_utils import Key, read_until, fill_policy, append_checksum +from public_constants import MAX_TR_SIGNERS +from wallet import BaseStorageWallet +from menu import MenuSystem, MenuItem +from ux import ux_show_story, ux_confirm, ux_dramatic_pause +from files import CardSlot, CardMissingError, needs_microsd +from utils import problem_file_line, xfp2str, addr_fmt_label, truncate_address, to_ascii_printable, swab32 +from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER + + +class MiniscriptException(ValueError): + pass + + +class MiniScriptWallet(BaseStorageWallet): + key_name = "miniscript" + + def __init__(self, desc=None, policy=None, keys=None, key=None, + af=None, name=None, taproot=False, sh=False, wsh=False, + wpkh=False, chain_type=None): + super().__init__(chain_type=chain_type) + self._policy = policy + self._keys = keys + self._key = key + self._af = af + self._taproot = taproot + self._sh = sh + self._wsh = wsh + self._wpkh = wpkh + self._desc = desc + self.name = name + + @property + def policy(self): + if not self._policy: + self._policy = self.desc.storage_policy() + return self._policy + + @property + def keys(self): + if not self._keys: + self._keys = self.desc.keys + if self._keys is not None: + self._keys = [k.to_string() for k in self._keys] + return self._keys + + @property + def key(self): + if not self._key: + self._key = self.desc.key + if self._key is not None: + self._key = self._key.to_string() + return self._key + + @property + def addr_fmt(self): + if not self._af: + self._af = self.desc.addr_fmt + return self._af + + @property + def taproot(self): + if not self._taproot: + self._taproot = self.desc.taproot + return self._taproot + + @property + def sh(self): + if not self._sh: + self._sh = self.desc.sh + return self._sh + + @property + def wsh(self): + if not self._wsh: + self._wsh = self.desc.wsh + return self._wsh + + @property + def wpkh(self): + if not self._wpkh: + self._wpkh = self.desc.wpkh + return self._wpkh + + @property + def desc(self): + if self._desc is None: + from descriptor import Descriptor, Tapscript + + ts = None + ms = None + key = None + if self._key: + key = Key.from_string(self._key) + + filled_policy = fill_policy(self.policy, self.keys) + if self._taproot and self._policy: + # tapscript + ts = Tapscript.read_from(uio.BytesIO(filled_policy)) + elif self._policy: + # miniscript + ms = Miniscript.read_from(uio.BytesIO(filled_policy)) + self._desc = Descriptor(key=key, tapscript=ts, miniscript=ms, + taproot=self._taproot, sh=self._sh, + wsh=self._wsh, wpkh=self._wpkh) + self._desc.set_from_addr_fmt(self._af) + return self._desc + + def to_descriptor(self): + return self.desc + + def serialize(self): + policy = None + key = None + if self.desc.key: + key = self.desc.key.to_string() + + keys = [k.to_string() for k in self.desc.keys] + if self.desc.tapscript or self.desc.miniscript: + policy = self.desc.storage_policy() + + sh = self.desc.sh + wsh = self.desc.wsh + wpkh = self.desc.wpkh + taproot = self.desc.taproot + return ( + self.name, + self.chain_type, + self.desc.addr_fmt, + key, + keys, + policy, + sh, wsh, wpkh, taproot + ) + + @classmethod + def deserialize(cls, c, idx=-1): + name, ct, af, key, keys, policy, sh, wsh, wpkh, taproot = c + rv = cls(name=name, key=key, keys=keys, policy=policy, af=af, + taproot=taproot, sh=sh, wsh=wsh, wpkh=wpkh, + chain_type=ct) + rv.storage_idx = idx + return rv + + def xfp_paths(self): + if self._desc is None: + res = [] + if self._key: + ik = Key.from_string(self.key) + if ik.origin: + res.append(ik.origin.psbt_derivation()) + elif not isinstance(ik.node, bytes): + if ik.is_provably_unspendable: + res.append([swab32(ik.node.my_fp())]) + + for k in self.keys: + k = Key.from_string(k) + if k.origin: + res.append(k.origin.psbt_derivation()) + return res + return self.desc.xfp_paths() + + @classmethod + def find_match(cls, xfp_paths, addr_fmt=None): + for rv in cls.iter_wallets(): + if addr_fmt is not None: + if rv.addr_fmt != addr_fmt: + continue + if rv.matching_subpaths(xfp_paths): + return rv + return None + + def matching_subpaths(self, xfp_paths): + my_xfp_paths = self.xfp_paths() + if len(xfp_paths) != len(my_xfp_paths): + return False + for x in my_xfp_paths: + prefix_len = len(x) + for y in xfp_paths: + if x == y[:prefix_len]: + break + else: + return False + return True + + def subderivation_indexes(self, xfp_paths): + # we already know that they do match + my_xfp_paths = self.desc.xfp_paths() + res = set() + for x in my_xfp_paths: + prefix_len = len(x) + for y in xfp_paths: + if x == y[:prefix_len]: + to_derive = tuple(y[prefix_len:]) + res.add(to_derive) + + assert res + if len(res) == 1: + branch, idx = list(res)[0] + else: + branch = [i[0] for i in res] + indexes = set([i[1] for i in res]) + assert len(indexes) == 1 + idx = list(indexes)[0] + + return branch, idx + + def derive_desc(self, xfp_paths): + branch, idx = self.subderivation_indexes(xfp_paths) + derived_desc = self.desc.derive(branch).derive(idx) + return derived_desc + + def validate_script(self, redeem_script, xfp_paths, script_pubkey=None): + derived_desc = self.derive_desc(xfp_paths) + assert derived_desc.miniscript.compile() == redeem_script, "script mismatch" + if script_pubkey: + assert script_pubkey == derived_desc.script_pubkey(), "spk mismatch" + return derived_desc + + def validate_script_pubkey(self, script_pubkey, xfp_paths, merkle_root=None): + derived_desc = self.derive_desc(xfp_paths) + derived_spk = derived_desc.script_pubkey() + assert derived_spk == script_pubkey, "spk mismatch" + if merkle_root: + assert derived_desc.tapscript.merkle_root == merkle_root, "psbt merkle root" + return derived_desc + + def ux_policy(self): + if self.taproot and self.policy: + return "Tapscript:\n\n" + self.policy + return self.policy + + async def _detail(self, new_wallet=False, is_duplicate=False, short=False): + + s = addr_fmt_label(self.addr_fmt) + "\n\n" + if self.taproot: + s += self.taproot_internal_key_detail(short=short) + + s += self.ux_policy() + + story = s + "\n\nPress (1) to see extended public keys" + if new_wallet and not is_duplicate: + story += ", OK to approve, X to cancel." + return story + + async def show_detail(self, new_wallet=False, duplicates=None, short=False): + title = self.name + story = "" + if duplicates: + title = None + story += "This wallet is a duplicate of already saved wallet %s\n\n" % duplicates[0].name + elif new_wallet: + title = None + story += "Create new miniscript wallet?\n\nWallet Name:\n %s\n\n" % self.name + story += await self._detail(new_wallet, is_duplicate=duplicates, short=short) + while True: + ch = await ux_show_story(story, title=title, escape="1") + if ch == "1": + await self.show_keys() + + elif ch != "y": + return None + else: + return True + + def taproot_internal_key_detail(self, short=False): + if self.taproot: + key = Key.from_string(self.key) + s = "Taproot internal key:\n\n" + if key.is_provably_unspendable: + note = "provably unspendable" + if short: + s += note + else: + if isinstance(key.node, bytes): + s += b2a_hex(key.node).decode() + s += "\n (%s)" % note + else: + s += self.key + if type(key) is Key: + # it is unspendable, BUT not unspend( + s += "\n (%s)" % note + s += "\n\n" + else: + xfp, deriv, xpub = key.to_cc_data() + s += '%s:\n %s\n\n%s/%s\n\n' % (xfp2str(xfp), deriv, xpub, + key.derivation.to_string()) + return s + + async def show_keys(self): + msg = "" + if self.taproot: + msg = self.taproot_internal_key_detail() + msg += "Taproot tree keys:\n\n" + + orig_keys = OrderedDict() + for k in self.keys: + if isinstance(k, str): + k = Key.from_string(k) + if k.origin not in orig_keys: + orig_keys[k.origin] = [] + orig_keys[k.origin].append(k) + + for idx, k_lst in enumerate(orig_keys.values()): + subderiv = True if len(k_lst) == 1 else False + if idx: + msg += '\n---===---\n\n' + + msg += '@%s:\n %s\n\n' % (idx, k_lst[0].to_string(subderiv=subderiv)) + + await ux_show_story(msg) + + @classmethod + def from_file(cls, config, name=None): + from descriptor import Descriptor + if name is None: + desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True) + name = cs + else: + name = to_ascii_printable(name) + desc_obj = Descriptor.from_string(config.strip()) + assert not desc_obj.is_basic_multisig, "Use Settings -> Multisig Wallets" + wal = cls(desc_obj, name=name, chain_type=desc_obj.keys[0].chain_type) + return wal + + def find_duplicates(self): + matches = [] + name_unique = True + for rv in self.iter_wallets(): + if self.name == rv.name: + name_unique = False + if self.key != rv.key: + continue + if self.policy != rv.policy: + continue + if len(self.keys) != len(rv.keys): + continue + if self.keys != rv.keys: + continue + + matches.append(rv) + + return matches, name_unique + + async def confirm_import(self): + nope, yes = (KEY_CANCEL, KEY_ENTER) if version.has_qwerty else ("x", "y") + dups, name_unique = self.find_duplicates() + if not name_unique: + await ux_show_story(title="FAILED", msg=("Miniscript wallet with name '%s'" + " already exists. All wallets MUST" + " have unique names.") % self.name) + return nope + to_save = await self.show_detail(new_wallet=True, duplicates=dups) + + ch = yes if to_save else nope + if to_save and not dups: + assert self.storage_idx == -1 + self.commit() + await ux_dramatic_pause("Saved.", 2) + + return ch + + def yield_addresses(self, start_idx, count, change=False, scripts=True, change_idx=0): + ch = chains.current_chain() + dd = self.desc.derive(None, change=change) + idx = start_idx + while count: + # make the redeem script, convert into address + d = dd.derive(idx) + addr = ch.render_address(d.script_pubkey()) + + script = "" + if scripts: + if d.tapscript: + script = d.tapscript.script_tree(d.tapscript.tree) + else: + script = b2a_hex(ser_string(d.miniscript.compile())).decode() + + if d.tapscript: + yield (idx, + addr, + ["[%s]" % str(k.origin) for k in d.keys], + script, + d.key.serialize(), + str(d.key.origin) if d.key.origin else "") + else: + yield (idx, + addr, + ["[%s]" % str(k.origin) for k in d.keys], + script, + None, + None) + + idx += 1 + count -= 1 + + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis + + addrs = [] + + for idx, addr, paths, _, ik, _ in self.yield_addresses(start, n, + change=bool(change), + scripts=False): + if idx == 0 and len(paths) <= 4 and not ik: + msg += '\n'.join(paths) + '\n =>\n' + else: + change_idx = set([int(p.split("/")[-2]) for p in paths]) + if len(change_idx) == 1: + msg += '.../%d/%d =>\n' % (list(change_idx)[0], idx) + else: + msg += '.../%d =>\n' % idx + + addrs.append(addr) + msg += truncate_address(addr) + '\n\n' + dis.progress_sofar(idx - start + 1, n) + + return msg, addrs + + def generate_address_csv(self, start, n, change): + part = [] + if self.taproot: + scr_h = "Taptree" + if self.desc.key.is_provably_unspendable: + part = ["Unspendable Internal Key"] + else: + part = ["Internal Key"] + + else: + scr_h = "Script" + + yield '"' + '","'.join( + ['Index', 'Payment Address', scr_h] + ['Derivation'] * len(self.keys) + + part + ) + '"\n' + for (idx, addr, derivs, script, ik, ikp) in self.yield_addresses(start, n, + change=bool(change)): + ln = '%d,"%s","%s","' % (idx, addr, script) + ln += '","'.join(derivs) + if ik: + # internal xonly key with its derivation (if any) + if ikp: + ln += '","[%s]%s' % (ikp, b2a_hex(ik).decode()) + else: + ln += '","%s' % (b2a_hex(ik).decode()) + ln += '"\n' + + yield ln + + def bitcoin_core_serialize(self): + # this will become legacy one day + # instead use <0;1> descriptor format + res = [] + for external in (True, False): + desc_obj = { + "desc": self.to_string(external, not external, unspend_compat=True), + "active": True, + "timestamp": "now", + "internal": not external, + "range": [0, 100], + } + res.append(desc_obj) + return res + + def to_string(self, external=True, internal=True, checksum=True, unspend_compat=False): + if self._key: + key = self._key + if "unspend(" in key and unspend_compat: + # for bitcoin core that does not support 'unspend(' descriptor notation + # serialize 'unspend(' as classic extended key + k = Key.from_string(self.key) + key = k.extended_public_key() + if k.derivation: + key += "/" + k.derivation.to_string(external, internal) + + multipath_rgx = ure.compile(r"<\d+;\d+>") + match = multipath_rgx.search(key) + if match: + mp = match.group(0) + ext, int = mp[1:-1].split(";") + if internal != external: + to_replace = ext if external else int + key = self._key.replace(mp, to_replace) + if self._taproot: + desc = "tr(%s" % key + if self.policy: + desc += "," + tree = fill_policy(self._policy, self._keys, + external, internal) + desc += tree + + res = desc + ")" + + elif self._policy: + res = fill_policy(self._policy, self._keys, + external, internal) + if self._wsh: + res = "wsh(%s)" % res + else: + if self._wpkh: + res = "wpkh(%s)" % self._key + else: + res = "pkh(%s)" % self._key + + if self._sh: + res = "sh(%s)" % res + + if checksum: + res = append_checksum(res) + return res + + async def export_wallet_file(self, mode="exported from", extra_msg=None, descriptor=False, + core=False, desc_pretty=True): + from glob import NFC, dis + from ux import import_export_prompt + + if core: + name = "Bitcoin Core miniscript" + fname_pattern = 'bitcoin-core-%s' % self.name + else: + name = "Miniscript" + fname_pattern = 'minsc-%s' % self.name + + fname_pattern = fname_pattern + ".txt" + + if core: + msg = "importdescriptors cmd" + dis.fullscreen('Wait...') + core_obj = self.bitcoin_core_serialize() + core_str = ujson.dumps(core_obj) + res = "importdescriptors '%s'\n" % core_str + # elif desc_pretty: + # pass TODO + else: + msg = self.name + int_ext = True + ch = await ux_show_story( + "To export receiving and change descriptors in one descriptor (<0;1> notation) press OK, " + "press (1) to export receiving and change descriptors separately.", escape='1') + if ch == "1": + int_ext = False + elif ch != "y": + return + + dis.fullscreen('Wait...') + if int_ext: + res = self.to_string() + else: + res = "%s\n%s" % ( + self.to_string(internal=False), + self.to_string(external=False), + ) + + ch = await import_export_prompt("%s file" % name) + if isinstance(ch, str): + if ch in "3"+KEY_NFC: + await NFC.share_text(res) + elif ch == KEY_QR: + try: + from ux import show_qr_code + await show_qr_code(res, msg=msg) + except: + if version.has_qwerty: + from ux_q1 import show_bbqr_codes + await show_bbqr_codes('U', res, msg) + return + + try: + with CardSlot(**ch) as card: + fname, nice = card.pick_filename(fname_pattern) + + # do actual write + with open(fname, 'w+') as fp: + fp.write(res) + # fp.seek(0) + # contents = fp.read() + # TODO re-enable once we know how to proceed with regards to with which key to sign + # from auth import write_sig_file + # h = ngu.hash.sha256s(contents.encode()) + # sig_nice = write_sig_file([(h, fname)]) + + msg = '%s file written:\n\n%s' % (name, nice) + # msg += '\n\nColdcard multisig signature file written:\n\n%s' % sig_nice + if extra_msg: + msg += extra_msg + + await ux_show_story(msg) + + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e))) + return + +async def no_miniscript_yet(*a): + await ux_show_story("You don't have any miniscript wallets yet.") + +async def miniscript_delete(msc): + if not await ux_confirm("Delete miniscript wallet '%s'?\n\nFunds may be impacted." % msc.name): + await ux_dramatic_pause('Aborted.', 3) + return + + msc.delete() + await ux_dramatic_pause('Deleted.', 3) + +async def miniscript_wallet_delete(menu, label, item): + msc = item.arg + + await miniscript_delete(msc) + + from ux import the_ux + # pop stack + the_ux.pop() + + m = the_ux.top_of_stack() + m.update_contents() + +async def miniscript_wallet_detail(menu, label, item): + # show details of single multisig wallet + + msc = item.arg + + return await msc.show_detail(short=True) + +async def import_miniscript(*a): + # pick text file from SD card, import as multisig setup file + from actions import file_picker + from glob import dis + from ux import import_export_prompt + + ch = await import_export_prompt("miniscript wallet file", is_import=True) + if isinstance(ch, str): + if ch == KEY_QR: + await import_miniscript_qr() + elif ch == KEY_NFC: + await import_miniscript_nfc() + return + + def possible(filename): + with open(filename, 'rt') as fd: + for ln in fd: + if "sh(" in ln or "wsh(" in ln or "tr(" in ln: + # descriptor import + return True + + fn = await file_picker(suffix=['.txt', '.json'], min_size=100, + taster=possible, **ch) + if not fn: return + + try: + with CardSlot(**ch) as card: + with open(fn, 'rt') as fp: + data = fp.read() + except CardMissingError: + await needs_microsd() + return + + from auth import maybe_enroll_xpub + try: + possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None + maybe_enroll_xpub(config=data, name=possible_name, miniscript=True) + except BaseException as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + +async def import_miniscript_nfc(*a): + from glob import NFC + try: + return await NFC.import_miniscript_nfc() + except Exception as e: + await ux_show_story(title="ERROR", msg="Failed to import miniscript. %s" % str(e)) + +async def import_miniscript_qr(*a): + from auth import maybe_enroll_xpub + from ux_q1 import QRScannerInteraction + data = await QRScannerInteraction().scan_text('Scan Miniscript from a QR code') + if not data: + # press pressed CANCEL + return + + try: + maybe_enroll_xpub(config=data, miniscript=True) + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + +async def miniscript_wallet_export(menu, label, item): + # create a text file with the details; ready for import to next Coldcard + msc = item.arg[0] + kwargs = item.arg[1] + await msc.export_wallet_file(**kwargs) + +async def make_miniscript_wallet_descriptor_menu(menu, label, item): + # descriptor menu + msc = item.arg + if not msc: + return + + rv = [ + MenuItem('Export', f=miniscript_wallet_export, arg=(msc, {"core": False})), + MenuItem('Bitcoin Core', f=miniscript_wallet_export, arg=(msc, {"core": True})), + ] + return rv + +async def make_miniscript_wallet_menu(menu, label, item): + # details, actions on single multisig wallet + msc = MiniScriptWallet.get_by_idx(item.arg) + if not msc: return + + rv = [ + MenuItem('"%s"' % msc.name, f=miniscript_wallet_detail, arg=msc), + MenuItem('View Details', f=miniscript_wallet_detail, arg=msc), + MenuItem('Delete', f=miniscript_wallet_delete, arg=msc), + MenuItem('Descriptors', menu=make_miniscript_wallet_descriptor_menu, arg=msc), + ] + return rv + + +class MiniscriptMenu(MenuSystem): + @classmethod + def construct(cls): + import version + from menu import ShortcutItem + + exists, exists_other_chain = MiniScriptWallet.exists() + if not exists: + rv = [MenuItem(MiniScriptWallet.none_setup_yet(exists_other_chain), f=no_miniscript_yet)] + else: + rv = [] + for msc in MiniScriptWallet.get_all(): + rv.append(MenuItem('%s' % msc.name, + menu=make_miniscript_wallet_menu, + arg=msc.storage_idx)) + from glob import NFC + rv.append(MenuItem('Import', f=import_miniscript)) + rv.append(ShortcutItem(KEY_NFC, predicate=lambda: NFC is not None, + f=import_miniscript_nfc)) + rv.append(ShortcutItem(KEY_QR, predicate=lambda: version.has_qwerty, + f=import_miniscript_qr)) + return rv + + def update_contents(self): + # Reconstruct the list of wallets on this dynamic menu, because + # we added or changed them and are showing that same menu again. + tmp = self.construct() + self.replace_items(tmp) + +async def make_miniscript_menu(*a): + # list of all multisig wallets, and high-level settings/actions + from pincodes import pa + + if pa.is_secret_blank(): + await ux_show_story("You must have wallet seed before creating miniscript wallets.") + return + + rv = MiniscriptMenu.construct() + return MiniscriptMenu(rv) + + +class Number: + def __init__(self, num): + self.num = num + + @classmethod + def read_from(cls, s, taproot=False): + num = 0 + char = s.read(1) + while char in b"0123456789": + num = 10 * num + int(char.decode()) + char = s.read(1) + s.seek(-1, 1) + return cls(num) + + def compile(self): + if self.num == 0: + return b"\x00" + if self.num <= 16: + return bytes([80 + self.num]) + b = self.num.to_bytes(32, "little").rstrip(b"\x00") + if b[-1] >= 128: + b += b"\x00" + return bytes([len(b)]) + b + + def __len__(self): + return len(self.compile()) + + def to_string(self, *args, **kwargs): + return "%d" % self.num + + +class KeyHash(Key): + @classmethod + def parse_key(cls, k: bytes, *args, **kwargs): + # convert to string + kd = k.decode() + # raw 20-byte hash + if len(kd) == 40: + return kd, None + return super().parse_key(k, *args, **kwargs) + + def serialize(self, *args, **kwargs): + if self.taproot: + return ngu.hash.hash160(self.node.pubkey()[1:33]) + return ngu.hash.hash160(self.node.pubkey()) + + def __len__(self): + return 21 # <20:pkh> + + def compile(self): + d = self.serialize() + return ser_compact_size(len(d)) + d + + +class Raw: + def __init__(self, raw): + if len(raw) != self.LEN * 2: + raise ValueError("Invalid raw element length: %d" % len(raw)) + self.raw = a2b_hex(raw) + + @classmethod + def read_from(cls, s, taproot=False): + return cls(s.read(2 * cls.LEN).decode()) + + def to_string(self, *args, **kwargs): + return b2a_hex(self.raw).decode() + + def compile(self): + return ser_compact_size(len(self.raw)) + self.raw + + def __len__(self): + return len(ser_compact_size(self.LEN)) + self.LEN + + +class Raw32(Raw): + LEN = 32 + def __len__(self): + return 33 + + +class Raw20(Raw): + LEN = 20 + def __len__(self): + return 21 + + +class Miniscript: + def __init__(self, *args, **kwargs): + self.args = args + self.taproot = kwargs.get("taproot", False) + + def compile(self): + return self.inner_compile() + + def verify(self): + for arg in self.args: + if isinstance(arg, Miniscript): + arg.verify() + + @property + def keys(self): + return sum( + [arg.keys for arg in self.args if isinstance(arg, Miniscript)], + [k for k in self.args if isinstance(k, Key) or isinstance(k, KeyHash)], + ) + + def is_sane(self, taproot=False): + err = "multi mixin" + # cannot have same keys in single miniscript + forbiden = (Sortedmulti_a, Multi_a) + keys = self.keys + # provably unspendable taproot internal key is not covered here + # all other keys (miniscript,tapscript) require key origin info + assert all(k.origin for k in keys), "Key origin info is required" + assert len(keys) == len(set(keys)), "Insane" + if taproot: + forbiden = (Sortedmulti, Multi) + + assert type(self) not in forbiden, err + + for arg in self.args: + assert type(arg) not in forbiden, err + if isinstance(arg, Miniscript): + arg.is_sane(taproot=taproot) + + @staticmethod + def key_derive(key, idx, key_map=None, change=False): + if key_map and key in key_map: + kd = key_map[key] + else: + kd = key.derive(idx, change=change) + return kd + + def derive(self, idx, key_map=None, change=False): + args = [] + for arg in self.args: + if hasattr(arg, "derive"): + if isinstance(arg, Key) or isinstance(arg, KeyHash): + arg = self.key_derive(arg, idx, key_map, change=change) + else: + arg = arg.derive(idx, change=change) + + args.append(arg) + return type(self)(*args) + + @property + def properties(self): + return self.PROPS + + @property + def type(self): + return self.TYPE + + @classmethod + def read_from(cls, s, taproot=False): + op, char = read_until(s, b"(") + op = op.decode() + wrappers = "" + if ":" in op: + wrappers, op = op.split(":") + if char != b"(": + raise MiniscriptException("Missing operator") + if op not in OPERATOR_NAMES: + raise MiniscriptException("Unknown operator '%s'" % op) + # number of arguments, classes of arguments, compile function, type, validity checker + MiniscriptCls = OPERATORS[OPERATOR_NAMES.index(op)] + args = MiniscriptCls.read_arguments(s, taproot=taproot) + miniscript = MiniscriptCls(*args, taproot=taproot) + for w in reversed(wrappers): + if w not in WRAPPER_NAMES: + raise MiniscriptException("Unknown wrapper %s" % w) + WrapperCls = WRAPPERS[WRAPPER_NAMES.index(w)] + miniscript = WrapperCls(miniscript, taproot=taproot) + return miniscript + + @classmethod + def read_arguments(cls, s, taproot=False): + args = [] + if cls.NARGS is None: + if type(cls.ARGCLS) == tuple: + firstcls, nextcls = cls.ARGCLS + else: + firstcls, nextcls = cls.ARGCLS, cls.ARGCLS + + args.append(firstcls.read_from(s, taproot=taproot)) + while True: + char = s.read(1) + if char == b",": + args.append(nextcls.read_from(s, taproot=taproot)) + elif char == b")": + break + else: + raise MiniscriptException( + "Expected , or ), got: %s" % (char + s.read()) + ) + else: + for i in range(cls.NARGS): + args.append(cls.ARGCLS.read_from(s, taproot=taproot)) + if i < cls.NARGS - 1: + char = s.read(1) + if char != b",": + raise MiniscriptException("Missing arguments, %s" % char) + char = s.read(1) + if char != b")": + raise MiniscriptException("Expected ) got %s" % (char + s.read())) + return args + + def to_string(self, external=True, internal=True): + # meh + res = type(self).NAME + "(" + res += ",".join([ + arg.to_string(external, internal) + for arg in self.args + ]) + res += ")" + return res + + def __len__(self): + """Length of the compiled script, override this if you know the length""" + return len(self.compile()) + + def len_args(self): + return sum([len(arg) for arg in self.args]) + +########### Known fragments (miniscript operators) ############## + + +class OneArg(Miniscript): + NARGS = 1 + # small handy functions + @property + def arg(self): + return self.args[0] + + @property + def carg(self): + return self.arg.compile() + + +class PkK(OneArg): + # + NAME = "pk_k" + ARGCLS = Key + TYPE = "K" + PROPS = "ondu" + + def inner_compile(self): + return self.carg + + def __len__(self): + return self.len_args() + + +class PkH(OneArg): + # DUP HASH160 EQUALVERIFY + NAME = "pk_h" + ARGCLS = KeyHash + TYPE = "K" + PROPS = "ndu" + + def inner_compile(self): + return b"\x76\xa9" + self.carg + b"\x88" + + def __len__(self): + return self.len_args() + 3 + +class Older(OneArg): + # CHECKSEQUENCEVERIFY + NAME = "older" + ARGCLS = Number + TYPE = "B" + PROPS = "z" + + def inner_compile(self): + return self.carg + b"\xb2" + + def verify(self): + super().verify() + if (self.arg.num < 1) or (self.arg.num >= 0x80000000): + raise MiniscriptException( + "%s should have an argument in range [1, 0x80000000)" % self.NAME + ) + + def __len__(self): + return self.len_args() + 1 + +class After(Older): + # CHECKLOCKTIMEVERIFY + NAME = "after" + + def inner_compile(self): + return self.carg + b"\xb1" + + +class Sha256(OneArg): + # SIZE <32> EQUALVERIFY SHA256 EQUAL + NAME = "sha256" + ARGCLS = Raw32 + TYPE = "B" + PROPS = "ondu" + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xa8" + self.carg + b"\x87" + + def __len__(self): + return self.len_args() + 6 + +class Hash256(Sha256): + # SIZE <32> EQUALVERIFY HASH256 EQUAL + NAME = "hash256" + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xaa" + self.carg + b"\x87" + + +class Ripemd160(Sha256): + # SIZE <32> EQUALVERIFY RIPEMD160 EQUAL + NAME = "ripemd160" + ARGCLS = Raw20 + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xa6" + self.carg + b"\x87" + + +class Hash160(Ripemd160): + # SIZE <32> EQUALVERIFY HASH160 EQUAL + NAME = "hash160" + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xa9" + self.carg + b"\x87" + + +class AndOr(Miniscript): + # [X] NOTIF [Z] ELSE [Y] ENDIF + NAME = "andor" + NARGS = 3 + ARGCLS = Miniscript + + @property + def type(self): + # type same as Y/Z + return self.args[1].type + + def verify(self): + # requires: X is Bdu; Y and Z are both B, K, or V + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("andor: X should be 'B'") + px = self.args[0].properties + if "d" not in px and "u" not in px: + raise MiniscriptException("andor: X should be 'du'") + if self.args[1].type != self.args[2].type: + raise MiniscriptException("andor: Y and Z should have the same types") + if self.args[1].type not in "BKV": + raise MiniscriptException("andor: Y and Z should be B K or V") + + @property + def properties(self): + # props: z=zXzYzZ; o=zXoYoZ or oXzYzZ; u=uYuZ; d=dZ + props = "" + px, py, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in py and "z" in pz: + props += "z" + if ("z" in px and "o" in py and "o" in pz) or ( + "o" in px and "z" in py and "z" in pz + ): + props += "o" + if "u" in py and "u" in pz: + props += "u" + if "d" in pz: + props += "d" + return props + + def inner_compile(self): + return ( + self.args[0].compile() + + b"\x64" + + self.args[2].compile() + + b"\x67" + + self.args[1].compile() + + b"\x68" + ) + + def __len__(self): + return self.len_args() + 3 + +class AndV(Miniscript): + # [X] [Y] + NAME = "and_v" + NARGS = 2 + ARGCLS = Miniscript + + def inner_compile(self): + return self.args[0].compile() + self.args[1].compile() + + def __len__(self): + return self.len_args() + + def verify(self): + # X is V; Y is B, K, or V + super().verify() + if self.args[0].type != "V": + raise MiniscriptException("and_v: X should be 'V'") + if self.args[1].type not in "BKV": + raise MiniscriptException("and_v: Y should be B K or V") + + @property + def type(self): + # same as Y + return self.args[1].type + + @property + def properties(self): + # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; u=uY + px, py = [arg.properties for arg in self.args] + props = "" + if "z" in px and "z" in py: + props += "z" + if ("z" in px and "o" in py) or ("z" in py and "o" in px): + props += "o" + if "n" in px or ("z" in px and "n" in py): + props += "n" + if "u" in py: + props += "u" + return props + + +class AndB(Miniscript): + # [X] [Y] BOOLAND + NAME = "and_b" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "B" + + def inner_compile(self): + return self.args[0].compile() + self.args[1].compile() + b"\x9a" + + def __len__(self): + return self.len_args() + 1 + + def verify(self): + # X is B; Y is W + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("and_b: X should be B") + if self.args[1].type != "W": + raise MiniscriptException("and_b: Y should be W") + + @property + def properties(self): + # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; d=dXdY; u + px, py = [arg.properties for arg in self.args] + props = "" + if "z" in px and "z" in py: + props += "z" + if ("z" in px and "o" in py) or ("z" in py and "o" in px): + props += "o" + if "n" in px or ("z" in px and "n" in py): + props += "n" + if "d" in px and "d" in py: + props += "d" + props += "u" + return props + + +class AndN(Miniscript): + # [X] NOTIF 0 ELSE [Y] ENDIF + # andor(X,Y,0) + NAME = "and_n" + NARGS = 2 + ARGCLS = Miniscript + + def inner_compile(self): + return ( + self.args[0].compile() + + b"\x64" + + Number(0).compile() + + b"\x67" + + self.args[1].compile() + + b"\x68" + ) + + def __len__(self): + return self.len_args() + 4 + + @property + def type(self): + # type same as Y/Z + return self.args[1].type + + def verify(self): + # requires: X is Bdu; Y and Z are both B, K, or V + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("and_n: X should be 'B'") + px = self.args[0].properties + if "d" not in px and "u" not in px: + raise MiniscriptException("and_n: X should be 'du'") + if self.args[1].type != "B": + raise MiniscriptException("and_n: Y should be B") + + @property + def properties(self): + # props: z=zXzYzZ; o=zXoYoZ or oXzYzZ; u=uYuZ; d=dZ + props = "" + px, py = [arg.properties for arg in self.args] + pz = "zud" + if "z" in px and "z" in py and "z" in pz: + props += "z" + if ("z" in px and "o" in py and "o" in pz) or ( + "o" in px and "z" in py and "z" in pz + ): + props += "o" + if "u" in py and "u" in pz: + props += "u" + if "d" in pz: + props += "d" + return props + + +class OrB(Miniscript): + # [X] [Z] BOOLOR + NAME = "or_b" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "B" + + def inner_compile(self): + return self.args[0].compile() + self.args[1].compile() + b"\x9b" + + def __len__(self): + return self.len_args() + 1 + + def verify(self): + # X is Bd; Z is Wd + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("or_b: X should be B") + if "d" not in self.args[0].properties: + raise MiniscriptException("or_b: X should be d") + if self.args[1].type != "W": + raise MiniscriptException("or_b: Z should be W") + if "d" not in self.args[1].properties: + raise MiniscriptException("or_b: Z should be d") + + @property + def properties(self): + # z=zXzZ; o=zXoZ or zZoX; d; u + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "z" + if ("z" in px and "o" in pz) or ("z" in pz and "o" in px): + props += "o" + props += "du" + return props + + +class OrC(Miniscript): + # [X] NOTIF [Z] ENDIF + NAME = "or_c" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "V" + + def inner_compile(self): + return self.args[0].compile() + b"\x64" + self.args[1].compile() + b"\x68" + + def __len__(self): + return self.len_args() + 2 + + def verify(self): + # X is Bdu; Z is V + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("or_c: X should be B") + if self.args[1].type != "V": + raise MiniscriptException("or_c: Z should be V") + px = self.args[0].properties + if "d" not in px or "u" not in px: + raise MiniscriptException("or_c: X should be du") + + @property + def properties(self): + # z=zXzZ; o=oXzZ + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "z" + if "o" in px and "z" in pz: + props += "o" + return props + + +class OrD(Miniscript): + # [X] IFDUP NOTIF [Z] ENDIF + NAME = "or_d" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "B" + + def inner_compile(self): + return self.args[0].compile() + b"\x73\x64" + self.args[1].compile() + b"\x68" + + def __len__(self): + return self.len_args() + 3 + + def verify(self): + # X is Bdu; Z is B + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("or_d: X should be B") + if self.args[1].type != "B": + raise MiniscriptException("or_d: Z should be B") + px = self.args[0].properties + if "d" not in px or "u" not in px: + raise MiniscriptException("or_d: X should be du") + + @property + def properties(self): + # z=zXzZ; o=oXzZ; d=dZ; u=uZ + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "z" + if "o" in px and "z" in pz: + props += "o" + if "d" in pz: + props += "d" + if "u" in pz: + props += "u" + return props + + +class OrI(Miniscript): + # IF [X] ELSE [Z] ENDIF + NAME = "or_i" + NARGS = 2 + ARGCLS = Miniscript + + def inner_compile(self): + return ( + b"\x63" + + self.args[0].compile() + + b"\x67" + + self.args[1].compile() + + b"\x68" + ) + + def __len__(self): + return self.len_args() + 3 + + def verify(self): + # both are B, K, or V + super().verify() + if self.args[0].type != self.args[1].type: + raise MiniscriptException("or_i: X and Z should be the same type") + if self.args[0].type not in "BKV": + raise MiniscriptException("or_i: X and Z should be B K or V") + + @property + def type(self): + return self.args[0].type + + @property + def properties(self): + # o=zXzZ; u=uXuZ; d=dX or dZ + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "o" + if "u" in px and "u" in pz: + props += "u" + if "d" in px or "d" in pz: + props += "d" + return props + + +class Thresh(Miniscript): + # [X1] [X2] ADD ... [Xn] ADD ... EQUAL + NAME = "thresh" + NARGS = None + ARGCLS = (Number, Miniscript) + TYPE = "B" + + def inner_compile(self): + return ( + self.args[1].compile() + + b"".join([arg.compile()+b"\x93" for arg in self.args[2:]]) + + self.args[0].compile() + + b"\x87" + ) + + def __len__(self): + return self.len_args() + len(self.args) - 1 + + def verify(self): + # 1 <= k <= n; X1 is Bdu; others are Wdu + super().verify() + if self.args[0].num < 1 or self.args[0].num >= len(self.args): + raise MiniscriptException( + "thresh: Invalid k! Should be 1 <= k <= %d, got %d" + % (len(self.args) - 1, self.args[0].num) + ) + if self.args[1].type != "B": + raise MiniscriptException("thresh: X1 should be B") + px = self.args[1].properties + if "d" not in px or "u" not in px: + raise MiniscriptException("thresh: X1 should be du") + for i, arg in enumerate(self.args[2:]): + if arg.type != "W": + raise MiniscriptException("thresh: X%d should be W" % (i + 1)) + p = arg.properties + if "d" not in p or "u" not in p: + raise MiniscriptException("thresh: X%d should be du" % (i + 1)) + + @property + def properties(self): + # z=all are z; o=all are z except one is o; d; u + props = "" + parr = [arg.properties for arg in self.args[1:]] + zarr = ["z" for p in parr if "z" in p] + if len(zarr) == len(parr): + props += "z" + noz = [p for p in parr if "z" not in p] + if len(noz) == 1 and "o" in noz[0]: + props += "o" + props += "du" + return props + + +class Multi(Miniscript): + # ... CHECKMULTISIG + NAME = "multi" + NARGS = None + ARGCLS = (Number, Key) + TYPE = "B" + PROPS = "ndu" + N_MAX = 20 + + def inner_compile(self): + return ( + b"".join([arg.compile() for arg in self.args]) + + Number(len(self.args) - 1).compile() + + b"\xae" + ) + + def __len__(self): + return self.len_args() + 2 + + def m_n(self): + return self.args[0].num, len(self.args[1:]) + + def verify(self): + super().verify() + N = (len(self.args) - 1) + assert N <= self.N_MAX, 'M/N range' + M = self.args[0].num + if M < 1 or M > N: + raise ValueError( + "M must be <= N: 1 <= M <= %d, got %d" % ((len(self.args) - 1), self.args[0].num) + ) + + +class Sortedmulti(Multi): + # ... CHECKMULTISIG + NAME = "sortedmulti" + + def inner_compile(self): + return ( + self.args[0].compile() + + b"".join(sorted([arg.compile() for arg in self.args[1:]])) + + Number(len(self.args) - 1).compile() + + b"\xae" + ) + +class Multi_a(Multi): + # CHECKSIG CHECKSIGADD ... CHECKSIGADD EQUALVERIFY + NAME = "multi_a" + PROPS = "du" + N_MAX = MAX_TR_SIGNERS + + def inner_compile(self): + from opcodes import OP_CHECKSIGADD, OP_NUMEQUAL, OP_CHECKSIG + script = b"" + for i, key in enumerate(self.args[1:]): + script += key.compile() + if i == 0: + script += bytes([OP_CHECKSIG]) + else: + script += bytes([OP_CHECKSIGADD]) + script += self.args[0].compile() # M (threshold) + script += bytes([OP_NUMEQUAL]) + return script + + def __len__(self): + # len(M) + len(k0) ... + len(kN) + len(keys) + 1 + return self.len_args() + len(self.args) + + +class Sortedmulti_a(Multi_a): + # CHECKSIG CHECKSIGADD ... CHECKSIGADD EQUALVERIFY + NAME = "sortedmulti_a" + + def inner_compile(self): + from opcodes import OP_CHECKSIGADD, OP_NUMEQUAL, OP_CHECKSIG + script = b"" + for i, key in enumerate(sorted([arg.compile() for arg in self.args[1:]])): + script += key + if i == 0: + script += bytes([OP_CHECKSIG]) + else: + script += bytes([OP_CHECKSIGADD]) + script += self.args[0].compile() # M (threshold) + script += bytes([OP_NUMEQUAL]) + return script + + +class Pk(OneArg): + # CHECKSIG + NAME = "pk" + ARGCLS = Key + TYPE = "B" + PROPS = "ondu" + + def inner_compile(self): + return self.carg + b"\xac" + + def __len__(self): + return self.len_args() + 1 + + +class Pkh(OneArg): + # DUP HASH160 EQUALVERIFY CHECKSIG + NAME = "pkh" + ARGCLS = KeyHash + TYPE = "B" + PROPS = "ndu" + + def inner_compile(self): + return b"\x76\xa9" + self.carg + b"\x88\xac" + + def __len__(self): + return self.len_args() + 4 + + +OPERATORS = [ + PkK, + PkH, + Older, + After, + Sha256, + Hash256, + Ripemd160, + Hash160, + AndOr, + AndV, + AndB, + AndN, + OrB, + OrC, + OrD, + OrI, + Thresh, + Multi, + Sortedmulti, + Multi_a, + Sortedmulti_a, + Pk, + Pkh, +] +OPERATOR_NAMES = [cls.NAME for cls in OPERATORS] + + +class Wrapper(OneArg): + ARGCLS = Miniscript + + @property + def op(self): + return type(self).__name__.lower() + + def to_string(self, *args, **kwargs): + # more wrappers follow + if isinstance(self.arg, Wrapper): + return self.op + self.arg.to_string(*args, **kwargs) + # we are the last wrapper + return self.op + ":" + self.arg.to_string(*args, **kwargs) + + +class A(Wrapper): + # TOALTSTACK [X] FROMALTSTACK + TYPE = "W" + + def inner_compile(self): + return b"\x6b" + self.carg + b"\x6c" + + def __len__(self): + return len(self.arg) + 2 + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("a: X should be B") + + @property + def properties(self): + props = "" + px = self.arg.properties + if "d" in px: + props += "d" + if "u" in px: + props += "u" + return props + + +class S(Wrapper): + # SWAP [X] + TYPE = "W" + + def inner_compile(self): + return b"\x7c" + self.carg + + def __len__(self): + return len(self.arg) + 1 + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("s: X should be B") + if "o" not in self.arg.properties: + raise MiniscriptException("s: X should be o") + + @property + def properties(self): + props = "" + px = self.arg.properties + if "d" in px: + props += "d" + if "u" in px: + props += "u" + return props + + +class C(Wrapper): + # [X] CHECKSIG + TYPE = "B" + + def inner_compile(self): + return self.carg + b"\xac" + + def __len__(self): + return len(self.arg) + 1 + + def verify(self): + super().verify() + if self.arg.type != "K": + raise MiniscriptException("c: X should be K") + + @property + def properties(self): + props = "" + px = self.arg.properties + for p in ["o", "n", "d"]: + if p in px: + props += p + props += "u" + return props + + +class T(Wrapper): + # [X] 1 + TYPE = "B" + + def inner_compile(self): + return self.carg + Number(1).compile() + + def __len__(self): + return len(self.arg) + 1 + + @property + def properties(self): + # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; u=uY + px = self.arg.properties + py = "zu" + props = "" + if "z" in px and "z" in py: + props += "z" + if ("z" in px and "o" in py) or ("z" in py and "o" in px): + props += "o" + if "n" in px or ("z" in px and "n" in py): + props += "n" + if "u" in py: + props += "u" + return props + + +class D(Wrapper): + # DUP IF [X] ENDIF + TYPE = "B" + + def inner_compile(self): + return b"\x76\x63" + self.carg + b"\x68" + + def __len__(self): + return len(self.arg) + 3 + + def verify(self): + super().verify() + if self.arg.type != "V": + raise MiniscriptException("d: X should be V") + if "z" not in self.arg.properties: + raise MiniscriptException("d: X should be z") + + @property + def properties(self): + # https://github.com/bitcoin/bitcoin/pull/24906 + if self.taproot: + props = "ndu" + else: + props = "nd" + px = self.arg.properties + if "z" in px: + props += "o" + return props + + +class V(Wrapper): + # [X] VERIFY (or VERIFY version of last opcode in [X]) + TYPE = "V" + + def inner_compile(self): + """Checks last check code and makes it verify""" + if self.carg[-1] in [0xAC, 0xAE, 0x9C, 0x87]: + return self.carg[:-1] + bytes([self.carg[-1] + 1]) + return self.carg + b"\x69" + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("v: X should be B") + + @property + def properties(self): + props = "" + px = self.arg.properties + for p in ["z", "o", "n"]: + if p in px: + props += p + return props + + +class J(Wrapper): + # SIZE 0NOTEQUAL IF [X] ENDIF + TYPE = "B" + + def inner_compile(self): + return b"\x82\x92\x63" + self.carg + b"\x68" + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("j: X should be B") + if "n" not in self.arg.properties: + raise MiniscriptException("j: X should be n") + + @property + def properties(self): + props = "nd" + px = self.arg.properties + for p in ["o", "u"]: + if p in px: + props += p + return props + + +class N(Wrapper): + # [X] 0NOTEQUAL + TYPE = "B" + + def inner_compile(self): + return self.carg + b"\x92" + + def __len__(self): + return len(self.arg) + 1 + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("n: X should be B") + + @property + def properties(self): + props = "u" + px = self.arg.properties + for p in ["z", "o", "n", "d"]: + if p in px: + props += p + return props + + +class L(Wrapper): + # IF 0 ELSE [X] ENDIF + TYPE = "B" + + def inner_compile(self): + return b"\x63" + Number(0).compile() + b"\x67" + self.carg + b"\x68" + + def __len__(self): + return len(self.arg) + 4 + + def verify(self): + # both are B, K, or V + super().verify() + if self.arg.type != "B": + raise MiniscriptException("or_i: X and Z should be the same type") + + @property + def properties(self): + # o=zXzZ; u=uXuZ; d=dX or dZ + props = "d" + pz = self.arg.properties + if "z" in pz: + props += "o" + if "u" in pz: + props += "u" + return props + + +class U(L): + # IF [X] ELSE 0 ENDIF + def inner_compile(self): + return b"\x63" + self.carg + b"\x67" + Number(0).compile() + b"\x68" + + def __len__(self): + return len(self.arg) + 4 + + +WRAPPERS = [A, S, C, T, D, V, J, N, L, U] +WRAPPER_NAMES = [w.__name__.lower() for w in WRAPPERS] \ No newline at end of file diff --git a/shared/multisig.py b/shared/multisig.py index f0bb1620d..7ee60b723 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -3,19 +3,23 @@ # multisig.py - support code for multisig signing and p2sh in general. # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version -from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable -from utils import str_to_keypath, problem_file_line, parse_extended_key, get_filesize +from ubinascii import hexlify as b2a_hex +from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, to_ascii_printable +from utils import str_to_keypath, problem_file_line, check_xpub, truncate_address, get_filesize from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code, ux_enter_number, OK, X from files import CardSlot, CardMissingError, needs_microsd -from descriptor import MultisigDescriptor, multisig_descriptor_template -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS +from descriptor import Descriptor +from miniscript import Key, Sortedmulti, Number, Multi +from desc_utils import multisig_descriptor_template +from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS, AF_P2TR from menu import MenuSystem, MenuItem, NonDefaultMenuItem from opcodes import OP_CHECKMULTISIG from exceptions import FatalPSBTIssue from glob import settings from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR -from wallet import WalletABC, MAX_BIP32_IDX +from serializations import disassemble +from wallet import BaseStorageWallet, MAX_BIP32_IDX # PSBT Xpub trust policies TRUST_VERIFY = const(0) @@ -23,14 +27,11 @@ TRUST_PSBT = const(2) -class MultisigOutOfSpace(RuntimeError): - pass - def disassemble_multisig_mn(redeem_script): # pull out just M and N from script. Simple, faster, no memory. - assert MAX_SIGNERS == 15 - assert redeem_script[-1] == OP_CHECKMULTISIG, 'need CHECKMULTISIG' + if redeem_script[-1] != OP_CHECKMULTISIG: + return None, None M = redeem_script[0] - 80 N = redeem_script[-2] - 80 @@ -42,9 +43,7 @@ def disassemble_multisig(redeem_script): # - only for multisig scripts, not general purpose # - expect OP_1 (pk1) (pk2) (pk3) OP_3 OP_CHECKMULTISIG for 1 of 3 case # - returns M, N, (list of pubkeys) - # - for very unlikely/impossible asserts, dont document reason; otherwise do. - from serializations import disassemble - + # - for very unlikely/impossible asserts, don't document reason; otherwise do. M, N = disassemble_multisig_mn(redeem_script) assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range' assert len(redeem_script) == 1 + (N * 34) + 1 + 1, 'bad len' @@ -107,7 +106,7 @@ def make_redeem_script(M, nodes, subkey_idx, bip67=True): return b''.join(pubkeys) -class MultisigWallet(WalletABC): +class MultisigWallet(BaseStorageWallet): # Capture the info we need to store long-term in order to participate in a # multisig wallet as a co-signer. # - can be saved to nvram @@ -122,19 +121,20 @@ class MultisigWallet(WalletABC): (AF_P2SH, 'p2sh'), (AF_P2WSH, 'p2wsh'), (AF_P2WSH_P2SH, 'p2sh-p2wsh'), # preferred + (AF_P2TR, 'p2tr'), (AF_P2WSH_P2SH, 'p2wsh-p2sh'), # obsolete (now an alias) ] # optional: user can short-circuit many checks (system wide, one power-cycle only) disable_checks = False + key_name = "multisig" - def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type='BTC', bip67=True): - self.storage_idx = -1 + def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type=None, bip67=True): + super().__init__(chain_type=chain_type) self.name = name assert len(m_of_n) == 2 self.M, self.N = m_of_n - self.chain_type = chain_type or 'BTC' assert len(xpubs[0]) == 3 self.xpubs = xpubs # list of (xfp(int), deriv, xpub(str)) self.addr_fmt = addr_fmt # address format for wallet @@ -163,17 +163,13 @@ def render_path(self, change_idx, idx): deriv = derivs[0] return deriv + '/%d/%d' % (change_idx, idx) - @property - def chain(self): - return chains.get_chain(self.chain_type) - @classmethod def get_trust_policy(cls): which = settings.get('pms', None) - + exists, _ = cls.exists() if which is None: - which = TRUST_VERIFY if cls.exists() else TRUST_OFFER + which = TRUST_VERIFY if exists else TRUST_OFFER return which @@ -239,14 +235,29 @@ def deserialize(cls, vals, idx=-1): return rv @classmethod - def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmt=None): + def is_correct_chain(cls, o, curr_chain): + # for newer versions, last element can be bip67 marker + d = o[-1] if isinstance(o[-1], dict) else o[-2] + + if "ch" not in d: + # mainnet + ch = "BTC" + else: + ch = d["ch"] + + if ch == curr_chain.ctype: + return True + return False + + @classmethod + def iter_wallets(cls, M=None, N=None, addr_fmt=None): # yield MS wallets we know about, that match at least right M,N if known. # - this is only place we should be searching this list, please!! - lst = settings.get('multisig', []) + lst = settings.get(cls.key_name, []) + c = chains.current_key_chain() for idx, rec in enumerate(lst): - if idx == not_idx: - # ignore one by index + if not cls.is_correct_chain(rec, c): continue if M or N: @@ -343,57 +354,6 @@ def quick_check(cls, M, N, xfp_xor): return False - @classmethod - def get_all(cls): - # return them all, as a generator - return cls.iter_wallets() - - @classmethod - def exists(cls): - # are there any wallets defined? - return bool(settings.get('multisig', False)) - - @classmethod - def get_by_idx(cls, nth): - # instance from index number (used in menu) - lst = settings.get('multisig', []) - try: - obj = lst[nth] - except IndexError: - return None - - return cls.deserialize(obj, nth) - - def commit(self): - # data to save - # - important that this fails immediately when nvram overflows - obj = self.serialize() - - v = settings.get('multisig', []) - orig = v.copy() - if not v or self.storage_idx == -1: - # create - self.storage_idx = len(v) - v.append(obj) - else: - # update in place - v[self.storage_idx] = obj - - settings.set('multisig', v) - - # save now, rather than in background, so we can recover - # from out-of-space situation - try: - settings.save() - except: - # back out change; no longer sure of NVRAM state - try: - settings.set('multisig', orig) - settings.save() - except: pass # give up on recovery - - raise MultisigOutOfSpace - def has_similar(self): # check if we already have a saved duplicate to this proposed wallet # - return (name_change, diff_items, count_similar) where: @@ -454,12 +414,12 @@ def delete(self): else: raise IndexError # consistency bug - lst = settings.get('multisig', []) + lst = settings.get(self.key_name, []) del lst[self.storage_idx] if lst: - settings.set('multisig', lst) + settings.set(self.key_name, lst) else: - settings.remove_key('multisig') + settings.remove_key(self.key_name) settings.save() self.storage_idx = -1 @@ -472,7 +432,7 @@ def xpubs_with_xfp(self, xfp): def yield_addresses(self, start_idx, count, change_idx=0): # Assuming a suffix of /0/0 on the defined prefix's, yield # possible deposit addresses for this wallet. - ch = self.chain + ch = chains.current_chain() assert self.addr_fmt, 'no addr fmt known' @@ -501,6 +461,35 @@ def yield_addresses(self, start_idx, count, change_idx=0): idx += 1 count -= 1 + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis + + addrs = [] + + for idx, addr, paths, script in self.yield_addresses(start, n, change): + if idx == 0 and self.N <= 4: + msg += '\n'.join(paths) + '\n =>\n' + else: + msg += '.../%d/%d =>\n' % (change, idx) + + addrs.append(addr) + msg += truncate_address(addr) + '\n\n' + dis.progress_sofar(idx - start + 1, n) + + return msg, addrs + + def generate_address_csv(self, start, n, change): + yield '"' + '","'.join(['Index', 'Payment Address', + 'Redeem Script (%d of %d)' % (self.M, self.N)] + + (['Derivation'] * self.N)) + '"\n' + + for (idx, addr, derivs, script) in self.yield_addresses(start, n, change_idx=change): + ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode()) + ln += '","'.join(derivs) + ln += '"\n' + + yield ln + def validate_script(self, redeem_script, subpaths=None, xfp_paths=None): # Check we can generate all pubkeys in the redeem script, raise on errors. # - working from pubkeys in the script, because duplicate XFP can happen @@ -572,7 +561,7 @@ def validate_script(self, redeem_script, subpaths=None, xfp_paths=None): found_pk = node.pubkey() # Document path(s) used. Not sure this is useful info to user tho. - # - Do not show what we can't verify: we don't really know the hardeneded + # - Do not show what we can't verify: we don't really know the hardened # part of the path from fingerprint to here. here = '[%s]' % xfp2str(xfp) if dp != len(path): @@ -683,7 +672,9 @@ def from_simple_text(cls, lines): continue # deserialize, update list and lots of checks - is_mine = cls.check_xpub(xfp, value, deriv, chains.current_chain().ctype, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, value, deriv, chains.current_key_chain().ctype, + my_xfp, cls.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 @@ -696,21 +687,35 @@ def from_descriptor(cls, descriptor: str): my_xfp = settings.get('xfp') xpubs = [] - desc = MultisigDescriptor.parse(descriptor) - for xfp, deriv, xpub in desc.keys: + descriptor = Descriptor.from_string(descriptor) + assert descriptor.is_basic_multisig, "not multisig" # raises + addr_fmt = descriptor.addr_fmt + + M, N = descriptor.miniscript.m_n() + for key in descriptor.miniscript.keys: + assert key.derivation.is_external, "Invalid subderivation path - only 0/* or <0;1>/* allowed" + xfp = key.origin.cc_fp + deriv = key.origin.str_derivation() + xpub = key.extended_public_key() deriv = cleanup_deriv_path(deriv) - is_mine = cls.check_xpub(xfp, xpub, deriv, chains.current_chain().ctype, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, xpub, deriv, chains.current_key_chain().ctype, + my_xfp, cls.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 - return None, desc.addr_fmt, xpubs, has_mine, desc.M, desc.N, desc.is_sorted + + return None, addr_fmt, xpubs, has_mine, M, N, descriptor.is_sortedmulti def to_descriptor(self): - return MultisigDescriptor( - M=self.M, N=self.N, - keys=self.xpubs, - addr_fmt=self.addr_fmt, - is_sorted=self.bip67, - ) + keys = [ + Key.from_cc_data(xfp, deriv, xpub) + for xfp, deriv, xpub in self.xpubs + ] + _cls = Sortedmulti if self.bip67 else Multi + miniscript = _cls(Number(self.M), *keys) + desc = Descriptor(miniscript=miniscript) + desc.set_from_addr_fmt(self.addr_fmt) + return desc @classmethod def from_file(cls, config, name=None): @@ -731,8 +736,10 @@ def from_file(cls, config, name=None): # - M of N line (assume N of N if not spec'd) # - xpub: any bip32 serialization we understand, but be consistent # - expect_chain = chains.current_chain().ctype - if MultisigDescriptor.is_descriptor(config): + expect_chain = chains.current_key_chain().ctype + if Descriptor.is_descriptor(config): + # assume descriptor, classic config should not contain sertedmulti( and check for checksum separator + # ignore name _, addr_fmt, xpubs, has_mine, M, N, bip67 = cls.from_descriptor(config) if not bip67 and not settings.get("unsort_ms", 0): # BIP-67 disabled, but unsort_ms not allowed - raise @@ -775,83 +782,6 @@ def from_file(cls, config, name=None): return cls(name, (M, N), xpubs, addr_fmt=addr_fmt, chain_type=expect_chain, bip67=bip67) - @classmethod - def check_xpub(cls, xfp, xpub, deriv, expect_chain, my_xfp, xpubs): - # Shared code: consider an xpub for inclusion into a wallet, if ok, append - # to list: xpubs with a tuple: (xfp, deriv, xpub) - # return T if it's our own key - # - deriv can be None, and in very limited cases can recover derivation path - # - could enforce all same depth, and/or all depth >= 1, but - # seems like more restrictive than needed, so "m" is allowed - - try: - # Note: addr fmt detected here via SLIP-132 isn't useful - node, chain, _ = parse_extended_key(xpub) - except: - raise AssertionError('unable to parse xpub') - - try: - assert node.privkey() == None # 'no privkeys plz' - except ValueError: - pass - - if expect_chain == "XRT": - # HACK but there is no difference extended_keys - just bech32 hrp - assert chain.ctype == "XTN" - else: - assert chain.ctype == expect_chain, 'wrong chain' - - depth = node.depth() - - if depth == 1: - if not xfp: - # allow a shortcut: zero/omit xfp => use observed parent value - xfp = swab32(node.parent_fp()) - else: - # generally cannot check fingerprint values, but if we can, do so. - if not cls.disable_checks: - assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong' - - assert xfp, 'need fingerprint' # happens if bare xpub given - - # In most cases, we cannot verify the derivation path because it's hardened - # and we know none of the private keys involved. - if depth == 1: - # but derivation is implied at depth==1 - kn, is_hard = node.child_number() - if is_hard: kn |= 0x80000000 - guess = keypath_to_str([kn], skip=0) - - if deriv: - if not cls.disable_checks: - assert guess == deriv, '%s != %s' % (guess, deriv) - else: - deriv = guess # reachable? doubt it - - assert deriv, 'empty deriv' # or force to be 'm'? - assert deriv[0] == 'm' - - # path length of derivation given needs to match xpub's depth - if not cls.disable_checks: - p_len = deriv.count('/') - assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( - p_len, depth, xfp2str(xfp)) - - if xfp == my_xfp: - # its supposed to be my key, so I should be able to generate pubkey - # - might indicate collision on xfp value between co-signers, - # and that's not supported - with stash.SensitiveValues() as sv: - chk_node = sv.derive_path(deriv) - assert node.pubkey() == chk_node.pubkey(), \ - "[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:]) - - # serialize xpub w/ BIP-32 standard now. - # - this has effect of stripping SLIP-132 confusion away - xpubs.append((xfp, deriv, chain.serialize_public(node, AF_P2SH))) - - return (xfp == my_xfp) - def make_fname(self, prefix, suffix='txt'): rv = '%s-%s.%s' % (prefix, self.name, suffix) return rv.replace(' ', '_') @@ -956,7 +886,7 @@ async def export_wallet_file(self, mode="exported from", extra_msg=None, descrip await needs_microsd() return except Exception as e: - await ux_show_story('Failed to write!\n\n\n'+str(e)) + await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e))) return def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc_pretty=True): @@ -969,9 +899,10 @@ def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc print("importdescriptors '%s'\n" % core_str, file=fp) else: if desc_pretty: - desc = desc_obj.pretty_serialize() + # TODO pretty serialize + desc = desc_obj.to_string(internal=False) else: - desc = desc_obj.serialize() + desc = desc_obj.to_string(internal=False) print("%s\n" % desc, file=fp) else: if hdr_comment: @@ -1043,8 +974,9 @@ def import_from_psbt(cls, M, N, xpubs_list): for k, v in xpubs_list: xfp, *path = ustruct.unpack_from('<%dI' % (len(k)//4), k, 0) xpub = ngu.codecs.b58_encode(v) - is_mine = cls.check_xpub(xfp, xpub, keypath_to_str(path, skip=0), - expect_chain, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0), + expect_chain, my_xfp, cls.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 addr_fmt = cls.guess_addr_fmt(path) @@ -1054,7 +986,7 @@ def import_from_psbt(cls, M, N, xpubs_list): name = 'PSBT-%d-of-%d' % (M, N) # this will always create sortedmulti multisig (BIP-67) # because BIP-174 came years after wide spread acceptance of BIP-67 policy - ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH) + ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH) # TODO why legacy # may just keep in-memory version, no approval required, if we are # trusting PSBT's today, otherwise caller will need to handle UX w.r.t new wallet @@ -1078,7 +1010,9 @@ def validate_psbt_xpubs(self, xpubs_list): # cleanup and normalize xpub tmp = [] - self.check_xpub(xfp, xpub, keypath_to_str(path, skip=0), self.chain_type, 0, tmp) + is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0), + self.chain_type, 0, self.disable_checks) + tmp.append(item) (_, deriv, xpub_reserialized) = tmp[0] assert deriv # because given as arg @@ -1182,7 +1116,7 @@ async def confirm_import(self): continue if ch == 'y' and not is_dup: - # save to nvram, may raise MultisigOutOfSpace + # save to nvram, may raise WalletOutOfSpace if name_change: name_change.delete() @@ -1215,7 +1149,7 @@ async def show_detail(self, verbose=True): msg.write('%s:\n %s\n\n%s\n' % (xfp2str(xfp), deriv, xpub)) - if self.addr_fmt != AF_P2SH: + if self.addr_fmt not in (AF_P2SH, AF_P2TR): # SLIP-132 format [yz]pubs here when not p2sh mode. # - has same info as proper bitcoin serialization, but looks much different node = self.chain.deserialize_node(xpub, AF_P2SH) @@ -1343,8 +1277,11 @@ class MultisigMenu(MenuSystem): def construct(cls): # Dynamic menu with user-defined names of wallets shown - if not MultisigWallet.exists(): - rv = [MenuItem('(none setup yet)', f=no_ms_yet)] + from bsms import make_ms_wallet_bsms_menu + + exists, exists_other_chain = MultisigWallet.exists() + if not exists: + rv = [MenuItem(MultisigWallet.none_setup_yet(exists_other_chain), f=no_ms_yet)] else: rv = [] for ms in MultisigWallet.get_all(): @@ -1357,6 +1294,7 @@ def construct(cls): rv.append(MenuItem('Import via NFC', f=import_multisig_nfc, predicate=bool(NFC), shortcut=KEY_NFC)) rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs)) + rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu)) rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) @@ -1451,7 +1389,7 @@ async def ms_wallet_show_descriptor(menu, label, item): dis.fullscreen("Wait...") ms = item.arg desc = ms.to_descriptor() - desc_str = desc.serialize() + desc_str = desc.to_string(internal=False) ch = await ux_show_story("Press (1) to export in pretty human readable format.\n\n" + desc_str, escape="1") if ch == "1": await ms.export_wallet_file(descriptor=True, desc_pretty=True) @@ -1516,6 +1454,8 @@ async def export_multisig_xpubs(*a): m/48h/{coin}h/{{acct}}h/1h P2WSH: m/48h/{coin}h/{{acct}}h/2h +P2TR: + m/48h/{coin}h/{{acct}}h/3h {ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X) @@ -1534,9 +1474,10 @@ async def export_multisig_xpubs(*a): dis.fullscreen('Generating...') todo = [ - ( "m/45h", 'p2sh', AF_P2SH), # iff acct_num == 0 - ( "m/48h/{coin}h/{acct_num}h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH ), - ( "m/48h/{coin}h/{acct_num}h/2h", 'p2wsh', AF_P2WSH ), + ("m/45h", 'p2sh', AF_P2SH), # iff acct_num == 0 + ("m/48h/{coin}h/{acct_num}h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH), + ("m/48h/{coin}h/{acct_num}h/2h", 'p2wsh', AF_P2WSH), + ("m/48h/{coin}h/{acct_num}h/3h", 'p2tr', AF_P2TR), ] def render(fp): @@ -1604,7 +1545,9 @@ async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs): deriv = cleanup_deriv_path(obj[af_str + '_deriv']) ln = obj.get(af_str) - return MultisigWallet.check_xpub(xfp, ln, deriv, chain.ctype, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, ln, deriv, chain.ctype, my_xfp, xpubs) + xpubs.append(item) + return is_mine async def ms_coordinator_qr(af_str, my_xfp, chain): # Scan a number of JSON files from BBQr w/ derive, xfp and xpub details. @@ -1663,7 +1606,7 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): # sigh, OS/filesystem variations file_size = var[1] if len(var) == 2 else get_filesize(full_fname) - if not (0 <= file_size <= 1100): + if not (0 <= file_size <= 1500): # out of range size continue @@ -1763,9 +1706,9 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False) ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt) if num_mine: - from auth import NewEnrollRequest, UserAuthorizedAction + from auth import NewMiniscriptEnrollRequest, UserAuthorizedAction - UserAuthorizedAction.active_request = NewEnrollRequest(ms) + UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(ms) # menu item case: add to stack from ux import the_ux @@ -1818,7 +1761,7 @@ async def import_multisig_nfc(*a): from glob import NFC # this menu option should not be available if NFC is disabled try: - return await NFC.import_multisig_nfc() + return await NFC.import_miniscript_nfc(legacy_multisig=True) except Exception as e: await ux_show_story(title="ERROR", msg="Failed to import multisig. %s" % str(e)) @@ -1865,7 +1808,7 @@ def possible(filename): if 'pub' in ln: return True - fn = await file_picker(suffix=['.txt', '.json'], min_size=100, max_size=20*200, + fn = await file_picker(suffix=['.txt', '.json'], min_size=100, max_size=350*200, taster=possible, force_vdisk=force_vdisk) if not fn: return diff --git a/shared/nfc.py b/shared/nfc.py index 2a7c57e15..9f57feb5c 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -613,7 +613,6 @@ async def selftest(cls): aborted = await n.share_text("NFC is working: %s" % n.get_uid(), allow_enter=False) assert not aborted, "Aborted" - async def share_file(self): # Pick file from SD card and share over NFC... from actions import file_picker @@ -663,51 +662,40 @@ async def import_multisig_nfc(self, *a): # user is pushing a file downloaded from another CC over NFC # - would need an NFC app in between for the sneakernet step # get some data - data = await self.start_nfc_rx() - if not data: return + def f(m): + if len(m) < 70: + return + m = m.decode() - winner = None - for urn, msg, meta in ndef.record_parser(data): - if len(msg) < 70: continue - msg = bytes(msg).decode() # from memory view # multi( catches both multi( and sortedmulti( - if 'pub' in msg or "multi(" in msg: - winner = msg - break + if 'pub' in m or "multi(" in m: + return m - if not winner: - await ux_show_story('Unable to find multisig descriptor.') - return + winner = await self._nfc_reader(f, 'Unable to find multisig descriptor.') - from auth import maybe_enroll_xpub - try: - maybe_enroll_xpub(config=winner) - except Exception as e: - #import sys; sys.print_exception(e) - await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + if winner: + from auth import maybe_enroll_xpub + try: + maybe_enroll_xpub(config=winner) + except Exception as e: + #import sys; sys.print_exception(e) + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) async def import_ephemeral_seed_words_nfc(self, *a): - data = await self.start_nfc_rx() - if not data: return + def f(m): + sm = m.decode().strip().split(" ") + if len(sm) in stash.SEED_LEN_OPTS: + return sm - winner = None - for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode().strip() # from memory view - split_msg = msg.split(" ") - if len(split_msg) in stash.SEED_LEN_OPTS: - winner = split_msg - break + winner = await self._nfc_reader(f, 'Unable to find seed words') - if not winner: - await ux_show_story('Unable to find seed words') - return - - try: - from seed import set_ephemeral_seed_words - await set_ephemeral_seed_words(winner, meta='NFC Import') - except Exception as e: - #import sys; sys.print_exception(e) - await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + if winner: + try: + from seed import set_ephemeral_seed_words + await set_ephemeral_seed_words(winner, meta='NFC Import') + except Exception as e: + #import sys; sys.print_exception(e) + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) async def confirm_share_loop(self, string): while True: @@ -720,21 +708,16 @@ async def confirm_share_loop(self, string): break async def address_show_and_share(self): - from auth import show_address, ApproveMessageSign + from auth import show_address - data = await self.start_nfc_rx() - if not data: return + def f(m): + sm = m.decode().split("\n") + if 1 <= len(sm) <= 2: + return sm - winner = None - for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode() # from memory view - split_msg = msg.split("\n") - if 1 <= len(split_msg) <= 2: - winner = split_msg - break + winner = await self._nfc_reader(f, 'Expected address and derivation path.') if not winner: - await ux_show_story('Expected address and derivation path.') return if len(winner) == 1: @@ -759,19 +742,15 @@ async def start_msg_sign(self): UserAuthorizedAction.cleanup() - data = await self.start_nfc_rx() - if not data: return - - winner = None - for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode() # from memory view - split_msg = msg.split("\n") + def f(m): + m = m.decode() + split_msg = m.split("\n") if 1 <= len(split_msg) <= 3: - winner = split_msg - break + return split_msg + + winner = await self._nfc_reader(f, 'Unable to find correctly formated message to sign.') if not winner: - await ux_show_story('Unable to find correctly formated message to sign.') return if len(winner) == 1: @@ -805,82 +784,94 @@ async def msg_sign_done(self, signature, address, text): async def verify_sig_nfc(self): from auth import verify_armored_signed_msg - data = await self.start_nfc_rx() - if not data: return - - winner = None - for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode() # from memory view - if "SIGNED MESSAGE" in msg: - winner = msg.strip() - break + f = lambda x: x.decode().strip() if b"SIGNED MESSAGE" in x else None + winner = await self._nfc_reader(f, 'Unable to find signed message.') - if not winner: - await ux_show_story('Unable to find signed message.') - return - - await verify_armored_signed_msg(winner, digest_check=False) + if winner: + await verify_armored_signed_msg(winner, digest_check=False) async def verify_address_nfc(self): # Get an address or complete bip-21 url even and search it... slow. from utils import decode_bip21_text - data = await self.start_nfc_rx() - if not data: return + def f(m): + m = m.decode() + what, vals = decode_bip21_text(m) + if what == 'addr': + return vals[1] - winner = None - for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode() # from memory view - try: - what, vals = decode_bip21_text(msg) - if what == 'addr': - winner = vals[1] - break - except ValueError: - pass - - if not winner: - await ux_show_story('Unable to find address from NFC data.') - return + winner = await self._nfc_reader(f, 'Unable to find address from NFC data.') - from ownership import OWNERSHIP - await OWNERSHIP.search_ux(winner) + if winner: + from ownership import OWNERSHIP + await OWNERSHIP.search_ux(winner) async def read_extended_private_key(self): - data = await self.start_nfc_rx() - if not data: return - - winner = None - for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode() # from memory view - if "prv" in msg: - winner = msg.strip() - break - - if not winner: - await ux_show_story('Unable to find extended private key.') - return - - return winner + f = lambda x: x.decode().strip() if b"prv" in x else None + return await self._nfc_reader(f, 'Unable to find extended private key.') async def read_tapsigner_b64_backup(self): + f = lambda x: a2b_base64(x.decode()) if 150 <= len(x) <= 280 else None + return await self._nfc_reader(f, 'Unable to find base64 encoded TAPSIGNER backup.') + + async def _nfc_reader(self, func, fail_msg): data = await self.start_nfc_rx() if not data: return winner = None for urn, msg, meta in ndef.record_parser(data): - msg = bytes(msg).decode() # from memory view + msg = bytes(msg) try: - if 150 <= len(msg) <= 280: - winner = a2b_base64(msg) + r = func(msg) + if r is not None: + winner = r break except: pass if not winner: - await ux_show_story('Unable to find base64 encoded TAPSIGNER backup.') + await ux_show_story(fail_msg) return return winner + async def read_bsms_token(self): + def f(m): + m = m.decode().strip() + try: + int(m, 16) + return m + except: pass + + return await self._nfc_reader(f, 'Unable to find BSMS token in NDEF data') + + async def read_bsms_data(self): + def f(m): + m = m.decode().strip() # from memory view + try: + if "BSMS" in m or int(m[:6], 16): + # unencrypted/encrypted case + return m + except: pass + + return await self._nfc_reader(f, 'Unable to find BSMS data in NDEF data') + + async def import_miniscript_nfc(self, legacy_multisig=False): + def f(m): + if len(m) < 70: return + m = m.decode() + # TODO this should be Descriptor.is_descriptor() ? + if 'pub' in m: + return m + + winner = await self._nfc_reader(f, 'Unable to find miniscript descriptor expected in NDEF') + if not winner: + return + + from auth import maybe_enroll_xpub + try: + maybe_enroll_xpub(config=winner, miniscript=not legacy_multisig) + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + # EOF diff --git a/shared/notes.py b/shared/notes.py index 108bfd975..14b9d2c64 100644 --- a/shared/notes.py +++ b/shared/notes.py @@ -21,7 +21,13 @@ ONE_LINE = CHARS_W-2 async def make_notes_menu(*a): - if settings.get('notes', False) == False: + + from pincodes import pa + if pa.is_deltamode(): + import callgate + callgate.fast_wipe() + + if not settings.get('secnap', False): # Explain feature, and then enable if interested. Drop them into menu. ch = await ux_show_story('''\ Enable this feature to store short text notes and passwords inside the Coldcard. @@ -34,8 +40,10 @@ async def make_notes_menu(*a): if ch != 'y': return - # mark as enabled (altho empty) - settings.set('notes', []) + # mark as enabled + settings.set('secnap', True) + if settings.get('notes', None) is None: + settings.set('notes', []) # need to correct top menu now, so this choice is there. goto_top_menu() @@ -170,6 +178,7 @@ def update_contents(self): async def disable_notes(cls, *a): # they don't want feature anymore; already checked no notes in effect # - no need for confirm, they aren't loosing anything + settings.remove_key('secnap') settings.remove_key('notes') settings.save() diff --git a/shared/nvstore.py b/shared/nvstore.py index 6151c2c04..4bb73994c 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -33,6 +33,7 @@ # _age = internal verison number for data (see below) # tested = selftest has been completed successfully # multisig = list of defined multisig wallets (complex) +# miniscript = list of defined miniscript wallets (complex) # pms = trust/import/distrust xpubs found in PSBT files # fee_limit = (int) percentage of tx value allowed as max fee # axi = index of last selected address in explorer @@ -56,6 +57,7 @@ # seedvault = (bool) opt-in enable seed vault feature # seeds = list of stored secrets for seedvault feature # bright = (int:0-255) LCD brightness when on battery +# secnap = (bool) opt-in enable Secure Notes & Passwords feature # notes = (complex) Secure notes held for user, see notes.py # accts = (list of tuples: (addr_fmt, account#)) Single-sig wallets we've seen them use # aei = (bool) allow changing start index in Address Explorer @@ -76,7 +78,7 @@ # terms_ok = customer has signed-off on the terms of sale # settings linked to seed -# LINKED_SETTINGS = ["multisig", "tp", "ovc", "xfp", "xpub", "words"] +# LINKED_SETTINGS = ["multisig","miniscript", "tp", "ovc", "xfp", "xpub", "words"] # settings that does not make sense to copy to temporary secret # LINKED_SETTINGS += ["sd2fa", "usr", "axi", "hsmcmd"] # prelogin settings - do not need to be part of other saved settings diff --git a/shared/opcodes.py b/shared/opcodes.py index d015d1737..7224795f0 100644 --- a/shared/opcodes.py +++ b/shared/opcodes.py @@ -82,7 +82,7 @@ #OP_RSHIFT = const(153) #OP_BOOLAND = const(154) #OP_BOOLOR = const(155) -#OP_NUMEQUAL = const(156) +OP_NUMEQUAL = const(156) #OP_NUMEQUALVERIFY = const(157) #OP_NUMNOTEQUAL = const(158) #OP_LESSTHAN = const(159) @@ -114,6 +114,7 @@ #OP_NOP8 = const(183) #OP_NOP9 = const(184) #OP_NOP10 = const(185) +OP_CHECKSIGADD = const(186) #OP_NULLDATA = const(252) #OP_PUBKEYHASH = const(253) #OP_PUBKEY = const(254) diff --git a/shared/ownership.py b/shared/ownership.py index 2eb6c9773..b89738f3a 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -7,6 +7,8 @@ from ucollections import namedtuple from ubinascii import hexlify as b2a_hex from exceptions import UnknownAddressExplained +from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR +from utils import problem_file_line # Track many addresses, but in compressed form # - map from random Bech32/Base58 payment address to (wallet) + keypath @@ -49,7 +51,7 @@ class AddressCacheFile: def __init__(self, wallet, change_idx): self.wallet = wallet self.change_idx = change_idx - desc = wallet.to_descriptor().serialize() + desc = wallet.to_descriptor().to_string(internal=False) h = b2a_hex(ngu.hash.sha256d(wallet.chain.ctype + desc)) self.fname = h[0:32] + '-%d.own' % change_idx self.salt = h[32:] @@ -158,8 +160,8 @@ def build_and_search(self, addr): self.setup(self.change_idx, start_idx) - for idx,here,*_ in self.wallet.yield_addresses(start_idx, count, - change_idx=self.change_idx): + # change_idx is used as flag here + for idx,here,*_ in self.wallet.yield_addresses(start_idx, count, self.change_idx): if here == addr: # Found it! But keep going a little for next time. @@ -207,7 +209,7 @@ def search(cls, addr): # - returns wallet object, and tuple2 of final 2 subpath components # - if you start w/ testnet, we'll follow that from multisig import MultisigWallet - from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH + from miniscript import MiniScriptWallet from glob import dis ch = chains.current_chain() @@ -220,21 +222,28 @@ def search(cls, addr): possibles = [] + msc_exists = MiniScriptWallet.exists()[0] + + if addr_fmt == AF_P2TR and msc_exists: + possibles.extend([w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2TR]) + if addr_fmt & AFC_SCRIPT: # multisig or script at least.. must exist already possibles.extend(MultisigWallet.iter_wallets(addr_fmt=addr_fmt)) + msc = [w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == addr_fmt] + possibles.extend(msc) if addr_fmt == AF_P2SH: # might look like P2SH but actually be AF_P2WSH_P2SH possibles.extend(MultisigWallet.iter_wallets(addr_fmt=AF_P2WSH_P2SH)) + msc = [w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2WSH_P2SH] + possibles.extend(msc) # Might be single-sig p2wpkh wrapped in p2sh ... but that was a transition # thing that hopefully is going away, so if they have any multisig wallets, # defined, assume that that's the only p2sh address source. addr_fmt = AF_P2WPKH_P2SH - # TODO: add tapscript and such fancy stuff here - try: # Construct possible single-signer wallets, always at least account=0 case from wallet import MasterSingleSigWallet @@ -252,7 +261,7 @@ def search(cls, addr): if not possibles: # can only happen w/ scripts; for single-signer we have things to check raise UnknownAddressExplained( - "No suitable multisig wallets are currently defined.") + "No suitable multisig/miniscript wallets are currently defined.") # "quick" check first, before doing any generations @@ -314,7 +323,8 @@ async def search_ux(cls, addr): msg = addr msg += '\n\nFound in wallet:\n ' + wallet.name - msg += '\nDerivation path:\n ' + wallet.render_path(*subpath) + if hasattr(wallet, "render_path"): + msg += '\nDerivation path:\n ' + wallet.render_path(*subpath) if version.has_qwerty: esc = KEY_QR else: @@ -325,11 +335,15 @@ async def search_ux(cls, addr): ch = await ux_show_story(msg, title="Verified Address", escape=esc, hint_icons=KEY_QR) if ch != esc: break - await show_qr_code(addr, is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), - msg=addr) + await show_qr_code(addr, + is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), + msg=addr) except UnknownAddressExplained as exc: await ux_show_story(addr + '\n\n' + str(exc), title="Unknown Address") + except Exception as e: + await ux_show_story('Ownership search failed.\n\n%s\n%s' % (e, problem_file_line(e))) + @classmethod def note_subpath_used(cls, subpath): diff --git a/shared/paper.py b/shared/paper.py index 952b667f5..8358827a1 100644 --- a/shared/paper.py +++ b/shared/paper.py @@ -3,14 +3,15 @@ # # paper.py - generate paper wallets, based on random values (not linked to wallet) # -import ujson +import ujson, ngu, chains from ubinascii import hexlify as b2a_hex from utils import imported -from public_constants import AF_CLASSIC, AF_P2WPKH +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR from ux import ux_show_story, ux_dramatic_pause from files import CardSlot, CardMissingError, needs_microsd from actions import file_picker from menu import MenuSystem, MenuItem +from stash import blank_object background_msg = '''\ Coldcard will pick a random private key (which has no relation to your seed words), \ @@ -29,10 +30,6 @@ SECP256K1_ORDER = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xba\xae\xdc\xe6\xaf\x48\xa0\x3b\xbf\xd2\x5e\x8c\xd0\x36\x41\x41" -# Aprox. time of this feature release (Nov 20/2019) so no need to scan -# blockchain earlier than this during "importmulti" -FEATURE_RELEASE_TIME = const(1574277000) - # These very-specific text values are matched on the Coldcard; cannot be changed. class placeholders: addr = b'ADDRESS_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX' # 37 long @@ -51,6 +48,12 @@ def __init__(self, my_menu): self.my_menu = my_menu self.template_fn = None self.is_segwit = False + self.is_taproot = False + + def atype(self): + if self.is_taproot: return 2, 'Taproot P2TR' + if self.is_segwit: return 1, 'Segwit P2WPKH' + return 0, 'Classic P2PKH' async def pick_template(self, *a): fn = await file_picker(suffix='.pdf', min_size=20000, taster=template_taster, @@ -62,17 +65,17 @@ async def pick_template(self, *a): def addr_format_chooser(self, *a): # simple bool choice def set(idx, text): - self.is_segwit = bool(idx) + self.is_segwit = idx == 1 + self.is_taproot = idx == 2 self.update_menu() - return int(self.is_segwit), ['Classic P2PKH', 'Segwit P2WPKH'], set + return self.atype()[0], ['Classic P2PKH', 'Segwit P2WPKH', 'Taproot P2TR'], set def update_menu(self): # Reconstruct the menu contents based on our state. self.my_menu.replace_items([ MenuItem("Don't make PDF" if not self.template_fn else 'Making PDF', f=self.pick_template), - MenuItem('Classic P2PKH' if not self.is_segwit else 'Segwit P2WPKH', - chooser=self.addr_format_chooser), + MenuItem(self.atype()[1], chooser=self.addr_format_chooser), MenuItem('Use Dice', f=self.use_dice), MenuItem('GENERATE WALLET', f=self.doit), ], keep_position=True) @@ -82,12 +85,6 @@ async def doit(self, *a, have_key=None): from glob import dis, VD try: - import ngu - from auth import write_sig_file - from chains import current_chain - from serializations import hash160 - from stash import blank_object - if not have_key: # get some random bytes await ux_dramatic_pause("Picking key...", 2) @@ -104,12 +101,16 @@ async def doit(self, *a, have_key=None): dis.fullscreen("Rendering...") # make payment address - digest = hash160(pubkey) - ch = current_chain() + ch = chains.current_chain() if self.is_segwit: - addr = ngu.codecs.segwit_encode(ch.bech32_hrp, 0, digest) + af = AF_P2WPKH + elif self.is_taproot: + af = AF_P2TR + pubkey = pubkey[1:] else: - addr = ngu.codecs.b58_encode(ch.b58_addr + digest) + af = AF_CLASSIC + + addr = ch.pubkey_to_address(pubkey, af) wif = ngu.codecs.b58_encode(ch.b58_privkey + privkey + b'\x01') @@ -164,8 +165,11 @@ async def doit(self, *a, have_key=None): else: nice_pdf = '' - nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename, - addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC) + nice_sig = None + if af != AF_P2TR: + from auth import write_sig_file + nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename, + addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC) # Half-hearted attempt to cleanup secrets-contaminated memory # - better would be force user to reboot @@ -185,7 +189,8 @@ async def doit(self, *a, have_key=None): story = "Done! Created file(s):\n\n%s" % nice_txt if nice_pdf: story += "\n\n%s" % nice_pdf - story += "\n\n%s" % nice_sig + if nice_sig: + story += "\n\n%s" % nice_sig await ux_show_story(story) async def use_dice(self, *a): @@ -214,10 +219,17 @@ def make_txt(self, fp, addr, wif, privkey, qr_addr=None, qr_wif=None): fp.write('Bitcoin Core command:\n\n') # new hotness: output descriptors - desc = ('wpkh(%s)' if self.is_segwit else 'pkh(%s)') % wif - multi = ujson.dumps(dict(timestamp=FEATURE_RELEASE_TIME, desc=append_checksum(desc))) - fp.write(" bitcoin-cli importmulti '[%s]'\n\n" % multi) - fp.write('# OR (more compatible, but slower)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif) + if self.is_taproot: + desc = 'tr(%s)' + elif self.is_segwit: + desc = 'wpkh(%s)' + else: + desc = 'pkh(%s)' + desc = desc % wif + descriptor = ujson.dumps(dict(timestamp="now", desc=append_checksum(desc))) + fp.write(" bitcoin-cli importdescriptors '[%s]'\n\n" % descriptor) + if not self.is_taproot: + fp.write('# OR (only supported with legacy wallets)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif) if qr_addr and qr_wif: fp.write('\n\n--- QR Codes --- (requires UTF-8, unicode, white background)\n\n\n\n') diff --git a/shared/psbt.py b/shared/psbt.py index 6cc30b88d..a5ddf8a1d 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -4,19 +4,21 @@ # from ustruct import unpack_from, unpack, pack from ubinascii import hexlify as b2a_hex -from utils import xfp2str, B2A, keypath_to_str, problem_file_line -from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str +from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length +from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, problem_file_line import stash, gc, history, sys, ngu, ckcc, chains from uhashlib import sha256 from uio import BytesIO from sffile import SizerFile -from multisig import MultisigWallet, disassemble_multisig, disassemble_multisig_mn +from chains import taptweak, tapleaf_hash +from miniscript import MiniScriptWallet +from multisig import MultisigWallet, disassemble_multisig_mn from exceptions import FatalPSBTIssue, FraudulentChangeOutput -from serializations import ser_compact_size, deser_compact_size, hash160, hash256 -from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, ser_uint256, COutPoint +from serializations import ser_compact_size, deser_compact_size, hash160 +from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint from serializations import ser_sig_der, uint256_from_str, ser_push_data from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY -from serializations import ALL_SIGHASH_FLAGS +from serializations import ALL_SIGHASH_FLAGS, SIGHASH_DEFAULT from glob import settings from public_constants import ( @@ -24,13 +26,19 @@ PSBT_IN_PARTIAL_SIG, PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_BIP32_DERIVATION, PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, - PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_SCRIPT, PSBT_OUT_AMOUNT, PSBT_GLOBAL_VERSION, + PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_TAP_BIP32_DERIVATION, PSBT_OUT_TAP_INTERNAL_KEY, + PSBT_IN_TAP_BIP32_DERIVATION, PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_KEY_SIG, PSBT_OUT_TAP_TREE, + PSBT_IN_TAP_MERKLE_ROOT, PSBT_IN_TAP_LEAF_SCRIPT, PSBT_IN_TAP_SCRIPT_SIG, + TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK, + PSBT_OUT_SCRIPT, PSBT_OUT_AMOUNT, PSBT_GLOBAL_VERSION, PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT, PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS ) +psbt_tmp256 = bytearray(256) + # PSBT proprietary keytype PSBT_PROPRIETARY = const(0xFC) @@ -239,10 +247,21 @@ def write(self, out_fd, ktype, val, key=b''): elif isinstance(val, list): # for subpaths lists (LE32 ints) - assert ktype in (PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION) - out_fd.write(ser_compact_size(len(val) * 4)) - for i in val: - out_fd.write(pack(' [xfp, *path] - # - will be single entry for non-p2sh ins and outs + def parse_taproot_subpaths(self, my_xfp, warnings): + if not self.taproot_subpaths: + return 0 + num_ours = 0 + for xonly_pk in self.taproot_subpaths: + assert len(xonly_pk) == 32 # "PSBT_IN_TAP_BIP32_DERIVATION xonly-pubkey length != 32" + + pos, length = self.taproot_subpaths[xonly_pk] + end_pos = pos + length + self.fd.seek(pos) + leaf_hash_len = deser_compact_size(self.fd) + leaf_hashes = [] + for _ in range(leaf_hash_len): + leaf_hashes.append(self.fd.read(32)) + + curr_pos = self.fd.tell() + to_read = end_pos - curr_pos + # internal key is allowed to go from master + # unspendable path can be just a bare xonly pubkey + allow_master = True if not leaf_hashes else False + validate_derivation_path_length(to_read, allow_master=allow_master) + v = self.fd.read(to_read) + here = list(unpack_from('<%dI' % (to_read // 4), v)) + # Tricky & Useful: if xfp of zero is observed in file, assume that's a + # placeholder for my XFP value. Replace on the fly. Great when master + # XFP is unknown because PSBT built from derived XPUB only. Also privacy. + if here[0] == 0: + here[0] = my_xfp + if not any(True for k, _ in warnings if 'XFP' in k): + warnings.append(('Zero XFP', + 'Assuming XFP of zero should be replaced by correct XFP')) + # update in place + self.taproot_subpaths[xonly_pk] = [leaf_hashes] + here + if here[0] == my_xfp: + num_ours += 1 + + return num_ours + + def parse_non_taproot_subpaths(self, my_xfp, warnings): if not self.subpaths: return 0 - if self.num_our_keys != None: - # already been here once - return self.num_our_keys - num_ours = 0 for pk in self.subpaths: assert len(pk) in {33, 65}, "hdpath pubkey len" @@ -274,28 +322,21 @@ def parse_subpaths(self, my_xfp, warnings): assert pk[0] in {0x02, 0x03}, "uncompressed pubkey" vl = self.subpaths[pk][1] - - # force them to use a derived key, never the master - assert vl >= 8, 'too short key path' - assert (vl % 4) == 0, 'corrupt key path' - assert (vl//4) <= MAX_PATH_DEPTH, 'too deep' - + validate_derivation_path_length(vl) # promote to a list of ints v = self.get(self.subpaths[pk]) here = list(unpack_from('<%dI' % (vl//4), v)) - - # Tricky & Useful: if xfp of zero is observed in file, assume that's a + # Tricky & Useful: if xfp of zero is observed in file, assume that's a # placeholder for my XFP value. Replace on the fly. Great when master # XFP is unknown because PSBT built from derived XPUB only. Also privacy. if here[0] == 0: here[0] = my_xfp if not any(True for k,_ in warnings if 'XFP' in k): warnings.append(('Zero XFP', - 'Assuming XFP of zero should be replaced by correct XFP')) + 'Assuming XFP of zero should be replaced by correct XFP')) # update in place self.subpaths[pk] = here - if here[0] == my_xfp: num_ours += 1 else: @@ -303,24 +344,43 @@ def parse_subpaths(self, my_xfp, warnings): # or an input we're not supposed to be able to sign... and that's okay. pass - self.num_our_keys = num_ours return num_ours + def parse_subpaths(self, my_xfp, warnings): + # Reformat self.subpaths and self.taproot_subpaths into a more useful form for us; return # of them + # that are ours (and track that as self.num_our_keys) + # - works in-place, on self.subpaths and self.taproot_subpaths + # - creates dictionary: pubkey => [xfp, *path] (self.subpaths) + # - creates dictionary: pubkey => [leaf_hash_list, xfp, *path] (self.taproot_subpaths) + # - will be single entry for non-p2sh ins and outs + if self.num_our_keys != None: + # already been here once + return self.num_our_keys + + num_our = self.parse_non_taproot_subpaths(my_xfp, warnings) + num_our_taproot = self.parse_taproot_subpaths(my_xfp, warnings) + + self.num_our_keys = num_our + num_our_taproot + return self.num_our_keys # Track details of each output of PSBT # class psbtOutputProxy(psbtProxy): - no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT } + no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, PSBT_OUT_TAP_INTERNAL_KEY, PSBT_OUT_TAP_TREE } blank_flds = ('unknown', 'subpaths', 'redeem_script', 'witness_script', - 'is_change', 'num_our_keys', 'amount', 'script', 'attestation') + 'is_change', 'num_our_keys', 'amount', 'script', 'attestation', + 'taproot_internal_key', 'taproot_subpaths', 'taproot_tree') def __init__(self, fd, idx): super().__init__() # things we track - #self.subpaths = None # a dictionary if non-empty + #self.subpaths = None # a dictionary if non-empty + #self.taproot_subpaths = None # a dictionary if non-empty + #self.taproot_internal_key = None + #self.taproot_tree = None #self.redeem_script = None #self.witness_script = None #self.script = None @@ -331,6 +391,23 @@ def __init__(self, fd, idx): self.parse(fd) + def parse_taproot_tree(self): + if not self.taproot_tree: + return + length = self.taproot_tree[1] + + res = [] + while length: + tree = BytesIO(self.get(self.taproot_tree)) + depth = tree.read(1) + leaf_version = tree.read(1)[0] + assert (leaf_version & ~TAPROOT_LEAF_MASK) == 0 + script_len, nb = deser_compact_size(tree, ret_num_bytes=True) + script = tree.read(script_len) + res.append((depth, leaf_version, script)) + length -= (2 + nb + script_len) + + return res def store(self, kt, key, val): # do not forget that key[0] includes kt (type) @@ -354,6 +431,14 @@ def store(self, kt, key, val): # prop key for attestation does not have keydata because the # value is a recoverable signature (already contains pubkey) self.attestation = self.get(val) + elif kt == PSBT_OUT_TAP_INTERNAL_KEY: + self.taproot_internal_key = val + elif kt == PSBT_OUT_TAP_BIP32_DERIVATION: + if not self.taproot_subpaths: + self.taproot_subpaths = {} + self.taproot_subpaths[key[1:]] = val + elif kt == PSBT_OUT_TAP_TREE: + self.taproot_tree = val else: self.unknown = self.unknown or {} if key in self.unknown: @@ -374,6 +459,16 @@ def serialize(self, out_fd, is_v2): if self.witness_script: wr(PSBT_OUT_WITNESS_SCRIPT, self.witness_script) + if self.taproot_internal_key: + wr(PSBT_OUT_TAP_INTERNAL_KEY, self.taproot_internal_key) + + if self.taproot_subpaths: + for k in self.taproot_subpaths: + wr(PSBT_OUT_TAP_BIP32_DERIVATION, self.taproot_subpaths[k], k) + + if self.taproot_tree: + wr(PSBT_OUT_TAP_TREE, self.taproot_tree) + if is_v2: wr(PSBT_OUT_SCRIPT, self.script) wr(PSBT_OUT_AMOUNT, self.amount) @@ -385,7 +480,7 @@ def serialize(self, out_fd, is_v2): for k, v in self.unknown.items(): wr(k[0], v, k[1:]) - def validate(self, out_idx, txo, my_xfp, active_multisig, parent): + def validate(self, out_idx, txo, my_xfp, active_multisig, active_miniscript, parent): # Do things make sense for this output? # NOTE: We might think it's a change output just because the PSBT @@ -396,6 +491,9 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): # - full key derivation and validation is done during signing, and critical. # - we raise fraud alarms, since these are not innocent errors # + if self.taproot_internal_key: + assert self.taproot_internal_key[1] == 32 # "PSBT_OUT_TAP_INTERNAL_KEY length != 32" + num_ours = self.parse_subpaths(my_xfp, parent.warnings) if num_ours == 0: @@ -406,9 +504,11 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): # - must match expected address for this output, coming from unsigned txn addr_type, addr_or_pubkey, is_segwit = txo.get_address() - if len(self.subpaths) == 1: + if self.subpaths and len(self.subpaths) == 1 and not active_miniscript: # miniscript can have one key only # p2pk, p2pkh, p2wpkh cases expect_pubkey, = self.subpaths.keys() + elif self.taproot_subpaths and len(self.taproot_subpaths) == 1: + expect_pubkey, = self.taproot_subpaths.keys() else: # p2wsh/p2sh cases need full set of pubkeys, and therefore redeem script expect_pubkey = None @@ -453,43 +553,66 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): expect_pkh = None else: - # Multisig change output, for wallet we're supposed to be a part of. - # - our key must be part of it - # - must look like input side redeem script (same fingerprints) - # - assert M/N structure of output to match any inputs we have signed in PSBT! - # - assert all provided pubkeys are in redeem script, not just ours - # - we get all of that by re-constructing the script from our wallet details if not redeem_script and not witness_script: - # Perhaps an omission, so let's not call fraud on it - # But definately required, else we don't know what script we're sending to. - raise FatalPSBTIssue( - "Missing redeem/witness script for multisig output #%d" % out_idx - ) + if active_miniscript: + # TODO + # this should be also acceptable for any other script type, we do not need + # redeem/witness script + # scriptPubkey can be compared against script that we build - if exact match change + # if not not change - definitely not FatalPSBTIssue + # + # without this I cannot sign with liana as they do not provide witness/redeem + try: + active_miniscript.validate_script_pubkey(txo.scriptPubKey, + list(self.subpaths.values())) + self.is_change = True + return + except Exception as e: + raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e) + else: + # Perhaps an omission, so let's not call fraud on it + # But definately required, else we don't know what script we're sending to. + raise FatalPSBTIssue("Missing redeem/witness script for output #%d" % out_idx) # it cannot be change if it doesn't precisely match our multisig setup - if not active_multisig: + if not active_multisig and not active_miniscript: # - might be a p2sh output for another wallet that isn't us # - not fraud, just an output with more details than we need. self.is_change = False return - if MultisigWallet.disable_checks: - # Without validation, we have to assume all outputs - # will be taken from us, and are not really change. - self.is_change = False - return - - # redeem script must be exactly what we expect - # - pubkeys will be reconstructed from derived paths here - # - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor) - # - p2sh-p2wsh needs witness script here, not redeem script value - # - if details provided in output section, must our match multisig wallet - try: - active_multisig.validate_script(witness_script or redeem_script, - subpaths=self.subpaths) - except BaseException as exc: - raise FraudulentChangeOutput(out_idx, - "P2WSH or P2SH change output script: %s" % exc) + if active_multisig: + # Multisig change output, for wallet we're supposed to be a part of. + # - our key must be part of it + # - must look like input side redeem script (same fingerprints) + # - assert M/N structure of output to match any inputs we have signed in PSBT! + # - assert all provided pubkeys are in redeem script, not just ours + # - we get all of that by re-constructing the script from our wallet details + if MultisigWallet.disable_checks: + # Without validation, we have to assume all outputs + # will be taken from us, and are not really change. + self.is_change = False + return + # redeem script must be exactly what we expect + # - pubkeys will be reconstructed from derived paths here + # - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor) + # - p2sh-p2wsh needs witness script here, not redeem script value + # - if details provided in output section, must our match multisig wallet + try: + active_multisig.validate_script(witness_script or redeem_script, + subpaths=self.subpaths) + except BaseException as exc: + raise FraudulentChangeOutput(out_idx, + "P2WSH or P2SH change output script: %s" % exc) + else: + # active miniscript + try: + active_miniscript.validate_script(witness_script or redeem_script, + list(self.subpaths.values()), + script_pubkey=txo.scriptPubKey) + except BaseException as exc: + raise FraudulentChangeOutput(out_idx, + "P2WSH or P2SH change output script: %s" % exc) if is_segwit: # p2wsh case @@ -521,6 +644,21 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): # input is hash160 of a single public key assert len(addr_or_pubkey) == 20 expect_pkh = hash160(expect_pubkey) + elif addr_type == "p2tr": + if expect_pubkey is None and len(self.taproot_subpaths) > 1: + if active_miniscript: + try: + active_miniscript.validate_script_pubkey( + b"\x51\x20" + pkh, + [v[1:] for v in self.taproot_subpaths.values() if len(v[1:]) > 1] + ) + self.is_change = True + return + except Exception as e: + raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e) + expect_pkh = None + else: + expect_pkh = taptweak(expect_pubkey) else: # we don't know how to "solve" this type of input return @@ -540,15 +678,18 @@ class psbtInputProxy(psbtProxy): short_values = { PSBT_IN_SIGHASH_TYPE } # only part-sigs have a key to be stored. - no_keys = { PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE, - PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG, - PSBT_IN_FINAL_SCRIPTWITNESS } + no_keys = {PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE, + PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG, + PSBT_IN_FINAL_SCRIPTWITNESS,PSBT_IN_TAP_KEY_SIG, + PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_MERKLE_ROOT} blank_flds = ( 'unknown', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script', 'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys', - 'required_key', 'scriptSig', 'amount', 'scriptCode', 'added_sig', 'previous_txid', - 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime' + 'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid', + 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'taproot_key_sig', + 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "use_keypath", "subpaths", + "taproot_subpaths", "taproot_internal_key", "part_sig" ) def __init__(self, fd, idx): @@ -556,9 +697,9 @@ def __init__(self, fd, idx): #self.utxo = None #self.witness_utxo = None - self.part_sig = {} + # self.part_sig = {} #self.sighash = None - self.subpaths = {} # will typically be non-empty for all inputs + # self.subpaths = {} # will be empty if taproot #self.redeem_script = None #self.witness_script = None @@ -578,8 +719,12 @@ def __init__(self, fd, idx): #self.amount = None #self.scriptCode = None # only expected for segwit inputs - # after signing, we'll have a signature to add to output PSBT - #self.added_sig = None + # self.taproot_subpaths = {} # will be empty if non-taproot + # self.taproot_internal_key = None # will be empty if non-taproot + # self.taproot_key_sig = None # will be empty if non-taproot + # self.taproot_merkle_root = None # will be empty if non-taproot + # self.taproot_script_sigs = None # will be empty if non-taproot + # self.taproot_scripts = None # will be empty if non-taproot #self.previous_txid = None #self.prevout_idx = None @@ -589,6 +734,32 @@ def __init__(self, fd, idx): self.parse(fd) + def parse_taproot_script_sigs(self): + # not needed at this point as we do not support tapscript + # parsing this field without actual tapscript support is just a waste of memory + parsed_taproot_script_sigs = {} + for key in self.taproot_script_sigs: + assert len(key) == 64 # "PSBT_IN_TAP_SCRIPT_SIG key length != 64" + assert self.taproot_script_sigs[key][1] in (64, 65) # "PSBT_IN_TAP_SCRIPT_SIG signature length != 64 or 65" + xonly, script_hash = key[:32], key[32:] + parsed_taproot_script_sigs[(xonly, script_hash)] = self.get(self.taproot_script_sigs[key]) + self.taproot_script_sigs = parsed_taproot_script_sigs + + def parse_taproot_scripts(self): + # not needed at this point as we do not support tapscript + # parsing this field without actual tapscript support is just a waste of memory + parsed_taproot_scripts = {} + for key in self.taproot_scripts: + assert len(key) > 32 # "PSBT_IN_TAP_LEAF_SCRIPT control block is too short" + assert (len(key) - 1) % 32 == 0 # "PSBT_IN_TAP_LEAF_SCRIPT control block is not valid" + script = self.get(self.taproot_scripts[key]) + assert len(script) != 0 # "PSBT_IN_TAP_LEAF_SCRIPT cannot be empty" + leaf_script = (script[:-1], int(script[-1])) + if leaf_script not in self.taproot_scripts: + parsed_taproot_scripts[leaf_script] = set() + parsed_taproot_scripts[leaf_script].add(key) + self.taproot_scripts = parsed_taproot_scripts + def has_relative_timelock(self, txin): # https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki SEQUENCE_LOCKTIME_DISABLE_FLAG = (1 << 31) @@ -624,6 +795,15 @@ def validate(self, idx, txin, my_xfp, parent): if self.redeem_script: assert self.redeem_script[1] >= 22 + if self.taproot_internal_key: + assert self.taproot_internal_key[1] == 32 # "PSBT_IN_TAP_INTERNAL_KEY length != 32" + + if self.taproot_script_sigs: + self.parse_taproot_script_sigs() + + if self.taproot_scripts: + self.parse_taproot_scripts() + # require path for each addr, check some are ours # rework the pubkey => subpath mapping @@ -635,11 +815,19 @@ def validate(self, idx, txin, my_xfp, parent): # - seems harmless if they fool us into thinking already signed; we do nothing # - could also look at pubkey needed vs. sig provided # - could consider structure of MofN in p2sh cases - self.fully_signed = (len(self.part_sig) >= len(self.subpaths)) + self.fully_signed = len(self.part_sig) >= len(self.subpaths) else: # No signatures at all yet for this input (typical non multisig) self.fully_signed = False + if self.taproot_key_sig: + assert self.taproot_key_sig[1] in (64, 65) # "PSBT_IN_TAP_KEY_SIG length != 64 or 65" + if self.taproot_key_sig[1] == 65: + taproot_sig = self.get(self.taproot_key_sig) + if self.sighash: + assert taproot_sig[64] == self.sighash # "PSBT_IN_SIGHASH_TYPE != PSBT_IN_TAP_KEY_SIG[64]" + self.fully_signed = True + if self.utxo: # Important: they might be trying to trick us with an un-related # funding transaction (UTXO) that does not match the input signature we're making @@ -655,7 +843,7 @@ def validate(self, idx, txin, my_xfp, parent): def handle_none_sighash(self): if self.sighash is None: - self.sighash = SIGHASH_ALL + self.sighash = SIGHASH_DEFAULT if self.taproot_subpaths else SIGHASH_ALL def has_utxo(self): # do we have a copy of the corresponding UTXO? @@ -713,17 +901,16 @@ def get_utxo(self, idx): return utxo - def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): # See what it takes to sign this particular input # - type of script # - which pubkey needed # - scriptSig value # - also validates redeem_script when present - + merkle_root = None self.amount = utxo.nValue - if not self.subpaths or self.fully_signed: + if (not self.subpaths and not self.taproot_subpaths) or self.fully_signed: # without xfp+path we will not be able to sign this input # - okay if fully signed # - okay if payjoin or other multi-signer (not multisig) txn @@ -731,6 +918,7 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): return self.is_multisig = False + self.is_miniscript = False self.is_p2sh = False which_key = None @@ -779,9 +967,13 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): self.is_segwit = True else: # multiple keys involved, we probably can't do the finalize step - self.is_multisig = True + M, N = disassemble_multisig_mn(redeem_script) + if M is None and N is None: + self.is_miniscript = True + else: + self.is_multisig = True - if self.witness_script and not self.is_segwit and self.is_multisig: + if self.witness_script and not self.is_segwit and (self.is_miniscript or self.is_multisig): # bugfix addr_type = 'p2sh-p2wsh' self.is_segwit = True @@ -799,6 +991,43 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): # none of the pubkeys provided hashes to that address raise FatalPSBTIssue('Input #%d: pubkey vs. address wrong' % my_idx) + elif addr_type == 'p2tr': + pubkey = addr_or_pubkey + merkle_root = None if self.taproot_merkle_root is None else self.get(self.taproot_merkle_root) + if len(self.taproot_subpaths) == 1: + # keyspend without a script path + assert merkle_root is None, "merkle_root should not be defined for simple keyspend" + xonly_pubkey, lhs_path = list(self.taproot_subpaths.items())[0] + lhs, path = lhs_path[0], lhs_path[1:] # meh - should be a tuple + assert not lhs, "LeafHashes have to be empty for internal key" + if path[0] == my_xfp: + output_key = taptweak(xonly_pubkey) + if output_key == pubkey: + which_key = xonly_pubkey + else: + # tapscript (is always miniscript wallet) + self.is_miniscript = True + for xonly_pubkey, lhs_path in self.taproot_subpaths.items(): + lhs, path = lhs_path[0], lhs_path[1:] # meh - should be a tuple + # ignore keys that does not have correct xfp specified in PSBT + if path[0] == my_xfp: + assert merkle_root is not None, "Merkle root not defined" + if not lhs: + output_key = taptweak(xonly_pubkey, merkle_root) + if output_key == pubkey: + which_key = xonly_pubkey + # if we find a possibiity to spend keypath (internal_key) - we do keypath + # even though script path is available + self.use_keypath = True + break + else: + internal_key = self.get(self.taproot_internal_key) + output_pubkey = taptweak(internal_key, merkle_root) + if not which_key: + which_key = set() + if pubkey == output_pubkey: + which_key.add(xonly_pubkey) + elif addr_type == 'p2pk': # input is single public key (less common) self.scriptSig = utxo.scriptPubKey @@ -820,7 +1049,6 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): # - check it's the right M/N to match redeem script #print("redeem: %s" % b2a_hex(redeem_script)) - M, N = disassemble_multisig_mn(redeem_script) xfp_paths = list(self.subpaths.values()) xfp_paths.sort() @@ -842,6 +1070,27 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): sys.print_exception(exc) raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc)) + if self.is_miniscript and which_key: + try: + xfp_paths = [item[1:] for item in self.taproot_subpaths.values() if len(item[1:]) > 1] + except AttributeError: + xfp_paths = list(self.subpaths.values()) + + xfp_paths.sort() + if not psbt.active_miniscript: + wal = MiniScriptWallet.find_match(xfp_paths) + if not wal: + raise FatalPSBTIssue('Unknown miniscript wallet') + psbt.active_miniscript = wal + + assert psbt.active_miniscript + try: + # contains PSBT merkle root verification + psbt.active_miniscript.validate_script_pubkey(utxo.scriptPubKey, + xfp_paths, merkle_root) + except BaseException as e: + raise FatalPSBTIssue('Input #%d: %s\n\n' % (my_idx, e) + problem_file_line(e)) + if not which_key and DEBUG: print("no key: input #%d: type=%s segwit=%d a_or_pk=%s scriptPubKey=%s" % ( my_idx, addr_type, self.is_segwit or 0, @@ -849,7 +1098,7 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): self.required_key = which_key - if self.is_segwit: + if self.is_segwit and addr_type != 'p2tr': if ('pkh' in addr_type): # This comment from : # @@ -881,8 +1130,12 @@ def store(self, kt, key, val): elif kt == PSBT_IN_WITNESS_UTXO: self.witness_utxo = val elif kt == PSBT_IN_PARTIAL_SIG: + if self.part_sig is None: + self.part_sig = {} self.part_sig[key[1:]] = val elif kt == PSBT_IN_BIP32_DERIVATION: + if self.subpaths is None: + self.subpaths = {} self.subpaths[key[1:]] = val elif kt == PSBT_IN_REDEEM_SCRIPT: self.redeem_script = val @@ -890,6 +1143,24 @@ def store(self, kt, key, val): self.witness_script = val elif kt == PSBT_IN_SIGHASH_TYPE: self.sighash = unpack(' leaf hashes + if path[1] == my_xfp: + in_paths.append(path[2:]) if not in_paths: # We aren't adding any signatures? Can happen but we're going to be @@ -1600,35 +1899,58 @@ def hard_bits(p): idx_max = max(i[-1]&0x7fffffff for i in in_paths) + 200 hard_pattern = hard_bits(in_paths[0]) + def check_output_path(path): + if len(path) != path_len: + iss = "has wrong path length (%d not %d)" % (len(path), path_len) + elif hard_bits(path) != hard_pattern: + iss = "has different hardening pattern" + elif path[0:len(path_prefix)] != path_prefix: + iss = "goes to diff path prefix" + # elif (path[-2] & 0x7fffffff) not in {0, 1}: + # iss = "2nd last component not 0 or 1" + elif (path[-1] & 0x7fffffff) > idx_max: + iss = "last component beyond reasonable gap" + else: + # looks OK + iss = None + return iss + + def problem_fmt_str(nout, iss, path): + return "Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected" % ( + nout, + iss, + keypath_to_str(path, skip=0), + keypath_to_str(path_prefix, skip=0), + "'" if hard_pattern[-2] else "", + idx_max, + "'" if hard_pattern[-1] else "", + ) + probs = [] for nout, out in enumerate(self.outputs): if not out.is_change: continue # it's a change output, okay if a p2sh change; we're looking at paths - for path in out.subpaths.values(): - if path[0] != my_xfp: continue # possible in p2sh case - - path = path[1:] - if len(path) != path_len: - iss = "has wrong path length (%d not %d)" % (len(path), path_len) - elif hard_bits(path) != hard_pattern: - iss = "has different hardening pattern" - elif path[0:len(path_prefix)] != path_prefix: - iss = "goes to diff path prefix" - elif (path[-2]&0x7fffffff) not in {0, 1}: - iss = "2nd last component not 0 or 1" - elif (path[-1]&0x7fffffff) > idx_max: - iss = "last component beyond reasonable gap" - else: - # looks ok - continue - - probs.append("Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected" - % (nout, iss, keypath_to_str(path, skip=0), - keypath_to_str(path_prefix, skip=0), - "'" if hard_pattern[-2] else "", - idx_max, "'" if hard_pattern[-1] else "", - )) - break + if out.subpaths: + for path in out.subpaths.values(): + if path[0] != my_xfp: + # possible in p2sh case + continue + path = path[1:] + iss = check_output_path(path) + if iss is None: + continue + probs.append(problem_fmt_str(nout, iss, path)) + break + if out.taproot_subpaths: + for path in out.taproot_subpaths.values(): + if path[1] != my_xfp: + continue + path = path[2:] + iss = check_output_path(path) + if iss is None: + continue + probs.append(problem_fmt_str(nout, iss, path)) + break for p in probs: self.warnings.append(('Troublesome Change Outs', p)) @@ -1720,16 +2042,20 @@ def calculate_fee(self): return self.total_value_in - self.total_value_out def consider_keys(self): - # check we posess the right keys for the inputs + # check we possess the right keys for the inputs cnt = sum(1 for i in self.inputs if i.num_our_keys) if cnt: return # collect a list of XFP's given in file that aren't ours others = set() for inp in self.inputs: - if not inp.subpaths: continue - for path in inp.subpaths.values(): - others.add(path[0]) + if inp.subpaths: + for path in inp.subpaths.values(): + others.add(path[0]) + if inp.taproot_subpaths: + for path in inp.taproot_subpaths.values(): + # xfp is on index 1, on index 0 -> leaf hashes + others.add(path[1]) if not others: # Can happen w/ Electrum in watch-mode on XPUB. It doesn't know XFP and @@ -1845,21 +2171,38 @@ def sign_it(self): oup = self.outputs[out_idx] good = 0 - for pubkey, subpath in oup.subpaths.items(): - if subpath[0] != self.my_xfp: - # for multisig, will be N paths, and exactly one will - # be our key. For single-signer, should always be my XFP - continue - - # derive actual pubkey from private - skp = keypath_to_str(subpath) - node = sv.derive_path(skp) - - # check the pubkey of this BIP-32 node - if pubkey == node.pubkey(): - good += 1 - - OWNERSHIP.note_subpath_used(subpath) + if oup.subpaths: + for pubkey, subpath in oup.subpaths.items(): + if subpath[0] != self.my_xfp: + # for multisig, will be N paths, and exactly one will + # be our key. For single-signer, should always be my XFP + continue + + # derive actual pubkey from private + skp = keypath_to_str(subpath) + node = sv.derive_path(skp) + + # check the pubkey of this BIP-32 node + if pubkey == node.pubkey(): + good += 1 + + if oup.taproot_subpaths: + for xonly_pk, val in oup.taproot_subpaths.items(): + leaf_hashes, subpath = val[0], val[1:] + if subpath[0] != self.my_xfp: + # for multisig, will be N paths, and exactly one will + # be our key. For single-signer, should always be my XFP + continue + + # derive actual pubkey from private + skp = keypath_to_str(subpath) + node = sv.derive_path(skp) + + # check the pubkey of this BIP-32 node + if xonly_pk == node.pubkey()[1:]: + good += 1 + + OWNERSHIP.note_subpath_used(subpath) if not good: raise FraudulentChangeOutput(out_idx, @@ -1891,47 +2234,90 @@ def sign_it(self): continue txi.scriptSig = inp.scriptSig - assert txi.scriptSig, "no scriptsig?" - + schnorrsig = False + tr_sh = [] inp.handle_none_sighash() - if inp.is_multisig: + to_sign = [] + if isinstance(inp.required_key, set) and (inp.is_multisig or inp.is_miniscript): # need to consider a set of possible keys, since xfp may not be unique for which_key in inp.required_key: # get node required - skp = keypath_to_str(inp.subpaths[which_key]) + if inp.taproot_subpaths: # this can be set to False even if we haev script ready, but can send keypath + # tapscript + schnorrsig = True + # previously internal keys would be filtered here with if item[0] + # as per BIP-371 first item is leaf hashes which has to be empty for internal key + xfp_paths = [item[1:] for item in inp.taproot_subpaths.values()] + int_path = inp.taproot_subpaths[which_key][1:] + skp = keypath_to_str(int_path) + else: + xfp_paths = list(inp.subpaths.values()) + int_path = inp.subpaths[which_key] + skp = keypath_to_str(int_path) + node = sv.derive_path(skp, register=False) # expensive test, but works... and important pu = node.pubkey() if pu == which_key: - break - else: - raise AssertionError("Input #%d needs pubkey I dont have" % in_idx) + to_sign.append(node) + if len(which_key) == 32 and pu[1:] == which_key: + # get the script + inner_tr_sh = [] + assert self.active_miniscript + der_d = self.active_miniscript.derive_desc(xfp_paths) + for (script, lv), cb in inp.taproot_scripts.items(): + target_leaf = None + # always exact check/match the script, if we would generate such + for leaf in der_d.tapscript.iter_leaves(der_d.tapscript.tree): + sc = leaf.compile() + if sc == script: + target_leaf = leaf + break + else: + continue + + if which_key in [k.key_bytes() for k in target_leaf.keys]: + inner_tr_sh.append((script, lv)) + + to_sign.append(node) + tr_sh.append(inner_tr_sh) else: # single pubkey <=> single key which_key = inp.required_key - - assert not inp.added_sig, "already done??" - assert which_key in inp.subpaths, 'unk key' + assert not inp.part_sig, "already done??" + assert not inp.taproot_key_sig, "already done taproot??" - if inp.subpaths[which_key][0] != self.my_xfp: + if inp.subpaths and inp.subpaths.get(which_key) and inp.subpaths[which_key][0] == self.my_xfp: + skp = keypath_to_str(inp.subpaths[which_key]) + # get node required + node = sv.derive_path(skp, register=False) + # expensive test, but works... and important + pu = node.pubkey() + elif inp.taproot_subpaths and inp.taproot_subpaths.get(which_key) \ + and inp.taproot_subpaths[which_key][1] == self.my_xfp: + + skp = keypath_to_str(inp.taproot_subpaths[which_key][1:]) # ignore leaf hashes + # get node required + node = sv.derive_path(skp, register=False) + # expensive test, but works... and important + pu = node.pubkey()[1:] + schnorrsig = True + else: # we don't have the key for this subkey # (redundant, required_key wouldn't be set) continue - # get node required - skp = keypath_to_str(inp.subpaths[which_key]) - node = sv.derive_path(skp, register=False) - - # expensive test, but works... and important - pu = node.pubkey() assert pu == which_key, \ "Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx) + to_sign.append(node) + # track wallet usage - OWNERSHIP.note_subpath_used(inp.subpaths[which_key]) + subp = inp.taproot_subpaths[which_key] if schnorrsig else inp.subpaths[which_key] + OWNERSHIP.note_subpath_used(subp) if sv.deltamode: # Current user is actually a thug with a slightly wrong PIN, so we @@ -1944,64 +2330,111 @@ def sign_it(self): digest = self.make_txn_sighash(in_idx, txi, inp.sighash) else: # Hash the inputs and such in totally new ways, based on BIP-143 - digest = self.make_txn_segwit_sighash(in_idx, txi, - inp.amount, inp.scriptCode, inp.sighash) + if not inp.taproot_subpaths: + digest = self.make_txn_segwit_sighash(in_idx, txi, inp.amount, inp.scriptCode, inp.sighash) + elif tr_sh: + pass # later() + else: + digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash) # The precious private key we need - pk = node.privkey() - - #print("privkey %s" % b2a_hex(pk).decode('ascii')) - #print(" pubkey %s" % b2a_hex(which_key).decode('ascii')) - #print(" digest %s" % b2a_hex(digest).decode('ascii')) - - # Do the ACTUAL signature ... finally!!! - - # We need to grind sometimes to get a positive R - # value that will encode (after DER) into a shorter string. - # - saves on miner's fee (which might be expected/required) - # - blends in with Bitcoin Core signatures which do this from 0.17.0 - - n = 0 # retry num - while True: - # time to produce signature on stm32: ~25.1ms - result = ngu.secp256k1.sign(pk, digest, n).to_bytes() - - if result[1] < 0x80: - # - no need to check for low S value as those are generated by default - # by secp256k1 lib - # - to produce 71 bytes long signature (both low S low R values), - # we need on average 2 retries - # - worst case ~25 grinding iterations need to be performed total - break - - n += 1 - - # DER serialization after we have low S and low R values in our signature - r = result[1:33] - s = result[33:65] - der_sig = ser_sig_der(r, s, inp.sighash) - - # private key no longer required - stash.blank_object(pk) - stash.blank_object(node) - del pk, node, pu, skp, n - - inp.added_sig = (which_key, der_sig) - - # Could remove sighash from input object - it is not required, takes space, - # and is already in signature or is implicit by not being part of the - # signature (taproot SIGHASH_DEFAULT) - ## inp.sighash = None - - success.add(in_idx) - - if self.is_v2: - self.set_modifiable_flag(inp) - - # memory cleanup - del result, r, s - - gc.collect() + if not inp.taproot_script_sigs: + inp.taproot_script_sigs = {} + + if not inp.part_sig: + inp.part_sig = {} + + for i, node in enumerate(to_sign): + sk = node.privkey() + kp = ngu.secp256k1.keypair(sk) + pk = node.pubkey() + xonly_pk = kp.xonly_pubkey().to_bytes() + + # print("privkey %s" % b2a_hex(sk).decode('ascii')) + # print(" pubkey %s" % b2a_hex(pk).decode('ascii')) + # print(" digest %s" % b2a_hex(digest).decode('ascii')) + + # Do the ACTUAL signature ... finally!!! + if schnorrsig: + if tr_sh: + # in tapscript keys are not tweaked, just sign with the key in the script + for taproot_script, leaf_ver in tr_sh[i]: + _key = (xonly_pk, tapleaf_hash(taproot_script, leaf_ver)) + if _key in inp.taproot_script_sigs: + continue + + digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash, + scriptpath=True, + script=taproot_script, leaf_ver=leaf_ver) + sig = ngu.secp256k1.sign_schnorr(sk, digest, ngu.random.bytes(32)) + if inp.sighash != SIGHASH_DEFAULT: + sig += bytes([inp.sighash]) + # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by + # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed + inp.taproot_script_sigs[_key] = sig + else: + # BIP 341 states: "If the spending conditions do not require a script path, + # the output key should commit to an unspendable script path instead of having no script path. + # This can be achieved by computing the output key point as Q = P + int(hashTapTweak(bytes(P)))G." + internal_key = xonly_pk + tweak = internal_key + if inp.taproot_merkle_root is not None: + # we have a script path but internal key is spendable by us + # merkle root needs to be added to tweak with internal key + # merkle root was already verified against registered script in determine_my_signing_key + tweak += self.get(inp.taproot_merkle_root) + tweak = ngu.secp256k1.tagged_sha256(b"TapTweak", tweak) + kpt = kp.xonly_tweak_add(tweak) + sig = ngu.secp256k1.sign_schnorr(kpt, digest, ngu.random.bytes(32)) + if inp.sighash != SIGHASH_DEFAULT: + sig += bytes([inp.sighash]) + # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by + # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed + inp.taproot_key_sig = sig + else: + # We need to grind sometimes to get a positive R + # value that will encode (after DER) into a shorter string. + # - saves on miner's fee (which might be expected/required) + # - blends in with Bitcoin Core signatures which do this from 0.17.0 + + n = 0 # retry num + while True: + # time to produce signature on stm32: ~25.1ms + result = ngu.secp256k1.sign(sk, digest, n).to_bytes() + + if result[1] < 0x80: + # - no need to check for low S value as those are generated by default + # by secp256k1 lib + # - to produce 71 bytes long signature (both low S low R values), + # we need on average 2 retries + # - worst case ~25 grinding iterations need to be performed total + break + + n += 1 + + # DER serialization after we have low S and low R values in our signature + r = result[1:33] + s = result[33:65] + der_sig = ser_sig_der(r, s, inp.sighash) + inp.part_sig[pk] = der_sig + # memory cleanup + del result, r, s + + # private key no longer required + stash.blank_object(sk) + stash.blank_object(node) + del sk, node + + # Could remove sighash from input object - it is not required, takes space, + # and is already in signature or is implicit by not being part of the + # signature (taproot SIGHASH_DEFAULT) + ## inp.sighash = None + + success.add(in_idx) + gc.collect() + + if self.is_v2: + self.set_modifiable_flag(inp) # done. dis.progress_bar_show(1) @@ -2092,6 +2525,111 @@ def make_txn_sighash(self, replace_idx, replacement, sighash_type): # double SHA256 return ngu.hash.sha256s(rv.digest()) + def make_txn_taproot_sighash(self, input_index, hash_type=SIGHASH_DEFAULT, scriptpath=False, script=None, + codeseparator_pos=-1, annex=None, leaf_ver=TAPROOT_LEAF_TAPSCRIPT): + # BIP-341 + fd = self.fd + old_pos = fd.tell() + + out_type = SIGHASH_ALL if (hash_type == 0) else (hash_type & 3) + in_type = hash_type & SIGHASH_ANYONECANPAY + + if not self.hashValues and in_type != SIGHASH_ANYONECANPAY: + hashPrevouts = sha256() + hashSequence = sha256() + hashValues = sha256() + hashScriptPubKeys = sha256() + # input side + for in_idx, txi in self.input_iter(): + hashPrevouts.update(txi.prevout.serialize()) + hashSequence.update(pack(" @@ -2184,11 +2722,12 @@ def is_complete(self): # plus we added some signatures for inp in self.inputs: - if inp.is_multisig: - # but we can't combine/finalize multisig stuff, so will never't be 'final' + if inp.is_multisig or (inp.is_miniscript and not inp.use_keypath): + # but we can't combine/finalize multisig/miniscript stuff, so will never't be 'final' return False - - if inp.added_sig: + if inp.part_sig and len(inp.part_sig) == len(inp.subpaths): + signed += 1 + if inp.taproot_key_sig: signed += 1 return signed == self.num_inputs @@ -2231,10 +2770,11 @@ def finalize(self, fd): else: # insert the new signature(s), assuming fully signed txn. - assert inp.added_sig, 'No signature on input #%d'%in_idx + assert inp.part_sig, 'No signature on input #%d' % in_idx + assert len(inp.part_sig) < 2, 'More signatures on input #%d' % in_idx assert not inp.is_multisig, 'Multisig PSBT combine not supported' - pubkey, der_sig = inp.added_sig + pubkey, der_sig = list(inp.part_sig.items())[0] s = b'' s += ser_push_data(der_sig) @@ -2261,14 +2801,22 @@ def finalize(self, fd): for in_idx, wit in self.input_witness_iter(): inp = self.inputs[in_idx] - if inp.is_segwit and inp.added_sig: + if inp.is_segwit and (inp.part_sig or inp.taproot_key_sig): # put in new sig: wit is a CTxInWitness assert not wit.scriptWitness.stack, 'replacing non-empty?' assert not inp.is_multisig, 'Multisig PSBT combine not supported' - pubkey, der_sig = inp.added_sig - assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey" - wit.scriptWitness.stack = [der_sig, pubkey] + # TODO tapscript can also be non multisig, we are not able to finalize that - yet + if inp.taproot_key_sig: + # segwit v1 (taproot) + # can be 65 bytes if sighash != SIGHASH_DEFAULT (0x00) + assert len(inp.taproot_key_sig) in (64, 65) + wit.scriptWitness.stack = [inp.taproot_key_sig] + else: + # segwit v0 + pubkey, der_sig = list(inp.part_sig.items())[0] + assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey" + wit.scriptWitness.stack = [der_sig, pubkey] fd.write(wit.serialize()) diff --git a/shared/seed.py b/shared/seed.py index 38627a4cf..ec82a81e4 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -423,9 +423,11 @@ async def add_seed_to_vault(encoded, meta=None): if not settings.master_get("seedvault", False): # seed vault disabled + # this can be re-enabled by attacker in deltamode return - if pa.is_secret_blank(): + if pa.is_secret_blank() or pa.is_deltamode(): # do not save anything if no SE secret yet + # do not offer any access to SV in deltamode return # do not offer to store secrets that are already in vault @@ -828,42 +830,37 @@ async def _set(menu, label, item): async def _remove(menu, label, item): from glob import dis, settings + esc = "" + tmp_val = False idx, xfp_str, encoded = item.arg + current_active = (pa.tmp_value == bytes(encoded)) - msg = ("Remove seed from seed vault and delete its " - "settings?\n\nPress %s to continue, press (1) to " - "only remove from seed vault and keep " - "encrypted settings for later use.\n\n" - "WARNING: Funds will be lost if wallet is" - " not backed-up elsewhere.") % OK + msg = "Remove seed from seed vault " + if pa.tmp_value and current_active: + tmp_val = True + msg += "?\n\n" + else: + msg += ("and delete its settings?\n\n" + "Press %s to continue, press (1) to " + "only remove from seed vault and keep " + "encrypted settings for later use.\n\n") % OK + esc += "1" - ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape="1") + msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere." + + ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape=esc) if ch == "x": return dis.fullscreen("Saving...") - wipe_slot = (ch != "1") - tmp_val = False - - if pa.tmp_value: - tmp_val = True + wipe_slot = not current_active and (ch != "1") if wipe_slot: - # are we deleting current active ephemeral wallet - # and its settings ? - # slot wiping - if tmp_val: - # wipe current settings - settings.blank() - pa.tmp_value = False - settings.return_to_master_seed() - else: - # in main settings - xs = SettingsObject() - xs.set_key(encoded) - xs.load() - xs.blank() - del xs + xs = SettingsObject() + xs.set_key(encoded) + xs.load() + xs.blank() + del xs # CAUTION: will get shadow copy if in tmp seed mode already seeds = settings.master_get("seeds", []) @@ -970,6 +967,12 @@ def construct(cls): from glob import settings from pincodes import pa + if pa.is_deltamode(): + # attacker has re-enabled SeedVault in Settings + import callgate + callgate.fast_wipe() + + rv = [] add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp) diff --git a/shared/serializations.py b/shared/serializations.py index 33de34543..ce2f34b2b 100755 --- a/shared/serializations.py +++ b/shared/serializations.py @@ -16,7 +16,6 @@ """ from ubinascii import hexlify as b2a_hex -from ubinascii import unhexlify as a2b_hex import ustruct as struct import ngu from opcodes import * @@ -30,6 +29,7 @@ def bytes_to_hex_str(s): return str(b2a_hex(s), 'ascii') +SIGHASH_DEFAULT = const(0) # in taproot meaning same as SIGHASH_ALL (over whole TX) SIGHASH_ALL = const(1) SIGHASH_NONE = const(2) SIGHASH_SINGLE = const(3) @@ -37,6 +37,7 @@ def bytes_to_hex_str(s): # list containing all flags that we support signing for ALL_SIGHASH_FLAGS = [ + SIGHASH_DEFAULT, SIGHASH_ALL, SIGHASH_NONE, SIGHASH_SINGLE, @@ -56,14 +57,20 @@ def ser_compact_size(l): else: return struct.pack("= 4, 'too short key path' + assert (length % 4) == 0, 'corrupt key path' + assert (length // 4) <= MAX_PATH_DEPTH, 'too deep' + class DecodeStreamer: def __init__(self): self.runt = bytearray() @@ -431,7 +436,7 @@ def clean_shutdown(style=0): # wipe SPI flash and shutdown (wiping main memory) # - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom) # - bootrom wipes every byte of SRAM, so no need to repeat here - import callgate, version, uasyncio + import callgate, uasyncio # save if anything pending from glob import settings @@ -507,9 +512,7 @@ def word_wrap(ln, w): def parse_addr_fmt_str(addr_fmt): # accepts strings and also integers if already parsed - from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH - - if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]: + if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC, AF_P2TR]: return addr_fmt addr_fmt = addr_fmt.lower() @@ -519,9 +522,10 @@ def parse_addr_fmt_str(addr_fmt): return AF_CLASSIC elif addr_fmt == "p2wpkh": return AF_P2WPKH + elif addr_fmt == "p2tr": + return AF_P2TR else: - raise ValueError("Invalid address format: '%s'\n\n" - "Choose from p2pkh, p2wpkh, p2sh-p2wpkh." % addr_fmt) + raise ValueError("Unsupported address format: '%s'" % addr_fmt) def parse_extended_key(ln, private=False): # read an xpub/ypub/etc and return BIP-32 node and what chain it's on. @@ -563,7 +567,10 @@ def addr_fmt_label(addr_fmt): return { AF_CLASSIC: "Classic P2PKH", AF_P2WPKH_P2SH: "P2SH-Segwit", - AF_P2WPKH: "Segwit P2WPKH" + AF_P2WPKH: "Segwit P2WPKH", + AF_P2TR: "Taproot P2TR", + AF_P2WSH: "Segwit P2WSH", + AF_P2WSH_P2SH: "P2SH-P2WSH" }[addr_fmt] @@ -615,11 +622,6 @@ def datetime_to_str(dt, fmt="%d-%02d-%02d %02d:%02d:%02d"): dts = fmt % (y, mo, d, h, mi, s) return dts + " UTC" -def censor_address(addr): - # We don't like to show the user multisig addresses because we cannot be certain - # they are valid and could actually be signed. And yet, dont blank too many - # spots or else an attacker could grind out a suitable replacement. - return addr[0:12] + '___' + addr[12+3:] def txid_from_fname(fname): if len(fname) >= 64: @@ -698,7 +700,95 @@ def decode_bip21_text(got): raise ValueError('not bip-21') +def check_xpub(xfp, xpub, deriv, expect_chain, my_xfp, disable_checks=False): + # Shared code: consider an xpub for inclusion into a wallet + # return T if it's our own key and parsed details in form (xfp, deriv, xpub) + # - deriv can be None, and in very limited cases can recover derivation path + # - could enforce all same depth, and/or all depth >= 1, but + # seems like more restrictive than needed, so "m" is allowed + import stash + from public_constants import AF_P2SH + try: + # Note: addr fmt detected here via SLIP-132 isn't useful + node, chain, _ = parse_extended_key(xpub) + except: + raise AssertionError('unable to parse xpub') + + try: + assert node.privkey() == None # 'no privkeys plz' + except ValueError: + pass + + if expect_chain == "XRT": + # HACK but there is no difference extended_keys - just bech32 hrp + assert chain.ctype == "XTN" + else: + assert chain.ctype == expect_chain, 'wrong chain' + + depth = node.depth() + + if depth == 1: + if not xfp: + # allow a shortcut: zero/omit xfp => use observed parent value + xfp = swab32(node.parent_fp()) + else: + # generally cannot check fingerprint values, but if we can, do so. + if not disable_checks: + assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong' + + assert xfp, 'need fingerprint' # happens if bare xpub given + + # In most cases, we cannot verify the derivation path because it's hardened + # and we know none of the private keys involved. + if depth == 1: + # but derivation is implied at depth==1 + kn, is_hard = node.child_number() + if is_hard: kn |= 0x80000000 + guess = keypath_to_str([kn], skip=0) + + if deriv: + if not disable_checks: + assert guess == deriv, '%s != %s' % (guess, deriv) + else: + deriv = guess # reachable? doubt it + + assert deriv, 'empty deriv' # or force to be 'm'? + assert deriv[0] == 'm' + + # path length of derivation given needs to match xpub's depth + if not disable_checks: + p_len = deriv.count('/') + assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( + p_len, depth, xfp2str(xfp)) + + if xfp == my_xfp: + # its supposed to be my key, so I should be able to generate pubkey + # - might indicate collision on xfp value between co-signers, + # and that's not supported + with stash.SensitiveValues() as sv: + chk_node = sv.derive_path(deriv) + assert node.pubkey() == chk_node.pubkey(), \ + "[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:]) + + # serialize xpub w/ BIP-32 standard now. + # - this has effect of stripping SLIP-132 confusion away + return xfp == my_xfp, (xfp, deriv, chain.serialize_public(node, AF_P2SH)) + + +def truncate_address(addr): + # Truncates address to width of screen, replacing middle chars + if not version.has_qwerty: + # - 16 chars screen width + # - but 2 lost at left (menu arrow, corner arrow) + # - want to show not truncated on right side + return addr[0:6] + '⋯' + addr[-6:] + else: + # tons of space on Q1 + return addr[0:12] + '⋯' + addr[-12:] + + def encode_seed_qr(words): return ''.join('%04d' % bip39.get_word_index(w) for w in words) + # EOF diff --git a/shared/ux.py b/shared/ux.py index 813fa8094..6259e445e 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -456,7 +456,7 @@ def import_export_prompt_decode(ch): async def import_export_prompt(what_it_is, is_import=False, no_qr=False, no_nfc=False, title=None, intro='', footnotes='', - slot_b_only=False): + slot_b_only=False, force_prompt=False): # Show story allowing user to select source for importing/exporting # - return either str(mode) OR dict(file_args) # - KEY_NFC or KEY_QR for those sources @@ -466,7 +466,8 @@ async def import_export_prompt(what_it_is, is_import=False, no_qr=False, if is_import: prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only) else: - prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc) + prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc, + force_prompt=force_prompt) # TODO: detect if we're only asking A or B, when just one card is inserted # - assume that's what they want to do diff --git a/shared/ux_q1.py b/shared/ux_q1.py index ba55f8382..e8a32e3fa 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -934,12 +934,13 @@ async def scan_anything(self, expect_secret=False, tmp=False): await ux_visualize_bip21(proto, addr, args) return - if what == "multi": + if what in ("multi", "minisc"): from auth import maybe_enroll_xpub from ux import ux_show_story ms_config, = vals try: - maybe_enroll_xpub(config=ms_config) + maybe_enroll_xpub(config=ms_config, + miniscript=False if what == "multi" else None) except Exception as e: await ux_show_story( 'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) diff --git a/shared/version.py b/shared/version.py index 1a528751a..69d1b855c 100644 --- a/shared/version.py +++ b/shared/version.py @@ -122,6 +122,9 @@ def probe_system(): # what firmware signing key did we boot with? are we in dev mode? is_devmode = get_is_devmode() + # newer, edge code in effect? + is_edge = (get_mpy_version()[1][-1] == 'X') + # increase size limits for mk4 from public_constants import MAX_TXN_LEN_MK4, MAX_UPLOAD_LEN_MK4 MAX_UPLOAD_LEN = MAX_UPLOAD_LEN_MK4 diff --git a/shared/wallet.py b/shared/wallet.py index 016b8a32b..666a05d09 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -3,12 +3,17 @@ # wallet.py - A place you find UTXO, addresses and descriptors. # import chains -from descriptor import Descriptor -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from glob import settings +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from stash import SensitiveValues + MAX_BIP32_IDX = (2 ** 31) - 1 +class WalletOutOfSpace(RuntimeError): + pass + + class WalletABC: # How to make this ABC useful without consuming memory/code space?? # - be more of an "interface" than a base class @@ -40,8 +45,10 @@ def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None): # Construct a wallet based on current master secret, and chain. # - path is optional, and then we use standard path for addr_fmt # - path can be overriden when we come here via address explorer - - if addr_fmt == AF_P2WPKH: + if addr_fmt == AF_P2TR: + n = 'Taproot P2TR' + prefix = path or 'm/86h/{coin_type}h/{account}h' + elif addr_fmt == AF_P2WPKH: n = 'Segwit P2WPKH' prefix = path or 'm/84h/{coin_type}h/{account}h' elif addr_fmt == AF_CLASSIC: @@ -66,7 +73,6 @@ def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None): if self.chain.ctype == 'XRT': n += ' (Regtest)' - self.name = n self.addr_fmt = addr_fmt @@ -82,7 +88,6 @@ def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None): self._path = p - def yield_addresses(self, start_idx, count, change_idx=None): # Render a range of addresses. Slow to start, since accesses SE in general # - if count==1, don't derive any subkey, just do path. @@ -126,10 +131,132 @@ def render_path(self, change_idx, idx): def to_descriptor(self): from glob import settings + from descriptor import Descriptor, Key xfp = settings.get('xfp') xpub = settings.get('xpub') - keys = (xfp, self._path, xpub) - return Descriptor([keys], self.addr_fmt) + d = Descriptor(key=Key.from_cc_data(xfp, self._path, xpub)) + d.set_from_addr_fmt(self.addr_fmt) + return d + + +class BaseStorageWallet(WalletABC): + key_name = None + + def __init__(self, chain_type=None): + self.storage_idx = -1 + self.chain_type = chain_type or 'BTC' + + @property + def chain(self): + return chains.get_chain(self.chain_type) + + @classmethod + def none_setup_yet(cls, other_chain=False): + return '(none setup yet)' + ("*" if other_chain else "") + + @classmethod + def is_correct_chain(cls, o, curr_chain): + if o[1] is None: + # mainnet + ch = "BTC" + else: + ch = o[1] + + if ch == curr_chain.ctype: + return True + return False + + @classmethod + def exists(cls): + # are there any wallets defined? + exists = False + exists_other_chain = False + c = chains.current_key_chain() + for o in settings.get(cls.key_name, []): + if cls.is_correct_chain(o, c): + exists = True + else: + exists_other_chain = True + + return exists, exists_other_chain + + @classmethod + def get_all(cls): + # return them all, as a generator + return cls.iter_wallets() + + @classmethod + def iter_wallets(cls): + # - this is only place we should be searching this list, please!! + lst = settings.get(cls.key_name, []) + c = chains.current_key_chain() + + for idx, rec in enumerate(lst): + if cls.is_correct_chain(rec, c): + yield cls.deserialize(rec, idx) + + def serialize(self): + raise NotImplemented + + @classmethod + def deserialize(cls, c, idx=-1): + raise NotImplemented + + @classmethod + def get_by_idx(cls, nth): + # instance from index number (used in menu) + lst = settings.get(cls.key_name, []) + try: + obj = lst[nth] + except IndexError: + return None + + return cls.deserialize(obj, nth) + + def commit(self): + # data to save + # - important that this fails immediately when nvram overflows + obj = self.serialize() + + v = settings.get(self.key_name, []) + orig = v.copy() + if not v or self.storage_idx == -1: + # create + self.storage_idx = len(v) + v.append(obj) + else: + # update in place + v[self.storage_idx] = obj + + settings.set(self.key_name, v) + + # save now, rather than in background, so we can recover + # from out-of-space situation + try: + settings.save() + except: + # back out change; no longer sure of NVRAM state + try: + settings.set(self.key_name, orig) + settings.save() + except: pass # give up on recovery + + raise WalletOutOfSpace + + def delete(self): + # remove saved entry + # - important: not expecting more than one instance of this class in memory + assert self.storage_idx >= 0 + lst = settings.get(self.key_name, []) + try: + del lst[self.storage_idx] + if lst: + settings.set(self.key_name, lst) + else: + settings.remove_key(self.key_name) + settings.save() # actual write + except IndexError: pass + self.storage_idx = -1 # EOF diff --git a/stm32/COLDCARD_MK4/file_time.c b/stm32/COLDCARD_MK4/file_time.c index 6dde832ab..6526886cc 100644 --- a/stm32/COLDCARD_MK4/file_time.c +++ b/stm32/COLDCARD_MK4/file_time.c @@ -2,12 +2,12 @@ // // AUTO-generated. // -// built: 2024-09-12 -// version: 5.4.0 +// built: 2024-07-04 +// version: 6.3.3X // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x592c2880UL; + return 0x58e43060UL; } diff --git a/stm32/MK4-Makefile b/stm32/MK4-Makefile index 02b543de9..a346daba6 100644 --- a/stm32/MK4-Makefile +++ b/stm32/MK4-Makefile @@ -19,7 +19,7 @@ LATEST_RELEASE = $(shell ls -t1 ../releases/*-mk4-*.dfu | head -1) # Our version for this release. # - caution, the bootrom will not accept version < 3.0.0 -VERSION_STRING = 5.4.0 +VERSION_STRING = 6.3.4X # keep near top, because defined default target (all) include shared.mk diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile index 323e8d431..9dce75d18 100644 --- a/stm32/Q1-Makefile +++ b/stm32/Q1-Makefile @@ -16,7 +16,7 @@ BOOTLOADER_DIR = q1-bootloader LATEST_RELEASE = $(shell ls -t1 ../releases/*-q1-*.dfu | head -1) # Our version for this release. -VERSION_STRING = 1.3.0Q +VERSION_STRING = 6.3.4QX # Remove this closer to shipping. #$(warning "Forcing debug build") diff --git a/testing/api.py b/testing/api.py index 2522dbce8..b8bfe1e8b 100644 --- a/testing/api.py +++ b/testing/api.py @@ -239,7 +239,6 @@ def bitcoind_d_sim_watch(bitcoind): descriptors = [ { "timestamp": "now", - "label": "Coldcard 0f056943 segwit v0", "active": True, "desc": "wpkh([0f056943/84h/1h/0h]tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/0/*)#erexmnep", "internal": False @@ -252,7 +251,6 @@ def bitcoind_d_sim_watch(bitcoind): }, { "timestamp": "now", - "label": "Coldcard 0f056943 segwit v1", "active": True, "desc": "tr([0f056943/86h/1h/0h]tpubDCeEX49avtiXrBTv3JWTtco99Ka499jXdZHBRtm7va2gkMAui11ctZjqNAT9dLVNaEozt2C1kfTM88cnvZCXsWLJN2p4viGvsyGjtKVV7A1/0/*)#6ghw47ge", "internal": False @@ -265,7 +263,6 @@ def bitcoind_d_sim_watch(bitcoind): }, { "timestamp": "now", - "label": "Coldcard 0f056943 p2pkh", "active": True, "desc": "pkh([0f056943/44h/1h/0h]tpubDCiHGUNYdRRBPNYm7CqeeLwPWfeb2ZT2rPsk4aEW3eUoJM93jbBa7hPpB1T9YKtigmjpxHrB1522kSsTxGm9V6cqKqrp1EDaYaeJZqcirYB/0/*)#fxwk08tc", "internal": False @@ -278,7 +275,6 @@ def bitcoind_d_sim_watch(bitcoind): }, { "timestamp": "now", - "label": "Coldcard 0f056943 p2sh-p2wpkh", "active": True, "desc": "sh(wpkh([0f056943/49h/1h/0h]tpubDCDqt7XXvhAYY9HSwrCXB7BXqYM4RXB8WFtKgtTXGa6u3U6EV1NJJRFTcuTRyhSY5Vreg1LP8aPdyiAPQGrDJLikkHoc7VQg6DA9NtUxHtj/0/*))#weah3vek", "internal": False @@ -324,7 +320,6 @@ def bitcoind_d_sim_sign(bitcoind): descriptors = [ { "timestamp": "now", - "label": "Coldcard 0f056943", "active": True, "desc": "wpkh([0f056943/84h/1h/0h]tprv8fRh8AYC5iQitbbtzwVaUUyXVZh3Y7HxVYSbqzf45eao9SMfEc3MexJx4y6pU1WjjxcEiYArEjhRTSy5mqfXzBtSncTYhKfxQWywcfeqxFE/0/*)#mzg0pna0", "internal": False @@ -337,7 +332,6 @@ def bitcoind_d_sim_sign(bitcoind): }, { "timestamp": "now", - "label": "Coldcard 0f056943 segwit v1", "active": True, "desc": "tr([0f056943/86h/1h/0h]tprv8fxCNe7LnX2rxiS89eqsVD92aJ47ypYd4FgQ9NipWJEHurv95cC2i57yC2mRHnpuHfmgdb17GV9wfSNjswUQXmaY7Qs2Jaa5hEdkxaHy4BK/0/*)#x7dfk9mw", "internal": False @@ -350,7 +344,6 @@ def bitcoind_d_sim_sign(bitcoind): }, { "timestamp": "now", - "label": "Coldcard 0f056943", "active": True, "desc": "pkh([0f056943/44h/1h/0h]tprv8g2F84LJV3jWVuWyDZB4EwHGwe8esEG8H6Gxn4CCdNgQTrtH7CMywCmwzuMGZjz13sQ9rcCZucCm6i2zigkYGSPUvCzDQxGW8RCy7FpPdrg/0/*)#kjnlnm3v", "internal": False @@ -363,7 +356,6 @@ def bitcoind_d_sim_sign(bitcoind): }, { "timestamp": "now", - "label": "Coldcard 0f056943", "active": True, "desc": "sh(wpkh([0f056943/49h/1h/0h]tprv8fXojhVHnKUsegFf4CXvmhXRGWq8GBzDvxHYQNRDrJJWCyqTrcYi7vdbSn65CHETVPdw4sxc75v23Ev7o8fCePazRf917CMt1C3mjnKV4Jq/0/*))#0qf5gv2y", "internal": False diff --git a/testing/bip32.py b/testing/bip32.py index fe10bd752..d52867dc3 100644 --- a/testing/bip32.py +++ b/testing/bip32.py @@ -6,8 +6,9 @@ try: from pysecp256k1 import ( ec_seckey_verify, ec_pubkey_create, ec_pubkey_serialize, ec_pubkey_parse, - ec_seckey_tweak_add, ec_pubkey_tweak_add, + ec_seckey_tweak_add, ec_pubkey_tweak_add, tagged_sha256 ) + from pysecp256k1.extrakeys import xonly_pubkey_from_pubkey, xonly_pubkey_serialize, xonly_pubkey_tweak_add except ImportError: import ecdsa SECP256k1 = ecdsa.curves.SECP256k1 @@ -119,6 +120,10 @@ def tweak_add(self, tweak32: bytes) -> "PrivateKey": tweaked = ec_seckey_tweak_add(self.k, tweak32) return PrivateKey(sec_exp=tweaked) + def address(self, compressed: bool = True, chain: str = "BTC", + addr_fmt: str = "p2wpkh") -> str: + return self.K.address(compressed, chain, addr_fmt) + @classmethod def from_wif(cls, wif_str: str) -> "PrivateKey": """ @@ -193,8 +198,17 @@ def sec(self, compressed: bool = True) -> bytes: return self.K.to_string(encoding="compressed" if compressed else "uncompressed") def tweak_add(self, tweak32: bytes) -> "PublicKey": + assert len(tweak32) == 32 return PublicKey(pub_key=ec_pubkey_tweak_add(self.K, tweak32)) + def taptweak(self, tweak32: bytes = None) -> "bytes": + xonly_key, _ = xonly_pubkey_from_pubkey(self.K) + tweak = tweak32 or xonly_pubkey_serialize(xonly_key) + tweak = tagged_sha256(b"TapTweak", tweak) + tweaked_pubkey = xonly_pubkey_tweak_add(xonly_key, tweak) + tweaked_xonly_pubkey, parity = xonly_pubkey_from_pubkey(tweaked_pubkey) + return xonly_pubkey_serialize(tweaked_xonly_pubkey) + @classmethod def parse(cls, key_bytes: bytes) -> "PublicKey": """ @@ -227,7 +241,7 @@ def h160(self, compressed: bool = True) -> bytes: """ return hash160(self.sec(compressed=compressed)) - def address(self, compressed: bool = True, testnet: bool = False, + def address(self, compressed: bool = True, chain: str = "BTC", addr_fmt: str = "p2wpkh") -> str: """ Generates bitcoin address from public key. @@ -240,18 +254,33 @@ def address(self, compressed: bool = True, testnet: bool = False, 3. p2wpkh (default) :return: bitcoin address """ + if chain == "BTC": + hrp = "bc" + pkh_prefix = b"\x00" + sh_prefix = b"\x05" + else: + pkh_prefix = b"\x6f" + sh_prefix = b"\xc4" + if chain == "XRT": + hrp = "bcrt" + elif chain == "XTN": + hrp = "tb" + else: + assert False + + if addr_fmt == "p2tr": + tweaked_xonly = self.taptweak() + return bech32.encode(hrp=hrp, witver=1, witprog=tweaked_xonly) + h160 = self.h160(compressed=compressed) if addr_fmt == "p2pkh": - prefix = b"\x6f" if testnet else b"\x00" - return encode_base58_checksum(prefix + h160) + return encode_base58_checksum(pkh_prefix + h160) elif addr_fmt == "p2wpkh": - hrp = "tb" if testnet else "bc" return bech32.encode(hrp=hrp, witver=0, witprog=h160) elif addr_fmt == "p2sh-p2wpkh": scr = b"\x00\x14" + h160 # witversion 0 + pubkey hash h160 = hash160(scr) - prefix = b"\xc4" if testnet else b"\x05" - return encode_base58_checksum(prefix + h160) + return encode_base58_checksum(sh_prefix + h160) raise ValueError("Unsupported address type.") @@ -708,6 +737,12 @@ def from_hwif(cls, extended_key): ek = PubKeyNode.parse(extended_key, testnet) return cls(ek, netcode="XTN" if testnet else "BTC") + @classmethod + def from_chaincode_pubkey(cls, chain_code, pubkey, netcode="XTN"): + node = PubKeyNode(pubkey, chain_code, 0, 0, + False if netcode == "BTC" else True) + return cls(node, netcode=netcode) + def subkey_for_path(self, path): path_list = str_to_path(path) node = self.node @@ -730,9 +765,9 @@ def from_wallet_key(cls, extended_key): def hash160(self, compressed=True): return self.node.public_key.h160(compressed) - def address(self, compressed=True, netcode="XTN", addr_fmt="p2pkh"): + def address(self, compressed=True, chain="XTN", addr_fmt="p2pkh"): return self.node.public_key.address(compressed, addr_fmt=addr_fmt, - testnet=False if netcode == "BTC" else True) + chain=chain) def sec(self, compressed=True): return self.node.public_key.sec(compressed) diff --git a/testing/conftest.py b/testing/conftest.py index 7f71115b7..1acfd28e7 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,9 +1,9 @@ # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, pdb +import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, bech32, pdb from subprocess import check_output from ckcc.protocol import CCProtocolPacker -from helpers import B2A, U2SAT, hash160 +from helpers import B2A, U2SAT, hash160, taptweak from base58 import decode_base58_checksum from bip32 import BIP32Node from msg import verify_message @@ -293,26 +293,30 @@ def addr_vs_path(master_xpub): from bip32 import BIP32Node from ckcc_protocol.constants import AF_CLASSIC, AFC_PUBKEY, AF_P2WPKH, AFC_SCRIPT from ckcc_protocol.constants import AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH - from bech32 import bech32_decode, convertbits, Encoding + from bech32 import bech32_decode, convertbits, decode, Encoding from hashlib import sha256 - def doit(given_addr, path=None, addr_fmt=None, script=None, testnet=True): + def doit(given_addr, path=None, addr_fmt=None, script=None, chain="XTN"): if not script: try: # prefer using xpub if we can mk = BIP32Node.from_wallet_key(master_xpub) - if not testnet: - mk._netcode = "BTC" - sk = mk.subkey_for_path(path[2:]) + mk._netcode = chain + sk = mk.subkey_for_path(path) except: mk = BIP32Node.from_wallet_key(simulator_fixed_tprv) - if not testnet: - mk._netcode = "BTC" - sk = mk.subkey_for_path(path[2:]) + mk._netcode = chain + sk = mk.subkey_for_path(path) - if addr_fmt in {None, AF_CLASSIC}: + if addr_fmt == AF_P2TR: + tweaked_xonly = taptweak(sk.sec()[1:]) + decoded = decode(given_addr[:2], given_addr) + assert not given_addr.startswith("bcrt") # regtest + assert tweaked_xonly == bytes(decoded[1]) + + elif addr_fmt in {None, AF_CLASSIC}: # easy - assert sk.address(netcode="XTN" if testnet else "BTC") == given_addr + assert sk.address(chain=chain) == given_addr elif addr_fmt & AFC_PUBKEY: @@ -360,7 +364,6 @@ def doit(given_addr, path=None, addr_fmt=None, script=None, testnet=True): return doit - @pytest.fixture(scope='module') def capture_enabled(sim_eval): # need to have sim_display imported early, see unix/frozen-modules/ckcc @@ -622,6 +625,12 @@ def doit(): return doit +@pytest.fixture +def clear_miniscript(unit_test): + def doit(): + unit_test('devtest/wipe_miniscript.py') + return doit + @pytest.fixture(scope='module') def press_select(dev, has_qwerty): f = functools.partial(_press_select, dev, has_qwerty) @@ -1574,6 +1583,9 @@ def doit_usb(): def nfc_write(request, needs_nfc, is_q1): # WRITE data into NFC "chip" def doit_usb(ccfile): + from ckcc.constants import MAX_MSG_LEN + if len(ccfile) >= MAX_MSG_LEN: + pytest.xfail("MAX_MSG_LEN") sim_exec = request.getfixturevalue('sim_exec') press_select = request.getfixturevalue('press_select') rv = sim_exec('list(glob.NFC.big_write(%r))' % ccfile) @@ -1689,7 +1701,7 @@ def doit(name, path): return doit @pytest.fixture -def verify_detached_signature_file(microsd_path, virtdisk_path): +def verify_detached_signature_file(microsd_path, virtdisk_path, garbage_collector): def doit(fnames, sig_fname, way, addr_fmt=None): fpaths = [] for fname in fnames: @@ -1698,6 +1710,7 @@ def doit(fnames, sig_fname, way, addr_fmt=None): else: path = virtdisk_path(fname) fpaths.append(path) + garbage_collector.append(path) if way == "sd": sig_path = microsd_path(sig_fname) @@ -1738,9 +1751,7 @@ def doit(fnames, sig_fname, way, addr_fmt=None): assert (hashlib.sha256(contents).digest().hex() + fn_addendum) in msg assert verify_message(address, sig, msg) is True - try: - os.unlink(sig_path) - except: pass + garbage_collector.append(sig_path) return fcontents[0], address return doit @@ -1774,10 +1785,10 @@ def doit(export_story, way, addr_fmt=None, is_json=False, label="wallet", fpatte @pytest.fixture def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_text, nfc_read_json, load_export_and_verify_signature, is_q1, press_cancel, press_select, readback_bbqr, - cap_screen_qr): + cap_screen_qr, garbage_collector): def doit(way, label, is_json, sig_check=True, addr_fmt=AF_CLASSIC, ret_sig_addr=False, tail_check=None, sd_key=None, vdisk_key=None, nfc_key=None, ret_fname=False, - fpattern=None, qr_key=None): + fpattern=None, qr_key=None, skip_query=False): s_label = None if label == "Address summary": @@ -1789,54 +1800,55 @@ def doit(way, label, is_json, sig_check=True, addr_fmt=AF_CLASSIC, ret_sig_addr= "nfc": nfc_key or (KEY_NFC if is_q1 else "3"), "qr": qr_key or (KEY_QR if is_q1 else "4"), } - time.sleep(0.2) - title, story = cap_story() - if way == "sd": - if f"({key_map['sd']}) to save {s_label if s_label else label} file to SD Card" in story: - need_keypress(key_map['sd']) + if not skip_query: + time.sleep(0.2) + title, story = cap_story() + if way == "sd": + if f"({key_map['sd']}) to save {s_label if s_label else label} file to SD Card" in story: + need_keypress(key_map['sd']) - elif way == "nfc": - if f"{key_map['nfc'] if is_q1 else '(3)'} to share via NFC" not in story: - pytest.skip("NFC disabled") - else: - need_keypress(key_map['nfc']) - time.sleep(0.2) - if is_json: - nfc_export = nfc_read_json() + elif way == "nfc": + if f"{key_map['nfc'] if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") else: - nfc_export = nfc_read_text() + need_keypress(key_map['nfc']) + time.sleep(0.2) + if is_json: + nfc_export = nfc_read_json() + else: + nfc_export = nfc_read_text() + time.sleep(0.3) + press_cancel() # exit NFC animation + return nfc_export + elif way == "qr": + if 'file written' in story: + assert not is_q1 + # mk4 only does QR if fits in normal QR, becaise it can't do BBQr + pytest.skip('no BBQr on Mk4') + + need_keypress(key_map["qr"]) time.sleep(0.3) - press_cancel() # exit NFC animation - return nfc_export - elif way == "qr": - if 'file written' in story: - assert not is_q1 - # mk4 only does QR if fits in normal QR, becaise it can't do BBQr - pytest.skip('no BBQr on Mk4') - - need_keypress(key_map["qr"]) - time.sleep(0.3) - try: - file_type, data = readback_bbqr() - if file_type == "J": - return json.loads(data) - elif file_type == "U": - return data.decode('utf-8') if not isinstance(data, str) else data - else: - raise NotImplementedError - except: - raise - res = cap_screen_qr().decode('ascii') try: - return json.loads(res) + file_type, data = readback_bbqr() + if file_type == "J": + return json.loads(data) + elif file_type == "U": + return data.decode('utf-8') if not isinstance(data, str) else data + else: + raise NotImplementedError except: - return res - else: - # virtual disk - if f"({key_map['vdisk']}) to save to Virtual Disk" not in story: - pytest.skip("Vdisk disabled") + raise + res = cap_screen_qr().decode('ascii') + try: + return json.loads(res) + except: + return res else: - need_keypress(key_map['vdisk']) + # virtual disk + if f"({key_map['vdisk']}) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress(key_map['vdisk']) time.sleep(0.2) title, story = cap_story() @@ -1865,6 +1877,8 @@ def doit(way, label, is_json, sig_check=True, addr_fmt=AF_CLASSIC, ret_sig_addr= if is_json: export = json.loads(export) + garbage_collector.append(path) + press_select() if ret_sig_addr and sig_addr: @@ -1909,7 +1923,7 @@ def doit(way, testnet=True): return doit @pytest.fixture -def choose_by_word_length(need_keypress): +def choose_by_word_length(need_keypress, press_select): # for use in seed XOR menu system def doit(num_words): if num_words == 12: @@ -1917,7 +1931,7 @@ def doit(num_words): elif num_words == 18: need_keypress("2") else: - need_keypress("y") + press_select() return doit # workaround: need these fixtures to be global so I can call test from a test @@ -2151,6 +2165,9 @@ def doit(data, chain="XTN"): elif af in ("p2wpkh", "p2wsh"): target = "bc1q" if chain == "BTC" else "tb1q" assert addr.startswith(target) + elif af == "p2tr": + target = "bc1p" if chain == "BTC" else "tb1p" + assert addr.startswith(target) elif af in ("p2sh", "p2wpkh-p2sh", "p2wsh-p2sh"): target = "3" if chain == "BTC" else "2" assert addr.startswith(target) @@ -2194,6 +2211,30 @@ def doit(data, chain="XTN"): return doit +@pytest.fixture +def validate_address(): + # Check whether an address is covered by the given subkey + def doit(addr, sk): + if addr[0] in '1mn': + chain = "XTN" if addr[0] != "1" else "BTC" + assert addr == sk.address(addr_fmt="p2pkh", chain=chain) + elif addr[0:4] in {'bc1q', 'tb1q'}: + chain = "XTN" if addr[0:4] != 'bc1q' else "BTC" + assert addr == sk.address(addr_fmt="p2wpkh", chain=chain) + elif addr[0:6] == "bcrt1q": + assert addr == sk.address(addr_fmt="p2wpkh", chain="XRT") + elif addr[0:4] in {'bc1p', 'tb1p'}: + chain = "XTN" if addr[0:4] != 'bc1p' else "BTC" + assert addr == sk.address(addr_fmt="p2tr", chain=chain) + elif addr[0:6] == "bcrt1p": + assert addr == sk.address(addr_fmt="p2tr", chain="XRT") + elif addr[0] in '23': + chain = "XTN" if addr[0] != '3' else "BTC" + assert addr == sk.address(addr_fmt="p2sh-p2wpkh", chain=chain) + else: + raise ValueError(addr) + return doit + @pytest.fixture def skip_if_useless_way(is_q1, nfc_disabled): @@ -2220,7 +2261,8 @@ def dev_core_import_object(dev): ders = [ ("m/44h/1h/0h", AF_CLASSIC), ("m/49h/1h/0h", AF_P2WPKH_P2SH), - ("m/84h/1h/0h", AF_P2WPKH) + ("m/84h/1h/0h", AF_P2WPKH), + ("m/86h/1h/0h", AF_P2TR), ] descriptors = [] for idx, (path, addr_format) in enumerate(ders): @@ -2239,6 +2281,15 @@ def dev_core_import_object(dev): return descriptors +@pytest.fixture +def garbage_collector(): + to_remove = [] + yield to_remove + for pth in to_remove: + try: + os.remove(pth) + except: pass + # useful fixtures from test_backup import backup_system from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll @@ -2248,6 +2299,7 @@ def dev_core_import_object(dev): from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn +from test_miniscript import offer_minsc_import from test_multisig import make_ms_address, clear_ms, make_myself_wallet, import_multisig from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed from test_seed_xor import restore_seed_xor diff --git a/testing/constants.py b/testing/constants.py index 2a6192293..490eef6c3 100644 --- a/testing/constants.py +++ b/testing/constants.py @@ -25,9 +25,11 @@ 'p2wsh': AF_P2WSH, 'p2wsh-p2sh': AF_P2WSH_P2SH, 'p2sh-p2wsh': AF_P2WSH_P2SH, + "p2tr": AF_P2TR, } msg_sign_unmap_addr_fmt = { + 'p2tr': AF_P2TR, # not supported for msg signign tho 'p2pkh': AF_CLASSIC, 'p2wpkh': AF_P2WPKH, 'p2sh-p2wpkh': AF_P2WPKH_P2SH, @@ -35,6 +37,7 @@ } addr_fmt_names = { + AF_P2TR: 'p2tr', AF_CLASSIC: 'p2pkh', AF_P2SH: 'p2sh', AF_P2WPKH: 'p2wpkh', @@ -45,10 +48,10 @@ # all possible addr types, including multisig/scripts -ADDR_STYLES = ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh'] +ADDR_STYLES = ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh', 'p2tr'] # single-signer -ADDR_STYLES_SINGLE = ['p2wpkh', 'p2pkh', 'p2wpkh-p2sh'] +ADDR_STYLES_SINGLE = ['p2wpkh', 'p2pkh', 'p2wpkh-p2sh', 'p2tr'] # multi signer ADDR_STYLES_MS = ['p2sh', 'p2wsh', 'p2wsh-p2sh'] diff --git a/testing/data/taproot/in_internal_key_len.psbt b/testing/data/taproot/in_internal_key_len.psbt new file mode 100644 index 000000000..141da152a Binary files /dev/null and b/testing/data/taproot/in_internal_key_len.psbt differ diff --git a/testing/data/taproot/in_key_pth_sig_len.psbt b/testing/data/taproot/in_key_pth_sig_len.psbt new file mode 100644 index 000000000..1ea3c554b --- /dev/null +++ b/testing/data/taproot/in_key_pth_sig_len.psbt @@ -0,0 +1 @@ +70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a075701133f173bb3d36c074afb716fec6307a069a2e450b995f3c82785945ab8df0e24260dcd703b0cbf34de399184a9481ac2b3586db6601f026a77f7e4938481bc3475000000 \ No newline at end of file diff --git a/testing/data/taproot/in_key_pth_sig_len1.psbt b/testing/data/taproot/in_key_pth_sig_len1.psbt new file mode 100644 index 000000000..a9a6bc8aa --- /dev/null +++ b/testing/data/taproot/in_key_pth_sig_len1.psbt @@ -0,0 +1 @@ +70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a0757011342173bb3d36c074afb716fec6307a069a2e450b995f3c82785945ab8df0e24260dcd703b0cbf34de399184a9481ac2b3586db6601f026a77f7e4938481bc34751701aa000000 \ No newline at end of file diff --git a/testing/data/taproot/in_leaf_script_cb_len.psbt b/testing/data/taproot/in_leaf_script_cb_len.psbt new file mode 100644 index 000000000..4108d0bad --- /dev/null +++ b/testing/data/taproot/in_leaf_script_cb_len.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6926315c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac06f7d62059e9497a1a4a267569d9876da60101aff38e3529b9b939ce7f91ae970115f2e490af7cc45c4f78511f36057ce5c5a5c56325a29fb44dfc203f356e1f80023202cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2acc00000 \ No newline at end of file diff --git a/testing/data/taproot/in_leaf_script_cb_len1.psbt b/testing/data/taproot/in_leaf_script_cb_len1.psbt new file mode 100644 index 000000000..7de51589a --- /dev/null +++ b/testing/data/taproot/in_leaf_script_cb_len1.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6926115c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac06f7d62059e9497a1a4a267569d9876da60101aff38e3529b9b939ce7f91ae970115f2e490af7cc45c4f78511f36057ce5c5a5c56325a29fb44dfc203f356e123202cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2acc00000 \ No newline at end of file diff --git a/testing/data/taproot/in_script_sig_key_len.psbt b/testing/data/taproot/in_script_sig_key_len.psbt new file mode 100644 index 000000000..0cc688694 --- /dev/null +++ b/testing/data/taproot/in_script_sig_key_len.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6924214022cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b094089756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd43cb0000 \ No newline at end of file diff --git a/testing/data/taproot/in_script_sig_sig_len.psbt b/testing/data/taproot/in_script_sig_sig_len.psbt new file mode 100644 index 000000000..ba6c4daf1 --- /dev/null +++ b/testing/data/taproot/in_script_sig_sig_len.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b69241142cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b094289756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd43cb01010000 \ No newline at end of file diff --git a/testing/data/taproot/in_script_sig_sig_len1.psbt b/testing/data/taproot/in_script_sig_sig_len1.psbt new file mode 100644 index 000000000..76c68695f --- /dev/null +++ b/testing/data/taproot/in_script_sig_sig_len1.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b69241142cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b093f89756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd430000 \ No newline at end of file diff --git a/testing/data/taproot/in_tr_deriv_key_len.psbt b/testing/data/taproot/in_tr_deriv_key_len.psbt new file mode 100644 index 000000000..0ad329722 --- /dev/null +++ b/testing/data/taproot/in_tr_deriv_key_len.psbt @@ -0,0 +1 @@ +70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a0757221602fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa2321900772b2da75600008001000080000000800100000000000000000000 \ No newline at end of file diff --git a/testing/descriptor.py b/testing/descriptor.py new file mode 100644 index 000000000..34d7cb6ba --- /dev/null +++ b/testing/descriptor.py @@ -0,0 +1,481 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# descriptor.py - Bitcoin Core's descriptors and their specialized checksums. +# +import struct +from binascii import unhexlify as a2b_hex +from binascii import hexlify as b2a_hex +from constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR + +MULTI_FMT_TO_SCRIPT = { + AF_P2SH: "sh(%s)", + AF_P2WSH_P2SH: "sh(wsh(%s))", + AF_P2WSH: "wsh(%s)", + AF_P2TR: "tr(%s)", + None: "wsh(%s)", + # hack for tests + "p2sh": "sh(%s)", + "p2sh-p2wsh": "sh(wsh(%s))", + "p2wsh-p2sh": "sh(wsh(%s))", + "p2wsh": "wsh(%s)", + "p2tr": "tr(%s)" +} + +SINGLE_FMT_TO_SCRIPT = { + AF_P2WPKH: "wpkh(%s)", + AF_CLASSIC: "pkh(%s)", + AF_P2WPKH_P2SH: "sh(wpkh(%s))", + AF_P2TR: "tr(%s)", + None: "wpkh(%s)", + "p2pkh": "pkh(%s)", + "p2wpkh": "wpkh(%s)", + "p2sh-p2wpkh": "sh(wpkh(%s))", + "p2wpkh-p2sh": "sh(wpkh(%s))", + "p2tr": "tr(%s)", +} + +PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" +INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def xfp2str(xfp): + # Standardized way to show an xpub's fingerprint... it's a 4-byte string + # and not really an integer. Used to show as '0x%08x' but that's wrong endian. + return b2a_hex(struct.pack('> 35 + c = ((c & 0x7ffffffff) << 5) ^ val + if (c0 & 1): + c ^= 0xf5dee51989 + if (c0 & 2): + c ^= 0xa9fdca3312 + if (c0 & 4): + c ^= 0x1bab10e32d + if (c0 & 8): + c ^= 0x3706b1677a + if (c0 & 16): + c ^= 0x644d626ffd + + return c + +def descriptor_checksum(desc): + c = 1 + cls = 0 + clscount = 0 + for ch in desc: + pos = INPUT_CHARSET.find(ch) + if pos == -1: + raise ValueError(ch) + + c = polymod(c, pos & 31) + cls = cls * 3 + (pos >> 5) + clscount += 1 + if clscount == 3: + c = polymod(c, cls) + cls = 0 + clscount = 0 + + if clscount > 0: + c = polymod(c, cls) + for j in range(0, 8): + c = polymod(c, 0) + c ^= 1 + + rv = '' + for j in range(0, 8): + rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + + return rv + +def append_checksum(desc): + return desc + "#" + descriptor_checksum(desc) + + +def parse_desc_str(string): + """Remove comments, empty lines and strip line. Produce single line string""" + res = "" + for l in string.split("\n"): + strip_l = l.strip() + if not strip_l: + continue + if strip_l.startswith("#"): + continue + res += strip_l + return res + + +def multisig_descriptor_template(xpub, path, xfp, addr_fmt): + key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub) + if addr_fmt == AF_P2WSH_P2SH: + descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))" + elif addr_fmt == AF_P2WSH: + descriptor_template = "wsh(sortedmulti(M,%s,...))" + elif addr_fmt == AF_P2SH: + descriptor_template = "sh(sortedmulti(M,%s,...))" + elif addr_fmt == AF_P2TR: + # provably unspendable BIP-0341 + descriptor_template = "tr(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))" + else: + return None + descriptor_template = descriptor_template % key_exp + return descriptor_template + + +class Descriptor: + __slots__ = ( + "keys", + "addr_fmt", + ) + + def __init__(self, keys, addr_fmt): + self.keys = keys + self.addr_fmt = addr_fmt + + @staticmethod + def checksum_check(desc_w_checksum , csum_required=False): + try: + desc, checksum = desc_w_checksum.split("#") + except ValueError: + if csum_required: + raise ValueError("Missing descriptor checksum") + return desc_w_checksum, None + + calc_checksum = descriptor_checksum(desc) + if calc_checksum != checksum: + raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum)) + return desc, checksum + + @staticmethod + def parse_key_orig_info(key: str): + # key origin info is required for our MultisigWallet + close_index = key.find("]") + if key[0] != "[" or close_index == -1: + raise ValueError("Key origin info is required for %s" % (key)) + key_orig_info = key[1:close_index] # remove brackets + key = key[close_index + 1:] + assert "/" in key_orig_info, "Malformed key derivation info" + return key_orig_info, key + + @staticmethod + def parse_key_derivation_info(key: str): + invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed" + slash_split = key.split("/") + assert len(slash_split) > 1, invalid_subderiv_msg + if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]): + assert slash_split[-1] == "*", invalid_subderiv_msg + assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg + assert len(slash_split[1:]) == 2, invalid_subderiv_msg + return slash_split[0] + else: + raise ValueError("Cannot use hardened sub derivation path") + + def checksum(self): + return descriptor_checksum(self._serialize()) + + def serialize_keys(self, internal=False, int_ext=False, keys=None): + to_do = keys if keys is not None else self.keys + result = [] + for xfp, deriv, xpub in to_do: + if deriv[0] == "m": + # get rid of 'm' + deriv = deriv[1:] + elif deriv[0] != "/": + # input "84'/0'/0'" would lack slash separtor with xfp + deriv = "/" + deriv + if not isinstance(xfp, str): + xfp = xfp2str(xfp) + koi = xfp + deriv + # normalize xpub to use h for hardened instead of ' + key_str = "[%s]%s" % (koi.lower(), xpub) + if int_ext: + key_str = key_str + "/" + "<0;1>" + "/" + "*" + else: + key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"]) + result.append(key_str.replace("'", "h")) + return result + + def _serialize(self, internal=False, int_ext=False): + """Serialize without checksum""" + assert len(self.keys) == 1 # "Multiple keys for single signature script" + desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt] + inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0] + return desc_base % (inner) + + def serialize(self, internal=False, int_ext=False): + """Serialize with checksum""" + return append_checksum(self._serialize(internal=internal, int_ext=int_ext)) + + @classmethod + def parse(cls, desc_w_checksum): + # remove garbage + desc_w_checksum = parse_desc_str(desc_w_checksum) + # check correct checksum + desc, checksum = cls.checksum_check(desc_w_checksum) + # legacy + if desc.startswith("pkh("): + addr_fmt = AF_CLASSIC + tmp_desc = desc.replace("pkh(", "") + tmp_desc = tmp_desc.rstrip(")") + + # native segwit + elif desc.startswith("wpkh("): + addr_fmt = AF_P2WPKH + tmp_desc = desc.replace("wpkh(", "") + tmp_desc = tmp_desc.rstrip(")") + + # wrapped segwit + elif desc.startswith("sh(wpkh("): + addr_fmt = AF_P2WPKH_P2SH + tmp_desc = desc.replace("sh(wpkh(", "") + tmp_desc = tmp_desc.rstrip("))") + + # wrapped segwit + elif desc.startswith("tr("): + addr_fmt = AF_P2TR + tmp_desc = desc.replace("tr(", "") + tmp_desc = tmp_desc.rstrip(")") + + else: + raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh(.") + + koi, key = cls.parse_key_orig_info(tmp_desc) + if key[0:4] not in ["tpub", "xpub"]: + raise ValueError("Only extended public keys are supported") + + xpub = cls.parse_key_derivation_info(key) + xfp = str2xfp(koi[:8]) + origin_deriv = "m" + koi[8:] + + return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt) + + @classmethod + def is_descriptor(cls, desc_str): + # Quick method to guess whether this is a descriptor + try: + temp = parse_desc_str(desc_str) + except: + return False + + for prefix in ("pk(", "pkh(", "wpkh(", "tr(", "addr(", "raw(", "rawtr(", "combo(", + "sh(", "wsh(", "multi(", "sortedmulti(", "multi_a(", "sortedmulti_a("): + if temp.startswith(prefix): + return True + if prefix in temp: + # weaker case - needed for JSON wrapped imports + # if descriptor is invalid or unsuitable for our purpose + # we fail later (in parsing) + return True + return False + + def bitcoin_core_serialize(self, external_label=None): + # this will become legacy one day + # instead use <0;1> descriptor format + res = [] + for internal in [False, True]: + desc_obj = { + "desc": self.serialize(internal=internal), + "active": True, + "timestamp": "now", + "internal": internal, + "range": [0, 100], + } + if internal is False and external_label: + desc_obj["label"] = external_label + res.append(desc_obj) + + return res + + +class MultisigDescriptor(Descriptor): + # only supprt with key derivation info + # only xpubs + # can be extended when needed + __slots__ = ( + "M", + "N", + "internal_key", + "keys", + "addr_fmt", + "is_sorted" # whether to use sortedmulti() or multi() + ) + + def __init__(self, M, N, keys, addr_fmt, internal_key=None, is_sorted=True): + self.M = M + self.N = N + self.internal_key = is_sorted + self.is_sorted = is_sorted + super().__init__(keys, addr_fmt) + + @classmethod + def parse(cls, desc_w_checksum): + internal_key = None + # remove garbage + desc_w_checksum = parse_desc_str(desc_w_checksum) + # check correct checksum + desc, checksum = cls.checksum_check(desc_w_checksum) + is_sorted = "sortedmulti(" in desc + rplc = "sortedmulti(" if is_sorted else "multi(" + + # wrapped segwit + if desc.startswith("sh(wsh("+rplc): + addr_fmt = AF_P2WSH_P2SH + tmp_desc = desc.replace("sh(wsh("+rplc, "") + tmp_desc = tmp_desc.rstrip(")))") + + # native segwit + elif desc.startswith("wsh("+rplc): + addr_fmt = AF_P2WSH + tmp_desc = desc.replace("wsh("+rplc, "") + tmp_desc = tmp_desc.rstrip("))") + + # legacy + elif desc.startswith("sh("+rplc): + addr_fmt = AF_P2SH + tmp_desc = desc.replace("sh("+rplc, "") + tmp_desc = tmp_desc.rstrip("))") + elif desc.startswith("tr("): + addr_fmt = AF_P2TR + tmp_desc = desc.replace("tr(", "") + tmp_desc = tmp_desc.rstrip(")") + internal_key, tmp_desc = tmp_desc.split(",", 1) + tmp_desc = tmp_desc.replace(rplc + "_a(", "") + tmp_desc = tmp_desc.rstrip(")") + + try: + koi, key = cls.parse_key_orig_info(internal_key) + if key[0:4] not in ["tpub", "xpub"]: + raise ValueError("Only extended public keys are supported") + xpub = cls.parse_key_derivation_info(key) + xfp = str2xfp(koi[:8]) + origin_deriv = "m" + koi[8:] + internal_key = (xfp, origin_deriv, xpub) + except ValueError: + # https://github.com/BlockstreamResearch/secp256k1-zkp/blob/11af7015de624b010424273be3d91f117f172c82/src/modules/rangeproof/main_impl.h#L16 + # H = lift_x(0x0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) + # if internal_key == PROVABLY_UNSPENDABLE: + # # unspendable H as defined in BIP-0341 + # pass + # else: + # assert "r=" in internal_key + # _, r = internal_key.split("=") + # if r == "@": + # # pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG + # kp = ngu.secp256k1.keypair() + # else: + # # H + rG where r is provided from user + # r = a2b_hex(r) + # assert len(r) == 32, "r != 32" + # kp = ngu.secp256k1.keypair(r) + # + # H = a2b_hex(PROVABLY_UNSPENDABLE) + # H_xo = ngu.secp256k1.xonly_pubkey(H) + # internal_key = H_xo.tweak_add(kp.xonly_pubkey().to_bytes()) + # internal_key = b2a_hex(internal_key.to_bytes()).decode() + pass + + else: + raise ValueError("Unsupported descriptor. Supported: sh(), sh(wsh()), wsh().") + + splitted = tmp_desc.split(",") + M, keys = int(splitted[0]), splitted[1:] + N = int(len(keys)) + if M > N: + raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N)) + + res_keys = [] + for key in keys: + koi, key = cls.parse_key_orig_info(key) + if key[0:4] not in ["tpub", "xpub"]: + raise ValueError("Only extended public keys are supported") + + xpub = cls.parse_key_derivation_info(key) + xfp = str2xfp(koi[:8]) + origin_deriv = "m" + koi[8:] + res_keys.append((xfp, origin_deriv, xpub)) + + return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt, + internal_key=internal_key,is_sorted=is_sorted) + + def _serialize(self, internal=False, int_ext=False): + """Serialize without checksum""" + desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt] + if self.addr_fmt == AF_P2TR: + if isinstance(self.internal_key, str): + desc_base = desc_base % (self.internal_key + ",sortedmulti_a(%s)") + else: + ik_ser = self.serialize_keys(keys=[self.internal_key])[0] + desc_base = desc_base % (ik_ser + ",sortedmulti_a(%s)") + _type = "sortedmulti" if self.is_sorted else "multi" + _type += "(%s)" + desc_base = desc_base % _type + assert len(self.keys) == self.N + inner = str(self.M) + "," + ",".join( + self.serialize_keys(internal=internal, int_ext=int_ext)) + + return desc_base % (inner) + + def pretty_serialize(self): + """Serialize in pretty and human-readable format""" + _type = "sortedmulti" if self.is_sorted else "multi" + res = "# Coldcard descriptor export\n" + if self.is_sorted: + res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n" + else: + res += ("# !!! DANGER: order of keys in descriptor MUST be preserved. " + "Correct order of keys is required to compose valid redeem/witness script.\n") + if self.addr_fmt == AF_P2SH: + res += "# bare multisig - p2sh\n" + res += "sh("+_type+"(\n%s\n))" + # native segwit + elif self.addr_fmt == AF_P2WSH: + res += "# native segwit - p2wsh\n" + res += "wsh("+_type+"(\n%s\n))" + + # wrapped segwit + elif self.addr_fmt == AF_P2WSH_P2SH: + res += "# wrapped segwit - p2sh-p2wsh\n" + res += "sh(wsh(" + _type + "(\n%s\n)))" + elif self.addr_fmt == AF_P2TR: + inner_ident = 2 + res += "# taproot multisig - p2tr\n" + res += "tr(\n" + if isinstance(self.internal_key, str): + res += "\t" + "# internal key (provably unspendable)\n" + res += "\t" + self.internal_key + ",\n" + res += "\t" + _type + "_a(\n%s\n))" + else: + ik_ser = self.serialize_keys(keys=[self.internal_key])[0] + res += "\t" + "# internal key\n" + res += "\t" + ik_ser + ",\n" + res += "\t" + _type + "_a(\n%s\n))" + else: + raise ValueError("Malformed descriptor") + + assert len(self.keys) == self.N + inner = "\t" + "# %d of %d (%s)\n" % ( + self.M, self.N, + "requires all participants to sign" if self.M == self.N else "threshold") + inner += "\t" + str(self.M) + ",\n" + ser_keys = self.serialize_keys() + for i, key_str in enumerate(ser_keys, start=1): + if i == self.N: + inner += "\t" + key_str + else: + inner += "\t" + key_str + ",\n" + + checksum = self.serialize().split("#")[1] + + return (res % inner) + "#" + checksum + +# EOF diff --git a/testing/devtest/clear_seed.py b/testing/devtest/clear_seed.py index 353efef28..baa506318 100644 --- a/testing/devtest/clear_seed.py +++ b/testing/devtest/clear_seed.py @@ -23,6 +23,7 @@ pa.login() assert pa.is_secret_blank() + settings.blank() SettingsObject.master_sv_data = {} SettingsObject.master_nvram_key = None diff --git a/testing/devtest/wipe_miniscript.py b/testing/devtest/wipe_miniscript.py new file mode 100644 index 000000000..4fa8e646b --- /dev/null +++ b/testing/devtest/wipe_miniscript.py @@ -0,0 +1,13 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# quickly clear all miniscript wallets installed +from glob import settings +from ux import restore_menu + +if settings.get('miniscript'): + del settings.current['miniscript'] + settings.save() + + print("cleared miniscript") + +restore_menu() \ No newline at end of file diff --git a/testing/helpers.py b/testing/helpers.py index 2e76e7f25..8e17f4bdb 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -24,13 +24,15 @@ def prandom(count): return bytes(random.randint(0, 255) for i in range(count)) def taptweak(internal_key, tweak=None): - tweak = internal_key if tweak is None else internal_key + tweak assert len(internal_key) == 32, "not xonly-pubkey (len!=32)" + if tweak is not None: + assert len(tweak) == 32, "tweak (len!=32)" + tweak = internal_key if tweak is None else internal_key + tweak xonly_pubkey = xonly_pubkey_parse(internal_key) tweak = tagged_sha256(b"TapTweak", tweak) tweaked_pubkey = xonly_pubkey_tweak_add(xonly_pubkey, tweak) - tweaked_xonnly_pubkey, parity = xonly_pubkey_from_pubkey(tweaked_pubkey) - return xonly_pubkey_serialize(tweaked_xonnly_pubkey) + tweaked_xonly_pubkey, parity = xonly_pubkey_from_pubkey(tweaked_pubkey) + return xonly_pubkey_serialize(tweaked_xonly_pubkey) def fake_dest_addr(style='p2pkh'): # Make a plausible output address, but it's random garbage. Cant use for change outs diff --git a/testing/psbt.py b/testing/psbt.py index 130356581..6d39ca335 100644 --- a/testing/psbt.py +++ b/testing/psbt.py @@ -123,6 +123,9 @@ def defaults(self): self.taproot_bip32_paths = {} self.taproot_internal_key = None self.taproot_key_sig = None + self.taproot_merkle_root = None + self.taproot_scripts = {} + self.taproot_script_sigs = {} self.redeem_script = None self.witness_script = None self.previous_txid = None # v2 @@ -147,6 +150,9 @@ def __eq__(a, b): a.taproot_key_sig == b.taproot_key_sig and \ a.taproot_bip32_paths == b.taproot_bip32_paths and \ a.taproot_internal_key == b.taproot_internal_key and \ + a.taproot_merkle_root == b.taproot_merkle_root and \ + a.taproot_scripts == b.taproot_scripts and \ + a.taproot_script_sigs == b.taproot_script_sigs and \ sorted(a.part_sigs.keys()) == sorted(b.part_sigs.keys()) and \ a.previous_txid == b.previous_txid and \ a.prevout_idx == b.prevout_idx and \ @@ -189,7 +195,7 @@ def parse_kv(self, kt, key, val): self.others[kt] = val elif kt == PSBT_IN_TAP_BIP32_DERIVATION: self.taproot_bip32_paths[key] = val - elif kt == PSBT_OUT_TAP_INTERNAL_KEY: + elif kt == PSBT_IN_TAP_INTERNAL_KEY: self.taproot_internal_key = val elif kt == PSBT_IN_TAP_KEY_SIG: self.taproot_key_sig = val @@ -203,6 +209,21 @@ def parse_kv(self, kt, key, val): self.req_time_locktime = struct.unpack(" 32, "PSBT_IN_TAP_LEAF_SCRIPT control block is too short" + assert (len(key) - 1) % 32 == 0, "PSBT_IN_TAP_LEAF_SCRIPT control block is not valid" + assert len(val) != 0, "PSBT_IN_TAP_LEAF_SCRIPT cannot be empty" + leaf_script = (val[:-1], int(val[-1])) + if leaf_script not in self.taproot_scripts: + self.taproot_scripts[leaf_script] = set() + self.taproot_scripts[leaf_script].add(key) + elif kt == PSBT_IN_TAP_MERKLE_ROOT: + self.taproot_merkle_root = val else: self.unknown[bytes([kt]) + key] = val @@ -236,6 +257,16 @@ def serialize_kvs(self, wr, v2): if self.taproot_key_sig: wr(PSBT_IN_TAP_KEY_SIG, self.taproot_key_sig) + if self.taproot_merkle_root: + wr(PSBT_IN_TAP_MERKLE_ROOT, self.taproot_merkle_root) + if self.taproot_scripts: + for (script, leaf_ver), control_blocks in self.taproot_scripts.items(): + for control_block in control_blocks: + wr(PSBT_IN_TAP_LEAF_SCRIPT, script + struct.pack("B", leaf_ver), control_block) + if self.taproot_script_sigs: + for (xonly, leaf_hash), sig in self.taproot_script_sigs.items(): + wr(PSBT_IN_TAP_SCRIPT_SIG, sig, xonly + leaf_hash) + if v2: if self.previous_txid is not None: wr(PSBT_IN_PREVIOUS_TXID, self.previous_txid) @@ -267,6 +298,7 @@ def defaults(self): self.bip32_paths = {} self.taproot_bip32_paths = {} self.taproot_internal_key = None + self.taproot_tree = None self.script = None # v2 self.amount = None # v2 self.proprietary = {} @@ -282,6 +314,7 @@ def __eq__(a, b): a.taproot_bip32_paths == b.taproot_bip32_paths and \ a.taproot_internal_key == b.taproot_internal_key and \ a.proprietary == b.proprietary and \ + a.taproot_tree == b.taproot_tree and \ a.unknown == b.unknown def parse_kv(self, kt, key, val): @@ -297,6 +330,18 @@ def parse_kv(self, kt, key, val): self.taproot_bip32_paths[key] = val elif kt == PSBT_OUT_TAP_INTERNAL_KEY: self.taproot_internal_key = val + elif kt == PSBT_OUT_TAP_TREE: + res = [] + reader = io.BytesIO(val) + while True: + depth = reader.read(1) + if not depth: + break + leaf_version = reader.read(1)[0] + script_len = deser_compact_size(reader) + script = reader.read(script_len) + res.append((depth[0], leaf_version, script)) + self.taproot_tree = res elif kt == PSBT_OUT_SCRIPT: self.script = val elif kt == PSBT_OUT_AMOUNT: @@ -319,6 +364,11 @@ def serialize_kvs(self, wr, v2): wr(PSBT_OUT_TAP_BIP32_DERIVATION, self.taproot_bip32_paths[k], k) if self.taproot_internal_key: wr(PSBT_OUT_TAP_INTERNAL_KEY, self.taproot_internal_key) + if self.taproot_tree: + res = b'' + for depth, leaf_version, script in self.taproot_tree: + res += bytes([depth, leaf_version]) + ser_compact_size(len(script)) + script + wr(PSBT_OUT_TAP_TREE, res) if v2 and self.script is not None: wr(PSBT_OUT_SCRIPT, self.script) if v2 and self.amount is not None: diff --git a/testing/test_addr.py b/testing/test_addr.py index a874bb593..95256dc7e 100644 --- a/testing/test_addr.py +++ b/testing/test_addr.py @@ -12,7 +12,7 @@ from constants import msg_sign_unmap_addr_fmt @pytest.mark.parametrize('path', [ 'm', "m/1/2", "m/1'/100'"]) -@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH ]) +@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) def test_show_addr_usb(dev, press_select, addr_vs_path, path, addr_fmt, is_simulator): addr = dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None) @@ -27,7 +27,7 @@ def test_show_addr_usb(dev, press_select, addr_vs_path, path, addr_fmt, is_simul @pytest.mark.qrcode @pytest.mark.parametrize('path', [ 'm', "m/1/2", "m/1'/100'", "m/0h/500h"]) -@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH ]) +@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, cap_story, cap_screen_qr, qr_quality_check, press_cancel, is_q1): @@ -60,25 +60,40 @@ def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, assert qr == addr or qr == addr.upper() @pytest.mark.bitcoind -def test_addr_vs_bitcoind(use_regtest, press_select, dev, bitcoind_d_sim_sign): +@pytest.mark.parametrize("addr_fmt", [ + (AF_CLASSIC, "legacy"), + (AF_P2WPKH_P2SH, "p2sh-segwit"), + (AF_P2WPKH, "bech32"), + (AF_P2TR, "bech32m") +]) +def test_addr_vs_bitcoind(addr_fmt, use_regtest, press_select, dev, bitcoind_d_sim_sign): # check our p2wpkh wrapped in p2sh is right use_regtest() + addr_fmt, addr_fmt_bitcoind = addr_fmt for i in range(5): - core_addr = bitcoind_d_sim_sign.getnewaddress(f"{i}-addr", "p2sh-segwit") - assert core_addr[0] == '2' + core_addr = bitcoind_d_sim_sign.getnewaddress(f"{i}-addr", addr_fmt_bitcoind) resp = bitcoind_d_sim_sign.getaddressinfo(core_addr) - assert resp['embedded']['iswitness'] == True - assert resp['isscript'] == True + assert resp["ismine"] is True + if addr_fmt in (AF_P2TR, AF_P2WPKH): + wit_ver = resp["witness_version"] + if addr_fmt == AF_P2TR: + assert wit_ver == 1 + else: + assert wit_ver == 0 + assert resp["iswitness"] is True + if addr_fmt == AF_P2WPKH_P2SH: + assert resp['embedded']['iswitness'] is True + assert resp['isscript'] is True + assert resp['embedded']['witness_version'] == 0 path = resp['hdkeypath'] - addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2WPKH_P2SH), timeout=None) + addr = dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None) press_select() assert addr == core_addr @pytest.mark.parametrize("body_err", [ - ("m\np2wsh", "Invalid address format: 'p2wsh'"), - ("m\np2sh-p2wsh", "Invalid address format: 'p2sh-p2wsh'"), - ("m\np2tr", "Invalid address format: 'p2tr'"), + ("m\np2wsh", "Unsupported address format: 'p2wsh'"), + ("m\np2sh-p2wsh", "Unsupported address format: 'p2sh-p2wsh'"), ("m/0/0/0/0/0/0/0/0/0/0/0/0/0\np2pkh", "too deep"), ("m/0/0/0/0/0/q/0/0/0\np2pkh", "invalid characters"), ]) @@ -94,7 +109,7 @@ def test_show_addr_nfc_invalid(body_err, goto_home, pick_menu_item, nfc_write_te assert err in story @pytest.mark.parametrize("path", ["m/84'/0'/0'/300/0", "m/800h/0h", "m/0/0/0/0/1/1/1"]) -@pytest.mark.parametrize("str_addr_fmt", ["p2pkh", "", "p2wpkh", "p2wpkh-p2sh", "p2sh-p2wpkh"]) +@pytest.mark.parametrize("str_addr_fmt", ["p2pkh", "", "p2wpkh", "p2wpkh-p2sh", "p2sh-p2wpkh", "p2tr"]) def test_show_addr_nfc(path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_menu_item, goto_home, cap_story, press_nfc, addr_vs_path, press_select, is_q1, cap_screen): @@ -142,4 +157,59 @@ def test_show_addr_nfc(path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_m assert story_addr == addr addr_vs_path(addr, path, addr_fmt) -# EOF +def test_bip86(dev, set_seed_words, use_mainnet, need_keypress): + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + set_seed_words(mnemonic) + use_mainnet() + + path = "m/86'/0'/0'" + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + # xprv = "xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk" + xpub = "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ" + assert xp == xpub + + # Account 0, first receiving + path = "m/86'/0'/0'/0/0" + addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2TR), timeout=None) + need_keypress('y') + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + + # xprv = "xprvA449goEeU9okwCzzZaxiy475EQGQzBkc65su82nXEvcwzfSskb2hAt2WymrjyRL6kpbVTGL3cKtp9herYXSjjQ1j4stsXXiRF7kXkCacK3T" + xpub = "xpub6H3W6JmYJXN49h5TfcVjLC3onS6uPeUTTJoVvRC8oG9vsTn2J8LwigLzq5tHbrwAzH9DGo6ThGUdWsqce8dGfwHVBxSbixjDADGGdzF7t2B" + # internal_key = "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" + # output_key = "a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c" + # scriptPubKey = "5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c" + address = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" + assert xp == xpub + assert addr == address + + # Account 0, second receiving + path = "m/86'/0'/0'/0/1" + addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2TR), timeout=None) + need_keypress('y') + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + # xprv = "xxprvA449goEeU9okyiF1LmKiDaTgeXvmh87DVyRd35VPbsSop8n8uALpbtrUhUXByPFKK7C2yuqrB1FrhiDkEMC4RGmA5KTwsE1aB5jRu9zHsuQ" + xpub = "xpub6H3W6JmYJXN4CCKUSnriaiQRCZmG6aq4sCMDqTu1ACyngw7HShf59hAxYjXgKDuuHThVEUzdHrc3aXCr9kfvQvZPit5dnD3K9xVRBzjK3rX" + # internal_key = "83dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145" + # output_key = "a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb" + # scriptPubKey = "5120a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb" + address = "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" + assert xp == xpub + assert addr == address + + # Account 0, first change + path = "m/86'/0'/0'/1/0" + addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2TR), timeout=None) + need_keypress('y') + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + # xprv = "xprvA3Ln3Gt3aphvUgzgEDT8vE2cYqb4PjFfpmbiFKphxLg1FjXQpkAk5M1ZKDY15bmCAHA35jTiawbFuwGtbDZogKF1WfjwxML4gK7WfYW5JRP" + xpub = "xpub6GL8SnQwRCGDhB59LEz9HMyM6sRYoByXBzXK3iEKWgCz8XrZNHUzd9L3AUBELW5NzA7dEFvMas1F84TuPH3xqdUA5tumaGWFgihJzWytXe3" + # internal_key = "399f1b2f4393f29a18c937859c5dd8a77350103157eb880f02e8c08214277cef" + # output_key = "882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc" + # scriptPubKey = "5120882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc" + address = "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7" + assert xp == xpub + assert addr == address + +# EOF \ No newline at end of file diff --git a/testing/test_address_explorer.py b/testing/test_address_explorer.py index 608f24b21..b23e18233 100644 --- a/testing/test_address_explorer.py +++ b/testing/test_address_explorer.py @@ -24,9 +24,10 @@ def doit(netcode): # Removed in v4.1.3: ( "m/{change}/{idx}", AF_CLASSIC ), #( "m/{account}'/{change}'/{idx}'", AF_CLASSIC ), #( "m/{account}'/{change}'/{idx}'", AF_P2WPKH ), - ( "m/44h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_CLASSIC ), - ( "m/49h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH_P2SH ), - ( "m/84h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH ) + ("m/44h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_CLASSIC), + ("m/49h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH_P2SH), + ("m/84h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH), + ("m/86h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2TR), ] return doit @@ -57,32 +58,14 @@ def doit(start, n): return d return doit -@pytest.fixture -def validate_address(): - # Check whether an address is covered by the given subkey - def doit(addr, sk): - if addr[0] in '1mn': - assert addr == sk.address() - elif addr[0:3] in { 'bc1', 'tb1' }: - h20 = sk.hash160() - assert addr == bech32.encode(addr[0:2], 0, h20) - elif addr[0:5] == "bcrt1": - h20 = sk.hash160() - assert addr == bech32.encode(addr[0:4], 0, h20) - elif addr[0] in '23': - h20 = hash160(b'\x00\x14' + sk.hash160()) - assert h20 == decode_base58_checksum(addr)[1:] - else: - raise ValueError(addr) - return doit @pytest.fixture def generate_addresses_file(goto_address_explorer, need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_text, load_export_and_verify_signature, - press_select, press_nfc): + press_select, press_nfc, load_export): # Generates the address file through the simulator, reads the file and # returns a list of tuples of the form (subpath, address) - def doit(start_idx=0, way="sd", change=False, is_custom_single=False): + def doit(start_idx=0, way="sd", change=False, is_custom_single=False, is_p2tr=False): expected_qty = 250 if way != "nfc" else 10 if (start_idx + expected_qty) > MAX_BIP32_IDX: expected_qty = (MAX_BIP32_IDX - start_idx) + 1 @@ -92,7 +75,8 @@ def doit(start_idx=0, way="sd", change=False, is_custom_single=False): if change and not is_custom_single: need_keypress("0") if way == "sd": - need_keypress('1') + if "Press (1)" in story: + need_keypress('1') elif way == "vdisk": if "save to Virtual Disk" not in story: raise pytest.skip("Vdisk disabled") @@ -110,9 +94,16 @@ def doit(start_idx=0, way="sd", change=False, is_custom_single=False): assert len(addresses.split("\n")) == expected_qty raise pytest.xfail("PASSED - different export format for NFC") - time.sleep(.5) # always long enough to write the file? - title, body = cap_story() - contents, sig_addr = load_export_and_verify_signature(body, way, label="Address summary") + if is_p2tr: + # p2tr - no signature file + contents = load_export(way, label="Address summary", is_json=False, + sig_check=False, skip_query=True) + sig_addr = None + else: + time.sleep(.5) # always long enough to write the file? + title, body = cap_story() + contents, sig_addr = load_export_and_verify_signature(body, way, label="Address summary") + addr_dump = io.StringIO(contents) cc = csv.reader(addr_dump) hdr = next(cc) @@ -120,7 +111,8 @@ def doit(start_idx=0, way="sd", change=False, is_custom_single=False): for n, (idx, addr, deriv) in enumerate(cc, start=start_idx): assert int(idx) == n if n == start_idx: - assert sig_addr == addr + if sig_addr: + assert sig_addr == addr if not is_custom_single: assert ('/%s' % idx) in deriv @@ -272,7 +264,7 @@ def test_address_display(goto_address_explorer, parse_display_screen, mk_common_ press_cancel() # back -@pytest.mark.parametrize('click_idx', ["Classic P2PKH", "P2SH-Segwit", "Segwit P2WPKH"]) +@pytest.mark.parametrize('click_idx', ["Classic P2PKH", "P2SH-Segwit", "Segwit P2WPKH", 'Taproot P2TR']) @pytest.mark.parametrize("change", [True, False]) @pytest.mark.parametrize("start_idx", [MAX_BIP32_IDX, 80965, 0]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) @@ -289,7 +281,8 @@ def test_dump_addresses(way, change, generate_addresses_file, mk_common_derivati set_addr_exp_start_idx(start_idx) pick_menu_item(click_idx) # Generate the addresses file and get each line in a list - for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx, change=change): + is_p2tr = click_idx == 'Taproot P2TR' + for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx, change=change, is_p2tr=is_p2tr): # derive the subkey and validate the corresponding address assert subpath.split("/")[-2] == "1" if change else "0" sk = node_prv.subkey_for_path(subpath) @@ -336,7 +329,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, # derive index=0 address assert '{account}' in path - subpath = path.format(account=account_num, change=0, idx=start_idx) # e.g. "m/44'/1'/X'/0/0" + subpath = path.format(account=account_num, change=0, idx=start_idx, is_p2tr=addr_format==AF_P2TR) # e.g. "m/44'/1'/X'/0/0" sk = node_prv.subkey_for_path(subpath) # capture full index=0 address from display screen & validate it @@ -357,7 +350,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, assert expected_addr.startswith(start) assert expected_addr.endswith(end) - for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx): + for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx,is_p2tr=addr_format==AF_P2TR): assert subpath.split('/')[-3] == str(account_num)+"h" sk = node_prv.subkey_for_path(subpath) validate_address(addr, sk) @@ -378,7 +371,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, ("m/1/2/3/4/5", MAX_BIP32_IDX), ("m/1h/2h/3h/4h/5h", 0), ]) -@pytest.mark.parametrize('which_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH ]) +@pytest.mark.parametrize('which_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR]) def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_address_explorer, need_keypress, cap_menu, parse_display_screen, validate_address, cap_screen_qr, qr_quality_check, nfc_read_text, get_setting, @@ -445,12 +438,14 @@ def ss(x): m = cap_menu() assert m[0] == 'Classic P2PKH' assert m[1] == 'Segwit P2WPKH' - assert m[2] == 'P2SH-Segwit' - + assert m[2] == 'Taproot P2TR' + assert m[3] == 'P2SH-Segwit' + fmts = { AF_CLASSIC: 'Classic P2PKH', AF_P2WPKH: 'Segwit P2WPKH', AF_P2WPKH_P2SH: 'P2SH-Segwit', + AF_P2TR: 'Taproot P2TR', } pick_menu_item(fmts[which_fmt]) @@ -479,7 +474,7 @@ def ss(x): need_keypress(KEY_QR if is_q1 else '4') qr = cap_screen_qr().decode('ascii') - if which_fmt == AF_P2WPKH: + if which_fmt in (AF_P2WPKH, AF_P2TR): assert qr == addr.upper() else: assert qr == addr @@ -501,7 +496,7 @@ def ss(x): # remove QR from screen press_cancel() - addr_gen = generate_addresses_file(change=False, is_custom_single=True) + addr_gen = generate_addresses_file(change=False, is_custom_single=True, is_p2tr=which_fmt == AF_P2TR) f_path, f_addr = next(addr_gen) assert f_path == path assert f_addr == addr @@ -529,7 +524,7 @@ def ss(x): need_keypress(KEY_QR if is_q1 else '4') for i in range(n): qr = cap_screen_qr().decode('ascii') - if which_fmt == AF_P2WPKH: + if which_fmt in (AF_P2WPKH, AF_P2TR): qr = qr.lower() qr_addr_list.append(qr) need_keypress(KEY_RIGHT if is_q1 else "9") @@ -542,11 +537,92 @@ def ss(x): assert sorted(qr_addr_list) == sorted(addr_dict.values()) - addr_gen = generate_addresses_file(start_idx=start_idx, change=False) + addr_gen = generate_addresses_file(start_idx=start_idx, change=False, is_p2tr=which_fmt==AF_P2TR) assert addr_dict == {p: a for i,(p, a) in enumerate(addr_gen) if i < n} # check the rest of file export for p, a in addr_gen: addr_vs_path(a, p, addr_fmt=which_fmt) + +@pytest.mark.bitcoind +@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2TR]) +@pytest.mark.parametrize("acct_num", [None, "999"]) +def test_bitcoind_descriptor_address(addr_fmt, acct_num, bitcoind, goto_home, pick_menu_item, cap_story, + use_regtest, need_keypress, microsd_path, generate_addresses_file, + bitcoind_d_wallet_w_sk, load_export, settings_set, cap_menu, + goto_address_explorer, press_cancel, press_select, enter_number): + # export single sig descriptors (external, internal) + # export addressses from address explorer + # derive addresses from descriptor with bitcoind + # compare bitcoind derived addressses with those exported from address explorer + bitcoind = bitcoind_d_wallet_w_sk + use_regtest() + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Export Wallet") + pick_menu_item("Descriptor") + time.sleep(.1) + _, story = cap_story() + assert "This saves a ranged xpub descriptor" in story + assert "Press (1) to enter a non-zero account number" in story + assert "sensitive--in terms of privacy" in story + assert "not compromise your funds directly" in story + + if isinstance(acct_num, str): + need_keypress("1") # chosse account number + for ch in acct_num: + need_keypress(ch) # input num + press_select() # confirm selection + else: + press_select() # confirm story + + time.sleep(.1) + _, story = cap_story() + assert "press (1) to export receiving and change descriptors separately" in story + need_keypress("1") + + sig_check = True + if addr_fmt == AF_P2WPKH: + menu_item = "Segwit P2WPKH" + desc_prefix = "wpkh(" + elif addr_fmt == AF_P2WPKH_P2SH: + menu_item = "P2SH-Segwit" + desc_prefix = "sh(wpkh(" + elif addr_fmt == AF_P2TR: + menu_item = "Taproot P2TR" + desc_prefix = "tr(" + sig_check = False + else: + # addr_fmt == AF_CLASSIC: + menu_item = "Classic P2PKH" + desc_prefix = "pkh(" + + pick_menu_item(menu_item) + contents = load_export("sd", label="Descriptor", is_json=False, addr_fmt=addr_fmt, + sig_check=sig_check) + descriptors = contents.strip() + ext_desc, int_desc = descriptors.split("\n") + assert ext_desc.startswith(desc_prefix) + assert int_desc.startswith(desc_prefix) + + # check both external and internal + for chng in [False, True]: + goto_address_explorer() + if acct_num: + menu = cap_menu() + # can be "Account number" or "Account: N" + mi = [m for m in menu if "Account" in m] + assert len(mi) == 1 + pick_menu_item(mi[0]) + enter_number(acct_num) + + desc = int_desc if chng else ext_desc + settings_set("axi", 0) + pick_menu_item(menu_item) + cc_addrs_gen = generate_addresses_file(change=chng, is_p2tr=addr_fmt == AF_P2TR) + cc_addrs = [addr for deriv, addr in cc_addrs_gen] + bitcoind_addrs = bitcoind.deriveaddresses(desc, [0, 249]) + assert cc_addrs == bitcoind_addrs + # EOF diff --git a/testing/test_bsms.py b/testing/test_bsms.py new file mode 100644 index 000000000..e93968f0f --- /dev/null +++ b/testing/test_bsms.py @@ -0,0 +1,1667 @@ +import sys +sys.path.append("../shared") +import pytest, time, pdb, os, random, hashlib, base64 +from constants import simulator_fixed_tprv +from charcodes import KEY_NFC +from bsms import CoordinatorSession, Signer +from bsms.encryption import key_derivation_function, decrypt, encrypt +from bsms.util import bitcoin_msg, str2path +from bsms.bip32 import PrvKeyNode, PubKeyNode +from bsms.ecdsa import ecdsa_verify, ecdsa_recover +from bsms.address import p2wsh_address, p2sh_p2wsh_address +from descriptor import MultisigDescriptor, append_checksum +from msg import sign_message +from bip32 import BIP32Node + + +BSMS_VERSION = "BSMS 1.0" +ALLOWED_PATH_RESTRICTIONS = "/0/*,/1/*" + + +# keys in settings object +BSMS_SETTINGS = "bsms" +BSMS_SIGNER_SETTINGS = "s" +BSMS_COORD_SETTINGS = "c" + + +et_map = { + "1": "STANDARD", + "2": "EXTENDED", + "3": "NO_ENCRYPTION" +} + +af_map = { + "p2wsh": 14, + "p2sh-p2wsh": 26 +} + + +def coordinator_label(M, N, addr_fmt, et, index=None): + fmt_str = "%dof%d_%s_%s" % (M, N, "native" if addr_fmt == "p2wsh" else "nested", et) + if index: + fmt_str = "%d %s" % (index, fmt_str) + return fmt_str + + +def assert_coord_summary(title, story, M, N, addr_fmt, et): + assert title == "SUMMARY" + assert f"{M} of {N}" in story + assert f"Address format:\n{addr_fmt}" in story + assert f"Encryption type:\n{et_map[et].replace('_', ' ')}" in story + tokens = story.split("\n\n")[3:-1] + if et == "1": + assert len(tokens) == 1 + elif et == "2": + assert len(tokens) == N + else: + assert len(tokens) == 0 + return tokens + +@pytest.fixture +def make_coordinator_round1(settings_remove, settings_get, settings_set, microsd_path, virtdisk_path): + def doit(M, N, addr_fmt, et, way, purge_bsms=True, tokens_only=False): + if purge_bsms: + settings_remove(BSMS_SETTINGS) # clear bsms + bsms = settings_get(BSMS_SETTINGS) or {} + tokens = [] + if et == "1": + tokens = [os.urandom(8).hex()] + elif et == "2": + tokens = [os.urandom(16).hex() for _ in range(N)] + coord_tuple = (M, N, af_map[addr_fmt], et, tokens) + if BSMS_COORD_SETTINGS in bsms: + bsms[BSMS_COORD_SETTINGS].append(coord_tuple) + else: + bsms[BSMS_COORD_SETTINGS] = [coord_tuple] + settings_set(BSMS_SETTINGS, bsms) + if tokens_only: + return tokens + if way == "sd": + path_fn = microsd_path + elif way == "vdisk": + path_fn = virtdisk_path + else: + return tokens + for token_hex in tokens: + basename = "bsms_%s.token" % token_hex[:4] + with open(path_fn(basename), "w") as f: + f.write(token_hex) + return tokens + return doit + + +def bsms_sr1_fname(token, is_extended, suffix, index=None): + fname = "bsms_sr1" + if is_extended: + fname += "_" + token[:4] + else: + if index: # ignores index = 0 + fname += "-" + str(index) + return fname + suffix + + +@pytest.fixture +def make_signer_round1(settings_get, settings_set, settings_remove, microsd_path, virtdisk_path): + def doit(token, way, root_xprv=None, bsms_version=BSMS_VERSION, description=None, purge_bsms=True, + add_to_settings=False, data_only=False, index=None, wrong_sig=False, wrong_encryption=False, slip=False): + is_extended = len(token) == 32 + if purge_bsms: + settings_remove(BSMS_SETTINGS) # clear bsms + if add_to_settings: + bsms = settings_get(BSMS_SETTINGS) or {} + if BSMS_SIGNER_SETTINGS in bsms: + bsms[BSMS_COORD_SETTINGS].append(token) + else: + bsms[BSMS_SIGNER_SETTINGS] = [token] + + if root_xprv: + wk = BIP32Node.from_wallet_key(root_xprv) + else: + wk = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN") + root_xfp = wk.fingerprint().hex() + paths = ["48'/1'/0'/2'", "48'/1'/0'/1'", "0'/1'/0'/0'", "0'", "100'/0'"] + path = random.choice(paths) + sk = wk.subkey_for_path(path) + xpub = sk.hwif(as_private=False) + if slip: + xpub = xpub.replace("tpub", random.choice(["upub", "vpub", "Upub", "Vpub"])) + key_expr = "[%s/%s]%s" % (root_xfp, path, xpub) + data = "%s\n" % bsms_version + data += "%s\n" % token + data += "%s\n" % key_expr + if description is None: + description = "Coldcard Signer %s" % root_xfp + data += "%s" % description + sig = sign_message(bytes(sk.node.private_key), + data.encode()+b"ff" if wrong_sig else data.encode(), + b64=True) + data += "\n%s" % sig + suffix = ".txt" + mode = "wt" + if token != "00": + suffix = ".dat" + mode = "wb" + dkey = key_derivation_function(token) + if wrong_encryption: + wrong = "ffff" + token[4:] + dkey = key_derivation_function(wrong) + data = encrypt(dkey, token, data) + data = bytes.fromhex(data) + if data_only: + return data + if way != "nfc": + if way == "sd": + path_fn = microsd_path + else: + # vdisk + path_fn = virtdisk_path + basename = bsms_sr1_fname(token, is_extended, suffix, index) + with open(path_fn(basename), mode) as f: + f.write(data) + return data + + return doit + + +def ms_address_from_descriptor_bsms(desc_obj: MultisigDescriptor, subpath="0/0", network="XTN"): + testnet = True if network == "XTN" else False + nodes = [ + PubKeyNode.parse(ek).derive_path(str2path(subpath)) + for _, _, ek in desc_obj.keys + ] + secs = [node.sec() for node in nodes] + secs.sort() + if desc_obj.addr_fmt == af_map["p2wsh"]: + address = p2wsh_address(secs, desc_obj.M, sortedmulti=True, testnet=testnet) + else: + address = p2sh_p2wsh_address(secs, desc_obj.M, sortedmulti=True, testnet=testnet) + return address + + +def bsms_cr2_fname(token, is_extended, suffix): + fname = "bsms_cr2" + if is_extended: + fname += "_" + token[:4] + return fname + suffix + + +@pytest.fixture +def make_coordinator_round2(make_coordinator_round1, settings_get, settings_set, microsd_path, virtdisk_path): + def doit(M, N, addr_fmt, et, way, has_ours=True, ours_no=1, path_restrictions=ALLOWED_PATH_RESTRICTIONS, + bsms_version=BSMS_VERSION, sortedmulti=True, wrong_address=False, wrong_encryption=False, + wrong_chain=False, add_checksum=False, wrong_checksum=False): + tokens = make_coordinator_round1(M, N, addr_fmt, et, way=way, purge_bsms=True, tokens_only=True) + range_num = N if has_ours is False else N - ours_no + keys = [] + for _ in range(range_num): + wk = BIP32Node.from_master_secret(os.urandom(32), netcode="BTC" if wrong_chain else "XTN") + root_xfp = wk.fingerprint().hex() + paths = ["48'/1'/0'/2'", "48'/1'/0'/1'", "0'/1'/0'/0'", "0'", "100'/0'"] + path = random.choice(paths) + sk = wk.subkey_for_path(path) + xpub = sk.hwif(as_private=False) + keys.append((root_xfp, "m/" + path, xpub)) + if has_ours: + for _ in range(ours_no): + wk = BIP32Node.from_wallet_key(simulator_fixed_tprv) + root_xfp = wk.fingerprint().hex() + paths = ["48'/1'/0'/2'", "48'/1'/0'/1'", "0'/1'/0'/0'", "0'", "100'/0'"] + path = random.choice(paths) + sk = wk.subkey_for_path(path) + xpub = sk.hwif(as_private=False) + keys.append((root_xfp, "m/" + path, xpub)) + + desc_obj = MultisigDescriptor(M=M, N=N, addr_fmt=af_map[addr_fmt], keys=keys) + desc = desc_obj._serialize(int_ext=True) + wcs = append_checksum(desc).split("#")[-1] + desc = desc.replace("/<0;1>/*", "/**") + if add_checksum: + desc = append_checksum(desc) + elif wrong_checksum: + desc = desc + "#" + wcs + if not sortedmulti: + desc = desc.replace("sortedmulti", "multi") + desc_template = "%s\n" % bsms_version + desc_template += "%s\n" % desc + desc_template += "%s\n" % path_restrictions + if wrong_address: + addr = ms_address_from_descriptor_bsms(desc_obj, subpath="1000/100") + else: + addr = ms_address_from_descriptor_bsms(desc_obj) + desc_template += "%s" % addr + + # create signer artificialy and produce correct descriptor template file + bsms = settings_get(BSMS_SETTINGS) or {} + bsms[BSMS_SIGNER_SETTINGS] = [] # purge + if not tokens: + token = "00" + bsms[BSMS_SIGNER_SETTINGS].append(token) + res = desc_template + else: + token = tokens[0] + # same for STANDARD and EXTENDED --> encrypt + bsms[BSMS_SIGNER_SETTINGS].append(token) + if wrong_encryption: + res = encrypt(key_derivation_function(os.urandom(16).hex()), token, desc_template) + else: + res = encrypt(key_derivation_function(token), token, desc_template) + res = bytes.fromhex(res) + + settings_set(BSMS_SETTINGS, bsms) + if way != "nfc": + if way == "sd": + path_fn = microsd_path + else: + # vdisk + path_fn = virtdisk_path + mode = "wb" if et in ["1", "2"] else "wt" + suffix = ".dat" if et in ["1", "2"] else ".txt" + basename = bsms_cr2_fname(token, et == "2", suffix) + with open(path_fn(basename), mode) as f: + f.write(res) + + return res, token + + return doit + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, + pick_menu_item, cap_menu, cap_story, microsd_path, settings_remove, + nfc_read_text, request, settings_get, microsd_wipe, press_select, is_q1): + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + + M, N = M_N + + microsd_wipe() + settings_remove(BSMS_SETTINGS) # clear bsms + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 1 # nothing should be in menu at this point but round 1 + pick_menu_item('Create BSMS') + # choose number of signers N + for num in str(N): + need_keypress(num) + press_select() + # choose threshold M + for num in str(M): + need_keypress(num) + press_select() + if addr_fmt == "p2wsh": + press_select() + else: + need_keypress("2") + time.sleep(0.1) + title, story = cap_story() + assert story == "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED, and (3) for no encryption" + need_keypress(encryption_type) + time.sleep(0.1) + title, story = cap_story() + tokens = assert_coord_summary(title, story, M, N, addr_fmt, encryption_type) + press_select() # confirm summary + time.sleep(0.1) + title, story = cap_story() + assert "Press (1) to participate as co-signer in this BSMS" in story + press_select() # continue normally + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "3": + assert story == "Success. Coordinator round 1 saved." + else: + if way == "sd": + if "Press (1) to save BSMS token file(s) to SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + + if f"press {KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + time.sleep(0.2) + bsms_tokens = nfc_read_text() + time.sleep(0.2) + press_select() # exit NFC UI simulation + time.sleep(0.5) + else: + # virtual disk + if "press (2) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + + read_tokens = [] + if way == "nfc" and encryption_type != "3": + read_tokens = bsms_tokens.split("\n\n") + else: + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS token file(s) written' in story + fnames = story.split('\n\n')[2:] + # check token files contains first 4 chars of token + try: + token_start = set([tok.split(" ")[1][:4] for tok in tokens]) + except IndexError: + # only one token - special case without numbering + assert len(tokens) == 1 + token_start = set([tokens[0].split("\n")[1][:4]]) + token_fnames_start = set([fn.replace(".token", "").split("_")[-1].split("-")[0] for fn in fnames]) + assert token_start == token_fnames_start + read_tokens = [] + for fname in fnames: + if way == "vdisk": + path = virtdisk_path(fname) + else: + path = microsd_path(fname) + with open(path, 'rt') as f: + token = f.read().strip() + read_tokens.append(token) + + if encryption_type == "1": + assert len(read_tokens) == 1 + elif encryption_type == "2": + assert len(read_tokens) == N + else: + assert len(tokens) == 0 + + press_select() # confirm success or files written story + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 2 + current_coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert menu[0] == current_coord_menu_item + assert menu[1] == "Create BSMS" + # check correct summary in detail + pick_menu_item(menu[0]) + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 3 + assert menu[0] == "Round 2" + assert menu[1] == "Detail" + assert menu[2] == "Delete" + pick_menu_item("Detail") + time.sleep(0.1) + title, story = cap_story() + assert_coord_summary(title, story, M, N, addr_fmt, encryption_type) + press_select() + # check correct coord tuple saved + bsms_settings = settings_get(BSMS_SETTINGS) + if BSMS_SIGNER_SETTINGS in bsms_settings: + assert bsms_settings[BSMS_SIGNER_SETTINGS] == [] + coord_settings = bsms_settings[BSMS_COORD_SETTINGS] + assert len(coord_settings) == 1 + assert coord_settings[0] == ( + M, N, af_map[addr_fmt], encryption_type, + [tok.split(" ")[-1].replace("Tokens:\n", "") for tok in tokens] if tokens else [] + ) + # delete coordinator settings + pick_menu_item("Delete") + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 1 + assert menu[0] == "Create BSMS" + bsms_settings = settings_get(BSMS_SETTINGS) + coord_settings = bsms_settings[BSMS_COORD_SETTINGS] + assert coord_settings == [] + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, + cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, + make_coordinator_round1, nfc_write_text, microsd_wipe, press_select, + is_q1): + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + + M, N = M_N + microsd_wipe() + tokens = make_coordinator_round1(M, N, addr_fmt, encryption_type, way) + if encryption_type != "3": + assert tokens + else: + assert tokens == [] + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu = cap_menu() + assert len(menu) == 1 # nothing should be in menu at this point but round 1 + pick_menu_item('Round 1') + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "3": + token = "00" + need_keypress("3") # no token (unencrypted BSMS) + else: + token = random.choice(tokens) + if way == "sd": + if "Press (1) to import token file from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + if f"{KEY_NFC if is_q1 else '(4)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "4") + time.sleep(0.1) + nfc_write_text(token) + time.sleep(0.4) + else: + # virtual disk + if "(6) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("6") + + if way != "nfc": + time.sleep(0.2) + fname = "bsms_%s.token" % token[:4] + pick_menu_item(fname) + + time.sleep(0.1) + title, story = cap_story() + assert "You have entered token:\n%s" % token in story + press_select() + time.sleep(0.1) + _, story = cap_story() + # address format a.k.a. SLIP derivation path - ignore and use SLIP agnostic + assert "Choose co-signer address format for correct SLIP derivation path" in story + press_select() # default + # account number prompt + press_select() + time.sleep(0.1) + _, story = cap_story() + # textual key description + assert "Choose key description" in story + press_select() # default + time.sleep(0.1) + title, story = cap_story() + suffix = ".txt" if encryption_type == "3" else ".dat" + mode = "rt" if encryption_type == "3" else "rb" + if way == "sd": + if "Press (1) to save BSMS signer round 1 file to SD Card" in story: + need_keypress("1") + elif way == "nfc": + if f"press {KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + time.sleep(0.2) + signer_r1 = nfc_read_text() + time.sleep(0.2) + press_select() # exit NFC UI simulation + time.sleep(0.5) + else: + # virtual disk + if "press (2) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + + if way != "nfc": + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS signer round 1 file written' in story + fname = story.split('\n\n')[-1] + assert suffix in fname + if encryption_type == "2": + # check token files contains first 4 chars of token or just 00 + assert token[:4] == fname.split(".")[0][-4:] + if way == "vdisk": + path = virtdisk_path(fname) + else: + path = microsd_path(fname) + with open(path, mode) as f: + signer_r1 = f.read() + + bsms = settings_get(BSMS_SETTINGS) + assert len(bsms[BSMS_SIGNER_SETTINGS]) == 1 + assert bsms[BSMS_SIGNER_SETTINGS][0] == token + + if encryption_type in ["1", "2"]: + # decrypt + if isinstance(signer_r1, bytes): + signer_r1 = signer_r1.hex() + signer_r1 = decrypt(key_derivation_function(token), signer_r1) + + version, tok, key_exp, description, sig = signer_r1.strip().split("\n") + assert version == BSMS_VERSION + assert tok == token + close_index = key_exp.find("]") + assert key_exp[0] == "[" and close_index != -1 + key_orig_info = key_exp[1:close_index] # remove brackets + xpub = key_exp[close_index + 1:] + assert xpub[:4] in ["xpub", "tpub"] + xfp, path = key_orig_info.split("/", 1) + # pycoin xpub check + mk = BIP32Node.from_wallet_key(simulator_fixed_tprv) + sk = mk.subkey_for_path(path) + pycoin_xpub = sk.hwif(as_private=False) + assert xpub == pycoin_xpub + # bsms lib xpub check + mk0 = PrvKeyNode.parse(simulator_fixed_tprv, testnet=True) + sk0 = mk0.derive_path(str2path(path)) + bsms_xpub = sk0.extended_public_key() + assert xpub == bsms_xpub + signed_data = "\n".join([version, tok, key_exp, description]) + # verify msg bsms lib (pure python ecdsa) + signed_digest = bitcoin_msg(signed_data) + decoded_sig = base64.b64decode(sig) + recovered_sec = ecdsa_recover(signed_digest, decoded_sig) + assert ecdsa_verify(signed_digest, decoded_sig, recovered_sec), "Signature invalid" + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +@pytest.mark.parametrize("auto_collect", [True, False]) +def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, clear_ms, goto_home, need_keypress, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, + settings_get, make_coordinator_round1, make_signer_round1, nfc_write_text, + microsd_wipe, pick_menu_item, press_select, is_q1): + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == N and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + + M, N = M_N + microsd_wipe() + tokens = make_coordinator_round1(M, N, addr_fmt, encryption_type, way=way, tokens_only=True) + all_data = [] + for i in range(N): + token = get_token(i) + index = None + if encryption_type != "2": + index = i + 1 + + all_data.append(make_signer_round1(token, way, purge_bsms=False, index=index)) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "vdisk": + if "(2) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + else: + # NFC + if f"{KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + + if way == "nfc": + if auto_collect is True: + pytest.skip("No auto-collection for NFC") + for i, data in enumerate(all_data): + time.sleep(0.1) + title, story = cap_story() + token = get_token(i) + if encryption_type == "2": + expect = "Share co-signer #%d round-1 data for token starting with %s" % (i + 1, token[:4]) + else: + expect = "Share co-signer #%d round-1 data" % (i + 1) + assert expect in story + press_select() + time.sleep(.2) + nfc_write_text(data.hex() if isinstance(data, bytes) else data) + time.sleep(0.3) + else: + suffix = ".txt" if encryption_type == "3" else ".dat" + time.sleep(0.1) + title, story = cap_story() + assert "Press OK to pick co-signer round 1 files manually, or press (1) to attempt auto-collection." in story + assert "For auto-collection to succeed all filenames have to start with 'bsms_sr1'" in story + suffix_target = "and end with extension '%s'" % suffix + assert suffix_target in story + if encryption_type == "2": + assert "In addition for EXTENDED encryption all files must contain first four characters of respective token." in story + elif encryption_type == "3": + assert ("In addition for NO ENCRYPTION cases, number of files with above mentioned" + " pattern and suffix must equal number of signers (N).") in story + assert "If above is not respected auto-collection fails and defaults to manual selection of files." in story + if auto_collect: + need_keypress("1") + else: + press_select() # continue with manual selection + for i, _ in enumerate(all_data, start=1): + token = get_token(i - 1) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % i + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + menu_item = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + pick_menu_item(menu_item) + + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to save BSMS descriptor template file(s) to SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + if f"{KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + else: + # virtual disk + if "(2) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + + descriptor_templates = [] + if way == "nfc": + # not implemented because of the fake nfc limit + # pytest skip will be raised before we can get here + if encryption_type == "2": + for i, token in enumerate(tokens, start=1): + time.sleep(.1) + title, story = cap_story() + expect = "Exporting data for co-signer #%d with token %s" % (i, token[:4]) + assert expect in story + press_select() + time.sleep(.5) + rv = nfc_read_text() + time.sleep(.5) + descriptor_templates.append(rv) + press_select() # exit animation + + time.sleep(.1) + title, story = cap_story() + assert "All done" in story + press_select() + else: + time.sleep(.5) + rv = nfc_read_text() + time.sleep(.5) + descriptor_templates.append(rv) + press_select() # exit animation + else: + if way == "sd": + path_fn = microsd_path + else: + path_fn = virtdisk_path + time.sleep(0.1) + _, story = cap_story() + assert "BSMS descriptor template file(s) written." in story + fnames = story.split("\n\n")[1:] + if encryption_type == "2": + for fname, token in zip(fnames, tokens): + assert token[:4] in fname + + for fname in fnames: + with open(path_fn(fname), "rt" if encryption_type == "3" else "rb") as f: + desc_temp = f.read() + descriptor_templates.append(desc_temp) + + assert descriptor_templates + if encryption_type == "2": + # each file encrypted with different token/key + templates = set() + for token, desc_template in zip(tokens, descriptor_templates): + plaintext = decrypt( + key_derivation_function(token), + desc_template if isinstance(desc_template, str) else desc_template.hex() + ) + assert plaintext + templates.add(plaintext) + assert len(templates) == 1 + # pick last to be the template + the_template = plaintext + elif encryption_type == "1": + # just one template but encrypted + assert len(descriptor_templates) == 1 + plaintext = decrypt( + key_derivation_function(get_token(0)), + descriptor_templates[0] if isinstance(descriptor_templates[0], str) else descriptor_templates[0].hex() + ) + assert plaintext + the_template = plaintext + else: + assert len(descriptor_templates) == 1 + the_template = descriptor_templates[0] + + version, descriptor, pth_restrictions, addr = the_template.split("\n") + assert version == BSMS_VERSION + try: + MultisigDescriptor.checksum_check(descriptor) + descriptor = descriptor.split("#")[0] + except ValueError: + pass + # replace /** so we can parse it + descriptor = descriptor.replace("/**", "/0/*") + descriptor = append_checksum(descriptor) + desc_obj = MultisigDescriptor.parse(descriptor) + assert len(desc_obj.keys) == N + assert pth_restrictions == ALLOWED_PATH_RESTRICTIONS + # bsms lib test ms address + address = ms_address_from_descriptor_bsms(desc_obj) + assert addr == address + + +@pytest.mark.parametrize("refuse", [True, False]) +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("with_checksum", [True, False]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, + make_coordinator_round2, nfc_write_text, microsd_wipe, with_checksum, + press_select, press_cancel, is_q1): + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + M, N = M_N + clear_ms() + microsd_wipe() + desc_template, token = make_coordinator_round2(M, N, addr_fmt, encryption_type, way=way, add_checksum=with_checksum) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu = cap_menu() + assert len(menu) == 2 + assert "Round 1" in menu + menu_item = "1 %s" % token[:4] + assert menu_item in menu + pick_menu_item(menu_item) + menu = cap_menu() + assert len(menu) == 3 + assert "Detail" in menu + assert "Delete" in menu + assert "Round 2" in menu + pick_menu_item("Detail") + time.sleep(0.1) + _, story = cap_story() + assert token in story + assert str(int(token, 16)) in story + press_select() + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "vdisk": + if "(2) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + else: + # NFC + if f"{KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + + if way == "nfc": + time.sleep(0.1) + nfc_write_text(desc_template.hex() if isinstance(desc_template, bytes) else desc_template) + time.sleep(0.3) + else: + suffix = ".txt" if encryption_type == "3" else ".dat" + time.sleep(0.1) + menu_item = bsms_cr2_fname(token, encryption_type == "2", suffix) + pick_menu_item(menu_item) + + time.sleep(0.5) + _, story = cap_story() + assert "Create new multisig wallet?" in story + assert "bsms" in story # part of the name + policy = "Policy: %d of %d" % (M, N) + assert policy in story + assert addr_fmt.upper() in story + ms_wal_name = story.split("\n\n")[1].split("\n")[-1].strip() + ms_wal_menu_item = "%d/%d: %s" % (M, N, ms_wal_name) + if refuse: + press_cancel() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item not in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 NOT removed + assert bsms_settings.get(BSMS_SIGNER_SETTINGS) + else: + press_select() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 removed + assert not bsms_settings.get(BSMS_SIGNER_SETTINGS, None) + + +@pytest.mark.parametrize("token", [ + "f" * 15, + "f" * 17, + "0" * 31, + "0" * 33, +]) +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk", "manual"]) +def test_invalid_token_signer_round1(token, way, pick_menu_item, cap_story, need_keypress, + nfc_write_text, microsd_path, virtdisk_path, goto_home, + press_select, is_q1): + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + pick_menu_item('Round 1') + time.sleep(0.1) + title, story = cap_story() + if way == "manual": + need_keypress("2") # manual + need_keypress("2") # decimal + for num in str(int(token, 16)): + need_keypress(num) + press_select() + else: + if way != "nfc": + token_fname = "error.token" + path_func = virtdisk_path if way == "vdisk" else microsd_path + with open(path_func(token_fname), "w") as f: + f.write(token) + if way == "sd": + if "Press (1) to import token file from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + if f"{KEY_NFC if is_q1 else '(4)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "4") + time.sleep(0.1) + nfc_write_text(token) + time.sleep(0.4) + else: + # virtual disk + if "(6) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("6") + + if way != "nfc": + time.sleep(0.2) + pick_menu_item(token_fname) + + time.sleep(0.1) + title, story = cap_story() + assert title == "FAILURE" + assert "BSMS signer round1 failed" in story + assert "Invalid token length. Expected 64 or 128 bits (16 or 32 hex characters)" in story + + +@pytest.mark.parametrize("failure", ["slip", "wrong_sig", "bsms_version"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +def test_failure_coordinator_round2(encryption_type, make_coordinator_round1, make_signer_round1, microsd_wipe, cap_menu, + pick_menu_item, press_select, goto_home, cap_story, failure, + need_keypress): + microsd_wipe() + + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == 2 and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + if failure == "bsms_version": + kws = {failure: "BSMS 1.1"} + else: + kws = {failure: True} + tokens = make_coordinator_round1(2, 2, "p2wsh", encryption_type, way="sd", tokens_only=True) + for i in range(2): + token = get_token(i) + index = None + if encryption_type != "2": + index = i + 1 + make_signer_round1(token, "sd", purge_bsms=False, index=index, **kws) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(2, 2, "p2wsh", encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + press_select() # continue with manual file selection + suffix = ".txt" if encryption_type == "3" else ".dat" + for i, _ in enumerate(range(2), start=1): + token = get_token(i - 1) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % i + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + menu_item = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + pick_menu_item(menu_item) + time.sleep(0.1) + title, story = cap_story() + assert title == "FAILURE" + assert "BSMS coordinator round2 failed" in story + if failure == "slip": + failure_msg = "no slip" + elif failure == "wrong_sig": + failure_msg = "Recovered key from signature does not equal key provided. Wrong signature?" + else: + failure_msg = "Incompatible BSMS version. Need BSMS 1.0 got BSMS 1.1" + assert failure_msg in story + + +# TODO do this for NFC too when length requirements are lifted from 250 +@pytest.mark.parametrize("encryption_type", ["1", "2"]) +def test_wrong_encryption_coordinator_round2(encryption_type, make_coordinator_round1, make_signer_round1, microsd_wipe, + cap_menu, pick_menu_item, need_keypress, goto_home, cap_story, + press_cancel, press_select): + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == 2 and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + microsd_wipe() + tokens = make_coordinator_round1(2, 2, "p2wsh", encryption_type, way="sd", tokens_only=True) + for i in range(2): + token = get_token(i) + index = None + if encryption_type == "1": + index = i + 1 + make_signer_round1(token, "sd", purge_bsms=False, index=index, wrong_encryption=True) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(2, 2, "p2wsh", encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + press_select() # continue with manual file selection + suffix = ".txt" if encryption_type == "3" else ".dat" + for i, _ in enumerate(range(2), start=1): + for attempt in range(2): + token = get_token(i - 1) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % i + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + menu_item = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + pick_menu_item(menu_item) + time.sleep(0.1) + _, story = cap_story() + expect_story = "Decryption failed for co-signer #%d" % i + if encryption_type == 2: + expect_story += " with token %s" % token[:4] + assert expect_story in story + if attempt == 0: + assert "Try again?" in story + press_select() + else: + assert "Try again?" not in story + press_cancel() + break + break + + +@pytest.mark.parametrize("failure", [ + "wrong_address", "path_restrictions", "bsms_version", "sortedmulti", "has_ours", "ours_no", + "wrong_encryption", "wrong_chain", "wrong_checksum" +]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +def test_failure_signer_round2(encryption_type, goto_home, press_select, pick_menu_item, cap_menu, cap_story, + microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, microsd_wipe, + make_coordinator_round2, failure, need_keypress): + microsd_wipe() + if failure == "wrong_address": + kws = {failure: True} + failure_msg = "Address mismatch!" + elif failure == "path_restrictions": + kws = {failure: "5/*,4/*"} + failure_msg = "Only '/0/*,/1/*' allowed as path restrictions." + elif failure == "bsms_version": + kws = {failure: "BSMS 2.0"} + failure_msg = "Incompatible BSMS version. Need BSMS 1.0 got BSMS 2.0" + elif failure == "sortedmulti": + kws = {failure: False} + failure_msg = "sortedmulti required" + elif failure == "has_ours": + kws = {failure: False} + failure_msg = "My key 0F056943 missing in descriptor." + elif failure == "ours_no": + kws = {failure: 2} + failure_msg = "Multiple 0F056943 keys in descriptor (2)" + elif failure == "wrong_chain": + kws = {failure: True} + failure_msg = "wrong chain" + elif failure == "wrong_checksum": + kws = {failure: True} + failure_msg = "Wrong checksum" + else: + assert failure == "wrong_encryption" + if encryption_type == "3": + pytest.skip("Cannot test wrong encryption on unencrypted BSMS") + kws = {failure: True} + failure_msg = "Decryption with token {token} failed." + + desc_template, token = make_coordinator_round2(2, 2, "p2wsh", encryption_type, way="sd", **kws) + failure_msg = failure_msg.format(token=token[:4]) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu_item = "1 %s" % token[:4] + pick_menu_item(menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + + suffix = ".txt" if encryption_type == "3" else ".dat" + time.sleep(0.1) + menu_item = bsms_cr2_fname(token, encryption_type == "2", suffix) + pick_menu_item(menu_item) + time.sleep(0.1) + title, story = cap_story() + assert title == "FAILURE" + assert "BSMS signer round2 failed" in story + assert failure_msg in story + + +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_integration_signer(encryption_type, M_N, addr_fmt, clear_ms, microsd_wipe, goto_home, pick_menu_item, cap_story, + press_select, settings_remove, microsd_path, settings_get, cap_menu, use_mainnet, + need_keypress): + # test CC signer full with bsms lib coordinator (test just SD card no need to retest IO paths again - tested above) + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == N and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + M, N = M_N + settings_remove(BSMS_SETTINGS) + use_mainnet() + clear_ms() + microsd_wipe() + coordinator = CoordinatorSession(M, N, addr_fmt, et_map[encryption_type]) + session_data = coordinator.generate_token_key_pairs() + tokens = [x[0] for x in session_data] + cc_token = get_token(0) + other_signers = [] + for i in range(1, N): + other_signers.append(Signer(token=get_token(i), key_description="Other signer %d" % i)) + # ROUND 1 + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + pick_menu_item('Round 1') + time.sleep(0.1) + _, story = cap_story() + if encryption_type == "3": + need_keypress("3") # no token (unencrypted BSMS) + else: + fname = "bsms_%s.token" % cc_token[:4] if cc_token != "00" else "1" + with open(microsd_path(fname), "w") as f: + f.write(cc_token) + if "Press (1) to import token file from SD Card" in story: + need_keypress("1") + time.sleep(0.2) + fname = "bsms_%s.token" % cc_token[:4] + pick_menu_item(fname) + + time.sleep(0.1) + title, story = cap_story() + assert "You have entered token:\n%s" % cc_token in story + press_select() + time.sleep(0.1) + _, story = cap_story() + # address format a.k.a. SLIP derivation path - ignore and use SLIP agnostic + assert "Choose co-signer address format for correct SLIP derivation path" in story + press_select() + # account number prompt + press_select() + time.sleep(0.1) + _, story = cap_story() + # textual key description + assert "Choose key description" in story + press_select() # default + time.sleep(0.1) + title, story = cap_story() + suffix = ".txt" if encryption_type == "3" else ".dat" + mode = "rt" if encryption_type == "3" else "rb" + if "Press (1) to save BSMS signer round 1 file to SD Card" in story: + need_keypress("1") + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS signer round 1 file written' in story + fname = story.split('\n\n')[-1] + assert suffix in fname + path = microsd_path(fname) + with open(path, mode) as f: + signer_r1 = f.read() + + bsms = settings_get(BSMS_SETTINGS) + assert len(bsms[BSMS_SIGNER_SETTINGS]) == 1 + assert bsms[BSMS_SIGNER_SETTINGS][0] == cc_token + + # ROUND 2 + all_r1_data = [signer_r1.hex() if encryption_type != "3" else signer_r1] + for s in other_signers: + all_r1_data.append(s.round_1()) + + descriptor_templates = coordinator.round_2(all_r1_data) + if encryption_type == "2": + assert len(descriptor_templates) == N + for signer, tmplt in zip(other_signers, descriptor_templates[1:]): + signer.round_2(tmplt) + else: + assert len(descriptor_templates) == 1 + for signer in other_signers: + signer.round_2(descriptor_templates[0]) + + cc_desc_template = descriptor_templates[0] # zeroeth as our token is zero too + suffix = ".txt" if encryption_type == "3" else ".dat" + mode = "wt" if encryption_type == "3" else "wb" + fname = bsms_cr2_fname(cc_token, encryption_type == "2", suffix) + with open(microsd_path(fname), mode) as f: + f.write(bytes.fromhex(cc_desc_template) if mode == "wb" else cc_desc_template) + time.sleep(0.1) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu_item = "1 %s" % cc_token[:4] + pick_menu_item(menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + time.sleep(0.1) + menu_item = bsms_cr2_fname(cc_token, encryption_type == "2", suffix) + pick_menu_item(menu_item) + time.sleep(0.1) + title, story = cap_story() + assert "Create new multisig wallet?" in story + assert "bsms" in story # part of the name + policy = "Policy: %d of %d" % (M, N) + assert policy in story + assert addr_fmt.upper() in story + ms_wal_name = story.split("\n\n")[1].split("\n")[-1].strip() + ms_wal_menu_item = "%d/%d: %s" % (M, N, ms_wal_name) + press_select() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 removed + assert not bsms_settings.get(BSMS_SIGNER_SETTINGS, None) + + +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +@pytest.mark.parametrize("cr1_shortcut", [True, False]) +def test_integration_coordinator(encryption_type, M_N, addr_fmt, clear_ms, microsd_wipe, goto_home, pick_menu_item, + cap_story, need_keypress, settings_remove, microsd_path, settings_get, cap_menu, + use_mainnet, cr1_shortcut, press_select): + M, N = M_N + settings_remove(BSMS_SETTINGS) + use_mainnet() + clear_ms() + microsd_wipe() + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 1 # nothing should be in menu at this point but round 1 + pick_menu_item('Create BSMS') + # choose number of signers N + for num in str(N): + need_keypress(num) + press_select() + # choose threshold M + for num in str(M): + need_keypress(num) + press_select() + if addr_fmt == "p2wsh": + press_select() + else: + need_keypress("2") + time.sleep(0.1) + title, story = cap_story() + assert story == "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED, and (3) for no encryption" + need_keypress(encryption_type) + time.sleep(0.1) + title, story = cap_story() + assert_coord_summary(title, story, M, N, addr_fmt, encryption_type) + press_select() # confirm summary + time.sleep(0.1) + title, story = cap_story() + assert "Press (1) to participate as co-signer in this BSMS" in story + if cr1_shortcut: + _start_idx = 1 + need_keypress("1") + press_select() # slip + press_select() # acct num 0 + press_select() # default textual key description + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to save BSMS signer round 1 file to SD Card" in story: + need_keypress("1") + time.sleep(0.2) + _, story = cap_story() + shortcut_fname = story.split("\n\n")[-1] + press_select() # looking at save sr1 filename + else: + _start_idx = 0 + press_select() # continue normally + + time.sleep(0.1) + title, story = cap_story() + read_tokens = [] + if encryption_type == "3": + assert story == "Success. Coordinator round 1 saved." + else: + if "Press (1) to save BSMS token file(s) to SD Card" in story: + need_keypress("1") + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS token file(s) written' in story + fnames = story.split('\n\n')[2:] + for fname in fnames: + path = microsd_path(fname) + with open(path, 'rt') as f: + tok = f.read().strip() + read_tokens.append(tok) + + all_signers = [] + if encryption_type == "1": + assert len(read_tokens) == 1 + for i in range(_start_idx, N): + all_signers.append(Signer(read_tokens[0], "key %d" % i)) + elif encryption_type == "2": + assert len(read_tokens) == (N - _start_idx) + for i in range(N - _start_idx): + all_signers.append(Signer(read_tokens[i], "key %d" % i)) + else: + assert len(read_tokens) == 0 + for i in range(N - _start_idx): + all_signers.append(Signer("00", "key %d" % i)) + + press_select() # confirm success or files written story + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 2 + current_coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert menu[0] == current_coord_menu_item + # check correct coord tuple saved + bsms_settings = settings_get(BSMS_SETTINGS) + if BSMS_SIGNER_SETTINGS in bsms_settings: + if cr1_shortcut: + assert len(bsms_settings[BSMS_SIGNER_SETTINGS]) == 1 + shortcut_token = bsms_settings[BSMS_SIGNER_SETTINGS][0] + else: + assert bsms_settings[BSMS_SIGNER_SETTINGS] == [] + shortcut_token = None + coord_settings = bsms_settings[BSMS_COORD_SETTINGS] + assert len(coord_settings) == 1 + if read_tokens: + expect_tokens = [tok.split(" ")[-1] for tok in read_tokens] + if cr1_shortcut and encryption_type == "2": + expect_tokens = [shortcut_token] + expect_tokens + else: + expect_tokens = [] + assert coord_settings[0] == (M, N, af_map[addr_fmt], encryption_type, expect_tokens) + + # ROUND 2 + def get_token(index): + if len(read_tokens) == 1 and encryption_type == "1": + token = read_tokens[0] + elif encryption_type == "2": + token = read_tokens[index] + else: + token = "00" + return token + + all_r1_signer_data = [s.round_1() for s in all_signers] + mode = "wt" if encryption_type == "3" else "wb" + suffix = ".txt" if encryption_type == "3" else ".dat" + for i, data in enumerate(all_r1_signer_data, start=1): + token = get_token(i - 1) + fname = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + with open(microsd_path(fname), mode) as f: + f.write(bytes.fromhex(data) if mode == "wb" else data) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + press_select() # continue with manual file selection + if cr1_shortcut: + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #1 file containing round 1 data for token starting with %s' % shortcut_token[:4] + else: + expect = 'Select co-signer #1 file containing round 1 data' + assert expect in story + press_select() + pick_menu_item(shortcut_fname) + for i in range(_start_idx, N): + token = get_token(i - _start_idx) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i + 1, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % (i + 1) + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + fname = bsms_sr1_fname(token, encryption_type == "2", suffix, i + 1 - _start_idx) + pick_menu_item(fname) + + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to save BSMS descriptor template file(s) to SD Card" in story: + need_keypress("1") + time.sleep(0.1) + _, story = cap_story() + assert "BSMS descriptor template file(s) written." in story + fnames = story.split("\n\n")[1:] + if encryption_type == "2": + if cr1_shortcut: + read_tokens = [shortcut_token] + read_tokens + for fname, token in zip(fnames, read_tokens): + assert token[:4] in fname + descriptor_templates = [] + for fname in fnames: + with open(microsd_path(fname), "rt" if encryption_type == "3" else "rb") as f: + desc_temp = f.read() + descriptor_templates.append(desc_temp) + if len(descriptor_templates) == 1: + target = descriptor_templates[0] + if isinstance(target, bytes): + target = target.hex() + for signer in all_signers: + signer.round_2(target) + else: + if cr1_shortcut: + _, descriptor_templates = descriptor_templates[0], descriptor_templates[1:] + for signer, desc_tmplt in zip(all_signers, descriptor_templates): + if isinstance(desc_tmplt, bytes): + desc_tmplt = desc_tmplt.hex() + signer.round_2(desc_tmplt) + if cr1_shortcut: + # still need to add our signer + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + press_select() + pick_menu_item('Signer') + menu_item = "1 %s" % shortcut_token[:4] + pick_menu_item(menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + time.sleep(0.1) + pick_menu_item(fnames[0]) + time.sleep(0.1) + title, story = cap_story() + assert "Create new multisig wallet?" in story + assert "bsms" in story # part of the name + policy = "Policy: %d of %d" % (M, N) + assert policy in story + assert addr_fmt.upper() in story + ms_wal_name = story.split("\n\n")[1].split("\n")[-1].strip() + ms_wal_menu_item = "%d/%d: %s" % (M, N, ms_wal_name) + press_select() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 removed + assert not bsms_settings.get(BSMS_SIGNER_SETTINGS, None) + + + +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2, 2), (3, 5), (15, 15)]) +def test_auto_collection_coordinator_r2(encryption_type, M_N, goto_home, need_keypress, pick_menu_item, microsd_wipe, + cap_story, microsd_path,make_coordinator_round1, make_signer_round1, + press_select): + M, N = M_N + microsd_wipe() + + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == N and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + # add twice as many files with different tokens - should be still able to collect the correct ones + f_pattern = "bsms_sr1" + if encryption_type == "2": + suffix = ".dat" + for i in range(N): + token = os.urandom(16).hex() + s = Signer(token=token, key_description="key%d" % i) + r1 = s.round_1() + fname = "%s_%s%s" % (f_pattern, token[:4], suffix) + with open(microsd_path(fname), "wb") as f: + f.write(bytes.fromhex(r1)) + + elif encryption_type == "1": + suffix = ".dat" + for i in range(N): + token = os.urandom(8).hex() + s = Signer(token=token, key_description="key%d" % i) + r1 = s.round_1() + fname = "%s%s" % (f_pattern, suffix) + with open(microsd_path(fname), "wb") as f: + f.write(bytes.fromhex(r1)) + + else: + suffix = ".txt" + for i in range(N): + s = Signer(token="00", key_description="key%d" % i) + r1 = s.round_1() + fname = "%s%s" % (f_pattern, suffix) + with open(microsd_path(fname), "w") as f: + f.write(r1) + + tokens = make_coordinator_round1(M, N, "p2wsh", encryption_type, way="sd", tokens_only=True) + all_data = [] + for i in range(N): + token = get_token(i) + index = None + if encryption_type == "1": + index = i + 1 + all_data.append(make_signer_round1(token, "sd", purge_bsms=False, index=index)) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + coord_menu_item = coordinator_label(M, N, "p2wsh", encryption_type, index=1) + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + need_keypress("1") # auto-collection + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "3": + # we need exact number of files for unencrypted as we would have no idea which are part of this multisig setup + assert "Auto-collection failed. Defaulting to manual selection of files." in story + else: + if "Press (1) to save BSMS descriptor template file(s) to SD Card" in story: + # if NFC or Vdisk enabled - but means auto-collection was successful and we are prompted where to + # save the resulting descriptor (coordinator round2 data) + assert True + else: + # NFC and Vdisk disabled, automatically written to SD card - success + assert "BSMS descriptor template file(s) written" in story diff --git a/testing/test_decoders.py b/testing/test_decoders.py index 4faa38868..61bb06278 100644 --- a/testing/test_decoders.py +++ b/testing/test_decoders.py @@ -14,20 +14,24 @@ @pytest.fixture def try_decode(sim_exec): - def doit(arg): + def doit(arg, ): cmd = "from decoders import decode_qr_result; " + \ f"RV.write(repr(decode_qr_result({arg!r})))" result = sim_exec(cmd) - if 'Traceback' in result: raise RuntimeError(result) - if '<' in result: - # objects, like "', "'") + try: + return eval(result) + except SyntaxError: + if '<' in result: + # objects, like "', "'") + return eval(result) + + raise - return eval(result) return doit @pytest.mark.parametrize('fname,expect', [ @@ -145,7 +149,6 @@ def test_urldecode(url, sim_exec): @pytest.mark.parametrize('config', [ - 'wsh(sortedmulti(2,[0f056943/48h/1h/0h/2h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[6ba6cfd0/48h/1h/0h/2h]tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm/0/*,[747b698e/48h/1h/0h/2h]tpubDExj5FnaUnPAn7sHGUeBqD3buoNH5dqmjAT6884vbDpH1iDYWigb7kFo2cA97dc8EHb54u13TRcZxC4kgRS9gc3Ey2xc8c5urytEzTcp3ac/0/*,[7bb026be/48h/1h/0h/2h]tpubDFiuHYSJhNbHcbLJoxWdbjtUcbKR6PvLq53qC1Xq6t93CrRx78W3wcng8vJyQnY3giMJZEgNCRVzTojLb8RqPFpW5Ms2dYpjcJYofN1joyu/0/*))#al5z7mcj', '0f056943: tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP\n6ba6cfd0: tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm', '0f056943: xpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP\n6ba6cfd0: tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm', ' 0F056943 : tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP\n 6BA6CFD0 : tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm', @@ -163,6 +166,18 @@ def test_multisig(config, try_decode): assert ft == "multi" assert vals[0] == config +@pytest.mark.parametrize('desc', [ + 'wsh(sortedmulti(2,[0f056943/48h/1h/0h/2h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[6ba6cfd0/48h/1h/0h/2h]tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm/0/*,[747b698e/48h/1h/0h/2h]tpubDExj5FnaUnPAn7sHGUeBqD3buoNH5dqmjAT6884vbDpH1iDYWigb7kFo2cA97dc8EHb54u13TRcZxC4kgRS9gc3Ey2xc8c5urytEzTcp3ac/0/*,[7bb026be/48h/1h/0h/2h]tpubDFiuHYSJhNbHcbLJoxWdbjtUcbKR6PvLq53qC1Xq6t93CrRx78W3wcng8vJyQnY3giMJZEgNCRVzTojLb8RqPFpW5Ms2dYpjcJYofN1joyu/0/*))#al5z7mcj', + 'wsh(or_d(pk([5155f1fa/44h/1h/0h]tpubDCtts5PqRUpJZaRegaWEGTULHp9XbFVsmrxQ38bAXf291HfmnTuDdeeXgyi59ywvRzaAmE8hiFZMVEv7KyGnH5YVBK3SDK625Huv4uoTsWZ/<0;1>/*),and_v(v:pkh([0f056943/84h/0h/0h]tpubDCx8y86cKonoPyTtj3f9NZLpBYoBNkbAzUdafMHhggjxkhF8Dny2aekWfDafywEMZEQaQjkK9Gxn7aN7usLRUQdYbvDgcnmYRf72khPEouL/<0;1>/*),older(5))))#sraf9nwn', + 'wsh(or_d(pk([d7beb757/44h/1h/0h]tpubDCKMUppLh1DJkSgbp9dmKaMwHyBQwrmLzxgwz8J7obXnFEaWneGyMZymyLra1PBjDyqBUE9JmPVyn33QCgXwkeAniz3LCXXTpw8YFe6edjk/0/*),and_v(v:pkh([0f056943/84h/0h/0h]tpubDCx8y86cKonoPyTtj3f9NZLpBYoBNkbAzUdafMHhggjxkhF8Dny2aekWfDafywEMZEQaQjkK9Gxn7aN7usLRUQdYbvDgcnmYRf72khPEouL/<0;1>/*),older(5))))', + '{"name":"a","desc":"wsh(or_d(pk([d7beb757/44h/1h/0h]tpubDCKMUppLh1DJkSgbp9dmKaMwHyBQwrmLzxgwz8J7obXnFEaWneGyMZymyLra1PBjDyqBUE9JmPVyn33QCgXwkeAniz3LCXXTpw8YFe6edjk/0/*),and_v(v:pkh([0f056943/84h/0h/0h]tpubDCx8y86cKonoPyTtj3f9NZLpBYoBNkbAzUdafMHhggjxkhF8Dny2aekWfDafywEMZEQaQjkK9Gxn7aN7usLRUQdYbvDgcnmYRf72khPEouL/<0;1>/*),older(5))))"}', +]) +def test_miniscript_descriptors(desc, try_decode): + # includes multisig + ft, vals = try_decode(desc) + assert ft == "minisc" + assert vals[0] == desc + @pytest.mark.parametrize('data', [ ('5J9Gfy2FNTw2EpkkQu41S9CTBBVij123kYPkbYAnaQkUHtMuv2Q', False, False), ('L2TgtddYM9ueK2auJVkNaNEF3egMMK1MTMkng5RBAcBWXnCMnxcb', True, False), diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index 795a8744f..c57c26f95 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -256,7 +256,7 @@ def doit(seedvault=False, expect_xfp=None): @pytest.fixture def seed_vault_delete(pick_menu_item, need_keypress, cap_menu, cap_story, - goto_home, press_select): + goto_home, press_select, settings_get): def doit(xfp, wipe=True): # delete it from records goto_home() @@ -276,12 +276,17 @@ def doit(xfp, wipe=True): title, story = cap_story() assert "Remove" in story assert xfp in title - assert "press (1)" in story + if wipe: press_select() else: - # preserve settings - remove just from seed vaul - need_keypress("1") + if xfp2str(settings_get("xfp")) == xfp: + assert "press (1)" not in story + press_select() # will NOT wipe settings + else: + assert "press (1)" in story + # preserve settings - remove just from seed vaul + need_keypress("1") time.sleep(.1) goto_home() @@ -1117,16 +1122,21 @@ def test_seed_vault_modifications(settings_set, reset_seed_words, pick_menu_item m = cap_menu() assert m[0] == "AAA" pick_menu_item("Delete") + time.sleep(.1) + title, story = cap_story() + # current active does not offer to purge the slot, only to remove from Seed Vault + assert "delete its settings?" not in story press_select() time.sleep(.1) + goto_home() m = cap_menu() - # after we delete from seed vault together with its settings - # we're back to master secret - assert m[0] == "Ready To Sign" + # still in tmp mode + assert m[0] != "Ready To Sign" pick_menu_item("Seed Vault") time.sleep(.1) m = cap_menu() - assert len(m) == 2 + # Ignore Add Current and Restore Master (only SV items are numbered with colon) + assert len([mi for mi in m if ":" in mi]) == 2 press_down() press_select() @@ -1146,7 +1156,10 @@ def test_seed_vault_modifications(settings_set, reset_seed_words, pick_menu_item assert "Delete" in m pick_menu_item("Delete") - need_keypress("1") # only delete from seed vault + time.sleep(.1) + _, story = cap_story() + assert "delete its settings?" not in story + press_select() # only delete from seed vault, no other option provided time.sleep(.1) m = cap_menu() assert len(m) == 3 @@ -1164,6 +1177,25 @@ def test_seed_vault_modifications(settings_set, reset_seed_words, pick_menu_item # still in ephemeral assert title == m[0] + restore_main_seed() + pick_menu_item("Seed Vault") + press_select() + time.sleep(.1) + m = cap_menu() + assert "Rename" in m + assert "Use This Seed" in m + assert "Delete" in m + + pick_menu_item("Delete") + time.sleep(.1) + _, story = cap_story() + assert "delete its settings?" in story + need_keypress("1") # only remove from seed vault, keep settings + time.sleep(.1) + m = cap_menu() + assert all([":" not in mi for mi in m]) + assert "(none saved yet)" in m + def test_xfp_collision(reset_seed_words, settings_set, import_ephemeral_xprv, cap_story, press_cancel, pick_menu_item, cap_menu, @@ -1473,4 +1505,21 @@ def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_m m = cap_menu() assert m[0] == "Ready To Sign" + +def test_seed_vault_enable_on_tmp(generate_ephemeral_words, reset_seed_words, + goto_eph_seed_menu, ephemeral_seed_disabled, + verify_ephemeral_secret_ui, goto_home, cap_menu, + restore_main_seed, pick_menu_item, settings_set): + settings_set("seedvault", None) # disable seed vault + reset_seed_words() + goto_eph_seed_menu() + ephemeral_seed_disabled() + e_seed_words = generate_ephemeral_words(num_words=12, dice=False, + from_main=True, seed_vault=False) + verify_ephemeral_secret_ui(mnemonic=e_seed_words, seed_vault=False) + goto_home() + pick_menu_item("Advanced/Tools") + m = cap_menu() + assert "Seed Vault" not in m + # EOF diff --git a/testing/test_export.py b/testing/test_export.py index 0ea61eaea..15d801e08 100644 --- a/testing/test_export.py +++ b/testing/test_export.py @@ -4,12 +4,10 @@ # # Start simulator with: simulator.py --eff --set nfc=1 # -import sys -sys.path.append("../shared") -from descriptor import Descriptor -from mnemonic import Mnemonic import pytest, time, os, json, io, bech32 from bip32 import BIP32Node +from descriptor import Descriptor +from mnemonic import Mnemonic from ckcc_protocol.constants import * from helpers import xfp2str, slip132undo from conftest import simulator_fixed_xfp, simulator_fixed_tprv, simulator_fixed_words, simulator_fixed_xprv @@ -85,7 +83,12 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, addrs = [] imm_js = None imd_js = None + imd_js_tr = None + tr = False for ln in fp: + if ln.startswith("p2tr:"): + tr = True + if 'importmulti' in ln: # PLAN: this will become obsolete assert ln.startswith("importmulti '") @@ -93,20 +96,26 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, assert not imm_js, "dup importmulti lines" imm_js = ln[13:-2] elif "importdescriptors '" in ln: + ln = ln.strip() assert ln.startswith("importdescriptors '") - assert ln.endswith("'\n") - assert not imd_js, "dup importdesc lines" - imd_js = ln[19:-2] + if tr: + imd_js_tr = ln[19:-1] + tr = False + else: + imd_js = ln[19:-1] elif '=>' in ln: path, addr = ln.strip().split(' => ', 1) - assert path.startswith(f"m/84h/1h/{acct_num}h/0") - assert addr.startswith('bcrt1q') # TODO here we should differentiate if testnet or smthg sk = BIP32Node.from_wallet_key(simulator_fixed_tprv).subkey_for_path(path) - h20 = sk.hash160() - assert addr == bech32.encode(addr[0:4], 0, h20) # TODO here we should differentiate if testnet or smthg + if path.startswith(f"m/86h/1h/{acct_num}h/0"): + assert addr.startswith('bcrt1p') + assert addr == sk.address(addr_fmt="p2tr", chain="XRT") + else: + assert path.startswith(f"m/84h/1h/{acct_num}h/0") + assert addr.startswith("bcrt1q") + assert addr == sk.address(addr_fmt="p2wpkh", chain="XRT") addrs.append(addr) - assert len(addrs) == 3 + assert len(addrs) == 6 xfp = xfp2str(simulator_fixed_xfp).lower() @@ -140,14 +149,9 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, x = bitcoind_wallet.getaddressinfo(addrs[-1]) pprint(x) assert x['address'] == addrs[-1] - if 'label' in x: - # pre 0.21.? - assert x['label'] == 'testcase' - else: - assert x['labels'] == ['testcase'] - assert x['iswatchonly'] == True - assert x['iswitness'] == True - assert x['hdkeypath'] == f"m/84'/1'/{acct_num}'/0/%d" % (len(addrs)-1) + # assert x['iswatchonly'] == True + assert x['iswitness'] is True + # assert x['hdkeypath'] == f"m/84'/1'/{acct_num}'/0/%d" % (len(addrs)-1) # importdescriptors -- its better assert imd_js @@ -168,26 +172,49 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, assert expect in desc assert expect+f'/{n}/*' in desc - assert 'label' not in d + res = bitcoind_d_wallet.importdescriptors(obj) + assert res[0]["success"] + assert res[1]["success"] + x = bitcoind_d_wallet.getaddressinfo(addrs[2]) + pprint(x) + assert x['address'] == addrs[2] + assert x['iswatchonly'] == False + assert x['iswitness'] == True + assert x['solvable'] == True + assert x['hdmasterfingerprint'] == xfp2str(dev.master_fingerprint).lower() + assert x['hdkeypath'].replace("'", "h") == f"m/84h/1h/{acct_num}h/0/%d" % 2 + + assert imd_js_tr + obj = json.loads(imd_js_tr) + for n, here in enumerate(obj): + assert here['timestamp'] == 'now' + assert here['internal'] == bool(n) + + d = here['desc'] + desc, chk = d.split('#', 1) + assert len(chk) == 8 + + assert desc.startswith(f'tr([{xfp}/86h/1h/{acct_num}h]') + + expect = BIP32Node.from_wallet_key(simulator_fixed_tprv) \ + .subkey_for_path(f"m/86h/1h/{acct_num}h").hwif() + + assert expect in desc + assert expect + f'/{n}/*' in desc # test against bitcoind -- needs a "descriptor native" wallet res = bitcoind_d_wallet.importdescriptors(obj) assert res[0]["success"] assert res[1]["success"] - core_gen = [] - for i in range(3): - core_gen.append(bitcoind_d_wallet.getnewaddress()) - assert core_gen == addrs x = bitcoind_d_wallet.getaddressinfo(addrs[-1]) pprint(x) assert x['address'] == addrs[-1] - assert x['iswatchonly'] == False - assert x['iswitness'] == True - # assert x['ismine'] == True # TODO we have imported pubkeys - it has no idea if it is ours or solvable - # assert x['solvable'] == True - # assert x['hdmasterfingerprint'] == xfp2str(dev.master_fingerprint).lower() - #assert x['hdkeypath'] == f"m/84'/1'/{acct_num}'/0/%d" % (len(addrs)-1) + assert x['iswatchonly'] is False + assert x['iswitness'] is True + assert x['solvable'] is True + assert x['hdmasterfingerprint'] == xfp2str(dev.master_fingerprint).lower() + assert x['hdkeypath'].replace("'", "h") == f"m/86h/1h/{acct_num}h/0/%d" % 2 @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"]) @@ -305,7 +332,7 @@ def test_export_electrum(way, dev, mode, acct_num, pick_menu_item, goto_home, ca @pytest.mark.parametrize('acct_num', [ None, '99', '1236']) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"]) -@pytest.mark.parametrize('testnet', [True, False]) +@pytest.mark.parametrize('chain', ["BTC", "XTN"]) @pytest.mark.parametrize('app', [ # no need to run them all - just name check differs ("Generic JSON", "Generic Export"), @@ -317,12 +344,12 @@ def test_export_electrum(way, dev, mode, acct_num, pick_menu_item, goto_home, ca ]) def test_export_coldcard(way, dev, acct_num, app, pick_menu_item, goto_home, cap_story, need_keypress, microsd_path, nfc_read_json, virtdisk_path, addr_vs_path, enter_number, - load_export, testnet, use_mainnet, press_select, + load_export, chain, use_mainnet, press_select, skip_if_useless_way, expect_acctnum_captured): skip_if_useless_way(way) - if not testnet: + if chain == "BTC": use_mainnet() export_mi, app_f_name = app @@ -377,8 +404,8 @@ def test_export_coldcard(way, dev, acct_num, app, pick_menu_item, goto_home, cap addr = v.get('first', None) if fn == 'bip44': - assert first.address(netcode="XTN" if testnet else "BTC") == v['first'] - addr_vs_path(addr, v['deriv'] + '/0/0', AF_CLASSIC, testnet=testnet) + assert first.address(chain=chain) == v['first'] + addr_vs_path(addr, v['deriv'] + '/0/0', AF_CLASSIC, chain=chain) elif ('bip48_' in fn) or (fn == 'bip45'): # multisig: cant do addrs assert addr == None @@ -389,11 +416,11 @@ def test_export_coldcard(way, dev, acct_num, app, pick_menu_item, goto_home, cap h20 = first.hash160() if fn == 'bip84': assert addr == bech32.encode(addr[0:2], 0, h20) - addr_vs_path(addr, v['deriv'] + '/0/0', AF_P2WPKH, testnet=testnet) + addr_vs_path(addr, v['deriv'] + '/0/0', AF_P2WPKH, chain=chain) elif fn == 'bip49': # don't have test logic for verifying these addrs # - need to make script, and bleh - assert first.address(addr_fmt="p2sh-p2wpkh", netcode="XTN" if testnet else "BTC") == v['first'] + assert first.address(addr_fmt="p2sh-p2wpkh", chain=chain) == v['first'] else: assert False @@ -455,15 +482,14 @@ def test_export_unchained(way, dev, pick_menu_item, goto_home, cap_story, need_k @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"]) -@pytest.mark.parametrize('testnet', [True, False]) +@pytest.mark.parametrize('chain', ["BTC", "XTN"]) def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, microsd_path, - addr_vs_path, virtdisk_path, nfc_read_text, cap_story, use_mainnet, - load_export, testnet, skip_if_useless_way): + addr_vs_path, virtdisk_path, nfc_read_text, cap_story, use_testnet, + load_export, chain, skip_if_useless_way): # test UX and values produced. skip_if_useless_way(way) - if not testnet: - use_mainnet() + use_testnet(chain == "XTN") goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('File Management') @@ -481,7 +507,7 @@ def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, mi xfp = xfp2str(simulator_fixed_xfp).upper() - ek = simulator_fixed_tprv if testnet else simulator_fixed_xprv + ek = simulator_fixed_tprv if chain == "XTN" else simulator_fixed_xprv root = BIP32Node.from_wallet_key(ek) for ln in fp: @@ -508,14 +534,16 @@ def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, mi if not f: if rhs[0] in '1mn': f = AF_CLASSIC - elif rhs[0:3] in ['tb1', "bc1"]: + elif rhs[0:4] in ['tb1q', "bc1q"]: f = AF_P2WPKH + elif rhs[0:4] in ['tb1p', "bc1p"]: + f = AF_P2TR elif rhs[0] in '23': f = AF_P2WPKH_P2SH else: raise ValueError(rhs) - addr_vs_path(rhs, path=lhs, addr_fmt=f, testnet=testnet) + addr_vs_path(rhs, path=lhs, addr_fmt=f, chain=chain) @pytest.mark.qrcode @@ -538,6 +566,8 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home is_xfp = False if '-84' in m: expect = "m/84h/0h/{acct}h" + elif '86' in m and 'P2TR' in m: + expect = "m/86h/0h/{acct}h" elif '-44' in m: expect = "m/44h/0h/{acct}h" elif '49' in m: @@ -603,14 +633,13 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home @pytest.mark.parametrize("chain", ["BTC", "XTN", "XRT"]) @pytest.mark.parametrize("way", ["sd", "vdisk", "nfc", "qr"]) -@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC]) +@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2TR]) @pytest.mark.parametrize("acct_num", [None, 0, 1, (2 ** 31) - 1]) @pytest.mark.parametrize("int_ext", [True, False]) def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home, settings_set, need_keypress, expect_acctnum_captured, OK, pick_menu_item, way, cap_story, cap_menu, int_ext, settings_get, - virtdisk_path, load_export, press_select, skip_if_useless_way): - skip_if_useless_way(way) + virtdisk_path, load_export, press_select): settings_set('chain', chain) chain_num = 1 if chain in ["XTN", "XRT"] else 0 @@ -651,6 +680,10 @@ def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home, menu_item = "P2SH-Segwit" desc_prefix = "sh(wpkh(" bip44_purpose = 49 + elif addr_fmt == AF_P2TR: + menu_item = "Taproot P2TR" + desc_prefix = "tr(" + bip44_purpose = 86 else: # addr_fmt == AF_CLASSIC: menu_item = "Classic P2PKH" @@ -662,7 +695,11 @@ def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home, expect_acctnum_captured(acct_num) - contents = load_export(way, label="Descriptor", is_json=False, addr_fmt=addr_fmt) + sig_check = True + if addr_fmt == AF_P2TR: + sig_check = False + contents = load_export(way, label="Descriptor", is_json=False, addr_fmt=addr_fmt, + sig_check=sig_check) descriptor = contents.strip() if int_ext is False: diff --git a/testing/test_hsm.py b/testing/test_hsm.py index 815835596..d08929eb3 100644 --- a/testing/test_hsm.py +++ b/testing/test_hsm.py @@ -206,7 +206,7 @@ def doit(): # wallets (DICT(rules=[dict(wallet='1')]), - '(non multisig)'), + '(singlesig only)'), # users (DICT(rules=[dict(users=USERS)]), @@ -570,7 +570,7 @@ def test_named_wallets(dev, start_hsm, tweak_rule, make_myself_wallet, hsm_statu # simple p2pkh should fail psbt = fake_txn(1, 2, dev.master_xpub, outvals=[amount, 1E8-amount], change_outputs=[1], fee=0) - attempt_psbt(psbt, "not multisig") + attempt_psbt(psbt, "singlesig only") # but txn w/ multisig wallet should work psbt = fake_ms_txn(1, 2, M, keys, fee=0, outvals=[amount, 1E8-amount], outstyles=['p2wsh'], @@ -579,7 +579,119 @@ def test_named_wallets(dev, start_hsm, tweak_rule, make_myself_wallet, hsm_statu # check ms txn not accepted when rule spec's a single signer tweak_rule(0, dict(wallet='1')) - attempt_psbt(psbt, 'wrong wallet') + attempt_psbt(psbt, 'wrong multisig wallet') + +@pytest.mark.bitcoind +def test_named_wallets_miniscript(dev, start_hsm, tweak_rule, make_myself_wallet, + hsm_status, attempt_psbt, fake_txn, bitcoind, + offer_minsc_import, need_keypress, pick_menu_item, + load_export, goto_home): + stat = hsm_status() + assert not stat.active + + from test_miniscript import CHANGE_BASED_DESCS + for i, desc in enumerate(CHANGE_BASED_DESCS): + name = f"hsm_msc{i}" + xd = json.dumps({"name": name, "desc": desc}) + title, story = offer_minsc_import(xd) + assert "Create new miniscript wallet?" in story + assert name in story + need_keypress("y") + time.sleep(.2) + + core_wallets = [] + for i in range(len(CHANGE_BASED_DESCS)): + name = f"hsm_msc{i}" + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item(name) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + af = "bech32" + if i > 1: + af = "bech32m" + + addr = wo.getnewaddress("", af) + bitcoind.supply_wallet.sendtoaddress(addr, 1.0) + core_wallets.append(wo) + + # mine above txns + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + for w in core_wallets: + assert len(w.listunspent()) > 0, "nu funds" + + stat = hsm_status() + for i in range(len(CHANGE_BASED_DESCS)): + assert f"hsm_msc{i}" in stat.wallets + + # policy: only allow miniscript 0 + wname = "hsm_msc0" + policy = DICT(share_addrs=["any"], rules=[dict(wallet=wname)]) + + stat = start_hsm(policy) + assert 'Any amount from miniscript wallet' in stat.summary + assert wname in stat.summary + assert 'wallets' not in stat + + # simple p2pkh should fail + psbt = fake_txn(1, 2, outvals=[5E6, 1E8-5E6], change_outputs=[1], fee=0) + attempt_psbt(psbt, "singlesig only") + + # but txn from target miniscript wallet 0 must work + wal0 = core_wallets[0] + psbt_res = wal0.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.2}], 0, {"fee_rate": 20}) + attempt_psbt(base64.b64decode(psbt_res["psbt"])) + + # WRONG + wal2 = core_wallets[2] + psbt_res = wal2.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.2}], 0, {"fee_rate": 18}) + attempt_psbt(base64.b64decode(psbt_res["psbt"]), 'wrong miniscript wallet') + + wal1 = core_wallets[1] + psbt_res = wal1.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.2}], 0, {"fee_rate": 12}) + attempt_psbt(base64.b64decode(psbt_res["psbt"]), 'wrong miniscript wallet') + + # works + psbt_res = wal0.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.3}], 0, {"fee_rate": 15}) + attempt_psbt(base64.b64decode(psbt_res["psbt"])) + + wname = "hsm_msc3" + tweak_rule(0, dict(wallet=wname)) + + # this worked before but now, after tweak, it does not + psbt_res = wal0.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.1}], 0, {"fee_rate": 13}) + attempt_psbt(base64.b64decode(psbt_res["psbt"]), 'wrong miniscript wallet') + + # correct wallet 3 + wal3 = core_wallets[3] + psbt_res = wal3.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.6}], 0, {"fee_rate": 10}) + attempt_psbt(base64.b64decode(psbt_res["psbt"])) + + psbt_res = wal3.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.15}], 0, {"fee_rate": 15}) + last_correct = base64.b64decode(psbt_res["psbt"]) + attempt_psbt(last_correct) + + # check ms txn not accepted when rule spec's a single signer + tweak_rule(0, dict(wallet='1')) + attempt_psbt(last_correct, 'wrong miniscript wallet') + + stat = hsm_status() + assert stat.approvals == 4 + assert stat.refusals == 5 @pytest.mark.parametrize('with_whitelist_opts', [ False, True]) def test_whitelist_single(dev, start_hsm, tweak_rule, attempt_psbt, fake_txn, with_whitelist_opts, amount=5E6): @@ -594,7 +706,7 @@ def test_whitelist_single(dev, start_hsm, tweak_rule, attempt_psbt, fake_txn, wi start_hsm(policy) # try all addr types - for style in ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh']: + for style in ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh', 'p2tr']: dests = [] psbt = fake_txn(1, 2, dev.master_xpub, outstyles=[style, 'p2wpkh'], @@ -1157,6 +1269,31 @@ def test_show_p2sh_addr(dev, hsm_reset, start_hsm, change_hsm, make_myself_walle M, xfp_paths, scr, addr_fmt=AF_P2WSH)) assert 'Not allowed in HSM mode' in str(ee) +def test_show_miniscript_addr(dev, offer_minsc_import, start_hsm, + change_hsm, need_keypress, clear_miniscript): + clear_miniscript() + from test_miniscript import CHANGE_BASED_DESCS + name = "hsm_msc_msas" + xd = json.dumps({"name": name, "desc": CHANGE_BASED_DESCS[0]}) + title, story = offer_minsc_import(xd) + assert "Create new miniscript wallet?" in story + assert name in story + need_keypress("y") + time.sleep(.2) + + policy = DICT(share_addrs=["any", "p2sh"], rules=[dict(wallet=name)]) + start_hsm(policy) + + with pytest.raises(CCProtoError) as ee: + dev.send_recv(CCProtocolPacker.miniscript_address(name, False, 0)) + assert "Not allowed in HSM mode" in ee.value.args[0] + + # change policy to allow miniscript address show + policy = DICT(share_addrs=["any", "p2sh", "msas"], rules=[dict(wallet=name)]) + change_hsm(policy) + addr = dev.send_recv(CCProtocolPacker.miniscript_address(name, False, 0)) + assert addr[2:4] == "1q" + def test_xpub_sharing(dev, start_hsm, change_hsm, addr_fmt=AF_CLASSIC): # xpub sharing, but only at certain derivations # - note 'm' is always shared @@ -1537,7 +1674,8 @@ def test_op_return_output_local(op_return_data, start_hsm, attempt_psbt, fake_tx def test_op_return_output_bitcoind(op_return_data, start_hsm, attempt_psbt, bitcoind_d_sim_watch, bitcoind, hsm_reset): cc = bitcoind_d_sim_watch dest_address = cc.getnewaddress() - bitcoind.supply_wallet.generatetoaddress(101, dest_address) + bitcoind.supply_wallet.sendtoaddress(dest_address, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) psbt = cc.walletcreatefundedpsbt([], [{dest_address: 1.0}, {"data": op_return_data.hex()}], 0, {"fee_rate": 20})["psbt"] policy = DICT(rules=[dict(max_amount=10)]) start_hsm(policy) diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py new file mode 100644 index 000000000..6433af289 --- /dev/null +++ b/testing/test_miniscript.py @@ -0,0 +1,3011 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# Miniscript-related tests. +# +import pytest, json, time, itertools, struct, random, os, base64 +from ckcc.protocol import CCProtocolPacker +from constants import AF_P2TR +from psbt import BasicPSBT +from charcodes import KEY_QR, KEY_RIGHT, KEY_CANCEL +from bbqr import split_qrs +from bip32 import BIP32Node + + +H = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" # BIP-0341 +TREE = { + 1: '%s', + 2: '{%s,%s}', + 3: random.choice(['{{%s,%s},%s}','{%s,{%s,%s}}']), + 4: '{{%s,%s},{%s,%s}}', + 5: random.choice(['{{%s,%s},{%s,{%s,%s}}}', '{{{%s,%s},%s},{%s,%s}}']), + 6: '{{%s,{%s,%s}},{{%s,%s},%s}}', + 7: '{{%s,{%s,%s}},{%s,{%s,{%s,%s}}}}', + 8: '{{{%s,%s},{%s,%s}},{{%s,%s},{%s,%s}}}', + # more than MAX (4) for test purposes + 9: '{{{%s{%s,%s}},{%s,%s}},{{%s,%s},{%s,%s}}}' +} + + +def ranged_unspendable_internal_key(chain_code=32 * b"\x01", subderiv="/<0;1>/*"): + # provide ranged provably unspendable key in serialized extended key format for core to understand it + # core does NOT understand 'unspend(' + pk = b"\x02" + bytes.fromhex(H) + node = BIP32Node.from_chaincode_pubkey(chain_code, pk) + return node.hwif() + subderiv + + +@pytest.fixture +def offer_minsc_import(cap_story, dev): + def doit(config, allow_non_ascii=False): + # upload the file, trigger import + file_len, sha = dev.upload_file(config.encode('utf-8' if allow_non_ascii else 'ascii')) + + open('debug/last-config-msc.txt', 'wt').write(config) + dev.send_recv(CCProtocolPacker.miniscript_enroll(file_len, sha)) + + time.sleep(.2) + title, story = cap_story() + return title, story + + return doit + + +@pytest.fixture +def import_miniscript(goto_home, pick_menu_item, cap_story, need_keypress, + nfc_write_text, press_select, scan_a_qr, press_nfc): + def doit(fname, way="sd", data=None): + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import') + time.sleep(.3) + _, story = cap_story() + if way == "nfc": + if "via NFC" not in story: + pytest.skip("nfc disabled") + + press_nfc() + time.sleep(.1) + if isinstance(data, dict): + data = json.dumps(data) + nfc_write_text(data) + time.sleep(1) + return cap_story() + elif way == "qr": + if isinstance(data, dict): + data = json.dumps(data) + + need_keypress(KEY_QR) + try: + scan_a_qr(data) + except: + # always as text - even if it is json + actual_vers, parts = split_qrs(data, 'U', max_version=20) + random.shuffle(parts) + + for p in parts: + scan_a_qr(p) + time.sleep(1) # just so we can watch + + time.sleep(1) + return cap_story() + + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk or NFC is enabled + if way == "sd": + need_keypress("1") + + elif way == "vdisk": + if "ress (2)" not in story: + pytest.xfail(way) + + need_keypress("2") + else: + if way != "sd": + pytest.xfail(way) + + time.sleep(.5) + pick_menu_item(fname) + time.sleep(.1) + return cap_story() + + return doit + +@pytest.fixture +def import_duplicate(import_miniscript, press_cancel, virtdisk_path, microsd_path): + def doit(fname, way="sd", data=None): + new_fpath = None + new_fname = None + path_f = microsd_path + if way == "vdisk": + path_f = virtdisk_path + + time.sleep(.2) + title, story = import_miniscript(fname, way, data=data) + if "unique names" in story: + # trying to import duplicate with same name + # cannot get over name uniqueness requirement + # need to duplicate + if way in ["qr", "nfc"]: + data["name"] = data["name"] + "-new" + else: + with open(path_f(fname), "r") as f: + res = f.read() + + basename, ext = fname.split(".", 1) + new_fname = basename + "-new" + "." + ext + new_fpath = path_f(basename+"-new"+"."+ext) + with open(new_fpath, "w") as f: + f.write(res) + + title, story = import_miniscript(new_fname, way, data=data) + time.sleep(.2) + + assert "duplicate of already saved wallet" in story + assert "OK to approve" not in story + press_cancel() + + if new_fpath: + os.remove(new_fpath) + + return doit + +@pytest.fixture +def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, + microsd_path, is_q1, readback_bbqr, cap_screen_qr, + garbage_collector): + + def doit(minsc_name): + qr_external = None + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item(minsc_name) + pick_menu_item("Descriptors") + pick_menu_item("Export") + need_keypress("1") # internal and external separately + time.sleep(.1) + if is_q1: + # check QR + need_keypress(KEY_QR) + try: + file_type, data = readback_bbqr() + assert file_type == "U" + data = data.decode() + except: + data = cap_screen_qr().decode('ascii') + + qr_external, qr_internal = data.split("\n") + need_keypress(KEY_CANCEL) + + pick_menu_item("Export") + need_keypress("1") # internal and external separately + time.sleep(.2) + + title, story = cap_story() + if "Press (1)" in story: + need_keypress("1") + time.sleep(.2) + title, story = cap_story() + + assert "Miniscript file written" in story + fname = story.split("\n\n")[-1] + fpath = microsd_path(fname) + garbage_collector.append(fpath) + with open(fpath, "r") as f: + cont = f.read() + external, internal = cont.split("\n") + if qr_external: + assert qr_external == external + assert qr_internal == internal + return external, internal + + return doit + + +@pytest.fixture +def usb_miniscript_get(dev): + def doit(name): + dev.check_mitm() + resp = dev.send_recv(CCProtocolPacker.miniscript_get(name)) + return json.loads(resp) + + return doit + + +@pytest.fixture +def usb_miniscript_delete(dev): + def doit(name): + dev.check_mitm() + dev.send_recv(CCProtocolPacker.miniscript_delete(name)) + + return doit + + +@pytest.fixture +def usb_miniscript_ls(dev): + def doit(): + dev.check_mitm() + resp = dev.send_recv(CCProtocolPacker.miniscript_ls()) + return json.loads(resp) + + return doit + + +@pytest.fixture +def usb_miniscript_addr(dev): + def doit(name, index, change=False): + dev.check_mitm() + resp = dev.send_recv(CCProtocolPacker.miniscript_address(name, change, index)) + return resp + + return doit + + +@pytest.fixture +def get_cc_key(dev): + def doit(path, subderiv=None): + # cc device key + master_xfp_str = struct.pack('/*'}" + return doit + + +@pytest.fixture +def bitcoin_core_signer(bitcoind): + def doit(name="core_signer"): + # core signer + signer = bitcoind.create_wallet(wallet_name=name, disable_private_keys=False, + blank=False, passphrase=None, avoid_reuse=False, + descriptors=True) + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith("pkh(") and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_key = core_desc[4:-1] + return signer, core_key + return doit + + +@pytest.fixture +def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, + cap_story, load_export, miniscript_descriptors, + usb_miniscript_addr, cap_screen_qr): + def doit(way, addr_fmt, wallet, cc_minsc_name, export_check=True): + goto_home() + pick_menu_item("Address Explorer") + need_keypress('4') # warning + m = cap_menu() + wal_name = m[-1] + pick_menu_item(wal_name) + + title, story = cap_story() + assert "Taproot internal key" not in story + + if way == "qr": + need_keypress(KEY_QR) + cc_addrs = [] + for i in range(10): + cc_addrs.append(cap_screen_qr().decode()) + need_keypress(KEY_RIGHT) + time.sleep(.2) + need_keypress(KEY_CANCEL) + else: + contents = load_export(way, label="Address summary", is_json=False, sig_check=False) + addr_cont = contents.strip() + + time.sleep(.5) + title, story = cap_story() + assert "(0)" in story + assert "change addresses." in story + need_keypress("0") + time.sleep(.5) + title, story = cap_story() + assert "(0)" not in story + assert "change addresses." not in story + + if way == "qr": + need_keypress(KEY_QR) + cc_addrs_change = [] + for i in range(10): + cc_addrs_change.append(cap_screen_qr().decode()) + need_keypress(KEY_RIGHT) + time.sleep(.2) + need_keypress(KEY_CANCEL) + else: + contents_change = load_export(way, label="Address summary", is_json=False, + sig_check=False) + addr_cont_change = contents_change.strip() + + if way == "nfc": + addr_range = [0, 9] + cc_addrs = addr_cont.split("\n") + cc_addrs_change = addr_cont_change.split("\n") + part_addr_index = 0 + elif way == 'qr': + addr_range = [0, 9] + part_addr_index = 0 + else: + addr_range = [0, 249] + cc_addrs_split = addr_cont.split("\n") + cc_addrs_split_change = addr_cont_change.split("\n") + # header is different for taproot + if addr_fmt == "bech32m": + try: + assert "Internal Key" in cc_addrs_split[0] + except AssertionError: + assert "Unspendable Internal Key" in cc_addrs_split[0] + assert "Taptree" in cc_addrs_split[0] + else: + assert "Internal Key" not in cc_addrs_split[0] + assert "Taptree" not in cc_addrs_split[0] + + cc_addrs = cc_addrs_split[1:] + cc_addrs_change = cc_addrs_split_change[1:] + part_addr_index = 1 + + time.sleep(2) + + internal_desc = None + external_desc = None + descriptors = wallet.listdescriptors()["descriptors"] + for desc in descriptors: + if desc["internal"]: + internal_desc = desc["desc"] + else: + external_desc = desc["desc"] + + if export_check: + cc_external, cc_internal = miniscript_descriptors(cc_minsc_name) + + unspend = "unspend(" + if unspend in cc_external: + assert "unspend(" in cc_internal + netcode = "XTN" if "tpub" in cc_external else "BTC" + # bitcoin core does not recognize unspend( - needs hack + # CC properly exports any imported unspend( for bitcoin core + # as extended key serialization xpub/<0;1>/* + start_idx = cc_external.find(unspend) + assert start_idx != -1 + end_idx = start_idx + len(unspend) + 64 + 1 + uns = cc_external[start_idx: end_idx] + chain_code = bytes.fromhex(uns[len(unspend):-1]) + node = BIP32Node.from_chaincode_pubkey(chain_code, + b"\x02" + bytes.fromhex(H), + netcode=netcode) + ek = node.hwif() + cc_external = cc_external.replace(uns, ek) + cc_internal = cc_internal.replace(uns, ek) + + assert cc_external.split("#")[0] == external_desc.split("#")[0].replace("'", "h") + assert cc_internal.split("#")[0] == internal_desc.split("#")[0].replace("'", "h") + + bitcoind_addrs = wallet.deriveaddresses(external_desc, addr_range) + bitcoind_addrs_change = wallet.deriveaddresses(internal_desc, addr_range) + + for cc, core in [(cc_addrs, bitcoind_addrs), (cc_addrs_change, bitcoind_addrs_change)]: + for idx, cc_item in enumerate(cc): + if way == "nfc": + address = cc_item + elif way == "qr": + if cc_item.startswith("BC"): + cc_item = cc_item.lower() + address = cc_item + else: + cc_item = cc_item.split(",") + address = cc_item[part_addr_index] + address = address[1:-1] + assert core[idx] == address + + # check few USB addresses + for i in range(5): + addr = usb_miniscript_addr(cc_minsc_name, i, change=False) + time.sleep(.1) + title, story = cap_story() + assert addr in story + assert addr == bitcoind_addrs[i] + + for i in range(5): + addr = usb_miniscript_addr(cc_minsc_name, i, change=True) + time.sleep(.1) + title, story = cap_story() + assert addr in story + assert addr == bitcoind_addrs_change[i] + + return doit + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("addr_fmt", ["bech32", "p2sh-segwit"]) +@pytest.mark.parametrize("lt_type", ["older", "after"]) # this is actually not generated by liana (liana is relative only) +@pytest.mark.parametrize("recovery", [True, False]) +@pytest.mark.parametrize("way", ["qr", "nfc", "sd", "vdisk"]) +@pytest.mark.parametrize("minisc", [ + "or_d(pk(@A),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:pk(@B),locktime(N)))", # this is actually not generated by liana + + "or_d(multi(2,@A,@C),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:multi(2,@B,@C),locktime(N)))", +]) +def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_miniscript, goto_home, + pick_menu_item, cap_menu, cap_story, microsd_path, way, + use_regtest, bitcoind, microsd_wipe, load_export, dev, + address_explorer_check, get_cc_key, import_miniscript, + bitcoin_core_signer, import_duplicate, press_select, + virtdisk_path, skip_if_useless_way, garbage_collector): + skip_if_useless_way(way) + normal_cosign_core = False + recovery_cosign_core = False + if "multi(" in minisc.split("),", 1)[0]: + normal_cosign_core = True + if "multi(" in minisc.split("),", 1)[-1]: + recovery_cosign_core = True + + if lt_type == "older": + sequence = 5 + locktime = 0 + # 101 blocks are mined by default + to_replace = "older(5)" + else: + sequence = None + locktime = 105 + to_replace = "after(105)" + + minisc = minisc.replace("locktime(N)", to_replace) + + if addr_fmt == "bech32": + desc = f"wsh({minisc})" + else: + desc = f"sh(wsh({minisc}))" + + # core signer + signer0, core_key0 = bitcoin_core_signer("s0") + + # cc device key + cc_key = get_cc_key("84h/0h/0h") + + if recovery: + # recevoery path is always B + desc = desc.replace("@B", cc_key) + desc = desc.replace("@A", core_key0) + else: + desc = desc.replace("@A", cc_key) + desc = desc.replace("@B", core_key0) + + if "@C" in desc: + signer1, core_key1 = bitcoin_core_signer("s1") + desc = desc.replace("@C", core_key1) + + use_regtest() + clear_miniscript() + goto_home() + name = "core-miniscript" + fname = f"{name}.txt" + if way in ["qr", "nfc"]: + data = dict(name=name, desc=desc) + else: + path_f = microsd_path if way == "sd" else virtdisk_path + data = None + fpath = path_f(fname) + garbage_collector.append(fpath) + with open(fpath, "w") as f: + f.write(desc) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + _, story = import_miniscript(fname, way=way, data=data) + try: + assert "Create new miniscript wallet?" in story + except: + time.sleep(.2) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname, way=way, data=data) + menu = cap_menu() + assert menu[0] == name + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export(way, label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + addr = wo.getnewaddress("", addr_fmt) + addr_dest = wo.getnewaddress("", addr_fmt) # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + if recovery and sequence: + inp["sequence"] = sequence + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: all_of_it - 1}], + locktime if recovery else 0, + {"fee_rate": 20, "change_type": addr_fmt, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if normal_cosign_core or recovery_cosign_core: + psbt = signer1.walletprocesspsbt(psbt, True, "ALL")["psbt"] + + name = f"{name}.psbt" + fpath = microsd_path(name) + with open(fpath, "w") as f: + f.write(psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + if recovery: + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' if sequence else "non-final" + bitcoind.supply_wallet.generatetoaddress(6, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + else: + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check(way, addr_fmt, wo, "core-miniscript") + + +@pytest.mark.parametrize("addr_fmt", ["bech32", "p2sh-segwit"]) +@pytest.mark.parametrize("way", ["qr", "sd"]) +@pytest.mark.parametrize("minsc", [ + ("or_i(and_v(v:pkh($0),older(10)),or_d(multi(3,@A,@B,@C),and_v(v:thresh(2,pkh($1),a:pkh($2),a:pkh($3)),older(5))))", 0), + ("or_i(and_v(v:pkh(@A),older(10)),or_d(multi(3,$0,$1,$2),and_v(v:thresh(2,pkh($3),a:pkh($4),a:pkh($5)),older(5))))", 10), + ("or_i(and_v(v:pkh($0),older(10)),or_d(multi(3,$1,$2,$3),and_v(v:thresh(2,pkh(@A),a:pkh(@B),a:pkh($4)),older(5))))", 5), +]) +def test_liana_miniscripts_complex(addr_fmt, minsc, bitcoind, use_regtest, clear_miniscript, + microsd_path, pick_menu_item, cap_story, + load_export, goto_home, address_explorer_check, cap_menu, + get_cc_key, import_miniscript, bitcoin_core_signer, + import_duplicate, press_select, way, skip_if_useless_way, + garbage_collector): + skip_if_useless_way(way) + use_regtest() + clear_miniscript() + goto_home() + + minsc, to_gen = minsc + signer_keys = minsc.count("@") + bsigners = signer_keys - 1 + random_keys = minsc.count("$") + bitcoind_signers = [] + for i in range(random_keys + bsigners): + s, core_key = bitcoin_core_signer(f"co-signer-{i}") + bitcoind_signers.append((s, core_key)) + + cc_key = get_cc_key("m/84h/1h/0h") + minsc = minsc.replace("@A", cc_key) + + use_signers = [] + if bsigners == 2: + for ph, (s, key) in zip(["@B", "@C"], bitcoind_signers[:2]): + use_signers.append(s) + minsc = minsc.replace(ph, key) + for i, (s, key) in enumerate(bitcoind_signers[2:]): + ph = f"${i}" + minsc = minsc.replace(ph, key) + elif bsigners == 1: + use_signers.append(bitcoind_signers[0][0]) + minsc = minsc.replace("@B", bitcoind_signers[0][1]) + for i, (s, key) in enumerate(bitcoind_signers[1:]): + ph = f"${i}" + minsc = minsc.replace(ph, key) + elif bsigners == 0: + for i, (s, key) in enumerate(bitcoind_signers): + ph = f"${i}" + minsc = minsc.replace(ph, key) + else: + assert False + + if addr_fmt == "bech32": + desc = f"wsh({minsc})" + else: + desc = f"sh(wsh({minsc}))" + + name = "cmplx-miniscript" + + if way in ["qr", "nfc"]: + fname = None + data = dict(name=name, desc=desc) + else: + fname = f"{name}.txt" + data = None + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + _, story = import_miniscript(fname, way=way, data=data) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname, way=way, data=data) + menu = cap_menu() + assert menu[0] == name + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export(way, label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + addr = wo.getnewaddress("", addr_fmt) + addr_dest = wo.getnewaddress("", addr_fmt) # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + if to_gen: + inp["sequence"] = to_gen + + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: 1}], + 0, + {"fee_rate": 20, "change_type": addr_fmt, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + # cosingers signing first + for s in use_signers: + psbt = s.walletprocesspsbt(psbt, True, "ALL")["psbt"] + + pname = f"{name}.psbt" + ppath = microsd_path(pname) + with open(ppath, "w") as f: + f.write(psbt) + garbage_collector.append(ppath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(pname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + if to_gen: + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + bitcoind.supply_wallet.generatetoaddress(to_gen, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + else: + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check(way, addr_fmt, wo, name) + + +@pytest.fixture +def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, + pick_menu_item, goto_home, cap_menu, microsd_path, + use_regtest, get_cc_key, import_miniscript, + bitcoin_core_signer, import_duplicate, press_select, + virtdisk_path, garbage_collector): + def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None, + tapscript_threshold=False, add_own_pk=False, same_account=False, way="sd"): + + use_regtest() + bitcoind_signers = [] + bitcoind_signers_xpubs = [] + for i in range(N - 1): + s, core_key = bitcoin_core_signer(f"bitcoind--signer{i}") + s.keypoolrefill(10) + bitcoind_signers.append(s) + bitcoind_signers_xpubs.append(core_key) + + # watch only wallet where multisig descriptor will be imported + ms = bitcoind.create_wallet( + wallet_name=f"watch_only_{script_type}_{M}of{N}", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('Export XPUB') + time.sleep(0.5) + title, story = cap_story() + assert "extended public keys (XPUB) you would need to join a multisig wallet" in story + press_select() + need_keypress(str(cc_account)) # account + press_select() + xpub_obj = load_export(way, label="Multisig XPUB", is_json=True, sig_check=False) + template = xpub_obj[script_type +"_desc"] + acct_deriv = xpub_obj[script_type + '_deriv'] + + if tapscript_threshold: + me = f"[{xpub_obj['xfp']}/{acct_deriv.replace('m/','')}]{xpub_obj[script_type]}/<0;1>/*" + signers_xp = [me] + bitcoind_signers_xpubs + assert len(signers_xp) == N + desc = f"tr({H},%s)" + if internal_key: + desc = desc.replace(H, internal_key) + elif r: + desc = desc.replace(H, f"r={r}") + + scripts = [] + for c in itertools.combinations(signers_xp, M): + tmplt = f"sortedmulti_a({M},{','.join(c)})" + scripts.append(tmplt) + + if len(scripts) > 8: + while True: + # just some of them but at least one has to have my key + x = random.sample(scripts, 8) + if any(me in s for s in x): + scripts = x + break + + if add_own_pk: + if len(scripts) < 8: + if same_account: + cc_key = get_cc_key("m/86h/1h/0h", subderiv="/<2;3>/*") + else: + cc_key = get_cc_key("m/86h/1h/1000h") + cc_pk_leaf = f"pk({cc_key})" + scripts.append(cc_pk_leaf) + else: + pytest.skip("Scripts full") + + temp = TREE[len(scripts)] + temp = temp % tuple(scripts) + + desc = desc % temp + + else: + if add_own_pk: + if same_account: + ss = [get_cc_key("m/86h/1h/0h", subderiv="/<4;5>/*")] + bitcoind_signers_xpubs + cc_key = get_cc_key("m/86h/1h/0h", subderiv="/<6;7>/*") + else: + ss = [get_cc_key("m/86h/1h/0h")] + bitcoind_signers_xpubs + cc_key = get_cc_key("m/86h/1h/1000h") + + tmplt = f"sortedmulti_a({M},{','.join(ss)})" + cc_pk_leaf = f"pk({cc_key})" + desc = f"tr({H},{{{tmplt},{cc_pk_leaf}}})" + else: + desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) + + if internal_key: + desc = desc.replace(H, internal_key) + elif r: + desc = desc.replace(H, f"r={r}") + + name = "minisc" + fname = None + if way in ["sd", "vdisk"]: + data = None + fname = f"{name}.txt" + path_f = microsd_path if way == 'sd' else virtdisk_path + fpath = path_f(fname) + with open(fpath, "w") as f: + f.write(desc + "\n") + garbage_collector.append(fpath) + else: + data = dict(name=name, desc=desc) + + _, story = import_miniscript(fname, way=way, data=data) + assert "Create new miniscript wallet?" in story + assert name in story + if script_type == "p2tr": + assert "Taproot internal key" in story + assert "Tapscript" in story + assert "Press (1) to see extended public keys" in story + if script_type == "p2wsh": + assert "P2WSH" in story + elif script_type == "p2sh": + assert "P2SH" in story + elif script_type == "p2tr": + assert "P2TR" in story + else: + assert "P2SH-P2WSH" in story + # assert "Derivation:\n Varies (2)" in story + press_select() # approve multisig import + if r == "@": + # unspendable key is generated randomly + # descriptors will differ + with pytest.raises(AssertionError): + import_duplicate(fname, way=way, data=data) + else: + import_duplicate(fname, way=way, data=data) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + menu = cap_menu() + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export(way, label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # import descriptors to watch only wallet + res = ms.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + if r and r != "@": + from pysecp256k1.extrakeys import keypair_create, keypair_xonly_pub, xonly_pubkey_parse + from pysecp256k1.extrakeys import xonly_pubkey_tweak_add, xonly_pubkey_serialize, xonly_pubkey_from_pubkey + H_xo = xonly_pubkey_parse(bytes.fromhex(H)) + r_bytes = bytes.fromhex(r) + kp = keypair_create(r_bytes) + kp_xo, kp_parity = keypair_xonly_pub(kp) + pk = xonly_pubkey_tweak_add(H_xo, xonly_pubkey_serialize(kp_xo)) + xo, xo_parity = xonly_pubkey_from_pubkey(pk) + internal_key_bytes = xonly_pubkey_serialize(xo) + internal_key_hex = internal_key_bytes.hex() + assert internal_key_hex in core_desc_object[0]["desc"] + assert internal_key_hex in core_desc_object[1]["desc"] + + if funded: + if script_type == "p2wsh": + addr_type = "bech32" + elif script_type == "p2tr": + addr_type = "bech32m" + elif script_type == "p2sh": + addr_type = "legacy" + else: + addr_type = "p2sh-segwit" + + addr = ms.getnewaddress("", addr_type) + if script_type == "p2wsh": + sw = "bcrt1q" + elif script_type == "p2tr": + sw = "bcrt1p" + else: + sw = "2" + assert addr.startswith(sw) + # get some coins and fund above multisig address + bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + return ms, bitcoind_signers + + return doit + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("add_pk", [True, False]) +@pytest.mark.parametrize("same_acct", [None, True, False]) +@pytest.mark.parametrize("way", ["qr", "sd"]) +@pytest.mark.parametrize("M_N", [(3,4),(4,5),(5,6)]) +def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, + cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, + load_export, bitcoind_miniscript, add_pk, same_acct, get_cc_key, + press_select, way, skip_if_useless_way, garbage_collector): + skip_if_useless_way(way) + M, N = M_N + clear_miniscript() + microsd_wipe() + internal_key = None + if same_acct is None: + internal_key = ranged_unspendable_internal_key() + elif same_acct: + # provide internal key with same account derivation (change based derivation) + internal_key = get_cc_key("m/86h/1h/0h", subderiv='/<10;11>/*') + + wo, signers = bitcoind_miniscript(M, N, "p2tr", tapscript_threshold=True, + add_own_pk=add_pk, internal_key=internal_key, + same_account=same_acct, way=way) + addr = wo.getnewaddress("", "bech32m") + bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + conso_addr = wo.getnewaddress("conso", "bech32m") + psbt = wo.walletcreatefundedpsbt([], [{conso_addr:25}], 0, {"fee_rate": 2})["psbt"] + if not cc_first: + for s in signers[0:M-1]: + psbt = s.walletprocesspsbt(psbt, True, "DEFAULT")["psbt"] + + psbt_fpath = microsd_path("ts_tree.psbt") + with open(psbt_fpath, "w") as f: + f.write(psbt) + + garbage_collector.append(psbt_fpath) + time.sleep(2) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item("ts_tree.psbt") + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + press_select() + time.sleep(0.1) + title, story = cap_story() + assert title == "PSBT Signed" + fname = [i for i in story.split("\n\n") if ".psbt" in i][0] + fpath = microsd_path(fname) + with open(fpath, "r") as f: + psbt = f.read().strip() + garbage_collector.append(fpath) + if cc_first: + # we MUST be able to finalize this without anyone else if add pk + if not add_pk: + for s in signers[0:M-1]: + psbt = s.walletprocesspsbt(psbt, True, "DEFAULT")["psbt"] + res = wo.finalizepsbt(psbt) + assert res["complete"] is True + accept_res = wo.testmempoolaccept([res["hex"]])[0] + assert accept_res["allowed"] is True + txid = wo.sendrawtransaction(res["hex"]) + assert len(txid) == 64 + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("csa", [True, False]) +@pytest.mark.parametrize("add_pk", [True, False]) +@pytest.mark.parametrize('M_N', [(3, 15), (2, 2), (3, 5)]) +@pytest.mark.parametrize('way', ["qr", "sd", "vdisk", "nfc"]) +@pytest.mark.parametrize('internal_type', ["unspend(", "xpub", "static"]) +def test_bitcoind_tapscript_address(M_N, clear_miniscript, bitcoind_miniscript, + use_regtest, way, csa, address_explorer_check, + add_pk, internal_type, skip_if_useless_way): + skip_if_useless_way(way) + use_regtest() + clear_miniscript() + M, N = M_N + + ik = None # default static + if internal_type == "unspend(": + ik = f"unspend({os.urandom(32).hex()})/<20;21>/*" + elif internal_type == "xpub": + ik = ranged_unspendable_internal_key(os.urandom(32)) + + ms_wo, _ = bitcoind_miniscript(M, N, "p2tr", funded=False, tapscript_threshold=csa, + add_own_pk=add_pk, way=way, internal_key=ik) + address_explorer_check(way, "bech32m", ms_wo, "minisc") + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("m_n", [(2,2), (3, 5), (32, 32)]) +@pytest.mark.parametrize("way", ["qr", "sd"]) +@pytest.mark.parametrize("internal_key_spendable", [ + True, + False, + "77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76", + "@", + "tpubD6NzVbkrYhZ4WhUnV3cPSoRWGf9AUdG2dvNpsXPiYzuTnxzAxemnbajrATDBWhaAVreZSzoGSe3YbbkY2K267tK3TrRmNiLH2pRBpo8yaWm/<2;3>/*", + "unspend(c72231504cf8c1bbefa55974db4e0cdac781049a9a81a87e7ff5beeb45b34d3d)/<0;1>/*" +]) +def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, bitcoind, goto_home, cap_menu, + pick_menu_item, cap_story, microsd_path, load_export, microsd_wipe, dev, way, + bitcoind_miniscript, clear_miniscript, get_cc_key, press_cancel, press_select, + skip_if_useless_way, garbage_collector): + skip_if_useless_way(way) + M, N = m_n + clear_miniscript() + microsd_wipe() + internal_key = None + r = None + if internal_key_spendable is True: + internal_key = get_cc_key("86h/0h/3h") + elif internal_key_spendable == "@": + r = "@" + elif isinstance(internal_key_spendable, str): + if len(internal_key_spendable) == 64: + r = internal_key_spendable + else: + internal_key = internal_key_spendable + + tapscript_wo, bitcoind_signers = bitcoind_miniscript( + M, N, "p2tr", internal_key=internal_key, r=r, + way=way + ) + + dest_addr = tapscript_wo.getnewaddress("", "bech32m") + psbt = tapscript_wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 20})["psbt"] + fname = "tapscript.psbt" + if not cc_first: + # bitcoind cosigner sigs first + for i in range(M - 1): + signer = bitcoind_signers[i] + psbt = signer.walletprocesspsbt(psbt, True, "DEFAULT", True)["psbt"] + + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbt) + + garbage_collector.append(fpath) + goto_home() + # bug in goto_home ? + press_cancel() + time.sleep(0.1) + # CC signing + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + press_select() + time.sleep(0.1) + title, story = cap_story() + split_story = story.split("\n\n") + cc_tx_id = None + if "(ready for broadcast)" in story: + signed_fname = split_story[1] + signed_txn_fname = split_story[-2] + cc_tx_id = split_story[-1].split("\n")[-1] + txn_fpath = microsd_path(signed_txn_fname) + with open(txn_fpath, "r") as f: + signed_txn = f.read().strip() + garbage_collector.append(txn_fpath) + else: + signed_fname = split_story[-1] + + fpath = microsd_path(signed_fname) + with open(fpath, "r") as f: + signed_psbt = f.read().strip() + + garbage_collector.append(fpath) + if cc_first: + for signer in bitcoind_signers: + signed_psbt = signer.walletprocesspsbt(signed_psbt, True, "DEFAULT", True)["psbt"] + res = tapscript_wo.finalizepsbt(signed_psbt, True) + assert res['complete'] + tx_hex = res["hex"] + res = bitcoind.supply_wallet.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + if cc_tx_id: + assert tx_hex == signed_txn + assert txn_id == cc_tx_id + assert len(txn_id) == 64 + + +@pytest.mark.parametrize("num_leafs", [1, 2, 5, 8]) +@pytest.mark.parametrize("internal_key_spendable", [True, False]) +def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bitcoind, + internal_key_spendable, dev, microsd_path, get_cc_key, + pick_menu_item, cap_story, goto_home, cap_menu, load_export, + import_miniscript, bitcoin_core_signer, import_duplicate, + press_select, garbage_collector): + use_regtest() + clear_miniscript() + microsd_wipe() + tmplt = TREE[num_leafs] + bitcoind_signers_xpubs = [] + bitcoind_signers = [] + for i in range(num_leafs): + s, core_key = bitcoin_core_signer(f"bitcoind--signer{i}") + bitcoind_signers.append(s) + bitcoind_signers_xpubs.append(core_key) + + bitcoin_signer_leafs = [f"pk({k})" for k in bitcoind_signers_xpubs] + + cc_key = get_cc_key("86h/0h/100h") + cc_leaf = f"pk({cc_key})" + + if internal_key_spendable: + desc = f"tr({cc_key},{tmplt % (*bitcoin_signer_leafs,)})" + else: + internal_key = bitcoind_signers_xpubs[0] + leafs = bitcoin_signer_leafs[1:] + [cc_leaf] + random.shuffle(leafs) + desc = f"tr({internal_key},{tmplt % (*leafs,)})" + + ts = bitcoind.create_wallet( + wallet_name=f"watch_only_pk_ts", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + + fname = "ts_pk.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc + "\n") + + garbage_collector.append(fpath) + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Taproot internal key" in story + assert "Tapscript" in story + assert "Press (1) to see extended public keys" in story + assert "P2TR" in story + + press_select() + import_duplicate(fname) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + menu = cap_menu() + pick_menu_item(menu[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # import descriptors to watch only wallet + res = ts.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = ts.getnewaddress("", "bech32m") + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = ts.getnewaddress("", "bech32m") # selfspend + psbt = ts.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "ts_pk.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbt) + + garbage_collector.append(fpath) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + + garbage_collector.append(fpath_psbt) + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = ts.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = ts.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + +@pytest.mark.parametrize("desc", [ + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})#tpm3afjn", + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)}})", + "tr(tpubD6NzVbkrYhZ4XB7hZjurMYsPsgNY32QYGZ8YFVU7cy1VBRNoYpKAVuUfqfUFss6BooXRrCeYAdK9av2yFnqWXZaUMJuZdpE9Kuh6gubCVHu/<0;1>/*,{sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)}})", + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)})", + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},or_d(pk([0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*),and_v(v:pkh([30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),older(500)))})", + "tr(unspend(b320077905d0954b01a8a328ea08c0ac3b4b066d1240f47a1b2c58651dcda4eb)/<0;1>/*,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},or_d(pk([0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*),and_v(v:pkh([30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),older(500)))})", +]) +def test_tapscript_import_export(clear_miniscript, pick_menu_item, cap_story, + import_miniscript, load_export, desc, microsd_path, + press_select): + clear_miniscript() + fname = "imdesc.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + _, story = import_miniscript(fname) + press_select() # approve miniscript import + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Export") + time.sleep(.1) + title, story = cap_story() + assert "(<0;1> notation) press OK" in story + press_select() + contents = load_export("sd", label="Miniscript", is_json=False, addr_fmt=AF_P2TR, + sig_check=False) + descriptor = contents.strip() + assert desc.split("#")[0].replace("<0;1>/*", "0/*").replace("'", "h") == descriptor.split("#")[0].replace("<0;1>/*", "0/*").replace("'", "h") + + +def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, bitcoind, dev, + goto_home, pick_menu_item, microsd_path, import_miniscript, + cap_story, load_export, get_cc_key, garbage_collector, + bitcoin_core_signer, import_duplicate, press_select): + # works in core - but some discussions are ongoing + # https://github.com/bitcoin/bitcoin/issues/27104 + # CC also allows this for now... (experimental branch) + use_regtest() + clear_miniscript() + microsd_wipe() + ss, core_key = bitcoin_core_signer(f"dup_leafs") + + cc_key = get_cc_key("86h/0h/100h") + cc_leaf = f"pk({cc_key})" + + tmplt = TREE[2] + tmplt = tmplt % (cc_leaf, cc_leaf) + desc = f"tr({core_key},{tmplt})" + fname = "dup_leafs.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Taproot internal key" in story + assert "Tapscript" in story + assert "Press (1) to see extended public keys" in story + assert "P2TR" in story + + press_select() + import_duplicate(fname) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # wo wallet + ts = bitcoind.create_wallet( + wallet_name=f"dup_leafs_wo", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + # import descriptors to watch only wallet + res = ts.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = ts.getnewaddress("", "bech32m") + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = ts.getnewaddress("", "bech32m") # selfspend + psbt = ts.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "ts_pk.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = ts.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = ts.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + +def test_same_key_account_based_minisc(goto_home, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind, + import_miniscript, use_regtest, import_duplicate, + press_select, garbage_collector): + clear_miniscript() + use_regtest() + + desc = ("wsh(" + "or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*)," + "and_v(" + "v:pkh([0f056943/84'/1'/9']tpubDC7jGaaSE66QBAcX8TUD3JKWari1zmGH4gNyKZcrfq6NwCofKujNF2kyeVXgKshotxw5Yib8UxLrmmCmWd8NVPVTAL8rGfMdc7TsAKqsy6y/<0;1>/*)," + "older(5))))#qmwvph5c") + + name = "mini-accounts" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Press (1) to see extended public keys" in story + + press_select() + import_duplicate(fname) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # wo wallet + wo = bitcoind.create_wallet( + wallet_name=f"multi-account", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + # import descriptors to watch only wallet + res = wo.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = wo.getnewaddress("", "bech32") + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = wo.getnewaddress("", "bech32") # selfspend + psbt = wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "multi-acct.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + + _psbt = BasicPSBT().parse(final_psbt.encode()) + assert len(_psbt.inputs[0].part_sigs) == 2 + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + +CHANGE_BASED_DESCS = [ + ( + "wsh(" + "or_d(" + "pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*)," + "and_v(" + "v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*)," + "older(5)" + ")" + ")" + ")#aq0kpuae" + ), + ( + "wsh(or_i(" + "and_v(" + "v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*)," + "older(10)" + ")," + "or_d(" + "multi(" + "3," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*" + ")," + "and_v(" + "v:thresh(" + "2," + "pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*)," + "a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*)," + "a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)" + ")," + "older(5)" + ")" + ")" + "))#a4nfkskx" + ), + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})#z5x7409w", + "tr([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<66;67>/*,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})#qqcy9jlr", +] + +@pytest.mark.parametrize("desc", CHANGE_BASED_DESCS) +def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind, + import_miniscript, address_explorer_check, use_regtest, + desc, press_select, garbage_collector): + clear_miniscript() + use_regtest() + if desc.startswith("tr("): + af = "bech32m" + else: + af = "bech32" + + name = "mini-change" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Press (1) to see extended public keys" in story + + press_select() + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # wo wallet + wo = bitcoind.create_wallet( + wallet_name=f"minsc-change", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + # import descriptors to watch only wallet + res = wo.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = wo.getnewaddress("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = wo.getnewaddress("", af) # selfspend + psbt = wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "msc-change-conso.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr_0 = bitcoind.supply_wallet.getnewaddress() + dest_addr_1 = bitcoind.supply_wallet.getnewaddress() + dest_addr_2 = bitcoind.supply_wallet.getnewaddress() + psbt = wo.walletcreatefundedpsbt( + [], + [{dest_addr_0: 1.0}, {dest_addr_1: 2.56}, {dest_addr_2: 12.99}], + 0, {"fee_rate": 2} + )["psbt"] + fname = "msc-change-send.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + # check addresses + address_explorer_check("sd", af, wo, "mini-change") + + +def test_same_key_account_based_multisig(goto_home, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind, + import_miniscript, garbage_collector): + clear_miniscript() + desc = ("wsh(sortedmulti(2," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," + "[0f056943/84'/1'/9']tpubDC7jGaaSE66QBAcX8TUD3JKWari1zmGH4gNyKZcrfq6NwCofKujNF2kyeVXgKshotxw5Yib8UxLrmmCmWd8NVPVTAL8rGfMdc7TsAKqsy6y/<0;1>/*" + "))") + name = "multi-accounts" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + _, story = import_miniscript(fname) + assert "Failed to import" in story + assert "Use Settings -> Multisig Wallets" in story + + +@pytest.mark.parametrize("desc", [ + "wsh(or_d(pk(@A),and_v(v:pkh(@A),older(5))))", + "tr(%s,multi_a(2,@A,@A))" % H, + "tr(%s,{sortedmulti_a(2,@A,@A),pk(@A)})" % H, + "tr(%s,or_d(pk(@A),and_v(v:pkh(@A),older(5))))" % H, +]) +def test_insane_miniscript(get_cc_key, pick_menu_item, cap_story, + microsd_path, desc, import_miniscript, + garbage_collector): + + cc_key = get_cc_key("84h/0h/0h") + desc = desc.replace("@A", cc_key) + fname = "insane.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + _, story = import_miniscript(fname) + assert "Failed to import" in story + assert "Insane" in story + +def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, + microsd_path, import_miniscript, garbage_collector): + leaf_num = 9 + scripts = [] + for i in range(leaf_num): + k = get_cc_key(f"84h/0h/{i}h") + scripts.append(f"pk({k})") + + tree = TREE[leaf_num] % tuple(scripts) + desc = f"tr({H},{tree})" + fname = "9leafs.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + _, story = import_miniscript(fname) + assert "Failed to import" in story + assert "num_leafs > 8" in story + +@pytest.mark.bitcoind +# @pytest.mark.parametrize("lt_type", ["older", "after"]) +@pytest.mark.parametrize("same_acct", [True, False]) +@pytest.mark.parametrize("recovery", [True, False]) +@pytest.mark.parametrize("leaf2_mine", [True, False]) +@pytest.mark.parametrize("internal_type", ["unspend(", "xpub", "static"]) +@pytest.mark.parametrize("minisc", [ + "or_d(pk(@A),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:pk(@B),locktime(N)))", + + "or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:multi_a(2,@B,@C),locktime(N)))", +]) +def test_minitapscript(leaf2_mine, recovery, minisc, clear_miniscript, goto_home, + pick_menu_item, cap_menu, cap_story, microsd_path, internal_type, + use_regtest, bitcoind, microsd_wipe, load_export, dev, + address_explorer_check, get_cc_key, import_miniscript, + bitcoin_core_signer, same_acct, import_duplicate, press_select, + garbage_collector): + lt_type = "older" + # needs bitcoind 26.0 + normal_cosign_core = False + recovery_cosign_core = False + if "multi_a(" in minisc.split("),", 1)[0]: + normal_cosign_core = True + if "multi_a(" in minisc.split("),", 1)[-1]: + recovery_cosign_core = True + + if lt_type == "older": + sequence = 5 + locktime = 0 + # 101 blocks are mined by default + to_replace = "older(5)" + else: + sequence = None + locktime = 105 + to_replace = "after(105)" + + minisc = minisc.replace("locktime(N)", to_replace) + + core_keys = [] + signers = [] + for i in range(3): + # core signers + signer, core_key = bitcoin_core_signer(f"co-signer{i}") + core_keys.append(core_key) + signers.append(signer) + + # cc device key + if same_acct: + cc_key = get_cc_key("86h/1h/0h", subderiv="/<4;5>/*") + cc_key1 = get_cc_key("86h/1h/0h", subderiv="/<6;7>/*") + else: + cc_key = get_cc_key("86h/1h/0h") + cc_key1 = get_cc_key("86h/1h/1h") + + if recovery: + # recevoery path is always B + minisc = minisc.replace("@B", cc_key) + minisc = minisc.replace("@A", core_keys[0]) + else: + minisc = minisc.replace("@A", cc_key) + minisc = minisc.replace("@B", core_keys[0]) + + if "@C" in minisc: + minisc = minisc.replace("@C", core_keys[1]) + + ik = H + if internal_type == "unspend(": + ik = f"unspend({os.urandom(32).hex()})/<2;3>/*" + elif internal_type == "xpub": + ik = ranged_unspendable_internal_key(os.urandom(32)) + + if leaf2_mine: + desc = f"tr({ik},{{{minisc},pk({cc_key1})}})" + else: + desc = f"tr({ik},{{pk({core_keys[2]}),{minisc}}})" + + use_regtest() + clear_miniscript() + name = "minitapscript" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname) + menu = cap_menu() + assert menu[0] == name + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + addr = wo.getnewaddress("", "bech32m") + addr_dest = wo.getnewaddress("", "bech32m") # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + if recovery and sequence and not leaf2_mine: + inp["sequence"] = sequence + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: all_of_it - 1}], + locktime if (recovery and not leaf2_mine) else 0, + {"fee_rate": 20, "change_type": "bech32m", "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if (normal_cosign_core or recovery_cosign_core) and not leaf2_mine: + psbt = signers[1].walletprocesspsbt(psbt, True, "ALL")["psbt"] + + name = f"{name}.psbt" + fpath = microsd_path(name) + with open(fpath, "w") as f: + f.write(psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath) + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + if recovery and not leaf2_mine: + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' if sequence else "non-final" + bitcoind.supply_wallet.generatetoaddress(6, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + else: + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", "bech32m", wo, "minitapscript") + +@pytest.mark.parametrize("desc", [ + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})", + "wsh(sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*))", + "sh(wsh(or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:multi_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),older(500)))))", +]) +def test_multi_mixin(desc, clear_miniscript, microsd_path, pick_menu_item, + cap_story, import_miniscript, garbage_collector): + clear_miniscript() + fname = "imdesc.txt" + fpath = microsd_path(fname) + with open(microsd_path(fname), "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + title, story = import_miniscript(fname) + assert "Failed to import" in story + assert "multi mixin" in story + + +def test_timelock_mixin(): + pass + + +@pytest.mark.parametrize("addr_fmt", ["bech32", "bech32m"]) +@pytest.mark.parametrize("cc_first", [True, False]) +def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, cap_story, cap_menu, + load_export, microsd_path, use_regtest, clear_miniscript, cc_first, + address_explorer_check, import_miniscript, bitcoin_core_signer, press_select, + garbage_collector): + + # check D wrapper u property for segwit v0 and v1 + # https://github.com/bitcoin/bitcoin/pull/24906/files + minsc = "thresh(3,c:pk_k(@A),sc:pk_k(@B),sc:pk_k(@C),sdv:older(5))" + + core_keys = [] + signers = [] + for i in range(2): + # core signers + signer, core_key = bitcoin_core_signer(f"co-signer{i}") + core_keys.append(core_key) + signers.append(signer) + + cc_key = get_cc_key(f"{84 if addr_fmt == 'bech32' else 86}h/1h/0h") + + minsc = minsc.replace("@A", cc_key) + minsc = minsc.replace("@B", core_keys[0]) + minsc = minsc.replace("@C", core_keys[1]) + + if addr_fmt == "bech32": + desc = f"wsh({minsc})" + else: + desc = f"tr({H},{minsc})" + + name = "d_wrapper" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + clear_miniscript() + use_regtest() + _, story = import_miniscript(fname) + if addr_fmt == "bech32": + assert "Failed to import" in story + assert "thresh: X3 should be du" in story + return + + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + menu = cap_menu() + assert menu[0] == name + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + addr = wo.getnewaddress("", addr_fmt) # self-spend + addr_dest = wo.getnewaddress("", addr_fmt) # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + inp["sequence"] = 5 + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: all_of_it - 1}], + 0, + {"fee_rate": 20, "change_type": addr_fmt}, + ) + psbt = psbt_resp.get("psbt") + + if not cc_first: + to_sign_psbt_o = signers[0].walletprocesspsbt(psbt, True) + to_sign_psbt = to_sign_psbt_o["psbt"] + assert to_sign_psbt != psbt + else: + to_sign_psbt = psbt + + name = f"{name}.psbt" + fpath = microsd_path(name) + with open(fpath, "w") as f: + f.write(to_sign_psbt) + garbage_collector.append(fpath) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + assert final_psbt != to_sign_psbt + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + + if cc_first: + done_o = signers[0].walletprocesspsbt(final_psbt, True) + done = done_o["psbt"] + else: + done = final_psbt + + res = wo.finalizepsbt(done) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + bitcoind.supply_wallet.generatetoaddress(6, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", addr_fmt, wo, "d_wrapper") + + +def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, + clear_miniscript, goto_home, cap_menu, pick_menu_item, + import_miniscript, microsd_path, press_select, garbage_collector): + clear_miniscript() + use_regtest() + + x = "wsh(or_d(pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),and_v(v:pkh([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),older(100))))" + z = "wsh(or_d(pk([0f056943/48'/0'/0'/3']xpub6FQgdFZAHcAeDMVe9KxWoLMxziCjscCExzuKJhRSjM71CA9dUDZEGNgPe4S2SsRumCBXeaTBZ5nKz2cMDiK4UEbGkFXNipHLkm46inpjE9D/0/*),and_v(v:pkh([0f056943/48'/0'/0'/2']xpub6FQgdFZAHcAeAhQX2VvQ42CW2fDdKDhgwzhzXuUhWb4yfArmaZXkLbGS9W1UcgHwNxVESCS1b8BK8tgNYEF8cgmc9zkmsE45QSEvbwdp6Kr/0/*),older(100))))" + y = f"tr({H},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" + + fname_btc = "BTC.txt" + fname_xtn = "XTN.txt" + fname_xtn0 = "XTN0.txt" + + for desc, fname in [(x, fname_xtn), (z, fname_btc), (y, fname_xtn0)]: + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + # cannot import XPUBS when testnet/regtest enabled + _, story = import_miniscript(fname_btc) + assert "Failed to import" in story + assert "wrong chain" in story + + import_miniscript(fname_xtn) + press_select() + # assert that wallets created at XRT always store XTN anywas (key_chain) + res = settings_get("miniscript") + assert len(res) == 1 + assert res[0][1] == "XTN" + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert fname_xtn.split(".")[0] in m[0] + goto_home() + settings_set("chain", "BTC") + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + # asterisk hints that some wallets are already stored + # but not on current active chain + assert "(none setup yet)*" in m + import_miniscript(fname_btc) + press_select() + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + assert fname_btc.split(".")[0] in m[0] + for mi in m: + assert fname_xtn.split(".")[0] not in mi + + _, story = import_miniscript(fname_xtn) + assert "Failed to import" in story + assert "wrong chain" in story + + settings_set("chain", "XTN") + import_miniscript(fname_xtn0) + press_select() + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert fname_xtn.split(".")[0] in m[0] + assert fname_xtn0.split(".")[0] in m[1] + for mi in m: + assert fname_btc not in mi + + +@pytest.mark.parametrize("taproot_ikspendable", [ + (True, False), (True, True), (False, False) +]) +@pytest.mark.parametrize("minisc", [ + "or_d(pk(@A),and_v(v:pkh(@B),after(100)))", + "or_d(multi(2,@A,@C),and_v(v:pkh(@B),after(100)))", +]) +def test_import_same_policy_same_keys_diff_order(taproot_ikspendable, minisc, + clear_miniscript, use_regtest, + get_cc_key, bitcoin_core_signer, + offer_minsc_import, cap_menu, + bitcoind, pick_menu_item, + press_select): + use_regtest() + clear_miniscript() + taproot, ik_spendable = taproot_ikspendable + if taproot: + minisc = minisc.replace("multi(", "multi_a(") + if ik_spendable: + ik = get_cc_key("84h/1h/100h", subderiv="/0/*") + desc = f"tr({ik},{minisc})" + else: + desc = f"tr({H},{minisc})" + else: + desc = f"wsh({minisc})" + + cc_key0 = get_cc_key("84h/1h/0h", subderiv="/0/*") + signer0, core_key0 = bitcoin_core_signer("s00") + # recevoery path is always B + desc0 = desc.replace("@A", cc_key0) + desc0 = desc0.replace("@B", core_key0) + + if "@C" in desc: + signer1, core_key1 = bitcoin_core_signer("s11") + desc0 = desc0.replace("@C", core_key1) + + # now just change order of the keys (A,B), but same keys same policy + desc1 = desc.replace("@B", cc_key0) + desc1 = desc1.replace("@A", core_key0) + + if "@C" in desc: + desc1 = desc1.replace("@C", core_key1) + + # checksum required if via USB + desc_info = bitcoind.supply_wallet.getdescriptorinfo(desc0) + desc0 = desc_info["descriptor"] # with checksum + desc_info = bitcoind.supply_wallet.getdescriptorinfo(desc1) + desc1 = desc_info["descriptor"] # with checksum + + title, story = offer_minsc_import(desc0) + assert "Create new miniscript wallet?" in story + press_select() + time.sleep(.2) + title, story = offer_minsc_import(desc1) + assert "Create new miniscript wallet?" in story + press_select() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 2 + + +@pytest.mark.parametrize("cs", [True, False]) +@pytest.mark.parametrize("way", ["usb", "nfc", "sd", "vdisk"]) +def test_import_miniscript_usb_json(use_regtest, cs, way, cap_menu, + clear_miniscript, pick_menu_item, + get_cc_key, bitcoin_core_signer, + offer_minsc_import, bitcoind, microsd_path, + virtdisk_path, import_miniscript, goto_home, + press_select): + name = "my_minisc" + minsc = f"tr({H},or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),after(100))))" + use_regtest() + clear_miniscript() + + cc_key = get_cc_key("84h/1h/0h", subderiv="/0/*") + signer0, core_key0 = bitcoin_core_signer("s00") + # recevoery path is always B + desc = minsc.replace("@A", cc_key) + desc = desc.replace("@B", core_key0) + + signer1, core_key1 = bitcoin_core_signer("s11") + desc = desc.replace("@C", core_key1) + + if cs: + desc_info = bitcoind.supply_wallet.getdescriptorinfo(desc) + desc = desc_info["descriptor"] # with checksum + + val = json.dumps({"name": name, "desc": desc}) + + nfc_data = None + fname = "diff_name.txt" # will be ignored as name in the json has preference + if way == "usb": + title, story = offer_minsc_import(val) + else: + if way == "nfc": + nfc_data = val + else: + if way == "sd": + fpath = microsd_path(fname) + else: + fpath = virtdisk_path(fname) + + with open(fpath, "w") as f: + f.write(val) + + title, story = import_miniscript(fname, way, nfc_data) + + assert "Create new miniscript wallet?" in story + assert name in story + press_select() + time.sleep(.2) + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 1 + assert m[0] == name + + +@pytest.mark.parametrize("config", [ + # all dummy data there to satisfy badlen check in usb.py + # missing 'desc' key + {"name": "my_miniscript", "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # name longer than 40 chars + {"name": "a" * 41, "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # name too short + {"name": "a", "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # desc key empty + {"name": "ab", "desc": "", "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # name type + {"name": None, "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # desc type + {"name": "ab", "desc": None, "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, +]) +def test_json_import_failures(config, offer_minsc_import): + with pytest.raises(Exception): + offer_minsc_import(json.dumps(config)) + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("is_json", [True, False]) +def test_unique_name(clear_miniscript, use_regtest, offer_minsc_import, + pick_menu_item, cap_menu, way, goto_home, + microsd_path, virtdisk_path, is_json, + import_miniscript, press_select): + clear_miniscript() + use_regtest() + + name = "my_name" + x = "wsh(or_d(pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),and_v(v:pkh([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),older(100))))" + y = f"tr({H},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" + + xd = json.dumps({"name": name, "desc": x}) + title, story = offer_minsc_import(xd) + assert "Create new miniscript wallet?" in story + assert name in story + press_select() + time.sleep(.2) + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 1 + assert m[0] == name + + # completely different wallet but with the same name (USB) + yd = json.dumps({"name": name, "desc": y}) + title, story = offer_minsc_import(yd) + assert title == "FAILED" + assert "MUST have unique names" in story + press_select() + # nothing imported + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 1 + assert m[0] == name + + goto_home() + fname = f"{name}.txt" + nfc_data = None + if way == "nfc": + if not is_json: + pytest.xfail("impossible") + + nfc_data = yd + else: + if way == "sd": + fpath = microsd_path(fname) + elif way == "vdisk": + fpath = virtdisk_path(fname) + else: + assert False + + with open(fpath, "w") as f: + f.write(yd if is_json else y) + + title, story = import_miniscript(fname=fname, way=way, data=nfc_data) + assert "FAILED" == title + assert "MUST have unique names" in story + + +@pytest.mark.qrcode +def test_usb_workflow(usb_miniscript_get, usb_miniscript_ls, clear_miniscript, + usb_miniscript_addr, usb_miniscript_delete, use_regtest, + reset_seed_words, offer_minsc_import, need_keypress, + cap_story, cap_screen_qr, press_select): + use_regtest() + reset_seed_words() + clear_miniscript() + assert [] == usb_miniscript_ls() + for i, desc in enumerate(CHANGE_BASED_DESCS): + _, story = offer_minsc_import(json.dumps({"name": f"w{i}", "desc": desc})) + assert "Create new miniscript wallet?" in story + press_select() + time.sleep(.2) + + msc_wallets = usb_miniscript_ls() + assert len(msc_wallets) == 4 + assert sorted(msc_wallets) == ["w0", "w1", "w2", "w3"] + + # try to get/delete nonexistent wallet + with pytest.raises(Exception) as err: + usb_miniscript_get("w4") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + with pytest.raises(Exception) as err: + usb_miniscript_delete("w4") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + for i, w in enumerate(msc_wallets): + assert usb_miniscript_get(w)["desc"].split("#")[0] == CHANGE_BASED_DESCS[i].split("#")[0].replace("'", 'h') + + #check random address + addr = usb_miniscript_addr("w0", 55, False) + time.sleep(0.1) + need_keypress('4') + time.sleep(0.1) + qr = cap_screen_qr().decode('ascii') + assert qr == addr.upper() + + usb_miniscript_delete("w3") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w3'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 3 + with pytest.raises(Exception) as err: + usb_miniscript_get("w3") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + usb_miniscript_delete("w2") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w2'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 2 + with pytest.raises(Exception) as err: + usb_miniscript_get("w2") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + usb_miniscript_delete("w1") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w1'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 1 + with pytest.raises(Exception) as err: + usb_miniscript_get("w1") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + usb_miniscript_delete("w0") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w0'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 0 + with pytest.raises(Exception) as err: + usb_miniscript_get("w0") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + +def test_miniscript_name_validation(microsd_path, offer_minsc_import): + for tc in ["weê", "eee\teee"]: + with pytest.raises(Exception) as e: + offer_minsc_import(json.dumps({"name": tc, "desc": CHANGE_BASED_DESCS[0]})) + assert "must be ascii" in e.value.args[0] + + +def test_bug_fill_policy(set_seed_words, goto_home, pick_menu_item, need_keypress, + microsd_path, cap_story, press_select, clear_miniscript, + cap_menu, bitcoind, start_sign, end_sign): + clear_miniscript() + mnemonic = "normal useless alpha sphere grid defense feed era farm law hair region" + set_seed_words(mnemonic) + + desc = """tr(tpubD6NzVbkrYhZ4Xjg1aU3fkQSj6yp8d7XNpnpVvjUBqDzMJt7J6QafSCBF5RLY2wwi +Vuhu79MKCKbUxjCxvicdATdc7hMPEejgCkQy3B28MiP/<0;1>/*,{and_v(v:multi_a(1, +[61cd4eb6/48'/1'/0'/2']tpubDE4RRPsyHN6GUsic4hrniYUhTsQ7h1bRQYyDPcWDFjKZ +vhms2nUNwo2j4oRwtuDZNJwwXzeoZ22RjGrueJ3zgAbbSTEM8kZQ8EnyDE79sGK/<2;3>/* +,[c658b283/48'/1'/0'/2']tpubDFL5wzgPBYK5pZ2Kh1T8qrxnp43kjE5CXfguZHHBrZS +WpkfASy5rVfj7prh11XdqkC1P3kRwUPBeX7AHN8XBNx8UwiprnFnEm5jyswiRD4p/<2;3>/ +*),older(65535)),multi_a(2,[c658b283/48'/1'/0'/2']tpubDFL5wzgPBYK5pZ2Kh +1T8qrxnp43kjE5CXfguZHHBrZSWpkfASy5rVfj7prh11XdqkC1P3kRwUPBeX7AHN8XBNx8U +wiprnFnEm5jyswiRD4p/<0;1>/*,[61cd4eb6/48'/1'/0'/2']tpubDE4RRPsyHN6GUsic +4hrniYUhTsQ7h1bRQYyDPcWDFjKZvhms2nUNwo2j4oRwtuDZNJwwXzeoZ22RjGrueJ3zgAb +bSTEM8kZQ8EnyDE79sGK/<0;1>/*,[25f48f59/48'/1'/0'/2']tpubDFRnTG8pxuoQ67w +aXsh1vNLD9c88JcRwEFxKCUsXzR11RkuV4pqFU6ccCZdwnjGY4yw25uCRHh4wCKNquvfgQ3 +zUvcND8MhRQFv8dCFzjNu/<0;1>/*)})#vh0vvyyn""" + + psbt = """cHNidP8BAIkCAAAAAeqLNNQht+6fI8FkMNHKGAvQGxbT13MnWFy4E+bjjLgCAQAAAAD9/// +/AqCGAQAAAAAAIlEgucVAj4RPepF0/SyzmhPtCRuKI9xAQd2ScMQhRo9QxS5DCAMAAAAAAC +JRIAS4JaU4120D1sK/uwi3pX/d44riN1ZL7/8gihqjovNiAAAAAAABASs2kgQAAAAAACJRI +HWNphYJKzPZvktvz5R8JcN2jyq3X037IdsYEIDkyJk7QhXB5HKqDFDM67yjCq7Se80ncwja +RKN9sUObTyvZmbUObbeSJsFccViS0oZLC6gQ/8Qmufbj1s4NQa3LIWyvMivI3mkgM/TcQjN +Yw24uBt3x3dPWB1zB6JE2XXpQ1SZxj8o/A42sIHQi+N5Ks8V63jBweYeXAHfYdbbK8i8g+K +nAk87zPU+4uiBcgNyWXXed03Q77nXydquU/r3OGKaNmfgKZEaReol/GbpSnMBCFcHkcqoMU +MzrvKMKrtJ7zSdzCNpEo32xQ5tPK9mZtQ5tt+YJ/0OcHr4oEr0kYvDKBTQQmmLvIQOcvrLs +WIK71wAuTCDt0of0dokHgcFnysYqBSMq0n/q8BXbdtc6FN45FDFJ5qwg2jHHFnREqivJDEd +6OP6MVGPTh+VKFGcVw5069IYoHu26UZ0D//8AssAhFjP03EIzWMNuLgbd8d3T1gdcweiRNl +16UNUmcY/KPwONPQHmCf9DnB6+KBK9JGLwygU0EJpi7yEDnL6y7FiCu9cALsZYsoMwAACAA +QAAgAAAAIACAACAAQAAAAEAAAAhFlyA3JZdd53TdDvudfJ2q5T+vc4Ypo2Z+ApkRpF6iX8Z +PQHmCf9DnB6+KBK9JGLwygU0EJpi7yEDnL6y7FiCu9cALiX0j1kwAACAAQAAgAAAAIACAAC +AAQAAAAEAAAAhFnQi+N5Ks8V63jBweYeXAHfYdbbK8i8g+KnAk87zPU+4PQHmCf9DnB6+KB +K9JGLwygU0EJpi7yEDnL6y7FiCu9cALmHNTrYwAACAAQAAgAAAAIACAACAAQAAAAEAAAAhF +toxxxZ0RKoryQxHejj+jFRj04flShRnFcOdOvSGKB7tPQGSJsFccViS0oZLC6gQ/8Qmufbj +1s4NQa3LIWyvMivI3sZYsoMwAACAAQAAgAAAAIACAACAAwAAAAEAAAAhFuRyqgxQzOu8owq +u0nvNJ3MI2kSjfbFDm08r2Zm1Dm23DQB8Rh5dAQAAAAEAAAAhFu3Sh/R2iQeBwWfKxioFIy +rSf+rwFdt21zoU3jkUMUnmPQGSJsFccViS0oZLC6gQ/8Qmufbj1s4NQa3LIWyvMivI3mHNT +rYwAACAAQAAgAAAAIACAACAAwAAAAEAAAABFyDkcqoMUMzrvKMKrtJ7zSdzCNpEo32xQ5tP +K9mZtQ5ttwEYIM5NkFnDQB89FHqGhszz+s+W7dqU367i55HGAojV3UIeAAABBSBbkkOJTQO +GaVlOrV3dhuuoJ+mExi5yco1KgXreMLenRAEGuQHAaCDmAYkOelpDlG83jdRpTPCCRnycqv +57ZqHfHdVKmDEPN6wgPuunxNxW0oPW2ZejdP8jfaaB5k+tCfWK2OFY0b4qVJe6IG5O5Uawc +tSgkNBrJ/pX/Fxfg33+67rTirW8sUmhiiNQulKcAcBLIJAD9nceZ+8HESN1pKN/mC4PD+52 +KlrvkLEbnlY90unxrCAh9E3FtPjeBG5Rt8tFIVn2mCgcsefMY+oLB85YQYNX3LpRnQP//wC +yIQch9E3FtPjeBG5Rt8tFIVn2mCgcsefMY+oLB85YQYNX3D0B7rC0ojXeM3TXglbOnszIeY +YXUZmryJkcTjQlleT5XnPGWLKDMAAAgAEAAIAAAACAAgAAgAMAAAADAAAAIQc+66fE3FbSg +9bZl6N0/yN9poHmT60J9YrY4VjRvipUlz0BY9TGKw5dxhZn81aA+bduIqWCMpBW2K5F0Fux +fY4ofjdhzU62MAAAgAEAAIAAAACAAgAAgAEAAAADAAAAIQdbkkOJTQOGaVlOrV3dhuuoJ+m +Exi5yco1KgXreMLenRA0AfEYeXQEAAAADAAAAIQduTuVGsHLUoJDQayf6V/xcX4N9/uu604 +q1vLFJoYojUD0BY9TGKw5dxhZn81aA+bduIqWCMpBW2K5F0FuxfY4ofjcl9I9ZMAAAgAEAA +IAAAACAAgAAgAEAAAADAAAAIQeQA/Z3HmfvBxEjdaSjf5guDw/udipa75CxG55WPdLp8T0B +7rC0ojXeM3TXglbOnszIeYYXUZmryJkcTjQlleT5XnNhzU62MAAAgAEAAIAAAACAAgAAgAM +AAAADAAAAIQfmAYkOelpDlG83jdRpTPCCRnycqv57ZqHfHdVKmDEPNz0BY9TGKw5dxhZn81 +aA+bduIqWCMpBW2K5F0FuxfY4ofjfGWLKDMAAAgAEAAIAAAACAAgAAgAEAAAADAAAAAA==""" + + desc_fname = "minib.txt" + with open(microsd_path(desc_fname), "w") as f: + f.write(desc) + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import") + need_keypress("1") + pick_menu_item(desc_fname) + time.sleep(.1) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + assert "minib" in story # name + press_select() + + goto_home() + start_sign(base64.b64decode(psbt)) + signed = end_sign(accept=True) + assert signed != base64.b64decode(psbt) + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("tmplt", [ + "wsh(or_d(multi(2,@0/<0;1>/*,@1/<0;1>/*),and_v(v:thresh(2,pkh(@0/<2;3>/*),a:pkh(@1/<2;3>/*),a:pkh(@2/<0;1>/*)),older(10))))", + # below is same as above with just first two keys swapped in thresh + "wsh(or_d(multi(2,@0/<0;1>/*,@1/<0;1>/*),and_v(v:thresh(2,pkh(@1/<2;3>/*),a:pkh(@0/<2;3>/*),a:pkh(@2/<0;1>/*)),older(10))))", + "tr(unspend()/<0;1>/*,{and_v(v:multi_a(2,@0/<2;3>/*,@1/<2;3>/*,@2/<0;1>/*,@3/<0;1>/*),older(10)),multi_a(2,@0/<0;1>/*,@1/<0;1>/*)})", + # below is same as above with just first two keys swapped in last multi_a + "tr(unspend()/<0;1>/*,{and_v(v:multi_a(2,@0/<2;3>/*,@1/<2;3>/*,@2/<0;1>/*,@3/<0;1>/*),older(10)),multi_a(2,@1/<0;1>/*,@0/<0;1>/*)})", + # internal key is ours + "tr(@0/<0;1>/*,{and_v(v:multi_a(2,@0/<2;3>/*,@1/<2;3>/*,@2/<2;3>/*,@3/<0;1>/*),older(10)),multi_a(2,@1/<0;1>/*,@2/<0;1>/*)})", +]) +def test_expanding_multisig(tmplt, clear_miniscript, goto_home, pick_menu_item, garbage_collector, + cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, + load_export, dev, address_explorer_check, get_cc_key, import_miniscript, + bitcoin_core_signer, import_duplicate, press_select, start_sign, end_sign): + use_regtest() + clear_miniscript() + sequence = 10 + af = "bech32m" if tmplt.startswith("tr(") else "bech32" + unspend = "tpubD6NzVbkrYhZ4WbzhCs1gLUM8s8LAwTh68xVh1a3nRQyA3tbAJFSE2FEaH2CEGJTKmzcBagpyG35Kjv3UGpTEWbc7qSCX6mswrLQVVPgXECd" + tmplt = tmplt.replace("unspend()", unspend) + + csigner0, ckey0 = bitcoin_core_signer(f"co-signer-0") + ckey0 = ckey0.replace("/0/*", "") + csigner0.keypoolrefill(20) + csigner1, ckey1 = bitcoin_core_signer(f"co-signer-1") + ckey1 = ckey1.replace("/0/*", "") + csigner1.keypoolrefill(20) + csigner2, ckey2 = None, None + + # cc device key + cc_key = get_cc_key("86h/1h/0h").replace('/<0;1>/*', "") + + # fill policy + desc = tmplt.replace("@0", cc_key) + desc = desc.replace("@1", ckey0) + desc = desc.replace("@2", ckey1) + + if "@3" in tmplt: + csigner2, ckey2 = bitcoin_core_signer(f"co-signer-2") + ckey2 = ckey2.replace("/0/*", "") + csigner2.keypoolrefill(20) + desc = desc.replace("@3", ckey2) + + wname = "expand_msc" + fname = f"{wname}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + + wo = bitcoind.create_wallet(wallet_name=wname, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + menu = cap_menu() + assert menu[0] == wname + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + # fund wallet + addr = wo.getnewaddress("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + # use non-recovery path to split into 5 utxos + 1 going back to supply (not a conso) + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + dest_addrs = [wo.getnewaddress(f"a{i}", af) for i in range(5)] + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{a: 5} for a in dest_addrs] + [{bitcoind.supply_wallet.getnewaddress(): 5}], + 0, + {"fee_rate": 20, "change_type": af}, + ) + psbt = psbt_resp.get("psbt") + + # if we have internal key we just spend with it, singlesig on chain + have_internal = "tr(@0," in tmplt + + if not have_internal: + # first sign with cosigner in gucci path (non-recovery) + psbt = csigner0.walletprocesspsbt(psbt, True)["psbt"] + + # now CC + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + final_psbt = end_sign(True) + + # client software finalization + res = wo.finalizepsbt(base64.b64encode(final_psbt).decode()) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + unspent = wo.listunspent() + assert len(unspent) == 6 # created 5 txos of 5 btc, one to supply & change back is 6th utxo + + # consolidation - consolidate 3 utxo into one bigger + to_spend = [{"txid": o["txid"], "vout": o["vout"]} for o in unspent if float(o["amount"]) == 5.0][:3] + psbt_resp = wo.walletcreatefundedpsbt( + to_spend, + [{wo.getnewaddress("conso", af): 15}], + 0, + {"fee_rate": 20, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + # now CC signing first + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + updated_psbt = end_sign(True) + updated_psbt = base64.b64encode(updated_psbt).decode() + + if not have_internal: + # now cosigner (still on non-recovery path) + final_psbt = csigner0.walletprocesspsbt(updated_psbt, True, + "DEFAULT"if "tr(" == tmplt[:3] else "ALL")["psbt"] + + # client software finalization + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + unspent = wo.listunspent() + assert len(unspent) == 4 + + # now we lost our non-recovey path cosigner + del csigner0 + # use recovery key to consolidate all our outputs and send them to other wallet + dest = bitcoind.supply_wallet.getnewaddress() + all_of_it = wo.getbalance() + # need to bump sequence here + psbt_resp = wo.walletcreatefundedpsbt( + [ {"txid": o["txid"], "vout": o["vout"], "sequence": sequence} for o in unspent], + [{dest: all_of_it}], + 0, + {"fee_rate": 10, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + # now cosigner (on recovery path) + psbt = csigner1.walletprocesspsbt(psbt, True)["psbt"] + + if have_internal: + final_psbt = csigner2.walletprocesspsbt(psbt, True)["psbt"] + else: + # CC + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + final_psbt = end_sign(True) + final_psbt = base64.b64encode(final_psbt).decode() + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + # timelocked + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + + # mines some blocks to release the lock + bitcoind.supply_wallet.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) + + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + assert len(wo.listunspent()) == 0 + + # check addresses + address_explorer_check("sd", af, wo, wname) + + +def test_big_boy(use_regtest, clear_miniscript, bitcoin_core_signer, get_cc_key, microsd_path, + garbage_collector, pick_menu_item, bitcoind, import_miniscript, press_select, + cap_story, cap_menu, load_export, start_sign, end_sign): + # keys (@0,@4,@5) are more important (primary) than keys (@1,@2,@3) (secondary) + # currently requires to tweak MAX_TR_SIGNERS = 33 + tmplt = ( + "tr(" + "tpubD6NzVbkrYhZ4XgXS51CV3bhoP5dJeQqPhEyhKPDXBgEs64VdSyAfku99gtDXQzY6HEXY5Dqdw8Qud1fYiyewDmYjKe9gGJeDx7x936ur4Ju/<0;1>/*," # unspendable + "{{{and_v(v:multi_a(3,@5/<8;9>/*,@1/<8;9>/*,@2/<8;9>/*,@3/<8;9>/*),older(1000))," # after 1000 blocks one of primary keys can sign with 2 secondary + "and_v(v:multi_a(3,@0/<8;9>/*,@1/<10;11>/*,@2/<10;11>/*,@3/<10;11>/*),older(1000))}," # after 1000 blocks one of primary keys can sign with 2 secondary + "{{and_v(v:multi_a(5,@4/<2;3>/*,@5/<2;3>/*,@0/<2;3>/*,@1/<2;3>/*,@2/<2;3>/*,@3/<2;3>/*),older(20))," # 5of6 after 20 blocks + "and_v(v:multi_a(4,@4/<4;5>/*,@5/<4;5>/*,@0/<4;5>/*,@1/<4;5>/*,@2/<4;5>/*,@3/<4;5>/*),older(60))}," # 4of6 after 60 blocks + "{and_v(v:multi_a(2,@4/<6;7>/*,@5/<6;7>/*,@0/<6;7>/*),older(120))," # after 120 blocks it is enough to have 2 of (@0,@4,@5) + "and_v(v:multi_a(3,@4/<8;9>/*,@1/<6;7>/*,@2/<6;7>/*,@3/<6;7>/*),older(1000))}}}," # after 1000 blocks one of primary keys can sign with 2 secondary + "multi_a(6,@1/<0;1>/*,@2/<0;1>/*,@3/<0;1>/*,@4/<0;1>/*,@5/<0;1>/*,@0/<0;1>/*)})" # 6of6 primary path + ) + + use_regtest() + clear_miniscript() + af = "bech32m" + + cc_key = get_cc_key("86h/1h/0h").replace('/<0;1>/*', "") + desc = tmplt.replace("@0", cc_key) + + cosigners = [] + for i in range(1, 6): + csigner, ckey = bitcoin_core_signer(f"co-signer-{i}") + ckey = ckey.replace("/0/*", "") + csigner.keypoolrefill(20) + cosigners.append(csigner) + desc = desc.replace(f"@{i}", ckey) + + wname = "bigboy" + fname = f"{wname}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + + wo = bitcoind.create_wallet(wallet_name=wname, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + menu = cap_menu() + assert menu[0] == wname + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + # fund wallet + addr = wo.getnewaddress("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + # split to 10 utxos + dest_addrs = [wo.getnewaddress(f"a{i}", af) for i in range(10)] + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{a: 4} for a in dest_addrs] + [{bitcoind.supply_wallet.getnewaddress(): 5}], + 0, + {"fee_rate": 3, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + # sign with all cosigners + for s in cosigners: + psbt = s.walletprocesspsbt(psbt, True)["psbt"] + + # now CC + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + final_psbt = end_sign(True) + final_psbt = base64.b64encode(final_psbt).decode() + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + unspent = wo.listunspent() + assert len(unspent) == 11 + + +@pytest.mark.parametrize("af", ["bech32", "bech32m"]) +def test_single_key_miniscript(af, settings_set, clear_miniscript, goto_home, get_cc_key, + garbage_collector, microsd_path, bitcoind, import_miniscript, + press_select, cap_menu, pick_menu_item, load_export, cap_story, + start_sign, end_sign): + sequence = 10 + goto_home() + clear_miniscript() + settings_set("chain", "XRT") + policy = "and_v(v:pk(@0/<0;1>/*),older(10))" + + if af == "bech32m": + tmplt = f"tr(tpubD6NzVbkrYhZ4XgXS51CV3bhoP5dJeQqPhEyhKPDXBgEs64VdSyAfku99gtDXQzY6HEXY5Dqdw8Qud1fYiyewDmYjKe9gGJeDx7x936ur4Ju/<0;1>/*,{policy})" + else: + tmplt = f"wsh({policy})" + + cc_key = get_cc_key("m/99h/0h/0h").replace('/<0;1>/*', '') + tmplt = tmplt.replace("@0", cc_key) + + wname = "single_key_mini" + fname = f"{wname}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(tmplt) + + garbage_collector.append(fpath) + + wo = bitcoind.create_wallet(wallet_name=wname, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + menu = cap_menu() + assert menu[0] == wname + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + # fund wallet + addr = wo.getnewaddress("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + unspent = wo.listunspent() + assert len(unspent) == 1 + + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"], "sequence": sequence} + # split to 10 utxos + dest_addrs = [wo.getnewaddress(f"a{i}", af) for i in range(10)] + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{a: 4} for a in dest_addrs] + [{bitcoind.supply_wallet.getnewaddress(): 5}], + 0, + {"fee_rate": 3, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + final_psbt = end_sign(True) + final_psbt = base64.b64encode(final_psbt).decode() + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + # timelocked + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + + # mines some blocks to release the lock + bitcoind.supply_wallet.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) + + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + unspent = wo.listunspent() + assert len(unspent) == 11 + + # now consolidate to one output + psbt_resp = wo.walletcreatefundedpsbt( + [{"txid": o["txid"], "vout": o["vout"], "sequence": sequence} for o in unspent], + [{wo.getnewaddress("", af): wo.getbalance()}], + 0, + {"fee_rate": 3, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + final_psbt = end_sign(True) + final_psbt = base64.b64encode(final_psbt).decode() + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + # timelocked + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + + # mines some blocks to release the lock + bitcoind.supply_wallet.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) + + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + unspent = wo.listunspent() + assert len(unspent) == 1 diff --git a/testing/test_multisig.py b/testing/test_multisig.py index fafa0fdd9..5a1f56cbc 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -6,9 +6,6 @@ # # py.test test_multisig.py -m ms_danger --ms-danger # -import sys -sys.path.append("../shared") -from descriptor import MultisigDescriptor, append_checksum, MULTI_FMT_TO_SCRIPT, parse_desc_str import time, pytest, os, random, json, shutil, pdb, io, base64, struct, bech32, itertools, re from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput from ckcc.protocol import CCProtocolPacker, MAX_TXN_LEN @@ -24,6 +21,7 @@ from io import BytesIO from hashlib import sha256 from bbqr import split_qrs +from descriptor import MULTI_FMT_TO_SCRIPT, MultisigDescriptor, parse_desc_str from charcodes import KEY_QR @@ -100,11 +98,11 @@ def make_multisig(dev, sim_execfile): # default is BIP-45: m/45'/... (but no co-signer idx) # - but can provide str format for deriviation, use {idx} for cosigner idx - def doit(M, N, unique=0, deriv=None, dev_key=False): + def doit(M, N, unique=0, deriv=None, dev_key=False, chain="XTN"): keys = [] for i in range(N-1): - pk = BIP32Node.from_master_secret(b'CSW is a fraud %d - %d' % (i, unique), 'XTN') + pk = BIP32Node.from_master_secret(b'CSW is a fraud %d - %d' % (i, unique), chain) xfp = unpack("I', xfp_bytes)[0]) else: - pk = BIP32Node.from_wallet_key(simulator_fixed_tprv) + pk = BIP32Node.from_wallet_key(simulator_fixed_tprv if chain == "XTN" else simulator_fixed_xprv) xfp = simulator_fixed_xfp if not deriv: @@ -258,7 +256,7 @@ def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, keys=None, do_import=True, derivs=None, descriptor=False, int_ext_desc=False, dev_key=False, way=None, bip67=True, - force_unsort_ms=True): + force_unsort_ms=True, chain="XTN"): # param: bip67 if false, only usable together with descriptor=True if not bip67: assert descriptor, "needs descriptor=True" @@ -267,7 +265,8 @@ def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, settings_set("unsort_ms", 1) keys = keys or make_multisig(M, N, unique=unique, dev_key=dev_key, - deriv=common or (derivs[0] if derivs else None)) + deriv=common or (derivs[0] if derivs else None), + chain=chain) name = name or f'test-{M}-{N}' if not do_import: @@ -391,6 +390,7 @@ def test_ms_import_variations(N, make_multisig, offer_ms_import, press_cancel, i # the different addr formats for af in unmap_addr_fmt.keys(): + if af == "p2tr": continue config = f'format: {af}\n' config += '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) title, story = offer_ms_import(config) @@ -497,7 +497,7 @@ def make_ms_address(M, keys, idx=0, is_change=0, addr_fmt=AF_P2SH, testnet=1, @pytest.fixture def test_ms_show_addr(dev, cap_story, press_select, addr_vs_path, bitcoind_p2sh, has_ms_checks, is_q1): - def doit(M, keys, addr_fmt=AF_P2SH, bip45=True, **make_redeem_args): + def doit(M, keys, addr_fmt=AF_P2SH, bip45=True, chain="XTN", **make_redeem_args): # test we are showing addresses correctly # - verifies against bitcoind as well addr_fmt = unmap_addr_fmt.get(addr_fmt, addr_fmt) @@ -530,7 +530,7 @@ def doit(M, keys, addr_fmt=AF_P2SH, bip45=True, **make_redeem_args): press_select() # check expected addr was generated based on my math - addr_vs_path(got_addr, addr_fmt=addr_fmt, script=scr) + addr_vs_path(got_addr, addr_fmt=addr_fmt, script=scr, chain=chain) # also check against bitcoind core_addr, core_scr = bitcoind_p2sh(M, pubkeys, addr_fmt) @@ -554,7 +554,7 @@ def test_import_ranges(m_of_n, use_regtest, addr_fmt, clear_ms, import_ms_wallet try: # test an address that should be in that wallet. time.sleep(.1) - test_ms_show_addr(M, keys, addr_fmt=addr_fmt) + test_ms_show_addr(M, keys, addr_fmt=addr_fmt, chain="XRT") finally: clear_ms() @@ -1051,8 +1051,8 @@ def has_name(name, num_wallets=1): menu = cap_menu() assert f'{M}/{N}: {name}' in menu - # depending if NFC enabled or not, and if Q (has QR) - assert (len(menu) - num_wallets) in [6, 7, 8] + # depending if NFC enabled or not, and if Q (has QR) or whether EDGE + assert (len(menu) - num_wallets) in [6, 7, 8, 9] title, story = offer_ms_import(make_named('xxx-orig')) assert 'Create new multisig wallet' in story @@ -1484,7 +1484,7 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev # IMPORTANT: wont work if you start simulator with --ms flag. Use no args - all_out_styles = list(unmap_addr_fmt.keys()) + all_out_styles = [af for af in unmap_addr_fmt.keys() if af != "p2tr"] num_outs = len(all_out_styles) clear_ms() @@ -2001,43 +2001,6 @@ def tweak(case, pk_num, data): assert len(story.split(':')[-1].strip()), story -@pytest.mark.parametrize('repeat', range(2) ) -def test_iss6743(repeat, set_seed_words, sim_execfile, try_sign): - # from SomberNight - psbt_b4 = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae3000008001000080000000800100008000000000030000000000') - # pre 3.2.0 result - psbt_wrong = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100a85d08eef6675803fe2b58dda11a553641080e07da36a2f3e116f1224201931b022071b0ba83ef920d49b520c37993c039d13ae508a1adbd47eb4b329713fcc8baef01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') - # psbt_right = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100ae90a7e4c350389816b03af0af46df59a2f53da04cc95a2abd81c0bbc5950c1d02202f9471d6b0664b7a46e81da62d149f688adc7ba2b3413372d26fa618a8460eba01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') - # changed with with introduction of signature grinding - psbt_right = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381947304402201008b084f53d3064ee381dfb3ff4373b29d6ae765b2af15a4e217e8d5d049c650220576af95d79b8fc686627da8a534141208b225ceb6085cd93fcaffb153ac016ea01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') - seed_words = 'all all all all all all all all all all all all' - expect_xfp = swab32(int('5c9e228d', 16)) - assert xfp2str(expect_xfp) == '5c9e228d'.upper() - - # load specific private key - xfp = set_seed_words(seed_words) - assert xfp == expect_xfp - - # check Coldcard derives expected Upub - derivation = "m/48h/1h/0h/1h" # part of devtest/unit_iss6743.py - expect_xpub = 'Upub5SJWbuhs5tM4mkJST69tnpGGaf8dDTqByx3BLSocWFpq5YLh1fky4DQTFGQVG6nCSqZfUiAAeStdxSQteUcfMsWjDkhniZx4GdwpB18Tnbq' - - pub = sim_execfile('devtest/unit_iss6743.py') - assert pub == expect_xpub - - # verify psbt globals section - tp = BasicPSBT().parse(psbt_b4) - (hdr_xpub, hdr_path), = [(v,k) for v,k in tp.xpubs if k[0:4] == pack('\n", "=> ").replace('1/0]\n =>', "1/0 =>") + story = story.replace("=>\n", "=> ").replace('1/0]\n =>', "1/0] =>") else: - story = story.replace("=>\n", "=> ").replace('0/0]\n =>', "0/0 =>") + story = story.replace("=>\n", "=> ").replace('0/0]\n =>', "0/0] =>") maps = [] for ln in story.split('\n'): @@ -2222,8 +2186,9 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, path,chk,addr = ln.split() assert chk == '=>' assert '/' in path + path = path.replace("[", "").replace("]", "") - maps.append( (path, addr) ) + maps.append((path, addr)) if start_idx <= 2147483638: assert len(maps) == 10 @@ -2238,6 +2203,7 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, path_mapper=path_mapper, bip67=bip67) assert int(subpath.split('/')[-1]) == idx + assert int(subpath.split('/')[-2]) == chng_idx #print('../0/%s => \n %s' % (idx, B2A(script))) start, end = detruncate_address(addr) @@ -2421,12 +2387,134 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke bitcoind_addrs = bitcoind.deriveaddresses(desc_export, addr_range) for idx, cc_item in enumerate(cc_addrs): cc_item = cc_item.split(",") - partial_address = cc_item[part_addr_index] - _start, _end = partial_address.split("___") + address = cc_item[part_addr_index] if way != "nfc": - _start, _end = _start[1:], _end[:-1] - assert bitcoind_addrs[idx].startswith(_start) - assert bitcoind_addrs[idx].endswith(_end) + address = address[1:-1] + assert bitcoind_addrs[idx] == address + + +@pytest.fixture +def bitcoind_multisig(bitcoind, bitcoind_d_sim_watch, need_keypress, cap_story, load_export, pick_menu_item, goto_home, + cap_menu, microsd_path, use_regtest, press_select): + def doit(M, N, script_type, cc_account=0, funded=True): + use_regtest() + bitcoind_signers = [ + bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + for i in range(N - 1) + ] + for signer in bitcoind_signers: + signer.keypoolrefill(10) + # watch only wallet where multisig descriptor will be imported + ms = bitcoind.create_wallet( + wallet_name=f"watch_only_{script_type}_{M}of{N}", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('Export XPUB') + time.sleep(0.5) + title, story = cap_story() + assert "extended public keys (XPUB) you would need to join a multisig wallet" in story + press_select() + need_keypress(str(cc_account)) # account + press_select() + xpub_obj = load_export("sd", label="Multisig XPUB", is_json=True, sig_check=False) + template = xpub_obj[script_type +"_desc"] + # get keys from bitcoind signers + bitcoind_signers_xpubs = [] + for signer in bitcoind_signers: + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for desc in bitcoind_descriptors: + if desc["desc"].startswith("pkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove pkh(....) + core_key = core_desc[4:-1] + bitcoind_signers_xpubs.append(core_key) + desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) + + if script_type == 'p2wsh': + name = f"core{M}of{N}_native.txt" + elif script_type == "p2sh_p2wsh": + name = f"core{M}of{N}_wrapped.txt" + else: + name = f"core{M}of{N}_legacy.txt" + with open(microsd_path(name), "w") as f: + f.write(desc + "\n") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import multisig wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + pick_menu_item(name) + _, story = cap_story() + assert "Create new multisig wallet?" in story + assert name.split(".")[0] in story + assert f"{M} of {N}" in story + if M == N: + assert f"All {N} co-signers must approve spends" in story + else: + assert f"{M} signatures, from {N} possible" in story + if script_type == "p2wsh": + assert "P2WSH" in story + elif script_type == "p2sh": + assert "P2SH" in story + else: + assert "P2SH-P2WSH" in story + assert "Derivation:\n Varies (2)" in story + press_select() # approve multisig import + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + menu = cap_menu() + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # import descriptors to watch only wallet + res = ms.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + if funded: + if script_type == "p2wsh": + addr_type = "bech32" + elif script_type == "p2tr": + addr_type = "bech32m" + elif script_type == "p2sh": + addr_type = "legacy" + else: + addr_type = "p2sh-segwit" + + addr = ms.getnewaddress("", addr_type) + if script_type == "p2wsh": + sw = "bcrt1q" + elif script_type == "p2tr": + sw = "bcrt1p" + else: + sw = "2" + assert addr.startswith(sw) + # get some coins and fund above multisig address + bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + return ms, bitcoind_signers + + return doit @pytest.mark.bitcoind @@ -2802,17 +2890,16 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre @pytest.mark.parametrize("desc", [ + # lack of checksum is now legal # ("Missing descriptor checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), ("Wrong checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl7"), ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), + ("All keys must be ranged", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#9h02aqg5"), + ("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), ("Key origin info is required", "wsh(sortedmulti(2,tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#ypuy22nw"), - ("Malformed key derivation info", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl6"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"), + ("xpub depth", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), + ("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"), ("Cannot use hardened sub derivation path", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0'/*))#3w6hpha3"), - # ("Unsupported descriptor", "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))#t2zpj2eu"), - ("Unsupported descriptor", "pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)#ml40v0wf"), ("M must be <= N", "wsh(sortedmulti(3,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#uueddtsy"), ]) def test_exotic_descriptors(desc, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, @@ -2894,7 +2981,7 @@ def test_ms_xpub_ordering(descriptor, m_n, clear_ms, make_multisig, import_ms_wa @pytest.mark.parametrize('cmn_pth_from_root', [True, False]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) -@pytest.mark.parametrize('M_N', [(3, 15), (2, 2), (3, 5), (15, 15)]) +@pytest.mark.parametrize('M_N', [(2, 3), (3, 5), (15, 15)]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) @pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH]) def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear_ms, make_multisig, @@ -2986,6 +3073,82 @@ def choose_multisig_wallet(): clear_ms() +def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, + clear_ms, goto_home, cap_menu, pick_menu_item, + need_keypress, import_ms_wallet): + clear_ms() + use_regtest() + + # cannot import XPUBS when testnet/regtest enabled + with pytest.raises(Exception): + import_ms_wallet(3, 3, addr_fmt="p2wsh", accept=1, descriptor=True, chain="BTC") + + import_ms_wallet(2, 2, addr_fmt="p2wsh", accept=1, descriptor=True, chain="XTN") + # assert that wallets created at XRT always store XTN anywas (key_chain) + res = settings_get("multisig") + assert len(res) == 1 + assert res[0][-1]["ch"] == "XTN" + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert "2/2:" in m[0] + goto_home() + settings_set("chain", "BTC") + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + # asterisk hints that some wallets are already stored + # but not on current active chain + assert "(none setup yet)*" in m + import_ms_wallet(3, 3, addr_fmt="p2wsh", accept=1, descriptor=True, chain="BTC") + goto_home() + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + assert "3/3:" in m[0] + for mi in m: + assert not mi.startswith("2/2:") + + goto_home() + settings_set("chain", "XTN") + import_ms_wallet(4, 4, addr_fmt="p2wsh", accept=1, descriptor=True, chain="XTN") + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert "2/2:" in m[0] + assert "4/4:" in m[1] + for mi in m: + assert not mi.startswith("3/3:") + + +@pytest.mark.parametrize("desc", [ + ("wsh(sortedmulti(2," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," + "[0f056943/84'/1'/9']tpubDC7jGaaSE66QBAcX8TUD3JKWari1zmGH4gNyKZcrfq6NwCofKujNF2kyeVXgKshotxw5Yib8UxLrmmCmWd8NVPVTAL8rGfMdc7TsAKqsy6y/<0;1>/*" + "))"), + ("wsh(sortedmulti(2," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*" + "))"), +]) +def test_same_key_account_based_multisig(goto_home, need_keypress, pick_menu_item, cap_story, + clear_ms, microsd_path, load_export, desc, + offer_ms_import): + clear_ms() + try: + _, story = offer_ms_import(desc) + except Exception as e: + assert "my key included more than once" in str(e) + + def test_multisig_name_validation(microsd_path, offer_ms_import): with open("data/multisig/export-p2wsh-myself.txt", "r") as f: config = f.read() diff --git a/testing/test_ownership.py b/testing/test_ownership.py index 181c9f576..d7bb05a5c 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -2,12 +2,12 @@ # # Address ownership tests. # -import pytest, time, io, csv +import pytest, time, io, csv, json from txn import fake_address from base58 import encode_base58_checksum -from helpers import hash160 +from helpers import hash160, taptweak from bip32 import BIP32Node -from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from constants import simulator_fixed_xprv, simulator_fixed_tprv, addr_fmt_names @pytest.fixture @@ -23,7 +23,7 @@ def doit(): [14, 8, 26, 1, 7, 19] ''' @pytest.mark.parametrize('addr_fmt', [ - AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH + AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) @pytest.mark.parametrize('testnet', [ False, True] ) def test_negative(addr_fmt, testnet, sim_exec): @@ -36,24 +36,26 @@ def test_negative(addr_fmt, testnet, sim_exec): assert 'Explained' in lst -@pytest.mark.parametrize('addr_fmt, testnet', [ - (AF_CLASSIC, True), - (AF_CLASSIC, False), - (AF_P2WPKH, True), - (AF_P2WPKH, False), - (AF_P2WPKH_P2SH, True), - (AF_P2WPKH_P2SH, False), +@pytest.mark.parametrize('addr_fmt, chain', [ + (AF_CLASSIC, "XTN"), + (AF_CLASSIC, "BTC"), + (AF_P2WPKH, "XTN"), + (AF_P2WPKH, "BTC"), + (AF_P2WPKH_P2SH, "XTN"), + (AF_P2WPKH_P2SH, "BTC"), + (AF_P2TR, "XTN"), + (AF_P2TR, "BTC"), # multisig - testnet only - (AF_P2WSH, True), - (AF_P2SH, True), - (AF_P2WSH_P2SH,True), + (AF_P2WSH, "XTN"), + (AF_P2SH, "XTN"), + (AF_P2WSH_P2SH, "XTN"), ]) @pytest.mark.parametrize('offset', [ 3, 760] ) @pytest.mark.parametrize('subaccount', [ 0, 34] ) @pytest.mark.parametrize('change_idx', [ 0, 1] ) @pytest.mark.parametrize('from_empty', [ True, False] ) -def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, +def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, sim_exec, wipe_cache, make_myself_wallet, use_testnet, goto_home, pick_menu_item, enter_number, press_cancel, settings_set, import_ms_wallet, clear_ms ): @@ -61,17 +63,23 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, # API/Unit test, limited UX - if not testnet and addr_fmt in { AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH }: - # multisig jigs assume testnet - raise pytest.skip('testnet only') + if chain == "BTC": + use_testnet(False) + testnet = False + if addr_fmt in { AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH }: + # multisig jigs assume testnet + raise pytest.skip('testnet only') + + coin_type = 0 + if chain == "XTN": + use_testnet(True) + coin_type = 1 + testnet = True - use_testnet(testnet) if from_empty: wipe_cache() # very different codepaths settings_set('accts', []) - coin_type = 1 if testnet else 0 - if addr_fmt in { AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH }: from test_multisig import make_ms_address, HARD M, N = 1, 3 @@ -99,6 +107,9 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, elif addr_fmt == AF_P2WPKH: menu_item = expect_name = 'Segwit P2WPKH' path = "m/84h/{ct}h/{acc}h" + elif addr_fmt == AF_P2TR: + menu_item = expect_name = 'Taproot P2TR' + path = "m/86h/{ct}h/{acc}h" else: raise ValueError(addr_fmt) @@ -108,14 +119,18 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, # see addr_vs_path mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) - sk = mk.subkey_for_path(path[2:].replace('h', "'")) + sk = mk.subkey_for_path(path) if addr_fmt == AF_CLASSIC: - addr = sk.address(netcode="XTN" if testnet else "BTC") + addr = sk.address(chain=chain) elif addr_fmt == AF_P2WPKH_P2SH: pkh = sk.hash160() digest = hash160(b'\x00\x14' + pkh) addr = encode_base58_checksum(bytes([196 if testnet else 5]) + digest) + elif addr_fmt == AF_P2TR: + from bech32 import encode + tweked_xonly = taptweak(sk.sec()[1:]) + addr = encode("tb" if testnet else "bc", 1, tweked_xonly) else: pkh = sk.hash160() addr = bech32_encode('tb' if testnet else 'bc', 0, pkh) @@ -166,7 +181,7 @@ def test_ux(valid, testnet, method, mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) path = "m/44h/{ct}h/{acc}h/0/3".format(acc=0, ct=(1 if testnet else 0)) sk = mk.subkey_for_path(path) - addr = sk.address(netcode="XTN" if testnet else "BTC") + addr = sk.address(chain="XTN" if testnet else "BTC") else: addr = fake_address(addr_fmt, testnet) @@ -220,20 +235,26 @@ def test_ux(valid, testnet, method, assert 'Searched ' in story assert 'candidates without finding a match' in story -@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "ms0"]) +@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "Taproot P2TR", "ms0", "msc0", "msc2"]) def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explorer, pick_menu_item, need_keypress, sim_exec, clear_ms, import_ms_wallet, press_select, goto_home, nfc_write, load_shared_mod, load_export_and_verify_signature, - cap_story): + cap_story, load_export, offer_minsc_import): goto_home() wipe_cache() settings_set('accts', []) if af == "ms0": clear_ms() - import_ms_wallet(2,3, name=af) + import_ms_wallet(2, 3, name=af) press_select() # accept ms import + elif "msc" in af: + from test_miniscript import CHANGE_BASED_DESCS + which = int(af[-1]) + title, story = offer_minsc_import(json.dumps({"name": af, "desc": CHANGE_BASED_DESCS[which]})) + assert "Create new miniscript wallet?" in story + press_select() # accept goto_address_explorer() pick_menu_item(af) @@ -245,17 +266,19 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo lst = eval(lst) assert lst - if af == "ms0": - return # multisig addresses are blanked - title, body = cap_story() - contents, sig_addr = load_export_and_verify_signature(body, "sd", label="Address summary") + if af in ("Taproot P2TR", "ms0", "msc0", "msc2"): + # p2tr - no signature file + contents = load_export("sd", label="Address summary", is_json=False, sig_check=False) + else: + contents, _ = load_export_and_verify_signature(body, "sd", label="Address summary") + addr_dump = io.StringIO(contents) cc = csv.reader(addr_dump) hdr = next(cc) - assert hdr == ['Index', 'Payment Address', 'Derivation'] addr = None - for n, (idx, addr, deriv) in enumerate(cc, start=0): + assert hdr[:2] == ['Index', 'Payment Address'] + for n, (idx, addr, *_) in enumerate(cc, start=0): assert int(idx) == n if idx == 200: addr = addr @@ -279,7 +302,7 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo assert addr in story assert title == 'Verified Address' assert 'Found in wallet' in story - assert 'Derivation path' in story + # assert 'Derivation path' in story if af == "P2SH-Segwit": assert "P2WPKH-in-P2SH" in story elif af == "Segwit P2WPKH": diff --git a/testing/test_paper.py b/testing/test_paper.py index 0dc2e0db0..18333be34 100644 --- a/testing/test_paper.py +++ b/testing/test_paper.py @@ -6,19 +6,19 @@ # This module can and should be run with `-l` and without it. # -import pytest, time, os, shutil, re, random +import pytest, time, os, shutil, re, random, json from binascii import a2b_hex from hashlib import sha256 from bip32 import PrivateKey from ckcc_protocol.constants import * -@pytest.mark.parametrize('mode', ["classic", 'segwit']) +@pytest.mark.parametrize('mode', ["classic", 'segwit', 'taproot']) @pytest.mark.parametrize('pdf', [False, True]) -@pytest.mark.parametrize('netcode', ["XTN", "BTC"]) +@pytest.mark.parametrize('netcode', ["XRT", "BTC", "XTN"]) def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, cap_story, need_keypress, microsd_path, verify_detached_signature_file, settings_set, - press_select): + press_select, validate_address, bitcoind): # test UX and operation of the 'bitcoin core' wallet export mx = "Don't make PDF" @@ -26,10 +26,7 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, goto_home() pick_menu_item('Advanced/Tools') - try: - pick_menu_item('Paper Wallets') - except: - raise pytest.skip('Feature absent') + pick_menu_item('Paper Wallets') time.sleep(0.1) title, story = cap_story() @@ -45,6 +42,11 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, pick_menu_item('Segwit P2WPKH') time.sleep(0.5) + if mode == 'taproot': + pick_menu_item('Classic P2PKH') + pick_menu_item('Taproot P2TR') + time.sleep(0.5) + if pdf: assert mx in cap_menu() shutil.copy('../docs/paperwallet.pdf', microsd_path('paperwallet.pdf')) @@ -58,7 +60,7 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, time.sleep(0.1) title, story = cap_story() - if "Press (1) to save paper wallet file to SD Card" in story: + if "Press (1)" in story: need_keypress("1") time.sleep(0.2) title, story = cap_story() @@ -68,20 +70,32 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, story = [i for i in story.split('\n') if i] sig_file = story[-1] if not pdf: - fname = story[-2] - fnames = [fname] + if mode == "taproot": + fname = story[-1] + else: + fname = story[-2] + fnames = [fname] else: - fname = story[-3] - pdf_name = story[-2] - fnames = [fname, pdf_name] + if mode == "taproot": + fname = story[-2] + pdf_name = story[-1] + else: + fname = story[-3] + pdf_name = story[-2] + fnames = [fname, pdf_name] assert pdf_name.endswith('.pdf') assert fname.endswith('.txt') - assert sig_file.endswith(".sig") - verify_detached_signature_file(fnames, sig_file, "sd", - addr_fmt=AF_CLASSIC if mode == "classic" else AF_P2WPKH) + if mode != 'taproot': + assert sig_file.endswith(".sig") + verify_detached_signature_file(fnames, sig_file, "sd", + addr_fmt=AF_CLASSIC if mode == "classic" else AF_P2WPKH) path = microsd_path(fname) + _wif = None + _sk = None + _addr = None + _idesc = None with open(path, 'rt') as fp: hdr = None for ln in fp: @@ -98,27 +112,46 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, val = ln.strip() if 'Deposit address' in hdr: assert val == fname.split('.', 1)[0].split('-', 1)[0] - txt_addr = val - addr = val + _addr = val elif hdr == 'Private key:': # for QR case - assert val == wif + assert val == _wif elif 'Private key' in hdr and 'WIF=Wallet' in hdr: - wif = val - k1 = PrivateKey.from_wif(val) + _wif = val elif 'Private key' in hdr and 'Hex, 32 bytes' in hdr: - k2 = PrivateKey(sec_exp=a2b_hex(val)) + _sk = val elif 'Bitcoin Core command': - assert wif in val - assert 'importmulti' in val or 'importprivkey' in val + assert _wif in val + if 'importdescriptors' in val: + _idesc = val + assert 'importprivkey' in val or 'importdescriptors' in val else: print(f'{hdr} => {val}') raise ValueError(hdr) - assert k1.K.sec() == k2.K.sec() - assert addr == k1.K.address(addr_fmt="p2wpkh" if mode == "segwit" else "p2pkh", - testnet=True if netcode == "XTN" else False) - - os.unlink(path) + if netcode != "XRT": + from bip32 import PrivateKey + k1 = PrivateKey.from_wif(_wif) + k2 = PrivateKey.parse(a2b_hex(_sk)) + assert k1 == k2 + validate_address(_addr, k1) + else: + if mode == "segwit": + assert _addr.startswith("bcrt1q") + elif mode == "taproot": + assert _addr.startswith("bcrt1p") + else: + assert _addr[0] in "mn" + + # bitcoind on regtest + conn = bitcoind.create_wallet(wallet_name="paper", disable_private_keys=False, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + desc_obj_s, desc_obj_e = _idesc.find("["), _idesc.find("]") + 1 + desc_obj = json.loads(_idesc[desc_obj_s:desc_obj_e]) + desc = desc_obj[0]["desc"] + res = conn.importdescriptors(desc_obj) + assert res[0]["success"] + assert _addr == conn.deriveaddresses(desc)[0] + bitcoind.delete_wallet_files() if not pdf: return @@ -126,8 +159,8 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, with open(path, 'rb') as fp: d = fp.read() - assert wif.encode('ascii') in d - assert txt_addr.encode('ascii') in d + assert _wif.encode('ascii') in d + assert _addr.encode('ascii') in d os.unlink(path) @@ -276,7 +309,7 @@ def test_dice_generate(rolls, testnet, dev, cap_menu, pick_menu_item, goto_home, val, = hx k2 = PrivateKey(sec_exp=a2b_hex(val)) - assert addr == k2.K.address(testnet=testnet, addr_fmt="p2pkh") + assert addr == k2.K.address(chain="XTN" if testnet else "BTC", addr_fmt="p2pkh") assert val == sha256(rolls.encode('ascii')).hexdigest() diff --git a/testing/test_sign.py b/testing/test_sign.py index 4043da146..45f1bd4bd 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -12,7 +12,7 @@ from decimal import Decimal from base64 import b64encode, b64decode from base58 import encode_base58_checksum -from helpers import B2A, U2SAT, prandom, fake_dest_addr, make_change_addr, parse_change_back +from helpers import B2A, fake_dest_addr, parse_change_back from helpers import xfp2str, seconds2human_readable, hash160 from msg import verify_message from bip32 import BIP32Node @@ -133,8 +133,8 @@ def test_psbt_proxy_parsing(fn, sim_execfile, sim_exec): assert oo == rb @pytest.mark.unfinalized -def test_speed_test(dev, fake_txn, is_mark3, is_mark4, start_sign, end_sign, - press_select): +@pytest.mark.parametrize("taproot", [True, False]) +def test_speed_test(dev, taproot, fake_txn, is_mark3, is_mark4, start_sign, end_sign, press_select): # measure time to sign a larger txn if is_mark4: # Mk4: expect @@ -149,7 +149,10 @@ def test_speed_test(dev, fake_txn, is_mark3, is_mark4, start_sign, end_sign, num_in = 9 num_out = 100 - psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=True) + if taproot: + psbt = fake_txn(num_in, num_out, dev.master_xpub, taproot_in=True) + else: + psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=True) open('debug/speed.psbt', 'wb').write(psbt) dt = time.time() @@ -191,8 +194,9 @@ def test_mega_txn(fake_txn, is_mark4, start_sign, end_sign, dev): @pytest.mark.bitcoind @pytest.mark.veryslow @pytest.mark.parametrize('segwit', [True, False]) -def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, - start_sign, end_sign, dev, segwit, accept = True): +@pytest.mark.parametrize('taproot', [True, False]) +def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, is_mark3, is_mark4, + start_sign, end_sign, dev, segwit, taproot, accept=True): # try a bunch of different bigger sized txns # - important to test on real device, due to it's limited memory @@ -209,7 +213,8 @@ def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, num_in = 250 num_out = 2000 - psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=segwit, outstyles=ADDR_STYLES) + psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=segwit, + taproot_in=taproot, outstyles=ADDR_STYLES) open('debug/last.psbt', 'wb').write(psbt) @@ -262,11 +267,13 @@ def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, @pytest.mark.bitcoind @pytest.mark.parametrize('num_ins', [ 2, 7, 15 ]) @pytest.mark.parametrize('segwit', [True, False]) -def test_real_signing(fake_txn, use_regtest, try_sign, dev, num_ins, segwit, decode_with_bitcoind): +@pytest.mark.parametrize('taproot', [True, False]) +def test_real_signing(fake_txn, use_regtest, try_sign, dev, num_ins, segwit,taproot, + decode_with_bitcoind): # create a TXN using actual addresses that are correct for DUT xp = dev.master_xpub - psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit) + psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit, taproot_in=taproot) open('debug/real-%d.psbt' % num_ins, 'wb').write(psbt) _, txn = try_sign(psbt, accept=True, finalize=True) @@ -908,16 +915,17 @@ def test_network_fee_unlimited(fake_txn, start_sign, end_sign, dev, settings_set @pytest.mark.parametrize('num_outs', [ 2, 7, 15 ]) @pytest.mark.parametrize('act_outs', [ 2, 1, -1]) @pytest.mark.parametrize('segwit', [True, False]) +@pytest.mark.parametrize('taproot', [True, False]) @pytest.mark.parametrize('add_xpub', [True, False]) @pytest.mark.parametrize('out_style', ADDR_STYLES_SINGLE) @pytest.mark.parametrize('visualized', [0, STXN_VISUALIZE, STXN_VISUALIZE|STXN_SIGNED]) def test_change_outs(fake_txn, start_sign, end_sign, cap_story, dev, num_outs, master_xpub, - act_outs, segwit, out_style, visualized, add_xpub, num_ins=3): + act_outs, segwit, taproot, out_style, visualized, add_xpub, num_ins=3): # create a TXN which has change outputs, which shouldn't be shown to user, and also not fail. xp = dev.master_xpub couts = num_outs if act_outs == -1 else num_ins-act_outs - psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, + psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, taproot_in=taproot, outstyles=[out_style], change_outputs=range(couts), add_xpub=add_xpub) open('debug/change.psbt', 'wb').write(psbt) @@ -1083,8 +1091,8 @@ def test_finalization_vs_bitcoind(match_key, use_regtest, check_against_bitcoind ("45'/1'/0'/1/5", 'diff path prefix'), ("44'/2'/0'/1/5", 'diff path prefix'), ("44'/1'/1'/1/5", 'diff path prefix'), - ("44'/1'/0'/3000/5", '2nd last component'), - ("44'/1'/0'/3/5", '2nd last component'), + # ("44'/1'/0'/3000/5", '2nd last component'), + # ("44'/1'/0'/3/5", '2nd last component'), ]) def test_change_troublesome(dev, start_sign, cap_story, try_path, expect): # NOTE: out#1 is change: @@ -1209,8 +1217,10 @@ def doit(): @pytest.mark.parametrize('num_utxo', [9, 100]) @pytest.mark.parametrize('segwit_in', [False, True]) -def test_bip143_attack_data_capture(num_utxo, segwit_in, try_sign, fake_txn, settings_set, - settings_get, cap_story, sim_exec, hist_count): +@pytest.mark.parametrize('taproot_in', [False, True]) +def test_bip143_attack_data_capture(num_utxo, segwit_in, taproot_in, try_sign, fake_txn, + settings_set, settings_get, cap_story, sim_exec, + hist_count): # cleanup prev runs, if very first time thru sim_exec('import history; history.OutptValueCache.clear()') @@ -1219,12 +1229,13 @@ def test_bip143_attack_data_capture(num_utxo, segwit_in, try_sign, fake_txn, set # make a txn, capture the outputs of that as inputs for another txn psbt = fake_txn(1, num_utxo+3, segwit_in=segwit_in, change_outputs=range(num_utxo+2), - outstyles=(['p2wpkh']*num_utxo) + ['p2wpkh-p2sh', 'p2pkh']) + taproot_in=taproot_in, + outstyles=(['p2wpkh']*num_utxo) + ['p2wpkh-p2sh', 'p2pkh']) _, txn = try_sign(psbt, accept=True, finalize=True) open('debug/funding.psbt', 'wb').write(psbt) - num_inp_utxo = (1 if segwit_in else 0) + num_inp_utxo = (1 if (segwit_in or taproot_in) else 0) time.sleep(.1) title, story = cap_story() @@ -1268,12 +1279,15 @@ def value_tweak(spendables): @pytest.mark.parametrize('segwit', [False, True]) +@pytest.mark.parametrize('taproot', [False, True]) @pytest.mark.parametrize('num_ins', [1, 17]) -def test_txid_calc(num_ins, fake_txn, try_sign, dev, segwit, decode_with_bitcoind, cap_story): +@pytest.mark.parametrize('num_outs', [1, 17]) +def test_txid_calc(num_ins, fake_txn, try_sign, dev, segwit, decode_with_bitcoind, + cap_story, taproot, num_outs): # verify correct txid for transactions is being calculated xp = dev.master_xpub - psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit) + psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, taproot_in=taproot) _, txn = try_sign(psbt, accept=True, finalize=True) @@ -1320,7 +1334,7 @@ def hack(psbt): pp = psbt.inputs[0].bip32_paths[pk] psbt.inputs[0].bip32_paths[pk] = b'what' + pp[4:] - psbt = fake_txn(2, num_outs, xp, segwit_in=True, psbt_hacker=hack) + psbt = fake_txn(3, num_outs, xp, segwit_in=True, taproot_in=True, psbt_hacker=hack) _, txn, txid = try_sign_microsd(psbt, finalize=not partial, encoding=encoding, del_after=del_after) @@ -1352,7 +1366,8 @@ def hack(psbt): txn = end_sign(True, finalize=False) @pytest.mark.parametrize('segwit', [False, True]) -def test_fully_unsigned(fake_txn, try_sign, segwit): +@pytest.mark.parametrize('taproot', [False, True]) +def test_fully_unsigned(fake_txn, try_sign, segwit, taproot): # A PSBT which is unsigned but all inputs lack keypaths @@ -1360,8 +1375,9 @@ def hack(psbt): # change all inputs to be "not ours" ... but with utxo details for i in psbt.inputs: i.bip32_paths.clear() + i.taproot_bip32_paths.clear() - psbt = fake_txn(7, 2, segwit_in=segwit, psbt_hacker=hack) + psbt = fake_txn(7, 2, segwit_in=segwit, taproot_in=taproot, psbt_hacker=hack) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(psbt, accept=True) @@ -1369,7 +1385,8 @@ def hack(psbt): assert 'does not contain any key path information' in str(ee) @pytest.mark.parametrize('segwit', [False, True]) -def test_wrong_xfp(fake_txn, try_sign, segwit): +@pytest.mark.parametrize('taproot', [False, True]) +def test_wrong_xfp(fake_txn, try_sign, segwit, taproot): # A PSBT which is unsigned and doesn't involve our XFP value @@ -1380,8 +1397,10 @@ def hack(psbt): for i in psbt.inputs: for pubkey in i.bip32_paths: i.bip32_paths[pubkey] = wrong_xfp + i.bip32_paths[pubkey][4:] + for xonly_pubkey in i.taproot_bip32_paths: + i.taproot_bip32_paths[xonly_pubkey] = b"\x00" + wrong_xfp + i.taproot_bip32_paths[xonly_pubkey][5:] - psbt = fake_txn(7, 2, segwit_in=segwit, psbt_hacker=hack) + psbt = fake_txn(7, 2, segwit_in=segwit, taproot_in=taproot, psbt_hacker=hack) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(psbt, accept=True) @@ -1390,7 +1409,8 @@ def hack(psbt): assert 'found 12345678' in str(ee) @pytest.mark.parametrize('segwit', [False, True]) -def test_wrong_xfp_multi(fake_txn, try_sign, segwit): +@pytest.mark.parametrize('taproot', [False, True]) +def test_wrong_xfp_multi(fake_txn, try_sign, segwit, taproot): # A PSBT which is unsigned and doesn't involve our XFP value # - but multiple wrong XFP values @@ -1404,8 +1424,12 @@ def hack(psbt): here = struct.pack('