Skip to content

Conversation

hmalik88
Copy link
Contributor

@hmalik88 hmalik88 commented Sep 18, 2025

Explanation

MultichainAccountService

  • Unified wallet creation into one flow (createMultichainAccountWallet) that handles import/restore/new vault.
  • Builds a single ServiceState index in one pass and passes state slices to wallets/groups (cuts repeated controller scans/calls).
  • Simplified init path and removed dead accountIdToContext mapping.

MultichainAccountWallet

  • init now consumes a pre-sliced wallet state (entropySource → groups → providerName → ids) instead of querying providers.
  • Emits clear events on group creation/updates; alignment orchestration uses provider state instead of full scans.

MultichainAccountGroup

  • init registers account IDs per provider and fills reverse maps; calls provider.addAccounts(ids) to keep providers in sync.
  • Added getAccountIds() for direct access to underlying IDs.
  • Improved partial‑failure reporting (aggregates provider errors by name).

BaseBip44AccountProvider

  • Added addAccounts(ids: string[]), enabling providers to track their own account ID lists.
  • getAccounts() paths rely on known IDs (plural lookups) rather than scanning the full controller list.

EvmAccountProvider

  • Switched from address‑based scans to ID‑based fetches (getAccount(s)) for create/discover (removes $O(n)$ scans).

Performance Analysis

n = total BIP-44 accounts in the AccountsController
p = number of providers (currently 4)
w = number of wallets (entropy sources)
g = total number of groups
e = number of created EVM accounts

When fully aligned $g = n / p$.
When accounts are not fully aligned then $g = max(f(p))$, where $f(p)$ is the number of accounts associated with a provider.

Consider two scenarios:

  1. State 1 -> State 2 transition, the user has unaligned groups after the transition.
  2. Already transitioned to State 2, the service is initialized after onboarding and every time the client is unlocked.

General formulas

For Scenario 2, the formulas are as follows:

Before this refactor, the number of loops can be represented $n * p * (1 + w + g)$, which with $p = 4$, becomes $n^2 + 4n(1 + w)$.

Before this refactor, the number of controller calls can be represented as $1 + w + g$, which with $p = 4$, becomes $1 + w + n/4$.

After this refactor, the number of loops can be represented by $n * p$, which with $p = 4$, becomes $4n$.

After this refactor, the number of calls is just $1$.

For Scenario 1, the formulas are entirely dependent on the breakdown of the number of accounts each provider has amongst the $n$ accounts, let's consider a scenario where Solana has $n/2$, Ethereum has $n/8$, Bitcoin has $n/4$ and Tron has $n/8$, the formulas would be as follows:

Before this refactor, the number of loops in the alignment process can be represented as $(p * g) + (n * e)$, which with $p=4$ and $g = n/2$, becomes $2n + 3n^2/8$. Therefore the number of loops for initialization + alignment in this scenario with $p = 4$ and $g = n/2$, becomes $(19/8)n^2 + (4w + 6)n$.

Before this refactor, the number of controller calls in the alignment process can be represented as $e$, which becomes $3n/8$. Therefore the number of controller calls for initialization + alignment in this scenario with $p = 4$, becomes $1 + w + 5n/8$.

After this refactor, the number of loops in the alignment process can be represented as $p * g$, which becomes $2n$. Therefore, the number of loops for initialization + alignment in this scenario with $p = 4$ and $g = n/2$, becomes $6n$.

After this refactor, the number of controller calls in the alignment process can be represented as $e$ which becomes $3n/8$. Therefore, the number of controller calls for initialization + alignment in this scenario with $p = 4$ and $g = n/2$, becomes $1 + 3n/8$.

In short, previous init performance for loops and controller calls was quadratic and linear, respectively. After, it is linear and constant.

Performance Charts

Below are charts that show performance (loops and controller calls) $n = 0$ -> $n = 256$ for Scenario 1 and 2 with $w = 2$, respectively:

MisalignedLoops MisalignedCalls AlignedLoops AlignedCalls

References

N/A

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

Note

Major refactor to make multichain account management state-driven with a unified wallet creation flow, plus new AccountsController and KeyringController actions to support efficient lookups and vault operations.

  • Multichain Account Service:
    • Introduce state-driven init via ServiceState; wallets/groups now use init(...) with pre-sliced state.
    • Unify wallet creation in createMultichainAccountWallet supporting import/create/restore flows; validates params and uses new keyring actions.
    • Remove reverse-mapping/sync logic; rebuild from state; improve alignment/discovery and partial-failure aggregation.
  • Wallet (MultichainAccountWallet):
    • Add init(walletState); create groups from state; background provider creation; clearer warnings; discovery builds groups and updates state.
  • Group (MultichainAccountGroup):
    • Add init(groupState), getAccountIds(); register provider IDs; skip disabled providers; aggregate errors with provider names.
  • Providers:
    • BaseBip44AccountProvider: track account IDs (addAccounts, removeAccountsFromList); getAccounts uses AccountsController:getAccounts.
    • EvmAccountProvider: derive deterministic account ID; fetch via getAccount; keep retry/timeout discovery.
    • AccountProviderWrapper: add isDisabled helper.
  • Controllers:
    • AccountsController: add getAccounts(ids) action/handler (+ tests/exports).
    • KeyringController: add actions createNewVaultAndKeychain and createNewVaultAndRestore (+ handlers); expose for service flows.
  • Infra/Tests:
    • Update messengers’ allowed actions; broaden tests across service/wallet/group/providers.
  • Changelogs:
    • Document breaking performance refactor and new actions across affected packages.

Written by Cursor Bugbot for commit 98db5d3. This will update automatically on new commits. Configure here.

accountsList,
);
// we cast here because we know that the accounts are BIP-44 compatible
return internalAccounts as Bip44Account<KeyringAccount>[];
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Although the getAccounts's return type is (InternalAccount | undefined)[], we're sure to get back all the accounts we want since the accounts list will never be stale.

MultichainAccountWallet<Bip44Account<KeyringAccount>>
>;

readonly #accountIdToContext: Map<
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Decided to get rid of this mapping since it was only being used for handling the accountRemoved and accountAdded events, removing this gets rid of a large loop in the init function as well. If there's a particular need for this data at the client level, we can always add this back in.

@hmalik88 hmalik88 changed the title refactor: MultichainAccountService, MultichainAccountWallet, MultichainAccountGroup performance and DevX improvements refactor(multichain-account-service): Improved performance across package classes and improved error messages Sep 28, 2025
@hmalik88 hmalik88 marked this pull request as ready for review September 28, 2025 02:29
@hmalik88 hmalik88 requested review from a team as code owners September 28, 2025 02:29
cursor[bot]

This comment was marked as outdated.

Comment on lines +86 to +87
// Add the accounts to the provider's internal list of account IDs
provider.addAccounts(accountIds);
Copy link
Contributor

Choose a reason for hiding this comment

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

See my other comment about:

Given the providers are the sources of accounts...

*
* @param accounts - The accounts to add.
*/
addAccounts(accounts: Bip44Account<KeyringAccount>['id'][]): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

Given the providers are the sources of accounts, I don't think they should have an addAccounts method? 🤔

They should be able to hold a list of known accounts once they are initialized? And since we're sharing the same providers instances across the service/wallets/groups, this initialization should happen only once. Further updates to the internal account list happens when a createAccounts is called on that providers.

WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is to more so maintain a global list of the ids and reduce the number of calls/work we would have to make to other controllers. I wanted it this way because we were just grabbing the entire list of accounts from the AccountsController every time we called the getAccount(s) in the group and provider classes, so maintaining a global list that the provider can fetch from the new AccountsController:getAccounts method made sense to me.

Comment on lines +244 to +249
const wallet = new MultichainAccountWallet({
entropySource,
providers: this.#providers,
messenger: this.#messenger,
});
wallet.init(serviceState[entropySource]);
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we could omit the init part?

I really like that idea of "passing" a in-memory-state to the wallet/groups, because indeed, they are just "wrapper" around methods for their own domain (wallet domain or group domain).

Having the service being the owner of the entire memory layout and passing "views" on each "wallet/group state" would make sense to me.

And I think we could have a similar pattern on the providers too, so the providers can update the state directly, and since every components would share the same "data views", they would get updated automatically too.

Just need to double-check for concurrent accesses if we start sharing the same spaces though 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm I'd prefer to keep this pattern, it feels less complicated and more explicit about the intended action to the wallet/group state. I think we would have to end up changing the provider interface as well and would essentially be moving computation here to the provider. We're still reacting to the provider's actions by initing with additional state at the points of creation.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants