Add bitcoin app playground with a CLI and TUI
What changed, and why it matters
This commit adds a new developer-only testing tool called a 'playground' for the Ledger Bitcoin app. It includes a command-line interface (CLI) and a text-based user interface (TUI) that let developers send test commands to either a software emulator (speculos) or a real Ledger device. The code is entirely in a new dev-tools/ directory, does not change the app itself, and is not part of any production build or shipped firmware. There is nothing in the commit that fixes, introduces, or discusses a security vulnerability.
No security action required. Treat as a normal developer-tooling addition. If desired, review the playground's use of fixed test mnemonics and temporary HMAC cache to confirm it is documented as dev-only and never used in production.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces dev-tools/playground/{README.md,init.py,cli.py,gui.py,presets.py,requirements.txt}. cli.py implements argparse subcommands that wrap the existing ledger_bitcoin client library to call get-fingerprint, get-xpub, register-wallet, get-address, sign-psbt, sign-message, and make-psbt against speculos or HID. gui.py is a Textual TUI that mirrors those commands as tabs, adds preset selection, captures stdout/stderr into a log panel, and supports clipboard paste via xclip/xsel/wl-paste/pbpaste. presets.py defines test wallet policies, BIP-32 paths, external cosigner mnemonics, and a temporary JSON HMAC cache. The code is purely additive, lives under dev-tools/, and has no effect on the Bitcoin app binary or its production code paths.
Changed components
dev-tools/playground/cli.pydev-tools/playground/gui.pydev-tools/playground/presets.pydev-tools/playground/README.mddev-tools/playground/requirements.txtdev-tools/playground/__init__.pyInspect captured patch +1825 / −0
diff --git a/dev-tools/playground/README.md b/dev-tools/playground/README.md
new file mode 100644
index 0000000..22281c5
--- /dev/null
+++ b/dev-tools/playground/README.md
@@ -0,0 +1,171 @@
+# Bitcoin app playground
+
+A small, dev-only playground for exercising the Ledger Bitcoin app's APDU
+commands against speculos or a real device. Each command maps to one APDU
+so you can watch the corresponding UX flow on the device screen.
+
+Two front-ends:
+
+- **CLI** ([cli.py](cli.py)) — single-shot subcommands, scriptable. Takes a
+ fully-resolved wallet policy via inline `--template` / `--key` / `--name`
+ flags; no preset concept.
+- **TUI** ([gui.py](gui.py)) — Textual-based, tabbed UI; one tab per
+ command. Adds *presets* on top: picking a preset prefills the tab's
+ fields (and, for wallet policies, eagerly resolves device-derived xpubs).
+ Streams the same stdout/stderr the CLI would print into an output panel.
+
+## Setup
+
+All commands assume you're at the repo root (`app-bitcoin-new/`).
+
+```bash
+# 1. Create and activate a Python virtualenv (Python >= 3.8).
+python3 -m venv .venv
+source .venv/bin/activate
+
+# 2. Install the ledger_bitcoin client library (editable install).
+pip install -e ./bitcoin_client
+
+# 3. Install the playground's extra dependencies.
+pip install -r dev-tools/playground/requirements.txt
+```
+
+If you also plan to use the speculos emulator, install it too:
+
+```bash
+pip install speculos
+```
+
+## Running speculos (optional, for emulator use)
+
+In a separate terminal:
+
+```bash
+speculos build/nanos2/bin/app.elf
+```
+
+(replace `nanos2` with the device target you have an SDK build for).
+
+## Running the TUI
+
+```bash
+python dev-tools/playground/gui.py
+```
+
+The TUI has one tab per command (`get-fingerprint`, `get-xpub`,
+`register-wallet`, `get-address`, `sign-psbt`, `sign-message`). At
+startup it probes speculos first, then a USB device; pass `--target
+speculos` or `--target hid` to skip the probe. The Output panel at the
+bottom streams stdout/stderr from each command in real time — so what
+you see is exactly what the CLI would have printed.
+
+### Presets
+
+Most tabs (`get-xpub`, `register-wallet`, `get-address`, `sign-psbt`) have
+a **Preset** combobox at the top. Picking a preset prefills the remaining
+fields:
+
+- **`get-xpub`** presets are common BIP-32 paths
+ (`bip44-account0`, …, `bip86-account0`, `multisig-account0`, and one
+ `deepest-allowed` at the maximum derivation depth the app accepts).
+
+- **`register-wallet`** / **`get-address`** / **`sign-psbt`** presets carry
+ a full wallet policy — descriptor template, wallet name, and one
+ `KeySpec` per `@N`. On selection the TUI eagerly queries the device to
+ resolve every internal key into a `[fpr/origin]xpub` line; external
+ cosigners are derived locally from one of the fixed mnemonics in
+ `EXTERNAL_MNEMONICS`. The resulting policy is written into the editable
+ fields so you can review or tweak it before pressing Run.
+
+- The `sign-psbt` tab also exposes **scenario presets** (e.g.
+ `huge-fee-wpkh`, `zero-outputs-tr`): same wallet-policy form, plus a
+ Python mutator that's applied to the auto-generated fake PSBT just before
+ signing. Useful for poking at edge-case device UX (high-fee warning,
+ zero outputs, …). Mutators only run when the PSBT textarea is empty; if
+ you paste a fixture the mutator is ignored.
+
+Pick `(no preset)` to keep editing the fields by hand.
+
+### Sign-PSBT input
+
+A single textarea: leave empty to generate a fake PSBT (uses the
+Inputs/Outputs fields); paste a base64 PSBT (auto-detected by the `cHNidP`
+prefix); or type a file path to load from disk.
+
+### Keyboard / mouse
+
+- `Ctrl+L` clears the log; `Ctrl+R` reconnects; `Ctrl+Q` quits.
+- Right-click on any text field pastes from the OS clipboard (requires one
+ of `xclip` / `xsel` / `wl-paste` / `pbpaste`). On most terminals
+ `Shift+Right-Click` also works via the terminal's own paste path.
+
+## Running the CLI
+
+The CLI has no preset concept. Wallet-policy subcommands take a
+fully-resolved policy via inline flags:
+
+```bash
+# Simple commands (no wallet policy).
+python dev-tools/playground/cli.py get-fingerprint
+python dev-tools/playground/cli.py get-xpub "m/86'/1'/0'" --display
+python dev-tools/playground/cli.py sign-message "m/44'/1'/0'/0/0" "hi"
+
+# Standard single-sig (no --name => standard policy, no registration).
+python dev-tools/playground/cli.py get-address \
+ --template "tr(@0/**)" \
+ --key "[f5acc2fd/86'/1'/0']tpub..." \
+ --display
+
+# Non-standard policy: --name is required for register-wallet and triggers
+# HMAC caching for subsequent get-address / sign-psbt calls.
+python dev-tools/playground/cli.py register-wallet \
+ --name "Joint account" \
+ --template "wsh(or_d(pk(@0/**),pkh(@1/**)))" \
+ --key "[f5acc2fd/48'/1'/0'/2']tpub..." \
+ --key "[d34db33f/48'/1'/0'/2']tpub..."
+
+# Same flags on sign-psbt; defaults to a generated fake PSBT.
+python dev-tools/playground/cli.py sign-psbt \
+ --name "Joint account" \
+ --template "wsh(or_d(pk(@0/**),pkh(@1/**)))" \
+ --key "[f5acc2fd/48'/1'/0'/2']tpub..." \
+ --key "[d34db33f/48'/1'/0'/2']tpub..."
+
+# Pre-build a fake PSBT (no device interaction).
+python dev-tools/playground/cli.py make-psbt \
+ --template "tr(@0/**)" \
+ --key "[f5acc2fd/86'/1'/0']tpub..." \
+ --inputs 2 --outputs 4 -o /tmp/fake.psbt
+```
+
+For MuSig2 (`tr(musig(@0,@1)/**)`), `sign-psbt` stops after round 1 by
+default. Pass `--external-xpriv <xpriv>` once per non-device cosigner to
+drive round 2 end-to-end.
+
+To talk to a real USB-connected device instead of speculos, pass
+`--target hid`:
+
+```bash
+python dev-tools/playground/cli.py --target hid get-fingerprint
+```
+
+## How to add a preset (TUI only)
+
+Edit [presets.py](presets.py) and append to the relevant list:
+
+- `XPUB_PRESETS`: an `XpubPreset(name, path, description)` for a new
+ derivation path.
+- `POLICY_PRESETS`: a `PolicyPreset(name, description, wallet_name,
+ template, keys=[KeySpec(...), ...])` for a new wallet policy. Use
+ `wallet_name=""` for standard single-sig (no registration); any
+ non-empty string makes it a registrable policy.
+- `SIGN_PSBT_SCENARIO_PRESETS`: a `PolicyPreset` with `psbt_mutator`
+ set to a function `(psbt) -> psbt` that mutates the auto-generated fake
+ PSBT to put the device in some interesting state.
+
+`KeySpec(path, external_index=None)` means the key is sourced from the
+device at `path`; pass an `external_index` (`0`, `1`, or `2`) to use one
+of the pinned mnemonics in `EXTERNAL_MNEMONICS`. External cosigners give
+the playground a true co-custody policy without requiring a second
+device, and the HMAC cache (`$TMPDIR/btcapp-playground/wallets.json`)
+stays valid across runs because the policy id is reproducible.
diff --git a/dev-tools/playground/__init__.py b/dev-tools/playground/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dev-tools/playground/cli.py b/dev-tools/playground/cli.py
new file mode 100644
index 0000000..eb2b115
--- /dev/null
+++ b/dev-tools/playground/cli.py
@@ -0,0 +1,464 @@
+#!/usr/bin/env python3
+"""Bitcoin app playground CLI.
+
+A single-shot CLI for exercising the Ledger Bitcoin app's APDU commands
+against speculos or a real device. Each subcommand maps to one APDU so you
+can watch the corresponding UX flow on the device screen.
+
+Wallet-policy subcommands accept a fully-resolved policy via inline flags:
+
+ --template "wsh(or_d(pk(@0/**),pkh(@1/**)))"
+ --key "[f5acc2fd/48'/1'/0'/2']tpubDE...XYZ"
+ --key "[d34db33f/48'/1'/0'/2']tpubDC...ABC"
+ --name "Joint account"
+
+Run from the repo root, e.g.
+
+ python dev-tools/playground/cli.py get-fingerprint
+ python dev-tools/playground/cli.py get-xpub "m/86'/1'/0'" --display
+ python dev-tools/playground/cli.py register-wallet \\
+ --name "Joint account" \\
+ --template "wsh(or_d(pk(@0/**),pkh(@1/**)))" \\
+ --key "[f5acc2fd/...]tpub..." --key "[d34db33f/...]tpub..."
+
+Standard single-sig policies (pkh, sh(wpkh), wpkh, tr with a single key)
+do not need registration; pass `--name ""` (the default) for those.
+
+Speculos is expected to be already running (default 127.0.0.1:9999).
+"""
+
+import argparse
+import sys
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+_HERE = Path(__file__).resolve().parent
+_REPO_ROOT = _HERE.parents[1]
+for p in (_REPO_ROOT, _HERE):
+ if str(p) not in sys.path:
+ sys.path.insert(0, str(p))
+
+from bitcoin_client.ledger_bitcoin import ( # noqa: E402
+ Chain,
+ MusigPartialSignature,
+ MusigPubNonce,
+ TransportClient,
+ WalletPolicy,
+ createClient,
+)
+from bitcoin_client.ledger_bitcoin.client_base import Client # noqa: E402
+from bitcoin_client.ledger_bitcoin.psbt import PSBT # noqa: E402
+
+from presets import ( # noqa: E402
+ cache_file_path,
+ cached_registration,
+ store_registration,
+)
+
+
+# ----- connection -----------------------------------------------------------
+
+CHAIN_BY_NAME = {
+ "main": Chain.MAIN,
+ "test": Chain.TEST,
+ "regtest": Chain.REGTEST,
+ "signet": Chain.SIGNET,
+}
+
+
+def open_client(args: argparse.Namespace) -> Client:
+ if args.target == "speculos":
+ transport = TransportClient(
+ interface="tcp",
+ server=args.host,
+ port=args.port,
+ debug=args.debug,
+ )
+ else: # hid
+ transport = TransportClient(interface="hid", debug=args.debug)
+ return createClient(transport, chain=CHAIN_BY_NAME[args.chain], debug=args.debug)
+
+
+# ----- policy building & registration ---------------------------------------
+
+def _build_policy(args: argparse.Namespace) -> WalletPolicy:
+ keys = list(args.keys or [])
+ if not args.template:
+ raise SystemExit("--template is required")
+ if not keys:
+ raise SystemExit("at least one --key is required")
+ return WalletPolicy(args.name or "", args.template, keys)
+
+
+def ensure_registered(
+ client: Client,
+ master_fpr_hex: str,
+ policy: WalletPolicy,
+ refresh: bool,
+) -> Optional[bytes]:
+ """Return the wallet HMAC for `policy`, registering on-device if needed,
+ or `None` for standard policies (empty name) that don't require it.
+
+ The cache is keyed on `(master_fpr, policy.id)` so any change to the
+ template or keys silently invalidates it.
+ """
+ if policy.name == "":
+ return None
+
+ wallet_id = policy.id
+ if not refresh:
+ cached = cached_registration(master_fpr_hex, wallet_id)
+ if cached is not None:
+ print(
+ f"[cache] using cached HMAC for {policy.name!r} "
+ f"(fingerprint {master_fpr_hex}). Pass --no-cache to re-register.",
+ file=sys.stderr,
+ )
+ return cached
+
+ print(
+ f"[register] registering wallet policy {policy.name!r} on device — "
+ "approve on screen...",
+ file=sys.stderr,
+ )
+ wallet_id, wallet_hmac = client.register_wallet(policy)
+ store_registration(master_fpr_hex, wallet_id, wallet_hmac)
+ print(f"[register] id: {wallet_id.hex()}", file=sys.stderr)
+ print(f"[register] hmac: {wallet_hmac.hex()}", file=sys.stderr)
+ return wallet_hmac
+
+
+# ----- helpers --------------------------------------------------------------
+
+def load_psbt(path: Path) -> PSBT:
+ psbt = PSBT()
+ psbt.deserialize(path.read_text().strip())
+ return psbt
+
+
+def make_fake_psbt(policy: WalletPolicy, n_inputs: int, n_outputs: int) -> PSBT:
+ """Build a fake-but-valid-looking PSBT spending from `policy`.
+
+ Reuses `test_utils.txmaker.createPsbt`, which only needs the *xpubs* from
+ the wallet's keys_info to derive credible prevout scripts — no private
+ keys involved. The synthetic prevouts won't exist on any chain, but the
+ PSBT is well-formed enough for the app to display and sign.
+ """
+ from test_utils.txmaker import createPsbt # local import (heavy)
+
+ if n_inputs < 1 or n_outputs < 1:
+ raise SystemExit("--inputs and --outputs must be >= 1")
+
+ input_amounts = [100_000_000 + 10_000_000 * i for i in range(n_inputs)]
+ total_in = sum(input_amounts)
+ fee = 1_000
+
+ if n_outputs == 1:
+ # Send everything (minus fee) back to ourselves as change.
+ output_amounts = [total_in - fee]
+ output_is_change = [True]
+ else:
+ n_recipients = n_outputs - 1
+ change_amount = max(10_000, total_in // (n_outputs + 1))
+ per_recipient = (total_in - change_amount - fee) // n_recipients
+ output_amounts = [per_recipient] * n_recipients + [
+ total_in - per_recipient * n_recipients - fee
+ ]
+ output_is_change = [False] * n_recipients + [True]
+
+ return createPsbt(policy, input_amounts, output_amounts, output_is_change)
+
+
+def sign_psbt_musig2(
+ client: Client,
+ policy: WalletPolicy,
+ wallet_hmac: Optional[bytes],
+ psbt: PSBT,
+ external_xprivs: List[str],
+) -> List[Tuple[int, object]]:
+ """Drive both rounds of a BIP-327 musig2 signing session.
+
+ Round 1 (silent on the device): the device returns its `MusigPubNonce`
+ for each musig input. We add it to the PSBT, then each external
+ `HotMusig2Cosigner` (one per `external_xprivs[i]`) adds theirs. Round 2
+ (interactive — review on device): the device returns its
+ `MusigPartialSignature`; external cosigners then contribute theirs.
+
+ If `external_xprivs` is empty, only round 1 is performed and the
+ returned tuples are the device's pubnonces. To complete signing, supply
+ every cosigner's xpriv.
+ """
+ from test_utils.musig2 import HotMusig2Cosigner # noqa: E402
+
+ externals = [HotMusig2Cosigner(policy, xpriv) for xpriv in external_xprivs]
+
+ # ---- Round 1 -----------------------------------------------------------
+ print(
+ "[musig2] Round 1: fetching pubnonces from device (silent, no UX)...",
+ file=sys.stderr,
+ )
+ round1 = client.sign_psbt(psbt, policy, wallet_hmac)
+ for input_index, obj in round1:
+ if not isinstance(obj, MusigPubNonce):
+ raise RuntimeError(
+ f"Expected MusigPubNonce in round 1, got {type(obj).__name__}: {obj}"
+ )
+ psbt.inputs[input_index].musig2_pub_nonces[
+ (obj.participant_pubkey, obj.aggregate_pubkey, obj.tapleaf_hash)
+ ] = obj.pubnonce
+
+ if not externals:
+ print(
+ "[musig2] no external cosigners supplied (--external-xpriv); "
+ "stopping after round 1. Pass each cosigner xpriv to complete signing.",
+ file=sys.stderr,
+ )
+ return round1
+
+ print(
+ f"[musig2] device added {len(round1)} pubnonce(s); "
+ f"now collecting from {len(externals)} external cosigner(s)...",
+ file=sys.stderr,
+ )
+ for cosigner in externals:
+ cosigner.generate_public_nonces(psbt)
+
+ # ---- Round 2 -----------------------------------------------------------
+ print(
+ "[musig2] Round 2: requesting partial signatures from device — "
+ "review and confirm transaction on device...",
+ file=sys.stderr,
+ )
+ round2 = client.sign_psbt(psbt, policy, wallet_hmac)
+ for input_index, obj in round2:
+ if not isinstance(obj, MusigPartialSignature):
+ raise RuntimeError(
+ f"Expected MusigPartialSignature in round 2, got {type(obj).__name__}: {obj}"
+ )
+ psbt.inputs[input_index].musig2_partial_sigs[
+ (obj.participant_pubkey, obj.aggregate_pubkey, obj.tapleaf_hash)
+ ] = obj.partial_signature
+ print(
+ f"[musig2] device added {len(round2)} partial signature(s); "
+ f"now collecting from {len(externals)} external cosigner(s)...",
+ file=sys.stderr,
+ )
+ for cosigner in externals:
+ cosigner.generate_partial_signatures(psbt)
+
+ return round2
+
+
+def print_signatures(results: List[Tuple[int, object]]) -> None:
+ if not results:
+ print("(no signatures returned)")
+ return
+ for input_index, obj in results:
+ print(f"Input #{input_index}: {obj}")
+
+
+# ----- subcommands ----------------------------------------------------------
+
+def cmd_get_fingerprint(args: argparse.Namespace) -> None:
+ with open_client(args) as client:
+ fpr = client.get_master_fingerprint()
+ print(fpr.hex())
+
+
+def cmd_get_xpub(args: argparse.Namespace) -> None:
+ with open_client(args) as client:
+ if args.display:
+ print("Confirm on device...", file=sys.stderr)
+ xpub = client.get_extended_pubkey(args.path, display=args.display)
+ print(xpub)
+
+
+def cmd_register_wallet(args: argparse.Namespace) -> None:
+ policy = _build_policy(args)
+ if policy.name == "":
+ raise SystemExit(
+ "register-wallet requires a non-empty --name "
+ "(standard single-sig policies don't need registration)."
+ )
+ with open_client(args) as client:
+ master_fpr = client.get_master_fingerprint().hex()
+ print(f"Resolved policy:\n {policy.descriptor_template}", file=sys.stderr)
+ for k in policy.keys_info:
+ print(f" {k}", file=sys.stderr)
+ print("Approve registration on device...", file=sys.stderr)
+ wallet_id, wallet_hmac = client.register_wallet(policy)
+ store_registration(master_fpr, wallet_id, wallet_hmac)
+ print(f"id: {wallet_id.hex()}")
+ print(f"hmac: {wallet_hmac.hex()}")
+
+
+def cmd_get_address(args: argparse.Namespace) -> None:
+ policy = _build_policy(args)
+ with open_client(args) as client:
+ master_fpr = client.get_master_fingerprint().hex()
+ hmac = ensure_registered(client, master_fpr, policy, refresh=args.no_cache)
+ if args.display:
+ print("Confirm address on device...", file=sys.stderr)
+ addr = client.get_wallet_address(
+ policy, hmac, args.change, args.index, args.display
+ )
+ print(addr)
+
+
+def cmd_sign_psbt(args: argparse.Namespace) -> None:
+ policy = _build_policy(args)
+ external_xprivs = list(getattr(args, "external_xprivs", None) or [])
+
+ with open_client(args) as client:
+ master_fpr = client.get_master_fingerprint().hex()
+ hmac = ensure_registered(client, master_fpr, policy, refresh=args.no_cache)
+
+ if args.fixture:
+ print(f"[psbt] loading {args.fixture}", file=sys.stderr)
+ psbt = load_psbt(Path(args.fixture))
+ else:
+ print(
+ f"[psbt] generating fake PSBT ({args.inputs} in / {args.outputs} out)...",
+ file=sys.stderr,
+ )
+ psbt = make_fake_psbt(policy, args.inputs, args.outputs)
+
+ # TUI-only hook: a callable that mutates the generated PSBT
+ # before signing. Not exposed via argparse.
+ mutator = getattr(args, "psbt_mutator", None)
+ if mutator is not None:
+ print("[psbt] applying preset mutator", file=sys.stderr)
+ psbt = mutator(psbt)
+
+ if "musig(" in policy.descriptor_template:
+ results = sign_psbt_musig2(client, policy, hmac, psbt, external_xprivs)
+ else:
+ print("Review and confirm transaction on device...", file=sys.stderr)
+ results = client.sign_psbt(psbt, policy, hmac)
+
+ print_signatures(results)
+
+
+def cmd_make_psbt(args: argparse.Namespace) -> None:
+ policy = _build_policy(args)
+ psbt = make_fake_psbt(policy, args.inputs, args.outputs)
+ b64 = psbt.serialize()
+ if args.output:
+ Path(args.output).write_text(b64 + "\n")
+ print(f"wrote {args.output}", file=sys.stderr)
+ else:
+ print(b64)
+
+
+def cmd_sign_message(args: argparse.Namespace) -> None:
+ with open_client(args) as client:
+ print("Confirm message on device...", file=sys.stderr)
+ sig = client.sign_message(args.message, args.path)
+ print(sig)
+
+
+# ----- argparse plumbing ----------------------------------------------------
+
+def _add_policy_args(p: argparse.ArgumentParser, *, with_name_default: bool = True) -> None:
+ """Add the four flags every policy subcommand shares."""
+ p.add_argument(
+ "--template", required=True,
+ help='BIP-388 descriptor template, e.g. "wsh(or_d(pk(@0/**),pkh(@1/**)))"',
+ )
+ p.add_argument(
+ "--key", "-k", action="append", dest="keys", required=True,
+ help='one BIP-388 key-info string "[fpr/origin]xpub"; repeat for multi-key policies',
+ )
+ p.add_argument(
+ "--name", default="" if with_name_default else None,
+ help=(
+ "WalletPolicy name. Leave empty for standard single-sig policies; "
+ "any non-empty value triggers on-device registration and HMAC caching."
+ ),
+ )
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="playground",
+ description="Bitcoin app playground CLI (speculos / real device).",
+ epilog=f"Registration cache: {cache_file_path()}",
+ )
+ p.add_argument(
+ "--target", choices=["speculos", "hid"], default="speculos",
+ help="connection target (default: speculos)",
+ )
+ p.add_argument("--host", default="127.0.0.1", help="speculos host (default: 127.0.0.1)")
+ p.add_argument("--port", type=int, default=9999, help="speculos APDU port (default: 9999)")
+ p.add_argument(
+ "--chain", choices=list(CHAIN_BY_NAME), default="test",
+ help="Bitcoin network (default: test)",
+ )
+ p.add_argument("--debug", action="store_true", help="dump APDUs on stderr")
+
+ sub = p.add_subparsers(dest="command", required=True)
+
+ sp = sub.add_parser("get-fingerprint", help="GET_MASTER_FINGERPRINT (no UX)")
+ sp.set_defaults(func=cmd_get_fingerprint)
+
+ sp = sub.add_parser("get-xpub", help="GET_EXTENDED_PUBKEY")
+ sp.add_argument("path", help='BIP-32 derivation path, e.g. "m/86\'/1\'/0\'"')
+ sp.add_argument("--display", action="store_true", help="confirm xpub on device")
+ sp.set_defaults(func=cmd_get_xpub)
+
+ sp = sub.add_parser("register-wallet", help="REGISTER_WALLET")
+ _add_policy_args(sp)
+ sp.set_defaults(func=cmd_register_wallet)
+
+ sp = sub.add_parser("get-address", help="GET_WALLET_ADDRESS")
+ _add_policy_args(sp)
+ sp.add_argument("--change", type=int, default=0, choices=[0, 1])
+ sp.add_argument("--index", type=int, default=0)
+ sp.add_argument("--display", action="store_true", help="confirm address on device")
+ sp.add_argument("--no-cache", action="store_true", help="force re-registration")
+ sp.set_defaults(func=cmd_get_address)
+
+ sp = sub.add_parser("sign-psbt", help="SIGN_PSBT")
+ _add_policy_args(sp)
+ sp.add_argument(
+ "--fixture",
+ help="path to a PSBT file (base64); default is to generate a fake PSBT",
+ )
+ sp.add_argument("--inputs", type=int, default=1, help="(generated) number of inputs")
+ sp.add_argument("--outputs", type=int, default=2, help="(generated) number of outputs")
+ sp.add_argument("--no-cache", action="store_true", help="force re-registration")
+ sp.add_argument(
+ "--external-xpriv", action="append", dest="external_xprivs",
+ help=(
+ "musig2-only: xpriv for one external cosigner to drive round-2 signing. "
+ "Repeat once per non-device cosigner. Without it, sign-psbt stops after "
+ "round 1 and prints the device's pubnonce(s)."
+ ),
+ )
+ sp.set_defaults(func=cmd_sign_psbt)
+
+ sp = sub.add_parser(
+ "make-psbt",
+ help="generate a fake PSBT for a wallet policy (no device interaction, does not sign)",
+ )
+ _add_policy_args(sp)
+ sp.add_argument("--inputs", type=int, default=1)
+ sp.add_argument("--outputs", type=int, default=2)
+ sp.add_argument("-o", "--output", help="write to file (default: stdout)")
+ sp.set_defaults(func=cmd_make_psbt)
+
+ sp = sub.add_parser("sign-message", help="SIGN_MESSAGE")
+ sp.add_argument("path", help='BIP-32 derivation path, e.g. "m/44\'/1\'/0\'/0/0"')
+ sp.add_argument("message", help="message text")
+ sp.set_defaults(func=cmd_sign_message)
+
+ return p
+
+
+def main(argv: Optional[List[str]] = None) -> None:
+ args = build_parser().parse_args(argv)
+ args.func(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dev-tools/playground/gui.py b/dev-tools/playground/gui.py
new file mode 100644
index 0000000..bf21507
--- /dev/null
+++ b/dev-tools/playground/gui.py
@@ -0,0 +1,871 @@
+#!/usr/bin/env python3
+"""Bitcoin app playground — Textual TUI front-end.
+
+Mirrors the CLI subcommands as tabs, but adds *presets* on top: a preset
+prefills the form fields of one tab. Presets that include device-derived
+keys are resolved eagerly against the connected device — picking
+`tr-singlesig`, for example, immediately calls get_xpub at m/86'/1'/0' and
+populates the keys textarea with the resulting `[fpr/path]xpub`. The
+user can still edit any field afterward.
+
+Each Run button builds an `argparse.Namespace` matching the corresponding
+`cmd_*` function in `cli.py` (which is now preset-free and takes a fully
+inline wallet policy) and dispatches it on a worker thread with
+stdout/stderr captured into the Output panel.
+
+Run from the repo root, with the project venv active:
+
+ python dev-tools/playground/gui.py
+
+Speculos is expected to be running separately (default 127.0.0.1:9999).
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+import tempfile
+import threading
+import traceback
+from pathlib import Path
+from typing import Any, Callable, List, Optional
+
+_HERE = Path(__file__).resolve().parent
+_REPO_ROOT = _HERE.parents[1]
+for p in (_REPO_ROOT, _HERE):
+ if str(p) not in sys.path:
+ sys.path.insert(0, str(p))
+
+from textual import events, on, work # noqa: E402
+from textual.app import App, ComposeResult # noqa: E402
+from textual.containers import Horizontal, VerticalScroll # noqa: E402
+from textual.widgets import ( # noqa: E402
+ Button,
+ Checkbox,
+ Footer,
+ Header,
+ Input,
+ Label,
+ RadioButton,
+ RadioSet,
+ RichLog,
+ Select,
+ Static,
+ TabbedContent,
+ TabPane,
+ TextArea,
+)
+
+from cli import ( # noqa: E402
+ CHAIN_BY_NAME,
+ cmd_get_address,
+ cmd_get_fingerprint,
+ cmd_get_xpub,
+ cmd_register_wallet,
+ cmd_sign_message,
+ cmd_sign_psbt,
+)
+from presets import ( # noqa: E402
+ POLICY_PRESETS,
+ PolicyPreset,
+ XPUB_PRESETS,
+ XpubPreset,
+ external_key_info,
+ external_xpriv,
+ sign_psbt_presets,
+)
+
+
+NO_PRESET = "" # sentinel value used by every Preset Select
+PSBT_BASE64_PREFIX = "cHNidP" # base64-encoded "psbt"
+
+
+def _preset_choices(presets) -> list[tuple[str, str]]:
+ """Build the `(label, value)` list for a preset combobox.
+
+ The first entry is a "(no preset)" sentinel that doesn't prefill anything;
+ the rest show `name — description` so the user knows what each one does.
+ """
+ out: list[tuple[str, str]] = [("(no preset)", NO_PRESET)]
+ for p in presets:
+ desc = p.description
+ if len(desc) > 70:
+ desc = desc[:67] + "..."
+ out.append((f"{p.name} — {desc}", p.name))
+ return out
+
+
+# ----- connection probing ----------------------------------------------------
+
+def _try_target(target: str, host: str, port: int, chain) -> Optional[str]:
+ """Attempt a transport connection plus a `get_version()` round-trip.
+
+ Returns `None` on success (Bitcoin (Test) app open and reachable), or a
+ short human-readable error string on failure.
+ """
+ from bitcoin_client.ledger_bitcoin import TransportClient, createClient
+
+ try:
+ if target == "speculos":
+ transport = TransportClient(interface="tcp", server=host, port=port)
+ elif target == "hid":
+ transport = TransportClient(interface="hid")
+ else:
+ return f"unknown target {target!r}"
+ except ConnectionRefusedError:
+ return f"speculos not running at {host}:{port}"
+ except OSError as e:
+ if target == "speculos":
+ return f"speculos unreachable at {host}:{port} ({e})"
+ return f"no Ledger USB device detected ({e})"
+ except Exception as e: # broad on purpose — hidapi raises varied types
+ if target == "hid":
+ return f"no Ledger USB device detected ({type(e).__name__}: {e})"
+ return f"transport setup failed ({type(e).__name__}: {e})"
+
+ client = None
+ try:
+ client = createClient(transport, chain=chain)
+ app_name, _version, _flags = client.get_version()
+ except Exception as e:
+ return f"could not read app version ({type(e).__name__}: {e})"
+ finally:
+ if client is not None:
+ try:
+ client.stop()
+ except Exception:
+ pass
+
+ if not app_name.startswith("Bitcoin"):
+ return (
+ f"the Bitcoin (Test) app is not open on the device "
+ f"(saw {app_name!r})"
+ )
+ return None
+
+
+# ----- clipboard read (for right-click paste) --------------------------------
+
+def _read_clipboard() -> Optional[str]:
+ """Read text from the OS clipboard.
+
+ Textual captures mouse events, so the terminal's own right-click-paste
+ never reaches the cursor. We re-implement it by shelling out to a
+ standard clipboard tool: xclip / xsel / wl-paste / pbpaste, returning
+ the first one that succeeds. Returns None if none are available or the
+ clipboard is empty.
+ """
+ import subprocess
+ candidates = [
+ ["xclip", "-selection", "clipboard", "-o"],
+ ["xsel", "--clipboard", "--output"],
+ ["wl-paste", "--no-newline"],
+ ["pbpaste"],
+ ]
+ for cmd in candidates:
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=1
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ continue
+ if result.returncode == 0 and result.stdout:
+ return result.stdout
+ return None
+
+
+# ----- stdout/stderr capture -------------------------------------------------
+
+class _LogStream:
+ """Captures writes from a worker thread and forwards them to a RichLog."""
+
+ def __init__(self, app: App, log: RichLog, is_err: bool) -> None:
+ self._app = app
+ self._log = log
+ self._is_err = is_err
+ self._buf = ""
+ self._lock = threading.Lock()
+
+ def write(self, s: str) -> int:
+ with self._lock:
+ self._buf += s
+ while "\n" in self._buf:
+ line, self._buf = self._buf.split("\n", 1)
+ self._emit(line)
+ return len(s)
+
+ def flush(self) -> None:
+ with self._lock:
+ if self._buf:
+ self._emit(self._buf)
+ self._buf = ""
+
+ def _emit(self, line: str) -> None:
+ text = f"[dim]{line}[/]" if self._is_err else line
+ self._app.call_from_thread(self._log.write, text)
+
+ def isatty(self) -> bool:
+ return False
+
+
+# ----- the app ---------------------------------------------------------------
+
+class PlaygroundApp(App):
+ """Textual front-end for the Bitcoin app playground."""
+
+ CSS = """
+ Screen {
+ layout: vertical;
+ }
+ #status_bar {
+ height: 1;
+ padding: 0 2;
+ background: $boost;
+ color: $text-muted;
+ }
+ TabbedContent {
+ height: 3fr;
+ min-height: 12;
+ }
+ TabPane {
+ layout: vertical;
+ padding: 0;
+ }
+ /* Scrollable region holding a tab's inputs. The Run bar below it is
+ pinned, so the button stays visible even when the form overflows. */
+ .tab-body {
+ height: 1fr;
+ padding: 1 2 0 2;
+ }
+ .form-row {
+ height: 3;
+ }
+ .form-row Label {
+ width: 22;
+ content-align: left middle;
+ }
+ .form-row Input {
+ width: 1fr;
+ }
+ .keys-area {
+ height: 6;
+ }
+ .actions {
+ height: auto;
+ align-horizontal: right;
+ padding: 0 2;
+ border-top: solid $panel;
+ }
+ .actions Button {
+ margin-left: 2;
+ }
+ #log {
+ height: 1fr;
+ min-height: 6;
+ max-height: 16;
+ border: round $primary;
+ padding: 0 1;
+ }
+ #sign_psbt_input {
+ height: 5;
+ }
+ """
+
+ BINDINGS = [
+ ("ctrl+l", "clear_log", "Clear log"),
+ ("ctrl+r", "reconnect", "Reconnect"),
+ ("ctrl+q", "quit", "Quit"),
+ ]
+
+ # Presets shown on each policy tab.
+ REGISTER_PRESETS = [p for p in POLICY_PRESETS if p.needs_registration]
+ ADDR_PRESETS = list(POLICY_PRESETS)
+ SIGN_PRESETS = sign_psbt_presets()
+
+ def __init__(
+ self,
+ target: str = "auto",
+ host: str = "127.0.0.1",
+ port: int = 9999,
+ chain: str = "test",
+ debug: bool = False,
+ ) -> None:
+ super().__init__()
+ if target not in ("auto", "speculos", "hid"):
+ raise ValueError(f"invalid target: {target!r}")
+ if chain not in CHAIN_BY_NAME:
+ raise ValueError(f"invalid chain: {chain!r}")
+ self._target_arg = target
+ self.target = "speculos" if target == "auto" else target
+ self.host = host
+ self.port = port
+ self.chain = chain
+ self.debug_flag = debug
+ self._connected: bool = False
+
+ # State carried from a sign-psbt preset selection to the next Run.
+ # Reset whenever a new preset is picked on the sign-psbt tab.
+ self._sign_psbt_mutator: Optional[Callable[[Any], Any]] = None
+ self._sign_psbt_external_xprivs: List[str] = []
+
+ def compose(self) -> ComposeResult:
+ yield Header(show_clock=False)
+ yield Static("starting...", id="status_bar")
+
+ with TabbedContent(initial="tab_fpr", id="cmd_tabs"):
+ with TabPane("get-fingerprint", id="tab_fpr"):
+ with VerticalScroll(classes="tab-body"):
+ yield from self._compose_get_fingerprint()
+ yield from self._run_bar("run_fpr")
+ with TabPane("get-xpub", id="tab_xpub"):
+ with VerticalScroll(classes="tab-body"):
+ yield from self._compose_get_xpub()
+ yield from self._run_bar("run_xpub")
+ with TabPane("register-wallet", id="tab_register"):
+ with VerticalScroll(classes="tab-body"):
+ yield from self._compose_register_wallet()
+ yield from self._run_bar("run_register")
+ with TabPane("get-address", id="tab_addr"):
+ with VerticalScroll(classes="tab-body"):
+ yield from self._compose_get_address()
+ yield from self._run_bar("run_addr")
+ with TabPane("sign-psbt", id="tab_sign_psbt"):
+ with VerticalScroll(classes="tab-body"):
+ yield from self._compose_sign_psbt()
+ yield from self._run_bar("run_sign_psbt")
+ with TabPane("sign-message", id="tab_sign_msg"):
+ with VerticalScroll(classes="tab-body"):
+ yield from self._compose_sign_message()
+ yield from self._run_bar("run_sign_msg")
+
+ yield RichLog(id="log", highlight=False, markup=True, wrap=False, max_lines=10_000)
+ yield Footer()
+
+ # ----- tab compose helpers ----------------------------------------------
+
+ def _run_bar(self, button_id: str) -> ComposeResult:
+ """Pinned action bar with a single Run button, kept outside the
+ scrollable `.tab-body` so it stays visible no matter how tall the
+ form is."""
+ with Horizontal(classes="actions"):
+ yield Button("Run", id=button_id, variant="primary")
+
+ def _compose_get_fingerprint(self) -> ComposeResult:
+ yield Label("Read the device's master fingerprint (GET_MASTER_FINGERPRINT, no UX).")
+
+ def _compose_get_xpub(self) -> ComposeResult:
+ with Horizontal(classes="form-row"):
+ yield Label("Preset:")
+ yield Select(_preset_choices(XPUB_PRESETS), value=NO_PRESET,
+ id="xpub_preset", allow_blank=False)
+ with Horizontal(classes="form-row"):
+ yield Label("BIP-32 path:")
+ yield Input(value="m/86'/1'/0'", id="xpub_path")
+ with Horizontal(classes="form-row"):
+ yield Checkbox("Display & confirm on device", id="xpub_display")
+
+ def _compose_policy_form(self, tab_id: str, presets) -> ComposeResult:
+ """Shared layout for register-wallet / get-address / sign-psbt.
+
+ A preset combobox at the top, followed by editable name / template /
+ keys textarea fields. Picking a preset eagerly resolves the policy
+ against the connected device and overwrites these fields; the user
+ is then free to tweak any of them before Run.
+ """
+ with Horizontal(classes="form-row"):
+ yield Label("Preset:")
+ yield Select(_preset_choices(presets), value=NO_PRESET,
+ id=f"{tab_id}_preset", allow_blank=False)
+ with Horizontal(classes="form-row"):
+ yield Label("Wallet name:")
+ yield Input(value="", id=f"{tab_id}_name",
+ placeholder="empty = standard policy, no registration")
+ with Horizontal(classes="form-row"):
+ yield Label("Descriptor template:")
+ yield Input(value="", id=f"{tab_id}_template",
+ placeholder='e.g. wsh(or_d(pk(@0/**),pkh(@1/**)))')
+ yield Label("Keys info (one [fpr/path]xpub per line, in @N order):")
+ yield TextArea(id=f"{tab_id}_keys", classes="keys-area")
+
+ def _compose_register_wallet(self) -> ComposeResult:
+ yield from self._compose_policy_form("reg", self.REGISTER_PRESETS)
+
+ def _compose_get_address(self) -> ComposeResult:
+ yield from self._compose_policy_form("addr", self.ADDR_PRESETS)
+ with Horizontal(classes="form-row"):
+ yield Label("Change:")
+ with RadioSet(id="addr_change"):
+ yield RadioButton("0 (receive)", id="addr_change_0", value=True)
+ yield RadioButton("1 (change)", id="addr_change_1")
+ with Horizontal(classes="form-row"):
+ yield Label("Address index:")
+ yield Input(value="0", id="addr_index", type="integer")
+ with Horizontal(classes="form-row"):
+ yield Checkbox("Display & confirm on device", id="addr_display")
+ yield Checkbox("Re-register (--no-cache)", id="addr_no_cache")
+
+ def _compose_sign_psbt(self) -> ComposeResult:
+ yield from self._compose_policy_form("sign", self.SIGN_PRESETS)
+ yield Label(
+ "PSBT (empty = generate fake; otherwise, a base64-encode PSBT or a file path):"
+ )
+ yield TextArea(id="sign_psbt_input")
+ with Horizontal(classes="form-row"):
+ yield Label("Generated inputs:")
+ yield Input(value="1", id="sign_inputs", type="integer")
+ with Horizontal(classes="form-row"):
+ yield Label("Generated outputs:")
+ yield Input(value="2", id="sign_outputs", type="integer")
+ with Horizontal(classes="form-row"):
+ yield Checkbox("Re-register (--no-cache)", id="sign_no_cache")
+
+ def _compose_sign_message(self) -> ComposeResult:
+ with Horizontal(classes="form-row"):
+ yield Label("BIP-32 path:")
+ yield Input(value="m/44'/1'/0'/0/0", id="msg_path")
+ with Horizontal(classes="form-row"):
+ yield Label("Message:")
+ yield Input(value="hello world", id="msg_text")
+
+ # ----- preset selection -------------------------------------------------
+
+ @on(Select.Changed)
+ def _on_preset_changed(self, event: Select.Changed) -> None:
+ sel_id = event.select.id
+ if not sel_id or not sel_id.endswith("_preset"):
+ return
+ tab_id = sel_id[: -len("_preset")]
+ if event.value == NO_PRESET:
+ # Picking the sentinel leaves the form alone; also clear any
+ # sign-psbt-only state so a subsequent Run doesn't apply a
+ # stale mutator or xpriv list.
+ if tab_id == "sign":
+ self._sign_psbt_mutator = None
+ self._sign_psbt_external_xprivs = []
+ return
+
+ if tab_id == "xpub":
+ preset = next((p for p in XPUB_PRESETS if p.name == event.value), None)
+ if preset is None:
+ return
+ self._apply_xpub_preset(preset)
+ return
+
+ # Policy tabs: pick from the per-tab preset list.
+ per_tab = {
+ "reg": self.REGISTER_PRESETS,
+ "addr": self.ADDR_PRESETS,
+ "sign": self.SIGN_PRESETS,
+ }.get(tab_id, [])
+ preset = next((p for p in per_tab if p.name == event.value), None)
+ if preset is None:
+ return
+
+ if tab_id == "sign":
+ self._sign_psbt_mutator = preset.psbt_mutator
+ self._sign_psbt_external_xprivs = [] # filled by resolver below
+
+ self._resolve_policy_preset(tab_id, preset)
+
+ def _apply_xpub_preset(self, preset: XpubPreset) -> None:
+ self.query_one("#xpub_path", Input).value = preset.path
+
+ @work(thread=True, exclusive=True, group="resolve")
+ def _resolve_policy_preset(self, tab_id: str, preset: PolicyPreset) -> None:
+ """Resolve `preset` against the connected device and populate fields.
+
+ Internal `KeySpec` entries trigger a get_extended_pubkey APDU and the
+ resulting xpub is wrapped with the device's master fingerprint;
+ external entries are derived locally from `EXTERNAL_MNEMONICS`. The
+ full `[fpr/origin]xpub` lines are written into the keys textarea.
+ """
+ log = self.query_one("#log", RichLog)
+ self.call_from_thread(
+ log.write, f"[dim]resolving preset {preset.name!r}...[/]"
+ )
+ chain = CHAIN_BY_NAME[self.chain]
+
+ from bitcoin_client.ledger_bitcoin import TransportClient, createClient
+
+ client = None
+ try:
+ if self.target == "speculos":
+ transport = TransportClient(
+ interface="tcp", server=self.host, port=self.port
+ )
+ else:
+ transport = TransportClient(interface="hid")
+ client = createClient(transport, chain=chain)
+ master_fpr = client.get_master_fingerprint().hex()
+
+ keys_info: List[str] = []
+ external_xprivs: List[str] = []
+ for spec in preset.keys:
+ if spec.is_external:
+ keys_info.append(
+ external_key_info(chain, spec.external_index, spec.path)
+ )
+ external_xprivs.append(
+ external_xpriv(chain, spec.external_index, spec.path)
+ )
+ else:
+ xpub = client.get_extended_pubkey(spec.path, display=False)
+ origin = spec.path.lstrip("m").lstrip("/")
+ line = f"[{master_fpr}/{origin}]{xpub}" if origin else xpub
+ keys_info.append(line)
+ except Exception as e:
+ self.call_from_thread(
+ log.write,
+ f"[red bold]preset resolution failed:[/] "
+ f"{type(e).__name__}: {e}",
+ )
+ return
+ finally:
+ if client is not None:
+ try:
+ client.stop()
+ except Exception:
+ pass
+
+ keys_text = "\n".join(keys_info)
+
+ def populate() -> None:
+ self.query_one(f"#{tab_id}_name", Input).value = preset.wallet_name
+ self.query_one(f"#{tab_id}_template", Input).value = preset.template
+ self.query_one(f"#{tab_id}_keys", TextArea).text = keys_text
+ log.write(f"[green]✓[/] preset {preset.name!r} resolved")
+
+ self.call_from_thread(populate)
+
+ if tab_id == "sign":
+ self._sign_psbt_external_xprivs = external_xprivs
+
+ # ----- run-button dispatchers -------------------------------------------
+
+ @on(Button.Pressed, "#run_fpr")
+ def _run_fpr(self) -> None:
+ self._dispatch("get-fingerprint", cmd_get_fingerprint)
+
+ @on(Button.Pressed, "#run_xpub")
+ def _run_xpub(self) -> None:
+ path = self.query_one("#xpub_path", Input).value.strip()
+ display = self.query_one("#xpub_display", Checkbox).value
+ flags = f"{path}" + (" --display" if display else "")
+ self._dispatch(f"get-xpub {flags}", cmd_get_xpub, path=path, display=display)
+
+ @on(Button.Pressed, "#run_register")
+ def _run_register(self) -> None:
+ name, template, keys = self._read_policy_form("reg")
+ flags = f'--name "{name}" --template "{template}" ({len(keys)} key(s))'
+ self._dispatch(f"register-wallet {flags}", cmd_register_wallet,
+ name=name, template=template, keys=keys)
+
+ @on(Button.Pressed, "#run_addr")
+ def _run_addr(self) -> None:
+ name, template, keys = self._read_policy_form("addr")
+ change = self.query_one("#addr_change", RadioSet).pressed_index or 0
+ index = int(self.query_one("#addr_index", Input).value or "0")
+ display = self.query_one("#addr_display", Checkbox).value
+ no_cache = self.query_one("#addr_no_cache", Checkbox).value
+ flags = (
+ f'--name "{name}" --change {change} --index {index}'
+ + (" --display" if display else "")
+ + (" --no-cache" if no_cache else "")
+ )
+ self._dispatch(f"get-address {flags}", cmd_get_address,
+ name=name, template=template, keys=keys,
+ change=change, index=index,
+ display=display, no_cache=no_cache)
+
+ @on(Button.Pressed, "#run_sign_psbt")
+ def _run_sign_psbt(self) -> None:
+ name, template, keys = self._read_policy_form("sign")
+ inputs = int(self.query_one("#sign_inputs", Input).value or "1")
+ outputs = int(self.query_one("#sign_outputs", Input).value or "2")
+ no_cache = self.query_one("#sign_no_cache", Checkbox).value
+
+ psbt_raw = self.query_one("#sign_psbt_input", TextArea).text.strip()
+ fixture: Optional[str] = None
+ cleanup_tmp: Optional[Path] = None
+
+ if not psbt_raw:
+ psbt_label = f"--inputs {inputs} --outputs {outputs}"
+ elif psbt_raw.startswith(PSBT_BASE64_PREFIX):
+ tmp_dir = Path(tempfile.gettempdir()) / "btcapp-playground"
+ tmp_dir.mkdir(parents=True, exist_ok=True)
+ tmp = tempfile.NamedTemporaryFile(
+ mode="w", delete=False, dir=tmp_dir, suffix=".psbt"
+ )
+ tmp.write(psbt_raw + "\n")
+ tmp.close()
+ fixture = tmp.name
+ cleanup_tmp = Path(tmp.name)
+ psbt_label = f"--fixture {fixture} (from pasted base64)"
+ else:
+ fixture = psbt_raw
+ psbt_label = f"--fixture {fixture}"
+
+ # Preset state: mutator only applies to *generated* PSBTs; if the user
+ # supplied a fixture, leave it alone. External xprivs flow through to
+ # musig2 e2e signing regardless.
+ psbt_mutator = self._sign_psbt_mutator if fixture is None else None
+ external_xprivs = list(self._sign_psbt_external_xprivs)
+
+ flags = (
+ f'--name "{name}" {psbt_label}'
+ + (" --no-cache" if no_cache else "")
+ + (f" [mutator={psbt_mutator.__name__}]" if psbt_mutator else "")
+ + (f" [{len(external_xprivs)} ext xpriv(s)]" if external_xprivs else "")
+ )
+ self._dispatch(f"sign-psbt {flags}", cmd_sign_psbt,
+ name=name, template=template, keys=keys,
+ fixture=fixture, inputs=inputs, outputs=outputs,
+ no_cache=no_cache,
+ psbt_mutator=psbt_mutator,
+ external_xprivs=external_xprivs,
+ _cleanup_tmp=cleanup_tmp)
+
+ @on(Button.Pressed, "#run_sign_msg")
+ def _run_sign_msg(self) -> None:
+ path = self.query_one("#msg_path", Input).value.strip()
+ message = self.query_one("#msg_text", Input).value
+ self._dispatch(f"sign-message {path} {message!r}",
+ cmd_sign_message, path=path, message=message)
+
+ # ----- policy form reader -----------------------------------------------
+
+ def _read_policy_form(self, tab_id: str) -> tuple[str, str, List[str]]:
+ """Returns `(wallet_name, descriptor_template, keys_info)` from the
+ shared name/template/keys widgets on the given policy tab.
+
+ Trims surrounding whitespace and drops blank lines from the keys
+ textarea; raises ValueError early if either of the required fields
+ is empty so the dispatcher can log a clean error instead of letting
+ cli.py's argparse crash."""
+ name = self.query_one(f"#{tab_id}_name", Input).value.strip()
+ template = self.query_one(f"#{tab_id}_template", Input).value.strip()
+ keys_text = self.query_one(f"#{tab_id}_keys", TextArea).text
+ keys = [k.strip() for k in keys_text.splitlines() if k.strip()]
+ if not template:
+ raise ValueError("descriptor template cannot be empty")
+ if not keys:
+ raise ValueError("at least one key required")
+ return name, template, keys
+
+ # ----- connection ------------------------------------------------------
+
+ def _read_conn_args(self) -> argparse.Namespace:
+ return argparse.Namespace(
+ target=self.target,
+ host=self.host,
+ port=self.port,
+ chain=self.chain,
+ debug=self.debug_flag,
+ )
+
+ def _set_status(self, text: str, *, ok: bool = False, error: bool = False) -> None:
+ if ok:
+ text = f"[green]●[/] {text}"
+ elif error:
+ text = f"[red]●[/] {text}"
+ else:
+ text = f"[yellow]●[/] {text}"
+ self.query_one("#status_bar", Static).update(text)
+
+ def on_mount(self) -> None:
+ chain_label = f"chain={self.chain}"
+ if self._target_arg in ("speculos", "hid"):
+ self._set_status(f"forced target: {self._target_arg} · {chain_label}")
+ self._probe_connection(forced=True)
+ else:
+ self._set_status(f"probing... · {chain_label}")
+ self._probe_connection(forced=False)
+
+ # ----- startup probe ---------------------------------------------------
+
+ @work(exclusive=True, thread=True, group="probe")
+ def _probe_connection(self, forced: bool) -> None:
+ log = self.query_one("#log", RichLog)
+ chain_obj = CHAIN_BY_NAME[self.chain]
+
+ def status(text: str, *, ok: bool = False, error: bool = False) -> None:
+ self.call_from_thread(self._set_status, text, ok=ok, error=error)
+
+ def write(line: str) -> None:
+ self.call_from_thread(log.write, line)
+
+ def _label(target: str) -> str:
+ if target == "speculos":
+ return f"speculos {self.host}:{self.port}"
+ return target
+
+ if forced:
+ err = _try_target(self._target_arg, self.host, self.port, chain_obj)
+ if err is None:
+ self.target = self._target_arg
+ self._connected = True
+ write(f"[green]✓[/] connected via {_label(self._target_arg)}")
+ status(f"connected · {_label(self._target_arg)} · chain={self.chain}", ok=True)
+ return
+ self._connected = False
+ write(f"[red bold]✗ {self._target_arg}:[/] {err}")
+ status(f"not connected · {self._target_arg}: {err}", error=True)
+ return
+
+ err_spec = _try_target("speculos", self.host, self.port, chain_obj)
+ if err_spec is None:
+ self.target = "speculos"
+ self._connected = True
+ write(f"[green]✓[/] connected via {_label('speculos')}")
+ status(f"connected · {_label('speculos')} · chain={self.chain}", ok=True)
+ return
+
+ err_hid = _try_target("hid", self.host, self.port, chain_obj)
+ if err_hid is None:
+ self.target = "hid"
+ self._connected = True
+ write("[green]✓[/] connected via USB Ledger device")
+ status(f"connected · hid · chain={self.chain}", ok=True)
+ return
+
+ self.target = "speculos"
+ self._connected = False
+ write("[red bold]✗ no Bitcoin app found[/]")
+ write(f" speculos: {err_spec}")
+ write(f" hid: {err_hid}")
+ write(
+ "[yellow]Open the Bitcoin (Test) app on the device or start speculos, "
+ "then press Ctrl+R to reconnect.[/]"
+ )
+ status("not connected · open the Bitcoin app and press Ctrl+R", error=True)
+
+ # ----- dispatch + worker -----------------------------------------------
+
+ def _dispatch(self, label: str, cmd_func: Callable, *,
+ _cleanup_tmp: Optional[Path] = None,
+ **overrides) -> None:
+ try:
+ args = self._read_conn_args()
+ except Exception as e:
+ self._log_error(f"bad connection settings: {e}")
+ return
+ try:
+ for k, v in overrides.items():
+ setattr(args, k, v)
+ except Exception as e:
+ self._log_error(str(e))
+ return
+ self._run_in_worker(label, cmd_func, args, _cleanup_tmp)
+
+ @work(exclusive=True, thread=True, group="cmd")
+ def _run_in_worker(self, label: str, cmd_func: Callable,
+ args: argparse.Namespace,
+ cleanup_tmp: Optional[Path]) -> None:
+ log = self.query_one("#log", RichLog)
+ self.call_from_thread(log.write, f"[bold cyan]$ playground {label}[/]")
+
+ old_out, old_err = sys.stdout, sys.stderr
+ sys.stdout = _LogStream(self, log, is_err=False)
+ sys.stderr = _LogStream(self, log, is_err=True)
+ try:
+ cmd_func(args)
+ except SystemExit as e:
+ if str(e) not in ("", "0", "None"):
+ self.call_from_thread(log.write, f"[red bold]error:[/] {e}")
+ except KeyboardInterrupt:
+ self.call_from_thread(log.write, "[yellow]interrupted[/]")
+ except Exception as e:
+ self.call_from_thread(
+ log.write, f"[red bold]error:[/] {type(e).__name__}: {e}"
+ )
+ for line in traceback.format_exc().rstrip("\n").split("\n"):
+ self.call_from_thread(log.write, f"[red dim]{line}[/]")
+ finally:
+ sys.stdout.flush() # type: ignore[union-attr]
+ sys.stderr.flush() # type: ignore[union-attr]
+ sys.stdout, sys.stderr = old_out, old_err
+ if cleanup_tmp is not None:
+ try:
+ cleanup_tmp.unlink(missing_ok=True)
+ except OSError:
+ pass
+ self.call_from_thread(log.write, "")
+
+ def _log_error(self, msg: str) -> None:
+ self.query_one("#log", RichLog).write(f"[red bold]error:[/] {msg}")
+ self.query_one("#log", RichLog).write("")
+
+ # ----- right-click paste -----------------------------------------------
+
+ def on_click(self, event: events.Click) -> None:
+ """Right-click on an Input/TextArea pastes from the OS clipboard.
+
+ Textual captures mouse events, so the terminal's native right-click
+ paste never reaches us. We re-implement the gesture: read the
+ clipboard via xclip/xsel/wl-paste/pbpaste in a worker thread and
+ insert the text at the focused widget's cursor.
+ """
+ if event.button != 3:
+ return
+ widget = self.focused
+ if not isinstance(widget, (Input, TextArea)):
+ return
+ self._paste_clipboard(widget)
+
+ @work(thread=True, exclusive=True, group="paste")
+ def _paste_clipboard(self, widget) -> None:
+ text = _read_clipboard()
+ if not text:
+ return
+ if isinstance(widget, Input):
+ text = text.replace("\r", "").replace("\n", " ")
+ self.call_from_thread(widget.insert_text_at_cursor, text)
+ else:
+ self.call_from_thread(widget.insert, text)
+
+ # ----- actions ----------------------------------------------------------
+
+ def action_clear_log(self) -> None:
+ self.query_one("#log", RichLog).clear()
+
+ def action_reconnect(self) -> None:
+ self._set_status(f"probing... · chain={self.chain}")
+ self._probe_connection(forced=(self._target_arg in ("speculos", "hid")))
+
+
+def _parse_gui_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ prog="playground-gui",
+ description="Bitcoin app playground TUI.",
+ )
+ p.add_argument(
+ "--target", choices=["auto", "speculos", "hid"], default="auto",
+ help=(
+ "connection target. 'auto' (default) probes speculos first, then "
+ "USB; 'speculos' or 'hid' force that target."
+ ),
+ )
+ p.add_argument("--host", default="127.0.0.1",
+ help="speculos host (default: 127.0.0.1)")
+ p.add_argument("--port", type=int, default=9999,
+ help="speculos APDU port (default: 9999)")
+ p.add_argument("--chain", choices=list(CHAIN_BY_NAME), default="test",
+ help="Bitcoin network (default: test)")
+ p.add_argument("--debug", action="store_true",
+ help="dump APDUs on stderr")
+ return p.parse_args(argv)
+
+
+def main(argv: Optional[List[str]] = None) -> None:
+ args = _parse_gui_args(argv)
+ PlaygroundApp(
+ target=args.target,
+ host=args.host,
+ port=args.port,
+ chain=args.chain,
+ debug=args.debug,
+ ).run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dev-tools/playground/presets.py b/dev-tools/playground/presets.py
new file mode 100644
index 0000000..5a45d56
--- /dev/null
+++ b/dev-tools/playground/presets.py
@@ -0,0 +1,312 @@
+"""TUI-only presets for the Bitcoin app playground.
+
+A preset prefills the form fields of one TUI tab. Selecting a preset never
+involves the device unless the preset includes a device-derived key, in
+which case the TUI eagerly queries the device to resolve the
+`[fpr/path]xpub` before populating the keys textarea — so the user always
+sees the concrete policy they're about to send.
+
+Two flavors:
+
+- `XpubPreset` — a BIP-32 path. Fills the path field on the get-xpub tab.
+
+- `PolicyPreset` — a complete wallet policy (descriptor template + per-`@N`
+ `KeySpec`). Fills the wallet-name / template / keys fields on the
+ register-wallet / get-address / sign-psbt tabs. For sign-psbt only, an
+ optional `psbt_mutator` is applied to the generated fake PSBT just
+ before signing — used for scenario presets like "huge-fee".
+
+The CLI does not use presets: it accepts a fully-resolved policy via
+inline `--template` / `--key` / `--name` flags.
+"""
+
+import json
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, List, Optional
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from bitcoin_client.ledger_bitcoin import Chain # noqa: E402
+from bitcoin_client.ledger_bitcoin.psbt import PSBT # noqa: E402
+
+
+REPO_ROOT: Path = _REPO_ROOT
+
+
+# Fixed test mnemonics used to derive external-cosigner xpubs. Pinning the
+# mnemonics keeps each policy id stable across runs, which keeps the
+# registration HMAC cache valid.
+EXTERNAL_MNEMONICS: List[str] = [
+ "all all all all all all all all all all all all",
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ "legal winner thank year wave sausage worth useful legal winner thank yellow",
+]
+
+
+@dataclass(frozen=True)
+class KeySpec:
+ """How to source one `@N` key when resolving a policy preset against the device."""
+ path: str
+ external_index: Optional[int] = None # None = device; else EXTERNAL_MNEMONICS[index]
+
+ @property
+ def is_external(self) -> bool:
+ return self.external_index is not None
+
+
+def _external_master(chain: Chain, index: int):
+ """Build a `python-bip32` master key from EXTERNAL_MNEMONICS[index]."""
+ from bip32 import BIP32
+ from mnemonic import Mnemonic
+
+ if not (0 <= index < len(EXTERNAL_MNEMONICS)):
+ raise ValueError(
+ f"external_index {index} out of range; "
+ f"{len(EXTERNAL_MNEMONICS)} mnemonics available"
+ )
+
+ seed = Mnemonic("english").to_seed(EXTERNAL_MNEMONICS[index])
+ network = "main" if chain == Chain.MAIN else "test"
+ return BIP32.from_seed(seed, network=network)
+
+
+def external_key_info(chain: Chain, index: int, path: str) -> str:
+ """Build a BIP-388 key-info string `[fpr/path]xpub` for the external
+ cosigner at `index`, derived from EXTERNAL_MNEMONICS[index]."""
+ from bitcoin_client.ledger_bitcoin.common import hash160
+
+ master = _external_master(chain, index)
+ master_fpr = hash160(master.pubkey)[:4].hex()
+ xpub = master.get_xpub_from_path(path)
+
+ origin = path.lstrip("m").lstrip("/")
+ return f"[{master_fpr}/{origin}]{xpub}" if origin else xpub
+
+
+def external_xpriv(chain: Chain, index: int, path: str) -> str:
+ """Returns the base58-serialized xpriv at `path` derived from
+ EXTERNAL_MNEMONICS[index]. Used by the musig2 cosigner to participate
+ in both rounds of the protocol with its own private key."""
+ return _external_master(chain, index).get_xpriv_from_path(path)
+
+
+# ----- get-xpub presets ------------------------------------------------------
+
+@dataclass(frozen=True)
+class XpubPreset:
+ name: str
+ path: str
+ description: str = ""
+
+
+XPUB_PRESETS: List[XpubPreset] = [
+ XpubPreset("bip44-account0", "m/44'/1'/0'", "BIP-44 legacy (P2PKH), first account"),
+ XpubPreset("bip49-account0", "m/49'/1'/0'", "BIP-49 wrapped segwit (P2SH-P2WPKH), first account"),
+ XpubPreset("bip84-account0", "m/84'/1'/0'", "BIP-84 native segwit (P2WPKH), first account"),
+ XpubPreset("bip86-account0", "m/86'/1'/0'", "BIP-86 taproot (P2TR), first account"),
+ XpubPreset("multisig-account0", "m/48'/1'/0'/2'", "BIP-48 multisig (native segwit), first account"),
+ # The Bitcoin app caps explicit derivation depth at 8 steps (m/.../.../.../.../.../.../.../...).
+ XpubPreset(
+ "deepest-allowed",
+ "m/48'/1'/0'/2'/0/0/0/0",
+ "Maximum derivation depth the app accepts (8 steps)",
+ ),
+]
+
+
+# ----- wallet-policy presets -------------------------------------------------
+
+@dataclass
+class PolicyPreset:
+ name: str
+ description: str
+ wallet_name: str # WalletPolicy.name; "" => standard, no registration
+ template: str
+ keys: List[KeySpec]
+ # sign-psbt only: optional mutator applied to the generated fake PSBT.
+ # Ignored on the register-wallet and get-address tabs.
+ psbt_mutator: Optional[Callable[[PSBT], PSBT]] = None
+
+ @property
+ def needs_registration(self) -> bool:
+ return self.wallet_name != ""
+
+
+POLICY_PRESETS: List[PolicyPreset] = [
+ # --- standard singlesig (no registration) -------------------------------
+ PolicyPreset(
+ name="pkh-singlesig",
+ description="BIP-44 legacy single-sig (P2PKH)",
+ wallet_name="",
+ template="pkh(@0/**)",
+ keys=[KeySpec("m/44'/1'/0'")],
+ ),
+ PolicyPreset(
+ name="sh-wpkh-singlesig",
+ description="BIP-49 wrapped-segwit single-sig (P2SH-P2WPKH)",
+ wallet_name="",
+ template="sh(wpkh(@0/**))",
+ keys=[KeySpec("m/49'/1'/0'")],
+ ),
+ PolicyPreset(
+ name="wpkh-singlesig",
+ description="BIP-84 native-segwit single-sig (P2WPKH)",
+ wallet_name="",
+ template="wpkh(@0/**)",
+ keys=[KeySpec("m/84'/1'/0'")],
+ ),
+ PolicyPreset(
+ name="tr-singlesig",
+ description="BIP-86 taproot single-sig (P2TR)",
+ wallet_name="",
+ template="tr(@0/**)",
+ keys=[KeySpec("m/86'/1'/0'")],
+ ),
+
+ # --- multisig (1 device + 1 external) -----------------------------------
+ PolicyPreset(
+ name="multisig-2of2-wsh",
+ description="2-of-2 native-segwit sortedmulti (1 device + 1 external cosigner)",
+ wallet_name="Test multisig",
+ template="wsh(sortedmulti(2,@0/**,@1/**))",
+ keys=[
+ KeySpec("m/48'/1'/0'/2'"), # device
+ KeySpec("m/48'/1'/0'/2'", external_index=0), # external
+ ],
+ ),
+
+ # --- miniscript ---------------------------------------------------------
+ PolicyPreset(
+ name="miniscript-or",
+ description="Miniscript: device OR external — wsh(or_d(pk(@0/**),pkh(@1/**)))",
+ wallet_name="Joint account",
+ template="wsh(or_d(pk(@0/**),pkh(@1/**)))",
+ keys=[
+ KeySpec("m/48'/1'/0'/2'"),
+ KeySpec("m/48'/1'/0'/2'", external_index=0),
+ ],
+ ),
+ PolicyPreset(
+ name="miniscript-2fa-with-fallback",
+ description=(
+ "Miniscript 2FA with timelock fallback: device AND "
+ "(external OR after 12960 blocks)"
+ ),
+ wallet_name="2FA with fallback",
+ template="wsh(and_v(v:pk(@0/**),or_d(pk(@1/**),older(12960))))",
+ keys=[
+ KeySpec("m/48'/1'/0'/2'"),
+ KeySpec("m/48'/1'/0'/2'", external_index=0),
+ ],
+ ),
+
+ # --- musig2 -------------------------------------------------------------
+ PolicyPreset(
+ name="musig2-keypath",
+ description="MuSig2 at the taproot key-path with 2 aggregated keys (1 device + 1 external)",
+ wallet_name="Musig 2 my ears",
+ template="tr(musig(@0,@1)/**)",
+ keys=[
+ KeySpec("m/48'/1'/0'/2'"),
+ KeySpec("m/48'/1'/0'/2'", external_index=0),
+ ],
+ ),
+]
+
+
+# ----- sign-psbt PSBT mutators ----------------------------------------------
+
+def _huge_fee_mutator(psbt: PSBT) -> PSBT:
+ """Reduce every output value to dust so that almost the entire input
+ value becomes fee — exercises the device's high-fee warning UX."""
+ DUST = 1000
+ for vout in psbt.tx.vout:
+ vout.nValue = DUST
+ return psbt
+
+
+def _zero_outputs_mutator(psbt: PSBT) -> PSBT:
+ """Set every output value to zero — exercises the device's zero-amount
+ handling and (via the resulting massive fee) the high-fee warning."""
+ for vout in psbt.tx.vout:
+ vout.nValue = 0
+ return psbt
+
+
+# Sign-psbt-only scenario presets. These reuse simple wallet policies but
+# mutate the generated fake PSBT to put the device in an interesting state.
+SIGN_PSBT_SCENARIO_PRESETS: List[PolicyPreset] = [
+ PolicyPreset(
+ name="huge-fee-wpkh",
+ description="wpkh single-sig with all outputs forced to dust — should trigger the high-fee warning",
+ wallet_name="",
+ template="wpkh(@0/**)",
+ keys=[KeySpec("m/84'/1'/0'")],
+ psbt_mutator=_huge_fee_mutator,
+ ),
+]
+
+
+def sign_psbt_presets() -> List[PolicyPreset]:
+ """Presets shown on the sign-psbt tab: every wallet-policy preset (no
+ mutator) plus the scenario presets (with mutators)."""
+ return list(POLICY_PRESETS) + list(SIGN_PSBT_SCENARIO_PRESETS)
+
+
+# ----- registration cache ---------------------------------------------------
+
+_CACHE_DIR = Path(tempfile.gettempdir()) / "btcapp-playground"
+_CACHE_FILE = _CACHE_DIR / "wallets.json"
+
+
+def _load_cache() -> dict:
+ if not _CACHE_FILE.exists():
+ return {}
+ try:
+ return json.loads(_CACHE_FILE.read_text())
+ except (OSError, json.JSONDecodeError):
+ return {}
+
+
+def _save_cache(data: dict) -> None:
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ _CACHE_FILE.write_text(json.dumps(data, indent=2))
+
+
+def _cache_key(master_fpr_hex: str, wallet_id_hex: str) -> str:
+ return f"{master_fpr_hex}:{wallet_id_hex}"
+
+
+def cached_registration(master_fpr_hex: str, wallet_id: bytes) -> Optional[bytes]:
+ """Look up a cached HMAC for `(master_fpr, wallet_id)`.
+
+ The cache is keyed on the wallet id (a hash of the policy itself), so it
+ is automatically invalidated whenever the user changes a template/key —
+ no preset name needed.
+ """
+ entry = _load_cache().get(_cache_key(master_fpr_hex, wallet_id.hex()))
+ if not entry:
+ return None
+ try:
+ return bytes.fromhex(entry["hmac"])
+ except (KeyError, ValueError):
+ return None
+
+
+def store_registration(
+ master_fpr_hex: str, wallet_id: bytes, wallet_hmac: bytes
+) -> None:
+ data = _load_cache()
+ data[_cache_key(master_fpr_hex, wallet_id.hex())] = {
+ "hmac": wallet_hmac.hex(),
+ }
+ _save_cache(data)
+
+
+def cache_file_path() -> Path:
+ return _CACHE_FILE
diff --git a/dev-tools/playground/requirements.txt b/dev-tools/playground/requirements.txt
new file mode 100644
index 0000000..266d7ac
--- /dev/null
+++ b/dev-tools/playground/requirements.txt
@@ -0,0 +1,7 @@
+# Direct Python dependencies for the Bitcoin app playground (CLI + TUI).
+# See dev-tools/playground/README.md for full setup instructions.
+
+bip32>=3.4,<4.0 # external-cosigner xpub derivation
+mnemonic==0.20 # BIP-39 mnemonic -> seed
+embit>=0.7.0,<0.8.0 # used by test_utils.txmaker (fake-PSBT generator)
+textual>=0.50 # TUI front-end (dev-tools/playground/gui.py)
Why this scored 15/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.