Skip to content

Commit 760ecda

Browse files
ccharlydavid0xd
andauthored
feat(account-api): add group/wallet id parsing support (#360)
Adding parsing helpers (similar to the ones we have in `@metamask/utils` for CAIP) for account wallet/group IDs. --------- Co-authored-by: David Drazic <david@timechaser.org>
1 parent dedf498 commit 760ecda

14 files changed

Lines changed: 575 additions & 43 deletions

File tree

packages/account-api/src/api/group.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
import {
22
DEFAULT_ACCOUNT_GROUP_UNIQUE_ID,
3+
isAccountGroupId,
4+
parseAccountGroupId,
5+
stripAccountWalletId,
36
toAccountGroupId,
47
toDefaultAccountGroupId,
58
} from './group';
69
import { AccountWalletType, toAccountWalletId } from './wallet';
10+
import {
11+
MOCK_ENTROPY_GROUP_ID,
12+
MOCK_ENTROPY_SOURCE_1,
13+
MOCK_KEYRING_GROUP_ID,
14+
MOCK_PRIVATE_KEY_KEYRING_TYPE,
15+
MOCK_SNAP_1,
16+
MOCK_SNAP_2,
17+
MOCK_SNAP_LOCAL_GROUP_ID,
18+
MOCK_SNAP_NPM_GROUP_ID,
19+
} from '../mocks';
20+
21+
const MOCK_INVALID_GROUP_IDS = [
22+
'invalid-id',
23+
'entropy/01K3KE7FE52Z62S76VMNYNZH3J:0',
24+
'keyring:Simple Key Pair@0x456',
25+
];
726

827
describe('group', () => {
928
describe('toAccountGroupId', () => {
@@ -27,4 +46,120 @@ describe('group', () => {
2746
);
2847
});
2948
});
49+
50+
describe('isAccountGroupId', () => {
51+
it.each([
52+
MOCK_ENTROPY_GROUP_ID,
53+
MOCK_SNAP_LOCAL_GROUP_ID,
54+
MOCK_SNAP_NPM_GROUP_ID,
55+
MOCK_KEYRING_GROUP_ID,
56+
])('returns true if ID is valid: %s', (id) => {
57+
expect(isAccountGroupId(id)).toBe(true);
58+
});
59+
60+
it.each(MOCK_INVALID_GROUP_IDS)(
61+
'returns false if ID is invalid: %s',
62+
(id) => {
63+
expect(isAccountGroupId(id)).toBe(false);
64+
},
65+
);
66+
});
67+
68+
describe('parseAccountGroupId', () => {
69+
it.each([
70+
{
71+
id: MOCK_ENTROPY_GROUP_ID,
72+
parsed: {
73+
wallet: {
74+
id: toAccountWalletId(
75+
AccountWalletType.Entropy,
76+
MOCK_ENTROPY_SOURCE_1,
77+
),
78+
type: AccountWalletType.Entropy,
79+
subId: MOCK_ENTROPY_SOURCE_1,
80+
},
81+
subId: '0',
82+
},
83+
},
84+
{
85+
id: MOCK_SNAP_LOCAL_GROUP_ID,
86+
parsed: {
87+
wallet: {
88+
id: toAccountWalletId(AccountWalletType.Snap, MOCK_SNAP_1.id),
89+
type: AccountWalletType.Snap,
90+
subId: MOCK_SNAP_1.id,
91+
},
92+
subId: '0x123',
93+
},
94+
},
95+
{
96+
id: MOCK_SNAP_NPM_GROUP_ID,
97+
parsed: {
98+
wallet: {
99+
id: toAccountWalletId(AccountWalletType.Snap, MOCK_SNAP_2.id),
100+
type: AccountWalletType.Snap,
101+
subId: MOCK_SNAP_2.id,
102+
},
103+
subId: '0x456',
104+
},
105+
},
106+
{
107+
id: MOCK_KEYRING_GROUP_ID,
108+
parsed: {
109+
wallet: {
110+
id: toAccountWalletId(
111+
AccountWalletType.Keyring,
112+
MOCK_PRIVATE_KEY_KEYRING_TYPE,
113+
),
114+
type: AccountWalletType.Keyring,
115+
subId: MOCK_PRIVATE_KEY_KEYRING_TYPE,
116+
},
117+
subId: '0x789',
118+
},
119+
},
120+
])('parses account group id for: %s', ({ id, parsed }) => {
121+
expect(parseAccountGroupId(id)).toStrictEqual(parsed);
122+
});
123+
124+
it.each(MOCK_INVALID_GROUP_IDS)(
125+
'fails to parse invalid account group ID',
126+
(id) => {
127+
expect(() => parseAccountGroupId(id)).toThrow(
128+
`Invalid account group ID: "${id}"`,
129+
);
130+
},
131+
);
132+
});
133+
134+
describe('stripAccountWalletId', () => {
135+
it.each([
136+
{
137+
id: MOCK_ENTROPY_GROUP_ID,
138+
stripped: '0',
139+
},
140+
{
141+
id: MOCK_SNAP_LOCAL_GROUP_ID,
142+
stripped: '0x123',
143+
},
144+
{
145+
id: MOCK_SNAP_NPM_GROUP_ID,
146+
stripped: '0x456',
147+
},
148+
{
149+
id: MOCK_KEYRING_GROUP_ID,
150+
stripped: '0x789',
151+
},
152+
])('get account group sub-ID for: %s', ({ id, stripped }) => {
153+
expect(stripAccountWalletId(id)).toStrictEqual(stripped);
154+
});
155+
156+
it.each(MOCK_INVALID_GROUP_IDS)(
157+
'fails to parse invalid account group ID',
158+
(id) => {
159+
expect(() => stripAccountWalletId(id)).toThrow(
160+
`Invalid account group ID: "${id}"`,
161+
);
162+
},
163+
);
164+
});
30165
});

packages/account-api/src/api/group.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import type { KeyringAccount } from '@metamask/keyring-api';
22

33
// Circular import are allowed when using `import type`.
44
import type { AccountSelector } from './selector';
5-
import type {
6-
AccountWallet,
7-
AccountWalletId,
8-
AccountWalletIdOf,
9-
AccountWalletType,
5+
import {
6+
type AccountWallet,
7+
type AccountWalletId,
8+
type AccountWalletIdOf,
9+
type AccountWalletType,
1010
} from './wallet';
1111

1212
/**
@@ -35,6 +35,24 @@ export enum AccountGroupType {
3535
*/
3636
export type AccountGroupId = `${AccountWalletId}/${string}`;
3737

38+
/**
39+
* Regex to validate a valid account group ID.
40+
*/
41+
export const ACCOUNT_GROUP_ID_REGEX =
42+
/^(?<walletId>(?<walletType>entropy|snap|keyring):(?<walletSubId>.+))\/(?<groupSubId>[^/]+)$/u;
43+
44+
/**
45+
* Parsed account group ID with its parsed wallet component and its sub-ID.
46+
*/
47+
export type ParsedAccountGroupId = {
48+
wallet: {
49+
id: AccountWalletId;
50+
type: AccountWalletType;
51+
subId: string;
52+
};
53+
subId: string;
54+
};
55+
3856
/**
3957
* Account group that can hold multiple accounts.
4058
*/
@@ -122,3 +140,53 @@ export function toDefaultAccountGroupId<WalletType extends AccountWalletType>(
122140
DEFAULT_ACCOUNT_GROUP_UNIQUE_ID,
123141
);
124142
}
143+
144+
/**
145+
* Checks if the given value is {@link AccountGroupId}.
146+
*
147+
* @param value - The value to check.
148+
* @returns Whether the value is a {@link AccountGroupId}.
149+
*/
150+
export function isAccountGroupId(value: string): value is AccountGroupId {
151+
return ACCOUNT_GROUP_ID_REGEX.test(value);
152+
}
153+
154+
/**
155+
* Parse a multichain account group ID to an object containing a wallet ID
156+
* information (wallet type and wallet sub-ID), as well as account group ID
157+
* information (group sub-ID).
158+
*
159+
* @param groupId - The account group ID to validate and parse.
160+
* @returns The parsed account group ID.
161+
* @throws When the group ID format is invalid.
162+
*/
163+
export function parseAccountGroupId(groupId: string): ParsedAccountGroupId {
164+
const match = ACCOUNT_GROUP_ID_REGEX.exec(groupId);
165+
if (!match?.groups) {
166+
throw new Error(`Invalid account group ID: "${groupId}"`);
167+
}
168+
169+
const walletId = match.groups.walletId as AccountWalletId;
170+
const walletType = match.groups.walletType as AccountWalletType;
171+
const walletSubId = match.groups.walletSubId as string;
172+
173+
return {
174+
wallet: {
175+
id: walletId,
176+
type: walletType,
177+
subId: walletSubId,
178+
},
179+
subId: match.groups.groupSubId as string,
180+
};
181+
}
182+
183+
/**
184+
* Strip the account wallet ID from an account group ID.
185+
*
186+
* @param groupId - Account group ID.
187+
* @returns Account group sub-ID.
188+
* @throws When the group ID format is invalid.
189+
*/
190+
export function stripAccountWalletId(groupId: string): string {
191+
return parseAccountGroupId(groupId).subId;
192+
}

packages/account-api/src/api/multichain/group.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('multichain group', () => {
6666
// be possible!
6767
groupId as unknown as MultichainAccountGroupId,
6868
),
69-
).toThrow('Unable to extract group index');
69+
).toThrow(`Invalid multichain account group ID: "${groupId}"`);
7070
});
7171
});
7272
});

packages/account-api/src/api/multichain/group.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,31 @@ import type {
66
} from './wallet';
77
import type { Bip44Account } from '../bip44';
88
import type { AccountGroup, AccountGroupType } from '../group';
9-
import { AccountWalletType } from '../wallet';
10-
11-
const MULTICHAIN_ACCOUNT_GROUP_ID_REGEX = new RegExp(
12-
`^${AccountWalletType.Entropy}:.*/(?<groupIndex>\\d+)$`,
13-
'u',
14-
);
9+
import type { AccountWalletType } from '../wallet';
1510

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

16+
/**
17+
* Regex to validate a valid multichain account group ID.
18+
*/
19+
export const MULTICHAIN_ACCOUNT_GROUP_ID_REGEX =
20+
/^(?<walletId>(?<walletType>entropy):(?<walletSubId>.+))\/(?<groupIndex>[0-9]+)$/u;
21+
22+
/**
23+
* Parsed account group ID with its parsed wallet component and its sub-ID.
24+
*/
25+
export type ParsedMultichainAccountGroupId = {
26+
wallet: {
27+
id: MultichainAccountWalletId;
28+
type: AccountWalletType.Entropy;
29+
subId: string;
30+
};
31+
groupIndex: number;
32+
};
33+
2134
/**
2235
* A multichain account that holds multiple accounts.
2336
*/
@@ -71,21 +84,46 @@ export function isMultichainAccountGroupId(
7184
return MULTICHAIN_ACCOUNT_GROUP_ID_REGEX.test(value);
7285
}
7386

87+
/**
88+
* Parse a multichain account group ID to an object containing a multichain
89+
* wallet ID information (wallet type and wallet sub-ID), as well as
90+
* multichain account group ID information (group index).
91+
*
92+
* @param groupId - The multichain account group ID to validate and parse.
93+
* @returns The parsed multichain account group ID.
94+
* @throws When the group ID format is invalid.
95+
*/
96+
export function parseMultichainAccountGroupId(
97+
groupId: string,
98+
): ParsedMultichainAccountGroupId {
99+
const match = MULTICHAIN_ACCOUNT_GROUP_ID_REGEX.exec(groupId);
100+
if (!match?.groups) {
101+
throw new Error(`Invalid multichain account group ID: "${groupId}"`);
102+
}
103+
104+
const walletId = match.groups.walletId as MultichainAccountWalletId;
105+
const walletType = match.groups.walletType as AccountWalletType.Entropy;
106+
const walletSubId = match.groups.walletSubId as string;
107+
108+
return {
109+
wallet: {
110+
id: walletId,
111+
type: walletType,
112+
subId: walletSubId,
113+
},
114+
groupIndex: Number(match.groups.groupIndex),
115+
};
116+
}
117+
74118
/**
75119
* Gets the multichain account index from an account group ID.
76120
*
77121
* @param id - Multichain account ID.
78122
* @returns The multichain account index if extractable, undefined otherwise.
123+
* @throws When the group ID format is invalid.
79124
*/
80125
export function getGroupIndexFromMultichainAccountGroupId(
81126
id: MultichainAccountGroupId,
82127
): number {
83-
const matched = id.match(MULTICHAIN_ACCOUNT_GROUP_ID_REGEX);
84-
if (matched?.groups?.groupIndex === undefined) {
85-
// Unable to extract group index, even though, type wise, this should not
86-
// be possible!
87-
throw new Error('Unable to extract group index');
88-
}
89-
90-
return Number(matched.groups.groupIndex);
128+
return parseMultichainAccountGroupId(id).groupIndex;
91129
}

0 commit comments

Comments
 (0)