From 9156c78209ab2d3ee968c82e0f8b242ce08f0e04 Mon Sep 17 00:00:00 2001 From: Daniel Zhao Date: Mon, 22 Sep 2025 18:06:12 -0400 Subject: [PATCH 1/7] refactor: migrated generateWallet to typed routes TICKET: WP-5415 --- modules/express/src/clientRoutes.ts | 4 +- modules/express/src/typedRoutes/api/index.ts | 4 + .../src/typedRoutes/api/v2/generate.ts | 113 ++++++++++ modules/express/src/wallet/codec.ts | 41 ++++ .../test/unit/clientRoutes/generateWallet.ts | 208 +++++++++++++++--- 5 files changed, 333 insertions(+), 37 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/generate.ts create mode 100644 modules/express/src/wallet/codec.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f6433eb73b..6a69911cb2 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -637,7 +637,7 @@ export async function handleV2OFCSignPayload( * handle new wallet creation * @param req */ -export async function handleV2GenerateWallet(req: express.Request) { +export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'express.wallet.generate', 'post'>) { const bitgo = req.bitgo; const coin = bitgo.coin(req.params.coin); const result = await coin.wallets().generateWallet(req.body); @@ -1605,7 +1605,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.keychain.local', [prepareBitGo(config), typedPromiseWrapper(handleV2CreateLocalKeyChain)]); // generate wallet - app.post('/api/v2/:coin/wallet/generate', parseBody, prepareBitGo(config), promiseWrapper(handleV2GenerateWallet)); + router.post('express.wallet.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateWallet)]); app.put('/express/api/v2/:coin/wallet/:id', parseBody, prepareBitGo(config), promiseWrapper(handleWalletUpdate)); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index b4db56f42d..735f7a08a2 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -26,6 +26,7 @@ import { PostCreateAddress } from './v2/createAddress'; import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; +import { PostGenerateWallet } from './v2/generate'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -100,6 +101,9 @@ export const ExpressApi = apiSpec({ 'express.v2.wallet.recovertoken': { post: PostWalletRecoverToken, }, + 'express.wallet.generate': { + post: PostGenerateWallet, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v2/generate.ts b/modules/express/src/typedRoutes/api/v2/generate.ts new file mode 100644 index 0000000000..a28a426fc3 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/generate.ts @@ -0,0 +1,113 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { UserKeychainCodec, BackupKeychainCodec, BitgoKeychainCodec } from '../../../wallet/codec'; + +/** + * Request body for wallet generation. + */ +export const GenerateWalletBody = { + /** Wallet label */ + label: t.string, + /** Enterprise id. This is required for Ethereum wallets since they can only be created as part of an enterprise */ + enterprise: t.string, + /** If absent, BitGo uses the default wallet type for the asset */ + multisigType: optional(t.union([t.literal('onchain'), t.literal('tss'), t.literal('blsdkg')])), + /** The type of wallet, defined by key management and signing protocols. 'hot' and 'cold' are both self-managed wallets. If absent, defaults to 'hot' */ + type: optional(t.union([t.literal('hot'), t.literal('cold'), t.literal('custodial')])), + /** Passphrase to be used to encrypt the user key on the wallet */ + passphrase: optional(t.string), + /** User provided public key */ + userKey: optional(t.string), + /** Backup extended public key */ + backupXpub: optional(t.string), + /** Optional key recovery service to provide and store the backup key */ + backupXpubProvider: optional(t.literal('dai')), + /** Flag for disabling wallet transaction notifications */ + disableTransactionNotifications: optional(t.boolean), + /** The passphrase used for decrypting the encrypted wallet passphrase during wallet recovery */ + passcodeEncryptionCode: optional(t.string), + /** Seed that derives an extended user key or common keychain for a cold wallet */ + coldDerivationSeed: optional(t.string), + /** Gas price to use when deploying an Ethereum wallet */ + gasPrice: optional(t.number), + /** Flag for preventing KRS from sending email after creating backup key */ + disableKRSEmail: optional(t.boolean), + /** (ETH only) Specify the wallet creation contract version used when creating a wallet contract */ + walletVersion: optional(t.number), + /** True, if the wallet type is a distributed-custodial. If passed, you must also pass the 'enterprise' parameter */ + isDistributedCustody: optional(t.boolean), + /** BitGo key ID for self-managed cold MPC wallets */ + bitgoKeyId: optional(t.string), + /** Common keychain for self-managed cold MPC wallets */ + commonKeychain: optional(t.string), +} as const; + +export const GenerateWalletResponse200 = t.union([ + t.UnknownRecord, + t.type({ + wallet: t.UnknownRecord, + encryptedWalletPassphrase: optional(t.string), + userKeychain: optional(UserKeychainCodec), + backupKeychain: optional(BackupKeychainCodec), + bitgoKeychain: optional(BitgoKeychainCodec), + warning: optional(t.string), + }), +]); + +/** + * Response body for wallet generation. + */ +export const GenerateWalletResponse = { + /** The newly created wallet */ + 200: GenerateWalletResponse200, + /** Bad request */ + 400: BitgoExpressError, +} as const; + +/** + * Path parameters for wallet generation. + */ +export const GenerateWalletV2Params = { + /** Coin ticker / chain identifier */ + coin: t.string, +}; + +/** + * Query parameters for wallet generation. + * @property includeKeychains - Include user, backup and bitgo keychains along with generated wallet + */ +export const GenerateWalletV2Query = { + /** Include user, backup and bitgo keychains along with generated wallet */ + includeKeychains: optional(t.string), +}; + +/** + * Generate Wallet + * + * This API call creates a new wallet. Under the hood, the SDK (or BitGo Express) does the following: + * + * 1. Creates the user keychain locally on the machine, and encrypts it with the provided passphrase (skipped if userKey is provided). + * 2. Creates the backup keychain locally on the machine. + * 3. Uploads the encrypted user keychain and public backup keychain. + * 4. Creates the BitGo key (and the backup key if backupXpubProvider is set) on the service. + * 5. Creates the wallet on BitGo with the 3 public keys above. + * + * ⓘ Ethereum wallets can only be created under an enterprise. Pass in the id of the enterprise to associate the wallet with. Your enterprise id can be seen by clicking on the "Manage Organization" link on the enterprise dropdown. Each enterprise has a fee address which will be used to pay for transaction fees on all Ethereum wallets in that enterprise. The fee address is displayed in the dashboard of the website, please fund it before creating a wallet. + * + * ⓘ You cannot generate a wallet by passing in a subtoken as the coin. Subtokens share wallets with their parent coin and it is not possible to create a wallet specific to one token. + * + * ⓘ This endpoint should be called through BitGo Express if used without the SDK, such as when using cURL. + * + * @operationId express.wallet.generate + */ +export const PostGenerateWallet = httpRoute({ + path: '/api/v2/:coin/wallet/generate', + method: 'POST', + request: httpRequest({ + params: GenerateWalletV2Params, + query: GenerateWalletV2Query, + body: GenerateWalletBody, + }), + response: GenerateWalletResponse, +}); diff --git a/modules/express/src/wallet/codec.ts b/modules/express/src/wallet/codec.ts new file mode 100644 index 0000000000..f15b896cde --- /dev/null +++ b/modules/express/src/wallet/codec.ts @@ -0,0 +1,41 @@ +import * as t from 'io-ts'; + +// Base keychain fields +const BaseKeychainCodec = t.type({ + id: t.string, + pub: t.string, + source: t.string, +}); + +// User keychain: can have encryptedPrv and prv +export const UserKeychainCodec = t.intersection([ + BaseKeychainCodec, + t.partial({ + ethAddress: t.string, + coinSpecific: t.UnknownRecord, + encryptedPrv: t.string, + prv: t.string, + }), +]); + +// Backup keychain: can have prv +export const BackupKeychainCodec = t.intersection([ + BaseKeychainCodec, + t.partial({ + ethAddress: t.string, + coinSpecific: t.UnknownRecord, + prv: t.string, + }), +]); + +// BitGo keychain: must have isBitGo +export const BitgoKeychainCodec = t.intersection([ + BaseKeychainCodec, + t.type({ + isBitGo: t.boolean, + }), + t.partial({ + ethAddress: t.string, + coinSpecific: t.UnknownRecord, + }), +]); diff --git a/modules/express/test/unit/clientRoutes/generateWallet.ts b/modules/express/test/unit/clientRoutes/generateWallet.ts index 52baca2017..977b017a11 100644 --- a/modules/express/test/unit/clientRoutes/generateWallet.ts +++ b/modules/express/test/unit/clientRoutes/generateWallet.ts @@ -1,63 +1,185 @@ -import * as sinon from 'sinon'; +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 * as express from 'express'; - +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; import { handleV2GenerateWallet } from '../../../src/clientRoutes'; - -import { BitGo } from 'bitgo'; -import { BaseCoin, Wallets, WalletWithKeychains } from '@bitgo/sdk-core'; +import { GenerateWalletResponse } from '../../../src/typedRoutes/api/v2/generate'; describe('Generate Wallet', () => { + let bitgo: TestBitGoAPI; + let bgUrl: string; + const coin = 'tbtc'; + + 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'); + }); + + after(() => { + if (nock.isActive()) { + nock.restore(); + } + }); + it('should return the internal wallet object and keychains by default or if includeKeychains is true', async () => { - const walletStub = sinon - .stub<[], Promise>() - .resolves({ wallet: { toJSON: () => 'walletdata with keychains' } } as any); - const walletsStub = sinon.createStubInstance(Wallets, { generateWallet: walletStub }); - const coinStub = sinon.createStubInstance(BaseCoin, { - wallets: sinon.stub<[], Wallets>().returns(walletsStub as any), - }); - const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); - const walletGenerateBody = {}; - const coin = 'tbtc'; + // Mock keychain creation calls + const userKeychainId = 'user-keychain-id'; + const backupKeychainId = 'backup-keychain-id'; + const bitgoKeychainId = 'bitgo-keychain-id'; + const walletId = 'wallet-id'; + + // Mock wallet creation + const walletNock = nock(bgUrl) + .post(`/api/v2/${coin}/wallet/add`) + .times(2) + .reply(200, { + id: walletId, + label: 'Test Wallet', + keys: [userKeychainId, backupKeychainId, bitgoKeychainId], + coin: coin, + }); + + // Mock keychain retrieval calls + const keychainRetrievalNocks = [ + nock(bgUrl).get(`/api/v2/${coin}/key/${userKeychainId}`).times(2).reply(200, { + id: userKeychainId, + pub: 'user-pub-key', + source: 'user', + encryptedPrv: 'encrypted-user-prv', + }), + nock(bgUrl).get(`/api/v2/${coin}/key/${backupKeychainId}`).times(2).reply(200, { + id: backupKeychainId, + pub: 'backup-pub-key', + source: 'backup', + prv: 'backup-prv', + }), + nock(bgUrl).get(`/api/v2/${coin}/key/${bitgoKeychainId}`).times(2).reply(200, { + id: bitgoKeychainId, + pub: 'bitgo-pub-key', + source: 'bitgo', + isBitGo: true, + }), + ]; + + const walletGenerateBody = { + label: 'Test Wallet', + type: 'custodial', + enterprise: 'test-enterprise', + } as const; + const reqDefault = { - bitgo: stubBitgo, + bitgo, params: { coin, }, query: {}, body: walletGenerateBody, - } as unknown as express.Request; + decoded: { + ...walletGenerateBody, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; + const reqIncludeKeychains = { - bitgo: stubBitgo, + bitgo, params: { coin, }, query: { - includeKeychains: true, + includeKeychains: 'true', }, body: walletGenerateBody, - } as unknown as express.Request; + decoded: { + ...walletGenerateBody, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; - await handleV2GenerateWallet(reqDefault).should.be.resolvedWith({ wallet: 'walletdata with keychains' }); - await handleV2GenerateWallet(reqIncludeKeychains).should.be.resolvedWith({ wallet: 'walletdata with keychains' }); + const resDefault = await handleV2GenerateWallet(reqDefault); + decodeOrElse('GenerateWalletResponse', GenerateWalletResponse[200], resDefault, (_) => { + throw new Error('Response did not match expected codec'); + }); + + const resIncludeKeychains = await handleV2GenerateWallet(reqIncludeKeychains); + decodeOrElse('GenerateWalletResponse', GenerateWalletResponse[200], resIncludeKeychains, (_) => { + throw new Error('Response did not match expected codec'); + }); + + // Double verify the responses contain the expected structure + resDefault.should.have.property('wallet'); + resDefault.should.have.property('userKeychain'); + resDefault.should.have.property('backupKeychain'); + resDefault.should.have.property('bitgoKeychain'); + + resIncludeKeychains.should.have.property('wallet'); + resIncludeKeychains.should.have.property('userKeychain'); + resIncludeKeychains.should.have.property('backupKeychain'); + resIncludeKeychains.should.have.property('bitgoKeychain'); + + walletNock.done(); + keychainRetrievalNocks.forEach((nock) => nock.done()); }); it('should only return wallet data if includeKeychains query param is false', async () => { - const walletsStub = sinon.createStubInstance(Wallets, { - generateWallet: { wallet: { toJSON: () => 'walletdata' } } as any, - }); - const coinStub = sinon.createStubInstance(BaseCoin, { - wallets: sinon.stub<[], Wallets>().returns(walletsStub as any), - }); - const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); - const walletGenerateBody = {}; - const coin = 'tbtc'; + // Mock keychain creation calls + const userKeychainId = 'user-keychain-id'; + const backupKeychainId = 'backup-keychain-id'; + const bitgoKeychainId = 'bitgo-keychain-id'; + const walletId = 'wallet-id'; + + // Mock wallet creation + const walletNock = nock(bgUrl) + .post(`/api/v2/${coin}/wallet/add`) + .reply(200, { + id: walletId, + label: 'Test Wallet', + keys: [userKeychainId, backupKeychainId, bitgoKeychainId], + coin: coin, + }); + + // Mock keychain retrieval calls + const keychainRetrievalNocks = [ + nock(bgUrl).get(`/api/v2/${coin}/key/${userKeychainId}`).reply(200, { + id: userKeychainId, + pub: 'user-pub-key', + source: 'user', + encryptedPrv: 'encrypted-user-prv', + }), + nock(bgUrl).get(`/api/v2/${coin}/key/${backupKeychainId}`).reply(200, { + id: backupKeychainId, + pub: 'backup-pub-key', + source: 'backup', + prv: 'backup-prv', + }), + nock(bgUrl).get(`/api/v2/${coin}/key/${bitgoKeychainId}`).reply(200, { + id: bitgoKeychainId, + pub: 'bitgo-pub-key', + source: 'bitgo', + isBitGo: true, + }), + ]; + + const walletGenerateBody = { + label: 'Test Wallet', + type: 'custodial', + enterprise: 'test-enterprise', + } as const; + const req = { - bitgo: stubBitgo, + bitgo, params: { coin, }, @@ -65,8 +187,24 @@ describe('Generate Wallet', () => { includeKeychains: 'false', }, body: walletGenerateBody, - } as unknown as express.Request; + decoded: { + ...walletGenerateBody, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; + + const res = await handleV2GenerateWallet(req); + decodeOrElse('GenerateWalletResponse', GenerateWalletResponse[200], res, (_) => { + throw new Error('Response did not match expected codec'); + }); - await handleV2GenerateWallet(req).should.be.resolvedWith('walletdata'); + // When includeKeychains is false, should only return wallet data + res.should.have.property('id', walletId); + res.should.have.property('label', 'Test Wallet'); + res.should.have.property('coin', coin); + res.should.not.have.property('userKeychain'); + res.should.not.have.property('backupKeychain'); + res.should.not.have.property('bitgoKeychain'); + walletNock.done(); + keychainRetrievalNocks.forEach((nock) => nock.done()); }); }); From 70d48adfc2ab98160f4d8c653d53aec8145e7673 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Thu, 25 Sep 2025 12:26:30 -0400 Subject: [PATCH 2/7] refactor: moved codecs and changed generate.ts->generateWallet.ts TICKET: WP-5415 --- modules/express/src/clientRoutes.ts | 2 +- modules/express/src/typedRoutes/api/index.ts | 2 +- .../typedRoutes/api/v2/{generate.ts => generateWallet.ts} | 7 ++++--- .../{wallet/codec.ts => typedRoutes/schemas/keychain.ts} | 0 modules/express/src/typedRoutes/schemas/wallet.ts | 5 +++++ modules/express/test/unit/clientRoutes/generateWallet.ts | 5 ++++- 6 files changed, 15 insertions(+), 6 deletions(-) rename modules/express/src/typedRoutes/api/v2/{generate.ts => generateWallet.ts} (95%) rename modules/express/src/{wallet/codec.ts => typedRoutes/schemas/keychain.ts} (100%) create mode 100644 modules/express/src/typedRoutes/schemas/wallet.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 6a69911cb2..be5dcbb1a9 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -639,7 +639,7 @@ export async function handleV2OFCSignPayload( */ export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'express.wallet.generate', 'post'>) { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); + const coin = bitgo.coin(req.decoded.coin); const result = await coin.wallets().generateWallet(req.body); if (req.query.includeKeychains === 'false') { return result.wallet.toJSON(); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 735f7a08a2..dc86216a3e 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -26,7 +26,7 @@ import { PostCreateAddress } from './v2/createAddress'; import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; -import { PostGenerateWallet } from './v2/generate'; +import { PostGenerateWallet } from './v2/generateWallet'; export const ExpressApi = apiSpec({ 'express.ping': { diff --git a/modules/express/src/typedRoutes/api/v2/generate.ts b/modules/express/src/typedRoutes/api/v2/generateWallet.ts similarity index 95% rename from modules/express/src/typedRoutes/api/v2/generate.ts rename to modules/express/src/typedRoutes/api/v2/generateWallet.ts index a28a426fc3..e88f757c81 100644 --- a/modules/express/src/typedRoutes/api/v2/generate.ts +++ b/modules/express/src/typedRoutes/api/v2/generateWallet.ts @@ -1,7 +1,8 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; import { BitgoExpressError } from '../../schemas/error'; -import { UserKeychainCodec, BackupKeychainCodec, BitgoKeychainCodec } from '../../../wallet/codec'; +import { UserKeychainCodec, BackupKeychainCodec, BitgoKeychainCodec } from '../../schemas/keychain'; +import { multisigType, walletType } from '../../schemas/wallet'; /** * Request body for wallet generation. @@ -12,9 +13,9 @@ export const GenerateWalletBody = { /** Enterprise id. This is required for Ethereum wallets since they can only be created as part of an enterprise */ enterprise: t.string, /** If absent, BitGo uses the default wallet type for the asset */ - multisigType: optional(t.union([t.literal('onchain'), t.literal('tss'), t.literal('blsdkg')])), + multisigType: multisigType, /** The type of wallet, defined by key management and signing protocols. 'hot' and 'cold' are both self-managed wallets. If absent, defaults to 'hot' */ - type: optional(t.union([t.literal('hot'), t.literal('cold'), t.literal('custodial')])), + type: walletType, /** Passphrase to be used to encrypt the user key on the wallet */ passphrase: optional(t.string), /** User provided public key */ diff --git a/modules/express/src/wallet/codec.ts b/modules/express/src/typedRoutes/schemas/keychain.ts similarity index 100% rename from modules/express/src/wallet/codec.ts rename to modules/express/src/typedRoutes/schemas/keychain.ts diff --git a/modules/express/src/typedRoutes/schemas/wallet.ts b/modules/express/src/typedRoutes/schemas/wallet.ts new file mode 100644 index 0000000000..716f245213 --- /dev/null +++ b/modules/express/src/typedRoutes/schemas/wallet.ts @@ -0,0 +1,5 @@ +import * as t from 'io-ts'; + +export const multisigType = t.union([t.literal('onchain'), t.literal('tss'), t.literal('blsdkg')]); + +export const walletType = t.union([t.literal('hot'), t.literal('cold'), t.literal('custodial')]); diff --git a/modules/express/test/unit/clientRoutes/generateWallet.ts b/modules/express/test/unit/clientRoutes/generateWallet.ts index 977b017a11..d9bdf1c18d 100644 --- a/modules/express/test/unit/clientRoutes/generateWallet.ts +++ b/modules/express/test/unit/clientRoutes/generateWallet.ts @@ -9,7 +9,7 @@ import '../../lib/asserts'; import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; import { handleV2GenerateWallet } from '../../../src/clientRoutes'; -import { GenerateWalletResponse } from '../../../src/typedRoutes/api/v2/generate'; +import { GenerateWalletResponse } from '../../../src/typedRoutes/api/v2/generateWallet'; describe('Generate Wallet', () => { let bitgo: TestBitGoAPI; @@ -91,6 +91,7 @@ describe('Generate Wallet', () => { body: walletGenerateBody, decoded: { ...walletGenerateBody, + coin, }, } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; @@ -105,6 +106,7 @@ describe('Generate Wallet', () => { body: walletGenerateBody, decoded: { ...walletGenerateBody, + coin, }, } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; @@ -189,6 +191,7 @@ describe('Generate Wallet', () => { body: walletGenerateBody, decoded: { ...walletGenerateBody, + coin, }, } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; From abd87a915d56d95dad226903e95a7de14666633f Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Tue, 30 Sep 2025 10:17:03 -0400 Subject: [PATCH 3/7] refactor: includeKeychain is type BooleanFromString TICKET: WP-5415 --- modules/express/src/clientRoutes.ts | 2 +- modules/express/src/typedRoutes/api/v2/generateWallet.ts | 3 ++- modules/express/test/unit/clientRoutes/generateWallet.ts | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index be5dcbb1a9..ee0aeef511 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -641,7 +641,7 @@ export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'expres const bitgo = req.bitgo; const coin = bitgo.coin(req.decoded.coin); const result = await coin.wallets().generateWallet(req.body); - if (req.query.includeKeychains === 'false') { + if ((req.decoded.includeKeychains as any) === false) { return result.wallet.toJSON(); } return { ...result, wallet: result.wallet.toJSON() }; diff --git a/modules/express/src/typedRoutes/api/v2/generateWallet.ts b/modules/express/src/typedRoutes/api/v2/generateWallet.ts index e88f757c81..e37184e1b9 100644 --- a/modules/express/src/typedRoutes/api/v2/generateWallet.ts +++ b/modules/express/src/typedRoutes/api/v2/generateWallet.ts @@ -1,4 +1,5 @@ import * as t from 'io-ts'; +import { BooleanFromString } from 'io-ts-types'; import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; import { BitgoExpressError } from '../../schemas/error'; import { UserKeychainCodec, BackupKeychainCodec, BitgoKeychainCodec } from '../../schemas/keychain'; @@ -80,7 +81,7 @@ export const GenerateWalletV2Params = { */ export const GenerateWalletV2Query = { /** Include user, backup and bitgo keychains along with generated wallet */ - includeKeychains: optional(t.string), + includeKeychains: optional(BooleanFromString), }; /** diff --git a/modules/express/test/unit/clientRoutes/generateWallet.ts b/modules/express/test/unit/clientRoutes/generateWallet.ts index d9bdf1c18d..1dc222818b 100644 --- a/modules/express/test/unit/clientRoutes/generateWallet.ts +++ b/modules/express/test/unit/clientRoutes/generateWallet.ts @@ -101,12 +101,13 @@ describe('Generate Wallet', () => { coin, }, query: { - includeKeychains: 'true', + includeKeychains: true, }, body: walletGenerateBody, decoded: { ...walletGenerateBody, coin, + includeKeychains: true, }, } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; @@ -186,12 +187,13 @@ describe('Generate Wallet', () => { coin, }, query: { - includeKeychains: 'false', + includeKeychains: false, }, body: walletGenerateBody, decoded: { ...walletGenerateBody, coin, + includeKeychains: false, }, } as unknown as ExpressApiRouteRequest<'express.wallet.generate', 'post'>; From 678e89176e41d8a4fd9a67006dad80ecb3ccc3d2 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Tue, 30 Sep 2025 10:53:02 -0400 Subject: [PATCH 4/7] refactor: included missing package TICKET: WP-5415 --- modules/express/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/express/package.json b/modules/express/package.json index a0c37ad41d..e936c8a8b2 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -49,6 +49,7 @@ "debug": "^3.1.0", "dotenv": "^16.0.0", "express": "4.21.2", + "io-ts-types": "^0.5.16", "io-ts": "npm:@bitgo-forks/io-ts@2.1.4", "io-ts-types": "^0.5.19", "lodash": "^4.17.20", From d03a2367d8824eed5c1448c7fcd4cd9c839bc670 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Sun, 19 Oct 2025 23:14:53 -0400 Subject: [PATCH 5/7] refactor: added supterests and modified request codec TICKET: WP-5415 --- modules/express/src/clientRoutes.ts | 2 +- .../src/typedRoutes/api/v2/generateWallet.ts | 8 +- .../test/unit/typedRoutes/generateWallet.ts | 397 ++++++++++++++++++ 3 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 modules/express/test/unit/typedRoutes/generateWallet.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index ee0aeef511..00b89566ab 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -640,7 +640,7 @@ export async function handleV2OFCSignPayload( export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'express.wallet.generate', 'post'>) { const bitgo = req.bitgo; const coin = bitgo.coin(req.decoded.coin); - const result = await coin.wallets().generateWallet(req.body); + const result = await coin.wallets().generateWallet(req.decoded as any); if ((req.decoded.includeKeychains as any) === false) { return result.wallet.toJSON(); } diff --git a/modules/express/src/typedRoutes/api/v2/generateWallet.ts b/modules/express/src/typedRoutes/api/v2/generateWallet.ts index e37184e1b9..41649db82b 100644 --- a/modules/express/src/typedRoutes/api/v2/generateWallet.ts +++ b/modules/express/src/typedRoutes/api/v2/generateWallet.ts @@ -11,12 +11,12 @@ import { multisigType, walletType } from '../../schemas/wallet'; export const GenerateWalletBody = { /** Wallet label */ label: t.string, - /** Enterprise id. This is required for Ethereum wallets since they can only be created as part of an enterprise */ - enterprise: t.string, + /** Enterprise id. Required for Ethereum wallets since they can only be created as part of an enterprise. Optional for other coins. */ + enterprise: optional(t.string), /** If absent, BitGo uses the default wallet type for the asset */ - multisigType: multisigType, + multisigType: optional(multisigType), /** The type of wallet, defined by key management and signing protocols. 'hot' and 'cold' are both self-managed wallets. If absent, defaults to 'hot' */ - type: walletType, + type: optional(walletType), /** Passphrase to be used to encrypt the user key on the wallet */ passphrase: optional(t.string), /** User provided public key */ diff --git a/modules/express/test/unit/typedRoutes/generateWallet.ts b/modules/express/test/unit/typedRoutes/generateWallet.ts new file mode 100644 index 0000000000..73208c9e9d --- /dev/null +++ b/modules/express/test/unit/typedRoutes/generateWallet.ts @@ -0,0 +1,397 @@ +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 { BitGo } from 'bitgo'; +import { PostGenerateWallet } from '../../../src/typedRoutes/api/v2/generateWallet'; + +describe('Generate Wallet 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('Success Cases', function () { + it('should successfully generate wallet with all parameters', async function () { + const coin = 'tbtc'; + const label = 'Test Wallet'; + const passphrase = 'mySecurePassphrase123'; + const enterprise = 'enterprise123'; + + const mockWallet = { + id: 'wallet123', + coin, + label, + toJSON: sinon.stub().returns({ + id: 'wallet123', + coin, + label, + keys: ['userKey123', 'backupKey123', 'bitgoKey123'], + }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { + id: 'userKey123', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encrypted_user_prv', + }, + backupKeychain: { + id: 'backupKey123', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encrypted_backup_prv', + }, + bitgoKeychain: { + id: 'bitgoKey123', + pub: 'xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU521AAwdZafEz7mnzBBsz4wKY5fTtTQBm', + }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + passphrase, + enterprise, + }); + + res.status.should.equal(200); + res.body.should.have.property('wallet'); + res.body.wallet.should.have.property('id', 'wallet123'); + res.body.wallet.should.have.property('label', label); + res.body.should.have.property('userKeychain'); + res.body.should.have.property('backupKeychain'); + res.body.should.have.property('bitgoKeychain'); + + generateWalletStub.should.have.been.calledOnce(); + generateWalletStub.firstCall.args[0].should.have.property('label', label); + generateWalletStub.firstCall.args[0].should.have.property('passphrase', passphrase); + generateWalletStub.firstCall.args[0].should.have.property('enterprise', enterprise); + }); + + it('should successfully generate wallet with optional type and multisigType', async function () { + const coin = 'tbtc'; + const label = 'Test Wallet'; + const passphrase = 'mySecurePassphrase123'; + const enterprise = 'enterprise123'; + const type = 'cold'; + const multisigType = 'tss'; + + const mockWallet = { + id: 'wallet456', + coin, + label, + toJSON: sinon.stub().returns({ + id: 'wallet456', + coin, + label, + type, + multisigType, + }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { id: 'userKey456' }, + backupKeychain: { id: 'backupKey456' }, + bitgoKeychain: { id: 'bitgoKey456' }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + passphrase, + enterprise, + type, + multisigType, + }); + + res.status.should.equal(200); + res.body.should.have.property('wallet'); + res.body.wallet.should.have.property('id', 'wallet456'); + + generateWalletStub.should.have.been.calledOnce(); + generateWalletStub.firstCall.args[0].should.have.property('type', type); + generateWalletStub.firstCall.args[0].should.have.property('multisigType', multisigType); + }); + + it('should generate wallet without keychains when includeKeychains=false', async function () { + const coin = 'tbtc'; + const label = 'Test Wallet'; + const passphrase = 'mySecurePassphrase123'; + const enterprise = 'enterprise123'; + + const mockWallet = { + id: 'wallet789', + coin, + label, + toJSON: sinon.stub().returns({ + id: 'wallet789', + coin, + label, + }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { id: 'userKey789' }, + backupKeychain: { id: 'backupKey789' }, + bitgoKeychain: { id: 'bitgoKey789' }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).query({ includeKeychains: 'false' }).send({ + label, + passphrase, + enterprise, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', 'wallet789'); + res.body.should.not.have.property('userKeychain'); + res.body.should.not.have.property('backupKeychain'); + res.body.should.not.have.property('bitgoKeychain'); + }); + + it('should successfully generate wallet with backupXpubProvider', async function () { + const coin = 'tbtc'; + const label = 'Test Wallet'; + const passphrase = 'mySecurePassphrase123'; + const enterprise = 'enterprise123'; + const backupXpubProvider = 'dai'; + + const mockWallet = { + id: 'walletKRS', + coin, + label, + toJSON: sinon.stub().returns({ + id: 'walletKRS', + coin, + label, + }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { id: 'userKeyKRS' }, + backupKeychain: { id: 'backupKeyKRS', provider: 'dai' }, + bitgoKeychain: { id: 'bitgoKeyKRS' }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + passphrase, + enterprise, + backupXpubProvider, + }); + + res.status.should.equal(200); + res.body.should.have.property('wallet'); + res.body.should.have.property('backupKeychain'); + res.body.backupKeychain.should.have.property('provider', 'dai'); + + generateWalletStub.should.have.been.calledOnce(); + generateWalletStub.firstCall.args[0].should.have.property('backupXpubProvider', backupXpubProvider); + }); + + it('should successfully generate MPC wallet with bitgoKeyId and commonKeychain', async function () { + const coin = 'tbtc'; + const label = 'MPC Test Wallet'; + const enterprise = 'enterprise123'; + const bitgoKeyId = 'bitgoMpcKey123'; + const commonKeychain = 'commonKeychain123'; + + const mockWallet = { + id: 'walletMPC', + coin, + label, + multisigType: 'tss', + toJSON: sinon.stub().returns({ + id: 'walletMPC', + coin, + label, + multisigType: 'tss', + }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { id: 'userKeyMPC' }, + backupKeychain: { id: 'backupKeyMPC' }, + bitgoKeychain: { id: bitgoKeyId }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + enterprise, + multisigType: 'tss', + type: 'cold', + bitgoKeyId, + commonKeychain, + }); + + res.status.should.equal(200); + res.body.should.have.property('wallet'); + res.body.wallet.should.have.property('multisigType', 'tss'); + + generateWalletStub.should.have.been.calledOnce(); + generateWalletStub.firstCall.args[0].should.have.property('bitgoKeyId', bitgoKeyId); + generateWalletStub.firstCall.args[0].should.have.property('commonKeychain', commonKeychain); + }); + }); + + describe('Codec Validation', function () { + it('should return 400 when label is missing', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + enterprise: 'enterprise123', + passphrase: 'password', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/label/); + }); + + it('should return 400 when label is not a string', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 123, + enterprise: 'enterprise123', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/label/); + }); + + it('should return 400 when type is invalid', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + enterprise: 'enterprise123', + type: 'invalid_type', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/type/); + }); + + it('should return 400 when multisigType is invalid', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + enterprise: 'enterprise123', + multisigType: 'invalid_multisig', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/multisigType/); + }); + + it('should return 400 when backupXpubProvider is invalid', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + enterprise: 'enterprise123', + backupXpubProvider: 'invalid_provider', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/backupXpubProvider/); + }); + + it('should return 400 when disableTransactionNotifications is not boolean', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + enterprise: 'enterprise123', + disableTransactionNotifications: 'true', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/disableTransactionNotifications/); + }); + }); + + describe('Handler Errors', function () { + it('should return error when SDK generateWallet fails', async function () { + const coin = 'tbtc'; + const label = 'Test Wallet'; + const enterprise = 'enterprise123'; + + const generateWalletStub = sinon.stub().rejects(new Error('Insufficient funds')); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + enterprise, + passphrase: 'password', + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + res.body.error.should.match(/Insufficient funds/); + }); + }); + + describe('Route Definition', function () { + it('should have correct route metadata', function () { + assert.strictEqual(PostGenerateWallet.method, 'POST'); + assert.strictEqual(PostGenerateWallet.path, '/api/v2/:coin/wallet/generate'); + assert.ok(PostGenerateWallet.response[200]); + assert.ok(PostGenerateWallet.response[400]); + }); + }); +}); From 35276bbc27d1233a138a0c53655304209bd552ad Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Wed, 22 Oct 2025 17:26:52 -0400 Subject: [PATCH 6/7] refactor: fix bug in generateWalletOptionsi TICKET: WP-5415 --- modules/express/src/clientRoutes.ts | 2 +- modules/express/src/typedRoutes/schemas/wallet.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index ff399be49a..5019315c70 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -640,7 +640,7 @@ export async function handleV2OFCSignPayload( export async function handleV2GenerateWallet(req: ExpressApiRouteRequest<'express.wallet.generate', 'post'>) { const bitgo = req.bitgo; const coin = bitgo.coin(req.decoded.coin); - const result = await coin.wallets().generateWallet(req.decoded as any); + const result = await coin.wallets().generateWallet(req.decoded); if ((req.decoded.includeKeychains as any) === false) { return result.wallet.toJSON(); } diff --git a/modules/express/src/typedRoutes/schemas/wallet.ts b/modules/express/src/typedRoutes/schemas/wallet.ts index b52afa45ff..ec89e7bf09 100644 --- a/modules/express/src/typedRoutes/schemas/wallet.ts +++ b/modules/express/src/typedRoutes/schemas/wallet.ts @@ -140,15 +140,7 @@ export const CustomChangeKeySignatures = t.partial({ export const multisigType = t.union([t.literal('onchain'), t.literal('tss')]); -export const walletType = t.union([ - t.literal('backing'), - t.literal('cold'), - t.literal('custodial'), - t.literal('custodialPaired'), - t.literal('hot'), - t.literal('trading'), - t.literal('advanced'), -]); +export const walletType = t.union([t.literal('cold'), t.literal('custodial'), t.literal('hot'), t.literal('trading')]); /** * Wallet response data From 95909b1aa70e82cd94199183150899bf78a74f51 Mon Sep 17 00:00:00 2001 From: danielzhao122 Date: Wed, 22 Oct 2025 17:32:44 -0400 Subject: [PATCH 7/7] refactor: generateWalletOptions change TICKET: WP-5415 --- modules/sdk-core/src/bitgo/wallet/iWallets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 79db270d84..f1389a5ab5 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -60,8 +60,8 @@ export interface GenerateWalletOptions { backupXpubProvider?: string; passcodeEncryptionCode?: string; enterprise?: string; - disableTransactionNotifications?: string; - gasPrice?: string; + disableTransactionNotifications?: boolean; + gasPrice?: number; eip1559?: { maxFeePerGas: string; maxPriorityFeePerGas: string;