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
2 changes: 2 additions & 0 deletions packages/keyring-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add `EthAddressStrictStruct` struct and `EthAddressStrict` types ([#465](https://github.com/MetaMask/accounts/pull/465))
- This is a stricter variant of `EthAddressStruct` which uses `Hex` instead of `string` for its inferred type.
- Add `assertCreateAccountOptionIsSupported` helper ([#464](https://github.com/MetaMask/accounts/pull/464))
- This helper can be used to implement `createAccounts` and narrow down the `options` to the supported types (based on the keyring capabilities).

### Changed

Expand Down
227 changes: 226 additions & 1 deletion packages/keyring-api/src/api/v2/create-account/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { assert, is } from '@metamask/superstruct';

import { AccountCreationType, CreateAccountOptionsStruct } from '.';
import {
AccountCreationType,
assertCreateAccountOptionIsSupported,
CreateAccountOptionsStruct,
type CreateAccountBip44DeriveIndexOptions,
type CreateAccountOptions,
} from '.';

describe('CreateAccountOptionsStruct', () => {
describe('valid account creation types', () => {
Expand Down Expand Up @@ -195,3 +201,222 @@ describe('CreateAccountOptionsStruct', () => {
});
});
});

describe('assertCreateAccountOptionIsSupported', () => {
describe('when type is supported', () => {
it('does not throw when type is in supportedTypes array', () => {
const options = {
type: AccountCreationType.Bip44DerivePath,
entropySource: 'user-input',
derivationPath: "m/44'/0'/0'/0/0",
} as CreateAccountOptions;
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Bip44DeriveIndex,
];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).not.toThrow();
});

it('does not throw when type is the only supported type', () => {
const options = {
type: AccountCreationType.PrivateKeyImport,
privateKey: '0x1234567890abcdef',
encoding: 'hexadecimal',
} as CreateAccountOptions;
const supportedTypes = [AccountCreationType.PrivateKeyImport];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).not.toThrow();
});

it('does not throw when all types are supported', () => {
const options = {
type: AccountCreationType.Custom,
scope: 'scope',
} as CreateAccountOptions;
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Bip44DeriveIndex,
AccountCreationType.Bip44DeriveIndexRange,
AccountCreationType.Bip44Discover,
AccountCreationType.PrivateKeyImport,
AccountCreationType.Custom,
];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).not.toThrow();
});

it('does not throw for each supported BIP-44 type', () => {
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Bip44DeriveIndex,
AccountCreationType.Bip44DeriveIndexRange,
AccountCreationType.Bip44Discover,
];

const optionsMap: Record<AccountCreationType, CreateAccountOptions> = {
[AccountCreationType.Bip44DerivePath]: {
type: AccountCreationType.Bip44DerivePath,
entropySource: 'user-input',
derivationPath: "m/44'/0'/0'/0/0",
},
[AccountCreationType.Bip44DeriveIndex]: {
type: AccountCreationType.Bip44DeriveIndex,
entropySource: 'user-input',
groupIndex: 0,
},
[AccountCreationType.Bip44DeriveIndexRange]: {
type: AccountCreationType.Bip44DeriveIndexRange,
entropySource: 'user-input',
range: {
from: 0,
to: 1,
},
},
[AccountCreationType.Bip44Discover]: {
type: AccountCreationType.Bip44Discover,
entropySource: 'user-input',
groupIndex: 0,
},
[AccountCreationType.PrivateKeyImport]: {
type: AccountCreationType.PrivateKeyImport,
privateKey: '0x1234567890abcdef',
encoding: 'hexadecimal',
},
[AccountCreationType.Custom]: {
type: AccountCreationType.Custom,
// FIXME: We cannot use custom fields currently with `CreateAccountOptions` type.
// We need to define a struct manually to open up the type for custom options to
// allow additional fields.
},
};

supportedTypes.forEach((type) => {
expect(() =>
assertCreateAccountOptionIsSupported(
optionsMap[type],
supportedTypes,
),
).not.toThrow();
});
});
});

describe('when type is not supported', () => {
it('throws error with correct message when type is not in supportedTypes', () => {
const options = {
type: AccountCreationType.Custom,
scope: 'scope',
} as CreateAccountOptions;
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Bip44DeriveIndex,
];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).toThrow('Unsupported create account option type: custom');
});

it('throws error when supportedTypes is empty', () => {
const options = {
type: AccountCreationType.Bip44DerivePath,
entropySource: 'user-input',
derivationPath: "m/44'/0'/0'/0/0",
} as CreateAccountOptions;
const supportedTypes: AccountCreationType[] = [];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).toThrow('Unsupported create account option type: bip44:derive-path');
});

it('throws error for PrivateKeyImport when only BIP-44 types are supported', () => {
const options = {
type: AccountCreationType.PrivateKeyImport,
privateKey: '0x1234567890abcdef',
encoding: 'hexadecimal',
} as CreateAccountOptions;
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Bip44DeriveIndex,
];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).toThrow('Unsupported create account option type: private-key:import');
});

it('throws error for Bip44Discover when not in supportedTypes', () => {
const options = {
type: AccountCreationType.Bip44Discover,
entropySource: 'user-input',
groupIndex: 0,
} as CreateAccountOptions;
const supportedTypes = [
AccountCreationType.Bip44DerivePath,
AccountCreationType.Custom,
];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).toThrow('Unsupported create account option type: bip44:discover');
});

it('includes the unsupported type value in error message', () => {
const options = {
type: AccountCreationType.Bip44DeriveIndexRange,
entropySource: 'user-input',
range: {
from: 0,
to: 1,
},
} as CreateAccountOptions;
const supportedTypes = [AccountCreationType.PrivateKeyImport];

expect(() =>
assertCreateAccountOptionIsSupported(options, supportedTypes),
).toThrow(/bip44:derive-index-range/u);
});
});

describe('type narrowing behavior', () => {
it('narrows type correctly after assertion passes', () => {
const options = {
type: AccountCreationType.Bip44DerivePath,
entropySource: 'user-input',
derivationPath: "m/44'/0'/0'/0/0",
} as CreateAccountOptions;
const supportedTypes = [
`${AccountCreationType.Bip44DerivePath}`,
] as const;

assertCreateAccountOptionIsSupported(options, supportedTypes);

// After the assertion, TypeScript should narrow the type.
const narrowedType: (typeof supportedTypes)[number] = options.type; // Compile-time check.
expect(narrowedType).toBe(AccountCreationType.Bip44DerivePath);
});

it('narrows CreateAccountOptions type based on supported type', () => {
const options = {
type: AccountCreationType.Bip44DeriveIndex,
entropySource: 'mock-entropy-source',
groupIndex: 0,
} as CreateAccountOptions;

const supportedTypes = [AccountCreationType.Bip44DeriveIndex] as const;
assertCreateAccountOptionIsSupported(options, supportedTypes);

// After assertion, options should be narrowed to CreateAccountBip44DeriveIndexOptions
const narrowedOptions: CreateAccountBip44DeriveIndexOptions = options; // Compile-time check.
expect(narrowedOptions.type).toBe(AccountCreationType.Bip44DeriveIndex);
});
});
});
43 changes: 43 additions & 0 deletions packages/keyring-api/src/api/v2/create-account/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,46 @@ export const CreateAccountOptionsStruct = selectiveUnion((value: any) => {
* Represents the available options for creating a new account.
*/
export type CreateAccountOptions = Infer<typeof CreateAccountOptionsStruct>;

/**
* Asserts that a given create account option type is supported by the keyring.
*
* @example
* ```ts
* createAccounts(options: CreateAccountOptions) {
* assertCreateAccountOptionIsSupported(options, [
* ${AccountCreationType.Bip44DeriveIndex},
* ${AccountCreationType.Bip44DeriveIndexRange},
* ] as const);
*
* // At this point, TypeScript knows that options.type is either Bip44DeriveIndex or Bip44DeriveIndexRange.
* if (options.type === AccountCreationType.Bip44DeriveIndex) {
* ... // Handle Bip44DeriveIndex case.
* } else {
* ... // Handle Bip44DeriveIndexRange case.
* }
* ...
* return accounts;
* }
* ```
*
* @param options - The create account option object to check.
* @param supportedTypes - The list of supported create account option types for this keyring.
* @throws Will throw an error if the provided options are not supported.
*/
export function assertCreateAccountOptionIsSupported<
Options extends CreateAccountOptions,
// We use template literal types to enforce string-literal over strict enum values.
Type extends `${CreateAccountOptions['type']}`,
>(
options: Options,
supportedTypes: readonly `${Type}`[],
// Use intersection to avoid widening `type` beyond `Options['type']`.
): asserts options is Options & { type: `${Type}` & `${Options['type']}` } {
const { type } = options;
const types: readonly CreateAccountOptions['type'][] = supportedTypes;

if (!types.includes(type)) {
throw new Error(`Unsupported create account option type: ${type}`);
}
}
Loading