Skip to content

Commit 0f07658

Browse files
committed
feat(express): migrate deriveLocalKeyChain to typed routes
Ticket: WP-5402
1 parent 5fccfe7 commit 0f07658

File tree

4 files changed

+294
-2
lines changed

4 files changed

+294
-2
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ function handleCreateLocalKeyChain(req: express.Request) {
125125
* @deprecated
126126
* @param req
127127
*/
128-
function handleDeriveLocalKeyChain(req: express.Request) {
128+
function handleDeriveLocalKeyChain(req: ExpressApiRouteRequest<'express.v1.keychain.derive', 'post'>) {
129129
return req.bitgo.keychains().deriveLocal(req.body);
130130
}
131131

@@ -1566,7 +1566,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
15661566
]);
15671567

15681568
app.post('/api/v1/keychain/local', parseBody, prepareBitGo(config), promiseWrapper(handleCreateLocalKeyChain));
1569-
app.post('/api/v1/keychain/derive', parseBody, prepareBitGo(config), promiseWrapper(handleDeriveLocalKeyChain));
1569+
router.post('express.v1.keychain.derive', [prepareBitGo(config), typedPromiseWrapper(handleDeriveLocalKeyChain)]);
15701570
router.post('express.v1.wallet.simplecreate', [
15711571
prepareBitGo(config),
15721572
typedPromiseWrapper(handleCreateWalletWithKeychains),

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { PostSignTransaction } from './v1/signTransaction';
1616
import { PostKeychainLocal } from './v2/keychainLocal';
1717
import { PostLightningInitWallet } from './v2/lightningInitWallet';
1818
import { PostVerifyCoinAddress } from './v2/verifyAddress';
19+
import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain';
1920

2021
export const ExpressApi = apiSpec({
2122
'express.ping': {
@@ -60,6 +61,9 @@ export const ExpressApi = apiSpec({
6061
'express.calculateminerfeeinfo': {
6162
post: PostCalculateMinerFeeInfo,
6263
},
64+
'express.v1.keychain.derive': {
65+
post: PostDeriveLocalKeyChain,
66+
},
6367
});
6468

6569
export type ExpressApi = typeof ExpressApi;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 deriving a local keychain
7+
*/
8+
export const DeriveLocalKeyChainRequestBody = {
9+
/** The derivation path to use (e.g. 'm/0/1') */
10+
path: t.string,
11+
/** The extended private key to derive from (either xprv or xpub must be provided) */
12+
xprv: optional(t.string),
13+
/** The extended public key to derive from (either xprv or xpub must be provided) */
14+
xpub: optional(t.string),
15+
};
16+
17+
/**
18+
* Response for deriving a local keychain
19+
*/
20+
export const DeriveLocalKeyChainResponse = t.type({
21+
/** The derivation path that was used */
22+
path: t.string,
23+
/** The derived extended public key */
24+
xpub: t.string,
25+
/** The derived extended private key (only included if xprv was provided in the request) */
26+
xprv: optional(t.string),
27+
/** The Ethereum address derived from the xpub (if available) */
28+
ethAddress: optional(t.string),
29+
});
30+
31+
/**
32+
* Derive a local keychain
33+
*
34+
* Locally derives a keychain from a top level BIP32 string (xprv or xpub), given a path.
35+
* This is useful for deriving child keys from a parent key without having to store the child keys.
36+
*
37+
* The derivation process:
38+
* 1. Takes either an xprv (extended private key) or xpub (extended public key) as input
39+
* 2. Derives a child key at the specified path using BIP32 derivation
40+
* 3. Returns the derived xpub (and xprv if an xprv was provided)
41+
* 4. Also attempts to derive an Ethereum address from the xpub if possible
42+
*
43+
* Note: You must provide either xprv or xpub, but not both. If xprv is provided,
44+
* both the derived xprv and xpub are returned. If xpub is provided, only the
45+
* derived xpub is returned.
46+
*
47+
* @operationId express.v1.keychain.derive
48+
*/
49+
export const PostDeriveLocalKeyChain = httpRoute({
50+
path: '/api/v1/keychain/derive',
51+
method: 'POST',
52+
request: httpRequest({
53+
body: DeriveLocalKeyChainRequestBody,
54+
}),
55+
response: {
56+
/** Successfully derived keychain */
57+
200: DeriveLocalKeyChainResponse,
58+
/** Invalid request or derivation fails */
59+
400: BitgoExpressError,
60+
},
61+
});
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import * as assert from 'assert';
2+
import * as t from 'io-ts';
3+
import {
4+
DeriveLocalKeyChainRequestBody,
5+
DeriveLocalKeyChainResponse,
6+
PostDeriveLocalKeyChain,
7+
} from '../../../src/typedRoutes/api/v1/deriveLocalKeyChain';
8+
import { assertDecode } from './common';
9+
10+
describe('DeriveLocalKeyChain codec tests', function () {
11+
describe('DeriveLocalKeyChainRequestBody', function () {
12+
it('should validate body with required path and xprv', function () {
13+
const validBody = {
14+
path: 'm/0/1',
15+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
16+
};
17+
18+
const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), validBody);
19+
assert.strictEqual(decoded.path, validBody.path);
20+
assert.strictEqual(decoded.xprv, validBody.xprv);
21+
assert.strictEqual(decoded.xpub, undefined); // Optional field
22+
});
23+
24+
it('should validate body with required path and xpub', function () {
25+
const validBody = {
26+
path: 'm/0/1',
27+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
28+
};
29+
30+
const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), validBody);
31+
assert.strictEqual(decoded.path, validBody.path);
32+
assert.strictEqual(decoded.xpub, validBody.xpub);
33+
assert.strictEqual(decoded.xprv, undefined); // Optional field
34+
});
35+
36+
it('should reject body with missing path', function () {
37+
const invalidBody = {
38+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
39+
};
40+
41+
assert.throws(() => {
42+
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
43+
});
44+
});
45+
46+
it('should reject body with non-string path', function () {
47+
const invalidBody = {
48+
path: 123, // number instead of string
49+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
50+
};
51+
52+
assert.throws(() => {
53+
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
54+
});
55+
});
56+
57+
it('should reject body with non-string xprv', function () {
58+
const invalidBody = {
59+
path: 'm/0/1',
60+
xprv: 123, // number instead of string
61+
};
62+
63+
assert.throws(() => {
64+
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
65+
});
66+
});
67+
68+
it('should reject body with non-string xpub', function () {
69+
const invalidBody = {
70+
path: 'm/0/1',
71+
xpub: 123, // number instead of string
72+
};
73+
74+
assert.throws(() => {
75+
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
76+
});
77+
});
78+
79+
// Note: The validation that either xprv or xpub must be provided is handled by the implementation,
80+
// not by the io-ts codec, so we don't test for that here.
81+
});
82+
83+
describe('DeriveLocalKeyChainResponse', function () {
84+
it('should validate response with all required fields', function () {
85+
const validResponse = {
86+
path: 'm/0/1',
87+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
88+
};
89+
90+
const decoded = assertDecode(DeriveLocalKeyChainResponse, validResponse);
91+
assert.strictEqual(decoded.path, validResponse.path);
92+
assert.strictEqual(decoded.xpub, validResponse.xpub);
93+
assert.strictEqual(decoded.xprv, undefined); // Optional field
94+
assert.strictEqual(decoded.ethAddress, undefined); // Optional field
95+
});
96+
97+
it('should validate response with all fields including optional ones', function () {
98+
const validResponse = {
99+
path: 'm/0/1',
100+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
101+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
102+
ethAddress: '0x1234567890123456789012345678901234567890',
103+
};
104+
105+
const decoded = assertDecode(DeriveLocalKeyChainResponse, validResponse);
106+
assert.strictEqual(decoded.path, validResponse.path);
107+
assert.strictEqual(decoded.xpub, validResponse.xpub);
108+
assert.strictEqual(decoded.xprv, validResponse.xprv);
109+
assert.strictEqual(decoded.ethAddress, validResponse.ethAddress);
110+
});
111+
112+
it('should reject response with missing path', function () {
113+
const invalidResponse = {
114+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
115+
};
116+
117+
assert.throws(() => {
118+
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
119+
});
120+
});
121+
122+
it('should reject response with missing xpub', function () {
123+
const invalidResponse = {
124+
path: 'm/0/1',
125+
};
126+
127+
assert.throws(() => {
128+
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
129+
});
130+
});
131+
132+
it('should reject response with non-string path', function () {
133+
const invalidResponse = {
134+
path: 123, // number instead of string
135+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
136+
};
137+
138+
assert.throws(() => {
139+
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
140+
});
141+
});
142+
143+
it('should reject response with non-string xpub', function () {
144+
const invalidResponse = {
145+
path: 'm/0/1',
146+
xpub: 123, // number instead of string
147+
};
148+
149+
assert.throws(() => {
150+
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
151+
});
152+
});
153+
154+
it('should reject response with non-string xprv', function () {
155+
const invalidResponse = {
156+
path: 'm/0/1',
157+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
158+
xprv: 123, // number instead of string
159+
};
160+
161+
assert.throws(() => {
162+
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
163+
});
164+
});
165+
166+
it('should reject response with non-string ethAddress', function () {
167+
const invalidResponse = {
168+
path: 'm/0/1',
169+
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
170+
ethAddress: 123, // number instead of string
171+
};
172+
173+
assert.throws(() => {
174+
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
175+
});
176+
});
177+
});
178+
179+
describe('Edge cases', function () {
180+
it('should handle empty strings for string fields', function () {
181+
const body = {
182+
path: '',
183+
xprv: '',
184+
};
185+
186+
const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), body);
187+
assert.strictEqual(decoded.path, '');
188+
assert.strictEqual(decoded.xprv, '');
189+
});
190+
191+
it('should handle additional unknown properties', function () {
192+
const body = {
193+
path: 'm/0/1',
194+
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
195+
unknownProperty: 'some value',
196+
};
197+
198+
// io-ts with t.exact() strips out additional properties
199+
const decoded = assertDecode(t.exact(t.type(DeriveLocalKeyChainRequestBody)), body);
200+
assert.strictEqual(decoded.path, body.path);
201+
assert.strictEqual(decoded.xprv, body.xprv);
202+
// @ts-expect-error - unknownProperty doesn't exist on the type
203+
assert.strictEqual(decoded.unknownProperty, undefined);
204+
});
205+
});
206+
207+
describe('PostDeriveLocalKeyChain route definition', function () {
208+
it('should have the correct path', function () {
209+
assert.strictEqual(PostDeriveLocalKeyChain.path, '/api/v1/keychain/derive');
210+
});
211+
212+
it('should have the correct HTTP method', function () {
213+
assert.strictEqual(PostDeriveLocalKeyChain.method, 'POST');
214+
});
215+
216+
it('should have the correct request configuration', function () {
217+
// Verify the route is configured with a request property
218+
assert.ok(PostDeriveLocalKeyChain.request);
219+
});
220+
221+
it('should have the correct response types', function () {
222+
// Check that the response object has the expected status codes
223+
assert.ok(PostDeriveLocalKeyChain.response[200]);
224+
assert.ok(PostDeriveLocalKeyChain.response[400]);
225+
});
226+
});
227+
});

0 commit comments

Comments
 (0)