Skip to content

Commit aeb595f

Browse files
Merge pull request #6361 from BitGo/BTC-2170.add-utxo-bin-fromfixedscript
feat(utxo-bin): add new utilities and descriptor conversion features
2 parents d5654da + 7c927ff commit aeb595f

File tree

11 files changed

+214
-3
lines changed

11 files changed

+214
-3
lines changed

modules/utxo-bin/.eslintrc.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
"extends": "../../.eslintrc.json",
33
"rules": {
44
"@typescript-eslint/explicit-module-boundary-types": "error",
5+
"import/no-internal-modules": [
6+
"error",
7+
{
8+
// these are false-positives
9+
// certain packages explicitly ALLOW deep imports via the `exports` directive in package.json
10+
"allow": [
11+
"@bitgo/utxo-core/*",
12+
"@noble/curves/*"
13+
]
14+
}
15+
],
516
"indent": "off"
617
}
718
}

modules/utxo-bin/bin/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
#!/usr/bin/env node
22
import * as yargs from 'yargs';
33

4-
import { cmdParseTx, cmdParseScript, cmdBip32, cmdPsbt, cmdAddress } from '../src/commands';
4+
import { cmdParseTx, cmdParseScript, cmdBip32, cmdPsbt, cmdAddress, cmdDescriptor } from '../src/commands';
55

66
yargs
77
.command(cmdParseTx)
88
.command(cmdAddress)
9+
.command(cmdDescriptor)
910
.command(cmdParseScript)
1011
.command(cmdPsbt)
1112
.command(cmdBip32)

modules/utxo-bin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@bitgo/blockapis": "^1.10.18",
3030
"@bitgo/statics": "^54.7.0",
3131
"@bitgo/unspents": "^0.48.3",
32+
"@bitgo/utxo-core": "^1.11.0",
3233
"@bitgo/utxo-lib": "^11.6.1",
3334
"@bitgo/wasm-miniscript": "2.0.0-beta.7",
3435
"@noble/curves": "1.8.1",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { CommandModule } from 'yargs';
2+
import * as utxolib from '@bitgo/utxo-lib';
3+
import { getNamedDescriptorsForRootWalletKeys } from '@bitgo/utxo-core/descriptor';
4+
5+
import {
6+
FormatTreeOrJson,
7+
formatTreeOrJson,
8+
getNetworkOptionsDemand,
9+
getRootWalletKeys,
10+
keyOptions,
11+
KeyOptions,
12+
} from '../../args';
13+
import { formatObjAsTree } from '../../format';
14+
15+
type Triple<T> = [T, T, T];
16+
17+
type ArgsFixedScriptToDescriptor = KeyOptions & {
18+
network: utxolib.Network;
19+
format: FormatTreeOrJson;
20+
};
21+
22+
function mapKeyToNetwork(key: utxolib.BIP32Interface, network: utxolib.Network): utxolib.BIP32Interface {
23+
key = utxolib.bip32.fromBase58(key.toBase58());
24+
key.network = network;
25+
return key;
26+
}
27+
28+
function mapRootWalletKeysToNetwork(
29+
rootWalletKeys: utxolib.bitgo.RootWalletKeys,
30+
network: utxolib.Network
31+
): utxolib.bitgo.RootWalletKeys {
32+
return new utxolib.bitgo.RootWalletKeys(
33+
rootWalletKeys.triple.map((key) => mapKeyToNetwork(key, network)) as Triple<utxolib.BIP32Interface>,
34+
rootWalletKeys.derivationPrefixes
35+
);
36+
}
37+
38+
export const cmdFromFixedScript: CommandModule<unknown, ArgsFixedScriptToDescriptor> = {
39+
command: 'fromFixedScript',
40+
describe: 'Convert BitGo FixedScript RootWalletKeys to output descriptors',
41+
builder(b) {
42+
return b.option(getNetworkOptionsDemand('bitcoin')).options(keyOptions).options({ format: formatTreeOrJson });
43+
},
44+
handler(argv): void {
45+
let rootWalletKeys = getRootWalletKeys(argv);
46+
if (argv.network !== utxolib.networks.bitcoin) {
47+
rootWalletKeys = mapRootWalletKeysToNetwork(rootWalletKeys, argv.network);
48+
}
49+
const descriptorMap = getNamedDescriptorsForRootWalletKeys(rootWalletKeys);
50+
const obj = Object.fromEntries(
51+
[...descriptorMap].map(([name, descriptor]) => [name, descriptor?.toString() ?? null])
52+
);
53+
if (argv.format === 'tree') {
54+
console.log(formatObjAsTree('descriptors', obj));
55+
} else if (argv.format === 'json') {
56+
console.log(JSON.stringify(obj, null, 2));
57+
} else {
58+
throw new Error(`Invalid format: ${argv.format}. Expected 'tree' or 'json'.`);
59+
}
60+
},
61+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { CommandModule } from 'yargs';
2+
import { cmdFromFixedScript } from './fromFixedScript';
3+
4+
export * from './fromFixedScript';
5+
6+
export const cmdDescriptor: CommandModule<unknown, unknown> = {
7+
command: 'descriptor <command>',
8+
describe: 'descriptor commands',
9+
builder(b) {
10+
return b.strict().command(cmdFromFixedScript).demandCommand();
11+
},
12+
handler() {
13+
// do nothing
14+
},
15+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './cmdParseTx';
22
export * from './cmdAddress';
3+
export * from './cmdDescriptor';
34
export * from './cmdParseScript';
45
export * from './cmdBip32';
56
export * from './cmdPsbt';

modules/utxo-bin/src/format.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
import { Chalk, Instance } from 'chalk';
22
import archy from 'archy';
33

4-
import { ParserNode, ParserNodeValue } from './Parser';
4+
import { Parser, ParserNode, ParserNodeValue } from './Parser';
5+
import { parseUnknown } from './parseUnknown';
56

67
const hideDefault = ['pubkeys', 'sequence', 'locktime', 'scriptSig', 'witness'];
78

89
export function formatSat(v: number | bigint): string {
910
return (Number(v) / 1e8).toFixed(8);
1011
}
1112

13+
type FormatOptions = {
14+
hide?: string[];
15+
chalk?: Chalk;
16+
};
17+
18+
function getDefaultChalk(): Chalk {
19+
if (process.env.NO_COLOR) {
20+
return new Instance({ level: 0 });
21+
}
22+
return new Instance();
23+
}
24+
1225
export function formatTree(
1326
n: ParserNode,
14-
{ hide = hideDefault, chalk = new Instance() }: { hide?: string[]; chalk?: Chalk } = {}
27+
{ hide = hideDefault, chalk = getDefaultChalk() }: FormatOptions = {}
1528
): string {
1629
function getLabel(
1730
label: string | number,
@@ -61,3 +74,13 @@ export function formatTree(
6174

6275
return archy(toArchy(n));
6376
}
77+
78+
export function formatObjAsTree(
79+
label: string | number,
80+
obj: unknown,
81+
{ hide = hideDefault, chalk = getDefaultChalk() }: FormatOptions = {}
82+
): string {
83+
const p = new Parser({ parseError: 'continue' });
84+
const node = parseUnknown(p, label, obj, { omit: hide });
85+
return formatTree(node, { hide, chalk });
86+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as util from 'node:util';
2+
3+
export async function captureConsole<T>(
4+
func: () => Promise<T>
5+
): Promise<{ stdout: string; stderr: string; result: T }> {
6+
process.env.NO_COLOR = '1'; // Disable colors in console output for easier testing
7+
const oldConsoleLog = console.log;
8+
const oldConsoleError = console.error;
9+
10+
const stdoutData: string[] = [];
11+
const stderrData: string[] = [];
12+
13+
console.log = (...args: any[]) => {
14+
stdoutData.push(util.format(...args));
15+
};
16+
17+
console.error = (...args: any[]) => {
18+
stderrData.push(util.format(...args));
19+
};
20+
21+
let result: T;
22+
try {
23+
result = await func();
24+
} finally {
25+
console.log = oldConsoleLog;
26+
console.error = oldConsoleError;
27+
}
28+
29+
const join = (data: string[]) => (data.length > 0 ? data.join('\n') + '\n' : '');
30+
31+
return {
32+
stdout: join(stdoutData),
33+
stderr: join(stderrData),
34+
result,
35+
};
36+
}
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+
import yargs from 'yargs';
3+
4+
import { cmdFromFixedScript } from '../../src/commands/cmdDescriptor';
5+
import { getFixtureString } from '../fixtures';
6+
import { getKeyTriple } from '../bip32.util';
7+
import { captureConsole } from '../captureConsole';
8+
9+
function keyArgs(): string[] {
10+
const [userKey, backupKey, bitgoKey] = getKeyTriple('generateAddress').map((k) => k.neutered().toBase58());
11+
return ['--userKey', userKey, '--backupKey', backupKey, '--bitgoKey', bitgoKey, '--scriptType', 'p2sh'];
12+
}
13+
14+
describe('cmdDescriptor fromFixedScript', function () {
15+
function runTest(argv: string[], fixtureName: string) {
16+
it(`should output expected descriptor (${fixtureName})`, async function () {
17+
const y = yargs(argv)
18+
.command(cmdFromFixedScript)
19+
.exitProcess(false)
20+
.fail((msg, err) => {
21+
throw err || new Error(msg);
22+
});
23+
24+
const { stdout, stderr } = await captureConsole(async () => {
25+
await y.parse();
26+
});
27+
28+
// Compare output to fixture, or check for expected descriptor substring
29+
const expected = await getFixtureString(`test/fixtures/fromFixedScript/${fixtureName}.txt`, stdout);
30+
assert.strictEqual(stdout.trim(), expected.trim());
31+
assert.strictEqual(stderr, '');
32+
});
33+
}
34+
35+
runTest(['fromFixedScript', ...keyArgs()], 'default');
36+
37+
runTest(['fromFixedScript', ...keyArgs(), '--network', 'testnet'], 'network-testnet');
38+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
descriptors
2+
├── p2sh/external: "sh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/0/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/0/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/0/*))#5npd8z8f"
3+
├── p2sh/internal: "sh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/1/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/1/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/1/*))#ung2jfhq"
4+
├── p2shP2wsh/external: "sh(wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/10/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/10/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/10/*)))#nlkef9fp"
5+
├── p2shP2wsh/internal: "sh(wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/11/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/11/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/11/*)))#3v6t34rd"
6+
├── p2wsh/external: "wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/20/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/20/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/20/*))#2sputcy5"
7+
├── p2wsh/internal: "wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/21/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/21/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/21/*))#9ush3alg"
8+
├── p2tr/external: null
9+
├── p2tr/internal: null
10+
├── p2trMusig2/external: null
11+
└── p2trMusig2/internal: null
12+

0 commit comments

Comments
 (0)