Skip to content

Commit cedbbfd

Browse files
authored
Merge pull request #250 from BitGo/danielpeng/wcn-888-mbe-async-ify-multisig_recovery-endpoints
feat: implement async multisig recovery operations
2 parents 4db3801 + 241fe52 commit cedbbfd

15 files changed

Lines changed: 722 additions & 48 deletions

masterBitgoExpress.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,16 @@
14751475
}
14761476
}
14771477
},
1478+
"202": {
1479+
"description": "Accepted",
1480+
"content": {
1481+
"application/json": {
1482+
"schema": {
1483+
"$ref": "#/components/schemas/AsyncJobResponseCodec"
1484+
}
1485+
}
1486+
}
1487+
},
14781488
"400": {
14791489
"description": "Bad Request",
14801490
"content": {
@@ -1656,6 +1666,16 @@
16561666
}
16571667
}
16581668
},
1669+
"202": {
1670+
"description": "Accepted",
1671+
"content": {
1672+
"application/json": {
1673+
"schema": {
1674+
"$ref": "#/components/schemas/AsyncJobResponseCodec"
1675+
}
1676+
}
1677+
}
1678+
},
16591679
"400": {
16601680
"description": "Bad Request",
16611681
"content": {

src/__tests__/api/master/asyncJobWorker.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
processPendingJobs,
1111
handleKeyGenerationOperation,
1212
handleMultisigSignOperation,
13+
handleMultisigRecoveryOperation,
1314
} from '../../../masterBitgoExpress/workers/asyncJobWorker';
1415
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
1516
import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
@@ -630,4 +631,50 @@ describe('asyncJobWorker', () => {
630631
await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected();
631632
});
632633
});
634+
635+
describe('handleMultisigRecoveryOperation()', () => {
636+
const recoveredTxHex = 'recovered-signed-tx-hex';
637+
638+
function makeRecoveryJob(overrides: Partial<BridgeJobResponse> = {}): BridgeJobResponse {
639+
return makeSignJob({
640+
jobId: 'job-recovery-123',
641+
operationType: 'multisig_recovery',
642+
awmResponse: awmOk({ txHex: recoveredTxHex }),
643+
request: {
644+
endpoint: `/api/${COIN}/multisig/recovery`,
645+
method: 'POST',
646+
body: { userPub: 'xpub_user', backupPub: 'xpub_backup', walletContractAddress: '' },
647+
},
648+
...overrides,
649+
});
650+
}
651+
652+
it('PATCHes job complete with the signed recovery tx (no WP submit)', async () => {
653+
const job = makeRecoveryJob();
654+
const updateNock = nock(BRIDGE_URL)
655+
.patch(
656+
`/job/${job.jobId}`,
657+
(body) => body.status === 'complete' && body.result?.txHex === recoveredTxHex,
658+
)
659+
.reply(204);
660+
661+
await handleMultisigRecoveryOperation(job, bridge, bitgo);
662+
663+
updateNock.done();
664+
});
665+
666+
it('throws when awmResponse is missing', async () => {
667+
const job = makeRecoveryJob({ awmResponse: undefined });
668+
669+
await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejected();
670+
});
671+
672+
it('throws when awmResponse.body is not a valid signed transaction', async () => {
673+
const job = makeRecoveryJob({ awmResponse: { status: 200, body: { bad: 'shape' } } });
674+
675+
await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejectedWith(
676+
/expected txHex or halfSigned/,
677+
);
678+
});
679+
});
633680
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import 'should';
2+
import assert from 'assert';
3+
import nock from 'nock';
4+
import {
5+
parseSignedRecoveryTransaction,
6+
submitMultisigRecoveryJob,
7+
} from '../../../masterBitgoExpress/handlers/utils/multisigRecoveryUtils';
8+
import { SignedMultisigTransactionSchema } from '../../../masterBitgoExpress/handlers/utils/multisigSignUtils';
9+
import { AppMode, KeySource, MasterExpressConfig } from '../../../shared/types';
10+
import { BitGoRequest } from '../../../types/request';
11+
import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
12+
import { OsoBridgeClient } from '../../../masterBitgoExpress/clients/bridgeClient';
13+
14+
describe('multisigRecoveryUtils', () => {
15+
const bridgeUrl = 'http://bridge.invalid';
16+
const coin = 'tbtc';
17+
const userPub =
18+
'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8';
19+
const backupPub =
20+
'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH';
21+
const recoveryBody = {
22+
userPub,
23+
backupPub,
24+
bitgoPub: 'xpub_bitgo',
25+
unsignedSweepPrebuildTx: { txHex: 'unsigned-tx-hex' },
26+
walletContractAddress: '',
27+
};
28+
29+
describe('submitMultisigRecoveryJob', () => {
30+
afterEach(() => {
31+
nock.cleanAll();
32+
});
33+
34+
function makeAsyncReq(): BitGoRequest<MasterExpressConfig> {
35+
return {
36+
config: {
37+
appMode: AppMode.MASTER_EXPRESS,
38+
asyncModeConfig: {
39+
enabled: true,
40+
awmAsyncUrl: bridgeUrl,
41+
pollIntervalInMs: 30000,
42+
jobTtlInSeconds: 3600,
43+
jobTtlMpcInSeconds: 7200,
44+
},
45+
} as MasterExpressConfig,
46+
bridgeClient: new OsoBridgeClient(bridgeUrl, 60000),
47+
} as unknown as BitGoRequest<MasterExpressConfig>;
48+
}
49+
50+
it('returns null when async mode is disabled', async () => {
51+
const req = {
52+
config: { asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG },
53+
} as BitGoRequest<MasterExpressConfig>;
54+
55+
const result = await submitMultisigRecoveryJob(req, coin, recoveryBody);
56+
assert.strictEqual(result, null);
57+
});
58+
59+
it('submits multisig_recovery to the bridge with correct path and headers', async () => {
60+
const jobId = 'job-123';
61+
const bridgeNock = nock(bridgeUrl)
62+
.post(`/api/${coin}/multisig/recovery`, (body) => {
63+
body.should.eql(recoveryBody);
64+
return true;
65+
})
66+
.matchHeader('X-OSO-Source', KeySource.USER)
67+
.matchHeader('X-OSO-Operation', 'multisig_recovery')
68+
.reply(202, { jobId });
69+
70+
const result = await submitMultisigRecoveryJob(makeAsyncReq(), coin, recoveryBody);
71+
assert(result);
72+
result.should.eql({ jobId, status: 'pending' });
73+
bridgeNock.done();
74+
});
75+
});
76+
77+
describe('parseSignedRecoveryTransaction', () => {
78+
it('accepts a top-level txHex', () => {
79+
parseSignedRecoveryTransaction({ txHex: 'signed-tx-hex' }).should.eql({
80+
txHex: 'signed-tx-hex',
81+
});
82+
});
83+
84+
it('rejects bodies missing txHex and halfSigned', () => {
85+
(() => parseSignedRecoveryTransaction({ bad: 'shape' })).should.throw(
86+
/expected txHex or halfSigned/,
87+
);
88+
});
89+
90+
it('uses the same schema as multisig sign responses', () => {
91+
const body = { halfSigned: { txHex: 'signed-tx-hex' } };
92+
parseSignedRecoveryTransaction(body).should.eql(
93+
SignedMultisigTransactionSchema.parse(body) as { halfSigned: { txHex: string } },
94+
);
95+
});
96+
});
97+
});

src/__tests__/api/master/recoveryConsolidationsWallet.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import nock from 'nock';
55
import { app as expressApp } from '../../../masterBitGoExpressApp';
66
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
77
import { Trx } from '@bitgo-beta/sdk-coin-trx';
8-
import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
8+
import {
9+
BitGoAPITestHarness,
10+
DEFAULT_ASYNC_MODE_CONFIG,
11+
makeMasterExpressTestConfig,
12+
nockAsyncMultisigRecoveryJob,
13+
} from './testUtils';
914

1015
describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
1116
let agent: request.SuperAgentTest;
@@ -619,4 +624,115 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
619624
response.status.should.equal(400);
620625
response.body.should.have.property('error');
621626
});
627+
628+
describe('Async mode', () => {
629+
const jobId = 'recovery-consolidation-job-id-123';
630+
let asyncAgent: request.SuperAgentTest;
631+
632+
before(() => {
633+
const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, {
634+
asyncEnabled: true,
635+
overrides: { recoveryMode: true },
636+
});
637+
asyncAgent = request.agent(expressApp(asyncConfig));
638+
});
639+
640+
it('should return 202 + jobId for a single-tx onchain consolidation recovery', async () => {
641+
nock(solRpcBase)
642+
.post('/', (b) => b.method === 'getBalance' && b.params[0] === solWalletAddress2)
643+
.reply(200, { jsonrpc: '2.0', result: { context: { slot: 1 }, value: 1000000000 }, id: 1 });
644+
nock(solRpcBase)
645+
.post('/', (b) => b.method === 'getLatestBlockhash')
646+
.reply(200, {
647+
jsonrpc: '2.0',
648+
result: {
649+
context: { slot: 2792 },
650+
value: {
651+
blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N',
652+
lastValidBlockHeight: 3090,
653+
},
654+
},
655+
id: 1,
656+
});
657+
nock(solRpcBase)
658+
.post('/', (b) => b.method === 'getAccountInfo' && b.params[0] === solDurableNoncePubKey)
659+
.reply(200, solDurableNonceAccountInfo);
660+
nock(solRpcBase)
661+
.post('/', (b) => b.method === 'getFeeForMessage')
662+
.reply(200, { jsonrpc: '2.0', result: { context: { slot: 1 }, value: 5000 }, id: 1 });
663+
664+
const { bridgeNock, awmRecoveryNock } = nockAsyncMultisigRecoveryJob({
665+
coin: 'sol',
666+
advancedWalletManagerUrl,
667+
jobId,
668+
});
669+
670+
const response = await asyncAgent
671+
.post(`/api/v1/sol/advancedwallet/recoveryconsolidations`)
672+
.set('Authorization', `Bearer ${accessToken}`)
673+
.send({
674+
multisigType: 'onchain' as const,
675+
userPub: solBitgoKey,
676+
backupPub: solBitgoKey,
677+
bitgoPub: solBitgoKey,
678+
startingScanIndex: 2,
679+
endingScanIndex: 3,
680+
durableNonces: {
681+
publicKeys: [solDurableNoncePubKey, solDurableNoncePubKey2, solDurableNoncePubKey3],
682+
secretKey: solDurableNoncePrivKey,
683+
},
684+
});
685+
686+
response.status.should.equal(202);
687+
response.body.should.have.property('jobId', jobId);
688+
response.body.should.have.property('status', 'pending');
689+
bridgeNock.done();
690+
awmRecoveryNock.isDone().should.be.false();
691+
});
692+
693+
it('should reject async mode when more than one consolidation tx is built', async () => {
694+
const tronBalanceWithToken = {
695+
data: [{ balance: 200_000_000, trc20: [{ [trxTokenContractAddress]: '1000000' }] }],
696+
};
697+
nock(tronBase).get(`/v1/accounts/${TRX_ADDR_1}`).reply(200, tronBalanceWithToken);
698+
nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX });
699+
nock(tronBase).get(`/v1/accounts/${TRX_ADDR_2}`).reply(200, tronBalanceWithToken);
700+
nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX });
701+
702+
const response = await asyncAgent
703+
.post(`/api/v1/trx/advancedwallet/recoveryconsolidations`)
704+
.set('Authorization', `Bearer ${accessToken}`)
705+
.send({
706+
multisigType: 'onchain' as const,
707+
userPub: mockUserPub,
708+
backupPub: mockBackupPub,
709+
bitgoPub: mockBitgoPub,
710+
tokenContractAddress: trxTokenContractAddress,
711+
startingScanIndex: 1,
712+
endingScanIndex: 3,
713+
});
714+
715+
response.status.should.equal(400);
716+
response.body.details.should.containEql(
717+
'Async mode supports a single consolidation recovery only',
718+
);
719+
});
720+
721+
it('should reject async mode for MPC (tss) consolidation recovery', async () => {
722+
const response = await asyncAgent
723+
.post(`/api/v1/tsui/advancedwallet/recoveryconsolidations`)
724+
.set('Authorization', `Bearer ${accessToken}`)
725+
.send({
726+
multisigType: 'tss' as const,
727+
commonKeychain: suiBitgoKey,
728+
startingScanIndex: 1,
729+
endingScanIndex: 2,
730+
});
731+
732+
response.status.should.equal(400);
733+
response.body.details.should.containEql(
734+
'Async mode is not yet supported for TSS/MPC recovery consolidations',
735+
);
736+
});
737+
});
622738
});

0 commit comments

Comments
 (0)