diff --git a/.idea/core.iml b/.idea/core.iml
new file mode 100644
index 0000000000..d6ebd48059
--- /dev/null
+++ b/.idea/core.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000000..402ffa7e2b
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000000..35eb1ddfbb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000000..3d959051ca
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1732151639949
+
+
+ 1732151639949
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index d015ce114c..b1a3e5da8a 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@ Each package in this repository has its own README where you can find installati
- [`@metamask/profile-sync-controller`](packages/profile-sync-controller)
- [`@metamask/queued-request-controller`](packages/queued-request-controller)
- [`@metamask/rate-limit-controller`](packages/rate-limit-controller)
+- [`@metamask/remote-feature-flag-controller`](packages/remote-feature-flag-controller)
- [`@metamask/selected-network-controller`](packages/selected-network-controller)
- [`@metamask/signature-controller`](packages/signature-controller)
- [`@metamask/transaction-controller`](packages/transaction-controller)
@@ -162,8 +163,8 @@ linkStyle default opacity:0.5
preferences_controller --> keyring_controller;
profile_sync_controller --> base_controller;
profile_sync_controller --> keyring_controller;
- profile_sync_controller --> accounts_controller;
profile_sync_controller --> network_controller;
+ profile_sync_controller --> accounts_controller;
queued_request_controller --> base_controller;
queued_request_controller --> controller_utils;
queued_request_controller --> json_rpc_engine;
@@ -179,6 +180,7 @@ linkStyle default opacity:0.5
signature_controller --> approval_controller;
signature_controller --> keyring_controller;
signature_controller --> logging_controller;
+ signature_controller --> network_controller;
transaction_controller --> base_controller;
transaction_controller --> controller_utils;
transaction_controller --> accounts_controller;
diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md
new file mode 100644
index 0000000000..057fc9fd4a
--- /dev/null
+++ b/packages/remote-feature-flag-controller/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0]
+
+### Added
+
+- Initial release
+
+[Unreleased]: https://github.com/MetaMask/core/
diff --git a/packages/remote-feature-flag-controller/LICENSE b/packages/remote-feature-flag-controller/LICENSE
new file mode 100644
index 0000000000..6f8bff03fc
--- /dev/null
+++ b/packages/remote-feature-flag-controller/LICENSE
@@ -0,0 +1,20 @@
+MIT License
+
+Copyright (c) 2024 MetaMask
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
diff --git a/packages/remote-feature-flag-controller/README.md b/packages/remote-feature-flag-controller/README.md
new file mode 100644
index 0000000000..91b4f41d69
--- /dev/null
+++ b/packages/remote-feature-flag-controller/README.md
@@ -0,0 +1,15 @@
+# `@metamask/example-controllers`
+
+This package is designed to illustrate best practices for controller packages and controller files, including tests.
+
+## Installation
+
+`yarn add @metamask/remote-feature-flag-controller`
+
+or
+
+`npm install @metamask/remote-feature-flag-controller`
+
+## Contributing
+
+This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
diff --git a/packages/remote-feature-flag-controller/jest.config.js b/packages/remote-feature-flag-controller/jest.config.js
new file mode 100644
index 0000000000..ca08413339
--- /dev/null
+++ b/packages/remote-feature-flag-controller/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * For a detailed explanation regarding each configuration property and type check, visit:
+ * https://jestjs.io/docs/configuration
+ */
+
+const merge = require('deepmerge');
+const path = require('path');
+
+const baseConfig = require('../../jest.config.packages');
+
+const displayName = path.basename(__dirname);
+
+module.exports = merge(baseConfig, {
+ // The display name when running multiple projects
+ displayName,
+
+ // An object that configures minimum threshold enforcement for coverage results
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ },
+});
diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json
new file mode 100644
index 0000000000..6dd3862e29
--- /dev/null
+++ b/packages/remote-feature-flag-controller/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@metamask/remote-feature-flag-controller",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Controller with caching, fallback, and privacy for managing feature flags via ClientConfigAPI",
+ "keywords": [
+ "MetaMask",
+ "Ethereum"
+ ],
+ "homepage": "https://github.com/MetaMask/core/tree/main/packages/remote-feature-flag-controller#readme",
+ "bugs": {
+ "url": "https://github.com/MetaMask/core/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/core.git"
+ },
+ "license": "MIT",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/index.cjs",
+ "types": "./dist/index.d.cts",
+ "files": [
+ "dist/"
+ ],
+ "scripts": {
+ "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
+ "build:docs": "typedoc",
+ "changelog:update": "../../scripts/update-changelog.sh @metamask/remote-feature-flag-controllers",
+ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/remote-feature-flag-controllers",
+ "since-latest-release": "../../scripts/since-latest-release.sh",
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
+ "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
+ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
+ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
+ },
+ "dependencies": {
+ "@metamask/base-controller": "^7.0.2",
+ "@metamask/utils": "^10.0.0",
+ "cockatiel": "^3.1.2"
+ },
+ "devDependencies": {
+ "@lavamoat/allow-scripts": "^3.0.4",
+ "@metamask/auto-changelog": "^3.4.4",
+ "@metamask/controller-utils": "^11.4.3",
+ "@types/jest": "^27.4.1",
+ "deepmerge": "^4.2.2",
+ "jest": "^27.5.1",
+ "nock": "^13.3.1",
+ "ts-jest": "^27.1.4",
+ "typedoc": "^0.24.8",
+ "typedoc-plugin-missing-exports": "^2.0.0",
+ "typescript": "~5.2.2"
+ },
+ "engines": {
+ "node": "^18.18 || >=20"
+ }
+}
diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts
new file mode 100644
index 0000000000..a7def3bef5
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts
@@ -0,0 +1,9 @@
+import type { PublicInterface } from '@metamask/utils';
+
+import type { ClientConfigApiService } from './client-config-api-service';
+
+/**
+ * A service object responsible for fetching feature flags.
+ */
+export type AbstractClientConfigApiService =
+ PublicInterface;
diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts
new file mode 100644
index 0000000000..4d1932b0db
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts
@@ -0,0 +1,311 @@
+import type { FeatureFlags } from '../remote-feature-flag-controller-types';
+import {
+ ClientType,
+ DistributionType,
+ EnvironmentType,
+} from '../remote-feature-flag-controller-types';
+import { ClientConfigApiService } from './client-config-api-service';
+
+const BASE_URL = 'https://client-config.api.cx.metamask.io/v1';
+
+// eslint-disable-next-line jest/prefer-spy-on
+console.error = jest.fn();
+
+describe('ClientConfigApiService', () => {
+ let clientConfigApiService: ClientConfigApiService;
+ let mockFetch: jest.Mock;
+
+ const mockFeatureFlags: FeatureFlags = {
+ feature1: false,
+ feature2: { chrome: '<109' },
+ };
+
+ const networkError = new Error('Network error');
+ Object.assign(networkError, {
+ response: {
+ status: 503,
+ statusText: 'Service Unavailable',
+ },
+ });
+
+ beforeEach(() => {
+ mockFetch = jest.fn();
+ clientConfigApiService = new ClientConfigApiService({
+ fetch: mockFetch,
+ retries: 0,
+ config: {
+ client: ClientType.Extension,
+ distribution: DistributionType.Main,
+ environment: EnvironmentType.Production,
+ },
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should successfully fetch and return feature flags', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => mockFeatureFlags,
+ });
+
+ const result = await clientConfigApiService.fetchFlags();
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ `${BASE_URL}/flags?client=extension&distribution=main&environment=prod`,
+ { cache: 'no-cache' },
+ );
+
+ expect(result).toStrictEqual({
+ error: false,
+ message: 'Success',
+ statusCode: '200',
+ statusText: 'OK',
+ cachedData: mockFeatureFlags,
+ cacheTimestamp: expect.any(Number),
+ });
+ });
+
+ it('should return cached data when API request fails and cached data is available', async () => {
+ const cachedData = { feature3: true };
+ const cacheTimestamp = Date.now();
+
+ mockFetch.mockRejectedValueOnce(networkError);
+
+ const result = await clientConfigApiService.fetchFlags(
+ cachedData,
+ cacheTimestamp,
+ );
+
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Network error',
+ statusCode: '503',
+ statusText: 'Service Unavailable',
+ cachedData,
+ cacheTimestamp,
+ });
+ });
+
+ it('should return empty object when API request fails and cached data is not available', async () => {
+ mockFetch.mockRejectedValueOnce(networkError);
+ const result = await clientConfigApiService.fetchFlags();
+
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Network error',
+ statusCode: '503',
+ statusText: 'Service Unavailable',
+ cachedData: {},
+ cacheTimestamp: expect.any(Number),
+ });
+ });
+
+ it('should handle non-200 responses without cache data', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ });
+
+ const result = await clientConfigApiService.fetchFlags();
+ const currentTime = Date.now();
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Failed to fetch flags',
+ statusCode: '404',
+ statusText: 'Not Found',
+ cachedData: {},
+ cacheTimestamp: currentTime,
+ });
+ });
+
+ it('should handle non-200 responses with cache data', async () => {
+ const cachedData = { feature3: true };
+ const cacheTimestamp = Date.now();
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ });
+
+ const result = await clientConfigApiService.fetchFlags(
+ cachedData,
+ cacheTimestamp,
+ );
+
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Failed to fetch flags',
+ statusCode: '404',
+ statusText: 'Not Found',
+ cachedData,
+ cacheTimestamp,
+ });
+ });
+
+ it('should handle invalid API responses', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => null, // Invalid response
+ });
+
+ const result = await clientConfigApiService.fetchFlags();
+
+ const currentTime = Date.now();
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Invalid API response',
+ statusCode: null,
+ statusText: null,
+ cachedData: {},
+ cacheTimestamp: currentTime,
+ });
+ });
+
+ it('should retry the fetch the specified number of times on failure', async () => {
+ const maxRetries = 3;
+ clientConfigApiService = new ClientConfigApiService({
+ fetch: mockFetch,
+ retries: maxRetries,
+ config: {
+ client: ClientType.Extension,
+ distribution: DistributionType.Main,
+ environment: EnvironmentType.Production,
+ },
+ });
+
+ // Mock fetch to fail every time
+ mockFetch.mockRejectedValue(networkError);
+
+ const result = await clientConfigApiService.fetchFlags();
+ const currentTime = Date.now();
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Network error',
+ statusCode: '503',
+ statusText: 'Service Unavailable',
+ cachedData: {},
+ cacheTimestamp: currentTime,
+ });
+ // Check that fetch was retried the correct number of times
+ expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); // Initial + retries
+ });
+
+ it('should open the circuit breaker after consecutive failures', async () => {
+ const maxFailures = 3; // Set max consecutive failures for circuit breaker
+ clientConfigApiService = new ClientConfigApiService({
+ fetch: mockFetch,
+ maximumConsecutiveFailures: maxFailures,
+ config: {
+ client: ClientType.Extension,
+ distribution: DistributionType.Main,
+ environment: EnvironmentType.Production,
+ },
+ });
+
+ // Mock fetch to fail every time
+ mockFetch.mockRejectedValue(networkError);
+
+ // Trigger fetch attempts
+ for (let i = 0; i < maxFailures; i++) {
+ await clientConfigApiService.fetchFlags();
+ }
+
+ const result = await clientConfigApiService.fetchFlags();
+
+ expect(result).toStrictEqual({
+ error: true,
+ message: 'Execution prevented because the circuit breaker is open',
+ statusCode: null,
+ statusText: null,
+ cachedData: {},
+ cacheTimestamp: expect.any(Number),
+ });
+
+ // Check that fetch was called for each failure before the circuit breaker opened
+ expect(mockFetch).toHaveBeenCalledTimes(maxFailures);
+ });
+
+ it('should call the onDegraded callback when requests are slow', async () => {
+ const onDegraded = jest.fn();
+ const slowFetchTime = 5500; // Exceed the DEFAULT_DEGRADED_THRESHOLD (5000ms)
+
+ clientConfigApiService = new ClientConfigApiService({
+ fetch: mockFetch,
+ onDegraded,
+ config: {
+ client: ClientType.Extension,
+ distribution: DistributionType.Main,
+ environment: EnvironmentType.Production,
+ },
+ });
+
+ // Mock fetch to take a long time
+ mockFetch.mockImplementation(
+ () =>
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => mockFeatureFlags,
+ }),
+ slowFetchTime,
+ ),
+ ),
+ );
+
+ await clientConfigApiService.fetchFlags();
+
+ // Verify the degraded callback was called
+ expect(onDegraded).toHaveBeenCalled();
+ });
+
+ it('should succeed on a subsequent fetch attempt after retries', async () => {
+ const maxRetries = 2;
+ clientConfigApiService = new ClientConfigApiService({
+ fetch: mockFetch,
+ retries: maxRetries,
+ config: {
+ client: ClientType.Extension,
+ distribution: DistributionType.Main,
+ environment: EnvironmentType.Production,
+ },
+ });
+
+ // Mock fetch to fail initially, then succeed
+ mockFetch
+ .mockRejectedValueOnce(networkError) // First attempt fails
+ .mockRejectedValueOnce(networkError) // Second attempt fails
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => mockFeatureFlags, // Third attempt succeeds
+ });
+
+ const result = await clientConfigApiService.fetchFlags();
+
+ // Verify success on the third attempt
+ expect(result).toStrictEqual({
+ error: false,
+ message: 'Success',
+ statusCode: '200',
+ statusText: 'OK',
+ cachedData: mockFeatureFlags,
+ cacheTimestamp: expect.any(Number),
+ });
+
+ // Verify fetch was retried the correct number of times
+ expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); // Initial + retries
+ });
+});
diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts
new file mode 100644
index 0000000000..5e17a449a2
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts
@@ -0,0 +1,197 @@
+import {
+ circuitBreaker,
+ ConsecutiveBreaker,
+ ExponentialBackoff,
+ handleAll,
+ type IPolicy,
+ retry,
+ wrap,
+ CircuitState,
+} from 'cockatiel';
+
+import type {
+ FeatureFlags,
+ ClientType,
+ DistributionType,
+ EnvironmentType,
+} from '../remote-feature-flag-controller-types';
+
+type ApiResponse = {
+ error: boolean;
+ message: string;
+ statusCode: string | null;
+ statusText: string | null;
+ cachedData: FeatureFlags;
+ cacheTimestamp: number | null;
+};
+
+const DEFAULT_FETCH_RETRIES = 3;
+// Each update attempt will result (1 + retries) calls if the server is down
+const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_FETCH_RETRIES) * 3;
+
+const DEFAULT_DEGRADED_THRESHOLD = 5000;
+
+/**
+ * This service is responsible for fetching feature flags from the ClientConfig API.
+ */
+export class ClientConfigApiService {
+ #fetch: typeof fetch;
+
+ #policy: IPolicy;
+
+ #baseUrl = 'https://client-config.api.cx.metamask.io/v1';
+
+ #client: ClientType;
+
+ #distribution: DistributionType;
+
+ #environment: EnvironmentType;
+
+ /**
+ * Constructs a new ClientConfigApiService object.
+ *
+ * @param args - The arguments.
+ * @param args.fetch - A function that can be used to make an HTTP request.
+ * @param args.retries - Number of retry attempts for each fetch request.
+ * @param args.maximumConsecutiveFailures - The maximum number of consecutive failures
+ * allowed before breaking the circuit and pausing further fetch attempts.
+ * @param args.circuitBreakDuration - The duration for which the circuit remains open after
+ * too many consecutive failures.
+ * @param args.onBreak - Callback invoked when the circuit breaks.
+ * @param args.onDegraded - Callback invoked when the service is degraded (requests resolving too slowly).
+ * @param args.config - The configuration object, includes client, distribution, and environment.
+ * @param args.config.client - The client type (e.g., 'extension', 'mobile').
+ * @param args.config.distribution - The distribution type (e.g., 'main', 'flask').
+ * @param args.config.environment - The environment type (e.g., 'prod', 'rc', 'dev').
+ */
+ constructor({
+ fetch: fetchFunction,
+ retries = DEFAULT_FETCH_RETRIES,
+ maximumConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES,
+ circuitBreakDuration = 30 * 60 * 1000,
+ onBreak,
+ onDegraded,
+ config,
+ }: {
+ fetch: typeof fetch;
+ retries?: number;
+ maximumConsecutiveFailures?: number;
+ circuitBreakDuration?: number;
+ onBreak?: () => void;
+ onDegraded?: () => void;
+ config: {
+ client: ClientType;
+ distribution: DistributionType;
+ environment: EnvironmentType;
+ };
+ }) {
+ this.#fetch = fetchFunction;
+ this.#client = config.client;
+ this.#distribution = config.distribution;
+ this.#environment = config.environment;
+
+ const retryPolicy = retry(handleAll, {
+ maxAttempts: retries,
+ backoff: new ExponentialBackoff(),
+ });
+
+ const circuitBreakerPolicy = circuitBreaker(handleAll, {
+ halfOpenAfter: circuitBreakDuration,
+ breaker: new ConsecutiveBreaker(maximumConsecutiveFailures),
+ });
+
+ if (onBreak) {
+ circuitBreakerPolicy.onBreak(onBreak);
+ }
+
+ if (onDegraded) {
+ retryPolicy.onGiveUp(() => {
+ if (circuitBreakerPolicy.state === CircuitState.Closed) {
+ onDegraded();
+ }
+ });
+
+ retryPolicy.onSuccess(({ duration }) => {
+ if (
+ circuitBreakerPolicy.state === CircuitState.Closed &&
+ duration > DEFAULT_DEGRADED_THRESHOLD // Default degraded threshold
+ ) {
+ onDegraded();
+ }
+ });
+ }
+
+ this.#policy = wrap(retryPolicy, circuitBreakerPolicy);
+ }
+
+ /**
+ * Validate the API response.
+ *
+ * @param result - The result to validate.
+ * @throws Throws if the result is invalid.
+ */
+ #validate(result: unknown): asserts result is FeatureFlags {
+ if (typeof result !== 'object' || result === null) {
+ throw new Error('Invalid API response');
+ }
+ }
+
+ /**
+ * Fetches feature flags from the API with specific client, distribution, and environment parameters.
+ * Provides structured error handling, including fallback to cached data if available.
+ * @param cachedData - cachedData from controller state
+ * @param cacheTimestamp - timestamp of data being cached from controller state
+ * @returns An object of feature flags and their boolean values or a structured error object.
+ */
+ public async fetchFlags(
+ cachedData?: FeatureFlags,
+ cacheTimestamp?: number,
+ ): Promise {
+ const url = `${this.#baseUrl}/flags?client=${this.#client}&distribution=${
+ this.#distribution
+ }&environment=${this.#environment}`;
+
+ try {
+ const response = await this.#policy.execute(() =>
+ this.#fetch(url, { cache: 'no-cache' }),
+ );
+
+ if (!response || !response.ok) {
+ return {
+ error: true,
+ message: 'Failed to fetch flags',
+ statusCode: response?.status?.toString() || null,
+ statusText: response?.statusText || 'Error',
+ cachedData: cachedData || {},
+ cacheTimestamp: cacheTimestamp ?? Date.now(),
+ };
+ }
+
+ const data = await response.json();
+ this.#validate(data);
+
+ return {
+ error: false,
+ message: 'Success',
+ statusCode: response.status.toString(),
+ statusText: response.statusText || 'OK',
+ cachedData: data,
+ cacheTimestamp: Date.now(),
+ };
+ } catch (error) {
+ console.error('Feature flag API request failed:', error);
+
+ const err = error as Error & {
+ response?: { status: number; statusText: string };
+ };
+ return {
+ error: true,
+ message: err.message || 'Unknown error',
+ statusCode: err.response?.status?.toString() || null,
+ statusText: err.response?.statusText || null,
+ cachedData: cachedData || {}, // Return cached data if available
+ cacheTimestamp: cacheTimestamp || Date.now(),
+ };
+ }
+ }
+}
diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts
new file mode 100644
index 0000000000..56b3d6bf57
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/index.ts
@@ -0,0 +1,12 @@
+export { RemoteFeatureFlagController } from './remote-feature-flag-controller';
+
+export type {
+ RemoteFeatureFlagControllerState,
+ RemoteFeatureFlagControllerGetStateAction,
+ FeatureFlags,
+ ClientType,
+ DistributionType,
+ EnvironmentType,
+} from './remote-feature-flag-controller-types';
+export { getDefaultRemoteFeatureFlagControllerState } from './remote-feature-flag-controller-types';
+export { ClientConfigApiService } from './client-config-api-service/client-config-api-service';
diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts
new file mode 100644
index 0000000000..d7a21b8d67
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts
@@ -0,0 +1,57 @@
+import type { ControllerGetStateAction } from '@metamask/base-controller';
+import type { Json } from '@metamask/utils';
+
+// Define accepted values for client, distribution, and environment
+export enum ClientType {
+ Extension = 'extension',
+ Mobile = 'mobile',
+}
+
+export enum DistributionType {
+ Main = 'main',
+ Flask = 'flask',
+}
+
+export enum EnvironmentType {
+ Production = 'prod',
+ ReleaseCandidate = 'rc',
+ Development = 'dev',
+}
+
+/** Type representing the feature flags collection */
+export type FeatureFlags = Record;
+
+/**
+ * Describes the shape of the state object for the {@link RemoteFeatureFlagController}.
+ */
+export type RemoteFeatureFlagControllerState = {
+ /**
+ * The collection of feature flags and their respective values, which can be objects.
+ */
+ remoteFeatureFlags: FeatureFlags;
+ /**
+ * The timestamp of the last successful feature flag cache.
+ */
+ cacheTimestamp: number;
+};
+
+/**
+ * Constructs the default state for the {@link RemoteFeatureFlagController}.
+ *
+ * @returns The default {@link RemoteFeatureFlagController} state.
+ */
+export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagControllerState {
+ return {
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ };
+}
+
+/**
+ * The action to retrieve the state of the {@link RemoteFeatureFlagController}.
+ */
+export type RemoteFeatureFlagControllerGetStateAction =
+ ControllerGetStateAction<
+ 'RemoteFeatureFlagController',
+ RemoteFeatureFlagControllerState
+ >;
diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts
new file mode 100644
index 0000000000..259df18af1
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts
@@ -0,0 +1,241 @@
+import { ControllerMessenger } from '@metamask/base-controller';
+
+import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service';
+import {
+ RemoteFeatureFlagController,
+ controllerName,
+} from './remote-feature-flag-controller';
+import type {
+ RemoteFeatureFlagControllerActions,
+ RemoteFeatureFlagControllerMessenger,
+ RemoteFeatureFlagControllerState,
+ RemoteFeatureFlagControllerStateChangeEvent,
+} from './remote-feature-flag-controller';
+
+const mockFlags = {
+ feature1: true,
+ feature2: { chrome: '<109' },
+};
+
+/**
+ * Creates a controller instance with default parameters for testing
+ */
+/**
+ * Creates a controller instance with default parameters for testing
+ * @param options - The controller configuration options
+ * @param options.messenger - The controller messenger instance
+ * @param options.state - The initial controller state
+ * @param options.clientConfigApiService - The client config API service instance
+ * @param options.disabled - Whether the controller should start disabled
+ * @returns A configured RemoteFeatureFlagController instance
+ */
+function createController(
+ options: Partial<{
+ messenger: RemoteFeatureFlagControllerMessenger;
+ state: Partial;
+ clientConfigApiService: AbstractClientConfigApiService;
+ disabled: boolean;
+ }> = {},
+) {
+ return new RemoteFeatureFlagController({
+ messenger: options.messenger ?? getControllerMessenger(),
+ state: options.state ?? {},
+ clientConfigApiService:
+ options.clientConfigApiService ?? buildClientConfigApiService(),
+ disabled: options.disabled,
+ });
+}
+
+describe('RemoteFeatureFlagController', () => {
+ let clientConfigApiService: AbstractClientConfigApiService;
+
+ beforeEach(() => {
+ clientConfigApiService = buildClientConfigApiService();
+ jest.clearAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should initialize with default state', () => {
+ const controller = createController();
+
+ expect(controller.state).toStrictEqual({
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ });
+ });
+
+ it('should initialize with disabled parameter', () => {
+ const controller = createController({ disabled: true });
+
+ expect(controller.state).toStrictEqual({
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ });
+ });
+ });
+
+ describe('getRemoteFeatureFlags', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should get feature flags from API when cache is invalid', async () => {
+ const controller = createController({ clientConfigApiService });
+
+ const flags = await controller.getRemoteFeatureFlags();
+
+ expect(flags).toStrictEqual(mockFlags);
+ });
+
+ it('should return empty object when disabled', async () => {
+ const controller = createController({
+ clientConfigApiService,
+ disabled: true,
+ });
+
+ const remoteFeatureFlags = await controller.getRemoteFeatureFlags();
+
+ expect(remoteFeatureFlags).toStrictEqual({});
+ expect(clientConfigApiService.fetchFlags).not.toHaveBeenCalled();
+ });
+
+ it('should properly enable controller and make network request', async () => {
+ const controller = createController({
+ clientConfigApiService,
+ disabled: true,
+ });
+
+ controller.enable();
+
+ const remoteFeatureFlags = await controller.getRemoteFeatureFlags();
+ expect(remoteFeatureFlags).toStrictEqual(mockFlags);
+ expect(clientConfigApiService.fetchFlags).toHaveBeenCalledTimes(1);
+ });
+
+ it('should properly disable controller and not make network request', async () => {
+ const controller = createController({
+ clientConfigApiService,
+ disabled: false,
+ });
+
+ controller.disable();
+
+ const remoteFeatureFlags = await controller.getRemoteFeatureFlags();
+ expect(remoteFeatureFlags).toStrictEqual({});
+ expect(clientConfigApiService.fetchFlags).not.toHaveBeenCalled();
+ });
+
+ it('should not affect existing cache when toggling disabled state', async () => {
+ const controller = createController({
+ clientConfigApiService,
+ disabled: false,
+ });
+
+ // First, enable and get flags to populate cache
+ await controller.getRemoteFeatureFlags();
+ expect(controller.state.remoteFeatureFlags).toStrictEqual(mockFlags);
+
+ // Then disable and verify cache remains but is not accessible
+ controller.disable();
+ const remoteFeatureFlags = await controller.getRemoteFeatureFlags();
+ expect(remoteFeatureFlags).toStrictEqual({});
+ expect(controller.state.remoteFeatureFlags).toStrictEqual(mockFlags);
+ });
+
+ it('should use cached flags when cache is valid', async () => {
+ const controller = createController({ clientConfigApiService });
+
+ // First call to set cache
+ await controller.getRemoteFeatureFlags();
+
+ // Mock different response
+ jest
+ .spyOn(clientConfigApiService, 'fetchFlags')
+ .mockImplementation(async () => ({
+ error: false,
+ message: 'Success',
+ statusCode: '200',
+ statusText: 'OK',
+ cachedData: { differentFlag: true },
+ cacheTimestamp: Date.now(),
+ }));
+
+ const remoteFeatureFlags = await controller.getRemoteFeatureFlags();
+
+ expect(remoteFeatureFlags).toStrictEqual(mockFlags);
+ });
+
+ it('should handle concurrent flag updates', async () => {
+ const controller = createController();
+
+ const [result1, result2] = await Promise.all([
+ controller.getRemoteFeatureFlags(),
+ controller.getRemoteFeatureFlags(),
+ ]);
+
+ expect(result1).toStrictEqual(mockFlags);
+ expect(result2).toStrictEqual(mockFlags);
+ });
+
+ it('should emit state change when updating cache', async () => {
+ const rootMessenger = getRootControllerMessenger();
+ const stateChangeSpy = jest.fn();
+ rootMessenger.subscribe(`${controllerName}:stateChange`, stateChangeSpy);
+
+ const controller = createController({
+ messenger: getControllerMessenger(rootMessenger),
+ });
+
+ await controller.getRemoteFeatureFlags();
+
+ expect(stateChangeSpy).toHaveBeenCalled();
+ expect(controller.state.remoteFeatureFlags).toStrictEqual(mockFlags);
+ });
+ });
+});
+
+type RootAction = RemoteFeatureFlagControllerActions;
+type RootEvent = RemoteFeatureFlagControllerStateChangeEvent;
+
+/**
+ * Creates and returns a root controller messenger for testing
+ * @returns A controller messenger instance
+ */
+function getRootControllerMessenger(): ControllerMessenger<
+ RootAction,
+ RootEvent
+> {
+ return new ControllerMessenger();
+}
+
+/**
+ * Creates a restricted controller messenger for testing
+ * @param rootMessenger - The root messenger to restrict
+ * @returns A restricted controller messenger instance
+ */
+function getControllerMessenger(
+ rootMessenger = getRootControllerMessenger(),
+): RemoteFeatureFlagControllerMessenger {
+ return rootMessenger.getRestricted({
+ name: controllerName,
+ allowedActions: [],
+ allowedEvents: [],
+ });
+}
+
+/**
+ * Builds a mock client config API service for testing
+ * @returns A mock client config API service
+ */
+function buildClientConfigApiService(): AbstractClientConfigApiService {
+ return {
+ fetchFlags: jest.fn().mockResolvedValue({
+ error: false,
+ message: 'Success',
+ statusCode: '200',
+ statusText: 'OK',
+ cachedData: mockFlags,
+ cacheTimestamp: Date.now(),
+ }),
+ };
+}
diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts
new file mode 100644
index 0000000000..d74a273651
--- /dev/null
+++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts
@@ -0,0 +1,174 @@
+import type {
+ ControllerGetStateAction,
+ ControllerStateChangeEvent,
+ RestrictedControllerMessenger,
+} from '@metamask/base-controller';
+import { BaseController } from '@metamask/base-controller';
+
+import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service';
+import type { FeatureFlags } from './remote-feature-flag-controller-types';
+
+// === GENERAL ===
+
+export const controllerName = 'RemoteFeatureFlagController';
+const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day
+
+// === STATE ===
+
+export type RemoteFeatureFlagControllerState = {
+ remoteFeatureFlags: FeatureFlags;
+ cacheTimestamp: number;
+};
+
+const remoteFeatureFlagControllerMetadata = {
+ remoteFeatureFlags: { persist: true, anonymous: false },
+ cacheTimestamp: { persist: true, anonymous: true },
+};
+
+// === MESSENGER ===
+
+export type RemoteFeatureFlagControllerGetStateAction =
+ ControllerGetStateAction<
+ typeof controllerName,
+ RemoteFeatureFlagControllerState
+ >;
+
+export type RemoteFeatureFlagControllerGetRemoteFeatureFlagsAction = {
+ type: `${typeof controllerName}:getRemoteFeatureFlags`;
+ handler: RemoteFeatureFlagController['getRemoteFeatureFlags'];
+};
+
+export type RemoteFeatureFlagControllerActions =
+ RemoteFeatureFlagControllerGetStateAction;
+
+export type AllowedActions = never;
+
+export type RemoteFeatureFlagControllerStateChangeEvent =
+ ControllerStateChangeEvent<
+ typeof controllerName,
+ RemoteFeatureFlagControllerState
+ >;
+
+export type RemoteFeatureFlagControllerEvents =
+ RemoteFeatureFlagControllerStateChangeEvent;
+
+export type AllowedEvents = never;
+
+export type RemoteFeatureFlagControllerMessenger =
+ RestrictedControllerMessenger<
+ typeof controllerName,
+ RemoteFeatureFlagControllerActions | AllowedActions,
+ RemoteFeatureFlagControllerEvents | AllowedEvents,
+ AllowedActions['type'],
+ AllowedEvents['type']
+ >;
+
+/**
+ * Returns the default state for the RemoteFeatureFlagController
+ * @returns The default controller state
+ */
+export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagControllerState {
+ return {
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ };
+}
+
+// === CONTROLLER DEFINITION ===
+
+export class RemoteFeatureFlagController extends BaseController<
+ typeof controllerName,
+ RemoteFeatureFlagControllerState,
+ RemoteFeatureFlagControllerMessenger
+> {
+ readonly #fetchInterval: number;
+
+ #disabled: boolean;
+
+ #clientConfigApiService: AbstractClientConfigApiService;
+
+ #inProgressFlagUpdate?: Promise<{ cachedData: FeatureFlags }>;
+
+ constructor({
+ messenger,
+ state,
+ clientConfigApiService,
+ fetchInterval = DEFAULT_CACHE_DURATION,
+ disabled = false,
+ }: {
+ messenger: RemoteFeatureFlagControllerMessenger;
+ state: Partial;
+ clientConfigApiService: AbstractClientConfigApiService;
+ fetchInterval?: number;
+ disabled?: boolean;
+ }) {
+ super({
+ name: controllerName,
+ metadata: remoteFeatureFlagControllerMetadata,
+ messenger,
+ state: {
+ ...getDefaultRemoteFeatureFlagControllerState(),
+ ...state,
+ },
+ });
+
+ this.#fetchInterval = fetchInterval;
+ this.#disabled = disabled;
+ this.#clientConfigApiService = clientConfigApiService;
+ }
+
+ private isCacheValid(): boolean {
+ return Date.now() - this.state.cacheTimestamp < this.#fetchInterval;
+ }
+
+ async getRemoteFeatureFlags(): Promise {
+ if (this.#disabled) {
+ return {};
+ }
+
+ if (this.isCacheValid()) {
+ return this.state.remoteFeatureFlags;
+ }
+
+ if (this.#inProgressFlagUpdate) {
+ await this.#inProgressFlagUpdate;
+ }
+
+ try {
+ this.#inProgressFlagUpdate = this.#clientConfigApiService.fetchFlags();
+ const flags = await this.#inProgressFlagUpdate;
+
+ if (Object.keys(flags.cachedData).length > 0) {
+ this.updateCache(flags.cachedData);
+ return flags.cachedData;
+ }
+ } finally {
+ this.#inProgressFlagUpdate = undefined;
+ }
+
+ return this.state.remoteFeatureFlags;
+ }
+
+ private updateCache(remoteFeatureFlags: FeatureFlags) {
+ const newState: RemoteFeatureFlagControllerState = {
+ remoteFeatureFlags,
+ cacheTimestamp: Date.now(),
+ };
+
+ this.update(() => newState);
+ }
+
+ /**
+ * Allows controller to make network request
+ */
+ enable(): void {
+ this.#disabled = false;
+ }
+
+ /**
+ * Blocks controller from making network request
+ */
+ disable(): void {
+ this.#disabled = true;
+ }
+}
diff --git a/packages/remote-feature-flag-controller/tsconfig.build.json b/packages/remote-feature-flag-controller/tsconfig.build.json
new file mode 100644
index 0000000000..e5fd7422b9
--- /dev/null
+++ b/packages/remote-feature-flag-controller/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.packages.build.json",
+ "compilerOptions": {
+ "baseUrl": "./",
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "references": [{ "path": "../base-controller/tsconfig.build.json" }],
+ "include": ["../../types", "./src"]
+}
diff --git a/packages/remote-feature-flag-controller/tsconfig.json b/packages/remote-feature-flag-controller/tsconfig.json
new file mode 100644
index 0000000000..831cc7b867
--- /dev/null
+++ b/packages/remote-feature-flag-controller/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.packages.json",
+ "compilerOptions": {
+ "baseUrl": "./"
+ },
+ "references": [{ "path": "../../packages/base-controller" }],
+ "include": ["../../types", "./src"]
+}
diff --git a/packages/remote-feature-flag-controller/typedoc.json b/packages/remote-feature-flag-controller/typedoc.json
new file mode 100644
index 0000000000..c9da015dbf
--- /dev/null
+++ b/packages/remote-feature-flag-controller/typedoc.json
@@ -0,0 +1,7 @@
+{
+ "entryPoints": ["./src/index.ts"],
+ "excludePrivate": true,
+ "hideGenerator": true,
+ "out": "docs",
+ "tsconfig": "./tsconfig.build.json"
+}
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 6102878c56..12d1120de3 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -32,6 +32,7 @@
{ "path": "./packages/profile-sync-controller/tsconfig.build.json" },
{ "path": "./packages/queued-request-controller/tsconfig.build.json" },
{ "path": "./packages/rate-limit-controller/tsconfig.build.json" },
+ { "path": "./packages/remote-feature-flag-controller/tsconfig.build.json" },
{ "path": "./packages/selected-network-controller/tsconfig.build.json" },
{ "path": "./packages/signature-controller/tsconfig.build.json" },
{ "path": "./packages/transaction-controller/tsconfig.build.json" },
diff --git a/tsconfig.json b/tsconfig.json
index 127a643b9d..f0c8f813c4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,6 +30,7 @@
{ "path": "./packages/profile-sync-controller" },
{ "path": "./packages/queued-request-controller" },
{ "path": "./packages/rate-limit-controller" },
+ { "path": "./packages/remote-feature-flag-controller" },
{ "path": "./packages/selected-network-controller" },
{ "path": "./packages/signature-controller" },
{ "path": "./packages/transaction-controller" },
diff --git a/yarn.lock b/yarn.lock
index 21b527d7fc..7f21918924 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3437,6 +3437,27 @@ __metadata:
languageName: unknown
linkType: soft
+"@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller":
+ version: 0.0.0-use.local
+ resolution: "@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller"
+ dependencies:
+ "@lavamoat/allow-scripts": "npm:^3.0.4"
+ "@metamask/auto-changelog": "npm:^3.4.4"
+ "@metamask/base-controller": "npm:^7.0.2"
+ "@metamask/controller-utils": "npm:^11.4.3"
+ "@metamask/utils": "npm:^10.0.0"
+ "@types/jest": "npm:^27.4.1"
+ cockatiel: "npm:^3.1.2"
+ deepmerge: "npm:^4.2.2"
+ jest: "npm:^27.5.1"
+ nock: "npm:^13.3.1"
+ ts-jest: "npm:^27.1.4"
+ typedoc: "npm:^0.24.8"
+ typedoc-plugin-missing-exports: "npm:^2.0.0"
+ typescript: "npm:~5.2.2"
+ languageName: unknown
+ linkType: soft
+
"@metamask/rpc-errors@npm:^6.2.1":
version: 6.3.1
resolution: "@metamask/rpc-errors@npm:6.3.1"