Skip to content

Commit 5671d8f

Browse files
committed
descriptors: add fuzz and benchmark tests
1 parent 9b9f9f1 commit 5671d8f

4 files changed

Lines changed: 458 additions & 0 deletions

File tree

Makefile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ define print
3939
echo $(GREEN)$1$(NC)
4040
endef
4141

42+
# Time budget per fuzz target for go-fuzz. Override with `fuzztime=10m` for a
43+
# long (nightly-style) run; the default is a quick local smoke.
44+
fuzztime ?= 15s
45+
4246
#? default: Run `make build`
4347
default: build
4448

@@ -127,6 +131,21 @@ unit-race:
127131
); \
128132
done
129133

134+
#? 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.
135+
go-fuzz:
136+
@set -e; \
137+
for module in $(MODULES); do \
138+
( cd $$module; \
139+
for pkg in $$(go list ./...); do \
140+
for target in $$(go test -list='^Fuzz' $$pkg \
141+
| grep '^Fuzz' || true); do \
142+
echo "=== go-fuzz: $$target ($$pkg)"; \
143+
go test -run='^$$' -fuzz="^$$target\$$" \
144+
-fuzztime=$(fuzztime) $$pkg; \
145+
done; \
146+
done ); \
147+
done
148+
130149
# =========
131150
# UTILITIES
132151
# =========
@@ -164,6 +183,7 @@ tidy-module:
164183
fmt \
165184
lint \
166185
clean \
186+
go-fuzz \
167187
tidy-module
168188

169189
#? help: Get more info on make commands

descriptors/benchmark_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package descriptors
2+
3+
import (
4+
"testing"
5+
6+
"github.com/btcsuite/btcd/chaincfg/v2"
7+
"github.com/btcsuite/btcd/descriptors/miniscript"
8+
)
9+
10+
// msDescriptor is a wsh(miniscript) descriptor with a wildcard, i.e. the case
11+
// where deriving an address must build the inner miniscript at the requested
12+
// index. This isolates the per-derivation miniscript cost that the AST cache
13+
// targets.
14+
const msDescriptor = "wsh(or_d(pk([e81a5744/48'/0'/0'/2']xpub6Duv8Gj9gZeA3" +
15+
"sUo5nUMPEv6FZ81GHn3feyaUej5KqcjPKsYLww4xBX4MmYZUPX5NqzaVJWYdYZwGLEC" +
16+
"tgQruG4FkZMh566RkfUT2pbzsEg/*),and_v(v:pk([3c157b79/48'/0'/0'/2']xp" +
17+
"ub6DdSN9RNZi3eDjhZWA8PJ5mSuWgfmPdBduXWzSP91Y3GxKWNwkjyc5mF9FcpTFymU" +
18+
"h9C4Bar45b6rWv6Y5kSbi9yJDjuJUDzQSWUh3ijzXP/*),older(52560))))"
19+
20+
// BenchmarkNewDescriptor measures parsing every descriptor in the corpus once
21+
// per iteration. This is the "loading" path a wallet hits when it ingests a set
22+
// of descriptors.
23+
func BenchmarkNewDescriptor(b *testing.B) {
24+
descs := loadCorpus(b)
25+
b.ReportAllocs()
26+
b.ResetTimer()
27+
28+
for i := 0; i < b.N; i++ {
29+
for _, s := range descs {
30+
d, err := NewDescriptor(s)
31+
if err != nil {
32+
b.Fatalf("parse %q: %v", s, err)
33+
}
34+
_ = d
35+
}
36+
}
37+
}
38+
39+
// BenchmarkAddressAtMiniscript derives addresses at increasing indices from a
40+
// single wsh(miniscript) descriptor. Each derivation used to re-parse the whole
41+
// miniscript expression; it now clones the AST cached at construction time.
42+
func BenchmarkAddressAtMiniscript(b *testing.B) {
43+
d, err := NewDescriptor(msDescriptor)
44+
if err != nil {
45+
b.Fatalf("parse: %v", err)
46+
}
47+
params := &chaincfg.MainNetParams
48+
49+
b.ReportAllocs()
50+
b.ResetTimer()
51+
52+
for i := 0; i < b.N; i++ {
53+
if _, err := d.AddressAt(params, 0, uint32(i)); err != nil {
54+
b.Fatalf("derive: %v", err)
55+
}
56+
}
57+
}
58+
59+
// BenchmarkMiniscriptParseVsClone compares parsing a miniscript expression from
60+
// scratch against cloning an already-parsed AST, which is what the derivation
61+
// path does per address. It quantifies the saving the cache provides.
62+
func BenchmarkMiniscriptParseVsClone(b *testing.B) {
63+
d, err := NewDescriptor(msDescriptor)
64+
if err != nil {
65+
b.Fatalf("parse: %v", err)
66+
}
67+
68+
// Reach the inner miniscript node of the wsh wrapper.
69+
inner := d.root.sub
70+
71+
b.Run("Parse", func(b *testing.B) {
72+
b.ReportAllocs()
73+
for i := 0; i < b.N; i++ {
74+
ast, err := miniscript.Parse(inner.msExpr, inner.msCtx)
75+
if err != nil {
76+
b.Fatal(err)
77+
}
78+
_ = ast
79+
}
80+
})
81+
82+
b.Run("Clone", func(b *testing.B) {
83+
b.ReportAllocs()
84+
for i := 0; i < b.N; i++ {
85+
_ = inner.msAST.Clone()
86+
}
87+
})
88+
}
89+
90+
// BenchmarkAddressAt measures deriving a single address from every corpus
91+
// descriptor that supports one. The descriptor is parsed once up front so the
92+
// benchmark isolates the per-derivation cost.
93+
func BenchmarkAddressAt(b *testing.B) {
94+
raw := loadCorpus(b)
95+
96+
params := &chaincfg.MainNetParams
97+
var descs []*Descriptor
98+
for _, s := range raw {
99+
d, err := NewDescriptor(s)
100+
if err != nil {
101+
b.Fatalf("parse %q: %v", s, err)
102+
}
103+
104+
// Only keep descriptors that can produce an address at index 0.
105+
if _, err := d.AddressAt(params, 0, 0); err != nil {
106+
continue
107+
}
108+
descs = append(descs, d)
109+
}
110+
111+
b.ReportAllocs()
112+
b.ResetTimer()
113+
114+
for i := 0; i < b.N; i++ {
115+
for _, d := range descs {
116+
if _, err := d.AddressAt(params, 0, 0); err != nil {
117+
b.Fatalf("derive: %v", err)
118+
}
119+
}
120+
}
121+
}

descriptors/fuzz_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package descriptors
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/btcsuite/btcd/chaincfg/v2"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// maxFuzzInput bounds the size of a fuzz input. Descriptor parsing recurses
14+
// into miniscript, whose threshold passes are exponential in the number of
15+
// thresh sub expressions, so this keeps individual executions fast while
16+
// leaving room to reach every descriptor and script shape.
17+
const maxFuzzInput = 4096
18+
19+
// gPointX is the x coordinate of the secp256k1 generator, used to build seed
20+
// descriptors with valid raw keys (which, unlike the xpub seeds, survive
21+
// mutation without breaking base58 decoding).
22+
const gPointX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f28" +
23+
"15b16f81798"
24+
25+
// hardcodedDescriptorSeeds keep the seed set representative even if the corpus
26+
// file is unavailable, and add raw-key forms that mutate more gracefully than
27+
// the long xpub corpus entries.
28+
var hardcodedDescriptorSeeds = []string{
29+
"pk(" + basicTestXpub + ")",
30+
"pkh(" + basicTestXpub + "/*)",
31+
"wpkh(" + basicTestXpub + "/<0;1>/*)",
32+
"sh(wpkh(" + basicTestXpub + "/*))",
33+
"wsh(pk(" + basicTestXpub + "/*))",
34+
"sh(wsh(pkh(" + basicTestXpub + "/*)))",
35+
"wsh(multi(1,02" + gPointX + "))",
36+
"tr(" + gPointX + ")",
37+
"tr(" + gPointX + ",pk(" + gPointX + "))",
38+
"tr(" + gPointX + ",{pk(" + gPointX + "),pk(" + gPointX + ")})",
39+
}
40+
41+
// FuzzNewDescriptor fuzzes descriptor parsing and, on every successful parse,
42+
// exercises the deep downstream methods (type classification, lifting, address
43+
// and script-code derivation, weight estimation and planning). It checks the
44+
// no-crash property throughout, plus the invariant that the descriptor's string
45+
// form is a stable, re-parseable fixed point.
46+
func FuzzNewDescriptor(f *testing.F) {
47+
addDescriptorSeeds(f, filepath.Join(
48+
"testdata", "descriptors_corpus.txt",
49+
))
50+
for _, seed := range hardcodedDescriptorSeeds {
51+
f.Add(seed)
52+
}
53+
54+
f.Fuzz(func(t *testing.T, desc string) {
55+
if len(desc) > maxFuzzInput {
56+
return
57+
}
58+
59+
d, err := NewDescriptor(desc)
60+
if err != nil {
61+
return
62+
}
63+
exerciseDescriptor(t, d)
64+
})
65+
}
66+
67+
// exerciseDescriptor drives the deep operations of a successfully parsed
68+
// descriptor, asserting only invariants that must hold for any valid one.
69+
func exerciseDescriptor(t *testing.T, d *Descriptor) {
70+
// The string form must be a stable fixed point: re-parsing it must
71+
// succeed and reproduce exactly the same string (the checksum is
72+
// recomputed on each round).
73+
s := d.String()
74+
reparsed, err := NewDescriptor(s)
75+
require.NoErrorf(t, err, "re-parsing own string %q failed", s)
76+
require.Equal(t, s, reparsed.String(),
77+
"descriptor string is not a stable fixed point")
78+
79+
// The remaining methods must never panic on a valid descriptor.
80+
_ = d.DescType()
81+
_ = d.Keys()
82+
_, _ = d.Lift()
83+
_, _ = d.MaxWeightToSatisfy()
84+
85+
multipath := d.MultipathLen()
86+
require.GreaterOrEqualf(t, multipath, 1,
87+
"multipath length must be at least 1 for %q", s)
88+
89+
// Derive an address and script code for a bounded sample of the
90+
// multipath sub-descriptors; both may legitimately error, but must not
91+
// panic.
92+
params := &chaincfg.RegressionNetParams
93+
for mp := uint32(0); int(mp) < multipath && mp < 4; mp++ {
94+
_, _ = d.AddressAt(params, mp, 0)
95+
_, _ = d.ScriptCodeAt(mp, 0)
96+
}
97+
98+
// Planning with everything available drives the plan builder and its
99+
// satisfaction machinery.
100+
_, _ = d.PlanAt(0, 0, Assets{
101+
LookupEcdsaSig: func(string) bool {
102+
return true
103+
},
104+
LookupTapKeySpendSig: func(string) (uint32, bool) {
105+
return 64, true
106+
},
107+
LookupTapLeafScriptSig: func(string, string) (uint32, bool) {
108+
return 64, true
109+
},
110+
})
111+
}
112+
113+
// addDescriptorSeeds adds each non-empty, non-comment line of the given file as
114+
// a fuzz seed. A missing file is ignored so the fuzzer stays usable without the
115+
// full corpus.
116+
func addDescriptorSeeds(f *testing.F, path string) {
117+
data, err := os.ReadFile(path)
118+
if err != nil {
119+
return
120+
}
121+
122+
for _, line := range strings.Split(string(data), "\n") {
123+
line = strings.TrimSpace(line)
124+
if line == "" || strings.HasPrefix(line, "#") {
125+
continue
126+
}
127+
f.Add(line)
128+
}
129+
}

0 commit comments

Comments
 (0)