Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dd79887
make: test Golang modules in a loop
guggero Aug 6, 2026
b354dd1
descriptors/miniscript: add the expression parser
benma Aug 10, 2026
bdb9dbe
descriptors/miniscript: expand wrappers and syntactic sugar
benma Aug 10, 2026
30315c5
descriptors/miniscript: add the type system
benma Aug 10, 2026
1b5af10
descriptors/miniscript: detect malleable expressions
benma Aug 10, 2026
5f2a47f
descriptors/miniscript: compile expressions to script
benma Aug 10, 2026
eb22283
descriptors/miniscript: substitute concrete keys and hashes
benma Aug 10, 2026
5a345de
descriptors/miniscript: count the ops of a satisfaction
benma Aug 10, 2026
739475d
descriptors/miniscript: add the upstream test vectors
benma Aug 10, 2026
6a3ab9b
descriptors/miniscript: compute the witness stack size
guggero Aug 10, 2026
625dc79
descriptors/miniscript: compute the satisfaction size
guggero Aug 10, 2026
b8d83d1
descriptors/miniscript: compute the execution stack size
guggero Aug 10, 2026
5e4ddb3
descriptors/miniscript: detect time lock mixing
guggero Aug 10, 2026
9b20a7b
descriptors/miniscript: enforce the resource limits
guggero Aug 10, 2026
2d365a3
descriptors/miniscript: reject expressions that nest too deeply
guggero Aug 10, 2026
0d18616
descriptors/miniscript: satisfy an expression
benma Aug 10, 2026
83f45e5
descriptors/miniscript: check against rust-miniscript
guggero Aug 10, 2026
fb83808
descriptors/miniscript: spend the generated scripts
benma Aug 10, 2026
15eff45
descriptors/miniscript: lift an expression into a policy
guggero Aug 10, 2026
0e8e964
descriptors/miniscript: fuzz the parser
guggero Aug 10, 2026
f4cc1de
descriptors/miniscript: document the package
guggero Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 37 additions & 24 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ DEV_TAGS := rpctest
GOTEST_DEV = go test -v -tags=$(DEV_TAGS)
GOTEST := go test -v
COVER_FLAGS = -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(PKG)/...
MODULES := address btcec btcutil chaincfg chainhash descriptors psbt txscript v2transport wire

# Linting uses a lot of memory, so keep it under control by limiting the number
# of workers if requested.
Expand All @@ -38,6 +39,10 @@ define print
echo $(GREEN)$1$(NC)
endef

# Time budget per fuzz target for go-fuzz. Override with `fuzztime=10m` for a
# long (nightly-style) run; the default is a quick local smoke.
fuzztime ?= 15s

#? default: Run `make build`
default: build

Expand Down Expand Up @@ -96,14 +101,11 @@ check: unit
unit:
@$(call print, "Running unit tests.")
$(GOTEST_DEV) ./... -test.timeout=20m
cd address && $(GOTEST_DEV) ./... -test.timeout=20m
cd btcec && $(GOTEST_DEV) ./... -test.timeout=20m
cd btcutil && $(GOTEST_DEV) ./... -test.timeout=20m
cd chaincfg && $(GOTEST_DEV) ./... -test.timeout=20m
cd chainhash && $(GOTEST_DEV) ./... -test.timeout=20m
cd txscript && $(GOTEST_DEV) ./... -test.timeout=20m
cd psbt && $(GOTEST_DEV) ./... -test.timeout=20m
cd wire && $(GOTEST_DEV) ./... -test.timeout=20m
for module in $(MODULES); do \
( cd $$module; echo $$module; \
$(GOTEST_DEV) ./... -test.timeout=20m \
); \
done

#? unit-cover: Run unit coverage tests
unit-cover:
Expand All @@ -112,27 +114,37 @@ unit-cover:

# We need to remove the /v2 pathing from the module to have it work
# nicely with the CI tool we use to render live code coverage.
cd address && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd btcec && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd btcutil && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd chaincfg && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd chainhash && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd txscript && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd psbt && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
cd wire && $(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt
for module in $(MODULES); do \
( cd $$module; echo $$module; \
$(GOTEST) $(COVER_FLAGS) ./... && sed -i.bak 's/v2\///g' coverage.txt \
); \
done

#? unit-race: Run unit race tests
unit-race:
@$(call print, "Running unit race tests.")
env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd address && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd btcec && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd btcutil && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd chaincfg && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd chainhash && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd txscript && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd psbt && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...
cd wire && env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./...

for module in $(MODULES); do \
( cd $$module; echo $$module; \
env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(GOTEST) -race -test.timeout=20m ./... \
); \
done

#? go-fuzz: Run every Fuzz* target, coverage-guided, for `fuzztime` (default 15s) each. Seed corpora always run as part of go-unit; this target is the mutation engine on top.
go-fuzz:
@set -e; \
for module in $(MODULES); do \
( cd $$module; \
for pkg in $$(go list ./...); do \
for target in $$(go test -list='^Fuzz' $$pkg \
| grep '^Fuzz' || true); do \
echo "=== go-fuzz: $$target ($$pkg)"; \
go test -run='^$$' -fuzz="^$$target\$$" \
-fuzztime=$(fuzztime) $$pkg; \
done; \
done ); \
done

# =========
# UTILITIES
Expand Down Expand Up @@ -171,6 +183,7 @@ tidy-module:
fmt \
lint \
clean \
go-fuzz \
tidy-module

#? help: Get more info on make commands
Expand Down
25 changes: 25 additions & 0 deletions descriptors/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module github.com/btcsuite/btcd/descriptors

go 1.25.0

require (
github.com/btcsuite/btcd/address/v2 v2.0.0
github.com/btcsuite/btcd/btcec/v2 v2.5.0
github.com/btcsuite/btcd/btcutil/v2 v2.0.0
github.com/btcsuite/btcd/chaincfg/v2 v2.0.0
github.com/btcsuite/btcd/chainhash/v2 v2.0.0
github.com/btcsuite/btcd/txscript/v2 v2.0.0
github.com/btcsuite/btcd/wire/v2 v2.0.0
github.com/stretchr/testify v1.10.0
)

require (
github.com/btcsuite/btclog v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
34 changes: 34 additions & 0 deletions descriptors/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
github.com/btcsuite/btcd/address/v2 v2.0.0 h1:UVu8Hal6Siu4XastFe+JX5JkeBYONbDUIY5E+SVTs6I=
github.com/btcsuite/btcd/address/v2 v2.0.0/go.mod h1:htJK1AtaeK3bKNfZY63ep2oN8LbrI6qvmPGe1vekb3I=
github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8=
github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk=
github.com/btcsuite/btcd/btcutil/v2 v2.0.0 h1:77pgf/4tjWaSBLdos8yiWVWL3rSphxWNqkLwcyONExA=
github.com/btcsuite/btcd/btcutil/v2 v2.0.0/go.mod h1:ZF8MMdsx1JGgvHJUanxbigekSO+8bN/ai34LBk/lg3c=
github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 h1:M/RTtXfXA9odC1RUEOyZFXj/NXKVHPYZXVjb60xTOok=
github.com/btcsuite/btcd/chaincfg/v2 v2.0.0/go.mod h1:rHgHIXYYfn70m25a+BJ9f9z7VZAsTiDQGB2XYaippGQ=
github.com/btcsuite/btcd/chainhash/v2 v2.0.0 h1:PMLlSloHJuEeB80XG9EjpXWNEKAZAMLl6YHZ6YsEuoA=
github.com/btcsuite/btcd/chainhash/v2 v2.0.0/go.mod h1:mKxcZ7oGTXE7IRV+sS9hP4EVBwc/SzfNR+52IsOP9j8=
github.com/btcsuite/btcd/txscript/v2 v2.0.0 h1:pEmmHaC8eRx6KSB63zSVJD7qrit9/c9cLSrw++XrYP8=
github.com/btcsuite/btcd/txscript/v2 v2.0.0/go.mod h1:pZXabc11Xr9nz/18kXY3yErdAajYc3gi28Zqb3KqlFo=
github.com/btcsuite/btcd/wire/v2 v2.0.0 h1:mYSKzZZ0a1sK+aMhXzfDSVsSzRkWkU3x2U04TFRS2z8=
github.com/btcsuite/btcd/wire/v2 v2.0.0/go.mod h1:bGxkPkk8IiDvUo1D96wE03llBIk7p2MdWYRyAQwLmqM=
github.com/btcsuite/btclog v1.0.0 h1:sEkpKJMmfGiyZjADwEIgB1NSwMyfdD1FB8v6+w1T0Ns=
github.com/btcsuite/btclog v1.0.0/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
87 changes: 87 additions & 0 deletions descriptors/miniscript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# miniscript

Miniscript as specified in BIP379: parse an expression, analyze it, build its
script, and produce a non-malleable satisfaction. Used by the
[`descriptors`](../README.md) package, but usable on its own.

## What is supported

**Every BIP379 fragment and wrapper**, plus `multi_a()` (BIP387) and
`sortedmulti_a()` (BIP387), including the syntactic sugar (`pk`, `pkh`, `and_n`,
`t:`, `l:`, `u:`).

**Three script contexts**, which decide the allowed fragments, the key
serialization and the resource limits:

| Context | Keys | Multisig | Script size | Ops | Other limits |
|---------|------|----------|-------------|-----|--------------|
| `P2WSH` | 33-byte compressed | `multi`, ≤ 20 keys | ≤ 3600 | ≤ 201 | ≤ 100 witness elements, ≤ 1000 stack elements |
| `P2TR` | 32-byte x-only | `multi_a`/`sortedmulti_a`, ≤ 999 keys | ≤ 10000 | - | ≤ 1000 stack elements |
| `Legacy` | 33-byte compressed | `multi`, ≤ 20 keys | ≤ 520 | ≤ 201 | ≤ 1650 byte scriptSig, no `or_i`, no `d:` |

**Analysis**: the correctness type system (`B`/`V`/`K`/`W` plus the
`zondumsfe` properties), malleability, timelock mixing, script size, op count,
witness element count, execution stack peak and satisfaction size - the whole
static analysis BIP379 describes.

**API**: `Parse`, `ParseInsane`, and on the resulting `AST`: `Script`,
`Satisfy`, `Keys`, `ApplyVars`, `Clone`, `Lift`, `DrawTree`, `IsSane`,
`IsValidTopLevel`, `ScriptLen`, `MaxSatisfactionSize`,
`MaxSatisfactionWitnessElements`.

## What is not supported

- **No compiler.** A policy cannot be compiled to miniscript; only the reverse
(`Lift`). rust-miniscript has a compiler behind a feature flag.
- **No script decoding.** A miniscript cannot be recovered from raw script bytes,
which both Core and rust can do.
- **Keys are opaque.** A key argument is an identifier until `ApplyVars`
substitutes bytes for it, and only its length is checked; whether it is a
valid curve point is the caller's business.

## Divergences worth knowing

- **`Parse` is sane by default.** It rejects expressions that are malleable,
need no signature, are not a valid top level, mix timelock kinds, or exceed a
resource limit - the same set rust-miniscript's `from_str` enforces through
`Ctx::SANE`. `ParseInsane` runs the analysis without those checks, for
inspecting an expression that is known not to be sane.
- **The Tapscript script size limit is 10000 bytes**, not the block weight.
Tapscript imposes no script size limit of its own, but the script builder
cannot emit more, so the parse-time limit is what can actually be built rather
than a limit that would let an expression parse and never compile.
- **The `Legacy` context is a rust concept.** Core does not accept miniscript
inside `sh()` at all (`descriptor.cpp:2682` in Core `c4fbd3c7211`). Where the
context exists here, it mirrors rust's: `or_i` and `d:` are rejected because
an `OP_IF` argument is not required to be minimally encoded outside segwit, so
a third party could malleate the branch selector. Unlike rust's, it takes
compressed keys only.
- **The execution stack model differs from rust in three fragments.** For
`thresh`, `or_d` and `multi`, this package computes the true peak: rust's
value is an order-dependent conservative estimate for `thresh`, and one
respectively two elements short of the peak for `or_d` (the `OP_IFDUP` of a
satisfied first branch) and `multi` (the `<k>` and `<n>` around its keys). The
differential test records the difference. Every other computed property
matches rust exactly.
- **The witness size of `d:` is one byte larger than rust's.** rust-miniscript
counts the `<1>` selector element that a `d:` satisfaction pushes as a single
witness byte, inconsistently with its own `or_i`, which counts the identical
element as two: its length prefix plus the byte itself. This package counts
two, so that a fee estimate covers the witness its satisfier really produces.
- **Malleability propagation differs in one corner.** The satisfaction type has
no equivalent of rust's "impossible versus unavailable" distinction, so the
malleable flag of a non-sane threshold branch without a signature can differ.
Sane expressions are unaffected, and no satisfaction this produces is invalid.

## Testing

The package is checked against rust-miniscript by differential tests over about
8,200 expressions per context: every computed property
(`testdata/props_from_rust*.tsv`) and the byte-exact script encoding
(`testdata/scripts_from_rust*.tsv`), plus parse agreement over roughly 13,700
expressions. The corpora from rust's own test suite (`testdata/*.txt`) cover
valid, invalid, malleable and timelock-conflicting expressions with their
expected types. `execute_test.go` and `tap_test.go` run real spends through the
btcd script engine, and `FuzzParse` fuzzes the parser and every downstream pass.
See [`testdata/README.md`](testdata/README.md) for how the corpora were
generated; they have since been contributed upstream as BIP379's test vectors.
Loading
Loading