From 55f6d01250a8c58543acf1a5c82efb413e9cd7f3 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 13:17:40 +0200 Subject: [PATCH 1/5] 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 057279f7a6..c0eec61c64 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,6 +132,7 @@ ## Initialization /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations /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 dff073e7f2..3b811b6533 100644 --- a/README.md +++ b/README.md @@ -573,6 +573,7 @@ linkStyle default opacity:0.5 wallet --> controller_utils; wallet --> keyring_controller; wallet --> messenger; + wallet --> remote_feature_flag_controller; wallet --> storage_service; ``` diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 787705fe66..ddfc95cd57 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Wire `ApprovalController` into the default wallet initialization ([#8953](https://github.com/MetaMask/core/pull/8953)) - The default `Wallet` now constructs an `ApprovalController` and registers its `ApprovalController:*` messenger actions. Consumers that pass their own `messenger` and already wire an `ApprovalController` must remove their own before upgrading, or the duplicate registration will collide. - Adds an `approvalController` slot to `instanceOptions` with `showApprovalRequest` (the callback that surfaces pending approval requests to the user; defaults to a no-op) and `typesExcludedFromRateLimiting` (the approval types exempt from per-origin rate limiting; defaults to a baseline of EVM approval types). Both let consumers (extension, mobile, wallet-cli) inject their platform-specific values. +- Wire `RemoteFeatureFlagController` into the default wallet initialization ([#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] diff --git a/packages/wallet/package.json b/packages/wallet/package.json index f4f1414434..9ff4b1968f 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -59,6 +59,7 @@ "@metamask/controller-utils": "^12.1.0", "@metamask/keyring-controller": "^26.0.0", "@metamask/messenger": "^1.2.0", + "@metamask/remote-feature-flag-controller": "^4.2.2", "@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 aa79320da7..784ee64e12 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -1,3 +1,4 @@ export { approvalController } from './approval-controller/approval-controller'; export { keyringController } from './keyring-controller/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 08c2b63321..2730c1de32 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 { @@ -10,6 +11,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>; @@ -23,5 +28,18 @@ export type WalletOptions = { export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; 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 68e89ef628..d0173910bf 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 7b905db657..dfec2afb34 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../controller-utils/tsconfig.json" }, { "path": "../keyring-controller/tsconfig.json" }, { "path": "../messenger/tsconfig.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.json" }, { "path": "../storage-service/tsconfig.json" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 62390dcb39..39d4d14224 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8789,6 +8789,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.1.0" "@metamask/keyring-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^1.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/storage-service": "npm:^1.0.1" "@metamask/utils": "npm:^11.9.0" From 1942c0c430dbf15767ebc33242617575820eeb6a Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 13:18:31 +0200 Subject: [PATCH 2/5] 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 ddfc95cd57..684c54f22d 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -12,7 +12,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. ## [2.0.0] From 082ae1a5397c310b131999f1bceb133c25050a14 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 2 Jun 2026 18:17:40 +0200 Subject: [PATCH 3/5] 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 c0eec61c64..db2bcdc703 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,7 +132,7 @@ ## Initialization /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations /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 784ee64e12..b9a64c1047 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/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 2730c1de32..544206d281 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 { @@ -8,13 +7,10 @@ import type { } from './initialization/defaults'; import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-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>; @@ -28,18 +24,6 @@ export type WalletOptions = { export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; 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 005feb9b7456a15474084548ebdb02d99bb3cb4b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 9 Jun 2026 11:56:06 +0100 Subject: [PATCH 4/5] 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 684c54f22d..fa18953f38 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -12,8 +12,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. ## [2.0.0] diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 1067eced4f..c9479033f6 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -11,12 +11,24 @@ 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: { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -73,6 +85,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -115,6 +128,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); const { state } = wallet; @@ -215,6 +229,7 @@ describe('Wallet', () => { storageService: { storage: new InMemoryStorageAdapter(), }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -281,10 +296,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 544206d281..44ab4dfbd2 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -24,6 +24,6 @@ export type WalletOptions = { export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; - remoteFeatureFlagController?: RemoteFeatureFlagControllerInstanceOptions; + remoteFeatureFlagController: RemoteFeatureFlagControllerInstanceOptions; storageService: StorageServiceInstanceOptions; }; From e4acb70b494e036c5f2b68417e3acc5e060f2fc4 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 9 Jun 2026 12:01:57 +0100 Subject: [PATCH 5/5] 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 c9479033f6..8a28a925a6 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -11,8 +11,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 },