diff --git a/modules/blockapis/src/UtxoApi.ts b/modules/blockapis/src/UtxoApi.ts index 78a36210e4..8f61ebc2e4 100644 --- a/modules/blockapis/src/UtxoApi.ts +++ b/modules/blockapis/src/UtxoApi.ts @@ -42,32 +42,60 @@ export interface UtxoApi extends TransactionApi { getUnspentsForAddresses(address: string[]): Promise; } +function toOutPoints(arr: utxolib.TxInput[] | utxolib.bitgo.TxOutPoint[]): utxolib.bitgo.TxOutPoint[] { + return arr.map((i) => { + if ('txid' in i) { + return i; + } + return utxolib.bitgo.getOutputIdForInput(i); + }); +} + /** - * Helper to efficiently fetch output data. - * Typical we can query output data for all outputs of a transaction, so we first fetch all - * the output list via `f` and then pick the output data from the result. + * Helper to efficiently fetch output data. Deduplicates transaction ids and only does one lookup per txid. * @param outpoints - * @param f - maps txid to a list of outputs with type TOut - * @return list of TOut corresponding to outputs + * @param f - lookup function for txid + * @return list of T corresponding to outpoints */ -async function mapInputs( - outpoints: utxolib.bitgo.TxOutPoint[], - f: (txid: string) => Promise -): Promise { +async function mapInputs(outpoints: utxolib.bitgo.TxOutPoint[], f: (txid: string) => Promise): Promise { const txids = [...new Set(outpoints.map((i) => i.txid))]; const txMap = new Map(await mapSeries(txids, async (txid) => [txid, await f(txid)])); return outpoints.map((i) => { - const arr = txMap.get(i.txid); - if (arr) { - if (i.vout in arr) { - return arr[i.vout]; - } - throw new Error(`could not find output ${i.vout}`); + const v = txMap.get(i.txid); + if (!v) { + throw new Error(`could not find tx ${i.txid}`); } - throw new Error(`could not find tx ${i.txid}`); + return v; }); } +/** + * @param outpoints + * @param f - maps txid to a list of TOut. + * @return list of TOut corresponding to outpoints + */ +async function mapInputsVOut( + outpoints: utxolib.bitgo.TxOutPoint[], + f: (txid: string) => Promise +): Promise { + const allOutputs = await mapInputs(outpoints, f); + return outpoints.map((p, i) => { + const arr = allOutputs[i]; + if (p.vout in arr) { + return allOutputs[i][p.vout]; + } + throw new Error(`could not find output ${p.vout}`); + }); +} + +export async function fetchPrevTxBuffers( + ins: utxolib.TxInput[] | utxolib.bitgo.TxOutPoint[], + api: UtxoApi, + _network: utxolib.Network +): Promise { + return mapInputs(toOutPoints(ins), async (txid) => Buffer.from(await api.getTransactionHex(txid), 'hex')); +} + /** * Fetch transaction inputs from transaction input list * @param ins @@ -79,13 +107,8 @@ export async function fetchInputs( api: UtxoApi, network: utxolib.Network ): Promise { - return mapInputs( - ins.map((i: utxolib.TxInput | utxolib.bitgo.TxOutPoint) => { - if ('txid' in i) { - return i; - } - return utxolib.bitgo.getOutputIdForInput(i); - }), + return mapInputsVOut( + toOutPoints(ins), async (txid) => utxolib.bitgo.createTransactionFromHex(await api.getTransactionHex(txid), network).outs ); } @@ -97,5 +120,5 @@ export async function fetchTransactionSpends( outpoints: utxolib.bitgo.TxOutPoint[], api: UtxoApi ): Promise { - return mapInputs(outpoints, async (txid) => await api.getTransactionSpends(txid)); + return mapInputsVOut(outpoints, async (txid) => await api.getTransactionSpends(txid)); } diff --git a/modules/utxo-bin/bin/index.ts b/modules/utxo-bin/bin/index.ts index 254e533aa5..393ee81f4f 100644 --- a/modules/utxo-bin/bin/index.ts +++ b/modules/utxo-bin/bin/index.ts @@ -1,12 +1,14 @@ #!/usr/bin/env node import * as yargs from 'yargs'; -import { cmdGenerateAddress, cmdParseAddress, cmdParseScript, cmdParseTx } from '../src/commands'; +import { cmdGenerateAddress, cmdParseAddress, cmdParseScript, cmdParseTx, cmdConvertTx } from '../src/commands'; yargs .command(cmdParseTx) + .command(cmdConvertTx) .command(cmdParseAddress) .command(cmdParseScript) .command(cmdGenerateAddress) .demandCommand() .help() + .strict() .parse(); diff --git a/modules/utxo-bin/src/commands.ts b/modules/utxo-bin/src/commands.ts index cfe770994e..1c106e44c8 100644 --- a/modules/utxo-bin/src/commands.ts +++ b/modules/utxo-bin/src/commands.ts @@ -21,7 +21,7 @@ import { AddressParser } from './AddressParser'; import { BaseHttpClient, CachingHttpClient, HttpClient } from '@bitgo/blockapis'; import { readStdin } from './readStdin'; import { parseUnknown } from './parseUnknown'; -import { getParserTxProperties } from './ParserTx'; +import { getParserTxProperties, ParserTx } from './ParserTx'; import { ScriptParser } from './ScriptParser'; import { stringToBuffer } from './parseString'; import { @@ -32,16 +32,120 @@ import { getRange, parseIndexRange, } from './generateAddress'; +import { convertTransaction } from './convertTransaction'; type OutputFormat = 'tree' | 'json'; -type ArgsParseTransaction = { +type ArgsReadTransaction = { network: string; stdin: boolean; - clipboard: boolean; path?: string; - txid?: string; data?: string; + clipboard: boolean; + txid?: string; + cache: boolean; + finalize: boolean; +}; + +function addReadTransactionOptions(b: yargs.Argv): yargs.Argv { + return b + .option('network', { alias: 'n', type: 'string', demandOption: true }) + .option('stdin', { type: 'boolean', default: false }) + .option('path', { type: 'string', nargs: 1, default: '' }) + .option('data', { type: 'string', description: 'transaction bytes (hex or base64)', alias: 'hex' }) + .option('clipboard', { type: 'boolean', default: false }) + .option('txid', { type: 'string' }) + .option('cache', { + type: 'boolean', + default: false, + description: 'use local cache for http responses', + }) + .option('finalize', { + type: 'boolean', + default: false, + description: 'finalize PSBT and parse result instead of PSBT', + }); +} + +async function readTransactionBytes(argv: ArgsReadTransaction, httpClient: HttpClient): Promise { + const network = getNetworkForName(argv.network); + let data; + + if (argv.txid) { + data = await fetchTransactionHex(httpClient, argv.txid, 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) { + throw new Error(`no txdata`); + } + + return stringToBuffer(data, ['hex', 'base64']); +} + +async function readTransaction(argv: ArgsReadTransaction, httpClient: HttpClient): Promise { + const network = getNetworkForName(argv.network); + const bytes = await readTransactionBytes(argv, httpClient); + + let tx = utxolib.bitgo.isPsbt(bytes) + ? utxolib.bitgo.createPsbtFromBuffer(bytes, network) + : utxolib.bitgo.createTransactionFromBuffer(bytes, network, { amountType: 'bigint' }); + + const { id: txid } = getParserTxProperties(tx, undefined); + if (tx instanceof utxolib.bitgo.UtxoTransaction) { + if (argv.txid && txid !== argv.txid) { + throw new Error(`computed txid does not match txid argument`); + } + } else if (argv.finalize) { + tx.finalizeAllInputs(); + tx = tx.extractTransaction(); + } + + return tx; +} + +type ArgsConvertTransaction = ArgsReadTransaction & { + format: 'legacy' | 'psbt'; + outfile?: string; +}; + +type ArgsParseTransaction = ArgsReadTransaction & { + clipboard: boolean; + txid?: string; all: boolean; cache: boolean; format: OutputFormat; @@ -99,10 +203,6 @@ function getNetworkForName(name: string) { return network; } -function getNetwork(argv: yargs.Arguments<{ network: string }>): utxolib.Network { - return getNetworkForName(argv.network); -} - function formatString( parsed: ParserNode, argv: yargs.Arguments<{ @@ -162,17 +262,11 @@ export const cmdParseTx = { 'Bytes must be encoded in hex or base64 format.', 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 }) - .option('txid', { type: 'string' }) + return addReadTransactionOptions(b) .option('fetchAll', { type: 'boolean', default: false }) .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 }) @@ -185,85 +279,16 @@ export const cmdParseTx = { .option('maxOutputs', { type: 'number' }) .option('vin', { type: 'number' }) .array('vin') - .option('finalize', { - type: 'boolean', - default: false, - description: 'finalize PSBT and parse result instead of PSBT', - }) .option('all', { type: 'boolean', default: false }) - .option('cache', { - type: 'boolean', - default: false, - description: 'use local cache for http responses', - }) .option('format', { choices: ['tree', 'json'], default: 'tree' } as const) .option('parseError', { choices: ['continue', 'throw'], default: 'continue' } as const); }, async handler(argv: yargs.Arguments): Promise { - const network = getNetwork(argv); - let data; - - const httpClient = await getClient({ cache: argv.cache }); - - if (argv.txid) { - data = await fetchTransactionHex(httpClient, argv.txid, 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) { - throw new Error(`no txdata`); - } - - const bytes = stringToBuffer(data, ['hex', 'base64']); - - let tx = utxolib.bitgo.isPsbt(bytes) - ? utxolib.bitgo.createPsbtFromBuffer(bytes, network) - : utxolib.bitgo.createTransactionFromBuffer(bytes, network, { amountType: 'bigint' }); - - const { id: txid } = getParserTxProperties(tx, undefined); - if (tx instanceof utxolib.bitgo.UtxoTransaction) { - if (argv.txid && txid !== argv.txid) { - throw new Error(`computed txid does not match txid argument`); - } - } else if (argv.finalize) { - tx.finalizeAllInputs(); - tx = tx.extractTransaction(); - } + const { txid } = argv; + const network = getNetworkForName(argv.network ?? 'bitcoin'); + const httpClient = await getClient(argv); + const tx = await readTransaction(argv, httpClient); if (argv.parseAsUnknown) { console.log(formatString(parseUnknown(new Parser(), 'tx', tx), argv)); @@ -375,3 +400,20 @@ export const cmdGenerateAddress = { } }, }; + +export const cmdConvertTx = { + command: 'convertTx [path]', + describe: 'convert between transaction formats', + builder(b: yargs.Argv): yargs.Argv { + return addReadTransactionOptions(b).option('format', { choices: ['legacy', 'psbt'], default: 'psbt' } as const); + }, + async handler(argv: yargs.Arguments): Promise { + const httpClient = await getClient(argv); + const tx = await readTransaction(argv, httpClient); + await convertTransaction(tx, { + httpClient, + format: argv.format, + outfile: argv.outfile, + }); + }, +}; diff --git a/modules/utxo-bin/src/convertTransaction.ts b/modules/utxo-bin/src/convertTransaction.ts new file mode 100644 index 0000000000..6672dd5d2a --- /dev/null +++ b/modules/utxo-bin/src/convertTransaction.ts @@ -0,0 +1,61 @@ +import { promises as fs } from 'fs'; +import * as utxolib from '@bitgo/utxo-lib'; +import { HttpClient } from '@bitgo/blockapis'; +import { fetchPrevTx } from './fetch'; +import { getParserTxProperties, ParserTx } from './ParserTx'; + +function getOutfile(tx: ParserTx, format: string): string { + const { id } = getParserTxProperties(tx, undefined); + const suffix = format === 'psbt' ? 'psbt' : 'hex'; + return `${id}.${suffix}`; +} + +async function legacyToPsbt(httpClient: HttpClient, tx: utxolib.bitgo.UtxoTransaction) { + const prevTxs = await fetchPrevTx(httpClient, tx); + const prevOutputs = tx.ins.map((input, i): utxolib.bitgo.PrevOutput => { + const { txid, vout } = utxolib.bitgo.getOutputIdForInput(input); + const prevTx = prevTxs[i]; + const prevTxParsed = utxolib.bitgo.createTransactionFromBuffer(prevTxs[i], tx.network, { amountType: 'bigint' }); + const { script, value } = prevTxParsed.outs[vout]; + return { txid, vout, script, value, prevTx }; + }); + + return utxolib.bitgo.createPsbtFromTransaction(tx, prevOutputs); +} + +export async function convertTransaction( + tx: ParserTx, + params: { + httpClient: HttpClient; + format: 'legacy' | 'psbt'; + outfile?: string; + } +): Promise { + const { format } = params; + + switch (format) { + case 'legacy': + if (tx instanceof utxolib.bitgo.UtxoTransaction) { + throw new Error(`input is already in legacy format`); + } + if (tx instanceof utxolib.bitgo.UtxoPsbt) { + throw new Error(`not implemented yet`); + } + + throw new Error(`unknown tx type`); + case 'psbt': + if (tx instanceof utxolib.bitgo.UtxoPsbt) { + throw new Error(`input is already in psbt format`); + } + + if (tx instanceof utxolib.bitgo.UtxoTransaction) { + const psbt = await legacyToPsbt(params.httpClient, tx); + const { outfile = getOutfile(tx, format) } = params; + await fs.writeFile(outfile, psbt.toBase64()); + console.log(`wrote ${outfile}`); + return; + } + + throw new Error(`unknown tx type`); + } +} diff --git a/modules/utxo-bin/src/fetch.ts b/modules/utxo-bin/src/fetch.ts index 42e185ab42..878232161a 100644 --- a/modules/utxo-bin/src/fetch.ts +++ b/modules/utxo-bin/src/fetch.ts @@ -63,6 +63,10 @@ export async function fetchTransactionStatus( return await getApi(httpClient, network).getTransactionStatus(txid); } +export async function fetchPrevTx(httpClient: HttpClient, tx: ParserTx): Promise { + return await blockapis.fetchPrevTxBuffers(getTxOutPoints(tx), getApi(httpClient, tx.network), tx.network); +} + export async function fetchPrevOutputs(httpClient: HttpClient, tx: ParserTx): Promise[]> { return (await blockapis.fetchInputs(getTxOutPoints(tx), getApi(httpClient, tx.network), tx.network)).map((v) => ({ ...v,