diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 48f3e68..4599265 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -1475,6 +1475,16 @@ } } }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncJobResponseCodec" + } + } + } + }, "400": { "description": "Bad Request", "content": { @@ -1656,6 +1666,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 12b4a76..ab69c02 100644 --- a/src/__tests__/api/master/asyncJobWorker.test.ts +++ b/src/__tests__/api/master/asyncJobWorker.test.ts @@ -10,6 +10,7 @@ import { processPendingJobs, handleKeyGenerationOperation, handleMultisigSignOperation, + handleMultisigRecoveryOperation, } from '../../../masterBitgoExpress/workers/asyncJobWorker'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils'; @@ -630,4 +631,50 @@ describe('asyncJobWorker', () => { await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected(); }); }); + + describe('handleMultisigRecoveryOperation()', () => { + const recoveredTxHex = 'recovered-signed-tx-hex'; + + function makeRecoveryJob(overrides: Partial = {}): BridgeJobResponse { + return makeSignJob({ + jobId: 'job-recovery-123', + operationType: 'multisig_recovery', + awmResponse: awmOk({ txHex: recoveredTxHex }), + request: { + endpoint: `/api/${COIN}/multisig/recovery`, + method: 'POST', + body: { userPub: 'xpub_user', backupPub: 'xpub_backup', walletContractAddress: '' }, + }, + ...overrides, + }); + } + + it('PATCHes job complete with the signed recovery tx (no WP submit)', async () => { + const job = makeRecoveryJob(); + const updateNock = nock(BRIDGE_URL) + .patch( + `/job/${job.jobId}`, + (body) => body.status === 'complete' && body.result?.txHex === recoveredTxHex, + ) + .reply(204); + + await handleMultisigRecoveryOperation(job, bridge, bitgo); + + updateNock.done(); + }); + + it('throws when awmResponse is missing', async () => { + const job = makeRecoveryJob({ awmResponse: undefined }); + + await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejected(); + }); + + it('throws when awmResponse.body is not a valid signed transaction', async () => { + const job = makeRecoveryJob({ awmResponse: { status: 200, body: { bad: 'shape' } } }); + + await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejectedWith( + /expected txHex or halfSigned/, + ); + }); + }); }); diff --git a/src/__tests__/api/master/multisigRecoveryUtils.test.ts b/src/__tests__/api/master/multisigRecoveryUtils.test.ts new file mode 100644 index 0000000..cd7aec5 --- /dev/null +++ b/src/__tests__/api/master/multisigRecoveryUtils.test.ts @@ -0,0 +1,97 @@ +import 'should'; +import assert from 'assert'; +import nock from 'nock'; +import { + parseSignedRecoveryTransaction, + submitMultisigRecoveryJob, +} from '../../../masterBitgoExpress/handlers/utils/multisigRecoveryUtils'; +import { SignedMultisigTransactionSchema } 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('multisigRecoveryUtils', () => { + const bridgeUrl = 'http://bridge.invalid'; + const coin = 'tbtc'; + const userPub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + const backupPub = + 'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH'; + const recoveryBody = { + userPub, + backupPub, + bitgoPub: 'xpub_bitgo', + unsignedSweepPrebuildTx: { txHex: 'unsigned-tx-hex' }, + walletContractAddress: '', + }; + + describe('submitMultisigRecoveryJob', () => { + afterEach(() => { + 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 submitMultisigRecoveryJob(req, coin, recoveryBody); + assert.strictEqual(result, null); + }); + + it('submits multisig_recovery to the bridge with correct path and headers', async () => { + const jobId = 'job-123'; + const bridgeNock = nock(bridgeUrl) + .post(`/api/${coin}/multisig/recovery`, (body) => { + body.should.eql(recoveryBody); + return true; + }) + .matchHeader('X-OSO-Source', KeySource.USER) + .matchHeader('X-OSO-Operation', 'multisig_recovery') + .reply(202, { jobId }); + + const result = await submitMultisigRecoveryJob(makeAsyncReq(), coin, recoveryBody); + assert(result); + result.should.eql({ jobId, status: 'pending' }); + bridgeNock.done(); + }); + }); + + describe('parseSignedRecoveryTransaction', () => { + it('accepts a top-level txHex', () => { + parseSignedRecoveryTransaction({ txHex: 'signed-tx-hex' }).should.eql({ + txHex: 'signed-tx-hex', + }); + }); + + it('rejects bodies missing txHex and halfSigned', () => { + (() => parseSignedRecoveryTransaction({ bad: 'shape' })).should.throw( + /expected txHex or halfSigned/, + ); + }); + + it('uses the same schema as multisig sign responses', () => { + const body = { halfSigned: { txHex: 'signed-tx-hex' } }; + parseSignedRecoveryTransaction(body).should.eql( + SignedMultisigTransactionSchema.parse(body) as { halfSigned: { txHex: string } }, + ); + }); + }); +}); diff --git a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts index 224e071..f0b357e 100644 --- a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -5,7 +5,12 @@ import nock from 'nock'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { Trx } from '@bitgo-beta/sdk-coin-trx'; -import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG } from './testUtils'; +import { + BitGoAPITestHarness, + DEFAULT_ASYNC_MODE_CONFIG, + makeMasterExpressTestConfig, + nockAsyncMultisigRecoveryJob, +} from './testUtils'; describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { let agent: request.SuperAgentTest; @@ -619,4 +624,115 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { response.status.should.equal(400); response.body.should.have.property('error'); }); + + describe('Async mode', () => { + const jobId = 'recovery-consolidation-job-id-123'; + let asyncAgent: request.SuperAgentTest; + + before(() => { + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + overrides: { recoveryMode: true }, + }); + asyncAgent = request.agent(expressApp(asyncConfig)); + }); + + it('should return 202 + jobId for a single-tx onchain consolidation recovery', async () => { + nock(solRpcBase) + .post('/', (b) => b.method === 'getBalance' && b.params[0] === solWalletAddress2) + .reply(200, { jsonrpc: '2.0', result: { context: { slot: 1 }, value: 1000000000 }, id: 1 }); + nock(solRpcBase) + .post('/', (b) => b.method === 'getLatestBlockhash') + .reply(200, { + jsonrpc: '2.0', + result: { + context: { slot: 2792 }, + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 3090, + }, + }, + id: 1, + }); + nock(solRpcBase) + .post('/', (b) => b.method === 'getAccountInfo' && b.params[0] === solDurableNoncePubKey) + .reply(200, solDurableNonceAccountInfo); + nock(solRpcBase) + .post('/', (b) => b.method === 'getFeeForMessage') + .reply(200, { jsonrpc: '2.0', result: { context: { slot: 1 }, value: 5000 }, id: 1 }); + + const { bridgeNock, awmRecoveryNock } = nockAsyncMultisigRecoveryJob({ + coin: 'sol', + advancedWalletManagerUrl, + jobId, + }); + + const response = await asyncAgent + .post(`/api/v1/sol/advancedwallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain' as const, + userPub: solBitgoKey, + backupPub: solBitgoKey, + bitgoPub: solBitgoKey, + startingScanIndex: 2, + endingScanIndex: 3, + durableNonces: { + publicKeys: [solDurableNoncePubKey, solDurableNoncePubKey2, solDurableNoncePubKey3], + secretKey: solDurableNoncePrivKey, + }, + }); + + response.status.should.equal(202); + response.body.should.have.property('jobId', jobId); + response.body.should.have.property('status', 'pending'); + bridgeNock.done(); + awmRecoveryNock.isDone().should.be.false(); + }); + + it('should reject async mode when more than one consolidation tx is built', async () => { + const tronBalanceWithToken = { + data: [{ balance: 200_000_000, trc20: [{ [trxTokenContractAddress]: '1000000' }] }], + }; + nock(tronBase).get(`/v1/accounts/${TRX_ADDR_1}`).reply(200, tronBalanceWithToken); + nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX }); + nock(tronBase).get(`/v1/accounts/${TRX_ADDR_2}`).reply(200, tronBalanceWithToken); + nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX }); + + const response = await asyncAgent + .post(`/api/v1/trx/advancedwallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain' as const, + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + tokenContractAddress: trxTokenContractAddress, + startingScanIndex: 1, + endingScanIndex: 3, + }); + + response.status.should.equal(400); + response.body.details.should.containEql( + 'Async mode supports a single consolidation recovery only', + ); + }); + + it('should reject async mode for MPC (tss) consolidation recovery', async () => { + const response = await asyncAgent + .post(`/api/v1/tsui/advancedwallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'tss' as const, + commonKeychain: suiBitgoKey, + startingScanIndex: 1, + endingScanIndex: 2, + }); + + response.status.should.equal(400); + response.body.details.should.containEql( + 'Async mode is not yet supported for TSS/MPC recovery consolidations', + ); + }); + }); }); diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index 2fbb8f5..c54649a 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -4,7 +4,12 @@ import nock from 'nock'; import sinon from 'sinon'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; -import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG } from './testUtils'; +import { + BitGoAPITestHarness, + DEFAULT_ASYNC_MODE_CONFIG, + makeMasterExpressTestConfig, + nockAsyncMultisigRecoveryJob, +} from './testUtils'; describe('Recovery Tests', () => { let agent: request.SuperAgentTest; @@ -615,4 +620,200 @@ describe('Recovery Tests', () => { awmNock.isDone().should.be.true(); }); }); + + describe('Async mode', () => { + const jobId = 'recovery-job-id-123'; + const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, { + asyncEnabled: true, + overrides: { recoveryMode: true, disableEnvCheck: true }, + }); + let asyncAgent: request.SuperAgentTest; + + before(() => { + asyncAgent = request.agent(expressApp(asyncConfig)); + }); + + it('should return 202 + jobId for UTXO multisig recovery, submitting to the bridge not AWM', async () => { + const coin = 'tbtc'; + const userPub = + 'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg'; + const backupPub = + 'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr'; + const bitgoPub = + 'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy'; + const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d'; + const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; + const blockchairBase = 'https://api.blockchair.com'; + + nock(blockchairBase) + .get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`) + .reply(200, { + data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } }, + }); + nock(blockchairBase) + .get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`) + .reply(200, { + data: { + utxo: [ + { + transaction_hash: + '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed', + index: 0, + recipient: addrWithFunds, + value: 4000, + block_id: 100, + spending_transaction_hash: null, + spending_index: null, + address: addrWithFunds, + }, + ], + }, + }); + nock(blockchairBase) + .persist() + .get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/) + .reply(function (uri) { + const match = uri.match(/\/dashboards\/address\/([^?]+)\?/); + const addr = match ? decodeURIComponent(match[1]) : 'unknown'; + return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }]; + }); + nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, { + fastestFee: 20, + halfHourFee: 10, + hourFee: 5, + }); + + let capturedBody: Record | undefined; + const { bridgeNock, awmRecoveryNock } = nockAsyncMultisigRecoveryJob({ + coin, + advancedWalletManagerUrl, + jobId, + captureJobBody: (body) => { + capturedBody = body; + }, + }); + + const response = await asyncAgent + .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: recoveryDestination, + coin, + apiKey: 'key', + coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } }, + }); + + response.status.should.equal(202); + response.body.should.have.property('jobId', jobId); + response.body.should.have.property('status', 'pending'); + bridgeNock.done(); + awmRecoveryNock.isDone().should.be.false(); + const body = capturedBody as Record; + body.should.have.property('userPub', userPub); + body.should.have.property('backupPub', backupPub); + body.should.have.property('bitgoPub', bitgoPub); + body.should.have.property('unsignedSweepPrebuildTx'); + }); + + it('should return 202 + jobId for EVM multisig recovery, submitting to the bridge not AWM', async () => { + const ethCoinId = 'hteth'; + const ethUserPub = + 'xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK'; + const ethBackupPub = + 'xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e'; + const recoveryDestination = '0x1234567890123456789012345678901234567890'; + const walletContractAddress = '0x0987654321098765432109876543210987654321'; + const backupKeyAddress = '0x30edc88a77598833f58947638b2ac3d5713d9845'; + const etherscanBase = 'https://api.etherscan.io'; + const chainid = '560048'; + const apiKey = 'key'; + + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=account&action=txlist&address=${backupKeyAddress}&apikey=${apiKey}`, + ) + .twice() + .reply(200, { result: [] }); + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=account&action=balance&address=${backupKeyAddress}&apikey=${apiKey}`, + ) + .reply(200, { result: '10000000000000000' }); + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=account&action=balance&address=${walletContractAddress}&apikey=${apiKey}`, + ) + .reply(200, { result: '1000000000000000000' }); + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=proxy&action=eth_call&to=${walletContractAddress}&data=a0b7967b&tag=latest&apikey=${apiKey}`, + ) + .reply(200, { + result: '0x0000000000000000000000000000000000000000000000000000000000000001', + }); + + let capturedBody: Record | undefined; + const { bridgeNock, awmRecoveryNock } = nockAsyncMultisigRecoveryJob({ + coin: ethCoinId, + advancedWalletManagerUrl, + jobId, + captureJobBody: (body) => { + capturedBody = body; + }, + }); + + const response = await asyncAgent + .post(`/api/v1/${ethCoinId}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub: ethUserPub, + backupPub: ethBackupPub, + bitgoPub: '', + walletContractAddress, + }, + recoveryDestinationAddress: recoveryDestination, + coin: ethCoinId, + apiKey, + coinSpecificParams: { evmRecoveryOptions: {} }, + }); + + response.status.should.equal(202); + response.body.should.have.property('jobId', jobId); + response.body.should.have.property('status', 'pending'); + bridgeNock.done(); + awmRecoveryNock.isDone().should.be.false(); + const body = capturedBody as Record; + body.should.have.property('userPub', ethUserPub); + body.should.have.property('backupPub', ethBackupPub); + body.should.have.property('unsignedSweepPrebuildTx'); + }); + + it('should reject async mode for TSS recovery with a 400', async () => { + const response = await asyncAgent + .post(`/api/v1/tsol/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + isTssRecovery: true, + tssRecoveryParams: { + commonKeychain: + 'b6f5fb808f538a32735a89609e98fab75690a2c79b26f50a54c4cbf0fbca287138b733783f1590e12b4916ef0f6053b22044860117274bda44bd5d711855f174', + }, + recoveryDestinationAddress: 'DpgugQVWnNbTQr6jqLvkHQVWa43WTGWb7jH5zeNGJjtA', + coinSpecificParams: { solanaRecoveryOptions: {} }, + }); + + response.status.should.equal(400); + response.body.should.have.property('details'); + response.body.details.should.containEql( + 'Async mode is not yet supported for TSS/MPC recovery', + ); + }); + }); }); diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts index 601e20c..666d307 100644 --- a/src/__tests__/api/master/testUtils.ts +++ b/src/__tests__/api/master/testUtils.ts @@ -77,6 +77,31 @@ export function nockAsyncMultisigSignJob(options: { return { bridgeNock, awmSignNock }; } +export function nockAsyncMultisigRecoveryJob(options: { + coin: string; + advancedWalletManagerUrl: string; + jobId: string; + captureJobBody?: (body: Record) => void; + bridgeUrl?: string; +}) { + const bridgeUrl = options.bridgeUrl ?? ASYNC_TEST_BRIDGE_URL; + + const bridgeNock = nock(bridgeUrl) + .post(`/api/${options.coin}/multisig/recovery`, (body) => { + options.captureJobBody?.(body); + return true; + }) + .matchHeader('X-OSO-Source', KeySource.USER) + .matchHeader('X-OSO-Operation', 'multisig_recovery') + .reply(202, { jobId: options.jobId }); + + const awmRecoveryNock = nock(options.advancedWalletManagerUrl) + .post(`/api/${options.coin}/multisig/recovery`) + .reply(500, { error: 'should not reach AWM in async mode' }); + + return { bridgeNock, awmRecoveryNock }; +} + 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 09db12b..0cd26e8 100644 --- a/src/__tests__/integration/asyncJobWorker.integ.test.ts +++ b/src/__tests__/integration/asyncJobWorker.integ.test.ts @@ -104,6 +104,36 @@ const INTEG_WP_SUBMIT_PARAMS: Record> = { }, }; +function makeAwaitingBitgoMultisigRecoveryJob( + overrides: Partial = {}, +): BridgeJobResponse { + return { + jobId: JOB_ID, + status: 'awaiting_bitgo', + version: 1, + coin: COIN, + operationType: 'multisig_recovery', + awmResponse: { + status: 200, + body: { txHex: 'recovered-tx-hex' }, + }, + request: { + endpoint: `/api/${COIN}/multisig/recovery`, + method: 'POST', + body: { + userPub: USER_XPUB, + backupPub: 'xpub_backup', + unsignedSweepPrebuildTx: { txHex: 'unsigned' }, + walletContractAddress: '', + }, + }, + createdAt: 1717977600, + updatedAt: 1717977600, + ttl: 3600, + ...overrides, + }; +} + function makeAwaitingBitgoMultisigSignJob( wpSubmitKind: WpSubmitKind, overrides: Partial = {}, @@ -199,6 +229,24 @@ describe('asyncJobWorker: end-to-end polling', () => { (patchCall.body as { status: string }).status.should.equal('failed'); }); + it('picks up an awaiting_bitgo multisig_recovery job and PATCHes complete with signed tx', async () => { + assert(services.bridge, 'bridge service should be defined'); + services.bridge.setPendingJobs([makeAwaitingBitgoMultisigRecoveryJob()]); + + 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`); + const patchBody = patchCall.body as { status: string; result: { txHex: string } }; + patchBody.status.should.equal('complete'); + patchBody.result.should.have.property('txHex', 'recovered-tx-hex'); + }); + 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([makeAwaitingBitgoMultisigSignJob('sendMany')]); diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index fcd709c..2b604a2 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -90,17 +90,19 @@ interface SignMultisigOptions { walletPubs?: string[]; } -interface RecoveryMultisigOptions { +export type RecoveryMultisigUnsignedSweepTx = + | RecoveryInfo + | OfflineVaultTxInfo + | UnsignedSweepTxMPCv2 + | FormattedOfflineVaultTxInfo + | MPCTx + | RecoveryTransaction; + +export interface RecoveryMultisigOptions { userPub: string; backupPub: string; bitgoPub?: string; - unsignedSweepPrebuildTx: - | RecoveryInfo - | OfflineVaultTxInfo - | UnsignedSweepTxMPCv2 - | FormattedOfflineVaultTxInfo - | MPCTx - | RecoveryTransaction; + unsignedSweepPrebuildTx: RecoveryMultisigUnsignedSweepTx; walletContractAddress: string; // When set, only sign with the specified key (user half-sign or backup full-sign). keyToSign?: 'user' | 'backup'; diff --git a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts index 4bd7566..8b01657 100644 --- a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts +++ b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts @@ -22,6 +22,9 @@ import type { Tao, Ttao } from '@bitgo-beta/sdk-coin-tao'; import coinFactory from '../../shared/coinFactory'; import { checkRecoveryMode } from './utils/utils'; import { MasterExpressConfig } from '../../shared/types'; +import { BadRequestError } from '../../shared/errors'; +import { orThrow } from '../../shared/utils'; +import { submitMultisigRecoveryJob } from './utils/multisigRecoveryUtils'; type RecoveryConsolidationParams = | ConsolidationRecoveryOptions @@ -44,6 +47,13 @@ export async function handleRecoveryConsolidations( const awmClient = req.awmUserClient; const isMPC = req.decoded.multisigType === 'tss'; + const asyncEnabled = req.config.asyncModeConfig.enabled; + + if (isMPC && asyncEnabled) { + throw new BadRequestError( + 'Async mode is not yet supported for TSS/MPC recovery consolidations', + ); + } const { commonKeychain, apiKey = '' } = req.decoded; let { userPub, backupPub, bitgoPub } = req.decoded; @@ -83,6 +93,24 @@ export async function handleRecoveryConsolidations( logger.info(`Found ${txs.length} unsigned consolidation transactions`); + if (asyncEnabled) { + // Async mode supports a single recovery build only; multi-tx batches remain sync-only. + if (txs.length !== 1) { + throw new BadRequestError( + `Async mode supports a single consolidation recovery only, but built ${txs.length}`, + ); + } + return orThrow( + await submitMultisigRecoveryJob(req, coin, { + userPub, + backupPub, + unsignedSweepPrebuildTx: txs[0] as RecoveryTransaction, + walletContractAddress: '', + }), + 'async recovery consolidation job submission failed', + ); + } + const signedTxs = []; try { for (const tx of txs) { diff --git a/src/masterBitgoExpress/handlers/recoveryWallet.ts b/src/masterBitgoExpress/handlers/recoveryWallet.ts index 581aa2e..b880777 100644 --- a/src/masterBitgoExpress/handlers/recoveryWallet.ts +++ b/src/masterBitgoExpress/handlers/recoveryWallet.ts @@ -1,5 +1,10 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; -import { BaseCoin, MethodNotImplementedError, MPCRecoveryOptions } from '@bitgo-beta/sdk-core'; +import { + BaseCoin, + MethodNotImplementedError, + MPCRecoveryOptions, + SignedTransaction, +} from '@bitgo-beta/sdk-core'; import { AbstractEthLikeNewCoins } from '@bitgo-beta/abstract-eth'; import { AbstractUtxoCoin } from '@bitgo-beta/abstract-utxo'; import { type SolRecoveryOptions } from '@bitgo-beta/sdk-coin-sol'; @@ -20,16 +25,21 @@ import { getReplayProtectionOptions, } from '../../shared/recoveryUtils'; -import { AdvancedWalletManagerClient } from '../clients/advancedWalletManagerClient'; +import { + AdvancedWalletManagerClient, + RecoveryMultisigUnsignedSweepTx, +} from '../clients/advancedWalletManagerClient'; import { MasterApiSpecRouteRequest, ScriptType2Of3 } from '../routers/masterBitGoExpressApiSpec'; import { CoinSpecificParams, CoinSpecificParamsUnion } from '../routers/recoveryRoute'; import { recoverEddsaWallets } from './recoveryEddsa'; import { EnvironmentName, MasterExpressConfig } from '../../shared/types'; import { recoverEcdsaMpcV2Params, recoverEcdsaMPCv2Wallets } from './recoveryEcdsa'; import logger from '../../shared/logger'; -import { NotImplementedError, ValidationError } from '../../shared/errors'; +import { BadRequestError, NotImplementedError, ValidationError } from '../../shared/errors'; import { CoinFamily } from '@bitgo-beta/statics'; import { checkRecoveryMode } from './utils/utils'; +import { AsyncJobResponse } from '../clients/bridgeClient.types'; +import { MultisigRecoveryBody, submitMultisigRecoveryJob } from './utils/multisigRecoveryUtils'; interface RecoveryParams { userKey: string; @@ -43,7 +53,7 @@ interface AdvancedWalletManagerRecoveryParams { userPub: string; backupPub: string; apiKey: string; - unsignedSweepPrebuildTx: any; // TODO: type this properly once we have the SDK types + unsignedSweepPrebuildTx: RecoveryMultisigUnsignedSweepTx | undefined; coinSpecificParams?: CoinSpecificParamsUnion; walletContractAddress: string; } @@ -102,35 +112,46 @@ function validateRecoveryParams( } } +async function recoverMultisigOrSubmitJob( + req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, + awmClient: AdvancedWalletManagerClient, + recoveryBody: MultisigRecoveryBody, +): Promise { + const asyncResult = await submitMultisigRecoveryJob(req, req.decoded.coin, recoveryBody); + if (asyncResult) { + return asyncResult; + } + return awmClient.recoveryMultisig(recoveryBody); +} + async function handleEthLikeRecovery( + req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, sdkCoin: BaseCoin, commonRecoveryParams: RecoveryParams, - awmClient: any, + awmClient: AdvancedWalletManagerClient, params: AdvancedWalletManagerRecoveryParams, env: EnvironmentName, ) { - try { - const { gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas } = DEFAULT_MUSIG_ETH_GAS_PARAMS; - const unsignedSweepPrebuildTx = await (sdkCoin as AbstractEthLikeNewCoins).recover({ - ...commonRecoveryParams, - gasPrice, - gasLimit, - eip1559: { - maxFeePerGas, - maxPriorityFeePerGas, - }, - replayProtectionOptions: getReplayProtectionOptions(env), - apiKey: params.apiKey, - isUnsignedSweep: true, - }); - - return await awmClient.recoveryMultisig({ - ...params, - unsignedSweepPrebuildTx, - }); - } catch (err) { - throw err; - } + const { gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas } = DEFAULT_MUSIG_ETH_GAS_PARAMS; + const unsignedSweepPrebuildTx = await (sdkCoin as AbstractEthLikeNewCoins).recover({ + ...commonRecoveryParams, + gasPrice, + gasLimit, + eip1559: { + maxFeePerGas, + maxPriorityFeePerGas, + }, + replayProtectionOptions: getReplayProtectionOptions(env), + apiKey: params.apiKey, + isUnsignedSweep: true, + }); + + return recoverMultisigOrSubmitJob(req, awmClient, { + userPub: params.userPub, + backupPub: params.backupPub, + unsignedSweepPrebuildTx, + walletContractAddress: params.walletContractAddress, + }); } async function handleEddsaRecovery( @@ -194,10 +215,11 @@ export type UtxoCoinSpecificRecoveryParams = Pick< >; async function handleUtxoLikeRecovery( + req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, sdkCoin: BaseCoin, awmClient: AdvancedWalletManagerClient, recoveryParams: UtxoCoinSpecificRecoveryParams, -): Promise<{ txHex: string }> { +): Promise { const abstractUtxoCoin = sdkCoin as unknown as AbstractUtxoCoin; const recoverTx = await abstractUtxoCoin.recover(recoveryParams); @@ -206,13 +228,13 @@ async function handleUtxoLikeRecovery( throw new MethodNotImplementedError(`Unknown transaction ${JSON.stringify(recoverTx)} created`); } - return (await awmClient.recoveryMultisig({ + return recoverMultisigOrSubmitJob(req, awmClient, { userPub: recoveryParams.userKey, backupPub: recoveryParams.backupKey, bitgoPub: recoveryParams.bitgoKey, unsignedSweepPrebuildTx: recoverTx, walletContractAddress: '', - })) as { txHex: string }; + }); } export async function handleRecoveryWallet( @@ -231,6 +253,9 @@ export async function handleRecoveryWallet( // Handle TSS recovery if (req.decoded.isTssRecovery) { + if (req.config.asyncModeConfig.enabled) { + throw new BadRequestError('Async mode is not yet supported for TSS/MPC recovery'); + } assert(req.decoded.tssRecoveryParams, 'TSS recovery parameters are required'); const { commonKeychain } = req.decoded.tssRecoveryParams; @@ -331,6 +356,7 @@ export async function handleRecoveryWallet( throw new Error('Missing walletContract address'); } return handleEthLikeRecovery( + req, sdkCoin, commonRecoveryParams, awmClient, @@ -350,7 +376,7 @@ export async function handleRecoveryWallet( } if (isUtxoCoin(sdkCoin)) { - return handleUtxoLikeRecovery(sdkCoin, req.awmUserClient, { + return handleUtxoLikeRecovery(req, sdkCoin, req.awmUserClient, { userKey: userPub, backupKey: backupPub, bitgoKey: bitgoPub, diff --git a/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts new file mode 100644 index 0000000..7c9917e --- /dev/null +++ b/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts @@ -0,0 +1,34 @@ +import { SignedTransaction } from '@bitgo-beta/sdk-core'; +import { AsyncJobResponse } from '../../clients/bridgeClient.types'; +import { RecoveryMultisigUnsignedSweepTx } from '../../clients/advancedWalletManagerClient'; +import { KeySource, MasterExpressConfig } from '../../../shared/types'; +import { BitGoRequest } from '../../../types/request'; +import { submitJobViaBridgeClient } from './asyncUtils'; +import { parseSignedMultisigTransaction } from './multisigSignUtils'; + +/** Bridge body for a `multisig_recovery` job — what would be sent to AWM. */ +export type MultisigRecoveryBody = { + userPub: string; + backupPub: string; + bitgoPub?: string; + unsignedSweepPrebuildTx: RecoveryMultisigUnsignedSweepTx; + walletContractAddress: string; +}; + +/** Submits a recovery job to the bridge, or returns null when async mode is off. */ +export async function submitMultisigRecoveryJob( + req: BitGoRequest, + coin: string, + body: MultisigRecoveryBody, +): Promise { + return submitJobViaBridgeClient(req, { + path: `/api/${coin}/multisig/recovery`, + body, + sources: [KeySource.USER], + operationType: 'multisig_recovery', + }); +} + +export function parseSignedRecoveryTransaction(body: unknown): SignedTransaction { + return parseSignedMultisigTransaction(body); +} diff --git a/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts b/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts index ea9f3f2..2ffe05d 100644 --- a/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts +++ b/src/masterBitgoExpress/routers/masterBitGoExpressApiSpec.ts @@ -39,17 +39,18 @@ export function parseBody(req: express.Request, res: express.Response, next: exp return express.json({ limit: '20mb' })(req, res, next); } -/** - * TODO: union with other handler response types as they are async-ified (WCN-887 through WCN-893) - */ +/** Handler responses; async-capable handlers may also return an AsyncJobResponse. */ type MasterBitGoAPIHandlerResponses = | Awaited> | Awaited> | Awaited> - | Awaited>; + | Awaited> + | Awaited> + | Awaited>; function toApiResponse(result: MasterBitGoAPIHandlerResponses) { - return 'jobId' in result ? Response.accepted(result) : Response.ok(result); + const isAsyncJob = typeof result === 'object' && result !== null && 'jobId' in result; + return isAsyncJob ? Response.accepted(result) : Response.ok(result); } // API Specification @@ -131,7 +132,7 @@ export function createMasterApiRouter( responseHandler(async (req: express.Request) => { const typedReq = req as GenericMasterApiSpecRouteRequest; const result = await handleRecoveryWallet(typedReq); - return Response.ok(result); + return toApiResponse(result); }), ]); @@ -147,7 +148,7 @@ export function createMasterApiRouter( responseHandler(async (req: express.Request) => { const typedReq = req as GenericMasterApiSpecRouteRequest; const result = await handleRecoveryConsolidations(typedReq); - return Response.ok(result); + return toApiResponse(result); }), ]); diff --git a/src/masterBitgoExpress/routers/recoveryConsolidationsRoute.ts b/src/masterBitgoExpress/routers/recoveryConsolidationsRoute.ts index 0ab307c..1149de1 100644 --- a/src/masterBitgoExpress/routers/recoveryConsolidationsRoute.ts +++ b/src/masterBitgoExpress/routers/recoveryConsolidationsRoute.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'; /** * Request type for wallet recovery consolidations endpoint. @@ -109,6 +110,7 @@ const RecoveryConsolidationsWalletResponse: HttpResponse = { * The exact structure depends on the coin and recovery type. */ 200: t.any, // Complex response structure varies by coin and recovery type + 202: AsyncJobResponseCodec, ...ErrorResponses, }; diff --git a/src/masterBitgoExpress/routers/recoveryRoute.ts b/src/masterBitgoExpress/routers/recoveryRoute.ts index 0e1aa94..2eda763 100644 --- a/src/masterBitgoExpress/routers/recoveryRoute.ts +++ b/src/masterBitgoExpress/routers/recoveryRoute.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'; /** * Recovery parameter types used by the wallet recovery endpoints @@ -203,6 +204,7 @@ const RecoveryWalletResponse: HttpResponse = { * @example { "txHex": "01000000000101edd7a5d948a6c79f273ce686a6a8f2e96ed8c2583b5e77b866aa2a1b3426fbed0100000000ffffffff02102700000000000017a914192f23283c2a9e6c5d11562db0eb5d4eb47f460287b9bc2c000000000017a9145c139b242ab3701f321d2399d3a11b028b3b361e870247304402206ac9477fece38d96688c6c3719cb27396c0563ead0567457e7e884b406b6da8802201992d1cfa1b55a67ce8acb482e9957812487d2555f5f54fb0286ecd3095d78e4012103c92564575197c4d6e3d9792280e7548b3ba52a432101c62de2186c4e2fa7fc580000000000" } */ 200: RecoveryWalletResponseCodec, + 202: AsyncJobResponseCodec, ...ErrorResponses, }; diff --git a/src/masterBitgoExpress/workers/asyncJobWorker.ts b/src/masterBitgoExpress/workers/asyncJobWorker.ts index 9c4d959..7677184 100644 --- a/src/masterBitgoExpress/workers/asyncJobWorker.ts +++ b/src/masterBitgoExpress/workers/asyncJobWorker.ts @@ -14,6 +14,7 @@ import { parseMultisigSignJobContext, parseSignedMultisigTransaction, } from '../handlers/utils/multisigSignUtils'; +import { parseSignedRecoveryTransaction } from '../handlers/utils/multisigRecoveryUtils'; import { WP_SUBMIT_HANDLERS } from '../handlers/utils/multisigSubmitUtils'; const ASYNC_OPERATIONS_TO_HANDLERS: Partial< @@ -24,6 +25,7 @@ const ASYNC_OPERATIONS_TO_HANDLERS: Partial< > = { multisig_keygen: handleKeyGenerationOperation, multisig_sign: handleMultisigSignOperation, + multisig_recovery: handleMultisigRecoveryOperation, }; function parseAwmResponseBody( @@ -190,3 +192,26 @@ export async function handleMultisigSignOperation( logger.info(`${logPrefix} job ${jobId} complete`); } + +/** Completes a `multisig_recovery` job by returning the signed sweep tx from AWM (no WP submit). */ +export async function handleMultisigRecoveryOperation( + job: BridgeJobResponse, + bridge: OsoBridgeClient, + _bitgo: BitGoAPI, +): Promise { + const logPrefix = '[asyncJobWorker:handleMultisigRecoveryOperation]'; + const signedTx = parseSignedRecoveryTransaction( + parseAwmResponseBody(job.awmResponse, 'awmResponse'), + ); + const { jobId, version } = job; + + logger.info(`${logPrefix} job ${jobId} recovered - updating job status to complete`); + await bridge.updateJob({ + jobId, + version, + status: 'complete', + result: signedTx, + }); + + logger.info(`${logPrefix} job ${jobId} complete`); +}