Skip to content

Commit ca90869

Browse files
feat(mmbe): recovery consolidation
1 parent e908fe6 commit ca90869

File tree

4 files changed

+187
-8
lines changed

4 files changed

+187
-8
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpressClient';
10+
11+
describe('POST /api/:coin/wallet/recoveryConsolidations', () => {
12+
let agent: request.SuperAgentTest;
13+
const enclavedExpressUrl = 'http://enclaved.invalid';
14+
const accessToken = 'test-token';
15+
16+
before(() => {
17+
nock.disableNetConnect();
18+
nock.enableNetConnect('127.0.0.1');
19+
const config: MasterExpressConfig = {
20+
appMode: AppMode.MASTER_EXPRESS,
21+
port: 0,
22+
bind: 'localhost',
23+
timeout: 60000,
24+
logFile: '',
25+
env: 'test',
26+
disableEnvCheck: true,
27+
authVersion: 2,
28+
enclavedExpressUrl,
29+
enclavedExpressCert: 'dummy-cert',
30+
tlsMode: TlsMode.DISABLED,
31+
mtlsRequestCert: false,
32+
allowSelfSigned: true,
33+
};
34+
const app = expressApp(config);
35+
agent = request.agent(app);
36+
});
37+
38+
afterEach(() => {
39+
nock.cleanAll();
40+
sinon.restore();
41+
});
42+
43+
it('should handle TRON consolidation recovery', async () => {
44+
const mockTransactions = [{ txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }];
45+
46+
const recoverConsolidationsStub = sinon.stub(Trx.prototype, 'recoverConsolidations').resolves({
47+
transactions: mockTransactions,
48+
});
49+
50+
const recoveryMultisigStub = sinon
51+
.stub(EnclavedExpressClient.prototype, 'recoveryMultisig')
52+
.resolves({ txHex: 'signed-tx' });
53+
54+
const response = await agent
55+
.post(`/api/trx/wallet/recoveryConsolidations`)
56+
.set('Authorization', `Bearer ${accessToken}`)
57+
.send({
58+
userPub: 'user-xpub',
59+
backupPub: 'backup-xpub',
60+
bitgoKey: 'bitgo-xpub',
61+
tokenContractAddress: 'tron-token',
62+
startingScanIndex: 1,
63+
endingScanIndex: 3,
64+
});
65+
66+
response.status.should.equal(200);
67+
response.body.should.have.property('signedTxs');
68+
sinon.assert.calledOnce(recoverConsolidationsStub);
69+
sinon.assert.calledOnce(recoveryMultisigStub);
70+
const callArgs = recoverConsolidationsStub.firstCall.args[0];
71+
callArgs.tokenContractAddress!.should.equal('tron-token');
72+
callArgs.userKey.should.equal('user-xpub');
73+
callArgs.backupKey.should.equal('backup-xpub');
74+
callArgs.bitgoKey.should.equal('bitgo-xpub');
75+
});
76+
77+
it('should handle Solana consolidation recovery', async () => {
78+
const mockTransactions = [{ txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }];
79+
80+
const recoverConsolidationsStub = sinon.stub(Sol.prototype, 'recoverConsolidations').resolves({
81+
transactions: mockTransactions,
82+
});
83+
84+
const recoveryMultisigStub = sinon
85+
.stub(EnclavedExpressClient.prototype, 'recoveryMultisig')
86+
.resolves({ txHex: 'signed-tx' });
87+
88+
const response = await agent
89+
.post(`/api/sol/wallet/recoveryConsolidations`)
90+
.set('Authorization', `Bearer ${accessToken}`)
91+
.send({
92+
userPub: 'user-xpub',
93+
backupPub: 'backup-xpub',
94+
bitgoKey: 'bitgo-xpub',
95+
durableNonces: {
96+
publicKeys: ['sol-pubkey-1', 'sol-pubkey-2'],
97+
secretKey: 'sol-secret',
98+
},
99+
});
100+
101+
response.status.should.equal(200);
102+
response.body.should.have.property('signedTxs');
103+
sinon.assert.calledOnce(recoverConsolidationsStub);
104+
sinon.assert.calledOnce(recoveryMultisigStub);
105+
const callArgs = recoverConsolidationsStub.firstCall.args[0];
106+
callArgs.durableNonces.should.have.property('publicKeys').which.is.an.Array();
107+
callArgs.durableNonces.should.have.property('secretKey', 'sol-secret');
108+
});
109+
});

src/api/master/clients/enclavedExpressClient.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@ import {
1313
GShare,
1414
Keychain,
1515
ApiKeyShare,
16-
MPCSweepTxs,
1716
MPCTx,
18-
MPCTxs,
1917
} from '@bitgo/sdk-core';
18+
import { RecoveryTransaction } from '@bitgo/sdk-coin-trx';
2019
import { superagentRequestFactory, buildApiClient, ApiClient } from '@api-ts/superagent-wrapper';
2120
import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth';
2221

@@ -87,7 +86,9 @@ interface RecoveryMultisigOptions {
8786
| RecoveryInfo
8887
| OfflineVaultTxInfo
8988
| UnsignedSweepTxMPCv2
90-
| FormattedOfflineVaultTxInfo;
89+
| FormattedOfflineVaultTxInfo
90+
| MPCTx
91+
| RecoveryTransaction;
9192
walletContractAddress: string;
9293
}
9394

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec';
2+
import logger from '../../../logger';
3+
import { isSolCoin } from '../../../shared/coinUtils';
4+
import { MPCTx } from 'bitgo';
5+
import { RecoveryTransaction } from '@bitgo/sdk-coin-trx';
6+
7+
// Handler for recovery from receive addresses (consolidation sweeps)
8+
export async function handleRecoveryConsolidationsOnPrem(
9+
req: MasterApiSpecRouteRequest<'v1.wallet.recoveryConsolidations', 'post'>,
10+
) {
11+
const bitgo = req.bitgo;
12+
const coin = req.decoded.coin;
13+
const enclavedExpressClient = req.enclavedExpressClient;
14+
15+
const { userPub, backupPub, bitgoKey } = req.decoded;
16+
17+
const sdkCoin = bitgo.coin(coin);
18+
let txs: MPCTx[] | RecoveryTransaction[] = [];
19+
// 1. Build unsigned consolidations
20+
if (isSolCoin(sdkCoin) && !req.decoded.durableNonces) {
21+
throw new Error('durableNonces is required for Solana consolidation recovery');
22+
}
23+
24+
if (typeof (sdkCoin as any).recoverConsolidations !== 'function') {
25+
throw new Error(`recoverConsolidations is not supported for coin: ${coin}`);
26+
}
27+
28+
// Use type assertion to access recoverConsolidations
29+
const result = await (sdkCoin as any).recoverConsolidations({
30+
...req.decoded,
31+
userKey: userPub,
32+
backupKey: backupPub,
33+
bitgoKey,
34+
durableNonces: req.decoded.durableNonces,
35+
});
36+
37+
if ('transactions' in result) {
38+
txs = result.transactions;
39+
} else if ('txRequests' in result) {
40+
txs = result.txRequests;
41+
} else {
42+
throw new Error('recoverConsolidations did not return expected transactions');
43+
}
44+
45+
logger.debug(`Found ${txs.length} unsigned consolidation transactions`);
46+
47+
// 2. For each unsigned sweep, get it signed by EBE (using recoveryMultisig)
48+
const signedTxs = [];
49+
try {
50+
for (const tx of txs) {
51+
const signedTx = await enclavedExpressClient.recoveryMultisig({
52+
userPub,
53+
backupPub,
54+
unsignedSweepPrebuildTx: tx,
55+
walletContractAddress: '',
56+
});
57+
signedTxs.push(signedTx);
58+
}
59+
60+
return { signedTxs };
61+
} catch (err) {
62+
logger.error('Error during consolidation recovery:', err);
63+
throw err;
64+
}
65+
}

src/shared/coinUtils.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth';
22
import { BackupKeyRecoveryTransansaction, FormattedOfflineVaultTxInfo } from '@bitgo/abstract-utxo';
33
import { CoinFamily } from '@bitgo/statics';
44
import { BaseCoin } from 'bitgo';
5-
import { AbstractUtxoCoin, Eos, Sol, Stx, Xtz } from 'bitgo/dist/types/src/v2/coins';
6-
7-
export function isSolCoin(coin: BaseCoin): coin is Sol {
8-
return isFamily(coin, CoinFamily.SOL);
9-
}
5+
import { AbstractUtxoCoin, Eos, Stx, Xtz, Sol, Trx } from 'bitgo/dist/types/src/v2/coins';
106

117
export function isEthLikeCoin(coin: BaseCoin): coin is AbstractEthLikeNewCoins {
128
const isEthPure = isFamily(coin, CoinFamily.ETH);
@@ -56,6 +52,14 @@ export function isXtzCoin(coin: BaseCoin): coin is Xtz {
5652
return isFamily(coin, CoinFamily.XTZ);
5753
}
5854

55+
export function isSolCoin(coin: BaseCoin): coin is Sol {
56+
return isFamily(coin, CoinFamily.SOL);
57+
}
58+
59+
export function isTrxCoin(coin: BaseCoin): coin is Trx {
60+
return isFamily(coin, CoinFamily.TRX);
61+
}
62+
5963
function isFamily(coin: BaseCoin, family: CoinFamily) {
6064
return Boolean(coin && coin.getFamily() === family);
6165
}

0 commit comments

Comments
 (0)