Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions src/__tests__/api/master/consolidate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
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 { Environments, Wallet } from '@bitgo/sdk-core';
import { Hteth } from '@bitgo/sdk-coin-eth';

describe('POST /api/:coin/wallet/:walletId/consolidate', () => {
let agent: request.SuperAgentTest;
const coin = 'hteth';
const walletId = 'test-wallet-id';
const accessToken = 'test-access-token';
const bitgoApiUrl = Environments.test.uri;
const enclavedExpressUrl = 'https://test-enclaved-express.com';

before(() => {
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1');

const config: MasterExpressConfig = {
appMode: AppMode.MASTER_EXPRESS,
port: 0, // Let OS assign a free port
bind: 'localhost',
timeout: 30000,
logFile: '',
env: 'test',
disableEnvCheck: true,
authVersion: 2,
enclavedExpressUrl: enclavedExpressUrl,
enclavedExpressCert: 'test-cert',
tlsMode: TlsMode.DISABLED,
mtlsRequestCert: false,
allowSelfSigned: true,
};

const app = expressApp(config);
agent = request.agent(app);
});

afterEach(() => {
nock.cleanAll();
sinon.restore();
});

it('should consolidate account addresses by calling the enclaved express service', async () => {
// Mock wallet get request
const walletGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/wallet/${walletId}`)
.matchHeader('any', () => true)
.reply(200, {
id: walletId,
type: 'cold',
subType: 'onPrem',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
});

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/key/user-key-id`)
.matchHeader('any', () => true)
.reply(200, {
id: 'user-key-id',
pub: 'xpub_user',
});

// Mock sendAccountConsolidations
const sendConsolidationsStub = sinon
.stub(Wallet.prototype, 'sendAccountConsolidations')
.resolves({
success: [
{
txid: 'consolidation-tx-1',
status: 'signed',
},
],
failure: [],
});

const response = await agent
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
source: 'user',
pubkey: 'xpub_user',
consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'],
});

response.status.should.equal(200);
response.body.should.have.property('success');
response.body.success.should.have.length(1);
response.body.success[0].should.have.property('txid', 'consolidation-tx-1');

walletGetNock.done();
keychainGetNock.done();
sinon.assert.calledOnce(sendConsolidationsStub);
});

it('should handle partial consolidation failures', async () => {
// Mock wallet get request
const walletGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/wallet/${walletId}`)
.matchHeader('any', () => true)
.reply(200, {
id: walletId,
type: 'cold',
subType: 'onPrem',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
});

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/key/user-key-id`)
.matchHeader('any', () => true)
.reply(200, {
id: 'user-key-id',
pub: 'xpub_user',
});

// Mock sendAccountConsolidations with partial failures
const sendConsolidationsStub = sinon
.stub(Wallet.prototype, 'sendAccountConsolidations')
.resolves({
success: [
{
txid: 'consolidation-tx-1',
status: 'signed',
},
],
failure: [
{
error: 'Insufficient funds',
address: '0xfedcba0987654321',
},
],
});

const response = await agent
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
source: 'user',
pubkey: 'xpub_user',
consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'],
});

response.status.should.equal(500);
response.body.should.have.property('error', 'Internal Server Error');
response.body.should.have
.property('details')
.which.match(/Consolidations failed: 1 and succeeded: 1/);

walletGetNock.done();
keychainGetNock.done();
sinon.assert.calledOnce(sendConsolidationsStub);
});

it('should throw error when all consolidations fail', async () => {
// Mock wallet get request
const walletGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/wallet/${walletId}`)
.matchHeader('any', () => true)
.reply(200, {
id: walletId,
type: 'cold',
subType: 'onPrem',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
});

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/key/user-key-id`)
.matchHeader('any', () => true)
.reply(200, {
id: 'user-key-id',
pub: 'xpub_user',
});

// Mock sendAccountConsolidations with all failures
const sendConsolidationsStub = sinon
.stub(Wallet.prototype, 'sendAccountConsolidations')
.resolves({
success: [],
failure: [
{
error: 'All consolidations failed',
address: '0x1234567890abcdef',
},
{
error: 'All consolidations failed',
address: '0xfedcba0987654321',
},
],
});

const response = await agent
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
source: 'user',
pubkey: 'xpub_user',
consolidateAddresses: ['0x1234567890abcdef', '0xfedcba0987654321'],
});

response.status.should.equal(500);
response.body.should.have.property('error');
response.body.should.have.property('details').which.match(/All consolidations failed/);

walletGetNock.done();
keychainGetNock.done();
sinon.assert.calledOnce(sendConsolidationsStub);
});

it('should throw error when coin does not support account consolidations', async () => {
// Mock wallet get request
const walletGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/wallet/${walletId}`)
.matchHeader('any', () => true)
.reply(200, {
id: walletId,
type: 'cold',
subType: 'onPrem',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
});

// Mock allowsAccountConsolidations to return false
const allowsConsolidationsStub = sinon
.stub(Hteth.prototype, 'allowsAccountConsolidations')
.returns(false);

const response = await agent
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
source: 'user',
pubkey: 'xpub_user',
});

response.status.should.equal(500);

walletGetNock.done();
sinon.assert.calledOnce(allowsConsolidationsStub);
});

it('should throw error when provided pubkey does not match wallet keychain', async () => {
// Mock wallet get request
const walletGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/wallet/${walletId}`)
.matchHeader('any', () => true)
.reply(200, {
id: walletId,
type: 'cold',
subType: 'onPrem',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
});

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/key/user-key-id`)
.matchHeader('any', () => true)
.reply(200, {
id: 'user-key-id',
pub: 'xpub_user',
});

const response = await agent
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
source: 'user',
pubkey: 'wrong_pubkey',
});

response.status.should.equal(500);

walletGetNock.done();
keychainGetNock.done();
});
});
92 changes: 92 additions & 0 deletions src/api/master/handlers/handleConsolidate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { RequestTracer, KeyIndices } from '@bitgo/sdk-core';
import logger from '../../../logger';
import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec';

export async function handleConsolidate(
req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>,
) {
const enclavedExpressClient = req.enclavedExpressClient;
const reqId = new RequestTracer();
const bitgo = req.bitgo;
const baseCoin = bitgo.coin(req.params.coin);
const params = req.decoded;
const walletId = req.params.walletId;
const wallet = await baseCoin.wallets().get({ id: walletId, reqId });

if (!wallet) {
throw new Error(`Wallet ${walletId} not found`);
}

// Check if the coin supports account consolidations
if (!baseCoin.allowsAccountConsolidations()) {
throw new Error('Invalid coin selected - account consolidations not supported');
}

// Validate consolidateAddresses parameter
if (params.consolidateAddresses && !Array.isArray(params.consolidateAddresses)) {
throw new Error('consolidateAddresses must be an array of addresses');
}

// Get the signing keychain based on source
const keyIdIndex = params.source === 'user' ? KeyIndices.USER : KeyIndices.BACKUP;
const signingKeychain = await baseCoin.keychains().get({
id: wallet.keyIds()[keyIdIndex],
});

if (!signingKeychain || !signingKeychain.pub) {
throw new Error(`Signing keychain for ${params.source} not found`);
}

if (params.pubkey && params.pubkey !== signingKeychain.pub) {
throw new Error(`Pub provided does not match the keychain on wallet for ${params.source}`);
}

try {
// Create custom signing function that delegates to EBE
const customSigningFunction = async (signParams: any) => {
const signedTx = await enclavedExpressClient.signMultisig({
txPrebuild: signParams.txPrebuild,
source: params.source,
pub: signingKeychain.pub!,
});
return signedTx;
};

// Prepare consolidation parameters
const consolidationParams = {
...params,
customSigningFunction,
reqId,
};

// Send account consolidations
const result = await wallet.sendAccountConsolidations(consolidationParams);

// Handle failures
if (result.failure && result.failure.length > 0) {
logger.debug('Consolidation result: %s', JSON.stringify(result, null, 2));
let msg = '';
let status = 202;

if (result.success && result.success.length > 0) {
// Some succeeded, some failed
msg = `Consolidations failed: ${result.failure.length} and succeeded: ${result.success.length}`;
} else {
// All failed
status = 400;
msg = 'All consolidations failed';
}

const error = new Error(msg);
(error as any).status = status;
(error as any).result = result;
throw error;
}

return result;
} catch (error) {
const err = error as Error;
logger.error('Failed to consolidate account: %s', err.message);
throw err;
}
}
Loading