1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 11:22:43 +02:00
metamask-extension/ui/pages/import-token/import-token.component.js
David Walsh 5b1b5dc03b
NFTs: Remove feature flag for release (#17401)
* NFTs: Remove feature flag for release

* Update security tab jest test

* Fix broken test

* Update snapshot

* Update test

* Fix test

* Remove last usages of flag

* Update CI jobs

* Fix jest tests
2023-03-13 14:29:37 -05:00

662 lines
18 KiB
JavaScript

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', [
<a
className="import-token__nft-address-error-link"
onClick={() =>
this.props.history.push({
pathname: ADD_NFT_ROUTE,
state: {
addressEnteredOnImportTokensPage: this.state.customAddress,
},
})
}
key="nftAddressError"
>
{this.context.t('importNFTPage')}
</a>,
]),
});
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 (
<div className="import-token__custom-token-form">
{tokenDetectionInactiveOnNonMainnetSupportedNetwork ? (
<ActionableMessage
type="warning"
message={t('customTokenWarningInTokenDetectionNetworkWithTDOFF', [
<Button
type="link"
key="import-token-security-risk"
className="import-token__link"
rel="noopener noreferrer"
target="_blank"
href={ZENDESK_URLS.TOKEN_SAFETY_PRACTICES}
>
{t('tokenScamSecurityRisk')}
</Button>,
<Button
type="link"
key="import-token-token-detection-announcement"
className="import-token__link"
onClick={() =>
history.push(`${SECURITY_ROUTE}#token-description`)
}
>
{t('inYourSettings')}
</Button>,
])}
withRightButton
useIcon
iconFillColor="var(--color-warning-default)"
/>
) : (
<ActionableMessage
type={isDynamicTokenListAvailable ? 'warning' : 'default'}
message={t(
isDynamicTokenListAvailable
? 'customTokenWarningInTokenDetectionNetwork'
: 'customTokenWarningInNonTokenDetectionNetwork',
[
<Button
type="link"
key="import-token-fake-token-warning"
className="import-token__link"
rel="noopener noreferrer"
target="_blank"
href={ZENDESK_URLS.TOKEN_SAFETY_PRACTICES}
>
{t('learnScamRisk')}
</Button>,
],
)}
withRightButton
useIcon
iconFillColor={
isDynamicTokenListAvailable
? 'var(--color-warning-default)'
: 'var(--color-info-default)'
}
/>
)}
<TextField
id="custom-address"
label={t('tokenContractAddress')}
type="text"
value={customAddress}
onChange={(e) => this.handleCustomAddressChange(e.target.value)}
error={customAddressError || mainnetTokenWarning || nftAddressError}
fullWidth
autoFocus
margin="normal"
/>
<TextField
id="custom-symbol"
label={
<div className="import-token__custom-symbol__label-wrapper">
<span className="import-token__custom-symbol__label">
{t('tokenSymbol')}
</span>
{symbolAutoFilled && !forceEditSymbol && (
<div
className="import-token__custom-symbol__edit"
onClick={() => this.setState({ forceEditSymbol: true })}
>
{t('edit')}
</div>
)}
</div>
}
type="text"
value={customSymbol}
onChange={(e) => this.handleCustomSymbolChange(e.target.value)}
error={customSymbolError}
fullWidth
margin="normal"
disabled={symbolAutoFilled && !forceEditSymbol}
/>
<TextField
id="custom-decimals"
label={t('decimal')}
type="number"
value={customDecimals}
onChange={(e) => this.handleCustomDecimalsChange(e.target.value)}
error={customDecimals ? customDecimalsError : null}
fullWidth
margin="normal"
disabled={decimalAutoFilled}
min={MIN_DECIMAL_VALUE}
max={MAX_DECIMAL_VALUE}
/>
{customDecimals === '' && (
<ActionableMessage
message={
<>
<Typography
variant={TypographyVariant.H7}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('tokenDecimalFetchFailed')}
</Typography>
<Typography
variant={TypographyVariant.H7}
fontWeight={FONT_WEIGHT.NORMAL}
>
{t('verifyThisTokenDecimalOn', [
<Button
type="link"
key="import-token-verify-token-decimal"
className="import-token__link"
rel="noopener noreferrer"
target="_blank"
href={blockExplorerTokenLink}
>
{blockExplorerLabel}
</Button>,
])}
</Typography>
</>
}
type="warning"
withRightButton
className="import-token__decimal-warning"
/>
)}
</div>
);
}
renderSearchToken() {
const { t } = this.context;
const { tokenList, history, useTokenDetection, networkName } = this.props;
const { tokenSelectorError, selectedTokens, searchResults } = this.state;
return (
<div className="import-token__search-token">
{!useTokenDetection && (
<ActionableMessage
message={t('enhancedTokenDetectionAlertMessage', [
networkName,
<Button
type="link"
key="token-detection-announcement"
className="import-token__link"
onClick={() =>
history.push(`${SECURITY_ROUTE}#token-description`)
}
>
{t('enableFromSettings')}
</Button>,
])}
withRightButton
useIcon
iconFillColor="var(--color-primary-default)"
className="import-token__token-detection-announcement"
/>
)}
<TokenSearch
onSearch={({ results = [] }) =>
this.setState({ searchResults: results })
}
error={tokenSelectorError}
tokenList={tokenList}
/>
<div className="import-token__token-list">
<TokenList
results={searchResults}
selectedTokens={selectedTokens}
onToggleToken={(token) => this.handleToggleToken(token)}
/>
</div>
</div>
);
}
renderTabs() {
const { t } = this.context;
const { showSearchTab } = this.props;
const tabs = [];
if (showSearchTab) {
tabs.push(
<Tab name={t('search')} key="search-tab" tabKey="search">
{this.renderSearchToken()}
</Tab>,
);
}
tabs.push(
<Tab name={t('customToken')} key="custom-tab" tabKey="customToken">
{this.renderCustomTokenForm()}
</Tab>,
);
return <Tabs>{tabs}</Tabs>;
}
render() {
const { history, clearPendingTokens, mostRecentOverviewPage } = this.props;
return (
<PageContainer
title={this.context.t('importTokensCamelCase')}
tabsComponent={this.renderTabs()}
onSubmit={() => this.handleNext()}
hideCancel
disabled={Boolean(this.hasError()) || !this.hasSelected()}
onClose={() => {
clearPendingTokens();
history.push(mostRecentOverviewPage);
}}
/>
);
}
}
export default ImportToken;