From fb8f402ed999007b85bfaf57f230ae740233f013 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 10 Nov 2025 17:49:04 +0100 Subject: [PATCH 1/8] feat(utxo-lib): add round-trip PSBT buffer test Add a test to ensure PSBTs can be serialized to buffer and back without any loss of information. Issue: BTC-2732 Co-authored-by: llm-git --- .../utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts index f5a86cf3aa..ba29f39317 100644 --- a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts @@ -3,6 +3,7 @@ import * as assert from 'assert'; import { addXpubsToPsbt, clonePsbtWithoutNonWitnessUtxo, + createPsbtFromBuffer, getPsbtInputSignatureCount, getSignatureValidationArrayPsbt, getStrictSignatureCount, @@ -136,9 +137,16 @@ function runPsbt( describe(`psbt build, sign and verify for ${coin} ${inputTypes.join('-')} ${sign}`, function () { let psbt: UtxoPsbt; - it(`getSignatureValidationArray with globalXpub ${coin} ${sign}`, function () { + before(function () { psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign, { deterministic: true }); addXpubsToPsbt(psbt, rootWalletKeysXpubs); + }); + + it('round-trip test', function () { + assert.deepStrictEqual(psbt.toBuffer(), createPsbtFromBuffer(psbt.toBuffer(), network).toBuffer()); + }); + + it(`getSignatureValidationArray with globalXpub ${coin} ${sign}`, function () { psbt.data.inputs.forEach((input, inputIndex) => { const isP2shP2pk = inputs[inputIndex].scriptType === 'p2shP2pk'; const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); From 64aabd4c18994675a1fa1c3faa71003e9aa21406 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 11:20:16 +0100 Subject: [PATCH 2/8] feat(utxo-lib): add AcidTest utility for comprehensive PSBT testing Creates a utility class for testing that generates PSBTs with all supported input and output types for a given network. The AcidTest class can create PSBTs with: - All wallet script types supported by the network - p2shP2pk inputs (for replay protection) - Various output types including those from another wallet - OP_RETURN outputs Refactors existing tests to use this utility, making them more comprehensive and maintainable. Issue: BTC-2732 Co-authored-by: llm-git --- modules/utxo-lib/src/testutil/psbt.ts | 99 +++++++++- modules/utxo-lib/src/testutil/transaction.ts | 8 +- .../test/bitgo/psbt/SignVerifyPsbtAndTx.ts | 182 ++++++------------ 3 files changed, 160 insertions(+), 129 deletions(-) diff --git a/modules/utxo-lib/src/testutil/psbt.ts b/modules/utxo-lib/src/testutil/psbt.ts index 8ff877f4b7..4dd50426af 100644 --- a/modules/utxo-lib/src/testutil/psbt.ts +++ b/modules/utxo-lib/src/testutil/psbt.ts @@ -3,6 +3,7 @@ import { ok as assert } from 'assert'; import { createOutputScriptP2shP2pk, + isSupportedScriptType, ScriptType, ScriptType2Of3, scriptTypeP2shP2pk, @@ -26,10 +27,12 @@ import { UtxoTransaction, verifySignatureWithUnspent, addXpubsToPsbt, + clonePsbtWithoutNonWitnessUtxo, } from '../bitgo'; import { Network } from '../networks'; import { mockReplayProtectionUnspent, mockWalletUnspent } from './mock'; import { toOutputScript } from '../address'; +import { getDefaultWalletKeys, getWalletKeysForSeed } from './keys'; /** * This is a bit of a misnomer, as it actually specifies the spend type of the input. @@ -48,6 +51,8 @@ export type Input = { value: bigint; }; +export type SignStage = 'unsigned' | 'halfsigned' | 'fullsigned'; + /** * Set isInternalAddress=true for internal output address */ @@ -169,7 +174,7 @@ export function constructPsbt( outputs: Output[], network: Network, rootWalletKeys: RootWalletKeys, - sign: 'unsigned' | 'halfsigned' | 'fullsigned', + signStage: SignStage, params?: { signers?: { signerName: KeyName; cosignerName?: KeyName }; deterministic?: boolean; @@ -183,6 +188,11 @@ export function constructPsbt( assert(totalInputAmount >= outputInputAmount, 'total output can not exceed total input'); const psbt = createPsbtForNetwork({ network }); + + if (params?.addGlobalXPubs) { + addXpubsToPsbt(psbt, rootWalletKeys); + } + const unspents = inputs.map((input, i) => toUnspent(input, i, network, rootWalletKeys)); unspents.forEach((u, i) => { @@ -226,7 +236,7 @@ export function constructPsbt( throw new Error('invalid output'); }); - if (sign === 'unsigned') { + if (signStage === 'unsigned') { return psbt; } @@ -237,15 +247,90 @@ export function constructPsbt( signAllPsbtInputs(psbt, inputs, rootWalletKeys, 'halfsigned', { signers, skipNonWitnessUtxo }); - if (sign === 'fullsigned') { - signAllPsbtInputs(psbt, inputs, rootWalletKeys, sign, { signers, deterministic, skipNonWitnessUtxo }); + if (signStage === 'fullsigned') { + signAllPsbtInputs(psbt, inputs, rootWalletKeys, signStage, { signers, deterministic, skipNonWitnessUtxo }); } - if (params?.addGlobalXPubs) { - addXpubsToPsbt(psbt, rootWalletKeys); + return psbt; +} + +export type TxFormat = 'psbt' | 'psbt-lite'; + +/** + * Creates a valid PSBT with as many features as possible. + * + * - Inputs: + * - All wallet script types that are supported by the network. + * - A p2shP2pk input (for replay protection) + * - Outputs: + * - All wallet script types that are supported by the network. + * - A p2sh output with derivation info of a different wallet (not in the global psbt xpubs) + * - A p2sh output with no derivation info (external output) + * - An OP_RETURN output + */ +export class AcidTest { + public readonly network: Network; + public readonly signStage: SignStage; + public readonly txFormat: TxFormat; + public readonly rootWalletKeys: RootWalletKeys; + public readonly otherWalletKeys: RootWalletKeys; + public readonly inputs: Input[]; + public readonly outputs: Output[]; + + constructor( + network: Network, + signStage: SignStage, + txFormat: TxFormat, + rootWalletKeys: RootWalletKeys, + otherWalletKeys: RootWalletKeys, + inputs: Input[], + outputs: Output[] + ) { + this.network = network; + this.signStage = signStage; + this.txFormat = txFormat; + this.rootWalletKeys = rootWalletKeys; + this.otherWalletKeys = otherWalletKeys; + this.inputs = inputs; + this.outputs = outputs; } - return psbt; + static withDefaults(network: Network, signStage: SignStage, txFormat: TxFormat): AcidTest { + const rootWalletKeys = getDefaultWalletKeys(); + + const otherWalletKeys = getWalletKeysForSeed('too many secrets'); + const inputs: Input[] = inputScriptTypes + .filter((scriptType) => + scriptType === 'taprootKeyPathSpend' + ? isSupportedScriptType(network, 'p2trMusig2') + : isSupportedScriptType(network, scriptType) + ) + .map((scriptType) => ({ scriptType, value: BigInt(2000) })); + + const outputs: Output[] = outputScriptTypes + .filter((scriptType) => isSupportedScriptType(network, scriptType)) + .map((scriptType) => ({ scriptType, value: BigInt(900) })); + + // Test other wallet output (with derivation info) + outputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: otherWalletKeys }); + // Tes non-wallet output + outputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: null }); + // Test OP_RETURN output + outputs.push({ opReturn: 'setec astronomy', value: BigInt(900) }); + + return new AcidTest(network, signStage, txFormat, rootWalletKeys, otherWalletKeys, inputs, outputs); + } + + createPsbt(): UtxoPsbt { + const psbt = constructPsbt(this.inputs, this.outputs, this.network, this.rootWalletKeys, this.signStage, { + deterministic: true, + addGlobalXPubs: true, + }); + if (this.txFormat === 'psbt-lite') { + return clonePsbtWithoutNonWitnessUtxo(psbt); + } + return psbt; + } } /** diff --git a/modules/utxo-lib/src/testutil/transaction.ts b/modules/utxo-lib/src/testutil/transaction.ts index 97d883cd00..2c47887ef8 100644 --- a/modules/utxo-lib/src/testutil/transaction.ts +++ b/modules/utxo-lib/src/testutil/transaction.ts @@ -154,8 +154,12 @@ export function constructTxnBuilder( const outputInputAmount = outputs.reduce((sum, output) => sum + BigInt(output.value), BigInt(0)); assert(totalInputAmount >= outputInputAmount, 'total output can not exceed total input'); assert( - !outputs.some((o) => (o.scriptType && o.address) || (!o.scriptType && !o.address)), - 'only either output script type or address should be provided' + outputs.every((o) => o.scriptType || o.address), + 'must provide either scriptType or address for each output' + ); + assert( + outputs.every((o) => !(o.scriptType && o.address)), + 'cannot provide both scriptType and address for the same output' ); const txb = createTransactionBuilderForNetwork(network); diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts index ba29f39317..ccb9a3dcac 100644 --- a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts @@ -1,8 +1,6 @@ import * as assert from 'assert'; import { - addXpubsToPsbt, - clonePsbtWithoutNonWitnessUtxo, createPsbtFromBuffer, getPsbtInputSignatureCount, getSignatureValidationArrayPsbt, @@ -10,72 +8,34 @@ import { getStrictSignatureCounts, PsbtInput, PsbtOutput, - RootWalletKeys, Triple, UtxoPsbt, UtxoTransaction, } from '../../../src/bitgo'; -import { BIP32Interface } from '@bitgo/secp256k1'; -import { - constructPsbt, - constructTxnBuilder, - getDefaultWalletKeys, - Input as TestUtilInput, - InputScriptType, - inputScriptTypes, - Output as TestUtilOutput, - outputScriptTypes, - TxnInput, - txnInputScriptTypes, - TxnOutput, - txnOutputScriptTypes, - getWalletKeysForSeed, -} from '../../../src/testutil'; -import { Output as TestutilPsbtOutput } from '../../../src/testutil/psbt'; -import { getNetworkList, getNetworkName, isMainnet, Network, networks } from '../../../src'; -import { isSupportedScriptType } from '../../../src/bitgo/outputScripts'; +import { constructTxnBuilder, Input as TestUtilInput, TxnInput } from '../../../src/testutil'; +import { AcidTest, InputScriptType, SignStage } from '../../../src/testutil/psbt'; +import { getNetworkList, getNetworkName, isMainnet, networks } from '../../../src'; import { parsePsbtMusig2Nonces, parsePsbtMusig2PartialSigs, parsePsbtMusig2Participants, } from '../../../src/bitgo/Musig2'; -import { SignatureTargetType } from './Psbt'; import { getFixture } from '../../fixture.util'; -const rootWalletKeys = getDefaultWalletKeys(); const signs = ['unsigned', 'halfsigned', 'fullsigned'] as const; -const rootWalletKeysXpubs = new RootWalletKeys( - rootWalletKeys.triple.map((bip32) => bip32.neutered()) as Triple, - rootWalletKeys.derivationPrefixes -); - -const psbtInputs = inputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(2000) })); -const psbtOutputs: TestutilPsbtOutput[] = outputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(900) })); - -const otherWalletKeys = getWalletKeysForSeed('too many secrets'); -// Test other wallet output -psbtOutputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: otherWalletKeys }); -// Test non-wallet output -psbtOutputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: null }); -// Test OP_RETURN output -psbtOutputs.push({ opReturn: 'setec astronomy', value: BigInt(900) }); - -const txInputs = txnInputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(1000) })); -const txOutputs = txnOutputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(900) })); - -function getSigValidArray(scriptType: InputScriptType, sign: SignatureTargetType): Triple { - if (scriptType === 'p2shP2pk' || sign === 'unsigned') { +function getSigValidArray(scriptType: InputScriptType, signStage: SignStage): Triple { + if (scriptType === 'p2shP2pk' || signStage === 'unsigned') { return [false, false, false]; } - if (sign === 'halfsigned') { + if (signStage === 'halfsigned') { return [true, false, false]; } return scriptType === 'p2trMusig2' ? [true, true, false] : [true, false, true]; } -function signCount(sign: SignatureTargetType) { - return sign === 'unsigned' ? 0 : sign === 'halfsigned' ? 1 : 2; +function signCount(signStage: SignStage) { + return signStage === 'unsigned' ? 0 : signStage === 'halfsigned' ? 1 : 2; } // normalize buffers to hex @@ -119,81 +79,63 @@ function getFixturePsbtOutputs(psbt: UtxoPsbt) { return psbt.data.outputs.map((output: PsbtOutput) => toFixture(output)); } -function runPsbt( - network: Network, - sign: SignatureTargetType, - inputs: TestUtilInput[], - outputs: TestUtilOutput[], - { - txFormat, - }: { - txFormat?: 'psbt' | 'psbt-lite'; - } -) { - const coin = getNetworkName(network); - const signatureCount = signCount(sign); - const inputTypes = inputs.map((input) => input.scriptType); +function runPsbt(acidTest: AcidTest) { + const coin = getNetworkName(acidTest.network); + const signatureCount = signCount(acidTest.signStage); - describe(`psbt build, sign and verify for ${coin} ${inputTypes.join('-')} ${sign}`, function () { + describe(`psbt build, sign and verify for ${coin} ${acidTest.signStage}`, function () { let psbt: UtxoPsbt; before(function () { - psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign, { deterministic: true }); - addXpubsToPsbt(psbt, rootWalletKeysXpubs); + psbt = acidTest.createPsbt(); }); it('round-trip test', function () { - assert.deepStrictEqual(psbt.toBuffer(), createPsbtFromBuffer(psbt.toBuffer(), network).toBuffer()); + assert.deepStrictEqual(psbt.toBuffer(), createPsbtFromBuffer(psbt.toBuffer(), acidTest.network).toBuffer()); }); - it(`getSignatureValidationArray with globalXpub ${coin} ${sign}`, function () { + it(`getSignatureValidationArray with globalXpub ${coin} ${acidTest.signStage}`, function () { psbt.data.inputs.forEach((input, inputIndex) => { - const isP2shP2pk = inputs[inputIndex].scriptType === 'p2shP2pk'; - const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); - psbt.getSignatureValidationArray(inputIndex, { rootNodes: rootWalletKeys.triple }).forEach((sv, i) => { - if (isP2shP2pk && sign !== 'unsigned' && i === 0) { + const isP2shP2pk = acidTest.inputs[inputIndex].scriptType === 'p2shP2pk'; + const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); + psbt.getSignatureValidationArray(inputIndex, { rootNodes: acidTest.rootWalletKeys.triple }).forEach((sv, i) => { + if (isP2shP2pk && acidTest.signStage !== 'unsigned' && i === 0) { assert.strictEqual(sv, true); } else { assert.strictEqual(sv, expectedSigValid[i]); } }); }); - - if (txFormat === 'psbt-lite') { - psbt = clonePsbtWithoutNonWitnessUtxo(psbt); - } }); it('matches fixture', async function () { let finalizedPsbt: UtxoPsbt | undefined; let extractedTransaction: Buffer | undefined; - if (sign === 'fullsigned') { + if (acidTest.signStage === 'fullsigned') { finalizedPsbt = psbt.clone().finalizeAllInputs(); extractedTransaction = finalizedPsbt.extractTransaction().toBuffer(); } const fixture = { - walletKeys: rootWalletKeys.triple.map((xpub) => xpub.toBase58()), + walletKeys: acidTest.rootWalletKeys.triple.map((xpub) => xpub.toBase58()), psbtBase64: psbt.toBase64(), psbtBase64Finalized: finalizedPsbt ? finalizedPsbt.toBase64() : null, inputs: psbt.txInputs.map((input) => toFixture(input)), - psbtInputs: getFixturePsbtInputs(psbt, inputs), - psbtInputsFinalized: finalizedPsbt ? getFixturePsbtInputs(finalizedPsbt, inputs) : null, + psbtInputs: getFixturePsbtInputs(psbt, acidTest.inputs), + psbtInputsFinalized: finalizedPsbt ? getFixturePsbtInputs(finalizedPsbt, acidTest.inputs) : null, outputs: psbt.txOutputs.map((output) => toFixture(output)), psbtOutputs: getFixturePsbtOutputs(psbt), extractedTransaction: extractedTransaction ? toFixture(extractedTransaction) : null, }; - const filename = [txFormat, coin, sign, 'json'].join('.'); + const filename = [acidTest.txFormat, coin, acidTest.signStage, 'json'].join('.'); assert.deepStrictEqual(fixture, await getFixture(`${__dirname}/../fixtures/psbt/${filename}`, fixture)); }); - it(`getSignatureValidationArray with rootNodes ${coin} ${sign}`, function () { - const psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign); - addXpubsToPsbt(psbt, rootWalletKeysXpubs); + it(`getSignatureValidationArray with rootNodes ${coin} ${acidTest.signStage}`, function () { psbt.data.inputs.forEach((input, inputIndex) => { - const isP2shP2pk = inputs[inputIndex].scriptType === 'p2shP2pk'; - const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); - psbt.getSignatureValidationArray(inputIndex, { rootNodes: rootWalletKeysXpubs.triple }).forEach((sv, i) => { - if (isP2shP2pk && sign !== 'unsigned' && i === 0) { + const isP2shP2pk = acidTest.inputs[inputIndex].scriptType === 'p2shP2pk'; + const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); + psbt.getSignatureValidationArray(inputIndex, { rootNodes: acidTest.rootWalletKeys.triple }).forEach((sv, i) => { + if (isP2shP2pk && acidTest.signStage !== 'unsigned' && i === 0) { assert.strictEqual(sv, true); } else { assert.strictEqual(sv, expectedSigValid[i]); @@ -202,39 +144,39 @@ function runPsbt( }); }); - it(`getSignatureValidationArrayPsbt ${coin} ${sign}`, function () { - const psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign); - const sigValidations = getSignatureValidationArrayPsbt(psbt, rootWalletKeysXpubs); + it(`getSignatureValidationArrayPsbt ${coin} ${acidTest.signStage}`, function () { + const sigValidations = getSignatureValidationArrayPsbt(psbt, acidTest.rootWalletKeys); psbt.data.inputs.forEach((input, inputIndex) => { - const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); + const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); const sigValid = sigValidations.find((sv) => sv[0] === inputIndex); assert.ok(sigValid); sigValid[1].forEach((sv, i) => assert.strictEqual(sv, expectedSigValid[i])); }); }); - it(`psbt signature counts ${coin} ${sign}`, function () { - const psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign); + it(`psbt signature counts ${coin} ${acidTest.signStage}`, function () { const counts = getStrictSignatureCounts(psbt); const countsFromInputs = getStrictSignatureCounts(psbt.data.inputs); assert.strictEqual(counts.length, psbt.data.inputs.length); assert.strictEqual(countsFromInputs.length, psbt.data.inputs.length); psbt.data.inputs.forEach((input, inputIndex) => { - const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; + const expectedCount = + acidTest.inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; assert.strictEqual(getPsbtInputSignatureCount(input), expectedCount); assert.strictEqual(getStrictSignatureCount(input), expectedCount); assert.strictEqual(counts[inputIndex], expectedCount); assert.strictEqual(countsFromInputs[inputIndex], expectedCount); }); - if (sign === 'fullsigned') { + if (acidTest.signStage === 'fullsigned') { const tx = psbt.finalizeAllInputs().extractTransaction() as UtxoTransaction; const counts = getStrictSignatureCounts(tx); const countsFromIns = getStrictSignatureCounts(tx.ins); tx.ins.forEach((input, inputIndex) => { - const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' ? 1 : signatureCount; + const expectedCount = + acidTest.inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; assert.strictEqual(getStrictSignatureCount(input), expectedCount); assert.strictEqual(counts[inputIndex], expectedCount); assert.strictEqual(countsFromIns[inputIndex], expectedCount); @@ -244,18 +186,22 @@ function runPsbt( }); } -function runTx( - network: Network, - sign: SignatureTargetType, - inputs: TxnInput[], - outputs: TxnOutput[] -) { - const coin = getNetworkName(network); - const signatureCount = signCount(sign); - describe(`tx build, sign and verify for ${coin} ${sign}`, function () { - it(`tx signature counts ${coin} ${sign}`, function () { - const txb = constructTxnBuilder(inputs, outputs, network, rootWalletKeys, sign); - const tx = sign === 'fullsigned' ? txb.build() : txb.buildIncomplete(); +function runTx(acidTest: AcidTest) { + const coin = getNetworkName(acidTest.network); + const signatureCount = signCount(acidTest.signStage); + describe(`tx build, sign and verify for ${coin} ${acidTest.signStage}`, function () { + const inputs = acidTest.inputs.filter( + (input): input is TxnInput => + input.scriptType !== 'taprootKeyPathSpend' && input.scriptType !== 'p2trMusig2' + ); + const outputs = acidTest.outputs.filter( + (output) => + ('scriptType' in output && output.scriptType !== undefined) || + ('address' in output && output.address !== undefined) + ); + it(`tx signature counts ${coin} ${acidTest.signStage}`, function () { + const txb = constructTxnBuilder(inputs, outputs, acidTest.network, acidTest.rootWalletKeys, acidTest.signStage); + const tx = acidTest.signStage === 'fullsigned' ? txb.build() : txb.buildIncomplete(); const counts = getStrictSignatureCounts(tx); const countsFromIns = getStrictSignatureCounts(tx.ins); @@ -264,7 +210,11 @@ function runTx( assert.strictEqual(countsFromIns.length, tx.ins.length); tx.ins.forEach((input, inputIndex) => { const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; - assert.strictEqual(getStrictSignatureCount(input), expectedCount); + assert.strictEqual( + getStrictSignatureCount(input), + expectedCount, + `input ${inputIndex} has ${getStrictSignatureCount(input)} signatures, expected ${expectedCount}` + ); assert.strictEqual(counts[inputIndex], expectedCount); assert.strictEqual(countsFromIns[inputIndex], expectedCount); }); @@ -276,16 +226,8 @@ signs.forEach((sign) => { getNetworkList() .filter((v) => isMainnet(v) && v !== networks.bitcoinsv) .forEach((network) => { - const supportedPsbtInputs = psbtInputs.filter((input) => - isSupportedScriptType(network, input.scriptType === 'taprootKeyPathSpend' ? 'p2trMusig2' : input.scriptType) - ); - const supportedPsbtOutputs = psbtOutputs.filter((output) => - 'scriptType' in output ? isSupportedScriptType(network, output.scriptType) : true - ); - runPsbt(network, sign, supportedPsbtInputs, supportedPsbtOutputs, { txFormat: 'psbt' }); - runPsbt(network, sign, supportedPsbtInputs, supportedPsbtOutputs, { txFormat: 'psbt-lite' }); - const supportedTxInputs = txInputs.filter((input) => isSupportedScriptType(network, input.scriptType)); - const supportedTxOutputs = txOutputs.filter((output) => isSupportedScriptType(network, output.scriptType)); - runTx(network, sign, supportedTxInputs, supportedTxOutputs); + runPsbt(AcidTest.withDefaults(network, sign, 'psbt')); + runPsbt(AcidTest.withDefaults(network, sign, 'psbt-lite')); + runTx(AcidTest.withDefaults(network, sign, 'psbt')); }); }); From 5d1dae59a3424b3f0fde6d6aaf1b8ae7be33b87e Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 11:23:27 +0100 Subject: [PATCH 3/8] feat(utxo-lib): split sign verify tests for PSBTs and legacy transactions Split the combined test file into two separate files - one for PSBT operations and another for legacy transaction signing and verification. Issue: BTC-2732 Co-authored-by: llm-git --- .../test/bitgo/psbt/SignVerifyLegacy.ts | 56 +++++++++++++++++++ ...gnVerifyPsbtAndTx.ts => SignVerifyPsbt.ts} | 39 +------------ 2 files changed, 57 insertions(+), 38 deletions(-) create mode 100644 modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts rename modules/utxo-lib/test/bitgo/psbt/{SignVerifyPsbtAndTx.ts => SignVerifyPsbt.ts} (81%) diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts new file mode 100644 index 0000000000..008e81b90c --- /dev/null +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts @@ -0,0 +1,56 @@ +import * as assert from 'assert'; + +import { getStrictSignatureCount, getStrictSignatureCounts } from '../../../src/bitgo'; +import { constructTxnBuilder, TxnInput } from '../../../src/testutil'; +import { AcidTest, SignStage } from '../../../src/testutil/psbt'; +import { getNetworkList, getNetworkName, isMainnet, networks } from '../../../src'; + +const signs = ['unsigned', 'halfsigned', 'fullsigned'] as const; + +function signCount(signStage: SignStage) { + return signStage === 'unsigned' ? 0 : signStage === 'halfsigned' ? 1 : 2; +} + +function runTx(acidTest: AcidTest) { + const coin = getNetworkName(acidTest.network); + const signatureCount = signCount(acidTest.signStage); + describe(`tx build, sign and verify for ${coin} ${acidTest.signStage}`, function () { + const inputs = acidTest.inputs.filter( + (input): input is TxnInput => + input.scriptType !== 'taprootKeyPathSpend' && input.scriptType !== 'p2trMusig2' + ); + const outputs = acidTest.outputs.filter( + (output) => + ('scriptType' in output && output.scriptType !== undefined) || + ('address' in output && output.address !== undefined) + ); + it(`tx signature counts ${coin} ${acidTest.signStage}`, function () { + const txb = constructTxnBuilder(inputs, outputs, acidTest.network, acidTest.rootWalletKeys, acidTest.signStage); + const tx = acidTest.signStage === 'fullsigned' ? txb.build() : txb.buildIncomplete(); + + const counts = getStrictSignatureCounts(tx); + const countsFromIns = getStrictSignatureCounts(tx.ins); + + assert.strictEqual(counts.length, tx.ins.length); + assert.strictEqual(countsFromIns.length, tx.ins.length); + tx.ins.forEach((input, inputIndex) => { + const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; + assert.strictEqual( + getStrictSignatureCount(input), + expectedCount, + `input ${inputIndex} has ${getStrictSignatureCount(input)} signatures, expected ${expectedCount}` + ); + assert.strictEqual(counts[inputIndex], expectedCount); + assert.strictEqual(countsFromIns[inputIndex], expectedCount); + }); + }); + }); +} + +signs.forEach((sign) => { + getNetworkList() + .filter((v) => isMainnet(v) && v !== networks.bitcoinsv) + .forEach((network) => { + runTx(AcidTest.withDefaults(network, sign, 'psbt')); + }); +}); diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts similarity index 81% rename from modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts rename to modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts index ccb9a3dcac..71435567b9 100644 --- a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts @@ -12,7 +12,7 @@ import { UtxoPsbt, UtxoTransaction, } from '../../../src/bitgo'; -import { constructTxnBuilder, Input as TestUtilInput, TxnInput } from '../../../src/testutil'; +import { Input as TestUtilInput } from '../../../src/testutil'; import { AcidTest, InputScriptType, SignStage } from '../../../src/testutil/psbt'; import { getNetworkList, getNetworkName, isMainnet, networks } from '../../../src'; import { @@ -186,48 +186,11 @@ function runPsbt(acidTest: AcidTest) { }); } -function runTx(acidTest: AcidTest) { - const coin = getNetworkName(acidTest.network); - const signatureCount = signCount(acidTest.signStage); - describe(`tx build, sign and verify for ${coin} ${acidTest.signStage}`, function () { - const inputs = acidTest.inputs.filter( - (input): input is TxnInput => - input.scriptType !== 'taprootKeyPathSpend' && input.scriptType !== 'p2trMusig2' - ); - const outputs = acidTest.outputs.filter( - (output) => - ('scriptType' in output && output.scriptType !== undefined) || - ('address' in output && output.address !== undefined) - ); - it(`tx signature counts ${coin} ${acidTest.signStage}`, function () { - const txb = constructTxnBuilder(inputs, outputs, acidTest.network, acidTest.rootWalletKeys, acidTest.signStage); - const tx = acidTest.signStage === 'fullsigned' ? txb.build() : txb.buildIncomplete(); - - const counts = getStrictSignatureCounts(tx); - const countsFromIns = getStrictSignatureCounts(tx.ins); - - assert.strictEqual(counts.length, tx.ins.length); - assert.strictEqual(countsFromIns.length, tx.ins.length); - tx.ins.forEach((input, inputIndex) => { - const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; - assert.strictEqual( - getStrictSignatureCount(input), - expectedCount, - `input ${inputIndex} has ${getStrictSignatureCount(input)} signatures, expected ${expectedCount}` - ); - assert.strictEqual(counts[inputIndex], expectedCount); - assert.strictEqual(countsFromIns[inputIndex], expectedCount); - }); - }); - }); -} - signs.forEach((sign) => { getNetworkList() .filter((v) => isMainnet(v) && v !== networks.bitcoinsv) .forEach((network) => { runPsbt(AcidTest.withDefaults(network, sign, 'psbt')); runPsbt(AcidTest.withDefaults(network, sign, 'psbt-lite')); - runTx(AcidTest.withDefaults(network, sign, 'psbt')); }); }); From aad58a26fa9ec062ef541fd958a5c4501c889885 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 11:37:52 +0100 Subject: [PATCH 4/8] feat(utxo-lib): add AcidTest.suite() for testing utilities Adds a utility function to generate a comprehensive test suite for PSBT functionality across networks, sign stages, and tx formats. Improves test organization by using const arrays and type refinements, and adds a name getter to AcidTest for better test reporting. Issue: BTC-2732 Co-authored-by: llm-git --- modules/utxo-lib/src/testutil/psbt.ts | 23 ++++++++++-- .../test/bitgo/psbt/SignVerifyLegacy.ts | 16 ++++----- .../test/bitgo/psbt/SignVerifyPsbt.ts | 35 ++++--------------- 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/modules/utxo-lib/src/testutil/psbt.ts b/modules/utxo-lib/src/testutil/psbt.ts index 4dd50426af..23fe79033f 100644 --- a/modules/utxo-lib/src/testutil/psbt.ts +++ b/modules/utxo-lib/src/testutil/psbt.ts @@ -29,7 +29,7 @@ import { addXpubsToPsbt, clonePsbtWithoutNonWitnessUtxo, } from '../bitgo'; -import { Network } from '../networks'; +import { getNetworkList, getNetworkName, isMainnet, Network, networks } from '../networks'; import { mockReplayProtectionUnspent, mockWalletUnspent } from './mock'; import { toOutputScript } from '../address'; import { getDefaultWalletKeys, getWalletKeysForSeed } from './keys'; @@ -51,7 +51,8 @@ export type Input = { value: bigint; }; -export type SignStage = 'unsigned' | 'halfsigned' | 'fullsigned'; +export const signStages = ['unsigned', 'halfsigned', 'fullsigned'] as const; +export type SignStage = (typeof signStages)[number]; /** * Set isInternalAddress=true for internal output address @@ -254,7 +255,8 @@ export function constructPsbt( return psbt; } -export type TxFormat = 'psbt' | 'psbt-lite'; +export const txFormats = ['psbt', 'psbt-lite'] as const; +export type TxFormat = (typeof txFormats)[number]; /** * Creates a valid PSBT with as many features as possible. @@ -321,6 +323,11 @@ export class AcidTest { return new AcidTest(network, signStage, txFormat, rootWalletKeys, otherWalletKeys, inputs, outputs); } + get name(): string { + const networkName = getNetworkName(this.network); + return `${networkName} ${this.signStage} ${this.txFormat}`; + } + createPsbt(): UtxoPsbt { const psbt = constructPsbt(this.inputs, this.outputs, this.network, this.rootWalletKeys, this.signStage, { deterministic: true, @@ -331,6 +338,16 @@ export class AcidTest { } return psbt; } + + static suite(): AcidTest[] { + return getNetworkList() + .filter((network) => isMainnet(network) && network !== networks.bitcoinsv) + .flatMap((network) => + signStages.flatMap((signStage) => + txFormats.flatMap((txFormat) => AcidTest.withDefaults(network, signStage, txFormat)) + ) + ); + } } /** diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts index 008e81b90c..5a1398d50f 100644 --- a/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts @@ -3,9 +3,7 @@ import * as assert from 'assert'; import { getStrictSignatureCount, getStrictSignatureCounts } from '../../../src/bitgo'; import { constructTxnBuilder, TxnInput } from '../../../src/testutil'; import { AcidTest, SignStage } from '../../../src/testutil/psbt'; -import { getNetworkList, getNetworkName, isMainnet, networks } from '../../../src'; - -const signs = ['unsigned', 'halfsigned', 'fullsigned'] as const; +import { getNetworkName } from '../../../src'; function signCount(signStage: SignStage) { return signStage === 'unsigned' ? 0 : signStage === 'halfsigned' ? 1 : 2; @@ -47,10 +45,8 @@ function runTx(acidTest: AcidTest) { }); } -signs.forEach((sign) => { - getNetworkList() - .filter((v) => isMainnet(v) && v !== networks.bitcoinsv) - .forEach((network) => { - runTx(AcidTest.withDefaults(network, sign, 'psbt')); - }); -}); +AcidTest.suite() + .filter((acidTest) => acidTest.txFormat === 'psbt') + .forEach((acidTest) => { + runTx(acidTest); + }); diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts index 71435567b9..36c0350c84 100644 --- a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts @@ -14,7 +14,7 @@ import { } from '../../../src/bitgo'; import { Input as TestUtilInput } from '../../../src/testutil'; import { AcidTest, InputScriptType, SignStage } from '../../../src/testutil/psbt'; -import { getNetworkList, getNetworkName, isMainnet, networks } from '../../../src'; +import { getNetworkName } from '../../../src'; import { parsePsbtMusig2Nonces, parsePsbtMusig2PartialSigs, @@ -22,8 +22,6 @@ import { } from '../../../src/bitgo/Musig2'; import { getFixture } from '../../fixture.util'; -const signs = ['unsigned', 'halfsigned', 'fullsigned'] as const; - function getSigValidArray(scriptType: InputScriptType, signStage: SignStage): Triple { if (scriptType === 'p2shP2pk' || signStage === 'unsigned') { return [false, false, false]; @@ -83,7 +81,7 @@ function runPsbt(acidTest: AcidTest) { const coin = getNetworkName(acidTest.network); const signatureCount = signCount(acidTest.signStage); - describe(`psbt build, sign and verify for ${coin} ${acidTest.signStage}`, function () { + describe(`psbt suite for ${acidTest.name}`, function () { let psbt: UtxoPsbt; before(function () { @@ -94,20 +92,6 @@ function runPsbt(acidTest: AcidTest) { assert.deepStrictEqual(psbt.toBuffer(), createPsbtFromBuffer(psbt.toBuffer(), acidTest.network).toBuffer()); }); - it(`getSignatureValidationArray with globalXpub ${coin} ${acidTest.signStage}`, function () { - psbt.data.inputs.forEach((input, inputIndex) => { - const isP2shP2pk = acidTest.inputs[inputIndex].scriptType === 'p2shP2pk'; - const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); - psbt.getSignatureValidationArray(inputIndex, { rootNodes: acidTest.rootWalletKeys.triple }).forEach((sv, i) => { - if (isP2shP2pk && acidTest.signStage !== 'unsigned' && i === 0) { - assert.strictEqual(sv, true); - } else { - assert.strictEqual(sv, expectedSigValid[i]); - } - }); - }); - }); - it('matches fixture', async function () { let finalizedPsbt: UtxoPsbt | undefined; let extractedTransaction: Buffer | undefined; @@ -130,7 +114,7 @@ function runPsbt(acidTest: AcidTest) { assert.deepStrictEqual(fixture, await getFixture(`${__dirname}/../fixtures/psbt/${filename}`, fixture)); }); - it(`getSignatureValidationArray with rootNodes ${coin} ${acidTest.signStage}`, function () { + it(`getSignatureValidationArray`, function () { psbt.data.inputs.forEach((input, inputIndex) => { const isP2shP2pk = acidTest.inputs[inputIndex].scriptType === 'p2shP2pk'; const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); @@ -144,7 +128,7 @@ function runPsbt(acidTest: AcidTest) { }); }); - it(`getSignatureValidationArrayPsbt ${coin} ${acidTest.signStage}`, function () { + it(`getSignatureValidationArrayPsbt`, function () { const sigValidations = getSignatureValidationArrayPsbt(psbt, acidTest.rootWalletKeys); psbt.data.inputs.forEach((input, inputIndex) => { const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); @@ -154,7 +138,7 @@ function runPsbt(acidTest: AcidTest) { }); }); - it(`psbt signature counts ${coin} ${acidTest.signStage}`, function () { + it(`psbt signature counts`, function () { const counts = getStrictSignatureCounts(psbt); const countsFromInputs = getStrictSignatureCounts(psbt.data.inputs); @@ -186,11 +170,6 @@ function runPsbt(acidTest: AcidTest) { }); } -signs.forEach((sign) => { - getNetworkList() - .filter((v) => isMainnet(v) && v !== networks.bitcoinsv) - .forEach((network) => { - runPsbt(AcidTest.withDefaults(network, sign, 'psbt')); - runPsbt(AcidTest.withDefaults(network, sign, 'psbt-lite')); - }); +AcidTest.suite().forEach((acidTest) => { + runPsbt(acidTest); }); From 17b5a94a8b903973c63729bca04da0831422c257 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 13:03:54 +0100 Subject: [PATCH 5/8] feat(abstract-utxo): add test for explainPsbt utility Add unit test for the explainPsbt utility function using AcidTest framework from utxo-lib. Tests ensure that transaction explanations correctly include output amounts, change outputs, and signature counts across different signing stages. Issue: BTC-2732 Co-authored-by: llm-git --- .../transaction/fixedScript/explainPsbt.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts new file mode 100644 index 0000000000..4078cf14d8 --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; + +import { testutil } from '@bitgo/utxo-lib'; + +import { explainPsbt } from '../../../../src/transaction/fixedScript'; + +function describeTransactionWith(acidTest: testutil.AcidTest) { + describe(`explainPsbt ${acidTest.name}`, function () { + it('should explain the transaction', function () { + const psbt = acidTest.createPsbt(); + const explanation = explainPsbt( + psbt, + { pubs: acidTest.rootWalletKeys.triple.map((k) => k.toBase58()) }, + acidTest.network, + { strict: true } + ); + assert.strictEqual(explanation.outputs.length, 3); + assert.strictEqual(explanation.outputAmount, '2700'); + assert.strictEqual(explanation.changeOutputs.length, acidTest.outputs.length - 3); + explanation.changeOutputs.forEach((change) => { + assert.strictEqual(change.amount, '900'); + assert.strictEqual(typeof change.address, 'string'); + }); + assert.strictEqual(explanation.inputSignatures.length, acidTest.inputs.length); + explanation.inputSignatures.forEach((signature, i) => { + if (acidTest.inputs[i].scriptType === 'p2shP2pk') { + return; + } + if (acidTest.signStage === 'unsigned') { + assert.strictEqual(signature, 0); + } else if (acidTest.signStage === 'halfsigned') { + assert.strictEqual(signature, 1); + } else if (acidTest.signStage === 'fullsigned') { + assert.strictEqual(signature, 2); + } + }); + }); + }); +} + +testutil.AcidTest.suite().forEach((test) => describeTransactionWith(test)); From fd2e902a360ba07541f75e28169304e684717f22 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 13:04:43 +0100 Subject: [PATCH 6/8] feat(abstract-utxo): refactor transaction explanation utilities Extracted helper functions from the main explainPsbt function to improve readability and maintainability. Updated getRootWalletKeys to properly handle RootWalletKeys instances and added proper typing throughout the codebase. Issue: BTC-2732 Co-authored-by: llm-git --- .../fixedScript/explainTransaction.ts | 284 +++++++++--------- .../transaction/fixedScript/explainPsbt.ts | 7 +- 2 files changed, 146 insertions(+), 145 deletions(-) diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts index ac568931f7..a79218cb81 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts @@ -92,7 +92,10 @@ function explainCommon( return { displayOrder, id: tx.getId(), ...outputDetails, fee, locktime }; } -function getRootWalletKeys(params: { pubs?: string[] }) { +function getRootWalletKeys(params: { pubs?: bitgo.RootWalletKeys | string[] }): bitgo.RootWalletKeys | undefined { + if (params.pubs instanceof bitgo.RootWalletKeys) { + return params.pubs; + } const keys = params.pubs?.map((xpub) => bip32.fromBase58(xpub)); return keys && keys.length === 3 ? new bitgo.RootWalletKeys(keys as Triple) : undefined; } @@ -100,7 +103,7 @@ function getRootWalletKeys(params: { pubs?: string[] }) { function getPsbtInputSignaturesCount( psbt: bitgo.UtxoPsbt, params: { - pubs?: string[]; + pubs?: bitgo.RootWalletKeys | string[]; } ) { const rootWalletKeys = getRootWalletKeys(params); @@ -113,7 +116,7 @@ function getTxInputSignaturesCount( tx: bitgo.UtxoTransaction, params: { txInfo?: { unspents?: bitgo.Unspent[] }; - pubs?: string[]; + pubs?: bitgo.RootWalletKeys | string[]; }, network: utxolib.Network ) { @@ -142,162 +145,165 @@ function getTxInputSignaturesCount( }); } -/** - * Decompose a raw psbt into useful information, such as the total amounts, - * change amounts, and transaction outputs. - */ -export function explainPsbt>( - psbt: bitgo.UtxoPsbt, - params: { - pubs?: string[]; - txInfo?: { unspents?: bitgo.Unspent[] }; - }, - network: utxolib.Network, - { strict = false }: { strict?: boolean } = {} -): TransactionExplanation { - const txOutputs = psbt.txOutputs; - const txInputs = psbt.txInputs; +function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) { + const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined; + if (!derivations) { + return undefined; + } + const paths = derivations.map((d) => d.path); + if (!paths || paths.length !== 3) { + throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation'); + } + if (!paths.every((p) => paths[0] === p)) { + throw new Error('expected all paths to be the same'); + } - function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) { - const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined; - if (!derivations) { - return undefined; - } - const paths = derivations.map((d) => d.path); - if (!paths || paths.length !== 3) { - throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation'); - } - if (!paths.every((p) => paths[0] === p)) { - throw new Error('expected all paths to be the same'); + paths.forEach((path) => { + if (paths[0] !== path) { + throw new Error( + 'Unable to get a single chain and index on the output because there are different paths for different keys' + ); } + }); + return utxolib.bitgo.getChainAndIndexFromPath(paths[0]); +} - paths.forEach((path) => { - if (paths[0] !== path) { - throw new Error( - 'Unable to get a single chain and index on the output because there are different paths for different keys' - ); +function getChangeInfo(psbt: bitgo.UtxoPsbt): ChangeAddressInfo[] | undefined { + try { + return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => { + const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]); + if (!derivationInformation) { + throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation'); } + return { + address: utxolib.address.fromOutputScript(psbt.txOutputs[i].script, psbt.network), + external: false, + ...derivationInformation, + }; }); - return utxolib.bitgo.getChainAndIndexFromPath(paths[0]); + } catch (e) { + if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) { + return undefined; + } + throw e; } +} - function getChangeInfo() { - try { - return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => { - const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]); - if (!derivationInformation) { - throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation'); - } - return { - address: utxolib.address.fromOutputScript(txOutputs[i].script, network), - external: false, - ...derivationInformation, - }; - }); - } catch (e) { - if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) { - return undefined; - } - throw e; - } +/** + * Extract PayGo address proof information from the PSBT if present + * @returns Information about the PayGo proof, including the output index and address + */ +function getPayGoVerificationInfo( + psbt: bitgo.UtxoPsbt, + network: utxolib.Network +): { outputIndex: number; verificationPubkey: string } | undefined { + let outputIndex: number | undefined = undefined; + let address: string | undefined = undefined; + // Check if this PSBT has any PayGo address proofs + if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) { + return undefined; } - /** - * Extract PayGo address proof information from the PSBT if present - * @returns Information about the PayGo proof, including the output index and address - */ - function getPayGoVerificationInfo(): { outputIndex: number; verificationPubkey: string } | undefined { - let outputIndex: number | undefined = undefined; - let address: string | undefined = undefined; - // Check if this PSBT has any PayGo address proofs - if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) { - return undefined; - } + // This pulls the pubkey depending on given network + const verificationPubkey = getPayGoVerificationPubkey(network); + // find which output index that contains the PayGo proof + outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt); + if (outputIndex === undefined || !verificationPubkey) { + return undefined; + } + const output = psbt.txOutputs[outputIndex]; + address = utxolib.address.fromOutputScript(output.script, network); + if (!address) { + throw new Error(`Can not derive address ${address} Pay Go Attestation.`); + } - // This pulls the pubkey depending on given network - const verificationPubkey = getPayGoVerificationPubkey(network); - // find which output index that contains the PayGo proof - outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt); - if (outputIndex === undefined || !verificationPubkey) { - return undefined; - } - const output = txOutputs[outputIndex]; - address = utxolib.address.fromOutputScript(output.script, network); - if (!address) { - throw new Error(`Can not derive address ${address} Pay Go Attestation.`); - } + return { outputIndex, verificationPubkey }; +} - return { outputIndex, verificationPubkey }; - } +/** + * Extract the BIP322 messages and addresses from the PSBT inputs and perform + * verification on the transaction to ensure that it meets the BIP322 requirements. + * @returns An array of objects containing the message and address for each input, + * or undefined if no BIP322 messages are found. + */ +function getBip322MessageInfoAndVerify(psbt: bitgo.UtxoPsbt, network: utxolib.Network): Bip322Message[] | undefined { + const bip322Messages: { message: string; address: string }[] = []; + for (let i = 0; i < psbt.data.inputs.length; i++) { + const message = bip322.getBip322ProofMessageAtIndex(psbt, i); + if (message) { + const input = psbt.data.inputs[i]; + if (!input.witnessUtxo) { + throw new Error(`Missing witnessUtxo for input index ${i}`); + } + if (!input.nonWitnessUtxo) { + throw new Error(`Missing nonWitnessUtxo for input index ${i}`); + } + const scriptPubKey = input.witnessUtxo.script; - /** - * Extract the BIP322 messages and addresses from the PSBT inputs and perform - * verification on the transaction to ensure that it meets the BIP322 requirements. - * @returns An array of objects containing the message and address for each input, - * or undefined if no BIP322 messages are found. - */ - function getBip322MessageInfoAndVerify(): Bip322Message[] | undefined { - const bip322Messages: { message: string; address: string }[] = []; - for (let i = 0; i < psbt.data.inputs.length; i++) { - const message = bip322.getBip322ProofMessageAtIndex(psbt, i); - if (message) { - const input = psbt.data.inputs[i]; - if (!input.witnessUtxo) { - throw new Error(`Missing witnessUtxo for input index ${i}`); - } - if (!input.nonWitnessUtxo) { - throw new Error(`Missing nonWitnessUtxo for input index ${i}`); - } - const scriptPubKey = input.witnessUtxo.script; - - // Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo - const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message); - const toSpendB64 = toSpend.toBuffer().toString('base64'); - if (input.nonWitnessUtxo.toString('base64') !== toSpendB64) { - throw new Error(`Non-witness UTXO does not match the expected toSpend transaction at input index ${i}`); - } - - // Verify that the toSpend transaction ID matches the input's referenced transaction ID - if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(txInputs[i]).txid) { - throw new Error(`ToSpend transaction ID does not match the input at index ${i}`); - } - - // Verify the input specifics - if (txInputs[i].sequence !== 0) { - throw new Error(`Unexpected sequence number at input index ${i}: ${txInputs[i].sequence}. Expected 0.`); - } - if (txInputs[i].index !== 0) { - throw new Error(`Unexpected input index at position ${i}: ${txInputs[i].index}. Expected 0.`); - } - - bip322Messages.push({ - message: message.toString('utf8'), - address: utxolib.address.fromOutputScript(scriptPubKey, network), - }); + // Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo + const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message); + const toSpendB64 = toSpend.toBuffer().toString('base64'); + if (input.nonWitnessUtxo.toString('base64') !== toSpendB64) { + throw new Error(`Non-witness UTXO does not match the expected toSpend transaction at input index ${i}`); } - } - if (bip322Messages.length > 0) { - // If there is a BIP322 message in any input, all inputs must have one. - if (bip322Messages.length !== psbt.data.inputs.length) { - throw new Error('Inconsistent BIP322 messages across inputs.'); + // Verify that the toSpend transaction ID matches the input's referenced transaction ID + if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(psbt.txInputs[i]).txid) { + throw new Error(`ToSpend transaction ID does not match the input at index ${i}`); } - // Verify the transaction specifics for BIP322 - if (psbt.version !== 0 && psbt.version !== 2) { - throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `); + // Verify the input specifics + if (psbt.txInputs[i].sequence !== 0) { + throw new Error(`Unexpected sequence number at input index ${i}: ${psbt.txInputs[i].sequence}. Expected 0.`); } - if (psbt.data.outputs.length !== 1 || txOutputs[0].script.toString('hex') !== '6a' || txOutputs[0].value !== 0n) { - throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`); + if (psbt.txInputs[i].index !== 0) { + throw new Error(`Unexpected input index at position ${i}: ${psbt.txInputs[i].index}. Expected 0.`); } - return bip322Messages; + bip322Messages.push({ + message: message.toString('utf8'), + address: utxolib.address.fromOutputScript(scriptPubKey, network), + }); + } + } + + if (bip322Messages.length > 0) { + // If there is a BIP322 message in any input, all inputs must have one. + if (bip322Messages.length !== psbt.data.inputs.length) { + throw new Error('Inconsistent BIP322 messages across inputs.'); } - return undefined; + // Verify the transaction specifics for BIP322 + if (psbt.version !== 0 && psbt.version !== 2) { + throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `); + } + if ( + psbt.data.outputs.length !== 1 || + psbt.txOutputs[0].script.toString('hex') !== '6a' || + psbt.txOutputs[0].value !== 0n + ) { + throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`); + } + + return bip322Messages; } - const payGoVerificationInfo = getPayGoVerificationInfo(); + return undefined; +} + +/** + * Decompose a raw psbt into useful information, such as the total amounts, + * change amounts, and transaction outputs. + */ +export function explainPsbt>( + psbt: bitgo.UtxoPsbt, + params: { + pubs?: bitgo.RootWalletKeys | string[]; + }, + network: utxolib.Network, + { strict = false }: { strict?: boolean } = {} +): TransactionExplanation { + const payGoVerificationInfo = getPayGoVerificationInfo(psbt, network); if (payGoVerificationInfo) { try { utxocore.paygo.verifyPayGoAddressProof( @@ -313,14 +319,14 @@ export function explainPsbt; const common = explainCommon(tx, { ...params, changeInfo }, network); const inputSignaturesCount = getPsbtInputSignaturesCount(psbt, params); // Set fee from subtracting inputs from outputs - const outputAmount = txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0)); + const outputAmount = psbt.txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0)); const inputAmount = psbt.txInputs.reduce((cumulative, txInput, i) => { const data = psbt.data.inputs[i]; if (data.witnessUtxo) { diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts index 4078cf14d8..4b9697a4bb 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -8,12 +8,7 @@ function describeTransactionWith(acidTest: testutil.AcidTest) { describe(`explainPsbt ${acidTest.name}`, function () { it('should explain the transaction', function () { const psbt = acidTest.createPsbt(); - const explanation = explainPsbt( - psbt, - { pubs: acidTest.rootWalletKeys.triple.map((k) => k.toBase58()) }, - acidTest.network, - { strict: true } - ); + const explanation = explainPsbt(psbt, { pubs: acidTest.rootWalletKeys }, acidTest.network, { strict: true }); assert.strictEqual(explanation.outputs.length, 3); assert.strictEqual(explanation.outputAmount, '2700'); assert.strictEqual(explanation.changeOutputs.length, acidTest.outputs.length - 3); From 6229861b85f70c3d7a91f992f9fbe71853f55db3 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 13:06:28 +0100 Subject: [PATCH 7/8] feat(abstract-utxo): remove type param from explainPsbt Simplify the explainPsbt function by removing unnecessary type parameters since the types can be inferred from the UtxoPsbt. Issue: BG-59313 Co-authored-by: llm-git --- .../src/transaction/fixedScript/explainTransaction.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts index a79218cb81..a05a2049ef 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts @@ -295,8 +295,8 @@ function getBip322MessageInfoAndVerify(psbt: bitgo.UtxoPsbt, network: utxolib.Ne * Decompose a raw psbt into useful information, such as the total amounts, * change amounts, and transaction outputs. */ -export function explainPsbt>( - psbt: bitgo.UtxoPsbt, +export function explainPsbt( + psbt: bitgo.UtxoPsbt, params: { pubs?: bitgo.RootWalletKeys | string[]; }, @@ -321,7 +321,7 @@ export function explainPsbt; + const tx = psbt.getUnsignedTx(); const common = explainCommon(tx, { ...params, changeInfo }, network); const inputSignaturesCount = getPsbtInputSignaturesCount(psbt, params); From 0912ec000390c2f46b1005ca5898e8d9733af642 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 13:23:18 +0100 Subject: [PATCH 8/8] feat(abstract-utxo): make TransactionExplanation more flexible Adjust TransactionExplanation interface to allow for undefined locktime and parameterized fee type to support both string and undefined return types. This change helps with different use cases across the codebase. Issue: BTC-2732 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 6 +++--- modules/abstract-utxo/src/impl/doge/doge.ts | 2 +- .../src/offlineVault/TransactionExplanation.ts | 6 +++--- .../src/offlineVault/descriptor/transaction.ts | 2 +- .../abstract-utxo/src/transaction/descriptor/explainPsbt.ts | 2 +- modules/abstract-utxo/src/transaction/explainTransaction.ts | 2 +- .../src/transaction/fixedScript/explainTransaction.ts | 6 +++--- .../src/transaction/fixedScript/parseTransaction.ts | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 20daac7be6..7d6da1fea5 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -200,8 +200,8 @@ export function isWalletOutput(output: Output): output is FixedScriptWalletOutpu ); } -export interface TransactionExplanation extends BaseTransactionExplanation { - locktime: number; +export interface TransactionExplanation extends BaseTransactionExplanation { + locktime?: number; /** NOTE: this actually only captures external outputs */ outputs: Output[]; changeOutputs: Output[]; @@ -872,7 +872,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { */ async explainTransaction( params: ExplainTransactionOptions - ): Promise { + ): Promise> { return explainTx(this.decodeTransactionFromPrebuild(params), params, this.network); } diff --git a/modules/abstract-utxo/src/impl/doge/doge.ts b/modules/abstract-utxo/src/impl/doge/doge.ts index 551dec78b6..3a7d1e394b 100644 --- a/modules/abstract-utxo/src/impl/doge/doge.ts +++ b/modules/abstract-utxo/src/impl/doge/doge.ts @@ -114,7 +114,7 @@ export class Doge extends AbstractUtxoCoin { async explainTransaction( params: ExplainTransactionOptions | (ExplainTransactionOptions & { txInfo: TransactionInfoJSON }) - ): Promise { + ): Promise> { return super.explainTransaction({ ...params, txInfo: params.txInfo ? parseTransactionInfo(params.txInfo as TransactionInfoJSON) : undefined, diff --git a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts index ebea0d53bc..8e7996b645 100644 --- a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts +++ b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts @@ -8,18 +8,18 @@ export interface ExplanationOutput { amount: string | number; } -export interface TransactionExplanation { +export interface TransactionExplanation { outputs: ExplanationOutput[]; changeOutputs: ExplanationOutput[]; fee: { /* network fee */ - fee: string | number; + fee: TFee; payGoFeeString: string | number | undefined; payGoFeeAddress: string | undefined; }; } -export function getTransactionExplanation(coin: string, tx: unknown): TransactionExplanation { +export function getTransactionExplanation(coin: string, tx: unknown): TransactionExplanation { if (!OfflineVaultSignable.is(tx)) { throw new Error('not a signable transaction'); } diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts index 98e5225a61..a62795cd37 100644 --- a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts +++ b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts @@ -45,7 +45,7 @@ export function getHalfSignedPsbt( export function getTransactionExplanationFromPsbt( tx: DescriptorTransaction, network: utxolib.Network -): TransactionExplanation { +): TransactionExplanation { const psbt = utxolib.bitgo.createPsbtDecode(tx.coinSpecific.txHex, network); const descriptorMap = getDescriptorsFromDescriptorTransaction(tx); const { outputs, changeOutputs, fee } = explainPsbt(psbt, descriptorMap); diff --git a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts index 9fa7af1865..58e2257c34 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts @@ -34,7 +34,7 @@ function getInputSignatures(psbt: utxolib.bitgo.UtxoPsbt): number[] { export function explainPsbt( psbt: utxolib.bitgo.UtxoPsbt, descriptors: coreDescriptors.DescriptorMap -): TransactionExplanation { +): TransactionExplanation { const parsedTransaction = coreDescriptors.parse(psbt, descriptors, psbt.network); const { inputs, outputs } = parsedTransaction; const externalOutputs = outputs.filter((o) => o.scriptId === undefined); diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index 54c943bee2..bf7663cc44 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -22,7 +22,7 @@ export function explainTx( changeInfo?: fixedScript.ChangeAddressInfo[]; }, network: utxolib.Network -): TransactionExplanation { +): TransactionExplanation { if (params.wallet && isDescriptorWallet(params.wallet)) { if (tx instanceof utxolib.bitgo.UtxoPsbt) { if (!params.pubs || !isTriple(params.pubs)) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts index a05a2049ef..d78d7627cb 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts @@ -345,7 +345,7 @@ export function explainPsbt( inputSignatures: inputSignaturesCount, signatures: inputSignaturesCount.reduce((prev, curr) => (curr > prev ? curr : prev), 0), messages, - } as TransactionExplanation; + }; } export function explainLegacyTx( @@ -356,12 +356,12 @@ export function explainLegacyTx( changeInfo?: { address: string; chain: number; index: number }[]; }, network: utxolib.Network -): TransactionExplanation { +): TransactionExplanation { const common = explainCommon(tx, params, network); const inputSignaturesCount = getTxInputSignaturesCount(tx, params, network); return { ...common, inputSignatures: inputSignaturesCount, signatures: inputSignaturesCount.reduce((prev, curr) => (curr > prev ? curr : prev), 0), - } as TransactionExplanation; + }; } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index d2294bc3ff..024ed86fa1 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -53,7 +53,7 @@ export async function parseTransaction( } // obtain all outputs - const explanation: TransactionExplanation = await coin.explainTransaction({ + const explanation: TransactionExplanation = await coin.explainTransaction({ txHex: txPrebuild.txHex, txInfo: txPrebuild.txInfo, pubs: keychainArray.map((k) => k.pub) as Triple,