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 16, 2023
1 parent dbc43aa commit 687c922
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 99 deletions.
258 changes: 175 additions & 83 deletions modules/utxo-lib/src/bitgo/Descriptor/Descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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';

Expand All @@ -17,14 +17,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 assertDescriptorPlaceholders(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 @@ -37,70 +46,81 @@ export function expandNonKeyLocks(descriptorOrMiniscript: string): string {
}, descriptorOrMiniscript);
}

export function assertDescriptorKey(
keyInfo: desc.KeyInfo,
params: { allowPrivateKeys?: boolean } = { allowPrivateKeys: false }
): void {
export function assertDescriptorKey({
key,
allowPrivateKeys,
allowXpubHardenedKeyPath,
}: {
key: desc.KeyInfo;
allowPrivateKeys?: boolean;
allowXpubHardenedKeyPath?: boolean;
}): void {
const hasPrivateKey =
!!(keyInfo.bip32 && !keyInfo.bip32.isNeutered()) ||
!!(keyInfo.ecpair && Buffer.isBuffer(keyInfo.ecpair.privateKey));
!!(key.bip32 && !key.bip32.isNeutered()) || !!(key.ecpair && Buffer.isBuffer(key.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 hasHardenedKeyPath = key.keyPath && isHardenedPath(key.keyPath);
assert(
allowXpubHardenedKeyPath || hasPrivateKey || !hasHardenedKeyPath,
'Descriptor with a hardened key path for extended public key is not supported'
);

const rangeIndexCount = (keyInfo.keyPath?.match(/\*/g) || []).length;
const wildcardCount = (key.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 || (key.keyPath && endsWithWildcard(key.keyPath)),
'If wildcard 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 assertDescriptorKeys({
keys,
allowPrivateKeys,
allowXpubHardenedKeyPath,
}: {
keys: desc.KeyInfo[];
allowPrivateKeys?: boolean;
allowXpubHardenedKeyPath?: boolean;
}): void {
keys.forEach((key) => assertDescriptorKey({ key, allowPrivateKeys, allowXpubHardenedKeyPath }));
}

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 {
export function assertDescriptor({
descriptor,
network,
allowPrivateKeys,
allowXpubHardenedKeyPath,
allowWithNoKey,
allowNonMiniscript,
allowMiniscriptInP2SH,
checksumRequired = true,
}: {
descriptor: string;
network: Network;
allowPrivateKeys?: boolean;
allowXpubHardenedKeyPath?: boolean;
allowWithNoKey?: boolean;
allowMiniscriptInP2SH?: boolean;
allowNonMiniscript?: boolean;
checksumRequired?: boolean;
}): 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 });
assertDescriptorKeys({ keys: Object.values(expansionMap), allowPrivateKeys, allowXpubHardenedKeyPath });
} else {
assert(allowWithNoKey, 'Descriptor without keys is not supported');
}
Expand All @@ -115,75 +135,147 @@ 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 findKeyWithBip32WildcardPath(keys: desc.KeyInfo[]): desc.KeyInfo | undefined {
return keys.find((key) => key.keyPath && endsWithWildcard(key.keyPath));
}

function getValueForDescriptorWildcardIndex(input: PsbtInput, expansion: desc.Expansion): number | undefined {
if (!expansion.isRanged) {
return undefined;
}
assert(expansion.expansionMap, 'Missing expansionMap');
const keys = Object.values(expansion.expansionMap);
assertDescriptorKeys({ keys, allowPrivateKeys: true, allowXpubHardenedKeyPath: true });
const key = findKeyWithBip32WildcardPath(keys);
assert(key, 'Missing key with wildcard path');
const index = findValueOfWildcard(key, input);
assert(index !== undefined, 'Missing index value');
return index;
}

function findKeyWithRangeIndex(keys: desc.KeyInfo[]): desc.KeyInfo | undefined {
return keys.find((key) => key.keyPath && endsWithRangeIndex(key.keyPath));
export function createDescriptorOutput({
descriptor,
index,
network,
checksumRequired = true,
allowMiniscriptInP2SH,
}: {
descriptor: string;
network: Network;
index?: number;
checksumRequired?: boolean;
allowMiniscriptInP2SH?: boolean;
}): desc.OutputInstance {
return new Output({ descriptor, index, network, allowMiniscriptInP2SH, checksumRequired });
}

export function expandDescriptor({
descriptor,
network,
index,
checksumRequired,
allowMiniscriptInP2SH,
}: {
descriptor: string;
network: Network;
index?: number;
checksumRequired?: boolean;
allowMiniscriptInP2SH?: boolean;
}): desc.Expansion {
return expand({
descriptor,
network,
index,
checksumRequired,
allowMiniscriptInP2SH,
});
}

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

const expansion = expandDescriptor({
descriptor,
network,
allowMiniscriptInP2SH: true,
});

function createDescriptorOutput(index: number): desc.OutputInstance {
return new Output({ descriptor, index, allowMiniscriptInP2SH: true, network });
function assertScripts(input: PsbtInput, descriptorOutput: desc.OutputInstance) {
const redeemScript = descriptorOutput.getRedeemScript();
const witnessScript = descriptorOutput.getWitnessScript();
assert(!redeemScript || !!input.redeemScript?.equals(redeemScript));
assert(!witnessScript || !!input.witnessScript?.equals(witnessScript));
}

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 assertSignature(inputIndex: number, key: desc.KeyInfo, keyId: string) {
assert(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 getScripts(input: PsbtInput, descOutput: desc.OutputInstance | undefined) {
return descOutput
? { redeemScript: descOutput.getRedeemScript(), witnessScript: descOutput.getWitnessScript() }
: { redeemScript, witnessScript };
function assertSignatures(inputIndex: number, descriptorOutput: desc.OutputInstance) {
const expansionMap = descriptorOutput.expand().expansionMap;
assert(expansionMap, 'Missing expansionMap for signature check');
assert(!!sigCheckKeyIds?.length, 'sigCheckKeyIds is required for signature check');
assertDescriptorPlaceholders(sigCheckKeyIds, '@');
sigCheckKeyIds.forEach((keyId) => {
const key = expansionMap[keyId];
assert(key, `Key id ${keyId} has no match in the descriptor`);
assertSignature(inputIndex, key, keyId);
});
}

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));
function assertInput(inputIndex: number) {
const input = psbt.data.inputs[inputIndex];
const index = getValueForDescriptorWildcardIndex(input, expansion);
const descriptorOutput = createDescriptorOutput({ descriptor, index, network, checksumRequired: false });
if (scriptCheck) {
assertScripts(input, descriptorOutput);
}
if (sigCheck) {
assertSignatures(inputIndex, descriptorOutput);
}
}

psbt.data.inputs.forEach((input) => {
const index = getRangeIndexValue(input);
const descOutput = index === undefined ? undefined : createDescriptorOutput(index);
assertScripts(input, descOutput);
psbt.data.inputs.forEach((_, inputIndex) => {
assertInput(inputIndex);
});
}
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 getIndexValueStrOfBip32Path(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 = getIndexValueStrOfBip32Path(path);
const isHardenedIndex = indexStr.endsWith("'");
return isHardenedIndex ? parseInt(indexStr.slice(0, -1), 10) : parseInt(indexStr, 10);
}
Expand Down
Loading

0 comments on commit 687c922

Please sign in to comment.