From 3c8d1d340ca866f34aaa5f2d802c394831cd8c88 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 21 Aug 2026 15:49:56 +0200 Subject: [PATCH] feat(utxo): add PoX-5 lockup primitives Build canonical descriptors and use the native Miniscript PSBT finalizer for PoX-5 locktime and early-exit spending paths. --- modules/utxo-descriptors/package.json | 3 + modules/utxo-descriptors/src/index.ts | 1 + .../utxo-descriptors/src/pox5/descriptor.ts | 100 +++++++++++ modules/utxo-descriptors/src/pox5/index.ts | 2 + .../src/pox5/parseDescriptor.ts | 112 ++++++++++++ .../test/unit/pox5/descriptor.ts | 167 ++++++++++++++++++ modules/utxo-staking/package.json | 1 + modules/utxo-staking/src/index.ts | 1 + modules/utxo-staking/src/pox5/index.ts | 1 + modules/utxo-staking/src/pox5/witness.ts | 58 ++++++ .../utxo-staking/test/unit/pox5/witness.ts | 80 +++++++++ modules/utxo-staking/tsconfig.json | 3 + yarn.lock | 127 ++++++++++++- 13 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 modules/utxo-descriptors/src/pox5/descriptor.ts create mode 100644 modules/utxo-descriptors/src/pox5/index.ts create mode 100644 modules/utxo-descriptors/src/pox5/parseDescriptor.ts create mode 100644 modules/utxo-descriptors/test/unit/pox5/descriptor.ts create mode 100644 modules/utxo-staking/src/pox5/index.ts create mode 100644 modules/utxo-staking/src/pox5/witness.ts create mode 100644 modules/utxo-staking/test/unit/pox5/witness.ts diff --git a/modules/utxo-descriptors/package.json b/modules/utxo-descriptors/package.json index bfb568e537..2c1e89c837 100644 --- a/modules/utxo-descriptors/package.json +++ b/modules/utxo-descriptors/package.json @@ -61,5 +61,8 @@ "dependencies": { "@bitgo/utxo-core": "^1.40.0", "@bitgo/wasm-utxo": "^4.27.0" + }, + "devDependencies": { + "@stacks/bitcoin-staking": "7.6.0" } } diff --git a/modules/utxo-descriptors/src/index.ts b/modules/utxo-descriptors/src/index.ts index 7e309b49c7..1fc0aedc0a 100644 --- a/modules/utxo-descriptors/src/index.ts +++ b/modules/utxo-descriptors/src/index.ts @@ -1 +1,2 @@ export * as sbtc from './sbtc'; +export * as pox5 from './pox5'; diff --git a/modules/utxo-descriptors/src/pox5/descriptor.ts b/modules/utxo-descriptors/src/pox5/descriptor.ts new file mode 100644 index 0000000000..5496040575 --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/descriptor.ts @@ -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]]; +} diff --git a/modules/utxo-descriptors/src/pox5/index.ts b/modules/utxo-descriptors/src/pox5/index.ts new file mode 100644 index 0000000000..f2bc6c47f3 --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/index.ts @@ -0,0 +1,2 @@ +export * from './descriptor'; +export * from './parseDescriptor'; diff --git a/modules/utxo-descriptors/src/pox5/parseDescriptor.ts b/modules/utxo-descriptors/src/pox5/parseDescriptor.ts new file mode 100644 index 0000000000..2623305a7f --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/parseDescriptor.ts @@ -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, + }; +} diff --git a/modules/utxo-descriptors/test/unit/pox5/descriptor.ts b/modules/utxo-descriptors/test/unit/pox5/descriptor.ts new file mode 100644 index 0000000000..dadb181cf9 --- /dev/null +++ b/modules/utxo-descriptors/test/unit/pox5/descriptor.ts @@ -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 = { + 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 { + 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); + }); +}); diff --git a/modules/utxo-staking/package.json b/modules/utxo-staking/package.json index 3536ab437a..549dc1cf3f 100644 --- a/modules/utxo-staking/package.json +++ b/modules/utxo-staking/package.json @@ -62,6 +62,7 @@ "@babylonlabs-io/babylon-proto-ts": "1.7.2", "@bitgo/babylonlabs-io-btc-staking-ts": "^3.5.1", "@bitgo/utxo-core": "^1.40.0", + "@bitgo/utxo-descriptors": "^1.3.4", "@bitgo/utxo-lib": "^11.24.2", "@bitgo/wasm-utxo": "^4.27.0", "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", diff --git a/modules/utxo-staking/src/index.ts b/modules/utxo-staking/src/index.ts index 1baacf80d2..b092d773d8 100644 --- a/modules/utxo-staking/src/index.ts +++ b/modules/utxo-staking/src/index.ts @@ -1,2 +1,3 @@ export * as coreDao from './coreDao'; export * as babylon from './babylon'; +export * as pox5 from './pox5'; diff --git a/modules/utxo-staking/src/pox5/index.ts b/modules/utxo-staking/src/pox5/index.ts new file mode 100644 index 0000000000..7ba6e34eca --- /dev/null +++ b/modules/utxo-staking/src/pox5/index.ts @@ -0,0 +1 @@ +export * from './witness'; diff --git a/modules/utxo-staking/src/pox5/witness.ts b/modules/utxo-staking/src/pox5/witness.ts new file mode 100644 index 0000000000..fcddb2a5fc --- /dev/null +++ b/modules/utxo-staking/src/pox5/witness.ts @@ -0,0 +1,58 @@ +import { createHash } from 'crypto'; + +import { ast, Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import { pox5 } from '@bitgo/utxo-descriptors'; + +type Pox5Descriptor = Descriptor | ast.DescriptorNode; + +export type Pox5FinalizerParams = { + /** A definite or derivation-indexed canonical PoX-5 descriptor. */ + descriptor: Pox5Descriptor; + /** The derived user, backup, and BitGo keys in descriptor order. */ + stakerKeys: [Buffer, Buffer, Buffer]; +}; + +function getParsedDescriptor(params: Pox5FinalizerParams) { + const parsed = pox5.parsePox5LockupDescriptor(params.descriptor); + if (!parsed || !parsed.stakerKeys) { + throw new Error('descriptor must be a definite or derivation-indexed canonical PoX-5 descriptor'); + } + if (!parsed.stakerKeys.every((key, index) => key.equals(params.stakerKeys[index]))) { + throw new Error('stakerKeys must match the canonical descriptor order'); + } + return parsed; +} + +function getDescriptor(descriptor: Pox5Descriptor): Descriptor { + return descriptor instanceof Descriptor ? descriptor : Descriptor.fromString(ast.formatNode(descriptor), 'definite'); +} + +function prepareInput(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams) { + const parsed = getParsedDescriptor(params); + psbt.updateInputWithDescriptor(inputIndex, getDescriptor(params.descriptor)); + return parsed; +} + +/** Finalize the post-CLTV 2-of-3 PoX-5 spend branch. */ +export function finalizePox5LocktimePath(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams): void { + const parsed = prepareInput(psbt, inputIndex, params); + if (psbt.lockTime() < parsed.unlockHeight) { + throw new Error(`transaction locktime must be at least ${parsed.unlockHeight}`); + } + psbt.finalizeInput(inputIndex); +} + +/** Finalize the principal-preimage early-exit 2-of-3 PoX-5 spend branch. */ +export function finalizePox5EarlyExitPath( + psbt: Psbt, + inputIndex: number, + params: Pox5FinalizerParams & { principalPreimage: Buffer } +): void { + const parsed = prepareInput(psbt, inputIndex, params); + const preimageHash = createHash('sha256').update(params.principalPreimage).digest(); + if (!preimageHash.equals(parsed.stakerCommitment)) { + throw new Error('principalPreimage does not match the descriptor stakerCommitment'); + } + psbt.addSha256Preimage(inputIndex, params.principalPreimage); + psbt.finalizeInput(inputIndex); +} diff --git a/modules/utxo-staking/test/unit/pox5/witness.ts b/modules/utxo-staking/test/unit/pox5/witness.ts new file mode 100644 index 0000000000..28d89a78b3 --- /dev/null +++ b/modules/utxo-staking/test/unit/pox5/witness.ts @@ -0,0 +1,80 @@ +import * as assert from 'assert'; +import { createHash } from 'crypto'; + +import { pox5 } from '@bitgo/utxo-descriptors'; +import { Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, Pox5FinalizerParams } from '../../../src/pox5'; + +const UNLOCK_HEIGHT = 840_000; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function createPox5Psbt( + lockTime: number, + includeEarlyExitSignature: boolean +): { + psbt: Psbt; + params: Pox5FinalizerParams; + principalPreimage: Buffer; +} { + const [user, backup, bitgo] = getKeyTriple('utxo-staking-pox5'); + const earlyExit = getKey('utxo-staking-pox5-early-exit'); + const principalPreimage = Buffer.alloc(32, 0x42); + const stakerKeys = [Buffer.from(user.publicKey), Buffer.from(backup.publicKey), Buffer.from(bitgo.publicKey)] as [ + Buffer, + Buffer, + Buffer + ]; + const params: Pox5FinalizerParams = { + descriptor: Descriptor.fromString( + pox5.createPox5LockupDescriptor({ + unlockHeight: UNLOCK_HEIGHT, + stakerCommitment: sha256(principalPreimage), + earlyExitKey: Buffer.from(earlyExit.publicKey), + stakerKeys, + }), + 'definite' + ), + stakerKeys, + }; + const descriptor = params.descriptor as Descriptor; + const scriptPubKey = descriptor.scriptPubkey(); + const psbt = Psbt.create(2, lockTime); + psbt.addInput('01'.repeat(32), 0, 100_000n, scriptPubKey, 0xfffffffe); + psbt.addOutput(scriptPubKey, 90_000n); + psbt.updateInputWithDescriptor(0, descriptor); + + for (const key of includeEarlyExitSignature ? [user, backup, earlyExit] : [user, backup]) { + assert.ok(key.privateKey, 'test key must include private key material'); + psbt.signWithPrv(key.privateKey); + } + return { psbt, params, principalPreimage }; +} + +describe('PoX-5 witness finalization', function () { + it('finalizes the CLTV branch through the native Miniscript finalizer', function () { + const { psbt, params } = createPox5Psbt(UNLOCK_HEIGHT, false); + + finalizePox5LocktimePath(psbt, 0, params); + + assert.deepStrictEqual(psbt.getPartialSignatures(0), []); + assert.ok(psbt.extractTransaction().toBytes().length > 0); + }); + + it('registers the principal preimage and finalizes the early-exit branch', function () { + const { psbt, params, principalPreimage } = createPox5Psbt(0, true); + + assert.throws( + () => finalizePox5EarlyExitPath(psbt, 0, { ...params, principalPreimage: Buffer.alloc(32) }), + /principalPreimage/ + ); + finalizePox5EarlyExitPath(psbt, 0, { ...params, principalPreimage }); + + assert.deepStrictEqual(psbt.getPartialSignatures(0), []); + assert.ok(psbt.extractTransaction().toBytes().length > 0); + }); +}); diff --git a/modules/utxo-staking/tsconfig.json b/modules/utxo-staking/tsconfig.json index c75876f9ad..f8894a3e7d 100644 --- a/modules/utxo-staking/tsconfig.json +++ b/modules/utxo-staking/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../utxo-core" + }, + { + "path": "../utxo-descriptors" } ] } diff --git a/yarn.lock b/yarn.lock index cd9d910a34..b86b5a73c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3764,6 +3764,13 @@ dependencies: "@noble/hashes" "1.8.0" +"@noble/curves@2.2.0", "@noble/curves@~2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz#981be3aadc3bbfbcdb245e78cc97aa6f759246c2" + integrity sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ== + dependencies: + "@noble/hashes" "2.2.0" + "@noble/curves@^1.0.0", "@noble/curves@^1.2.0", "@noble/curves@^1.3.0", "@noble/curves@^1.6.0", "@noble/curves@^1.7.0", "@noble/curves@^1.8.1", "@noble/curves@^1.9.6", "@noble/curves@~1.9.0": version "1.9.7" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" @@ -3771,6 +3778,11 @@ dependencies: "@noble/hashes" "1.8.0" +"@noble/hashes@1.1.5", "@noble/hashes@~1.1.1": + version "1.1.5" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz#1a0377f3b9020efe2fae03290bd2a12140c95c11" + integrity sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ== + "@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.2.0.tgz" @@ -3796,11 +3808,21 @@ resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz" integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== -"@noble/hashes@1.8.0", "@noble/hashes@^1", "@noble/hashes@^1.0.0", "@noble/hashes@^1.1.5", "@noble/hashes@^1.2.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.3.3", "@noble/hashes@^1.4.0", "@noble/hashes@^1.5.0", "@noble/hashes@^1.8.0", "@noble/hashes@~1.8.0": +"@noble/hashes@1.8.0", "@noble/hashes@^1", "@noble/hashes@^1.0.0", "@noble/hashes@^1.1.2", "@noble/hashes@^1.1.5", "@noble/hashes@^1.2.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.3.3", "@noble/hashes@^1.4.0", "@noble/hashes@^1.5.0", "@noble/hashes@^1.8.0", "@noble/hashes@~1.8.0": version "1.8.0" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/hashes@2.0.1": + version "2.0.1" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + +"@noble/hashes@2.2.0", "@noble/hashes@~2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" + integrity sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg== + "@noble/secp256k1@1.6.3": version "1.6.3" resolved "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.6.3.tgz" @@ -5014,6 +5036,11 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.5.tgz" integrity sha512-Brj9FiG2W1MRQSTB212YVPRrcbjkv48FoZi/u4l/zds/ieRrqsh7aUf6CLwkAq61oKXr/ZlTzlY66gLIj3TFTQ== +"@scure/base@2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + "@scure/base@^1.1.1", "@scure/base@^1.1.3", "@scure/base@^1.1.7", "@scure/base@^1.2.0", "@scure/base@^1.2.4", "@scure/base@~1.2.5": version "1.2.6" resolved "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz" @@ -5024,6 +5051,11 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz" integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== +"@scure/base@~2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz#1311378ed247df6d58f8eb8941921965e97e5747" + integrity sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg== + "@scure/bip32@1.1.5": version "1.1.5" resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.1.5.tgz" @@ -5051,6 +5083,14 @@ "@noble/hashes" "~1.8.0" "@scure/base" "~1.2.5" +"@scure/bip39@1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz#92f11d095bae025f166bef3defcc5bf4945d419a" + integrity sha512-pwrPOS16VeTKg98dYXQyIjJEcWfz7/1YJIwxUEPFfQPtc86Ym/1sVgQ2RLoD43AazMk2l/unK4ITySSpW2+82w== + dependencies: + "@noble/hashes" "~1.1.1" + "@scure/base" "~1.1.0" + "@scure/bip39@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.1.tgz" @@ -5075,6 +5115,16 @@ "@noble/hashes" "~1.8.0" "@scure/base" "~1.2.5" +"@scure/btc-signer@2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz#8aa38dcda9606a47deffb18fb1485e0c116ed17b" + integrity sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw== + dependencies: + "@noble/curves" "~2.2.0" + "@noble/hashes" "~2.2.0" + "@scure/base" "~2.2.0" + micro-packed "~0.9.0" + "@scure/starknet@^1.1.0": version "1.1.2" resolved "https://registry.npmjs.org/@scure/starknet/-/starknet-1.1.2.tgz#3b9492d83e773092158a240fb1e2a437933d2a7e" @@ -5530,6 +5580,20 @@ "@stablelib/salsa20" "^1.0.2" "@stablelib/wipe" "^1.0.1" +"@stacks/bitcoin-staking@7.6.0": + version "7.6.0" + resolved "https://registry.npmjs.org/@stacks/bitcoin-staking/-/bitcoin-staking-7.6.0.tgz#eb77b720126a84df61a837c3dd5aadfefb1212ed" + integrity sha512-UrZSsYsSAf2w4zaisY7hx0KoIODZaboIk3jB0bvrjBm5uKs08ZEL6MTOkRINwKyDsuqr1mq+TUmGYxl+cumIRw== + dependencies: + "@noble/curves" "2.2.0" + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + "@scure/btc-signer" "2.2.0" + "@stacks/common" "^7.6.0" + "@stacks/encryption" "^7.6.0" + "@stacks/network" "^7.6.0" + "@stacks/transactions" "^7.6.0" + "@stacks/common@^1.2.2": version "1.2.2" resolved "https://registry.npmjs.org/@stacks/common/-/common-1.2.2.tgz" @@ -5556,6 +5620,25 @@ "@types/node" "^18.0.4" buffer "^6.0.3" +"@stacks/common@^7.6.0": + version "7.6.0" + resolved "https://registry.npmjs.org/@stacks/common/-/common-7.6.0.tgz#65b756f0f19ce64a7ebd9ffe05bbb43bd9562ee6" + integrity sha512-VUW8WDmZP4wXUNWVvpFerV+7LQ0PqIn8YOs8AjSUMA7ZOXIRLxJHoKxjfiHzJqazy7KlyryPW4kSNBCr7tLqhQ== + +"@stacks/encryption@^7.6.0": + version "7.6.0" + resolved "https://registry.npmjs.org/@stacks/encryption/-/encryption-7.6.0.tgz#da3cc62b5f1c98209ae0bc1442810e4870b59dd8" + integrity sha512-1D2yazEcC5blc/xZkCPXQRbiAozXiVisLdxdFF3bVvNocUZ7yWkW7SOhhNve8RJ0Yf0XBkG6IbnBa3F1vyOyXA== + dependencies: + "@noble/hashes" "1.1.5" + "@noble/secp256k1" "1.7.1" + "@scure/bip39" "1.1.0" + "@stacks/common" "^7.6.0" + base64-js "^1.5.1" + bs58 "^5.0.0" + ripemd160-min "^0.0.6" + varuint-bitcoin "^1.1.2" + "@stacks/network@^1.2.2": version "1.2.2" resolved "https://registry.npmjs.org/@stacks/network/-/network-1.2.2.tgz" @@ -5571,6 +5654,14 @@ "@stacks/common" "^4.3.5" cross-fetch "^3.1.5" +"@stacks/network@^7.6.0": + version "7.6.0" + resolved "https://registry.npmjs.org/@stacks/network/-/network-7.6.0.tgz#39dadc33e194cf77541c1bd9fdccaff596b719dc" + integrity sha512-Blm85nsEbvJoFIFaDp0QE9WMC0Hf+o3Yb0oIDwWu3PPgpVSdndb/bJQUeT6eJTs0ibS26A2E/5d5L0fb4Ff/lA== + dependencies: + "@stacks/common" "^7.6.0" + cross-fetch "^3.1.5" + "@stacks/transactions@2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@stacks/transactions/-/transactions-2.0.1.tgz" @@ -5593,6 +5684,18 @@ sha.js "^2.4.11" smart-buffer "^4.1.0" +"@stacks/transactions@^7.6.0": + version "7.6.0" + resolved "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.6.0.tgz#4113a50ce993e1e5fff7d2b791a6c9573644e765" + integrity sha512-s7F7eJtQVnZoB79j8pY9SLKhlvvdYZZD1NSDRWrFjzSJTDmxptL8c50S563WFja6KOhFOgg1jd+p0XWTxFAVyA== + dependencies: + "@noble/hashes" "1.1.5" + "@noble/secp256k1" "1.7.1" + "@stacks/common" "^7.6.0" + "@stacks/network" "^7.6.0" + c32check "^2.0.0" + lodash.clonedeep "^4.5.0" + "@substrate/connect-extension-protocol@^2.0.0": version "2.2.2" resolved "https://registry.npmjs.org/@substrate/connect-extension-protocol/-/connect-extension-protocol-2.2.2.tgz" @@ -7768,7 +7871,7 @@ base64-arraybuffer@^1.0.2: resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== -base64-js@*, base64-js@1.5.1, base64-js@^1.3.0, base64-js@^1.3.1: +base64-js@*, base64-js@1.5.1, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -8407,6 +8510,14 @@ c32check@^1.1.2: buffer "^5.6.0" cross-sha256 "^1.2.0" +c32check@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/c32check/-/c32check-2.0.0.tgz#b9365618b2fb135c0783d03f00605b7b0f90c659" + integrity sha512-rpwfAcS/CMqo0oCqDf3r9eeLgScRE3l/xHDCXhM3UyrfvIn7PrLq63uHh7yYbv8NzaZn5MVsVhIRpQ+5GZ5HyA== + dependencies: + "@noble/hashes" "^1.1.2" + base-x "^4.0.0" + cacache@^16.1.0: version "16.1.3" resolved "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz" @@ -14528,6 +14639,11 @@ lodash.camelcase@^4.3.0: resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz" integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + lodash.combinations@~18.9.19: version "18.9.19" resolved "https://registry.npmjs.org/lodash.combinations/-/lodash.combinations-18.9.19.tgz" @@ -15039,6 +15155,13 @@ micro-packed@~0.5.1: dependencies: "@scure/base" "~1.1.5" +micro-packed@~0.9.0: + version "0.9.0" + resolved "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz#59202d4c848b79b14769f066c33982fe4fcf1ac3" + integrity sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg== + dependencies: + "@scure/base" "~2.2.0" + micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz"