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
4 changes: 2 additions & 2 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions modules/express/src/typedRoutes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down Expand Up @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
163 changes: 163 additions & 0 deletions modules/express/test/unit/typedRoutes/createLocalKeyChain.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
});