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:
parent
d8cda0b093
commit
e48053a6d5
@ -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';
|
||||
|
@ -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 {
|
||||
/**
|
||||
|
@ -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();
|
||||
}
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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',
|
||||
};
|
||||
|
54
shared/modules/rpc.utils.js
Normal file
54
shared/modules/rpc.utils.js
Normal 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;
|
||||
}
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user