Skip to content
Merged
135 changes: 135 additions & 0 deletions packages/account-api/src/api/group.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import {
DEFAULT_ACCOUNT_GROUP_UNIQUE_ID,
isAccountGroupId,
parseAccountGroupId,
stripAccountWalletId,
toAccountGroupId,
toDefaultAccountGroupId,
} from './group';
import { AccountWalletType, toAccountWalletId } from './wallet';
import {
MOCK_ENTROPY_GROUP_ID,
MOCK_ENTROPY_SOURCE_1,
MOCK_KEYRING_GROUP_ID,
MOCK_PRIVATE_KEY_KEYRING_TYPE,
MOCK_SNAP_1,
MOCK_SNAP_2,
MOCK_SNAP_LOCAL_GROUP_ID,
MOCK_SNAP_NPM_GROUP_ID,
} from '../mocks';

const MOCK_INVALID_GROUP_IDS = [
'invalid-id',
'entropy/01K3KE7FE52Z62S76VMNYNZH3J:0',
'keyring:Simple Key Pair@0x456',
];

describe('group', () => {
describe('toAccountGroupId', () => {
Expand All @@ -27,4 +46,120 @@ describe('group', () => {
);
});
});

describe('isAccountGroupId', () => {
it.each([
MOCK_ENTROPY_GROUP_ID,
MOCK_SNAP_LOCAL_GROUP_ID,
MOCK_SNAP_NPM_GROUP_ID,
MOCK_KEYRING_GROUP_ID,
])('returns true if ID is valid: %s', (id) => {
expect(isAccountGroupId(id)).toBe(true);
});

it.each(MOCK_INVALID_GROUP_IDS)(
'returns false if ID is invalid: %s',
(id) => {
expect(isAccountGroupId(id)).toBe(false);
},
);
});

describe('parseAccountGroupId', () => {
it.each([
{
id: MOCK_ENTROPY_GROUP_ID,
parsed: {
wallet: {
id: toAccountWalletId(
AccountWalletType.Entropy,
MOCK_ENTROPY_SOURCE_1,
),
type: AccountWalletType.Entropy,
subId: MOCK_ENTROPY_SOURCE_1,
},
subId: '0',
},
},
{
id: MOCK_SNAP_LOCAL_GROUP_ID,
parsed: {
wallet: {
id: toAccountWalletId(AccountWalletType.Snap, MOCK_SNAP_1.id),
type: AccountWalletType.Snap,
subId: MOCK_SNAP_1.id,
},
subId: '0x123',
},
},
{
id: MOCK_SNAP_NPM_GROUP_ID,
parsed: {
wallet: {
id: toAccountWalletId(AccountWalletType.Snap, MOCK_SNAP_2.id),
type: AccountWalletType.Snap,
subId: MOCK_SNAP_2.id,
},
subId: '0x456',
},
},
{
id: MOCK_KEYRING_GROUP_ID,
parsed: {
wallet: {
id: toAccountWalletId(
AccountWalletType.Keyring,
MOCK_PRIVATE_KEY_KEYRING_TYPE,
),
type: AccountWalletType.Keyring,
subId: MOCK_PRIVATE_KEY_KEYRING_TYPE,
},
subId: '0x789',
},
},
])('parses account group id for: %s', ({ id, parsed }) => {
expect(parseAccountGroupId(id)).toStrictEqual(parsed);
});

it.each(MOCK_INVALID_GROUP_IDS)(
'fails to parse invalid account group ID',
(id) => {
expect(() => parseAccountGroupId(id)).toThrow(
`Invalid account group ID: "${id}"`,
);
},
);
});

describe('stripAccountWalletId', () => {
it.each([
{
id: MOCK_ENTROPY_GROUP_ID,
stripped: '0',
},
{
id: MOCK_SNAP_LOCAL_GROUP_ID,
stripped: '0x123',
},
{
id: MOCK_SNAP_NPM_GROUP_ID,
stripped: '0x456',
},
{
id: MOCK_KEYRING_GROUP_ID,
stripped: '0x789',
},
])('get account group sub-ID for: %s', ({ id, stripped }) => {
expect(stripAccountWalletId(id)).toStrictEqual(stripped);
});

it.each(MOCK_INVALID_GROUP_IDS)(
'fails to parse invalid account group ID',
(id) => {
expect(() => stripAccountWalletId(id)).toThrow(
`Invalid account group ID: "${id}"`,
);
},
);
});
});
78 changes: 73 additions & 5 deletions packages/account-api/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type { KeyringAccount } from '@metamask/keyring-api';

// Circular import are allowed when using `import type`.
import type { AccountSelector } from './selector';
import type {
AccountWallet,
AccountWalletId,
AccountWalletIdOf,
AccountWalletType,
import {
type AccountWallet,
type AccountWalletId,
type AccountWalletIdOf,
type AccountWalletType,
} from './wallet';

/**
Expand Down Expand Up @@ -35,6 +35,24 @@ export enum AccountGroupType {
*/
export type AccountGroupId = `${AccountWalletId}/${string}`;

/**
* Regex to validate a valid account group ID.
*/
export const ACCOUNT_GROUP_ID_REGEX =
/^(?<walletId>(?<walletType>entropy|snap|keyring):(?<walletSubId>.+))\/(?<groupSubId>[^/]+)$/u;

/**
* Parsed account group ID with its parsed wallet component and its sub-ID.
*/
export type ParsedAccountGroupId = {
wallet: {
id: AccountWalletId;
type: AccountWalletType;
subId: string;
};
subId: string;
};

/**
* Account group that can hold multiple accounts.
*/
Expand Down Expand Up @@ -122,3 +140,53 @@ export function toDefaultAccountGroupId<WalletType extends AccountWalletType>(
DEFAULT_ACCOUNT_GROUP_UNIQUE_ID,
);
}

/**
* Checks if the given value is {@link AccountGroupId}.
*
* @param value - The value to check.
* @returns Whether the value is a {@link AccountGroupId}.
*/
export function isAccountGroupId(value: string): value is AccountGroupId {
return ACCOUNT_GROUP_ID_REGEX.test(value);
}

/**
* Parse a multichain account group ID to an object containing a wallet ID
* information (wallet type and wallet sub-ID), as well as account group ID
* information (group sub-ID).
*
* @param groupId - The account group ID to validate and parse.
* @returns The parsed account group ID.
* @throws When the group ID format is invalid.
*/
export function parseAccountGroupId(groupId: string): ParsedAccountGroupId {
const match = ACCOUNT_GROUP_ID_REGEX.exec(groupId);
if (!match?.groups) {
throw new Error(`Invalid account group ID: "${groupId}"`);
}

const walletId = match.groups.walletId as AccountWalletId;
const walletType = match.groups.walletType as AccountWalletType;
const walletSubId = match.groups.walletSubId as string;

return {
wallet: {
id: walletId,
type: walletType,
subId: walletSubId,
},
subId: match.groups.groupSubId as string,
};
}

/**
* Strip the account wallet ID from an account group ID.
*
* @param groupId - Account group ID.
* @returns Account group sub-ID.
* @throws When the group ID format is invalid.
*/
export function stripAccountWalletId(groupId: string): string {
return parseAccountGroupId(groupId).subId;
}
2 changes: 1 addition & 1 deletion packages/account-api/src/api/multichain/group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('multichain group', () => {
// be possible!
groupId as unknown as MultichainAccountGroupId,
),
).toThrow('Unable to extract group index');
).toThrow(`Invalid multichain account group ID: "${groupId}"`);
});
});
});
66 changes: 52 additions & 14 deletions packages/account-api/src/api/multichain/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,31 @@ import type {
} from './wallet';
import type { Bip44Account } from '../bip44';
import type { AccountGroup, AccountGroupType } from '../group';
import { AccountWalletType } from '../wallet';

const MULTICHAIN_ACCOUNT_GROUP_ID_REGEX = new RegExp(
`^${AccountWalletType.Entropy}:.*/(?<groupIndex>\\d+)$`,
'u',
);
import type { AccountWalletType } from '../wallet';

/**
* Multichain account ID.
*/
export type MultichainAccountGroupId = `${MultichainAccountWalletId}/${number}`; // Use number for the account group index.

/**
* Regex to validate a valid multichain account group ID.
*/
export const MULTICHAIN_ACCOUNT_GROUP_ID_REGEX =
/^(?<walletId>(?<walletType>entropy):(?<walletSubId>.+))\/(?<groupIndex>[0-9]+)$/u;

/**
* Parsed account group ID with its parsed wallet component and its sub-ID.
*/
export type ParsedMultichainAccountGroupId = {
wallet: {
id: MultichainAccountWalletId;
type: AccountWalletType.Entropy;
subId: string;
};
groupIndex: number;
};

/**
* A multichain account that holds multiple accounts.
*/
Expand Down Expand Up @@ -71,21 +84,46 @@ export function isMultichainAccountGroupId(
return MULTICHAIN_ACCOUNT_GROUP_ID_REGEX.test(value);
}

/**
* Parse a multichain account group ID to an object containing a multichain
* wallet ID information (wallet type and wallet sub-ID), as well as
* multichain account group ID information (group index).
*
* @param groupId - The multichain account group ID to validate and parse.
* @returns The parsed multichain account group ID.
* @throws When the group ID format is invalid.
Copy link
Contributor

Choose a reason for hiding this comment

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

The reason I suggested having a type of the error in front is because it's proposed by TSDoc and also some editors (like mine) are recognizing it that way. It's not a blocker, but something to think about.

https://tsdoc.org/pages/tags/throws/

They're more specific about their suggestion:

It is suggested, but not required, for the @throws block to start with a line containing only the name of the exception.

For example what I see in editor:
Image

The other way around is like:
Image

So, it's kinda "more semantic" and IDE uses it in some way.

But, anyway this is not hard requirement, just some stuff good to know if you want to consider having in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't aware of this TBH. It's good that you share this! I'll definitely start using those then, I thought those were just old type annotation that we were not really using anymore in tsdoc (cause we don't really have rule to enforce this in all the linter/toolings we have in place).

Anyway, I'll make sure to update our docs with this next time! 👍

*/
export function parseMultichainAccountGroupId(
groupId: string,
): ParsedMultichainAccountGroupId {
const match = MULTICHAIN_ACCOUNT_GROUP_ID_REGEX.exec(groupId);
if (!match?.groups) {
throw new Error(`Invalid multichain account group ID: "${groupId}"`);
}

const walletId = match.groups.walletId as MultichainAccountWalletId;
const walletType = match.groups.walletType as AccountWalletType.Entropy;
const walletSubId = match.groups.walletSubId as string;

return {
wallet: {
id: walletId,
type: walletType,
subId: walletSubId,
},
groupIndex: Number(match.groups.groupIndex),
};
}

/**
* Gets the multichain account index from an account group ID.
*
* @param id - Multichain account ID.
* @returns The multichain account index if extractable, undefined otherwise.
* @throws When the group ID format is invalid.
*/
export function getGroupIndexFromMultichainAccountGroupId(
id: MultichainAccountGroupId,
): number {
const matched = id.match(MULTICHAIN_ACCOUNT_GROUP_ID_REGEX);
if (matched?.groups?.groupIndex === undefined) {
// Unable to extract group index, even though, type wise, this should not
// be possible!
throw new Error('Unable to extract group index');
}

return Number(matched.groups.groupIndex);
return parseMultichainAccountGroupId(id).groupIndex;
}
Loading
Loading