From ffc9f508184223c28f6232f26909d9a8fc05e04b Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Tue, 3 Sep 2024 19:21:54 -0300 Subject: [PATCH] feat: integrate l2 chain info to chain ep and env configuration --- apps/api/src/common/config/index.ts | 6 +- apps/api/src/common/config/schemas.ts | 6 + apps/api/src/index.ts | 23 ++- apps/api/src/metrics/controllers/index.ts | 54 ++++++- .../src/metrics/dto/response/l2Metrics.dto.ts | 10 +- .../unit/metrics/metricsController.spec.ts | 133 +++++++++++++++++- 6 files changed, 212 insertions(+), 20 deletions(-) diff --git a/apps/api/src/common/config/index.ts b/apps/api/src/common/config/index.ts index dd5592b..96189b8 100644 --- a/apps/api/src/common/config/index.ts +++ b/apps/api/src/common/config/index.ts @@ -12,7 +12,10 @@ dotenv.config(); const logger = Logger.getInstance(); -const env = validationSchema.safeParse(process.env); +const env = validationSchema.safeParse({ + ...process.env, + L2_RPC_URLS_MAP: JSON.parse(process.env.L2_RPC_URLS_MAP || "{}"), +}); if (!env.success) { logger.error(env.error.issues.map((issue) => JSON.stringify(issue)).join("\n")); @@ -78,6 +81,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..d34de45 100644 --- a/apps/api/src/common/config/schemas.ts +++ b/apps/api/src/common/config/schemas.ts @@ -18,6 +18,11 @@ const urlArraySchema = z message: "Must be a comma-separated list of valid URLs", }); +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), BRIDGE_HUB_ADDRESS: addressSchema, @@ -25,6 +30,7 @@ const baseSchema = z.object({ STATE_MANAGER_ADDRESSES: addressArraySchema, ENVIRONMENT: z.enum(["mainnet", "testnet", "local"]).default("mainnet"), L1_RPC_URLS: urlArraySchema, + L2_RPC_URLS_MAP: urlArrayMapSchema, 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());