Skip to content

feat: add persistent metadata for account groups and wallets #6221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

fabiobozzo
Copy link
Contributor

@fabiobozzo fabiobozzo commented Jul 31, 2025

Explanation

This PR adds persistence capabilities to the AccountTreeController to support user customizations like custom names, pinning/hiding account groups, and sync metadata. Previously, the controller was stateless and would lose all user customizations on each tree rebuild.

Current State & Problem

The AccountTreeController currently rebuilds the account tree from scratch using rules every time it initializes, resulting in:

  • Loss of custom names set by users for wallets and account groups
  • No support for pinning/hiding account groups in the UI
  • No persistence of sync metadata required for Account Syncing V2
  • Poor user experience as customizations don't survive app restarts or tree rebuilds

Solution Overview

This implementation adds persistent metadata storage with two new state properties that store user customizations separately from the tree structure:

  • accountGroupsMetadata: Record<AccountGroupId, AccountGroupMetadata>
  • accountWalletsMetadata: Record<AccountWalletId, AccountWalletMetadata>

Key Changes

Persistent State Structure:

  • Added AccountGroupMetadata type supporting name, pinned, hidden, and lastUpdatedAt fields
  • Added AccountWalletMetadata type supporting name and lastUpdatedAt fields
  • Metadata is stored in dedicated maps indexed by stable IDs

Enhanced Tree Building Process:

  • Modified existing #renameAccountWalletIfNeeded() and #renameAccountGroupIfNeeded() methods to prioritize persisted custom names
  • Customizations are injected during tree construction rather than requiring separate tree traversals
  • Maintains the existing two-phase approach: structure building + customization application

New Public Methods:

  • setAccountGroupName(groupId, name) - Sets custom name for account groups
  • setAccountWalletName(walletId, name) - Sets custom name for wallets
  • setAccountGroupPinned(groupId, pinned) - Controls group pinning state
  • setAccountGroupHidden(groupId, hidden) - Controls group visibility

Optimization Strategy:

  • Leverages stable wallet/group IDs for O(1) metadata lookups during tree building
  • Avoids expensive tree traversals by applying customizations inline during construction
  • Automatic tree rebuilding when metadata changes to ensure consistency

Implementation Details

The persistence follows a metadata overlay pattern where:

  1. Base tree structure is built using existing rules (Entropy → Snap → Keyring)
  2. Persisted customizations are applied during the naming phase if they exist
  3. Default rule-generated names are used as fallbacks
  4. All metadata updates trigger automatic tree rebuilds to maintain consistency

This approach ensures backward compatibility while adding the persistence layer needed for Account Syncing V2 and advanced UI features like pinning/hiding.

References

  • Supports Account Syncing V2 metadata requirements (lastUpdatedAt timestamps for conflict resolution)
  • Enables pinning and hiding account groups functionality
  • Foundation for persistent user customizations across app sessions

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

- Allowed users to customize names, pinning, hiding, and collapsing states.
- Added methods to set custom names, toggle pinned and hidden states for account groups, and toggle collapsed state for account wallets.
- Updated types to reflect new metadata structures for better state management.
- Added tests to verify persistence of custom names, pinned, and hidden states for account groups and wallets across initialization calls.
- Ensured that metadata structures are correctly initialized and preserved during state updates.
- Included checks for handling non-existent groups and wallets gracefully without throwing errors.
Copy link
Contributor

@mathieuartu mathieuartu left a comment

Choose a reason for hiding this comment

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

Will review more in-depth tomorrow, added comments in the meantime :)

Also, we will need to change the shape of metadata properties following the discussions we recently had:

So that they are all objects that contain a value and a lastUpdatedAt property.
Example (only a suggestion, let's discuss with everybody :)) :

export type UpdatableField<T> = { value: T; lastUpdatedAt: number };

...

type IsAccountWalletObject<
  Type extends {
    type: AccountWalletType;
    id: AccountWalletId;
    groups: {
      [groupId: AccountGroupId]: AccountGroupObject;
    };
    metadata: {
      name: UpdatableField<string>;
      pinned: UpdatableField<boolean>;
      // ...etc
    };
  },
> = Type;

Comment on lines 30 to 37
/** Custom name set by user, overrides default naming logic */
name?: string;
/** Whether this group is pinned in the UI */
pinned?: boolean;
/** Whether this group is hidden in the UI */
hidden?: boolean;
/** Timestamp of last metadata update for sync conflict resolution */
lastUpdatedAt?: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: shouldn't we want to expand on the existing metadata types and define those properties there?
For example:

  • Here for WalletObject
  • Here for AccountGroupObject

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, the "metadata" might becomes ambiguous... I think we should have 2 types like:

type Account{Group,Wallet}PersistedMetadata = { ... };
type Account{Group,Wallet}Metadata = { ... };

and intersect the 2 types together on the *Object types.

This way, we could avoid having this Omit<..., 'name'> which kinda feels out-of-place (I was not a big fan of it when I added it, but I think it makes sense to "move" the name to some other types now).

@@ -30,6 +58,10 @@ export type AccountTreeControllerState = {
};
selectedAccountGroup: AccountGroupId | '';
};
/** Persistent metadata for account groups (names, pinning, hiding, sync timestamps) */
accountGroupsMetadata: Record<AccountGroupId, AccountGroupMetadata>;
Copy link
Contributor

Choose a reason for hiding this comment

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

And then here have

  • Record<AccountGroupId, AccountGroupObject['metadata']>
  • Record<AccountWalletId, AccountWalletObject['metadata']>

Copy link
Contributor

Choose a reason for hiding this comment

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

If we go with having Account{Group,Wallet}PersistedMetadata, we could use those here instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pardon me, perhaps I don't understand correctly. As far as I understand:

Option 1 (me, Charly): separate persistent and "tree building" types

// Controller state
type AccountGroupPersistedMetadata = {
  name?: UpdatableField<string>;
  pinned?: UpdatableField<boolean>;
  hidden?: UpdatableField<boolean>;
};

// During tree building, objects could extract .value from UpdatableField.value
type AccountGroupTreeMetadata = {
  name: string;           
  pinned: boolean;     
  hidden: boolean;     
};

Option 2 (Mathieu): unified

// Tree objects store UpdatableField directly
type AccountGroupMetadata = {
  name: UpdatableField<string>;
  entropy: { groupIndex: number };
  pinned: UpdatableField<boolean>;
  hidden: UpdatableField<boolean>;
};

In the latter option, the controller doesn't use a different type, so: accountGroupsMetadata: Record<AccountGroupId, AccountGroupObject['metadata']>;

BUT... the UI should be adjusted to access group.metadata.pinned.value.. no? @ccharly @mathieuartu

Copy link
Contributor

Choose a reason for hiding this comment

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

Here's was I had in mind to "extract" a "normal object" out from a *PersistedMetadata type:

Which is:

type UpdatableField<Value> = {
   value: Value;
   lastUpdatedAt: number;
}

type ToValue<Field> = Field extends UpdatableField<unknown> ? Field['value'] : Field;

type ToObject<
  ObjectValue extends Record<string, unknown>,
> = {
  [Key in keyof ObjectValue]: ToValue<ObjectValue[Key]>
}

type PersistedMetadata = {
   name: UpdatableField<string>;
   test: boolean;
}

type Metadata = ToObject<PersistedMetadata>;

Which then could be used:

type AccountGroupPersistedMetadata = {
  name: UpdatableField<string>;
  pinned: UpdatableField<boolean>;
  hidden: UpdatableField<boolean>;
}

type AccountGroupMultichainAccountObject = {
  type: AccountGroupType.MultichainAccount;
  id: MultichainAccountGroupId;
  // Blockchain Accounts (at least 1 account per multichain-accounts):
  accounts: [AccountId, ...AccountId[]];
  metadata: ToObject<AccountGroupPersistedMetadata> & {
    entropy: {
      groupIndex: number;
    };
  };

Note

The ToObject name is "too generic" IMO, but you get the idea!

*/
export type AccountWalletMetadata = {
/** Custom name set by user, overrides default naming logic */
name?: string;
Copy link
Contributor

@mathieuartu mathieuartu Jul 31, 2025

Choose a reason for hiding this comment

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

Question: Should those properties be required instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Optional makes sense to me because not all groups/wallets will have custom names initially. @ccharly do we prefer null values by default (empty strings or so) ?

*/
setAccountGroupName(groupId: AccountGroupId, name: string): void {
this.update((state) => {
state.accountGroupsMetadata[groupId] = {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably check that the groupId also exists in this map before using it.

IMO, throwing if the groupId is not "valid" would make sense here.

});

// Rebuild tree to apply the new name
this.init();
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems "too much" to me. Though, we don't have any "contexts" or "mapping" using a groupId.

Maybe we should add one 🤔 so we could update the group node directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I introduced a readonly #groupIdToWalletId: Map<AccountGroupId, AccountWalletId>; for fast lookup. Wdyt?

Comment on lines 567 to 576
this.update((state) => {
state.accountWalletsMetadata[walletId] = {
...state.accountWalletsMetadata[walletId],
name,
lastUpdatedAt: Date.now(),
};
});

// Rebuild tree to apply the new name
this.init();
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

Comment on lines 586 to 591
this.update((state) => {
state.accountGroupsMetadata[groupId] = {
...state.accountGroupsMetadata[groupId],
pinned,
lastUpdatedAt: Date.now(),
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

Comment on lines 603 to 607
state.accountGroupsMetadata[groupId] = {
...state.accountGroupsMetadata[groupId],
hidden,
lastUpdatedAt: Date.now(),
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

Comment on lines 622 to 626
state.accountWalletsMetadata[walletId] = {
...state.accountWalletsMetadata[walletId],
collapsed,
lastUpdatedAt: Date.now(),
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

Comment on lines 30 to 37
/** Custom name set by user, overrides default naming logic */
name?: string;
/** Whether this group is pinned in the UI */
pinned?: boolean;
/** Whether this group is hidden in the UI */
hidden?: boolean;
/** Timestamp of last metadata update for sync conflict resolution */
lastUpdatedAt?: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, the "metadata" might becomes ambiguous... I think we should have 2 types like:

type Account{Group,Wallet}PersistedMetadata = { ... };
type Account{Group,Wallet}Metadata = { ... };

and intersect the 2 types together on the *Object types.

This way, we could avoid having this Omit<..., 'name'> which kinda feels out-of-place (I was not a big fan of it when I added it, but I think it makes sense to "move" the name to some other types now).

@@ -30,6 +58,10 @@ export type AccountTreeControllerState = {
};
selectedAccountGroup: AccountGroupId | '';
};
/** Persistent metadata for account groups (names, pinning, hiding, sync timestamps) */
accountGroupsMetadata: Record<AccountGroupId, AccountGroupMetadata>;
Copy link
Contributor

Choose a reason for hiding this comment

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

If we go with having Account{Group,Wallet}PersistedMetadata, we could use those here instead.

…ounts and groups

- Updated account and group metadata to include updatable fields for names, pinned, hidden, and collapsed states.
- Refactored methods to apply persisted metadata during initialization and state updates.
- Improved type definitions to support new metadata structures, ensuring better state management and persistence.
…ta and related methods

- Eliminated the collapsed state from account wallet and group metadata structures.
- Refactored tests to remove references to the collapsed state.
- Updated type definitions to reflect the removal of the collapsed property, ensuring cleaner state management.
@fabiobozzo fabiobozzo requested a review from mathieuartu August 1, 2025 12:36
…rsistence properties set

- Enhanced methods to validate the existence of account groups and wallets before performing operations, throwing errors when they are not found.
- Updated tests to reflect the new error handling behavior for non-existent groups and wallets.
- Introduced a mapping for efficient access to wallet IDs associated with account groups, improving performance during updates.
@fabiobozzo fabiobozzo requested a review from ccharly August 1, 2025 12:59
fabiobozzo and others added 4 commits August 1, 2025 14:59
…de pinned and hidden states

- Added 'pinned' and 'hidden' properties to the metadata structure for account groups and wallets in the Entropy, Keyring, and Snap rules.
- This enhancement aligns with previous updates to improve state management and metadata persistence.
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.

3 participants