Skip to content

Commit

Permalink
Merge pull request #5068 from BitGo/BTC-1578-descriptor
Browse files Browse the repository at this point in the history
feat(utxo-coredao): descriptor for staking output
  • Loading branch information
davidkaplanbitgo authored Oct 31, 2024
2 parents 8ef8b38 + 791f9e4 commit 2cd6b5d
Show file tree
Hide file tree
Showing 17 changed files with 306 additions and 41 deletions.
3 changes: 2 additions & 1 deletion modules/utxo-coredao/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
]
},
"dependencies": {
"@bitgo/utxo-lib": "^11.0.0"
"@bitgo/utxo-lib": "^11.0.0",
"@bitgo/wasm-miniscript": "^2.0.0-beta.2"
}
}
35 changes: 35 additions & 0 deletions modules/utxo-coredao/src/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { BIP32Interface } from '@bitgo/utxo-lib';

/**
* Script type for a descriptor. This is either a p2sh (sh) or a p2sh-p2wsh (sh-wsh) script.
*/
export type ScriptType = 'sh' | 'sh-wsh';

/**
* Create a multi-sig descriptor to produce a coredao staking address
* @param scriptType segwit or legacy
* @param locktime locktime for CLTV
* @param m Total number of keys required to unlock
* @param orderedKeys
* @param neutered If true, neuter the keys. Default to true
*/
export function createMultiSigDescriptor(
scriptType: ScriptType,
locktime: number,
m: number,
orderedKeys: BIP32Interface[],
neutered = true
): string {
if (m > orderedKeys.length || m < 1) {
throw new Error(
`m (${m}) must be less than or equal to the number of keys (${orderedKeys.length}) and greater than 0`
);
}
if (locktime <= 0) {
throw new Error(`locktime (${locktime}) must be greater than 0`);
}

const xpubs = orderedKeys.map((key) => (neutered ? key.neutered() : key).toBase58() + '/*');
const inner = `and_v(r:after(${locktime}),multi(${m},${xpubs.join(',')}))`;
return scriptType === 'sh' ? `sh(${inner})` : `sh(wsh(${inner}))`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OP_PUSHBYTES_2 0008 OP_CLTV OP_DROP OP_PUSHNUM_2 OP_PUSHBYTES_33 03b6ca82c5298cba26c76a9cc5a9bb08c68b125263924ac9d58a15a60d49cf3ef3 OP_PUSHBYTES_33 02e6a4442a7efbc0f53fad6ff2a7c0940ea18c751a0b6644c03675babd731015cc OP_PUSHNUM_2 OP_CHECKMULTISIG
26 changes: 26 additions & 0 deletions modules/utxo-coredao/test/fixtures/descriptor/sh-2of2-ast.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"Sh": {
"Ms": {
"AndV": [
{
"Drop": {
"After": {
"absLockTime": 2048
}
}
},
{
"Multi": [
2,
{
"XPub": "xpub661MyMwAqRbcGZfgedgcQiBJpkHoZ37k6uotUVLoC6Px4Y46Yrdmy1CUogcJMoAsosY381gPjhGe9jFx1uYAcxy2gTHh9YFP32tUWycqHnV/*"
},
{
"XPub": "xpub661MyMwAqRbcF8FoYWukS8eTn2gVEojzwf5DYETpB8uqC8t5sqDCEFnuJ39DaPjLRerBDK9QqSMvSYpT4WSugCVbUK5HEevSKAu1wUkVWsS/*"
}
]
}
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sh(and_v(r:after(2048),multi(2,xprv9s21ZrQH143K45bDYc9c3aEaGiTK9aPtjgtHg6wBdkryBjix1KKXRCszxPcFPejLT9tdLgNe8E8AuQXK2fy8KhNPeLAZsGoX8w9KS2PkacL/*,xprv9s21ZrQH143K2eBLSVNk4zhjDzqzqM29aS9cjr4CcoNrKLYwLHtwgTURSk7RPV3cH9zNZQeR1zGw3MEwSjvARSfWEGpxfaBmduhW3TKsH5g/*)))
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
02000000013ccb3b820130a360a33cc4b8afa063d6c6c2cf04680d6ac3b728db0a82cbec6700000000e100483045022100bdf153f069124649bd6053bc5134b8ee49769fd91bebd38a1d366dfa6aca837c02201c78e9a8804339bef63c2a8250e86faced3feab246f092b460a2289070ff1e8301483045022100cf53ecb0baa76b98e66a2b110a28267f72dff6b243d799013e20561696dfa61d0220067ecaa7fbc50026a56234fe61a25995c4a2b2f6c29c9ab55241cfd5ffc33183014c4c020008b175522103b6ca82c5298cba26c76a9cc5a9bb08c68b125263924ac9d58a15a60d49cf3ef32102e6a4442a7efbc0f53fad6ff2a7c0940ea18c751a0b6644c03675babd731015cc52aefeffffff01804a5d050000000017a914199ff00d994285c5fa0c08a4becd8973945e06c28700080000
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OP_PUSHBYTES_2 0008 OP_CLTV OP_DROP OP_PUSHNUM_3 OP_PUSHBYTES_33 03b6ca82c5298cba26c76a9cc5a9bb08c68b125263924ac9d58a15a60d49cf3ef3 OP_PUSHBYTES_33 02e6a4442a7efbc0f53fad6ff2a7c0940ea18c751a0b6644c03675babd731015cc OP_PUSHBYTES_33 038ce8843d1085927fdb26a26e9869d6c10dcd37aa45fdd9773ff544a7f3514c82 OP_PUSHNUM_3 OP_CHECKMULTISIG
29 changes: 29 additions & 0 deletions modules/utxo-coredao/test/fixtures/descriptor/sh-3of3-ast.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"Sh": {
"Ms": {
"AndV": [
{
"Drop": {
"After": {
"absLockTime": 2048
}
}
},
{
"Multi": [
3,
{
"XPub": "xpub661MyMwAqRbcGZfgedgcQiBJpkHoZ37k6uotUVLoC6Px4Y46Yrdmy1CUogcJMoAsosY381gPjhGe9jFx1uYAcxy2gTHh9YFP32tUWycqHnV/*"
},
{
"XPub": "xpub661MyMwAqRbcF8FoYWukS8eTn2gVEojzwf5DYETpB8uqC8t5sqDCEFnuJ39DaPjLRerBDK9QqSMvSYpT4WSugCVbUK5HEevSKAu1wUkVWsS/*"
},
{
"XPub": "xpub661MyMwAqRbcFdScyinA4JpCViqkKsd37MQ6fwuZQQ4shdaGRRX9a8bWvR9QC1AFqKongweJJfyrm7uCoWmCw7UixwGZkZnCT2mchFr7cQb/*"
}
]
}
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sh(and_v(r:after(2048),multi(3,xprv9s21ZrQH143K45bDYc9c3aEaGiTK9aPtjgtHg6wBdkryBjix1KKXRCszxPcFPejLT9tdLgNe8E8AuQXK2fy8KhNPeLAZsGoX8w9KS2PkacL/*,xprv9s21ZrQH143K2eBLSVNk4zhjDzqzqM29aS9cjr4CcoNrKLYwLHtwgTURSk7RPV3cH9zNZQeR1zGw3MEwSjvARSfWEGpxfaBmduhW3TKsH5g/*,xprv9s21ZrQH143K39N9shF9hAsTwh1FvQuBk8UVsZVwr4XtpqF7stCu2LH358NLuqkkK6pu1Af7TJHr5FZERQoLLtnC7wkoM9sdFo1HuP7dWuv/*)))
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
02000000012864beb49a42260574c7fc2d89a97dba04073353eb5bae5c08b2bde845db371300000000fd49010047304402201f27a9ed1572e5cf4be3c09c48c4df8cbebb7553af584dd437ef541f7c9edc9a02204fe6c5d4bdbd8094f20c7e78160686243a664f69dd41256a4eb6aa97c1f3765b01473044022023d1e8dd29365a384553889b7101071bf1bd265978032155734a65ef7fb240c1022073d45ea7090e31798ebd3e3b41d552706f6bec78ca5fa129c3f3d3a2690478fc01473044022053ad461ec6173e4699ece94345f3aedaa758d7b4070d6f3c75567ab07092013e02204d77ece097fa2f7283dd5e3e4256695c29f3abee7e59d47fc4334c15277f2334014c6e020008b175532103b6ca82c5298cba26c76a9cc5a9bb08c68b125263924ac9d58a15a60d49cf3ef32102e6a4442a7efbc0f53fad6ff2a7c0940ea18c751a0b6644c03675babd731015cc21038ce8843d1085927fdb26a26e9869d6c10dcd37aa45fdd9773ff544a7f3514c8253aefeffffff01804a5d050000000017a914bf73c6a0f7b8e8ed8ad854cc2d65ffe21ad276298700080000
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OP_PUSHBYTES_2 0008 OP_CLTV OP_DROP OP_PUSHNUM_2 OP_PUSHBYTES_33 03b6ca82c5298cba26c76a9cc5a9bb08c68b125263924ac9d58a15a60d49cf3ef3 OP_PUSHBYTES_33 02e6a4442a7efbc0f53fad6ff2a7c0940ea18c751a0b6644c03675babd731015cc OP_PUSHNUM_2 OP_CHECKMULTISIG
28 changes: 28 additions & 0 deletions modules/utxo-coredao/test/fixtures/descriptor/sh-wsh-2of2-ast.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"Sh": {
"Wsh": {
"Ms": {
"AndV": [
{
"Drop": {
"After": {
"absLockTime": 2048
}
}
},
{
"Multi": [
2,
{
"XPub": "xpub661MyMwAqRbcGZfgedgcQiBJpkHoZ37k6uotUVLoC6Px4Y46Yrdmy1CUogcJMoAsosY381gPjhGe9jFx1uYAcxy2gTHh9YFP32tUWycqHnV/*"
},
{
"XPub": "xpub661MyMwAqRbcF8FoYWukS8eTn2gVEojzwf5DYETpB8uqC8t5sqDCEFnuJ39DaPjLRerBDK9QqSMvSYpT4WSugCVbUK5HEevSKAu1wUkVWsS/*"
}
]
}
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sh(wsh(and_v(r:after(2048),multi(2,xprv9s21ZrQH143K45bDYc9c3aEaGiTK9aPtjgtHg6wBdkryBjix1KKXRCszxPcFPejLT9tdLgNe8E8AuQXK2fy8KhNPeLAZsGoX8w9KS2PkacL/*,xprv9s21ZrQH143K2eBLSVNk4zhjDzqzqM29aS9cjr4CcoNrKLYwLHtwgTURSk7RPV3cH9zNZQeR1zGw3MEwSjvARSfWEGpxfaBmduhW3TKsH5g/*))))
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
020000000001016529f61c633614899fb44d3feef56796225b9010803b44e463334bacf3927af200000000232200206e9093852388246c2aeceaff64ebb2a3753eb5cb15079e91aee0ffa4d62e5530feffffff01804a5d050000000017a914afbde2b1755e3bec5ba23355408f07cbf20d8d53870400473044022039b22b53980cc018ec079a611567229d0e5b73d5822b9d999e65d554b4650fc702205de919715d3b2a0f619f69cf7fff0ebc6a393a6388264051cb2626440f7229d2014730440220353ffb65f59a8d7c9f46a96c23c2cb82f520e356700148fc720ac36d51ff3bf5022026363c67bc038705c5f4dfc5cdd00b8e392f66e70c0390e2825ec1ab1fbd93d8014c020008b175522103b6ca82c5298cba26c76a9cc5a9bb08c68b125263924ac9d58a15a60d49cf3ef32102e6a4442a7efbc0f53fad6ff2a7c0940ea18c751a0b6644c03675babd731015cc52ae00080000
117 changes: 117 additions & 0 deletions modules/utxo-coredao/test/unit/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as assert from 'assert';
import * as utxolib from '@bitgo/utxo-lib';
import { Descriptor } from '@bitgo/wasm-miniscript';

import { createMultiSigDescriptor } from '../../src/descriptor';
import { finalizePsbt, getFixture, updateInputWithDescriptor } from './utils';

describe('descriptor', function () {
const baseFixturePath = 'test/fixtures/descriptor/';
const rootWalletKeys = utxolib.testutil.getDefaultWalletKeys();
const key1 = rootWalletKeys.triple[0];
const key2 = rootWalletKeys.triple[1];
const key3 = rootWalletKeys.triple[2];
const validLocktime = 2048;

it('should fail if m is longer than the number of keys or not at least 1', function () {
assert.throws(() => {
createMultiSigDescriptor('sh', validLocktime, 3, [key1, key2], false);
});

assert.throws(() => {
createMultiSigDescriptor('sh', validLocktime, 0, [key1, key2], false);
});
});

it('should fail if locktime is invalid', function () {
assert.throws(() => {
createMultiSigDescriptor('sh', 0, 2, [key1, key2], false);
});
});

async function runTestForParams(scriptType: 'sh' | 'sh-wsh', m: number, keys: utxolib.BIP32Interface[]) {
const fixturePath = baseFixturePath + `${scriptType}-${m}of${keys.length}`;
describe(`should create a ${m} of ${keys.length} multi-sig ${scriptType} descriptor`, function () {
it('has expected descriptor string', async function () {
const descriptorString = createMultiSigDescriptor(scriptType, validLocktime, m, keys, false);
assert.strictEqual(
descriptorString,
await getFixture(fixturePath + `-string.txt`, descriptorString),
descriptorString
);
});

it('has expected AST', async function () {
const descriptor = Descriptor.fromString(
createMultiSigDescriptor(scriptType, validLocktime, m, keys, false),
'derivable'
);

assert.deepStrictEqual(descriptor.node(), await getFixture(fixturePath + '-ast.json', descriptor.node()));
});

it('has expected asm', async function () {
const descriptor = Descriptor.fromString(
createMultiSigDescriptor(scriptType, validLocktime, m, keys, false),
'derivable'
);
const asmString = descriptor.atDerivationIndex(0).toAsmString();
assert.strictEqual(asmString, await getFixture(fixturePath + '-asm.txt', asmString), asmString);
});

it('can be signed', async function () {
// Derive the script from the descriptor
const descriptor = Descriptor.fromString(
createMultiSigDescriptor(scriptType, validLocktime, m, keys, false),
'derivable'
);
const descriptorAt0 = descriptor.atDerivationIndex(0);
const script = Buffer.from(descriptorAt0.scriptPubkey());

// Make the prevTx
const prevPsbt = utxolib.testutil.constructPsbt(
[{ scriptType: 'p2wsh', value: BigInt(1.1e8) }],
[{ script: script.toString('hex'), value: BigInt(1e8) }],
utxolib.networks.bitcoin,
rootWalletKeys,
'fullsigned'
);
const prevTx = prevPsbt.finalizeAllInputs().extractTransaction();

// Create the PSBT and sign
const psbt = Object.assign(new utxolib.Psbt({ network: utxolib.networks.bitcoin }), {
locktime: validLocktime,
});
psbt.addInput({
hash: prevTx.getId(),
index: 0,
sequence: 0xfffffffe,
});
if (scriptType === 'sh-wsh') {
psbt.updateInput(0, { witnessUtxo: { script, value: BigInt(1e8) } });
} else {
psbt.updateInput(0, { nonWitnessUtxo: prevTx.toBuffer() });
}
psbt.addOutput({ script, value: BigInt(0.9e8) });
updateInputWithDescriptor(psbt, 0, descriptorAt0);
keys.forEach((signer, i) => {
if (i >= m) {
return;
}
psbt.signAllInputsHD(signer);
});

// Get the fully signed transaction and check
const signedTx = finalizePsbt(psbt).extractTransaction().toBuffer();
assert.strictEqual(
signedTx.toString('hex'),
await getFixture(fixturePath + '-tx.txt', signedTx.toString('hex'))
);
});
});
}

runTestForParams('sh', 2, [key1, key2]);
runTestForParams('sh-wsh', 2, [key1, key2]);
runTestForParams('sh', 3, [key1, key2, key3]);
});
51 changes: 51 additions & 0 deletions modules/utxo-coredao/test/unit/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { promises as fs } from 'fs';
import { Descriptor, Psbt } from '@bitgo/wasm-miniscript';
import * as utxolib from '@bitgo/utxo-lib';

function encode(path: string, defaultValue: unknown): string {
if (path.endsWith('.txt')) {
return String(defaultValue);
}
if (path.endsWith('.json')) {
return JSON.stringify(defaultValue, null, 2);
}
throw new Error(`unrecognized path ${path}`);
}

function decode(path: string, v: string): unknown {
if (path.endsWith('.txt')) {
return v;
}
if (path.endsWith('.json')) {
return JSON.parse(v);
}
throw new Error(`unrecognized path ${path}`);
}

export async function getFixture(path: string, defaultValue: unknown): Promise<unknown> {
try {
return decode(path, await fs.readFile(path, 'utf8'));
} catch (e) {
if (e.code === 'ENOENT') {
await fs.writeFile(path, encode(path, defaultValue), 'utf8');
throw new Error(`wrote default value for ${path}`);
}
throw e;
}
}

export function updateInputWithDescriptor(psbt: utxolib.Psbt, inputIndex: number, descriptor: Descriptor): void {
const wrappedPsbt = Psbt.deserialize(psbt.toBuffer());
wrappedPsbt.updateInputWithDescriptor(inputIndex, descriptor);
psbt.data.inputs[inputIndex] = utxolib.bitgo.UtxoPsbt.fromBuffer(Buffer.from(wrappedPsbt.serialize()), {
network: utxolib.networks.bitcoin,
}).data.inputs[inputIndex];
}

export function finalizePsbt(psbt: utxolib.Psbt): utxolib.bitgo.UtxoPsbt {
const wrappedPsbt = Psbt.deserialize(psbt.toBuffer());
wrappedPsbt.finalize();
return utxolib.bitgo.UtxoPsbt.fromBuffer(Buffer.from(wrappedPsbt.serialize()), {
network: utxolib.networks.bitcoin,
});
}
Loading

0 comments on commit 2cd6b5d

Please sign in to comment.