From cb75baa7270451e714a49d40f83952da780eface Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 13:17:40 +0200 Subject: [PATCH 1/6] 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 | 2 + 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, 385 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 8f25405593..0274d0852e 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.ts @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..c6aec0e9d1 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -20,6 +20,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 ([#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. ### Changed 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..23d4dceef0 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -324,4 +324,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 ad20f90c65..75b77404ca 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'; export { storageService } from './storage-service/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 8700c5d4e0..3ed4efd318 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,3 +1,4 @@ +import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { @@ -11,6 +12,10 @@ import type { KeyringControllerInstanceOptions } from './initialization/instance import type { StorageServiceInstanceOptions } from './initialization/instances/storage-service/types'; import { InitializationConfiguration } from './initialization/types'; +type RemoteFeatureFlagControllerOptions = ConstructorParameters< + typeof RemoteFeatureFlagController +>[0]; + export type WalletOptions = { messenger?: RootMessenger; state?: Record | undefined>; @@ -25,5 +30,18 @@ export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; connectivityController: ConnectivityControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; + // 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: 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" From 051609763148785917244c0baa18c09d57aa42e5 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 13:18:31 +0200 Subject: [PATCH 2/6] 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 c6aec0e9d1..9505dc1837 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -20,7 +20,7 @@ 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 ([#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. ### Changed From 276c9ed56cc061831a5923efbeb1ec17de4283c9 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 18:17:40 +0200 Subject: [PATCH 3/6] 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 0274d0852e..56420370cc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -134,7 +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.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 /packages/wallet/src/initialization/instances/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform ## Package Release related diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 75b77404ca..bed6e52b26 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -2,5 +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'; +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.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 3ed4efd318..a30652ce31 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,4 +1,3 @@ -import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { @@ -9,13 +8,10 @@ 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'; -type RemoteFeatureFlagControllerOptions = ConstructorParameters< - typeof RemoteFeatureFlagController ->[0]; - export type WalletOptions = { messenger?: RootMessenger; state?: Record | undefined>; @@ -30,18 +26,6 @@ export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; connectivityController: ConnectivityControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; - // 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: StorageServiceInstanceOptions; }; From ae0116347f6edf3dfcb9d1bbab480daf8359405f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 9 Jun 2026 11:56:06 +0100 Subject: [PATCH 4/6] refactor(wallet): require clientConfigApiService for RemoteFeatureFlagController Address review feedback on #8969: - Require `instanceOptions.remoteFeatureFlagController.clientConfigApiService` instead of falling back to an inert default service. There is no platform-agnostic default (the service needs a client type, distribution, environment, and fetch), and silently returning no flags would mask a missing injection in production. The `remoteFeatureFlagController` slot is now required, matching the `storageService` precedent. - Mark the changelog entry as breaking. - Drop the verbose inline comments in the instance file. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/CHANGELOG.md | 5 +- packages/wallet/src/Wallet.test.ts | 19 +++- .../remote-feature-flag-controller.test.ts | 96 ++++++++++--------- .../remote-feature-flag-controller.ts | 45 +-------- .../remote-feature-flag-controller/types.ts | 18 ++-- packages/wallet/src/types.ts | 2 +- 6 files changed, 80 insertions(+), 105 deletions(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 9505dc1837..ad3d9c2c2c 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -20,8 +20,9 @@ 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. +- **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. ### Changed diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 23d4dceef0..6b3962b974 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -13,6 +13,17 @@ import { Wallet } from './Wallet'; const TEST_SRP = 'test test test test test test test test test test test ball'; const TEST_PASSWORD = 'testpass'; +// `clientConfigApiService` is a required `remoteFeatureFlagController` option; +// this stub fetches no flags, for constructions that don't exercise it. +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 +33,7 @@ async function setupWallet(): Promise { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -81,6 +93,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -126,6 +139,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); const { state } = wallet; @@ -275,6 +289,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -341,10 +356,6 @@ describe('Wallet', () => { }); 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() }, 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 index c64c959f3f..cff0142544 100644 --- 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 @@ -18,6 +18,21 @@ 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', () => { // Proves the controller is part of the default ensemble that `initialize()` @@ -35,7 +50,7 @@ describe('remoteFeatureFlagController', () => { const instance = remoteFeatureFlagController.init({ state: undefined, messenger, - options: {}, + options: { clientConfigApiService: getClientConfigApiService() }, }); expect(instance).toBeInstanceOf(RemoteFeatureFlagController); @@ -57,27 +72,31 @@ describe('remoteFeatureFlagController', () => { cacheTimestamp: 12345, }, messenger, - options: {}, + options: { clientConfigApiService: getClientConfigApiService() }, }); expect(instance.state.remoteFeatureFlags).toStrictEqual({ testFlag: true }); }); - it('falls back to inert defaults that fetch no flags when no options are provided', async () => { + 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: {}, + options: { clientConfigApiService }, }); - // 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. + // Exercises the default `getMetaMetricsId` (`() => ''`, invoked while + // processing flags) and the default `clientVersion` ('0.0.0', a valid SemVer + // so construction does not throw). await instance.updateRemoteFeatureFlags(); + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).toHaveBeenCalledTimes(1); expect(instance.state.remoteFeatureFlags).toStrictEqual({}); }); @@ -108,22 +127,21 @@ describe('remoteFeatureFlagController', () => { }); it('does not fetch flags when initialized as disabled', async () => { - const fetchRemoteFeatureFlags = jest.fn(); + const clientConfigApiService = getClientConfigApiService(); const messenger = remoteFeatureFlagController.getMessenger(getRootMessenger()); const instance = remoteFeatureFlagController.init({ state: undefined, messenger, - options: { - clientConfigApiService: { fetchRemoteFeatureFlags }, - disabled: true, - }, + options: { clientConfigApiService, disabled: true }, }); await instance.updateRemoteFeatureFlags(); - expect(fetchRemoteFeatureFlags).not.toHaveBeenCalled(); + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).not.toHaveBeenCalled(); }); it('invalidates the cache when prevClientVersion differs from clientVersion', () => { @@ -136,7 +154,11 @@ describe('remoteFeatureFlagController', () => { cacheTimestamp: Date.now(), }, messenger, - options: { clientVersion: '2.0.0', prevClientVersion: '1.0.0' }, + options: { + clientConfigApiService: getClientConfigApiService(), + clientVersion: '2.0.0', + prevClientVersion: '1.0.0', + }, }); // A version change resets the cache timestamp to 0 so the next update @@ -157,27 +179,16 @@ describe('remoteFeatureFlagController', () => { // 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' }, + options: { + clientConfigApiService: getClientConfigApiService(), + 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()); @@ -186,33 +197,32 @@ describe('remoteFeatureFlagController', () => { remoteFeatureFlagController.init({ state: undefined, messenger, - options: { clientVersion: 'not-semver' }, + options: { + clientConfigApiService: getClientConfigApiService(), + 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 clientConfigApiService = getClientConfigApiService(); 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. + // A recent cache timestamp combined with a large fetchInterval keeps the + // cache fresh, so no fetch happens. state: { remoteFeatureFlags: {}, cacheTimestamp: Date.now() }, messenger, - options: { - clientConfigApiService: { fetchRemoteFeatureFlags }, - fetchInterval: 60 * 60 * 1000, - }, + options: { clientConfigApiService, fetchInterval: 60 * 60 * 1000 }, }); await instance.updateRemoteFeatureFlags(); - expect(fetchRemoteFeatureFlags).not.toHaveBeenCalled(); + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).not.toHaveBeenCalled(); }); it('exposes its state through the root messenger', () => { @@ -222,7 +232,7 @@ describe('remoteFeatureFlagController', () => { remoteFeatureFlagController.init({ state: undefined, messenger, - options: {}, + options: { clientConfigApiService: getClientConfigApiService() }, }); expect( 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 index 9943d67b92..5f155deb02 100644 --- 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 @@ -5,31 +5,6 @@ import { } 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, @@ -40,28 +15,10 @@ export const remoteFeatureFlagController: InitializationConfiguration< 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, + clientConfigApiService: options.clientConfigApiService, 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, }), 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 index f3e00fba09..1477c632cf 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -5,21 +5,17 @@ type RemoteFeatureFlagControllerOptions = ConstructorParameters< >[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. + * 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. Clients inject a real - * `ClientConfigApiService` configured for their client type, distribution, - * and environment; defaults to a network-free service that returns no flags. + * 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']; + clientConfigApiService: RemoteFeatureFlagControllerOptions['clientConfigApiService']; /** * Returns the current MetaMetrics id, used for user-segmentation thresholds. * Defaults to `() => ''`. diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index a30652ce31..0bc6c0efaa 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -26,6 +26,6 @@ export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; connectivityController: ConnectivityControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; - remoteFeatureFlagController?: RemoteFeatureFlagControllerInstanceOptions; + remoteFeatureFlagController: RemoteFeatureFlagControllerInstanceOptions; storageService: StorageServiceInstanceOptions; }; From 23005c02a3dba8141cf693ee71cba5aa3b873274 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 9 Jun 2026 12:01:57 +0100 Subject: [PATCH 5/6] refactor(wallet): drop low-value comments from RemoteFeatureFlagController tests Remove inline comments that restated the test/code behavior without adding value, per review feedback. The per-controller `types.ts` JSDoc is kept, as it documents the public option contract (matching the approval-controller layout). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/src/Wallet.test.ts | 2 -- .../remote-feature-flag-controller.test.ts | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 6b3962b974..218b03843a 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -13,8 +13,6 @@ import { Wallet } from './Wallet'; const TEST_SRP = 'test test test test test test test test test test test ball'; const TEST_PASSWORD = 'testpass'; -// `clientConfigApiService` is a required `remoteFeatureFlagController` option; -// this stub fetches no flags, for constructions that don't exercise it. const REMOTE_FEATURE_FLAG_OPTIONS = { clientConfigApiService: { fetchRemoteFeatureFlags: async (): Promise<{ 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 index cff0142544..5939977fa8 100644 --- 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 @@ -35,9 +35,6 @@ function getClientConfigApiService(): { fetchRemoteFeatureFlags: jest.Mock } { 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, ); @@ -89,9 +86,6 @@ describe('remoteFeatureFlagController', () => { options: { clientConfigApiService }, }); - // Exercises the default `getMetaMetricsId` (`() => ''`, invoked while - // processing flags) and the default `clientVersion` ('0.0.0', a valid SemVer - // so construction does not throw). await instance.updateRemoteFeatureFlags(); expect( @@ -161,8 +155,6 @@ describe('remoteFeatureFlagController', () => { }, }); - // 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); }); @@ -176,9 +168,6 @@ describe('remoteFeatureFlagController', () => { 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: { clientConfigApiService: getClientConfigApiService(), clientVersion: '2.0.0', @@ -211,8 +200,6 @@ describe('remoteFeatureFlagController', () => { remoteFeatureFlagController.getMessenger(getRootMessenger()); const instance = remoteFeatureFlagController.init({ - // A recent cache timestamp combined with a large fetchInterval keeps the - // cache fresh, so no fetch happens. state: { remoteFeatureFlags: {}, cacheTimestamp: Date.now() }, messenger, options: { clientConfigApiService, fetchInterval: 60 * 60 * 1000 }, From 16f33fc41651dac76fb8de5bfcbb5c448d02b3af Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 10 Jun 2026 10:24:19 +0100 Subject: [PATCH 6/6] fix(wallet): reconcile RemoteFeatureFlagController wiring after rebase on #8924 Rebasing onto main (which released `@metamask/wallet@3.0.0` and added the `AccountsController`/`ConnectivityController` in #8924) left two artifacts that this commit resolves: - The `RemoteFeatureFlagController` changelog entry was carried into the now released `[3.0.0]` section alongside the `ApprovalController` entry it originally followed. Move it back under `[Unreleased]`, after the #8924 entry. - #8924 made `instanceOptions.connectivityController` required and added Wallet test cases that predate this branch. Pass the now-required `remoteFeatureFlagController` option to those cases, and the now-required `connectivityController` option to the RemoteFeatureFlagController test added by this branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet/CHANGELOG.md | 6 +++--- packages/wallet/src/Wallet.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index ad3d9c2c2c..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] @@ -20,9 +23,6 @@ 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. -- **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. ### Changed diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 218b03843a..a89602c3fc 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -177,6 +177,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -251,6 +252,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -356,6 +358,9 @@ describe('Wallet', () => { 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: {