Skip to content

Commit

Permalink
feat(utxo-core): add new module for UTXO types and functions
Browse files Browse the repository at this point in the history
Add core types and functions for Bitcoin-like UTXO-based cryptocurrencies.
This module provides the glue between SDK-dependent modules and low-level
libraries.

Issue: BTC-1821
  • Loading branch information
OttoAllmendinger committed Feb 6, 2025
1 parent 6e244e6 commit 7046b8a
Show file tree
Hide file tree
Showing 51 changed files with 3,193 additions and 1 deletion.
3 changes: 2 additions & 1 deletion CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
/modules/sdk-core/src/bitgo/lightning/ @BitGo/btc-team
/modules/unspents/ @BitGo/btc-team
/modules/utxo-bin/ @BitGo/btc-team
/modules/utxo-staking/ @BitGo/btc-team
/modules/utxo-core/ @BitGo/btc-team
/modules/utxo-lib/ @BitGo/btc-team
/modules/utxo-ord/ @BitGo/btc-team
/modules/utxo-staking/ @BitGo/btc-team

# Lightning coin modules
/modules/abstract-lightning/ @BitGo/btc-team
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-utxo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@bitgo/sdk-core": "^28.23.0",
"@bitgo/unspents": "^0.47.17",
"@bitgo/utxo-lib": "^11.2.1",
"@bitgo/utxo-core": "^1.0.0",
"@bitgo/wasm-miniscript": "^2.0.0-beta.2",
"@types/bluebird": "^3.5.25",
"@types/lodash": "^4.14.121",
Expand Down
3 changes: 3 additions & 0 deletions modules/abstract-utxo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
},
{
"path": "../utxo-lib"
},
{
"path": "../utxo-core"
}
]
}
6 changes: 6 additions & 0 deletions modules/utxo-core/.mocharc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

module.exports = {
require: 'ts-node/register',
extension: ['.js', '.ts'],
};
6 changes: 6 additions & 0 deletions modules/utxo-core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# utxo-core

This repository contains core types and functions for Bitcoin-like UTXO-based cryptocurrencies.

It is the glue between SDK-dependent modules like `abstract-utxo` and low-level libraries like `utxo-lib` and
`wasm-miniscript`.
54 changes: 54 additions & 0 deletions modules/utxo-core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@bitgo/utxo-core",
"version": "1.0.0",
"description": "BitGo UTXO Core types",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"files": [
"dist/src"
],
"exports": {
".": "./dist/src/index.js",
"./descriptor": "./dist/src/descriptor/index.js",
"./testutil": "./dist/src/testutil/index.js",
"./testutil/descriptor": "./dist/src/testutil/descriptor/index.js"
},
"scripts": {
"build": "yarn tsc --build --incremental --verbose .",
"fmt": "prettier --write .",
"check-fmt": "prettier --check .",
"clean": "rm -r ./dist",
"lint": "eslint --quiet .",
"prepare": "npm run build",
"test": "npm run unit-test",
"unit-test": "mocha --recursive test/"
},
"author": "BitGo SDK Team <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/BitGo/BitGoJS.git",
"directory": "modules/utxo-core"
},
"lint-staged": {
"*.{js,ts}": [
"yarn prettier --write",
"yarn eslint --fix"
]
},
"publishConfig": {
"access": "public"
},
"nyc": {
"extension": [
".ts"
]
},
"dependencies": {
"@bitgo/unspents": "^0.47.17",
"@bitgo/utxo-lib": "^11.2.1",
"@bitgo/wasm-miniscript": "^2.0.0-beta.2",
"bip174": "npm:@bitgo-forks/[email protected]"
},
"gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c"
}
14 changes: 14 additions & 0 deletions modules/utxo-core/src/descriptor/DescriptorMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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: Descriptor | string }[]): DescriptorMap {
return new Map(
descriptors.map((d) => [
d.name,
d.value instanceof Descriptor ? d.value : Descriptor.fromString(d.value, 'derivable'),
])
);
}
51 changes: 51 additions & 0 deletions modules/utxo-core/src/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),
};
}
107 changes: 107 additions & 0 deletions modules/utxo-core/src/descriptor/VirtualSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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':
case 'Tr':
// P2WSH: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
// P2TR: https://github.com/bitcoin/bips/blob/58ffd93812ff25e87d53d1f202fbb389fdfb85bb/bip-0341.mediawiki#script-validation-rules
// > A Taproot output is a native SegWit output (see BIP141) with version number 1, and a 32-byte witness program.
// 32 bytes for the hash, 1 byte for the version, 1 byte for the push opcode
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/utxo-core/src/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);
}
9 changes: 9 additions & 0 deletions modules/utxo-core/src/descriptor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type { Output, DescriptorWalletOutput, WithDescriptor, WithOptDescriptor } from './Output';
export type { DescriptorMap } from './DescriptorMap';
export * as psbt from './psbt';
export type { PsbtParams } from './psbt';

export { createAddressFromDescriptor, createScriptPubKeyFromDescriptor } from './address';
export { createPsbt, finalizePsbt, parse } from './psbt';
export { toDescriptorMap } from './DescriptorMap';
export { toDerivedDescriptorWalletOutput } from './Output';
72 changes: 72 additions & 0 deletions modules/utxo-core/src/descriptor/psbt/assertSatisfiable.ts
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 7046b8a

Please sign in to comment.