diff --git a/config/metrics.ts b/config/metrics.ts index 59e678d77..5e1ac3074 100644 --- a/config/metrics.ts +++ b/config/metrics.ts @@ -4,4 +4,5 @@ export const enum METRIC_NAMES { REQUESTS_TOTAL = 'requests_total', API_RESPONSE = 'api_response', SUBGRAPHS_RESPONSE = 'subgraphs_response', + ETH_CALL_ADDRESS_TO = 'eth_call_address_to', } diff --git a/pages/api/rpc.ts b/pages/api/rpc.ts index 1841d2708..0655d14b0 100644 --- a/pages/api/rpc.ts +++ b/pages/api/rpc.ts @@ -6,6 +6,7 @@ import { rateLimit, responseTimeMetric, defaultErrorHandler, + requestAddressMetric, } from 'utilsApi'; import Metrics from 'utilsApi/metrics'; import { rpcUrls } from 'utilsApi/rpcUrls'; @@ -42,5 +43,6 @@ const rpc = rpcFactory({ export default wrapNextRequest([ rateLimit, responseTimeMetric(Metrics.request.apiTimings, API_ROUTES.RPC), + requestAddressMetric(Metrics.request.ethCallToAddress), defaultErrorHandler, ])(rpc); diff --git a/utilsApi/contractAddressesMetricsMap.ts b/utilsApi/contractAddressesMetricsMap.ts new file mode 100644 index 000000000..17d4a5ea8 --- /dev/null +++ b/utilsApi/contractAddressesMetricsMap.ts @@ -0,0 +1,93 @@ +import { + CHAINS, + TOKENS, + getTokenAddress, + getAggregatorAddress, + getWithdrawalQueueAddress, +} from '@lido-sdk/constants'; +import { + StethAbiFactory, + WithdrawalQueueAbiFactory, + WstethAbiFactory, +} from '@lido-sdk/contracts'; +import { dynamics, getAggregatorStEthUsdPriceFeedAddress } from 'config'; +import { utils } from 'ethers'; +import { + AggregatorAbi__factory, + AggregatorEthUsdPriceFeedAbi__factory, +} from 'generated'; +import { invert, isNull, memoize, omitBy } from 'lodash'; + +export const CONTRACT_NAMES = { + stETH: 'stETH', + wstETH: 'wstETH', + WithdrawalQueue: 'WithdrawalQueue', + Aggregator: 'Aggregator', + AggregatorStEthUsdPriceFeed: 'AggregatorStEthUsdPriceFeed', +} as const; +export type CONTRACT_NAMES = keyof typeof CONTRACT_NAMES; + +export const METRIC_CONTRACT_ABIS = { + [CONTRACT_NAMES.stETH]: StethAbiFactory.abi, + [CONTRACT_NAMES.wstETH]: WstethAbiFactory.abi, + [CONTRACT_NAMES.WithdrawalQueue]: WithdrawalQueueAbiFactory.abi, + [CONTRACT_NAMES.Aggregator]: AggregatorAbi__factory.abi, + [CONTRACT_NAMES.AggregatorStEthUsdPriceFeed]: + AggregatorEthUsdPriceFeedAbi__factory.abi, +} as const; + +export const getMetricContractInterface = memoize( + (contractName: CONTRACT_NAMES) => + new utils.Interface(METRIC_CONTRACT_ABIS[contractName]), +); + +const getAddressOrNull = < + G extends (...args: any) => string, + A extends Parameters, +>( + getter: G, + ...args: A +) => { + try { + const address = getter(...args); + return address ? utils.getAddress(address) : null; + } catch (error) { + return null; + } +}; + +export const METRIC_CONTRACT_ADDRESSES = ( + dynamics.supportedChains as CHAINS[] +).reduce( + (mapped, chainId) => { + const map = { + [CONTRACT_NAMES.stETH]: getAddressOrNull( + getTokenAddress, + chainId, + TOKENS.STETH, + ), + [CONTRACT_NAMES.wstETH]: getAddressOrNull( + getTokenAddress, + chainId, + TOKENS.WSTETH, + ), + [CONTRACT_NAMES.WithdrawalQueue]: getAddressOrNull( + getWithdrawalQueueAddress, + chainId, + ), + [CONTRACT_NAMES.Aggregator]: getAddressOrNull( + getAggregatorAddress, + chainId, + ), + [CONTRACT_NAMES.AggregatorStEthUsdPriceFeed]: getAddressOrNull( + getAggregatorStEthUsdPriceFeedAddress, + chainId, + ), + }; + return { + ...mapped, + [chainId]: invert(omitBy(map, isNull)), + }; + }, + {} as Record>, +); diff --git a/utilsApi/metrics/request.ts b/utilsApi/metrics/request.ts index 19169e5a7..d62b78f25 100644 --- a/utilsApi/metrics/request.ts +++ b/utilsApi/metrics/request.ts @@ -5,11 +5,13 @@ export class RequestMetrics { apiTimings: Histogram<'hostname' | 'route' | 'entity' | 'status'>; apiTimingsExternal: Histogram<'hostname' | 'route' | 'entity' | 'status'>; requestCounter: Counter<'route'>; + ethCallToAddress: Counter<'address' | 'referrer'>; constructor(public registry: Registry) { this.apiTimings = this.apiTimingsInit('internal'); this.apiTimingsExternal = this.apiTimingsInit('external'); this.requestCounter = this.requestsCounterInit(); + this.ethCallToAddress = this.ethCallToAddressInit(); } apiTimingsInit(postfix: string) { @@ -36,4 +38,19 @@ export class RequestMetrics { registers: [this.registry], }); } + + ethCallToAddressInit() { + return new Counter({ + name: METRICS_PREFIX + METRIC_NAMES.ETH_CALL_ADDRESS_TO, + help: 'Addresses presented as "to" in eth_call requests', + labelNames: [ + 'address', + 'referer', + 'contractName', + 'methodEncoded', + 'methodDecoded', + ], + registers: [this.registry], + }); + } } diff --git a/utilsApi/nextApiWrappers.ts b/utilsApi/nextApiWrappers.ts index 95f4ad2fb..f616c88d2 100644 --- a/utilsApi/nextApiWrappers.ts +++ b/utilsApi/nextApiWrappers.ts @@ -1,4 +1,5 @@ -import type { Histogram } from 'prom-client'; +import type { Histogram, Counter } from 'prom-client'; +import { utils } from 'ethers'; import { getStatusLabel } from '@lidofinance/api-metrics'; import { RequestWrapper, @@ -13,6 +14,11 @@ import { RATE_LIMIT, RATE_LIMIT_TIME_FRAME, } from 'config'; +import { + getMetricContractInterface, + METRIC_CONTRACT_ADDRESSES, +} from './contractAddressesMetricsMap'; +import { CHAINS } from '@lido-sdk/constants'; export enum HttpMethod { GET = 'GET', @@ -44,63 +50,122 @@ export type CorsWrapperType = { export const cors = ({ - origin = ['*'], - methods = [HttpMethod.GET], - allowedHeaders = ['*'], - credentials = false, - }: CorsWrapperType): RequestWrapper => - async (req, res, next) => { - if (!req || !req.method) { - res.status(405); - throw new Error('Not HTTP method provided'); - } + origin = ['*'], + methods = [HttpMethod.GET], + allowedHeaders = ['*'], + credentials = false, + }: CorsWrapperType): RequestWrapper => + async (req, res, next) => { + if (!req || !req.method) { + res.status(405); + throw new Error('Not HTTP method provided'); + } + + res.setHeader('Access-Control-Allow-Credentials', String(credentials)); + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Methods', methods.toString()); + res.setHeader('Access-Control-Allow-Headers', allowedHeaders.toString()); + + if (req.method === HttpMethod.OPTIONS) { + // In preflight just need return a CORS headers + res.status(200).end(); + return; + } + + await next?.(req, res, next); + }; - res.setHeader('Access-Control-Allow-Credentials', String(credentials)); - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Methods', methods.toString()); - res.setHeader('Access-Control-Allow-Headers', allowedHeaders.toString()); +export const httpMethodGuard = + (methodAllowList: HttpMethod[]): RequestWrapper => + async (req, res, next) => { + if ( + !req || + !req.method || + !Object.values(methodAllowList).includes(req.method as HttpMethod) + ) { + res.status(405); + throw new Error(`You can use only: ${methodAllowList.toString()}`); + } + + await next?.(req, res, next); + }; - if (req.method === HttpMethod.OPTIONS) { - // In preflight just need return a CORS headers - res.status(200).end(); - return; - } +export const responseTimeMetric = + (metrics: Histogram, route: string): RequestWrapper => + async (req, res, next) => { + let status = '2xx'; + const endMetric = metrics.startTimer({ route }); + try { await next?.(req, res, next); - }; + status = getStatusLabel(res.statusCode); + } catch (error) { + status = getStatusLabel(res.statusCode); + // throw error up the stack + throw error; + } finally { + endMetric({ status }); + } + }; + +const collectRequestAddressMetric = async ({ + calls, + referer, + chainId, + metrics, +}: { + calls: any[]; + referer: string; + chainId: CHAINS; + metrics: Counter; +}) => { + const url = new URL(referer); + const urlWithoutQuery = `${url.origin}${url.pathname}`; + calls.forEach((call: any) => { + if ( + typeof call === 'object' && + call.method === 'eth_call' && + call.params[0].to + ) { + const { to, data } = call.params[0]; + const address = utils.getAddress(to); + const contractName = METRIC_CONTRACT_ADDRESSES[chainId][address]; + const methodEncoded = data?.slice(0, 10); // `0x` and 8 next symbols + const methodDecoded = contractName + ? getMetricContractInterface(contractName)?.getFunction(methodEncoded) + ?.name + : null; + + metrics + .labels({ + address, + referer: urlWithoutQuery, + contractName: contractName || 'N/A', + methodEncoded: methodEncoded || 'N/A', + methodDecoded: methodDecoded || 'N/A', + }) + .inc(1); + } + }); +}; -export const httpMethodGuard = - (methodWhitelist: HttpMethod[]): RequestWrapper => - async (req, res, next) => { - if ( - !req || - !req.method || - !Object.values(methodWhitelist).includes(req.method as HttpMethod) - ) { - res.status(405); - throw new Error(`You can use only: ${methodWhitelist.toString()}`); - } +export const requestAddressMetric = + (metrics: Counter): RequestWrapper => + async (req, res, next) => { + const referer = req.headers.referer as string; + const chainId = req.query.chainId as unknown as CHAINS; - await next?.(req, res, next); - }; + if (req.body) { + void collectRequestAddressMetric({ + calls: Array.isArray(req.body) ? req.body : [req.body], + referer, + chainId, + metrics, + }).catch(console.error); + } -export const responseTimeMetric = - (metrics: Histogram, route: string): RequestWrapper => - async (req, res, next) => { - let status = '2xx'; - const endMetric = metrics.startTimer({ route }); - - try { - await next?.(req, res, next); - status = getStatusLabel(res.statusCode); - } catch (error) { - status = getStatusLabel(res.statusCode); - // throw error up the stack - throw error; - } finally { - endMetric({ status }); - } - }; + await next?.(req, res, next); + }; export const rateLimit = rateLimitWrapper({ rateLimit: RATE_LIMIT, @@ -109,26 +174,26 @@ export const rateLimit = rateLimitWrapper({ export const nextDefaultErrorHandler = (args?: DefaultErrorHandlerArgs): RequestWrapper => - async (req, res, next) => { - const { errorMessage = DEFAULT_API_ERROR_MESSAGE, serverLogger: console } = + async (req, res, next) => { + const { errorMessage = DEFAULT_API_ERROR_MESSAGE, serverLogger: console } = args || {}; - try { - await next?.(req, res, next); - } catch (error) { - const isInnerError = res.statusCode === 200; - const status = isInnerError ? 500 : res.statusCode || 500; - - if (error instanceof Error) { - const serverError = 'status' in error && (error.status as number); - console?.error(extractErrorMessage(error, errorMessage)); - res - .status(serverError || status) - .json({ message: extractErrorMessage(error, errorMessage) }); - } else { - res.status(status).json({ message: errorMessage }); - } + try { + await next?.(req, res, next); + } catch (error) { + const isInnerError = res.statusCode === 200; + const status = isInnerError ? 500 : res.statusCode || 500; + + if (error instanceof Error) { + const serverError = 'status' in error && (error.status as number); + console?.error(extractErrorMessage(error, errorMessage)); + res + .status(serverError || status) + .json({ message: extractErrorMessage(error, errorMessage) }); + } else { + res.status(status).json({ message: errorMessage }); } - }; + } + }; export const defaultErrorHandler = nextDefaultErrorHandler({ serverLogger: console,