diff --git a/abi/lidoLocator.abi.json b/abi/lidoLocator.abi.json new file mode 100644 index 000000000..078f17b1d --- /dev/null +++ b/abi/lidoLocator.abi.json @@ -0,0 +1,135 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "implementation_", + "type": "address" + }, + { "internalType": "address", "name": "admin_", "type": "address" }, + { "internalType": "bytes", "name": "data_", "type": "bytes" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { "inputs": [], "name": "NotAdmin", "type": "error" }, + { "inputs": [], "name": "ProxyIsOssified", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "previousAdmin", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "AdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "beacon", + "type": "address" + } + ], + "name": "BeaconUpgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "ProxyOssified", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { "stateMutability": "payable", "type": "fallback" }, + { + "inputs": [ + { "internalType": "address", "name": "newAdmin_", "type": "address" } + ], + "name": "proxy__changeAdmin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "proxy__getAdmin", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxy__getImplementation", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxy__getIsOssified", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxy__ossify", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation_", + "type": "address" + } + ], + "name": "proxy__upgradeTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation_", + "type": "address" + }, + { "internalType": "bytes", "name": "setupCalldata_", "type": "bytes" }, + { "internalType": "bool", "name": "forceCall_", "type": "bool" } + ], + "name": "proxy__upgradeToAndCall", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { "stateMutability": "payable", "type": "receive" } +] diff --git a/config/groups/web3.ts b/config/groups/web3.ts index 3e072920d..ea3bbe8b5 100644 --- a/config/groups/web3.ts +++ b/config/groups/web3.ts @@ -4,6 +4,8 @@ import { parseEther } from '@ethersproject/units'; export const PROVIDER_POLLING_INTERVAL = 12_000; // how long in ms to wait for RPC batching(multicall and provider) export const PROVIDER_BATCH_TIME = 150; +// max batch +export const PROVIDER_MAX_BATCH = 10; // account for gas estimation // will always have >=0.001 ether, >=0.001 stETH, >=0.001 wstETH diff --git a/features/rewards/hooks/use-steth-eth-rate.ts b/features/rewards/hooks/use-steth-eth-rate.ts index c4fcd063e..39ddb8fa9 100644 --- a/features/rewards/hooks/use-steth-eth-rate.ts +++ b/features/rewards/hooks/use-steth-eth-rate.ts @@ -7,7 +7,7 @@ import { PartialCurveAbiAbi__factory } from 'generated'; import { createContractGetter } from '@lido-sdk/contracts'; const getCurveContract = createContractGetter(PartialCurveAbiAbi__factory); -const MAINNET_CURVE = '0xDC24316b9AE028F1497c275EB9192a3Ea0f67022'; +export const MAINNET_CURVE = '0xDC24316b9AE028F1497c275EB9192a3Ea0f67022'; export const useStethEthRate = () => { const { chainId } = useSDK(); diff --git a/pages/api/rpc.ts b/pages/api/rpc.ts index 01eaa99d1..523f49ffe 100644 --- a/pages/api/rpc.ts +++ b/pages/api/rpc.ts @@ -1,4 +1,3 @@ -import { rpcFactory } from '@lidofinance/next-pages'; import { CHAINS } from '@lido-sdk/constants'; import { wrapRequest as wrapNextRequest } from '@lidofinance/next-api-wrapper'; import { trackedFetchRpcFactory } from '@lidofinance/api-rpc'; @@ -15,17 +14,34 @@ import { HttpMethod, } from 'utilsApi'; import Metrics from 'utilsApi/metrics'; +import { rpcFactory } from 'utilsApi/rpcFactory'; +import { METRIC_CONTRACT_ADDRESSES } from 'utilsApi/contractAddressesMetricsMap'; + +const allowedCallAddresses: Record = Object.entries( + METRIC_CONTRACT_ADDRESSES, +).reduce( + (acc, [chainId, addresses]) => { + acc[chainId] = Object.keys(addresses); + return acc; + }, + {} as Record, +); const rpc = rpcFactory({ fetchRPC: trackedFetchRpcFactory({ registry: Metrics.registry, prefix: METRICS_PREFIX, }), - serverLogger: console, metrics: { prefix: METRICS_PREFIX, registry: Metrics.registry, }, + defaultChain: `${config.defaultChain}`, + providers: { + [CHAINS.Mainnet]: secretConfig.rpcUrls_1, + [CHAINS.Holesky]: secretConfig.rpcUrls_17000, + [CHAINS.Sepolia]: secretConfig.rpcUrls_11155111, + }, allowedRPCMethods: [ 'test', 'eth_call', @@ -44,12 +60,8 @@ const rpc = rpcFactory({ 'eth_chainId', 'net_version', ], - defaultChain: `${config.defaultChain}`, - providers: { - [CHAINS.Mainnet]: secretConfig.rpcUrls_1, - [CHAINS.Holesky]: secretConfig.rpcUrls_17000, - [CHAINS.Sepolia]: secretConfig.rpcUrls_11155111, - }, + allowedCallAddresses, + maxBatchCount: 10, }); export default wrapNextRequest([ diff --git a/providers/web3.tsx b/providers/web3.tsx index a7abd995f..e68442d82 100644 --- a/providers/web3.tsx +++ b/providers/web3.tsx @@ -85,12 +85,8 @@ const Web3Provider: FC = ({ children }) => { chains: supportedChains, ssr: true, connectors: [], - batch: { - // eth_call's can be batched via multicall contract - multicall: { - wait: config.PROVIDER_BATCH_TIME, - }, + multicall: false, }, multiInjectedProviderDiscovery: false, pollingInterval: config.PROVIDER_POLLING_INTERVAL, diff --git a/utils/use-web3-transport.ts b/utils/use-web3-transport.ts index 26a594d04..682b9a9e6 100644 --- a/utils/use-web3-transport.ts +++ b/utils/use-web3-transport.ts @@ -90,11 +90,17 @@ export const useWeb3Transport = ( ({ transportMap, setTransportMap }, chain) => { const [transport, setTransport] = runtimeMutableTransport([ http(backendRpcMap[chain.id], { - batch: { wait: config.PROVIDER_BATCH_TIME }, + batch: { + wait: config.PROVIDER_BATCH_TIME, + batchSize: config.PROVIDER_MAX_BATCH, + }, name: backendRpcMap[chain.id], }), http(undefined, { - batch: { wait: config.PROVIDER_BATCH_TIME }, + batch: { + wait: config.PROVIDER_BATCH_TIME, + batchSize: config.PROVIDER_MAX_BATCH, + }, name: 'default HTTP RPC', }), ]); diff --git a/utilsApi/contractAddressesMetricsMap.ts b/utilsApi/contractAddressesMetricsMap.ts index 1d4f255ee..31de19419 100644 --- a/utilsApi/contractAddressesMetricsMap.ts +++ b/utilsApi/contractAddressesMetricsMap.ts @@ -17,6 +17,14 @@ import { import { config } from 'config'; import { getAggregatorStEthUsdPriceFeedAddress } from 'consts/aggregator'; +import { + PartialCurveAbiAbi__factory, + PartialStakingRouterAbi__factory, + LidoLocatorAbi__factory, +} from 'generated'; +import { getStakingRouterAddress } from 'consts/staking-router'; +import { MAINNET_CURVE } from 'features/rewards/hooks/use-steth-eth-rate'; +import { LIDO_LOCATOR_BY_CHAIN } from '@lidofinance/lido-ethereum-sdk'; export const CONTRACT_NAMES = { stETH: 'stETH', @@ -24,6 +32,9 @@ export const CONTRACT_NAMES = { WithdrawalQueue: 'WithdrawalQueue', Aggregator: 'Aggregator', AggregatorStEthUsdPriceFeed: 'AggregatorStEthUsdPriceFeed', + StakingRouter: 'StakingRouter', + StethCurve: 'StethCurve', + LidoLocator: 'LidoLocator', } as const; export type CONTRACT_NAMES = keyof typeof CONTRACT_NAMES; @@ -33,6 +44,9 @@ export const METRIC_CONTRACT_ABIS = { [CONTRACT_NAMES.WithdrawalQueue]: WithdrawalQueueAbiFactory.abi, [CONTRACT_NAMES.Aggregator]: AggregatorAbiFactory.abi, [CONTRACT_NAMES.AggregatorStEthUsdPriceFeed]: AggregatorAbiFactory.abi, + [CONTRACT_NAMES.StakingRouter]: PartialStakingRouterAbi__factory.abi, + [CONTRACT_NAMES.StethCurve]: PartialCurveAbiAbi__factory.abi, + [CONTRACT_NAMES.LidoLocator]: LidoLocatorAbi__factory.abi, } as const; export const getMetricContractInterface = memoize( @@ -82,6 +96,17 @@ export const METRIC_CONTRACT_ADDRESSES = ( getAggregatorStEthUsdPriceFeedAddress, chainId, ), + [CONTRACT_NAMES.StakingRouter]: getAddressOrNull( + getStakingRouterAddress, + chainId, + ), + [CONTRACT_NAMES.StethCurve]: getAddressOrNull((chainId: CHAINS) => { + if (chainId === 1) return MAINNET_CURVE; + else throw new Error('no contract address'); + }, chainId), + [CONTRACT_NAMES.LidoLocator]: getAddressOrNull((chainId: CHAINS) => { + return (LIDO_LOCATOR_BY_CHAIN as any)[chainId] as string; + }, chainId), }; return { ...mapped, diff --git a/utilsApi/rpcFactory.ts b/utilsApi/rpcFactory.ts new file mode 100644 index 000000000..7e4841f1f --- /dev/null +++ b/utilsApi/rpcFactory.ts @@ -0,0 +1,152 @@ +import { Readable } from 'node:stream'; +import { ReadableStream } from 'node:stream/web'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { Counter, Registry } from 'prom-client'; +import type { TrackedFetchRPC } from '@lidofinance/api-rpc'; +import type { FetchRpcInitBody } from '@lidofinance/rpc'; +import { iterateUrls } from '@lidofinance/rpc'; + +export type RpcProviders = Record; + +export const DEFAULT_API_ERROR_MESSAGE = + 'Something went wrong. Sorry, try again later :('; + +export const HEALTHY_RPC_SERVICES_ARE_OVER = 'Healthy RPC services are over!'; + +export class UnsupportedChainIdError extends Error { + constructor(message?: string) { + super(message || 'Unsupported chainId'); + } +} + +export class UnsupportedHTTPMethodError extends Error { + constructor(message?: string) { + super(message || 'Unsupported HTTP method'); + } +} + +export type RPCFactoryParams = { + metrics: { + prefix: string; + registry: Registry; + }; + providers: RpcProviders; + fetchRPC: TrackedFetchRPC; + defaultChain: string | number; + // If we don't specify allowed RPC methods, then we can't use + // fetchRPC with prometheus, otherwise it will blow up, if someone will send arbitrary + // methods + allowedRPCMethods: string[]; + // filtration by eth_call to addresses + allowedCallAddresses?: Record; + maxBatchCount?: number; +}; + +export const rpcFactory = ({ + metrics: { prefix, registry }, + providers, + fetchRPC, + defaultChain, + allowedRPCMethods, + allowedCallAddresses = {}, + maxBatchCount, +}: RPCFactoryParams) => { + const rpcRequestBlocked = new Counter({ + name: prefix + 'rpc_service_request_blocked', + help: 'RPC service request blocked', + labelNames: [], + registers: [], + }); + registry.registerMetric(rpcRequestBlocked); + + const allowedCallAddressMap = Object.entries(allowedCallAddresses).reduce( + (acc, [chainId, addresses]) => { + acc[chainId] = new Set(addresses.map((a) => a.toLowerCase())); + return acc; + }, + {} as Record>, + ); + + return async (req: NextApiRequest, res: NextApiResponse): Promise => { + try { + // Accept only POST requests + if (req.method !== 'POST') { + // We don't care about tracking blocked requests here + throw new UnsupportedHTTPMethodError(); + } + + const chainId = Number(req.query.chainId || defaultChain); + + // Allow only chainId of specified chains + if (providers[chainId] == null) { + // We don't care about tracking blocked requests here + throw new UnsupportedChainIdError(); + } + + const requests = Array.isArray(req.body) ? req.body : [req.body]; + + if ( + typeof maxBatchCount === 'number' && + requests.length > maxBatchCount + ) { + throw new Error(`Too many batched requests`); + } + + // TODO: consider returning array of validators instead of throwing error right away + + // Check if provided methods are allowed + for (const { method, params } of requests) { + if (typeof method !== 'string') { + throw new Error(`RPC method isn't string`); + } + if (!allowedRPCMethods.includes(method)) { + rpcRequestBlocked.inc(); + throw new Error(`RPC method ${method} isn't allowed`); + } + if (method === 'eth_call' && allowedCallAddressMap[chainId]) { + if ( + Array.isArray(params) && + typeof params[0] === 'object' && + typeof params[0].to === 'string' + ) { + if ( + !allowedCallAddressMap[chainId].has(params[0].to.toLowerCase()) + ) { + rpcRequestBlocked.inc(); + throw new Error(`Address not allowed for eth_call`); + } + } else throw new Error(`RPC method eth_call is invalid`); + } + } + + const requested = await iterateUrls( + providers[chainId], + // TODO: consider adding verification that body is actually matches FetchRpcInitBody + (url) => + fetchRPC(url, { body: req.body as FetchRpcInitBody }, { chainId }), + // eslint-disable-next-line @typescript-eslint/unbound-method + console.error, + ); + + res.setHeader( + 'Content-Type', + requested.headers.get('Content-Type') ?? 'application/json', + ); + if (requested.body) { + Readable.fromWeb(requested.body as ReadableStream).pipe(res); + } else { + res + .status(requested.status) + .json('There are a problems with RPC provider'); + } + } catch (error) { + if (error instanceof Error) { + // TODO: check if there are errors duplication with iterateUrls + console.error(error.message ?? DEFAULT_API_ERROR_MESSAGE); + res.status(500).json(error.message ?? DEFAULT_API_ERROR_MESSAGE); + } else { + res.status(500).json(HEALTHY_RPC_SERVICES_ARE_OVER); + } + } + }; +};