From d67bdbaafde9b042505639d86d161c5afad081c5 Mon Sep 17 00:00:00 2001 From: rarquevaux Date: Fri, 9 Jan 2026 17:09:17 -0800 Subject: [PATCH] feat(STX-331): replace legacy STX swaps flags with smart transaction flags from remote config API --- .../transaction-controller-init.ts | 16 +- ...smart-transactions-controller-messenger.ts | 56 ++-- .../subscription-service-messenger.ts | 1 + ...smart-transactions-controller-init.test.ts | 65 +--- .../smart-transactions-controller-init.ts | 16 +- .../smart-transactions.test.ts | 287 ++++++------------ .../smart-transaction/smart-transactions.ts | 44 +-- .../subscription/subscription-service.test.ts | 12 + .../subscription/subscription-service.ts | 3 + app/scripts/services/subscription/types.ts | 2 + lavamoat/browserify/beta/policy.json | 5 +- lavamoat/browserify/experimental/policy.json | 5 +- lavamoat/browserify/flask/policy.json | 5 +- lavamoat/browserify/main/policy.json | 5 +- lavamoat/webpack/mv2/policy.json | 5 +- lavamoat/webpack/mv3/policy.json | 7 + package.json | 2 +- shared/modules/selectors/feature-flags.ts | 6 + shared/modules/selectors/index.test.ts | 179 +++++++---- shared/modules/selectors/index.ts | 1 - .../modules/selectors/smart-transactions.ts | 70 +++-- test/data/bridge/mock-bridge-store.ts | 14 +- .../gas-fee-tokens-smart-transactions.spec.ts | 11 +- .../tests/smart-transactions/remote-flags.ts | 39 +++ .../smart-transactions.spec.ts | 6 +- .../data/integration-init-state.json | 8 + test/jest/mock-store.js | 11 + ui/ducks/bridge/selectors.test.ts | 28 +- ui/ducks/swaps/swaps.js | 10 +- .../smart-transactions-banner-alert.test.tsx | 4 +- .../useSmartTransactionFeatureFlags.test.ts | 35 ++- .../hooks/useSmartTransactionFeatureFlags.ts | 16 +- yarn.lock | 19 +- 33 files changed, 501 insertions(+), 492 deletions(-) create mode 100644 test/e2e/tests/smart-transactions/remote-flags.ts diff --git a/app/scripts/controller-init/confirmations/transaction-controller-init.ts b/app/scripts/controller-init/confirmations/transaction-controller-init.ts index b94389e8ff0e..ac02df56a7ec 100644 --- a/app/scripts/controller-init/confirmations/transaction-controller-init.ts +++ b/app/scripts/controller-init/confirmations/transaction-controller-init.ts @@ -386,8 +386,10 @@ export async function publishHook({ transactionController: TransactionController; transactionMeta: TransactionMeta; }) { - const { isSmartTransaction, featureFlags, isHardwareWalletAccount } = - getSmartTransactionCommonParams(flatState, transactionMeta.chainId); + const { isSmartTransaction, featureFlags } = getSmartTransactionCommonParams( + flatState, + transactionMeta.chainId, + ); const sendBundleSupport = await isSendBundleSupported( transactionMeta.chainId, ); @@ -420,8 +422,6 @@ export async function publishHook({ smartTransactionsController, controllerMessenger: initMessenger, isSmartTransaction, - isHardwareWallet: isHardwareWalletAccount, - // @ts-expect-error Smart transaction selector return type does not match FeatureFlags type from hook featureFlags, }); @@ -462,8 +462,10 @@ export function publishBatchHook({ ); } - const { isSmartTransaction, featureFlags, isHardwareWalletAccount } = - getSmartTransactionCommonParams(flatState, transactionMeta.chainId); + const { isSmartTransaction, featureFlags } = getSmartTransactionCommonParams( + flatState, + transactionMeta.chainId, + ); if (!isSmartTransaction) { return undefined; @@ -475,8 +477,6 @@ export function publishBatchHook({ smartTransactionsController, controllerMessenger: hookControllerMessenger, isSmartTransaction, - isHardwareWallet: isHardwareWalletAccount, - // @ts-expect-error Smart transaction selector return type does not match FeatureFlags type from hook featureFlags, transactionMeta, }); diff --git a/app/scripts/controller-init/messengers/smart-transactions-controller-messenger.ts b/app/scripts/controller-init/messengers/smart-transactions-controller-messenger.ts index 30863aecbe11..b04c3eee94b9 100644 --- a/app/scripts/controller-init/messengers/smart-transactions-controller-messenger.ts +++ b/app/scripts/controller-init/messengers/smart-transactions-controller-messenger.ts @@ -1,52 +1,46 @@ -import { Messenger } from '@metamask/messenger'; -import type { - TransactionControllerGetNonceLockAction, - TransactionControllerGetTransactionsAction, - TransactionControllerUpdateTransactionAction, -} from '@metamask/transaction-controller'; import { - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerGetStateAction, - NetworkControllerStateChangeEvent, -} from '@metamask/network-controller'; + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { SmartTransactionsControllerMessenger } from '@metamask/smart-transactions-controller'; import { MetaMetricsControllerTrackEventAction } from '../../controllers/metametrics-controller'; import { RootMessenger } from '../../lib/messenger'; -type MessengerActions = - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetStateAction - | TransactionControllerGetNonceLockAction - | TransactionControllerGetTransactionsAction - | TransactionControllerUpdateTransactionAction; - -type MessengerEvents = NetworkControllerStateChangeEvent; - -export type SmartTransactionsControllerMessenger = ReturnType< - typeof getSmartTransactionsControllerMessenger ->; - +/** + * Get the messenger for the smart transactions controller. This is scoped to the + * actions and events that the smart transactions controller is allowed to handle. + * + * @param rootMessenger - The root messenger. + * @returns The SmartTransactionsControllerMessenger. + */ export function getSmartTransactionsControllerMessenger( - messenger: RootMessenger, -) { + rootMessenger: RootMessenger, +): SmartTransactionsControllerMessenger { const controllerMessenger = new Messenger< 'SmartTransactionsController', - MessengerActions, - MessengerEvents, - typeof messenger + MessengerActions, + MessengerEvents, + RootMessenger >({ namespace: 'SmartTransactionsController', - parent: messenger, + parent: rootMessenger, }); - messenger.delegate({ + rootMessenger.delegate({ messenger: controllerMessenger, actions: [ + 'ErrorReportingService:captureException', 'NetworkController:getNetworkClientById', 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', 'TransactionController:updateTransaction', ], - events: ['NetworkController:stateChange'], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', + ], }); return controllerMessenger; } diff --git a/app/scripts/controller-init/messengers/subscription/subscription-service-messenger.ts b/app/scripts/controller-init/messengers/subscription/subscription-service-messenger.ts index a8bad73714d9..6117bc3a55c3 100644 --- a/app/scripts/controller-init/messengers/subscription/subscription-service-messenger.ts +++ b/app/scripts/controller-init/messengers/subscription/subscription-service-messenger.ts @@ -51,6 +51,7 @@ export function getSubscriptionServiceMessenger( 'SmartTransactionsController:getState', 'NetworkController:getState', 'SwapsController:getState', + 'RemoteFeatureFlagController:getState', 'MetaMetricsController:trackEvent', 'KeyringController:getState', // Rewards Integration diff --git a/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.test.ts b/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.test.ts index 038cb0de5d19..5725f16406b7 100644 --- a/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.test.ts +++ b/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.test.ts @@ -1,5 +1,6 @@ import { SmartTransactionsController, + SmartTransactionsControllerMessenger, ClientId, } from '@metamask/smart-transactions-controller'; import { @@ -20,7 +21,6 @@ import type { import { getSmartTransactionsControllerInitMessenger, SmartTransactionsControllerInitMessenger, - SmartTransactionsControllerMessenger, } from '../messengers/smart-transactions-controller-messenger'; import { ControllerFlatState } from '../controller-list'; import type { @@ -35,10 +35,7 @@ jest.mock('@metamask/smart-transactions-controller'); type MockAccountsController = Pick; type MockTransactionController = Pick< TransactionController, - | 'getNonceLock' - | 'confirmExternalTransaction' - | 'getTransactions' - | 'updateTransaction' + 'getNonceLock' | 'getTransactions' | 'updateTransaction' >; type TestInitRequest = ControllerInitRequest< @@ -96,7 +93,6 @@ describe('SmartTransactionsController Init', () => { const transactionController: MockTransactionController = { getNonceLock: jest.fn().mockResolvedValue({ releaseLock: jest.fn() }), - confirmExternalTransaction: jest.fn(), getTransactions: jest.fn().mockReturnValue([]), updateTransaction: jest.fn(), }; @@ -324,63 +320,6 @@ describe('SmartTransactionsController Init', () => { expect(listener).toHaveBeenCalledWith(testPayload); }); - describe('getFeatureFlags', () => { - it('returns feature flags from state', () => { - const { fullRequest } = buildInitRequest(); - SmartTransactionsControllerInit(fullRequest); - - const constructorCall = - smartTransactionsControllerClassMock.mock.calls[0][0]; - const { getFeatureFlags } = constructorCall; - - const result = getFeatureFlags(); - - expect(fullRequest.getUIState).toHaveBeenCalled(); - expect(result).toHaveProperty('smartTransactions'); - expect(result.smartTransactions).toHaveProperty('extensionActive'); - expect(result.smartTransactions).toHaveProperty('mobileActive'); - expect(result.smartTransactions).toHaveProperty('expectedDeadline'); - expect(result.smartTransactions).toHaveProperty('maxDeadline'); - expect(result.smartTransactions).toHaveProperty( - 'extensionReturnTxHashAsap', - ); - }); - - it('returns default feature flags when getFeatureFlagsByChainId returns null', () => { - // To test the null case, we need to make getStateUI return a state - // that would cause getFeatureFlagsByChainId to return null - const { fullRequest } = buildInitRequest({ - getUIState: jest.fn().mockReturnValue({ - preferences: {}, - selectedNetworkClientId: 'mainnet', - networkConfigurationsByChainId: { - '0x1': { - chainId: '0x1', - rpcEndpoints: [ - { - networkClientId: 'mainnet', - url: 'https://mainnet.infura.io/v3/abc', - }, - ], - }, - }, - // No swapsState to test null case - }), - }); - - SmartTransactionsControllerInit(fullRequest); - - const constructorCall = - smartTransactionsControllerClassMock.mock.calls[0][0]; - const { getFeatureFlags } = constructorCall; - - const result = getFeatureFlags(); - - // When getFeatureFlagsByChainId returns null, the result should be null - expect(result).toBeNull(); - }); - }); - describe('getMetaMetricsProps', () => { it('returns correct meta metrics properties', async () => { const { fullRequest } = buildInitRequest(); diff --git a/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.ts b/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.ts index 5db68b53c8ea..dd5455d09a86 100644 --- a/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.ts +++ b/app/scripts/controller-init/smart-transactions/smart-transactions-controller-init.ts @@ -1,19 +1,13 @@ import { SmartTransactionsController, + SmartTransactionsControllerMessenger, ClientId, } from '@metamask/smart-transactions-controller'; import type { Hex } from '@metamask/utils'; import type { TraceCallback } from '@metamask/controller-utils'; import { getAllowedSmartTransactionsChainIds } from '../../../../shared/constants/smartTransactions'; -import { getFeatureFlagsByChainId } from '../../../../shared/modules/selectors'; -import { type ProviderConfigState } from '../../../../shared/modules/selectors/networks'; -import { type FeatureFlagsMetaMaskState } from '../../../../shared/modules/selectors/feature-flags'; -import type { FeatureFlags } from '../../lib/smart-transaction/smart-transactions'; import { ControllerInitFunction, ControllerInitRequest } from '../types'; -import { - SmartTransactionsControllerInitMessenger, - SmartTransactionsControllerMessenger, -} from '../messengers/smart-transactions-controller-messenger'; +import { SmartTransactionsControllerInitMessenger } from '../messengers/smart-transactions-controller-messenger'; // This import is only used for the type. // eslint-disable-next-line import/no-restricted-paths import type { MetaMaskReduxState } from '../../../../ui/store/store'; @@ -57,12 +51,6 @@ export const SmartTransactionsControllerInit: ControllerInitFunction< >[0]['trackMetaMetricsEvent'], state: persistedState.SmartTransactionsController, messenger: controllerMessenger, - getFeatureFlags: () => { - const state = { metamask: getUIState() }; - return getFeatureFlagsByChainId( - state as unknown as ProviderConfigState & FeatureFlagsMetaMaskState, - ) as unknown as FeatureFlags; - }, getMetaMetricsProps: async () => { const metamask = getUIState(); const { internalAccounts } = metamask; diff --git a/app/scripts/lib/smart-transaction/smart-transactions.test.ts b/app/scripts/lib/smart-transaction/smart-transactions.test.ts index 8c74a75e7028..9d32aff86400 100644 --- a/app/scripts/lib/smart-transaction/smart-transactions.test.ts +++ b/app/scripts/lib/smart-transaction/smart-transactions.test.ts @@ -17,7 +17,6 @@ import { type SmartTransaction, } from '@metamask/smart-transactions-controller'; import type { - TransactionControllerConfirmExternalTransactionAction, TransactionControllerGetNonceLockAction, TransactionControllerGetTransactionsAction, TransactionControllerUpdateTransactionAction, @@ -88,7 +87,6 @@ function withRequest( MockAnyNamespace, | MessengerActions | TransactionControllerGetNonceLockAction - | TransactionControllerConfirmExternalTransactionAction | TransactionControllerGetTransactionsAction | TransactionControllerUpdateTransactionAction | AllowedActions, @@ -123,6 +121,24 @@ function withRequest( const endFlowSpy = jest.fn(); messenger.registerActionHandler('ApprovalController:endFlow', endFlowSpy); + // Register RemoteFeatureFlagController:getState handler for the new controller + messenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true }, + }, + }, + }), + ); + + // Register ErrorReportingService:captureException handler + messenger.registerActionHandler( + 'ErrorReportingService:captureException', + jest.fn(), + ); + const smartTransactionsControllerMessenger = new Messenger< 'SmartTransactionsController', MessengerActions, @@ -138,8 +154,13 @@ function withRequest( 'TransactionController:getNonceLock', 'TransactionController:getTransactions', 'TransactionController:updateTransaction', + 'RemoteFeatureFlagController:getState', + 'ErrorReportingService:captureException', + ], + events: [ + 'NetworkController:stateChange', + 'RemoteFeatureFlagController:stateChange', ], - events: ['NetworkController:stateChange'], }); const smartTransactionsController = new SmartTransactionsController({ @@ -147,7 +168,6 @@ function withRequest( trackMetaMetricsEvent: jest.fn(), getMetaMetricsProps: jest.fn(), clientId: ClientId.Extension, - getFeatureFlags: jest.fn(), }); jest.spyOn(smartTransactionsController, 'getFees').mockResolvedValue({ @@ -201,12 +221,10 @@ function withRequest( featureFlags: { extensionActive: true, mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - extensionReturnTxHashAsapBatch: false, - }, + expectedDeadline: 45, + maxDeadline: 150, + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, }, ...options, }; @@ -295,7 +313,7 @@ describe('submitSmartTransactionHook', () => { it('skips getting fees if the transaction is signed and sponsored', async () => { withRequest(async ({ request }) => { request.transactionMeta.isGasFeeSponsored = true; - request.featureFlags.smartTransactions.extensionReturnTxHashAsap = true; + request.featureFlags.extensionReturnTxHashAsap = true; const result = await submitSmartTransactionHook(request); @@ -311,7 +329,7 @@ describe('submitSmartTransactionHook', () => { it('returns a txHash asap if the feature flag requires it', async () => { withRequest(async ({ request }) => { - request.featureFlags.smartTransactions.extensionReturnTxHashAsap = true; + request.featureFlags.extensionReturnTxHashAsap = true; const result = await submitSmartTransactionHook(request); expect(result).toEqual({ transactionHash: txHash }); }); @@ -893,196 +911,76 @@ describe('submitSmartTransactionHook', () => { }); describe('extensionSkipSTXStatusPage feature flag', () => { - it('skips status page when extensionSkipSTXStatusPage is true', async () => { - withRequest( - { - options: { - featureFlags: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - extensionReturnTxHashAsapBatch: false, - extensionSkipSmartTransactionStatusPage: true, - }, - }, - }, - }, - async ({ request, messenger, startFlowSpy, addRequestSpy }) => { - setImmediate(() => { - messenger.publish('SmartTransactionsController:smartTransaction', { - status: 'success', - uuid, - statusMetadata: { - minedHash: txHash, - }, - } as SmartTransaction); - }); - - const result = await submitSmartTransactionHook(request); - - // Status page should NOT be shown when flag is true - expect(startFlowSpy).not.toHaveBeenCalled(); - expect(addRequestSpy).not.toHaveBeenCalled(); - expect(result).toEqual({ transactionHash: txHash }); - }, - ); - }); - - it('shows status page when extensionSkipSTXStatusPage is false', async () => { - withRequest( - { - options: { - featureFlags: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - extensionReturnTxHashAsapBatch: false, - extensionSkipSmartTransactionStatusPage: false, - }, - }, - }, - }, - async ({ request, messenger, startFlowSpy, addRequestSpy }) => { - setImmediate(() => { - messenger.publish('SmartTransactionsController:smartTransaction', { - status: 'success', - uuid, - statusMetadata: { - minedHash: txHash, - }, - } as SmartTransaction); - }); - - const result = await submitSmartTransactionHook(request); - - // Status page should be shown when flag is false (existing logic applies) - expect(startFlowSpy).toHaveBeenCalled(); - expect(addRequestSpy).toHaveBeenCalled(); - expect(result).toEqual({ transactionHash: txHash }); - }, - ); - }); + const baseFeatureFlags = { + extensionActive: true, + mobileActive: false, + expectedDeadline: 45, + maxDeadline: 150, + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + }; - it('shows status page when extensionSkipSTXStatusPage is undefined (backwards compatible)', async () => { - withRequest( - { - options: { - featureFlags: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - extensionReturnTxHashAsapBatch: false, - // extensionSkipSTXStatusPage is not set (undefined) + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([ + { flag: true, shouldShow: false, desc: 'skips status page when true' }, + { flag: false, shouldShow: true, desc: 'shows status page when false' }, + { + flag: undefined, + shouldShow: true, + desc: 'shows status page when undefined (backwards compatible)', + }, + ])( + '$desc', + async ({ + flag, + shouldShow, + }: { + flag: boolean | undefined; + shouldShow: boolean; + }) => { + withRequest( + { + options: { + featureFlags: { + ...baseFeatureFlags, + extensionSkipSmartTransactionStatusPage: flag, }, }, }, - }, - async ({ request, messenger, startFlowSpy, addRequestSpy }) => { - setImmediate(() => { - messenger.publish('SmartTransactionsController:smartTransaction', { - status: 'success', - uuid, - statusMetadata: { - minedHash: txHash, - }, - } as SmartTransaction); - }); - - const result = await submitSmartTransactionHook(request); + async ({ request, messenger, startFlowSpy, addRequestSpy }) => { + setImmediate(() => { + messenger.publish( + 'SmartTransactionsController:smartTransaction', + { + status: 'success', + uuid, + statusMetadata: { minedHash: txHash }, + } as SmartTransaction, + ); + }); - // Status page should be shown when flag is undefined (existing logic applies) - expect(startFlowSpy).toHaveBeenCalled(); - expect(addRequestSpy).toHaveBeenCalled(); - expect(result).toEqual({ transactionHash: txHash }); - }, - ); - }); + const result = await submitSmartTransactionHook(request); - it('skips status page for bridge transactions when extensionSkipSTXStatusPage is true', async () => { - withRequest( - { - options: { - transactionMeta: { - hash: txHash, - status: TransactionStatus.signed, - id: '1', - txParams: { - from: addressFrom, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - maxFeePerGas: '0x2fd8a58d7', - maxPriorityFeePerGas: '0xaa0f8a94', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.bridge, - chainId: CHAIN_IDS.MAINNET, - networkClientId: 'testNetworkClientId', - time: 1624408066355, - defaultGasEstimates: { - gas: '0x7b0d', - gasPrice: '0x77359400', - }, - securityProviderResponse: { - flagAsDangerous: 0, - }, - }, - featureFlags: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - extensionReturnTxHashAsapBatch: false, - extensionSkipSmartTransactionStatusPage: true, - }, - }, + if (shouldShow) { + expect(startFlowSpy).toHaveBeenCalled(); + expect(addRequestSpy).toHaveBeenCalled(); + } else { + expect(startFlowSpy).not.toHaveBeenCalled(); + expect(addRequestSpy).not.toHaveBeenCalled(); + } + expect(result).toEqual({ transactionHash: txHash }); }, - }, - async ({ request, messenger, startFlowSpy, addRequestSpy }) => { - setImmediate(() => { - messenger.publish('SmartTransactionsController:smartTransaction', { - status: 'success', - uuid, - statusMetadata: { - minedHash: txHash, - }, - } as SmartTransaction); - }); - - const result = await submitSmartTransactionHook(request); - - // Status page should NOT be shown when flag is true (overrides existing logic) - expect(startFlowSpy).not.toHaveBeenCalled(); - expect(addRequestSpy).not.toHaveBeenCalled(); - expect(result).toEqual({ transactionHash: txHash }); - }, - ); - }); + ); + }, + ); - it('skips status page even with batch transactions when extensionSkipSTXStatusPage is true', async () => { + it('skips status page even with batch transactions when flag is true', async () => { withRequest( { options: { featureFlags: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - extensionReturnTxHashAsapBatch: false, - extensionSkipSmartTransactionStatusPage: true, - }, + ...baseFeatureFlags, + extensionSkipSmartTransactionStatusPage: true, }, transactions: [ { @@ -1101,15 +999,12 @@ describe('submitSmartTransactionHook', () => { messenger.publish('SmartTransactionsController:smartTransaction', { status: 'success', uuid, - statusMetadata: { - minedHash: txHash, - }, + statusMetadata: { minedHash: txHash }, } as SmartTransaction); }); const result = await submitSmartTransactionHook(request); - // Status page should NOT be shown when flag is true, even with batch transactions expect(startFlowSpy).not.toHaveBeenCalled(); expect(addRequestSpy).not.toHaveBeenCalled(); expect(result).toEqual({ transactionHash: txHash }); @@ -1367,7 +1262,7 @@ describe('submitBatchSmartTransactionHook', () => { it('returns txHashes asap if extensionReturnTxHashAsapBatch feature flag is enabled', async () => { withRequest(async ({ request }) => { - request.featureFlags.smartTransactions.extensionReturnTxHashAsapBatch = true; + request.featureFlags.extensionReturnTxHashAsapBatch = true; request.smartTransactionsController.submitSignedTransactions = jest.fn( async (_) => { return { @@ -1387,7 +1282,7 @@ describe('submitBatchSmartTransactionHook', () => { it('waits for transaction hash if extensionReturnTxHashAsapBatch is false', async () => { withRequest(async ({ request, messenger }) => { - request.featureFlags.smartTransactions.extensionReturnTxHashAsapBatch = false; + request.featureFlags.extensionReturnTxHashAsapBatch = false; request.smartTransactionsController.submitSignedTransactions = jest.fn( async (_) => { return { diff --git a/app/scripts/lib/smart-transaction/smart-transactions.ts b/app/scripts/lib/smart-transaction/smart-transactions.ts index c6c57ac02780..7fc8c973dafb 100644 --- a/app/scripts/lib/smart-transaction/smart-transactions.ts +++ b/app/scripts/lib/smart-transaction/smart-transactions.ts @@ -12,6 +12,7 @@ import { type Fee, type Fees, type SmartTransaction, + type SmartTransactionsNetworkConfig, } from '@metamask/smart-transactions-controller'; import { TransactionController, @@ -30,10 +31,11 @@ import { import { CANCEL_GAS_LIMIT_DEC } from '../../../../shared/constants/smartTransactions'; import { decimalToHex } from '../../../../shared/modules/conversion.utils'; import { - getFeatureFlagsByChainId, getIsSmartTransaction, isHardwareWallet, + getSmartTransactionsFeatureFlagsForChain, } from '../../../../shared/modules/selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { isLegacyTransaction } from '../../../../shared/modules/transaction.utils'; import { ControllerFlatState } from '../../controller-init/controller-list'; @@ -53,17 +55,7 @@ export type SmartTransactionHookMessenger = Messenger< AllowedEvents >; -export type FeatureFlags = { - extensionActive: boolean; - mobileActive: boolean; - smartTransactions: { - expectedDeadline?: number; - maxDeadline?: number; - extensionReturnTxHashAsap?: boolean; - extensionReturnTxHashAsapBatch?: boolean; - extensionSkipSmartTransactionStatusPage?: boolean; - }; -}; +export type FeatureFlags = SmartTransactionsNetworkConfig; export type SubmitSmartTransactionRequest = { transactionMeta: TransactionMeta; @@ -88,17 +80,7 @@ class SmartTransactionHook { #controllerMessenger: SmartTransactionHookMessenger; - #featureFlags: { - extensionActive: boolean; - mobileActive: boolean; - smartTransactions: { - expectedDeadline?: number; - maxDeadline?: number; - extensionReturnTxHashAsap?: boolean; - extensionReturnTxHashAsapBatch?: boolean; - extensionSkipSmartTransactionStatusPage?: boolean; - }; - }; + #featureFlags: FeatureFlags; #isDapp: boolean; @@ -143,7 +125,7 @@ class SmartTransactionHook { this.#txParams = transactionMeta.txParams; this.#transactions = transactions; const extensionSkipSmartTransactionStatusPage = - featureFlags?.smartTransactions?.extensionSkipSmartTransactionStatusPage; + featureFlags?.extensionSkipSmartTransactionStatusPage; this.#shouldShowStatusPage = extensionSkipSmartTransactionStatusPage ? false @@ -218,7 +200,7 @@ class SmartTransactionHook { await this.#processApprovalIfNeeded(uuid); const extensionReturnTxHashAsap = - this.#featureFlags?.smartTransactions?.extensionReturnTxHashAsap; + this.#featureFlags?.extensionReturnTxHashAsap; let transactionHash: string | undefined | null; if (extensionReturnTxHashAsap && submitTransactionResponse?.txHash) { @@ -278,7 +260,7 @@ class SmartTransactionHook { } const extensionReturnTxHashAsapBatch = - this.#featureFlags?.smartTransactions?.extensionReturnTxHashAsapBatch; + this.#featureFlags?.extensionReturnTxHashAsapBatch; if ( extensionReturnTxHashAsapBatch && @@ -574,17 +556,19 @@ function getUIState(flatState: ControllerFlatState) { export function getSmartTransactionCommonParams( flatState: ControllerFlatState, - chainId?: string, + chainId?: Hex, ) { // UI state is required to support shared selectors to avoid duplicate logic in frontend and backend. // Ideally all backend logic would instead rely on messenger event / state subscriptions. const uiState = getUIState(flatState); - + const effectiveChainId = chainId ?? getCurrentChainId(uiState); // @ts-expect-error Smart transaction selector types does not match controller state const isSmartTransaction = getIsSmartTransaction(uiState, chainId); - // @ts-expect-error Smart transaction selector types does not match controller state - const featureFlags = getFeatureFlagsByChainId(uiState, chainId); + const featureFlags = getSmartTransactionsFeatureFlagsForChain( + uiState, + effectiveChainId, + ); const isHardwareWalletAccount = isHardwareWallet(uiState); diff --git a/app/scripts/services/subscription/subscription-service.test.ts b/app/scripts/services/subscription/subscription-service.test.ts index 038d109f2487..d4ffb5300505 100644 --- a/app/scripts/services/subscription/subscription-service.test.ts +++ b/app/scripts/services/subscription/subscription-service.test.ts @@ -84,6 +84,7 @@ const mockStartShieldSubscriptionWithCard = jest.fn(); const mockGetSubscriptions = jest.fn(); const mockGetSwapsControllerState = jest.fn(); const mockGetNetworkControllerState = jest.fn(); +const mockGetRemoteFeatureFlagState = jest.fn(); const mockGetAppStateControllerState = jest.fn(); const mockGetMetaMetricsControllerState = jest.fn(); const mockGetSubscriptionControllerState = jest.fn(); @@ -132,6 +133,10 @@ rootMessenger.registerActionHandler( 'NetworkController:getState', mockGetNetworkControllerState, ); +rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockGetRemoteFeatureFlagState, +); rootMessenger.registerActionHandler( 'AppStateController:getState', mockGetAppStateControllerState, @@ -183,6 +188,7 @@ rootMessenger.delegate({ 'SmartTransactionsController:getState', 'SwapsController:getState', 'NetworkController:getState', + 'RemoteFeatureFlagController:getState', 'AppStateController:getState', 'MetaMetricsController:trackEvent', 'SubscriptionController:getState', @@ -499,6 +505,9 @@ describe('SubscriptionService - handlePostTransaction', () => { networkConfigurationsByChainId: MOCK_STATE.networkConfigurationsByChainId, networksMetadata: MOCK_STATE.networksMetadata, }); + mockGetRemoteFeatureFlagState.mockReturnValueOnce({ + remoteFeatureFlags: MOCK_STATE.remoteFeatureFlags, + }); mockGetKeyringControllerState.mockReturnValue({ keyrings: MOCK_STATE.keyrings, }); @@ -693,6 +702,9 @@ describe('SubscriptionService - submitSubscriptionSponsorshipIntent', () => { networkConfigurationsByChainId: MOCK_STATE.networkConfigurationsByChainId, networksMetadata: MOCK_STATE.networksMetadata, }); + mockGetRemoteFeatureFlagState.mockReturnValueOnce({ + remoteFeatureFlags: MOCK_STATE.remoteFeatureFlags, + }); }); it('should submit the sponsorship intent', async () => { diff --git a/app/scripts/services/subscription/subscription-service.ts b/app/scripts/services/subscription/subscription-service.ts index e9de67443fd0..680084a99f59 100644 --- a/app/scripts/services/subscription/subscription-service.ts +++ b/app/scripts/services/subscription/subscription-service.ts @@ -529,6 +529,7 @@ export class SubscriptionService { ...this.#messenger.call('PreferencesController:getState'), ...this.#messenger.call('SmartTransactionsController:getState'), ...this.#messenger.call('NetworkController:getState'), + ...this.#messenger.call('RemoteFeatureFlagController:getState'), }, }; // @ts-expect-error Smart transaction selector types does not match controller state @@ -538,6 +539,8 @@ export class SubscriptionService { return isSendBundleSupportedChain && isSmartTransaction; } + // Deprecated: remove in follow-up clean up task + // Clean-up task https://consensyssoftware.atlassian.net/browse/STX-371 async #getSwapsFeatureFlagsFromNetwork(): Promise< SwapsControllerState | undefined > { diff --git a/app/scripts/services/subscription/types.ts b/app/scripts/services/subscription/types.ts index d93fd0fd8e43..4325bce56556 100644 --- a/app/scripts/services/subscription/types.ts +++ b/app/scripts/services/subscription/types.ts @@ -20,6 +20,7 @@ import { AccountsControllerGetStateAction } from '@metamask/accounts-controller' import { SmartTransactionsControllerGetStateAction } from '@metamask/smart-transactions-controller'; import { NetworkControllerGetStateAction } from '@metamask/network-controller'; import { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import ExtensionPlatform from '../../platforms/extension'; import { WebAuthenticator } from '../oauth/types'; import { PreferencesControllerGetStateAction } from '../../controllers/preferences-controller'; @@ -62,6 +63,7 @@ export type SubscriptionServiceAction = | SmartTransactionsControllerGetStateAction | SwapsControllerGetStateAction | NetworkControllerGetStateAction + | RemoteFeatureFlagControllerGetStateAction | AuthenticationControllerGetBearerToken | AppStateControllerGetStateAction | AppStateControllerSetPendingShieldCohortAction diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 907c57853b9a..ed9bb82cd0c2 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2092,9 +2092,12 @@ "@metamask/controller-utils": true, "@metamask/controller-utils>@metamask/eth-query": true, "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "@metamask/superstruct": true, "@metamask/transaction-controller": true, + "@metamask/utils": true, "@metamask/smart-transactions-controller>bignumber.js": true, - "lodash": true + "lodash": true, + "reselect": true } }, "@metamask/snaps-controllers": { diff --git a/lavamoat/browserify/experimental/policy.json b/lavamoat/browserify/experimental/policy.json index 907c57853b9a..ed9bb82cd0c2 100644 --- a/lavamoat/browserify/experimental/policy.json +++ b/lavamoat/browserify/experimental/policy.json @@ -2092,9 +2092,12 @@ "@metamask/controller-utils": true, "@metamask/controller-utils>@metamask/eth-query": true, "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "@metamask/superstruct": true, "@metamask/transaction-controller": true, + "@metamask/utils": true, "@metamask/smart-transactions-controller>bignumber.js": true, - "lodash": true + "lodash": true, + "reselect": true } }, "@metamask/snaps-controllers": { diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 907c57853b9a..ed9bb82cd0c2 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2092,9 +2092,12 @@ "@metamask/controller-utils": true, "@metamask/controller-utils>@metamask/eth-query": true, "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "@metamask/superstruct": true, "@metamask/transaction-controller": true, + "@metamask/utils": true, "@metamask/smart-transactions-controller>bignumber.js": true, - "lodash": true + "lodash": true, + "reselect": true } }, "@metamask/snaps-controllers": { diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 907c57853b9a..ed9bb82cd0c2 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2092,9 +2092,12 @@ "@metamask/controller-utils": true, "@metamask/controller-utils>@metamask/eth-query": true, "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "@metamask/superstruct": true, "@metamask/transaction-controller": true, + "@metamask/utils": true, "@metamask/smart-transactions-controller>bignumber.js": true, - "lodash": true + "lodash": true, + "reselect": true } }, "@metamask/snaps-controllers": { diff --git a/lavamoat/webpack/mv2/policy.json b/lavamoat/webpack/mv2/policy.json index c8dfa25171aa..4ca9be9ec86d 100644 --- a/lavamoat/webpack/mv2/policy.json +++ b/lavamoat/webpack/mv2/policy.json @@ -2050,9 +2050,12 @@ "@metamask/controller-utils": true, "@metamask/controller-utils>@metamask/eth-query": true, "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "@metamask/superstruct": true, "@metamask/transaction-controller": true, + "@metamask/utils": true, "@metamask/smart-transactions-controller>bignumber.js": true, - "lodash": true + "lodash": true, + "reselect": true } }, "@metamask/snaps-controllers": { diff --git a/lavamoat/webpack/mv3/policy.json b/lavamoat/webpack/mv3/policy.json index fde5cbddb30b..6e42403668d5 100644 --- a/lavamoat/webpack/mv3/policy.json +++ b/lavamoat/webpack/mv3/policy.json @@ -1148,6 +1148,13 @@ "@metamask/scure-bip39>@scure/base": true } }, + "@metamask/smart-transactions-controller": { + "packages": { + "@metamask/superstruct": true, + "@metamask/utils": true, + "reselect": true + } + }, "@metamask/snaps-execution-environments": { "globals": { "document.getElementById": true diff --git a/package.json b/package.json index 2266e9125ad8..b1945ffc7e3c 100644 --- a/package.json +++ b/package.json @@ -375,7 +375,7 @@ "@metamask/selected-network-controller": "^25.0.0", "@metamask/shield-controller": "^4.1.0", "@metamask/signature-controller": "^38.0.0", - "@metamask/smart-transactions-controller": "^21.1.0", + "@metamask/smart-transactions-controller": "^22.0.0", "@metamask/snaps-controllers": "^17.2.1", "@metamask/snaps-execution-environments": "^10.3.0", "@metamask/snaps-rpc-methods": "^14.1.1", diff --git a/shared/modules/selectors/feature-flags.ts b/shared/modules/selectors/feature-flags.ts index 16c709fd1f64..f6c39c5e26b3 100644 --- a/shared/modules/selectors/feature-flags.ts +++ b/shared/modules/selectors/feature-flags.ts @@ -50,6 +50,12 @@ export type FeatureFlagsMetaMaskState = { }; }; +/** + * @param state + * @param chainId + * @deprecated Use selectSmartTransactionsFeatureFlagsForChain instead + * Will be removed in a future release. + */ export function getFeatureFlagsByChainId( state: ProviderConfigState & FeatureFlagsMetaMaskState, chainId?: string, diff --git a/shared/modules/selectors/index.test.ts b/shared/modules/selectors/index.test.ts index 9b0d06765a85..c355c0abad24 100644 --- a/shared/modules/selectors/index.test.ts +++ b/shared/modules/selectors/index.test.ts @@ -11,6 +11,7 @@ import { getSmartTransactionsEnabled, getIsSmartTransaction, getSmartTransactionsPreferenceEnabled, + getSmartTransactionsFeatureFlagsForChain, } from '.'; describe('Selectors', () => { @@ -40,20 +41,19 @@ describe('Selectors', () => { balance: '0x15f6f0b9d4f8d000', }, }, - swapsState: { - swapsFeatureFlags: { - ethereum: { + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - }, }, - smartTransactions: { + [CHAIN_IDS.MAINNET]: { extensionActive: true, - mobileActive: false, + expectedDeadline: 45, + maxDeadline: 150, + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + batchStatusPollingInterval: 1000, }, }, }, @@ -195,7 +195,9 @@ describe('Selectors', () => { 'returns false if feature flag is disabled, not a HW and is Ethereum network', () => { const state = createSwapsMockStore(); - state.metamask.swapsState.swapsFeatureFlags.smartTransactions.extensionActive = false; + state.metamask.remoteFeatureFlags.smartTransactionsNetworks[ + CHAIN_IDS.MAINNET + ] = { extensionActive: false }; expect(getSmartTransactionsEnabled(state)).toBe(false); }, ); @@ -285,15 +287,9 @@ describe('Selectors', () => { 'returns false if feature flag is disabled for BSC, not a HW and is BSC network with a default RPC URL', () => { const state = createSwapsMockStore(); - state.metamask.swapsState.swapsFeatureFlags = { - ...state.metamask.swapsState.swapsFeatureFlags, - bsc: { - ...state.metamask.swapsState.swapsFeatureFlags.bsc, - smartTransactions: { - extensionActive: false, - }, - }, - }; + state.metamask.remoteFeatureFlags.smartTransactionsNetworks[ + CHAIN_IDS.BSC + ] = { extensionActive: false }; const newState = { ...state, metamask: { @@ -330,6 +326,14 @@ describe('Selectors', () => { 'returns true if feature flag is enabled, not a HW and is Linea network with a default RPC URL', () => { const state = createSwapsMockStore(); + ( + state.metamask.remoteFeatureFlags.smartTransactionsNetworks as Record< + string, + { extensionActive: boolean } + > + )[CHAIN_IDS.LINEA_MAINNET] = { + extensionActive: true, + }; const newState = { ...state, metamask: { @@ -338,19 +342,6 @@ describe('Selectors', () => { chainId: CHAIN_IDS.LINEA_MAINNET, rpcUrl: 'https://linea-mainnet.infura.io/v3/test-project-id', }), - swapsState: { - ...state.metamask.swapsState, - swapsFeatureFlags: { - ...state.metamask.swapsState.swapsFeatureFlags, - linea: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - extensionActive: true, - }, - }, - }, - }, }, }; expect(getSmartTransactionsEnabled(newState)).toBe(true); @@ -361,6 +352,14 @@ describe('Selectors', () => { 'returns false if feature flag is enabled, not a HW and is Linea network with a non-default RPC URL', () => { const state = createSwapsMockStore(); + ( + state.metamask.remoteFeatureFlags.smartTransactionsNetworks as Record< + string, + { extensionActive: boolean } + > + )[CHAIN_IDS.LINEA_MAINNET] = { + extensionActive: true, + }; const newState = { ...state, metamask: { @@ -369,19 +368,6 @@ describe('Selectors', () => { chainId: CHAIN_IDS.LINEA_MAINNET, rpcUrl: 'https://rpc.linea.build/', }), - swapsState: { - ...state.metamask.swapsState, - swapsFeatureFlags: { - ...state.metamask.swapsState.swapsFeatureFlags, - linea: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - extensionActive: true, - }, - }, - }, - }, }, }; expect(getSmartTransactionsEnabled(newState)).toBe(false); @@ -552,19 +538,11 @@ describe('Selectors', () => { ...state, metamask: { ...state.metamask, - swapsState: { - ...state.metamask.swapsState, - swapsFeatureFlags: { - ethereum: { - extensionActive: true, - mobileActive: false, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - extensionReturnTxHashAsap: false, - }, - }, - smartTransactions: { + remoteFeatureFlags: { + ...state.metamask.remoteFeatureFlags, + smartTransactionsNetworks: { + ...state.metamask.remoteFeatureFlags.smartTransactionsNetworks, + [CHAIN_IDS.MAINNET]: { extensionActive: false, mobileActive: false, }, @@ -603,4 +581,85 @@ describe('Selectors', () => { ); }); }); + + describe('getSmartTransactionsFeatureFlagsForChain', () => { + const createStateWithFeatureFlags = ( + smartTransactionsNetworks: Record, + ) => ({ + metamask: { + remoteFeatureFlags: { + smartTransactionsNetworks, + }, + }, + }); + + jestIt( + 'should return merged config with default and chain-specific values', + () => { + const state = createStateWithFeatureFlags({ + default: { + extensionActive: true, + expectedDeadline: 45, + maxDeadline: 150, + }, + [CHAIN_IDS.MAINNET]: { + extensionActive: true, + expectedDeadline: 30, + }, + }); + + const result = getSmartTransactionsFeatureFlagsForChain( + state, + CHAIN_IDS.MAINNET, + ); + + // Chain-specific expectedDeadline should override default + expect(result.expectedDeadline).toBe(30); + // Default maxDeadline should be used since not specified for chain + expect(result.maxDeadline).toBe(150); + expect(result.extensionActive).toBe(true); + }, + ); + + jestIt('should return default config when chain is not configured', () => { + const state = createStateWithFeatureFlags({ + default: { + extensionActive: true, + expectedDeadline: 45, + maxDeadline: 150, + }, + [CHAIN_IDS.MAINNET]: { + extensionActive: true, + }, + }); + + // Use OPTIMISM (not in allowed STX chain IDs) to test unconfigured chain behavior + const result = getSmartTransactionsFeatureFlagsForChain( + state, + CHAIN_IDS.OPTIMISM, + ); + + // Controller returns false for extensionActive on unconfigured chains, + // but inherits numeric values like expectedDeadline/maxDeadline from default + expect(result.extensionActive).toBe(false); + expect(result.expectedDeadline).toBe(45); + expect(result.maxDeadline).toBe(150); + }); + + jestIt( + 'should return default values when no feature flags are configured', + () => { + const state = createStateWithFeatureFlags({}); + + const result = getSmartTransactionsFeatureFlagsForChain( + state, + CHAIN_IDS.MAINNET, + ); + + // Should return default config from the controller + expect(result).toBeDefined(); + expect(typeof result.extensionActive).toBe('boolean'); + }, + ); + }); }); diff --git a/shared/modules/selectors/index.ts b/shared/modules/selectors/index.ts index 9cd44470752b..6e983385b5cf 100644 --- a/shared/modules/selectors/index.ts +++ b/shared/modules/selectors/index.ts @@ -3,7 +3,6 @@ import { getHardwareWalletType } from '../../../ui/selectors/selectors'; export * from './smart-transactions'; -export * from './feature-flags'; export * from './account'; export { getHardwareWalletType }; diff --git a/shared/modules/selectors/smart-transactions.ts b/shared/modules/selectors/smart-transactions.ts index 622d570f8752..a4c75c7ab15f 100644 --- a/shared/modules/selectors/smart-transactions.ts +++ b/shared/modules/selectors/smart-transactions.ts @@ -1,4 +1,10 @@ import { createSelector } from 'reselect'; +import type { + SmartTransactionsNetworkConfig, + SmartTransactionsFeatureFlagsState, +} from '@metamask/smart-transactions-controller'; +import { selectSmartTransactionsFeatureFlagsForChain } from '@metamask/smart-transactions-controller'; +import type { Hex, CaipChainId } from '@metamask/utils'; import { getAllowedSmartTransactionsChainIds, SKIP_STX_RPC_URL_CHECK_CHAIN_IDS, @@ -10,9 +16,14 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file. +import { + getRemoteFeatureFlags, + type RemoteFeatureFlagsState, + // eslint-disable-next-line import/no-restricted-paths +} from '../../../ui/selectors/remote-feature-flags'; import { isProduction } from '../environment'; -import { getFeatureFlagsByChainId } from './feature-flags'; -import { getCurrentChainId, NetworkState } from './networks'; +import { getCurrentChainId, type NetworkState } from './networks'; +import { createDeepEqualSelector } from './util'; export type SmartTransactionsMetaMaskState = { metamask: { @@ -32,23 +43,6 @@ export type SmartTransactionsMetaMaskState = { }; }; }; - swapsState: { - swapsFeatureFlags: { - ethereum: { - extensionActive: boolean; - mobileActive: boolean; - smartTransactions: { - expectedDeadline?: number; - maxDeadline?: number; - extensionReturnTxHashAsap?: boolean; - }; - }; - smartTransactions: { - extensionActive: boolean; - mobileActive: boolean; - }; - }; - }; smartTransactionsState: { liveness: boolean; livenessByChainId: Record; @@ -57,7 +51,30 @@ export type SmartTransactionsMetaMaskState = { }; export type SmartTransactionsState = SmartTransactionsMetaMaskState & - NetworkState; + NetworkState & + RemoteFeatureFlagsState; + +/** + * Stable wrapper for controller's feature flags state shape. + */ +const selectSmartTransactionsFeatureFlagsState = createDeepEqualSelector( + (state) => getRemoteFeatureFlags(state).smartTransactionsNetworks, + (smartTransactionsNetworks): SmartTransactionsFeatureFlagsState => ({ + remoteFeatureFlags: { smartTransactionsNetworks }, + }), +); + +/** + * @param state - The Redux state. + * @param chainId - The chain ID (hex or CAIP-2 format). + * @returns The validated and merged feature flags for the chain. + */ +export const getSmartTransactionsFeatureFlagsForChain = createDeepEqualSelector( + selectSmartTransactionsFeatureFlagsState, + (_state, chainId: Hex | CaipChainId) => chainId, + (featureFlagsState, chainId): SmartTransactionsNetworkConfig => + selectSmartTransactionsFeatureFlagsForChain(featureFlagsState, chainId), +); /** * Returns the user's explicit opt-in status for the smart transactions feature. @@ -177,13 +194,14 @@ export const getSmartTransactionsEnabled = ( state: SmartTransactionsState, chainId?: string, ): boolean => { - const effectiveChainId = chainId ?? getCurrentChainId(state); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const effectiveChainId = (chainId || getCurrentChainId(state)) as Hex; const supportedAccount = accountSupportsSmartTx(state); - // @ts-expect-error Smart transaction selector types does not match controller state - const featureFlagsByChainId = getFeatureFlagsByChainId(state, chainId); - // TODO: Create a new proxy service only for MM feature flags. - const smartTransactionsFeatureFlagEnabled = - featureFlagsByChainId?.smartTransactions?.extensionActive; + const featureFlags = getSmartTransactionsFeatureFlagsForChain( + state, + effectiveChainId, + ); + const smartTransactionsFeatureFlagEnabled = featureFlags?.extensionActive; const smartTransactionsLiveness = state.metamask.smartTransactionsState?.livenessByChainId?.[ effectiveChainId diff --git a/test/data/bridge/mock-bridge-store.ts b/test/data/bridge/mock-bridge-store.ts index c416510d506e..d2472230047b 100644 --- a/test/data/bridge/mock-bridge-store.ts +++ b/test/data/bridge/mock-bridge-store.ts @@ -9,6 +9,7 @@ import { AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS } from '@metamask/multichai import { KeyringTypes } from '@metamask/keyring-controller'; import { EthAccountType, EthScope } from '@metamask/keyring-api'; import { ETH_SCOPE_EOA } from '@metamask/keyring-utils'; +import type { SmartTransactionsNetworks } from '../../../shared/modules/selectors/feature-flags'; import { CHAIN_IDS } from '../../../shared/constants/network'; import type { BridgeAppState } from '../../../ui/ducks/bridge/selectors'; import { createSwapsMockStore } from '../../jest/mock-store'; @@ -127,7 +128,10 @@ export const createBridgeMockStore = ({ metamaskStateOverrides = {}, stateOverrides = {}, }: { - featureFlagOverrides?: { bridgeConfig: Partial }; + featureFlagOverrides?: { + bridgeConfig: Partial; + smartTransactionsNetworks?: SmartTransactionsNetworks; + }; bridgeStateOverrides?: Partial; // bridgeStatusStateOverrides?: Partial; // metamaskStateOverrides?: Partial; @@ -341,10 +345,18 @@ export const createBridgeMockStore = ({ }, }, ], + smartTransactionsState: { + liveness: false, + livenessByChainId: { '0x1': true }, + }, ...{ ...getDefaultBridgeControllerState(), remoteFeatureFlags: { ...featureFlagOverrides, + smartTransactionsNetworks: { + '0x1': { extensionActive: true }, + ...featureFlagOverrides?.smartTransactionsNetworks, + }, bridgeConfig: { minimumVersion: '0.0.0', support: false, diff --git a/test/e2e/tests/confirmations/transactions/gas-fee-tokens-smart-transactions.spec.ts b/test/e2e/tests/confirmations/transactions/gas-fee-tokens-smart-transactions.spec.ts index 53ad01b0c1eb..4fadcf941b86 100644 --- a/test/e2e/tests/confirmations/transactions/gas-fee-tokens-smart-transactions.spec.ts +++ b/test/e2e/tests/confirmations/transactions/gas-fee-tokens-smart-transactions.spec.ts @@ -16,6 +16,7 @@ import HomePage from '../../../page-objects/pages/home/homepage'; import { Driver } from '../../../webdriver/driver'; import { mockSmartTransactionBatchRequests } from '../../smart-transactions/mocks'; import { mockSpotPrices } from '../../tokens/utils/mocks'; +import { mockSmartTransactionsRemoteFlags } from '../../smart-transactions/remote-flags'; const TRANSACTION_HASH = '0xf25183af3bf64af01e9210201a2ede3c1dcd6d16091283152d13265242939fc4'; @@ -46,6 +47,7 @@ describe('Gas Fee Tokens - Smart Transactions', function (this: Suite) { hardfork: 'london', }, testSpecificMock: async (mockServer: MockttpServer) => { + await mockSmartTransactionsRemoteFlags(mockServer); await mockMultiNetworkBalancePolling(mockServer); mockSimulationResponse(mockServer); mockSmartTransactionBatchRequests(mockServer, { @@ -112,13 +114,14 @@ describe('Gas Fee Tokens - Smart Transactions', function (this: Suite) { localNodeOptions: { hardfork: 'london', }, - testSpecificMock: (mockServer: MockttpServer) => { - mockSimulationResponse(mockServer); - mockSmartTransactionBatchRequests(mockServer, { + testSpecificMock: async (mockServer: MockttpServer) => { + await mockSmartTransactionsRemoteFlags(mockServer); + await mockSimulationResponse(mockServer); + await mockSmartTransactionBatchRequests(mockServer, { transactionHashes: [TRANSACTION_HASH, TRANSACTION_HASH_2], error: true, }); - mockSentinelNetworks(mockServer); + await mockSentinelNetworks(mockServer); }, title: this.test?.fullTitle(), }, diff --git a/test/e2e/tests/smart-transactions/remote-flags.ts b/test/e2e/tests/smart-transactions/remote-flags.ts new file mode 100644 index 000000000000..deae20a97007 --- /dev/null +++ b/test/e2e/tests/smart-transactions/remote-flags.ts @@ -0,0 +1,39 @@ +import { MockttpServer } from 'mockttp'; + +/** + * Mocks remote feature flags for smart transactions usage in tests. + * + * @param mockServer - Mock server instance used to stub the flags request. + */ +export async function mockSmartTransactionsRemoteFlags( + mockServer: MockttpServer, +): Promise { + await mockServer + .forGet('https://client-config.api.cx.metamask.io/v1/flags') + .withQuery({ + client: 'extension', + distribution: 'main', + environment: 'dev', + }) + .always() + .thenCallback(() => { + return { + ok: true, + statusCode: 200, + json: [ + { + smartTransactionsNetworks: { + '0x1': { + extensionActive: true, + }, + }, + }, + { + sendRedesign: { + enabled: false, + }, + }, + ], + }; + }); +} diff --git a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts index 85f34f3d500d..341e41d7ace2 100644 --- a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts +++ b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts @@ -12,6 +12,7 @@ import SwapPage from '../../page-objects/pages/swap/swap-page'; import SendTokenPage from '../../page-objects/pages/send/send-token-page'; import { TX_SENTINEL_URL } from '../../../../shared/constants/transaction'; import { mockSpotPrices } from '../tokens/utils/mocks'; +import { mockSmartTransactionsRemoteFlags } from './remote-flags'; import { mockSmartTransactionRequests, mockGasIncludedTransactionRequests, @@ -45,7 +46,10 @@ async function withFixturesForSmartTransactions( hardfork: 'london', chainId: '1', }, - testSpecificMock, + testSpecificMock: async (mockServer: MockttpServer) => { + await mockSmartTransactionsRemoteFlags(mockServer); + await testSpecificMock(mockServer); + }, }, async ({ driver }) => { await unlockWallet(driver); diff --git a/test/integration/data/integration-init-state.json b/test/integration/data/integration-init-state.json index 7345d21fc111..bb298043b610 100644 --- a/test/integration/data/integration-init-state.json +++ b/test/integration/data/integration-init-state.json @@ -856,6 +856,14 @@ "remoteFeatureFlags": { "bridgeConfig": { "support": true + }, + "smartTransactionsNetworks": { + "0x1": { + "extensionActive": true + }, + "0xaa36a7": { + "extensionActive": true + } } }, "securityAlertsEnabled": true, diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index d21ade33d044..4cb51afaf430 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -139,6 +139,17 @@ export const createSwapsMockStore = () => { bridgeConfig: { support: false, }, + smartTransactionsNetworks: { + default: { + extensionActive: false, + }, + [CHAIN_IDS.MAINNET]: { + extensionActive: true, + }, + [CHAIN_IDS.BSC]: { + extensionActive: true, + }, + }, }, preferences: { showFiatInTestnets: true, diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index 8791dc59fef3..74e01829dde1 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -1896,6 +1896,14 @@ describe('Bridge selectors', () => { describe('getIsGasIncluded', () => { it('returns true when both smart transactions are enabled and chain supports gas-included swaps', () => { const state = createBridgeMockStore({ + featureFlagOverrides: { + bridgeConfig: {}, + smartTransactionsNetworks: { + [CHAIN_IDS.MAINNET]: { + extensionActive: true, + }, + }, + }, metamaskStateOverrides: { ...mockNetworkState({ id: 'network-configuration-id-1', @@ -1917,26 +1925,6 @@ describe('Bridge selectors', () => { '0x1': true, }, }, - swapsState: { - swapsFeatureFlags: { - ethereum: { - extensionActive: true, - mobileActive: true, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - returnTxHashAsap: false, - extensionActive: true, - }, - }, - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 150, - returnTxHashAsap: false, - extensionActive: true, - }, - }, - }, }, }); diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index 99c523cd8689..c88b32c87537 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -58,7 +58,6 @@ import { getCurrentChainId, getSelectedNetworkClientId, } from '../../../shared/modules/selectors/networks'; -import { getFeatureFlagsByChainId } from '../../../shared/modules/selectors/feature-flags'; import { getSelectedAccount, getTokenExchangeRates, @@ -73,6 +72,7 @@ import { } from '../../selectors'; import { getSmartTransactionsEnabled, + getSmartTransactionsFeatureFlagsForChain, getSmartTransactionsOptInStatusForMetrics, getSmartTransactionsPreferenceEnabled, } from '../../../shared/modules/selectors'; @@ -952,11 +952,15 @@ export const signAndSendSwapsSmartTransaction = ({ const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData; const usedQuote = getUsedQuote(state); const selectedNetwork = getSelectedNetwork(state); - const swapsFeatureFlags = getFeatureFlagsByChainId(state); + const chainId = getCurrentChainId(state); + const featureFlags = getSmartTransactionsFeatureFlagsForChain( + state, + chainId, + ); dispatch( setSmartTransactionsRefreshInterval( - swapsFeatureFlags?.smartTransactions?.batchStatusPollingInterval, + featureFlags?.batchStatusPollingInterval, ), ); diff --git a/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.test.tsx b/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.test.tsx index 6b30a51dd5c7..afc184bf139f 100644 --- a/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.test.tsx +++ b/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.test.tsx @@ -158,8 +158,8 @@ describe('SmartTransactionsBannerAlert', () => { ...mockState.metamask, ...mockNetworkState({ id: 'network-configuration-id-2', - chainId: CHAIN_IDS.POLYGON, - rpcUrl: 'https://polygon-rpc.com', + chainId: CHAIN_IDS.OPTIMISM, // OPTIMISM is not in the allowed STX chain IDs + rpcUrl: 'https://optimism-rpc.com', }), }, }; diff --git a/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.test.ts b/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.test.ts index d651874946d8..a0e6435d702c 100644 --- a/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.test.ts +++ b/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.test.ts @@ -35,10 +35,12 @@ async function runHook({ smartTransactionsOptInStatus, chainId, confirmation, + batchStatusPollingInterval, }: { smartTransactionsOptInStatus: boolean; chainId: Hex; confirmation?: Partial; + batchStatusPollingInterval?: number; }) { const transaction = (confirmation as TransactionMeta) ?? @@ -53,6 +55,17 @@ async function runHook({ preferences: { smartTransactionsOptInStatus, }, + remoteFeatureFlags: { + smartTransactionsNetworks: { + default: { + extensionActive: true, + }, + [chainId]: { + extensionActive: true, + batchStatusPollingInterval, + }, + }, + }, }, }); @@ -106,7 +119,7 @@ describe('useSmartTransactionFeatureFlags', () => { it('does not update feature flags if chain not supported', async () => { await runHook({ smartTransactionsOptInStatus: true, - chainId: CHAIN_IDS.POLYGON, + chainId: CHAIN_IDS.OPTIMISM, // OPTIMISM is not in the allowed STX chain IDs }); expect(setSwapsFeatureFlagsMock).not.toHaveBeenCalled(); @@ -123,34 +136,24 @@ describe('useSmartTransactionFeatureFlags', () => { }); it('updates refresh interval when feature flags include interval', async () => { - fetchSwapsFeatureFlagsMock.mockResolvedValue({ - smartTransactions: { - batchStatusPollingInterval: 1000, - }, - }); - await runHook({ smartTransactionsOptInStatus: true, chainId: CHAIN_IDS.MAINNET, + batchStatusPollingInterval: 5000, }); expect(setSmartTransactionsRefreshIntervalMock).toHaveBeenCalledTimes(1); - expect(setSmartTransactionsRefreshIntervalMock).toHaveBeenCalledWith(1000); + expect(setSmartTransactionsRefreshIntervalMock).toHaveBeenCalledWith(5000); }); - it('does not update refresh interval when feature flags do not include interval', async () => { - fetchSwapsFeatureFlagsMock.mockResolvedValue({ - smartTransactions: {}, - }); - + it('uses default refresh interval when feature flags do not include interval', async () => { await runHook({ smartTransactionsOptInStatus: true, chainId: CHAIN_IDS.MAINNET, + // batchStatusPollingInterval not set, so defaults to 1000 }); expect(setSmartTransactionsRefreshIntervalMock).toHaveBeenCalledTimes(1); - expect(setSmartTransactionsRefreshIntervalMock).toHaveBeenCalledWith( - undefined, - ); + expect(setSmartTransactionsRefreshIntervalMock).toHaveBeenCalledWith(1000); }); }); diff --git a/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.ts b/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.ts index 67201cb33f67..4f81c6fab403 100644 --- a/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.ts +++ b/ui/pages/confirmations/hooks/useSmartTransactionFeatureFlags.ts @@ -3,7 +3,10 @@ import { useEffect } from 'react'; import { TransactionMeta } from '@metamask/transaction-controller'; import log from 'loglevel'; import { getAllowedSmartTransactionsChainIds } from '../../../../shared/constants/smartTransactions'; -import { getSmartTransactionsPreferenceEnabled } from '../../../../shared/modules/selectors'; +import { + getSmartTransactionsFeatureFlagsForChain, + getSmartTransactionsPreferenceEnabled, +} from '../../../../shared/modules/selectors'; import { fetchSwapsFeatureFlags } from '../../swaps/swaps.util'; import { fetchSmartTransactionsLiveness, @@ -31,6 +34,12 @@ export function useSmartTransactionFeatureFlags() { transactionChainId && getAllowedSmartTransactionsChainIds().includes(transactionChainId); + const featureFlags = useSelector((state) => + transactionChainId + ? getSmartTransactionsFeatureFlagsForChain(state, transactionChainId) + : undefined, + ); + useEffect(() => { if ( !isTransaction || @@ -42,6 +51,7 @@ export function useSmartTransactionFeatureFlags() { } Promise.all([ + // TODO: remove this when swaps feature flags are removed. fetchSwapsFeatureFlags(), fetchSmartTransactionsLiveness({ chainId: transactionChainId })(), ]) @@ -49,7 +59,7 @@ export function useSmartTransactionFeatureFlags() { dispatch(setSwapsFeatureFlags(swapsFeatureFlags)); dispatch( setSmartTransactionsRefreshInterval( - swapsFeatureFlags.smartTransactions?.batchStatusPollingInterval, + featureFlags?.batchStatusPollingInterval ?? 1000, ), ); }) @@ -62,5 +72,7 @@ export function useSmartTransactionFeatureFlags() { smartTransactionsPreferenceEnabled, chainSupportsSTX, transactionChainId, + featureFlags, + dispatch, ]); } diff --git a/yarn.lock b/yarn.lock index 2dab1e42b07a..b440e309bb80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8240,9 +8240,9 @@ __metadata: languageName: node linkType: hard -"@metamask/smart-transactions-controller@npm:^21.1.0": - version: 21.1.0 - resolution: "@metamask/smart-transactions-controller@npm:21.1.0" +"@metamask/smart-transactions-controller@npm:^22.0.0": + version: 22.0.0 + resolution: "@metamask/smart-transactions-controller@npm:22.0.0" dependencies: "@babel/runtime": "npm:^7.24.1" "@ethereumjs/tx": "npm:^5.2.1" @@ -8256,11 +8256,16 @@ __metadata: "@metamask/eth-query": "npm:^4.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^15.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.0.0" bignumber.js: "npm:^9.0.1" fast-json-patch: "npm:^3.1.0" lodash: "npm:^4.17.21" + reselect: "npm:^5.1.1" 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": @@ -8271,9 +8276,7 @@ __metadata: optional: true "@metamask/gas-fee-controller": optional: true - "@metamask/remote-feature-flag-controller": - optional: true - checksum: 10/85d51b140fd535cf129eed062d687f66d308a11315f433f60bf8484a447a00419af3939f6bf59d43bc3b8b9f0a8ee91a480937e1caef40984bde829cbce2d6eb + checksum: 10/e3b9eebfbd3a3b7dad9a53403d11f4fe50060916c69086afdbe3d48a6348becac2696829c6538c7e8f9cccff16c9d142ea60e78b5579a9be0d9e2524a56c63ec languageName: node linkType: hard @@ -8837,7 +8840,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.0, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.0, @metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.0, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.9.0 resolution: "@metamask/utils@npm:11.9.0" dependencies: @@ -33581,7 +33584,7 @@ __metadata: "@metamask/selected-network-controller": "npm:^25.0.0" "@metamask/shield-controller": "npm:^4.1.0" "@metamask/signature-controller": "npm:^38.0.0" - "@metamask/smart-transactions-controller": "npm:^21.1.0" + "@metamask/smart-transactions-controller": "npm:^22.0.0" "@metamask/snap-account-abstraction-keyring-site": "npm:^1.0.0" "@metamask/snap-simple-keyring-site": "npm:^2.0.0" "@metamask/snaps-controllers": "npm:^17.2.1"