Skip to content

Commit

Permalink
Merge pull request #5413 from BitGo/BTC-1786.add-tr-support
Browse files Browse the repository at this point in the history
feat(abstract-utxo): add support for taproot descriptors
  • Loading branch information
OttoAllmendinger authored Jan 24, 2025
2 parents 0762ae1 + 6034f43 commit 3e51361
Show file tree
Hide file tree
Showing 12 changed files with 896 additions and 68 deletions.
6 changes: 5 additions & 1 deletion modules/abstract-utxo/src/core/descriptor/VirtualSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ function getScriptPubKeyLength(descType: string): number {
case 'Pkh':
return 25;
case 'Wsh':
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
case 'Tr':
// P2WSH: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
// P2TR: https://github.com/bitcoin/bips/blob/58ffd93812ff25e87d53d1f202fbb389fdfb85bb/bip-0341.mediawiki#script-validation-rules
// > A Taproot output is a native SegWit output (see BIP141) with version number 1, and a 32-byte witness program.
// 32 bytes for the hash, 1 byte for the version, 1 byte for the push opcode
return 34;
case 'Bare':
throw new Error('cannot determine scriptPubKey length for Bare descriptor');
Expand Down
23 changes: 15 additions & 8 deletions modules/abstract-utxo/src/core/descriptor/psbt/findDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function findDescriptorForDerivationIndex(
function getDerivationIndexFromPath(path: string): number {
const indexStr = path.split('/').pop();
if (!indexStr) {
throw new Error('Invalid derivation path');
throw new Error(`Invalid derivation path ${path}`);
}
const index = parseInt(indexStr, 10);
if (index.toString() !== indexStr) {
Expand Down Expand Up @@ -84,14 +84,21 @@ export function findDescriptorForInput(
if (!script) {
throw new Error('Missing script');
}
if (!input.bip32Derivation) {
throw new Error('Missing derivation paths');
if (input.bip32Derivation !== undefined) {
return findDescriptorForAnyDerivationPath(
script,
input.bip32Derivation.map((v) => v.path),
descriptorMap
);
}
return findDescriptorForAnyDerivationPath(
script,
input.bip32Derivation.map((v) => v.path),
descriptorMap
);
if (input.tapBip32Derivation !== undefined) {
return findDescriptorForAnyDerivationPath(
script,
input.tapBip32Derivation.filter((v) => v.path !== '' && v.path !== 'm').map((v) => v.path),
descriptorMap
);
}
throw new Error('Missing derivation path');
}

/**
Expand Down
55 changes: 48 additions & 7 deletions modules/abstract-utxo/test/core/descriptor/descriptor.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,37 @@ export function getDefaultXPubs(seed?: string): Triple<string> {
return getKeyTriple(seed).map((k) => k.neutered().toBase58()) as Triple<string>;
}

export function getUnspendableKey(): string {
/*
https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs
```
If one or more of the spending conditions consist of just a single key (after aggregation), the most likely one should
be made the internal key. If no such condition exists, it may be worthwhile adding one that consists of an aggregation
of all keys participating in all scripts combined; effectively adding an "everyone agrees" branch. If that is
inacceptable, pick as internal key a "Nothing Up My Sleeve" (NUMS) point, i.e., a point with unknown discrete
logarithm.
One example of such a point is H = lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is
constructed by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X coordinate.
In order to avoid leaking the information that key path spending is not possible it is recommended to pick a fresh
integer r in the range 0...n-1 uniformly at random and use H + rG as internal key. It is possible to prove that this
internal key does not have a known discrete logarithm with respect to G by revealing r to a verifier who can then
reconstruct how the internal key was created.
```
We could do the random integer trick here, but for internal testing it is sufficient to use the fixed point.
*/
return '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0';
}

function toDescriptorMap(v: Record<string, string>): DescriptorMap {
return new Map(Object.entries(v).map(([k, v]) => [k, Descriptor.fromString(v, 'derivable')]));
}

export type DescriptorTemplate =
| 'Wsh2Of3'
| 'Tr2Of3-NoKeyPath'
| 'Wsh2Of2'
/*
* This is a wrapped segwit 2of3 multisig that also uses a relative locktime with
Expand All @@ -30,21 +55,36 @@ function toXPub(k: BIP32Interface | string): string {
return k.neutered().toBase58();
}

function multi(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
function multi(
prefix: 'multi' | 'multi_a',
m: number,
n: number,
keys: BIP32Interface[] | string[],
path: string
): string {
if (n < m) {
throw new Error(`Cannot create ${m} of ${n} multisig`);
}
if (keys.length < n) {
throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`);
}
keys = keys.slice(0, n);
return `multi(${m},${keys.map((k) => `${toXPub(k)}/${path}`).join(',')})`;
return prefix + `(${m},${keys.map((k) => `${toXPub(k)}/${path}`).join(',')})`;
}

function multiWsh(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
return multi('multi', m, n, keys, path);
}

function multiTap(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
return multi('multi_a', m, n, keys, path);
}

export function getPsbtParams(t: DescriptorTemplate): Partial<PsbtParams> {
switch (t) {
case 'Wsh2Of3':
case 'Wsh2Of2':
case 'Tr2Of3-NoKeyPath':
return {};
case 'ShWsh2Of3CltvDrop':
return { locktime: 1 };
Expand All @@ -58,13 +98,14 @@ export function getDescriptorString(
): string {
switch (template) {
case 'Wsh2Of3':
return `wsh(${multi(2, 3, keys, path)})`;
return `wsh(${multiWsh(2, 3, keys, path)})`;
case 'ShWsh2Of3CltvDrop':
const { locktime } = getPsbtParams(template);
return `sh(wsh(and_v(r:after(${locktime}),${multi(2, 3, keys, path)})))`;
case 'Wsh2Of2': {
return `wsh(${multi(2, 2, keys, path)})`;
}
return `sh(wsh(and_v(r:after(${locktime}),${multiWsh(2, 3, keys, path)})))`;
case 'Wsh2Of2':
return `wsh(${multiWsh(2, 2, keys, path)})`;
case 'Tr2Of3-NoKeyPath':
return `tr(${getUnspendableKey()},${multiTap(2, 3, keys, path)})`;
}
throw new Error(`Unknown descriptor template: ${template}`);
}
Expand Down
54 changes: 30 additions & 24 deletions modules/abstract-utxo/test/core/descriptor/psbt/VirtualSize.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as assert from 'assert';
import assert from 'assert';

import {
getChangeOutputVSizesForDescriptor,
getInputVSizesForDescriptors,
getVirtualSize,
} from '../../../../src/core/descriptor/VirtualSize';
import { getDescriptor, getDescriptorMap } from '../descriptor.utils';
import { DescriptorTemplate, getDescriptor, getDescriptorMap } from '../descriptor.utils';

describe('VirtualSize', function () {
describe('getInputVSizesForDescriptorWallet', function () {
Expand Down Expand Up @@ -38,29 +38,35 @@ describe('VirtualSize', function () {
});
});

describe('getVirtualSize', function () {
it('returns expected virtual size', function () {
assert.deepStrictEqual(
getVirtualSize(
{
inputs: [{ descriptorName: 'internal' }],
outputs: [{ script: Buffer.alloc(32) }],
},
getDescriptorMap('Wsh2Of3')
),
157
);
function describeWithTemplate(t: DescriptorTemplate, inputSize: number, outputSize: number) {
describe(`getVirtualSize ${t}`, function () {
it('returns expected virtual size', function () {
assert.deepStrictEqual(
getVirtualSize(
{
inputs: [{ descriptorName: 'internal' }],
outputs: [{ script: Buffer.alloc(32) }],
},
getDescriptorMap(t)
),
outputSize
);

const descriptor = getDescriptor('Wsh2Of3');
const descriptor = getDescriptor(t);

assert.deepStrictEqual(
getVirtualSize({
/* as proof we can pass 10_000 inputs */
inputs: Array.from({ length: 10_000 }).map(() => descriptor),
outputs: [{ script: Buffer.alloc(32) }],
}),
1_050_052
);
const nInputs = 10_000;
assert.deepStrictEqual(
getVirtualSize({
/* as proof we can pass 10_000 inputs */
inputs: Array.from({ length: nInputs }).map(() => descriptor),
outputs: [],
}),
inputSize * nInputs + 11
);
});
});
});
}

describeWithTemplate('Wsh2Of3', 105, 157);
describeWithTemplate('Tr2Of3-NoKeyPath', 109, 161);
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ function describeCreatePsbt(t: DescriptorTemplate) {

describeCreatePsbt('Wsh2Of3');
describeCreatePsbt('ShWsh2Of3CltvDrop');
describeCreatePsbt('Tr2Of3-NoKeyPath');
61 changes: 33 additions & 28 deletions modules/abstract-utxo/test/core/descriptor/psbt/findDescriptors.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
import * as assert from 'assert';

import { getDefaultXPubs, getDescriptor } from '../descriptor.utils';
import { DescriptorTemplate, getDefaultXPubs, getDescriptor } from '../descriptor.utils';
import { findDescriptorForInput, findDescriptorForOutput } from '../../../../src/core/descriptor/psbt/findDescriptors';

import { mockPsbt } from './mock.utils';

describe('parsePsbt', function () {
const descriptorA = getDescriptor('Wsh2Of3', getDefaultXPubs('a'));
const descriptorB = getDescriptor('Wsh2Of3', getDefaultXPubs('b'));
const descriptorMap = new Map([
['a', descriptorA],
['b', descriptorB],
]);
function describeWithTemplates(tA: DescriptorTemplate, tB: DescriptorTemplate) {
describe(`parsePsbt [${tA},${tB}]`, function () {
const descriptorA = getDescriptor(tA, getDefaultXPubs('a'));
const descriptorB = getDescriptor(tB, getDefaultXPubs('b'));
const descriptorMap = new Map([
['a', descriptorA],
['b', descriptorB],
]);

it('finds descriptors for PSBT inputs/outputs', function () {
const psbt = mockPsbt(
[
{ descriptor: descriptorA, index: 0 },
{ descriptor: descriptorB, index: 1, id: { vout: 1 } },
],
[{ descriptor: descriptorA, index: 2, value: BigInt(1e6) }]
);
it('finds descriptors for PSBT inputs/outputs', function () {
const psbt = mockPsbt(
[
{ descriptor: descriptorA, index: 0 },
{ descriptor: descriptorB, index: 1, id: { vout: 1 } },
],
[{ descriptor: descriptorA, index: 2, value: BigInt(1e6) }]
);

assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[0], descriptorMap), {
descriptor: descriptorA,
index: 0,
});
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[1], descriptorMap), {
descriptor: descriptorB,
index: 1,
});
assert.deepStrictEqual(findDescriptorForOutput(psbt.txOutputs[0].script, psbt.data.outputs[0], descriptorMap), {
descriptor: descriptorA,
index: 2,
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[0], descriptorMap), {
descriptor: descriptorA,
index: 0,
});
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[1], descriptorMap), {
descriptor: descriptorB,
index: 1,
});
assert.deepStrictEqual(findDescriptorForOutput(psbt.txOutputs[0].script, psbt.data.outputs[0], descriptorMap), {
descriptor: descriptorA,
index: 2,
});
});
});
});
}

describeWithTemplates('Wsh2Of3', 'Wsh2Of3');
describeWithTemplates('Wsh2Of3', 'Tr2Of3-NoKeyPath');
Loading

0 comments on commit 3e51361

Please sign in to comment.