From b65d4bee70c91b8fe1bb7d0340b31e41148c13f7 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 f67a3321432e5a2393dd853405a83b17256856d8 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); }