1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Add Custom Network UI (#10310)

This commit is contained in:
Brad Decker 2021-02-22 10:20:42 -06:00 committed by GitHub
parent f196c9feb8
commit aabe653240
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1042 additions and 28 deletions

View File

@ -52,6 +52,23 @@
"addAlias": {
"message": "Add alias"
},
"addEthereumChainConfirmationDescription": {
"message": "This will allow this network to be used within MetaMask."
},
"addEthereumChainConfirmationRisks": {
"message": "MetaMask does not verify custom networks."
},
"addEthereumChainConfirmationRisksLearnMore": {
"message": "Learn about $1.",
"description": "$1 is a link with text that is provided by the 'addEthereumChainConfirmationRisksLearnMoreLink' key"
},
"addEthereumChainConfirmationRisksLearnMoreLink": {
"message": "scams and network security risks",
"description": "Link text for the 'addEthereumChainConfirmationRisksLearnMore' translation key"
},
"addEthereumChainConfirmationTitle": {
"message": "Allow this site to add a network?"
},
"addNetwork": {
"message": "Add Network"
},
@ -150,6 +167,9 @@
"approve": {
"message": "Approve spend limit"
},
"approveButtonText": {
"message": "Approve"
},
"approveSpendLimit": {
"message": "Approve $1 spend limit",
"description": "The token symbol that is being approved"
@ -212,7 +232,10 @@
"message": "Basic"
},
"blockExplorerUrl": {
"message": "Block Explorer"
"message": "Block Explorer URL"
},
"blockExplorerUrlDefinition": {
"message": "The URL used as the block explorer for this network."
},
"blockExplorerView": {
"message": "View account at $1",
@ -254,6 +277,9 @@
"chainId": {
"message": "Chain ID"
},
"chainIdDefinition": {
"message": "The chain ID used to sign transactions for this network."
},
"chromeRequiredForHardwareWallets": {
"message": "You need to use MetaMask on Google Chrome in order to connect to your Hardware Wallet."
},
@ -409,6 +435,12 @@
"currencyConversion": {
"message": "Currency Conversion"
},
"currencySymbol": {
"message": "Currency Symbol"
},
"currencySymbolDefinition": {
"message": "The ticker symbol displayed for this networks currency."
},
"currentAccountNotConnected": {
"message": "Your current account is not connected"
},
@ -1005,6 +1037,14 @@
"metametricsOptInDescription": {
"message": "MetaMask would like to gather usage data to better understand how our users interact with the extension. This data will be used to continually improve the usability and user experience of our product and the Ethereum ecosystem."
},
"mismatchedChain": {
"message": "This network details for this Chain ID do not match our records. We recommend that you $1 before proceeding.",
"description": "$1 is a clickable link with text defined by the 'mismatchedChainLinkText' key"
},
"mismatchedChainLinkText": {
"message": "verify the network details",
"description": "Serves as link text for the 'mismatchedChain' key. This text will be embedded inside the translation for that key."
},
"mobileSyncText": {
"message": "Please enter your password to confirm it's you!"
},
@ -1030,15 +1070,27 @@
"negativeETH": {
"message": "Can not send negative amounts of ETH."
},
"networkDetails": {
"message": "Network Details"
},
"networkName": {
"message": "Network Name"
},
"networkNameDefinition": {
"message": "The name associated with this network."
},
"networkSettingsChainIdDescription": {
"message": "The chain ID is used for signing transactions. It must match the chain ID returned by the network. You can enter a decimal or '0x'-prefixed hexadecimal number, but we will display the number in decimal."
},
"networkSettingsDescription": {
"message": "Add and edit custom RPC networks"
},
"networkURL": {
"message": "Network URL"
},
"networkURLDefinition": {
"message": "The URL used to access this network."
},
"networks": {
"message": "Networks"
},
@ -1892,12 +1944,24 @@
"swapsViewInActivity": {
"message": "View in activity"
},
"switchEthereumChainConfirmationDescription": {
"message": "This will will switch the selected network within MetaMask to a previously added network:"
},
"switchEthereumChainConfirmationTitle": {
"message": "Allow this site to switch the network?"
},
"switchNetwork": {
"message": "Switch network"
},
"switchNetworks": {
"message": "Switch Networks"
},
"switchToThisAccount": {
"message": "Switch to this account"
},
"switchingNetworksCancelsPendingConfirmations": {
"message": "Switching networks will cancel all pending confirmations"
},
"symbol": {
"message": "Symbol"
},
@ -2073,6 +2137,14 @@
"unlockMessage": {
"message": "The decentralized web awaits"
},
"unrecognizedChain": {
"message": "This custom network is not recognized. We recommend that you $1 before proceeding",
"description": "$1 is a clickable link with text defined by the 'unrecognizedChanLinkText' key. The link will open to instructions for users to validate custom network details."
},
"unrecognizedChainLinkText": {
"message": "verify the network details",
"description": "Serves as link text for the 'unrecognizedChain' key. This text will be embedded inside the translation for that key."
},
"updatedWithDate": {
"message": "Updated $1"
},
@ -2145,6 +2217,10 @@
"message": "$1 of $2",
"description": "$1 and $2 are intended to be two numbers, where $2 is a total, and $1 is a count towards that total"
},
"xOfYPending": {
"message": "$1 of $2 pending",
"description": "$1 and $2 are intended to be two numbers, where $2 is a total number of pending confirmations, and $1 is a count towards that total"
},
"yesLetsTry": {
"message": "Yes, let's try"
},

View File

@ -172,6 +172,7 @@ async function verifyEnglishLocale() {
// and gradually phase out the key based search
const globsToStrictSearch = [
'ui/app/components/app/metamask-translation/*.js',
'ui/app/pages/confirmation/templates/*.js',
];
const testGlob = '**/*.test.js';
const javascriptFiles = await glob(['ui/**/*.js', 'shared/**/*.js'], {

View File

@ -135,6 +135,7 @@
"fuse.js": "^3.2.0",
"globalthis": "^1.0.1",
"human-standard-token-abi": "^2.0.0",
"immer": "^8.0.1",
"json-rpc-engine": "^6.1.0",
"json-rpc-middleware-stream": "^2.1.1",
"jsonschema": "^1.2.4",

View File

@ -6,9 +6,11 @@ import Popover from '../../ui/popover';
import Typography from '../../ui/typography';
import Box from '../../ui/box';
import MetaMaskTranslation from '../metamask-translation';
import NetworkDisplay from '../network-display';
export const safeComponentList = {
MetaMaskTranslation,
a: 'a',
b: 'b',
p: 'p',
div: 'div',
@ -20,4 +22,5 @@ export const safeComponentList = {
Button,
Popover,
Box,
NetworkDisplay,
};

View File

@ -70,11 +70,8 @@ describe('Account Details Modal', function () {
wrapper.setProps({ rpcPrefs: { blockExplorerUrl } });
const modalButton = wrapper.find('.account-details-modal__button');
const blockExplorerLink = modalButton.first();
const blockExplorerLink = modalButton.first().shallow();
assert.strictEqual(
blockExplorerLink.html(),
'<button class="button btn-secondary account-details-modal__button">blockExplorerView</button>',
);
assert.strictEqual(blockExplorerLink.text(), 'blockExplorerView');
});
});

View File

@ -2,7 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useSelector } from 'react-redux';
import { NETWORK_TYPE_RPC } from '../../../../../shared/constants/network';
import {
NETWORK_TYPE_RPC,
NETWORK_TYPE_TO_ID_MAP,
} from '../../../../../shared/constants/network';
import LoadingIndicator from '../../ui/loading-indicator';
import ColorIndicator from '../../ui/color-indicator';
@ -21,15 +24,19 @@ export default function NetworkDisplay({
indicatorSize,
disabled,
labelProps,
targetNetwork,
onClick,
}) {
const { network, networkNickname, networkType } = useSelector((state) => ({
const currentNetwork = useSelector((state) => ({
network: state.metamask.network,
networkNickname: state.metamask.provider.nickname,
networkType: state.metamask.provider.type,
nickname: state.metamask.provider.nickname,
type: state.metamask.provider.type,
}));
const t = useI18nContext();
const { network = '', nickname: networkNickname, type: networkType } =
targetNetwork ?? currentNetwork;
return (
<Chip
borderColor={outline ? COLORS.UI3 : COLORS.TRANSPARENT}
@ -45,7 +52,9 @@ export default function NetworkDisplay({
size={indicatorSize}
type={ColorIndicator.TYPES.FILLED}
iconClassName={
networkType === NETWORK_TYPE_RPC ? 'fa fa-question' : undefined
networkType === NETWORK_TYPE_RPC && indicatorSize !== SIZES.XS
? 'fa fa-question'
: undefined
}
/>
</LoadingIndicator>
@ -75,8 +84,13 @@ export default function NetworkDisplay({
NetworkDisplay.propTypes = {
colored: PropTypes.bool,
indicatorSize: PropTypes.oneOf(Object.values(SIZES)),
labelProps: PropTypes.shape({
...Chip.propTypes.labelProps,
labelProps: Chip.propTypes.labelProps,
targetNetwork: PropTypes.shape({
type: PropTypes.oneOf([
...Object.values(NETWORK_TYPE_TO_ID_MAP),
NETWORK_TYPE_RPC,
]),
nickname: PropTypes.string,
}),
outline: PropTypes.bool,
disabled: PropTypes.bool,

View File

@ -166,7 +166,7 @@ export default class SignatureRequestOriginal extends Component {
{originMetadata?.icon ? (
<SiteIcon
icon={originMetadata.icon}
name={originMetadata.name}
name={originMetadata.hostname}
size={24}
/>
) : null}

View File

@ -45,6 +45,15 @@ const Button = ({
} else if (submit) {
buttonProps.type = 'submit';
}
if (typeof buttonProps.onClick === 'function') {
buttonProps.onKeyUp ??= (event) => {
if (event.key === 'Enter') {
buttonProps.onClick();
}
};
buttonProps.role ??= 'button';
buttonProps.tabIndex ??= 0;
}
return (
<Tag
className={classnames(

View File

@ -9,6 +9,10 @@
grid-template-rows: 1fr;
transition: opacity 0.75s 0s;
a {
color: $primary-1;
}
&--dismissible {
&#{$self}--first {
box-shadow: 0 -5px 5px -5px rgba(0, 0, 0, 0.18);

View File

@ -21,6 +21,8 @@ export default function Chip({
}
};
const isInteractive = typeof onClick === 'function';
return (
<div
onClick={onClick}
@ -30,8 +32,8 @@ export default function Chip({
'chip--with-right-icon': Boolean(rightIcon),
[`chip--${borderColor}`]: true,
})}
role="button"
tabIndex={0}
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
>
{leftIcon && <div className="chip__left-icon">{leftIcon}</div>}
{children ?? (

View File

@ -14,7 +14,7 @@ export default class Tooltip extends PureComponent {
offset: 0,
size: 'small',
title: null,
trigger: 'mouseenter',
trigger: 'mouseenter focus',
wrapperClassName: undefined,
theme: '',
};
@ -35,6 +35,7 @@ export default class Tooltip extends PureComponent {
wrapperClassName: PropTypes.string,
style: PropTypes.object,
theme: PropTypes.string,
tabIndex: PropTypes.number,
};
render() {
@ -54,6 +55,7 @@ export default class Tooltip extends PureComponent {
wrapperClassName,
style,
theme,
tabIndex,
} = this.props;
if (!title && !html) {
@ -77,6 +79,7 @@ export default class Tooltip extends PureComponent {
title={title}
trigger={trigger}
theme={theme}
tabIndex={tabIndex || 0}
>
{children}
</ReactTippy>

View File

@ -64,6 +64,7 @@ const CONFIRM_TOKEN_METHOD_PATH = '/token-method';
const SIGNATURE_REQUEST_PATH = '/signature-request';
const DECRYPT_MESSAGE_REQUEST_PATH = '/decrypt-message-request';
const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request';
const CONFIRMATION_V_NEXT_ROUTE = '/confirmation';
// Used to pull a convenient name for analytics tracking events. The key must
// be react-router ready path, and can include params such as :id for popup windows
@ -170,6 +171,7 @@ export {
SIGNATURE_REQUEST_PATH,
DECRYPT_MESSAGE_REQUEST_PATH,
ENCRYPTION_PUBLIC_KEY_REQUEST_PATH,
CONFIRMATION_V_NEXT_ROUTE,
INITIALIZE_METAMETRICS_OPT_IN_ROUTE,
ADVANCED_ROUTE,
SECURITY_ROUTE,

View File

@ -350,6 +350,17 @@ export function stripHttpSchemes(urlString) {
return urlString.replace(/^https?:\/\//u, '');
}
/**
* Strips the following schemes from URL strings:
* - https
*
* @param {string} urlString - The URL string to strip the scheme from.
* @returns {string} The URL string, without the scheme, if it was stripped.
*/
export function stripHttpsScheme(urlString) {
return urlString.replace(/^https:\/\//u, '');
}
/**
* Checks whether a URL-like value (object or string) is an extension URL.
*

View File

@ -0,0 +1,42 @@
import { useSelector } from 'react-redux';
import { getDomainMetadata } from '../selectors';
/**
* @typedef {Object} OriginMetadata
* @property {string} host - The host of the origin
* @property {string} hostname - The hostname of the origin (host + port)
* @property {string} origin - The original origin string itself
* @property {string} [icon] - The origin's site icon if available
* @property {number} [lastUpdated] - Timestamp of the last update to the
* origin's metadata
* @property {string} [name] - The registered name of the origin if available
*/
/**
* Gets origin metadata from redux and formats it appropriately.
* @param {string} origin - The fully formed url of the site interacting with
* MetaMask
* @returns {OriginMetadata | null} - The origin metadata available for the
* current origin
*/
export function useOriginMetadata(origin) {
const domainMetaData = useSelector(getDomainMetadata);
if (!origin) {
return null;
}
const url = new URL(origin);
const minimumOriginMetadata = {
host: url.host,
hostname: url.hostname,
origin,
};
if (domainMetaData?.[origin]) {
return {
...minimumOriginMetadata,
...domainMetaData[origin],
};
}
return minimumOriginMetadata;
}

View File

@ -179,7 +179,7 @@ export default class ConfirmDecryptMessage extends Component {
const { t } = this.context;
const originMetadata = domainMetadata[txData.msgParams.origin];
const name = originMetadata?.name || txData.msgParams.origin;
const name = originMetadata?.hostname || txData.msgParams.origin;
const notice = t('decryptMessageNotice', [txData.msgParams.origin]);
const {

View File

@ -160,7 +160,7 @@ export default class ConfirmEncryptionPublicKey extends Component {
const originMetadata = domainMetadata[txData.origin];
const notice = t('encryptionPublicKeyNotice', [txData.origin]);
const name = originMetadata?.name || txData.origin;
const name = originMetadata?.hostname || txData.origin;
return (
<div className="request-encryption-public-key__body">

View File

@ -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 (
<div className="confirmation-footer">
{alerts}
<div className="confirmation-footer__actions">
<Button rounded type="secondary" onClick={onCancel}>
{cancelText}
</Button>
<Button rounded type="primary" onClick={onApprove}>
{approveText}
</Button>
</div>
</div>
);
}
ConfirmationFooter.propTypes = {
alerts: PropTypes.node,
onApprove: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
approveText: PropTypes.string.isRequired,
cancelText: PropTypes.string.isRequired,
};

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
export { default } from './confirmation-footer';

View File

@ -0,0 +1,240 @@
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 { getEnvironmentType } from '../../../../app/scripts/lib/util';
import {
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_POPUP,
} from '../../../../shared/constants/app';
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(() => {
const environmentType = getEnvironmentType();
// If the number of pending confirmations reduces to zero when the user
// is in the fullscreen or popup UI, 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 &&
(environmentType === ENVIRONMENT_TYPE_FULLSCREEN ||
environmentType === ENVIRONMENT_TYPE_POPUP)
) {
history.push(DEFAULT_ROUTE);
} else if (pendingConfirmations.length <= currentPendingConfirmation) {
setCurrentPendingConfirmation(pendingConfirmations.length - 1);
}
}, [pendingConfirmations, history, currentPendingConfirmation]);
if (!pendingConfirmation) {
return null;
}
return (
<div className="confirmation-page">
{pendingConfirmations.length > 1 && (
<div className="confirmation-page__navigation">
<p>
{t('xOfYPending', [
currentPendingConfirmation + 1,
pendingConfirmations.length,
])}
</p>
{currentPendingConfirmation > 0 && (
<button
className="confirmation-page__navigation-button"
onClick={() =>
setCurrentPendingConfirmation(currentPendingConfirmation - 1)
}
>
<i className="fas fa-chevron-left"></i>
</button>
)}
<button
className="confirmation-page__navigation-button"
disabled={
currentPendingConfirmation + 1 === pendingConfirmations.length
}
onClick={() =>
setCurrentPendingConfirmation(currentPendingConfirmation + 1)
}
>
<i className="fas fa-chevron-right"></i>
</button>
</div>
)}
<div className="confirmation-page__content">
<Box justifyContent="center">
<NetworkDisplay
colored={false}
indicatorSize={SIZES.XS}
labelProps={{ color: COLORS.BLACK }}
/>
</Box>
<Box justifyContent="center" padding={[1, 4, 4]}>
<Chip
label={stripHttpsScheme(originMetadata.origin)}
leftIcon={
<SiteIcon
icon={originMetadata.icon}
name={originMetadata.hostname}
size={32}
/>
}
/>
</Box>
<MetaMaskTemplateRenderer sections={templatedValues.content} />
</div>
<ConfirmationFooter
alerts={
alertState[pendingConfirmation.id] &&
Object.values(alertState[pendingConfirmation.id])
.filter((alert) => alert.dismissed === false)
.map((alert, idx, filtered) => (
<Callout
key={alert.id}
severity={alert.severity}
dismiss={() => dismissAlert(alert.id)}
isFirst={idx === 0}
isLast={idx === filtered.length - 1}
isMultiple={filtered.length > 1}
>
<MetaMaskTemplateRenderer sections={alert.content} />
</Callout>
))
}
onApprove={templatedValues.onApprove}
onCancel={templatedValues.onCancel}
approveText={templatedValues.approvalText}
cancelText={templatedValues.cancelText}
/>
</div>
);
}

View File

@ -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;
}
}

View File

@ -0,0 +1 @@
export { default } from './confirmation';

View File

@ -0,0 +1,216 @@
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/360056196151',
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/360056196151',
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: '#',
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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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),
};
};

View File

@ -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';

View File

@ -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
/>
<Authenticated
path={CONFIRMATION_V_NEXT_ROUTE}
component={ConfirmationPage}
/>
<Authenticated path={NEW_ACCOUNT_ROUTE} component={CreateAccountPage} />
<Authenticated
path={`${CONNECT_ROUTE}/:id`}
@ -244,7 +250,14 @@ export default class Routes extends Component {
}),
);
return isHandlingPermissionsRequest;
const isHandlingAddEthereumChainRequest = Boolean(
matchPath(location.pathname, {
path: CONFIRMATION_V_NEXT_ROUTE,
exact: false,
}),
);
return isHandlingPermissionsRequest || isHandlingAddEthereumChainRequest;
}
render() {

View File

@ -282,11 +282,6 @@ export function getPermissionsRequests(state) {
return state.metamask.permissionsRequests || [];
}
export function getPermissionsRequestCount(state) {
const permissionsRequests = getPermissionsRequests(state);
return permissionsRequests.length;
}
export function getFirstPermissionRequest(state) {
const requests = getPermissionsRequests(state);
return requests && requests[0] ? requests[0] : null;

View File

@ -11,7 +11,6 @@ import {
checksumAddress,
getAccountByAddress,
} from '../helpers/utils/util';
import { getPermissionsRequestCount } from './permissions';
export function getNetworkIdentifier(state) {
const {
@ -259,6 +258,7 @@ export function getTotalUnapprovedCount(state) {
unapprovedDecryptMsgCount = 0,
unapprovedEncryptionPublicKeyMsgCount = 0,
unapprovedTypedMessagesCount = 0,
pendingApprovalCount = 0,
} = state.metamask;
return (
@ -268,7 +268,7 @@ export function getTotalUnapprovedCount(state) {
unapprovedEncryptionPublicKeyMsgCount +
unapprovedTypedMessagesCount +
getUnapprovedTxCount(state) +
getPermissionsRequestCount(state) +
pendingApprovalCount +
getSuggestedTokenCount(state)
);
}
@ -278,6 +278,11 @@ function getUnapprovedTxCount(state) {
return Object.keys(unapprovedTxs).length;
}
export function getUnapprovedConfirmations(state) {
const { pendingApprovals } = state.metamask;
return Object.values(pendingApprovals);
}
function getSuggestedTokenCount(state) {
const { suggestedTokens = {} } = state.metamask;
return Object.keys(suggestedTokens).length;

View File

@ -2463,6 +2463,44 @@ export function clearPermissions() {
};
}
// Pending Approvals
/**
* Resolves a pending approval and closes the current notification window if no
* further approvals are pending after the background state updates.
* @param {string} id - The pending approval id
* @param {any} [value] - The value required to confirm a pending approval
*/
export function resolvePendingApproval(id, value) {
return async (dispatch) => {
await promisifiedBackground.resolvePendingApproval(id, value);
// Before closing the current window, check if any additional confirmations
// are added as a result of this confirmation being accepted
const { pendingApprovals } = await promisifiedBackground.getState();
if (Object.values(pendingApprovals).length === 0) {
dispatch(closeCurrentNotificationWindow());
}
};
}
/**
* Rejects a pending approval and closes the current notification window if no
* further approvals are pending after the background state updates.
* @param {string} id - The pending approval id
* @param {Error} [error] - The error to throw when rejecting the approval
*/
export function rejectPendingApproval(id, error) {
return async (dispatch) => {
await promisifiedBackground.rejectPendingApproval(id, error);
// Before closing the current window, check if any additional confirmations
// are added as a result of this confirmation being rejected
const { pendingApprovals } = await promisifiedBackground.getState();
if (Object.values(pendingApprovals).length === 0) {
dispatch(closeCurrentNotificationWindow());
}
};
}
export function setFirstTimeFlowType(type) {
return (dispatch) => {
log.debug(`background.setFirstTimeFlowType`);

View File

@ -12842,7 +12842,7 @@ immer@1.10.0:
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
immer@^8.0.0:
immer@^8.0.0, immer@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==