diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 6b42d86d27..819bd987c6 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -34,6 +34,7 @@ import type { ProxyWithAccessibleTarget, } from './create-auto-managed-network-client'; import { createAutoManagedNetworkClient } from './create-auto-managed-network-client'; +import { lastUpdatedNetworkConfiguration } from './last-updated-at-network-configuration'; import { projectLogger, createModuleLogger } from './logger'; import { NetworkClientType } from './types'; import type { @@ -203,6 +204,11 @@ export type NetworkConfiguration = { * interact with the chain. */ rpcEndpoints: RpcEndpoint[]; + /** + * Profile Sync - Network Sync field. + * Allows comparison of local network state with state to sync. + */ + lastUpdatedAt?: number; }; /** @@ -212,8 +218,11 @@ export type NetworkConfiguration = { * Custom RPC endpoints do not need a `networkClientId` property because it is * assumed that they have not already been added and therefore network clients * do not exist for them yet (and hence IDs need to be generated). + * + * However Custom RPC endpoints, that are synchronized between, + * can use the same `networkClientId` set on both devices. */ -export type AddNetworkCustomRpcEndpointFields = Omit< +export type AddNetworkCustomRpcEndpointFields = Partialize< CustomRpcEndpoint, 'networkClientId' >; @@ -409,13 +418,33 @@ export type NetworkControllerNetworkAddedEvent = { payload: [networkConfiguration: NetworkConfiguration]; }; +/** + * `networkUpdated` is published after a network configuration is updated in the + * network configuration registry and network clients are created as needed. + */ +export type NetworkControllerNetworkUpdatedEvent = { + type: 'NetworkController:networkUpdated'; + payload: [networkConfiguration: NetworkConfiguration]; +}; + +/** + * `networkRemoved` is published after a network configuration is removed from the + * network configuration registry and once the network clients have been removed. + */ +export type NetworkControllerNetworkRemovedEvent = { + type: 'NetworkController:networkRemoved'; + payload: [networkConfiguration: NetworkConfiguration]; +}; + export type NetworkControllerEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkWillChangeEvent | NetworkControllerNetworkDidChangeEvent | NetworkControllerInfuraIsBlockedEvent | NetworkControllerInfuraIsUnblockedEvent - | NetworkControllerNetworkAddedEvent; + | NetworkControllerNetworkAddedEvent + | NetworkControllerNetworkUpdatedEvent + | NetworkControllerNetworkRemovedEvent; export type NetworkControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -473,6 +502,26 @@ export type NetworkControllerGetNetworkConfigurationByNetworkClientId = { handler: NetworkController['getNetworkConfigurationByNetworkClientId']; }; +export type NetworkControllerAddNetworkAction = { + type: 'NetworkController:addNetwork'; + handler: NetworkController['addNetwork']; +}; + +export type NetworkControllerUpdateNetworkAction = { + type: 'NetworkController:updateNetwork'; + handler: NetworkController['updateNetwork']; +}; + +export type NetworkControllerRemoveNetworkAction = { + type: 'NetworkController:removeNetwork'; + handler: NetworkController['removeNetwork']; +}; + +export type NetworkControllerDangerouslySetNetworkConfigurationAction = { + type: 'NetworkController:dangerouslySetNetworkConfiguration'; + handler: NetworkController['dangerouslySetNetworkConfiguration']; +}; + export type NetworkControllerActions = | NetworkControllerGetStateAction | NetworkControllerGetEthQueryAction @@ -483,7 +532,11 @@ export type NetworkControllerActions = | NetworkControllerSetActiveNetworkAction | NetworkControllerSetProviderTypeAction | NetworkControllerGetNetworkConfigurationByChainId - | NetworkControllerGetNetworkConfigurationByNetworkClientId; + | NetworkControllerGetNetworkConfigurationByNetworkClientId + | NetworkControllerAddNetworkAction + | NetworkControllerUpdateNetworkAction + | NetworkControllerRemoveNetworkAction + | NetworkControllerDangerouslySetNetworkConfigurationAction; export type NetworkControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -954,6 +1007,32 @@ export class NetworkController extends BaseController< `${this.name}:getSelectedNetworkClient`, this.getSelectedNetworkClient.bind(this), ); + + this.messagingSystem.registerActionHandler( + // ESLint is mistaken here; `name` is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${this.name}:addNetwork`, + this.addNetwork.bind(this), + ); + + this.messagingSystem.registerActionHandler( + // ESLint is mistaken here; `name` is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${this.name}:updateNetwork`, + this.updateNetwork.bind(this), + ); + + this.messagingSystem.registerActionHandler( + // ESLint is mistaken here; `name` is a string. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${this.name}:removeNetwork`, + this.removeNetwork.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `${this.name}:dangerouslySetNetworkConfiguration`, + this.dangerouslySetNetworkConfiguration.bind(this), + ); } /** @@ -1557,7 +1636,8 @@ export class NetworkController extends BaseController< defaultOrCustomRpcEndpointFields.type === RpcEndpointType.Custom ? { ...defaultOrCustomRpcEndpointFields, - networkClientId: uuidV4(), + networkClientId: + defaultOrCustomRpcEndpointFields.networkClientId ?? uuidV4(), } : defaultOrCustomRpcEndpointFields; return { @@ -1881,6 +1961,11 @@ export class NetworkController extends BaseController< autoManagedNetworkClientRegistry, }); + this.messagingSystem.publish( + 'NetworkController:networkUpdated', + updatedNetworkConfiguration, + ); + return updatedNetworkConfiguration; } @@ -1939,6 +2024,110 @@ export class NetworkController extends BaseController< buildNetworkConfigurationsByNetworkClientId( this.state.networkConfigurationsByChainId, ); + + this.messagingSystem.publish( + 'NetworkController:networkRemoved', + existingNetworkConfiguration, + ); + } + + /** + * This is used to override an existing network configuration. + * This is only meant for internal use only and not to be exposed via the UI. + * It is used as part of "Network Syncing", to sync networks, RPCs and block explorers cross devices. + * + * This will subsequently update the network client registry; state.networksMetadata, and state.selectedNetworkClientId + * @param networkConfiguration - the network configuration to override + */ + async dangerouslySetNetworkConfiguration( + networkConfiguration: NetworkConfiguration, + ) { + const prevNetworkConfig: NetworkConfiguration | undefined = + networkConfiguration.chainId in this.state.networkConfigurationsByChainId + ? this.state.networkConfigurationsByChainId[ + networkConfiguration.chainId + ] + : undefined; + + // Update Registry (remove old and add new) + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + if (prevNetworkConfig) { + const networkClientOperations = prevNetworkConfig.rpcEndpoints.map( + (rpcEndpoint) => { + return { + type: 'remove' as const, + rpcEndpoint, + }; + }, + ); + this.#unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + } + const networkClientOperations = networkConfiguration.rpcEndpoints.map( + (rpcEndpoint) => { + return { + type: 'add' as const, + rpcEndpoint, + }; + }, + ); + this.#registerNetworkClientsAsNeeded({ + networkFields: networkConfiguration, + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + + // update new `networkConfigurationsByChainId` (full replace) + this.update((state) => { + state.networkConfigurationsByChainId[networkConfiguration.chainId] = + networkConfiguration; + }); + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + this.state.networkConfigurationsByChainId, + ); + + // update `networksMetadata` remove old + if (prevNetworkConfig) { + this.update((state) => { + const newNetworksMetadata: NetworksMetadata = { + ...state.networksMetadata, + }; + prevNetworkConfig.rpcEndpoints.forEach((rpcEndpoint) => { + delete newNetworksMetadata[rpcEndpoint.networkClientId]; + }); + }); + } + + // update `networksMetadata` update new + for (const rpcEndpoint of networkConfiguration.rpcEndpoints) { + await this.lookupNetwork(rpcEndpoint.networkClientId); + } + + // update selectedNetworkId + const selectedClientId = this.state.selectedNetworkClientId; + const wasReplacedClientId = prevNetworkConfig?.rpcEndpoints.some( + (r) => r.networkClientId === selectedClientId, + ); + const isValidClientIdInNewNetworks = networkConfiguration.rpcEndpoints.some( + (r) => r.networkClientId === selectedClientId, + ); + if (wasReplacedClientId) { + if (!isValidClientIdInNewNetworks) { + // Update clientId to something that exists + const newSelectedNetworkMetadata = + networkConfiguration.rpcEndpoints.find( + (r) => r.networkClientId in this.state.networksMetadata, + )?.networkClientId; + const anyClientId = Object.keys(this.state.networksMetadata)[0]; + const newlySelectedNetwork = + newSelectedNetworkMetadata ?? anyClientId ?? selectedClientId; + await this.#refreshNetwork(newlySelectedNetwork); + } + } } /** @@ -2438,6 +2627,7 @@ export class NetworkController extends BaseController< } if (mode === 'add' || mode === 'update') { + lastUpdatedNetworkConfiguration(args.networkConfigurationToPersist); state.networkConfigurationsByChainId[args.networkFields.chainId] = args.networkConfigurationToPersist; } diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index e05622fc65..633a41ab18 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -17,6 +17,9 @@ export type { NetworkControllerNetworkDidChangeEvent, NetworkControllerInfuraIsBlockedEvent, NetworkControllerInfuraIsUnblockedEvent, + NetworkControllerNetworkAddedEvent, + NetworkControllerNetworkUpdatedEvent, + NetworkControllerNetworkRemovedEvent, NetworkControllerEvents, NetworkControllerGetStateAction, NetworkControllerGetEthQueryAction, @@ -26,6 +29,10 @@ export type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerSetProviderTypeAction, NetworkControllerSetActiveNetworkAction, + NetworkControllerAddNetworkAction, + NetworkControllerUpdateNetworkAction, + NetworkControllerRemoveNetworkAction, + NetworkControllerDangerouslySetNetworkConfigurationAction, NetworkControllerGetNetworkConfigurationByNetworkClientId, NetworkControllerActions, NetworkControllerMessenger, diff --git a/packages/network-controller/src/last-updated-at-network-configuration.ts b/packages/network-controller/src/last-updated-at-network-configuration.ts new file mode 100644 index 0000000000..efe16355c7 --- /dev/null +++ b/packages/network-controller/src/last-updated-at-network-configuration.ts @@ -0,0 +1,16 @@ +import type { NetworkConfiguration } from './NetworkController'; + +/** + * Adds or updates the NetworkConfiguration `lastUpdatedAt` property. + * Keeping this property updated on network changes allows us to compare remote vs local NetworkConfiguration + * for network syncing. + * + * @param configuration - NetworkConfiguration that is being updated + * @returns the NetworkConfiguration with the lastUpdatedAt property updated. + */ +export function lastUpdatedNetworkConfiguration( + configuration: NetworkConfiguration, +) { + configuration.lastUpdatedAt = Date.now(); + return configuration; +} diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 24665f9c88..a4d3635e05 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -25,6 +25,7 @@ import { NetworkStatus } from '../src/constants'; import * as createAutoManagedNetworkClientModule from '../src/create-auto-managed-network-client'; import type { NetworkClient } from '../src/create-network-client'; import { createNetworkClient } from '../src/create-network-client'; +import * as LastUpdatedAtNetworkConfigurationModule from '../src/last-updated-at-network-configuration'; import type { AutoManagedBuiltInNetworkClientRegistry, AutoManagedCustomNetworkClientRegistry, @@ -139,6 +140,13 @@ describe('NetworkController', () => { uuidCounter += 1; return uuid; }); + + jest + .spyOn( + LastUpdatedAtNetworkConfigurationModule, + 'lastUpdatedNetworkConfiguration', + ) + .mockImplementation((n) => n); }); afterEach(() => { diff --git a/packages/network-controller/tests/last-updated-at-network-configuration.test.ts b/packages/network-controller/tests/last-updated-at-network-configuration.test.ts new file mode 100644 index 0000000000..ad2eeeac8e --- /dev/null +++ b/packages/network-controller/tests/last-updated-at-network-configuration.test.ts @@ -0,0 +1,12 @@ +import { lastUpdatedNetworkConfiguration } from '../src/last-updated-at-network-configuration'; +import { buildCustomNetworkConfiguration } from './helpers'; + +describe('lastUpdatedNetworkConfiguration() tests', () => { + it('adds a timestamp (ms) to the network configuration', () => { + const configuration = buildCustomNetworkConfiguration(); + + expect(configuration.lastUpdatedAt).toBeUndefined(); + lastUpdatedNetworkConfiguration(configuration); + expect(configuration.lastUpdatedAt).toBeDefined(); + }); +}); 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 1a753acd99..accbceb6bb 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 @@ -39,6 +39,14 @@ import type { } from './UserStorageController'; import UserStorageController from './UserStorageController'; +// Creates the correct typed call params for mocks +type CallParams = { + [K in AllowedActions['type']]: [ + K, + ...Parameters['handler']>, + ]; +}[AllowedActions['type']]; + const typedMockFn = unknown>() => jest.fn, Parameters>(); @@ -1730,14 +1738,6 @@ function mockUserStorageMessenger(options?: { const mockAccountsUpdateAccountMetadata = jest.fn().mockResolvedValue(true); jest.spyOn(messenger, 'call').mockImplementation((...args) => { - // Creates the correct typed call params for mocks - type CallParams = { - [K in AllowedActions['type']]: [ - K, - ...Parameters['handler']>, - ]; - }[AllowedActions['type']]; - const [actionType, params] = args as unknown as CallParams; if (actionType === 'SnapController:handleRequest') { 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 b35f400ca9..1d1361bc7a 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -19,7 +19,13 @@ import { type KeyringControllerAddNewAccountAction, KeyringTypes, } from '@metamask/keyring-controller'; -import type { NetworkConfiguration } from '@metamask/network-controller'; +import type { + NetworkControllerAddNetworkAction, + NetworkControllerDangerouslySetNetworkConfigurationAction, + NetworkControllerGetStateAction, + NetworkControllerNetworkRemovedEvent, + NetworkControllerRemoveNetworkAction, +} from '@metamask/network-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import { createSHA256Hash } from '../../shared/encryption'; @@ -42,7 +48,10 @@ import { isNameDefaultAccountName, mapInternalAccountToUserStorageAccount, } from './accounts/user-storage'; -import { startNetworkSyncing } from './network-syncing/controller-integration'; +import { + performMainNetworkSync, + startNetworkSyncing, +} from './network-syncing/controller-integration'; import { batchUpsertUserStorage, deleteUserStorage, @@ -52,27 +61,6 @@ 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 = { @@ -131,6 +119,30 @@ type ControllerConfig = { */ onAccountNameUpdated?: (profileId: string) => void; }; + + networkSyncing?: { + /** + * Callback that fires when network sync adds a network + * This is used for analytics. + * @param profileId - ID for a given User (shared cross devices once authenticated) + * @param chainId - Chain ID for the network added (in hex) + */ + onNetworkAdded?: (profileId: string, chainId: string) => void; + /** + * Callback that fires when network sync updates a network + * This is used for analytics. + * @param profileId - ID for a given User (shared cross devices once authenticated) + * @param chainId - Chain ID for the network added (in hex) + */ + onNetworkUpdated?: (profileId: string, chainId: string) => void; + /** + * Callback that fires when network sync deletes a network + * This is used for analytics. + * @param profileId - ID for a given User (shared cross devices once authenticated) + * @param chainId - Chain ID for the network added (in hex) + */ + onNetworkRemoved?: (profileId: string, chainId: string) => void; + }; }; // Messenger Actions @@ -187,10 +199,15 @@ export type AllowedActions = // Metamask Notifications | NotificationServicesControllerDisableNotificationServices | NotificationServicesControllerSelectIsNotificationServicesEnabled - // Account syncing + // Account Syncing | AccountsControllerListAccountsAction | AccountsControllerUpdateAccountMetadataAction - | KeyringControllerAddNewAccountAction; + | KeyringControllerAddNewAccountAction + // Network Syncing + | NetworkControllerGetStateAction + | NetworkControllerAddNetworkAction + | NetworkControllerDangerouslySetNetworkConfigurationAction + | NetworkControllerRemoveNetworkAction; // Messenger events export type UserStorageControllerStateChangeEvent = ControllerStateChangeEvent< @@ -220,9 +237,7 @@ export type AllowedEvents = | AccountsControllerAccountAddedEvent | AccountsControllerAccountRenamedEvent // Network Syncing Events - | NetworkControllerNetworkAddedEvent - | NetworkControllerNetworkChangedEvent - | NetworkControllerNetworkDeletedEvent; + | NetworkControllerNetworkRemovedEvent; // Messenger export type UserStorageControllerMessenger = RestrictedControllerMessenger< @@ -246,6 +261,13 @@ export default class UserStorageController extends BaseController< UserStorageControllerState, UserStorageControllerMessenger > { + // This is replaced with the actual value in the constructor + // We will remove this once the feature will be released + #env = { + isAccountSyncingEnabled: false, + isNetworkSyncingEnabled: false, + }; + #auth = { getBearerToken: async () => { return await this.messagingSystem.call( @@ -274,8 +296,6 @@ export default class UserStorageController extends BaseController< }; #accounts = { - // This is replaced with the actual value in the constructor - isAccountSyncingEnabled: false, isAccountSyncingInProgress: false, maxNumberOfAccountsToAdd: 0, canSync: () => { @@ -286,7 +306,7 @@ export default class UserStorageController extends BaseController< return false; } - if (!this.#accounts.isAccountSyncingEnabled) { + if (!this.#env.isAccountSyncingEnabled) { return false; } @@ -443,12 +463,10 @@ export default class UserStorageController extends BaseController< state: { ...defaultState, ...state }, }); + this.#env.isAccountSyncingEnabled = Boolean(env?.isAccountSyncingEnabled); + this.#env.isNetworkSyncingEnabled = Boolean(env?.isNetworkSyncingEnabled); this.#config = config; - this.#accounts.isAccountSyncingEnabled = Boolean( - env?.isAccountSyncingEnabled, - ); - this.#accounts.maxNumberOfAccountsToAdd = config?.accountSyncing?.maxNumberOfAccountsToAdd ?? 100; @@ -459,18 +477,10 @@ export default class UserStorageController extends BaseController< this.#accounts.setupAccountSyncingSubscriptions(); // Network Syncing - if (env?.isNetworkSyncingEnabled) { + if (this.#env.isNetworkSyncingEnabled) { startNetworkSyncing({ messenger, - getStorageConfig: async () => { - const { storageKey, bearerToken } = - await this.#getStorageKeyAndBearerToken(); - return { - storageKey, - bearerToken, - nativeScryptCrypto: this.#nativeScryptCrypto, - }; - }, + getStorageConfig: () => this.#getStorageOptions(), }); } } @@ -521,6 +531,20 @@ export default class UserStorageController extends BaseController< ); } + async #getStorageOptions() { + if (!this.state.isProfileSyncingEnabled) { + return null; + } + + const { storageKey, bearerToken } = + await this.#getStorageKeyAndBearerToken(); + return { + storageKey, + bearerToken, + nativeScryptCrypto: this.#nativeScryptCrypto, + }; + } + public async enableProfileSyncing(): Promise { try { this.#setIsProfileSyncingUpdateLoading(true); @@ -999,4 +1023,23 @@ export default class UserStorageController extends BaseController< ); } } + + async syncNetworks() { + if (!this.#env.isNetworkSyncingEnabled) { + return; + } + + const profileId = await this.#auth.getProfileId(); + + await performMainNetworkSync({ + messenger: this.messagingSystem, + getStorageConfig: () => this.#getStorageOptions(), + onNetworkAdded: (cId) => + this.#config?.networkSyncing?.onNetworkAdded?.(profileId, cId), + onNetworkUpdated: (cId) => + this.#config?.networkSyncing?.onNetworkUpdated?.(profileId, cId), + onNetworkRemoved: (cId) => + this.#config?.networkSyncing?.onNetworkRemoved?.(profileId, cId), + }); + } } 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 index 162d865dca..721c0a4be0 100644 --- 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 @@ -2,13 +2,42 @@ import type { NotNamespacedBy } from '@metamask/base-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import log from 'loglevel'; -import type { AllowedActions, AllowedEvents } from '..'; +import type { + AllowedActions, + AllowedEvents, + UserStorageControllerMessenger, +} 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-mutations'; +import { + createMockNetworkConfiguration, + createMockRemoteNetworkConfiguration, +} from './__fixtures__/mockNetwork'; +import { + performMainNetworkSync, + startNetworkSyncing, +} from './controller-integration'; +import * as ControllerIntegrationModule from './controller-integration'; +import * as ServicesModule from './services'; +import * as SyncAllModule from './sync-all'; +import * as SyncMutationsModule from './sync-mutations'; + +type GetActionHandler = Extract< + AllowedActions, + { type: Type } +>['handler']; + +// Creates the correct typed call params for mocks +type CallParams = { + [K in AllowedActions['type']]: [K, ...Parameters>]; +}[AllowedActions['type']]; + +const typedMockCallFn = < + Type extends AllowedActions['type'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Func extends (...args: any[]) => any = GetActionHandler, +>() => jest.fn, Parameters>(); jest.mock('loglevel', () => { const actual = jest.requireActual('loglevel'); @@ -34,86 +63,337 @@ 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()); +const getEvents = (): ExternalEvents[] => ['NetworkController:networkRemoved']; - await waitFor(() => { - expect(getStorageConfig).toHaveBeenCalled(); - expect(syncFnMock).toHaveBeenCalled(); - }); +describe('network-syncing/controller-integration - startNetworkSyncing()', () => { + it(`should successfully sync when NetworkController:networkRemoved is emitted`, async () => { + const { baseMessenger, messenger, getStorageConfig, deleteNetworkMock } = + arrangeMocks(); + startNetworkSyncing({ messenger, getStorageConfig }); + baseMessenger.publish( + 'NetworkController:networkRemoved', + createMockNetworkConfiguration(), + ); + + await waitFor(() => { + expect(getStorageConfig).toHaveBeenCalled(); + expect(deleteNetworkMock).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()); + it('should silently fail is unable to authenticate or get storage key', async () => { + const { baseMessenger, messenger, getStorageConfig, deleteNetworkMock } = + arrangeMocks(); + getStorageConfig.mockRejectedValue(new Error('Mock Error')); + startNetworkSyncing({ messenger, getStorageConfig }); + baseMessenger.publish( + 'NetworkController:networkRemoved', + createMockNetworkConfiguration(), + ); + await waitFor(() => { expect(getStorageConfig).toHaveBeenCalled(); - expect(syncFnMock).not.toHaveBeenCalled(); + expect(deleteNetworkMock).not.toHaveBeenCalled(); }); + }); - it(`should emit a warning if controller messenger is missing the ${event} event`, async () => { - const { baseMessenger, getStorageConfig } = arrangeMocks(); + it('should silently fail if unable to get storage config', async () => { + const { baseMessenger, messenger, getStorageConfig, deleteNetworkMock } = + arrangeMocks(); + getStorageConfig.mockResolvedValue(null); + startNetworkSyncing({ messenger, getStorageConfig }); + baseMessenger.publish( + 'NetworkController:networkRemoved', + createMockNetworkConfiguration(), + ); - const eventsWithoutNetworkAdded = getEvents().filter((e) => e !== event); - const messenger = mockUserStorageMessenger( - baseMessenger, - eventsWithoutNetworkAdded, - ); + await waitFor(() => { + expect(getStorageConfig).toHaveBeenCalled(); + expect(deleteNetworkMock).not.toHaveBeenCalled(); + }); + }); + + it(`should emit a warning if controller messenger is missing the NetworkController:networkRemoved event`, async () => { + const { baseMessenger, getStorageConfig } = arrangeMocks(); + const eventsWithoutNetworkAdded = getEvents().filter( + (e) => e !== 'NetworkController:networkRemoved', + ); + const messenger = mockUserStorageMessenger( + baseMessenger, + eventsWithoutNetworkAdded, + ); + + await waitFor(() => { 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); + it('should not remove networks if main sync is in progress', async () => { + const { baseMessenger, getStorageConfig, deleteNetworkMock } = + arrangeMocks(); + const messenger = mockUserStorageMessenger(baseMessenger); - return { - getStorageConfig: getStorageConfigMock, - baseMessenger, - messenger, - }; -} + // TODO - replace with jest.replaceProperty once we upgrade jest. + Object.defineProperty( + ControllerIntegrationModule, + 'isMainNetworkSyncInProgress', + { value: true }, + ); + + startNetworkSyncing({ + messenger, + getStorageConfig, + }); + + baseMessenger.publish( + 'NetworkController:networkRemoved', + createMockNetworkConfiguration(), + ); + + expect(getStorageConfig).not.toHaveBeenCalled(); + expect(deleteNetworkMock).not.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); + const deleteNetworkMock = jest + .spyOn(SyncMutationsModule, 'deleteNetwork') + .mockResolvedValue(); + + return { + getStorageConfig: getStorageConfigMock, + baseMessenger, + messenger, + deleteNetworkMock, + }; + } +}); + +describe('network-syncing/controller-integration - performMainSync()', () => { + it('should do nothing if unable to get storage config', async () => { + const { getStorageConfig, messenger, mockCalls } = arrangeMocks(); + getStorageConfig.mockResolvedValue(null); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(getStorageConfig).toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerGetState).not.toHaveBeenCalled(); + }); + + it('should do nothing if unable to calculate networks to update', async () => { + const { messenger, getStorageConfig, mockSync, mockServices, mockCalls } = + arrangeMocks(); + mockSync.findNetworksToUpdate.mockReturnValue(undefined); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(mockServices.mockBatchUpdateNetworks).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerAddNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerUpdateNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerRemoveNetwork).not.toHaveBeenCalled(); + }); + + it('should update remote networks if there are local networks to add', async () => { + const { messenger, getStorageConfig, mockSync, mockServices, mockCalls } = + arrangeMocks(); + mockSync.findNetworksToUpdate.mockReturnValue({ + remoteNetworksToUpdate: [createMockRemoteNetworkConfiguration()], + missingLocalNetworks: [], + localNetworksToUpdate: [], + localNetworksToRemove: [], + }); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(mockServices.mockBatchUpdateNetworks).toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerAddNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerUpdateNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerRemoveNetwork).not.toHaveBeenCalled(); + }); + + it('should add missing local networks', async () => { + const { messenger, getStorageConfig, mockSync, mockServices, mockCalls } = + arrangeMocks(); + mockSync.findNetworksToUpdate.mockReturnValue({ + remoteNetworksToUpdate: [], + missingLocalNetworks: [createMockNetworkConfiguration()], + localNetworksToUpdate: [], + localNetworksToRemove: [], + }); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(mockServices.mockBatchUpdateNetworks).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerAddNetwork).toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerUpdateNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerRemoveNetwork).not.toHaveBeenCalled(); + }); + + it('should update local networks', async () => { + const { messenger, getStorageConfig, mockSync, mockServices, mockCalls } = + arrangeMocks(); + mockSync.findNetworksToUpdate.mockReturnValue({ + remoteNetworksToUpdate: [], + missingLocalNetworks: [], + localNetworksToUpdate: [createMockNetworkConfiguration()], + localNetworksToRemove: [], + }); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(mockServices.mockBatchUpdateNetworks).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerAddNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerUpdateNetwork).toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerRemoveNetwork).not.toHaveBeenCalled(); + }); + + it('should remove local networks', async () => { + const { messenger, getStorageConfig, mockSync, mockServices, mockCalls } = + arrangeMocks(); + mockSync.findNetworksToUpdate.mockReturnValue({ + remoteNetworksToUpdate: [], + missingLocalNetworks: [], + localNetworksToUpdate: [], + localNetworksToRemove: [createMockNetworkConfiguration()], + }); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(mockServices.mockBatchUpdateNetworks).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerAddNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerUpdateNetwork).not.toHaveBeenCalled(); + expect(mockCalls.mockNetworkControllerRemoveNetwork).toHaveBeenCalled(); + }); + + it('should handle multiple networks to update', async () => { + const { messenger, getStorageConfig, mockSync, mockServices, mockCalls } = + arrangeMocks(); + mockSync.findNetworksToUpdate.mockReturnValue({ + remoteNetworksToUpdate: [ + createMockRemoteNetworkConfiguration(), + createMockRemoteNetworkConfiguration(), + ], + missingLocalNetworks: [ + createMockNetworkConfiguration(), + createMockNetworkConfiguration(), + ], + localNetworksToUpdate: [ + createMockNetworkConfiguration(), + createMockNetworkConfiguration(), + ], + localNetworksToRemove: [ + createMockNetworkConfiguration(), + createMockNetworkConfiguration(), + ], + }); + + await performMainNetworkSync({ messenger, getStorageConfig }); + expect(mockServices.mockBatchUpdateNetworks).toHaveBeenCalledTimes(1); // this is a batch endpoint + expect(mockCalls.mockNetworkControllerAddNetwork).toHaveBeenCalledTimes(2); + expect(mockCalls.mockNetworkControllerUpdateNetwork).toHaveBeenCalledTimes( + 2, + ); + expect(mockCalls.mockNetworkControllerRemoveNetwork).toHaveBeenCalledTimes( + 2, + ); + }); + + /** + * Jest Mock Utility - create suite of mocks for tests + * @returns mocks for tests + */ + function arrangeMocks() { + const baseMessenger = mockBaseMessenger(); + const messenger = mockUserStorageMessenger(baseMessenger); + const getStorageConfigMock = jest + .fn, []>() + .mockResolvedValue(storageOpts); + + const mockCalls = mockMessengerCalls(messenger); + + return { + baseMessenger, + messenger, + getStorageConfig: getStorageConfigMock, + mockCalls, + mockServices: { + mockGetAllRemoveNetworks: jest + .spyOn(ServicesModule, 'getAllRemoteNetworks') + .mockResolvedValue([]), + mockBatchUpdateNetworks: jest + .spyOn(ServicesModule, 'batchUpsertRemoteNetworks') + .mockResolvedValue(), + }, + mockSync: { + findNetworksToUpdate: jest + .spyOn(SyncAllModule, 'findNetworksToUpdate') + .mockReturnValue(undefined), + }, + }; + } + + /** + * Jest Mock Utility - create a mock User Storage Messenger + * @param messenger - The messenger to mock + * @returns messenger call mocks + */ + function mockMessengerCalls(messenger: UserStorageControllerMessenger) { + const mockNetworkControllerGetState = + typedMockCallFn<'NetworkController:getState'>().mockReturnValue({ + selectedNetworkClientId: '', + networksMetadata: {}, + networkConfigurationsByChainId: {}, + }); + + const mockNetworkControllerAddNetwork = + typedMockCallFn<'NetworkController:addNetwork'>(); + + const mockNetworkControllerUpdateNetwork = + typedMockCallFn<'NetworkController:dangerouslySetNetworkConfiguration'>(); + + const mockNetworkControllerRemoveNetwork = + typedMockCallFn<'NetworkController:removeNetwork'>(); + + jest.spyOn(messenger, 'call').mockImplementation((...args) => { + const typedArgs = args as unknown as CallParams; + const [actionType] = typedArgs; + + if (actionType === 'NetworkController:getState') { + return mockNetworkControllerGetState(); + } + + if (actionType === 'NetworkController:addNetwork') { + const [, ...params] = typedArgs; + return mockNetworkControllerAddNetwork(...params); + } + + if ( + actionType === 'NetworkController:dangerouslySetNetworkConfiguration' + ) { + const [, ...params] = typedArgs; + return mockNetworkControllerUpdateNetwork(...params); + } + + if (actionType === 'NetworkController:removeNetwork') { + const [, ...params] = typedArgs; + return mockNetworkControllerRemoveNetwork(...params); + } + + throw new Error( + `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, + ); + }); + + return { + mockNetworkControllerGetState, + mockNetworkControllerAddNetwork, + mockNetworkControllerUpdateNetwork, + mockNetworkControllerRemoveNetwork, + }; + } +}); /** * Test Utility - creates a base messenger so we can invoke/publish events @@ -142,7 +422,20 @@ function mockUserStorageMessenger( const messenger = baseMessenger.getRestricted({ name: 'UserStorageController', - allowedActions: [], + allowedActions: [ + 'KeyringController:getState', + 'SnapController:handleRequest', + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + 'AuthenticationController:isSignedIn', + 'AuthenticationController:performSignIn', + 'AuthenticationController:performSignOut', + 'NotificationServicesController:disableNotificationServices', + 'NotificationServicesController:selectIsNotificationServicesEnabled', + 'AccountsController:listAccounts', + 'AccountsController:updateAccountMetadata', + 'KeyringController:addNewAccount', + ], allowedEvents, }); 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 index 901046a295..381675f2f0 100644 --- 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 @@ -2,28 +2,60 @@ import log from 'loglevel'; import type { UserStorageBaseOptions } from '../services'; import type { UserStorageControllerMessenger } from '../UserStorageController'; -import { addNetwork, deleteNetwork, updateNetwork } from './sync-mutations'; +import { getAllRemoteNetworks } from './services'; +import { findNetworksToUpdate } from './sync-all'; +import { batchUpdateNetworks, deleteNetwork } from './sync-mutations'; -type SetupNetworkSyncingProps = { +type StartNetworkSyncingProps = { messenger: UserStorageControllerMessenger; - getStorageConfig: () => Promise; + getStorageConfig: () => Promise; }; +type PerformMainNetworkSyncProps = { + messenger: UserStorageControllerMessenger; + getStorageConfig: () => Promise; + onNetworkAdded?: (chainId: string) => void; + onNetworkUpdated?: (chainId: string) => void; + onNetworkRemoved?: (chainId: string) => void; +}; + +/** + * Global in-mem cache to signify that the network syncing is in progress + * Ensures that listeners do not fire during main sync (prevent double requests) + */ +// Exported to help testing +// eslint-disable-next-line import/no-mutable-exports +export let isMainNetworkSyncInProgress = false; + /** * Initialize and setup events to listen to for network syncing + * We will be listening to: + * - Remove Event, to indicate that we need to remote network from remote + * + * We will not be listening to: + * - Add/Update events are not required, as we can sync these during the main sync + * * @param props - parameters used for initializing and enabling network syncing */ -export function startNetworkSyncing(props: SetupNetworkSyncingProps) { +export function startNetworkSyncing(props: StartNetworkSyncingProps) { const { messenger, getStorageConfig } = props; - try { messenger.subscribe( - 'NetworkController:networkAdded', + 'NetworkController:networkRemoved', // eslint-disable-next-line @typescript-eslint/no-misused-promises async (networkConfiguration) => { try { + // As main sync is in progress, it will already local and remote networks + // So no need to re-process again. + if (isMainNetworkSyncInProgress) { + return; + } + const opts = await getStorageConfig(); - await addNetwork(networkConfiguration, opts); + if (!opts) { + return; + } + await deleteNetwork(networkConfiguration, opts); } catch { // Silently fail sync } @@ -32,38 +64,118 @@ export function startNetworkSyncing(props: SetupNetworkSyncingProps) { } catch (e) { log.warn('NetworkSyncing, event subscription failed', e); } +} + +/** + * Action to perform the main network sync. + * It will fetch local networks and remote networks, then determines which networks (local and remote) to add/update + * @param props - parameters used for this main sync + */ +export async function performMainNetworkSync( + props: PerformMainNetworkSyncProps, +) { + const { messenger, getStorageConfig } = props; + + // Edge-Case, we do not want to re-run the main-sync if it already is in progress + if (isMainNetworkSyncInProgress) { + return; + } + isMainNetworkSyncInProgress = true; try { - messenger.subscribe( - 'NetworkController:networkDeleted', - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async (networkConfiguration) => { + const opts = await getStorageConfig(); + if (!opts) { + return; + } + + const networkControllerState = messenger.call('NetworkController:getState'); + const localNetworks = Object.values( + networkControllerState.networkConfigurationsByChainId ?? {}, + ); + + const remoteNetworks = await getAllRemoteNetworks(opts); + const networkChanges = findNetworksToUpdate({ + localNetworks, + remoteNetworks, + }); + + log.debug('performMainNetworkSync() - Network Syncing Started', { + localNetworks, + remoteNetworks, + networkChanges, + }); + + // Update Remote + if ( + networkChanges?.remoteNetworksToUpdate && + networkChanges.remoteNetworksToUpdate.length > 0 + ) { + await batchUpdateNetworks(networkChanges?.remoteNetworksToUpdate, opts); + } + + // Add missing local networks + if ( + networkChanges?.missingLocalNetworks && + networkChanges.missingLocalNetworks.length > 0 + ) { + networkChanges.missingLocalNetworks.forEach((n) => { try { - const opts = await getStorageConfig(); - await deleteNetwork(networkConfiguration, opts); - } catch { - // Silently fail sync + messenger.call('NetworkController:addNetwork', n); + props.onNetworkAdded?.(n.chainId); + } catch (e) { + console.error( + 'performMainNetworkSync() - NetworkController:addNetwork failed', + e, + ); + // Silently fail, we can try this again on next main 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) => { + // Update local networks + if ( + networkChanges?.localNetworksToUpdate && + networkChanges.localNetworksToUpdate.length > 0 + ) { + for (const n of networkChanges.localNetworksToUpdate) { try { - const opts = await getStorageConfig(); - await updateNetwork(networkConfiguration, opts); - } catch { - // Silently fail sync + await messenger.call( + 'NetworkController:dangerouslySetNetworkConfiguration', + n, + ); + props.onNetworkUpdated?.(n.chainId); + } catch (e) { + console.error( + 'performMainNetworkSync() - NetworkController:dangerouslySetNetworkConfiguration failed', + e, + ); + // Silently fail, we can try this again on next main sync } - }, - ); + } + } + + // Remove local networks + if ( + networkChanges?.localNetworksToRemove && + networkChanges.localNetworksToRemove.length > 0 + ) { + networkChanges.localNetworksToRemove.forEach((n) => { + try { + messenger.call('NetworkController:removeNetwork', n.chainId); + props.onNetworkRemoved?.(n.chainId); + } catch (e) { + console.error( + 'performMainNetworkSync() - NetworkController:removeNetwork failed', + e, + ); + // Silently fail, we can try this again on next main sync + } + }); + } } catch (e) { - log.warn('NetworkSyncing, event subscription failed', e); + console.error('performMainNetworkSync() failed', e); + // Silently fail sync + } finally { + isMainNetworkSyncInProgress = false; } } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts index e464d55463..abebcbf66e 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/services.ts @@ -1,5 +1,6 @@ import type { UserStorageBaseOptions } from '../services'; import { + batchUpsertUserStorage, getUserStorageAllFeatureEntries, upsertUserStorage, } from '../services'; @@ -71,8 +72,14 @@ export async function batchUpsertRemoteNetworks( networks: RemoteNetworkConfiguration[], opts: UserStorageBaseOptions, ): Promise { - // TODO - this has not yet been provided by the backend team - // we will replace this with a batch endpoint in near future - const promises = networks.map((n) => upsertRemoteNetwork(n, opts)); - await Promise.allSettled(promises); + const networkPathAndValues = networks.map((n) => { + const path = n.chainId; + const data = JSON.stringify(n); + return [path, data] as [string, string]; + }); + + await batchUpsertUserStorage(networkPathAndValues, { + path: 'networks', + ...opts, + }); } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts index 9702f641ad..efd314fee7 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts @@ -4,11 +4,10 @@ import { } from './__fixtures__/mockNetwork'; import { checkWhichNetworkIsLatest, - findNetworksToUpdate, getDataStructures, getMissingNetworkLists, - getNewLocalNetworks, getUpdatedNetworkLists, + findNetworksToUpdate, } from './sync-all'; import type { NetworkConfiguration, RemoteNetworkConfiguration } from './types'; @@ -205,75 +204,22 @@ describe('getUpdatedNetworkLists()', () => { }); }); -/** - * This is not used externally, but meant to check logic is consistent - */ -describe('getNewLocalNetworks()', () => { - it('should append original list with missing networks', () => { - const originalList = arrangeLocalNetworks(['1', '2', '3']); - const missingNetworks = arrangeLocalNetworks(['4']); - - const result = getNewLocalNetworks({ - originalList, - missingLocalNetworks: missingNetworks, - localNetworksToRemove: [], - localNetworksToUpdate: [], - }); - - expect(result).toHaveLength(4); - expect(result.map((n) => n.chainId)).toStrictEqual([ - '0x1', - '0x2', - '0x3', - '0x4', - ]); - }); - - it('should update original list if there are networks that need updating', () => { - const originalList = arrangeLocalNetworks(['1', '2', '3']); - const updatedNetwork = createMockNetworkConfiguration({ - chainId: '0x1', - name: 'Updated Name', - }); - - const result = getNewLocalNetworks({ - originalList, - missingLocalNetworks: [], - localNetworksToRemove: [], - localNetworksToUpdate: [updatedNetwork], - }); - - expect(result).toHaveLength(3); - expect(result.find((n) => n.chainId === '0x1')?.name).toBe('Updated Name'); - }); - - it('should remote a network from the original list if there are networks that need to be removed', () => { - const originalList = arrangeLocalNetworks(['1', '2', '3']); - const deletedNetwork = createMockNetworkConfiguration({ chainId: '0x1' }); - - const result = getNewLocalNetworks({ - originalList, - missingLocalNetworks: [], - localNetworksToRemove: [deletedNetwork], - localNetworksToUpdate: [], - }); - - expect(result).toHaveLength(2); - expect(result.find((n) => n.chainId === '0x1')).toBeUndefined(); - }); -}); - describe('findNetworksToUpdate()', () => { it('should add missing networks to remote and local', () => { const localNetworks = arrangeLocalNetworks(['1']); const remoteNetworks = arrangeRemoteNetworks(['2']); const result = findNetworksToUpdate({ localNetworks, remoteNetworks }); - expect(result?.newLocalNetworks).toHaveLength(2); - expect(result?.newLocalNetworks.map((n) => n.chainId)).toStrictEqual([ - '0x1', - '0x2', - ]); + + // Only 1 network needs to be added to local + expect(result?.missingLocalNetworks).toHaveLength(1); + expect(result?.missingLocalNetworks?.[0]?.chainId).toBe('0x2'); + + // No networks are to be removed locally + expect(result?.localNetworksToRemove).toStrictEqual([]); + + // No networks are to be updated locally + expect(result?.localNetworksToUpdate).toStrictEqual([]); // Only 1 network needs to be updated expect(result?.remoteNetworksToUpdate).toHaveLength(1); @@ -302,38 +248,43 @@ describe('findNetworksToUpdate()', () => { }); const result = findNetworksToUpdate({ localNetworks, remoteNetworks }); - const newLocalIds = result?.newLocalNetworks?.map((n) => n.chainId) ?? []; + + // Assert - No local networks to add or remove + expect(result?.missingLocalNetworks).toStrictEqual([]); + expect(result?.localNetworksToRemove).toStrictEqual([]); + + // Assert - Local and Remote networks to update + const updateLocalIds = + result?.localNetworksToUpdate?.map((n) => n.chainId) ?? []; const updateRemoteIds = result?.remoteNetworksToUpdate?.map((n) => n.chainId) ?? []; - // Assert - Test Matrix combinations were all tested + + // Check Test Matrix combinations were all tested let testCount = 0; testMatrix.forEach(({ actual }, idx) => { const chainId = `0x${idx}` as const; if (actual === 'Do Nothing') { testCount += 1; - // Combined Local Networks will include this - // Updated Remote Networks will not include this, as it is not a network that needs updating on remote + // No lists are updated if nothing changes // eslint-disable-next-line jest/no-conditional-expect expect([ - newLocalIds.includes(chainId), + updateLocalIds.includes(chainId), updateRemoteIds.includes(chainId), - ]).toStrictEqual([true, false]); + ]).toStrictEqual([false, false]); } else if (actual === 'Local Wins') { testCount += 1; - // Combined Local Networks will include this - // Updated Remote Networks will include this, as we need to update remote + // Only remote is updated if local wins // eslint-disable-next-line jest/no-conditional-expect expect([ - newLocalIds.includes(chainId), + updateLocalIds.includes(chainId), updateRemoteIds.includes(chainId), - ]).toStrictEqual([true, true]); + ]).toStrictEqual([false, true]); } else if (actual === 'Remote Wins') { testCount += 1; - // Combined Local Networks will include this - // Updated Remote Networks will not include this, as it is not a network that needs updating on remote + // Only local is updated if remote wins // eslint-disable-next-line jest/no-conditional-expect expect([ - newLocalIds.includes(chainId), + updateLocalIds.includes(chainId), updateRemoteIds.includes(chainId), ]).toStrictEqual([true, false]); } @@ -349,11 +300,17 @@ describe('findNetworksToUpdate()', () => { remoteNetworks[1].d = true; const result = findNetworksToUpdate({ localNetworks, remoteNetworks }); - // Combined Local List is updated - expect(result?.newLocalNetworks).toHaveLength(1); - expect( - result?.newLocalNetworks.find((n) => n.chainId === '0x2'), - ).toBeUndefined(); + + // Assert no remote networks need updating + expect(result?.remoteNetworksToUpdate).toStrictEqual([]); + + // Assert no local networks need to be updated or added + expect(result?.localNetworksToUpdate).toStrictEqual([]); + expect(result?.missingLocalNetworks).toStrictEqual([]); + + // Assert that a network needs to be removed locally (network 0x2) + expect(result?.localNetworksToRemove).toHaveLength(1); + expect(result?.localNetworksToRemove?.[0]?.chainId).toBe('0x2'); // Remote List does not have any networks that need updating expect(result?.remoteNetworksToUpdate).toHaveLength(0); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts index f3cd7da156..a1cc414a32 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts @@ -81,6 +81,8 @@ export const checkWhichNetworkIsLatest = ( : 'Remote Wins'; } + // Unreachable statement + /* istanbul ignore next */ return 'Do Nothing'; }; @@ -141,6 +143,9 @@ export const getUpdatedNetworkLists = ( const localNetwork = localMap.get(chain); const remoteNetwork = remoteMap.get(chain); if (!localNetwork || !remoteNetwork) { + // This should be unreachable as we know the Maps created will have the values + // This is to satisfy types + /* istanbul ignore next */ return; } @@ -173,35 +178,6 @@ export const getUpdatedNetworkLists = ( }; }; -export const getNewLocalNetworks = (props: { - originalList: NetworkConfiguration[]; - missingLocalNetworks: NetworkConfiguration[]; - localNetworksToUpdate: NetworkConfiguration[]; - localNetworksToRemove: NetworkConfiguration[]; -}) => { - let newList = [...props.originalList]; - newList.push(...props.missingLocalNetworks); - const updateMap = createMap(props.localNetworksToUpdate); - const remoteMap = createMap(props.localNetworksToRemove); - - newList = newList - .map((n) => { - if (remoteMap.has(n.chainId)) { - return undefined; - } - - const updatedNetwork = updateMap.get(n.chainId); - if (updatedNetwork) { - return updatedNetwork; - } - - return n; - }) - .filter((n): n is NetworkConfiguration => n !== undefined); - - return newList; -}; - export const findNetworksToUpdate = (props: FindNetworksToUpdateProps) => { try { const { localNetworks, remoteNetworks } = props; @@ -221,21 +197,17 @@ export const findNetworksToUpdate = (props: FindNetworksToUpdateProps) => { ...updatedNetworks.remoteNetworksToUpdate, ]; - // List of new local networks - const newLocalNetworks = getNewLocalNetworks({ - originalList: localNetworks, + return { + remoteNetworksToUpdate, missingLocalNetworks: missingNetworks.missingLocalNetworks, localNetworksToRemove: updatedNetworks.localNetworksToRemove, localNetworksToUpdate: updatedNetworks.localNetworksToUpdate, - }); - - return { - remoteNetworksToUpdate, - newLocalNetworks, }; } catch { // Unable to perform sync, silently fail } + // Unreachable statement + /* istanbul ignore next */ return undefined; }; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts index 841a459dcd..4545248c3a 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts @@ -21,7 +21,7 @@ export const deleteNetwork = async ( v: '1', ...network, d: true, - lastUpdatedAt: network.lastUpdatedAt ?? Date.now(), // Ensures that a deleted entry has a date field + lastUpdatedAt: Date.now(), // Ensures that a deleted entry has a date field. }, opts, );