Skip to content

Commit

Permalink
Merge pull request #5228 from BitGo/BTC-1450.add-abstract-utxo-core.1
Browse files Browse the repository at this point in the history
feat: add `abstract-utxo/src/core`
  • Loading branch information
OttoAllmendinger authored Dec 9, 2024
2 parents 454befb + 6ca55f5 commit dd69113
Show file tree
Hide file tree
Showing 38 changed files with 2,237 additions and 6 deletions.
6 changes: 2 additions & 4 deletions modules/abstract-utxo/.mocharc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

module.exports = {
require: 'ts-node/register',
timeout: '20000',
reporter: 'min',
'reporter-option': ['cdn=true', 'json=false'],
timeout: '2000',
exit: true,
spec: ['test/unit/**/*.ts'],
spec: ['test/**/*.ts'],
};
5 changes: 4 additions & 1 deletion modules/abstract-utxo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"check-fmt": "prettier --check .",
"clean": "rm -r ./dist",
"lint": "eslint --quiet .",
"prepare": "npm run build"
"prepare": "npm run build",
"test": "npm run unit-test",
"unit-test": "mocha --recursive test/"
},
"author": "BitGo SDK Team <[email protected]>",
"license": "MIT",
Expand Down Expand Up @@ -46,6 +48,7 @@
"@types/bluebird": "^3.5.25",
"@types/lodash": "^4.14.121",
"@types/superagent": "4.1.15",
"bip174": "npm:@bitgo-forks/[email protected]",
"bignumber.js": "^9.0.2",
"bitcoinjs-message": "npm:@bitgo-forks/[email protected]",
"bluebird": "^3.5.3",
Expand Down
9 changes: 9 additions & 0 deletions modules/abstract-utxo/src/core/descriptor/DescriptorMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Descriptor } from '@bitgo/wasm-miniscript';

/** Map from descriptor name to descriptor */
export type DescriptorMap = Map<string, Descriptor>;

/** Convert an array of descriptor name-value pairs to a descriptor map */
export function toDescriptorMap(descriptors: { name: string; value: string }[]): DescriptorMap {
return new Map(descriptors.map((d) => [d.name, Descriptor.fromString(d.value, 'derivable')]));
}
51 changes: 51 additions & 0 deletions modules/abstract-utxo/src/core/descriptor/Output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Descriptor } from '@bitgo/wasm-miniscript';

import { DescriptorMap } from './DescriptorMap';
import { createScriptPubKeyFromDescriptor } from './address';

export type Output = {
script: Buffer;
value: bigint;
};

export type WithDescriptor<T> = T & {
descriptor: Descriptor;
};

export type WithOptDescriptor<T> = T & {
descriptor?: Descriptor;
};

export type PrevOutput = {
hash: string;
index: number;
witnessUtxo: Output;
};

export type DescriptorWalletOutput = PrevOutput & {
descriptorName: string;
descriptorIndex: number;
};

export type DerivedDescriptorWalletOutput = WithDescriptor<PrevOutput>;

export function toDerivedDescriptorWalletOutput(
output: DescriptorWalletOutput,
descriptorMap: DescriptorMap
): DerivedDescriptorWalletOutput {
const descriptor = descriptorMap.get(output.descriptorName);
if (!descriptor) {
throw new Error(`Descriptor not found: ${output.descriptorName}`);
}
const derivedDescriptor = descriptor.atDerivationIndex(output.descriptorIndex);
const script = createScriptPubKeyFromDescriptor(derivedDescriptor);
if (!script.equals(output.witnessUtxo.script)) {
throw new Error(`Script mismatch: descriptor ${output.descriptorName} ${descriptor.toString()} script=${script}`);
}
return {
hash: output.hash,
index: output.index,
witnessUtxo: output.witnessUtxo,
descriptor: descriptor.atDerivationIndex(output.descriptorIndex),
};
}
103 changes: 103 additions & 0 deletions modules/abstract-utxo/src/core/descriptor/VirtualSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Dimensions, VirtualSizes } from '@bitgo/unspents';
import { Descriptor } from '@bitgo/wasm-miniscript';

import { DescriptorMap } from './DescriptorMap';

function getScriptPubKeyLength(descType: string): number {
// See https://bitcoinops.org/en/tools/calc-size/
switch (descType) {
case 'Wpkh':
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wpkh
return 22;
case 'Sh':
case 'ShWsh':
case 'ShWpkh':
// https://github.com/bitcoin/bips/blob/master/bip-0016.mediawiki#specification
return 23;
case 'Pkh':
return 25;
case 'Wsh':
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
return 34;
case 'Bare':
throw new Error('cannot determine scriptPubKey length for Bare descriptor');
default:
throw new Error('unexpected descriptor type ' + descType);
}
}

function getInputVSizeForDescriptor(descriptor: Descriptor): number {
// FIXME(BTC-1489): this can overestimate the size of the input significantly
const maxWeight = descriptor.maxWeightToSatisfy();
const maxVSize = Math.ceil(maxWeight / 4);
const sizeOpPushdata1 = 1;
const sizeOpPushdata2 = 2;
return (
// inputId
32 +
// vOut
4 +
// nSequence
4 +
// script overhead
(maxVSize < 255 ? sizeOpPushdata1 : sizeOpPushdata2) +
// script
maxVSize
);
}

export function getInputVSizesForDescriptors(descriptors: DescriptorMap): Record<string, number> {
return Object.fromEntries(
Array.from(descriptors.entries()).map(([name, d]) => {
return [name, getInputVSizeForDescriptor(d)];
})
);
}

export function getChangeOutputVSizesForDescriptor(d: Descriptor): {
inputVSize: number;
outputVSize: number;
} {
return {
inputVSize: getInputVSizeForDescriptor(d),
outputVSize: getScriptPubKeyLength(d.descType()),
};
}

type InputWithDescriptorName = { descriptorName: string };
type OutputWithScript = { script: Buffer };

type Tx<TInput> = {
inputs: TInput[];
outputs: OutputWithScript[];
};

export function getVirtualSize(tx: Tx<Descriptor>): number;
export function getVirtualSize(tx: Tx<InputWithDescriptorName>, descriptors: DescriptorMap): number;
export function getVirtualSize(
tx: Tx<Descriptor> | Tx<InputWithDescriptorName>,
descriptorMap?: DescriptorMap
): number {
const lookup = descriptorMap ? getInputVSizesForDescriptors(descriptorMap) : undefined;
const inputVSize = tx.inputs.reduce((sum, input) => {
if (input instanceof Descriptor) {
return sum + getInputVSizeForDescriptor(input);
}
if ('descriptorName' in input) {
if (!lookup) {
throw new Error('missing descriptorMap');
}
const vsize = lookup[input.descriptorName];
if (!vsize) {
throw new Error(`Could not find descriptor ${input.descriptorName}`);
}
return sum + vsize;
}
throw new Error('unexpected input');
}, 0);
const outputVSize = tx.outputs.reduce((sum, o) => {
return sum + Dimensions.getVSizeForOutputWithScriptLength(o.script.length);
}, 0);
// we will just assume that we have at least one segwit input
return inputVSize + outputVSize + VirtualSizes.txSegOverheadVSize;
}
17 changes: 17 additions & 0 deletions modules/abstract-utxo/src/core/descriptor/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Descriptor } from '@bitgo/wasm-miniscript';
import * as utxolib from '@bitgo/utxo-lib';

export function createScriptPubKeyFromDescriptor(descriptor: Descriptor, index?: number): Buffer {
if (index === undefined) {
return Buffer.from(descriptor.scriptPubkey());
}
return createScriptPubKeyFromDescriptor(descriptor.atDerivationIndex(index));
}

export function createAddressFromDescriptor(
descriptor: Descriptor,
index: number | undefined,
network: utxolib.Network
): string {
return utxolib.address.fromOutputScript(createScriptPubKeyFromDescriptor(descriptor, index), network);
}
10 changes: 10 additions & 0 deletions modules/abstract-utxo/src/core/descriptor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type { Output, DescriptorWalletOutput, WithDescriptor, WithOptDescriptor } from './Output';
export type { DescriptorMap } from './DescriptorMap';
export type { PsbtParams } from './psbt';

export { createAddressFromDescriptor, createScriptPubKeyFromDescriptor } from './address';
export { createPsbt, finalizePsbt } from './psbt';
export { toDescriptorMap } from './DescriptorMap';
export { toDerivedDescriptorWalletOutput } from './Output';
export { signTxLocal } from './signTxLocal';
export { parseAndValidateTransaction } from './parseTransaction';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './parseTransaction';
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Descriptor } from '@bitgo/wasm-miniscript';
import * as utxolib from '@bitgo/utxo-lib';

import { DescriptorMap } from '../DescriptorMap';
import { getVirtualSize } from '../VirtualSize';
import { findDescriptorForInput, findDescriptorForOutput } from '../psbt/findDescriptors';
import { assertSatisfiable } from '../psbt/assertSatisfiable';

type ScriptId = { descriptor: Descriptor; index: number };

type ParsedInput = {
address: string;
value: bigint;
scriptId: ScriptId;
};

type ParsedOutput = {
address?: string;
script: Buffer;
value: bigint;
scriptId?: ScriptId;
};

type ParsedDescriptorTransaction = {
inputs: ParsedInput[];
outputs: ParsedOutput[];
spendAmount: bigint;
minerFee: bigint;
virtualSize: number;
};

function sum(...values: bigint[]): bigint {
return values.reduce((a, b) => a + b, BigInt(0));
}

export function parseAndValidateTransaction(
psbt: utxolib.Psbt,
descriptorMap: DescriptorMap,
network: utxolib.Network
): ParsedDescriptorTransaction {
const inputs = psbt.data.inputs.map((input, inputIndex): ParsedInput => {
if (!input.witnessUtxo) {
throw new Error('invalid input: no witnessUtxo');
}
if (!input.witnessUtxo.value) {
throw new Error('invalid input: no value');
}
const descriptorWithIndex = findDescriptorForInput(input, descriptorMap);
if (!descriptorWithIndex) {
throw new Error('invalid input: no descriptor found');
}
assertSatisfiable(psbt, inputIndex, descriptorWithIndex.descriptor);
return {
address: utxolib.address.fromOutputScript(input.witnessUtxo.script, network),
value: input.witnessUtxo.value,
scriptId: descriptorWithIndex,
};
});
const outputs = psbt.txOutputs.map((output, i): ParsedOutput => {
if (output.value === undefined) {
throw new Error('invalid output: no value');
}
const descriptorWithIndex = findDescriptorForOutput(output.script, psbt.data.outputs[i], descriptorMap);
return {
address: output.address,
script: output.script,
value: output.value,
scriptId: descriptorWithIndex,
};
});
const inputAmount = sum(...inputs.map((input) => input.value));
const outputSum = sum(...outputs.map((output) => output.value));
const spendAmount = sum(...outputs.filter((output) => !('descriptor' in output)).map((output) => output.value));
const minerFee = inputAmount - outputSum;
return {
inputs,
outputs,
spendAmount,
minerFee,
virtualSize: getVirtualSize({ inputs: inputs.map((i) => i.scriptId.descriptor), outputs }),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* These are some helpers for testing satisfiability of descriptors in PSBTs.
*
* They are mostly a debugging aid - if an input cannot be satisified, the `finalizePsbt()` method will fail, but
* the error message is pretty vague.
*
* The methods here have the goal of catching certain cases earlier and with a better error message.
*
* The goal is not an exhaustive check, but to catch common mistakes.
*/
import { Descriptor } from '@bitgo/wasm-miniscript';
import * as utxolib from '@bitgo/utxo-lib';

export const FINAL_SEQUENCE = 0xffffffff;

/**
* Get the required locktime for a descriptor.
* @param descriptor
*/
export function getRequiredLocktime(descriptor: Descriptor | unknown): number | undefined {
if (descriptor instanceof Descriptor) {
return getRequiredLocktime(descriptor.node());
}
if (typeof descriptor !== 'object' || descriptor === null) {
return undefined;
}
if ('Wsh' in descriptor) {
return getRequiredLocktime(descriptor.Wsh);
}
if ('Sh' in descriptor) {
return getRequiredLocktime(descriptor.Sh);
}
if ('Ms' in descriptor) {
return getRequiredLocktime(descriptor.Ms);
}
if ('AndV' in descriptor) {
if (!Array.isArray(descriptor.AndV)) {
throw new Error('Expected an array');
}
if (descriptor.AndV.length !== 2) {
throw new Error('Expected exactly two elements');
}
const [a, b] = descriptor.AndV;
return getRequiredLocktime(a) ?? getRequiredLocktime(b);
}
if ('Drop' in descriptor) {
return getRequiredLocktime(descriptor.Drop);
}
if ('Verify' in descriptor) {
return getRequiredLocktime(descriptor.Verify);
}
if ('After' in descriptor && typeof descriptor.After === 'object' && descriptor.After !== null) {
if ('absLockTime' in descriptor.After && typeof descriptor.After.absLockTime === 'number') {
return descriptor.After.absLockTime;
}
}
return undefined;
}

export function assertSatisfiable(psbt: utxolib.Psbt, inputIndex: number, descriptor: Descriptor): void {
// If the descriptor requires a locktime, the input must have a non-final sequence number
const requiredLocktime = getRequiredLocktime(descriptor);
if (requiredLocktime !== undefined) {
const input = psbt.txInputs[inputIndex];
if (input.sequence === FINAL_SEQUENCE) {
throw new Error(`Input ${inputIndex} has a non-final sequence number, but requires a timelock`);
}
if (psbt.locktime !== requiredLocktime) {
throw new Error(`psbt locktime (${psbt.locktime}) does not match required locktime (${requiredLocktime})`);
}
}
}
Loading

0 comments on commit dd69113

Please sign in to comment.