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 dbc43aa commit af40a5d
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 56 deletions.
139 changes: 96 additions & 43 deletions modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ 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 { filterByMasterFingerprint, getIndexValueOfBip32Path } from '../PsbtUtil';
import { PsbtInput } from 'bip174/src/lib/interfaces';
import { Psbt } from 'bitcoinjs-lib/src/psbt';

export type ExpansionMap = {
// key will have this format: @i, where i is an integer
[key: string]: desc.KeyInfo;
};

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

export function isDescriptorSupported(network: Network): boolean {
Expand All @@ -17,14 +22,23 @@ export function assertDescriptorSupport(network: Network): void {
assert(isDescriptorSupported(network), 'Descriptors are supported only for the Bitcoin');
}

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

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

export function assertExpandedPlaceholders(placeholders: string[], prefix: '@' | '#' | '$'): void {
const re = { '@': /^@(\d|[1-9]\d+)$/, '#': /^#(\d|[1-9]\d+)$/, $: /^\$(\d|[1-9]\d+)$/ };

const distinctPlaceholders = [...new Set(placeholders)];
distinctPlaceholders.forEach((ph) =>
assert(re[prefix].test(ph), `${ph} does not match the expanded placeholder format`)
);
}

export function expandNonKeyLocks(descriptorOrMiniscript: string): string {
const expandConfigs: { pattern: RegExp; prefix: '#' | '$' }[] = [
{ pattern: /(?:older|after)\((\d+)\)/g, prefix: '#' },
Expand All @@ -51,13 +65,13 @@ export function assertDescriptorKey(
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;
const wildcardCount = (keyInfo.keyPath?.match(/\*/g) || []).length;

assert(rangeIndexCount <= 1, 'Descriptor key path should have at most 1 ranged index');
assert(wildcardCount <= 1, 'Descriptor key path should have at most 1 wildcard 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'
wildcardCount === 0 || (keyInfo.keyPath && endsWithWildcard(keyInfo.keyPath)),
'If wildcard index is used in the descriptor key path, it should be the last index'
);
}

Expand Down Expand Up @@ -115,75 +129,114 @@ function sanitizeHardenedMarker(path: string): string {
return path.replace(/[Hh]/g, "'");
}

function findMatchForRangePath(paths: string[], rangePath: string): string | undefined {
const sanitizedRangePath = sanitizeHardenedMarker(rangePath).slice(2);
function findWildcardPathMatch(paths: string[], wildcardPath: string): string | undefined {
const sanitizedWildcardPath = sanitizeHardenedMarker(wildcardPath).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;
const index = getIndexValueOfBip32Path(path);
const sanitizedPath = sanitizedWildcardPath.replace(/[*]/g, index.toString());
const pathToCompare = path.replace(/^m\//, '');
return sanitizedPath === pathToCompare;
});
}

function findRangeIndexValue(keyInfo: desc.KeyInfo, input: PsbtInput): number | undefined {
if (!keyInfo?.bip32 || !keyInfo?.path || !input.bip32Derivation?.length) {
return undefined;
}
function findValueOfWildcard(keyInfo: desc.KeyInfo, input: PsbtInput): number | undefined {
assert(
keyInfo?.bip32 && keyInfo?.path && input.bip32Derivation?.length,
'Missing required data to find wildcard value'
);
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;
const path = findWildcardPathMatch(paths, keyInfo.path);
return path ? getIndexValueOfBip32Path(path) : undefined;
}

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

export function assertPsbt(psbt: Psbt, descriptor: string, network: Network): void {
export function assertPsbt(
psbt: Psbt,
descriptor: string,
network: Network,
params: {
scriptCheck?: boolean;
sigCheck?: boolean;
sigCheckKeyIds?: string[];
} = { scriptCheck: true }
): void {
assertDescriptorSupport(network);
const { expansionMap, redeemScript, witnessScript, isRanged } = expand({

const expansionWithoutIndex = expand({
descriptor,
network,
allowMiniscriptInP2SH: true,
});

function createDescriptorOutput(index: number): desc.OutputInstance {
return new Output({ descriptor, index, allowMiniscriptInP2SH: true, network });
}
const { scriptCheck, sigCheck, sigCheckKeyIds } = params;

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

function getScripts(input: PsbtInput, descOutput: desc.OutputInstance | undefined) {
return descOutput
? { redeemScript: descOutput.getRedeemScript(), witnessScript: descOutput.getWitnessScript() }
: { redeemScript, witnessScript };
function createDescriptorOutput(index: number) {
return new Output({ descriptor, index, allowMiniscriptInP2SH: true, network });
}

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

function assertScripts(input: PsbtInput, descOutput: desc.OutputInstance | undefined) {
function assertScripts(input: PsbtInput, { outputWithIndex }: { outputWithIndex?: desc.OutputInstance }) {
assert(Buffer.isBuffer(input.redeemScript) || Buffer.isBuffer(input.witnessScript));
const { redeemScript, witnessScript } = getScripts(input, descOutput);
const { redeemScript, witnessScript } = getScripts(input, outputWithIndex);
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);
function assertSignature(inputIndex: number, key: desc.KeyInfo, keyId: string) {
assert(Buffer.isBuffer(key.pubkey), `Missing pubkey for key ${keyId}`);
const isValid = psbt.validateSignaturesOfInput(inputIndex, (p, m, s) => ecc.verify(m, p, s, true), key.pubkey);
assert(isValid, `Key ${keyId} has no valid signature`);
}

function assertSignatures(inputIndex: number, { expansionMap }: { expansionMap: ExpansionMap }) {
assert(!!sigCheckKeyIds?.length, 'sigCheckKeyIds is required for signature check');
assertExpandedPlaceholders(sigCheckKeyIds, '@');
sigCheckKeyIds.forEach((keyId) => {
const key = expansionMap[keyId];
assert(key, `Key id ${keyId} has no match in the descriptor`);
assertSignature(inputIndex, key, keyId);
});
}

psbt.data.inputs.forEach((input, inputIndex) => {
const index = getValueForDescriptorWildcardIndex(input);
const outputWithIndex = index !== undefined ? createDescriptorOutput(index) : undefined;
const expansionMap = outputWithIndex?.expand().expansionMap || expansionWithoutIndex.expansionMap;
if (scriptCheck) {
assertScripts(input, { outputWithIndex });
}
if (sigCheck) {
assert(expansionMap, 'Missing expansionMap for signature check');
assertSignatures(inputIndex, { expansionMap });
}
});
}
6 changes: 3 additions & 3 deletions modules/utxo-lib/src/bitgo/PsbtUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,15 @@ export function isValidBip32DerivationPath(path: string): boolean {
return /^(m\/)?(\d+'?\/)*\d+'?$/.test(path);
}

export function getBip32PathIndexValueStr(path: string): string {
export function getStrIndexValueOfBip32Path(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);
export function getIndexValueOfBip32Path(path: string): number {
const indexStr = getStrIndexValueOfBip32Path(path);
const isHardenedIndex = indexStr.endsWith("'");
return isHardenedIndex ? parseInt(indexStr.slice(0, -1), 10) : parseInt(indexStr, 10);
}
Expand Down
32 changes: 22 additions & 10 deletions modules/utxo-lib/test/bitgo/descriptor/descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,40 @@ 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 {
assertDescriptor,
assertExpandedPlaceholders,
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 () {
it(`dummy`, function () {
assertExpandedPlaceholders(['$10', '$0'], '$');
});
it(`success`, function () {
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))))";

assertDescriptor(descriptor, network, { checksumRequired: false });

const { expandedMiniscript } = expand({
descriptor,
network,
checksumRequired: false,
});
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)))'
);

const outputDescs: desc.OutputInstance[] = [0, 1, 2, 3, 4].map(
(index) => new Output({ descriptor, index, network })
);
Expand All @@ -29,12 +47,6 @@ describe('Descriptors', function () {
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 }));
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6814,6 +6814,16 @@ bip32@^4.0.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 Down

0 comments on commit af40a5d

Please sign in to comment.