diff --git a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts new file mode 100644 index 0000000..0d37a71 --- /dev/null +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -0,0 +1,311 @@ +import 'should'; +import sinon from 'sinon'; +import * as request from 'supertest'; +import nock from 'nock'; +import { app as expressApp } from '../../../masterExpressApp'; +import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import { Trx } from '@bitgo/sdk-coin-trx'; +import { Sol } from '@bitgo/sdk-coin-sol'; +import { Sui } from '@bitgo/sdk-coin-sui'; +import { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpressClient'; + +describe('POST /api/:coin/wallet/recoveryconsolidations', () => { + let agent: request.SuperAgentTest; + const enclavedExpressUrl = 'http://enclaved.invalid'; + const accessToken = 'test-token'; + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + const config: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 60000, + logFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + enclavedExpressUrl, + enclavedExpressCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + mtlsRequestCert: false, + allowSelfSigned: true, + }; + const app = expressApp(config); + agent = request.agent(app); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + describe('Non-MPC Wallets (multisigType: onchain)', () => { + it('should handle TRON consolidation recovery for onchain wallet', async () => { + const mockTransactions = [ + { txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }, + { txHex: 'unsigned-tx-2', serializedTx: 'serialized-unsigned-tx-2' }, + ]; + + const recoverConsolidationsStub = sinon + .stub(Trx.prototype, 'recoverConsolidations') + .resolves({ + transactions: mockTransactions, + }); + + const recoveryMultisigStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') + .resolves({ txHex: 'signed-tx' }); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: 'user-xpub', + backupPub: 'backup-xpub', + bitgoPub: 'bitgo-xpub', + tokenContractAddress: 'tron-token', + startingScanIndex: 1, + endingScanIndex: 3, + }); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(2); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledTwice(recoveryMultisigStub); + + const callArgs = recoverConsolidationsStub.firstCall.args[0]; + callArgs.tokenContractAddress!.should.equal('tron-token'); + callArgs.userKey!.should.equal('user-xpub'); + callArgs.backupKey!.should.equal('backup-xpub'); + callArgs.bitgoKey.should.equal('bitgo-xpub'); + }); + + it('should handle Solana consolidation recovery for onchain wallet', async () => { + const mockTransactions = [ + { txHex: 'unsigned-tx-1', serializedTx: 'serialized-unsigned-tx-1' }, + ]; + + const recoverConsolidationsStub = sinon + .stub(Sol.prototype, 'recoverConsolidations') + .resolves({ + transactions: mockTransactions, + }); + + const recoveryMultisigStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMultisig') + .resolves({ txHex: 'signed-tx' }); + + const response = await agent + .post(`/api/sol/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: 'user-xpub', + backupPub: 'backup-xpub', + bitgoPub: 'bitgo-xpub', + durableNonces: { + publicKeys: ['sol-pubkey-1', 'sol-pubkey-2'], + secretKey: 'sol-secret', + }, + }); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMultisigStub); + + const callArgs = recoverConsolidationsStub.firstCall.args[0]; + callArgs.durableNonces.should.have.property('publicKeys').which.is.an.Array(); + callArgs.durableNonces.should.have.property('secretKey', 'sol-secret'); + callArgs.userKey!.should.equal('user-xpub'); + callArgs.backupKey!.should.equal('backup-xpub'); + callArgs.bitgoKey.should.equal('bitgo-xpub'); + }); + }); + + describe('MPC Wallets (multisigType: tss)', () => { + it('should handle MPC consolidation recovery with commonKeychain', async () => { + const mockTxRequests = [ + { + walletCoin: 'tsui', + transactions: [ + { + unsignedTx: { + txHex: 'unsigned-mpc-tx-1', + serializedTx: 'serialized-unsigned-mpc-tx-1', + }, + signatureShares: [], + }, + ], + }, + ] as any; + + const recoverConsolidationsStub = sinon + .stub(Sui.prototype, 'recoverConsolidations') + .resolves({ + txRequests: mockTxRequests, + }); + + const recoveryMPCStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMPC') + .resolves({ txHex: 'signed-mpc-tx' }); + + const response = await agent + .post(`/api/tsui/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'tss', + commonKeychain: 'common-keychain-key', + apiKey: 'test-api-key', + startingScanIndex: 0, + endingScanIndex: 5, + }); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(1); + + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMPCStub); + + const callArgs = recoverConsolidationsStub.firstCall.args[0]; + callArgs.userKey!.should.equal(''); + callArgs.backupKey!.should.equal(''); + callArgs.bitgoKey.should.equal('common-keychain-key'); + + const mpcCallArgs = recoveryMPCStub.firstCall.args[0]; + mpcCallArgs.userPub.should.equal('common-keychain-key'); + mpcCallArgs.backupPub.should.equal('common-keychain-key'); + mpcCallArgs.apiKey.should.equal('test-api-key'); + }); + + it('should handle SOL MPC consolidation recovery', async () => { + const mockTransactions = [ + { txHex: 'unsigned-mpc-tx-1', serializedTx: 'serialized-mpc-tx-1' }, + ]; + + const recoverConsolidationsStub = sinon + .stub(Sol.prototype, 'recoverConsolidations') + .resolves({ + transactions: mockTransactions, + }); + + const recoveryMPCStub = sinon + .stub(EnclavedExpressClient.prototype, 'recoveryMPC') + .resolves({ txHex: 'signed-mpc-tx' }); + + const response = await agent + .post(`/api/sol/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'tss', + commonKeychain: 'sol-common-key', + apiKey: 'sol-api-key', + durableNonces: { + publicKeys: ['sol-pubkey-1'], + secretKey: 'sol-secret', + }, + }); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + sinon.assert.calledOnce(recoverConsolidationsStub); + sinon.assert.calledOnce(recoveryMPCStub); + + const mpcCallArgs = recoveryMPCStub.firstCall.args[0]; + mpcCallArgs.userPub.should.equal('sol-common-key'); + mpcCallArgs.backupPub.should.equal('sol-common-key'); + mpcCallArgs.apiKey.should.equal('sol-api-key'); + }); + }); + + describe('Error Cases', () => { + it('should throw error when commonKeychain is missing for MPC wallet', async () => { + const response = await agent + .post(`/api/tsui/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'tss', + // Missing commonKeychain + apiKey: 'test-api-key', + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.should.have + .property('details') + .which.match(/Missing required key: commonKeychain/); + }); + + it('should throw error when required keys are missing for onchain wallet', async () => { + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: 'user-xpub', + // Missing backupPub and bitgoPub + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.should.have.property('details').which.match(/Missing required keys/); + }); + + it('should handle empty recovery consolidations result', async () => { + const recoverConsolidationsStub = sinon + .stub(Trx.prototype, 'recoverConsolidations') + .resolves({ + transactions: [], // Empty result + } as any); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: 'user-xpub', + backupPub: 'backup-xpub', + bitgoPub: 'bitgo-xpub', + }); + + response.status.should.equal(200); + response.body.should.have.property('signedTxs'); + response.body.signedTxs.should.have.length(0); // Empty array + + sinon.assert.calledOnce(recoverConsolidationsStub); + }); + + it('should throw error when recoverConsolidations returns unexpected result structure', async () => { + const recoverConsolidationsStub = sinon + .stub(Trx.prototype, 'recoverConsolidations') + .resolves({ + // Missing both transactions and txRequests properties + someOtherProperty: 'value', + } as any); + + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: 'user-xpub', + backupPub: 'backup-xpub', + bitgoPub: 'bitgo-xpub', + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.should.have + .property('details') + .which.match(/recoverConsolidations did not return expected transactions/); + + sinon.assert.calledOnce(recoverConsolidationsStub); + }); + }); +}); diff --git a/src/api/master/clients/enclavedExpressClient.ts b/src/api/master/clients/enclavedExpressClient.ts index 9f30120..84ffb9b 100644 --- a/src/api/master/clients/enclavedExpressClient.ts +++ b/src/api/master/clients/enclavedExpressClient.ts @@ -13,10 +13,12 @@ import { GShare, Keychain, ApiKeyShare, - MPCSweepTxs, MPCTx, + MPCSweepTxs, MPCTxs, + MPCUnsignedTx, } from '@bitgo/sdk-core'; +import { RecoveryTransaction } from '@bitgo/sdk-coin-trx'; import { superagentRequestFactory, buildApiClient, ApiClient } from '@api-ts/superagent-wrapper'; import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth'; @@ -33,6 +35,7 @@ import { MpcV2RoundResponseType, } from '../../../enclavedBitgoExpress/routers/enclavedApiSpec'; import { FormattedOfflineVaultTxInfo } from '@bitgo/abstract-utxo'; +import { RecoveryTxRequest } from 'bitgo'; const debugLogger = debug('bitgo:express:enclavedExpressClient'); @@ -87,7 +90,9 @@ interface RecoveryMultisigOptions { | RecoveryInfo | OfflineVaultTxInfo | UnsignedSweepTxMPCv2 - | FormattedOfflineVaultTxInfo; + | FormattedOfflineVaultTxInfo + | MPCTx + | RecoveryTransaction; walletContractAddress: string; } @@ -170,7 +175,7 @@ export interface SignMpcV2Round3Response { export class EnclavedExpressClient { async recoveryMPC(params: { - unsignedSweepPrebuildTx: MPCTx | MPCSweepTxs | MPCTxs; + unsignedSweepPrebuildTx: MPCTx | MPCSweepTxs | MPCTxs | RecoveryTxRequest; userPub: string; backupPub: string; apiKey: string; @@ -201,6 +206,11 @@ export class EnclavedExpressClient { txRequest.signableHex = firstTx.unsignedTx?.serializedTx || ''; txRequest.derivationPath = firstTx.unsignedTx?.derivationPath || ''; } + } else if ('transactions' in tx && Array.isArray(tx.transactions)) { + // RecoveryTxRequest + const firstTransaction = tx.transactions[0] as MPCUnsignedTx; + txRequest.signableHex = firstTransaction.unsignedTx?.serializedTx || ''; + txRequest.derivationPath = firstTransaction.unsignedTx?.derivationPath || ''; } else if ('signableHex' in tx) { // MPCTx format txRequest.signableHex = tx.signableHex || ''; diff --git a/src/api/master/handlers/recoveryConsolidationsWallet.ts b/src/api/master/handlers/recoveryConsolidationsWallet.ts new file mode 100644 index 0000000..a81f521 --- /dev/null +++ b/src/api/master/handlers/recoveryConsolidationsWallet.ts @@ -0,0 +1,143 @@ +import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; +import logger from '../../../logger'; +import { BaseCoin, MPCConsolidationRecoveryOptions, MPCTx, RecoveryTxRequest } from 'bitgo'; +import { RecoveryTransaction } from '@bitgo/sdk-coin-trx'; +import { BitGoBase } from '@bitgo/sdk-core'; +import { CoinFamily } from '@bitgo/statics'; +import type { Sol, SolConsolidationRecoveryOptions, Tsol } from '@bitgo/sdk-coin-sol'; +import type { Trx, ConsolidationRecoveryOptions, Ttrx } from '@bitgo/sdk-coin-trx'; +import type { Sui, Tsui } from '@bitgo/sdk-coin-sui'; +import type { Ada, Tada } from '@bitgo/sdk-coin-ada'; +import type { Dot, Tdot } from '@bitgo/sdk-coin-dot'; +import type { Tao, Ttao } from '@bitgo/sdk-coin-tao'; + +type RecoveryConsolidationParams = + | ConsolidationRecoveryOptions + | SolConsolidationRecoveryOptions + | MPCConsolidationRecoveryOptions; + +type RecoveryConsolidationResult = { + transactions?: (RecoveryTransaction | MPCTx)[]; + txRequests?: RecoveryTxRequest[]; +}; + +export async function recoveryConsolidateWallets( + sdk: BitGoBase, + baseCoin: BaseCoin, + params: RecoveryConsolidationParams, +): Promise { + const family = baseCoin.getFamily(); + + switch (family) { + case CoinFamily.SOL: { + const { register } = await import('@bitgo/sdk-coin-sol'); + register(sdk); + const solCoin = baseCoin as unknown as Sol | Tsol; + return await solCoin.recoverConsolidations(params as SolConsolidationRecoveryOptions); + } + case CoinFamily.TRX: { + const { register } = await import('@bitgo/sdk-coin-trx'); + register(sdk); + const trxCoin = baseCoin as unknown as Trx | Ttrx; + return await trxCoin.recoverConsolidations(params as ConsolidationRecoveryOptions); + } + default: { + const [ + { register: registerSui }, + { register: registerAda }, + { register: registerDot }, + { register: registerTao }, + ] = await Promise.all([ + import('@bitgo/sdk-coin-sui'), + import('@bitgo/sdk-coin-ada'), + import('@bitgo/sdk-coin-dot'), + import('@bitgo/sdk-coin-tao'), + ]); + registerAda(sdk); + registerSui(sdk); + registerDot(sdk); + registerTao(sdk); + const coin = baseCoin as unknown as Sui | Tsui | Ada | Tada | Dot | Tdot | Tao | Ttao; + return await coin.recoverConsolidations(params as MPCConsolidationRecoveryOptions); + } + } +} + +// Handler for recovery from receive addresses (consolidation sweeps) +export async function handleRecoveryConsolidationsOnPrem( + req: MasterApiSpecRouteRequest<'v1.wallet.recoveryConsolidations', 'post'>, +) { + const bitgo = req.bitgo; + const coin = req.decoded.coin; + const enclavedExpressClient = req.enclavedExpressClient; + + const isMPC = req.decoded.multisigType === 'tss'; + + const { commonKeychain, apiKey = '' } = req.decoded; + let { userPub, backupPub, bitgoPub } = req.decoded; + + if (isMPC) { + if (!commonKeychain) { + throw new Error('Missing required key: commonKeychain'); + } + + userPub = commonKeychain; + backupPub = commonKeychain; + bitgoPub = commonKeychain; + } + + if (!userPub || !backupPub || !bitgoPub) { + throw new Error('Missing required keys: userPub, backupPub, bitgoPub'); + } + + const sdkCoin = bitgo.coin(coin); + let txs: (RecoveryTransaction | MPCTx | RecoveryTxRequest)[] = []; + + // Use type assertion to access recoverConsolidations + const result = await recoveryConsolidateWallets(bitgo, sdkCoin, { + ...req.decoded, + userKey: !isMPC ? userPub : '', + backupKey: !isMPC ? backupPub : '', + bitgoKey: bitgoPub, + }); + + console.log(`Recovery consolidations result: ${JSON.stringify(result)}`); + + if (result.transactions) { + txs = result.transactions; + } else if (result.txRequests) { + txs = result.txRequests; + } else { + throw new Error('recoverConsolidations did not return expected transactions'); + } + + logger.debug(`Found ${txs.length} unsigned consolidation transactions`); + + const signedTxs = []; + try { + for (const tx of txs) { + const signedTx = isMPC + ? await enclavedExpressClient.recoveryMPC({ + userPub, + backupPub, + apiKey, + unsignedSweepPrebuildTx: tx as MPCTx | RecoveryTxRequest, + coinSpecificParams: {}, + walletContractAddress: '', + }) + : await enclavedExpressClient.recoveryMultisig({ + userPub, + backupPub, + unsignedSweepPrebuildTx: tx as RecoveryTransaction, + walletContractAddress: '', + }); + + signedTxs.push(signedTx); + } + + return { signedTxs }; + } catch (err) { + logger.error('Error during consolidation recovery:', err); + throw err; + } +} diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index bf1a211..54323ec 100644 --- a/src/api/master/routers/masterApiSpec.ts +++ b/src/api/master/routers/masterApiSpec.ts @@ -25,6 +25,7 @@ import { handleConsolidate } from '../handlers/handleConsolidate'; import { handleAccelerate } from '../handlers/handleAccelerate'; import { handleConsolidateUnspents } from '../handlers/handleConsolidateUnspents'; import { handleSignAndSendTxRequest } from '../handlers/handleSignAndSendTxRequest'; +import { handleRecoveryConsolidationsOnPrem } from '../handlers/recoveryConsolidationsWallet'; // Middleware functions export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -197,7 +198,32 @@ const RecoveryWalletRequest = { ), }; -export type RecoveryWalletRequest = typeof RecoveryWalletRequest; +const RecoveryConsolidationsWalletRequest = { + userPub: optional(t.string), + backupPub: optional(t.string), + bitgoPub: optional(t.string), + multisigType: t.union([t.literal('onchain'), t.literal('tss')]), + commonKeychain: optional(t.string), + tokenContractAddress: optional(t.string), + startingScanIndex: optional(t.number), + endingScanIndex: optional(t.number), + apiKey: optional(t.string), + durableNonces: optional( + t.type({ + secretKey: t.string, + publicKeys: t.array(t.string), + }), + ), +}; + +// Response type for /recoveryconsolidations endpoint +const RecoveryConsolidationsWalletResponse: HttpResponse = { + 200: t.any, + 500: t.type({ + error: t.string, + details: t.string, + }), +}; export const ConsolidateUnspentsRequest = { pubkey: t.string, @@ -304,6 +330,20 @@ export const MasterApiSpec = apiSpec({ description: 'Recover an existing wallet', }), }, + 'v1.wallet.recoveryConsolidations': { + post: httpRoute({ + method: 'POST', + path: '/api/{coin}/wallet/recoveryconsolidations', + request: httpRequest({ + params: { + coin: t.string, + }, + body: RecoveryConsolidationsWalletRequest, + }), + response: RecoveryConsolidationsWalletResponse, + description: 'Consolidate and recover an existing wallet', + }), + }, 'v1.wallet.consolidate': { post: httpRoute({ method: 'POST', @@ -409,6 +449,14 @@ export function createMasterApiRouter( }), ]); + router.post('v1.wallet.recoveryConsolidations', [ + responseHandler(async (req: express.Request) => { + const typedReq = req as GenericMasterApiSpecRouteRequest; + const result = await handleRecoveryConsolidationsOnPrem(typedReq); + return Response.ok(result); + }), + ]); + router.post('v1.wallet.accelerate', [ responseHandler(async (req: express.Request) => { const typedReq = req as GenericMasterApiSpecRouteRequest; diff --git a/src/shared/coinUtils.ts b/src/shared/coinUtils.ts index 935d748..93615e0 100644 --- a/src/shared/coinUtils.ts +++ b/src/shared/coinUtils.ts @@ -2,11 +2,7 @@ import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth'; import { BackupKeyRecoveryTransansaction, FormattedOfflineVaultTxInfo } from '@bitgo/abstract-utxo'; import { CoinFamily } from '@bitgo/statics'; import { BaseCoin } from 'bitgo'; -import { AbstractUtxoCoin, Eos, Sol, Stx, Xtz } from 'bitgo/dist/types/src/v2/coins'; - -export function isSolCoin(coin: BaseCoin): coin is Sol { - return isFamily(coin, CoinFamily.SOL); -} +import { AbstractUtxoCoin, Eos, Stx, Xtz } from 'bitgo/dist/types/src/v2/coins'; export function isEthLikeCoin(coin: BaseCoin): coin is AbstractEthLikeNewCoins { const isEthPure = isFamily(coin, CoinFamily.ETH);