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..aa487bde04 --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/input.ts @@ -0,0 +1,63 @@ +import { Descriptor, Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; + +import { Pox5DescriptorInfo, parsePox5LockupDescriptor } from './parseDescriptor'; + +type ResolvedPox5DescriptorInfo = Pox5DescriptorInfo & { + stakerKeys: [Buffer, Buffer, Buffer]; +}; + +export type Pox5InputMatch = { + /** 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; +}; + +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 +): Pox5InputMatch | 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]; + return input ? findPox5DescriptorForInput(input, descriptors) : 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..e11ba4f63d --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/validation.ts @@ -0,0 +1,84 @@ +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 and validate the unique PoX-5 principal preimage from a native PSBT input. + * The PSBT_IN_SHA256 key data is the digest and its value is the preimage. + */ +export function getPox5PrincipalPreimage(psbt: Psbt, inputIndex: number): Buffer { + const records = psbt.getInputKeyValues(inputIndex).filter(isSha256InputKeyValue); + if (records.length !== 1) { + throw new Error(`expected exactly one ${SHA256_INPUT_KEY} record, 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..926f62c423 --- /dev/null +++ b/modules/utxo-descriptors/test/unit/pox5/validation.ts @@ -0,0 +1,73 @@ +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 unique native SHA256 record', function () { + const preimage = Buffer.alloc(32, 0x42); + const psbt = createPsbt(); + psbt.addSha256Preimage(0, preimage); + + assert.deepStrictEqual(getPox5PrincipalPreimage(psbt, 0), preimage); + }); + + it('rejects missing and duplicate SHA256 records', function () { + assert.throws(() => getPox5PrincipalPreimage(createPsbt(), 0), /exactly one/); + + const psbt = createPsbt(); + psbt.addSha256Preimage(0, Buffer.alloc(32, 0x42)); + psbt.addSha256Preimage(0, Buffer.alloc(32, 0x43)); + assert.throws(() => getPox5PrincipalPreimage(psbt, 0), /exactly one/); + }); + + it('rejects malformed SHA256 digest and preimage values', function () { + const psbt = createPsbt(); + psbt.setInputKV(0, { type: 'unknown', keyType: 0x0b, data: new Uint8Array(32) }, new Uint8Array(31)); + assert.throws(() => getPox5PrincipalPreimage(psbt, 0), /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..403a412482 --- /dev/null +++ b/modules/utxo-staking/src/pox5/recovery.ts @@ -0,0 +1,80 @@ +import { Psbt, Transaction } from '@bitgo/wasm-utxo'; +import { pox5 } from '@bitgo/utxo-descriptors'; + +export const POX5_MAX_UNLOCK_HEIGHT = 500_000_000; + +export type Pox5SpendBranch = 'locktime' | 'early-exit'; + +export type Pox5SpendInput = pox5.Pox5InputMatch | pox5.Pox5DescriptorInfo; + +function getPox5DescriptorInfo(input: Pox5SpendInput): pox5.Pox5DescriptorInfo { + return 'info' in input ? input.info : input; +} + +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 hasFinalInput(psbt: Psbt): boolean { + return Transaction.fromBytes(psbt.getUnsignedTx()) + .getInputs() + .some((input) => input.sequence === 0xffffffff); +} + +/** Classify a canonical PoX-5 input by the transaction branch it can spend. */ +export function classifyPox5Spend(psbt: Psbt, input: pox5.Pox5InputMatch): Pox5SpendBranch { + const { unlockHeight } = input.info; + assertPox5UnlockHeight(unlockHeight); + const lockTime = psbt.lockTime(); + assertPox5BlockHeightLocktime(lockTime); + return lockTime >= unlockHeight ? 'locktime' : 'early-exit'; +} + +/** Validate the post-CLTV policy for all canonical PoX-5 inputs in a recovery PSBT. */ +export function assertPox5LocktimeSpend(psbt: Psbt, inputs: readonly Pox5SpendInput[]): void { + if (inputs.length === 0) { + throw new Error('PoX-5 lockup descriptor match is required'); + } + + const unlockHeights = inputs.map((input) => { + const { unlockHeight } = getPox5DescriptorInfo(input); + 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}`); + } + if (hasFinalInput(psbt)) { + throw new Error('PoX-5 locktime spend inputs must use non-final sequences'); + } +} + +/** Validate that a canonical PoX-5 input uses the principal-preimage branch. */ +export function assertPox5EarlyExitSpend(psbt: Psbt, input: pox5.Pox5InputMatch): void { + if (classifyPox5Spend(psbt, input) !== 'early-exit') { + throw new Error('PoX-5 input is not an early-exit spend'); + } +} + +/** Add validated principal-preimage metadata for an early-exit spend. */ +export function preparePox5EarlyExit( + psbt: Psbt, + inputIndex: number, + input: pox5.Pox5InputMatch, + principalPreimage: Uint8Array +): void { + 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..ee02fa98fb 100644 --- a/modules/utxo-staking/src/pox5/witness.ts +++ b/modules/utxo-staking/src/pox5/witness.ts @@ -1,44 +1,17 @@ -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 { assertPox5EarlyExitSpend, 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'); - } - 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}`); - } + assertPox5LocktimeSpend(psbt, [params.match]); + psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor); psbt.finalizeInput(inputIndex); } @@ -46,13 +19,11 @@ 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); + assertPox5EarlyExitSpend(psbt, params.match); + pox5.assertPox5PrincipalPreimage(params.match.info, params.principalPreimage); + 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..39a565918f --- /dev/null +++ b/modules/utxo-staking/test/unit/pox5/recovery.ts @@ -0,0 +1,101 @@ +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, + classifyPox5Spend, + 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('classifies locktime and early-exit branches from native transaction data', function () { + const locktimeSpend = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + const earlyExitSpend = createPox5RecoveryPsbt(0); + + assert.equal(classifyPox5Spend(locktimeSpend.psbt, locktimeSpend.match), 'locktime'); + assert.equal(classifyPox5Spend(earlyExitSpend.psbt, earlyExitSpend.match), 'early-exit'); + assert.doesNotThrow(() => assertPox5LocktimeSpend(locktimeSpend.psbt, [locktimeSpend.match])); + assert.doesNotThrow(() => assertPox5EarlyExitSpend(earlyExitSpend.psbt, earlyExitSpend.match)); + assert.throws(() => assertPox5EarlyExitSpend(locktimeSpend.psbt, locktimeSpend.match), /not an early-exit spend/); + }); + + 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 sequences/); + assert.doesNotThrow(() => assertPox5LocktimeSpend(nonFinal.psbt, [nonFinal.match])); + }); + + 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');