Skip to content

Commit b70bcb3

Browse files
Merge pull request #5326 from BitGo/BTC-1708.add-helper-createDescriptorWallet
feat(abstract-utxo): add helper for descriptor wallet creation
2 parents 468c250 + 079d9b9 commit b70bcb3

File tree

11 files changed

+237
-15
lines changed

11 files changed

+237
-15
lines changed
Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,41 @@
11
import * as t from 'io-ts';
2+
import { Descriptor } from '@bitgo/wasm-miniscript';
3+
import { BIP32Interface, networks } from '@bitgo/utxo-lib';
4+
import { signMessage, verifyMessage } from '@bitgo/sdk-core';
25

3-
export const NamedDescriptor = t.type({
4-
name: t.string,
5-
value: t.string,
6-
});
6+
export const NamedDescriptor = t.intersection(
7+
[
8+
t.type({
9+
name: t.string,
10+
value: t.string,
11+
}),
12+
t.partial({
13+
signatures: t.union([t.array(t.string), t.undefined]),
14+
}),
15+
],
16+
'NamedDescriptor'
17+
);
718

819
export type NamedDescriptor = t.TypeOf<typeof NamedDescriptor>;
20+
21+
export function createNamedDescriptorWithSignature(
22+
name: string,
23+
descriptor: Descriptor,
24+
signingKey: BIP32Interface
25+
): NamedDescriptor {
26+
const value = descriptor.toString();
27+
const signature = signMessage(value, signingKey, networks.bitcoin).toString('hex');
28+
return { name, value, signatures: [signature] };
29+
}
30+
31+
export function assertHasValidSignature(namedDescriptor: NamedDescriptor, key: BIP32Interface): void {
32+
if (namedDescriptor.signatures === undefined) {
33+
throw new Error(`Descriptor ${namedDescriptor.name} does not have a signature`);
34+
}
35+
const isValid = namedDescriptor.signatures.some((signature) => {
36+
return verifyMessage(namedDescriptor.value, key, Buffer.from(signature, 'hex'), networks.bitcoin);
37+
});
38+
if (!isValid) {
39+
throw new Error(`Descriptor ${namedDescriptor.name} does not have a valid signature (key=${key.toBase58()})`);
40+
}
41+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { BitGoAPI } from '@bitgo/sdk-api';
2+
import * as utxolib from '@bitgo/utxo-lib';
3+
import { Wallet } from '@bitgo/sdk-core';
4+
5+
import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
6+
import { IDescriptorWallet } from '../descriptorWallet';
7+
import { NamedDescriptor } from '../NamedDescriptor';
8+
9+
import { DescriptorFromKeys } from './createDescriptors';
10+
11+
export async function createDescriptorWallet(
12+
bitgo: BitGoAPI,
13+
coin: AbstractUtxoCoin,
14+
{
15+
descriptors,
16+
...params
17+
}: {
18+
type: 'hot';
19+
label: string;
20+
enterprise: string;
21+
keys: string[];
22+
descriptors: NamedDescriptor[];
23+
}
24+
): Promise<IDescriptorWallet> {
25+
// We don't use `coin.wallets().add` here because it does a bunch of validation that does not make sense
26+
// for descriptor wallets.
27+
const newWallet = await bitgo
28+
.post(coin.url('/wallet/add'))
29+
.send({
30+
...params,
31+
coinSpecific: { descriptors },
32+
})
33+
.result();
34+
return new Wallet(bitgo, coin, newWallet) as IDescriptorWallet;
35+
}
36+
37+
export async function createDescriptorWalletWithWalletPassphrase(
38+
bitgo: BitGoAPI,
39+
coin: AbstractUtxoCoin,
40+
{
41+
enterprise,
42+
walletPassphrase,
43+
descriptorsFromKeys,
44+
...params
45+
}: {
46+
label: string;
47+
enterprise: string;
48+
walletPassphrase: string;
49+
descriptorsFromKeys: DescriptorFromKeys;
50+
[key: string]: unknown;
51+
}
52+
): Promise<IDescriptorWallet> {
53+
const userKeychain = await coin.keychains().createUserKeychain(walletPassphrase);
54+
const backupKeychain = await coin.keychains().createBackup();
55+
const bitgoKeychain = await coin.keychains().createBitGo({ enterprise });
56+
if (!userKeychain.prv) {
57+
throw new Error('Missing private key');
58+
}
59+
const userKey = utxolib.bip32.fromBase58(userKeychain.prv);
60+
const cosigners = [backupKeychain, bitgoKeychain].map((keychain) => {
61+
if (!keychain.pub) {
62+
throw new Error('Missing public key');
63+
}
64+
return utxolib.bip32.fromBase58(keychain.pub);
65+
});
66+
return createDescriptorWallet(bitgo, coin, {
67+
...params,
68+
type: 'hot',
69+
enterprise,
70+
keys: [userKeychain.id, backupKeychain.id, bitgoKeychain.id],
71+
descriptors: descriptorsFromKeys(userKey, cosigners),
72+
});
73+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { BIP32Interface } from '@bitgo/utxo-lib';
2+
3+
import { createNamedDescriptorWithSignature, NamedDescriptor } from '../NamedDescriptor';
4+
import { getDescriptorFromBuilder, DescriptorBuilder } from '../builder';
5+
6+
export type DescriptorFromKeys = (userKey: BIP32Interface, cosigners: BIP32Interface[]) => NamedDescriptor[];
7+
8+
/**
9+
* Create a pair of external and internal descriptors for a 2-of-3 multisig wallet.
10+
* Overrides the path of the builder to use the external and internal derivation paths (0/* and 1/*).
11+
*
12+
* @param builder
13+
* @param userKey
14+
*/
15+
function createExternalInternalPair(
16+
builder: DescriptorBuilder,
17+
userKey: BIP32Interface
18+
): [NamedDescriptor, NamedDescriptor] {
19+
if (userKey.isNeutered()) {
20+
throw new Error('User key must be private');
21+
}
22+
return [
23+
createNamedDescriptorWithSignature(
24+
builder.name + '/external',
25+
getDescriptorFromBuilder({ ...builder, path: '0/*' }),
26+
userKey
27+
),
28+
createNamedDescriptorWithSignature(
29+
builder.name + '/internal',
30+
getDescriptorFromBuilder({ ...builder, path: '1/*' }),
31+
userKey
32+
),
33+
];
34+
}
35+
36+
/**
37+
* Create a pair of external and internal descriptors for a 2-of-3 multisig wallet.
38+
*
39+
* @param userKey
40+
* @param cosigners
41+
* @constructor
42+
*/
43+
export const DefaultWsh2Of3: DescriptorFromKeys = (userKey, cosigners) =>
44+
createExternalInternalPair({ name: 'Wsh2Of3', keys: [userKey.neutered(), ...cosigners], path: '' }, userKey);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './createDescriptors';
2+
export * from './createDescriptorWallet';

modules/abstract-utxo/src/descriptor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { assertDescriptorWalletAddress } from './assertDescriptorWalletAddress';
33
export { NamedDescriptor } from './NamedDescriptor';
44
export { isDescriptorWallet, getDescriptorMapFromWallet } from './descriptorWallet';
55
export { getPolicyForEnv } from './validatePolicy';
6+
export * as createWallet from './createWallet';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import assert from 'assert';
2+
3+
import { assertHasValidSignature, createNamedDescriptorWithSignature } from '../../src/descriptor/NamedDescriptor';
4+
import { getKeyTriple } from '../core/key.utils';
5+
import { getDescriptorFromBuilder } from '../../src/descriptor/builder';
6+
import { getFixture } from '../core/fixtures.utils';
7+
8+
describe('NamedDescriptor', function () {
9+
it('creates named descriptor with signature', async function () {
10+
const keys = getKeyTriple();
11+
const namedDescriptor = createNamedDescriptorWithSignature(
12+
'foo',
13+
getDescriptorFromBuilder({ name: 'Wsh2Of2', keys, path: '0/*' }),
14+
keys[0]
15+
);
16+
assert.deepStrictEqual(
17+
await getFixture(__dirname + '/fixtures/NamedDescriptorWithSignature.json', namedDescriptor),
18+
namedDescriptor
19+
);
20+
assertHasValidSignature(namedDescriptor, keys[0]);
21+
});
22+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import assert from 'assert';
2+
3+
import { getKeyTriple } from '../../core/key.utils';
4+
import { assertHasValidSignature } from '../../../src/descriptor/NamedDescriptor';
5+
import { DefaultWsh2Of3 } from '../../../src/descriptor/createWallet';
6+
import { getFixture } from '../../core/fixtures.utils';
7+
8+
describe('createDescriptors', function () {
9+
it('should create standard named descriptors', async function () {
10+
const keys = getKeyTriple();
11+
const namedDescriptors = DefaultWsh2Of3(keys[0], keys.slice(1));
12+
assert.deepStrictEqual(
13+
namedDescriptors,
14+
await getFixture(__dirname + '/fixtures/DefaultWsh2Of3.json', namedDescriptors)
15+
);
16+
for (const namedDescriptor of namedDescriptors) {
17+
assertHasValidSignature(namedDescriptor, keys[0]);
18+
}
19+
});
20+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
{
3+
"name": "Wsh2Of3/external",
4+
"value": "wsh(multi(2,xpub661MyMwAqRbcFXS2qwsTkaFc7PEpwDXWgY1Hx2MS7XywhW24sTjQzxiUgnGNW5v6DsW9Z8JcAqf8a22v21jDSA3DwLbbpt2ra3WbP83QNvP/0/*,xpub661MyMwAqRbcGfiaWcoeKLerFu3qRfy6zSYAwnmxSKW8JSauRajFsAsRHs2pV4q5rxkb4ynx4Bm8t54McTCp8V27s7XsdpD8T4s56etpjro/0/*,xpub661MyMwAqRbcH4HzWiCwmYajy1SXZngxHvpYDNEX4xjrDAneAm6rnpPuPPXcBRsSgxupDBmH2tzPHkikNrnLbsvTHemPFHSFbZxonZcCwFi/0/*))#x382fygz",
5+
"signatures": [
6+
"20f303b798a75260ce7934e8fff7d8da12fd1b99dbd74879596c201d6acc344cbd151291c25d3d5fba428e844a6eb63a45f645deb2a5ae44e51d968614d4de9a39"
7+
]
8+
},
9+
{
10+
"name": "Wsh2Of3/internal",
11+
"value": "wsh(multi(2,xpub661MyMwAqRbcFXS2qwsTkaFc7PEpwDXWgY1Hx2MS7XywhW24sTjQzxiUgnGNW5v6DsW9Z8JcAqf8a22v21jDSA3DwLbbpt2ra3WbP83QNvP/1/*,xpub661MyMwAqRbcGfiaWcoeKLerFu3qRfy6zSYAwnmxSKW8JSauRajFsAsRHs2pV4q5rxkb4ynx4Bm8t54McTCp8V27s7XsdpD8T4s56etpjro/1/*,xpub661MyMwAqRbcH4HzWiCwmYajy1SXZngxHvpYDNEX4xjrDAneAm6rnpPuPPXcBRsSgxupDBmH2tzPHkikNrnLbsvTHemPFHSFbZxonZcCwFi/1/*))#73gjjc7a",
12+
"signatures": [
13+
"1fd2b2d9e294e9fe56ccba37d9992220c9bc16bb4c05fbd4f700de2eff1ae1dc051d1b3573304c470a61d5f5c4fa652a785cf9a69865b44da7059701bc9ee8173f"
14+
]
15+
}
16+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "foo",
3+
"value": "wsh(multi(2,xpub661MyMwAqRbcFXS2qwsTkaFc7PEpwDXWgY1Hx2MS7XywhW24sTjQzxiUgnGNW5v6DsW9Z8JcAqf8a22v21jDSA3DwLbbpt2ra3WbP83QNvP/0/*,xpub661MyMwAqRbcGfiaWcoeKLerFu3qRfy6zSYAwnmxSKW8JSauRajFsAsRHs2pV4q5rxkb4ynx4Bm8t54McTCp8V27s7XsdpD8T4s56etpjro/0/*))#qd26atvj",
4+
"signatures": [
5+
"20c5eac59397664cc8cdf07b92b85bc55f6d91051aac522a78f7d1ab72043543cd17c3436e11d96d3326274754812c3dd0e693c6330a6cdab1e3e53d618a066091"
6+
]
7+
}

modules/bitgo/test/v2/unit/coins/utxo/descriptorAddress.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('descriptor wallets', function () {
2727
return {
2828
name,
2929
value: withChecksum(`sh(multi(2,${a}/*,${b}/*))`),
30+
signatures: [],
3031
};
3132
}
3233

0 commit comments

Comments
 (0)