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

Merge remote-tracking branch 'origin/develop' into master-sync

This commit is contained in:
Dan Miller 2022-07-01 13:57:47 -02:30
commit 9bc8e0bb08
70 changed files with 4495 additions and 2342 deletions

View File

@ -5,12 +5,12 @@ set -u
set -o pipefail
# To get the latest version, see <https://www.ubuntuupdates.org/ppa/google_chrome?dist=stable>
CHROME_VERSION='102.0.5005.61-1'
CHROME_VERSION='103.0.5060.53-1'
CHROME_BINARY="google-chrome-stable_${CHROME_VERSION}_amd64.deb"
CHROME_BINARY_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/${CHROME_BINARY}"
# To retrieve this checksum, run the `wget` and `shasum` commands below
CHROME_BINARY_SHA512SUM='dd701b99febf7d927657f38716d90f3a0b967ae75dac5f6e8fbf9df632c8a531ccb9f37ee09340ad730b4fe40d0564c1b64201121d2d3e4e503f3f167ca632cd'
CHROME_BINARY_SHA512SUM='36f4e79f46cb71c1431dccf1489f5f8e89d35204a717a4618c7f6f638123ddc2b37bd5cbd00498be8f84c7713149f2faa447cb6da3518be1cb9703e99d110e1a'
wget -O "${CHROME_BINARY}" -t 5 "${CHROME_BINARY_URL}"

View File

@ -157,6 +157,9 @@
"addMemo": {
"message": "Add memo"
},
"addMoreNetworks": {
"message": "add more networks manually"
},
"addNetwork": {
"message": "Add Network"
},
@ -1954,6 +1957,9 @@
"network": {
"message": "Network:"
},
"networkAddedSuccessfully": {
"message": "Network added successfully!"
},
"networkDetails": {
"message": "Network Details"
},
@ -2891,6 +2897,12 @@
"showAdvancedGasInlineDescription": {
"message": "Select this to show gas price and limit controls directly on the send and confirm screens."
},
"showCustomNetworkList": {
"message": "Show Custom Network List"
},
"showCustomNetworkListDescription": {
"message": "Select this to show a list of networks with prefilled details when adding a new network."
},
"showFiatConversionInTestnets": {
"message": "Show Conversion on test networks"
},
@ -3000,6 +3012,9 @@
"snapsToggle": {
"message": "A snap will only run if it is enabled"
},
"someNetworksMayPoseSecurity": {
"message": "Some networks may pose security and/or privacy risks. Understand the risks before adding & using a network."
},
"somethingWentWrong": {
"message": "Oops! Something went wrong."
},
@ -3562,6 +3577,10 @@
"switchNetworks": {
"message": "Switch Networks"
},
"switchToNetwork": {
"message": "Switch to $1",
"description": "$1 represents the custom network that has previously been added"
},
"switchToThisAccount": {
"message": "Switch to this account"
},
@ -3865,6 +3884,9 @@
"unknownCameraErrorTitle": {
"message": "Ooops! Something went wrong...."
},
"unknownCollection": {
"message": "Unnamed collection"
},
"unknownNetwork": {
"message": "Unknown Private Network"
},
@ -4005,6 +4027,9 @@
"walletCreationSuccessTitle": {
"message": "Wallet creation successful"
},
"wantToAddThisNetwork": {
"message": "Want to add this network?"
},
"warning": {
"message": "Warning"
},
@ -4067,6 +4092,10 @@
"yesLetsTry": {
"message": "Yes, let's try"
},
"youHaveAddedAll": {
"message": "You've added all the popular networks. You can discover more networks $1 Or you can $2",
"description": "$1 is a link with the text 'here' and $2 is a button with the text 'add more networks manually'"
},
"youNeedToAllowCameraAccess": {
"message": "You need to allow camera access to use this feature."
},

View File

@ -0,0 +1 @@
<svg width="1024" height="1024" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle fill="#1969FF" cx="512" cy="512" r="512"/><path d="M480.953 162.82c17.25-9.093 43.496-9.093 60.746 0l176.016 92.795c10.39 5.477 16.095 13.638 17.117 22.063H735V744.11c-.23 9.19-5.988 18.32-17.285 24.275L541.7 861.18c-17.25 9.093-43.497 9.093-60.746 0l-176.017-92.795c-11.249-5.93-16.647-15.123-16.914-24.275a32.372 32.372 0 0 1-.001-2.35V280.82a24 24 0 0 1 0-1.937v-1.204h.08c.781-8.518 6.228-16.47 16.835-22.063l176.017-92.795ZM707 537l-165.355 87.46c-17.225 9.111-43.433 9.111-60.658 0L316 537.195v205.474l164.987 86.802c9.75 5.217 19.888 10.3 29.76 10.521l.569.008c9.852.032 19.418-4.978 29.117-9.72L707 741.92V537ZM260.424 734c0 17.88 2.06 29.633 6.15 37.912 3.389 6.863 8.475 12.107 17.761 18.489l.53.362c2.038 1.387 4.283 2.839 7.016 4.545l3.223 1.992 9.896 6.025L290.806 827l-11.076-6.75-1.862-1.153c-3.202-1.995-5.857-3.707-8.333-5.392-26.467-18.003-36.337-37.63-36.532-78.461L233 734h27.424ZM498 413c-1.28.44-2.481.951-3.575 1.53l-175.748 93.094c-.185.097-.36.194-.528.29L318 508l.276.159.4.217 175.749 93.094c1.094.579 2.294 1.09 3.575 1.53V413Zm28 0v190a25.085 25.085 0 0 0 3.576-1.53l175.747-93.094c.184-.097.36-.194.528-.29L706 508l-.276-.159-.401-.217-175.747-93.094A25.085 25.085 0 0 0 526 413Zm181-102-158 83 158 83V311Zm-391 0v166l158-83-158-83Zm213.422-123.373c-9.147-4.836-25.697-4.836-34.844 0l-175.9 92.997c-.185.098-.362.194-.529.29L318 281l.276.158.401.218 175.9 92.996c9.148 4.837 25.698 4.837 34.845 0l175.9-92.996c.185-.098.361-.194.528-.29L706 281l-.276-.158-.402-.218-175.9-92.997ZM733.194 197l11.076 6.75 1.862 1.152c3.202 1.995 5.857 3.709 8.333 5.393 26.467 18.003 36.337 37.63 36.532 78.461L791 290h-27.424c0-17.882-2.06-29.633-6.15-37.913-3.388-6.862-8.474-12.107-17.76-18.488l-.531-.362a212.559 212.559 0 0 0-7.016-4.545l-3.223-1.992-9.896-6.025L733.194 197Z" fill="#FFF" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1 @@
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><defs><linearGradient id="a" x1="71.37" y1="228.63" x2="228.63" y2="71.37" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#00aee9"/><stop offset="1" stop-color="#69fabd"/></linearGradient></defs><path d="M201.17 60a38.81 38.81 0 0 0-38.84 38.71v42.92c-4 .27-8.09.44-12.33.44s-8.31.17-12.33.41V98.71a38.84 38.84 0 0 0-77.67 0v102.58a38.84 38.84 0 0 0 77.67 0v-42.92c4-.27 8.09-.44 12.33-.44s8.31-.17 12.33-.41v43.77a38.84 38.84 0 0 0 77.67 0V98.71A38.81 38.81 0 0 0 201.17 60ZM98.83 75.86a22.91 22.91 0 0 1 22.92 22.85v45.45a130.64 130.64 0 0 0-33 9.33 60 60 0 0 0-12.8 7.64V98.71a22.91 22.91 0 0 1 22.88-22.85Zm22.92 125.43a22.92 22.92 0 0 1-45.84 0V191c0-9.09 7.2-17.7 19.27-23.06a113 113 0 0 1 26.57-7.77Zm79.42 22.85a22.91 22.91 0 0 1-22.92-22.85v-45.45a130.64 130.64 0 0 0 33-9.33 60 60 0 0 0 12.8-7.64v62.42a22.91 22.91 0 0 1-22.88 22.85Zm3.65-92.14a113 113 0 0 1-26.57 7.77V98.71a22.92 22.92 0 0 1 45.84 0V109c0 9.05-7.2 17.66-19.27 23Z" style="fill:url(#a)"/><path style="fill:none" d="M0 0h300v300H0z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
app/images/info-fox.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -69,6 +69,7 @@ export default class PreferencesController {
? LEDGER_TRANSPORT_TYPES.WEBHID
: LEDGER_TRANSPORT_TYPES.U2F,
theme: 'light',
customNetworkListEnabled: false,
...opts.initState,
};
@ -179,6 +180,17 @@ export default class PreferencesController {
this.store.updateState({ theme: val });
}
/**
* Setter for the `customNetworkListEnabled` property
*
* @param customNetworkListEnabled
*/
setCustomNetworkListEnabled(customNetworkListEnabled) {
this.store.updateState({
customNetworkListEnabled,
});
}
/**
* Add new methodData to state, to avoid requesting this information again through Infura
*

View File

@ -1572,7 +1572,8 @@ export default class MetamaskController extends EventEmitter {
setCustomRpc: this.setCustomRpc.bind(this),
updateAndSetCustomRpc: this.updateAndSetCustomRpc.bind(this),
delCustomRpc: this.delCustomRpc.bind(this),
addCustomNetwork: this.addCustomNetwork.bind(this),
requestUserApproval: this.requestUserApproval.bind(this),
// PreferencesController
setSelectedAddress: preferencesController.setSelectedAddress.bind(
preferencesController,
@ -1609,7 +1610,9 @@ export default class MetamaskController extends EventEmitter {
preferencesController,
),
setTheme: preferencesController.setTheme.bind(preferencesController),
setCustomNetworkListEnabled: preferencesController.setCustomNetworkListEnabled.bind(
preferencesController,
),
// AssetsContractController
getTokenStandardAndDetails: this.getTokenStandardAndDetails.bind(this),
@ -2026,6 +2029,43 @@ export default class MetamaskController extends EventEmitter {
}
}
async requestUserApproval(customRpc, originIsMetaMask) {
try {
await this.approvalController.addAndShowApprovalRequest({
origin: 'metamask',
type: 'wallet_addEthereumChain',
requestData: {
chainId: customRpc.chainId,
blockExplorerUrl: customRpc.rpcPrefs.blockExplorerUrl,
chainName: customRpc.nickname,
rpcUrl: customRpc.rpcUrl,
ticker: customRpc.ticker,
imageUrl: customRpc.rpcPrefs.imageUrl,
},
});
} catch (error) {
if (
!(originIsMetaMask && error.message === 'User rejected the request.')
) {
throw error;
}
}
}
async addCustomNetwork(customRpc) {
const { chainId, chainName, rpcUrl, ticker, blockExplorerUrl } = customRpc;
await this.preferencesController.addToFrequentRpcList(
rpcUrl,
chainId,
ticker,
chainName,
{
blockExplorerUrl,
},
);
}
/**
* Create a new Vault and restore an existent keyring.
*

View File

@ -282,7 +282,7 @@
"browser-util-inspect": "^0.2.0",
"browserify": "^16.5.1",
"chalk": "^3.0.0",
"chromedriver": "^102.0.0",
"chromedriver": "^103.0.0",
"concurrently": "^5.2.0",
"copy-webpack-plugin": "^6.0.3",
"cross-spawn": "^7.0.3",

View File

@ -28,6 +28,9 @@ export const POLYGON_CHAIN_ID = '0x89';
export const AVALANCHE_CHAIN_ID = '0xa86a';
export const FANTOM_CHAIN_ID = '0xfa';
export const CELO_CHAIN_ID = '0xa4ec';
export const ARBITRUM_CHAIN_ID = '0xa4b1';
export const HARMONY_CHAIN_ID = '0x63564c40';
export const PALM_CHAIN_ID = '0x2a15c308d';
/**
* The largest possible chain ID we can handle.
@ -43,7 +46,14 @@ export const GOERLI_DISPLAY_NAME = 'Goerli';
export const LOCALHOST_DISPLAY_NAME = 'Localhost 8545';
export const BSC_DISPLAY_NAME = 'Binance Smart Chain';
export const POLYGON_DISPLAY_NAME = 'Polygon';
export const AVALANCHE_DISPLAY_NAME = 'Avalanche';
export const AVALANCHE_DISPLAY_NAME = 'Avalanche Network C-Chain';
export const ARBITRUM_DISPLAY_NAME = 'Arbitrum One';
export const BNB_DISPLAY_NAME =
'BNB Smart Chain (previously Binance Smart Chain Mainnet)';
export const OPTIMISM_DISPLAY_NAME = 'Optimism';
export const FANTOM_DISPLAY_NAME = 'Fantom Opera';
export const HARMONY_DISPLAY_NAME = 'Harmony Mainnet Shard 0';
export const PALM_DISPLAY_NAME = 'Palm';
const infuraProjectId = process.env.INFURA_PROJECT_ID;
export const getRpcUrl = ({ network, excludeProjectId = false }) =>
@ -64,12 +74,20 @@ export const MATIC_SYMBOL = 'MATIC';
export const AVALANCHE_SYMBOL = 'AVAX';
export const FANTOM_SYMBOL = 'FTM';
export const CELO_SYMBOL = 'CELO';
export const ARBITRUM_SYMBOL = 'AETH';
export const HARMONY_SYMBOL = 'ONE';
export const PALM_SYMBOL = 'PALM';
export const ETH_TOKEN_IMAGE_URL = './images/eth_logo.svg';
export const TEST_ETH_TOKEN_IMAGE_URL = './images/black-eth-logo.svg';
export const BNB_TOKEN_IMAGE_URL = './images/bnb.png';
export const MATIC_TOKEN_IMAGE_URL = './images/matic-token.png';
export const AVAX_TOKEN_IMAGE_URL = './images/avax-token.png';
export const AETH_TOKEN_IMAGE_URL = './images/arbitrum.svg';
export const FTM_TOKEN_IMAGE_URL = './images/fantom-opera.svg';
export const HARMONY_ONE_TOKEN_IMAGE_URL = './images/harmony-one.svg';
export const OPTIMISM_TOKEN_IMAGE_URL = './images/optimism.svg';
export const PALM_TOKEN_IMAGE_URL = './images/palm.svg';
export const INFURA_PROVIDER_TYPES = [ROPSTEN, RINKEBY, KOVAN, MAINNET, GOERLI];
@ -166,6 +184,12 @@ export const CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP = {
[AVALANCHE_CHAIN_ID]: AVAX_TOKEN_IMAGE_URL,
[BSC_CHAIN_ID]: BNB_TOKEN_IMAGE_URL,
[POLYGON_CHAIN_ID]: MATIC_TOKEN_IMAGE_URL,
[ARBITRUM_CHAIN_ID]: AETH_TOKEN_IMAGE_URL,
[BSC_CHAIN_ID]: BNB_TOKEN_IMAGE_URL,
[FANTOM_CHAIN_ID]: FTM_TOKEN_IMAGE_URL,
[HARMONY_CHAIN_ID]: HARMONY_ONE_TOKEN_IMAGE_URL,
[OPTIMISM_CHAIN_ID]: OPTIMISM_TOKEN_IMAGE_URL,
[PALM_CHAIN_ID]: PALM_TOKEN_IMAGE_URL,
};
export const CHAIN_ID_TO_NETWORK_ID_MAP = Object.values(
@ -309,3 +333,86 @@ export const BUYABLE_CHAINS_MAP = {
},
},
};
export const FEATURED_RPCS = [
{
chainId: ARBITRUM_CHAIN_ID,
nickname: ARBITRUM_DISPLAY_NAME,
rpcUrl: `https://arbitrum-mainnet.infura.io/v3/${infuraProjectId}`,
ticker: ARBITRUM_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://explorer.arbitrum.io',
imageUrl: AETH_TOKEN_IMAGE_URL,
},
},
{
chainId: AVALANCHE_CHAIN_ID,
nickname: AVALANCHE_DISPLAY_NAME,
rpcUrl: 'https://api.avax.network/ext/bc/C/rpc',
ticker: AVALANCHE_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://snowtrace.io/',
imageUrl: AVAX_TOKEN_IMAGE_URL,
},
},
{
chainId: BSC_CHAIN_ID,
nickname: BNB_DISPLAY_NAME,
rpcUrl: 'https://bsc-dataseed.binance.org/',
ticker: BNB_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://bscscan.com/',
imageUrl: BNB_TOKEN_IMAGE_URL,
},
},
{
chainId: FANTOM_CHAIN_ID,
nickname: FANTOM_DISPLAY_NAME,
rpcUrl: 'https://rpc.ftm.tools/',
ticker: FANTOM_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://ftmscan.com/',
imageUrl: FTM_TOKEN_IMAGE_URL,
},
},
{
chainId: HARMONY_CHAIN_ID,
nickname: HARMONY_DISPLAY_NAME,
rpcUrl: 'https://api.harmony.one/',
ticker: HARMONY_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://explorer.harmony.one/',
imageUrl: HARMONY_ONE_TOKEN_IMAGE_URL,
},
},
{
chainId: OPTIMISM_CHAIN_ID,
nickname: OPTIMISM_DISPLAY_NAME,
rpcUrl: `https://optimism-mainnet.infura.io/v3/${infuraProjectId}`,
ticker: ETH_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://optimistic.etherscan.io/',
imageUrl: OPTIMISM_TOKEN_IMAGE_URL,
},
},
{
chainId: PALM_CHAIN_ID,
nickname: PALM_DISPLAY_NAME,
rpcUrl: `https://palm-mainnet.infura.io/v3/${infuraProjectId}`,
ticker: PALM_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://explorer.palm.io/',
imageUrl: PALM_TOKEN_IMAGE_URL,
},
},
{
chainId: POLYGON_CHAIN_ID,
nickname: `${POLYGON_DISPLAY_NAME} ${capitalize(MAINNET)}`,
rpcUrl: `https://polygon-mainnet.infura.io/v3/${infuraProjectId}`,
ticker: MATIC_SYMBOL,
rpcPrefs: {
blockExplorerUrl: 'https://polygonscan.com/',
imageUrl: MATIC_TOKEN_IMAGE_URL,
},
},
];

View File

@ -8,3 +8,16 @@ import contractMap from '@metamask/contract-metadata';
export const LISTED_CONTRACT_ADDRESSES = Object.keys(
contractMap,
).map((address) => address.toLowerCase());
/**
* @typedef {Object} TokenDetails
* @property {string} address - The address of the selected 'TOKEN' or
* 'COLLECTIBLE' contract.
* @property {string} [symbol] - The symbol of the token.
* @property {number} [decimals] - The number of decimals of the selected
* 'ERC20' asset.
* @property {number} [tokenId] - The id of the selected 'COLLECTIBLE' asset.
* @property {TokenStandardStrings} [standard] - The standard of the selected
* asset.
* @property {boolean} [isERC721] - True when the asset is a ERC721 token.
*/

View File

@ -1,3 +1,8 @@
import {
draftTransactionInitialState,
initialState,
} from '../../ui/ducks/send';
export const TOP_ASSETS_GET_RESPONSE = [
{
symbol: 'LINK',
@ -103,3 +108,42 @@ export const createGasFeeEstimatesForFeeMarket = () => {
estimatedBaseFee: '50',
};
};
export const INITIAL_SEND_STATE_FOR_EXISTING_DRAFT = {
...initialState,
currentTransactionUUID: 'test-uuid',
draftTransactions: {
'test-uuid': {
...draftTransactionInitialState,
},
},
};
export const getInitialSendStateWithExistingTxState = (draftTxState) => ({
...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
draftTransactions: {
'test-uuid': {
...draftTransactionInitialState,
...draftTxState,
amount: {
...draftTransactionInitialState.amount,
...draftTxState.amount,
},
asset: {
...draftTransactionInitialState.asset,
...draftTxState.asset,
},
gas: {
...draftTransactionInitialState.gas,
...draftTxState.gas,
},
recipient: {
...draftTransactionInitialState.recipient,
...draftTxState.recipient,
},
history: draftTxState.history ?? [],
// Use this key if you want to console.log inside the send.js file.
test: draftTxState.test ?? 'yo',
},
},
});

View File

@ -1,168 +1,286 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { I18nContext } from '../../../contexts/i18n';
import Box from '../../ui/box';
import Typography from '../../ui/typography';
import {
ALIGN_ITEMS,
BLOCK_SIZES,
COLORS,
DISPLAY,
FLEX_DIRECTION,
FONT_WEIGHT,
TYPOGRAPHY,
JUSTIFY_CONTENT,
SIZES,
} from '../../../helpers/constants/design-system';
import Button from '../../ui/button';
import IconCaretLeft from '../../ui/icon/icon-caret-left';
import Tooltip from '../../ui/tooltip';
import IconWithFallback from '../../ui/icon-with-fallback';
import IconBorder from '../../ui/icon-border';
import { getTheme } from '../../../selectors';
import { THEME_TYPE } from '../../../pages/settings/experimental-tab/experimental-tab.constant';
import {
getFrequentRpcListDetail,
getUnapprovedConfirmations,
} from '../../../selectors';
const AddNetwork = ({
onBackClick,
onAddNetworkClick,
onAddNetworkManuallyClick,
featuredRPCS,
}) => {
import {
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_POPUP,
MESSAGE_TYPE,
} from '../../../../shared/constants/app';
import { requestUserApproval } from '../../../store/actions';
import Popover from '../../ui/popover';
import ConfirmationPage from '../../../pages/confirmation/confirmation';
import { FEATURED_RPCS } from '../../../../shared/constants/network';
import { ADD_NETWORK_ROUTE } from '../../../helpers/constants/routes';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
const AddNetwork = () => {
const t = useContext(I18nContext);
const theme = useSelector(getTheme);
const dispatch = useDispatch();
const history = useHistory();
const frequentRpcList = useSelector(getFrequentRpcListDetail);
const frequentRpcListChainIds = Object.values(frequentRpcList).map(
(net) => net.chainId,
);
const infuraRegex = /infura.io/u;
const nets = featuredRPCS
.sort((a, b) => (a.ticker > b.ticker ? 1 : -1))
.slice(0, 8);
const nets = FEATURED_RPCS.sort((a, b) =>
a.ticker > b.ticker ? 1 : -1,
).slice(0, FEATURED_RPCS.length);
const notFrequentRpcNetworks = nets.filter(
(net) => frequentRpcListChainIds.indexOf(net.chainId) === -1,
);
const unapprovedConfirmations = useSelector(getUnapprovedConfirmations);
const [showPopover, setShowPopover] = useState(false);
useEffect(() => {
const anAddNetworkConfirmationFromMetaMaskExists = unapprovedConfirmations?.find(
(confirmation) => {
return (
confirmation.origin === 'metamask' &&
confirmation.type === MESSAGE_TYPE.ADD_ETHEREUM_CHAIN
);
},
);
if (!showPopover && anAddNetworkConfirmationFromMetaMaskExists) {
setShowPopover(true);
}
if (showPopover && !anAddNetworkConfirmationFromMetaMaskExists) {
setShowPopover(false);
}
}, [unapprovedConfirmations, showPopover]);
return (
<Box>
<Box
height={BLOCK_SIZES.TWO_TWELFTHS}
padding={[4, 0, 4, 0]}
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
flexDirection={FLEX_DIRECTION.ROW}
className="add-network__header"
>
<IconCaretLeft
aria-label={t('back')}
onClick={onBackClick}
className="add-network__header__back-icon"
/>
<Typography variant={TYPOGRAPHY.H3} color={COLORS.TEXT_DEFAULT}>
{t('addNetwork')}
</Typography>
</Box>
<Box
height={BLOCK_SIZES.FOUR_FIFTHS}
width={BLOCK_SIZES.TEN_TWELFTHS}
margin={[0, 6, 0, 6]}
>
<Typography
variant={TYPOGRAPHY.H6}
color={COLORS.TEXT_ALTERNATIVE}
margin={[4, 0, 0, 0]}
<>
{Object.keys(notFrequentRpcNetworks).length === 0 ? (
<Box
className="add-network__edge-case-box"
borderRadius={SIZES.MD}
padding={4}
margin={[4, 6, 0, 6]}
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.ROW}
backgroundColor={COLORS.BACKGROUND_ALTERNATIVE}
>
{t('addFromAListOfPopularNetworks')}
</Typography>
<Typography
variant={TYPOGRAPHY.H7}
color={COLORS.TEXT_MUTED}
margin={[4, 0, 3, 0]}
>
{t('popularCustomNetworks')}
</Typography>
{nets.map((item, index) => (
<Box
key={index}
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN}
marginBottom={6}
>
<Box display={DISPLAY.FLEX} alignItems={ALIGN_ITEMS.CENTER}>
<IconBorder size={24}>
<IconWithFallback
icon={item.rpcPrefs.imageUrl}
name={item.nickname}
size={24}
/>
</IconBorder>
<Typography
variant={TYPOGRAPHY.H7}
color={COLORS.TEXT_DEFAULT}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ marginLeft: 2 }}
>
{item.nickname}
<Box marginRight={4}>
<img src="images/info-fox.svg" />
</Box>
<Box>
<Typography variant={TYPOGRAPHY.H7}>
{t('youHaveAddedAll', [
<a
key="link"
className="add-network__edge-case-box__link"
href="https://chainlist.wtf/"
target="_blank"
rel="noreferrer"
>
{t('here')}.
</a>,
<Button
key="button"
type="inline"
onClick={(event) => {
event.preventDefault();
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? global.platform.openExtensionInBrowser(
ADD_NETWORK_ROUTE,
)
: history.push(ADD_NETWORK_ROUTE);
}}
>
<Typography
variant={TYPOGRAPHY.H7}
color={COLORS.INFO_DEFAULT}
>
{t('addMoreNetworks')}.
</Typography>
</Button>,
])}
</Typography>
</Box>
</Box>
) : (
<Box className="add-network__networks-container">
{getEnvironmentType() === ENVIRONMENT_TYPE_FULLSCREEN && (
<Box
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
flexDirection={FLEX_DIRECTION.ROW}
marginTop={7}
marginBottom={4}
paddingBottom={2}
className="add-network__header"
>
<Typography variant={TYPOGRAPHY.H4} color={COLORS.TEXT_MUTED}>
{t('networks')}
</Typography>
<span className="add-network__header__subtitle">{' > '}</span>
<Typography variant={TYPOGRAPHY.H4} color={COLORS.TEXT_DEFAULT}>
{t('addANetwork')}
</Typography>
</Box>
<Box display={DISPLAY.FLEX} alignItems={ALIGN_ITEMS.CENTER}>
{
// Warning for the networks that doesn't use infura.io as the RPC
!infuraRegex.test(item.rpcUrl) && (
<Tooltip
className="add-network__warning-tooltip"
position="top"
interactive
html={
<Box margin={3} className="add-network__warning-tooltip">
{t('addNetworkTooltipWarning', [
<a
key="zendesk_page_link"
href="https://metamask.zendesk.com/hc/en-us/articles/4417500466971"
rel="noreferrer"
target="_blank"
>
{t('learnMoreUpperCase')}
</a>,
])}
</Box>
}
trigger="mouseenter"
theme={theme === THEME_TYPE.DEFAULT ? 'light' : 'dark'}
>
<i
className="fa fa-exclamation-triangle add-network__warning-icon"
title={t('warning')}
/>
</Tooltip>
)
}
<Button
type="inline"
className="add-network__add-button"
onClick={onAddNetworkClick}
)}
<Box
margin={
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? [0, 0, 1, 0]
: [4, 0, 1, 0]
}
className="add-network__main-container"
>
<Typography
variant={TYPOGRAPHY.H6}
color={COLORS.TEXT_ALTERNATIVE}
margin={[4, 0, 0, 0]}
>
{t('addFromAListOfPopularNetworks')}
</Typography>
<Typography
variant={TYPOGRAPHY.H7}
color={COLORS.TEXT_MUTED}
margin={[4, 0, 3, 0]}
>
{t('popularCustomNetworks')}
</Typography>
{notFrequentRpcNetworks.map((item, index) => (
<Box
key={index}
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN}
marginBottom={6}
className="add-network__list-of-networks"
>
{t('add')}
</Button>
</Box>
<Box display={DISPLAY.FLEX} alignItems={ALIGN_ITEMS.CENTER}>
<Box>
<IconBorder size={24}>
<IconWithFallback
icon={item.rpcPrefs.imageUrl}
name={item.nickname}
size={24}
/>
</IconBorder>
</Box>
<Box marginLeft={2}>
<Typography
variant={TYPOGRAPHY.H7}
color={COLORS.TEXT_DEFAULT}
fontWeight={FONT_WEIGHT.BOLD}
>
{item.nickname}
</Typography>
</Box>
</Box>
<Box
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
marginLeft={1}
>
{
// Warning for the networks that doesn't use infura.io as the RPC
!infuraRegex.test(item.rpcUrl) && (
<Tooltip
position="top"
interactive
html={
<Box
margin={3}
className="add-network__warning-tooltip"
>
{t('addNetworkTooltipWarning', [
<a
key="zendesk_page_link"
href="https://metamask.zendesk.com/hc/en-us/articles/4417500466971"
rel="noreferrer"
target="_blank"
>
{t('learnMoreUpperCase')}
</a>,
])}
</Box>
}
trigger="mouseenter"
>
<i
className="fa fa-exclamation-triangle add-network__warning-icon"
title={t('warning')}
/>
</Tooltip>
)
}
<Button
type="inline"
className="add-network__add-button"
onClick={async () => {
await dispatch(requestUserApproval(item, true));
}}
>
{t('add')}
</Button>
</Box>
</Box>
))}
</Box>
))}
</Box>
<Box
height={BLOCK_SIZES.ONE_TWELFTH}
padding={[4, 4, 4, 4]}
className="add-network__footer"
>
<Button type="link" onClick={onAddNetworkManuallyClick}>
<Typography variant={TYPOGRAPHY.H6} color={COLORS.PRIMARY_DEFAULT}>
{t('addANetworkManually')}
</Typography>
</Button>
</Box>
</Box>
<Box
padding={
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? [2, 0, 2, 6]
: [2, 0, 2, 0]
}
className="add-network__footer"
>
<Button
type="link"
onClick={(event) => {
event.preventDefault();
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE)
: history.push(ADD_NETWORK_ROUTE);
}}
>
<Typography
variant={TYPOGRAPHY.H6}
color={COLORS.PRIMARY_DEFAULT}
>
{t('addANetworkManually')}
</Typography>
</Button>
</Box>
</Box>
)}
{showPopover && (
<Popover>
<ConfirmationPage />
</Popover>
)}
</>
);
};
AddNetwork.propTypes = {
onBackClick: PropTypes.func,
onAddNetworkClick: PropTypes.func,
onAddNetworkManuallyClick: PropTypes.func,
featuredRPCS: PropTypes.array,
};
export default AddNetwork;

View File

@ -0,0 +1,60 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import AddNetwork from './add-network';
jest.mock('../../../selectors', () => ({
getFrequentRpcListDetail: () => ({
frequentRpcList: [
{
chainId: '0x539',
nickname: 'Localhost 8545',
rpcPrefs: {},
rpcUrl: 'http://localhost:8545',
ticker: 'ETH',
},
{
chainId: '0xA4B1',
nickname: 'Arbitrum One',
rpcPrefs: { blockExplorerUrl: 'https://explorer.arbitrum.io' },
rpcUrl:
'https://arbitrum-mainnet.infura.io/v3/7e127583378c4732a858df2550aff333',
ticker: 'AETH',
},
],
}),
getUnapprovedConfirmations: jest.fn(),
getTheme: () => 'light',
}));
const render = () => {
const store = configureStore({
metamask: {
...mockState.metamask,
},
});
return renderWithProvider(<AddNetwork />, store);
};
describe('AddNetwork', () => {
it('should show Add from a list.. text', () => {
render();
expect(
screen.getByText(
'Add from a list of popular networks or add a network manually. Only interact with the entities you trust.',
),
).toBeInTheDocument();
});
it('should show Popular custom networks text', () => {
render();
expect(screen.getByText('Popular custom networks')).toBeInTheDocument();
});
it('should show Arbitrum One network nickname', () => {
render();
expect(screen.getByText('Arbitrum One')).toBeInTheDocument();
});
});

View File

@ -1,10 +1,36 @@
.add-network {
&__networks-container {
padding-inline-end: 24px;
@media screen and (max-width: $break-small) {
padding: 0;
}
}
&__header {
border-bottom: 1px solid var(--color-border-default);
&__back-icon {
margin-left: 24px;
margin-right: 16px;
@media screen and (max-width: 575px) {
padding-inline-start: 24px;
padding-inline-end: 24px;
}
&__subtitle {
margin-inline-start: 10px;
margin-inline-end: 10px;
}
}
&__main-container {
@media screen and (max-width: 575px) {
padding-inline-start: 24px;
padding-inline-end: 24px;
}
}
&__list-of-networks {
@media screen and (min-width: $break-large) {
width: 75%;
}
}
@ -23,19 +49,25 @@
&__add-icon {
color: var(--color-text-alternative);
margin-left: auto;
margin-right: 0;
margin-inline-start: auto;
margin-inline-end: 0;
cursor: pointer;
}
&__add-button.button {
color: var(--color-primary-default);
font-size: $font-size-h7;
margin-left: 24px;
margin-inline-start: 24px;
}
&__footer {
border-top: 1px solid var(--color-border-muted);
width: 100%;
padding-bottom: 8px;
@media screen and (max-width: 575px) {
padding-inline-start: 24px !important;
}
& .btn-link {
display: initial;
@ -51,6 +83,14 @@
color: var(--color-text-alternative);
}
}
&__edge-case-box {
border: 1px solid var(--color-border-muted);
&__link {
color: var(--color-info-default);
display: inline;
padding: 0;
}
}
}

View File

@ -14,6 +14,7 @@
@import 'collectibles-items/index';
@import 'collectibles-tab/index';
@import 'collectible-details/index';
@import 'collectible-default-image/index';
@import 'collectible-options/index';
@import 'collectibles-detection-notice/index';
@import 'connected-accounts-list/index';

View File

@ -9,7 +9,7 @@ import Tooltip from '../../ui/tooltip';
import InfoIcon from '../../ui/icon/info-icon.component';
import Button from '../../ui/button';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { updateSendAsset } from '../../../ducks/send';
import { startNewDraftTransaction } from '../../../ducks/send';
import { SEND_ROUTE } from '../../../helpers/constants/routes';
import { SEVERITIES } from '../../../helpers/constants/design-system';
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
@ -74,7 +74,7 @@ const AssetListItem = ({
});
try {
await dispatch(
updateSendAsset({
startNewDraftTransaction({
type: ASSET_TYPES.TOKEN,
details: {
address: tokenAddress,

View File

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Typography from '../../ui/typography';
import { TYPOGRAPHY } from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
export default function CollectibleDefaultImage({
name,
tokenId,
handleImageClick,
}) {
const t = useI18nContext();
return (
<div
className={classnames('collectible-default', {
'collectible-default--clickable': handleImageClick,
})}
onClick={handleImageClick}
>
<Typography variant={TYPOGRAPHY.H6} className="collectible-default__text">
{name ?? t('unknownCollection')} <br /> #{tokenId}
</Typography>
</div>
);
}
CollectibleDefaultImage.propTypes = {
/**
* The name of the collectible collection if not supplied will default to "Unnamed collection"
*/
name: PropTypes.string,
/**
* The token id of the collectible
*/
tokenId: PropTypes.string,
/**
* The click handler for the collectible default image
*/
handleImageClick: PropTypes.func,
};

View File

@ -0,0 +1,42 @@
import React from 'react';
import CollectibleDefaultImage from '.';
export default {
title: 'Components/App/CollectibleDefaultImage',
id: __filename,
argTypes: {
name: {
control: 'text',
},
tokenId: {
control: 'text',
},
handleImageClick: {
action: 'handleImageClick',
},
},
args: {
name: null,
tokenId: '12345',
handleImageClick: null,
},
};
export const DefaultStory = (args) => (
<div style={{ width: 200, height: 200 }}>
<CollectibleDefaultImage {...args} />
</div>
);
DefaultStory.storyName = 'Default';
export const handleImageClick = (args) => (
<div style={{ width: 200, height: 200 }}>
<CollectibleDefaultImage {...args} />
</div>
);
handleImageClick.args = {
// eslint-disable-next-line no-alert
handleImageClick: () => window.alert('CollectibleDefaultImage clicked!'),
};

View File

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

View File

@ -0,0 +1,22 @@
.collectible-default {
background-color: var(--color-background-alternative);
padding-top: 100%; // retains 1:1 aspect ratio
position: relative;
width: 100%;
&__text {
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
position: absolute;
white-space: nowrap;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% - 32px);
}
&--clickable {
cursor: pointer;
}
}

View File

@ -45,13 +45,14 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app';
import CollectibleOptions from '../collectible-options/collectible-options';
import Button from '../../ui/button';
import { updateSendAsset } from '../../../ducks/send';
import { startNewDraftTransaction } from '../../../ducks/send';
import InfoTooltip from '../../ui/info-tooltip';
import { ERC721 } from '../../../helpers/constants/common';
import { usePrevious } from '../../../hooks/usePrevious';
import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils';
import { ASSET_TYPES } from '../../../../shared/constants/transaction';
import CollectibleDefaultImage from '../collectible-default-image';
export default function CollectibleDetails({ collectible }) {
const {
@ -119,7 +120,7 @@ export default function CollectibleDetails({ collectible }) {
const onSend = async () => {
await dispatch(
updateSendAsset({
startNewDraftTransaction({
type: ASSET_TYPES.COLLECTIBLE,
details: collectible,
}),
@ -176,7 +177,11 @@ export default function CollectibleDetails({ collectible }) {
justifyContent={JUSTIFY_CONTENT.CENTER}
className="collectible-details__card"
>
<img className="collectible-details__image" src={image} />
{image ? (
<img className="collectible-details__image" src={image} />
) : (
<CollectibleDefaultImage name={name} tokenId={tokenId} />
)}
</Card>
<Box
flexDirection={FLEX_DIRECTION.COLUMN}
@ -215,6 +220,7 @@ export default function CollectibleDetails({ collectible }) {
<Typography
color={COLORS.TEXT_ALTERNATIVE}
variant={TYPOGRAPHY.H6}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
boxProps={{ margin: 0, marginBottom: 4 }}
>
{description}

View File

@ -1,16 +1,6 @@
import React from 'react';
import CollectibleDetails from './collectible-details';
export default {
title: 'Components/App/CollectiblesDetail',
id: __filename,
argTypes: {
collectible: {
control: 'object',
},
},
};
const collectible = {
name: 'Catnip Spicywright',
tokenId: '1124157',
@ -20,12 +10,32 @@ const collectible = {
"Good day. My name is Catnip Spicywight, which got me teased a lot in high school. If I want to put low fat mayo all over my hamburgers, I shouldn't have to answer to anyone about it, am I right? One time I beat Arlene in an arm wrestle.",
};
export const DefaultStory = () => {
return <CollectibleDetails collectible={collectible} />;
export default {
title: 'Components/App/CollectiblesDetail',
id: __filename,
argTypes: {
collectible: {
control: 'object',
},
},
args: {
collectible,
},
};
export const DefaultStory = (args) => {
return <CollectibleDetails {...args} />;
};
DefaultStory.storyName = 'Default';
DefaultStory.args = {
collectible,
export const NoImage = (args) => {
return <CollectibleDetails {...args} />;
};
NoImage.args = {
collectible: {
...collectible,
image: undefined,
},
};

View File

@ -28,6 +28,8 @@ import { getAssetImageURL } from '../../../helpers/utils/util';
import { updateCollectibleDropDownState } from '../../../store/actions';
import { usePrevious } from '../../../hooks/usePrevious';
import { getCollectiblesDropdownState } from '../../../ducks/metamask/metamask';
import { useI18nContext } from '../../../hooks/useI18nContext';
import CollectibleDefaultImage from '../collectible-default-image';
const width =
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
@ -46,6 +48,7 @@ export default function CollectiblesItems({
const previousCollectionKeys = usePrevious(collectionsKeys);
const selectedAddress = useSelector(getSelectedAddress);
const chainId = useSelector(getCurrentChainId);
const t = useI18nContext();
useEffect(() => {
if (
@ -101,7 +104,7 @@ export default function CollectiblesItems({
}
return (
<div className="collectibles-items__collection-image-alt">
{collectionName[0]}
{collectionName?.[0]?.toUpperCase() ?? null}
</div>
);
};
@ -164,7 +167,9 @@ export default function CollectiblesItems({
variant={TYPOGRAPHY.H5}
margin={[0, 0, 0, 2]}
>
{`${collectionName} (${collectibles.length})`}
{`${collectionName ?? t('unknownCollection')} (${
collectibles.length
})`}
</Typography>
</Box>
<Box alignItems={ALIGN_ITEMS.FLEX_END}>
@ -180,29 +185,48 @@ export default function CollectiblesItems({
{isExpanded ? (
<Box display={DISPLAY.FLEX} flexWrap={FLEX_WRAP.WRAP} gap={4}>
{collectibles.map((collectible, i) => {
const { image, address, tokenId, backgroundColor } = collectible;
const {
image,
address,
tokenId,
backgroundColor,
name,
} = collectible;
const collectibleImage = getAssetImageURL(image, ipfsGateway);
const handleImageClick = () =>
history.push(`${ASSET_ROUTE}/${address}/${tokenId}`);
return (
<Box
width={width}
key={`collectible-${i}`}
className="collectibles-items__collection-item-wrapper"
className="collectibles-items__item-wrapper"
>
<Card padding={0} justifyContent={JUSTIFY_CONTENT.CENTER}>
<div
className="collectibles-items__collection-item"
style={{
backgroundColor,
}}
>
<img
onClick={() =>
history.push(`${ASSET_ROUTE}/${address}/${tokenId}`)
}
className="collectibles-items__collection-item-image"
src={collectibleImage}
<Card
padding={0}
justifyContent={JUSTIFY_CONTENT.CENTER}
className="collectibles-items__item-wrapper__card"
>
{collectibleImage ? (
<div
className="collectibles-items__item"
style={{
backgroundColor,
}}
>
<img
onClick={handleImageClick}
className="collectibles-items__item-image"
src={collectibleImage}
/>
</div>
) : (
<CollectibleDefaultImage
name={name}
tokenId={tokenId}
handleImageClick={handleImageClick}
/>
</div>
)}
</Card>
</Box>
);

View File

@ -27,29 +27,33 @@
color: var(--color-overlay-inverse);
text-align: center;
}
}
&-item-wrapper {
align-self: center;
&__item-wrapper {
align-self: center;
&__card {
overflow: hidden;
}
}
&-item {
border-radius: 4px;
width: 100%;
display: flex;
justify-content: center;
cursor: pointer;
align-self: center;
}
&__item {
border-radius: 4px;
width: 100%;
display: flex;
justify-content: center;
cursor: pointer;
align-self: center;
&-item-image {
&-image {
border-radius: 4px;
width: 100%;
height: 100%;
cursor: pointer;
}
}
&__icon-chevron {
color: var(--color-icon-default);
}
&__icon-chevron {
color: var(--color-icon-default);
}
}

View File

@ -20,6 +20,7 @@ import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app';
import { EVENT } from '../../../../shared/constants/metametrics';
import {
ADD_NETWORK_ROUTE,
ADD_POPULAR_CUSTOM_NETWORK,
ADVANCED_ROUTE,
} from '../../../helpers/constants/routes';
import IconCheck from '../../ui/icon/icon-check';
@ -49,6 +50,7 @@ function mapStateToProps(state) {
frequentRpcListDetail: state.metamask.frequentRpcListDetail || [],
networkDropdownOpen: state.appState.networkDropdownOpen,
showTestnetMessageInDropdown: state.metamask.showTestnetMessageInDropdown,
addPopularNetworkFeatureToggledOn: state.metamask.customNetworkListEnabled,
};
}
@ -101,6 +103,7 @@ class NetworkDropdown extends Component {
showTestnetMessageInDropdown: PropTypes.bool.isRequired,
hideTestNetMessage: PropTypes.func.isRequired,
history: PropTypes.object,
addPopularNetworkFeatureToggledOn: PropTypes.bool,
};
handleClick(newProviderType) {
@ -129,10 +132,12 @@ class NetworkDropdown extends Component {
<Button
type="secondary"
onClick={() => {
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE);
if (this.props.addPopularNetworkFeatureToggledOn) {
this.props.history.push(ADD_POPULAR_CUSTOM_NETWORK);
} else {
this.props.history.push(ADD_NETWORK_ROUTE);
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE)
: this.props.history.push(ADD_NETWORK_ROUTE);
}
this.props.hideNetworkDropdown();
}}

View File

@ -43,6 +43,9 @@ const MetaMaskTemplateRenderer = ({ sections }) => {
return (
<>
{sections.reduce((allChildren, child) => {
if (child?.hide === true) {
return allChildren;
}
if (typeof child === 'string') {
// React can render strings directly, so push them into the accumulator
allChildren.push(child);

View File

@ -9,6 +9,8 @@ import MetaMaskTranslation from '../metamask-translation';
import NetworkDisplay from '../network-display';
import TextArea from '../../ui/textarea/textarea';
import ConfirmationNetworkSwitch from '../../../pages/confirmation/components/confirmation-network-switch';
import UrlIcon from '../../ui/url-icon';
import Tooltip from '../../ui/tooltip/tooltip';
export const safeComponentList = {
MetaMaskTranslation,
@ -27,4 +29,7 @@ export const safeComponentList = {
NetworkDisplay,
TextArea,
ConfirmationNetworkSwitch,
UrlIcon,
Tooltip,
i: 'i',
};

View File

@ -33,6 +33,8 @@ import { isHardwareKeyring } from '../../../helpers/utils/hardware';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { EVENT } from '../../../../shared/constants/metametrics';
import Spinner from '../../ui/spinner';
import { startNewDraftTransaction } from '../../../ducks/send';
import { ASSET_TYPES } from '../../../../shared/constants/transaction';
import WalletOverview from './wallet-overview';
const EthOverview = ({ className }) => {
@ -131,7 +133,11 @@ const EthOverview = ({ className }) => {
legacy_event: true,
},
});
history.push(SEND_ROUTE);
dispatch(
startNewDraftTransaction({ type: ASSET_TYPES.NATIVE }),
).then(() => {
history.push(SEND_ROUTE);
});
}}
/>
<IconButton

View File

@ -14,7 +14,7 @@ import {
} from '../../../helpers/constants/routes';
import { useTokenTracker } from '../../../hooks/useTokenTracker';
import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount';
import { updateSendAsset } from '../../../ducks/send';
import { startNewDraftTransaction } from '../../../ducks/send';
import { setSwapsFromToken } from '../../../ducks/swaps/swaps';
import {
getCurrentKeyring,
@ -93,7 +93,7 @@ const TokenOverview = ({ className, token }) => {
});
try {
await dispatch(
updateSendAsset({
startNewDraftTransaction({
type: ASSET_TYPES.TOKEN,
details: token,
}),

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classnames from 'classnames';
import { omit } from 'lodash';
import Typography from '../typography';
import UrlIcon from '../url-icon';
import { COLORS, TYPOGRAPHY } from '../../../helpers/constants/design-system';
export default function Chip({
@ -14,9 +15,11 @@ export default function Chip({
label,
labelProps = {},
leftIcon,
leftIconUrl = '',
rightIcon,
onClick,
maxContent = true,
displayInlineBlock = false,
}) {
const onKeyPress = (event) => {
if (event.key === 'Enter' && onClick) {
@ -37,11 +40,17 @@ export default function Chip({
[`chip--border-color-${borderColor}`]: true,
[`chip--background-color-${backgroundColor}`]: true,
'chip--max-content': maxContent,
'chip--display-inline-block': displayInlineBlock,
})}
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
>
{leftIcon ? <div className="chip__left-icon">{leftIcon}</div> : null}
{leftIcon && !leftIconUrl ? (
<div className="chip__left-icon">{leftIcon}</div>
) : null}
{leftIconUrl ? (
<UrlIcon className="chip__left-url-icon" url={leftIconUrl} />
) : null}
{children ?? (
<Typography
className="chip__label"
@ -106,4 +115,12 @@ Chip.propTypes = {
* max-content can overflow the parent's width and break designs
*/
maxContent: PropTypes.bool,
/**
* Icon location
*/
leftIconUrl: PropTypes.string,
/**
* Display or not the inline block
*/
displayInlineBlock: PropTypes.bool,
};

View File

@ -16,6 +16,10 @@
align-items: center;
}
&__left-url-icon {
margin-right: 8px;
};
@each $variant, $color in design-system.$color-map {
&--border-color-#{$variant} {
border-color: var($color);
@ -67,4 +71,8 @@
&--max-content {
width: max-content;
}
&--display-inline-block {
display: inline-block;
}
}

View File

@ -7,6 +7,7 @@ import {
SIZES,
TYPOGRAPHY,
FONT_WEIGHT,
OVERFLOW_WRAP,
} from '../../../helpers/constants/design-system';
import Tooltip from '../tooltip';
@ -60,6 +61,7 @@ export default function DefinitionList({
marginBottom: MARGIN_MAP[gapSize],
}}
className="definition-list__definition"
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
tag="dd"
>
{definition}

View File

@ -118,7 +118,7 @@ export default class TokenInput extends PureComponent {
isEqualCaseInsensitive(address, token.address),
);
const tokenExchangeRate = tokenExchangeRates?.[existingToken.address] || 0;
const tokenExchangeRate = tokenExchangeRates?.[existingToken?.address] ?? 0;
let currency, numberOfDecimals;
if (hideConversion) {

View File

@ -60,6 +60,7 @@ export default function reduceApp(state = {}, action) {
newCollectibleAddedMessage: '',
sendInputCurrencySwitched: false,
newTokensImported: '',
newCustomNetworkAdded: {},
...state,
};
@ -393,6 +394,11 @@ export default function reduceApp(state = {}, action) {
...appState,
sendInputCurrencySwitched: !appState.sendInputCurrencySwitched,
};
case actionConstants.SET_NEW_CUSTOM_NETWORK_ADDED:
return {
...appState,
newCustomNetworkAdded: action.value,
};
default:
return appState;
}
@ -444,3 +450,7 @@ export function getLedgerTransportStatus(state) {
export function toggleCurrencySwitch() {
return { type: actionConstants.TOGGLE_CURRENCY_INPUT_SWITCH };
}
export function setNewCustomNetworkAdded(value) {
return { type: actionConstants.SET_NEW_CUSTOM_NETWORK_ADDED, value };
}

295
ui/ducks/send/helpers.js Normal file
View File

@ -0,0 +1,295 @@
import { addHexPrefix } from 'ethereumjs-util';
import abi from 'human-standard-token-abi';
import { GAS_LIMITS } from '../../../shared/constants/gas';
import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network';
import {
ASSET_TYPES,
TRANSACTION_ENVELOPE_TYPES,
} from '../../../shared/constants/transaction';
import { readAddressAsContract } from '../../../shared/modules/contract-utils';
import {
conversionUtil,
multiplyCurrencies,
} from '../../../shared/modules/conversion.utils';
import { ETH, GWEI } from '../../helpers/constants/common';
import { calcTokenAmount } from '../../helpers/utils/token-util';
import { MIN_GAS_LIMIT_HEX } from '../../pages/send/send.constants';
import {
addGasBuffer,
generateERC20TransferData,
generateERC721TransferData,
getAssetTransferData,
} from '../../pages/send/send.utils';
import { getGasPriceInHexWei } from '../../selectors';
import { estimateGas } from '../../store/actions';
export async function estimateGasLimitForSend({
selectedAddress,
value,
gasPrice,
sendToken,
to,
data,
isNonStandardEthChain,
chainId,
gasLimit,
...options
}) {
let isSimpleSendOnNonStandardNetwork = false;
// blockGasLimit may be a falsy, but defined, value when we receive it from
// state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. Some
// network implementations check the gas parameter supplied to
// eth_estimateGas for validity. For this reason, we set token sends
// blockGasLimit default to a higher number. Note that the current gasLimit
// on a BLOCK is 15,000,000 and will be 30,000,000 on mainnet after London.
// Meanwhile, MIN_GAS_LIMIT_HEX is 0x5208.
let blockGasLimit = MIN_GAS_LIMIT_HEX;
if (options.blockGasLimit) {
blockGasLimit = options.blockGasLimit;
} else if (sendToken) {
blockGasLimit = GAS_LIMITS.BASE_TOKEN_ESTIMATE;
}
// The parameters below will be sent to our background process to estimate
// how much gas will be used for a transaction. That background process is
// located in tx-gas-utils.js in the transaction controller folder.
const paramsForGasEstimate = { from: selectedAddress, value, gasPrice };
if (sendToken) {
if (!to) {
// If no to address is provided, we cannot generate the token transfer
// hexData. hexData in a transaction largely dictates how much gas will
// be consumed by a transaction. We must use our best guess, which is
// represented in the gas shared constants.
return GAS_LIMITS.BASE_TOKEN_ESTIMATE;
}
paramsForGasEstimate.value = '0x0';
// We have to generate the erc20/erc721 contract call to transfer tokens in
// order to get a proper estimate for gasLimit.
paramsForGasEstimate.data = getAssetTransferData({
sendToken,
fromAddress: selectedAddress,
toAddress: to,
amount: value,
});
paramsForGasEstimate.to = sendToken.address;
} else {
if (!data) {
// eth.getCode will return the compiled smart contract code at the
// address. If this returns 0x, 0x0 or a nullish value then the address
// is an externally owned account (NOT a contract account). For these
// types of transactions the gasLimit will always be 21,000 or 0x5208
const { isContractAddress } = to
? await readAddressAsContract(global.eth, to)
: {};
if (!isContractAddress && !isNonStandardEthChain) {
return GAS_LIMITS.SIMPLE;
} else if (!isContractAddress && isNonStandardEthChain) {
isSimpleSendOnNonStandardNetwork = true;
}
}
paramsForGasEstimate.data = data;
if (to) {
paramsForGasEstimate.to = to;
}
if (!value || value === '0') {
// TODO: Figure out what's going on here. According to eth_estimateGas
// docs this value can be zero, or undefined, yet we are setting it to a
// value here when the value is undefined or zero. For more context:
// https://github.com/MetaMask/metamask-extension/pull/6195
paramsForGasEstimate.value = '0xff';
}
}
if (!isSimpleSendOnNonStandardNetwork) {
// If we do not yet have a gasLimit, we must call into our background
// process to get an estimate for gasLimit based on known parameters.
paramsForGasEstimate.gas = addHexPrefix(
multiplyCurrencies(blockGasLimit, 0.95, {
multiplicandBase: 16,
multiplierBase: 10,
roundDown: '0',
toNumericBase: 'hex',
}),
);
}
// The buffer multipler reduces transaction failures by ensuring that the
// estimated gas is always sufficient. Without the multiplier, estimates
// for contract interactions can become inaccurate over time. This is because
// gas estimation is non-deterministic. The gas required for the exact same
// transaction call can change based on state of a contract or changes in the
// contracts environment (blockchain data or contracts it interacts with).
// Applying the 1.5 buffer has proven to be a useful guard against this non-
// deterministic behaviour.
//
// Gas estimation of simple sends should, however, be deterministic. As such
// no buffer is needed in those cases.
let bufferMultiplier = 1.5;
if (isSimpleSendOnNonStandardNetwork) {
bufferMultiplier = 1;
} else if (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]) {
bufferMultiplier = CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId];
}
try {
// Call into the background process that will simulate transaction
// execution on the node and return an estimate of gasLimit
const estimatedGasLimit = await estimateGas(paramsForGasEstimate);
const estimateWithBuffer = addGasBuffer(
estimatedGasLimit,
blockGasLimit,
bufferMultiplier,
);
return addHexPrefix(estimateWithBuffer);
} catch (error) {
const simulationFailed =
error.message.includes('Transaction execution error.') ||
error.message.includes(
'gas required exceeds allowance or always failing transaction',
) ||
(CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId] &&
error.message.includes('gas required exceeds allowance'));
if (simulationFailed) {
const estimateWithBuffer = addGasBuffer(
paramsForGasEstimate?.gas ?? gasLimit,
blockGasLimit,
bufferMultiplier,
);
return addHexPrefix(estimateWithBuffer);
}
throw error;
}
}
/**
* Generates a txParams from the send slice.
*
* @param {import('.').SendState} sendState - the state of the send slice
* @returns {import(
* '../../../shared/constants/transaction'
* ).TxParams} A txParams object that can be used to create a transaction or
* update an existing transaction.
*/
export function generateTransactionParams(sendState) {
const draftTransaction =
sendState.draftTransactions[sendState.currentTransactionUUID];
const txParams = {
// If the fromAccount has been specified we use that, if not we use the
// selected account.
from:
draftTransaction.fromAccount?.address ||
sendState.selectedAccount.address,
// gasLimit always needs to be set regardless of the asset being sent
// or the type of transaction.
gas: draftTransaction.gas.gasLimit,
};
switch (draftTransaction.asset.type) {
case ASSET_TYPES.TOKEN:
// When sending a token the to address is the contract address of
// the token being sent. The value is set to '0x0' and the data
// is generated from the recipient address, token being sent and
// amount.
txParams.to = draftTransaction.asset.details.address;
txParams.value = '0x0';
txParams.data = generateERC20TransferData({
toAddress: draftTransaction.recipient.address,
amount: draftTransaction.amount.value,
sendToken: draftTransaction.asset.details,
});
break;
case ASSET_TYPES.COLLECTIBLE:
// When sending a token the to address is the contract address of
// the token being sent. The value is set to '0x0' and the data
// is generated from the recipient address, token being sent and
// amount.
txParams.to = draftTransaction.asset.details.address;
txParams.value = '0x0';
txParams.data = generateERC721TransferData({
toAddress: draftTransaction.recipient.address,
fromAddress:
draftTransaction.fromAccount?.address ??
sendState.selectedAccount.address,
tokenId: draftTransaction.asset.details.tokenId,
});
break;
case ASSET_TYPES.NATIVE:
default:
// When sending native currency the to and value fields use the
// recipient and amount values and the data key is either null or
// populated with the user input provided in hex field.
txParams.to = draftTransaction.recipient.address;
txParams.value = draftTransaction.amount.value;
txParams.data = draftTransaction.userInputHexData ?? undefined;
}
// We need to make sure that we only include the right gas fee fields
// based on the type of transaction the network supports. We will also set
// the type param here.
if (sendState.eip1559support) {
txParams.type = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET;
txParams.maxFeePerGas = draftTransaction.gas.maxFeePerGas;
txParams.maxPriorityFeePerGas = draftTransaction.gas.maxPriorityFeePerGas;
if (!txParams.maxFeePerGas || txParams.maxFeePerGas === '0x0') {
txParams.maxFeePerGas = draftTransaction.gas.gasPrice;
}
if (
!txParams.maxPriorityFeePerGas ||
txParams.maxPriorityFeePerGas === '0x0'
) {
txParams.maxPriorityFeePerGas = txParams.maxFeePerGas;
}
} else {
txParams.gasPrice = draftTransaction.gas.gasPrice;
txParams.type = TRANSACTION_ENVELOPE_TYPES.LEGACY;
}
return txParams;
}
/**
* This method is used to keep the original logic from the gas.duck.js file
* after receiving a gasPrice from eth_gasPrice. First, the returned gasPrice
* was converted to GWEI, then it was converted to a Number, then in the send
* duck (here) we would use getGasPriceInHexWei to get back to hexWei. Now that
* we receive a GWEI estimate from the controller, we still need to do this
* weird conversion to get the proper rounding.
*
* @param {string} gasPriceEstimate
* @returns {string}
*/
export function getRoundedGasPrice(gasPriceEstimate) {
const gasPriceInDecGwei = conversionUtil(gasPriceEstimate, {
numberOfDecimals: 9,
toDenomination: GWEI,
fromNumericBase: 'dec',
toNumericBase: 'dec',
fromCurrency: ETH,
fromDenomination: GWEI,
});
const gasPriceAsNumber = Number(gasPriceInDecGwei);
return getGasPriceInHexWei(gasPriceAsNumber);
}
export async function getERC20Balance(token, accountAddress) {
const contract = global.eth.contract(abi).at(token.address);
const usersToken = (await contract.balanceOf(accountAddress)) ?? null;
if (!usersToken) {
return '0x0';
}
const amount = calcTokenAmount(
usersToken.balance.toString(),
token.decimals,
).toString(16);
return addHexPrefix(amount);
}

View File

@ -0,0 +1,163 @@
import { ethers } from 'ethers';
import { GAS_LIMITS } from '../../../shared/constants/gas';
import {
ASSET_TYPES,
TRANSACTION_ENVELOPE_TYPES,
} from '../../../shared/constants/transaction';
import { BURN_ADDRESS } from '../../../shared/modules/hexstring-utils';
import { getInitialSendStateWithExistingTxState } from '../../../test/jest/mocks';
import { TOKEN_STANDARDS } from '../../helpers/constants/common';
import {
generateERC20TransferData,
generateERC721TransferData,
} from '../../pages/send/send.utils';
import { generateTransactionParams } from './helpers';
describe('Send Slice Helpers', () => {
describe('generateTransactionParams', () => {
it('should generate a txParams for a token transfer', () => {
const tokenDetails = {
address: '0xToken',
symbol: 'SYMB',
decimals: 18,
};
const txParams = generateTransactionParams(
getInitialSendStateWithExistingTxState({
fromAccount: {
address: '0x00',
},
amount: {
value: '0x1',
},
asset: {
type: ASSET_TYPES.TOKEN,
balance: '0xaf',
details: tokenDetails,
},
recipient: {
address: BURN_ADDRESS,
},
}),
);
expect(txParams).toStrictEqual({
from: '0x00',
data: generateERC20TransferData({
toAddress: BURN_ADDRESS,
amount: '0x1',
sendToken: tokenDetails,
}),
to: '0xToken',
type: '0x0',
value: '0x0',
gas: '0x0',
gasPrice: '0x0',
});
});
it('should generate a txParams for a collectible transfer', () => {
const txParams = generateTransactionParams(
getInitialSendStateWithExistingTxState({
fromAccount: {
address: '0x00',
},
amount: {
value: '0x1',
},
asset: {
type: ASSET_TYPES.COLLECTIBLE,
balance: '0xaf',
details: {
address: '0xToken',
standard: TOKEN_STANDARDS.ERC721,
tokenId: ethers.BigNumber.from(15000).toString(),
},
},
recipient: {
address: BURN_ADDRESS,
},
}),
);
expect(txParams).toStrictEqual({
from: '0x00',
data: generateERC721TransferData({
toAddress: BURN_ADDRESS,
fromAddress: '0x00',
tokenId: ethers.BigNumber.from(15000).toString(),
}),
to: '0xToken',
type: '0x0',
value: '0x0',
gas: '0x0',
gasPrice: '0x0',
});
});
it('should generate a txParams for a native legacy transaction', () => {
const txParams = generateTransactionParams(
getInitialSendStateWithExistingTxState({
fromAccount: {
address: '0x00',
},
amount: {
value: '0x1',
},
asset: {
type: ASSET_TYPES.NATIVE,
balance: '0xaf',
details: null,
},
recipient: {
address: BURN_ADDRESS,
},
}),
);
expect(txParams).toStrictEqual({
from: '0x00',
data: undefined,
to: BURN_ADDRESS,
type: '0x0',
value: '0x1',
gas: '0x0',
gasPrice: '0x0',
});
});
it('should generate a txParams for a native fee market transaction', () => {
const txParams = generateTransactionParams({
...getInitialSendStateWithExistingTxState({
fromAccount: {
address: '0x00',
},
amount: {
value: '0x1',
},
asset: {
type: ASSET_TYPES.NATIVE,
balance: '0xaf',
details: null,
},
recipient: {
address: BURN_ADDRESS,
},
gas: {
maxFeePerGas: '0x2',
maxPriorityFeePerGas: '0x1',
gasLimit: GAS_LIMITS.SIMPLE,
},
transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET,
}),
eip1559support: true,
});
expect(txParams).toStrictEqual({
from: '0x00',
data: undefined,
to: BURN_ADDRESS,
type: '0x2',
value: '0x1',
gas: GAS_LIMITS.SIMPLE,
maxFeePerGas: '0x2',
maxPriorityFeePerGas: '0x1',
});
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,8 @@ const ALERTS_ROUTE = '/settings/alerts';
const NETWORKS_ROUTE = '/settings/networks';
const NETWORKS_FORM_ROUTE = '/settings/networks/form';
const ADD_NETWORK_ROUTE = '/settings/networks/add-network';
const ADD_POPULAR_CUSTOM_NETWORK =
'/settings/networks/add-popular-custom-network';
const SNAPS_LIST_ROUTE = '/settings/snaps-list';
const SNAPS_VIEW_ROUTE = '/settings/snaps-view';
const CONTACT_LIST_ROUTE = '/settings/contact-list';
@ -113,6 +115,8 @@ const PATH_NAME_MAP = {
[NETWORKS_ROUTE]: 'Network Settings Page',
[NETWORKS_FORM_ROUTE]: 'Network Settings Page Form',
[ADD_NETWORK_ROUTE]: 'Add Network From Settings Page Form',
[ADD_POPULAR_CUSTOM_NETWORK]:
'Add Network From A List Of Popular Custom Networks',
[CONTACT_LIST_ROUTE]: 'Contact List Settings Page',
[`${CONTACT_EDIT_ROUTE}/:address`]: 'Edit Contact Settings Page',
[CONTACT_ADD_ROUTE]: 'Add Contact Settings Page',
@ -224,6 +228,7 @@ export {
NETWORKS_ROUTE,
NETWORKS_FORM_ROUTE,
ADD_NETWORK_ROUTE,
ADD_POPULAR_CUSTOM_NETWORK,
INITIALIZE_BACKUP_SEED_PHRASE_ROUTE,
INITIALIZE_SEED_PHRASE_INTRO_ROUTE,
CONNECT_ROUTE,

View File

@ -346,4 +346,11 @@ export const SETTINGS_CONSTANTS = [
icon: 'fa fa-flask',
featureFlag: 'COLLECTIBLES_V1',
},
{
tabMessage: (t) => t('experimental'),
sectionMessage: (t) => t('showCustomNetworkList'),
descriptionMessage: (t) => t('showCustomNetworkListDescription'),
route: `${EXPERIMENTAL_ROUTE}#show-custom-network`,
icon: 'fa fa-flask',
},
];

View File

@ -195,7 +195,7 @@ describe('Settings Search Utils', () => {
it('should get good experimental section number', () => {
expect(getNumberOfSettingsInSection(t, t('experimental'))).toStrictEqual(
3,
4,
);
});

View File

@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { util } from '@metamask/controllers';
import { useI18nContext } from '../../hooks/useI18nContext';
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
import {
DISPLAY,
FONT_WEIGHT,
@ -55,9 +54,7 @@ export default function AddCollectible() {
const handleAddCollectible = async () => {
try {
await dispatch(
addCollectibleVerifyOwnership(address, tokenId.toString()),
);
await dispatch(addCollectibleVerifyOwnership(address, tokenId));
} catch (error) {
const { message } = error;
dispatch(setNewCollectibleAddedMessage(message));
@ -99,7 +96,7 @@ export default function AddCollectible() {
};
const validateAndSetTokenId = (val) => {
setDisabled(!util.isValidHexAddress(address) || !val);
setDisabled(!util.isValidHexAddress(address) || !val || isNaN(Number(val)));
setTokenId(val);
};
@ -149,7 +146,7 @@ export default function AddCollectible() {
)}
<Box margin={4}>
<FormField
id="address"
dataTestId="address"
titleText={t('address')}
placeholder="0x..."
value={address}
@ -161,7 +158,7 @@ export default function AddCollectible() {
autoFocus
/>
<FormField
id="token-id"
dataTestId="token-id"
titleText={t('tokenId')}
placeholder={t('nftTokenIdPlaceholder')}
value={tokenId}
@ -170,7 +167,6 @@ export default function AddCollectible() {
setCollectibleAddFailed(false);
}}
tooltipText={t('importNFTTokenIdToolTip')}
numeric
/>
</Box>
</Box>

View File

@ -0,0 +1,79 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../test/jest/rendering';
import * as Actions from '../../store/actions';
import AddCollectible from '.';
const VALID_ADDRESS = '0x312BE6a98441F9F6e3F6246B13CA19701e0AC3B9';
const INVALID_ADDRESS = 'aoinsafasdfa';
const VALID_TOKENID = '1201';
const INVALID_TOKENID = 'abcde';
describe('AddCollectible', () => {
const store = configureMockStore([])({
metamask: { provider: { chainId: '0x1' } },
});
it('should enable the "Add" button when valid entries are input into both Address and TokenId fields', () => {
const { getByTestId, getByText } = renderWithProvider(
<AddCollectible />,
store,
);
expect(getByText('Add')).not.toBeEnabled();
fireEvent.change(getByTestId('address'), {
target: { value: VALID_ADDRESS },
});
fireEvent.change(getByTestId('token-id'), {
target: { value: VALID_TOKENID },
});
expect(getByText('Add')).toBeEnabled();
});
it('should not enable the "Add" button when an invalid entry is input into one or both Address and TokenId fields', () => {
const { getByTestId, getByText } = renderWithProvider(
<AddCollectible />,
store,
);
expect(getByText('Add')).not.toBeEnabled();
fireEvent.change(getByTestId('address'), {
target: { value: INVALID_ADDRESS },
});
fireEvent.change(getByTestId('token-id'), {
target: { value: VALID_TOKENID },
});
expect(getByText('Add')).not.toBeEnabled();
fireEvent.change(getByTestId('address'), {
target: { value: VALID_ADDRESS },
});
expect(getByText('Add')).toBeEnabled();
fireEvent.change(getByTestId('token-id'), {
target: { value: INVALID_TOKENID },
});
expect(getByText('Add')).not.toBeEnabled();
});
it('should call addCollectibleVerifyOwnership action with correct values (tokenId should not be in scientific notation)', () => {
const { getByTestId, getByText } = renderWithProvider(
<AddCollectible />,
store,
);
fireEvent.change(getByTestId('address'), {
target: { value: VALID_ADDRESS },
});
const LARGE_TOKEN_ID = Number.MAX_SAFE_INTEGER + 1;
fireEvent.change(getByTestId('token-id'), {
target: { value: LARGE_TOKEN_ID },
});
const addCollectibleVerifyOwnershipSpy = jest.spyOn(
Actions,
'addCollectibleVerifyOwnership',
);
fireEvent.click(getByText('Add'));
expect(addCollectibleVerifyOwnershipSpy).toHaveBeenCalledWith(
'0x312BE6a98441F9F6e3F6246B13CA19701e0AC3B9',
'9007199254740992',
);
});
});

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { editTransaction } from '../../ducks/send';
import { editExistingTransaction } from '../../ducks/send';
import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck';
import { ASSET_TYPES } from '../../../shared/constants/transaction';
import ConfirmSendEther from './confirm-send-ether.component';
@ -20,7 +20,9 @@ const mapDispatchToProps = (dispatch) => {
return {
editTransaction: async (txData) => {
const { id } = txData;
await dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString()));
await dispatch(
editExistingTransaction(ASSET_TYPES.NATIVE, id.toString()),
);
dispatch(clearConfirmTransaction());
},
};

View File

@ -6,14 +6,15 @@ import { SEND_ROUTE } from '../../helpers/constants/routes';
export default class ConfirmSendToken extends Component {
static propTypes = {
history: PropTypes.object,
editTransaction: PropTypes.func,
editExistingTransaction: PropTypes.func,
tokenAmount: PropTypes.string,
};
handleEdit(confirmTransactionData) {
const { editTransaction, history } = this.props;
editTransaction(confirmTransactionData);
history.push(SEND_ROUTE);
const { editExistingTransaction, history } = this.props;
editExistingTransaction(confirmTransactionData).then(() => {
history.push(SEND_ROUTE);
});
}
render() {

View File

@ -3,7 +3,7 @@ import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck';
import { showSendTokenPage } from '../../store/actions';
import { editTransaction } from '../../ducks/send';
import { editExistingTransaction } from '../../ducks/send';
import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors';
import { ASSET_TYPES } from '../../../shared/constants/transaction';
import ConfirmSendToken from './confirm-send-token.component';
@ -18,18 +18,11 @@ const mapStateToProps = (state) => {
const mapDispatchToProps = (dispatch) => {
return {
editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => {
editExistingTransaction: async ({ txData }) => {
const { id } = txData;
dispatch(
editTransaction(
ASSET_TYPES.TOKEN,
id.toString(),
tokenData,
assetDetails,
),
);
dispatch(clearConfirmTransaction());
dispatch(showSendTokenPage());
await dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, id.toString()));
await dispatch(clearConfirmTransaction());
await dispatch(showSendTokenPage());
},
};
};

View File

@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import ConfirmTokenTransactionBase from '../confirm-token-transaction-base/confirm-token-transaction-base';
import { SEND_ROUTE } from '../../helpers/constants/routes';
import { editTransaction } from '../../ducks/send';
import { editExistingTransaction } from '../../ducks/send';
import {
contractExchangeRateSelector,
getCurrentCurrency,
@ -35,27 +35,17 @@ export default function ConfirmSendToken({
const dispatch = useDispatch();
const history = useHistory();
const handleEditTransaction = ({
txData,
tokenData,
tokenProps: assetDetails,
}) => {
const handleEditTransaction = async ({ txData }) => {
const { id } = txData;
dispatch(
editTransaction(
ASSET_TYPES.TOKEN,
id.toString(),
tokenData,
assetDetails,
),
);
await dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, id.toString()));
dispatch(clearConfirmTransaction());
dispatch(showSendTokenPage());
};
const handleEdit = (confirmTransactionData) => {
handleEditTransaction(confirmTransactionData);
history.push(SEND_ROUTE);
handleEditTransaction(confirmTransactionData).then(() => {
history.push(SEND_ROUTE);
});
};
const conversionRate = useSelector(getConversionRate);
const nativeCurrency = useSelector(getNativeCurrency);

View File

@ -25,6 +25,7 @@ import { getUnapprovedTemplatedConfirmations } from '../../selectors';
import NetworkDisplay from '../../components/app/network-display/network-display';
import Callout from '../../components/ui/callout';
import SiteOrigin from '../../components/ui/site-origin';
import { addCustomNetwork } from '../../store/actions';
import ConfirmationFooter from './components/confirmation-footer';
import { getTemplateValues, getTemplateAlerts } from './templates';
@ -130,6 +131,7 @@ export default function ConfirmationPage() {
const pendingConfirmation = pendingConfirmations[currentPendingConfirmation];
const originMetadata = useOriginMetadata(pendingConfirmation?.origin) || {};
const [alertState, dismissAlert] = useAlertState(pendingConfirmation);
const [stayOnPage, setStayOnPage] = useState(false);
// Generating templatedValues is potentially expensive, and if done on every render
// will result in a new object. Avoiding calling this generation unnecessarily will
@ -146,11 +148,11 @@ export default function ConfirmationPage() {
// confirmations reduces to a number that is less than the currently
// viewed index, reset the index.
if (pendingConfirmations.length === 0) {
history.push(DEFAULT_ROUTE);
!stayOnPage && history.push(DEFAULT_ROUTE);
} else if (pendingConfirmations.length <= currentPendingConfirmation) {
setCurrentPendingConfirmation(pendingConfirmations.length - 1);
}
}, [pendingConfirmations, history, currentPendingConfirmation]);
}, [pendingConfirmations, history, currentPendingConfirmation, stayOnPage]);
if (!pendingConfirmation) {
return null;
}
@ -197,23 +199,25 @@ export default function ConfirmationPage() {
/>
</Box>
) : null}
<Box
alignItems="center"
marginTop={1}
padding={[1, 4, 4]}
flexDirection={FLEX_DIRECTION.COLUMN}
>
<SiteIcon
icon={originMetadata.iconUrl}
name={originMetadata.hostname}
size={36}
/>
<SiteOrigin
chip
siteOrigin={stripHttpsScheme(originMetadata.origin)}
title={stripHttpsScheme(originMetadata.origin)}
/>
</Box>
{pendingConfirmation.origin === 'metamask' ? null : (
<Box
alignItems="center"
marginTop={1}
padding={[1, 4, 4]}
flexDirection={FLEX_DIRECTION.COLUMN}
>
<SiteIcon
icon={originMetadata.iconUrl}
name={originMetadata.hostname}
size={36}
/>
<SiteOrigin
chip
siteOrigin={stripHttpsScheme(originMetadata.origin)}
title={stripHttpsScheme(originMetadata.origin)}
/>
</Box>
)}
<MetaMaskTemplateRenderer sections={templatedValues.content} />
</div>
<ConfirmationFooter
@ -234,8 +238,15 @@ export default function ConfirmationPage() {
</Callout>
))
}
onApprove={templatedValues.onApprove}
onCancel={templatedValues.onCancel}
onApprove={() => {
templatedValues.onApprove.apply();
pendingConfirmation.origin === 'metamask' &&
dispatch(addCustomNetwork(pendingConfirmation.requestData));
}}
onCancel={() => {
templatedValues.onCancel.apply();
pendingConfirmation.origin === 'metamask' && setStayOnPage(true);
}}
approveText={templatedValues.approvalText}
cancelText={templatedValues.cancelText}
/>

View File

@ -1,7 +1,12 @@
import { ethErrors } from 'eth-rpc-errors';
import React from 'react';
import {
SEVERITIES,
TYPOGRAPHY,
TEXT_ALIGN,
JUSTIFY_CONTENT,
DISPLAY,
COLORS,
} from '../../../helpers/constants/design-system';
import fetchWithCache from '../../../helpers/utils/fetch-with-cache';
@ -79,6 +84,11 @@ async function getAlerts(pendingApproval) {
);
let validated = Boolean(matchedChain);
const originIsMetaMask = pendingApproval.origin === 'metamask';
if (originIsMetaMask && validated) {
return [];
}
if (matchedChain) {
if (
matchedChain.nativeCurrency?.decimals !== 18 ||
@ -104,12 +114,39 @@ async function getAlerts(pendingApproval) {
}
function getValues(pendingApproval, t, actions) {
const originIsMetaMask = pendingApproval.origin === 'metamask';
return {
content: [
{
hide: !originIsMetaMask,
element: 'Box',
key: 'network-box',
props: {
textAlign: TEXT_ALIGN.CENTER,
display: DISPLAY.FLEX,
justifyContent: JUSTIFY_CONTENT.CENTER,
marginTop: 4,
marginBottom: 2,
},
children: [
{
element: 'Chip',
key: 'network-chip',
props: {
label: pendingApproval.requestData.chainName,
backgroundColor: COLORS.BACKGROUND_ALTERNATIVE,
leftIconUrl: pendingApproval.requestData.imageUrl,
},
},
],
},
{
element: 'Typography',
key: 'title',
children: t('addEthereumChainConfirmationTitle'),
children: originIsMetaMask
? t('wantToAddThisNetwork')
: t('addEthereumChainConfirmationTitle'),
props: {
variant: TYPOGRAPHY.H3,
align: 'center',
@ -127,7 +164,7 @@ function getValues(pendingApproval, t, actions) {
variant: TYPOGRAPHY.H7,
align: 'center',
boxProps: {
margin: [0, 0, 4],
margin: originIsMetaMask ? [0, 8, 4] : [0, 0, 4],
},
},
},
@ -138,7 +175,55 @@ function getValues(pendingApproval, t, actions) {
{
element: 'b',
key: 'bolded-text',
children: `${t('addEthereumChainConfirmationRisks')} `,
props: {
style: { display: originIsMetaMask && '-webkit-box' },
},
children: [
`${t('addEthereumChainConfirmationRisks')} `,
{
hide: !originIsMetaMask,
element: 'Tooltip',
key: 'tooltip-info',
props: {
position: 'bottom',
interactive: true,
trigger: 'mouseenter',
html: (
<div
style={{
width: '180px',
margin: '16px',
textAlign: 'left',
}}
>
{t('someNetworksMayPoseSecurity')}{' '}
<a
key="zendesk_page_link"
href="https://metamask.zendesk.com/hc/en-us/articles/4417500466971"
rel="noreferrer"
target="_blank"
style={{ color: 'var(--color-primary-default)' }}
>
{t('learnMoreUpperCase')}
</a>
</div>
),
},
children: [
{
element: 'i',
key: 'info-circle',
props: {
className: 'fas fa-info-circle',
style: {
marginLeft: '4px',
color: 'var(--color-icon-default)',
},
},
},
],
},
],
},
{
element: 'MetaMaskTranslation',
@ -164,7 +249,7 @@ function getValues(pendingApproval, t, actions) {
variant: TYPOGRAPHY.H7,
align: 'center',
boxProps: {
margin: 0,
margin: originIsMetaMask ? [0, 8] : 0,
},
},
},
@ -205,7 +290,7 @@ function getValues(pendingApproval, t, actions) {
pendingApproval.id,
ethErrors.provider.userRejectedRequest().serialize(),
),
networkDisplay: true,
networkDisplay: !originIsMetaMask,
};
}

View File

@ -26,9 +26,7 @@ import {
TYPOGRAPHY,
FONT_WEIGHT,
DISPLAY,
///: BEGIN:ONLY_INCLUDE_IN(flask)
COLORS,
///: END:ONLY_INCLUDE_IN
} from '../../helpers/constants/design-system';
import {
@ -143,6 +141,9 @@ export default class Home extends PureComponent {
closeNotificationPopup: PropTypes.func.isRequired,
newTokensImported: PropTypes.string,
setNewTokensImported: PropTypes.func.isRequired,
newCustomNetworkAdded: PropTypes.object,
setNewCustomNetworkAdded: PropTypes.func,
setRpcTarget: PropTypes.func,
};
state = {
@ -280,6 +281,9 @@ export default class Home extends PureComponent {
setNewCollectibleAddedMessage,
newTokensImported,
setNewTokensImported,
newCustomNetworkAdded,
setNewCustomNetworkAdded,
setRpcTarget,
} = this.props;
return (
<MultipleNotifications>
@ -479,6 +483,53 @@ export default class Home extends PureComponent {
key="home-infuraBlockedNotification"
/>
) : null}
{Object.keys(newCustomNetworkAdded).length !== 0 && (
<Popover className="home__new-network-added">
<i className="fa fa-check-circle fa-2x home__new-network-added__check-circle" />
<Typography
variant={TYPOGRAPHY.H4}
margin={[5, 9, 0, 9]}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('networkAddedSuccessfully')}
</Typography>
<Box margin={[8, 8, 5, 8]}>
<Button
type="primary"
className="home__new-network-added__switch-to-button"
onClick={() => {
setRpcTarget(
newCustomNetworkAdded.rpcUrl,
newCustomNetworkAdded.chainId,
newCustomNetworkAdded.ticker,
newCustomNetworkAdded.chainName,
);
setNewCustomNetworkAdded();
}}
>
<Typography
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.NORMAL}
color={COLORS.PRIMARY_INVERSE}
>
{t('switchToNetwork', [newCustomNetworkAdded.chainName])}
</Typography>
</Button>
<Button
type="secondary"
onClick={() => setNewCustomNetworkAdded()}
>
<Typography
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.NORMAL}
color={COLORS.PRIMARY_DEFAULT}
>
{t('dismiss')}
</Typography>
</Button>
</Box>
</Popover>
)}
</MultipleNotifications>
);
}

View File

@ -37,11 +37,16 @@ import {
setNewNetworkAdded,
setNewCollectibleAddedMessage,
setNewTokensImported,
setRpcTarget,
///: BEGIN:ONLY_INCLUDE_IN(flask)
removeSnapError,
///: END:ONLY_INCLUDE_IN
} from '../../store/actions';
import { setThreeBoxLastUpdated, hideWhatsNewPopup } from '../../ducks/app/app';
import {
setThreeBoxLastUpdated,
hideWhatsNewPopup,
setNewCustomNetworkAdded,
} from '../../ducks/app/app';
import { getWeb3ShimUsageAlertEnabledness } from '../../ducks/metamask/metamask';
import { getSwapsFeatureIsLive } from '../../ducks/swaps/swaps';
import { getEnvironmentType } from '../../../app/scripts/lib/util';
@ -138,6 +143,7 @@ const mapStateToProps = (state) => {
isSigningQRHardwareTransaction,
newCollectibleAddedMessage: getNewCollectibleAddedMessage(state),
newTokensImported: getNewTokensImported(state),
newCustomNetworkAdded: appState.newCustomNetworkAdded,
};
};
@ -180,6 +186,12 @@ const mapDispatchToProps = (dispatch) => ({
setNewTokensImported: (newTokens) => {
dispatch(setNewTokensImported(newTokens));
},
setNewCustomNetworkAdded: () => {
dispatch(setNewCustomNetworkAdded({}));
},
setRpcTarget: (rpcUrl, chainId, ticker, nickname) => {
dispatch(setRpcTarget(rpcUrl, chainId, ticker, nickname));
},
});
export default compose(

View File

@ -207,4 +207,18 @@
margin-inline-start: 32px;
}
}
&__new-network-added {
border-radius: 10px;
text-align: center;
&__check-circle {
color: var(--color-success-default);
margin-top: 20px;
}
&__switch-to-button {
margin-bottom: 16px;
}
}
}

View File

@ -3,9 +3,13 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fireEvent } from '@testing-library/react';
import { initialState, SEND_STATUSES } from '../../../../../ducks/send';
import { AMOUNT_MODES, SEND_STATUSES } from '../../../../../ducks/send';
import { renderWithProvider } from '../../../../../../test/jest';
import { GAS_ESTIMATE_TYPES } from '../../../../../../shared/constants/gas';
import {
getInitialSendStateWithExistingTxState,
INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
} from '../../../../../../test/jest/mocks';
import AmountMaxButton from './amount-max-button';
const middleware = [thunk];
@ -22,7 +26,7 @@ describe('AmountMaxButton Component', () => {
EIPS: {},
},
},
send: initialState,
send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
}),
);
expect(getByText('Max')).toBeTruthy();
@ -36,12 +40,14 @@ describe('AmountMaxButton Component', () => {
EIPS: {},
},
},
send: { ...initialState, status: SEND_STATUSES.VALID },
send: getInitialSendStateWithExistingTxState({
status: SEND_STATUSES.VALID,
}),
});
const { getByText } = renderWithProvider(<AmountMaxButton />, store);
const expectedActions = [
{ type: 'send/updateAmountMode', payload: 'MAX' },
{ type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX },
];
fireEvent.click(getByText('Max'), { bubbles: true });
@ -58,9 +64,10 @@ describe('AmountMaxButton Component', () => {
},
},
send: {
...initialState,
status: SEND_STATUSES.VALID,
amount: { ...initialState.amount, mode: 'MAX' },
...getInitialSendStateWithExistingTxState({
status: SEND_STATUSES.VALID,
}),
amountMode: AMOUNT_MODES.MAX,
},
});
const { getByText } = renderWithProvider(<AmountMaxButton />, store);

View File

@ -3,9 +3,13 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fireEvent } from '@testing-library/react';
import { initialState, SEND_STAGES } from '../../../ducks/send';
import { SEND_STAGES } from '../../../ducks/send';
import { renderWithProvider } from '../../../../test/jest';
import { ASSET_TYPES } from '../../../../shared/constants/transaction';
import {
getInitialSendStateWithExistingTxState,
INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
} from '../../../../test/jest/mocks';
import SendHeader from './send-header.component';
const middleware = [thunk];
@ -26,7 +30,7 @@ describe('SendHeader Component', () => {
const { getByText, rerender } = renderWithProvider(
<SendHeader />,
configureMockStore(middleware)({
send: initialState,
send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
}),
@ -35,7 +39,10 @@ describe('SendHeader Component', () => {
rerender(
<SendHeader />,
configureMockStore(middleware)({
send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT },
send: {
...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
stage: SEND_STAGES.ADD_RECIPIENT,
},
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
}),
@ -48,9 +55,12 @@ describe('SendHeader Component', () => {
<SendHeader />,
configureMockStore(middleware)({
send: {
...initialState,
...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
stage: SEND_STAGES.DRAFT,
asset: { ...initialState.asset, type: ASSET_TYPES.NATIVE },
asset: {
...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT.asset,
type: ASSET_TYPES.NATIVE,
},
},
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
@ -64,9 +74,12 @@ describe('SendHeader Component', () => {
<SendHeader />,
configureMockStore(middleware)({
send: {
...initialState,
...getInitialSendStateWithExistingTxState({
asset: {
type: ASSET_TYPES.TOKEN,
},
}),
stage: SEND_STAGES.DRAFT,
asset: { ...initialState.asset, type: ASSET_TYPES.TOKEN },
},
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
@ -80,7 +93,7 @@ describe('SendHeader Component', () => {
<SendHeader />,
configureMockStore(middleware)({
send: {
...initialState,
...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
stage: SEND_STAGES.EDIT,
},
gas: { basicEstimateStatus: 'LOADING' },
@ -96,7 +109,7 @@ describe('SendHeader Component', () => {
const { getByText } = renderWithProvider(
<SendHeader />,
configureMockStore(middleware)({
send: initialState,
send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
}),
@ -108,7 +121,10 @@ describe('SendHeader Component', () => {
const { getByText } = renderWithProvider(
<SendHeader />,
configureMockStore(middleware)({
send: { ...initialState, stage: SEND_STAGES.EDIT },
send: {
...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
stage: SEND_STAGES.EDIT,
},
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
}),
@ -118,7 +134,7 @@ describe('SendHeader Component', () => {
it('resets send state when clicked', () => {
const store = configureMockStore(middleware)({
send: initialState,
send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
gas: { basicEstimateStatus: 'LOADING' },
history: { mostRecentOverviewPage: 'activity' },
});

View File

@ -7,14 +7,13 @@ import {
getRecipient,
getRecipientUserInput,
getSendStage,
initializeSendState,
resetRecipientInput,
resetSendState,
SEND_STAGES,
updateRecipient,
updateRecipientUserInput,
} from '../../ducks/send';
import { getCurrentChainId, isCustomPriceExcessive } from '../../selectors';
import { isCustomPriceExcessive } from '../../selectors';
import { getSendHexDataFeatureFlagState } from '../../ducks/metamask/metamask';
import { showQrScanner } from '../../store/actions';
import { MetaMetricsContext } from '../../contexts/metametrics';
@ -30,7 +29,6 @@ const sendSliceIsCustomPriceExcessive = (state) =>
export default function SendTransactionScreen() {
const history = useHistory();
const chainId = useSelector(getCurrentChainId);
const stage = useSelector(getSendStage);
const gasIsExcessive = useSelector(sendSliceIsCustomPriceExcessive);
const isUsingMyAccountsForRecipientSearch = useSelector(
@ -49,11 +47,8 @@ export default function SendTransactionScreen() {
}, [dispatch]);
useEffect(() => {
if (chainId !== undefined) {
dispatch(initializeSendState());
window.addEventListener('beforeunload', cleanup);
}
}, [chainId, dispatch, cleanup]);
window.addEventListener('beforeunload', cleanup);
}, [cleanup]);
useEffect(() => {
if (location.search === '?scan=true') {

View File

@ -3,11 +3,12 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { useLocation } from 'react-router-dom';
import { initialState, SEND_STAGES } from '../../ducks/send';
import { SEND_STAGES } from '../../ducks/send';
import { ensInitialState } from '../../ducks/ens';
import { renderWithProvider } from '../../../test/jest';
import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network';
import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas';
import { INITIAL_SEND_STATE_FOR_EXISTING_DRAFT } from '../../../test/jest/mocks';
import Send from './send';
const middleware = [thunk];
@ -34,7 +35,7 @@ jest.mock(
);
const baseStore = {
send: initialState,
send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT,
ENS: ensInitialState,
gas: {
customData: { limit: null, price: null },
@ -87,7 +88,7 @@ const baseStore = {
describe('Send Page', () => {
describe('Send Flow Initialization', () => {
it('should initialize the send, ENS, and gas slices on render', () => {
it('should initialize the ENS slice on render', () => {
const store = configureMockStore(middleware)(baseStore);
renderWithProvider(<Send />, store);
const actions = store.getActions();
@ -96,9 +97,6 @@ describe('Send Page', () => {
expect.objectContaining({
type: 'ENS/enableEnsLookup',
}),
expect.objectContaining({
type: 'send/initializeSendState/pending',
}),
]),
);
});
@ -113,9 +111,6 @@ describe('Send Page', () => {
expect.objectContaining({
type: 'ENS/enableEnsLookup',
}),
expect.objectContaining({
type: 'send/initializeSendState/pending',
}),
expect.objectContaining({
type: 'UI_MODAL_OPEN',
payload: { name: 'QR_SCANNER' },

View File

@ -26,6 +26,8 @@ export default class ExperimentalTab extends PureComponent {
setEIP1559V2Enabled: PropTypes.func,
theme: PropTypes.string,
setTheme: PropTypes.func,
customNetworkListEnabled: PropTypes.bool,
setCustomNetworkListEnabled: PropTypes.func,
};
settingsRefs = Array(
@ -284,6 +286,45 @@ export default class ExperimentalTab extends PureComponent {
);
}
renderCustomNetworkListToggle() {
const { t } = this.context;
const {
customNetworkListEnabled,
setCustomNetworkListEnabled,
} = this.props;
return (
<div ref={this.settingsRefs[5]} className="settings-page__content-row">
<div className="settings-page__content-item">
<span>{t('showCustomNetworkList')}</span>
<div className="settings-page__content-description">
{t('showCustomNetworkListDescription')}
</div>
</div>
<div className="settings-page__content-item">
<div className="settings-page__content-item-col">
<ToggleButton
value={customNetworkListEnabled}
onToggle={(value) => {
this.context.trackEvent({
category: EVENT.CATEGORIES.SETTINGS,
event: 'Enabled/Disable CustomNetworkList',
properties: {
action: 'Enabled/Disable CustomNetworkList',
legacy_event: true,
},
});
setCustomNetworkListEnabled(!value);
}}
offLabel={t('off')}
onLabel={t('on')}
/>
</div>
</div>
</div>
);
}
render() {
return (
<div className="settings-page__body">
@ -295,6 +336,7 @@ export default class ExperimentalTab extends PureComponent {
{this.renderCollectibleDetectionToggle()}
{this.renderEIP1559V2EnabledToggle()}
{this.renderTheme()}
{this.renderCustomNetworkListToggle()}
</div>
);
}

View File

@ -7,6 +7,7 @@ import {
setOpenSeaEnabled,
setEIP1559V2Enabled,
setTheme,
setCustomNetworkListEnabled,
} from '../../../store/actions';
import {
getUseTokenDetection,
@ -14,6 +15,7 @@ import {
getOpenSeaEnabled,
getEIP1559V2Enabled,
getTheme,
getIsCustomNetworkListEnabled,
} from '../../../selectors';
import ExperimentalTab from './experimental-tab.component';
@ -26,6 +28,7 @@ const mapStateToProps = (state) => {
openSeaEnabled: getOpenSeaEnabled(state),
eip1559V2Enabled: getEIP1559V2Enabled(state),
theme: getTheme(state),
customNetworkListEnabled: getIsCustomNetworkListEnabled(state),
};
};
@ -40,6 +43,8 @@ const mapDispatchToProps = (dispatch) => {
setOpenSeaEnabled: (val) => dispatch(setOpenSeaEnabled(val)),
setEIP1559V2Enabled: (val) => dispatch(setEIP1559V2Enabled(val)),
setTheme: (val) => dispatch(setTheme(val)),
setCustomNetworkListEnabled: (val) =>
dispatch(setCustomNetworkListEnabled(val)),
};
};

View File

@ -522,7 +522,6 @@ const NetworksForm = ({
onConfirm: () => {
resetForm();
dispatch(setSelectedSettingsRpcUrl(''));
history.push(NETWORKS_ROUTE);
},
}),
);

View File

@ -1,18 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import { ADD_NETWORK_ROUTE } from '../../../../helpers/constants/routes';
import {
ADD_NETWORK_ROUTE,
ADD_POPULAR_CUSTOM_NETWORK,
} from '../../../../helpers/constants/routes';
import Button from '../../../../components/ui/button';
import { getIsCustomNetworkListEnabled } from '../../../../selectors';
const NetworksFormSubheader = ({ addNewNetwork }) => {
const t = useI18nContext();
const history = useHistory();
const addPopularNetworkFeatureToggledOn = useSelector(
getIsCustomNetworkListEnabled,
);
return addNewNetwork ? (
<div className="networks-tab__subheader">
<span className="networks-tab__sub-header-text">{t('networks')}</span>
<span className="networks-tab__sub-header-text">{' > '}</span>
<div className="networks-tab__sub-header-text">{t('addANetwork')}</div>
<span>{' > '}</span>
<div className="networks-tab__subheader--break">{t('addANetwork')}</div>
<div className="networks-tab__subheader--break">
{t('addANetworkManually')}
</div>
</div>
) : (
<div className="settings-page__sub-header">
@ -22,7 +35,9 @@ const NetworksFormSubheader = ({ addNewNetwork }) => {
type="primary"
onClick={(event) => {
event.preventDefault();
history.push(ADD_NETWORK_ROUTE);
addPopularNetworkFeatureToggledOn
? history.push(ADD_POPULAR_CUSTOM_NETWORK)
: history.push(ADD_NETWORK_ROUTE);
}}
>
{t('addANetwork')}

View File

@ -1,5 +1,6 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import { waitFor } from '@testing-library/react';
import { renderWithProvider } from '../../../../../test/jest/rendering';
import NetworksTabSubheader from '.';
@ -36,11 +37,11 @@ describe('NetworksTabSubheader Component', () => {
expect(getByRole('button', { text: 'Add a network' })).toBeDefined();
});
it('should render add network form subheader correctly', () => {
const { queryByText } = renderComponent({
const { queryByText, getAllByText } = renderComponent({
addNewNetwork: true,
});
expect(queryByText('Networks')).toBeInTheDocument();
expect(queryByText('>')).toBeInTheDocument();
waitFor(() => expect(getAllByText('>')).toBeInTheDocument());
expect(queryByText('Add a network')).toBeInTheDocument();
});
});

View File

@ -1,11 +1,12 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useLocation } from 'react-router-dom';
import { useLocation, useHistory } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
ADD_NETWORK_ROUTE,
ADD_POPULAR_CUSTOM_NETWORK,
NETWORKS_FORM_ROUTE,
} from '../../../helpers/constants/routes';
import { setSelectedSettingsRpcUrl } from '../../../store/actions';
@ -14,6 +15,7 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app';
import {
getFrequentRpcListDetail,
getIsCustomNetworkListEnabled,
getNetworksTabSelectedRpcUrl,
getProvider,
} from '../../../selectors';
@ -36,6 +38,7 @@ const NetworksTab = ({ addNewNetwork }) => {
const t = useI18nContext();
const dispatch = useDispatch();
const { pathname } = useLocation();
const history = useHistory();
const environmentType = getEnvironmentType();
const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN;
@ -45,6 +48,9 @@ const NetworksTab = ({ addNewNetwork }) => {
const frequentRpcListDetail = useSelector(getFrequentRpcListDetail);
const provider = useSelector(getProvider);
const networksTabSelectedRpcUrl = useSelector(getNetworksTabSelectedRpcUrl);
const addPopularNetworkFeatureToggledOn = useSelector(
getIsCustomNetworkListEnabled,
);
const frequentRpcNetworkListDetails = frequentRpcListDetail.map((rpc) => {
return {
@ -118,9 +124,16 @@ const NetworksTab = ({ addNewNetwork }) => {
<div className="networks-tab__networks-list-popup-footer">
<Button
type="primary"
onClick={(event) => {
event.preventDefault();
global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE);
onClick={() => {
if (addPopularNetworkFeatureToggledOn) {
history.push(ADD_POPULAR_CUSTOM_NETWORK);
} else {
isFullScreen
? history.push(ADD_NETWORK_ROUTE)
: global.platform.openExtensionInBrowser(
ADD_NETWORK_ROUTE,
);
}
}}
>
{t('addNetwork')}

View File

@ -23,9 +23,11 @@ import {
CONTACT_VIEW_ROUTE,
EXPERIMENTAL_ROUTE,
ADD_NETWORK_ROUTE,
ADD_POPULAR_CUSTOM_NETWORK,
} from '../../helpers/constants/routes';
import { getSettingsRoutes } from '../../helpers/utils/settings-search';
import AddNetwork from '../../components/app/add-network/add-network';
import SettingsTab from './settings-tab';
import AlertsTab from './alerts-tab';
import NetworksTab from './networks-tab';
@ -124,7 +126,6 @@ class SettingsPage extends PureComponent {
)}
{this.renderTitle()}
<div
className="settings-page__header__title-container__close-button"
onClick={() => {
@ -343,9 +344,15 @@ class SettingsPage extends PureComponent {
render={() => <NetworksTab addNewNetwork />}
/>
<Route
exact
path={NETWORKS_ROUTE}
render={() => <NetworksTab addNewNetwork={false} />}
/>
<Route
exact
path={ADD_POPULAR_CUSTOM_NETWORK}
render={() => <AddNetwork />}
/>
<Route exact path={SECURITY_ROUTE} component={SecurityTab} />
<Route exact path={EXPERIMENTAL_ROUTE} component={ExperimentalTab} />
<Route exact path={CONTACT_LIST_ROUTE} component={ContactListTab} />

View File

@ -27,6 +27,7 @@ import {
ADD_NETWORK_ROUTE,
SNAPS_LIST_ROUTE,
SNAPS_VIEW_ROUTE,
ADD_POPULAR_CUSTOM_NETWORK,
} from '../../helpers/constants/routes';
import Settings from './settings.component';
@ -46,6 +47,7 @@ const ROUTES_TO_I18N_KEYS = {
[ADD_NETWORK_ROUTE]: 'networks',
[SECURITY_ROUTE]: 'securityAndPrivacy',
[EXPERIMENTAL_ROUTE]: 'experimental',
[ADD_POPULAR_CUSTOM_NETWORK]: 'addNetwork',
};
const mapStateToProps = (state, ownProps) => {
@ -64,6 +66,9 @@ const mapStateToProps = (state, ownProps) => {
Boolean(pathname.match(NETWORKS_FORM_ROUTE)) ||
Boolean(pathname.match(ADD_NETWORK_ROUTE));
const addNewNetwork = Boolean(pathname.match(ADD_NETWORK_ROUTE));
const isAddPopularCustomNetwork = Boolean(
pathname.match(ADD_POPULAR_CUSTOM_NETWORK),
);
const isPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP;
const pathnameI18nKey = ROUTES_TO_I18N_KEYS[pathname];
@ -77,6 +82,8 @@ const mapStateToProps = (state, ownProps) => {
backRoute = NETWORKS_ROUTE;
} else if (isSnapViewPage) {
backRoute = SNAPS_LIST_ROUTE;
} else if (isAddPopularCustomNetwork) {
backRoute = NETWORKS_ROUTE;
}
let initialBreadCrumbRoute;

View File

@ -8,7 +8,7 @@ import { decEthToConvertedCurrency as ethTotalToConvertedCurrency } from '../hel
import { formatETHFee } from '../helpers/utils/formatters';
import { calcGasTotal } from '../pages/send/send.utils';
import { getGasPrice } from '../ducks/send';
import { getGasLimit, getGasPrice } from '../ducks/send';
import {
GAS_ESTIMATE_TYPES as GAS_FEE_CONTROLLER_ESTIMATE_TYPES,
GAS_LIMITS,
@ -321,8 +321,9 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) {
return [];
}
const showFiat = getShouldShowFiat(state);
const gasLimit =
state.send.gas.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE;
getGasLimit(state) ?? getCustomGasLimit(state) ?? GAS_LIMITS.SIMPLE;
const { conversionRate } = state.metamask;
const currentCurrency = getCurrentCurrency(state);
const gasFeeEstimates = getGasFeeEstimates(state);

View File

@ -1,4 +1,5 @@
import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../shared/constants/gas';
import { getInitialSendStateWithExistingTxState } from '../../test/jest/mocks';
import {
getCustomGasLimit,
getCustomGasPrice,
@ -11,7 +12,9 @@ import {
describe('custom-gas selectors', () => {
describe('getCustomGasPrice()', () => {
it('should return gas.customData.price', () => {
const mockState = { gas: { customData: { price: 'mockPrice' } } };
const mockState = {
gas: { customData: { price: 'mockPrice' } },
};
expect(getCustomGasPrice(mockState)).toStrictEqual('mockPrice');
});
});
@ -200,11 +203,11 @@ describe('custom-gas selectors', () => {
EIPS: {},
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasPrice: '0x28bed0160',
},
},
}),
gas: {
customData: { price: null },
},
@ -222,11 +225,11 @@ describe('custom-gas selectors', () => {
EIPS: {},
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasPrice: '0x30e4f9b400',
},
},
}),
gas: {
customData: { price: null },
},
@ -330,11 +333,11 @@ describe('custom-gas selectors', () => {
chainId: '0x1',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -379,11 +382,11 @@ describe('custom-gas selectors', () => {
chainId: '0x4',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -428,11 +431,11 @@ describe('custom-gas selectors', () => {
chainId: '0x4',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -477,11 +480,11 @@ describe('custom-gas selectors', () => {
chainId: '0x1',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
];
@ -542,11 +545,11 @@ describe('custom-gas selectors', () => {
chainId: '0x1',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -591,11 +594,11 @@ describe('custom-gas selectors', () => {
chainId: '0x1',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -640,11 +643,11 @@ describe('custom-gas selectors', () => {
chainId: '0x4',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -689,11 +692,11 @@ describe('custom-gas selectors', () => {
chainId: '0x4',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
{
@ -738,11 +741,11 @@ describe('custom-gas selectors', () => {
chainId: '0x1',
},
},
send: {
send: getInitialSendStateWithExistingTxState({
gas: {
gasLimit: GAS_LIMITS.SIMPLE,
},
},
}),
},
},
];

View File

@ -1062,3 +1062,13 @@ export function getDetectedTokensInCurrentNetwork(state) {
export function getNewTokensImported(state) {
return state.appState.newTokensImported;
}
/**
* To get the `customNetworkListEnabled` value which determines whether we use the custom network list
*
* @param {*} state
* @returns Boolean
*/
export function getIsCustomNetworkListEnabled(state) {
return state.metamask.customNetworkListEnabled;
}

View File

@ -92,6 +92,7 @@ export const SET_SELECTED_SETTINGS_RPC_URL = 'SET_SELECTED_SETTINGS_RPC_URL';
export const SET_NEW_NETWORK_ADDED = 'SET_NEW_NETWORK_ADDED';
export const SET_NEW_COLLECTIBLE_ADDED_MESSAGE =
'SET_NEW_COLLECTIBLE_ADDED_MESSAGE';
export const SET_NEW_CUSTOM_NETWORK_ADDED = 'SET_NEW_CUSTOM_NETWORK_ADDED';
export const LOADING_METHOD_DATA_STARTED = 'LOADING_METHOD_DATA_STARTED';
export const LOADING_METHOD_DATA_FINISHED = 'LOADING_METHOD_DATA_FINISHED';

View File

@ -27,7 +27,11 @@ import {
getNotifications,
///: END:ONLY_INCLUDE_IN
} from '../selectors';
import { computeEstimatedGasLimit, resetSendState } from '../ducks/send';
import {
computeEstimatedGasLimit,
initializeSendState,
resetSendState,
} from '../ducks/send';
import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account';
import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask';
import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils';
@ -42,6 +46,7 @@ import { isEqualCaseInsensitive } from '../../shared/modules/string-utils';
///: BEGIN:ONLY_INCLUDE_IN(flask)
import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notifications';
///: END:ONLY_INCLUDE_IN
import { setNewCustomNetworkAdded } from '../ducks/app/app';
import * as actionConstants from './actionConstants';
let background = null;
@ -1442,6 +1447,11 @@ export function updateMetamaskState(newState) {
type: actionConstants.CHAIN_CHANGED,
payload: newProvider.chainId,
});
// We dispatch this action to ensure that the send state stays up to date
// after the chain changes. This async thunk will fail gracefully in the
// event that we are not yet on the send flow with a draftTransaction in
// progress.
dispatch(initializeSendState({ chainHasChanged: true }));
}
dispatch({
type: actionConstants.UPDATE_METAMASK_STATE,
@ -3728,6 +3738,18 @@ export function setEnableEIP1559V2NoticeDismissed() {
return promisifiedBackground.setEnableEIP1559V2NoticeDismissed(true);
}
export function setCustomNetworkListEnabled(customNetworkListEnabled) {
return async () => {
try {
await promisifiedBackground.setCustomNetworkListEnabled(
customNetworkListEnabled,
);
} catch (error) {
log.error(error);
}
};
}
// QR Hardware Wallets
export async function submitQRHardwareCryptoHDKey(cbor) {
await promisifiedBackground.submitQRHardwareCryptoHDKey(cbor);
@ -3754,3 +3776,29 @@ export function cancelQRHardwareSignRequest() {
await promisifiedBackground.cancelQRHardwareSignRequest();
};
}
export function addCustomNetwork(customRpc) {
return async (dispatch) => {
try {
dispatch(setNewCustomNetworkAdded(customRpc));
await promisifiedBackground.addCustomNetwork(customRpc);
} catch (error) {
log.error(error);
dispatch(displayWarning('Had a problem changing networks!'));
}
};
}
export function requestUserApproval(customRpc, originIsMetaMask) {
return async (dispatch) => {
try {
await promisifiedBackground.requestUserApproval(
customRpc,
originIsMetaMask,
);
} catch (error) {
log.error(error);
dispatch(displayWarning('Had a problem changing networks!'));
}
};
}

View File

@ -2895,9 +2895,9 @@
web3-provider-engine "^16.0.3"
"@metamask/design-tokens@^1.6.0", "@metamask/design-tokens@^1.6.5":
version "1.6.5"
resolved "https://registry.yarnpkg.com/@metamask/design-tokens/-/design-tokens-1.6.5.tgz#e585b67f73ce301e0218d98ba89e079f7e81c412"
integrity sha512-5eCrUHXrIivXX1xx6kwNtM9s/ejhrPYSATSniFc7YKS9z+TkCK4/n52owOBnDIbrL8W3XxQIiaaqQAM+NQad4w==
version "1.7.0"
resolved "https://registry.yarnpkg.com/@metamask/design-tokens/-/design-tokens-1.7.0.tgz#fab069c0101da9e25d35ae051df2ff6bb5ff7a38"
integrity sha512-ejakgcsnTlLQmMrKb8XixXgExsYuMjlv71lkqJXeT0wa2oe4skVhB2dZul7Y9W4vYvQzTkwsW2NLfaj273eeEw==
"@metamask/eslint-config-jest@^9.0.0":
version "9.0.0"
@ -8017,10 +8017,10 @@ chrome-trace-event@^1.0.2:
dependencies:
tslib "^1.9.0"
chromedriver@^102.0.0:
version "102.0.0"
resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-102.0.0.tgz#02844c39ee33d1e88ac8c48fbe28cb8423e970a4"
integrity sha512-xer/0g1Oarkjc2e+4nyoLgZT4kJHYhcj3PcxD1nEoGJQYEllTjprN1uDpSb4BkgMGo0ydfIS1VDkszrr/J9OOg==
chromedriver@^103.0.0:
version "103.0.0"
resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-103.0.0.tgz#2ef086d62076e3ff6df6cfb84895d11d2c18d9cf"
integrity sha512-7BHf6HWt0PeOHCzWO8qlnD13sARzr5AKTtG8Csn+czsuAsajwPxdLNtry5GPh8HYFyl+i0M+yg3bT43AGfgU9w==
dependencies:
"@testim/chrome-version" "^1.1.2"
axios "^0.27.2"