diff --git a/src/__tests__/api/master/accelerate.test.ts b/src/__tests__/api/master/accelerate.test.ts new file mode 100644 index 00000000..fdd80aae --- /dev/null +++ b/src/__tests__/api/master/accelerate.test.ts @@ -0,0 +1,286 @@ +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/accelerate', () => { + let agent: request.SuperAgentTest; + const coin = 'tbtc'; + 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 accelerate transaction 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 accelerateTransaction + const accelerateTransactionStub = sinon + .stub(Wallet.prototype, 'accelerateTransaction') + .resolves({ + txid: 'accelerated-tx-id', + tx: 'accerated-transaction-hex', + status: 'signed', + }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + maxFee: 10000, + }); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'accelerated-tx-id'); + response.body.should.have.property('status', 'signed'); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(accelerateTransactionStub); + }); + + it('should handle acceleration with backup key signing', 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 for backup key + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/backup-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'backup-key-id', + pub: 'xpub_backup', + }); + + // Mock accelerateTransaction + const accelerateTransactionStub = sinon + .stub(Wallet.prototype, 'accelerateTransaction') + .resolves({ + txid: 'accelerated-tx-id', + status: 'signed', + tx: 'accelerated-transaction-hex', + }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'backup', + pubkey: 'xpub_backup', + rbfTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + feeMultiplier: 1.5, + }); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'accelerated-tx-id'); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(accelerateTransactionStub); + }); + + it('should throw error when wallet not found', async () => { + // Mock wallet get request to return 404 + const walletGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('any', () => true) + .reply(404, { error: 'Wallet not found' }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + + walletGetNock.done(); + }); + + it('should throw error when signing keychain not found', 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 to return 404 + const keychainGetNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('any', () => true) + .reply(404, { error: 'Keychain not found' }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + + walletGetNock.done(); + keychainGetNock.done(); + }); + + 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}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'wrong_pubkey', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + }); + + response.status.should.equal(500); + response.body.should.have.property('error'); + + walletGetNock.done(); + keychainGetNock.done(); + }); + + it('should handle acceleration with additional parameters', 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 accelerateTransaction + const accelerateTransactionStub = sinon + .stub(Wallet.prototype, 'accelerateTransaction') + .resolves({ + txid: 'accelerated-tx-id', + status: 'signed', + tx: 'accelerated-transaction-hex', + }); + + const response = await agent + .post(`/api/${coin}/wallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + pubkey: 'xpub_user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 100, + maxFee: 20000, + feeMultiplier: 2.0, + }); + + response.status.should.equal(200); + response.body.should.have.property('txid', 'accelerated-tx-id'); + + walletGetNock.done(); + keychainGetNock.done(); + sinon.assert.calledOnce(accelerateTransactionStub); + }); +}); diff --git a/src/__tests__/api/master/consolidate.test.ts b/src/__tests__/api/master/consolidate.test.ts index 58dca78e..352a9269 100644 --- a/src/__tests__/api/master/consolidate.test.ts +++ b/src/__tests__/api/master/consolidate.test.ts @@ -223,6 +223,14 @@ describe('POST /api/:coin/wallet/:walletId/consolidate', () => { 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 allowsAccountConsolidations to return false const allowsConsolidationsStub = sinon @@ -240,6 +248,7 @@ describe('POST /api/:coin/wallet/:walletId/consolidate', () => { response.status.should.equal(500); walletGetNock.done(); + keychainGetNock.done(); sinon.assert.calledOnce(allowsConsolidationsStub); }); diff --git a/src/api/master/handlers/handleAccelerate.ts b/src/api/master/handlers/handleAccelerate.ts new file mode 100644 index 00000000..49ff66a9 --- /dev/null +++ b/src/api/master/handlers/handleAccelerate.ts @@ -0,0 +1,49 @@ +import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; +import logger from '../../../logger'; +import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; +import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../../../shared/coinUtils'; + +export async function handleAccelerate( + req: MasterApiSpecRouteRequest<'v1.wallet.accelerate', '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 acceleration parameters + const accelerationParams = { + ...params, + customSigningFunction, + reqId, + }; + + // Accelerate transaction + const result = await wallet.accelerateTransaction(accelerationParams); + + return result; + } catch (error) { + const err = error as Error; + logger.error('Failed to accelerate transaction: %s', err.message); + throw err; + } +} diff --git a/src/api/master/handlers/handleConsolidate.ts b/src/api/master/handlers/handleConsolidate.ts index 0419eae9..9d633622 100644 --- a/src/api/master/handlers/handleConsolidate.ts +++ b/src/api/master/handlers/handleConsolidate.ts @@ -1,6 +1,7 @@ import { RequestTracer, KeyIndices } from '@bitgo/sdk-core'; import logger from '../../../logger'; import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; +import { getWalletAndSigningKeychain, makeCustomSigningFunction } from '../../../shared/coinUtils'; export async function handleConsolidate( req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>, @@ -8,14 +9,18 @@ export async function handleConsolidate( 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 }); + const coin = req.params.coin; - if (!wallet) { - throw new Error(`Wallet ${walletId} not found`); - } + const { baseCoin, wallet, signingKeychain } = await getWalletAndSigningKeychain({ + bitgo, + coin, + walletId, + params, + reqId, + KeyIndices, + }); // Check if the coin supports account consolidations if (!baseCoin.allowsAccountConsolidations()) { @@ -27,30 +32,13 @@ export async function handleConsolidate( 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; - }; + const customSigningFunction = makeCustomSigningFunction({ + enclavedExpressClient, + source: params.source, + pub: signingKeychain.pub!, + }); // Prepare consolidation parameters const consolidationParams = { diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index 2017fdb2..c0946ab1 100644 --- a/src/api/master/routers/masterApiSpec.ts +++ b/src/api/master/routers/masterApiSpec.ts @@ -22,6 +22,7 @@ import { handleSendMany } from '../handlers/handleSendMany'; import { validateMasterExpressConfig } from '../middleware/middleware'; import { handleRecoveryWalletOnPrem } from '../handlers/recoveryWallet'; import { handleConsolidate } from '../handlers/handleConsolidate'; +import { handleAccelerate } from '../handlers/handleAccelerate'; // Middleware functions export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -122,6 +123,30 @@ const ConsolidateResponse: HttpResponse = { }), }; +// Request type for /accelerate endpoint +export const AccelerateRequest = { + pubkey: t.string, + source: t.union([t.literal('user'), t.literal('backup')]), + cpfpTxIds: t.union([t.undefined, t.array(t.string)]), + cpfpFeeRate: t.union([t.undefined, t.number]), + maxFee: t.union([t.undefined, t.number]), + rbfTxIds: t.union([t.undefined, t.array(t.string)]), + feeMultiplier: t.union([t.undefined, t.number]), +}; + +// Response type for /accelerate endpoint +const AccelerateResponse: HttpResponse = { + // TODO: Get type from public types repo / Wallet Platform + 200: t.type({ + txid: t.string, + tx: t.string, + }), + 500: t.type({ + error: t.string, + details: t.string, + }), +}; + // Response type for /recovery endpoint const RecoveryWalletResponse: HttpResponse = { // TODO: Get type from public types repo @@ -223,6 +248,21 @@ export const MasterApiSpec = apiSpec({ description: 'Consolidate addresses', }), }, + 'v1.wallet.accelerate': { + post: httpRoute({ + method: 'POST', + path: '/api/{coin}/wallet/{walletId}/accelerate', + request: httpRequest({ + params: { + walletId: t.string, + coin: t.string, + }, + body: AccelerateRequest, + }), + response: AccelerateResponse, + description: 'Accelerate transaction', + }), + }, }); export type MasterApiSpec = typeof MasterApiSpec; @@ -283,5 +323,13 @@ export function createMasterApiRouter( }), ]); + router.post('v1.wallet.accelerate', [ + responseHandler(async (req: express.Request) => { + const typedReq = req as GenericMasterApiSpecRouteRequest; + const result = await handleAccelerate(typedReq); + return Response.ok(result); + }), + ]); + return router; } diff --git a/src/shared/coinUtils.ts b/src/shared/coinUtils.ts index 54d72545..d42c1867 100644 --- a/src/shared/coinUtils.ts +++ b/src/shared/coinUtils.ts @@ -1,8 +1,10 @@ import { FormattedOfflineVaultTxInfo, BackupKeyRecoveryTransansaction } from '@bitgo/abstract-utxo'; import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth'; import { CoinFamily } from '@bitgo/statics'; -import { BaseCoin } from 'bitgo'; +import { BaseCoin, BitGo } 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); @@ -61,3 +63,67 @@ 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, + }); + }; +}