diff --git a/.eslintrc.js b/.eslintrc.js index 4aa9ef688592..3fe128b67118 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -487,10 +487,11 @@ module.exports = { 'getNfts', 'getProviderConfig', 'getRpcPrefsForCurrentProvider', + 'getSelectedNetworkClientId', 'getUSDConversionRate', 'isCurrentProviderCustom', ] - .map((method) => `(${method})`) + .map((method) => `^${method}$`) .join('|')}/]`, message: 'Avoid using global network selectors in confirmations', }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index bc681b5fec49..d903517f8f6f 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3337,7 +3337,7 @@ "description": "$1 represents the network name" }, "networkSwitchMessage": { - "message": "Network switched to $1", + "message": "Network Switched: $1", "description": "$1 represents the network name" }, "networkURL": { @@ -3929,6 +3929,10 @@ "origin": { "message": "Origin" }, + "originSwitchMessage": { + "message": "Site Switched: $1", + "description": "$1 represents the dApp origin" + }, "osTheme": { "message": "System" }, @@ -6206,7 +6210,16 @@ "description": "$1 represents the network name" }, "switchingNetworksCancelsPendingConfirmations": { - "message": "Switching networks will cancel all pending confirmations" + "message": "$1 pending confirmations from the site will be cancelled" + }, + "switchingNetworksCancelsPendingConfirmationsExtended": { + "message": "The site network will be updated and $1 pending confirmations will be cancelled" + }, + "switchingNetworksCancelsPendingConfirmationsExtendedSingular": { + "message": "The site network will be updated and 1 pending confirmation will be cancelled" + }, + "switchingNetworksCancelsPendingConfirmationsSingular": { + "message": "1 pending confirmation from the site will be cancelled" }, "symbol": { "message": "Symbol" diff --git a/app/scripts/background.js b/app/scripts/background.js index 029adf026841..e2a0d3353760 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1071,11 +1071,6 @@ export function setupController( updateBadge, ); - controller.controllerMessenger.subscribe( - METAMASK_CONTROLLER_EVENTS.QUEUED_REQUEST_STATE_CHANGE, - updateBadge, - ); - controller.controllerMessenger.subscribe( METAMASK_CONTROLLER_EVENTS.METAMASK_NOTIFICATIONS_LIST_UPDATED, updateBadge, diff --git a/app/scripts/lib/approval/utils.ts b/app/scripts/lib/approval/utils.ts index 5bd44b5db079..b54f664e7cc6 100644 --- a/app/scripts/lib/approval/utils.ts +++ b/app/scripts/lib/approval/utils.ts @@ -1,8 +1,11 @@ -import { ApprovalController } from '@metamask/approval-controller'; +import { + ApprovalController, + ApprovalRequest, +} from '@metamask/approval-controller'; import { ApprovalType } from '@metamask/controller-utils'; import { DIALOG_APPROVAL_TYPES } from '@metamask/snaps-rpc-methods'; import { providerErrors } from '@metamask/rpc-errors'; -import { createProjectLogger } from '@metamask/utils'; +import { createProjectLogger, Json } from '@metamask/utils'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES } from '../../../../shared/constants/app'; ///: END:ONLY_INCLUDE_IF @@ -20,37 +23,78 @@ export function rejectAllApprovals({ const approvalRequests = Object.values(approvalRequestsById); for (const approvalRequest of approvalRequests) { - const { id, type } = approvalRequest; - const interfaceId = approvalRequest.requestData?.id as string; - - switch (type) { - case ApprovalType.SnapDialogAlert: - case ApprovalType.SnapDialogPrompt: - case DIALOG_APPROVAL_TYPES.default: - log('Rejecting snap dialog', { id, interfaceId }); - approvalController.accept(id, null); - deleteInterface?.(interfaceId); - break; - - case ApprovalType.SnapDialogConfirmation: - log('Rejecting snap confirmation', { id, interfaceId }); - approvalController.accept(id, false); - deleteInterface?.(interfaceId); - break; - - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountCreation: - case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountRemoval: - case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.showSnapAccountRedirect: - log('Rejecting snap account confirmation', { id }); - approvalController.accept(id, false); - break; - ///: END:ONLY_INCLUDE_IF - - default: - log('Rejecting pending approval', { id }); - approvalController.reject(id, providerErrors.userRejectedRequest()); - break; - } + rejectApproval({ + approvalController, + approvalRequest, + deleteInterface, + }); + } +} + +export function rejectOriginApprovals({ + approvalController, + deleteInterface, + origin, +}: { + approvalController: ApprovalController; + deleteInterface?: (id: string) => void; + origin: string; +}) { + const approvalRequestsById = approvalController.state.pendingApprovals; + const approvalRequests = Object.values(approvalRequestsById); + + const originApprovalRequests = approvalRequests.filter( + (approvalRequest) => approvalRequest.origin === origin, + ); + + for (const approvalRequest of originApprovalRequests) { + rejectApproval({ + approvalController, + approvalRequest, + deleteInterface, + }); + } +} + +function rejectApproval({ + approvalController, + approvalRequest, + deleteInterface, +}: { + approvalController: ApprovalController; + approvalRequest: ApprovalRequest>; + deleteInterface?: (id: string) => void; +}) { + const { id, type, origin } = approvalRequest; + const interfaceId = approvalRequest.requestData?.id as string; + + switch (type) { + case ApprovalType.SnapDialogAlert: + case ApprovalType.SnapDialogPrompt: + case DIALOG_APPROVAL_TYPES.default: + log('Rejecting snap dialog', { id, interfaceId, origin, type }); + approvalController.accept(id, null); + deleteInterface?.(interfaceId); + break; + + case ApprovalType.SnapDialogConfirmation: + log('Rejecting snap confirmation', { id, interfaceId, origin, type }); + approvalController.accept(id, false); + deleteInterface?.(interfaceId); + break; + + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountCreation: + case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountRemoval: + case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.showSnapAccountRedirect: + log('Rejecting snap account confirmation', { id, origin, type }); + approvalController.accept(id, false); + break; + ///: END:ONLY_INCLUDE_IF + + default: + log('Rejecting pending approval', { id, origin, type }); + approvalController.reject(id, providerErrors.userRejectedRequest()); + break; } } diff --git a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js index 66d57dd8786b..758c2d6ccaed 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js @@ -24,6 +24,8 @@ const addEthereumChain = { getCaveat: true, requestPermittedChainsPermission: true, grantPermittedChainsPermissionIncremental: true, + hasApprovalRequestsForOrigin: true, + rejectApprovalRequestsForOrigin: true, }, }; @@ -46,6 +48,8 @@ async function addEthereumChainHandler( getCaveat, requestPermittedChainsPermission, grantPermittedChainsPermissionIncremental, + hasApprovalRequestsForOrigin, + rejectApprovalRequestsForOrigin, }, ) { let validParams; @@ -193,13 +197,32 @@ async function addEthereumChainHandler( const { networkClientId } = updatedNetwork.rpcEndpoints[updatedNetwork.defaultRpcEndpointIndex]; - return switchChain(res, end, chainId, networkClientId, approvalFlowId, { - isAddFlow: true, - setActiveNetwork, - endApprovalFlow, - getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + const fromNetworkConfiguration = getNetworkConfigurationByChainId( + currentChainIdForDomain, + ); + + const toNetworkConfiguration = getNetworkConfigurationByChainId(chainId); + + return switchChain({ + res, + end, + chainId, + networkClientId, + approvalFlowId, + fromNetworkConfiguration, + toNetworkConfiguration, + origin, + hooks: { + isAddFlow: true, + setActiveNetwork, + endApprovalFlow, + getCaveat, + requestPermittedChainsPermission, + grantPermittedChainsPermissionIncremental, + requestUserApproval, + hasApprovalRequestsForOrigin, + rejectApprovalRequestsForOrigin, + }, }); } else if (approvalFlowId) { endApprovalFlow({ id: approvalFlowId }); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js index 2f86b30885e5..7f323e28b12b 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js @@ -1,4 +1,5 @@ import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; +import { ApprovalType } from '@metamask/controller-utils'; import { isPrefixedFormattedHexString, isSafeChainId, @@ -152,21 +153,27 @@ export function validateAddEthereumChainParams(params) { }; } -export async function switchChain( +export async function switchChain({ res, end, chainId, networkClientId, approvalFlowId, - { + origin, + fromNetworkConfiguration, + toNetworkConfiguration, + hooks: { isAddFlow, setActiveNetwork, endApprovalFlow, getCaveat, requestPermittedChainsPermission, grantPermittedChainsPermissionIncremental, + requestUserApproval, + hasApprovalRequestsForOrigin, + rejectApprovalRequestsForOrigin, }, -) { +}) { try { const { value: permissionedChainIds } = getCaveat({ @@ -183,8 +190,19 @@ export async function switchChain( } else { await requestPermittedChainsPermission([chainId]); } + } else if (hasApprovalRequestsForOrigin() && !isAddFlow) { + await requestUserApproval({ + origin, + type: ApprovalType.SwitchEthereumChain, + requestData: { + toNetworkConfiguration, + fromNetworkConfiguration, + }, + }); } + rejectApprovalRequestsForOrigin(); + await setActiveNetwork(networkClientId); res.result = null; } catch (error) { diff --git a/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js b/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js index 1fbeedbef3f5..8ed5cf996539 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js @@ -15,6 +15,9 @@ const switchEthereumChain = { requestPermittedChainsPermission: true, getCurrentChainIdForDomain: true, grantPermittedChainsPermissionIncremental: true, + requestUserApproval: true, + hasApprovalRequestsForOrigin: true, + rejectApprovalRequestsForOrigin: true, }, }; @@ -32,6 +35,9 @@ async function switchEthereumChainHandler( getCaveat, getCurrentChainIdForDomain, grantPermittedChainsPermissionIncremental, + requestUserApproval, + hasApprovalRequestsForOrigin, + rejectApprovalRequestsForOrigin, }, ) { let chainId; @@ -48,11 +54,15 @@ async function switchEthereumChainHandler( return end(); } - const networkConfigurationForRequestedChainId = - getNetworkConfigurationByChainId(chainId); + const fromNetworkConfiguration = getNetworkConfigurationByChainId( + currentChainIdForOrigin, + ); + + const toNetworkConfiguration = getNetworkConfigurationByChainId(chainId); + const networkClientIdToSwitchTo = - networkConfigurationForRequestedChainId?.rpcEndpoints[ - networkConfigurationForRequestedChainId.defaultRpcEndpointIndex + toNetworkConfiguration?.rpcEndpoints[ + toNetworkConfiguration.defaultRpcEndpointIndex ].networkClientId; if (!networkClientIdToSwitchTo) { @@ -64,10 +74,22 @@ async function switchEthereumChainHandler( ); } - return switchChain(res, end, chainId, networkClientIdToSwitchTo, null, { - setActiveNetwork, - getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + return switchChain({ + res, + end, + chainId, + networkClientId: networkClientIdToSwitchTo, + fromNetworkConfiguration, + toNetworkConfiguration, + origin, + hooks: { + setActiveNetwork, + getCaveat, + requestPermittedChainsPermission, + grantPermittedChainsPermissionIncremental, + requestUserApproval, + hasApprovalRequestsForOrigin, + rejectApprovalRequestsForOrigin, + }, }); } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index c98ba72c0f96..3b973e223ae7 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -29,7 +29,6 @@ import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscripti import { errorCodes as rpcErrorCodes, JsonRpcError, - providerErrors, } from '@metamask/rpc-errors'; import { Mutex } from 'await-semaphore'; @@ -133,11 +132,6 @@ import { LensNameProvider, } from '@metamask/name-controller'; -import { - QueuedRequestController, - createQueuedRequestMiddleware, -} from '@metamask/queued-request-controller'; - import { UserOperationController } from '@metamask/user-operation-controller'; import { @@ -165,11 +159,6 @@ import { NotificationServicesPushController, NotificationServicesController, } from '@metamask/notification-services-controller'; -import { - methodsRequiringNetworkSwitch, - methodsThatCanSwitchNetworkWithoutApproval, - methodsThatShouldBeEnqueued, -} from '../../shared/constants/methods-tags'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; @@ -380,7 +369,10 @@ import { PatchStore } from './lib/PatchStore'; import { sanitizeUIState } from './lib/state-utils'; import BridgeStatusController from './controllers/bridge-status/bridge-status-controller'; import { BRIDGE_STATUS_CONTROLLER_NAME } from './controllers/bridge-status/constants'; -import { rejectAllApprovals } from './lib/approval/utils'; +import { + rejectAllApprovals, + rejectOriginApprovals, +} from './lib/approval/utils'; const { TRIGGER_TYPES } = NotificationServicesController.Constants; export const METAMASK_CONTROLLER_EVENTS = { @@ -390,7 +382,6 @@ export const METAMASK_CONTROLLER_EVENTS = { // TODO: Add this and similar enums to the `controllers` repo and export them APPROVAL_STATE_CHANGE: 'ApprovalController:stateChange', APP_STATE_UNLOCK_CHANGE: 'AppStateController:unlockChange', - QUEUED_REQUEST_STATE_CHANGE: 'QueuedRequestController:stateChange', METAMASK_NOTIFICATIONS_LIST_UPDATED: 'NotificationServicesController:notificationsListUpdated', METAMASK_NOTIFICATIONS_MARK_AS_READ: @@ -506,15 +497,6 @@ export default class MetamaskController extends EventEmitter { currentAppVersion: version, }); - // next, we will initialize the controllers - // controller initialization order matters - const clearPendingConfirmations = () => { - this.encryptionPublicKeyController.clearUnapproved(); - this.decryptMessageController.clearUnapproved(); - this.signatureController.clearUnapproved(); - this.approvalController.clear(providerErrors.userRejectedRequest()); - }; - this.approvalController = new ApprovalController({ messenger: this.controllerMessenger.getRestricted({ name: 'ApprovalController', @@ -530,28 +512,6 @@ export default class MetamaskController extends EventEmitter { ], }); - this.queuedRequestController = new QueuedRequestController({ - messenger: this.controllerMessenger.getRestricted({ - name: 'QueuedRequestController', - allowedActions: [ - 'NetworkController:getState', - 'NetworkController:setActiveNetwork', - 'SelectedNetworkController:getNetworkClientIdForDomain', - ], - allowedEvents: ['SelectedNetworkController:stateChange'], - }), - shouldRequestSwitchNetwork: ({ method }) => - methodsRequiringNetworkSwitch.includes(method), - canRequestSwitchNetworkWithoutApproval: ({ method }) => - methodsThatCanSwitchNetworkWithoutApproval.includes(method), - clearPendingConfirmations, - showApprovalRequest: () => { - if (this.approvalController.getTotalApprovalCount() > 0) { - opts.showUserConfirmation(); - } - }, - }); - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) this.mmiConfigurationController = new MmiConfigurationController({ initState: initState.MmiConfigurationController, @@ -2404,12 +2364,6 @@ export default class MetamaskController extends EventEmitter { }, ); - // clear unapproved transactions and messages when the network will change - networkControllerMessenger.subscribe( - 'NetworkController:networkWillChange', - clearPendingConfirmations.bind(this), - ); - // RemoteFeatureFlagController has subscription for preferences changes this.controllerMessenger.subscribe( 'PreferencesController:stateChange', @@ -2663,7 +2617,6 @@ export default class MetamaskController extends EventEmitter { AuthenticationController: this.authenticationController, UserStorageController: this.userStorageController, NotificationServicesController: this.notificationServicesController, - QueuedRequestController: this.queuedRequestController, NotificationServicesPushController: this.notificationServicesPushController, RemoteFeatureFlagController: this.remoteFeatureFlagController, @@ -5952,20 +5905,6 @@ export default class MetamaskController extends EventEmitter { // Append selectedNetworkClientId to each request engine.push(createSelectedNetworkMiddleware(this.controllerMessenger)); - // Add a middleware that will switch chain on each request (as needed) - const requestQueueMiddleware = createQueuedRequestMiddleware({ - enqueueRequest: this.queuedRequestController.enqueueRequest.bind( - this.queuedRequestController, - ), - shouldEnqueueRequest: (request) => { - return methodsThatShouldBeEnqueued.includes(request.method); - }, - // This will be removed once we can actually remove useRequestQueue state - // i.e. unrevert https://github.com/MetaMask/core/pull/5065 - useRequestQueue: () => true, - }); - engine.push(requestQueueMiddleware); - // If the origin is not in the selectedNetworkController's `domains` state // when the provider engine is created, the selectedNetworkController will // fetch the globally selected networkClient from the networkController and wrap @@ -6095,6 +6034,10 @@ export default class MetamaskController extends EventEmitter { endApprovalFlow: this.approvalController.endFlow.bind( this.approvalController, ), + hasApprovalRequestsForOrigin: () => + this.approvalController.has({ origin }), + rejectApprovalRequestsForOrigin: () => + this.rejectOriginPendingApprovals(origin), sendMetrics: this.metaMetricsController.trackEvent.bind( this.metaMetricsController, ), @@ -7157,6 +7100,20 @@ export default class MetamaskController extends EventEmitter { }); } + rejectOriginPendingApprovals = (origin) => { + const deleteInterface = (id) => + this.controllerMessenger.call( + 'SnapInterfaceController:deleteInterface', + id, + ); + + rejectOriginApprovals({ + approvalController: this.approvalController, + deleteInterface, + origin, + }); + }; + async _onAccountChange(newAddress) { const permittedAccountsMap = getPermittedAccountsByOrigin( this.permissionController.state, diff --git a/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js b/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js index e5e8503e6c73..4416646fff39 100644 --- a/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js +++ b/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js @@ -10,11 +10,16 @@ import { FlexDirection, FontWeight, JustifyContent, + Severity, TextAlign, TextVariant, } from '../../../../helpers/constants/design-system'; -import { Box, Text } from '../../../component-library'; +import { BannerAlert, Box, Text } from '../../../component-library'; import { getURLHost } from '../../../../helpers/utils/util'; +import { + CaveatTypes, + EndowmentTypes, +} from '../../../../../shared/constants/permissions'; export default class PermissionPageContainerContent extends PureComponent { static propTypes = { @@ -27,6 +32,7 @@ export default class PermissionPageContainerContent extends PureComponent { }), selectedPermissions: PropTypes.object.isRequired, selectedAccounts: PropTypes.array, + originPendingApprovals: PropTypes.array, }; static defaultProps = { @@ -40,8 +46,12 @@ export default class PermissionPageContainerContent extends PureComponent { render() { const { t } = this.context; - const { selectedPermissions, selectedAccounts, subjectMetadata } = - this.props; + const { + selectedPermissions, + selectedAccounts, + subjectMetadata, + originPendingApprovals, + } = this.props; const accounts = selectedAccounts.reduce((accumulator, account) => { accumulator.push({ @@ -51,6 +61,15 @@ export default class PermissionPageContainerContent extends PureComponent { return accumulator; }, []); + const otherOriginApprovalsCount = originPendingApprovals.length - 1; + const originHasPendingApprovals = otherOriginApprovalsCount > 0; + + const hasChainPermissions = Boolean( + selectedPermissions?.[EndowmentTypes.permittedChains]?.[ + CaveatTypes.restrictNetworkSwitching + ]?.length, + ); + return ( + {hasChainPermissions && originHasPendingApprovals && ( + + {otherOriginApprovalsCount === 1 + ? t( + 'switchingNetworksCancelsPendingConfirmationsExtendedSingular', + ) + : t('switchingNetworksCancelsPendingConfirmationsExtended', [ + otherOriginApprovalsCount, + ])} + + )} ); } diff --git a/ui/components/app/permission-page-container/permission-page-container.component.js b/ui/components/app/permission-page-container/permission-page-container.component.js index f5f69da8f947..48032028d82a 100644 --- a/ui/components/app/permission-page-container/permission-page-container.component.js +++ b/ui/components/app/permission-page-container/permission-page-container.component.js @@ -46,6 +46,7 @@ export default class PermissionPageContainer extends Component { }), history: PropTypes.object.isRequired, connectPath: PropTypes.string.isRequired, + originPendingApprovals: PropTypes.array, }; static defaultProps = { @@ -183,6 +184,7 @@ export default class PermissionPageContainer extends Component { targetSubjectMetadata, selectedAccounts, allAccountsSelected, + originPendingApprovals, } = this.props; const requestedPermissions = this.getRequestedPermissions(); @@ -218,6 +220,7 @@ export default class PermissionPageContainer extends Component { selectedPermissions={requestedPermissions} selectedAccounts={selectedAccounts} allAccountsSelected={allAccountsSelected} + originPendingApprovals={originPendingApprovals} /> { const { selectedAccounts } = ownProps; - const currentPermissions = getPermissions( - state, - ownProps.request.metadata?.origin, - ); - + const origin = ownProps.request.metadata?.origin; + const currentPermissions = getPermissions(state, origin); const allInternalAccounts = getInternalAccounts(state); + const originPendingApprovals = selectPendingApprovalsForOrigin(state, origin); + const allInternalAccountsSelected = Object.keys(selectedAccounts).length === Object.keys(allInternalAccounts).length && selectedAccounts.length > 1; @@ -17,6 +20,7 @@ const mapStateToProps = (state, ownProps) => { return { allInternalAccountsSelected, currentPermissions, + originPendingApprovals, }; }; diff --git a/ui/components/multichain/toast/index.scss b/ui/components/multichain/toast/index.scss index 8bb290a777ce..7aa2515bc50e 100644 --- a/ui/components/multichain/toast/index.scss +++ b/ui/components/multichain/toast/index.scss @@ -17,4 +17,5 @@ .toast-text { word-break: break-word; + white-space: pre-line; } diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts b/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts index d492c5030354..3cd3145f7d5e 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts @@ -5,17 +5,16 @@ import { import { useSelector } from 'react-redux'; import { isLegacyTransaction } from '../../../../../../helpers/utils/transactions.util'; import { checkNetworkAndAccountSupports1559 } from '../../../../../../selectors'; -import { getSelectedNetworkClientId } from '../../../../../../../shared/modules/selectors/networks'; export function useSupportsEIP1559(transactionMeta: TransactionMeta) { + const { networkClientId, txParams } = transactionMeta ?? {}; + const isLegacyTxn = - transactionMeta?.txParams?.type === TransactionEnvelopeType.legacy || + txParams?.type === TransactionEnvelopeType.legacy || isLegacyTransaction(transactionMeta); - const selectedNetworkClientId = useSelector(getSelectedNetworkClientId); - const networkAndAccountSupports1559 = useSelector((state) => - checkNetworkAndAccountSupports1559(state, selectedNetworkClientId), + checkNetworkAndAccountSupports1559(state, networkClientId), ); const supportsEIP1559 = networkAndAccountSupports1559 && !isLegacyTxn; diff --git a/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.test.tsx b/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.test.tsx index 589cf064dd6e..93dc380c4a48 100644 --- a/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.test.tsx +++ b/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.test.tsx @@ -17,6 +17,7 @@ const render = () => { time: new Date().getTime(), type: TransactionType.personalSign, chainId: '0x1', + origin: 'test.com', }; const mockExpectedState = { diff --git a/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx b/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx index f8d6b87e50db..046f1396883b 100644 --- a/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx +++ b/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx @@ -16,10 +16,12 @@ const TOAST_TIMEOUT_MILLISECONDS = 5 * 1000; // 5 Seconds const NetworkChangeToastLegacy = ({ confirmation, }: { - confirmation: { id: string; chainId: string }; + confirmation: { id: string; chainId: string; origin: string }; }) => { const newChainId = confirmation?.chainId; - const [toastVisible, setToastVisible] = useState(false); + const newOrigin = confirmation?.origin; + const [chainChanged, setChainChanged] = useState(false); + const [originChanged, setOriginChanged] = useState(false); const t = useI18nContext(); const network = useSelector((state) => @@ -27,8 +29,9 @@ const NetworkChangeToastLegacy = ({ ); const hideToast = useCallback(() => { - setToastVisible(false); - }, [setToastVisible]); + setChainChanged(false); + setOriginChanged(false); + }, []); useEffect(() => { let isMounted = true; @@ -40,49 +43,74 @@ const NetworkChangeToastLegacy = ({ (async () => { const lastInteractedConfirmationInfo = await getLastInteractedConfirmationInfo(); + const currentTimestamp = new Date().getTime(); - if ( + + const timeSinceLastConfirmation = + currentTimestamp - lastInteractedConfirmationInfo.timestamp; + + const recentlyViewedOtherConfirmation = + timeSinceLastConfirmation <= CHAIN_CHANGE_THRESHOLD_MILLISECONDS; + + const isDifferentChain = lastInteractedConfirmationInfo && - lastInteractedConfirmationInfo.chainId !== newChainId && - currentTimestamp - lastInteractedConfirmationInfo.timestamp <= - CHAIN_CHANGE_THRESHOLD_MILLISECONDS && + lastInteractedConfirmationInfo.chainId !== newChainId; + + const isDifferentOrigin = + lastInteractedConfirmationInfo && + lastInteractedConfirmationInfo.origin !== newOrigin; + + if ( + recentlyViewedOtherConfirmation && + (isDifferentChain || isDifferentOrigin) && isMounted ) { - setToastVisible(true); + setChainChanged(isDifferentChain); + setOriginChanged(isDifferentOrigin); + setTimeout(() => { if (isMounted) { hideToast(); } }, TOAST_TIMEOUT_MILLISECONDS); } - if ( - (!lastInteractedConfirmationInfo || - lastInteractedConfirmationInfo?.id !== confirmation.id) && - isMounted - ) { + + const isNewId = + !lastInteractedConfirmationInfo || + lastInteractedConfirmationInfo?.id !== confirmation.id; + + if (isNewId && isMounted) { setLastInteractedConfirmationInfo({ id: confirmation.id, chainId: newChainId, + origin: newOrigin, timestamp: new Date().getTime(), }); } })(); + return () => { isMounted = false; }; }, [confirmation?.id]); - if (!toastVisible) { + if (!chainChanged && !originChanged) { return null; } + const text = []; + + if (chainChanged) { + text.push(t('networkSwitchMessage', [network.name ?? ''])); + } + + if (originChanged) { + text.push(t('originSwitchMessage', [new URL(newOrigin).host])); + } + return ( - + ); }; diff --git a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.component.js b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.component.js index bf90043c9778..99cc638422c2 100644 --- a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.component.js @@ -180,10 +180,10 @@ export default class ConfirmTransactionBase extends Component { maxValue: PropTypes.string, smartTransactionsPreferenceEnabled: PropTypes.bool, currentChainSupportsSmartTransactions: PropTypes.bool, - selectedNetworkClientId: PropTypes.string, isSmartTransactionsEnabled: PropTypes.bool, hasPriorityApprovalRequest: PropTypes.bool, chainId: PropTypes.string, + networkClientId: PropTypes.string, }; state = { @@ -1048,17 +1048,17 @@ export default class ConfirmTransactionBase extends Component { * while waiting for `gasFeeStartPollingByNetworkClientId` to resolve, the `_isMounted` * flag ensures that a call to disconnect happens after promise resolution. */ - gasFeeStartPollingByNetworkClientId( - this.props.selectedNetworkClientId, - ).then((pollingToken) => { - if (this._isMounted) { - addPollingTokenToAppState(pollingToken); - this.setState({ pollingToken }); - } else { - gasFeeStopPollingByPollingToken(pollingToken); - removePollingTokenFromAppState(this.state.pollingToken); - } - }); + gasFeeStartPollingByNetworkClientId(this.props.networkClientId).then( + (pollingToken) => { + if (this._isMounted) { + addPollingTokenToAppState(pollingToken); + this.setState({ pollingToken }); + } else { + gasFeeStopPollingByPollingToken(pollingToken); + removePollingTokenFromAppState(this.state.pollingToken); + } + }, + ); window.addEventListener('beforeunload', this._beforeUnloadForGasPolling); diff --git a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js index d9a04ace67a9..d5e3a27f4171 100644 --- a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js @@ -71,7 +71,6 @@ import { getSendToAccounts, findKeyringForAddress, } from '../../../ducks/metamask/metamask'; -import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { addHexPrefix, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -163,7 +162,6 @@ const mapStateToProps = (state, ownProps) => { } = ownProps; const { id: paramsTransactionId } = params; const isMainnet = getIsMainnet(state); - const selectedNetworkClientId = getSelectedNetworkClientId(state); ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) const envType = getEnvironmentType(); @@ -181,7 +179,7 @@ const mapStateToProps = (state, ownProps) => { const { txParams = {}, id: transactionId, type } = txData; const txId = transactionId || paramsTransactionId; const transaction = getUnapprovedTransaction(state, txId) ?? {}; - const { chainId } = transaction; + const { chainId, networkClientId } = transaction; const conversionRate = selectConversionRateByChainId(state, chainId); const { @@ -354,7 +352,6 @@ const mapStateToProps = (state, ownProps) => { nextNonce, mostRecentOverviewPage: getMostRecentOverviewPage(state), isMainnet, - selectedNetworkClientId, isEthGasPriceFetched, noGasPrice, supportsEIP1559, @@ -367,6 +364,7 @@ const mapStateToProps = (state, ownProps) => { nativeCurrency, hardwareWalletRequiresConnection, chainId, + networkClientId, isBuyableChain, useCurrencyRateCheck: getUseCurrencyRateCheck(state), keyringForAccount: keyring, diff --git a/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js b/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js index f5858e853dd1..22da7ab1e316 100644 --- a/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js +++ b/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js @@ -13,7 +13,6 @@ import { } from '../../../ducks/confirm-transaction/confirm-transaction.duck'; import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { getSendTo } from '../../../ducks/send'; -import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { CONFIRM_DEPLOY_CONTRACT_PATH, CONFIRM_SEND_ETHER_PATH, @@ -65,7 +64,6 @@ const ConfirmTransaction = () => { const unconfirmedTxsSorted = useSelector(unconfirmedTransactionsListSelector); const unconfirmedTxs = useSelector(unconfirmedTransactionsHashSelector); - const networkClientId = useSelector(getSelectedNetworkClientId); const totalUnapproved = unconfirmedTxsSorted.length || 0; const getTransaction = useCallback(() => { @@ -129,7 +127,7 @@ const ConfirmTransaction = () => { startPolling: (input) => gasFeeStartPollingByNetworkClientId(input.networkClientId), stopPollingByPollingToken: gasFeeStopPollingByPollingToken, - input: { networkClientId: transaction.networkClientId ?? networkClientId }, + input: { networkClientId: transaction.networkClientId }, }); useEffect(() => { diff --git a/ui/pages/confirmations/confirmation/confirmation.js b/ui/pages/confirmations/confirmation/confirmation.js index d8d951871586..419d06803175 100644 --- a/ui/pages/confirmations/confirmation/confirmation.js +++ b/ui/pages/confirmations/confirmation/confirmation.js @@ -34,6 +34,7 @@ import { useSafeChainsListValidationSelector, getSnapsMetadata, getHideSnapBranding, + getPendingApprovals, } from '../../../selectors'; import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import Callout from '../../../components/ui/callout'; @@ -106,6 +107,7 @@ const alertStateReducer = produce((state, action) => { * @param state.matchedChain * @param state.providerError * @param state.preventAlertsForAddChainValidation + * @param state.pendingApprovals * @returns {[alertState: object, dismissAlert: Function]} A tuple with * the current alert state and function to dismiss an alert by id */ @@ -117,6 +119,7 @@ function useAlertState( matchedChain, providerError, preventAlertsForAddChainValidation = false, + pendingApprovals, } = {}, ) { const [alertState, dispatch] = useReducer(alertStateReducer, {}); @@ -137,6 +140,7 @@ function useAlertState( useSafeChainsListValidation, matchedChain, providerError, + pendingApprovals, }).then((alerts) => { if (isMounted && alerts.length > 0) { dispatch({ @@ -255,12 +259,14 @@ export default function ConfirmationPage({ !chainFetchComplete; const [currencySymbolWarning, setCurrencySymbolWarning] = useState(null); const [providerError, setProviderError] = useState(null); + const pendingApprovals = useSelector(getPendingApprovals); const [alertState, dismissAlert] = useAlertState(pendingConfirmation, { unapprovedTxsCount, useSafeChainsListValidation, matchedChain, providerError, preventAlertsForAddChainValidation, + pendingApprovals, }); const [templateState] = useTemplateState(pendingConfirmation); const [showWarningModal, setShowWarningModal] = useState(false); diff --git a/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js b/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js index 0c375bb1e667..a3fd2cbaa832 100644 --- a/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js +++ b/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js @@ -162,10 +162,29 @@ const ERROR_CONNECTING_TO_RPC = { }, }; +const ALERT_PENDING_CONFIRMATIONS = (count) => ({ + id: 'PENDING_TX_DROP_NOTICE', + severity: Severity.Warning, + content: { + element: 'span', + children: { + element: 'MetaMaskTranslation', + props: { + translationKey: + count === 1 + ? 'switchingNetworksCancelsPendingConfirmationsExtendedSingular' + : 'switchingNetworksCancelsPendingConfirmationsExtended', + variables: [count], + }, + }, + }, +}); + async function getAlerts(pendingApproval, data) { + const { origin } = pendingApproval; const alerts = []; - const originIsMetaMask = pendingApproval.origin === 'metamask'; + const originIsMetaMask = origin === 'metamask'; if (originIsMetaMask && Boolean(data.matchedChain)) { return []; } @@ -184,9 +203,11 @@ async function getAlerts(pendingApproval, data) { alerts.push(MISMATCHED_NETWORK_SYMBOL); } - const { origin } = new URL(pendingApproval.requestData.rpcUrl); + const { origin: rpcOrigin } = new URL(pendingApproval.requestData.rpcUrl); if ( - !data.matchedChain.rpc?.map((rpc) => new URL(rpc).origin).includes(origin) + !data.matchedChain.rpc + ?.map((rpc) => new URL(rpc).origin) + .includes(rpcOrigin) ) { alerts.push(MISMATCHED_NETWORK_RPC); } @@ -207,6 +228,14 @@ async function getAlerts(pendingApproval, data) { alerts.push(MISMATCHED_CHAIN_RECOMMENDATION); } + const originPendingApprovals = data.pendingApprovals.filter( + (approval) => approval.origin === origin, + ); + + if (originPendingApprovals.length > 1) { + alerts.push(ALERT_PENDING_CONFIRMATIONS(originPendingApprovals.length - 1)); + } + return alerts; } diff --git a/ui/pages/confirmations/confirmation/templates/switch-ethereum-chain.js b/ui/pages/confirmations/confirmation/templates/switch-ethereum-chain.js index 7dbdb8c9c757..04ec70438a11 100644 --- a/ui/pages/confirmations/confirmation/templates/switch-ethereum-chain.js +++ b/ui/pages/confirmations/confirmation/templates/switch-ethereum-chain.js @@ -6,7 +6,7 @@ import { TypographyVariant, } from '../../../../helpers/constants/design-system'; -const PENDING_TX_DROP_NOTICE = { +const ALERT_PENDING_CONFIRMATIONS = (count) => ({ id: 'PENDING_TX_DROP_NOTICE', severity: SEVERITIES.WARNING, content: { @@ -14,18 +14,18 @@ const PENDING_TX_DROP_NOTICE = { children: { element: 'MetaMaskTranslation', props: { - translationKey: 'switchingNetworksCancelsPendingConfirmations', + translationKey: + count === 1 + ? 'switchingNetworksCancelsPendingConfirmationsSingular' + : 'switchingNetworksCancelsPendingConfirmations', + variables: [count], }, }, }, -}; +}); async function getAlerts(_pendingApproval, state) { - const alerts = []; - if (state.unapprovedTxsCount > 0) { - alerts.push(PENDING_TX_DROP_NOTICE); - } - return alerts; + return [ALERT_PENDING_CONFIRMATIONS(state.pendingApprovals.length - 1)]; } function getValues(pendingApproval, t, actions) { @@ -58,6 +58,14 @@ function getValues(pendingApproval, t, actions) { }, }, }, + { + element: 'OriginPill', + key: 'origin-pill', + props: { + origin: pendingApproval.origin, + dataTestId: 'signature-origin-pill', + }, + }, { element: 'Box', key: 'status-box', diff --git a/ui/pages/confirmations/hooks/useConfirmationNavigation.ts b/ui/pages/confirmations/hooks/useConfirmationNavigation.ts index 95bc1d93cd34..ef081c6497c9 100644 --- a/ui/pages/confirmations/hooks/useConfirmationNavigation.ts +++ b/ui/pages/confirmations/hooks/useConfirmationNavigation.ts @@ -76,12 +76,14 @@ export function navigateToConfirmation( hasApprovalFlows: boolean, history: ReturnType, ) { - if (hasApprovalFlows) { + const hasNoConfirmations = confirmations?.length <= 0 || !confirmationId; + + if (hasApprovalFlows && hasNoConfirmations) { history.replace(`${CONFIRMATION_V_NEXT_ROUTE}`); return; } - if (confirmations?.length <= 0 || !confirmationId) { + if (hasNoConfirmations) { return; } diff --git a/ui/selectors/approvals.ts b/ui/selectors/approvals.ts index f8f937bb2f34..89ccad2a5d1e 100644 --- a/ui/selectors/approvals.ts +++ b/ui/selectors/approvals.ts @@ -112,3 +112,10 @@ function isWatchNftApproval(approval: ApprovalRequest>) { return approval.type === ApprovalType.WatchAsset && Boolean(tokenId); } + +export const selectPendingApprovalsForOrigin = createDeepEqualSelector( + getPendingApprovals, + (_state: ApprovalsMetaMaskState, origin: string) => origin, + (pendingApprovals, origin) => + pendingApprovals.filter((approval) => approval.origin === origin), +); diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index 2017c3c68932..4a700b3ca846 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -28,10 +28,7 @@ import { subtractHexes, sumHexes, } from '../../shared/modules/conversion.utils'; -import { - getProviderConfig, - getCurrentChainId, -} from '../../shared/modules/selectors/networks'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { getAveragePriceEstimateInHexWEI } from './custom-gas'; import { checkNetworkAndAccountSupports1559, @@ -56,14 +53,12 @@ export const unconfirmedTransactionsListSelector = createSelector( unapprovedDecryptMsgsSelector, unapprovedEncryptionPublicKeyMsgsSelector, unapprovedTypedMessagesSelector, - getCurrentChainId, ( unapprovedTxs = {}, unapprovedPersonalMsgs = {}, unapprovedDecryptMsgs = {}, unapprovedEncryptionPublicKeyMsgs = {}, unapprovedTypedMessages = {}, - chainId, ) => txHelper( unapprovedTxs, @@ -71,7 +66,6 @@ export const unconfirmedTransactionsListSelector = createSelector( unapprovedDecryptMsgs, unapprovedEncryptionPublicKeyMsgs, unapprovedTypedMessages, - chainId, ) || [], ); @@ -81,36 +75,19 @@ export const unconfirmedTransactionsHashSelector = createSelector( unapprovedDecryptMsgsSelector, unapprovedEncryptionPublicKeyMsgsSelector, unapprovedTypedMessagesSelector, - getCurrentChainId, ( unapprovedTxs = {}, unapprovedPersonalMsgs = {}, unapprovedDecryptMsgs = {}, unapprovedEncryptionPublicKeyMsgs = {}, unapprovedTypedMessages = {}, - chainId, - ) => { - const filteredUnapprovedTxs = Object.keys(unapprovedTxs).reduce( - (acc, address) => { - const transactions = { ...acc }; - - if (unapprovedTxs[address].chainId === chainId) { - transactions[address] = unapprovedTxs[address]; - } - - return transactions; - }, - {}, - ); - - return { - ...filteredUnapprovedTxs, - ...unapprovedPersonalMsgs, - ...unapprovedDecryptMsgs, - ...unapprovedEncryptionPublicKeyMsgs, - ...unapprovedTypedMessages, - }; - }, + ) => ({ + ...unapprovedTxs, + ...unapprovedPersonalMsgs, + ...unapprovedDecryptMsgs, + ...unapprovedEncryptionPublicKeyMsgs, + ...unapprovedTypedMessages, + }), ); export const unconfirmedMessagesHashSelector = createSelector( diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js index 4e5b038b3cc8..c6c840b41245 100644 --- a/ui/selectors/transactions.js +++ b/ui/selectors/transactions.js @@ -72,7 +72,7 @@ export const getCurrentNetworkTransactions = createDeepEqualSelector( export const getUnapprovedTransactions = createDeepEqualSelector( (state) => { - const currentNetworkTransactions = getCurrentNetworkTransactions(state); + const currentNetworkTransactions = getTransactions(state); return filterAndShapeUnapprovedTransactions(currentNetworkTransactions); }, (transactions) => transactions,