|
|
|
import { ethErrors, errorCodes } from 'eth-rpc-errors';
|
|
|
|
import validUrl from 'valid-url';
|
|
|
|
import { omit } from 'lodash';
|
|
|
|
import { MESSAGE_TYPE } 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,
|
|
|
|
findCustomRpcBy: true,
|
|
|
|
updateRpcTarget: true,
|
|
|
|
requestUserApproval: true,
|
|
|
|
sendMetrics: true,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
export default addEthereumChain;
|
|
|
|
|
|
|
|
async function addEthereumChainHandler(
|
|
|
|
req,
|
|
|
|
res,
|
|
|
|
_next,
|
|
|
|
end,
|
|
|
|
{
|
|
|
|
addCustomRpc,
|
|
|
|
getCurrentChainId,
|
|
|
|
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 firstValidRPCUrl = Array.isArray(rpcUrls)
|
|
|
|
? rpcUrls.find((rpcUrl) => validUrl.isHttpsUri(rpcUrl))
|
|
|
|
: null;
|
|
|
|
|
|
|
|
const firstValidBlockExplorerUrl =
|
|
|
|
blockExplorerUrls !== null && Array.isArray(blockExplorerUrls)
|
|
|
|
? blockExplorerUrls.find((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 (existingNetwork) {
|
|
|
|
// If the network already exists, the request is considered successful
|
|
|
|
res.result = null;
|
|
|
|
|
|
|
|
const currentChainId = getCurrentChainId();
|
|
|
|
if (currentChainId === _chainId) {
|
|
|
|
return end();
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 || 'ETH';
|
|
|
|
|
|
|
|
if (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}`,
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
},
|
|
|
|
sensitiveProperties: {
|
|
|
|
chain_id: _chainId,
|
|
|
|
rpc_url: firstValidRPCUrl,
|
|
|
|
network_name: _chainName,
|
|
|
|
// Including network to override the default network
|
|
|
|
// property included in all events. For RPC type networks
|
|
|
|
// the MetaMetrics controller uses the rpcUrl for the network
|
|
|
|
// property.
|
|
|
|
network: firstValidRPCUrl,
|
|
|
|
symbol: ticker,
|
|
|
|
block_explorer_url: firstValidBlockExplorerUrl,
|
|
|
|
source: '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();
|
|
|
|
}
|