From 6cf64c648cb5c94aefd69348d1e219b320d2f51e Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Wed, 11 Sep 2024 15:22:59 +0100 Subject: [PATCH] feat: network-syncing and user storage controller integration (#4687) ## Explanation This is a follow up on https://github.com/MetaMask/core/pull/4685, and adds the controller integration for the network mutation syncs. NOTE - we are currently using mock/temporary events that are not yet exposed on the network controller. We will add these network events in an upcoming PR. ## References https://consensyssoftware.atlassian.net/browse/NOTIFY-1032 ## Changelog ### `@metamask/proflile-sync-controller` - **ADDED**: temporarily added non-existing `NetworkController:networkAdded`; `NetworkController:networkChanged`; and `NetworkController:networkDeleted` events. - These will be provided in a future PR. - **ADDED**: add `isNetworkSyncingEnabled` environment switch to `UserStorageController` to control when we enable network syncing. - **ADDED**: add `startNetworkSyncing()` to initialise and listen to all the events required for network syncing. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../user-storage/UserStorageController.ts | 62 ++++++-- .../user-storage/__fixtures__/test-utils.ts | 35 ++++ .../controller-integration.test.ts | 150 ++++++++++++++++++ .../network-syncing/controller-integration.ts | 69 ++++++++ 4 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts 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 661599098e..2b56819f4a 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -19,6 +19,7 @@ import type { KeyringControllerUnlockEvent, KeyringControllerAddNewAccountAction, } from '@metamask/keyring-controller'; +import type { NetworkConfiguration } from '@metamask/network-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests'; @@ -35,6 +36,7 @@ import { mapInternalAccountToUserStorageAccount, } from './accounts/user-storage'; import { createSHA256Hash } from './encryption'; +import { startNetworkSyncing } from './network-syncing/controller-integration'; import type { UserStoragePathWithFeatureAndKey, UserStoragePathWithFeatureOnly, @@ -45,6 +47,27 @@ import { upsertUserStorage, } from './services'; +// TODO: add external NetworkController event +// Need to listen for when a network gets added +type NetworkControllerNetworkAddedEvent = { + type: 'NetworkController:networkAdded'; + payload: [networkConfiguration: NetworkConfiguration]; +}; + +// TODO: add external NetworkController event +// Need to listen for when a network is updated, or the default rpc/block explorer changes +type NetworkControllerNetworkChangedEvent = { + type: 'NetworkController:networkChanged'; + payload: [networkConfiguration: NetworkConfiguration]; +}; + +// TODO: add external NetworkController event +// Need to listen for when a network gets deleted +type NetworkControllerNetworkDeletedEvent = { + type: 'NetworkController:networkDeleted'; + payload: [networkConfiguration: NetworkConfiguration]; +}; + // TODO: fix external dependencies export declare type NotificationServicesControllerDisableNotificationServices = { @@ -137,13 +160,6 @@ export type UserStorageControllerSyncInternalAccountsWithUserStorage = export type UserStorageControllerSaveInternalAccountToUserStorage = ActionsObj['saveInternalAccountToUserStorage']; -export type UserStorageControllerStateChangeEvent = ControllerStateChangeEvent< - typeof controllerName, - UserStorageControllerState ->; -export type Events = UserStorageControllerStateChangeEvent; - -// Allowed Actions export type AllowedActions = // Keyring Requests | KeyringControllerGetStateAction @@ -165,7 +181,7 @@ export type AllowedActions = | KeyringControllerAddNewAccountAction; // Messenger events -export type UserStorageControllerChangeEvent = ControllerStateChangeEvent< +export type UserStorageControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, UserStorageControllerState >; @@ -177,15 +193,24 @@ export type UserStorageControllerAccountSyncingComplete = { type: `${typeof controllerName}:accountSyncingComplete`; payload: [boolean]; }; +export type Events = + | UserStorageControllerStateChangeEvent + | UserStorageControllerAccountSyncingInProgress + | UserStorageControllerAccountSyncingComplete; export type AllowedEvents = - | UserStorageControllerChangeEvent + | UserStorageControllerStateChangeEvent | UserStorageControllerAccountSyncingInProgress | UserStorageControllerAccountSyncingComplete | KeyringControllerLockEvent | KeyringControllerUnlockEvent + // Account Syncing Events | AccountsControllerAccountAddedEvent - | AccountsControllerAccountRenamedEvent; + | AccountsControllerAccountRenamedEvent + // Network Syncing Events + | NetworkControllerNetworkAddedEvent + | NetworkControllerNetworkChangedEvent + | NetworkControllerNetworkDeletedEvent; // Messenger export type UserStorageControllerMessenger = RestrictedControllerMessenger< @@ -372,6 +397,7 @@ export default class UserStorageController extends BaseController< state?: UserStorageControllerState; env?: { isAccountSyncingEnabled?: boolean; + isNetworkSyncingEnabled?: boolean; }; getMetaMetricsState: () => boolean; nativeScryptCrypto?: NativeScrypt; @@ -392,6 +418,22 @@ export default class UserStorageController extends BaseController< this.#registerMessageHandlers(); this.#nativeScryptCrypto = nativeScryptCrypto; this.#accounts.setupAccountSyncingSubscriptions(); + + // Network Syncing + if (env?.isNetworkSyncingEnabled) { + startNetworkSyncing({ + messenger, + getStorageConfig: async () => { + const { storageKey, bearerToken } = + await this.#getStorageKeyAndBearerToken(); + return { + storageKey, + bearerToken, + nativeScryptCrypto: this.#nativeScryptCrypto, + }; + }, + }); + } } /** diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts new file mode 100644 index 0000000000..6c0983fd23 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts @@ -0,0 +1,35 @@ +type WaitForOptions = { + intervalMs?: number; + timeoutMs?: number; +}; + +/** + * Testing Utility - waitFor. Waits for and checks (at an interval) if assertion is reached. + * + * @param assertionFn - assertion function + * @param options - set wait for options + * @returns promise that you need to await in tests + */ +export const waitFor = async ( + assertionFn: () => void, + options: WaitForOptions = {}, +): Promise => { + const { intervalMs = 50, timeoutMs = 2000 } = options; + + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const intervalId = setInterval(() => { + try { + assertionFn(); + clearInterval(intervalId); + resolve(); + } catch (error) { + if (Date.now() - startTime >= timeoutMs) { + clearInterval(intervalId); + reject(new Error(`waitFor: timeout reached after ${timeoutMs}ms`)); + } + } + }, intervalMs); + }); +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts new file mode 100644 index 0000000000..827f0b685c --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts @@ -0,0 +1,150 @@ +import type { NotNamespacedBy } from '@metamask/base-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; +import log from 'loglevel'; + +import type { AllowedActions, AllowedEvents } from '..'; +import { MOCK_STORAGE_KEY } from '../__fixtures__'; +import { waitFor } from '../__fixtures__/test-utils'; +import type { UserStorageBaseOptions } from '../services'; +import { createMockNetworkConfiguration } from './__fixtures__/mockNetwork'; +import { startNetworkSyncing } from './controller-integration'; +import * as SyncModule from './sync'; + +jest.mock('loglevel', () => { + const actual = jest.requireActual('loglevel'); + return { + ...actual, + default: { + ...actual.default, + warn: jest.fn(), + }, + // Mocking an ESModule. + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + }; +}); +const warnMock = jest.mocked(log.warn); + +const storageOpts: UserStorageBaseOptions = { + bearerToken: 'MOCK_TOKEN', + storageKey: MOCK_STORAGE_KEY, +}; + +type ExternalEvents = NotNamespacedBy< + 'UserStorageController', + AllowedEvents['type'] +>; +const getEvents = (): ExternalEvents[] => [ + 'NetworkController:networkAdded', + 'NetworkController:networkChanged', + 'NetworkController:networkDeleted', +]; + +const testMatrix = [ + { + event: 'NetworkController:networkAdded' as const, + arrangeSyncFnMock: () => + jest.spyOn(SyncModule, 'addNetwork').mockResolvedValue(), + }, + { + event: 'NetworkController:networkChanged' as const, + arrangeSyncFnMock: () => + jest.spyOn(SyncModule, 'updateNetwork').mockResolvedValue(), + }, + { + event: 'NetworkController:networkDeleted' as const, + arrangeSyncFnMock: () => + jest.spyOn(SyncModule, 'deleteNetwork').mockResolvedValue(), + }, +]; + +describe.each(testMatrix)( + 'network-syncing/controller-integration - $event', + ({ event, arrangeSyncFnMock }) => { + it(`should successfully sync when ${event} is emitted`, async () => { + const syncFnMock = arrangeSyncFnMock(); + const { baseMessenger, messenger, getStorageConfig } = arrangeMocks(); + startNetworkSyncing({ messenger, getStorageConfig }); + baseMessenger.publish(event, createMockNetworkConfiguration()); + + await waitFor(() => { + expect(getStorageConfig).toHaveBeenCalled(); + expect(syncFnMock).toHaveBeenCalled(); + }); + }); + + it('should silently fail is unable to authenticate or get storage key', async () => { + const syncFnMock = arrangeSyncFnMock(); + const { baseMessenger, messenger, getStorageConfig } = arrangeMocks(); + getStorageConfig.mockRejectedValue(new Error('Mock Error')); + startNetworkSyncing({ messenger, getStorageConfig }); + baseMessenger.publish(event, createMockNetworkConfiguration()); + + expect(getStorageConfig).toHaveBeenCalled(); + expect(syncFnMock).not.toHaveBeenCalled(); + }); + + it(`should emit a warning if controller messenger is missing the ${event} event`, async () => { + const { baseMessenger, getStorageConfig } = arrangeMocks(); + + const eventsWithoutNetworkAdded = getEvents().filter((e) => e !== event); + const messenger = mockUserStorageMessenger( + baseMessenger, + eventsWithoutNetworkAdded, + ); + + startNetworkSyncing({ messenger, getStorageConfig }); + expect(warnMock).toHaveBeenCalled(); + }); + }, +); + +/** + * Test Utility - arrange mocks and parameters + * @returns the mocks and parameters used when testing `startNetworkSyncing()` + */ +function arrangeMocks() { + const baseMessenger = mockBaseMessenger(); + const messenger = mockUserStorageMessenger(baseMessenger); + const getStorageConfigMock = jest.fn().mockResolvedValue(storageOpts); + + return { + getStorageConfig: getStorageConfigMock, + baseMessenger, + messenger, + }; +} + +/** + * Test Utility - creates a base messenger so we can invoke/publish events + * @returns Base messenger for publishing events + */ +function mockBaseMessenger() { + const baseMessenger = new ControllerMessenger< + AllowedActions, + AllowedEvents + >(); + + return baseMessenger; +} + +/** + * Test Utility - creates a UserStorageMessenger to simulate the messenger used inside the UserStorageController + * @param baseMessenger - base messenger to restrict + * @param eventsOverride - provide optional override events + * @returns UserStorageMessenger + */ +function mockUserStorageMessenger( + baseMessenger: ReturnType, + eventsOverride?: ExternalEvents[], +) { + const allowedEvents = eventsOverride ?? getEvents(); + + const messenger = baseMessenger.getRestricted({ + name: 'UserStorageController', + allowedActions: [], + allowedEvents, + }); + + return messenger; +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts new file mode 100644 index 0000000000..a7b77fe91a --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts @@ -0,0 +1,69 @@ +import log from 'loglevel'; + +import type { UserStorageBaseOptions } from '../services'; +import type { UserStorageControllerMessenger } from '../UserStorageController'; +import { addNetwork, deleteNetwork, updateNetwork } from './sync'; + +type SetupNetworkSyncingProps = { + messenger: UserStorageControllerMessenger; + getStorageConfig: () => Promise; +}; + +/** + * Initialize and setup events to listen to for network syncing + * @param props - parameters used for initializing and enabling network syncing + */ +export function startNetworkSyncing(props: SetupNetworkSyncingProps) { + const { messenger, getStorageConfig } = props; + + try { + messenger.subscribe( + 'NetworkController:networkAdded', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (networkConfiguration) => { + try { + const opts = await getStorageConfig(); + await addNetwork(networkConfiguration, opts); + } catch { + // Silently fail sync + } + }, + ); + } catch (e) { + log.warn('NetworkSyncing, event subscription failed', e); + } + + try { + messenger.subscribe( + 'NetworkController:networkDeleted', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (networkConfiguration) => { + try { + const opts = await getStorageConfig(); + await deleteNetwork(networkConfiguration, opts); + } catch { + // Silently fail sync + } + }, + ); + } catch (e) { + log.warn('NetworkSyncing, event subscription failed', e); + } + + try { + messenger.subscribe( + 'NetworkController:networkChanged', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (networkConfiguration) => { + try { + const opts = await getStorageConfig(); + await updateNetwork(networkConfiguration, opts); + } catch { + // Silently fail sync + } + }, + ); + } catch (e) { + log.warn('NetworkSyncing, event subscription failed', e); + } +}