From 304ca86973cda3312a66bf9a1b63f91f6ed8b57d Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Mon, 1 Jun 2026 10:38:10 -0400 Subject: [PATCH] refactor: use sdk for accelerate, consolidate, and consolidateUnspents Ticket: WAL-1489 --- src/__tests__/api/master/accelerate.test.ts | 323 ++++++++--------- src/__tests__/api/master/consolidate.test.ts | 317 ++++------------- .../api/master/consolidateUnspents.test.ts | 333 +++++++----------- 3 files changed, 365 insertions(+), 608 deletions(-) diff --git a/src/__tests__/api/master/accelerate.test.ts b/src/__tests__/api/master/accelerate.test.ts index 227ff1e..62ce1f8 100644 --- a/src/__tests__/api/master/accelerate.test.ts +++ b/src/__tests__/api/master/accelerate.test.ts @@ -2,9 +2,16 @@ import 'should'; import sinon from 'sinon'; import * as request from 'supertest'; import nock from 'nock'; +import * as utxolib from '@bitgo-beta/utxo-lib'; +import { Tbtc } from '@bitgo-beta/sdk-coin-btc'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { Environments, Wallet } from '@bitgo-beta/sdk-core'; +import { BitGoAPITestHarness } from './testUtils'; + +const TBTC_PREBUILD_PSBT_HEX = utxolib.bitgo + .createPsbtForNetwork({ network: utxolib.networks.testnet }) + .toHex(); describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { let agent: request.SuperAgentTest; @@ -24,19 +31,19 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { const mockUserKeychain = { id: 'user-key-id', - pub: 'xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCWzSgHCZkdXgp', + pub: 'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg', type: 'independent', }; const mockBackupKeychain = { id: 'backup-key-id', - pub: 'xpub661MyMwAqRbcGaZrYqfYmaTRzQxM9PKEZ7GRb6DKfghkzgjk2dKT4qBXfz6WzpT4N5fXJhFW', + pub: 'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr', type: 'independent', }; const mockBitgoKeychain = { id: 'bitgo-key-id', - pub: 'xpub661MyMwAqRbcHtYNxRNuEtDFmPMRzBVPDfBXNu2RUBVFNz8MnWQgkrMZCNB', + pub: 'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy', type: 'bitgo', }; @@ -66,182 +73,196 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); - it('should succeed in accelerating transaction with CPFP using user key', async () => { - const walletGetNock = nock(bitgoApiUrl) + after(() => { + nock.enableNetConnect(); + }); + + // Keychains are fetched by getWalletAndSigningKeychain, getWalletPubs and getKeysForSigning + function nockWalletAndKeychains() { + nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockWalletData); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockUserKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); + } + + it('should succeed in accelerating transaction with CPFP using user key', async () => { + nockWalletAndKeychains(); - const accelerateTransactionStub = sinon - .stub(Wallet.prototype, 'accelerateTransaction') - .resolves({ - txid: 'accelerated-tx-id-123', - tx: '0100000001abcdef...', - status: 'signed', - hash: 'accelerated-tx-id-123', + let capturedBuildBody: any; + const buildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`, (body) => { + capturedBuildBody = body; + return true; + }) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); - const requestPayload = { - pubkey: mockUserKeychain.pub, - source: 'user' as const, - cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], - cpfpFeeRate: 50, - maxFee: 10000, - }; + sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); + + const signNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); + + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { txid: 'accelerated-tx-id-123', tx: '0100000001abcdef...', status: 'signed' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) - .send(requestPayload); + .send({ + pubkey: mockUserKeychain.pub, + source: 'user' as const, + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + maxFee: 10000, + }); response.status.should.equal(200); response.body.should.have.property('txid', 'accelerated-tx-id-123'); response.body.should.have.property('tx', '0100000001abcdef...'); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(accelerateTransactionStub); - - const callArgs = accelerateTransactionStub.firstCall.args[0]; - callArgs!.should.have.property('cpfpTxIds'); - callArgs!.should.have.property('cpfpFeeRate', 50); - callArgs!.should.have.property('maxFee', 10000); - callArgs!.should.have.property('customSigningFunction'); - callArgs!.should.have.property('reqId'); + buildNock.done(); + signNock.done(); + submitNock.done(); + // Acceleration params are forwarded to the SDK build request + capturedBuildBody.should.have.property('cpfpTxIds'); + capturedBuildBody.should.have.property('cpfpFeeRate', 50); + capturedBuildBody.should.have.property('maxFee', 10000); }); it('should succeed in accelerating transaction with RBF using backup key', async () => { - const walletGetNock = nock(bitgoApiUrl) + nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockWalletData); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) + nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); - - // All 3 keychains fetched for walletPubs nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockUserKeychain); nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); - const accelerateTransactionStub = sinon - .stub(Wallet.prototype, 'accelerateTransaction') - .resolves({ - txid: 'rbf-accelerated-tx-id', - tx: '0100000001fedcba...', + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); - const requestPayload = { - pubkey: mockBackupKeychain.pub, - source: 'backup' as const, - rbfTxIds: ['a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890'], - feeMultiplier: 1.5, - maxFee: 15000, - }; + sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); + + const signNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'backup', + pub: mockBackupKeychain.pub, + }); + + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { txid: 'rbf-accelerated-tx-id', tx: '0100000001fedcba...' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) - .send(requestPayload); + .send({ + pubkey: mockBackupKeychain.pub, + source: 'backup' as const, + rbfTxIds: ['a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890'], + feeMultiplier: 1.5, + maxFee: 15000, + }); response.status.should.equal(200); response.body.should.have.property('txid', 'rbf-accelerated-tx-id'); response.body.should.have.property('tx', '0100000001fedcba...'); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(accelerateTransactionStub); + signNock.done(); + submitNock.done(); }); it('should succeed in accelerating transaction with all optional parameters', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); - - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); + nockWalletAndKeychains(); - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); - const accelerateTransactionStub = sinon - .stub(Wallet.prototype, 'accelerateTransaction') - .resolves({ - txid: 'accelerated-with-all-params', - tx: '0100000001abcdef123...', + sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); + + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, }); - const requestPayload = { - pubkey: mockUserKeychain.pub, - source: 'user' as const, - cpfpTxIds: ['tx1', 'tx2'], - cpfpFeeRate: 100, - maxFee: 20000, - rbfTxIds: ['tx3', 'tx4'], - feeMultiplier: 2.0, - }; + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { txid: 'accelerated-with-all-params', tx: '0100000001abcdef123...' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) .set('Authorization', `Bearer ${accessToken}`) - .send(requestPayload); + .send({ + pubkey: mockUserKeychain.pub, + source: 'user' as const, + cpfpTxIds: ['tx1'], + cpfpFeeRate: 100, + maxFee: 20000, + }); response.status.should.equal(200); response.body.should.have.property('txid', 'accelerated-with-all-params'); response.body.should.have.property('tx', '0100000001abcdef123...'); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(accelerateTransactionStub); + submitNock.done(); }); it('should fail when wallet is not found', async () => { @@ -369,35 +390,12 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { response.body.should.have.property('details'); }); - it('should fail when accelerateTransaction throws an error', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); - - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); + it('should fail when transaction build fails', async () => { + nockWalletAndKeychains(); - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); - - const accelerateTransactionStub = sinon - .stub(Wallet.prototype, 'accelerateTransaction') - .rejects(new Error('Insufficient funds for acceleration')); + const buildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(400, { error: 'Insufficient funds for acceleration' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) @@ -407,16 +405,12 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { source: 'user', cpfpTxIds: ['test-tx-id'], cpfpFeeRate: 100, + maxFee: 10000, }); - response.status.should.equal(500); - response.body.should.have.property('error', 'Internal Server Error'); - response.body.should.have.property('name', 'Error'); - response.body.should.have.property('details', 'Insufficient funds for acceleration'); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(accelerateTransactionStub); + response.status.should.be.aboveOrEqual(400); + response.body.should.have.property('error'); + buildNock.done(); }); it('should fail when cpfpTxIds parameter is not an array', async () => { @@ -476,32 +470,17 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { }); it('should pass walletPubs (all 3 xpubs) to AWM for UTXO signing', async () => { - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); + nockWalletAndKeychains(); - // Signing keychain (user) — fetched once by getWalletAndSigningKeychain nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); let capturedSignBody: any; const awmSignNock = nock(advancedWalletManagerUrl) @@ -515,11 +494,10 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { pub: mockUserKeychain.pub, }); - // Stub accelerateTransaction to call customSigningFunction so the AWM request is made - sinon.stub(Wallet.prototype, 'accelerateTransaction').callsFake(async (params: any) => { - await params.customSigningFunction({ txPrebuild: { txHex: 'prebuilt-tx' } }); - return { txid: 'accelerated-tx-id', tx: '0100000001abcdef...', status: 'signed' }; - }); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { txid: 'accelerated-tx-id', tx: '0100000001abcdef...', status: 'signed' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) @@ -529,6 +507,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { source: 'user', cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], cpfpFeeRate: 50, + maxFee: 10000, }); response.status.should.equal(200); @@ -548,21 +527,19 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { .reply(200, mockWalletData); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockUserKeychain); nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, { id: 'backup-key-id' }); // no pub nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); @@ -579,6 +556,8 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { pub: mockUserKeychain.pub, }); + // The real accelerateTransaction (via prebuildAndSignTransaction) asserts k.pub on every onchain + // keychain when building signingParams.pubs, so this case never reaches customSigningFunction sinon.stub(Wallet.prototype, 'accelerateTransaction').callsFake(async (params: any) => { await params.customSigningFunction({ txPrebuild: { txHex: 'prebuilt-tx' } }); return { txid: 'accelerated-tx-id', tx: '0100000001abcdef...', status: 'signed' }; diff --git a/src/__tests__/api/master/consolidate.test.ts b/src/__tests__/api/master/consolidate.test.ts index 834bb56..c5d79be 100644 --- a/src/__tests__/api/master/consolidate.test.ts +++ b/src/__tests__/api/master/consolidate.test.ts @@ -4,11 +4,10 @@ import * as request from 'supertest'; import nock from 'nock'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; -import { Environments, Wallet } from '@bitgo-beta/sdk-core'; +import { Environments } from '@bitgo-beta/sdk-core'; import { Hteth } from '@bitgo-beta/sdk-coin-eth'; import * as transactionRequests from '../../../masterBitgoExpress/handlers/transactionRequests'; -import * as handlerUtils from '../../../masterBitgoExpress/handlers/utils/utils'; -import assert from 'assert'; +import { BitGoAPITestHarness } from './testUtils'; describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { let agent: request.SuperAgentTest; @@ -71,66 +70,56 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); - it('should succeed in consolidating multisig wallet addresses', async () => { - const walletGetNock = nock(bitgoApiUrl) + // Nocks wallet and all 3 keychains + function nockWalletAndKeychains(multisigType: 'onchain' | 'tss') { + nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData('onchain')); - - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); + .reply(200, mockWalletData(multisigType)); - // All 3 keychains fetched for walletPubs nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockUserKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); + } + + it('should succeed in consolidating multisig wallet addresses', async () => { + nockWalletAndKeychains('onchain'); const mockBuilds = [ - { - walletId, - txHex: 'unsigned-tx-hex-1', - txInfo: { unspents: [] }, - feeInfo: { fee: 1000 }, - }, - { - walletId, - txHex: 'unsigned-tx-hex-2', - txInfo: { unspents: [] }, - feeInfo: { fee: 1500 }, - }, + { consolidateId: 'consolidate-1', walletId, txHex: '0xabc111' }, + { consolidateId: 'consolidate-2', walletId, txHex: '0xabc222' }, ]; - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves(mockBuilds); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount/build`) + .reply(200, mockBuilds); - const sendAccountConsolidationStub = sinon - .stub(Wallet.prototype, 'sendAccountConsolidation') - .resolves({ - txid: 'consolidation-tx-1', - status: 'signed', - }); + sinon.stub(Hteth.prototype, 'verifyTransaction').resolves(true); - const makeCustomSigningFunctionStub = sinon - .stub(handlerUtils, 'makeCustomSigningFunction') - .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .times(2) + .reply(200, { halfSigned: { txHex: 'signed-eth-tx' } }); - const allowsConsolidationsStub = sinon - .stub(Hteth.prototype, 'allowsAccountConsolidations') - .returns(true); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .times(2) + .reply(200, { txid: 'consolidation-tx-1', status: 'signed' }); const requestPayload = { pubkey: mockUserKeychain.pub, @@ -148,44 +137,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { response.body.success.should.have.length(2); response.body.should.have.property('failure'); response.body.failure.should.have.length(0); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(buildConsolidationsStub); - sinon.assert.calledTwice(sendAccountConsolidationStub); - sinon.assert.calledTwice(makeCustomSigningFunctionStub); - const signingArgs = makeCustomSigningFunctionStub.firstCall.args[0]; - signingArgs.should.have.property('walletPubs').which.is.an.Array(); - sinon.assert.calledOnce(allowsConsolidationsStub); - - const { walletPubs } = signingArgs; - assert(walletPubs, 'Expected walletPubs to be defined'); - walletPubs.should.containEql(mockUserKeychain.pub); - walletPubs.should.containEql(mockBackupKeychain.pub); - walletPubs.should.containEql(mockBitgoKeychain.pub); }); it('should succeed in consolidating MPC wallet using signAndSendTxRequests', async () => { - const walletGetNock = nock(bitgoApiUrl) + nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockWalletData('tss')); - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, { ...mockUserKeychain, commonKeychain: 'user-common-key' }); - - // All 3 keychains fetched for walletPubs nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, { ...mockUserKeychain, commonKeychain: 'user-common-key' }); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); @@ -198,9 +169,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { txRequestId: 'mpc-tx-request-1', }; - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves([mockMpcBuild]); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount/build`) + .reply(200, [mockMpcBuild]); const getTxRequestNock = nock(bitgoApiUrl) .get(`/api/v2/wallet/${walletId}/txrequests`) @@ -227,6 +198,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { ], }); + // TSS MPC signing flow is tested in signAndSendTxRequest.test.ts const signAndSendTxRequestsStub = sinon .stub(transactionRequests, 'signAndSendTxRequests') .resolves({ @@ -235,10 +207,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { state: 'signed', }); - const allowsConsolidationsStub = sinon - .stub(Hteth.prototype, 'allowsAccountConsolidations') - .returns(true); - const requestPayload = { pubkey: mockUserKeychain.pub, source: 'user' as const, @@ -258,64 +226,28 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { response.body.should.have.property('failure'); response.body.failure.should.have.length(0); - walletGetNock.done(); - keychainGetNock.done(); getTxRequestNock.done(); - sinon.assert.calledOnce(buildConsolidationsStub); sinon.assert.calledOnce(signAndSendTxRequestsStub); - sinon.assert.calledOnce(allowsConsolidationsStub); }); it('should succeed in consolidating with backup key', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData('onchain')); + nockWalletAndKeychains('onchain'); - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); + const mockBuild = { consolidateId: 'consolidate-backup-1', walletId, txHex: '0xabc333' }; - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount/build`) + .reply(200, [mockBuild]); - const mockBuild = { - walletId, - txHex: 'unsigned-tx-hex-backup', - txInfo: { unspents: [] }, - feeInfo: { fee: 1200 }, - }; + sinon.stub(Hteth.prototype, 'verifyTransaction').resolves(true); - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves([mockBuild]); + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { halfSigned: { txHex: 'signed-eth-tx' } }); - const sendAccountConsolidationStub = sinon - .stub(Wallet.prototype, 'sendAccountConsolidation') - .resolves({ - txid: 'backup-consolidation-tx', - status: 'signed', - }); - - const makeCustomSigningFunctionStub = sinon - .stub(handlerUtils, 'makeCustomSigningFunction') - .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); - - const allowsConsolidationsStub = sinon - .stub(Hteth.prototype, 'allowsAccountConsolidations') - .returns(true); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, { txid: 'backup-consolidation-tx', status: 'signed' }); const requestPayload = { pubkey: mockBackupKeychain.pub, @@ -331,13 +263,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { response.status.should.equal(200); response.body.should.have.property('success'); response.body.success.should.have.length(1); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(buildConsolidationsStub); - sinon.assert.calledOnce(sendAccountConsolidationStub); - sinon.assert.calledOnce(makeCustomSigningFunctionStub); - sinon.assert.calledOnce(allowsConsolidationsStub); }); it('should fail when wallet is not found', async () => { @@ -412,30 +337,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { }); it('should fail when coin does not support account consolidations', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData('onchain')); - - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + nockWalletAndKeychains('onchain'); + // hteth natively returns true so this is stubbed to test the negative path const allowsConsolidationsStub = sinon .stub(Hteth.prototype, 'allowsAccountConsolidations') .returns(false); @@ -456,8 +360,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { 'Invalid coin selected - account consolidations not supported', ); - walletGetNock.done(); - keychainGetNock.done(); sinon.assert.calledOnce(allowsConsolidationsStub); }); @@ -519,53 +421,30 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { }); it('should fail when partial multisig consolidation failures occur', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData('onchain')); - - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + nockWalletAndKeychains('onchain'); const mockBuilds = [ - { walletId, txHex: 'unsigned-tx-hex-1' }, - { walletId, txHex: 'unsigned-tx-hex-2' }, + { consolidateId: 'consolidate-1', walletId, txHex: '0xabc111' }, + { consolidateId: 'consolidate-2', walletId, txHex: '0xabc222' }, ]; - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves(mockBuilds); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount/build`) + .reply(200, mockBuilds); - const sendAccountConsolidationStub = sinon.stub(Wallet.prototype, 'sendAccountConsolidation'); - sendAccountConsolidationStub.onFirstCall().resolves({ - txid: 'consolidation-tx-1', - status: 'signed', - }); - sendAccountConsolidationStub.onSecondCall().rejects(new Error('Insufficient funds')); + sinon.stub(Hteth.prototype, 'verifyTransaction').resolves(true); - const makeCustomSigningFunctionStub = sinon - .stub(handlerUtils, 'makeCustomSigningFunction') - .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + // First consolidation succeeds, second fails at AWM + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { halfSigned: { txHex: 'signed-eth-tx' } }); + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(500, { error: 'Internal Server Error', details: 'Insufficient funds' }); - const allowsConsolidationsStub = sinon - .stub(Hteth.prototype, 'allowsAccountConsolidations') - .returns(true); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, { txid: 'consolidation-tx-1', status: 'signed' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidate`) @@ -581,60 +460,27 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { response.body.should.have .property('details') .which.match(/Consolidations failed: 1 and succeeded: 1/); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(buildConsolidationsStub); - sinon.assert.calledTwice(sendAccountConsolidationStub); - sinon.assert.calledTwice(makeCustomSigningFunctionStub); - sinon.assert.calledOnce(allowsConsolidationsStub); }); it('should fail when all consolidations fail', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData('onchain')); - - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + nockWalletAndKeychains('onchain'); const mockBuilds = [ - { walletId, txHex: 'unsigned-tx-hex-1' }, - { walletId, txHex: 'unsigned-tx-hex-2' }, + { consolidateId: 'consolidate-1', walletId, txHex: '0xabc111' }, + { consolidateId: 'consolidate-2', walletId, txHex: '0xabc222' }, ]; - const buildConsolidationsStub = sinon - .stub(Wallet.prototype, 'buildAccountConsolidations') - .resolves(mockBuilds); - - const sendAccountConsolidationStub = sinon - .stub(Wallet.prototype, 'sendAccountConsolidation') - .rejects(new Error('All consolidations failed')); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateAccount/build`) + .reply(200, mockBuilds); - const makeCustomSigningFunctionStub = sinon - .stub(handlerUtils, 'makeCustomSigningFunction') - .returns(() => Promise.resolve({ txHex: 'signed-tx-hex' })); + sinon.stub(Hteth.prototype, 'verifyTransaction').resolves(true); - const allowsConsolidationsStub = sinon - .stub(Hteth.prototype, 'allowsAccountConsolidations') - .returns(true); + // Both consolidations fail at AWM + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .times(2) + .reply(500, { error: 'Internal Server Error', details: 'All consolidations failed' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidate`) @@ -648,13 +494,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { response.status.should.equal(500); response.body.should.have.property('error'); response.body.should.have.property('details').which.match(/All consolidations failed/); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(buildConsolidationsStub); - sinon.assert.calledTwice(sendAccountConsolidationStub); - sinon.assert.calledTwice(makeCustomSigningFunctionStub); - sinon.assert.calledOnce(allowsConsolidationsStub); }); it('should fail when consolidateAddresses parameter is not an array', async () => { diff --git a/src/__tests__/api/master/consolidateUnspents.test.ts b/src/__tests__/api/master/consolidateUnspents.test.ts index 08e8b3f..2826deb 100644 --- a/src/__tests__/api/master/consolidateUnspents.test.ts +++ b/src/__tests__/api/master/consolidateUnspents.test.ts @@ -2,9 +2,16 @@ import 'should'; import sinon from 'sinon'; import * as request from 'supertest'; import nock from 'nock'; +import * as utxolib from '@bitgo-beta/utxo-lib'; +import { Btc } from '@bitgo-beta/sdk-coin-btc'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { Environments, Wallet } from '@bitgo-beta/sdk-core'; +import { BitGoAPITestHarness } from './testUtils'; + +const BTC_PREBUILD_PSBT_HEX = utxolib.bitgo + .createPsbtForNetwork({ network: utxolib.networks.bitcoin }) + .toHex(); describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () => { let agent: request.SuperAgentTest; @@ -24,19 +31,19 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = const mockUserKeychain = { id: 'user-key-id', - pub: 'xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCWzSgHCZkdXgp', + pub: 'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg', type: 'independent', }; const mockBackupKeychain = { id: 'backup-key-id', - pub: 'xpub661MyMwAqRbcGaZrYqfYmaTRzQxM9PKEZ7GRb6DKfghkzgjk2dKT4qBXfz6WzpT4N5fXJhFW', + pub: 'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr', type: 'independent', }; const mockBitgoKeychain = { id: 'bitgo-key-id', - pub: 'xpub661MyMwAqRbcHtYNxRNuEtDFmPMRzBVPDfBXNu2RUBVFNz8MnWQgkrMZCNB', + pub: 'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy', type: 'bitgo', }; @@ -66,33 +73,49 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); - it('should succeed in consolidating unspents with user key', async () => { - const walletGetNock = nock(bitgoApiUrl) + // Nocks wallet and all 3 keychains with persist() to handle multiple fetches + function nockWalletAndKeychains() { + nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockWalletData); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockUserKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); + } + + it('should succeed in consolidating unspents with user key', async () => { + nockWalletAndKeychains(); + + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }); + + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); + + const signNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); const mockResult = { transfer: { @@ -112,9 +135,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = status: 'signed', }; - const consolidateUnspentsStub = sinon - .stub(Wallet.prototype, 'consolidateUnspents') - .resolves(mockResult); + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, mockResult); const requestPayload = { pubkey: mockUserKeychain.pub, @@ -138,53 +161,55 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = response.body.transfer.should.have.property('status', mockResult.transfer.status); response.body.transfer.should.have.property('entries').which.is.Array(); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(consolidateUnspentsStub); - - const callArgs = consolidateUnspentsStub.firstCall.args[0]; - callArgs!.should.have.property('feeRate', 1000); - callArgs!.should.have.property('maxFeeRate', 2000); - callArgs!.should.have.property('minValue', 1000); - callArgs!.should.have.property('customSigningFunction'); - callArgs!.should.have.property('reqId'); + signNock.done(); + submitNock.done(); }); it('should succeed in consolidating unspents with backup key', async () => { - const walletGetNock = nock(bitgoApiUrl) + nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockWalletData); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - - // All 3 keychains fetched for walletPubs nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/user-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockUserKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/backup-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); nock(bitgoApiUrl) + .persist() .get(`/api/v2/${coin}/key/bitgo-key-id`) .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBitgoKeychain); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }); + + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); + + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'backup', + pub: mockBackupKeychain.pub, + }); + const mockResult = { txid: 'backup-consolidation-tx-id', tx: '01000000000102backup...', status: 'signed', }; - const consolidateUnspentsStub = sinon - .stub(Wallet.prototype, 'consolidateUnspents') - .resolves(mockResult); + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, mockResult); const requestPayload = { pubkey: mockBackupKeychain.pub, @@ -203,36 +228,27 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = response.body.should.have.property('tx', mockResult.tx); response.body.should.have.property('status', mockResult.status); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(consolidateUnspentsStub); + submitNock.done(); }); it('should handle array result from consolidateUnspents and return first element', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); + nockWalletAndKeychains(); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs + // Build returns an array of 1 prebuild + // SDK returns array of 1 send result nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, [{ txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }]); + + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); + + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); const mockArrayResult = [ { @@ -254,9 +270,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = }, ]; - const consolidateUnspentsStub = sinon - .stub(Wallet.prototype, 'consolidateUnspents') - .resolves(mockArrayResult); + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, mockArrayResult[0]); const requestPayload = { pubkey: mockUserKeychain.pub, @@ -281,53 +297,35 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = response.body.transfer.should.have.property('status', 'signed'); response.body.transfer.should.have.property('entries').which.is.Array(); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(consolidateUnspentsStub); + submitNock.done(); }); it('should fail when consolidateUnspents returns array with more than one element', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); + nockWalletAndKeychains(); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); + // Build returns 2 prebuilds, SDK signs+sends both, returns array of 2, handler throws nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, [ + { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }, + { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }, + ]); - const mockArrayResult = [ - { - txid: 'first-tx-id', - tx: '01000000000102first...', - status: 'signed', - }, - { - txid: 'second-tx-id', - tx: '01000000000102second...', - status: 'signed', - }, - ]; + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); + + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .times(2) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); - const consolidateUnspentsStub = sinon - .stub(Wallet.prototype, 'consolidateUnspents') - .resolves(mockArrayResult); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .times(2) + .reply(200, { txid: 'some-tx-id', status: 'signed' }); const requestPayload = { pubkey: mockUserKeychain.pub, @@ -347,37 +345,24 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = 'details', 'Expected single consolidation result, but received 2 results', ); - - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(consolidateUnspentsStub); }); it('should succeed in consolidating unspents with all optional parameters', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); - - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); + nockWalletAndKeychains(); - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }); + + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); + + nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); const mockResult = { txid: 'full-params-consolidation-tx-id', @@ -385,9 +370,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = status: 'signed', }; - const consolidateUnspentsStub = sinon - .stub(Wallet.prototype, 'consolidateUnspents') - .resolves(mockResult); + const submitNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, mockResult); const requestPayload = { pubkey: mockUserKeychain.pub, @@ -417,9 +402,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = response.body.should.have.property('tx', mockResult.tx); response.body.should.have.property('status', mockResult.status); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(consolidateUnspentsStub); + submitNock.done(); }); it('should fail when wallet is not found', async () => { @@ -548,34 +531,12 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = }); it('should fail when consolidateUnspents throws an error', async () => { - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); + nockWalletAndKeychains(); - // Signing keychain fetched by getWalletAndSigningKeychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); - - const consolidateUnspentsStub = sinon - .stub(Wallet.prototype, 'consolidateUnspents') - .rejects(new Error('No unspents available for consolidation')); + // Make the build endpoint fail with a server error to cause consolidateUnspents to throw + const buildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(500, { error: 'No unspents available for consolidation' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) @@ -587,13 +548,10 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = }); response.status.should.equal(500); - response.body.should.have.property('error', 'Internal Server Error'); - response.body.should.have.property('name', 'Error'); - response.body.should.have.property('details', 'No unspents available for consolidation'); + response.body.should.have.property('error'); + response.body.should.have.property('details'); - walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(consolidateUnspentsStub); + buildNock.done(); }); it('should fail when pubkey parameter is not a string', async () => { @@ -611,32 +569,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = }); it('should pass walletPubs (all 3 xpubs) to AWM for UTXO signing', async () => { - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockWalletData); + nockWalletAndKeychains(); - // Signing keychain (user) — fetched once by getWalletAndSigningKeychain nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }); - // All 3 keychains fetched for walletPubs - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockUserKeychain); - - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/backup-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBackupKeychain); - - nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('authorization', `Bearer ${accessToken}`) - .reply(200, mockBitgoKeychain); + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); let capturedSignBody: any; const awmSignNock = nock(advancedWalletManagerUrl) @@ -650,11 +589,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = pub: mockUserKeychain.pub, }); - // Stub consolidateUnspents to call customSigningFunction so the AWM request is made - sinon.stub(Wallet.prototype, 'consolidateUnspents').callsFake(async (params: any) => { - await params.customSigningFunction({ txPrebuild: { txHex: 'prebuilt-tx' } }); - return { txid: 'consolidate-tx-id', tx: '01000000...', status: 'signed' }; - }); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/send`) + .reply(200, { txid: 'consolidate-tx-id', tx: '01000000...', status: 'signed' }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) @@ -713,6 +650,8 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = pub: mockUserKeychain.pub, }); + // The real consolidateUnspents (via manageUnspents) asserts k.pub on every keychain when + // building pubs before signTransaction is called, so this case never reaches customSigningFunction sinon.stub(Wallet.prototype, 'consolidateUnspents').callsFake(async (params: any) => { await params.customSigningFunction({ txPrebuild: { txHex: 'prebuilt-tx' } }); return { txid: 'consolidate-tx-id', tx: '01000000...', status: 'signed' };