Add to playground presets for sighash flags, and for external inputs
What changed, and why it matters
This commit only adds new developer-testing presets and a declarative helper for the project's internal 'playground' dev tool. It does not change the Ledger Bitcoin app firmware, wallet logic, or any code that end users rely on. There is no security fix or vulnerability introduced here.
No security action required. Treat as a normal development-tooling enhancement.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff extends dev-tools/playground (a CLI/GUI test harness) with a PsbtSpec dataclass and build_psbt_from_spec() factory so scenario presets can describe fake PSBTs declaratively (inputs, outputs, external flags, sighash bytes, fee) instead of only via imperative mutators. It converts the existing ‘huge-fee-wpkh’ preset from a mutator to a spec and adds new presets for external-inputs net-send/receive and non-default sighash types. The changes are confined to playground tooling; no firmware, client library, or production signing flow is modified.
Changed components
dev-tools/playground/presets.pydev-tools/playground/cli.pydev-tools/playground/gui.pydev-tools/playground/README.mdInspect captured patch +280 / −40
diff --git a/dev-tools/playground/README.md b/dev-tools/playground/README.md
index 6f585bd..bf34cbe 100644
--- a/dev-tools/playground/README.md
+++ b/dev-tools/playground/README.md
@@ -78,11 +78,19 @@ fields:
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.
+ `huge-fee-wpkh`, `external-inputs-net-receive`): same wallet-policy form,
+ plus a way to shape the auto-generated fake PSBT into an interesting state
+ before signing. Two mechanisms:
+ - a declarative **`PsbtSpec`** (preferred, used by both presets above) —
+ describes the inputs and outputs as data, including *external* inputs the
+ wallet can't sign and the exact fee; the spec defines its own input/output
+ set, so the "Generated inputs/outputs" fields are ignored;
+ - a **`psbt_mutator`** escape hatch — a Python callable `(psbt) -> psbt`
+ that tweaks the generated PSBT, for the rare tweak a spec can't express
+ (no preset currently needs it).
+ When a preset carries both, the spec builds the PSBT and the mutator tweaks
+ it. Both only run when the PSBT textarea is empty; if you paste a fixture
+ they're ignored.
Pick `(no preset)` to keep editing the fields by hand.
@@ -159,9 +167,14 @@ Edit [presets.py](presets.py) and append to the relevant list:
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.
+- `SIGN_PSBT_SCENARIO_PRESETS`: a `PolicyPreset` that shapes the
+ auto-generated fake PSBT with a declarative `psbt_spec=PsbtSpec(...)`
+ describing the inputs (`PsbtInputSpec(amount, external=...)`), the outputs
+ (`PsbtOutputSpec(amount=..., is_change=...)`), and the exact `fee`. Leave one
+ output's `amount` unset to make it absorb the remainder. Keep at least one
+ internal (non-`external`) input so the device has something to sign. For the
+ rare tweak a spec can't express, `psbt_mutator=(psbt) -> psbt` is available
+ as an escape hatch (applied on top of the spec).
`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
diff --git a/dev-tools/playground/cli.py b/dev-tools/playground/cli.py
index eb2b115..a00cfdb 100644
--- a/dev-tools/playground/cli.py
+++ b/dev-tools/playground/cli.py
@@ -50,6 +50,7 @@ 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
+ build_psbt_from_spec,
cache_file_path,
cached_registration,
store_registration,
@@ -313,9 +314,15 @@ def cmd_sign_psbt(args: argparse.Namespace) -> None:
master_fpr = client.get_master_fingerprint().hex()
hmac = ensure_registered(client, master_fpr, policy, refresh=args.no_cache)
+ psbt_spec = getattr(args, "psbt_spec", None)
if args.fixture:
print(f"[psbt] loading {args.fixture}", file=sys.stderr)
psbt = load_psbt(Path(args.fixture))
+ elif psbt_spec is not None:
+ # TUI-only: a declarative PSBT shape (defines its own inputs/outputs,
+ # so --inputs/--outputs are ignored). Not exposed via argparse.
+ print("[psbt] building PSBT from preset spec", file=sys.stderr)
+ psbt = build_psbt_from_spec(policy, psbt_spec)
else:
print(
f"[psbt] generating fake PSBT ({args.inputs} in / {args.outputs} out)...",
@@ -323,12 +330,13 @@ def cmd_sign_psbt(args: argparse.Namespace) -> None:
)
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)
+ # TUI-only hook: a callable that tweaks the generated PSBT before
+ # signing (escape hatch; composes on top of a spec). Not exposed via
+ # argparse, and never applied to a loaded fixture.
+ mutator = getattr(args, "psbt_mutator", None)
+ if mutator is not None and not args.fixture:
+ 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)
diff --git a/dev-tools/playground/gui.py b/dev-tools/playground/gui.py
index bf21507..6b1349d 100644
--- a/dev-tools/playground/gui.py
+++ b/dev-tools/playground/gui.py
@@ -305,6 +305,7 @@ class PlaygroundApp(App):
# 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_spec: Optional[Any] = None
self._sign_psbt_mutator: Optional[Callable[[Any], Any]] = None
self._sign_psbt_external_xprivs: List[str] = []
@@ -438,8 +439,9 @@ class PlaygroundApp(App):
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.
+ # stale spec, mutator or xpriv list.
if tab_id == "sign":
+ self._sign_psbt_spec = None
self._sign_psbt_mutator = None
self._sign_psbt_external_xprivs = []
return
@@ -462,6 +464,7 @@ class PlaygroundApp(App):
return
if tab_id == "sign":
+ self._sign_psbt_spec = preset.psbt_spec
self._sign_psbt_mutator = preset.psbt_mutator
self._sign_psbt_external_xprivs = [] # filled by resolver below
@@ -589,7 +592,12 @@ class PlaygroundApp(App):
cleanup_tmp: Optional[Path] = None
if not psbt_raw:
- psbt_label = f"--inputs {inputs} --outputs {outputs}"
+ # A preset spec defines its own inputs/outputs, so --inputs/--outputs
+ # are ignored in that case.
+ psbt_label = (
+ "from preset spec" if self._sign_psbt_spec is not None
+ else 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)
@@ -605,15 +613,17 @@ class PlaygroundApp(App):
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.
+ # Preset state: the spec/mutator only apply to *generated* PSBTs; if the
+ # user supplied a fixture, leave it alone. External xprivs flow through
+ # to musig2 e2e signing regardless.
+ psbt_spec = self._sign_psbt_spec if fixture is None else None
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 "")
+ + (" [spec]" if psbt_spec else "")
+ (f" [mutator={psbt_mutator.__name__}]" if psbt_mutator else "")
+ (f" [{len(external_xprivs)} ext xpriv(s)]" if external_xprivs else "")
)
@@ -621,6 +631,7 @@ class PlaygroundApp(App):
name=name, template=template, keys=keys,
fixture=fixture, inputs=inputs, outputs=outputs,
no_cache=no_cache,
+ psbt_spec=psbt_spec,
psbt_mutator=psbt_mutator,
external_xprivs=external_xprivs,
_cleanup_tmp=cleanup_tmp)
diff --git a/dev-tools/playground/presets.py b/dev-tools/playground/presets.py
index 084526e..b4205b6 100644
--- a/dev-tools/playground/presets.py
+++ b/dev-tools/playground/presets.py
@@ -12,9 +12,11 @@ Two flavors:
- `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".
+ register-wallet / get-address / sign-psbt tabs. For sign-psbt only, the
+ fake PSBT to sign can be shaped in two ways: a declarative `PsbtSpec`
+ (preferred — describes inputs/outputs, including external inputs, as data)
+ or a `psbt_mutator` escape hatch (a callable that tweaks the generated
+ PSBT). When both are set, the spec builds it and the mutator tweaks it.
The CLI does not use presets: it accepts a fully-resolved policy via
inline `--template` / `--key` / `--name` flags.
@@ -119,6 +121,116 @@ XPUB_PRESETS: List[XpubPreset] = [
]
+# ----- declarative sign-psbt shapes -----------------------------------------
+
+# SIGHASH flag bytes. The low bits pick the base type; ANYONECANPAY (0x80) is a
+# modifier OR-ed on top (e.g. SIGHASH_ALL | SIGHASH_ANYONECANPAY == 0x81). The
+# app rejects a bare 0x80 and SIGHASH_DEFAULT (0x00) on segwitv0, so always OR
+# ANYONECANPAY with a base type. Signing a non-default sighash also needs the
+# device's "non-standard sighash" setting enabled.
+SIGHASH_ALL = 0x01
+SIGHASH_NONE = 0x02
+SIGHASH_SINGLE = 0x03
+SIGHASH_ANYONECANPAY = 0x80
+
+
+@dataclass(frozen=True)
+class PsbtInputSpec:
+ """One input of a `PsbtSpec`."""
+ amount: int
+ external: bool = False # True => a foreign input the wallet can't sign
+ sighash: Optional[int] = None # None => default; else a SIGHASH_* byte (PSBT_IN_SIGHASH_TYPE)
+
+
+@dataclass(frozen=True)
+class PsbtOutputSpec:
+ """One output of a `PsbtSpec`. `amount=None` marks the single "remainder"
+ output that absorbs whatever value is left after the explicit outputs and
+ the fee — set it on the change output to produce a net receive."""
+ amount: Optional[int] = None
+ is_change: bool = False # True => wallet change (internal)
+
+
+@dataclass(frozen=True)
+class PsbtSpec:
+ """A declarative description of the fake PSBT a sign-psbt preset wants.
+
+ Turned into a `test_utils.txmaker.createPsbt` call by `build_psbt_from_spec`.
+ Prefer this over a `psbt_mutator` for anything that is really about the
+ *shape* of the transaction (which inputs are external, the amounts, which
+ output is change). Keep at least one internal input so the device signs.
+
+ `fee` is the exact fee in satoshis. Because a transaction must balance
+ (inputs = outputs + fee), you control the fee by leaving one output's
+ `amount` unset (the "remainder"): it becomes `total_in - explicit - fee`.
+ So a large `fee` with a small remainder gives a high-fee transaction, and a
+ small `fee` with a change remainder gives a net receive. If every output has
+ an explicit amount there is no free variable, so `fee` must equal
+ `total_in - sum(outputs)` exactly (else it's a mistake and we raise).
+ """
+ inputs: List[PsbtInputSpec]
+ outputs: List[PsbtOutputSpec]
+ fee: int = 1000
+
+
+def build_psbt_from_spec(policy, spec: PsbtSpec) -> PSBT:
+ """Build a fake PSBT for `policy` matching `spec`, via the shared factory.
+
+ At most one output may leave `amount` unset ("remainder"): it receives
+ `total_in - sum(explicit outputs) - spec.fee`. All other amounts are taken
+ verbatim. `spec.fee` is always honored — via the remainder if there is one,
+ otherwise as a checked invariant against `total_in - sum(outputs)`. A
+ per-input `sighash` is copied onto the built PSBT input.
+ """
+ from test_utils.txmaker import createPsbt # local import (heavy)
+
+ input_amounts = [i.amount for i in spec.inputs]
+ input_is_external = [i.external for i in spec.inputs]
+ total_in = sum(input_amounts)
+
+ remainder_indices = [n for n, o in enumerate(spec.outputs) if o.amount is None]
+ if len(remainder_indices) > 1:
+ raise ValueError("at most one output may omit `amount` (the remainder)")
+ explicit_total = sum(o.amount for o in spec.outputs if o.amount is not None)
+
+ if remainder_indices:
+ remainder = total_in - explicit_total - spec.fee
+ if remainder < 0:
+ raise ValueError(
+ f"remainder is negative: inputs ({total_in}) too small for "
+ f"explicit outputs ({explicit_total}) + fee ({spec.fee})"
+ )
+ output_amounts = [
+ remainder if o.amount is None else o.amount for o in spec.outputs
+ ]
+ else:
+ # No free variable: the fee is fully determined by inputs - outputs, so
+ # the declared fee must match (guards against a silently-ignored fee).
+ implied_fee = total_in - explicit_total
+ if implied_fee < 0:
+ raise ValueError(
+ f"outputs ({explicit_total}) exceed inputs ({total_in})"
+ )
+ if spec.fee != implied_fee:
+ raise ValueError(
+ f"fee={spec.fee} disagrees with inputs - outputs ({implied_fee}); "
+ f"leave one output's amount unset (the remainder) to let the fee "
+ f"take effect, or set fee={implied_fee}"
+ )
+ output_amounts = [o.amount for o in spec.outputs]
+ output_is_change = [o.is_change for o in spec.outputs]
+
+ psbt = createPsbt(
+ policy, input_amounts, output_amounts, output_is_change, input_is_external
+ )
+
+ for i, inp in enumerate(spec.inputs):
+ if inp.sighash is not None:
+ psbt.inputs[i].sighash = inp.sighash
+
+ return psbt
+
+
# ----- wallet-policy presets -------------------------------------------------
@dataclass
@@ -128,8 +240,11 @@ class PolicyPreset:
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.
+ # sign-psbt only; both are ignored on the register-wallet / get-address tabs.
+ # `psbt_spec` declaratively describes the fake PSBT to generate (preferred).
+ # `psbt_mutator` is an escape hatch that tweaks the generated PSBT after the
+ # fact; when both are set, the spec builds it and the mutator tweaks it.
+ psbt_spec: Optional[PsbtSpec] = None
psbt_mutator: Optional[Callable[[PSBT], PSBT]] = None
@property
@@ -219,34 +334,127 @@ POLICY_PRESETS: List[PolicyPreset] = [
]
-# ----- sign-psbt PSBT mutators ----------------------------------------------
+# ----- sign-psbt scenario presets -------------------------------------------
-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
-
-
-# Sign-psbt-only scenario presets. These reuse simple wallet policies but
-# mutate the generated fake PSBT to put the device in an interesting state.
+# These reuse simple wallet policies but shape the generated fake PSBT (via a
+# declarative `psbt_spec`) to put the device in an interesting state. The
+# `psbt_mutator` escape hatch on `PolicyPreset` is available for tweaks a spec
+# can't express, but no preset currently needs it.
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",
+ description="wpkh single-sig paying almost the entire input as fee — should trigger the high-fee warning",
+ wallet_name="",
+ template="wpkh(@0/**)",
+ keys=[KeySpec("m/84'/1'/0'")],
+ psbt_spec=PsbtSpec(
+ inputs=[PsbtInputSpec(100_000_000)],
+ # The remainder recipient gets total_in - fee = 1000 (dust); the
+ # ~1 BTC fee dwarfs it -> high-fee warning.
+ outputs=[PsbtOutputSpec()],
+ fee=99_999_000,
+ ),
+ ),
+ PolicyPreset(
+ name="external-inputs-net-send",
+ description=(
+ "tr single-sig with a large external input, most value sent out — "
+ "external-inputs warning + net-send display"
+ ),
+ wallet_name="",
+ template="tr(@0/**)",
+ keys=[KeySpec("m/86'/1'/0'")],
+ psbt_spec=PsbtSpec(
+ inputs=[
+ PsbtInputSpec(100_000_000), # internal (the wallet signs this)
+ PsbtInputSpec(300_000_000, external=True), # external (foreign, unsigned)
+ ],
+ outputs=[
+ PsbtOutputSpec(), # recipient absorbs the rest
+ # change < internal inputs (100M) => net send
+ PsbtOutputSpec(amount=50_000_000, is_change=True),
+ ],
+ ),
+ ),
+ PolicyPreset(
+ name="external-inputs-net-receive",
+ description=(
+ "tr single-sig with a large external input routed to change — "
+ "external-inputs warning + net-receive display"
+ ),
+ wallet_name="",
+ template="tr(@0/**)",
+ keys=[KeySpec("m/86'/1'/0'")],
+ psbt_spec=PsbtSpec(
+ inputs=[
+ PsbtInputSpec(100_000_000), # internal (the wallet signs this)
+ PsbtInputSpec(300_000_000, external=True), # external (foreign, unsigned)
+ ],
+ outputs=[
+ PsbtOutputSpec(amount=10_000), # small recipient
+ PsbtOutputSpec(is_change=True), # change absorbs the rest => net receive
+ ],
+ ),
+ ),
+
+ # --- non-default sighash types (need the device's non-standard-sighash setting) ---
+ PolicyPreset(
+ name="sighash-anyonecanpay",
+ description=(
+ "wpkh single-sig, every input with ALL | ANYONECANPAY"
+ ),
+ wallet_name="",
+ template="wpkh(@0/**)",
+ keys=[KeySpec("m/84'/1'/0'")],
+ psbt_spec=PsbtSpec(
+ inputs=[
+ PsbtInputSpec(100_000_000, sighash=SIGHASH_ALL | SIGHASH_ANYONECANPAY),
+ PsbtInputSpec(50_000_000, sighash=SIGHASH_ALL | SIGHASH_ANYONECANPAY),
+ ],
+ outputs=[
+ PsbtOutputSpec(amount=120_000_000), # recipient
+ PsbtOutputSpec(is_change=True), # change
+ ],
+ ),
+ ),
+ PolicyPreset(
+ name="sighash-none",
+ description=("wpkh single-sig, every input with NONE (0x02)"),
wallet_name="",
template="wpkh(@0/**)",
keys=[KeySpec("m/84'/1'/0'")],
- psbt_mutator=_huge_fee_mutator,
+ psbt_spec=PsbtSpec(
+ inputs=[
+ PsbtInputSpec(100_000_000, sighash=SIGHASH_NONE),
+ PsbtInputSpec(50_000_000, sighash=SIGHASH_NONE),
+ ],
+ outputs=[
+ PsbtOutputSpec(amount=120_000_000), # recipient
+ PsbtOutputSpec(is_change=True), # change
+ ],
+ ),
+ ),
+ PolicyPreset(
+ name="sighash-single-anyonecanpay-1in1out",
+ description=("wpkh single-sig, 1-in-1-out, SINGLE|ANYONECANPAY"),
+ wallet_name="",
+ template="wpkh(@0/**)",
+ keys=[KeySpec("m/84'/1'/0'")],
+ psbt_spec=PsbtSpec(
+ inputs=[
+ PsbtInputSpec(100_000_000, sighash=SIGHASH_SINGLE | SIGHASH_ANYONECANPAY),
+ ],
+ outputs=[
+ PsbtOutputSpec(amount=120_000_000, is_change=True),
+ ],
+ ),
),
]
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)."""
+ """Presets shown on the sign-psbt tab: every wallet-policy preset plus the
+ scenario presets."""
return list(POLICY_PRESETS) + list(SIGN_PSBT_SCENARIO_PRESETS)
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.