diff --git a/modules/express/README.md b/modules/express/README.md
index f21074cdb6d..12273b7af05 100644
--- a/modules/express/README.md
+++ b/modules/express/README.md
@@ -232,7 +232,7 @@ BitGo Express is able to take configuration options from either command line arg
| N/A | --disableproxy | `BITGO_DISABLE_PROXY` 0 | N/A | Disable proxying of routes not explicitly handled by bitgo-express |
| N/A | --disableenvcheck | `BITGO_DISABLE_ENV_CHECK` 0 | N/A | Disable checking for correct `NODE_ENV` environment variable when running against BitGo production environment. |
| -i | --ipc | `BITGO_IPC` | N/A | If set, bind to the given IPC (unix domain) socket. Binding to an IPC socket can be useful if the caller of bitgo-express resides on the same host as the bitgo-express instance itself, since the socket can be secured using normal file permissions and ownership semantics. Note: This is not supported on Windows platforms. |
-| N/A | --authversion | `BITGO_AUTH_VERSION` | 2 | BitGo Authentication scheme version which should be used form making requests to the BitGo server. For more info on authentication scheme versions, view the API reference on the BitGo [Developer Portal](https://developers.bitgo.com/reference/overview#/). |
+| N/A | --authversion | `BITGO_AUTH_VERSION` | 2 | BitGo Authentication scheme version which should be used form making requests to the BitGo server. For more info on authentication scheme versions, view the API reference on the BitGo [Developer Portal](https://developers.bitgo.com/reference/overview#/). |
| N/A | --externalSignerUrl | `BITGO_EXTERNAL_SIGNER_URL` | N/A | URL specifying the external API to call for remote signing. |
| N/A | --signerMode | `BITGO_SIGNER_MODE ` | N/A | If set, run Express as a remote signer. |
| N/A | --signerFileSystemPath | `BITGO_SIGNER_FILE_SYSTEM_PATH ` | N/A | Local path specifying where an Express signer machine keeps the encrypted user private keys. Required when signerMode is set. |
@@ -265,6 +265,22 @@ To enable the `bitgo:v2:utxo` and `bitgo:express` debug namespaces, start BitGo
Wildcards using `*` are also supported. For example, all bitgo debug namespaces can be enabled with `--debug-namespace bitgo:*`, but beware, this can be very noisy.
+## Wallet Safes (client-side generation)
+
+Safe creation and child-wallet minting run local key ceremonies, so they must be called through BitGo Express rather than the BitGo API. The passphrase never leaves the Express host. Production Express requires TLS.
+
+| Method | Path | SDK method |
+| ------ | ------------------------------------------------------------------- | ---------------------- |
+| POST | `/api/v2/enterprise/{enterpriseId}/safes/generate` | `Safes.generateSafe` |
+| POST | `/api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets/generate` | `Safe.createWallet` |
+| POST | `/api/v2/enterprise/{enterpriseId}/safes/{safeId}/keys/generate` | `Safes.createSafeKeys` |
+
+`POST /api/v2/enterprise/{enterpriseId}/safes` (without `/generate`) is initialize-only and is proxied to the BitGo API. Listing, get, finalize, archive, freeze, and update are also proxied.
+
+Default Express timeout is 305 seconds. `generateSafe` can run up to four parallel root-key ceremonies; raise `--timeout` if a run is cut off.
+
+When signing a safe child wallet through existing Express send/sign routes, `WALLET_{walletId}_PASSPHRASE` is the **safe** passphrase (it decrypts the root user keychain).
+
# Release Notes
You can find the complete release notes (since version 4.44.0) [here](https://github.com/BitGo/BitGoJS/blob/master/CHANGELOG.md).
diff --git a/modules/express/package.json b/modules/express/package.json
index f62acb182c5..05f8a4e9f2e 100644
--- a/modules/express/package.json
+++ b/modules/express/package.json
@@ -41,6 +41,7 @@
"@api-ts/typed-express-router": "2.0.0",
"@bitgo/abstract-lightning": "^8.3.9",
"@bitgo/logger": "^1.4.0",
+ "@bitgo/public-types": "6.79.0",
"@bitgo/sdk-core": "^38.19.0",
"@bitgo/utxo-lib": "^11.24.4",
"@types/proxyquire": "^1.3.31",
@@ -60,7 +61,7 @@
"superagent": "^9.0.1"
},
"devDependencies": {
- "@bitgo/public-types": "6.79.0",
+
"@bitgo/sdk-lib-mpc": "^11.0.0",
"@bitgo/sdk-test": "^9.1.80",
"@types/argparse": "^1.0.36",
diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts
index bdcad288abe..f080cb87a4c 100755
--- a/modules/express/src/clientRoutes.ts
+++ b/modules/express/src/clientRoutes.ts
@@ -30,6 +30,7 @@ import {
IncorrectPasswordError,
MPCType,
multisigTypes,
+ Safes,
ShareType,
SignShare,
SShare,
@@ -704,6 +705,50 @@ export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'expres
return { ...result, wallet: result.wallet.toJSON() };
}
+/**
+ * One-shot Wallet Safe creation: initialize → local root-key ceremonies → finalize.
+ */
+export async function handleV2GenerateSafe(
+ req: ExpressApiRouteRequest<'express.v2.safes.generate', 'post'>
+): Promise {
+ const safes = new Safes(req.bitgo, req.decoded.enterpriseId);
+ const safe = await safes.generateSafe({
+ label: req.decoded.label,
+ passphrase: req.decoded.passphrase,
+ });
+ return safe.toJSON();
+}
+
+/**
+ * Mint a hot child wallet from a Wallet Safe (onchain hardened derive or TSS ceremony).
+ */
+export async function handleV2GenerateSafeWallet(
+ req: ExpressApiRouteRequest<'express.v2.safes.wallet.generate', 'post'>
+) {
+ const safes = new Safes(req.bitgo, req.decoded.enterpriseId);
+ const safe = await safes.get({ id: req.decoded.safeId });
+ const wallet = await safe.createWallet({
+ coin: req.decoded.coin,
+ label: req.decoded.label,
+ passphrase: req.decoded.passphrase,
+ multisigType: req.decoded.multisigType,
+ });
+ return wallet.toJSON();
+}
+
+/**
+ * Phase-2-only Safe key ceremonies for an already-initialized safe.
+ */
+export async function handleV2GenerateSafeKeys(req: ExpressApiRouteRequest<'express.v2.safes.keys.generate', 'post'>) {
+ const safes = new Safes(req.bitgo, req.decoded.enterpriseId);
+ return safes.createSafeKeys({
+ label: '',
+ passphrase: req.decoded.passphrase,
+ safeId: req.decoded.safeId,
+ enabledRootSlots: req.decoded.enabledRootSlots,
+ });
+}
+
/**
* Independently verify newly created address(es) against client-supplied trusted keychains.
*
@@ -2103,6 +2148,14 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
// generate wallet
router.post('express.wallet.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateWallet)]);
+ // generate wallet safe / mint a child wallet from a safe / phase-2 key ceremonies
+ router.post('express.v2.safes.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateSafe)]);
+ router.post('express.v2.safes.wallet.generate', [
+ prepareBitGo(config),
+ typedPromiseWrapper(handleV2GenerateSafeWallet),
+ ]);
+ router.post('express.v2.safes.keys.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateSafeKeys)]);
+
router.put('express.wallet.update', [prepareBitGo(config), typedPromiseWrapper(handleWalletUpdate)]);
// change wallet passphrase
diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts
index 85adc8a2eae..5df558606aa 100644
--- a/modules/express/src/typedRoutes/api/index.ts
+++ b/modules/express/src/typedRoutes/api/index.ts
@@ -35,6 +35,9 @@ import { PutFanoutUnspents } from './v1/fanoutUnspents';
import { PostOfcSignPayload } from './v2/ofcSignPayload';
import { PostWalletRecoverToken } from './v2/walletRecoverToken';
import { PostGenerateWallet } from './v2/generateWallet';
+import { PostGenerateSafe } from './v2/generateSafe';
+import { PostGenerateSafeWallet } from './v2/generateSafeWallet';
+import { PostGenerateSafeKeys } from './v2/generateSafeKeys';
import { PostSignerMacaroon } from './v2/signerMacaroon';
import { PostCoinSignTx } from './v2/coinSignTx';
import { PostWalletSignTx } from './v2/walletSignTx';
@@ -356,6 +359,18 @@ export const ExpressWalletManagementApiSpec = apiSpec({
},
});
+export const ExpressSafesApiSpec = apiSpec({
+ 'express.v2.safes.generate': {
+ post: PostGenerateSafe,
+ },
+ 'express.v2.safes.wallet.generate': {
+ post: PostGenerateSafeWallet,
+ },
+ 'express.v2.safes.keys.generate': {
+ post: PostGenerateSafeKeys,
+ },
+});
+
export const ExpressV2CanonicalAddressApiSpec = apiSpec({
'express.canonicaladdress': {
post: PostCanonicalAddress,
@@ -429,7 +444,8 @@ export type ExpressApi = typeof ExpressPingApiSpec &
typeof ExpressV2WalletResourceDelegationsApiSpec &
typeof ExpressV2WalletDelegateResourcesApiSpec &
typeof ExpressV2WalletUndelegateResourcesApiSpec &
- typeof ExpressWalletManagementApiSpec;
+ typeof ExpressWalletManagementApiSpec &
+ typeof ExpressSafesApiSpec;
export const ExpressApi: ExpressApi = {
...ExpressPingApiSpec,
@@ -476,6 +492,7 @@ export const ExpressApi: ExpressApi = {
...ExpressV2WalletDelegateResourcesApiSpec,
...ExpressV2WalletUndelegateResourcesApiSpec,
...ExpressWalletManagementApiSpec,
+ ...ExpressSafesApiSpec,
};
type ExtractDecoded = T extends t.Type ? O : never;
diff --git a/modules/express/src/typedRoutes/api/v2/generateSafe.ts b/modules/express/src/typedRoutes/api/v2/generateSafe.ts
new file mode 100644
index 00000000000..c9194cec8cf
--- /dev/null
+++ b/modules/express/src/typedRoutes/api/v2/generateSafe.ts
@@ -0,0 +1,64 @@
+import * as t from 'io-ts';
+import { httpRoute, httpRequest } from '@api-ts/io-ts-http';
+import { BitgoExpressError } from '../../schemas/error';
+
+/**
+ * Path parameters for one-shot Safe generation.
+ */
+export const GenerateSafeParams = {
+ /** Enterprise public id that owns the safe */
+ enterpriseId: t.string,
+} as const;
+
+/**
+ * Request body for one-shot Safe generation.
+ *
+ * `enabledRootSlots` is decided by the server at initialize (Flipt); do not send it here.
+ */
+export const GenerateSafeBody = {
+ /** Safe label */
+ label: t.string,
+ /** Passphrase used to encrypt locally generated root user/backup keys and to run MPC ceremonies */
+ passphrase: t.string,
+} as const;
+
+/**
+ * Response body for one-shot Safe generation.
+ */
+export const GenerateSafeResponse = {
+ /** The newly created, finalized safe */
+ 200: t.UnknownRecord,
+ /** Bad request */
+ 400: BitgoExpressError,
+} as const;
+
+/**
+ * Generate Safe
+ *
+ * One-shot Wallet Safe creation for hot custody. Runs locally on BitGo Express:
+ *
+ * 1. Initialize the safe on BitGo (metadata only).
+ * 2. Run the enabled root-key ceremonies on this machine (multisig keygen + MPC), encrypted with `passphrase`.
+ * 3. Finalize the safe with the minted root key ids.
+ *
+ * If a ceremony fails, the SDK archives the half-created safe and throws with the per-slot failure list.
+ * Create a new safe and retry.
+ *
+ * ⓘ This endpoint must be called through BitGo Express. `POST /api/v2/enterprise/{enterpriseId}/safes`
+ * (without `/generate`) is the server-side initialize-only call and does not run local crypto.
+ *
+ * ⓘ Production Express requires TLS. The passphrase never leaves the Express host.
+ *
+ * @operationId express.v2.safes.generate
+ * @tag Express
+ * @private
+ */
+export const PostGenerateSafe = httpRoute({
+ path: '/api/v2/enterprise/{enterpriseId}/safes/generate',
+ method: 'POST',
+ request: httpRequest({
+ params: GenerateSafeParams,
+ body: GenerateSafeBody,
+ }),
+ response: GenerateSafeResponse,
+});
diff --git a/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts b/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts
new file mode 100644
index 00000000000..064dbb94c57
--- /dev/null
+++ b/modules/express/src/typedRoutes/api/v2/generateSafeKeys.ts
@@ -0,0 +1,63 @@
+import * as t from 'io-ts';
+import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
+import { RootKeyType } from '@bitgo/public-types';
+import { BitgoExpressError } from '../../schemas/error';
+
+/**
+ * Path parameters for Phase-2-only Safe key ceremonies.
+ */
+export const GenerateSafeKeysParams = {
+ /** Enterprise public id that owns the safe */
+ enterpriseId: t.string,
+ /** Already-initialized safe id to tag the ceremonies with */
+ safeId: t.string,
+} as const;
+
+/**
+ * Request body for Phase-2-only Safe key ceremonies.
+ *
+ * `initializeSafe` and `finalizeSafe` remain direct server calls (no local crypto).
+ */
+export const GenerateSafeKeysBody = {
+ /** Passphrase used to encrypt locally generated root user/backup keys and to run MPC ceremonies */
+ passphrase: t.string,
+ /** Slots to run. Omit to run all four (older WP / retry of a full ceremony set). */
+ enabledRootSlots: optional(t.array(RootKeyType)),
+} as const;
+
+/**
+ * Response body for Phase-2-only Safe key ceremonies.
+ */
+export const GenerateSafeKeysResponse = {
+ /** Minted root key ids, as ordered [user, backup, bitgo] triplets — the payload `finalizeSafe` consumes */
+ 200: t.UnknownRecord,
+ /** Bad request */
+ 400: BitgoExpressError,
+} as const;
+
+/**
+ * Generate Safe Keys
+ *
+ * Phase 2 of Wallet Safe creation: run the enabled root-key ceremonies for an already-initialized
+ * safe. Use this when a thin client already called `POST /safes` (initialize) and only needs the
+ * local ceremonies before `POST .../finalize`.
+ *
+ * If a ceremony fails, the SDK archives the half-created safe and throws with the per-slot failure
+ * list. Create a new safe and retry.
+ *
+ * ⓘ This endpoint must be called through BitGo Express. One-shot creation is
+ * `POST /api/v2/enterprise/{enterpriseId}/safes/generate`.
+ *
+ * @operationId express.v2.safes.keys.generate
+ * @tag Express
+ * @private
+ */
+export const PostGenerateSafeKeys = httpRoute({
+ path: '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/keys/generate',
+ method: 'POST',
+ request: httpRequest({
+ params: GenerateSafeKeysParams,
+ body: GenerateSafeKeysBody,
+ }),
+ response: GenerateSafeKeysResponse,
+});
diff --git a/modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts b/modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts
new file mode 100644
index 00000000000..48d8ee0d5d2
--- /dev/null
+++ b/modules/express/src/typedRoutes/api/v2/generateSafeWallet.ts
@@ -0,0 +1,68 @@
+import * as t from 'io-ts';
+import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
+import { BitgoExpressError } from '../../schemas/error';
+import { WalletResponse, multisigType } from '../../schemas/wallet';
+
+/**
+ * Path parameters for minting a child wallet from a Safe.
+ */
+export const GenerateSafeWalletParams = {
+ /** Enterprise public id that owns the safe */
+ enterpriseId: t.string,
+ /** Safe id to mint the wallet from */
+ safeId: t.string,
+} as const;
+
+/**
+ * Request body for minting a child wallet from a Safe.
+ *
+ * Safe wallets are hot-only in v1. Child keys are derived locally; do not send `keys`.
+ */
+export const GenerateSafeWalletBody = {
+ /** Coin ticker / chain identifier for the child wallet */
+ coin: t.string,
+ /** Wallet label */
+ label: t.string,
+ /** Safe passphrase — decrypts the root user keychain and encrypts derived material */
+ passphrase: t.string,
+ /** `onchain` (default) mints a secp256k1/ed25519 multisig wallet; `tss` mints an MPC wallet */
+ multisigType: optional(multisigType),
+} as const;
+
+/**
+ * Response body for minting a child wallet from a Safe.
+ */
+export const GenerateSafeWalletResponse = {
+ /** The minted child wallet */
+ 200: WalletResponse,
+ /** Bad request */
+ 400: BitgoExpressError,
+} as const;
+
+/**
+ * Generate Safe Wallet
+ *
+ * Mint a hot child wallet from an existing Wallet Safe. Runs locally on BitGo Express:
+ *
+ * - `onchain`: decrypt the safe root, hardened-derive the user child, register it, then mint.
+ * - `tss`: decrypt the MPC root blob, run the user/BitGo hard-derive ceremony, then mint.
+ *
+ * ⓘ This endpoint must be called through BitGo Express. `POST /api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets`
+ * (without `/generate`) is the server-side mint that expects pre-built `keys`.
+ *
+ * ⓘ For later signing through Express, `WALLET_{walletId}_PASSPHRASE` is the **safe** passphrase
+ * (it decrypts the root user keychain, not a child envelope).
+ *
+ * @operationId express.v2.safes.wallet.generate
+ * @tag Express
+ * @private
+ */
+export const PostGenerateSafeWallet = httpRoute({
+ path: '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets/generate',
+ method: 'POST',
+ request: httpRequest({
+ params: GenerateSafeWalletParams,
+ body: GenerateSafeWalletBody,
+ }),
+ response: GenerateSafeWalletResponse,
+});
diff --git a/modules/express/test/unit/clientRoutes/generateSafe.ts b/modules/express/test/unit/clientRoutes/generateSafe.ts
new file mode 100644
index 00000000000..8a1066d7431
--- /dev/null
+++ b/modules/express/test/unit/clientRoutes/generateSafe.ts
@@ -0,0 +1,330 @@
+import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
+import { BitGo } from 'bitgo';
+import { common, decodeOrElse } from '@bitgo/sdk-core';
+import nock from 'nock';
+
+import 'should-http';
+import 'should-sinon';
+import '../../lib/asserts';
+
+import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api';
+import { handleV2GenerateSafe, handleV2GenerateSafeKeys, handleV2GenerateSafeWallet } from '../../../src/clientRoutes';
+import { GenerateSafeResponse } from '../../../src/typedRoutes/api/v2/generateSafe';
+import { GenerateSafeKeysResponse } from '../../../src/typedRoutes/api/v2/generateSafeKeys';
+import { GenerateSafeWalletResponse } from '../../../src/typedRoutes/api/v2/generateSafeWallet';
+
+const ENTERPRISE_ID = '6a217b164ae0ae10226a16569d3682ec';
+const SAFE_ID = 'safe123';
+const PASSPHRASE = 'mySecurePassphrase123';
+const USER_ROOT_ID = 'tbtc-user-root';
+const BACKUP_ROOT_ID = 'tbtc-backup-root';
+const BITGO_ROOT_ID = 'tbtc-bitgo-root';
+
+function safeDataWire(
+ overrides: {
+ label?: string;
+ status?: string;
+ rootKeys?: {
+ hot?: {
+ secp256k1Multisig?: [string, string, string];
+ };
+ };
+ } = {}
+) {
+ return {
+ id: SAFE_ID,
+ enterpriseId: ENTERPRISE_ID,
+ label: overrides.label ?? 'Test Safe',
+ status: overrides.status ?? 'active',
+ creator: 'user1',
+ users: [{ userId: 'user1', permissions: ['admin', 'spend'] }],
+ createdAt: '2026-07-07T00:00:00.000Z',
+ rootKeys: overrides.rootKeys,
+ };
+}
+
+function nockSecp256k1MultisigRootKeys(bgUrl: string) {
+ return [
+ nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { source?: string; safeId?: string }) => {
+ return body.source === 'user' && body.safeId === SAFE_ID;
+ })
+ .reply(200, { id: USER_ROOT_ID }),
+ nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { source?: string; safeId?: string }) => {
+ return body.source === 'backup' && body.safeId === SAFE_ID;
+ })
+ .reply(200, { id: BACKUP_ROOT_ID }),
+ nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { source?: string; safeId?: string }) => {
+ return body.source === 'bitgo' && body.safeId === SAFE_ID;
+ })
+ .reply(200, { id: BITGO_ROOT_ID, pub: 'bitgo-pub' }),
+ ];
+}
+
+describe('Generate Safe (typed handler)', () => {
+ let bitgo: TestBitGoAPI;
+ let bgUrl: string;
+
+ before(async function () {
+ if (!nock.isActive()) {
+ nock.activate();
+ }
+
+ bitgo = TestBitGo.decorate(BitGo, { env: 'test' });
+ bitgo.initializeTestVars();
+
+ bgUrl = common.Environments[bitgo.getEnv()].uri;
+
+ nock.disableNetConnect();
+ nock.enableNetConnect('127.0.0.1');
+ });
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ after(() => {
+ if (nock.isActive()) {
+ nock.restore();
+ }
+ });
+
+ it('should initialize, mint the enabled secp256k1Multisig root, and finalize', async () => {
+ const label = 'Test Safe';
+ const initializeNock = nock(bgUrl)
+ .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes`, { label })
+ .reply(200, {
+ id: SAFE_ID,
+ status: 'initializing',
+ enabledRootSlots: ['secp256k1Multisig'],
+ });
+
+ const keyNocks = nockSecp256k1MultisigRootKeys(bgUrl);
+
+ const finalizeNock = nock(bgUrl)
+ .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/finalize`, (body: { rootKeys?: unknown }) => {
+ return (
+ JSON.stringify(body.rootKeys) ===
+ JSON.stringify({
+ hot: {
+ secp256k1Multisig: [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID],
+ },
+ })
+ );
+ })
+ .reply(
+ 200,
+ safeDataWire({
+ label,
+ rootKeys: {
+ hot: {
+ secp256k1Multisig: [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID],
+ },
+ },
+ })
+ );
+
+ const req = {
+ bitgo,
+ params: {
+ enterpriseId: ENTERPRISE_ID,
+ },
+ query: {},
+ body: {
+ label,
+ passphrase: PASSPHRASE,
+ },
+ decoded: {
+ enterpriseId: ENTERPRISE_ID,
+ label,
+ passphrase: PASSPHRASE,
+ },
+ } as unknown as ExpressApiRouteRequest<'express.v2.safes.generate', 'post'>;
+
+ const res = await handleV2GenerateSafe(req);
+ decodeOrElse('GenerateSafeResponse', GenerateSafeResponse[200], JSON.parse(JSON.stringify(res)), (errors) => {
+ throw new Error(`Response did not match expected codec: ${JSON.stringify(errors)}`);
+ });
+
+ res.should.have.property('id', SAFE_ID);
+ res.should.have.property('label', label);
+ res.should.have.property('status', 'active');
+
+ initializeNock.done();
+ keyNocks.forEach((scope) => scope.done());
+ finalizeNock.done();
+ });
+
+ it('should archive the safe and throw when a root ceremony fails', async () => {
+ const label = 'Test Safe';
+ nock(bgUrl)
+ .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes`, { label })
+ .reply(200, {
+ id: SAFE_ID,
+ status: 'initializing',
+ enabledRootSlots: ['secp256k1Multisig'],
+ });
+
+ nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { source?: string }) => body.source === 'user')
+ .reply(500, { error: 'user key boom' });
+ nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { source?: string }) => body.source === 'backup')
+ .reply(200, { id: BACKUP_ROOT_ID });
+ nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { source?: string }) => body.source === 'bitgo')
+ .reply(200, { id: BITGO_ROOT_ID, pub: 'bitgo-pub' });
+
+ const archiveNock = nock(bgUrl)
+ .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/archive`)
+ .reply(200, safeDataWire({ status: 'archived' }));
+
+ const req = {
+ bitgo,
+ params: {
+ enterpriseId: ENTERPRISE_ID,
+ },
+ query: {},
+ body: {
+ label,
+ passphrase: PASSPHRASE,
+ },
+ decoded: {
+ enterpriseId: ENTERPRISE_ID,
+ label,
+ passphrase: PASSPHRASE,
+ },
+ } as unknown as ExpressApiRouteRequest<'express.v2.safes.generate', 'post'>;
+
+ await handleV2GenerateSafe(req).should.be.rejectedWith(/has been archived/);
+ archiveNock.done();
+ });
+
+ it('should run Phase-2 key ceremonies for an already-initialized safe', async () => {
+ const keyNocks = nockSecp256k1MultisigRootKeys(bgUrl);
+
+ const req = {
+ bitgo,
+ params: {
+ enterpriseId: ENTERPRISE_ID,
+ safeId: SAFE_ID,
+ },
+ query: {},
+ body: {
+ passphrase: PASSPHRASE,
+ enabledRootSlots: ['secp256k1Multisig'],
+ },
+ decoded: {
+ enterpriseId: ENTERPRISE_ID,
+ safeId: SAFE_ID,
+ passphrase: PASSPHRASE,
+ enabledRootSlots: ['secp256k1Multisig' as const],
+ },
+ } as unknown as ExpressApiRouteRequest<'express.v2.safes.keys.generate', 'post'>;
+
+ const res = await handleV2GenerateSafeKeys(req);
+ decodeOrElse('GenerateSafeKeysResponse', GenerateSafeKeysResponse[200], res, (errors) => {
+ throw new Error(`Response did not match expected codec: ${JSON.stringify(errors)}`);
+ });
+
+ res.should.have.property('rootKeys');
+ res.rootKeys.should.have.property('hot');
+ res.rootKeys.hot.should.have.property('secp256k1Multisig', [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID]);
+
+ keyNocks.forEach((scope) => scope.done());
+ });
+
+ it('should mint an onchain child wallet from a safe', async () => {
+ const label = 'Safe Child';
+ const walletId = 'wallet-from-safe';
+ const childKeyId = 'tbtc-child-user';
+ const userKeyPair = bitgo.coin('tbtc').keychains().create();
+ const encryptedPrv = await bitgo.encrypt({ input: userKeyPair.prv, password: PASSPHRASE });
+
+ const getSafeNock = nock(bgUrl)
+ .get(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}`)
+ .reply(
+ 200,
+ safeDataWire({
+ rootKeys: {
+ hot: {
+ secp256k1Multisig: [USER_ROOT_ID, BACKUP_ROOT_ID, BITGO_ROOT_ID],
+ },
+ },
+ })
+ );
+
+ const derivationNock = nock(bgUrl)
+ .get(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/derivation-index`)
+ .query({ slot: 'secp256k1Multisig' })
+ .reply(200, { slot: 'secp256k1Multisig', index: 0 });
+
+ const getRootKeyNock = nock(bgUrl).get(`/api/v2/tbtc/key/${USER_ROOT_ID}`).reply(200, {
+ id: USER_ROOT_ID,
+ pub: userKeyPair.pub,
+ source: 'user',
+ encryptedPrv,
+ });
+
+ const addChildNock = nock(bgUrl)
+ .post('/api/v2/tbtc/key', (body: { parent?: string; safeId?: string; source?: string }) => {
+ return body.parent === USER_ROOT_ID && body.safeId === SAFE_ID && body.source === 'user';
+ })
+ .reply(200, { id: childKeyId });
+
+ const mintNock = nock(bgUrl)
+ .post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets`, {
+ coin: 'tbtc',
+ label,
+ type: 'hot',
+ multisigType: 'onchain',
+ keys: [childKeyId],
+ })
+ .reply(200, {
+ id: walletId,
+ label,
+ coin: 'tbtc',
+ keys: [childKeyId],
+ type: 'hot',
+ multisigType: 'onchain',
+ });
+
+ const req = {
+ bitgo,
+ params: {
+ enterpriseId: ENTERPRISE_ID,
+ safeId: SAFE_ID,
+ },
+ query: {},
+ body: {
+ coin: 'tbtc',
+ label,
+ passphrase: PASSPHRASE,
+ },
+ decoded: {
+ enterpriseId: ENTERPRISE_ID,
+ safeId: SAFE_ID,
+ coin: 'tbtc',
+ label,
+ passphrase: PASSPHRASE,
+ },
+ } as unknown as ExpressApiRouteRequest<'express.v2.safes.wallet.generate', 'post'>;
+
+ const res = await handleV2GenerateSafeWallet(req);
+ decodeOrElse('GenerateSafeWalletResponse', GenerateSafeWalletResponse[200], res, (errors) => {
+ throw new Error(`Response did not match expected codec: ${JSON.stringify(errors)}`);
+ });
+
+ res.should.have.property('id', walletId);
+ res.should.have.property('label', label);
+ res.should.have.property('coin', 'tbtc');
+
+ getSafeNock.done();
+ derivationNock.done();
+ getRootKeyNock.done();
+ addChildNock.done();
+ mintNock.done();
+ });
+});
diff --git a/modules/express/test/unit/typedRoutes/generateSafe.ts b/modules/express/test/unit/typedRoutes/generateSafe.ts
new file mode 100644
index 00000000000..e852ef0b339
--- /dev/null
+++ b/modules/express/test/unit/typedRoutes/generateSafe.ts
@@ -0,0 +1,273 @@
+import * as assert from 'assert';
+import * as sinon from 'sinon';
+import { agent as supertest } from 'supertest';
+import 'should';
+import 'should-http';
+import 'should-sinon';
+import '../../lib/asserts';
+import { IncorrectPasswordError, Safes } from '@bitgo/sdk-core';
+import { PostGenerateSafe } from '../../../src/typedRoutes/api/v2/generateSafe';
+import { PostGenerateSafeWallet } from '../../../src/typedRoutes/api/v2/generateSafeWallet';
+import { PostGenerateSafeKeys } from '../../../src/typedRoutes/api/v2/generateSafeKeys';
+
+const ENTERPRISE_ID = '6a217b164ae0ae10226a16569d3682ec';
+const SAFE_ID = 'safe123';
+
+function mockSafeJson(label: string) {
+ return {
+ id: SAFE_ID,
+ enterpriseId: ENTERPRISE_ID,
+ label,
+ status: 'active' as const,
+ creator: 'user1',
+ users: [{ userId: 'user1', permissions: ['admin', 'spend'] }],
+ createdAt: new Date('2026-07-07T00:00:00.000Z'),
+ };
+}
+
+function stubSafe(partial: { toJSON?: () => unknown; createWallet?: sinon.SinonStub }) {
+ return partial as unknown as Awaited>;
+}
+
+describe('Generate Safe Typed Routes Tests', function () {
+ let agent: ReturnType;
+
+ before(function () {
+ const { app } = require('../../../src/expressApp');
+ const config = require('../../../src/config').DefaultConfig;
+ const testApp = app(config);
+ agent = supertest(testApp);
+ });
+
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ describe('express.v2.safes.generate', function () {
+ it('should generate a safe and return SafeData', async function () {
+ const label = 'Test Safe';
+ const passphrase = 'mySecurePassphrase123';
+ const safeJson = mockSafeJson(label);
+ const generateSafeStub = sinon.stub(Safes.prototype, 'generateSafe').resolves(
+ stubSafe({
+ toJSON: () => safeJson,
+ })
+ );
+
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
+ label,
+ passphrase,
+ });
+
+ res.status.should.equal(200);
+ res.body.should.have.property('id', SAFE_ID);
+ res.body.should.have.property('label', label);
+ res.body.should.have.property('status', 'active');
+ generateSafeStub.should.have.been.calledOnce();
+ generateSafeStub.firstCall.args[0].should.deepEqual({ label, passphrase });
+ });
+
+ it('should return 400 when label is missing', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
+ passphrase: 'password',
+ });
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ });
+
+ it('should return 400 when passphrase is missing', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
+ label: 'Test Safe',
+ });
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ });
+
+ it('should propagate ceremony-failure errors unwrapped', async function () {
+ sinon
+ .stub(Safes.prototype, 'generateSafe')
+ .rejects(
+ new Error(
+ 'Safe key generation failed for 1 root ceremony/ies [ecdsaMpc: boom]. The safe (safe123) has been archived; create a new safe and retry.'
+ )
+ );
+
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
+ label: 'Test Safe',
+ passphrase: 'password',
+ });
+
+ res.status.should.equal(500);
+ res.body.should.have.property('error');
+ res.body.error.should.match(/has been archived/);
+ });
+
+ it('should have correct route metadata', function () {
+ assert.strictEqual(PostGenerateSafe.method, 'POST');
+ assert.strictEqual(PostGenerateSafe.path, '/api/v2/enterprise/{enterpriseId}/safes/generate');
+ assert.ok(PostGenerateSafe.response[200]);
+ assert.ok(PostGenerateSafe.response[400]);
+ });
+ });
+
+ describe('express.v2.safes.wallet.generate', function () {
+ it('should mint a child wallet from a safe', async function () {
+ const label = 'Safe Child';
+ const passphrase = 'mySecurePassphrase123';
+ const coin = 'tsol';
+ const walletJson = {
+ id: 'wallet123',
+ coin,
+ label,
+ keys: ['userKey123', 'backupKey123', 'bitgoKey123'],
+ multisigType: 'tss' as const,
+ };
+ const createWalletStub = sinon.stub().resolves({
+ toJSON: () => walletJson,
+ });
+ sinon.stub(Safes.prototype, 'get').resolves(
+ stubSafe({
+ createWallet: createWalletStub,
+ })
+ );
+
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({
+ coin,
+ label,
+ passphrase,
+ multisigType: 'tss',
+ });
+
+ res.status.should.equal(200);
+ res.body.should.have.property('id', 'wallet123');
+ res.body.should.have.property('label', label);
+ res.body.should.have.property('multisigType', 'tss');
+ createWalletStub.should.have.been.calledOnce();
+ createWalletStub.firstCall.args[0].should.deepEqual({
+ coin,
+ label,
+ passphrase,
+ multisigType: 'tss',
+ });
+ });
+
+ it('should return 400 when coin is missing', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({
+ label: 'Safe Child',
+ passphrase: 'password',
+ });
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ });
+
+ it('should return 400 when passphrase is missing', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({
+ coin: 'tbtc',
+ label: 'Safe Child',
+ });
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ });
+
+ it('should return 400 when multisigType is invalid', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({
+ coin: 'tbtc',
+ label: 'Safe Child',
+ passphrase: 'password',
+ multisigType: 'cold',
+ });
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ res.body.error.should.match(/multisigType/);
+ });
+
+ it('should propagate IncorrectPasswordError', async function () {
+ sinon.stub(Safes.prototype, 'get').resolves(
+ stubSafe({
+ createWallet: sinon.stub().rejects(new IncorrectPasswordError()),
+ })
+ );
+
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/wallets/generate`).send({
+ coin: 'tbtc',
+ label: 'Safe Child',
+ passphrase: 'wrong',
+ });
+
+ res.status.should.equal(401);
+ res.body.should.have.property('error');
+ String(res.body.error || res.body.message).should.match(/passphrase/i);
+ });
+
+ it('should have correct route metadata', function () {
+ assert.strictEqual(PostGenerateSafeWallet.method, 'POST');
+ assert.strictEqual(
+ PostGenerateSafeWallet.path,
+ '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/wallets/generate'
+ );
+ assert.ok(PostGenerateSafeWallet.response[200]);
+ assert.ok(PostGenerateSafeWallet.response[400]);
+ });
+ });
+
+ describe('express.v2.safes.keys.generate', function () {
+ const rootKeys = {
+ rootKeys: {
+ hot: {
+ secp256k1Multisig: ['user-1', 'backup-1', 'bitgo-1'] as [string, string, string],
+ },
+ },
+ };
+
+ it('should run phase-2 ceremonies and return root key ids', async function () {
+ const passphrase = 'mySecurePassphrase123';
+ const createSafeKeysStub = sinon.stub(Safes.prototype, 'createSafeKeys').resolves(rootKeys);
+
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({
+ passphrase,
+ enabledRootSlots: ['secp256k1Multisig'],
+ });
+
+ res.status.should.equal(200);
+ res.body.should.have.property('rootKeys');
+ res.body.rootKeys.hot.secp256k1Multisig.should.deepEqual(['user-1', 'backup-1', 'bitgo-1']);
+ createSafeKeysStub.should.have.been.calledOnce();
+ createSafeKeysStub.firstCall.args[0].should.have.property('passphrase', passphrase);
+ createSafeKeysStub.firstCall.args[0].should.have.property('safeId', SAFE_ID);
+ const enabledRootSlots = createSafeKeysStub.firstCall.args[0].enabledRootSlots;
+ if (enabledRootSlots === undefined) {
+ throw new Error('expected enabledRootSlots');
+ }
+ enabledRootSlots.should.deepEqual(['secp256k1Multisig']);
+ });
+
+ it('should return 400 when passphrase is missing', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({});
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ });
+
+ it('should return 400 when enabledRootSlots contains an unknown slot', async function () {
+ const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({
+ passphrase: 'password',
+ enabledRootSlots: ['notASlot'],
+ });
+
+ res.status.should.equal(400);
+ res.body.should.have.property('error');
+ });
+
+ it('should have correct route metadata', function () {
+ assert.strictEqual(PostGenerateSafeKeys.method, 'POST');
+ assert.strictEqual(PostGenerateSafeKeys.path, '/api/v2/enterprise/{enterpriseId}/safes/{safeId}/keys/generate');
+ assert.ok(PostGenerateSafeKeys.response[200]);
+ assert.ok(PostGenerateSafeKeys.response[400]);
+ });
+ });
+});