|
| 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