Skip to content

Conversation

@ccharly
Copy link
Contributor

@ccharly ccharly commented Dec 5, 2025

Explanation

Moving the add/remove account/address logic in the KeyringController. This way, we don't need to run a complex logic in the AccountsController everytime a new account gets added.

Part of this logic is now at the KeyringController-level yes, but the logic itself seems simpler, and hopefully, is faster too!

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

Adds KeyringController:accountAdded/accountRemoved events and updates AccountsController to use these events instead of KeyringController:stateChange, with corresponding tests and changelog updates.

  • Keyring Controller:
    • Add and emit KeyringController:accountAdded and KeyringController:accountRemoved events (via overridden update, persistAllKeyrings, and withKeyring).
    • Adjust internals (e.g., make #updateVault async) and add comprehensive event tests.
  • Accounts Controller:
    • Subscribe to KeyringController:account{Added,Removed}; implement #handleOnKeyringAccountAdded/#handleOnKeyringAccountRemoved to update internal accounts and publish AccountsController:account{Added,Removed}.
    • Remove reliance on KeyringController:stateChange; update event wiring and tests (including order guarantees for :accountAdded before :selectedAccountChange).
  • Changelogs:
    • Document new events and breaking change: AccountsController now requires KeyringController:account{Added,Removed} instead of :stateChange.

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

@ccharly ccharly force-pushed the feat/keyring-controller-account-added branch from f128679 to e8ec6c8 Compare December 5, 2025 19:01
@ccharly ccharly force-pushed the feat/keyring-controller-account-added branch from e8ec6c8 to 3fe4986 Compare December 8, 2025 13:56
@ccharly ccharly marked this pull request as ready for review December 12, 2025 11:46
@ccharly ccharly requested review from a team as code owners December 12, 2025 11:46
const updated = await updateVault();
const newKeyrings = this.state.keyrings;

const oldAccounts = toAccountsMap(oldKeyrings);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Kept it as a Map even for oldAccounts for simplicity. We're not sending the KeyringObject alongside :accountRemoved, maybe we should?

But then, should the removed account be part of keyring.accounts still? What about other removed accounts? Should we remove them all before sending the events?

For now, I'd just keep the original signature for :accountRemoved.

#handleOnKeyringAccountRemoved(address: string) {
const account = this.listMultichainAccounts().find(
({ address: accountAddress }) => accountAddress === address,
);
Copy link

Choose a reason for hiding this comment

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

Bug: Case-sensitive address comparison may fail to find accounts

The #handleOnKeyringAccountRemoved method uses strict equality (===) to compare addresses, while the existing getAccountByAddress method uses case-insensitive comparison with .toLowerCase() on both sides. Ethereum addresses can exist in different case formats (checksummed vs lowercase), so if there's any inconsistency in how addresses are stored versus how they're received from the KeyringController:accountRemoved event, the account lookup will fail silently and the account won't be removed from the state.

Fix in Cursor Fix in Web


// NOTE: Since we also track "updated" accounts with our patches, we could fire a new event
// like `accountUpdated` (we would still need to check if anything really changed on the account).
}
Copy link

Choose a reason for hiding this comment

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

Bug: Event ordering inconsistency when removing selected account

The #handleOnKeyringAccountRemoved method publishes accountRemoved event after calling #update, while the analogous #handleOnKeyringAccountAdded method publishes accountAdded inside the beforeAutoSelectAccount callback of #update. This asymmetry causes an event ordering bug: when the removed account is the currently selected one, #update triggers auto-selection and publishes selectedAccountChange before accountRemoved gets published. This reverses the expected event order. The PR includes a test ("fires :accountAdded before :selectedAccountChange") that validates the correct ordering for added accounts, but the same ordering guarantee isn't maintained for removed accounts. Moving messenger.publish('AccountsController:accountRemoved', ...) into a beforeAutoSelectAccount callback would fix this inconsistency.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's true for the asymmetry, but IMO this makes more sense to call :selectedAccountChange before :accountRemoved:

  • We change the account cause it will get removed
  • We remove the account

This way, we don't have any "dangling" account being wrongly selected (from a consumer point of view)

#handleOnKeyringAccountRemoved(address: string): void {
const account = this.listMultichainAccounts().find(
({ address: accountAddress }) => accountAddress === address,
);
Copy link

Choose a reason for hiding this comment

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

Bug: Address comparison uses strict equality instead of case-insensitive

The #handleOnKeyringAccountRemoved method uses strict equality (===) to compare addresses, but the codebase convention (seen in getAccountByAddress and utils.ts) uses case-insensitive comparison with .toLowerCase(). Ethereum addresses can be checksummed (mixed case) or lowercase, so if the KeyringController emits an address with different casing than what's stored in AccountsController, the account won't be found and won't be removed, leaving orphaned accounts in state.

Fix in Cursor Fix in Web

this.messenger.subscribe(
'KeyringController:accountAdded',
(address, keyring) => this.#handleOnKeyringAccountAdded(address, keyring),
);
Copy link

Choose a reason for hiding this comment

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

Bug: Wallet lock removes accounts and loses custom names

The old #handleOnKeyringStateChange method had a guard if (!isUnlocked || keyrings.length === 0) { return; } that prevented account state changes when the wallet was locked. The new event-based handlers lack this guard. When setLocked() is called, it sets state.keyrings = [], which triggers accountRemoved events for all accounts via the update override. The AccountsController processes these events and removes all accounts. On unlock, accounts are re-added with auto-generated names via getNextAvailableAccountName, causing loss of any custom account names the user had set.

Additional Locations (1)

Fix in Cursor Fix in Web

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