Skip to content

Commit

Permalink
Merge pull request #5154 from BitGo/BTC-1472.add-utxo-cli-psbt
Browse files Browse the repository at this point in the history
feat(utxo-bin): add `psbt` subcommand
  • Loading branch information
OttoAllmendinger authored Nov 21, 2024
2 parents 578cea9 + f4b945b commit 1a16835
Show file tree
Hide file tree
Showing 38 changed files with 867 additions and 36 deletions.
3 changes: 2 additions & 1 deletion modules/utxo-bin/bin/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#!/usr/bin/env node
import * as yargs from 'yargs';

import { cmdParseTx, cmdParseScript, cmdBip32, cmdAddress } from '../src/commands';
import { cmdParseTx, cmdParseScript, cmdBip32, cmdPsbt, cmdAddress } from '../src/commands';

yargs
.command(cmdParseTx)
.command(cmdAddress)
.command(cmdParseScript)
.command(cmdPsbt)
.command(cmdBip32)
.strict()
.demandCommand()
Expand Down
1 change: 1 addition & 0 deletions modules/utxo-bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@bitgo/blockapis": "^1.10.6",
"@bitgo/statics": "^50.8.0",
"@bitgo/utxo-lib": "^11.0.1",
"@bitgo/unspents": "^0.47.13",
"@bitgo/wasm-miniscript": "^1.8.0",
"archy": "^1.0.0",
"bech32": "^2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion modules/utxo-bin/src/commands/cmdParseTx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export type ArgsParseTransaction = ReadStringOptions & {
parseError: 'throw' | 'continue';
} & Omit<TxParserArgs, 'parseSignatureData'>;

export function getTxParser(argv: yargs.Arguments<ArgsParseTransaction>): TxParser {
export function getTxParser(argv: ArgsParseTransaction): TxParser {
if (argv.all) {
return new TxParser({ ...argv, ...TxParser.PARSE_ALL });
}
Expand Down
99 changes: 99 additions & 0 deletions modules/utxo-bin/src/commands/cmdPsbt/cmdAddDescriptorInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as utxolib from '@bitgo/utxo-lib';
import { Descriptor } from '@bitgo/wasm-miniscript';
import * as yargs from 'yargs';

import { toUtxoPsbt, toWrappedPsbt } from './wrap';
import { withPsbt, WithPsbtOptions, withPsbtOptions } from './withPsbt';

/**
* Non-Final (Replaceable)
* Reference: https://github.com/bitcoin/bitcoin/blob/v25.1/src/rpc/rawtransaction_util.cpp#L49
* */
const MAX_BIP125_RBF_SEQUENCE = 0xffffffff - 2;

type ArgsAddDescriptorInput = WithPsbtOptions & {
outputId: string;
address?: string;
scriptPubKey?: string;
value: string;
descriptor: string;
descriptorIndex: number;
};

function getScriptPubKey(
args: { address?: string; scriptPubKey?: string },
network: utxolib.Network
): Buffer | undefined {
if (args.address) {
return utxolib.addressFormat.toOutputScriptTryFormats(args.address, network);
}
if (args.scriptPubKey) {
return Buffer.from(args.scriptPubKey, 'hex');
}
return undefined;
}

function addDescriptorInput(
psbt: utxolib.Psbt,
outputId: string,
scriptPubKey: Buffer | undefined,
value: bigint,
descriptorString: string,
descriptorIndex: number,
{ sequence = MAX_BIP125_RBF_SEQUENCE } = {}
): void {
const { txid, vout } = utxolib.bitgo.parseOutputId(outputId);
const descriptor = Descriptor.fromString(descriptorString, 'derivable');
const derivedDescriptor = descriptor.atDerivationIndex(descriptorIndex);
if (scriptPubKey === undefined) {
scriptPubKey = Buffer.from(derivedDescriptor.scriptPubkey());
}
psbt.addInput({
hash: txid,
index: vout,
sequence,
witnessUtxo: {
script: scriptPubKey,
value,
},
});
const inputIndex = psbt.txInputs.length - 1;
const wrappedPsbt = toWrappedPsbt(psbt);
wrappedPsbt.updateInputWithDescriptor(inputIndex, derivedDescriptor);
const utxoPsbt = toUtxoPsbt(wrappedPsbt);
psbt.data.inputs[inputIndex] = utxoPsbt.data.inputs[inputIndex];
}

export const cmdAddDescriptorInput: yargs.CommandModule<unknown, ArgsAddDescriptorInput> = {
command: 'addDescriptorInput',
describe: 'add descriptor input to psbt',
builder(b: yargs.Argv<unknown>) {
return b
.options(withPsbtOptions)
.option('outputId', { type: 'string', demandOption: true })
.option('address', { type: 'string' })
.option('scriptPubKey', { type: 'string' })
.option('value', { type: 'string', demandOption: true })
.option('descriptor', { type: 'string', demandOption: true })
.option('descriptorIndex', { type: 'number', demandOption: true });
},
async handler(argv) {
await withPsbt(argv, async function (psbt) {
addDescriptorInput(
psbt,
argv.outputId,
getScriptPubKey(
{
address: argv.address,
scriptPubKey: argv.scriptPubKey,
},
argv.network
),
BigInt(argv.value),
argv.descriptor,
argv.descriptorIndex
);
return psbt;
});
},
};
129 changes: 129 additions & 0 deletions modules/utxo-bin/src/commands/cmdPsbt/cmdAddOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as yargs from 'yargs';
import * as utxolib from '@bitgo/utxo-lib';
import { Dimensions } from '@bitgo/unspents';
import { Buffer } from 'buffer';
import { Descriptor, Miniscript } from '@bitgo/wasm-miniscript';

import { withPsbt, withPsbtOptions, WithPsbtOptions } from './withPsbt';

function toScriptPubKey(
params: {
address?: string;
scriptPubKey?: string;
},
network: utxolib.Network
): Buffer {
if (params.address) {
return utxolib.addressFormat.toOutputScriptTryFormats(params.address, network);
}
if (params.scriptPubKey) {
return Buffer.from(params.scriptPubKey, 'hex');
}
throw new Error('address or scriptPubKey is required');
}

type ArgsAddOutput = WithPsbtOptions & {
address?: string;
scriptPubKey?: string;
amount: string;
feeRateSatB?: number;
};

function getInputWeight(psbt: utxolib.Psbt, inputIndex?: number): number {
if (inputIndex === undefined) {
return psbt.txInputs.reduce((sum, input, inputIndex) => sum + getInputWeight(psbt, inputIndex), 0);
}
const { redeemScript, witnessScript } = psbt.data.inputs[inputIndex];
if (redeemScript) {
throw new Error('redeemScript is not supported');
}
if (!witnessScript) {
throw new Error('witnessScript is required');
}
const witnessMiniscript = Miniscript.fromBitcoinScript(witnessScript, 'segwitv0');
const descriptor = Descriptor.fromString(`wsh(${witnessMiniscript.toString()})`, 'definite');
return descriptor.maxWeightToSatisfy();
}

function getOutputVsize(psbt: utxolib.Psbt, outputIndex?: number): number {
if (outputIndex === undefined) {
return psbt.txOutputs.reduce((sum, output, outputIndex) => sum + getOutputVsize(psbt, outputIndex), 0);
}
const { script } = psbt.txOutputs[outputIndex];
return Dimensions.getVSizeForOutputWithScriptLength(script.length);
}

function getMaxOutputValue(
psbt: utxolib.Psbt,
{
scriptPubKey,
feeRateSatB,
}: {
scriptPubKey: Buffer;
feeRateSatB: number;
}
): bigint {
const inputSum = psbt.data.inputs.reduce((sum, input) => {
if (!input.witnessUtxo) {
throw new Error('witnessUtxo is required');
}
return sum + input.witnessUtxo.value;
}, BigInt(0));
const outputSum = psbt.txOutputs.reduce((sum, output) => sum + output.value, BigInt(0));
const inputVsize = Math.ceil(getInputWeight(psbt) / 4);
const outputVsize = getOutputVsize(psbt) + Dimensions.getVSizeForOutputWithScriptLength(scriptPubKey.length);
const totalVsize = inputVsize + outputVsize + 11;
const fee = BigInt(totalVsize * feeRateSatB);
if (inputSum < outputSum + fee) {
throw new Error(`insufficient funds: [inputSum=${inputSum}, outputSum=${outputSum}, fee=${fee}]`);
}
return inputSum - outputSum - fee;
}

function getOutputValue(
amount: string,
{
scriptPubKey,
psbt,
feeRateSatB,
}: {
scriptPubKey: Buffer;
psbt: utxolib.Psbt;
feeRateSatB?: number;
}
): bigint {
if (amount === 'max') {
if (!feeRateSatB) {
throw new Error('feeRateSatB is required');
}
return getMaxOutputValue(psbt, { scriptPubKey, feeRateSatB });
}
return BigInt(parseFloat(amount));
}

export const cmdAddOutput: yargs.CommandModule<unknown, ArgsAddOutput> = {
command: 'addOutput',
describe: 'add output to psbt',
builder(b: yargs.Argv<unknown>) {
return b
.options(withPsbtOptions)
.option('address', { type: 'string' })
.option('scriptPubKey', { type: 'string' })
.option('amount', { type: 'string', demandOption: true })
.option('feeRateSatB', { type: 'number' });
},
async handler(argv) {
await withPsbt(argv, async function (psbt) {
const scriptPubKey = toScriptPubKey(
{
address: argv.address,
scriptPubKey: argv.scriptPubKey,
},
argv.network
);
const value = getOutputValue(argv.amount, { scriptPubKey, psbt, feeRateSatB: argv.feeRateSatB });
psbt.addOutput({ script: scriptPubKey, value });
return psbt;
});
},
};
26 changes: 26 additions & 0 deletions modules/utxo-bin/src/commands/cmdPsbt/cmdCreate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as yargs from 'yargs';
import { withPsbt, withPsbtOptions, WithPsbtOptions } from './withPsbt';

type ArgsCreatePsbt = WithPsbtOptions & {
txVersion?: number;
txLocktime?: number;
};

export const cmdCreate: yargs.CommandModule<unknown, ArgsCreatePsbt> = {
builder(b: yargs.Argv<unknown>) {
return b.options(withPsbtOptions).option('txVersion', { type: 'number' }).option('txLocktime', { type: 'number' });
},
command: 'create',
describe: 'create empty psbt without inputs or outputs',
async handler(argv) {
return withPsbt({ ...argv, create: true, expectEmpty: true }, async function (psbt) {
if (argv.txVersion !== undefined) {
psbt.setVersion(argv.txVersion);
}
if (argv.txLocktime !== undefined) {
psbt.setLocktime(argv.txLocktime);
}
return psbt;
});
},
};
47 changes: 47 additions & 0 deletions modules/utxo-bin/src/commands/cmdPsbt/cmdFinalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Argv, CommandModule } from 'yargs';
import { withPsbt, WithPsbtOptions, withPsbtOptions } from './withPsbt';
import * as utxolib from '@bitgo/utxo-lib';
import { toUtxoPsbt, toWrappedPsbt } from './wrap';

type ArgsFinalizePsbt = WithPsbtOptions & {
extract: boolean;
};

export function finalizeWithWrappedPsbt(psbt: utxolib.bitgo.UtxoPsbt | utxolib.Psbt): void {
const wrappedPsbt = toWrappedPsbt(psbt);
wrappedPsbt.finalize();
const unwrappedPsbt = toUtxoPsbt(wrappedPsbt);
for (let i = 0; i < psbt.data.inputs.length; i++) {
psbt.data.inputs[i] = unwrappedPsbt.data.inputs[i];
}
}

export const cmdFinalize: CommandModule<unknown, ArgsFinalizePsbt> = {
command: 'finalize [psbt]',
describe: 'finalize psbt',
builder(b: Argv<unknown>): Argv<ArgsFinalizePsbt> {
return b.options(withPsbtOptions).option('extract', { type: 'boolean', default: false });
},
async handler(argv) {
await withPsbt(argv, async function (psbt) {
finalizeWithWrappedPsbt(psbt);
if (argv.extract) {
return psbt.extractTransaction().toBuffer();
}
return psbt;
});
},
};

export const cmdExtract: CommandModule<unknown, WithPsbtOptions> = {
command: 'extract [psbt]',
describe: 'extract transaction from psbt',
builder(b: Argv<unknown>): Argv<WithPsbtOptions> {
return b.options(withPsbtOptions);
},
async handler(argv) {
await withPsbt(argv, async function (psbt) {
return psbt.extractTransaction().toBuffer();
});
},
};
27 changes: 27 additions & 0 deletions modules/utxo-bin/src/commands/cmdPsbt/cmdSign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Argv, CommandModule } from 'yargs';
import * as utxolib from '@bitgo/utxo-lib';

import { withPsbt, WithPsbtOptions, withPsbtOptions } from './withPsbt';
import { getNetworkOptionsDemand } from '../../args';

export type ArgsSignPsbt = WithPsbtOptions & {
key: string;
};

export const cmdSign: CommandModule<unknown, ArgsSignPsbt> = {
command: 'sign [psbt]',
describe: 'sign psbt',
builder(b: Argv<unknown>): Argv<ArgsSignPsbt> {
return b
.options(getNetworkOptionsDemand('bitcoin'))
.options(withPsbtOptions)
.option('key', { type: 'string', demandOption: true });
},
async handler(argv) {
const key = utxolib.bip32.fromBase58(argv.key, argv.network);
await withPsbt(argv, async function (psbt) {
psbt.signAllInputsHD(key);
return psbt;
});
},
};
26 changes: 26 additions & 0 deletions modules/utxo-bin/src/commands/cmdPsbt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Argv, CommandModule } from 'yargs';

import { cmdSign } from './cmdSign';
import { cmdAddOutput } from './cmdAddOutput';
import { cmdAddDescriptorInput } from './cmdAddDescriptorInput';
import { cmdExtract, cmdFinalize } from './cmdFinalize';
import { cmdCreate } from './cmdCreate';

export const cmdPsbt = {
command: 'psbt <command>',
describe: 'psbt commands',
builder(b: Argv<unknown>): Argv<unknown> {
return b
.strict()
.command(cmdCreate)
.command(cmdAddDescriptorInput)
.command(cmdAddOutput)
.command(cmdSign)
.command(cmdFinalize)
.command(cmdExtract)
.demandCommand();
},
handler(): void {
// do nothing
},
} satisfies CommandModule<unknown, unknown>;
Loading

0 comments on commit 1a16835

Please sign in to comment.