diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index bb2aaf599..880e81834 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -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 diff --git a/packages/keyring-api/src/api/v2/create-account/index.test.ts b/packages/keyring-api/src/api/v2/create-account/index.test.ts index e5228ad06..69ce5c93f 100644 --- a/packages/keyring-api/src/api/v2/create-account/index.test.ts +++ b/packages/keyring-api/src/api/v2/create-account/index.test.ts @@ -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', () => { @@ -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.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); + }); + }); +}); diff --git a/packages/keyring-api/src/api/v2/create-account/index.ts b/packages/keyring-api/src/api/v2/create-account/index.ts index e37c7b8aa..767e43d9c 100644 --- a/packages/keyring-api/src/api/v2/create-account/index.ts +++ b/packages/keyring-api/src/api/v2/create-account/index.ts @@ -92,3 +92,46 @@ export const CreateAccountOptionsStruct = selectiveUnion((value: any) => { * Represents the available options for creating a new account. */ export type CreateAccountOptions = Infer; + +/** + * 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}`); + } +}