Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(utxo-bin): add new command convertTx #4017

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 47 additions & 24 deletions modules/blockapis/src/UtxoApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,60 @@ export interface UtxoApi extends TransactionApi {
getUnspentsForAddresses(address: string[]): Promise<utxolib.bitgo.Unspent[]>;
}

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<TOut>(
outpoints: utxolib.bitgo.TxOutPoint[],
f: (txid: string) => Promise<TOut[]>
): Promise<TOut[]> {
async function mapInputs<T>(outpoints: utxolib.bitgo.TxOutPoint[], f: (txid: string) => Promise<T>): Promise<T[]> {
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<TOut>(
outpoints: utxolib.bitgo.TxOutPoint[],
f: (txid: string) => Promise<TOut[]>
): Promise<TOut[]> {
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<Buffer[]> {
return mapInputs(toOutPoints(ins), async (txid) => Buffer.from(await api.getTransactionHex(txid), 'hex'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unusual seeing here an async function being passed this way, but I like it!

}

/**
* Fetch transaction inputs from transaction input list
* @param ins
Expand All @@ -79,13 +107,8 @@ export async function fetchInputs(
api: UtxoApi,
network: utxolib.Network
): Promise<utxolib.TxOutput[]> {
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
);
}
Expand All @@ -97,5 +120,5 @@ export async function fetchTransactionSpends(
outpoints: utxolib.bitgo.TxOutPoint[],
api: UtxoApi
): Promise<OutputSpend[]> {
return mapInputs(outpoints, async (txid) => await api.getTransactionSpends(txid));
return mapInputsVOut(outpoints, async (txid) => await api.getTransactionSpends(txid));
}
4 changes: 3 additions & 1 deletion modules/utxo-bin/bin/index.ts
Original file line number Diff line number Diff line change
@@ -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();
218 changes: 130 additions & 88 deletions modules/utxo-bin/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<T>(b: yargs.Argv<T>): yargs.Argv<T & ArgsReadTransaction> {
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<Buffer> {
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`);
}
rushilbg marked this conversation as resolved.
Show resolved Hide resolved

return stringToBuffer(data, ['hex', 'base64']);
}

async function readTransaction(argv: ArgsReadTransaction, httpClient: HttpClient): Promise<ParserTx> {
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;
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -162,17 +262,11 @@ export const cmdParseTx = {
'Bytes must be encoded in hex or base64 format.',

builder(b: yargs.Argv<unknown>): yargs.Argv<ArgsParseTransaction> {
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 })
Expand All @@ -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<ArgsParseTransaction>): Promise<void> {
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));
Expand Down Expand Up @@ -375,3 +400,20 @@ export const cmdGenerateAddress = {
}
},
};

export const cmdConvertTx = {
command: 'convertTx [path]',
describe: 'convert between transaction formats',
builder(b: yargs.Argv<unknown>): yargs.Argv<ArgsConvertTransaction> {
return addReadTransactionOptions(b).option('format', { choices: ['legacy', 'psbt'], default: 'psbt' } as const);
},
async handler(argv: yargs.Arguments<ArgsConvertTransaction>): Promise<void> {
const httpClient = await getClient(argv);
const tx = await readTransaction(argv, httpClient);
await convertTransaction(tx, {
httpClient,
format: argv.format,
outfile: argv.outfile,
});
},
};
Loading
Loading