mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-26 20:39:08 +01:00
6e5c2f03bf
* addding the legacy tokenlist, tuning token detection OFF by default, adding new message while importing tokens updating the controller version and calling detectNewToken on network change fixing rebase error Run yarn lavamoat:auto for updating policies updating lavamoat Deleted node modules and run again lavamoat auto fixing rebase issues updating lavamoat policies updating lavamoat after rebasing policies updating custom token warning and blocking detectedtoken link when tpken detection is off for supported networks to update the token in fetchTosync updating the contract map object Revert build-system lavamoat policy changes Move token list selection logic from components to getTokenList selector updating the tokenList Update lavamoat Fix error updating lavamoat lint fix fix unit test fail fix unit test fail lint fix fixing rebase locale error rebase fix Revert build-system policy changes temp addressing review comments * rebase fix
662 lines
19 KiB
JavaScript
662 lines
19 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_COLLECTIBLE_ROUTE,
|
|
CONFIRM_IMPORT_TOKEN_ROUTE,
|
|
ADVANCED_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 { TYPOGRAPHY, FONT_WEIGHT } from '../../helpers/constants/design-system';
|
|
import Button from '../../components/ui/button';
|
|
import { TOKEN_STANDARDS } 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: TOKEN_STANDARDS.NONE,
|
|
tokenSelectorError: null,
|
|
customAddressError: null,
|
|
customSymbolError: null,
|
|
customDecimalsError: null,
|
|
collectibleAddressError: 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,
|
|
collectibleAddressError,
|
|
} = this.state;
|
|
|
|
return (
|
|
tokenSelectorError ||
|
|
customAddressError ||
|
|
customSymbolError ||
|
|
customDecimalsError ||
|
|
collectibleAddressError
|
|
);
|
|
}
|
|
|
|
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,
|
|
collectibleAddressError: 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 process.env.COLLECTIBLES_V1 &&
|
|
(standard === 'ERC1155' || standard === 'ERC721'):
|
|
this.setState({
|
|
collectibleAddressError: this.context.t('collectibleAddressError', [
|
|
<a
|
|
className="import-token__collectible-address-error-link"
|
|
onClick={() =>
|
|
this.props.history.push({
|
|
pathname: ADD_COLLECTIBLE_ROUTE,
|
|
state: {
|
|
addressEnteredOnImportTokensPage: this.state.customAddress,
|
|
},
|
|
})
|
|
}
|
|
key="collectibleAddressError"
|
|
>
|
|
{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,
|
|
collectibleAddressError,
|
|
} = 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(`${ADVANCED_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 || collectibleAddressError
|
|
}
|
|
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={TYPOGRAPHY.H7}
|
|
fontWeight={FONT_WEIGHT.BOLD}
|
|
>
|
|
{t('tokenDecimalFetchFailed')}
|
|
</Typography>
|
|
<Typography
|
|
variant={TYPOGRAPHY.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('tokenDetectionAlertMessage', [
|
|
networkName,
|
|
<Button
|
|
type="link"
|
|
key="token-detection-announcement"
|
|
className="import-token__link"
|
|
onClick={() =>
|
|
history.push(`${ADVANCED_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">
|
|
{this.renderSearchToken()}
|
|
</Tab>,
|
|
);
|
|
}
|
|
tabs.push(
|
|
<Tab name={t('customToken')} key="custom-tab">
|
|
{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;
|