Skip to content

refactor(multichain-account-service)!: replace withKeyring usage with withKeyringV2#8491

Open
hmalik88 wants to merge 14 commits intomainfrom
hm/mul-1545
Open

refactor(multichain-account-service)!: replace withKeyring usage with withKeyringV2#8491
hmalik88 wants to merge 14 commits intomainfrom
hm/mul-1545

Conversation

@hmalik88
Copy link
Copy Markdown
Contributor

@hmalik88 hmalik88 commented Apr 16, 2026

Explanation

Changes

Base provider & types

  • Update AllowedActions to use KeyringControllerWithKeyringV2Action
  • BaseBip44AccountProvider.withKeyring now calls KeyringController:withKeyringV2 with KeyringSelectorV2

EvmAccountProvider

  • Callbacks use KeyringV2 methods: getAccounts() returns KeyringAccount[], createAccounts() replaces addAccounts()
  • Range creation loops with bip44:derive-index since HdKeyringV2 does not support bip44:derive-index-range
  • discoverAccounts replaces the V1 keyring.root peek with a create → check → delete-if-no-activity pattern, preserving the same net behavior of not leaving unused accounts in the
    HD keyring
  • Removed #getAddressFromGroupIndex (relied on V1 HD keyring internals)

SnapAccountProvider

  • Removed RestrictedSnapKeyring escape-hatch pattern that leaked keyring references outside the withKeyring mutex
  • withSnap now runs operations inside the withKeyringV2 scope
  • createBip44Accounts always uses keyring.createAccounts() directly, removing the V1/V2 branching based on batched config
  • resyncAccounts uses keyring.deleteAccount(id) instead of keyring.removeAccount(address)

Snap subclasses (BTC, SOL, TRX)

  • Removed createAccountV1 overrides — V2 snaps handle chain-specific account creation internally

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
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Touches account creation/discovery paths and keyring mutex/persistence boundaries; regressions could lead to missed discovery results or unintended account/keyring state changes, though changes are scoped to provider/keyring integration.

Overview
BREAKING: Migrates multichain account providers’ keyring access from KeyringController:withKeyring to KeyringController:withKeyringV2, updating shared types/messenger delegation and the base provider withKeyring helper to use KeyringSelectorV2.

Updates EvmAccountProvider to use V2 keyring methods (getAccounts() returning KeyringAccount[], createAccounts(), deleteAccount()), changes range creation to loop per-index (since V2 HD keyrings don’t support range creation), and rewrites discovery to do create→RPC activity check→delete-if-inactive within a single withKeyringV2 callback to ensure no net keyring state change when no activity is found.

Keeps Snap-based providers on V1 keyring behavior for now by overriding withKeyring to call KeyringController:withKeyring, while still accepting V2 selectors; tests are updated accordingly and the changelog documents the breaking API shift.

Reviewed by Cursor Bugbot for commit 15f7986. Bugbot is set up for automated code reviews on this repo. Configure here.

@hmalik88 hmalik88 changed the title refactor(multichain-account-service): replace withKeyring usage with withKeyringV2 refactor(multichain-account-service)!: replace withKeyring usage with withKeyringV2 Apr 16, 2026
@hmalik88 hmalik88 marked this pull request as ready for review April 16, 2026 20:45
@hmalik88 hmalik88 requested review from a team as code owners April 16, 2026 20:45
Comment thread packages/multichain-account-service/src/providers/EvmAccountProvider.ts Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e442d90. Configure here.

// Activity found — account stays. Return the address so we can
// look it up in the AccountsController after the callback persists.
return address;
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Discovery holds keyring mutex during network I/O

Medium Severity

The refactored discoverAccounts now performs the entire create → RPC check → delete flow inside a single withKeyringV2 callback. Since withKeyringV2 holds the KeyringController's exclusive mutex (#persistOrRollback#withControllerLock), the RPC call to #getTransactionCount — which retries up to maxAttempts times with timeoutMs and backOffMs delays — now blocks all other keyring operations (signing, account creation, switching, etc.) for potentially several seconds. The prior implementation performed the RPC call outside withKeyring, acquiring the mutex only briefly for the peek and create steps.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e442d90. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The prior implementation peeked at addresses via keyring.root (V1 internal state) outside the lock. V2 keyrings don't expose HD internals, so we create the account inside the callback to discover the address, and delete it if inactive. Both must happen in the same callback so that #persistOrRollback sees zero net state change, as a result this holds the lock during the rpc call


### Changed

- **BREAKING:** Replace `KeyringController:withKeyring` with `KeyringController:withKeyringV2` across all account providers ([#8491](https://github.com/MetaMask/core/pull/8491))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
- **BREAKING:** Replace `KeyringController:withKeyring` with `KeyringController:withKeyringV2` across all account providers ([#8491](https://github.com/MetaMask/core/pull/8491))
- **BREAKING:** Replace `KeyringController:withKeyring` with `KeyringController:withKeyringV2` for the EVM account provider ([#8491](https://github.com/MetaMask/core/pull/8491))


protected async withKeyring<SelectedKeyring, CallbackResult = void>(
selector: KeyringSelector,
selector: KeyringSelectorV2,
Copy link
Copy Markdown
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 should have introduced a withKeyringV2 here too? This way we can add a constraint with SelectedKeyring extends KeyringV2?

The old one would still remain and be used by the SnapAccountProvider for now until we have proper support for SnapKeyringV2.

WDYT?

Comment on lines 42 to +49
return {
...actual,
publicToAddress: jest.fn(),
};
});

function mockNextDiscoveryAddress(address: string): void {
jest.mocked(publicToAddress).mockReturnValue(createBytes(address as Hex));
id: account.id,
address: account.address,
type: account.type,
options: account.options,
methods: account.methods,
scopes: account.scopes ?? [],
} as unknown as KeyringAccount;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An alternative without casting would be to use:

  const {
    metadata, // `InternalAccount` is just a `KeyringAccount` + `metadata.
    ...keyringAccount;
  };
  
  return keyringAccount;

class MockEthKeyring implements EthKeyring {
readonly type = 'MockEthKeyring';
// Internal test-only state — not part of the Keyring interface.
readonly accounts: InternalAccount[];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Then we could just use KeyringAccount right away no?

const newAccountsIndex = this.accounts.length;
createAccounts = jest
.fn()
.mockImplementation((options: Record<string, unknown>) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can properly type this with CreateAccountOptions (from the keyring-api)

Comment on lines +564 to +566
// The account was created to peek at the address, then deleted
// because there was no on-chain activity.
expect(keyring.deleteAccount).toHaveBeenCalledTimes(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh, I completely forgot about this, and actually we might have to tweak this a bit...

We don't want to create an account + remove it after, this cause some weird glitch where a group would be created out of this account and be removed 1s after...

So maybe we need to re-export the same getters than the HdKeyring v1 to be able to derive the address ourselves for now 🤔 (or add a peekAddress inside the v2 wrapper for now.

NOTE: We might need to re-export root and other getters, cause I think we use them on the clients for the Snap platform 😅

WDYT?

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