Skip to content

Commit 6ca55f5

Browse files
feat(abstract-utxo): add package src/core
Adds psbt and descriptor utilities TICKET: BTC-1450
1 parent c2187fb commit 6ca55f5

37 files changed

+2236
-5
lines changed

modules/abstract-utxo/.mocharc.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22

33
module.exports = {
44
require: 'ts-node/register',
5-
timeout: '20000',
6-
reporter: 'min',
7-
'reporter-option': ['cdn=true', 'json=false'],
5+
timeout: '2000',
86
exit: true,
9-
spec: ['test/unit/**/*.ts'],
7+
spec: ['test/**/*.ts'],
108
};

modules/abstract-utxo/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"check-fmt": "prettier --check .",
1111
"clean": "rm -r ./dist",
1212
"lint": "eslint --quiet .",
13-
"prepare": "npm run build"
13+
"prepare": "npm run build",
14+
"test": "npm run unit-test",
15+
"unit-test": "mocha --recursive test/"
1416
},
1517
"author": "BitGo SDK Team <[email protected]>",
1618
"license": "MIT",
@@ -46,6 +48,7 @@
4648
"@types/bluebird": "^3.5.25",
4749
"@types/lodash": "^4.14.121",
4850
"@types/superagent": "4.1.15",
51+
"bip174": "npm:@bitgo-forks/[email protected]",
4952
"bignumber.js": "^9.0.2",
5053
"bitcoinjs-message": "npm:@bitgo-forks/[email protected]",
5154
"bluebird": "^3.5.3",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Descriptor } from '@bitgo/wasm-miniscript';
2+
3+
/** Map from descriptor name to descriptor */
4+
export type DescriptorMap = Map<string, Descriptor>;
5+
6+
/** Convert an array of descriptor name-value pairs to a descriptor map */
7+
export function toDescriptorMap(descriptors: { name: string; value: string }[]): DescriptorMap {
8+
return new Map(descriptors.map((d) => [d.name, Descriptor.fromString(d.value, 'derivable')]));
9+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Descriptor } from '@bitgo/wasm-miniscript';
2+
3+
import { DescriptorMap } from './DescriptorMap';
4+
import { createScriptPubKeyFromDescriptor } from './address';
5+
6+
export type Output = {
7+
script: Buffer;
8+
value: bigint;
9+
};
10+
11+
export type WithDescriptor<T> = T & {
12+
descriptor: Descriptor;
13+
};
14+
15+
export type WithOptDescriptor<T> = T & {
16+
descriptor?: Descriptor;
17+
};
18+
19+
export type PrevOutput = {
20+
hash: string;
21+
index: number;
22+
witnessUtxo: Output;
23+
};
24+
25+
export type DescriptorWalletOutput = PrevOutput & {
26+
descriptorName: string;
27+
descriptorIndex: number;
28+
};
29+
30+
export type DerivedDescriptorWalletOutput = WithDescriptor<PrevOutput>;
31+
32+
export function toDerivedDescriptorWalletOutput(
33+
output: DescriptorWalletOutput,
34+
descriptorMap: DescriptorMap
35+
): DerivedDescriptorWalletOutput {
36+
const descriptor = descriptorMap.get(output.descriptorName);
37+
if (!descriptor) {
38+
throw new Error(`Descriptor not found: ${output.descriptorName}`);
39+
}
40+
const derivedDescriptor = descriptor.atDerivationIndex(output.descriptorIndex);
41+
const script = createScriptPubKeyFromDescriptor(derivedDescriptor);
42+
if (!script.equals(output.witnessUtxo.script)) {
43+
throw new Error(`Script mismatch: descriptor ${output.descriptorName} ${descriptor.toString()} script=${script}`);
44+
}
45+
return {
46+
hash: output.hash,
47+
index: output.index,
48+
witnessUtxo: output.witnessUtxo,
49+
descriptor: descriptor.atDerivationIndex(output.descriptorIndex),
50+
};
51+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Dimensions, VirtualSizes } from '@bitgo/unspents';
2+
import { Descriptor } from '@bitgo/wasm-miniscript';
3+
4+
import { DescriptorMap } from './DescriptorMap';
5+
6+
function getScriptPubKeyLength(descType: string): number {
7+
// See https://bitcoinops.org/en/tools/calc-size/
8+
switch (descType) {
9+
case 'Wpkh':
10+
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wpkh
11+
return 22;
12+
case 'Sh':
13+
case 'ShWsh':
14+
case 'ShWpkh':
15+
// https://github.com/bitcoin/bips/blob/master/bip-0016.mediawiki#specification
16+
return 23;
17+
case 'Pkh':
18+
return 25;
19+
case 'Wsh':
20+
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
21+
return 34;
22+
case 'Bare':
23+
throw new Error('cannot determine scriptPubKey length for Bare descriptor');
24+
default:
25+
throw new Error('unexpected descriptor type ' + descType);
26+
}
27+
}
28+
29+
function getInputVSizeForDescriptor(descriptor: Descriptor): number {
30+
// FIXME(BTC-1489): this can overestimate the size of the input significantly
31+
const maxWeight = descriptor.maxWeightToSatisfy();
32+
const maxVSize = Math.ceil(maxWeight / 4);
33+
const sizeOpPushdata1 = 1;
34+
const sizeOpPushdata2 = 2;
35+
return (
36+
// inputId
37+
32 +
38+
// vOut
39+
4 +
40+
// nSequence
41+
4 +
42+
// script overhead
43+
(maxVSize < 255 ? sizeOpPushdata1 : sizeOpPushdata2) +
44+
// script
45+
maxVSize
46+
);
47+
}
48+
49+
export function getInputVSizesForDescriptors(descriptors: DescriptorMap): Record<string, number> {
50+
return Object.fromEntries(
51+
Array.from(descriptors.entries()).map(([name, d]) => {
52+
return [name, getInputVSizeForDescriptor(d)];
53+
})
54+
);
55+
}
56+
57+
export function getChangeOutputVSizesForDescriptor(d: Descriptor): {
58+
inputVSize: number;
59+
outputVSize: number;
60+
} {
61+
return {
62+
inputVSize: getInputVSizeForDescriptor(d),
63+
outputVSize: getScriptPubKeyLength(d.descType()),
64+
};
65+
}
66+
67+
type InputWithDescriptorName = { descriptorName: string };
68+
type OutputWithScript = { script: Buffer };
69+
70+
type Tx<TInput> = {
71+
inputs: TInput[];
72+
outputs: OutputWithScript[];
73+
};
74+
75+
export function getVirtualSize(tx: Tx<Descriptor>): number;
76+
export function getVirtualSize(tx: Tx<InputWithDescriptorName>, descriptors: DescriptorMap): number;
77+
export function getVirtualSize(
78+
tx: Tx<Descriptor> | Tx<InputWithDescriptorName>,
79+
descriptorMap?: DescriptorMap
80+
): number {
81+
const lookup = descriptorMap ? getInputVSizesForDescriptors(descriptorMap) : undefined;
82+
const inputVSize = tx.inputs.reduce((sum, input) => {
83+
if (input instanceof Descriptor) {
84+
return sum + getInputVSizeForDescriptor(input);
85+
}
86+
if ('descriptorName' in input) {
87+
if (!lookup) {
88+
throw new Error('missing descriptorMap');
89+
}
90+
const vsize = lookup[input.descriptorName];
91+
if (!vsize) {
92+
throw new Error(`Could not find descriptor ${input.descriptorName}`);
93+
}
94+
return sum + vsize;
95+
}
96+
throw new Error('unexpected input');
97+
}, 0);
98+
const outputVSize = tx.outputs.reduce((sum, o) => {
99+
return sum + Dimensions.getVSizeForOutputWithScriptLength(o.script.length);
100+
}, 0);
101+
// we will just assume that we have at least one segwit input
102+
return inputVSize + outputVSize + VirtualSizes.txSegOverheadVSize;
103+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Descriptor } from '@bitgo/wasm-miniscript';
2+
import * as utxolib from '@bitgo/utxo-lib';
3+
4+
export function createScriptPubKeyFromDescriptor(descriptor: Descriptor, index?: number): Buffer {
5+
if (index === undefined) {
6+
return Buffer.from(descriptor.scriptPubkey());
7+
}
8+
return createScriptPubKeyFromDescriptor(descriptor.atDerivationIndex(index));
9+
}
10+
11+
export function createAddressFromDescriptor(
12+
descriptor: Descriptor,
13+
index: number | undefined,
14+
network: utxolib.Network
15+
): string {
16+
return utxolib.address.fromOutputScript(createScriptPubKeyFromDescriptor(descriptor, index), network);
17+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type { Output, DescriptorWalletOutput, WithDescriptor, WithOptDescriptor } from './Output';
2+
export type { DescriptorMap } from './DescriptorMap';
3+
export type { PsbtParams } from './psbt';
4+
5+
export { createAddressFromDescriptor, createScriptPubKeyFromDescriptor } from './address';
6+
export { createPsbt, finalizePsbt } from './psbt';
7+
export { toDescriptorMap } from './DescriptorMap';
8+
export { toDerivedDescriptorWalletOutput } from './Output';
9+
export { signTxLocal } from './signTxLocal';
10+
export { parseAndValidateTransaction } from './parseTransaction';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './parseTransaction';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Descriptor } from '@bitgo/wasm-miniscript';
2+
import * as utxolib from '@bitgo/utxo-lib';
3+
4+
import { DescriptorMap } from '../DescriptorMap';
5+
import { getVirtualSize } from '../VirtualSize';
6+
import { findDescriptorForInput, findDescriptorForOutput } from '../psbt/findDescriptors';
7+
import { assertSatisfiable } from '../psbt/assertSatisfiable';
8+
9+
type ScriptId = { descriptor: Descriptor; index: number };
10+
11+
type ParsedInput = {
12+
address: string;
13+
value: bigint;
14+
scriptId: ScriptId;
15+
};
16+
17+
type ParsedOutput = {
18+
address?: string;
19+
script: Buffer;
20+
value: bigint;
21+
scriptId?: ScriptId;
22+
};
23+
24+
type ParsedDescriptorTransaction = {
25+
inputs: ParsedInput[];
26+
outputs: ParsedOutput[];
27+
spendAmount: bigint;
28+
minerFee: bigint;
29+
virtualSize: number;
30+
};
31+
32+
function sum(...values: bigint[]): bigint {
33+
return values.reduce((a, b) => a + b, BigInt(0));
34+
}
35+
36+
export function parseAndValidateTransaction(
37+
psbt: utxolib.Psbt,
38+
descriptorMap: DescriptorMap,
39+
network: utxolib.Network
40+
): ParsedDescriptorTransaction {
41+
const inputs = psbt.data.inputs.map((input, inputIndex): ParsedInput => {
42+
if (!input.witnessUtxo) {
43+
throw new Error('invalid input: no witnessUtxo');
44+
}
45+
if (!input.witnessUtxo.value) {
46+
throw new Error('invalid input: no value');
47+
}
48+
const descriptorWithIndex = findDescriptorForInput(input, descriptorMap);
49+
if (!descriptorWithIndex) {
50+
throw new Error('invalid input: no descriptor found');
51+
}
52+
assertSatisfiable(psbt, inputIndex, descriptorWithIndex.descriptor);
53+
return {
54+
address: utxolib.address.fromOutputScript(input.witnessUtxo.script, network),
55+
value: input.witnessUtxo.value,
56+
scriptId: descriptorWithIndex,
57+
};
58+
});
59+
const outputs = psbt.txOutputs.map((output, i): ParsedOutput => {
60+
if (output.value === undefined) {
61+
throw new Error('invalid output: no value');
62+
}
63+
const descriptorWithIndex = findDescriptorForOutput(output.script, psbt.data.outputs[i], descriptorMap);
64+
return {
65+
address: output.address,
66+
script: output.script,
67+
value: output.value,
68+
scriptId: descriptorWithIndex,
69+
};
70+
});
71+
const inputAmount = sum(...inputs.map((input) => input.value));
72+
const outputSum = sum(...outputs.map((output) => output.value));
73+
const spendAmount = sum(...outputs.filter((output) => !('descriptor' in output)).map((output) => output.value));
74+
const minerFee = inputAmount - outputSum;
75+
return {
76+
inputs,
77+
outputs,
78+
spendAmount,
79+
minerFee,
80+
virtualSize: getVirtualSize({ inputs: inputs.map((i) => i.scriptId.descriptor), outputs }),
81+
};
82+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* These are some helpers for testing satisfiability of descriptors in PSBTs.
3+
*
4+
* They are mostly a debugging aid - if an input cannot be satisified, the `finalizePsbt()` method will fail, but
5+
* the error message is pretty vague.
6+
*
7+
* The methods here have the goal of catching certain cases earlier and with a better error message.
8+
*
9+
* The goal is not an exhaustive check, but to catch common mistakes.
10+
*/
11+
import { Descriptor } from '@bitgo/wasm-miniscript';
12+
import * as utxolib from '@bitgo/utxo-lib';
13+
14+
export const FINAL_SEQUENCE = 0xffffffff;
15+
16+
/**
17+
* Get the required locktime for a descriptor.
18+
* @param descriptor
19+
*/
20+
export function getRequiredLocktime(descriptor: Descriptor | unknown): number | undefined {
21+
if (descriptor instanceof Descriptor) {
22+
return getRequiredLocktime(descriptor.node());
23+
}
24+
if (typeof descriptor !== 'object' || descriptor === null) {
25+
return undefined;
26+
}
27+
if ('Wsh' in descriptor) {
28+
return getRequiredLocktime(descriptor.Wsh);
29+
}
30+
if ('Sh' in descriptor) {
31+
return getRequiredLocktime(descriptor.Sh);
32+
}
33+
if ('Ms' in descriptor) {
34+
return getRequiredLocktime(descriptor.Ms);
35+
}
36+
if ('AndV' in descriptor) {
37+
if (!Array.isArray(descriptor.AndV)) {
38+
throw new Error('Expected an array');
39+
}
40+
if (descriptor.AndV.length !== 2) {
41+
throw new Error('Expected exactly two elements');
42+
}
43+
const [a, b] = descriptor.AndV;
44+
return getRequiredLocktime(a) ?? getRequiredLocktime(b);
45+
}
46+
if ('Drop' in descriptor) {
47+
return getRequiredLocktime(descriptor.Drop);
48+
}
49+
if ('Verify' in descriptor) {
50+
return getRequiredLocktime(descriptor.Verify);
51+
}
52+
if ('After' in descriptor && typeof descriptor.After === 'object' && descriptor.After !== null) {
53+
if ('absLockTime' in descriptor.After && typeof descriptor.After.absLockTime === 'number') {
54+
return descriptor.After.absLockTime;
55+
}
56+
}
57+
return undefined;
58+
}
59+
60+
export function assertSatisfiable(psbt: utxolib.Psbt, inputIndex: number, descriptor: Descriptor): void {
61+
// If the descriptor requires a locktime, the input must have a non-final sequence number
62+
const requiredLocktime = getRequiredLocktime(descriptor);
63+
if (requiredLocktime !== undefined) {
64+
const input = psbt.txInputs[inputIndex];
65+
if (input.sequence === FINAL_SEQUENCE) {
66+
throw new Error(`Input ${inputIndex} has a non-final sequence number, but requires a timelock`);
67+
}
68+
if (psbt.locktime !== requiredLocktime) {
69+
throw new Error(`psbt locktime (${psbt.locktime}) does not match required locktime (${requiredLocktime})`);
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)