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 @@ -125,7 +125,7 @@ function handleCreateLocalKeyChain(req: express.Request) {
* @deprecated
* @param req
*/
function handleDeriveLocalKeyChain(req: express.Request) {
function handleDeriveLocalKeyChain(req: ExpressApiRouteRequest<'express.v1.keychain.derive', 'post'>) {
return req.bitgo.keychains().deriveLocal(req.body);
}

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

app.post('/api/v1/keychain/local', parseBody, prepareBitGo(config), promiseWrapper(handleCreateLocalKeyChain));
app.post('/api/v1/keychain/derive', parseBody, prepareBitGo(config), promiseWrapper(handleDeriveLocalKeyChain));
router.post('express.v1.keychain.derive', [prepareBitGo(config), typedPromiseWrapper(handleDeriveLocalKeyChain)]);
router.post('express.v1.wallet.simplecreate', [
prepareBitGo(config),
typedPromiseWrapper(handleCreateWalletWithKeychains),
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 @@ -16,6 +16,7 @@ import { PostSignTransaction } from './v1/signTransaction';
import { PostKeychainLocal } from './v2/keychainLocal';
import { PostLightningInitWallet } from './v2/lightningInitWallet';
import { PostVerifyCoinAddress } from './v2/verifyAddress';
import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain';

export const ExpressApi = apiSpec({
'express.ping': {
Expand Down Expand Up @@ -60,6 +61,9 @@ export const ExpressApi = apiSpec({
'express.calculateminerfeeinfo': {
post: PostCalculateMinerFeeInfo,
},
'express.v1.keychain.derive': {
post: PostDeriveLocalKeyChain,
},
});

export type ExpressApi = typeof ExpressApi;
Expand Down
61 changes: 61 additions & 0 deletions modules/express/src/typedRoutes/api/v1/deriveLocalKeyChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { BitgoExpressError } from '../../schemas/error';

/**
* Request parameters for deriving a local keychain
*/
export const DeriveLocalKeyChainRequestBody = {
/** The derivation path to use (e.g. 'm/0/1') */
path: t.string,
/** The extended private key to derive from (either xprv or xpub must be provided) */
xprv: optional(t.string),
/** The extended public key to derive from (either xprv or xpub must be provided) */
xpub: optional(t.string),
};

/**
* Response for deriving a local keychain
*/
export const DeriveLocalKeyChainResponse = t.type({
/** The derivation path that was used */
path: t.string,
/** The derived extended public key */
xpub: t.string,
/** The derived extended private key (only included if xprv was provided in the request) */
xprv: optional(t.string),
/** The Ethereum address derived from the xpub (if available) */
ethAddress: optional(t.string),
});

/**
* Derive a local keychain
*
* Locally derives a keychain from a top level BIP32 string (xprv or xpub), given a path.
* This is useful for deriving child keys from a parent key without having to store the child keys.
*
* The derivation process:
* 1. Takes either an xprv (extended private key) or xpub (extended public key) as input
* 2. Derives a child key at the specified path using BIP32 derivation
* 3. Returns the derived xpub (and xprv if an xprv was provided)
* 4. Also attempts to derive an Ethereum address from the xpub if possible
*
* Note: You must provide either xprv or xpub, but not both. If xprv is provided,
* both the derived xprv and xpub are returned. If xpub is provided, only the
* derived xpub is returned.
*
* @operationId express.v1.keychain.derive
*/
export const PostDeriveLocalKeyChain = httpRoute({
path: '/api/v1/keychain/derive',
method: 'POST',
request: httpRequest({
body: DeriveLocalKeyChainRequestBody,
}),
response: {
/** Successfully derived keychain */
200: DeriveLocalKeyChainResponse,
/** Invalid request or derivation fails */
400: BitgoExpressError,
},
});
227 changes: 227 additions & 0 deletions modules/express/test/unit/typedRoutes/deriveLocalKeyChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import * as assert from 'assert';
import * as t from 'io-ts';
import {
DeriveLocalKeyChainRequestBody,
DeriveLocalKeyChainResponse,
PostDeriveLocalKeyChain,
} from '../../../src/typedRoutes/api/v1/deriveLocalKeyChain';
import { assertDecode } from './common';

describe('DeriveLocalKeyChain codec tests', function () {
describe('DeriveLocalKeyChainRequestBody', function () {
it('should validate body with required path and xprv', function () {
const validBody = {
path: 'm/0/1',
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
};

const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), validBody);
assert.strictEqual(decoded.path, validBody.path);
assert.strictEqual(decoded.xprv, validBody.xprv);
assert.strictEqual(decoded.xpub, undefined); // Optional field
});

it('should validate body with required path and xpub', function () {
const validBody = {
path: 'm/0/1',
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
};

const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), validBody);
assert.strictEqual(decoded.path, validBody.path);
assert.strictEqual(decoded.xpub, validBody.xpub);
assert.strictEqual(decoded.xprv, undefined); // Optional field
});

it('should reject body with missing path', function () {
const invalidBody = {
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
};

assert.throws(() => {
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
});
});

it('should reject body with non-string path', function () {
const invalidBody = {
path: 123, // number instead of string
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
};

assert.throws(() => {
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
});
});

it('should reject body with non-string xprv', function () {
const invalidBody = {
path: 'm/0/1',
xprv: 123, // number instead of string
};

assert.throws(() => {
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
});
});

it('should reject body with non-string xpub', function () {
const invalidBody = {
path: 'm/0/1',
xpub: 123, // number instead of string
};

assert.throws(() => {
assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody);
});
});

// Note: The validation that either xprv or xpub must be provided is handled by the implementation,
// not by the io-ts codec, so we don't test for that here.
});

describe('DeriveLocalKeyChainResponse', function () {
it('should validate response with all required fields', function () {
const validResponse = {
path: 'm/0/1',
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
};

const decoded = assertDecode(DeriveLocalKeyChainResponse, validResponse);
assert.strictEqual(decoded.path, validResponse.path);
assert.strictEqual(decoded.xpub, validResponse.xpub);
assert.strictEqual(decoded.xprv, undefined); // Optional field
assert.strictEqual(decoded.ethAddress, undefined); // Optional field
});

it('should validate response with all fields including optional ones', function () {
const validResponse = {
path: 'm/0/1',
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
ethAddress: '0x1234567890123456789012345678901234567890',
};

const decoded = assertDecode(DeriveLocalKeyChainResponse, validResponse);
assert.strictEqual(decoded.path, validResponse.path);
assert.strictEqual(decoded.xpub, validResponse.xpub);
assert.strictEqual(decoded.xprv, validResponse.xprv);
assert.strictEqual(decoded.ethAddress, validResponse.ethAddress);
});

it('should reject response with missing path', function () {
const invalidResponse = {
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
};

assert.throws(() => {
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
});
});

it('should reject response with missing xpub', function () {
const invalidResponse = {
path: 'm/0/1',
};

assert.throws(() => {
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
});
});

it('should reject response with non-string path', function () {
const invalidResponse = {
path: 123, // number instead of string
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
};

assert.throws(() => {
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
});
});

it('should reject response with non-string xpub', function () {
const invalidResponse = {
path: 'm/0/1',
xpub: 123, // number instead of string
};

assert.throws(() => {
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
});
});

it('should reject response with non-string xprv', function () {
const invalidResponse = {
path: 'm/0/1',
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
xprv: 123, // number instead of string
};

assert.throws(() => {
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
});
});

it('should reject response with non-string ethAddress', function () {
const invalidResponse = {
path: 'm/0/1',
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
ethAddress: 123, // number instead of string
};

assert.throws(() => {
assertDecode(DeriveLocalKeyChainResponse, invalidResponse);
});
});
});

describe('Edge cases', function () {
it('should handle empty strings for string fields', function () {
const body = {
path: '',
xprv: '',
};

const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), body);
assert.strictEqual(decoded.path, '');
assert.strictEqual(decoded.xprv, '');
});

it('should handle additional unknown properties', function () {
const body = {
path: 'm/0/1',
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
unknownProperty: 'some value',
};

// io-ts with t.exact() strips out additional properties
const decoded = assertDecode(t.exact(t.type(DeriveLocalKeyChainRequestBody)), body);
assert.strictEqual(decoded.path, body.path);
assert.strictEqual(decoded.xprv, body.xprv);
// @ts-expect-error - unknownProperty doesn't exist on the type
assert.strictEqual(decoded.unknownProperty, undefined);
});
});

describe('PostDeriveLocalKeyChain route definition', function () {
it('should have the correct path', function () {
assert.strictEqual(PostDeriveLocalKeyChain.path, '/api/v1/keychain/derive');
});

it('should have the correct HTTP method', function () {
assert.strictEqual(PostDeriveLocalKeyChain.method, 'POST');
});

it('should have the correct request configuration', function () {
// Verify the route is configured with a request property
assert.ok(PostDeriveLocalKeyChain.request);
});

it('should have the correct response types', function () {
// Check that the response object has the expected status codes
assert.ok(PostDeriveLocalKeyChain.response[200]);
assert.ok(PostDeriveLocalKeyChain.response[400]);
});
});
});