Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
aa318fe
feat: have group align method print all provider errors
hmalik88 Sep 14, 2025
17e855f
Merge branch 'main' into hm/bip-44-perf-and-devx-improvements
hmalik88 Sep 15, 2025
924a679
Merge branch 'main' into hm/bip-44-perf-and-devx-improvements
hmalik88 Sep 17, 2025
be8b2f7
Merge branch 'main' into hm/bip-44-perf-and-devx-improvements
hmalik88 Sep 17, 2025
b49f7b4
feat: construct service state at the top level and pass state slices …
hmalik88 Sep 18, 2025
a261e1e
Merge branch 'main' into hm/bip-44-perf-and-devx-improvements
hmalik88 Sep 18, 2025
6104131
chore: readd code removed by mistake
hmalik88 Sep 18, 2025
c45f612
feat: add getAccounts method to AccountsController
hmalik88 Sep 18, 2025
acf36e0
chore: update types to include getAccounts action
hmalik88 Sep 18, 2025
ce3cef4
refactor: derive account ID and use that to do a getAccounts call ins…
hmalik88 Sep 18, 2025
891fa50
feat: finish refactor
hmalik88 Sep 18, 2025
83060d6
Merge remote-tracking branch 'origin/main' into hm/bip-44-perf-and-de…
hmalik88 Sep 18, 2025
01096bf
chore: add JSDoc for wallet init
hmalik88 Sep 18, 2025
4c2e582
chore: remove accountId to context mapping since with the removal of …
hmalik88 Sep 19, 2025
43972f8
Merge remote-tracking branch 'origin/main' into hm/bip-44-perf-and-de…
hmalik88 Sep 19, 2025
7cec854
feat: start to add logic that will let createMultichainAccountWallet …
hmalik88 Sep 22, 2025
f05e6da
feat: remove need for mnemonicAsBytes
hmalik88 Sep 22, 2025
e325c77
Merge branch 'main' into hm/bip-44-perf-and-devx-improvements
hmalik88 Sep 26, 2025
6c5b86e
feat: update creatMultichainAccountWallet method to cover all entry p…
hmalik88 Sep 26, 2025
7d805ef
chore: update JSDoc
hmalik88 Sep 26, 2025
6b6e776
chore: update log messages
hmalik88 Sep 26, 2025
33e5e34
chore: add more JSDocs
hmalik88 Sep 26, 2025
f4d79c0
test: update multichain service tests
hmalik88 Sep 26, 2025
350aa93
fix: address type errors, remove try/catch block to not swallow error…
hmalik88 Sep 26, 2025
ce322e0
fix: lint fixes
hmalik88 Sep 26, 2025
31a6d71
chore: remove commented test
hmalik88 Sep 26, 2025
4c485a6
refactor: make changes to messenger and providers to make the compati…
hmalik88 Sep 26, 2025
c82de17
refactor: simplify init and properly filter for rejected already alig…
hmalik88 Sep 26, 2025
7d6e76a
fix: properly initialize group state
hmalik88 Sep 26, 2025
2d55dbd
test: update multichain account group tests
hmalik88 Sep 26, 2025
b8a87ff
test: update multichain account wallet tests
hmalik88 Sep 26, 2025
92d1c3a
test: update provider tests so that they are compatible with the new …
hmalik88 Sep 26, 2025
8dedd1a
Merge remote-tracking branch 'origin/main' into hm/bip-44-perf-and-de…
hmalik88 Sep 26, 2025
11cc131
test: update btc and trx tests to account for new logic
hmalik88 Sep 26, 2025
d28a324
chore: update changelogs
hmalik88 Sep 27, 2025
f308e9f
fix: lint fixes
hmalik88 Sep 27, 2025
8bc1237
test: add test for getAccounts
hmalik88 Sep 27, 2025
19cfdf3
feat: register getAccounts message handler
hmalik88 Sep 27, 2025
82fece8
chore: update JSDoc
hmalik88 Sep 27, 2025
c812a82
refactor: use Object.entries in wallet init
hmalik88 Sep 27, 2025
91aa52e
refactor: simplified error logic and include provider names in error …
hmalik88 Sep 27, 2025
4b19ed5
test: have account group creation test check actual failure message
hmalik88 Sep 27, 2025
5bcaa53
Merge remote-tracking branch 'origin/main' into hm/bip-44-perf-and-de…
hmalik88 Oct 9, 2025
c12435a
test: update tests
hmalik88 Oct 9, 2025
e9ab58d
chore: remove old comment
hmalik88 Oct 9, 2025
521a71b
chore: update changelog
hmalik88 Oct 9, 2025
1162348
Merge remote-tracking branch 'origin/main' into hm/bip-44-perf-and-de…
hmalik88 Oct 13, 2025
31f0193
feat: add code to remove disabled provider state from groups
hmalik88 Oct 13, 2025
7128b55
test: add tests for disabled provider
hmalik88 Oct 13, 2025
0c9324c
fix: update param passed to clean disabled provider state function
hmalik88 Oct 13, 2025
6b15c2d
fix: update logic and tests
hmalik88 Oct 13, 2025
0ec9181
Merge remote-tracking branch 'origin/main' into hm/bip-44-perf-and-de…
hmalik88 Oct 13, 2025
98db5d3
fix: lint fix
hmalik88 Oct 13, 2025
98d6373
refactor: rename function and add JSDoc
hmalik88 Oct 13, 2025
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
16 changes: 16 additions & 0 deletions packages/accounts-controller/src/AccountsController.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably also split this from this PR. For similar reasons (see my other comment about packages/keyring-controller/src/KeyringController.ts file)

Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export type AccountsControllerGetAccountAction = {
handler: AccountsController['getAccount'];
};

export type AccountsControllerGetAccountsAction = {
type: `${typeof controllerName}:getAccounts`;
handler: AccountsController['getAccounts'];
};

export type AccountsControllerUpdateAccountMetadataAction = {
type: `${typeof controllerName}:updateAccountMetadata`;
handler: AccountsController['updateAccountMetadata'];
Expand All @@ -145,6 +150,7 @@ export type AccountsControllerActions =
| AccountsControllerGetSelectedAccountAction
| AccountsControllerGetNextAvailableAccountNameAction
| AccountsControllerGetAccountAction
| AccountsControllerGetAccountsAction
| AccountsControllerGetSelectedMultichainAccountAction
| AccountsControllerUpdateAccountMetadataAction;

Expand Down Expand Up @@ -303,6 +309,16 @@ export class AccountsController extends BaseController<
return this.state.internalAccounts.accounts[accountId];
}

/**
* Returns the internal account objects for the given account IDs, if they exist.
*
* @param accountIds - The IDs of the accounts to retrieve.
* @returns The internal account objects, or undefined if the account(s) do not exist.
*/
getAccounts(accountIds: string[]): (InternalAccount | undefined)[] {
return accountIds.map((accountId) => this.getAccount(accountId));
}

/**
* Returns an array of all evm internal accounts.
*
Expand Down
1 change: 1 addition & 0 deletions packages/accounts-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type {
AccountsControllerGetAccountByAddressAction,
AccountsControllerGetNextAvailableAccountNameAction,
AccountsControllerGetAccountAction,
AccountsControllerGetAccountsAction,
AccountsControllerUpdateAccountMetadataAction,
AllowedActions,
AccountsControllerActions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ describe('MultichainAccount', () => {
expect(providers[1].createAccounts).not.toHaveBeenCalled();
});

it('warns if provider alignment fails', async () => {
it('warns if alignment fails for a single provider', async () => {
const groupIndex = 0;
const { group, providers, wallet } = setup({
groupIndex,
Expand All @@ -208,7 +208,7 @@ describe('MultichainAccount', () => {

const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
providers[1].createAccounts.mockRejectedValueOnce(
new Error('Unable to create accounts'),
new Error('Provider 2: Unable to create accounts'),
);

await group.alignAccounts();
Expand All @@ -219,7 +219,39 @@ describe('MultichainAccount', () => {
groupIndex,
});
expect(consoleSpy).toHaveBeenCalledWith(
`Failed to fully align multichain account group for entropy ID: ${wallet.entropySource} and group index: ${groupIndex}, some accounts might be missing`,
`Failed to fully align multichain account group for entropy ID: ${wallet.entropySource} and group index: ${groupIndex}, some accounts might be missing. Provider threw the following error:\n- Error: Provider 2: Unable to create accounts`,
);
});

it('warns if alignment fails for multiple providers', async () => {
const groupIndex = 0;
const { group, providers, wallet } = setup({
groupIndex,
accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [], []],
});

const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
providers[1].createAccounts.mockRejectedValueOnce(
new Error('Provider 2: Unable to create accounts'),
);

providers[2].createAccounts.mockRejectedValueOnce(
new Error('Provider 3: Unable to create accounts'),
)

await group.align();

expect(providers[0].createAccounts).not.toHaveBeenCalled();
expect(providers[1].createAccounts).toHaveBeenCalledWith({
entropySource: wallet.entropySource,
groupIndex,
});
expect(providers[2].createAccounts).toHaveBeenCalledWith({
entropySource: wallet.entropySource,
groupIndex,
});
expect(consoleSpy).toHaveBeenCalledWith(
`Failed to fully align multichain account group for entropy ID: ${wallet.entropySource} and group index: ${groupIndex}, some accounts might be missing. Providers threw the following errors:\n- Error: Provider 2: Unable to create accounts\n- Error: Provider 3: Unable to create accounts`,
);
});
});
Expand Down
110 changes: 66 additions & 44 deletions packages/multichain-account-service/src/MultichainAccountGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import type { Bip44Account } from '@metamask/account-api';
import type { AccountSelector } from '@metamask/account-api';
import { type KeyringAccount } from '@metamask/keyring-api';

import type { ServiceState, StateKeys } from './MultichainAccountService';
import type { MultichainAccountWallet } from './MultichainAccountWallet';
import type { NamedAccountProvider } from './providers';
import type { BaseBip44AccountProvider } from './providers';
import type { MultichainAccountServiceMessenger } from './types';

export type GroupState =
ServiceState[StateKeys['entropySource']][StateKeys['groupIndex']];

/**
* A multichain account group that holds multiple accounts.
*/
Expand All @@ -25,21 +29,14 @@ export class MultichainAccountGroup<

readonly #groupIndex: number;

readonly #providers: NamedAccountProvider<Account>[];
readonly #providers: BaseBip44AccountProvider[];
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO we should re-introduce the generic parameter for those providers. It was meant to be "extensible" and would align it with the Account type parameter being used by the top-level class here


readonly #providerToAccounts: Map<
NamedAccountProvider<Account>,
Account['id'][]
>;
readonly #providerToAccounts: Map<BaseBip44AccountProvider, Account['id'][]>;

readonly #accountToProvider: Map<
Account['id'],
NamedAccountProvider<Account>
>;
readonly #accountToProvider: Map<Account['id'], BaseBip44AccountProvider>;

readonly #messenger: MultichainAccountServiceMessenger;

// eslint-disable-next-line @typescript-eslint/prefer-readonly
#initialized = false;

constructor({
Expand All @@ -50,7 +47,7 @@ export class MultichainAccountGroup<
}: {
groupIndex: number;
wallet: MultichainAccountWallet<Account>;
providers: NamedAccountProvider<Account>[];
providers: BaseBip44AccountProvider[];
messenger: MultichainAccountServiceMessenger;
}) {
this.#id = toMultichainAccountGroupId(wallet.id, groupIndex);
Expand All @@ -60,44 +57,37 @@ export class MultichainAccountGroup<
this.#messenger = messenger;
this.#providerToAccounts = new Map();
this.#accountToProvider = new Map();

this.sync();
this.#initialized = true;
}

/**
* Force multichain account synchronization.
* Initialize the multichain account group and construct the internal representation of accounts.
*
* Note: This method can be called multiple times to update the group state.
*
* This can be used if account providers got new accounts that the multichain
* account doesn't know about.
* @param groupState - The group state.
*/
sync(): void {
// Clear reverse mapping and re-construct it entirely based on the refreshed
// list of accounts from each providers.
this.#accountToProvider.clear();

init(groupState: GroupState) {
for (const provider of this.#providers) {
// Filter account only for that index.
const accounts = [];
for (const account of provider.getAccounts()) {
if (
account.options.entropy.id === this.wallet.entropySource &&
account.options.entropy.groupIndex === this.groupIndex
) {
// We only use IDs to always fetch the latest version of accounts.
accounts.push(account.id);
}
}
this.#providerToAccounts.set(provider, accounts);
const accountIds = groupState[provider.getName()];

// Reverse-mapping for fast indexing.
for (const id of accounts) {
this.#accountToProvider.set(id, provider);
if (accountIds) {
for (const accountId of accountIds) {
this.#accountToProvider.set(accountId, provider);
}
const providerAccounts = this.#providerToAccounts.get(provider);
if (!providerAccounts) {
this.#providerToAccounts.set(provider, accountIds);
} else {
providerAccounts.push(...accountIds);
}
// Add the accounts to the provider's internal list of account IDs
provider.addAccounts(accountIds);
}
}

// Emit update event when group is synced (only if initialized)
if (this.#initialized) {
if (!this.#initialized) {
this.#initialized = true;
} else {
this.#messenger.publish(
'MultichainAccountService:multichainAccountGroupUpdated',
this,
Expand Down Expand Up @@ -167,14 +157,24 @@ export class MultichainAccountGroup<
// If for some reason we cannot get this account from the provider, it
// might means it has been deleted or something, so we just filter it
// out.
allAccounts.push(account);
// We cast here because TS cannot infer the type of the account from the provider
allAccounts.push(account as Account);
}
}
}

return allAccounts;
}

/**
* Gets the account IDs for this multichain account.
*
* @returns The account IDs.
*/
getAccountIds(): Account['id'][] {
return [...this.#providerToAccounts.values()].flat();
}

/**
* Gets the account for a given account ID.
*
Expand All @@ -189,7 +189,9 @@ export class MultichainAccountGroup<
if (!provider) {
return undefined;
}
return provider.getAccount(id);

// We cast here because TS cannot infer the type of the account from the provider
return provider.getAccount(id) as Account;
Copy link
Contributor

Choose a reason for hiding this comment

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

We need the cast because the providers are not using the Account type parameter. This cast should be gone if we re-add it I guess

}

/**
Expand Down Expand Up @@ -228,13 +230,33 @@ export class MultichainAccountGroup<
groupIndex: this.groupIndex,
});
}
return Promise.resolve();
return Promise.reject(new Error('Already aligned'));
Copy link
Contributor

Choose a reason for hiding this comment

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

I would not use an error message in that case. Maybe we can re-use what you did in the EvmAccountProvider.#createAccount last time, like returning a boolean to indicate if we created accounts or not?

Like const [didCreate, accounts] = .... WDYT?

}),
);

const groupState = results.reduce<GroupState>((state, result, idx) => {
if (result.status === 'fulfilled') {
state[this.#providers[idx].getName()] = result.value.map(
(account) => account.id,
);
}
return state;
}, {});

// Update group state
this.init(groupState);

if (results.some((result) => result.status === 'rejected')) {
const rejectedResults = results.filter(
(result) =>
result.status === 'rejected' && result.reason !== 'Already aligned',
) as PromiseRejectedResult[];
const errors = rejectedResults
.map((result) => `- ${result.reason}`)
.join('\n');
const hasMultipleFailures = rejectedResults.length > 1;
console.warn(
`Failed to fully align multichain account group for entropy ID: ${this.wallet.entropySource} and group index: ${this.groupIndex}, some accounts might be missing`,
`Failed to fully align multichain account group for entropy ID: ${this.wallet.entropySource} and group index: ${this.groupIndex}, some accounts might be missing. ${hasMultipleFailures ? 'Providers' : 'Provider'} threw the following ${hasMultipleFailures ? 'errors' : 'error'}:\n${errors}`,
);
}
}
Expand Down
Loading
Loading