From 6b6ec6d31c30d6d8cbb3bb2cd5a33519842d5710 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 19 Oct 2026 10:05:31 +0200 Subject: [PATCH 1/2] refactor(abstract-utxo): offline vault applies the descriptor policy spend rules The offline vault half-sign step called assertPox5SpendPolicy directly while the hot descriptor wallet applies the spend rules through the descriptor policy (validateSpend). Build the offline vault descriptor policy in one place and have getHalfSignedPsbt call policy.validateSpend, so both paths share one entry point and the offline vault no longer names PoX-5 in its sign step. No behavior change. Refs: SI-1971 --- .../offlineVault/descriptor/transaction.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts index 8184844e16..814335386b 100644 --- a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts +++ b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts @@ -4,6 +4,7 @@ import { bip32, Psbt } from '@bitgo/wasm-utxo'; import { DescriptorMap, NamedDescriptor } from '../../descriptor'; import { OfflineVaultSignable, toKeyTriple } from '../OfflineVaultSignable'; import { + DescriptorValidationPolicy, getValidatorOneOfTemplates, getValidatorPox5Wallet, getValidatorSignedByUserKey, @@ -13,7 +14,6 @@ import { import { explainPsbt, signPsbt } from '../../transaction/descriptor'; import { TransactionExplanation } from '../TransactionExplanation'; import { UtxoCoinName } from '../../names'; -import { assertPox5SpendPolicy } from '../../descriptor/pox5SpendPolicy'; export const DescriptorTransaction = t.intersection( [OfflineVaultSignable, t.type({ descriptors: t.array(NamedDescriptor) })], @@ -22,10 +22,8 @@ export const DescriptorTransaction = t.intersection( export type DescriptorTransaction = t.TypeOf; -export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransaction): DescriptorMap { - const { descriptors, xpubsWithDerivationPath } = tx; - const pubkeys = toKeyTriple(xpubsWithDerivationPath); - const policy = getValidatorSome([ +function getDescriptorPolicy(): DescriptorValidationPolicy { + return 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 @@ -33,13 +31,21 @@ export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransactio // allow all descriptors signed by the user key getValidatorSignedByUserKey(), ]); - return toDescriptorMapValidate(descriptors, pubkeys, policy); +} + +export function getDescriptorsFromDescriptorTransaction( + tx: DescriptorTransaction, + policy = getDescriptorPolicy() +): DescriptorMap { + const { descriptors, xpubsWithDerivationPath } = tx; + return toDescriptorMapValidate(descriptors, toKeyTriple(xpubsWithDerivationPath), policy); } 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); + const policy = getDescriptorPolicy(); + const descriptorMap = getDescriptorsFromDescriptorTransaction(tx, policy); + policy.validateSpend?.(psbt, descriptorMap); signPsbt(psbt, descriptorMap, prv, { onUnknownInput: 'throw' }); return psbt; } From 7affce90645d92713224c6d4a9ec8bec08a924c2 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 19 Oct 2026 10:05:31 +0200 Subject: [PATCH 2/2] refactor(abstract-utxo): tighten the PoX-5 early-exit policy Follow-ups from the review of the early-exit policy: - require exactly one PSBT_IN_SHA256 record on the early-exit input, not just exactly one that matches the descriptor commitment, so an unrelated extra record is refused too - drop the redundant descriptor-match check in the early-exit branch (classifyPox5Spend has already done it) and make the branch switch exhaustive instead of having an unreachable default - document that classifyPox5Spend treats any PSBT_IN_SHA256 record as an early exit on purpose, so a stray record in a late exit fails closed, and that the shared assertPox5EarlyExitSpend only checks the descriptor match and not nLockTime or the preimage Refs: SI-1971 --- .../src/descriptor/pox5SpendPolicy.ts | 22 +++++++++++++++---- .../test/unit/offlineVault/pox5.ts | 19 ++++++++++++++++ modules/utxo-descriptors/src/pox5/recovery.ts | 8 ++++++- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/modules/abstract-utxo/src/descriptor/pox5SpendPolicy.ts b/modules/abstract-utxo/src/descriptor/pox5SpendPolicy.ts index 83f65965b9..4f621b3f57 100644 --- a/modules/abstract-utxo/src/descriptor/pox5SpendPolicy.ts +++ b/modules/abstract-utxo/src/descriptor/pox5SpendPolicy.ts @@ -10,13 +10,23 @@ function getPox5InputMatches(psbt: Psbt, descriptorMap: descriptorWallet.Descrip /** WP builds early exits with a zero nLockTime; the early-exit branch does not evaluate the CLTV. */ const POX5_EARLY_EXIT_LOCKTIME = 0; +/** + * The early-exit checks beyond what the shared `pox5.assertPox5EarlyExitSpend` does (it only checks that the + * input matches the descriptor): nLockTime 0 and exactly one principal preimage that the descriptor commits to. + * `classifyPox5Spend` has already checked the descriptor match by the time this runs. + */ function assertPox5EarlyExitPolicy(psbt: Psbt, match: pox5.Pox5InputMatch): void { - pox5.assertPox5EarlyExitSpend(psbt, match); const lockTime = psbt.lockTime(); if (lockTime !== POX5_EARLY_EXIT_LOCKTIME) { throw new Error(`PoX-5 early exit nLockTime must be ${POX5_EARLY_EXIT_LOCKTIME}, got ${lockTime}`); } - // throws unless exactly one PSBT_IN_SHA256 record carries the descriptor's commitment and its preimage + const sha256Records = psbt + .getInputKeyValues(match.inputIndex) + .filter((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); + if (sha256Records.length !== 1) { + throw new Error(`PoX-5 early exit requires exactly one PSBT_IN_SHA256 record, found ${sha256Records.length}`); + } + // throws unless the record carries the descriptor's commitment and its preimage pox5.getPox5PrincipalPreimage(psbt, match.inputIndex, match.info); } @@ -37,6 +47,8 @@ export function assertPox5SpendPolicy(psbt: Psbt, descriptorMap: descriptorWalle throw new Error(`expected exactly one PoX-5 lockup input, found ${matches.length}`); } const [match] = matches; + // Any PSBT_IN_SHA256 record makes this an early exit, even one the descriptor does not commit to: a stray + // record is then refused by the early-exit checks instead of being carried along in a late exit. const branch = pox5.classifyPox5Spend(psbt, match); switch (branch) { case 'locktime': @@ -45,7 +57,9 @@ export function assertPox5SpendPolicy(psbt: Psbt, descriptorMap: descriptorWalle case 'early-exit': assertPox5EarlyExitPolicy(psbt, match); return; - default: - throw new Error(`unsupported PoX-5 spend branch: ${branch}`); + default: { + const unsupported: never = branch; + throw new Error(`unsupported PoX-5 spend branch: ${String(unsupported)}`); + } } } diff --git a/modules/abstract-utxo/test/unit/offlineVault/pox5.ts b/modules/abstract-utxo/test/unit/offlineVault/pox5.ts index 44751d15cc..8334bd95b4 100644 --- a/modules/abstract-utxo/test/unit/offlineVault/pox5.ts +++ b/modules/abstract-utxo/test/unit/offlineVault/pox5.ts @@ -62,6 +62,8 @@ type Pox5TxParams = { lockupInputs?: number; /** add the principal preimage of the lockup input, which makes the spend an early exit */ earlyExitPreimage?: Uint8Array; + /** an additional principal preimage record on the lockup input */ + extraPreimage?: Uint8Array; }; function createPox5Tx(params: Pox5TxParams = {}): { tx: DescriptorTransaction; rootPrv: BIP32 } { @@ -90,6 +92,9 @@ function createPox5Tx(params: Pox5TxParams = {}): { tx: DescriptorTransaction; r if (params.earlyExitPreimage) { psbt.addSha256Preimage(0, params.earlyExitPreimage); } + if (params.extraPreimage) { + psbt.addSha256Preimage(0, params.extraPreimage); + } const tx: DescriptorTransaction = { xpubsWithDerivationPath: { @@ -195,6 +200,20 @@ describe('OfflineVault PoX-5 lockup descriptor', function () { ); }); + it('rejects a second principal preimage record that the descriptor does not commit to', function () { + assert.throws( + () => halfSign(coinName, earlyExit({ extraPreimage: Buffer.alloc(32, 0x43) })), + /requires exactly one PSBT_IN_SHA256 record/ + ); + }); + + it('rejects a late exit that carries a stray principal preimage record', function () { + assert.throws( + () => halfSign(coinName, createPox5Tx({ extraPreimage: Buffer.alloc(32, 0x43) })), + /nLockTime must be 0/ + ); + }); + it('rejects lockup descriptors with foreign staker keys', function () { assert.throws(() => halfSign(coinName, earlyExit({ lockupKeys: getForeignKeys() })), /does not match policy/); }); diff --git a/modules/utxo-descriptors/src/pox5/recovery.ts b/modules/utxo-descriptors/src/pox5/recovery.ts index b88aa818f7..5bfd3c34b8 100644 --- a/modules/utxo-descriptors/src/pox5/recovery.ts +++ b/modules/utxo-descriptors/src/pox5/recovery.ts @@ -55,7 +55,13 @@ export function assertPox5LocktimeSpend(psbt: Psbt, inputs: readonly Pox5InputMa } } -/** Validate that a canonical PoX-5 input can use the principal-preimage branch. */ +/** + * Validate that a canonical PoX-5 input can use the principal-preimage branch. + * + * This only checks that the descriptor match belongs to the PSBT input. It does not check the nLockTime or the + * principal preimage, so callers that sign or finalize an early exit must check those themselves + * (see `assertPox5SpendPolicy` in `@bitgo/abstract-utxo`, which requires nLockTime 0 and the committed preimage). + */ export function assertPox5EarlyExitSpend(psbt: Psbt, input: Pox5InputMatch): void { getMatchedTransactionInput(psbt, input); }