1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Add currency symbol validation in the add network form (#12431)

* validate ticker symbol in add/edit network form
This commit is contained in:
Alex Donesky 2022-02-04 12:14:52 -06:00 committed by GitHub
parent 87daae708c
commit 48cc9d5ad3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 523 additions and 247 deletions

View File

@ -444,6 +444,10 @@
"chainIdExistsErrorMsg": { "chainIdExistsErrorMsg": {
"message": "This Chain ID is currently used by the $1 network." "message": "This Chain ID is currently used by the $1 network."
}, },
"chainListReturnedDifferentTickerSymbol": {
"message": "The network with chain ID $1 may use a different currency symbol ($2) than the one you have entered. Please verify before continuing.",
"description": "$1 is the chain id currently entered in the network form and $2 is the return value of nativeCurrency.symbol from chainlist.network"
},
"chromeRequiredForHardwareWallets": { "chromeRequiredForHardwareWallets": {
"message": "You need to use MetaMask on Google Chrome in order to connect to your Hardware Wallet." "message": "You need to use MetaMask on Google Chrome in order to connect to your Hardware Wallet."
}, },
@ -1017,7 +1021,7 @@
"message": "Learn more." "message": "Learn more."
}, },
"endpointReturnedDifferentChainId": { "endpointReturnedDifferentChainId": {
"message": "The endpoint returned a different chain ID: $1", "message": "The RPC URL you have entered returned a different chain ID ($1). Please update the Chain ID to match the RPC URL of the network you are trying to add.",
"description": "$1 is the return value of eth_chainId from an RPC endpoint" "description": "$1 is the return value of eth_chainId from an RPC endpoint"
}, },
"ensIllegalCharacter": { "ensIllegalCharacter": {
@ -1126,6 +1130,9 @@
"failedToFetchChainId": { "failedToFetchChainId": {
"message": "Could not fetch chain ID. Is your RPC URL correct?" "message": "Could not fetch chain ID. Is your RPC URL correct?"
}, },
"failedToFetchTickerSymbolData": {
"message": "Ticker symbol verification data is currently unavailable, make sure that the symbol you have entered is correct. It will impact the conversion rates that you see for this network"
},
"failureMessage": { "failureMessage": {
"message": "Something went wrong, and we were unable to complete the action" "message": "Something went wrong, and we were unable to complete the action"
}, },

View File

@ -14,6 +14,7 @@ describe('Stores custom RPC history', function () {
it(`creates first custom RPC entry`, async function () { it(`creates first custom RPC entry`, async function () {
const port = 8546; const port = 8546;
const chainId = 1338; const chainId = 1338;
const symbol = 'TEST';
await withFixtures( await withFixtures(
{ {
fixtures: 'imported-account', fixtures: 'imported-account',
@ -38,6 +39,7 @@ describe('Stores custom RPC history', function () {
const networkNameInput = customRpcInputs[0]; const networkNameInput = customRpcInputs[0];
const rpcUrlInput = customRpcInputs[1]; const rpcUrlInput = customRpcInputs[1];
const chainIdInput = customRpcInputs[2]; const chainIdInput = customRpcInputs[2];
const symbolInput = customRpcInputs[3];
await networkNameInput.clear(); await networkNameInput.clear();
await networkNameInput.sendKeys(networkName); await networkNameInput.sendKeys(networkName);
@ -48,6 +50,9 @@ describe('Stores custom RPC history', function () {
await chainIdInput.clear(); await chainIdInput.clear();
await chainIdInput.sendKeys(chainId.toString()); await chainIdInput.sendKeys(chainId.toString());
await symbolInput.clear();
await symbolInput.sendKeys(symbol);
await driver.clickElement( await driver.clickElement(
'.networks-tab__add-network-form-footer .btn-primary', '.networks-tab__add-network-form-footer .btn-primary',
); );
@ -70,7 +75,8 @@ describe('Stores custom RPC history', function () {
await driver.press('#password', driver.Key.ENTER); await driver.press('#password', driver.Key.ENTER);
// duplicate network // duplicate network
const duplicateRpcUrl = 'http://localhost:8545'; const duplicateRpcUrl =
'https://mainnet.infura.io/v3/00000000000000000000000000000000';
await driver.clickElement('.network-display'); await driver.clickElement('.network-display');
@ -84,7 +90,7 @@ describe('Stores custom RPC history', function () {
await rpcUrlInput.clear(); await rpcUrlInput.clear();
await rpcUrlInput.sendKeys(duplicateRpcUrl); await rpcUrlInput.sendKeys(duplicateRpcUrl);
await driver.findElement({ await driver.findElement({
text: 'This URL is currently used by the Localhost 8545 network.', text: 'This URL is currently used by the mainnet network.',
tag: 'h6', tag: 'h6',
}); });
}, },
@ -97,6 +103,7 @@ describe('Stores custom RPC history', function () {
fixtures: 'imported-account', fixtures: 'imported-account',
ganacheOptions, ganacheOptions,
title: this.test.title, title: this.test.title,
failOnConsoleError: false,
}, },
async ({ driver }) => { async ({ driver }) => {
await driver.navigate(); await driver.navigate();
@ -117,9 +124,6 @@ describe('Stores custom RPC history', function () {
const rpcUrlInput = customRpcInputs[1]; const rpcUrlInput = customRpcInputs[1];
const chainIdInput = customRpcInputs[2]; const chainIdInput = customRpcInputs[2];
await rpcUrlInput.clear();
await rpcUrlInput.sendKeys(newRpcUrl);
await chainIdInput.clear(); await chainIdInput.clear();
await chainIdInput.sendKeys(duplicateChainId); await chainIdInput.sendKeys(duplicateChainId);
await driver.findElement({ await driver.findElement({
@ -127,6 +131,14 @@ describe('Stores custom RPC history', function () {
'This Chain ID is currently used by the Localhost 8545 network.', 'This Chain ID is currently used by the Localhost 8545 network.',
tag: 'h6', tag: 'h6',
}); });
await rpcUrlInput.clear();
await rpcUrlInput.sendKeys(newRpcUrl);
await driver.findElement({
text: 'Could not fetch chain ID. Is your RPC URL correct?',
tag: 'h6',
});
}, },
); );
}); });
@ -184,6 +196,7 @@ describe('Stores custom RPC history', function () {
fixtures: 'custom-rpc', fixtures: 'custom-rpc',
ganacheOptions, ganacheOptions,
title: this.test.title, title: this.test.title,
failOnConsoleError: false,
}, },
async ({ driver }) => { async ({ driver }) => {
await driver.navigate(); await driver.navigate();

View File

@ -31,6 +31,7 @@ export default function FormField({
allowDecimals, allowDecimals,
disabled, disabled,
placeholder, placeholder,
warning,
}) { }) {
return ( return (
<div <div
@ -92,6 +93,7 @@ export default function FormField({
<input <input
className={classNames('form-field__input', { className={classNames('form-field__input', {
'form-field__input--error': error, 'form-field__input--error': error,
'form-field__input--warning': warning,
})} })}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
value={value} value={value}
@ -111,6 +113,15 @@ export default function FormField({
{error} {error}
</Typography> </Typography>
)} )}
{warning && (
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H7}
className="form-field__warning"
>
{warning}
</Typography>
)}
</label> </label>
</div> </div>
); );
@ -141,6 +152,10 @@ FormField.propTypes = {
* Show error message * Show error message
*/ */
error: PropTypes.string, error: PropTypes.string,
/**
* Show warning message
*/
warning: PropTypes.string,
/** /**
* Handler when fields change * Handler when fields change
*/ */

View File

@ -45,5 +45,9 @@
&--error { &--error {
border-color: var(--error-1); border-color: var(--error-1);
} }
&--warning {
border-color: var(--alert-3);
}
} }
} }

View File

@ -48,7 +48,7 @@
&__network-form { &__network-form {
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
max-height: 465px; max-height: 465px;

View File

@ -26,6 +26,8 @@ import {
DEFAULT_ROUTE, DEFAULT_ROUTE,
NETWORKS_ROUTE, NETWORKS_ROUTE,
} from '../../../../helpers/constants/routes'; } from '../../../../helpers/constants/routes';
import fetchWithCache from '../../../../helpers/utils/fetch-with-cache';
import { usePrevious } from '../../../../hooks/usePrevious';
/** /**
* Attempts to convert the given chainId to a decimal string, for display * Attempts to convert the given chainId to a decimal string, for display
@ -83,6 +85,7 @@ const NetworksForm = ({
selectedNetwork?.blockExplorerUrl || '', selectedNetwork?.blockExplorerUrl || '',
); );
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [warnings, setWarnings] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
@ -92,6 +95,7 @@ const NetworksForm = ({
setTicker(selectedNetwork?.ticker); setTicker(selectedNetwork?.ticker);
setBlockExplorerUrl(selectedNetwork?.blockExplorerUrl); setBlockExplorerUrl(selectedNetwork?.blockExplorerUrl);
setErrors({}); setErrors({});
setWarnings({});
setIsSubmitting(false); setIsSubmitting(false);
}, [selectedNetwork, selectedNetworkName]); }, [selectedNetwork, selectedNetworkName]);
@ -170,216 +174,297 @@ const NetworksForm = ({
dispatch, dispatch,
]); ]);
const setErrorTo = (errorKey, errorVal) => {
setErrors({ ...errors, [errorKey]: errorVal });
};
const setErrorEmpty = (errorKey) => {
setErrors({
...errors,
[errorKey]: {
msg: '',
key: '',
},
});
};
const hasError = (errorKey, errorKeyVal) => {
return errors[errorKey]?.key === errorKeyVal;
};
const hasErrors = () => { const hasErrors = () => {
return Object.keys(errors).some((key) => { return Object.keys(errors).some((key) => {
const error = errors[key]; const error = errors[key];
// Do not factor in duplicate chain id error for submission disabling // Do not factor in duplicate chain id error for submission disabling
if (key === 'chainId' && error.key === 'chainIdExistsErrorMsg') { if (key === 'chainId' && error?.key === 'chainIdExistsErrorMsg') {
return false; return false;
} }
return error.key && error.msg; return error?.key && error?.msg;
}); });
}; };
const validateChainIdOnChange = (chainArg = '') => { const validateBlockExplorerURL = useCallback(
const formChainId = chainArg.trim(); (url) => {
let errorKey = ''; if (!validUrl.isWebUri(url) && url !== '') {
let errorMessage = ''; let errorKey;
let radix = 10; let errorMessage;
let hexChainId = formChainId;
if (!hexChainId.startsWith('0x')) { if (isValidWhenAppended(url)) {
try { errorKey = 'urlErrorMsg';
hexChainId = `0x${decimalToHex(hexChainId)}`; errorMessage = t('urlErrorMsg');
} catch (err) { } else {
setErrorTo('chainId', { errorKey = 'invalidBlockExplorerURL';
key: 'invalidHexNumber', errorMessage = t('invalidBlockExplorerURL');
msg: t('invalidHexNumber'), }
});
return; return {
key: errorKey,
msg: errorMessage,
};
} }
} return null;
},
[t],
);
const [matchingChainId] = networksToRender.filter( const validateChainId = useCallback(
(e) => e.chainId === hexChainId && e.rpcUrl !== rpcUrl, async (chainArg = '') => {
); const formChainId = chainArg.trim();
let errorKey = '';
let errorMessage = '';
let radix = 10;
let hexChainId = formChainId;
if (formChainId === '') { if (!hexChainId.startsWith('0x')) {
setErrorEmpty('chainId');
return;
} else if (matchingChainId) {
errorKey = 'chainIdExistsErrorMsg';
errorMessage = t('chainIdExistsErrorMsg', [
matchingChainId.label ?? matchingChainId.labelKey,
]);
} else if (formChainId.startsWith('0x')) {
radix = 16;
if (!/^0x[0-9a-f]+$/iu.test(formChainId)) {
errorKey = 'invalidHexNumber';
errorMessage = t('invalidHexNumber');
} else if (!isPrefixedFormattedHexString(formChainId)) {
errorMessage = t('invalidHexNumberLeadingZeros');
}
} else if (!/^[0-9]+$/u.test(formChainId)) {
errorKey = 'invalidNumber';
errorMessage = t('invalidNumber');
} else if (formChainId.startsWith('0')) {
errorKey = 'invalidNumberLeadingZeros';
errorMessage = t('invalidNumberLeadingZeros');
} else if (!isSafeChainId(parseInt(formChainId, radix))) {
errorKey = 'invalidChainIdTooBig';
errorMessage = t('invalidChainIdTooBig');
}
setErrorTo('chainId', {
key: errorKey,
msg: errorMessage,
});
};
/**
* Validates the chain ID by checking it against the `eth_chainId` return
* value from the given RPC URL.
* Assumes that all strings are non-empty and correctly formatted.
*
* @param {string} formChainId - Non-empty, hex or decimal number string from
* the form.
* @param {string} parsedChainId - The parsed, hex string chain ID.
* @param {string} formRpcUrl - The RPC URL from the form.
*/
const validateChainIdOnSubmit = async (
formChainId,
parsedChainId,
formRpcUrl,
) => {
let errorKey;
let errorMessage;
let endpointChainId;
let providerError;
try {
endpointChainId = await jsonRpcRequest(formRpcUrl, 'eth_chainId');
} catch (err) {
log.warn('Failed to fetch the chainId from the endpoint.', err);
providerError = err;
}
if (providerError || typeof endpointChainId !== 'string') {
errorKey = 'failedToFetchChainId';
errorMessage = t('failedToFetchChainId');
} else if (parsedChainId !== endpointChainId) {
// Here, we are in an error state. The endpoint should always return a
// hexadecimal string. If the user entered a decimal string, we attempt
// to convert the endpoint's return value to decimal before rendering it
// in an error message in the form.
if (!formChainId.startsWith('0x')) {
try { try {
endpointChainId = parseInt(endpointChainId, 16).toString(10); hexChainId = `0x${decimalToHex(hexChainId)}`;
} catch (err) { } catch (err) {
log.warn( return {
'Failed to convert endpoint chain ID to decimal', key: 'invalidHexNumber',
endpointChainId, msg: t('invalidHexNumber'),
); };
} }
} }
errorKey = 'endpointReturnedDifferentChainId'; const [matchingChainId] = networksToRender.filter(
errorMessage = t('endpointReturnedDifferentChainId', [ (e) => e.chainId === hexChainId && e.rpcUrl !== rpcUrl,
endpointChainId.length <= 12 );
? endpointChainId
: `${endpointChainId.slice(0, 9)}...`,
]);
}
if (errorKey) { if (formChainId === '') {
setErrorTo('chainId', { return null;
key: errorKey, } else if (matchingChainId) {
msg: errorMessage, errorKey = 'chainIdExistsErrorMsg';
}); errorMessage = t('chainIdExistsErrorMsg', [
return false; matchingChainId.label ?? matchingChainId.labelKey,
} ]);
} else if (formChainId.startsWith('0x')) {
setErrorEmpty('chainId'); radix = 16;
return true; if (!/^0x[0-9a-f]+$/iu.test(formChainId)) {
}; errorKey = 'invalidHexNumber';
errorMessage = t('invalidHexNumber');
const validateBlockExplorerURL = (url) => { } else if (!isPrefixedFormattedHexString(formChainId)) {
if (!validUrl.isWebUri(url) && url !== '') { errorMessage = t('invalidHexNumberLeadingZeros');
let errorKey; }
let errorMessage; } else if (!/^[0-9]+$/u.test(formChainId)) {
errorKey = 'invalidNumber';
if (isValidWhenAppended(url)) { errorMessage = t('invalidNumber');
errorKey = 'urlErrorMsg'; } else if (formChainId.startsWith('0')) {
errorMessage = t('urlErrorMsg'); errorKey = 'invalidNumberLeadingZeros';
} else { errorMessage = t('invalidNumberLeadingZeros');
errorKey = 'invalidBlockExplorerURL'; } else if (!isSafeChainId(parseInt(formChainId, radix))) {
errorMessage = t('invalidBlockExplorerURL'); errorKey = 'invalidChainIdTooBig';
errorMessage = t('invalidChainIdTooBig');
} }
setErrorTo('blockExplorerUrl', { let endpointChainId;
key: errorKey, let providerError;
msg: errorMessage,
});
} else {
setErrorEmpty('blockExplorerUrl');
}
};
const validateUrlRpcUrl = (url) => { try {
const isValidUrl = validUrl.isWebUri(url); endpointChainId = await jsonRpcRequest(rpcUrl, 'eth_chainId');
const chainIdFetchFailed = hasError('chainId', 'failedToFetchChainId'); } catch (err) {
const [matchingRPCUrl] = networksToRender.filter((e) => e.rpcUrl === url); log.warn('Failed to fetch the chainId from the endpoint.', err);
providerError = err;
if (!isValidUrl && url !== '') {
let errorKey;
let errorMessage;
if (isValidWhenAppended(url)) {
errorKey = 'urlErrorMsg';
errorMessage = t('urlErrorMsg');
} else {
errorKey = 'invalidRPC';
errorMessage = t('invalidRPC');
} }
setErrorTo('rpcUrl', {
key: errorKey, if (rpcUrl && formChainId) {
msg: errorMessage, if (providerError || typeof endpointChainId !== 'string') {
}); errorKey = 'failedToFetchChainId';
} else if (matchingRPCUrl) { errorMessage = t('failedToFetchChainId');
setErrorTo('rpcUrl', { } else if (hexChainId !== endpointChainId) {
key: 'urlExistsErrorMsg', // Here, we are in an error state. The endpoint should always return a
msg: t('urlExistsErrorMsg', [ // hexadecimal string. If the user entered a decimal string, we attempt
matchingRPCUrl.label ?? matchingRPCUrl.labelKey, // to convert the endpoint's return value to decimal before rendering it
]), // in an error message in the form.
}); if (!formChainId.startsWith('0x')) {
} else { try {
setErrorEmpty('rpcUrl'); endpointChainId = parseInt(endpointChainId, 16).toString(10);
} catch (err) {
log.warn(
'Failed to convert endpoint chain ID to decimal',
endpointChainId,
);
}
}
errorKey = 'endpointReturnedDifferentChainId';
errorMessage = t('endpointReturnedDifferentChainId', [
endpointChainId.length <= 12
? endpointChainId
: `${endpointChainId.slice(0, 9)}...`,
]);
}
}
if (errorKey) {
return {
key: errorKey,
msg: errorMessage,
};
}
return null;
},
[rpcUrl, networksToRender, t],
);
/**
* Validates the ticker symbol by checking it against the nativeCurrency.symbol return
* value from chainid.network trusted chain data
* Assumes that all strings are non-empty and correctly formatted.
*
* @param {string} formChainId - The Chain ID currently entered in the form.
* @param {string} formTickerSymbol - The ticker/currency symbol currently entered in the form.
*/
const validateTickerSymbol = useCallback(
async (formChainId, formTickerSymbol) => {
let warningKey;
let warningMessage;
let safeChainsList;
let providerError;
if (!formChainId || !formTickerSymbol) {
return null;
}
try {
safeChainsList = await fetchWithCache(
'https://chainid.network/chains.json',
);
} catch (err) {
log.warn('Failed to fetch the chainList from chainid.network', err);
providerError = err;
}
if (providerError || !Array.isArray(safeChainsList)) {
warningKey = 'failedToFetchTickerSymbolData';
warningMessage = t('failedToFetchTickerSymbolData');
} else {
const matchedChain = safeChainsList?.find(
(chain) => chain.chainId.toString() === formChainId,
);
if (matchedChain === undefined) {
warningKey = 'failedToFetchTickerSymbolData';
warningMessage = t('failedToFetchTickerSymbolData');
} else {
const returnedTickerSymbol = matchedChain.nativeCurrency?.symbol;
if (returnedTickerSymbol !== formTickerSymbol) {
warningKey = 'chainListReturnedDifferentTickerSymbol';
warningMessage = t('chainListReturnedDifferentTickerSymbol', [
formChainId,
returnedTickerSymbol,
]);
}
}
}
if (warningKey) {
return {
key: warningKey,
msg: warningMessage,
};
}
return null;
},
[t],
);
const validateRPCUrl = useCallback(
(url) => {
const isValidUrl = validUrl.isWebUri(url);
const [
{
rpcUrl: matchingRPCUrl = null,
label: matchingRPCLabel,
labelKey: matchingRPCLabelKey,
} = {},
] = networksToRender.filter((e) => e.rpcUrl === url);
const { rpcUrl: selectedNetworkRpcUrl } = selectedNetwork;
if (!isValidUrl && url !== '') {
let errorKey;
let errorMessage;
if (isValidWhenAppended(url)) {
errorKey = 'urlErrorMsg';
errorMessage = t('urlErrorMsg');
} else {
errorKey = 'invalidRPC';
errorMessage = t('invalidRPC');
}
return {
key: errorKey,
msg: errorMessage,
};
} else if (matchingRPCUrl && matchingRPCUrl !== selectedNetworkRpcUrl) {
return {
key: 'urlExistsErrorMsg',
msg: t('urlExistsErrorMsg', [
matchingRPCLabel ?? matchingRPCLabelKey,
]),
};
}
return null;
},
[selectedNetwork, networksToRender, t],
);
// validation effect
const previousRpcUrl = usePrevious(rpcUrl);
const previousChainId = usePrevious(chainId);
const previousTicker = usePrevious(ticker);
const previousBlockExplorerUrl = usePrevious(blockExplorerUrl);
useEffect(() => {
if (viewOnly) {
return;
} }
// Re-validate the chain id if it could not be found with previous rpc url if (
if (chainId && isValidUrl && chainIdFetchFailed) { previousRpcUrl === rpcUrl &&
const formChainId = chainId.trim().toLowerCase(); previousChainId === chainId &&
const prefixedChainId = prefixChainId(formChainId); previousTicker === ticker &&
validateChainIdOnSubmit(formChainId, prefixedChainId, url); previousBlockExplorerUrl === blockExplorerUrl
) {
return;
} }
}; async function validate() {
const chainIdError = await validateChainId(chainId);
const tickerWarning = await validateTickerSymbol(chainId, ticker);
const blockExplorerError = validateBlockExplorerURL(blockExplorerUrl);
const rpcUrlError = validateRPCUrl(rpcUrl);
setErrors({
...errors,
chainId: chainIdError,
blockExplorerUrl: blockExplorerError,
rpcUrl: rpcUrlError,
});
setWarnings({
...warnings,
ticker: tickerWarning,
});
}
validate();
}, [
errors,
warnings,
rpcUrl,
chainId,
ticker,
blockExplorerUrl,
viewOnly,
label,
previousRpcUrl,
previousChainId,
previousTicker,
previousBlockExplorerUrl,
validateBlockExplorerURL,
validateChainId,
validateTickerSymbol,
validateRPCUrl,
]);
const onSubmit = async () => { const onSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
@ -387,13 +472,6 @@ const NetworksForm = ({
const formChainId = chainId.trim().toLowerCase(); const formChainId = chainId.trim().toLowerCase();
const prefixedChainId = prefixChainId(formChainId); const prefixedChainId = prefixChainId(formChainId);
if (
!(await validateChainIdOnSubmit(formChainId, prefixedChainId, rpcUrl))
) {
setIsSubmitting(false);
return;
}
// After this point, isSubmitting will be reset in componentDidUpdate // After this point, isSubmitting will be reset in componentDidUpdate
if (selectedNetwork.rpcUrl && rpcUrl !== selectedNetwork.rpcUrl) { if (selectedNetwork.rpcUrl && rpcUrl !== selectedNetwork.rpcUrl) {
await dispatch( await dispatch(
@ -453,7 +531,12 @@ const NetworksForm = ({
const deletable = !isCurrentRpcTarget && !viewOnly && !addNewNetwork; const deletable = !isCurrentRpcTarget && !viewOnly && !addNewNetwork;
const stateUnchanged = stateIsUnchanged(); const stateUnchanged = stateIsUnchanged();
const isSubmitDisabled = const isSubmitDisabled =
hasErrors() || isSubmitting || stateUnchanged || !rpcUrl || !chainId; hasErrors() ||
isSubmitting ||
stateUnchanged ||
!rpcUrl ||
!chainId ||
!ticker;
return ( return (
<div <div
@ -488,39 +571,29 @@ const NetworksForm = ({
/> />
<FormField <FormField
error={errors.rpcUrl?.msg || ''} error={errors.rpcUrl?.msg || ''}
onChange={(value) => { onChange={setRpcUrl}
setRpcUrl(value);
validateUrlRpcUrl(value);
}}
titleText={t('rpcUrl')} titleText={t('rpcUrl')}
value={rpcUrl} value={rpcUrl}
disabled={viewOnly} disabled={viewOnly}
/> />
<FormField <FormField
error={errors.chainId?.msg || ''} error={errors.chainId?.msg || ''}
onChange={(value) => { onChange={setChainId}
setChainId(value);
validateChainIdOnChange(value);
}}
titleText={t('chainId')} titleText={t('chainId')}
value={chainId} value={chainId}
disabled={viewOnly} disabled={viewOnly}
tooltipText={viewOnly ? null : t('networkSettingsChainIdDescription')} tooltipText={viewOnly ? null : t('networkSettingsChainIdDescription')}
/> />
<FormField <FormField
error={errors.ticker?.msg || ''} warning={warnings.ticker?.msg || ''}
onChange={setTicker} onChange={setTicker}
titleText={t('currencySymbol')} titleText={t('currencySymbol')}
titleUnit={t('optionalWithParanthesis')}
value={ticker} value={ticker}
disabled={viewOnly} disabled={viewOnly}
/> />
<FormField <FormField
error={errors.blockExplorerUrl?.msg || ''} error={errors.blockExplorerUrl?.msg || ''}
onChange={(value) => { onChange={setBlockExplorerUrl}
setBlockExplorerUrl(value);
validateBlockExplorerURL(value);
}}
titleText={t('blockExplorerUrl')} titleText={t('blockExplorerUrl')}
titleUnit={t('optionalWithParanthesis')} titleUnit={t('optionalWithParanthesis')}
value={blockExplorerUrl} value={blockExplorerUrl}

View File

@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import { fireEvent } from '@testing-library/react'; import { fireEvent, screen } from '@testing-library/react';
import nock from 'nock';
import { renderWithProvider } from '../../../../../test/jest/rendering'; import { renderWithProvider } from '../../../../../test/jest/rendering';
import { defaultNetworksData } from '../networks-tab.constants'; import { defaultNetworksData } from '../networks-tab.constants';
import { MAINNET_RPC_URL } from '../../../../../shared/constants/network';
import NetworksForm from '.'; import NetworksForm from '.';
const renderComponent = (props) => { const renderComponent = (props) => {
@ -36,7 +38,51 @@ const propNetworkDisplay = {
}; };
describe('NetworkForm Component', () => { describe('NetworkForm Component', () => {
it('should render Add new network form correctly', () => { beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.enableNetConnect();
});
beforeEach(() => {
nock('https://chainid.network:443', { encodedQueryParams: true })
.get('/chains.json')
.reply(200, [
{
name: 'Polygon Mainnet',
chain: 'Polygon',
rpc: [
'https://polygon-rpc.com/',
'https://rpc-mainnet.matic.network',
'https://matic-mainnet.chainstacklabs.com',
'https://rpc-mainnet.maticvigil.com',
'https://rpc-mainnet.matic.quiknode.pro',
'https://matic-mainnet-full-rpc.bwarelabs.com',
],
nativeCurrency: {
name: 'MATIC',
symbol: 'MATIC',
decimals: 18,
},
shortName: 'MATIC',
chainId: 137,
},
]);
nock('https://bsc-dataseed.binance.org:443', {
encodedQueryParams: true,
})
.post('/')
.reply(200, { jsonrpc: '2.0', id: '1643927040523', result: '0x38' });
});
afterEach(() => {
nock.cleanAll();
});
it('should render add new network form correctly', async () => {
const { queryByText, queryAllByText } = renderComponent(propNewNetwork); const { queryByText, queryAllByText } = renderComponent(propNewNetwork);
expect( expect(
queryByText( queryByText(
@ -48,9 +94,30 @@ describe('NetworkForm Component', () => {
expect(queryByText('Chain ID')).toBeInTheDocument(); expect(queryByText('Chain ID')).toBeInTheDocument();
expect(queryByText('Currency Symbol')).toBeInTheDocument(); expect(queryByText('Currency Symbol')).toBeInTheDocument();
expect(queryByText('Block Explorer URL')).toBeInTheDocument(); expect(queryByText('Block Explorer URL')).toBeInTheDocument();
expect(queryAllByText('(Optional)')).toHaveLength(2); expect(queryAllByText('(Optional)')).toHaveLength(1);
expect(queryByText('Cancel')).toBeInTheDocument(); expect(queryByText('Cancel')).toBeInTheDocument();
expect(queryByText('Save')).toBeInTheDocument(); expect(queryByText('Save')).toBeInTheDocument();
await fireEvent.change(screen.getByRole('textbox', { name: 'Chain ID' }), {
target: { value: '1' },
});
expect(
await screen.findByText(
'This Chain ID is currently used by the mainnet network.',
),
).toBeInTheDocument();
await fireEvent.change(
screen.getByRole('textbox', { name: 'New RPC URL' }),
{
target: { value: 'test' },
},
);
expect(
await screen.findByText(
'URLs require the appropriate HTTP/HTTPS prefix.',
),
).toBeInTheDocument();
}); });
it('should render network form correctly', () => { it('should render network form correctly', () => {
@ -81,31 +148,121 @@ describe('NetworkForm Component', () => {
expect( expect(
getByDisplayValue(propNetworkDisplay.selectedNetwork.blockExplorerUrl), getByDisplayValue(propNetworkDisplay.selectedNetwork.blockExplorerUrl),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.change( });
getByDisplayValue(propNetworkDisplay.selectedNetwork.label),
{ it('should validate RPC URL field correctly', async () => {
target: { value: 'LocalHost 8545' }, renderComponent(propNewNetwork);
},
); const rpcUrlField = screen.getByRole('textbox', { name: 'New RPC URL' });
expect(getByDisplayValue('LocalHost 8545')).toBeInTheDocument(); await fireEvent.change(rpcUrlField, {
fireEvent.change( target: { value: 'test' },
getByDisplayValue(propNetworkDisplay.selectedNetwork.chainId), });
{
target: { value: '1' },
},
);
expect( expect(
queryByText('This Chain ID is currently used by the mainnet network.'), await screen.findByText(
'URLs require the appropriate HTTP/HTTPS prefix.',
),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.change( await fireEvent.change(rpcUrlField, {
getByDisplayValue(propNetworkDisplay.selectedNetwork.rpcUrl), target: { value: ' ' },
{ });
target: { value: 'test' }, expect(await screen.findByText('Invalid RPC URL')).toBeInTheDocument();
},
); await fireEvent.change(rpcUrlField, {
target: { value: MAINNET_RPC_URL },
});
expect( expect(
queryByText('URLs require the appropriate HTTP/HTTPS prefix.'), await screen.findByText(
'This URL is currently used by the mainnet network.',
),
).toBeInTheDocument();
});
it('should validate chain id field correctly', async () => {
renderComponent(propNewNetwork);
const chainIdField = screen.getByRole('textbox', { name: 'Chain ID' });
const rpcUrlField = screen.getByRole('textbox', { name: 'New RPC URL' });
fireEvent.change(chainIdField, {
target: { value: '1' },
});
expect(
await screen.findByText(
'This Chain ID is currently used by the mainnet network.',
),
).toBeInTheDocument();
fireEvent.change(rpcUrlField, {
target: { value: 'https://bsc-dataseed.binance.org/' },
});
const expectedWarning =
'The RPC URL you have entered returned a different chain ID (56). Please update the Chain ID to match the RPC URL of the network you are trying to add.';
expect(await screen.findByText(expectedWarning)).toBeInTheDocument();
fireEvent.change(chainIdField, {
target: { value: 'a' },
});
expect(
await screen.findByText('Invalid hexadecimal number.'),
).toBeInTheDocument();
// reset RCP URL field
fireEvent.change(rpcUrlField, {
target: { value: '' },
});
fireEvent.change(chainIdField, {
target: { value: '00000012314' },
});
expect(
await screen.findByText('Invalid number. Remove any leading zeros.'),
).toBeInTheDocument();
});
it('should validate currency symbol field correctly', async () => {
renderComponent(propNewNetwork);
const chainIdField = screen.getByRole('textbox', { name: 'Chain ID' });
const currencySymbolField = screen.getByRole('textbox', {
name: 'Currency Symbol',
});
fireEvent.change(chainIdField, {
target: { value: '1234' },
});
fireEvent.change(currencySymbolField, {
target: { value: 'abcd' },
});
const expectedWarning =
'Ticker symbol verification data is currently unavailable, make sure that the symbol you have entered is correct. It will impact the conversion rates that you see for this network';
expect(await screen.findByText(expectedWarning)).toBeInTheDocument();
fireEvent.change(chainIdField, {
target: { value: '137' },
});
const secondExpectedWarning =
'The network with chain ID 137 may use a different currency symbol (MATIC) than the one you have entered. Please verify before continuing.';
expect(await screen.findByText(secondExpectedWarning)).toBeInTheDocument();
});
it('should validate block explorer url field correctly', async () => {
renderComponent(propNewNetwork);
const blockExplorerUrlField = screen.getByRole('textbox', {
name: 'Block Explorer URL (Optional)',
});
fireEvent.change(blockExplorerUrlField, {
target: { value: '1234' },
});
expect(
await screen.findByText(
'URLs require the appropriate HTTP/HTTPS prefix.',
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import { fireEvent } from '@testing-library/react'; import { fireEvent, screen } from '@testing-library/react';
import { renderWithProvider } from '../../../../../test/jest/rendering'; import { renderWithProvider } from '../../../../../test/jest/rendering';
import { defaultNetworksData } from '../networks-tab.constants'; import { defaultNetworksData } from '../networks-tab.constants';
import NetworksTabContent from '.'; import NetworksTabContent from '.';
@ -45,7 +45,7 @@ const props = {
}; };
describe('NetworksTabContent Component', () => { describe('NetworksTabContent Component', () => {
it('should render networks tab content correctly', () => { it('should render networks tab content correctly', async () => {
const { queryByText, getByDisplayValue } = renderComponent(props); const { queryByText, getByDisplayValue } = renderComponent(props);
expect(queryByText('Ethereum Mainnet')).toBeInTheDocument(); expect(queryByText('Ethereum Mainnet')).toBeInTheDocument();
@ -71,22 +71,29 @@ describe('NetworksTabContent Component', () => {
expect( expect(
getByDisplayValue(props.selectedNetwork.blockExplorerUrl), getByDisplayValue(props.selectedNetwork.blockExplorerUrl),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.change(getByDisplayValue(props.selectedNetwork.label), { fireEvent.change(getByDisplayValue(props.selectedNetwork.label), {
target: { value: 'LocalHost 8545' }, target: { value: 'LocalHost 8545' },
}); });
expect(getByDisplayValue('LocalHost 8545')).toBeInTheDocument(); expect(await getByDisplayValue('LocalHost 8545')).toBeInTheDocument();
fireEvent.change(getByDisplayValue(props.selectedNetwork.chainId), {
target: { value: '1' },
});
expect(
queryByText('This Chain ID is currently used by the mainnet network.'),
).toBeInTheDocument();
fireEvent.change(getByDisplayValue(props.selectedNetwork.rpcUrl), { fireEvent.change(getByDisplayValue(props.selectedNetwork.rpcUrl), {
target: { value: 'test' }, target: { value: 'test' },
}); });
expect( expect(
queryByText('URLs require the appropriate HTTP/HTTPS prefix.'), await screen.findByText(
'URLs require the appropriate HTTP/HTTPS prefix.',
),
).toBeInTheDocument();
fireEvent.change(getByDisplayValue(props.selectedNetwork.chainId), {
target: { value: '1' },
});
expect(
await screen.findByText(
'Could not fetch chain ID. Is your RPC URL correct?',
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });