Skip to content
Closed
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
120 changes: 117 additions & 3 deletions masterBitgoExpress.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,120 @@
"description": "BitGo Enclaved Express - Secure enclave for BitGo signing operations with mTLS"
},
"paths": {
"/api/{coin}/wallet/{walletId}/accelerate": {
"post": {
"parameters": [
{
"name": "walletId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "coin",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"pubkey": {
"type": "string"
},
"source": {
"type": "string",
"enum": [
"user",
"backup"
]
},
"cpfpTxIds": {
"type": "array",
"items": {
"type": "string"
}
},
"cpfpFeeRate": {
"type": "number"
},
"maxFee": {
"type": "number"
},
"rbfTxIds": {
"type": "array",
"items": {
"type": "string"
}
},
"feeMultiplier": {
"type": "number"
}
},
"required": [
"pubkey",
"source"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"txid": {
"type": "string"
},
"tx": {
"type": "string"
}
},
"required": [
"txid",
"tx"
]
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"details": {
"type": "string"
}
},
"required": [
"error",
"details"
]
}
}
}
}
}
}
},
"/api/{coin}/wallet/{walletId}/consolidate": {
"post": {
"parameters": [
Expand Down Expand Up @@ -54,6 +168,9 @@
"full",
"lite"
]
},
"commonKeychain": {
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -142,9 +259,6 @@
"backup"
]
},
"walletPassphrase": {
"type": "string"
},
"feeRate": {
"type": "number"
},
Expand Down
212 changes: 212 additions & 0 deletions src/__tests__/api/master/consolidateMPC.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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 * as eddsa from '../../../api/master/handlers/eddsa';

describe('POST /api/:coin/wallet/:walletId/consolidate (EDDSA MPC)', () => {
let agent: request.SuperAgentTest;
const coin = 'tsol';
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,
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 using EDDSA MPC custom hooks', async () => {
// Mock wallet get request
const walletGetNock = nock(bitgoApiUrl)
.get(`/api/v2/${coin}/wallet/${walletId}`)
.reply(200, {
id: walletId,
type: 'cold',
subType: 'onPrem',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
multisigType: 'tss',
});

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl).get(`/api/v2/${coin}/key/user-key-id`).reply(200, {
id: 'user-key-id',
commonKeychain: 'pubkey',
});

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

// Spy on custom EDDSA hooks - these should return actual functions, not strings
const mockCommitmentFn = sinon.stub().resolves({ userToBitgoCommitment: 'commitment' });
const mockRShareFn = sinon.stub().resolves({ rShare: 'rshare' });
const mockGShareFn = sinon.stub().resolves({ gShare: 'gshare' });

const commitmentSpy = sinon
.stub(eddsa, 'createCustomCommitmentGenerator')
.returns(mockCommitmentFn);
const rshareSpy = sinon.stub(eddsa, 'createCustomRShareGenerator').returns(mockRShareFn);
const gshareSpy = sinon.stub(eddsa, 'createCustomGShareGenerator').returns(mockGShareFn);

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

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', 'mpc-txid-1');

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

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

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl).get(`/api/v2/${coin}/key/user-key-id`).reply(200, {
id: 'user-key-id',
commonKeychain: 'pubkey',
});

// Mock partial failure response
sinon.stub(Wallet.prototype, 'sendAccountConsolidations').resolves({
success: [{ txid: 'success-txid', status: 'signed' }],
failure: [{ error: 'Insufficient funds', address: '0xfailed' }],
});

// Mock EDDSA hooks
const mockCommitmentFn = sinon.stub().resolves({ userToBitgoCommitment: 'commitment' });
const mockRShareFn = sinon.stub().resolves({ rShare: 'rshare' });
const mockGShareFn = sinon.stub().resolves({ gShare: 'gshare' });

sinon.stub(eddsa, 'createCustomCommitmentGenerator').returns(mockCommitmentFn);
sinon.stub(eddsa, 'createCustomRShareGenerator').returns(mockRShareFn);
sinon.stub(eddsa, 'createCustomGShareGenerator').returns(mockGShareFn);

const response = await agent
.post(`/api/${coin}/wallet/${walletId}/consolidate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
source: 'user',
commonKeychain: 'pubkey',
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();
});

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

// Mock keychain get request
const keychainGetNock = nock(bitgoApiUrl).get(`/api/v2/${coin}/key/user-key-id`).reply(200, {
id: 'user-key-id',
commonKeychain: 'pubkey',
});

// Mock total failure response
sinon.stub(Wallet.prototype, 'sendAccountConsolidations').resolves({
success: [],
failure: [
{ error: 'Insufficient funds', address: '0xfailed1' },
{ error: 'Invalid address', address: '0xfailed2' },
],
});

// Mock EDDSA hooks
const mockCommitmentFn = sinon.stub().resolves({ userToBitgoCommitment: 'commitment' });
const mockRShareFn = sinon.stub().resolves({ rShare: 'rshare' });
const mockGShareFn = sinon.stub().resolves({ gShare: 'gshare' });

sinon.stub(eddsa, 'createCustomCommitmentGenerator').returns(mockCommitmentFn);
sinon.stub(eddsa, 'createCustomRShareGenerator').returns(mockRShareFn);
sinon.stub(eddsa, 'createCustomGShareGenerator').returns(mockGShareFn);

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

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();
});
});
10 changes: 8 additions & 2 deletions src/api/master/handlerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export async function getWalletAndSigningKeychain({
bitgo: BitGo;
coin: string;
walletId: string;
params: { source: 'user' | 'backup'; pubkey?: string };
params: { source: 'user' | 'backup'; pubkey?: string; commonKeychain?: string };
reqId: RequestTracer;
KeyIndices: { USER: number; BACKUP: number; BITGO: number };
}) {
Expand All @@ -34,14 +34,20 @@ export async function getWalletAndSigningKeychain({
id: wallet.keyIds()[keyIdIndex],
});

if (!signingKeychain || !signingKeychain.pub) {
if (!signingKeychain) {
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}`);
}

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

return { baseCoin, wallet, signingKeychain };
}
/**
Expand Down
Loading