diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index a0245e5..a88195c 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -797,6 +797,16 @@ } } }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncJobResponseCodec" + } + } + } + }, "400": { "description": "Bad Request", "content": { diff --git a/src/__tests__/api/master/asyncJobWorker.test.ts b/src/__tests__/api/master/asyncJobWorker.test.ts index 3b1c657..005cb09 100644 --- a/src/__tests__/api/master/asyncJobWorker.test.ts +++ b/src/__tests__/api/master/asyncJobWorker.test.ts @@ -9,6 +9,7 @@ import { startAsyncJobWorker, processPendingJobs, handleKeyGenerationOperation, + handleMultisigSignOperation, } from '../../../masterBitgoExpress/workers/asyncJobWorker'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils'; @@ -138,6 +139,61 @@ function nockUpdateJobComplete(jobId: string, walletId: string) { .reply(204); } +function makeSignJob(overrides: Partial = {}): BridgeJobResponse { + return { + jobId: 'job-sign-123', + status: 'awaiting_bitgo', + version: 1, + coin: COIN, + operationType: 'multisig_sign', + awmResponse: awmOk({ txHex: 'signed-tx-hex' }), + request: { + endpoint: `/api/${COIN}/multisig/sign`, + method: 'POST', + body: { + source: 'user', + pub: 'xpub_user', + txPrebuild: { txHex: '70736274ff' }, + walletId: 'test-wallet-id', + wpSubmitKind: 'sendMany', + wpSubmitParams: { + recipients: [{ address: 'tb1qtest1', amount: '100000' }], + source: 'user', + }, + }, + }, + createdAt: 1717977600, + updatedAt: 1717977600, + ttl: 3600, + ...overrides, + }; +} + +function nockWalletGet(walletId: string) { + return nock(BITGO_API_URL) + .get(`/api/v2/${COIN}/wallet/${walletId}`) + .matchHeader('any', () => true) + .reply(200, { + id: walletId, + type: 'advanced', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'onchain', + }); +} + +function nockTxSend(walletId: string, txid: string) { + return nock(BITGO_API_URL) + .post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`) + .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) + .reply(204); +} + describe('asyncJobWorker', () => { let bitgo: BitGoAPI; let bridge: OsoBridgeClient; @@ -287,7 +343,7 @@ describe('asyncJobWorker', () => { }); it('skips jobs with unknown operationType', async () => { - const job = makeJob({ operationType: 'multisig_sign' }); + const job = makeJob({ operationType: 'mpc_sign' }); const n = nock(BRIDGE_URL) .get('/jobs') @@ -415,4 +471,65 @@ describe('asyncJobWorker', () => { nock.pendingMocks().should.have.length(0); }); }); + + describe('handleMultisigSignOperation()', () => { + it('submits signed tx to WP and PATCHes job complete', async () => { + const job = makeSignJob(); + const walletId = 'test-wallet-id'; + const txid = 'test-tx-id'; + + const walletGetNock = nockWalletGet(walletId); + const sendNock = nockTxSend(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 }); + + await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected(); + }); + + it('throws when awmResponse.body is not a valid signed transaction', async () => { + const job = makeSignJob({ + awmResponse: { status: 200, body: { bad: 'shape' } }, + }); + + await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejectedWith( + /expected txHex or halfSigned/, + ); + }); + + it('throws when request.body is missing walletId', async () => { + const job = makeSignJob({ + request: { + endpoint: `/api/${COIN}/multisig/sign`, + method: 'POST', + body: { wpSubmitKind: 'sendMany', wpSubmitParams: { recipients: [] } }, + }, + }); + + await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejectedWith( + /missing walletId/, + ); + }); + + it('throws when WP tx submit fails', async () => { + const job = makeSignJob(); + const walletId = 'test-wallet-id'; + + nockWalletGet(walletId); + nock(BITGO_API_URL) + .post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`) + .matchHeader('any', () => true) + .reply(500, { message: 'submit failed' }); + + await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected(); + }); + }); }); diff --git a/src/__tests__/api/master/multisigSignUtils.test.ts b/src/__tests__/api/master/multisigSignUtils.test.ts new file mode 100644 index 0000000..3c6c60e --- /dev/null +++ b/src/__tests__/api/master/multisigSignUtils.test.ts @@ -0,0 +1,216 @@ +import 'should'; +import assert from 'assert'; +import nock from 'nock'; +import sinon from 'sinon'; +import { Keychain, PrebuildTransactionResult } from '@bitgo-beta/sdk-core'; +import { + buildMultisigSignBody, + parseMultisigSignJobContext, + SignedMultisigTransactionSchema, + submitMultisigSignJob, +} from '../../../masterBitgoExpress/handlers/utils/multisigSignUtils'; +import { AppMode, KeySource, MasterExpressConfig } from '../../../shared/types'; +import { BitGoRequest } from '../../../types/request'; +import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils'; +import { OsoBridgeClient } from '../../../masterBitgoExpress/clients/bridgeClient'; + +describe('multisigSignUtils', () => { + const bridgeUrl = 'http://bridge.invalid'; + const coin = 'tbtc'; + const txPrebuild = { + txHex: '70736274ff', + txInfo: { nP2SHInputs: 0, nSegwitInputs: 1, nOutputs: 1 }, + walletId: 'test-wallet-id', + } as PrebuildTransactionResult; + const userPub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + + describe('buildMultisigSignBody', () => { + it('builds the AWM multisig sign payload', () => { + const body = buildMultisigSignBody({ + source: 'user', + signingKeychain: { pub: userPub, source: 'user' } as Keychain, + txPrebuilt: txPrebuild, + walletPubs: [userPub, 'backup-pub', 'bitgo-pub'], + }); + + body.should.eql({ + source: 'user', + pub: userPub, + txPrebuild, + walletPubs: [userPub, 'backup-pub', 'bitgo-pub'], + }); + }); + + it('omits walletPubs when not provided', () => { + const body = buildMultisigSignBody({ + source: 'backup', + signingKeychain: { pub: userPub, source: 'backup' } as Keychain, + txPrebuilt: txPrebuild, + }); + + body.should.eql({ + source: 'backup', + pub: userPub, + txPrebuild, + }); + assert(!('walletPubs' in body)); + }); + + it('throws when signing keychain pub is missing', () => { + (() => + buildMultisigSignBody({ + source: 'user', + signingKeychain: { source: 'user' } as Keychain, + txPrebuilt: txPrebuild, + })).should.throw(/Signing keychain pub not found for user/); + }); + }); + + describe('submitMultisigSignJob', () => { + afterEach(() => { + sinon.restore(); + nock.cleanAll(); + }); + + function makeAsyncReq(): BitGoRequest { + return { + config: { + appMode: AppMode.MASTER_EXPRESS, + asyncModeConfig: { + enabled: true, + awmAsyncUrl: bridgeUrl, + pollIntervalInMs: 30000, + jobTtlInSeconds: 3600, + jobTtlMpcInSeconds: 7200, + }, + } as MasterExpressConfig, + bridgeClient: new OsoBridgeClient(bridgeUrl, 60000), + } as unknown as BitGoRequest; + } + + it('returns null when async mode is disabled', async () => { + const req = { + config: { asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG }, + } as BitGoRequest; + + const result = await submitMultisigSignJob( + req, + coin, + { + source: 'user', + pub: userPub, + txPrebuild, + }, + { + walletId: 'test-wallet-id', + wpSubmitKind: 'sendMany', + wpSubmitParams: { recipients: [] }, + }, + ); + + assert.strictEqual(result, null); + }); + + it('submits multisig_sign to the bridge with correct path and headers', async () => { + const jobId = 'job-123'; + const signBody = buildMultisigSignBody({ + source: 'user', + signingKeychain: { pub: userPub, source: 'user' } as Keychain, + txPrebuilt: txPrebuild, + }); + const jobContext = { + walletId: 'test-wallet-id', + wpSubmitKind: 'sendMany' as const, + wpSubmitParams: { recipients: [{ address: 'tb1qtest', amount: '100000' }] }, + }; + + const bridgeNock = nock(bridgeUrl) + .post(`/api/${coin}/multisig/sign`, (body) => { + body.should.eql({ ...signBody, ...jobContext }); + return true; + }) + .matchHeader('X-OSO-Source', KeySource.USER) + .matchHeader('X-OSO-Operation', 'multisig_sign') + .reply(202, { jobId }); + + const result = await submitMultisigSignJob(makeAsyncReq(), coin, signBody, jobContext); + assert(result); + result.should.eql({ jobId, status: 'pending' }); + bridgeNock.done(); + }); + }); + + describe('SignedMultisigTransactionSchema', () => { + it('accepts a top-level txHex', () => { + SignedMultisigTransactionSchema.parse({ txHex: 'signed-tx-hex' }).should.eql({ + txHex: 'signed-tx-hex', + }); + }); + + it('accepts halfSigned.txHex from UTXO user signing', () => { + SignedMultisigTransactionSchema.parse({ + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: userPub, + }).should.eql({ + halfSigned: { txHex: 'signed-tx-hex' }, + source: 'user', + pub: userPub, + }); + }); + + it('accepts halfSigned.signature from ETH-style signing', () => { + SignedMultisigTransactionSchema.parse({ + halfSigned: { + signature: '0xabc', + operationHash: '0xdef', + recipients: [{ address: '0x123', amount: '1000' }], + }, + }).should.eql({ + halfSigned: { + signature: '0xabc', + operationHash: '0xdef', + recipients: [{ address: '0x123', amount: '1000' }], + }, + }); + }); + + it('rejects bodies missing txHex and halfSigned', () => { + (() => SignedMultisigTransactionSchema.parse({ bad: 'shape' })).should.throw( + /expected txHex or halfSigned/, + ); + }); + }); + + describe('parseMultisigSignJobContext', () => { + it('parses walletId, wpSubmitKind, and wpSubmitParams from job body', () => { + parseMultisigSignJobContext({ + walletId: 'test-wallet-id', + wpSubmitKind: 'sendMany', + wpSubmitParams: { recipients: [] }, + source: 'user', + pub: userPub, + }).should.eql({ + walletId: 'test-wallet-id', + wpSubmitKind: 'sendMany', + wpSubmitParams: { recipients: [] }, + }); + }); + + it('throws when wpSubmitKind is missing or unsupported', () => { + (() => + parseMultisigSignJobContext({ + walletId: 'test-wallet-id', + wpSubmitParams: { recipients: [] }, + })).should.throw(/unsupported wpSubmitKind/); + + (() => + parseMultisigSignJobContext({ + walletId: 'test-wallet-id', + wpSubmitKind: 'accelerate', + wpSubmitParams: { recipients: [] }, + })).should.throw(/unsupported wpSubmitKind: accelerate/); + }); + }); +}); diff --git a/src/__tests__/api/master/sendMany.test.ts b/src/__tests__/api/master/sendMany.test.ts index ae4ebea..443401a 100644 --- a/src/__tests__/api/master/sendMany.test.ts +++ b/src/__tests__/api/master/sendMany.test.ts @@ -3,8 +3,11 @@ import sinon from 'sinon'; import * as request from 'supertest'; import nock from 'nock'; +import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { app as expressApp } from '../../../masterBitGoExpressApp'; -import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import { AppMode, KeySource, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import * as middleware from '../../../shared/middleware'; +import { BitGoRequest } from '../../../types/request'; import { Environments, openpgpUtils } from '@bitgo-beta/sdk-core'; import * as utxolib from '@bitgo-beta/utxo-lib'; import { Tbtc } from '@bitgo-beta/sdk-coin-btc'; @@ -471,6 +474,169 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { signNock.done(); submitNock.done(); }); + + 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 asyncApp = expressApp(asyncConfig); + const asyncAgent = request.agent(asyncApp); + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/wallet/${walletId}`) + .matchHeader('any', () => true) + .reply(200, { + id: walletId, + type: 'advanced', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'onchain', + }); + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/user-key-id`) + .matchHeader('any', () => true) + .times(2) + .reply(200, { id: 'user-key-id', pub: 'xpub_user' }); + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/backup-key-id`) + .matchHeader('any', () => true) + .reply(200, { id: 'backup-key-id', pub: 'xpub_backup' }); + + nock(bitgoApiUrl) + .get(`/api/v2/${coin}/key/bitgo-key-id`) + .matchHeader('any', () => true) + .reply(200, { id: 'bitgo-key-id', pub: 'xpub_bitgo' }); + + 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 }); + + 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 response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + recipients: [{ address: 'tb1qtest1', amount: '100000' }], + source: 'user', + pubkey: 'xpub_user', + }); + + response.status.should.equal(202); + response.body.should.have.property('jobId', jobId); + response.body.should.have.property('status', 'pending'); + bridgeNock.done(); + awmSignNock.isDone().should.be.false(); + }); + + 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 asyncApp = expressApp(asyncConfig); + const asyncAgent = request.agent(asyncApp); + + nock(bitgoApiUrl) + .get(`/api/v2/${tssCoin}/wallet/${walletId}`) + .matchHeader('any', () => true) + .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('any', () => true) + .reply(200, { id: 'user-key-id', pub: 'xpub_user' }); + + const response = await asyncAgent + .post(`/api/v1/${tssCoin}/advancedwallet/${walletId}/sendMany`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + recipients: [{ address: 'test-address', amount: '100000' }], + source: 'user', + pubkey: 'xpub_user', + }); + + response.status.should.equal(400); + response.body.details.should.containEql('Async mode is not yet supported for TSS sendMany'); + }); }); describe('SendMany TSS EDDSA:', () => { diff --git a/src/__tests__/integration/asyncJobWorker.integ.test.ts b/src/__tests__/integration/asyncJobWorker.integ.test.ts index cfa0160..52aee7b 100644 --- a/src/__tests__/integration/asyncJobWorker.integ.test.ts +++ b/src/__tests__/integration/asyncJobWorker.integ.test.ts @@ -5,6 +5,11 @@ import { BridgeJobResponse } from '../../masterBitgoExpress/clients/bridgeClient import { MockBridgeServer } from './helpers/mockBridgeServer'; const COIN = 'tbtc'; +const WALLET_ID = 'test-wallet-id'; +const JOB_ID = 'integ-job-123'; + +const USER_XPUB = + 'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH'; async function waitForJobCompletion( bridge: MockBridgeServer, @@ -31,7 +36,7 @@ async function waitForJobCompletion( function makeAwaitingBitgoJob(overrides: Partial = {}): BridgeJobResponse { return { - jobId: 'integ-job-123', + jobId: JOB_ID, status: 'awaiting_bitgo', version: 1, coin: COIN, @@ -68,6 +73,48 @@ 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', + }, + }, + }, + createdAt: 1717977600, + updatedAt: 1717977600, + ttl: 3600, + ...overrides, + }; +} + describe('asyncJobWorker: end-to-end polling', () => { let services: IntegServices; @@ -86,7 +133,7 @@ describe('asyncJobWorker: end-to-end polling', () => { }); it('picks up an awaiting_bitgo keygen job, creates wallet, and PATCHes complete', async () => { - const jobId = 'integ-job-123'; + const jobId = JOB_ID; assert(services.bridge, 'bridge service should be defined'); services.bridge.setPendingJobs([makeAwaitingBitgoJob()]); @@ -110,7 +157,7 @@ describe('asyncJobWorker: end-to-end polling', () => { }); it('PATCHes job failed when awmResponse.body is not a valid keychain', async () => { - const jobId = 'integ-job-123'; + const jobId = JOB_ID; assert(services.bridge, 'bridge service should be defined'); services.bridge.setPendingJobs([ makeAwaitingBitgoJob({ @@ -126,4 +173,76 @@ describe('asyncJobWorker: end-to-end polling', () => { assert(patchCall !== undefined, `expected PATCH /job/${jobId} to be called`); (patchCall.body as { status: string }).status.should.equal('failed'); }); + + 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()]); + + 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 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 awmResponse.body is not a valid signed transaction', async () => { + assert(services.bridge, 'bridge service should be defined'); + services.bridge.setPendingJobs([ + makeAwaitingBitgoSignJob({ + awmResponse: { status: 200, body: { bad: 'shape' } }, + }), + ]); + + await waitForJobCompletion(services.bridge, JOB_ID, 5000); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(0); + + 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`); + (patchCall.body as { status: string }).status.should.equal('failed'); + }); + + 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({ + request: { + endpoint: `/api/${COIN}/multisig/sign`, + method: 'POST', + body: { + source: 'user', + pub: USER_XPUB, + wpSubmitKind: 'sendMany', + wpSubmitParams: { recipients: [] }, + }, + }, + }), + ]); + + await waitForJobCompletion(services.bridge, JOB_ID, 5000); + + const sendCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')); + sendCalls.should.have.length(0); + + 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`); + (patchCall.body as { status: string }).status.should.equal('failed'); + }); }); diff --git a/src/masterBitgoExpress/handlers/handleSendMany.ts b/src/masterBitgoExpress/handlers/handleSendMany.ts index 17e084d..f525391 100644 --- a/src/masterBitgoExpress/handlers/handleSendMany.ts +++ b/src/masterBitgoExpress/handlers/handleSendMany.ts @@ -3,9 +3,7 @@ import { PrebuildTransactionOptions, Memo, KeyIndices, - Wallet, SendManyOptions, - PrebuildTransactionResult, Keychain, } from '@bitgo-beta/sdk-core'; import logger from '../../shared/logger'; @@ -17,6 +15,8 @@ import { BadRequestError, NotFoundError } from '../../shared/errors'; 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'; /** * Defines the structure for a single recipient in a send-many transaction. @@ -107,6 +107,10 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s } const isTss = wallet.multisigType() === 'tss'; + if (isTss && req.config.asyncModeConfig.enabled) { + throw new BadRequestError('Async mode is not yet supported for TSS sendMany'); + } + if (isTss) { if (!params.commonKeychain) { throw new BadRequestError(`commonKeychain must be provided for TSS ${params.source} signing`); @@ -183,57 +187,31 @@ export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.s const walletPubs = await getWalletPubs({ baseCoin, wallet }); - return signAndSendMultisig( - wallet, - req.decoded.source, - txPrebuilt, - prebuildParams, - awmClient, + const signBody = buildMultisigSignBody({ + source: req.decoded.source, signingKeychain, - reqId, + txPrebuilt, walletPubs, - ); + }); + + /** When run in async mode, submit the job via the bridge client. Fall back to sync-mode, otherwise */ + const asyncResult = await submitMultisigSignJob(req, req.params.coin, signBody, { + walletId: req.params.walletId, + wpSubmitKind: 'sendMany', + wpSubmitParams: prebuildParams, + }); + if (asyncResult) { + return asyncResult; + } + + logger.info(`Signing with ${req.decoded.source} keychain, pub: ${signBody.pub}`); + logger.debug(`Signing keychain: ${JSON.stringify(signingKeychain, null, 2)}`); + + const signedTx = await awmClient.signMultisig(signBody); + return submitSignedMultisigToWp(wallet, signedTx, prebuildParams, reqId); } catch (error) { const err = error as Error; logger.error('Failed to send many: %s', err.message); throw err; } } - -export async function signAndSendMultisig( - wallet: Wallet, - source: 'user' | 'backup', - txPrebuilt: PrebuildTransactionResult, - params: SendManyOptions, - awmClient: AdvancedWalletManagerClient, - signingKeychain: Keychain, - reqId: RequestTracer, - walletPubs?: string[], -) { - if (!signingKeychain.pub) { - throw new BadRequestError(`Signing keychain pub not found for ${source}`); - } - logger.info(`Signing with ${source} keychain, pub: ${signingKeychain.pub}`); - logger.debug(`Signing keychain: ${JSON.stringify(signingKeychain, null, 2)}`); - - // Then sign it using the advanced wallet manager client - const signedTx = await awmClient.signMultisig({ - source, - walletPubs, - txPrebuild: txPrebuilt, - pub: signingKeychain.pub, - }); - - // Get extra prebuild parameters - const extraParams = await wallet.baseCoin.getExtraPrebuildParams({ - ...params, - wallet, - }); - - // Combine the signed transaction with extra parameters - const finalTxParams = { ...signedTx, ...extraParams }; - - // Submit the half signed transaction - const result = (await wallet.submitTransaction(finalTxParams, reqId)) as any; - return result; -} diff --git a/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts new file mode 100644 index 0000000..48422ba --- /dev/null +++ b/src/masterBitgoExpress/handlers/utils/multisigSignUtils.ts @@ -0,0 +1,107 @@ +import { Keychain, PrebuildTransactionResult, SignedTransaction } from '@bitgo-beta/sdk-core'; +import { z } from 'zod'; +import { AsyncJobResponse } from '../../clients/bridgeClient.types'; +import { BadRequestError } from '../../../shared/errors'; +import { KeySource, MasterExpressConfig, UserOrBackupKey } from '../../../shared/types'; +import { BitGoRequest } from '../../../types/request'; +import { submitJobViaBridgeClient } from './asyncUtils'; + +export type MultisigSignSource = 'user' | 'backup'; + +export type MultisigSignBody = { + source: MultisigSignSource; + pub: string; + txPrebuild: PrebuildTransactionResult; + walletPubs?: string[]; +}; + +/** Minimal shape /sign from bridge client; bridge returns it as unknown */ +export const SignedMultisigTransactionSchema = z + .object({ + txHex: z.string().optional(), + halfSigned: z.record(z.unknown()).optional(), + }) + .passthrough() + .refine( + (body) => + typeof body.txHex === 'string' || + (typeof body.halfSigned === 'object' && body.halfSigned !== null), + { message: 'expected txHex or halfSigned' }, + ); + +export type SignedMultisigTransaction = z.infer; + +export function parseSignedMultisigTransaction(body: unknown): SignedTransaction { + return SignedMultisigTransactionSchema.parse(body) as SignedTransaction; +} + +export const WP_SUBMIT_KINDS = ['sendMany'] as const; +export type WpSubmitKind = (typeof WP_SUBMIT_KINDS)[number]; + +export function isWpSubmitKind(value: unknown): value is WpSubmitKind { + return typeof value === 'string' && (WP_SUBMIT_KINDS as readonly string[]).includes(value); +} + +export type MultisigSignJobContext = { + walletId: string; + wpSubmitKind: WpSubmitKind; + wpSubmitParams: Record; +}; + +export function parseMultisigSignJobContext( + body: Record | undefined, +): MultisigSignJobContext { + if (!body || typeof body.walletId !== 'string') { + throw new Error('job request.body missing walletId'); + } + if (!isWpSubmitKind(body.wpSubmitKind)) { + throw new Error( + `job request.body missing or unsupported wpSubmitKind: ${String(body.wpSubmitKind)}`, + ); + } + if (!body.wpSubmitParams || typeof body.wpSubmitParams !== 'object') { + throw new Error('job request.body missing wpSubmitParams'); + } + return { + walletId: body.walletId, + wpSubmitKind: body.wpSubmitKind, + wpSubmitParams: body.wpSubmitParams as Record, + }; +} + +const SOURCE_TO_KEY_SOURCE = { + user: KeySource.USER, + backup: KeySource.BACKUP, +} as const satisfies Record; + +export function buildMultisigSignBody(params: { + source: MultisigSignSource; + signingKeychain: Keychain; + txPrebuilt: PrebuildTransactionResult; + walletPubs?: string[]; +}): MultisigSignBody { + if (!params.signingKeychain.pub) { + throw new BadRequestError(`Signing keychain pub not found for ${params.source}`); + } + + return { + source: params.source, + pub: params.signingKeychain.pub, + txPrebuild: params.txPrebuilt, + ...(params.walletPubs && { walletPubs: params.walletPubs }), + }; +} + +export async function submitMultisigSignJob( + req: BitGoRequest, + coin: string, + signBody: MultisigSignBody, + jobContext: MultisigSignJobContext, +): Promise { + return submitJobViaBridgeClient(req, { + path: `/api/${coin}/multisig/sign`, + body: { ...signBody, ...jobContext }, + sources: [SOURCE_TO_KEY_SOURCE[signBody.source]], + operationType: 'multisig_sign', + }); +} diff --git a/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts new file mode 100644 index 0000000..28dcffd --- /dev/null +++ b/src/masterBitgoExpress/handlers/utils/multisigSubmitUtils.ts @@ -0,0 +1,17 @@ +import { RequestTracer, SendManyOptions, SignedTransaction, Wallet } from '@bitgo-beta/sdk-core'; + +export async function submitSignedMultisigToWp( + wallet: Wallet, + signedTx: SignedTransaction, + params: SendManyOptions, + reqId: RequestTracer, +): Promise> { + const extraParams = await wallet.baseCoin.getExtraPrebuildParams({ + ...params, + wallet, + }); + + const finalTxParams = { ...signedTx, ...extraParams }; + + return (await wallet.submitTransaction(finalTxParams, reqId)) as Record; +} diff --git a/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts b/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts index 4a55c08..e0bb94e 100644 --- a/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts +++ b/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts @@ -42,7 +42,9 @@ export function parseBody(req: express.Request, res: express.Response, next: exp /** * TODO: union with other handler response types as they are async-ified (WCN-887 through WCN-893) */ -type MasterBitGoAPIHandlerResponses = Awaited>; +type MasterBitGoAPIHandlerResponses = + | Awaited> + | Awaited>; function toApiResponse(result: MasterBitGoAPIHandlerResponses) { return 'jobId' in result ? Response.accepted(result) : Response.ok(result); @@ -119,7 +121,7 @@ export function createMasterApiRouter( responseHandler(async (req: express.Request) => { const typedReq = req as GenericMasterApiSpecRouteRequest; const result = await handleSendMany(typedReq); - return Response.ok(result); + return toApiResponse(result); }), ]); diff --git a/src/masterBitgoExpress/routers/sendManyRoute.ts b/src/masterBitgoExpress/routers/sendManyRoute.ts index a7f6199..e9e64f4 100644 --- a/src/masterBitgoExpress/routers/sendManyRoute.ts +++ b/src/masterBitgoExpress/routers/sendManyRoute.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 SendManyRequest = { /** @@ -204,6 +205,7 @@ export const SendManyRequest = { export const SendManyResponse: HttpResponse = { 200: t.any, + 202: AsyncJobResponseCodec, ...ErrorResponses, }; diff --git a/src/masterBitgoExpress/workers/asyncJobWorker.ts b/src/masterBitgoExpress/workers/asyncJobWorker.ts index aede98b..9548b3f 100644 --- a/src/masterBitgoExpress/workers/asyncJobWorker.ts +++ b/src/masterBitgoExpress/workers/asyncJobWorker.ts @@ -1,4 +1,5 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; +import { RequestTracer, SendManyOptions, SignedTransaction, Wallet } from '@bitgo-beta/sdk-core'; import { OsoBridgeClient } from '../clients/bridgeClient'; import { AwmResponseSchema, BridgeJobResponse } from '../clients/bridgeClient.types'; import { @@ -9,6 +10,12 @@ import coinFactory from '../../shared/coinFactory'; import { MasterExpressConfig } from '../../shared/types'; import logger from '../../shared/logger'; import { createOnchainKeyGenCallbackForPreGeneratedKeychains } from '../handlers/walletGenerationCallbacks'; +import { + parseMultisigSignJobContext, + parseSignedMultisigTransaction, + WpSubmitKind, +} from '../handlers/utils/multisigSignUtils'; +import { submitSignedMultisigToWp } from '../handlers/utils/multisigSubmitUtils'; const ASYNC_OPERATIONS_TO_HANDLERS: Partial< Record< @@ -17,12 +24,26 @@ const ASYNC_OPERATIONS_TO_HANDLERS: Partial< > > = { multisig_keygen: handleKeyGenerationOperation, + multisig_sign: handleMultisigSignOperation, }; -function parseKeychainFromAwmResponse( +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: 'awmResponse' | 'awmBackupResponse', -): IndependentKeychainResponse { + field: string, +): Record { if (awmResponse === undefined) { throw new Error(`job missing ${field}`); } @@ -34,7 +55,20 @@ function parseKeychainFromAwmResponse( if (r.status >= 400 || r.error) { throw new Error(r.error ?? `AWM ${field} returned status ${r.status}`); } - return IndependentKeychainResponseSchema.parse(r.body); + return r.body; +} + +function parseKeychainFromAwmResponse( + awmResponse: BridgeJobResponse['awmResponse'], + field: 'awmResponse' | 'awmBackupResponse', +): IndependentKeychainResponse { + return IndependentKeychainResponseSchema.parse(parseAwmResponseBody(awmResponse, field)); +} + +function parseSignedTxFromAwmResponse( + awmResponse: BridgeJobResponse['awmResponse'], +): SignedTransaction { + return parseSignedMultisigTransaction(parseAwmResponseBody(awmResponse, 'awmResponse')); } export function startAsyncJobWorker(cfg: MasterExpressConfig): () => void { @@ -136,3 +170,32 @@ export async function handleKeyGenerationOperation( logger.info(`${logPrefix} job ${jobId} complete, walletId ${walletId}`); } + +export async function handleMultisigSignOperation( + job: BridgeJobResponse, + bridge: OsoBridgeClient, + bitgo: BitGoAPI, +): Promise { + const logPrefix = '[asyncJobWorker:handleMultisigSignOperation]'; + const signedTx = parseSignedTxFromAwmResponse(job.awmResponse); + const { walletId, wpSubmitKind, wpSubmitParams } = parseMultisigSignJobContext(job.request?.body); + const submitHandler = WP_SUBMIT_HANDLERS[wpSubmitKind]; + const { jobId, coin, version } = job; + const reqId = new RequestTracer(); + + const baseCoin = await coinFactory.getCoin(coin, bitgo); + const wallet = await baseCoin.wallets().get({ id: walletId, reqId }); + + logger.info(`${logPrefix} submitting job ${jobId} to wallet platform`); + const result = await submitHandler(wallet, signedTx, wpSubmitParams, reqId); + + logger.info(`${logPrefix} job ${jobId} submitted transaction - updating job status to complete`); + await bridge.updateJob({ + jobId, + version, + status: 'complete', + result, + }); + + logger.info(`${logPrefix} job ${jobId} complete`); +}