From 0c9a46023baf6f39206044a1d30b1fdaaedd5b5d Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Mon, 8 Jun 2026 10:41:15 -0400 Subject: [PATCH] test: add integration test for consolidate unspents Ticket: WCN-764 --- .../integration/accelerate.integ.test.ts | 63 +++++- .../consolidateUnspents.integ.test.ts | 200 ++++++++++++++++++ .../bitgo/prebuildTx.accelerate.tbtc.json | 11 +- .../bitgo/prebuildTx.consolidate.tbtc.json | 11 + .../integration/helpers/mockBitgoServer.ts | 12 +- 5 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/integration/consolidateUnspents.integ.test.ts create mode 100644 src/__tests__/integration/fixtures/bitgo/prebuildTx.consolidate.tbtc.json diff --git a/src/__tests__/integration/accelerate.integ.test.ts b/src/__tests__/integration/accelerate.integ.test.ts index f0eb63a9..88e574af 100644 --- a/src/__tests__/integration/accelerate.integ.test.ts +++ b/src/__tests__/integration/accelerate.integ.test.ts @@ -5,7 +5,7 @@ import { SigningMode } from '../../shared/types'; /** * Deterministic test keypair derived from Buffer.alloc(64, 0x42) — a public, reproducible seed. - * Not a secret. Never funded. Matches getKeychain.user.json and prebuildTx.tbtc.json. + * Not a secret. Never funded. Matches getKeychain.user.json and prebuildTx.accelerate.tbtc.json. */ const USER_XPUB = 'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH'; @@ -23,6 +23,22 @@ const accelerateRequestBody = { maxFee: 10000, }; +interface TransferEntry { + address: string; + value: number; + isChange?: boolean; +} + +interface AccelerateResponse { + txid: string; + tx: string; + status: string; + transfer: { + txid: string; + entries: TransferEntry[]; + }; +} + describe('Accelerate: EXTERNAL signing', () => { let services: IntegServices; @@ -50,19 +66,29 @@ describe('Accelerate: EXTERNAL signing', () => { ); res.status.should.equal(200); - const body = (await res.json()) as { txid: string; tx: string; status: string }; + const body = (await res.json()) as AccelerateResponse; body.should.have.property('txid', 'test-tx-id'); body.should.have.property('tx', '01000000000101030a0000'); body.should.have.property('status', 'signed'); + body.transfer.should.have.property('txid', 'test-tx-id'); + body.transfer.entries.should.be.Array().and.have.length(2); /** * In external mode, AWM delegates signing to the key provider. * POST /sign must be called — not POST /key (no local key retrieval for signing). + * The signablePayload for BTC is a PSBT hex. */ - services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(1); + const signCalls = services.keyProvider.calls.filter((c) => c.path === '/sign'); + signCalls.should.have.length(1); + const signBody = signCalls[0].body as { signablePayload: string }; + signBody.signablePayload.should.startWith('70736274ff'); + services.keyProvider.calls.filter((c) => c.path === '/key').should.have.length(0); - /** BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send */ + /** + * BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send. + * cpfpTxIds and txHex both survive the TxSendBody whitelist and appear in tx/send. + */ const buildCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')); buildCalls.should.have.length(1); const buildBody = buildCalls[0].body as { cpfpTxIds?: string[] }; @@ -71,7 +97,15 @@ describe('Accelerate: EXTERNAL signing', () => { services.bitgo.calls .filter((c) => c.path.endsWith('/public/block/latest')) .should.have.length(1); - services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')).should.have.length(1); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(1); + const sendBody = sendCalls[0].body as { + cpfpTxIds?: string[]; + txHex?: string; + }; + sendBody.should.have.property('cpfpTxIds').which.deepEqual([CPFP_TX_ID]); + sendBody.txHex!.should.startWith('70736274ff'); }); }); @@ -119,10 +153,12 @@ describe('Accelerate: LOCAL signing', () => { ); res.status.should.equal(200); - const body = (await res.json()) as { txid: string; tx: string; status: string }; + const body = (await res.json()) as AccelerateResponse; body.should.have.property('txid', 'test-tx-id'); body.should.have.property('tx', '01000000000101030a0000'); body.should.have.property('status', 'signed'); + body.transfer.should.have.property('txid', 'test-tx-id'); + body.transfer.entries.should.be.Array().and.have.length(2); /** * In local mode, AWM retrieves the xprv via GET /key/:pub and signs internally. @@ -131,7 +167,10 @@ describe('Accelerate: LOCAL signing', () => { services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(0); services.keyProvider.calls.filter((c) => c.path.startsWith('/key/')).length.should.be.above(0); - /** BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send */ + /** + * BitGo must receive tx/build with the correct cpfpTxIds, block/latest, and tx/send. + * cpfpTxIds and txHex both survive the TxSendBody whitelist and appear in tx/send. + */ const buildCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')); buildCalls.should.have.length(1); const buildBody = buildCalls[0].body as { cpfpTxIds?: string[] }; @@ -140,6 +179,14 @@ describe('Accelerate: LOCAL signing', () => { services.bitgo.calls .filter((c) => c.path.endsWith('/public/block/latest')) .should.have.length(1); - services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')).should.have.length(1); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(1); + const sendBody = sendCalls[0].body as { + cpfpTxIds?: string[]; + txHex?: string; + }; + sendBody.should.have.property('cpfpTxIds').which.deepEqual([CPFP_TX_ID]); + sendBody.txHex!.should.startWith('70736274ff'); }); }); diff --git a/src/__tests__/integration/consolidateUnspents.integ.test.ts b/src/__tests__/integration/consolidateUnspents.integ.test.ts new file mode 100644 index 00000000..c6fd533b --- /dev/null +++ b/src/__tests__/integration/consolidateUnspents.integ.test.ts @@ -0,0 +1,200 @@ +import 'should'; +import { startServices, IntegServices } from './helpers/setup'; +import { LOCALHOST } from './helpers/servers'; +import { SigningMode } from '../../shared/types'; + +/** + * Deterministic test keypair derived from Buffer.alloc(64, 0x42) — a public, reproducible seed. + * Not a secret. Never funded. Matches getKeychain.user.json and prebuildTx.consolidate.tbtc.json. + */ +const USER_XPUB = + 'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH'; +const USER_XPRV = + 'xprv9s21ZrQH143K2SDwr5LpPcLbsf4FZJmnbzXKnqURCoqGwoR965apxWYoS2DKu2ivcMTB9uTK6XhZDEPfTeNXGf7mmACuMN6cFS5ttmrpZ3i'; + +const WALLET_ID = 'test-wallet-id'; + +const consolidateRequestBody = { + pubkey: USER_XPUB, + source: 'user' as const, + feeRate: 1000, + maxFeeRate: 2000, + minValue: 1000, +}; + +interface TransferEntry { + address: string; + value: number; + isChange?: boolean; +} + +interface ConsolidateResponse { + txid: string; + tx: string; + status: string; + transfer: { + txid: string; + status: string; + entries: TransferEntry[]; + }; +} + +describe('Consolidate unspents: EXTERNAL signing', () => { + let services: IntegServices; + + before(async () => { + services = await startServices({ signingMode: SigningMode.EXTERNAL }); + }); + + after(async () => { + await services.teardown(); + }); + + beforeEach(() => { + services.keyProvider.calls.length = 0; + services.bitgo.calls.length = 0; + }); + + it('consolidates tbtc unspents via external key provider', async () => { + const res = await fetch( + `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/${WALLET_ID}/consolidateunspents`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify(consolidateRequestBody), + }, + ); + + res.status.should.equal(200); + const body = (await res.json()) as ConsolidateResponse; + body.should.have.property('txid', 'test-tx-id'); + body.should.have.property('tx', '01000000000101030a0000'); + body.should.have.property('status', 'signed'); + body.transfer.should.have.property('txid', 'test-tx-id'); + body.transfer.entries.should.be.Array().and.have.length(2); + + /** + * In external mode, AWM delegates signing to the key provider. + * POST /sign must be called — not POST /key (no local key retrieval for signing). + * The signablePayload for BTC is a PSBT hex (starts with PSBT magic bytes 70736274ff). + */ + const signCalls = services.keyProvider.calls.filter((c) => c.path === '/sign'); + signCalls.should.have.length(1); + const signBody = signCalls[0].body as { signablePayload: string }; + signBody.signablePayload.should.startWith('70736274ff'); + + services.keyProvider.calls.filter((c) => c.path === '/key').should.have.length(0); + + /** + * BitGo must receive consolidateUnspents (not tx/build) with the consolidation params, + * then tx/send with type: 'consolidate'. tx/build is only used by sendMany and accelerate. + */ + const consolidateCalls = services.bitgo.calls.filter((c) => + c.path.endsWith('/consolidateUnspents'), + ); + consolidateCalls.should.have.length(1); + const consolidateBody = consolidateCalls[0].body as { + feeRate?: number; + maxFeeRate?: number; + minValue?: number; + txFormat?: string; + }; + consolidateBody.should.have.property('feeRate', 1000); + consolidateBody.should.have.property('maxFeeRate', 2000); + consolidateBody.should.have.property('minValue', 1000); + consolidateBody.should.have.property('txFormat', 'psbt-lite'); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(1); + const sendBody = sendCalls[0].body as { type?: string }; + sendBody.should.have.property('type', 'consolidate'); + + services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')).should.have.length(0); + }); +}); + +describe('Consolidate unspents: LOCAL signing', () => { + let services: IntegServices; + + before(async () => { + services = await startServices({ signingMode: SigningMode.LOCAL }); + + /** + * Seed the mock key provider with a known xprv so AWM can retrieve it + * via GET /key/:pub and sign the PSBT locally. The xpub must match + * getKeychain.user.json and the bip32Derivation in prebuildTx.consolidate.tbtc.json. + */ + await fetch(`http://127.0.0.1:${services.keyProvider.port}/key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pub: USER_XPUB, + prv: USER_XPRV, + coin: 'tbtc', + source: 'user', + type: 'independent', + }), + }); + }); + + after(async () => { + await services.teardown(); + }); + + beforeEach(() => { + services.keyProvider.calls.length = 0; + services.bitgo.calls.length = 0; + }); + + it('consolidates tbtc unspents using locally stored xprv', async () => { + const res = await fetch( + `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/${WALLET_ID}/consolidateunspents`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify(consolidateRequestBody), + }, + ); + + res.status.should.equal(200); + const body = (await res.json()) as ConsolidateResponse; + body.should.have.property('txid', 'test-tx-id'); + body.should.have.property('tx', '01000000000101030a0000'); + body.should.have.property('status', 'signed'); + body.transfer.should.have.property('txid', 'test-tx-id'); + body.transfer.entries.should.be.Array().and.have.length(2); + + /** + * In local mode, AWM retrieves the xprv via GET /key/:pub and signs internally. + * POST /sign must NOT be called — signing happens inside AWM, not in the key provider. + */ + services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(0); + services.keyProvider.calls.filter((c) => c.path.startsWith('/key/')).length.should.be.above(0); + + /** + * BitGo must receive consolidateUnspents (not tx/build) with the consolidation params, + * then tx/send with type: 'consolidate'. + */ + const consolidateCalls = services.bitgo.calls.filter((c) => + c.path.endsWith('/consolidateUnspents'), + ); + consolidateCalls.should.have.length(1); + const consolidateBody = consolidateCalls[0].body as { + feeRate?: number; + maxFeeRate?: number; + minValue?: number; + txFormat?: string; + }; + consolidateBody.should.have.property('feeRate', 1000); + consolidateBody.should.have.property('maxFeeRate', 2000); + consolidateBody.should.have.property('minValue', 1000); + consolidateBody.should.have.property('txFormat', 'psbt-lite'); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(1); + const sendBody = sendCalls[0].body as { type?: string }; + sendBody.should.have.property('type', 'consolidate'); + + services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')).should.have.length(0); + }); +}); diff --git a/src/__tests__/integration/fixtures/bitgo/prebuildTx.accelerate.tbtc.json b/src/__tests__/integration/fixtures/bitgo/prebuildTx.accelerate.tbtc.json index 93a7c4d6..dbcd6bd7 100644 --- a/src/__tests__/integration/fixtures/bitgo/prebuildTx.accelerate.tbtc.json +++ b/src/__tests__/integration/fixtures/bitgo/prebuildTx.accelerate.tbtc.json @@ -1,4 +1,11 @@ { - "txHex": "70736274ff01000a01000000000000000000000000", - "txInfo": { "nP2SHInputs": 0, "nSegwitInputs": 0, "nOutputs": 0 } + "txHex": "70736274ff01005e0100000001015736beea8d00e1145b39e6e16505877e84961ffbb64071b4ed2556c53c27e20000000000ffffffff01905f010000000000220020b41d3a1e7ae51e81e1f5ac6518d6a4c449f1e1744461b9a298452913c7c14a9e000000004f010488b21e0000000000000000001860687e74faf798e258d66855e725343e64978b899441c761e05f321eece137025a0fef9917548521cc62dc96f7d55da6392e001fe6f3087f201310eb83f7bb8a04460c65944f010488b21e00000000000000000025de30d2eda67a684e7290fb06abb26e29488db6d6522a081c2e1c09d125a4c20335929e0c0d34bfec5318221142dbfa31dda3915480909706272e196974d76283046bf51aa84f010488b21e00000000000000000035ed9ee074c71886a1a6fa216a6fde3f285e7dea25044ed1d96fba451081690f0338681e310aeea9e95ee4d629655a77b72c022ec5c8b673647cea4b2f82ef67c604d3b12b170001012ba08601000000000022002008dbf93c4d3788ce02d95d4b5c6932a93df1fab66c6522c27d27bd5366e0a64a0103040100000001056952210293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a210316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a742102fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572353ae22060293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a146bf51aa800000000000000001500000000000000220602fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572314d3b12b170000000000000000150000000000000022060316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a7414460c6594000000000000000015000000000000000001016952210295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d682021038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf542102020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410153ae220202020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410114d3b12b170000000000000000140000000000000022020295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d6820146bf51aa8000000000000000014000000000000002202038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf5414460c65940000000000000000140000000000000000", + "txInfo": { + "nP2SHInputs": 0, + "nSegwitInputs": 1, + "nOutputs": 1, + "changeAddresses": [ + "tb1qkswn58n6u50grc0443j3344yc3ylrct5g3smng5cg553837pf20qgcd3u9" + ] + } } diff --git a/src/__tests__/integration/fixtures/bitgo/prebuildTx.consolidate.tbtc.json b/src/__tests__/integration/fixtures/bitgo/prebuildTx.consolidate.tbtc.json new file mode 100644 index 00000000..533da821 --- /dev/null +++ b/src/__tests__/integration/fixtures/bitgo/prebuildTx.consolidate.tbtc.json @@ -0,0 +1,11 @@ +{ + "txHex": "70736274ff0100870100000002a6758b462440d328f24027763756b0e0bc094741a5ceb0416a199dac2acc1a180000000000ffffffffe0538936ed9a8e57700a33dd4881174c625eb9189abdae3bbfa21aed9c60c7ed0100000000ffffffff01d07e010000000000220020b41d3a1e7ae51e81e1f5ac6518d6a4c449f1e1744461b9a298452913c7c14a9e000000004f010488b21e0000000000000000001860687e74faf798e258d66855e725343e64978b899441c761e05f321eece137025a0fef9917548521cc62dc96f7d55da6392e001fe6f3087f201310eb83f7bb8a04460c65944f010488b21e00000000000000000025de30d2eda67a684e7290fb06abb26e29488db6d6522a081c2e1c09d125a4c20335929e0c0d34bfec5318221142dbfa31dda3915480909706272e196974d76283046bf51aa84f010488b21e00000000000000000035ed9ee074c71886a1a6fa216a6fde3f285e7dea25044ed1d96fba451081690f0338681e310aeea9e95ee4d629655a77b72c022ec5c8b673647cea4b2f82ef67c604d3b12b170001012b50c300000000000022002008dbf93c4d3788ce02d95d4b5c6932a93df1fab66c6522c27d27bd5366e0a64a0103040100000001056952210293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a210316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a742102fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572353ae22060293bdede5bdeeb0e2c43143a84468167bb0b6666db59015da11d65d7f5ddc974a146bf51aa800000000000000001500000000000000220602fbed0170983333c42d809a3cfef91c59a37ae35f6b9351f320f798d19a82572314d3b12b170000000000000000150000000000000022060316e1bc7c3de9b4705b7cf81cab17006e9db0f89a4b6f94bd1962a36cb3dd3a7414460c6594000000000000000015000000000000000001012b50c3000000000000220020b180ecd6ef26da91ec5f739c2e7d631a525f914d3839a3d0e04a9f15a2798346010304010000000105695221024d7e8c3413c9410e9685f0b4aaeb24ef8933b7de4156a8cf07da9c7bd879151d21033b5c5699c1ae79ce9b3c6e52069b96a5c8814f6124e13655be3637006d599f4f21028e334ee478c3650865833c96e83b93579277be05a2e89d2a50e53cbdf21c732753ae2206024d7e8c3413c9410e9685f0b4aaeb24ef8933b7de4156a8cf07da9c7bd879151d146bf51aa8000000000000000015000000010000002206028e334ee478c3650865833c96e83b93579277be05a2e89d2a50e53cbdf21c732714d3b12b17000000000000000015000000010000002206033b5c5699c1ae79ce9b3c6e52069b96a5c8814f6124e13655be3637006d599f4f14460c6594000000000000000015000000010000000001016952210295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d682021038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf542102020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410153ae220202020e1b0585251821432d92d3d716774aee750cb0758b52d4e51a480a952d410114d3b12b170000000000000000140000000000000022020295bc96ecdb5ceae2716051cd835ec0e635ff3ab464a992c0b2d7dbdf969d6820146bf51aa8000000000000000014000000000000002202038c69636106774394c8369affe354e33b63a0deb196278320be2eaa273a5dcf5414460c65940000000000000000140000000000000000", + "txInfo": { + "nP2SHInputs": 0, + "nSegwitInputs": 2, + "nOutputs": 1, + "changeAddresses": [ + "tb1qkswn58n6u50grc0443j3344yc3ylrct5g3smng5cg553837pf20qgcd3u9" + ] + } +} diff --git a/src/__tests__/integration/helpers/mockBitgoServer.ts b/src/__tests__/integration/helpers/mockBitgoServer.ts index 4119a2ba..581df2c2 100644 --- a/src/__tests__/integration/helpers/mockBitgoServer.ts +++ b/src/__tests__/integration/helpers/mockBitgoServer.ts @@ -25,7 +25,10 @@ type SendManyFixtureMethod = 'getWallet' | 'prebuildTx' | 'sendTx'; type SupportedCoin = 'hteth' | 'tbtc'; type CoinToFixtures = { [K in SendManyFixtureMethod]: `${K}.${C}`; -} & { acceleratePrebuildTx: `prebuildTx.accelerate.${C}` | `prebuildTx.${C}` }; +} & { + acceleratePrebuildTx: `prebuildTx.accelerate.${C}` | `prebuildTx.${C}`; + consolidatePrebuildTx: `prebuildTx.consolidate.${C}` | `prebuildTx.${C}`; +}; /** Registry — add a new coin here to support it across all sendMany integ test routes */ const COIN_FIXTURES: { [C in SupportedCoin]: CoinToFixtures } = { @@ -34,12 +37,14 @@ const COIN_FIXTURES: { [C in SupportedCoin]: CoinToFixtures } = { prebuildTx: 'prebuildTx.hteth', sendTx: 'sendTx.hteth', acceleratePrebuildTx: 'prebuildTx.hteth', // CPFP/RBF not applicable to EVM; reuses standard prebuild + consolidatePrebuildTx: 'prebuildTx.hteth', // consolidateUnspents not applicable to EVM; reuses standard prebuild }, tbtc: { getWallet: 'getWallet.tbtc', prebuildTx: 'prebuildTx.tbtc', sendTx: 'sendTx.tbtc', acceleratePrebuildTx: 'prebuildTx.accelerate.tbtc', + consolidatePrebuildTx: 'prebuildTx.consolidate.tbtc', }, }; @@ -117,6 +122,11 @@ export async function startMockBitgoServer(): Promise { res.json(loadFixture(fixtureName)); }); + /** Consolidate unspents prebuild — coin-specific PSBT-lite fixture */ + app.post('/api/v2/:coin/wallet/:walletId/consolidateUnspents', (req, res) => { + res.json(loadFixture(coinFixtures(req.params.coin).consolidatePrebuildTx)); + }); + /** Transaction submit — coin-specific fixture */ app.post('/api/v2/:coin/wallet/:walletId/tx/send', (req, res) => { res.json(loadFixture(coinFixtures(req.params.coin).sendTx));