Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,80 @@ export const createMultichainAccountGroup = async (
}
};

/**
* Creates multiple multichain account groups in batch (from 0 to maxGroupIndex).
* This is an optimized version that creates all groups in one operation instead of
* creating them sequentially.
*
* @param context - The sync context containing controller and messenger.
* @param entropySourceId - The entropy source ID.
* @param maxGroupIndex - Maximum group index (inclusive) to create.
* @param profileId - The profile ID for analytics.
* @param analyticsAction - The analytics action to log for each created group.
* @returns Array of created group IDs.
*/
export const createMultichainAccountGroupsBatch = async (
context: BackupAndSyncContext,
entropySourceId: string,
maxGroupIndex: number,
profileId: ProfileId,
analyticsAction: BackupAndSyncAnalyticsAction,
): Promise<string[]> => {
backupAndSyncLogger(
`Creating account groups 0-${maxGroupIndex} in batch for entropy source: ${entropySourceId}`,
);

try {
// Call the batched creation method.
const groups = await context.messenger.call(
'MultichainAccountService:createMultichainAccountGroups',
{
entropySource: entropySourceId,
maxGroupIndex,
},
);

// Emit analytics event for each newly created group.
// Note: groups array contains all groups (existing + newly created).
const createdGroupIds: string[] = [];

for (const group of groups) {
// TODO: A group should not be null here, but EVM provider might fail to create some groups sometimes, which means
// we can end up having an "empty group" for some time.
if (group) {
createdGroupIds.push(group.id);

// Emit analytics event.
context.emitAnalyticsEventFn({
action: analyticsAction,
profileId,
});
}
}

backupAndSyncLogger(
`Successfully created ${groups.length} groups (indices 0-${maxGroupIndex})`,
);

return createdGroupIds;
} catch (error) {
// This can happen if the Snap Keyring is not ready yet when invoking
// `MultichainAccountService:createMultichainAccountGroups`.
// Since `MultichainAccountService:createMultichainAccountGroups` will at
// least create the EVM account and the account group before throwing, we can safely
// ignore this error and swallow it.
// Any missing Snap accounts will be added later with alignment.

backupAndSyncLogger(
`Failed to create account groups batch:`,
// istanbul ignore next
error instanceof Error ? error.message : String(error),
);

return [];
}
};

/**
* Creates local groups from user storage groups.
*
Expand All @@ -93,19 +167,14 @@ export async function createLocalGroupsFromUserStorage(

// Creating multichain account group is idempotent, so we can safely
// re-create every groups starting from 0.
for (
let groupIndex = 0;
groupIndex <= numberOfAccountGroupsToCreate;
groupIndex++
) {
await createMultichainAccountGroup(
context,
entropySourceId,
groupIndex,
profileId,
BackupAndSyncAnalyticsEvent.GroupAdded,
);
}
// Use batch creation for better performance.
await createMultichainAccountGroupsBatch(
context,
entropySourceId,
numberOfAccountGroupsToCreate,
profileId,
BackupAndSyncAnalyticsEvent.GroupAdded,
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { toMultichainAccountWalletId } from '@metamask/account-api';
import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller';

import { createMultichainAccountGroup } from './group';
import { createMultichainAccountGroupsBatch } from './group';
import { backupAndSyncLogger } from '../../logger';
import { BackupAndSyncAnalyticsEvent } from '../analytics';
import type { ProfileId } from '../authentication';
Expand Down Expand Up @@ -51,16 +51,14 @@ export const performLegacyAccountSyncing = async (
if (numberOfAccountGroupsToCreate > 0) {
// Creating multichain account group is idempotent, so we can safely
// re-create every groups starting from 0.
for (let i = 0; i < numberOfAccountGroupsToCreate; i++) {
backupAndSyncLogger(`Creating account group ${i} for legacy account`);
await createMultichainAccountGroup(
context,
entropySourceId,
i,
profileId,
BackupAndSyncAnalyticsEvent.LegacyGroupAddedFromAccount,
);
}
// Use batch creation for better performance.
await createMultichainAccountGroupsBatch(
context,
entropySourceId,
numberOfAccountGroupsToCreate - 1, // maxGroupIndex is inclusive, so subtract 1
profileId,
BackupAndSyncAnalyticsEvent.LegacyGroupAddedFromAccount,
);
}

// 3. Rename account groups if needed
Expand Down
8 changes: 6 additions & 2 deletions packages/account-tree-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import type {
import type { TraceCallback } from '@metamask/controller-utils';
import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller';
import type { Messenger } from '@metamask/messenger';
import type { MultichainAccountServiceCreateMultichainAccountGroupAction } from '@metamask/multichain-account-service';
import type {
MultichainAccountServiceCreateMultichainAccountGroupAction,
MultichainAccountServiceCreateMultichainAccountGroupsAction,
} from '@metamask/multichain-account-service';
import type {
AuthenticationController,
UserStorageController,
Expand Down Expand Up @@ -132,7 +135,8 @@ export type AllowedActions =
| UserStorageController.UserStorageControllerPerformSetStorage
| UserStorageController.UserStorageControllerPerformBatchSetStorage
| AuthenticationController.AuthenticationControllerGetSessionProfile
| MultichainAccountServiceCreateMultichainAccountGroupAction;
| MultichainAccountServiceCreateMultichainAccountGroupAction
| MultichainAccountServiceCreateMultichainAccountGroupsAction;

export type AccountTreeControllerActions =
| AccountTreeControllerGetStateAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
MOCK_SNAP_ACCOUNT_2,
MOCK_SOL_ACCOUNT_1,
MockAccountBuilder,
mockCreateAccountsOnce,

Check failure on line 31 in packages/multichain-account-service/src/MultichainAccountService.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

'mockCreateAccountsOnce' is defined but never used
mockCreateMaxAccountsOnce,
} from './tests';
import {
MOCK_HD_KEYRING_1,
Expand Down Expand Up @@ -94,8 +96,9 @@
providerClass: new (messenger: MultichainAccountServiceMessenger) => Provider,
mocks: MockAccountProvider,
accounts: KeyringAccount[],
idx: number,
_type: KeyringAccount['type'],
index: number,
name: string,
type: KeyringAccount['type'],
): void {
jest.mocked(providerClass).mockImplementation((...args) => {
mocks.constructor(...args);
Expand All @@ -105,21 +108,13 @@
setupBip44AccountProvider({
mocks,
accounts,
index: idx,
index,
});

// Provide stable provider name and compatibility logic for grouping
if (providerClass === (EvmAccountProvider as unknown)) {
mocks.getName.mockReturnValue(EVM_ACCOUNT_PROVIDER_NAME);
mocks.isAccountCompatible?.mockImplementation(
(account: KeyringAccount) => account.type === EthAccountType.Eoa,
);
} else if (providerClass === (SolAccountProvider as unknown)) {
mocks.getName.mockReturnValue(SOL_ACCOUNT_PROVIDER_NAME);
mocks.isAccountCompatible?.mockImplementation(
(account: KeyringAccount) => account.type === SolAccountType.DataAccount,
);
}
mocks.getName.mockReturnValue(name);
mocks.isAccountCompatible?.mockImplementation(
(account: KeyringAccount) => account.type === type,
);
}

async function setup({
Expand Down Expand Up @@ -239,13 +234,15 @@
mocks.EvmAccountProvider,
accounts,
0,
EVM_ACCOUNT_PROVIDER_NAME,
EthAccountType.Eoa,
);
mockAccountProvider<SolAccountProvider>(
SolAccountProvider,
mocks.SolAccountProvider,
accounts,
1,
SOL_ACCOUNT_PROVIDER_NAME,
SolAccountType.DataAccount,
);
}
Expand Down Expand Up @@ -582,6 +579,102 @@
});
});

describe('createMultichainAccountGroups', () => {
it('creates multiple multichain account groups from 0 to maxGroupIndex', async () => {
const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(0)
.get();
const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(1)
.get();

const { service } = await setup({
accounts: [mockEvmAccount0, mockEvmAccount1],
});

const groups = await service.createMultichainAccountGroups({
entropySource: MOCK_HD_KEYRING_1.metadata.id,
maxGroupIndex: 1,
});

expect(groups).toHaveLength(2);
expect(groups[0]?.groupIndex).toBe(0);
expect(groups[1]?.groupIndex).toBe(1);
});

it('returns existing groups when they already exist (idempotent)', async () => {
const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(0)
.get();

const { service } = await setup({
accounts: [mockEvmAccount0],
});

// Create group 0 first.
await service.createMultichainAccountGroup({
entropySource: MOCK_HD_KEYRING_1.metadata.id,
groupIndex: 0,
});

// Now create 0 again via createMultichainAccountGroups.
const groups = await service.createMultichainAccountGroups({
entropySource: MOCK_HD_KEYRING_1.metadata.id,
maxGroupIndex: 0,
});

expect(groups).toHaveLength(1);
expect(groups[0]?.groupIndex).toBe(0);
});

it('emits multichainAccountGroupCreated events for newly created groups', async () => {
const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(0)
.withUuid()
.get();
const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(1)
.withUuid()
.get();
const mockEvmAccount2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(2)
.withUuid()
.get();

const { service, messenger, mocks } = await setup({
accounts: [mockEvmAccount0],
});

const mockMultichainAccountGroupCreated = jest.fn();
messenger.subscribe(
'MultichainAccountService:multichainAccountGroupCreated',
mockMultichainAccountGroupCreated,
);

mockCreateMaxAccountsOnce(mocks.EvmAccountProvider, [
// The `createMaxAccounts` method is idempotennt and must include
// existing accounts too.
[mockEvmAccount0],
// Those are the 2 new accounts to be created.
[mockEvmAccount1],
[mockEvmAccount2],
]);

await service.createMultichainAccountGroups({
entropySource: MOCK_HD_KEYRING_1.metadata.id,
maxGroupIndex: 2,
});

expect(mockMultichainAccountGroupCreated).toHaveBeenCalledTimes(2);
});
});

describe('alignWallets', () => {
it('aligns all multichain account wallets', async () => {
const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
Expand Down Expand Up @@ -741,6 +834,31 @@
expect(firstGroup.getAccounts()[0]).toStrictEqual(MOCK_HD_ACCOUNT_1);
});

it('creates a multichain account group with MultichainAccountService:createMultichainAccountGroups', async () => {
const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2];
const { messenger } = await setup({ accounts });

const groups = await messenger.call(
'MultichainAccountService:createMultichainAccountGroups',
{
entropySource: MOCK_HD_KEYRING_1.metadata.id,
maxGroupIndex: 1,
},
);

expect(groups).toHaveLength(2);

expect(groups[0]).not.toBeNull();
expect(groups[0]?.groupIndex).toBe(0);
expect(groups[0]?.getAccounts()).toHaveLength(1);
expect(groups[0]?.getAccounts()[0]).toStrictEqual(MOCK_HD_ACCOUNT_1);

expect(groups[1]).not.toBeNull();
expect(groups[1]?.groupIndex).toBe(0);
expect(groups[1]?.getAccounts()).toHaveLength(1);
expect(groups[1]?.getAccounts()[0]).toStrictEqual(MOCK_HD_ACCOUNT_2);
});

it('aligns a multichain account wallet with MultichainAccountService:alignWallet', async () => {
const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
Expand Down Expand Up @@ -1036,6 +1154,27 @@
expect(result).toStrictEqual([]);
});

it('returns empty array when createMaxAccounts() is disabled', async () => {
const options = {
entropySource: MOCK_HD_ACCOUNT_1.options.entropy.id,
maxGroupIndex: 0,
};

// Enable first - should work normally
(solProvider.createMaxAccounts as jest.Mock).mockResolvedValue([
[MOCK_HD_ACCOUNT_1],
]);
expect(await wrapper.createMaxAccounts(options)).toStrictEqual([
[MOCK_HD_ACCOUNT_1],
]);

// Disable - should return empty array and not call underlying provider
wrapper.setEnabled(false);

const result = await wrapper.createMaxAccounts(options);
expect(result).toStrictEqual([]);
});

it('returns empty array when discoverAccounts() is disabled', async () => {
const options = {
entropySource: MOCK_HD_ACCOUNT_1.options.entropy.id,
Expand Down
Loading
Loading