Skip to content

Commit 91840ce

Browse files
Merge pull request #151 from BitGo/BTC-2866.extend-descriptor-tooling
feat(wasm-utxo): extend descriptor testutils
2 parents 5cd8a99 + 7e28567 commit 91840ce

19 files changed

Lines changed: 1463 additions & 4 deletions

packages/wasm-utxo/js/bip32.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,38 @@ export class BIP32 implements BIP32Interface {
228228
return new BIP32(wasm);
229229
}
230230

231+
/**
232+
* Check equality with another BIP32 key.
233+
* Two keys are equal if they have the same type (public/private) and identical
234+
* BIP32 metadata (depth, parent fingerprint, child index, chain code, key data).
235+
* This is a fast comparison that does not require serialization.
236+
*
237+
* @param other - The other key to compare with. Accepts BIP32, or any BIP32Interface.
238+
* @returns True if the keys are equal
239+
*/
240+
equals(other: BIP32Interface): boolean {
241+
const otherWasm = other instanceof BIP32 ? other._wasm : BIP32.from(other)._wasm;
242+
return this._wasm.equals(otherWasm);
243+
}
244+
245+
/**
246+
* Custom JSON representation for debugging.
247+
* Always serializes the public key (xpub) to avoid leaking private keys.
248+
* Includes a `hasPrivateKey` flag to indicate whether the key is neutered.
249+
*/
250+
toJSON(): { xpub: string; hasPrivateKey: boolean } {
251+
return { xpub: this.neutered().toBase58(), hasPrivateKey: !this.isNeutered() };
252+
}
253+
254+
/**
255+
* Custom inspect representation for Node.js util.inspect and console.log.
256+
* Always shows the public key (xpub) to avoid leaking private keys.
257+
*/
258+
[Symbol.for("nodejs.util.inspect.custom")](): string {
259+
const flag = this.isNeutered() ? "" : ", hasPrivateKey";
260+
return `BIP32(${this.neutered().toBase58()}${flag})`;
261+
}
262+
231263
/**
232264
* Get the underlying WASM instance (internal use only)
233265
* @internal

packages/wasm-utxo/js/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,12 @@ export * as address from "./address.js";
1010
export * as ast from "./ast/index.js";
1111
export * as bip322 from "./bip322/index.js";
1212
export * as inscriptions from "./inscriptions.js";
13+
export * as message from "./message.js";
1314
export * as utxolibCompat from "./utxolibCompat.js";
1415
export * as fixedScriptWallet from "./fixedScriptWallet/index.js";
1516
export * as descriptorWallet from "./descriptorWallet/index.js";
1617
export * as bip32 from "./bip32.js";
1718
export * as ecpair from "./ecpair.js";
18-
export * as testutils from "./testutils/index.js";
19-
2019
// Only the most commonly used classes and types are exported at the top level for convenience
2120
export { ECPair } from "./ecpair.js";
2221
export { BIP32 } from "./bip32.js";
@@ -87,6 +86,11 @@ declare module "./wasm/wasm_utxo.js" {
8786
tapBip32Derivation: PsbtBip32Derivation[];
8887
}
8988

89+
/** PSBT output data with resolved address, returned by getOutputsWithAddress() */
90+
interface PsbtOutputDataWithAddress extends PsbtOutputData {
91+
address: string;
92+
}
93+
9094
interface WrapPsbt {
9195
// Signing methods (legacy - kept for backwards compatibility)
9296
signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult;
@@ -101,6 +105,7 @@ declare module "./wasm/wasm_utxo.js" {
101105
outputCount(): number;
102106
getInputs(): PsbtInputData[];
103107
getOutputs(): PsbtOutputData[];
108+
getOutputsWithAddress(coin: import("./coinName.js").CoinName): PsbtOutputDataWithAddress[];
104109
getPartialSignatures(inputIndex: number): Array<{
105110
pubkey: Uint8Array;
106111
signature: Uint8Array;

packages/wasm-utxo/js/message.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Bitcoin message signing and verification (BIP-137)
3+
*
4+
* This module provides functions for signing and verifying Bitcoin messages
5+
* using the standard Bitcoin Signed Message format (BIP-137).
6+
*
7+
* @example
8+
* ```typescript
9+
* import { message, ECPair } from '@bitgo/wasm-utxo';
10+
*
11+
* // Sign a message
12+
* const key = ECPair.fromWIF('L1TnU2zbNaAqMoVh65Cyvmcjzbrj41Gs9iTLcWbpJCpV1iNMXpuR');
13+
* const signature = message.signMessage('Hello, Bitcoin!', key);
14+
*
15+
* // Verify a message
16+
* const isValid = message.verifyMessage('Hello, Bitcoin!', key, signature);
17+
* ```
18+
*/
19+
20+
import { MessageNamespace } from "./wasm/wasm_utxo.js";
21+
import { ECPair, type ECPairArg } from "./ecpair.js";
22+
23+
/**
24+
* Sign a message using Bitcoin message signing (BIP-137)
25+
*
26+
* @param message - The message to sign
27+
* @param key - The key to sign with (must have a private key)
28+
* @returns 65-byte signature (1-byte header + 64-byte signature)
29+
*/
30+
export function signMessage(message: string, key: ECPairArg): Uint8Array {
31+
const ecpair = ECPair.from(key);
32+
return new Uint8Array(MessageNamespace.sign_message(ecpair.wasm, message));
33+
}
34+
35+
/**
36+
* Verify a Bitcoin message signature (BIP-137)
37+
*
38+
* @param message - The message that was signed
39+
* @param key - The key to verify against
40+
* @param signature - 65-byte signature (1-byte header + 64-byte signature)
41+
* @returns True if the signature is valid for this key
42+
*/
43+
export function verifyMessage(message: string, key: ECPairArg, signature: Uint8Array): boolean {
44+
const ecpair = ECPair.from(key);
45+
return MessageNamespace.verify_message(ecpair.wasm, message, signature);
46+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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(
160+
`TapLeafScripts are only supported for Taproot descriptors, got: ${JSON.stringify(t)}`,
161+
);
162+
}
163+
if (!Array.isArray(node.tr) || node.tr.length !== 2) {
164+
throw new Error(`expected tuple, got: ${JSON.stringify(node.tr)}`);
165+
}
166+
const tapscript = node.tr[1];
167+
if (!Array.isArray(tapscript)) {
168+
throw new Error(`expected tapscript to be an array, got: ${JSON.stringify(tapscript)}`);
169+
}
170+
return getTapLeafScriptNodes(tapscript);
171+
}
172+
173+
throw new Error(`Invalid input: ${JSON.stringify(t)}`);
174+
}
175+
176+
export function containsKey(
177+
script: Miniscript | ast.MiniscriptNode,
178+
key: BIP32Interface | string,
179+
): boolean {
180+
if (script instanceof Miniscript) {
181+
script = ast.fromMiniscript(script);
182+
}
183+
if ("pk" in script) {
184+
return script.pk === toXOnly(key);
185+
}
186+
throw new Error(`Unsupported script type: ${JSON.stringify(script)}`);
187+
}
188+
189+
export function getTapLeafScripts(d: Descriptor): string[] {
190+
return getTapLeafScriptNodes(ast.fromDescriptor(d)).map((n) =>
191+
Miniscript.fromString(ast.formatNode(n), "tap").toString(),
192+
);
193+
}
194+
195+
export function getDescriptor(
196+
template: DescriptorTemplate,
197+
keys: KeyTriple | string[] = getDefaultXPubs(),
198+
path = "0/*",
199+
): Descriptor {
200+
return Descriptor.fromStringDetectType(ast.formatNode(getDescriptorNode(template, keys, path)));
201+
}
202+
203+
export function getDescriptorMap(
204+
template: DescriptorTemplate,
205+
keys: KeyTriple | string[] = getDefaultXPubs(),
206+
): DescriptorMap {
207+
return toDescriptorMap({
208+
external: getDescriptor(template, keys, "0/*").toString(),
209+
internal: getDescriptor(template, keys, "1/*").toString(),
210+
});
211+
}
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";

0 commit comments

Comments
 (0)