Skip to content

Commit 090bac5

Browse files
OttoAllmendingerllm-git
andcommitted
feat(utxo-core): add descriptor support for fixed script wallets
Add functionality to generate output descriptors compatible with BitGo's proprietary HD wallet setup. Support p2sh, p2shP2wsh, and p2wsh script types for both internal and external chains. Issue: BTC-2170 Co-authored-by: llm-git <[email protected]>
1 parent 097e425 commit 090bac5

File tree

2 files changed

+186
-0
lines changed

2 files changed

+186
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { Descriptor, ast } from '@bitgo/wasm-miniscript';
3+
4+
import { DescriptorMap } from './DescriptorMap';
5+
6+
/** Expand a template with the given root wallet keys and chain code */
7+
function expand(rootWalletKeys: utxolib.bitgo.RootWalletKeys, keyIndex: number, chainCode: number): string {
8+
if (keyIndex !== 0 && keyIndex !== 1 && keyIndex !== 2) {
9+
throw new Error('Invalid key index');
10+
}
11+
const xpub = rootWalletKeys.triple[keyIndex].neutered().toBase58();
12+
const prefix = rootWalletKeys.derivationPrefixes[keyIndex];
13+
return xpub + '/' + prefix + '/' + chainCode + '/*';
14+
}
15+
16+
/**
17+
* Get a standard output descriptor that corresponds to the proprietary HD wallet setup
18+
* used in BitGo wallets.
19+
* Only supports a subset of script types.
20+
*/
21+
export function getDescriptorForScriptType(
22+
rootWalletKeys: utxolib.bitgo.RootWalletKeys,
23+
scriptType: 'p2sh' | 'p2shP2wsh' | 'p2wsh',
24+
scope: 'internal' | 'external'
25+
): Descriptor {
26+
const chain =
27+
scope === 'external'
28+
? utxolib.bitgo.getExternalChainCode(scriptType)
29+
: utxolib.bitgo.getInternalChainCode(scriptType);
30+
const multi: ast.MiniscriptNode = {
31+
multi: [2, ...rootWalletKeys.triple.map((_, i) => expand(rootWalletKeys, i, chain))],
32+
};
33+
switch (scriptType) {
34+
case 'p2sh':
35+
return Descriptor.fromString(ast.formatNode({ sh: multi }), 'derivable');
36+
case 'p2shP2wsh':
37+
return Descriptor.fromString(ast.formatNode({ sh: { wsh: multi } }), 'derivable');
38+
case 'p2wsh':
39+
return Descriptor.fromString(ast.formatNode({ wsh: multi }), 'derivable');
40+
default:
41+
throw new Error(`Unsupported script type ${scriptType}`);
42+
}
43+
}
44+
45+
export function getNamedDescriptorsForRootWalletKeys(rootWalletKeys: utxolib.bitgo.RootWalletKeys): DescriptorMap {
46+
const scriptTypes = ['p2sh', 'p2shP2wsh', 'p2wsh'] as const;
47+
const scopes = ['external', 'internal'] as const;
48+
return new Map(
49+
scriptTypes.flatMap((scriptType) =>
50+
scopes.map((scope) => [`${scriptType}/${scope}`, getDescriptorForScriptType(rootWalletKeys, scriptType, scope)])
51+
)
52+
);
53+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import * as assert from 'node:assert/strict';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
import { Descriptor } from '@bitgo/wasm-miniscript';
5+
6+
import {
7+
getDescriptorForScriptType,
8+
getNamedDescriptorsForRootWalletKeys,
9+
} from '../../src/descriptor/fromFixedScriptWallet';
10+
11+
function getRootWalletKeys(derivationPrefixes?: utxolib.bitgo.Triple<string>): utxolib.bitgo.RootWalletKeys {
12+
// This is a fixed script wallet, so we use a fixed key triple.
13+
// In practice, this would be derived from the wallet's root keys.
14+
return new utxolib.bitgo.RootWalletKeys(utxolib.testutil.getKeyTriple('fixedScript'), derivationPrefixes);
15+
}
16+
17+
const customPrefixes: (utxolib.bitgo.Triple<string> | undefined)[] = [
18+
undefined,
19+
['1/2', '1/2', '1/2'], // Custom prefixes for testing
20+
['1/2', '3/4', '5/6'], // Different custom prefixes
21+
];
22+
const scriptTypes = ['p2sh', 'p2shP2wsh', 'p2wsh'] as const;
23+
const scope = ['external', 'internal'] as const;
24+
const index = [0, 1, 2];
25+
26+
/** Get the expected max weight to satisfy the descriptor */
27+
function getExpectedMaxWeightToSatisfy(scriptType: utxolib.bitgo.outputScripts.ScriptType2Of3) {
28+
switch (scriptType) {
29+
case 'p2sh':
30+
return 256;
31+
case 'p2shP2wsh':
32+
return 99;
33+
case 'p2wsh':
34+
return 64;
35+
default:
36+
throw new Error('unexpected script type');
37+
}
38+
}
39+
40+
/** Compute the total size of the input, including overhead */
41+
function getTotalInputSize(vSize: number) {
42+
const sizeOpPushData1 = 1;
43+
const sizeOpPushData2 = 2;
44+
return 32 /* txid */ + 4 /* vout */ + 4 /* nSequence */ + (vSize < 255 ? sizeOpPushData1 : sizeOpPushData2) + vSize;
45+
}
46+
47+
/** Get the full expected vSize of the input including overhead */
48+
function getExpectedVSize(scriptType: utxolib.bitgo.outputScripts.ScriptType2Of3) {
49+
// https://github.com/BitGo/BitGoJS/blob/master/modules/unspents/docs/input-costs.md
50+
switch (scriptType) {
51+
case 'p2sh':
52+
return 298;
53+
case 'p2shP2wsh':
54+
return 140;
55+
case 'p2wsh':
56+
return 105;
57+
default:
58+
throw new Error('unexpected script type');
59+
}
60+
}
61+
62+
function runTestGetDescriptorWithScriptType(
63+
customPrefix: utxolib.bitgo.Triple<string> | undefined,
64+
scriptType: 'p2sh' | 'p2shP2wsh' | 'p2wsh',
65+
index: number,
66+
scope: 'internal' | 'external'
67+
) {
68+
describe(`customPrefix=${customPrefix}, scriptType=${scriptType}, index=${index}, scope=${scope}`, function () {
69+
const rootWalletKeys = getRootWalletKeys(customPrefix);
70+
const chainCode =
71+
scope === 'external'
72+
? utxolib.bitgo.getExternalChainCode(scriptType)
73+
: utxolib.bitgo.getInternalChainCode(scriptType);
74+
const derivedKeys = rootWalletKeys.deriveForChainAndIndex(chainCode, index);
75+
const scriptUtxolib = utxolib.bitgo.outputScripts.createOutputScript2of3(
76+
derivedKeys.publicKeys,
77+
scriptType
78+
).scriptPubKey;
79+
80+
let descriptor: Descriptor;
81+
82+
before(function () {
83+
descriptor = getDescriptorForScriptType(rootWalletKeys, scriptType, scope);
84+
});
85+
86+
it('address should match descriptor', function () {
87+
const scriptFromDescriptor = Buffer.from(descriptor.atDerivationIndex(index).scriptPubkey());
88+
assert.deepStrictEqual(scriptUtxolib.toString('hex'), scriptFromDescriptor.toString('hex'));
89+
});
90+
91+
it('should have expected weights', function () {
92+
assert.ok(Number.isInteger(descriptor.maxWeightToSatisfy()));
93+
const vSize = Math.ceil(descriptor.maxWeightToSatisfy() / 4);
94+
assert.equal(vSize, getExpectedMaxWeightToSatisfy(scriptType));
95+
assert.equal(getTotalInputSize(vSize), getExpectedVSize(scriptType));
96+
});
97+
});
98+
}
99+
100+
function runTestGetDescriptorMap(customPrefix: utxolib.bitgo.Triple<string> | undefined) {
101+
describe(`getNamedDescriptorsForRootWalletKeys with customPrefix=${customPrefix}`, function () {
102+
const rootWalletKeys = getRootWalletKeys(customPrefix);
103+
const descriptorMap = getNamedDescriptorsForRootWalletKeys(rootWalletKeys);
104+
105+
it('should return a map with the correct number of entries', function () {
106+
assert.equal(descriptorMap.size, scriptTypes.length * scope.length);
107+
});
108+
109+
scriptTypes.forEach((scriptType) => {
110+
scope.forEach((s) => {
111+
const key = `${scriptType}/${s}`;
112+
it(`should have a correct descriptor for ${key}`, function () {
113+
const descriptorFromMap = descriptorMap.get(key);
114+
assert.ok(descriptorFromMap, `Descriptor for ${key} should exist`);
115+
const expectedDescriptor = getDescriptorForScriptType(rootWalletKeys, scriptType, s);
116+
assert.equal(descriptorFromMap.toString(), expectedDescriptor.toString());
117+
});
118+
});
119+
});
120+
});
121+
}
122+
123+
customPrefixes.forEach((customPrefix) => {
124+
scriptTypes.forEach((scriptType) => {
125+
index.forEach((index) => {
126+
scope.forEach((scope) => {
127+
runTestGetDescriptorWithScriptType(customPrefix, scriptType, index, scope);
128+
});
129+
});
130+
});
131+
132+
runTestGetDescriptorMap(customPrefix);
133+
});

0 commit comments

Comments
 (0)