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