diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e228d8fb4b..7de476d012 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -131,6 +131,7 @@ ## Initialization /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations /packages/wallet/src/initialization/instances/keyring-controller.ts @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/README.md b/README.md index 54235840db..cc122fb526 100644 --- a/README.md +++ b/README.md @@ -572,6 +572,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 787705fe66..684c54f22d 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Wire `ApprovalController` into the default wallet initialization ([#8953](https://github.com/MetaMask/core/pull/8953)) - The default `Wallet` now constructs an `ApprovalController` and registers its `ApprovalController:*` messenger actions. Consumers that pass their own `messenger` and already wire an `ApprovalController` must remove their own before upgrading, or the duplicate registration will collide. - Adds an `approvalController` slot to `instanceOptions` with `showApprovalRequest` (the callback that surfaces pending approval requests to the user; defaults to a no-op) and `typesExcludedFromRateLimiting` (the approval types exempt from per-origin rate limiting; defaults to a baseline of EVM approval types). Both let consumers (extension, mobile, wallet-cli) inject their platform-specific values. +- Wire `RemoteFeatureFlagController` into the default wallet initialization ([#8969](https://github.com/MetaMask/core/pull/8969)) + - Adds a `remoteFeatureFlagController` slot to `instanceOptions` with `clientConfigApiService`, `getMetaMetricsId`, `clientVersion`, `prevClientVersion`, `fetchInterval`, and `disabled`. Each value differs per platform (extension, mobile, wallet-cli), so all are injectable with inert defaults; `prevClientVersion` lets consumers trigger feature-flag cache invalidation when the client version changes between sessions. ## [2.0.0] diff --git a/packages/wallet/package.json b/packages/wallet/package.json index f4f1414434..9dd2bd48de 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -59,6 +59,7 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-controller": "^26.0.0", "@metamask/messenger": "^1.2.0", + "@metamask/remote-feature-flag-controller": "^4.2.1", "@metamask/scure-bip39": "^2.1.1", "@metamask/storage-service": "^1.0.1", "@metamask/utils": "^11.9.0" diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index e0ff366c09..1067eced4f 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -264,4 +264,54 @@ 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 () => { + // Proves the end-to-end path: the camelCased `remoteFeatureFlagController` + // option key reaches `initialize` -> `init` -> the controller. An injected + // service returns a known flag, which then appears in state fetched over + // the shared messenger. + const wallet = new Wallet({ + instanceOptions: { + 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 56cb78a012..d30ab868f7 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1,3 +1,4 @@ export { approvalController } from './approval-controller/approval-controller'; export { keyringController } from './keyring-controller'; +export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller'; export { storageService } from './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..c64c959f3f --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts @@ -0,0 +1,237 @@ +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' }); +} + +describe('remoteFeatureFlagController', () => { + it('is registered as a default initialization configuration', () => { + // Proves the controller is part of the default ensemble that `initialize()` + // wires, without constructing a `Wallet` (which keeps this PR independent of + // the constructor-options shape). + 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: {}, + }); + + 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: {}, + }); + + expect(instance.state.remoteFeatureFlags).toStrictEqual({ testFlag: true }); + }); + + it('falls back to inert defaults that fetch no flags when no options are provided', async () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: {}, + }); + + // Exercises the default `clientConfigApiService` and `getMetaMetricsId`: + // the cache is expired (timestamp 0), so this fetches via the inert default + // service, which returns an empty flag set. + await instance.updateRemoteFeatureFlags(); + + 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 fetchRemoteFeatureFlags = jest.fn(); + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { + clientConfigApiService: { fetchRemoteFeatureFlags }, + disabled: true, + }, + }); + + await instance.updateRemoteFeatureFlags(); + + expect(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: { clientVersion: '2.0.0', prevClientVersion: '1.0.0' }, + }); + + // A version change resets the cache timestamp to 0 so the next update + // refetches rather than serving stale flags from a previous version. + 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, + // Same version: invalidation must be conditional, so the timestamp is + // preserved (this proves both versions are forwarded to the right slots, + // not that the controller always zeroes the cache). + options: { clientVersion: '2.0.0', prevClientVersion: '2.0.0' }, + }); + + expect(instance.state.cacheTimestamp).toBe(5000); + }); + + it('does not throw with the default clientVersion', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + // The default '0.0.0' is a valid SemVer; the controller throws on invalid + // versions, so this proves a headless consumer can construct it. + expect(() => + remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: {}, + }), + ).not.toThrow(); + }); + + it('surfaces the controller throw on an invalid clientVersion', () => { + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + expect(() => + remoteFeatureFlagController.init({ + state: undefined, + messenger, + options: { clientVersion: 'not-semver' }, + }), + ).toThrow('Invalid clientVersion'); + }); + + it('forwards a custom fetchInterval to the controller', async () => { + const fetchRemoteFeatureFlags = jest.fn().mockResolvedValue({ + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }); + const messenger = + remoteFeatureFlagController.getMessenger(getRootMessenger()); + + const instance = remoteFeatureFlagController.init({ + // A non-expired cache (recent timestamp) combined with a very large + // fetchInterval means the cache is considered fresh, so no fetch happens. + state: { remoteFeatureFlags: {}, cacheTimestamp: Date.now() }, + messenger, + options: { + clientConfigApiService: { fetchRemoteFeatureFlags }, + fetchInterval: 60 * 60 * 1000, + }, + }); + + await instance.updateRemoteFeatureFlags(); + + expect(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: {}, + }); + + 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..9943d67b92 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -0,0 +1,73 @@ +import { Messenger } from '@metamask/messenger'; +import { + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger, +} from '@metamask/remote-feature-flag-controller'; + +import { InitializationConfiguration } from '../../types'; +import type { RemoteFeatureFlagControllerInstanceOptions } from './types'; + +/** + * A platform-agnostic, network-free client-config API service used when a + * consumer does not inject its own. Its `fetchRemoteFeatureFlags` performs no + * request and resolves to an empty flag set, so the wallet can wire a + * functional `RemoteFeatureFlagController` headlessly (e.g. for wallet-cli). + * Clients inject a real `ClientConfigApiService` configured for their own + * client type, distribution, and environment via + * `instanceOptions.remoteFeatureFlagController.clientConfigApiService` — there + * is no single correct value to hardcode, since it differs per platform. + * + * Note: a consumer that intends to fetch flags but forgets to inject a service + * will silently get an empty flag set rather than an error. Extension and + * mobile always inject a real service (see the PR's per-environment table), so + * this only affects deliberately headless consumers. + */ +const defaultClientConfigApiService: NonNullable< + RemoteFeatureFlagControllerInstanceOptions['clientConfigApiService'] +> = { + fetchRemoteFeatureFlags: async () => ({ + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }), +}; + +export const remoteFeatureFlagController: InitializationConfiguration< + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger +> = { + name: 'RemoteFeatureFlagController', + init: ({ state, messenger, options }) => + new RemoteFeatureFlagController({ + state, + messenger, + // These options differ per platform (see the PR's per-environment table), + // so they are injected rather than hardcoded; the service and metrics-id + // fall back to network-free/empty defaults so the controller is usable + // headlessly. + clientConfigApiService: + options.clientConfigApiService ?? defaultClientConfigApiService, + getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), + // `clientVersion` must be a valid 3-part SemVer or the controller throws. + // '0.0.0' is a valid default that avoids the throw; because it is the + // lowest possible version, any version-gated flag resolves to no match + // and is dropped (non-version flags are unaffected). Clients pass their + // real version so version gating works. + clientVersion: options.clientVersion ?? '0.0.0', + // Triggers feature-flag cache invalidation when the client version changes + // between sessions; consumers supply the previously-run version. + prevClientVersion: options.prevClientVersion, + // `undefined` lets the controller apply its own defaults (1-day interval, + // enabled). The dynamic enable/disable toggling that the clients drive + // from their Preferences/Onboarding (extension) or basic-functionality + // selector (mobile) stays client-side, via the controller's exposed + // `enable`/`disable` actions on the shared messenger — those sources are + // not wallet controllers, so they are not delegated here. + 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..f3e00fba09 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -0,0 +1,49 @@ +import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; + +type RemoteFeatureFlagControllerOptions = ConstructorParameters< + typeof RemoteFeatureFlagController +>[0]; + +/** + * Per-instance options for the wallet's `RemoteFeatureFlagController`. All + * fields are optional; see the controller's `init` for the defaults applied + * when omitted. The wallet injects neutral defaults for `clientConfigApiService` + * (a network-free service that returns no flags), `getMetaMetricsId` (`''`), and + * `clientVersion` (`'0.0.0'`) so a headless consumer can pass `{}`. The + * remaining options merely tune behavior and fall through to the controller's + * own defaults when omitted. + */ +export type RemoteFeatureFlagControllerInstanceOptions = { + /** + * The service that fetches remote feature flags. Clients inject a real + * `ClientConfigApiService` configured for their client type, distribution, + * and environment; defaults to a network-free service that returns no flags. + */ + 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 a95699f8ef..9fc0c943fa 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -9,6 +9,7 @@ import type { } from './initialization/defaults'; import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-controller/types'; import { GenericEncryptor } from './initialization/instances/keyring-controller'; +import type { RemoteFeatureFlagControllerInstanceOptions } from './initialization/instances/remote-feature-flag-controller/types'; import { InitializationConfiguration } from './initialization/types'; export type WalletOptions = { @@ -28,6 +29,7 @@ export type InstanceSpecificOptions = { keyringBuilders?: KeyringControllerOptions['keyringBuilders']; keyringV2Builders?: KeyringControllerOptions['keyringV2Builders']; }; + remoteFeatureFlagController?: RemoteFeatureFlagControllerInstanceOptions; storageService: { storage: StorageAdapter; }; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 68e89ef628..d0173910bf 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -11,6 +11,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 7b905db657..dfec2afb34 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -9,6 +9,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 fa1830cbf7..2b5e3f453d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5894,6 +5894,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.1" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/storage-service": "npm:^1.0.1" "@metamask/utils": "npm:^11.9.0"