Skip to content

Commit

Permalink
feat: network-syncing and user storage controller integration (#4687)
Browse files Browse the repository at this point in the history
## Explanation

This is a follow up on #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

<!--
If you're making any consumer-facing changes, list those changes here as
if you were updating a changelog, using the template below as a guide.

(CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or
FIXED. For security-related issues, follow the Security Advisory
process.)

Please take care to name the exact pieces of the API you've added or
changed (e.g. types, interfaces, functions, or methods).

If there are any breaking changes, make sure to offer a solution for
consumers to follow once they upgrade to the changes.

Finally, if you're only making changes to development scripts or tests,
you may replace the template below with "None".
-->

### `@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
  • Loading branch information
Prithpal-Sooriya authored Sep 11, 2024
1 parent d32ada7 commit 6cf64c6
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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 =
{
Expand Down Expand Up @@ -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
Expand All @@ -165,7 +181,7 @@ export type AllowedActions =
| KeyringControllerAddNewAccountAction;

// Messenger events
export type UserStorageControllerChangeEvent = ControllerStateChangeEvent<
export type UserStorageControllerStateChangeEvent = ControllerStateChangeEvent<
typeof controllerName,
UserStorageControllerState
>;
Expand All @@ -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<
Expand Down Expand Up @@ -372,6 +397,7 @@ export default class UserStorageController extends BaseController<
state?: UserStorageControllerState;
env?: {
isAccountSyncingEnabled?: boolean;
isNetworkSyncingEnabled?: boolean;
};
getMetaMetricsState: () => boolean;
nativeScryptCrypto?: NativeScrypt;
Expand All @@ -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,
};
},
});
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => {
const { intervalMs = 50, timeoutMs = 2000 } = options;

const startTime = Date.now();

return new Promise<void>((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);
});
};
Original file line number Diff line number Diff line change
@@ -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<typeof mockBaseMessenger>,
eventsOverride?: ExternalEvents[],
) {
const allowedEvents = eventsOverride ?? getEvents();

const messenger = baseMessenger.getRestricted({
name: 'UserStorageController',
allowedActions: [],
allowedEvents,
});

return messenger;
}
Original file line number Diff line number Diff line change
@@ -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<UserStorageBaseOptions>;
};

/**
* 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);
}
}

0 comments on commit 6cf64c6

Please sign in to comment.