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 13, 2023
1 parent f9e2358 commit 8564e6f
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 0 deletions.
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.0.5",
"bech32": "^2.0.0",
"bip174": "npm:@bitgo-forks/[email protected]",
"bip32": "^3.0.1",
Expand Down
182 changes: 182 additions & 0 deletions modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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 { Psbt } from 'bitcoinjs-lib/src/psbt';
import { getBip32DerivationPathIndex } from '../PsbtUtil';
import { Bip32Derivation, PsbtInput } from 'bip174/src/lib/interfaces';

const { expand, parseKeyExpression, Output } = desc.DescriptorsFactory(ecc);
export const descriptors = { expand, parseKeyExpression, Output, ...desc };
export const miniscripts = ms;

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 assertDescriptorKey(
keyInfo: desc.KeyInfo,
{ allowPrivateKeys = false, keyId }: { allowPrivateKeys?: boolean; keyId?: string }
): void {
const hasPrivateKey =
!!(keyInfo.bip32 && !keyInfo.bip32.isNeutered()) ||
!!(keyInfo.ecpair && Buffer.isBuffer(keyInfo.ecpair.privateKey));

assert(allowPrivateKeys || !hasPrivateKey, `Descriptor with a private key is not supported: ${keyId}`);

const hasHardenedPath = keyInfo.keyPath && isHardenedPath(keyInfo.keyPath);
assert(allowPrivateKeys || !hasHardenedPath, `Descriptor with a hardened path is not supported: ${keyId}`);

const rangedIndexCount = (keyInfo.keyPath?.match(/\*/g) || []).length;
assert(rangedIndexCount <= 1, `Descriptor key path should have at most 1 ranged index: ${keyId}`);
assert(
rangedIndexCount === 0 || (keyInfo.keyPath && endsWithRangeIndex(keyInfo.keyPath)),
`If ranged index is used in the descriptor key path, it should be the last index: ${keyId}`
);
}

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

export function assertDescriptor(
descriptor: string,
network: Network,
{
allowPrivateKeys = false,
allowWithNoKey = false,
allowMiniscriptInP2SH = false,
allowNonMiniscript = false,
checksumRequired = true,
}: {
allowPrivateKeys?: boolean;
allowWithNoKey?: boolean;
allowMiniscriptInP2SH?: boolean;
allowNonMiniscript?: boolean;
checksumRequired?: boolean;
}
): void {
assertDescriptorSupport(network);

const { expandedMiniscript, expansionMap } = expand({
descriptor,
network,
checksumRequired,
allowMiniscriptInP2SH,
});

if (expansionMap) {
Object.keys(expansionMap).forEach((keyId) => assertDescriptorKey(expansionMap[keyId], { allowPrivateKeys, keyId }));
} else {
assert(allowWithNoKey, 'Descriptor without keys is not supported');
}

if (expandedMiniscript) {
assertMiniscript(expandedMiniscript);
} else {
assert(allowNonMiniscript, 'Descriptor without miniscript is not supported');
}
}

export function expandHashAndTimeLocks(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);
}

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

function findPathWithMatch(paths: string[], rangedPath: string): string | undefined {
const parsedRangedPath = sanitizeHardenedMarker(rangedPath).slice(1);
return paths.find((path) => {
const index = getBip32DerivationPathIndex(path);
const nonRangedPath = parsedRangedPath.replace(/[/*]/g, index.toString());
const pathWithoutMasterPrefix = path.replace(/^m\//, '');
return nonRangedPath === pathWithoutMasterPrefix;
});
}

function filterByMasterFingerprint(bip32Dvs: Bip32Derivation[], masterFingerprint: Buffer): Bip32Derivation[] {
return bip32Dvs?.filter((bv) => masterFingerprint.equals(bv.masterFingerprint));
}

function getRangedIndexValue(input: PsbtInput, keyInfo: desc.KeyInfo): number {
assert(!!input.bip32Derivation?.length);
assert(!!keyInfo?.bip32);

const matchingDerivations = filterByMasterFingerprint(input.bip32Derivation, keyInfo.bip32.fingerprint);

assert(!!matchingDerivations?.length);
assert(!!keyInfo?.keyPath);

const paths = matchingDerivations.map((v) => v.path);
const pathWithIndex = findPathWithMatch(paths, keyInfo.keyPath);
assert(pathWithIndex);
return getBip32DerivationPathIndex(pathWithIndex);
}

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

function getKeyWithRangeIndex() {
assert(expansionMap);
Object.keys(expansionMap).forEach((keyId) =>
assertDescriptorKey(expansionMap[keyId], { allowPrivateKeys: true, keyId })
);
return Object.values(expansionMap).find((keyInfo) => {
return keyInfo.keyPath && endsWithRangeIndex(keyInfo.keyPath);
});
}

const keyInfoWithRangeIndex = isRanged ? getKeyWithRangeIndex() : undefined;

function generateScriptsForIndex(index) {
const output = new Output({ descriptor, network, index });
const redeemScript = output.getRedeemScript();
const witnessScript = output.getWitnessScript();
return { redeemScript, witnessScript };
}

function getScripts(input: PsbtInput) {
return keyInfoWithRangeIndex
? generateScriptsForIndex(getRangedIndexValue(input, keyInfoWithRangeIndex))
: { redeemScript, witnessScript };
}

psbt.data.inputs.forEach((input) => {
const { redeemScript, witnessScript } = getScripts(input);

assert(Buffer.isBuffer(redeemScript) || Buffer.isBuffer(witnessScript));
assert(Buffer.isBuffer(input.redeemScript) || Buffer.isBuffer(input.witnessScript));

assert(!Buffer.isBuffer(redeemScript) || !!input.redeemScript?.equals(redeemScript));
assert(!Buffer.isBuffer(witnessScript) || !!input.witnessScript?.equals(witnessScript));
});
}
Empty file.
20 changes: 20 additions & 0 deletions modules/utxo-lib/src/bitgo/PsbtUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,23 @@ 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 getBip32DerivationPathIndex(path: string, allowHardenedIndex = false): number {
if (!isValidBip32DerivationPath(path)) {
throw new Error(`Invalid BIP32 derivation path: ${path}`);
}

const pathComponents = path.split('/');
const indexStr = pathComponents[pathComponents.length - 1];
const isHardenedIndex = indexStr.endsWith("'");

if (!allowHardenedIndex && isHardenedIndex) {
throw new Error(`Hardened index is not allowed in BIP32 derivation path: ${path}`);
}

return isHardenedIndex ? parseInt(indexStr.slice(0, -1), 10) : parseInt(indexStr, 10);
}
Empty file.
50 changes: 50 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,27 @@
resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==

"@saravanan7mani/descriptors@^2.0.5":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@saravanan7mani/descriptors/-/descriptors-2.0.5.tgz#e0f95d78a4e03b1710403101b1955b219a612cfb"
integrity sha512-nLlkdgO24dV0ppzIdzCUAUbOWUDsSyV7lJmnoFJQraTOkp0bQp6q0WMtyyAUR34G2hEOYfSCxM0FQjezl7wjcg==
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]"

"@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 +6802,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 +6836,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

0 comments on commit 8564e6f

Please sign in to comment.