Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ export class MultichainAccountService {
AccountContext<Bip44Account<KeyringAccount>>
>;

readonly #pendingWalletSyncs: Set<MultichainAccountWalletId> = new Set();

readonly #pendingGroupSyncs: Map<MultichainAccountWalletId, Set<number>> =
new Map();

#syncTimeout: ReturnType<typeof setTimeout> | undefined;

/**
* The name of the service.
*/
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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<void> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bip44AccountProvider<Account>>();

// 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<Account>,
);
} 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<Account>,
) => {
// 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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hex[]> {
if (count <= 0) {
return [];
}

return await this.withKeyring<EthKeyring, Hex[]>(
{ 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,
Expand Down Expand Up @@ -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<number> {
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<void> {
if (count === 0) {
return;
}

await this.withKeyring<EthKeyring>(
{ id: entropySource },
async ({ keyring }) => {
await keyring.addAccounts(count);
},
);
}
Copy link

Choose a reason for hiding this comment

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

Bug: bulkCreateAccounts adds count instead of reaching target count

The bulkCreateAccounts method passes count directly to keyring.addAccounts(), but addAccounts(N) adds N new accounts rather than setting the total to N. When called with maxGroupIndex + activeCount (e.g., 5 when there are 2 existing accounts and 3 new ones found), this will add 5 accounts instead of the intended 3. The private #createAccountsBatch method handles this correctly by computing toCreate = count - existingCount, but bulkCreateAccounts doesn't use that logic.

Additional Locations (1)

Fix in Cursor Fix in Web


async resyncAccounts(): Promise<void> {
// No-op for the EVM account provider, since keyring accounts are already on
// the MetaMask side.
Expand Down