diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 240b188ce09..db554c89073 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **BREAKING:** Add backup and sync capabilities ([#6344](https://github.com/MetaMask/core/pull/6344)) + - New `syncWithUserStorage()` and `syncWithUserStorageAtLeastOnce()` method for manual sync triggers, replacing `UserStorageController:syncInternalAccountsWithUserStorage` usage in clients. + - `BackupAndSyncService` with full and atomic sync operations for account tree data persistence. + - Bidirectional metadata synchronization for wallets and groups with user storage. + - Automatic sync triggers on metadata changes (rename, pin/hide operations). + - New `isBackupAndSyncInProgress` state property to track sync status. + - Analytics event tracking and performance tracing for sync operations. + - Rollback mechanism for failed sync operations with state snapshot/restore capabilities. + - Support for entropy-based wallets with multichain account syncing. + - Legacy account syncing compatibility for seamless migration. + - Optional configuration through new `AccountTreeControllerConfig.backupAndSync` options. + - Add `@metamask/superstruct` for data validation. +- **BREAKING:** Add `@metamask/profile-sync-controller` and `@metamask/multichain-account-service` peer dependencies ([#6344](https://github.com/MetaMask/core/pull/6344)) - Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6470](https://github.com/MetaMask/core/pull/6470)) ### Changed diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 7c586857fbc..7fc41c3348c 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -50,6 +50,9 @@ "@metamask/base-controller": "^8.3.0", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.4.2", + "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" }, "devDependencies": { @@ -58,6 +61,8 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-api": "^20.1.0", "@metamask/keyring-controller": "^23.0.0", + "@metamask/multichain-account-service": "^0.7.0", + "@metamask/profile-sync-controller": "^24.0.0", "@metamask/providers": "^22.1.0", "@metamask/snaps-controllers": "^14.0.1", "@types/jest": "^27.4.1", @@ -73,6 +78,8 @@ "@metamask/account-api": "^0.9.0", "@metamask/accounts-controller": "^33.0.0", "@metamask/keyring-controller": "^23.0.0", + "@metamask/multichain-account-service": "^0.7.0", + "@metamask/profile-sync-controller": "^24.0.0", "@metamask/providers": "^22.0.0", "@metamask/snaps-controllers": "^14.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 202ad7db610..b2546d36f11 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -23,7 +23,12 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { GetSnap as SnapControllerGetSnap } from '@metamask/snaps-controllers'; -import { AccountTreeController } from './AccountTreeController'; +import { + AccountTreeController, + getDefaultAccountTreeControllerState, +} from './AccountTreeController'; +import type { BackupAndSyncAnalyticsEventPayload } from './backup-and-sync/analytics'; +import { BackupAndSyncService } from './backup-and-sync/service'; import { isAccountGroupNameUnique } from './group'; import { getAccountWalletNameFromKeyringType } from './rules/keyring'; import { @@ -218,12 +223,20 @@ function getAccountTreeControllerMessenger( 'AccountsController:accountAdded', 'AccountsController:accountRemoved', 'AccountsController:selectedAccountChange', + 'UserStorageController:stateChange', ], allowedActions: [ 'AccountsController:listMultichainAccounts', 'AccountsController:getAccount', 'AccountsController:getSelectedAccount', 'AccountsController:setSelectedAccount', + 'UserStorageController:getState', + 'UserStorageController:performGetStorage', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performSetStorage', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:getSessionProfile', + 'MultichainAccountService:createMultichainAccountGroup', 'KeyringController:getState', 'SnapController:get', ], @@ -238,6 +251,11 @@ function getAccountTreeControllerMessenger( * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. * @param options.accounts - Accounts to use for AccountsController:listMultichainAccounts handler. * @param options.keyrings - Keyring objects to use for KeyringController:getState handler. + * @param options.config - Configuration options for the controller. + * @param options.config.backupAndSync - Configuration options for backup and sync. + * @param options.config.backupAndSync.onBackupAndSyncEvent - Event handler for backup and sync events. + * @param options.config.backupAndSync.isAccountSyncingEnabled - Flag to enable account syncing. + * @param options.config.backupAndSync.isBackupAndSyncEnabled - Flag to enable backup and sync. * @returns An object containing the controller instance and the messenger. */ function setup({ @@ -245,6 +263,13 @@ function setup({ messenger = getRootMessenger(), accounts = [], keyrings = [], + config = { + backupAndSync: { + isAccountSyncingEnabled: true, + isBackupAndSyncEnabled: true, + onBackupAndSyncEvent: jest.fn(), + }, + }, }: { state?: Partial; messenger?: Messenger< @@ -253,6 +278,15 @@ function setup({ >; accounts?: InternalAccount[]; keyrings?: KeyringObject[]; + config?: { + backupAndSync?: { + isAccountSyncingEnabled?: boolean; + isBackupAndSyncEnabled?: boolean; + onBackupAndSyncEvent?: ( + event: BackupAndSyncAnalyticsEventPayload, + ) => void; + }; + }; } = {}): { controller: AccountTreeController; messenger: Messenger< @@ -273,6 +307,16 @@ function setup({ getSelectedAccount: jest.Mock; getAccount: jest.Mock; }; + UserStorageController: { + performGetStorage: jest.Mock; + performGetStorageAllFeatureEntries: jest.Mock; + performSetStorage: jest.Mock; + performBatchSetStorage: jest.Mock; + syncInternalAccountsWithUserStorage: jest.Mock; + }; + AuthenticationController: { + getSessionProfile: jest.Mock; + }; }; } { const mocks = { @@ -286,6 +330,22 @@ function setup({ getAccount: jest.fn(), getSelectedAccount: jest.fn(), }, + UserStorageController: { + getState: jest.fn(), + performGetStorage: jest.fn(), + performGetStorageAllFeatureEntries: jest.fn(), + performSetStorage: jest.fn(), + performBatchSetStorage: jest.fn(), + syncInternalAccountsWithUserStorage: jest.fn(), + }, + AuthenticationController: { + getSessionProfile: jest.fn().mockResolvedValue({ + profileId: 'f88227bd-b615-41a3-b0be-467dd781a4ad', + metaMetricsId: '561ec651-a844-4b36-a451-04d6eac35740', + identifierId: + 'da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb', + }), + }, }; if (accounts) { @@ -319,6 +379,39 @@ function setup({ 'AccountsController:setSelectedAccount', jest.fn(), ); + + // Mock AuthenticationController:getSessionProfile + messenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + mocks.AuthenticationController.getSessionProfile, + ); + + // Mock UserStorageController methods + mocks.UserStorageController.getState.mockImplementation(() => ({ + isBackupAndSyncEnabled: config?.backupAndSync?.isBackupAndSyncEnabled, + isAccountSyncingEnabled: config?.backupAndSync?.isAccountSyncingEnabled, + })); + messenger.registerActionHandler( + 'UserStorageController:getState', + mocks.UserStorageController.getState, + ); + + messenger.registerActionHandler( + 'UserStorageController:performGetStorage', + mocks.UserStorageController.performGetStorage, + ); + messenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + mocks.UserStorageController.performGetStorageAllFeatureEntries, + ); + messenger.registerActionHandler( + 'UserStorageController:performSetStorage', + mocks.UserStorageController.performSetStorage, + ); + messenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + mocks.UserStorageController.performBatchSetStorage, + ); } if (keyrings) { @@ -335,6 +428,7 @@ function setup({ const controller = new AccountTreeController({ messenger: getAccountTreeControllerMessenger(messenger), state, + ...(config && { config }), }); const consoleWarnSpy = jest @@ -529,6 +623,8 @@ describe('AccountTreeController', () => { }, selectedAccountGroup: expect.any(String), // Will be set to some group after init }, + hasAccountTreeSyncingSyncedAtLeastOnce: false, + isAccountTreeSyncingInProgress: false, accountGroupsMetadata: {}, accountWalletsMetadata: {}, } as AccountTreeControllerState); @@ -864,6 +960,8 @@ describe('AccountTreeController', () => { }, selectedAccountGroup: expect.any(String), // Will be set after init }, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, accountGroupsMetadata: {}, accountWalletsMetadata: {}, } as AccountTreeControllerState); @@ -932,6 +1030,8 @@ describe('AccountTreeController', () => { }, selectedAccountGroup: expect.any(String), // Will be set after init }, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, accountGroupsMetadata: {}, accountWalletsMetadata: {}, } as AccountTreeControllerState); @@ -952,6 +1052,8 @@ describe('AccountTreeController', () => { expect(controller.state).toStrictEqual({ accountGroupsMetadata: {}, accountWalletsMetadata: {}, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, accountTree: { // No wallets should be present. wallets: {}, @@ -1037,6 +1139,8 @@ describe('AccountTreeController', () => { }, accountGroupsMetadata: {}, accountWalletsMetadata: {}, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, } as AccountTreeControllerState); }); @@ -1150,6 +1254,8 @@ describe('AccountTreeController', () => { }, accountGroupsMetadata: {}, accountWalletsMetadata: {}, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, } as AccountTreeControllerState); }); }); @@ -2854,6 +2960,203 @@ describe('AccountTreeController', () => { }); }); + describe('syncWithUserStorage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls performFullSync on the syncing service', async () => { + // Spy on the BackupAndSyncService constructor and methods + const performFullSyncSpy = jest.spyOn( + BackupAndSyncService.prototype, + 'performFullSync', + ); + + const { controller } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1], // Use hardware account to avoid entropy calls + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + await controller.syncWithUserStorage(); + + expect(performFullSyncSpy).toHaveBeenCalledTimes(1); + }); + + it('handles sync errors gracefully', async () => { + const syncError = new Error('Sync failed'); + const performFullSyncSpy = jest + .spyOn(BackupAndSyncService.prototype, 'performFullSync') + .mockRejectedValue(syncError); + + const { controller } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1], // Use hardware account to avoid entropy calls + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + await expect(controller.syncWithUserStorage()).rejects.toThrow( + syncError.message, + ); + expect(performFullSyncSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('syncWithUserStorageAtLeastOnce', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls performFullSyncAtLeastOnce on the syncing service', async () => { + // Spy on the BackupAndSyncService constructor and methods + const performFullSyncAtLeastOnceSpy = jest.spyOn( + BackupAndSyncService.prototype, + 'performFullSyncAtLeastOnce', + ); + + const { controller } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1], // Use hardware account to avoid entropy calls + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + await controller.syncWithUserStorageAtLeastOnce(); + + expect(performFullSyncAtLeastOnceSpy).toHaveBeenCalledTimes(1); + }); + + it('handles sync errors gracefully', async () => { + const syncError = new Error('Sync failed'); + const performFullSyncAtLeastOnceSpy = jest + .spyOn(BackupAndSyncService.prototype, 'performFullSyncAtLeastOnce') + .mockRejectedValue(syncError); + + const { controller } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1], // Use hardware account to avoid entropy calls + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + await expect(controller.syncWithUserStorageAtLeastOnce()).rejects.toThrow( + syncError.message, + ); + expect(performFullSyncAtLeastOnceSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('UserStorageController:stateChange subscription', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls BackupAndSyncService.handleUserStorageStateChange', () => { + const handleUserStorageStateChangeSpy = jest.spyOn( + BackupAndSyncService.prototype, + 'handleUserStorageStateChange', + ); + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + messenger.publish( + 'UserStorageController:stateChange', + { + isBackupAndSyncEnabled: false, + isAccountSyncingEnabled: true, + isBackupAndSyncUpdateLoading: false, + isContactSyncingEnabled: false, + isContactSyncingInProgress: false, + }, + [], + ); + + expect(handleUserStorageStateChangeSpy).toHaveBeenCalled(); + expect(handleUserStorageStateChangeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('clearPersistedMetadataAndSyncingState', () => { + it('clears all persisted metadata and syncing state', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + // Set some metadata first + controller.setAccountGroupName( + 'entropy:mock-keyring-id-1/0', + 'Test Group', + ); + controller.setAccountWalletName( + 'entropy:mock-keyring-id-1', + 'Test Wallet', + ); + + // Verify metadata exists + expect(controller.state.accountGroupsMetadata).not.toStrictEqual({}); + expect(controller.state.accountWalletsMetadata).not.toStrictEqual({}); + + // Clear the metadata + controller.clearState(); + + // Verify everything is cleared + expect(controller.state).toStrictEqual( + getDefaultAccountTreeControllerState(), + ); + }); + }); + + describe('backup and sync config initialization', () => { + it('initializes backup and sync config with provided analytics callback', async () => { + const mockAnalyticsCallback = jest.fn(); + + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + config: { + backupAndSync: { + isAccountSyncingEnabled: true, + isBackupAndSyncEnabled: true, + onBackupAndSyncEvent: mockAnalyticsCallback, + }, + }, + }); + + controller.init(); + + // Verify config is initialized - controller should be defined and working + expect(controller).toBeDefined(); + expect(controller.state).toBeDefined(); + + // Test that the analytics callback can be accessed through the backup and sync service + // We'll trigger a sync to test the callback (this should cover the callback invocation) + await controller.syncWithUserStorage(); + expect(mockAnalyticsCallback).toHaveBeenCalled(); + }); + + it('initializes backup and sync config with default values when no config provided', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + // Verify controller works without config (tests default config initialization) + expect(controller).toBeDefined(); + expect(controller.state).toBeDefined(); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const { controller } = setup(); @@ -2884,6 +3187,7 @@ describe('AccountTreeController', () => { "wallets": Object {}, }, "accountWalletsMetadata": Object {}, + "hasAccountTreeSyncingSyncedAtLeastOnce": false, } `); }); @@ -2901,6 +3205,7 @@ describe('AccountTreeController', () => { Object { "accountGroupsMetadata": Object {}, "accountWalletsMetadata": Object {}, + "hasAccountTreeSyncingSyncedAtLeastOnce": false, } `); }); @@ -2922,6 +3227,8 @@ describe('AccountTreeController', () => { "wallets": Object {}, }, "accountWalletsMetadata": Object {}, + "hasAccountTreeSyncingSyncedAtLeastOnce": false, + "isAccountTreeSyncingInProgress": false, } `); }); diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 1a2410eb7a9..3f008dba4b8 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -8,9 +8,17 @@ import { AccountWalletType, select } from '@metamask/account-api'; import { type AccountId } from '@metamask/accounts-controller'; import type { StateMetadata } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { BackupAndSyncEmitAnalyticsEventParams } from './backup-and-sync/analytics'; +import { + formatAnalyticsEvent, + traceFallback, +} from './backup-and-sync/analytics'; +import { BackupAndSyncService } from './backup-and-sync/service'; +import type { BackupAndSyncContext } from './backup-and-sync/types'; import type { AccountGroupObject } from './group'; import { isAccountGroupNameUnique } from './group'; import type { Rule } from './rule'; @@ -18,10 +26,12 @@ import { EntropyRule } from './rules/entropy'; import { KeyringRule } from './rules/keyring'; import { SnapRule } from './rules/snap'; import type { + AccountTreeControllerConfig, + AccountTreeControllerInternalBackupAndSyncConfig, AccountTreeControllerMessenger, AccountTreeControllerState, } from './types'; -import type { AccountWalletObject, AccountWalletObjectOf } from './wallet'; +import { type AccountWalletObject, type AccountWalletObjectOf } from './wallet'; export const controllerName = 'AccountTreeController'; @@ -33,6 +43,18 @@ const accountTreeControllerMetadata: StateMetadata = anonymous: false, usedInUi: true, }, + isAccountTreeSyncingInProgress: { + includeInStateLogs: false, + persist: false, + anonymous: false, + usedInUi: true, + }, + hasAccountTreeSyncingSyncedAtLeastOnce: { + includeInStateLogs: true, + persist: true, + anonymous: false, + usedInUi: true, + }, accountGroupsMetadata: { includeInStateLogs: true, persist: true, @@ -58,6 +80,8 @@ export function getDefaultAccountTreeControllerState(): AccountTreeControllerSta wallets: {}, selectedAccountGroup: '', }, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, accountGroupsMetadata: {}, accountWalletsMetadata: {}, }; @@ -87,22 +111,34 @@ export class AccountTreeController extends BaseController< readonly #groupIdToWalletId: Map; + /** + * Service responsible for all backup and sync operations. + */ + readonly #backupAndSyncService: BackupAndSyncService; + readonly #rules: [EntropyRule, SnapRule, KeyringRule]; + readonly #trace: TraceCallback; + + readonly #backupAndSyncConfig: AccountTreeControllerInternalBackupAndSyncConfig; + /** * Constructor for AccountTreeController. * * @param options - The controller options. * @param options.messenger - The messenger object. * @param options.state - Initial state to set on this controller + * @param options.config - Optional configuration for the controller. */ constructor({ messenger, state, + config, }: { messenger: AccountTreeControllerMessenger; state?: Partial; + config?: AccountTreeControllerConfig; }) { super({ messenger, @@ -130,6 +166,24 @@ export class AccountTreeController extends BaseController< new KeyringRule(this.messagingSystem), ]; + // Initialize trace function + this.#trace = config?.trace ?? traceFallback; + + // Initialize backup and sync config + this.#backupAndSyncConfig = { + emitAnalyticsEventFn: (event: BackupAndSyncEmitAnalyticsEventParams) => { + return ( + config?.backupAndSync?.onBackupAndSyncEvent && + config.backupAndSync.onBackupAndSyncEvent(formatAnalyticsEvent(event)) + ); + }, + }; + + // Initialize the backup and sync service + this.#backupAndSyncService = new BackupAndSyncService( + this.#createBackupAndSyncContext(), + ); + this.messagingSystem.subscribe( 'AccountsController:accountAdded', (account) => { @@ -151,6 +205,15 @@ export class AccountTreeController extends BaseController< }, ); + this.messagingSystem.subscribe( + 'UserStorageController:stateChange', + (userStorageControllerState) => { + this.#backupAndSyncService.handleUserStorageStateChange( + userStorageControllerState, + ); + }, + ); + this.#registerMessageHandlers(); } @@ -606,6 +669,11 @@ export class AccountTreeController extends BaseController< // the union tag `result.wallet.type`. } as AccountWalletObject; wallet = wallets[walletId]; + + // Trigger atomic sync for new wallet (only for entropy wallets) + if (wallet.type === AccountWalletType.Entropy) { + this.#backupAndSyncService.enqueueSingleWalletSync(walletId); + } } const groupId = result.group.id; @@ -627,6 +695,11 @@ export class AccountTreeController extends BaseController< // Map group ID to its containing wallet ID for efficient direct access this.#groupIdToWalletId.set(groupId, walletId); + + // Trigger atomic sync for new group (only for entropy wallets) + if (wallet.type === AccountWalletType.Entropy) { + this.#backupAndSyncService.enqueueSingleGroupSync(groupId); + } } else { group.accounts.push(account.id); } @@ -895,6 +968,8 @@ export class AccountTreeController extends BaseController< // Validate that the name is unique this.#assertAccountGroupNameIsUnique(groupId, name); + const walletId = this.#groupIdToWalletId.get(groupId); + this.update((state) => { // Update persistent metadata state.accountGroupsMetadata[groupId] ??= {}; @@ -904,12 +979,20 @@ export class AccountTreeController extends BaseController< }; // Update tree node directly using efficient mapping - const walletId = this.#groupIdToWalletId.get(groupId); if (walletId) { state.accountTree.wallets[walletId].groups[groupId].metadata.name = name; } }); + + // Trigger atomic sync for group rename (only for groups from entropy wallets) + if ( + walletId && + this.state.accountTree.wallets[walletId].type === + AccountWalletType.Entropy + ) { + this.#backupAndSyncService.enqueueSingleGroupSync(groupId); + } } /** @@ -934,6 +1017,14 @@ export class AccountTreeController extends BaseController< // Update tree node directly state.accountTree.wallets[walletId].metadata.name = name; }); + + // Trigger atomic sync for wallet rename (only for groups from entropy wallets) + if ( + this.state.accountTree.wallets[walletId].type === + AccountWalletType.Entropy + ) { + this.#backupAndSyncService.enqueueSingleWalletSync(walletId); + } } /** @@ -947,6 +1038,8 @@ export class AccountTreeController extends BaseController< // Validate that the group exists in the current tree this.#assertAccountGroupExists(groupId); + const walletId = this.#groupIdToWalletId.get(groupId); + this.update((state) => { // Update persistent metadata state.accountGroupsMetadata[groupId] ??= {}; @@ -956,12 +1049,20 @@ export class AccountTreeController extends BaseController< }; // Update tree node directly using efficient mapping - const walletId = this.#groupIdToWalletId.get(groupId); if (walletId) { state.accountTree.wallets[walletId].groups[groupId].metadata.pinned = pinned; } }); + + // Trigger atomic sync for group pinning (only for groups from entropy wallets) + if ( + walletId && + this.state.accountTree.wallets[walletId].type === + AccountWalletType.Entropy + ) { + this.#backupAndSyncService.enqueueSingleGroupSync(groupId); + } } /** @@ -975,6 +1076,8 @@ export class AccountTreeController extends BaseController< // Validate that the group exists in the current tree this.#assertAccountGroupExists(groupId); + const walletId = this.#groupIdToWalletId.get(groupId); + this.update((state) => { // Update persistent metadata state.accountGroupsMetadata[groupId] ??= {}; @@ -984,12 +1087,33 @@ export class AccountTreeController extends BaseController< }; // Update tree node directly using efficient mapping - const walletId = this.#groupIdToWalletId.get(groupId); if (walletId) { state.accountTree.wallets[walletId].groups[groupId].metadata.hidden = hidden; } }); + + // Trigger atomic sync for group hiding (only for groups from entropy wallets) + if ( + walletId && + this.state.accountTree.wallets[walletId].type === + AccountWalletType.Entropy + ) { + this.#backupAndSyncService.enqueueSingleGroupSync(groupId); + } + } + + /** + * Clears the controller state and resets to default values. + * Also clears the backup and sync service state. + */ + clearState(): void { + this.update(() => { + return { + ...getDefaultAccountTreeControllerState(), + }; + }); + this.#backupAndSyncService.clearState(); } /** @@ -1031,4 +1155,52 @@ export class AccountTreeController extends BaseController< this.setAccountGroupHidden.bind(this), ); } + + /** + * Bi-directionally syncs the account tree with user storage. + * This will perform a full sync, including both pulling updates + * from user storage and pushing local changes to user storage. + * This also performs legacy account syncing if needed. + * + * IMPORTANT: + * If a full sync is already in progress, it will return the ongoing promise. + * + * @returns A promise that resolves when the sync is complete. + */ + async syncWithUserStorage(): Promise { + return this.#backupAndSyncService.performFullSync(); + } + + /** + * Bi-directionally syncs the account tree with user storage. + * This will ensure at least one full sync is ran, including both pulling updates + * from user storage and pushing local changes to user storage. + * This also performs legacy account syncing if needed. + * + * IMPORTANT: + * If the first ever full sync is already in progress, it will return the ongoing promise. + * If the first ever full sync was previously completed, it will NOT start a new sync, and will resolve immediately. + * + * @returns A promise that resolves when the first ever full sync is complete. + */ + async syncWithUserStorageAtLeastOnce(): Promise { + return this.#backupAndSyncService.performFullSyncAtLeastOnce(); + } + + /** + * Creates an backup and sync context for sync operations. + * Used by the backup and sync service. + * + * @returns The backup and sync context. + */ + #createBackupAndSyncContext(): BackupAndSyncContext { + return { + ...this.#backupAndSyncConfig, + controller: this, + messenger: this.messagingSystem, + controllerStateUpdateFn: this.update.bind(this), + traceFn: this.#trace.bind(this), + groupIdToWalletId: this.#groupIdToWalletId, + }; + } } diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/index.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/index.ts new file mode 100644 index 00000000000..33fa061b779 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './segment'; +export * from './traces'; diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts new file mode 100644 index 00000000000..cf39bf57b31 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/segment.test.ts @@ -0,0 +1,112 @@ +import { + BackupAndSyncAnalyticsEvent, + formatAnalyticsEvent, + type BackupAndSyncAnalyticsAction, + type BackupAndSyncEmitAnalyticsEventParams, + type BackupAndSyncAnalyticsEventPayload, +} from './segment'; + +describe('BackupAndSyncAnalytics - Segment', () => { + describe('BackupAndSyncAnalyticsEvents', () => { + it('contains all expected event names', () => { + expect(BackupAndSyncAnalyticsEvent).toStrictEqual({ + WalletRenamed: 'wallet_renamed', + GroupAdded: 'group_added', + GroupRenamed: 'group_renamed', + GroupHiddenStatusChanged: 'group_hidden_status_changed', + GroupPinnedStatusChanged: 'group_pinned_status_changed', + LegacySyncingDone: 'legacy_syncing_done', + LegacyGroupAddedFromAccount: 'legacy_group_added_from_account', + LegacyGroupRenamed: 'legacy_group_renamed', + }); + }); + }); + + describe('formatAnalyticsEvent', () => { + const mockProfileId = 'test-profile-id-123'; + + it('formats analytics event with required parameters', () => { + const params: BackupAndSyncEmitAnalyticsEventParams = { + action: BackupAndSyncAnalyticsEvent.WalletRenamed, + profileId: mockProfileId, + }; + + const result = formatAnalyticsEvent(params); + + const expected: BackupAndSyncAnalyticsEventPayload = { + feature_name: 'Multichain Account Syncing', + action: 'wallet_renamed', + profile_id: mockProfileId, + }; + + expect(result).toStrictEqual(expected); + }); + + it('formats analytics event with additional description', () => { + const additionalDescription = 'Wallet renamed from old to new'; + const params: BackupAndSyncEmitAnalyticsEventParams = { + action: BackupAndSyncAnalyticsEvent.GroupRenamed, + profileId: mockProfileId, + additionalDescription, + }; + + const result = formatAnalyticsEvent(params); + + expect(result).toStrictEqual({ + feature_name: 'Multichain Account Syncing', + action: 'group_renamed', + profile_id: mockProfileId, + additional_description: additionalDescription, + }); + }); + + it('handles all event types correctly', () => { + const eventTypes: BackupAndSyncAnalyticsAction[] = [ + BackupAndSyncAnalyticsEvent.WalletRenamed, + BackupAndSyncAnalyticsEvent.GroupAdded, + BackupAndSyncAnalyticsEvent.GroupRenamed, + BackupAndSyncAnalyticsEvent.GroupHiddenStatusChanged, + BackupAndSyncAnalyticsEvent.GroupPinnedStatusChanged, + BackupAndSyncAnalyticsEvent.LegacySyncingDone, + ]; + + eventTypes.forEach((action) => { + const params: BackupAndSyncEmitAnalyticsEventParams = { + action, + profileId: mockProfileId, + }; + + const result = formatAnalyticsEvent(params); + + expect(result).toStrictEqual({ + feature_name: 'Multichain Account Syncing', + action, + profile_id: mockProfileId, + }); + }); + }); + + it('handles empty additional description parameter', () => { + const params: BackupAndSyncEmitAnalyticsEventParams = { + action: BackupAndSyncAnalyticsEvent.GroupAdded, + profileId: mockProfileId, + additionalDescription: '', + }; + + const result = formatAnalyticsEvent(params); + + expect(result.additional_description).toBe(''); + }); + + it('always includes the same feature name', () => { + const params: BackupAndSyncEmitAnalyticsEventParams = { + action: BackupAndSyncAnalyticsEvent.LegacySyncingDone, + profileId: mockProfileId, + }; + + const result = formatAnalyticsEvent(params); + + expect(result.feature_name).toBe('Multichain Account Syncing'); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/segment.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/segment.ts new file mode 100644 index 00000000000..4f1e7502fcd --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/segment.ts @@ -0,0 +1,57 @@ +import type { ProfileId } from '../authentication'; + +export const BackupAndSyncAnalyticsEvent = { + WalletRenamed: 'wallet_renamed', + GroupAdded: 'group_added', + GroupRenamed: 'group_renamed', + GroupHiddenStatusChanged: 'group_hidden_status_changed', + GroupPinnedStatusChanged: 'group_pinned_status_changed', + LegacySyncingDone: 'legacy_syncing_done', + LegacyGroupAddedFromAccount: 'legacy_group_added_from_account', + LegacyGroupRenamed: 'legacy_group_renamed', +} as const; + +const BACKUP_AND_SYNC_EVENT_FEATURE_NAME = 'Multichain Account Syncing'; + +export type BackupAndSyncAnalyticsAction = + (typeof BackupAndSyncAnalyticsEvent)[keyof typeof BackupAndSyncAnalyticsEvent]; + +export type BackupAndSyncEmitAnalyticsEventParams = { + action: BackupAndSyncAnalyticsAction; + profileId: ProfileId; + additionalDescription?: string; +}; + +export type BackupAndSyncAnalyticsEventPayload = { + feature_name: typeof BACKUP_AND_SYNC_EVENT_FEATURE_NAME; + action: BackupAndSyncAnalyticsAction; + profile_id: ProfileId; + additional_description?: string; +}; + +/** + * Formats the analytics event payload to match the segment schema. + * + * @param params - The parameters for the analytics event. + * @param params.action - The action being performed. + * @param params.profileId - The profile ID associated with the event. + * @param params.additionalDescription - Optional additional description for the event. + * + * @returns The formatted event payload. + */ +export const formatAnalyticsEvent = ({ + action, + profileId, + additionalDescription, +}: BackupAndSyncEmitAnalyticsEventParams): BackupAndSyncAnalyticsEventPayload => { + return { + feature_name: BACKUP_AND_SYNC_EVENT_FEATURE_NAME, + action, + profile_id: profileId, + ...(additionalDescription !== undefined + ? { + additional_description: additionalDescription, + } + : {}), + }; +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts new file mode 100644 index 00000000000..791ccdd563d --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts @@ -0,0 +1,74 @@ +import type { TraceRequest } from '@metamask/controller-utils'; + +import { TraceName, traceFallback } from './traces'; + +describe('BackupAndSyncAnalytics - Traces', () => { + describe('TraceName', () => { + it('contains expected trace names', () => { + expect(TraceName).toStrictEqual({ + AccountSyncFull: 'Multichain Account Syncing - Full', + }); + }); + }); + + describe('traceFallback', () => { + let mockTraceRequest: TraceRequest; + + beforeEach(() => { + mockTraceRequest = { + name: TraceName.AccountSyncFull, + id: 'trace-id-123', + tags: {}, + }; + }); + + it('returns undefined when no function is provided', async () => { + const result = await traceFallback(mockTraceRequest); + + expect(result).toBeUndefined(); + }); + + it('executes the provided function and return its result', async () => { + const mockResult = 'test-result'; + const mockFn = jest.fn().mockReturnValue(mockResult); + + const result = await traceFallback(mockTraceRequest, mockFn); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(); + expect(result).toBe(mockResult); + }); + + it('executes async function and return its result', async () => { + const mockResult = { data: 'async-result' }; + const mockAsyncFn = jest.fn().mockResolvedValue(mockResult); + + const result = await traceFallback(mockTraceRequest, mockAsyncFn); + + expect(mockAsyncFn).toHaveBeenCalledTimes(1); + expect(result).toBe(mockResult); + }); + + it('handles function that throws an error', async () => { + const mockError = new Error('Test error'); + const mockFn = jest.fn().mockImplementation(() => { + throw mockError; + }); + + await expect(traceFallback(mockTraceRequest, mockFn)).rejects.toThrow( + mockError, + ); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('handles function that returns a rejected promise', async () => { + const mockError = new Error('Async error'); + const mockFn = jest.fn().mockRejectedValue(mockError); + + await expect(traceFallback(mockTraceRequest, mockFn)).rejects.toThrow( + mockError, + ); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts new file mode 100644 index 00000000000..7383fadebf5 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts @@ -0,0 +1,29 @@ +import type { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; + +export const TraceName = { + AccountSyncFull: 'Multichain Account Syncing - Full', +} as const; + +/** + * Fallback function for tracing. + * This function is used when no specific trace function is provided. + * It executes the provided function in a trace context if available. + * + * @param _request - The trace request containing additional data and context. + * @param fn - The function to execute within the trace context. + * @returns A promise that resolves to the result of the executed function. + * If no function is provided, it resolves to undefined. + */ +export const traceFallback: TraceCallback = async ( + _request: TraceRequest, + fn?: (context?: TraceContext) => ReturnType, +): Promise => { + if (!fn) { + return undefined as ReturnType; + } + return await Promise.resolve(fn()); +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/authentication/index.ts b/packages/account-tree-controller/src/backup-and-sync/authentication/index.ts new file mode 100644 index 00000000000..04bca77e0de --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/authentication/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/packages/account-tree-controller/src/backup-and-sync/authentication/utils.test.ts b/packages/account-tree-controller/src/backup-and-sync/authentication/utils.test.ts new file mode 100644 index 00000000000..3b42dc36e10 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/authentication/utils.test.ts @@ -0,0 +1,56 @@ +import { getProfileId } from './utils'; +import type { AccountTreeController } from '../../AccountTreeController'; +import type { BackupAndSyncContext } from '../types'; + +describe('BackupAndSyncAuthentication - Utils', () => { + describe('getProfileId', () => { + const mockMessenger = { + call: jest.fn(), + }; + + const mockContext: BackupAndSyncContext = { + messenger: mockMessenger as unknown as BackupAndSyncContext['messenger'], + controller: {} as AccountTreeController, + controllerStateUpdateFn: jest.fn(), + traceFn: jest.fn(), + groupIdToWalletId: new Map(), + emitAnalyticsEventFn: jest.fn(), + }; + + const mockEntropySourceId = 'entropy-123'; + const mockSessionProfile = { + profileId: 'test-profile-id-123', + identifierId: 'test-identifier-id', + metaMetricsId: 'test-metametrics-id', + }; + + it('calls AuthenticationController:getSessionProfile', async () => { + mockMessenger.call.mockResolvedValue(mockSessionProfile); + + const result1 = await getProfileId(mockContext); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AuthenticationController:getSessionProfile', + undefined, + ); + + const result2 = await getProfileId(mockContext, mockEntropySourceId); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AuthenticationController:getSessionProfile', + mockEntropySourceId, + ); + + expect(result1).toBe(mockSessionProfile.profileId); + expect(result2).toBe(mockSessionProfile.profileId); + }); + + it('returns undefined if AuthenticationController:getSessionProfile throws', async () => { + mockMessenger.call.mockRejectedValue(new Error('Test error')); + + const result = await getProfileId(mockContext); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/authentication/utils.ts b/packages/account-tree-controller/src/backup-and-sync/authentication/utils.ts new file mode 100644 index 00000000000..c380db605db --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/authentication/utils.ts @@ -0,0 +1,29 @@ +import type { SDK } from '@metamask/profile-sync-controller'; + +import { backupAndSyncLogger } from '../../logger'; +import type { BackupAndSyncContext } from '../types'; + +export type ProfileId = SDK.UserProfile['profileId'] | undefined; + +/** + * Retrieves the profile ID from AuthenticationController. + * + * @param context - The backup and sync context. + * @param entropySourceId - The optional entropy source ID. + * @returns The profile ID associated with the session, if available. + */ +export const getProfileId = async ( + context: BackupAndSyncContext, + entropySourceId?: string, +): Promise => { + try { + const sessionProfile = await context.messenger.call( + 'AuthenticationController:getSessionProfile', + entropySourceId, + ); + return sessionProfile.profileId; + } catch (error) { + backupAndSyncLogger(`Failed to retrieve profile ID:`, error); + return undefined; + } +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/service/atomic-sync-queue.test.ts b/packages/account-tree-controller/src/backup-and-sync/service/atomic-sync-queue.test.ts new file mode 100644 index 00000000000..6f6b0c5fe7c --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/service/atomic-sync-queue.test.ts @@ -0,0 +1,261 @@ +/* eslint-disable no-void */ +import { AtomicSyncQueue } from './atomic-sync-queue'; +import { backupAndSyncLogger } from '../../logger'; + +jest.mock('../../logger', () => ({ + backupAndSyncLogger: jest.fn(), +})); + +const mockBackupAndSyncLogger = backupAndSyncLogger as jest.MockedFunction< + typeof backupAndSyncLogger +>; + +describe('BackupAndSync - Service - AtomicSyncQueue', () => { + let atomicSyncQueue: AtomicSyncQueue; + + beforeEach(() => { + jest.clearAllMocks(); + atomicSyncQueue = new AtomicSyncQueue(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('constructor', () => { + it('initializes with default debug logging function', () => { + const queue = new AtomicSyncQueue(); + expect(queue.size).toBe(0); + expect(queue.isProcessing).toBe(false); + }); + + it('initializes with provided debug logging function', () => { + const queue = new AtomicSyncQueue(); + expect(queue.size).toBe(0); + expect(queue.isProcessing).toBe(false); + }); + }); + + describe('clearAndEnqueue', () => { + it('clears queue and enqueues new sync function', () => { + const mockSyncFunction1 = jest.fn().mockResolvedValue(undefined); + const mockSyncFunction2 = jest.fn().mockResolvedValue(undefined); + + // First enqueue some functions + void atomicSyncQueue.enqueue(mockSyncFunction1); + void atomicSyncQueue.enqueue(mockSyncFunction1); + expect(atomicSyncQueue.size).toBe(2); + + // Then clearAndEnqueue should clear existing and add new + void atomicSyncQueue.clearAndEnqueue(mockSyncFunction2); + expect(atomicSyncQueue.size).toBe(1); + }); + }); + + describe('enqueue', () => { + it('enqueues sync function when big sync is not in progress', () => { + const mockSyncFunction = jest.fn().mockResolvedValue(undefined); + + void atomicSyncQueue.enqueue(mockSyncFunction); + + expect(atomicSyncQueue.size).toBe(1); + }); + + it('triggers async processing after enqueueing', async () => { + jest.useFakeTimers(); + const mockSyncFunction = jest.fn().mockResolvedValue(undefined); + + void atomicSyncQueue.enqueue(mockSyncFunction); + + expect(atomicSyncQueue.size).toBe(1); + + // Fast-forward timers to trigger async processing + jest.advanceTimersByTime(1); + await Promise.resolve(); // Let promises resolve + + expect(mockSyncFunction).toHaveBeenCalled(); + expect(atomicSyncQueue.size).toBe(0); + }); + }); + + describe('process', () => { + it('processes queued sync functions', async () => { + const mockSyncFunction1 = jest.fn().mockResolvedValue(undefined); + const mockSyncFunction2 = jest.fn().mockResolvedValue(undefined); + + void atomicSyncQueue.enqueue(mockSyncFunction1); + void atomicSyncQueue.enqueue(mockSyncFunction2); + + await atomicSyncQueue.process(); + + expect(mockSyncFunction1).toHaveBeenCalled(); + expect(mockSyncFunction2).toHaveBeenCalled(); + expect(atomicSyncQueue.size).toBe(0); + }); + + it('does not process when already processing', async () => { + const mockSyncFunction = jest.fn().mockImplementation(async () => { + // While first function is processing, try to process again + await atomicSyncQueue.process(); + }); + + void atomicSyncQueue.enqueue(mockSyncFunction); + + await atomicSyncQueue.process(); + + expect(mockSyncFunction).toHaveBeenCalledTimes(1); + }); + + it('handles sync function errors gracefully', async () => { + const error = new Error('Sync function failed'); + const mockSyncFunction1 = jest.fn().mockRejectedValue(error); + const mockSyncFunction2 = jest.fn().mockResolvedValue(undefined); + + const promise1 = atomicSyncQueue.enqueue(mockSyncFunction1); + const promise2 = atomicSyncQueue.enqueue(mockSyncFunction2); + + await atomicSyncQueue.process(); + + expect(mockSyncFunction1).toHaveBeenCalled(); + expect(mockSyncFunction2).toHaveBeenCalled(); + expect(atomicSyncQueue.size).toBe(0); + + // Handle the rejected promises to avoid unhandled rejections + /* eslint-disable jest/no-restricted-matchers */ + await expect(promise1).rejects.toThrow('Sync function failed'); + await expect(promise2).resolves.toBeUndefined(); + /* eslint-enable jest/no-restricted-matchers */ + }); + + it('returns early when queue is empty', async () => { + await atomicSyncQueue.process(); + + expect(atomicSyncQueue.size).toBe(0); + expect(atomicSyncQueue.isProcessing).toBe(false); + }); + }); + + describe('clear', () => { + it('clears all queued sync events', () => { + const mockSyncFunction1 = jest.fn().mockResolvedValue(undefined); + const mockSyncFunction2 = jest.fn().mockResolvedValue(undefined); + + void atomicSyncQueue.enqueue(mockSyncFunction1); + void atomicSyncQueue.enqueue(mockSyncFunction2); + + expect(atomicSyncQueue.size).toBe(2); + + atomicSyncQueue.clear(); + + expect(atomicSyncQueue.size).toBe(0); + }); + }); + + describe('properties', () => { + it('returns correct queue size', () => { + expect(atomicSyncQueue.size).toBe(0); + + void atomicSyncQueue.enqueue(jest.fn()); + expect(atomicSyncQueue.size).toBe(1); + + void atomicSyncQueue.enqueue(jest.fn()); + expect(atomicSyncQueue.size).toBe(2); + }); + + it('returns correct processing status', async () => { + expect(atomicSyncQueue.isProcessing).toBe(false); + + const slowSyncFunction = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + void atomicSyncQueue.enqueue(slowSyncFunction); + + const processPromise = atomicSyncQueue.process(); + + // Should be processing now + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(atomicSyncQueue.isProcessing).toBe(true); + + await processPromise; + expect(atomicSyncQueue.isProcessing).toBe(false); + }); + + it('accesses size property correctly', () => { + // Create a fresh queue to test size property + const freshQueue = new AtomicSyncQueue(); + expect(freshQueue.size).toBe(0); + + // Add multiple items + void freshQueue.enqueue(jest.fn()); + void freshQueue.enqueue(jest.fn()); + void freshQueue.enqueue(jest.fn()); + + expect(freshQueue.size).toBe(3); + + // Clear and verify + freshQueue.clear(); + expect(freshQueue.size).toBe(0); + }); + }); + + describe('error handling in async processing', () => { + it('handles errors in async process call', async () => { + jest.useFakeTimers(); + + const error = new Error('Process error'); + jest.spyOn(atomicSyncQueue, 'process').mockRejectedValueOnce(error); + + const mockSyncFunction = jest.fn().mockResolvedValue(undefined); + void atomicSyncQueue.enqueue(mockSyncFunction); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + + expect(mockBackupAndSyncLogger).toHaveBeenCalledWith( + 'Error processing atomic sync queue:', + error, + ); + }); + + it('rejects promise when awaited sync function throws error', async () => { + const error = new Error('Sync function failed'); + const mockSyncFunction = jest.fn().mockRejectedValue(error); + + const promise = atomicSyncQueue.enqueue(mockSyncFunction); + + await expect(promise).rejects.toThrow('Sync function failed'); + expect(mockSyncFunction).toHaveBeenCalled(); + }); + + it('returns promise that resolves when sync function succeeds', async () => { + const mockSyncFunction = jest.fn().mockResolvedValue(undefined); + + const promise = atomicSyncQueue.enqueue(mockSyncFunction); + + /* eslint-disable jest/no-restricted-matchers */ + await expect(promise).resolves.toBeUndefined(); + /* eslint-enable jest/no-restricted-matchers */ + expect(mockSyncFunction).toHaveBeenCalled(); + }); + + it('handles empty queue after shift operation', async () => { + // Test the scenario where shift() might return undefined/null + // This can happen in race conditions or edge cases + const mockSyncFunction1 = jest.fn().mockResolvedValue(undefined); + const mockSyncFunction2 = jest.fn().mockResolvedValue(undefined); + + void atomicSyncQueue.enqueue(mockSyncFunction1); + void atomicSyncQueue.enqueue(mockSyncFunction2); + + // Process concurrently to potentially create race conditions + const promise1 = atomicSyncQueue.process(); + const promise2 = atomicSyncQueue.process(); + + await Promise.all([promise1, promise2]); + + expect(atomicSyncQueue.size).toBe(0); + expect(atomicSyncQueue.isProcessing).toBe(false); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/service/atomic-sync-queue.ts b/packages/account-tree-controller/src/backup-and-sync/service/atomic-sync-queue.ts new file mode 100644 index 00000000000..64b4147b2ea --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/service/atomic-sync-queue.ts @@ -0,0 +1,118 @@ +import { createDeferredPromise } from '@metamask/utils'; + +import { backupAndSyncLogger } from '../../logger'; +import type { AtomicSyncEvent } from '../types'; + +/** + * Manages atomic sync operations in a queue to prevent concurrent execution + * and ensure proper ordering of sync events. + */ +export class AtomicSyncQueue { + /** + * Queue for atomic sync events that need to be processed asynchronously. + */ + readonly #queue: AtomicSyncEvent[] = []; + + /** + * Flag to prevent multiple queue processing operations from running concurrently. + */ + #isProcessingInProgress = false; + + /** + * Clears the queue and enqueues a new sync function. + * + * @param syncFunction - The sync function to enqueue. + * @returns A Promise that resolves when the sync function completes. + */ + clearAndEnqueue(syncFunction: () => Promise): Promise { + this.clear(); + return this.enqueue(syncFunction); + } + + /** + * Enqueues an atomic sync function for processing. + * + * @param syncFunction - The sync function to enqueue. + * @returns A Promise that resolves when the sync function completes. + */ + enqueue(syncFunction: () => Promise): Promise { + const { promise, resolve, reject } = createDeferredPromise(); + + // Create the sync event with promise handlers + const syncEvent: AtomicSyncEvent = { + execute: async () => { + try { + await syncFunction(); + resolve?.(); + } catch (error) { + reject?.(error); + } + }, + }; + + // Add to queue and start processing + this.#queue.push(syncEvent); + setTimeout(() => { + this.process().catch((error) => { + backupAndSyncLogger('Error processing atomic sync queue:', error); + }); + }, 0); + + return promise; + } + + /** + * Processes the atomic sync queue. + */ + async process(): Promise { + if (this.#isProcessingInProgress) { + return; + } + + if (this.#queue.length === 0) { + return; + } + + this.#isProcessingInProgress = true; + + try { + while (this.#queue.length > 0) { + const event = this.#queue.shift(); + /* istanbul ignore next */ + if (!event) { + break; + } + + await event.execute(); + } + } finally { + this.#isProcessingInProgress = false; + } + } + + /** + * Clears all pending sync events from the queue. + * Useful when big sync starts to prevent stale updates. + */ + clear(): void { + this.#queue.length = 0; + } + + /** + * Gets the current queue size. + * + * @returns The number of pending sync events. + */ + get size(): number { + return this.#queue.length; + } + + /** + * Checks if queue processing is currently in progress. + * + * @returns True if processing is in progress. + */ + get isProcessing(): boolean { + return this.#isProcessingInProgress; + } +} diff --git a/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts b/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts new file mode 100644 index 00000000000..2f5a24dce66 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts @@ -0,0 +1,714 @@ +import { AccountWalletType } from '@metamask/account-api'; + +import { BackupAndSyncService } from '.'; +import type { AccountGroupObject } from '../../group'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import { getProfileId } from '../authentication'; +import type { BackupAndSyncContext } from '../types'; +// We only need to import the functions we actually spy on +import { getLocalEntropyWallets } from '../utils'; + +// Mock the sync functions and all external dependencies +jest.mock('../syncing'); +jest.mock('../authentication'); +jest.mock('../utils'); +jest.mock('../user-storage'); + +// Get typed mocks for the functions we want to spy on +const mockGetProfileId = getProfileId as jest.MockedFunction< + typeof getProfileId +>; +const mockGetLocalEntropyWallets = + getLocalEntropyWallets as jest.MockedFunction; + +describe('BackupAndSync - Service - BackupAndSyncService', () => { + let mockContext: BackupAndSyncContext; + let backupAndSyncService: BackupAndSyncService; + + const setupMockUserStorageControllerState = ( + isBackupAndSyncEnabled = true, + isAccountSyncingEnabled = true, + ) => { + (mockContext.messenger.call as jest.Mock).mockImplementation((action) => { + if (action === 'UserStorageController:getState') { + return { + isBackupAndSyncEnabled, + isAccountSyncingEnabled, + }; + } + return undefined; + }); + }; + + beforeEach(() => { + mockContext = { + controller: { + state: { + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: true, + accountTree: { + wallets: {}, + }, + }, + }, + controllerStateUpdateFn: jest.fn(), + messenger: { + call: jest.fn(), + }, + traceFn: jest.fn().mockImplementation((_config, fn) => fn()), + groupIdToWalletId: new Map(), + } as unknown as BackupAndSyncContext; + + // Default setup - backup and sync enabled + setupMockUserStorageControllerState(); + + // Setup default mock returns + mockGetLocalEntropyWallets.mockReturnValue([]); + mockGetProfileId.mockResolvedValue('test-profile-id'); + + backupAndSyncService = new BackupAndSyncService(mockContext); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('isInProgress getter', () => { + it('returns sync progress status', () => { + expect(backupAndSyncService.isInProgress).toBe(false); + + mockContext.controller.state.isAccountTreeSyncingInProgress = true; + expect(backupAndSyncService.isInProgress).toBe(true); + }); + }); + + describe('enqueueSingleWalletSync', () => { + it('returns early when backup and sync is disabled', () => { + setupMockUserStorageControllerState(false, true); + + // Method should return early without any side effects + backupAndSyncService.enqueueSingleWalletSync('entropy:wallet-1'); + + // Should not have called any messenger functions beyond the state check + expect(mockContext.messenger.call).toHaveBeenCalledTimes(1); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + }); + + it('returns early when account syncing is disabled', () => { + setupMockUserStorageControllerState(true, false); + + backupAndSyncService.enqueueSingleWalletSync('entropy:wallet-1'); + + // Should not have called any messenger functions beyond the state check + expect(mockContext.messenger.call).toHaveBeenCalledTimes(1); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + }); + + it('enqueues single wallet sync when enabled and synced at least once', async () => { + mockContext.controller.state.hasAccountTreeSyncingSyncedAtLeastOnce = + true; + + // Add a mock wallet to the context so the sync can find it + mockContext.controller.state.accountTree.wallets = { + 'entropy:wallet-1': { + id: 'entropy:wallet-1', + type: AccountWalletType.Entropy, + metadata: { + entropy: { id: 'test-entropy-id' }, + name: 'Test Wallet', + }, + groups: {}, + } as unknown as AccountWalletEntropyObject, + }; + + // This should enqueue a single wallet sync (not a full sync) + backupAndSyncService.enqueueSingleWalletSync('entropy:wallet-1'); + + // Wait a bit for the atomic queue to process + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should have checked the UserStorage state + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + + // Should NOT have called getLocalEntropyWallets (which is only called by full sync) + expect(mockGetLocalEntropyWallets).not.toHaveBeenCalled(); + + // Should have called the profile ID function for the single wallet sync + expect(mockGetProfileId).toHaveBeenCalledWith( + expect.anything(), + 'test-entropy-id', + ); + }); + + it('triggers full sync when never synced before', async () => { + mockContext.controller.state.hasAccountTreeSyncingSyncedAtLeastOnce = + false; + + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + backupAndSyncService.enqueueSingleWalletSync('entropy:wallet-1'); + + // Wait for the atomic queue to process the full sync + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should have checked the state + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + + // Should have triggered a full sync operation + expect(mockGetLocalEntropyWallets).toHaveBeenCalled(); + expect(mockGetProfileId).toHaveBeenCalled(); + }); + }); + + describe('enqueueSingleGroupSync', () => { + it('returns early when backup and sync is disabled', () => { + setupMockUserStorageControllerState(false, true); + + backupAndSyncService.enqueueSingleGroupSync('entropy:wallet-1/1'); + + // Should only have checked the sync state + expect(mockContext.messenger.call).toHaveBeenCalledTimes(1); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + }); + + it('returns early when account syncing is disabled', () => { + setupMockUserStorageControllerState(true, false); + + backupAndSyncService.enqueueSingleGroupSync('entropy:wallet-1/1'); + + // Should only have checked the sync state + expect(mockContext.messenger.call).toHaveBeenCalledTimes(1); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + }); + + it('enqueues group sync when enabled and synced at least once', async () => { + mockContext.controller.state.hasAccountTreeSyncingSyncedAtLeastOnce = + true; + + // Set up the group mapping and wallet context + mockContext.groupIdToWalletId.set( + 'entropy:wallet-1/1', + 'entropy:wallet-1', + ); + mockContext.controller.state.accountTree.wallets = { + 'entropy:wallet-1': { + id: 'entropy:wallet-1', + type: AccountWalletType.Entropy, + metadata: { + entropy: { id: 'test-entropy-id' }, + name: 'Test Wallet', + }, + groups: { + 'entropy:wallet-1/1': { + id: 'entropy:wallet-1/1', + name: 'Test Group', + metadata: { + entropy: { groupIndex: 1 }, + }, + } as unknown as AccountGroupObject, + }, + } as unknown as AccountWalletEntropyObject, + }; + + // This should enqueue a single group sync (not a full sync) + backupAndSyncService.enqueueSingleGroupSync('entropy:wallet-1/1'); + + // Wait for the atomic queue to process + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should have checked the UserStorage state + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + + // Should NOT have called getLocalEntropyWallets (which is only called by full sync) + expect(mockGetLocalEntropyWallets).not.toHaveBeenCalled(); + + // Should have called getProfileId as part of group sync + expect(mockGetProfileId).toHaveBeenCalled(); + }); + + it('triggers full sync when never synced before', async () => { + mockContext.controller.state.hasAccountTreeSyncingSyncedAtLeastOnce = + false; + + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + backupAndSyncService.enqueueSingleGroupSync('entropy:wallet-1/1'); + + // Wait for the atomic queue to process the full sync + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should have checked the state + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + + // Should have triggered a full sync operation instead of group sync + expect(mockGetLocalEntropyWallets).toHaveBeenCalled(); + expect(mockGetProfileId).toHaveBeenCalled(); + }); + }); + + describe('performFullSync', () => { + it('returns early when sync is already in progress', async () => { + mockContext.controller.state.isAccountTreeSyncingInProgress = true; + + const result = await backupAndSyncService.performFullSync(); + + // Should return undefined when skipping + expect(result).toBeUndefined(); + + // Should only have checked the backup/sync state, not updated controller state + expect(mockContext.messenger.call).toHaveBeenCalledTimes(1); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + expect(mockContext.controllerStateUpdateFn).not.toHaveBeenCalled(); + }); + + it('returns early when backup and sync is disabled', async () => { + setupMockUserStorageControllerState(false, true); + + const result = await backupAndSyncService.performFullSync(); + + // Should return undefined when disabled + expect(result).toBeUndefined(); + + // Should only have checked the sync state + expect(mockContext.messenger.call).toHaveBeenCalledTimes(1); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + expect(mockContext.controllerStateUpdateFn).not.toHaveBeenCalled(); + }); + + it('executes full sync when enabled', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + await backupAndSyncService.performFullSync(); + + // Should have checked the backup/sync state + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + + // Should have updated controller state to mark sync in progress and then completed + expect(mockContext.controllerStateUpdateFn).toHaveBeenCalled(); + + // Should have called traceFn to wrap the sync operation + expect(mockContext.traceFn).toHaveBeenCalled(); + + // The key difference: full sync should call getLocalEntropyWallets + expect(mockGetLocalEntropyWallets).toHaveBeenCalled(); + }); + + it('awaits the ongoing promise if a second call is made during sync', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Make traceFn actually async to simulate real sync work + let resolveTrace: (() => void) | undefined; + const tracePromise = new Promise((resolve) => { + resolveTrace = resolve; + }); + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + fn(); + return tracePromise; + }, + ); + + // Start first sync + const firstSyncPromise = backupAndSyncService.performFullSync(); + + // Start second sync immediately (while first is still running) + const secondSyncPromise = backupAndSyncService.performFullSync(); + + // Both promises should be the same reference + expect(firstSyncPromise).toStrictEqual(secondSyncPromise); + + // Resolve the trace to complete the sync + resolveTrace?.(); + + // Both should resolve to the same value + const [firstResult, secondResult] = await Promise.all([ + firstSyncPromise, + secondSyncPromise, + ]); + expect(firstResult).toStrictEqual(secondResult); + + // getLocalEntropyWallets should only be called once (not twice) + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(1); + }); + + it('does not start two full syncs if called in rapid succession', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track how many times the actual sync logic runs + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Fire multiple syncs rapidly + const promises = [ + backupAndSyncService.performFullSync(), + backupAndSyncService.performFullSync(), + backupAndSyncService.performFullSync(), + ]; + + // All promises should be the same reference (promise caching) + expect(promises[0]).toStrictEqual(promises[1]); + expect(promises[1]).toStrictEqual(promises[2]); + + // Wait for all to complete + await Promise.all(promises); + + // Should only have executed the sync logic once + expect(syncExecutionCount).toBe(1); + + // getLocalEntropyWallets should only be called once + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(1); + + // All promises should resolve successfully to the same value + const results = await Promise.all(promises); + expect(results[0]).toStrictEqual(results[1]); + expect(results[1]).toStrictEqual(results[2]); + }); + + it('creates a new promise for subsequent calls after the first sync completes', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track how many times the actual sync logic runs + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Start first sync and wait for it to complete + const firstSyncPromise = backupAndSyncService.performFullSync(); + await firstSyncPromise; + + // Start second sync after first one is complete + const secondSyncPromise = backupAndSyncService.performFullSync(); + + // Promises should be different (first one was cleaned up) + expect(firstSyncPromise).not.toBe(secondSyncPromise); + + // Wait for second sync to complete + await secondSyncPromise; + + // Should have executed the sync logic twice (once for each call) + expect(syncExecutionCount).toBe(2); + + // getLocalEntropyWallets should be called twice (once for each sync) + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(2); + + // Both promises should resolve successfully + expect(await firstSyncPromise).toBeUndefined(); + expect(await secondSyncPromise).toBeUndefined(); + }); + + it('sets first ever ongoing promise correctly', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track sync execution + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Perform first sync + const firstSyncPromise = backupAndSyncService.performFullSync(); + + // Call performFullSyncAtLeastOnce while first sync is ongoing + const atLeastOncePromise = + backupAndSyncService.performFullSyncAtLeastOnce(); + + // Both should resolve to the same promise (first sync sets the first ever promise) + expect(firstSyncPromise).toStrictEqual(atLeastOncePromise); + + await Promise.all([firstSyncPromise, atLeastOncePromise]); + + // Should only have executed once + expect(syncExecutionCount).toBe(1); + }); + }); + + describe('performFullSyncAtLeastOnce', () => { + beforeEach(() => { + setupMockUserStorageControllerState(true, true); + // Clear all mocks before each test + jest.clearAllMocks(); + mockGetLocalEntropyWallets.mockClear(); + }); + + it('returns undefined when backup and sync is disabled', async () => { + setupMockUserStorageControllerState(true, false); + + const result = await backupAndSyncService.performFullSyncAtLeastOnce(); + + expect(result).toBeUndefined(); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:getState', + ); + }); + + it('creates and returns first sync promise when called for the first time', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track sync execution + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + const syncPromise = backupAndSyncService.performFullSyncAtLeastOnce(); + + expect(syncPromise).toBeInstanceOf(Promise); + + await syncPromise; + + expect(syncExecutionCount).toBe(1); + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(1); + }); + + it('returns same promise for concurrent calls during first sync', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track sync execution + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Fire multiple calls rapidly + const promises = [ + backupAndSyncService.performFullSyncAtLeastOnce(), + backupAndSyncService.performFullSyncAtLeastOnce(), + backupAndSyncService.performFullSyncAtLeastOnce(), + ]; + + // All promises should be the same reference (promise caching) + expect(promises[0]).toStrictEqual(promises[1]); + expect(promises[1]).toStrictEqual(promises[2]); + + // Wait for all to complete + await Promise.all(promises); + + // Should only have executed the sync logic once + expect(syncExecutionCount).toBe(1); + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(1); + + // All promises should resolve successfully to the same value + const results = await Promise.all(promises); + expect(results[0]).toStrictEqual(results[1]); + expect(results[1]).toStrictEqual(results[2]); + }); + + it('returns same completed promise for calls after first sync completes', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track sync execution + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Start first sync and wait for it to complete + const firstSyncPromise = + backupAndSyncService.performFullSyncAtLeastOnce(); + await firstSyncPromise; + + // Start second call after first one is complete + const secondSyncPromise = + backupAndSyncService.performFullSyncAtLeastOnce(); + + // Should return the same promise (cached first sync promise) + expect(firstSyncPromise).toStrictEqual(secondSyncPromise); + + // Wait for second promise (should resolve immediately since it's already complete) + await secondSyncPromise; + + // Should only have executed the sync logic once (no new sync created) + expect(syncExecutionCount).toBe(1); + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(1); + + // Both promises should resolve successfully + expect(await firstSyncPromise).toBeUndefined(); + expect(await secondSyncPromise).toBeUndefined(); + }); + + it('does not create new syncs after first sync completes', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track sync execution + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Multiple sequential calls + await backupAndSyncService.performFullSyncAtLeastOnce(); + await backupAndSyncService.performFullSyncAtLeastOnce(); + await backupAndSyncService.performFullSyncAtLeastOnce(); + + // Should only have executed once, regardless of how many times it's called + expect(syncExecutionCount).toBe(1); + expect(mockGetLocalEntropyWallets).toHaveBeenCalledTimes(1); + }); + + it('interacts correctly with performFullSync', async () => { + // Mock some local wallets for the full sync to process + mockGetLocalEntropyWallets.mockReturnValue([ + { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject, + ]); + + // Track sync execution + let syncExecutionCount = 0; + (mockContext.traceFn as jest.Mock).mockImplementation( + (_: unknown, fn: () => unknown) => { + syncExecutionCount += 1; + return fn(); + }, + ); + + // Call performFullSyncAtLeastOnce first + const atLeastOncePromise = + backupAndSyncService.performFullSyncAtLeastOnce(); + + // Then call performFullSync while first is ongoing + const fullSyncPromise = backupAndSyncService.performFullSync(); + + // They should return the same promise (both use the first sync promise) + expect(atLeastOncePromise).toStrictEqual(fullSyncPromise); + + await Promise.all([atLeastOncePromise, fullSyncPromise]); + + // Should only have executed once + expect(syncExecutionCount).toBe(1); + + // Now call performFullSync again after completion + const secondFullSyncPromise = backupAndSyncService.performFullSync(); + + // This should be different from the first (new sync created) + expect(secondFullSyncPromise).not.toBe(fullSyncPromise); + + await secondFullSyncPromise; + + // Should have executed twice now (one for each performFullSync call) + expect(syncExecutionCount).toBe(2); + + // But performFullSyncAtLeastOnce should still return the original promise + const laterAtLeastOncePromise = + backupAndSyncService.performFullSyncAtLeastOnce(); + expect(laterAtLeastOncePromise).toStrictEqual(atLeastOncePromise); + + // And should not trigger another sync + await laterAtLeastOncePromise; + expect(syncExecutionCount).toBe(2); // Still only 2 + }, 15000); // Increase timeout to 15 seconds + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/service/index.ts b/packages/account-tree-controller/src/backup-and-sync/service/index.ts new file mode 100644 index 00000000000..6c8077fe512 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/service/index.ts @@ -0,0 +1,549 @@ +import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; +import { AccountWalletType } from '@metamask/account-api'; +import type { UserStorageController } from '@metamask/profile-sync-controller'; + +import { AtomicSyncQueue } from './atomic-sync-queue'; +import { backupAndSyncLogger } from '../../logger'; +import type { AccountTreeControllerState } from '../../types'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import { TraceName } from '../analytics'; +import type { ProfileId } from '../authentication'; +import { getProfileId } from '../authentication'; +import { + createLocalGroupsFromUserStorage, + performLegacyAccountSyncing, + syncGroupsMetadata, + syncGroupMetadata, + syncWalletMetadata, +} from '../syncing'; +import type { + BackupAndSyncContext, + UserStorageSyncedWallet, + UserStorageSyncedWalletGroup, +} from '../types'; +import { + getAllGroupsFromUserStorage, + getGroupFromUserStorage, + getWalletFromUserStorage, + pushGroupToUserStorageBatch, +} from '../user-storage'; +import { + createStateSnapshot, + restoreStateFromSnapshot, + getLocalEntropyWallets, + getLocalGroupsForEntropyWallet, +} from '../utils'; +import type { StateSnapshot } from '../utils'; + +/** + * Service responsible for managing all backup and sync operations. + * + * This service handles: + * - Full sync operations + * - Single item sync operations + * - Sync queue management + * - Sync state management + */ +export class BackupAndSyncService { + readonly #context: BackupAndSyncContext; + + /** + * Queue manager for atomic sync operations. + */ + readonly #atomicSyncQueue: AtomicSyncQueue; + + /** + * Cached promise for ongoing full sync operations. + * Ensures multiple callers await the same sync operation. + */ + #ongoingFullSyncPromise: Promise | null = null; + + /** + * Cached promise for the first ongoing full sync operation. + * Ensures multiple callers await the same sync operation. + */ + #firstOngoingFullSyncPromise: Promise | null = null; + + constructor(context: BackupAndSyncContext) { + this.#context = context; + this.#atomicSyncQueue = new AtomicSyncQueue(); + } + + /** + * Checks if syncing is currently in progress. + * + * @returns True if syncing is in progress. + */ + get isInProgress(): boolean { + return this.#context.controller.state.isAccountTreeSyncingInProgress; + } + + /** + * Checks if the account tree has been synced at least once. + * + * @returns True if the account tree has been synced at least once. + */ + get hasSyncedAtLeastOnce(): boolean { + return this.#context.controller.state + .hasAccountTreeSyncingSyncedAtLeastOnce; + } + + /** + * Checks if backup and sync is enabled by checking UserStorageController state. + * + * @returns True if backup and sync + account syncing is enabled. + */ + get isBackupAndSyncEnabled(): boolean { + const userStorageControllerState = this.#context.messenger.call( + 'UserStorageController:getState', + ); + const { isAccountSyncingEnabled, isBackupAndSyncEnabled } = + userStorageControllerState; + + return isBackupAndSyncEnabled && isAccountSyncingEnabled; + } + + /** + * Clears the atomic queue and resets ongoing operations. + */ + clearState(): void { + this.#atomicSyncQueue.clear(); + this.#ongoingFullSyncPromise = null; + this.#firstOngoingFullSyncPromise = null; + } + + /** + * Handles changes to the user storage state. + * Used to clear the backup and sync service state. + * + * @param state - The new user storage state. + */ + handleUserStorageStateChange( + state: UserStorageController.UserStorageControllerState, + ): void { + if (!state.isAccountSyncingEnabled || !state.isBackupAndSyncEnabled) { + // If either syncing is disabled, clear the account tree state + this.clearState(); + } + } + + /** + * Gets the entropy wallet associated with the given wallet ID. + * + * @param walletId - The wallet ID to look up. + * @returns The associated entropy wallet, or undefined if not found. + */ + #getEntropyWallet( + walletId: AccountWalletId, + ): AccountWalletEntropyObject | undefined { + const wallet = this.#context.controller.state.accountTree.wallets[walletId]; + return wallet?.type === AccountWalletType.Entropy ? wallet : undefined; + } + + /** + * Sets up cleanup for ongoing sync promise tracking without affecting error propagation. + * + * @param promise - The promise to track and clean up + * @returns The same promise (for chaining) + */ + #setupOngoingPromiseCleanup(promise: Promise): Promise { + this.#ongoingFullSyncPromise = promise; + // Set up cleanup without affecting the returned promise + promise + .finally(() => { + this.#ongoingFullSyncPromise = null; + }) + .catch(() => { + // Only ignore errors from the cleanup operation itself + // The original promise errors are still propagated to callers + }); + return promise; + } + + /** + * Enqueues a single wallet sync operation (fire-and-forget). + * + * @param walletId - The wallet ID to sync. + */ + enqueueSingleWalletSync(walletId: AccountWalletId): void { + if (!this.isBackupAndSyncEnabled) { + return; + } + + if (!this.hasSyncedAtLeastOnce) { + // Run big sync + // eslint-disable-next-line no-void + void this.performFullSync(); + return; + } + // eslint-disable-next-line no-void + void this.#atomicSyncQueue.enqueue(() => + this.#performSingleWalletSyncInner(walletId), + ); + } + + /** + * Enqueues a single group sync operation (fire-and-forget). + * + * @param groupId - The group ID to sync. + */ + enqueueSingleGroupSync(groupId: AccountGroupId): void { + if (!this.isBackupAndSyncEnabled) { + return; + } + + if (!this.hasSyncedAtLeastOnce) { + // Run big sync + // eslint-disable-next-line no-void + void this.performFullSync(); + return; + } + + // eslint-disable-next-line no-void + void this.#atomicSyncQueue.enqueue(() => + this.#performSingleGroupSyncInner(groupId), + ); + } + + /** + * Performs a full synchronization of the local account tree with user storage, ensuring consistency + * between local state and cloud-stored account data. + * If a full sync is already in progress, it will return the ongoing promise. + * This clears the atomic sync queue before starting the full sync. + * + * NOTE: in some very edge cases, this can be ran concurrently if triggered quickly after + * toggling back and forth the backup and sync feature from the UI. + * + * @returns A promise that resolves when the sync is complete. + */ + async performFullSync(): Promise { + if (!this.isBackupAndSyncEnabled) { + return undefined; + } + + // If there's an ongoing sync (including first sync), return it + if (this.#ongoingFullSyncPromise) { + return this.#ongoingFullSyncPromise; + } + + // Create a new ongoing sync (sequential calls after previous completed) + const newSyncPromise = this.#atomicSyncQueue.clearAndEnqueue(() => + this.#performFullSyncInner(), + ); + + // First sync setup - create and cache the first sync promise + if (!this.#firstOngoingFullSyncPromise) { + this.#firstOngoingFullSyncPromise = newSyncPromise; + } + + return this.#setupOngoingPromiseCleanup(newSyncPromise); + } + + /** + * Performs a full synchronization of the local account tree with user storage, ensuring consistency + * between local state and cloud-stored account data. + * + * If the first ever full sync is already in progress, it will return the ongoing promise. + * If the first ever full sync has already completed, it will resolve and NOT start a new sync. + * + * This clears the atomic sync queue before starting the full sync. + * + * @returns A promise that resolves when the sync is complete. + */ + async performFullSyncAtLeastOnce(): Promise { + if (!this.isBackupAndSyncEnabled) { + return undefined; + } + + if (!this.#firstOngoingFullSyncPromise) { + this.#firstOngoingFullSyncPromise = this.#atomicSyncQueue.clearAndEnqueue( + () => this.#performFullSyncInner(), + ); + // eslint-disable-next-line no-void + void this.#setupOngoingPromiseCleanup(this.#firstOngoingFullSyncPromise); + } + + return this.#firstOngoingFullSyncPromise; + } + + /** + * Performs a full synchronization of the local account tree with user storage, ensuring consistency + * between local state and cloud-stored account data. + * + * This method performs a comprehensive sync operation that: + * 1. Identifies all local entropy wallets that can be synchronized + * 2. Performs legacy account syncing if needed (for backwards compatibility) + * - Disables subsequent legacy syncing by setting a flag in user storage + * - Exits early if multichain account syncing is disabled after legacy sync + * 3. Executes multichain account syncing for each wallet: + * - Syncs wallet metadata bidirectionally + * - Creates missing local groups from user storage data (or pushes local groups if none exist remotely) + * - Refreshes local state to reflect newly created groups + * - Syncs group metadata bidirectionally + * + * The sync is atomic per wallet with rollback on errors, but continues processing other wallets + * if individual wallet sync fails. A global lock prevents concurrent sync operations. + * + * During this process, all other atomic multichain related user storage updates are blocked. + * + * @throws Will throw if the sync operation encounters unrecoverable errors + */ + async #performFullSyncInner(): Promise { + // Prevent multiple syncs from running at the same time. + // Also prevents atomic updates from being applied while syncing is in progress. + if (this.isInProgress) { + return; + } + + // Set isAccountTreeSyncingInProgress immediately to prevent race conditions + this.#context.controllerStateUpdateFn( + (state: AccountTreeControllerState) => { + state.isAccountTreeSyncingInProgress = true; + }, + ); + + // Encapsulate the sync logic in a function to allow tracing + const bigSyncFn = async () => { + try { + // 1. Identifies all local entropy wallets that can be synchronized + const localSyncableWallets = getLocalEntropyWallets(this.#context); + + if (!localSyncableWallets.length) { + // No wallets to sync, just return. This shouldn't happen. + return; + } + + // 2. Iterate over each local wallet + for (const wallet of localSyncableWallets) { + const entropySourceId = wallet.metadata.entropy.id; + + let walletProfileId: ProfileId; + let walletFromUserStorage: UserStorageSyncedWallet | null; + let groupsFromUserStorage: UserStorageSyncedWalletGroup[]; + + try { + walletProfileId = await getProfileId( + this.#context, + entropySourceId, + ); + + [walletFromUserStorage, groupsFromUserStorage] = await Promise.all([ + getWalletFromUserStorage(this.#context, entropySourceId), + getAllGroupsFromUserStorage(this.#context, entropySourceId), + ]); + + // 2.1 Decide if we need to perform legacy account syncing + if ( + !walletFromUserStorage || + !walletFromUserStorage.isLegacyAccountSyncingDisabled + ) { + // 2.2 Perform legacy account syncing + // This will migrate legacy account data to the new structure. + // This operation will only be performed once. + await performLegacyAccountSyncing( + this.#context, + entropySourceId, + walletProfileId, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorString = `Legacy syncing failed for wallet ${wallet.id}: ${errorMessage}`; + + backupAndSyncLogger(errorString); + throw new Error(errorString); + } + + // 3. Execute multichain account syncing + let stateSnapshot: StateSnapshot | undefined; + + try { + // 3.1 Wallet syncing + // Create a state snapshot before processing each wallet for potential rollback + stateSnapshot = createStateSnapshot(this.#context); + + // Sync wallet metadata bidirectionally + await syncWalletMetadata( + this.#context, + wallet, + walletFromUserStorage, + walletProfileId, + ); + + // 3.2 Groups syncing + // If groups data does not exist in user storage yet, create it + if (!groupsFromUserStorage.length) { + // If no groups exist in user storage, we can push all groups from the wallet to the user storage and exit + await pushGroupToUserStorageBatch( + this.#context, + getLocalGroupsForEntropyWallet(this.#context, wallet.id), + entropySourceId, + ); + + continue; // No need to proceed with metadata comparison if groups are new + } + + // Create local groups for each group from user storage if they do not exist + // This will ensure that we have all groups available locally before syncing metadata + await createLocalGroupsFromUserStorage( + this.#context, + groupsFromUserStorage, + entropySourceId, + walletProfileId, + ); + + // Sync group metadata bidirectionally + await syncGroupsMetadata( + this.#context, + wallet, + groupsFromUserStorage, + entropySourceId, + walletProfileId, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorString = `Error during multichain account syncing for wallet ${wallet.id}: ${errorMessage}`; + + backupAndSyncLogger(errorString); + + // Attempt to rollback state changes for this wallet + try { + if (!stateSnapshot) { + throw new Error( + `State snapshot is missing for wallet ${wallet.id}`, + ); + } + restoreStateFromSnapshot(this.#context, stateSnapshot); + backupAndSyncLogger( + `Rolled back state changes for wallet ${wallet.id}`, + ); + } catch (rollbackError) { + backupAndSyncLogger( + `Failed to rollback state for wallet ${wallet.id}:`, + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + ); + } + + // Continue with next wallet instead of failing the entire sync + continue; + } + } + } catch (error) { + backupAndSyncLogger('Error during multichain account syncing:', error); + throw error; + } + + this.#context.controllerStateUpdateFn((state) => { + state.hasAccountTreeSyncingSyncedAtLeastOnce = true; + }); + }; + + // Execute the big sync function with tracing and ensure state cleanup + try { + await this.#context.traceFn( + { + name: TraceName.AccountSyncFull, + }, + bigSyncFn, + ); + } finally { + // Always reset state, regardless of success or failure + this.#context.controllerStateUpdateFn( + (state: AccountTreeControllerState) => { + state.isAccountTreeSyncingInProgress = false; + }, + ); + } + } + + /** + * Performs a single wallet's bidirectional metadata sync with user storage. + * + * @param walletId - The wallet ID to sync. + */ + async #performSingleWalletSyncInner( + walletId: AccountWalletId, + ): Promise { + try { + const wallet = this.#getEntropyWallet(walletId); + if (!wallet) { + return; // Only sync entropy wallets + } + + const entropySourceId = wallet.metadata.entropy.id; + const walletProfileId = await getProfileId( + this.#context, + entropySourceId, + ); + const walletFromUserStorage = await getWalletFromUserStorage( + this.#context, + entropySourceId, + ); + + await syncWalletMetadata( + this.#context, + wallet, + walletFromUserStorage, + walletProfileId, + ); + } catch (error) { + backupAndSyncLogger( + `Error in single wallet sync for ${walletId}:`, + error, + ); + throw error; + } + } + + /** + * Performs a single group's bidirectional metadata sync with user storage. + * + * @param groupId - The group ID to sync. + */ + async #performSingleGroupSyncInner(groupId: AccountGroupId): Promise { + try { + const walletId = this.#context.groupIdToWalletId.get(groupId); + if (!walletId) { + return; + } + + const wallet = this.#getEntropyWallet(walletId); + if (!wallet) { + return; // Only sync entropy wallets + } + + const group = wallet.groups[groupId]; + if (!group) { + return; + } + + const entropySourceId = wallet.metadata.entropy.id; + const walletProfileId = await getProfileId( + this.#context, + entropySourceId, + ); + + // Get the specific group from user storage + const groupFromUserStorage = await getGroupFromUserStorage( + this.#context, + entropySourceId, + group.metadata.entropy.groupIndex, + ); + + await syncGroupMetadata( + this.#context, + group, + groupFromUserStorage, + entropySourceId, + walletProfileId, + ); + } catch (error) { + backupAndSyncLogger(`Error in single group sync for ${groupId}:`, error); + throw error; + } + } +} diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/group.test.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/group.test.ts new file mode 100644 index 00000000000..a3429fd2641 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/group.test.ts @@ -0,0 +1,605 @@ +import { + createLocalGroupsFromUserStorage, + syncGroupMetadata, + syncGroupsMetadata, +} from './group'; +import * as metadataExports from './metadata'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { + BackupAndSyncContext, + UserStorageSyncedWalletGroup, +} from '../types'; +import { + pushGroupToUserStorage, + pushGroupToUserStorageBatch, +} from '../user-storage/network-operations'; +import { getLocalGroupsForEntropyWallet } from '../utils'; + +jest.mock('./metadata'); +jest.mock('../user-storage/network-operations'); +jest.mock('../utils', () => ({ + getLocalGroupsForEntropyWallet: jest.fn(), +})); + +jest.mock('../../logger', () => ({ + backupAndSyncLogger: jest.fn(), +})); + +const mockCompareAndSyncMetadata = + metadataExports.compareAndSyncMetadata as jest.MockedFunction< + typeof metadataExports.compareAndSyncMetadata + >; +const mockPushGroupToUserStorage = + pushGroupToUserStorage as jest.MockedFunction; +const mockPushGroupToUserStorageBatch = + pushGroupToUserStorageBatch as jest.MockedFunction< + typeof pushGroupToUserStorageBatch + >; +const mockGetLocalGroupsForEntropyWallet = + getLocalGroupsForEntropyWallet as jest.MockedFunction< + typeof getLocalGroupsForEntropyWallet + >; + +describe('BackupAndSync - Syncing - Group', () => { + let mockContext: BackupAndSyncContext; + let mockLocalGroup: AccountGroupMultichainAccountObject; + let mockWallet: AccountWalletEntropyObject; + + beforeEach(() => { + mockContext = { + controller: { + state: { + accountTree: { + wallets: { + 'entropy:test-entropy': { + groups: {}, + }, + }, + }, + accountGroupsMetadata: {}, + }, + setAccountGroupName: jest.fn(), + setAccountGroupPinned: jest.fn(), + setAccountGroupHidden: jest.fn(), + }, + messenger: { + call: jest.fn(), + }, + emitAnalyticsEventFn: jest.fn(), + } as unknown as BackupAndSyncContext; + + mockLocalGroup = { + id: 'entropy:test-entropy/0', + name: 'Test Group', + metadata: { entropy: { groupIndex: 0 } }, + } as unknown as AccountGroupMultichainAccountObject; + + mockWallet = { + id: 'entropy:test-entropy', + name: 'Test Wallet', + } as unknown as AccountWalletEntropyObject; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createLocalGroupsFromUserStorage', () => { + it('creates groups up until the highest groupIndex from user storage', async () => { + const unsortedGroups: UserStorageSyncedWalletGroup[] = [ + { groupIndex: 4 }, + { groupIndex: 1 }, + ]; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(undefined); + + await createLocalGroupsFromUserStorage( + mockContext, + unsortedGroups, + 'test-entropy', + 'test-profile', + ); + + expect(mockContext.messenger.call).toHaveBeenCalledTimes(5); + expect(mockContext.messenger.call).toHaveBeenNthCalledWith( + 1, + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: 'test-entropy', groupIndex: 0 }, + ); + expect(mockContext.messenger.call).toHaveBeenNthCalledWith( + 2, + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: 'test-entropy', groupIndex: 1 }, + ); + expect(mockContext.messenger.call).toHaveBeenNthCalledWith( + 3, + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: 'test-entropy', groupIndex: 2 }, + ); + expect(mockContext.messenger.call).toHaveBeenNthCalledWith( + 4, + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: 'test-entropy', groupIndex: 3 }, + ); + expect(mockContext.messenger.call).toHaveBeenNthCalledWith( + 5, + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: 'test-entropy', groupIndex: 4 }, + ); + expect(mockContext.messenger.call).not.toHaveBeenNthCalledWith( + 6, + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: 'test-entropy', groupIndex: 5 }, + ); + }); + + it('continues on creation errors', async () => { + const groups: UserStorageSyncedWalletGroup[] = [ + { groupIndex: 0 }, + { groupIndex: 1 }, + ]; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockRejectedValueOnce(new Error('Creation failed')) + .mockResolvedValueOnce(undefined); + + await createLocalGroupsFromUserStorage( + mockContext, + groups, + 'test-entropy', + 'test-profile', + ); + + expect(mockContext.messenger.call).toHaveBeenCalledTimes(2); + expect(mockContext.emitAnalyticsEventFn).toHaveBeenCalledTimes(1); + }); + + it('emits analytics events for successful creations', async () => { + const groups: UserStorageSyncedWalletGroup[] = [{ groupIndex: 0 }]; + + await createLocalGroupsFromUserStorage( + mockContext, + groups, + 'test-entropy', + 'test-profile', + ); + + expect(mockContext.emitAnalyticsEventFn).toHaveBeenCalledWith({ + action: BackupAndSyncAnalyticsEvent.GroupAdded, + profileId: 'test-profile', + }); + }); + }); + + describe('syncGroupMetadata', () => { + it('pushes group when sync check returns true', async () => { + mockContext.controller.state.accountGroupsMetadata[mockLocalGroup.id] = { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + mockCompareAndSyncMetadata.mockResolvedValue(true); + + await syncGroupMetadata( + mockContext, + mockLocalGroup, + { + name: { value: 'Remote Name', lastUpdatedAt: 2000 }, + } as unknown as UserStorageSyncedWalletGroup, + 'test-entropy', + 'test-profile', + ); + + expect(mockPushGroupToUserStorage).toHaveBeenCalledWith( + mockContext, + mockLocalGroup, + 'test-entropy', + ); + }); + + it('does not push group when sync check returns false', async () => { + mockCompareAndSyncMetadata.mockResolvedValue(false); + + await syncGroupMetadata( + mockContext, + mockLocalGroup, + { + name: { value: 'Remote Name', lastUpdatedAt: 2000 }, + } as unknown as UserStorageSyncedWalletGroup, + 'test-entropy', + 'test-profile', + ); + + expect(mockPushGroupToUserStorage).not.toHaveBeenCalled(); + }); + + it('handles name metadata validation and apply local update', async () => { + mockContext.controller.state.accountGroupsMetadata[mockLocalGroup.id] = { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + + let validateNameFunction: + | Parameters< + typeof metadataExports.compareAndSyncMetadata + >[0]['validateUserStorageValue'] + | undefined; + let applyNameUpdate: + | Parameters< + typeof metadataExports.compareAndSyncMetadata + >[0]['applyLocalUpdate'] + | undefined; + + mockCompareAndSyncMetadata.mockImplementation( + async ( + options: Parameters[0], + ) => { + /* eslint-disable jest/no-conditional-in-test */ + if ( + options.userStorageMetadata && + 'value' in options.userStorageMetadata && + typeof options.userStorageMetadata.value === 'string' + ) { + validateNameFunction = options.validateUserStorageValue; + applyNameUpdate = options.applyLocalUpdate; + } + return false; + /* eslint-enable jest/no-conditional-in-test */ + }, + ); + + await syncGroupMetadata( + mockContext, + mockLocalGroup, + { + name: { value: 'Remote Name', lastUpdatedAt: 2000 }, + } as unknown as UserStorageSyncedWalletGroup, + 'test-entropy', + 'test-profile', + ); + + expect(validateNameFunction).toBeDefined(); + expect(applyNameUpdate).toBeDefined(); + /* eslint-disable jest/no-conditional-in-test */ + /* eslint-disable jest/no-conditional-expect */ + if (validateNameFunction) { + expect(validateNameFunction('New Name')).toBe(true); + expect(validateNameFunction('Local Name')).toBe(true); + expect(validateNameFunction(null)).toBe(false); + } + + if (applyNameUpdate) { + await applyNameUpdate('New Name'); + expect(mockContext.controller.setAccountGroupName).toHaveBeenCalledWith( + mockLocalGroup.id, + 'New Name', + ); + } + /* eslint-enable jest/no-conditional-in-test */ + /* eslint-enable jest/no-conditional-expect */ + }); + + it('handles pinned metadata validation and apply local update', async () => { + mockContext.controller.state.accountGroupsMetadata[mockLocalGroup.id] = { + pinned: { value: false, lastUpdatedAt: 1000 }, + }; + + let validatePinnedFunction: + | Parameters< + typeof metadataExports.compareAndSyncMetadata + >[0]['validateUserStorageValue'] + | undefined; + let applyPinnedUpdate: + | Parameters< + typeof metadataExports.compareAndSyncMetadata + >[0]['applyLocalUpdate'] + | undefined; + + mockCompareAndSyncMetadata.mockImplementation( + async ( + options: Parameters[0], + ) => { + /* eslint-disable jest/no-conditional-in-test */ + if ( + options.userStorageMetadata && + 'value' in options.userStorageMetadata && + typeof options.userStorageMetadata.value === 'boolean' + ) { + validatePinnedFunction = options.validateUserStorageValue; + applyPinnedUpdate = options.applyLocalUpdate; + } + return false; + /* eslint-enable jest/no-conditional-in-test */ + }, + ); + + await syncGroupMetadata( + mockContext, + mockLocalGroup, + { + pinned: { value: true, lastUpdatedAt: 2000 }, + } as unknown as UserStorageSyncedWalletGroup, + 'test-entropy', + 'test-profile', + ); + + expect(validatePinnedFunction).toBeDefined(); + expect(applyPinnedUpdate).toBeDefined(); + /* eslint-disable jest/no-conditional-in-test */ + /* eslint-disable jest/no-conditional-expect */ + if (validatePinnedFunction) { + expect(validatePinnedFunction(true)).toBe(true); + expect(validatePinnedFunction(false)).toBe(true); + expect(validatePinnedFunction('invalid')).toBe(false); + expect(validatePinnedFunction(null)).toBe(false); + } + + if (applyPinnedUpdate) { + await applyPinnedUpdate(true); + expect( + mockContext.controller.setAccountGroupPinned, + ).toHaveBeenCalledWith(mockLocalGroup.id, true); + } + /* eslint-enable jest/no-conditional-in-test */ + /* eslint-enable jest/no-conditional-expect */ + }); + + it('handles hidden metadata validation and apply local update', async () => { + mockContext.controller.state.accountGroupsMetadata[mockLocalGroup.id] = { + hidden: { value: false, lastUpdatedAt: 1000 }, + }; + + let validateHiddenFunction: + | Parameters< + typeof metadataExports.compareAndSyncMetadata + >[0]['validateUserStorageValue'] + | undefined; + let applyHiddenUpdate: + | Parameters< + typeof metadataExports.compareAndSyncMetadata + >[0]['applyLocalUpdate'] + | undefined; + + mockCompareAndSyncMetadata.mockImplementation( + async ( + options: Parameters[0], + ) => { + /* eslint-disable jest/no-conditional-in-test */ + if ( + options.userStorageMetadata && + 'value' in options.userStorageMetadata && + typeof options.userStorageMetadata.value === 'boolean' + ) { + validateHiddenFunction = options.validateUserStorageValue; + applyHiddenUpdate = options.applyLocalUpdate; + } + return false; + /* eslint-enable jest/no-conditional-in-test */ + }, + ); + + await syncGroupMetadata( + mockContext, + mockLocalGroup, + { + hidden: { value: true, lastUpdatedAt: 2000 }, + } as unknown as UserStorageSyncedWalletGroup, + 'test-entropy', + 'test-profile', + ); + + expect(validateHiddenFunction).toBeDefined(); + expect(applyHiddenUpdate).toBeDefined(); + /* eslint-disable jest/no-conditional-in-test */ + /* eslint-disable jest/no-conditional-expect */ + if (validateHiddenFunction) { + expect(validateHiddenFunction(true)).toBe(true); + expect(validateHiddenFunction(false)).toBe(true); + expect(validateHiddenFunction('invalid')).toBe(false); + expect(validateHiddenFunction(123)).toBe(false); + } + + if (applyHiddenUpdate) { + await applyHiddenUpdate(false); + expect( + mockContext.controller.setAccountGroupHidden, + ).toHaveBeenCalledWith(mockLocalGroup.id, false); + } + /* eslint-enable jest/no-conditional-in-test */ + /* eslint-enable jest/no-conditional-expect */ + }); + }); + + describe('syncGroupsMetadata', () => { + it('syncs all local groups and batch push when needed', async () => { + const localGroups = [ + { + id: 'entropy:test-entropy/0', + metadata: { entropy: { groupIndex: 0 } }, + }, + { + id: 'entropy:test-entropy/1', + metadata: { entropy: { groupIndex: 1 } }, + }, + ] as unknown as AccountGroupMultichainAccountObject[]; + const userStorageGroups = [ + { groupIndex: 0, name: { value: 'Remote 1' } }, + { groupIndex: 1, name: { value: 'Remote 2' } }, + ] as unknown as UserStorageSyncedWalletGroup[]; + + mockGetLocalGroupsForEntropyWallet.mockReturnValue(localGroups); + mockCompareAndSyncMetadata.mockResolvedValue(true); + + await syncGroupsMetadata( + mockContext, + mockWallet, + userStorageGroups, + 'test-entropy', + 'test-profile', + ); + + expect(mockGetLocalGroupsForEntropyWallet).toHaveBeenCalledWith( + mockContext, + mockWallet.id, + ); + expect(mockPushGroupToUserStorageBatch).toHaveBeenCalledWith( + mockContext, + localGroups, + 'test-entropy', + ); + }); + + it('pushes group if it is not present in user storage', async () => { + const localGroups = [ + { + id: 'entropy:test-entropy/0', + metadata: { entropy: { groupIndex: 0 } }, + } as unknown as AccountGroupMultichainAccountObject, + ]; + + mockGetLocalGroupsForEntropyWallet.mockReturnValue(localGroups); + + await syncGroupsMetadata( + mockContext, + mockWallet, + [], + 'test-entropy', + 'test-profile', + ); + + expect(mockPushGroupToUserStorageBatch).toHaveBeenCalled(); + }); + + it('handles metadata sync for name, pinned, and hidden fields', async () => { + const localGroup = { + id: 'entropy:test-entropy/0', + metadata: { entropy: { groupIndex: 0 } }, + } as unknown as AccountGroupMultichainAccountObject; + + mockContext.controller.state.accountGroupsMetadata[localGroup.id] = { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + pinned: { value: true, lastUpdatedAt: 1000 }, + hidden: { value: false, lastUpdatedAt: 1000 }, + }; + + mockGetLocalGroupsForEntropyWallet.mockReturnValue([localGroup]); + mockCompareAndSyncMetadata.mockResolvedValue(false); + + await syncGroupsMetadata( + mockContext, + mockWallet, + [ + { + groupIndex: 0, + name: { value: 'Remote Name', lastUpdatedAt: 2000 }, + pinned: { value: false, lastUpdatedAt: 2000 }, + hidden: { value: true, lastUpdatedAt: 2000 }, + }, + ], + 'test-entropy', + 'test-profile', + ); + + expect(mockCompareAndSyncMetadata).toHaveBeenCalledTimes(3); + expect(mockCompareAndSyncMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupRenamed, + profileId: 'test-profile', + }, + }), + ); + expect(mockCompareAndSyncMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupPinnedStatusChanged, + profileId: 'test-profile', + }, + }), + ); + expect(mockCompareAndSyncMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupHiddenStatusChanged, + profileId: 'test-profile', + }, + }), + ); + }); + }); + + describe('syncGroupMetadata - debug logging coverage', () => { + it('logs when group does not exist in user storage', async () => { + const testContext = { + ...mockContext, + } as BackupAndSyncContext; + + testContext.controller.state.accountGroupsMetadata = { + [mockLocalGroup.id]: { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }, + }; + + mockGetLocalGroupsForEntropyWallet.mockReturnValue([mockLocalGroup]); + mockPushGroupToUserStorage.mockResolvedValue(); + + await syncGroupMetadata( + testContext, + mockLocalGroup, + null, // groupFromUserStorage is null + 'test-entropy', + 'test-profile', + ); + + // Should push the group since it has local metadata + expect(mockPushGroupToUserStorage).toHaveBeenCalled(); + }); + + it('calls applyLocalUpdate when metadata sync requires local update', async () => { + const testGroupName = 'Updated Name'; + const testContext = { ...mockContext }; + jest + .spyOn(testContext.controller, 'setAccountGroupName') + .mockImplementation(); + + testContext.controller.state.accountGroupsMetadata = { + [mockLocalGroup.id]: { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }, + }; + + const groupFromUserStorage = { + groupIndex: 0, + name: { value: testGroupName, lastUpdatedAt: 2000 }, + }; + + mockCompareAndSyncMetadata.mockImplementation( + async ( + config: Parameters[0], + ) => { + // Simulate calling applyLocalUpdate + await config.applyLocalUpdate(testGroupName); + return false; // No push needed + }, + ); + + await syncGroupMetadata( + testContext, + mockLocalGroup, + groupFromUserStorage, + 'test-entropy', + 'test-profile', + ); + + // Verify that setAccountGroupName was called + expect(testContext.controller.setAccountGroupName).toHaveBeenCalledWith( + mockLocalGroup.id, + testGroupName, + ); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts new file mode 100644 index 00000000000..16513168fe2 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/group.ts @@ -0,0 +1,269 @@ +import { compareAndSyncMetadata } from './metadata'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import { backupAndSyncLogger } from '../../logger'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import type { BackupAndSyncAnalyticsAction } from '../analytics'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { ProfileId } from '../authentication'; +import { + UserStorageSyncedWalletGroupSchema, + type BackupAndSyncContext, + type UserStorageSyncedWalletGroup, +} from '../types'; +import { + pushGroupToUserStorage, + pushGroupToUserStorageBatch, +} from '../user-storage/network-operations'; +import { getLocalGroupsForEntropyWallet } from '../utils'; + +/** + * Creates a multichain account group. + * + * @param context - The sync context containing controller and messenger. + * @param entropySourceId - The entropy source ID. + * @param groupIndex - The group index. + * @param profileId - The profile ID for analytics. + * @param analyticsAction - The analytics action to log. + */ +export const createMultichainAccountGroup = async ( + context: BackupAndSyncContext, + entropySourceId: string, + groupIndex: number, + profileId: ProfileId, + analyticsAction: BackupAndSyncAnalyticsAction, +) => { + try { + // This will be idempotent so we can create the group even if it already exists + await context.messenger.call( + 'MultichainAccountService:createMultichainAccountGroup', + { + entropySource: entropySourceId, + groupIndex, + }, + ); + + context.emitAnalyticsEventFn({ + action: analyticsAction, + profileId, + }); + } catch (error) { + backupAndSyncLogger( + `Failed to create group ${groupIndex} for entropy ${entropySourceId}:`, + // istanbul ignore next + error instanceof Error ? error.message : String(error), + ); + throw error; + } +}; + +/** + * Creates local groups from user storage groups. + * + * @param context - The sync context containing controller and messenger. + * @param groupsFromUserStorage - Array of groups from user storage. + * @param entropySourceId - The entropy source ID. + * @param profileId - The profile ID for analytics. + */ +export async function createLocalGroupsFromUserStorage( + context: BackupAndSyncContext, + groupsFromUserStorage: UserStorageSyncedWalletGroup[], + entropySourceId: string, + profileId: ProfileId, +): Promise { + const numberOfAccountGroupsToCreate = Math.max( + ...groupsFromUserStorage.map((g) => g.groupIndex), + ); + + for ( + let groupIndex = 0; + groupIndex <= numberOfAccountGroupsToCreate; + groupIndex++ + ) { + try { + // Creating multichain account group is idempotent, so we can safely + // re-create every groups starting from 0. + await createMultichainAccountGroup( + context, + entropySourceId, + groupIndex, + profileId, + BackupAndSyncAnalyticsEvent.GroupAdded, + ); + } catch { + // This can happen if the Snap Keyring is not ready yet when invoking + // `MultichainAccountService:createMultichainAccountGroup`. + // Since `MultichainAccountService:createMultichainAccountGroup` will at + // least create the EVM account and the account group before throwing, we can safely + // ignore this error and continue. + // Any missing Snap accounts will be added later with alignment. + continue; + } + } +} + +/** + * Syncs group metadata fields and determines if the group needs to be pushed to user storage. + * + * @param context - The sync context containing controller and messenger. + * @param localGroup - The local group to sync. + * @param groupFromUserStorage - The group from user storage to compare against. + * @param profileId - The profile ID for analytics. + * @returns A promise that resolves to true if the group needs to be pushed to user storage. + */ +async function syncGroupMetadataAndCheckIfPushNeeded( + context: BackupAndSyncContext, + localGroup: AccountGroupMultichainAccountObject, + groupFromUserStorage: UserStorageSyncedWalletGroup | null | undefined, + profileId: ProfileId, +): Promise { + const groupPersistedMetadata = + context.controller.state.accountGroupsMetadata[localGroup.id]; + + if (!groupFromUserStorage) { + backupAndSyncLogger( + `Group ${localGroup.id} did not exist in user storage, pushing to user storage...`, + ); + + return true; + } + + // Track if we need to push this group to user storage + let shouldPushGroup = false; + + // Compare and sync name metadata + const shouldPushForName = await compareAndSyncMetadata({ + context, + localMetadata: groupPersistedMetadata?.name, + userStorageMetadata: groupFromUserStorage.name, + validateUserStorageValue: (value) => + UserStorageSyncedWalletGroupSchema.schema.name.schema.value.is(value), + applyLocalUpdate: (name: string) => { + context.controller.setAccountGroupName(localGroup.id, name); + }, + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupRenamed, + profileId, + }, + }); + + shouldPushGroup ||= shouldPushForName; + + // Compare and sync pinned metadata + const shouldPushForPinned = await compareAndSyncMetadata({ + context, + localMetadata: groupPersistedMetadata?.pinned, + userStorageMetadata: groupFromUserStorage.pinned, + validateUserStorageValue: (value) => + UserStorageSyncedWalletGroupSchema.schema.pinned.schema.value.is(value), + applyLocalUpdate: (pinned: boolean) => { + context.controller.setAccountGroupPinned(localGroup.id, pinned); + }, + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupPinnedStatusChanged, + profileId, + }, + }); + + shouldPushGroup ||= shouldPushForPinned; + + // Compare and sync hidden metadata + const shouldPushForHidden = await compareAndSyncMetadata({ + context, + localMetadata: groupPersistedMetadata?.hidden, + userStorageMetadata: groupFromUserStorage.hidden, + validateUserStorageValue: (value) => + UserStorageSyncedWalletGroupSchema.schema.hidden.schema.value.is(value), + applyLocalUpdate: (hidden: boolean) => { + context.controller.setAccountGroupHidden(localGroup.id, hidden); + }, + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupHiddenStatusChanged, + profileId, + }, + }); + + shouldPushGroup ||= shouldPushForHidden; + + return shouldPushGroup; +} + +/** + * Syncs a single group's metadata between local and user storage. + * + * @param context - The sync context containing controller and messenger. + * @param localGroup - The local group to sync. + * @param groupFromUserStorage - The group from user storage to compare against (or null if it doesn't exist). + * @param entropySourceId - The entropy source ID. + * @param profileId - The profile ID for analytics. + */ +export async function syncGroupMetadata( + context: BackupAndSyncContext, + localGroup: AccountGroupMultichainAccountObject, + groupFromUserStorage: UserStorageSyncedWalletGroup | null, + entropySourceId: string, + profileId: ProfileId, +): Promise { + const shouldPushGroup = await syncGroupMetadataAndCheckIfPushNeeded( + context, + localGroup, + groupFromUserStorage, + profileId, + ); + + if (shouldPushGroup) { + await pushGroupToUserStorage(context, localGroup, entropySourceId); + } +} + +/** + * Syncs group metadata between local and user storage. + * + * @param context - The sync context containing controller and messenger. + * @param wallet - The local wallet containing the groups. + * @param groupsFromUserStorage - Array of groups from user storage. + * @param entropySourceId - The entropy source ID. + * @param profileId - The profile ID for analytics. + */ +export async function syncGroupsMetadata( + context: BackupAndSyncContext, + wallet: AccountWalletEntropyObject, + groupsFromUserStorage: UserStorageSyncedWalletGroup[], + entropySourceId: string, + profileId: ProfileId, +): Promise { + const localSyncableGroupsToBePushedToUserStorage: AccountGroupMultichainAccountObject[] = + []; + + const localSyncableGroups = getLocalGroupsForEntropyWallet( + context, + wallet.id, + ); + + for (const localSyncableGroup of localSyncableGroups) { + const groupFromUserStorage = groupsFromUserStorage.find( + (group) => + group.groupIndex === localSyncableGroup.metadata.entropy.groupIndex, + ); + + const shouldPushGroup = await syncGroupMetadataAndCheckIfPushNeeded( + context, + localSyncableGroup, + groupFromUserStorage, + profileId, + ); + + // Add to push list if any metadata needs to be updated in user storage + if (shouldPushGroup) { + localSyncableGroupsToBePushedToUserStorage.push(localSyncableGroup); + } + } + + // Push all groups that need to be updated to user storage + if (localSyncableGroupsToBePushedToUserStorage.length > 0) { + await pushGroupToUserStorageBatch( + context, + localSyncableGroupsToBePushedToUserStorage, + entropySourceId, + ); + } +} diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/index.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/index.ts new file mode 100644 index 00000000000..2a76d6d32da --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/index.ts @@ -0,0 +1,4 @@ +export * from './group'; +export * from './legacy'; +export * from './wallet'; +export * from './metadata'; diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/legacy.test.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/legacy.test.ts new file mode 100644 index 00000000000..5d4872332f5 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/legacy.test.ts @@ -0,0 +1,343 @@ +import { AccountGroupType } from '@metamask/account-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; + +import { createMultichainAccountGroup } from './group'; +import { performLegacyAccountSyncing } from './legacy'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { BackupAndSyncContext } from '../types'; +import { getAllLegacyUserStorageAccounts } from '../user-storage'; +import { getLocalGroupsForEntropyWallet } from '../utils'; + +jest.mock('@metamask/accounts-controller'); +jest.mock('../user-storage'); +jest.mock('../utils', () => ({ + getLocalGroupsForEntropyWallet: jest.fn(), +})); +jest.mock('./group'); + +const mockGetUUIDFromAddressOfNormalAccount = + getUUIDFromAddressOfNormalAccount as jest.MockedFunction< + typeof getUUIDFromAddressOfNormalAccount + >; +const mockGetAllLegacyUserStorageAccounts = + getAllLegacyUserStorageAccounts as jest.MockedFunction< + typeof getAllLegacyUserStorageAccounts + >; +const mockGetLocalGroupsForEntropyWallet = + getLocalGroupsForEntropyWallet as jest.MockedFunction< + typeof getLocalGroupsForEntropyWallet + >; +const mockCreateMultichainAccountGroup = + createMultichainAccountGroup as jest.MockedFunction< + typeof createMultichainAccountGroup + >; + +describe('BackupAndSync - Syncing - Legacy', () => { + let mockContext: BackupAndSyncContext; + + beforeEach(() => { + mockContext = { + controller: { + setAccountGroupName: jest.fn(), + }, + emitAnalyticsEventFn: jest.fn(), + } as unknown as BackupAndSyncContext; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('performLegacyAccountSyncing', () => { + const testEntropySourceId = 'test-entropy-id'; + const testProfileId = 'test-profile-id'; + + it('emits analytics and return early when no legacy accounts exist', async () => { + mockGetAllLegacyUserStorageAccounts.mockResolvedValue([]); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + expect(mockGetAllLegacyUserStorageAccounts).toHaveBeenCalledWith( + mockContext, + testEntropySourceId, + ); + expect(mockContext.emitAnalyticsEventFn).toHaveBeenCalledWith({ + action: BackupAndSyncAnalyticsEvent.LegacySyncingDone, + profileId: testProfileId, + }); + expect(mockGetLocalGroupsForEntropyWallet).not.toHaveBeenCalled(); + }); + + it('creates groups', async () => { + const mockLegacyAccounts = [ + { n: 'Account 1', a: '0x123' }, + { n: 'Account 2', a: '0x456' }, + { n: 'Account 3', a: '0x789' }, + ]; + const mockLocalGroups = [ + { + id: 'entropy:test-entropy/0' as const, + type: AccountGroupType.MultichainAccount, + accounts: ['account-1'], + metadata: { entropy: { groupIndex: 0 } }, + }, + ] as unknown as AccountGroupMultichainAccountObject[]; // Only 1 existing group + + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValueOnce(mockLocalGroups); + mockGetLocalGroupsForEntropyWallet.mockReturnValueOnce([ + ...mockLocalGroups, + { + id: 'entropy:test-entropy/1' as const, + type: AccountGroupType.MultichainAccount, + accounts: ['account-2'], + metadata: { entropy: { groupIndex: 1 } }, + }, + { + id: 'entropy:test-entropy/2' as const, + type: AccountGroupType.MultichainAccount, + accounts: ['account-3'], + metadata: { entropy: { groupIndex: 2 } }, + }, + ] as unknown as AccountGroupMultichainAccountObject[]); + mockCreateMultichainAccountGroup.mockResolvedValue(); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + // Should create 3 groups + expect(mockCreateMultichainAccountGroup).toHaveBeenCalledTimes(3); + expect(mockCreateMultichainAccountGroup).toHaveBeenCalledWith( + mockContext, + testEntropySourceId, + 0, + testProfileId, + BackupAndSyncAnalyticsEvent.LegacyGroupAddedFromAccount, + ); + expect(mockCreateMultichainAccountGroup).toHaveBeenCalledWith( + mockContext, + testEntropySourceId, + 1, + testProfileId, + BackupAndSyncAnalyticsEvent.LegacyGroupAddedFromAccount, + ); + }); + + it('renames account groups based on legacy account data', async () => { + const mockAccountId1 = 'uuid-for-0x123'; + const mockAccountId2 = 'uuid-for-0x456'; + const mockLegacyAccounts = [ + { n: 'Legacy Account 1', a: '0x123' }, + { n: 'Legacy Account 2', a: '0x456' }, + ]; + const mockLocalGroups = [ + { + id: 'entropy:test-entropy/0' as const, + type: AccountGroupType.MultichainAccount, + accounts: [mockAccountId1], + metadata: { entropy: { groupIndex: 0 } }, + }, + { + id: 'entropy:test-entropy/1' as const, + type: AccountGroupType.MultichainAccount, + accounts: [mockAccountId2], + metadata: { entropy: { groupIndex: 1 } }, + }, + ] as unknown as AccountGroupMultichainAccountObject[]; + + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValueOnce(mockLocalGroups); + mockGetUUIDFromAddressOfNormalAccount + .mockReturnValueOnce(mockAccountId1) + .mockReturnValueOnce(mockAccountId2); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + expect(mockGetUUIDFromAddressOfNormalAccount).toHaveBeenCalledWith( + '0x123', + ); + expect(mockGetUUIDFromAddressOfNormalAccount).toHaveBeenCalledWith( + '0x456', + ); + expect(mockContext.controller.setAccountGroupName).toHaveBeenCalledWith( + 'entropy:test-entropy/0', + 'Legacy Account 1', + ); + expect(mockContext.controller.setAccountGroupName).toHaveBeenCalledWith( + 'entropy:test-entropy/1', + 'Legacy Account 2', + ); + }); + + it('skips legacy accounts with missing name or address', async () => { + const mockLegacyAccounts = [ + { n: 'Valid Account', a: '0x123' }, + { n: '', a: '0x456' }, // Missing name + { n: 'No Address', a: undefined }, // Missing address + { a: '0x789' }, // Missing name property + { n: 'Missing Address' }, // Missing address property + ]; + + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValue([]); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + expect(mockGetUUIDFromAddressOfNormalAccount).toHaveBeenCalledTimes(1); // Only valid account + }); + + it('does not rename group when no matching local group is found', async () => { + const mockAccountId = 'uuid-for-0x123'; + const mockLegacyAccounts = [{ n: 'Orphan Account', a: '0x123' }]; + const mockLocalGroups = [ + { + id: 'entropy:test-entropy/0' as const, + type: AccountGroupType.MultichainAccount, + accounts: ['different-account-id'], // Different account + metadata: { entropy: { groupIndex: 0 } }, + }, + ] as unknown as AccountGroupMultichainAccountObject[]; + + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValue([]); + mockGetLocalGroupsForEntropyWallet.mockReturnValueOnce(mockLocalGroups); + mockGetUUIDFromAddressOfNormalAccount.mockReturnValue(mockAccountId); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + expect(mockContext.controller.setAccountGroupName).not.toHaveBeenCalled(); + }); + + it('emits analytics event on completion', async () => { + const mockLegacyAccounts = [{ n: 'Test Account', a: '0x123' }]; + + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValue([]); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + expect(mockContext.emitAnalyticsEventFn).toHaveBeenCalledWith({ + action: BackupAndSyncAnalyticsEvent.LegacySyncingDone, + profileId: testProfileId, + }); + }); + + it('handles complex scenario with group creation and renaming', async () => { + const mockAccountId1 = 'uuid-for-0x111'; + const mockAccountId2 = 'uuid-for-0x222'; + const mockAccountId3 = 'uuid-for-0x333'; + + const mockLegacyAccounts = [ + { n: 'Main Account', a: '0x111' }, + { n: 'Trading Account', a: '0x222' }, + { n: 'Savings Account', a: '0x333' }, + ]; + + // After group creation, we have all 3 groups + const mockRefreshedLocalGroups = [ + { + id: 'entropy:test-entropy/0' as const, + type: AccountGroupType.MultichainAccount, + accounts: [mockAccountId1], + metadata: { entropy: { groupIndex: 0 } }, + }, + { + id: 'entropy:test-entropy/1' as const, + type: AccountGroupType.MultichainAccount, + accounts: [mockAccountId2], + metadata: { entropy: { groupIndex: 1 } }, + }, + { + id: 'entropy:test-entropy/2' as const, + type: AccountGroupType.MultichainAccount, + accounts: [mockAccountId3], + metadata: { entropy: { groupIndex: 2 } }, + }, + ] as unknown as AccountGroupMultichainAccountObject[]; + + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValueOnce( + mockRefreshedLocalGroups, + ); // For renaming logic + mockCreateMultichainAccountGroup.mockResolvedValue(); + mockGetUUIDFromAddressOfNormalAccount + .mockReturnValueOnce(mockAccountId1) + .mockReturnValueOnce(mockAccountId2) + .mockReturnValueOnce(mockAccountId3); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + // Should create 3 groups + expect(mockCreateMultichainAccountGroup).toHaveBeenCalledTimes(3); + + // Should rename all 3 groups + expect(mockContext.controller.setAccountGroupName).toHaveBeenCalledWith( + 'entropy:test-entropy/0', + 'Main Account', + ); + expect(mockContext.controller.setAccountGroupName).toHaveBeenCalledWith( + 'entropy:test-entropy/1', + 'Trading Account', + ); + expect(mockContext.controller.setAccountGroupName).toHaveBeenCalledWith( + 'entropy:test-entropy/2', + 'Savings Account', + ); + + expect(mockContext.emitAnalyticsEventFn).toHaveBeenCalledWith({ + action: BackupAndSyncAnalyticsEvent.LegacySyncingDone, + profileId: testProfileId, + }); + }); + + it('handle edge case where refreshed local groups return different data', async () => { + const mockAccountId = 'uuid-for-0x123'; + const mockLegacyAccounts = [{ n: 'Test Account', a: '0x123' }]; + + // Initial call returns empty, but refreshed call also returns empty + mockGetAllLegacyUserStorageAccounts.mockResolvedValue(mockLegacyAccounts); + mockGetLocalGroupsForEntropyWallet.mockReturnValue([]); + mockGetUUIDFromAddressOfNormalAccount.mockReturnValue(mockAccountId); + + await performLegacyAccountSyncing( + mockContext, + testEntropySourceId, + testProfileId, + ); + + // Should still process but find no matching groups + expect(mockGetUUIDFromAddressOfNormalAccount).toHaveBeenCalledWith( + '0x123', + ); + expect(mockContext.controller.setAccountGroupName).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/legacy.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/legacy.ts new file mode 100644 index 00000000000..d8a4344e38e --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/legacy.ts @@ -0,0 +1,104 @@ +import { toMultichainAccountWalletId } from '@metamask/account-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; + +import { createMultichainAccountGroup } from './group'; +import { backupAndSyncLogger } from '../../logger'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { ProfileId } from '../authentication'; +import type { BackupAndSyncContext } from '../types'; +import { getAllLegacyUserStorageAccounts } from '../user-storage'; +import { getLocalGroupsForEntropyWallet } from '../utils'; + +/** + * Performs a stripped down version of legacy account syncing, replacing the current + * UserStorageController:syncInternalAccountsWithUserStorage call. + * This ensures legacy (V1) account syncing data is correctly migrated to + * the new AccountTreeController data structure. It should only happen + * once per wallet. + * + * @param context - The sync context containing controller and messenger. + * @param entropySourceId - The entropy source ID. + * @param profileId - The profile ID for analytics. + */ +export const performLegacyAccountSyncing = async ( + context: BackupAndSyncContext, + entropySourceId: string, + profileId: ProfileId, +) => { + // 1. Get legacy account syncing data + const legacyAccountsFromUserStorage = await getAllLegacyUserStorageAccounts( + context, + entropySourceId, + ); + if (legacyAccountsFromUserStorage.length === 0) { + backupAndSyncLogger('No legacy accounts, skipping legacy account syncing'); + + context.emitAnalyticsEventFn({ + action: BackupAndSyncAnalyticsEvent.LegacySyncingDone, + profileId, + }); + + return; + } + + // 2. Create account groups accordingly + const numberOfAccountGroupsToCreate = legacyAccountsFromUserStorage.length; + + backupAndSyncLogger( + `Creating ${numberOfAccountGroupsToCreate} account groups for legacy accounts`, + ); + + if (numberOfAccountGroupsToCreate > 0) { + for (let i = 0; i < numberOfAccountGroupsToCreate; i++) { + backupAndSyncLogger(`Creating account group ${i} for legacy account`); + await createMultichainAccountGroup( + context, + entropySourceId, + i, + profileId, + BackupAndSyncAnalyticsEvent.LegacyGroupAddedFromAccount, + ); + } + } + + // 3. Rename account groups if needed + const localAccountGroups = getLocalGroupsForEntropyWallet( + context, + toMultichainAccountWalletId(entropySourceId), + ); + for (const legacyAccount of legacyAccountsFromUserStorage) { + // n: name + // a: EVM address + const { n, a } = legacyAccount; + if (!a || !n) { + backupAndSyncLogger( + `Legacy account data is missing name or address, skipping account: ${JSON.stringify( + legacyAccount, + )}`, + ); + continue; + } + + if (n) { + // Find the local group that corresponds to this EVM address + const localAccountId = getUUIDFromAddressOfNormalAccount(a); + const localGroup = localAccountGroups.find((group) => + group.accounts.includes(localAccountId), + ); + if (localGroup) { + context.controller.setAccountGroupName(localGroup.id, n); + + context.emitAnalyticsEventFn({ + action: BackupAndSyncAnalyticsEvent.LegacyGroupRenamed, + profileId, + additionalDescription: `Renamed legacy group ${localGroup.id} to ${n}`, + }); + } + } + } + + context.emitAnalyticsEventFn({ + action: BackupAndSyncAnalyticsEvent.LegacySyncingDone, + profileId, + }); +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/metadata.test.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/metadata.test.ts new file mode 100644 index 00000000000..9f6f901572b --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/metadata.test.ts @@ -0,0 +1,128 @@ +import { compareAndSyncMetadata } from './metadata'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { BackupAndSyncContext } from '../types'; + +describe('BackupAndSync - Syncing - Metadata', () => { + let mockContext: BackupAndSyncContext; + let mockApplyLocalUpdate: jest.Mock; + let mockValidateUserStorageValue: jest.Mock; + + beforeEach(() => { + mockApplyLocalUpdate = jest.fn(); + mockValidateUserStorageValue = jest.fn().mockReturnValue(true); + + mockContext = { + emitAnalyticsEventFn: jest.fn(), + } as unknown as BackupAndSyncContext; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('compareAndSyncMetadata', () => { + it('returns false when values are identical', async () => { + const result = await compareAndSyncMetadata({ + context: mockContext, + localMetadata: { value: 'test', lastUpdatedAt: 1000 }, + userStorageMetadata: { value: 'test', lastUpdatedAt: 2000 }, + applyLocalUpdate: mockApplyLocalUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + }); + + expect(result).toBe(false); + expect(mockApplyLocalUpdate).not.toHaveBeenCalled(); + expect(mockContext.emitAnalyticsEventFn).not.toHaveBeenCalled(); + }); + + it('applies user storage value when it is more recent and valid', async () => { + const result = await compareAndSyncMetadata({ + context: mockContext, + localMetadata: { value: 'old', lastUpdatedAt: 1000 }, + userStorageMetadata: { value: 'new', lastUpdatedAt: 2000 }, + applyLocalUpdate: mockApplyLocalUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + analytics: { + action: BackupAndSyncAnalyticsEvent.GroupRenamed, + profileId: 'test-profile', + }, + }); + + expect(result).toBe(false); + expect(mockApplyLocalUpdate).toHaveBeenCalledWith('new'); + expect(mockContext.emitAnalyticsEventFn).toHaveBeenCalledWith({ + action: BackupAndSyncAnalyticsEvent.GroupRenamed, + profileId: 'test-profile', + }); + }); + + it('returns true when local value is more recent', async () => { + const result = await compareAndSyncMetadata({ + context: mockContext, + localMetadata: { value: 'new', lastUpdatedAt: 2000 }, + userStorageMetadata: { value: 'old', lastUpdatedAt: 1000 }, + applyLocalUpdate: mockApplyLocalUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + }); + + expect(result).toBe(true); + expect(mockApplyLocalUpdate).not.toHaveBeenCalled(); + expect(mockContext.emitAnalyticsEventFn).not.toHaveBeenCalled(); + }); + + it('returns true when user storage value is invalid', async () => { + mockValidateUserStorageValue.mockReturnValue(false); + + const result = await compareAndSyncMetadata({ + context: mockContext, + localMetadata: { value: 'local', lastUpdatedAt: 1000 }, + userStorageMetadata: { value: 'invalid', lastUpdatedAt: 2000 }, + applyLocalUpdate: mockApplyLocalUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + }); + + expect(result).toBe(true); + expect(mockApplyLocalUpdate).not.toHaveBeenCalled(); + expect(mockContext.emitAnalyticsEventFn).not.toHaveBeenCalled(); + }); + + it('applies user storage value when no local metadata exists', async () => { + const result = await compareAndSyncMetadata({ + context: mockContext, + localMetadata: undefined, + userStorageMetadata: { value: 'remote', lastUpdatedAt: 1000 }, + applyLocalUpdate: mockApplyLocalUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + }); + + expect(result).toBe(false); + expect(mockApplyLocalUpdate).toHaveBeenCalledWith('remote'); + }); + + it('does not emit analytics when no analytics config provided', async () => { + await compareAndSyncMetadata({ + context: mockContext, + localMetadata: { value: 'old', lastUpdatedAt: 1000 }, + userStorageMetadata: { value: 'new', lastUpdatedAt: 2000 }, + applyLocalUpdate: mockApplyLocalUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + }); + + expect(mockContext.emitAnalyticsEventFn).not.toHaveBeenCalled(); + }); + + it('handles async applyLocalUpdate function', async () => { + const asyncUpdate = jest.fn().mockResolvedValue(undefined); + + await compareAndSyncMetadata({ + context: mockContext, + localMetadata: { value: 'old', lastUpdatedAt: 1000 }, + userStorageMetadata: { value: 'new', lastUpdatedAt: 2000 }, + applyLocalUpdate: asyncUpdate, + validateUserStorageValue: mockValidateUserStorageValue, + }); + + expect(asyncUpdate).toHaveBeenCalledWith('new'); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/metadata.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/metadata.ts new file mode 100644 index 00000000000..c435942444f --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/metadata.ts @@ -0,0 +1,78 @@ +import deepEqual from 'fast-deep-equal'; + +import type { BackupAndSyncAnalyticsAction } from '../analytics'; +import type { ProfileId } from '../authentication'; +import type { BackupAndSyncContext } from '../types'; + +/** + * Compares metadata between local and user storage, applying the most recent version. + * + * @param options - Configuration object for metadata comparison. + * @param options.context - The backup and sync context containing controller and messenger. + * @param options.localMetadata - The local metadata object. + * @param options.localMetadata.value - The local metadata value. + * @param options.localMetadata.lastUpdatedAt - The local metadata timestamp. + * @param options.userStorageMetadata - The user storage metadata object. + * @param options.userStorageMetadata.value - The user storage metadata value. + * @param options.userStorageMetadata.lastUpdatedAt - The user storage metadata timestamp. + * @param options.applyLocalUpdate - Function to apply the user storage value locally. + * @param options.validateUserStorageValue - Function to validate user storage data. + * @param options.analytics - Optional analytics configuration for tracking updates. + * @param options.analytics.action - The analytics action for the event. + * @param options.analytics.profileId - The profile ID for analytics. + * @returns Promise resolving to true if local data should be pushed to user storage. + */ +export async function compareAndSyncMetadata({ + context, + localMetadata, + userStorageMetadata, + applyLocalUpdate, + validateUserStorageValue, + analytics, +}: { + context: BackupAndSyncContext; + localMetadata?: { value?: T; lastUpdatedAt?: number }; + userStorageMetadata?: { value?: T; lastUpdatedAt?: number }; + applyLocalUpdate: (value: T) => Promise | void; + validateUserStorageValue: (value: T | undefined) => boolean; + analytics?: { + action: BackupAndSyncAnalyticsAction; + profileId: ProfileId; + }; +}): Promise { + const localValue = localMetadata?.value; + const localTimestamp = localMetadata?.lastUpdatedAt; + const userStorageValue = userStorageMetadata?.value; + const userStorageTimestamp = userStorageMetadata?.lastUpdatedAt; + + const isSameValue = deepEqual(localValue, userStorageValue); + + if (isSameValue) { + return false; // No sync needed, values are the same + } + + const isUserStorageMoreRecent = + localTimestamp && + userStorageTimestamp && + localTimestamp < userStorageTimestamp; + + // Validate user storage value using the provided validator + const isUserStorageValueValid = validateUserStorageValue(userStorageValue); + + if ((isUserStorageMoreRecent || !localMetadata) && isUserStorageValueValid) { + // User storage is more recent and valid, apply it locally + await applyLocalUpdate(userStorageValue as T); + + // Emit analytics event if provided + if (analytics) { + context.emitAnalyticsEventFn({ + action: analytics.action, + profileId: analytics.profileId, + }); + } + + return false; // Don't push to user storage since we just pulled from it + } + + return true; // Local is more recent or user storage is invalid, should push to user storage +} diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.test.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.test.ts new file mode 100644 index 00000000000..e9476f13517 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.test.ts @@ -0,0 +1,215 @@ +import { compareAndSyncMetadata } from './metadata'; +import { + syncWalletMetadataAndCheckIfPushNeeded, + syncWalletMetadata, +} from './wallet'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { BackupAndSyncContext, UserStorageSyncedWallet } from '../types'; +import { pushWalletToUserStorage } from '../user-storage/network-operations'; + +jest.mock('./metadata'); +jest.mock('../user-storage/network-operations'); + +const mockCompareAndSyncMetadata = + compareAndSyncMetadata as jest.MockedFunction; +const mockPushWalletToUserStorage = + pushWalletToUserStorage as jest.MockedFunction< + typeof pushWalletToUserStorage + >; + +describe('BackupAndSync - Syncing - Wallet', () => { + let mockContext: BackupAndSyncContext; + let mockLocalWallet: AccountWalletEntropyObject; + let mockWalletFromUserStorage: UserStorageSyncedWallet; + + beforeEach(() => { + mockContext = { + controller: { + state: { + accountWalletsMetadata: {}, + }, + setAccountWalletName: jest.fn(), + }, + } as unknown as BackupAndSyncContext; + + mockLocalWallet = { + id: 'entropy:wallet-1', + name: 'Test Wallet', + } as unknown as AccountWalletEntropyObject; + + mockWalletFromUserStorage = { + name: { value: 'Remote Wallet', lastUpdatedAt: 2000 }, + } as unknown as UserStorageSyncedWallet; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('syncWalletMetadataAndCheckIfPushNeeded', () => { + it('returns true when wallet does not exist in user storage but has local metadata', async () => { + mockContext.controller.state.accountWalletsMetadata[mockLocalWallet.id] = + { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + + const result = await syncWalletMetadataAndCheckIfPushNeeded( + mockContext, + mockLocalWallet, + null, + 'test-profile', + ); + + expect(result).toBe(true); + }); + + it('returns true when wallet does not exist in user storage and has no local metadata', async () => { + const result = await syncWalletMetadataAndCheckIfPushNeeded( + mockContext, + mockLocalWallet, + null, + 'test-profile', + ); + + expect(result).toBe(true); + }); + + it('syncs name metadata and return push decision', async () => { + mockContext.controller.state.accountWalletsMetadata[mockLocalWallet.id] = + { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + mockCompareAndSyncMetadata.mockResolvedValue(true); + + const result = await syncWalletMetadataAndCheckIfPushNeeded( + mockContext, + mockLocalWallet, + mockWalletFromUserStorage, + 'test-profile', + ); + + expect(mockCompareAndSyncMetadata).toHaveBeenCalledWith({ + context: mockContext, + localMetadata: { value: 'Local Name', lastUpdatedAt: 1000 }, + userStorageMetadata: { value: 'Remote Wallet', lastUpdatedAt: 2000 }, + validateUserStorageValue: expect.any(Function), + applyLocalUpdate: expect.any(Function), + analytics: { + action: BackupAndSyncAnalyticsEvent.WalletRenamed, + profileId: 'test-profile', + }, + }); + expect(result).toBe(true); + }); + + it('calls setAccountWalletName when applying local update', async () => { + mockContext.controller.state.accountWalletsMetadata[mockLocalWallet.id] = + { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + + let applyLocalUpdate: + | Parameters[0]['applyLocalUpdate'] + | undefined; + mockCompareAndSyncMetadata.mockImplementation( + async (options: Parameters[0]) => { + applyLocalUpdate = options.applyLocalUpdate; + return false; + }, + ); + + await syncWalletMetadataAndCheckIfPushNeeded( + mockContext, + mockLocalWallet, + mockWalletFromUserStorage, + 'test-profile', + ); + + expect(applyLocalUpdate).toBeDefined(); + /* eslint-disable jest/no-conditional-in-test */ + /* eslint-disable jest/no-conditional-expect */ + if (applyLocalUpdate) { + await applyLocalUpdate('New Name'); + expect( + mockContext.controller.setAccountWalletName, + ).toHaveBeenCalledWith(mockLocalWallet.id, 'New Name'); + } + /* eslint-enable jest/no-conditional-in-test */ + /* eslint-enable jest/no-conditional-expect */ + }); + + it('validates user storage values using the schema validator', async () => { + mockContext.controller.state.accountWalletsMetadata[mockLocalWallet.id] = + { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + + let validateUserStorageValue: + | Parameters< + typeof compareAndSyncMetadata + >[0]['validateUserStorageValue'] + | undefined; + mockCompareAndSyncMetadata.mockImplementation( + async (options: Parameters[0]) => { + validateUserStorageValue = options.validateUserStorageValue; + return false; + }, + ); + + await syncWalletMetadataAndCheckIfPushNeeded( + mockContext, + mockLocalWallet, + mockWalletFromUserStorage, + 'test-profile', + ); + + expect(validateUserStorageValue).toBeDefined(); + /* eslint-disable jest/no-conditional-in-test */ + /* eslint-disable jest/no-conditional-expect */ + if (validateUserStorageValue) { + expect(validateUserStorageValue('valid string')).toBe(true); + expect(validateUserStorageValue(123)).toBe(false); + expect(validateUserStorageValue(null)).toBe(false); + expect(validateUserStorageValue(undefined)).toBe(false); + } + /* eslint-enable jest/no-conditional-in-test */ + /* eslint-enable jest/no-conditional-expect */ + }); + }); + + describe('syncWalletMetadata', () => { + it('pushes to user storage when sync check returns true', async () => { + mockContext.controller.state.accountWalletsMetadata[mockLocalWallet.id] = + { + name: { value: 'Local Name', lastUpdatedAt: 1000 }, + }; + mockCompareAndSyncMetadata.mockResolvedValue(true); + + await syncWalletMetadata( + mockContext, + mockLocalWallet, + mockWalletFromUserStorage, + 'test-profile', + ); + + expect(mockPushWalletToUserStorage).toHaveBeenCalledWith( + mockContext, + mockLocalWallet, + ); + }); + + it('does not push to user storage when sync check returns false', async () => { + mockCompareAndSyncMetadata.mockResolvedValue(false); + + await syncWalletMetadata( + mockContext, + mockLocalWallet, + mockWalletFromUserStorage, + 'test-profile', + ); + + expect(mockPushWalletToUserStorage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts b/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts new file mode 100644 index 00000000000..cf7bdbafbca --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/syncing/wallet.ts @@ -0,0 +1,85 @@ +import { compareAndSyncMetadata } from './metadata'; +import { backupAndSyncLogger } from '../../logger'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import { BackupAndSyncAnalyticsEvent } from '../analytics'; +import type { ProfileId } from '../authentication'; +import { + UserStorageSyncedWalletSchema, + type BackupAndSyncContext, + type UserStorageSyncedWallet, +} from '../types'; +import { pushWalletToUserStorage } from '../user-storage/network-operations'; + +/** + * Syncs wallet metadata fields and determines if the wallet needs to be pushed to user storage. + * + * @param context - The sync context containing controller and messenger. + * @param localWallet - The local wallet to sync. + * @param walletFromUserStorage - The wallet data from user storage, if any. + * @param profileId - The profile ID for analytics. + * @returns Promise resolving to true if the wallet should be pushed to user storage. + */ +export async function syncWalletMetadataAndCheckIfPushNeeded( + context: BackupAndSyncContext, + localWallet: AccountWalletEntropyObject, + walletFromUserStorage: UserStorageSyncedWallet | null | undefined, + profileId: ProfileId, +): Promise { + const walletPersistedMetadata = + context.controller.state.accountWalletsMetadata[localWallet.id]; + + if (!walletFromUserStorage) { + backupAndSyncLogger( + `Wallet ${localWallet.id} did not exist in user storage, pushing to user storage...`, + ); + return true; + } + // Track if we need to push this wallet to user storage + let shouldPushWallet = false; + + // Compare and sync name metadata + const shouldPushForName = await compareAndSyncMetadata({ + context, + localMetadata: walletPersistedMetadata?.name, + userStorageMetadata: walletFromUserStorage.name, + validateUserStorageValue: (value) => + UserStorageSyncedWalletSchema.schema.name.schema.value.is(value), + applyLocalUpdate: (name: string) => { + context.controller.setAccountWalletName(localWallet.id, name); + }, + analytics: { + action: BackupAndSyncAnalyticsEvent.WalletRenamed, + profileId, + }, + }); + + shouldPushWallet ||= shouldPushForName; + + return shouldPushWallet; +} + +/** + * Syncs wallet metadata and pushes it to user storage if needed. + * + * @param context - The sync context containing controller and messenger. + * @param localWallet - The local wallet to sync. + * @param walletFromUserStorage - The wallet data from user storage, if any. + * @param profileId - The profile ID for analytics. + */ +export async function syncWalletMetadata( + context: BackupAndSyncContext, + localWallet: AccountWalletEntropyObject, + walletFromUserStorage: UserStorageSyncedWallet | null | undefined, + profileId: ProfileId, +): Promise { + const shouldPushToUserStorage = await syncWalletMetadataAndCheckIfPushNeeded( + context, + localWallet, + walletFromUserStorage, + profileId, + ); + + if (shouldPushToUserStorage) { + await pushWalletToUserStorage(context, localWallet); + } +} diff --git a/packages/account-tree-controller/src/backup-and-sync/types.ts b/packages/account-tree-controller/src/backup-and-sync/types.ts new file mode 100644 index 00000000000..3dce42f90cd --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/types.ts @@ -0,0 +1,106 @@ +import type { + AccountGroupId, + AccountGroupType, + AccountWalletId, + AccountWalletType, +} from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Infer } from '@metamask/superstruct'; +import { + object, + string, + boolean, + number, + optional, + type Struct, +} from '@metamask/superstruct'; + +import type { BackupAndSyncEmitAnalyticsEventParams } from './analytics'; +import type { AccountTreeController } from '../AccountTreeController'; +import type { + AccountGroupMultichainAccountObject, + AccountTreeGroupPersistedMetadata, +} from '../group'; +import type { RuleResult } from '../rule'; +import type { AccountTreeControllerMessenger } from '../types'; +import type { AccountTreeWalletPersistedMetadata } from '../wallet'; + +/** + * Schema for an updatable field with value and timestamp. + * + * @param valueSchema - The schema for the value field. + * @returns A superstruct schema for an updatable field. + */ +const UpdatableFieldSchema = (valueSchema: Struct) => + object({ + value: valueSchema, + lastUpdatedAt: number(), + }); + +/** + * Superstruct schema for UserStorageSyncedWallet validation. + */ +export const UserStorageSyncedWalletSchema = object({ + name: optional(UpdatableFieldSchema(string())), + isLegacyAccountSyncingDisabled: optional(boolean()), +}); + +/** + * Superstruct schema for UserStorageSyncedWalletGroup validation. + */ +export const UserStorageSyncedWalletGroupSchema = object({ + name: optional(UpdatableFieldSchema(string())), + pinned: optional(UpdatableFieldSchema(boolean())), + hidden: optional(UpdatableFieldSchema(boolean())), + groupIndex: number(), +}); + +/** + * Superstruct schema for LegacyUserStorageSyncedAccount validation. + */ +export const LegacyUserStorageSyncedAccountSchema = object({ + v: optional(string()), + i: optional(string()), + a: optional(string()), + n: optional(string()), + nlu: optional(number()), +}); + +export type UserStorageSyncedWallet = AccountTreeWalletPersistedMetadata & + Infer; + +export type UserStorageSyncedWalletGroup = AccountTreeGroupPersistedMetadata & { + groupIndex: AccountGroupMultichainAccountObject['metadata']['entropy']['groupIndex']; +} & Infer; + +export type LegacyUserStorageSyncedAccount = Infer< + typeof LegacyUserStorageSyncedAccountSchema +>; + +export type BackupAndSyncContext = { + messenger: AccountTreeControllerMessenger; + controller: AccountTreeController; + controllerStateUpdateFn: AccountTreeController['update']; + traceFn: TraceCallback; + groupIdToWalletId: Map; + emitAnalyticsEventFn: (event: BackupAndSyncEmitAnalyticsEventParams) => void; +}; + +export type LegacyAccountSyncingContext = { + listAccounts: () => InternalAccount[]; + getEntropyRule: () => { + match: ( + account: InternalAccount, + ) => + | RuleResult< + AccountWalletType.Entropy, + AccountGroupType.MultichainAccount + > + | undefined; + }; +}; + +export type AtomicSyncEvent = { + execute: () => Promise; +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/constants.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/constants.ts new file mode 100644 index 00000000000..1c3e687a9cd --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/constants.ts @@ -0,0 +1,6 @@ +export const USER_STORAGE_FEATURE_PREFIX = 'multichain_accounts'; + +export const USER_STORAGE_WALLETS_FEATURE_KEY = `${USER_STORAGE_FEATURE_PREFIX}_wallets`; +export const USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY = 'wallet'; + +export const USER_STORAGE_GROUPS_FEATURE_KEY = `${USER_STORAGE_FEATURE_PREFIX}_groups`; diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.test.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.test.ts new file mode 100644 index 00000000000..2e9540c31c3 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.test.ts @@ -0,0 +1,282 @@ +import { + formatWalletForUserStorageUsage, + formatGroupForUserStorageUsage, + parseWalletFromUserStorageResponse, + parseGroupFromUserStorageResponse, + parseLegacyAccountFromUserStorageResponse, +} from './format-utils'; +import { + assertValidUserStorageWallet, + assertValidUserStorageGroup, + assertValidLegacyUserStorageAccount, +} from './validation'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import type { BackupAndSyncContext, UserStorageSyncedWallet } from '../types'; + +jest.mock('./validation'); + +const mockAssertValidUserStorageWallet = + assertValidUserStorageWallet as jest.MockedFunction< + typeof assertValidUserStorageWallet + >; +const mockAssertValidUserStorageGroup = + assertValidUserStorageGroup as jest.MockedFunction< + typeof assertValidUserStorageGroup + >; +const mockAssertValidLegacyUserStorageAccount = + assertValidLegacyUserStorageAccount as jest.MockedFunction< + typeof assertValidLegacyUserStorageAccount + >; + +describe('BackupAndSync - UserStorage - FormatUtils', () => { + let mockContext: BackupAndSyncContext; + let mockWallet: AccountWalletEntropyObject; + let mockGroup: AccountGroupMultichainAccountObject; + + beforeEach(() => { + mockContext = { + controller: { + state: { + accountWalletsMetadata: {}, + accountGroupsMetadata: {}, + }, + }, + } as unknown as BackupAndSyncContext; + + mockWallet = { + id: 'entropy:wallet-1', + name: 'Test Wallet', + } as unknown as AccountWalletEntropyObject; + + mockGroup = { + id: 'entropy:wallet-1/group-1', + name: 'Test Group', + metadata: { entropy: { groupIndex: 0 } }, + } as unknown as AccountGroupMultichainAccountObject; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('formatWalletForUserStorageUsage', () => { + it('returns wallet metadata when it exists', () => { + const walletMetadata: UserStorageSyncedWallet = { + name: { value: 'Wallet Name', lastUpdatedAt: 123456 }, + }; + mockContext.controller.state.accountWalletsMetadata[mockWallet.id] = + walletMetadata; + + const result = formatWalletForUserStorageUsage(mockContext, mockWallet); + + expect(result).toStrictEqual({ + ...walletMetadata, + isLegacyAccountSyncingDisabled: true, + }); + }); + + it('returns default object when no wallet metadata exists', () => { + const result = formatWalletForUserStorageUsage(mockContext, mockWallet); + + expect(result).toStrictEqual({ + isLegacyAccountSyncingDisabled: true, + }); + }); + }); + + describe('formatGroupForUserStorageUsage', () => { + it('returns group metadata with groupIndex', () => { + const groupMetadata = { + name: { value: 'Group Name', lastUpdatedAt: 123456 }, + pinned: { value: true, lastUpdatedAt: 123456 }, + }; + mockContext.controller.state.accountGroupsMetadata[mockGroup.id] = + groupMetadata; + + const result = formatGroupForUserStorageUsage(mockContext, mockGroup); + + expect(result).toStrictEqual({ + ...groupMetadata, + groupIndex: 0, + }); + }); + + it('returns only groupIndex when no group metadata exists', () => { + const result = formatGroupForUserStorageUsage(mockContext, mockGroup); + + expect(result).toStrictEqual({ + groupIndex: 0, + }); + }); + }); + + describe('parseWalletFromUserStorageResponse', () => { + it('parses valid wallet JSON', () => { + const walletData = { + name: { value: 'Test Wallet', lastUpdatedAt: 123456 }, + }; + const walletString = JSON.stringify(walletData); + + mockAssertValidUserStorageWallet.mockImplementation(() => true); + + const result = parseWalletFromUserStorageResponse(walletString); + + expect(result).toStrictEqual(walletData); + expect(mockAssertValidUserStorageWallet).toHaveBeenCalledWith(walletData); + }); + + it('throws error for invalid JSON', () => { + const invalidJson = 'invalid json string'; + + expect(() => parseWalletFromUserStorageResponse(invalidJson)).toThrow( + 'Error trying to parse wallet from user storage response:', + ); + }); + + it('throws error when validation fails', () => { + const walletData = { invalid: 'data' }; + const walletString = JSON.stringify(walletData); + + mockAssertValidUserStorageWallet.mockImplementation(() => { + throw new Error('Validation failed'); + }); + + expect(() => parseWalletFromUserStorageResponse(walletString)).toThrow( + 'Error trying to parse wallet from user storage response: Validation failed', + ); + }); + }); + + describe('parseGroupFromUserStorageResponse', () => { + it('parses valid group JSON', () => { + const groupData = { + groupIndex: 0, + name: { value: 'Test Group', lastUpdatedAt: 123456 }, + }; + const groupString = JSON.stringify(groupData); + + mockAssertValidUserStorageGroup.mockImplementation(() => true); + + const result = parseGroupFromUserStorageResponse(groupString); + + expect(result).toStrictEqual(groupData); + expect(mockAssertValidUserStorageGroup).toHaveBeenCalledWith(groupData); + }); + + it('throws error for invalid JSON', () => { + const invalidJson = 'invalid json string'; + + expect(() => parseGroupFromUserStorageResponse(invalidJson)).toThrow( + 'Error trying to parse group from user storage response:', + ); + }); + + it('throws error when validation fails', () => { + const groupData = { invalid: 'data' }; + const groupString = JSON.stringify(groupData); + + mockAssertValidUserStorageGroup.mockImplementation(() => { + throw new Error('Validation failed'); + }); + + expect(() => parseGroupFromUserStorageResponse(groupString)).toThrow( + 'Error trying to parse group from user storage response: Validation failed', + ); + }); + + it('handles non-Error thrown objects in wallet parsing', () => { + const walletData = { valid: 'data' }; + const walletString = JSON.stringify(walletData); + + /* eslint-disable @typescript-eslint/only-throw-error */ + mockAssertValidUserStorageWallet.mockImplementation(() => { + throw 'String error'; // Throw a non-Error object + }); + /* eslint-enable @typescript-eslint/only-throw-error */ + + expect(() => parseWalletFromUserStorageResponse(walletString)).toThrow( + 'Error trying to parse wallet from user storage response: String error', + ); + }); + + it('handles non-Error thrown objects in group parsing', () => { + const groupData = { valid: 'data' }; + const groupString = JSON.stringify(groupData); + + /* eslint-disable @typescript-eslint/only-throw-error */ + mockAssertValidUserStorageGroup.mockImplementation(() => { + throw { message: 'Object error' }; // Throw a non-Error object + }); + /* eslint-enable @typescript-eslint/only-throw-error */ + + expect(() => parseGroupFromUserStorageResponse(groupString)).toThrow( + 'Error trying to parse group from user storage response: [object Object]', + ); + }); + }); + + describe('parseLegacyAccountFromUserStorageResponse', () => { + it('parses valid legacy account JSON', () => { + const accountData = { + n: 'Test Account', + a: '0x123456789abcdef', + v: '1', + i: 'test-id', + nlu: 1234567890, + }; + const accountString = JSON.stringify(accountData); + + mockAssertValidLegacyUserStorageAccount.mockImplementation(() => true); + + const result = parseLegacyAccountFromUserStorageResponse(accountString); + + expect(result).toStrictEqual(accountData); + expect(mockAssertValidLegacyUserStorageAccount).toHaveBeenCalledWith( + accountData, + ); + }); + + it('throws error for invalid JSON', () => { + const invalidJson = 'invalid json string'; + + expect(() => + parseLegacyAccountFromUserStorageResponse(invalidJson), + ).toThrow( + 'Error trying to parse legacy account from user storage response:', + ); + }); + + it('throws error when validation fails', () => { + const accountData = { invalid: 'data' }; + const accountString = JSON.stringify(accountData); + + mockAssertValidLegacyUserStorageAccount.mockImplementation(() => { + throw new Error('Validation failed'); + }); + + expect(() => + parseLegacyAccountFromUserStorageResponse(accountString), + ).toThrow( + 'Error trying to parse legacy account from user storage response: Validation failed', + ); + }); + + it('handles non-Error thrown objects in legacy account parsing', () => { + const accountData = { valid: 'data' }; + const accountString = JSON.stringify(accountData); + + /* eslint-disable @typescript-eslint/only-throw-error */ + mockAssertValidLegacyUserStorageAccount.mockImplementation(() => { + throw 'String error'; // Throw a non-Error object + }); + /* eslint-enable @typescript-eslint/only-throw-error */ + + expect(() => + parseLegacyAccountFromUserStorageResponse(accountString), + ).toThrow( + 'Error trying to parse legacy account from user storage response: String error', + ); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.ts new file mode 100644 index 00000000000..0e9fb3e22c8 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.ts @@ -0,0 +1,128 @@ +import { + assertValidUserStorageWallet, + assertValidUserStorageGroup, + assertValidLegacyUserStorageAccount, +} from './validation'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import type { + BackupAndSyncContext, + LegacyUserStorageSyncedAccount, + UserStorageSyncedWallet, + UserStorageSyncedWalletGroup, +} from '../types'; + +/** + * Formats the wallet for user storage usage. + * This function extracts the necessary metadata from the wallet + * and formats it according to the user storage requirements. + * + * @param context - The backup and sync context. + * @param wallet - The wallet object to format. + * @returns The formatted wallet for user storage. + */ +export const formatWalletForUserStorageUsage = ( + context: BackupAndSyncContext, + wallet: AccountWalletEntropyObject, +): UserStorageSyncedWallet => { + // This can be null if the user has not manually set a name + const persistedWalletMetadata = + context.controller.state.accountWalletsMetadata[wallet.id]; + + return { + ...(persistedWalletMetadata ?? {}), + isLegacyAccountSyncingDisabled: true, // If we're here, it means legacy account syncing has been performed at least once, so we can disable it for this wallet. + }; +}; + +/** + * Formats the group for user storage usage. + * This function extracts the necessary metadata from the group + * and formats it according to the user storage requirements. + * + * @param context - The backup and sync context. + * @param group - The group object to format. + * @returns The formatted group for user storage. + */ +export const formatGroupForUserStorageUsage = ( + context: BackupAndSyncContext, + group: AccountGroupMultichainAccountObject, +): UserStorageSyncedWalletGroup => { + // This can be null if the user has not manually set a name, pinned or hidden the group + const persistedGroupMetadata = + context.controller.state.accountGroupsMetadata[group.id]; + + return { + ...(persistedGroupMetadata ?? {}), + groupIndex: group.metadata.entropy.groupIndex, + }; +}; + +/** + * Parses the wallet from user storage response. + * This function attempts to parse the wallet data from a string format + * and returns it as a UserStorageSyncedWallet object. + * + * @param wallet - The wallet data in string format. + * @returns The parsed UserStorageSyncedWallet object. + * @throws If the wallet data is not in valid JSON format or fails validation. + */ +export const parseWalletFromUserStorageResponse = ( + wallet: string, +): UserStorageSyncedWallet => { + try { + const walletData = JSON.parse(wallet); + assertValidUserStorageWallet(walletData); + return walletData; + } catch (error: unknown) { + throw new Error( + `Error trying to parse wallet from user storage response: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + +/** + * Parses the group from user storage response. + * This function attempts to parse the group data from a string format + * and returns it as a UserStorageSyncedWalletGroup object. + * + * @param group - The group data in string format. + * @returns The parsed UserStorageSyncedWalletGroup object. + * @throws If the group data is not in valid JSON format or fails validation. + */ +export const parseGroupFromUserStorageResponse = ( + group: string, +): UserStorageSyncedWalletGroup => { + try { + const groupData = JSON.parse(group); + assertValidUserStorageGroup(groupData); + return groupData; + } catch (error: unknown) { + throw new Error( + `Error trying to parse group from user storage response: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + +/** + * Parses the legacy account from user storage response. + * This function attempts to parse the account data from a string format + * and returns it as a LegacyUserStorageSyncedAccount object. + * + * @param account - The account data in string format. + * @returns The parsed LegacyUserStorageSyncedAccount object. + * @throws If the account data is not in valid JSON format or fails validation. + */ +export const parseLegacyAccountFromUserStorageResponse = ( + account: string, +): LegacyUserStorageSyncedAccount => { + try { + const accountData = JSON.parse(account); + assertValidLegacyUserStorageAccount(accountData); + return accountData; + } catch (error: unknown) { + throw new Error( + `Error trying to parse legacy account from user storage response: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/index.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/index.ts new file mode 100644 index 00000000000..75b762c215e --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/index.ts @@ -0,0 +1,4 @@ +export * from './format-utils'; +export * from './network-utils'; +export * from './network-operations'; +export * from './validation'; diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.test.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.test.ts new file mode 100644 index 00000000000..d6b98539189 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.test.ts @@ -0,0 +1,581 @@ +import { SDK } from '@metamask/profile-sync-controller'; + +import { + USER_STORAGE_WALLETS_FEATURE_KEY, + USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY, + USER_STORAGE_GROUPS_FEATURE_KEY, +} from './constants'; +import { + formatWalletForUserStorageUsage, + formatGroupForUserStorageUsage, + parseWalletFromUserStorageResponse, + parseGroupFromUserStorageResponse, + parseLegacyAccountFromUserStorageResponse, +} from './format-utils'; +import { + getWalletFromUserStorage, + pushWalletToUserStorage, + getAllGroupsFromUserStorage, + getGroupFromUserStorage, + pushGroupToUserStorage, + pushGroupToUserStorageBatch, + getAllLegacyUserStorageAccounts, +} from './network-operations'; +import { executeWithRetry } from './network-utils'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import type { + BackupAndSyncContext, + UserStorageSyncedWallet, + UserStorageSyncedWalletGroup, +} from '../types'; + +jest.mock('./format-utils'); +jest.mock('./network-utils'); + +const mockFormatWalletForUserStorageUsage = + formatWalletForUserStorageUsage as jest.MockedFunction< + typeof formatWalletForUserStorageUsage + >; +const mockFormatGroupForUserStorageUsage = + formatGroupForUserStorageUsage as jest.MockedFunction< + typeof formatGroupForUserStorageUsage + >; +const mockParseWalletFromUserStorageResponse = + parseWalletFromUserStorageResponse as jest.MockedFunction< + typeof parseWalletFromUserStorageResponse + >; +const mockParseGroupFromUserStorageResponse = + parseGroupFromUserStorageResponse as jest.MockedFunction< + typeof parseGroupFromUserStorageResponse + >; +const mockParseLegacyAccountFromUserStorageResponse = + parseLegacyAccountFromUserStorageResponse as jest.MockedFunction< + typeof parseLegacyAccountFromUserStorageResponse + >; +const mockExecuteWithRetry = executeWithRetry as jest.MockedFunction< + typeof executeWithRetry +>; + +describe('BackupAndSync - UserStorage - NetworkOperations', () => { + let mockContext: BackupAndSyncContext; + let mockWallet: AccountWalletEntropyObject; + let mockGroup: AccountGroupMultichainAccountObject; + + beforeEach(() => { + mockContext = { + messenger: { + call: jest.fn(), + }, + } as unknown as BackupAndSyncContext; + + mockWallet = { + id: 'entropy:wallet-1', + metadata: { entropy: { id: 'test-entropy-id' } }, + } as unknown as AccountWalletEntropyObject; + + mockGroup = { + id: 'entropy:wallet-1/group-1', + metadata: { entropy: { groupIndex: 0 } }, + } as unknown as AccountGroupMultichainAccountObject; + + // Default mock implementation that just calls the operation + mockExecuteWithRetry.mockImplementation(async (operation) => operation()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getWalletFromUserStorage', () => { + it('returns parsed wallet data when found', async () => { + const walletData = '{"name":{"value":"Test Wallet"}}'; + const parsedWallet = { + name: { value: 'Test Wallet' }, + } as unknown as UserStorageSyncedWallet; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(walletData); + mockParseWalletFromUserStorageResponse.mockReturnValue(parsedWallet); + + const result = await getWalletFromUserStorage( + mockContext, + 'test-entropy-id', + ); + + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performGetStorage', + `${USER_STORAGE_WALLETS_FEATURE_KEY}.${USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY}`, + 'test-entropy-id', + ); + expect(mockParseWalletFromUserStorageResponse).toHaveBeenCalledWith( + walletData, + ); + expect(result).toBe(parsedWallet); + }); + + it('returns null when no wallet data found', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(null); + + const result = await getWalletFromUserStorage( + mockContext, + 'test-entropy-id', + ); + + expect(result).toBeNull(); + expect(mockParseWalletFromUserStorageResponse).not.toHaveBeenCalled(); + }); + + it('returns null when parsing fails', async () => { + const walletData = 'invalid json'; + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(walletData); + mockParseWalletFromUserStorageResponse.mockImplementation(() => { + throw new Error('Parse error'); + }); + + const result = await getWalletFromUserStorage( + mockContext, + 'test-entropy-id', + ); + + expect(result).toBeNull(); + }); + + it('covers non-Error exception handling in wallet parsing debug logging', async () => { + // Set up context with debug logging enabled + const debugContext = { + ...mockContext, + }; + + // Mock executeWithRetry to pass through the function directly + mockExecuteWithRetry.mockImplementation(async (fn) => fn()); + + // Set up messenger to return wallet data + jest + .spyOn(debugContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue('wallet-data'); + + // Mock the parser to throw a non-Error object + /* eslint-disable @typescript-eslint/only-throw-error */ + mockParseWalletFromUserStorageResponse.mockImplementation(() => { + throw 'String error for wallet parsing'; + }); + /* eslint-enable @typescript-eslint/only-throw-error */ + + const result = await getWalletFromUserStorage( + debugContext, + 'test-entropy-id', + ); + + expect(result).toBeNull(); + }); + }); + + describe('pushWalletToUserStorage', () => { + it('formats and push wallet to user storage', async () => { + const formattedWallet = { + name: { value: 'Formatted Wallet' }, + } as unknown as UserStorageSyncedWallet; + + mockFormatWalletForUserStorageUsage.mockReturnValue(formattedWallet); + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(undefined); + + await pushWalletToUserStorage(mockContext, mockWallet); + + expect(mockFormatWalletForUserStorageUsage).toHaveBeenCalledWith( + mockContext, + mockWallet, + ); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performSetStorage', + `${USER_STORAGE_WALLETS_FEATURE_KEY}.${USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY}`, + JSON.stringify(formattedWallet), + 'test-entropy-id', + ); + }); + }); + + describe('getAllGroupsFromUserStorage', () => { + it('returns parsed groups array when found', async () => { + const groupsData = ['{"groupIndex":0}', '{"groupIndex":1}']; + const parsedGroups = [ + { groupIndex: 0 }, + { groupIndex: 1 }, + ] as unknown as UserStorageSyncedWalletGroup[]; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(groupsData); + mockParseGroupFromUserStorageResponse + .mockReturnValueOnce(parsedGroups[0]) + .mockReturnValueOnce(parsedGroups[1]); + + const result = await getAllGroupsFromUserStorage( + mockContext, + 'test-entropy-id', + ); + + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performGetStorageAllFeatureEntries', + USER_STORAGE_GROUPS_FEATURE_KEY, + 'test-entropy-id', + ); + expect(result).toStrictEqual(parsedGroups); + }); + + it('returns empty array when no group data found', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(null); + + const result = await getAllGroupsFromUserStorage( + mockContext, + 'test-entropy-id', + ); + + expect(result).toStrictEqual([]); + }); + + it('filters out invalid groups when parsing fails', async () => { + const groupsData = [ + '{"groupIndex":0}', + 'invalid json', + '{"groupIndex":1}', + ]; + const validGroups = [ + { groupIndex: 0 }, + { groupIndex: 1 }, + ] as unknown as UserStorageSyncedWalletGroup[]; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(groupsData); + mockParseGroupFromUserStorageResponse + .mockReturnValueOnce(validGroups[0]) + .mockImplementationOnce(() => { + throw new Error('Parse error'); + }) + .mockReturnValueOnce(validGroups[1]); + + const result = await getAllGroupsFromUserStorage( + mockContext, + 'test-entropy-id', + ); + + expect(result).toStrictEqual(validGroups); + }); + + it('covers non-Error exception handling in getAllGroups debug logging', async () => { + // Set up context with debug logging enabled + const debugContext = { + ...mockContext, + }; + + // Mock executeWithRetry to pass through the function directly + mockExecuteWithRetry.mockImplementation(async (fn) => fn()); + + // Set up messenger to return groups data with one invalid entry + jest + .spyOn(debugContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(['valid-json', 'invalid-json']); + + // Mock the parser - first call succeeds, second throws non-Error + /* eslint-disable @typescript-eslint/only-throw-error */ + mockParseGroupFromUserStorageResponse + .mockReturnValueOnce({ groupIndex: 0 }) + .mockImplementationOnce(() => { + throw 'String error for group parsing'; + }); + /* eslint-enable @typescript-eslint/only-throw-error */ + + const result = await getAllGroupsFromUserStorage( + debugContext, + 'test-entropy-id', + ); + + expect(result).toStrictEqual([{ groupIndex: 0 }]); + }); + }); + + describe('getGroupFromUserStorage', () => { + it('returns parsed group when found', async () => { + const groupData = '{"groupIndex":0}'; + const parsedGroup = { groupIndex: 0 }; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(groupData); + mockParseGroupFromUserStorageResponse.mockReturnValue(parsedGroup); + + const result = await getGroupFromUserStorage( + mockContext, + 'test-entropy-id', + 0, + ); + + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performGetStorage', + `${USER_STORAGE_GROUPS_FEATURE_KEY}.0`, + 'test-entropy-id', + ); + expect(result).toBe(parsedGroup); + }); + + it('returns null when parsing fails', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue('invalid json'); + mockParseGroupFromUserStorageResponse.mockImplementation(() => { + throw new Error('Parse error'); + }); + + const result = await getGroupFromUserStorage( + mockContext, + 'test-entropy-id', + 0, + ); + + expect(result).toBeNull(); + }); + + it('returns null when there is no group data', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(null); + + const result = await getGroupFromUserStorage( + mockContext, + 'test-entropy-id', + 0, + ); + + expect(result).toBeNull(); + }); + + it('logs debug warning when parsing fails and debug logging is enabled', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue('invalid json'); + mockParseGroupFromUserStorageResponse.mockImplementation(() => { + throw new Error('Parse error'); + }); + + const result = await getGroupFromUserStorage( + mockContext, + 'test-entropy-id', + 0, + ); + + expect(result).toBeNull(); + }); + + it('handles non-Error objects in debug logging', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue('invalid json'); + /* eslint-disable @typescript-eslint/only-throw-error */ + mockParseGroupFromUserStorageResponse.mockImplementation(() => { + throw 'String error'; // Non-Error object + }); + /* eslint-enable @typescript-eslint/only-throw-error */ + + const result = await getGroupFromUserStorage( + mockContext, + 'test-entropy-id', + 0, + ); + + expect(result).toBeNull(); + }); + }); + + describe('pushGroupToUserStorage', () => { + it('formats and push group to user storage', async () => { + // Set up context with debug logging enabled + const debugContext = { + ...mockContext, + }; + + const formattedGroup = { + groupIndex: 0, + name: { value: 'Test Group' }, + } as unknown as UserStorageSyncedWalletGroup; + + mockFormatGroupForUserStorageUsage.mockReturnValue(formattedGroup); + + await pushGroupToUserStorage(debugContext, mockGroup, 'test-entropy-id'); + + expect(mockFormatGroupForUserStorageUsage).toHaveBeenCalledWith( + debugContext, + mockGroup, + ); + expect(debugContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performSetStorage', + `${USER_STORAGE_GROUPS_FEATURE_KEY}.0`, + JSON.stringify(formattedGroup), + 'test-entropy-id', + ); + }); + }); + + describe('pushGroupToUserStorageBatch', () => { + it('formats and batch push groups to user storage', async () => { + const groups = [ + mockGroup, + { ...mockGroup, metadata: { entropy: { groupIndex: 1 } } }, + ] as unknown as AccountGroupMultichainAccountObject[]; + const formattedGroups = [ + { groupIndex: 0, name: { value: 'Group 1' } }, + { groupIndex: 1, name: { value: 'Group 2' } }, + ] as unknown as UserStorageSyncedWalletGroup[]; + + mockFormatGroupForUserStorageUsage + .mockReturnValueOnce(formattedGroups[0]) + .mockReturnValueOnce(formattedGroups[1]); + + await pushGroupToUserStorageBatch(mockContext, groups, 'test-entropy-id'); + + expect(mockFormatGroupForUserStorageUsage).toHaveBeenCalledTimes(2); + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_GROUPS_FEATURE_KEY, + [ + ['0', JSON.stringify(formattedGroups[0])], + ['1', JSON.stringify(formattedGroups[1])], + ], + 'test-entropy-id', + ); + }); + }); + + describe('getAllLegacyUserStorageAccounts', () => { + it('returns parsed legacy account data', async () => { + const rawAccountsData = [ + '{"a":"address1","n":"name1","nlu":123}', + '{"a":"address2","n":"name2","nlu":456}', + ]; + const expectedData = [ + { a: 'address1', n: 'name1', nlu: 123 }, + { a: 'address2', n: 'name2', nlu: 456 }, + ]; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(rawAccountsData); + mockParseLegacyAccountFromUserStorageResponse + .mockReturnValueOnce(expectedData[0]) + .mockReturnValueOnce(expectedData[1]); + + const result = await getAllLegacyUserStorageAccounts( + mockContext, + 'test-entropy-id', + ); + + expect(mockContext.messenger.call).toHaveBeenCalledWith( + 'UserStorageController:performGetStorageAllFeatureEntries', + SDK.USER_STORAGE_FEATURE_NAMES.accounts, + 'test-entropy-id', + ); + expect(result).toStrictEqual(expectedData); + }); + + it('returns empty array when no legacy data found', async () => { + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(null); + + const result = await getAllLegacyUserStorageAccounts( + mockContext, + 'test-entropy-id', + ); + + expect(result).toStrictEqual([]); + }); + + it('filters out invalid legacy accounts and log warnings when debug enabled', async () => { + const rawAccountsData = [ + '{"a":"address1","n":"name1","nlu":123}', // Valid + '{"invalid":"data"}', // Invalid - will throw error + '{"a":"address2","n":"name2","nlu":456}', // Valid + ]; + const expectedValidData = [ + { a: 'address1', n: 'name1', nlu: 123 }, + { a: 'address2', n: 'name2', nlu: 456 }, + ]; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(rawAccountsData); + + mockParseLegacyAccountFromUserStorageResponse + .mockReturnValueOnce(expectedValidData[0]) + .mockImplementationOnce(() => { + throw new Error('Parse error for invalid data'); + }) + .mockReturnValueOnce(expectedValidData[1]); + + const mockContextWithDebug = { + ...mockContext, + }; + + const result = await getAllLegacyUserStorageAccounts( + mockContextWithDebug, + 'test-entropy-id', + ); + + expect(result).toStrictEqual(expectedValidData); + }); + + it('handles non-Error objects thrown during parsing', async () => { + const rawAccountsData = ['{"invalid":"data"}']; + + jest + .spyOn(mockContext.messenger, 'call') + .mockImplementation() + .mockResolvedValue(rawAccountsData); + + /* eslint-disable @typescript-eslint/only-throw-error */ + mockParseLegacyAccountFromUserStorageResponse.mockImplementationOnce( + () => { + throw 'String error'; // Non-Error object + }, + ); + /* eslint-enable @typescript-eslint/only-throw-error */ + + const mockContextWithDebug = { + ...mockContext, + }; + + const result = await getAllLegacyUserStorageAccounts( + mockContextWithDebug, + 'test-entropy-id', + ); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.ts new file mode 100644 index 00000000000..53690a37f4b --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.ts @@ -0,0 +1,289 @@ +import { SDK } from '@metamask/profile-sync-controller'; + +import { + USER_STORAGE_GROUPS_FEATURE_KEY, + USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY, + USER_STORAGE_WALLETS_FEATURE_KEY, +} from './constants'; +import { + formatWalletForUserStorageUsage, + formatGroupForUserStorageUsage, + parseWalletFromUserStorageResponse, + parseGroupFromUserStorageResponse, + parseLegacyAccountFromUserStorageResponse, +} from './format-utils'; +import { executeWithRetry } from './network-utils'; +import type { AccountGroupMultichainAccountObject } from '../../group'; +import { backupAndSyncLogger } from '../../logger'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import type { + BackupAndSyncContext, + LegacyUserStorageSyncedAccount, + UserStorageSyncedWallet, + UserStorageSyncedWalletGroup, +} from '../types'; + +/** + * Retrieves the wallet from user storage. + * + * @param context - The backup and sync context. + * @param entropySourceId - The entropy source ID. + * @returns The wallet from user storage or null if not found or invalid. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + */ +export const getWalletFromUserStorage = async ( + context: BackupAndSyncContext, + entropySourceId: string, +): Promise => { + return executeWithRetry(async () => { + const walletData = await context.messenger.call( + 'UserStorageController:performGetStorage', + `${USER_STORAGE_WALLETS_FEATURE_KEY}.${USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY}`, + entropySourceId, + ); + if (!walletData) { + return null; + } + + try { + backupAndSyncLogger( + `Retrieved wallet data from user storage: ${JSON.stringify(walletData)}`, + ); + return parseWalletFromUserStorageResponse(walletData); + } catch (error) { + backupAndSyncLogger( + `Failed to parse wallet data from user storage: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }); +}; + +/** + * Pushes the wallet to user storage. + * + * @param context - The backup and sync context. + * @param wallet - The wallet to push to user storage. + * @returns A promise that resolves when the operation is complete. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + * @throws When JSON.stringify fails on the formatted wallet data. + */ +export const pushWalletToUserStorage = async ( + context: BackupAndSyncContext, + wallet: AccountWalletEntropyObject, +): Promise => { + return executeWithRetry(async () => { + const formattedWallet = formatWalletForUserStorageUsage(context, wallet); + const stringifiedWallet = JSON.stringify(formattedWallet); + const entropySourceId = wallet.metadata.entropy.id; + + backupAndSyncLogger(`Pushing wallet to user storage: ${stringifiedWallet}`); + + return await context.messenger.call( + 'UserStorageController:performSetStorage', + `${USER_STORAGE_WALLETS_FEATURE_KEY}.${USER_STORAGE_WALLETS_FEATURE_ENTRY_KEY}`, + stringifiedWallet, + entropySourceId, + ); + }); +}; + +/** + * Retrieves all groups from user storage. + * + * @param context - The backup and sync context. + * @param entropySourceId - The entropy source ID. + * @returns An array of groups from user storage. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + */ +export const getAllGroupsFromUserStorage = async ( + context: BackupAndSyncContext, + entropySourceId: string, +): Promise => { + return executeWithRetry(async () => { + const groupData = await context.messenger.call( + 'UserStorageController:performGetStorageAllFeatureEntries', + `${USER_STORAGE_GROUPS_FEATURE_KEY}`, + entropySourceId, + ); + if (!groupData) { + return []; + } + + const allGroups = groupData + .map((stringifiedGroup) => { + try { + return parseGroupFromUserStorageResponse(stringifiedGroup); + } catch (error) { + backupAndSyncLogger( + `Failed to parse group data from user storage: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }) + .filter((group): group is UserStorageSyncedWalletGroup => group !== null); + + backupAndSyncLogger( + `Retrieved groups from user storage: ${JSON.stringify(allGroups)}`, + ); + + return allGroups; + }); +}; + +/** + * Retrieves a single group from user storage by group index. + * + * @param context - The backup and sync context. + * @param entropySourceId - The entropy source ID. + * @param groupIndex - The group index to retrieve. + * @returns The group from user storage or null if not found or invalid. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + */ +export const getGroupFromUserStorage = async ( + context: BackupAndSyncContext, + entropySourceId: string, + groupIndex: number, +): Promise => { + return executeWithRetry(async () => { + const groupData = await context.messenger.call( + 'UserStorageController:performGetStorage', + `${USER_STORAGE_GROUPS_FEATURE_KEY}.${groupIndex}`, + entropySourceId, + ); + if (!groupData) { + return null; + } + + try { + return parseGroupFromUserStorageResponse(groupData); + } catch (error) { + backupAndSyncLogger( + `Failed to parse group data from user storage: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }); +}; + +/** + * Pushes a group to user storage. + * + * @param context - The backup and sync context. + * @param group - The group to push to user storage. + * @param entropySourceId - The entropy source ID. + * @returns A promise that resolves when the operation is complete. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + * @throws When JSON.stringify fails on the formatted group data. + */ +export const pushGroupToUserStorage = async ( + context: BackupAndSyncContext, + group: AccountGroupMultichainAccountObject, + entropySourceId: string, +): Promise => { + return executeWithRetry(async () => { + const formattedGroup = formatGroupForUserStorageUsage(context, group); + const stringifiedGroup = JSON.stringify(formattedGroup); + + backupAndSyncLogger(`Pushing group to user storage: ${stringifiedGroup}`); + + return await context.messenger.call( + 'UserStorageController:performSetStorage', + `${USER_STORAGE_GROUPS_FEATURE_KEY}.${formattedGroup.groupIndex}`, + stringifiedGroup, + entropySourceId, + ); + }); +}; + +/** + * Pushes a batch of groups to user storage. + * + * @param context - The backup and sync context. + * @param groups - The groups to push to user storage. + * @param entropySourceId - The entropy source ID. + * @returns A promise that resolves when the operation is complete. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + * @throws When JSON.stringify fails on any of the formatted group data. + */ +export const pushGroupToUserStorageBatch = async ( + context: BackupAndSyncContext, + groups: AccountGroupMultichainAccountObject[], + entropySourceId: string, +): Promise => { + return executeWithRetry(async () => { + const formattedGroups = groups.map((group) => + formatGroupForUserStorageUsage(context, group), + ); + + const entries: [string, string][] = formattedGroups.map((group) => [ + String(group.groupIndex), + JSON.stringify(group), + ]); + + backupAndSyncLogger( + `Pushing groups to user storage: ${entries.map(([_, value]) => value).join(', ')}`, + ); + + return await context.messenger.call( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_GROUPS_FEATURE_KEY, + entries, + entropySourceId, + ); + }); +}; + +/** + * Retrieves legacy user storage accounts for a specific entropy source ID. + * + * @param context - The backup and sync context. + * @param entropySourceId - The entropy source ID to retrieve data for. + * @returns A promise that resolves with the legacy user storage accounts. + * @throws When network operations fail after maximum retry attempts. + * @throws When messenger calls to UserStorageController fail due to authentication errors, encryption/decryption failures, or network issues. + */ +export const getAllLegacyUserStorageAccounts = async ( + context: BackupAndSyncContext, + entropySourceId: string, +): Promise => { + return executeWithRetry(async () => { + const accountsData = await context.messenger.call( + 'UserStorageController:performGetStorageAllFeatureEntries', + SDK.USER_STORAGE_FEATURE_NAMES.accounts, + entropySourceId, + ); + + if (!accountsData) { + return []; + } + + const allAccounts = accountsData + .map((stringifiedAccount) => { + try { + return parseLegacyAccountFromUserStorageResponse(stringifiedAccount); + } catch (error) { + backupAndSyncLogger( + `Failed to parse legacy account data from user storage: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }) + .filter( + (account): account is LegacyUserStorageSyncedAccount => + account !== null, + ); + + backupAndSyncLogger( + `Retrieved legacy accounts from user storage: ${JSON.stringify(allAccounts)}`, + ); + + return allAccounts; + }); +}; diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.test.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.test.ts new file mode 100644 index 00000000000..eee876f3f97 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.test.ts @@ -0,0 +1,134 @@ +import { executeWithRetry } from './network-utils'; + +describe('BackupAndSync - UserStorage - NetworkUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('executeWithRetry', () => { + it('returns result on successful operation', async () => { + const mockOperation = jest.fn().mockResolvedValue('success'); + + const result = await executeWithRetry(mockOperation); + + expect(result).toBe('success'); + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + + it('retries on failure and eventually succeed', async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('First attempt failed')) + .mockRejectedValueOnce(new Error('Second attempt failed')) + .mockResolvedValueOnce('success on third attempt'); + + const result = await executeWithRetry(mockOperation, 3, 10); + + expect(result).toBe('success on third attempt'); + expect(mockOperation).toHaveBeenCalledTimes(3); + }); + + it('throws last error after max retries exceeded', async () => { + const lastError = new Error('Final failure'); + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('First failure')) + .mockRejectedValueOnce(new Error('Second failure')) + .mockRejectedValueOnce(lastError); + + await expect(executeWithRetry(mockOperation, 2, 10)).rejects.toThrow( + 'Final failure', + ); + expect(mockOperation).toHaveBeenCalledTimes(3); + }); + + it('uses default parameters', async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('First failure')) + .mockResolvedValueOnce('success on retry'); + + // Mock setTimeout to avoid actual delays but verify default parameters are used + const originalSetTimeout = setTimeout; + const mockSetTimeout = jest.fn().mockImplementation((callback) => { + callback(); // Execute immediately + return 'timeout-id'; + }); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + global.setTimeout = mockSetTimeout as any; + + try { + const result = await executeWithRetry(mockOperation); + + expect(result).toBe('success on retry'); + expect(mockOperation).toHaveBeenCalledTimes(2); + // Verify default delay (1000ms) was used + expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + } finally { + global.setTimeout = originalSetTimeout; + } + }); + + it('works with custom parameters', async () => { + const mockOperation = jest + .fn() + .mockRejectedValue(new Error('Always fails')); + + await expect(executeWithRetry(mockOperation, 3, 1)).rejects.toThrow( + 'Always fails', + ); + expect(mockOperation).toHaveBeenCalledTimes(4); // 1 + 3 retries + }); + + it('handles non-Error thrown objects', async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce('string error') + .mockRejectedValueOnce({ message: 'object error' }) + .mockRejectedValueOnce(42); + + await expect(executeWithRetry(mockOperation, 2, 10)).rejects.toThrow( + '42', + ); + expect(mockOperation).toHaveBeenCalledTimes(3); + }); + + it('applies exponential backoff delay', async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('First failure')) + .mockRejectedValueOnce(new Error('Second failure')) + .mockResolvedValueOnce('success'); + + const startTime = Date.now(); + const result = await executeWithRetry(mockOperation, 3, 50); + const endTime = Date.now(); + + expect(result).toBe('success'); + expect(endTime - startTime).toBeGreaterThan(50 + 100 - 10); // Allow for timing variance + expect(mockOperation).toHaveBeenCalledTimes(3); + }); + + it('handles edge case where operation never succeeds with zero retries', async () => { + const mockOperation = jest + .fn() + .mockRejectedValue(new Error('Never succeeds')); + + await expect(executeWithRetry(mockOperation, 0, 10)).rejects.toThrow( + 'Never succeeds', + ); + expect(mockOperation).toHaveBeenCalledTimes(1); // Only the initial attempt + }); + + it('handles immediate failure on first attempt with minimal retries', async () => { + const mockOperation = jest + .fn() + .mockRejectedValue(new Error('Immediate failure')); + + await expect(executeWithRetry(mockOperation, 1, 1)).rejects.toThrow( + 'Immediate failure', + ); + expect(mockOperation).toHaveBeenCalledTimes(2); // Initial + 1 retry + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.ts new file mode 100644 index 00000000000..89e9091be35 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/network-utils.ts @@ -0,0 +1,36 @@ +/** + * Executes a network operation with retry logic for transient failures. + * + * @param operation - The async operation to execute. + * @param maxRetries - Maximum number of retry attempts. + * @param baseDelayMs - Base delay between retries in milliseconds. + * @returns Promise that resolves with the operation result. + */ +export async function executeWithRetry( + operation: () => Promise, + maxRetries = 3, + baseDelayMs = 1000, +): Promise { + let lastError: Error = new Error('Unknown error'); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt === maxRetries) { + break; // Exit loop after final attempt + } + + // Calculate exponential backoff delay + const delayMs = baseDelayMs * Math.pow(2, attempt); + + // Wait before retry + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + // This will only be reached if all attempts failed + throw lastError; +} diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/validation.test.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/validation.test.ts new file mode 100644 index 00000000000..5fb6387f92b --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/validation.test.ts @@ -0,0 +1,220 @@ +import { + assertValidUserStorageWallet, + assertValidUserStorageGroup, + assertValidLegacyUserStorageAccount, +} from './validation'; + +describe('BackupAndSync - UserStorage - Validation', () => { + describe('assertValidUserStorageWallet', () => { + it('passes for valid wallet data', () => { + const validWalletData = { + name: { value: 'Test Wallet', lastUpdatedAt: 1234567890 }, + }; + + expect(() => assertValidUserStorageWallet(validWalletData)).not.toThrow(); + }); + + it('throws error for invalid wallet data with detailed message', () => { + const invalidWalletData = { + name: { value: 123, lastUpdatedAt: 'invalid' }, // value should be string, lastUpdatedAt should be number + }; + + expect(() => assertValidUserStorageWallet(invalidWalletData)).toThrow( + /Invalid user storage wallet data:/u, + ); + }); + + it('throws error for completely invalid data structure', () => { + const invalidData = 'not an object'; + + expect(() => assertValidUserStorageWallet(invalidData)).toThrow( + /Invalid user storage wallet data:/u, + ); + }); + + it('handles missing required fields', () => { + const incompleteData = {}; + + expect(() => assertValidUserStorageWallet(incompleteData)).not.toThrow(); + }); + + it('handles null data', () => { + expect(() => assertValidUserStorageWallet(null)).toThrow( + /Invalid user storage wallet data:/u, + ); + }); + + it('handles undefined data', () => { + expect(() => assertValidUserStorageWallet(undefined)).toThrow( + /Invalid user storage wallet data:/u, + ); + }); + }); + + describe('assertValidUserStorageGroup', () => { + it('passes for valid group data', () => { + const validGroupData = { + name: { value: 'Test Group', lastUpdatedAt: 1234567890 }, + pinned: { value: true, lastUpdatedAt: 1234567890 }, + hidden: { value: false, lastUpdatedAt: 1234567890 }, + groupIndex: 0, + }; + + expect(() => assertValidUserStorageGroup(validGroupData)).not.toThrow(); + }); + + it('throws error for invalid group data with detailed message', () => { + const invalidGroupData = { + name: { value: 123, lastUpdatedAt: 'invalid' }, // value should be string, lastUpdatedAt should be number + groupIndex: 'not a number', // should be number + }; + + expect(() => assertValidUserStorageGroup(invalidGroupData)).toThrow( + /Invalid user storage group data:/u, + ); + }); + + it('throws error for completely invalid data structure', () => { + const invalidData = null; + + expect(() => assertValidUserStorageGroup(invalidData)).toThrow( + /Invalid user storage group data:/u, + ); + }); + + it('handles edge cases in validation failures', () => { + // Test with nested path failures + const dataWithNestedIssues = { + name: { + value: 'Valid Name', + lastUpdatedAt: null, // This should cause a validation error + }, + pinned: { + value: 'not boolean', // This should cause a validation error + lastUpdatedAt: 1234567890, + }, + }; + + expect(() => assertValidUserStorageGroup(dataWithNestedIssues)).toThrow( + /Invalid user storage group data:/u, + ); + }); + + it('handles array input', () => { + expect(() => assertValidUserStorageGroup([])).toThrow( + /Invalid user storage group data:/u, + ); + }); + + it('handles string input', () => { + expect(() => assertValidUserStorageGroup('invalid')).toThrow( + /Invalid user storage group data:/u, + ); + }); + + it('handles number input', () => { + expect(() => assertValidUserStorageGroup(123)).toThrow( + /Invalid user storage group data:/u, + ); + }); + + it('handles boolean input', () => { + expect(() => assertValidUserStorageGroup(true)).toThrow( + /Invalid user storage group data:/u, + ); + }); + }); + + describe('assertValidLegacyUserStorageAccount', () => { + it('passes for valid legacy account data', () => { + const validAccountData = { + v: '1.0', + i: 'identifier123', + a: '0x1234567890abcdef', + n: 'My Account', + nlu: 1234567890, + }; + + expect(() => + assertValidLegacyUserStorageAccount(validAccountData), + ).not.toThrow(); + }); + + it('passes for minimal legacy account data', () => { + const minimalAccountData = {}; // All fields are optional + + expect(() => + assertValidLegacyUserStorageAccount(minimalAccountData), + ).not.toThrow(); + }); + + it('passes for partial legacy account data', () => { + const partialAccountData = { + a: '0x1234567890abcdef', + n: 'My Account', + }; + + expect(() => + assertValidLegacyUserStorageAccount(partialAccountData), + ).not.toThrow(); + }); + + it('throws error for invalid legacy account data with detailed message', () => { + const invalidAccountData = { + v: 123, // should be string + i: true, // should be string + a: null, // should be string or undefined + n: [], // should be string + nlu: 'not a number', // should be number + }; + + expect(() => + assertValidLegacyUserStorageAccount(invalidAccountData), + ).toThrow(/Invalid legacy user storage account data:/u); + }); + + it('throws error for null input', () => { + expect(() => assertValidLegacyUserStorageAccount(null)).toThrow( + /Invalid legacy user storage account data:/u, + ); + }); + + it('throws error for undefined input', () => { + expect(() => assertValidLegacyUserStorageAccount(undefined)).toThrow( + /Invalid legacy user storage account data:/u, + ); + }); + + it('throws error for string input', () => { + expect(() => assertValidLegacyUserStorageAccount('invalid')).toThrow( + /Invalid legacy user storage account data:/u, + ); + }); + + it('handles multiple validation failures', () => { + const multipleFailuresData = { + v: 123, // wrong type + a: true, // wrong type + n: {}, // wrong type + nlu: 'string', // wrong type + }; + + let errorMessage = ''; + try { + assertValidLegacyUserStorageAccount(multipleFailuresData); + } catch (error) { + // eslint-disable-next-line jest/no-conditional-in-test + errorMessage = error instanceof Error ? error.message : String(error); + } + + expect(errorMessage).toMatch( + /Invalid legacy user storage account data:/u, + ); + // Should contain multiple validation failures + expect(errorMessage).toContain('v'); + expect(errorMessage).toContain('a'); + expect(errorMessage).toContain('n'); + expect(errorMessage).toContain('nlu'); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/user-storage/validation.ts b/packages/account-tree-controller/src/backup-and-sync/user-storage/validation.ts new file mode 100644 index 00000000000..d31bf2bae78 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/user-storage/validation.ts @@ -0,0 +1,92 @@ +import { assert, StructError } from '@metamask/superstruct'; + +import type { + LegacyUserStorageSyncedAccount, + UserStorageSyncedWallet, + UserStorageSyncedWalletGroup, +} from '../types'; +import { + UserStorageSyncedWalletSchema, + UserStorageSyncedWalletGroupSchema, + LegacyUserStorageSyncedAccountSchema, +} from '../types'; + +/** + * Formats validation error messages for user storage data. + * + * @param error - The StructError thrown during validation. + * @returns A formatted string of validation error messages. + */ +const formatValidationErrorMessages = (error: StructError) => { + const validationFailures = error + .failures() + .map(({ path, message }) => `[${path.join('.')}] ${message}`) + .join(', '); + return `Invalid user storage data: ${validationFailures}`; +}; + +/** + * Validates and asserts user storage wallet data, throwing detailed errors if invalid. + * + * @param walletData - The wallet data from user storage to validate. + * @throws StructError if the wallet data is invalid. + */ +export function assertValidUserStorageWallet( + walletData: unknown, +): asserts walletData is UserStorageSyncedWallet { + try { + assert(walletData, UserStorageSyncedWalletSchema); + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid user storage wallet data: ${formatValidationErrorMessages(error)}`, + ); + } + /* istanbul ignore next */ + throw error; + } +} + +/** + * Validates and asserts user storage group data, throwing detailed errors if invalid. + * + * @param groupData - The group data from user storage to validate. + * @throws StructError if the group data is invalid. + */ +export function assertValidUserStorageGroup( + groupData: unknown, +): asserts groupData is UserStorageSyncedWalletGroup { + try { + assert(groupData, UserStorageSyncedWalletGroupSchema); + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid user storage group data: ${formatValidationErrorMessages(error)}`, + ); + } + /* istanbul ignore next */ + throw error; + } +} + +/** + * Validates and asserts legacy user storage account data, throwing detailed errors if invalid. + * + * @param accountData - The account data from user storage to validate. + * @throws StructError if the account data is invalid. + */ +export function assertValidLegacyUserStorageAccount( + accountData: unknown, +): asserts accountData is LegacyUserStorageSyncedAccount { + try { + assert(accountData, LegacyUserStorageSyncedAccountSchema); + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid legacy user storage account data: ${formatValidationErrorMessages(error)}`, + ); + } + /* istanbul ignore next */ + throw error; + } +} diff --git a/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts b/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts new file mode 100644 index 00000000000..be1a5b640fe --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/utils/controller.test.ts @@ -0,0 +1,298 @@ +import { AccountWalletType, AccountGroupType } from '@metamask/account-api'; + +import { + getLocalEntropyWallets, + getLocalGroupsForEntropyWallet, + createStateSnapshot, + restoreStateFromSnapshot, + type StateSnapshot, +} from './controller'; +import type { AccountTreeController } from '../../AccountTreeController'; +import type { + AccountWalletEntropyObject, + AccountWalletKeyringObject, +} from '../../wallet'; +import type { BackupAndSyncContext } from '../types'; + +describe('BackupAndSyncUtils - Controller', () => { + let mockContext: BackupAndSyncContext; + let mockController: AccountTreeController; + let mockControllerStateUpdateFn: jest.Mock; + + beforeEach(() => { + mockControllerStateUpdateFn = jest.fn(); + + mockController = { + state: { + accountTree: { + wallets: {}, + selectedAccountGroup: '', + }, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }, + init: jest.fn(), + } as unknown as AccountTreeController; + + mockContext = { + controller: mockController, + controllerStateUpdateFn: mockControllerStateUpdateFn, + messenger: {} as unknown as BackupAndSyncContext['messenger'], + traceFn: jest.fn(), + groupIdToWalletId: new Map(), + emitAnalyticsEventFn: jest.fn(), + }; + + // Set up the mock implementation for controllerStateUpdateFn + mockControllerStateUpdateFn.mockImplementation((updateFn) => { + updateFn(mockController.state); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getLocalEntropyWallets', () => { + it('returns empty array when no wallets exist', () => { + const result = getLocalEntropyWallets(mockContext); + expect(result).toStrictEqual([]); + }); + + it('returns only entropy wallets', () => { + const entropyWallet = { + id: 'entropy:wallet-1', + type: AccountWalletType.Entropy, + name: 'Entropy Wallet', + groups: {}, + } as unknown as AccountWalletEntropyObject; + + const keyringWallet = { + id: 'keyring:wallet-2', + type: AccountWalletType.Keyring, + name: 'Keyring Wallet', + groups: {}, + } as unknown as AccountWalletKeyringObject; + + mockController.state.accountTree.wallets = { + 'entropy:wallet-1': entropyWallet, + 'keyring:wallet-2': keyringWallet, + }; + + const result = getLocalEntropyWallets(mockContext); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(entropyWallet); + }); + + it('filters out non-entropy wallets correctly', () => { + mockController.state.accountTree.wallets = { + 'entropy:wallet-1': { + type: AccountWalletType.Entropy, + } as unknown as AccountWalletEntropyObject, + 'keyring:wallet-2': { + type: AccountWalletType.Keyring, + } as unknown as AccountWalletKeyringObject, + 'entropy:wallet-3': { + type: AccountWalletType.Entropy, + } as unknown as AccountWalletEntropyObject, + }; + + const result = getLocalEntropyWallets(mockContext); + expect(result).toHaveLength(2); + expect(result.every((w) => w.type === AccountWalletType.Entropy)).toBe( + true, + ); + }); + }); + + describe('getLocalGroupsForEntropyWallet', () => { + it('returns empty array when wallet does not exist', () => { + const result = getLocalGroupsForEntropyWallet( + mockContext, + 'entropy:non-existent', + ); + + expect(result).toStrictEqual([]); + }); + + it('returns groups for entropy wallet', () => { + const group = { + id: 'entropy:wallet-1/group-1', + type: AccountGroupType.MultichainAccount, + name: 'Group 1', + metadata: { entropy: { groupIndex: 0 } }, + }; + + const entropyWallet = { + id: 'entropy:wallet-1', + type: AccountWalletType.Entropy, + name: 'Entropy Wallet', + groups: { + 'entropy:wallet-1/group-1': group, + }, + } as unknown as AccountWalletEntropyObject; + + mockController.state.accountTree.wallets = { + 'entropy:wallet-1': entropyWallet, + }; + + const result = getLocalGroupsForEntropyWallet( + mockContext, + 'entropy:wallet-1', + ); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(group); + }); + + it('returns empty array for wallet without groups', () => { + const entropyWallet = { + id: 'entropy:wallet-1', + type: AccountWalletType.Entropy, + name: 'Entropy Wallet', + groups: {}, + } as unknown as AccountWalletEntropyObject; + + mockController.state.accountTree.wallets = { + 'entropy:wallet-1': entropyWallet, + }; + + const result = getLocalGroupsForEntropyWallet( + mockContext, + 'entropy:wallet-1', + ); + + expect(result).toStrictEqual([]); + }); + }); + + describe('createStateSnapshot', () => { + it('creates a deep copy of state properties', () => { + const originalState = { + accountGroupsMetadata: { test: { name: 'Test' } }, + accountWalletsMetadata: { test: { name: 'Test' } }, + selectedAccountGroup: 'entropy:test-group/group' as const, + wallets: { + 'entropy:test': { name: 'Test Wallet' }, + } as unknown as AccountWalletEntropyObject, + }; + + mockController.state.accountGroupsMetadata = + originalState.accountGroupsMetadata; + mockController.state.accountWalletsMetadata = + originalState.accountWalletsMetadata; + mockController.state.accountTree.selectedAccountGroup = + originalState.selectedAccountGroup; + mockController.state.accountTree.wallets = originalState.wallets; + + const snapshot = createStateSnapshot(mockContext); + + expect(snapshot.accountGroupsMetadata).toStrictEqual( + originalState.accountGroupsMetadata, + ); + expect(snapshot.accountWalletsMetadata).toStrictEqual( + originalState.accountWalletsMetadata, + ); + expect(snapshot.selectedAccountGroup).toBe( + originalState.selectedAccountGroup, + ); + expect(snapshot.accountTreeWallets).toStrictEqual(originalState.wallets); + }); + + it('creates independent copies (deep clone)', () => { + const originalGroupsMetadata = { + 'entropy:test-group/test': { + name: { + value: 'Original', + lastUpdatedAt: 1234567890, + }, + }, + }; + + mockController.state.accountGroupsMetadata = originalGroupsMetadata; + + const snapshot = createStateSnapshot(mockContext); + + // Modify original state + mockController.state.accountGroupsMetadata[ + 'entropy:test-group/test' + ].name = { + value: 'Modified', + lastUpdatedAt: Date.now(), + }; + + // Snapshot should remain unchanged + expect( + snapshot.accountGroupsMetadata['entropy:test-group/test'].name, + ).toStrictEqual({ + value: 'Original', + lastUpdatedAt: 1234567890, + }); + }); + }); + + describe('restoreStateFromSnapshot', () => { + let mockSnapshot: StateSnapshot; + + beforeEach(() => { + mockSnapshot = { + accountGroupsMetadata: { test: { name: 'Restored Group' } }, + accountWalletsMetadata: { test: { name: 'Restored Wallet' } }, + selectedAccountGroup: 'entropy:restored-group/group', + accountTreeWallets: { + 'entropy:test': { name: 'Restored Wallet Object' }, + }, + } as unknown as StateSnapshot; + }); + + it('restores all snapshot properties to state', () => { + restoreStateFromSnapshot(mockContext, mockSnapshot); + + expect(mockController.state.accountGroupsMetadata).toStrictEqual( + mockSnapshot.accountGroupsMetadata, + ); + expect(mockController.state.accountWalletsMetadata).toStrictEqual( + mockSnapshot.accountWalletsMetadata, + ); + expect( + mockController.state.accountTree.selectedAccountGroup, + ).toStrictEqual(mockSnapshot.selectedAccountGroup); + expect(mockController.state.accountTree.wallets).toStrictEqual( + mockSnapshot.accountTreeWallets, + ); + }); + + it('calls controllerStateUpdateFn with update function', () => { + restoreStateFromSnapshot(mockContext, mockSnapshot); + + expect(mockControllerStateUpdateFn).toHaveBeenCalledTimes(1); + expect(mockControllerStateUpdateFn).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + + it('calls controller.init() after state restoration', () => { + restoreStateFromSnapshot(mockContext, mockSnapshot); + + expect(mockController.init).toHaveBeenCalledTimes(1); + }); + + it('calls init after state update', () => { + const callOrder: string[] = []; + + mockControllerStateUpdateFn.mockImplementation((updateFn) => { + callOrder.push('updateFn'); + updateFn(mockController.state); + }); + + (mockController.init as jest.Mock).mockImplementation(() => { + callOrder.push('init'); + }); + + restoreStateFromSnapshot(mockContext, mockSnapshot); + + expect(callOrder).toStrictEqual(['updateFn', 'init']); + }); + }); +}); diff --git a/packages/account-tree-controller/src/backup-and-sync/utils/controller.ts b/packages/account-tree-controller/src/backup-and-sync/utils/controller.ts new file mode 100644 index 00000000000..cb606dfddc3 --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/utils/controller.ts @@ -0,0 +1,105 @@ +import { AccountWalletType } from '@metamask/account-api'; +import type { AccountWalletId } from '@metamask/account-api'; + +import type { AccountGroupMultichainAccountObject } from '../../group'; +import { backupAndSyncLogger } from '../../logger'; +import type { AccountTreeControllerState } from '../../types'; +import type { AccountWalletEntropyObject } from '../../wallet'; +import type { BackupAndSyncContext } from '../types'; + +/** + * Gets all local entropy wallets that can be synced. + * + * @param context - The backup and sync context. + * @returns Array of entropy wallet objects. + */ +export function getLocalEntropyWallets( + context: BackupAndSyncContext, +): AccountWalletEntropyObject[] { + return Object.values(context.controller.state.accountTree.wallets).filter( + (wallet) => wallet.type === AccountWalletType.Entropy, + ) as AccountWalletEntropyObject[]; +} + +/** + * Gets all groups for a specific entropy wallet. + * + * @param context - The backup and sync context. + * @param walletId - The wallet ID to get groups for. + * @returns Array of multichain account group objects. + */ +export function getLocalGroupsForEntropyWallet( + context: BackupAndSyncContext, + walletId: AccountWalletId, +): AccountGroupMultichainAccountObject[] { + const wallet = context.controller.state.accountTree.wallets[walletId]; + if (!wallet || wallet.type !== AccountWalletType.Entropy) { + backupAndSyncLogger( + `Wallet ${walletId} not found or is not an entropy wallet`, + ); + return []; + } + + return Object.values(wallet.groups); +} + +/** + * State snapshot type for rollback operations. + * Captures all the state that needs to be restored in case of sync failures. + */ +export type StateSnapshot = { + accountGroupsMetadata: AccountTreeControllerState['accountGroupsMetadata']; + accountWalletsMetadata: AccountTreeControllerState['accountWalletsMetadata']; + selectedAccountGroup: AccountTreeControllerState['accountTree']['selectedAccountGroup']; + accountTreeWallets: AccountTreeControllerState['accountTree']['wallets']; +}; + +/** + * Creates a snapshot of the current controller state for rollback purposes. + * Captures all state including the account tree structure. + * + * @param context - The backup and sync context containing controller and messenger. + * @returns A deep copy of relevant state that can be restored later. + */ +export function createStateSnapshot( + context: BackupAndSyncContext, +): StateSnapshot { + return { + accountGroupsMetadata: JSON.parse( + JSON.stringify(context.controller.state.accountGroupsMetadata), + ), + accountWalletsMetadata: JSON.parse( + JSON.stringify(context.controller.state.accountWalletsMetadata), + ), + selectedAccountGroup: + context.controller.state.accountTree.selectedAccountGroup, + accountTreeWallets: JSON.parse( + JSON.stringify(context.controller.state.accountTree.wallets), + ), + }; +} + +/** + * Restores state using an update callback. + * Restores both persisted metadata and the complete account tree structure. + * Uses the controller's init() method to rebuild internal maps correctly. + * + * @param context - The backup and sync context containing controller and messenger. + * @param snapshot - The state snapshot to restore. + */ +export function restoreStateFromSnapshot( + context: BackupAndSyncContext, + snapshot: StateSnapshot, +): void { + context.controllerStateUpdateFn((state) => { + state.accountGroupsMetadata = snapshot.accountGroupsMetadata; + state.accountWalletsMetadata = snapshot.accountWalletsMetadata; + state.accountTree.selectedAccountGroup = snapshot.selectedAccountGroup; + state.accountTree.wallets = snapshot.accountTreeWallets; + }); + + // Use init() to rebuild the internal maps from the restored account tree state + // This ensures that the internal maps (#accountIdToContext and #groupIdToWalletId) + // are correctly synchronized with the restored account tree structure + context.controller.init(); +} diff --git a/packages/account-tree-controller/src/backup-and-sync/utils/index.ts b/packages/account-tree-controller/src/backup-and-sync/utils/index.ts new file mode 100644 index 00000000000..0471403012a --- /dev/null +++ b/packages/account-tree-controller/src/backup-and-sync/utils/index.ts @@ -0,0 +1 @@ +export * from './controller'; diff --git a/packages/account-tree-controller/src/logger.ts b/packages/account-tree-controller/src/logger.ts new file mode 100644 index 00000000000..65723fd36a6 --- /dev/null +++ b/packages/account-tree-controller/src/logger.ts @@ -0,0 +1,9 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { controllerName } from './AccountTreeController'; + +export const projectLogger = createProjectLogger(controllerName); +export const backupAndSyncLogger = createModuleLogger( + projectLogger, + 'Backup and sync', +); diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index b1f22b0e491..a4c7cc3ea58 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -13,13 +13,23 @@ import { type ControllerStateChangeEvent, type RestrictedMessenger, } from '@metamask/base-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import type { MultichainAccountServiceCreateMultichainAccountGroupAction } from '@metamask/multichain-account-service'; +import type { + AuthenticationController, + UserStorageController, +} from '@metamask/profile-sync-controller'; import type { GetSnap as SnapControllerGetSnap } from '@metamask/snaps-controllers'; import type { AccountTreeController, controllerName, } from './AccountTreeController'; +import type { + BackupAndSyncAnalyticsEventPayload, + BackupAndSyncEmitAnalyticsEventParams, +} from './backup-and-sync/analytics'; import type { AccountGroupObject, AccountTreeGroupPersistedMetadata, @@ -48,6 +58,8 @@ export type AccountTreeControllerState = { }; selectedAccountGroup: AccountGroupId | ''; }; + isAccountTreeSyncingInProgress: boolean; + hasAccountTreeSyncingSyncedAtLeastOnce: boolean; /** Persistent metadata for account groups (names, pinning, hiding, sync timestamps) */ accountGroupsMetadata: Record< AccountGroupId, @@ -106,7 +118,14 @@ export type AllowedActions = | AccountsControllerListMultichainAccountsAction | AccountsControllerSetSelectedAccountAction | KeyringControllerGetStateAction - | SnapControllerGetSnap; + | SnapControllerGetSnap + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorage + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntries + | UserStorageController.UserStorageControllerPerformSetStorage + | UserStorageController.UserStorageControllerPerformBatchSetStorage + | AuthenticationController.AuthenticationControllerGetSessionProfile + | MultichainAccountServiceCreateMultichainAccountGroupAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction @@ -144,7 +163,8 @@ export type AccountTreeControllerSelectedAccountGroupChangeEvent = { export type AllowedEvents = | AccountsControllerAccountAddedEvent | AccountsControllerAccountRemovedEvent - | AccountsControllerSelectedAccountChangeEvent; + | AccountsControllerSelectedAccountChangeEvent + | UserStorageController.UserStorageControllerStateChangeEvent; export type AccountTreeControllerEvents = | AccountTreeControllerStateChangeEvent @@ -158,3 +178,14 @@ export type AccountTreeControllerMessenger = RestrictedMessenger< AllowedActions['type'], AllowedEvents['type'] >; + +export type AccountTreeControllerConfig = { + trace?: TraceCallback; + backupAndSync?: { + onBackupAndSyncEvent?: (event: BackupAndSyncAnalyticsEventPayload) => void; + }; +}; + +export type AccountTreeControllerInternalBackupAndSyncConfig = { + emitAnalyticsEventFn: (event: BackupAndSyncEmitAnalyticsEventParams) => void; +}; diff --git a/packages/account-tree-controller/tsconfig.build.json b/packages/account-tree-controller/tsconfig.build.json index 5e3f6b10dd6..707a559080c 100644 --- a/packages/account-tree-controller/tsconfig.build.json +++ b/packages/account-tree-controller/tsconfig.build.json @@ -8,7 +8,9 @@ "references": [ { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../keyring-controller/tsconfig.build.json" } + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../multichain-account-service/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/account-tree-controller/tsconfig.json b/packages/account-tree-controller/tsconfig.json index 8b6228af6b8..ca31cc28bbc 100644 --- a/packages/account-tree-controller/tsconfig.json +++ b/packages/account-tree-controller/tsconfig.json @@ -12,6 +12,12 @@ }, { "path": "../accounts-controller" + }, + { + "path": "../multichain-account-service" + }, + { + "path": "../profile-sync-controller" } ], "include": ["../../types", "./src"] diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index d50be9fc28b..8409cd207ce 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **BREAKING:** Add missing `@metamask/address-book-controller` peer dependency ([#6344](https://github.com/MetaMask/core/pull/6344)) - Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6470](https://github.com/MetaMask/core/pull/6470)) ### Changed @@ -17,6 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `#deferredLogin` method that ensures only one login operation executes at a time using Promise map caching - Bump `@metamask/base-controller` from `^8.1.0` to `^8.3.0` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465)) +### Removed + +- **BREAKING:** Remove `@metamask/accounts-controller` peer dependency ([#6344](https://github.com/MetaMask/core/pull/6344)) +- **BREAKING:** Remove all account syncing code & logic ([#6344](https://github.com/MetaMask/core/pull/6344)) + - `UserStorageController` now only holds the account syncing enablement status, but the logic itself has been moved to `@metamask/account-tree-controller` +- Remove `UserStorageController` optional config callback `getIsMultichainAccountSyncingEnabled`, and `getIsMultichainAccountSyncingEnabled` public method / messenger action ([#6344](https://github.com/MetaMask/core/pull/6344)) + ## [24.0.0] ### Added diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 2e81b06f1a1..4419cc1e85d 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -112,7 +112,7 @@ "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@metamask/accounts-controller": "^33.0.0", + "@metamask/address-book-controller": "^6.1.1", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-api": "^20.1.0", "@metamask/keyring-controller": "^23.0.0", @@ -132,7 +132,7 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/accounts-controller": "^33.0.0", + "@metamask/address-book-controller": "^6.1.1", "@metamask/keyring-controller": "^23.0.0", "@metamask/providers": "^22.0.0", "@metamask/snaps-controllers": "^14.0.0", diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index 20ce1d6d39c..54d70c58800 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -11,8 +11,6 @@ import { mockEndpointDeleteUserStorage, mockEndpointBatchDeleteUserStorage, } from './__fixtures__/mockServices'; -import { mockUserStorageMessengerForAccountSyncing } from './account-syncing/__fixtures__/test-utils'; -import * as AccountSyncControllerIntegrationModule from './account-syncing/controller-integration'; import { BACKUPANDSYNC_FEATURES } from './constants'; import { MOCK_STORAGE_DATA, MOCK_STORAGE_KEY } from './mocks/mockStorage'; import UserStorageController, { defaultState } from './UserStorageController'; @@ -591,9 +589,6 @@ describe('UserStorageController', () => { isBackupAndSyncEnabled: false, isBackupAndSyncUpdateLoading: false, isAccountSyncingEnabled: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, isContactSyncingEnabled: false, isContactSyncingInProgress: false, }, @@ -619,9 +614,6 @@ describe('UserStorageController', () => { isBackupAndSyncEnabled: false, isBackupAndSyncUpdateLoading: false, isAccountSyncingEnabled: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, isContactSyncingEnabled: false, isContactSyncingInProgress: false, }, @@ -651,9 +643,6 @@ describe('UserStorageController', () => { isBackupAndSyncEnabled: true, isBackupAndSyncUpdateLoading: false, isAccountSyncingEnabled: true, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, isContactSyncingEnabled: true, isContactSyncingInProgress: false, }, @@ -669,71 +658,6 @@ describe('UserStorageController', () => { }); }); - describe('syncInternalAccountsWithUserStorage', () => { - const arrangeMocks = () => { - const messengerMocks = mockUserStorageMessengerForAccountSyncing(); - const mockSyncInternalAccountsWithUserStorage = jest.spyOn( - AccountSyncControllerIntegrationModule, - 'syncInternalAccountsWithUserStorage', - ); - const mockSaveInternalAccountToUserStorage = jest.spyOn( - AccountSyncControllerIntegrationModule, - 'saveInternalAccountToUserStorage', - ); - return { - messenger: messengerMocks.messenger, - mockSyncInternalAccountsWithUserStorage, - mockSaveInternalAccountToUserStorage, - }; - }; - - // NOTE the actual testing of the implementation is done in `controller-integration.ts` file. - // See relevant unit tests to see how this feature works and is tested - it('should invoke syncing from the integration module', async () => { - const { messenger, mockSyncInternalAccountsWithUserStorage } = - arrangeMocks(); - const controller = new UserStorageController({ - messenger, - // We're only verifying that calling this controller method will call the integration module - // The actual implementation is tested in the integration tests - // This is done to prevent creating unnecessary nock instances in this test - config: { - accountSyncing: { - onAccountAdded: jest.fn(), - onAccountNameUpdated: jest.fn(), - onAccountSyncErroneousSituation: jest.fn(), - }, - }, - }); - - mockSyncInternalAccountsWithUserStorage.mockImplementation( - async ( - { - onAccountAdded, - onAccountNameUpdated, - onAccountSyncErroneousSituation, - }, - { - getMessenger = jest.fn(), - getUserStorageControllerInstance = jest.fn(), - }, - ) => { - onAccountAdded?.(); - onAccountNameUpdated?.(); - onAccountSyncErroneousSituation?.('error message', {}); - getMessenger(); - getUserStorageControllerInstance(); - return undefined; - }, - ); - - await controller.syncInternalAccountsWithUserStorage(); - - expect(mockSyncInternalAccountsWithUserStorage).toHaveBeenCalled(); - expect(controller.state.hasAccountSyncingSyncedAtLeastOnce).toBe(true); - }); - }); - describe('error handling edge cases', () => { const arrangeMocks = () => { const messengerMocks = mockUserStorageMessenger(); @@ -778,38 +702,6 @@ describe('UserStorageController', () => { }); }); - describe('account syncing edge cases', () => { - it('handles account syncing disabled case', async () => { - const messengerMocks = mockUserStorageMessenger(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - }); - - await controller.setIsBackupAndSyncFeatureEnabled( - BACKUPANDSYNC_FEATURES.accountSyncing, - false, - ); - await controller.syncInternalAccountsWithUserStorage(); - - // Should not have called the account syncing module - expect(messengerMocks.mockAccountsListAccounts).not.toHaveBeenCalled(); - }); - - it('handles syncing when not signed in', async () => { - const messengerMocks = mockUserStorageMessenger(); - messengerMocks.mockAuthIsSignedIn.mockReturnValue(false); - - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - expect(messengerMocks.mockAuthIsSignedIn).toHaveBeenCalled(); - expect(messengerMocks.mockAuthPerformSignIn).not.toHaveBeenCalled(); - }); - }); - describe('snap handling', () => { it('leverages a cache', async () => { const messengerMocks = mockUserStorageMessenger(); @@ -898,9 +790,7 @@ describe('metadata', () => { ), ).toMatchInlineSnapshot(` Object { - "hasAccountSyncingSyncedAtLeastOnce": false, "isAccountSyncingEnabled": true, - "isAccountSyncingReadyToBeDispatched": false, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, } @@ -916,9 +806,7 @@ describe('metadata', () => { deriveStateFromMetadata(controller.state, controller.metadata, 'persist'), ).toMatchInlineSnapshot(` Object { - "hasAccountSyncingSyncedAtLeastOnce": false, "isAccountSyncingEnabled": true, - "isAccountSyncingReadyToBeDispatched": false, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, } @@ -938,9 +826,7 @@ describe('metadata', () => { ), ).toMatchInlineSnapshot(` Object { - "hasAccountSyncingSyncedAtLeastOnce": false, "isAccountSyncingEnabled": true, - "isAccountSyncingReadyToBeDispatched": false, "isBackupAndSyncEnabled": true, "isBackupAndSyncUpdateLoading": false, "isContactSyncingEnabled": true, diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 40a8ef2a209..7d231a4c5b8 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -1,10 +1,3 @@ -import type { - AccountsControllerListAccountsAction, - AccountsControllerUpdateAccountMetadataAction, - AccountsControllerAccountRenamedEvent, - AccountsControllerAccountAddedEvent, - AccountsControllerUpdateAccountsAction, -} from '@metamask/accounts-controller'; import type { AddressBookControllerContactUpdatedEvent, AddressBookControllerContactDeletedEvent, @@ -30,12 +23,9 @@ import { type KeyringControllerGetStateAction, type KeyringControllerLockEvent, type KeyringControllerUnlockEvent, - type KeyringControllerWithKeyringAction, } from '@metamask/keyring-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; -import { syncInternalAccountsWithUserStorage } from './account-syncing/controller-integration'; -import { setupAccountSyncingSubscriptions } from './account-syncing/setup-subscriptions'; import { BACKUPANDSYNC_FEATURES } from './constants'; import { syncContactsWithUserStorage } from './contact-syncing/controller-integration'; import { setupContactSyncingSubscriptions } from './contact-syncing/setup-subscriptions'; @@ -79,20 +69,6 @@ export type UserStorageControllerState = { * Condition used by UI to determine if contact syncing is in progress. */ isContactSyncingInProgress: boolean; - /** - * Condition used to determine if account syncing has been dispatched at least once. - * This is used for event listeners to determine if they should be triggered. - * This is also used in E2E tests for verification purposes. - */ - hasAccountSyncingSyncedAtLeastOnce: boolean; - /** - * Condition used by UI to determine if account syncing is ready to be dispatched. - */ - isAccountSyncingReadyToBeDispatched: boolean; - /** - * Condition used by UI to determine if account syncing is in progress. - */ - isAccountSyncingInProgress: boolean; }; export const defaultState: UserStorageControllerState = { @@ -101,9 +77,6 @@ export const defaultState: UserStorageControllerState = { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isContactSyncingInProgress: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, }; const metadata: StateMetadata = { @@ -137,58 +110,10 @@ const metadata: StateMetadata = { anonymous: false, usedInUi: true, }, - hasAccountSyncingSyncedAtLeastOnce: { - includeInStateLogs: true, - persist: true, - anonymous: false, - usedInUi: true, - }, - isAccountSyncingReadyToBeDispatched: { - includeInStateLogs: true, - persist: true, - anonymous: false, - usedInUi: true, - }, - isAccountSyncingInProgress: { - includeInStateLogs: false, - persist: false, - anonymous: false, - usedInUi: false, - }, }; type ControllerConfig = { env: Env; - accountSyncing?: { - /** - * Defines the strategy to use for account syncing. - * If true, it will prevent any new push updates from being sent to the user storage. - * Multichain account syncing will be handled by `@metamask/account-tree-controller`. - */ - getIsMultichainAccountSyncingEnabled?: () => boolean; - maxNumberOfAccountsToAdd?: number; - /** - * Callback that fires when account sync adds an account. - * This is used for analytics. - */ - onAccountAdded?: (profileId: string) => void; - - /** - * Callback that fires when account sync updates the name of an account. - * This is used for analytics. - */ - onAccountNameUpdated?: (profileId: string) => void; - - /** - * Callback that fires when an erroneous situation happens during account sync. - * This is used for analytics. - */ - onAccountSyncErroneousSituation?: ( - profileId: string, - situationMessage: string, - sentryContext?: Record, - ) => void; - }; contactSyncing?: { /** * Callback that fires when contact sync updates a contact. @@ -229,7 +154,6 @@ type ActionsObj = CreateActionsObj< | 'performDeleteStorage' | 'performBatchDeleteStorage' | 'getStorageKey' - | 'getIsMultichainAccountSyncingEnabled' >; export type UserStorageControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -251,8 +175,6 @@ export type UserStorageControllerPerformDeleteStorage = export type UserStorageControllerPerformBatchDeleteStorage = ActionsObj['performBatchDeleteStorage']; export type UserStorageControllerGetStorageKey = ActionsObj['getStorageKey']; -export type UserStorageControllerGetIsMultichainAccountSyncingEnabled = - ActionsObj['getIsMultichainAccountSyncingEnabled']; export type AllowedActions = // Keyring Requests @@ -264,11 +186,6 @@ export type AllowedActions = | AuthenticationControllerGetSessionProfile | AuthenticationControllerPerformSignIn | AuthenticationControllerIsSignedIn - // Account Syncing - | AccountsControllerListAccountsAction - | AccountsControllerUpdateAccountMetadataAction - | AccountsControllerUpdateAccountsAction - | KeyringControllerWithKeyringAction // Contact Syncing | AddressBookControllerListAction | AddressBookControllerSetAction @@ -287,9 +204,6 @@ export type AllowedEvents = | UserStorageControllerStateChangeEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent - // Account Syncing Events - | AccountsControllerAccountRenamedEvent - | AccountsControllerAccountAddedEvent // Address Book Events | AddressBookControllerContactUpdatedEvent | AddressBookControllerContactDeletedEvent; @@ -440,13 +354,6 @@ export default class UserStorageController extends BaseController< this.#registerMessageHandlers(); this.#nativeScryptCrypto = nativeScryptCrypto; - // Account Syncing - setupAccountSyncingSubscriptions({ - getUserStorageControllerInstance: () => this, - getMessenger: () => this.messagingSystem, - trace: this.#trace, - }); - // Contact Syncing setupContactSyncingSubscriptions({ getUserStorageControllerInstance: () => this, @@ -494,11 +401,6 @@ export default class UserStorageController extends BaseController< 'UserStorageController:getStorageKey', this.getStorageKey.bind(this), ); - - this.messagingSystem.registerActionHandler( - 'UserStorageController:getIsMultichainAccountSyncingEnabled', - this.getIsMultichainAccountSyncingEnabled.bind(this), - ); } /** @@ -632,13 +534,6 @@ export default class UserStorageController extends BaseController< }); } - public getIsMultichainAccountSyncingEnabled(): boolean { - return ( - this.#config.accountSyncing?.getIsMultichainAccountSyncingEnabled?.() ?? - false - ); - } - /** * Retrieves the storage key, for internal use only! * @@ -761,32 +656,6 @@ export default class UserStorageController extends BaseController< }); } - async setHasAccountSyncingSyncedAtLeastOnce( - hasAccountSyncingSyncedAtLeastOnce: boolean, - ): Promise { - this.update((state) => { - state.hasAccountSyncingSyncedAtLeastOnce = - hasAccountSyncingSyncedAtLeastOnce; - }); - } - - async setIsAccountSyncingReadyToBeDispatched( - isAccountSyncingReadyToBeDispatched: boolean, - ): Promise { - this.update((state) => { - state.isAccountSyncingReadyToBeDispatched = - isAccountSyncingReadyToBeDispatched; - }); - } - - async setIsAccountSyncingInProgress( - isAccountSyncingInProgress: boolean, - ): Promise { - this.update((state) => { - state.isAccountSyncingInProgress = isAccountSyncingInProgress; - }); - } - /** * Sets the isContactSyncingInProgress flag to prevent infinite loops during contact synchronization * @@ -800,55 +669,6 @@ export default class UserStorageController extends BaseController< }); } - /** - * Syncs the internal accounts list with the user storage accounts list. - * This method is used to make sure that the internal accounts list is up-to-date with the user storage accounts list and vice-versa. - * It will add new accounts to the internal accounts list, update/merge conflicting names and re-upload the results in some cases to the user storage. - */ - async syncInternalAccountsWithUserStorage(): Promise { - const entropySourceIds = await this.listEntropySources(); - - try { - for (const entropySourceId of entropySourceIds) { - const profileId = await this.#auth.getProfileId(entropySourceId); - - await syncInternalAccountsWithUserStorage( - { - maxNumberOfAccountsToAdd: - this.#config?.accountSyncing?.maxNumberOfAccountsToAdd, - onAccountAdded: () => - this.#config?.accountSyncing?.onAccountAdded?.(profileId), - onAccountNameUpdated: () => - this.#config?.accountSyncing?.onAccountNameUpdated?.(profileId), - onAccountSyncErroneousSituation: ( - situationMessage, - sentryContext, - ) => - this.#config?.accountSyncing?.onAccountSyncErroneousSituation?.( - profileId, - situationMessage, - sentryContext, - ), - }, - { - getMessenger: () => this.messagingSystem, - getUserStorageControllerInstance: () => this, - trace: this.#trace, - }, - entropySourceId, - ); - } - - // We do this here and not in the finally statement because we want to make sure that - // the accounts are saved / updated / deleted at least once before we set this flag - await this.setHasAccountSyncingSyncedAtLeastOnce(true); - } catch (e) { - // Silently fail for now - // istanbul ignore next - console.error(e); - } - } - /** * Syncs the address book list with the user storage address book list. * This method is used to make sure that the address book list is up-to-date with the user storage address book list and vice-versa. diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts index 19a708c2a51..399f1dc6535 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts @@ -1,8 +1,5 @@ import type { NotNamespacedBy } from '@metamask/base-controller'; import { Messenger } from '@metamask/base-controller'; -import type { KeyringObject } from '@metamask/keyring-controller'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { EthKeyring } from '@metamask/keyring-internal-api'; import type { AllowedActions, @@ -10,7 +7,6 @@ import type { UserStorageControllerMessenger, } from '..'; import { MOCK_LOGIN_RESPONSE } from '../../authentication/mocks'; -import { MOCK_ENTROPY_SOURCE_IDS } from '../account-syncing/__fixtures__/mockAccounts'; import { MOCK_STORAGE_KEY_SIGNATURE } from '../mocks'; type GetHandler = Extract< @@ -56,20 +52,15 @@ export function createCustomUserStorageMessenger(props?: { name: 'UserStorageController', allowedActions: [ 'KeyringController:getState', - 'KeyringController:withKeyring', 'SnapController:handleRequest', 'AuthenticationController:getBearerToken', 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', 'AuthenticationController:performSignIn', - 'AccountsController:listAccounts', - 'AccountsController:updateAccountMetadata', ], allowedEvents: props?.overrideEvents ?? [ 'KeyringController:lock', 'KeyringController:unlock', - 'AccountsController:accountRenamed', - 'AccountsController:accountAdded', 'AddressBookController:contactUpdated', 'AddressBookController:contactDeleted', ], @@ -99,14 +90,6 @@ export function mockUserStorageMessenger( overrideMessengers ?? createCustomUserStorageMessenger(); const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); - const mockSnapGetAllPublicKeys = jest - .fn() - .mockResolvedValue( - MOCK_ENTROPY_SOURCE_IDS.map((entropySourceId) => [ - entropySourceId, - 'MOCK_PUBLIC_KEY', - ]), - ); const mockSnapSignMessage = jest .fn() .mockResolvedValue(MOCK_STORAGE_KEY_SIGNATURE); @@ -131,7 +114,6 @@ export function mockUserStorageMessenger( 'AuthenticationController:isSignedIn', ).mockReturnValue(true); - const mockKeyringWithKeyring = typedMockFn('KeyringController:withKeyring'); const mockKeyringGetAccounts = jest.fn(); const mockKeyringAddAccounts = jest.fn(); const mockWithKeyringSelector = jest.fn(); @@ -140,34 +122,11 @@ export function mockUserStorageMessenger( 'KeyringController:getState', ).mockReturnValue({ isUnlocked: true, - keyrings: [ - { - type: KeyringTypes.hd, - metadata: { - name: '1', - id: MOCK_ENTROPY_SOURCE_IDS[0], - }, - }, - { - type: KeyringTypes.hd, - metadata: { - name: '2', - id: MOCK_ENTROPY_SOURCE_IDS[1], - }, - }, - ] as unknown as KeyringObject[], + keyrings: [], }); const mockAccountsListAccounts = jest.fn(); - const mockAccountsUpdateAccountMetadata = typedMockFn( - 'AccountsController:updateAccountMetadata', - ).mockResolvedValue(true as never); - - const mockAccountsUpdateAccounts = typedMockFn( - 'AccountsController:updateAccounts', - ).mockResolvedValue(true as never); - jest.spyOn(messenger, 'call').mockImplementation((...args) => { const typedArgs = args as unknown as CallParams; const [actionType] = typedArgs; @@ -178,10 +137,6 @@ export function mockUserStorageMessenger( return mockSnapGetPublicKey(); } - if (params.request.method === 'getAllPublicKeys') { - return mockSnapGetAllPublicKeys(); - } - if (params.request.method === 'signMessage') { return mockSnapSignMessage(); } @@ -213,35 +168,6 @@ export function mockUserStorageMessenger( return mockKeyringGetState(); } - if (actionType === 'KeyringController:withKeyring') { - const [, ...params] = typedArgs; - const [selector, operation] = params; - - mockWithKeyringSelector(selector); - - const keyring = { - getAccounts: mockKeyringGetAccounts, - addAccounts: mockKeyringAddAccounts, - } as unknown as EthKeyring; - - const metadata = { id: 'mock-id', name: '' }; - - return operation({ keyring, metadata }); - } - - if (actionType === 'AccountsController:listAccounts') { - return mockAccountsListAccounts(); - } - - if (actionType === 'AccountsController:updateAccounts') { - return mockAccountsUpdateAccounts(); - } - - if (typedArgs[0] === 'AccountsController:updateAccountMetadata') { - const [, ...params] = typedArgs; - return mockAccountsUpdateAccountMetadata(...params); - } - throw new Error( `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, ); @@ -252,17 +178,14 @@ export function mockUserStorageMessenger( messenger, mockSnapGetPublicKey, mockSnapSignMessage, - mockSnapGetAllPublicKeys, mockAuthGetBearerToken, mockAuthGetSessionProfile, mockAuthPerformSignIn, mockAuthIsSignedIn, mockKeyringGetAccounts, mockKeyringAddAccounts, - mockKeyringWithKeyring, mockKeyringGetState, mockWithKeyringSelector, - mockAccountsUpdateAccountMetadata, mockAccountsListAccounts, }; } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts deleted file mode 100644 index 5b0df983327..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { EthAccountType } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { LOCALIZED_DEFAULT_ACCOUNT_NAMES } from '../constants'; -import { mapInternalAccountToUserStorageAccount } from '../utils'; - -/** - * Map an array of internal accounts to an array of user storage accounts - * Only used for testing purposes - * - * @param internalAccounts - An array of internal accounts - * @returns An array of user storage accounts - */ -const mapInternalAccountsListToUserStorageAccountsList = ( - internalAccounts: InternalAccount[], -) => internalAccounts.map(mapInternalAccountToUserStorageAccount); - -/** - * Get a random default account name from the list of localized default account names - * - * @returns A random default account name - */ -export const getMockRandomDefaultAccountName = () => - LOCALIZED_DEFAULT_ACCOUNT_NAMES[ - Math.floor(Math.random() * LOCALIZED_DEFAULT_ACCOUNT_NAMES.length) - ]; - -export const MOCK_ENTROPY_SOURCE_IDS = [ - 'MOCK_ENTROPY_SOURCE_ID', - 'MOCK_ENTROPY_SOURCE_ID2', -]; - -export const MOCK_INTERNAL_ACCOUNTS = { - EMPTY: [], - ONE: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'test', - nameLastUpdatedAt: 1, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], - ONE_DEFAULT_NAME: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: `${getMockRandomDefaultAccountName()} 1`, - nameLastUpdatedAt: 1, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], - ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'Internal account custom name without nameLastUpdatedAt', - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], - ONE_CUSTOM_NAME_WITH_LAST_UPDATED: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'Internal account custom name with nameLastUpdatedAt', - nameLastUpdatedAt: 1, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], - ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'Internal account custom name with nameLastUpdatedAt', - nameLastUpdatedAt: 9999, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], - ALL: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'test', - nameLastUpdatedAt: 1, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0x456', - id: '2', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'Account 2', - nameLastUpdatedAt: 2, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0x789', - id: '3', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'Účet 2', - nameLastUpdatedAt: 3, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0xabc', - id: '4', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'My Account 4', - nameLastUpdatedAt: 4, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], - MULTI_SRP: [ - { - address: '0x123', - id: '1', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'test', - nameLastUpdatedAt: 1, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0x456', - id: '2', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[0], - }, - metadata: { - name: 'test 2', - nameLastUpdatedAt: 2, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0x789', - id: '3', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[1], - }, - metadata: { - name: 'Account 2', - nameLastUpdatedAt: 2, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0xabc', - id: '4', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[1], - }, - metadata: { - name: 'Account 3', - nameLastUpdatedAt: 3, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - { - address: '0xdef', - id: '5', - type: EthAccountType.Eoa, - options: { - entropySource: MOCK_ENTROPY_SOURCE_IDS[1], - }, - metadata: { - name: 'Account 4', - nameLastUpdatedAt: 5, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ], -}; - -export const MOCK_USER_STORAGE_ACCOUNTS = { - SAME_AS_INTERNAL_ALL: mapInternalAccountsListToUserStorageAccountsList( - MOCK_INTERNAL_ACCOUNTS.ALL as unknown as InternalAccount[], - ), - ONE: mapInternalAccountsListToUserStorageAccountsList( - MOCK_INTERNAL_ACCOUNTS.ONE as unknown as InternalAccount[], - ), - TWO_DEFAULT_NAMES_WITH_ONE_BOGUS: - mapInternalAccountsListToUserStorageAccountsList([ - ...MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME, - { - ...MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME[0], - address: '0x000000', - metadata: { - name: `${getMockRandomDefaultAccountName()} 1`, - nameLastUpdatedAt: 2, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ] as unknown as InternalAccount[]), - ONE_DEFAULT_NAME: mapInternalAccountsListToUserStorageAccountsList( - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as unknown as InternalAccount[], - ), - ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED: - mapInternalAccountsListToUserStorageAccountsList([ - { - ...MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0], - metadata: { - name: 'User storage account custom name without nameLastUpdatedAt', - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ] as unknown as InternalAccount[]), - ONE_CUSTOM_NAME_WITH_LAST_UPDATED: - mapInternalAccountsListToUserStorageAccountsList([ - { - ...MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0], - metadata: { - name: 'User storage account custom name with nameLastUpdatedAt', - nameLastUpdatedAt: 3, - keyring: { - type: KeyringTypes.hd, - }, - }, - }, - ] as unknown as InternalAccount[]), - MULTI_SRP: { - [MOCK_ENTROPY_SOURCE_IDS[0]]: - mapInternalAccountsListToUserStorageAccountsList([ - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[0], - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[1], - ] as unknown as InternalAccount[]), - [MOCK_ENTROPY_SOURCE_IDS[1]]: - mapInternalAccountsListToUserStorageAccountsList([ - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[2], - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[3], - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[4], - ] as unknown as InternalAccount[]), - }, -}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts deleted file mode 100644 index a6c5381fba4..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { MOCK_INTERNAL_ACCOUNTS } from './mockAccounts'; -import { createSHA256Hash } from '../../../../shared/encryption'; -import { mockUserStorageMessenger } from '../../__fixtures__/mockMessenger'; -import { mapInternalAccountToUserStorageAccount } from '../utils'; - -/** - * Test Utility - create a mock user storage messenger for account syncing tests - * - * @param options - options for the mock messenger - * @param options.accounts - options for the accounts part of the controller - * @param options.accounts.accountsList - list of accounts to return for the 'AccountsController:listAccounts' action - * @returns Mock User Storage Messenger - */ -export function mockUserStorageMessengerForAccountSyncing(options?: { - accounts?: { - accountsList?: InternalAccount[]; - }; -}) { - const messengerMocks = mockUserStorageMessenger(); - - messengerMocks.mockKeyringGetAccounts.mockImplementation(async () => { - return ( - options?.accounts?.accountsList - ?.filter((a) => a.metadata.keyring.type === KeyringTypes.hd) - .map((a) => a.address) ?? - MOCK_INTERNAL_ACCOUNTS.ALL.map((a) => a.address) - ); - }); - - messengerMocks.mockAccountsListAccounts.mockReturnValue( - (options?.accounts?.accountsList ?? - MOCK_INTERNAL_ACCOUNTS.ALL) as InternalAccount[], - ); - - return messengerMocks; -} - -/** - * Test Utility - creates a realistic expected batch upsert payload - * - * @param data - data supposed to be upserted - * @param storageKey - storage key - * @returns expected body - */ -export function createExpectedAccountSyncBatchUpsertBody( - data: [string, InternalAccount][], - storageKey: string, -) { - return data.map(([entryKey, entryValue]) => [ - createSHA256Hash(String(entryKey) + storageKey), - JSON.stringify(mapInternalAccountToUserStorageAccount(entryValue)), - ]); -} - -/** - * Test Utility - creates a realistic expected batch delete payload - * - * @param data - data supposed to be deleted - * @param storageKey - storage key - * @returns expected body - */ -export function createExpectedAccountSyncBatchDeleteBody( - data: string[], - storageKey: string, -) { - return data.map((entryKey) => - createSHA256Hash(String(entryKey) + storageKey), - ); -} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/constants.ts deleted file mode 100644 index cda74f9b4dc..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/constants.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const USER_STORAGE_VERSION = '1'; - -// Force cast. We don't really care about the type here since we treat it as a unique symbol -export const USER_STORAGE_VERSION_KEY: unique symbol = 'v' as never; - -// We need this in order to know if an account is a default account or not when we do account syncing -export const LOCALIZED_DEFAULT_ACCOUNT_NAMES = [ - 'Account', - 'መለያ', - 'الحساب', - 'Профил', - 'অ্যাকাউন্ট', - 'Compte', - 'Účet', - 'Konto', - 'Λογαριασμός', - 'Cuenta', - 'حساب', - 'Tili', - 'એકાઉન્ટ', - 'חשבון', - 'अकाउंट', - 'खाता', - 'Račun', - 'Kont', - 'Fiók', - 'Akun', - 'アカウント', - 'ಖಾತೆ', - '계정', - 'Paskyra', - 'Konts', - 'അക്കൗണ്ട്', - 'खाते', - 'Akaun', - 'Conta', - 'Cont', - 'Счет', - 'налог', - 'Akaunti', - 'கணக்கு', - 'ఖాతా', - 'บัญชี', - 'Hesap', - 'Обліковий запис', - 'Tài khoản', - '账户', - '帳戶', -] as const; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts deleted file mode 100644 index 94388915254..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts +++ /dev/null @@ -1,1429 +0,0 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { - MOCK_ENTROPY_SOURCE_IDS, - MOCK_INTERNAL_ACCOUNTS, - MOCK_USER_STORAGE_ACCOUNTS, -} from './__fixtures__/mockAccounts'; -import { - createExpectedAccountSyncBatchDeleteBody, - createExpectedAccountSyncBatchUpsertBody, - mockUserStorageMessengerForAccountSyncing, -} from './__fixtures__/test-utils'; -import * as AccountSyncingControllerIntegrationModule from './controller-integration'; -import * as AccountSyncingUtils from './sync-utils'; -import * as AccountsUserStorageModule from './utils'; -import UserStorageController, { USER_STORAGE_FEATURE_NAMES } from '..'; -import { - mockEndpointBatchDeleteUserStorage, - mockEndpointBatchUpsertUserStorage, - mockEndpointGetUserStorage, - mockEndpointGetUserStorageAllFeatureEntries, - mockEndpointUpsertUserStorage, -} from '../__fixtures__/mockServices'; -import { - createMockUserStorageEntries, - decryptBatchUpsertBody, -} from '../__fixtures__/test-utils'; -import { MOCK_STORAGE_KEY } from '../mocks'; - -const baseState = { - isBackupAndSyncEnabled: true, - isAccountSyncingEnabled: true, - isBackupAndSyncUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, - isContactSyncingEnabled: true, - isContactSyncingInProgress: false, -}; - -const arrangeMocks = async ( - { - stateOverrides = baseState as Partial, - messengerMockOptions = undefined as Parameters< - typeof mockUserStorageMessengerForAccountSyncing - >[0], - } = { - stateOverrides: baseState as Partial, - messengerMockOptions: undefined as Parameters< - typeof mockUserStorageMessengerForAccountSyncing - >[0], - }, -) => { - const messengerMocks = - mockUserStorageMessengerForAccountSyncing(messengerMockOptions); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - state: { - ...baseState, - ...stateOverrides, - }, - }); - - const options = { - getMessenger: () => messengerMocks.messenger, - getUserStorageControllerInstance: () => controller, - }; - - const entropySourceIds = [ - 'MOCK_ENTROPY_SOURCE_ID', - 'MOCK_ENTROPY_SOURCE_ID2', - ]; - - return { - messengerMocks, - controller, - options, - entropySourceIds, - }; -}; - -describe('user-storage/account-syncing/controller-integration - saveInternalAccountsListToUserStorage() tests', () => { - it('returns void if account syncing is enabled but the internal accounts list is empty', async () => { - const { controller, options, entropySourceIds } = await arrangeMocks({}); - - const mockPerformBatchSetStorage = jest - .spyOn(controller, 'performBatchSetStorage') - .mockImplementation(() => Promise.resolve()); - - jest - .spyOn(AccountSyncingUtils, 'getInternalAccountsList') - .mockResolvedValue([]); - - await AccountSyncingControllerIntegrationModule.saveInternalAccountsListToUserStorage( - options, - entropySourceIds[0], - ); - - expect(mockPerformBatchSetStorage).not.toHaveBeenCalled(); - }); - - it('does not save internal accounts to user storage if multichain account syncing is enabled', async () => { - const { controller, options, entropySourceIds } = await arrangeMocks(); - - jest - .spyOn(controller, 'getIsMultichainAccountSyncingEnabled') - .mockReturnValue(true); - - const mockPerformBatchSetStorage = jest - .spyOn(controller, 'performBatchSetStorage') - .mockImplementation(() => Promise.resolve()); - - jest - .spyOn(AccountSyncingUtils, 'getInternalAccountsList') - .mockResolvedValue( - MOCK_INTERNAL_ACCOUNTS.ALL as unknown as InternalAccount[], - ); - - await AccountSyncingControllerIntegrationModule.saveInternalAccountsListToUserStorage( - options, - entropySourceIds[0], - ); - - expect(mockPerformBatchSetStorage).not.toHaveBeenCalled(); - }); -}); - -describe('user-storage/account-syncing/controller-integration - syncInternalAccountsWithUserStorage() tests', () => { - it('returns void if UserStorage is not enabled', async () => { - const { controller, messengerMocks, options, entropySourceIds } = - await arrangeMocks({ - stateOverrides: { - isBackupAndSyncEnabled: false, - }, - }); - - await mockEndpointGetUserStorage(); - - await controller.setIsAccountSyncingReadyToBeDispatched(true); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - expect(messengerMocks.mockAccountsListAccounts).not.toHaveBeenCalled(); - }); - - it('returns void if account syncing is disabled', async () => { - const { controller, options, entropySourceIds, messengerMocks } = - await arrangeMocks({ - stateOverrides: { - isAccountSyncingEnabled: false, - }, - }); - - await mockEndpointGetUserStorage(); - - await controller.setIsAccountSyncingReadyToBeDispatched(true); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - expect(messengerMocks.mockAccountsListAccounts).not.toHaveBeenCalled(); - }); - - it('throws if AccountsController:listAccounts fails or returns an empty list', async () => { - const { options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: [], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }, - ), - }; - - await expect( - AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ), - ).rejects.toThrow(expect.any(Error)); - - mockAPI.mockEndpointGetUserStorage.done(); - }); - - it('uploads accounts list to user storage if user storage is empty', async () => { - const { options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: MOCK_INTERNAL_ACCOUNTS.ALL.slice( - 0, - 2, - ) as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 404, - body: [], - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - const decryptedBody = await decryptBatchUpsertBody( - requestBody, - MOCK_STORAGE_KEY, - ); - - const expectedBody = createExpectedAccountSyncBatchUpsertBody( - MOCK_INTERNAL_ACCOUNTS.ALL.slice(0, 2).map((account) => [ - account.address, - account as unknown as InternalAccount, - ]), - MOCK_STORAGE_KEY, - ); - - expect(decryptedBody).toStrictEqual(expectedBody); - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - mockAPI.mockEndpointGetUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(true); - }); - - it('creates internal accounts if user storage has more accounts', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }, - ), - mockEndpointBatchDeleteUserStorage: mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - // eslint-disable-next-line jest/no-conditional-in-test - if (typeof requestBody === 'string') { - return; - } - - const expectedBody = createExpectedAccountSyncBatchDeleteBody( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.filter( - (account) => - !MOCK_INTERNAL_ACCOUNTS.ONE.find( - (internalAccount) => internalAccount.address === account.a, - ), - ).map((account) => account.a), - MOCK_STORAGE_KEY, - ); - - expect(requestBody.batch_delete).toStrictEqual(expectedBody); - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - - const numberOfAddedAccounts = - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.length - - MOCK_INTERNAL_ACCOUNTS.ONE.length; - - expect(messengerMocks.mockKeyringAddAccounts).toHaveBeenCalledWith( - numberOfAddedAccounts, - ); - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); - }); - - it('never saves accounts in the user storage if multichain account syncing is enabled', async () => { - const { options, entropySourceIds, controller } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE as unknown as InternalAccount[], - }, - }, - }); - - options.getUserStorageControllerInstance = () => controller; - jest - .spyOn(controller, 'getIsMultichainAccountSyncingEnabled') - .mockReturnValue(true); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries([]), - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(false); - }); - - it('manages multi-SRP accounts correctly', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: [ - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[0], - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP[2], - ] as unknown as InternalAccount[], - }, - }, - }); - - // Multi-SRP account syncing happens sequentially for each entropy source - // This is done in UserStorageController, so here we trigger the function manually for each entropy source - - // SRP 1 Sync - const mockAPISrp1 = { - mockEndpointGetUserStorageSrp1: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.MULTI_SRP[MOCK_ENTROPY_SOURCE_IDS[0]], - ), - }, - ), - // These two mocks below don't happen in reality, but we need to mock them to avoid - // the test to fail because the internal accounts list doesn't match, and creates erroneous situations - // Since this is not what we are testing here, this is fine - mockEndpointBatchDeleteUserStorage: mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - const numberOfAddedAccountsSrp1 = - MOCK_USER_STORAGE_ACCOUNTS.MULTI_SRP[MOCK_ENTROPY_SOURCE_IDS[0]].length - - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP.filter( - (a) => a.options.entropySource === MOCK_ENTROPY_SOURCE_IDS[0], - ).length + - 1; - - expect(messengerMocks.mockWithKeyringSelector).toHaveBeenCalledWith({ - id: MOCK_ENTROPY_SOURCE_IDS[0], - }); - expect(messengerMocks.mockKeyringAddAccounts).toHaveBeenCalledWith( - numberOfAddedAccountsSrp1, - ); - - mockAPISrp1.mockEndpointGetUserStorageSrp1.persist(false); - mockAPISrp1.mockEndpointBatchDeleteUserStorage.done(); - - // SRP 2 Sync - const mockAPISrp2 = { - mockEndpointGetUserStorageSrp2: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.MULTI_SRP[MOCK_ENTROPY_SOURCE_IDS[1]], - ), - }, - ), - // This doesn't happen in reality, but we need to mock it to avoid - // the test to fail because the internal accounts list doesn't match since this is not what we are testing here - mockEndpointBatchDeleteUserStorage: mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[1], - ); - - const numberOfAddedAccountsSrp2 = - MOCK_USER_STORAGE_ACCOUNTS.MULTI_SRP[MOCK_ENTROPY_SOURCE_IDS[1]].length - - MOCK_INTERNAL_ACCOUNTS.MULTI_SRP.filter( - (a) => a.options.entropySource === MOCK_ENTROPY_SOURCE_IDS[1], - ).length + - 1; - - expect(messengerMocks.mockWithKeyringSelector).toHaveBeenCalledWith({ - id: MOCK_ENTROPY_SOURCE_IDS[1], - }); - expect(messengerMocks.mockKeyringAddAccounts).toHaveBeenCalledWith( - numberOfAddedAccountsSrp2, - ); - - mockAPISrp1.mockEndpointBatchUpsertUserStorage.done(); - mockAPISrp2.mockEndpointGetUserStorageSrp2.done(); - mockAPISrp2.mockEndpointBatchDeleteUserStorage.done(); - - expect(mockAPISrp1.mockEndpointGetUserStorageSrp1.isDone()).toBe(true); - expect(mockAPISrp2.mockEndpointGetUserStorageSrp2.isDone()).toBe(true); - }); - - describe('handles corrupted user storage gracefully', () => { - const arrangeMocksForBogusAccounts = async (persist = true) => { - const accountsList = - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as unknown as InternalAccount[]; - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList, - }, - }, - }); - - const userStorageList = - MOCK_USER_STORAGE_ACCOUNTS.TWO_DEFAULT_NAMES_WITH_ONE_BOGUS; - - return { - options, - messengerMocks, - accountsList, - userStorageList, - entropySourceIds, - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries(userStorageList), - }, - persist, - ), - mockEndpointBatchDeleteUserStorage: - mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - if (typeof requestBody === 'string') { - return; - } - - const expectedBody = createExpectedAccountSyncBatchDeleteBody( - [ - MOCK_USER_STORAGE_ACCOUNTS - .TWO_DEFAULT_NAMES_WITH_ONE_BOGUS[1].a, - ], - MOCK_STORAGE_KEY, - ); - - expect(requestBody.batch_delete).toStrictEqual(expectedBody); - }, - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - it('does not save the bogus account to user storage, and deletes it from user storage', async () => { - const { options, mockAPI, entropySourceIds } = - await arrangeMocksForBogusAccounts(); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(false); - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); - }); - - it('does not delete the bogus accounts from user storage if multichain account syncing is enabled', async () => { - const { options, mockAPI, entropySourceIds } = - await arrangeMocksForBogusAccounts(); - - jest - .spyOn( - options.getUserStorageControllerInstance(), - 'getIsMultichainAccountSyncingEnabled', - ) - .mockReturnValue(true); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(false); - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(false); - }); - - describe('Fires the onAccountSyncErroneousSituation callback on erroneous situations', () => { - it('and logs if the final state is incorrect', async () => { - const onAccountSyncErroneousSituation = jest.fn(); - - const { options, userStorageList, accountsList, entropySourceIds } = - await arrangeMocksForBogusAccounts(false); - - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: 'null', - }, - ); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - { - onAccountSyncErroneousSituation, - }, - options, - entropySourceIds[0], - ); - - expect(onAccountSyncErroneousSituation).toHaveBeenCalledTimes(2); - // eslint-disable-next-line jest/prefer-strict-equal - expect(onAccountSyncErroneousSituation.mock.calls).toEqual([ - [ - 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', - { - internalAccountsList: accountsList, - internalAccountsToBeSavedToUserStorage: [], - refreshedInternalAccountsList: accountsList, - userStorageAccountsList: userStorageList, - userStorageAccountsToBeDeleted: [userStorageList[1]], - }, - ], - [ - 'Erroneous situations were found during the sync, and final state does not match the expected state', - { - finalInternalAccountsList: accountsList, - finalUserStorageAccountsList: null, - }, - ], - ]); - }); - - it('and logs if the final state is correct', async () => { - const onAccountSyncErroneousSituation = jest.fn(); - - const { options, userStorageList, accountsList, entropySourceIds } = - await arrangeMocksForBogusAccounts(false); - - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries([userStorageList[0]]), - }, - ); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - { - onAccountSyncErroneousSituation, - }, - options, - entropySourceIds[0], - ); - - expect(onAccountSyncErroneousSituation).toHaveBeenCalledTimes(2); - // eslint-disable-next-line jest/prefer-strict-equal - expect(onAccountSyncErroneousSituation.mock.calls).toEqual([ - [ - 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', - { - internalAccountsList: accountsList, - internalAccountsToBeSavedToUserStorage: [], - refreshedInternalAccountsList: accountsList, - userStorageAccountsList: userStorageList, - userStorageAccountsToBeDeleted: [userStorageList[1]], - }, - ], - [ - 'Erroneous situations were found during the sync, but final state matches the expected state', - { - finalInternalAccountsList: accountsList, - finalUserStorageAccountsList: [userStorageList[0]], - }, - ], - ]); - }); - }); - }); - - it('fires the onAccountAdded callback when adding an account', async () => { - const { options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }, - ), - mockEndpointBatchDeleteUserStorage: mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - // eslint-disable-next-line jest/no-conditional-in-test - if (typeof requestBody === 'string') { - return; - } - - const expectedBody = createExpectedAccountSyncBatchDeleteBody( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.filter( - (account) => - !MOCK_INTERNAL_ACCOUNTS.ONE.find( - (internalAccount) => internalAccount.address === account.a, - ), - ).map((account) => account.a), - MOCK_STORAGE_KEY, - ); - - expect(requestBody.batch_delete).toStrictEqual(expectedBody); - }, - ), - }; - - const onAccountAdded = jest.fn(); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - { - onAccountAdded, - }, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(onAccountAdded).toHaveBeenCalledTimes( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.length - - MOCK_INTERNAL_ACCOUNTS.ONE.length, - ); - - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); - }); - - it('does not create internal accounts if user storage has less accounts', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: MOCK_INTERNAL_ACCOUNTS.ALL.slice( - 0, - 2, - ) as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.slice(0, 1), - ), - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(true); - - expect(messengerMocks.mockKeyringAddAccounts).not.toHaveBeenCalled(); - }); - - describe('User storage name is a default name', () => { - it('does not update the internal account name if both user storage and internal accounts have default names', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, - ), - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('does not update the internal account name if the internal account name is custom without last updated', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, - ), - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('does not update the internal account name if the internal account name is custom with last updated', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, - ), - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - }); - - describe('User storage name is a custom name without last updated', () => { - it('updates the internal account name if the internal account name is a default name', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, - ), - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0].i, - { - name: MOCK_USER_STORAGE_ACCOUNTS - .ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0].n, - }, - ); - }); - - it('does not update internal account name if both user storage and internal accounts have custom names without last updated', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, - ), - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('does not update the internal account name if the internal account name is custom with last updated', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, - ), - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('fires the onAccountNameUpdated callback when renaming an internal account', async () => { - const { options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, - ), - }, - ), - }; - - const onAccountNameUpdated = jest.fn(); - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - { - onAccountNameUpdated, - }, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(onAccountNameUpdated).toHaveBeenCalledTimes(1); - }); - }); - - describe('User storage name is a custom name with last updated', () => { - it('updates the internal account name if the internal account name is a default name', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, - ), - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, - { - name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] - .n, - }, - ); - }); - - it('updates the internal account name and last updated if the internal account name is a custom name without last updated', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, - ), - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, - { - name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] - .n, - nameLastUpdatedAt: - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].nlu, - }, - ); - }); - - it('updates the internal account name and last updated if the user storage account is more recent', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, - ), - }, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, - { - name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] - .n, - nameLastUpdatedAt: - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].nlu, - }, - ); - }); - - it('does not update the internal account if the user storage account is less recent', async () => { - const { messengerMocks, options, entropySourceIds } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT as unknown as InternalAccount[], - }, - }, - }); - - const mockAPI = { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, - ), - }, - ), - mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }; - - await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( - {}, - options, - entropySourceIds[0], - ); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - }); -}); - -describe('user-storage/account-syncing/controller-integration - saveInternalAccountToUserStorage() tests', () => { - it('returns void if UserStorage is not enabled', async () => { - const { options } = await arrangeMocks({ - stateOverrides: { - isBackupAndSyncEnabled: false, - }, - }); - - const mapInternalAccountToUserStorageAccountMock = jest.spyOn( - AccountsUserStorageModule, - 'mapInternalAccountToUserStorageAccount', - ); - - await AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - options, - ); - - expect(mapInternalAccountToUserStorageAccountMock).not.toHaveBeenCalled(); - }); - - it.todo('returns void if account syncing feature flag is disabled'); - - it('saves an internal account to user storage', async () => { - const { options } = await arrangeMocks(); - const mockAPI = { - mockEndpointUpsertUserStorage: mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - ), - }; - - await AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - options, - ); - - expect(mockAPI.mockEndpointUpsertUserStorage.isDone()).toBe(true); - }); - - it('does not save an internal account to user storage if multichain account syncing is enabled', async () => { - const { options, controller } = await arrangeMocks(); - - jest - .spyOn(controller, 'getIsMultichainAccountSyncingEnabled') - .mockReturnValue(true); - - const mockAPI = { - mockEndpointUpsertUserStorage: mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - ), - }; - - await AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - options, - ); - - expect(mockAPI.mockEndpointUpsertUserStorage.isDone()).toBe(false); - }); - - it('rejects if api call fails', async () => { - const { options } = await arrangeMocks(); - - mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - { status: 500 }, - ); - - await expect( - AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - options, - ), - ).rejects.toThrow(expect.any(Error)); - }); - - describe('it reacts to other controller events', () => { - const arrangeMocksForAccounts = async () => { - const { messengerMocks, controller, options } = await arrangeMocks({ - messengerMockOptions: { - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT as unknown as InternalAccount[], - }, - }, - }); - - return { - options, - controller, - messengerMocks, - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }, - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - mockEndpointUpsertUserStorage: mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - ), - }, - }; - }; - - it('saves an internal account to user storage when the AccountsController:accountRenamed event is fired', async () => { - const { messengerMocks, controller } = await arrangeMocksForAccounts(); - - // We need to sync at least once before we listen for other controller events - await controller.setHasAccountSyncingSyncedAtLeastOnce(true); - - const mockSaveInternalAccountToUserStorage = jest - .spyOn( - AccountSyncingControllerIntegrationModule, - 'saveInternalAccountToUserStorage', - ) - .mockImplementation(); - - messengerMocks.baseMessenger.publish( - 'AccountsController:accountRenamed', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - ); - - expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalledWith( - MOCK_INTERNAL_ACCOUNTS.ONE[0], - expect.anything(), - ); - }); - - it('does not save an internal account to user storage when the AccountsController:accountRenamed event is fired and account syncing has never been dispatched at least once', async () => { - const { messengerMocks } = await arrangeMocksForAccounts(); - - const mockSaveInternalAccountToUserStorage = jest - .spyOn( - AccountSyncingControllerIntegrationModule, - 'saveInternalAccountToUserStorage', - ) - .mockImplementation(); - - messengerMocks.baseMessenger.publish( - 'AccountsController:accountRenamed', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - ); - - expect(mockSaveInternalAccountToUserStorage).not.toHaveBeenCalled(); - }); - - it('does not save an internal account to user storage when the AccountsController:accountRenamed or AccountsController:accountAdded event are fired and multichain account syncing is enabled', async () => { - const { messengerMocks, controller } = await arrangeMocksForAccounts(); - - // We need to sync at least once before we listen for other controller events - await controller.setHasAccountSyncingSyncedAtLeastOnce(true); - - jest - .spyOn(controller, 'getIsMultichainAccountSyncingEnabled') - .mockReturnValue(true); - - const mockSaveInternalAccountToUserStorage = jest - .spyOn( - AccountSyncingControllerIntegrationModule, - 'saveInternalAccountToUserStorage', - ) - .mockImplementation(); - - messengerMocks.baseMessenger.publish( - 'AccountsController:accountRenamed', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - ); - - messengerMocks.baseMessenger.publish( - 'AccountsController:accountAdded', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - ); - - expect(mockSaveInternalAccountToUserStorage).not.toHaveBeenCalled(); - }); - - it('saves an internal account to user storage when the AccountsController:accountAdded event is fired', async () => { - const { controller, messengerMocks } = await arrangeMocksForAccounts(); - - // We need to sync at least once before we listen for other controller events - await controller.setHasAccountSyncingSyncedAtLeastOnce(true); - - const mockSaveInternalAccountToUserStorage = jest - .spyOn( - AccountSyncingControllerIntegrationModule, - 'saveInternalAccountToUserStorage', - ) - .mockImplementation(); - - messengerMocks.baseMessenger.publish( - 'AccountsController:accountAdded', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as unknown as InternalAccount, - ); - - expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalledWith( - MOCK_INTERNAL_ACCOUNTS.ONE[0], - expect.anything(), - ); - }); - }); -}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts deleted file mode 100644 index 693810f50b4..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { - canPerformAccountSyncing, - getInternalAccountsList, - getUserStorageAccountsList, -} from './sync-utils'; -import type { AccountSyncingOptions } from './types'; -import { - isNameDefaultAccountName, - mapInternalAccountToUserStorageAccount, -} from './utils'; -import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; -import { TraceName } from '../constants'; - -/** - * Saves an individual internal account to the user storage. - * - * @param internalAccount - The internal account to save - * @param options - parameters used for saving the internal account - * @returns Promise that resolves when the account is saved - */ -export async function saveInternalAccountToUserStorage( - internalAccount: InternalAccount, - options: AccountSyncingOptions, -): Promise { - const { trace } = options; - - const saveAccount = async () => { - const { getUserStorageControllerInstance } = options; - - if ( - getUserStorageControllerInstance().getIsMultichainAccountSyncingEnabled() - ) { - // If multichain account syncing is enabled, we do not push account syncing V1 data anymore. - // AccountTreeController handles proper multichain account syncing - return; - } - - if ( - !canPerformAccountSyncing(options) || - internalAccount.metadata.keyring.type !== String(KeyringTypes.hd) // sync only EVM accounts until we support multichain accounts - ) { - return; - } - - // properties of `options` are (wrongly?) typed as `Json` and eslint crashes if we try to interpret it as such and call a `?.toString()` on it. - // but we know this is a string?, so we can safely cast it - const entropySourceId = internalAccount.options?.entropySource as - | string - | undefined; - - try { - // Map the internal account to the user storage account schema - const mappedAccount = - mapInternalAccountToUserStorageAccount(internalAccount); - - await getUserStorageControllerInstance().performSetStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${internalAccount.address}`, - JSON.stringify(mappedAccount), - entropySourceId, - ); - } catch (e) { - // istanbul ignore next - const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); - throw new Error( - `UserStorageController - failed to save account to user storage - ${errorMessage}`, - ); - } - }; - - if (trace) { - return await trace( - { name: TraceName.AccountSyncSaveIndividual }, - saveAccount, - ); - } - - return await saveAccount(); -} - -/** - * Saves the list of internal accounts to the user storage. - * - * @param options - parameters used for saving the list of internal accounts - * @param entropySourceId - The entropy source ID used to derive the key, - * when multiple sources are available (Multi-SRP). - * @returns Promise that resolves when all accounts are saved - */ -export async function saveInternalAccountsListToUserStorage( - options: AccountSyncingOptions, - entropySourceId: string, -): Promise { - const { getUserStorageControllerInstance } = options; - if ( - getUserStorageControllerInstance().getIsMultichainAccountSyncingEnabled() - ) { - // If multichain account syncing is enabled, we do not push account syncing V1 data anymore. - // AccountTreeController handles proper multichain account syncing - return; - } - - const internalAccountsList = await getInternalAccountsList( - options, - entropySourceId, - ); - - if (!internalAccountsList?.length) { - return; - } - - const internalAccountsListFormattedForUserStorage = internalAccountsList.map( - mapInternalAccountToUserStorageAccount, - ); - - await getUserStorageControllerInstance().performBatchSetStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - internalAccountsListFormattedForUserStorage.map((account) => [ - account.a, - JSON.stringify(account), - ]), - entropySourceId, - ); -} - -type SyncInternalAccountsWithUserStorageConfig = { - maxNumberOfAccountsToAdd?: number; - onAccountAdded?: () => void; - onAccountNameUpdated?: () => void; - onAccountSyncErroneousSituation?: ( - errorMessage: string, - sentryContext?: Record, - ) => void; -}; - -/** - * Syncs the internal accounts list with the user storage accounts list. - * This method is used to make sure that the internal accounts list is up-to-date with the user storage accounts list and vice-versa. - * It will add new accounts to the internal accounts list, update/merge conflicting names and re-upload the results in some cases to the user storage. - * - * @param config - parameters used for syncing the internal accounts list with the user storage accounts list - * @param options - parameters used for syncing the internal accounts list with the user storage accounts list - * @param entropySourceId - The entropy source ID used to derive the key, - * @returns Promise that resolves when synchronization is complete - */ -export async function syncInternalAccountsWithUserStorage( - config: SyncInternalAccountsWithUserStorageConfig, - options: AccountSyncingOptions, - entropySourceId: string, -): Promise { - const { trace } = options; - - const performAccountSync = async () => { - if (!canPerformAccountSyncing(options)) { - return; - } - - const { - maxNumberOfAccountsToAdd = Infinity, - onAccountAdded, - onAccountNameUpdated, - onAccountSyncErroneousSituation, - } = config; - const { getMessenger, getUserStorageControllerInstance } = options; - - try { - await getUserStorageControllerInstance().setIsAccountSyncingInProgress( - true, - ); - - const userStorageAccountsList = await getUserStorageAccountsList( - options, - entropySourceId, - ); - - if (!userStorageAccountsList || !userStorageAccountsList.length) { - await saveInternalAccountsListToUserStorage(options, entropySourceId); - return; - } - // Keep a record if erroneous situations are found during the sync - // This is done so we can send the context to Sentry in case of an erroneous situation - let erroneousSituationsFound = false; - - // Prepare an array of internal accounts to be saved to the user storage - const internalAccountsToBeSavedToUserStorage: InternalAccount[] = []; - - // Compare internal accounts list with user storage accounts list - // First step: compare lengths - const internalAccountsList = await getInternalAccountsList( - options, - entropySourceId, - ); - - if (!internalAccountsList || !internalAccountsList.length) { - throw new Error(`Failed to get internal accounts list`); - } - - const hasMoreUserStorageAccountsThanInternalAccounts = - userStorageAccountsList.length > internalAccountsList.length; - - // We don't want to remove existing accounts for a user - // so we only add new accounts if the user has more accounts in user storage than internal accounts - if (hasMoreUserStorageAccountsThanInternalAccounts) { - const numberOfAccountsToAdd = - Math.min(userStorageAccountsList.length, maxNumberOfAccountsToAdd) - - internalAccountsList.length; - - // Create new accounts to match the user storage accounts list - await getMessenger().call( - 'KeyringController:withKeyring', - { - id: entropySourceId, - }, - async ({ keyring }) => { - await keyring.addAccounts(numberOfAccountsToAdd); - }, - ); - - // TODO: below code is kept for analytics but should probably be re-thought - for (let i = 0; i < numberOfAccountsToAdd; i++) { - onAccountAdded?.(); - } - } - - // Second step: compare account names - // Get the internal accounts list again since new accounts might have been added in the previous step - const refreshedInternalAccountsList = await getInternalAccountsList( - options, - entropySourceId, - ); - - const newlyAddedAccounts = refreshedInternalAccountsList.filter( - (account) => - !internalAccountsList.find((a) => a.address === account.address), - ); - - for (const internalAccount of refreshedInternalAccountsList) { - const userStorageAccount = userStorageAccountsList.find( - (account) => account.a === internalAccount.address, - ); - - // If the account is not present in user storage - // istanbul ignore next - if (!userStorageAccount) { - // If the account was just added in the previous step, skip saving it, it's likely to be a bogus account - if (newlyAddedAccounts.includes(internalAccount)) { - erroneousSituationsFound = true; - onAccountSyncErroneousSituation?.( - 'An account was added to the internal accounts list but was not present in the user storage accounts list', - { - internalAccount, - userStorageAccount, - newlyAddedAccounts, - userStorageAccountsList, - internalAccountsList, - refreshedInternalAccountsList, - internalAccountsToBeSavedToUserStorage, - }, - ); - continue; - } - // Otherwise, it means that this internal account was present before the sync, and needs to be saved to the user storage - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - - // From this point on, we know that the account is present in - // both the internal accounts list and the user storage accounts list - - // One or both accounts have default names - const isInternalAccountNameDefault = isNameDefaultAccountName( - internalAccount.metadata.name, - ); - const isUserStorageAccountNameDefault = isNameDefaultAccountName( - userStorageAccount.n, - ); - - // Internal account has default name - if (isInternalAccountNameDefault) { - if (!isUserStorageAccountNameDefault) { - getMessenger().call( - 'AccountsController:updateAccountMetadata', - internalAccount.id, - { - name: userStorageAccount.n, - }, - ); - - onAccountNameUpdated?.(); - } - continue; - } - - // Internal account has custom name but user storage account has default name - if (isUserStorageAccountNameDefault) { - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - - // Both accounts have custom names - - // User storage account has a nameLastUpdatedAt timestamp - // Note: not storing the undefined checks in constants to act as a type guard - if (userStorageAccount.nlu !== undefined) { - if (internalAccount.metadata.nameLastUpdatedAt !== undefined) { - const isInternalAccountNameNewer = - internalAccount.metadata.nameLastUpdatedAt > - userStorageAccount.nlu; - - if (isInternalAccountNameNewer) { - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - } - - getMessenger().call( - 'AccountsController:updateAccountMetadata', - internalAccount.id, - { - name: userStorageAccount.n, - nameLastUpdatedAt: userStorageAccount.nlu, - }, - ); - - const areInternalAndUserStorageAccountNamesEqual = - internalAccount.metadata.name === userStorageAccount.n; - - if (!areInternalAndUserStorageAccountNamesEqual) { - onAccountNameUpdated?.(); - } - - continue; - } else if (internalAccount.metadata.nameLastUpdatedAt !== undefined) { - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - } - - // Save the internal accounts list to the user storage - if (internalAccountsToBeSavedToUserStorage.length) { - if ( - !getUserStorageControllerInstance().getIsMultichainAccountSyncingEnabled() - ) { - // If multichain account syncing is enabled, we do not push account syncing V1 data anymore. - // AccountTreeController handles proper multichain account syncing - await getUserStorageControllerInstance().performBatchSetStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - internalAccountsToBeSavedToUserStorage.map((account) => [ - account.address, - JSON.stringify(mapInternalAccountToUserStorageAccount(account)), - ]), - entropySourceId, - ); - } - } - - // In case we have corrupted user storage with accounts that don't exist in the internal accounts list - // Delete those accounts from the user storage - const userStorageAccountsToBeDeleted = userStorageAccountsList.filter( - (account) => - !refreshedInternalAccountsList.find((a) => a.address === account.a), - ); - - if (userStorageAccountsToBeDeleted.length) { - if ( - !getUserStorageControllerInstance().getIsMultichainAccountSyncingEnabled() - ) { - // If multichain account syncing is enabled, we do not push account syncing V1 data anymore. - // AccountTreeController handles proper multichain account syncing - await getUserStorageControllerInstance().performBatchDeleteStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - userStorageAccountsToBeDeleted.map((account) => account.a), - entropySourceId, - ); - erroneousSituationsFound = true; - onAccountSyncErroneousSituation?.( - 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', - { - userStorageAccountsToBeDeleted, - internalAccountsList, - refreshedInternalAccountsList, - internalAccountsToBeSavedToUserStorage, - userStorageAccountsList, - }, - ); - } - } - - if (erroneousSituationsFound) { - const [finalUserStorageAccountsList, finalInternalAccountsList] = - await Promise.all([ - getUserStorageAccountsList(options, entropySourceId), - getInternalAccountsList(options, entropySourceId), - ]); - - const doesEveryAccountInInternalAccountsListExistInUserStorageAccountsList = - finalInternalAccountsList.every((account) => - finalUserStorageAccountsList?.some( - (userStorageAccount) => userStorageAccount.a === account.address, - ), - ); - - // istanbul ignore next - const doesEveryAccountInUserStorageAccountsListExistInInternalAccountsList = - (finalUserStorageAccountsList?.length || 0) > maxNumberOfAccountsToAdd - ? true - : finalUserStorageAccountsList?.every((account) => - finalInternalAccountsList.some( - (internalAccount) => internalAccount.address === account.a, - ), - ); - - const doFinalListsMatch = - doesEveryAccountInInternalAccountsListExistInUserStorageAccountsList && - doesEveryAccountInUserStorageAccountsListExistInInternalAccountsList; - - const context = { - finalUserStorageAccountsList, - finalInternalAccountsList, - }; - if (doFinalListsMatch) { - onAccountSyncErroneousSituation?.( - 'Erroneous situations were found during the sync, but final state matches the expected state', - context, - ); - } else { - onAccountSyncErroneousSituation?.( - 'Erroneous situations were found during the sync, and final state does not match the expected state', - context, - ); - } - } - } catch (e) { - // istanbul ignore next - const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); - throw new Error( - `UserStorageController - failed to sync user storage accounts list - ${errorMessage}`, - ); - } finally { - await getUserStorageControllerInstance().setIsAccountSyncingInProgress( - false, - ); - } - }; - - if (trace) { - return await trace({ name: TraceName.AccountSyncFull }, performAccountSync); - } - - return await performAccountSync(); -} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.test.ts deleted file mode 100644 index b6b13db3412..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { setupAccountSyncingSubscriptions } from './setup-subscriptions'; - -describe('user-storage/account-syncing/setup-subscriptions - setupAccountSyncingSubscriptions', () => { - it('should subscribe to accountAdded and accountRenamed events', () => { - const options = { - getMessenger: jest.fn().mockReturnValue({ - subscribe: jest.fn(), - }), - getUserStorageControllerInstance: jest.fn().mockReturnValue({ - state: { - hasAccountSyncingSyncedAtLeastOnce: true, - }, - }), - }; - - setupAccountSyncingSubscriptions(options); - - expect(options.getMessenger().subscribe).toHaveBeenCalledWith( - 'AccountsController:accountAdded', - expect.any(Function), - ); - - expect(options.getMessenger().subscribe).toHaveBeenCalledWith( - 'AccountsController:accountRenamed', - expect.any(Function), - ); - }); -}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts deleted file mode 100644 index b11d952b111..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { saveInternalAccountToUserStorage } from './controller-integration'; -import { canPerformAccountSyncing } from './sync-utils'; -import type { AccountSyncingOptions } from './types'; - -/** - * Initialize and setup events to listen to for account syncing - * - * @param options - parameters used for initializing and enabling account syncing - */ -export function setupAccountSyncingSubscriptions( - options: AccountSyncingOptions, -) { - const { getMessenger, getUserStorageControllerInstance } = options; - - getMessenger().subscribe( - 'AccountsController:accountAdded', - - async (account) => { - if ( - !canPerformAccountSyncing(options) || - !getUserStorageControllerInstance().state - .hasAccountSyncingSyncedAtLeastOnce || - // If multichain account syncing is enabled, we do not push account syncing V1 data anymore. - // AccountTreeController handles proper multichain account syncing - getUserStorageControllerInstance().getIsMultichainAccountSyncingEnabled() - ) { - return; - } - - const { eventQueue } = getUserStorageControllerInstance(); - - eventQueue.push( - async () => await saveInternalAccountToUserStorage(account, options), - ); - await eventQueue.run(); - }, - ); - - getMessenger().subscribe( - 'AccountsController:accountRenamed', - - async (account) => { - if ( - !canPerformAccountSyncing(options) || - !getUserStorageControllerInstance().state - .hasAccountSyncingSyncedAtLeastOnce || - // If multichain account syncing is enabled, we do not push account syncing V1 data anymore. - // AccountTreeController handles proper multichain account syncing - getUserStorageControllerInstance().getIsMultichainAccountSyncingEnabled() - ) { - return; - } - - const { eventQueue } = getUserStorageControllerInstance(); - - eventQueue.push( - async () => await saveInternalAccountToUserStorage(account, options), - ); - await eventQueue.run(); - }, - ); -} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts deleted file mode 100644 index 31a29ba3307..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { MOCK_ENTROPY_SOURCE_IDS } from './__fixtures__/mockAccounts'; -import { - canPerformAccountSyncing, - getInternalAccountsList, - getUserStorageAccountsList, -} from './sync-utils'; -import type { AccountSyncingOptions } from './types'; - -describe('user-storage/account-syncing/sync-utils', () => { - describe('canPerformAccountSyncing', () => { - const arrangeMocks = ({ - isBackupAndSyncEnabled = true, - isAccountSyncingEnabled = true, - isAccountSyncingInProgress = false, - messengerCallControllerAndAction = 'AuthenticationController:isSignedIn', - messengerCallCallback = () => true, - }) => { - const options: AccountSyncingOptions = { - getMessenger: jest.fn().mockReturnValue({ - call: jest - .fn() - .mockImplementation((controllerAndActionName) => - controllerAndActionName === messengerCallControllerAndAction - ? messengerCallCallback() - : null, - ), - }), - getUserStorageControllerInstance: jest.fn().mockReturnValue({ - state: { - isBackupAndSyncEnabled, - isAccountSyncingEnabled, - isAccountSyncingInProgress, - }, - }), - }; - - return { options }; - }; - - const failureCases = [ - ['backup and sync is not enabled', { isBackupAndSyncEnabled: false }], - [ - 'backup and sync is not enabled but account syncing is', - { isBackupAndSyncEnabled: false, isAccountSyncingEnabled: true }, - ], - [ - 'backup and sync is enabled but not account syncing', - { isBackupAndSyncEnabled: true, isAccountSyncingEnabled: false }, - ], - [ - 'authentication is not enabled', - { - messengerCallControllerAndAction: - 'AuthenticationController:isSignedIn', - messengerCallCallback: () => false, - }, - ], - ['account syncing is in progress', { isAccountSyncingInProgress: true }], - ] as const; - - it.each(failureCases)('returns false if %s', (_message, mocks) => { - const { options } = arrangeMocks(mocks); - - expect(canPerformAccountSyncing(options)).toBe(false); - }); - - it('returns true if all conditions are met', () => { - const { options } = arrangeMocks({}); - - expect(canPerformAccountSyncing(options)).toBe(true); - }); - }); - - describe('getInternalAccountsList', () => { - it('returns filtered internal accounts list', async () => { - const internalAccounts = [ - { - address: '0x123', - id: '1', - options: { entropySource: MOCK_ENTROPY_SOURCE_IDS[0] }, - metadata: { keyring: { type: KeyringTypes.hd } }, - }, - { - address: '0x456', - id: '2', - options: { entropySource: MOCK_ENTROPY_SOURCE_IDS[1] }, - metadata: { keyring: { type: KeyringTypes.trezor } }, - }, - ] as unknown as InternalAccount[]; - - const options: AccountSyncingOptions = { - getMessenger: jest.fn().mockReturnValue({ - call: jest.fn().mockImplementation((controllerAndActionName) => { - // eslint-disable-next-line jest/no-conditional-in-test - if (controllerAndActionName === 'AccountsController:listAccounts') { - return internalAccounts; - } - - // eslint-disable-next-line jest/no-conditional-in-test - if (controllerAndActionName === 'KeyringController:withKeyring') { - return ['0x123']; - } - - return null; - }), - }), - getUserStorageControllerInstance: jest.fn(), - }; - - const result = await getInternalAccountsList( - options, - MOCK_ENTROPY_SOURCE_IDS[0], - ); - expect(result).toStrictEqual([internalAccounts[0]]); - }); - - it('calls updateAccounts if entropy source is not present for all internal accounts', async () => { - const internalAccounts = [ - { - address: '0x123', - id: '1', - options: { entropySource: undefined }, - metadata: { keyring: { type: KeyringTypes.hd } }, - }, - { - address: '0x456', - id: '2', - options: { entropySource: MOCK_ENTROPY_SOURCE_IDS[0] }, - metadata: { keyring: { type: KeyringTypes.hd } }, - }, - ] as unknown as InternalAccount[]; - - const options: AccountSyncingOptions = { - getMessenger: jest.fn().mockReturnValue({ - call: jest.fn().mockImplementation((controllerAndActionName) => { - // eslint-disable-next-line jest/no-conditional-in-test - if (controllerAndActionName === 'AccountsController:listAccounts') { - return internalAccounts; - } - - return null; - }), - }), - getUserStorageControllerInstance: jest.fn(), - }; - - await getInternalAccountsList(options, MOCK_ENTROPY_SOURCE_IDS[0]); - expect(options.getMessenger().call).toHaveBeenCalledWith( - 'AccountsController:updateAccounts', - ); - }); - - it('does not call updateAccounts if entropy source is present for all internal accounts', async () => { - const internalAccounts = [ - { - address: '0x123', - id: '1', - options: { entropySource: MOCK_ENTROPY_SOURCE_IDS[0] }, - metadata: { keyring: { type: KeyringTypes.hd } }, - }, - { - address: '0x456', - id: '2', - options: { entropySource: MOCK_ENTROPY_SOURCE_IDS[0] }, - metadata: { keyring: { type: KeyringTypes.hd } }, - }, - ] as unknown as InternalAccount[]; - - const options: AccountSyncingOptions = { - getMessenger: jest.fn().mockReturnValue({ - call: jest.fn().mockImplementation((controllerAndActionName) => { - // eslint-disable-next-line jest/no-conditional-in-test - if (controllerAndActionName === 'AccountsController:listAccounts') { - return internalAccounts; - } - - return null; - }), - }), - getUserStorageControllerInstance: jest.fn(), - }; - - await getInternalAccountsList(options, MOCK_ENTROPY_SOURCE_IDS[0]); - expect(options.getMessenger().call).not.toHaveBeenCalledWith( - 'AccountsController:updateAccounts', - ); - }); - }); - - describe('getUserStorageAccountsList', () => { - it('returns parsed user storage accounts list', async () => { - const rawAccounts = ['{"id":"1"}', '{"id":"2"}']; - - const options: AccountSyncingOptions = { - getUserStorageControllerInstance: jest.fn().mockReturnValue({ - performGetStorageAllFeatureEntries: jest - .fn() - .mockResolvedValue(rawAccounts), - }), - getMessenger: jest.fn(), - }; - - const result = await getUserStorageAccountsList(options); - expect(result).toStrictEqual([{ id: '1' }, { id: '2' }]); - }); - - it('returns null if no raw accounts are found', async () => { - const options: AccountSyncingOptions = { - getUserStorageControllerInstance: jest.fn().mockReturnValue({ - performGetStorageAllFeatureEntries: jest.fn().mockResolvedValue(null), - }), - getMessenger: jest.fn(), - }; - - const result = await getUserStorageAccountsList(options); - expect(result).toBeNull(); - }); - }); -}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts deleted file mode 100644 index 527a7c98426..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import type { AccountSyncingOptions, UserStorageAccount } from './types'; -import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; - -/** - * Checks if account syncing can be performed based on a set of conditions - * - * @param options - parameters used for checking if account syncing can be performed - * @returns Returns true if account syncing can be performed, false otherwise. - */ -export function canPerformAccountSyncing( - options: AccountSyncingOptions, -): boolean { - const { getMessenger, getUserStorageControllerInstance } = options; - - const { - isBackupAndSyncEnabled, - isAccountSyncingEnabled, - isAccountSyncingInProgress, - } = getUserStorageControllerInstance().state; - const isAuthEnabled = getMessenger().call( - 'AuthenticationController:isSignedIn', - ); - - if ( - !isBackupAndSyncEnabled || - !isAccountSyncingEnabled || - !isAuthEnabled || - isAccountSyncingInProgress - ) { - return false; - } - - return true; -} - -/** - * Get the list of internal accounts - * This function returns only the internal accounts that are from the primary SRP - * and are from the HD keyring - * - * @param options - parameters used for getting the list of internal accounts - * @param entropySourceId - The entropy source ID used to derive the key, - * when multiple sources are available (Multi-SRP). - * @returns the list of internal accounts - */ -export async function getInternalAccountsList( - options: AccountSyncingOptions, - entropySourceId: string, -): Promise { - const { getMessenger } = options; - - let internalAccountsList = getMessenger().call( - 'AccountsController:listAccounts', - ); - - const doEachInternalAccountHaveEntropySource = internalAccountsList.every( - (account) => Boolean(account.options.entropySource), - ); - - if (!doEachInternalAccountHaveEntropySource) { - await getMessenger().call('AccountsController:updateAccounts'); - internalAccountsList = getMessenger().call( - 'AccountsController:listAccounts', - ); - } - - return internalAccountsList.filter( - (account) => - entropySourceId === account.options.entropySource && - account.metadata.keyring.type === String(KeyringTypes.hd), // sync only EVM accounts until we support multichain accounts - ); -} - -/** - * Get the list of user storage accounts - * - * @param options - parameters used for getting the list of user storage accounts - * @param entropySourceId - The entropy source ID used to derive the storage key, - * when multiple sources are available (Multi-SRP). - * @returns the list of user storage accounts - */ -export async function getUserStorageAccountsList( - options: AccountSyncingOptions, - entropySourceId?: string, -): Promise { - const { getUserStorageControllerInstance } = options; - - const rawAccountsListResponse = - await getUserStorageControllerInstance().performGetStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - entropySourceId, - ); - - return ( - rawAccountsListResponse?.map((rawAccount) => JSON.parse(rawAccount)) ?? null - ); -} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts deleted file mode 100644 index aa70e13094f..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { TraceCallback } from '@metamask/controller-utils'; - -import type { - USER_STORAGE_VERSION_KEY, - USER_STORAGE_VERSION, -} from './constants'; -import type { UserStorageControllerMessenger } from '../UserStorageController'; -import type UserStorageController from '../UserStorageController'; - -export type UserStorageAccount = { - /** - * The Version 'v' of the User Storage. - * NOTE - will allow us to support upgrade/downgrades in the future - */ - [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; - /** the id 'i' of the account */ - i: string; - /** the address 'a' of the account */ - a: string; - /** the name 'n' of the account */ - n: string; - /** the nameLastUpdatedAt timestamp 'nlu' of the account */ - nlu?: number; -}; - -export type AccountSyncingOptions = { - getUserStorageControllerInstance: () => UserStorageController; - getMessenger: () => UserStorageControllerMessenger; - trace?: TraceCallback; -}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.test.ts deleted file mode 100644 index e54fe750071..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { getMockRandomDefaultAccountName } from './__fixtures__/mockAccounts'; -import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants'; -import { - isNameDefaultAccountName, - mapInternalAccountToUserStorageAccount, -} from './utils'; - -describe('user-storage/account-syncing/utils', () => { - describe('isNameDefaultAccountName', () => { - it('should return true for default account names', () => { - expect( - isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 89`), - ).toBe(true); - expect( - isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 1`), - ).toBe(true); - expect( - isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 123543`), - ).toBe(true); - }); - - it('should return false for non-default account names', () => { - expect(isNameDefaultAccountName('My Account')).toBe(false); - expect(isNameDefaultAccountName('Mon compte 34')).toBe(false); - }); - }); - - describe('mapInternalAccountToUserStorageAccount', () => { - const internalAccount = { - address: '0x123', - id: '1', - metadata: { - name: `${getMockRandomDefaultAccountName()} 1`, - nameLastUpdatedAt: 1620000000000, - keyring: { - type: KeyringTypes.hd, - }, - }, - } as InternalAccount; - - it('should map an internal account to a user storage account with default account name', () => { - const userStorageAccount = - mapInternalAccountToUserStorageAccount(internalAccount); - - expect(userStorageAccount).toStrictEqual({ - [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, - a: internalAccount.address, - i: internalAccount.id, - n: internalAccount.metadata.name, - }); - }); - - it('should map an internal account to a user storage account with non-default account name', () => { - const internalAccountWithCustomName = { - ...internalAccount, - metadata: { - ...internalAccount.metadata, - name: 'My Account', - }, - } as InternalAccount; - - const userStorageAccount = mapInternalAccountToUserStorageAccount( - internalAccountWithCustomName, - ); - - expect(userStorageAccount).toStrictEqual({ - [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, - a: internalAccountWithCustomName.address, - i: internalAccountWithCustomName.id, - n: internalAccountWithCustomName.metadata.name, - nlu: internalAccountWithCustomName.metadata.nameLastUpdatedAt, - }); - }); - }); -}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts deleted file mode 100644 index 4e05bc4684a..00000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { - USER_STORAGE_VERSION_KEY, - USER_STORAGE_VERSION, - LOCALIZED_DEFAULT_ACCOUNT_NAMES, -} from './constants'; -import type { UserStorageAccount } from './types'; - -/** - * Tells if the given name is a default account name. - * Default account names are localized names that are automatically generated by the clients. - * - * @param name - the name to check - * @returns true if the name is a default account name, false otherwise - */ - -export const isNameDefaultAccountName = (name: string) => { - return LOCALIZED_DEFAULT_ACCOUNT_NAMES.some((prefix) => { - return new RegExp(`^${prefix} ([0-9]+)$`, 'u').test(name); - }); -}; - -/** - * Map an internal account to a user storage account - * - * @param internalAccount - An internal account - * @returns A user storage account - */ -export const mapInternalAccountToUserStorageAccount = ( - internalAccount: InternalAccount, -): UserStorageAccount => { - const { address, id, metadata } = internalAccount; - const { name, nameLastUpdatedAt } = metadata; - - return { - [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, - a: address, - i: id, - n: name, - ...(isNameDefaultAccountName(name) ? {} : { nlu: nameLastUpdatedAt }), - }; -}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts index 5f49e8b6b3d..4b3efff235b 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts @@ -13,8 +13,4 @@ export const TraceName = { ContactSyncSaveBatch: 'Contact Sync Save Batch', ContactSyncUpdateRemote: 'Contact Sync Update Remote', ContactSyncDeleteRemote: 'Contact Sync Delete Remote', - - // Account syncing traces - AccountSyncFull: 'Account Sync Full', - AccountSyncSaveIndividual: 'Account Sync Save Individual', } as const; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts index 17ee41a99a2..c747bc71c0e 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts @@ -55,9 +55,6 @@ const baseState = { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isBackupAndSyncUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - isAccountSyncingInProgress: false, isContactSyncingInProgress: false, }; diff --git a/packages/profile-sync-controller/tsconfig.build.json b/packages/profile-sync-controller/tsconfig.build.json index a80d95226b7..ca9500d8729 100644 --- a/packages/profile-sync-controller/tsconfig.build.json +++ b/packages/profile-sync-controller/tsconfig.build.json @@ -9,7 +9,6 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, - { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../address-book-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"], diff --git a/packages/profile-sync-controller/tsconfig.json b/packages/profile-sync-controller/tsconfig.json index fa469473e1f..bbd45ba561c 100644 --- a/packages/profile-sync-controller/tsconfig.json +++ b/packages/profile-sync-controller/tsconfig.json @@ -6,7 +6,6 @@ "references": [ { "path": "../base-controller" }, { "path": "../keyring-controller" }, - { "path": "../accounts-controller" }, { "path": "../address-book-controller" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 0cf2a57b624..25a8362f2b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2414,12 +2414,17 @@ __metadata: "@metamask/base-controller": "npm:^8.3.0" "@metamask/keyring-api": "npm:^20.1.0" "@metamask/keyring-controller": "npm:^23.0.0" + "@metamask/multichain-account-service": "npm:^0.7.0" + "@metamask/profile-sync-controller": "npm:^24.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.4.2" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^27.5.1" lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" @@ -2431,6 +2436,8 @@ __metadata: "@metamask/account-api": ^0.9.0 "@metamask/accounts-controller": ^33.0.0 "@metamask/keyring-controller": ^23.0.0 + "@metamask/multichain-account-service": ^0.7.0 + "@metamask/profile-sync-controller": ^24.0.0 "@metamask/providers": ^22.0.0 "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 @@ -2490,7 +2497,7 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@workspace:packages/address-book-controller": +"@metamask/address-book-controller@npm:^6.1.1, @metamask/address-book-controller@workspace:packages/address-book-controller": version: 0.0.0-use.local resolution: "@metamask/address-book-controller@workspace:packages/address-book-controller" dependencies: @@ -4256,7 +4263,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/accounts-controller": "npm:^33.0.0" + "@metamask/address-book-controller": "npm:^6.1.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.3.0" "@metamask/keyring-api": "npm:^20.1.0" @@ -4283,7 +4290,7 @@ __metadata: typescript: "npm:~5.2.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/accounts-controller": ^33.0.0 + "@metamask/address-book-controller": ^6.1.1 "@metamask/keyring-controller": ^23.0.0 "@metamask/providers": ^22.0.0 "@metamask/snaps-controllers": ^14.0.0