diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 7cb4a8e..b95fe8c 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -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": [ @@ -142,9 +256,6 @@ "backup" ] }, - "walletPassphrase": { - "type": "string" - }, "feeRate": { "type": "number" }, @@ -463,6 +574,92 @@ } } }, + "/api/{coin}/wallet/{walletId}/txrequest/{txRequestId}/signAndSend": { + "post": { + "parameters": [ + { + "name": "walletId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "coin", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "txRequestId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "user", + "backup" + ] + }, + "commonKeychain": { + "type": "string" + } + }, + "required": [ + "source" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {} + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "details": { + "type": "string" + } + }, + "required": [ + "error", + "details" + ] + } + } + } + } + } + } + }, "/api/{coin}/wallet/generate": { "post": { "parameters": [ @@ -503,6 +700,7 @@ }, "required": [ "label", + "multisigType", "enterprise" ] } diff --git a/src/__tests__/api/master/signAndSendTxRequest.test.ts b/src/__tests__/api/master/signAndSendTxRequest.test.ts new file mode 100644 index 0000000..0351850 --- /dev/null +++ b/src/__tests__/api/master/signAndSendTxRequest.test.ts @@ -0,0 +1,362 @@ +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, + TxRequest, + PendingApprovals, + State, + Type, + PendingApproval, + BitGoBase, + IBaseCoin, +} from '@bitgo/sdk-core'; +import { BitGo } from 'bitgo'; +import * as mpcv2 from '../../../api/master/handlers/ecdsaMPCv2'; +import * as eddsa from '../../../api/master/handlers/eddsa'; + +describe('POST /api/:coin/wallet/:walletId/txrequest/:txRequestId/signAndSend', () => { + let agent: request.SuperAgentTest; + let bitgo: BitGoBase; + let baseCoin: IBaseCoin; + let wallet: Wallet; + const enclavedExpressUrl = 'http://enclaved.invalid'; + const bitgoApiUrl = Environments.test.uri; + const accessToken = 'test-token'; + const walletId = 'test-wallet-id'; + const txRequestId = 'test-tx-request-id'; + const coin = 'hteth'; // Use hteth for ECDSA testing + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + bitgo = new BitGo({ env: 'local' }); + baseCoin = bitgo.coin(coin); + wallet = new Wallet(bitgo, baseCoin, walletId); + + const config: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, // Let OS assign a free port + bind: 'localhost', + timeout: 60000, + logFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + enclavedExpressUrl: enclavedExpressUrl, + enclavedExpressCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + mtlsRequestCert: false, + allowSelfSigned: true, + }; + + const app = expressApp(config); + agent = request.agent(app); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + describe('ECDSA MPCv2 Sign and Send:', () => { + it('should successfully sign and send ECDSA MPCv2 transaction with user key', 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'], + multisigType: 'tss', + coin: 'hteth', + }); + + // Mock keychain get request for user key + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'user-key-id', + pub: 'xpub_user', + commonKeychain: 'common-keychain-123', + source: 'user', + }); + + // Mock getTxRequest + const txRequest: TxRequest = { + txRequestId, + apiVersion: 'full', + enterpriseId: 'test-enterprise-id', + transactions: [ + { + unsignedTx: { + derivationPath: 'm/0', + signableHex: 'testMessage', + serializedTxHex: 'testMessage', + }, + state: 'pendingSignature', + signatureShares: [], + }, + ], + state: 'pendingUserSignature', + walletId, + walletType: 'hot', + version: 2, + date: new Date().toISOString(), + userId: 'test-user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + }; + + const getTxRequestNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .matchHeader('any', () => true) + .reply(200, { + txRequests: [txRequest], + }); + + // Replace the imported function with our stub + const signAndSendStub = sinon.stub(mpcv2, 'signAndSendEcdsaMPCv2FromTxRequest').resolves({ + ...txRequest, + state: 'signed', + transactions: [ + { + ...(txRequest.transactions || [])[0], + signedTx: { + id: 'test-tx-id', + tx: 'signed-transaction-hex', + }, + }, + ], + }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/txrequest/${txRequestId}/signAndSend`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'common-keychain-123', + }); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'test-tx-id'); + response.body.should.have.property('tx', 'signed-transaction-hex'); + + walletGetNock.done(); + keychainGetNock.done(); + getTxRequestNock.done(); + sinon.assert.calledOnce(signAndSendStub); + + sinon.restore(); + }); + + it('should handle pending approval response', 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'], + multisigType: 'tss', + coin: 'hteth', + }); + + // 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', + commonKeychain: 'common-keychain-123', + source: 'user', + }); + + // Mock getTxRequest + const txRequest: TxRequest = { + txRequestId, + apiVersion: 'full', + enterpriseId: 'test-enterprise-id', + transactions: [], + state: 'pendingUserSignature', + walletId, + walletType: 'hot', + version: 2, + date: new Date().toISOString(), + userId: 'test-user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + }; + + const getTxRequestNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .matchHeader('any', () => true) + .reply(200, { + txRequests: [txRequest], + }); + + const signAndSendStub = sinon.stub(mpcv2, 'signAndSendEcdsaMPCv2FromTxRequest').resolves({ + ...txRequest, + state: 'pendingApproval', + pendingApprovalId: 'pending-approval-id', + }); + + const pendingApprovalData = { + id: 'pending-approval-id', + wallet: 'test-wallet-id', + state: 'pending' as State, + creator: 'test-user-id', + info: { + type: 'transactionRequestFull' as Type, + transactionRequestFull: { + ...txRequest, + }, + }, + }; + + const mockPendingApproval = new PendingApproval(bitgo, baseCoin, pendingApprovalData, wallet); + + sinon.stub(PendingApprovals.prototype, 'get').resolves(mockPendingApproval); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/txrequest/${txRequestId}/signAndSend`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'common-keychain-123', + }); + + response.status.should.equal(200); + response.body.should.have.property('pendingApproval'); + response.body.should.have.property('txRequest'); + response.body.pendingApproval.should.have.property('id', 'pending-approval-id'); + + walletGetNock.done(); + keychainGetNock.done(); + getTxRequestNock.done(); + sinon.assert.calledOnce(signAndSendStub); + + sinon.restore(); + }); + }); + + describe('EdDSA Sign and Send:', () => { + const eddsaCoin = 'tsol'; // Use tsol for EdDSA testing + + it('should successfully sign and send EdDSA transaction', async () => { + // Mock wallet get request + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${eddsaCoin}/wallet/${walletId}`) + .matchHeader('any', () => true) + .reply(200, { + id: walletId, + type: 'cold', + subType: 'onPrem', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + coin: 'tsol', + }); + + // Mock keychain get request + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${eddsaCoin}/key/user-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'user-key-id', + pub: 'xpub_user', + commonKeychain: 'common-keychain-123', + source: 'user', + }); + + // Mock getTxRequest + const txRequest: TxRequest = { + txRequestId, + apiVersion: 'full', + enterpriseId: 'test-enterprise-id', + transactions: [ + { + unsignedTx: { + derivationPath: 'm/0', + signableHex: 'testMessage', + serializedTxHex: 'testMessage', + }, + state: 'pendingSignature', + signatureShares: [], + }, + ], + state: 'pendingUserSignature', + walletId, + walletType: 'hot', + version: 2, + date: new Date().toISOString(), + userId: 'test-user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + }; + + const getTxRequestNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .matchHeader('any', () => true) + .reply(200, { + txRequests: [txRequest], + }); + + const signAndSendStub = sinon.stub(eddsa, 'handleEddsaSigning').resolves({ + ...txRequest, + state: 'signed', + transactions: [ + { + ...(txRequest.transactions || [])[0], + signedTx: { + id: 'test-tx-id', + tx: 'signed-transaction-hex', + }, + }, + ], + }); + + const response = await agent + .post(`/api/${eddsaCoin}/wallet/${walletId}/txrequest/${txRequestId}/signAndSend`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + commonKeychain: 'common-keychain-123', + }); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'test-tx-id'); + response.body.should.have.property('tx', 'signed-transaction-hex'); + + walletGetNock.done(); + keychainGetNock.done(); + getTxRequestNock.done(); + sinon.assert.calledOnce(signAndSendStub); + + sinon.restore(); + }); + }); +}); diff --git a/src/api/master/handlers/handleSignAndSendTxRequest.ts b/src/api/master/handlers/handleSignAndSendTxRequest.ts new file mode 100644 index 0000000..cbfa674 --- /dev/null +++ b/src/api/master/handlers/handleSignAndSendTxRequest.ts @@ -0,0 +1,62 @@ +import { getTxRequest, KeyIndices, RequestTracer } from '@bitgo/sdk-core'; +import logger from '../../../logger'; +import { signAndSendTxRequests } from './transactionRequests'; +import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; + +export async function handleSignAndSendTxRequest( + req: MasterApiSpecRouteRequest<'v1.wallet.txrequest.signAndSend', '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`); + } + + if (wallet.type() !== 'cold' || wallet.subType() !== 'onPrem') { + throw new Error('Wallet is not an on-prem wallet'); + } + + const keyIdIndex = params.source === 'user' ? KeyIndices.USER : KeyIndices.BACKUP; + logger.info(`Key ID index: ${keyIdIndex}`); + logger.info(`Key IDs: ${JSON.stringify(wallet.keyIds(), null, 2)}`); + + // Get the signing keychain + const signingKeychain = await baseCoin.keychains().get({ + id: wallet.keyIds()[keyIdIndex], + }); + + if (!signingKeychain) { + throw new Error(`Signing keychain for ${params.source} not found`); + } + if (params.commonKeychain && signingKeychain.commonKeychain !== params.commonKeychain) { + throw new Error( + `Common keychain provided does not match the keychain on wallet for ${params.source}`, + ); + } + + logger.debug(`Signing keychain: ${JSON.stringify(signingKeychain, null, 2)}`); + logger.debug(`Params: ${JSON.stringify(params, null, 2)}`); + + const txRequest = await getTxRequest(bitgo, wallet.id(), req.params.txRequestId, reqId); + if (!txRequest) { + throw new Error(`TxRequest ${req.params.txRequestId} not found`); + } + + logger.debug(`TxRequest: ${JSON.stringify(txRequest, null, 2)}`); + + return signAndSendTxRequests( + bitgo, + wallet, + txRequest, + enclavedExpressClient, + signingKeychain, + reqId, + ); +} diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index a9548e5..c3c3999 100644 --- a/src/api/master/routers/masterApiSpec.ts +++ b/src/api/master/routers/masterApiSpec.ts @@ -24,6 +24,7 @@ import { handleRecoveryWalletOnPrem } from '../handlers/recoveryWallet'; import { handleConsolidate } from '../handlers/handleConsolidate'; import { handleAccelerate } from '../handlers/handleAccelerate'; import { handleConsolidateUnspents } from '../handlers/handleConsolidateUnspents'; +import { handleSignAndSendTxRequest } from '../handlers/handleSignAndSendTxRequest'; // Middleware functions export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -218,6 +219,19 @@ const ConsolidateUnspentsResponse: HttpResponse = { }), }; +const SignMpcRequest = { + source: t.union([t.literal('user'), t.literal('backup')]), + commonKeychain: t.union([t.undefined, t.string]), +}; + +const SignMpcResponse: HttpResponse = { + 200: t.any, + 500: t.type({ + error: t.string, + details: t.string, + }), +}; + // API Specification export const MasterApiSpec = apiSpec({ 'v1.wallet.generate': { @@ -249,6 +263,22 @@ export const MasterApiSpec = apiSpec({ description: 'Send many transactions', }), }, + 'v1.wallet.txrequest.signAndSend': { + post: httpRoute({ + method: 'POST', + path: '/api/{coin}/wallet/{walletId}/txrequest/{txRequestId}/signAndSend', + request: httpRequest({ + params: { + walletId: t.string, + coin: t.string, + txRequestId: t.string, + }, + body: SignMpcRequest, + }), + response: SignMpcResponse, + description: 'Sign MPC with TxRequest', + }), + }, 'v1.wallet.recovery': { post: httpRoute({ method: 'POST', @@ -384,5 +414,13 @@ export function createMasterApiRouter( }), ]); + router.post('v1.wallet.txrequest.signAndSend', [ + responseHandler(async (req: express.Request) => { + const typedReq = req as GenericMasterApiSpecRouteRequest; + const result = await handleSignAndSendTxRequest(typedReq); + return Response.ok(result); + }), + ]); + return router; }