Skip to content

Commit 46310cf

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add descriptor testing utilities
Port test utilities from utxo-core for descriptor wallet testing: - Add fixture handling and object serialization helpers - Add descriptor template functions to create common test descriptors - Add mock PSBT utilities for descriptor wallet testing Issue: BTC-2866 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 081142c commit 46310cf

6 files changed

Lines changed: 762 additions & 0 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* Descriptor test utilities for building common descriptor templates.
3+
* Ported from @bitgo/utxo-core/testutil/descriptor/descriptors.ts.
4+
*/
5+
import assert from "assert";
6+
7+
import { BIP32 } from "../../bip32.js";
8+
import type { BIP32Interface } from "../../bip32.js";
9+
import { Descriptor, Miniscript, ast } from "../../index.js";
10+
import type { Triple } from "../../triple.js";
11+
import { DescriptorMap, PsbtParams } from "../../descriptorWallet/index.js";
12+
import { getKeyTriple } from "../keys.js";
13+
14+
type KeyTriple = Triple<BIP32Interface>;
15+
16+
export type DescriptorTemplate =
17+
| "Wsh2Of3"
18+
| "Tr1Of3-NoKeyPath-Tree"
19+
// no xpubs, just plain keys
20+
| "Tr1Of3-NoKeyPath-Tree-Plain"
21+
| "Tr2Of3-NoKeyPath"
22+
| "Wsh2Of2"
23+
/**
24+
* Wrapped segwit 2of3 multisig with a relative locktime OP_DROP
25+
* (requiring a miniscript extension). Used in CoreDao staking transactions.
26+
*/
27+
| "Wsh2Of3CltvDrop";
28+
29+
/**
30+
* Get the BIP-341 "Nothing Up My Sleeve" (NUMS) unspendable key.
31+
* This is the x-only public key with unknown discrete logarithm
32+
* constructed by hashing the uncompressed secp256k1 base point G.
33+
*
34+
* @see https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs
35+
*/
36+
export function getUnspendableKey(): string {
37+
return "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0";
38+
}
39+
40+
export function getDefaultXPubs(seed?: string): Triple<string> {
41+
return getKeyTriple(seed ?? "default").map((k) => k.neutered().toBase58()) as Triple<string>;
42+
}
43+
44+
function toDescriptorMap(v: Record<string, string>): DescriptorMap {
45+
return new Map(Object.entries(v).map(([k, v]) => [k, Descriptor.fromString(v, "derivable")]));
46+
}
47+
48+
function toXPub(k: BIP32Interface | string, path: string): string {
49+
if (typeof k === "string") {
50+
return k + "/" + path;
51+
}
52+
return k.neutered().toBase58() + "/" + path;
53+
}
54+
55+
function toPlain(k: BIP32Interface | string, { xonly = false } = {}): string {
56+
if (typeof k === "string") {
57+
if (k.startsWith("xpub") || k.startsWith("xprv")) {
58+
return toPlain(BIP32.fromBase58(k), { xonly });
59+
}
60+
return k;
61+
}
62+
return toHex(k.publicKey.subarray(xonly ? 1 : 0));
63+
}
64+
65+
function toHex(bytes: Uint8Array): string {
66+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
67+
}
68+
69+
function toXOnly(k: BIP32Interface | string): string {
70+
return toPlain(k, { xonly: true });
71+
}
72+
73+
function multiArgs(
74+
m: number,
75+
n: number,
76+
keys: BIP32Interface[] | string[],
77+
path: string,
78+
): [number, ...string[]] {
79+
if (n < m) {
80+
throw new Error(`Cannot create ${m} of ${n} multisig`);
81+
}
82+
if (keys.length < n) {
83+
throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`);
84+
}
85+
keys = keys.slice(0, n);
86+
return [m, ...keys.map((k: BIP32Interface | string) => toXPub(k, path))];
87+
}
88+
89+
export function getPsbtParams(t: DescriptorTemplate): Partial<PsbtParams> {
90+
switch (t) {
91+
case "Wsh2Of3CltvDrop":
92+
return { locktime: 1 };
93+
default:
94+
return {};
95+
}
96+
}
97+
98+
export function getDescriptorNode(
99+
template: DescriptorTemplate,
100+
keys: KeyTriple | string[] = getDefaultXPubs(),
101+
path = "0/*",
102+
): ast.DescriptorNode {
103+
switch (template) {
104+
case "Wsh2Of3":
105+
return {
106+
wsh: { multi: multiArgs(2, 3, keys, path) },
107+
};
108+
case "Wsh2Of3CltvDrop": {
109+
const { locktime } = getPsbtParams(template);
110+
assert(locktime);
111+
return {
112+
wsh: {
113+
and_v: [{ "r:after": locktime }, { multi: multiArgs(2, 3, keys, path) }],
114+
},
115+
};
116+
}
117+
case "Wsh2Of2":
118+
return {
119+
wsh: { multi: multiArgs(2, 2, keys, path) },
120+
};
121+
case "Tr2Of3-NoKeyPath":
122+
return {
123+
tr: [getUnspendableKey(), { multi_a: multiArgs(2, 3, keys, path) }],
124+
};
125+
case "Tr1Of3-NoKeyPath-Tree":
126+
return {
127+
tr: [
128+
getUnspendableKey(),
129+
[
130+
{ pk: toXPub(keys[0], path) },
131+
[{ pk: toXPub(keys[1], path) }, { pk: toXPub(keys[2], path) }],
132+
],
133+
],
134+
};
135+
case "Tr1Of3-NoKeyPath-Tree-Plain":
136+
return {
137+
tr: [
138+
getUnspendableKey(),
139+
[{ pk: toXOnly(keys[0]) }, [{ pk: toXOnly(keys[1]) }, { pk: toXOnly(keys[2]) }]],
140+
],
141+
};
142+
}
143+
throw new Error(`Unknown descriptor template: ${template as string}`);
144+
}
145+
146+
type TapTree = [TapTree, TapTree] | ast.MiniscriptNode;
147+
148+
function getTapLeafScriptNodes(t: ast.DescriptorNode | TapTree): ast.MiniscriptNode[] {
149+
if (Array.isArray(t)) {
150+
if (t.length !== 2) {
151+
throw new Error(`expected tuple, got: ${JSON.stringify(t)}`);
152+
}
153+
return t.map((v) => (Array.isArray(v) ? getTapLeafScriptNodes(v) : v)).flat();
154+
}
155+
156+
if (typeof t === "object") {
157+
const node = t;
158+
if (!("tr" in node)) {
159+
throw new Error(`TapLeafScripts are only supported for Taproot descriptors, got: ${JSON.stringify(t)}`);
160+
}
161+
if (!Array.isArray(node.tr) || node.tr.length !== 2) {
162+
throw new Error(`expected tuple, got: ${JSON.stringify(node.tr)}`);
163+
}
164+
const tapscript = node.tr[1];
165+
if (!Array.isArray(tapscript)) {
166+
throw new Error(`expected tapscript to be an array, got: ${JSON.stringify(tapscript)}`);
167+
}
168+
return getTapLeafScriptNodes(tapscript);
169+
}
170+
171+
throw new Error(`Invalid input: ${JSON.stringify(t)}`);
172+
}
173+
174+
export function containsKey(
175+
script: Miniscript | ast.MiniscriptNode,
176+
key: BIP32Interface | string,
177+
): boolean {
178+
if (script instanceof Miniscript) {
179+
script = ast.fromMiniscript(script);
180+
}
181+
if ("pk" in script) {
182+
return script.pk === toXOnly(key);
183+
}
184+
throw new Error(`Unsupported script type: ${JSON.stringify(script)}`);
185+
}
186+
187+
export function getTapLeafScripts(d: Descriptor): string[] {
188+
return getTapLeafScriptNodes(ast.fromDescriptor(d)).map((n) =>
189+
Miniscript.fromString(ast.formatNode(n), "tap").toString(),
190+
);
191+
}
192+
193+
export function getDescriptor(
194+
template: DescriptorTemplate,
195+
keys: KeyTriple | string[] = getDefaultXPubs(),
196+
path = "0/*",
197+
): Descriptor {
198+
return Descriptor.fromStringDetectType(ast.formatNode(getDescriptorNode(template, keys, path)));
199+
}
200+
201+
export function getDescriptorMap(
202+
template: DescriptorTemplate,
203+
keys: KeyTriple | string[] = getDefaultXPubs(),
204+
): DescriptorMap {
205+
return toDescriptorMap({
206+
external: getDescriptor(template, keys, "0/*").toString(),
207+
internal: getDescriptor(template, keys, "1/*").toString(),
208+
});
209+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./descriptors.js";
2+
export * from "./mockPsbt.js";
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Mock PSBT utilities for descriptor wallet testing.
3+
* Ported from @bitgo/utxo-core/testutil/descriptor/mock.utils.ts.
4+
*
5+
* Key difference from utxo-core: returns wasm-utxo Psbt instances directly
6+
* instead of utxolib.bitgo.UtxoPsbt. PsbtParams does not require a network field.
7+
*/
8+
import { Descriptor, Miniscript, Psbt } from "../../index.js";
9+
import {
10+
PsbtParams,
11+
DerivedDescriptorTransactionInput,
12+
createPsbt,
13+
createScriptPubKeyFromDescriptor,
14+
WithOptDescriptor,
15+
Output,
16+
} from "../../descriptorWallet/index.js";
17+
18+
import {
19+
DescriptorTemplate,
20+
getDefaultXPubs,
21+
getDescriptor,
22+
getPsbtParams,
23+
} from "./descriptors.js";
24+
25+
type MockOutputIdParams = { hash?: string; vout?: number };
26+
27+
type BaseMockDescriptorOutputParams = {
28+
id?: MockOutputIdParams;
29+
index?: number;
30+
value?: bigint;
31+
sequence?: number;
32+
selectTapLeafScript?: Miniscript;
33+
};
34+
35+
function mockOutputId(id?: MockOutputIdParams): {
36+
hash: string;
37+
vout: number;
38+
} {
39+
const hash = id?.hash ?? "0101010101010101010101010101010101010101010101010101010101010101";
40+
const vout = id?.vout ?? 0;
41+
return { hash, vout };
42+
}
43+
44+
export function mockDerivedDescriptorWalletOutput(
45+
descriptor: Descriptor,
46+
outputParams: BaseMockDescriptorOutputParams = {},
47+
): DerivedDescriptorTransactionInput {
48+
const { value = BigInt(1e6) } = outputParams;
49+
const { hash, vout } = mockOutputId(outputParams.id);
50+
return {
51+
hash,
52+
index: vout,
53+
witnessUtxo: {
54+
script: createScriptPubKeyFromDescriptor(descriptor, undefined),
55+
value,
56+
},
57+
descriptor,
58+
selectTapLeafScript: outputParams.selectTapLeafScript,
59+
sequence: outputParams.sequence,
60+
};
61+
}
62+
63+
type MockInput = BaseMockDescriptorOutputParams & {
64+
index: number;
65+
descriptor: Descriptor;
66+
selectTapLeafScript?: Miniscript;
67+
};
68+
69+
type MockOutput = {
70+
descriptor: Descriptor;
71+
index: number;
72+
value: bigint;
73+
external?: boolean;
74+
};
75+
76+
function tryDeriveAtIndex(descriptor: Descriptor, index: number): Descriptor {
77+
return descriptor.hasWildcard() ? descriptor.atDerivationIndex(index) : descriptor;
78+
}
79+
80+
export function mockPsbt(
81+
inputs: MockInput[],
82+
outputs: MockOutput[],
83+
params: Partial<PsbtParams> = {},
84+
): Psbt {
85+
return createPsbt(
86+
params,
87+
inputs.map((i) =>
88+
mockDerivedDescriptorWalletOutput(tryDeriveAtIndex(i.descriptor, i.index), i),
89+
),
90+
outputs.map((o): WithOptDescriptor<Output> => {
91+
const derivedDescriptor = tryDeriveAtIndex(o.descriptor, o.index);
92+
return {
93+
script: createScriptPubKeyFromDescriptor(derivedDescriptor, undefined),
94+
value: o.value,
95+
descriptor: o.external ? undefined : derivedDescriptor,
96+
};
97+
}),
98+
);
99+
}
100+
101+
export function mockPsbtDefault({
102+
descriptorSelf = getDescriptor("Wsh2Of3", getDefaultXPubs("a")),
103+
descriptorOther = getDescriptor("Wsh2Of3", getDefaultXPubs("b")),
104+
params = {},
105+
}: {
106+
descriptorSelf?: Descriptor;
107+
descriptorOther?: Descriptor;
108+
params?: Partial<PsbtParams>;
109+
} = {}): Psbt {
110+
return mockPsbt(
111+
[
112+
{ descriptor: descriptorSelf, index: 0 },
113+
{ descriptor: descriptorSelf, index: 1, id: { vout: 1 } },
114+
],
115+
[
116+
{
117+
descriptor: descriptorOther,
118+
index: 0,
119+
value: BigInt(4e5),
120+
external: true,
121+
},
122+
{ descriptor: descriptorSelf, index: 0, value: BigInt(4e5) },
123+
],
124+
params,
125+
);
126+
}
127+
128+
export function mockPsbtDefaultWithDescriptorTemplate(
129+
t: DescriptorTemplate,
130+
params: Partial<PsbtParams> = {},
131+
): Psbt {
132+
return mockPsbtDefault({
133+
descriptorSelf: getDescriptor(t, getDefaultXPubs("a")),
134+
descriptorOther: getDescriptor(t, getDefaultXPubs("b")),
135+
params: { ...getPsbtParams(t), ...params },
136+
});
137+
}

0 commit comments

Comments
 (0)