Skip to content

Commit 76143f7

Browse files
Merge pull request #6227 from BitGo/BTC-2170.descriptor-support-for-fixed-script-wallets
feat(utxo-core): add descriptor support for fixed script wallets
2 parents 2c99be5 + 090bac5 commit 76143f7

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)