Skip to content

Commit 9b32326

Browse files
Merge pull request #39 from BitGo/WP-4752/consolidateAccount
feat(mbe): consolidate account
2 parents 43fcf25 + cfbc608 commit 9b32326

3 files changed

Lines changed: 416 additions & 0 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import 'should';
2+
import sinon from 'sinon';
3+
import * as request from 'supertest';
4+
import nock from 'nock';
5+
import { app as expressApp } from '../../../masterExpressApp';
6+
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
7+
import { Environments, Wallet } from '@bitgo/sdk-core';
8+
import { Hteth } from '@bitgo/sdk-coin-eth';
9+
10+
describe('POST /api/:coin/wallet/:walletId/consolidate', () => {
11+
let agent: request.SuperAgentTest;
12+
const coin = 'hteth';
13+
const walletId = 'test-wallet-id';
14+
const accessToken = 'test-access-token';
15+
const bitgoApiUrl = Environments.test.uri;
16+
const enclavedExpressUrl = 'https://test-enclaved-express.com';
17+
18+
before(() => {
19+
nock.disableNetConnect();
20+
nock.enableNetConnect('127.0.0.1');
21+
22+
const config: MasterExpressConfig = {
23+
appMode: AppMode.MASTER_EXPRESS,
24+
port: 0, // Let OS assign a free port
25+
bind: 'localhost',
26+
timeout: 30000,
27+
logFile: '',
28+
env: 'test',
29+
disableEnvCheck: true,
30+
authVersion: 2,
31+
enclavedExpressUrl: enclavedExpressUrl,
32+
enclavedExpressCert: 'test-cert',
33+
tlsMode: TlsMode.DISABLED,
34+
mtlsRequestCert: false,
35+
allowSelfSigned: true,
36+
};
37+
38+
const app = expressApp(config);
39+
agent = request.agent(app);
40+
});
41+
42+
afterEach(() => {
43+
nock.cleanAll();
44+
sinon.restore();
45+
});
46+
47+
it('should consolidate account addresses by calling the enclaved express service', async () => {
48+
// Mock wallet get request
49+
const walletGetNock = nock(bitgoApiUrl)
50+
.get(`/api/v2/${coin}/wallet/${walletId}`)
51+
.matchHeader('any', () => true)
52+
.reply(200, {
53+
id: walletId,
54+
type: 'cold',
55+
subType: 'onPrem',
56+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
57+
});
58+
59+
// Mock keychain get request
60+
const keychainGetNock = nock(bitgoApiUrl)
61+
.get(`/api/v2/${coin}/key/user-key-id`)
62+
.matchHeader('any', () => true)
63+
.reply(200, {
64+
id: 'user-key-id',
65+
pub: 'xpub_user',
66+
});
67+
68+
// Mock sendAccountConsolidations
69+
const sendConsolidationsStub = sinon
70+
.stub(Wallet.prototype, 'sendAccountConsolidations')
71+
.resolves({
72+
success: [
73+
{
74+
txid: 'consolidation-tx-1',
75+
status: 'signed',
76+
},
77+
],
78+
failure: [],
79+
});
80+
81+
const response = await agent
82+
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
83+
.set('Authorization', `Bearer ${accessToken}`)
84+
.send({
85+
source: 'user',
86+
pubkey: 'xpub_user',
87+
consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'],
88+
});
89+
90+
response.status.should.equal(200);
91+
response.body.should.have.property('success');
92+
response.body.success.should.have.length(1);
93+
response.body.success[0].should.have.property('txid', 'consolidation-tx-1');
94+
95+
walletGetNock.done();
96+
keychainGetNock.done();
97+
sinon.assert.calledOnce(sendConsolidationsStub);
98+
});
99+
100+
it('should handle partial consolidation failures', async () => {
101+
// Mock wallet get request
102+
const walletGetNock = nock(bitgoApiUrl)
103+
.get(`/api/v2/${coin}/wallet/${walletId}`)
104+
.matchHeader('any', () => true)
105+
.reply(200, {
106+
id: walletId,
107+
type: 'cold',
108+
subType: 'onPrem',
109+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
110+
});
111+
112+
// Mock keychain get request
113+
const keychainGetNock = nock(bitgoApiUrl)
114+
.get(`/api/v2/${coin}/key/user-key-id`)
115+
.matchHeader('any', () => true)
116+
.reply(200, {
117+
id: 'user-key-id',
118+
pub: 'xpub_user',
119+
});
120+
121+
// Mock sendAccountConsolidations with partial failures
122+
const sendConsolidationsStub = sinon
123+
.stub(Wallet.prototype, 'sendAccountConsolidations')
124+
.resolves({
125+
success: [
126+
{
127+
txid: 'consolidation-tx-1',
128+
status: 'signed',
129+
},
130+
],
131+
failure: [
132+
{
133+
error: 'Insufficient funds',
134+
address: '0xfedcba0987654321',
135+
},
136+
],
137+
});
138+
139+
const response = await agent
140+
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
141+
.set('Authorization', `Bearer ${accessToken}`)
142+
.send({
143+
source: 'user',
144+
pubkey: 'xpub_user',
145+
consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'],
146+
});
147+
148+
response.status.should.equal(500);
149+
response.body.should.have.property('error', 'Internal Server Error');
150+
response.body.should.have
151+
.property('details')
152+
.which.match(/Consolidations failed: 1 and succeeded: 1/);
153+
154+
walletGetNock.done();
155+
keychainGetNock.done();
156+
sinon.assert.calledOnce(sendConsolidationsStub);
157+
});
158+
159+
it('should throw error when all consolidations fail', async () => {
160+
// Mock wallet get request
161+
const walletGetNock = nock(bitgoApiUrl)
162+
.get(`/api/v2/${coin}/wallet/${walletId}`)
163+
.matchHeader('any', () => true)
164+
.reply(200, {
165+
id: walletId,
166+
type: 'cold',
167+
subType: 'onPrem',
168+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
169+
});
170+
171+
// Mock keychain get request
172+
const keychainGetNock = nock(bitgoApiUrl)
173+
.get(`/api/v2/${coin}/key/user-key-id`)
174+
.matchHeader('any', () => true)
175+
.reply(200, {
176+
id: 'user-key-id',
177+
pub: 'xpub_user',
178+
});
179+
180+
// Mock sendAccountConsolidations with all failures
181+
const sendConsolidationsStub = sinon
182+
.stub(Wallet.prototype, 'sendAccountConsolidations')
183+
.resolves({
184+
success: [],
185+
failure: [
186+
{
187+
error: 'All consolidations failed',
188+
address: '0x1234567890abcdef',
189+
},
190+
{
191+
error: 'All consolidations failed',
192+
address: '0xfedcba0987654321',
193+
},
194+
],
195+
});
196+
197+
const response = await agent
198+
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
199+
.set('Authorization', `Bearer ${accessToken}`)
200+
.send({
201+
source: 'user',
202+
pubkey: 'xpub_user',
203+
consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'],
204+
});
205+
206+
response.status.should.equal(500);
207+
response.body.should.have.property('error');
208+
response.body.should.have.property('details').which.match(/All consolidations failed/);
209+
210+
walletGetNock.done();
211+
keychainGetNock.done();
212+
sinon.assert.calledOnce(sendConsolidationsStub);
213+
});
214+
215+
it('should throw error when coin does not support account consolidations', async () => {
216+
// Mock wallet get request
217+
const walletGetNock = nock(bitgoApiUrl)
218+
.get(`/api/v2/${coin}/wallet/${walletId}`)
219+
.matchHeader('any', () => true)
220+
.reply(200, {
221+
id: walletId,
222+
type: 'cold',
223+
subType: 'onPrem',
224+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
225+
});
226+
227+
// Mock allowsAccountConsolidations to return false
228+
const allowsConsolidationsStub = sinon
229+
.stub(Hteth.prototype, 'allowsAccountConsolidations')
230+
.returns(false);
231+
232+
const response = await agent
233+
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
234+
.set('Authorization', `Bearer ${accessToken}`)
235+
.send({
236+
source: 'user',
237+
pubkey: 'xpub_user',
238+
});
239+
240+
response.status.should.equal(500);
241+
242+
walletGetNock.done();
243+
sinon.assert.calledOnce(allowsConsolidationsStub);
244+
});
245+
246+
it('should throw error when provided pubkey does not match wallet keychain', async () => {
247+
// Mock wallet get request
248+
const walletGetNock = nock(bitgoApiUrl)
249+
.get(`/api/v2/${coin}/wallet/${walletId}`)
250+
.matchHeader('any', () => true)
251+
.reply(200, {
252+
id: walletId,
253+
type: 'cold',
254+
subType: 'onPrem',
255+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
256+
});
257+
258+
// Mock keychain get request
259+
const keychainGetNock = nock(bitgoApiUrl)
260+
.get(`/api/v2/${coin}/key/user-key-id`)
261+
.matchHeader('any', () => true)
262+
.reply(200, {
263+
id: 'user-key-id',
264+
pub: 'xpub_user',
265+
});
266+
267+
const response = await agent
268+
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
269+
.set('Authorization', `Bearer ${accessToken}`)
270+
.send({
271+
source: 'user',
272+
pubkey: 'wrong_pubkey',
273+
});
274+
275+
response.status.should.equal(500);
276+
277+
walletGetNock.done();
278+
keychainGetNock.done();
279+
});
280+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { RequestTracer, KeyIndices } from '@bitgo/sdk-core';
2+
import logger from '../../../logger';
3+
import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec';
4+
5+
export async function handleConsolidate(
6+
req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>,
7+
) {
8+
const enclavedExpressClient = req.enclavedExpressClient;
9+
const reqId = new RequestTracer();
10+
const bitgo = req.bitgo;
11+
const baseCoin = bitgo.coin(req.params.coin);
12+
const params = req.decoded;
13+
const walletId = req.params.walletId;
14+
const wallet = await baseCoin.wallets().get({ id: walletId, reqId });
15+
16+
if (!wallet) {
17+
throw new Error(`Wallet ${walletId} not found`);
18+
}
19+
20+
// Check if the coin supports account consolidations
21+
if (!baseCoin.allowsAccountConsolidations()) {
22+
throw new Error('Invalid coin selected - account consolidations not supported');
23+
}
24+
25+
// Validate consolidateAddresses parameter
26+
if (params.consolidateAddresses && !Array.isArray(params.consolidateAddresses)) {
27+
throw new Error('consolidateAddresses must be an array of addresses');
28+
}
29+
30+
// Get the signing keychain based on source
31+
const keyIdIndex = params.source === 'user' ? KeyIndices.USER : KeyIndices.BACKUP;
32+
const signingKeychain = await baseCoin.keychains().get({
33+
id: wallet.keyIds()[keyIdIndex],
34+
});
35+
36+
if (!signingKeychain || !signingKeychain.pub) {
37+
throw new Error(`Signing keychain for ${params.source} not found`);
38+
}
39+
40+
if (params.pubkey && params.pubkey !== signingKeychain.pub) {
41+
throw new Error(`Pub provided does not match the keychain on wallet for ${params.source}`);
42+
}
43+
44+
try {
45+
// Create custom signing function that delegates to EBE
46+
const customSigningFunction = async (signParams: any) => {
47+
const signedTx = await enclavedExpressClient.signMultisig({
48+
txPrebuild: signParams.txPrebuild,
49+
source: params.source,
50+
pub: signingKeychain.pub!,
51+
});
52+
return signedTx;
53+
};
54+
55+
// Prepare consolidation parameters
56+
const consolidationParams = {
57+
...params,
58+
customSigningFunction,
59+
reqId,
60+
};
61+
62+
// Send account consolidations
63+
const result = await wallet.sendAccountConsolidations(consolidationParams);
64+
65+
// Handle failures
66+
if (result.failure && result.failure.length > 0) {
67+
logger.debug('Consolidation result: %s', JSON.stringify(result, null, 2));
68+
let msg = '';
69+
let status = 202;
70+
71+
if (result.success && result.success.length > 0) {
72+
// Some succeeded, some failed
73+
msg = `Consolidations failed: ${result.failure.length} and succeeded: ${result.success.length}`;
74+
} else {
75+
// All failed
76+
status = 400;
77+
msg = 'All consolidations failed';
78+
}
79+
80+
const error = new Error(msg);
81+
(error as any).status = status;
82+
(error as any).result = result;
83+
throw error;
84+
}
85+
86+
return result;
87+
} catch (error) {
88+
const err = error as Error;
89+
logger.error('Failed to consolidate account: %s', err.message);
90+
throw err;
91+
}
92+
}

0 commit comments

Comments
 (0)