1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 17:33:23 +01:00

Add custom network RPC method (#9724)

Co-authored-by: Erik Marks <25517051+rekmarks@users.noreply.github.com>
Co-authored-by: Brad Decker <git@braddecker.dev>
This commit is contained in:
Erik Marks 2021-02-12 07:25:58 -08:00 committed by GitHub
parent d8cda0b093
commit e48053a6d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 381 additions and 64 deletions

View File

@ -21,7 +21,7 @@ import {
import {
isPrefixedFormattedHexString,
isSafeChainId,
} from '../../../../shared/modules/utils';
} from '../../../../shared/modules/network.utils';
import createMetamaskMiddleware from './createMetamaskMiddleware';
import createInfuraClient from './createInfuraClient';
import createJsonRpcClient from './createJsonRpcClient';

View File

@ -7,7 +7,7 @@ import ethers from 'ethers';
import log from 'loglevel';
import { LISTED_CONTRACT_ADDRESSES } from '../../../shared/constants/tokens';
import { NETWORK_TYPE_TO_ID_MAP } from '../../../shared/constants/network';
import { isPrefixedFormattedHexString } from '../../../shared/modules/utils';
import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils';
export default class PreferencesController {
/**

View File

@ -0,0 +1,248 @@
import { ethErrors } from 'eth-rpc-errors';
import validUrl from 'valid-url';
import { omit } from 'lodash';
import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
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,
};
export default addEthereumChain;
async function addEthereumChainHandler(
req,
res,
_next,
end,
{
addCustomRpc,
getCurrentNetwork,
findCustomRpcBy,
updateRpcTarget,
requestUserApproval,
},
) {
if (!req.params?.[0] || typeof req.params[0] !== 'object') {
return end(
ethErrors.rpc.invalidParams({
message: `Expected single, object parameter. Received:\n${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 !== null) {
const currentNetwork = getCurrentNetwork();
if (currentNetwork.chainId === _chainId) {
res.result = null;
return end();
}
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) {
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,
},
}),
);
await updateRpcTarget(
await requestUserApproval({
origin,
type: MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN,
requestData: {
rpcUrl: firstValidRPCUrl,
chainId: _chainId,
nickname: _chainName,
ticker,
},
}),
);
res.result = null;
} catch (error) {
return end(error);
}
return end();
}

View File

@ -1,6 +1,12 @@
// import addEthereumChain from './add-ethereum-chain';
import getProviderState from './get-provider-state';
import logWeb3ShimUsage from './log-web3-shim-usage';
import watchAsset from './watch-asset';
const handlers = [getProviderState, logWeb3ShimUsage, watchAsset];
const handlers = [
// addEthereumChain,
getProviderState,
logWeb3ShimUsage,
watchAsset,
];
export default handlers;

View File

@ -246,7 +246,6 @@ export default class MetamaskController extends EventEmitter {
notifyDomain: this.notifyConnections.bind(this),
notifyAllDomains: this.notifyAllConnections.bind(this),
preferences: this.preferencesController.store,
showPermissionRequest: opts.showUserConfirmation,
},
initState.PermissionsController,
initState.PermissionsMetadata,
@ -578,6 +577,7 @@ export default class MetamaskController extends EventEmitter {
getApi() {
const {
alertController,
approvalController,
keyringController,
metaMetricsController,
networkController,
@ -902,6 +902,16 @@ export default class MetamaskController extends EventEmitter {
metaMetricsController.trackPage,
metaMetricsController,
),
// approval controller
resolvePendingApproval: nodeify(
approvalController.resolve,
approvalController,
),
rejectPendingApproval: nodeify(
approvalController.reject,
approvalController,
),
};
}
@ -2097,6 +2107,36 @@ export default class MetamaskController extends EventEmitter {
setWeb3ShimUsageRecorded: this.alertController.setWeb3ShimUsageRecorded.bind(
this.alertController,
),
findCustomRpcBy: this.findCustomRpcBy.bind(this),
getCurrentNetwork: this.getCurrentNetwork.bind(this),
requestUserApproval: this.approvalController.addAndShowApprovalRequest.bind(
this.approvalController,
),
updateRpcTarget: ({ rpcUrl, chainId, ticker, nickname }) => {
this.networkController.setRpcTarget(
rpcUrl,
chainId,
ticker,
nickname,
);
},
addCustomRpc: async ({
chainId,
blockExplorerUrl,
ticker,
chainName,
rpcUrl,
} = {}) => {
await this.preferencesController.addToFrequentRpcList(
rpcUrl,
chainId,
ticker,
chainName,
{
blockExplorerUrl,
},
);
},
}),
);
// filter and subscription polyfills
@ -2450,8 +2490,10 @@ export default class MetamaskController extends EventEmitter {
* @param {string} rpcUrl - A URL for a valid Ethereum RPC API.
* @param {string} chainId - The chainId of the selected network.
* @param {string} ticker - The ticker symbol of the selected network.
* @param {string} nickname - Optional nickname of the selected network.
* @returns {Promise<String>} The RPC Target URL confirmed.
* @param {string} [nickname] - Nickname of the selected network.
* @param {Object} [rpcPrefs] - RPC preferences.
* @param {string} [rpcPrefs.blockExplorerUrl] - URL of block explorer for the chain.
* @returns {Promise<String>} - The RPC Target URL confirmed.
*/
async updateAndSetCustomRpc(
rpcUrl,
@ -2532,6 +2574,25 @@ export default class MetamaskController extends EventEmitter {
await this.preferencesController.removeFromFrequentRpcList(rpcUrl);
}
/**
* Returns the first RPC info object that matches at least one field of the
* provided search criteria. Returns null if no match is found
*
* @param {Object} rpcInfo - The RPC endpoint properties and values to check.
* @returns {Object} rpcInfo found in the frequentRpcList
*/
findCustomRpcBy(rpcInfo) {
const frequentRpcListDetail = this.preferencesController.getFrequentRpcListDetail();
for (const existingRpcInfo of frequentRpcListDetail) {
for (const key of Object.keys(rpcInfo)) {
if (existingRpcInfo[key] === rpcInfo[key]) {
return existingRpcInfo;
}
}
}
return null;
}
async initializeThreeBox() {
await this.threeBoxController.init();
}

View File

@ -27,4 +27,6 @@ export const MESSAGE_TYPE = {
PERSONAL_SIGN: 'personal_sign',
WATCH_ASSET: 'wallet_watchAsset',
WATCH_ASSET_LEGACY: 'metamask_watchAsset',
ADD_ETHEREUM_CHAIN: 'wallet_addEthereumChain',
SWITCH_ETHEREUM_CHAIN: 'metamask_switchEthereumChain',
};

View File

@ -0,0 +1,54 @@
import getFetchWithTimeout from './fetch-with-timeout';
const fetchWithTimeout = getFetchWithTimeout(30000);
/**
* Makes a JSON RPC request to the given URL, with the given RPC method and params.
*
* @param {string} rpcUrl - The RPC endpoint URL to target.
* @param {string} rpcMethod - The RPC method to request.
* @param {Array<unknown>} [rpcParams] - The RPC method params.
* @returns {Promise<unknown|undefined>} Returns the result of the RPC method call,
* or throws an error in case of failure.
*/
export async function jsonRpcRequest(rpcUrl, rpcMethod, rpcParams = []) {
let fetchUrl = rpcUrl;
const headers = {
'Content-Type': 'application/json',
};
// Convert basic auth URL component to Authorization header
const { origin, pathname, username, password, search } = new URL(rpcUrl);
// URLs containing username and password needs special processing
if (username && password) {
const encodedAuth = Buffer.from(`${username}:${password}`).toString(
'base64',
);
headers.Authorization = `Basic ${encodedAuth}`;
fetchUrl = `${origin}${pathname}${search}`;
}
const jsonRpcResponse = await fetchWithTimeout(fetchUrl, {
method: 'POST',
body: JSON.stringify({
id: Date.now().toString(),
jsonrpc: '2.0',
method: rpcMethod,
params: rpcParams,
}),
headers,
cache: 'default',
}).then((httpResponse) => httpResponse.json());
if (
!jsonRpcResponse ||
Array.isArray(jsonRpcResponse) ||
typeof jsonRpcResponse !== 'object'
) {
throw new Error(`RPC endpoint ${rpcUrl} returned non-object response.`);
}
const { error, result } = jsonRpcResponse;
if (error) {
throw new Error(error?.message || error);
}
return result;
}

View File

@ -3,7 +3,7 @@ import {
getEnvironmentType,
sufficientBalance,
} from '../../../app/scripts/lib/util';
import { isPrefixedFormattedHexString } from '../../../shared/modules/utils';
import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils';
import {
ENVIRONMENT_TYPE_POPUP,

View File

@ -11,7 +11,7 @@ import {
} from '../../../helpers/constants/routes';
import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../../shared/constants/app';
import { NETWORK_TYPE_RPC } from '../../../../../shared/constants/network';
import { isPrefixedFormattedHexString } from '../../../../../shared/modules/utils';
import { isPrefixedFormattedHexString } from '../../../../../shared/modules/network.utils';
import { getEnvironmentType } from '../../../../../app/scripts/lib/util';
import ColorIndicator from '../../ui/color-indicator';

View File

@ -4,9 +4,6 @@ import BigNumber from 'bignumber.js';
import ethUtil from 'ethereumjs-util';
import { DateTime } from 'luxon';
import { addHexPrefix } from '../../../../app/scripts/lib/util';
import getFetchWithTimeout from '../../../../shared/modules/fetch-with-timeout';
const fetchWithTimeout = getFetchWithTimeout(30000);
// formatData :: ( date: <Unix Timestamp> ) -> String
export function formatDate(date, format = "M/d/y 'at' T") {
@ -456,54 +453,3 @@ export function constructTxParams({
}
return addHexPrefixToObjectValues(txParams);
}
/**
* Makes a JSON RPC request to the given URL, with the given RPC method and params.
*
* @param {string} rpcUrl - The RPC endpoint URL to target.
* @param {string} rpcMethod - The RPC method to request.
* @param {Array<unknown>} [rpcParams] - The RPC method params.
* @returns {Promise<unknown|undefined>} Returns the result of the RPC method call,
* or throws an error in case of failure.
*/
export async function jsonRpcRequest(rpcUrl, rpcMethod, rpcParams = []) {
let fetchUrl = rpcUrl;
const headers = {
'Content-Type': 'application/json',
};
// Convert basic auth URL component to Authorization header
const { origin, pathname, username, password, search } = new URL(rpcUrl);
// URLs containing username and password needs special processing
if (username && password) {
const encodedAuth = Buffer.from(`${username}:${password}`).toString(
'base64',
);
headers.Authorization = `Basic ${encodedAuth}`;
fetchUrl = `${origin}${pathname}${search}`;
}
const jsonRpcResponse = await fetchWithTimeout(fetchUrl, {
method: 'POST',
body: JSON.stringify({
id: Date.now().toString(),
jsonrpc: '2.0',
method: rpcMethod,
params: rpcParams,
}),
headers,
cache: 'default',
}).then((httpResponse) => httpResponse.json());
if (
!jsonRpcResponse ||
Array.isArray(jsonRpcResponse) ||
typeof jsonRpcResponse !== 'object'
) {
throw new Error(`RPC endpoint ${rpcUrl} returned non-object response.`);
}
const { error, result } = jsonRpcResponse;
if (error) {
throw new Error(error?.message || error);
}
return result;
}

View File

@ -8,8 +8,8 @@ import Tooltip from '../../../../components/ui/tooltip';
import {
isPrefixedFormattedHexString,
isSafeChainId,
} from '../../../../../../shared/modules/utils';
import { jsonRpcRequest } from '../../../../helpers/utils/util';
} from '../../../../../../shared/modules/network.utils';
import { jsonRpcRequest } from '../../../../../../shared/modules/rpc.utils';
const FORM_STATE_KEYS = [
'rpcUrl',