diff --git a/ui/app/pages/confirm-encryption-public-key/confirm-encryption-public-key.container.js b/ui/app/pages/confirm-encryption-public-key/confirm-encryption-public-key.container.js
index ee8061dcf..3439589b6 100644
--- a/ui/app/pages/confirm-encryption-public-key/confirm-encryption-public-key.container.js
+++ b/ui/app/pages/confirm-encryption-public-key/confirm-encryption-public-key.container.js
@@ -10,6 +10,7 @@ import {
import {
conversionRateSelector,
+ unconfirmedTransactionsListSelector,
getTargetAccountWithSendEtherInfo,
} from '../../selectors';
@@ -19,11 +20,12 @@ import ConfirmEncryptionPublicKey from './confirm-encryption-public-key.componen
function mapStateToProps(state) {
const {
- confirmTransaction,
metamask: { domainMetadata = {} },
} = state;
- const { txData = {} } = confirmTransaction;
+ const unconfirmedTransactions = unconfirmedTransactionsListSelector(state);
+
+ const txData = unconfirmedTransactions[0];
const { msgParams: from } = txData;
diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js
index 5d09b3198..d1b702df9 100644
--- a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js
+++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js
@@ -22,6 +22,7 @@ import {
TRANSACTION_CATEGORIES,
TRANSACTION_STATUSES,
} from '../../../../shared/constants/transaction';
+import { getTransactionCategoryTitle } from '../../helpers/utils/transactions.util';
export default class ConfirmTransactionBase extends Component {
static contextTypes = {
@@ -690,7 +691,7 @@ export default class ConfirmTransactionBase extends Component {
let functionType = getMethodName(name);
if (!functionType) {
if (transactionCategory) {
- functionType = t(transactionCategory) || transactionCategory;
+ functionType = getTransactionCategoryTitle(t, transactionCategory);
} else {
functionType = t('contractInteraction');
}
diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js
index 0875f8892..69c8fa0d9 100644
--- a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js
+++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js
@@ -39,6 +39,7 @@ import {
transactionFeeSelector,
} from '../../selectors';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
+import { transactionMatchesNetwork } from '../../../../shared/modules/transaction.utils';
import ConfirmTransactionBase from './confirm-transaction-base.component';
const casedContractMap = Object.keys(contractMap).reduce((acc, base) => {
@@ -77,6 +78,7 @@ const mapStateToProps = (state, ownProps) => {
unapprovedTxs,
metaMetricsSendCount,
nextNonce,
+ provider: { chainId },
} = metamask;
const { tokenData, txData, tokenProps, nonce } = confirmTransaction;
const {
@@ -127,7 +129,9 @@ const mapStateToProps = (state, ownProps) => {
}
const currentNetworkUnapprovedTxs = Object.keys(unapprovedTxs)
- .filter((key) => unapprovedTxs[key].metamaskNetworkId === network)
+ .filter((key) =>
+ transactionMatchesNetwork(unapprovedTxs[key], chainId, network),
+ )
.reduce((acc, key) => ({ ...acc, [key]: unapprovedTxs[key] }), {});
const unapprovedTxCount = valuesFor(currentNetworkUnapprovedTxs).length;
diff --git a/ui/app/pages/confirm-transaction/conf-tx.js b/ui/app/pages/confirm-transaction/conf-tx.js
index 5acc1c435..ef8657424 100644
--- a/ui/app/pages/confirm-transaction/conf-tx.js
+++ b/ui/app/pages/confirm-transaction/conf-tx.js
@@ -32,6 +32,7 @@ function mapStateToProps(state) {
index: txId,
warning: state.appState.warning,
network: state.metamask.network,
+ chainId: state.metamask.provider.chainId,
currentCurrency: state.metamask.currentCurrency,
blockGasLimit: state.metamask.currentBlockGasLimit,
unapprovedMsgCount,
@@ -49,6 +50,7 @@ class ConfirmTxScreen extends Component {
unapprovedPersonalMsgCount: PropTypes.number,
unapprovedTypedMessagesCount: PropTypes.number,
network: PropTypes.string,
+ chainId: PropTypes.string,
index: PropTypes.number,
unapprovedTxs: PropTypes.object,
unapprovedMsgs: PropTypes.object,
@@ -94,6 +96,7 @@ class ConfirmTxScreen extends Component {
unapprovedPersonalMsgs,
unapprovedTypedMessages,
match: { params: { id: transactionId } = {} },
+ chainId,
} = this.props;
const unconfTxList = txHelper(
@@ -102,6 +105,7 @@ class ConfirmTxScreen extends Component {
unapprovedPersonalMsgs,
unapprovedTypedMessages,
network,
+ chainId,
);
log.info(`rendering a combined ${unconfTxList.length} unconf msgs & txs`);
@@ -177,9 +181,10 @@ class ConfirmTxScreen extends Component {
history,
mostRecentOverviewPage,
network,
+ chainId,
send,
} = this.props;
- const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network);
+ const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network, chainId);
if (
unconfTxList.length === 0 &&
@@ -194,6 +199,7 @@ class ConfirmTxScreen extends Component {
const {
unapprovedTxs = {},
network,
+ chainId,
currentNetworkTxList,
send,
history,
@@ -207,13 +213,20 @@ class ConfirmTxScreen extends Component {
prevTx = currentNetworkTxList.find(({ id }) => `${id}` === transactionId);
} else {
const { index: prevIndex, unapprovedTxs: prevUnapprovedTxs } = prevProps;
- const prevUnconfTxList = txHelper(prevUnapprovedTxs, {}, {}, {}, network);
+ const prevUnconfTxList = txHelper(
+ prevUnapprovedTxs,
+ {},
+ {},
+ {},
+ network,
+ chainId,
+ );
const prevTxData = prevUnconfTxList[prevIndex] || {};
prevTx =
currentNetworkTxList.find(({ id }) => id === prevTxData.id) || {};
}
- const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network);
+ const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network, chainId);
if (prevTx && prevTx.status === TRANSACTION_STATUSES.DROPPED) {
this.props.dispatch(
diff --git a/ui/app/pages/confirmation/components/confirmation-footer/confirmation-footer.js b/ui/app/pages/confirmation/components/confirmation-footer/confirmation-footer.js
new file mode 100644
index 000000000..9574a13a4
--- /dev/null
+++ b/ui/app/pages/confirmation/components/confirmation-footer/confirmation-footer.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from '../../../../components/ui/button';
+
+export default function ConfirmationFooter({
+ onApprove,
+ onCancel,
+ approveText,
+ cancelText,
+ alerts,
+}) {
+ return (
+
+ {alerts}
+
+
+ {cancelText}
+
+
+ {approveText}
+
+
+
+ );
+}
+
+ConfirmationFooter.propTypes = {
+ alerts: PropTypes.node,
+ onApprove: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ approveText: PropTypes.string.isRequired,
+ cancelText: PropTypes.string.isRequired,
+};
diff --git a/ui/app/pages/confirmation/components/confirmation-footer/confirmation-footer.scss b/ui/app/pages/confirmation/components/confirmation-footer/confirmation-footer.scss
new file mode 100644
index 000000000..9bad79296
--- /dev/null
+++ b/ui/app/pages/confirmation/components/confirmation-footer/confirmation-footer.scss
@@ -0,0 +1,14 @@
+.confirmation-footer {
+ grid-area: footer;
+
+ &__actions {
+ display: flex;
+ border-top: 1px solid $ui-2;
+ background-color: white;
+ padding: 16px;
+
+ & .button:first-child {
+ margin-right: 16px;
+ }
+ }
+}
diff --git a/ui/app/pages/confirmation/components/confirmation-footer/index.js b/ui/app/pages/confirmation/components/confirmation-footer/index.js
new file mode 100644
index 000000000..e2f17c87a
--- /dev/null
+++ b/ui/app/pages/confirmation/components/confirmation-footer/index.js
@@ -0,0 +1 @@
+export { default } from './confirmation-footer';
diff --git a/ui/app/pages/confirmation/confirmation.js b/ui/app/pages/confirmation/confirmation.js
new file mode 100644
index 000000000..47ce4f191
--- /dev/null
+++ b/ui/app/pages/confirmation/confirmation.js
@@ -0,0 +1,230 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useReducer,
+ useState,
+} from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory } from 'react-router-dom';
+import { isEqual } from 'lodash';
+import { produce } from 'immer';
+import Box from '../../components/ui/box';
+import Chip from '../../components/ui/chip';
+import MetaMaskTemplateRenderer from '../../components/app/metamask-template-renderer';
+import SiteIcon from '../../components/ui/site-icon';
+import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
+import { stripHttpsScheme } from '../../helpers/utils/util';
+import { useI18nContext } from '../../hooks/useI18nContext';
+import { useOriginMetadata } from '../../hooks/useOriginMetadata';
+import { getUnapprovedConfirmations } from '../../selectors';
+import NetworkDisplay from '../../components/app/network-display/network-display';
+import { COLORS, SIZES } from '../../helpers/constants/design-system';
+import Callout from '../../components/ui/callout';
+import ConfirmationFooter from './components/confirmation-footer';
+import { getTemplateValues, getTemplateAlerts } from './templates';
+
+/**
+ * a very simple reducer using produce from Immer to keep state manipulation
+ * immutable and painless. This state is not stored in redux state because it
+ * should persist only for the lifespan of the current session, and will only
+ * be used on this page. Dismissing alerts for confirmations should persist
+ * while the user pages back and forth between confirmations. However, if the
+ * user closes the confirmation window and later reopens the extension they
+ * should be displayed the alerts again.
+ */
+const alertStateReducer = produce((state, action) => {
+ switch (action.type) {
+ case 'dismiss':
+ if (state?.[action.confirmationId]?.[action.alertId]) {
+ state[action.confirmationId][action.alertId].dismissed = true;
+ }
+ break;
+ case 'set':
+ if (!state[action.confirmationId]) {
+ state[action.confirmationId] = {};
+ }
+ action.alerts.forEach((alert) => {
+ state[action.confirmationId][alert.id] = {
+ ...alert,
+ dismissed: false,
+ };
+ });
+ break;
+ default:
+ throw new Error(
+ 'You must provide a type when dispatching an action for alertState',
+ );
+ }
+});
+
+/**
+ * Encapsulates the state and effects needed to manage alert state for the
+ * confirmation page in a custom hook. This hook is not likely to be used
+ * outside of this file, but it helps to reduce complexity of the primary
+ * component.
+ * @param {Object} pendingConfirmation - a pending confirmation waiting for
+ * user approval
+ * @returns {[alertState: Object, dismissAlert: Function]} - tuple with
+ * the current alert state and function to dismiss an alert by id
+ */
+function useAlertState(pendingConfirmation) {
+ const [alertState, dispatch] = useReducer(alertStateReducer, {});
+
+ /**
+ * Computation of the current alert state happens every time the current
+ * pendingConfirmation changes. The async function getTemplateAlerts is
+ * responsible for returning alert state. Setting state on unmounted
+ * components is an anti-pattern, so we use a isMounted variable to keep
+ * track of the current state of the component. Returning a function that
+ * sets isMounted to false when the component is unmounted.
+ */
+ useEffect(() => {
+ let isMounted = true;
+ if (pendingConfirmation) {
+ getTemplateAlerts(pendingConfirmation).then((alerts) => {
+ if (isMounted && alerts) {
+ dispatch({
+ type: 'set',
+ confirmationId: pendingConfirmation.id,
+ alerts,
+ });
+ }
+ });
+ }
+ return () => {
+ isMounted = false;
+ };
+ }, [pendingConfirmation]);
+
+ const dismissAlert = useCallback(
+ (alertId) => {
+ dispatch({
+ type: 'dismiss',
+ confirmationId: pendingConfirmation.id,
+ alertId,
+ });
+ },
+ [pendingConfirmation],
+ );
+
+ return [alertState, dismissAlert];
+}
+
+export default function ConfirmationPage() {
+ const t = useI18nContext();
+ const dispatch = useDispatch();
+ const history = useHistory();
+ const pendingConfirmations = useSelector(getUnapprovedConfirmations, isEqual);
+ const [currentPendingConfirmation, setCurrentPendingConfirmation] = useState(
+ 0,
+ );
+ const pendingConfirmation = pendingConfirmations[currentPendingConfirmation];
+ const originMetadata = useOriginMetadata(pendingConfirmation?.origin);
+ const [alertState, dismissAlert] = useAlertState(pendingConfirmation);
+
+ // Generating templatedValues is potentially expensive, and if done on every render
+ // will result in a new object. Avoiding calling this generation unnecessarily will
+ // improve performance and prevent unnecessary draws.
+ const templatedValues = useMemo(() => {
+ return pendingConfirmation
+ ? getTemplateValues(pendingConfirmation, t, dispatch)
+ : {};
+ }, [pendingConfirmation, t, dispatch]);
+
+ useEffect(() => {
+ // If the number of pending confirmations reduces to zero when the user
+ // return them to the default route. Otherwise, if the number of pending
+ // confirmations reduces to a number that is less than the currently
+ // viewed index, reset the index.
+ if (pendingConfirmations.length === 0) {
+ history.push(DEFAULT_ROUTE);
+ } else if (pendingConfirmations.length <= currentPendingConfirmation) {
+ setCurrentPendingConfirmation(pendingConfirmations.length - 1);
+ }
+ }, [pendingConfirmations, history, currentPendingConfirmation]);
+ if (!pendingConfirmation) {
+ return null;
+ }
+
+ return (
+
+ {pendingConfirmations.length > 1 && (
+
+
+ {t('xOfYPending', [
+ currentPendingConfirmation + 1,
+ pendingConfirmations.length,
+ ])}
+
+ {currentPendingConfirmation > 0 && (
+
+ setCurrentPendingConfirmation(currentPendingConfirmation - 1)
+ }
+ >
+
+
+ )}
+
+ setCurrentPendingConfirmation(currentPendingConfirmation + 1)
+ }
+ >
+
+
+
+ )}
+
+
+
+
+
+
+ }
+ />
+
+
+
+
alert.dismissed === false)
+ .map((alert, idx, filtered) => (
+ dismissAlert(alert.id)}
+ isFirst={idx === 0}
+ isLast={idx === filtered.length - 1}
+ isMultiple={filtered.length > 1}
+ >
+
+
+ ))
+ }
+ onApprove={templatedValues.onApprove}
+ onCancel={templatedValues.onCancel}
+ approveText={templatedValues.approvalText}
+ cancelText={templatedValues.cancelText}
+ />
+
+ );
+}
diff --git a/ui/app/pages/confirmation/confirmation.scss b/ui/app/pages/confirmation/confirmation.scss
new file mode 100644
index 000000000..dea6190ee
--- /dev/null
+++ b/ui/app/pages/confirmation/confirmation.scss
@@ -0,0 +1,62 @@
+@import 'components/confirmation-footer/confirmation-footer';
+
+.confirmation-page {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ background: white;
+ display: grid;
+ flex-direction: column;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ 'navigation'
+ 'content'
+ 'footer';
+
+ a {
+ color: $primary-1;
+ }
+
+ &__content {
+ grid-area: content;
+ padding: 16px 16px 0;
+
+ & > :last-child {
+ margin-bottom: 16px;
+ }
+ }
+
+ &__navigation {
+ @include H7;
+
+ grid-area: navigation;
+ background-color: $Grey-000;
+ border-bottom: 1px solid $geyser;
+ padding: 6px 16px 5px 16px;
+ color: $Grey-500;
+ display: grid;
+ grid-template-columns: 1fr minmax(0, auto) minmax(0, auto);
+ align-items: center;
+ }
+
+ &__navigation-button {
+ background-color: white;
+ border-radius: 100px;
+ color: $Grey-500;
+ font-size: $font-size-h6;
+ height: 20px;
+ width: 20px;
+ padding: 0;
+
+ &:disabled {
+ cursor: not-allowed;
+ background-color: $Grey-100;
+ color: $Grey-300;
+ }
+ }
+
+ &__navigation &__navigation-button:last-child {
+ margin-left: 8px;
+ }
+}
diff --git a/ui/app/pages/confirmation/index.js b/ui/app/pages/confirmation/index.js
new file mode 100644
index 000000000..2bb2f9499
--- /dev/null
+++ b/ui/app/pages/confirmation/index.js
@@ -0,0 +1 @@
+export { default } from './confirmation';
diff --git a/ui/app/pages/confirmation/templates/add-ethereum-chain.js b/ui/app/pages/confirmation/templates/add-ethereum-chain.js
new file mode 100644
index 000000000..a9c85c53d
--- /dev/null
+++ b/ui/app/pages/confirmation/templates/add-ethereum-chain.js
@@ -0,0 +1,217 @@
+import { ethErrors } from 'eth-rpc-errors';
+import {
+ SEVERITIES,
+ TYPOGRAPHY,
+} from '../../../helpers/constants/design-system';
+import fetchWithCache from '../../../helpers/utils/fetch-with-cache';
+
+const UNRECOGNIZED_CHAIN = {
+ id: 'UNRECOGNIZED_CHAIN',
+ severity: SEVERITIES.WARNING,
+ content: {
+ element: 'span',
+ children: {
+ element: 'MetaMaskTranslation',
+ props: {
+ translationKey: 'unrecognizedChain',
+ variables: [
+ {
+ element: 'a',
+ key: 'unrecognizedChainLink',
+ props: {
+ href:
+ 'https://metamask.zendesk.com/hc/en-us/articles/360057142392',
+ target: '__blank',
+ tabIndex: 0,
+ },
+ children: {
+ element: 'MetaMaskTranslation',
+ props: {
+ translationKey: 'unrecognizedChainLinkText',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+const INVALID_CHAIN = {
+ id: 'INVALID_CHAIN',
+ severity: SEVERITIES.DANGER,
+ content: {
+ element: 'span',
+ children: {
+ element: 'MetaMaskTranslation',
+ props: {
+ translationKey: 'mismatchedChain',
+ variables: [
+ {
+ element: 'a',
+ key: 'mismatchedChainLink',
+ props: {
+ href:
+ 'https://metamask.zendesk.com/hc/en-us/articles/360057142392',
+ target: '__blank',
+ tabIndex: 0,
+ },
+ children: {
+ element: 'MetaMaskTranslation',
+ props: {
+ translationKey: 'mismatchedChainLinkText',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+async function getAlerts(pendingApproval) {
+ const alerts = [];
+ const safeChainsList = await fetchWithCache(
+ 'https://chainid.network/chains.json',
+ );
+ const matchedChain = safeChainsList.find(
+ (chain) =>
+ chain.chainId === parseInt(pendingApproval.requestData.chainId, 16),
+ );
+ let validated = Boolean(matchedChain);
+
+ if (matchedChain) {
+ if (
+ matchedChain.nativeCurrency?.decimals !== 18 ||
+ matchedChain.name.toLowerCase() !==
+ pendingApproval.requestData.chainName.toLowerCase() ||
+ matchedChain.nativeCurrency?.symbol !== pendingApproval.requestData.ticker
+ ) {
+ validated = false;
+ }
+
+ const { origin } = new URL(pendingApproval.requestData.rpcUrl);
+ if (!matchedChain.rpc.map((rpc) => new URL(rpc).origin).includes(origin)) {
+ validated = false;
+ }
+ }
+
+ if (!matchedChain) {
+ alerts.push(UNRECOGNIZED_CHAIN);
+ } else if (!validated) {
+ alerts.push(INVALID_CHAIN);
+ }
+ return alerts;
+}
+
+function getValues(pendingApproval, t, actions) {
+ return {
+ content: [
+ {
+ element: 'Typography',
+ key: 'title',
+ children: t('addEthereumChainConfirmationTitle'),
+ props: {
+ variant: TYPOGRAPHY.H3,
+ align: 'center',
+ fontWeight: 'bold',
+ boxProps: {
+ margin: [0, 0, 4],
+ },
+ },
+ },
+ {
+ element: 'Typography',
+ key: 'description',
+ children: t('addEthereumChainConfirmationDescription'),
+ props: {
+ variant: TYPOGRAPHY.H7,
+ align: 'center',
+ boxProps: {
+ margin: [0, 0, 4],
+ },
+ },
+ },
+ {
+ element: 'Typography',
+ key: 'only-add-networks-you-trust',
+ children: [
+ {
+ element: 'b',
+ key: 'bolded-text',
+ children: `${t('addEthereumChainConfirmationRisks')} `,
+ },
+ {
+ element: 'MetaMaskTranslation',
+ key: 'learn-about-risks',
+ props: {
+ translationKey: 'addEthereumChainConfirmationRisksLearnMore',
+ variables: [
+ {
+ element: 'a',
+ children: t('addEthereumChainConfirmationRisksLearnMoreLink'),
+ key: 'addEthereumChainConfirmationRisksLearnMoreLink',
+ props: {
+ href:
+ 'https://metamask.zendesk.com/hc/en-us/articles/360056196151',
+ target: '__blank',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ props: {
+ variant: TYPOGRAPHY.H7,
+ align: 'center',
+ boxProps: {
+ margin: 0,
+ },
+ },
+ },
+ {
+ element: 'TruncatedDefinitionList',
+ key: 'network-details',
+ props: {
+ title: t('networkDetails'),
+ tooltips: {
+ [t('networkName')]: t('networkNameDefinition'),
+ [t('networkURL')]: t('networkURLDefinition'),
+ [t('chainId')]: t('chainIdDefinition'),
+ [t('currencySymbol')]: t('currencySymbolDefinition'),
+ [t('blockExplorerUrl')]: t('blockExplorerUrlDefinition'),
+ },
+ dictionary: {
+ [t('networkName')]: pendingApproval.requestData.chainName,
+ [t('networkURL')]: pendingApproval.requestData.rpcUrl,
+ [t('chainId')]: parseInt(pendingApproval.requestData.chainId, 16),
+ [t('currencySymbol')]: pendingApproval.requestData.ticker,
+ [t('blockExplorerUrl')]: pendingApproval.requestData
+ .blockExplorerUrl,
+ },
+ prefaceKeys: [t('networkName'), t('networkURL'), t('chainId')],
+ },
+ },
+ ],
+ approvalText: t('approveButtonText'),
+ cancelText: t('cancel'),
+ onApprove: () =>
+ actions.resolvePendingApproval(
+ pendingApproval.id,
+ pendingApproval.requestData,
+ ),
+
+ onCancel: () =>
+ actions.rejectPendingApproval(
+ pendingApproval.id,
+ ethErrors.provider.userRejectedRequest(),
+ ),
+ };
+}
+
+const addEthereumChain = {
+ getAlerts,
+ getValues,
+};
+
+export default addEthereumChain;
diff --git a/ui/app/pages/confirmation/templates/index.js b/ui/app/pages/confirmation/templates/index.js
new file mode 100644
index 000000000..b00c1552b
--- /dev/null
+++ b/ui/app/pages/confirmation/templates/index.js
@@ -0,0 +1,127 @@
+import { omit, pick } from 'lodash';
+import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
+import {
+ rejectPendingApproval,
+ resolvePendingApproval,
+} from '../../../store/actions';
+import addEthereumChain from './add-ethereum-chain';
+import switchEthereumChain from './switch-ethereum-chain';
+
+const APPROVAL_TEMPLATES = {
+ [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN]: addEthereumChain,
+ [MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN]: switchEthereumChain,
+};
+
+const ALLOWED_TEMPLATE_KEYS = [
+ 'content',
+ 'approvalText',
+ 'cancelText',
+ 'onApprove',
+ 'onCancel',
+];
+
+/**
+ * @typedef {Object} PendingApproval
+ * @property {string} id - The randomly generated id of the approval
+ * @property {string} origin - The origin of the site requesting this approval
+ * @property {number} time - The time the approval was requested
+ * @property {string} type - The type of approval being requested
+ * @property {Object} requestData - The data submitted with the request
+ */
+
+/**
+ * getTemplateAlerts calls the getAlerts function exported by the template if
+ * it exists, and then returns the result of that function. In the confirmation
+ * page the alerts returned from the getAlerts method will be set into the
+ * alertState state object.
+ *
+ * @param {Object} pendingApproval - the object representing the confirmation
+ */
+export async function getTemplateAlerts(pendingApproval) {
+ const fn = APPROVAL_TEMPLATES[pendingApproval.type]?.getAlerts;
+ const results = fn ? await fn(pendingApproval) : undefined;
+ if (!Array.isArray(results)) {
+ throw new Error(`Template alerts must be an array, received: ${results}`);
+ }
+ if (results.some((result) => result?.id === undefined)) {
+ throw new Error(
+ `Template alert entries must be objects with an id key. Received: ${results}`,
+ );
+ }
+ return results;
+}
+
+/**
+ * The function call to return state must be a promise returning function
+ * this "NOOP" is here to conform to the requirements for templates without
+ * state.
+ */
+async function emptyState() {
+ return {};
+}
+
+/**
+ * getTemplateState calls the getState function exported by the template if
+ * it exists, and then returns the result of that function. In the confirmation
+ * page the object returned from the getState method will be set into the
+ * confirmationState state object. Note, this state is not consumed by the page
+ * itself.
+ * @param {Object} pendingApproval - the object representing the confirmation
+ */
+export async function getTemplateState(pendingApproval) {
+ const fn = APPROVAL_TEMPLATES[pendingApproval.type]?.getState ?? emptyState;
+ const result = await fn(pendingApproval);
+ if (typeof result !== 'object' || Array.isArray(result)) {
+ throw new Error(`Template state must be an object, received: ${result}`);
+ } else if (result === null || result === undefined) {
+ return {};
+ }
+ return result;
+}
+
+/**
+ * We do not want to pass the entire dispatch function to the template.
+ * This function should return an object of actions that we generally consider
+ * to be safe for templates to invoke. In the future we could put these behind
+ * permission sets so that snaps that wish to manipulate state must ask for
+ * explicit permission to do so.
+ * @param {Function} dispatch - Redux dispatch function
+ */
+function getAttenuatedDispatch(dispatch) {
+ return {
+ rejectPendingApproval: (...args) =>
+ dispatch(rejectPendingApproval(...args)),
+ resolvePendingApproval: (...args) =>
+ dispatch(resolvePendingApproval(...args)),
+ };
+}
+
+/**
+ * Returns the templated values to be consumed in the confirmation page
+ * @param {Object} pendingApproval - The pending confirmation object
+ * @param {Function} t - Translation function
+ * @param {Function} dispatch - Redux dispatch function
+ */
+export function getTemplateValues(pendingApproval, t, dispatch) {
+ const fn = APPROVAL_TEMPLATES[pendingApproval.type]?.getValues;
+ if (!fn) {
+ throw new Error(
+ `MESSAGE_TYPE: '${pendingApproval.type}' is not specified in approval templates`,
+ );
+ }
+
+ const safeActions = getAttenuatedDispatch(dispatch);
+ const values = fn(pendingApproval, t, safeActions);
+ const extraneousKeys = omit(values, ALLOWED_TEMPLATE_KEYS);
+ const safeValues = pick(values, ALLOWED_TEMPLATE_KEYS);
+ if (extraneousKeys.length > 0) {
+ throw new Error(
+ `Received extraneous keys from ${
+ pendingApproval.type
+ }.getValues. These keys are not passed to the confirmation page: ${Object.keys(
+ extraneousKeys,
+ )}`,
+ );
+ }
+ return safeValues;
+}
diff --git a/ui/app/pages/confirmation/templates/switch-ethereum-chain.js b/ui/app/pages/confirmation/templates/switch-ethereum-chain.js
new file mode 100644
index 000000000..4dc351027
--- /dev/null
+++ b/ui/app/pages/confirmation/templates/switch-ethereum-chain.js
@@ -0,0 +1,96 @@
+import { ethErrors } from 'eth-rpc-errors';
+import { NETWORK_TYPE_RPC } from '../../../../../shared/constants/network';
+import {
+ JUSTIFY_CONTENT,
+ SEVERITIES,
+ TYPOGRAPHY,
+} from '../../../helpers/constants/design-system';
+
+const PENDING_TX_DROP_NOTICE = {
+ id: 'PENDING_TX_DROP_NOTICE',
+ severity: SEVERITIES.WARNING,
+ content: {
+ element: 'span',
+ children: {
+ element: 'MetaMaskTranslation',
+ props: {
+ translationKey: 'switchingNetworksCancelsPendingConfirmations',
+ },
+ },
+ },
+};
+
+async function getAlerts() {
+ return [PENDING_TX_DROP_NOTICE];
+}
+
+function getValues(pendingApproval, t, actions) {
+ return {
+ content: [
+ {
+ element: 'Typography',
+ key: 'title',
+ children: t('switchEthereumChainConfirmationTitle'),
+ props: {
+ variant: TYPOGRAPHY.H3,
+ align: 'center',
+ fontWeight: 'bold',
+ boxProps: {
+ margin: [0, 0, 4],
+ },
+ },
+ },
+ {
+ element: 'Typography',
+ key: 'description',
+ children: t('switchEthereumChainConfirmationDescription'),
+ props: {
+ variant: TYPOGRAPHY.H7,
+ align: 'center',
+ boxProps: {
+ margin: [0, 0, 4],
+ },
+ },
+ },
+ {
+ element: 'Box',
+ key: 'status-box',
+ props: {
+ justifyContent: JUSTIFY_CONTENT.CENTER,
+ },
+ children: {
+ element: 'NetworkDisplay',
+ key: 'network-being-switched',
+ props: {
+ colored: false,
+ outline: true,
+ targetNetwork: {
+ type: NETWORK_TYPE_RPC,
+ nickname: pendingApproval.requestData.nickname,
+ },
+ },
+ },
+ },
+ ],
+ approvalText: t('switchNetwork'),
+ cancelText: t('cancel'),
+ onApprove: () =>
+ actions.resolvePendingApproval(
+ pendingApproval.id,
+ pendingApproval.requestData,
+ ),
+
+ onCancel: () =>
+ actions.rejectPendingApproval(
+ pendingApproval.id,
+ ethErrors.provider.userRejectedRequest(),
+ ),
+ };
+}
+
+const switchEthereumChain = {
+ getAlerts,
+ getValues,
+};
+
+export default switchEthereumChain;
diff --git a/ui/app/pages/create-account/connect-hardware/account-list.js b/ui/app/pages/create-account/connect-hardware/account-list.js
index df1ec8cdd..53b9d8f7b 100644
--- a/ui/app/pages/create-account/connect-hardware/account-list.js
+++ b/ui/app/pages/create-account/connect-hardware/account-list.js
@@ -1,19 +1,22 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
-import Select from 'react-select';
import getAccountLink from '../../../../lib/account-link';
import Button from '../../../components/ui/button';
+import Dropdown from '../../../components/ui/dropdown';
class AccountList extends Component {
getHdPaths() {
+ const ledgerLiveKey = `m/44'/60'/0'/0/0`;
+ const mewKey = `m/44'/60'/0'`;
+
return [
{
- label: `Ledger Live`,
- value: `m/44'/60'/0'/0/0`,
+ name: `Ledger Live`,
+ value: ledgerLiveKey,
},
{
- label: `Legacy (MEW / MyCrypto)`,
- value: `m/44'/60'/0'`,
+ name: `Legacy (MEW / MyCrypto)`,
+ value: mewKey,
},
];
}
@@ -33,8 +36,8 @@ class AccountList extends Component {
renderHdPathSelector() {
const { onPathChange, selectedPath } = this.props;
-
const options = this.getHdPaths();
+
return (
@@ -42,14 +45,12 @@ class AccountList extends Component {
{this.context.t('selectPathHelp')}
- {
- onPathChange(opt.value);
+ selectedOption={selectedPath}
+ onChange={(value) => {
+ onPathChange(value);
}}
/>
diff --git a/ui/app/pages/create-account/import-account/index.js b/ui/app/pages/create-account/import-account/index.js
index 8068773c3..9c2a38b44 100644
--- a/ui/app/pages/create-account/import-account/index.js
+++ b/ui/app/pages/create-account/import-account/index.js
@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
-import Select from 'react-select';
+
+import Dropdown from '../../../components/ui/dropdown';
// Subviews
import JsonImportView from './json';
@@ -59,19 +60,12 @@ export default class AccountImportSubview extends Component {
{this.context.t('selectType')}
-
{
- return {
- value: text,
- label: text,
- };
- })}
- onChange={(opt) => {
- this.setState({ type: opt.value });
+ options={menuItems.map((text) => ({ value: text }))}
+ selectedOption={type || menuItems[0]}
+ onChange={(value) => {
+ this.setState({ type: value });
}}
/>
diff --git a/ui/app/pages/create-account/import-account/index.scss b/ui/app/pages/create-account/import-account/index.scss
index 6749e0e76..11056d25d 100644
--- a/ui/app/pages/create-account/import-account/index.scss
+++ b/ui/app/pages/create-account/import-account/index.scss
@@ -29,25 +29,9 @@
}
&__select {
- height: 54px;
width: 210px;
- border: 1px solid #d2d8dd;
- border-radius: 4px;
- background-color: #fff;
display: flex;
align-items: center;
-
- .Select-control,
- .Select-control:hover {
- height: 100%;
- border: none;
- box-shadow: none;
-
- .Select-value {
- display: flex;
- align-items: center;
- }
- }
}
&__private-key-password-container {
diff --git a/ui/app/pages/home/home.component.js b/ui/app/pages/home/home.component.js
index d5b5f75d7..3f9c14caa 100644
--- a/ui/app/pages/home/home.component.js
+++ b/ui/app/pages/home/home.component.js
@@ -27,6 +27,7 @@ import {
AWAITING_SWAP_ROUTE,
BUILD_QUOTE_ROUTE,
VIEW_QUOTE_ROUTE,
+ CONFIRMATION_V_NEXT_ROUTE,
} from '../../helpers/constants/routes';
const LEARN_MORE_URL =
@@ -72,6 +73,7 @@ export default class Home extends PureComponent {
setWeb3ShimUsageAlertDismissed: PropTypes.func.isRequired,
originOfCurrentTab: PropTypes.string,
disableWeb3ShimUsageAlert: PropTypes.func.isRequired,
+ pendingApprovals: PropTypes.arrayOf(PropTypes.object).isRequired,
};
state = {
@@ -89,6 +91,7 @@ export default class Home extends PureComponent {
haveSwapsQuotes,
showAwaitingSwapScreen,
swapsFetchParams,
+ pendingApprovals,
} = this.props;
this.setState({ mounted: true });
@@ -106,6 +109,8 @@ export default class Home extends PureComponent {
history.push(CONFIRM_TRANSACTION_ROUTE);
} else if (Object.keys(suggestedTokens).length > 0) {
history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE);
+ } else if (pendingApprovals.length > 0) {
+ history.push(CONFIRMATION_V_NEXT_ROUTE);
}
}
diff --git a/ui/app/pages/home/home.container.js b/ui/app/pages/home/home.container.js
index b53a5a96d..3a9422486 100644
--- a/ui/app/pages/home/home.container.js
+++ b/ui/app/pages/home/home.container.js
@@ -52,6 +52,7 @@ const mapStateToProps = (state) => {
connectedStatusPopoverHasBeenShown,
defaultHomeActiveTabName,
swapsState,
+ pendingApprovals = {},
} = metamask;
const accountBalance = getCurrentEthBalance(state);
const { forgottenPassword, threeBoxLastUpdated } = appState;
@@ -101,6 +102,7 @@ const mapStateToProps = (state) => {
isMainnet: getIsMainnet(state),
originOfCurrentTab,
shouldShowWeb3ShimUsageNotification,
+ pendingApprovals: Object.values(pendingApprovals),
};
};
diff --git a/ui/app/pages/pages.scss b/ui/app/pages/pages.scss
index adb813e7f..09c1c58bc 100644
--- a/ui/app/pages/pages.scss
+++ b/ui/app/pages/pages.scss
@@ -5,6 +5,7 @@
@import 'confirm-approve/index';
@import 'confirm-decrypt-message/confirm-decrypt-message';
@import 'confirm-encryption-public-key/confirm-encryption-public-key';
+@import 'confirmation/confirmation';
@import 'connected-sites/index';
@import 'connected-accounts/index';
@import 'connected-sites/index';
diff --git a/ui/app/pages/routes/routes.component.js b/ui/app/pages/routes/routes.component.js
index c563d2b03..1f5e0eef3 100644
--- a/ui/app/pages/routes/routes.component.js
+++ b/ui/app/pages/routes/routes.component.js
@@ -53,6 +53,7 @@ import {
SETTINGS_ROUTE,
UNLOCK_ROUTE,
BUILD_QUOTE_ROUTE,
+ CONFIRMATION_V_NEXT_ROUTE,
} from '../../helpers/constants/routes';
import {
@@ -61,6 +62,7 @@ import {
} from '../../../../shared/constants/app';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { TRANSACTION_STATUSES } from '../../../../shared/constants/transaction';
+import ConfirmationPage from '../confirmation';
export default class Routes extends Component {
static propTypes = {
@@ -158,6 +160,10 @@ export default class Routes extends Component {
component={ConfirmAddSuggestedTokenPage}
exact
/>
+
- {name || ellipsify(address)}
+ {name ? : ellipsify(address)}
{name && (
@@ -211,14 +213,13 @@ export default class AddRecipient extends Component {
}
renderDialogs() {
- const { toError, ensResolutionError, ensResolution } = this.props;
+ const {
+ toError,
+ toWarning,
+ ensResolutionError,
+ ensResolution,
+ } = this.props;
const { t } = this.context;
- const contacts = this.searchForContacts();
- const recents = this.searchForRecents();
-
- if (contacts.length || recents.length) {
- return null;
- }
if (ensResolutionError) {
return (
@@ -226,14 +227,18 @@ export default class AddRecipient extends Component {
{ensResolutionError}
);
- }
-
- if (toError && toError !== 'required' && !ensResolution) {
+ } else if (toError && toError !== 'required' && !ensResolution) {
return (
{t(toError)}
);
+ } else if (toWarning) {
+ return (
+
+ {t(toWarning)}
+
+ );
}
return null;
diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.js
index 9a423e974..195da89cb 100644
--- a/ui/app/pages/send/send-content/add-recipient/add-recipient.js
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.js
@@ -1,16 +1,19 @@
import ethUtil from 'ethereumjs-util';
import contractMap from '@metamask/contract-metadata';
+import { isConfusing } from 'unicode-confusables';
import {
REQUIRED_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
KNOWN_RECIPIENT_ADDRESS_ERROR,
INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR,
+ CONFUSING_ENS_ERROR,
} from '../../send.constants';
import {
isValidAddress,
isEthNetwork,
checkExistingAddresses,
+ isValidDomainName,
} from '../../../../helpers/utils/util';
export function getToErrorObject(to, hasHexData = false, network) {
@@ -36,6 +39,9 @@ export function getToWarningObject(to, tokens = [], sendToken = null) {
checkExistingAddresses(to, tokens))
) {
toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR;
+ } else if (isValidDomainName(to) && isConfusing(to)) {
+ toWarning = CONFUSING_ENS_ERROR;
}
+
return { to: toWarning };
}
diff --git a/ui/app/pages/send/send-content/add-recipient/ens-input.container.js b/ui/app/pages/send/send-content/add-recipient/ens-input.container.js
index f8ad890bc..42e75e501 100644
--- a/ui/app/pages/send/send-content/add-recipient/ens-input.container.js
+++ b/ui/app/pages/send/send-content/add-recipient/ens-input.container.js
@@ -1,16 +1,18 @@
import { connect } from 'react-redux';
+import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../../shared/constants/network';
import {
- getCurrentNetwork,
getSendTo,
getSendToNickname,
getAddressBookEntry,
+ getCurrentChainId,
} from '../../../../selectors';
import EnsInput from './ens-input.component';
export default connect((state) => {
const selectedAddress = getSendTo(state);
+ const chainId = getCurrentChainId(state);
return {
- network: getCurrentNetwork(state),
+ network: CHAIN_ID_TO_NETWORK_ID_MAP[chainId],
selectedAddress,
selectedName: getSendToNickname(state),
contact: getAddressBookEntry(state, selectedAddress),
diff --git a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js
index 7736969e9..55f193590 100644
--- a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js
@@ -210,20 +210,5 @@ describe('AddRecipient Component', function () {
assert.strictEqual(dialog.length, 0);
});
-
- it('should not render error when query has results', function () {
- wrapper.setProps({
- addressBook: [
- { address: '0x125', name: 'alice' },
- { address: '0x126', name: 'alex' },
- { address: '0x127', name: 'catherine' },
- ],
- toError: 'bad',
- });
-
- const dialog = wrapper.find(Dialog);
-
- assert.strictEqual(dialog.length, 0);
- });
});
});
diff --git a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
index 9d4947107..cdf1a0c36 100644
--- a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
@@ -6,6 +6,7 @@ import {
REQUIRED_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
KNOWN_RECIPIENT_ADDRESS_ERROR,
+ CONFUSING_ENS_ERROR,
} from '../../../send.constants';
const stubs = {
@@ -93,5 +94,17 @@ describe('add-recipient utils', function () {
},
);
});
+
+ it('should warn if name is a valid domain and confusable', function () {
+ assert.deepEqual(getToWarningObject('vitalik.eth'), {
+ to: CONFUSING_ENS_ERROR,
+ });
+ });
+
+ it('should not warn if name is a valid domain and not confusable', function () {
+ assert.deepEqual(getToWarningObject('vitalik.eth'), {
+ to: null,
+ });
+ });
});
});
diff --git a/ui/app/pages/send/send-content/send-asset-row/send-asset-row.component.js b/ui/app/pages/send/send-content/send-asset-row/send-asset-row.component.js
index 870ff79e8..589eb048e 100644
--- a/ui/app/pages/send/send-content/send-asset-row/send-asset-row.component.js
+++ b/ui/app/pages/send/send-content/send-asset-row/send-asset-row.component.js
@@ -4,7 +4,7 @@ import SendRowWrapper from '../send-row-wrapper';
import Identicon from '../../../../components/ui/identicon/identicon.component';
import TokenBalance from '../../../../components/ui/token-balance';
import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display';
-import { PRIMARY } from '../../../../helpers/constants/common';
+import { ERC20, ETH, PRIMARY } from '../../../../helpers/constants/common';
export default class SendAssetRow extends Component {
static propTypes = {
@@ -19,6 +19,7 @@ export default class SendAssetRow extends Component {
selectedAddress: PropTypes.string.isRequired,
sendTokenAddress: PropTypes.string,
setSendToken: PropTypes.func.isRequired,
+ nativeCurrency: PropTypes.string,
};
static contextTypes = {
@@ -47,7 +48,7 @@ export default class SendAssetRow extends Component {
name: 'User clicks "Assets" dropdown',
},
customVariables: {
- assetSelected: token ? 'ERC20' : 'ETH',
+ assetSelected: token ? ERC20 : this.props.nativeCurrency,
},
});
this.props.setSendToken(token);
@@ -78,7 +79,7 @@ export default class SendAssetRow extends Component {
className="send-v2__asset-dropdown__input-wrapper"
onClick={this.openDropdown}
>
- {token ? this.renderAsset(token) : this.renderEth()}
+ {token ? this.renderAsset(token) : this.renderNativeCurrency()}
);
}
@@ -92,7 +93,7 @@ export default class SendAssetRow extends Component {
onClick={this.closeDropdown}
/>
- {this.renderEth(true)}
+ {this.renderNativeCurrency(true)}
{this.props.tokens.map((token) => this.renderAsset(token, true))}
@@ -100,9 +101,9 @@ export default class SendAssetRow extends Component {
);
}
- renderEth(insideDropdown = false) {
+ renderNativeCurrency(insideDropdown = false) {
const { t } = this.context;
- const { accounts, selectedAddress } = this.props;
+ const { accounts, selectedAddress, nativeCurrency } = this.props;
const balanceValue = accounts[selectedAddress]
? accounts[selectedAddress].balance
@@ -118,10 +119,15 @@ export default class SendAssetRow extends Component {
onClick={() => this.selectToken()}
>
-
+
-
ETH
+
+ {nativeCurrency}
+
{`${t('balance')}:`}
@@ -133,7 +139,7 @@ export default class SendAssetRow extends Component {
{!insideDropdown && this.props.tokens.length > 0 && (
-
+
)}
);
@@ -162,7 +168,7 @@ export default class SendAssetRow extends Component {
{!insideDropdown && (
-