diff --git a/modules/abstract-utxo/src/descriptor/validatePolicy.ts b/modules/abstract-utxo/src/descriptor/validatePolicy.ts index 1d3d01e714d..dc0d5302de4 100644 --- a/modules/abstract-utxo/src/descriptor/validatePolicy.ts +++ b/modules/abstract-utxo/src/descriptor/validatePolicy.ts @@ -1,7 +1,8 @@ import { EnvironmentName, Triple } from '@bitgo/sdk-core'; -import { bip32, descriptorWallet } from '@bitgo/wasm-utxo'; +import { pox5 } from '@bitgo/utxo-descriptors'; +import { bip32, Descriptor, descriptorWallet } from '@bitgo/wasm-utxo'; -import { parseDescriptor } from './builder'; +import { DescriptorBuilder, parseDescriptor } from './builder'; import { hasValidSignature, NamedDescriptor, NamedDescriptorNative, toNamedDescriptorNative } from './NamedDescriptor'; import type { DescriptorMap } from './index'; @@ -19,13 +20,23 @@ export const policyAllowAll: DescriptorValidationPolicy = { validate: () => true, }; +function tryParseDescriptor(descriptor: Descriptor): DescriptorBuilder | undefined { + try { + return parseDescriptor(descriptor); + } catch { + // not a descriptor we know how to parse: it cannot match any template + return undefined; + } +} + export function getValidatorDescriptorTemplate(name: string): DescriptorValidationPolicy { return { name: 'descriptorTemplate(' + name + ')', validate(arr: NamedDescriptorNative[], walletKeys: KeyTriple): boolean { return arr.every((d) => { - const parsed = parseDescriptor(d.value); + const parsed = tryParseDescriptor(d.value); return ( + parsed !== undefined && parsed.name === name && parsed.keys.length === walletKeys.length && parsed.keys.every((k, i) => k.toBase58() === walletKeys[i].neutered().toBase58()) @@ -67,6 +78,62 @@ export function getValidatorSignedByUserKey(): DescriptorValidationPolicy { }; } +function isCanonicalPox5Lockup(descriptor: Descriptor, walletKeys: KeyTriple): boolean { + try { + const info = pox5.parsePox5LockupDescriptor(descriptor); + if (!info) { + return false; + } + // Rebuilding the canonical descriptor from the wallet keys checks both that the + // staker keys are the wallet keys (with the `/*` derivation) and that the + // descriptor has no non-canonical encoding. + const canonical = pox5.createPox5LockupDescriptor({ + unlockHeight: info.unlockHeight, + stakerCommitment: info.stakerCommitment, + earlyExitKey: info.earlyExitKey, + stakerKeys: [walletKeys[0], walletKeys[1], walletKeys[2]], + }); + return canonical.toString() === descriptor.toString(); + } catch { + // malformed lockup parameters + return false; + } +} + +/** + * Accepts descriptors that are canonical PoX-5 lockup descriptors whose staker keys + * are the wallet keys. + */ +export function getValidatorPox5Lockup(): DescriptorValidationPolicy { + return { + name: 'pox5Lockup', + validate(arr: NamedDescriptorNative[], walletKeys: KeyTriple): boolean { + return arr.every((d) => isCanonicalPox5Lockup(d.value, walletKeys)); + }, + }; +} + +/** + * Accepts descriptor groups where each descriptor individually satisfies at least one of + * the validators. Unlike `getValidatorSome`, different descriptors may match different validators. + */ +export function getValidatorEachOneOf(validators: DescriptorValidationPolicy[]): DescriptorValidationPolicy { + return { + name: 'eachOneOf(' + validators.map((v) => v.name).join(',') + ')', + validate(arr: NamedDescriptorNative[], walletKeys: KeyTriple): boolean { + return arr.every((d) => validators.some((v) => v.validate([d], walletKeys))); + }, + }; +} + +/** + * Accepts the descriptors of a PoX-5 staking wallet: the regular 2-of-3 wallet descriptors + * together with PoX-5 lockup descriptors, all using the wallet keys. + */ +export function getValidatorPox5Wallet(): DescriptorValidationPolicy { + return getValidatorEachOneOf([getValidatorDescriptorTemplate('Wsh2Of3'), getValidatorPox5Lockup()]); +} + export class DescriptorPolicyValidationError extends Error { constructor(ds: NamedDescriptorNative[], policy: DescriptorValidationPolicy) { super(`Descriptors ${ds.map((d) => d.value.toString())} does not match policy ${policy.name}`); @@ -106,6 +173,8 @@ export function getPolicyForEnv(env: EnvironmentName): DescriptorValidationPolic getValidatorDescriptorTemplate('Wsh2Of3CltvDrop'), // allow sBTC peg-in deposit descriptors where the reclaim keys match the wallet keys getValidatorDescriptorTemplate('SbtcDeposit'), + // allow 2-of-3 wallet descriptors together with PoX-5 lockup descriptors (stacks staking) + getValidatorPox5Wallet(), // allow all descriptors signed by the user key getValidatorSignedByUserKey(), ]); diff --git a/modules/abstract-utxo/src/impl/btc/tbtcstx.ts b/modules/abstract-utxo/src/impl/btc/tbtcstx.ts index a8a92bcbef2..8acf7f5006a 100644 --- a/modules/abstract-utxo/src/impl/btc/tbtcstx.ts +++ b/modules/abstract-utxo/src/impl/btc/tbtcstx.ts @@ -1,6 +1,6 @@ import { BitGoBase } from '@bitgo/sdk-core'; -import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; +import { UtxoCoinName } from '../../names'; import { Tbtc } from './tbtc'; @@ -14,8 +14,4 @@ export class Tbtcstx extends Tbtc { static createInstance(bitgo: BitGoBase): Tbtcstx { return new Tbtcstx(bitgo); } - - override get wasmName(): WasmUtxoCoinName { - return 'tbtcreg'; - } } diff --git a/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts b/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts index 8b41370c12d..cdb83b65824 100644 --- a/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts +++ b/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts @@ -1,6 +1,6 @@ import { BitGoBase } from '@bitgo/sdk-core'; -import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; +import { UtxoCoinName } from '../../names'; import { Tbtc } from './tbtc'; @@ -14,8 +14,4 @@ export class Tbtcstxsignet extends Tbtc { static createInstance(bitgo: BitGoBase): Tbtcstxsignet { return new Tbtcstxsignet(bitgo); } - - override get wasmName(): WasmUtxoCoinName { - return 'tbtcsig'; - } } diff --git a/modules/abstract-utxo/src/names.ts b/modules/abstract-utxo/src/names.ts index b6b564e34cb..7d808438b0f 100644 --- a/modules/abstract-utxo/src/names.ts +++ b/modules/abstract-utxo/src/names.ts @@ -33,7 +33,15 @@ export type UtxoCoinNameTestnet = | 'tbtcstxsignet'; export type UtxoCoinName = UtxoCoinNameMainnet | UtxoCoinNameTestnet; +/** Coin name used by wasm-utxo. Private Bitcoin networks map to a shared codec. */ export function toWasmUtxoCoinName(coinName: UtxoCoinName | WasmUtxoCoinName): WasmUtxoCoinName { + switch (coinName) { + case 'tbtcstx': + case 'tbtcstxprivate1': + return 'tbtcreg'; + case 'tbtcstxsignet': + return 'tbtcsig'; + } if (!isCoinName(coinName)) { throw new Error(`coin ${coinName} is not supported by wasm-utxo`); } diff --git a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts index 2b5daa4fb3b..11f1452a0be 100644 --- a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts +++ b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts @@ -11,6 +11,8 @@ export interface ExplanationOutput { export interface TransactionExplanation { outputs: ExplanationOutput[]; changeOutputs: ExplanationOutput[]; + /* nLockTime of the transaction, to be verified by the signer for time-locked spends */ + locktime: number; fee: { /* network fee */ fee: TFee; diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/pox5.ts b/modules/abstract-utxo/src/offlineVault/descriptor/pox5.ts new file mode 100644 index 00000000000..03fb367b490 --- /dev/null +++ b/modules/abstract-utxo/src/offlineVault/descriptor/pox5.ts @@ -0,0 +1,30 @@ +import { pox5 } from '@bitgo/utxo-descriptors'; +import { descriptorWallet, Psbt } from '@bitgo/wasm-utxo'; + +function getPox5InputMatches(psbt: Psbt, descriptorMap: descriptorWallet.DescriptorMap): pox5.Pox5InputMatch[] { + return Array.from({ length: psbt.inputCount() }, (_, i) => pox5.matchPox5Input(psbt, i, descriptorMap)).filter( + (match): match is pox5.Pox5InputMatch => match !== undefined + ); +} + +/** + * Apply the PoX-5 staking spend policy to a PSBT before it is signed. + * + * PSBTs without PoX-5 lockup inputs are not affected. Otherwise, the PSBT must spend exactly one lockup + * input through the post-CLTV (late exit) branch, with nLockTime at or above the unlock height and a non-final sequence. + */ +export function assertPox5SpendPolicy(psbt: Psbt, descriptorMap: descriptorWallet.DescriptorMap): void { + const matches = getPox5InputMatches(psbt, descriptorMap); + if (matches.length === 0) { + return; + } + if (matches.length !== 1) { + throw new Error(`expected exactly one PoX-5 lockup input, found ${matches.length}`); + } + const [match] = matches; + const branch = pox5.classifyPox5Spend(psbt, match); + if (branch !== 'locktime') { + throw new Error(`unsupported PoX-5 spend branch: ${branch}`); + } + pox5.assertPox5LocktimeSpend(psbt, matches); +} diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts index ae4f17768f8..af6bf889c03 100644 --- a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts +++ b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts @@ -5,6 +5,7 @@ import { DescriptorMap, NamedDescriptor } from '../../descriptor'; import { OfflineVaultSignable, toKeyTriple } from '../OfflineVaultSignable'; import { getValidatorOneOfTemplates, + getValidatorPox5Wallet, getValidatorSignedByUserKey, getValidatorSome, toDescriptorMapValidate, @@ -13,6 +14,8 @@ import { explainPsbt, signPsbt } from '../../transaction/descriptor'; import { TransactionExplanation } from '../TransactionExplanation'; import { UtxoCoinName } from '../../names'; +import { assertPox5SpendPolicy } from './pox5'; + export const DescriptorTransaction = t.intersection( [OfflineVaultSignable, t.type({ descriptors: t.array(NamedDescriptor) })], 'DescriptorTransaction' @@ -26,6 +29,8 @@ export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransactio const policy = getValidatorSome([ // allow all 2-of-3-ish descriptors where the keys match the wallet keys getValidatorOneOfTemplates(['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']), + // allow 2-of-3 wallet descriptors together with PoX-5 lockup descriptors where the keys match the wallet keys + getValidatorPox5Wallet(), // allow all descriptors signed by the user key getValidatorSignedByUserKey(), ]); @@ -35,6 +40,7 @@ export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransactio export function getHalfSignedPsbt(tx: DescriptorTransaction, prv: bip32.BIP32Interface, coinName: UtxoCoinName): Psbt { const psbt = Psbt.deserialize(Buffer.from(tx.coinSpecific.txHex, 'hex')); const descriptorMap = getDescriptorsFromDescriptorTransaction(tx); + assertPox5SpendPolicy(psbt, descriptorMap); signPsbt(psbt, descriptorMap, prv, { onUnknownInput: 'throw' }); return psbt; } @@ -49,6 +55,7 @@ export function getTransactionExplanationFromPsbt( return { outputs, changeOutputs, + locktime: psbt.lockTime(), fee: { /* network fee */ fee, diff --git a/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json b/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json index 0fe5e59f98d..69b12347adb 100644 --- a/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json +++ b/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json @@ -14,6 +14,7 @@ "amount": "196995579" } ], + "locktime": 0, "fee": { "fee": "4421" } diff --git a/modules/abstract-utxo/test/unit/offlineVault/pox5.ts b/modules/abstract-utxo/test/unit/offlineVault/pox5.ts new file mode 100644 index 00000000000..ff27d88a2de --- /dev/null +++ b/modules/abstract-utxo/test/unit/offlineVault/pox5.ts @@ -0,0 +1,264 @@ +import assert from 'assert'; +import { createHash } from 'crypto'; + +import { pox5 } from '@bitgo/utxo-descriptors'; +import { ast, BIP32, bip32, Descriptor, descriptorWallet, Psbt } from '@bitgo/wasm-utxo'; +import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { createHalfSigned, getTransactionExplanation } from '../../../src/offlineVault'; +import { DescriptorTransaction } from '../../../src/offlineVault/descriptor'; +import { getDescriptorFromBuilder } from '../../../src/descriptor/builder'; +import { + assertDescriptorPolicy, + getPolicyForEnv, + getValidatorDescriptorTemplate, +} from '../../../src/descriptor/validatePolicy'; +import { toNamedDescriptorNative } from '../../../src/descriptor'; +import { deriveKeyWithSeed } from '../../../src/deriveKeyWithSeed'; +import { UtxoCoinName } from '../../../src/names'; + +const UNLOCK_HEIGHT = 840_000; +const DERIVATION_ID = 'pox5-test-seed'; +const INPUT_VALUE = 100_000n; +const CHANGE_VALUE = 60_000n; +const EXTERNAL_VALUE = 30_000n; + +const coinNames: UtxoCoinName[] = ['btc', 'tbtcstxsignet']; + +type KeyTriple = [BIP32, BIP32, BIP32]; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function getWalletKeys(): { rootPrv: BIP32; keys: KeyTriple } { + const rootPrv = getKey('pox5-offline-vault-root'); + const userPrv = deriveKeyWithSeed(rootPrv, DERIVATION_ID).key; + const [, backup, bitgo] = getKeyTriple('pox5-offline-vault'); + return { rootPrv, keys: [userPrv.neutered(), backup.neutered(), bitgo.neutered()] }; +} + +function getForeignKeys(): KeyTriple { + const [a, b, c] = getKeyTriple('pox5-offline-vault-foreign'); + return [a.neutered(), b.neutered(), c.neutered()]; +} + +function createLockupDescriptor(keys: KeyTriple, unlockHeight = UNLOCK_HEIGHT): Descriptor { + return pox5.createPox5LockupDescriptor({ + unlockHeight, + stakerCommitment: sha256(Buffer.alloc(32, 0x42)), + earlyExitKey: Buffer.from(getKey('pox5-offline-vault-early-exit').publicKey), + stakerKeys: keys, + }); +} + +type Pox5TxParams = { + /** keys used in the lockup descriptor, defaults to the wallet keys */ + lockupKeys?: KeyTriple; + lockupDescriptor?: Descriptor; + locktime?: number; + sequence?: number; + lockupInputs?: number; +}; + +function createPox5Tx(params: Pox5TxParams = {}): { tx: DescriptorTransaction; rootPrv: BIP32 } { + const { rootPrv, keys } = getWalletKeys(); + const external = getDescriptorFromBuilder({ name: 'Wsh2Of3', keys, path: '0/*' }); + const internal = getDescriptorFromBuilder({ name: 'Wsh2Of3', keys, path: '1/*' }); + const lockup = params.lockupDescriptor ?? createLockupDescriptor(params.lockupKeys ?? keys); + const lockupAtIndex = lockup.atDerivationIndex(0); + + const inputs = Array.from({ length: params.lockupInputs ?? 1 }, (_, i) => ({ + hash: Buffer.alloc(32, i + 1).toString('hex'), + index: 0, + witnessUtxo: { script: lockupAtIndex.scriptPubkey(), value: INPUT_VALUE }, + descriptor: lockupAtIndex, + sequence: params.sequence ?? 0xfffffffe, + })); + const psbt = descriptorWallet.createPsbt({ version: 2, locktime: params.locktime ?? UNLOCK_HEIGHT }, inputs, [ + { script: external.atDerivationIndex(7).scriptPubkey(), value: EXTERNAL_VALUE }, + { + script: internal.atDerivationIndex(3).scriptPubkey(), + value: CHANGE_VALUE, + descriptor: internal.atDerivationIndex(3), + }, + ]); + + const tx: DescriptorTransaction = { + xpubsWithDerivationPath: { + user: { xpub: keys[0].toBase58(), derivedFromParentWithSeed: DERIVATION_ID }, + backup: { xpub: keys[1].toBase58() }, + bitgo: { xpub: keys[2].toBase58() }, + }, + coinSpecific: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, + descriptors: [ + { name: 'external', value: external.toString() }, + { name: 'internal', value: internal.toString() }, + { name: `pox5-lockup-u${UNLOCK_HEIGHT}-test`, value: lockup.toString() }, + ], + }; + return { tx, rootPrv }; +} + +function halfSign(coinName: UtxoCoinName, { tx, rootPrv }: { tx: DescriptorTransaction; rootPrv: BIP32 }): Psbt { + const { halfSigned } = createHalfSigned(coinName, rootPrv, DERIVATION_ID, tx); + return Psbt.deserialize(Buffer.from(halfSigned.txHex, 'hex')); +} + +describe('OfflineVault PoX-5 lockup descriptor', function () { + for (const coinName of coinNames) { + describe(coinName, function () { + it('explains a late exit including nLockTime', function () { + const { tx } = createPox5Tx(); + const explanation = getTransactionExplanation(coinName, tx); + assert.strictEqual(explanation.locktime, UNLOCK_HEIGHT); + assert.strictEqual(explanation.outputs.length, 1); + assert.strictEqual(explanation.outputs[0].amount, EXTERNAL_VALUE.toString()); + assert.strictEqual(explanation.changeOutputs.length, 1); + assert.strictEqual(explanation.changeOutputs[0].amount, CHANGE_VALUE.toString()); + assert.strictEqual(explanation.fee.fee, (INPUT_VALUE - EXTERNAL_VALUE - CHANGE_VALUE).toString()); + }); + + it('half-signs a late exit', function () { + const signed = halfSign(coinName, createPox5Tx()); + assert.ok(signed.hasPartialSignatures(0)); + const sigs = signed.getPartialSignatures(0); + assert.strictEqual(sigs.length, 1); + assert.ok(signed.validateSignatureAtInput(0, sigs[0].pubkey)); + }); + + it('half-signs a late exit with nLockTime above the unlock height', function () { + halfSign(coinName, createPox5Tx({ locktime: UNLOCK_HEIGHT + 100 })); + }); + + describe('rejects', function () { + function assertRejected(params: Pox5TxParams, message: RegExp) { + const tx = createPox5Tx(params); + assert.throws(() => halfSign(coinName, tx), message); + } + + it('lockup descriptors with foreign staker keys', function () { + const params = { lockupKeys: getForeignKeys() }; + assert.throws(() => getTransactionExplanation(coinName, createPox5Tx(params).tx), /does not match policy/); + assertRejected(params, /does not match policy/); + }); + + it('lockup descriptors with only one foreign staker key', function () { + const { keys } = getWalletKeys(); + const [foreign] = getForeignKeys(); + assertRejected({ lockupKeys: [keys[0], keys[1], foreign] }, /does not match policy/); + }); + + it('lockup descriptors with permuted staker keys', function () { + const { keys } = getWalletKeys(); + assertRejected({ lockupKeys: [keys[1], keys[0], keys[2]] }, /does not match policy/); + }); + + it('malformed lockup descriptors', function () { + const { keys } = getWalletKeys(); + // unlock height is outside of the block height range + const node = ast.fromDescriptor(createLockupDescriptor(keys)) as { + wsh: { and_v: [{ 'v:or_i': [{ after: number }, unknown] }, unknown] }; + }; + node.wsh.and_v[0]['v:or_i'][0] = { after: 600_000_000 }; + const malformed = Descriptor.fromString(ast.formatNode(node as ast.DescriptorNode), 'derivable'); + assertRejected({ lockupDescriptor: malformed }, /does not match policy/); + }); + + it('non-canonical lockup descriptors', function () { + const { keys } = getWalletKeys(); + const xpubs = keys.map((k) => k.toBase58() + '/*'); + // plain 2-of-3 with the early exit branch removed + const other = Descriptor.fromString(`wsh(and_v(v:pk(${xpubs[0]}),multi(2,${xpubs.join(',')})))`, 'derivable'); + assertRejected({ lockupDescriptor: other }, /does not match policy/); + }); + + it('nLockTime below the unlock height', function () { + assertRejected({ locktime: UNLOCK_HEIGHT - 1 }, /nLockTime must be at least/); + }); + + it('nLockTime that is a timestamp', function () { + assertRejected({ locktime: 500_000_000 }, /nLockTime must be a block height/); + }); + + it('final sequence on the lockup input', function () { + assertRejected({ sequence: 0xffffffff }, /non-final sequence/); + }); + + it('more than one lockup input', function () { + assertRejected({ lockupInputs: 2 }, /exactly one PoX-5 lockup input/); + }); + }); + + it('still explains and signs a regular Wsh2Of3 transaction', function () { + const { rootPrv, keys } = getWalletKeys(); + const external = getDescriptorFromBuilder({ name: 'Wsh2Of3', keys, path: '0/*' }); + const input = external.atDerivationIndex(1); + const psbt = descriptorWallet.createPsbt( + { version: 2, locktime: 0 }, + [ + { + hash: '01'.repeat(32), + index: 0, + witnessUtxo: { script: input.scriptPubkey(), value: INPUT_VALUE }, + descriptor: input, + }, + ], + [{ script: external.atDerivationIndex(2).scriptPubkey(), value: 90_000n }] + ); + const tx: DescriptorTransaction = { + xpubsWithDerivationPath: { + user: { xpub: keys[0].toBase58(), derivedFromParentWithSeed: DERIVATION_ID }, + backup: { xpub: keys[1].toBase58() }, + bitgo: { xpub: keys[2].toBase58() }, + }, + coinSpecific: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, + descriptors: [{ name: 'external', value: external.toString() }], + }; + assert.strictEqual(getTransactionExplanation(coinName, tx).locktime, 0); + assert.ok(halfSign(coinName, { tx, rootPrv }).hasPartialSignatures(0)); + }); + }); + } + + describe('getPolicyForEnv(prod)', function () { + function validate(descriptors: Descriptor[], keys: bip32.BIP32Interface[]) { + assertDescriptorPolicy( + descriptors.map((value, i) => + toNamedDescriptorNative({ name: String(i), value: value.toString() }, 'derivable') + ), + getPolicyForEnv('prod'), + [keys[0], keys[1], keys[2]] + ); + } + + it('accepts wallet descriptors together with the PoX-5 lockup descriptor', function () { + const { keys } = getWalletKeys(); + const external = getDescriptorFromBuilder({ name: 'Wsh2Of3', keys, path: '0/*' }); + validate([external, createLockupDescriptor(keys)], keys); + validate([createLockupDescriptor(keys, 900_000)], keys); + }); + + it('rejects a lockup descriptor with foreign keys', function () { + const { keys } = getWalletKeys(); + assert.throws(() => validate([createLockupDescriptor(getForeignKeys())], keys), /does not match policy/); + }); + + it('accepts Wsh2Of3CltvDrop descriptors', function () { + const { keys } = getWalletKeys(); + const cltvDrop = getDescriptorFromBuilder({ name: 'Wsh2Of3CltvDrop', keys, path: '0/*', locktime: 1_000 }); + validate([cltvDrop], keys); + }); + + it('returns false instead of throwing for descriptors a template cannot parse', function () { + const { keys } = getWalletKeys(); + const lockup = toNamedDescriptorNative( + { name: 'lockup', value: createLockupDescriptor(keys).toString() }, + 'derivable' + ); + for (const name of ['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop', 'SbtcDeposit']) { + assert.strictEqual(getValidatorDescriptorTemplate(name).validate([lockup], [keys[0], keys[1], keys[2]]), false); + } + }); + }); +}); diff --git a/modules/utxo-descriptors/src/pox5/index.ts b/modules/utxo-descriptors/src/pox5/index.ts index 412f53819e6..8859205a2c5 100644 --- a/modules/utxo-descriptors/src/pox5/index.ts +++ b/modules/utxo-descriptors/src/pox5/index.ts @@ -2,3 +2,4 @@ export * from './descriptor'; export * from './parseDescriptor'; export * from './input'; export * from './validation'; +export * from './recovery'; diff --git a/modules/utxo-descriptors/src/pox5/recovery.ts b/modules/utxo-descriptors/src/pox5/recovery.ts new file mode 100644 index 00000000000..b88aa818f73 --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/recovery.ts @@ -0,0 +1,87 @@ +import { Psbt, Transaction } from '@bitgo/wasm-utxo'; + +import type { Pox5InputMatch } from './input'; +import { assertPox5PrincipalPreimage } from './validation'; + +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: 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 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: Pox5InputMatch): void { + getMatchedTransactionInput(psbt, input); +} + +export type Pox5SpendBranch = 'locktime' | 'early-exit'; + +/** Classify a PoX-5 spend from native principal-preimage metadata. */ +export function classifyPox5Spend(psbt: Psbt, input: Pox5InputMatch): Pox5SpendBranch { + getMatchedTransactionInput(psbt, input); + const hasPrincipalPreimage = psbt + .getInputKeyValues(input.inputIndex) + .some((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); + return hasPrincipalPreimage ? 'early-exit' : 'locktime'; +} + +/** Add validated principal-preimage metadata for an early-exit spend. */ +export function preparePox5EarlyExit( + psbt: Psbt, + inputIndex: number, + input: 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); + assertPox5PrincipalPreimage(input.info, principalPreimage); + psbt.addSha256Preimage(inputIndex, principalPreimage); +} diff --git a/modules/utxo-staking/test/unit/pox5/recovery.ts b/modules/utxo-descriptors/test/unit/pox5/recovery.ts similarity index 95% rename from modules/utxo-staking/test/unit/pox5/recovery.ts rename to modules/utxo-descriptors/test/unit/pox5/recovery.ts index 4570da27866..112e3548506 100644 --- a/modules/utxo-staking/test/unit/pox5/recovery.ts +++ b/modules/utxo-descriptors/test/unit/pox5/recovery.ts @@ -1,7 +1,6 @@ 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'; @@ -9,12 +8,13 @@ import { assertPox5EarlyExitSpend, assertPox5LocktimeSpend, classifyPox5Spend, + createPox5LockupDescriptor, + matchPox5Input, + type Pox5InputMatch, POX5_MAX_UNLOCK_HEIGHT, preparePox5EarlyExit, } from '../../../src/pox5'; -type Pox5InputMatch = pox5.Pox5InputMatch; - const UNLOCK_HEIGHT = 840_000; function sha256(value: Uint8Array): Buffer { @@ -33,7 +33,7 @@ function createPox5RecoveryPsbt( 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({ + const descriptor = createPox5LockupDescriptor({ unlockHeight, stakerCommitment: sha256(principalPreimage), earlyExitKey: Buffer.from(earlyExit.publicKey), @@ -45,7 +45,7 @@ function createPox5RecoveryPsbt( psbt.addOutput(concreteDescriptor.scriptPubkey(), 90_000n); psbt.updateInputWithDescriptor(0, concreteDescriptor); - const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', concreteDescriptor]])); + const match = matchPox5Input(psbt, 0, new Map([['pox5', concreteDescriptor]])); assert.ok(match); return { psbt, match: match as Pox5InputMatch, principalPreimage }; } diff --git a/modules/utxo-staking/src/pox5/recovery.ts b/modules/utxo-staking/src/pox5/recovery.ts index bed19574316..a950fad1835 100644 --- a/modules/utxo-staking/src/pox5/recovery.ts +++ b/modules/utxo-staking/src/pox5/recovery.ts @@ -1,85 +1,19 @@ -import { Psbt, Transaction } from '@bitgo/wasm-utxo'; import { pox5 } from '@bitgo/utxo-descriptors'; -export const POX5_MAX_UNLOCK_HEIGHT = 500_000_000; +/** @deprecated Use `pox5.POX5_MAX_UNLOCK_HEIGHT` from `@bitgo/utxo-descriptors`. */ +export const POX5_MAX_UNLOCK_HEIGHT = pox5.POX5_MAX_UNLOCK_HEIGHT; -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}`); - } -} +/** @deprecated Use `pox5.assertPox5LocktimeSpend` from `@bitgo/utxo-descriptors`. */ +export const assertPox5LocktimeSpend = pox5.assertPox5LocktimeSpend; -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}`); - } -} +/** @deprecated Use `pox5.assertPox5EarlyExitSpend` from `@bitgo/utxo-descriptors`. */ +export const assertPox5EarlyExitSpend = pox5.assertPox5EarlyExitSpend; -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; -} +/** @deprecated Use `pox5.classifyPox5Spend` from `@bitgo/utxo-descriptors`. */ +export const classifyPox5Spend = pox5.classifyPox5Spend; -/** 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'); - } +/** @deprecated Use `pox5.preparePox5EarlyExit` from `@bitgo/utxo-descriptors`. */ +export const preparePox5EarlyExit = pox5.preparePox5EarlyExit; - 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); -} - -export type Pox5SpendBranch = 'locktime' | 'early-exit'; - -/** Classify a PoX-5 spend from native principal-preimage metadata. */ -export function classifyPox5Spend(psbt: Psbt, input: pox5.Pox5InputMatch): Pox5SpendBranch { - getMatchedTransactionInput(psbt, input); - const hasPrincipalPreimage = psbt - .getInputKeyValues(input.inputIndex) - .some((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); - return hasPrincipalPreimage ? 'early-exit' : 'locktime'; -} - -/** 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); -} +/** @deprecated Use `pox5.Pox5SpendBranch` from `@bitgo/utxo-descriptors`. */ +export type Pox5SpendBranch = pox5.Pox5SpendBranch; diff --git a/modules/utxo-staking/src/pox5/witness.ts b/modules/utxo-staking/src/pox5/witness.ts index 80bb3848d31..64070fac364 100644 --- a/modules/utxo-staking/src/pox5/witness.ts +++ b/modules/utxo-staking/src/pox5/witness.ts @@ -1,8 +1,6 @@ import { Psbt } from '@bitgo/wasm-utxo'; import { pox5 } from '@bitgo/utxo-descriptors'; -import { assertPox5LocktimeSpend, preparePox5EarlyExit } from './recovery'; - export type Pox5FinalizerParams = { /** The canonical descriptor match for the input being finalized. */ match: pox5.Pox5InputMatch; @@ -17,7 +15,7 @@ function assertFinalizerInputIndex(inputIndex: number, match: pox5.Pox5InputMatc /** Finalize the post-CLTV 2-of-3 PoX-5 spend branch. */ export function finalizePox5LocktimePath(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams): void { assertFinalizerInputIndex(inputIndex, params.match); - assertPox5LocktimeSpend(psbt, [params.match]); + pox5.assertPox5LocktimeSpend(psbt, [params.match]); psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor); psbt.finalizeInput(inputIndex); } @@ -29,7 +27,7 @@ export function finalizePox5EarlyExitPath( params: Pox5FinalizerParams & { principalPreimage: Uint8Array } ): void { assertFinalizerInputIndex(inputIndex, params.match); - preparePox5EarlyExit(psbt, inputIndex, params.match, params.principalPreimage); + pox5.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/recoveryCompat.ts b/modules/utxo-staking/test/unit/pox5/recoveryCompat.ts new file mode 100644 index 00000000000..990b76f7161 --- /dev/null +++ b/modules/utxo-staking/test/unit/pox5/recoveryCompat.ts @@ -0,0 +1,15 @@ +import assert from 'assert/strict'; + +import { pox5 } from '@bitgo/utxo-descriptors'; + +import * as staking from '../../../src/pox5'; + +describe('PoX-5 spend policy compatibility exports', function () { + it('re-exports the spend policy from utxo-descriptors', function () { + assert.equal(staking.POX5_MAX_UNLOCK_HEIGHT, pox5.POX5_MAX_UNLOCK_HEIGHT); + assert.equal(staking.assertPox5LocktimeSpend, pox5.assertPox5LocktimeSpend); + assert.equal(staking.assertPox5EarlyExitSpend, pox5.assertPox5EarlyExitSpend); + assert.equal(staking.classifyPox5Spend, pox5.classifyPox5Spend); + assert.equal(staking.preparePox5EarlyExit, pox5.preparePox5EarlyExit); + }); +});