diff --git a/modules/utxo-bin/bin/index.ts b/modules/utxo-bin/bin/index.ts index b0f2a8ce53..3ba460e052 100644 --- a/modules/utxo-bin/bin/index.ts +++ b/modules/utxo-bin/bin/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import * as yargs from 'yargs'; -import { cmdGenerateAddress, cmdParseAddress, cmdParseScript, cmdParseTx, cmdParseXpub } from '../src/commands'; +import { cmdParseTx, cmdParseAddress, cmdParseScript, cmdGenerateAddress, cmdParseXpub } from '../src/commands'; yargs .command(cmdParseTx) diff --git a/modules/utxo-bin/src/args/index.ts b/modules/utxo-bin/src/args/index.ts new file mode 100644 index 0000000000..264640f0a8 --- /dev/null +++ b/modules/utxo-bin/src/args/index.ts @@ -0,0 +1,3 @@ +export * from './parseString'; +export * from './walletKeys'; +export * from './parseNetwork'; diff --git a/modules/utxo-bin/src/args/parseNetwork.ts b/modules/utxo-bin/src/args/parseNetwork.ts new file mode 100644 index 0000000000..7c51daa4bc --- /dev/null +++ b/modules/utxo-bin/src/args/parseNetwork.ts @@ -0,0 +1,43 @@ +import * as utxolib from '@bitgo/utxo-lib'; + +export function getNetworkForName(name: string): utxolib.Network { + const network = utxolib.networks[name as utxolib.NetworkName]; + if (!network) { + throw new Error(`invalid network ${name}`); + } + return network; +} + +export function getNetwork(argv: { network: string }): utxolib.Network { + return getNetworkForName(argv.network); +} + +type DemandOption = T & { demandOption: true }; + +type NetworkOption = { + type: 'string'; + description: string; + default: TDefault; + coerce: (arg: string) => utxolib.Network; +}; + +export function getNetworkOptions(defaultValue?: string): { + network: NetworkOption; +} { + return { + network: { + type: 'string', + description: 'network name', + default: defaultValue, + coerce: getNetworkForName, + }, + }; +} + +export function getNetworkOptionsDemand(defaultValue?: string): { + network: DemandOption>; +} { + return { + network: { ...getNetworkOptions(defaultValue).network, demandOption: true }, + }; +} diff --git a/modules/utxo-bin/src/args/parseString.ts b/modules/utxo-bin/src/args/parseString.ts new file mode 100644 index 0000000000..0068310b6d --- /dev/null +++ b/modules/utxo-bin/src/args/parseString.ts @@ -0,0 +1,134 @@ +import * as process from 'process'; +import * as fs from 'fs'; + +import clipboardy from 'clipboardy-cjs'; + +type Format = 'hex' | 'base64'; +export function stringToBuffer(data: string, format: Format | Format[]): Buffer { + if (typeof format !== 'string') { + for (const f of format) { + try { + return stringToBuffer(data, f); + } catch (err) { + // ignore, try next + } + } + throw new Error(`could not parse data, formats: ${format}`); + } + + // strip all whitespace + data = data.replace(/\s*/g, ''); + + if (format === 'hex') { + data = data.toLowerCase(); + } + + const buf = Buffer.from(data, format); + // make sure there were no decoding errors + if (buf.toString(format) !== data) { + throw new Error(`invalid ${format}`); + } + return buf; +} + +export const readStringOptions = { + path: { type: 'string', nargs: 1, default: '' }, + stdin: { type: 'boolean', default: false }, + data: { + type: 'string', + description: 'hex or base64', + alias: 'hex', + }, + clipboard: { type: 'boolean', default: false }, +} as const; + +export type ReadStringOptions = { + clipboard?: boolean; + path?: string; + data?: string; + stdin: boolean; +}; + +/** + * Reads from stdin until Ctrl-D is pressed. + */ +export async function readStdin(): Promise { + /* + * High-performance implementation of reading from stdin. + * Standard readline is extremely slow for long lines. + */ + return new Promise((resolve, reject) => { + // Using readline is not an option because it is extremely slow for long lines. + // By enabling raw mode, we can read more than 4096 bytes, but it requires manual Ctrl-C/Ctrl-D handling + if (!process.stdin.setRawMode) { + throw new Error('stdin is not a tty'); + } + process.stdin.setRawMode(true); + const buf: Buffer[] = []; + + process.stdin.on('data', (chunk) => { + if (chunk[0] === 0x03) { + // Ctrl-C + process.exit(130); + } + if (chunk[0] === 0x04) { + // Ctrl-D + process.stdin.emit('end'); + return; + } + buf.push(chunk); + process.stdout.write(chunk); + }); + + process.stdin.on('end', () => { + resolve(Buffer.concat(buf).toString('utf8')); + }); + + process.stdin.on('error', (err) => { + reject(err); + }); + }); +} + +/** + * @param argv + * @param input - optional input data. If set, this function just ensures that nothing else is set. + * @return string from specified source + */ +export async function argToString(argv: ReadStringOptions, input?: string): Promise { + if (argv.stdin || argv.path === '-') { + if (input) { + throw new Error(`conflicting arguments`); + } + console.log('Reading from stdin. Please paste hex-encoded transaction data.'); + console.log('After inserting data, press Ctrl-D to finish. Press Ctrl-C to cancel.'); + if (process.stdin.isTTY) { + input = await readStdin(); + } else { + input = await fs.promises.readFile('/dev/stdin', 'utf8'); + } + } + + if (argv.clipboard) { + if (input) { + throw new Error(`conflicting arguments`); + } + input = await clipboardy.read(); + } + + if (argv.path) { + if (input) { + throw new Error(`conflicting arguments`); + } + input = (await fs.promises.readFile(argv.path, 'utf8')).toString(); + } + + if (argv.data) { + if (input) { + throw new Error(`conflicting arguments`); + } + input = argv.data; + } + + return input; +} diff --git a/modules/utxo-bin/src/args/walletKeys.ts b/modules/utxo-bin/src/args/walletKeys.ts new file mode 100644 index 0000000000..a1a0fdfaf6 --- /dev/null +++ b/modules/utxo-bin/src/args/walletKeys.ts @@ -0,0 +1,34 @@ +import * as assert from 'assert'; +import * as utxolib from '@bitgo/utxo-lib'; + +export function isWalletKeyName(name: string): name is utxolib.bitgo.KeyName { + return name === 'user' || name === 'backup' || name === 'bitgo'; +} + +export const keyOptions = { + userKey: { type: 'string', demandOption: true }, + userKeyPrefix: { type: 'string', default: '0/0' }, + backupKey: { type: 'string', demandOption: true }, + backupKeyPrefix: { type: 'string', default: '0/0' }, + bitgoKey: { type: 'string', demandOption: true }, + bitgoKeyPrefix: { type: 'string', default: '0/0' }, +} as const; + +export type KeyOptions = { + userKey: string; + userKeyPrefix?: string; + backupKey: string; + backupKeyPrefix?: string; + bitgoKey: string; + bitgoKeyPrefix?: string; +}; + +export function getRootWalletKeys(argv: KeyOptions): utxolib.bitgo.RootWalletKeys { + const xpubs = [argv.userKey, argv.backupKey, argv.bitgoKey].map((k) => utxolib.bip32.fromBase58(k)); + assert(utxolib.bitgo.isTriple(xpubs)); + return new utxolib.bitgo.RootWalletKeys(xpubs, [ + argv.userKeyPrefix ?? utxolib.bitgo.RootWalletKeys.defaultPrefix, + argv.backupKeyPrefix ?? utxolib.bitgo.RootWalletKeys.defaultPrefix, + argv.bitgoKeyPrefix ?? utxolib.bitgo.RootWalletKeys.defaultPrefix, + ]); +} diff --git a/modules/utxo-bin/src/commands.ts b/modules/utxo-bin/src/commands.ts index 5501a11c36..bbf4c6413a 100644 --- a/modules/utxo-bin/src/commands.ts +++ b/modules/utxo-bin/src/commands.ts @@ -1,10 +1,5 @@ /* eslint-disable no-console */ import * as yargs from 'yargs'; -import * as fs from 'fs'; -import * as process from 'process'; -import { promisify } from 'util'; - -import clipboardy from 'clipboardy-cjs'; import * as utxolib from '@bitgo/utxo-lib'; import { Parser, ParserNode } from './Parser'; @@ -15,15 +10,23 @@ import { fetchPrevOutputSpends, fetchTransactionHex, fetchTransactionStatus, + getClient, } from './fetch'; import { TxParser, TxParserArgs } from './TxParser'; import { AddressParser } from './AddressParser'; -import { BaseHttpClient, CachingHttpClient, HttpClient } from '@bitgo/blockapis'; -import { readStdin } from './readStdin'; import { parseUnknown } from './parseUnknown'; import { getParserTxProperties } from './ParserTx'; import { ScriptParser } from './ScriptParser'; -import { stringToBuffer } from './parseString'; +import { + argToString, + stringToBuffer, + readStringOptions, + ReadStringOptions, + KeyOptions, + keyOptions, + getNetworkOptions, + getNetworkOptionsDemand, +} from './args'; import { formatAddressTree, formatAddressWithFormatString, @@ -36,15 +39,11 @@ import { parseXpub } from './bip32'; type OutputFormat = 'tree' | 'json'; -type ArgsParseTransaction = { - network: string; - stdin: boolean; - clipboard: boolean; - path?: string; +type ArgsParseTransaction = ReadStringOptions & { + network: utxolib.Network; txid?: string; blockHeight?: number; txIndex?: number; - data?: string; all: boolean; cache: boolean; format: OutputFormat; @@ -59,7 +58,7 @@ type ArgsParseTransaction = { } & Omit; type ArgsParseAddress = { - network?: string; + network?: utxolib.Network; all: boolean; format: OutputFormat; convert: boolean; @@ -67,45 +66,20 @@ type ArgsParseAddress = { }; type ArgsParseScript = { - network?: string; + network?: utxolib.Network; format: OutputFormat; all: boolean; script: string; }; -export type ArgsGenerateAddress = { - network?: string; - userKey: string; - backupKey: string; - bitgoKey: string; +export type ArgsGenerateAddress = KeyOptions & { + network: utxolib.Network; chain?: number[]; format: string; index?: string[]; limit?: number; }; -async function getClient({ cache }: { cache: boolean }): Promise { - if (cache) { - const mkdir = promisify(fs.mkdir); - const dir = `${process.env.HOME}/.cache/utxo-bin/`; - await mkdir(dir, { recursive: true }); - return new CachingHttpClient(dir); - } - return new BaseHttpClient(); -} - -function getNetworkForName(name: string) { - const network = utxolib.networks[name as utxolib.NetworkName]; - if (!network) { - throw new Error(`invalid network ${name}`); - } - return network; -} - -function getNetwork(argv: yargs.Arguments<{ network: string }>): utxolib.Network { - return getNetworkForName(argv.network); -} - type FormatStringArgs = { format: OutputFormat; all: boolean; @@ -121,17 +95,6 @@ function formatString(parsed: ParserNode, argv: yargs.Arguments( - args: T -): T & { - network?: utxolib.Network; -} { - if (args.network) { - return { ...args, network: getNetworkForName(args.network) }; - } - return { ...args, network: undefined }; -} - export function getTxParser(argv: yargs.Arguments): TxParser { if (argv.all) { return new TxParser({ ...argv, ...TxParser.PARSE_ALL }); @@ -147,11 +110,11 @@ export function getTxParser(argv: yargs.Arguments): TxPars } export function getAddressParser(argv: ArgsParseAddress): AddressParser { - return new AddressParser(resolveNetwork(argv)); + return new AddressParser(argv); } export function getScriptParser(argv: ArgsParseScript): ScriptParser { - return new ScriptParser(resolveNetwork(argv)); + return new ScriptParser(argv); } export const cmdParseTx = { @@ -165,10 +128,8 @@ export const cmdParseTx = { builder(b: yargs.Argv): yargs.Argv { return b - .option('path', { type: 'string', nargs: 1, default: '' }) - .option('stdin', { type: 'boolean', default: false }) - .option('data', { type: 'string', description: 'transaction bytes (hex or base64)', alias: 'hex' }) - .option('clipboard', { type: 'boolean', default: false }) + .options(readStringOptions) + .options(getNetworkOptionsDemand()) .option('txid', { type: 'string' }) .option('blockHeight', { type: 'number' }) .option('txIndex', { type: 'number' }) @@ -176,7 +137,6 @@ export const cmdParseTx = { .option('fetchStatus', { type: 'boolean', default: false }) .option('fetchInputs', { type: 'boolean', default: false }) .option('fetchSpends', { type: 'boolean', default: false }) - .option('network', { alias: 'n', type: 'string', demandOption: true }) .option('parseScriptAsm', { alias: 'scriptasm', type: 'boolean', default: false }) .option('parseScriptData', { alias: 'scriptdata', type: 'boolean', default: false }) .option('parseSignatureData', { alias: 'sigdata', type: 'boolean', default: false }) @@ -207,7 +167,6 @@ export const cmdParseTx = { }, async handler(argv: yargs.Arguments): Promise { - const network = getNetwork(argv); let data; const httpClient = await getClient({ cache: argv.cache }); @@ -220,54 +179,20 @@ export const cmdParseTx = { blockHeight: argv.blockHeight, txIndex: argv.txIndex, }, - network + argv.network ); } - if (argv.stdin || argv.path === '-') { - if (data) { - throw new Error(`conflicting arguments`); - } - console.log('Reading from stdin. Please paste hex-encoded transaction data.'); - console.log('After inserting data, press Ctrl-D to finish. Press Ctrl-C to cancel.'); - if (process.stdin.isTTY) { - data = await readStdin(); - } else { - data = await fs.promises.readFile('/dev/stdin', 'utf8'); - } - } - - if (argv.clipboard) { - if (data) { - throw new Error(`conflicting arguments`); - } - data = await clipboardy.read(); - } - - if (argv.path) { - if (data) { - throw new Error(`conflicting arguments`); - } - data = (await fs.promises.readFile(argv.path, 'utf8')).toString(); - } - - if (argv.data) { - if (data) { - throw new Error(`conflicting arguments`); - } - data = argv.data; - } - - // strip whitespace - if (!data) { + const string = await argToString(argv, data); + if (!string) { throw new Error(`no txdata`); } - const bytes = stringToBuffer(data, ['hex', 'base64']); + const bytes = stringToBuffer(string, 'hex'); let tx = utxolib.bitgo.isPsbt(bytes) - ? utxolib.bitgo.createPsbtFromBuffer(bytes, network) - : utxolib.bitgo.createTransactionFromBuffer(bytes, network, { amountType: 'bigint' }); + ? utxolib.bitgo.createPsbtFromBuffer(bytes, argv.network) + : utxolib.bitgo.createTransactionFromBuffer(bytes, argv.network, { amountType: 'bigint' }); const { id: txid } = getParserTxProperties(tx, undefined); if (tx instanceof utxolib.bitgo.UtxoTransaction) { @@ -291,7 +216,7 @@ export const cmdParseTx = { } const parsed = getTxParser(argv).parse(tx, { - status: argv.fetchStatus && txid ? await fetchTransactionStatus(httpClient, txid, network) : undefined, + status: argv.fetchStatus && txid ? await fetchTransactionStatus(httpClient, txid, argv.network) : undefined, prevOutputs: argv.fetchInputs ? await fetchPrevOutputs(httpClient, tx) : undefined, prevOutputSpends: argv.fetchSpends ? await fetchPrevOutputSpends(httpClient, tx) : undefined, outputSpends: @@ -310,7 +235,7 @@ export const cmdParseAddress = { describe: 'parse address', builder(b: yargs.Argv): yargs.Argv { return b - .option('network', { alias: 'n', type: 'string' }) + .options(getNetworkOptions()) .option('format', { choices: ['tree', 'json'], default: 'tree' } as const) .option('convert', { type: 'boolean', default: false }) .option('all', { type: 'boolean', default: false }) @@ -328,7 +253,7 @@ export const cmdParseScript = { describe: 'parse script', builder(b: yargs.Argv): yargs.Argv { return b - .option('network', { alias: 'n', type: 'string' }) + .options(getNetworkOptions()) .option('format', { choices: ['tree', 'json'], default: 'tree' } as const) .option('all', { type: 'boolean', default: false }) .positional('script', { type: 'string', demandOption: true }); @@ -345,13 +270,8 @@ export const cmdGenerateAddress = { describe: 'generate addresses', builder(b: yargs.Argv): yargs.Argv { return b - .option('network', { alias: 'n', type: 'string' }) - .option('userKey', { type: 'string', demandOption: true }) - .option('userKeyPrefix', { type: 'string', default: '0/0' }) - .option('backupKey', { type: 'string', demandOption: true }) - .option('backupKeyPrefix', { type: 'string', default: '0/0' }) - .option('bitgoKey', { type: 'string', demandOption: true }) - .option('bitgoKeyPrefix', { type: 'string', default: '0/0' }) + .options(getNetworkOptionsDemand('bitcoin')) + .options(keyOptions) .option('format', { type: 'string', default: '%p0\t%a', @@ -382,7 +302,6 @@ export const cmdGenerateAddress = { for (const address of generateAddress({ ...argv, index: indexRange, - network: getNetworkForName(argv.network ?? 'bitcoin'), })) { if (argv.format === 'tree') { console.log(formatAddressTree(address)); @@ -396,14 +315,26 @@ export const cmdGenerateAddress = { export const cmdParseXpub = { command: 'parseXpub [xpub]', describe: 'show xpub info', - builder(b: yargs.Argv): yargs.Argv<{ xpub: string; derive?: string } & FormatStringArgs> { + builder(b: yargs.Argv): yargs.Argv< + { + xpub: string; + derive?: string; + } & FormatStringArgs + > { return b .positional('xpub', { type: 'string', demandOption: true }) .option('format', { choices: ['tree', 'json'], default: 'tree' } as const) .option('all', { type: 'boolean', default: false }) .option('derive', { type: 'string', description: 'show xpub derived with path' }); }, - handler(argv: yargs.Arguments<{ xpub: string; derive?: string } & FormatStringArgs>): void { + handler( + argv: yargs.Arguments< + { + xpub: string; + derive?: string; + } & FormatStringArgs + > + ): void { console.log(formatString(parseXpub(argv.xpub, { derive: argv.derive }), argv)); }, }; diff --git a/modules/utxo-bin/src/fetch.ts b/modules/utxo-bin/src/fetch.ts index 06c9ef6db3..82a539f952 100644 --- a/modules/utxo-bin/src/fetch.ts +++ b/modules/utxo-bin/src/fetch.ts @@ -1,8 +1,13 @@ +import * as fs from 'fs'; +import * as process from 'process'; + import * as utxolib from '@bitgo/utxo-lib'; import * as blockapis from '@bitgo/blockapis'; +import { BaseHttpClient, CachingHttpClient, getTransactionIdsAtHeight, HttpClient } from '@bitgo/blockapis'; import { coins, UtxoCoin } from '@bitgo/statics'; -import { getTransactionIdsAtHeight, HttpClient } from '@bitgo/blockapis'; + import { ParserTx } from './ParserTx'; +import { promisify } from 'util'; function getTxOutPoints(tx: ParserTx): utxolib.bitgo.TxOutPoint[] { if (tx instanceof utxolib.bitgo.UtxoTransaction) { @@ -114,3 +119,13 @@ export async function fetchOutputSpends( return []; } } + +export async function getClient({ cache }: { cache: boolean }): Promise { + if (cache) { + const mkdir = promisify(fs.mkdir); + const dir = `${process.env.HOME}/.cache/utxo-bin/`; + await mkdir(dir, { recursive: true }); + return new CachingHttpClient(dir); + } + return new BaseHttpClient(); +} diff --git a/modules/utxo-bin/src/generateAddress.ts b/modules/utxo-bin/src/generateAddress.ts index f7de37bc7a..2417816100 100644 --- a/modules/utxo-bin/src/generateAddress.ts +++ b/modules/utxo-bin/src/generateAddress.ts @@ -3,6 +3,7 @@ import * as utxolib from '@bitgo/utxo-lib'; import { Parser } from './Parser'; import { parseUnknown } from './parseUnknown'; import { formatTree } from './format'; +import { KeyOptions, getRootWalletKeys } from './args'; function getDefaultChainCodes(): number[] { return utxolib.bitgo.chainCodes.filter( @@ -108,25 +109,15 @@ export function parseIndexRange(ranges: string[]): number[] { }); } -export function* generateAddress(argv: { - network?: utxolib.Network; - userKey: string; - userKeyPrefix?: string; - backupKey: string; - backupKeyPrefix?: string; - bitgoKey: string; - bitgoKeyPrefix?: string; - chain?: number[]; - format: string; - index: number[]; -}): Generator { - const xpubs = [argv.userKey, argv.backupKey, argv.bitgoKey].map((k) => utxolib.bip32.fromBase58(k)); - assert(utxolib.bitgo.isTriple(xpubs)); - const rootXpubs = new utxolib.bitgo.RootWalletKeys(xpubs, [ - argv.userKeyPrefix ?? utxolib.bitgo.RootWalletKeys.defaultPrefix, - argv.backupKeyPrefix ?? utxolib.bitgo.RootWalletKeys.defaultPrefix, - argv.bitgoKeyPrefix ?? utxolib.bitgo.RootWalletKeys.defaultPrefix, - ]); +export function* generateAddress( + argv: KeyOptions & { + network?: utxolib.Network; + chain?: number[]; + format: string; + index: number[]; + } +): Generator { + const rootXpubs = getRootWalletKeys(argv); const chains = argv.chain ?? getDefaultChainCodes(); for (const i of argv.index) { for (const chain of chains) { diff --git a/modules/utxo-bin/src/parseString.ts b/modules/utxo-bin/src/parseString.ts deleted file mode 100644 index 6572998b3f..0000000000 --- a/modules/utxo-bin/src/parseString.ts +++ /dev/null @@ -1,27 +0,0 @@ -type Format = 'hex' | 'base64'; -export function stringToBuffer(data: string, format: Format | Format[]): Buffer { - if (typeof format !== 'string') { - for (const f of format) { - try { - return stringToBuffer(data, f); - } catch (err) { - // ignore, try next - } - } - throw new Error(`could not parse data, formats: ${format}`); - } - - // strip all whitespace - data = data.replace(/\s*/g, ''); - - if (format === 'hex') { - data = data.toLowerCase(); - } - - const buf = Buffer.from(data, format); - // make sure there were no decoding errors - if (buf.toString(format) !== data) { - throw new Error(`invalid ${format}`); - } - return buf; -} diff --git a/modules/utxo-bin/src/readStdin.ts b/modules/utxo-bin/src/readStdin.ts deleted file mode 100644 index 1b1d566549..0000000000 --- a/modules/utxo-bin/src/readStdin.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Contains a high-performance implementation of reading from stdin. - * Standard readline is extremely slow for long lines. - */ - -/** - * Reads from stdin until Ctrl-D is pressed. - */ -export async function readStdin(): Promise { - return new Promise((resolve, reject) => { - // Using readline is not an option because it is extremely slow for long lines. - // By enabling raw mode, we can read more than 4096 bytes, but it requires manual Ctrl-C/Ctrl-D handling - if (!process.stdin.setRawMode) { - throw new Error('stdin is not a tty'); - } - process.stdin.setRawMode(true); - const buf: Buffer[] = []; - - process.stdin.on('data', (chunk) => { - if (chunk[0] === 0x03) { - // Ctrl-C - process.exit(130); - } - if (chunk[0] === 0x04) { - // Ctrl-D - process.stdin.emit('end'); - return; - } - buf.push(chunk); - process.stdout.write(chunk); - }); - - process.stdin.on('end', () => { - resolve(Buffer.concat(buf).toString('utf8')); - }); - - process.stdin.on('error', (err) => { - reject(err); - }); - }); -} diff --git a/modules/utxo-bin/test/stringToBuffer.ts b/modules/utxo-bin/test/args/stringToBuffer.ts similarity index 89% rename from modules/utxo-bin/test/stringToBuffer.ts rename to modules/utxo-bin/test/args/stringToBuffer.ts index 1f6c99b528..1af01228f9 100644 --- a/modules/utxo-bin/test/stringToBuffer.ts +++ b/modules/utxo-bin/test/args/stringToBuffer.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; -import { stringToBuffer } from '../src/parseString'; +import { stringToBuffer } from '../../src/args/parseString'; describe('stringToBuffer', function () { const bytes = Buffer.alloc(32, 42);