Miniscript misconceptions
Five things I was sure about, and what's actually going on.
I got here through bdk-cli. I added randomized unspendable keys to taproot descriptors, so that script-path-only outputs stop wearing the same well-known key that anyone can spot on chain. Then I looked at what that key sits inside, and got hooked. Miniscript is a whole language for spending conditions, with a compiler and a type system behind it. So I sat down with rust-miniscript, and found out how much I'd just been assuming.
1. Compiling a policy just wraps it in a descriptor
What I thought: Policy and Miniscript are kind of the same thing, and compiling just puts a wrapper around it - or(pk(A),pk(B)) goes in, wsh(or(pk(A),pk(B))) comes out.
Actually:
$ bdk-cli compile "or(pk(A),pk(B))"
{
"descriptor": "wsh(or_b(pk(A),s:pk(B)))#z2tsuf0c"
}
or came back as or_b, and the second key picked up an s:. That's not my policy in a wrapper, it's a translation into another language. Policy is what I write, Miniscript is what the compiler writes. And neither of them is what ends up on chain, by the way - that's plain Script, and Miniscript is a layer on top of it.
2. There is one Policy type
What I thought: there's one policy language, so I figured there's one policy type in the library.
Actually: there are two of them, sitting on opposite sides of Miniscript.
[Concrete Policy] ---compile---> [Miniscript] ---lift---> [Semantic Policy]#!/usr/bin/env -S cargo +nightly -Zscript
---
[package]
edition = "2024"
[dependencies]
miniscript = { git = "https://github.com/rust-bitcoin/rust-miniscript", features = ["compiler"] }
---
use std::str::FromStr;
use miniscript::policy::{Concrete, Liftable};
use miniscript::{Miniscript, Segwitv0};
fn main() {
// What I write.
let concrete = Concrete::<String>::from_str("or(pk(A),pk(B))").unwrap();
// What the compiler writes.
let ms: Miniscript<String, Segwitv0> = concrete.compile().unwrap();
// What analysis gives back, lifted out of the Miniscript.
let semantic = ms.lift().unwrap();
println!("concrete {concrete}");
println!("miniscript {ms}");
println!("semantic {semantic}");
}
chmod +x it and run:
$ ./miniscript-roundtrip.rs
concrete or(1@pk(A),1@pk(B))
miniscript or_b(pk(A),s:pk(B))
semantic (pk(A) ∨ pk(B))
Three different things out of one input. And see that 1@? I never typed it. A concrete policy always carries branch weights, and here they just defaulted to equal. The semantic policy has nowhere to put them, because it isn't describing what I want - only what the script means.
How I think about it now: semantic policy is a cut-down subset of concrete policy that exists only for analysis. You don't write one and you can't compile one. You lift it out of a Miniscript and ask it questions - does this policy entail that one, what are the timelocks, how many keys.
3. You can go back from a script to a policy
What I thought: compile goes down, lift goes up, so I can go back and forth.
Actually: it only goes one way, and the run above already shows the leak. The semantic policy came back without the weights: they did their job picking the script, then stopped existing. The other direction is no better - there are several valid Miniscripts for the same policy and the compiler just picks one, so a finished script tells you nothing about which policy it came from.
And it gets worse. Lifting hands you something that looks like a policy and parses as one, so the round trip seems to work right up until it quietly hands you a different script. It's a known trap - rust-miniscript#885 has the story, including a wallet tool that shipped it - and it's why semantic policy prints with ∧ and ∨ these days.
4. The policy language is part of the spec
What I thought: policy got standardized along with Miniscript.
Actually: BIP-379 covers Miniscript, BIP-380 covers descriptors. The policy language and its compiler sit outside both specs. The BIP barely mentions policy at all - one passing line in a list of things Miniscript is good for: "It can be targeted by spending policy compilers."
My guess at why it stops there: there's no single right answer for a compiler to produce. Same thing as section 3 - one policy has several valid encodings, and which one you get comes down to how that particular compiler trades size against likelihood. A language where every program means one thing is worth standardizing. A heuristic that picks one encoding out of many isn't.
So you can hand a descriptor to another wallet. A policy string, not really: it's a convenience of whichever compiler you happen to be using.
5. Miniscript goes in any descriptor
What I thought: descriptors are the wrapper, Miniscript is what you put in them.
Actually: welllll, yes and no. Descriptors are the general container, and only some of them have a slot for a script. pkh() and wpkh() want a key and refuse a script outright. sh() takes one even though BIP-379 excludes it, and Bitcoin Core flatly refuses the result - which I found out by trying it. And Miniscript with no wrapper at all parses fine, then falls over for a completely different reason: nodes only relay a couple of trivial bare script shapes, so yours would never make it anywhere.
Links
- Miniscript - Pieter Wuille's site and C++ compiler
- BIP-379 - Miniscript
- BIP-380 - Output Script Descriptors
- rust-miniscript - the Rust implementation
- docs.rs/miniscript - its docs, where the module-level prose explains the concepts and not just the API
- Understanding Bitcoin Miniscript - BitBox series, written by implementers
P.S.
Yeah, this was written with an LLM. Nobody writes anything without the fucking things anymore, me included.
But I reread it dozens of times and made hundreds of edits getting here. So, dear reader, note the difference between a post written with an LLM and a post written by one.