Skip to content

Commit

Permalink
feat(utxo-bin): add new command convertTx
Browse files Browse the repository at this point in the history
Currently only supports converting from legacy or network to PSBT.

Issue: BTC-428
  • Loading branch information
OttoAllmendinger committed Oct 25, 2023
1 parent 45f6543 commit 461fbf4
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 89 deletions.
3 changes: 2 additions & 1 deletion modules/utxo-bin/bin/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/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)
Expand Down
213 changes: 125 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,115 @@ 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 readTransaction(argv: ArgsReadTransaction, httpClient: HttpClient): Promise<ParserTx> {
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`);
}

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();
}

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 +198,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 +257,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 +274,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 +395,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,
});
},
};
60 changes: 60 additions & 0 deletions modules/utxo-bin/src/convertTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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<bigint>) {
const prevTxs = await fetchPrevTx(httpClient, tx);
const prevOutputs = tx.ins.map((input, i): utxolib.bitgo.PrevOutput<bigint> => {
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<void> {
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(`TODO`);
}
break;
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;
}

break;
}
}
4 changes: 4 additions & 0 deletions modules/utxo-bin/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export async function fetchTransactionStatus(
return await getApi(httpClient, network).getTransactionStatus(txid);
}

export async function fetchPrevTx(httpClient: HttpClient, tx: ParserTx): Promise<Buffer[]> {
return await blockapis.fetchPrevTxBuffers(getTxOutPoints(tx), getApi(httpClient, tx.network), tx.network);
}

export async function fetchPrevOutputs(httpClient: HttpClient, tx: ParserTx): Promise<utxolib.TxOutput<bigint>[]> {
return (await blockapis.fetchInputs(getTxOutPoints(tx), getApi(httpClient, tx.network), tx.network)).map((v) => ({
...v,
Expand Down

0 comments on commit 461fbf4

Please sign in to comment.