import { ethErrors, errorCodes } from 'eth-rpc-errors'; import validUrl from 'valid-url'; import { omit } from 'lodash'; import { MESSAGE_TYPE, UNKNOWN_TICKER_SYMBOL, } from '../../../../../shared/constants/app'; import { EVENT } from '../../../../../shared/constants/metametrics'; import { isPrefixedFormattedHexString, isSafeChainId, } from '../../../../../shared/modules/network.utils'; import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/network'; const addEthereumChain = { methodNames: [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN], implementation: addEthereumChainHandler, hookNames: { addCustomRpc: true, getCurrentChainId: true, getCurrentRpcUrl: true, findCustomRpcBy: true, updateRpcTarget: true, requestUserApproval: true, sendMetrics: true, }, }; export default addEthereumChain; async function addEthereumChainHandler( req, res, _next, end, { addCustomRpc, getCurrentChainId, getCurrentRpcUrl, findCustomRpcBy, updateRpcTarget, requestUserApproval, sendMetrics, }, ) { if (!req.params?.[0] || typeof req.params[0] !== 'object') { return end( ethErrors.rpc.invalidParams({ message: `Expected single, object parameter. Received:\n${JSON.stringify( req.params, )}`, }), ); } const { origin } = req; const { chainId, chainName = null, blockExplorerUrls = null, nativeCurrency = null, rpcUrls, } = req.params[0]; const otherKeys = Object.keys( omit(req.params[0], [ 'chainId', 'chainName', 'blockExplorerUrls', 'iconUrls', 'rpcUrls', 'nativeCurrency', ]), ); if (otherKeys.length > 0) { return end( ethErrors.rpc.invalidParams({ message: `Received unexpected keys on object parameter. Unsupported keys:\n${otherKeys}`, }), ); } const isLocalhost = (strUrl) => { try { const url = new URL(strUrl); return url.hostname === 'localhost' || url.hostname === '127.0.0.1'; } catch (error) { return false; } }; const firstValidRPCUrl = Array.isArray(rpcUrls) ? rpcUrls.find( (rpcUrl) => isLocalhost(rpcUrl) || validUrl.isHttpsUri(rpcUrl), ) : null; const firstValidBlockExplorerUrl = blockExplorerUrls !== null && Array.isArray(blockExplorerUrls) ? blockExplorerUrls.find( (blockExplorerUrl) => isLocalhost(blockExplorerUrl) || validUrl.isHttpsUri(blockExplorerUrl), ) : null; if (!firstValidRPCUrl) { return end( ethErrors.rpc.invalidParams({ message: `Expected an array with at least one valid string HTTPS url 'rpcUrls', Received:\n${rpcUrls}`, }), ); } if (blockExplorerUrls !== null && !firstValidBlockExplorerUrl) { return end( ethErrors.rpc.invalidParams({ message: `Expected null or array with at least one valid string HTTPS URL 'blockExplorerUrl'. Received: ${blockExplorerUrls}`, }), ); } const _chainId = typeof chainId === 'string' && chainId.toLowerCase(); if (!isPrefixedFormattedHexString(_chainId)) { return end( ethErrors.rpc.invalidParams({ message: `Expected 0x-prefixed, unpadded, non-zero hexadecimal string 'chainId'. Received:\n${chainId}`, }), ); } if (!isSafeChainId(parseInt(_chainId, 16))) { return end( ethErrors.rpc.invalidParams({ message: `Invalid chain ID "${_chainId}": numerical value greater than max safe value. Received:\n${chainId}`, }), ); } if (CHAIN_ID_TO_NETWORK_ID_MAP[_chainId]) { return end( ethErrors.rpc.invalidParams({ message: `May not specify default MetaMask chain.`, }), ); } const existingNetwork = findCustomRpcBy({ chainId: _chainId }); // if the request is to add a network that is already added and configured // with the same RPC gateway we shouldn't try to add it again. if (existingNetwork && existingNetwork.rpcUrl === firstValidRPCUrl) { // If the network already exists, the request is considered successful res.result = null; const currentChainId = getCurrentChainId(); const currentRpcUrl = getCurrentRpcUrl(); // If the current chainId and rpcUrl matches that of the incoming request // We don't need to proceed further. if (currentChainId === _chainId && currentRpcUrl === firstValidRPCUrl) { return end(); } // If this network is already added with but is not the currently selected network // Ask the user to switch the network try { await updateRpcTarget( await requestUserApproval({ origin, type: MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN, requestData: { rpcUrl: existingNetwork.rpcUrl, chainId: existingNetwork.chainId, nickname: existingNetwork.nickname, ticker: existingNetwork.ticker, }, }), ); res.result = null; } catch (error) { // For the purposes of this method, it does not matter if the user // declines to switch the selected network. However, other errors indicate // that something is wrong. if (error.code !== errorCodes.provider.userRejectedRequest) { return end(error); } } return end(); } let endpointChainId; try { endpointChainId = await jsonRpcRequest(firstValidRPCUrl, 'eth_chainId'); } catch (err) { return end( ethErrors.rpc.internal({ message: `Request for method 'eth_chainId on ${firstValidRPCUrl} failed`, data: { networkErr: err }, }), ); } if (_chainId !== endpointChainId) { return end( ethErrors.rpc.invalidParams({ message: `Chain ID returned by RPC URL ${firstValidRPCUrl} does not match ${_chainId}`, data: { chainId: endpointChainId }, }), ); } if (typeof chainName !== 'string' || !chainName) { return end( ethErrors.rpc.invalidParams({ message: `Expected non-empty string 'chainName'. Received:\n${chainName}`, }), ); } const _chainName = chainName.length > 100 ? chainName.substring(0, 100) : chainName; if (nativeCurrency !== null) { if (typeof nativeCurrency !== 'object' || Array.isArray(nativeCurrency)) { return end( ethErrors.rpc.invalidParams({ message: `Expected null or object 'nativeCurrency'. Received:\n${nativeCurrency}`, }), ); } if (nativeCurrency.decimals !== 18) { return end( ethErrors.rpc.invalidParams({ message: `Expected the number 18 for 'nativeCurrency.decimals' when 'nativeCurrency' is provided. Received: ${nativeCurrency.decimals}`, }), ); } if (!nativeCurrency.symbol || typeof nativeCurrency.symbol !== 'string') { return end( ethErrors.rpc.invalidParams({ message: `Expected a string 'nativeCurrency.symbol'. Received: ${nativeCurrency.symbol}`, }), ); } } const ticker = nativeCurrency?.symbol || UNKNOWN_TICKER_SYMBOL; if ( ticker !== UNKNOWN_TICKER_SYMBOL && (typeof ticker !== 'string' || ticker.length < 2 || ticker.length > 6) ) { return end( ethErrors.rpc.invalidParams({ message: `Expected 2-6 character string 'nativeCurrency.symbol'. Received:\n${ticker}`, }), ); } // if the chainId is the same as an existing network but the ticker is different we want to block this action // as it is potentially malicious and confusing if ( existingNetwork && existingNetwork.chainId === _chainId && existingNetwork.ticker !== ticker ) { return end( ethErrors.rpc.invalidParams({ message: `nativeCurrency.symbol does not match currency symbol for a network the user already has added with the same chainId. Received:\n${ticker}`, }), ); } try { await addCustomRpc( await requestUserApproval({ origin, type: MESSAGE_TYPE.ADD_ETHEREUM_CHAIN, requestData: { chainId: _chainId, blockExplorerUrl: firstValidBlockExplorerUrl, chainName: _chainName, rpcUrl: firstValidRPCUrl, ticker, }, }), ); sendMetrics({ event: 'Custom Network Added', category: EVENT.CATEGORIES.NETWORK, referrer: { url: origin, }, properties: { chain_id: _chainId, network_name: _chainName, symbol: ticker, source: EVENT.SOURCE.TRANSACTION.DAPP, }, }); // Once the network has been added, the requested is considered successful res.result = null; } catch (error) { return end(error); } // Ask the user to switch the network try { await updateRpcTarget( await requestUserApproval({ origin, type: MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN, requestData: { rpcUrl: firstValidRPCUrl, chainId: _chainId, nickname: _chainName, ticker, }, }), ); } catch (error) { // For the purposes of this method, it does not matter if the user // declines to switch the selected network. However, other errors indicate // that something is wrong. if (error.code !== errorCodes.provider.userRejectedRequest) { return end(error); } } return end(); }