From 6dcdfd7ac41bd76b7121514f31ecb01fc51efa81 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Wed, 17 Jun 2026 10:09:12 -0400 Subject: [PATCH] feat: implement async accelerate endpoint This commit implements an asynchronous version of the accelerate endpoint so the work can be offloaded to the async job worker in the background. Ticket: WCN-887 --- masterBitgoExpress.json | 48 +++--- src/__tests__/api/master/accelerate.test.ts | 161 +++++++++++++++--- .../api/master/asyncJobWorker.test.ts | 51 ++++++ .../api/master/multisigSignUtils.test.ts | 16 +- src/__tests__/api/master/sendMany.test.ts | 112 +++--------- src/__tests__/api/master/testUtils.ts | 67 +++++++- .../integration/asyncJobWorker.integ.test.ts | 71 ++++++++ .../handlers/handleAccelerate.ts | 108 ++++++++++-- .../handlers/handleSendMany.ts | 9 +- .../handlers/utils/multisigSignUtils.ts | 2 +- .../handlers/utils/multisigSubmitUtils.ts | 55 ++++-- .../routers/accelerateRoute.ts | 2 + .../routers/masterBitGoExpressApiSpec.ts | 5 +- .../workers/asyncJobWorker.ts | 29 ++-- src/shared/utils.ts | 11 ++ 15 files changed, 569 insertions(+), 178 deletions(-) create mode 100644 src/shared/utils.ts diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index a88195c..d656a37 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -162,6 +162,16 @@ } } }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncJobResponseCodec" + } + } + } + }, "400": { "description": "Bad Request", "content": { @@ -1939,6 +1949,25 @@ "txid" ] }, + "AsyncJobResponseCodec": { + "title": "AsyncJobResponseCodec", + "type": "object", + "properties": { + "jobId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending" + ] + } + }, + "required": [ + "jobId", + "status" + ] + }, "AccelerateResponseCodec": { "title": "AccelerateResponseCodec", "type": "object", @@ -1968,25 +1997,6 @@ "txHex" ] }, - "AsyncJobResponseCodec": { - "title": "AsyncJobResponseCodec", - "type": "object", - "properties": { - "jobId": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "pending" - ] - } - }, - "required": [ - "jobId", - "status" - ] - }, "GenerateWalletResponseCodec": { "title": "GenerateWalletResponseCodec", "type": "object", diff --git a/src/__tests__/api/master/accelerate.test.ts b/src/__tests__/api/master/accelerate.test.ts index 4ca3a6f..d8c49c3 100644 --- a/src/__tests__/api/master/accelerate.test.ts +++ b/src/__tests__/api/master/accelerate.test.ts @@ -5,9 +5,13 @@ import nock from 'nock'; import * as utxolib from '@bitgo-beta/utxo-lib'; import { Tbtc } 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 { + BitGoAPITestHarness, + makeMasterExpressTestConfig, + nockAsyncMultisigSignJob, +} from './testUtils'; +import assert from 'assert'; const TBTC_PREBUILD_PSBT_HEX = utxolib.bitgo .createPsbtForNetwork({ network: utxolib.networks.testnet }) @@ -51,24 +55,8 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { 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, - 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(() => { @@ -578,4 +566,137 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => { awmSignNock.done(); capturedSignBody.should.not.have.property('walletPubs'); }); + + it('should return 202 with jobId when async mode is enabled for onchain multisig accelerate', async () => { + const jobId = 'test-accelerate-job-id'; + const cpfpTxIds = ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26']; + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + }); + const asyncAgent = request.agent(expressApp(asyncConfig)); + + nockWalletAndKeychains(); + + let capturedBuildBody: Record | undefined; + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`, (body) => { + capturedBuildBody = body; + return true; + }) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); + sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); + + let capturedJobBody: Record | undefined; + const { bridgeNock, awmSignNock } = nockAsyncMultisigSignJob({ + coin, + advancedWalletManagerUrl, + jobId, + captureJobBody: (body) => { + capturedJobBody = body; + }, + }); + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user' as const, + cpfpTxIds, + cpfpFeeRate: 50, + maxFee: 10000, + }); + + 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('cpfpTxIds').which.deepEqual(cpfpTxIds); + capturedBuildBody.should.have.property('recipients').which.deepEqual([]); + + assert(capturedJobBody, 'capturedJobBody is undefined'); + capturedJobBody.should.have.property('wpSubmitKind', 'accelerate'); + (capturedJobBody.wpSubmitParams as Record).should.have + .property('cpfpTxIds') + .which.deepEqual(cpfpTxIds); + + bridgeNock.done(); + awmSignNock.isDone().should.be.false(); + }); + + it('should fail when async mode is enabled for TSS accelerate', async () => { + const tssCoin = 'tsol'; + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + }); + const asyncAgent = request.agent(expressApp(asyncConfig)); + + nock(bitgoApiUrl) + .get(`/api/v2/${tssCoin}/wallet/${walletId}`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, { + id: walletId, + type: 'advanced', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + }); + + nock(bitgoApiUrl) + .get(`/api/v2/${tssCoin}/key/user-key-id`) + .matchHeader('authorization', `Bearer ${accessToken}`) + .reply(200, mockUserKeychain); + + const response = await asyncAgent + .post(`/api/v1/${tssCoin}/advancedwallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + }); + + response.status.should.equal(400); + response.body.details.should.containEql('Async mode is not yet supported for TSS accelerate'); + }); + + 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}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); + + const verifyStub = sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(false); + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/accelerate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + pubkey: mockUserKeychain.pub, + source: 'user', + cpfpTxIds: ['b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'], + cpfpFeeRate: 50, + }); + + 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/asyncJobWorker.test.ts b/src/__tests__/api/master/asyncJobWorker.test.ts index 005cb09..b08b733 100644 --- a/src/__tests__/api/master/asyncJobWorker.test.ts +++ b/src/__tests__/api/master/asyncJobWorker.test.ts @@ -188,6 +188,40 @@ function nockTxSend(walletId: string, txid: string) { .reply(200, { txid, status: 'signed' }); } +function makeAccelerateSignJob(overrides: Partial = {}): BridgeJobResponse { + const cpfpTxId = 'b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'; + return makeSignJob({ + request: { + endpoint: `/api/${COIN}/multisig/sign`, + method: 'POST', + body: { + source: 'user', + pub: 'xpub_user', + txPrebuild: { txHex: '70736274ff' }, + walletId: 'test-wallet-id', + wpSubmitKind: 'accelerate', + wpSubmitParams: { + cpfpTxIds: [cpfpTxId], + cpfpFeeRate: 50, + recipients: [], + }, + }, + }, + ...overrides, + }); +} + +function nockAccelerateTxSend(walletId: string, txid: string, cpfpTxId: string) { + return nock(BITGO_API_URL) + .post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`, (body) => { + body.should.have.property('cpfpTxIds').which.deepEqual([cpfpTxId]); + 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) @@ -489,6 +523,23 @@ describe('asyncJobWorker', () => { updateNock.done(); }); + it('submits signed accelerate tx to WP with cpfp params and PATCHes job complete', async () => { + const job = makeAccelerateSignJob(); + const walletId = 'test-wallet-id'; + const txid = 'accelerated-tx-id'; + const cpfpTxId = 'b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'; + + const walletGetNock = nockWalletGet(walletId); + const sendNock = nockAccelerateTxSend(walletId, txid, cpfpTxId); + 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/multisigSignUtils.test.ts b/src/__tests__/api/master/multisigSignUtils.test.ts index 3c6c60e..7b60911 100644 --- a/src/__tests__/api/master/multisigSignUtils.test.ts +++ b/src/__tests__/api/master/multisigSignUtils.test.ts @@ -198,6 +198,18 @@ describe('multisigSignUtils', () => { }); }); + it('parses accelerate wpSubmitKind', () => { + parseMultisigSignJobContext({ + walletId: 'test-wallet-id', + wpSubmitKind: 'accelerate', + wpSubmitParams: { cpfpTxIds: ['tx-id'], cpfpFeeRate: 50 }, + }).should.eql({ + walletId: 'test-wallet-id', + wpSubmitKind: 'accelerate', + wpSubmitParams: { cpfpTxIds: ['tx-id'], cpfpFeeRate: 50 }, + }); + }); + it('throws when wpSubmitKind is missing or unsupported', () => { (() => parseMultisigSignJobContext({ @@ -208,9 +220,9 @@ describe('multisigSignUtils', () => { (() => parseMultisigSignJobContext({ walletId: 'test-wallet-id', - wpSubmitKind: 'accelerate', + wpSubmitKind: 'consolidate', wpSubmitParams: { recipients: [] }, - })).should.throw(/unsupported wpSubmitKind: accelerate/); + })).should.throw(/unsupported wpSubmitKind: consolidate/); }); }); }); diff --git a/src/__tests__/api/master/sendMany.test.ts b/src/__tests__/api/master/sendMany.test.ts index 443401a..50e6e8f 100644 --- a/src/__tests__/api/master/sendMany.test.ts +++ b/src/__tests__/api/master/sendMany.test.ts @@ -5,7 +5,7 @@ import * as request from 'supertest'; import nock from 'nock'; import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { app as expressApp } from '../../../masterBitGoExpressApp'; -import { AppMode, KeySource, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import * as middleware from '../../../shared/middleware'; import { BitGoRequest } from '../../../types/request'; import { Environments, openpgpUtils } from '@bitgo-beta/sdk-core'; @@ -16,6 +16,8 @@ import assert from 'assert'; import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG, + makeMasterExpressTestConfig, + nockAsyncMultisigSignJob, nockEcdsaMpcv2SendManySigningFlow, } from './testUtils'; @@ -27,6 +29,16 @@ const TBTC_PREBUILD_PSBT_HEX = utxolib.bitgo .createPsbtForNetwork({ network: utxolib.networks.testnet }) .toHex(); +function stubPrepareBitGoWithConfig(asyncConfig: MasterExpressConfig): BitGoAPI { + const asyncBitgo = new BitGoAPI({ env: 'test' }); + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, _res, next) => { + (req as BitGoRequest).bitgo = asyncBitgo; + (req as BitGoRequest).config = asyncConfig; + next(); + }); + return asyncBitgo; +} + function buildPendingEdDsaTxRequest(walletIdParam: string) { return { txRequestId: tssTxRequestId, @@ -123,21 +135,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { 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: 60000, - httpLoggerFile: '', - env: 'test', - disableEnvCheck: true, - authVersion: 2, - advancedWalletManagerUrl: advancedWalletManagerUrl, - awmServerCaCert: 'dummy-cert', - tlsMode: TlsMode.DISABLED, - clientCertAllowSelfSigned: true, - asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG, - }; + const config = makeMasterExpressTestConfig(advancedWalletManagerUrl); const app = expressApp(config); agent = request.agent(app); @@ -476,40 +474,14 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { }); it('should return 202 with jobId when async mode is enabled for onchain multisig sendMany', async () => { - const bridgeUrl = 'http://bridge.invalid'; const jobId = 'test-job-id-123'; - const asyncBitgo = new BitGoAPI({ env: 'test' }); - const asyncConfig: MasterExpressConfig = { - appMode: AppMode.MASTER_EXPRESS, - port: 0, - bind: 'localhost', - timeout: 60000, - httpLoggerFile: '', - env: 'test', - disableEnvCheck: true, - authVersion: 2, - advancedWalletManagerUrl: advancedWalletManagerUrl, - awmServerCaCert: 'dummy-cert', - tlsMode: TlsMode.DISABLED, - clientCertAllowSelfSigned: true, - asyncModeConfig: { - enabled: true, - awmAsyncUrl: bridgeUrl, - pollIntervalInMs: 30000, - jobTtlInSeconds: 3600, - jobTtlMpcInSeconds: 7200, - }, - }; - - sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, _res, next) => { - (req as BitGoRequest).bitgo = asyncBitgo; - (req as BitGoRequest).config = asyncConfig; - next(); + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, }); + stubPrepareBitGoWithConfig(asyncConfig); - const asyncApp = expressApp(asyncConfig); - const asyncAgent = request.agent(asyncApp); + const asyncAgent = request.agent(expressApp(asyncConfig)); nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) @@ -547,15 +519,11 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); - const bridgeNock = nock(bridgeUrl) - .post(`/api/${coin}/multisig/sign`) - .matchHeader('X-OSO-Source', KeySource.USER) - .matchHeader('X-OSO-Operation', 'multisig_sign') - .reply(202, { jobId }); - - const awmSignNock = nock(advancedWalletManagerUrl) - .post(`/api/${coin}/multisig/sign`) - .reply(500, { error: 'should not reach AWM in async mode' }); + const { bridgeNock, awmSignNock } = nockAsyncMultisigSignJob({ + coin, + advancedWalletManagerUrl, + jobId, + }); const response = await asyncAgent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) @@ -574,41 +542,15 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { }); it('should fail when async mode is enabled for TSS sendMany', async () => { - const bridgeUrl = 'http://bridge.invalid'; const tssCoin = 'tsol'; sinon.restore(); - const asyncBitgo = new BitGoAPI({ env: 'test' }); - const asyncConfig: MasterExpressConfig = { - appMode: AppMode.MASTER_EXPRESS, - port: 0, - bind: 'localhost', - timeout: 60000, - httpLoggerFile: '', - env: 'test', - disableEnvCheck: true, - authVersion: 2, - advancedWalletManagerUrl: advancedWalletManagerUrl, - awmServerCaCert: 'dummy-cert', - tlsMode: TlsMode.DISABLED, - clientCertAllowSelfSigned: true, - asyncModeConfig: { - enabled: true, - awmAsyncUrl: bridgeUrl, - pollIntervalInMs: 30000, - jobTtlInSeconds: 3600, - jobTtlMpcInSeconds: 7200, - }, - }; - - sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, _res, next) => { - (req as BitGoRequest).bitgo = asyncBitgo; - (req as BitGoRequest).config = asyncConfig; - next(); + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, }); + stubPrepareBitGoWithConfig(asyncConfig); - const asyncApp = expressApp(asyncConfig); - const asyncAgent = request.agent(asyncApp); + const asyncAgent = request.agent(expressApp(asyncConfig)); nock(bitgoApiUrl) .get(`/api/v2/${tssCoin}/wallet/${walletId}`) diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts index 452851f..601e20c 100644 --- a/src/__tests__/api/master/testUtils.ts +++ b/src/__tests__/api/master/testUtils.ts @@ -2,7 +2,13 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { SignatureShareRecord, SignatureShareType } from '@bitgo-beta/sdk-core'; import nock from 'nock'; import { BridgeJobResponse } from '../../../masterBitgoExpress/clients/bridgeClient.types'; -import { AsyncModeConfig } from '../../../shared/types'; +import { + AppMode, + AsyncModeConfig, + KeySource, + MasterExpressConfig, + TlsMode, +} from '../../../shared/types'; export const DEFAULT_ASYNC_MODE_CONFIG: AsyncModeConfig = { enabled: false, @@ -12,6 +18,65 @@ export const DEFAULT_ASYNC_MODE_CONFIG: AsyncModeConfig = { jobTtlMpcInSeconds: 7200, }; +export const ASYNC_TEST_BRIDGE_URL = 'http://bridge.invalid'; + +export function makeMasterExpressTestConfig( + advancedWalletManagerUrl: string, + options: { asyncEnabled?: boolean; overrides?: Partial } = {}, +): MasterExpressConfig { + return { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 30000, + httpLoggerFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + advancedWalletManagerUrl, + awmServerCaCert: 'test-cert', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + asyncModeConfig: options.asyncEnabled + ? { + enabled: true, + awmAsyncUrl: ASYNC_TEST_BRIDGE_URL, + pollIntervalInMs: 30000, + jobTtlInSeconds: 3600, + jobTtlMpcInSeconds: 7200, + } + : DEFAULT_ASYNC_MODE_CONFIG, + ...options.overrides, + }; +} + +export function nockAsyncMultisigSignJob(options: { + coin: string; + advancedWalletManagerUrl: string; + jobId: string; + captureJobBody?: (body: Record) => void; + bridgeUrl?: string; + source?: KeySource; +}) { + const bridgeUrl = options.bridgeUrl ?? ASYNC_TEST_BRIDGE_URL; + const source = options.source ?? KeySource.USER; + + const bridgeNock = nock(bridgeUrl) + .post(`/api/${options.coin}/multisig/sign`, (body) => { + options.captureJobBody?.(body); + return true; + }) + .matchHeader('X-OSO-Source', source) + .matchHeader('X-OSO-Operation', 'multisig_sign') + .reply(202, { jobId: options.jobId }); + + const awmSignNock = nock(options.advancedWalletManagerUrl) + .post(`/api/${options.coin}/multisig/sign`) + .reply(500, { error: 'should not reach AWM in async mode' }); + + return { bridgeNock, awmSignNock }; +} + export function makeBridgeJob( overrides: Partial = {}, jobId = 'job-123', diff --git a/src/__tests__/integration/asyncJobWorker.integ.test.ts b/src/__tests__/integration/asyncJobWorker.integ.test.ts index 52aee7b..e523025 100644 --- a/src/__tests__/integration/asyncJobWorker.integ.test.ts +++ b/src/__tests__/integration/asyncJobWorker.integ.test.ts @@ -7,6 +7,7 @@ import { MockBridgeServer } from './helpers/mockBridgeServer'; const COIN = 'tbtc'; const WALLET_ID = 'test-wallet-id'; const JOB_ID = 'integ-job-123'; +const CPFP_TX_ID = 'b8a828b98dbf32d9fd1875cbace9640ceb8c82626716b4a64203fdc79bb46d26'; const USER_XPUB = 'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH'; @@ -115,6 +116,49 @@ function makeAwaitingBitgoSignJob(overrides: Partial = {}): B }; } +function makeAwaitingBitgoAccelerateJob( + 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: 'accelerate', + wpSubmitParams: { + pubkey: USER_XPUB, + source: 'user', + cpfpTxIds: [CPFP_TX_ID], + cpfpFeeRate: 50, + maxFee: 10000, + recipients: [], + txFormat: 'psbt-lite', + }, + }, + }, + createdAt: 1717977600, + updatedAt: 1717977600, + ttl: 3600, + ...overrides, + }; +} + describe('asyncJobWorker: end-to-end polling', () => { let services: IntegServices; @@ -217,6 +261,33 @@ describe('asyncJobWorker: end-to-end polling', () => { (patchCall.body as { status: string }).status.should.equal('failed'); }); + 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()]); + + 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 { cpfpTxIds?: string[]; txHex?: string }; + sendBody.should.have.property('cpfpTxIds').which.deepEqual([CPFP_TX_ID]); + 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([ diff --git a/src/masterBitgoExpress/handlers/handleAccelerate.ts b/src/masterBitgoExpress/handlers/handleAccelerate.ts index aab90ed..aa044de 100644 --- a/src/masterBitgoExpress/handlers/handleAccelerate.ts +++ b/src/masterBitgoExpress/handlers/handleAccelerate.ts @@ -1,4 +1,12 @@ -import { RequestTracer, KeyIndices } from '@bitgo-beta/sdk-core'; +import { + AccelerateTransactionOptions, + BaseCoin, + Keychain, + PrebuildTransactionOptions, + RequestTracer, + KeyIndices, + Wallet, +} from '@bitgo-beta/sdk-core'; import logger from '../../shared/logger'; import { MasterApiSpecRouteRequest } from '../routers/masterBitGoExpressApiSpec'; import { @@ -6,12 +14,62 @@ import { makeCustomSigningFunction, getWalletPubs, } from './utils/utils'; +import { isUtxoCoin } from '../../shared/coinUtils'; +import { BadRequestError } from '../../shared/errors'; +import { AsyncJobResponse } from '../clients/bridgeClient.types'; +import { buildMultisigSignBody, submitMultisigSignJob } from './utils/multisigSignUtils'; +import { orThrow } from '../../shared/utils'; + +async function handleAccelerateAsync(params: { + req: MasterApiSpecRouteRequest<'v1.wallet.accelerate', 'post'>; + coin: string; + walletId: string; + baseCoin: BaseCoin; + wallet: Wallet; + signingKeychain: Keychain; + walletPubs: string[] | undefined; + accelerationParams: PrebuildTransactionOptions; + requestTracer: RequestTracer; +}): Promise { + const txPrebuilt = await params.wallet.prebuildTransaction(params.accelerationParams); + + const verified = await params.baseCoin.verifyTransaction({ + txParams: { ...params.accelerationParams }, + txPrebuild: txPrebuilt, + wallet: params.wallet, + verification: {}, + reqId: params.requestTracer, + walletType: params.wallet.multisigType(), + }); + if (!verified) { + throw new BadRequestError('Transaction prebuild failed local validation'); + } + + 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: 'accelerate', + wpSubmitParams: params.accelerationParams, + }, + ), + 'async accelerate job submission failed', + ); +} export async function handleAccelerate( req: MasterApiSpecRouteRequest<'v1.wallet.accelerate', '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 +80,42 @@ export async function handleAccelerate( coin, walletId, params, - reqId, + reqId: requestTracer, KeyIndices, }); + const isTss = wallet.multisigType() === 'tss'; + if (isTss && req.config.asyncModeConfig.enabled) { + throw new BadRequestError('Async mode is not yet supported for TSS accelerate'); + } + const walletPubs = await getWalletPubs({ baseCoin, wallet }); + const accelerationParams = { + ...params, + /** + * SDK validateAccelerationParams requires recipients to be [] when present (CPFP/RBF builds from tx ids, not recipients). + */ + recipients: [] as AccelerateTransactionOptions['recipients'], + reqId: requestTracer, + ...(isUtxoCoin(baseCoin) && { txFormat: 'psbt-lite' }), + } satisfies PrebuildTransactionOptions; + try { - // Create custom signing function that delegates to EBE + if (req.config.asyncModeConfig.enabled) { + return await handleAccelerateAsync({ + req, + coin, + walletId, + baseCoin, + wallet, + signingKeychain, + walletPubs, + accelerationParams, + requestTracer, + }); + } + const customSigningFunction = makeCustomSigningFunction({ awmClient, source: params.source, @@ -37,18 +123,10 @@ export async function handleAccelerate( walletPubs, }); - // Prepare acceleration parameters - const accelerationParams = { - ...params, + return wallet.accelerateTransaction({ + ...accelerationParams, customSigningFunction, - reqId, - txFormat: 'psbt-lite', - }; - - // 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); diff --git a/src/masterBitgoExpress/handlers/handleSendMany.ts b/src/masterBitgoExpress/handlers/handleSendMany.ts index f525391..b6835f7 100644 --- a/src/masterBitgoExpress/handlers/handleSendMany.ts +++ b/src/masterBitgoExpress/handlers/handleSendMany.ts @@ -16,7 +16,7 @@ import coinFactory from '../../shared/coinFactory'; import { getWalletPubs } from './utils/utils'; import { isUtxoCoin } from '../../shared/coinUtils'; import { buildMultisigSignBody, submitMultisigSignJob } from './utils/multisigSignUtils'; -import { submitSignedMultisigToWp } from './utils/multisigSubmitUtils'; +import { WP_SUBMIT_HANDLERS } from './utils/multisigSubmitUtils'; /** * Defines the structure for a single recipient in a send-many transaction. @@ -208,7 +208,12 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s logger.debug(`Signing keychain: ${JSON.stringify(signingKeychain, null, 2)}`); const signedTx = await awmClient.signMultisig(signBody); - return submitSignedMultisigToWp(wallet, signedTx, prebuildParams, reqId); + return WP_SUBMIT_HANDLERS.sendMany({ + wallet, + signedTx, + wpSubmitParams: prebuildParams, + requestTracer: reqId, + }); } catch (error) { const err = error as Error; logger.error('Failed to send many: %s', err.message); diff --git a/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts index 48422ba..947500f 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'] as const; +export const WP_SUBMIT_KINDS = ['sendMany', 'accelerate'] 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 28dcffd..677c6d9 100644 --- a/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts +++ b/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts @@ -1,17 +1,48 @@ +import _ from 'lodash'; import { RequestTracer, SendManyOptions, SignedTransaction, Wallet } from '@bitgo-beta/sdk-core'; +import { WpSubmitKind } from './multisigSignUtils'; -export async function submitSignedMultisigToWp( - wallet: Wallet, - signedTx: SignedTransaction, - params: SendManyOptions, - reqId: RequestTracer, +export type WpSubmitArgs = { + wallet: Wallet; + signedTx: SignedTransaction; + wpSubmitParams: Record; +}; +export type WpSubmitHandlerArgs = WpSubmitArgs & { requestTracer: RequestTracer }; +export type ComposeFinalTxParams = ( + args: WpSubmitArgs, +) => Promise> | Record; + +export async function submitSignedTxToWp( + args: WpSubmitHandlerArgs, + composeFinalTxParams: ComposeFinalTxParams, ): Promise> { - const extraParams = await wallet.baseCoin.getExtraPrebuildParams({ - ...params, - wallet, - }); + const finalTxParams = await composeFinalTxParams(args); + return (await args.wallet.submitTransaction(finalTxParams, args.requestTracer)) as Record< + string, + unknown + >; +} - const finalTxParams = { ...signedTx, ...extraParams }; +export const WP_SUBMIT_HANDLERS: Record< + WpSubmitKind, + (args: WpSubmitHandlerArgs) => Promise> +> = { + sendMany: (args) => + submitSignedTxToWp(args, async ({ wallet, signedTx, wpSubmitParams }) => { + const extraParams = await wallet.baseCoin.getExtraPrebuildParams({ + ...(wpSubmitParams as SendManyOptions), + wallet, + }); + return { ...signedTx, ...extraParams }; + }), - return (await wallet.submitTransaction(finalTxParams, reqId)) as Record; -} + /** + * Same field whitelist as SDK's Wallet.accelerateTransaction + * https://github.com/BitGo/BitGoJS/blob/c0b8a699231f81119a02cced5b2c7fe16fae9757/modules/sdk-core/src/bitgo/wallet/wallet.ts#L298-L301 + */ + accelerate: (args) => + submitSignedTxToWp(args, ({ wallet, signedTx, wpSubmitParams }) => ({ + ...signedTx, + ..._.pick(wpSubmitParams, wallet.prebuildWhitelistedParams()), + })), +}; diff --git a/src/masterBitgoExpress/routers/accelerateRoute.ts b/src/masterBitgoExpress/routers/accelerateRoute.ts index b1d3c76..3e9f854 100644 --- a/src/masterBitgoExpress/routers/accelerateRoute.ts +++ b/src/masterBitgoExpress/routers/accelerateRoute.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 AccelerateRequest = { /** @@ -65,6 +66,7 @@ const AccelerateResponse: HttpResponse = { * @example { "txid": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", "tx": "01000000000101edd7a5d948a6c79f273ce686a6a8f2e96ed8c2583b5e77b866aa2a1b3426fbed0100000000ffffffff02102700000000000017a914192f23283c2a9e6c5d11562db0eb5d4eb47f460287b9bc2c000000000017a9145c139b242ab3701f321d2399d3a11b028b3b361e870247304402206ac9477fece38d96688c6c3719cb27396c0563ead0567457e7e884b406b6da8802201992d1cfa1b55a67ce8acb482e9957812487d2555f5f54fb0286ecd3095d78e4012103c92564575197c4d6e3d9792280e7548b3ba52a432101c62de2186c4e2fa7fc580000000000" } */ 200: AccelerateResponseCodec, + 202: AsyncJobResponseCodec, ...ErrorResponses, }; diff --git a/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts b/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts index e0bb94e..5563fe1 100644 --- a/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts +++ b/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts @@ -44,7 +44,8 @@ export function parseBody(req: express.Request, res: express.Response, next: exp */ type MasterBitGoAPIHandlerResponses = | Awaited> - | Awaited>; + | Awaited> + | Awaited>; function toApiResponse(result: MasterBitGoAPIHandlerResponses) { return 'jobId' in result ? Response.accepted(result) : Response.ok(result); @@ -153,7 +154,7 @@ export function createMasterApiRouter( responseHandler(async (req: express.Request) => { const typedReq = req as GenericMasterApiSpecRouteRequest; const result = await handleAccelerate(typedReq); - return Response.ok(result); + return toApiResponse(result); }), ]); diff --git a/src/masterBitgoExpress/workers/asyncJobWorker.ts b/src/masterBitgoExpress/workers/asyncJobWorker.ts index 9548b3f..9c4d959 100644 --- a/src/masterBitgoExpress/workers/asyncJobWorker.ts +++ b/src/masterBitgoExpress/workers/asyncJobWorker.ts @@ -1,5 +1,5 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; -import { RequestTracer, SendManyOptions, SignedTransaction, Wallet } from '@bitgo-beta/sdk-core'; +import { RequestTracer, SignedTransaction } from '@bitgo-beta/sdk-core'; import { OsoBridgeClient } from '../clients/bridgeClient'; import { AwmResponseSchema, BridgeJobResponse } from '../clients/bridgeClient.types'; import { @@ -13,9 +13,8 @@ import { createOnchainKeyGenCallbackForPreGeneratedKeychains } from '../handlers import { parseMultisigSignJobContext, parseSignedMultisigTransaction, - WpSubmitKind, } from '../handlers/utils/multisigSignUtils'; -import { submitSignedMultisigToWp } from '../handlers/utils/multisigSubmitUtils'; +import { WP_SUBMIT_HANDLERS } from '../handlers/utils/multisigSubmitUtils'; const ASYNC_OPERATIONS_TO_HANDLERS: Partial< Record< @@ -27,19 +26,6 @@ const ASYNC_OPERATIONS_TO_HANDLERS: Partial< multisig_sign: handleMultisigSignOperation, }; -const WP_SUBMIT_HANDLERS: Record< - WpSubmitKind, - ( - wallet: Wallet, - signedTx: SignedTransaction, - wpSubmitParams: Record, - reqId: RequestTracer, - ) => Promise> -> = { - sendMany: (wallet, signedTx, wpSubmitParams, reqId) => - submitSignedMultisigToWp(wallet, signedTx, wpSubmitParams as SendManyOptions, reqId), -}; - function parseAwmResponseBody( awmResponse: BridgeJobResponse['awmResponse'], field: string, @@ -181,13 +167,18 @@ export async function handleMultisigSignOperation( const { walletId, wpSubmitKind, wpSubmitParams } = parseMultisigSignJobContext(job.request?.body); const submitHandler = WP_SUBMIT_HANDLERS[wpSubmitKind]; const { jobId, coin, version } = job; - const reqId = new RequestTracer(); + const requestTracer = new RequestTracer(); const baseCoin = await coinFactory.getCoin(coin, bitgo); - const wallet = await baseCoin.wallets().get({ id: walletId, reqId }); + const wallet = await baseCoin.wallets().get({ id: walletId, reqId: requestTracer }); logger.info(`${logPrefix} submitting job ${jobId} to wallet platform`); - const result = await submitHandler(wallet, signedTx, wpSubmitParams, reqId); + const result = await submitHandler({ + wallet, + signedTx, + wpSubmitParams, + requestTracer, + }); logger.info(`${logPrefix} job ${jobId} submitted transaction - updating job status to complete`); await bridge.updateJob({ diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..3dec6b8 --- /dev/null +++ b/src/shared/utils.ts @@ -0,0 +1,11 @@ +/** + * @param value Value to check for existence + * @param message Error message to throw if value is null or undefined + * @returns + */ +export function orThrow(value: T | null | undefined, message: string): T { + if (value == null) { + throw new Error(message); + } + return value; +}