diff --git a/app/scripts/background.js b/app/scripts/background.js index 2b2f5c4693df..be72e879e627 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -772,7 +772,6 @@ export function setupController( // // MetaMask Controller // - controller = new MetamaskController({ infuraProjectId: process.env.INFURA_PROJECT_ID, // User confirmation callbacks: @@ -892,6 +891,8 @@ export function setupController( controller.isClientOpen = true; controller.setupTrustedCommunication(portStream, remotePort.sender); + initializeRemoteFeatureFlags(); + if (processName === ENVIRONMENT_TYPE_POPUP) { openPopupCount += 1; finished(portStream, () => { @@ -1094,6 +1095,22 @@ export function setupController( } } + /** + * Initializes remote feature flags by making a request to fetch them from the clientConfigApi. + * This function is called when MM is during internal process. + * If the request fails, the error will be logged but won't interrupt extension initialization. + * + * @returns {Promise} A promise that resolves when the remote feature flags have been updated. + */ + async function initializeRemoteFeatureFlags() { + try { + // initialize the request to fetch remote feature flags + await controller.remoteFeatureFlagController.updateRemoteFeatureFlags(); + } catch (error) { + log.error('Error initializing remote feature flags:', error); + } + } + function getPendingApprovalCount() { try { let pendingApprovalCount = diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 9823b2ada540..1087b5ac608b 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -270,6 +270,10 @@ export const SENTRY_BACKGROUND_STATE = { useTransactionSimulations: true, enableMV3TimestampSave: true, }, + RemoteFeatureFlagController: { + remoteFeatureFlags: true, + cacheTimestamp: false, + }, NotificationServicesPushController: { fcmToken: false, }, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1eaf354b4caf..ae42d59db747 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -104,6 +104,13 @@ import { } from '@metamask/controller-utils'; import { AccountsController } from '@metamask/accounts-controller'; +import { + RemoteFeatureFlagController, + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import { @@ -241,6 +248,7 @@ import { endTrace, trace } from '../../shared/lib/trace'; // eslint-disable-next-line import/no-restricted-paths import { isSnapId } from '../../ui/helpers/utils/snaps'; import { BridgeStatusAction } from '../../shared/types/bridge-status'; +import { ENVIRONMENT } from '../../development/build/constants'; import fetchWithCache from '../../shared/lib/fetch-with-cache'; import { BalancesController as MultichainBalancesController } from './lib/accounts/BalancesController'; import { @@ -393,6 +401,17 @@ const PHISHING_SAFELIST = 'metamask-phishing-safelist'; // OneKey devices can connect to Metamask using Trezor USB transport. They use a specific device minor version (99) to differentiate between genuine Trezor and OneKey devices. export const ONE_KEY_VIA_TREZOR_MINOR_VERSION = 99; +const environmentMappingForRemoteFeatureFlag = { + [ENVIRONMENT.DEVELOPMENT]: EnvironmentType.Development, + [ENVIRONMENT.RELEASE_CANDIDATE]: EnvironmentType.ReleaseCandidate, + [ENVIRONMENT.PRODUCTION]: EnvironmentType.Production, +}; + +const buildTypeMappingForRemoteFeatureFlag = { + flask: DistributionType.Flask, + main: DistributionType.Main, +}; + export default class MetamaskController extends EventEmitter { /** * @param {object} opts @@ -2342,6 +2361,40 @@ export default class MetamaskController extends EventEmitter { clearPendingConfirmations.bind(this), ); + // RemoteFeatureFlagController has subscription for preferences changes + this.controllerMessenger.subscribe( + 'PreferencesController:stateChange', + previousValueComparator((prevState, currState) => { + const { useExternalServices: prevUseExternalServices } = prevState; + const { useExternalServices: currUseExternalServices } = currState; + if (currUseExternalServices && !prevUseExternalServices) { + this.remoteFeatureFlagController.enable(); + this.remoteFeatureFlagController.updateRemoteFeatureFlags(); + } else if (!currUseExternalServices && prevUseExternalServices) { + this.remoteFeatureFlagController.disable(); + } + }, this.preferencesController.state), + ); + + // Initialize RemoteFeatureFlagController + this.remoteFeatureFlagController = new RemoteFeatureFlagController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'RemoteFeatureFlagController', + allowedActions: [], + allowedEvents: [], + }), + disabled: !this.preferencesController.state.useExternalServices, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch.bind(globalThis), + config: { + client: ClientType.Extension, + distribution: + this._getConfigForRemoteFeatureFlagRequest().distribution, + environment: this._getConfigForRemoteFeatureFlagRequest().environment, + }, + }), + }); + this.metamaskMiddleware = createMetamaskMiddleware({ static: { eth_syncing: false, @@ -2508,6 +2561,7 @@ export default class MetamaskController extends EventEmitter { NotificationServicesController: this.notificationServicesController, NotificationServicesPushController: this.notificationServicesPushController, + RemoteFeatureFlagController: this.remoteFeatureFlagController, ...resetOnRestartStore, }); @@ -2563,6 +2617,7 @@ export default class MetamaskController extends EventEmitter { QueuedRequestController: this.queuedRequestController, NotificationServicesPushController: this.notificationServicesPushController, + RemoteFeatureFlagController: this.remoteFeatureFlagController, ...resetOnRestartStore, }, controllerMessenger: this.controllerMessenger, @@ -7364,6 +7419,17 @@ export default class MetamaskController extends EventEmitter { }; } + _getConfigForRemoteFeatureFlagRequest() { + const distribution = + buildTypeMappingForRemoteFeatureFlag[process.env.METAMASK_BUILD_TYPE] || + DistributionType.Main; + const environment = + environmentMappingForRemoteFeatureFlag[ + process.env.METAMASK_ENVIRONMENT + ] || EnvironmentType.Development; + return { distribution, environment }; + } + #checkTokenListPolling(currentState, previousState) { const previousEnabled = this.#isTokenListPollingRequired(previousState); const newEnabled = this.#isTokenListPollingRequired(currentState); diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index f7115f9364bd..29e6f3a954d6 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -42,6 +42,7 @@ import { flushPromises } from '../../test/lib/timer-helpers'; import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { createMockInternalAccount } from '../../test/jest/mocks'; import { mockNetworkState } from '../../test/stub/networks'; +import { ENVIRONMENT } from '../../development/build/constants'; import { SECOND } from '../../shared/constants/time'; import { BalancesController as MultichainBalancesController, @@ -2607,6 +2608,131 @@ describe('MetaMaskController', () => { ); }); }); + + describe('RemoteFeatureFlagController', () => { + let localMetamaskController; + + beforeEach(() => { + localMetamaskController = new MetaMaskController({ + showUserConfirmation: noop, + encryptor: mockEncryptor, + initState: { + ...cloneDeep(firstTimeState), + PreferencesController: { + useExternalServices: false, + }, + }, + initLangCode: 'en_US', + platform: { + showTransactionNotification: () => undefined, + getVersion: () => 'foo', + }, + browser: browserPolyfillMock, + infuraProjectId: 'foo', + isFirstMetaMaskControllerSetup: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize RemoteFeatureFlagController in disabled state when useExternalServices is false', async () => { + const { remoteFeatureFlagController, preferencesController } = + localMetamaskController; + + expect(preferencesController.state.useExternalServices).toBe(false); + expect(remoteFeatureFlagController.state).toStrictEqual({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + + it('should disable feature flag fetching when useExternalServices is disabled', async () => { + const { remoteFeatureFlagController } = localMetamaskController; + + // First enable external services + await simulatePreferencesChange({ + useExternalServices: true, + }); + + // Then disable them + await simulatePreferencesChange({ + useExternalServices: false, + }); + + expect(remoteFeatureFlagController.state).toStrictEqual({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + + it('should handle errors during feature flag updates', async () => { + const { remoteFeatureFlagController } = localMetamaskController; + const mockError = new Error('Failed to fetch'); + + jest + .spyOn(remoteFeatureFlagController, 'updateRemoteFeatureFlags') + .mockRejectedValue(mockError); + + await simulatePreferencesChange({ + useExternalServices: true, + }); + + expect(remoteFeatureFlagController.state).toStrictEqual({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + + it('should maintain feature flag state across preference toggles', async () => { + const { remoteFeatureFlagController } = localMetamaskController; + const mockFlags = { testFlag: true }; + + jest + .spyOn(remoteFeatureFlagController, 'updateRemoteFeatureFlags') + .mockResolvedValue(mockFlags); + + // Enable external services + await simulatePreferencesChange({ + useExternalServices: true, + }); + + // Disable external services + await simulatePreferencesChange({ + useExternalServices: false, + }); + + // Verify state is cleared + expect(remoteFeatureFlagController.state).toStrictEqual({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + }); + + describe('_getConfigForRemoteFeatureFlagRequest', () => { + it('returns config in mapping', async () => { + const result = + await metamaskController._getConfigForRemoteFeatureFlagRequest(); + expect(result).toStrictEqual({ + distribution: 'main', + environment: 'dev', + }); + }); + + it('returna config when not matching default mapping', async () => { + process.env.METAMASK_BUILD_TYPE = 'beta'; + process.env.METAMASK_ENVIRONMENT = ENVIRONMENT.RELEASE_CANDIDATE; + + const result = + await metamaskController._getConfigForRemoteFeatureFlagRequest(); + expect(result).toStrictEqual({ + distribution: 'main', + environment: 'rc', + }); + }); + }); }); describe('onFeatureFlagResponseReceived', () => { diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 47227aeef932..77c0940ef550 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2338,6 +2338,12 @@ "semver": true } }, + "@metamask/remote-feature-flag-controller": { + "packages": { + "@metamask/base-controller": true, + "cockatiel": true + } + }, "@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 47227aeef932..77c0940ef550 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2338,6 +2338,12 @@ "semver": true } }, + "@metamask/remote-feature-flag-controller": { + "packages": { + "@metamask/base-controller": true, + "cockatiel": true + } + }, "@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 47227aeef932..77c0940ef550 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2338,6 +2338,12 @@ "semver": true } }, + "@metamask/remote-feature-flag-controller": { + "packages": { + "@metamask/base-controller": true, + "cockatiel": true + } + }, "@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 89eedc822794..142c348220e3 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2430,6 +2430,12 @@ "semver": true } }, + "@metamask/remote-feature-flag-controller": { + "packages": { + "@metamask/base-controller": true, + "cockatiel": true + } + }, "@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/package.json b/package.json index 803853059703..4dd6f2eed3b8 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ "@metamask/providers": "^18.2.0", "@metamask/queued-request-controller": "^7.0.1", "@metamask/rate-limit-controller": "^6.0.0", + "@metamask/remote-feature-flag-controller": "^1.1.0", "@metamask/rpc-errors": "^7.0.0", "@metamask/safe-event-emitter": "^3.1.1", "@metamask/scure-bip39": "^2.0.3", diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 5620903a5c73..d133a74b3b30 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -16,6 +16,7 @@ "cdn.segment.io", "cdnjs.cloudflare.com", "chainid.network", + "client-config.api.cx.metamask.io", "client-side-detection.api.cx.metamask.io", "configuration.dev.metamask-institutional.io", "configuration.metamask-institutional.io", diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 7269bda82153..d2dc32297737 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -235,6 +235,12 @@ class FixtureBuilder { }); } + withUseBasicFunctionalityDisabled() { + return this.withPreferencesController({ + useExternalServices: false, + }); + } + withGasFeeController(data) { merge(this.fixture.data.GasFeeController, data); return this; diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index fc6b1ea4397a..2c56c5797a32 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -125,7 +125,6 @@ async function setupMocking( }); const mockedEndpoint = await testSpecificMock(server); - // Mocks below this line can be overridden by test-specific mocks // Account link @@ -738,6 +737,22 @@ async function setupMocking( }; }); + // remote feature flags + await server + .forGet('https://client-config.api.cx.metamask.io/v1/flags') + .withQuery({ + client: 'extension', + distribution: 'main', + environment: 'dev', + }) + .thenCallback(() => { + return { + ok: true, + statusCode: 200, + json: [{ feature1: true }, { feature2: false }], + }; + }); + /** * Returns an array of alphanumerically sorted hostnames that were requested * during the current test suite. diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 8351032761a7..ecd0b1d0db7a 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -233,17 +233,17 @@ "petnamesEnabled": true, "showMultiRpcModal": "boolean", "isRedesignedConfirmationsDeveloperEnabled": "boolean", - "redesignedConfirmationsEnabled": true, - "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", + "shouldShowAggregatedBalancePopover": "boolean", "tokenNetworkFilter": { "0x1": "boolean", - "0x539": "boolean", "0xaa36a7": "boolean", "0xe705": "boolean", - "0xe708": "boolean" + "0xe708": "boolean", + "0x539": "boolean" }, - "shouldShowAggregatedBalancePopover": "boolean" + "redesignedConfirmationsEnabled": true, + "redesignedTransactionsEnabled": "boolean" }, "ipfsGateway": "string", "isIpfsGatewayEnabled": "boolean", @@ -260,6 +260,10 @@ "showIncomingTransactions": "object" }, "QueuedRequestController": { "queuedRequestCount": 0 }, + "RemoteFeatureFlagController": { + "remoteFeatureFlags": { "feature1": true, "feature2": false }, + "cacheTimestamp": "number" + }, "SelectedNetworkController": { "domains": "object" }, "SignatureController": { "signatureRequests": "object", @@ -320,15 +324,13 @@ "swapsFeatureFlags": {} } }, - "TokenBalancesController": { - "tokenBalances": "object" - }, + "TokenBalancesController": { "tokenBalances": "object" }, "TokenListController": { "tokenList": "object", "tokensChainsCache": { "0x1": "object", - "0x539": "object", - "0xe708": "object" + "0xe708": "object", + "0x539": "object" }, "preventPollingOnNetworkRestart": false }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 5d79ca5964b8..ccecc8669c81 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -36,14 +36,14 @@ "showMultiRpcModal": "boolean", "isRedesignedConfirmationsDeveloperEnabled": "boolean", "tokenSortConfig": "object", + "shouldShowAggregatedBalancePopover": "boolean", "tokenNetworkFilter": { "0x1": "boolean", - "0x539": "boolean", "0xaa36a7": "boolean", "0xe705": "boolean", - "0xe708": "boolean" + "0xe708": "boolean", + "0x539": "boolean" }, - "shouldShowAggregatedBalancePopover": "boolean", "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean" }, @@ -181,10 +181,9 @@ "tokenList": "object", "tokensChainsCache": { "0x1": "object", - "0x539": "object", - "0xe708": "object" + "0xe708": "object", + "0x539": "object" }, - "tokenBalances": "object", "preventPollingOnNetworkRestart": false, "tokens": "object", "ignoredTokens": "object", @@ -192,6 +191,7 @@ "allTokens": {}, "allIgnoredTokens": {}, "allDetectedTokens": {}, + "tokenBalances": "object", "smartTransactionsState": { "fees": {}, "feesByChainId": "object", @@ -240,6 +240,8 @@ "isCheckingAccountsPresence": "boolean", "queuedRequestCount": 0, "fcmToken": "string", + "remoteFeatureFlags": { "feature1": true, "feature2": false }, + "cacheTimestamp": "number", "accounts": "object", "accountsByChainId": "object", "marketData": "object", diff --git a/test/e2e/tests/remote-feature-flag/mock-data.ts b/test/e2e/tests/remote-feature-flag/mock-data.ts new file mode 100644 index 000000000000..5d05dc9367f9 --- /dev/null +++ b/test/e2e/tests/remote-feature-flag/mock-data.ts @@ -0,0 +1,4 @@ +export const MOCK_REMOTE_FEATURE_FLAGS_RESPONSE = { + feature1: true, + feature2: false, +}; diff --git a/test/e2e/tests/remote-feature-flag/remote-feature-flag.spec.ts b/test/e2e/tests/remote-feature-flag/remote-feature-flag.spec.ts new file mode 100644 index 000000000000..d8d6878e37c4 --- /dev/null +++ b/test/e2e/tests/remote-feature-flag/remote-feature-flag.spec.ts @@ -0,0 +1,42 @@ +import { strict as assert } from 'assert'; +import { Suite } from 'mocha'; +import { getCleanAppState, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { TestSuiteArguments } from '../confirmations/transactions/shared'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; +import { MOCK_REMOTE_FEATURE_FLAGS_RESPONSE } from './mock-data'; + +describe('Remote feature flag', function (this: Suite) { + it('should be fetched when basic functionality toggle is on', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + title: this.test?.fullTitle(), + }, + async ({ driver }: TestSuiteArguments) => { + await loginWithBalanceValidation(driver); + const uiState = await getCleanAppState(driver); + assert.deepStrictEqual( + uiState.metamask.remoteFeatureFlags, + MOCK_REMOTE_FEATURE_FLAGS_RESPONSE, + ); + }, + ); + }); + + it('should not be fetched when basic functionality toggle is off', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withUseBasicFunctionalityDisabled() + .build(), + title: this.test?.fullTitle(), + }, + async ({ driver }: TestSuiteArguments) => { + await loginWithBalanceValidation(driver); + const uiState = await getCleanAppState(driver); + assert.deepStrictEqual(uiState.metamask.remoteFeatureFlags, {}); + }, + ); + }); +}); diff --git a/ui/pages/settings/info-tab/info-tab.component.js b/ui/pages/settings/info-tab/info-tab.component.js index 121f29e7c52c..6c6efac729bb 100644 --- a/ui/pages/settings/info-tab/info-tab.component.js +++ b/ui/pages/settings/info-tab/info-tab.component.js @@ -28,6 +28,10 @@ import { } from '../../../../shared/lib/ui-utils'; export default class InfoTab extends PureComponent { + static propTypes = { + remoteFeatureFlags: PropTypes.object.isRequired, + }; + state = { version: process.env.METAMASK_VERSION, }; @@ -53,6 +57,12 @@ export default class InfoTab extends PureComponent { componentDidMount() { const { t } = this.context; handleSettingsRefs(t, t('about'), this.settingsRefs); + if (this.props.remoteFeatureFlags.testBooleanFlag) { + // eslint-disable-next-line no-console + console.log( + `Fetch remote feature flag success, eg: testBooleanFlag has value ${this.props.remoteFeatureFlags.testBooleanFlag}`, + ); + } } renderInfoLinks() { diff --git a/ui/pages/settings/info-tab/info-tab.stories.js b/ui/pages/settings/info-tab/info-tab.stories.js index 34f855172d1c..020f60d2516e 100644 --- a/ui/pages/settings/info-tab/info-tab.stories.js +++ b/ui/pages/settings/info-tab/info-tab.stories.js @@ -5,6 +5,6 @@ export default { title: 'Pages/Settings/InfoTab', }; -export const DefaultStory = () => ; +export const DefaultStory = () => ; DefaultStory.storyName = 'Default'; diff --git a/ui/pages/settings/info-tab/info-tab.test.tsx b/ui/pages/settings/info-tab/info-tab.test.tsx index b25be3f8ed5e..7625caf09efb 100644 --- a/ui/pages/settings/info-tab/info-tab.test.tsx +++ b/ui/pages/settings/info-tab/info-tab.test.tsx @@ -7,7 +7,9 @@ describe('InfoTab', () => { let getByText: (text: string) => HTMLElement; beforeEach(() => { - const renderResult = renderWithProvider(); + const renderResult = renderWithProvider( + , + ); getByText = renderResult.getByText; }); diff --git a/ui/pages/settings/settings.component.js b/ui/pages/settings/settings.component.js index 6cd630245819..724c661c9aeb 100644 --- a/ui/pages/settings/settings.component.js +++ b/ui/pages/settings/settings.component.js @@ -69,6 +69,7 @@ class SettingsPage extends PureComponent { isPopup: PropTypes.bool, mostRecentOverviewPage: PropTypes.string.isRequired, pathnameI18nKey: PropTypes.string, + remoteFeatureFlags: PropTypes.object.isRequired, toggleNetworkMenu: PropTypes.func.isRequired, useExternalServices: PropTypes.bool, }; @@ -382,7 +383,13 @@ class SettingsPage extends PureComponent { /> )} /> - + ( + + )} + /> { const { metamask: { currencyRates }, } = state; - + const remoteFeatureFlags = getRemoteFeatureFlags(state); const conversionDate = currencyRates[ticker]?.conversionDate; const pathNameTail = pathname.match(/[^/]+$/u)[0]; @@ -113,6 +114,7 @@ const mapStateToProps = (state, ownProps) => { isPopup, mostRecentOverviewPage: getMostRecentOverviewPage(state), pathnameI18nKey, + remoteFeatureFlags, useExternalServices, }; }; diff --git a/ui/pages/settings/settings.stories.js b/ui/pages/settings/settings.stories.js index a23cd3c1e1cf..53437f4175db 100644 --- a/ui/pages/settings/settings.stories.js +++ b/ui/pages/settings/settings.stories.js @@ -61,6 +61,7 @@ const Settings = ({ history }) => { history={history} pathnameI18nKey={pathnameI18nKey} backRoute={SETTINGS_ROUTE} + remoteFeatureFlags={{}} /> ); diff --git a/ui/pages/settings/settings.test.js b/ui/pages/settings/settings.test.js index 238b50f3ce5b..061175865fce 100644 --- a/ui/pages/settings/settings.test.js +++ b/ui/pages/settings/settings.test.js @@ -19,6 +19,7 @@ describe('SettingsPage', () => { isSnapViewPage: false, mostRecentOverviewPage: '/', pathnameI18nKey: '', + remoteFeatureFlags: {}, }; const mockStore = configureMockStore()(mockState); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index deb18c1c5309..591b569874ec 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2904,6 +2904,10 @@ export function getMetaMetricsDataDeletionStatus(state) { return state.metamask.metaMetricsDataDeletionStatus; } +export function getRemoteFeatureFlags(state) { + return state.metamask.remoteFeatureFlags; +} + /** * To get all installed snaps with proper metadata * diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index ba0544af729e..b4b748104189 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -2163,4 +2163,19 @@ describe('#getConnectedSitesList', () => { expect(getCurrentChainIdSpy).not.toHaveBeenCalled(); // Ensure overrideChainId is used }); }); + + describe('#getRemoteFeatureFlags', () => { + it('returns remoteFeatureFlags in state', () => { + const state = { + metamask: { + remoteFeatureFlags: { + existingFlag: true, + }, + }, + }; + expect(selectors.getRemoteFeatureFlags(state)).toStrictEqual({ + existingFlag: true, + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index c88156929c17..222d18e5dcb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6139,6 +6139,17 @@ __metadata: languageName: node linkType: hard +"@metamask/remote-feature-flag-controller@npm:^1.1.0": + version: 1.1.0 + resolution: "@metamask/remote-feature-flag-controller@npm:1.1.0" + dependencies: + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/utils": "npm:^10.0.0" + cockatiel: "npm:^3.1.2" + checksum: 10/b4b9be277a0da57e8214bdb5ad832c46cdacf8f824426fc377fb40535d9c277f1c1b17c3290343a00f37fa3514232c21dcc18a2eaf5bb47267b3b4c09b150acb + languageName: node + linkType: hard + "@metamask/rpc-errors@npm:^6.0.0, @metamask/rpc-errors@npm:^6.2.1, @metamask/rpc-errors@npm:^6.3.0, @metamask/rpc-errors@npm:^6.3.1": version: 6.4.0 resolution: "@metamask/rpc-errors@npm:6.4.0" @@ -26552,6 +26563,7 @@ __metadata: "@metamask/providers": "npm:^18.2.0" "@metamask/queued-request-controller": "npm:^7.0.1" "@metamask/rate-limit-controller": "npm:^6.0.0" + "@metamask/remote-feature-flag-controller": "npm:^1.1.0" "@metamask/rpc-errors": "npm:^7.0.0" "@metamask/safe-event-emitter": "npm:^3.1.1" "@metamask/scure-bip39": "npm:^2.0.3"