diff --git a/src/__tests__/api/master/accelerate.test.ts b/src/__tests__/api/master/accelerate.test.ts index 728c3115..227ff1ea 100644 --- a/src/__tests__/api/master/accelerate.test.ts +++ b/src/__tests__/api/master/accelerate.test.ts @@ -34,6 +34,12 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { type: 'independent', }; + const mockBitgoKeychain = { + id: 'bitgo-key-id', + pub: 'xpub661MyMwAqRbcHtYNxRNuEtDFmPMRzBVPDfBXNu2RUBVFNz8MnWQgkrMZCNB', + type: 'bitgo', + }; + before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -68,11 +74,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { .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) + .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') .resolves({ @@ -117,11 +138,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { .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) + .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') .resolves({ @@ -157,11 +193,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { .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) + .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') .resolves({ @@ -324,11 +375,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { .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) + .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')); @@ -408,4 +474,128 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { response.body.should.have.property('error', 'Internal Server Error'); response.body.should.have.property('details'); }); + + 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); + + // 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); + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/bitgo-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBitgoKeychain); + + let capturedSignBody: any; + const awmSignNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`, (body) => { + capturedSignBody = body; + return true; + }) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + 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' }; + }); + + const response = await agent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + }); + + response.status.should.equal(200); + awmSignNock.done(); + capturedSignBody.should.have.property('walletPubs'); + capturedSignBody.walletPubs.should.deepEqual([ + mockUserKeychain.pub, + mockBackupKeychain.pub, + mockBitgoKeychain.pub, + ]); + }); + + it('should omit walletPubs from AWM request when any keychain is missing a pub', async () => { + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + nock(bitgoApiUrl) + .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) + .get(`/api/v2/${coin}/key/backup-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { id: 'backup-key-id' }); // no pub + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/bitgo-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBitgoKeychain); + + let capturedSignBody: any; + const awmSignNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`, (body) => { + capturedSignBody = body; + return true; + }) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); + + 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' }; + }); + + const response = await agent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + }); + + response.status.should.equal(200); + awmSignNock.done(); + capturedSignBody.should.not.have.property('walletPubs'); + }); }); diff --git a/src/__tests__/api/master/consolidate.test.ts b/src/__tests__/api/master/consolidate.test.ts index f86e5506..509f8b43 100644 --- a/src/__tests__/api/master/consolidate.test.ts +++ b/src/__tests__/api/master/consolidate.test.ts @@ -38,6 +38,12 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { type: 'independent', }; + const mockBitgoKeychain = { + id: 'bitgo-key-id', + pub: 'xpub661MyMwAqRbcHtYNxRNuEtDFmPMRzBVPDfBXNu2RUBVFNz8MnWQgkrMZCNB', + type: 'bitgo', + }; + before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -77,6 +83,20 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { .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 mockBuilds = [ { walletId, @@ -147,6 +167,20 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, { ...mockUserKeychain, commonKeychain: 'user-common-key' }); + // All 3 keychains fetched for walletPubs + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { ...mockUserKeychain, commonKeychain: 'user-common-key' }); + 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 mockMpcBuild = { walletId, txHex: 'unsigned-mpc-tx-hex-1', @@ -234,6 +268,20 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { .matchHeader('authorization', `Bearer ${accessToken}`) .reply(200, mockBackupKeychain); + // 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 mockBuild = { walletId, txHex: 'unsigned-tx-hex-backup', @@ -365,6 +413,20 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { .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 allowsConsolidationsStub = sinon .stub(Hteth.prototype, 'allowsAccountConsolidations') .returns(false); @@ -458,6 +520,20 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { .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 mockBuilds = [ { walletId, txHex: 'unsigned-tx-hex-1' }, { walletId, txHex: 'unsigned-tx-hex-2' }, @@ -516,6 +592,20 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidate', () => { .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 mockBuilds = [ { walletId, txHex: 'unsigned-tx-hex-1' }, { walletId, txHex: 'unsigned-tx-hex-2' }, diff --git a/src/__tests__/api/master/consolidateUnspents.test.ts b/src/__tests__/api/master/consolidateUnspents.test.ts index 9ee75e5c..08e8b3f4 100644 --- a/src/__tests__/api/master/consolidateUnspents.test.ts +++ b/src/__tests__/api/master/consolidateUnspents.test.ts @@ -34,6 +34,12 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = type: 'independent', }; + const mockBitgoKeychain = { + id: 'bitgo-key-id', + pub: 'xpub661MyMwAqRbcHtYNxRNuEtDFmPMRzBVPDfBXNu2RUBVFNz8MnWQgkrMZCNB', + type: 'bitgo', + }; + before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -68,11 +74,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .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) + .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 mockResult = { transfer: { entries: [ @@ -135,11 +156,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .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) + .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 mockResult = { txid: 'backup-consolidation-tx-id', tx: '01000000000102backup...', @@ -178,11 +214,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .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) + .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 mockArrayResult = [ { transfer: { @@ -241,11 +292,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .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) + .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 mockArrayResult = [ { txid: 'first-tx-id', @@ -293,11 +359,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .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) + .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 mockResult = { txid: 'full-params-consolidation-tx-id', tx: '01000000000102full...', @@ -472,11 +553,26 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .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) + .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')); @@ -513,4 +609,126 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = response.status.should.equal(400); response.body.should.have.property('error'); }); + + 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); + + // 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); + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/bitgo-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBitgoKeychain); + + let capturedSignBody: any; + const awmSignNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`, (body) => { + capturedSignBody = body; + return true; + }) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + 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' }; + }); + + const response = await agent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(200); + awmSignNock.done(); + capturedSignBody.should.have.property('walletPubs'); + capturedSignBody.walletPubs.should.deepEqual([ + mockUserKeychain.pub, + mockBackupKeychain.pub, + mockBitgoKeychain.pub, + ]); + }); + + it('should omit walletPubs from AWM request when any keychain is missing a pub', async () => { + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockWalletData); + + nock(bitgoApiUrl) + .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) + .get(`/api/v2/${coin}/key/backup-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { id: 'backup-key-id' }); // no pub + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/bitgo-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockBitgoKeychain); + + let capturedSignBody: any; + const awmSignNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/multisig/sign`, (body) => { + capturedSignBody = body; + return true; + }) + .reply(200, { + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: mockUserKeychain.pub, + }); + + 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' }; + }); + + const response = await agent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(200); + awmSignNock.done(); + capturedSignBody.should.not.have.property('walletPubs'); + }); }); diff --git a/src/masterBitgoExpress/handlers/handleAccelerate.ts b/src/masterBitgoExpress/handlers/handleAccelerate.ts index 6057236b..aab90ed3 100644 --- a/src/masterBitgoExpress/handlers/handleAccelerate.ts +++ b/src/masterBitgoExpress/handlers/handleAccelerate.ts @@ -1,7 +1,11 @@ import { RequestTracer, KeyIndices } from '@bitgo-beta/sdk-core'; import logger from '../../shared/logger'; import { MasterApiSpecRouteRequest } from '../routers/masterBitGoExpressApiSpec'; -import { getWalletAndSigningKeychain, makeCustomSigningFunction } from './utils/utils'; +import { + getWalletAndSigningKeychain, + makeCustomSigningFunction, + getWalletPubs, +} from './utils/utils'; export async function handleAccelerate( req: MasterApiSpecRouteRequest<'v1.wallet.accelerate', 'post'>, @@ -13,7 +17,7 @@ export async function handleAccelerate( const walletId = req.params.walletId; const coin = req.params.coin; - const { wallet, signingKeychain } = await getWalletAndSigningKeychain({ + const { baseCoin, wallet, signingKeychain } = await getWalletAndSigningKeychain({ bitgo, coin, walletId, @@ -22,12 +26,15 @@ export async function handleAccelerate( KeyIndices, }); + const walletPubs = await getWalletPubs({ baseCoin, wallet }); + try { // Create custom signing function that delegates to EBE const customSigningFunction = makeCustomSigningFunction({ awmClient, source: params.source, pub: signingKeychain.pub!, + walletPubs, }); // Prepare acceleration parameters diff --git a/src/masterBitgoExpress/handlers/handleConsolidate.ts b/src/masterBitgoExpress/handlers/handleConsolidate.ts index 9eedd5a1..7ef1046a 100644 --- a/src/masterBitgoExpress/handlers/handleConsolidate.ts +++ b/src/masterBitgoExpress/handlers/handleConsolidate.ts @@ -6,7 +6,11 @@ import { } from '@bitgo-beta/sdk-core'; import logger from '../../shared/logger'; import { MasterApiSpecRouteRequest } from '../routers/masterBitGoExpressApiSpec'; -import { getWalletAndSigningKeychain, makeCustomSigningFunction } from './utils/utils'; +import { + getWalletAndSigningKeychain, + makeCustomSigningFunction, + getWalletPubs, +} from './utils/utils'; import { signAndSendTxRequests } from './transactionRequests'; export async function handleConsolidate( @@ -28,6 +32,8 @@ export async function handleConsolidate( KeyIndices, }); + const walletPubs = await getWalletPubs({ baseCoin, wallet }); + // Check if the coin supports account consolidations if (!baseCoin.allowsAccountConsolidations()) { throw new Error('Invalid coin selected - account consolidations not supported'); @@ -86,6 +92,7 @@ export async function handleConsolidate( awmClient, source: params.source, pub: signingKeychain.pub!, + walletPubs, }), }); diff --git a/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts b/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts index 0842c8e0..8312d32b 100644 --- a/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts +++ b/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts @@ -1,7 +1,11 @@ -import { RequestTracer, KeyIndices } from '@bitgo-beta/sdk-core'; +import { KeyIndices, RequestTracer } from '@bitgo-beta/sdk-core'; import logger from '../../shared/logger'; import { MasterApiSpecRouteRequest } from '../routers/masterBitGoExpressApiSpec'; -import { getWalletAndSigningKeychain, makeCustomSigningFunction } from './utils/utils'; +import { + getWalletAndSigningKeychain, + makeCustomSigningFunction, + getWalletPubs, +} from './utils/utils'; export async function handleConsolidateUnspents( req: MasterApiSpecRouteRequest<'v1.wallet.consolidateunspents', 'post'>, @@ -13,7 +17,7 @@ export async function handleConsolidateUnspents( const walletId = req.params.walletId; const coin = req.params.coin; - const { wallet, signingKeychain } = await getWalletAndSigningKeychain({ + const { baseCoin, wallet, signingKeychain } = await getWalletAndSigningKeychain({ bitgo, coin, walletId, @@ -22,12 +26,15 @@ export async function handleConsolidateUnspents( KeyIndices, }); + const walletPubs = await getWalletPubs({ baseCoin, wallet }); + try { // Create custom signing function that delegates to EBE const customSigningFunction = makeCustomSigningFunction({ awmClient, source: params.source, pub: signingKeychain.pub!, + walletPubs, }); // Prepare consolidation parameters diff --git a/src/masterBitgoExpress/handlers/handleSendMany.ts b/src/masterBitgoExpress/handlers/handleSendMany.ts index 6159c8de..c883b270 100644 --- a/src/masterBitgoExpress/handlers/handleSendMany.ts +++ b/src/masterBitgoExpress/handlers/handleSendMany.ts @@ -15,6 +15,7 @@ import { AdvancedWalletManagerClient } from '../clients/advancedWalletManagerCli import { createEddsaCustomSigningFunctions } from './eddsa'; import { BadRequestError, NotFoundError } from '../../shared/errors'; import coinFactory from '../../shared/coinFactory'; +import { getWalletPubs } from './utils/utils'; /** * Defines the structure for a single recipient in a send-many transaction. @@ -179,15 +180,7 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s throw new BadRequestError(`Transaction prebuild failed local validation: ${err.message}`); } - const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ - baseCoin.keychains().get({ id: wallet.keyIds()[KeyIndices.USER] }), - baseCoin.keychains().get({ id: wallet.keyIds()[KeyIndices.BACKUP] }), - baseCoin.keychains().get({ id: wallet.keyIds()[KeyIndices.BITGO] }), - ]); - const walletPubs = - userKeychain?.pub && backupKeychain?.pub && bitgoKeychain?.pub - ? [userKeychain.pub, backupKeychain.pub, bitgoKeychain.pub] - : undefined; + const walletPubs = await getWalletPubs({ baseCoin, wallet }); return signAndSendMultisig( wallet, diff --git a/src/masterBitgoExpress/handlers/utils/utils.ts b/src/masterBitgoExpress/handlers/utils/utils.ts index d1aeb090..d77f58b9 100644 --- a/src/masterBitgoExpress/handlers/utils/utils.ts +++ b/src/masterBitgoExpress/handlers/utils/utils.ts @@ -1,5 +1,6 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; -import { CustomSigningFunction, RequestTracer } from '@bitgo-beta/sdk-core'; +import { BaseCoin } from '@bitgo-beta/sdk-core'; +import { CustomSigningFunction, RequestTracer, KeyIndices, Wallet } from '@bitgo-beta/sdk-core'; import coinFactory from '../../../shared/coinFactory'; import { AdvancedWalletManagerClient } from '../../clients/advancedWalletManagerClient'; import { MasterExpressConfig } from '../../../shared/types'; @@ -57,6 +58,29 @@ export async function getWalletAndSigningKeychain({ return { baseCoin, wallet, signingKeychain }; } + +/** + * Fetches all 3 wallet keychains (user, backup, bitgo) and returns their public keys. + * Returns undefined if any keychain is missing a pub (required for UTXO multisig signing). + */ +export async function getWalletPubs({ + baseCoin, + wallet, +}: { + baseCoin: BaseCoin; + wallet: Wallet; +}): Promise { + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + baseCoin.keychains().get({ id: wallet.keyIds()[KeyIndices.USER] }), + baseCoin.keychains().get({ id: wallet.keyIds()[KeyIndices.BACKUP] }), + baseCoin.keychains().get({ id: wallet.keyIds()[KeyIndices.BITGO] }), + ]); + + return userKeychain?.pub && backupKeychain?.pub && bitgoKeychain?.pub + ? [userKeychain.pub, backupKeychain.pub, bitgoKeychain.pub] + : undefined; +} + /** * Create a custom signing function that delegates to awmClient.signMultisig. */ @@ -65,16 +89,19 @@ export function makeCustomSigningFunction({ awmClient, source, pub, + walletPubs, }: { awmClient: AdvancedWalletManagerClient; source: 'user' | 'backup'; pub: string; + walletPubs?: string[]; }): CustomSigningFunction { return async function customSigningFunction(signParams: any) { return awmClient.signMultisig({ txPrebuild: signParams.txPrebuild, source, pub, + walletPubs, }); }; }