diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8f25405593..56420370cc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -134,6 +134,7 @@ /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations /packages/wallet/src/initialization/instances/connectivity-controller/ @MetaMask/core-platform /packages/wallet/src/initialization/instances/keyring-controller/ @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/wallet/src/initialization/instances/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform ## Package Release related diff --git a/README.md b/README.md index 9dfbf4325f..e35dbbaf41 100644 --- a/README.md +++ b/README.md @@ -595,6 +595,7 @@ linkStyle default opacity:0.5 wallet --> controller_utils; wallet --> keyring_controller; wallet --> messenger; + wallet --> remote_feature_flag_controller; wallet --> storage_service; ``` diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index ee874fbbff..4e050aa9dc 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `AccountsController` and `ConnectivityController` as default initialized controllers ([#8924](https://github.com/MetaMask/core/pull/8924)) - Passing `instanceOptions.connectivityController.connectivityAdapter` is now required. - Export `AlwaysOnlineAdapter` from the package root for environments without a platform-specific network API (e.g. Node/tests). +- **BREAKING:** Wire `RemoteFeatureFlagController` into the default wallet initialization ([#8969](https://github.com/MetaMask/core/pull/8969)) + - The default `Wallet` now constructs a `RemoteFeatureFlagController` and registers its `RemoteFeatureFlagController:*` messenger actions. Consumers that pass their own `messenger` and already wire a `RemoteFeatureFlagController` must remove their own before upgrading, or the duplicate registration will collide. + - Adds a required `remoteFeatureFlagController` slot to `instanceOptions`. `clientConfigApiService` is required (each client injects a `ClientConfigApiService` configured for its own client type, distribution, and environment); `getMetaMetricsId`, `clientVersion`, `prevClientVersion`, `fetchInterval`, and `disabled` are optional. `prevClientVersion` lets consumers trigger feature-flag cache invalidation when the client version changes between sessions. ## [3.0.0] diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 239af80c59..f9c2e0099a 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -61,6 +61,7 @@ "@metamask/controller-utils": "^12.1.1", "@metamask/keyring-controller": "^27.0.0", "@metamask/messenger": "^1.2.0", + "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/scure-bip39": "^2.1.1", "@metamask/storage-service": "^1.0.2", "@metamask/utils": "^11.9.0" diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 32e9097e25..a89602c3fc 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -13,6 +13,15 @@ import { Wallet } from './Wallet'; const TEST_SRP = 'test test test test test test test test test test test ball'; const TEST_PASSWORD = 'testpass'; +const REMOTE_FEATURE_FLAG_OPTIONS = { + clientConfigApiService: { + fetchRemoteFeatureFlags: async (): Promise<{ + remoteFeatureFlags: Record; + cacheTimestamp: number; + }> => ({ remoteFeatureFlags: {}, cacheTimestamp: Date.now() }), + }, +}; + async function setupWallet(): Promise { const wallet = new Wallet({ instanceOptions: { @@ -22,6 +31,7 @@ async function setupWallet(): Promise { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -81,6 +91,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -126,6 +137,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); const { state } = wallet; @@ -165,6 +177,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -239,6 +252,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -275,6 +289,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -324,4 +339,53 @@ describe('Wallet', () => { ).toBe('bar'); }); }); + + describe('RemoteFeatureFlagController', () => { + it('is wired and exposes its state on the wallet messenger', async () => { + const wallet = await setupWallet(); + const { messenger } = wallet; + + expect( + messenger.call('RemoteFeatureFlagController:getState'), + ).toStrictEqual({ + remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + + it('routes injected instanceOptions through to the controller', async () => { + const wallet = new Wallet({ + instanceOptions: { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + keyringController: { encryptor: new MockEncryptor() }, + storageService: { storage: new InMemoryStorageAdapter() }, + remoteFeatureFlagController: { + clientConfigApiService: { + fetchRemoteFeatureFlags: async (): Promise<{ + remoteFeatureFlags: Record; + cacheTimestamp: number; + }> => ({ + remoteFeatureFlags: { testFlag: true }, + cacheTimestamp: Date.now(), + }), + }, + }, + }, + }); + const { messenger } = wallet; + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + messenger.call('RemoteFeatureFlagController:getState') + .remoteFeatureFlags, + ).toStrictEqual({ testFlag: true }); + }); + }); }); diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index ad20f90c65..bed6e52b26 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -2,4 +2,5 @@ export { accountsController } from './accounts-controller/accounts-controller'; export { approvalController } from './approval-controller/approval-controller'; export { connectivityController } from './connectivity-controller/connectivity-controller'; export { keyringController } from './keyring-controller/keyring-controller'; +export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller'; export { storageService } from './storage-service/storage-service'; diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts new file mode 100644 index 0000000000..5939977fa8 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts @@ -0,0 +1,234 @@ +import { Messenger } from '@metamask/messenger'; +import { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; + +import { defaultConfigurations } from '../../defaults'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; +import { remoteFeatureFlagController } from './remote-feature-flag-controller'; + +/** + * Creates a root messenger for use in tests. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +/** + * Creates a stub client-config API service whose `fetchRemoteFeatureFlags` + * resolves to an empty flag set. + * + * @returns A stub client-config API service. + */ +function getClientConfigApiService(): { fetchRemoteFeatureFlags: jest.Mock } { + return { + fetchRemoteFeatureFlags: jest.fn().mockResolvedValue({ + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }), + }; +} + +describe('remoteFeatureFlagController', () => { + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain( + remoteFeatureFlagController, + ); + }); + + it('initializes a RemoteFeatureFlagController with default state', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { clientConfigApiService: getClientConfigApiService() }, + }); + + expect(instance).toBeInstanceOf(RemoteFeatureFlagController); + expect(instance.state).toStrictEqual({ + remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + + it('forwards the provided state to the controller', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: { + remoteFeatureFlags: { testFlag: true }, + cacheTimestamp: 12345, + }, + messenger, + options: { clientConfigApiService: getClientConfigApiService() }, + }); + + expect(instance.state.remoteFeatureFlags).toStrictEqual({ testFlag: true }); + }); + + it('applies default getMetaMetricsId and clientVersion when omitted', async () => { + const clientConfigApiService = getClientConfigApiService(); + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { clientConfigApiService }, + }); + + await instance.updateRemoteFeatureFlags(); + + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).toHaveBeenCalledTimes(1); + expect(instance.state.remoteFeatureFlags).toStrictEqual({}); + }); + + it('uses the injected clientConfigApiService, getMetaMetricsId, and clientVersion', async () => { + const fetchRemoteFeatureFlags = jest.fn().mockResolvedValue({ + remoteFeatureFlags: { testFlag: true }, + cacheTimestamp: Date.now(), + }); + const getMetaMetricsId = jest.fn(() => 'test-metrics-id'); + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { + clientConfigApiService: { fetchRemoteFeatureFlags }, + getMetaMetricsId, + clientVersion: '1.2.3', + }, + }); + + await instance.updateRemoteFeatureFlags(); + + expect(fetchRemoteFeatureFlags).toHaveBeenCalledTimes(1); + expect(getMetaMetricsId).toHaveBeenCalled(); + expect(instance.state.remoteFeatureFlags).toStrictEqual({ testFlag: true }); + }); + + it('does not fetch flags when initialized as disabled', async () => { + const clientConfigApiService = getClientConfigApiService(); + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { clientConfigApiService, disabled: true }, + }); + + await instance.updateRemoteFeatureFlags(); + + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).not.toHaveBeenCalled(); + }); + + it('invalidates the cache when prevClientVersion differs from clientVersion', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: { + remoteFeatureFlags: { testFlag: true }, + cacheTimestamp: Date.now(), + }, + messenger, + options: { + clientConfigApiService: getClientConfigApiService(), + clientVersion: '2.0.0', + prevClientVersion: '1.0.0', + }, + }); + + expect(instance.state.cacheTimestamp).toBe(0); + }); + + it('preserves the cache when prevClientVersion matches clientVersion', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: { + remoteFeatureFlags: { testFlag: true }, + cacheTimestamp: 5000, + }, + messenger, + options: { + clientConfigApiService: getClientConfigApiService(), + clientVersion: '2.0.0', + prevClientVersion: '2.0.0', + }, + }); + + expect(instance.state.cacheTimestamp).toBe(5000); + }); + + it('surfaces the controller throw on an invalid clientVersion', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + expect(() => + remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { + clientConfigApiService: getClientConfigApiService(), + clientVersion: 'not-semver', + }, + }), + ).toThrow('Invalid clientVersion'); + }); + + it('forwards a custom fetchInterval to the controller', async () => { + const clientConfigApiService = getClientConfigApiService(); + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: { remoteFeatureFlags: {}, cacheTimestamp: Date.now() }, + messenger, + options: { clientConfigApiService, fetchInterval: 60 * 60 * 1000 }, + }); + + await instance.updateRemoteFeatureFlags(); + + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).not.toHaveBeenCalled(); + }); + + it('exposes its state through the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = remoteFeatureFlagController.getMessenger(rootMessenger); + + remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { clientConfigApiService: getClientConfigApiService() }, + }); + + expect( + rootMessenger.call('RemoteFeatureFlagController:getState'), + ).toStrictEqual({ + remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); +}); diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts new file mode 100644 index 0000000000..5f155deb02 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -0,0 +1,30 @@ +import { Messenger } from '@metamask/messenger'; +import { + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger, +} from '@metamask/remote-feature-flag-controller'; + +import { InitializationConfiguration } from '../../types'; + +export const remoteFeatureFlagController: InitializationConfiguration< + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger +> = { + name: 'RemoteFeatureFlagController', + init: ({ state, messenger, options }) => + new RemoteFeatureFlagController({ + state, + messenger, + clientConfigApiService: options.clientConfigApiService, + getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), + clientVersion: options.clientVersion ?? '0.0.0', + prevClientVersion: options.prevClientVersion, + fetchInterval: options.fetchInterval, + disabled: options.disabled, + }), + getMessenger: (parent) => + new Messenger({ + namespace: 'RemoteFeatureFlagController', + parent, + }), +}; diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts new file mode 100644 index 0000000000..1477c632cf --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -0,0 +1,45 @@ +import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; + +type RemoteFeatureFlagControllerOptions = ConstructorParameters< + typeof RemoteFeatureFlagController +>[0]; + +/** + * Per-instance options for the wallet's `RemoteFeatureFlagController`. + * `clientConfigApiService` is required; the rest are optional and fall back to + * the defaults applied in the controller's `init`. + */ +export type RemoteFeatureFlagControllerInstanceOptions = { + /** + * The service that fetches remote feature flags. Required: each client + * injects a `ClientConfigApiService` configured for its own client type, + * distribution, and environment, so there is no platform-agnostic default. + */ + clientConfigApiService: RemoteFeatureFlagControllerOptions['clientConfigApiService']; + /** + * Returns the current MetaMetrics id, used for user-segmentation thresholds. + * Defaults to `() => ''`. + */ + getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; + /** + * The current client version for version-based flag filtering. Must be a + * valid 3-part SemVer or the controller throws. Defaults to `'0.0.0'`. + */ + clientVersion?: RemoteFeatureFlagControllerOptions['clientVersion']; + /** + * The previously-run client version. When it differs from `clientVersion`, + * the controller invalidates its cached flags on the next update. + */ + prevClientVersion?: RemoteFeatureFlagControllerOptions['prevClientVersion']; + /** + * Milliseconds before cached flags expire. Defaults to the controller's own + * default (1 day). + */ + fetchInterval?: RemoteFeatureFlagControllerOptions['fetchInterval']; + /** + * Whether the controller starts disabled. Defaults to `false`. The dynamic + * enable/disable toggling stays client-side via the controller's exposed + * `enable`/`disable` actions. + */ + disabled?: RemoteFeatureFlagControllerOptions['disabled']; +}; diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 8700c5d4e0..0bc6c0efaa 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -8,6 +8,7 @@ import type { import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-controller/types'; import type { ConnectivityControllerInstanceOptions } from './initialization/instances/connectivity-controller/types'; import type { KeyringControllerInstanceOptions } from './initialization/instances/keyring-controller/types'; +import type { RemoteFeatureFlagControllerInstanceOptions } from './initialization/instances/remote-feature-flag-controller/types'; import type { StorageServiceInstanceOptions } from './initialization/instances/storage-service/types'; import { InitializationConfiguration } from './initialization/types'; @@ -25,5 +26,6 @@ export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; connectivityController: ConnectivityControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; + remoteFeatureFlagController: RemoteFeatureFlagControllerInstanceOptions; storageService: StorageServiceInstanceOptions; }; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 273beb026a..317fe28aac 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 7bb23616ae..abbd559b3e 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../controller-utils/tsconfig.json" }, { "path": "../keyring-controller/tsconfig.json" }, { "path": "../messenger/tsconfig.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.json" }, { "path": "../storage-service/tsconfig.json" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 50b1294856..dd40275236 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8801,6 +8801,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.1" "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/storage-service": "npm:^1.0.2" "@metamask/utils": "npm:^11.9.0"