diff --git a/libs/metrics/src/exceptions/provider.exception.ts b/libs/metrics/src/exceptions/provider.exception.ts new file mode 100644 index 0000000..d596bac --- /dev/null +++ b/libs/metrics/src/exceptions/provider.exception.ts @@ -0,0 +1,13 @@ +export class ProviderException extends Error { + constructor(message: string) { + super(message); + this.name = "ProviderException"; + } +} + +export class L1ProviderException extends ProviderException { + constructor(message: string) { + super(message); + this.name = "L1ProviderException"; + } +} diff --git a/libs/metrics/src/l1/l1MetricsService.ts b/libs/metrics/src/l1/l1MetricsService.ts index 75de2d7..4dcd4ad 100644 --- a/libs/metrics/src/l1/l1MetricsService.ts +++ b/libs/metrics/src/l1/l1MetricsService.ts @@ -1,22 +1,36 @@ import assert from "assert"; +import { isNativeError } from "util/types"; import { Inject, Injectable, LoggerService } from "@nestjs/common"; import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston"; import { Address, ContractConstructorArgs, + encodeFunctionData, + erc20Abi, formatUnits, parseAbiParameters, + parseEther, parseUnits, + zeroAddress, } from "viem"; +import { L1ProviderException } from "@zkchainhub/metrics/exceptions/provider.exception"; import { bridgeHubAbi, sharedBridgeAbi } from "@zkchainhub/metrics/l1/abis"; import { tokenBalancesAbi } from "@zkchainhub/metrics/l1/abis/tokenBalances.abi"; import { tokenBalancesBytecode } from "@zkchainhub/metrics/l1/bytecode"; -import { AssetTvl } from "@zkchainhub/metrics/types"; +import { AssetTvl, GasInfo } from "@zkchainhub/metrics/types"; import { IPricingService, PRICING_PROVIDER } from "@zkchainhub/pricing"; import { EvmProviderService } from "@zkchainhub/providers"; -import { AbiWithAddress, ChainId, L1_CONTRACTS } from "@zkchainhub/shared"; -import { erc20Tokens, isNativeToken, tokens } from "@zkchainhub/shared/tokens/tokens"; +import { AbiWithAddress, ChainId, L1_CONTRACTS, vitalikAddress } from "@zkchainhub/shared"; +import { + erc20Tokens, + isNativeToken, + nativeToken, + tokens, + WETH, +} from "@zkchainhub/shared/tokens/tokens"; + +const ONE_ETHER = parseEther("1"); /** * Acts as a wrapper around Viem library to provide methods to interact with an EVM-based blockchain. @@ -147,10 +161,59 @@ export class L1MetricsService { async chainType(_chainId: number): Promise<"validium" | "rollup"> { return "rollup"; } - //TODO: Implement ethGasInfo. - async ethGasInfo(): Promise<{ gasPrice: number; ethTransfer: number; erc20Transfer: number }> { - return { gasPrice: 50, ethTransfer: 21000, erc20Transfer: 65000 }; + + /** + * Retrieves gas information for Ethereum transfers and ERC20 token transfers. + * @returns {GasInfo} A promise that resolves to an object containing gas-related information. + */ + async ethGasInfo(): Promise { + try { + const [ethTransferGasCost, erc20TransferGasCost, gasPrice] = await Promise.all([ + // Estimate gas for an ETH transfer. + this.evmProviderService.estimateGas({ + account: vitalikAddress, + to: zeroAddress, + value: ONE_ETHER, + }), + // Estimate gas for an ERC20 transfer. + this.evmProviderService.estimateGas({ + account: vitalikAddress, + to: WETH.contractAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [L1_CONTRACTS.SHARED_BRIDGE, ONE_ETHER], + }), + }), + // Get the current gas price. + this.evmProviderService.getGasPrice(), + ]); + + // Get the current price of ether. + let ethPriceInUsd: number | undefined = undefined; + try { + const priceResult = await this.pricingService.getTokenPrices([ + nativeToken.coingeckoId, + ]); + ethPriceInUsd = priceResult[nativeToken.coingeckoId]; + } catch (e) { + this.logger.error("Failed to get the price of ether."); + } + + return { + gasPrice, + ethPrice: ethPriceInUsd, + ethTransferGas: ethTransferGasCost, + erc20TransferGas: erc20TransferGasCost, + }; + } catch (e: unknown) { + if (isNativeError(e)) { + this.logger.error(`Failed to get gas information: ${e.message}`); + } + throw new L1ProviderException("Failed to get gas information from L1."); + } } + //TODO: Implement feeParams. async feeParams(_chainId: number): Promise<{ batchOverheadL1Gas: number; diff --git a/libs/metrics/src/types/gasInfo.type.ts b/libs/metrics/src/types/gasInfo.type.ts new file mode 100644 index 0000000..f066649 --- /dev/null +++ b/libs/metrics/src/types/gasInfo.type.ts @@ -0,0 +1,6 @@ +export type GasInfo = { + gasPrice: bigint; // wei + ethPrice?: number; // USD + ethTransferGas: bigint; // units of gas + erc20TransferGas: bigint; // units of gas +}; diff --git a/libs/metrics/src/types/index.ts b/libs/metrics/src/types/index.ts index 411abef..b2f1a4d 100644 --- a/libs/metrics/src/types/index.ts +++ b/libs/metrics/src/types/index.ts @@ -1 +1,2 @@ +export * from "./gasInfo.type"; export * from "./tvl.type"; diff --git a/libs/metrics/test/unit/l1/l1MetricsService.spec.ts b/libs/metrics/test/unit/l1/l1MetricsService.spec.ts index 0a31a80..93780b6 100644 --- a/libs/metrics/test/unit/l1/l1MetricsService.spec.ts +++ b/libs/metrics/test/unit/l1/l1MetricsService.spec.ts @@ -2,34 +2,26 @@ import { createMock } from "@golevelup/ts-jest"; import { Logger } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; import { WINSTON_MODULE_PROVIDER } from "nest-winston"; +import { encodeFunctionData, erc20Abi, parseEther, zeroAddress } from "viem"; +import { L1ProviderException } from "@zkchainhub/metrics/exceptions/provider.exception"; import { L1MetricsService } from "@zkchainhub/metrics/l1/"; import { bridgeHubAbi, sharedBridgeAbi } from "@zkchainhub/metrics/l1/abis"; import { tokenBalancesAbi } from "@zkchainhub/metrics/l1/abis/tokenBalances.abi"; import { tokenBalancesBytecode } from "@zkchainhub/metrics/l1/bytecode"; import { IPricingService, PRICING_PROVIDER } from "@zkchainhub/pricing"; import { EvmProviderService } from "@zkchainhub/providers"; -import { L1_CONTRACTS } from "@zkchainhub/shared"; +import { L1_CONTRACTS, vitalikAddress } from "@zkchainhub/shared"; +import { nativeToken, WETH } from "@zkchainhub/shared/tokens/tokens"; // Mock implementations of the dependencies const mockEvmProviderService = createMock(); const mockPricingService = createMock(); +const ONE_ETHER = parseEther("1"); jest.mock("@zkchainhub/shared/tokens/tokens", () => ({ ...jest.requireActual("@zkchainhub/shared/tokens/tokens"), - get nativeToken() { - return { - name: "Ethereum", - symbol: "ETH", - contractAddress: null, - coingeckoId: "ethereum", - type: "native", - imageUrl: - "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", - decimals: 18, - }; - }, get erc20Tokens() { return [ { @@ -107,11 +99,15 @@ describe("L1MetricsService", () => { { provide: L1MetricsService, useFactory: ( - evmProviderService: EvmProviderService, - pricingService: IPricingService, + mockEvmProviderService: EvmProviderService, + mockPricingService: IPricingService, logger: Logger, ) => { - return new L1MetricsService(evmProviderService, pricingService, logger); + return new L1MetricsService( + mockEvmProviderService, + mockPricingService, + logger, + ); }, inject: [EvmProviderService, PRICING_PROVIDER, WINSTON_MODULE_PROVIDER], }, @@ -267,9 +263,151 @@ describe("L1MetricsService", () => { }); describe("ethGasInfo", () => { - it("return ethGasInfo", async () => { + it("returns gas information from L1", async () => { + // Mock the necessary dependencies + const mockEstimateGas = jest.spyOn(mockEvmProviderService, "estimateGas"); + mockEstimateGas.mockResolvedValueOnce(BigInt(21000)); // ethTransferGasCost + mockEstimateGas.mockResolvedValueOnce(BigInt(65000)); // erc20TransferGasCost' + const mockGetGasPrice = jest.spyOn(mockEvmProviderService, "getGasPrice"); + mockGetGasPrice.mockResolvedValueOnce(BigInt(50000000000)); // gasPrice + + const mockGetTokenPrices = jest.spyOn(mockPricingService, "getTokenPrices"); + mockGetTokenPrices.mockResolvedValueOnce({ [nativeToken.coingeckoId]: 2000 }); // ethPriceInUsd + + // Call the method const result = await l1MetricsService.ethGasInfo(); - expect(result).toEqual({ gasPrice: 50, ethTransfer: 21000, erc20Transfer: 65000 }); + + // Assertions + expect(mockEstimateGas).toHaveBeenCalledTimes(2); + expect(mockEstimateGas).toHaveBeenNthCalledWith(1, { + account: vitalikAddress, + to: zeroAddress, + value: ONE_ETHER, + }); + expect(mockEstimateGas).toHaveBeenNthCalledWith(2, { + account: vitalikAddress, + to: WETH.contractAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [L1_CONTRACTS.SHARED_BRIDGE, ONE_ETHER], + }), + }); + + expect(mockGetGasPrice).toHaveBeenCalledTimes(1); + + expect(mockGetTokenPrices).toHaveBeenCalledTimes(1); + expect(mockGetTokenPrices).toHaveBeenCalledWith([nativeToken.coingeckoId]); + + expect(result).toEqual({ + gasPrice: 50000000000n, + ethPrice: 2000, + ethTransferGas: 21000n, + erc20TransferGas: 65000n, + }); + }); + + it("returns gas information from L1 without ether price", async () => { + const mockEstimateGas = jest.spyOn(mockEvmProviderService, "estimateGas"); + mockEstimateGas.mockResolvedValueOnce(BigInt(21000)); // ethTransferGasCost + mockEstimateGas.mockResolvedValueOnce(BigInt(65000)); // erc20TransferGasCost + + const mockGetGasPrice = jest.spyOn(mockEvmProviderService, "getGasPrice"); + mockGetGasPrice.mockResolvedValueOnce(BigInt(50000000000)); // gasPrice + + const mockGetTokenPrices = jest.spyOn(mockPricingService, "getTokenPrices"); + mockGetTokenPrices.mockRejectedValueOnce(new Error("Failed to get token prices")); + + const result = await l1MetricsService.ethGasInfo(); + + // Assertions + expect(result).toEqual({ + gasPrice: 50000000000n, + ethPrice: undefined, + ethTransferGas: 21000n, + erc20TransferGas: 65000n, + }); + expect(mockEstimateGas).toHaveBeenCalledTimes(2); + expect(mockEstimateGas).toHaveBeenNthCalledWith(1, { + account: vitalikAddress, + to: zeroAddress, + value: ONE_ETHER, + }); + expect(mockEstimateGas).toHaveBeenNthCalledWith(2, { + account: vitalikAddress, + to: WETH.contractAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [L1_CONTRACTS.SHARED_BRIDGE, ONE_ETHER], + }), + }); + + expect(mockGetGasPrice).toHaveBeenCalledTimes(1); + + expect(mockGetTokenPrices).toHaveBeenCalledTimes(1); + expect(mockGetTokenPrices).toHaveBeenCalledWith([nativeToken.coingeckoId]); + }); + + it("throws L1ProviderException when estimateGas fails", async () => { + // Mock the necessary dependencies + const mockEstimateGas = jest.spyOn(mockEvmProviderService, "estimateGas"); + mockEstimateGas.mockRejectedValueOnce(new Error("Failed to estimate gas")); + + const mockGetGasPrice = jest.spyOn(mockEvmProviderService, "getGasPrice"); + mockGetGasPrice.mockResolvedValueOnce(BigInt(50000000000)); // gasPrice + + const mockGetTokenPrices = jest.spyOn(mockPricingService, "getTokenPrices"); + mockGetTokenPrices.mockResolvedValueOnce({ [nativeToken.coingeckoId]: 2000 }); // ethPriceInUsd + + // Call the method and expect it to throw L1ProviderException + await expect(l1MetricsService.ethGasInfo()).rejects.toThrow(L1ProviderException); + + // Assertions + expect(mockEstimateGas).toHaveBeenCalledWith({ + account: vitalikAddress, + to: zeroAddress, + value: ONE_ETHER, + }); + + expect(mockGetTokenPrices).not.toHaveBeenCalled(); + }); + + it("throws L1ProviderException when getGasPrice fails", async () => { + // Mock the necessary dependencies + const mockEstimateGas = jest.spyOn(mockEvmProviderService, "estimateGas"); + mockEstimateGas.mockResolvedValueOnce(BigInt(21000)); // ethTransferGasCost + mockEstimateGas.mockResolvedValueOnce(BigInt(65000)); // erc20TransferGasCost + + const mockGetGasPrice = jest.spyOn(mockEvmProviderService, "getGasPrice"); + mockGetGasPrice.mockRejectedValueOnce(new Error("Failed to get gas price")); + + const mockGetTokenPrices = jest.spyOn(mockPricingService, "getTokenPrices"); + mockGetTokenPrices.mockResolvedValueOnce({ [nativeToken.coingeckoId]: 2000 }); // ethPriceInUsd + + // Call the method and expect it to throw L1ProviderException + await expect(l1MetricsService.ethGasInfo()).rejects.toThrow(L1ProviderException); + + // Assertions + expect(mockEstimateGas).toHaveBeenCalledTimes(2); + expect(mockEstimateGas).toHaveBeenNthCalledWith(1, { + account: vitalikAddress, + to: zeroAddress, + value: ONE_ETHER, + }); + expect(mockEstimateGas).toHaveBeenNthCalledWith(2, { + account: vitalikAddress, + to: WETH.contractAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [L1_CONTRACTS.SHARED_BRIDGE, ONE_ETHER], + }), + }); + + expect(mockGetGasPrice).toHaveBeenCalledTimes(1); + + expect(mockGetTokenPrices).not.toHaveBeenCalled(); }); }); diff --git a/libs/providers/src/providers/evmProvider.service.ts b/libs/providers/src/providers/evmProvider.service.ts index e698c63..9cd018b 100644 --- a/libs/providers/src/providers/evmProvider.service.ts +++ b/libs/providers/src/providers/evmProvider.service.ts @@ -13,6 +13,7 @@ import { decodeAbiParameters, DecodeAbiParametersReturnType, encodeDeployData, + EstimateGasParameters, GetBlockReturnType, Hex, http, @@ -74,6 +75,10 @@ export class EvmProviderService { return this.client.getGasPrice(); } + async estimateGas(args: EstimateGasParameters): Promise { + return this.client.estimateGas(args); + } + /** * Retrieves the value from a storage slot at a given address. * @param {Address} address The address of the contract. diff --git a/libs/providers/test/unit/providers/evmProvider.service.spec.ts b/libs/providers/test/unit/providers/evmProvider.service.spec.ts index c6c97a7..73cc90f 100644 --- a/libs/providers/test/unit/providers/evmProvider.service.spec.ts +++ b/libs/providers/test/unit/providers/evmProvider.service.spec.ts @@ -99,6 +99,24 @@ describe("EvmProviderService", () => { }); }); + describe("estimateGas", () => { + it("return the estimated gas for the given transaction", async () => { + const args = createMock>({ + account: "0xffff", + to: viem.zeroAddress, + value: 100n, + }); + + const expectedGas = 50000n; + jest.spyOn(mockClient, "estimateGas").mockResolvedValue(expectedGas); + + const gas = await viemProvider.estimateGas(args); + + expect(gas).toBe(expectedGas); + expect(mockClient.estimateGas).toHaveBeenCalledWith(args); + }); + }); + describe("getStorageAt", () => { it("should return the value of the storage slot at the given address and slot number", async () => { const address = "0x123456789"; diff --git a/libs/shared/src/constants/addresses.ts b/libs/shared/src/constants/addresses.ts new file mode 100644 index 0000000..c8c6fd0 --- /dev/null +++ b/libs/shared/src/constants/addresses.ts @@ -0,0 +1,3 @@ +import { Address } from "abitype"; + +export const vitalikAddress: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; diff --git a/libs/shared/src/constants/index.ts b/libs/shared/src/constants/index.ts index 9962fbe..4016c2e 100644 --- a/libs/shared/src/constants/index.ts +++ b/libs/shared/src/constants/index.ts @@ -1,3 +1,4 @@ export * from "./l1"; +export * from "./addresses"; export const TOKEN_CACHE_TTL_IN_SEC = 60; export const BASE_CURRENCY = "usd"; diff --git a/libs/shared/src/tokens/tokens.ts b/libs/shared/src/tokens/tokens.ts index 9865e86..0a206ee 100644 --- a/libs/shared/src/tokens/tokens.ts +++ b/libs/shared/src/tokens/tokens.ts @@ -32,6 +32,16 @@ export const nativeToken: Readonly> = { decimals: 18, }; +export const WETH: Readonly> = { + name: "Wrapped Ether", + symbol: "WETH", + contractAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + coingeckoId: "weth", + imageUrl: "https://coin-images.coingecko.com/coins/images/2518/large/weth.png?1696503332", + type: "erc20", + decimals: 18, +}; + export const erc20Tokens: Readonly[]> = [ { name: "USDC",