diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 20b058e08..beffe06bc 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -369,6 +369,9 @@
"assets": {
"message": "Assets"
},
+ "attemptSendingAssets": {
+ "message": "If you attempt to send assets directly from one network to another, this may result in permanent asset loss. Make sure to use a bridge."
+ },
"attemptToCancel": {
"message": "Attempt to cancel?"
},
@@ -583,6 +586,9 @@
"message": "Click here to connect your Ledger via WebHID",
"description": "Text that can be clicked to open a browser popup for connecting the ledger device via webhid"
},
+ "clickToManuallyAdd": {
+ "message": "Click here to manually add the tokens."
+ },
"clickToRevealSeed": {
"message": "Click here to reveal secret words"
},
@@ -2041,6 +2047,10 @@
"name": {
"message": "Name"
},
+ "nativeToken": {
+ "message": "The native token on this network is $1. It is the token used for gas fees.",
+ "description": "$1 represents the name of the native token on the current network"
+ },
"needCryptoInWallet": {
"message": "To interact with decentralized applications using MetaMask, you’ll need $1 in your wallet.",
"description": "$1 represents the cypto symbol to be purchased"
@@ -3762,6 +3772,9 @@
"switchToThisAccount": {
"message": "Switch to this account"
},
+ "switchedTo": {
+ "message": "You have switched to"
+ },
"switchingNetworksCancelsPendingConfirmations": {
"message": "Switching networks will cancel all pending confirmations"
},
@@ -3828,6 +3841,9 @@
"themeDescription": {
"message": "Choose your preferred MetaMask theme."
},
+ "thingsToKeep": {
+ "message": "Things to keep in mind:"
+ },
"thisWillCreate": {
"message": "This will create a new wallet and Secret Recovery Phrase"
},
@@ -3890,6 +3906,9 @@
"tokenScamSecurityRisk": {
"message": "token scams and security risks"
},
+ "tokenShowUp": {
+ "message": "Your tokens may not automatically show up in your wallet."
+ },
"tokenSymbol": {
"message": "Token symbol"
},
diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js
index 5bf237e31..4dee8a8c3 100644
--- a/app/scripts/controllers/app-state.js
+++ b/app/scripts/controllers/app-state.js
@@ -37,6 +37,14 @@ export default class AppStateController extends EventEmitter {
...initState,
qrHardware: {},
collectiblesDropdownState: {},
+ usedNetworks: {
+ '0x1': true,
+ '0x2a': true,
+ '0x3': true,
+ '0x4': true,
+ '0x5': true,
+ '0x539': true,
+ },
});
this.timer = null;
@@ -294,4 +302,18 @@ export default class AppStateController extends EventEmitter {
collectiblesDropdownState,
});
}
+
+ /**
+ * Updates the array of the first time used networks
+ *
+ * @param chainId
+ * @returns {void}
+ */
+ setFirstTimeUsedNetwork(chainId) {
+ const currentState = this.store.getState();
+ const { usedNetworks } = currentState;
+ usedNetworks[chainId] = true;
+
+ this.store.updateState({ usedNetworks });
+ }
}
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index ddf3c20f5..1a915ac78 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -1739,6 +1739,8 @@ export default class MetamaskController extends EventEmitter {
appStateController.updateCollectibleDropDownState.bind(
appStateController,
),
+ setFirstTimeUsedNetwork:
+ appStateController.setFirstTimeUsedNetwork.bind(appStateController),
// EnsController
tryReverseResolveAddress:
ensController.reverseResolveAddress.bind(ensController),
diff --git a/shared/constants/tokens.js b/shared/constants/tokens.js
index 568d89b07..c846fff18 100644
--- a/shared/constants/tokens.js
+++ b/shared/constants/tokens.js
@@ -36,3 +36,6 @@ export const STATIC_MAINNET_TOKEN_LIST = Object.keys(contractMap).reduce(
},
{},
);
+
+export const TOKEN_API_METASWAP_CODEFI_URL =
+ 'https://token-api.metaswap.codefi.network/tokens/';
diff --git a/ui/components/ui/new-network-info/index.js b/ui/components/ui/new-network-info/index.js
new file mode 100644
index 000000000..e4bd2dcdb
--- /dev/null
+++ b/ui/components/ui/new-network-info/index.js
@@ -0,0 +1 @@
+export { default } from './new-network-info';
diff --git a/ui/components/ui/new-network-info/index.scss b/ui/components/ui/new-network-info/index.scss
new file mode 100644
index 000000000..6dafff60f
--- /dev/null
+++ b/ui/components/ui/new-network-info/index.scss
@@ -0,0 +1,48 @@
+.new-network-info {
+ &__wrapper {
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.214);
+ border-radius: 8px;
+
+ .popover-footer {
+ border-top: none;
+ }
+
+ .popover-header {
+ padding-bottom: 1px;
+ }
+
+ .fa-question-circle,
+ .popover-header__button {
+ color: var(--color-icon-default);
+ }
+ }
+
+ &__token-box {
+ align-self: center;
+ margin-top: 8px;
+ max-width: 245px;
+ }
+
+ &__bullet-paragraph {
+ border-bottom: 1px solid var(--color-border-default);
+ }
+
+ &__token-show-up {
+ display: inline;
+ }
+
+ &__button {
+ display: initial;
+ padding: 0;
+ }
+
+ &__manually-add-tokens {
+ display: inline;
+ }
+}
+
+.chip--with-left-icon {
+ padding-left: 8px;
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
diff --git a/ui/components/ui/new-network-info/new-network-info.js b/ui/components/ui/new-network-info/new-network-info.js
new file mode 100644
index 000000000..e8817c775
--- /dev/null
+++ b/ui/components/ui/new-network-info/new-network-info.js
@@ -0,0 +1,225 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useHistory } from 'react-router-dom';
+import { I18nContext } from '../../../contexts/i18n';
+import Popover from '../popover';
+import Button from '../button';
+import Identicon from '../identicon/identicon.component';
+import { NETWORK_TYPE_RPC } from '../../../../shared/constants/network';
+import Box from '../box';
+import {
+ ALIGN_ITEMS,
+ COLORS,
+ DISPLAY,
+ FONT_WEIGHT,
+ TEXT_ALIGN,
+ TYPOGRAPHY,
+} from '../../../helpers/constants/design-system';
+import Typography from '../typography';
+import { TOKEN_API_METASWAP_CODEFI_URL } from '../../../../shared/constants/tokens';
+import fetchWithCache from '../../../helpers/utils/fetch-with-cache';
+import {
+ getNativeCurrencyImage,
+ getProvider,
+ getUseTokenDetection,
+} from '../../../selectors';
+import { IMPORT_TOKEN_ROUTE } from '../../../helpers/constants/routes';
+import Chip from '../chip/chip';
+import { setFirstTimeUsedNetwork } from '../../../store/actions';
+
+const NewNetworkInfo = () => {
+ const t = useContext(I18nContext);
+ const history = useHistory();
+ const [tokenDetectionSupported, setTokenDetectionSupported] = useState(false);
+ const [showPopup, setShowPopup] = useState(true);
+ const autoDetectToken = useSelector(getUseTokenDetection);
+ const primaryTokenImage = useSelector(getNativeCurrencyImage);
+ const currentProvider = useSelector(getProvider);
+
+ const onCloseClick = () => {
+ setShowPopup(false);
+ setFirstTimeUsedNetwork(currentProvider.chainId);
+ };
+
+ const addTokenManually = () => {
+ history.push(IMPORT_TOKEN_ROUTE);
+ setShowPopup(false);
+ setFirstTimeUsedNetwork(currentProvider.chainId);
+ };
+
+ const getIsTokenDetectionSupported = async () => {
+ const fetchedTokenData = await fetchWithCache(
+ `${TOKEN_API_METASWAP_CODEFI_URL}${currentProvider.chainId}`,
+ );
+
+ return !fetchedTokenData.error;
+ };
+
+ const checkTokenDetection = async () => {
+ const fetchedData = await getIsTokenDetectionSupported();
+
+ setTokenDetectionSupported(fetchedData);
+ };
+
+ useEffect(() => {
+ checkTokenDetection();
+ });
+
+ if (!showPopup) {
+ return null;
+ }
+
+ return (
+
+ {t('recoveryPhraseReminderConfirm')}
+
+ }
+ >
+
+ {t('switchedTo')}
+
+
+ ) : (
+
+ )
+ }
+ />
+
+ {t('thingsToKeep')}
+
+
+ {currentProvider.ticker ? (
+
+
+ •
+
+
+ {t('nativeToken', [
+
+ {currentProvider.ticker}
+ ,
+ ])}
+
+
+ ) : null}
+
+
+ •
+
+
+ {t('attemptSendingAssets')}{' '}
+
+
+ {t('learnMoreUpperCase')}
+
+
+
+
+ {!autoDetectToken || !tokenDetectionSupported ? (
+
+
+ •
+
+
+
+ {t('tokenShowUp')}{' '}
+
+
+
+
+ ) : null}
+
+
+ );
+};
+
+export default NewNetworkInfo;
diff --git a/ui/components/ui/new-network-info/new-network-info.test.js b/ui/components/ui/new-network-info/new-network-info.test.js
new file mode 100644
index 000000000..2c4e9359a
--- /dev/null
+++ b/ui/components/ui/new-network-info/new-network-info.test.js
@@ -0,0 +1,171 @@
+import React from 'react';
+import configureMockStore from 'redux-mock-store';
+import nock from 'nock';
+import { renderWithProvider } from '../../../../test/lib/render-helpers';
+import NewNetworkInfo from './new-network-info';
+
+const fetchWithCache =
+ require('../../../helpers/utils/fetch-with-cache').default;
+
+const state = {
+ metamask: {
+ provider: {
+ ticker: 'ETH',
+ nickname: '',
+ chainId: '0x1',
+ type: 'mainnet',
+ },
+ useTokenDetection: false,
+ nativeCurrency: 'ETH',
+ },
+};
+
+describe('NewNetworkInfo', () => {
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ it('should render title', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x1')
+ .reply(
+ 200,
+ '[{"address":"0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f","symbol":"SNX","decimals":18,"name":"Synthetix Network Token","iconUrl":"https://assets.coingecko.com/coins/images/3406/large/SNX.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","synthetix","zapper","zerion","zeroEx"],"occurrences":12},{"address":"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984","symbol":"UNI","decimals":18,"name":"Uniswap","iconUrl":"https://images.prismic.io/token-price-prod/d0352dd9-5de8-4633-839d-bc3422c44d9c_UNI%404x.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","zapper","zerion","zeroEx"],"occurrences":11}]',
+ );
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x1',
+ );
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { getByText } = renderWithProvider(, store);
+
+ expect(getByText('You have switched to')).toBeInTheDocument();
+ });
+
+ it('should render a question mark icon image', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x1')
+ .reply(
+ 200,
+ '[{"address":"0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f","symbol":"SNX","decimals":18,"name":"Synthetix Network Token","iconUrl":"https://assets.coingecko.com/coins/images/3406/large/SNX.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","synthetix","zapper","zerion","zeroEx"],"occurrences":12},{"address":"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984","symbol":"UNI","decimals":18,"name":"Uniswap","iconUrl":"https://images.prismic.io/token-price-prod/d0352dd9-5de8-4633-839d-bc3422c44d9c_UNI%404x.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","zapper","zerion","zeroEx"],"occurrences":11}]',
+ );
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x1',
+ );
+
+ state.metamask.nativeCurrency = '';
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { container } = renderWithProvider(, store);
+ const questionMark = container.querySelector('.fa fa-question-circle');
+
+ expect(questionMark).toBeDefined();
+ });
+
+ it('should render Ethereum Mainnet caption', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x1')
+ .reply(
+ 200,
+ '[{"address":"0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f","symbol":"SNX","decimals":18,"name":"Synthetix Network Token","iconUrl":"https://assets.coingecko.com/coins/images/3406/large/SNX.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","synthetix","zapper","zerion","zeroEx"],"occurrences":12},{"address":"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984","symbol":"UNI","decimals":18,"name":"Uniswap","iconUrl":"https://images.prismic.io/token-price-prod/d0352dd9-5de8-4633-839d-bc3422c44d9c_UNI%404x.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","zapper","zerion","zeroEx"],"occurrences":11}]',
+ );
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x1',
+ );
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { getByText } = renderWithProvider(, store);
+
+ expect(getByText('Ethereum Mainnet')).toBeInTheDocument();
+ });
+
+ it('should render things to keep in mind text', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x1')
+ .reply(
+ 200,
+ '[{"address":"0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f","symbol":"SNX","decimals":18,"name":"Synthetix Network Token","iconUrl":"https://assets.coingecko.com/coins/images/3406/large/SNX.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","synthetix","zapper","zerion","zeroEx"],"occurrences":12},{"address":"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984","symbol":"UNI","decimals":18,"name":"Uniswap","iconUrl":"https://images.prismic.io/token-price-prod/d0352dd9-5de8-4633-839d-bc3422c44d9c_UNI%404x.png","aggregators":["aave","bancor","cmc","cryptocom","coinGecko","oneInch","paraswap","pmm","zapper","zerion","zeroEx"],"occurrences":11}]',
+ );
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x1',
+ );
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { getByText } = renderWithProvider(, store);
+
+ expect(getByText('Things to keep in mind:')).toBeInTheDocument();
+ });
+
+ it('should render things to keep in mind text when token detection support is not available', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x3')
+ .reply(200, '{"error":"ChainId 0x3 is not supported"}');
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x3',
+ );
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { getByText } = renderWithProvider(, store);
+
+ expect(getByText('Things to keep in mind:')).toBeInTheDocument();
+ });
+
+ it('should not render first bullet when provider ticker is null', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x3')
+ .reply(200, '{"error":"ChainId 0x3 is not supported"}');
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x3',
+ );
+
+ state.metamask.provider.ticker = null;
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { container } = renderWithProvider(, store);
+ const firstBox = container.querySelector('new-network-info__content-box-1');
+
+ expect(firstBox).toBeNull();
+ });
+
+ it('should render click to manually add link', async () => {
+ nock('https://token-api.metaswap.codefi.network')
+ .get('/tokens/0x3')
+ .reply(200, '{"error":"ChainId 0x3 is not supported"}');
+
+ const updateTokenDetectionSupportStatus = await fetchWithCache(
+ 'https://token-api.metaswap.codefi.network/tokens/0x3',
+ );
+
+ const store = configureMockStore()(
+ state,
+ updateTokenDetectionSupportStatus,
+ );
+ const { getByText } = renderWithProvider(, store);
+
+ expect(getByText('Click here to manually add the tokens.')).toBeDefined();
+ });
+});
diff --git a/ui/components/ui/ui-components.scss b/ui/components/ui/ui-components.scss
index b47976bea..24dc7e2e7 100644
--- a/ui/components/ui/ui-components.scss
+++ b/ui/components/ui/ui-components.scss
@@ -33,6 +33,7 @@
@import 'logo/logo-coinbasepay.scss';
@import 'loading-screen/index';
@import 'menu/menu';
+@import 'new-network-info/index';
@import 'numeric-input/numeric-input';
@import 'nickname-popover/index';
@import 'form-field/index';
diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js
index a4d3d8c5a..24c16f4e2 100644
--- a/ui/pages/routes/routes.component.js
+++ b/ui/pages/routes/routes.component.js
@@ -77,6 +77,7 @@ import OnboardingFlow from '../onboarding-flow/onboarding-flow';
import QRHardwarePopover from '../../components/app/qr-hardware-popover';
import { SEND_STAGES } from '../../ducks/send';
import { THEME_TYPE } from '../settings/experimental-tab/experimental-tab.constant';
+import NewNetworkInfo from '../../components/ui/new-network-info/new-network-info';
export default class Routes extends Component {
static propTypes = {
@@ -104,6 +105,8 @@ export default class Routes extends Component {
browserEnvironmentBrowser: PropTypes.string,
theme: PropTypes.string,
sendStage: PropTypes.string,
+ isNetworkUsed: PropTypes.bool,
+ hasAnAccountWithNoFundsOnNetwork: PropTypes.bool,
};
static contextTypes = {
@@ -358,11 +361,17 @@ export default class Routes extends Component {
isMouseUser,
browserEnvironmentOs: os,
browserEnvironmentBrowser: browser,
+ isNetworkUsed,
+ hasAnAccountWithNoFundsOnNetwork,
} = this.props;
const loadMessage =
loadingMessage || isNetworkLoading
? this.getConnectingLabel(loadingMessage)
: null;
+
+ const shouldShowNetworkInfo =
+ isUnlocked && !isNetworkUsed && hasAnAccountWithNoFundsOnNetwork;
+
return (
+ {shouldShowNetworkInfo &&
}
diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js
index 4be9745ac..7e38f553c 100644
--- a/ui/pages/routes/routes.container.js
+++ b/ui/pages/routes/routes.container.js
@@ -2,6 +2,8 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { compose } from 'redux';
import {
+ getHasAnyAccountWithNoFundsOnNetwork,
+ getIsNetworkUsed,
getNetworkIdentifier,
getPreferences,
isNetworkLoading,
@@ -40,6 +42,9 @@ function mapStateToProps(state) {
providerType: state.metamask.provider?.type,
theme: getTheme(state),
sendStage: getSendStage(state),
+ isNetworkUsed: getIsNetworkUsed(state),
+ hasAnAccountWithNoFundsOnNetwork:
+ getHasAnyAccountWithNoFundsOnNetwork(state),
};
}
diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js
index e45693fdb..c9e3b9a6d 100644
--- a/ui/selectors/selectors.js
+++ b/ui/selectors/selectors.js
@@ -1170,3 +1170,18 @@ export function getBlockExplorerLinkText(
return blockExplorerLinkText;
}
+
+export function getIsNetworkUsed(state) {
+ const chainId = getCurrentChainId(state);
+ const { usedNetworks } = state.metamask;
+
+ return Boolean(usedNetworks[chainId]);
+}
+
+export function getHasAnyAccountWithNoFundsOnNetwork(state) {
+ const balances = getMetaMaskCachedBalances(state) ?? {};
+ const hasAnAccountWithNoFundsOnNetwork =
+ Object.values(balances).indexOf('0x0');
+
+ return hasAnAccountWithNoFundsOnNetwork !== -1;
+}
diff --git a/ui/store/actions.js b/ui/store/actions.js
index dbad71244..a0b7ceb31 100644
--- a/ui/store/actions.js
+++ b/ui/store/actions.js
@@ -3785,6 +3785,10 @@ export function setCustomNetworkListEnabled(customNetworkListEnabled) {
};
}
+export function setFirstTimeUsedNetwork(chainId) {
+ return promisifiedBackground.setFirstTimeUsedNetwork(chainId);
+}
+
// QR Hardware Wallets
export async function submitQRHardwareCryptoHDKey(cbor) {
await promisifiedBackground.submitQRHardwareCryptoHDKey(cbor);