diff --git a/modules/utxo-descriptors/src/pox5/descriptor.ts b/modules/utxo-descriptors/src/pox5/descriptor.ts index 5496040575..146558b31e 100644 --- a/modules/utxo-descriptors/src/pox5/descriptor.ts +++ b/modules/utxo-descriptors/src/pox5/descriptor.ts @@ -57,7 +57,7 @@ function validateParams(params: Pox5LockupDescriptorParams): void { * 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 { +export function createPox5LockupDescriptor(params: Pox5LockupDescriptorParams): Descriptor { validateParams(params); const stakerKeys = params.stakerKeys.map((key, index) => asDescriptorKey(key, `stakerKeys[${index}]`)); const miniscript: ast.MiniscriptNode = { @@ -76,18 +76,17 @@ export function createPox5LockupDescriptor(params: Pox5LockupDescriptorParams): { multi: [2, ...stakerKeys] }, ], }; - return ast.formatNode({ wsh: miniscript }); + const descriptorString = ast.formatNode({ wsh: miniscript }); + return Descriptor.fromString(descriptorString, isBip32Triple(params.stakerKeys) ? 'derivable' : 'definite'); } /** 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.atDerivationIndex(derivationIndex).scriptPubkey()); } - return Buffer.from(Descriptor.fromString(descriptor, 'definite').scriptPubkey()); + return Buffer.from(descriptor.scriptPubkey()); } /** Derive the compressed staker keys needed to prepare a witness at an index. */ diff --git a/modules/utxo-descriptors/src/pox5/index.ts b/modules/utxo-descriptors/src/pox5/index.ts index f2bc6c47f3..412f53819e 100644 --- a/modules/utxo-descriptors/src/pox5/index.ts +++ b/modules/utxo-descriptors/src/pox5/index.ts @@ -1,2 +1,4 @@ export * from './descriptor'; export * from './parseDescriptor'; +export * from './input'; +export * from './validation'; diff --git a/modules/utxo-descriptors/src/pox5/input.ts b/modules/utxo-descriptors/src/pox5/input.ts new file mode 100644 index 0000000000..4032b3825f --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/input.ts @@ -0,0 +1,69 @@ +import { Descriptor, Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; + +import { Pox5DescriptorInfo, parsePox5LockupDescriptor } from './parseDescriptor'; + +type ResolvedPox5DescriptorInfo = Pox5DescriptorInfo & { + stakerKeys: [Buffer, Buffer, Buffer]; +}; + +export type Pox5DescriptorMatch = { + /** The concrete descriptor whose script matches the input. */ + descriptor: Descriptor; + /** The derivation index used for a wildcard descriptor, if any. */ + index: number | undefined; + info: ResolvedPox5DescriptorInfo; +}; + +/** A canonical PoX-5 descriptor match bound to a native PSBT input. */ +export type Pox5InputMatch = Pox5DescriptorMatch & { + inputIndex: number; +}; + +function getConcreteDescriptor(descriptor: Descriptor, index: number | undefined): Descriptor { + return index === undefined ? descriptor : descriptor.atDerivationIndex(index); +} + +/** + * Find and parse a canonical PoX-5 descriptor for a native PSBT input projection. + * + * Foreign root derivations are ignored by the shared native descriptor matcher. A + * match is returned only when all three staker keys can be resolved at the matched + * descriptor index. + */ +export function findPox5DescriptorForInput( + input: descriptorWallet.PsbtInput, + descriptors: descriptorWallet.DescriptorMap +): Pox5DescriptorMatch | undefined { + try { + const matched = descriptorWallet.findDescriptorForInput(input, descriptors); + if (!matched) { + return undefined; + } + const descriptor = getConcreteDescriptor(matched.descriptor, matched.index); + const info = parsePox5LockupDescriptor(descriptor); + if (!info?.stakerKeys) { + return undefined; + } + return { + descriptor, + index: matched.index, + info: { + ...info, + stakerKeys: info.stakerKeys, + }, + }; + } catch { + return undefined; + } +} + +/** Find and parse a canonical PoX-5 descriptor for one native PSBT input. */ +export function matchPox5Input( + psbt: Psbt, + inputIndex: number, + descriptors: descriptorWallet.DescriptorMap +): Pox5InputMatch | undefined { + const input = psbt.getInputs()[inputIndex]; + const match = input ? findPox5DescriptorForInput(input, descriptors) : undefined; + return match ? { ...match, inputIndex } : undefined; +} diff --git a/modules/utxo-descriptors/src/pox5/parseDescriptor.ts b/modules/utxo-descriptors/src/pox5/parseDescriptor.ts index 2623305a7f..90604e42dc 100644 --- a/modules/utxo-descriptors/src/pox5/parseDescriptor.ts +++ b/modules/utxo-descriptors/src/pox5/parseDescriptor.ts @@ -1,7 +1,8 @@ -import { BIP32, Descriptor, ast } from '@bitgo/wasm-utxo'; -import { Pattern, PatternMatcher } from '@bitgo/utxo-core/descriptor'; +import { BIP32, Descriptor, ast, descriptorWallet } from '@bitgo/wasm-utxo'; -export type ParsedPox5LockupDescriptor = { +type Pattern = descriptorWallet.Pattern; + +export type Pox5DescriptorInfo = { unlockHeight: number; stakerCommitment: Buffer; earlyExitKey: Buffer; @@ -10,6 +11,9 @@ export type ParsedPox5LockupDescriptor = { miniscriptNode: ast.MiniscriptNode; }; +/** @deprecated Use Pox5DescriptorInfo instead. */ +export type ParsedPox5LockupDescriptor = Pox5DescriptorInfo; + const COMPRESSED_KEY = /^(02|03)[0-9a-fA-F]{64}$/; const XPUB_WITH_INDEX = /^([1-9A-HJ-NP-Za-km-z]+)\/(\d+)$/; @@ -53,17 +57,15 @@ function resolveStakerKey(value: string): Buffer | undefined { /** * Parse only the canonical PoX-5 descriptor template. Other descriptors return - * null; malformed fields within the template throw so callers cannot finalize + * undefined; 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(); +export function parsePox5LockupDescriptor(descriptor: Descriptor | ast.DescriptorNode): Pox5DescriptorInfo | undefined { + const matcher = new descriptorWallet.PatternMatcher(); const descriptorNode = descriptor instanceof Descriptor ? ast.fromDescriptor(descriptor) : descriptor; const matched = matcher.match(descriptorNode, { wsh: { $var: 'miniscript' } }); if (!matched) { - return null; + return undefined; } const miniscriptNode = matched.miniscript as ast.MiniscriptNode; @@ -80,7 +82,7 @@ export function parsePox5LockupDescriptor( }; const fields = matcher.match(miniscriptNode, pattern); if (!fields) { - return null; + return undefined; } const unlockHeight = asNumber(fields.unlockHeight, 'after argument'); diff --git a/modules/utxo-descriptors/src/pox5/validation.ts b/modules/utxo-descriptors/src/pox5/validation.ts new file mode 100644 index 0000000000..1bc1eb765a --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/validation.ts @@ -0,0 +1,94 @@ +import { createHash } from 'crypto'; + +import { Psbt, type Descriptor, type PsbtInputKeyValue } from '@bitgo/wasm-utxo'; + +import { Pox5DescriptorInfo, parsePox5LockupDescriptor } from './parseDescriptor'; + +const SHA256_INPUT_KEY = 'PSBT_IN_SHA256'; + +type Sha256InputKeyValue = Extract; + +function isSha256InputKeyValue(keyValue: PsbtInputKeyValue): keyValue is Sha256InputKeyValue { + return keyValue.type === 'known' && keyValue.key === SHA256_INPUT_KEY; +} + +/** + * Read the principal preimage committed by a canonical PoX-5 descriptor from a + * native PSBT input. The PSBT_IN_SHA256 key data is the descriptor commitment. + */ +export function getPox5PrincipalPreimage( + psbt: Psbt, + inputIndex: number, + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode +): Buffer { + const info = getPox5DescriptorInfo(descriptor); + const records = psbt + .getInputKeyValues(inputIndex) + .filter(isSha256InputKeyValue) + .filter((record) => Buffer.from(record.keyData).equals(info.stakerCommitment)); + if (records.length !== 1) { + throw new Error( + `expected exactly one ${SHA256_INPUT_KEY} record matching the descriptor commitment, found ${records.length}` + ); + } + + const [record] = records; + if (record.keyData.length !== 32) { + throw new Error(`${SHA256_INPUT_KEY} digest must be 32 bytes`); + } + if (record.value.length !== 32) { + throw new Error(`${SHA256_INPUT_KEY} preimage must be 32 bytes`); + } + + const preimage = Buffer.from(record.value); + const digest = createHash('sha256').update(preimage).digest(); + if (!digest.equals(Buffer.from(record.keyData))) { + throw new Error(`${SHA256_INPUT_KEY} digest does not match its preimage`); + } + return preimage; +} + +function isPox5DescriptorInfo(value: unknown): value is Pox5DescriptorInfo { + return ( + value !== null && + typeof value === 'object' && + 'stakerCommitment' in value && + Buffer.isBuffer(value.stakerCommitment) + ); +} + +function getPox5DescriptorInfo( + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode +): Pox5DescriptorInfo { + if (isPox5DescriptorInfo(descriptor)) { + return descriptor; + } + const info = parsePox5LockupDescriptor(descriptor); + if (!info) { + throw new Error('descriptor is not a canonical PoX-5 lockup descriptor'); + } + return info; +} + +/** Verify that a principal preimage is committed by a canonical PoX-5 descriptor. */ +export function assertPox5PrincipalPreimage( + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode, + principalPreimage: Uint8Array +): void { + if (principalPreimage.length !== 32) { + throw new Error('principalPreimage must be 32 bytes'); + } + const info = getPox5DescriptorInfo(descriptor); + const digest = createHash('sha256').update(principalPreimage).digest(); + if (!digest.equals(info.stakerCommitment)) { + throw new Error('principalPreimage does not match the descriptor stakerCommitment'); + } +} + +/** Alias for callers that prefer validation terminology. */ +export function validatePox5PrincipalPreimage( + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode, + principalPreimage: Uint8Array +): void { + assertPox5PrincipalPreimage(descriptor, principalPreimage); +} diff --git a/modules/utxo-descriptors/test/unit/pox5/descriptor.ts b/modules/utxo-descriptors/test/unit/pox5/descriptor.ts index dadb181cf9..f72db220a7 100644 --- a/modules/utxo-descriptors/test/unit/pox5/descriptor.ts +++ b/modules/utxo-descriptors/test/unit/pox5/descriptor.ts @@ -93,8 +93,7 @@ describe('PoX-5 lockup descriptors', function () { 0 ); const definite = { ...derivable, stakerKeys }; - const descriptorString = createPox5LockupDescriptor(definite); - const descriptor = Descriptor.fromString(descriptorString, 'definite'); + const descriptor = createPox5LockupDescriptor(definite); const localWitnessScript = asmToScript(descriptor.toAsmString()); const unlockBytes = encodeTwoOfThreeUnlock(stakerKeys); const earlyUnlockBytes = buildUnlockScript(definite.earlyExitKey); @@ -137,7 +136,7 @@ describe('PoX-5 lockup descriptors', function () { it('supports derivation and preserves wildcard keys until an index is selected', function () { const value = params(); - const descriptor = Descriptor.fromString(createPox5LockupDescriptor(value), 'derivable'); + const descriptor = createPox5LockupDescriptor(value); const wildcard = parsePox5LockupDescriptor(descriptor); const derived = parsePox5LockupDescriptor(descriptor.atDerivationIndex(4)); @@ -162,6 +161,6 @@ describe('PoX-5 lockup descriptors', function () { ) ); const validKey = params().earlyExitKey.toString('hex'); - assert.strictEqual(parsePox5LockupDescriptor(Descriptor.fromString(`wsh(pk(${validKey}))`, 'definite')), null); + assert.strictEqual(parsePox5LockupDescriptor(Descriptor.fromString(`wsh(pk(${validKey}))`, 'definite')), undefined); }); }); diff --git a/modules/utxo-descriptors/test/unit/pox5/input.ts b/modules/utxo-descriptors/test/unit/pox5/input.ts new file mode 100644 index 0000000000..e716a98a3a --- /dev/null +++ b/modules/utxo-descriptors/test/unit/pox5/input.ts @@ -0,0 +1,105 @@ +import * as assert from 'assert'; +import { createHash } from 'crypto'; + +import { bip32, descriptorWallet, Psbt } from '@bitgo/wasm-utxo'; +import { getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { + createPox5LockupDescriptor, + derivePox5StakerKeys, + findPox5DescriptorForInput, + matchPox5Input, + Pox5LockupDescriptorParams, +} from '../../../src/pox5'; + +const UNLOCK_HEIGHT = 840_000; +type BIP32Interface = bip32.BIP32Interface; +type Pox5Bip32Params = Omit & { + stakerKeys: [BIP32Interface, BIP32Interface, BIP32Interface]; +}; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function getParams(): Pox5Bip32Params { + const stakerKeys = getKeyTriple('utxo-descriptors-pox5'); + return { + unlockHeight: UNLOCK_HEIGHT, + stakerCommitment: sha256(Buffer.alloc(32, 0x42)), + earlyExitKey: Buffer.from(stakerKeys[0].derive(9).publicKey), + stakerKeys, + }; +} + +function createInputPsbt(descriptor: ReturnType): Psbt { + return descriptorWallet.createPsbt( + { version: 2, locktime: 0 }, + [ + { + hash: '01'.repeat(32), + index: 0, + witnessUtxo: { script: descriptor.scriptPubkey(), value: 100_000n }, + descriptor, + }, + ], + [] + ); +} + +describe('PoX-5 input resolution', function () { + it('matches a definite descriptor without derivation metadata', function () { + const descriptor = createPox5LockupDescriptor({ + ...getParams(), + stakerKeys: derivePox5StakerKeys(getParams().stakerKeys, 0), + }); + const psbt = Psbt.create(2, 0); + psbt.addInput('01'.repeat(32), 0, 100_000n, descriptor.scriptPubkey()); + const match = matchPox5Input(psbt, 0, new Map([['lockup', descriptor]])); + + assert.ok(match); + assert.strictEqual(match.index, undefined); + assert.strictEqual(match.descriptor.toString(), descriptor.toString()); + assert.deepStrictEqual(match.info.stakerKeys, derivePox5StakerKeys(getParams().stakerKeys, 0)); + }); + + it('matches a derived descriptor while ignoring foreign root derivations', function () { + const params = getParams(); + const descriptor = createPox5LockupDescriptor(params); + const concreteDescriptor = descriptor.atDerivationIndex(4); + const psbt = createInputPsbt(concreteDescriptor); + const match = matchPox5Input(psbt, 0, new Map([['lockup', descriptor]])); + + assert.ok(match); + assert.strictEqual(match.index, 4); + assert.strictEqual(match.descriptor.toString(), concreteDescriptor.toString()); + assert.deepStrictEqual(match.info.stakerKeys, derivePox5StakerKeys(params.stakerKeys, 4)); + assert.ok(psbt.getInputs()[0]?.bip32Derivation.some((derivation) => derivation.path === '')); + }); + + it('returns no match when the input has no usable derivation metadata', function () { + const params = getParams(); + const descriptor = createPox5LockupDescriptor(params); + const concreteDescriptor = descriptor.atDerivationIndex(4); + const input = { + witnessUtxo: { script: concreteDescriptor.scriptPubkey(), value: 100_000n }, + bip32Derivation: [], + tapBip32Derivation: [], + }; + + assert.strictEqual(findPox5DescriptorForInput(input, new Map([['lockup', descriptor]])), undefined); + }); + + it('returns no match for a noncanonical descriptor', function () { + const params = getParams(); + const pox5Descriptor = createPox5LockupDescriptor(params); + const psbt = Psbt.create(2, 0); + psbt.addInput('01'.repeat(32), 0, 100_000n, pox5Descriptor.atDerivationIndex(0).scriptPubkey()); + const nonPox5Descriptor = createPox5LockupDescriptor({ + ...params, + unlockHeight: UNLOCK_HEIGHT + 1, + }).atDerivationIndex(0); + + assert.strictEqual(matchPox5Input(psbt, 0, new Map([['other', nonPox5Descriptor]])), undefined); + }); +}); diff --git a/modules/utxo-descriptors/test/unit/pox5/validation.ts b/modules/utxo-descriptors/test/unit/pox5/validation.ts new file mode 100644 index 0000000000..f8ca2fa238 --- /dev/null +++ b/modules/utxo-descriptors/test/unit/pox5/validation.ts @@ -0,0 +1,82 @@ +import * as assert from 'assert'; +import { createHash } from 'crypto'; + +import { Psbt } from '@bitgo/wasm-utxo'; +import { getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { + assertPox5PrincipalPreimage, + createPox5LockupDescriptor, + getPox5PrincipalPreimage, + Pox5LockupDescriptorParams, + parsePox5LockupDescriptor, +} from '../../../src/pox5'; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function getParams(stakerCommitment: Buffer): Pox5LockupDescriptorParams { + const stakerKeys = getKeyTriple('utxo-descriptors-pox5-validation'); + return { + unlockHeight: 840_000, + stakerCommitment, + earlyExitKey: Buffer.from(stakerKeys[0].derive(9).publicKey), + stakerKeys: [ + Buffer.from(stakerKeys[0].publicKey), + Buffer.from(stakerKeys[1].publicKey), + Buffer.from(stakerKeys[2].publicKey), + ], + }; +} + +function createPsbt(): Psbt { + const psbt = Psbt.create(2, 0); + psbt.addInput('01'.repeat(32), 0, 100_000n, new Uint8Array(34)); + psbt.addOutput(new Uint8Array([0x6a]), 0n); + return psbt; +} + +describe('PoX-5 principal preimage validation', function () { + it('extracts and validates the descriptor-committed SHA256 record', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + const psbt = createPsbt(); + psbt.addSha256Preimage(0, preimage); + + assert.deepStrictEqual(getPox5PrincipalPreimage(psbt, 0, descriptor), preimage); + }); + + it('ignores unrelated SHA256 records and rejects a missing committed preimage', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + assert.throws(() => getPox5PrincipalPreimage(createPsbt(), 0, descriptor), /matching the descriptor commitment/); + + const psbt = createPsbt(); + psbt.addSha256Preimage(0, preimage); + psbt.addSha256Preimage(0, Buffer.alloc(32, 0x43)); + assert.deepStrictEqual(getPox5PrincipalPreimage(psbt, 0, descriptor), preimage); + + const unmatchedPsbt = createPsbt(); + unmatchedPsbt.addSha256Preimage(0, Buffer.alloc(32, 0x43)); + assert.throws(() => getPox5PrincipalPreimage(unmatchedPsbt, 0, descriptor), /matching the descriptor commitment/); + }); + + it('rejects malformed SHA256 digest and preimage values', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + const psbt = createPsbt(); + psbt.setInputKV(0, { type: 'unknown', keyType: 0x0b, data: sha256(preimage) }, new Uint8Array(31)); + assert.throws(() => getPox5PrincipalPreimage(psbt, 0, descriptor), /preimage must be 32 bytes/); + }); + + it('checks the principal preimage against the descriptor commitment', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + const info = parsePox5LockupDescriptor(descriptor); + assert.ok(info); + + assert.doesNotThrow(() => assertPox5PrincipalPreimage(info, preimage)); + assert.throws(() => assertPox5PrincipalPreimage(info, Buffer.alloc(32, 0x43)), /does not match/); + }); +}); diff --git a/modules/utxo-staking/src/pox5/index.ts b/modules/utxo-staking/src/pox5/index.ts index 7ba6e34eca..628fcf1d2e 100644 --- a/modules/utxo-staking/src/pox5/index.ts +++ b/modules/utxo-staking/src/pox5/index.ts @@ -1 +1,2 @@ export * from './witness'; +export * from './recovery'; diff --git a/modules/utxo-staking/src/pox5/recovery.ts b/modules/utxo-staking/src/pox5/recovery.ts new file mode 100644 index 0000000000..7f19547ac6 --- /dev/null +++ b/modules/utxo-staking/src/pox5/recovery.ts @@ -0,0 +1,74 @@ +import { Psbt, Transaction } from '@bitgo/wasm-utxo'; +import { pox5 } from '@bitgo/utxo-descriptors'; + +export const POX5_MAX_UNLOCK_HEIGHT = 500_000_000; + +function assertPox5UnlockHeight(unlockHeight: number): void { + if (!Number.isSafeInteger(unlockHeight) || unlockHeight <= 0 || unlockHeight >= POX5_MAX_UNLOCK_HEIGHT) { + throw new Error(`PoX-5 unlock height must be a positive block height below ${POX5_MAX_UNLOCK_HEIGHT}`); + } +} + +function assertPox5BlockHeightLocktime(lockTime: number): void { + if (!Number.isSafeInteger(lockTime) || lockTime < 0 || lockTime >= POX5_MAX_UNLOCK_HEIGHT) { + throw new Error(`PoX-5 nLockTime must be a block height below ${POX5_MAX_UNLOCK_HEIGHT}`); + } +} + +function getMatchedTransactionInput(psbt: Psbt, match: pox5.Pox5InputMatch) { + const psbtInput = psbt.getInputs()[match.inputIndex]; + const transactionInput = Transaction.fromBytes(psbt.getUnsignedTx()).getInputs()[match.inputIndex]; + const descriptorScript = Buffer.from(match.descriptor.scriptPubkey()); + if ( + !psbtInput?.witnessUtxo || + !transactionInput || + !Buffer.from(psbtInput.witnessUtxo.script).equals(descriptorScript) + ) { + throw new Error(`PoX-5 descriptor match does not match PSBT input ${match.inputIndex}`); + } + return transactionInput; +} + +/** Validate the post-CLTV policy for all canonical PoX-5 inputs in a recovery PSBT. */ +export function assertPox5LocktimeSpend(psbt: Psbt, inputs: readonly pox5.Pox5InputMatch[]): void { + if (inputs.length === 0) { + throw new Error('PoX-5 lockup descriptor match is required'); + } + + const unlockHeights = inputs.map((input) => { + const { unlockHeight } = input.info; + assertPox5UnlockHeight(unlockHeight); + return unlockHeight; + }); + const lockTime = psbt.lockTime(); + assertPox5BlockHeightLocktime(lockTime); + const requiredLockTime = Math.max(...unlockHeights); + if (lockTime < requiredLockTime) { + throw new Error(`PoX-5 nLockTime must be at least ${requiredLockTime}`); + } + for (const input of inputs) { + if (getMatchedTransactionInput(psbt, input).sequence === 0xffffffff) { + throw new Error(`PoX-5 locktime spend input ${input.inputIndex} must use a non-final sequence`); + } + } +} + +/** Validate that a canonical PoX-5 input can use the principal-preimage branch. */ +export function assertPox5EarlyExitSpend(psbt: Psbt, input: pox5.Pox5InputMatch): void { + getMatchedTransactionInput(psbt, input); +} + +/** Add validated principal-preimage metadata for an early-exit spend. */ +export function preparePox5EarlyExit( + psbt: Psbt, + inputIndex: number, + input: pox5.Pox5InputMatch, + principalPreimage: Uint8Array +): void { + if (inputIndex !== input.inputIndex) { + throw new Error(`PoX-5 descriptor match belongs to PSBT input ${input.inputIndex}, not ${inputIndex}`); + } + assertPox5EarlyExitSpend(psbt, input); + pox5.assertPox5PrincipalPreimage(input.info, principalPreimage); + psbt.addSha256Preimage(inputIndex, principalPreimage); +} diff --git a/modules/utxo-staking/src/pox5/witness.ts b/modules/utxo-staking/src/pox5/witness.ts index fcddb2a5fc..80bb3848d3 100644 --- a/modules/utxo-staking/src/pox5/witness.ts +++ b/modules/utxo-staking/src/pox5/witness.ts @@ -1,44 +1,24 @@ -import { createHash } from 'crypto'; - -import { ast, Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import { Psbt } from '@bitgo/wasm-utxo'; import { pox5 } from '@bitgo/utxo-descriptors'; -type Pox5Descriptor = Descriptor | ast.DescriptorNode; +import { assertPox5LocktimeSpend, preparePox5EarlyExit } from './recovery'; 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]; + /** The canonical descriptor match for the input being finalized. */ + match: pox5.Pox5InputMatch; }; -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'); +function assertFinalizerInputIndex(inputIndex: number, match: pox5.Pox5InputMatch): void { + if (inputIndex !== match.inputIndex) { + throw new Error(`PoX-5 descriptor match belongs to PSBT input ${match.inputIndex}, not ${inputIndex}`); } - 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}`); - } + assertFinalizerInputIndex(inputIndex, params.match); + assertPox5LocktimeSpend(psbt, [params.match]); + psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor); psbt.finalizeInput(inputIndex); } @@ -46,13 +26,10 @@ export function finalizePox5LocktimePath(psbt: Psbt, inputIndex: number, params: export function finalizePox5EarlyExitPath( psbt: Psbt, inputIndex: number, - params: Pox5FinalizerParams & { principalPreimage: Buffer } + params: Pox5FinalizerParams & { principalPreimage: Uint8Array } ): 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); + assertFinalizerInputIndex(inputIndex, params.match); + preparePox5EarlyExit(psbt, inputIndex, params.match, params.principalPreimage); + psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor); psbt.finalizeInput(inputIndex); } diff --git a/modules/utxo-staking/test/unit/pox5/recovery.ts b/modules/utxo-staking/test/unit/pox5/recovery.ts new file mode 100644 index 0000000000..5e4cf9ec58 --- /dev/null +++ b/modules/utxo-staking/test/unit/pox5/recovery.ts @@ -0,0 +1,112 @@ +import assert from 'assert/strict'; +import { createHash } from 'crypto'; + +import { pox5 } from '@bitgo/utxo-descriptors'; +import { Psbt, type Descriptor } from '@bitgo/wasm-utxo'; +import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { + assertPox5EarlyExitSpend, + assertPox5LocktimeSpend, + POX5_MAX_UNLOCK_HEIGHT, + preparePox5EarlyExit, +} from '../../../src/pox5'; + +type Pox5InputMatch = pox5.Pox5InputMatch; + +const UNLOCK_HEIGHT = 840_000; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function createPox5RecoveryPsbt( + lockTime: number, + sequence = 0xfffffffe, + unlockHeight = UNLOCK_HEIGHT +): { + psbt: Psbt; + match: Pox5InputMatch; + principalPreimage: Buffer; +} { + const [user, backup, bitgo] = getKeyTriple('utxo-staking-pox5-recovery'); + const earlyExit = getKey('utxo-staking-pox5-recovery-early-exit'); + const principalPreimage = Buffer.alloc(32, 0x42); + const descriptor = pox5.createPox5LockupDescriptor({ + unlockHeight, + stakerCommitment: sha256(principalPreimage), + earlyExitKey: Buffer.from(earlyExit.publicKey), + stakerKeys: [Buffer.from(user.publicKey), Buffer.from(backup.publicKey), Buffer.from(bitgo.publicKey)], + }); + const concreteDescriptor = descriptor as Descriptor; + const psbt = Psbt.create(2, lockTime); + psbt.addInput('01'.repeat(32), 0, 100_000n, concreteDescriptor.scriptPubkey(), sequence); + psbt.addOutput(concreteDescriptor.scriptPubkey(), 90_000n); + psbt.updateInputWithDescriptor(0, concreteDescriptor); + + const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', concreteDescriptor]])); + assert.ok(match); + return { psbt, match: match as Pox5InputMatch, principalPreimage }; +} + +describe('PoX-5 spend policy', function () { + it('allows the early-exit branch regardless of nLockTime', function () { + const locktimeSpend = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + const earlyExitSpend = createPox5RecoveryPsbt(0); + const timestampLocktime = createPox5RecoveryPsbt(POX5_MAX_UNLOCK_HEIGHT); + + assert.doesNotThrow(() => assertPox5EarlyExitSpend(locktimeSpend.psbt, locktimeSpend.match)); + assert.doesNotThrow(() => assertPox5EarlyExitSpend(earlyExitSpend.psbt, earlyExitSpend.match)); + assert.doesNotThrow(() => assertPox5EarlyExitSpend(timestampLocktime.psbt, timestampLocktime.match)); + }); + + it('enforces the block-height and unlock-height boundaries', function () { + const atHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + const aboveHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT + 1); + const belowHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT - 1); + const timestampLocktime = createPox5RecoveryPsbt(POX5_MAX_UNLOCK_HEIGHT); + + assert.doesNotThrow(() => assertPox5LocktimeSpend(atHeight.psbt, [atHeight.match])); + assert.doesNotThrow(() => assertPox5LocktimeSpend(aboveHeight.psbt, [aboveHeight.match])); + assert.throws(() => assertPox5LocktimeSpend(belowHeight.psbt, [belowHeight.match]), /at least/); + assert.throws( + () => assertPox5LocktimeSpend(timestampLocktime.psbt, [timestampLocktime.match]), + /block height below/ + ); + }); + + it('requires non-final sequences for locktime spends', function () { + const final = createPox5RecoveryPsbt(UNLOCK_HEIGHT, 0xffffffff); + const nonFinal = createPox5RecoveryPsbt(UNLOCK_HEIGHT, 0xfffffffe); + + assert.throws(() => assertPox5LocktimeSpend(final.psbt, [final.match]), /non-final sequence/); + assert.doesNotThrow(() => assertPox5LocktimeSpend(nonFinal.psbt, [nonFinal.match])); + }); + + it('allows final sequences on unrelated transaction inputs', function () { + const { psbt, match } = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + psbt.addInput('02'.repeat(32), 0, 10_000n, new Uint8Array([0x51]), 0xffffffff); + + assert.doesNotThrow(() => assertPox5LocktimeSpend(psbt, [match])); + }); + + it('requires an input match to remain bound to its original PSBT input', function () { + const { psbt, match, principalPreimage } = createPox5RecoveryPsbt(0); + + assert.throws(() => preparePox5EarlyExit(psbt, 1, match, principalPreimage), /belongs to PSBT input/); + }); + + it('adds a validated principal preimage through the native PSBT API', function () { + const earlyExitSpend = createPox5RecoveryPsbt(0); + + preparePox5EarlyExit(earlyExitSpend.psbt, 0, earlyExitSpend.match, earlyExitSpend.principalPreimage); + + const records = earlyExitSpend.psbt + .getInputKeyValues(0) + .filter((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); + assert.equal(records.length, 1); + const [record] = records; + assert.deepStrictEqual(Buffer.from(record.keyData), sha256(earlyExitSpend.principalPreimage)); + assert.deepStrictEqual(Buffer.from(record.value), earlyExitSpend.principalPreimage); + }); +}); diff --git a/modules/utxo-staking/test/unit/pox5/witness.ts b/modules/utxo-staking/test/unit/pox5/witness.ts index 28d89a78b3..4529b275b0 100644 --- a/modules/utxo-staking/test/unit/pox5/witness.ts +++ b/modules/utxo-staking/test/unit/pox5/witness.ts @@ -2,10 +2,12 @@ import * as assert from 'assert'; import { createHash } from 'crypto'; import { pox5 } from '@bitgo/utxo-descriptors'; -import { Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import { Psbt, type Descriptor } from '@bitgo/wasm-utxo'; import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; -import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, Pox5FinalizerParams } from '../../../src/pox5'; +import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, type Pox5FinalizerParams } from '../../../src/pox5'; + +type Pox5InputMatch = pox5.Pox5InputMatch; const UNLOCK_HEIGHT = 840_000; @@ -29,24 +31,22 @@ function createPox5Psbt( Buffer, Buffer ]; - const params: Pox5FinalizerParams = { - descriptor: Descriptor.fromString( - pox5.createPox5LockupDescriptor({ - unlockHeight: UNLOCK_HEIGHT, - stakerCommitment: sha256(principalPreimage), - earlyExitKey: Buffer.from(earlyExit.publicKey), - stakerKeys, - }), - 'definite' - ), + const descriptor = pox5.createPox5LockupDescriptor({ + unlockHeight: UNLOCK_HEIGHT, + stakerCommitment: sha256(principalPreimage), + earlyExitKey: Buffer.from(earlyExit.publicKey), stakerKeys, - }; - const descriptor = params.descriptor as Descriptor; - const scriptPubKey = descriptor.scriptPubkey(); + }); + const paramsDescriptor = descriptor as Descriptor; + const scriptPubKey = paramsDescriptor.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); + psbt.updateInputWithDescriptor(0, paramsDescriptor); + + const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', paramsDescriptor]])); + assert.ok(match); + const params: Pox5FinalizerParams = { match: match as Pox5InputMatch }; for (const key of includeEarlyExitSignature ? [user, backup, earlyExit] : [user, backup]) { assert.ok(key.privateKey, 'test key must include private key material');