Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 54 additions & 58 deletions src/__tests__/api/master/generateWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,10 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {

let bitgo: BitGoAPI;

before(() => {
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1');

// Create a BitGo instance that we'll use for stubbing
bitgo = new BitGoAPI({ env: 'test' });

const config: MasterExpressConfig = {
function makeConfig(overrides: Partial<MasterExpressConfig> = {}): MasterExpressConfig {
return {
appMode: AppMode.MASTER_EXPRESS,
port: 0, // Let OS assign a free port
port: 0,
bind: 'localhost',
timeout: 60000,
httpLoggerFile: '',
Expand All @@ -87,7 +81,17 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
tlsMode: TlsMode.DISABLED,
clientCertAllowSelfSigned: true,
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
...overrides,
};
}

before(() => {
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1');

bitgo = new BitGoAPI({ env: 'test' });

const config = makeConfig();

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

// Override middleware to inject a separate backup client
sinon.restore();
const backupBitgo = new BitGoAPI({ env: 'test' });
const configWithBackup: MasterExpressConfig = {
appMode: AppMode.MASTER_EXPRESS,
port: 0,
bind: 'localhost',
timeout: 60000,
httpLoggerFile: '',
env: 'test',
disableEnvCheck: true,
authVersion: 2,
advancedWalletManagerUrl: advancedWalletManagerUrl,
advancedWalletManagerBackupUrl: backupAwmUrl,
awmServerCaCert: 'dummy-cert',
tlsMode: TlsMode.DISABLED,
clientCertAllowSelfSigned: true,
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
};
const configWithBackup = makeConfig({ advancedWalletManagerBackupUrl: backupAwmUrl });

sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
(req as BitGoRequest<MasterExpressConfig>).bitgo = backupBitgo;
Expand Down Expand Up @@ -354,22 +342,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
.get('/api/v1/client/constants')
.reply(200, { constants: { mpc: { bitgoPublicKey: 'test-bitgo-public-key' } } });
const backupBitgo = new BitGoAPI({ env: 'test' });
const configWithBackup: MasterExpressConfig = {
appMode: AppMode.MASTER_EXPRESS,
port: 0,
bind: 'localhost',
timeout: 60000,
httpLoggerFile: '',
env: 'test',
disableEnvCheck: true,
authVersion: 2,
advancedWalletManagerUrl: advancedWalletManagerUrl,
advancedWalletManagerBackupUrl: backupAwmUrl,
awmServerCaCert: 'dummy-cert',
tlsMode: TlsMode.DISABLED,
clientCertAllowSelfSigned: true,
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
};
const configWithBackup = makeConfig({ advancedWalletManagerBackupUrl: backupAwmUrl });

sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
(req as BitGoRequest<MasterExpressConfig>).bitgo = backupBitgo;
Expand Down Expand Up @@ -1007,22 +980,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
.get('/api/v1/client/constants')
.reply(200, { constants: { mpc: { bitgoMPCv2PublicKey: 'test-bitgo-public-key' } } });
const backupBitgo = new BitGoAPI({ env: 'test' });
const configWithBackup: MasterExpressConfig = {
appMode: AppMode.MASTER_EXPRESS,
port: 0,
bind: 'localhost',
timeout: 60000,
httpLoggerFile: '',
env: 'test',
disableEnvCheck: true,
authVersion: 2,
advancedWalletManagerUrl: advancedWalletManagerUrl,
advancedWalletManagerBackupUrl: backupAwmUrl,
awmServerCaCert: 'dummy-cert',
tlsMode: TlsMode.DISABLED,
clientCertAllowSelfSigned: true,
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
};
const configWithBackup = makeConfig({ advancedWalletManagerBackupUrl: backupAwmUrl });

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

it('should return 202 with jobId when async mode is enabled for onchain wallet', async () => {
const bridgeUrl = 'http://bridge.invalid';
const jobId = 'test-job-id-123';

sinon.restore();
const asyncBitgo = new BitGoAPI({ env: 'test' });
const asyncConfig = makeConfig({
asyncModeConfig: {
enabled: true,
awmAsyncUrl: bridgeUrl,
pollIntervalInMs: 30000,
jobTtlInSeconds: 3600,
jobTtlMpcInSeconds: 7200,
},
});

sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
(req as BitGoRequest<MasterExpressConfig>).bitgo = asyncBitgo;
(req as BitGoRequest<MasterExpressConfig>).config = asyncConfig;
next();
});

const asyncApp = expressApp(asyncConfig);
const asyncAgent = request.agent(asyncApp);

const bridgeNock = nock(bridgeUrl).post(`/api/${coin}/key/independent`).reply(202, { jobId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert async tests for bridge contract (header and body) as we just have jobId and status here now.


const response = await asyncAgent
.post(`/api/v1/${coin}/advancedwallet/generate`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ label: 'test_wallet', enterprise: 'test_enterprise', multisigType: 'onchain' });

response.status.should.equal(202);
response.body.should.have.property('jobId', jobId);
response.body.should.have.property('status', 'pending');
bridgeNock.done();
});

it('should fail when evmKeyRingReferenceWalletId is provided for a non-EVM coin', async () => {
const response = await agent
.post(`/api/v1/${coin}/advancedwallet/generate`)
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/integration/generateWallet.integ.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'should';
import assert from 'assert';
import { startServices, IntegServices } from './helpers/setup';
import { LOCALHOST } from './helpers/servers';
import { SigningMode } from '../../shared/types';
Expand Down Expand Up @@ -118,3 +119,52 @@ describe('Generate wallet: EXTERNAL signing', () => {
walletAddCalls.should.have.length(1);
});
});

describe('Generate wallet: ASYNC mode', () => {
let services: IntegServices;

before(async () => {
services = await startServices({ asyncMode: true });
});

after(async () => {
await services.teardown();
});

beforeEach(() => {
assert(services.bridge, 'bridge must be defined in async mode');
services.bridge.calls.length = 0;
services.bitgo.calls.length = 0;
services.keyProvider.calls.length = 0;
});

it('submits onchain wallet generation to bridge and returns 202 with jobId', async () => {
const res = await fetch(
`http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/generate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({
enterprise: 'test-enterprise',
label: 'test-wallet',
multisigType: 'onchain',
}),
},
);

res.status.should.equal(202);
const body = await res.json();
body.should.have.property('jobId', 'test-job-id');
body.should.have.property('status', 'pending');

/** Bridge received exactly 1 submit call */
assert(services.bridge, 'bridge must be defined in async mode');
services.bridge.calls.should.have.length(1);
services.bridge.calls[0].path.should.equal('/api/tbtc/key/independent');

/** AWM and BitGo were never called — bridge owns the full job */
services.keyProvider.calls.should.have.length(0);
services.bitgo.calls.filter((c) => c.path.endsWith('/key')).should.have.length(0);
services.bitgo.calls.filter((c) => c.path.endsWith('/wallet/add')).should.have.length(0);
});
});
36 changes: 36 additions & 0 deletions src/__tests__/integration/helpers/mockBridgeServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as http from 'http';
import express from 'express';
import { listen, close } from './servers';

export interface MockBridgeCall {
method: string;
path: string;
body: unknown;
}

export interface MockBridgeServer {
port: number;
calls: MockBridgeCall[];
close(): Promise<void>;
}

export async function startMockBridgeServer(): Promise<MockBridgeServer> {
const calls: MockBridgeCall[] = [];

const app = express();
app.use(express.json());

app.use((req, _res, next) => {
calls.push({ method: req.method, path: req.path, body: req.body });
next();
});

app.post('*', (_req, res) => {
res.status(202).json({ jobId: 'test-job-id' });
});

const server = http.createServer(app);
const port = await listen(server);

return { port, calls, close: () => close(server) };
}
16 changes: 15 additions & 1 deletion src/__tests__/integration/helpers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ import { DEFAULT_ASYNC_MODE_CONFIG } from '../../api/master/testUtils';
import { listen, close, LOCALHOST } from './servers';
import { startMockKeyProviderServer, MockKeyProviderServer } from './mockKeyProviderServer';
import { startMockBitgoServer, MockBitgoServer } from './mockBitgoServer';
import { startMockBridgeServer, MockBridgeServer } from './mockBridgeServer';

export interface IntegServices {
mbePort: number;
keyProvider: MockKeyProviderServer;
bitgo: MockBitgoServer;
bridge?: MockBridgeServer;
teardown(): Promise<void>;
}

export interface StartServicesOptions {
signingMode?: SigningMode;
recoveryMode?: boolean;
asyncMode?: boolean;
}

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

const keyProvider = await startMockKeyProviderServer();
const bitgo = await startMockBitgoServer();
const bridge = opts.asyncMode ? await startMockBridgeServer() : undefined;

const awmServer = http.createServer(
awmApp({
Expand Down Expand Up @@ -54,8 +58,16 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
advancedWalletManagerUrl: `http://${LOCALHOST}:${awmPort}`,
awmServerCertAllowSelfSigned: true,
customRootUri: `http://${LOCALHOST}:${bitgo.port}`,
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
recoveryMode,
asyncModeConfig: bridge
? {
enabled: true,
awmAsyncUrl: `http://${LOCALHOST}:${bridge.port}`,
pollIntervalInMs: 30000,
jobTtlInSeconds: 3600,
jobTtlMpcInSeconds: 7200,
}
: DEFAULT_ASYNC_MODE_CONFIG,
}),
);
const mbePort = await listen(mbeServer);
Expand All @@ -64,11 +76,13 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
mbePort,
keyProvider,
bitgo,
bridge,
async teardown() {
await close(mbeServer);
await close(awmServer);
await keyProvider.close();
await bitgo.close();
if (bridge) await bridge.close();
},
};
}
20 changes: 20 additions & 0 deletions src/masterBitgoExpress/handlers/handleGenerateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { orchestrateEcdsaKeyGen } from './ecdsa';
import { orchestrateEddsaKeyGen } from './eddsa';
import coinFactory from '../../shared/coinFactory';
import { BadRequestError } from '../../shared/errors';
import { KeySource } from '../../shared/types';
import { submitJobViaBridgeClient } from './utils/asyncUtils';

/**
* Request handler for generating an advanced wallet.
Expand All @@ -40,6 +42,16 @@ export async function handleGenerateWallet(
async function handleGenerateOnChainWallet(
req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>,
) {
const asyncResult = await submitJobViaBridgeClient(req, {
path: `/api/${req.params.coin}/key/independent`,
body: req.decoded,
sources: [KeySource.USER, KeySource.BACKUP],
operationType: 'multisig_keygen',
});
Comment on lines +45 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is req.decoded the correct bridge body (not some other trimmed/transformed payload)?

@pranishnepal pranishnepal Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're supposed to send the request as is - believe the typing is unknown 🤔

if (asyncResult) {
return asyncResult;
}

const bitgo = req.bitgo;
const baseCoin = await coinFactory.getCoin(req.params.coin, bitgo);

Expand Down Expand Up @@ -144,6 +156,10 @@ async function handleGenerateOnChainWallet(
async function handleGenerateMpcWallet(
req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>,
) {
if (req.config.asyncModeConfig.enabled) {
throw new BadRequestError('Async mode is not yet supported for TSS wallet generation');
}

Comment on lines +159 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add unit tests for async guards

const bitgo = req.bitgo;
const baseCoin = await coinFactory.getCoin(req.decoded.coin, bitgo);
const awmClient = req.awmUserClient;
Expand Down Expand Up @@ -227,6 +243,10 @@ async function handleGenerateMpcWallet(
async function handleGenerateEvmKeyRingWallet(
req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>,
) {
if (req.config.asyncModeConfig.enabled) {
throw new BadRequestError('Async mode is not yet supported for EVM keyring wallet generation');
}

const bitgo = req.bitgo;
const baseCoin = await coinFactory.getCoin(req.decoded.coin, bitgo);
if (!baseCoin.isEVM()) {
Expand Down
23 changes: 23 additions & 0 deletions src/masterBitgoExpress/handlers/utils/asyncUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { SubmitParams } from '../../clients/bridgeClient.types';
import { BitGoRequest } from '../../../types/request';
import { MasterExpressConfig } from '../../../shared/types';

export const ASYNC_JOB_SUBMITTED_STATUS = 'pending' as const;
export type AsyncJobSubmittedStatus = typeof ASYNC_JOB_SUBMITTED_STATUS;
export type AsyncJobResponse = { jobId: string; status: AsyncJobSubmittedStatus };

/**
* Submits a signing or keygen job to the bridge and returns { jobId, status: 'pending' }.
* Returns null when async mode is off — callers must fall through to the sync path in that case.
*/
export async function submitJobViaBridgeClient(
req: BitGoRequest<MasterExpressConfig>,
params: SubmitParams,
): Promise<AsyncJobResponse | null> {
if (!req.config.asyncModeConfig.enabled) return null;
if (!req.bridgeClient) {
throw new Error('bridgeClient is required when async mode is enabled');
}
const { jobId } = await req.bridgeClient.submit(params);
return { jobId, status: ASYNC_JOB_SUBMITTED_STATUS };
}
Loading
Loading