diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f4d36d60ba..9b29fac2d2 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -117,7 +117,7 @@ function handleVerifyAddress(req: ExpressApiRouteRequest<'express.verifyaddress' * @deprecated * @param req */ -function handleCreateLocalKeyChain(req: express.Request) { +function handleCreateLocalKeyChain(req: ExpressApiRouteRequest<'express.v1.keychain.local', 'post'>) { return req.bitgo.keychains().create(req.body); } @@ -1569,7 +1569,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { typedPromiseWrapper(handleCalculateMinerFeeInfo), ]); - app.post('/api/v1/keychain/local', parseBody, prepareBitGo(config), promiseWrapper(handleCreateLocalKeyChain)); + router.post('express.v1.keychain.local', [prepareBitGo(config), typedPromiseWrapper(handleCreateLocalKeyChain)]); router.post('express.v1.keychain.derive', [prepareBitGo(config), typedPromiseWrapper(handleDeriveLocalKeyChain)]); router.post('express.v1.wallet.simplecreate', [ prepareBitGo(config), diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index fa5871ff9b..e1125fdef7 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -18,6 +18,7 @@ import { PostLightningInitWallet } from './v2/lightningInitWallet'; import { PostUnlockLightningWallet } from './v2/unlockWallet'; import { PostVerifyCoinAddress } from './v2/verifyAddress'; import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain'; +import { PostCreateLocalKeyChain } from './v1/createLocalKeyChain'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -68,6 +69,9 @@ export const ExpressApi = apiSpec({ 'express.v1.keychain.derive': { post: PostDeriveLocalKeyChain, }, + 'express.v1.keychain.local': { + post: PostCreateLocalKeyChain, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts b/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts new file mode 100644 index 0000000000..b1cfdafc10 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts @@ -0,0 +1,50 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Request parameters for creating a local keychain + */ +export const CreateLocalKeyChainRequestBody = { + /** Optional seed for key generation (use with caution) */ + seed: optional(t.string), +}; + +/** + * Response for creating a local keychain + */ +export const CreateLocalKeyChainResponse = t.type({ + /** The extended private key */ + xprv: t.string, + /** The extended public key */ + xpub: t.string, + /** The Ethereum address derived from the xpub (if available) */ + ethAddress: optional(t.string), +}); + +/** + * Create a local keychain + * + * Locally creates a new keychain. This is a client-side function that does not + * involve any server-side operations. Returns an object containing the xprv and xpub + * for the new chain. The created keychain is not known to the BitGo service. + * To use it with the BitGo service, use the 'Add Keychain' API call. + * + * For security reasons, it is highly recommended that you encrypt and destroy + * the original xprv immediately to prevent theft. + * + * @operationId express.v1.keychain.local + */ +export const PostCreateLocalKeyChain = httpRoute({ + path: '/api/v1/keychain/local', + method: 'POST', + request: httpRequest({ + body: CreateLocalKeyChainRequestBody, + }), + response: { + /** Successfully created keychain */ + 200: CreateLocalKeyChainResponse, + /** Invalid request or key generation fails */ + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts b/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts new file mode 100644 index 0000000000..84c4023224 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts @@ -0,0 +1,163 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + CreateLocalKeyChainRequestBody, + CreateLocalKeyChainResponse, + PostCreateLocalKeyChain, +} from '../../../src/typedRoutes/api/v1/createLocalKeyChain'; +import { assertDecode } from './common'; + +describe('CreateLocalKeyChain codec tests', function () { + describe('CreateLocalKeyChainRequestBody', function () { + it('should validate body with optional seed', function () { + const validBody = { + seed: 'some-seed-value', + }; + + const decoded = assertDecode(t.type(CreateLocalKeyChainRequestBody), validBody); + assert.strictEqual(decoded.seed, validBody.seed); + }); + + it('should validate body with no parameters', function () { + const validBody = {}; + + const decoded = assertDecode(t.type(CreateLocalKeyChainRequestBody), validBody); + assert.strictEqual(decoded.seed, undefined); // Optional field + }); + + it('should reject body with non-string seed', function () { + const invalidBody = { + seed: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(CreateLocalKeyChainRequestBody), invalidBody); + }); + }); + }); + + describe('CreateLocalKeyChainResponse', function () { + it('should validate response with required fields', function () { + const validResponse = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse); + assert.strictEqual(decoded.xprv, validResponse.xprv); + assert.strictEqual(decoded.xpub, validResponse.xpub); + assert.strictEqual(decoded.ethAddress, undefined); // Optional field + }); + + it('should validate response with all fields including optional ones', function () { + const validResponse = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + ethAddress: '0x1234567890123456789012345678901234567890', + }; + + const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse); + assert.strictEqual(decoded.xprv, validResponse.xprv); + assert.strictEqual(decoded.xpub, validResponse.xpub); + assert.strictEqual(decoded.ethAddress, validResponse.ethAddress); + }); + + it('should reject response with missing xprv', function () { + const invalidResponse = { + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + assert.throws(() => { + assertDecode(CreateLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with missing xpub', function () { + const invalidResponse = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + assert.throws(() => { + assertDecode(CreateLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string xprv', function () { + const invalidResponse = { + xprv: 123, // number instead of string + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + assert.throws(() => { + assertDecode(CreateLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string xpub', function () { + const invalidResponse = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + xpub: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(CreateLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string ethAddress', function () { + const invalidResponse = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + ethAddress: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(CreateLocalKeyChainResponse, invalidResponse); + }); + }); + }); + + describe('Edge cases', function () { + it('should handle empty strings for string fields', function () { + const body = { + seed: '', + }; + + const decoded = assertDecode(t.type(CreateLocalKeyChainRequestBody), body); + assert.strictEqual(decoded.seed, ''); + }); + + it('should handle additional unknown properties', function () { + const body = { + seed: 'some-seed-value', + unknownProperty: 'some value', + }; + + // io-ts with t.exact() strips out additional properties + const decoded = assertDecode(t.exact(t.type(CreateLocalKeyChainRequestBody)), body); + assert.strictEqual(decoded.seed, body.seed); + // @ts-expect-error - unknownProperty doesn't exist on the type + assert.strictEqual(decoded.unknownProperty, undefined); + }); + }); + describe('PostCreateLocalKeyChain route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostCreateLocalKeyChain.path, '/api/v1/keychain/local'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostCreateLocalKeyChain.method, 'POST'); + }); + + it('should have the correct request configuration', function () { + // Verify the route is configured with a request property + assert.ok(PostCreateLocalKeyChain.request); + }); + + it('should have the correct response types', function () { + // Check that the response object has the expected status codes + assert.ok(PostCreateLocalKeyChain.response[200]); + assert.ok(PostCreateLocalKeyChain.response[400]); + }); + }); +});