Skip to content
Merged
6 changes: 6 additions & 0 deletions packages/multichain-account-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add per-provider throttling for non-EVM account creation to improve performance on low-end devices ([#7000](https://github.com/MetaMask/core/pull/7000))
- Solana provider is now limited to 3 concurrent account creations by default when creating multichain account groups.
- Other providers remain unthrottled by default.

## [2.0.1]

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ describe('MultichainAccountService', () => {
},
},
[SOL_ACCOUNT_PROVIDER_NAME]: {
maxConcurrency: 3,
discovery: {
timeoutMs: 5000,
maxAttempts: 4,
Expand All @@ -218,11 +219,11 @@ describe('MultichainAccountService', () => {

expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
providerConfigs[EvmAccountProvider.NAME],
providerConfigs?.[EvmAccountProvider.NAME],
);
expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
providerConfigs[SolAccountProvider.NAME],
providerConfigs?.[SolAccountProvider.NAME],
);
});

Expand All @@ -232,6 +233,7 @@ describe('MultichainAccountService', () => {
// NOTE: We use constants here, since `*AccountProvider` are mocked, thus, their `.NAME` will
// be `undefined`.
[SOL_ACCOUNT_PROVIDER_NAME]: {
maxConcurrency: 3,
discovery: {
timeoutMs: 5000,
maxAttempts: 4,
Expand All @@ -255,7 +257,7 @@ describe('MultichainAccountService', () => {
);
expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith(
messenger,
providerConfigs[SolAccountProvider.NAME],
providerConfigs?.[SolAccountProvider.NAME],
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ import { MultichainAccountWallet } from './MultichainAccountWallet';
import type {
EvmAccountProviderConfig,
NamedAccountProvider,
SolAccountProviderConfig,
} from './providers';
import {
AccountProviderWrapper,
isAccountProviderWrapper,
} from './providers/AccountProviderWrapper';
import { EvmAccountProvider } from './providers/EvmAccountProvider';
import { SolAccountProvider } from './providers/SolAccountProvider';
import {
SolAccountProvider,
type SolAccountProviderConfig,
} from './providers/SolAccountProvider';
import type { MultichainAccountServiceMessenger } from './types';

export const serviceName = 'MultichainAccountService';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,53 @@ describe('MultichainAccountWallet', () => {
'Unable to create multichain account group for index: 1',
);
});

it('aggregates non-EVM failures when waiting for all providers', async () => {
const startingIndex = 0;

const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withEntropySource(MOCK_HD_KEYRING_1.metadata.id)
.withGroupIndex(startingIndex)
.get();

const { wallet, providers } = setup({
providers: [
setupNamedAccountProvider({ accounts: [mockEvmAccount], index: 0 }),
setupNamedAccountProvider({
name: 'Non-EVM Provider',
accounts: [],
index: 1,
}),
],
});

const nextIndex = 1;
const nextEvmAccount = MockAccountBuilder.from(mockEvmAccount)
.withGroupIndex(nextIndex)
.get();

const [evmProvider, solProvider] = providers;
evmProvider.createAccounts.mockResolvedValueOnce([nextEvmAccount]);
evmProvider.getAccounts.mockReturnValueOnce([nextEvmAccount]);
evmProvider.getAccount.mockReturnValueOnce(nextEvmAccount);

const warnSpy = jest.spyOn(console, 'warn').mockImplementation();

const SOL_PROVIDER_ERROR = 'SOL create failed';
solProvider.createAccounts.mockRejectedValueOnce(
new Error(SOL_PROVIDER_ERROR),
);

await expect(
wallet.createMultichainAccountGroup(nextIndex, {
waitForAllProvidersToFinishCreatingAccounts: true,
}),
).rejects.toThrow(
`Unable to create multichain account group for index: ${nextIndex}:\n- Error: ${SOL_PROVIDER_ERROR}`,
);

expect(warnSpy).toHaveBeenCalled();
});
});

describe('createNextMultichainAccountGroup', () => {
Expand Down
151 changes: 90 additions & 61 deletions packages/multichain-account-service/src/MultichainAccountWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { MultichainAccountGroup } from './MultichainAccountGroup';
import { EvmAccountProvider, type NamedAccountProvider } from './providers';
import type { MultichainAccountServiceMessenger } from './types';
import { toRejectedErrorMessage } from './utils';

/**
* The context for a provider discovery.
Expand Down Expand Up @@ -220,6 +221,65 @@ export class MultichainAccountWallet<
}
}

/**
* Create accounts with non‑EVM providers. Optional throttling is managed by each provider internally.
* When awaitAll is true, waits for all providers and throws if any failed.
* When false, starts work in background and logs errors without throwing.
*
* @param options - Method options.
* @param options.groupIndex - The group index to create accounts for.
* @param options.providers - The non‑EVM account providers.
* @param options.awaitAll - Whether to wait for all providers to finish.
* @throws If awaitAll is true and any provider fails to create accounts.
* @returns A promise that resolves when done (if awaitAll is true) or immediately (if false).
*/
async #createNonEvmAccounts({
groupIndex,
providers,
awaitAll,
}: {
groupIndex: number;
providers: NamedAccountProvider<Account>[];
awaitAll: boolean;
}): Promise<void> {
if (awaitAll) {
const tasks = providers.map((provider) =>
provider.createAccounts({
entropySource: this.#entropySource,
groupIndex,
}),
);

const results = await Promise.allSettled(tasks);
if (results.some((r) => r.status === 'rejected')) {
const errorMessage = toRejectedErrorMessage(
`Unable to create multichain account group for index: ${groupIndex}`,
results,
);

this.#log(`${WARNING_PREFIX} ${errorMessage}`);
console.warn(errorMessage);
throw new Error(errorMessage);
}
return;
}

// Background mode: start tasks and log errors.
// Optional throttling is handled internally by each provider based on its config.
providers.forEach((provider) => {
// eslint-disable-next-line no-void
void provider
.createAccounts({
entropySource: this.#entropySource,
groupIndex,
})
.catch((error) => {
const errorMessage = `Unable to create multichain account group for index: ${groupIndex} (background mode with provider "${provider.getName()}")`;
this.#log(`${WARNING_PREFIX} ${errorMessage}:`, error);
});
});
}

/**
* Gets multichain account for a given ID.
* The default group ID will default to the multichain account with index 0.
Expand Down Expand Up @@ -335,70 +395,39 @@ export class MultichainAccountWallet<

this.#log(`Creating new group for index ${groupIndex}...`);

if (options?.waitForAllProvidersToFinishCreatingAccounts) {
// Create account with all providers and await them.
const results = await Promise.allSettled(
this.#providers.map((provider) =>
provider.createAccounts({
entropySource: this.#entropySource,
groupIndex,
}),
),
);
// Extract the EVM provider from the list of providers.
// We always await EVM account creation first.
const [evmProvider, ...otherProviders] = this.#providers;
assert(
evmProvider instanceof EvmAccountProvider,
'EVM account provider must be first',
);

// If any of the provider failed to create their accounts, then we consider the
// multichain account group to have failed too.
if (results.some((result) => result.status === 'rejected')) {
// NOTE: Some accounts might still have been created on other account providers. We
// don't rollback them.
const error = `Unable to create multichain account group for index: ${groupIndex}`;

let message = `${error}:`;
for (const result of results) {
if (result.status === 'rejected') {
message += `\n- ${result.reason}`;
}
}
this.#log(`${WARNING_PREFIX} ${message}`);
console.warn(message);
try {
await evmProvider.createAccounts({
entropySource: this.#entropySource,
groupIndex,
});
} catch (error) {
const errorMessage = `Unable to create multichain account group for index: ${groupIndex} with provider "${evmProvider.getName()}". Error: ${(error as Error).message}`;
this.#log(`${ERROR_PREFIX} ${errorMessage}:`, error);
throw new Error(errorMessage);
}

throw new Error(error);
}
// We then create accounts with other providers (some being throttled if configured).
// Depending on the options, we either await all providers or run them in background.
if (options?.waitForAllProvidersToFinishCreatingAccounts) {
await this.#createNonEvmAccounts({
groupIndex,
providers: otherProviders,
awaitAll: true,
});
} else {
// Extract the EVM provider from the list of providers.
// We will only await the EVM provider to create its accounts, while
// all other providers will be started in the background.
const [evmProvider, ...otherProviders] = this.#providers;
assert(
evmProvider instanceof EvmAccountProvider,
'EVM account provider must be first',
);

// Create account with the EVM provider first and await it.
// If it fails, we don't start creating accounts with other providers.
try {
await evmProvider.createAccounts({
entropySource: this.#entropySource,
groupIndex,
});
} catch (error) {
const errorMessage = `Unable to create multichain account group for index: ${groupIndex} with provider "${evmProvider.getName()}". Error: ${(error as Error).message}`;
this.#log(`${ERROR_PREFIX} ${errorMessage}:`, error);
throw new Error(errorMessage);
}

// Create account with other providers in the background
otherProviders.forEach((provider) => {
provider
.createAccounts({
entropySource: this.#entropySource,
groupIndex,
})
.catch((error) => {
// Log errors from background providers but don't fail the operation
const errorMessage = `Could not to create account with provider "${provider.getName()}" for multichain account group index: ${groupIndex}`;
this.#log(`${WARNING_PREFIX} ${errorMessage}:`, error);
});
// eslint-disable-next-line no-void
void this.#createNonEvmAccounts({
groupIndex,
providers: otherProviders,
awaitAll: false,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,14 @@ import type { SnapId } from '@metamask/snaps-sdk';
import { HandlerType } from '@metamask/snaps-utils';
import type { Json, JsonRpcRequest } from '@metamask/utils';

import { SnapAccountProvider } from './SnapAccountProvider';
import {
SnapAccountProvider,
type SnapAccountProviderConfig,
} from './SnapAccountProvider';
import { withRetry, withTimeout } from './utils';
import type { MultichainAccountServiceMessenger } from '../types';

export type BtcAccountProviderConfig = {
discovery: {
maxAttempts: number;
timeoutMs: number;
backOffMs: number;
};
createAccounts: {
timeoutMs: number;
};
};
export type BtcAccountProviderConfig = SnapAccountProviderConfig;

export const BTC_ACCOUNT_PROVIDER_NAME = 'Bitcoin' as const;

Expand All @@ -31,8 +25,6 @@ export class BtcAccountProvider extends SnapAccountProvider {

readonly #client: KeyringClient;

readonly #config: BtcAccountProviderConfig;

constructor(
messenger: MultichainAccountServiceMessenger,
config: BtcAccountProviderConfig = {
Expand All @@ -46,11 +38,10 @@ export class BtcAccountProvider extends SnapAccountProvider {
},
},
) {
super(BtcAccountProvider.BTC_SNAP_ID, messenger);
super(BtcAccountProvider.BTC_SNAP_ID, messenger, config);
this.#client = this.#getKeyringClientFromSnapId(
BtcAccountProvider.BTC_SNAP_ID,
);
this.#config = config;
}

getName(): string {
Expand Down Expand Up @@ -88,20 +79,22 @@ export class BtcAccountProvider extends SnapAccountProvider {
entropySource: EntropySourceId;
groupIndex: number;
}): Promise<Bip44Account<KeyringAccount>[]> {
const createAccount = await this.getRestrictedSnapAccountCreator();

const account = await withTimeout(
createAccount({
entropySource,
index,
addressType: BtcAccountType.P2wpkh,
scope: BtcScope.Mainnet,
}),
this.#config.createAccounts.timeoutMs,
);

assertIsBip44Account(account);
return [account];
return this.withMaxConcurrency(async () => {
const createAccount = await this.getRestrictedSnapAccountCreator();

const account = await withTimeout(
createAccount({
entropySource,
index,
addressType: BtcAccountType.P2wpkh,
scope: BtcScope.Mainnet,
}),
this.config.createAccounts.timeoutMs,
);

assertIsBip44Account(account);
return [account];
});
}

async discoverAccounts({
Expand All @@ -119,11 +112,11 @@ export class BtcAccountProvider extends SnapAccountProvider {
entropySource,
groupIndex,
),
this.#config.discovery.timeoutMs,
this.config.discovery.timeoutMs,
),
{
maxAttempts: this.#config.discovery.maxAttempts,
backOffMs: this.#config.discovery.backOffMs,
maxAttempts: this.config.discovery.maxAttempts,
backOffMs: this.config.discovery.backOffMs,
},
);

Expand Down
Loading
Loading