Key expression export

This commit is contained in:
scgbckbone 2025-11-12 17:43:30 +01:00 committed by doc-hex
parent f235a83cf3
commit 192f2d2dda
7 changed files with 207 additions and 15 deletions

View File

@ -4,7 +4,8 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk4 and Q
- tbd
- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
Navigate to `Advanced/Tools -> Export Wallet -> Key Expression`
# Mk4 Specific Changes

View File

@ -14,7 +14,7 @@ from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted
from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words
from export import export_contents, make_summary_file, make_descriptor_wallet_export
from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export
from export import generate_unchained_export, generate_electrum_wallet
from export import generate_unchained_export, generate_electrum_wallet, make_key_expression_export
from files import CardSlot, CardMissingError, needs_microsd
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
from glob import settings
@ -1193,6 +1193,46 @@ async def ss_descriptor_skeleton(_0, _1, item):
]
the_ux.push(MenuSystem(rv))
async def key_expression_skeleton_step2(_1, _2, item):
# pick a semi-random file name, render and save it.
orig_path = item.arg
await make_key_expression_export(orig_path)
async def key_expression_skeleton(_0, _1, item):
# Export key expression -> [xfp/d/e/r]xpub
acct_num = 0
ch = await ux_show_story("This saves a extended key expression."
+ PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
if ch == '1':
acct_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
elif ch != 'y':
return
todo = [
("Segwit P2WPKH", "m/84h/%dh/%dh"),
("Classic P2PKH", "m/44h/%dh/%dh"),
("P2SH-Segwit", "m/49h/%dh/%dh"),
("Multi P2WSH", "m/48h/%dh/%dh/2h"),
("Multi P2SH-P2WSH", "m/48h/%dh/%dh/1h"),
]
from address_explorer import KeypathMenu
async def doit(*a):
return KeypathMenu(ranged=False, done_fn=make_key_expression_export)
ct = chains.current_chain().b44_cointype
rv = [
MenuItem(label, f=key_expression_skeleton_step2, arg=orig_der % (ct, acct_num))
for label, orig_der in todo
]
rv += [MenuItem("Custom Path", menu=doit)]
the_ux.push(MenuSystem(rv))
async def samourai_post_mix_descriptor_export(*a):
name = "POST-MIX"
post_mix_acct_num = 2147483646

View File

@ -30,8 +30,10 @@ def censor_address(addr):
return addr[0:12] + '___' + addr[12+3:]
class KeypathMenu(MenuSystem):
def __init__(self, path=None, nl=0):
def __init__(self, path=None, nl=0, ranged=True, done_fn=None):
self.prefix = None
self.done_fn = done_fn
self.ranged = ranged
if path is None:
# Top level menu; useful shortcuts, and special case just "m"
@ -40,10 +42,13 @@ class KeypathMenu(MenuSystem):
MenuItem("m/44h/⋯", f=self.deeper),
MenuItem("m/49h/⋯", f=self.deeper),
MenuItem("m/84h/⋯", f=self.deeper),
MenuItem("m/0/{idx}", menu=self.done),
MenuItem("m/{idx}", menu=self.done),
MenuItem("m", f=self.done),
]
if self.ranged:
items += [
MenuItem("m/0/{idx}", menu=self.done),
MenuItem("m/{idx}", menu=self.done),
]
else:
# drill down one layer: (nl) is the current leaf
# - hardened choice first
@ -53,11 +58,14 @@ class KeypathMenu(MenuSystem):
MenuItem(p+"/⋯", menu=self.deeper),
MenuItem(p+"h", menu=self.done),
MenuItem(p, menu=self.done),
MenuItem(p+"h/0/{idx}", menu=self.done),
MenuItem(p+"/0/{idx}", menu=self.done), #useful shortcut?
MenuItem(p+"h/{idx}", menu=self.done),
MenuItem(p+"/{idx}", menu=self.done),
]
if self.ranged:
items += [
MenuItem(p + "h/0/{idx}", menu=self.done),
MenuItem(p + "/0/{idx}", menu=self.done), # useful shortcut?
MenuItem(p + "h/{idx}", menu=self.done),
MenuItem(p + "/{idx}", menu=self.done),
]
# simple consistent truncation when needed
max_wide = max(len(mi.label) for mi in items)
@ -95,9 +103,12 @@ class KeypathMenu(MenuSystem):
if isinstance(top, KeypathMenu):
the_ux.pop()
continue
assert isinstance(top, AddressListMenu)
# assert isinstance(top, AddressListMenu), type(top)
break
if self.done_fn:
return await self.done_fn(final_path)
return PickAddrFmtMenu(final_path, top)
async def deeper(self, _1, _2, item):
@ -105,7 +116,7 @@ class KeypathMenu(MenuSystem):
assert val.endswith('/⋯')
cpath = val[:-2]
nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True)
return KeypathMenu(cpath, nl)
return KeypathMenu(cpath, nl, ranged=self.ranged, done_fn=self.done_fn)
class PickAddrFmtMenu(MenuSystem):
def __init__(self, path, parent):

View File

@ -471,7 +471,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
dis.fullscreen('Generating...')
chain = chains.current_chain()
xfp = settings.get('xfp')
xfp = settings.get('xfp', 0)
dis.progress_bar_show(0.1)
if mode is None:
mode = chains.af_to_bip44_purpose(addr_type)
@ -503,5 +503,18 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
await export_contents("Descriptor", body, fname_pattern, derive + "/0/0",
addr_type, force_prompt=True, direct_way=direct_way)
async def make_key_expression_export(orig_der, fname_pattern="key_expr.txt"):
xfp = xfp2str(settings.get('xfp', 0)).lower()
with stash.SensitiveValues() as sv:
ek = chains.current_chain().serialize_public(sv.derive_path(orig_der))
body = "[%s/%s]%s" % (xfp, orig_der.replace("m/", ""), ek)
await export_contents("Key Expression", body, fname_pattern,
None, None, force_prompt=True)
# EOF

View File

@ -235,6 +235,7 @@ WalletExportMenu = [
MenuItem("Descriptor", f=ss_descriptor_skeleton),
MenuItem("Generic JSON", f=generic_skeleton),
MenuItem("Export XPUB", menu=XpubExportMenu),
MenuItem("Key Expression", f=key_expression_skeleton),
MenuItem("Dump Summary", predicate=has_secrets, f=dump_summary),
]

View File

@ -2568,8 +2568,8 @@ def skip_if_useless_way(is_q1, nfc_disabled, vdisk_disabled):
# when NFC is disabled, no point trying to do a PSBT via NFC
# - important: run_sim_tests.py will enable NFC for complete testing
# - similarly: the Mk4 and earlier had no QR scanner, so cannot use that as input
def doit(way):
if way == "qr" and not is_q1:
def doit(way, allow_mk4_qr=False):
if way == "qr" and (not is_q1 and not allow_mk4_qr):
raise pytest.skip("mk4 QR not supported")
elif way == 'nfc' and nfc_disabled():
# runner will test these cases, but fail faster otherwise

View File

@ -13,7 +13,6 @@ from bip32 import BIP32Node
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
from ckcc_protocol.constants import AF_CLASSIC, AF_P2WPKH
from pprint import pprint
from charcodes import KEY_NFC, KEY_QR
@ -888,4 +887,131 @@ def test_samourai_vs_generic(chain, account, settings_set, pick_menu_item, goto_
file_desc = load_export("sd", label="Descriptor", is_json=False, addr_fmt=AF_P2WPKH)
assert file_desc.strip() == file_desc_generic.strip()
@pytest.mark.parametrize("chain", ["BTC", "XTN"])
@pytest.mark.parametrize("way", ["sd", "vdisk", "nfc", "qr"])
@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2WSH, AF_P2WSH_P2SH])
@pytest.mark.parametrize("acct_num", [None, (2 ** 31) - 1])
def test_key_expression_export(chain, addr_fmt, acct_num, goto_home, settings_set, need_keypress,
pick_menu_item, way, cap_story, cap_menu, virtdisk_path, dev,
load_export, press_select, skip_if_useless_way):
skip_if_useless_way(way, allow_mk4_qr=True)
settings_set('chain', chain)
chain_num = 1 if chain in ["XTN", "XRT"] else 0
goto_home()
pick_menu_item("Advanced/Tools")
pick_menu_item("Export Wallet")
pick_menu_item("Key Expression")
time.sleep(.1)
_, story = cap_story()
assert "This saves a extended key expression" 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, int):
need_keypress("1") # chosse account number
for ch in str(acct_num):
need_keypress(ch) # input num
press_select() # confirm selection
else:
press_select() # confirm story
acct_num = 0
menu = cap_menu()
if addr_fmt == AF_P2WPKH:
menu_item = "Segwit P2WPKH"
derive = f"m/84h/{chain_num}h/{acct_num}h"
elif addr_fmt == AF_P2WPKH_P2SH:
menu_item = "P2SH-Segwit"
derive = f"m/49h/{chain_num}h/{acct_num}h"
elif addr_fmt == AF_CLASSIC:
menu_item = "Classic P2PKH"
derive = f"m/44h/{chain_num}h/{acct_num}h"
elif addr_fmt == AF_P2WSH:
menu_item = "Multi P2WSH"
derive = f"m/48h/{chain_num}h/{acct_num}h/2h"
else:
assert addr_fmt == AF_P2WSH_P2SH
menu_item = "Multi P2SH-P2WSH"
derive = f"m/48h/{chain_num}h/{acct_num}h/1h"
assert menu_item in menu
pick_menu_item(menu_item)
contents = load_export(way, label="Key Expression", is_json=False, sig_check=False)
key_exp = contents.strip()
xfp = dev.master_fingerprint
xfp = xfp2str(xfp).lower()
seed = Mnemonic.to_seed(simulator_fixed_words)
node = BIP32Node.from_master_secret(
seed, netcode="BTC" if chain == "BTC" else "XTN"
).subkey_for_path(derive)
target = f"[{xfp}/{derive.replace('m/', '')}]{node.hwif()}"
assert key_exp == target
@pytest.mark.parametrize('path', [
# NOTE: (2**31)-1 = 0x7fff_ffff = 2147483647
"m/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647/2147483647",
"m/1/2/3/4/5",
"m/1h/2h/3h/4h/5h",
"m/45h",
])
def test_custom_key_expression_export(path, goto_home, pick_menu_item, cap_menu, need_keypress,
press_select, load_export, use_testnet, dev):
use_testnet()
goto_home()
pick_menu_item("Advanced/Tools")
pick_menu_item("Export Wallet")
pick_menu_item("Key Expression")
press_select() # story
pick_menu_item("Custom Path")
# blind entry, using only first 2 menu items
deeper = path.split("/")[1:]
for depth, part in enumerate(deeper):
time.sleep(.01)
m = cap_menu()
for mi in m:
assert "{idx}" not in mi # ranged values not allowed here
if depth == 0:
assert m[0] == 'm/⋯'
pick_menu_item(m[0])
else:
assert m[0].endswith("h/⋯")
assert m[1].endswith("/⋯")
assert m[0] != m[1]
pick_menu_item(m[0 if last_part[-1] == "h" else 1])
# enter path component
for d in part:
if d == "h": break
need_keypress(d)
press_select()
last_part = part
time.sleep(.01)
m = cap_menu()
pick_menu_item(m[2 if part[-1] == "h" else 3])
contents = load_export("sd", label="Key Expression", is_json=False, sig_check=False)
key_exp = contents.strip()
xfp = dev.master_fingerprint
xfp = xfp2str(xfp).lower()
seed = Mnemonic.to_seed(simulator_fixed_words)
node = BIP32Node.from_master_secret(seed, netcode="XTN").subkey_for_path(path)
target = f"[{xfp}/{path.replace('m/', '')}]{node.hwif()}"
assert key_exp == target
# EOF