Skip to content

Commit 47cef83

Browse files
Merge pull request #43 from BitGo/WP-5142/consolidateunspents
[MBE]: Consolidate unspents for btc
2 parents 30936ff + f6205c2 commit 47cef83

File tree

9 files changed

+525
-73
lines changed

9 files changed

+525
-73
lines changed

masterBitgoExpress.json

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,171 @@
7373
}
7474
}
7575
},
76-
"202": {
77-
"description": "Accepted",
76+
"400": {
77+
"description": "Bad Request",
7878
"content": {
7979
"application/json": {
8080
"schema": {}
8181
}
8282
}
8383
},
84+
"500": {
85+
"description": "Internal Server Error",
86+
"content": {
87+
"application/json": {
88+
"schema": {
89+
"type": "object",
90+
"properties": {
91+
"error": {
92+
"type": "string"
93+
},
94+
"details": {
95+
"type": "string"
96+
}
97+
},
98+
"required": [
99+
"error",
100+
"details"
101+
]
102+
}
103+
}
104+
}
105+
}
106+
}
107+
}
108+
},
109+
"/api/{coin}/wallet/{walletId}/consolidateunspents": {
110+
"post": {
111+
"parameters": [
112+
{
113+
"name": "walletId",
114+
"in": "path",
115+
"required": true,
116+
"schema": {
117+
"type": "string"
118+
}
119+
},
120+
{
121+
"name": "coin",
122+
"in": "path",
123+
"required": true,
124+
"schema": {
125+
"type": "string"
126+
}
127+
}
128+
],
129+
"requestBody": {
130+
"content": {
131+
"application/json": {
132+
"schema": {
133+
"type": "object",
134+
"properties": {
135+
"pubkey": {
136+
"type": "string"
137+
},
138+
"source": {
139+
"type": "string",
140+
"enum": [
141+
"user",
142+
"backup"
143+
]
144+
},
145+
"walletPassphrase": {
146+
"type": "string"
147+
},
148+
"feeRate": {
149+
"type": "number"
150+
},
151+
"maxFeeRate": {
152+
"type": "number"
153+
},
154+
"maxFeePercentage": {
155+
"type": "number"
156+
},
157+
"feeTxConfirmTarget": {
158+
"type": "number"
159+
},
160+
"bulk": {
161+
"type": "boolean"
162+
},
163+
"minValue": {
164+
"oneOf": [
165+
{
166+
"type": "string"
167+
},
168+
{
169+
"type": "number"
170+
}
171+
]
172+
},
173+
"maxValue": {
174+
"oneOf": [
175+
{
176+
"type": "string"
177+
},
178+
{
179+
"type": "number"
180+
}
181+
]
182+
},
183+
"minHeight": {
184+
"type": "number"
185+
},
186+
"minConfirms": {
187+
"type": "number"
188+
},
189+
"enforceMinConfirmsForChange": {
190+
"type": "boolean"
191+
},
192+
"limit": {
193+
"type": "number"
194+
},
195+
"numUnspentsToMake": {
196+
"type": "number"
197+
},
198+
"targetAddress": {
199+
"type": "string"
200+
},
201+
"txFormat": {
202+
"type": "string",
203+
"enum": [
204+
"legacy",
205+
"psbt",
206+
"psbt-lite"
207+
]
208+
}
209+
},
210+
"required": [
211+
"pubkey",
212+
"source"
213+
]
214+
}
215+
}
216+
}
217+
},
218+
"responses": {
219+
"200": {
220+
"description": "OK",
221+
"content": {
222+
"application/json": {
223+
"schema": {
224+
"type": "object",
225+
"properties": {
226+
"tx": {
227+
"type": "string"
228+
},
229+
"txid": {
230+
"type": "string"
231+
}
232+
},
233+
"required": [
234+
"tx",
235+
"txid"
236+
]
237+
}
238+
}
239+
}
240+
},
84241
"400": {
85242
"description": "Bad Request",
86243
"content": {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"test:watch": "mocha --require ts-node/register --watch 'src/**/__tests__/**/*.test.ts'",
1515
"test:coverage": "nyc mocha --require ts-node/register 'src/**/__tests__/**/*.test.ts'",
1616
"lint": "eslint --quiet .",
17+
"lint:fix": "eslint --quiet . --fix",
1718
"generate-test-ssl": "openssl req -x509 -newkey rsa:2048 -keyout test-ssl-key.pem -out test-ssl-cert.pem -days 365 -nodes -subj '/CN=localhost'",
1819
"generate:openapi:masterExpress": "npx @api-ts/openapi-generator --name @bitgo/master-bitgo-express ./src/api/master/routers/index.ts > masterBitgoExpress.json",
1920
"container:build": "podman build -t bitgo-onprem-express ."
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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:
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+
159+
it('should throw error when provided pubkey does not match wallet keychain', async () => {
160+
const walletGetNock = nock(bitgoApiUrl)
161+
.get(`/api/v2/${coin}/wallet/${walletId}`)
162+
.matchHeader('any', () => true)
163+
.reply(200, {
164+
id: walletId,
165+
type: 'cold',
166+
subType: 'onPrem',
167+
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
168+
});
169+
170+
const keychainGetNock = nock(bitgoApiUrl)
171+
.get(`/api/v2/${coin}/key/user-key-id`)
172+
.matchHeader('any', () => true)
173+
.reply(200, {
174+
id: 'user-key-id',
175+
pub: 'xpub_user',
176+
});
177+
178+
const response = await agent
179+
.post(`/api/${coin}/wallet/${walletId}/consolidateunspents`)
180+
.set('Authorization', `Bearer ${accessToken}`)
181+
.send({
182+
source: 'user',
183+
pubkey: 'wrong_pubkey',
184+
feeRate: 1000,
185+
});
186+
187+
response.status.should.equal(500);
188+
189+
walletGetNock.done();
190+
keychainGetNock.done();
191+
});
192+
});

0 commit comments

Comments
 (0)