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
68 changes: 68 additions & 0 deletions src/BIP44CoinTypeNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,74 @@ describe('BIP44CoinTypeNode', () => {
});
});

describe('fromSeed', () => {
it('initializes a BIP44CoinTypeNode from a seed', async () => {
const node = await BIP44CoinTypeNode.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
],
});

const mnemonicNode = await BIP44CoinTypeNode.fromDerivationPath([
defaultBip39NodeToken,
BIP44PurposeNodeToken,
`bip32:60'`,
]);

expect(node.toJSON()).toStrictEqual(mnemonicNode.toJSON());
});

it('initializes a BIP44CoinTypeNode from a seed with custom cryptographic functions', async () => {
const functions = getMockFunctions();
const node = await BIP44CoinTypeNode.fromSeed(
{
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
],
},
functions,
);

const coinType = 60;
const pathString = `m / bip32:44' / bip32:${coinType}'`;

expect(node.coin_type).toStrictEqual(coinType);
expect(node.depth).toBe(2);
expect(node.privateKeyBytes).toHaveLength(32);
expect(node.publicKeyBytes).toHaveLength(65);
expect(node.path).toStrictEqual(pathString);

expect(functions.hmacSha512).toHaveBeenCalledTimes(3);
expect(functions.pbkdf2Sha512).not.toHaveBeenCalled();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📈

});

it('throws if derivation path has invalid depth', async () => {
await expect(
BIP44CoinTypeNode.fromSeed({
// @ts-expect-error: Invalid derivation path.
derivationPath: [fixtures.local.seed, BIP44PurposeNodeToken],
}),
).rejects.toThrow(
`Invalid depth: Coin type nodes must be of depth ${BIP_44_COIN_TYPE_DEPTH}. Received: "1"`,
);

await expect(
BIP44CoinTypeNode.fromDerivationPath([
defaultBip39NodeToken,
BIP44PurposeNodeToken,
`bip32:60'`,
`bip32:0'`,
] as any),
).rejects.toThrow(
`Invalid depth: Coin type nodes must be of depth ${BIP_44_COIN_TYPE_DEPTH}. Received: "3"`,
);
});
});

describe('deriveBIP44AddressKey', () => {
const coinTypePath = [
defaultBip39NodeToken,
Expand Down
56 changes: 47 additions & 9 deletions src/BIP44CoinTypeNode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { assert } from '@metamask/utils';

import type { BIP44NodeInterface, JsonBIP44Node } from './BIP44Node';
import { BIP44Node } from './BIP44Node';
import type {
Expand All @@ -16,6 +14,7 @@ import type { SupportedCurve } from './curves';
import { deriveChildNode } from './SLIP10Node';
import type { CoinTypeToAddressIndices } from './utils';
import {
getBIP44CoinType,
getBIP32NodeToken,
getBIP44ChangePathString,
getBIP44CoinTypePathString,
Expand All @@ -32,6 +31,12 @@ export type CoinTypeHDPathTuple = [
HardenedBIP32Node,
];

export type CoinTypeSeedPathTuple = [
Uint8Array,
typeof BIP44PurposeNodeToken,
HardenedBIP32Node,
];

export const BIP_44_COIN_TYPE_DEPTH = 2;

export type JsonBIP44CoinTypeNode = JsonBIP44Node & {
Expand All @@ -44,6 +49,11 @@ export type BIP44CoinTypeNodeInterface = BIP44NodeInterface & {
readonly path: CoinTypeHDPathString;
};

export type BIP44CoinTypeSeedOptions = {
readonly derivationPath: CoinTypeSeedPathTuple;
readonly network?: Network | undefined;
};

/**
* A wrapper object for BIP-44 `coin_type` keys. `coin_type` is the index
* specifying the protocol for which deeper keys are intended. For the
Expand Down Expand Up @@ -106,7 +116,7 @@ export class BIP44CoinTypeNode implements BIP44CoinTypeNodeInterface {
}

/**
* Constructs a BIP-44 `coin_type` node. `coin_type` is the index
* Construct a BIP-44 `coin_type` node. `coin_type` is the index
* specifying the protocol for which deeper keys are intended. For the
* authoritative list of coin types, please see
* [SLIP-44](https://github.com/satoshilabs/slips/blob/master/slip-0044.md).
Expand Down Expand Up @@ -141,14 +151,42 @@ export class BIP44CoinTypeNode implements BIP44CoinTypeNodeInterface {
cryptographicFunctions,
);

// Split the bip32 string token and extract the coin_type index.
const pathPart = derivationPath[BIP_44_COIN_TYPE_DEPTH].split(
':',
)[1]?.replace(`'`, '');
const coinType = getBIP44CoinType(derivationPath);
return new BIP44CoinTypeNode(node, coinType);
}

/**
* Create a new BIP-44 coin type node from a BIP-39 seed. The derivation path
* must be rooted, i.e. it must begin with a BIP-39 node, given as a
* `Uint8Array` of the seed bytes.
*
* All parameters are stringently validated, and an error is thrown if
* validation fails.
*
* @param options - The options for the new node.
* @param options.derivationPath - The rooted HD tree path that will be used
* to derive the key of this node.
* @param options.network - The network for the node. This is only used for
* extended keys, and defaults to `mainnet`.
* @param cryptographicFunctions - The cryptographic functions to use. If
* provided, these will be used instead of the built-in implementations.
* @returns A new BIP-44 node.
*/
static async fromSeed(
{ derivationPath, network }: BIP44CoinTypeSeedOptions,
cryptographicFunctions?: CryptographicFunctions,
): Promise<BIP44CoinTypeNode> {
validateCoinTypeNodeDepth(derivationPath.length - 1);

assert(pathPart, 'Invalid derivation path.');
const coinType = Number.parseInt(pathPart, 10);
const node = await BIP44Node.fromSeed(
{
derivationPath,
network,
},
cryptographicFunctions,
);

const coinType = getBIP44CoinType(derivationPath);
return new BIP44CoinTypeNode(node, coinType);
}

Expand Down
163 changes: 158 additions & 5 deletions src/BIP44Node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('BIP44Node', () => {
describe('fromExtendedKey', () => {
it('initializes a new node from a private key', async () => {
const { privateKey, chainCode } = await deriveChildKey({
path: fixtures.local.mnemonic,
path: fixtures.local.seed,
curve: secp256k1,
});

Expand Down Expand Up @@ -61,7 +61,7 @@ describe('BIP44Node', () => {

it('initializes a new node from a private key with custom cryptographic functions', async () => {
const { privateKey, chainCode } = await deriveChildKey({
path: fixtures.local.mnemonic,
path: fixtures.local.seed,
curve: secp256k1,
});

Expand Down Expand Up @@ -98,7 +98,7 @@ describe('BIP44Node', () => {

it('initializes a new node from JSON', async () => {
const { privateKey, chainCode } = await deriveChildKey({
path: fixtures.local.mnemonic,
path: fixtures.local.seed,
curve: secp256k1,
});

Expand All @@ -116,7 +116,7 @@ describe('BIP44Node', () => {

it('initializes a new node from JSON with custom cryptographic functions', async () => {
const { privateKey, chainCode } = await deriveChildKey({
path: fixtures.local.mnemonic,
path: fixtures.local.seed,
curve: secp256k1,
});

Expand Down Expand Up @@ -162,7 +162,7 @@ describe('BIP44Node', () => {

it('throws if the depth is invalid', async () => {
const { privateKey, chainCode } = await deriveChildKey({
path: fixtures.local.mnemonic,
path: fixtures.local.seed,
curve: secp256k1,
});

Expand Down Expand Up @@ -366,6 +366,159 @@ describe('BIP44Node', () => {
});
});

describe('fromSeed', () => {
it('initializes a new node from a seed', async () => {
const node = await BIP44Node.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
],
});

const mnemonicNode = await BIP44Node.fromDerivationPath({
derivationPath: [
defaultBip39NodeToken,
BIP44PurposeNodeToken,
`bip32:60'`,
],
});

expect(node.toJSON()).toStrictEqual(mnemonicNode.toJSON());
});

it('initializes a new node from a seed with custom cryptographic functions', async () => {
const functions = getMockFunctions();

const node = await BIP44Node.fromSeed(
{
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
],
},
functions,
);

expect(node.depth).toBe(2);
expect(node.toJSON()).toStrictEqual({
depth: node.depth,
masterFingerprint: node.masterFingerprint,
parentFingerprint: node.parentFingerprint,
index: node.index,
network: node.network,
privateKey: node.privateKey,
publicKey: node.publicKey,
chainCode: node.chainCode,
});

expect(functions.hmacSha512).toHaveBeenCalled();
expect(functions.pbkdf2Sha512).not.toHaveBeenCalled();
});

it('throws an error if attempting to modify the fields of a node', async () => {
const node: any = await BIP44Node.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
],
});

['depth', 'privateKey', 'publicKey', 'address'].forEach((property) => {
expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow(
expect.objectContaining({
name: 'TypeError',
message: expect.stringMatching(
/^Cannot set property .+ of .+ which has only a getter/iu,
),
}),
);
});
});

it('throws if the derivation path is of depth 0 and not a single BIP-39 node', async () => {
await expect(
BIP44Node.fromSeed({ derivationPath: [`bip32:0'`] as any }),
).rejects.toThrow(
'Invalid derivation path: The "m" / seed node (depth 0) must be a BIP-39 node.',
);
});

it('throws if the depth 1 node of the derivation path is not the BIP-44 purpose node', async () => {
await expect(
BIP44Node.fromSeed({
derivationPath: [fixtures.local.seed, `bip32:43'`] as any,
}),
).rejects.toThrow(
`Invalid derivation path: The "purpose" node (depth 1) must be the string "${BIP44PurposeNodeToken}".`,
);
});

it('throws if the depth 2 node of the derivation path is not a hardened BIP-32 node', async () => {
await expect(
BIP44Node.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60`,
] as any,
}),
).rejects.toThrow(
'Invalid derivation path: The "coin_type" node (depth 2) must be a hardened BIP-32 node.',
);
});

it('throws if the depth 3 node of the derivation path is not a hardened BIP-32 node', async () => {
await expect(
BIP44Node.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
`bip32:0`,
] as any,
}),
).rejects.toThrow(
'Invalid derivation path: The "account" node (depth 3) must be a hardened BIP-32 node.',
);
});

it('throws if the depth 4 node of the derivation path is not a BIP-32 node', async () => {
await expect(
BIP44Node.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
`bip32:0'`,
`bip32:-1`,
],
}),
).rejects.toThrow(
'Invalid derivation path: The "change" node (depth 4) must be a BIP-32 node.',
);
});

it('throws if the depth 5 node of the derivation path is not a BIP-32 node', async () => {
await expect(
BIP44Node.fromSeed({
derivationPath: [
fixtures.local.seed,
BIP44PurposeNodeToken,
`bip32:60'`,
`bip32:0'`,
`bip32:0`,
`bip32:-1`,
],
}),
).rejects.toThrow(
'Invalid derivation path: The "address_index" node (depth 5) must be a BIP-32 node.',
);
});
});

describe('derive', () => {
it('derives a child node', async () => {
const coinTypeNode = `bip32:40'`;
Expand Down
Loading
Loading