Skip to content

Commit 63acfb2

Browse files
authored
Merge pull request #75 from BitGo/WP-5260-fix-api-error-handling
chore(mbe): handle bitgo api error responses
2 parents eb2c974 + 63ebe9a commit 63acfb2

File tree

7 files changed

+108
-68
lines changed

7 files changed

+108
-68
lines changed

src/__tests__/api/master/accelerate.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ describe('POST /api/:coin/wallet/:walletId/accelerate', () => {
157157
cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'],
158158
});
159159

160-
response.status.should.equal(500);
160+
response.status.should.equal(404);
161161
response.body.should.have.property('error');
162162

163163
walletGetNock.done();
@@ -190,7 +190,7 @@ describe('POST /api/:coin/wallet/:walletId/accelerate', () => {
190190
cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'],
191191
});
192192

193-
response.status.should.equal(500);
193+
response.status.should.equal(404);
194194
response.body.should.have.property('error');
195195

196196
walletGetNock.done();

src/__tests__/api/master/consolidateUnspents.test.ts

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -107,55 +107,6 @@ describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => {
107107
sinon.assert.calledOnce(consolidateUnspentsStub);
108108
});
109109

110-
it('should return error, name, and details on failure', async () => {
111-
const walletGetNock = nock(bitgoApiUrl)
112-
.get(`/api/v2/${coin}/wallet/${walletId}`)
113-
.matchHeader('any', () => true)
114-
.reply(200, {
115-
id: walletId,
116-
type: 'cold',
117-
subType: 'onPrem',
118-
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
119-
});
120-
121-
const keychainGetNock = nock(bitgoApiUrl)
122-
.get(`/api/v2/${coin}/key/user-key-id`)
123-
.matchHeader('any', () => true)
124-
.reply(200, {
125-
id: 'user-key-id',
126-
pub: 'xpub_user',
127-
});
128-
129-
const mockError = {
130-
error: 'Internal Server Error',
131-
name: 'ApiResponseError',
132-
details:
133-
'There are too few unspents that meet the given parameters to consolidate (1 available).',
134-
};
135-
136-
const consolidateUnspentsStub = sinon
137-
.stub(Wallet.prototype, 'consolidateUnspents')
138-
.throws(Object.assign(new Error(mockError.details), mockError));
139-
140-
const response = await agent
141-
.post(`/api/${coin}/wallet/${walletId}/consolidateunspents`)
142-
.set('Authorization', `Bearer ${accessToken}`)
143-
.send({
144-
source: 'user',
145-
pubkey: 'xpub_user',
146-
feeRate: 1000,
147-
});
148-
149-
response.status.should.equal(500);
150-
response.body.should.have.property('error', mockError.error);
151-
response.body.should.have.property('name', mockError.name);
152-
response.body.should.have.property('details', mockError.details);
153-
154-
walletGetNock.done();
155-
keychainGetNock.done();
156-
sinon.assert.calledOnce(consolidateUnspentsStub);
157-
});
158-
159110
it('should throw error when provided pubkey does not match wallet keychain', async () => {
160111
const walletGetNock = nock(bitgoApiUrl)
161112
.get(`/api/v2/${coin}/wallet/${walletId}`)

src/__tests__/api/master/sendMany.test.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as request from 'supertest';
55
import nock from 'nock';
66
import { app as expressApp } from '../../../masterExpressApp';
77
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
8-
import { Environments, Wallet } from '@bitgo/sdk-core';
8+
import { ApiResponseError, Environments, Wallet } from '@bitgo/sdk-core';
99
import { Coin } from 'bitgo';
1010
import assert from 'assert';
1111

@@ -522,7 +522,7 @@ describe('POST /api/:coin/wallet/:walletId/sendmany', () => {
522522
pubkey: 'xpub_backup',
523523
});
524524

525-
response.status.should.equal(500);
525+
response.status.should.equal(400);
526526
response.body.details.should.equal('Backup MPC signing not supported for sendMany');
527527

528528
walletGetNock.done();
@@ -567,8 +567,7 @@ describe('POST /api/:coin/wallet/:walletId/sendmany', () => {
567567
pubkey: 'wrong_pubkey',
568568
});
569569

570-
// TODO: Fix this to expect the error message when the middleware on MBE is fixed to handle errors
571-
response.status.should.equal(500);
570+
response.status.should.equal(400);
572571

573572
walletGetNock.done();
574573
keychainGetNock.done();
@@ -648,7 +647,7 @@ describe('POST /api/:coin/wallet/:walletId/sendmany', () => {
648647
pubkey: 'xpub_user',
649648
});
650649

651-
response.status.should.equal(500);
650+
response.status.should.equal(400);
652651

653652
walletGetNock.done();
654653
keychainGetNock.done();
@@ -706,11 +705,85 @@ describe('POST /api/:coin/wallet/:walletId/sendmany', () => {
706705
pubkey: 'xpub_user',
707706
});
708707

708+
response.status.should.equal(400);
709+
710+
walletGetNock.done();
711+
keychainGetNock.done();
712+
sinon.assert.calledOnce(prebuildStub);
713+
sinon.assert.calledOnce(verifyStub);
714+
});
715+
716+
it('should handle BitGoApiResponseError correctly', async () => {
717+
// Mock wallet get request
718+
const walletGetNock = nock(bitgoApiUrl)
719+
.get(`/api/v2/${coin}/wallet/${walletId}`)
720+
.matchHeader('any', () => true)
721+
.reply(200, {
722+
id: walletId,
723+
type: 'cold',
724+
subType: 'onPrem',
725+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
726+
});
727+
728+
// Mock keychain get request
729+
const keychainGetNock = nock(bitgoApiUrl)
730+
.get(`/api/v2/${coin}/key/user-key-id`)
731+
.matchHeader('any', () => true)
732+
.reply(200, {
733+
id: 'user-key-id',
734+
pub: 'xpub_user',
735+
});
736+
737+
const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({
738+
txHex: 'prebuilt-tx-hex',
739+
txInfo: {
740+
nP2SHInputs: 1,
741+
nSegwitInputs: 0,
742+
nOutputs: 2,
743+
},
744+
walletId,
745+
});
746+
747+
const verifyStub = sinon.stub(Coin.Btc.prototype, 'verifyTransaction').resolves(true);
748+
749+
// Mock enclaved express sign request to return an error
750+
const signNock = nock(enclavedExpressUrl)
751+
.post(`/api/${coin}/multisig/sign`)
752+
.replyWithError(
753+
new ApiResponseError('Custom API error', 500, {
754+
error: 'Custom API error',
755+
requestId: 'test-request-id',
756+
}),
757+
);
758+
759+
const response = await agent
760+
.post(`/api/${coin}/wallet/${walletId}/sendMany`)
761+
.set('Authorization', `Bearer ${accessToken}`)
762+
.send({
763+
recipients: [
764+
{
765+
address: 'tb1qtest1',
766+
amount: '100000',
767+
},
768+
],
769+
source: 'user',
770+
pubkey: 'xpub_user',
771+
});
772+
773+
// The response should be a 500 error with the enclaved error details
709774
response.status.should.equal(500);
775+
response.body.should.have.property('error');
776+
response.body.should.have.property('details');
777+
response.body.error.should.equal('BitGoApiResponseError');
778+
response.body.details.should.deepEqual({
779+
error: 'Custom API error',
780+
requestId: 'test-request-id',
781+
});
710782

711783
walletGetNock.done();
712784
keychainGetNock.done();
713785
sinon.assert.calledOnce(prebuildStub);
714786
sinon.assert.calledOnce(verifyStub);
787+
signNock.done();
715788
});
716789
});

src/api/master/handlers/handleSendMany.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec';
1313
import { createEcdsaMPCv2CustomSigners } from './ecdsaMPCv2';
1414
import { EnclavedExpressClient } from '../clients/enclavedExpressClient';
1515
import { createEddsaCustomSigningFunctions } from './eddsa';
16+
import { BadRequestError, NotFoundError } from '../../../shared/errors';
1617

1718
/**
1819
* Defines the structure for a single recipient in a send-many transaction.
@@ -41,7 +42,7 @@ function createMPCSendParamsWithCustomSigningFns(
4142
const mpcAlgorithm = coin.getMPCAlgorithm();
4243

4344
if (!commonKeychain) {
44-
throw new Error('Common keychain is required for MPC signing');
45+
throw new BadRequestError('Common keychain is required for MPC signing');
4546
}
4647

4748
if (mpcAlgorithm === 'ecdsa') {
@@ -66,7 +67,7 @@ function createMPCSendParamsWithCustomSigningFns(
6667
};
6768
}
6869

69-
throw new Error(`Unsupported MPC algorithm: ${mpcAlgorithm}`);
70+
throw new BadRequestError(`Unsupported MPC algorithm: ${mpcAlgorithm}`);
7071
}
7172

7273
export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.sendMany', 'post'>) {
@@ -81,11 +82,11 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s
8182
const walletId = req.params.walletId;
8283
const wallet = await baseCoin.wallets().get({ id: walletId, reqId });
8384
if (!wallet) {
84-
throw new Error(`Wallet ${walletId} not found`);
85+
throw new NotFoundError(`Wallet ${walletId} not found`);
8586
}
8687

8788
if (wallet.type() !== 'cold' || wallet.subType() !== 'onPrem') {
88-
throw new Error('Wallet is not an on-prem wallet');
89+
throw new NotFoundError('Wallet is not an on-prem wallet');
8990
}
9091

9192
const keyIdIndex = params.source === 'user' ? KeyIndices.USER : KeyIndices.BACKUP;
@@ -98,13 +99,15 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s
9899
});
99100

100101
if (!signingKeychain) {
101-
throw new Error(`Signing keychain for ${params.source} not found`);
102+
throw new NotFoundError(`Signing keychain for ${params.source} not found`);
102103
}
103104
if (params.pubkey && signingKeychain.pub !== params.pubkey) {
104-
throw new Error(`Pub provided does not match the keychain on wallet for ${params.source}`);
105+
throw new BadRequestError(
106+
`Pub provided does not match the keychain on wallet for ${params.source}`,
107+
);
105108
}
106109
if (params.commonKeychain && signingKeychain.commonKeychain !== params.commonKeychain) {
107-
throw new Error(
110+
throw new BadRequestError(
108111
`Common keychain provided does not match the keychain on wallet for ${params.source}`,
109112
);
110113
}
@@ -113,7 +116,7 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s
113116
// Create MPC send parameters with custom signing functions
114117
if (wallet.multisigType() === 'tss') {
115118
if (signingKeychain.source === 'backup') {
116-
throw new Error('Backup MPC signing not supported for sendMany');
119+
throw new BadRequestError('Backup MPC signing not supported for sendMany');
117120
}
118121
const mpcSendParams = createMPCSendParamsWithCustomSigningFns(
119122
req,
@@ -150,14 +153,14 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s
150153
walletType: wallet.multisigType(),
151154
});
152155
if (!verified) {
153-
throw new Error('Transaction prebuild failed local validation');
156+
throw new BadRequestError('Transaction prebuild failed local validation');
154157
}
155158
logger.debug('Transaction prebuild verified');
156159
} catch (e) {
157160
const err = e as Error;
158161
logger.error('transaction prebuild failed local validation:', err.message);
159162
logger.error('transaction prebuild:', JSON.stringify(txPrebuilt, null, 2));
160-
throw new Error(`Transaction prebuild failed local validation: ${err.message}`);
163+
throw new BadRequestError(`Transaction prebuild failed local validation: ${err.message}`);
161164
}
162165

163166
logger.debug('Tx prebuild: %s', JSON.stringify(txPrebuilt, null, 2));
@@ -188,7 +191,7 @@ export async function signAndSendMultisig(
188191
reqId: RequestTracer,
189192
) {
190193
if (!signingKeychain.pub) {
191-
throw new Error(`Signing keychain pub not found for ${source}`);
194+
throw new BadRequestError(`Signing keychain pub not found for ${source}`);
192195
}
193196
logger.info(`Signing with ${source} keychain, pub: ${signingKeychain.pub}`);
194197
logger.debug(`Signing keychain: ${JSON.stringify(signingKeychain, null, 2)}`);

src/api/master/routers/masterApiSpec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
BadRequestResponse,
3232
InternalServerErrorResponse,
3333
UnprocessableEntityResponse,
34+
NotFoundResponse,
3435
} from '../../../shared/errors';
3536

3637
export type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
@@ -160,6 +161,8 @@ export const SendManyRequest = {
160161
export const SendManyResponse: HttpResponse = {
161162
// TODO: Get type from public types repo / Wallet Platform
162163
200: t.any,
164+
...BadRequestResponse,
165+
...NotFoundResponse,
163166
...InternalServerErrorResponse,
164167
};
165168

src/shared/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,4 @@ const ErrorResponse = t.type({
8484
export const BadRequestResponse = { 400: ErrorResponse };
8585
export const UnprocessableEntityResponse = { 422: ErrorResponse };
8686
export const InternalServerErrorResponse = { 500: ErrorResponse };
87+
export const NotFoundResponse = { 404: ErrorResponse };

src/shared/responseHandler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Request, Response as ExpressResponse, NextFunction } from 'express';
22
import { Config } from '../shared/types';
33
import { BitGoRequest } from '../types/request';
4-
import { EnclavedError } from '../errors';
4+
import { ApiResponseError, EnclavedError } from '../errors';
55
import {
66
BitgoExpressError,
77
ValidationError,
@@ -47,6 +47,15 @@ export function responseHandler<T extends Config = Config>(fn: ServiceFunction<T
4747
return res.sendEncoded(apiError.type, apiError.payload);
4848
}
4949

50+
if ((error as any).name === 'ApiResponseError') {
51+
const apiError = error as ApiResponseError;
52+
const body = {
53+
error: 'BitGoApiResponseError',
54+
details: apiError.result,
55+
};
56+
return res.status(apiError.status).json(body);
57+
}
58+
5059
// If it's a BitgoExpressError, map to appropriate status code
5160
if (error instanceof BitgoExpressError) {
5261
let statusCode = 500;

0 commit comments

Comments
 (0)