Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
d7a3248
fix(AccountTreeController): Fix critical multi-wallet naming bug
fabiobozzo Sep 15, 2025
4fb2af9
docs: Update changelog with critical multi-wallet naming fix
fabiobozzo Sep 15, 2025
b64f14c
fix(ESLint): Fix prefer-destructuring and jest/no-conditional-in-test…
fabiobozzo Sep 15, 2025
b38ffe6
fix: Restore proper imports for MultichainAccountWalletStatus
fabiobozzo Sep 15, 2025
081c4f0
fix: Correct per-wallet numbering and preserve EVM account names
fabiobozzo Sep 15, 2025
927a774
fix: Simplify account naming fix to prevent extension crashes
fabiobozzo Sep 15, 2025
acd49d8
fix(lint): Fix ESLint formatting and import issues
fabiobozzo Sep 15, 2025
f495812
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 15, 2025
90c4056
docs: Add PR link to changelog entry for #6601
fabiobozzo Sep 15, 2025
87a65ea
fix(coverage): Add istanbul ignore for defensive fallback code
fabiobozzo Sep 15, 2025
d26532d
fix(changelog): Reorder sections to follow Keep a Changelog format
fabiobozzo Sep 15, 2025
071a074
Update packages/account-tree-controller/CHANGELOG.md
fabiobozzo Sep 15, 2025
dca6f2d
Update packages/account-tree-controller/src/AccountTreeController.ts
fabiobozzo Sep 15, 2025
1c7c0df
address pr remarks
fabiobozzo Sep 15, 2025
e2a474b
address pr remarks
fabiobozzo Sep 15, 2025
a512217
add scenario based on pr suggestion
fabiobozzo Sep 15, 2025
0ef2ffa
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 15, 2025
b87494a
refactor: address charly comments on scalability and conflict resolution
fabiobozzo Sep 15, 2025
7d4e7dc
fix lint errors in test
fabiobozzo Sep 15, 2025
66fd0a9
refactor: optimize account naming logic to reduce conflict checks
fabiobozzo Sep 16, 2025
c2fe321
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 16, 2025
e75b4e6
adjusted the starting point for account group naming in the implement…
fabiobozzo Sep 16, 2025
a4a2709
feat: implement automatic conflict resolution for account group naming
fabiobozzo Sep 16, 2025
3efc670
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 16, 2025
9ece24c
test: add unit test for autoHandleConflict functionality in account g…
fabiobozzo Sep 17, 2025
70554ef
refactor: update name conflict resolution logic in AccountTreeController
fabiobozzo Sep 17, 2025
661b091
refactor: address pr remarks
fabiobozzo Sep 17, 2025
d3cb731
test: remove obsolete test for group ID error handling in AccountTree…
fabiobozzo Sep 17, 2025
9873b6e
test: achieve 100% test coverage with istanbul ignore for edge cases
fabiobozzo Sep 17, 2025
87573cd
Update packages/account-tree-controller/CHANGELOG.md
fabiobozzo Sep 18, 2025
44600fd
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 18, 2025
7e6f701
refactor: address latest PR feedback from Charly
fabiobozzo Sep 18, 2025
4aa81d4
fix: implement +1 logic for highest account name index
fabiobozzo Sep 18, 2025
04239c5
refactor: use assert from @metamask/utils for invariant checking
fabiobozzo Sep 18, 2025
39557d3
refactor: clean up AccountTreeController test file
fabiobozzo Sep 18, 2025
156becd
docs: update changelog for account tree controller improvements
fabiobozzo Sep 18, 2025
5935c76
test: update expectations for new +1 naming logic
fabiobozzo Sep 18, 2025
986114e
tiny comment change
fabiobozzo Sep 18, 2025
e47a278
refactor: use passed state parameter in applyAccountGroupMetadata
fabiobozzo Sep 18, 2025
ddb42e1
test: fix remaining test expectations for new naming behavior
fabiobozzo Sep 18, 2025
9d37632
fix: revert problematic +1 logic to restore proper sequential naming
fabiobozzo Sep 18, 2025
6a7184b
fix: remove duplicate changelog entries for released version
fabiobozzo Sep 18, 2025
47b4e4a
fix changelog
fabiobozzo Sep 18, 2025
ede8d24
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 18, 2025
34d02af
fix: correct changelog section ordering for validation
fabiobozzo Sep 18, 2025
d0a9e96
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 19, 2025
fc9ff35
fix(account-tree-controller): use state.wallets in group metadata app…
fabiobozzo Sep 19, 2025
c43a1d3
every state mutation must pick a ref from state itself
fabiobozzo Sep 19, 2025
646fc06
group metadata name assignment to directly access state.accountTree.w…
fabiobozzo Sep 19, 2025
5bc89b3
fix: implement per-wallet sequential account naming
fabiobozzo Sep 19, 2025
895007a
tiny changelog edit
fabiobozzo Sep 19, 2025
7d0c219
replace nullish coalescing assignment with direct assignment for acco…
fabiobozzo Sep 19, 2025
d243c52
Update packages/account-tree-controller/CHANGELOG.md
fabiobozzo Sep 19, 2025
38e0b5a
update tests to reflect new naming logic and ensure accuracy in accou…
fabiobozzo Sep 19, 2025
a6ec434
ignore testing init accountGroupsMetadata for groupId if it doesn't e…
fabiobozzo Sep 19, 2025
ffe2350
prettify
fabiobozzo Sep 19, 2025
3cc47b4
Merge branch 'main' into fix/account-group-names-consistency
fabiobozzo Sep 19, 2025
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
8 changes: 8 additions & 0 deletions packages/account-tree-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **CRITICAL**: Fix multi-wallet account naming bug causing duplicate and inconsistent names across wallets
- Each wallet now has independent sequential numbering (Account 1, Account 2, etc.)
- Names are immediately persisted to ensure consistency across app restarts
- Entropy groups use their actual groupIndex instead of broken alphabetical sorting
- Prevents issues like "Account 2, Account 2, Account 3..." and missing "Account 1"

### Changed

- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588))
Expand Down
187 changes: 187 additions & 0 deletions packages/account-tree-controller/src/AccountTreeController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2543,6 +2543,193 @@ describe('AccountTreeController', () => {
expect(group?.metadata.name).toBe('Account 1');
expect(group?.metadata.name).not.toBe('Solana Account 2');
});

it('ensures consistent per-wallet numbering for multiple SRPs', () => {
// This test reproduces a bug scenario where multiple SRPs
// showed incorrect numbering like "Account 2, 2, 3, 4..."

// Setup first SRP with multiple accounts
const srp1Keyring: KeyringObject = {
...MOCK_HD_KEYRING_1,
metadata: { ...MOCK_HD_KEYRING_1.metadata, id: 'srp1-id' },
};

const srp1Accounts: Bip44Account<InternalAccount>[] = [];
for (let i = 0; i < 5; i++) {
srp1Accounts.push({
...MOCK_HD_ACCOUNT_1,
id: `srp1-account-${i}`,
address: `0x1${i}`,
metadata: {
...MOCK_HD_ACCOUNT_1.metadata,
name: '', // Empty to force default naming
},
options: {
...MOCK_HD_ACCOUNT_1.options,
entropy: {
type: 'mnemonic',
id: 'srp1-id',
derivationPath: `m/44'/60'/${i}'/0/0`,
groupIndex: i,
},
},
});
}

// Setup second SRP with multiple accounts
const srp2Keyring: KeyringObject = {
...MOCK_HD_KEYRING_2,
metadata: { ...MOCK_HD_KEYRING_2.metadata, id: 'srp2-id' },
};

const srp2Accounts: Bip44Account<InternalAccount>[] = [];
for (let i = 0; i < 3; i++) {
srp2Accounts.push({
...MOCK_HD_ACCOUNT_2,
id: `srp2-account-${i}`,
address: `0x2${i}`,
metadata: {
...MOCK_HD_ACCOUNT_2.metadata,
name: '', // Empty to force default naming
},
options: {
...MOCK_HD_ACCOUNT_2.options,
entropy: {
type: 'mnemonic',
id: 'srp2-id',
derivationPath: `m/44'/60'/${i}'/0/0`,
groupIndex: i,
},
},
});
}

const { controller } = setup({
accounts: [...srp1Accounts, ...srp2Accounts],
keyrings: [srp1Keyring, srp2Keyring],
});

controller.init();

const { state } = controller;

// Verify first SRP has correct sequential naming
const wallet1Id = toMultichainAccountWalletId('srp1-id');
const wallet1 = state.accountTree.wallets[wallet1Id];

expect(wallet1).toBeDefined();

// Get groups in order by their groupIndex
const wallet1Groups = [
wallet1.groups[toMultichainAccountGroupId(wallet1Id, 0)],
wallet1.groups[toMultichainAccountGroupId(wallet1Id, 1)],
wallet1.groups[toMultichainAccountGroupId(wallet1Id, 2)],
wallet1.groups[toMultichainAccountGroupId(wallet1Id, 3)],
wallet1.groups[toMultichainAccountGroupId(wallet1Id, 4)],
];

expect(wallet1Groups).toHaveLength(5);
expect(wallet1Groups[0].metadata.name).toBe('Account 1');
expect(wallet1Groups[1].metadata.name).toBe('Account 2');
expect(wallet1Groups[2].metadata.name).toBe('Account 3');
expect(wallet1Groups[3].metadata.name).toBe('Account 4');
expect(wallet1Groups[4].metadata.name).toBe('Account 5');

// Verify second SRP ALSO starts from Account 1 (not Account 6!)
const wallet2Id = toMultichainAccountWalletId('srp2-id');
const wallet2 = state.accountTree.wallets[wallet2Id];

expect(wallet2).toBeDefined();

// Get groups in order by their groupIndex
const wallet2Groups = [
wallet2.groups[toMultichainAccountGroupId(wallet2Id, 0)],
wallet2.groups[toMultichainAccountGroupId(wallet2Id, 1)],
wallet2.groups[toMultichainAccountGroupId(wallet2Id, 2)],
];

expect(wallet2Groups).toHaveLength(3);
expect(wallet2Groups[0].metadata.name).toBe('Account 1');
expect(wallet2Groups[1].metadata.name).toBe('Account 2');
expect(wallet2Groups[2].metadata.name).toBe('Account 3');

// Verify names are persisted
const wallet1Group1Id = toMultichainAccountGroupId(wallet1Id, 0);
const groupMetadata =
controller.state.accountGroupsMetadata[wallet1Group1Id];
expect(groupMetadata?.name?.value).toBe('Account 1');
});

it('handles account naming correctly after app restart', () => {
// This test verifies that account names remain consistent after restart
// and don't change from "Account 1" to "Account 2" etc.

const { controller, messenger } = setup({
accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2],
keyrings: [MOCK_HD_KEYRING_1],
});

// First init - accounts get named
controller.init();

const walletId = toMultichainAccountWalletId(
MOCK_HD_KEYRING_1.metadata.id,
);
const group1Id = toMultichainAccountGroupId(walletId, 0);
const group2Id = toMultichainAccountGroupId(walletId, 1);

// Check initial names
const state1 = controller.state;
const wallet1 = state1.accountTree.wallets[walletId];
expect(wallet1.groups[group1Id].metadata.name).toBe('Account 1');
expect(wallet1.groups[group2Id].metadata.name).toBe('Account 2');

// Simulate app restart by re-initializing
controller.init();

// Names should remain the same
const state2 = controller.state;
const wallet2 = state2.accountTree.wallets[walletId];
expect(wallet2.groups[group1Id].metadata.name).toBe('Account 1');
expect(wallet2.groups[group2Id].metadata.name).toBe('Account 2');

// Add a new account after restart
const newAccount: Bip44Account<InternalAccount> = {
...MOCK_HD_ACCOUNT_1,
id: 'new-account',
address: '0xNEW',
metadata: {
...MOCK_HD_ACCOUNT_1.metadata,
name: '', // Empty to force default naming
},
options: {
...MOCK_HD_ACCOUNT_1.options,
entropy: {
type: 'mnemonic',
id: MOCK_HD_KEYRING_1.metadata.id,
derivationPath: "m/44'/60'/2'/0/0",
groupIndex: 2,
},
},
};

messenger.publish('AccountsController:accountAdded', newAccount);

// New account should get Account 3, not duplicate an existing name
const group3Id = toMultichainAccountGroupId(walletId, 2);
const state3 = controller.state;
const wallet3 = state3.accountTree.wallets[walletId];
expect(wallet3.groups[group3Id].metadata.name).toBe('Account 3');

// All names should be different
const allNames = [
wallet3.groups[group1Id].metadata.name,
wallet3.groups[group2Id].metadata.name,
wallet3.groups[group3Id].metadata.name,
];
const uniqueNames = new Set(allNames);
expect(uniqueNames.size).toBe(3); // All names should be unique
});
});

describe('actions', () => {
Expand Down
100 changes: 76 additions & 24 deletions packages/account-tree-controller/src/AccountTreeController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,47 +394,99 @@
wallet: AccountWalletObject,
group: AccountGroupObject,
) {
const persistedMetadata = this.state.accountGroupsMetadata[group.id];
const persistedGroupMetadata = this.state.accountGroupsMetadata[group.id];

// Apply persisted name if available (including empty strings)
if (persistedMetadata?.name !== undefined) {
group.metadata.name = persistedMetadata.name.value;
if (persistedGroupMetadata?.name !== undefined) {
group.metadata.name = persistedGroupMetadata.name.value;
} else if (!group.metadata.name) {
// Get the appropriate rule for this wallet type
const rule = this.#getRuleForWallet(wallet);
const typedWallet = wallet as AccountWalletObjectOf<typeof wallet.type>;
const typedGroup = typedWallet.groups[group.id] as AccountGroupObject;

// Calculate group index based on position within sorted group IDs
// We sort to ensure consistent ordering across all wallet types:
// - Entropy: group IDs like "entropy:abc/0", "entropy:abc/1" sort to logical order
// - Snap/Keyring: group IDs like "keyring:ledger/0xABC" get consistent alphabetical order
const sortedGroupIds = Object.keys(wallet.groups).sort();
let groupIndex = sortedGroupIds.indexOf(group.id);

// Defensive fallback: if group.id is not found in sortedGroupIds (should never happen
// in normal operation since we iterate over wallet.groups), use index 0 to prevent
// passing -1 to getDefaultAccountGroupName which would result in "Account 0"
/* istanbul ignore next */
if (groupIndex === -1) {
groupIndex = 0;
// Try to get computed name first (e.g., from existing EVM account names)
const computedName = rule.getComputedAccountGroupName(typedGroup);

if (computedName) {
group.metadata.name = computedName;
} else {
// Generate default name based on wallet-specific numbering
group.metadata.name = this.#generateDefaultGroupName(
wallet,
group,
rule,
);
}

Copy link

Choose a reason for hiding this comment

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

Bug: Account Group Naming Fails Sequentiality

The default account group naming logic can lead to non-sequential numbering. For entropy-based groups, Math.max may incorrectly override the natural groupIndex. For other group types, Object.keys(wallet.groups).length can cause numbering gaps, resulting in names like "Account 1", "Account 3" instead of "Account 1", "Account 2".

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.

Again, that's by design.

// Use computed name first, then fallback to default naming if empty
group.metadata.name =
rule.getComputedAccountGroupName(typedGroup) ||
rule.getDefaultAccountGroupName(groupIndex);
// Persist the assigned name to ensure consistency across restarts
// This prevents the name from changing on subsequent inits
this.update((state) => {
if (!state.accountGroupsMetadata[group.id]) {
state.accountGroupsMetadata[group.id] = {};
}
state.accountGroupsMetadata[group.id].name = {
value: group.metadata.name,
lastUpdatedAt: Date.now(),
};
});
}

// Apply persisted UI states
if (persistedMetadata?.pinned?.value !== undefined) {
group.metadata.pinned = persistedMetadata.pinned.value;
if (persistedGroupMetadata?.pinned?.value !== undefined) {
group.metadata.pinned = persistedGroupMetadata.pinned.value;
}
if (persistedMetadata?.hidden?.value !== undefined) {
group.metadata.hidden = persistedMetadata.hidden.value;
if (persistedGroupMetadata?.hidden?.value !== undefined) {
group.metadata.hidden = persistedGroupMetadata.hidden.value;
}
}

/**
* Generates a default name for an account group, ensuring unique numbering
* within each wallet.
*
* @param wallet - The wallet containing the group.
* @param group - The group to generate a name for.
* @param rule - The rule for this wallet type.
* @returns The generated default name.
*/
#generateDefaultGroupName(
wallet: AccountWalletObject,
group: AccountGroupObject,
rule: Rule<AccountWalletType, AccountGroupType>,
): string {
// For entropy groups, use the groupIndex from metadata for consistent numbering
if (group.type === 'multichain-account' && 'entropy' in group.metadata) {

Check warning on line 459 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

The two values in this comparison do not have a shared enum type
return rule.getDefaultAccountGroupName(group.metadata.entropy.groupIndex);
}

// For other types, use sequential numbering within the wallet
// Count existing groups to determine the next number
const existingNumbers = new Set<number>();

// Collect all numbers currently in use by this wallet's groups

Check warning on line 467 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
for (const existingGroup of Object.values(wallet.groups)) {

Check warning on line 468 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
const { id } = existingGroup;

Check warning on line 469 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
const { name: persistedName } = this.state.accountGroupsMetadata[id] || {};

Check warning on line 470 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `········const·{·name:·persistedName·}·=` with `······const·{·name:·persistedName·}·=⏎·······`
const existingName = persistedName?.value || existingGroup.metadata.name;

Check warning on line 471 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`

// Extract number from "Account X" pattern

Check warning on line 473 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
const match = existingName.match(/^Account (\d+)$/);

Check failure on line 474 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Use the 'u' flag

Check warning on line 474 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
if (match) {

Check warning on line 475 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
const [, numberStr] = match;

Check warning on line 476 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Delete `··`
existingNumbers.add(parseInt(numberStr, 10) - 1); // Convert to 0-based index
}
}

// Find the first available number (0-based)
let accountNumber = 0;
while (existingNumbers.has(accountNumber)) {
accountNumber++;

Check failure on line 484 in packages/account-tree-controller/src/AccountTreeController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Unary operator '++' used
}

return rule.getDefaultAccountGroupName(accountNumber);
}

/**
* Gets the account wallet object from its ID.
*
Expand Down
6 changes: 5 additions & 1 deletion packages/account-tree-controller/src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ import type { UpdatableField, ExtractFieldValues } from './type-utils.js';
export type AccountTreeWalletPersistedMetadata = {
/** Custom name set by user, overrides default naming logic */
name?: UpdatableField<string>;
/** Next account number to use for default naming (Account 1, Account 2, etc.) */
nextAccountNumber?: number;
};

/**
* Tree metadata for account wallets (required plain values extracted from persisted metadata).
*/
export type AccountTreeWalletMetadata = Required<
ExtractFieldValues<AccountTreeWalletPersistedMetadata>
ExtractFieldValues<
Omit<AccountTreeWalletPersistedMetadata, 'nextAccountNumber'>
>
>;

/**
Expand Down
Loading