This document is for contributors who want to understand or extend the
TypeScript Miniscript analyzer. It focuses on the static type system and how it
produces issane and issanesublevel, while keeping the satisfier-based
derivation flow intact.
- Parse the Miniscript expression into a lightweight AST.
- Run static analysis on the AST (correctness + malleability + timelocks).
- Derive
issaneandissanesublevel. - If sane, the satisfier derives satisfactions (witnesses) and classifies them as non-malleable or malleable.
Key modules:
src/compiler/parse.ts: parses expressions into an AST.src/compiler/compile.ts: compiles the AST into ASM.src/compiler/correctness.ts: correctness type rules.src/compiler/malleability.ts: malleability type rules.src/compiler/analyze.ts: analysis pass that computes sanity flags.src/compiler/index.ts: entry point that wires parse + compile + analysis.
AST stands for Abstract Syntax Tree. It is the tree representation of a Miniscript expression where each node corresponds to one fragment or wrapper.
The parser builds a minimal AST that matches Miniscript fragments and wrappers. A node is a plain object that always contains:
type: the normalized node kind (fragment or wrapper identifier) after syntactic sugar expansion (string)
Depending on the fragment, a node may also contain:
key: a key identifier (forpk_k,pk_h)value: a scalar value (forafter,older, hashes)k: a numeric threshold (forthresh,multi,multi_a)keys: array of keys (formulti,multi_a)arg: a single child node (for unary wrappersa/s/c/d/v/j/n)args: array of child nodes (for binary/ternary operators likeand_v,or_b,andor)subs: array of child nodes (forthresh)
Values are kept as strings during parsing; numeric validation happens in the analyzer and compiler.
Example input and shape:
and_v(v:pk(A),after(10))
{
type: 'and_v',
args: [
{ type: 'v', arg: { type: 'pk_k', key: 'A' } },
{ type: 'after', value: '10' }
]
}
Other examples:
- AST shape (summary):
Node :=
{ type, ... }
type in:
- "0" | "1"
- pk_k | pk_h (key)
- after | older (value)
- sha256 | hash160 | ... (value)
- multi | multi_a (k, keys[])
- thresh (k, subs[])
- and_v/or_b/... (args[2])
- andor (args[3])
- a/s/c/d/v/j/n (arg)
- Base nodes:
0,1,pk_k,pk_h,multi,multi_a,sha256,after,older,thresh,and_v,or_b,andor, etc. - Wrapper nodes:
a,s,c,d,v,j,nas unary nodes witharg. - Syntactic sugar is expanded during parsing:
pk(x)->c:pk_k(x)pkh(x)->c:pk_h(x)and_n(x,y)->andor(x,y,0)t:/l:/u:wrappers expand toand_v/or_iforms (so they never appear astypevalues)
The AST is intentionally simple so it can be shared by the compiler and the static analyzer.
The static type system mirrors the Miniscript specification:
- Correctness: basic type (B/V/K/W), z/o/n input modifiers, dissatisfiable flag and unit output.
- Malleability: signed (
s), forced (f), expressive (e), plus derivednonMalleable.
These rules are encoded in src/compiler/correctness.ts and
src/compiler/malleability.ts and are pure functions that take child types and
return a parent type (or an error).
Correctness is tracked as a small record with these fields:
basicType: one ofB,V,K,W(boolean, verify, key or wrapped output).zeroArg:zmodifier (consumes exactly 0 stack elements).oneArg:omodifier (consumes exactly 1 stack element).nonZero:nmodifier (top input is never required to be zero).dissatisfiable: whether the fragment has a valid dissatisfaction (dsat).unit: whether the fragment leaves exactly one stack element (u).
These fields are combined by the correctness rules (for example, or_b requires
both children to be dissatisfiable and produces a B output).
Context differences: in Tapscript, d:X gains the unit (u) property because
SCRIPT_VERIFY_MINIMALIF enforces exact 0/1 encoding for IF.
Malleability uses the Miniscript s/f/e properties from the
"Guaranteeing non-malleability" section of the spec:
signed: every satisfaction requires a signature.forced: every dissatisfaction requires a signature (or none exist).expressive: a unique unconditional dissatisfaction exists and any conditional dissatisfactions require signatures.nonMalleable: derived from the spec's "Requires" column to guarantee a non-malleable satisfaction exists.
Correctness properties answer "is this miniscript well-typed and semantically
valid?" They ensure fragments are used in the right context (B/V/K/W rules),
require the right kind of stack input and preserve intended meaning. Without
correctness, a miniscript may compile to ASM but no longer represent the policy
it claims (for example, an or_b that behaves like and).
Malleability properties answer "if it is valid, can it be spent without malleable witnesses?" They are only meaningful after correctness passes.
In code, correctness and malleability rules live side-by-side in
src/compiler/correctness.ts and src/compiler/malleability.ts. Correctness
helpers return { ok, correctness, error? } and malleability helpers return
signed/forced/expressive plus nonMalleable. Both are applied in
src/compiler/analyze.ts during analyzeNode.
src/compiler/analyze.ts walks the AST and computes:
- Type information via the type rules.
- Timelock mixing via
TimelockInfoand the combine functions. - Duplicate keys by merging key sets from child nodes.
analyzeParsedNode converts those results into:
issanesublevel: requires signature, non-malleable, no timelock mixing, and no duplicate keys.issane:issanesublevelplus top-levelBtype requirement.
The analysis returns a short, human-readable error message (for example,
sane: malleable satisfactions possible.) to help with debugging.
Timelocks are tracked by TimelockInfo:
after()sets CLTV height/time flags.older()sets CSV height/time flags (viabip68).
The combine rules follow the spec:
- For disjunctions (
k=1), timelock info is unioned. - For conjunctions (
k>1), incompatible height/time combinations setcontains_combinationto mark timelock mixing.
The satisfier runs the static analysis first. If issane is false, it throws:
Miniscript <expr> is not sane.
By default, unknown satisfactions are pruned unless computeUnknowns is
enabled. When computeUnknowns is true, the satisfier keeps solutions that
contain unknown signatures or preimages and surfaces them via unknownSats.
Enumeration is capped by maxSolutions (default 1000), which counts every
derived solution including dsats and intermediate combinations; set
maxSolutions: null to disable the cap. Raw analysis errors are attached to
err.cause for debugging.
When adding a new fragment, update all four layers:
- Parser: add a case in
parse.ts. - Compiler: add ASM generation in
compile.ts. - Type system: add correctness and malleability rules in
src/compiler/correctness.tsandsrc/compiler/malleability.ts. - Analyzer: add a case in
analyze.tsand merge keys/timelocks.
This keeps compile, analyze and satisfy behavior consistent.