From 08cce814371040bef1f94f5e0209c42e15bb6726 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Wed, 17 Jun 2026 15:46:24 -0400 Subject: [PATCH] feat: asyncify consolidate unspents flow This commit refactors the consolidate unspents flow to be asynchronous, similar to the existing sendMany and accelerate flows. The main changes include: - Adding a new WpSubmitKind 'consolidateUnspents' and its handler - Updating the asyncJobWorker to handle the new kind - Refactoring the handleConsolidateUnspents handler to build and send tx Ticket: WCN-887 --- masterBitgoExpress.json | 42 ++-- src/__tests__/api/master/accelerate.test.ts | 1 + .../api/master/asyncJobWorker.test.ts | 47 ++++ .../api/master/consolidateUnspents.test.ts | 201 +++++++++++++++--- .../api/master/multisigSignUtils.test.ts | 14 +- .../integration/asyncJobWorker.integ.test.ts | 120 ++++++----- .../handlers/handleAccelerate.ts | 4 +- .../handlers/handleConsolidateUnspents.ts | 139 +++++++++--- .../handlers/utils/multisigSignUtils.ts | 2 +- .../handlers/utils/multisigSubmitUtils.ts | 11 + .../routers/consolidateUnspentsRoute.ts | 2 + .../routers/masterBitGoExpressApiSpec.ts | 5 +- 12 files changed, 460 insertions(+), 128 deletions(-) diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index d656a37..48f3e68 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -508,6 +508,16 @@ } } }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncJobResponseCodec" + } + } + } + }, "400": { "description": "Bad Request", "content": { @@ -1933,22 +1943,6 @@ "failed" ] }, - "ConsolidateUnspentsResponseCodec": { - "title": "ConsolidateUnspentsResponseCodec", - "type": "object", - "properties": { - "tx": { - "type": "string" - }, - "txid": { - "type": "string" - } - }, - "required": [ - "tx", - "txid" - ] - }, "AsyncJobResponseCodec": { "title": "AsyncJobResponseCodec", "type": "object", @@ -1968,6 +1962,22 @@ "status" ] }, + "ConsolidateUnspentsResponseCodec": { + "title": "ConsolidateUnspentsResponseCodec", + "type": "object", + "properties": { + "tx": { + "type": "string" + }, + "txid": { + "type": "string" + } + }, + "required": [ + "tx", + "txid" + ] + }, "AccelerateResponseCodec": { "title": "AccelerateResponseCodec", "type": "object", diff --git a/src/__tests__/api/master/accelerate.test.ts b/src/__tests__/api/master/accelerate.test.ts index d8c49c3..5006484 100644 --- a/src/__tests__/api/master/accelerate.test.ts +++ b/src/__tests__/api/master/accelerate.test.ts @@ -624,6 +624,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { (capturedJobBody.wpSubmitParams as Record).should.have .property('cpfpTxIds') .which.deepEqual(cpfpTxIds); + (capturedJobBody.wpSubmitParams as Record).should.not.have.property('reqId'); bridgeNock.done(); awmSignNock.isDone().should.be.false(); diff --git a/src/__tests__/api/master/asyncJobWorker.test.ts b/src/__tests__/api/master/asyncJobWorker.test.ts index b08b733..12b4a76 100644 --- a/src/__tests__/api/master/asyncJobWorker.test.ts +++ b/src/__tests__/api/master/asyncJobWorker.test.ts @@ -222,6 +222,37 @@ function nockAccelerateTxSend(walletId: string, txid: string, cpfpTxId: string) .reply(200, { txid, status: 'signed' }); } +function makeConsolidateUnspentsSignJob( + overrides: Partial = {}, +): BridgeJobResponse { + return makeSignJob({ + request: { + endpoint: `/api/${COIN}/multisig/sign`, + method: 'POST', + body: { + source: 'user', + pub: 'xpub_user', + txPrebuild: { txHex: '70736274ff' }, + walletId: 'test-wallet-id', + wpSubmitKind: 'consolidateUnspents', + wpSubmitParams: { feeRate: 1000, minValue: 1000, txFormat: 'psbt-lite' }, + }, + }, + ...overrides, + }); +} + +function nockConsolidateUnspentsTxSend(walletId: string, txid: string) { + return nock(BITGO_API_URL) + .post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`, (body) => { + body.should.have.property('type', 'consolidate'); + body.should.have.property('txHex', 'signed-tx-hex'); + return true; + }) + .matchHeader('any', () => true) + .reply(200, { txid, status: 'signed' }); +} + function nockUpdateSignJobComplete(jobId: string, txid: string) { return nock(BRIDGE_URL) .patch(`/job/${jobId}`, (body) => body.status === 'complete' && body.result?.txid === txid) @@ -540,6 +571,22 @@ describe('asyncJobWorker', () => { updateNock.done(); }); + it('submits signed consolidateUnspents tx to WP with type consolidate and PATCHes job complete', async () => { + const job = makeConsolidateUnspentsSignJob(); + const walletId = 'test-wallet-id'; + const txid = 'consolidate-unspents-tx-id'; + + const walletGetNock = nockWalletGet(walletId); + const sendNock = nockConsolidateUnspentsTxSend(walletId, txid); + const updateNock = nockUpdateSignJobComplete(job.jobId, txid); + + await handleMultisigSignOperation(job, bridge, bitgo); + + walletGetNock.done(); + sendNock.done(); + updateNock.done(); + }); + it('throws when awmResponse is missing', async () => { const job = makeSignJob({ awmResponse: undefined }); diff --git a/src/__tests__/api/master/consolidateUnspents.test.ts b/src/__tests__/api/master/consolidateUnspents.test.ts index 5697d1c..248b50a 100644 --- a/src/__tests__/api/master/consolidateUnspents.test.ts +++ b/src/__tests__/api/master/consolidateUnspents.test.ts @@ -5,9 +5,14 @@ import nock from 'nock'; import * as utxolib from '@bitgo-beta/utxo-lib'; import { Btc } from '@bitgo-beta/sdk-coin-btc'; import { app as expressApp } from '../../../masterBitGoExpressApp'; -import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { Environments, Wallet } from '@bitgo-beta/sdk-core'; -import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG } from './testUtils'; +import { + ASYNC_TEST_BRIDGE_URL, + BitGoAPITestHarness, + makeMasterExpressTestConfig, + nockAsyncMultisigSignJob, +} from './testUtils'; +import assert from 'assert'; const BTC_PREBUILD_PSBT_HEX = utxolib.bitgo .createPsbtForNetwork({ network: utxolib.networks.bitcoin }) @@ -51,24 +56,8 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); - const config: MasterExpressConfig = { - appMode: AppMode.MASTER_EXPRESS, - port: 0, - bind: 'localhost', - timeout: 30000, - httpLoggerFile: '', - env: 'test', - disableEnvCheck: true, - authVersion: 2, - advancedWalletManagerUrl: advancedWalletManagerUrl, - awmServerCaCert: 'test-cert', - tlsMode: TlsMode.DISABLED, - clientCertAllowSelfSigned: true, - asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG, - }; - - const app = expressApp(config); - agent = request.agent(app); + const config = makeMasterExpressTestConfig(advancedWalletManagerUrl); + agent = request.agent(expressApp(config)); }); afterEach(() => { @@ -339,11 +328,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = .set('Authorization', `Bearer ${accessToken}`) .send(requestPayload); - response.status.should.equal(500); - response.body.should.have.property('error', 'Internal Server Error'); - response.body.should.have.property('name', 'Error'); - response.body.should.have.property( - 'details', + response.status.should.equal(400); + response.body.error.should.equal('BadRequestError'); + response.body.details.should.containEql( 'Expected single consolidation result, but received 2 results', ); }); @@ -671,4 +658,168 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () = awmSignNock.done(); capturedSignBody.should.not.have.property('walletPubs'); }); + + it('should return 202 with jobId when async mode is enabled for onchain multisig consolidateUnspents', async () => { + const jobId = 'test-consolidate-unspents-job-id'; + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + }); + const asyncAgent = request.agent(expressApp(asyncConfig)); + + nockWalletAndKeychains(); + + let capturedBuildBody: Record | undefined; + const buildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`, (body) => { + capturedBuildBody = body; + return true; + }) + .reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }); + + sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true); + + let capturedJobBody: Record | undefined; + const { bridgeNock, awmSignNock } = nockAsyncMultisigSignJob({ + coin, + advancedWalletManagerUrl, + jobId, + captureJobBody: (body) => { + capturedJobBody = body; + }, + }); + + const requestPayload = { + pubkey: mockUserKeychain.pub, + source: 'user' as const, + feeRate: 1000, + maxFeeRate: 2000, + minValue: 1000, + }; + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send(requestPayload); + + response.status.should.equal(202); + response.body.should.have.property('jobId', jobId); + response.body.should.have.property('status', 'pending'); + + assert(capturedBuildBody, 'capturedBuildBody is undefined'); + capturedBuildBody.should.have.property('feeRate', 1000); + capturedBuildBody.should.have.property('maxFeeRate', 2000); + capturedBuildBody.should.have.property('minValue', 1000); + capturedBuildBody.should.have.property('txFormat', 'psbt-lite'); + + assert(capturedJobBody, 'capturedJobBody is undefined'); + capturedJobBody.should.have.property('wpSubmitKind', 'consolidateUnspents'); + (capturedJobBody.wpSubmitParams as Record).should.have.property( + 'feeRate', + 1000, + ); + (capturedJobBody.wpSubmitParams as Record).should.have.property( + 'txFormat', + 'psbt-lite', + ); + (capturedJobBody.wpSubmitParams as Record).should.not.have.property('reqId'); + (capturedJobBody.walletPubs as string[]).should.deepEqual([ + mockUserKeychain.pub, + mockBackupKeychain.pub, + mockBitgoKeychain.pub, + ]); + + buildNock.done(); + bridgeNock.done(); + awmSignNock.isDone().should.be.false(); + }); + + it('should fail when async mode is enabled with bulk consolidateUnspents', async () => { + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + }); + const asyncAgent = request.agent(expressApp(asyncConfig)); + + nockWalletAndKeychains(); + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + bulk: true, + }); + + response.status.should.equal(400); + response.body.details.should.containEql('Async mode does not support bulk consolidateUnspents'); + }); + + it('should fail when async consolidateUnspents prebuild returns more than one result', async () => { + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + }); + const asyncAgent = request.agent(expressApp(asyncConfig)); + + nockWalletAndKeychains(); + + const buildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, [ + { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }, + { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }, + ]); + + const bridgeNock = nock(ASYNC_TEST_BRIDGE_URL) + .post(`/api/${coin}/multisig/sign`) + .reply(202, { jobId: 'should-not-reach-bridge' }); + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(400); + response.body.error.should.equal('BadRequestError'); + response.body.details.should.containEql( + 'Expected single consolidation result, but received 2 results', + ); + + buildNock.done(); + bridgeNock.isDone().should.be.false(); + }); + + it('should fail when async transaction verification returns false', async () => { + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + }); + const asyncAgent = request.agent(expressApp(asyncConfig)); + + nockWalletAndKeychains(); + + const buildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`) + .reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} }); + + const verifyStub = sinon.stub(Btc.prototype, 'verifyTransaction').resolves(false); + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + feeRate: 1000, + }); + + response.status.should.equal(400); + response.body.details.should.containEql('Transaction prebuild failed local validation'); + + buildNock.done(); + sinon.assert.calledOnce(verifyStub); + }); }); diff --git a/src/__tests__/api/master/multisigSignUtils.test.ts b/src/__tests__/api/master/multisigSignUtils.test.ts index 7b60911..e5defe5 100644 --- a/src/__tests__/api/master/multisigSignUtils.test.ts +++ b/src/__tests__/api/master/multisigSignUtils.test.ts @@ -210,6 +210,18 @@ describe('multisigSignUtils', () => { }); }); + it('parses consolidateUnspents wpSubmitKind', () => { + parseMultisigSignJobContext({ + walletId: 'test-wallet-id', + wpSubmitKind: 'consolidateUnspents', + wpSubmitParams: { feeRate: 1000, minValue: 1000, txFormat: 'psbt-lite' }, + }).should.eql({ + walletId: 'test-wallet-id', + wpSubmitKind: 'consolidateUnspents', + wpSubmitParams: { feeRate: 1000, minValue: 1000, txFormat: 'psbt-lite' }, + }); + }); + it('throws when wpSubmitKind is missing or unsupported', () => { (() => parseMultisigSignJobContext({ @@ -221,7 +233,7 @@ describe('multisigSignUtils', () => { parseMultisigSignJobContext({ walletId: 'test-wallet-id', wpSubmitKind: 'consolidate', - wpSubmitParams: { recipients: [] }, + wpSubmitParams: { feeRate: 1000 }, })).should.throw(/unsupported wpSubmitKind: consolidate/); }); }); diff --git a/src/__tests__/integration/asyncJobWorker.integ.test.ts b/src/__tests__/integration/asyncJobWorker.integ.test.ts index e523025..09db12b 100644 --- a/src/__tests__/integration/asyncJobWorker.integ.test.ts +++ b/src/__tests__/integration/asyncJobWorker.integ.test.ts @@ -2,6 +2,7 @@ import 'should'; import assert from 'assert'; import { startServices, IntegServices } from './helpers/setup'; import { BridgeJobResponse } from '../../masterBitgoExpress/clients/bridgeClient.types'; +import { WpSubmitKind } from '../../masterBitgoExpress/handlers/utils/multisigSignUtils'; import { MockBridgeServer } from './helpers/mockBridgeServer'; const COIN = 'tbtc'; @@ -74,49 +75,37 @@ function makeAwaitingBitgoJob(overrides: Partial = {}): Bridg }; } -function makeAwaitingBitgoSignJob(overrides: Partial = {}): BridgeJobResponse { - return { - jobId: JOB_ID, - status: 'awaiting_bitgo', - version: 1, - coin: COIN, - operationType: 'multisig_sign', - awmResponse: { - status: 200, - body: { txHex: 'signed-tx-hex' }, - }, - request: { - endpoint: `/api/${COIN}/multisig/sign`, - method: 'POST', - body: { - source: 'user', - pub: USER_XPUB, - txPrebuild: { - txHex: '70736274ff', - txInfo: { nP2SHInputs: 0, nSegwitInputs: 1, nOutputs: 1 }, - }, - walletId: WALLET_ID, - wpSubmitKind: 'sendMany', - wpSubmitParams: { - recipients: [ - { - address: 'tb1qdgj9n5nw33k2qk26mxu7j5hv30dapz6fewscd4jd87euyjxyp04qgphg92', - amount: '10000', - }, - ], - source: 'user', - txFormat: 'psbt-lite', - }, +const INTEG_WP_SUBMIT_PARAMS: Record> = { + sendMany: { + recipients: [ + { + address: 'tb1qdgj9n5nw33k2qk26mxu7j5hv30dapz6fewscd4jd87euyjxyp04qgphg92', + amount: '10000', }, - }, - createdAt: 1717977600, - updatedAt: 1717977600, - ttl: 3600, - ...overrides, - }; -} - -function makeAwaitingBitgoAccelerateJob( + ], + source: 'user', + txFormat: 'psbt-lite', + }, + accelerate: { + pubkey: USER_XPUB, + source: 'user', + cpfpTxIds: [CPFP_TX_ID], + cpfpFeeRate: 50, + maxFee: 10000, + recipients: [], + txFormat: 'psbt-lite', + }, + consolidateUnspents: { + pubkey: USER_XPUB, + source: 'user', + feeRate: 1000, + minValue: 1000, + txFormat: 'psbt-lite', + }, +}; + +function makeAwaitingBitgoMultisigSignJob( + wpSubmitKind: WpSubmitKind, overrides: Partial = {}, ): BridgeJobResponse { return { @@ -140,16 +129,8 @@ function makeAwaitingBitgoAccelerateJob( txInfo: { nP2SHInputs: 0, nSegwitInputs: 1, nOutputs: 1 }, }, walletId: WALLET_ID, - wpSubmitKind: 'accelerate', - wpSubmitParams: { - pubkey: USER_XPUB, - source: 'user', - cpfpTxIds: [CPFP_TX_ID], - cpfpFeeRate: 50, - maxFee: 10000, - recipients: [], - txFormat: 'psbt-lite', - }, + wpSubmitKind, + wpSubmitParams: INTEG_WP_SUBMIT_PARAMS[wpSubmitKind], }, }, createdAt: 1717977600, @@ -220,7 +201,7 @@ describe('asyncJobWorker: end-to-end polling', () => { it('picks up an awaiting_bitgo multisig_sign job, submits to WP, and PATCHes complete', async () => { assert(services.bridge, 'bridge service should be defined'); - services.bridge.setPendingJobs([makeAwaitingBitgoSignJob()]); + services.bridge.setPendingJobs([makeAwaitingBitgoMultisigSignJob('sendMany')]); await waitForJobCompletion(services.bridge, JOB_ID, 5000); @@ -244,7 +225,7 @@ describe('asyncJobWorker: end-to-end polling', () => { it('PATCHes multisig_sign job failed when awmResponse.body is not a valid signed transaction', async () => { assert(services.bridge, 'bridge service should be defined'); services.bridge.setPendingJobs([ - makeAwaitingBitgoSignJob({ + makeAwaitingBitgoMultisigSignJob('sendMany', { awmResponse: { status: 200, body: { bad: 'shape' } }, }), ]); @@ -263,7 +244,7 @@ describe('asyncJobWorker: end-to-end polling', () => { it('picks up an awaiting_bitgo accelerate job, submits cpfp params to WP, and PATCHes complete', async () => { assert(services.bridge, 'bridge service should be defined'); - services.bridge.setPendingJobs([makeAwaitingBitgoAccelerateJob()]); + services.bridge.setPendingJobs([makeAwaitingBitgoMultisigSignJob('accelerate')]); await waitForJobCompletion(services.bridge, JOB_ID, 5000); @@ -288,10 +269,37 @@ describe('asyncJobWorker: end-to-end polling', () => { patchBody.result.should.have.property('txid', 'test-tx-id'); }); + it('picks up an awaiting_bitgo consolidateUnspents job, submits type consolidate to WP, and PATCHes complete', async () => { + assert(services.bridge, 'bridge service should be defined'); + services.bridge.setPendingJobs([makeAwaitingBitgoMultisigSignJob('consolidateUnspents')]); + + await waitForJobCompletion(services.bridge, JOB_ID, 5000); + + const walletGetCalls = services.bitgo.calls.filter( + (c) => c.method === 'GET' && c.path.endsWith(`/wallet/${WALLET_ID}`), + ); + walletGetCalls.should.have.length(1); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(1); + const sendBody = sendCalls[0].body as { type?: string; txHex?: string }; + sendBody.should.have.property('type', 'consolidate'); + assert(sendBody.txHex, 'sendBody.txHex is undefined'); + sendBody.txHex.should.equal('signed-tx-hex'); + + const patchCall = services.bridge.calls.find( + (c) => c.method === 'PATCH' && c.path === `/job/${JOB_ID}`, + ); + assert(patchCall !== undefined, `expected PATCH /job/${JOB_ID} to be called`); + const patchBody = patchCall.body as { status: string; result: { txid: string } }; + patchBody.status.should.equal('complete'); + patchBody.result.should.have.property('txid', 'test-tx-id'); + }); + it('PATCHes multisig_sign job failed when request.body is missing walletId', async () => { assert(services.bridge, 'bridge service should be defined'); services.bridge.setPendingJobs([ - makeAwaitingBitgoSignJob({ + makeAwaitingBitgoMultisigSignJob('sendMany', { request: { endpoint: `/api/${COIN}/multisig/sign`, method: 'POST', diff --git a/src/masterBitgoExpress/handlers/handleAccelerate.ts b/src/masterBitgoExpress/handlers/handleAccelerate.ts index aa044de..c3a89f2 100644 --- a/src/masterBitgoExpress/handlers/handleAccelerate.ts +++ b/src/masterBitgoExpress/handlers/handleAccelerate.ts @@ -45,6 +45,8 @@ async function handleAccelerateAsync(params: { throw new BadRequestError('Transaction prebuild failed local validation'); } + const { reqId: _reqId, ...wpSubmitParams } = params.accelerationParams; + return orThrow( await submitMultisigSignJob( params.req, @@ -58,7 +60,7 @@ async function handleAccelerateAsync(params: { { walletId: params.walletId, wpSubmitKind: 'accelerate', - wpSubmitParams: params.accelerationParams, + wpSubmitParams, }, ), 'async accelerate job submission failed', diff --git a/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts b/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts index 8312d32..a6495fb 100644 --- a/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts +++ b/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts @@ -1,17 +1,97 @@ -import { KeyIndices, RequestTracer } from '@bitgo-beta/sdk-core'; +import { + BaseCoin, + ConsolidateUnspentsOptions, + KeyIndices, + Keychain, + ManageUnspentsOptions, + PrebuildTransactionResult, + RequestTracer, + Wallet, +} from '@bitgo-beta/sdk-core'; import logger from '../../shared/logger'; +import { BadRequestError } from '../../shared/errors'; +import { orThrow } from '../../shared/utils'; +import { AsyncJobResponse } from '../clients/bridgeClient.types'; import { MasterApiSpecRouteRequest } from '../routers/masterBitGoExpressApiSpec'; +import { buildMultisigSignBody, submitMultisigSignJob } from './utils/multisigSignUtils'; import { getWalletAndSigningKeychain, makeCustomSigningFunction, getWalletPubs, } from './utils/utils'; +function normalizeSingleConsolidateResponse(response: T | T[]): T { + if (Array.isArray(response)) { + if (response.length === 0 || response.length > 1) { + throw new BadRequestError( + response.length == 0 + ? 'Unable to build a consolidation transaction. No eligible unspents for this wallet' + : `Expected single consolidation result, but received ${response.length} results`, + ); + } + return response[0]; + } + return response; +} + +async function handleConsolidateUnspentsAsync(params: { + req: MasterApiSpecRouteRequest<'v1.wallet.consolidateunspents', 'post'>; + coin: string; + walletId: string; + baseCoin: BaseCoin; + wallet: Wallet; + signingKeychain: Keychain; + walletPubs: string[] | undefined; + consolidationParams: ConsolidateUnspentsOptions; + requestTracer: RequestTracer; +}): Promise { + const buildResponse = await params.wallet.consolidateUnspents( + params.consolidationParams, + ManageUnspentsOptions.BUILD_ONLY, + ); + const txPrebuilt = normalizeSingleConsolidateResponse( + buildResponse as PrebuildTransactionResult | PrebuildTransactionResult[], + ); + + const verified = await params.baseCoin.verifyTransaction({ + txParams: { ...params.consolidationParams }, + txPrebuild: txPrebuilt, + wallet: params.wallet, + verification: {}, + reqId: params.requestTracer, + walletType: params.wallet.multisigType(), + }); + if (!verified) { + throw new BadRequestError('Transaction prebuild failed local validation'); + } + + const { reqId: _reqId, ...wpSubmitParams } = params.consolidationParams; + + return orThrow( + await submitMultisigSignJob( + params.req, + params.coin, + buildMultisigSignBody({ + source: params.req.decoded.source, + signingKeychain: params.signingKeychain, + txPrebuilt, + walletPubs: params.walletPubs, + }), + { + walletId: params.walletId, + wpSubmitKind: 'consolidateUnspents', + wpSubmitParams, + }, + ), + 'async consolidateUnspents job submission failed', + ); +} + export async function handleConsolidateUnspents( req: MasterApiSpecRouteRequest<'v1.wallet.consolidateunspents', 'post'>, ) { const awmClient = req.awmUserClient; - const reqId = new RequestTracer(); + const requestTracer = new RequestTracer(); const bitgo = req.bitgo; const params = req.decoded; const walletId = req.params.walletId; @@ -22,14 +102,37 @@ export async function handleConsolidateUnspents( coin, walletId, params, - reqId, + reqId: requestTracer, KeyIndices, }); const walletPubs = await getWalletPubs({ baseCoin, wallet }); + const consolidationParams: ConsolidateUnspentsOptions = { + ...params, + reqId: requestTracer, + txFormat: 'psbt-lite', + }; + + if (params.bulk && req.config.asyncModeConfig.enabled) { + throw new BadRequestError('Async mode does not support bulk consolidateUnspents'); + } + try { - // Create custom signing function that delegates to EBE + if (req.config.asyncModeConfig.enabled) { + return await handleConsolidateUnspentsAsync({ + req, + coin, + walletId, + baseCoin, + wallet, + signingKeychain, + walletPubs, + consolidationParams, + requestTracer, + }); + } + const customSigningFunction = makeCustomSigningFunction({ awmClient, source: params.source, @@ -37,28 +140,12 @@ export async function handleConsolidateUnspents( walletPubs, }); - // Prepare consolidation parameters - const consolidationParams = { - ...params, - customSigningFunction, - reqId, - txFormat: 'psbt-lite', - }; - - // Send consolidate unspents - let result = await wallet.consolidateUnspents(consolidationParams); - - if (Array.isArray(result)) { - if (result.length === 1) { - result = result[0]; - } else if (result.length > 1) { - throw new Error( - `Expected single consolidation result, but received ${result.length} results`, - ); - } - } - - return result; + return normalizeSingleConsolidateResponse( + await wallet.consolidateUnspents({ + ...consolidationParams, + customSigningFunction, + }), + ); } catch (error) { const err = error as Error; logger.error('Failed to consolidate unspents: %s', err.message); diff --git a/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts index 947500f..1dff1f9 100644 --- a/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts +++ b/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts @@ -35,7 +35,7 @@ export function parseSignedMultisigTransaction(body: unknown): SignedTransaction return SignedMultisigTransactionSchema.parse(body) as SignedTransaction; } -export const WP_SUBMIT_KINDS = ['sendMany', 'accelerate'] as const; +export const WP_SUBMIT_KINDS = ['sendMany', 'accelerate', 'consolidateUnspents'] as const; export type WpSubmitKind = (typeof WP_SUBMIT_KINDS)[number]; export function isWpSubmitKind(value: unknown): value is WpSubmitKind { diff --git a/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts index 677c6d9..8645b77 100644 --- a/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts +++ b/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts @@ -45,4 +45,15 @@ export const WP_SUBMIT_HANDLERS: Record< ...signedTx, ..._.pick(wpSubmitParams, wallet.prebuildWhitelistedParams()), })), + + /** + * Same submit shape as SDK manageUnspents for routeName === 'consolidate' + * https://github.com/BitGo/BitGoJS/blob/master/modules/sdk-core/src/bitgo/wallet/wallet.ts + */ + consolidateUnspents: (args) => + submitSignedTxToWp(args, ({ signedTx, wpSubmitParams }) => ({ + ...signedTx, + ..._.pick(wpSubmitParams, ['comment', 'otp', 'bulk']), + type: 'consolidate', + })), }; diff --git a/src/masterBitgoExpress/routers/consolidateUnspentsRoute.ts b/src/masterBitgoExpress/routers/consolidateUnspentsRoute.ts index fdb12d5..c068730 100644 --- a/src/masterBitgoExpress/routers/consolidateUnspentsRoute.ts +++ b/src/masterBitgoExpress/routers/consolidateUnspentsRoute.ts @@ -1,6 +1,7 @@ import { httpRequest, HttpResponse, httpRoute, optional } from '@api-ts/io-ts-http'; import * as t from 'io-ts'; import { ErrorResponses } from '../../shared/errors'; +import { AsyncJobResponseCodec } from './generateWalletRoute'; export const ConsolidateUnspentsRequest = { /** @@ -73,6 +74,7 @@ export type ConsolidateUnspentsResponseBody = t.TypeOf> | Awaited> - | Awaited>; + | Awaited> + | Awaited>; function toApiResponse(result: MasterBitGoAPIHandlerResponses) { return 'jobId' in result ? Response.accepted(result) : Response.ok(result); @@ -162,7 +163,7 @@ export function createMasterApiRouter( responseHandler(async (req: express.Request) => { const typedReq = req as GenericMasterApiSpecRouteRequest; const result = await handleConsolidateUnspents(typedReq); - return Response.ok(result); + return toApiResponse(result); }), ]);