Skip to content

Commit 0e5ab4f

Browse files
Merge pull request #52 from BitGo/WP-5143/recovery-consolidate
feat(mbp): recovery consolidate from wallet address
2 parents 454b4c6 + d356c9a commit 0e5ab4f

File tree

5 files changed

+517
-9
lines changed

5 files changed

+517
-9
lines changed
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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 { Trx } from '@bitgo/sdk-coin-trx';
8+
import { Sol } from '@bitgo/sdk-coin-sol';
9+
import { Sui } from '@bitgo/sdk-coin-sui';
10+
import { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpressClient';
11+
12+
describe('POST /api/:coin/wallet/recoveryconsolidations', () => {
13+
let agent: request.SuperAgentTest;
14+
const enclavedExpressUrl = 'http://enclaved.invalid';
15+
const accessToken = 'test-token';
16+
17+
before(() => {
18+
nock.disableNetConnect();
19+
nock.enableNetConnect('127.0.0.1');
20+
const config: MasterExpressConfig = {
21+
appMode: AppMode.MASTER_EXPRESS,
22+
port: 0,
23+
bind: 'localhost',
24+
timeout: 60000,
25+
logFile: '',
26+
env: 'test',
27+
disableEnvCheck: true,
28+
authVersion: 2,
29+
enclavedExpressUrl,
30+
enclavedExpressCert: 'dummy-cert',
31+
tlsMode: TlsMode.DISABLED,
32+
mtlsRequestCert: false,
33+
allowSelfSigned: true,
34+
};
35+
const app = expressApp(config);
36+
agent = request.agent(app);
37+
});
38+
39+
afterEach(() => {
40+
nock.cleanAll();
41+
sinon.restore();
42+
});
43+
44+
describe('Non-MPC Wallets (multisigType: onchain)', () => {
45+
it('should handle TRON consolidation recovery for onchain wallet', async () => {
46+
const mockTransactions = [
47+
{ txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' },
48+
{ txHex: 'unsigned-tx-2', serializedTx: 'serialized-unsigned-tx-2' },
49+
];
50+
51+
const recoverConsolidationsStub = sinon
52+
.stub(Trx.prototype, 'recoverConsolidations')
53+
.resolves({
54+
transactions: mockTransactions,
55+
});
56+
57+
const recoveryMultisigStub = sinon
58+
.stub(EnclavedExpressClient.prototype, 'recoveryMultisig')
59+
.resolves({ txHex: 'signed-tx' });
60+
61+
const response = await agent
62+
.post(`/api/trx/wallet/recoveryconsolidations`)
63+
.set('Authorization', `Bearer ${accessToken}`)
64+
.send({
65+
multisigType: 'onchain',
66+
userPub: 'user-xpub',
67+
backupPub: 'backup-xpub',
68+
bitgoPub: 'bitgo-xpub',
69+
tokenContractAddress: 'tron-token',
70+
startingScanIndex: 1,
71+
endingScanIndex: 3,
72+
});
73+
74+
response.status.should.equal(200);
75+
response.body.should.have.property('signedTxs');
76+
response.body.signedTxs.should.have.length(2);
77+
78+
sinon.assert.calledOnce(recoverConsolidationsStub);
79+
sinon.assert.calledTwice(recoveryMultisigStub);
80+
81+
const callArgs = recoverConsolidationsStub.firstCall.args[0];
82+
callArgs.tokenContractAddress!.should.equal('tron-token');
83+
callArgs.userKey!.should.equal('user-xpub');
84+
callArgs.backupKey!.should.equal('backup-xpub');
85+
callArgs.bitgoKey.should.equal('bitgo-xpub');
86+
});
87+
88+
it('should handle Solana consolidation recovery for onchain wallet', async () => {
89+
const mockTransactions = [
90+
{ txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' },
91+
];
92+
93+
const recoverConsolidationsStub = sinon
94+
.stub(Sol.prototype, 'recoverConsolidations')
95+
.resolves({
96+
transactions: mockTransactions,
97+
});
98+
99+
const recoveryMultisigStub = sinon
100+
.stub(EnclavedExpressClient.prototype, 'recoveryMultisig')
101+
.resolves({ txHex: 'signed-tx' });
102+
103+
const response = await agent
104+
.post(`/api/sol/wallet/recoveryconsolidations`)
105+
.set('Authorization', `Bearer ${accessToken}`)
106+
.send({
107+
multisigType: 'onchain',
108+
userPub: 'user-xpub',
109+
backupPub: 'backup-xpub',
110+
bitgoPub: 'bitgo-xpub',
111+
durableNonces: {
112+
publicKeys: ['sol-pubkey-1', 'sol-pubkey-2'],
113+
secretKey: 'sol-secret',
114+
},
115+
});
116+
117+
response.status.should.equal(200);
118+
response.body.should.have.property('signedTxs');
119+
sinon.assert.calledOnce(recoverConsolidationsStub);
120+
sinon.assert.calledOnce(recoveryMultisigStub);
121+
122+
const callArgs = recoverConsolidationsStub.firstCall.args[0];
123+
callArgs.durableNonces.should.have.property('publicKeys').which.is.an.Array();
124+
callArgs.durableNonces.should.have.property('secretKey', 'sol-secret');
125+
callArgs.userKey!.should.equal('user-xpub');
126+
callArgs.backupKey!.should.equal('backup-xpub');
127+
callArgs.bitgoKey.should.equal('bitgo-xpub');
128+
});
129+
});
130+
131+
describe('MPC Wallets (multisigType: tss)', () => {
132+
it('should handle MPC consolidation recovery with commonKeychain', async () => {
133+
const mockTxRequests = [
134+
{
135+
walletCoin: 'tsui',
136+
transactions: [
137+
{
138+
unsignedTx: {
139+
txHex: 'unsigned-mpc-tx-1',
140+
serializedTx: 'serialized-unsigned-mpc-tx-1',
141+
},
142+
signatureShares: [],
143+
},
144+
],
145+
},
146+
] as any;
147+
148+
const recoverConsolidationsStub = sinon
149+
.stub(Sui.prototype, 'recoverConsolidations')
150+
.resolves({
151+
txRequests: mockTxRequests,
152+
});
153+
154+
const recoveryMPCStub = sinon
155+
.stub(EnclavedExpressClient.prototype, 'recoveryMPC')
156+
.resolves({ txHex: 'signed-mpc-tx' });
157+
158+
const response = await agent
159+
.post(`/api/tsui/wallet/recoveryconsolidations`)
160+
.set('Authorization', `Bearer ${accessToken}`)
161+
.send({
162+
multisigType: 'tss',
163+
commonKeychain: 'common-keychain-key',
164+
apiKey: 'test-api-key',
165+
startingScanIndex: 0,
166+
endingScanIndex: 5,
167+
});
168+
169+
response.status.should.equal(200);
170+
response.body.should.have.property('signedTxs');
171+
response.body.signedTxs.should.have.length(1);
172+
173+
sinon.assert.calledOnce(recoverConsolidationsStub);
174+
sinon.assert.calledOnce(recoveryMPCStub);
175+
176+
const callArgs = recoverConsolidationsStub.firstCall.args[0];
177+
callArgs.userKey!.should.equal('');
178+
callArgs.backupKey!.should.equal('');
179+
callArgs.bitgoKey.should.equal('common-keychain-key');
180+
181+
const mpcCallArgs = recoveryMPCStub.firstCall.args[0];
182+
mpcCallArgs.userPub.should.equal('common-keychain-key');
183+
mpcCallArgs.backupPub.should.equal('common-keychain-key');
184+
mpcCallArgs.apiKey.should.equal('test-api-key');
185+
});
186+
187+
it('should handle SOL MPC consolidation recovery', async () => {
188+
const mockTransactions = [
189+
{ txHex: 'unsigned-mpc-tx-1', serializedTx: 'serialized-mpc-tx-1' },
190+
];
191+
192+
const recoverConsolidationsStub = sinon
193+
.stub(Sol.prototype, 'recoverConsolidations')
194+
.resolves({
195+
transactions: mockTransactions,
196+
});
197+
198+
const recoveryMPCStub = sinon
199+
.stub(EnclavedExpressClient.prototype, 'recoveryMPC')
200+
.resolves({ txHex: 'signed-mpc-tx' });
201+
202+
const response = await agent
203+
.post(`/api/sol/wallet/recoveryconsolidations`)
204+
.set('Authorization', `Bearer ${accessToken}`)
205+
.send({
206+
multisigType: 'tss',
207+
commonKeychain: 'sol-common-key',
208+
apiKey: 'sol-api-key',
209+
durableNonces: {
210+
publicKeys: ['sol-pubkey-1'],
211+
secretKey: 'sol-secret',
212+
},
213+
});
214+
215+
response.status.should.equal(200);
216+
response.body.should.have.property('signedTxs');
217+
sinon.assert.calledOnce(recoverConsolidationsStub);
218+
sinon.assert.calledOnce(recoveryMPCStub);
219+
220+
const mpcCallArgs = recoveryMPCStub.firstCall.args[0];
221+
mpcCallArgs.userPub.should.equal('sol-common-key');
222+
mpcCallArgs.backupPub.should.equal('sol-common-key');
223+
mpcCallArgs.apiKey.should.equal('sol-api-key');
224+
});
225+
});
226+
227+
describe('Error Cases', () => {
228+
it('should throw error when commonKeychain is missing for MPC wallet', async () => {
229+
const response = await agent
230+
.post(`/api/tsui/wallet/recoveryconsolidations`)
231+
.set('Authorization', `Bearer ${accessToken}`)
232+
.send({
233+
multisigType: 'tss',
234+
// Missing commonKeychain
235+
apiKey: 'test-api-key',
236+
});
237+
238+
response.status.should.equal(500);
239+
response.body.should.have.property('error');
240+
response.body.should.have
241+
.property('details')
242+
.which.match(/Missing required key: commonKeychain/);
243+
});
244+
245+
it('should throw error when required keys are missing for onchain wallet', async () => {
246+
const response = await agent
247+
.post(`/api/trx/wallet/recoveryconsolidations`)
248+
.set('Authorization', `Bearer ${accessToken}`)
249+
.send({
250+
multisigType: 'onchain',
251+
userPub: 'user-xpub',
252+
// Missing backupPub and bitgoPub
253+
});
254+
255+
response.status.should.equal(500);
256+
response.body.should.have.property('error');
257+
response.body.should.have.property('details').which.match(/Missing required keys/);
258+
});
259+
260+
it('should handle empty recovery consolidations result', async () => {
261+
const recoverConsolidationsStub = sinon
262+
.stub(Trx.prototype, 'recoverConsolidations')
263+
.resolves({
264+
transactions: [], // Empty result
265+
} as any);
266+
267+
const response = await agent
268+
.post(`/api/trx/wallet/recoveryconsolidations`)
269+
.set('Authorization', `Bearer ${accessToken}`)
270+
.send({
271+
multisigType: 'onchain',
272+
userPub: 'user-xpub',
273+
backupPub: 'backup-xpub',
274+
bitgoPub: 'bitgo-xpub',
275+
});
276+
277+
response.status.should.equal(200);
278+
response.body.should.have.property('signedTxs');
279+
response.body.signedTxs.should.have.length(0); // Empty array
280+
281+
sinon.assert.calledOnce(recoverConsolidationsStub);
282+
});
283+
284+
it('should throw error when recoverConsolidations returns unexpected result structure', async () => {
285+
const recoverConsolidationsStub = sinon
286+
.stub(Trx.prototype, 'recoverConsolidations')
287+
.resolves({
288+
// Missing both transactions and txRequests properties
289+
someOtherProperty: 'value',
290+
} as any);
291+
292+
const response = await agent
293+
.post(`/api/trx/wallet/recoveryconsolidations`)
294+
.set('Authorization', `Bearer ${accessToken}`)
295+
.send({
296+
multisigType: 'onchain',
297+
userPub: 'user-xpub',
298+
backupPub: 'backup-xpub',
299+
bitgoPub: 'bitgo-xpub',
300+
});
301+
302+
response.status.should.equal(500);
303+
response.body.should.have.property('error');
304+
response.body.should.have
305+
.property('details')
306+
.which.match(/recoverConsolidations did not return expected transactions/);
307+
308+
sinon.assert.calledOnce(recoverConsolidationsStub);
309+
});
310+
});
311+
});

src/api/master/clients/enclavedExpressClient.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import {
1313
GShare,
1414
Keychain,
1515
ApiKeyShare,
16-
MPCSweepTxs,
1716
MPCTx,
17+
MPCSweepTxs,
1818
MPCTxs,
19+
MPCUnsignedTx,
1920
} from '@bitgo/sdk-core';
21+
import { RecoveryTransaction } from '@bitgo/sdk-coin-trx';
2022
import { superagentRequestFactory, buildApiClient, ApiClient } from '@api-ts/superagent-wrapper';
2123
import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth';
2224

@@ -33,6 +35,7 @@ import {
3335
MpcV2RoundResponseType,
3436
} from '../../../enclavedBitgoExpress/routers/enclavedApiSpec';
3537
import { FormattedOfflineVaultTxInfo } from '@bitgo/abstract-utxo';
38+
import { RecoveryTxRequest } from 'bitgo';
3639

3740
const debugLogger = debug('bitgo:express:enclavedExpressClient');
3841

@@ -87,7 +90,9 @@ interface RecoveryMultisigOptions {
8790
| RecoveryInfo
8891
| OfflineVaultTxInfo
8992
| UnsignedSweepTxMPCv2
90-
| FormattedOfflineVaultTxInfo;
93+
| FormattedOfflineVaultTxInfo
94+
| MPCTx
95+
| RecoveryTransaction;
9196
walletContractAddress: string;
9297
}
9398

@@ -170,7 +175,7 @@ export interface SignMpcV2Round3Response {
170175

171176
export class EnclavedExpressClient {
172177
async recoveryMPC(params: {
173-
unsignedSweepPrebuildTx: MPCTx | MPCSweepTxs | MPCTxs;
178+
unsignedSweepPrebuildTx: MPCTx | MPCSweepTxs | MPCTxs | RecoveryTxRequest;
174179
userPub: string;
175180
backupPub: string;
176181
apiKey: string;
@@ -201,6 +206,11 @@ export class EnclavedExpressClient {
201206
txRequest.signableHex = firstTx.unsignedTx?.serializedTx || '';
202207
txRequest.derivationPath = firstTx.unsignedTx?.derivationPath || '';
203208
}
209+
} else if ('transactions' in tx && Array.isArray(tx.transactions)) {
210+
// RecoveryTxRequest
211+
const firstTransaction = tx.transactions[0] as MPCUnsignedTx;
212+
txRequest.signableHex = firstTransaction.unsignedTx?.serializedTx || '';
213+
txRequest.derivationPath = firstTransaction.unsignedTx?.derivationPath || '';
204214
} else if ('signableHex' in tx) {
205215
// MPCTx format
206216
txRequest.signableHex = tx.signableHex || '';

0 commit comments

Comments
 (0)