From 557f8a5932fcd7d202933cefb4e89957339f22f3 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 14 Oct 2026 11:56:58 +0200 Subject: [PATCH 1/3] refactor(abstract-utxo): map tbtcstx* coin names in toWasmUtxoCoinName Move the tbtcstx, tbtcstxprivate1 (tbtcreg) and tbtcstxsignet (tbtcsig) wasm-utxo coin name mapping from per-class wasmName overrides into toWasmUtxoCoinName, so callers that only have a UtxoCoinName (e.g. the offline vault) can use these coins with wasm-utxo. Refs: WAL-2156 --- modules/abstract-utxo/src/impl/btc/tbtcstx.ts | 6 +----- modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts | 6 +----- modules/abstract-utxo/src/names.ts | 8 ++++++++ 3 files changed, 10 insertions(+), 10 deletions(-) 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`); } From d9d800b228a25723cc96ab61f448eb4710564436 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 14 Oct 2026 11:56:58 +0200 Subject: [PATCH 2/3] feat(utxo-descriptors): add PoX-5 spend policy, moved from utxo-staking Move POX5_MAX_UNLOCK_HEIGHT, assertPox5LocktimeSpend, assertPox5EarlyExitSpend, classifyPox5Spend and preparePox5EarlyExit into the pox5 namespace of utxo-descriptors. They only depend on the descriptor matching and preimage helpers that already live there, and this lets consumers such as abstract-utxo use the policy without importing the Babylon and CoreDAO staking code that the utxo-staking root export pulls in. utxo-staking keeps deprecated re-exports of the moved functions so existing consumers keep working; its PoX-5 finalizers call them through utxo-descriptors. Refs: WAL-2156 --- modules/utxo-descriptors/src/pox5/index.ts | 1 + modules/utxo-descriptors/src/pox5/recovery.ts | 87 ++++++++++++++++++ .../test/unit/pox5/recovery.ts | 10 +-- modules/utxo-staking/src/pox5/recovery.ts | 90 +++---------------- modules/utxo-staking/src/pox5/witness.ts | 6 +- .../test/unit/pox5/recoveryCompat.ts | 15 ++++ 6 files changed, 122 insertions(+), 87 deletions(-) create mode 100644 modules/utxo-descriptors/src/pox5/recovery.ts rename modules/{utxo-staking => utxo-descriptors}/test/unit/pox5/recovery.ts (95%) create mode 100644 modules/utxo-staking/test/unit/pox5/recoveryCompat.ts 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); + }); +}); From 1afbaaecaf587b72402eab3cae391131c5769e0a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 14 Oct 2026 11:56:58 +0200 Subject: [PATCH 3/3] fix(abstract-utxo): accept PoX-5 lockup descriptor in offline vault Add a PoX-5 lockup validator to the descriptor policy (offlineVault and prod getPolicyForEnv), accepting only canonical lockup descriptors whose staker keys are the wallet keys. Template validators now return false for descriptors they cannot parse instead of throwing. Half-signing applies the PoX-5 staking spend policy (nLockTime, non-final sequence, exactly one lockup input). The descriptor explanation now includes locktime. Refs: WAL-2156 --- .../src/descriptor/validatePolicy.ts | 75 ++++- .../offlineVault/TransactionExplanation.ts | 2 + .../src/offlineVault/descriptor/pox5.ts | 30 ++ .../offlineVault/descriptor/transaction.ts | 7 + ...2Of3.buildAdmin.custodial.explanation.json | 1 + .../test/unit/offlineVault/pox5.ts | 264 ++++++++++++++++++ 6 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 modules/abstract-utxo/src/offlineVault/descriptor/pox5.ts create mode 100644 modules/abstract-utxo/test/unit/offlineVault/pox5.ts 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/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); + } + }); + }); +});