Skip to content

Commit 2b79643

Browse files
feat(mbe): consolidate unspents
1 parent 7cdcb99 commit 2b79643

File tree

3 files changed

+288
-0
lines changed

3 files changed

+288
-0
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
9+
describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => {
10+
let agent: request.SuperAgentTest;
11+
const coin = 'btc';
12+
const walletId = 'test-wallet-id';
13+
const accessToken = 'test-access-token';
14+
const bitgoApiUrl = Environments.test.uri;
15+
const enclavedExpressUrl = 'https://test-enclaved-express.com';
16+
17+
before(() => {
18+
nock.disableNetConnect();
19+
nock.enableNetConnect('127.0.0.1');
20+
21+
const config: MasterExpressConfig = {
22+
appMode: AppMode.MASTER_EXPRESS,
23+
port: 0,
24+
bind: 'localhost',
25+
timeout: 30000,
26+
logFile: '',
27+
env: 'test',
28+
disableEnvCheck: true,
29+
authVersion: 2,
30+
enclavedExpressUrl: enclavedExpressUrl,
31+
enclavedExpressCert: 'test-cert',
32+
tlsMode: TlsMode.DISABLED,
33+
mtlsRequestCert: false,
34+
allowSelfSigned: true,
35+
};
36+
37+
const app = expressApp(config);
38+
agent = request.agent(app);
39+
});
40+
41+
afterEach(() => {
42+
nock.cleanAll();
43+
sinon.restore();
44+
});
45+
46+
it('should return transfer, txid, tx, and status on success', async () => {
47+
const walletGetNock = nock(bitgoApiUrl)
48+
.get(`/api/v2/${coin}/wallet/${walletId}`)
49+
.matchHeader('any', () => true)
50+
.reply(200, {
51+
id: walletId,
52+
type: 'cold',
53+
subType: 'onPrem',
54+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
55+
});
56+
57+
const keychainGetNock = nock(bitgoApiUrl)
58+
.get(`/api/v2/${coin}/key/user-key-id`)
59+
.matchHeader('any', () => true)
60+
.reply(200, {
61+
id: 'user-key-id',
62+
pub: 'xpub_user',
63+
});
64+
65+
const mockResult = {
66+
transfer: {
67+
entries: [
68+
{ address: 'tb1qu...', value: -4000 },
69+
{ address: 'tb1qle...', value: -4000 },
70+
{ address: 'tb1qtw...', value: 2714, isChange: true },
71+
],
72+
id: '685ac2f3c2f8a2a5d9cc18d3593f1751',
73+
coin: 'tbtc',
74+
wallet: '685abbf19ca95b79f88e0b41d9337109',
75+
txid: '239d143cdfc6d6c83a935da4f3d610b2364a956c7b6dcdc165eb706f62c4432a',
76+
status: 'signed',
77+
},
78+
txid: '239d143cdfc6d6c83a935da4f3d610b2364a956c7b6dcdc165eb706f62c4432a',
79+
tx: '01000000000102580b...',
80+
status: 'signed',
81+
};
82+
83+
const consolidateUnspentsStub = sinon
84+
.stub(Wallet.prototype, 'consolidateUnspents')
85+
.resolves(mockResult);
86+
87+
const response = await agent
88+
.post(`/api/${coin}/wallet/${walletId}/consolidateunspents`)
89+
.set('Authorization', `Bearer ${accessToken}`)
90+
.send({
91+
source: 'user',
92+
pubkey: 'xpub_user',
93+
feeRate: 1000,
94+
});
95+
96+
response.status.should.equal(200);
97+
response.body.should.have.property('transfer');
98+
response.body.should.have.property('txid', mockResult.txid);
99+
response.body.should.have.property('tx', mockResult.tx);
100+
response.body.should.have.property('status', mockResult.status);
101+
response.body.transfer.should.have.property('txid', mockResult.transfer.txid);
102+
response.body.transfer.should.have.property('status', mockResult.transfer.status);
103+
response.body.transfer.should.have.property('entries').which.is.Array();
104+
105+
walletGetNock.done();
106+
keychainGetNock.done();
107+
sinon.assert.calledOnce(consolidateUnspentsStub);
108+
});
109+
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: 'There are too few unspents that meet the given parameters to consolidate (1 available).',
133+
};
134+
135+
const consolidateUnspentsStub = sinon
136+
.stub(Wallet.prototype, 'consolidateUnspents')
137+
.throws(Object.assign(new Error(mockError.details), mockError));
138+
139+
const response = await agent
140+
.post(`/api/${coin}/wallet/${walletId}/consolidateunspents`)
141+
.set('Authorization', `Bearer ${accessToken}`)
142+
.send({
143+
source: 'user',
144+
pubkey: 'xpub_user',
145+
feeRate: 1000,
146+
});
147+
148+
response.status.should.equal(500);
149+
response.body.should.have.property('error', mockError.error);
150+
response.body.should.have.property('name', mockError.name);
151+
response.body.should.have.property('details', mockError.details);
152+
153+
walletGetNock.done();
154+
keychainGetNock.done();
155+
sinon.assert.calledOnce(consolidateUnspentsStub);
156+
});
157+
158+
it('should throw error when provided pubkey does not match wallet keychain', async () => {
159+
const walletGetNock = nock(bitgoApiUrl)
160+
.get(`/api/v2/${coin}/wallet/${walletId}`)
161+
.matchHeader('any', () => true)
162+
.reply(200, {
163+
id: walletId,
164+
type: 'cold',
165+
subType: 'onPrem',
166+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
167+
});
168+
169+
const keychainGetNock = nock(bitgoApiUrl)
170+
.get(`/api/v2/${coin}/key/user-key-id`)
171+
.matchHeader('any', () => true)
172+
.reply(200, {
173+
id: 'user-key-id',
174+
pub: 'xpub_user',
175+
});
176+
177+
const response = await agent
178+
.post(`/api/${coin}/wallet/${walletId}/consolidateunspents`)
179+
.set('Authorization', `Bearer ${accessToken}`)
180+
.send({
181+
source: 'user',
182+
pubkey: 'wrong_pubkey',
183+
feeRate: 1000,
184+
});
185+
186+
response.status.should.equal(500);
187+
188+
walletGetNock.done();
189+
keychainGetNock.done();
190+
});
191+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { RequestTracer, KeyIndices } from '@bitgo/sdk-core';
2+
import logger from '../../../logger';
3+
import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec';
4+
5+
export async function handleConsolidateUnspents(
6+
req: MasterApiSpecRouteRequest<'v1.wallet.consolidateUnspents', 'post'>,
7+
) {
8+
const enclavedExpressClient = req.enclavedExpressClient;
9+
const reqId = new RequestTracer();
10+
const bitgo = req.bitgo;
11+
const baseCoin = bitgo.coin((req as any).params.coin);
12+
const params = (req as any).decoded;
13+
const walletId = (req as any).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+
// Get the signing keychain based on source
21+
const keyIdIndex = params.source === 'user' ? KeyIndices.USER : KeyIndices.BACKUP;
22+
const signingKeychain = await baseCoin.keychains().get({
23+
id: wallet.keyIds()[keyIdIndex],
24+
});
25+
26+
if (!signingKeychain || !signingKeychain.pub) {
27+
throw new Error(`Signing keychain for ${params.source} not found`);
28+
}
29+
30+
if (params.pubkey && params.pubkey !== signingKeychain.pub) {
31+
throw new Error(`Pub provided does not match the keychain on wallet for ${params.source}`);
32+
}
33+
34+
try {
35+
// Create custom signing function that delegates to EBE
36+
const customSigningFunction = async (signParams: any) => {
37+
const signedTx = await enclavedExpressClient.signMultisig({
38+
txPrebuild: signParams.txPrebuild,
39+
source: params.source,
40+
pub: signingKeychain.pub!,
41+
});
42+
return signedTx;
43+
};
44+
45+
// Prepare consolidation parameters
46+
const consolidationParams = {
47+
...params,
48+
customSigningFunction,
49+
reqId,
50+
};
51+
52+
// Send consolidate unspents
53+
const result = await wallet.consolidateUnspents(consolidationParams);
54+
return result;
55+
} catch (error) {
56+
const err = error as Error;
57+
logger.error('Failed to consolidate unspents: %s', err.message);
58+
throw err;
59+
}
60+
}

src/api/master/routers/masterApiSpec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { validateMasterExpressConfig } from '../middleware/middleware';
2323
import { handleRecoveryWalletOnPrem } from '../handlers/recoveryWallet';
2424
import { handleConsolidate } from '../handlers/handleConsolidate';
2525
import { handleAccelerate } from '../handlers/handleAccelerate';
26+
import { handleConsolidateUnspents } from '../handlers/handleConsolidateUnspents';
2627

2728
// Middleware functions
2829
export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) {
@@ -188,6 +189,42 @@ const RecoveryWalletRequest = {
188189

189190
export type RecoveryWalletRequest = typeof RecoveryWalletRequest;
190191

192+
export const ConsolidateUnspentsRequest = {
193+
pubkey: t.string,
194+
source: t.union([t.literal('user'), t.literal('backup')]),
195+
walletPassphrase: t.union([t.undefined, t.string]),
196+
xprv: t.union([t.undefined, t.string]),
197+
feeRate: t.union([t.undefined, t.union([t.string, t.number])]),
198+
maxFeeRate: t.union([t.undefined, t.union([t.string, t.number])]),
199+
maxFeePercentage: t.union([t.undefined, t.number]),
200+
feeTxConfirmTarget: t.union([t.undefined, t.number]),
201+
bulk: t.union([t.undefined, t.boolean]),
202+
minValue: t.union([t.undefined, t.union([t.string, t.number])]),
203+
maxValue: t.union([t.undefined, t.union([t.string, t.number])]),
204+
minHeight: t.union([t.undefined, t.number]),
205+
minConfirms: t.union([t.undefined, t.number]),
206+
enforceMinConfirmsForChange: t.union([t.undefined, t.boolean]),
207+
limit: t.union([t.undefined, t.number]),
208+
numUnspentsToMake: t.union([t.undefined, t.number]),
209+
targetAddress: t.union([t.undefined, t.string]),
210+
txFormat: t.union([
211+
t.undefined,
212+
t.literal('legacy'),
213+
t.literal('psbt'),
214+
t.literal('psbt-lite'),
215+
]),
216+
};
217+
218+
const ConsolidateUnspentsResponse: HttpResponse = {
219+
200: t.any,
220+
202: t.any,
221+
400: t.any,
222+
500: t.type({
223+
error: t.string,
224+
details: t.string,
225+
}),
226+
};
227+
191228
// API Specification
192229
export const MasterApiSpec = apiSpec({
193230
'v1.wallet.generate': {

0 commit comments

Comments
 (0)