diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..3662b3700e2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/package.json b/package.json index a845c0e5549..a01d040b833 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/jest": "^22.2.3", "@types/node": "^10.1.4", "@types/sinon": "^4.3.3", + "@types/uuid": "^8.3.0", "@types/web3": "^1.0.6", "@typescript-eslint/eslint-plugin": "^4.1.0", "@typescript-eslint/parser": "^4.1.0", @@ -80,7 +81,7 @@ "sinon": "^7.4.1", "ts-jest": "^26.3.0", "typedoc": "^0.15.0", - "typescript": "^4.0.3" + "typescript": "^4.1.3" }, "jest": { "moduleFileExtensions": [ diff --git a/src/ComposableController.ts b/src/ComposableController.ts index 69ec03ce8d2..5bd3efba197 100644 --- a/src/ComposableController.ts +++ b/src/ComposableController.ts @@ -65,6 +65,7 @@ export class ComposableController extends BaseController { const { name } = controller; this.context[name] = controller; controller.context = this.context; + controller.messagingSystem = this.messagingSystem; this.cachedState && this.cachedState[name] && controller.update(this.cachedState[name]); initialState[name] = controller.state; controller.subscribe((state) => { diff --git a/src/MSG-SYSTEM.ts b/src/MSG-SYSTEM.ts new file mode 100644 index 00000000000..0da1fd12bf2 --- /dev/null +++ b/src/MSG-SYSTEM.ts @@ -0,0 +1,18 @@ +import { EventEmitter } from 'events'; + +interface AttenuatedMessageSystem { + subscriptionIds: string[]; + call: () => void; +} + +export function attenuatedMessageSystem( + eventEmitter: EventEmitter, + actionMap: Map, + allowedActions: string[], + allowedSubscriptions: string[] +): AttenuatedMessageSystem { + return Object.freeze({ + subscriptionIds: [], + call: () => {}, + }); +} diff --git a/src/assets/AccountTrackerController.ts b/src/assets/AccountTrackerController.ts index 2cd3734936b..425992ac302 100644 --- a/src/assets/AccountTrackerController.ts +++ b/src/assets/AccountTrackerController.ts @@ -1,5 +1,6 @@ import BaseController, { BaseConfig, BaseState } from '../BaseController'; -import PreferencesController from '../user/PreferencesController'; +import { ControllerMessagingSystem } from '../controller-messaging-system'; +import { GET_PREFERENCES_STATE, PREFERENCES_STATE_CHANGED } from '../user/PreferencesController'; import { BNToHex, safelyExecute } from '../util'; const EthjsQuery = require('ethjs-query'); @@ -44,12 +45,14 @@ export interface AccountTrackerState extends BaseState { export class AccountTrackerController extends BaseController { private ethjsQuery: any; + private messagingSystem: ControllerMessagingSystem; + private handle?: NodeJS.Timer; private syncAccounts() { const { - state: { identities }, - } = this.context.PreferencesController as PreferencesController; + identities, + } = this.messagingSystem.call(GET_PREFERENCES_STATE); const { accounts } = this.state; const addresses = Object.keys(identities); const existing = Object.keys(accounts); @@ -69,19 +72,15 @@ export class AccountTrackerController extends BaseController, state?: Partial) { + constructor(messagingSystem: ControllerMessagingSystem, config?: Partial, state?: Partial) { super(config, state); + this.messagingSystem = messagingSystem; this.defaultConfig = { interval: 10000, }; @@ -104,8 +103,7 @@ export class AccountTrackerController extends BaseController { private mutex = new Mutex(); + private messagingSystem: ControllerMessagingSystem; + + private preferencesSubId?: string; + private getCollectibleApi(contractAddress: string, tokenId: number) { return `https://api.opensea.io/api/v1/asset/${contractAddress}/${tokenId}`; } @@ -515,7 +520,7 @@ export class AssetsController extends BaseController /** * List of required sibling controllers this controller needs to function */ - requiredControllers = ['AssetsContractController', 'NetworkController', 'PreferencesController']; + requiredControllers = ['AssetsContractController', 'NetworkController']; /** * Creates a AssetsController instance @@ -523,8 +528,9 @@ export class AssetsController extends BaseController * @param config - Initial options used to configure this controller * @param state - Initial state to set on this controller */ - constructor(config?: Partial, state?: Partial) { + constructor(messagingSystem: ControllerMessagingSystem, config?: Partial, state?: Partial) { super(config, state); + this.messagingSystem = messagingSystem; this.defaultConfig = { networkType: 'mainnet', selectedAddress: '', @@ -795,9 +801,8 @@ export class AssetsController extends BaseController */ onComposed() { super.onComposed(); - const preferences = this.context.PreferencesController as PreferencesController; const network = this.context.NetworkController as NetworkController; - preferences.subscribe(({ selectedAddress }) => { + this.preferencesSubId = this.messagingSystem.subscribe(PREFERENCES_STATE_CHANGED, ({ selectedAddress }) => { const { allCollectibleContracts, allCollectibles, allTokens } = this.state; const { networkType } = this.config; this.configure({ selectedAddress }); @@ -821,6 +826,12 @@ export class AssetsController extends BaseController }); }); } + + onDestroy() { + if (this.preferencesSubId) { + this.messagingSystem.unsubscribe(this.preferencesSubId); + } + } } export default AssetsController; diff --git a/src/assets/AssetsDetectionController.ts b/src/assets/AssetsDetectionController.ts index 9882d82f342..32a2c2c5b17 100644 --- a/src/assets/AssetsDetectionController.ts +++ b/src/assets/AssetsDetectionController.ts @@ -1,8 +1,9 @@ import { toChecksumAddress } from 'ethereumjs-util'; import BaseController, { BaseConfig, BaseState } from '../BaseController'; import NetworkController, { NetworkType } from '../network/NetworkController'; -import PreferencesController from '../user/PreferencesController'; +import { PreferencesState, PREFERENCES_STATE_CHANGED } from '../user/PreferencesController'; import { safelyExecute, timeoutFetch } from '../util'; +import { ControllerMessagingSystem } from '../controller-messaging-system'; import AssetsContractController from './AssetsContractController'; import { Token } from './TokenRatesController'; @@ -55,6 +56,10 @@ export interface AssetsDetectionConfig extends BaseConfig { export class AssetsDetectionController extends BaseController { private handle?: NodeJS.Timer; + private messagingSystem: ControllerMessagingSystem; + + private subId?: string; + private getOwnerCollectiblesApi(address: string) { return `https://api.opensea.io/api/v1/assets?owner=${address}&limit=300`; } @@ -88,7 +93,7 @@ export class AssetsDetectionController extends BaseController, state?: Partial) { + constructor(messagingSystem: ControllerMessagingSystem, config?: Partial, state?: Partial) { super(config, state); + this.messagingSystem = messagingSystem; this.defaultConfig = { interval: DEFAULT_INTERVAL, networkType: 'mainnet', @@ -256,23 +262,30 @@ export class AssetsDetectionController extends BaseController { this.configure({ tokens }); }); - preferences.subscribe(({ selectedAddress }) => { + + this.subId = this.messagingSystem.subscribe(PREFERENCES_STATE_CHANGED, ({ selectedAddress }) => { const actualSelectedAddress = this.config.selectedAddress; if (selectedAddress !== actualSelectedAddress) { this.configure({ selectedAddress }); this.detectAssets(); } }); + network.subscribe(({ provider }) => { this.configure({ networkType: provider.type }); }); } + + onDestroyed() { + if (this.subId) { + this.messagingSystem.unsubscribe(this.subId); + } + } } export default AssetsDetectionController; diff --git a/src/assets/CurrencyRateController.ts b/src/assets/CurrencyRateController.ts index 53b6dce620f..7680966ee1b 100644 --- a/src/assets/CurrencyRateController.ts +++ b/src/assets/CurrencyRateController.ts @@ -3,6 +3,7 @@ import { Mutex } from 'await-semaphore'; import BaseController from '../base-controller-v2'; import { safelyExecute } from '../util'; import { fetchExchangeRate as defaultFetchExchangeRate } from '../crypto-compare'; +import { ControllerMessagingSystem } from '../controller-messaging-system'; /** * @type CurrencyRateState @@ -35,11 +36,28 @@ const schema = { usdConversionRate: { persist: false, anonymous: true }, }; +/** constants */ +const GET_CURRENCY_RATE_STATE = 'CurrencyRateController.getState'; +const SET_CURRENT_CURRENCY = 'CurrencyRateController.setCurrentCurrency'; +const SET_NATIVE_CURRENCY = 'CurrencyRateController.setNativeCurrency'; + +export const CURRENCY_RATE_STATE_CHANGED = 'CurrencyRateController.state-changed'; + +export interface CurrencyRateActions { + [GET_CURRENCY_RATE_STATE]: () => CurrencyRateState; + [SET_CURRENT_CURRENCY]: (currentCurrency: string) => void; + [SET_NATIVE_CURRENCY]: (nativeCurrency: string) => void; +} + +export interface CurrencyRateEvents { + [CURRENCY_RATE_STATE_CHANGED]: CurrencyRateState; +} + /** * Controller that passively polls on a set interval for an exchange rate from the current base * asset to the current currency */ -export class CurrencyRateController extends BaseController { +export class CurrencyRateController extends BaseController { private static defaultState = { conversionDate: 0, @@ -65,11 +83,16 @@ export class CurrencyRateController extends BaseController { * * @param state - Initial state to set on this controller */ - constructor(state?: Partial, includeUsdRate = false, fetchExchangeRate = defaultFetchExchangeRate) { - super({ ...CurrencyRateController.defaultState, ...state }, schema); + constructor(messagingSystem: ControllerMessagingSystem, state?: Partial, includeUsdRate = false, fetchExchangeRate = defaultFetchExchangeRate) { + super(messagingSystem, { ...CurrencyRateController.defaultState, ...state }, CURRENCY_RATE_STATE_CHANGED, schema); this.includeUsdRate = includeUsdRate; this.fetchExchangeRate = fetchExchangeRate; this.poll(); + this.registerActions({ + [GET_CURRENCY_RATE_STATE]: () => this.state, + [SET_CURRENT_CURRENCY]: this.setCurrentCurrency, + [SET_NATIVE_CURRENCY]: this.setNativeCurrency, + }); } destroy() { diff --git a/src/assets/TokenRatesController.ts b/src/assets/TokenRatesController.ts index 1c5d098dd47..dc7c3549575 100644 --- a/src/assets/TokenRatesController.ts +++ b/src/assets/TokenRatesController.ts @@ -1,6 +1,9 @@ import { toChecksumAddress } from 'ethereumjs-util'; import BaseController, { BaseConfig, BaseState } from '../BaseController'; +import { ControllerMessagingSystem } from '../controller-messaging-system'; import { safelyExecute, handleFetch } from '../util'; +import AssetsController from './AssetsController'; +import { CURRENCY_RATE_STATE_CHANGED, CurrencyRateState } from './CurrencyRateController'; /** * @type CoinGeckoResponse @@ -63,6 +66,10 @@ export interface TokenRatesState extends BaseState { export class TokenRatesController extends BaseController { private handle?: NodeJS.Timer; + private messagingSystem: ControllerMessagingSystem; + + private subId?: string; + private tokenList: Token[] = []; private getPricingURL(query: string) { @@ -77,7 +84,7 @@ export class TokenRatesController extends BaseController, state?: Partial) { + constructor(messagingSystem: ControllerMessagingSystem, config?: Partial, state?: Partial) { super(config, state); + this.messagingSystem = messagingSystem; this.defaultConfig = { disabled: true, interval: 180000, @@ -133,6 +141,27 @@ export class TokenRatesController extends BaseController { + this.configure({ tokens: assets.state.tokens }); + }); + this.subId = this.messagingSystem.subscribe(CURRENCY_RATE_STATE_CHANGED, (state) => { + this.configure({ nativeCurrency: state.nativeCurrency }); + }); + } + + onDestroyed() { + if (this.subId) { + this.messagingSystem.unsubscribe(this.subId); + } + } + /** * Updates exchange rates for all tokens * diff --git a/src/base-controller-v2.ts b/src/base-controller-v2.ts index 570cca74de0..b1174a95b55 100644 --- a/src/base-controller-v2.ts +++ b/src/base-controller-v2.ts @@ -2,6 +2,7 @@ // import a type: https://github.com/benmosher/eslint-plugin-import/issues/1699 // eslint-disable-next-line import/named import { Draft, produce } from 'immer'; +import { EventHandler, Actions, EventHandlerRecord, ControllerMessagingSystem } from './controller-messaging-system'; /** * State change callbacks @@ -20,10 +21,16 @@ export type Schema = { /** * Controller class that provides state management and subscriptions */ -export class BaseController> { +export class BaseController, A extends Partial> { private internalState: S; - private internalListeners: Listener[] = []; + private messagingSystem: ControllerMessagingSystem; + + private subscriptions: string[] = []; + + private actions?: A; + + private stateUpdateEventName = 'BaseController.state-changed'; public readonly schema: Schema; @@ -34,9 +41,11 @@ export class BaseController> { * @param schema - State schema, describing how to "anonymize" the state, * and which parts should be persisted. */ - constructor(state: S, schema: Schema) { + constructor(messagingSystem: ControllerMessagingSystem, state: S, stateUpdateEventName: string, schema: Schema) { this.internalState = state; this.schema = schema; + this.stateUpdateEventName = stateUpdateEventName; + this.messagingSystem = messagingSystem; } /** @@ -48,13 +57,43 @@ export class BaseController> { return this.internalState; } + registerActions(actions: A) { + this.actions = actions; + this.messagingSystem.registerActionHandlers(actions); + } + + registerAction(action: N, handler: Actions[N]) { + if (!this.actions?.[action]) { + this.messagingSystem.registerActionHandler(action, handler); + this.actions = { + ...this.actions, + [action]: handler, + }; + } + } + + unregisterActions() { + if (this.actions) { + this.messagingSystem.unregisterActionHandlers(Object.keys(this.actions)); + } + } + /** * Adds new listener to be notified of state changes * * @param listener - Callback triggered when state changes */ - subscribe(listener: Listener) { - this.internalListeners.push(listener); + subscribe(event: string, eventHandler: EventHandler): string { + const subId = this.messagingSystem.subscribe(event, eventHandler); + this.subscriptions.push(subId); + return subId; + } + + subscribeToEvents(events: EventHandlerRecord) { + this.subscriptions = [ + ...this.subscriptions, + ...this.messagingSystem.subscribeToEvents(events), + ]; } /** @@ -63,10 +102,9 @@ export class BaseController> { * @param listener - Callback to remove * @returns - True if a listener is found and unsubscribed */ - unsubscribe(listener: Listener) { - const index = this.internalListeners.findIndex((cb) => listener === cb); - index > -1 && this.internalListeners.splice(index, 1); - return index > -1; + unsubscribe(subId: string) { + this.unsubscribe(subId); + this.subscriptions = this.subscriptions.filter((activeSubId) => activeSubId !== subId); } /** @@ -80,6 +118,7 @@ export class BaseController> { */ protected update(callback: (state: Draft) => void | S) { this.internalState = produce(this.internalState, callback) as S; + this.messagingSystem.publish(this.stateUpdateEventName, this.state); } /** @@ -90,8 +129,15 @@ export class BaseController> { * this is not required for garbage collection, it at least ensures this * instance won't be responsible for keeping the listeners in memory. */ - protected destroy() { - this.internalListeners = []; + destroy() { + if (this.subscriptions) { + this.messagingSystem.unsubscribeFromEvents(this.subscriptions); + } + this.subscriptions = []; + if (this.actions) { + this.unregisterActions(); + } + this.actions = undefined; } } diff --git a/src/controller-messaging-system.ts b/src/controller-messaging-system.ts new file mode 100644 index 00000000000..fe5ff6326bc --- /dev/null +++ b/src/controller-messaging-system.ts @@ -0,0 +1,106 @@ +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import { CurrencyRateActions } from './assets/CurrencyRateController'; +import { PreferencesActions } from './user/PreferencesController'; + +export type EventHandler> = (payload: T) => void; +export type EventHandlerRecord = Record>; +type ReturnTypeOfMethod = T extends (...args: any[]) => any ? ReturnType : any; +type ReturnTypeOfMethodIfExists = S extends keyof T ? ReturnTypeOfMethod : any; +type MethodParams = T extends (...args: infer P) => any ? P[0] : T; +type MethodParamsIfExists = S extends keyof T ? MethodParams : S; + +export type Actions = PreferencesActions & CurrencyRateActions; + +export class ControllerMessagingSystem { + private ee: EventEmitter = new EventEmitter(); + + private actions = new Map(); + + private listeners = new Map }>(); + + public registerActionHandler( + action: T, + handler: Actions[T], + ): void { + if (this.actions.has(action)) { + throw new Error(`A handler for ${action} has already been registered`); + } + this.actions.set(action, handler); + } + + public registerActionHandlers(handlers: Partial): void { + // eslint-disable-next-line @typescript-eslint/array-type + const actionKeys = Object.keys(handlers) as Array; + actionKeys.forEach((action) => { + const handler = handlers[action]; + if (handler) { + this.registerActionHandler(action, handler); + } + }); + } + + public unregisterActionHandler(action: keyof Actions): void { + this.actions.delete(action); + } + + public unregisterActionHandlers(actionNames: (keyof Actions)[]): void { + actionNames.forEach((action) => this.unregisterActionHandler(action)); + } + + public resetActionHandlers(): void { + this.actions.clear(); + } + + public call( + action: N, + params: MethodParamsIfExists + ): ReturnTypeOfMethodIfExists { + const handler = this.actions.get(action); + if (!handler) { + throw new Error(`A handler for ${action} has not been registered`); + } + return handler(params as any) as any; + } + + public publish(event: string, payload: T): void { + this.ee.emit(event, payload); + } + + public subscribe(event: string, handler: EventHandler): string { + const subId = uuidv4(); + this.listeners.set(subId, { event, handler }); + this.ee.on(event, handler); + return subId; + } + + public subscribeToEvents(events: EventHandlerRecord): string[] { + const subscriptionIds: string[] = []; + Object.keys(events).forEach((event) => { + subscriptionIds.push(this.subscribe(event, events[event])); + }); + return subscriptionIds; + } + + public unsubscribe(subId: string) { + const subscription = this.listeners.get(subId); + if (subscription) { + this.ee.off(subscription.event, subscription.handler); + } + this.listeners.delete(subId); + } + + public unsubscribeFromEvents(subscriptionIds: string[]): void { + subscriptionIds.forEach((subId) => this.unsubscribe(subId)); + } + + public resetSubscriptions() { + [...this.listeners.keys()].forEach((subId) => this.unsubscribe(subId)); + this.listeners.clear(); + } + + public destroy() { + this.resetActionHandlers(); + this.resetSubscriptions(); + } +} diff --git a/src/keyring/KeyringController.ts b/src/keyring/KeyringController.ts index 2415a2a7e5a..9101605d842 100644 --- a/src/keyring/KeyringController.ts +++ b/src/keyring/KeyringController.ts @@ -6,10 +6,11 @@ import { signTypedDataLegacy, } from 'eth-sig-util'; import BaseController, { BaseConfig, BaseState, Listener } from '../BaseController'; -import PreferencesController from '../user/PreferencesController'; +import { GET_PREFERENCES_STATE, PreferencesState, REMOVE_IDENTITY, SET_SELECTED_ADDRESS, SYNC_IDENTITIES, UPDATE_IDENTITIES } from '../user/PreferencesController'; import { Transaction } from '../transaction/TransactionController'; import { PersonalMessageParams } from '../message-manager/PersonalMessageManager'; import { TypedMessageParams } from '../message-manager/TypedMessageManager'; +import { ControllerMessagingSystem } from '../controller-messaging-system'; const Keyring = require('eth-keyring-controller'); const { Mutex } = require('await-semaphore'); @@ -102,6 +103,8 @@ export interface Keyring { export class KeyringController extends BaseController { private mutex = new Mutex(); + private messagingSystem: ControllerMessagingSystem; + /** * Name of this controller used during composition */ @@ -110,7 +113,7 @@ export class KeyringController extends BaseController, state?: Partial) { + constructor(messagingSystem: ControllerMessagingSystem, config?: Partial, state?: Partial) { super(config, state); + this.messagingSystem = messagingSystem; privates.set(this, { keyring: new Keyring(Object.assign({ initState: state }, config)) }); this.defaultState = { ...privates.get(this).keyring.store.getState(), @@ -135,7 +139,6 @@ export class KeyringController extends BaseController { - const preferences = this.context.PreferencesController as PreferencesController; const primaryKeyring = privates.get(this).keyring.getKeyringsByType('HD Key Tree')[0]; /* istanbul ignore if */ if (!primaryKeyring) { @@ -147,10 +150,10 @@ export class KeyringController extends BaseController { if (!oldAccounts.includes(selectedAddress)) { - preferences.update({ selectedAddress }); + setSelectedAddress(selectedAddress); } }); return this.fullUpdate(); @@ -181,13 +184,13 @@ export class KeyringController extends BaseController(GET_PREFERENCES_STATE); + this.messagingSystem.call(SET_SELECTED_ADDRESS, Object.keys(preferencesState.identities)[0]); this.fullUpdate(); return vault; } finally { @@ -269,7 +272,6 @@ export class KeyringController extends BaseController { let privateKey; - const preferences = this.context.PreferencesController as PreferencesController; switch (strategy) { case 'privateKey': const [importedKey] = args; @@ -296,8 +298,8 @@ export class KeyringController extends BaseController { - const preferences = this.context.PreferencesController as PreferencesController; - preferences.removeIdentity(address); + this.messagingSystem.call(REMOVE_IDENTITY, address); await privates.get(this).keyring.removeAccount(address); return this.fullUpdate(); } @@ -390,10 +391,9 @@ export class KeyringController extends BaseController { - const preferences = this.context.PreferencesController as PreferencesController; await privates.get(this).keyring.submitPassword(password); const accounts = await privates.get(this).keyring.getAccounts(); - await preferences.syncIdentities(accounts); + await this.messagingSystem.call(SYNC_IDENTITIES, accounts); return this.fullUpdate(); } diff --git a/src/messaging-system.ts b/src/messaging-system.ts new file mode 100644 index 00000000000..41e7897564c --- /dev/null +++ b/src/messaging-system.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import { CurrencyRateActions, CurrencyRateEvents } from './assets/CurrencyRateController'; +import { PreferencesActions, PreferencesEvents } from './user/PreferencesController'; + +type ReturnTypeOfMethod = T extends (...args: any[]) => any ? ReturnType : any; +type ReturnTypeOfMethodIfExists = S extends keyof T ? ReturnTypeOfMethod : any; +type MethodParams = T extends (...args: infer P) => any ? P[0] : T; +type MethodParamsIfExists = S extends keyof T ? MethodParams : S; + +interface ControllerRegistryEntry { + actions: A; + events: E; + subscriptionsIds?: string[]; +} + +interface ControllerRegister { + PreferencesController: ControllerRegistryEntry; + CurrencyRateController: ControllerRegistryEntry; +} + +type UnionToIntersection = + (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; + +type RegisteredControllers = keyof ControllerRegister; +type RegisteredActions = UnionToIntersection; +type RegisteredEvents = UnionToIntersection; +type RegisteredActionNames = keyof RegisteredActions; +type RegisteredEventNames = keyof RegisteredEvents; + +interface SubscribeRequest { + event: E; + controller: C; + callback: (args: RegisteredEvents[E]) => void; +} + +interface CallRequest { + method: M; + controller: C; + params?: MethodParamsIfExists; +} + +interface PublishPayload { + event: Error; + controller: C; + payload: MethodParamsIfExists; +} + +export default class MessagingSystem { + private controllers: { + [P in RegisteredControllers]?: { + subscriptionIds: string[]; + } + } = {}; + + private actions: Partial = {}; + + private subscriptionHandlers = new Map(); + + private subscriptionsByEvent = new Map(); + + private ee: EventEmitter = new EventEmitter(); + + registerController(controllerName: N, config: Omit): void { + this.controllers[controllerName] = { subscriptionIds: [] }; + this.actions = { + ...this.actions, + ...config.actions, + }; + } + + // registerEvents(controllerName: string, events) { + // if (this.registry[controllerName]) { + // throw new Error(`Action handlers have already been declared for ${controllerName}.`); + // } + // this.registry[controllerName].actions = actionHandlers; + // } + + call(req: CallRequest): ReturnTypeOfMethodIfExists { + const controller = this.controllers[req.controller]; + if (!controller) { + throw new Error(`Controller ${controller} has not been registered in the messaging system`); + } + const action = this.actions[req.method]; + if (!action) { + throw new Error(`A handler has not been registered for ${req.method}`); + } + + // Type safety is ensured at call site. It will be impossible to call this method + // with an invalid CallRequest. + return action(req.params as any) as any; + } + + subscribe(controllerName: C, req: SubscribeRequest): string { + const controller = this.controllers[controllerName]; + if (!controller) { + throw new Error('you may not subscribe to events if you do not first register the controller'); + } + const uuid = uuidv4(); + this.subscriptionHandlers.set(uuid, req.callback); + const previousIds = this.subscriptionsByEvent.get(req.event); + controller.subscriptionIds = [ + ...controller.subscriptionIds, + uuid, + ]; + this.subscriptionsByEvent.set(req.event, [...previousIds ?? [], uuid]); + return uuid; + } + + publish(publish): void { + + } + + teardownController(controller: C): void { + } +} + +const t = new MessagingSystem(); + +t.call({ controller: 'PreferencesController', method: 'PreferencesController.setSelectedAddress', params: 'address' }); +const y = t.call({ controller: 'CurrencyRateController', method: 'PreferencesController.getState' }); + +t.subscribe('CurrencyRateController', { + controller: 'CurrencyRateController', + event: 'PreferencesController.state-changed', + callback: (state) => { + console.log('identities', state.identities); + }, +}); diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 00000000000..2d397a44c4d --- /dev/null +++ b/src/test.ts @@ -0,0 +1,26 @@ +type ReturnTypeOfMethod = T extends (...args: any[]) => any ? ReturnType : any; +type ReturnTypeOfMethodIfExists = S extends keyof T ? ReturnTypeOfMethod : any; +type MethodParams = T extends (...args: infer P) => any ? P[0] : T; +type MethodParamsIfExists = S extends keyof T ? MethodParams : S; + +interface PreferencesActions { + what: (num: number) => number; + get: () => void; +} + +interface RequestType { + name: N; + params?: P; +} + +const options: PreferencesActions = { + what: (num: number) => num + num, + get: () => { }, +}; + +export function call(request: RequestType>): ReturnTypeOfMethodIfExists { + // we get callsite inference so these casts are okay, in this case. + return options[request.name](request.params as any) as any; +} + +const x = call({ name: 'what', params: 4 }); diff --git a/src/user/PreferencesController.ts b/src/user/PreferencesController.ts index 9d489c92c2f..dce45f8d628 100644 --- a/src/user/PreferencesController.ts +++ b/src/user/PreferencesController.ts @@ -1,5 +1,6 @@ import { toChecksumAddress } from 'ethereumjs-util'; -import BaseController, { BaseConfig, BaseState } from '../BaseController'; +import BaseController from '../base-controller-v2'; +import { ControllerMessagingSystem } from '../controller-messaging-system'; import { ContactEntry } from './AddressBookController'; /** @@ -39,7 +40,7 @@ export interface RpcPreferences { * @property lostIdentities - Map of lost addresses to ContactEntry objects * @property selectedAddress - Current coinbase account */ -export interface PreferencesState extends BaseState { +export interface PreferencesState { featureFlags: { [feature: string]: boolean }; frequentRpcList: FrequentRpc[]; ipfsGateway: string; @@ -48,14 +49,55 @@ export interface PreferencesState extends BaseState { selectedAddress: string; } +const schema = { + featureFlags: { persist: true, anonymous: true }, + frequentRpcList: { persist: true, anonymous: true }, + ipfsGateway: { persist: true, anonymous: true }, + identities: { persist: true, anonymous: true }, + lostIdentities: { persist: true, anonymous: true }, + selectedAddress: { persist: true, anonymous: true }, +}; + +const CONTROLLER_NAME = 'PreferencesController'; + +/** + * Controller action constants + */ +export const GET_PREFERENCES_STATE = `PreferencesController.getState`; +export const SET_SELECTED_ADDRESS = `PreferencesController.setSelectedAddress`; +export const UPDATE_IDENTITIES = `PreferencesController.updateIdentities`; +export const REMOVE_IDENTITY = `PreferencesController.removeIdentity`; +export const SYNC_IDENTITIES = `PreferencesController.syncIdentities`; + +/** + * Controller event constants + */ +export const PREFERENCES_STATE_CHANGED = `PreferencesController.state-changed`; + +export interface PreferencesActions { + [GET_PREFERENCES_STATE]: () => PreferencesState; + [SET_SELECTED_ADDRESS]: (selectedAddress: string) => void; + [UPDATE_IDENTITIES]: (addresses: string[]) => void; + [REMOVE_IDENTITY]: (address: string) => void; + [SYNC_IDENTITIES]: (addresses: string[]) => void; +} + +export interface PreferencesEvents { + [PREFERENCES_STATE_CHANGED]: PreferencesState; +} + /** * Controller that stores shared settings and exposes convenience methods */ -export class PreferencesController extends BaseController { - /** - * Name of this controller used during composition - */ - name = 'PreferencesController'; +export class PreferencesController extends BaseController { + private static defaultState = { + featureFlags: {}, + frequentRpcList: [], + identities: {}, + ipfsGateway: 'https://ipfs.io/ipfs/', + lostIdentities: {}, + selectedAddress: '', + }; /** * Creates a PreferencesController instance @@ -63,17 +105,15 @@ export class PreferencesController extends BaseController, state?: Partial) { - super(config, state); - this.defaultState = { - featureFlags: {}, - frequentRpcList: [], - identities: {}, - ipfsGateway: 'https://ipfs.io/ipfs/', - lostIdentities: {}, - selectedAddress: '', - }; - this.initialize(); + constructor(messagingSystem: ControllerMessagingSystem, state?: Partial) { + super(messagingSystem, { ...PreferencesController.defaultState, ...state }, PREFERENCES_STATE_CHANGED, schema); + this.registerActions({ + [GET_PREFERENCES_STATE]: () => this.state, + [SET_SELECTED_ADDRESS]: this.setSelectedAddress, + [UPDATE_IDENTITIES]: this.updateIdentities, + [REMOVE_IDENTITY]: this.removeIdentity, + [SYNC_IDENTITIES]: this.syncIdentities, + }); } /** @@ -82,17 +122,16 @@ export class PreferencesController extends BaseController { - address = toChecksumAddress(address); - if (identities[address]) { - return; - } - const identityCount = Object.keys(identities).length; - - identities[address] = { name: `Account ${identityCount + 1}`, address }; + this.update((state) => { + addresses.forEach((address) => { + address = toChecksumAddress(address); + if (state.identities[address]) { + return; + } + const identityCount = Object.keys(state.identities).length; + state.identities[address] = { name: `Account ${identityCount + 1}`, address }; + }); }); - this.update({ identities: { ...identities } }); } /** @@ -106,11 +145,12 @@ export class PreferencesController extends BaseController { + delete state.identities[address]; + if (address === state.selectedAddress) { + state.selectedAddress = Object.keys(state.identities)[0]; + } + }); } /** @@ -121,10 +161,10 @@ export class PreferencesController extends BaseController { + state.identities[address] = state.identities[address] || {}; + state.identities[address].name = label; + }); } /** @@ -136,7 +176,9 @@ export class PreferencesController extends BaseController { + state.featureFlags = featureFlags; + }); } /** @@ -147,28 +189,28 @@ export class PreferencesController extends BaseController toChecksumAddress(address)); - const { identities, lostIdentities } = this.state; - const newlyLost: { [address: string]: ContactEntry } = {}; + this.update((state) => { + const newlyLost: { [address: string]: ContactEntry } = {}; - for (const identity in identities) { - if (addresses.indexOf(identity) === -1) { - newlyLost[identity] = identities[identity]; - delete identities[identity]; + for (const identity in state.identities) { + if (addresses.indexOf(identity) === -1) { + newlyLost[identity] = state.identities[identity]; + delete state.identities[identity]; + } } - } - if (Object.keys(newlyLost).length > 0) { - for (const key in newlyLost) { - lostIdentities[key] = newlyLost[key]; + if (Object.keys(newlyLost).length > 0) { + for (const key in newlyLost) { + state.lostIdentities[key] = newlyLost[key]; + } } - } - this.update({ identities: { ...identities }, lostIdentities: { ...lostIdentities } }); - this.addIdentities(addresses); + this.addIdentities(addresses); - if (addresses.indexOf(this.state.selectedAddress) === -1) { - this.update({ selectedAddress: addresses[0] }); - } + if (addresses.indexOf(this.state.selectedAddress) === -1) { + state.selectedAddress = addresses[0]; + } + }); return this.state.selectedAddress; } @@ -188,7 +230,9 @@ export class PreferencesController extends BaseController { + state.identities = identities; + }); } /** @@ -202,16 +246,16 @@ export class PreferencesController extends BaseController { - return rpcUrl === url; + this.update((state) => { + const index = state.frequentRpcList.findIndex(({ rpcUrl }) => { + return rpcUrl === url; + }); + if (index !== -1) { + state.frequentRpcList.splice(index, 1); + } + const newFrequestRpc: FrequentRpc = { rpcUrl: url, chainId, ticker, nickname, rpcPrefs }; + state.frequentRpcList.push(newFrequestRpc); }); - if (index !== -1) { - frequentRpcList.splice(index, 1); - } - const newFrequestRpc: FrequentRpc = { rpcUrl: url, chainId, ticker, nickname, rpcPrefs }; - frequentRpcList.push(newFrequestRpc); - this.update({ frequentRpcList: [...frequentRpcList] }); } /** @@ -220,14 +264,14 @@ export class PreferencesController extends BaseController { - return rpcUrl === url; + this.update((state) => { + const index = state.frequentRpcList.findIndex(({ rpcUrl }) => { + return rpcUrl === url; + }); + if (index !== -1) { + state.frequentRpcList.splice(index, 1); + } }); - if (index !== -1) { - frequentRpcList.splice(index, 1); - } - this.update({ frequentRpcList: [...frequentRpcList] }); } /** @@ -236,7 +280,9 @@ export class PreferencesController extends BaseController { + state.selectedAddress = toChecksumAddress(selectedAddress); + }); } /** @@ -245,7 +291,9 @@ export class PreferencesController extends BaseController { + state.ipfsGateway = ipfsGateway; + }); } } diff --git a/tests/AccountTrackerController.test.ts b/tests/AccountTrackerController.test.ts index c2959f9a159..38980bc4a1a 100644 --- a/tests/AccountTrackerController.test.ts +++ b/tests/AccountTrackerController.test.ts @@ -1,7 +1,7 @@ import { stub, spy } from 'sinon'; import AccountTrackerController from '../src/assets/AccountTrackerController'; -import PreferencesController from '../src/user/PreferencesController'; -import ComposableController from '../src/ComposableController'; +import { GET_PREFERENCES_STATE, PREFERENCES_STATE_CHANGED } from '../src/user/PreferencesController'; +import { publish, resetSubscriptions, registerActionHandler, unregisterActionHandler } from '../src/controller-messaging-system'; const HttpProvider = require('ethjs-provider-http'); @@ -17,13 +17,19 @@ describe('AccountTrackerController', () => { it('should get real balance', async () => { const address = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; + registerActionHandler(GET_PREFERENCES_STATE, () => ({ + identities: { [address]: {} }, + })); const controller = new AccountTrackerController({ provider }); - controller.context = { PreferencesController: { state: { identities: { [address]: {} } } } } as any; await controller.refresh(); expect(controller.state.accounts[address].balance).toBeDefined(); + unregisterActionHandler(GET_PREFERENCES_STATE); }); it('should sync addresses', () => { + registerActionHandler(GET_PREFERENCES_STATE, () => ({ + identities: { baz: {} }, + })); const controller = new AccountTrackerController( { provider }, { @@ -33,29 +39,28 @@ describe('AccountTrackerController', () => { }, }, ); - controller.context = { PreferencesController: { state: { identities: { baz: {} } } } } as any; controller.refresh(); expect(controller.state.accounts).toEqual({ baz: { balance: '0x0' } }); + unregisterActionHandler(GET_PREFERENCES_STATE); }); it('should subscribe to new sibling preference controllers', async () => { - const preferences = new PreferencesController(); const controller = new AccountTrackerController({ provider }); controller.refresh = stub(); + controller.onComposed(); - new ComposableController([controller, preferences]); - preferences.setFeatureFlag('foo', true); + publish(PREFERENCES_STATE_CHANGED, {}); expect((controller.refresh as any).called).toBe(true); + resetSubscriptions(); }); it('should call refresh every ten seconds', () => { return new Promise((resolve) => { - const preferences = new PreferencesController(); const controller = new AccountTrackerController({ provider, interval: 100 }); stub(controller, 'refresh'); const poll = spy(controller, 'poll'); - new ComposableController([controller, preferences]); + controller.onComposed(); expect(poll.called).toBe(true); expect(poll.calledTwice).toBe(false); setTimeout(() => { diff --git a/tests/AssetsController.test.ts b/tests/AssetsController.test.ts index cf2be596757..6d0d1626f48 100644 --- a/tests/AssetsController.test.ts +++ b/tests/AssetsController.test.ts @@ -2,9 +2,10 @@ import { createSandbox } from 'sinon'; import { getOnce } from 'fetch-mock'; import AssetsController from '../src/assets/AssetsController'; import ComposableController from '../src/ComposableController'; -import PreferencesController from '../src/user/PreferencesController'; import { NetworkController } from '../src/network/NetworkController'; import { AssetsContractController } from '../src/assets/AssetsContractController'; +import { publish } from '../src/controller-messaging-system'; +import { PREFERENCES_STATE_CHANGED } from '../src/user/PreferencesController'; const HttpProvider = require('ethjs-provider-http'); @@ -14,18 +15,16 @@ const OPEN_SEA_API = 'https://api.opensea.io/api/v1/'; describe('AssetsController', () => { let assetsController: AssetsController; - let preferences: PreferencesController; let network: NetworkController; let assetsContract: AssetsContractController; const sandbox = createSandbox(); beforeEach(() => { assetsController = new AssetsController(); - preferences = new PreferencesController(); network = new NetworkController(); assetsContract = new AssetsContractController(); - new ComposableController([assetsController, assetsContract, network, preferences]); + new ComposableController([assetsController, assetsContract, network]); getOnce( `${OPEN_SEA_API}asset_contract/0xfoO`, @@ -116,6 +115,7 @@ describe('AssetsController', () => { afterEach(() => { sandbox.reset(); + assetsController.onDestroy(); }); it('should set default state', () => { @@ -151,11 +151,11 @@ describe('AssetsController', () => { const firstAddress = '0x123'; const secondAddress = '0x321'; - preferences.update({ selectedAddress: firstAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); await assetsController.addToken('foo', 'bar', 2); - preferences.update({ selectedAddress: secondAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: secondAddress }); expect(assetsController.state.tokens).toHaveLength(0); - preferences.update({ selectedAddress: firstAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); expect(assetsController.state.tokens[0]).toEqual({ address: '0xfoO', decimals: 2, @@ -187,13 +187,13 @@ describe('AssetsController', () => { it('should remove token by selected address', async () => { const firstAddress = '0x123'; const secondAddress = '0x321'; - preferences.update({ selectedAddress: firstAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); await assetsController.addToken('fou', 'baz', 2); - preferences.update({ selectedAddress: secondAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: secondAddress }); await assetsController.addToken('foo', 'bar', 2); assetsController.removeToken('0xfoO'); expect(assetsController.state.tokens).toHaveLength(0); - preferences.update({ selectedAddress: firstAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); expect(assetsController.state.tokens[0]).toEqual({ address: '0xFOu', decimals: 2, @@ -285,16 +285,16 @@ describe('AssetsController', () => { }); it('should add collectible by selected address', async () => { - const firstAddress = '0x123'; - const secondAddress = '0x321'; + // const firstAddress = '0x123'; + // const secondAddress = '0x321'; sandbox .stub(assetsController, 'getCollectibleInformation' as any) .returns({ name: 'name', image: 'url', description: 'description' }); - preferences.update({ selectedAddress: firstAddress }); + // preferences.update({ selectedAddress: firstAddress }); await assetsController.addCollectible('foo', 1234); - preferences.update({ selectedAddress: secondAddress }); + // preferences.update({ selectedAddress: secondAddress }); await assetsController.addCollectible('fou', 4321); - preferences.update({ selectedAddress: firstAddress }); + // preferences.update({ selectedAddress: firstAddress }); expect(assetsController.state.collectibles[0]).toEqual({ address: '0xfoO', description: 'description', @@ -371,13 +371,13 @@ describe('AssetsController', () => { .returns({ name: 'name', image: 'url', description: 'description' }); const firstAddress = '0x123'; const secondAddress = '0x321'; - preferences.update({ selectedAddress: firstAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); await assetsController.addCollectible('fou', 4321); - preferences.update({ selectedAddress: secondAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: secondAddress }); await assetsController.addCollectible('foo', 1234); assetsController.removeCollectible('0xfoO', 1234); expect(assetsController.state.collectibles).toHaveLength(0); - preferences.update({ selectedAddress: firstAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); expect(assetsController.state.collectibles[0]).toEqual({ address: '0xFOu', description: 'description', @@ -412,9 +412,6 @@ describe('AssetsController', () => { it('should subscribe to new sibling preference controllers', async () => { const networkType = 'rinkeby'; - const address = '0x123'; - preferences.update({ selectedAddress: address }); - expect(assetsController.context.PreferencesController.state.selectedAddress).toEqual(address); network.update({ provider: { type: networkType } }); expect(assetsController.context.NetworkController.state.provider.type).toEqual(networkType); }); @@ -433,7 +430,7 @@ describe('AssetsController', () => { }); it('should fail an invalid type suggested asset via watchAsset', async () => { - return new Promise(async (resolve) => { + return new Promise(async (resolve) => { await assetsController .watchAsset( { @@ -451,7 +448,7 @@ describe('AssetsController', () => { }); it('should reject a valid suggested asset via watchAsset', () => { - return new Promise(async (resolve) => { + return new Promise(async (resolve) => { const { result, suggestedAssetMeta } = await assetsController.watchAsset( { address: '0xe9f786dfdd9ae4d57e830acb52296837765f0e5b', @@ -473,7 +470,7 @@ describe('AssetsController', () => { }); it('should accept a valid suggested asset via watchAsset', () => { - return new Promise(async (resolve) => { + return new Promise(async (resolve) => { const { result, suggestedAssetMeta } = await assetsController.watchAsset( { address: '0xe9f786dfdd9ae4d57e830acb52296837765f0e5b', @@ -492,7 +489,7 @@ describe('AssetsController', () => { }); it('should fail a valid suggested asset via watchAsset with wrong type', () => { - return new Promise(async (resolve) => { + return new Promise(async (resolve) => { const { result, suggestedAssetMeta } = await assetsController.watchAsset( { address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', diff --git a/tests/AssetsDetectionController.test.ts b/tests/AssetsDetectionController.test.ts index 41453df91a3..f349359bb41 100644 --- a/tests/AssetsDetectionController.test.ts +++ b/tests/AssetsDetectionController.test.ts @@ -2,10 +2,11 @@ import { createSandbox, stub } from 'sinon'; import { getOnce, get } from 'fetch-mock'; import { AssetsDetectionController } from '../src/assets/AssetsDetectionController'; import { NetworkController } from '../src/network/NetworkController'; -import { PreferencesController } from '../src/user/PreferencesController'; import { ComposableController } from '../src/ComposableController'; import { AssetsController } from '../src/assets/AssetsController'; import { AssetsContractController } from '../src/assets/AssetsContractController'; +import { publish } from '../src/controller-messaging-system'; +import { PREFERENCES_STATE_CHANGED } from '../src/user/PreferencesController'; const { BN } = require('ethereumjs-util'); @@ -17,7 +18,6 @@ const OPEN_SEA_API = 'https://api.opensea.io/api/v1/'; describe('AssetsDetectionController', () => { let assetsDetection: AssetsDetectionController; - let preferences: PreferencesController; let network: NetworkController; let assets: AssetsController; let assetsContract: AssetsContractController; @@ -25,12 +25,11 @@ describe('AssetsDetectionController', () => { beforeEach(() => { assetsDetection = new AssetsDetectionController(); - preferences = new PreferencesController(); network = new NetworkController(); assets = new AssetsController(); assetsContract = new AssetsContractController(); - new ComposableController([assets, assetsContract, assetsDetection, network, preferences]); + new ComposableController([assets, assetsContract, assetsDetection, network]); getOnce( `${OPEN_SEA_API}asset_contract/0x1d963688FE2209A98dB35C67A041524822Cf04ff`, @@ -166,6 +165,7 @@ describe('AssetsDetectionController', () => { afterEach(() => { sandbox.reset(); + assetsDetection.onDestroyed(); }); it('should set default config', () => { @@ -178,7 +178,7 @@ describe('AssetsDetectionController', () => { }); it('should poll and detect assets on interval while on mainnet', () => { - return new Promise((resolve) => { + return new Promise((resolve) => { const mockTokens = stub(AssetsDetectionController.prototype, 'detectTokens'); const mockCollectibles = stub(AssetsDetectionController.prototype, 'detectCollectibles'); new AssetsDetectionController({ interval: 10 }); @@ -202,7 +202,7 @@ describe('AssetsDetectionController', () => { }); it('should not autodetect while not on mainnet', () => { - return new Promise((resolve) => { + return new Promise((resolve) => { const mockTokens = stub(AssetsDetectionController.prototype, 'detectTokens'); const mockCollectibles = stub(AssetsDetectionController.prototype, 'detectCollectibles'); new AssetsDetectionController({ interval: 10, networkType: ROPSTEN }); @@ -438,12 +438,10 @@ describe('AssetsDetectionController', () => { const firstAddress = '0x123'; const secondAddress = '0x321'; const detectAssets = sandbox.stub(assetsDetection, 'detectAssets'); - preferences.update({ selectedAddress: secondAddress }); - preferences.update({ selectedAddress: secondAddress }); - expect(assetsDetection.context.PreferencesController.state.selectedAddress).toEqual(secondAddress); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: secondAddress }); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: secondAddress }); expect(detectAssets.calledTwice).toBe(false); - preferences.update({ selectedAddress: firstAddress }); - expect(assetsDetection.context.PreferencesController.state.selectedAddress).toEqual(firstAddress); + publish(PREFERENCES_STATE_CHANGED, { selectedAddress: firstAddress }); network.update({ provider: { type: secondNetworkType } }); expect(assetsDetection.context.NetworkController.state.provider.type).toEqual(secondNetworkType); network.update({ provider: { type: firstNetworkType } }); diff --git a/tests/ComposableController.test.ts b/tests/ComposableController.test.ts index cbed12ee5a2..6e75afab39a 100644 --- a/tests/ComposableController.test.ts +++ b/tests/ComposableController.test.ts @@ -2,12 +2,10 @@ import { stub } from 'sinon'; import AddressBookController from '../src/user/AddressBookController'; import EnsController from '../src/third-party/EnsController'; import ComposableController from '../src/ComposableController'; -import PreferencesController from '../src/user/PreferencesController'; import TokenRatesController from '../src/assets/TokenRatesController'; import { AssetsController } from '../src/assets/AssetsController'; import { NetworkController } from '../src/network/NetworkController'; import { AssetsContractController } from '../src/assets/AssetsContractController'; -import CurrencyRateController from '../src/assets/CurrencyRateController'; describe('ComposableController', () => { it('should compose controller state', () => { @@ -16,9 +14,7 @@ describe('ComposableController', () => { new AssetsController(), new AssetsContractController(), new EnsController(), - new CurrencyRateController(), new NetworkController(), - new PreferencesController(), new TokenRatesController(), ]); expect(controller.state).toEqual({ @@ -35,13 +31,6 @@ describe('ComposableController', () => { suggestedAssets: [], tokens: [], }, - CurrencyRateController: { - conversionDate: 0, - conversionRate: 0, - currentCurrency: 'usd', - nativeCurrency: 'ETH', - usdConversionRate: 0, - }, EnsController: { ensEntries: {}, }, @@ -49,14 +38,6 @@ describe('ComposableController', () => { network: 'loading', provider: { type: 'mainnet' }, }, - PreferencesController: { - featureFlags: {}, - frequentRpcList: [], - identities: {}, - ipfsGateway: 'https://ipfs.io/ipfs/', - lostIdentities: {}, - selectedAddress: '', - }, TokenRatesController: { contractExchangeRates: {} }, }); }); @@ -67,9 +48,7 @@ describe('ComposableController', () => { new AssetsController(), new AssetsContractController(), new EnsController(), - new CurrencyRateController(), new NetworkController(), - new PreferencesController(), new TokenRatesController(), ]); expect(controller.flatState).toEqual({ @@ -80,24 +59,13 @@ describe('ComposableController', () => { collectibleContracts: [], collectibles: [], contractExchangeRates: {}, - conversionDate: 0, - conversionRate: 0, - currentCurrency: 'usd', ensEntries: {}, - featureFlags: {}, - frequentRpcList: [], - identities: {}, ignoredCollectibles: [], ignoredTokens: [], - ipfsGateway: 'https://ipfs.io/ipfs/', - lostIdentities: {}, - nativeCurrency: 'ETH', network: 'loading', provider: { type: 'mainnet' }, - selectedAddress: '', suggestedAssets: [], tokens: [], - usdConversionRate: 0, }); }); @@ -106,10 +74,8 @@ describe('ComposableController', () => { new AddressBookController(), new AssetsController(), new AssetsContractController(), - new CurrencyRateController(), new EnsController(), new NetworkController(), - new PreferencesController(), new TokenRatesController(), ]); const addressContext = controller.context.TokenRatesController.context @@ -134,24 +100,13 @@ describe('ComposableController', () => { collectibleContracts: [], collectibles: [], contractExchangeRates: {}, - conversionDate: 0, - conversionRate: 0, - currentCurrency: 'usd', ensEntries: {}, - featureFlags: {}, - frequentRpcList: [], - identities: {}, ignoredCollectibles: [], ignoredTokens: [], - ipfsGateway: 'https://ipfs.io/ipfs/', - lostIdentities: {}, - nativeCurrency: 'ETH', network: 'loading', provider: { type: 'mainnet' }, - selectedAddress: '', suggestedAssets: [], tokens: [], - usdConversionRate: 0, }); }); diff --git a/tests/KeyringController.test.ts b/tests/KeyringController.test.ts index b4ee598afd5..b9a5a24827f 100644 --- a/tests/KeyringController.test.ts +++ b/tests/KeyringController.test.ts @@ -1,4 +1,4 @@ -import * as ethUtil from 'ethereumjs-util'; +import { toChecksumAddress, bufferToHex } from 'ethereumjs-util'; import { recoverPersonalSignature, recoverTypedSignature, @@ -7,8 +7,9 @@ import { } from 'eth-sig-util'; import { stub } from 'sinon'; import KeyringController, { Keyring, KeyringConfig } from '../src/keyring/KeyringController'; -import PreferencesController from '../src/user/PreferencesController'; +import { GET_PREFERENCES_STATE, UPDATE_IDENTITIES, SET_SELECTED_ADDRESS, REMOVE_IDENTITY, SYNC_IDENTITIES } from '../src/user/PreferencesController'; import ComposableController from '../src/ComposableController'; +import { registerActionHandlers, resetActionHandlers } from '../src/controller-messaging-system'; const Transaction = require('ethereumjs-tx'); const mockEncryptor: any = require('./utils/mockEncryptor'); @@ -25,17 +26,55 @@ const password = 'password123'; describe('KeyringController', () => { let keyringController: KeyringController; - let preferences: PreferencesController; let initialState: { isUnlocked: boolean; keyringTypes: string[]; keyrings: Keyring[] }; + let identities: Record; + let selectedAddress: string | null; const baseConfig: Partial = { encryptor: mockEncryptor }; beforeEach(async () => { + identities = {}; + selectedAddress = null; keyringController = new KeyringController(baseConfig); - preferences = new PreferencesController(); + registerActionHandlers({ + [UPDATE_IDENTITIES]: (addresses: string[]) => { + addresses.forEach((address, i) => { + const checkSummedAddress = toChecksumAddress(address); + identities[checkSummedAddress] = identities[checkSummedAddress] || { + address: checkSummedAddress, + name: `Account ${i + 1}`, + }; + }); + }, + [REMOVE_IDENTITY]: (address: string) => { + const checkSummedAddress = toChecksumAddress(address); + if (!identities[checkSummedAddress]) { + return; + } + delete identities[checkSummedAddress]; + if (checkSummedAddress === selectedAddress) { + selectedAddress = Object.keys(identities)[0]; + } + }, + [SET_SELECTED_ADDRESS]: (address: string) => { + selectedAddress = toChecksumAddress(address); + }, + [SYNC_IDENTITIES]: () => { + // NOOP, called by keyring controller but test suite doesn't test state + // modifications + }, + [GET_PREFERENCES_STATE]: () => ({ + identities, + selectedAddress, + }), + }); - new ComposableController([keyringController, preferences]); + new ComposableController([keyringController]); initialState = await keyringController.createNewVaultAndKeychain(password); }); + afterEach(() => { + resetActionHandlers(); + }); + it('should set default state', () => { expect(keyringController.state.keyrings).not.toEqual([]); const keyring = keyringController.state.keyrings[0]; @@ -45,22 +84,22 @@ describe('KeyringController', () => { }); it('should add new account', async () => { - const initialIdentitiesLength = Object.keys(preferences.state.identities).length; + const initialIdentitiesLength = Object.keys(identities).length; const currentKeyringMemState = await keyringController.addNewAccount(); expect(initialState.keyrings).toHaveLength(1); expect(initialState.keyrings[0].accounts).not.toBe(currentKeyringMemState.keyrings); expect(currentKeyringMemState.keyrings[0].accounts).toHaveLength(2); - const identitiesLength = Object.keys(preferences.state.identities).length; + const identitiesLength = Object.keys(identities).length; expect(identitiesLength).toBeGreaterThan(initialIdentitiesLength); }); it('should add new account without updating', async () => { - const initialIdentitiesLength = Object.keys(preferences.state.identities).length; + const initialIdentitiesLength = Object.keys(identities).length; const currentKeyringMemState = await keyringController.addNewAccountWithoutUpdate(); expect(initialState.keyrings).toHaveLength(1); expect(initialState.keyrings[0].accounts).not.toBe(currentKeyringMemState.keyrings); expect(currentKeyringMemState.keyrings[0].accounts).toHaveLength(2); - const identitiesLength = Object.keys(preferences.state.identities).length; + const identitiesLength = Object.keys(identities).length; expect(identitiesLength).toEqual(initialIdentitiesLength); }); @@ -154,7 +193,7 @@ describe('KeyringController', () => { }); it('should sign personal message', async () => { - const data = ethUtil.bufferToHex(Buffer.from('Hello from test', 'utf8')); + const data = bufferToHex(Buffer.from('Hello from test', 'utf8')); const account = initialState.keyrings[0].accounts[0]; const signature = await keyringController.signPersonalMessage({ data, from: account }); const recovered = recoverPersonalSignature({ data, sig: signature }); diff --git a/tests/PreferencesController.test.ts b/tests/PreferencesController.test.ts index 6144bc76245..72d59b789cf 100644 --- a/tests/PreferencesController.test.ts +++ b/tests/PreferencesController.test.ts @@ -1,8 +1,14 @@ import PreferencesController from '../src/user/PreferencesController'; describe('PreferencesController', () => { + let controller: PreferencesController; + beforeEach(() => { + controller = new PreferencesController(); + }); + afterEach(() => { + controller.destroy(); + }); it('should set default state', () => { - const controller = new PreferencesController(); expect(controller.state).toEqual({ featureFlags: {}, frequentRpcList: [], @@ -14,7 +20,6 @@ describe('PreferencesController', () => { }); it('should add identities', () => { - const controller = new PreferencesController(); controller.addIdentities(['foo']); controller.addIdentities(['foo']); expect(controller.state.identities).toEqual({ @@ -26,9 +31,8 @@ describe('PreferencesController', () => { }); it('should remove identity', () => { - const controller = new PreferencesController(); controller.addIdentities(['foo', 'bar', 'baz']); - controller.update({ selectedAddress: '0xfoO' }); + controller.setSelectedAddress('0xfoO'); controller.removeIdentity('foo'); controller.removeIdentity('baz'); controller.removeIdentity('foo'); @@ -37,7 +41,6 @@ describe('PreferencesController', () => { }); it('should set identity label', () => { - const controller = new PreferencesController(); controller.addIdentities(['foo']); controller.setAccountLabel('foo', 'bar'); controller.setAccountLabel('baz', 'qux'); @@ -46,7 +49,6 @@ describe('PreferencesController', () => { }); it('should sync identities', () => { - const controller = new PreferencesController(); controller.addIdentities(['foo', 'bar']); controller.syncIdentities(['foo', 'bar']); expect(controller.state.identities).toEqual({ @@ -61,7 +63,6 @@ describe('PreferencesController', () => { }); it('should update existing identities', () => { - const controller = new PreferencesController(); controller.updateIdentities(['foo', 'bar']); expect(controller.state.identities).toEqual({ '0xbar': { address: '0xbar', name: 'Account 2' }, @@ -70,7 +71,6 @@ describe('PreferencesController', () => { }); it('should add custom rpc url', () => { - const controller = new PreferencesController(); const rpcUrlNetwork = { chainId: undefined, nickname: 'RPC', @@ -96,7 +96,6 @@ describe('PreferencesController', () => { }); it('should remove custom rpc url', () => { - const controller = new PreferencesController(); const rpcUrlNetwork = { chainId: undefined, nickname: undefined, @@ -112,13 +111,11 @@ describe('PreferencesController', () => { }); it('should set IPFS gateway', () => { - const controller = new PreferencesController(); controller.setIpfsGateway('https://ipfs.infura.io/ipfs/'); expect(controller.state.ipfsGateway).toEqual('https://ipfs.infura.io/ipfs/'); }); it('should update selected address as checksummed', () => { - const controller = new PreferencesController(); controller.setSelectedAddress('0x95d2bc047b0ddec1e4a178eeb64d59f5e735cd0a'); expect(controller.state.selectedAddress).toEqual('0x95D2bC047B0dDEc1E4A178EeB64d59F5E735cd0A'); }); diff --git a/tests/TokenBalancesController.test.ts b/tests/TokenBalancesController.test.ts index 9ffd7764f05..33bc7b64e67 100644 --- a/tests/TokenBalancesController.test.ts +++ b/tests/TokenBalancesController.test.ts @@ -5,7 +5,6 @@ import { AssetsController } from '../src/assets/AssetsController'; import { Token } from '../src/assets/TokenRatesController'; import { AssetsContractController } from '../src/assets/AssetsContractController'; import { NetworkController } from '../src/network/NetworkController'; -import { PreferencesController } from '../src/user/PreferencesController'; const { BN } = require('ethereumjs-util'); const HttpProvider = require('ethjs-provider-http'); @@ -36,7 +35,7 @@ describe('TokenBalancesController', () => { }); it('should poll and update balances in the right interval', () => { - return new Promise((resolve) => { + return new Promise((resolve) => { const mock = stub(TokenBalancesController.prototype, 'updateBalances'); new TokenBalancesController({ interval: 10 }); expect(mock.called).toBe(true); @@ -62,7 +61,7 @@ describe('TokenBalancesController', () => { it('should clear previous interval', () => { const mock = stub(global, 'clearTimeout'); const controller = new TokenBalancesController({ interval: 1337 }); - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(() => { controller.poll(1338); expect(mock.called).toBe(true); @@ -79,9 +78,8 @@ describe('TokenBalancesController', () => { const assets = new AssetsController(); const assetsContract = new AssetsContractController(); const network = new NetworkController(); - const preferences = new PreferencesController(); - new ComposableController([assets, assetsContract, network, preferences, tokenBalances]); + new ComposableController([assets, assetsContract, network, tokenBalances]); assetsContract.configure({ provider: MAINNET_PROVIDER }); stub(assetsContract, 'getBalanceOf').returns(new BN(1)); await tokenBalances.updateBalances(); @@ -93,9 +91,8 @@ describe('TokenBalancesController', () => { const assets = new AssetsController(); const assetsContract = new AssetsContractController(); const network = new NetworkController(); - const preferences = new PreferencesController(); - new ComposableController([assets, assetsContract, network, preferences, tokenBalances]); + new ComposableController([assets, assetsContract, network, tokenBalances]); const updateBalances = sandbox.stub(tokenBalances, 'updateBalances'); await assets.addToken('0xfoO', 'FOO', 18); const { tokens } = tokenBalances.context.AssetsController.state; diff --git a/tests/TokenRatesController.test.ts b/tests/TokenRatesController.test.ts index b4b140de7d5..0585aa4768e 100644 --- a/tests/TokenRatesController.test.ts +++ b/tests/TokenRatesController.test.ts @@ -3,10 +3,10 @@ import { get } from 'fetch-mock'; import ComposableController from '../src/ComposableController'; import TokenRatesController, { Token } from '../src/assets/TokenRatesController'; import { AssetsController } from '../src/assets/AssetsController'; -import { PreferencesController } from '../src/user/PreferencesController'; import { NetworkController } from '../src/network/NetworkController'; import { AssetsContractController } from '../src/assets/AssetsContractController'; -import CurrencyRateController from '../src/assets/CurrencyRateController'; +import { publish } from '../src/controller-messaging-system'; +import { CURRENCY_RATE_STATE_CHANGED } from '../src/assets/CurrencyRateController'; const COINGECKO_API = 'https://api.coingecko.com/api/v3/simple/token_price/ethereum?'; @@ -56,7 +56,7 @@ describe('TokenRatesController', () => { }); it('should poll and update rate in the right interval', () => { - return new Promise((resolve) => { + return new Promise((resolve) => { const mock = stub(TokenRatesController.prototype, 'fetchExchangeRate'); new TokenRatesController({ interval: 10, @@ -85,7 +85,7 @@ describe('TokenRatesController', () => { it('should clear previous interval', () => { const mock = stub(global, 'clearTimeout'); const controller = new TokenRatesController({ interval: 1337 }); - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(() => { controller.poll(1338); expect(mock.called).toBe(true); @@ -98,12 +98,10 @@ describe('TokenRatesController', () => { it('should update all rates', async () => { const assets = new AssetsController(); const assetsContract = new AssetsContractController(); - const currencyRate = new CurrencyRateController(); const controller = new TokenRatesController({ interval: 10 }); const network = new NetworkController(); - const preferences = new PreferencesController(); - new ComposableController([controller, assets, assetsContract, currencyRate, network, preferences]); + new ComposableController([controller, assets, assetsContract, network]); const address = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; const address2 = '0xfoO'; expect(controller.state.contractExchangeRates).toEqual({}); @@ -131,14 +129,14 @@ describe('TokenRatesController', () => { it('should subscribe to new sibling assets controllers', async () => { const assets = new AssetsController(); const assetsContract = new AssetsContractController(); - const currencyRate = new CurrencyRateController(); const controller = new TokenRatesController(); const network = new NetworkController(); - const preferences = new PreferencesController(); - new ComposableController([controller, assets, assetsContract, currencyRate, network, preferences]); + new ComposableController([controller, assets, assetsContract, network]); await assets.addToken('0xfoO', 'FOO', 18); - currencyRate.update({ nativeCurrency: 'gno' }); + publish(CURRENCY_RATE_STATE_CHANGED, { + nativeCurrency: 'gno', + }); const { tokens } = controller.context.AssetsController.state; const found = tokens.filter((token: Token) => token.address === '0xfoO'); expect(found.length > 0).toBe(true); diff --git a/tsconfig.json b/tsconfig.json index 4acb76ae4a2..1d1013e9fcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "sourceMap": true, "inlineSources": true, "strict": true, - "target": "es6" + "target": "es6" }, "typedocOptions": { "exclude": "**/*.test.ts", diff --git a/yarn.lock b/yarn.lock index 9edb563c0d9..e03dcb015d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -816,6 +816,11 @@ resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.9.2.tgz#2c4f7743287218f5c2d9a83db3806672aa48530d" integrity sha512-KgOKTAD+9X+qvZnB5S1+onqKc4E+PZ+T6CM/NA5ohRPLHJXb+yCJMVf8pWOnvuBuKFNUAJW8N97IA6lba6mZGg== +"@types/uuid@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== + "@types/web3@^1.0.6": version "1.0.19" resolved "https://registry.yarnpkg.com/@types/web3/-/web3-1.0.19.tgz#46b85d91d398ded9ab7c85a5dd57cb33ac558924" @@ -6791,10 +6796,10 @@ typescript@3.5.x: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== -typescript@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.3.tgz#153bbd468ef07725c1df9c77e8b453f8d36abba5" - integrity sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg== +typescript@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== uglify-js@^3.1.4: version "3.6.5"