diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 19dfad22..7cb4a8e2 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -73,14 +73,171 @@ } } }, - "202": { - "description": "Accepted", + "400": { + "description": "Bad Request", "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/{walletId}/consolidateunspents": { + "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" + ] + }, + "walletPassphrase": { + "type": "string" + }, + "feeRate": { + "type": "number" + }, + "maxFeeRate": { + "type": "number" + }, + "maxFeePercentage": { + "type": "number" + }, + "feeTxConfirmTarget": { + "type": "number" + }, + "bulk": { + "type": "boolean" + }, + "minValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "maxValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "minHeight": { + "type": "number" + }, + "minConfirms": { + "type": "number" + }, + "enforceMinConfirmsForChange": { + "type": "boolean" + }, + "limit": { + "type": "number" + }, + "numUnspentsToMake": { + "type": "number" + }, + "targetAddress": { + "type": "string" + }, + "txFormat": { + "type": "string", + "enum": [ + "legacy", + "psbt", + "psbt-lite" + ] + } + }, + "required": [ + "pubkey", + "source" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tx": { + "type": "string" + }, + "txid": { + "type": "string" + } + }, + "required": [ + "tx", + "txid" + ] + } + } + } + }, "400": { "description": "Bad Request", "content": { diff --git a/package.json b/package.json index c0020739..be2842f9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:watch": "mocha --require ts-node/register --watch 'src/**/__tests__/**/*.test.ts'", "test:coverage": "nyc mocha --require ts-node/register 'src/**/__tests__/**/*.test.ts'", "lint": "eslint --quiet .", + "lint:fix": "eslint --quiet . --fix", "generate-test-ssl": "openssl req -x509 -newkey rsa:2048 -keyout test-ssl-key.pem -out test-ssl-cert.pem -days 365 -nodes -subj '/CN=localhost'", "generate:openapi:masterExpress": "npx @api-ts/openapi-generator --name @bitgo/master-bitgo-express ./src/api/master/routers/index.ts > masterBitgoExpress.json", "container:build": "podman build -t bitgo-onprem-express ." diff --git a/src/__tests__/api/master/consolidateUnspents.test.ts b/src/__tests__/api/master/consolidateUnspents.test.ts new file mode 100644 index 00000000..3665c53e --- /dev/null +++ b/src/__tests__/api/master/consolidateUnspents.test.ts @@ -0,0 +1,192 @@ +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'; + +describe('POST /api/:coin/wallet/:walletId/consolidateunspents', () => { + let agent: request.SuperAgentTest; + const coin = 'btc'; + 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 return transfer, txid, tx, and status on success', async () => { + 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'], + }); + + 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 mockResult = { + transfer: { + entries: [ + { address: 'tb1qu...', value: -4000 }, + { address: 'tb1qle...', value: -4000 }, + { address: 'tb1qtw...', value: 2714, isChange: true }, + ], + id: '685ac2f3c2f8a2a5d9cc18d3593f1751', + coin: 'tbtc', + wallet: '685abbf19ca95b79f88e0b41d9337109', + txid: '239d143cdfc6d6c83a935da4f3d610b2364a956c7b6dcdc165eb706f62c4432a', + status: 'signed', + }, + txid: '239d143cdfc6d6c83a935da4f3d610b2364a956c7b6dcdc165eb706f62c4432a', + tx: '01000000000102580b...', + status: 'signed', + }; + + const consolidateUnspentsStub = sinon + .stub(Wallet.prototype, 'consolidateUnspents') + .resolves(mockResult); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + feeRate: 1000, + }); + + response.status.should.equal(200); + response.body.should.have.property('transfer'); + response.body.should.have.property('txid', mockResult.txid); + response.body.should.have.property('tx', mockResult.tx); + response.body.should.have.property('status', mockResult.status); + response.body.transfer.should.have.property('txid', mockResult.transfer.txid); + response.body.transfer.should.have.property('status', mockResult.transfer.status); + response.body.transfer.should.have.property('entries').which.is.Array(); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(consolidateUnspentsStub); + }); + + it('should return error, name, and details on failure', async () => { + 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'], + }); + + 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 mockError = { + error: 'Internal Server Error', + name: 'ApiResponseError', + details: + 'There are too few unspents that meet the given parameters to consolidate (1 available).', + }; + + const consolidateUnspentsStub = sinon + .stub(Wallet.prototype, 'consolidateUnspents') + .throws(Object.assign(new Error(mockError.details), mockError)); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + feeRate: 1000, + }); + + response.status.should.equal(500); + response.body.should.have.property('error', mockError.error); + response.body.should.have.property('name', mockError.name); + response.body.should.have.property('details', mockError.details); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(consolidateUnspentsStub); + }); + + it('should throw error when provided pubkey does not match wallet keychain', async () => { + 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'], + }); + + 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}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'wrong_pubkey', + feeRate: 1000, + }); + + response.status.should.equal(500); + + walletGetNock.done(); + keychainGetNock.done(); + }); +}); diff --git a/src/api/master/handlerUtils.ts b/src/api/master/handlerUtils.ts new file mode 100644 index 00000000..9c297f57 --- /dev/null +++ b/src/api/master/handlerUtils.ts @@ -0,0 +1,67 @@ +import { BitGo, CustomSigningFunction, RequestTracer } from 'bitgo'; +import { EnclavedExpressClient } from './clients/enclavedExpressClient'; + +/** + * Fetch wallet and signing keychain, with validation for source and pubkey. + * Throws with a clear error if not found or mismatched. + */ + +export async function getWalletAndSigningKeychain({ + bitgo, + coin, + walletId, + params, + reqId, + KeyIndices, +}: { + bitgo: BitGo; + coin: string; + walletId: string; + params: { source: 'user' | 'backup'; pubkey?: string }; + reqId: RequestTracer; + KeyIndices: { USER: number; BACKUP: number; BITGO: number }; +}) { + const baseCoin = bitgo.coin(coin); + + const wallet = await baseCoin.wallets().get({ id: walletId, reqId }); + + if (!wallet) { + throw new Error(`Wallet ${walletId} not found`); + } + + 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}`); + } + + return { baseCoin, wallet, signingKeychain }; +} +/** + * Create a custom signing function that delegates to enclavedExpressClient.signMultisig. + */ + +export function makeCustomSigningFunction({ + enclavedExpressClient, + source, + pub, +}: { + enclavedExpressClient: EnclavedExpressClient; + source: 'user' | 'backup'; + pub: string; +}): CustomSigningFunction { + return async function customSigningFunction(signParams: any) { + return enclavedExpressClient.signMultisig({ + txPrebuild: signParams.txPrebuild, + source, + pub, + }); + }; +} diff --git a/src/api/master/handlers/handleAccelerate.ts b/src/api/master/handlers/handleAccelerate.ts index 49ff66a9..ec8d6f69 100644 --- a/src/api/master/handlers/handleAccelerate.ts +++ b/src/api/master/handlers/handleAccelerate.ts @@ -1,7 +1,7 @@ import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; import logger from '../../../logger'; import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; -import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../../../shared/coinUtils'; +import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../handlerUtils'; export async function handleAccelerate( req: MasterApiSpecRouteRequest<'v1.wallet.accelerate', 'post'>, diff --git a/src/api/master/handlers/handleConsolidate.ts b/src/api/master/handlers/handleConsolidate.ts index 9d633622..30940d4d 100644 --- a/src/api/master/handlers/handleConsolidate.ts +++ b/src/api/master/handlers/handleConsolidate.ts @@ -1,7 +1,7 @@ import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; import logger from '../../../logger'; import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; -import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../../../shared/coinUtils'; +import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../handlerUtils'; export async function handleConsolidate( req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>, diff --git a/src/api/master/handlers/handleConsolidateUnspents.ts b/src/api/master/handlers/handleConsolidateUnspents.ts new file mode 100644 index 00000000..e2011629 --- /dev/null +++ b/src/api/master/handlers/handleConsolidateUnspents.ts @@ -0,0 +1,48 @@ +import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; +import logger from '../../../logger'; +import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; +import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../handlerUtils'; + +export async function handleConsolidateUnspents( + req: MasterApiSpecRouteRequest<'v1.wallet.consolidateunspents', 'post'>, +) { + const enclavedExpressClient = req.enclavedExpressClient; + const reqId = new RequestTracer(); + const bitgo = req.bitgo; + const params = req.decoded; + const walletId = req.params.walletId; + const coin = req.params.coin; + + const { wallet, signingKeychain } = await getWalletAndSigningKeychain({ + bitgo, + coin, + walletId, + params, + reqId, + KeyIndices, + }); + + try { + // Create custom signing function that delegates to EBE + const customSigningFunction = makeCustomSigningFunction({ + enclavedExpressClient, + source: params.source, + pub: signingKeychain.pub!, + }); + + // Prepare consolidation parameters + const consolidationParams = { + ...params, + customSigningFunction, + reqId, + }; + + // Send consolidate unspents + const result = await wallet.consolidateUnspents(consolidationParams); + return result; + } catch (error) { + const err = error as Error; + logger.error('Failed to consolidate unspents: %s', err.message); + throw err; + } +} diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index c0946ab1..3a517e36 100644 --- a/src/api/master/routers/masterApiSpec.ts +++ b/src/api/master/routers/masterApiSpec.ts @@ -23,6 +23,7 @@ import { validateMasterExpressConfig } from '../middleware/middleware'; import { handleRecoveryWalletOnPrem } from '../handlers/recoveryWallet'; import { handleConsolidate } from '../handlers/handleConsolidate'; import { handleAccelerate } from '../handlers/handleAccelerate'; +import { handleConsolidateUnspents } from '../handlers/handleConsolidateUnspents'; // Middleware functions export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -113,9 +114,7 @@ export const ConsolidateRequest = { // Response type for /consolidate endpoint const ConsolidateResponse: HttpResponse = { - // TODO: Get type from public types repo / Wallet Platform 200: t.any, - 202: t.any, // Partial success 400: t.any, // All failed 500: t.type({ error: t.string, @@ -188,6 +187,37 @@ const RecoveryWalletRequest = { export type RecoveryWalletRequest = typeof RecoveryWalletRequest; +export const ConsolidateUnspentsRequest = { + pubkey: t.string, + source: t.union([t.literal('user'), t.literal('backup')]), + feeRate: t.union([t.undefined, t.number]), + maxFeeRate: t.union([t.undefined, t.number]), + maxFeePercentage: t.union([t.undefined, t.number]), + feeTxConfirmTarget: t.union([t.undefined, t.number]), + bulk: t.union([t.undefined, t.boolean]), + minValue: t.union([t.undefined, t.union([t.string, t.number])]), + maxValue: t.union([t.undefined, t.union([t.string, t.number])]), + minHeight: t.union([t.undefined, t.number]), + minConfirms: t.union([t.undefined, t.number]), + enforceMinConfirmsForChange: t.union([t.undefined, t.boolean]), + limit: t.union([t.undefined, t.number]), + numUnspentsToMake: t.union([t.undefined, t.number]), + targetAddress: t.union([t.undefined, t.string]), + txFormat: t.union([t.undefined, t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')]), +}; + +const ConsolidateUnspentsResponse: HttpResponse = { + 200: t.type({ + tx: t.string, + txid: t.string, + }), + 400: t.any, + 500: t.type({ + error: t.string, + details: t.string, + }), +}; + // API Specification export const MasterApiSpec = apiSpec({ 'v1.wallet.generate': { @@ -263,6 +293,21 @@ export const MasterApiSpec = apiSpec({ description: 'Accelerate transaction', }), }, + 'v1.wallet.consolidateunspents': { + post: httpRoute({ + method: 'POST', + path: '/api/{coin}/wallet/{walletId}/consolidateunspents', + request: httpRequest({ + params: { + walletId: t.string, + coin: t.string, + }, + body: ConsolidateUnspentsRequest, + }), + response: ConsolidateUnspentsResponse, + description: 'Consolidate unspents', + }), + }, }); export type MasterApiSpec = typeof MasterApiSpec; @@ -331,5 +376,13 @@ export function createMasterApiRouter( }), ]); + router.post('v1.wallet.consolidateunspents', [ + responseHandler(async (req: express.Request) => { + const typedReq = req as GenericMasterApiSpecRouteRequest; + const result = await handleConsolidateUnspents(typedReq); + return Response.ok(result); + }), + ]); + return router; } diff --git a/src/shared/coinUtils.ts b/src/shared/coinUtils.ts index d42c1867..54d72545 100644 --- a/src/shared/coinUtils.ts +++ b/src/shared/coinUtils.ts @@ -1,10 +1,8 @@ import { FormattedOfflineVaultTxInfo, BackupKeyRecoveryTransansaction } from '@bitgo/abstract-utxo'; import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth'; import { CoinFamily } from '@bitgo/statics'; -import { BaseCoin, BitGo } from 'bitgo'; +import { BaseCoin } from 'bitgo'; import { AbstractUtxoCoin, Eos, Stx, Xtz } from 'bitgo/dist/types/src/v2/coins'; -import { RequestTracer } from '@bitgo/sdk-core'; -import { EnclavedExpressClient } from '../api/master/clients/enclavedExpressClient'; export function isEthLikeCoin(coin: BaseCoin): coin is AbstractEthLikeNewCoins { const isEthPure = isFamily(coin, CoinFamily.ETH); @@ -63,67 +61,3 @@ export function isFormattedOfflineVaultTxInfo( ): obj is FormattedOfflineVaultTxInfo { return obj && 'txInfo' in obj && 'txHex' in obj && 'feeInfo' in obj; } - -/** - * Fetch wallet and signing keychain, with validation for source and pubkey. - * Throws with a clear error if not found or mismatched. - */ -export async function getWalletAndSigningKeychain({ - bitgo, - coin, - walletId, - params, - reqId, - KeyIndices, -}: { - bitgo: BitGo; - coin: string; - walletId: string; - params: { source: 'user' | 'backup'; pubkey?: string }; - reqId: RequestTracer; - KeyIndices: { USER: number; BACKUP: number; BITGO: number }; -}) { - const baseCoin = bitgo.coin(coin); - - const wallet = await baseCoin.wallets().get({ id: walletId, reqId }); - - if (!wallet) { - throw new Error(`Wallet ${walletId} not found`); - } - - 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}`); - } - - return { baseCoin, wallet, signingKeychain }; -} - -/** - * Create a custom signing function that delegates to enclavedExpressClient.signMultisig. - */ -export function makeCustomSigningFunction({ - enclavedExpressClient, - source, - pub, -}: { - enclavedExpressClient: EnclavedExpressClient; - source: 'user' | 'backup'; - pub: string; -}) { - return async function customSigningFunction(signParams: any) { - return enclavedExpressClient.signMultisig({ - txPrebuild: signParams.txPrebuild, - source, - pub, - }); - }; -}