Skip to content
This repository has been archived by the owner on Oct 7, 2024. It is now read-only.

refactor: split account types #293

Merged
merged 14 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
35 changes: 35 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { assert } from 'superstruct';

import { KeyringAccountStruct, KeyringAccountStructs } from './api'; // Import from `index.ts` to test the public API

const supportedKeyringAccountTypes = Object.keys(KeyringAccountStructs)
.map((type: string) => `"${type}"`)
.join(',');

describe('api', () => {
const baseAccount = {
id: '606a7759-b0fb-48e4-9874-bab62ff8e7eb',
address: '0x000',
options: {},
methods: [],
};

describe('KeyringAccount', () => {
it.each([
[undefined, 'undefined'],
[null, 'null'],
['not:supported', '"not:supported"'],
])(
'throws an error if account type is: %s',
(type: any, typeAsStr: string) => {
const account = {
type,
...baseAccount,
};
expect(() => assert(account, KeyringAccountStruct)).toThrow(
`At path: type -- Expected one of \`${supportedKeyringAccountTypes}\`, but received: ${typeAsStr}`,
);
},
);
});
});
113 changes: 55 additions & 58 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,72 @@
import type { Json } from '@metamask/utils';
import { JsonStruct } from '@metamask/utils';
import type { Infer } from 'superstruct';
import { array, enums, literal, record, string, union } from 'superstruct';

import {
enums,
array,
define,
validate,
literal,
record,
string,
union,
mask,
} from 'superstruct';

import type { StaticAssertAbstractAccount } from './base-types';
import type { EthEoaAccount, EthErc4337Account } from './eth';
import {
EthEoaAccountStruct,
EthErc4337AccountStruct,
EthAccountType,
} from './eth';
import { exactOptional, object } from './superstruct';
import { UuidStruct } from './utils';

/**
* Supported Ethereum methods.
* Type of supported accounts.
*/
export enum EthMethod {
// General signing methods
PersonalSign = 'personal_sign',
Sign = 'eth_sign',
SignTransaction = 'eth_signTransaction',
SignTypedDataV1 = 'eth_signTypedData_v1',
SignTypedDataV3 = 'eth_signTypedData_v3',
SignTypedDataV4 = 'eth_signTypedData_v4',
// ERC-4337 methods
PrepareUserOperation = 'eth_prepareUserOperation',
PatchUserOperation = 'eth_patchUserOperation',
SignUserOperation = 'eth_signUserOperation',
}
export type KeyringAccounts = StaticAssertAbstractAccount<
EthEoaAccount | EthErc4337Account
>;

/**
* Supported Ethereum account types.
* Mapping between account types and their matching `superstruct` schema.
*/
export enum EthAccountType {
Eoa = 'eip155:eoa',
Erc4337 = 'eip155:erc4337',
}

export const KeyringAccountStruct = object({
/**
* Account ID (UUIDv4).
*/
id: UuidStruct,

/**
* Account address or next receive address (UTXO).
*/
address: string(),

/**
* Keyring-dependent account options.
*/
options: record(string(), JsonStruct),

/**
* Account supported methods.
*/
methods: array(
enums([
`${EthMethod.PersonalSign}`,
`${EthMethod.Sign}`,
`${EthMethod.SignTransaction}`,
`${EthMethod.SignTypedDataV1}`,
`${EthMethod.SignTypedDataV3}`,
`${EthMethod.SignTypedDataV4}`,
`${EthMethod.PrepareUserOperation}`,
`${EthMethod.PatchUserOperation}`,
`${EthMethod.SignUserOperation}`,
]),
),
export const KeyringAccountStructs: Record<string, any> = {
danroc marked this conversation as resolved.
Show resolved Hide resolved
[`${EthAccountType.Eoa}`]: EthEoaAccountStruct,
[`${EthAccountType.Erc4337}`]: EthErc4337AccountStruct,
};

/**
* Account type.
*/
type: enums([`${EthAccountType.Eoa}`, `${EthAccountType.Erc4337}`]),
/**
* Base type with account's type for any account as a `superstruct.object`.
*/
export const KeyringAccountTypedStruct = object({
danroc marked this conversation as resolved.
Show resolved Hide resolved
type: enums(Object.keys(KeyringAccountStructs)),
danroc marked this conversation as resolved.
Show resolved Hide resolved
});

/**
* Account as a `superstruct.object`.
*
* See {@link KeyringAccount}.
*/
export const KeyringAccountStruct = define<KeyringAccounts>(
// 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
ccharly marked this conversation as resolved.
Show resolved Hide resolved
// use a much nicer messager from `superstruct`.
ccharly marked this conversation as resolved.
Show resolved Hide resolved
'KeyringAccount',
(value: unknown) => {
// This will also raise if `value` does not match any of the supported account types!
const account = mask(value, KeyringAccountTypedStruct);

// At this point, we know that `value.type` can be used as an index for `KeyringAccountStructs`
const [error] = validate(value, KeyringAccountStructs[account.type]);

return error ?? true;
},
);

/**
* Account object.
*
Expand Down
55 changes: 55 additions & 0 deletions src/base-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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<string, Json>;
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 extends AbstractAccount> = Type;
danroc marked this conversation as resolved.
Show resolved Hide resolved
112 changes: 112 additions & 0 deletions src/eth/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { expectAssignable, expectNotAssignable } from 'tsd';
gantunesr marked this conversation as resolved.
Show resolved Hide resolved

import type { EthEoaAccount, EthErc4337Account } from './types';
import { EthAccountType, EthErc4337Method, EthMethod } from './types';

const id = '606a7759-b0fb-48e4-9874-bab62ff8e7eb';
const address = '0x000';

// EOA account with no methods
expectAssignable<EthEoaAccount>({
type: EthAccountType.Eoa,
id,
address,
options: {},
methods: [],
});

// EOA account with all methods
expectAssignable<EthEoaAccount>({
type: EthAccountType.Eoa,
id,
address,
options: {},
methods: [
`${EthMethod.PersonalSign}`,
`${EthMethod.Sign}`,
`${EthMethod.SignTransaction}`,
`${EthMethod.SignTypedDataV1}`,
`${EthMethod.SignTypedDataV3}`,
`${EthMethod.SignTypedDataV4}`,
],
});

// EOA account with ERC-4337 methods is an error
expectNotAssignable<EthEoaAccount>({
type: EthAccountType.Eoa,
id,
address,
options: {},
methods: [
`${EthErc4337Method.PrepareUserOperation}`,
`${EthErc4337Method.PatchUserOperation}`,
`${EthErc4337Method.SignUserOperation}`,
],
});

// EOA account with ERC-4337 type is an error
expectNotAssignable<EthEoaAccount>({
type: EthAccountType.Erc4337,
id,
address,
options: {},
methods: [
`${EthErc4337Method.PrepareUserOperation}`,
`${EthErc4337Method.PatchUserOperation}`,
`${EthErc4337Method.SignUserOperation}`,
],
});

// ERC-4337 account with no methods
expectAssignable<EthErc4337Account>({
type: EthAccountType.Erc4337,
id,
address,
options: {},
methods: [],
});

// ERC-4337 account with all methods
expectAssignable<EthErc4337Account>({
type: EthAccountType.Erc4337,
id,
address,
options: {},
methods: [
`${EthMethod.PersonalSign}`,
`${EthMethod.Sign}`,
`${EthMethod.SignTransaction}`,
`${EthMethod.SignTypedDataV1}`,
`${EthMethod.SignTypedDataV3}`,
`${EthMethod.SignTypedDataV4}`,
`${EthErc4337Method.PrepareUserOperation}`,
`${EthErc4337Method.PatchUserOperation}`,
`${EthErc4337Method.SignUserOperation}`,
],
});

// ERC-4337 account with only user-ops methods
expectNotAssignable<EthErc4337Account>({
type: EthAccountType.Eoa,
id,
address,
options: {},
methods: [
`${EthErc4337Method.PrepareUserOperation}`,
`${EthErc4337Method.PatchUserOperation}`,
`${EthErc4337Method.SignUserOperation}`,
],
});

// ERC-4337 account with EOA type is an error
expectNotAssignable<EthErc4337Account>({
type: EthAccountType.Eoa,
id,
address,
options: {},
methods: [
`${EthErc4337Method.PrepareUserOperation}`,
`${EthErc4337Method.PatchUserOperation}`,
`${EthErc4337Method.SignUserOperation}`,
],
});
Loading
Loading