Skip to content

Commit 5f01a83

Browse files
committed
feat: implement async mode for generateWallet
When asyncModeConfig.enabled, handleGenerateOnChainWallet submits directly to the bridge and returns { jobId, status: 'pending' } (202) without touching AWM or BitGo. We're only supporting multisig wallets for now. Ticket: WCN-886
1 parent 3dca93d commit 5f01a83

13 files changed

Lines changed: 246 additions & 87 deletions

File tree

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

Lines changed: 54 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,10 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
6666

6767
let bitgo: BitGoAPI;
6868

69-
before(() => {
70-
nock.disableNetConnect();
71-
nock.enableNetConnect('127.0.0.1');
72-
73-
// Create a BitGo instance that we'll use for stubbing
74-
bitgo = new BitGoAPI({ env: 'test' });
75-
76-
const config: MasterExpressConfig = {
69+
function makeConfig(overrides: Partial<MasterExpressConfig> = {}): MasterExpressConfig {
70+
return {
7771
appMode: AppMode.MASTER_EXPRESS,
78-
port: 0, // Let OS assign a free port
72+
port: 0,
7973
bind: 'localhost',
8074
timeout: 60000,
8175
httpLoggerFile: '',
@@ -87,7 +81,17 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
8781
tlsMode: TlsMode.DISABLED,
8882
clientCertAllowSelfSigned: true,
8983
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
84+
...overrides,
9085
};
86+
}
87+
88+
before(() => {
89+
nock.disableNetConnect();
90+
nock.enableNetConnect('127.0.0.1');
91+
92+
bitgo = new BitGoAPI({ env: 'test' });
93+
94+
const config = makeConfig();
9195

9296
// Setup middleware stubs before creating app
9397
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
@@ -109,25 +113,9 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
109113
it('should generate an onchain wallet with separate backup AWM (separate-HSM mode)', async () => {
110114
const backupAwmUrl = 'http://backup-awm.invalid';
111115

112-
// Override middleware to inject a separate backup client
113116
sinon.restore();
114117
const backupBitgo = new BitGoAPI({ env: 'test' });
115-
const configWithBackup: MasterExpressConfig = {
116-
appMode: AppMode.MASTER_EXPRESS,
117-
port: 0,
118-
bind: 'localhost',
119-
timeout: 60000,
120-
httpLoggerFile: '',
121-
env: 'test',
122-
disableEnvCheck: true,
123-
authVersion: 2,
124-
advancedWalletManagerUrl: advancedWalletManagerUrl,
125-
advancedWalletManagerBackupUrl: backupAwmUrl,
126-
awmServerCaCert: 'dummy-cert',
127-
tlsMode: TlsMode.DISABLED,
128-
clientCertAllowSelfSigned: true,
129-
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
130-
};
118+
const configWithBackup = makeConfig({ advancedWalletManagerBackupUrl: backupAwmUrl });
131119

132120
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
133121
(req as BitGoRequest<MasterExpressConfig>).bitgo = backupBitgo;
@@ -354,22 +342,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
354342
.get('/api/v1/client/constants')
355343
.reply(200, { constants: { mpc: { bitgoPublicKey: 'test-bitgo-public-key' } } });
356344
const backupBitgo = new BitGoAPI({ env: 'test' });
357-
const configWithBackup: MasterExpressConfig = {
358-
appMode: AppMode.MASTER_EXPRESS,
359-
port: 0,
360-
bind: 'localhost',
361-
timeout: 60000,
362-
httpLoggerFile: '',
363-
env: 'test',
364-
disableEnvCheck: true,
365-
authVersion: 2,
366-
advancedWalletManagerUrl: advancedWalletManagerUrl,
367-
advancedWalletManagerBackupUrl: backupAwmUrl,
368-
awmServerCaCert: 'dummy-cert',
369-
tlsMode: TlsMode.DISABLED,
370-
clientCertAllowSelfSigned: true,
371-
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
372-
};
345+
const configWithBackup = makeConfig({ advancedWalletManagerBackupUrl: backupAwmUrl });
373346

374347
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
375348
(req as BitGoRequest<MasterExpressConfig>).bitgo = backupBitgo;
@@ -1007,22 +980,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
1007980
.get('/api/v1/client/constants')
1008981
.reply(200, { constants: { mpc: { bitgoMPCv2PublicKey: 'test-bitgo-public-key' } } });
1009982
const backupBitgo = new BitGoAPI({ env: 'test' });
1010-
const configWithBackup: MasterExpressConfig = {
1011-
appMode: AppMode.MASTER_EXPRESS,
1012-
port: 0,
1013-
bind: 'localhost',
1014-
timeout: 60000,
1015-
httpLoggerFile: '',
1016-
env: 'test',
1017-
disableEnvCheck: true,
1018-
authVersion: 2,
1019-
advancedWalletManagerUrl: advancedWalletManagerUrl,
1020-
advancedWalletManagerBackupUrl: backupAwmUrl,
1021-
awmServerCaCert: 'dummy-cert',
1022-
tlsMode: TlsMode.DISABLED,
1023-
clientCertAllowSelfSigned: true,
1024-
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
1025-
};
983+
const configWithBackup = makeConfig({ advancedWalletManagerBackupUrl: backupAwmUrl });
1026984

1027985
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
1028986
(req as BitGoRequest<MasterExpressConfig>).bitgo = backupBitgo;
@@ -2420,6 +2378,44 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
24202378
bitgoKeyNock.done();
24212379
});
24222380

2381+
it('should return 202 with jobId when async mode is enabled for onchain wallet', async () => {
2382+
const bridgeUrl = 'http://bridge.invalid';
2383+
const jobId = 'test-job-id-123';
2384+
2385+
sinon.restore();
2386+
const asyncBitgo = new BitGoAPI({ env: 'test' });
2387+
const asyncConfig = makeConfig({
2388+
asyncModeConfig: {
2389+
enabled: true,
2390+
awmAsyncUrl: bridgeUrl,
2391+
pollIntervalInMs: 30000,
2392+
jobTtlInSeconds: 3600,
2393+
jobTtlMpcInSeconds: 7200,
2394+
},
2395+
});
2396+
2397+
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
2398+
(req as BitGoRequest<MasterExpressConfig>).bitgo = asyncBitgo;
2399+
(req as BitGoRequest<MasterExpressConfig>).config = asyncConfig;
2400+
next();
2401+
});
2402+
2403+
const asyncApp = expressApp(asyncConfig);
2404+
const asyncAgent = request.agent(asyncApp);
2405+
2406+
const bridgeNock = nock(bridgeUrl).post(`/api/${coin}/key/independent`).reply(202, { jobId });
2407+
2408+
const response = await asyncAgent
2409+
.post(`/api/v1/${coin}/advancedwallet/generate`)
2410+
.set('Authorization', `Bearer ${accessToken}`)
2411+
.send({ label: 'test_wallet', enterprise: 'test_enterprise', multisigType: 'onchain' });
2412+
2413+
response.status.should.equal(202);
2414+
response.body.should.have.property('jobId', jobId);
2415+
response.body.should.have.property('status', 'pending');
2416+
bridgeNock.done();
2417+
});
2418+
24232419
it('should fail when evmKeyRingReferenceWalletId is provided for a non-EVM coin', async () => {
24242420
const response = await agent
24252421
.post(`/api/v1/${coin}/advancedwallet/generate`)

src/__tests__/integration/generateWallet.integ.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'should';
2+
import assert from 'assert';
23
import { startServices, IntegServices } from './helpers/setup';
34
import { LOCALHOST } from './helpers/servers';
45
import { SigningMode } from '../../shared/types';
@@ -118,3 +119,52 @@ describe('Generate wallet: EXTERNAL signing', () => {
118119
walletAddCalls.should.have.length(1);
119120
});
120121
});
122+
123+
describe('Generate wallet: ASYNC mode', () => {
124+
let services: IntegServices;
125+
126+
before(async () => {
127+
services = await startServices({ asyncMode: true });
128+
});
129+
130+
after(async () => {
131+
await services.teardown();
132+
});
133+
134+
beforeEach(() => {
135+
assert(services.bridge, 'bridge must be defined in async mode');
136+
services.bridge.calls.length = 0;
137+
services.bitgo.calls.length = 0;
138+
services.keyProvider.calls.length = 0;
139+
});
140+
141+
it('submits onchain wallet generation to bridge and returns 202 with jobId', async () => {
142+
const res = await fetch(
143+
`http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/generate`,
144+
{
145+
method: 'POST',
146+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
147+
body: JSON.stringify({
148+
enterprise: 'test-enterprise',
149+
label: 'test-wallet',
150+
multisigType: 'onchain',
151+
}),
152+
},
153+
);
154+
155+
res.status.should.equal(202);
156+
const body = await res.json();
157+
body.should.have.property('jobId', 'test-job-id');
158+
body.should.have.property('status', 'pending');
159+
160+
/** Bridge received exactly 1 submit call */
161+
assert(services.bridge, 'bridge must be defined in async mode');
162+
services.bridge.calls.should.have.length(1);
163+
services.bridge.calls[0].path.should.equal('/api/tbtc/key/independent');
164+
165+
/** AWM and BitGo were never called — bridge owns the full job */
166+
services.keyProvider.calls.should.have.length(0);
167+
services.bitgo.calls.filter((c) => c.path.endsWith('/key')).should.have.length(0);
168+
services.bitgo.calls.filter((c) => c.path.endsWith('/wallet/add')).should.have.length(0);
169+
});
170+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as http from 'http';
2+
import express from 'express';
3+
import { listen, close } from './servers';
4+
5+
export interface MockBridgeCall {
6+
method: string;
7+
path: string;
8+
body: unknown;
9+
}
10+
11+
export interface MockBridgeServer {
12+
port: number;
13+
calls: MockBridgeCall[];
14+
close(): Promise<void>;
15+
}
16+
17+
export async function startMockBridgeServer(): Promise<MockBridgeServer> {
18+
const calls: MockBridgeCall[] = [];
19+
20+
const app = express();
21+
app.use(express.json());
22+
23+
app.use((req, _res, next) => {
24+
calls.push({ method: req.method, path: req.path, body: req.body });
25+
next();
26+
});
27+
28+
app.post('*', (_req, res) => {
29+
res.status(202).json({ jobId: 'test-job-id' });
30+
});
31+
32+
const server = http.createServer(app);
33+
const port = await listen(server);
34+
35+
return { port, calls, close: () => close(server) };
36+
}

src/__tests__/integration/helpers/setup.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@ import { DEFAULT_ASYNC_MODE_CONFIG } from '../../api/master/testUtils';
66
import { listen, close, LOCALHOST } from './servers';
77
import { startMockKeyProviderServer, MockKeyProviderServer } from './mockKeyProviderServer';
88
import { startMockBitgoServer, MockBitgoServer } from './mockBitgoServer';
9+
import { startMockBridgeServer, MockBridgeServer } from './mockBridgeServer';
910

1011
export interface IntegServices {
1112
mbePort: number;
1213
keyProvider: MockKeyProviderServer;
1314
bitgo: MockBitgoServer;
15+
bridge?: MockBridgeServer;
1416
teardown(): Promise<void>;
1517
}
1618

1719
export interface StartServicesOptions {
1820
signingMode?: SigningMode;
1921
recoveryMode?: boolean;
22+
asyncMode?: boolean;
2023
}
2124

2225
export async function startServices(opts: StartServicesOptions = {}): Promise<IntegServices> {
@@ -25,6 +28,7 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
2528

2629
const keyProvider = await startMockKeyProviderServer();
2730
const bitgo = await startMockBitgoServer();
31+
const bridge = opts.asyncMode ? await startMockBridgeServer() : undefined;
2832

2933
const awmServer = http.createServer(
3034
awmApp({
@@ -54,8 +58,16 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
5458
advancedWalletManagerUrl: `http://${LOCALHOST}:${awmPort}`,
5559
awmServerCertAllowSelfSigned: true,
5660
customRootUri: `http://${LOCALHOST}:${bitgo.port}`,
57-
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
5861
recoveryMode,
62+
asyncModeConfig: bridge
63+
? {
64+
enabled: true,
65+
awmAsyncUrl: `http://${LOCALHOST}:${bridge.port}`,
66+
pollIntervalInMs: 30000,
67+
jobTtlInSeconds: 3600,
68+
jobTtlMpcInSeconds: 7200,
69+
}
70+
: DEFAULT_ASYNC_MODE_CONFIG,
5971
}),
6072
);
6173
const mbePort = await listen(mbeServer);
@@ -64,11 +76,13 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
6476
mbePort,
6577
keyProvider,
6678
bitgo,
79+
bridge,
6780
async teardown() {
6881
await close(mbeServer);
6982
await close(awmServer);
7083
await keyProvider.close();
7184
await bitgo.close();
85+
if (bridge) await bridge.close();
7286
},
7387
};
7488
}

src/masterBitgoExpress/handlers/handleGenerateWallet.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { orchestrateEcdsaKeyGen } from './ecdsa';
1414
import { orchestrateEddsaKeyGen } from './eddsa';
1515
import coinFactory from '../../shared/coinFactory';
1616
import { BadRequestError } from '../../shared/errors';
17+
import { KeySource } from '../../shared/types';
18+
import { submitJobViaBridgeClient } from './utils/asyncUtils';
1719

1820
/**
1921
* Request handler for generating an advanced wallet.
@@ -40,6 +42,16 @@ export async function handleGenerateWallet(
4042
async function handleGenerateOnChainWallet(
4143
req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>,
4244
) {
45+
const asyncResult = await submitJobViaBridgeClient(req, {
46+
path: `/api/${req.params.coin}/key/independent`,
47+
body: req.decoded,
48+
sources: [KeySource.USER, KeySource.BACKUP],
49+
operationType: 'multisig_keygen',
50+
});
51+
if (asyncResult) {
52+
return asyncResult;
53+
}
54+
4355
const bitgo = req.bitgo;
4456
const baseCoin = await coinFactory.getCoin(req.params.coin, bitgo);
4557

@@ -144,6 +156,10 @@ async function handleGenerateOnChainWallet(
144156
async function handleGenerateMpcWallet(
145157
req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>,
146158
) {
159+
if (req.config.asyncModeConfig.enabled) {
160+
throw new BadRequestError('Async mode is not yet supported for TSS wallet generation');
161+
}
162+
147163
const bitgo = req.bitgo;
148164
const baseCoin = await coinFactory.getCoin(req.decoded.coin, bitgo);
149165
const awmClient = req.awmUserClient;
@@ -227,6 +243,10 @@ async function handleGenerateMpcWallet(
227243
async function handleGenerateEvmKeyRingWallet(
228244
req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>,
229245
) {
246+
if (req.config.asyncModeConfig.enabled) {
247+
throw new BadRequestError('Async mode is not yet supported for EVM keyring wallet generation');
248+
}
249+
230250
const bitgo = req.bitgo;
231251
const baseCoin = await coinFactory.getCoin(req.decoded.coin, bitgo);
232252
if (!baseCoin.isEVM()) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { SubmitParams } from '../../clients/bridgeClient.types';
2+
import { BitGoRequest } from '../../../types/request';
3+
import { MasterExpressConfig } from '../../../shared/types';
4+
5+
export const ASYNC_JOB_SUBMITTED_STATUS = 'pending' as const;
6+
export type AsyncJobSubmittedStatus = typeof ASYNC_JOB_SUBMITTED_STATUS;
7+
export type AsyncJobResponse = { jobId: string; status: AsyncJobSubmittedStatus };
8+
9+
/**
10+
* Submits a signing or keygen job to the bridge and returns { jobId, status: 'pending' }.
11+
* Returns null when async mode is off — callers must fall through to the sync path in that case.
12+
*/
13+
export async function submitJobViaBridgeClient(
14+
req: BitGoRequest<MasterExpressConfig>,
15+
params: SubmitParams,
16+
): Promise<AsyncJobResponse | null> {
17+
if (!req.config.asyncModeConfig.enabled) return null;
18+
if (!req.bridgeClient) {
19+
throw new Error('bridgeClient is required when async mode is enabled');
20+
}
21+
const { jobId } = await req.bridgeClient.submit(params);
22+
return { jobId, status: ASYNC_JOB_SUBMITTED_STATUS };
23+
}

0 commit comments

Comments
 (0)