diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index d779736c75a..ada904f2bc5 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -74,6 +74,13 @@ export class MultichainAccountService { AccountContext> >; + readonly #pendingWalletSyncs: Set = new Set(); + + readonly #pendingGroupSyncs: Map> = + new Map(); + + #syncTimeout: ReturnType | undefined; + /** * The name of the service. */ @@ -297,7 +304,7 @@ export class MultichainAccountService { // This new account is a new multichain account, let the wallet know // it has to re-sync with its providers. if (sync) { - wallet.sync(); + this.#pendingWalletSyncs.add(wallet.id); } group = wallet.getMultichainAccountGroup( @@ -312,7 +319,12 @@ export class MultichainAccountService { // not able to find this multichain account (which should not be possible...) if (group) { if (sync) { - group.sync(); + let groupIndexes = this.#pendingGroupSyncs.get(wallet.id); + if (!groupIndexes) { + groupIndexes = new Set(); + this.#pendingGroupSyncs.set(wallet.id, groupIndexes); + } + groupIndexes.add(account.options.entropy.groupIndex); } // Same here, this account should have been already grouped in that @@ -322,6 +334,68 @@ export class MultichainAccountService { group, }); } + + this.#scheduleSync(); + } + + /** + * Schedules a flush of pending syncs. + */ + #scheduleSync(): void { + if (this.#syncTimeout) { + clearTimeout(this.#syncTimeout); + } + + this.#syncTimeout = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#flushSyncs(); + }, 50); // 50ms debounce + } + + /** + * Flushes all pending syncs. + */ + async #flushSyncs(): Promise { + const walletsToSync = new Set(this.#pendingWalletSyncs); + this.#pendingWalletSyncs.clear(); + + const groupsToSync = new Map(this.#pendingGroupSyncs); + this.#pendingGroupSyncs.clear(); + + // First sync wallets (this might create groups) + for (const walletId of walletsToSync) { + const wallet = this.#wallets.get(walletId); + if (wallet) { + log(`[Debounced] Syncing wallet: [${wallet.id}]`); + wallet.sync(); + } + } + + // Then sync specific groups + for (const [walletId, groupIndexes] of groupsToSync) { + // If we already synced the entire wallet, we might not need to sync groups individually + // depending on implementation. But usually wallet.sync() handles discovering new groups, + // not necessarily updating existing ones deep down? + // Actually MultichainAccountWallet.sync() iterates all providers and creates/updates groups. + // And then calls group.sync() on ALL groups. + // So if we synced the wallet, we don't need to sync groups. + if (walletsToSync.has(walletId)) { + continue; + } + + const wallet = this.#wallets.get(walletId); + if (wallet) { + for (const groupIndex of groupIndexes) { + const group = wallet.getMultichainAccountGroup(groupIndex); + if (group) { + log( + `[Debounced] Syncing group index ${groupIndex} for wallet: [${wallet.id}]`, + ); + group.sync(); + } + } + } + } } #handleOnAccountRemoved(id: KeyringAccount['id']): void { diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index 045138d51a9..16f13a3eeb9 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -585,10 +585,68 @@ export class MultichainAccountWallet< // from there). let maxGroupIndex = this.getNextGroupIndex(); + // Track which providers have been batch-processed to skip sequential discovery + const batchProcessedProviders = new Set>(); + + // OPTIMIZATION: Batch EVM account discovery to avoid N vault updates. + // First, find how many active accounts exist (read-only, no vault writes), + // then create them all in a single withKeyring call (one vault write). + const evmProvider = this.#providers.find( + (p) => p instanceof EvmAccountProvider, + ) as EvmAccountProvider | undefined; + + if (evmProvider) { + try { + this.#log( + `[EVM] Batch discovery: checking for active accounts starting at index ${maxGroupIndex}...`, + ); + + const activeCount = await evmProvider.findActiveAccountCount({ + entropySource: this.#entropySource, + startIndex: maxGroupIndex, + }); + + if (activeCount > 0) { + this.#log( + `[EVM] Found ${activeCount} active accounts. Creating in batch...`, + ); + await evmProvider.bulkCreateAccounts({ + entropySource: this.#entropySource, + count: maxGroupIndex + activeCount, + }); + maxGroupIndex += activeCount; + this.#log( + `[EVM] Batch creation complete. New maxGroupIndex: ${maxGroupIndex}`, + ); + } else { + this.#log(`[EVM] No new active accounts found.`); + } + + // Mark EVM as batch-processed so we skip sequential discovery for it + batchProcessedProviders.add( + evmProvider as unknown as Bip44AccountProvider, + ); + } catch (error) { + this.#log( + `${WARNING_PREFIX} [EVM] Batch discovery failed, falling back to sequential:`, + error, + ); + // Don't add to batchProcessedProviders - will fall through to sequential discovery + } + } + // One serialized loop per provider; all run concurrently const runProviderDiscovery = async ( context: AccountProviderDiscoveryContext, ) => { + // Skip providers that were already batch-processed + if (batchProcessedProviders.has(context.provider)) { + this.#log( + `[${context.provider.getName()}] Skipping sequential discovery (batch-processed)`, + ); + return; + } + const providerName = context.provider.getName(); const message = (stepName: string, groupIndex: number) => `[${providerName}] Discovery ${stepName} for group index: ${groupIndex}`; diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts index 7837ed0d387..d5e6f65bf75 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -129,6 +129,44 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { return result; } + /** + * Creates multiple accounts in a single keyring transaction. + * This is more efficient than calling createAccounts multiple times + * as it only triggers one vault update. + * + * @param opts - Options. + * @param opts.entropySource - The entropy source to use. + * @param opts.count - The number of accounts to create. + * @returns The addresses of the created accounts. + */ + async #createAccountsBatch({ + entropySource, + count, + }: { + entropySource: EntropySourceId; + count: number; + }): Promise { + if (count <= 0) { + return []; + } + + return await this.withKeyring( + { id: entropySource }, + async ({ keyring }) => { + const existingCount = (await keyring.getAccounts()).length; + const toCreate = count - existingCount; + + if (toCreate > 0) { + await keyring.addAccounts(toCreate); + } + + // Return all accounts up to the requested count + const allAccounts = await keyring.getAccounts(); + return allAccounts.slice(0, count); + }, + ); + } + async createAccounts({ entropySource, groupIndex, @@ -265,6 +303,70 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { ); } + /** + * Find the number of active accounts starting from a given index. + * This method is used to optimize the discovery process by batching the checks + * before creating the accounts. + * + * @param opts - Options. + * @param opts.entropySource - The entropy source to use. + * @param opts.startIndex - The index to start checking from. + * @returns The number of active accounts found. + */ + async findActiveAccountCount(opts: { + entropySource: EntropySourceId; + startIndex: number; + }): Promise { + const { entropySource, startIndex } = opts; + const provider = this.getEvmProvider(); + let currentIndex = startIndex; + let activeCount = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const address = await this.#getAddressFromGroupIndex({ + entropySource, + groupIndex: currentIndex, + }); + + const count = await this.#getTransactionCount(provider, address); + if (count === 0) { + break; + } + + activeCount += 1; + currentIndex += 1; + } + + return activeCount; + } + + /** + * Bulk create accounts for a given entropy source. + * + * @param opts - Options. + * @param opts.entropySource - The entropy source to use. + * @param opts.count - The number of accounts to create. + */ + async bulkCreateAccounts({ + entropySource, + count, + }: { + entropySource: EntropySourceId; + count: number; + }): Promise { + if (count === 0) { + return; + } + + await this.withKeyring( + { id: entropySource }, + async ({ keyring }) => { + await keyring.addAccounts(count); + }, + ); + } + async resyncAccounts(): Promise { // No-op for the EVM account provider, since keyring accounts are already on // the MetaMask side.