Skip to content

Commit

Permalink
feat(utxo-lib): add bitcoin descriptor utils
Browse files Browse the repository at this point in the history
Use temporarily forked descriptor lib
Add descriptor and miniscript util functions for bitgo stack

Ticket: BTC-715
  • Loading branch information
saravanan7mani committed Dec 15, 2023
1 parent 629f35e commit dbc43aa
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 1 deletion.
2 changes: 2 additions & 0 deletions modules/utxo-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@
"dist/src"
],
"dependencies": {
"@bitcoinerlab/miniscript": "^1.4.0",
"@bitgo/blake2b": "^3.2.4",
"@brandonblack/musig": "^0.0.1-alpha.0",
"@noble/secp256k1": "1.6.3",
"@saravanan7mani/descriptors": "^2.1.1",
"bech32": "^2.0.0",
"bip174": "npm:@bitgo-forks/[email protected]",
"bip32": "^3.0.1",
Expand Down
189 changes: 189 additions & 0 deletions modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import * as assert from 'assert';
import * as desc from '@saravanan7mani/descriptors';
import * as ms from '@bitcoinerlab/miniscript';
import { ecc } from '../../noble_ecc';
import { getMainnet, Network, networks } from '../../networks';
import { filterByMasterFingerprint, getBip32PathIndexValue } from '../PsbtUtil';
import { PsbtInput } from 'bip174/src/lib/interfaces';
import { Psbt } from 'bitcoinjs-lib/src/psbt';

const { expand, Output } = desc.DescriptorsFactory(ecc);

export function isDescriptorSupported(network: Network): boolean {
return getMainnet(network) === networks.bitcoin;
}

export function assertDescriptorSupport(network: Network): void {
assert(isDescriptorSupported(network), 'Descriptors are supported only for the Bitcoin');
}

export function endsWithRangeIndex(path: string): boolean {
return /\*([hH'])?$/.test(path);
}

export function isHardenedPath(path: string): boolean {
return /['Hh]/g.test(path);
}

export function expandNonKeyLocks(descriptorOrMiniscript: string): string {
const expandConfigs: { pattern: RegExp; prefix: '#' | '$' }[] = [
{ pattern: /(?:older|after)\((\d+)\)/g, prefix: '#' },
{ pattern: /(?:sha256|hash256|ripemd160|hash160)\(([0-9a-fA-F]{64})\)/g, prefix: '$' },
];

return expandConfigs.reduce((expanded, { pattern, prefix }) => {
let counter = 0;
return expanded.replace(pattern, (match, n) => match.replace(n, `${prefix}${counter++}`));
}, descriptorOrMiniscript);
}

export function assertDescriptorKey(
keyInfo: desc.KeyInfo,
params: { allowPrivateKeys?: boolean } = { allowPrivateKeys: false }
): void {
const hasPrivateKey =
!!(keyInfo.bip32 && !keyInfo.bip32.isNeutered()) ||
!!(keyInfo.ecpair && Buffer.isBuffer(keyInfo.ecpair.privateKey));

const { allowPrivateKeys } = params;
assert(allowPrivateKeys || !hasPrivateKey, 'Descriptor with a private key is not supported');

const hasHardenedPath = keyInfo.keyPath && isHardenedPath(keyInfo.keyPath);
assert(allowPrivateKeys || !hasHardenedPath, 'Descriptor with a hardened path is not supported');

const rangeIndexCount = (keyInfo.keyPath?.match(/\*/g) || []).length;

assert(rangeIndexCount <= 1, 'Descriptor key path should have at most 1 ranged index');

assert(
rangeIndexCount === 0 || (keyInfo.keyPath && endsWithRangeIndex(keyInfo.keyPath)),
'If ranged index is used in the descriptor key path, it should be the last index'
);
}

export function assertDescriptorKeys(
keys: desc.KeyInfo[],
params: { allowPrivateKeys?: boolean } = { allowPrivateKeys: false }
): void {
const { allowPrivateKeys } = params;
keys.forEach((key) => assertDescriptorKey(key, { allowPrivateKeys }));
}

export function assertMiniscript(expandedMiniscript: string): void {
const { issane } = ms.compileMiniscript(expandedMiniscript);
assert(issane, 'Invalid miniscript');
}

export function assertDescriptor(
descriptor: string,
network: Network,
params: {
allowPrivateKeys?: boolean;
allowWithNoKey?: boolean;
allowMiniscriptInP2SH?: boolean;
allowNonMiniscript?: boolean;
checksumRequired?: boolean;
} = {
allowPrivateKeys: false,
allowWithNoKey: false,
allowMiniscriptInP2SH: false,
allowNonMiniscript: false,
checksumRequired: true,
}
): void {
assertDescriptorSupport(network);
const { allowPrivateKeys, allowWithNoKey, allowNonMiniscript, allowMiniscriptInP2SH, checksumRequired } = params;
const { expandedMiniscript, expansionMap } = expand({
descriptor,
network,
checksumRequired,
allowMiniscriptInP2SH,
});
if (expansionMap) {
assertDescriptorKeys(Object.values(expansionMap), { allowPrivateKeys });
} else {
assert(allowWithNoKey, 'Descriptor without keys is not supported');
}
if (expandedMiniscript) {
assertMiniscript(expandedMiniscript);
} else {
assert(allowNonMiniscript, 'Descriptor without miniscript is not supported');
}
}

function sanitizeHardenedMarker(path: string): string {
return path.replace(/[Hh]/g, "'");
}

function findMatchForRangePath(paths: string[], rangePath: string): string | undefined {
const sanitizedRangePath = sanitizeHardenedMarker(rangePath).slice(2);
return paths.find((path) => {
const index = getBip32PathIndexValue(path);
const rangeReplacedPath = sanitizedRangePath.replace(/[*]/g, index.toString());
const sanitizedPath = path.replace(/^m\//, '');
return rangeReplacedPath === sanitizedPath;
});
}

function findRangeIndexValue(keyInfo: desc.KeyInfo, input: PsbtInput): number | undefined {
if (!keyInfo?.bip32 || !keyInfo?.path || !input.bip32Derivation?.length) {
return undefined;
}
const derivations = filterByMasterFingerprint(
input.bip32Derivation,
keyInfo.masterFingerprint || keyInfo.bip32.fingerprint
);
const paths = derivations.map((v) => v.path);
const matchForRangePath = findMatchForRangePath(paths, keyInfo.path);
return matchForRangePath ? getBip32PathIndexValue(matchForRangePath) : undefined;
}

function findKeyWithRangeIndex(keys: desc.KeyInfo[]): desc.KeyInfo | undefined {
return keys.find((key) => key.keyPath && endsWithRangeIndex(key.keyPath));
}

export function assertPsbt(psbt: Psbt, descriptor: string, network: Network): void {
assertDescriptorSupport(network);
const { expansionMap, redeemScript, witnessScript, isRanged } = expand({
descriptor,
network,
allowMiniscriptInP2SH: true,
});

function createDescriptorOutput(index: number): desc.OutputInstance {
return new Output({ descriptor, index, allowMiniscriptInP2SH: true, network });
}

function getRangeIndexValue(input: PsbtInput) {
if (!isRanged) {
return undefined;
}
assert(expansionMap);
const keys = Object.values(expansionMap);
assertDescriptorKeys(keys, { allowPrivateKeys: true });
const key = findKeyWithRangeIndex(keys);
assert(key);
const index = findRangeIndexValue(key, input);
assert(index !== undefined);
return index;
}

function getScripts(input: PsbtInput, descOutput: desc.OutputInstance | undefined) {
return descOutput
? { redeemScript: descOutput.getRedeemScript(), witnessScript: descOutput.getWitnessScript() }
: { redeemScript, witnessScript };
}

function assertScripts(input: PsbtInput, descOutput: desc.OutputInstance | undefined) {
assert(Buffer.isBuffer(input.redeemScript) || Buffer.isBuffer(input.witnessScript));
const { redeemScript, witnessScript } = getScripts(input, descOutput);
assert(!Buffer.isBuffer(input.witnessScript) || witnessScript?.equals(input.witnessScript));
assert(!Buffer.isBuffer(input.redeemScript) || redeemScript?.equals(input.redeemScript));
}

psbt.data.inputs.forEach((input) => {
const index = getRangeIndexValue(input);
const descOutput = index === undefined ? undefined : createDescriptorOutput(index);
assertScripts(input, descOutput);
});
}
Empty file.
24 changes: 23 additions & 1 deletion modules/utxo-lib/src/bitgo/PsbtUtil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as assert from 'assert';
import { decodeProprietaryKey, ProprietaryKey } from 'bip174/src/lib/proprietaryKeyVal';
import { PsbtInput } from 'bip174/src/lib/interfaces';
import { Bip32Derivation, PsbtInput } from 'bip174/src/lib/interfaces';
import { Psbt } from 'bitcoinjs-lib/src/psbt';

/**
Expand Down Expand Up @@ -119,3 +120,24 @@ export function withUnsafeNonSegwit<T>(psbt: Psbt, fn: () => T, unsafe = true):
(psbt as any).__CACHE.__WARN_UNSAFE_SIGN_NONSEGWIT = true;
}
}

export function isValidBip32DerivationPath(path: string): boolean {
return /^(m\/)?(\d+'?\/)*\d+'?$/.test(path);
}

export function getBip32PathIndexValueStr(path: string): string {
assert(isValidBip32DerivationPath(path), `Invalid BIP32 derivation path: ${path}`);
const match = path.match(/(?:^|\/)([^\/]+)$/);
assert(match);
return match[1];
}

export function getBip32PathIndexValue(path: string): number {
const indexStr = getBip32PathIndexValueStr(path);
const isHardenedIndex = indexStr.endsWith("'");
return isHardenedIndex ? parseInt(indexStr.slice(0, -1), 10) : parseInt(indexStr, 10);
}

export function filterByMasterFingerprint(bip32Dvs: Bip32Derivation[], masterFingerprint: Buffer): Bip32Derivation[] {
return bip32Dvs.filter((bv) => masterFingerprint.equals(bv.masterFingerprint));
}
48 changes: 48 additions & 0 deletions modules/utxo-lib/test/bitgo/descriptor/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as assert from 'assert';
import { constructPsbt, getDefaultWalletKeys, Input as InputType, Output as OutputType } from '../../../src/testutil';
import { ecc, networks } from '../../../src';
import * as desc from '@saravanan7mani/descriptors';
import { assertDescriptor, assertPsbt, expandNonKeyLocks } from '../../../src/bitgo/Descriptor/Descriptor';
import { Psbt } from 'bitcoinjs-lib/src/psbt';

const { expand, Output } = desc.DescriptorsFactory(ecc);
const rootWalletKeys = getDefaultWalletKeys();
const network = networks.testnet;
const descriptor =
"wsh(andor(pk([73d83c6f/48'/1'/0'/2']tpubDEyGjD5PkjZf6rQiKWKqRi8wD8yvA43aurE8mwjE5yxjX3AJWyu7xnm94BcoxxirQKAd9AExoz7oqLiHAQL2tVC78r452bLedHyvE1GRzW8/*),older(10000),thresh(3,pk([0d78119c/48'/1'/0'/2']tpubDExqoVD2WKmN5YFBNQQZV9SU3ajPr4CLa1JBmLkNyCUv5nPDJxxAn6oqXKTUtaQxtRXrhPaGELi8hP1a5Rpjs7bNdqnfKMvVxebTtpmFtd6/*),s:pk([8f8c7811/48'/1'/0'/2']tpubDErfvxUCfe4Yd7vURSL69zSA2VtweQHvgmZAzQHNXwoY4UTcCq5F9qWfgGXtfDS3e6HZySbT8XXjmbGWkcyngqSsEBv4mYAazmETx3QfP9f/*),s:pk([a592f770/48'/1'/0'/2']tpubDE6Xb7UALq4fnjL39g6DUaf3z8MN8SeTzBMDFEhzyjYDVyn4oqY8A4K3qCUAnTCbsv8qheZK3EPNsrkE7sDnAzNcj2bXq5Rt22UESJ5wSCx/*),s:pk([4ef7afff/48'/1'/0'/2']tpubDFf5zVvuuzU285RfaHEsnsizhcgfW5Wyk6vQezW3BG1RBJXXAWdHSRzzesGfH9kQjeXgWNvFJJjZHxHPMk8xeqBj89WuuiwrfJKiVCKNMyH/*),s:pk([0d8a8cb4/48'/1'/0'/2']tpubDFQBGM1DQZyD6bWBgtgU8UQtzJvVxEmtuVfSg3cYf1R2tU6GnU4ZVUoUQ37SHGrhJyGtrZ5XG5y1Bz9ydn3yUYUkQRkvnQqq4KCcr7oaYLq/*),sln:after(840000))))";

describe('Descriptors', function () {
it(`assertPsbt`, function () {
const { expandedMiniscript } = expand({
descriptor,
network,
checksumRequired: false,
});
const outputDescs: desc.OutputInstance[] = [0, 1, 2, 3, 4].map(
(index) => new Output({ descriptor, index, network })
);
const inputs: InputType[] = [{ scriptType: 'p2wsh', value: BigInt(1e9) }];
const outputs: OutputType[] = outputDescs.map((od, i) => ({
address: od.getAddress(),
value: BigInt((i + 1) * 10000),
}));
const inputPsbt = constructPsbt(inputs, outputs, network, rootWalletKeys, 'fullsigned');
const tx = inputPsbt.finalizeAllInputs().extractTransaction();
const txHex = tx.toHex();
assert(expandedMiniscript);
const nonKeyExpanded = expandNonKeyLocks(expandedMiniscript);
assert(
nonKeyExpanded === 'andor(pk(@0),older(#0),thresh(3,pk(@1),s:pk(@2),s:pk(@3),s:pk(@4),s:pk(@5),sln:after(#1)))'
);
assertDescriptor(descriptor, network, { checksumRequired: false });

const psbt = new Psbt({ network });
outputDescs.forEach((od, i) => od.updatePsbtAsInput({ psbt, txHex, vout: i }));
new Output({
descriptor: `addr(mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt)`,
network,
}).updatePsbtAsOutput({ psbt, value: BigInt(10000) });

assertPsbt(psbt, descriptor, network);
});
});
57 changes: 57 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,25 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"

"@bitcoinerlab/miniscript@^1.2.1", "@bitcoinerlab/miniscript@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@bitcoinerlab/miniscript/-/miniscript-1.4.0.tgz#9beda21d4dadb1cb806de6f846470927cfd96f6c"
integrity sha512-BsG3dmwQmgKHnRZecDgUsPjwcpnf1wgaZbolcMTByS10k1zYzIx97W51LzG7GvokRJ+wnzTX/GhC8Y3L2X0CQA==
dependencies:
bip68 "^1.0.4"

"@bitgo-beta/secp256k1@^1.0.2-beta.217":
version "1.0.2-beta.219"
resolved "https://registry.yarnpkg.com/@bitgo-beta/secp256k1/-/secp256k1-1.0.2-beta.219.tgz#baa70b22a784c7694d005ed8706927a10a3756b9"
integrity sha512-GtTAs03mlLBBMzMjj4rXJJZ2VXITFI4bf4qOpa9W/Ls6ySeehh/auaFGL77NkALA7UfSVAYtV45NEXVd+8/Rrw==
dependencies:
"@brandonblack/musig" "^0.0.1-alpha.0"
"@noble/secp256k1" "1.6.3"
bip32 "^3.0.1"
create-hash "^1.2.0"
create-hmac "^1.1.7"
ecpair "npm:@bitgo/[email protected]"

"@bitgo/[email protected]":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@bitgo/public-types/-/public-types-1.2.1.tgz#45028dd7ba89103d3fabde295cf33e1b733cc9bc"
Expand Down Expand Up @@ -4242,11 +4261,29 @@
resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==

"@saravanan7mani/descriptors@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@saravanan7mani/descriptors/-/descriptors-2.1.1.tgz#998650874436ebd07d3921afc5879b8fbeba1811"
integrity sha512-Lj/rV7zyizvHCSgmLsjxZr4+sJxSVGAwmx3gYQ/OZgCcAC0BVsR+IEvmvIIwzwkKNyelpUZstKD3YTpofogaew==
dependencies:
"@bitcoinerlab/miniscript" "^1.2.1"
"@bitgo-beta/secp256k1" "^1.0.2-beta.217"
bip32 "^4.0.0"
bitcoinjs-lib "npm:@bitgo-forks/[email protected]"
ecpair "npm:@bitgo/[email protected]"
lodash.memoize "^4.1.2"
varuint-bitcoin "^1.1.2"

"@scure/[email protected]", "@scure/base@~1.1.0":
version "1.1.1"
resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==

"@scure/base@^1.1.1":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.3.tgz#8584115565228290a6c6c4961973e0903bb3df2f"
integrity sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==

"@scure/[email protected]":
version "1.1.5"
resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.1.5.tgz"
Expand Down Expand Up @@ -6767,6 +6804,16 @@ bip32@^3.0.1, bip32@^3.1.0:
typeforce "^1.11.5"
wif "^2.0.6"

bip32@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/bip32/-/bip32-4.0.0.tgz#7fac3c05072188d2d355a4d6596b37188f06aa2f"
integrity sha512-aOGy88DDlVUhspIXJN+dVEtclhIsfAUppD43V0j40cPTld3pv/0X/MlrZSZ6jowIaQQzFwP8M6rFU2z2mVYjDQ==
dependencies:
"@noble/hashes" "^1.2.0"
"@scure/base" "^1.1.1"
typeforce "^1.11.5"
wif "^2.0.6"

[email protected]:
version "3.0.4"
resolved "https://registry.npmjs.org/bip39/-/bip39-3.0.4.tgz"
Expand All @@ -6791,6 +6838,11 @@ bip66@^1.1.5:
dependencies:
safe-buffer "^5.0.1"

bip68@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/bip68/-/bip68-1.0.4.tgz#78a95c7a43fad183957995cc2e08d79b0c372c4d"
integrity sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==

bitcoin-ops@^1.3.0:
version "1.4.1"
resolved "https://registry.npmjs.org/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz"
Expand Down Expand Up @@ -13270,6 +13322,11 @@ lodash.ismatch@^4.4.0:
resolved "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz"
integrity sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==

lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==

lodash.memoize@~3.0.3:
version "3.0.4"
resolved "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz"
Expand Down

0 comments on commit dbc43aa

Please sign in to comment.