From 8564e6fce256c85f5230ea6adec3aec9cca17ed4 Mon Sep 17 00:00:00 2001 From: Saravanan Mani Date: Thu, 7 Dec 2023 15:29:58 +0530 Subject: [PATCH] feat(utxo-lib): add bitcoin descriptor utils Use temporarily forked descriptor lib Add descriptor and miniscript util functions for bitgo stack Ticket: BTC-715 --- modules/utxo-lib/package.json | 2 + .../src/bitgo/Descriptor/Descriptor.ts | 182 ++++++++++++++++++ .../src/bitgo/Descriptor/Miniscript.ts | 0 modules/utxo-lib/src/bitgo/PsbtUtil.ts | 20 ++ .../test/bitgo/descriptor/descriptor.ts | 0 yarn.lock | 50 +++++ 6 files changed, 254 insertions(+) create mode 100644 modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts create mode 100644 modules/utxo-lib/src/bitgo/Descriptor/Miniscript.ts create mode 100644 modules/utxo-lib/test/bitgo/descriptor/descriptor.ts diff --git a/modules/utxo-lib/package.json b/modules/utxo-lib/package.json index 606c4237cc..ddb90c40b9 100644 --- a/modules/utxo-lib/package.json +++ b/modules/utxo-lib/package.json @@ -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/bip174@3.1.0-master.4", "bip32": "^3.0.1", diff --git a/modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts b/modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts new file mode 100644 index 0000000000..0822dbe3a0 --- /dev/null +++ b/modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts @@ -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)); + }); +} diff --git a/modules/utxo-lib/src/bitgo/Descriptor/Miniscript.ts b/modules/utxo-lib/src/bitgo/Descriptor/Miniscript.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/utxo-lib/src/bitgo/PsbtUtil.ts b/modules/utxo-lib/src/bitgo/PsbtUtil.ts index 61bf78913d..4e053c1cd0 100644 --- a/modules/utxo-lib/src/bitgo/PsbtUtil.ts +++ b/modules/utxo-lib/src/bitgo/PsbtUtil.ts @@ -119,3 +119,23 @@ export function withUnsafeNonSegwit(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); +} diff --git a/modules/utxo-lib/test/bitgo/descriptor/descriptor.ts b/modules/utxo-lib/test/bitgo/descriptor/descriptor.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/yarn.lock b/yarn.lock index 0de27bf99f..5af2ea92a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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/ecpair@2.1.0-rc.0" + "@bitgo/public-types@1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@bitgo/public-types/-/public-types-1.2.1.tgz#45028dd7ba89103d3fabde295cf33e1b733cc9bc" @@ -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/bitcoinjs-lib@7.1.0-master.6" + ecpair "npm:@bitgo/ecpair@2.1.0-rc.0" + "@scure/base@1.1.1", "@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/bip32@1.1.5": version "1.1.5" resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.1.5.tgz" @@ -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" + bip39@3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/bip39/-/bip39-3.0.4.tgz" @@ -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"