diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0ef2ec82406f..be2f7e4f6046 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5288,6 +5288,15 @@ "smartTransactions": { "message": "Smart Transactions" }, + "smartTransactionsEnabledDescription": { + "message": " and MEV protection. Now on by default." + }, + "smartTransactionsEnabledLink": { + "message": "Higher success rates" + }, + "smartTransactionsEnabledTitle": { + "message": "Transactions just got smarter" + }, "snapAccountCreated": { "message": "Account created" }, diff --git a/app/scripts/controllers/preferences-controller.test.ts b/app/scripts/controllers/preferences-controller.test.ts index 39a2d49648b2..f30e938e635e 100644 --- a/app/scripts/controllers/preferences-controller.test.ts +++ b/app/scripts/controllers/preferences-controller.test.ts @@ -733,6 +733,7 @@ describe('preferences controller', () => { privacyMode: false, showFiatInTestnets: false, showTestNetworks: false, + smartTransactionsMigrationApplied: false, smartTransactionsOptInStatus: true, useNativeCurrencyAsPrimaryCurrency: true, hideZeroBalanceTokens: false, @@ -762,6 +763,7 @@ describe('preferences controller', () => { showExtensionInFullSizeView: false, showFiatInTestnets: false, showTestNetworks: false, + smartTransactionsMigrationApplied: false, smartTransactionsOptInStatus: true, useNativeCurrencyAsPrimaryCurrency: true, hideZeroBalanceTokens: false, diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index d705be4c3180..217fe43b022d 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -104,6 +104,7 @@ export type Preferences = { showFiatInTestnets: boolean; showTestNetworks: boolean; smartTransactionsOptInStatus: boolean; + smartTransactionsMigrationApplied: boolean; showNativeTokenAsMainBalance: boolean; useNativeCurrencyAsPrimaryCurrency: boolean; hideZeroBalanceTokens: boolean; @@ -129,6 +130,7 @@ export type PreferencesControllerState = Omit< PreferencesState, | 'showTestNetworks' | 'smartTransactionsOptInStatus' + | 'smartTransactionsMigrationApplied' | 'privacyMode' | 'tokenSortConfig' | 'useMultiRpcMigration' @@ -217,6 +219,7 @@ export const getDefaultPreferencesControllerState = showFiatInTestnets: false, showTestNetworks: false, smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: false, showNativeTokenAsMainBalance: false, useNativeCurrencyAsPrimaryCurrency: true, hideZeroBalanceTokens: false, @@ -406,6 +409,16 @@ const controllerMetadata = { preferences: { persist: true, anonymous: true, + properties: { + smartTransactionsOptInStatus: { + persist: true, + anonymous: true, + }, + smartTransactionsMigrationApplied: { + persist: true, + anonymous: true, + }, + }, }, ipfsGateway: { persist: true, diff --git a/app/scripts/migrations/135.test.ts b/app/scripts/migrations/135.test.ts new file mode 100644 index 000000000000..b2cca43b7733 --- /dev/null +++ b/app/scripts/migrations/135.test.ts @@ -0,0 +1,187 @@ +import { SmartTransaction } from '@metamask/smart-transactions-controller/dist/types'; +import { migrate, VersionedData } from './135'; + +const prevVersion = 134; + +describe('migration #135', () => { + const mockSmartTransaction: SmartTransaction = { + uuid: 'test-uuid', + }; + + it('should update the version metadata', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: {}, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.meta).toStrictEqual({ version: 135 }); + }); + + it('should set stx opt-in to true and migration flag when stx opt-in status is null', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: { + PreferencesController: { + preferences: { + smartTransactionsOptInStatus: null, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsOptInStatus, + ).toBe(true); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsMigrationApplied, + ).toBe(true); + }); + + it('should set stx opt-in to true and migration flag when stx opt-in status is undefined', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: { + PreferencesController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsOptInStatus, + ).toBe(true); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsMigrationApplied, + ).toBe(true); + }); + + it('should set stx opt-in to true and migration flag when stx opt-in is false and no existing mainnet smart transactions', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: { + PreferencesController: { + preferences: { + smartTransactionsOptInStatus: false, + }, + }, + SmartTransactionsController: { + smartTransactionsState: { + smartTransactions: { + '0x1': [], // Empty mainnet transactions + '0xAA36A7': [mockSmartTransaction], // Sepolia has transactions + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsOptInStatus, + ).toBe(true); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsMigrationApplied, + ).toBe(true); + }); + + it('should not change stx opt-in when stx opt-in is false but has existing smart transactions, but should set migration flag', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: { + PreferencesController: { + preferences: { + smartTransactionsOptInStatus: false, + }, + }, + SmartTransactionsController: { + smartTransactionsState: { + smartTransactions: { + '0x1': [mockSmartTransaction], + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsOptInStatus, + ).toBe(false); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsMigrationApplied, + ).toBe(true); + }); + + it('should not change stx opt-in when stx opt-in is already true, but should set migration flag', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: { + PreferencesController: { + preferences: { + smartTransactionsOptInStatus: true, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsOptInStatus, + ).toBe(true); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsMigrationApplied, + ).toBe(true); + }); + + it('should initialize preferences object if it does not exist', async () => { + const oldStorage: VersionedData = { + meta: { version: prevVersion }, + data: { + PreferencesController: { + preferences: { + smartTransactionsOptInStatus: true, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data.PreferencesController?.preferences).toBeDefined(); + expect( + newStorage.data.PreferencesController?.preferences + ?.smartTransactionsMigrationApplied, + ).toBe(true); + }); + + it('should capture exception if PreferencesController state is invalid', async () => { + const sentryCaptureExceptionMock = jest.fn(); + global.sentry = { + captureException: sentryCaptureExceptionMock, + }; + + const oldStorage = { + meta: { version: prevVersion }, + data: { + PreferencesController: 'invalid', + }, + } as unknown as VersionedData; + + await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledTimes(1); + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error('Invalid PreferencesController state: string'), + ); + }); +}); diff --git a/app/scripts/migrations/135.ts b/app/scripts/migrations/135.ts new file mode 100644 index 000000000000..277aafa66227 --- /dev/null +++ b/app/scripts/migrations/135.ts @@ -0,0 +1,84 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; +import type { SmartTransaction } from '@metamask/smart-transactions-controller/dist/types'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +export type VersionedData = { + meta: { + version: number; + }; + data: { + PreferencesController?: { + preferences?: { + smartTransactionsOptInStatus?: boolean | null; + smartTransactionsMigrationApplied?: boolean; + }; + }; + SmartTransactionsController?: { + smartTransactionsState: { + smartTransactions: Record; + }; + }; + }; +}; + +export const version = 135; + +function transformState(state: VersionedData['data']) { + if ( + !hasProperty(state, 'PreferencesController') || + !isObject(state.PreferencesController) + ) { + global.sentry?.captureException?.( + new Error( + `Invalid PreferencesController state: ${typeof state.PreferencesController}`, + ), + ); + return state; + } + + const { PreferencesController } = state; + + const currentOptInStatus = + PreferencesController.preferences?.smartTransactionsOptInStatus; + + if ( + currentOptInStatus === undefined || + currentOptInStatus === null || + (currentOptInStatus === false && !hasExistingSmartTransactions(state)) + ) { + state.PreferencesController.preferences = { + ...state.PreferencesController.preferences, + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }; + } else { + state.PreferencesController.preferences = { + ...state.PreferencesController.preferences, + smartTransactionsMigrationApplied: true, + }; + } + + return state; +} + +function hasExistingSmartTransactions(state: VersionedData['data']): boolean { + const smartTransactions = + state?.SmartTransactionsController?.smartTransactionsState + ?.smartTransactions; + + if (!isObject(smartTransactions)) { + return false; + } + + return (smartTransactions[CHAIN_IDS.MAINNET] || []).length > 0; +} + +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + transformState(versionedData.data); + return versionedData; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 8700b833d8de..35b72816b388 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -158,6 +158,7 @@ const migrations = [ require('./133.1'), require('./133.2'), require('./134'), + require('./135'), ]; export default migrations; diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 24c6e9ae27da..7a23e6eca4b6 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -13,6 +13,7 @@ "bafkreifvhjdf6ve4jfv6qytqtux5nd4nwnelioeiqx5x2ez5yrgrzk7ypi.ipfs.dweb.link", "bafybeidxfmwycgzcp4v2togflpqh2gnibuexjy4m4qqwxp7nh3jx5zlh4y.ipfs.dweb.link", "bridge.api.cx.metamask.io", + "bridge.dev-api.cx.metamask.io", "cdn.segment.com", "cdn.segment.io", "cdnjs.cloudflare.com", diff --git a/shared/constants/alerts.ts b/shared/constants/alerts.ts index bbf6318f448e..71b246db2fb2 100644 --- a/shared/constants/alerts.ts +++ b/shared/constants/alerts.ts @@ -2,6 +2,7 @@ export enum AlertTypes { unconnectedAccount = 'unconnectedAccount', web3ShimUsage = 'web3ShimUsage', invalidCustomNetwork = 'invalidCustomNetwork', + smartTransactionsMigration = 'smartTransactionsMigration', } /** @@ -10,6 +11,7 @@ export enum AlertTypes { export const TOGGLEABLE_ALERT_TYPES = [ AlertTypes.unconnectedAccount, AlertTypes.web3ShimUsage, + AlertTypes.smartTransactionsMigration, ]; export enum Web3ShimUsageAlertStates { diff --git a/shared/modules/selectors/smart-transactions.ts b/shared/modules/selectors/smart-transactions.ts index 9367c24853c6..f3f7bb922711 100644 --- a/shared/modules/selectors/smart-transactions.ts +++ b/shared/modules/selectors/smart-transactions.ts @@ -17,6 +17,7 @@ type SmartTransactionsMetaMaskState = { metamask: { preferences: { smartTransactionsOptInStatus?: boolean; + smartTransactionsMigrationApplied?: boolean; }; internalAccounts: { selectedAccount: string; @@ -72,6 +73,25 @@ export const getSmartTransactionsOptInStatusInternal = createSelector( }, ); +/** + * Returns whether the smart transactions migration has been applied to the user's settings. + * This specifically tracks if Migration 135 has been run, which enables Smart Transactions + * by default for users who have never interacted with the feature or who previously opted out + * with no STX activity. + * + * This should only be used for internal checks of the migration status, and not + * for determining overall Smart Transactions availability. + * + * @param state - The state object. + * @returns true if the migration has been applied to the user's settings, false if not or if unset. + */ +export const getSmartTransactionsMigrationAppliedInternal = createSelector( + getPreferences, + (preferences: { smartTransactionsMigrationApplied?: boolean }): boolean => { + return preferences?.smartTransactionsMigrationApplied ?? false; + }, +); + /** * Returns the user's explicit opt-in status for the smart transactions feature. * This should only be used for metrics collection, and not for determining if the diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index cae1a6ae8951..c4d176348946 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -63,14 +63,10 @@ "bridgeState": { "bridgeFeatureFlags": { "extensionConfig": { - "maxRefreshCount": "number", "refreshRate": "number", + "maxRefreshCount": "number", "support": "boolean", - "chains": { - "0x1": "object", - "0xa4b1": "object", - "0xe708": "object" - } + "chains": { "0x1": "object", "0xa4b1": "object", "0xe708": "object" } } }, "srcTokens": {}, @@ -242,11 +238,10 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "tokenSortConfig": "object", "shouldShowAggregatedBalancePopover": "boolean", - "tokenNetworkFilter": { - "0x539": "boolean" - }, + "tokenNetworkFilter": { "0x539": "boolean" }, "redesignedConfirmationsEnabled": true, - "redesignedTransactionsEnabled": "boolean" + "redesignedTransactionsEnabled": "boolean", + "smartTransactionsMigrationApplied": "boolean" }, "ipfsGateway": "string", "isIpfsGatewayEnabled": "boolean", @@ -330,9 +325,7 @@ "TokenBalancesController": { "tokenBalances": "object" }, "TokenListController": { "tokenList": "object", - "tokensChainsCache": { - "0x539": "object" - }, + "tokensChainsCache": { "0x539": "object" }, "preventPollingOnNetworkRestart": false }, "TokenRatesController": { "marketData": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index a306c63c70b6..76cc18653595 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -37,11 +37,10 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "tokenSortConfig": "object", "shouldShowAggregatedBalancePopover": "boolean", - "tokenNetworkFilter": { - "0x539": "boolean" - }, + "tokenNetworkFilter": { "0x539": "boolean" }, "redesignedConfirmationsEnabled": true, - "redesignedTransactionsEnabled": "boolean" + "redesignedTransactionsEnabled": "boolean", + "smartTransactionsMigrationApplied": "boolean" }, "firstTimeFlowType": "import", "completedOnboarding": true, @@ -176,9 +175,7 @@ "gasEstimateType": "none", "nonRPCGasFeeApisDisabled": "boolean", "tokenList": "object", - "tokensChainsCache": { - "0x539": "object" - }, + "tokensChainsCache": { "0x539": "object" }, "preventPollingOnNetworkRestart": false, "tokens": "object", "ignoredTokens": "object", @@ -280,14 +277,10 @@ "bridgeState": { "bridgeFeatureFlags": { "extensionConfig": { - "maxRefreshCount": "number", "refreshRate": "number", + "maxRefreshCount": "number", "support": "boolean", - "chains": { - "0x1": "object", - "0xa4b1": "object", - "0xe708": "object" - } + "chains": { "0x1": "object", "0xa4b1": "object", "0xe708": "object" } } }, "srcTokens": {}, diff --git a/test/e2e/tests/metrics/swaps.spec.js b/test/e2e/tests/metrics/swaps.spec.js index 6b73e3f400b7..df2137b0157f 100644 --- a/test/e2e/tests/metrics/swaps.spec.js +++ b/test/e2e/tests/metrics/swaps.spec.js @@ -90,6 +90,7 @@ async function mockSegmentAndMetaswapRequests(mockServer) { ]; } +// TODO: (MM-PENDING) These tests are planned for deprecation as part of swaps testing revamp describe('Swap Eth for another Token @no-mmi', function () { it('Completes a Swap between ETH and DAI after changing initial rate', async function () { const { initialBalanceInHex } = genRandInitBal(); diff --git a/test/e2e/tests/swaps/shared.ts b/test/e2e/tests/swaps/shared.ts index 3f3aff4447e5..fa55b3a7f0a8 100644 --- a/test/e2e/tests/swaps/shared.ts +++ b/test/e2e/tests/swaps/shared.ts @@ -190,6 +190,12 @@ export const checkActivityTransaction = async ( await driver.clickElement('[data-testid="popover-close"]'); }; +export const closeSmartTransactionsMigrationNotification = async ( + driver: Driver, +) => { + await driver.clickElement('[aria-label="Close"]'); +}; + export const checkNotification = async ( driver: Driver, options: { title: string; text: string }, @@ -216,9 +222,21 @@ export const checkNotification = async ( }; export const changeExchangeRate = async (driver: Driver) => { + // Ensure quote view button is present + await driver.waitForSelector('[data-testid="review-quote-view-all-quotes"]'); + + // Scroll button into view before clicking + await driver.executeScript(` + const element = document.querySelector('[data-testid="review-quote-view-all-quotes"]'); + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + `); + + // Add small delay allowing for smooth scroll + await driver.delay(500); + + // Try to click the element await driver.clickElement('[data-testid="review-quote-view-all-quotes"]'); await driver.waitForSelector({ text: 'Quote details', tag: 'h2' }); - const networkFees = await driver.findElements( '[data-testid*="select-quote-popover-row"]', ); diff --git a/test/e2e/tests/swaps/swap-eth.spec.ts b/test/e2e/tests/swaps/swap-eth.spec.ts index 18d049e5de16..376d86fd2852 100644 --- a/test/e2e/tests/swaps/swap-eth.spec.ts +++ b/test/e2e/tests/swaps/swap-eth.spec.ts @@ -7,53 +7,54 @@ import { checkActivityTransaction, changeExchangeRate, mockEthDaiTrade, + closeSmartTransactionsMigrationNotification, } from './shared'; +// TODO: (MM-PENDING) These tests are planned for deprecation as part of swaps testing revamp describe('Swap Eth for another Token @no-mmi', function () { - it('Completes second Swaps while first swap is processing', async function () { - withFixturesOptions.ganacheOptions.miner.blockTime = 10; - + it('Completes a Swap between ETH and DAI after changing initial rate', async function () { await withFixtures( { ...withFixturesOptions, + testSpecificMock: mockEthDaiTrade, title: this.test?.fullTitle(), }, async ({ driver }) => { await unlockWallet(driver); + await buildQuote(driver, { - amount: 0.001, - swapTo: 'USDC', + amount: 2, + swapTo: 'DAI', }); + + // Close the STX notification immediately after buildQuote + // This ensures the UI is clear before we proceed with quote review + await closeSmartTransactionsMigrationNotification(driver); + await reviewQuote(driver, { - amount: 0.001, + amount: 2, swapFrom: 'TESTETH', - swapTo: 'USDC', - }); - await driver.clickElement({ text: 'Swap', tag: 'button' }); - await driver.clickElement({ text: 'View in activity', tag: 'button' }); - await buildQuote(driver, { - amount: 0.003, swapTo: 'DAI', }); + + // The changeExchangeRate function now includes scrolling logic + await changeExchangeRate(driver); + await reviewQuote(driver, { - amount: 0.003, + amount: 2, swapFrom: 'TESTETH', swapTo: 'DAI', + skipCounter: true, }); + await driver.clickElement({ text: 'Swap', tag: 'button' }); await waitForTransactionToComplete(driver, { tokenName: 'DAI' }); await checkActivityTransaction(driver, { index: 0, - amount: '0.003', + amount: '2', swapFrom: 'TESTETH', swapTo: 'DAI', }); - await checkActivityTransaction(driver, { - index: 1, - amount: '0.001', - swapFrom: 'TESTETH', - swapTo: 'USDC', - }); }, ); }); diff --git a/test/e2e/tests/swaps/swaps-notifications.spec.ts b/test/e2e/tests/swaps/swaps-notifications.spec.ts index 134741d3683c..835b86d277e6 100644 --- a/test/e2e/tests/swaps/swaps-notifications.spec.ts +++ b/test/e2e/tests/swaps/swaps-notifications.spec.ts @@ -6,6 +6,7 @@ import { buildQuote, reviewQuote, checkNotification, + closeSmartTransactionsMigrationNotification, } from './shared'; async function mockSwapsTransactionQuote(mockServer: Mockttp) { @@ -80,6 +81,7 @@ describe('Swaps - notifications @no-mmi', function () { amount: 2, swapTo: 'INUINU', }); + await closeSmartTransactionsMigrationNotification(driver); await checkNotification(driver, { title: 'Potentially inauthentic token', text: 'INUINU is only verified on 1 source. Consider verifying it on Etherscan before proceeding.', diff --git a/ui/pages/confirmations/components/smart-transactions-banner-alert/index.ts b/ui/pages/confirmations/components/smart-transactions-banner-alert/index.ts new file mode 100644 index 000000000000..2ffa38054d60 --- /dev/null +++ b/ui/pages/confirmations/components/smart-transactions-banner-alert/index.ts @@ -0,0 +1 @@ +export { SmartTransactionsBannerAlert } from './smart-transactions-banner-alert'; 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 new file mode 100644 index 000000000000..79be7acec262 --- /dev/null +++ b/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.test.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import type { Store } from '@reduxjs/toolkit'; +import { screen } from '@testing-library/react'; +import { TransactionType } from '@metamask/transaction-controller'; +import { ConfirmContext } from '../../context/confirm'; +import type { Confirmation, SignatureRequestType } from '../../types/confirm'; +import { renderWithProvider } from '../../../../../test/jest/rendering'; +import configureStore from '../../../../store/store'; +import { AlertTypes } from '../../../../../shared/constants/alerts'; +import { setAlertEnabledness } from '../../../../store/actions'; +import { SmartTransactionsBannerAlert } from './smart-transactions-banner-alert'; + +type TestConfirmContextValue = { + currentConfirmation: Confirmation; + isScrollToBottomCompleted: boolean; + setIsScrollToBottomCompleted: (isScrollToBottomCompleted: boolean) => void; +}; + +jest.mock('../../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => key, + __esModule: true, + default: () => (key: string) => key, +})); + +jest.mock('../../../../store/actions', () => ({ + setAlertEnabledness: jest.fn(() => ({ type: 'mock-action' })), +})); + +const renderWithConfirmContext = ( + component: React.ReactElement, + store: Store, + confirmationValue: TestConfirmContextValue = { + currentConfirmation: { + type: TransactionType.simpleSend, + id: '1', + } as SignatureRequestType, + isScrollToBottomCompleted: true, + setIsScrollToBottomCompleted: () => undefined, + }, +) => { + return renderWithProvider( + + {component} + , + store, + ); +}; + +describe('SmartTransactionsBannerAlert', () => { + const mockState = { + metamask: { + alertEnabledness: { + [AlertTypes.smartTransactionsMigration]: true, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }; + + it('renders banner when alert is enabled, STX is opted in, and migration is applied', () => { + const store = configureStore(mockState); + renderWithProvider(, store); + + expect( + screen.getByTestId('smart-transactions-banner-alert'), + ).toBeInTheDocument(); + expect( + screen.getByText('smartTransactionsEnabledTitle'), + ).toBeInTheDocument(); + expect( + screen.getByText('smartTransactionsEnabledDescription'), + ).toBeInTheDocument(); + expect( + screen.getByText('smartTransactionsEnabledLink'), + ).toBeInTheDocument(); + }); + + it('does not render when alert is disabled', () => { + const disabledState = { + metamask: { + alertEnabledness: { + [AlertTypes.smartTransactionsMigration]: false, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }; + const store = configureStore(disabledState); + renderWithProvider(, store); + + expect( + screen.queryByTestId('smart-transactions-banner-alert'), + ).not.toBeInTheDocument(); + }); + + it('does not render when migration has not been applied', () => { + const noMigrationState = { + metamask: { + alertEnabledness: { + [AlertTypes.smartTransactionsMigration]: true, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: false, + }, + }, + }; + const store = configureStore(noMigrationState); + renderWithProvider(, store); + + expect( + screen.queryByTestId('smart-transactions-banner-alert'), + ).not.toBeInTheDocument(); + }); + + it('dismisses banner when close button or link is clicked', () => { + const store = configureStore(mockState); + + // Test close button + const { unmount } = renderWithProvider( + , + store, + ); + screen.getByRole('button', { name: /close/iu }).click(); + expect(setAlertEnabledness).toHaveBeenCalledWith( + AlertTypes.smartTransactionsMigration, + false, + ); + + // Cleanup + unmount(); + jest.clearAllMocks(); + + // Test link + renderWithProvider(, store); + screen.getByText('smartTransactionsEnabledLink').click(); + expect(setAlertEnabledness).toHaveBeenCalledWith( + AlertTypes.smartTransactionsMigration, + false, + ); + }); + + it('renders banner when inside ConfirmContext with supported transaction type', () => { + const store = configureStore(mockState); + renderWithConfirmContext(, store); + + expect( + screen.getByTestId('smart-transactions-banner-alert'), + ).toBeInTheDocument(); + expect( + screen.getByText('smartTransactionsEnabledTitle'), + ).toBeInTheDocument(); + expect( + screen.getByText('smartTransactionsEnabledDescription'), + ).toBeInTheDocument(); + expect( + screen.getByText('smartTransactionsEnabledLink'), + ).toBeInTheDocument(); + }); + + it('does not render banner for unsupported transaction types', () => { + const store = configureStore(mockState); + const unsupportedConfirmation: TestConfirmContextValue = { + currentConfirmation: { + type: TransactionType.signTypedData, + id: '2', + } as SignatureRequestType, + isScrollToBottomCompleted: true, + setIsScrollToBottomCompleted: () => undefined, + }; + + renderWithConfirmContext( + , + store, + unsupportedConfirmation, + ); + + expect( + screen.queryByTestId('smart-transactions-banner-alert'), + ).not.toBeInTheDocument(); + }); + + describe('margin style tests', () => { + const store = configureStore(mockState); + + it('applies no styles with default margin type', () => { + renderWithConfirmContext(, store); + const alert = screen.getByTestId('smart-transactions-banner-alert'); + expect(alert).not.toHaveStyle({ margin: 0 }); + expect(alert).not.toHaveStyle({ marginTop: 0 }); + }); + + it('applies zero margin when marginType is "none"', () => { + renderWithConfirmContext( + , + store, + ); + const alert = screen.getByTestId('smart-transactions-banner-alert'); + expect(alert).toHaveStyle({ margin: 0 }); + }); + + it('applies zero top margin when marginType is "noTop"', () => { + renderWithConfirmContext( + , + store, + ); + const alert = screen.getByTestId('smart-transactions-banner-alert'); + expect(alert).toHaveStyle({ marginTop: 0 }); + }); + + it('applies only top margin when marginType is "onlyTop"', () => { + renderWithConfirmContext( + , + store, + ); + const alert = screen.getByTestId('smart-transactions-banner-alert'); + expect(alert).toHaveStyle({ margin: '16px 0px 0px 0px' }); + }); + }); + + it('handles being outside of ConfirmContext correctly', () => { + const store = configureStore(mockState); + + renderWithProvider(, store); + + expect( + screen.getByTestId('smart-transactions-banner-alert'), + ).toBeInTheDocument(); + }); + + it('automatically dismisses banner when Smart Transactions is manually disabled', () => { + const store = configureStore({ + metamask: { + alertEnabledness: { + [AlertTypes.smartTransactionsMigration]: true, + }, + preferences: { + smartTransactionsOptInStatus: false, + smartTransactionsMigrationApplied: true, + }, + }, + }); + + jest.clearAllMocks(); + + renderWithConfirmContext(, store); + + expect(setAlertEnabledness).toHaveBeenCalledTimes(1); + expect(setAlertEnabledness).toHaveBeenCalledWith( + AlertTypes.smartTransactionsMigration, + false, + ); + }); +}); diff --git a/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.tsx b/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.tsx new file mode 100644 index 000000000000..e9f28a25c318 --- /dev/null +++ b/ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.tsx @@ -0,0 +1,124 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + BannerAlert, + ButtonLink, + Text, + BannerAlertSeverity, +} from '../../../../components/component-library'; +import { setAlertEnabledness } from '../../../../store/actions'; +import { AlertTypes } from '../../../../../shared/constants/alerts'; +import { SMART_TRANSACTIONS_LEARN_MORE_URL } from '../../../../../shared/constants/smartTransactions'; +import { FontWeight } from '../../../../helpers/constants/design-system'; +import { useConfirmContext } from '../../context/confirm'; +import { isCorrectDeveloperTransactionType } from '../../../../../shared/lib/confirmation.utils'; +import { + getSmartTransactionsOptInStatusInternal, + getSmartTransactionsMigrationAppliedInternal, +} from '../../../../../shared/modules/selectors/smart-transactions'; + +type MarginType = 'default' | 'none' | 'noTop' | 'onlyTop'; + +type SmartTransactionsBannerAlertProps = { + marginType?: MarginType; +}; + +export const SmartTransactionsBannerAlert: React.FC = + React.memo(({ marginType = 'default' }) => { + const t = useI18nContext(); + + let currentConfirmation; + try { + const context = useConfirmContext(); + currentConfirmation = context?.currentConfirmation; + } catch { + currentConfirmation = null; + } + + const alertEnabled = useSelector( + (state: { + metamask: { alertEnabledness?: { [key: string]: boolean } }; + }) => + state.metamask.alertEnabledness?.[ + AlertTypes.smartTransactionsMigration + ] !== false, + ); + + const smartTransactionsOptIn = useSelector( + getSmartTransactionsOptInStatusInternal, + ); + + const smartTransactionsMigrationApplied = useSelector( + getSmartTransactionsMigrationAppliedInternal, + ); + + const dismissAlert = useCallback(() => { + setAlertEnabledness(AlertTypes.smartTransactionsMigration, false); + }, []); + + React.useEffect(() => { + if (alertEnabled && !smartTransactionsOptIn) { + dismissAlert(); + } + }, [alertEnabled, smartTransactionsOptIn, dismissAlert]); + + const alertConditions = + alertEnabled && + smartTransactionsOptIn && + smartTransactionsMigrationApplied; + + const shouldRender = + currentConfirmation === null + ? alertConditions + : alertConditions && + isCorrectDeveloperTransactionType( + currentConfirmation?.type as TransactionType, + ); + + if (!shouldRender) { + return null; + } + + const getMarginStyle = () => { + switch (marginType) { + case 'none': + return { margin: 0 }; + case 'noTop': + return { marginTop: 0 }; + case 'onlyTop': + return { margin: 0, marginTop: 16 }; + default: + return undefined; + } + }; + + return ( + + + {t('smartTransactionsEnabledTitle')} + + + + {t('smartTransactionsEnabledLink')} + + {t('smartTransactionsEnabledDescription')} + + + ); + }); + +SmartTransactionsBannerAlert.displayName = 'SmartTransactionsBannerAlert'; + +export default SmartTransactionsBannerAlert; diff --git a/ui/pages/confirmations/components/transaction-alerts/transaction-alerts.js b/ui/pages/confirmations/components/transaction-alerts/transaction-alerts.js index 5127003a81e8..99acb94bb754 100644 --- a/ui/pages/confirmations/components/transaction-alerts/transaction-alerts.js +++ b/ui/pages/confirmations/components/transaction-alerts/transaction-alerts.js @@ -5,6 +5,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import { PriorityLevels } from '../../../../../shared/constants/gas'; import { useGasFeeContext } from '../../../../contexts/gasFee'; import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { SmartTransactionsBannerAlert } from '../smart-transactions-banner-alert'; import { BannerAlert, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -81,6 +82,7 @@ const TransactionAlerts = ({ return (
+ {isSuspiciousResponse(txData?.securityProviderResponse) && ( { @@ -33,6 +35,13 @@ const STATE_MOCK = { ...mockNetworkState({ chainId: CHAIN_ID_MOCK, }), + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + [AlertTypes.smartTransactionsMigration]: { + state: ALERT_STATE.OPEN, }, }; @@ -40,12 +49,13 @@ function render({ componentProps = {}, useGasFeeContextValue = {}, submittedPendingTransactionsSelectorValue = null, + state = STATE_MOCK, }) { useGasFeeContext.mockReturnValue(useGasFeeContextValue); submittedPendingTransactionsSelector.mockReturnValue( submittedPendingTransactionsSelectorValue, ); - const store = configureStore(STATE_MOCK); + const store = configureStore(state); return renderWithProvider(, store); } @@ -558,3 +568,64 @@ describe('TransactionAlerts', () => { }); }); }); + +describe('Smart Transactions Migration Alert', () => { + it('shows when alert is enabled, opted in, and migration applied', () => { + const { getByTestId } = render({ + componentProps: { + txData: { + chainId: CHAIN_ID_MOCK, + txParams: { value: '0x1' }, + }, + }, + state: { + ...STATE_MOCK, + metamask: { + ...STATE_MOCK.metamask, + networkConfigurationsByChainId: { + [CHAIN_ID_MOCK]: { + chainId: CHAIN_ID_MOCK, + }, + }, + alertEnabledness: { + [AlertTypes.smartTransactionsMigration]: true, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }, + }); + expect(getByTestId('smart-transactions-banner-alert')).toBeInTheDocument(); + }); + + it('does not show when alert is disabled', () => { + const closedState = { + ...STATE_MOCK, + metamask: { + ...STATE_MOCK.metamask, + alertEnabledness: { + [AlertTypes.smartTransactionsMigration]: false, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }; + const store = configureStore(closedState); + const { queryByTestId } = renderWithProvider( + , + store, + ); + expect( + queryByTestId('smart-transactions-banner-alert'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js index 7dbcc2c3b0ab..cdcaabb1ce48 100644 --- a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js +++ b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js @@ -42,6 +42,7 @@ setBackgroundConnection({ getNextNonce: jest.fn(), updateTransaction: jest.fn(), getLastInteractedConfirmationInfo: jest.fn(), + setAlertEnabledness: jest.fn(), }); const mockTxParamsFromAddress = '0x123456789'; diff --git a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap index 23e0030109ee..85c6e24b3d78 100644 --- a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap +++ b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap @@ -116,6 +116,9 @@ exports[`Confirm matches snapshot for signature - personal sign type 1`] = `
+
+
+
+
+
+
`; + +exports[`Confirm should render SmartTransactionsBannerAlert for transaction types but not signature types 1`] = ` +
+
+
+
+
+
+
+
+ network logo +
+
+
+

+

+

+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+`; + +exports[`Confirm should render SmartTransactionsBannerAlert for transaction types but not signature types 2`] = ` +
+
+
+
+
+
+
+
+
+
+ Goerli logo +
+
+
+

+ Test Account +

+

+ Goerli +

+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ Request from +

+
+
+ +
+
+
+
+
+

+ metamask.github.io +

+
+
+
+
+
+

+ Interacting with +

+
+
+
+
+
+
+
+

+ 0xCcCCc...ccccC +

+
+
+
+
+
+
+ +
+
+

+ Message +

+
+
+
+
+
+
+

+ Primary type: +

+
+
+
+

+ Mail +

+
+
+
+
+
+
+
+

+ Contents: +

+
+
+
+

+ Hello, Bob! +

+
+
+
+
+
+

+ From: +

+
+
+
+
+
+
+

+ Name: +

+
+
+
+

+ Cow +

+
+
+
+
+
+

+ Wallets: +

+
+
+
+
+
+
+

+ 0: +

+
+
+
+
+
+
+
+

+ 0xCD2a3...DD826 +

+
+
+
+
+
+
+

+ 1: +

+
+
+
+
+
+
+
+

+ 0xDeaDb...DbeeF +

+
+
+
+
+
+
+

+ 2: +

+
+
+
+
+
+
+
+

+ 0x06195...43896 +

+
+
+
+
+
+
+
+
+
+
+

+ To: +

+
+
+
+
+
+
+

+ 0: +

+
+
+
+
+
+
+

+ Name: +

+
+
+
+

+ Bob +

+
+
+
+
+
+

+ Wallets: +

+
+
+
+
+
+
+

+ 0: +

+
+
+
+
+
+
+
+

+ 0xbBbBB...bBBbB +

+
+
+
+
+
+
+

+ 1: +

+
+
+
+
+
+
+
+

+ 0xB0Bda...bEa57 +

+
+
+
+
+
+
+

+ 2: +

+
+
+
+
+
+
+
+

+ 0xB0B0b...00000 +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+`; diff --git a/ui/pages/confirmations/confirm/confirm.test.tsx b/ui/pages/confirmations/confirm/confirm.test.tsx index 158884fc36a0..ded2c0a9bb3f 100644 --- a/ui/pages/confirmations/confirm/confirm.test.tsx +++ b/ui/pages/confirmations/confirm/confirm.test.tsx @@ -198,4 +198,44 @@ describe('Confirm', () => { expect(container).toMatchSnapshot(); }); }); + + it('should render SmartTransactionsBannerAlert for transaction types but not signature types', async () => { + // Test with a transaction type + const mockStateTransaction = { + ...mockState, + metamask: { + ...mockState.metamask, + alertEnabledness: { + smartTransactionsMigration: true, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }; + + const mockStoreTransaction = + configureMockStore(middleware)(mockStateTransaction); + + await act(async () => { + const { container } = renderWithConfirmContextProvider( + , + mockStoreTransaction, + ); + expect(container).toMatchSnapshot(); + }); + + // Test with a signature type (reuse existing mock) + const mockStateTypedSign = getMockTypedSignConfirmState(); + const mockStoreSign = configureMockStore(middleware)(mockStateTypedSign); + + await act(async () => { + const { container } = renderWithConfirmContextProvider( + , + mockStoreSign, + ); + expect(container).toMatchSnapshot(); + }); + }); }); diff --git a/ui/pages/confirmations/confirm/confirm.tsx b/ui/pages/confirmations/confirm/confirm.tsx index ed2cb9047271..094c8040a5aa 100644 --- a/ui/pages/confirmations/confirm/confirm.tsx +++ b/ui/pages/confirmations/confirm/confirm.tsx @@ -16,12 +16,14 @@ import { Header } from '../components/confirm/header'; import { Info } from '../components/confirm/info'; import { LedgerInfo } from '../components/confirm/ledger-info'; import { NetworkChangeToast } from '../components/confirm/network-change-toast'; +import { SmartTransactionsBannerAlert } from '../components/smart-transactions-banner-alert'; import { PluggableSection } from '../components/confirm/pluggable-section'; import ScrollToBottom from '../components/confirm/scroll-to-bottom'; import { Title } from '../components/confirm/title'; import EditGasFeePopover from '../components/edit-gas-fee-popover'; import { ConfirmContextProvider, useConfirmContext } from '../context/confirm'; import { ConfirmNav } from '../components/confirm/nav/nav'; +import { Box } from '../../../components/component-library'; const EIP1559TransactionGasModal = () => { return ( @@ -53,6 +55,9 @@ const Confirm = () => (
+ + + { ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js index e85cbfc6eb09..6857bb10d79b 100644 --- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js @@ -144,6 +144,7 @@ import SelectedToken from '../selected-token/selected-token'; import ListWithSearch from '../list-with-search/list-with-search'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import useBridging from '../../../hooks/bridge/useBridging'; +import { SmartTransactionsBannerAlert } from '../../confirmations/components/smart-transactions-banner-alert'; import QuotesLoadingAnimation from './quotes-loading-animation'; import ReviewQuote from './review-quote'; @@ -822,6 +823,9 @@ export default function PrepareSwapPage({ {tokenForImport && isImportTokenModalOpen && ( )} + + + { expect(bridgeButton).toBeNull(); }); + + describe('Smart Transactions Migration Banner', () => { + it('shows banner when alert is enabled, opted in, and migration applied', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)({ + ...mockStore, + metamask: { + ...mockStore.metamask, + alertEnabledness: { + smartTransactionsMigration: true, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }); + + const props = createProps(); + const { getByTestId } = renderWithProvider( + , + store, + ); + + expect( + getByTestId('smart-transactions-banner-alert'), + ).toBeInTheDocument(); + }); + + it('does not show banner when alert is disabled', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)({ + ...mockStore, + metamask: { + ...mockStore.metamask, + alertEnabledness: { + smartTransactionsMigration: false, + }, + preferences: { + smartTransactionsOptInStatus: true, + smartTransactionsMigrationApplied: true, + }, + }, + }); + + const props = createProps(); + const { queryByTestId } = renderWithProvider( + , + store, + ); + + expect( + queryByTestId('smart-transactions-banner-alert'), + ).not.toBeInTheDocument(); + }); + }); });