What changed, and why it matters
This commit upgrades the COLDCARD firmware's WIF Store feature so that individual private keys imported as WIF can sign transactions even when the PSBT file lacks the usual BIP-32 key-path metadata. It adds address/scriptPubKey auto-detection, descriptor export, and Electrum-style watch-only wallet support. The change is primarily a feature enhancement, but it touches sensitive signing paths and removes a previous safety assertion that required key paths to be at least depth 8, which slightly weakens one guardrail.
Treat as a feature commit with security-sensitive signing changes. Reviewers should verify that the synthetic subpath injection cannot be abused to bypass multisig validation, that the removed depth-8 assertion does not reintroduce master-key signing risks, and that the new no-key error path cannot be triggered by a malicious PSBT to deny service. Regression testing should focus on address-only PSBTs from Electrum and Bitcoin Core.
Security signals we found
Signing path now accepts PSBT inputs with no BIP-32 derivation data if a matching WIF-store pubkey is found
Synthetic subpaths with zero fingerprint are injected for WIF-matched inputs
Removed assertion that key paths must be at least depth 8 (vl >= 8)
New guard raises FatalPSBTIssue when no input keys belong to the device
Added tests verifying unrelated WIFs cannot sign presigned foreign PSBTs
Evidence from the diff
The patch refactors WIF handling: WIFStore becomes a class with match_address_hash() for P2PKH/P2WPKH/P2SH-P2WPKH, and the UI menu is split into WIFStoreMenu. psbtInputProxy now falls back to matching the input’s scriptPubKey/address hash against stored WIF pubkeys when PSBT_IN_BIP32_DERIVATION is absent, injecting a synthetic subpath with a zero fingerprint. It also removes the assert vl >= 8 in key-path validation and relaxes the order of consider_keys() when a WIF store is present. New tests cover descriptor export, Core/Electrum address-only PSBT signing, and negative cases where unrelated WIFs must not sign foreign PSBTs.
Changed components
shared/psbt.pyshared/wif.pyshared/auth.pyshared/flow.pytesting/test_wif.pytesting/electrum.pyInspect captured patch +717 / −84
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index a6c9375..a7a615c 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -6,6 +6,8 @@ This lists the new changes that have not yet been published in a normal release.
- Change: BIP-322 Proof of Reserves & message signing PSBT requires PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE
(read more [BIP-322 Proof of Reserves documentation](../docs/proof-of-reserves-bip-322.md) )
+- Enhancement: WIF Store export watch-only descriptor
+- Enhancement: WIF Store address detection without the need for PSBT_IN_BIP32_DERIVATION (Electrum support)
- Bugfix: Disable Virtual Disk and NFC before activating HSM
- Bugfix: Custom address default menu position wrong
- Bugfix: Delta Mode Trick PIN was never restored from backup
diff --git a/shared/auth.py b/shared/auth.py
index 0ef3859..5db13b1 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -370,13 +370,14 @@ class ApproveTransaction(UserAuthorizedAction):
await self.psbt.validate() # might do UX: accept multisig import
dis.progress_sofar(10, 100)
- # consider_keys only needs num_our_keys to be set
- # it set during psbt.validate()
- self.psbt.consider_keys()
+ if not self.psbt.wif_store:
+ self.psbt.consider_keys()
dis.progress_sofar(20, 100)
ccc_c_xfp = CCCFeature.get_xfp() # can be None
self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
+ if self.psbt.wif_store:
+ self.psbt.consider_keys()
dis.progress_sofar(50, 100)
self.psbt.consider_outputs()
@@ -1720,11 +1721,18 @@ class TXInpExplorer(TXExplorer):
ws = self.user_auth_action.psbt.wif_store
our = [inp.required_key] if isinstance(inp.required_key, bytes) else inp.required_key
psbt_item += "Our key%s:\n\n" % ("s" if len(our) > 1 else "")
+ wif_note = "(WIF Store)"
for k in our:
- pth = inp.subpaths[k]
- ws_note = "\n(WIF Store)" if (ws and k in ws) else ""
- psbt_item += "%s:\n%s%s\n\n" % (keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0])),
- b2a_hex(k).decode(), ws_note)
+ pubkey = b2a_hex(k).decode()
+ pth = inp.subpaths.get(k)
+ note = ""
+ if pth:
+ label = keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0]))
+ if ws and k in ws:
+ note = "\n" + wif_note
+ psbt_item += "%s:\n%s%s\n\n" % (label, pubkey, note)
+ else:
+ psbt_item += "%s\n%s\n\n" % (pubkey, wif_note)
if inp.is_multisig:
ks_coord = inp.witness_script or inp.redeem_script
diff --git a/shared/flow.py b/shared/flow.py
index 1debe1c..e73e6ba 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -20,7 +20,7 @@ from paper import make_paper_wallet
from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file
from ccc import toggle_ccc_feature, sssp_spending_policy, sssp_feature_menu
-from wif import WIFStore
+from wif import WIFStoreMenu
# useful shortcut keys
from charcodes import KEY_QR, KEY_NFC
@@ -425,7 +425,7 @@ AdvancedNormalMenu = [
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem("Spending Policy", menu=SpendingPolicySubMenu,shortcut='s',predicate=has_real_secret),
MenuItem('Paper Wallets', f=make_paper_wallet),
- MenuItem('WIF Store', menu=WIFStore.make_menu),
+ MenuItem('WIF Store', menu=WIFStoreMenu.make),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
]
@@ -552,7 +552,7 @@ HobbledAdvancedMenu = [
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu, predicate=sssp_related_keys),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
- MenuItem('WIF Store', menu=WIFStore.make_menu, predicate=sssp_related_keys),
+ MenuItem('WIF Store', menu=WIFStoreMenu.make, predicate=sssp_related_keys),
MenuItem('Show %s Version' % ("Firmware" if version.has_qwerty else "FW"), f=show_version),
MenuItem("Destroy Seed", f=clear_seed, predicate=has_real_secret),
]
diff --git a/shared/psbt.py b/shared/psbt.py
index d2bc010..7e66d57 100644
--- a/shared/psbt.py
+++ b/shared/psbt.py
@@ -5,7 +5,7 @@
import stash, gc, history, sys, ngu, ckcc, chains
from ustruct import unpack_from, unpack, pack
from ubinascii import hexlify as b2a_hex
-from utils import xfp2str, B2A, keypath_to_str, is_ascii
+from utils import xfp2str, B2A, keypath_to_str
from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, node_from_privkey
from chains import NLOCK_IS_TIME
from uhashlib import sha256
@@ -20,7 +20,7 @@ from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_AN
from serializations import ALL_SIGHASH_FLAGS
from opcodes import OP_CHECKMULTISIG, OP_RETURN
from glob import settings
-from wif import init_wif_store
+from wif import WIFStore
from public_constants import (
PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_XPUB, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO,
@@ -37,6 +37,7 @@ from public_constants import (
# transaction version error
TX_VER_ERR = "bad txn version"
+NO_KEY_ERR = "None of the keys involved in this transaction belong to this Coldcard"
# single sha256 of b'BIP0322-signed-message'
BIP322_TAG_HASH = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I'
@@ -292,8 +293,6 @@ class psbtProxy:
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'
@@ -588,6 +587,7 @@ class psbtInputProxy(psbtProxy):
'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys',
'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid',
'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'addr_fmt',
+ 'wif_redeem_script',
)
def __init__(self, fd, idx):
@@ -773,7 +773,23 @@ class psbtInputProxy(psbtProxy):
self.amount = utxo.nValue
self.addr_fmt, addr_or_pubkey, addr_is_segwit = utxo.get_address()
- if not self.subpaths or self.fully_signed or (not self.num_our_keys):
+ subpaths = dict(self.subpaths or {}) # shallow copy
+
+ if not subpaths and psbt.wif_store:
+ res = psbt.wif_store.match_address_hash(self.addr_fmt, addr_or_pubkey)
+ if res:
+ # we have private key in WIF Store
+ # add pubkey with bogus zero fingerprint to current scope "subpaths" copy (will not be serialized)
+ # we want this function to finish properly, to set scriptSig or scriptCode, address format, etc...
+ idx, pk = res
+ subpaths[pk] = bytes(4)
+ self.num_our_keys = 1
+ if self.addr_fmt == AF_P2SH:
+ self.wif_redeem_script = b'\x00\x14' + psbt.wif_store._pkh[idx]
+ if self.redeem_script:
+ assert self.get(self.redeem_script) == self.wif_redeem_script
+
+ if not subpaths or self.fully_signed or (not self.num_our_keys):
# 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
@@ -802,17 +818,21 @@ class psbtInputProxy(psbtProxy):
self.is_p2sh = True
# we must have the redeem script already (else fail)
- ks = self.witness_script or self.redeem_script
- if not ks:
- raise FatalPSBTIssue("Missing redeem/witness script for input #%d" % my_idx)
+ if self.wif_redeem_script:
+ redeem_script = self.wif_redeem_script
+ else:
+ ks = self.witness_script or self.redeem_script
+ if not ks:
+ raise FatalPSBTIssue("Missing redeem/witness script for input #%d" % my_idx)
+
+ redeem_script = self.get(ks)
- redeem_script = self.get(ks)
self.scriptSig = redeem_script
# new cheat: psbt creator probably telling us exactly what key
# to use, by providing exactly one. This is ideal for p2sh wrapped p2pkh
- if len(self.subpaths) == 1:
- which_key, = self.subpaths.keys()
+ if len(subpaths) == 1:
+ which_key, = subpaths.keys()
else:
# Assume we'll be signing with any key we know
# - limitation: we cannot be two legs of a multisig (only if CCC feature used)
@@ -820,7 +840,7 @@ class psbtInputProxy(psbtProxy):
if not which_key:
which_key = set()
- for pubkey, path in self.subpaths.items():
+ for pubkey, path in subpaths.items():
if self.part_sigs and (pubkey in self.part_sigs):
# pubkey has already signed, so ignore
continue
@@ -854,7 +874,7 @@ class psbtInputProxy(psbtProxy):
self.scriptSig = utxo.scriptPubKey
addr = addr_or_pubkey
- for pubkey in self.subpaths:
+ for pubkey in subpaths:
if hash160(pubkey) == addr:
which_key = pubkey
break
@@ -867,7 +887,7 @@ class psbtInputProxy(psbtProxy):
self.scriptSig = utxo.scriptPubKey
assert len(addr_or_pubkey) == 33
- if addr_or_pubkey in self.subpaths:
+ if addr_or_pubkey in subpaths:
which_key = addr_or_pubkey
else:
# pubkey provided is just wrong vs. UTXO
@@ -890,7 +910,7 @@ class psbtInputProxy(psbtProxy):
self.fully_signed = True
return
- xfp_paths = list(self.subpaths.values())
+ xfp_paths = list(subpaths.values())
xfp_paths.sort()
# only search wallets with correct script type (aka address format)
@@ -907,7 +927,7 @@ class psbtInputProxy(psbtProxy):
# validate redeem script, by disassembling it and checking all pubkeys
try:
- psbt.active_multisig.validate_script(redeem_script, subpaths=self.subpaths)
+ psbt.active_multisig.validate_script(redeem_script, subpaths=subpaths)
target_spk, _ = chains.current_chain().script_pubkey(self.addr_fmt,
script=redeem_script)
assert target_spk == utxo.scriptPubKey, "spk mismatch"
@@ -1004,8 +1024,9 @@ class psbtInputProxy(psbtProxy):
for k in self.subpaths:
wr(PSBT_IN_BIP32_DERIVATION, self.subpaths[k], k)
- if self.redeem_script:
- wr(PSBT_IN_REDEEM_SCRIPT, self.redeem_script)
+ redeem_script = self.redeem_script or self.wif_redeem_script
+ if redeem_script:
+ wr(PSBT_IN_REDEEM_SCRIPT, redeem_script)
if self.witness_script:
wr(PSBT_IN_WITNESS_SCRIPT, self.witness_script)
@@ -1044,7 +1065,7 @@ class psbtObject(psbtProxy):
self.xpubs = [] # tuples(xfp_path, xpub)
self.my_xfp = settings.get('xfp', 0)
- self.wif_store = init_wif_store()
+ self.wif_store = WIFStore()
# details that we discover as we go
self.inputs = None
@@ -1511,7 +1532,7 @@ class psbtObject(psbtProxy):
self.por322 = bool(self.por322_msg)
if self.por322:
- if not is_ascii(self.por322_msg):
+ if not all(ord(ch) < 128 for ch in self.por322_msg):
self.warnings.append((
"Message",
"Message contains non-ASCII characters that may not be readable on this screen."
@@ -1912,6 +1933,9 @@ class psbtObject(psbtProxy):
for n,inp in enumerate(self.inputs)
if (inp.required_key is None) and (not inp.fully_signed)
)
+ if len(no_keys) >= self.num_inputs:
+ raise FatalPSBTIssue(NO_KEY_ERR)
+
if no_keys:
# This is seen when you re-sign same signed file by accident (multisig)
# - case of len(no_keys)==num_inputs is handled by consider_keys
@@ -1958,9 +1982,7 @@ class psbtObject(psbtProxy):
others.discard(self.my_xfp)
msg = ', '.join(xfp2str(i) for i in others)
- raise FatalPSBTIssue('None of the keys involved in this transaction '
- 'belong to this Coldcard (need %s, found %s).'
- % (xfp2str(self.my_xfp), msg))
+ raise FatalPSBTIssue(NO_KEY_ERR + " (need %s, found %s)" % (xfp2str(self.my_xfp), msg))
@classmethod
def read_psbt(cls, fd):
@@ -2176,12 +2198,12 @@ class psbtObject(psbtProxy):
which_key = inp.required_key
assert not inp.added_sigs, "already done??"
- assert which_key in inp.subpaths, 'unk key'
if which_key in self.wif_store:
node = node_from_privkey(self.wif_store[which_key])
else:
+ assert which_key in inp.subpaths, 'unk key'
# get node required
skp = keypath_to_str(inp.subpaths[which_key])
node = sv.derive_path(skp, register=False)
diff --git a/shared/wif.py b/shared/wif.py
index aca772c..3d85f01 100644
--- a/shared/wif.py
+++ b/shared/wif.py
@@ -9,7 +9,7 @@ from utils import problem_file_line, show_single_address, node_from_pubkey
from files import CardSlot, CardMissingError, needs_microsd
from glob import settings
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
-from public_constants import AF_P2WPKH
+from public_constants import AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2SH
from msgsign import msg_signing_done
MAX_ITEMS = 30
@@ -100,13 +100,13 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet):
await ux_show_story(msg, title=title)
-class WIFStore(MenuSystem):
+class WIFStoreMenu(MenuSystem):
def __init__(self):
items = self.construct()
super().__init__(items)
@classmethod
- async def make_menu(cls, *a):
+ async def make(cls, *a):
if not settings.get("wifs", None):
intro = ("Individual private keys, encoded as WIF (Wallet Import Format) keys"
" can be imported and used for signing. Any PSBT that uses a WIF stored here"
@@ -143,6 +143,7 @@ class WIFStore(MenuSystem):
submenu = [
MenuItem("Detail", f=self.detail, arg=(wif,pk,sk)),
+ MenuItem("Descriptors", f=self.show_desc_step1, arg=pk),
MenuItem("Addresses", f=self.show_addr_step1, arg=pk),
MenuItem("Sign MSG", f=self.sign_msg_step1, arg=sk),
MenuItem('Delete', f=self.delete, arg=(i, pk), predicate=not_hobbled_mode),
@@ -172,40 +173,51 @@ class WIFStore(MenuSystem):
await export_contents(title, wif, "wif.txt", None, None,
force_prompt=True, intro=msg, ux_title=title)
+ async def show_desc_step1(self, a, b, item):
+ rv = [
+ MenuItem(chains.addr_fmt_label(af), f=self.show_desc_step2, arg=(item.arg, af))
+ for af in chains.SINGLESIG_AF
+ ]
+ the_ux.push(MenuSystem(rv))
+
+ async def show_desc_step2(self, a, b, item):
+ # allow to export pubkey, instead of main detail where WIF is exported
+ pk, af = item.arg
+ title = "Descriptor"
+
+ if af == AF_P2WPKH:
+ desc = "wpkh(%s)"
+ elif af == AF_CLASSIC:
+ desc = "pkh(%s)"
+ else:
+ assert af == AF_P2WPKH_P2SH
+ desc = "sh(wpkh(%s))"
+
+ from descriptor import append_checksum
+ desc = append_checksum(desc % pk)
+
+ from export import export_contents
+ await export_contents(title, desc, "wif_desc_%d.txt" % af, None, None,
+ force_prompt=True, intro=desc, ux_title=title)
+
async def show_addr_step1(self, a, b, item):
- pubkey = a2b_hex(item.arg)
rv = [
- MenuItem(chains.addr_fmt_label(af), f=self.show_addr_step2, arg=(pubkey, af))
+ MenuItem(chains.addr_fmt_label(af), f=self.show_addr_step2, arg=(item.arg, af))
for af in chains.SINGLESIG_AF
]
the_ux.push(MenuSystem(rv))
async def show_addr_step2(self, a, b, item):
- from glob import NFC
pubkey, af = item.arg
- node = node_from_pubkey(pubkey)
+ node = node_from_pubkey(a2b_hex(pubkey))
addr = chains.current_chain().address(node, af)
- msg = show_single_address(addr) + "\n\n"
-
- escape = ""
- # Q only hint keys
- if not version.has_qwerty:
- msg += "Press (1) to show address QR code."
- escape += "1"
- if NFC:
- msg += "(3) to share via NFC."
- escape += "3"
-
- title = chains.addr_fmt_label(af) if version.has_qwerty else None
- while True:
- ch = await ux_show_story(msg, title=title, escape=escape,
- hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
- if ch == "x": return
- if ch in "1"+KEY_QR:
- await show_qr_code(addr, is_alnum=af == AF_P2WPKH)
-
- elif NFC and (ch in "3"+KEY_NFC):
- await NFC.share_text(addr)
+ msg = show_single_address(addr)
+
+ ux_title = chains.addr_fmt_label(af) if version.has_qwerty else None
+
+ from export import export_contents
+ await export_contents("Address", addr, "wif_addr.txt", None, None,
+ force_prompt=True, intro=msg, ux_title=ux_title)
async def sign_msg_step1(self, a, b, item):
privkey = a2b_hex(item.arg)
@@ -358,13 +370,54 @@ class WIFStore(MenuSystem):
title="Failure")
-def init_wif_store():
- # stored as hex strings, need load to bytes
- wifs = settings.get('wifs', [])
- if not wifs: return {}
- res = {}
- for pk, sk in wifs:
- res[a2b_hex(pk)] = a2b_hex(sk)
- return res
+
+class WIFStore:
+ def __init__(self):
+ wifs = settings.get('wifs', [])
+ self.wifs = [] # max 30 items, each (pubkey, privkey)
+ for pk, sk in wifs:
+ self.wifs.append((a2b_hex(pk), a2b_hex(sk)))
+
+ # built lazily, on first match_address_hash() call
+ self._pkh = [] # hash160(pubkey) — P2PKH / P2WPKH
+ self._sh = [] # hash160(0014 || _pkh) — P2SH-P2WPKH
+
+ def __bool__(self):
+ return len(self.wifs) > 0
+
+ def __contains__(self, pubkey):
+ return self._privkey_for(pubkey) is not None
+
+ def __getitem__(self, pubkey):
+ sk = self._privkey_for(pubkey)
+ if sk is None: raise KeyError
+ return sk
+
+ def _privkey_for(self, pubkey):
+ for pk, sk in self.wifs:
+ if pk == pubkey:
+ return sk
+
+ def match_address_hash(self, addr_fmt, hash20):
+ if not self.wifs:
+ return None
+ if not self._pkh:
+ self._pkh = [ngu.hash.hash160(pk) for pk, _ in self.wifs]
+
+ if addr_fmt in (AF_P2WPKH, AF_CLASSIC):
+ table = self._pkh
+ elif addr_fmt == AF_P2SH:
+ if not self._sh:
+ self._sh = [ngu.hash.hash160(b'\x00\x14' + h) for h in self._pkh]
+ table = self._sh
+ else:
+ return None # AF_P2WSH / AF_P2TR / AF_BARE_PK / unknown — not us
+
+ try:
+ idx = table.index(hash20)
+ return idx, self.wifs[idx][0]
+ except ValueError:
+ return None
+
# EOF
diff --git a/testing/conftest.py b/testing/conftest.py
index 234e569..b249485 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -10,6 +10,7 @@ from msg import verify_message
from api import bitcoind, match_key
from api import bitcoind_wallet, bitcoind_d_wallet, bitcoind_d_wallet_w_sk, bitcoind_d_sim_sign, bitcoind_d_dev_watch
from api import bitcoind_d_sim_watch, finalize_v2_v0_convert
+from electrum import electrum
from binascii import b2a_hex, a2b_hex
from constants import *
from charcodes import *
diff --git a/testing/electrum.py b/testing/electrum.py
new file mode 100644
index 0000000..a336b87
--- /dev/null
+++ b/testing/electrum.py
@@ -0,0 +1,96 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# Lightweight pytest wrapper around the `electrum` CLI in --regtest --offline mode.
+# No backend (electrs/ElectrumX) needed: UTXOs are fed via `addtransaction`,
+# with raw tx hex coming from the `bitcoind` fixture. Targets Electrum 4.7+
+
+import os, time, shutil, pytest, tempfile, subprocess
+
+
+class Electrum:
+ def __init__(self, path):
+ self.electrum_path = path
+ self.datadir = tempfile.mkdtemp(prefix="electrum-test-")
+ self.daemon_started = False
+
+ def _cli(self, *args, offline=False):
+ # `--offline` is required for commands run *before* the daemon starts
+ # (setconfig, daemon -d) and rejected for commands that talk *to* the
+ # running daemon (restore, load_wallet, addtransaction, payto).
+ cmd = [self.electrum_path, "--regtest"]
+ if offline:
+ cmd.append("--offline")
+ cmd += ["-D", self.datadir, *args]
+ return subprocess.run(cmd, capture_output=True, text=True, check=True)
+
+ def start(self):
+ # Pre-daemon commands run --offline.
+ self._cli("setconfig", "log_to_file", "false", offline=True)
+ self._cli("daemon", "-d", offline=True)
+ self.daemon_started = True
+ time.sleep(1.5) # let RPC bind
+
+ def stop(self):
+ if self.daemon_started:
+ try:
+ self._cli("daemon", "stop")
+ except subprocess.CalledProcessError:
+ pass
+ self.daemon_started = False
+ if os.path.exists(self.datadir):
+ shutil.rmtree(self.datadir, ignore_errors=True)
+
+ def cleanup(self, *args, **kwargs):
+ self.stop()
+
+ def imported_addr_wallet(self, addr, name="paper"):
+ # Create and load a watch-only imported-address wallet. Returns the
+ # name; Electrum picks the actual on-disk location based on --regtest.
+ self._cli("restore", addr, "-w", name)
+ self._cli("load_wallet", "-w", name)
+ return name
+
+ def addtransaction(self, wallet, tx_hex):
+ # Feed a raw transaction so the wallet sees its UTXOs without a server.
+ self._cli("addtransaction", tx_hex, "-w", wallet)
+
+ def payto_unsigned_psbt(self, wallet, dest, amount, feerate=5):
+ # Build an unsigned PSBT spending to `dest`. Returns base64 PSBT.
+ # Offline daemon has no fee oracle, so we pass an explicit feerate
+ # (sat/byte). RBF is on by default in Electrum 4.7+.
+ r = self._cli("payto", dest, str(amount),
+ "--unsigned", "--feerate", str(feerate),
+ "-w", wallet)
+ # Electrum CLI wraps strings in quotes; strip them.
+ return r.stdout.strip().strip('"')
+
+ @staticmethod
+ def create(*args, **kwargs):
+ e = Electrum(*args, **kwargs)
+ e.start()
+ return e
+
+
+def _find_electrum():
+ # Resolve the `electrum` binary, in order:
+ # 1. ELECTRUM_BIN env var — for users with a venv install
+ # (e.g. ELECTRUM_BIN=/home/me/electrum/ENV/bin/electrum)
+ # 2. `electrum` on PATH
+ path = os.environ.get("ELECTRUM_BIN") or shutil.which("electrum")
+ if path and os.path.isfile(path) and os.access(path, os.X_OK):
+ return path
+ return None
+
+
+@pytest.fixture
+def electrum():
+ # Electrum 4.7+ daemon in --regtest --offline mode.
+ # Skips if no usable binary — set ELECTRUM_BIN to point at one.
+ path = _find_electrum()
+ if not path:
+ pytest.skip("electrum not found — set $ELECTRUM_BIN or put it on PATH")
+ e = Electrum.create(path)
+ yield e
+ e.stop()
+
+# EOF
diff --git a/testing/test_wif.py b/testing/test_wif.py
index ac77fce..8b0364c 100644
--- a/testing/test_wif.py
+++ b/testing/test_wif.py
@@ -1,6 +1,8 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
-import pytest, time, os
+import pytest, time, os, base64
+
+from conftest import microsd_path
from helpers import prandom, addr_from_display_format
from charcodes import KEY_QR, KEY_NFC, KEY_UP
from constants import unmap_addr_fmt, AF_P2WSH, AF_P2SH
@@ -149,9 +151,10 @@ def test_wif_store_detail(netcode, import_wif_to_store, use_mainnet, cap_menu, p
time.sleep(.1)
menu = cap_menu()
assert menu[0] == "Detail"
- assert menu[1] == "Addresses"
- assert menu[2] == "Sign MSG"
- assert menu[3] == "Delete"
+ assert menu[1] == "Descriptors"
+ assert menu[2] == "Addresses"
+ assert menu[3] == "Sign MSG"
+ assert menu[4] == "Delete"
pick_menu_item("Detail")
@@ -226,9 +229,9 @@ def test_wif_store_addresses(netcode, import_wif_to_store, use_mainnet, cap_menu
assert addr == target_addr
if not is_q1:
- assert "Press (1) to show address QR code." in story
+ assert "(4) to show QR code" in story
- need_keypress(KEY_QR if is_q1 else "1")
+ need_keypress(KEY_QR if is_q1 else "4")
time.sleep(.1)
qr_addr = cap_screen_qr().decode()
if af == "p2wpkh":
@@ -238,7 +241,7 @@ def test_wif_store_addresses(netcode, import_wif_to_store, use_mainnet, cap_menu
if nfc_is_enabled():
if not is_q1:
- assert "(3) to share via NFC." in story
+ assert "(3) to share via NFC" in story
press_nfc()
time.sleep(0.3)
@@ -834,4 +837,445 @@ def test_visualize_wif(wif, testnet, is_q1, goto_home, need_keypress, use_testne
assert "duplicate WIF" in story
press_select()
-# EOF
\ No newline at end of file
+
+@pytest.mark.bitcoind
+def test_descriptor_export(import_wif_to_store, cap_menu, goto_home, settings_remove,
+ pick_menu_item, skip_if_useless_way, need_keypress, load_export,
+ cap_story, is_q1, bitcoind, press_cancel):
+ goto_home()
+ settings_remove("wifs")
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ pk = node.node.private_key
+ wif_str = pk.wif(testnet=True)
+
+ target = f"wpkh({node.node.public_key.sec().hex()})"
+ target = bitcoind.rpc.getdescriptorinfo(target)["descriptor"]
+
+ import_wif_to_store([wif_str])
+ # now in wif store menu, only one menu item besides "Import WIF"
+ menu = cap_menu()
+ assert len(menu) == 2
+ pick_menu_item(menu[1])
+ pick_menu_item("Descriptors")
+ pick_menu_item("Segwit P2WPKH")
+ time.sleep(.1)
+ title, story = cap_story()
+ story_desc = story.split("\n\n")[0]
+ assert story_desc.strip() == target
+
+ need_keypress("1") # SD
+ sd_desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
+ assert sd_desc.strip() == target
+
+ time.sleep(.1)
+ title, story = cap_story()
+ if "QR" in story:
+ qr_desc = load_export("qr", "Descriptor", is_json=False, sig_check=False)
+ press_cancel() # exit QR disaply
+ assert qr_desc.strip() == target
+ time.sleep(.1)
+ title, story = cap_story()
+
+ if "NFC" in story:
+ nfc_desc = load_export("nfc", "Descriptor", is_json=False, sig_check=False)
+ assert nfc_desc.strip() == target
+ press_cancel()
+
+ goto_home()
+
+
+@pytest.mark.bitcoind
+@pytest.mark.parametrize('mode', [ "Classic P2PKH", "P2SH-Segwit", "Segwit P2WPKH"])
+def test_spend_paper_wallet_desc_core(mode, bitcoind, settings_remove, import_wif_to_store,
+ start_sign, end_sign, cap_story, use_regtest, cap_menu,
+ pick_menu_item, goto_home, need_keypress, load_export):
+ use_regtest()
+ goto_home()
+ settings_remove("wifs")
+ amount = 5 # BTC
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ pk = node.node.private_key
+ wif_str = pk.wif(testnet=True)
+
+ import_wif_to_store([wif_str])
+ # now in wif store menu, only one menu item besides "Import WIF"
+ menu = cap_menu()
+ assert len(menu) == 2
+ pick_menu_item(menu[1])
+ pick_menu_item("Descriptors")
+ pick_menu_item(mode)
+ need_keypress("1") # SD
+ desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
+
+ # must match pubkey from device
+ assert pk.K.sec().hex() in desc
+
+ paper_addr = bitcoind.rpc.deriveaddresses(desc)[0]
+
+ paper = bitcoind.create_wallet(wallet_name="paper-wif", disable_private_keys=True,
+ blank=True, descriptors=True)
+ res = paper.importdescriptors([{
+ "desc": desc, "timestamp": 0, "watchonly": True,
+ }])
+ assert len(res) == 1 and res[0]["success"]
+
+ bitcoind.supply_wallet.sendtoaddress(paper_addr, amount)
+ bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
+ assert paper.listunspent()
+
+ dest = bitcoind.supply_wallet.getnewaddress()
+ resp = paper.walletcreatefundedpsbt([], [{dest: amount}], 0,
+ {"fee_rate": 3, "subtractFeeFromOutputs": [0]})
+
+ po = BasicPSBT().parse(base64.b64decode(resp["psbt"]))
+ # first sign as provided by core
+ psbt_bytes = po.as_bytes()
+ start_sign(psbt_bytes, finalize=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "WIF store: 0" in story
+ signed = end_sign(accept=True, finalize=True)
+
+ # remove BIP-32 paths from PSBT inputs
+ # causes auto-detection on CC side
+ for i in range(len(po.inputs)):
+ po.inputs[i].bip32_paths = None
+
+ psbt1_bytes = po.as_bytes()
+ assert len(psbt_bytes) > len(psbt1_bytes)
+ start_sign(psbt1_bytes, finalize=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "WIF store: 0" in story
+ signed1 = end_sign(accept=True, finalize=True)
+ assert signed1 == signed
+
+ tx_hex = signed.hex()
+ accept = bitcoind.rpc.testmempoolaccept([tx_hex])
+
+ assert accept[0]["allowed"]
+ txid = bitcoind.rpc.sendrawtransaction(tx_hex)
+ assert len(txid) == 64
+
+ settings_remove("wifs")
+ goto_home()
+
+
+@pytest.mark.parametrize("wif_store", [True, False])
+@pytest.mark.parametrize("subpaths", [True, False])
+def test_no_keys(wif_store, subpaths, fake_txn, settings_set, start_sign,
+ cap_story, settings_remove):
+
+ hack = None
+ if subpaths is False:
+ def hack(psbt):
+ psbt.inputs[0].bip32_paths = None
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(1, 1, segwit_in=True, master_xpub=node.hwif(), psbt_v2=True, psbt_hacker=hack)
+
+ # overwrite node, causing PSBT to be from completely different WIF/address
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ n = node.subkey_for_path("0/0")
+ sk = bytes(n.node.private_key).hex()
+ pk = n.node.private_key.K.sec().hex()
+
+ if wif_store:
+ settings_set("wifs", [(pk, sk)])
+ else:
+ settings_remove("wifs")
+
+ start_sign(psbt, finalize=True)
+ title, story = cap_story()
+ assert "Failure" == title
+
+ if wif_store is False and subpaths is False:
+ assert "PSBT does not contain any key path information" in story
+ else:
+ assert "None of the keys involved in this transaction belong to this Coldcard" in story
+
+
+def test_unrelated_wif_does_not_allow_presigned_foreign_psbt(fake_txn, settings_set,
+ start_sign, cap_story):
+ foreign = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(2, 1, segwit_in=True, master_xpub=foreign.hwif(), psbt_v2=True)
+ po = BasicPSBT().parse(psbt)
+
+ pubkey = list(po.inputs[0].bip32_paths.keys())[0]
+ po.inputs[0].part_sigs[pubkey] = b'\x30' + os.urandom(70)
+
+ unrelated = BIP32Node.from_master_secret(os.urandom(32)).subkey_for_path("0/0")
+ sk = bytes(unrelated.node.private_key).hex()
+ pk = unrelated.node.private_key.K.sec().hex()
+ settings_set("wifs", [(pk, sk)])
+
+ start_sign(po.as_bytes(), finalize=False)
+ title, story = cap_story()
+ assert title == "Failure"
+ assert "None of the keys involved in this transaction belong to this Coldcard" in story
+
+
+@pytest.mark.bitcoind
+@pytest.mark.parametrize('mode', ["Classic P2PKH", "Segwit P2WPKH", "P2SH-Segwit"])
+def test_spend_paper_wallet_addr_only(mode, bitcoind, settings_remove, import_wif_to_store,
+ start_sign, end_sign, cap_story, use_regtest,
+ pick_menu_item, goto_home, cap_menu, need_keypress,
+ load_export):
+ use_regtest()
+ goto_home()
+ settings_remove("wifs")
+ amount = 10 # BTC
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ pk = node.node.private_key
+ wif_str = pk.wif(testnet=True)
+
+ import_wif_to_store([wif_str])
+ menu = cap_menu()
+ assert len(menu) == 2
+
+ # Use the device-exported descriptor only to derive the address; we'll
+ # build the watch-only wallet around addr() instead of pkh/wpkh.
+ pick_menu_item(menu[1])
+ pick_menu_item("Descriptors")
+ pick_menu_item(mode)
+ need_keypress("1") # SD
+ desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
+ paper_addr = bitcoind.rpc.deriveaddresses(desc.strip())[0]
+
+ # Watch-only wallet built from addr() — no pubkey knowledge at all.
+ addr_desc = bitcoind.rpc.getdescriptorinfo("addr(%s)" % paper_addr)["descriptor"]
+
+ wname = "paper-addr-%s" % mode.replace(' ', '-')
+ paper = bitcoind.create_wallet(wallet_name=wname, disable_private_keys=True,
+ blank=True, descriptors=True)
+ res = paper.importdescriptors([{
+ "desc": addr_desc, "timestamp": "now", "watchonly": True,
+ }])
+ assert len(res) == 1 and res[0]["success"], res
+
+ # two inputs
+ bitcoind.supply_wallet.sendtoaddress(paper_addr, amount/2)
+ bitcoind.supply_wallet.sendtoaddress(paper_addr, amount/2)
+ bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
+ assert paper.listunspent()
+
+ dest = bitcoind.supply_wallet.getnewaddress()
+ # solving_data lets Core estimate the dummy signature size for fee
+ # selection; it is NOT written into the PSBT, so bip32_paths stays empty.
+ pubkey_hex = node.node.public_key.sec().hex()
+ resp = paper.walletcreatefundedpsbt(
+ [], [{dest: amount}], 0,
+ {"fee_rate": 3, "subtractFeeFromOutputs": [0],
+ "solving_data": {"pubkeys": [pubkey_hex]}})
+ psbt_bytes = base64.b64decode(resp["psbt"])
+
+ # Sanity check
+ po = BasicPSBT().parse(psbt_bytes)
+ for i, inp in enumerate(po.inputs):
+ assert not inp.bip32_paths
+
+ start_sign(psbt_bytes, finalize=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "WIF store: 0" in story
+ signed = end_sign(accept=True, finalize=True)
+
+ tx_hex = signed.hex()
+ accept = bitcoind.rpc.testmempoolaccept([tx_hex])
+ assert accept[0]["allowed"], accept
+ txid = bitcoind.rpc.sendrawtransaction(tx_hex)
+ assert len(txid) == 64
+
+ settings_remove("wifs")
+ goto_home()
+
+
+@pytest.mark.bitcoind
+def test_spend_paper_wallet_addr_only_p2sh_segwit_signed_psbt_finalizes(
+ bitcoind, settings_remove, import_wif_to_store, start_sign, end_sign,
+ cap_story, use_regtest, pick_menu_item, goto_home, cap_menu, need_keypress,
+ load_export):
+ use_regtest()
+ goto_home()
+ settings_remove("wifs")
+ amount = 10 # BTC
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ pk = node.node.private_key
+ wif_str = pk.wif(testnet=True)
+
+ import_wif_to_store([wif_str])
+ menu = cap_menu()
+ assert len(menu) == 2
+
+ pick_menu_item(menu[1])
+ pick_menu_item("Descriptors")
+ pick_menu_item("P2SH-Segwit")
+ need_keypress("1") # SD
+ desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
+ paper_addr = bitcoind.rpc.deriveaddresses(desc.strip())[0]
+
+ addr_desc = bitcoind.rpc.getdescriptorinfo("addr(%s)" % paper_addr)["descriptor"]
+ paper = bitcoind.create_wallet(wallet_name="paper-addr-p2sh-segwit-signed-psbt",
+ disable_private_keys=True, blank=True,
+ descriptors=True)
+ res = paper.importdescriptors([{
+ "desc": addr_desc, "timestamp": "now", "watchonly": True,
+ }])
+ assert len(res) == 1 and res[0]["success"], res
+
+ bitcoind.supply_wallet.sendtoaddress(paper_addr, amount)
+ bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
+ assert paper.listunspent()
+
+ dest = bitcoind.supply_wallet.getnewaddress()
+ pubkey_hex = node.node.public_key.sec().hex()
+ resp = paper.walletcreatefundedpsbt(
+ [], [{dest: amount}], 0,
+ {"fee_rate": 3, "subtractFeeFromOutputs": [0],
+ "solving_data": {"pubkeys": [pubkey_hex]}})
+ psbt_bytes = base64.b64decode(resp["psbt"])
+
+ po = BasicPSBT().parse(psbt_bytes)
+ for inp in po.inputs:
+ assert not inp.bip32_paths
+
+ start_sign(psbt_bytes, finalize=False)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "WIF store: 0" in story
+ signed_psbt = end_sign(accept=True, finalize=False)
+
+ finalize_res = bitcoind.rpc.finalizepsbt(base64.b64encode(signed_psbt).decode(), True)
+ assert finalize_res["complete"], finalize_res
+
+ accept = bitcoind.rpc.testmempoolaccept([finalize_res["hex"]])
+ assert accept[0]["allowed"], accept
+
+ settings_remove("wifs")
+ goto_home()
+
+
+def test_spend_paper_wallet_addr_only_wif_input_details(
+ fake_txn, settings_set, settings_remove, start_sign, cap_story,
+ pick_menu_item, need_keypress, press_cancel):
+ settings_remove("wifs")
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ n = node.subkey_for_path("0/0")
+ pubkey_hex = n.node.private_key.K.sec().hex()
+ settings_set("wifs", [(pubkey_hex, bytes(n.node.private_key).hex())])
+
+ def hack(psbt):
+ psbt.inputs[0].bip32_paths = None
+ psbt.inputs[0].redeem_script = None
+
+ psbt = fake_txn(1, 1, segwit_in=True, wrapped=True, master_xpub=node.hwif(),
+ psbt_hacker=hack)
+
+ po = BasicPSBT().parse(psbt)
+ for inp in po.inputs:
+ assert not inp.bip32_paths
+ assert inp.redeem_script is None
+
+ start_sign(psbt, finalize=False)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "OK TO SEND?"
+ assert "WIF store: 0" in story
+ assert "Press (2) to explore transaction" in story
+
+ need_keypress("2")
+ pick_menu_item("Inputs")
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "Input 0"
+ assert "WIF Store" in story
+ assert pubkey_hex in story
+
+ for _ in range(3):
+ press_cancel()
+
+ settings_remove("wifs")
+
+
+@pytest.mark.bitcoind
+@pytest.mark.parametrize('mode', ["Classic P2PKH", "Segwit P2WPKH", "P2SH-Segwit"])
+def test_spend_paper_wallet_via_electrum(mode, bitcoind, electrum, settings_remove,
+ import_wif_to_store, start_sign, end_sign,
+ cap_story, use_regtest, pick_menu_item,
+ goto_home, cap_menu, press_cancel,
+ need_keypress, cap_screen_qr, is_q1):
+ use_regtest()
+ goto_home()
+ settings_remove("wifs")
+ amount = 5 # BTC
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ pk = node.node.private_key
+ wif_str = pk.wif(testnet=True)
+
+ import_wif_to_store([wif_str])
+ menu = cap_menu()
+ assert len(menu) == 2
+
+ pick_menu_item(menu[1])
+ pick_menu_item("Addresses")
+ pick_menu_item(mode)
+ time.sleep(.1)
+ need_keypress(KEY_QR if is_q1 else "4")
+ time.sleep(.1)
+ paper_addr = cap_screen_qr().decode()
+ if mode == "Segwit P2WPKH":
+ paper_addr = paper_addr.lower()
+
+ goto_home()
+
+ # Electrum imported-address watch-only wallet.
+ wallet_path = electrum.imported_addr_wallet(
+ paper_addr, name="paper-%s" % mode.replace(' ', '-'))
+
+ # Fund the address via bitcoind, confirm.
+ txid = bitcoind.supply_wallet.sendtoaddress(paper_addr, amount)
+ bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
+ # `getrawtransaction` won't see this once it's mined (no -txindex), but
+ # the supply wallet still has the tx in its own history.
+ funding_hex = bitcoind.supply_wallet.gettransaction(txid)["hex"]
+
+ # Tell Electrum about the funding tx so its wallet sees the UTXO without
+ # needing an Electrum server backend.
+ electrum.addtransaction(wallet_path, funding_hex)
+
+ # Build the unsigned PSBT in Electrum.
+ dest = bitcoind.supply_wallet.getnewaddress()
+ spend_amt = round(amount - 0.001, 8)
+ psbt_b64 = electrum.payto_unsigned_psbt(wallet_path, dest, spend_amt)
+ psbt_bytes = base64.b64decode(psbt_b64)
+
+ # Sanity: confirm Electrum did NOT include any bip32 derivations.
+ # If this changes upstream, the test below would no longer be exercising
+ # the scriptPubKey auto-detect path.
+ po = BasicPSBT().parse(psbt_bytes)
+ for i, inp in enumerate(po.inputs):
+ assert not inp.bip32_paths
+
+ # Sign on Coldcard — must use scriptPubKey hash auto-detect.
+ start_sign(psbt_bytes, finalize=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "WIF store: 0" in story
+ signed = end_sign(accept=True, finalize=True)
+
+ tx_hex = signed.hex()
+ accept = bitcoind.rpc.testmempoolaccept([tx_hex])
+ assert accept[0]["allowed"], accept
+ txid = bitcoind.rpc.sendrawtransaction(tx_hex)
+ assert len(txid) == 64
+
+ settings_remove("wifs")
+ goto_home()
+
+# EOF
diff --git a/testing/txn.py b/testing/txn.py
index f800c86..c4f6cbc 100644
--- a/testing/txn.py
+++ b/testing/txn.py
@@ -60,12 +60,19 @@ def fake_txn(dev, pytestconfig):
# - each input is 1BTC
# addr where the fake money will be stored.
- subkey = mk.subkey_for_path(subpath % i)
- sec = subkey.sec()
- assert len(sec) == 33, "expect compressed"
- assert subpath[0:2] == '0/'
-
- psbt.inputs[i].bip32_paths[sec] = xfp + struct.pack('<II', 0, i)
+ if subpath is None:
+ subkey = mk
+ sec = mk.sec()
+ bytes_path = b""
+ else:
+ subkey = mk.subkey_for_path(subpath % i)
+ sec = subkey.sec()
+ assert len(sec) == 33, "expect compressed"
+ assert subpath[0:2] == '0/'
+ # TODO does not respect subpath parameter
+ bytes_path = struct.pack('<II', 0, i)
+
+ psbt.inputs[i].bip32_paths[sec] = xfp + bytes_path
# UTXO that provides the funding for to-be-signed txn
supply = CTransaction()
Why this scored 40/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.