From 68861ee5ce83aabc8bca0a70f0b05270418a9023 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 11 Feb 2026 11:48:49 +0100 Subject: [PATCH 1/3] feat(abstract-utxo): replace utxo-lib with wasm-utxo in tests Port Bitcoin test implementation to use wasm-utxo library instead of utxo-lib. This includes implementing utility functions to create PSBTs and maintaining the same test behavior with the new implementation. Issue: BTC-2650 Co-authored-by: llm-git --- .../test/unit/impl/btc/unit/btc.ts | 111 ++++++++++++------ 1 file changed, 75 insertions(+), 36 deletions(-) diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts b/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts index 395476735a..2d4f5b3d93 100644 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts +++ b/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts @@ -3,13 +3,45 @@ import assert from 'assert'; import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; -import * as utxolib from '@bitgo/utxo-lib'; +import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; +import * as testutils from '@bitgo/wasm-utxo/testutils'; import { Wallet } from '@bitgo/sdk-core'; import { Tbtc } from '../../../../../src/impl/btc'; import { btcBackupKey } from './fixtures'; +const { BitGoPsbt, ChainCode } = fixedScriptWallet; + +type Input = { scriptType: fixedScriptWallet.OutputScriptType; value: bigint }; +type Output = { address: string; value: bigint }; +type PsbtOptions = { lockTime?: number; sequence?: number }; + +function constructPsbt( + inputs: Input[], + outputs: Output[], + network: CoinName, + walletKeys: fixedScriptWallet.RootWalletKeys, + options?: PsbtOptions +): fixedScriptWallet.BitGoPsbt { + const psbt = BitGoPsbt.createEmpty(network, walletKeys, { lockTime: options?.lockTime }); + + inputs.forEach((input, index) => { + const chain = ChainCode.value(input.scriptType, 'external'); + psbt.addWalletInput( + { txid: '00'.repeat(32), vout: index, value: input.value, sequence: options?.sequence }, + walletKeys, + { scriptId: { chain, index }, signPath: { signer: 'user', cosigner: 'bitgo' } } + ); + }); + + outputs.forEach((output) => { + psbt.addOutput(output.address, output.value); + }); + + return psbt; +} + describe('BTC:', () => { let bitgo: TestBitGoAPI; @@ -53,19 +85,30 @@ describe('BTC:', () => { }); it('should not modify locktime on postProcessPrebuild', async () => { - const txHex = - '0100000001a8ec78f09f7acb0d344622ed3082c1a98e51ba1b1ab65406044f6e0a801609020100000000ffffffff02a0860100000000001976a9149f9a7abd600c0caa03983a77c8c3df8e062cb2fa88acfbf2150000000000220020b922cc1e737e679d24ff2d2b18cfa9fff4e35a733b4fba94282eaa1b7cfe56d200000000'; + const walletKeys = testutils.getDefaultWalletKeys(); + + // Create a PSBT with lockTime=0 and sequence=0xffffffff + const psbt = constructPsbt( + [{ scriptType: 'p2wsh' as const, value: BigInt(100000) }], + [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(90000) }], + 'tbtc', + walletKeys, + { lockTime: 0, sequence: 0xffffffff } + ); + + const txHex = Buffer.from(psbt.serialize()).toString('hex'); const blockHeight = 100; const preBuild = { txHex, blockHeight }; const postProcessBuilt = await coin.postProcessPrebuild(preBuild); - const transaction = utxolib.bitgo.createTransactionFromHex( - postProcessBuilt.txHex as string, - utxolib.networks.bitcoin - ); - transaction.locktime.should.equal(0); - const inputs = transaction.ins; - for (const input of inputs) { + // Parse result as PSBT + const resultPsbt = BitGoPsbt.fromBytes(Buffer.from(postProcessBuilt.txHex as string, 'hex'), 'tbtc'); + + resultPsbt.lockTime.should.equal(0); + + // Check sequences via parseTransactionWithWalletKeys + const parsed = resultPsbt.parseTransactionWithWalletKeys(walletKeys, { publicKeys: [] }); + for (const input of parsed.inputs) { input.sequence.should.equal(0xffffffff); } }); @@ -120,8 +163,8 @@ describe('BTC:', () => { }); it('should detect hex spoofing in BUILD_SIGN_SEND', async (): Promise => { - const keyTriple = utxolib.testutil.getKeyTriple('default'); - const rootWalletKey = new utxolib.bitgo.RootWalletKeys(keyTriple); + const keyTriple = testutils.getKeyTriple('default'); + const rootWalletKey = testutils.getDefaultWalletKeys(); const [user] = keyTriple; const wallet = new Wallet(bitgoTest, coin, { @@ -130,23 +173,21 @@ describe('BTC:', () => { keys: ['user', 'backup', 'bitgo'], }); - const originalPsbt = utxolib.testutil.constructPsbt( + // originalPsbt is created to show what the legitimate transaction would look like + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const originalPsbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const + 'tbtc', + rootWalletKey ); - utxolib.bitgo.addXpubsToPsbt(originalPsbt, rootWalletKey); - const spoofedPsbt = utxolib.testutil.constructPsbt( + const spoofedPsbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], [{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const + 'tbtc', + rootWalletKey ); - utxolib.bitgo.addXpubsToPsbt(spoofedPsbt, rootWalletKey); - const spoofedHex: string = spoofedPsbt.toHex(); + const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); const bgUrl: string = (bitgoTest as any)._baseUrl; const nock = require('nock'); @@ -194,8 +235,8 @@ describe('BTC:', () => { }); it('should detect hex spoofing in fanout BUILD_SIGN_SEND', async (): Promise => { - const keyTriple = utxolib.testutil.getKeyTriple('default'); - const rootWalletKey = new utxolib.bitgo.RootWalletKeys(keyTriple); + const keyTriple = testutils.getKeyTriple('default'); + const rootWalletKey = testutils.getDefaultWalletKeys(); const [user] = keyTriple; const wallet = new Wallet(bitgoTest, coin, { @@ -204,24 +245,22 @@ describe('BTC:', () => { keys: ['user', 'backup', 'bitgo'], }); - const originalPsbt = utxolib.testutil.constructPsbt( + // originalPsbt is created to show what the legitimate transaction would look like + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const originalPsbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const + 'tbtc', + rootWalletKey ); - utxolib.bitgo.addXpubsToPsbt(originalPsbt, rootWalletKey); - const spoofedPsbt = utxolib.testutil.constructPsbt( + const spoofedPsbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], [{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const + 'tbtc', + rootWalletKey ); - utxolib.bitgo.addXpubsToPsbt(spoofedPsbt, rootWalletKey); - const spoofedHex: string = spoofedPsbt.toHex(); + const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); const bgUrl: string = (bitgoTest as any)._baseUrl; const nock = require('nock'); From 3732a6fab4e50b0c1ddc89140c67359ea895188e Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 11 Feb 2026 12:03:38 +0100 Subject: [PATCH 2/3] refactor(abstract-utxo): consolidate and refactor unit tests Consolidate test fixtures and refactor unit tests for better organization. Move address validation tests to their own file, reorganize BTC backup key fixtures and audit tests, and consolidate spoofing protection tests. Issue: BTC-2650 Co-authored-by: llm-git --- .../impl/btc/unit/fixtures/btcBackupKey.ts | 7 - .../test/unit/impl/btc/unit/fixtures/index.ts | 1 - .../test/unit/impl/ltc/unit/index.ts | 41 ----- modules/abstract-utxo/test/unit/keychains.ts | 55 ++++++ .../test/unit/postProcessPrebuild.ts | 53 ++++++ .../unit/btc.ts => testSpoofTransaction.ts} | 168 ++---------------- modules/abstract-utxo/test/unit/util/index.ts | 1 + modules/abstract-utxo/test/unit/util/psbt.ts | 36 ++++ 8 files changed, 162 insertions(+), 200 deletions(-) delete mode 100644 modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts delete mode 100644 modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts delete mode 100644 modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts create mode 100644 modules/abstract-utxo/test/unit/postProcessPrebuild.ts rename modules/abstract-utxo/test/unit/{impl/btc/unit/btc.ts => testSpoofTransaction.ts} (52%) create mode 100644 modules/abstract-utxo/test/unit/util/psbt.ts diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts b/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts deleted file mode 100644 index 2d461c704b..0000000000 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const btcBackupKey = { - key: - '{"iv":"JgqqE4W45/tKBSMSYqD+qg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"' + - ':"ccm","adata":"","cipher":"aes","salt":"kiLPf8VSdI0=","ct":"zUh4Oko/06g02E' + - 'wnqOfzJbTwtE2p3b19jDk8Tum07Jv3N/RP7Bo0w/ObLBO1uIJFossO3nJ1JS+7t/vPQhdCtN8oD' + - '6YrZnEZYrRwN6JQkL1uYPnZ1PoWbYI9navK5CLU1KQwDTO9YEN46++OrzFH+CjpQVLblaw="}', -}; diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts b/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts deleted file mode 100644 index 2cccced5fe..0000000000 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './btcBackupKey'; diff --git a/modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts b/modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts deleted file mode 100644 index 43d0cc1266..0000000000 --- a/modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import 'should'; - -import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; - -import { Ltc, Tltc } from '../../../../../src/impl/ltc'; - -describe('Litecoin:', function () { - const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgo.initializeTestVars(); - bitgo.safeRegister('ltc', Ltc.createInstance); - bitgo.safeRegister('tltc', Tltc.createInstance); - - const ltc = bitgo.coin('ltc') as Ltc; - const tltc = bitgo.coin('tltc') as Tltc; - - describe('should validate addresses', () => { - it('should validate base58 addresses', () => { - // known valid main and testnet base58 address are valid - ltc.isValidAddress('MH6J1PzpsAfapZek7QGHv2mheUxnP8Kdek').should.be.true(); - tltc.isValidAddress('QWC1miKKHFikbwg2iyt8KZBGsTSEBKr21i').should.be.true(); - // malformed base58 addresses are invalid - ltc.isValidAddress('MH6J1PzpsAfapZek7QGHv2mheUxnP8Kder').should.be.false(); - tltc.isValidAddress('QWC1miKKHFikbwg2iyt8KZBGsTSEBKr21l').should.be.false(); - }); - it('should validate bech32 addresses', () => { - // all lower case is valid - ltc.isValidAddress('ltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3wspfryg7').should.be.true(); - tltc.isValidAddress('tltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3ws2x86ht').should.be.true(); - // all upper case is valid - ltc.isValidAddress('LTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WSPFRYG7').should.be.false(); - tltc.isValidAddress('TLTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WS2X86HT').should.be.false(); - // mixed case is invalid - ltc.isValidAddress('LTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WSPFRYg7').should.be.false(); - tltc.isValidAddress('TLTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WS2X86Ht').should.be.false(); - // malformed addresses are invalid - ltc.isValidAddress('ltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3wspfryg9').should.be.false(); - tltc.isValidAddress('tltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3ws2x86hl').should.be.false(); - }); - }); -}); diff --git a/modules/abstract-utxo/test/unit/keychains.ts b/modules/abstract-utxo/test/unit/keychains.ts index 321288c37b..74a37b07b5 100644 --- a/modules/abstract-utxo/test/unit/keychains.ts +++ b/modules/abstract-utxo/test/unit/keychains.ts @@ -1,7 +1,11 @@ import * as assert from 'assert'; import 'should'; +import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; + import { AbstractUtxoCoin } from '../../src'; +import { Tbtc } from '../../src/impl/btc'; import { utxoCoins } from './util'; @@ -16,3 +20,54 @@ function run(coin: AbstractUtxoCoin) { } utxoCoins.forEach((c) => run(c)); + +describe('Audit Key', function () { + // Encrypted backup key fixture for testing assertIsValidKey + const btcBackupKey = { + key: + '{"iv":"JgqqE4W45/tKBSMSYqD+qg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"' + + ':"ccm","adata":"","cipher":"aes","salt":"kiLPf8VSdI0=","ct":"zUh4Oko/06g02E' + + 'wnqOfzJbTwtE2p3b19jDk8Tum07Jv3N/RP7Bo0w/ObLBO1uIJFossO3nJ1JS+7t/vPQhdCtN8oD' + + '6YrZnEZYrRwN6JQkL1uYPnZ1PoWbYI9navK5CLU1KQwDTO9YEN46++OrzFH+CjpQVLblaw="}', + }; + + let bitgo: TestBitGoAPI; + let coin: Tbtc; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('tbtc', Tbtc.createInstance); + bitgo.initializeTestVars(); + coin = bitgo.coin('tbtc') as Tbtc; + }); + + it('should return for valid inputs', function () { + coin.assertIsValidKey({ + encryptedPrv: btcBackupKey.key, + walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', + }); + }); + + it('should throw error if the walletPassphrase is incorrect', function () { + assert.throws( + () => + coin.assertIsValidKey({ + encryptedPrv: btcBackupKey.key, + walletPassphrase: 'foo', + }), + { message: "failed to decrypt prv: ccm: tag doesn't match" } + ); + }); + + it('should throw if the key is altered', function () { + const alteredKey = btcBackupKey.key.replace(/[0-9]/g, '0'); + assert.throws( + () => + coin.assertIsValidKey({ + encryptedPrv: alteredKey, + walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', + }), + { message: 'failed to decrypt prv: json decrypt: invalid parameters' } + ); + }); +}); diff --git a/modules/abstract-utxo/test/unit/postProcessPrebuild.ts b/modules/abstract-utxo/test/unit/postProcessPrebuild.ts new file mode 100644 index 0000000000..7f43fa1830 --- /dev/null +++ b/modules/abstract-utxo/test/unit/postProcessPrebuild.ts @@ -0,0 +1,53 @@ +import 'should'; + +import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import * as testutils from '@bitgo/wasm-utxo/testutils'; + +import { Tbtc } from '../../src/impl/btc'; + +import { constructPsbt } from './util'; + +const { BitGoPsbt } = fixedScriptWallet; + +describe('Post Build Validation', function () { + let bitgo: TestBitGoAPI; + let coin: Tbtc; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('tbtc', Tbtc.createInstance); + bitgo.initializeTestVars(); + coin = bitgo.coin('tbtc') as Tbtc; + }); + + it('should not modify locktime on postProcessPrebuild', async function () { + const walletKeys = testutils.getDefaultWalletKeys(); + + // Create a PSBT with lockTime=0 and sequence=0xffffffff + const psbt = constructPsbt( + [{ scriptType: 'p2wsh' as const, value: BigInt(100000) }], + [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(90000) }], + 'tbtc', + walletKeys, + { lockTime: 0, sequence: 0xffffffff } + ); + + const txHex = Buffer.from(psbt.serialize()).toString('hex'); + const blockHeight = 100; + const preBuild = { txHex, blockHeight }; + const postProcessBuilt = await coin.postProcessPrebuild(preBuild); + + // Parse result as PSBT + const resultPsbt = BitGoPsbt.fromBytes(Buffer.from(postProcessBuilt.txHex as string, 'hex'), 'tbtc'); + + resultPsbt.lockTime.should.equal(0); + + // Check sequences via parseTransactionWithWalletKeys + const parsed = resultPsbt.parseTransactionWithWalletKeys(walletKeys, { publicKeys: [] }); + for (const input of parsed.inputs) { + input.sequence.should.equal(0xffffffff); + } + }); +}); diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts b/modules/abstract-utxo/test/unit/testSpoofTransaction.ts similarity index 52% rename from modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts rename to modules/abstract-utxo/test/unit/testSpoofTransaction.ts index 2d4f5b3d93..8feeb4090b 100644 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts +++ b/modules/abstract-utxo/test/unit/testSpoofTransaction.ts @@ -1,168 +1,27 @@ -import 'should'; import assert from 'assert'; import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; -import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; import * as testutils from '@bitgo/wasm-utxo/testutils'; import { Wallet } from '@bitgo/sdk-core'; -import { Tbtc } from '../../../../../src/impl/btc'; +import { Tbtc } from '../../src/impl/btc'; -import { btcBackupKey } from './fixtures'; +import { constructPsbt } from './util'; -const { BitGoPsbt, ChainCode } = fixedScriptWallet; - -type Input = { scriptType: fixedScriptWallet.OutputScriptType; value: bigint }; -type Output = { address: string; value: bigint }; -type PsbtOptions = { lockTime?: number; sequence?: number }; - -function constructPsbt( - inputs: Input[], - outputs: Output[], - network: CoinName, - walletKeys: fixedScriptWallet.RootWalletKeys, - options?: PsbtOptions -): fixedScriptWallet.BitGoPsbt { - const psbt = BitGoPsbt.createEmpty(network, walletKeys, { lockTime: options?.lockTime }); - - inputs.forEach((input, index) => { - const chain = ChainCode.value(input.scriptType, 'external'); - psbt.addWalletInput( - { txid: '00'.repeat(32), vout: index, value: input.value, sequence: options?.sequence }, - walletKeys, - { scriptId: { chain, index }, signPath: { signer: 'user', cosigner: 'bitgo' } } - ); - }); - - outputs.forEach((output) => { - psbt.addOutput(output.address, output.value); - }); - - return psbt; -} - -describe('BTC:', () => { - let bitgo: TestBitGoAPI; - - before(() => { - bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgo.safeRegister('tbtc', Tbtc.createInstance); - bitgo.initializeTestVars(); - }); - - describe('Address validation:', () => { - let coin: Tbtc; - before(() => { - coin = bitgo.coin('tbtc') as Tbtc; - }); - - it('should validate a base58 address', () => { - const validBase58Address = '2Mv1fGp8gHSqsiXYG7WqcYmHZdurDGVtUbn'; - coin.isValidAddress(validBase58Address).should.be.true(); - const invalidBase58Address = '2MV1FGP8GHSQSSXYG7WQCYMHZDURDGVTUBN'; - coin.isValidAddress(invalidBase58Address).should.be.false(); - }); - - it('should validate a bech32 address', () => { - const validBech32Address = 'tb1qtxxqmkkdx4n4lcp0nt2cct89uh3h3dlcu940kw9fcqyyq36peh0st94hfp'; - coin.isValidAddress(validBech32Address).should.be.true(); - coin.isValidAddress(validBech32Address.toUpperCase()).should.be.false(); - }); - - it('should validate a bech32m address', () => { - // https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki#Test_vectors_for_Bech32m - const validBech32mAddress = 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7'; - coin.isValidAddress(validBech32mAddress).should.be.true(); - coin.isValidAddress(validBech32mAddress.toUpperCase()).should.be.false(); - }); - }); - - describe('Post Build Validation', () => { - let coin: Tbtc; - before(() => { - coin = bitgo.coin('tbtc') as Tbtc; - }); - - it('should not modify locktime on postProcessPrebuild', async () => { - const walletKeys = testutils.getDefaultWalletKeys(); - - // Create a PSBT with lockTime=0 and sequence=0xffffffff - const psbt = constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(100000) }], - [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(90000) }], - 'tbtc', - walletKeys, - { lockTime: 0, sequence: 0xffffffff } - ); - - const txHex = Buffer.from(psbt.serialize()).toString('hex'); - const blockHeight = 100; - const preBuild = { txHex, blockHeight }; - const postProcessBuilt = await coin.postProcessPrebuild(preBuild); - - // Parse result as PSBT - const resultPsbt = BitGoPsbt.fromBytes(Buffer.from(postProcessBuilt.txHex as string, 'hex'), 'tbtc'); - - resultPsbt.lockTime.should.equal(0); - - // Check sequences via parseTransactionWithWalletKeys - const parsed = resultPsbt.parseTransactionWithWalletKeys(walletKeys, { publicKeys: [] }); - for (const input of parsed.inputs) { - input.sequence.should.equal(0xffffffff); - } - }); - }); - - describe('Audit Key', () => { - const { key } = btcBackupKey; - let coin: Tbtc; - before(() => { - coin = bitgo.coin('tbtc') as Tbtc; - }); - - it('should return for valid inputs', () => { - coin.assertIsValidKey({ - encryptedPrv: key, - walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', - }); - }); - - it('should throw error if the walletPassphrase is incorrect', () => { - assert.throws( - () => - coin.assertIsValidKey({ - encryptedPrv: key, - walletPassphrase: 'foo', - }), - { message: "failed to decrypt prv: ccm: tag doesn't match" } - ); - }); - - it('should return throw if the key is altered', () => { - const alteredKey = key.replace(/[0-9]/g, '0'); - assert.throws( - () => - coin.assertIsValidKey({ - encryptedPrv: alteredKey, - walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', - }), - { message: 'failed to decrypt prv: json decrypt: invalid parameters' } - ); - }); - }); - - describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', () => { +describe('Transaction Spoofability Tests', function () { + describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', function () { let coin: Tbtc; let bitgoTest: TestBitGoAPI; - before(() => { + + before(function () { bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' }); bitgoTest.safeRegister('tbtc', Tbtc.createInstance); bitgoTest.initializeTestVars(); coin = bitgoTest.coin('tbtc') as Tbtc; }); - it('should detect hex spoofing in BUILD_SIGN_SEND', async (): Promise => { + it('should detect hex spoofing in BUILD_SIGN_SEND', async function (): Promise { const keyTriple = testutils.getKeyTriple('default'); const rootWalletKey = testutils.getDefaultWalletKeys(); const [user] = keyTriple; @@ -189,6 +48,7 @@ describe('BTC:', () => { ); const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const bgUrl: string = (bitgoTest as any)._baseUrl; const nock = require('nock'); @@ -198,6 +58,7 @@ describe('BTC:', () => { nock(bgUrl) .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any .reply((requestBody: any) => { if (requestBody?.txHex === spoofedHex) { throw new Error('Spoofed transaction was sent: spoofing protection failed'); @@ -217,6 +78,7 @@ describe('BTC:', () => { await assert.rejects( wallet.consolidateUnspents({ walletPassphrase: 'pass' }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any (e: any) => typeof e?.message === 'string' && e.message.includes('prebuild attempts to spend to unintended external recipients') @@ -224,17 +86,18 @@ describe('BTC:', () => { }); }); - describe('Unspent management spoofability - Fanout (BUILD_SIGN_SEND)', () => { + describe('Unspent management spoofability - Fanout (BUILD_SIGN_SEND)', function () { let coin: Tbtc; let bitgoTest: TestBitGoAPI; - before(() => { + + before(function () { bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' }); bitgoTest.safeRegister('tbtc', Tbtc.createInstance); bitgoTest.initializeTestVars(); coin = bitgoTest.coin('tbtc') as Tbtc; }); - it('should detect hex spoofing in fanout BUILD_SIGN_SEND', async (): Promise => { + it('should detect hex spoofing in fanout BUILD_SIGN_SEND', async function (): Promise { const keyTriple = testutils.getKeyTriple('default'); const rootWalletKey = testutils.getDefaultWalletKeys(); const [user] = keyTriple; @@ -262,6 +125,7 @@ describe('BTC:', () => { ); const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const bgUrl: string = (bitgoTest as any)._baseUrl; const nock = require('nock'); @@ -271,6 +135,7 @@ describe('BTC:', () => { nock(bgUrl) .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any .reply((requestBody: any) => { if (requestBody?.txHex === spoofedHex) { throw new Error('Spoofed transaction was sent: spoofing protection failed'); @@ -290,6 +155,7 @@ describe('BTC:', () => { await assert.rejects( wallet.fanoutUnspents({ walletPassphrase: 'pass' }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any (e: any) => typeof e?.message === 'string' && e.message.includes('prebuild attempts to spend to unintended external recipients') diff --git a/modules/abstract-utxo/test/unit/util/index.ts b/modules/abstract-utxo/test/unit/util/index.ts index 6063354ca6..588b1ae008 100644 --- a/modules/abstract-utxo/test/unit/util/index.ts +++ b/modules/abstract-utxo/test/unit/util/index.ts @@ -4,3 +4,4 @@ export * from './keychains'; export * from './wallet'; export * from './unspents'; export * from './transaction'; +export * from './psbt'; diff --git a/modules/abstract-utxo/test/unit/util/psbt.ts b/modules/abstract-utxo/test/unit/util/psbt.ts new file mode 100644 index 0000000000..a50aed7d1f --- /dev/null +++ b/modules/abstract-utxo/test/unit/util/psbt.ts @@ -0,0 +1,36 @@ +import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; + +const { BitGoPsbt, ChainCode } = fixedScriptWallet; + +export type PsbtInput = { scriptType: fixedScriptWallet.OutputScriptType; value: bigint }; +export type PsbtOutput = { address: string; value: bigint }; +export type PsbtOptions = { lockTime?: number; sequence?: number }; + +/** + * Construct a test PSBT with the given inputs and outputs. + * Uses dummy txids (all zeros) for inputs. + */ +export function constructPsbt( + inputs: PsbtInput[], + outputs: PsbtOutput[], + network: CoinName, + walletKeys: fixedScriptWallet.RootWalletKeys, + options?: PsbtOptions +): fixedScriptWallet.BitGoPsbt { + const psbt = BitGoPsbt.createEmpty(network, walletKeys, { lockTime: options?.lockTime }); + + inputs.forEach((input, index) => { + const chain = ChainCode.value(input.scriptType, 'external'); + psbt.addWalletInput( + { txid: '00'.repeat(32), vout: index, value: input.value, sequence: options?.sequence }, + walletKeys, + { scriptId: { chain, index }, signPath: { signer: 'user', cosigner: 'bitgo' } } + ); + }); + + outputs.forEach((output) => { + psbt.addOutput(output.address, output.value); + }); + + return psbt; +} From 2b6aa3772944ea2d851bf992ef9d0c45201501b3 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 11 Feb 2026 12:18:38 +0100 Subject: [PATCH 3/3] refactor(abstract-utxo): reduce test boilerplate - util/address.ts: Extract getWalletAddress helper from psbt.ts for generating addresses from RootWalletKeys, enabling dynamic address generation in tests instead of hardcoded values - util/nockBitGo.ts: Add nockWalletKeys helper to encapsulate the repeated pattern of mocking wallet key fetch endpoints, reducing 10+ lines of boilerplate per test to a single function call - util/index.ts: Export nockBitGo and address utilities so they're accessible from the central util import - postProcessPrebuild.ts: Use getUtxoCoin('tbtc') instead of manual TestBitGo setup, and generate output address dynamically instead of hardcoding - testSpoofTransaction.ts: Replace hardcoded addresses with dynamically generated ones using wallet keys vs attacker keys, making the test intent clearer. Use nockBitGo() and nockWalletKeys() to eliminate URL extraction and key mocking boilerplate Co-authored-by: Cursor TICKET: BTC-2650 --- .../test/unit/postProcessPrebuild.ts | 19 +-- .../test/unit/recovery/backupKeyRecovery.ts | 4 +- .../backupKeyRecoveryUnspentGathering.ts | 4 +- .../recovery/formatBackupKeyRecoveryResult.ts | 4 +- .../test/unit/testSpoofTransaction.ts | 109 ++++-------------- .../abstract-utxo/test/unit/util/address.ts | 24 ++++ modules/abstract-utxo/test/unit/util/index.ts | 2 + .../abstract-utxo/test/unit/util/keychains.ts | 9 +- .../abstract-utxo/test/unit/util/nockBitGo.ts | 19 ++- .../abstract-utxo/test/unit/util/unspents.ts | 43 +------ 10 files changed, 90 insertions(+), 147 deletions(-) create mode 100644 modules/abstract-utxo/test/unit/util/address.ts diff --git a/modules/abstract-utxo/test/unit/postProcessPrebuild.ts b/modules/abstract-utxo/test/unit/postProcessPrebuild.ts index 7f43fa1830..f827fda821 100644 --- a/modules/abstract-utxo/test/unit/postProcessPrebuild.ts +++ b/modules/abstract-utxo/test/unit/postProcessPrebuild.ts @@ -1,34 +1,23 @@ import 'should'; -import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import * as testutils from '@bitgo/wasm-utxo/testutils'; -import { Tbtc } from '../../src/impl/btc'; - -import { constructPsbt } from './util'; +import { constructPsbt, getWalletAddress, getUtxoCoin } from './util'; const { BitGoPsbt } = fixedScriptWallet; describe('Post Build Validation', function () { - let bitgo: TestBitGoAPI; - let coin: Tbtc; - - before(function () { - bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgo.safeRegister('tbtc', Tbtc.createInstance); - bitgo.initializeTestVars(); - coin = bitgo.coin('tbtc') as Tbtc; - }); + const coin = getUtxoCoin('tbtc'); it('should not modify locktime on postProcessPrebuild', async function () { const walletKeys = testutils.getDefaultWalletKeys(); + const walletAddress = getWalletAddress('tbtc', walletKeys); // Create a PSBT with lockTime=0 and sequence=0xffffffff const psbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(100000) }], - [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(90000) }], + [{ address: walletAddress, value: BigInt(90000) }], 'tbtc', walletKeys, { lockTime: 0, sequence: 0xffffffff } diff --git a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts index 5a6b5244ce..84bd20f74b 100644 --- a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts @@ -8,11 +8,11 @@ import { BIP32, ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, backupKeyRecoveryWithWalletUnspents } from '../../../src'; import type { WalletUnspent } from '../../../src/unspent'; import { + createWasmWalletKeys, getDefaultWasmWalletKeys, getFixture, getNormalTestnetCoin, getWalletAddress, - getWalletKeys, toUnspent, utxoCoins, } from '../util'; @@ -48,7 +48,7 @@ function run( const defaultFeeRateSatB = 100; describe(`Backup Key Recovery PSBT [${[coin.getChain(), ...tags].join(',')}]`, function () { - const externalWallet = getWalletKeys('external'); + const { walletKeys: externalWallet } = createWasmWalletKeys('external'); const recoveryDestination = getWalletAddress(coin.name, externalWallet); const fixtureCoin = getNormalTestnetCoin(coin); // Get xpubs from wallet keys diff --git a/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts b/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts index a8e76817ea..6d915f1086 100644 --- a/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts +++ b/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts @@ -13,12 +13,12 @@ import { } from '../../../src'; import type { Unspent } from '../../../src/unspent'; import { + createWasmWalletKeys, defaultBitGo, encryptKeychain, getDefaultWasmWalletKeys, getMinUtxoCoins, getWalletAddress, - getWalletKeys, keychainsBase58, toUnspentWithPrevTx, WalletUnspentWithPrevTx, @@ -79,7 +79,7 @@ describe('Backup Key Recovery - Unspent Gathering', function () { getMinUtxoCoins().forEach((coin) => { describe(`Unspent Gathering [${coin.getChain()}]`, function () { - const externalWallet = getWalletKeys('external'); + const { walletKeys: externalWallet } = createWasmWalletKeys('external'); const recoveryDestination = getWalletAddress(coin.name, externalWallet); before('mock', function () { diff --git a/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts b/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts index 26d526382a..6b783624e7 100644 --- a/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts +++ b/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts @@ -10,10 +10,10 @@ import { } from '../../../src'; import type { WalletUnspent } from '../../../src/unspent'; import { + createWasmWalletKeys, getDefaultWasmWalletKeys, getFixture, getWalletAddress, - getWalletKeys, shouldEqualJSON, toUnspent, utxoCoins, @@ -52,7 +52,7 @@ function clonePsbt(psbt: fixedScriptWallet.BitGoPsbt): fixedScriptWallet.BitGoPs } describe('formatBackupKeyRecoveryResult', function () { - const externalWallet = getWalletKeys('external'); + const { walletKeys: externalWallet } = createWasmWalletKeys('external'); const recoveryDestination = getWalletAddress(coin.name, externalWallet); const feeRateSatVB = 100; diff --git a/modules/abstract-utxo/test/unit/testSpoofTransaction.ts b/modules/abstract-utxo/test/unit/testSpoofTransaction.ts index 8feeb4090b..ac51335553 100644 --- a/modules/abstract-utxo/test/unit/testSpoofTransaction.ts +++ b/modules/abstract-utxo/test/unit/testSpoofTransaction.ts @@ -1,62 +1,40 @@ import assert from 'assert'; -import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; -import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import * as testutils from '@bitgo/wasm-utxo/testutils'; import { Wallet } from '@bitgo/sdk-core'; -import { Tbtc } from '../../src/impl/btc'; - -import { constructPsbt } from './util'; +import { constructPsbt, getWalletAddress, getUtxoCoin, defaultBitGo, nockBitGo, nockWalletKeys } from './util'; describe('Transaction Spoofability Tests', function () { - describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', function () { - let coin: Tbtc; - let bitgoTest: TestBitGoAPI; - - before(function () { - bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgoTest.safeRegister('tbtc', Tbtc.createInstance); - bitgoTest.initializeTestVars(); - coin = bitgoTest.coin('tbtc') as Tbtc; - }); + const coin = getUtxoCoin('tbtc'); - it('should detect hex spoofing in BUILD_SIGN_SEND', async function (): Promise { + describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', function () { + it('should detect spoofed consolidation to attacker address', async function (): Promise { const keyTriple = testutils.getKeyTriple('default'); - const rootWalletKey = testutils.getDefaultWalletKeys(); - const [user] = keyTriple; + const walletKeys = testutils.getDefaultWalletKeys(); + const attackerKeys = testutils.getWalletKeysForSeed('attacker'); - const wallet = new Wallet(bitgoTest, coin, { + const wallet = new Wallet(defaultBitGo, coin, { id: '5b34252f1bf349930e34020a', coin: 'tbtc', keys: ['user', 'backup', 'bitgo'], }); - // originalPsbt is created to show what the legitimate transaction would look like - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const originalPsbt = constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }], - 'tbtc', - rootWalletKey - ); + // The attacker replaces the legitimate wallet address with their own + const attackerAddress = getWalletAddress('tbtc', attackerKeys); const spoofedPsbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }], + [{ address: attackerAddress, value: BigInt(9000) }], 'tbtc', - rootWalletKey + walletKeys // Input uses wallet keys (the funds being stolen) ); const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bgUrl: string = (bitgoTest as any)._baseUrl; - const nock = require('nock'); - - nock(bgUrl) + nockBitGo() .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/consolidateUnspents`) .reply(200, { txHex: spoofedHex, consolidateId: 'test' }); - nock(bgUrl) + nockBitGo() .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) // eslint-disable-next-line @typescript-eslint/no-explicit-any .reply((requestBody: any) => { @@ -66,15 +44,7 @@ describe('Transaction Spoofability Tests', function () { return [200, { txid: 'test-txid-123', status: 'signed' }]; }); - const pubs = keyTriple.map((k) => k.neutered().toBase58()); - const responses = [ - { pub: pubs[0], encryptedPrv: encrypt('pass', user.toBase58()) }, - { pub: pubs[1] }, - { pub: pubs[2] }, - ]; - wallet - .keyIds() - .forEach((id, i) => nock(bgUrl).get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i])); + nockWalletKeys(wallet, keyTriple, 'pass'); await assert.rejects( wallet.consolidateUnspents({ walletPassphrase: 'pass' }), @@ -87,53 +57,32 @@ describe('Transaction Spoofability Tests', function () { }); describe('Unspent management spoofability - Fanout (BUILD_SIGN_SEND)', function () { - let coin: Tbtc; - let bitgoTest: TestBitGoAPI; - - before(function () { - bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgoTest.safeRegister('tbtc', Tbtc.createInstance); - bitgoTest.initializeTestVars(); - coin = bitgoTest.coin('tbtc') as Tbtc; - }); - - it('should detect hex spoofing in fanout BUILD_SIGN_SEND', async function (): Promise { + it('should detect spoofed fanout to attacker address', async function (): Promise { const keyTriple = testutils.getKeyTriple('default'); - const rootWalletKey = testutils.getDefaultWalletKeys(); - const [user] = keyTriple; + const walletKeys = testutils.getDefaultWalletKeys(); + const attackerKeys = testutils.getWalletKeysForSeed('attacker'); - const wallet = new Wallet(bitgoTest, coin, { + const wallet = new Wallet(defaultBitGo, coin, { id: '5b34252f1bf349930e34020a', coin: 'tbtc', keys: ['user', 'backup', 'bitgo'], }); - // originalPsbt is created to show what the legitimate transaction would look like - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const originalPsbt = constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }], - 'tbtc', - rootWalletKey - ); - + // The attacker replaces the legitimate wallet address with their own + const attackerAddress = getWalletAddress('tbtc', attackerKeys); const spoofedPsbt = constructPsbt( [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }], + [{ address: attackerAddress, value: BigInt(9000) }], 'tbtc', - rootWalletKey + walletKeys // Input uses wallet keys (the funds being stolen) ); const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bgUrl: string = (bitgoTest as any)._baseUrl; - const nock = require('nock'); - - nock(bgUrl) + nockBitGo() .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/fanoutUnspents`) .reply(200, { txHex: spoofedHex, fanoutId: 'test' }); - nock(bgUrl) + nockBitGo() .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) // eslint-disable-next-line @typescript-eslint/no-explicit-any .reply((requestBody: any) => { @@ -143,15 +92,7 @@ describe('Transaction Spoofability Tests', function () { return [200, { txid: 'test-txid-123', status: 'signed' }]; }); - const pubs = keyTriple.map((k) => k.neutered().toBase58()); - const responses = [ - { pub: pubs[0], encryptedPrv: encrypt('pass', user.toBase58()) }, - { pub: pubs[1] }, - { pub: pubs[2] }, - ]; - wallet - .keyIds() - .forEach((id, i) => nock(bgUrl).get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i])); + nockWalletKeys(wallet, keyTriple, 'pass'); await assert.rejects( wallet.fanoutUnspents({ walletPassphrase: 'pass' }), diff --git a/modules/abstract-utxo/test/unit/util/address.ts b/modules/abstract-utxo/test/unit/util/address.ts new file mode 100644 index 0000000000..21cabb1c4a --- /dev/null +++ b/modules/abstract-utxo/test/unit/util/address.ts @@ -0,0 +1,24 @@ +import * as utxolib from '@bitgo/utxo-lib'; +import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; + +const { ChainCode } = fixedScriptWallet; + +type UtxolibRootWalletKeys = utxolib.bitgo.RootWalletKeys; +type WasmRootWalletKeys = fixedScriptWallet.RootWalletKeys; +type RootWalletKeys = UtxolibRootWalletKeys | WasmRootWalletKeys; + +const defaultChain = ChainCode.value('p2sh', 'external'); + +/** + * Generate a wallet address from RootWalletKeys. + * Supports both utxolib and wasm-utxo RootWalletKeys. + * Utxolib keys are converted to wasm-utxo keys for address generation. + */ +export function getWalletAddress( + coinName: CoinName, + walletKeys: RootWalletKeys, + chain = defaultChain, + index = 0 +): string { + return fixedScriptWallet.address(walletKeys, chain, index, coinName); +} diff --git a/modules/abstract-utxo/test/unit/util/index.ts b/modules/abstract-utxo/test/unit/util/index.ts index 588b1ae008..e4e8f85b6d 100644 --- a/modules/abstract-utxo/test/unit/util/index.ts +++ b/modules/abstract-utxo/test/unit/util/index.ts @@ -5,3 +5,5 @@ export * from './wallet'; export * from './unspents'; export * from './transaction'; export * from './psbt'; +export * from './address'; +export * from './nockBitGo'; diff --git a/modules/abstract-utxo/test/unit/util/keychains.ts b/modules/abstract-utxo/test/unit/util/keychains.ts index b55636e025..a7f9ad5ee4 100644 --- a/modules/abstract-utxo/test/unit/util/keychains.ts +++ b/modules/abstract-utxo/test/unit/util/keychains.ts @@ -1,7 +1,7 @@ import { Triple } from '@bitgo/sdk-core'; import { encrypt } from '@bitgo/sdk-api'; import { getSeed } from '@bitgo/sdk-test'; -import { bip32, BIP32Interface, bitgo, testutil } from '@bitgo/utxo-lib'; +import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib'; import { BIP32 as WasmBIP32, fixedScriptWallet } from '@bitgo/wasm-utxo'; type RootWalletKeys = bitgo.RootWalletKeys; @@ -93,13 +93,16 @@ export function getWalletKeys(seed: string): RootWalletKeys { /** * Create test wallet keys from a seed string. - * Returns xpubs and xprivs as base58 strings. + * Uses the same seed generation as getWalletKeys (getSeed('seed/i')) + * to ensure compatibility with existing test fixtures. */ export function createTestWalletKeys(seed: string): { xpubs: Triple; xprivs: Triple; } { - const keys = testutil.getKeyTriple(seed); + const keys = Array.from({ length: 3 }).map((_, i) => + bip32.fromSeed(getSeed(`${seed}/${i}`)) + ) as Triple; return { xpubs: keys.map((k) => k.neutered().toBase58()) as Triple, xprivs: keys.map((k) => k.toBase58()) as Triple, diff --git a/modules/abstract-utxo/test/unit/util/nockBitGo.ts b/modules/abstract-utxo/test/unit/util/nockBitGo.ts index cd4cbd7983..eec60d50d5 100644 --- a/modules/abstract-utxo/test/unit/util/nockBitGo.ts +++ b/modules/abstract-utxo/test/unit/util/nockBitGo.ts @@ -1,5 +1,7 @@ import nock = require('nock'); -import { Environment, Environments } from '@bitgo/sdk-core'; +import { encrypt } from '@bitgo/sdk-api'; +import { Environment, Environments, Wallet } from '@bitgo/sdk-core'; +import { BIP32, Triple } from '@bitgo/wasm-utxo'; import { defaultBitGo } from './utxoCoins'; @@ -7,3 +9,18 @@ export function nockBitGo(bitgo = defaultBitGo): nock.Scope { const env = Environments[bitgo.getEnv()] as Environment; return nock(env.uri); } + +/** + * Mock the key fetching endpoints for a wallet. + * Sets up nock to return the key triple with the user key encrypted. + */ +export function nockWalletKeys(wallet: Wallet, keyTriple: Triple, userPassphrase: string): void { + const [user] = keyTriple; + const pubs = keyTriple.map((k) => k.neutered().toBase58()); + const responses = [ + { pub: pubs[0], encryptedPrv: encrypt(userPassphrase, user.toBase58()) }, + { pub: pubs[1] }, + { pub: pubs[2] }, + ]; + wallet.keyIds().forEach((id, i) => nockBitGo().get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i])); +} diff --git a/modules/abstract-utxo/test/unit/util/unspents.ts b/modules/abstract-utxo/test/unit/util/unspents.ts index c02734cd9a..c303a90139 100644 --- a/modules/abstract-utxo/test/unit/util/unspents.ts +++ b/modules/abstract-utxo/test/unit/util/unspents.ts @@ -6,7 +6,9 @@ import { getReplayProtectionAddresses } from '../../../src'; import { getCoinName, isUtxoCoinName, type UtxoCoinName } from '../../../src/names'; import type { Unspent, UnspentWithPrevTx, WalletUnspent } from '../../../src/unspent'; -const { scriptTypeForChain, chainCodesP2sh, getExternalChainCode, getInternalChainCode } = utxolib.bitgo; +import { getWalletAddress } from './address'; + +const { chainCodesP2sh, getExternalChainCode, getInternalChainCode } = utxolib.bitgo; export type UtxolibRootWalletKeys = utxolib.bitgo.RootWalletKeys; export type WasmRootWalletKeys = wasmUtxo.fixedScriptWallet.RootWalletKeys; @@ -38,41 +40,6 @@ export type Input = { const defaultChain: ChainCode = getExternalChainCode(chainCodesP2sh); -/** - * Check if walletKeys is wasm-utxo RootWalletKeys. - */ -function isWasmRootWalletKeys(walletKeys: RootWalletKeys): walletKeys is WasmRootWalletKeys { - return walletKeys instanceof wasmUtxo.fixedScriptWallet.RootWalletKeys; -} - -export function getOutputScript( - walletKeys: UtxolibRootWalletKeys, - chain = defaultChain, - index = 0 -): utxolib.bitgo.outputScripts.SpendableScript { - return utxolib.bitgo.outputScripts.createOutputScript2of3( - walletKeys.deriveForChainAndIndex(chain, index).publicKeys, - scriptTypeForChain(chain) - ); -} - -export function getWalletAddress( - network: NetworkArg, - walletKeys: RootWalletKeys, - chain = defaultChain, - index = 0 -): string { - const coinName = toCoinName(network); - - // Use wasm-utxo address generation for wasm-utxo RootWalletKeys - if (isWasmRootWalletKeys(walletKeys)) { - return wasmUtxo.fixedScriptWallet.address(walletKeys, chain, index, coinName); - } - - // For utxolib RootWalletKeys, generate address from output script - return wasmUtxo.address.fromOutputScriptWithCoin(getOutputScript(walletKeys, chain, index).scriptPubKey, coinName); -} - function mockOutputIdForAddress(address: string) { return getSeed(address).toString('hex') + ':1'; } @@ -88,7 +55,7 @@ export function mockWalletUnspent( if (chain === undefined) { throw new Error(`unspent chain must be set`); } - const deriveAddress = getWalletAddress(network, walletKeys, chain, index); + const deriveAddress = getWalletAddress(toCoinName(network), walletKeys, chain, index); if (address) { if (address !== deriveAddress) { throw new Error(`derivedAddress mismatch: ${address} derived=${deriveAddress}`); @@ -288,7 +255,7 @@ export function toUnspentWithPrevTx( const { prevTx, txid } = createMockPrevTx(0, outputScript, input.value); // Get the wallet address - const address = getWalletAddress(network, rootWalletKeys, chain, index); + const address = getWalletAddress(toCoinName(network), rootWalletKeys, chain, index); // Use the actual txid from the prevTx in the unspent id return {