From 81a349dd7065306d26a899e36bf1559dd2c2994a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 30 Apr 2026 17:06:21 +0200 Subject: [PATCH 1/6] test(abstract-utxo): add pubs param and fix musig2 signing - Add `pubs` parameter to all `signTransaction` calls for consistency - Fix test to properly sign non-standard 2-of-2 multisig scripts - Skip p2shP2pk tests when replay protection key is unavailable - Use WASM implementation for generating musig2 nonces in mock Issue: BTC-2650 Co-authored-by: llm-git --- .../test/unit/signTransaction.ts | 29 ++++++++++--------- .../abstract-utxo/test/unit/transaction.ts | 14 ++++++--- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/modules/abstract-utxo/test/unit/signTransaction.ts b/modules/abstract-utxo/test/unit/signTransaction.ts index cc79f732ba..ead5e27246 100644 --- a/modules/abstract-utxo/test/unit/signTransaction.ts +++ b/modules/abstract-utxo/test/unit/signTransaction.ts @@ -74,7 +74,7 @@ describe('signTransaction', function () { walletId: isTxWithTaprootKeyPathSpend ? wallet.id() : undefined, }, prv: userPrv, - pubs: isPsbt ? undefined : pubs, + pubs, }); assert.ok('txHex' in psbt); if (isPsbt) { @@ -92,6 +92,7 @@ describe('signTransaction', function () { const signerNoncePsbt = await coin.signTransaction({ txPrebuild: { txHex }, prv: userPrv, + pubs, signingStep: 'signerNonce', }); assert.ok('txHex' in signerNoncePsbt); @@ -109,6 +110,7 @@ describe('signTransaction', function () { const cosignerNoncePsbt = await coin.signTransaction({ txPrebuild: { ...signerNoncePsbt, walletId: wallet.id() }, + pubs, signingStep: 'cosignerNonce', }); assert.ok('txHex' in cosignerNoncePsbt); @@ -126,7 +128,7 @@ describe('signTransaction', function () { const signerSigPsbt = await coin.signTransaction({ txPrebuild: { ...cosignerNoncePsbt, txInfo: isPsbt ? undefined : { unspents } }, prv: userPrv, - pubs: isPsbt ? undefined : pubs, + pubs, signingStep: 'signerSignature', }); assert.ok('txHex' in signerSigPsbt); @@ -139,14 +141,14 @@ describe('signTransaction', function () { } it('customSigningFunction flow - PSBT with taprootKeyPathSpend inputs', async function () { - const inputs: testutil.Input[] = testutil.inputScriptTypes.map((scriptType) => ({ - scriptType, - value: BigInt(1000), - })); + const replayProtectionKey = getReplayProtectionPubkeys(coin.name)[0]; + const inputs: testutil.Input[] = testutil.inputScriptTypes + .filter((t) => t !== 'p2shP2pk' || replayProtectionKey !== undefined) + .map((scriptType) => ({ scriptType, value: BigInt(1000) })); const unspentSum = inputs.reduce((prev: bigint, curr) => prev + curr.value, BigInt(0)); const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(1000) }]; const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned', { - p2shP2pkKey: getReplayProtectionPubkeys(coin.name)[0], + p2shP2pkKey: replayProtectionKey, }); for (const v of [false, true]) { @@ -155,15 +157,15 @@ describe('signTransaction', function () { }); it('customSigningFunction flow - PSBT without taprootKeyPathSpend inputs', async function () { + const replayProtectionKey = getReplayProtectionPubkeys(coin.name)[0]; const inputs: testutil.Input[] = testutil.inputScriptTypes - .filter((v) => v !== 'taprootKeyPathSpend') - .map((scriptType) => ({ - scriptType, - value: BigInt(1000), - })); + .filter((v) => v !== 'taprootKeyPathSpend' && (v !== 'p2shP2pk' || replayProtectionKey !== undefined)) + .map((scriptType) => ({ scriptType, value: BigInt(1000) })); const unspentSum = inputs.reduce((prev: bigint, cur) => prev + cur.value, BigInt(0)); const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(1000) }]; - const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned'); + const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned', { + p2shP2pkKey: replayProtectionKey, + }); for (const v of [false, true]) { await signTransaction(psbt, v); @@ -199,6 +201,7 @@ describe('signTransaction', function () { await coin.signTransaction({ txPrebuild: { txHex: psbt.toHex() }, prv: userPrv, + pubs, signingStep: 'signerSignature', }); }, diff --git a/modules/abstract-utxo/test/unit/transaction.ts b/modules/abstract-utxo/test/unit/transaction.ts index c23794db27..71c333e19f 100644 --- a/modules/abstract-utxo/test/unit/transaction.ts +++ b/modules/abstract-utxo/test/unit/transaction.ts @@ -5,7 +5,7 @@ import * as _ from 'lodash'; import * as utxolib from '@bitgo/utxo-lib'; import nock = require('nock'); import { BIP32Interface, bitgo, testutil } from '@bitgo/utxo-lib'; -import { address as wasmAddress, fixedScriptWallet } from '@bitgo/wasm-utxo'; +import { address as wasmAddress, fixedScriptWallet, BIP32 } from '@bitgo/wasm-utxo'; import { common, FullySignedTransaction, @@ -118,11 +118,17 @@ function run( cosigner: BIP32Interface ): Promise { let scope: nock.Scope | undefined; - if (prebuild instanceof utxolib.bitgo.UtxoPsbt && isTransactionWithKeyPathSpend) { - const psbt = prebuild.clone().setAllInputsMusig2NonceHD(cosigner); + if (isTransactionWithKeyPathSpend) { scope = nock(bgUrl) .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/signpsbt`, (body) => body.psbt) - .reply(200, { psbt: psbt.toHex() }); + .reply(200, (_uri: string, requestBody: unknown) => { + const networkName = utxolib.getNetworkName(coin.network) as fixedScriptWallet.NetworkName; + const reqBytes = Buffer.from((requestBody as { psbt: string }).psbt, 'hex'); + const reqPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(reqBytes, networkName); + const cosignerWasm = BIP32.fromBase58(cosigner.toBase58()); + reqPsbt.generateMusig2Nonces(cosignerWasm); + return { psbt: Buffer.from(reqPsbt.serialize()).toString('hex') }; + }); } // half-sign with the user key From 0767c01d807c0858ca6e2a5b9a74739b80e183b4 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 30 Apr 2026 17:06:21 +0200 Subject: [PATCH 2/6] test(abstract-utxo): drop incoherent witnessScript mutation test The test overrode a P2WSH input's witnessScript while leaving witness_utxo.script_pubkey (the output commitment) unchanged, creating a PSBT whose inner script and output hash disagree. Such a transaction is unspendable on-chain regardless of signing outcome. The expected 'length mismatch' error was a utxolib implementation artifact with no security meaning. Issue: BTC-2650 --- .../test/unit/signTransaction.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/modules/abstract-utxo/test/unit/signTransaction.ts b/modules/abstract-utxo/test/unit/signTransaction.ts index ead5e27246..7491ecf82b 100644 --- a/modules/abstract-utxo/test/unit/signTransaction.ts +++ b/modules/abstract-utxo/test/unit/signTransaction.ts @@ -211,35 +211,4 @@ describe('signTransaction', function () { } ); }); - - it('fails on unsupported locking script', async function () { - const inputs: testutil.Input[] = [ - { scriptType: 'p2wsh', value: BigInt(1000) }, - { scriptType: 'p2trMusig2', value: BigInt(1000) }, - ]; - const unspentSum = inputs.reduce((prev: bigint, curr) => prev + curr.value, BigInt(0)); - const outputs: testutil.Output[] = [{ scriptType: 'p2sh', value: unspentSum - BigInt(500) }]; - const psbt = testutil.constructPsbt(inputs, outputs, coin.network, rootWalletKeys, 'unsigned'); - - // override the 1st PSBT input with unsupported 2 of 2 multi-sig locking script. - const unspent = testutil.toUnspent(inputs[0], 0, coin.network, rootWalletKeys); - if (!utxolib.bitgo.isWalletUnspent(unspent)) { - throw new Error('invalid unspent'); - } - const { publicKeys } = rootWalletKeys.deriveForChainAndIndex(unspent.chain, unspent.index); - const script2Of2 = utxolib.payments.p2ms({ m: 2, pubkeys: [publicKeys[0], publicKeys[1]] }); - psbt.data.inputs[0].witnessScript = script2Of2.output; - - await assert.rejects( - async () => { - await coin.signTransaction({ - txPrebuild: { txHex: psbt.toHex() }, - prv: userPrv, - }); - }, - { - message: `length mismatch`, - } - ); - }); }); From 76020d7d98506b85c60a865c35cbf7fa9b7068cd Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Apr 2026 11:16:41 +0200 Subject: [PATCH 3/6] test(abstract-utxo): drop utxolib backend test Utxolib backend is deprecated Issue: BTC-2650 --- modules/abstract-utxo/test/unit/customChangeWallet.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/abstract-utxo/test/unit/customChangeWallet.ts b/modules/abstract-utxo/test/unit/customChangeWallet.ts index a67cc301d3..5db70bd7db 100644 --- a/modules/abstract-utxo/test/unit/customChangeWallet.ts +++ b/modules/abstract-utxo/test/unit/customChangeWallet.ts @@ -237,5 +237,4 @@ function describeWithBackend(sdkBackend: SdkBackend) { }); } -describeWithBackend('utxolib'); describeWithBackend('wasm-utxo'); From f1c7d97099560616bde8905b1b69e6392ac5360d Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 4 May 2026 12:34:10 +0200 Subject: [PATCH 4/6] test(abstract-utxo): remove utxolib backend references Remove references to utxolib backend and SdkBackend parameter from transaction tests. Simplifies test suite by using only wasm-utxo backend, removing conditional logic and backend selection parameters. Issue: BTC-2650 Co-authored-by: llm-git --- .../test/unit/customChangeWallet.ts | 375 +++++++++--------- .../abstract-utxo/test/unit/transaction.ts | 29 +- .../unit/transaction/fixedScript/signPsbt.ts | 125 ++---- 3 files changed, 212 insertions(+), 317 deletions(-) diff --git a/modules/abstract-utxo/test/unit/customChangeWallet.ts b/modules/abstract-utxo/test/unit/customChangeWallet.ts index 5db70bd7db..92be7cc6d6 100644 --- a/modules/abstract-utxo/test/unit/customChangeWallet.ts +++ b/modules/abstract-utxo/test/unit/customChangeWallet.ts @@ -1,240 +1,225 @@ import assert from 'node:assert/strict'; import nock = require('nock'); -import { CoinName, fixedScriptWallet, BIP32, message } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, BIP32, message } from '@bitgo/wasm-utxo'; import * as utxolib from '@bitgo/utxo-lib'; import { testutil } from '@bitgo/utxo-lib'; import { common, Wallet } from '@bitgo/sdk-core'; import { getSeed } from '@bitgo/sdk-test'; -import { explainPsbt as explainPsbtUtxolib, explainPsbtWasm } from '../../src/transaction/fixedScript'; +import { explainPsbtWasm } from '../../src/transaction/fixedScript'; import { verifyKeySignature } from '../../src/verifyKey'; -import { SdkBackend } from '../../src/transaction'; import { defaultBitGo, getUtxoCoin } from './util'; function explainPsbt( - psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt, + psbt: fixedScriptWallet.BitGoPsbt, walletKeys: utxolib.bitgo.RootWalletKeys, - customChangeWalletKeys: utxolib.bitgo.RootWalletKeys | undefined, - coin: CoinName + customChangeWalletKeys: utxolib.bitgo.RootWalletKeys | undefined ) { - if (psbt instanceof fixedScriptWallet.BitGoPsbt) { - return explainPsbtWasm(psbt, fixedScriptWallet.RootWalletKeys.from(walletKeys), { - replayProtection: { publicKeys: [] }, - customChangeWalletXpubs: customChangeWalletKeys - ? fixedScriptWallet.RootWalletKeys.from(customChangeWalletKeys) - : undefined, - }); - } else { - return explainPsbtUtxolib(psbt, { pubs: walletKeys, customChangePubs: customChangeWalletKeys }, coin); - } + return explainPsbtWasm(psbt, fixedScriptWallet.RootWalletKeys.from(walletKeys), { + replayProtection: { publicKeys: [] }, + customChangeWalletXpubs: customChangeWalletKeys + ? fixedScriptWallet.RootWalletKeys.from(customChangeWalletKeys) + : undefined, + }); } -function describeWithBackend(sdkBackend: SdkBackend) { - describe(`Custom Change Wallets (sdkBackend=${sdkBackend})`, function () { - const coin = getUtxoCoin('btc'); - const network = utxolib.networks.bitcoin; - const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; - const rootWalletKeys = testutil.getDefaultWalletKeys(); - const customChangeWalletKeys = testutil.getWalletKeysForSeed('custom change'); - const userPrivateKey = BIP32.fromBase58(rootWalletKeys.triple[0].toBase58()).privateKey!; - - const mainKeyIds = rootWalletKeys.triple.map((k) => getSeed(k.neutered().toBase58()).toString('hex')); - const customChangeKeyIds = customChangeWalletKeys.triple.map((k) => - getSeed(k.neutered().toBase58()).toString('hex') +describe('Custom Change Wallets', function () { + const coin = getUtxoCoin('btc'); + const network = utxolib.networks.bitcoin; + const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; + const rootWalletKeys = testutil.getDefaultWalletKeys(); + const customChangeWalletKeys = testutil.getWalletKeysForSeed('custom change'); + const userPrivateKey = BIP32.fromBase58(rootWalletKeys.triple[0].toBase58()).privateKey!; + + const mainKeyIds = rootWalletKeys.triple.map((k) => getSeed(k.neutered().toBase58()).toString('hex')); + const customChangeKeyIds = customChangeWalletKeys.triple.map((k) => getSeed(k.neutered().toBase58()).toString('hex')); + const customChangeKeySignatures = Object.fromEntries( + (['user', 'backup', 'bitgo'] as const).map((name, i) => [ + name, + Buffer.from(message.signMessage(customChangeWalletKeys.triple[i].neutered().toBase58(), userPrivateKey)).toString( + 'hex' + ), + ]) + ) as Record<'user' | 'backup' | 'bitgo', string>; + + const inputs: testutil.Input[] = [{ scriptType: 'p2sh', value: BigInt(10000) }]; + const outputs: testutil.Output[] = [ + // regular change (uses rootWalletKeys via default) + { scriptType: 'p2sh', value: BigInt(3000) }, + // custom change (bip32Derivation from customChangeWalletKeys, not added as global xpubs) + { scriptType: 'p2sh', value: BigInt(3000), walletKeys: customChangeWalletKeys }, + // external (no derivation info) + { scriptType: 'p2sh', value: BigInt(3000), walletKeys: null }, + ]; + + const utxolibPsbt = testutil.constructPsbt(inputs, outputs, network, rootWalletKeys, 'unsigned', { + addGlobalXPubs: true, + }); + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(utxolibPsbt.toBuffer(), 'btc'); + + const externalAddress = utxolib.address.fromOutputScript(utxolibPsbt.txOutputs[2].script, network); + const customChangeWalletId = 'custom-change-wallet-id'; + const mainWalletId = 'main-wallet-id'; + + function nockKeyFetch(keyIds: string[], keys: utxolib.bitgo.RootWalletKeys): nock.Scope[] { + return keyIds.map((id, i) => + nock(bgUrl).get(`/api/v2/${coin.getChain()}/key/${id}`).reply(200, { pub: keys.triple[i].neutered().toBase58() }) ); - const customChangeKeySignatures = Object.fromEntries( - (['user', 'backup', 'bitgo'] as const).map((name, i) => [ - name, - Buffer.from( - message.signMessage(customChangeWalletKeys.triple[i].neutered().toBase58(), userPrivateKey) - ).toString('hex'), - ]) - ) as Record<'user' | 'backup' | 'bitgo', string>; - - const inputs: testutil.Input[] = [{ scriptType: 'p2sh', value: BigInt(10000) }]; - const outputs: testutil.Output[] = [ - // regular change (uses rootWalletKeys via default) - { scriptType: 'p2sh', value: BigInt(3000) }, - // custom change (bip32Derivation from customChangeWalletKeys, not added as global xpubs) - { scriptType: 'p2sh', value: BigInt(3000), walletKeys: customChangeWalletKeys }, - // external (no derivation info) - { scriptType: 'p2sh', value: BigInt(3000), walletKeys: null }, - ]; - - const utxolibPsbt = testutil.constructPsbt(inputs, outputs, network, rootWalletKeys, 'unsigned', { - addGlobalXPubs: true, + } + + function nockCustomChangeWallet(): nock.Scope { + return nock(bgUrl).get(`/api/v2/${coin.getChain()}/wallet/${customChangeWalletId}`).reply(200, { + id: customChangeWalletId, + keys: customChangeKeyIds, + coin: coin.getChain(), }); - const psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt = - sdkBackend === 'wasm-utxo' ? fixedScriptWallet.BitGoPsbt.fromBytes(utxolibPsbt.toBuffer(), 'btc') : utxolibPsbt; - - const externalAddress = utxolib.address.fromOutputScript(utxolibPsbt.txOutputs[2].script, network); - const customChangeWalletId = 'custom-change-wallet-id'; - const mainWalletId = 'main-wallet-id'; - - function nockKeyFetch(keyIds: string[], keys: utxolib.bitgo.RootWalletKeys): nock.Scope[] { - return keyIds.map((id, i) => - nock(bgUrl) - .get(`/api/v2/${coin.getChain()}/key/${id}`) - .reply(200, { pub: keys.triple[i].neutered().toBase58() }) - ); - } + } - function nockCustomChangeWallet(): nock.Scope { - return nock(bgUrl).get(`/api/v2/${coin.getChain()}/wallet/${customChangeWalletId}`).reply(200, { - id: customChangeWalletId, - keys: customChangeKeyIds, - coin: coin.getChain(), - }); - } + afterEach(() => nock.cleanAll()); - afterEach(() => nock.cleanAll()); + it('classifies custom change output when customChangePubs is provided', function () { + const explanation = explainPsbt(psbt, rootWalletKeys, customChangeWalletKeys); - it('classifies custom change output when customChangePubs is provided', function () { - const explanation = explainPsbt(psbt, rootWalletKeys, customChangeWalletKeys, 'btc'); + assert.strictEqual(explanation.changeOutputs.length, 1); + assert.strictEqual(explanation.changeOutputs[0].amount, '3000'); - assert.strictEqual(explanation.changeOutputs.length, 1); - assert.strictEqual(explanation.changeOutputs[0].amount, '3000'); + assert.ok(explanation.customChangeOutputs); + assert.strictEqual(explanation.customChangeOutputs.length, 1); + assert.strictEqual(explanation.customChangeOutputs[0].amount, '3000'); + assert.strictEqual(explanation.customChangeAmount, '3000'); - assert.ok(explanation.customChangeOutputs); - assert.strictEqual(explanation.customChangeOutputs.length, 1); - assert.strictEqual(explanation.customChangeOutputs[0].amount, '3000'); - assert.strictEqual(explanation.customChangeAmount, '3000'); + assert.strictEqual(explanation.outputs.length, 1); + assert.strictEqual(explanation.outputs[0].amount, '3000'); + }); - assert.strictEqual(explanation.outputs.length, 1); - assert.strictEqual(explanation.outputs[0].amount, '3000'); - }); + it('classifies custom change output as external without customChangePubs', function () { + const explanation = explainPsbt(psbt, rootWalletKeys, undefined); - it('classifies custom change output as external without customChangePubs', function () { - const explanation = explainPsbt(psbt, rootWalletKeys, undefined, 'btc'); + assert.strictEqual(explanation.changeOutputs.length, 1); + assert.strictEqual(explanation.changeOutputs[0].amount, '3000'); - assert.strictEqual(explanation.changeOutputs.length, 1); - assert.strictEqual(explanation.changeOutputs[0].amount, '3000'); + assert.strictEqual(explanation.customChangeOutputs?.length ?? 0, 0); - assert.strictEqual(explanation.customChangeOutputs?.length ?? 0, 0); + // custom change + external both treated as external outputs + assert.strictEqual(explanation.outputs.length, 2); + }); - // custom change + external both treated as external outputs - assert.strictEqual(explanation.outputs.length, 2); - }); + it('verifies valid custom change key signatures', function () { + const userPub = rootWalletKeys.triple[0].neutered().toBase58(); - it('verifies valid custom change key signatures', function () { - const userPub = rootWalletKeys.triple[0].neutered().toBase58(); + for (const key of customChangeWalletKeys.triple) { + const pub = key.neutered().toBase58(); + const signature = Buffer.from(message.signMessage(pub, userPrivateKey)).toString('hex'); + assert.ok( + verifyKeySignature({ userKeychain: { pub: userPub }, keychainToVerify: { pub }, keySignature: signature }) + ); + } + }); - for (const key of customChangeWalletKeys.triple) { - const pub = key.neutered().toBase58(); - const signature = Buffer.from(message.signMessage(pub, userPrivateKey)).toString('hex'); - assert.ok( - verifyKeySignature({ userKeychain: { pub: userPub }, keychainToVerify: { pub }, keySignature: signature }) - ); - } - }); + it('rejects invalid custom change key signatures', function () { + const wrongKey = BIP32.fromBase58(testutil.getWalletKeysForSeed('wrong').triple[0].toBase58()); + const userPub = rootWalletKeys.triple[0].neutered().toBase58(); - it('rejects invalid custom change key signatures', function () { - const wrongKey = BIP32.fromBase58(testutil.getWalletKeysForSeed('wrong').triple[0].toBase58()); - const userPub = rootWalletKeys.triple[0].neutered().toBase58(); - - for (const key of customChangeWalletKeys.triple) { - const pub = key.neutered().toBase58(); - const badSignature = Buffer.from(message.signMessage(pub, wrongKey.privateKey!)).toString('hex'); - assert.strictEqual( - verifyKeySignature({ userKeychain: { pub: userPub }, keychainToVerify: { pub }, keySignature: badSignature }), - false - ); + for (const key of customChangeWalletKeys.triple) { + const pub = key.neutered().toBase58(); + const badSignature = Buffer.from(message.signMessage(pub, wrongKey.privateKey!)).toString('hex'); + assert.strictEqual( + verifyKeySignature({ userKeychain: { pub: userPub }, keychainToVerify: { pub }, keySignature: badSignature }), + false + ); + } + }); + + describe('parseTransaction', function () { + it('fetches custom change wallet keys and verifies signatures', async function () { + const wallet = new Wallet(defaultBitGo, coin, { + id: mainWalletId, + keys: mainKeyIds, + coin: coin.getChain(), + coinSpecific: { customChangeWalletId }, + customChangeKeySignatures, + }); + + const nocks = [ + ...nockKeyFetch(mainKeyIds, rootWalletKeys), + nockCustomChangeWallet(), + ...nockKeyFetch(customChangeKeyIds, customChangeWalletKeys), + ]; + + const parsed = await coin.parseTransaction({ + txParams: { recipients: [{ address: externalAddress, amount: '3000' }] }, + txPrebuild: { txHex: utxolibPsbt.toHex() }, + wallet: wallet as unknown as import('../../src').UtxoWallet, + }); + + for (const n of nocks) assert.ok(n.isDone()); + + assert.ok(parsed.customChange); + assert.strictEqual(parsed.customChange.keys.length, 3); + for (let i = 0; i < 3; i++) { + assert.strictEqual(parsed.customChange.keys[i].pub, customChangeWalletKeys.triple[i].neutered().toBase58()); } + + assert.strictEqual(parsed.explicitExternalOutputs.length, 1); + assert.strictEqual(parsed.explicitExternalOutputs[0].amount, '3000'); }); - describe('parseTransaction', function () { - it('fetches custom change wallet keys and verifies signatures', async function () { - const wallet = new Wallet(defaultBitGo, coin, { - id: mainWalletId, - keys: mainKeyIds, - coin: coin.getChain(), - coinSpecific: { customChangeWalletId }, - customChangeKeySignatures, - }); - - const nocks = [ - ...nockKeyFetch(mainKeyIds, rootWalletKeys), - nockCustomChangeWallet(), - ...nockKeyFetch(customChangeKeyIds, customChangeWalletKeys), - ]; - - const parsed = await coin.parseTransaction({ - txParams: { recipients: [{ address: externalAddress, amount: '3000' }] }, - txPrebuild: { txHex: utxolibPsbt.toHex(), decodeWith: sdkBackend }, - wallet: wallet as unknown as import('../../src').UtxoWallet, - }); - - for (const n of nocks) assert.ok(n.isDone()); - - assert.ok(parsed.customChange); - assert.strictEqual(parsed.customChange.keys.length, 3); - for (let i = 0; i < 3; i++) { - assert.strictEqual(parsed.customChange.keys[i].pub, customChangeWalletKeys.triple[i].neutered().toBase58()); - } - - assert.strictEqual(parsed.explicitExternalOutputs.length, 1); - assert.strictEqual(parsed.explicitExternalOutputs[0].amount, '3000'); + it('has no custom change when wallet lacks customChangeWalletId', async function () { + const wallet = new Wallet(defaultBitGo, coin, { + id: mainWalletId, + keys: mainKeyIds, + coin: coin.getChain(), + coinSpecific: {}, }); - it('has no custom change when wallet lacks customChangeWalletId', async function () { - const wallet = new Wallet(defaultBitGo, coin, { - id: mainWalletId, - keys: mainKeyIds, - coin: coin.getChain(), - coinSpecific: {}, - }); + const nocks = nockKeyFetch(mainKeyIds, rootWalletKeys); - const nocks = nockKeyFetch(mainKeyIds, rootWalletKeys); + const parsed = await coin.parseTransaction({ + txParams: { recipients: [{ address: externalAddress, amount: '3000' }] }, + txPrebuild: { txHex: utxolibPsbt.toHex() }, + wallet: wallet as unknown as import('../../src').UtxoWallet, + }); - const parsed = await coin.parseTransaction({ - txParams: { recipients: [{ address: externalAddress, amount: '3000' }] }, - txPrebuild: { txHex: utxolibPsbt.toHex(), decodeWith: sdkBackend }, - wallet: wallet as unknown as import('../../src').UtxoWallet, - }); + for (const n of nocks) assert.ok(n.isDone()); - for (const n of nocks) assert.ok(n.isDone()); + assert.strictEqual(parsed.customChange, undefined); + assert.strictEqual(parsed.needsCustomChangeKeySignatureVerification, false); + }); - assert.strictEqual(parsed.customChange, undefined); - assert.strictEqual(parsed.needsCustomChangeKeySignatureVerification, false); + it('rejects invalid custom change key signatures', async function () { + const wrongKey = BIP32.fromBase58(testutil.getWalletKeysForSeed('wrong').triple[0].toBase58()); + const badSignatures = Object.fromEntries( + (['user', 'backup', 'bitgo'] as const).map((name, i) => [ + name, + Buffer.from( + message.signMessage(customChangeWalletKeys.triple[i].neutered().toBase58(), wrongKey.privateKey!) + ).toString('hex'), + ]) + ) as Record<'user' | 'backup' | 'bitgo', string>; + + const wallet = new Wallet(defaultBitGo, coin, { + id: mainWalletId, + keys: mainKeyIds, + coin: coin.getChain(), + coinSpecific: { customChangeWalletId }, + customChangeKeySignatures: badSignatures, }); - it('rejects invalid custom change key signatures', async function () { - const wrongKey = BIP32.fromBase58(testutil.getWalletKeysForSeed('wrong').triple[0].toBase58()); - const badSignatures = Object.fromEntries( - (['user', 'backup', 'bitgo'] as const).map((name, i) => [ - name, - Buffer.from( - message.signMessage(customChangeWalletKeys.triple[i].neutered().toBase58(), wrongKey.privateKey!) - ).toString('hex'), - ]) - ) as Record<'user' | 'backup' | 'bitgo', string>; - - const wallet = new Wallet(defaultBitGo, coin, { - id: mainWalletId, - keys: mainKeyIds, - coin: coin.getChain(), - coinSpecific: { customChangeWalletId }, - customChangeKeySignatures: badSignatures, - }); - - nockKeyFetch(mainKeyIds, rootWalletKeys); - nockCustomChangeWallet(); - nockKeyFetch(customChangeKeyIds, customChangeWalletKeys); - - await assert.rejects( - () => - coin.parseTransaction({ - txParams: { recipients: [{ address: externalAddress, amount: '3000' }] }, - txPrebuild: { txHex: utxolibPsbt.toHex(), decodeWith: sdkBackend }, - wallet: wallet as unknown as import('../../src').UtxoWallet, - }), - /failed to verify custom change .* key signature/ - ); - }); + nockKeyFetch(mainKeyIds, rootWalletKeys); + nockCustomChangeWallet(); + nockKeyFetch(customChangeKeyIds, customChangeWalletKeys); + + await assert.rejects( + () => + coin.parseTransaction({ + txParams: { recipients: [{ address: externalAddress, amount: '3000' }] }, + txPrebuild: { txHex: utxolibPsbt.toHex() }, + wallet: wallet as unknown as import('../../src').UtxoWallet, + }), + /failed to verify custom change .* key signature/ + ); }); }); -} - -describeWithBackend('wasm-utxo'); +}); diff --git a/modules/abstract-utxo/test/unit/transaction.ts b/modules/abstract-utxo/test/unit/transaction.ts index 71c333e19f..ed69fa610e 100644 --- a/modules/abstract-utxo/test/unit/transaction.ts +++ b/modules/abstract-utxo/test/unit/transaction.ts @@ -15,7 +15,6 @@ import { } from '@bitgo/sdk-core'; import { AbstractUtxoCoin, generateAddress, getReplayProtectionPubkeys } from '../../src'; -import { SdkBackend } from '../../src/transaction/types'; import type { Unspent, WalletUnspent } from '../../src/unspent'; import { @@ -38,16 +37,10 @@ import { function run( coin: AbstractUtxoCoin, inputScripts: testutil.InputScriptType[], - txFormat: 'legacy' | 'psbt', - { decodeWith }: { decodeWith?: SdkBackend } = {} + txFormat: 'legacy' | 'psbt' ) { const amountType = coin.amountType; - const title = [ - inputScripts.join(','), - `txFormat=${txFormat}`, - `amountType=${amountType}`, - decodeWith ? `decodeWith=${decodeWith}` : '', - ]; + const title = [inputScripts.join(','), `txFormat=${txFormat}`, `amountType=${amountType}`]; describe(`${title.join(' ')}`, function () { const bgUrl = common.Environments[defaultBitGo.getEnv()].uri; @@ -92,8 +85,7 @@ function run( function getSignParams( prebuildHex: string, signer: BIP32Interface, - cosigner: BIP32Interface, - decodeWith: SdkBackend | undefined + cosigner: BIP32Interface ): WalletSignTransactionOptions { const txInfo = { unspents: txFormat === 'psbt' ? undefined : getUnspents(), @@ -103,7 +95,6 @@ function run( walletId: isTransactionWithKeyPathSpend ? wallet.id() : undefined, txHex: prebuildHex, txInfo, - decodeWith, }, prv: signer.toBase58(), pubs: walletKeys.triple.map((k) => k.neutered().toBase58()), @@ -133,7 +124,7 @@ function run( // half-sign with the user key const result = (await wallet.signTransaction( - getSignParams(prebuild.toBuffer().toString('hex'), signer, cosigner, decodeWith) + getSignParams(prebuild.toBuffer().toString('hex'), signer, cosigner) )) as Promise; if (scope) { @@ -149,7 +140,7 @@ function run( cosigner: BIP32Interface ): Promise { return (await wallet.signTransaction({ - ...getSignParams(halfSigned.txHex, signer, cosigner, decodeWith), + ...getSignParams(halfSigned.txHex, signer, cosigner), isLastSignature: true, })) as FullySignedTransaction; } @@ -328,15 +319,10 @@ function run( unspents, }, pubs, - decodeWith, }); const expectedProperties = ['id', 'outputs', 'changeOutputs', 'changeAmount', 'outputAmount']; - if (decodeWith !== 'wasm-utxo') { - expectedProperties.push('displayOrder', 'inputSignatures', 'signatures'); - } - for (const prop of expectedProperties) { assert.ok(prop in explanation, `expected explanation to have property '${prop}'`); } @@ -412,16 +398,13 @@ function run( ? getUnspentsForPsbt().map((u) => ({ ...u, value: bitgo.toTNumber(u.value, amountType) as TNumber })) : getUnspents(); await testExplainTx(stageName, txHex, unspents, pubs); - if (decodeWith !== 'wasm-utxo') { - await testExplainTx(stageName, txHex, unspents, undefined); - } } }); }); } function runTestForCoin(coin: AbstractUtxoCoin) { - run(coin, getScriptTypes(coin, 'psbt'), 'psbt', { decodeWith: 'wasm-utxo' }); + run(coin, getScriptTypes(coin, 'psbt'), 'psbt'); } describe('Transaction Suite', function () { diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts index b3db89b12c..4474e66292 100644 --- a/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/signPsbt.ts @@ -3,62 +3,28 @@ import assert from 'node:assert/strict'; import * as utxolib from '@bitgo/utxo-lib'; import { BIP32, fixedScriptWallet } from '@bitgo/wasm-utxo'; -import { decodePsbtWith } from '../../../../src/transaction/decode'; import { Musig2Participant } from '../../../../src/transaction/fixedScript/musig2'; -import { signPsbtWithMusig2ParticipantUtxolib } from '../../../../src/transaction/fixedScript/signPsbtUtxolib'; import { ReplayProtectionKeys, signPsbtWithMusig2ParticipantWasm, } from '../../../../src/transaction/fixedScript/signPsbtWasm'; -import { SdkBackend } from '../../../../src/transaction/types'; -import { getCoinName } from '../../../../src/names'; - -function getMockCoinUtxolib(keys: utxolib.bitgo.RootWalletKeys): Musig2Participant { - return { - async getMusig2Nonces(psbt: utxolib.bitgo.UtxoPsbt, walletId: string): Promise { - psbt.setAllInputsMusig2NonceHD(keys.bitgo, { deterministic: true }); - return psbt; - }, - }; -} function getMockCoinWasm( keys: utxolib.bitgo.RootWalletKeys, network: utxolib.Network ): Musig2Participant { - // Convert utxolib RootWalletKeys to wasm BIP32 using base58 string - // This ensures the private key is properly transferred - const bitgoXprv = keys.bitgo.toBase58(); - const bitgoKey = BIP32.fromBase58(bitgoXprv); + const bitgoKey = BIP32.fromBase58(keys.bitgo.toBase58()); const networkName = utxolib.getNetworkName(network); assert(networkName, 'network name is required'); return { - async getMusig2Nonces(psbt: fixedScriptWallet.BitGoPsbt, walletId: string): Promise { - // Generate nonces using the bitgo key + async getMusig2Nonces(psbt: fixedScriptWallet.BitGoPsbt): Promise { psbt.generateMusig2Nonces(bitgoKey); - // Serialize and deserialize to simulate remote response - // This creates a new object so we don't get "recursive use of an object" error + // Serialize/deserialize to simulate remote response (avoids "recursive use of object" error) return fixedScriptWallet.BitGoPsbt.fromBytes(psbt.serialize(), networkName); }, }; } -function assertSignedUtxolib(psbt: utxolib.bitgo.UtxoPsbt, userKey: utxolib.BIP32Interface): void { - // Verify that all wallet inputs have been signed by user key - psbt.data.inputs.forEach((input, inputIndex) => { - const { scriptType } = utxolib.bitgo.parsePsbtInput(input); - - // Skip replay protection inputs (p2shP2pk) - if (scriptType === 'p2shP2pk') { - return; - } - - // Verify user signature is present - const isValid = psbt.validateSignaturesOfInputHD(inputIndex, userKey); - assert(isValid, `input ${inputIndex} should have valid user signature`); - }); -} - function assertSignedWasm( psbt: fixedScriptWallet.BitGoPsbt, userKey: utxolib.BIP32Interface, @@ -67,15 +33,10 @@ function assertSignedWasm( ): void { const wasmUserKey = BIP32.from(userKey); const parsed = psbt.parseTransactionWithWalletKeys(rootWalletKeys, { replayProtection }); - - // Verify that all wallet inputs have been signed by user key parsed.inputs.forEach((input, inputIndex) => { - // Skip replay protection inputs (p2shP2pk) if (input.scriptType === 'p2shP2pk') { return; } - - // Verify user signature is present const isValid = psbt.verifySignature(inputIndex, wasmUserKey); assert(isValid, `input ${inputIndex} should have valid user signature (scriptType=${input.scriptType})`); }); @@ -89,70 +50,36 @@ function toWasmWalletKeys(keys: utxolib.bitgo.RootWalletKeys): fixedScriptWallet } function getReplayProtectionKeys(keys: utxolib.bitgo.RootWalletKeys): ReplayProtectionKeys { - // Replay protection inputs use the underived user public key - return { - publicKeys: [keys.user.publicKey], - }; + return { publicKeys: [keys.user.publicKey] }; } -function describeSignPsbtWithMusig2Participant( - acidTest: utxolib.testutil.AcidTest, - { decodeWith }: { decodeWith: SdkBackend } -) { - describe(`${acidTest.name} ${decodeWith}`, function () { +function describeSignPsbtWithMusig2Participant(acidTest: utxolib.testutil.AcidTest) { + describe(acidTest.name, function () { it('should sign unsigned psbt to halfsigned', async function () { - // Create unsigned PSBT - const coinName = getCoinName(acidTest.network); - const psbt = decodePsbtWith(acidTest.createPsbt().toBuffer(), coinName, decodeWith); - - let result; - if (decodeWith === 'utxolib') { - assert(psbt instanceof utxolib.bitgo.UtxoPsbt, 'psbt should be a UtxoPsbt'); - result = await signPsbtWithMusig2ParticipantUtxolib( - getMockCoinUtxolib(acidTest.rootWalletKeys), - psbt, - acidTest.rootWalletKeys.user, - { - signingStep: undefined, - walletId: 'test-wallet-id', - } - ); - // Result should be a PSBT (not finalized) - assert(result instanceof utxolib.bitgo.UtxoPsbt, 'should return UtxoPsbt when not last signature'); - - assertSignedUtxolib(result, acidTest.rootWalletKeys.user); - } else { - assert(psbt instanceof fixedScriptWallet.BitGoPsbt, 'psbt should be a BitGoPsbt'); - const wasmWalletKeys = toWasmWalletKeys(acidTest.rootWalletKeys); - const replayProtection = getReplayProtectionKeys(acidTest.rootWalletKeys); - result = await signPsbtWithMusig2ParticipantWasm( - getMockCoinWasm(acidTest.rootWalletKeys, acidTest.network), - psbt, - acidTest.rootWalletKeys.user, - fixedScriptWallet.RootWalletKeys.from(acidTest.rootWalletKeys), - { - replayProtection, - signingStep: undefined, - walletId: 'test-wallet-id', - } - ); - // Result should be a PSBT (not finalized) - assert(result instanceof fixedScriptWallet.BitGoPsbt, 'should return BitGoPsbt when not last signature'); - - assertSignedWasm(result, acidTest.rootWalletKeys.user, wasmWalletKeys, replayProtection); - } + const networkName = utxolib.getNetworkName(acidTest.network); + assert(networkName, 'network name is required'); + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(acidTest.createPsbt().toBuffer(), networkName); + const wasmWalletKeys = toWasmWalletKeys(acidTest.rootWalletKeys); + const replayProtection = getReplayProtectionKeys(acidTest.rootWalletKeys); + const result = await signPsbtWithMusig2ParticipantWasm( + getMockCoinWasm(acidTest.rootWalletKeys, acidTest.network), + psbt, + acidTest.rootWalletKeys.user, + fixedScriptWallet.RootWalletKeys.from(acidTest.rootWalletKeys), + { + replayProtection, + signingStep: undefined, + walletId: 'test-wallet-id', + } + ); + assert(result instanceof fixedScriptWallet.BitGoPsbt, 'should return BitGoPsbt when not last signature'); + assertSignedWasm(result, acidTest.rootWalletKeys.user, wasmWalletKeys, replayProtection); }); }); } -describe('signPsbtWithMusig2ParticipantUtxolib', function () { - // Create test suite with includeP2trMusig2ScriptPath set to false - // p2trMusig2 script path inputs are signed by user and backup keys, - // which is not the typical signing pattern and makes testing more complex +describe('signPsbtWithMusig2Participant', function () { utxolib.testutil.AcidTest.suite({ includeP2trMusig2ScriptPath: false }) .filter((test) => test.signStage === 'unsigned') - .forEach((test) => { - describeSignPsbtWithMusig2Participant(test, { decodeWith: 'utxolib' }); - describeSignPsbtWithMusig2Participant(test, { decodeWith: 'wasm-utxo' }); - }); + .forEach((test) => describeSignPsbtWithMusig2Participant(test)); }); From 1702a08009eff32b12b466bf9cda31e97f31de13 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Apr 2026 11:16:41 +0200 Subject: [PATCH 5/6] feat(abstract-utxo): default to wasm-utxo for all coins Change defaultSdkBackend from a getter to a property with constant value 'wasm-utxo', removing network-based logic that favored utxolib for mainnet coins. This is effectively a noop since wallet-platform has been responding with the `decodeWith: 'wasm-utxo'` hint for many months. BREAKING CHANGE: mainnet coins now default to wasm-utxo instead of utxolib Co-authored-by: llm-git Issue: BTC-2650 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 6248c96e08..3e05ce2be1 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -81,7 +81,6 @@ import { getMainnetCoinName, getNetworkFromCoinName, isMainnetCoin, - isUtxoCoinNameMainnet, UtxoCoinName, UtxoCoinNameMainnet, } from './names'; @@ -434,9 +433,7 @@ export abstract class AbstractUtxoCoin this.amountType = amountType; } - get defaultSdkBackend(): SdkBackend { - return isUtxoCoinNameMainnet(this.name) ? 'utxolib' : 'wasm-utxo'; - } + defaultSdkBackend: SdkBackend = 'wasm-utxo'; /** * @deprecated - will be removed when we drop support for utxolib From 79849ce8e5de8292dc220467b3c0e1dea1e8740c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Apr 2026 11:02:01 +0200 Subject: [PATCH 6/6] feat(abstract-utxo): add supportedSdkBackends with validation Introduce supportedSdkBackends property to gate backend availability and validate SDK backend support before decoding PSBTs. Co-authored-by: llm-git Issue: BTC-2650 --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 3e05ce2be1..fce7791830 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -428,6 +428,11 @@ export abstract class AbstractUtxoCoin legacy: this.isMainnet(), }; + protected supportedSdkBackends: { utxolib: boolean; 'wasm-utxo': boolean } = { + utxolib: this.isMainnet(), + 'wasm-utxo': true, + }; + protected constructor(bitgo: BitGoBase, amountType: 'number' | 'bigint' = 'number') { super(bitgo); this.amountType = amountType; @@ -615,6 +620,10 @@ export abstract class AbstractUtxoCoin } if (utxolib.bitgo.isPsbt(input)) { + if (this.supportedSdkBackends[decodeWith] !== true) { + throw new Error(`SDK support for decodeWith=${decodeWith} is not available on this environment.`); + } + if (!this.supportedTxFormats.psbt) { throw new ErrorDeprecatedTxFormat('psbt'); }