Skip to content

Commit 56bec57

Browse files
committed
feat(account-group): add groupByWalletType rule and listAccountGroups function
1 parent 66d6142 commit 56bec57

File tree

2 files changed

+231
-42
lines changed

2 files changed

+231
-42
lines changed

packages/account-group-controller/src/AccountGroupController.test.ts

Lines changed: 188 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import {
1919
type AllowedActions,
2020
type AllowedEvents,
2121
} from './AccountGroupController';
22-
import type { AccountGroupControllerMessenger } from './AccountGroupController';
22+
import type {
23+
AccountGroupControllerMessenger,
24+
AccountGroupId,
25+
} from './AccountGroupController';
2326

2427
const ETH_EOA_METHODS = [
2528
EthMethod.PersonalSign,
@@ -31,7 +34,9 @@ const ETH_EOA_METHODS = [
3134
] as const;
3235

3336
/**
37+
* Creates a new root messenger instance for testing.
3438
*
39+
* @returns A new Messenger instance.
3540
*/
3641
function getRootMessenger() {
3742
return new Messenger<
@@ -41,8 +46,10 @@ function getRootMessenger() {
4146
}
4247

4348
/**
49+
* Retrieves a restricted messenger for the AccountGroupController.
4450
*
45-
* @param messenger
51+
* @param messenger - The root messenger instance. Defaults to a new Messenger created by getRootMessenger().
52+
* @returns The restricted messenger for the AccountGroupController.
4653
*/
4754
function getAccountGroupControllerMessenger(
4855
messenger = getRootMessenger(),
@@ -58,11 +65,12 @@ function getAccountGroupControllerMessenger(
5865
}
5966

6067
/**
68+
* Sets up the AccountGroupController for testing.
6169
*
62-
* @param options0
63-
* @param options0.initialState
64-
* @param options0.messenger
65-
* @param options0.state
70+
* @param options - Configuration options for setup.
71+
* @param options.state - Partial initial state for the controller. Defaults to empty object.
72+
* @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger.
73+
* @returns An object containing the controller instance and the messenger.
6674
*/
6775
function setup({
6876
state = {},
@@ -168,39 +176,184 @@ const MOCK_SNAP_ACCOUNT_2: InternalAccount = {
168176
},
169177
};
170178

179+
const MOCK_HARDWARE_ACCOUNT_1: InternalAccount = {
180+
id: 'mock-hardware-id-1',
181+
address: '0x123',
182+
options: {},
183+
methods: [...ETH_EOA_METHODS],
184+
type: EthAccountType.Eoa,
185+
scopes: [EthScope.Eoa],
186+
metadata: {
187+
name: '',
188+
keyring: { type: KeyringTypes.ledger },
189+
importTime: 1691565967600,
190+
lastSelected: 1955565967656,
191+
},
192+
};
193+
171194
describe('AccountGroupController', () => {
172-
it('group accounts according to the rules', async () => {
173-
const { controller, messenger } = setup({});
174-
175-
messenger.registerActionHandler(
176-
'AccountsController:listMultichainAccounts',
177-
() => {
178-
return [
179-
MOCK_HD_ACCOUNT_1,
180-
MOCK_HD_ACCOUNT_2,
181-
MOCK_SNAP_ACCOUNT_1,
182-
MOCK_SNAP_ACCOUNT_2,
183-
];
184-
},
185-
);
186-
187-
await controller.init();
188-
189-
expect(controller.state).toStrictEqual({
190-
accountGroups: {
191-
groups: {
192-
'mock-keyring-id-1': {
193-
[DEFAULT_SUB_GROUP]: [MOCK_HD_ACCOUNT_1.id],
195+
describe('updateAccountGroups', () => {
196+
it('should group accounts by entropy source, then snapId, then wallet type', async () => {
197+
const { controller, messenger } = setup({});
198+
199+
messenger.registerActionHandler(
200+
'AccountsController:listMultichainAccounts',
201+
() => {
202+
return [
203+
MOCK_HD_ACCOUNT_1,
204+
MOCK_HD_ACCOUNT_2,
205+
MOCK_SNAP_ACCOUNT_1,
206+
MOCK_SNAP_ACCOUNT_2,
207+
MOCK_HARDWARE_ACCOUNT_1,
208+
];
209+
},
210+
);
211+
212+
await controller.updateAccountGroups();
213+
214+
expect(controller.state.accountGroups.groups).toStrictEqual({
215+
'mock-keyring-id-1': {
216+
[DEFAULT_SUB_GROUP]: [MOCK_HD_ACCOUNT_1.id],
217+
},
218+
'mock-keyring-id-2': {
219+
[DEFAULT_SUB_GROUP]: [MOCK_HD_ACCOUNT_2.id, MOCK_SNAP_ACCOUNT_1.id],
220+
},
221+
'mock-snap-id-2': {
222+
[DEFAULT_SUB_GROUP]: [MOCK_SNAP_ACCOUNT_2.id],
223+
},
224+
[KeyringTypes.ledger]: {
225+
[DEFAULT_SUB_GROUP]: [MOCK_HARDWARE_ACCOUNT_1.id],
226+
},
227+
});
228+
});
229+
230+
it('should warn and fall back to wallet type grouping if an HD account is missing entropySource', async () => {
231+
const consoleWarnSpy = jest
232+
.spyOn(console, 'warn')
233+
.mockImplementation(() => undefined);
234+
const { controller, messenger } = setup({});
235+
236+
const mockHdAccountWithoutEntropy: InternalAccount = {
237+
id: 'hd-account-no-entropy',
238+
address: '0xHDADD',
239+
metadata: {
240+
name: 'HD Account Without Entropy',
241+
keyring: {
242+
type: KeyringTypes.hd,
243+
},
244+
importTime: Date.now(),
245+
lastSelected: Date.now(),
246+
},
247+
methods: [...ETH_EOA_METHODS],
248+
options: {},
249+
type: EthAccountType.Eoa,
250+
scopes: [EthScope.Eoa],
251+
};
252+
253+
const listAccountsMock = jest
254+
.fn()
255+
.mockReturnValue([mockHdAccountWithoutEntropy]);
256+
messenger.registerActionHandler(
257+
'AccountsController:listMultichainAccounts',
258+
listAccountsMock,
259+
);
260+
261+
await controller.updateAccountGroups();
262+
263+
expect(consoleWarnSpy).toHaveBeenCalledWith(
264+
"! Found an HD account with no entropy source: account won't be associated to its wallet",
265+
);
266+
267+
expect(
268+
controller.state.accountGroups.groups[KeyringTypes.hd]?.[
269+
DEFAULT_SUB_GROUP
270+
],
271+
).toContain(mockHdAccountWithoutEntropy.id);
272+
273+
expect(
274+
controller.state.accountGroups.groups[
275+
undefined as unknown as AccountGroupId
276+
],
277+
).toBeUndefined();
278+
279+
consoleWarnSpy.mockRestore();
280+
});
281+
});
282+
283+
describe('listAccountGroups', () => {
284+
it('should return an empty array if no groups exist', async () => {
285+
const { controller } = setup({
286+
state: {
287+
accountGroups: { groups: {} },
288+
accountGroupsMetadata: {},
289+
},
290+
});
291+
292+
const result = await controller.listAccountGroups();
293+
expect(result).toEqual([]);
294+
});
295+
296+
it('should correctly map group data and metadata to AccountGroup objects', async () => {
297+
const group1Id = 'group-id-1' as AccountGroupId;
298+
const group2Id = 'group-id-2' as AccountGroupId;
299+
300+
const initialState: AccountGroupControllerState = {
301+
accountGroups: {
302+
groups: {
303+
[group1Id]: {
304+
[DEFAULT_SUB_GROUP]: ['account-1', 'account-2'],
305+
},
306+
[group2Id]: {
307+
'sub-group-x': ['account-3'],
308+
},
194309
},
195-
'mock-keyring-id-2': {
196-
[DEFAULT_SUB_GROUP]: [MOCK_HD_ACCOUNT_2.id, MOCK_SNAP_ACCOUNT_1.id],
310+
},
311+
accountGroupsMetadata: {
312+
[group1Id]: { name: 'Group 1 Name' },
313+
[group2Id]: { name: 'Group 2 Name' },
314+
},
315+
};
316+
317+
const { controller } = setup({ state: initialState });
318+
const result = await controller.listAccountGroups();
319+
320+
expect(result).toEqual([
321+
{
322+
id: group1Id,
323+
name: 'Group 1 Name',
324+
subGroups: {
325+
[DEFAULT_SUB_GROUP]: ['account-1', 'account-2'],
197326
},
198-
'mock-snap-id-2': {
199-
[DEFAULT_SUB_GROUP]: [MOCK_SNAP_ACCOUNT_2.id],
327+
},
328+
{
329+
id: group2Id,
330+
name: 'Group 2 Name',
331+
subGroups: {
332+
'sub-group-x': ['account-3'],
200333
},
201334
},
202-
metadata: {},
203-
},
204-
} as AccountGroupControllerState);
335+
]);
336+
});
337+
338+
it('should throw a TypeError if metadata is missing for a group', async () => {
339+
const groupIdWithMissingMetadata = 'group-missing-meta' as AccountGroupId;
340+
const initialState: Partial<AccountGroupControllerState> = {
341+
accountGroups: {
342+
groups: {
343+
[groupIdWithMissingMetadata]: {
344+
[DEFAULT_SUB_GROUP]: ['account-x'],
345+
},
346+
},
347+
},
348+
// Metadata for groupIdWithMissingMetadata is deliberately omitted
349+
accountGroupsMetadata: {},
350+
};
351+
352+
const { controller } = setup({ state: initialState });
353+
354+
// Current implementation will throw: Cannot read properties of undefined (reading 'name')
355+
// which is a TypeError.
356+
await expect(controller.listAccountGroups()).rejects.toThrow(TypeError);
357+
});
205358
});
206359
});

packages/account-group-controller/src/AccountGroupController.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export type AccountGroupId = string;
2121
// NOTES:
2222
// - Maybe add a `metadata` / `flags` for each groups (or at least, top-level ones)
2323

24+
export type AccountGroup = {
25+
id: AccountGroupId;
26+
name: string;
27+
subGroups: {
28+
[accountSubGroup: AccountGroupId]: AccountId[];
29+
};
30+
};
31+
2432
export type AccountGroupMetadata = {
2533
name: string;
2634
};
@@ -34,10 +42,9 @@ export type AccountGroupControllerState = {
3442
[accountSubGroup: AccountGroupId]: AccountId[]; // Blockchain Accounts
3543
};
3644
};
37-
// QUESTION: Should we have some metadata here indexed by `AccountGroupId` or should we
38-
// group them on `groups` and sub-groups? The reasoning of having them separate would be
39-
// to be able to persist them and just re-create the groups at runtime.
40-
metadata: Record<AccountGroupId, AccountGroupMetadata>;
45+
};
46+
accountGroupsMetadata: {
47+
[accountGroup: AccountGroupId]: AccountGroupMetadata;
4148
};
4249
};
4350

@@ -46,9 +53,15 @@ export type AccountGroupControllerGetStateAction = ControllerGetStateAction<
4653
AccountGroupControllerState
4754
>;
4855

56+
export type AccountGroupControllerListAccountGroupsAction = {
57+
type: `${typeof controllerName}:listAccountGroups`;
58+
handler: AccountGroupController['listAccountGroups'];
59+
};
60+
4961
export type AllowedActions = AccountsControllerListMultichainAccountsAction;
5062

51-
export type AccountGroupControllerActions = never;
63+
export type AccountGroupControllerActions =
64+
AccountGroupControllerListAccountGroupsAction;
5265

5366
export type AccountGroupControllerChangeEvent = ControllerStateChangeEvent<
5467
typeof controllerName,
@@ -75,6 +88,10 @@ const accountGroupControllerMetadata: StateMetadata<AccountGroupControllerState>
7588
persist: false, // We do re-recompute this state everytime.
7689
anonymous: false,
7790
},
91+
accountGroupsMetadata: {
92+
persist: false, // TODO: Change it to true once we have data to persist.
93+
anonymous: false,
94+
},
7895
};
7996

8097
/**
@@ -86,8 +103,8 @@ export function getDefaultAccountGroupControllerState(): AccountGroupControllerS
86103
return {
87104
accountGroups: {
88105
groups: {},
89-
metadata: {},
90106
},
107+
accountGroupsMetadata: {},
91108
};
92109
}
93110

@@ -183,12 +200,18 @@ export class AccountGroupController extends BaseController<
183200
return undefined;
184201
}
185202

186-
async init(): Promise<void> {
203+
#groupByWalletType(account: InternalAccount): AccountGroupId | undefined {
204+
return account.metadata.keyring.type as AccountGroupId;
205+
}
206+
207+
async updateAccountGroups(): Promise<void> {
187208
const rules = [
188209
// 1. We group by entropy-source
189210
(account: InternalAccount) => this.#groupByEntropySource(account),
190211
// 2. We group by Snap ID
191212
(account: InternalAccount) => this.#groupBySnapId(account),
213+
// 3. We group by wallet type
214+
(account: InternalAccount) => this.#groupByWalletType(account),
192215
];
193216
const groups: AccountGroupControllerState['accountGroups']['groups'] = {};
194217

@@ -219,6 +242,19 @@ export class AccountGroupController extends BaseController<
219242
});
220243
}
221244

245+
async listAccountGroups(): Promise<AccountGroup[]> {
246+
return Object.keys(this.state.accountGroups.groups).map(
247+
(groupId: AccountGroupId) => {
248+
const subGroups = this.state.accountGroups.groups[groupId];
249+
return {
250+
id: groupId,
251+
name: this.state.accountGroupsMetadata[groupId].name,
252+
subGroups,
253+
};
254+
},
255+
);
256+
}
257+
222258
/**
223259
* Lists the multichain accounts coming from the `AccountsController`.
224260
*

0 commit comments

Comments
 (0)