diff --git a/apps/api/.env.example b/apps/api/.env.example index 64e4a65..ae944c3 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -6,8 +6,9 @@ BRIDGE_HUB_ADDRESS="" SHARED_BRIDGE_ADDRESS="" STATE_MANAGER_ADDRESSES="" #CSV list of State managers addresses -L1_RPC_URLS="" #CSV list of L1 RPC URLs -L2_RPC_URLS="" #CSV list of L2 RPC URLs +L1_RPC_URLS=[] # array of L1 RPC URLs +# map from chain id to array of L2 RPC URLs +L2_RPC_URLS_MAP={} PRICING_SOURCE="dummy" # Pricing source: 'dummy' | 'coingecko' diff --git a/apps/api/README.md b/apps/api/README.md index 78f95e0..8d749fe 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -38,7 +38,8 @@ Available options: | `BRIDGE_HUB_ADDRESS` | Bridge Hub address | N/A | Yes | | | `SHARED_BRIDGE_ADDRESS` | Shared Bridge address | N/A | Yes | | | `STATE_MANAGER_ADDRESSES` | CSV list of State manager addresses | N/A | Yes | | -| `L1_RPC_URLS` | CSV list of RPC URLs. For example, `https://eth.llamarpc.com,https://rpc.flashbots.net/fast` | N/A | Yes | You can check [Chainlist](https://chainlist.org/) for a list of public RPCs | +| `L1_RPC_URLS` | JSON array of RPC URLs. For example, ["https://eth.llamarpc.com","https://rpc.flashbots.net/fast"] | N/A | Yes | You can check [Chainlist](https://chainlist.org/) for a list of public RPCs | +| `L2_RPC_URLS_MAP` | JSON from chain id to CSV list of L2 RPC URLs. For example, {"324":"https://mainnet.era.zksync.io,https://zksync.drpc.org"} | N/A | No | You can check [Chainlist](https://chainlist.org/) for a list of public RPCs | | `PRICING_SOURCE` | Pricing source to use (`'dummy'`, `'coingecko'`) | 'dummy' | No | | | `DUMMY_PRICE` | Price for dummy pricing source | undefined | No | Only applicable if `PRICING_SOURCE` is `'dummy'` | | `COINGECKO_API_KEY` | API key for CoinGecko | N/A | If `'coingecko'` is selected | You can get an API key by creating an account on [CoinGecko's site](https://www.coingecko.com/en/api) | diff --git a/apps/api/src/common/config/index.ts b/apps/api/src/common/config/index.ts index dd5592b..e26fe15 100644 --- a/apps/api/src/common/config/index.ts +++ b/apps/api/src/common/config/index.ts @@ -78,6 +78,7 @@ export const config = { rpcUrls: envData.L1_RPC_URLS, chain: getChain(envData.ENVIRONMENT), }, + l2: envData.L2_RPC_URLS_MAP, bridgeHubAddress: envData.BRIDGE_HUB_ADDRESS as Address, sharedBridgeAddress: envData.SHARED_BRIDGE_ADDRESS as Address, stateTransitionManagerAddresses: envData.STATE_MANAGER_ADDRESSES as Address[], diff --git a/apps/api/src/common/config/schemas.ts b/apps/api/src/common/config/schemas.ts index 96d640b..7110983 100644 --- a/apps/api/src/common/config/schemas.ts +++ b/apps/api/src/common/config/schemas.ts @@ -1,6 +1,15 @@ import { isAddress } from "viem"; import { z } from "zod"; +const stringToJSONSchema = z.string().transform((str, ctx): z.infer> => { + try { + return JSON.parse(str); + } catch (e) { + ctx.addIssue({ code: "custom", message: "Invalid JSON" }); + return z.NEVER; + } +}); + const addressArraySchema = z .string() .transform((str) => str.split(",")) @@ -11,12 +20,12 @@ const addressSchema = z.string().refine((address) => isAddress(address), { message: "Must be a valid Address", }); -const urlArraySchema = z - .string() - .transform((str) => str.split(",")) - .refine((urls) => urls.every((url) => z.string().url().safeParse(url).success), { - message: "Must be a comma-separated list of valid URLs", - }); +const urlArraySchema = z.array(z.string().url()); + +const urlArrayMapSchema = z.record( + z.union([z.coerce.number().int(), z.string().regex(/^\d+$/)]), // key: number or string number + urlArraySchema, +); const baseSchema = z.object({ PORT: z.coerce.number().positive().default(3000), @@ -24,7 +33,8 @@ const baseSchema = z.object({ SHARED_BRIDGE_ADDRESS: addressSchema, STATE_MANAGER_ADDRESSES: addressArraySchema, ENVIRONMENT: z.enum(["mainnet", "testnet", "local"]).default("mainnet"), - L1_RPC_URLS: urlArraySchema, + L1_RPC_URLS: stringToJSONSchema.pipe(urlArraySchema), + L2_RPC_URLS_MAP: stringToJSONSchema.pipe(urlArrayMapSchema).default("{}"), PRICING_SOURCE: z.enum(["dummy", "coingecko"]).default("dummy"), DUMMY_PRICE: z.coerce.number().optional(), COINGECKO_API_KEY: z.string().optional(), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c533c95..3463001 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,11 +1,11 @@ import { inspect } from "util"; import { caching } from "cache-manager"; -import { EvmProvider } from "@zkchainhub/chain-providers"; +import { EvmProvider, ZKChainProvider } from "@zkchainhub/chain-providers"; import { MetadataProviderFactory } from "@zkchainhub/metadata"; -import { L1MetricsService } from "@zkchainhub/metrics"; +import { L1MetricsService, L2MetricsService } from "@zkchainhub/metrics"; import { PricingProviderFactory } from "@zkchainhub/pricing"; -import { Logger } from "@zkchainhub/shared"; +import { ChainId, Logger } from "@zkchainhub/shared"; import { App } from "./app.js"; import { config } from "./common/config/index.js"; @@ -40,7 +40,22 @@ const main = async (): Promise => { metadataProvider, logger, ); - const metricsController = new MetricsController(l1MetricsService, metadataProvider, logger); + + const l2ChainsConfigMap = config.l2; + const l2MetricsMap = new Map(); + + for (const [chainId, rpcUrls] of Object.entries(l2ChainsConfigMap)) { + const provider = new ZKChainProvider(rpcUrls, logger); + const metricsService = new L2MetricsService(provider, logger); + l2MetricsMap.set(BigInt(chainId), metricsService); + } + + const metricsController = new MetricsController( + l1MetricsService, + l2MetricsMap, + metadataProvider, + logger, + ); const metricsRouter = new MetricsRouter(metricsController, logger); const app = new App(config, [metricsRouter], logger); diff --git a/apps/api/src/metrics/controllers/index.ts b/apps/api/src/metrics/controllers/index.ts index 055a3c8..b500a05 100644 --- a/apps/api/src/metrics/controllers/index.ts +++ b/apps/api/src/metrics/controllers/index.ts @@ -1,15 +1,16 @@ import { BigNumber } from "bignumber.js"; import { IMetadataProvider } from "@zkchainhub/metadata"; -import { L1MetricsService } from "@zkchainhub/metrics"; -import { ILogger } from "@zkchainhub/shared"; +import { L1MetricsService, L2MetricsService } from "@zkchainhub/metrics"; +import { ChainId, ILogger } from "@zkchainhub/shared"; -import { EcosystemInfo, ZKChainInfo, ZkChainMetadata } from "../dto/response/index.js"; +import { EcosystemInfo, L2ChainInfo, ZKChainInfo, ZkChainMetadata } from "../dto/response/index.js"; import { ChainNotFound } from "../exceptions/index.js"; export class MetricsController { constructor( private readonly l1MetricsService: L1MetricsService, + private readonly l2MetricsMap: Map, private readonly metadataProvider: IMetadataProvider, private readonly logger: ILogger, ) {} @@ -65,13 +66,13 @@ export class MetricsController { async getChain(chainId: number) { const chainIdBn = BigInt(chainId); - const chainsMetadata = await this.metadataProvider.getChainsMetadata(); - const metadata = chainsMetadata.get(chainIdBn); const ecosystemChainIds = await this.l1MetricsService.getChainIds(); - if (!ecosystemChainIds.includes(chainIdBn)) { throw new ChainNotFound(chainIdBn); } + const chainsMetadata = await this.metadataProvider.getChainsMetadata(); + const metadata = chainsMetadata.get(chainIdBn); + const l2MetricsService = this.l2MetricsMap.get(chainIdBn); const [tvl, batchesInfo, feeParams, baseTokenInfo] = await Promise.all([ this.l1MetricsService.tvl(chainIdBn), @@ -79,6 +80,10 @@ export class MetricsController { this.l1MetricsService.feeParams(chainIdBn), this.l1MetricsService.getBaseTokens([chainIdBn]), ]); + let l2ChainInfo: L2ChainInfo | undefined; + if (l2MetricsService) { + l2ChainInfo = await this.getL2ChainInfo(l2MetricsService, Number(batchesInfo.verified)); + } const baseToken = baseTokenInfo[0]; const baseZkChainInfo = { @@ -102,6 +107,7 @@ export class MetricsController { ...baseZkChainInfo, chainType: await this.l1MetricsService.chainType(chainIdBn), baseToken, + l2ChainInfo, }); } @@ -112,6 +118,42 @@ export class MetricsController { chainType: metadataRest.chainType, baseToken: metadataRest.baseToken, metadata: new ZkChainMetadata(metadataRest), + l2ChainInfo, + }); + } + + private async getL2ChainInfo( + l2MetricsService: L2MetricsService, + verifiedBatches: number, + ): Promise { + const [tpsResult, avgBlockTimeResult, lastBlockResult, lastBlockVerifiedResult] = + await Promise.allSettled([ + l2MetricsService.tps(), + l2MetricsService.avgBlockTime(), + l2MetricsService.lastBlock(), + l2MetricsService.getLastVerifiedBlock(verifiedBatches), + ]); + + if ( + tpsResult.status === "rejected" && + avgBlockTimeResult.status === "rejected" && + lastBlockResult.status === "rejected" && + lastBlockVerifiedResult.status === "rejected" + ) + return undefined; + + return new L2ChainInfo({ + tps: tpsResult.status === "fulfilled" ? tpsResult.value : undefined, + avgBlockTime: + avgBlockTimeResult.status === "fulfilled" ? avgBlockTimeResult.value : undefined, + lastBlock: + lastBlockResult.status === "fulfilled" + ? lastBlockResult.value.toString() + : undefined, + lastBlockVerified: + lastBlockVerifiedResult.status === "fulfilled" + ? lastBlockVerifiedResult.value + : undefined, }); } } diff --git a/apps/api/src/metrics/dto/response/l2Metrics.dto.ts b/apps/api/src/metrics/dto/response/l2Metrics.dto.ts index c0b150a..697523f 100644 --- a/apps/api/src/metrics/dto/response/l2Metrics.dto.ts +++ b/apps/api/src/metrics/dto/response/l2Metrics.dto.ts @@ -7,28 +7,28 @@ export class L2ChainInfo { * @type {number} * @memberof L2ChainInfo */ - tps: number; + tps?: number; /** * Average block time in seconds. * @type {number} * @memberof L2ChainInfo */ - avgBlockTime: number; + avgBlockTime?: number; /** * The number of the last block. - * @type {number} + * @type {string} * @memberof L2ChainInfo */ - lastBlock: number; + lastBlock?: string; /** * The number of the last verified block. * @type {number} * @memberof L2ChainInfo */ - lastBlockVerified: number; + lastBlockVerified?: number; constructor(data: L2ChainInfo) { this.tps = data.tps; diff --git a/apps/api/test/unit/metrics/metricsController.spec.ts b/apps/api/test/unit/metrics/metricsController.spec.ts index 4d095b8..abe66ba 100644 --- a/apps/api/test/unit/metrics/metricsController.spec.ts +++ b/apps/api/test/unit/metrics/metricsController.spec.ts @@ -2,7 +2,7 @@ import BigNumber from "bignumber.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IMetadataProvider } from "@zkchainhub/metadata"; -import { AssetTvl, GasInfo, L1MetricsService } from "@zkchainhub/metrics"; +import { AssetTvl, GasInfo, L1MetricsService, L2MetricsService } from "@zkchainhub/metrics"; import { ChainId, ChainType, @@ -31,6 +31,7 @@ const mockLogger: ILogger = { describe("MetricsController", () => { let service: MetricsController; let l1MetricsService: L1MetricsService; + let l2MetricsMap: Map; let metadataProvider: IMetadataProvider; afterEach(() => { @@ -52,7 +53,13 @@ describe("MetricsController", () => { getChainsMetadata: vi.fn(), getTokensMetadata: vi.fn(), }; - service = new MetricsController(l1MetricsService, metadataProvider, mockLogger); + l2MetricsMap = new Map(); + service = new MetricsController( + l1MetricsService, + l2MetricsMap, + metadataProvider, + mockLogger, + ); }); it("should be defined", () => { @@ -84,7 +91,6 @@ describe("MetricsController", () => { symbol: "zkCRO", name: "zkCRO", contractAddress: "0x28Ff2E4dD1B58efEB0fC138602A28D5aE81e44e2", - coingeckoId: "unknown", type: "erc20", imageUrl: "https://zkevm.cronos.org/images/chains/zkevm.svg", decimals: 18, @@ -259,7 +265,6 @@ describe("MetricsController", () => { symbol: "zkCRO", name: "zkCRO", contractAddress: "0x28Ff2E4dD1B58efEB0fC138602A28D5aE81e44e2", - coingeckoId: "unknown", type: "erc20", imageUrl: "https://zkevm.cronos.org/images/chains/zkevm.svg", decimals: 18, @@ -679,6 +684,126 @@ describe("MetricsController", () => { ); }); + it("returns the chain information with L2 Metrics for the specified chain ID", async () => { + const l2MetricsService = { + tps: vi.fn(), + avgBlockTime: vi.fn(), + lastBlock: vi.fn(), + getLastVerifiedBlock: vi.fn(), + } as unknown as L2MetricsService; + l2MetricsMap.set(324n, l2MetricsService); + + const chainId = 324; + const chainIdBn = BigInt(chainId); + const mockZkTvl: AssetTvl[] = [ + { + amount: "118306.55998740125385395", + amountUsd: "300833115.01308356813367811665", + price: "2542.827", + name: "Ethereum", + symbol: "ETH", + contractAddress: null, + type: "native", + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + decimals: 18, + }, + { + amount: "56310048.030096", + amountUsd: "56366358.078126096", + price: "1.001", + name: "USDC", + symbol: "USDC", + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + imageUrl: + "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694", + type: "erc20", + decimals: 6, + }, + { + amount: "998459864.823799773445941598", + amountUsd: "11981518.377885597281351299176", + price: "0.012", + name: "Koi", + symbol: "KOI", + contractAddress: "0x9D14BcE1dADdf408d77295BB1be9b343814f44DE", + imageUrl: + "https://coin-images.coingecko.com/coins/images/35766/large/Koi_logo.png?1709782399", + type: "erc20", + decimals: 18, + }, + ]; + const mockBatchesInfo = { commited: 1n, verified: 1n, executed: 1n }; + const mockFeeParams = { + pubdataPricingMode: 1, + batchOverheadL1Gas: 50000, + maxL2GasPerBatch: 100000, + maxPubdataPerBatch: 8000, + minimalL2GasPrice: 2000000000n, + priorityTxMaxPubdata: 1000, + }; + const zkChainsMetadata = new Map(); + zkChainsMetadata.set(chainIdBn, { + chainId: 324n, + chainType: "Rollup", + baseToken: nativeToken, + iconUrl: "https://s2.coinmarketcap.com/static/img/coins/64x64/24091.png", + name: "ZKsyncERA", + publicRpcs: [ + "https://mainnet.era.zksync.io", + "https://zksync.drpc.org", + "https://zksync.meowrpc.com", + ], + explorerUrl: "https://explorer.zksync.io/", + launchDate: 1679626800, + }); + + vi.spyOn(metadataProvider, "getChainsMetadata").mockResolvedValue(zkChainsMetadata); + vi.spyOn(l1MetricsService, "getChainIds").mockResolvedValue([chainIdBn]); + vi.spyOn(l1MetricsService, "tvl").mockResolvedValue(mockZkTvl); + vi.spyOn(l1MetricsService, "getBatchesInfo").mockResolvedValue(mockBatchesInfo); + vi.spyOn(l1MetricsService, "feeParams").mockResolvedValue(mockFeeParams); + vi.spyOn(l1MetricsService, "getBaseTokens").mockResolvedValue([nativeToken]); + vi.spyOn(l2MetricsService, "tps").mockResolvedValue(1.5); + vi.spyOn(l2MetricsService, "avgBlockTime").mockResolvedValue(10.5); + vi.spyOn(l2MetricsService, "lastBlock").mockResolvedValue(5000n); + vi.spyOn(l2MetricsService, "getLastVerifiedBlock").mockResolvedValue(4950); + + const result = await service.getChain(chainId); + + expect(result).toEqual( + new ZKChainInfo({ + batchesInfo: { + commited: mockBatchesInfo.commited.toString(), + verified: mockBatchesInfo.verified.toString(), + executed: mockBatchesInfo.executed.toString(), + }, + tvl: mockZkTvl, + feeParams: { + batchOverheadL1Gas: mockFeeParams.batchOverheadL1Gas, + maxL2GasPerBatch: mockFeeParams.maxL2GasPerBatch, + maxPubdataPerBatch: mockFeeParams.maxPubdataPerBatch, + minimalL2GasPrice: mockFeeParams.minimalL2GasPrice.toString(), + priorityTxMaxPubdata: mockFeeParams.priorityTxMaxPubdata, + }, + chainType: zkChainsMetadata.get(chainIdBn)?.chainType as ChainType, + baseToken: zkChainsMetadata.get(chainIdBn)?.baseToken as Token< + "erc20" | "native" + >, + metadata: new ZkChainMetadata( + zkChainsMetadata.get(chainIdBn) as ZkChainMetadata, + ), + l2ChainInfo: { + tps: 1.5, + avgBlockTime: 10.5, + lastBlock: "5000", + lastBlockVerified: 4950, + }, + }), + ); + expect(l2MetricsService.getLastVerifiedBlock).toHaveBeenCalledWith(1); + }); + it("should throw ChainNotFound exception when chain ID is not found", async () => { const chainId = 999; vi.spyOn(metadataProvider, "getChainsMetadata").mockResolvedValue(new Map()); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 3aa3262..7568b88 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,5 +1,5 @@ import path from "path"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { @@ -10,7 +10,13 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], // Coverage reporters - exclude: ["node_modules", "dist"], // Files to exclude from coverage + exclude: [ + "node_modules", + "dist", + "src/api-docs", + "src/index.ts", + ...configDefaults.exclude, + ], // Files to exclude from coverage }, }, resolve: {