Skip to content

Commit 086d6af

Browse files
Merge pull request #5258 from BitGo/BTC-1450.descriptorBuilder
feat(abstract-utxo): add DescriptorBuilder and parser
2 parents d329d98 + e25bc68 commit 086d6af

File tree

4 files changed

+205
-0
lines changed

4 files changed

+205
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { BIP32Interface } from '@bitgo/utxo-lib';
2+
import { Descriptor } from '@bitgo/wasm-miniscript';
3+
4+
type DescriptorWithKeys<TName extends string> = {
5+
name: TName;
6+
keys: BIP32Interface[];
7+
path: string;
8+
};
9+
10+
export type DescriptorBuilder =
11+
| DescriptorWithKeys<'Wsh2Of2'>
12+
| DescriptorWithKeys<'Wsh2Of3'>
13+
/*
14+
* This is a wrapped segwit 2of3 multisig that also uses a relative locktime with
15+
* an OP_DROP (requiring a miniscript extension).
16+
* It is basically what is used in CoreDao staking transactions.
17+
*/
18+
| (DescriptorWithKeys<'ShWsh2Of3CltvDrop'> & { locktime: number });
19+
20+
function toXPub(k: BIP32Interface | string): string {
21+
if (typeof k === 'string') {
22+
return k;
23+
}
24+
return k.neutered().toBase58();
25+
}
26+
27+
function multi(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
28+
if (n < m) {
29+
throw new Error(`Cannot create ${m} of ${n} multisig`);
30+
}
31+
if (keys.length < n) {
32+
throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`);
33+
}
34+
keys = keys.slice(0, n);
35+
return `multi(${m},${keys.map((k) => `${toXPub(k)}/${path}`).join(',')})`;
36+
}
37+
38+
function getDescriptorString(builder: DescriptorBuilder): string {
39+
switch (builder.name) {
40+
case 'Wsh2Of3':
41+
return `wsh(${multi(2, 3, builder.keys, builder.path)})`;
42+
case 'Wsh2Of2':
43+
return `wsh(${multi(2, 2, builder.keys, builder.path)})`;
44+
case 'ShWsh2Of3CltvDrop':
45+
return `sh(wsh(and_v(r:after(${builder.locktime}),${multi(2, 3, builder.keys, builder.path)})))`;
46+
}
47+
throw new Error(`Unknown descriptor template: ${builder}`);
48+
}
49+
50+
export function getDescriptorFromBuilder(builder: DescriptorBuilder): Descriptor {
51+
return Descriptor.fromString(getDescriptorString(builder), 'derivable');
52+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { getDescriptorFromBuilder, DescriptorBuilder } from './builder';
2+
export { parseDescriptor } from './parse';
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { BIP32Interface } from '@bitgo/utxo-lib';
2+
import * as utxolib from '@bitgo/utxo-lib';
3+
import { Descriptor } from '@bitgo/wasm-miniscript';
4+
import { DescriptorBuilder, getDescriptorFromBuilder } from './builder';
5+
6+
type NodeUnary<Key extends string> = { [k in Key]: unknown };
7+
8+
function isUnaryNode<TKey extends string>(node: unknown, key: TKey): node is NodeUnary<TKey> {
9+
if (typeof node !== 'object' || node === null) {
10+
return false;
11+
}
12+
const keys = Object.keys(node);
13+
return keys.length === 1 && keys[0] === key;
14+
}
15+
16+
function unwrapNode(node: unknown, path: string[]): unknown {
17+
let current = node;
18+
for (const key of path) {
19+
if (!isUnaryNode(current, key)) {
20+
return undefined;
21+
}
22+
current = current[key];
23+
}
24+
return current;
25+
}
26+
27+
function parseMulti(node: unknown): {
28+
threshold: number;
29+
keys: BIP32Interface[];
30+
path: string;
31+
} {
32+
if (!Array.isArray(node)) {
33+
throw new Error('Unexpected node');
34+
}
35+
const [threshold, ...keyNodes] = node;
36+
if (typeof threshold !== 'number') {
37+
throw new Error('Expected threshold number');
38+
}
39+
const keyWithPath = keyNodes.map((keyNode) => {
40+
if (!isUnaryNode(keyNode, 'XPub')) {
41+
throw new Error('Expected XPub node');
42+
}
43+
if (typeof keyNode.XPub !== 'string') {
44+
throw new Error('Expected XPub string');
45+
}
46+
const parts = keyNode.XPub.split('/');
47+
return { xpub: parts[0], path: parts.slice(1).join('/') };
48+
});
49+
const paths = keyWithPath.map((k) => k.path);
50+
paths.forEach((path, i) => {
51+
if (path !== paths[0]) {
52+
throw new Error(`Expected all paths to be the same: ${path} !== ${paths[0]}`);
53+
}
54+
});
55+
return {
56+
threshold,
57+
keys: keyWithPath.map((k) => utxolib.bip32.fromBase58(k.xpub)),
58+
path: paths[0],
59+
};
60+
}
61+
62+
export function parseDescriptorNode(node: unknown): DescriptorBuilder {
63+
const wshMsMulti = unwrapNode(node, ['Wsh', 'Ms', 'Multi']);
64+
if (wshMsMulti) {
65+
const { threshold, keys, path } = parseMulti(wshMsMulti);
66+
let name;
67+
if (threshold === 2 && keys.length === 2) {
68+
name = 'Wsh2Of2';
69+
} else if (threshold === 2 && keys.length === 3) {
70+
name = 'Wsh2Of3';
71+
} else {
72+
throw new Error('Unexpected multisig');
73+
}
74+
return {
75+
name,
76+
keys,
77+
path,
78+
};
79+
}
80+
81+
const shWshMsAndV = unwrapNode(node, ['Sh', 'Wsh', 'Ms', 'AndV']);
82+
if (shWshMsAndV) {
83+
if (Array.isArray(shWshMsAndV) && shWshMsAndV.length === 2) {
84+
const [a, b] = shWshMsAndV;
85+
const dropAfterAbsLocktime = unwrapNode(a, ['Drop', 'After', 'absLockTime']);
86+
if (typeof dropAfterAbsLocktime !== 'number') {
87+
throw new Error('Expected absLockTime number');
88+
}
89+
if (!isUnaryNode(b, 'Multi')) {
90+
throw new Error('Expected Multi node');
91+
}
92+
const multi = parseMulti(b.Multi);
93+
if (multi.threshold === 2 && multi.keys.length === 3) {
94+
return {
95+
name: 'ShWsh2Of3CltvDrop',
96+
locktime: dropAfterAbsLocktime,
97+
keys: multi.keys,
98+
path: multi.path,
99+
};
100+
}
101+
}
102+
}
103+
104+
throw new Error('Not implemented');
105+
}
106+
107+
export function parseDescriptor(descriptor: Descriptor): DescriptorBuilder {
108+
const builder = parseDescriptorNode(descriptor.node());
109+
if (getDescriptorFromBuilder(builder).toString() !== descriptor.toString()) {
110+
throw new Error('Failed to parse descriptor');
111+
}
112+
return builder;
113+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as assert from 'assert';
2+
3+
import { parseDescriptor, DescriptorBuilder, getDescriptorFromBuilder } from '../../src/descriptor/builder';
4+
import { getKeyTriple } from '../core/key.utils';
5+
6+
function getDescriptorBuilderForType(name: DescriptorBuilder['name']): DescriptorBuilder {
7+
const keys = getKeyTriple().map((k) => k.neutered());
8+
switch (name) {
9+
case 'Wsh2Of2':
10+
case 'Wsh2Of3':
11+
return {
12+
name,
13+
keys: keys.slice(0, name === 'Wsh2Of3' ? 3 : 2),
14+
path: '0/*',
15+
};
16+
case 'ShWsh2Of3CltvDrop':
17+
return {
18+
name,
19+
keys,
20+
path: '0/*',
21+
locktime: 1,
22+
};
23+
}
24+
}
25+
26+
function describeForName(n: DescriptorBuilder['name']) {
27+
describe(`DescriptorBuilder ${n}`, () => {
28+
it('parses descriptor template', () => {
29+
const builder = getDescriptorBuilderForType(n);
30+
const descriptor = getDescriptorFromBuilder(builder);
31+
assert.deepStrictEqual(builder, parseDescriptor(descriptor));
32+
});
33+
});
34+
}
35+
36+
describeForName('Wsh2Of2');
37+
describeForName('Wsh2Of3');
38+
describeForName('ShWsh2Of3CltvDrop');

0 commit comments

Comments
 (0)