From f0ffe633f0abf6b61b078379253a327e04ba34df Mon Sep 17 00:00:00 2001 From: rarquevaux Date: Fri, 5 Dec 2025 11:47:51 -0800 Subject: [PATCH] feat(STX-331): migrate STX flags to smart-transactions-controller --- CHANGELOG.md | 9 + README.md | 47 ++ package.json | 12 +- src/SmartTransactionsController.test.ts | 605 +++++++++++++++++++++++- src/SmartTransactionsController.ts | 74 ++- src/constants.ts | 21 + src/featureFlags/feature-flags.test.ts | 387 +++++++++++++++ src/featureFlags/feature-flags.ts | 154 ++++++ src/featureFlags/index.ts | 17 + src/featureFlags/validators.test.ts | 369 +++++++++++++++ src/featureFlags/validators.ts | 183 +++++++ src/index.ts | 9 + src/selectors.test.ts | 115 +++++ src/selectors.ts | 94 ++++ src/types.ts | 29 +- src/utils.test.ts | 30 +- src/utils.ts | 19 +- yarn.lock | 55 ++- 18 files changed, 2163 insertions(+), 66 deletions(-) create mode 100644 src/featureFlags/feature-flags.test.ts create mode 100644 src/featureFlags/feature-flags.ts create mode 100644 src/featureFlags/index.ts create mode 100644 src/featureFlags/validators.test.ts create mode 100644 src/featureFlags/validators.ts create mode 100644 src/selectors.test.ts create mode 100644 src/selectors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e401137c..596d7582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING**: The controller now reads feature flags directly from `RemoteFeatureFlagController` via the messenger instead of using the `getFeatureFlags` callback + - Clients must configure the following as allowed actions in the controller messenger: + - `RemoteFeatureFlagController:getState` + - `ErrorReportingService:captureException` (for reporting validation errors to Sentry) + - Clients must configure `RemoteFeatureFlagController:stateChange` as an allowed event + - The `getFeatureFlags` constructor option is now deprecated and ignored + ## [21.1.0] ### Added diff --git a/README.md b/README.md index 22459410..a612e0e4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,53 @@ Run `yarn test` to run the tests once. To run tests on file changes, run `yarn t Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. +### Feature Flags + +Smart transactions feature flags are managed via `RemoteFeatureFlagController` (LaunchDarkly). The configuration uses a `default` remote object for global settings and chain-specific overrides keyed by hex chain ID. + +The flag in LaunchDarkly is named `smartTransactionsNetworks`. + +#### Adding a New Flag + +1. **Add the field to the schema** in `src/utils/validators.ts`: + + ```typescript + export const SmartTransactionsNetworkConfigSchema = type({ + // ... existing fields + myNewFlag: optional(boolean()), + }); + ``` + + The `SmartTransactionsNetworkConfig` type is automatically inferred from this schema. + +2. **Add default value** in `src/constants.ts` under `DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS`: + + These values should be defensive. They are applied when the remote config is invalid or does not exist for a network. + It disables smart transaction. + + ```typescript + export const DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS = { + default: { + // ... existing defaults + myNewFlag: false, + }, + }; + ``` + +3. **Use in clients** via the exported selectors: + + ```typescript + import { selectSmartTransactionsFeatureFlagsForChain } from '@metamask/smart-transactions-controller'; + + const chainConfig = selectSmartTransactionsFeatureFlagsForChain( + state, + '0x1', + ); + if (chainConfig.myNewFlag) { + // Feature is enabled + } + ``` + ### Release & Publishing The project follows the same release process as the other libraries in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. diff --git a/package.json b/package.json index 63121535..24f8637e 100644 --- a/package.json +++ b/package.json @@ -51,15 +51,19 @@ "@metamask/eth-query": "^4.0.0", "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^15.0.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.0.0", "bignumber.js": "^9.0.1", "fast-json-patch": "^3.1.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "reselect": "^5.1.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", "@lavamoat/allow-scripts": "^3.2.1", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.1.0", + "@metamask/error-reporting-service": "^3.0.0", "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", @@ -67,6 +71,7 @@ "@metamask/gas-fee-controller": "^22.0.0", "@metamask/json-rpc-engine": "^10.0.1", "@metamask/network-controller": "^25.0.0", + "@metamask/remote-feature-flag-controller": "^2.0.0", "@metamask/transaction-controller": "^61.0.0", "@ts-bridge/cli": "^0.6.3", "@types/jest": "^26.0.24", @@ -93,7 +98,9 @@ "typescript": "~4.8.4" }, "peerDependencies": { + "@metamask/error-reporting-service": "^3.0.0", "@metamask/network-controller": "^25.0.0", + "@metamask/remote-feature-flag-controller": "^2.0.0", "@metamask/transaction-controller": "^61.0.0" }, "peerDependenciesMeta": { @@ -108,9 +115,6 @@ }, "@metamask/gas-fee-controller": { "optional": true - }, - "@metamask/remote-feature-flag-controller": { - "optional": true } }, "packageManager": "yarn@3.2.1", diff --git a/src/SmartTransactionsController.test.ts b/src/SmartTransactionsController.test.ts index a4607223..ae3616c9 100644 --- a/src/SmartTransactionsController.test.ts +++ b/src/SmartTransactionsController.test.ts @@ -339,6 +339,549 @@ describe('SmartTransactionsController', () => { }); }); + describe('feature flag validation error reporting', () => { + it('reports error to ErrorReportingService when feature flags are invalid after state change', async () => { + const captureExceptionSpy = jest.fn(); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: { chainId: ChainId.mainnet }, + provider: getFakeProvider(), + }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: NetworkType.mainnet, + networkConfigurationsByChainId: {}, + }), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getNonceLock', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getTransactions', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:updateTransaction', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: 'invalid-data', + }, + }), + ); + rootMessenger.registerActionHandler( + 'ErrorReportingService:captureException', + captureExceptionSpy, + ); + + const messenger = new Messenger< + 'SmartTransactionsController', + AllActions, + AllEvents, + RootMessenger + >({ + namespace: 'SmartTransactionsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', + 'TransactionController:getNonceLock', + 'TransactionController:getTransactions', + 'TransactionController:updateTransaction', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + const controller = new SmartTransactionsController({ + messenger, + clientId: ClientId.Mobile, + trackMetaMetricsEvent: jest.fn(), + getMetaMetricsProps: jest.fn(async () => ({})), + }); + + // No validation on construction + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + // Trigger state change with invalid data + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + smartTransactionsNetworks: 'invalid-data', + }, + cacheTimestamp: Date.now(), + }, + [], + ); + + // Should be called after state change + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + '[SmartTransactionsController] Feature flag validation failed', + ), + }), + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.stop(); + }); + + it('does not report error when feature flags are valid after state change', async () => { + const captureExceptionSpy = jest.fn(); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: { chainId: ChainId.mainnet }, + provider: getFakeProvider(), + }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: NetworkType.mainnet, + networkConfigurationsByChainId: {}, + }), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getNonceLock', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getTransactions', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:updateTransaction', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true }, + }, + }, + }), + ); + rootMessenger.registerActionHandler( + 'ErrorReportingService:captureException', + captureExceptionSpy, + ); + + const messenger = new Messenger< + 'SmartTransactionsController', + AllActions, + AllEvents, + RootMessenger + >({ + namespace: 'SmartTransactionsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', + 'TransactionController:getNonceLock', + 'TransactionController:getTransactions', + 'TransactionController:updateTransaction', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + const controller = new SmartTransactionsController({ + messenger, + clientId: ClientId.Mobile, + trackMetaMetricsEvent: jest.fn(), + getMetaMetricsProps: jest.fn(async () => ({})), + }); + + // No validation on construction + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + // Trigger state change with valid data + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true }, + }, + }, + cacheTimestamp: Date.now(), + }, + [], + ); + + // Should not be called after state change with valid flags + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.stop(); + }); + + it('reports error when smartTransactionsNetworks flag is missing after state change', async () => { + const captureExceptionSpy = jest.fn(); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: { chainId: ChainId.mainnet }, + provider: getFakeProvider(), + }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: NetworkType.mainnet, + networkConfigurationsByChainId: {}, + }), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getNonceLock', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getTransactions', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:updateTransaction', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags: { + // smartTransactionsNetworks is undefined (missing from remote config) + }, + }), + ); + rootMessenger.registerActionHandler( + 'ErrorReportingService:captureException', + captureExceptionSpy, + ); + + const messenger = new Messenger< + 'SmartTransactionsController', + AllActions, + AllEvents, + RootMessenger + >({ + namespace: 'SmartTransactionsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', + 'TransactionController:getNonceLock', + 'TransactionController:getTransactions', + 'TransactionController:updateTransaction', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + const controller = new SmartTransactionsController({ + messenger, + clientId: ClientId.Mobile, + trackMetaMetricsEvent: jest.fn(), + getMetaMetricsProps: jest.fn(async () => ({})), + }); + + // No validation on construction + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + // Trigger state change with missing smartTransactionsNetworks flag + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + // smartTransactionsNetworks is undefined + }, + cacheTimestamp: Date.now(), + }, + [], + ); + + // Should report error when flag is missing after state change + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + '[SmartTransactionsController] Feature flag validation failed', + ), + }), + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.stop(); + }); + + it('reports error to ErrorReportingService when feature flags become invalid after state change', async () => { + const captureExceptionSpy = jest.fn(); + const getStateMock = jest.fn().mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true }, + }, + }, + }); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: { chainId: ChainId.mainnet }, + provider: getFakeProvider(), + }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: NetworkType.mainnet, + networkConfigurationsByChainId: {}, + }), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getNonceLock', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getTransactions', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:updateTransaction', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + getStateMock, + ); + rootMessenger.registerActionHandler( + 'ErrorReportingService:captureException', + captureExceptionSpy, + ); + + const messenger = new Messenger< + 'SmartTransactionsController', + AllActions, + AllEvents, + RootMessenger + >({ + namespace: 'SmartTransactionsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', + 'TransactionController:getNonceLock', + 'TransactionController:getTransactions', + 'TransactionController:updateTransaction', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + const controller = new SmartTransactionsController({ + messenger, + clientId: ClientId.Mobile, + trackMetaMetricsEvent: jest.fn(), + getMetaMetricsProps: jest.fn(async () => ({})), + }); + + // Should not be called initially (valid flags) + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + // Simulate feature flag state change with invalid data + getStateMock.mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: 'invalid-data', + }, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + smartTransactionsNetworks: 'invalid-data', + }, + cacheTimestamp: Date.now(), + }, + [], + ); + + // Should be called after state change with invalid data + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + '[SmartTransactionsController] Feature flag validation failed', + ), + }), + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.stop(); + }); + + it('reports multiple errors to ErrorReportingService when multiple chains are invalid after state change', async () => { + const captureExceptionSpy = jest.fn(); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: { chainId: ChainId.mainnet }, + provider: getFakeProvider(), + }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: NetworkType.mainnet, + networkConfigurationsByChainId: {}, + }), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getNonceLock', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:getTransactions', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:updateTransaction', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true }, + '0x1': { extensionActive: true }, + invalidChain1: { extensionActive: false }, + invalidChain2: { mobileActive: true }, + }, + }, + }), + ); + rootMessenger.registerActionHandler( + 'ErrorReportingService:captureException', + captureExceptionSpy, + ); + + const messenger = new Messenger< + 'SmartTransactionsController', + AllActions, + AllEvents, + RootMessenger + >({ + namespace: 'SmartTransactionsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', + 'TransactionController:getNonceLock', + 'TransactionController:getTransactions', + 'TransactionController:updateTransaction', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + const controller = new SmartTransactionsController({ + messenger, + clientId: ClientId.Mobile, + trackMetaMetricsEvent: jest.fn(), + getMetaMetricsProps: jest.fn(async () => ({})), + }); + + // No validation on construction + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + // Trigger state change with multiple invalid chains + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true }, + '0x1': { extensionActive: true }, + invalidChain1: { extensionActive: false }, + invalidChain2: { mobileActive: true }, + }, + }, + cacheTimestamp: Date.now(), + }, + [], + ); + + // Should be called twice - once for each invalid chain + expect(captureExceptionSpy).toHaveBeenCalledTimes(2); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.stop(); + }); + }); + describe('onNetworkChange', () => { it('calls poll', async () => { await withController(({ controller, triggerNetworStateChange }) => { @@ -1256,11 +1799,6 @@ describe('SmartTransactionsController', () => { await withController( { options: { - getFeatureFlags: () => ({ - smartTransactions: { - mobileReturnTxHashAsap: true, - }, - }), state: { smartTransactionsState: { ...defaultState.smartTransactionsState, @@ -1270,6 +1808,17 @@ describe('SmartTransactionsController', () => { }, }, }, + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { + mobileReturnTxHashAsap: true, + }, + // Chain-specific config required for known chains + '0x1': { + mobileReturnTxHashAsap: true, + }, + }, + }, updateTransaction: mockUpdateTransaction, getTransactions: () => [ { @@ -1318,12 +1867,12 @@ describe('SmartTransactionsController', () => { const mockUpdateTransaction = jest.fn(); await withController( { - options: { - getFeatureFlags: () => ({ - smartTransactions: { + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { mobileReturnTxHashAsap: false, }, - }), + }, }, updateTransaction: mockUpdateTransaction, getTransactions: () => [ @@ -1358,12 +1907,12 @@ describe('SmartTransactionsController', () => { await withController( { - options: { - getFeatureFlags: () => ({ - smartTransactions: { + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { mobileReturnTxHashAsap: true, }, - }), + }, }, updateTransaction: mockUpdateTransaction, getTransactions: () => [], @@ -1386,12 +1935,12 @@ describe('SmartTransactionsController', () => { const mockUpdateTransaction = jest.fn(); await withController( { - options: { - getFeatureFlags: () => ({ - smartTransactions: { + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { mobileReturnTxHashAsap: true, }, - }), + }, }, updateTransaction: mockUpdateTransaction, getTransactions: () => [ @@ -2563,6 +3112,9 @@ type WithControllerOptions = { getNonceLock?: TransactionControllerGetNonceLockAction['handler']; getTransactions?: TransactionControllerGetTransactionsAction['handler']; updateTransaction?: TransactionControllerUpdateTransactionAction['handler']; + remoteFeatureFlags?: { + smartTransactionsNetworks?: Record; + }; }; type WithControllerArgs = @@ -2590,6 +3142,7 @@ async function withController( }), getTransactions = jest.fn(), updateTransaction = jest.fn(), + remoteFeatureFlags = {}, } = rest; const rootMessenger: RootMessenger = new Messenger({ @@ -2668,6 +3221,16 @@ async function withController( 'TransactionController:updateTransaction', updateTransaction, ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags, + }), + ); + rootMessenger.registerActionHandler( + 'ErrorReportingService:captureException', + jest.fn(), + ); const messenger = new Messenger< 'SmartTransactionsController', @@ -2683,11 +3246,16 @@ async function withController( actions: [ 'NetworkController:getNetworkClientById', 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', 'TransactionController:updateTransaction', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', ], - events: ['NetworkController:stateChange'], }); const controller = new SmartTransactionsController({ @@ -2701,7 +3269,6 @@ async function withController( deviceModel: 'ledger', }); }), - getFeatureFlags: jest.fn(), ...options, }); diff --git a/src/SmartTransactionsController.ts b/src/SmartTransactionsController.ts index 7db1b62c..89d412a3 100644 --- a/src/SmartTransactionsController.ts +++ b/src/SmartTransactionsController.ts @@ -11,6 +11,7 @@ import { isSafeDynamicKey, type TraceCallback, } from '@metamask/controller-utils'; +import type { ErrorReportingServiceCaptureExceptionAction } from '@metamask/error-reporting-service'; import EthQuery from '@metamask/eth-query'; import type { Messenger } from '@metamask/messenger'; import type { @@ -20,6 +21,10 @@ import type { NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerGetNonceLockAction, TransactionControllerGetTransactionsAction, @@ -27,17 +32,23 @@ import type { TransactionMeta, TransactionParams, } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import cloneDeep from 'lodash/cloneDeep'; import { + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, MetaMetricsEventCategory, MetaMetricsEventName, SmartTransactionsTraceName, } from './constants'; +import { + getSmartTransactionsFeatureFlags, + getSmartTransactionsFeatureFlagsForChain, +} from './featureFlags/feature-flags'; +import { validateSmartTransactionsFeatureFlags } from './featureFlags/validators'; import type { Fees, - Hex, IndividualTxFees, SignedCanceledTransaction, SignedTransaction, @@ -150,9 +161,11 @@ export type SmartTransactionsControllerActions = type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetStateAction + | RemoteFeatureFlagControllerGetStateAction | TransactionControllerGetNonceLockAction | TransactionControllerGetTransactionsAction - | TransactionControllerUpdateTransactionAction; + | TransactionControllerUpdateTransactionAction + | ErrorReportingServiceCaptureExceptionAction; export type SmartTransactionsControllerStateChangeEvent = ControllerStateChangeEvent< @@ -178,7 +191,9 @@ export type SmartTransactionsControllerEvents = | SmartTransactionsControllerSmartTransactionEvent | SmartTransactionsControllerSmartTransactionConfirmationDoneEvent; -type AllowedEvents = NetworkControllerStateChangeEvent; +type AllowedEvents = + | NetworkControllerStateChangeEvent + | RemoteFeatureFlagControllerStateChangeEvent; /** * The messenger of the {@link SmartTransactionsController}. @@ -208,7 +223,12 @@ type SmartTransactionsControllerOptions = { state?: Partial; messenger: SmartTransactionsControllerMessenger; getMetaMetricsProps: () => Promise; - getFeatureFlags: () => FeatureFlags; + /** + * @deprecated This option is ignored. Feature flags are now read directly + * from RemoteFeatureFlagController via the messenger. This option will be + * removed in a future version. + */ + getFeatureFlags?: () => FeatureFlags; trace?: TraceCallback; }; @@ -237,10 +257,38 @@ export class SmartTransactionsController extends StaticIntervalPollingController readonly #getMetaMetricsProps: () => Promise; - #getFeatureFlags: SmartTransactionsControllerOptions['getFeatureFlags']; - #trace: TraceCallback; + /** + * Validates the smart transactions feature flags from the remote feature flag controller + * and reports any validation errors to Sentry via ErrorReportingService. + * Does not report errors when flags are undefined (not yet fetched). + */ + #validateAndReportFeatureFlags(): void { + const remoteFeatureFlagControllerState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const rawFlags = + remoteFeatureFlagControllerState?.remoteFeatureFlags + ?.smartTransactionsNetworks; + + const { errors } = validateSmartTransactionsFeatureFlags(rawFlags); + + // Report each validation error to Sentry + for (const error of errors) { + this.messenger.call( + 'ErrorReportingService:captureException', + new Error( + `[SmartTransactionsController] Feature flag validation failed: ${ + error.message + }. Please check the SmartTransactionNetworks feature flag in Remote Config. Smart transactions are disabled for this network. Default disabled config: ${JSON.stringify( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default, + )}`, + ), + ); + } + } + /* istanbul ignore next */ async #fetch(request: string, options?: RequestInit) { const fetchOptions = { @@ -263,7 +311,6 @@ export class SmartTransactionsController extends StaticIntervalPollingController state = {}, messenger, getMetaMetricsProps, - getFeatureFlags, trace, }: SmartTransactionsControllerOptions) { super({ @@ -283,7 +330,6 @@ export class SmartTransactionsController extends StaticIntervalPollingController this.#ethQuery = undefined; this.#trackMetaMetricsEvent = trackMetaMetricsEvent; this.#getMetaMetricsProps = getMetaMetricsProps; - this.#getFeatureFlags = getFeatureFlags; this.#trace = trace ?? (((_request, fn) => fn?.()) as TraceCallback); this.initializeSmartTransactionsForChainId(); @@ -308,6 +354,11 @@ export class SmartTransactionsController extends StaticIntervalPollingController this.messenger.subscribe(`${controllerName}:stateChange`, (currentState) => this.checkPoll(currentState), ); + + // Validate feature flags on changes + this.messenger.subscribe('RemoteFeatureFlagController:stateChange', () => { + this.#validateAndReportFeatureFlags(); + }); } async _executePoll({ @@ -562,11 +613,16 @@ export class SmartTransactionsController extends StaticIntervalPollingController nextSmartTransaction, ); + const featureFlags = getSmartTransactionsFeatureFlagsForChain( + getSmartTransactionsFeatureFlags(this.messenger), + chainId, + ); + if ( shouldMarkRegularTransactionsAsFailed({ smartTransaction: nextSmartTransaction, clientId: this.#clientId, - getFeatureFlags: this.#getFeatureFlags, + featureFlags, }) ) { markRegularTransactionsAsFailed({ diff --git a/src/constants.ts b/src/constants.ts index f92b0b6b..ea37f90e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -33,3 +33,24 @@ export enum SmartTransactionsTraceName { CancelTransaction = 'Smart Transactions: Cancel Transaction', FetchLiveness = 'Smart Transactions: Fetch Liveness', } + +/** + * Default feature flags configuration for smart transactions. + * Used as a fallback when remote feature flags are unavailable or invalid. + * This is voluntarily defensive because it is applied to any network without valid configuration. + */ +export const DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS = { + default: { + extensionActive: false, + mobileActive: false, + mobileActiveIOS: false, + mobileActiveAndroid: false, + expectedDeadline: 45, + maxDeadline: 150, + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + mobileReturnTxHashAsap: false, + extensionSkipSmartTransactionStatusPage: false, + batchStatusPollingInterval: 1000, + }, +} as const; diff --git a/src/featureFlags/feature-flags.test.ts b/src/featureFlags/feature-flags.test.ts new file mode 100644 index 00000000..7b7f1352 --- /dev/null +++ b/src/featureFlags/feature-flags.test.ts @@ -0,0 +1,387 @@ +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS } from '../constants'; +import { + processSmartTransactionsFeatureFlags, + getSmartTransactionsFeatureFlags, + getSmartTransactionsFeatureFlagsForChain, + normalizeChainId, +} from './feature-flags'; + +describe('feature-flags', () => { + describe('processSmartTransactionsFeatureFlags', () => { + it('should return default flags for null input', () => { + const result = processSmartTransactionsFeatureFlags(null); + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should return default flags for undefined input', () => { + const result = processSmartTransactionsFeatureFlags(undefined); + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should return default flags for invalid input', () => { + const result = processSmartTransactionsFeatureFlags('invalid'); + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should keep valid chains and remove invalid chain ID', () => { + const result = processSmartTransactionsFeatureFlags({ + default: { extensionActive: true }, + '0x1': { extensionActive: true }, + invalidChainId: { extensionActive: false }, + }); + // Valid chains are preserved, invalid chain is removed + expect(result).toStrictEqual({ + default: { extensionActive: true }, + '0x1': { extensionActive: true }, + }); + }); + + it('should return valid flags when input is valid', () => { + const validFlags = { + default: { + extensionActive: true, + mobileActive: false, + }, + '0x1': { + extensionActive: true, + expectedDeadline: 30, + }, + }; + const result = processSmartTransactionsFeatureFlags(validFlags); + expect(result).toStrictEqual(validFlags); + }); + + it('should return default flags for empty input', () => { + const result = processSmartTransactionsFeatureFlags({}); + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should silently return defaults without logging for invalid input', () => { + // Error reporting is handled by SmartTransactionsController, not here + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + processSmartTransactionsFeatureFlags(null); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('should preserve unknown properties in the output (forward compatibility)', () => { + const flagsWithUnknownProps = { + default: { + extensionActive: true, + futureFlag: 'new-value', // Unknown property + anotherNewFlag: 42, // Unknown property + }, + '0x1': { + extensionActive: true, + experimentalFeature: true, // Unknown property + }, + }; + + const result = processSmartTransactionsFeatureFlags( + flagsWithUnknownProps, + ); + + // Unknown properties should be preserved + expect((result.default as Record).futureFlag).toBe( + 'new-value', + ); + expect((result.default as Record).anotherNewFlag).toBe( + 42, + ); + expect( + (result['0x1'] as Record).experimentalFeature, + ).toBe(true); + }); + }); + + describe('getSmartTransactionsFeatureFlags', () => { + it('should return processed flags from messenger', () => { + const mockFlags = { + default: { + extensionActive: true, + mobileActive: true, + }, + '0x1': { + extensionActive: true, + }, + }; + + const mockMessenger = { + call: jest.fn().mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: mockFlags, + }, + }), + }; + + const result = getSmartTransactionsFeatureFlags(mockMessenger); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(result).toStrictEqual(mockFlags); + }); + + it('should return default flags when remoteFeatureFlags is undefined', () => { + const mockMessenger = { + call: jest.fn().mockReturnValue({}), + }; + + const result = getSmartTransactionsFeatureFlags(mockMessenger); + + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should return default flags when smartTransactionsNetworks is undefined', () => { + const mockMessenger = { + call: jest.fn().mockReturnValue({ + remoteFeatureFlags: {}, + }), + }; + + const result = getSmartTransactionsFeatureFlags(mockMessenger); + + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should return default flags when state is null', () => { + const mockMessenger = { + call: jest.fn().mockReturnValue(null), + }; + + const result = getSmartTransactionsFeatureFlags(mockMessenger); + + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + }); + + describe('getSmartTransactionsFeatureFlagsForChain', () => { + const baseFlags = { + default: { + extensionActive: true, + mobileActive: false, + expectedDeadline: 45, + maxDeadline: 150, + extensionReturnTxHashAsap: false, + }, + '0x1': { + extensionActive: true, + expectedDeadline: 30, + extensionReturnTxHashAsap: true, + }, + '0x38': { + extensionActive: false, + mobileActive: true, + }, + }; + + it('should return hardcoded disabled defaults for unknown chain', () => { + const result = getSmartTransactionsFeatureFlagsForChain( + baseFlags, + '0x999' as Hex, + ); + // Unknown chains get hardcoded disabled defaults, not remote default + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default, + ); + }); + + it('should merge chain-specific config with default', () => { + const result = getSmartTransactionsFeatureFlagsForChain( + baseFlags, + '0x1' as Hex, + ); + expect(result).toStrictEqual({ + extensionActive: true, + mobileActive: false, + expectedDeadline: 30, // overridden by chain-specific + maxDeadline: 150, // from default + extensionReturnTxHashAsap: true, // overridden by chain-specific + }); + }); + + it('should allow chain-specific config to override default values', () => { + const result = getSmartTransactionsFeatureFlagsForChain( + baseFlags, + '0x38' as Hex, + ); + expect(result).toStrictEqual({ + extensionActive: false, // overridden to false + mobileActive: true, // overridden to true + expectedDeadline: 45, // from default + maxDeadline: 150, // from default + extensionReturnTxHashAsap: false, // from default + }); + }); + + it('should handle empty default config', () => { + const flagsWithoutDefault = { + '0x1': { + extensionActive: true, + }, + }; + const result = getSmartTransactionsFeatureFlagsForChain( + flagsWithoutDefault, + '0x1' as Hex, + ); + expect(result).toStrictEqual({ + extensionActive: true, + }); + }); + + it('should return hardcoded disabled defaults when no default and chain not found', () => { + const flagsWithoutDefault = { + '0x1': { + extensionActive: true, + }, + }; + const result = getSmartTransactionsFeatureFlagsForChain( + flagsWithoutDefault, + '0x999' as Hex, + ); + // Unknown chains get hardcoded disabled defaults + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default, + ); + }); + + it('should return hardcoded disabled defaults for undefined chain config', () => { + const flagsWithUndefinedChain = { + default: { + extensionActive: true, + }, + '0x1': undefined, + }; + const result = getSmartTransactionsFeatureFlagsForChain( + flagsWithUndefinedChain, + '0x1' as Hex, + ); + // Undefined chain config means unknown chain, returns hardcoded defaults + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default, + ); + }); + + it('should normalize eip155:X to 0xY and find matching config', () => { + const flags = { + default: { extensionActive: false }, + '0x1': { extensionActive: true, mobileActive: true }, + }; + // Query with CAIP-2 format, should find config keyed by hex + const result = getSmartTransactionsFeatureFlagsForChain( + flags, + 'eip155:1' as CaipChainId, + ); + expect(result).toStrictEqual({ + extensionActive: true, + mobileActive: true, + }); + }); + + it('should normalize eip155:137 to 0x89', () => { + const flags = { + default: { extensionActive: false }, + '0x89': { extensionActive: true, mobileActive: true }, + }; + const result = getSmartTransactionsFeatureFlagsForChain( + flags, + 'eip155:137' as CaipChainId, + ); + expect(result).toStrictEqual({ + extensionActive: true, + mobileActive: true, + }); + }); + + it('should not normalize non-EVM CAIP-2 chain IDs (exact match)', () => { + const flags = { + default: { extensionActive: false }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + extensionActive: true, + mobileActive: true, + }, + }; + const result = getSmartTransactionsFeatureFlagsForChain( + flags, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + ); + expect(result).toStrictEqual({ + extensionActive: true, + mobileActive: true, + }); + }); + + it('should return hardcoded disabled defaults for non-matching non-EVM chain', () => { + const flags = { + default: { extensionActive: false }, + '0x1': { extensionActive: true }, + }; + const result = getSmartTransactionsFeatureFlagsForChain( + flags, + 'solana:unknown' as CaipChainId, + ); + // Unknown chains get hardcoded disabled defaults, not remote default + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default, + ); + }); + }); + + describe('normalizeChainId', () => { + it('should return hex chain IDs unchanged', () => { + expect(normalizeChainId('0x1')).toBe('0x1'); + expect(normalizeChainId('0x89')).toBe('0x89'); + expect(normalizeChainId('0x38')).toBe('0x38'); + }); + + it('should convert eip155:1 to 0x1', () => { + expect(normalizeChainId('eip155:1')).toBe('0x1'); + }); + + it('should convert eip155:137 to 0x89', () => { + expect(normalizeChainId('eip155:137')).toBe('0x89'); + }); + + it('should convert eip155:56 to 0x38', () => { + expect(normalizeChainId('eip155:56')).toBe('0x38'); + }); + + it('should convert eip155:42161 to 0xa4b1 (Arbitrum)', () => { + expect(normalizeChainId('eip155:42161')).toBe('0xa4b1'); + }); + + it('should not normalize non-EVM CAIP-2 chain IDs', () => { + expect(normalizeChainId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')).toBe( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + expect(normalizeChainId('bip122:000000000019d6689c085ae165831e93')).toBe( + 'bip122:000000000019d6689c085ae165831e93', + ); + }); + + it('should not normalize invalid eip155 formats', () => { + // Missing number + expect(normalizeChainId('eip155:' as CaipChainId)).toBe('eip155:'); + // Non-numeric + expect(normalizeChainId('eip155:abc' as CaipChainId)).toBe('eip155:abc'); + }); + }); +}); diff --git a/src/featureFlags/feature-flags.ts b/src/featureFlags/feature-flags.ts new file mode 100644 index 00000000..43ac56f8 --- /dev/null +++ b/src/featureFlags/feature-flags.ts @@ -0,0 +1,154 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import { + isCaipChainId, + KnownCaipNamespace, + numberToHex, + parseCaipChainId, +} from '@metamask/utils'; + +import { DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS } from '../constants'; +import type { + SmartTransactionsFeatureFlagsConfig, + SmartTransactionsNetworkConfig, +} from '../types'; +import { validateSmartTransactionsFeatureFlags } from './validators'; + +/** + * Normalizes a chain ID to hex format for EVM chains. + * - CAIP-2 EVM format (eip155:X) is converted to hex (0xY) + * - Hex format is returned as-is + * - Non-EVM CAIP-2 formats are returned as-is (exact match) + * - Invalid eip155 formats (non-numeric reference) are returned as-is + * - This is used because the current STX chains are EVM and declared as hex chain IDs in the existing flag. + * + * @param chainId - The chain ID in any supported format + * @returns Normalized chain ID (hex for EVM, original for non-EVM) + * @example + * ```ts + * normalizeChainId('0x1') // → '0x1' + * normalizeChainId('eip155:1') // → '0x1' + * normalizeChainId('eip155:137') // → '0x89' + * normalizeChainId('solana:...') // → 'solana:...' (unchanged) + * normalizeChainId('eip155:abc') // → 'eip155:abc' (invalid, unchanged) + * ``` + */ +export function normalizeChainId( + chainId: Hex | CaipChainId, +): Hex | CaipChainId { + // If it's already hex or not a valid CAIP chain ID, return as-is + if (!isCaipChainId(chainId)) { + return chainId; + } + + const { namespace, reference } = parseCaipChainId(chainId); + + // Only normalize EVM CAIP-2 chains with valid numeric references to hex + if (namespace === KnownCaipNamespace.Eip155) { + const decimal = parseInt(reference, 10); + // If reference is not a valid number, return as-is + if (Number.isNaN(decimal)) { + return chainId; + } + return numberToHex(decimal); + } + + // Non-EVM CAIP chains remain unchanged + return chainId; +} + +/** + * Processes raw feature flags data and returns a validated configuration. + * Invalid chain configs are silently removed. Error reporting is handled by + * the SmartTransactionsController via ErrorReportingService. + * + * @param rawFeatureFlags - The raw feature flags data from the remote feature flag controller + * @returns The validated feature flags configuration (partial if some chains were invalid) + */ +export function processSmartTransactionsFeatureFlags( + rawFeatureFlags: unknown, +): SmartTransactionsFeatureFlagsConfig { + const { config } = validateSmartTransactionsFeatureFlags(rawFeatureFlags); + + // Return config if it has any valid data, otherwise return defaults + if (Object.keys(config).length > 0) { + return config; + } + + return DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS; +} + +/** + * Gets the smart transactions feature flags from the remote feature flag controller. + * + * @param messenger - Any messenger with access to RemoteFeatureFlagController:getState + * @returns The smart transactions feature flags configuration + * @example + * ```ts + * const featureFlags = getSmartTransactionsFeatureFlags(messenger); + * const chainConfig = featureFlags['0x1'] ?? featureFlags.default; + * ``` + */ +export function getSmartTransactionsFeatureFlags< + T extends { + call( + action: 'RemoteFeatureFlagController:getState', + ): RemoteFeatureFlagControllerState; + }, +>(messenger: T): SmartTransactionsFeatureFlagsConfig { + const remoteFeatureFlagControllerState = messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + const rawSmartTransactionsNetworks = + remoteFeatureFlagControllerState?.remoteFeatureFlags + ?.smartTransactionsNetworks; + + return processSmartTransactionsFeatureFlags(rawSmartTransactionsNetworks); +} + +/** + * Gets the merged feature flags configuration for a specific chain. + * Chain-specific configuration takes precedence over default configuration. + * + * For EVM chains, the chain ID is normalized to hex format before lookup. + * This means both '0x1' and 'eip155:1' will resolve to the same configuration. + * Non-EVM chains (e.g., Solana, Bitcoin) use exact match. + * + * @param featureFlags - The full feature flags configuration + * @param chainId - The chain ID to get configuration for. + * Supports both hex (e.g., "0x1") and CAIP-2 format (e.g., "eip155:1", "solana:...") + * @returns The merged configuration for the specified chain + * @example + * ```ts + * const featureFlags = getSmartTransactionsFeatureFlags(messenger); + * + * // Both resolve to the same config (normalized to 0x1) + * const chainConfig = getSmartTransactionsFeatureFlagsForChain(featureFlags, '0x1'); + * const sameConfig = getSmartTransactionsFeatureFlagsForChain(featureFlags, 'eip155:1'); + * + * // Non-EVM uses exact match + * const solanaConfig = getSmartTransactionsFeatureFlagsForChain(featureFlags, 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + * + * if (chainConfig.extensionActive) { + * // Smart transactions are enabled for this chain + * } + * ``` + */ +export function getSmartTransactionsFeatureFlagsForChain( + featureFlags: SmartTransactionsFeatureFlagsConfig, + chainId: Hex | CaipChainId, +): SmartTransactionsNetworkConfig { + const normalizedChainId = normalizeChainId(chainId); + const defaultRemoteConfig = featureFlags.default ?? {}; + const chainRemoteConfig = featureFlags[normalizedChainId]; + + if (chainRemoteConfig === undefined) { + return DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default; + } + + return { + ...defaultRemoteConfig, + ...chainRemoteConfig, + }; +} diff --git a/src/featureFlags/index.ts b/src/featureFlags/index.ts new file mode 100644 index 00000000..8d2fad6e --- /dev/null +++ b/src/featureFlags/index.ts @@ -0,0 +1,17 @@ +export { + validateSmartTransactionsFeatureFlags, + validateSmartTransactionsNetworkConfig, + SmartTransactionsNetworkConfigSchema, + SmartTransactionsFeatureFlagsConfigSchema, + type SmartTransactionsNetworkConfigFromSchema, + type SmartTransactionsNetworkConfigFromSchema as SmartTransactionsNetworkConfig, + type SmartTransactionsFeatureFlagsConfigFromSchema, + type FeatureFlagsProcessResult, +} from './validators'; + +export { + getSmartTransactionsFeatureFlags, + processSmartTransactionsFeatureFlags, + getSmartTransactionsFeatureFlagsForChain, + normalizeChainId, +} from './feature-flags'; diff --git a/src/featureFlags/validators.test.ts b/src/featureFlags/validators.test.ts new file mode 100644 index 00000000..7672475a --- /dev/null +++ b/src/featureFlags/validators.test.ts @@ -0,0 +1,369 @@ +import { + validateSmartTransactionsFeatureFlags, + validateSmartTransactionsNetworkConfig, +} from './validators'; + +describe('validators', () => { + describe('validateSmartTransactionsNetworkConfig', () => { + it('should return true for a valid empty config', () => { + expect(validateSmartTransactionsNetworkConfig({})).toBe(true); + }); + + it('should return true for a valid config with all fields', () => { + const config = { + extensionActive: true, + mobileActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: false, + expectedDeadline: 45, + maxDeadline: 150, + extensionReturnTxHashAsap: true, + extensionReturnTxHashAsapBatch: true, + mobileReturnTxHashAsap: false, + extensionSkipSmartTransactionStatusPage: false, + batchStatusPollingInterval: 5000, + sentinelUrl: 'https://example.com', + }; + expect(validateSmartTransactionsNetworkConfig(config)).toBe(true); + }); + + it('should return true for a config with only some fields', () => { + const config = { + extensionActive: true, + expectedDeadline: 60, + }; + expect(validateSmartTransactionsNetworkConfig(config)).toBe(true); + }); + + it('should return false for null', () => { + expect(validateSmartTransactionsNetworkConfig(null)).toBe(false); + }); + + it('should return false for a non-object', () => { + expect(validateSmartTransactionsNetworkConfig('invalid')).toBe(false); + expect(validateSmartTransactionsNetworkConfig(123)).toBe(false); + expect(validateSmartTransactionsNetworkConfig(true)).toBe(false); + }); + + it('should return false for a config with invalid field types', () => { + expect( + validateSmartTransactionsNetworkConfig({ + extensionActive: 'true', // should be boolean + }), + ).toBe(false); + + expect( + validateSmartTransactionsNetworkConfig({ + expectedDeadline: '45', // should be number + }), + ).toBe(false); + }); + + it('should return true for a config with unknown properties (forward compatibility)', () => { + const config = { + extensionActive: true, + futureFlag: true, // Unknown property + anotherNewFlag: 'value', // Unknown property + }; + expect(validateSmartTransactionsNetworkConfig(config)).toBe(true); + }); + }); + + describe('validateSmartTransactionsFeatureFlags', () => { + it('should return empty config and no errors for a valid empty config', () => { + const result = validateSmartTransactionsFeatureFlags({}); + expect(result.config).toStrictEqual({}); + expect(result.errors).toHaveLength(0); + }); + + it('should return config with default and no errors', () => { + const config = { + default: { + extensionActive: true, + mobileActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should return full config with chain-specific overrides and no errors', () => { + const config = { + default: { + extensionActive: true, + mobileActive: false, + expectedDeadline: 45, + maxDeadline: 150, + }, + '0x1': { + extensionActive: true, + expectedDeadline: 30, + }, + '0x38': { + extensionActive: false, + mobileActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should handle config with undefined chain values', () => { + const config = { + default: { + extensionActive: true, + }, + '0x1': undefined, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config.default).toStrictEqual({ extensionActive: true }); + expect(result.errors).toHaveLength(0); + }); + + it('should return empty config and error for null', () => { + const result = validateSmartTransactionsFeatureFlags(null); + expect(result.config).toStrictEqual({}); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('null'); + }); + + it('should return empty config and error for non-objects', () => { + const stringResult = validateSmartTransactionsFeatureFlags('invalid'); + expect(stringResult.config).toStrictEqual({}); + expect(stringResult.errors).toHaveLength(1); + + const numberResult = validateSmartTransactionsFeatureFlags(123); + expect(numberResult.config).toStrictEqual({}); + expect(numberResult.errors).toHaveLength(1); + + const arrayResult = validateSmartTransactionsFeatureFlags([]); + expect(arrayResult.config).toStrictEqual({}); + expect(arrayResult.errors).toHaveLength(1); + expect(arrayResult.errors[0].message).toContain('array'); + }); + + it('should remove invalid chain ID and collect error, keeping valid chains', () => { + const config = { + default: { + extensionActive: true, + }, + '0x1': { + extensionActive: true, + }, + invalidChainId: { + // not a hex string or CAIP-2 format + extensionActive: false, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + // Valid chains are preserved + expect(result.config.default).toStrictEqual({ extensionActive: true }); + expect(result.config['0x1']).toStrictEqual({ extensionActive: true }); + // Invalid chain is removed + expect(result.config).not.toHaveProperty('invalidChainId'); + // Error is collected + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('invalidChainId'); + expect(result.errors[0].message).toContain('hex string'); + expect(result.errors[0].message).toContain('CAIP-2'); + }); + + it('should remove chain with invalid config values and collect error', () => { + const config = { + default: { + extensionActive: true, + }, + '0x1': { + extensionActive: 'true', // should be boolean + }, + '0x89': { + extensionActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + // Valid chains are preserved + expect(result.config.default).toStrictEqual({ extensionActive: true }); + expect(result.config['0x89']).toStrictEqual({ extensionActive: true }); + // Invalid chain is removed + expect(result.config).not.toHaveProperty('0x1'); + // Error is collected + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('0x1'); + }); + + it('should return empty config and error for invalid default config', () => { + const result = validateSmartTransactionsFeatureFlags({ + default: { extensionActive: 'not-a-boolean' }, + }); + expect(result.config).toStrictEqual({}); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('default'); + }); + + it('should return full config for complex valid production-like config', () => { + const config = { + default: { + batchStatusPollingInterval: 5000, + extensionActive: true, + mobileActive: true, + extensionReturnTxHashAsap: true, + extensionReturnTxHashAsapBatch: true, + extensionSkipSmartTransactionStatusPage: false, + expectedDeadline: 45, + maxDeadline: 150, + }, + '0x1': { + extensionActive: true, + mobileActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + }, + '0x38': { + extensionActive: true, + mobileActive: false, + expectedDeadline: 60, + maxDeadline: 180, + }, + '0x89': { + extensionActive: true, + mobileActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should preserve unknown properties in default (forward compatibility)', () => { + const config = { + default: { + extensionActive: true, + futureFlag: true, // Unknown property + experimentalFeature: 42, // Unknown property + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should preserve unknown properties in chain config (forward compatibility)', () => { + const config = { + default: { + extensionActive: true, + }, + '0x1': { + extensionActive: true, + newChainSpecificFlag: 'beta', // Unknown property + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should handle CAIP-2 EVM chain IDs (multi-chain support)', () => { + const config = { + default: { + extensionActive: true, + }, + 'eip155:1': { + extensionActive: true, + }, + 'eip155:137': { + mobileActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should handle CAIP-2 non-EVM chain IDs (multi-chain support)', () => { + const config = { + default: { + extensionActive: true, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + mobileActive: true, + }, + 'bip122:000000000019d6689c085ae165831e93': { + mobileActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should handle mixed hex and CAIP-2 chain IDs', () => { + const config = { + default: { + extensionActive: true, + }, + '0x1': { + extensionActive: true, + }, + 'eip155:137': { + mobileActive: true, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + mobileActive: true, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config).toStrictEqual(config); + expect(result.errors).toHaveLength(0); + }); + + it('should remove invalid CAIP-2 format and collect error', () => { + const config = { + default: { + extensionActive: true, + }, + invalid: { + // Not a valid hex or CAIP-2 format + extensionActive: false, + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + expect(result.config.default).toStrictEqual({ extensionActive: true }); + expect(result.config).not.toHaveProperty('invalid'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('invalid'); + expect(result.errors[0].message).toContain('CAIP-2'); + }); + + it('should collect multiple errors for multiple invalid chains', () => { + const config = { + default: { + extensionActive: true, + }, + '0x1': { + extensionActive: true, + }, + invalidChain1: { + extensionActive: false, + }, + invalidChain2: { + mobileActive: true, + }, + '0x89': { + extensionActive: 'invalid', // Invalid value type + }, + }; + const result = validateSmartTransactionsFeatureFlags(config); + // Valid chains are preserved + expect(result.config.default).toStrictEqual({ extensionActive: true }); + expect(result.config['0x1']).toStrictEqual({ extensionActive: true }); + // Invalid chains are removed + expect(result.config).not.toHaveProperty('invalidChain1'); + expect(result.config).not.toHaveProperty('invalidChain2'); + expect(result.config).not.toHaveProperty('0x89'); + // All errors are collected + expect(result.errors).toHaveLength(3); + }); + }); +}); diff --git a/src/featureFlags/validators.ts b/src/featureFlags/validators.ts new file mode 100644 index 00000000..e7e573be --- /dev/null +++ b/src/featureFlags/validators.ts @@ -0,0 +1,183 @@ +import { + boolean, + number, + optional, + string, + type, + is, + validate, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { isCaipChainId, isStrictHexString } from '@metamask/utils'; + +/** + * Validates that a key is a valid chain ID (hex or CAIP-2 format). + * Supports both EVM hex chain IDs and chain-agnostic CAIP-2 identifiers. + * + * @param key - The key to validate + * @returns True if the key is a valid chain ID format + */ +function isValidChainIdKey(key: string): boolean { + return isStrictHexString(key) || isCaipChainId(key); +} + +/** + * Schema for validating per-network smart transactions configuration. + * All fields are optional to allow partial configuration and merging with defaults. + */ +export const SmartTransactionsNetworkConfigSchema = type({ + /** Whether smart transactions are active for the extension client */ + extensionActive: optional(boolean()), + /** Whether smart transactions are active for mobile clients (generic) */ + mobileActive: optional(boolean()), + /** Whether smart transactions are active for iOS specifically */ + mobileActiveIOS: optional(boolean()), + /** Whether smart transactions are active for Android specifically */ + mobileActiveAndroid: optional(boolean()), + /** Expected time in seconds for a smart transaction to be mined */ + expectedDeadline: optional(number()), + /** Maximum time in seconds before a smart transaction is considered failed */ + maxDeadline: optional(number()), + /** Whether extension should return tx hash immediately without waiting for confirmation */ + extensionReturnTxHashAsap: optional(boolean()), + /** Whether extension should return tx hash immediately for batch transactions */ + extensionReturnTxHashAsapBatch: optional(boolean()), + /** Whether mobile should return tx hash immediately without waiting for confirmation */ + mobileReturnTxHashAsap: optional(boolean()), + /** Whether extension should skip the smart transaction status page */ + extensionSkipSmartTransactionStatusPage: optional(boolean()), + /** Polling interval in milliseconds for batch status updates */ + batchStatusPollingInterval: optional(number()), + /** Custom sentinel URL for the network */ + sentinelUrl: optional(string()), +}); + +/** + * Schema for validating the complete smart transactions feature flags configuration. + * This includes a default configuration and optional chain-specific overrides. + */ +export const SmartTransactionsFeatureFlagsConfigSchema = type({ + /** Default configuration applied to all chains unless overridden */ + default: optional(SmartTransactionsNetworkConfigSchema), +}); + +/** + * Type inferred from the SmartTransactionsNetworkConfigSchema + */ +export type SmartTransactionsNetworkConfigFromSchema = Infer< + typeof SmartTransactionsNetworkConfigSchema +>; + +/** + * Type inferred from the SmartTransactionsFeatureFlagsConfigSchema + */ +export type SmartTransactionsFeatureFlagsConfigFromSchema = Infer< + typeof SmartTransactionsFeatureFlagsConfigSchema +>; + +/** + * Result of processing feature flags with collected validation errors. + * Uses per-chain validation: invalid chains are removed, valid ones are kept. + */ +export type FeatureFlagsProcessResult = { + /** The validated configuration (may be partial if some chains were invalid) */ + config: SmartTransactionsFeatureFlagsConfigFromSchema & + Record; + /** Validation errors for invalid parts of the configuration */ + errors: Error[]; +}; + +/** + * Validates smart transactions feature flags with per-chain validation. + * - If the input is not an object, returns empty config with error + * - If `default` is present and invalid, returns empty config with error + * - For each chain: if invalid, removes it and collects error; if valid, includes it + * + * @param data - The data to validate + * @returns The validated config and any validation errors + */ +export function validateSmartTransactionsFeatureFlags( + data: unknown, +): FeatureFlagsProcessResult { + const errors: Error[] = []; + + // Step 1: Check if it's a valid object + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + const typeDescription = data === null ? 'null' : typeof data; + const arraySuffix = Array.isArray(data) ? ' (array)' : ''; + return { + config: {}, + errors: [ + new Error( + `Expected an object, received ${typeDescription}${arraySuffix}`, + ), + ], + }; + } + + const dataRecord = data as Record; + const validConfig: FeatureFlagsProcessResult['config'] = {}; + + // Step 2: Validate 'default' - if present and invalid, reject everything + if (dataRecord.default !== undefined) { + const [defaultError, validatedDefault] = validate( + dataRecord.default, + SmartTransactionsNetworkConfigSchema, + ); + if (defaultError) { + return { + config: {}, + errors: [ + new Error(`Invalid 'default' config: ${defaultError.message}`), + ], + }; + } + // validatedDefault is properly typed from superstruct + validConfig.default = validatedDefault; + } + + // Step 3: Validate chain-specific configs, keeping valid ones + for (const [key, value] of Object.entries(dataRecord)) { + if (key === 'default') { + continue; + } + + // Check chain ID format + if (!isValidChainIdKey(key)) { + errors.push( + new Error( + `Invalid chain ID key "${key}". Expected hex string (e.g., "0x1") or CAIP-2 format (e.g., "eip155:1", "solana:...")`, + ), + ); + continue; // Skip this chain, don't add to result + } + + // Validate chain config + if (value !== undefined) { + const [chainError, validatedChain] = validate( + value, + SmartTransactionsNetworkConfigSchema, + ); + if (chainError) { + errors.push(new Error(`Chain "${key}": ${chainError.message}`)); + continue; // Skip this chain, don't add to result + } + // validatedChain is properly typed from superstruct + validConfig[key] = validatedChain; + } + } + + return { config: validConfig, errors }; +} + +/** + * Validates that the given data conforms to the SmartTransactionsNetworkConfig schema. + * + * @param data - The data to validate + * @returns True if the data is valid, false otherwise + */ +export function validateSmartTransactionsNetworkConfig( + data: unknown, +): data is SmartTransactionsNetworkConfigFromSchema { + return is(data, SmartTransactionsNetworkConfigSchema); +} diff --git a/src/index.ts b/src/index.ts index 800c6c07..ee401682 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ export { type IndividualTxFees, type FeatureFlags, type SmartTransaction, + type SmartTransactionsNetworkConfig, + type SmartTransactionsFeatureFlagsConfig, SmartTransactionMinedTx, SmartTransactionCancellationReason, SmartTransactionStatuses, @@ -25,3 +27,10 @@ export { getSmartTransactionMetricsProperties, getSmartTransactionMetricsSensitiveProperties, } from './utils'; + +// Feature flag selectors +export { + selectSmartTransactionsFeatureFlags, + selectSmartTransactionsFeatureFlagsForChain, + type SmartTransactionsFeatureFlagsState, +} from './selectors'; diff --git a/src/selectors.test.ts b/src/selectors.test.ts new file mode 100644 index 00000000..f912fb78 --- /dev/null +++ b/src/selectors.test.ts @@ -0,0 +1,115 @@ +import type { Hex } from '@metamask/utils'; + +import { DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS } from './constants'; +import { + selectSmartTransactionsFeatureFlags, + selectSmartTransactionsFeatureFlagsForChain, + type SmartTransactionsFeatureFlagsState, +} from './selectors'; + +describe('selectors', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createMockState = ( + smartTransactionsNetworks?: unknown, + ): SmartTransactionsFeatureFlagsState => ({ + remoteFeatureFlags: + smartTransactionsNetworks === undefined + ? undefined + : { smartTransactionsNetworks }, + }); + + describe('selectSmartTransactionsFeatureFlags', () => { + it('should return default flags when state is empty', () => { + const state = createMockState(); + const result = selectSmartTransactionsFeatureFlags(state); + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should return default flags when smartTransactionsNetworks is invalid', () => { + const state = createMockState('invalid'); + const result = selectSmartTransactionsFeatureFlags(state); + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS, + ); + }); + + it('should return valid flags from state', () => { + const validFlags = { + default: { + extensionActive: true, + mobileActive: true, + }, + '0x1': { + extensionActive: true, + }, + }; + const state = createMockState(validFlags); + const result = selectSmartTransactionsFeatureFlags(state); + expect(result).toStrictEqual(validFlags); + }); + }); + + describe('selectSmartTransactionsFeatureFlagsForChain', () => { + const validFlags = { + default: { + extensionActive: true, + mobileActive: false, + expectedDeadline: 45, + maxDeadline: 150, + }, + '0x1': { + extensionActive: true, + expectedDeadline: 30, + }, + '0x38': { + extensionActive: false, + mobileActive: true, + }, + }; + + it('should return merged config for known chain', () => { + const state = createMockState(validFlags); + const result = selectSmartTransactionsFeatureFlagsForChain( + state, + '0x1' as Hex, + ); + expect(result).toStrictEqual({ + extensionActive: true, + mobileActive: false, + expectedDeadline: 30, + maxDeadline: 150, + }); + }); + + it('should return hardcoded disabled defaults for unknown chain', () => { + const state = createMockState(validFlags); + const result = selectSmartTransactionsFeatureFlagsForChain( + state, + '0x999' as Hex, + ); + // Unknown chains get hardcoded disabled defaults, not remote default + expect(result).toStrictEqual( + DEFAULT_DISABLED_SMART_TRANSACTIONS_FEATURE_FLAGS.default, + ); + }); + + it('should allow chain-specific values to override defaults', () => { + const state = createMockState(validFlags); + const result = selectSmartTransactionsFeatureFlagsForChain( + state, + '0x38' as Hex, + ); + expect(result.extensionActive).toBe(false); + expect(result.mobileActive).toBe(true); + }); + }); +}); diff --git a/src/selectors.ts b/src/selectors.ts new file mode 100644 index 00000000..e3b3a2ce --- /dev/null +++ b/src/selectors.ts @@ -0,0 +1,94 @@ +import type { CaipChainId, Hex } from '@metamask/utils'; +import { createSelector as createSelector_ } from 'reselect'; + +import { + processSmartTransactionsFeatureFlags, + getSmartTransactionsFeatureFlagsForChain, +} from './featureFlags/feature-flags'; +import type { + SmartTransactionsFeatureFlagsConfig, + SmartTransactionsNetworkConfig, +} from './types'; + +/** + * The state shape expected by the smart transactions feature flag selectors. + * This represents the relevant portion of the remote feature flag controller state. + */ +export type SmartTransactionsFeatureFlagsState = { + remoteFeatureFlags?: { + smartTransactionsNetworks?: unknown; + }; +}; + +/** + * Creates a typed selector for smart transactions feature flags + */ +const createSelector = + createSelector_.withTypes(); + +/** + * Selects and validates the smart transactions feature flags from state. + * Returns the validated configuration or defaults if invalid. + * If you need to get the feature flags for a specific chain, use `selectSmartTransactionsFeatureFlagsForChain`. + * + * @param state - The state containing remoteFeatureFlags.smartTransactionsNetworks + * @returns The validated smart transactions feature flags configuration + * @example + * ```ts + * // In a React component + * const featureFlags = useSelector(selectSmartTransactionsFeatureFlags); + * + * // Or with reselect composition + * const selectIsExtensionActive = createSelector( + * selectSmartTransactionsFeatureFlags, + * (flags) => flags.default?.extensionActive ?? false + * ); + * ``` + */ +export const selectSmartTransactionsFeatureFlags = createSelector( + [(state) => state.remoteFeatureFlags?.smartTransactionsNetworks], + (rawFeatureFlags): SmartTransactionsFeatureFlagsConfig => + processSmartTransactionsFeatureFlags(rawFeatureFlags), +); + +/** + * Selects the merged feature flags configuration for a specific chain. + * Chain-specific configuration takes precedence over default configuration. + * + * For EVM chains, the chain ID is normalized to hex format before lookup. + * This means both '0x1' and 'eip155:1' will resolve to the same configuration. + * Non-EVM chains (e.g., Solana, Bitcoin) use exact match. + * + * @param _state - The state containing remoteFeatureFlags.smartTransactionsNetworks + * @param chainId - The chain ID to get configuration for. + * Supports both hex (e.g., "0x1") and CAIP-2 format (e.g., "eip155:1", "solana:...") + * @returns The merged configuration for the specified chain + * @example + * ```ts + * // Both resolve to the same config (normalized to 0x1) + * const chainConfig = useSelector((state) => + * selectSmartTransactionsFeatureFlagsForChain(state, '0x1') + * ); + * const sameConfig = useSelector((state) => + * selectSmartTransactionsFeatureFlagsForChain(state, 'eip155:1') + * ); + * + * // Non-EVM uses exact match + * const solanaConfig = useSelector((state) => + * selectSmartTransactionsFeatureFlagsForChain(state, 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') + * ); + * + * if (chainConfig.extensionActive) { + * // Smart transactions are enabled + * } + * ``` + */ +export const selectSmartTransactionsFeatureFlagsForChain = createSelector( + [ + selectSmartTransactionsFeatureFlags, + (_state: SmartTransactionsFeatureFlagsState, chainId: Hex | CaipChainId) => + chainId, + ], + (featureFlags, chainId): SmartTransactionsNetworkConfig => + getSmartTransactionsFeatureFlagsForChain(featureFlags, chainId), +); diff --git a/src/types.ts b/src/types.ts index c3f1180f..446f1385 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,7 @@ import type { NetworkClientId } from '@metamask/network-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import type { SmartTransactionsNetworkConfig } from './featureFlags'; /** API */ export enum APIType { @@ -126,8 +129,6 @@ export type SignedTransaction = any; // TODO export type SignedCanceledTransaction = any; -export type Hex = `0x${string}`; - export type MetaMetricsProps = { accountHardwareType?: string; accountType?: string; @@ -140,3 +141,27 @@ export type FeatureFlags = { extensionReturnTxHashAsap?: boolean; }; }; + +/** + * Configuration for smart transactions on a specific network. + * These flags control feature availability and behavior per chain. + * + * This type is inferred from the SmartTransactionsNetworkConfigSchema. + * To add a new field, update the schema in `src/featureFlags/validators.ts`. + */ +export type { SmartTransactionsNetworkConfig }; + +/** + * Feature flags configuration for smart transactions across all networks. + * Contains a default configuration and optional chain-specific overrides. + */ +export type SmartTransactionsFeatureFlagsConfig = { + /** Default configuration applied to all chains unless overridden */ + default?: SmartTransactionsNetworkConfig; +} & { + /** + * Chain-specific configuration overrides, keyed by chain ID. + * Supports both hex (e.g., "0x1") and CAIP-2 format (e.g., "eip155:1", "solana:...") + */ + [chainId: Hex | CaipChainId]: SmartTransactionsNetworkConfig | undefined; +}; diff --git a/src/utils.test.ts b/src/utils.test.ts index b6f7bc99..04db6341 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -418,14 +418,10 @@ describe('src/utils.js', () => { }, }); - const mockGetFeatureFlags = - (returnTxHashAsap = true) => - () => ({ - smartTransactions: { - extensionReturnTxHashAsap: returnTxHashAsap, - mobileReturnTxHashAsap: returnTxHashAsap, - }, - }); + const createFeatureFlags = (returnTxHashAsap = true) => ({ + extensionReturnTxHashAsap: returnTxHashAsap, + mobileReturnTxHashAsap: returnTxHashAsap, + }); it('returns true for "cancelled" status when feature flag is enabled', () => { const result = utils.shouldMarkRegularTransactionsAsFailed({ @@ -433,7 +429,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.CANCELLED, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(true); }); @@ -444,7 +440,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.CANCELLED_USER_CANCELLED, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(true); }); @@ -455,7 +451,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.UNKNOWN, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(true); }); @@ -466,7 +462,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.RESOLVED, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(true); }); @@ -477,7 +473,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.PENDING, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(false); }); @@ -488,7 +484,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.SUCCESS, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(false); }); @@ -499,7 +495,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.CANCELLED, ), clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(false), + featureFlags: createFeatureFlags(false), }); expect(result).toBe(false); }); @@ -512,7 +508,7 @@ describe('src/utils.js', () => { const result = utils.shouldMarkRegularTransactionsAsFailed({ smartTransaction, clientId: ClientId.Extension, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(false); }); @@ -523,7 +519,7 @@ describe('src/utils.js', () => { SmartTransactionStatuses.CANCELLED, ), clientId: ClientId.Mobile, - getFeatureFlags: mockGetFeatureFlags(true), + featureFlags: createFeatureFlags(true), }); expect(result).toBe(true); }); diff --git a/src/utils.ts b/src/utils.ts index 72b0ab47..d1d1bb6c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,7 +19,7 @@ import { API_BASE_URL, SENTINEL_API_BASE_URL_MAP } from './constants'; import type { SmartTransaction, SmartTransactionsStatus, - FeatureFlags, + SmartTransactionsNetworkConfig, } from './types'; import { APIType, @@ -233,21 +233,21 @@ export const getSmartTransactionMetricsSensitiveProperties = ( export const getReturnTxHashAsap = ( clientId: ClientId, - smartTransactionsFeatureFlags: FeatureFlags['smartTransactions'], + featureFlags: SmartTransactionsNetworkConfig, ) => { return clientId === ClientId.Extension - ? smartTransactionsFeatureFlags?.extensionReturnTxHashAsap - : smartTransactionsFeatureFlags?.mobileReturnTxHashAsap; + ? featureFlags.extensionReturnTxHashAsap + : featureFlags.mobileReturnTxHashAsap; }; export const shouldMarkRegularTransactionsAsFailed = ({ smartTransaction, clientId, - getFeatureFlags, + featureFlags, }: { smartTransaction: SmartTransaction; clientId: ClientId; - getFeatureFlags: () => FeatureFlags; + featureFlags: SmartTransactionsNetworkConfig; }): boolean => { const { status, transactionId } = smartTransaction; const failureStatuses: SmartTransactionStatuses[] = [ @@ -262,12 +262,7 @@ export const shouldMarkRegularTransactionsAsFailed = ({ ) { return false; } - const { smartTransactions: smartTransactionsFeatureFlags } = - getFeatureFlags() ?? {}; - const returnTxHashAsapEnabled = getReturnTxHashAsap( - clientId, - smartTransactionsFeatureFlags, - ); + const returnTxHashAsapEnabled = getReturnTxHashAsap(clientId, featureFlags); return Boolean(returnTxHashAsapEnabled && transactionId); }; diff --git a/yarn.lock b/yarn.lock index 7125ef70..c40a5c7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1467,6 +1467,37 @@ __metadata: languageName: node linkType: hard +"@metamask/controller-utils@npm:^11.16.0": + version: 11.16.0 + resolution: "@metamask/controller-utils@npm:11.16.0" + dependencies: + "@metamask/eth-query": ^4.0.0 + "@metamask/ethjs-unit": ^0.3.0 + "@metamask/utils": ^11.8.1 + "@spruceid/siwe-parser": 2.1.0 + "@types/bn.js": ^5.1.5 + bignumber.js: ^9.1.2 + bn.js: ^5.2.1 + cockatiel: ^3.1.2 + eth-ens-namehash: ^2.0.8 + fast-deep-equal: ^3.1.3 + lodash: ^4.17.21 + peerDependencies: + "@babel/runtime": ^7.0.0 + checksum: 8ffd51e6dff63980973dee7f2e7c688709b2bc773a9443512682a9cb5aeeabea7bf8ab1c3d48e452f48f2d33ef1b227d71ab0bbb26c3336475a89000123c6e53 + languageName: node + linkType: hard + +"@metamask/error-reporting-service@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/error-reporting-service@npm:3.0.0" + dependencies: + "@metamask/base-controller": ^9.0.0 + "@metamask/messenger": ^0.3.0 + checksum: 167769444ce3ed06cf67985418c66fad977b772806598d049aed5f6b3a71d5938bc7dd2642c1481ce1c672e451a15e90f3805380ede438334bfe2f6e99a0fe87 + languageName: node + linkType: hard + "@metamask/eslint-config-jest@npm:^12.1.0": version: 12.1.0 resolution: "@metamask/eslint-config-jest@npm:12.1.0" @@ -1826,6 +1857,19 @@ __metadata: languageName: node linkType: hard +"@metamask/remote-feature-flag-controller@npm:^2.0.0": + version: 2.0.1 + resolution: "@metamask/remote-feature-flag-controller@npm:2.0.1" + dependencies: + "@metamask/base-controller": ^9.0.0 + "@metamask/controller-utils": ^11.16.0 + "@metamask/messenger": ^0.3.0 + "@metamask/utils": ^11.8.1 + uuid: ^8.3.2 + checksum: 8bc7ad591a1f91c138864375eb2bbcf9b610de0f71ae6470d5ff7840a0116f711d828dc19d92bb030fa101c624f895b8dcf52f4fd6d4754aab8c9bd0a9c0f577 + languageName: node + linkType: hard + "@metamask/rpc-errors@npm:^7.0.2": version: 7.0.3 resolution: "@metamask/rpc-errors@npm:7.0.3" @@ -1859,6 +1903,7 @@ __metadata: "@metamask/auto-changelog": ^3.1.0 "@metamask/base-controller": ^9.0.0 "@metamask/controller-utils": ^11.0.0 + "@metamask/error-reporting-service": ^3.0.0 "@metamask/eslint-config": ^12.2.0 "@metamask/eslint-config-jest": ^12.1.0 "@metamask/eslint-config-nodejs": ^12.1.0 @@ -1870,7 +1915,10 @@ __metadata: "@metamask/messenger": ^0.3.0 "@metamask/network-controller": ^25.0.0 "@metamask/polling-controller": ^15.0.0 + "@metamask/remote-feature-flag-controller": ^2.0.0 + "@metamask/superstruct": ^3.1.0 "@metamask/transaction-controller": ^61.0.0 + "@metamask/utils": ^11.0.0 "@ts-bridge/cli": ^0.6.3 "@types/jest": ^26.0.24 "@types/lodash": ^4.14.194 @@ -1894,11 +1942,14 @@ __metadata: nock: ^14.0.0-beta.7 prettier: ^2.8.8 prettier-plugin-packagejson: ^2.4.3 + reselect: ^5.1.1 sinon: ^9.2.4 ts-jest: ^29.1.4 typescript: ~4.8.4 peerDependencies: + "@metamask/error-reporting-service": ^3.0.0 "@metamask/network-controller": ^25.0.0 + "@metamask/remote-feature-flag-controller": ^2.0.0 "@metamask/transaction-controller": ^61.0.0 peerDependenciesMeta: "@metamask/accounts-controller": @@ -1909,8 +1960,6 @@ __metadata: optional: true "@metamask/gas-fee-controller": optional: true - "@metamask/remote-feature-flag-controller": - optional: true languageName: unknown linkType: soft @@ -1982,7 +2031,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1": +"@metamask/utils@npm:^11.0.0, @metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1": version: 11.8.1 resolution: "@metamask/utils@npm:11.8.1" dependencies: