Skip to content

Commit aa2ad8f

Browse files
authored
feat(express): migrate createLocalKeyChain to typed routes
2 parents 03fe19c + 912614a commit aa2ad8f

File tree

4 files changed

+219
-2
lines changed

4 files changed

+219
-2
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ function handleVerifyAddress(req: ExpressApiRouteRequest<'express.verifyaddress'
117117
* @deprecated
118118
* @param req
119119
*/
120-
function handleCreateLocalKeyChain(req: express.Request) {
120+
function handleCreateLocalKeyChain(req: ExpressApiRouteRequest<'express.v1.keychain.local', 'post'>) {
121121
return req.bitgo.keychains().create(req.body);
122122
}
123123

@@ -1569,7 +1569,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
15691569
typedPromiseWrapper(handleCalculateMinerFeeInfo),
15701570
]);
15711571

1572-
app.post('/api/v1/keychain/local', parseBody, prepareBitGo(config), promiseWrapper(handleCreateLocalKeyChain));
1572+
router.post('express.v1.keychain.local', [prepareBitGo(config), typedPromiseWrapper(handleCreateLocalKeyChain)]);
15731573
router.post('express.v1.keychain.derive', [prepareBitGo(config), typedPromiseWrapper(handleDeriveLocalKeyChain)]);
15741574
router.post('express.v1.wallet.simplecreate', [
15751575
prepareBitGo(config),

modules/express/src/typedRoutes/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { PostLightningInitWallet } from './v2/lightningInitWallet';
1818
import { PostUnlockLightningWallet } from './v2/unlockWallet';
1919
import { PostVerifyCoinAddress } from './v2/verifyAddress';
2020
import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain';
21+
import { PostCreateLocalKeyChain } from './v1/createLocalKeyChain';
2122

2223
export const ExpressApi = apiSpec({
2324
'express.ping': {
@@ -68,6 +69,9 @@ export const ExpressApi = apiSpec({
6869
'express.v1.keychain.derive': {
6970
post: PostDeriveLocalKeyChain,
7071
},
72+
'express.v1.keychain.local': {
73+
post: PostCreateLocalKeyChain,
74+
},
7175
});
7276

7377
export type ExpressApi = typeof ExpressApi;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as t from 'io-ts';
2+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
3+
import { BitgoExpressError } from '../../schemas/error';
4+
5+
/**
6+
* Request parameters for creating a local keychain
7+
*/
8+
export const CreateLocalKeyChainRequestBody = {
9+
/** Optional seed for key generation (use with caution) */
10+
seed: optional(t.string),
11+
};
12+
13+
/**
14+
* Response for creating a local keychain
15+
*/
16+
export const CreateLocalKeyChainResponse = t.type({
17+
/** The extended private key */
18+
xprv: t.string,
19+
/** The extended public key */
20+
xpub: t.string,
21+
/** The Ethereum address derived from the xpub (if available) */
22+
ethAddress: optional(t.string),
23+
});
24+
25+
/**
26+
* Create a local keychain
27+
*
28+
* Locally creates a new keychain. This is a client-side function that does not
29+
* involve any server-side operations. Returns an object containing the xprv and xpub
30+
* for the new chain. The created keychain is not known to the BitGo service.
31+
* To use it with the BitGo service, use the 'Add Keychain' API call.
32+
*
33+
* For security reasons, it is highly recommended that you encrypt and destroy
34+
* the original xprv immediately to prevent theft.
35+
*
36+
* @operationId express.v1.keychain.local
37+
*/
38+
export const PostCreateLocalKeyChain = httpRoute({
39+
path: '/api/v1/keychain/local',
40+
method: 'POST',
41+
request: httpRequest({
42+
body: CreateLocalKeyChainRequestBody,
43+
}),
44+
response: {
45+
/** Successfully created keychain */
46+
200: CreateLocalKeyChainResponse,
47+
/** Invalid request or key generation fails */
48+
400: BitgoExpressError,
49+
},
50+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import * as assert from 'assert';
2+
import * as t from 'io-ts';
3+
import {
4+
CreateLocalKeyChainRequestBody,
5+
CreateLocalKeyChainResponse,
6+
PostCreateLocalKeyChain,
7+
} from '../../../src/typedRoutes/api/v1/createLocalKeyChain';
8+
import { assertDecode } from './common';
9+
10+
describe('CreateLocalKeyChain codec tests', function () {
11+
describe('CreateLocalKeyChainRequestBody', function () {
12+
it('should validate body with optional seed', function () {
13+
const validBody = {
14+
seed: 'some-seed-value',
15+
};
16+
17+
const decoded = assertDecode(t.type(CreateLocalKeyChainRequestBody), validBody);
18+
assert.strictEqual(decoded.seed, validBody.seed);
19+
});
20+
21+
it('should validate body with no parameters', function () {
22+
const validBody = {};
23+
24+
const decoded = assertDecode(t.type(CreateLocalKeyChainRequestBody), validBody);
25+
assert.strictEqual(decoded.seed, undefined); // Optional field
26+
});
27+
28+
it('should reject body with non-string seed', function () {
29+
const invalidBody = {
30+
seed: 123, // number instead of string
31+
};
32+
33+
assert.throws(() => {
34+
assertDecode(t.type(CreateLocalKeyChainRequestBody), invalidBody);
35+
});
36+
});
37+
});
38+
39+
describe('CreateLocalKeyChainResponse', function () {
40+
it('should validate response with required fields', function () {
41+
const validResponse = {
42+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
43+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
44+
};
45+
46+
const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse);
47+
assert.strictEqual(decoded.xprv, validResponse.xprv);
48+
assert.strictEqual(decoded.xpub, validResponse.xpub);
49+
assert.strictEqual(decoded.ethAddress, undefined); // Optional field
50+
});
51+
52+
it('should validate response with all fields including optional ones', function () {
53+
const validResponse = {
54+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
55+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
56+
ethAddress: '0x1234567890123456789012345678901234567890',
57+
};
58+
59+
const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse);
60+
assert.strictEqual(decoded.xprv, validResponse.xprv);
61+
assert.strictEqual(decoded.xpub, validResponse.xpub);
62+
assert.strictEqual(decoded.ethAddress, validResponse.ethAddress);
63+
});
64+
65+
it('should reject response with missing xprv', function () {
66+
const invalidResponse = {
67+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
68+
};
69+
70+
assert.throws(() => {
71+
assertDecode(CreateLocalKeyChainResponse, invalidResponse);
72+
});
73+
});
74+
75+
it('should reject response with missing xpub', function () {
76+
const invalidResponse = {
77+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
78+
};
79+
80+
assert.throws(() => {
81+
assertDecode(CreateLocalKeyChainResponse, invalidResponse);
82+
});
83+
});
84+
85+
it('should reject response with non-string xprv', function () {
86+
const invalidResponse = {
87+
xprv: 123, // number instead of string
88+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
89+
};
90+
91+
assert.throws(() => {
92+
assertDecode(CreateLocalKeyChainResponse, invalidResponse);
93+
});
94+
});
95+
96+
it('should reject response with non-string xpub', function () {
97+
const invalidResponse = {
98+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
99+
xpub: 123, // number instead of string
100+
};
101+
102+
assert.throws(() => {
103+
assertDecode(CreateLocalKeyChainResponse, invalidResponse);
104+
});
105+
});
106+
107+
it('should reject response with non-string ethAddress', function () {
108+
const invalidResponse = {
109+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
110+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
111+
ethAddress: 123, // number instead of string
112+
};
113+
114+
assert.throws(() => {
115+
assertDecode(CreateLocalKeyChainResponse, invalidResponse);
116+
});
117+
});
118+
});
119+
120+
describe('Edge cases', function () {
121+
it('should handle empty strings for string fields', function () {
122+
const body = {
123+
seed: '',
124+
};
125+
126+
const decoded = assertDecode(t.type(CreateLocalKeyChainRequestBody), body);
127+
assert.strictEqual(decoded.seed, '');
128+
});
129+
130+
it('should handle additional unknown properties', function () {
131+
const body = {
132+
seed: 'some-seed-value',
133+
unknownProperty: 'some value',
134+
};
135+
136+
// io-ts with t.exact() strips out additional properties
137+
const decoded = assertDecode(t.exact(t.type(CreateLocalKeyChainRequestBody)), body);
138+
assert.strictEqual(decoded.seed, body.seed);
139+
// @ts-expect-error - unknownProperty doesn't exist on the type
140+
assert.strictEqual(decoded.unknownProperty, undefined);
141+
});
142+
});
143+
describe('PostCreateLocalKeyChain route definition', function () {
144+
it('should have the correct path', function () {
145+
assert.strictEqual(PostCreateLocalKeyChain.path, '/api/v1/keychain/local');
146+
});
147+
148+
it('should have the correct HTTP method', function () {
149+
assert.strictEqual(PostCreateLocalKeyChain.method, 'POST');
150+
});
151+
152+
it('should have the correct request configuration', function () {
153+
// Verify the route is configured with a request property
154+
assert.ok(PostCreateLocalKeyChain.request);
155+
});
156+
157+
it('should have the correct response types', function () {
158+
// Check that the response object has the expected status codes
159+
assert.ok(PostCreateLocalKeyChain.response[200]);
160+
assert.ok(PostCreateLocalKeyChain.response[400]);
161+
});
162+
});
163+
});

0 commit comments

Comments
 (0)