Skip to content

Commit 3e51361

Browse files
Merge pull request #5413 from BitGo/BTC-1786.add-tr-support
feat(abstract-utxo): add support for taproot descriptors
2 parents 0762ae1 + 6034f43 commit 3e51361

12 files changed

+896
-68
lines changed

modules/abstract-utxo/src/core/descriptor/VirtualSize.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ function getScriptPubKeyLength(descType: string): number {
1717
case 'Pkh':
1818
return 25;
1919
case 'Wsh':
20-
// https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
20+
case 'Tr':
21+
// P2WSH: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#p2wsh
22+
// P2TR: https://github.com/bitcoin/bips/blob/58ffd93812ff25e87d53d1f202fbb389fdfb85bb/bip-0341.mediawiki#script-validation-rules
23+
// > A Taproot output is a native SegWit output (see BIP141) with version number 1, and a 32-byte witness program.
24+
// 32 bytes for the hash, 1 byte for the version, 1 byte for the push opcode
2125
return 34;
2226
case 'Bare':
2327
throw new Error('cannot determine scriptPubKey length for Bare descriptor');

modules/abstract-utxo/src/core/descriptor/psbt/findDescriptors.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function findDescriptorForDerivationIndex(
4040
function getDerivationIndexFromPath(path: string): number {
4141
const indexStr = path.split('/').pop();
4242
if (!indexStr) {
43-
throw new Error('Invalid derivation path');
43+
throw new Error(`Invalid derivation path ${path}`);
4444
}
4545
const index = parseInt(indexStr, 10);
4646
if (index.toString() !== indexStr) {
@@ -84,14 +84,21 @@ export function findDescriptorForInput(
8484
if (!script) {
8585
throw new Error('Missing script');
8686
}
87-
if (!input.bip32Derivation) {
88-
throw new Error('Missing derivation paths');
87+
if (input.bip32Derivation !== undefined) {
88+
return findDescriptorForAnyDerivationPath(
89+
script,
90+
input.bip32Derivation.map((v) => v.path),
91+
descriptorMap
92+
);
8993
}
90-
return findDescriptorForAnyDerivationPath(
91-
script,
92-
input.bip32Derivation.map((v) => v.path),
93-
descriptorMap
94-
);
94+
if (input.tapBip32Derivation !== undefined) {
95+
return findDescriptorForAnyDerivationPath(
96+
script,
97+
input.tapBip32Derivation.filter((v) => v.path !== '' && v.path !== 'm').map((v) => v.path),
98+
descriptorMap
99+
);
100+
}
101+
throw new Error('Missing derivation path');
95102
}
96103

97104
/**

modules/abstract-utxo/test/core/descriptor/descriptor.utils.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,37 @@ export function getDefaultXPubs(seed?: string): Triple<string> {
99
return getKeyTriple(seed).map((k) => k.neutered().toBase58()) as Triple<string>;
1010
}
1111

12+
export function getUnspendableKey(): string {
13+
/*
14+
https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs
15+
16+
```
17+
If one or more of the spending conditions consist of just a single key (after aggregation), the most likely one should
18+
be made the internal key. If no such condition exists, it may be worthwhile adding one that consists of an aggregation
19+
of all keys participating in all scripts combined; effectively adding an "everyone agrees" branch. If that is
20+
inacceptable, pick as internal key a "Nothing Up My Sleeve" (NUMS) point, i.e., a point with unknown discrete
21+
logarithm.
22+
23+
One example of such a point is H = lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is
24+
constructed by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X coordinate.
25+
In order to avoid leaking the information that key path spending is not possible it is recommended to pick a fresh
26+
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
27+
internal key does not have a known discrete logarithm with respect to G by revealing r to a verifier who can then
28+
reconstruct how the internal key was created.
29+
```
30+
31+
We could do the random integer trick here, but for internal testing it is sufficient to use the fixed point.
32+
*/
33+
return '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0';
34+
}
35+
1236
function toDescriptorMap(v: Record<string, string>): DescriptorMap {
1337
return new Map(Object.entries(v).map(([k, v]) => [k, Descriptor.fromString(v, 'derivable')]));
1438
}
1539

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

33-
function multi(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
58+
function multi(
59+
prefix: 'multi' | 'multi_a',
60+
m: number,
61+
n: number,
62+
keys: BIP32Interface[] | string[],
63+
path: string
64+
): string {
3465
if (n < m) {
3566
throw new Error(`Cannot create ${m} of ${n} multisig`);
3667
}
3768
if (keys.length < n) {
3869
throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`);
3970
}
4071
keys = keys.slice(0, n);
41-
return `multi(${m},${keys.map((k) => `${toXPub(k)}/${path}`).join(',')})`;
72+
return prefix + `(${m},${keys.map((k) => `${toXPub(k)}/${path}`).join(',')})`;
73+
}
74+
75+
function multiWsh(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
76+
return multi('multi', m, n, keys, path);
77+
}
78+
79+
function multiTap(m: number, n: number, keys: BIP32Interface[] | string[], path: string): string {
80+
return multi('multi_a', m, n, keys, path);
4281
}
4382

4483
export function getPsbtParams(t: DescriptorTemplate): Partial<PsbtParams> {
4584
switch (t) {
4685
case 'Wsh2Of3':
4786
case 'Wsh2Of2':
87+
case 'Tr2Of3-NoKeyPath':
4888
return {};
4989
case 'ShWsh2Of3CltvDrop':
5090
return { locktime: 1 };
@@ -58,13 +98,14 @@ export function getDescriptorString(
5898
): string {
5999
switch (template) {
60100
case 'Wsh2Of3':
61-
return `wsh(${multi(2, 3, keys, path)})`;
101+
return `wsh(${multiWsh(2, 3, keys, path)})`;
62102
case 'ShWsh2Of3CltvDrop':
63103
const { locktime } = getPsbtParams(template);
64-
return `sh(wsh(and_v(r:after(${locktime}),${multi(2, 3, keys, path)})))`;
65-
case 'Wsh2Of2': {
66-
return `wsh(${multi(2, 2, keys, path)})`;
67-
}
104+
return `sh(wsh(and_v(r:after(${locktime}),${multiWsh(2, 3, keys, path)})))`;
105+
case 'Wsh2Of2':
106+
return `wsh(${multiWsh(2, 2, keys, path)})`;
107+
case 'Tr2Of3-NoKeyPath':
108+
return `tr(${getUnspendableKey()},${multiTap(2, 3, keys, path)})`;
68109
}
69110
throw new Error(`Unknown descriptor template: ${template}`);
70111
}

modules/abstract-utxo/test/core/descriptor/psbt/VirtualSize.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import * as assert from 'assert';
1+
import assert from 'assert';
22

33
import {
44
getChangeOutputVSizesForDescriptor,
55
getInputVSizesForDescriptors,
66
getVirtualSize,
77
} from '../../../../src/core/descriptor/VirtualSize';
8-
import { getDescriptor, getDescriptorMap } from '../descriptor.utils';
8+
import { DescriptorTemplate, getDescriptor, getDescriptorMap } from '../descriptor.utils';
99

1010
describe('VirtualSize', function () {
1111
describe('getInputVSizesForDescriptorWallet', function () {
@@ -38,29 +38,35 @@ describe('VirtualSize', function () {
3838
});
3939
});
4040

41-
describe('getVirtualSize', function () {
42-
it('returns expected virtual size', function () {
43-
assert.deepStrictEqual(
44-
getVirtualSize(
45-
{
46-
inputs: [{ descriptorName: 'internal' }],
47-
outputs: [{ script: Buffer.alloc(32) }],
48-
},
49-
getDescriptorMap('Wsh2Of3')
50-
),
51-
157
52-
);
41+
function describeWithTemplate(t: DescriptorTemplate, inputSize: number, outputSize: number) {
42+
describe(`getVirtualSize ${t}`, function () {
43+
it('returns expected virtual size', function () {
44+
assert.deepStrictEqual(
45+
getVirtualSize(
46+
{
47+
inputs: [{ descriptorName: 'internal' }],
48+
outputs: [{ script: Buffer.alloc(32) }],
49+
},
50+
getDescriptorMap(t)
51+
),
52+
outputSize
53+
);
5354

54-
const descriptor = getDescriptor('Wsh2Of3');
55+
const descriptor = getDescriptor(t);
5556

56-
assert.deepStrictEqual(
57-
getVirtualSize({
58-
/* as proof we can pass 10_000 inputs */
59-
inputs: Array.from({ length: 10_000 }).map(() => descriptor),
60-
outputs: [{ script: Buffer.alloc(32) }],
61-
}),
62-
1_050_052
63-
);
57+
const nInputs = 10_000;
58+
assert.deepStrictEqual(
59+
getVirtualSize({
60+
/* as proof we can pass 10_000 inputs */
61+
inputs: Array.from({ length: nInputs }).map(() => descriptor),
62+
outputs: [],
63+
}),
64+
inputSize * nInputs + 11
65+
);
66+
});
6467
});
65-
});
68+
}
69+
70+
describeWithTemplate('Wsh2Of3', 105, 157);
71+
describeWithTemplate('Tr2Of3-NoKeyPath', 109, 161);
6672
});

modules/abstract-utxo/test/core/descriptor/psbt/createPsbt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,4 @@ function describeCreatePsbt(t: DescriptorTemplate) {
6262

6363
describeCreatePsbt('Wsh2Of3');
6464
describeCreatePsbt('ShWsh2Of3CltvDrop');
65+
describeCreatePsbt('Tr2Of3-NoKeyPath');
Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,43 @@
11
import * as assert from 'assert';
22

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

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

8-
describe('parsePsbt', function () {
9-
const descriptorA = getDescriptor('Wsh2Of3', getDefaultXPubs('a'));
10-
const descriptorB = getDescriptor('Wsh2Of3', getDefaultXPubs('b'));
11-
const descriptorMap = new Map([
12-
['a', descriptorA],
13-
['b', descriptorB],
14-
]);
8+
function describeWithTemplates(tA: DescriptorTemplate, tB: DescriptorTemplate) {
9+
describe(`parsePsbt [${tA},${tB}]`, function () {
10+
const descriptorA = getDescriptor(tA, getDefaultXPubs('a'));
11+
const descriptorB = getDescriptor(tB, getDefaultXPubs('b'));
12+
const descriptorMap = new Map([
13+
['a', descriptorA],
14+
['b', descriptorB],
15+
]);
1516

16-
it('finds descriptors for PSBT inputs/outputs', function () {
17-
const psbt = mockPsbt(
18-
[
19-
{ descriptor: descriptorA, index: 0 },
20-
{ descriptor: descriptorB, index: 1, id: { vout: 1 } },
21-
],
22-
[{ descriptor: descriptorA, index: 2, value: BigInt(1e6) }]
23-
);
17+
it('finds descriptors for PSBT inputs/outputs', function () {
18+
const psbt = mockPsbt(
19+
[
20+
{ descriptor: descriptorA, index: 0 },
21+
{ descriptor: descriptorB, index: 1, id: { vout: 1 } },
22+
],
23+
[{ descriptor: descriptorA, index: 2, value: BigInt(1e6) }]
24+
);
2425

25-
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[0], descriptorMap), {
26-
descriptor: descriptorA,
27-
index: 0,
28-
});
29-
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[1], descriptorMap), {
30-
descriptor: descriptorB,
31-
index: 1,
32-
});
33-
assert.deepStrictEqual(findDescriptorForOutput(psbt.txOutputs[0].script, psbt.data.outputs[0], descriptorMap), {
34-
descriptor: descriptorA,
35-
index: 2,
26+
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[0], descriptorMap), {
27+
descriptor: descriptorA,
28+
index: 0,
29+
});
30+
assert.deepStrictEqual(findDescriptorForInput(psbt.data.inputs[1], descriptorMap), {
31+
descriptor: descriptorB,
32+
index: 1,
33+
});
34+
assert.deepStrictEqual(findDescriptorForOutput(psbt.txOutputs[0].script, psbt.data.outputs[0], descriptorMap), {
35+
descriptor: descriptorA,
36+
index: 2,
37+
});
3638
});
3739
});
38-
});
40+
}
41+
42+
describeWithTemplates('Wsh2Of3', 'Wsh2Of3');
43+
describeWithTemplates('Wsh2Of3', 'Tr2Of3-NoKeyPath');

0 commit comments

Comments
 (0)