diff --git a/modules/utxo-bin/.eslintrc.json b/modules/utxo-bin/.eslintrc.json index 8cc685ce4c..bbc73b4e08 100644 --- a/modules/utxo-bin/.eslintrc.json +++ b/modules/utxo-bin/.eslintrc.json @@ -2,6 +2,17 @@ "extends": "../../.eslintrc.json", "rules": { "@typescript-eslint/explicit-module-boundary-types": "error", + "import/no-internal-modules": [ + "error", + { + // these are false-positives + // certain packages explicitly ALLOW deep imports via the `exports` directive in package.json + "allow": [ + "@bitgo/utxo-core/*", + "@noble/curves/*" + ] + } + ], "indent": "off" } } diff --git a/modules/utxo-bin/bin/index.ts b/modules/utxo-bin/bin/index.ts index 0e64cfff59..21b3b372fe 100644 --- a/modules/utxo-bin/bin/index.ts +++ b/modules/utxo-bin/bin/index.ts @@ -1,11 +1,12 @@ #!/usr/bin/env node import * as yargs from 'yargs'; -import { cmdParseTx, cmdParseScript, cmdBip32, cmdPsbt, cmdAddress } from '../src/commands'; +import { cmdParseTx, cmdParseScript, cmdBip32, cmdPsbt, cmdAddress, cmdDescriptor } from '../src/commands'; yargs .command(cmdParseTx) .command(cmdAddress) + .command(cmdDescriptor) .command(cmdParseScript) .command(cmdPsbt) .command(cmdBip32) diff --git a/modules/utxo-bin/package.json b/modules/utxo-bin/package.json index 6a68fca33f..628fd7bda1 100644 --- a/modules/utxo-bin/package.json +++ b/modules/utxo-bin/package.json @@ -29,6 +29,7 @@ "@bitgo/blockapis": "^1.10.18", "@bitgo/statics": "^54.7.0", "@bitgo/unspents": "^0.48.3", + "@bitgo/utxo-core": "^1.11.0", "@bitgo/utxo-lib": "^11.6.1", "@bitgo/wasm-miniscript": "2.0.0-beta.7", "@noble/curves": "1.8.1", diff --git a/modules/utxo-bin/src/commands/cmdDescriptor/fromFixedScript.ts b/modules/utxo-bin/src/commands/cmdDescriptor/fromFixedScript.ts new file mode 100644 index 0000000000..c7e54cec60 --- /dev/null +++ b/modules/utxo-bin/src/commands/cmdDescriptor/fromFixedScript.ts @@ -0,0 +1,61 @@ +import { CommandModule } from 'yargs'; +import * as utxolib from '@bitgo/utxo-lib'; +import { getNamedDescriptorsForRootWalletKeys } from '@bitgo/utxo-core/descriptor'; + +import { + FormatTreeOrJson, + formatTreeOrJson, + getNetworkOptionsDemand, + getRootWalletKeys, + keyOptions, + KeyOptions, +} from '../../args'; +import { formatObjAsTree } from '../../format'; + +type Triple = [T, T, T]; + +type ArgsFixedScriptToDescriptor = KeyOptions & { + network: utxolib.Network; + format: FormatTreeOrJson; +}; + +function mapKeyToNetwork(key: utxolib.BIP32Interface, network: utxolib.Network): utxolib.BIP32Interface { + key = utxolib.bip32.fromBase58(key.toBase58()); + key.network = network; + return key; +} + +function mapRootWalletKeysToNetwork( + rootWalletKeys: utxolib.bitgo.RootWalletKeys, + network: utxolib.Network +): utxolib.bitgo.RootWalletKeys { + return new utxolib.bitgo.RootWalletKeys( + rootWalletKeys.triple.map((key) => mapKeyToNetwork(key, network)) as Triple, + rootWalletKeys.derivationPrefixes + ); +} + +export const cmdFromFixedScript: CommandModule = { + command: 'fromFixedScript', + describe: 'Convert BitGo FixedScript RootWalletKeys to output descriptors', + builder(b) { + return b.option(getNetworkOptionsDemand('bitcoin')).options(keyOptions).options({ format: formatTreeOrJson }); + }, + handler(argv): void { + let rootWalletKeys = getRootWalletKeys(argv); + if (argv.network !== utxolib.networks.bitcoin) { + rootWalletKeys = mapRootWalletKeysToNetwork(rootWalletKeys, argv.network); + } + const descriptorMap = getNamedDescriptorsForRootWalletKeys(rootWalletKeys); + const obj = Object.fromEntries( + [...descriptorMap].map(([name, descriptor]) => [name, descriptor?.toString() ?? null]) + ); + if (argv.format === 'tree') { + console.log(formatObjAsTree('descriptors', obj)); + } else if (argv.format === 'json') { + console.log(JSON.stringify(obj, null, 2)); + } else { + throw new Error(`Invalid format: ${argv.format}. Expected 'tree' or 'json'.`); + } + }, +}; diff --git a/modules/utxo-bin/src/commands/cmdDescriptor/index.ts b/modules/utxo-bin/src/commands/cmdDescriptor/index.ts new file mode 100644 index 0000000000..436576ee3d --- /dev/null +++ b/modules/utxo-bin/src/commands/cmdDescriptor/index.ts @@ -0,0 +1,15 @@ +import { CommandModule } from 'yargs'; +import { cmdFromFixedScript } from './fromFixedScript'; + +export * from './fromFixedScript'; + +export const cmdDescriptor: CommandModule = { + command: 'descriptor ', + describe: 'descriptor commands', + builder(b) { + return b.strict().command(cmdFromFixedScript).demandCommand(); + }, + handler() { + // do nothing + }, +}; diff --git a/modules/utxo-bin/src/commands/index.ts b/modules/utxo-bin/src/commands/index.ts index ec83b8e5d9..3368fb3f98 100644 --- a/modules/utxo-bin/src/commands/index.ts +++ b/modules/utxo-bin/src/commands/index.ts @@ -1,5 +1,6 @@ export * from './cmdParseTx'; export * from './cmdAddress'; +export * from './cmdDescriptor'; export * from './cmdParseScript'; export * from './cmdBip32'; export * from './cmdPsbt'; diff --git a/modules/utxo-bin/src/format.ts b/modules/utxo-bin/src/format.ts index 844bfd6d9d..6ddccbfa9c 100644 --- a/modules/utxo-bin/src/format.ts +++ b/modules/utxo-bin/src/format.ts @@ -1,7 +1,8 @@ import { Chalk, Instance } from 'chalk'; import archy from 'archy'; -import { ParserNode, ParserNodeValue } from './Parser'; +import { Parser, ParserNode, ParserNodeValue } from './Parser'; +import { parseUnknown } from './parseUnknown'; const hideDefault = ['pubkeys', 'sequence', 'locktime', 'scriptSig', 'witness']; @@ -9,9 +10,21 @@ export function formatSat(v: number | bigint): string { return (Number(v) / 1e8).toFixed(8); } +type FormatOptions = { + hide?: string[]; + chalk?: Chalk; +}; + +function getDefaultChalk(): Chalk { + if (process.env.NO_COLOR) { + return new Instance({ level: 0 }); + } + return new Instance(); +} + export function formatTree( n: ParserNode, - { hide = hideDefault, chalk = new Instance() }: { hide?: string[]; chalk?: Chalk } = {} + { hide = hideDefault, chalk = getDefaultChalk() }: FormatOptions = {} ): string { function getLabel( label: string | number, @@ -61,3 +74,13 @@ export function formatTree( return archy(toArchy(n)); } + +export function formatObjAsTree( + label: string | number, + obj: unknown, + { hide = hideDefault, chalk = getDefaultChalk() }: FormatOptions = {} +): string { + const p = new Parser({ parseError: 'continue' }); + const node = parseUnknown(p, label, obj, { omit: hide }); + return formatTree(node, { hide, chalk }); +} diff --git a/modules/utxo-bin/test/captureConsole.ts b/modules/utxo-bin/test/captureConsole.ts new file mode 100644 index 0000000000..982528cfca --- /dev/null +++ b/modules/utxo-bin/test/captureConsole.ts @@ -0,0 +1,36 @@ +import * as util from 'node:util'; + +export async function captureConsole( + func: () => Promise +): Promise<{ stdout: string; stderr: string; result: T }> { + process.env.NO_COLOR = '1'; // Disable colors in console output for easier testing + const oldConsoleLog = console.log; + const oldConsoleError = console.error; + + const stdoutData: string[] = []; + const stderrData: string[] = []; + + console.log = (...args: any[]) => { + stdoutData.push(util.format(...args)); + }; + + console.error = (...args: any[]) => { + stderrData.push(util.format(...args)); + }; + + let result: T; + try { + result = await func(); + } finally { + console.log = oldConsoleLog; + console.error = oldConsoleError; + } + + const join = (data: string[]) => (data.length > 0 ? data.join('\n') + '\n' : ''); + + return { + stdout: join(stdoutData), + stderr: join(stderrData), + result, + }; +} diff --git a/modules/utxo-bin/test/cmdDescriptor/fromFixedScript.ts b/modules/utxo-bin/test/cmdDescriptor/fromFixedScript.ts new file mode 100644 index 0000000000..4c6b222b20 --- /dev/null +++ b/modules/utxo-bin/test/cmdDescriptor/fromFixedScript.ts @@ -0,0 +1,38 @@ +import * as assert from 'assert'; +import yargs from 'yargs'; + +import { cmdFromFixedScript } from '../../src/commands/cmdDescriptor'; +import { getFixtureString } from '../fixtures'; +import { getKeyTriple } from '../bip32.util'; +import { captureConsole } from '../captureConsole'; + +function keyArgs(): string[] { + const [userKey, backupKey, bitgoKey] = getKeyTriple('generateAddress').map((k) => k.neutered().toBase58()); + return ['--userKey', userKey, '--backupKey', backupKey, '--bitgoKey', bitgoKey, '--scriptType', 'p2sh']; +} + +describe('cmdDescriptor fromFixedScript', function () { + function runTest(argv: string[], fixtureName: string) { + it(`should output expected descriptor (${fixtureName})`, async function () { + const y = yargs(argv) + .command(cmdFromFixedScript) + .exitProcess(false) + .fail((msg, err) => { + throw err || new Error(msg); + }); + + const { stdout, stderr } = await captureConsole(async () => { + await y.parse(); + }); + + // Compare output to fixture, or check for expected descriptor substring + const expected = await getFixtureString(`test/fixtures/fromFixedScript/${fixtureName}.txt`, stdout); + assert.strictEqual(stdout.trim(), expected.trim()); + assert.strictEqual(stderr, ''); + }); + } + + runTest(['fromFixedScript', ...keyArgs()], 'default'); + + runTest(['fromFixedScript', ...keyArgs(), '--network', 'testnet'], 'network-testnet'); +}); diff --git a/modules/utxo-bin/test/fixtures/fromFixedScript/default.txt b/modules/utxo-bin/test/fixtures/fromFixedScript/default.txt new file mode 100644 index 0000000000..3836d36609 --- /dev/null +++ b/modules/utxo-bin/test/fixtures/fromFixedScript/default.txt @@ -0,0 +1,12 @@ +descriptors +├── p2sh/external: "sh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/0/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/0/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/0/*))#5npd8z8f" +├── p2sh/internal: "sh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/1/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/1/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/1/*))#ung2jfhq" +├── p2shP2wsh/external: "sh(wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/10/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/10/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/10/*)))#nlkef9fp" +├── p2shP2wsh/internal: "sh(wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/11/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/11/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/11/*)))#3v6t34rd" +├── p2wsh/external: "wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/20/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/20/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/20/*))#2sputcy5" +├── p2wsh/internal: "wsh(multi(2,xpub661MyMwAqRbcFDYKGMFUdU7DbgCM5xcyaHzofQnvQK2vEV9wfHCyrh3xyXoMRL1DBURzdqjnAZdX91qbJv6C1UsiJUPDF7EBf6wez7VrEJR/0/0/21/*,xpub661MyMwAqRbcFawpks1M1Ky7LfJ8WA8Ck4tcp6bGEUNKCFdCC1s7CxuYTjpAQV9eN7W59ctCYyTFPBPRiiBm4CnsYdnvdYN4nwu3FsCKnDw/0/0/21/*,xpub661MyMwAqRbcFHxWrAKX6D3Uc7PzpaTSHuXVzCxfY9qFaBcREfy23vx7RtT8CC9tcTKp5JNbcK1pQgjGnBWi3Br8wUryLwLS13CyGREyFs3/0/0/21/*))#9ush3alg" +├── p2tr/external: null +├── p2tr/internal: null +├── p2trMusig2/external: null +└── p2trMusig2/internal: null + diff --git a/modules/utxo-bin/test/fixtures/fromFixedScript/network-testnet.txt b/modules/utxo-bin/test/fixtures/fromFixedScript/network-testnet.txt new file mode 100644 index 0000000000..430fdeee2e --- /dev/null +++ b/modules/utxo-bin/test/fixtures/fromFixedScript/network-testnet.txt @@ -0,0 +1,12 @@ +descriptors +├── p2sh/external: "sh(multi(2,tpubD6NzVbkrYhZ4X1jAiYEZqPSavoJ15M837vb71xr4kDnoympejW4518ioDZt8wpxSyNxuFRFehfTZqCLChmwRtvL1N58WvdtRF4Y4k7suApz/0/0/0/*,tpubD6NzVbkrYhZ4XP8gD3zSDFJUfnPnVYdGHhUvAeeQaP8CwYHuGEiCMQaNhmtwvz6tA22ymCQ565HJ5Mt37a2zweFAcEYEK52JNuVT1rWz9x8/0/0/0/*,tpubD6NzVbkrYhZ4X69NJMJcJ8NqwEVeoxxVqY7oLm1ot4b9KUH8Jtp7CNcwfvXuih78QMrigstU9Qqs6sDtB3MwvdJS15cH2TzfazoP2REqXVN/0/0/0/*))#k9dvpjv5" +├── p2sh/internal: "sh(multi(2,tpubD6NzVbkrYhZ4X1jAiYEZqPSavoJ15M837vb71xr4kDnoympejW4518ioDZt8wpxSyNxuFRFehfTZqCLChmwRtvL1N58WvdtRF4Y4k7suApz/0/0/1/*,tpubD6NzVbkrYhZ4XP8gD3zSDFJUfnPnVYdGHhUvAeeQaP8CwYHuGEiCMQaNhmtwvz6tA22ymCQ565HJ5Mt37a2zweFAcEYEK52JNuVT1rWz9x8/0/0/1/*,tpubD6NzVbkrYhZ4X69NJMJcJ8NqwEVeoxxVqY7oLm1ot4b9KUH8Jtp7CNcwfvXuih78QMrigstU9Qqs6sDtB3MwvdJS15cH2TzfazoP2REqXVN/0/0/1/*))#79yt5eua" +├── p2shP2wsh/external: "sh(wsh(multi(2,tpubD6NzVbkrYhZ4X1jAiYEZqPSavoJ15M837vb71xr4kDnoympejW4518ioDZt8wpxSyNxuFRFehfTZqCLChmwRtvL1N58WvdtRF4Y4k7suApz/0/0/10/*,tpubD6NzVbkrYhZ4XP8gD3zSDFJUfnPnVYdGHhUvAeeQaP8CwYHuGEiCMQaNhmtwvz6tA22ymCQ565HJ5Mt37a2zweFAcEYEK52JNuVT1rWz9x8/0/0/10/*,tpubD6NzVbkrYhZ4X69NJMJcJ8NqwEVeoxxVqY7oLm1ot4b9KUH8Jtp7CNcwfvXuih78QMrigstU9Qqs6sDtB3MwvdJS15cH2TzfazoP2REqXVN/0/0/10/*)))#5l20an83" +├── p2shP2wsh/internal: "sh(wsh(multi(2,tpubD6NzVbkrYhZ4X1jAiYEZqPSavoJ15M837vb71xr4kDnoympejW4518ioDZt8wpxSyNxuFRFehfTZqCLChmwRtvL1N58WvdtRF4Y4k7suApz/0/0/11/*,tpubD6NzVbkrYhZ4XP8gD3zSDFJUfnPnVYdGHhUvAeeQaP8CwYHuGEiCMQaNhmtwvz6tA22ymCQ565HJ5Mt37a2zweFAcEYEK52JNuVT1rWz9x8/0/0/11/*,tpubD6NzVbkrYhZ4X69NJMJcJ8NqwEVeoxxVqY7oLm1ot4b9KUH8Jtp7CNcwfvXuih78QMrigstU9Qqs6sDtB3MwvdJS15cH2TzfazoP2REqXVN/0/0/11/*)))#kvxa9rda" +├── p2wsh/external: "wsh(multi(2,tpubD6NzVbkrYhZ4X1jAiYEZqPSavoJ15M837vb71xr4kDnoympejW4518ioDZt8wpxSyNxuFRFehfTZqCLChmwRtvL1N58WvdtRF4Y4k7suApz/0/0/20/*,tpubD6NzVbkrYhZ4XP8gD3zSDFJUfnPnVYdGHhUvAeeQaP8CwYHuGEiCMQaNhmtwvz6tA22ymCQ565HJ5Mt37a2zweFAcEYEK52JNuVT1rWz9x8/0/0/20/*,tpubD6NzVbkrYhZ4X69NJMJcJ8NqwEVeoxxVqY7oLm1ot4b9KUH8Jtp7CNcwfvXuih78QMrigstU9Qqs6sDtB3MwvdJS15cH2TzfazoP2REqXVN/0/0/20/*))#7c7n6f3u" +├── p2wsh/internal: "wsh(multi(2,tpubD6NzVbkrYhZ4X1jAiYEZqPSavoJ15M837vb71xr4kDnoympejW4518ioDZt8wpxSyNxuFRFehfTZqCLChmwRtvL1N58WvdtRF4Y4k7suApz/0/0/21/*,tpubD6NzVbkrYhZ4XP8gD3zSDFJUfnPnVYdGHhUvAeeQaP8CwYHuGEiCMQaNhmtwvz6tA22ymCQ565HJ5Mt37a2zweFAcEYEK52JNuVT1rWz9x8/0/0/21/*,tpubD6NzVbkrYhZ4X69NJMJcJ8NqwEVeoxxVqY7oLm1ot4b9KUH8Jtp7CNcwfvXuih78QMrigstU9Qqs6sDtB3MwvdJS15cH2TzfazoP2REqXVN/0/0/21/*))#350cqv2q" +├── p2tr/external: null +├── p2tr/internal: null +├── p2trMusig2/external: null +└── p2trMusig2/internal: null +