Skip to content

Commit 9b9f9f1

Browse files
committed
descriptors: add Lift and PlanAt methods
This commit adds the Lift() and PlanAt() methods to the Descriptor struct.
1 parent cafbace commit 9b9f9f1

10 files changed

Lines changed: 3093 additions & 0 deletions

File tree

descriptors/differential_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ type descRecords struct {
1818
mplen string
1919
typ string
2020
mweight string
21+
lift string
22+
plan []string
2123
keys []string
2224
addr [][]string
2325
scode [][]string
@@ -73,6 +75,12 @@ func TestDescriptorDifferential(t *testing.T) {
7375
case "MWEIGHT":
7476
rec.mweight = fields[2]
7577

78+
case "LIFT":
79+
rec.lift = fields[2]
80+
81+
case "PLAN":
82+
rec.plan = fields[2:]
83+
7684
case "KEY":
7785
rec.keys = append(rec.keys, fields[2])
7886

@@ -96,6 +104,48 @@ func TestDescriptorDifferential(t *testing.T) {
96104
}
97105
}
98106

107+
// planScriptSigAdjustment returns how much larger the scriptSig this package
108+
// reports for a plan is than the one rust-miniscript v13 reports, which is only
109+
// non-zero for P2SH descriptors and has two independent reasons:
110+
//
111+
// - for a P2SH-wrapped segwit output, rust reports the raw length of the
112+
// scriptSig, while Plan.ScriptSigSize is documented as the size of the
113+
// serialized field, i.e. one byte more for the var-int that prefixes it. (A
114+
// plan with an empty scriptSig reports 1 in both implementations, so rust is
115+
// only self-consistent for that case.)
116+
// - for a legacy P2SH, rust does not count the redeem script at all, because
117+
// its plan hands it to the PSBT input as a separate field instead of
118+
// producing scriptSig bytes. Plan.Satisfy does produce them, so its size has
119+
// to cover the redeem script push. Note rust's own max_weight_to_satisfy
120+
// counts the redeem script too, i.e. its two APIs disagree with each other.
121+
// No byte is added for the var-int here: rust sizes a legacy scriptSig like
122+
// a witness, whose element count takes the byte our convention spends on the
123+
// var-int prefix.
124+
func planScriptSigAdjustment(t *testing.T, d *Descriptor) uint64 {
125+
switch d.DescType() {
126+
case DescTypeShWpkh, DescTypeShWsh:
127+
return 1
128+
129+
case DescTypeSh:
130+
// The redeem script of a legacy P2SH is its script code.
131+
redeem, err := d.ScriptCodeAt(0, 0)
132+
require.NoError(t, err)
133+
134+
return uint64(pushOpcodeSize(len(redeem)) + len(redeem))
135+
136+
default:
137+
return 0
138+
}
139+
}
140+
141+
// mustUint64 parses a decimal reference value.
142+
func mustUint64(t *testing.T, s string) uint64 {
143+
value, err := strconv.ParseUint(s, 10, 64)
144+
require.NoErrorf(t, err, "parsing %q", s)
145+
146+
return value
147+
}
148+
99149
// checkDescriptor parses one descriptor in Go and asserts all of its computed
100150
// properties match the rust reference records.
101151
func checkDescriptor(t *testing.T, expr string, rec *descRecords) {
@@ -120,6 +170,51 @@ func checkDescriptor(t *testing.T, expr string, rec *descRecords) {
120170
"max weight to satisfy for %s", expr)
121171
}
122172

173+
// Lifted semantic policy, compared in its canonical display form.
174+
policy, liftErr := d.Lift()
175+
if strings.HasPrefix(rec.lift, "ERR:") {
176+
require.Errorf(t, liftErr, "expected lift error for %s", expr)
177+
} else {
178+
require.NoErrorf(t, liftErr, "lift for %s", expr)
179+
require.Equalf(t, rec.lift, policy.String(),
180+
"lifted policy for %s", expr)
181+
}
182+
183+
// Plan weights, computed with every signature and timelock available so
184+
// the planner picks the cheapest spending path.
185+
relLock, absLock := uint32(65535), uint32(499999999)
186+
plan, planErr := d.PlanAt(0, 0, Assets{
187+
LookupEcdsaSig: func(string) bool { return true },
188+
LookupTapKeySpendSig: func(string) (uint32, bool) {
189+
return 64, true
190+
},
191+
LookupTapLeafScriptSig: func(string, string) (uint32, bool) {
192+
return 64, true
193+
},
194+
RelativeLocktime: &relLock,
195+
AbsoluteLocktime: &absLock,
196+
})
197+
if len(rec.plan) == 1 && rec.plan[0] == "ERR" {
198+
require.Errorf(t, planErr, "expected plan error for %s", expr)
199+
} else {
200+
require.NoErrorf(t, planErr, "plan for %s", expr)
201+
require.Lenf(t, rec.plan, 3, "plan record for %s", expr)
202+
203+
// The scriptSig accounting of a P2SH plan intentionally differs
204+
// from rust-miniscript v13, see planScriptSigAdjustment.
205+
adjust := planScriptSigAdjustment(t, d)
206+
wantScriptSig := mustUint64(t, rec.plan[1]) + adjust
207+
wantWeight := mustUint64(t, rec.plan[0]) + 4*adjust
208+
209+
require.Equalf(t, wantWeight, plan.SatisfactionWeight(),
210+
"plan satisfaction weight for %s", expr)
211+
require.Equalf(t, wantScriptSig, plan.ScriptSigSize(),
212+
"plan scriptsig size for %s", expr)
213+
require.Equalf(t, rec.plan[2], strconv.FormatUint(
214+
plan.WitnessSize(), 10),
215+
"plan witness size for %s", expr)
216+
}
217+
123218
for _, a := range rec.addr {
124219
// a = [mp, idx, net, value] (value may be "ERR:..."), or
125220
// [mp, idx, "ERR", msg] for a derivation error.

descriptors/key.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,56 @@ func (k *descKey) multipathLen() int {
424424
return 1
425425
}
426426

427+
// definiteString returns the canonical string of the key with its path fully
428+
// resolved at the given multipath and derivation index: the multipath element
429+
// is replaced by its multipath-index value and the wildcard by the derivation
430+
// index. This matches rust-miniscript's DefiniteDescriptorKey display and is
431+
// the identifier passed to the plan asset and satisfier lookups.
432+
func (k *descKey) definiteString(multipathIndex,
433+
derivationIndex uint32) string {
434+
435+
// Keep any optional "[origin]" prefix verbatim, since it contains its
436+
// own path separators.
437+
origin, rest := "", k.raw
438+
if strings.HasPrefix(rest, "[") {
439+
if end := strings.IndexByte(rest, ']'); end >= 0 {
440+
origin, rest = rest[:end+1], rest[end+1:]
441+
}
442+
}
443+
444+
// The remainder is the key followed by its path steps; the key is the
445+
// part before the first separator.
446+
base := rest
447+
if slash := strings.IndexByte(rest, '/'); slash >= 0 {
448+
base = rest[:slash]
449+
}
450+
451+
var b strings.Builder
452+
b.WriteString(origin)
453+
b.WriteString(base)
454+
for _, step := range k.steps {
455+
b.WriteByte('/')
456+
switch step.kind {
457+
case stepMultipath:
458+
b.WriteString(step.multipath[multipathIndex].String())
459+
460+
case stepWildcard:
461+
// The wildcard resolves to the derivation index, which
462+
// keeps the hardened indicator of the wildcard itself.
463+
resolved := pathIndex{
464+
num: derivationIndex,
465+
hardened: step.index.hardened,
466+
}
467+
b.WriteString(resolved.String())
468+
469+
default:
470+
b.WriteString(step.index.String())
471+
}
472+
}
473+
474+
return b.String()
475+
}
476+
427477
// isWildcard returns whether the key has a wildcard element and is therefore
428478
// ranged.
429479
func (k *descKey) isWildcard() bool {

descriptors/key_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"strings"
55
"testing"
66

7+
"github.com/btcsuite/btcd/btcutil/v2/hdkeychain"
78
"github.com/stretchr/testify/require"
89
)
910

@@ -246,3 +247,85 @@ func TestKeyOriginValidation(t *testing.T) {
246247
})
247248
}
248249
}
250+
251+
// TestHardenedDerivation checks the handling of hardened derivation steps,
252+
// which BIP380 and BIP389 allow anywhere in a path, including as the wildcard
253+
// and inside a multipath element. Deriving one needs the private extended key:
254+
// the descriptor is valid either way, so the distinction belongs at derivation
255+
// time rather than at parse time, where a hardened wildcard used to be rejected
256+
// outright.
257+
func TestHardenedDerivation(t *testing.T) {
258+
t.Parallel()
259+
260+
const (
261+
xpub = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqh" +
262+
"MkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJu" +
263+
"ZZvRcEL"
264+
xprv = "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWY" +
265+
"pDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCq" +
266+
"E2VbFWc"
267+
)
268+
269+
// A hardened path is derivable from a private extended key.
270+
priv, err := parseDescKey(xprv+"/3h/4'/5h/*h", keyFormCompressed)
271+
require.NoError(t, err)
272+
273+
derived, err := priv.derive(0, 7)
274+
require.NoError(t, err)
275+
require.Len(t, derived, 33)
276+
277+
// The definite key string keeps the hardened indicators, with the
278+
// wildcard resolved to the derivation index.
279+
require.Equal(
280+
t, xprv+"/3'/4'/5'/7'", priv.definiteString(0, 7),
281+
)
282+
283+
// The same expression is valid with a public extended key, but cannot
284+
// be derived from it.
285+
pub, err := parseDescKey(xpub+"/3h/4h/5h/*h", keyFormCompressed)
286+
require.NoError(t, err)
287+
288+
_, err = pub.derive(0, 7)
289+
require.ErrorContains(t, err, "cannot derive the hardened child")
290+
291+
// An unhardened wildcard on the same key works.
292+
unhardened, err := parseDescKey(xpub+"/*", keyFormCompressed)
293+
require.NoError(t, err)
294+
295+
_, err = unhardened.derive(0, 7)
296+
require.NoError(t, err)
297+
298+
// A derivation index in the hardened range must not silently turn an
299+
// unhardened wildcard into a hardened derivation.
300+
_, err = unhardened.derive(0, hdkeychain.HardenedKeyStart)
301+
require.ErrorContains(t, err, "in the hardened range")
302+
303+
// A multipath element may hold hardened indices (BIP389), which select
304+
// hardened children of the private key.
305+
multi, err := parseDescKey(
306+
xprv+"/<2147483647h;0>/0", keyFormCompressed,
307+
)
308+
require.NoError(t, err)
309+
require.Equal(t, 2, multi.multipathLen())
310+
311+
hardenedPath, err := multi.derive(0, 0)
312+
require.NoError(t, err)
313+
unhardenedPath, err := multi.derive(1, 0)
314+
require.NoError(t, err)
315+
require.NotEqual(t, hardenedPath, unhardenedPath)
316+
317+
require.Equal(t, xprv+"/2147483647'/0", multi.definiteString(0, 0))
318+
require.Equal(t, xprv+"/0/0", multi.definiteString(1, 0))
319+
320+
// A multipath element must not repeat an index (BIP389).
321+
_, err = parseDescKey(xpub+"/<0;0>/*", keyFormCompressed)
322+
require.ErrorContains(t, err, "more than once")
323+
324+
_, err = parseDescKey(xpub+"/<0;1;0>/*", keyFormCompressed)
325+
require.ErrorContains(t, err, "more than once")
326+
327+
// The hardened and unhardened spelling of the same index are the same
328+
// child, so they collide as well.
329+
_, err = parseDescKey(xprv+"/<1h;1'>/*", keyFormCompressed)
330+
require.ErrorContains(t, err, "more than once")
331+
}

0 commit comments

Comments
 (0)