diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 35fd65a9d04..fb45a791f6d 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add override functionality to remote feature flags ([#7271](https://github.com/MetaMask/core/pull/7271)) + - `setFlagOverride(flagName, value)` - Set a local override for a specific feature flag + - `removeFlagOverride(flagName)` - Clear the local override for a specific feature flag + - `clearAllFlagOverrides()` - Clear all local feature flag overrides +- Add new controller state properties ([#7271](https://github.com/MetaMask/core/pull/7271)) + - `localOverrides` - Local overrides for feature flags that take precedence over remote flags + - `rawRemoteFeatureFlags` - Raw flag value for all feature flags +- Export additional controller action types ([#7271](https://github.com/MetaMask/core/pull/7271)) + - `RemoteFeatureFlagControllerSetFlagOverrideAction` + - `RemoteFeatureFlagControllerremoveFlagOverrideAction` + - `RemoteFeatureFlagControllerclearAllFlagOverridesAction` + ## [3.0.0] ### Added diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 9906d3bc04d..f4064ddfb53 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -5,6 +5,9 @@ export type { RemoteFeatureFlagControllerActions, RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction, + RemoteFeatureFlagControllerSetFlagOverrideAction, + RemoteFeatureFlagControllerRemoveFlagOverrideAction, + RemoteFeatureFlagControllerClearAllFlagOverridesAction, RemoteFeatureFlagControllerEvents, RemoteFeatureFlagControllerStateChangeEvent, } from './remote-feature-flag-controller'; 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 index 6bf8e5ea2fc..d480ae3a915 100644 --- 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 @@ -51,3 +51,25 @@ export type ServiceResponse = { remoteFeatureFlags: FeatureFlags; cacheTimestamp: number | null; }; + +/** + * 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; + /** + * Local overrides for feature flags that take precedence over remote flags. + */ + localOverrides: FeatureFlags; + /** + * Raw flag for all feature flags. + */ + rawRemoteFeatureFlags: FeatureFlags; + /** + * The timestamp of the last successful feature flag cache. + */ + cacheTimestamp: number; +}; 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 index 4ac1f584ad0..bdd12cebdfb 100644 --- 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 @@ -9,6 +9,7 @@ import type { import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; import { RemoteFeatureFlagController, + controllerName, DEFAULT_CACHE_DURATION, getDefaultRemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; @@ -18,8 +19,6 @@ import type { } from './remote-feature-flag-controller'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; -const controllerName = 'RemoteFeatureFlagController'; - const MOCK_FLAGS: FeatureFlags = { feature1: true, feature2: { chrome: '<109' }, @@ -88,6 +87,8 @@ describe('RemoteFeatureFlagController', () => { expect(controller.state).toStrictEqual({ remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, cacheTimestamp: 0, }); }); @@ -97,6 +98,8 @@ describe('RemoteFeatureFlagController', () => { expect(controller.state).toStrictEqual({ remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, cacheTimestamp: 0, }); }); @@ -105,6 +108,8 @@ describe('RemoteFeatureFlagController', () => { const customState = { remoteFeatureFlags: MOCK_FLAGS_TWO, cacheTimestamp: 123456789, + rawRemoteFeatureFlags: {}, + localOverrides: {}, }; const controller = createController({ state: customState }); @@ -640,11 +645,141 @@ describe('RemoteFeatureFlagController', () => { it('should return default state', () => { expect(getDefaultRemoteFeatureFlagControllerState()).toStrictEqual({ remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, cacheTimestamp: 0, }); }); }); + describe('override functionality', () => { + describe('setFlagOverride', () => { + it('sets a local override for a feature flag', () => { + const controller = createController(); + + controller.setFlagOverride('testFlag', true); + + expect(controller.state.localOverrides).toStrictEqual({ + testFlag: true, + }); + }); + + it('overwrites existing override for the same flag', () => { + const controller = createController({ + state: { + localOverrides: { + testFlag: true, + }, + }, + }); + + controller.setFlagOverride('testFlag', false); + + expect(controller.state.localOverrides).toStrictEqual({ + testFlag: false, + }); + }); + + it('preserves other overrides when setting a new one', () => { + const controller = createController({ + state: { + localOverrides: { + flag1: 'value1', + }, + }, + }); + + controller.setFlagOverride('flag2', 'value2'); + + expect(controller.state.localOverrides).toStrictEqual({ + flag1: 'value1', + flag2: 'value2', + }); + }); + }); + + describe('removeFlagOverride', () => { + it('removes a specific override', () => { + const controller = createController({ + state: { + localOverrides: { + flag1: 'value1', + flag2: 'value2', + }, + }, + }); + + controller.removeFlagOverride('flag1'); + + expect(controller.state.localOverrides).toStrictEqual({ + flag2: 'value2', + }); + }); + + it('does not affect state when clearing non-existent override', () => { + const controller = createController({ + state: { + localOverrides: { + flag1: 'value1', + }, + }, + }); + + controller.removeFlagOverride('nonExistentFlag'); + + expect(controller.state.localOverrides).toStrictEqual({ + flag1: 'value1', + }); + }); + }); + + describe('clearAllFlagOverrides', () => { + it('removes all overrides', () => { + const controller = createController({ + state: { + localOverrides: { + flag1: 'value1', + flag2: 'value2', + }, + }, + }); + + controller.clearAllFlagOverrides(); + + expect(controller.state.localOverrides).toStrictEqual({}); + }); + + it('does not affect state when no overrides exist', () => { + const controller = createController(); + + controller.clearAllFlagOverrides(); + + expect(controller.state.localOverrides).toStrictEqual({}); + }); + }); + + describe('integration with remote flags', () => { + it('preserves overrides when remote flags are updated', async () => { + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: { remoteFlag: 'initialRemoteValue' }, + }); + const controller = createController({ clientConfigApiService }); + + // Set overrides before fetching remote flags + controller.setFlagOverride('overrideFlag', 'overrideValue'); + controller.setFlagOverride('remoteFlag', 'updatedRemoteValue'); + + await controller.updateRemoteFeatureFlags(); + + // Overrides should be preserved when remote flags are updated. + expect(controller.state.localOverrides).toStrictEqual({ + overrideFlag: 'overrideValue', + remoteFlag: 'updatedRemoteValue', + }); + }); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const controller = createController(); @@ -658,6 +793,8 @@ describe('RemoteFeatureFlagController', () => { ).toMatchInlineSnapshot(` Object { "cacheTimestamp": 0, + "localOverrides": Object {}, + "rawRemoteFeatureFlags": Object {}, "remoteFeatureFlags": Object {}, } `); @@ -675,6 +812,8 @@ describe('RemoteFeatureFlagController', () => { ).toMatchInlineSnapshot(` Object { "cacheTimestamp": 0, + "localOverrides": Object {}, + "rawRemoteFeatureFlags": Object {}, "remoteFeatureFlags": Object {}, } `); @@ -692,6 +831,8 @@ describe('RemoteFeatureFlagController', () => { ).toMatchInlineSnapshot(` Object { "cacheTimestamp": 0, + "localOverrides": Object {}, + "rawRemoteFeatureFlags": Object {}, "remoteFeatureFlags": Object {}, } `); @@ -708,6 +849,7 @@ describe('RemoteFeatureFlagController', () => { ), ).toMatchInlineSnapshot(` Object { + "localOverrides": Object {}, "remoteFeatureFlags": Object {}, } `); 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 index b1fee7ac67c..8db1702dc0c 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -21,13 +21,15 @@ import { isVersionFeatureFlag, getVersionData } from './utils/version'; // === GENERAL === -const controllerName = 'RemoteFeatureFlagController'; +export const controllerName = 'RemoteFeatureFlagController'; export const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day // === STATE === export type RemoteFeatureFlagControllerState = { remoteFeatureFlags: FeatureFlags; + localOverrides: FeatureFlags; + rawRemoteFeatureFlags: FeatureFlags; cacheTimestamp: number; }; @@ -38,6 +40,18 @@ const remoteFeatureFlagControllerMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, + localOverrides: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + rawRemoteFeatureFlags: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, cacheTimestamp: { includeInStateLogs: true, persist: true, @@ -62,9 +76,27 @@ export type RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction = { handler: RemoteFeatureFlagController['updateRemoteFeatureFlags']; }; +export type RemoteFeatureFlagControllerSetFlagOverrideAction = { + type: `${typeof controllerName}:setFlagOverride`; + handler: RemoteFeatureFlagController['setFlagOverride']; +}; + +export type RemoteFeatureFlagControllerRemoveFlagOverrideAction = { + type: `${typeof controllerName}:removeFlagOverride`; + handler: RemoteFeatureFlagController['removeFlagOverride']; +}; + +export type RemoteFeatureFlagControllerClearAllFlagOverridesAction = { + type: `${typeof controllerName}:clearAllFlagOverrides`; + handler: RemoteFeatureFlagController['clearAllFlagOverrides']; +}; + export type RemoteFeatureFlagControllerActions = | RemoteFeatureFlagControllerGetStateAction - | RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction; + | RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction + | RemoteFeatureFlagControllerSetFlagOverrideAction + | RemoteFeatureFlagControllerRemoveFlagOverrideAction + | RemoteFeatureFlagControllerClearAllFlagOverridesAction; export type RemoteFeatureFlagControllerStateChangeEvent = ControllerStateChangeEvent< @@ -89,6 +121,8 @@ export type RemoteFeatureFlagControllerMessenger = Messenger< export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagControllerState { return { remoteFeatureFlags: {}, + localOverrides: {}, + rawRemoteFeatureFlags: {}, cacheTimestamp: 0, }; } @@ -217,7 +251,9 @@ export class RemoteFeatureFlagController extends BaseController< await this.#processRemoteFeatureFlags(remoteFeatureFlags); this.update(() => { return { + ...this.state, remoteFeatureFlags: processedRemoteFeatureFlags, + rawRemoteFeatureFlags: remoteFeatureFlags, cacheTimestamp: Date.now(), }; }); @@ -291,4 +327,50 @@ export class RemoteFeatureFlagController extends BaseController< disable(): void { this.#disabled = true; } + + /** + * Sets a local override for a specific feature flag. + * + * @param flagName - The name of the feature flag to override. + * @param value - The override value for the feature flag. + */ + setFlagOverride(flagName: string, value: Json): void { + this.update(() => { + return { + ...this.state, + localOverrides: { + ...this.state.localOverrides, + [flagName]: value, + }, + }; + }); + } + + /** + * Clears the local override for a specific feature flag. + * + * @param flagName - The name of the feature flag to clear. + */ + removeFlagOverride(flagName: string): void { + const newLocalOverrides = { ...this.state.localOverrides }; + delete newLocalOverrides[flagName]; + this.update(() => { + return { + ...this.state, + localOverrides: newLocalOverrides, + }; + }); + } + + /** + * Clears all local feature flag overrides. + */ + clearAllFlagOverrides(): void { + this.update(() => { + return { + ...this.state, + localOverrides: {}, + }; + }); + } } diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 334785a8e3c..60d2046d9c7 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -60,6 +60,7 @@ import { buildEthSendRawTransactionRequestMock, buildEthGetTransactionReceiptRequestMock, } from '../tests/JsonRpcRequestMocks'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../remote-feature-flag-controller/src/remote-feature-flag-controller'; jest.mock('uuid', () => { const actual = jest.requireActual('uuid'); @@ -278,7 +279,7 @@ const setupController = async ( remoteFeatureFlagControllerMessenger.registerActionHandler( 'RemoteFeatureFlagController:getState', - () => ({ cacheTimestamp: 0, remoteFeatureFlags: {} }), + () => getDefaultRemoteFeatureFlagControllerState(), ); const options: TransactionControllerOptions = { diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index fa39f35de50..ee03a1d0b38 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -74,6 +74,8 @@ describe('Feature Flags Utils', () => { getFeatureFlagsMock.mockReturnValue({ cacheTimestamp: 0, remoteFeatureFlags: featureFlags, + rawRemoteFeatureFlags: {}, + localOverrides: {}, }); } diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts index cc66be6101c..18f194f5546 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts @@ -20,6 +20,7 @@ import type { import type { QuoteRequest } from '../../types'; import { calculateGasCost, calculateTransactionGasCost } from '../../utils/gas'; import { getTokenFiatRate } from '../../utils/token'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; jest.mock('../../utils/token'); jest.mock('../../utils/gas'); @@ -126,7 +127,7 @@ describe('Bridge Quotes Utils', () => { }); getRemoteFeatureFlagControllerStateMock.mockImplementation(() => ({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: getFeatureFlagsMock(), }, @@ -1009,7 +1010,7 @@ describe('Bridge Quotes Utils', () => { describe('getBridgeRefreshInterval', () => { it('returns chain interval from feature flags', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { bridgeConfigV2: { chains: { @@ -1031,7 +1032,7 @@ describe('Bridge Quotes Utils', () => { it('returns global interval from feature flags', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { bridgeConfigV2: { chains: { @@ -1054,7 +1055,7 @@ describe('Bridge Quotes Utils', () => { it('returns undefined if no chain or global interval', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { bridgeConfigV2: { chains: { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 8f17983a1e7..7e39081fe0a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -34,6 +34,7 @@ import { getTokenBalance, getTokenFiatRate, } from '../../utils/token'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; jest.mock('../../utils/token'); jest.mock('../../utils/gas'); @@ -192,8 +193,7 @@ describe('Relay Quotes Utils', () => { }); getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, - remoteFeatureFlags: {}, + ...getDefaultRemoteFeatureFlagControllerState(), }); getGasBufferMock.mockReturnValue(1.0); @@ -565,7 +565,7 @@ describe('Relay Quotes Utils', () => { const relayQuoteUrl = 'https://test.com/quote'; getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { relayQuoteUrl, @@ -925,7 +925,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { relayDisabledGasStationChains: [QUOTE_REQUEST_MOCK.sourceChainId], diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 31e789d8e78..8cd63cce96e 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -8,6 +8,7 @@ import { getGasBuffer, } from './feature-flags'; import { getMessengerMock } from '../tests/messenger-mock'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; const GAS_FALLBACK_ESTIMATE_MOCK = 123; const GAS_FALLBACK_MAX_MOCK = 456; @@ -27,8 +28,7 @@ describe('Feature Flags Utils', () => { jest.resetAllMocks(); getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, - remoteFeatureFlags: {}, + ...getDefaultRemoteFeatureFlagControllerState(), }); }); @@ -49,7 +49,7 @@ describe('Feature Flags Utils', () => { it('returns feature flags', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { relayDisabledGasStationChains: @@ -87,7 +87,7 @@ describe('Feature Flags Utils', () => { it('returns default gas buffer from feature flags when set', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { gasBuffer: { @@ -104,7 +104,7 @@ describe('Feature Flags Utils', () => { it('returns per-chain gas buffer when set for specific chain', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { gasBuffer: { @@ -127,7 +127,7 @@ describe('Feature Flags Utils', () => { it('falls back to default gas buffer when per-chain config exists but specific chain is not found', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { gasBuffer: { @@ -150,7 +150,7 @@ describe('Feature Flags Utils', () => { it('falls back to hardcoded default when per-chain config exists but no default is set', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - cacheTimestamp: 0, + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { gasBuffer: {