Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/utxo-descriptors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,8 @@
"dependencies": {
"@bitgo/utxo-core": "^1.40.0",
"@bitgo/wasm-utxo": "^4.27.0"
},
"devDependencies": {
"@stacks/bitcoin-staking": "7.6.0"
}
}
1 change: 1 addition & 0 deletions modules/utxo-descriptors/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * as sbtc from './sbtc';
export * as pox5 from './pox5';
100 changes: 100 additions & 0 deletions modules/utxo-descriptors/src/pox5/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ast, bip32, Descriptor } from '@bitgo/wasm-utxo';

type BIP32Interface = bip32.BIP32Interface;

export type Pox5StakerKey = BIP32Interface | Buffer;

export type Pox5LockupDescriptorParams = {
unlockHeight: number;
/** sha256(sha256(Clarity consensus principal bytes)). */
stakerCommitment: Buffer;
earlyExitKey: Buffer;
/** User, backup, and BitGo keys, all BIP32 or all concrete compressed keys. */
stakerKeys: [Pox5StakerKey, Pox5StakerKey, Pox5StakerKey];
};

function validateCompressedKey(key: Buffer, field: string): void {
if (key.length !== 33 || (key[0] !== 0x02 && key[0] !== 0x03)) {
throw new Error(`${field} must be a 33-byte compressed public key`);
}
}

function isBip32Triple(
keys: Pox5LockupDescriptorParams['stakerKeys']
): keys is [BIP32Interface, BIP32Interface, BIP32Interface] {
return keys.every((key) => !Buffer.isBuffer(key));
}

function asDescriptorKey(key: Pox5StakerKey, field: string): string {
if (Buffer.isBuffer(key)) {
validateCompressedKey(key, field);
return key.toString('hex');
}
return key.neutered().toBase58() + '/*';
}

function validateParams(params: Pox5LockupDescriptorParams): void {
if (!Number.isSafeInteger(params.unlockHeight) || params.unlockHeight <= 0 || params.unlockHeight >= 500_000_000) {
throw new Error(`unlockHeight (${params.unlockHeight}) must be a positive block height below 500000000`);
}
if (params.stakerCommitment.length !== 32) {
throw new Error(`stakerCommitment must be 32 bytes (got ${params.stakerCommitment.length})`);
}
validateCompressedKey(params.earlyExitKey, 'earlyExitKey');

const bufferCount = params.stakerKeys.filter(Buffer.isBuffer).length;
if (bufferCount !== 0 && bufferCount !== params.stakerKeys.length) {
throw new Error('stakerKeys must contain either three BIP32 keys or three compressed public keys');
}
params.stakerKeys.forEach((key, index) => {
if (Buffer.isBuffer(key)) {
validateCompressedKey(key, `stakerKeys[${index}]`);
}
});
}

/**
* Build the canonical PoX-5 P2WSH descriptor. The post-CLTV and early-exit
* paths share BitGo's standard 2-of-3 compressed-key multisig tail.
*/
export function createPox5LockupDescriptor(params: Pox5LockupDescriptorParams): string {
validateParams(params);
const stakerKeys = params.stakerKeys.map((key, index) => asDescriptorKey(key, `stakerKeys[${index}]`));
const miniscript: ast.MiniscriptNode = {
and_v: [
{
'v:or_i': [
{ after: params.unlockHeight },
{
and_v: [
{ 'v:sha256': params.stakerCommitment.toString('hex') },
{ pk: params.earlyExitKey.toString('hex') },
],
},
],
},
{ multi: [2, ...stakerKeys] },
],
};
return ast.formatNode({ wsh: miniscript });
}

/** Compile the PoX-5 P2WSH scriptPubKey at a BIP32 derivation index. */
export function createPox5LockupScriptPubKey(params: Pox5LockupDescriptorParams, derivationIndex = 0): Buffer {
const descriptor = createPox5LockupDescriptor(params);
if (isBip32Triple(params.stakerKeys)) {
return Buffer.from(
Descriptor.fromString(descriptor, 'derivable').atDerivationIndex(derivationIndex).scriptPubkey()
);
}
return Buffer.from(Descriptor.fromString(descriptor, 'definite').scriptPubkey());
}

/** Derive the compressed staker keys needed to prepare a witness at an index. */
export function derivePox5StakerKeys(
stakerKeys: [BIP32Interface, BIP32Interface, BIP32Interface],
index: number
): [Buffer, Buffer, Buffer] {
const keys = stakerKeys.map((key) => Buffer.from(key.derive(index).publicKey));
return [keys[0], keys[1], keys[2]];
}
2 changes: 2 additions & 0 deletions modules/utxo-descriptors/src/pox5/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './descriptor';
export * from './parseDescriptor';
112 changes: 112 additions & 0 deletions modules/utxo-descriptors/src/pox5/parseDescriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { BIP32, Descriptor, ast } from '@bitgo/wasm-utxo';
import { Pattern, PatternMatcher } from '@bitgo/utxo-core/descriptor';

export type ParsedPox5LockupDescriptor = {
unlockHeight: number;
stakerCommitment: Buffer;
earlyExitKey: Buffer;
stakerKeyStrings: [string, string, string];
stakerKeys: [Buffer, Buffer, Buffer] | undefined;
miniscriptNode: ast.MiniscriptNode;
};

const COMPRESSED_KEY = /^(02|03)[0-9a-fA-F]{64}$/;
const XPUB_WITH_INDEX = /^([1-9A-HJ-NP-Za-km-z]+)\/(\d+)$/;

function asNumber(value: unknown, field: string): number {
if (typeof value !== 'number') {
throw new Error(`${field} must be a number`);
}
return value;
}

function asString(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new Error(`${field} must be a string`);
}
return value;
}

function parseCompressedKey(value: string, field: string): Buffer {
if (!COMPRESSED_KEY.test(value)) {
throw new Error(`${field} must be a compressed public key`);
}
return Buffer.from(value, 'hex');
}

function resolveStakerKey(value: string): Buffer | undefined {
if (COMPRESSED_KEY.test(value)) {
return Buffer.from(value, 'hex');
}
const match = value.match(XPUB_WITH_INDEX);
if (!match) {
return undefined;
}
const [, xpub, indexString] = match;
const index = Number.parseInt(indexString, 10);
try {
return Buffer.from(BIP32.fromBase58(xpub).derive(index).publicKey);
} catch {
return undefined;
}
}

/**
* Parse only the canonical PoX-5 descriptor template. Other descriptors return
* null; malformed fields within the template throw so callers cannot finalize
* a script under an ambiguous policy.
*/
export function parsePox5LockupDescriptor(
descriptor: Descriptor | ast.DescriptorNode
): ParsedPox5LockupDescriptor | null {
const matcher = new PatternMatcher();
const descriptorNode = descriptor instanceof Descriptor ? ast.fromDescriptor(descriptor) : descriptor;
const matched = matcher.match(descriptorNode, { wsh: { $var: 'miniscript' } });
if (!matched) {
return null;
}

const miniscriptNode = matched.miniscript as ast.MiniscriptNode;
const pattern: Pattern = {
and_v: [
{
'v:or_i': [
{ after: { $var: 'unlockHeight' } },
{ and_v: [{ 'v:sha256': { $var: 'stakerCommitment' } }, { pk: { $var: 'earlyExitKey' } }] },
],
},
{ multi: { $var: 'stakerMulti' } },
],
};
const fields = matcher.match(miniscriptNode, pattern);
if (!fields) {
return null;
}

const unlockHeight = asNumber(fields.unlockHeight, 'after argument');
if (!Number.isSafeInteger(unlockHeight) || unlockHeight <= 0 || unlockHeight >= 500_000_000) {
throw new Error(`unlockHeight (${unlockHeight}) must be a positive block height below 500000000`);
}
const commitmentHex = asString(fields.stakerCommitment, 'sha256 commitment');
if (!/^[0-9a-fA-F]{64}$/.test(commitmentHex)) {
throw new Error('stakerCommitment must be 32 bytes');
}
const earlyExitKey = parseCompressedKey(asString(fields.earlyExitKey, 'early exit key'), 'earlyExitKey');

if (!Array.isArray(fields.stakerMulti) || fields.stakerMulti.length !== 4 || fields.stakerMulti[0] !== 2) {
throw new Error('staker multi must be a 2-of-3 multisig');
}
const stakerKeyStrings = fields.stakerMulti.slice(1).map((key, index) => asString(key, `staker key ${index}`));
const resolvedKeys = stakerKeyStrings.map(resolveStakerKey);

return {
unlockHeight,
stakerCommitment: Buffer.from(commitmentHex, 'hex'),
earlyExitKey,
stakerKeyStrings: [stakerKeyStrings[0], stakerKeyStrings[1], stakerKeyStrings[2]],
stakerKeys: resolvedKeys.every((key): key is Buffer => key !== undefined)
? [resolvedKeys[0], resolvedKeys[1], resolvedKeys[2]]
: undefined,
miniscriptNode,
};
}
167 changes: 167 additions & 0 deletions modules/utxo-descriptors/test/unit/pox5/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import * as assert from 'assert';
import { createHash } from 'crypto';

import {
buildLockAddress,
buildLockOutputScript,
buildLockScript,
buildUnlockScript,
computeRegisterPreimage,
} from '@stacks/bitcoin-staking';
import { address, bip32, Descriptor } from '@bitgo/wasm-utxo';
import { getKeyTriple } from '@bitgo/wasm-utxo/testutils';

import {
createPox5LockupDescriptor,
createPox5LockupScriptPubKey,
derivePox5StakerKeys,
parsePox5LockupDescriptor,
Pox5LockupDescriptorParams,
} from '../../../src/pox5';

type BIP32Interface = bip32.BIP32Interface;

const OPCODES: Record<string, number> = {
OP_0: 0x00,
OP_IF: 0x63,
OP_ELSE: 0x67,
OP_ENDIF: 0x68,
OP_VERIFY: 0x69,
OP_SIZE: 0x82,
OP_EQUAL: 0x87,
OP_EQUALVERIFY: 0x88,
OP_SHA256: 0xa8,
OP_CHECKSIG: 0xac,
OP_CHECKMULTISIG: 0xae,
OP_CLTV: 0xb1,
};

function getBip32Triple(): [BIP32Interface, BIP32Interface, BIP32Interface] {
const [user, backup, bitgo] = getKeyTriple('default');
return [user, backup, bitgo];
}

function sha256(value: Uint8Array): Buffer {
return createHash('sha256').update(value).digest();
}

function params(overrides: Partial<Pox5LockupDescriptorParams> = {}): Pox5LockupDescriptorParams {
const stakerKeys = getBip32Triple();
const earlyExitKey = Buffer.from(stakerKeys[0].derive(9).publicKey);
return {
unlockHeight: 840_000,
stakerCommitment: sha256(computeRegisterPreimage('SP000000000000000000002Q6VF78')),
earlyExitKey,
stakerKeys,
...overrides,
};
}

function asmToScript(asm: string): Buffer {
const tokens = asm.split(' ');
const chunks: Buffer[] = [];
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
const push = token.match(/^OP_PUSHBYTES_(\d+)$/);
if (push) {
const bytes = Buffer.from(tokens[++index], 'hex');
assert.strictEqual(bytes.length, Number(push[1]));
chunks.push(Buffer.of(bytes.length), bytes);
continue;
}
const number = token.match(/^OP_PUSHNUM_(\d+)$/);
if (number) {
chunks.push(Buffer.of(0x50 + Number(number[1])));
continue;
}
const opcode = OPCODES[token];
assert.notStrictEqual(opcode, undefined, `unsupported ASM token ${token}`);
chunks.push(Buffer.of(opcode));
}
return Buffer.concat(chunks);
}

function encodeTwoOfThreeUnlock(keys: [Buffer, Buffer, Buffer]): Buffer {
return Buffer.concat([Buffer.of(0x52), ...keys.flatMap((key) => [Buffer.of(33), key]), Buffer.of(0x53, 0xae)]);
}

describe('PoX-5 lockup descriptors', function () {
it('renders the required byte-identical canonical script and P2WSH output', function () {
const derivable = params();
const stakerKeys = derivePox5StakerKeys(
derivable.stakerKeys as [BIP32Interface, BIP32Interface, BIP32Interface],
0
);
const definite = { ...derivable, stakerKeys };
const descriptorString = createPox5LockupDescriptor(definite);
const descriptor = Descriptor.fromString(descriptorString, 'definite');
const localWitnessScript = asmToScript(descriptor.toAsmString());
const unlockBytes = encodeTwoOfThreeUnlock(stakerKeys);
const earlyUnlockBytes = buildUnlockScript(definite.earlyExitKey);
const sdkWitnessScript = Buffer.from(
buildLockScript({
stxAddress: 'SP000000000000000000002Q6VF78',
unlockHeight: definite.unlockHeight,
unlockBytes,
earlyUnlockBytes,
validateEarlyUnlockBytes: false,
})
);

assert.deepStrictEqual(localWitnessScript, sdkWitnessScript);
assert.ok(descriptor.toAsmString().includes('OP_SHA256 OP_PUSHBYTES_32'));
assert.ok(descriptor.toAsmString().includes('OP_EQUALVERIFY OP_PUSHBYTES_33'));

const localScriptPubKey = createPox5LockupScriptPubKey(definite);
const sdkScriptPubKey = Buffer.from(
buildLockOutputScript({
stxAddress: 'SP000000000000000000002Q6VF78',
unlockHeight: definite.unlockHeight,
unlockBytes,
earlyUnlockBytes,
})
);
assert.deepStrictEqual(localScriptPubKey, sdkScriptPubKey);
assert.strictEqual(
address.fromOutputScriptWithCoin(localScriptPubKey, 'btc'),
buildLockAddress({
stxAddress: 'SP000000000000000000002Q6VF78',
unlockHeight: definite.unlockHeight,
unlockBytes,
earlyUnlockBytes,
network: 'mainnet',
validateEarlyUnlockBytes: false,
})
);
});

it('supports derivation and preserves wildcard keys until an index is selected', function () {
const value = params();
const descriptor = Descriptor.fromString(createPox5LockupDescriptor(value), 'derivable');
const wildcard = parsePox5LockupDescriptor(descriptor);
const derived = parsePox5LockupDescriptor(descriptor.atDerivationIndex(4));

assert.ok(wildcard);
assert.strictEqual(wildcard.stakerKeys, undefined);
assert.ok(wildcard.stakerKeyStrings.every((key) => key.endsWith('/*')));
assert.ok(derived?.stakerKeys);
assert.deepStrictEqual(
derived?.stakerKeys,
derivePox5StakerKeys(value.stakerKeys as [BIP32Interface, BIP32Interface, BIP32Interface], 4)
);
});

it('rejects noncanonical parameter values and descriptor templates', function () {
assert.throws(() => createPox5LockupDescriptor(params({ unlockHeight: 0 })));
assert.throws(() => createPox5LockupDescriptor(params({ unlockHeight: 500_000_000 })));
assert.throws(() => createPox5LockupDescriptor(params({ stakerCommitment: Buffer.alloc(31) })));
assert.throws(() => createPox5LockupDescriptor(params({ earlyExitKey: Buffer.alloc(32) })));
assert.throws(() =>
createPox5LockupDescriptor(
params({ stakerKeys: [getBip32Triple()[0], Buffer.alloc(33, 2), Buffer.alloc(33, 2)] })
)
);
const validKey = params().earlyExitKey.toString('hex');
assert.strictEqual(parsePox5LockupDescriptor(Descriptor.fromString(`wsh(pk(${validKey}))`, 'definite')), null);
});
});
Loading
Loading