diff --git a/docs/miniscript-reference/README.md b/docs/miniscript-reference/README.md
new file mode 100644
index 0000000..f1a5d53
--- /dev/null
+++ b/docs/miniscript-reference/README.md
@@ -0,0 +1,43 @@
+> [!WARNING]
+> **BITME-ONLY REFERENCE MATERIAL:** The policies and flows shown in this file (e.g., exactly one immediate path, server-side hold/veto) belong to Bitme, NOT Keeper.
+> Keeper's actual wallet policy (including Miniscript inheritance paths) and server constraints differ. Any future implementation or tests for Keeper must use the actual Keeper Miniscript policy.
+
+# Miniscript Coordinator Pattern — Reference Implementation
+
+This directory contains reference material for the coordinator-side Miniscript approach, adapted from [TDH-Labs/Bitme](https://github.com/TDH-Labs/Bitme).
+
+## The Pattern
+
+Hardware wallets that don't natively understand Miniscript (Satochip, TAPSIGNER, Jade, Coldcard) can still participate in Miniscript wallets:
+
+1. **Coordinator** (wallet app) parses the Miniscript descriptor
+2. **Coordinator** determines which spending path applies
+3. **Coordinator** constructs the PSBT with the correct `witness_script` for that path
+4. **Hardware** signs the sighash digest (just a hash — no policy understanding needed)
+5. **Coordinator** collects signatures and finalizes
+
+The hardware never needs to understand the policy. It just signs what the coordinator gives it.
+
+## Files
+
+- **`invariants-reference.rs`**: Formal Miniscript invariant tests using rust-miniscript's `entails` algorithm. Proves:
+ - No single key can spend alone
+ - Intended signer combinations can spend (immediately or after timelock)
+ - Timelock boundaries hold exactly (not one block early)
+
+ Adapted from Bitme's `bitme-cosigner/src/invariants.rs`. Hardware references generalized from Satochip-specific to generic "hardware".
+
+- **`descriptor-reference.rs`**: Miniscript descriptor construction with BIP389 multipath. Shows the pattern of building descriptors from labeled keys.
+
+ Adapted from Bitme's `bitme-cosigner/src/descriptor.rs`. Note: Bitme's specific policy (Hardware+Server as the HOT path) is NOT Keeper's policy — Keeper uses 2-of-3 Mobile + Hardware + Server as the base. This is the *construction pattern*, not the *policy*.
+
+- **`coordinator-pattern.md`**: Excerpt explaining the coordinator flow — how the hardware and server never communicate directly, with the wallet app carrying the PSBT between them.
+
+## What's Next
+
+These are Rust references. For TypeScript implementation in SigningServer, we need to decide:
+1. Which JS/TS Miniscript library to use
+2. Where the TypeScript port should live (`src/miniscript/` vs `src/wallet/`)
+3. Whether invariant tests run in CI or serve as reference
+
+See the PR description for discussion.
diff --git a/docs/miniscript-reference/coordinator-pattern.md b/docs/miniscript-reference/coordinator-pattern.md
new file mode 100644
index 0000000..2ea3c59
--- /dev/null
+++ b/docs/miniscript-reference/coordinator-pattern.md
@@ -0,0 +1,55 @@
+> [!WARNING]
+> **BITME-ONLY REFERENCE MATERIAL:** The policies and flows shown in this file (e.g., exactly one immediate path, server-side hold/veto) belong to Bitme, NOT Keeper.
+> Keeper's actual wallet policy (including Miniscript inheritance paths) and server constraints differ. Any future implementation or tests for Keeper must use the actual Keeper Miniscript policy.
+
+
+## How a spend actually works
+
+**The Hardware never talks to the server. It has no networking - no WiFi, no Bluetooth, nothing
+that reaches the internet.** It only ever talks to whatever taps it: your phone over NFC, or a
+card reader plugged into a computer. Every "HARDWARE + SERVER" spend is really two separate,
+disconnected steps stitched together by a PSBT file that your wallet app carries between them -
+never a live connection between the card and this service.
+
+The server never signs the moment you ask, either way. It queues, it tells you, it waits, and
+only then does it sign - so a signature is never the first you hear about a transaction.
+
+```mermaid
+sequenceDiagram
+ participant SC as Your Hardware
+ participant You as Your wallet app
+ participant CS as Cosigner
+ participant Node as Your bitcoind
+ participant N as Notification
+
+ Note over SC,You: Tap to phone / card reader - purely local, no network
+ SC->>You: Partial signature (HARDWARE's share)
+ You->>CS: POST /sign_psbt (HARDWARE's signature already attached)
+ CS->>Node: Are these coins real? Whose are they?
+ Node-->>CS: UTXO details
+ Note over CS: Re-derives every address itself. Never trusts what the PSBT claims.
+ CS->>CS: Check against policy
+ alt Over your limits
+ CS-->>You: 422 denied — nothing queued, nothing signed
+ else Allowed
+ CS->>N: "Pending spend: 50,000 sat to bc1q… — signs in 15 min"
+ CS-->>You: 202 Accepted + id
+ Note over CS: Holding. You can POST /veto/{id}
+ alt You veto
+ CS->>CS: Cancelled permanently
+ else Hold elapses
+ CS->>CS: Re-check policy against live state
+ CS->>CS: Add the SERVER signature
+ CS-->>You: PSBT with both signatures — 2 of 3, spendable
+ end
+ end
+ Note over You: You broadcast. The server never does. Notice the Hardware only ever appears in the top line.
+```
+
+The cosigner never talks to the Hardware either — as far as this service is concerned, "HARDWARE
+signed" just means *a PSBT arrived with a valid signature under HARDWARE's key already in it*. It
+has no way to reach the card, ask it to sign, or know it exists except through that signature.
+Your wallet app (Bitcoin Keeper, or Sparrow with a card reader) is the only thing that talks to
+both sides — locally to the Hardware, over the network to the cosigner — and a PSBT file is the
+only thing that ever crosses between them.
+
diff --git a/docs/miniscript-reference/descriptor-reference.rs b/docs/miniscript-reference/descriptor-reference.rs
new file mode 100644
index 0000000..48132f9
--- /dev/null
+++ b/docs/miniscript-reference/descriptor-reference.rs
@@ -0,0 +1,433 @@
+/*
+ * BITME-ONLY REFERENCE MATERIAL
+ * The policies and flows shown in this file (e.g., exactly one immediate path, server-side hold/veto) belong to Bitme, NOT Keeper.
+ * Keeper's actual wallet policy (including Miniscript inheritance paths) and server constraints differ.
+ * Any future implementation or tests for Keeper must use the actual Keeper Miniscript policy.
+ */
+
+//! Constructs and parses the wallet's miniscript descriptor.
+//!
+//! Policy: **any two of the three keys can spend, but any spend involving the MOBILE key waits
+//! `N` blocks.**
+//!
+//! wsh(thresh(2,pk(HARDWARE),s:pk(SERVER),snj:and_v(v:pk(MOBILE),older(N))))
+//!
+//! Which gives exactly three spending combinations:
+//!
+//! - HARDWARE + SERVER, immediately - the "HOT" path, and the only one this service
+//! co-signs on demand, subject to the policy engine.
+//! - HARDWARE + MOBILE, after `N` blocks - recovery when *this service* is gone.
+//! - MOBILE + SERVER, after `N` blocks - recovery when the *HARDWARE* is gone.
+//!
+//! That third combination is the reason for this shape. An earlier revision made HARDWARE
+//! mandatory in both branches
+//! (`and_v(v:pk(HARDWARE),or_d(pk(SERVER),and_v(v:pk(MOBILE),older(N))))`), which meant losing
+//! the HARDWARE seed lost the funds outright - a single point of catastrophic failure with no
+//! recourse. Every single-device loss is now survivable, which is the property Bitkey's 2-of-3
+//! has and that one lacked.
+//!
+//! The timelock on the MOBILE branch is what keeps this service meaningful: the *only* way to
+//! spend without waiting `N` blocks is HARDWARE + SERVER, so the policy engine cannot be
+//! side-stepped for day-to-day spending. And unlike Bitkey - whose "Delay & Notify" waiting
+//! period is enforced by their server and therefore evaporates if that server is compromised or
+//! shut down - this delay is a consensus rule. It holds even if this service is rooted, seized,
+//! or deleted.
+
+use std::str::FromStr;
+
+use anyhow::{bail, Context, Result};
+use bitcoin::Address;
+use miniscript::descriptor::{DefiniteDescriptorKey, DescriptorType};
+use miniscript::{Descriptor, DescriptorPublicKey, ForEachKey, ToPublicKey};
+
+use crate::config::{ChainNetwork, KeySpec, WalletConfig};
+
+/// A fully constructed wallet descriptor, split into its receive (external) and change
+/// (internal) halves per BIP389 multipath convention `<0;1>`.
+#[derive(Debug, Clone)]
+pub struct BuiltDescriptor {
+ /// The single descriptor string containing both paths via `<0;1>`, as configured.
+ pub multipath: Descriptor,
+ /// Receive addresses (multipath index 0).
+ pub external: Descriptor,
+ /// Change addresses (multipath index 1).
+ pub internal: Descriptor,
+ pub timelock_blocks: u16,
+}
+
+fn key_expr(key: &KeySpec) -> Result {
+ let fp = key.master_fingerprint.trim().to_lowercase();
+ let path = bitcoin::bip32::DerivationPath::from_str(key.derivation_path.trim())
+ .context("invalid derivation path")?;
+ let xpub = key.xpub.trim();
+ if path.is_empty() {
+ Ok(format!("[{fp}]{xpub}/<0;1>/*"))
+ } else {
+ Ok(format!("[{fp}/{path}]{xpub}/<0;1>/*"))
+ }
+}
+
+/// Builds the raw descriptor string
+/// `wsh(thresh(2,pk(A),s:pk(B),snj:and_v(v:pk(C),older(N))))` from three already-formatted key
+/// expressions. Shared by the production builder and by tests, so the policy shape used in
+/// tests can never drift from the one actually deployed.
+///
+/// The `s:`/`snj:` wrappers are miniscript type-system coercions (`thresh` needs every branch
+/// to leave a 0-or-1 on the stack); this exact form is what rust-miniscript's own policy
+/// compiler emits for `thresh(2,pk(A),pk(B),and(pk(C),older(N)))`, kept literal here so the
+/// deployed script is deterministic rather than dependent on a compiler's cost heuristics.
+pub fn policy_string(
+ hardware_expr: &str,
+ server_expr: &str,
+ mobile_expr: &str,
+ timelock_blocks: u16,
+) -> String {
+ format!(
+ "wsh(thresh(2,pk({hardware_expr}),s:pk({server_expr}),snj:and_v(v:pk({mobile_expr}),older({timelock_blocks}))))"
+ )
+}
+
+pub fn build_descriptor(cfg: &WalletConfig) -> Result {
+ cfg.validate()?;
+
+ let hardware_expr = key_expr(&cfg.keys.hardware).context("keys.hardware")?;
+ let server_expr = key_expr(&cfg.keys.server).context("keys.server")?;
+ let mobile_expr = key_expr(&cfg.keys.mobile).context("keys.mobile")?;
+
+ let desc_str = policy_string(
+ &hardware_expr,
+ &server_expr,
+ &mobile_expr,
+ cfg.timelock_blocks,
+ );
+
+ let multipath = Descriptor::::from_str(&desc_str)
+ .with_context(|| format!("parsing generated descriptor: {desc_str}"))?;
+
+ if multipath.desc_type() != DescriptorType::Wsh {
+ bail!(
+ "expected a wsh() descriptor, got {:?}",
+ multipath.desc_type()
+ );
+ }
+ multipath
+ .sanity_check()
+ .context("generated descriptor failed miniscript sanity check")?;
+
+ let mut singles = multipath
+ .clone()
+ .into_single_descriptors()
+ .context("splitting multipath descriptor into external/internal")?;
+ if singles.len() != 2 {
+ bail!(
+ "expected exactly 2 derivation paths (external, internal) from <0;1>, got {}",
+ singles.len()
+ );
+ }
+ let internal = singles.pop().unwrap();
+ let external = singles.pop().unwrap();
+ external
+ .sanity_check()
+ .context("external descriptor failed sanity check")?;
+ internal
+ .sanity_check()
+ .context("internal descriptor failed sanity check")?;
+
+ Ok(BuiltDescriptor {
+ multipath,
+ external,
+ internal,
+ timelock_blocks: cfg.timelock_blocks,
+ })
+}
+
+/// Derives the address at `index` for a single-path (non-multipath) descriptor.
+pub fn address_at(
+ desc: &Descriptor,
+ index: u32,
+ network: ChainNetwork,
+) -> Result {
+ let definite = desc
+ .at_derivation_index(index)
+ .with_context(|| format!("deriving index {index}"))?;
+ definite
+ .address(network.to_bitcoin_network())
+ .with_context(|| format!("computing address at index {index}"))
+}
+
+/// Resolves a single-path descriptor to its fully concrete (non-wildcard) form at `index`,
+/// for invariant analysis and satisfaction.
+pub fn at_index(
+ desc: &Descriptor,
+ index: u32,
+) -> Result> {
+ desc.at_derivation_index(index)
+ .with_context(|| format!("deriving index {index}"))
+}
+
+/// Which chain (receive/external or change/internal) a derived scriptPubkey was found on.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Chain {
+ External,
+ Internal,
+}
+
+/// Where in the wallet a scriptPubkey was found, from [`find_owner`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Owned {
+ pub chain: Chain,
+ pub index: u32,
+}
+
+/// Searches `desc` at indices `0..gap_limit` for one whose scriptPubkey equals `target`.
+///
+/// This is the only trustworthy way to answer "is this scriptPubkey ours": PSBT metadata
+/// (bip32_derivation, the "this is change" convention some wallets use) is supplied by
+/// whoever built the PSBT and must never be taken on faith - see `inspect.rs`.
+fn find_on_chain(
+ desc: &Descriptor,
+ target: &bitcoin::ScriptBuf,
+ gap_limit: u32,
+) -> Result