import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { getTokenTrackerLink } from '@metamask/etherscan-link'; import ZENDESK_URLS from '../../helpers/constants/zendesk-url'; import { checkExistingAddresses, getURLHostName, } from '../../helpers/utils/util'; import { tokenInfoGetter } from '../../helpers/utils/token-util'; import { ADD_NFT_ROUTE, CONFIRM_IMPORT_TOKEN_ROUTE, SECURITY_ROUTE, } from '../../helpers/constants/routes'; import TextField from '../../components/ui/text-field'; import PageContainer from '../../components/ui/page-container'; import { Tabs, Tab } from '../../components/ui/tabs'; import { addHexPrefix } from '../../../app/scripts/lib/util'; import { isValidHexAddress } from '../../../shared/modules/hexstring-utils'; import ActionableMessage from '../../components/ui/actionable-message/actionable-message'; import Typography from '../../components/ui/typography'; import { TypographyVariant, FONT_WEIGHT, } from '../../helpers/constants/design-system'; import Button from '../../components/ui/button'; import { TokenStandard } from '../../../shared/constants/transaction'; import { STATIC_MAINNET_TOKEN_LIST } from '../../../shared/constants/tokens'; import TokenSearch from './token-search'; import TokenList from './token-list'; const emptyAddr = '0x0000000000000000000000000000000000000000'; const MIN_DECIMAL_VALUE = 0; const MAX_DECIMAL_VALUE = 36; class ImportToken extends Component { static contextTypes = { t: PropTypes.func, }; static propTypes = { /** * History object of the router. */ history: PropTypes.object, /** * Set the state of `pendingTokens`, called when adding a token. */ setPendingTokens: PropTypes.func, /** * The current list of pending tokens to be added. */ pendingTokens: PropTypes.object, /** * Clear the list of pending tokens. Called when closing the modal. */ clearPendingTokens: PropTypes.func, /** * The list of already added tokens. */ tokens: PropTypes.array, /** * The identities/accounts that are currently added to the wallet. */ identities: PropTypes.object, /** * Boolean flag that shows/hides the search tab. */ showSearchTab: PropTypes.bool.isRequired, /** * The most recent overview page route, which is 'navigated' to when closing the modal. */ mostRecentOverviewPage: PropTypes.string.isRequired, /** * The active chainId in use. */ chainId: PropTypes.string, /** * The rpc preferences to use for the current provider. */ rpcPrefs: PropTypes.object, /** * The list of tokens available for search. */ tokenList: PropTypes.object, /** * Boolean flag indicating whether token detection is enabled or not. * When disabled, shows an information alert in the search tab informing the * user of the availability of this feature. */ useTokenDetection: PropTypes.bool, /** * Function called to fetch information about the token standard and * details, see `actions.js`. */ getTokenStandardAndDetails: PropTypes.func, /** * The currently selected active address. */ selectedAddress: PropTypes.string, isDynamicTokenListAvailable: PropTypes.bool.isRequired, tokenDetectionInactiveOnNonMainnetSupportedNetwork: PropTypes.bool.isRequired, networkName: PropTypes.string.isRequired, }; static defaultProps = { tokenList: {}, }; state = { customAddress: '', customSymbol: '', customDecimals: 0, searchResults: [], selectedTokens: {}, standard: TokenStandard.NONE, tokenSelectorError: null, customAddressError: null, customSymbolError: null, customDecimalsError: null, nftAddressError: null, forceEditSymbol: false, symbolAutoFilled: false, decimalAutoFilled: false, mainnetTokenWarning: null, }; componentDidMount() { this.tokenInfoGetter = tokenInfoGetter(); const { pendingTokens = {} } = this.props; const pendingTokenKeys = Object.keys(pendingTokens); if (pendingTokenKeys.length > 0) { let selectedTokens = {}; let customToken = {}; pendingTokenKeys.forEach((tokenAddress) => { const token = pendingTokens[tokenAddress]; const { isCustom } = token; if (isCustom) { customToken = { ...token }; } else { selectedTokens = { ...selectedTokens, [tokenAddress]: { ...token } }; } }); const { address: customAddress = '', symbol: customSymbol = '', decimals: customDecimals = 0, } = customToken; this.setState({ selectedTokens, customAddress, customSymbol, customDecimals, }); } } handleToggleToken(token) { const { address } = token; const { selectedTokens = {} } = this.state; const selectedTokensCopy = { ...selectedTokens }; if (address in selectedTokensCopy) { delete selectedTokensCopy[address]; } else { selectedTokensCopy[address] = token; } this.setState({ selectedTokens: selectedTokensCopy, tokenSelectorError: null, }); } hasError() { const { tokenSelectorError, customAddressError, customSymbolError, customDecimalsError, nftAddressError, } = this.state; return ( tokenSelectorError || customAddressError || customSymbolError || customDecimalsError || nftAddressError ); } hasSelected() { const { customAddress = '', selectedTokens = {} } = this.state; return customAddress || Object.keys(selectedTokens).length > 0; } handleNext() { if (this.hasError()) { return; } if (!this.hasSelected()) { this.setState({ tokenSelectorError: this.context.t('mustSelectOne') }); return; } const { setPendingTokens, history, tokenList } = this.props; const tokenAddressList = Object.keys(tokenList); const { customAddress: address, customSymbol: symbol, customDecimals: decimals, selectedTokens, standard, } = this.state; const customToken = { address, symbol, decimals, standard, }; setPendingTokens({ customToken, selectedTokens, tokenAddressList }); history.push(CONFIRM_IMPORT_TOKEN_ROUTE); } async attemptToAutoFillTokenParams(address) { const { tokenList } = this.props; const { symbol = '', decimals } = await this.tokenInfoGetter( address, tokenList, ); const symbolAutoFilled = Boolean(symbol); const decimalAutoFilled = Boolean(decimals); this.setState({ symbolAutoFilled, decimalAutoFilled }); this.handleCustomSymbolChange(symbol || ''); this.handleCustomDecimalsChange(decimals); } async handleCustomAddressChange(value) { const customAddress = value.trim(); this.setState({ customAddress, customAddressError: null, nftAddressError: null, tokenSelectorError: null, symbolAutoFilled: false, decimalAutoFilled: false, mainnetTokenWarning: null, }); const addressIsValid = isValidHexAddress(customAddress, { allowNonPrefixed: false, }); const standardAddress = addHexPrefix(customAddress).toLowerCase(); const isMainnetToken = Object.keys(STATIC_MAINNET_TOKEN_LIST).some( (key) => key.toLowerCase() === customAddress.toLowerCase(), ); const isMainnetNetwork = this.props.chainId === '0x1'; let standard; if (addressIsValid) { try { ({ standard } = await this.props.getTokenStandardAndDetails( standardAddress, this.props.selectedAddress, )); } catch (error) { // ignore } } const addressIsEmpty = customAddress.length === 0 || customAddress === emptyAddr; switch (true) { case !addressIsValid && !addressIsEmpty: this.setState({ customAddressError: this.context.t('invalidAddress'), customSymbol: '', customDecimals: 0, customSymbolError: null, customDecimalsError: null, }); break; case standard === 'ERC1155' || standard === 'ERC721': this.setState({ nftAddressError: this.context.t('nftAddressError', [ this.props.history.push({ pathname: ADD_NFT_ROUTE, state: { addressEnteredOnImportTokensPage: this.state.customAddress, }, }) } key="nftAddressError" > {this.context.t('importNFTPage')} , ]), }); break; case isMainnetToken && !isMainnetNetwork: this.setState({ mainnetTokenWarning: this.context.t('mainnetToken'), customSymbol: '', customDecimals: 0, customSymbolError: null, customDecimalsError: null, }); break; case Boolean(this.props.identities[standardAddress]): this.setState({ customAddressError: this.context.t('personalAddressDetected'), }); break; case checkExistingAddresses(customAddress, this.props.tokens): this.setState({ customAddressError: this.context.t('tokenAlreadyAdded'), }); break; default: if (!addressIsEmpty) { this.attemptToAutoFillTokenParams(customAddress); if (standard) { this.setState({ standard }); } } } } handleCustomSymbolChange(value) { const customSymbol = value.trim(); const symbolLength = customSymbol.length; let customSymbolError = null; if (symbolLength <= 0 || symbolLength >= 12) { customSymbolError = this.context.t('symbolBetweenZeroTwelve'); } this.setState({ customSymbol, customSymbolError }); } handleCustomDecimalsChange(value) { let customDecimals; let customDecimalsError = null; if (value) { customDecimals = Number(value.trim()); customDecimalsError = value < MIN_DECIMAL_VALUE || value > MAX_DECIMAL_VALUE ? this.context.t('decimalsMustZerotoTen') : null; } else { customDecimals = ''; customDecimalsError = this.context.t('tokenDecimalFetchFailed'); } this.setState({ customDecimals, customDecimalsError }); } renderCustomTokenForm() { const { t } = this.context; const { customAddress, customSymbol, customDecimals, customAddressError, customSymbolError, customDecimalsError, forceEditSymbol, symbolAutoFilled, decimalAutoFilled, mainnetTokenWarning, nftAddressError, } = this.state; const { chainId, rpcPrefs, isDynamicTokenListAvailable, tokenDetectionInactiveOnNonMainnetSupportedNetwork, history, } = this.props; const blockExplorerTokenLink = getTokenTrackerLink( customAddress, chainId, null, null, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null }, ); const blockExplorerLabel = rpcPrefs?.blockExplorerUrl ? getURLHostName(blockExplorerTokenLink) : t('etherscan'); return (
{tokenDetectionInactiveOnNonMainnetSupportedNetwork ? ( {t('tokenScamSecurityRisk')} , , ])} withRightButton useIcon iconFillColor="var(--color-warning-default)" /> ) : ( {t('learnScamRisk')} , ], )} withRightButton useIcon iconFillColor={ isDynamicTokenListAvailable ? 'var(--color-warning-default)' : 'var(--color-info-default)' } /> )} this.handleCustomAddressChange(e.target.value)} error={customAddressError || mainnetTokenWarning || nftAddressError} fullWidth autoFocus margin="normal" /> {t('tokenSymbol')} {symbolAutoFilled && !forceEditSymbol && (
this.setState({ forceEditSymbol: true })} > {t('edit')}
)}
} type="text" value={customSymbol} onChange={(e) => this.handleCustomSymbolChange(e.target.value)} error={customSymbolError} fullWidth margin="normal" disabled={symbolAutoFilled && !forceEditSymbol} /> this.handleCustomDecimalsChange(e.target.value)} error={customDecimals ? customDecimalsError : null} fullWidth margin="normal" disabled={decimalAutoFilled} min={MIN_DECIMAL_VALUE} max={MAX_DECIMAL_VALUE} /> {customDecimals === '' && ( {t('tokenDecimalFetchFailed')} {t('verifyThisTokenDecimalOn', [ , ])} } type="warning" withRightButton className="import-token__decimal-warning" /> )} ); } renderSearchToken() { const { t } = this.context; const { tokenList, history, useTokenDetection, networkName } = this.props; const { tokenSelectorError, selectedTokens, searchResults } = this.state; return (
{!useTokenDetection && ( history.push(`${SECURITY_ROUTE}#token-description`) } > {t('enableFromSettings')} , ])} withRightButton useIcon iconFillColor="var(--color-primary-default)" className="import-token__token-detection-announcement" /> )} this.setState({ searchResults: results }) } error={tokenSelectorError} tokenList={tokenList} />
this.handleToggleToken(token)} />
); } renderTabs() { const { t } = this.context; const { showSearchTab } = this.props; const tabs = []; if (showSearchTab) { tabs.push( {this.renderSearchToken()} , ); } tabs.push( {this.renderCustomTokenForm()} , ); return {tabs}; } render() { const { history, clearPendingTokens, mostRecentOverviewPage } = this.props; return ( this.handleNext()} hideCancel disabled={Boolean(this.hasError()) || !this.hasSelected()} onClose={() => { clearPendingTokens(); history.push(mostRecentOverviewPage); }} /> ); } } export default ImportToken;