From 1c6466cc07e0b7b96baea9ce22afc439435c9b00 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 13:17:40 +0200 Subject: [PATCH 1/3] feat(wallet): wire `RemoteFeatureFlagController` into default initialization Adds `RemoteFeatureFlagController` to the wallet's default controller ensemble, exposing per-platform constructor values through a new `instanceOptions.remoteFeatureFlagController` slot: `clientConfigApiService`, `getMetaMetricsId`, `clientVersion`, `prevClientVersion`, `fetchInterval`, and `disabled`. Each is injectable with an inert/neutral default so the controller is usable headlessly; extension and mobile inject their own values. The controller's messenger is a plain namespaced child with no delegation. `prevClientVersion` lets consumers trigger feature-flag cache invalidation when the client version changes between sessions. Closes #8794 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 1 + README.md | 1 + packages/wallet/CHANGELOG.md | 5 + packages/wallet/package.json | 1 + packages/wallet/src/Wallet.test.ts | 50 ++++ .../src/initialization/instances/index.ts | 1 + .../remote-feature-flag-controller.test.ts | 233 ++++++++++++++++++ .../remote-feature-flag-controller.ts | 75 ++++++ packages/wallet/src/types.ts | 18 ++ packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 1 + yarn.lock | 1 + 12 files changed, 388 insertions(+) create mode 100644 packages/wallet/src/initialization/instances/remote-feature-flag-controller.test.ts create mode 100644 packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f855e4a66c..a3c04d7be5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -130,6 +130,7 @@ ## Initialization /packages/wallet/src/initialization/instances/keyring-controller.ts @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts @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 5a6cd3bd82..ca74c9c727 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,7 @@ linkStyle default opacity:0.5 wallet --> base_controller; 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 4bd983db6c..e266a3aa09 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Wire `RemoteFeatureFlagController` into the default wallet initialization ([#8794](https://github.com/MetaMask/core/issues/8794)) + - 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] ### Added diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 646a0f8994..0bfa9520ac 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -57,6 +57,7 @@ "@metamask/browser-passworder": "^6.0.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 95b4a4a080..0318976abd 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1,2 +1,3 @@ export { keyringController } from './keyring-controller'; +export { remoteFeatureFlagController } from './remote-feature-flag-controller'; export { storageService } from './storage-service'; diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller.test.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.test.ts new file mode 100644 index 0000000000..8562a92a7b --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.test.ts @@ -0,0 +1,233 @@ +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.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts new file mode 100644 index 0000000000..38c6c9dda6 --- /dev/null +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts @@ -0,0 +1,75 @@ +import { Messenger } from '@metamask/messenger'; +import { + RemoteFeatureFlagController, + RemoteFeatureFlagControllerMessenger, +} from '@metamask/remote-feature-flag-controller'; + +import { InitializationConfiguration } from '../types'; + +type RemoteFeatureFlagControllerOptions = ConstructorParameters< + typeof RemoteFeatureFlagController +>[0]; + +/** + * 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: RemoteFeatureFlagControllerOptions['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/types.ts b/packages/wallet/src/types.ts index 19926a251e..9254e1176e 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,4 +1,5 @@ import { KeyringControllerOptions } from '@metamask/keyring-controller'; +import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; import { StorageAdapter } from '@metamask/storage-service'; import type { Json } from '@metamask/utils'; @@ -10,6 +11,10 @@ import type { import { GenericEncryptor } from './initialization/instances/keyring-controller'; import { InitializationConfiguration } from './initialization/types'; +type RemoteFeatureFlagControllerOptions = ConstructorParameters< + typeof RemoteFeatureFlagController +>[0]; + export type WalletOptions = { messenger?: RootMessenger; state?: Record | undefined>; @@ -26,6 +31,19 @@ export type InstanceSpecificOptions = { keyringBuilders?: KeyringControllerOptions['keyringBuilders']; keyringV2Builders?: KeyringControllerOptions['keyringV2Builders']; }; + // The wallet injects neutral defaults for `clientConfigApiService` (a + // network-free service that returns no flags), `getMetaMetricsId` (`''`), and + // `clientVersion` (`'0.0.0'`) when omitted, so a headless consumer can pass + // `{}`. The remaining options merely tune behavior and fall through to the + // controller's own defaults when omitted. + remoteFeatureFlagController?: { + clientConfigApiService?: RemoteFeatureFlagControllerOptions['clientConfigApiService']; + getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; + clientVersion?: RemoteFeatureFlagControllerOptions['clientVersion']; + prevClientVersion?: RemoteFeatureFlagControllerOptions['prevClientVersion']; + fetchInterval?: RemoteFeatureFlagControllerOptions['fetchInterval']; + disabled?: RemoteFeatureFlagControllerOptions['disabled']; + }; storageService: { storage: StorageAdapter; }; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 376689caad..23f613cd52 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -9,6 +9,7 @@ { "path": "../base-controller/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 2d262243eb..05e495e2b6 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -7,6 +7,7 @@ { "path": "../base-controller/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 3e14bb93e7..8ed32369e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5888,6 +5888,7 @@ __metadata: "@metamask/browser-passworder": "npm:^6.0.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" From b0d071a604199af68f66d1efeef892e602d678a4 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 13:18:31 +0200 Subject: [PATCH 2/3] docs(wallet): link changelog entry to PR #8969 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index e266a3aa09..bcb2ad3807 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Wire `RemoteFeatureFlagController` into the default wallet initialization ([#8794](https://github.com/MetaMask/core/issues/8794)) +- 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] From d257b6259249b50f194774a27cee16052ba22ddb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 18:17:40 +0200 Subject: [PATCH 3/3] refactor(wallet): adopt per-controller directory layout for RemoteFeatureFlagController Migrates the RemoteFeatureFlagController instance to the per-controller directory convention (introduced by #8953, extended by #8977): `instances/remote-feature-flag-controller/` now holds the config, the colocated test, and a `RemoteFeatureFlagControllerInstanceOptions` type in its own `types.ts`. `InstanceSpecificOptions` references that type instead of an inline shape, and `instances/index.ts` + the CODEOWNERS `## Initialization` entry use the directory form. No public exports or option shapes change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 2 +- .../src/initialization/instances/index.ts | 2 +- .../remote-feature-flag-controller.test.ts | 8 ++- .../remote-feature-flag-controller.ts | 22 ++++----- .../remote-feature-flag-controller/types.ts | 49 +++++++++++++++++++ packages/wallet/src/types.ts | 20 +------- 6 files changed, 69 insertions(+), 34 deletions(-) rename packages/wallet/src/initialization/instances/{ => remote-feature-flag-controller}/remote-feature-flag-controller.test.ts (98%) rename packages/wallet/src/initialization/instances/{ => remote-feature-flag-controller}/remote-feature-flag-controller.ts (88%) create mode 100644 packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 166e50de71..7de476d012 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -131,7 +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.ts @MetaMask/extension-platform @MetaMask/mobile-platform @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/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 7daa523ac5..d30ab868f7 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1,4 +1,4 @@ export { approvalController } from './approval-controller/approval-controller'; export { keyringController } from './keyring-controller'; -export { remoteFeatureFlagController } from './remote-feature-flag-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.test.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts similarity index 98% rename from packages/wallet/src/initialization/instances/remote-feature-flag-controller.test.ts rename to packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts index 8562a92a7b..c64c959f3f 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller.test.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts @@ -1,8 +1,12 @@ 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 { defaultConfigurations } from '../../defaults'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults'; import { remoteFeatureFlagController } from './remote-feature-flag-controller'; /** diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts similarity index 88% rename from packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts rename to packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts index 38c6c9dda6..9943d67b92 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -4,11 +4,8 @@ import { RemoteFeatureFlagControllerMessenger, } from '@metamask/remote-feature-flag-controller'; -import { InitializationConfiguration } from '../types'; - -type RemoteFeatureFlagControllerOptions = ConstructorParameters< - typeof RemoteFeatureFlagController ->[0]; +import { InitializationConfiguration } from '../../types'; +import type { RemoteFeatureFlagControllerInstanceOptions } from './types'; /** * A platform-agnostic, network-free client-config API service used when a @@ -25,13 +22,14 @@ type RemoteFeatureFlagControllerOptions = ConstructorParameters< * mobile always inject a real service (see the PR's per-environment table), so * this only affects deliberately headless consumers. */ -const defaultClientConfigApiService: RemoteFeatureFlagControllerOptions['clientConfigApiService'] = - { - fetchRemoteFeatureFlags: async () => ({ - remoteFeatureFlags: {}, - cacheTimestamp: Date.now(), - }), - }; +const defaultClientConfigApiService: NonNullable< + RemoteFeatureFlagControllerInstanceOptions['clientConfigApiService'] +> = { + fetchRemoteFeatureFlags: async () => ({ + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }), +}; export const remoteFeatureFlagController: InitializationConfiguration< RemoteFeatureFlagController, 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 19ddcdc3cc..9fc0c943fa 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,5 +1,4 @@ import { KeyringControllerOptions } from '@metamask/keyring-controller'; -import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; import { StorageAdapter } from '@metamask/storage-service'; import type { Json } from '@metamask/utils'; @@ -10,12 +9,9 @@ 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'; -type RemoteFeatureFlagControllerOptions = ConstructorParameters< - typeof RemoteFeatureFlagController ->[0]; - export type WalletOptions = { messenger?: RootMessenger; state?: Record | undefined>; @@ -33,19 +29,7 @@ export type InstanceSpecificOptions = { keyringBuilders?: KeyringControllerOptions['keyringBuilders']; keyringV2Builders?: KeyringControllerOptions['keyringV2Builders']; }; - // The wallet injects neutral defaults for `clientConfigApiService` (a - // network-free service that returns no flags), `getMetaMetricsId` (`''`), and - // `clientVersion` (`'0.0.0'`) when omitted, so a headless consumer can pass - // `{}`. The remaining options merely tune behavior and fall through to the - // controller's own defaults when omitted. - remoteFeatureFlagController?: { - clientConfigApiService?: RemoteFeatureFlagControllerOptions['clientConfigApiService']; - getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; - clientVersion?: RemoteFeatureFlagControllerOptions['clientVersion']; - prevClientVersion?: RemoteFeatureFlagControllerOptions['prevClientVersion']; - fetchInterval?: RemoteFeatureFlagControllerOptions['fetchInterval']; - disabled?: RemoteFeatureFlagControllerOptions['disabled']; - }; + remoteFeatureFlagController?: RemoteFeatureFlagControllerInstanceOptions; storageService: { storage: StorageAdapter; };