diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..2e4e839f8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["bech32", "p2wpkh", "sendmany"] +} diff --git a/src/api.test.ts b/src/api.test.ts index b77424811..c3b3d000b 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,8 +1,10 @@ import { assert } from 'superstruct'; -import { KeyringAccountStruct, KeyringAccountStructs } from './api'; // Import from `index.ts` to test the public API +import { KeyringAccountStruct } from './api'; -const supportedKeyringAccountTypes = Object.keys(KeyringAccountStructs) +const supportedKeyringAccountTypes = Object.keys( + KeyringAccountStruct.schema.type.schema, +) .map((type: string) => `"${type}"`) .join(','); diff --git a/src/api.ts b/src/api.ts index 455473505..c29ba5eeb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,53 +1,42 @@ import type { Json } from '@metamask/utils'; import { JsonStruct } from '@metamask/utils'; -import type { Infer, Struct } from 'superstruct'; -import { - enums, - array, - define, - validate, - literal, - record, - string, - union, - mask, -} from 'superstruct'; - -import type { StaticAssertAbstractAccount } from './base-types'; -import type { BtcP2wpkhAccount } from './btc'; -import { BtcP2wpkhAccountStruct, BtcAccountType } from './btc'; -import type { EthEoaAccount, EthErc4337Account } from './eth'; -import { - EthEoaAccountStruct, - EthErc4337AccountStruct, - EthAccountType, -} from './eth'; +import type { Infer } from 'superstruct'; +import { enums, array, literal, record, string, union } from 'superstruct'; + import { exactOptional, object } from './superstruct'; import { UuidStruct } from './utils'; +// ! The `*AccountType` enums defined below should be kept in this file to +// ! avoid circular dependencies when the API is used by other files. + /** - * Type of supported accounts. + * Supported Ethereum account types. */ -export type KeyringAccounts = StaticAssertAbstractAccount< - EthEoaAccount | EthErc4337Account | BtcP2wpkhAccount ->; +export enum EthAccountType { + Eoa = 'eip155:eoa', + Erc4337 = 'eip155:erc4337', +} /** - * Mapping between account types and their matching `superstruct` schema. + * Supported Bitcoin account types. */ -export const KeyringAccountStructs: Record< - string, - Struct | Struct | Struct -> = { - [`${EthAccountType.Eoa}`]: EthEoaAccountStruct, - [`${EthAccountType.Erc4337}`]: EthErc4337AccountStruct, - [`${BtcAccountType.P2wpkh}`]: BtcP2wpkhAccountStruct, -}; +export enum BtcAccountType { + P2wpkh = 'bip122:p2wpkh', +} /** - * Base type for `KeyringAccount` as a `superstruct.object`. + * A struct which represents a Keyring account object. It is abstract enough to + * be used with any blockchain. Specific blockchain account types should extend + * this struct. + * + * See {@link KeyringAccount}. */ -export const BaseKeyringAccountStruct = object({ +export const KeyringAccountStruct = object({ + /** + * Account ID (UUIDv4). + */ + id: UuidStruct, + /** * Account type. */ @@ -56,38 +45,26 @@ export const BaseKeyringAccountStruct = object({ `${EthAccountType.Erc4337}`, `${BtcAccountType.P2wpkh}`, ]), -}); -/** - * Account as a `superstruct.object`. - * - * See {@link KeyringAccount}. - */ -export const KeyringAccountStruct = define( - // We do use a custom `define` for this type to avoid having to use a `union` since error - // messages are a bit confusing. - // - // Doing manual validation allows us to use the "concrete" type of each supported acounts giving - // use a much nicer message from `superstruct`. - 'KeyringAccount', - (value: unknown) => { - // This will also raise if `value` does not match any of the supported account types! - const account = mask(value, BaseKeyringAccountStruct); - - // At this point, we know that `value.type` can be used as an index for `KeyringAccountStructs` - const [error] = validate( - value, - KeyringAccountStructs[account.type] as Struct, - ); - - return error ?? true; - }, -); + /** + * Account main address. + */ + address: string(), + + /** + * Account options. + */ + options: record(string(), JsonStruct), + + /** + * Account supported methods. + */ + methods: array(string()), +}); /** - * Account object. - * - * Represents an account with its properties and capabilities. + * Keyring Account type represents an account and its properties from the + * point of view of the keyring. */ export type KeyringAccount = Infer; diff --git a/src/base-types.ts b/src/base-types.ts deleted file mode 100644 index 02b0f17af..000000000 --- a/src/base-types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Json } from '@metamask/utils'; -import { JsonStruct } from '@metamask/utils'; -import { object, record, string } from 'superstruct'; - -import { UuidStruct } from './utils'; - -/** - * Base type for any account. This type must be composed and extended to add a `methods` - * and `type` fields. - * - * NOTE: This type isn't a `superstruct.object` as it is used to compose other objects. See - * {@link BaseAccountStruct}. - */ -export const BaseAccount = { - /** - * Account ID (UUIDv4). - */ - id: UuidStruct, - - /** - * Account address or next receive address (UTXO). - */ - address: string(), - - /** - * Keyring-dependent account options. - */ - options: record(string(), JsonStruct), -}; - -/** - * Base type for any account as a `superstruct.object`. - */ -export const BaseAccountStruct = object(BaseAccount); - -/** - * Abstract struct that is used to match every supported account type. Making sure their type - * definition do not diverge from each others. - * - * NOTE: This type is using "primitive types" such as `string` to not contrain any real account - * type. It's up to those types to use more restrictions on their type definition. - */ -export type AbstractAccount = { - id: string; - address: string; - options: Record; - type: string; - methods: string[]; -}; - -/** - * Type helper to make sure `Type` is "equal to" `AbstractAccount`, asserting that `Type` (an account - * type actually) never diverges from other account types. - */ -export type StaticAssertAbstractAccount = Type; diff --git a/src/btc/types.test-d.ts b/src/btc/types.test-d.ts new file mode 100644 index 000000000..f3be51f5a --- /dev/null +++ b/src/btc/types.test-d.ts @@ -0,0 +1,7 @@ +import type { KeyringAccount } from '../api'; +import type { Extends } from '../utils'; +import { expectTrue } from '../utils'; +import type { BtcP2wpkhAccount } from './types'; + +// `BtcP2wpkhAccount` extends `KeyringAccount` +expectTrue>(); diff --git a/src/btc/types.ts b/src/btc/types.ts index bac42af29..7fcf02ad0 100644 --- a/src/btc/types.ts +++ b/src/btc/types.ts @@ -1,8 +1,9 @@ import { bech32 } from 'bech32'; import type { Infer } from 'superstruct'; -import { object, string, array, enums, literal, refine } from 'superstruct'; +import { string, array, enums, refine, literal } from 'superstruct'; -import { BaseAccount } from '../base-types'; +import { KeyringAccountStruct, BtcAccountType } from '../api'; +import { object } from '../superstruct'; export const BtcP2wpkhAddressStruct = refine( string(), @@ -27,15 +28,13 @@ export enum BtcMethod { SendMany = 'btc_sendmany', } -/** - * Supported Bitcoin account types. - */ -export enum BtcAccountType { - P2wpkh = 'bip122:p2wpkh', -} - export const BtcP2wpkhAccountStruct = object({ - ...BaseAccount, + ...KeyringAccountStruct.schema, + + /** + * Account address. + */ + address: BtcP2wpkhAddressStruct, /** * Account type. diff --git a/src/eth/types.test-d.ts b/src/eth/types.test-d.ts index ab0d631b1..bec12c935 100644 --- a/src/eth/types.test-d.ts +++ b/src/eth/types.test-d.ts @@ -1,7 +1,11 @@ import { expectAssignable, expectNotAssignable } from 'tsd'; +import type { KeyringAccount } from '../api'; +import { EthAccountType } from '../api'; +import type { Extends } from '../utils'; +import { expectTrue } from '../utils'; import type { EthEoaAccount, EthErc4337Account } from './types'; -import { EthAccountType, EthErc4337Method, EthMethod } from './types'; +import { EthMethod, EthErc4337Method } from './types'; const id = '606a7759-b0fb-48e4-9874-bab62ff8e7eb'; const address = '0x000'; @@ -109,3 +113,9 @@ expectNotAssignable({ `${EthErc4337Method.SignUserOperation}`, ], }); + +// `EthEoaAccount` extends `KeyringAccount` +expectTrue>(); + +// `EthErc4337Account` extends `KeyringAccount` +expectTrue>(); diff --git a/src/eth/types.ts b/src/eth/types.ts index 45c958459..4dd2f6a27 100644 --- a/src/eth/types.ts +++ b/src/eth/types.ts @@ -1,8 +1,8 @@ import type { Infer } from 'superstruct'; -import { object, array, enums, literal } from 'superstruct'; +import { array, enums, literal } from 'superstruct'; -import { BaseAccount } from '../base-types'; -import { definePattern } from '../superstruct'; +import { EthAccountType, KeyringAccountStruct } from '../api'; +import { object, definePattern } from '../superstruct'; export const EthBytesStruct = definePattern('EthBytes', /^0x[0-9a-f]*$/iu); @@ -39,16 +39,13 @@ export enum EthErc4337Method { SignUserOperation = 'eth_signUserOperation', } -/** - * Supported Ethereum account types. - */ -export enum EthAccountType { - Eoa = 'eip155:eoa', - Erc4337 = 'eip155:erc4337', -} - export const EthEoaAccountStruct = object({ - ...BaseAccount, + ...KeyringAccountStruct.schema, + + /** + * Account address. + */ + address: EthAddressStruct, /** * Account type. @@ -73,7 +70,12 @@ export const EthEoaAccountStruct = object({ export type EthEoaAccount = Infer; export const EthErc4337AccountStruct = object({ - ...BaseAccount, + ...KeyringAccountStruct.schema, + + /** + * Account address. + */ + address: EthAddressStruct, /** * Account type. diff --git a/src/eth/utils.test.ts b/src/eth/utils.test.ts index 98cf97fc5..ea3e9dd79 100644 --- a/src/eth/utils.test.ts +++ b/src/eth/utils.test.ts @@ -1,5 +1,4 @@ -import { EthAccountType } from '.'; -import { BtcAccountType } from '../btc'; +import { BtcAccountType, EthAccountType } from '../api'; import { isEvmAccountType } from './utils'; describe('isEvmAccountType', () => { diff --git a/src/eth/utils.ts b/src/eth/utils.ts index 1c451069d..a5b5c728a 100644 --- a/src/eth/utils.ts +++ b/src/eth/utils.ts @@ -1,5 +1,5 @@ +import { EthAccountType } from '../api'; import type { InternalAccountType } from '../internal'; -import { EthAccountType } from './types'; /** * Checks if the given type is an EVM account type. diff --git a/src/internal/events.test.ts b/src/internal/events.test.ts index feae5bae6..dc33ae9ec 100644 --- a/src/internal/events.test.ts +++ b/src/internal/events.test.ts @@ -1,6 +1,6 @@ import { is } from 'superstruct'; -import { EthAccountType } from '../eth/types'; +import { EthAccountType } from '../api'; import { KeyringEvent } from '../events'; import { AccountCreatedEventStruct, diff --git a/src/internal/types.ts b/src/internal/types.ts index 9ee0ca0ef..f79acf0b5 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -1,13 +1,9 @@ import type { Infer, Struct } from 'superstruct'; -import { boolean, string, number, define, mask, validate } from 'superstruct'; +import { boolean, string, number } from 'superstruct'; -import { BaseKeyringAccountStruct } from '../api'; -import { BtcP2wpkhAccountStruct, BtcAccountType } from '../btc/types'; -import { - EthEoaAccountStruct, - EthErc4337AccountStruct, - EthAccountType, -} from '../eth/types'; +import { BtcAccountType, EthAccountType, KeyringAccountStruct } from '../api'; +import { BtcP2wpkhAccountStruct } from '../btc/types'; +import { EthEoaAccountStruct, EthErc4337AccountStruct } from '../eth/types'; import { exactOptional, object } from '../superstruct'; export type InternalAccountType = EthAccountType | BtcAccountType; @@ -34,7 +30,7 @@ export const InternalAccountMetadataStruct = object({ * Creates an `InternalAccount` from an existing account `superstruct` object. * * @param accountStruct - An account `superstruct` object. - * @returns The `InternalAccount` assocaited to `accountStruct`. + * @returns The `InternalAccount` associated to `accountStruct`. */ function asInternalAccountStruct( accountStruct: Struct, @@ -82,20 +78,10 @@ export type InternalAccountTypes = | InternalEthErc4337Account | InternalBtcP2wpkhAccount; -export const InternalAccountStruct = define( - 'InternalAccount', - (value: unknown) => { - const account = mask(value, BaseKeyringAccountStruct); - - // At this point, we know that `value.type` can be used as an index for `KeyringAccountStructs` - const [error] = validate( - value, - InternalAccountStructs[account.type] as Struct, - ); - - return error ?? true; - }, -); +export const InternalAccountStruct = object({ + ...KeyringAccountStruct.schema, + ...InternalAccountMetadataStruct.schema, +}); /** * Internal account representation. diff --git a/src/utils.test-d.ts b/src/utils.test-d.ts new file mode 100644 index 000000000..894d8f322 --- /dev/null +++ b/src/utils.test-d.ts @@ -0,0 +1,12 @@ +import type { Extends } from './utils'; +import { expectTrue } from './utils'; + +expectTrue(); + +// @ts-expect-error [test] Type `false` doesn't extend `true`. +expectTrue(); + +expectTrue>(); + +// @ts-expect-error [test] The first type doesn't extend the second type. +expectTrue>(); diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 000000000..b54237d1b --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,7 @@ +import { expectTrue } from './utils'; + +describe('expectTrue', () => { + it('does nothing since expectTrue is an empty function', () => { + expect(() => expectTrue()).not.toThrow(); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 7e4393c2f..e4617b88c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -42,3 +42,31 @@ export function strictMask( assert(value, struct, message); return value; } + +/** + * Type that resolves to `true` if `Child` extends `Base`, otherwise `false`. + * + * @example + * ```ts + * type A = Extends<{a: string, b: string}, {a: string}>; // true + * type B = Extends<{a: string}, {a: string, b: string}>; // false + * ``` + */ +export type Extends = Child extends Base ? true : false; + +/** + * Assert that a type extends `true`. It can be used, for example, to assert + * that a given type extends another type. + * + * @example + * ```ts + * expectTrue>(); // Ok + * expectTrue>(); // Error + * ``` + * + * This function follows the naming pattern used on `tsd`. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function expectTrue(): void { + // Intentionally empty +}