diff --git a/.circleci/config.yml b/.circleci/config.yml index 607571e36eb1..38ccd80d7059 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -151,7 +151,6 @@ workflows: requires: - prep-deps - prep-build-test-flask-mv2: - <<: *main_master_rc_only requires: - prep-deps - prep-build-test-mmi: @@ -283,10 +282,14 @@ workflows: - prep-deps - prep-build - prep-build-mv2 - - trigger-beta-build - prep-build-mmi - prep-build-flask - prep-build-flask-mv2 + - prep-build-test + - prep-build-test-mv2 + - prep-build-test-flask + - prep-build-test-flask-mv2 + - trigger-beta-build - prep-build-storybook - prep-build-ts-migration-dashboard - benchmark @@ -701,10 +704,10 @@ jobs: name: Build extension for testing command: yarn build:test:flask:mv2 - run: - name: Move test build to 'dist-test-flask' to avoid conflict with production build + name: Move test build to 'dist-test-flask-mv2' to avoid conflict with production build command: mv ./dist ./dist-test-flask-mv2 - run: - name: Move test zips to 'builds-test-flask' to avoid conflict with production build + name: Move test zips to 'builds-test-flask-mv2' to avoid conflict with production build command: mv ./builds ./builds-test-flask-mv2 - persist_to_workspace: root: . @@ -1306,8 +1309,12 @@ jobs: destination: builds-mv2 - store_artifacts: path: builds-test + - store_artifacts: + path: builds-test-mv2 - store_artifacts: path: builds-test-flask + - store_artifacts: + path: builds-test-flask-mv2 - store_artifacts: path: test-artifacts destination: test-artifacts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0ef2ec82406f..a45457e8b4c1 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -4270,6 +4270,9 @@ "permitSimulationChange_listing": { "message": "You list" }, + "permitSimulationChange_nft_listing": { + "message": "Listing price" + }, "permitSimulationChange_receive": { "message": "You receive" }, @@ -5288,6 +5291,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/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index ab53ffb3f22d..705b33f450ba 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -42,6 +42,11 @@ export const SENTRY_BACKGROUND_STATE = { }, AuthenticationController: { isSignedIn: false, + sessionData: { + profile: true, + accessToken: false, + expiresIn: true, + }, }, NetworkOrderController: { orderedNetworkList: [], 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/metamask-controller.js b/app/scripts/metamask-controller.js index 62fe3c942589..49517145bcd7 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -146,12 +146,13 @@ import { TransactionType, } from '@metamask/transaction-controller'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) getLocalizedSnapManifest, stripSnapPrefix, + ///: END:ONLY_INCLUDE_IF + isSnapId, } from '@metamask/snaps-utils'; -///: END:ONLY_INCLUDE_IF import { Interface } from '@ethersproject/abi'; import { abiERC1155, abiERC721 } from '@metamask/metamask-eth-abis'; @@ -245,8 +246,6 @@ import { } from '../../shared/lib/transactions-controller-utils'; import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { endTrace, trace } from '../../shared/lib/trace'; -// eslint-disable-next-line import/no-restricted-paths -import { isSnapId } from '../../ui/helpers/utils/snaps'; import { BridgeStatusAction } from '../../shared/types/bridge-status'; import { ENVIRONMENT } from '../../development/build/constants'; import fetchWithCache from '../../shared/lib/fetch-with-cache'; @@ -2271,7 +2270,10 @@ export default class MetamaskController extends EventEmitter { const smartTransactionsControllerMessenger = this.controllerMessenger.getRestricted({ name: 'SmartTransactionsController', - allowedActions: ['NetworkController:getNetworkClientById'], + allowedActions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + ], allowedEvents: ['NetworkController:stateChange'], }); this.smartTransactionsController = new SmartTransactionsController({ 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/development/metamaskbot-build-announce.js b/development/metamaskbot-build-announce.js index eaf8b7b544f0..dc8c2ece4be6 100755 --- a/development/metamaskbot-build-announce.js +++ b/development/metamaskbot-build-announce.js @@ -65,50 +65,34 @@ async function start() { // build the github comment content // links to extension builds - const platforms = ['chrome', 'firefox']; - const buildLinks = platforms - .map((platform) => { - const url = - platform === 'firefox' - ? `${BUILD_LINK_BASE}/builds-mv2/metamask-${platform}-${VERSION}.zip` - : `${BUILD_LINK_BASE}/builds/metamask-${platform}-${VERSION}.zip`; - return `${platform}`; - }) - .join(', '); - const betaBuildLinks = `chrome`; - const flaskBuildLinks = platforms - .map((platform) => { - const url = - platform === 'firefox' - ? `${BUILD_LINK_BASE}/builds-flask-mv2/metamask-flask-${platform}-${VERSION}-flask.0.zip` - : `${BUILD_LINK_BASE}/builds-flask/metamask-flask-${platform}-${VERSION}-flask.0.zip`; - return `${platform}`; - }) - .join(', '); - const mmiBuildLinks = platforms - .map((platform) => { - const url = `${BUILD_LINK_BASE}/builds-mmi/metamask-mmi-${platform}-${VERSION}-mmi.0.zip`; - return `${platform}`; - }) - .join(', '); - const testBuildLinks = platforms - .map((platform) => { - const url = - platform === 'firefox' - ? `${BUILD_LINK_BASE}/builds-test-mv2/metamask-${platform}-${VERSION}.zip` - : `${BUILD_LINK_BASE}/builds-test/metamask-${platform}-${VERSION}.zip`; - return `${platform}`; - }) - .join(', '); - const testFlaskBuildLinks = platforms - .map((platform) => { - const url = - platform === 'firefox' - ? `${BUILD_LINK_BASE}/builds-test-flask-mv2/metamask-flask-${platform}-${VERSION}-flask.0.zip` - : `${BUILD_LINK_BASE}/builds-test-flask/metamask-flask-${platform}-${VERSION}-flask.0.zip`; + const buildMap = { + builds: { + chrome: `${BUILD_LINK_BASE}/builds/metamask-chrome-${VERSION}.zip`, + firefox: `${BUILD_LINK_BASE}/builds-mv2/metamask-firefox-${VERSION}.zip`, + }, + 'builds (flask)': { + chrome: `${BUILD_LINK_BASE}/builds-flask/metamask-flask-chrome-${VERSION}-flask.0.zip`, + firefox: `${BUILD_LINK_BASE}/builds-flask-mv2/metamask-flask-firefox-${VERSION}-flask.0.zip`, + }, + 'builds (MMI)': { + chrome: `${BUILD_LINK_BASE}/builds-mmi/metamask-mmi-chrome-${VERSION}-mmi.0.zip`, + }, + 'builds (test)': { + chrome: `${BUILD_LINK_BASE}/builds-test/metamask-chrome-${VERSION}.zip`, + firefox: `${BUILD_LINK_BASE}/builds-test-mv2/metamask-firefox-${VERSION}.zip`, + }, + 'builds (test-flask)': { + chrome: `${BUILD_LINK_BASE}/builds-test-flask/metamask-flask-chrome-${VERSION}-flask.0.zip`, + firefox: `${BUILD_LINK_BASE}/builds-test-flask-mv2/metamask-flask-firefox-${VERSION}-flask.0.zip`, + }, + }; + + const buildContentRows = Object.entries(buildMap).map(([label, builds]) => { + const buildLinks = Object.entries(builds).map(([platform, url]) => { return `${platform}`; - }) - .join(', '); + }); + return `${label}: ${buildLinks.join(', ')}`; + }); // links to bundle browser builds const bundles = {}; @@ -144,9 +128,6 @@ async function start() { const bundleSizeDataUrl = 'https://raw.githubusercontent.com/MetaMask/extension_bundlesize_stats/main/stats/bundle_size_data.json'; - const coverageUrl = `${BUILD_LINK_BASE}/coverage/index.html`; - const coverageLink = `Report`; - const storybookUrl = `${BUILD_LINK_BASE}/storybook/index.html`; const storybookLink = `Storybook`; @@ -165,16 +146,10 @@ async function start() { const allArtifactsUrl = `https://circleci.com/gh/MetaMask/metamask-extension/${CIRCLE_BUILD_NUM}#artifacts/containers/0`; const contentRows = [ - `builds: ${buildLinks}`, - `builds (beta): ${betaBuildLinks}`, - `builds (flask): ${flaskBuildLinks}`, - `builds (MMI): ${mmiBuildLinks}`, - `builds (test): ${testBuildLinks}`, - `builds (test-flask): ${testFlaskBuildLinks}`, + ...buildContentRows, `build viz: ${depVizLink}`, `mv3: ${bundleSizeStatsLink}`, `mv2: ${userActionsStatsLink}`, - `code coverage: ${coverageLink}`, `storybook: ${storybookLink}`, `typescript migration: ${tsMigrationDashboardLink}`, `all artifacts`, @@ -189,8 +164,9 @@ async function start() { const exposedContent = `Builds ready [${SHORT_SHA1}]`; const artifactsBody = `
${exposedContent}${hiddenContent}
\n\n`; + const benchmarkPlatforms = ['chrome']; const benchmarkResults = {}; - for (const platform of platforms) { + for (const platform of benchmarkPlatforms) { const benchmarkPath = path.resolve( __dirname, '..', 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/shared/modules/transaction.utils.ts b/shared/modules/transaction.utils.ts index 910fe9b6d4be..eb7a987f360f 100644 --- a/shared/modules/transaction.utils.ts +++ b/shared/modules/transaction.utils.ts @@ -294,10 +294,10 @@ function extractLargeMessageValue(dataToParse: string): string | undefined { } /** - * JSON.parse has a limitation which coerces values to scientific notation if numbers are greator than + * JSON.parse has a limitation which coerces values to scientific notation if numbers are greater than * Number.MAX_SAFE_INTEGER. This can cause a loss in precision. * - * Aside from precision concerns, if the value returned was a large number greator than 15 digits, + * Aside from precision concerns, if the value returned was a large number greater than 15 digits, * e.g. 3.000123123123121e+26, passing the value to BigNumber will throw the error: * Error: new BigNumber() number type has more than 15 significant digits * diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 3b69d55fc122..24240d6f609a 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -724,7 +724,10 @@ async function clickSignOnSignatureConfirmation({ // so we have to add a small delay as the last alternative to avoid flakiness. await driver.delay(regularDelayMs); } - + await driver.waitForSelector( + { text: 'Sign', tag: 'button' }, + { state: 'enabled' }, + ); await driver.clickElement({ text: 'Sign', tag: 'button' }); } diff --git a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts index 4f1afe586116..cf153761b60d 100644 --- a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts +++ b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts @@ -72,7 +72,7 @@ describe('Confirmation Signature - NFT Permit @no-mmi', function (this: Suite) { signatureType: 'eth_signTypedData_v4', primaryType: 'Permit', uiCustomizations: ['redesigned_confirmation', 'permit'], - decodingChangeTypes: ['RECEIVE', 'LISTING'], + decodingChangeTypes: ['LISTING', 'RECEIVE'], decodingResponse: 'CHANGE', }); @@ -115,7 +115,7 @@ describe('Confirmation Signature - NFT Permit @no-mmi', function (this: Suite) { primaryType: 'Permit', uiCustomizations: ['redesigned_confirmation', 'permit'], location: 'confirmation', - decodingChangeTypes: ['RECEIVE', 'LISTING'], + decodingChangeTypes: ['LISTING', 'RECEIVE'], decodingResponse: 'CHANGE', }); }, diff --git a/test/e2e/tests/confirmations/signatures/permit.spec.ts b/test/e2e/tests/confirmations/signatures/permit.spec.ts index ea375b0c1100..5f6ad8a9e16c 100644 --- a/test/e2e/tests/confirmations/signatures/permit.spec.ts +++ b/test/e2e/tests/confirmations/signatures/permit.spec.ts @@ -69,7 +69,7 @@ describe('Confirmation Signature - Permit @no-mmi', function (this: Suite) { signatureType: 'eth_signTypedData_v4', primaryType: 'Permit', uiCustomizations: ['redesigned_confirmation', 'permit'], - decodingChangeTypes: ['RECEIVE', 'LISTING'], + decodingChangeTypes: ['LISTING', 'RECEIVE'], decodingResponse: 'CHANGE', }); @@ -107,7 +107,7 @@ describe('Confirmation Signature - Permit @no-mmi', function (this: Suite) { primaryType: 'Permit', uiCustomizations: ['redesigned_confirmation', 'permit'], location: 'confirmation', - decodingChangeTypes: ['RECEIVE', 'LISTING'], + decodingChangeTypes: ['LISTING', 'RECEIVE'], decodingResponse: 'CHANGE', }); }, @@ -126,12 +126,12 @@ describe('Confirmation Signature - Permit @no-mmi', function (this: Suite) { const simulationSection = driver.findElement({ text: 'Estimated changes', }); - const receiveChange = driver.findElement({ text: 'You receive' }); + const receiveChange = driver.findElement({ text: 'Listing price' }); const listChange = driver.findElement({ text: 'You list' }); const listChangeValue = driver.findElement({ text: '#2101' }); assert.ok(await simulationSection, 'Estimated changes'); - assert.ok(await receiveChange, 'You receive'); + assert.ok(await receiveChange, 'Listing price'); assert.ok(await listChange, 'You list'); assert.ok(await listChangeValue, '#2101'); diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index 12a44e26fbf1..aaa5c0536485 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -904,6 +904,13 @@ describe('Sentry errors', function () { // the "resetState" method swapsFeatureFlags: true, }, + // Part of the AuthenticationController store, but initialized as undefined + // Only populated once the client is authenticated + sessionData: { + accessToken: false, + expiresIn: true, + profile: true, + }, // This can get erased due to a bug in the app state controller's // preferences state change handler timeoutMinutes: true, 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/components/app/assets/asset-list/asset-list.test.tsx b/ui/components/app/assets/asset-list/asset-list.test.tsx index 00a47df1c633..6b6dbbee88ad 100644 --- a/ui/components/app/assets/asset-list/asset-list.test.tsx +++ b/ui/components/app/assets/asset-list/asset-list.test.tsx @@ -72,6 +72,18 @@ jest.mock('../../../../store/actions', () => { })), tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), tokenBalancesStopPollingByPollingToken: jest.fn(), + addImportedTokens: jest.fn(), + }; +}); + +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, }; }); diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 19d9ecdd4c0d..5ac7a84e0b29 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -1,14 +1,17 @@ -import React, { useContext, useState } from 'react'; -import { useSelector } from 'react-redux'; +import React, { useContext, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Token } from '@metamask/assets-controllers'; +import { NetworkConfiguration } from '@metamask/network-controller'; import TokenList from '../token-list'; import { PRIMARY } from '../../../../helpers/constants/common'; import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferencedCurrency'; import { getAllDetectedTokensForSelectedAddress, getDetectedTokensInCurrentNetwork, - getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, getIsTokenNetworkFilterEqualCurrentNetwork, getSelectedAccount, + getSelectedAddress, + getUseTokenDetection, } from '../../../../selectors'; import { getMultichainIsEvm, @@ -23,9 +26,10 @@ import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { MetaMetricsEventCategory, MetaMetricsEventName, + MetaMetricsTokenEventSource, } from '../../../../../shared/constants/metametrics'; import DetectedToken from '../../detected-token/detected-token'; -import { DetectedTokensBanner, ReceiveModal } from '../../../multichain'; +import { ReceiveModal } from '../../../multichain'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { FundingMethodModal } from '../../../multichain/funding-method-modal/funding-method-modal'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -35,6 +39,16 @@ import { } from '../../../multichain/ramps-card/ramps-card'; import { getIsNativeTokenBuyable } from '../../../../ducks/ramps'; ///: END:ONLY_INCLUDE_IF +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, + getSelectedNetworkClientId, +} from '../../../../../shared/modules/selectors/networks'; +import { addImportedTokens } from '../../../../store/actions'; +import { + AssetType, + TokenStandard, +} from '../../../../../shared/constants/transaction'; import AssetListControlBar from './asset-list-control-bar'; import NativeToken from './native-token'; @@ -54,6 +68,7 @@ export type AssetListProps = { }; const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { + const dispatch = useDispatch(); const [showDetectedTokens, setShowDetectedTokens] = useState(false); const selectedAccount = useSelector(getSelectedAccount); const t = useI18nContext(); @@ -74,14 +89,19 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { }); const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork) || []; - const isTokenDetectionInactiveOnNonMainnetSupportedNetwork = useSelector( - getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, - ); const isTokenNetworkFilterEqualCurrentNetwork = useSelector( getIsTokenNetworkFilterEqualCurrentNetwork, ); + const allNetworks: Record<`0x${string}`, NetworkConfiguration> = useSelector( + getNetworkConfigurationsByChainId, + ); + const networkClientId = useSelector(getSelectedNetworkClientId); + const selectedAddress = useSelector(getSelectedAddress); + const useTokenDetection = useSelector(getUseTokenDetection); + const currentChainId = useSelector(getCurrentChainId); + const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); const [showReceiveModal, setShowReceiveModal] = useState(false); @@ -104,32 +124,88 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { // for EVM assets const shouldShowTokensLinks = showTokensLinks ?? isEvm; - const detectedTokensMultichain = useSelector( - getAllDetectedTokensForSelectedAddress, - ); + const detectedTokensMultichain: { + [key: `0x${string}`]: Token[]; + } = useSelector(getAllDetectedTokensForSelectedAddress); + + const multichainDetectedTokensLength = Object.values( + detectedTokensMultichain || {}, + ).reduce((acc, tokens) => acc + tokens.length, 0); + + // Add detected tokens to sate + useEffect(() => { + const importAllDetectedTokens = async () => { + // If autodetect tokens toggle is OFF, return + if (!useTokenDetection) { + return; + } + // TODO add event for MetaMetricsEventName.TokenAdded + + if ( + process.env.PORTFOLIO_VIEW && + !isTokenNetworkFilterEqualCurrentNetwork + ) { + const importPromises = Object.entries(detectedTokensMultichain).map( + async ([networkId, tokens]) => { + const chainConfig = allNetworks[networkId as `0x${string}`]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await dispatch( + addImportedTokens(tokens as Token[], networkInstanceId), + ); + tokens.forEach((importedToken) => { + trackEvent({ + event: MetaMetricsEventName.TokenAdded, + category: MetaMetricsEventCategory.Wallet, + sensitiveProperties: { + token_symbol: importedToken.symbol, + token_contract_address: importedToken.address, + token_decimal_precision: importedToken.decimals, + source: MetaMetricsTokenEventSource.Detected, + token_standard: TokenStandard.ERC20, + asset_type: AssetType.token, + token_added_type: 'detected', + chain_id: chainConfig.chainId, + }, + }); + }); + }, + ); + + await Promise.all(importPromises); + } else if (detectedTokens.length > 0) { + await dispatch(addImportedTokens(detectedTokens, networkClientId)); + detectedTokens.forEach((importedToken: Token) => { + trackEvent({ + event: MetaMetricsEventName.TokenAdded, + category: MetaMetricsEventCategory.Wallet, + sensitiveProperties: { + token_symbol: importedToken.symbol, + token_contract_address: importedToken.address, + token_decimal_precision: importedToken.decimals, + source: MetaMetricsTokenEventSource.Detected, + token_standard: TokenStandard.ERC20, + asset_type: AssetType.token, + token_added_type: 'detected', + chain_id: currentChainId, + }, + }); + }); + } + }; + importAllDetectedTokens(); + }, [ + isTokenNetworkFilterEqualCurrentNetwork, + selectedAddress, + networkClientId, + detectedTokens.length, + multichainDetectedTokensLength, + ]); - const totalTokens = - process.env.PORTFOLIO_VIEW && - !isTokenNetworkFilterEqualCurrentNetwork && - detectedTokensMultichain - ? (Object.values(detectedTokensMultichain).reduce( - // @ts-expect-error TS18046: 'tokenArray' is of type 'unknown' - (count, tokenArray) => count + tokenArray.length, - 0, - ) as number) - : detectedTokens.length; return ( <> - {totalTokens && - totalTokens > 0 && - !isTokenDetectionInactiveOnNonMainnetSupportedNetwork ? ( - setShowDetectedTokens(true)} - margin={4} - marginBottom={1} - /> - ) : null} { diff --git a/ui/components/app/snaps/snap-ui-footer-button/snap-ui-footer-button.tsx b/ui/components/app/snaps/snap-ui-footer-button/snap-ui-footer-button.tsx index ff3f530ae1e7..6b4dc63e9a3d 100644 --- a/ui/components/app/snaps/snap-ui-footer-button/snap-ui-footer-button.tsx +++ b/ui/components/app/snaps/snap-ui-footer-button/snap-ui-footer-button.tsx @@ -87,6 +87,7 @@ export const SnapUIFooterButton: FunctionComponent< alignItems: AlignItems.center, flexDirection: FlexDirection.Row, }} + data-theme={null} > {isSnapAction && !hideSnapBranding && !loading && ( diff --git a/ui/components/app/snaps/snap-ui-renderer/components/avatar.ts b/ui/components/app/snaps/snap-ui-renderer/components/avatar.ts index 9572516383b6..fd7953e54936 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/avatar.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/avatar.ts @@ -5,5 +5,6 @@ export const avatar: UIComponentFactory = ({ element }) => ({ element: 'SnapUIAvatar', props: { address: element.props.address, + size: element.props.size, }, }); diff --git a/ui/components/multichain/account-overview/account-overview-eth.test.tsx b/ui/components/multichain/account-overview/account-overview-eth.test.tsx index cfca7b93dddf..bc959b2e92bf 100644 --- a/ui/components/multichain/account-overview/account-overview-eth.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-eth.test.tsx @@ -15,6 +15,7 @@ jest.mock('../../../store/actions', () => ({ setTokenNetworkFilter: jest.fn(), updateSlides: jest.fn(), removeSlide: jest.fn(), + addImportedTokens: jest.fn(), })); // Mock the dispatch function diff --git a/ui/components/multichain/account-overview/account-overview-non-evm.test.tsx b/ui/components/multichain/account-overview/account-overview-non-evm.test.tsx index df7c2ef56d69..68a7978ffb53 100644 --- a/ui/components/multichain/account-overview/account-overview-non-evm.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-non-evm.test.tsx @@ -15,6 +15,7 @@ jest.mock('../../../store/actions', () => ({ setTokenNetworkFilter: jest.fn(), updateSlides: jest.fn(), removeSlide: jest.fn(), + addImportedTokens: jest.fn(), })); // Mock the dispatch function diff --git a/ui/components/multichain/pages/permissions-page/permissions-page.js b/ui/components/multichain/pages/permissions-page/permissions-page.js index 3f4680bf0350..fa9082b54af8 100644 --- a/ui/components/multichain/pages/permissions-page/permissions-page.js +++ b/ui/components/multichain/pages/permissions-page/permissions-page.js @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; +import { isSnapId } from '@metamask/snaps-utils'; import { Content, Header, Page } from '../page'; import { Box, @@ -26,7 +27,6 @@ import { REVIEW_PERMISSIONS, } from '../../../../helpers/constants/routes'; import { getConnectedSitesListWithNetworkInfo } from '../../../../selectors'; -import { isSnapId } from '../../../../helpers/utils/snaps'; import { ConnectionListItem } from './connection-list-item'; export const PermissionsPage = () => { diff --git a/ui/helpers/utils/snaps.ts b/ui/helpers/utils/snaps.ts index c788393e83ab..d7527f89b482 100644 --- a/ui/helpers/utils/snaps.ts +++ b/ui/helpers/utils/snaps.ts @@ -1,21 +1,5 @@ -import { SnapId } from '@metamask/snaps-sdk'; import { isProduction } from '../../../shared/modules/environment'; -/** - * Check if the given value is a valid snap ID. - * - * NOTE: This function is a duplicate oF a yet to be released version in @metamask/snaps-utils. - * - * @param value - The value to check. - * @returns `true` if the value is a valid snap ID, and `false` otherwise. - */ -export function isSnapId(value: unknown): value is SnapId { - return ( - (typeof value === 'string' || value instanceof String) && - (value.startsWith('local:') || value.startsWith('npm:')) - ); -} - /** * Decode a snap ID fron a pathname. * diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts index e34c4e9400c6..dee7139a623f 100644 --- a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts @@ -93,7 +93,8 @@ export default function useSubmitBridgeTransaction() { CHAIN_IDS.LINEA_GOERLI, CHAIN_IDS.LINEA_SEPOLIA, ] as Hex[] - ).includes(srcChainId) + ).includes(srcChainId) && + quoteResponse?.approval ) { debugLog( 'Delaying submitting bridge tx to make Linea confirmation more likely', diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 8e45f90cd1db..7b8fdf397e5a 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -161,7 +161,7 @@ export const BridgeCTAButton = ({ variant={TextVariant.bodyMd} data-testid="bridge-cta-button" style={{ boxShadow: 'none' }} - onClick={() => { + onClick={async () => { if (activeQuote && isTxSubmittable && !isSubmitting) { try { // We don't need to worry about setting to false if the tx submission succeeds @@ -179,7 +179,7 @@ export const BridgeCTAButton = ({ ...tradeProperties, }, }); - submitBridgeTransaction(activeQuote); + await submitBridgeTransaction(activeQuote); } finally { setIsSubmitting(false); } diff --git a/ui/pages/bridge/transaction-details/index.scss b/ui/pages/bridge/transaction-details/index.scss index 7832fffdd6a5..8f7a53abac4c 100644 --- a/ui/pages/bridge/transaction-details/index.scss +++ b/ui/pages/bridge/transaction-details/index.scss @@ -1,4 +1,8 @@ .bridge-transaction-details { + &__content { + overflow-y: hidden; + } + &__icon-loading { animation: loading-dot 1.2s linear infinite; } diff --git a/ui/pages/bridge/transaction-details/transaction-details.tsx b/ui/pages/bridge/transaction-details/transaction-details.tsx index 762e2c1218d9..48804dd3283f 100644 --- a/ui/pages/bridge/transaction-details/transaction-details.tsx +++ b/ui/pages/bridge/transaction-details/transaction-details.tsx @@ -254,205 +254,201 @@ const CrossChainSwapTxDetails = () => { ); return ( -
-
-
history.goBack()} - /> - } +
+
history.goBack()} + /> + } + > + {t('bridge')} details +
+ + - {t('bridge')} details -
- + {/* Delayed banner */} + {isDelayed && ( + + + {t('bridgeTxDetailsDelayedDescription')}  + { + trackEvent( + { + category: MetaMetricsEventCategory.Home, + event: MetaMetricsEventName.SupportLinkClicked, + properties: { + url: SUPPORT_REQUEST_LINK, + location: 'Bridge Tx Details', + }, + }, + { + contextPropsIntoEventProperties: [ + MetaMetricsContextProp.PageTitle, + ], + }, + ); + }} + > + {t('bridgeTxDetailsDelayedDescriptionSupport')} + + . + + + )} + + {/* Bridge step list */} + {status !== StatusTypes.COMPLETE && + (bridgeHistoryItem || srcChainTxMeta) && ( + + )} + + {/* Links to block explorers */} + + + + + {/* Bridge tx details */} - {/* Delayed banner */} - {isDelayed && ( - - - {t('bridgeTxDetailsDelayedDescription')}  - { - trackEvent( - { - category: MetaMetricsEventCategory.Home, - event: MetaMetricsEventName.SupportLinkClicked, - properties: { - url: SUPPORT_REQUEST_LINK, - location: 'Bridge Tx Details', - }, - }, - { - contextPropsIntoEventProperties: [ - MetaMetricsContextProp.PageTitle, - ], - }, - ); - }} - > - {t('bridgeTxDetailsDelayedDescriptionSupport')} - - . + + {status?.toLowerCase()} - - )} - - {/* Bridge step list */} - {status !== StatusTypes.COMPLETE && - (bridgeHistoryItem || srcChainTxMeta) && ( - - )} - - {/* Links to block explorers */} - + + {srcNetworkIconName} + + {destNetworkIconName} + + } + /> + + - - - {/* Bridge tx details */} - - - {status?.toLowerCase()} - - } - /> - - {srcNetworkIconName} - - {destNetworkIconName} - - } - /> - - - - + - {/* Bridge tx details 2 */} - - - {t('bridgeTxDetailsTokenAmountOnChain', [ - bridgeAmountSent, - bridgeHistoryItem?.quote.srcAsset.symbol, - ])} - {srcNetworkIconName} - - } - /> - - {t('bridgeTxDetailsTokenAmountOnChain', [ - bridgeAmountReceived, - bridgeHistoryItem?.quote.destAsset.symbol, - ])} - {destNetworkIconName} - - } - /> - - } - /> - + {/* Bridge tx details 2 */} + + + {t('bridgeTxDetailsTokenAmountOnChain', [ + bridgeAmountSent, + bridgeHistoryItem?.quote.srcAsset.symbol, + ])} + {srcNetworkIconName} + + } + /> + + {t('bridgeTxDetailsTokenAmountOnChain', [ + bridgeAmountReceived, + bridgeHistoryItem?.quote.destAsset.symbol, + ])} + {destNetworkIconName} + + } + /> + + } + /> + - + - {/* Generic tx details */} - - + {/* Generic tx details */} + + - - + - -
+ +
); }; diff --git a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx index 336ef3f9f92f..471309bf5e31 100644 --- a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { TransactionType } from '@metamask/transaction-controller'; +import { isSnapId } from '@metamask/snaps-utils'; import { getMockConfirmState, getMockPersonalSignConfirmState, @@ -13,7 +14,6 @@ import { signatureRequestSIWE, unapprovedPersonalSignMsg, } from '../../../../../../../test/data/confirmations/personal_sign'; -import * as snapUtils from '../../../../../../helpers/utils/snaps'; import { SignatureRequestType } from '../../../../types/confirm'; import * as utils from '../../../../utils'; import PersonalSignInfo from './personal-sign'; @@ -43,13 +43,10 @@ jest.mock('../../../../../../../node_modules/@metamask/snaps-utils', () => { ...originalUtils, stripSnapPrefix: jest.fn().mockReturnValue('@metamask/examplesnap'), getSnapPrefix: jest.fn().mockReturnValue('npm:'), + isSnapId: jest.fn(), }; }); -jest.mock('../../../../../../helpers/utils/snaps', () => ({ - isSnapId: jest.fn(), -})); - describe('PersonalSignInfo', () => { it('renders correctly for personal sign request', () => { const state = getMockPersonalSignConfirmState(); @@ -149,7 +146,7 @@ describe('PersonalSignInfo', () => { getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(false); - (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); + (isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(state); const { queryByText, getByText } = renderWithConfirmContextProvider( @@ -171,7 +168,7 @@ describe('PersonalSignInfo', () => { const state = getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(false); - (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); + (isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(state); const { getByText, queryByText } = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx index 38238a2fa49e..7a83402f2ec6 100644 --- a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx +++ b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import { isSnapId } from '@metamask/snaps-utils'; import { ConfirmInfoRowText, ConfirmInfoRowUrl, @@ -27,7 +28,6 @@ import { TextColor, TextVariant, } from '../../../../../../helpers/constants/design-system'; -import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { hexToText, sanitizeString, diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx index 83696b69ac64..34ee667ea797 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx @@ -2,10 +2,10 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { TransactionType } from '@metamask/transaction-controller'; +import { isSnapId } from '@metamask/snaps-utils'; import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../test/data/confirmations/helper'; import { unapprovedTypedSignMsgV1 } from '../../../../../../../test/data/confirmations/typed_sign'; -import * as snapUtils from '../../../../../../helpers/utils/snaps'; import TypedSignInfoV1 from './typed-sign-v1'; jest.mock( @@ -25,13 +25,10 @@ jest.mock('../../../../../../../node_modules/@metamask/snaps-utils', () => { ...originalUtils, stripSnapPrefix: jest.fn().mockReturnValue('@metamask/examplesnap'), getSnapPrefix: jest.fn().mockReturnValue('npm:'), + isSnapId: jest.fn(), }; }); -jest.mock('../../../../../../helpers/utils/snaps', () => ({ - isSnapId: jest.fn(), -})); - describe('TypedSignInfo', () => { it('correctly renders typed sign data request', () => { const mockState = getMockTypedSignConfirmStateForRequest( @@ -65,7 +62,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); + (isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , @@ -88,7 +85,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(false); + (isSnapId as unknown as jest.Mock).mockReturnValue(false); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx index 5ebc5a9e0f56..6560c33b4491 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { isSnapId } from '@metamask/snaps-utils'; import { ConfirmInfoAlertRow } from '../../../../../../components/app/confirm/info/row/alert-row/alert-row'; import { ConfirmInfoRow, @@ -14,7 +15,6 @@ import { import { useConfirmContext } from '../../../../context/confirm'; import { ConfirmInfoRowTypedSignDataV1 } from '../../row/typed-sign-data-v1/typedSignDataV1'; import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; -import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { SigningInWithRow } from '../shared/sign-in-with-row/sign-in-with-row'; const TypedSignV1Info: React.FC = () => { diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx index 0cfd564d8a69..be052c6adba0 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx @@ -8,7 +8,11 @@ import { import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../../test/data/confirmations/helper'; import { renderWithConfirmContextProvider } from '../../../../../../../../../test/lib/confirmations/render-helpers'; import { permitSignatureMsg } from '../../../../../../../../../test/data/confirmations/typed_sign'; -import PermitSimulation, { getStateChangeToolip } from './decoded-simulation'; +import PermitSimulation, { + getStateChangeType, + getStateChangeToolip, + StateChangeType, +} from './decoded-simulation'; const decodingData = { stateChanges: [ @@ -57,21 +61,40 @@ const decodingDataListingERC1155: DecodingDataStateChanges = [ tokenID: '2233', }, ]; -const decodingDataBidding: DecodingDataStateChanges = [ + +const nftListing: DecodingDataStateChanges = [ { assetType: 'ERC721', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '189', + }, + { + assetType: 'ERC20', changeType: DecodingDataChangeType.Receive, address: '', - amount: '900000000000000000', - contractAddress: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', }, +]; + +const nftBidding: DecodingDataStateChanges = [ { - assetType: 'Native', + assetType: 'ERC20', changeType: DecodingDataChangeType.Bidding, address: '', amount: '', - contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', - tokenID: '2101', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '189', + }, + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', }, ]; @@ -137,7 +160,7 @@ describe('DecodedSimulation', () => { ); expect(await findByText('Estimated changes')).toBeInTheDocument(); - expect(await findByText('You receive')).toBeInTheDocument(); + expect(await findByText('Listing price')).toBeInTheDocument(); expect(await findByText('You list')).toBeInTheDocument(); expect(await findByText('#2101')).toBeInTheDocument(); }); @@ -174,24 +197,74 @@ describe('DecodedSimulation', () => { expect(await findByText('Unavailable')).toBeInTheDocument(); }); + it('renders label only once if there are multiple state changes of same changeType', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { + stateChanges: [ + decodingData.stateChanges[0], + decodingData.stateChanges[0], + decodingData.stateChanges[0], + ], + }, + }); + const mockStore = configureMockStore([])(state); + + const { findAllByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findAllByText('Spending cap')).toHaveLength(1); + }); + + it('for NFT permit label for receive should be "Listing price"', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { + stateChanges: nftListing, + }, + }); + const mockStore = configureMockStore([])(state); + + const { findAllByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findAllByText('Listing price')).toHaveLength(1); + }); + describe('getStateChangeToolip', () => { it('return correct tooltip when permit is for listing NFT', async () => { const tooltip = getStateChangeToolip( - decodingDataListing, - decodingDataListing?.[0], + StateChangeType.NFTListingReceive, (str: string) => str, ); expect(tooltip).toBe('signature_decoding_list_nft_tooltip'); }); + + it('return correct tooltip when permit is for bidding NFT', async () => { + const tooltip = getStateChangeToolip( + StateChangeType.NFTBiddingReceive, + (str: string) => str, + ); + expect(tooltip).toBe('signature_decoding_bid_nft_tooltip'); + }); }); - it('return correct tooltip when permit is for bidding NFT', async () => { - const tooltip = getStateChangeToolip( - decodingDataBidding, - decodingDataBidding?.[0], - (str: string) => str, - ); - expect(tooltip).toBe('signature_decoding_bid_nft_tooltip'); + describe('getStateChangeType', () => { + it('return correct state change type for NFT listing receive', async () => { + const stateChange = getStateChangeType(nftListing, nftListing[1]); + expect(stateChange).toBe(StateChangeType.NFTListingReceive); + }); + + it('return correct state change type for NFT bidding receive', async () => { + const stateChange = getStateChangeType(nftBidding, nftBidding[1]); + expect(stateChange).toBe(StateChangeType.NFTBiddingReceive); + }); }); it('renders label only once if there are multiple state changes of same changeType', async () => { diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx index 9cfc0bdc1ae7..d89e42fdeb61 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx @@ -16,11 +16,15 @@ import StaticSimulation from '../../../shared/static-simulation/static-simulatio import TokenValueDisplay from '../value-display/value-display'; import NativeValueDisplay from '../native-value-display/native-value-display'; -export const getStateChangeToolip = ( +export enum StateChangeType { + NFTListingReceive = 'NFTListingReceive', + NFTBiddingReceive = 'NFTBiddingReceive', +} + +export const getStateChangeType = ( stateChangeList: DecodingDataStateChanges | null, stateChange: DecodingDataStateChange, - t: ReturnType, -): string | undefined => { +): StateChangeType | undefined => { if (stateChange.changeType === DecodingDataChangeType.Receive) { if ( stateChangeList?.some( @@ -29,7 +33,7 @@ export const getStateChangeToolip = ( change.assetType === TokenStandard.ERC721, ) ) { - return t('signature_decoding_list_nft_tooltip'); + return StateChangeType.NFTListingReceive; } if ( stateChange.assetType === TokenStandard.ERC721 && @@ -37,24 +41,50 @@ export const getStateChangeToolip = ( (change) => change.changeType === DecodingDataChangeType.Bidding, ) ) { - return t('signature_decoding_bid_nft_tooltip'); + return StateChangeType.NFTBiddingReceive; } } return undefined; }; +export const getStateChangeToolip = ( + nftTransactionType: StateChangeType | undefined, + t: ReturnType, +): string | undefined => { + if (nftTransactionType === StateChangeType.NFTListingReceive) { + return t('signature_decoding_list_nft_tooltip'); + } else if (nftTransactionType === StateChangeType.NFTBiddingReceive) { + return t('signature_decoding_bid_nft_tooltip'); + } + return undefined; +}; + +const stateChangeOrder = { + [DecodingDataChangeType.Transfer]: 1, + [DecodingDataChangeType.Listing]: 2, + [DecodingDataChangeType.Approve]: 3, + [DecodingDataChangeType.Revoke]: 4, + [DecodingDataChangeType.Bidding]: 5, + [DecodingDataChangeType.Receive]: 6, +}; + const getStateChangeLabelMap = ( t: ReturnType, changeType: string, -) => - ({ + stateChangeType?: StateChangeType, +) => { + return { [DecodingDataChangeType.Transfer]: t('permitSimulationChange_transfer'), - [DecodingDataChangeType.Receive]: t('permitSimulationChange_receive'), + [DecodingDataChangeType.Receive]: + stateChangeType === StateChangeType.NFTListingReceive + ? t('permitSimulationChange_nft_listing') + : t('permitSimulationChange_receive'), [DecodingDataChangeType.Approve]: t('permitSimulationChange_approve'), [DecodingDataChangeType.Revoke]: t('permitSimulationChange_revoke'), [DecodingDataChangeType.Bidding]: t('permitSimulationChange_bidding'), [DecodingDataChangeType.Listing]: t('permitSimulationChange_listing'), - }[changeType]); + }[changeType]; +}; const StateChangeRow = ({ stateChangeList, @@ -70,14 +100,19 @@ const StateChangeRow = ({ const t = useI18nContext(); const { assetType, changeType, amount, contractAddress, tokenID } = stateChange; - const tooltip = getStateChangeToolip(stateChangeList, stateChange, t); + const nftTransactionType = getStateChangeType(stateChangeList, stateChange); + const tooltip = getStateChangeToolip(nftTransactionType, t); const canDisplayValueAsUnlimited = assetType === TokenStandard.ERC20 && (changeType === DecodingDataChangeType.Approve || changeType === DecodingDataChangeType.Revoke); return ( {(assetType === TokenStandard.ERC20 || @@ -88,7 +123,10 @@ const StateChangeRow = ({ value={amount} chainId={chainId} tokenId={tokenID} - credit={changeType === DecodingDataChangeType.Receive} + credit={ + nftTransactionType !== StateChangeType.NFTListingReceive && + changeType === DecodingDataChangeType.Receive + } debit={changeType === DecodingDataChangeType.Transfer} canDisplayValueAsUnlimited={canDisplayValueAsUnlimited} /> @@ -97,7 +135,10 @@ const StateChangeRow = ({ )} @@ -112,8 +153,13 @@ const DecodedSimulation: React.FC = () => { const { decodingLoading, decodingData } = currentConfirmation; const stateChangeFragment = useMemo(() => { + const orderedStateChanges = decodingData?.stateChanges?.sort((c1, c2) => + stateChangeOrder[c1.changeType] > stateChangeOrder[c2.changeType] + ? 1 + : -1, + ); const stateChangesGrouped: Record = ( - decodingData?.stateChanges ?? [] + orderedStateChanges ?? [] ).reduce>( (result, stateChange) => { result[stateChange.changeType] = [ diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx index 756d54f57a9b..11b5681ef886 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx @@ -167,7 +167,7 @@ describe('PermitSimulation', () => { mockStore, ); - expect(await findByText('You receive')).toBeInTheDocument(); + expect(await findByText('Listing price')).toBeInTheDocument(); expect(await findByText('You list')).toBeInTheDocument(); }); }); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.test.tsx index 453f4a1b6d79..8c912eb1afd1 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.test.tsx @@ -4,7 +4,7 @@ import configureMockStore from 'redux-mock-store'; import mockState from '../../../../../../../../../test/data/mock-state.json'; import { renderWithProvider } from '../../../../../../../../../test/lib/render-helpers'; -import useTrackERC20WithoutDecimalInformation from '../../../../../../hooks/useTrackERC20WithoutDecimalInformation'; +import { MetaMetricsContext } from '../../../../../../../../contexts/metametrics'; import PermitSimulationValueDisplay from './value-display'; jest.mock('../../../../../../../../store/actions', () => { @@ -15,13 +15,6 @@ jest.mock('../../../../../../../../store/actions', () => { }; }); -jest.mock( - '../../../../../../hooks/useTrackERC20WithoutDecimalInformation', - () => { - return jest.fn(); - }, -); - describe('PermitSimulationValueDisplay', () => { it('renders component correctly', async () => { const mockStore = configureMockStore([])(mockState); @@ -43,18 +36,21 @@ describe('PermitSimulationValueDisplay', () => { it('should invoke method to track missing decimal information for ERC20 tokens', async () => { const mockStore = configureMockStore([])(mockState); + const mockTrackEvent = jest.fn(); await act(async () => { renderWithProvider( - , + + + , mockStore, ); - - expect(useTrackERC20WithoutDecimalInformation).toHaveBeenCalled(); }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.tsx index 1d59418d80c2..720b19a29086 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.tsx @@ -92,7 +92,7 @@ const PermitSimulationValueDisplay: React.FC< return exchangeRate.times(tokenAmount).toNumber(); } return undefined; - }, [exchangeRate, tokenDecimals, value]); + }, [exchangeRate, tokenDecimals, tokenId, value]); const { tokenValue, tokenValueMaxPrecision, shouldShowUnlimitedValue } = useMemo(() => { @@ -113,7 +113,7 @@ const PermitSimulationValueDisplay: React.FC< canDisplayValueAsUnlimited && Number(value) > TOKEN_VALUE_UNLIMITED_THRESHOLD, }; - }, [tokenDecimals, value]); + }, [tokenDecimals, tokenId, value]); /** Temporary error capturing as we are building out Permit Simulations */ if (!tokenContract) { diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx index 640b663e5cc1..4c0ce81a076b 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx @@ -5,6 +5,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; +import { isSnapId } from '@metamask/snaps-utils'; import { getMockConfirmStateForTransaction, getMockTypedSignConfirmState, @@ -17,7 +18,6 @@ import { unapprovedTypedSignMsgV4, } from '../../../../../../../test/data/confirmations/typed_sign'; import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; -import * as snapUtils from '../../../../../../helpers/utils/snaps'; import TypedSignInfo from './typed-sign'; jest.mock( @@ -44,13 +44,10 @@ jest.mock('../../../../../../../node_modules/@metamask/snaps-utils', () => { ...originalUtils, stripSnapPrefix: jest.fn().mockReturnValue('@metamask/examplesnap'), getSnapPrefix: jest.fn().mockReturnValue('npm:'), + isSnapId: jest.fn(), }; }); -jest.mock('../../../../../../helpers/utils/snaps', () => ({ - isSnapId: jest.fn(), -})); - describe('TypedSignInfo', () => { it('renders origin for typed sign data request', () => { const state = getMockTypedSignConfirmState(); @@ -153,7 +150,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); + (isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , @@ -177,7 +174,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(false); + (isSnapId as unknown as jest.Mock).mockReturnValue(false); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx index d21ed2b1fca9..d1fb12bf675b 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { isValidAddress } from 'ethereumjs-util'; +import { isSnapId } from '@metamask/snaps-utils'; import { ConfirmInfoAlertRow } from '../../../../../../components/app/confirm/info/row/alert-row/alert-row'; import { parseTypedDataMessage } from '../../../../../../../shared/modules/transaction.utils'; import { RowAlertKey } from '../../../../../../components/app/confirm/info/row/constants'; @@ -21,7 +22,6 @@ import { import { useConfirmContext } from '../../../../context/confirm'; import { useTypesSignSimulationEnabledInfo } from '../../../../hooks/useTypesSignSimulationEnabledInfo'; import { ConfirmInfoRowTypedSignData } from '../../row/typed-sign-data/typedSignData'; -import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { SigningInWithRow } from '../shared/sign-in-with-row/sign-in-with-row'; import { TypedSignV4Simulation } from './typed-sign-v4-simulation'; 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/confirmations/hooks/useGetTokenStandardAndDetails.ts b/ui/pages/confirmations/hooks/useGetTokenStandardAndDetails.ts index b4ad2b5d51ef..b68430fe3dd0 100644 --- a/ui/pages/confirmations/hooks/useGetTokenStandardAndDetails.ts +++ b/ui/pages/confirmations/hooks/useGetTokenStandardAndDetails.ts @@ -18,17 +18,16 @@ import { export const useGetTokenStandardAndDetails = ( tokenAddress?: Hex | string | undefined, ) => { - if (!tokenAddress) { - return { decimalsNumber: undefined }; - } + const { value: details } = + useAsyncResult(async () => { + if (!tokenAddress) { + return Promise.resolve(null); + } - const { value: details } = useAsyncResult( - async () => - (await memoizedGetTokenStandardAndDetails( + return (await memoizedGetTokenStandardAndDetails( tokenAddress, - )) as TokenDetailsERC20, - [tokenAddress], - ); + )) as TokenDetailsERC20; + }, [tokenAddress]); if (!details) { return { decimalsNumber: undefined }; diff --git a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts index 9c98432918e2..dc7ec929072c 100644 --- a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts +++ b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts @@ -22,7 +22,7 @@ describe('useTrackERC20WithoutDecimalInformation', () => { const trackEventMock = jest.fn(); - it('should invoke trackEvent method', () => { + it('should invoke trackEvent method only once per instance of the hook', () => { useContextMock.mockImplementation((context) => { if (context === MetaMetricsContext) { return trackEventMock; @@ -36,6 +36,6 @@ describe('useTrackERC20WithoutDecimalInformation', () => { } as TokenDetailsERC20), ); - expect(trackEventMock).toHaveBeenCalled(); + expect(trackEventMock).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts index 27417613d3cc..b6924e7869cc 100644 --- a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts +++ b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import { Hex } from '@metamask/utils'; import { @@ -26,32 +26,41 @@ const useTrackERC20WithoutDecimalInformation = ( metricLocation = MetaMetricsEventLocation.SignatureConfirmation, ) => { const trackEvent = useContext(MetaMetricsContext); + const hasTracked = useRef(false); useEffect(() => { - if (chainId === undefined || tokenDetails === undefined) { + if ( + chainId === undefined || + tokenDetails === undefined || + hasTracked.current + ) { return; } + const { decimals, standard } = tokenDetails || {}; - if (standard === TokenStandard.ERC20) { - const parsedDecimals = parseTokenDetailDecimals(decimals); - if (parsedDecimals === undefined) { - trackEvent({ - event: MetaMetricsEventName.SimulationIncompleteAssetDisplayed, - category: MetaMetricsEventCategory.Confirmations, - properties: { - token_decimals_available: 'not_available', - asset_address: tokenAddress, - asset_type: TokenStandard.ERC20, - chain_id: chainId, - location: metricLocation, - ui_customizations: [ - MetaMetricsEventUiCustomization.RedesignedConfirmation, - ], - }, - }); - } + if (standard !== TokenStandard.ERC20) { + return; + } + + const parsedDecimals = parseTokenDetailDecimals(decimals); + if (parsedDecimals === undefined) { + trackEvent({ + event: MetaMetricsEventName.SimulationIncompleteAssetDisplayed, + category: MetaMetricsEventCategory.Confirmations, + properties: { + token_decimals_available: 'not_available', + asset_address: tokenAddress, + asset_type: TokenStandard.ERC20, + chain_id: chainId, + location: metricLocation, + ui_customizations: [ + MetaMetricsEventUiCustomization.RedesignedConfirmation, + ], + }, + }); + hasTracked.current = true; } - }, [tokenDetails, chainId, tokenAddress, trackEvent]); + }, [tokenDetails, chainId, metricLocation, tokenAddress, trackEvent]); }; export default useTrackERC20WithoutDecimalInformation; diff --git a/ui/pages/permissions-connect/permissions-connect.component.js b/ui/pages/permissions-connect/permissions-connect.component.js index 17f73d633953..f7dc90363363 100644 --- a/ui/pages/permissions-connect/permissions-connect.component.js +++ b/ui/pages/permissions-connect/permissions-connect.component.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { Switch, Route } from 'react-router-dom'; import { providerErrors, serializeError } from '@metamask/rpc-errors'; import { SubjectType } from '@metamask/permission-controller'; +import { isSnapId } from '@metamask/snaps-utils'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { isEthAddress } from '../../../app/scripts/lib/multichain/address'; @@ -19,7 +20,6 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { PermissionNames } from '../../../app/scripts/controllers/permissions'; -import { isSnapId } from '../../helpers/utils/snaps'; import ChooseAccount from './choose-account'; import PermissionsRedirect from './redirect'; import SnapsConnect from './snaps/snaps-connect'; diff --git a/ui/pages/permissions-connect/snaps/snap-install/snap-install.js b/ui/pages/permissions-connect/snaps/snap-install/snap-install.js index 93bdc3c5bd8f..0adeac87dfeb 100644 --- a/ui/pages/permissions-connect/snaps/snap-install/snap-install.js +++ b/ui/pages/permissions-connect/snaps/snap-install/snap-install.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; +import { isSnapId } from '@metamask/snaps-utils'; import { PageContainerFooter } from '../../../../components/ui/page-container'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import SnapInstallWarning from '../../../../components/app/snaps/snap-install-warning'; @@ -34,7 +35,6 @@ import { useOriginMetadata } from '../../../../hooks/useOriginMetadata'; import { getSnapMetadata, getSnapsMetadata } from '../../../../selectors'; import { getSnapName } from '../../../../helpers/utils/util'; import PermissionConnectHeader from '../../../../components/app/permission-connect-header'; -import { isSnapId } from '../../../../helpers/utils/snaps'; export default function SnapInstall({ request, diff --git a/ui/pages/permissions-connect/snaps/snaps-connect/snaps-connect.js b/ui/pages/permissions-connect/snaps/snaps-connect/snaps-connect.js index b46eb070c3ed..645019848e25 100644 --- a/ui/pages/permissions-connect/snaps/snaps-connect/snaps-connect.js +++ b/ui/pages/permissions-connect/snaps/snaps-connect/snaps-connect.js @@ -1,6 +1,7 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; +import { isSnapId } from '@metamask/snaps-utils'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { Box, IconSize, Text } from '../../../../components/component-library'; import { @@ -26,7 +27,6 @@ import { getSnapMetadata, } from '../../../../selectors'; import { useOriginMetadata } from '../../../../hooks/useOriginMetadata'; -import { isSnapId } from '../../../../helpers/utils/snaps'; import { SnapIcon } from '../../../../components/app/snaps/snap-icon'; export default function SnapsConnect({ diff --git a/ui/pages/snaps/snap-view/snap-settings.js b/ui/pages/snaps/snap-view/snap-settings.js index 9db18ea7350e..2e8e02897abf 100644 --- a/ui/pages/snaps/snap-view/snap-settings.js +++ b/ui/pages/snaps/snap-view/snap-settings.js @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { useHistory } from 'react-router-dom'; import semver from 'semver'; +import { isSnapId } from '@metamask/snaps-utils'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { BackgroundColor, @@ -51,7 +52,6 @@ import { DelineatorType } from '../../../helpers/constants/snaps'; import SnapUpdateAlert from '../../../components/app/snaps/snap-update-alert'; import { CONNECT_ROUTE } from '../../../helpers/constants/routes'; import { ShowMore } from '../../../components/app/snaps/show-more'; -import { isSnapId } from '../../../helpers/utils/snaps'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { KeyringSnapRemovalResultStatus } from './constants'; ///: END:ONLY_INCLUDE_IF 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(); + }); + }); });