Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: gas info #38

Merged
merged 4 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions libs/metrics/src/exceptions/provider.exception.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
75 changes: 69 additions & 6 deletions libs/metrics/src/l1/l1MetricsService.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<GasInfo> {
try {
const [ethTransferGasCost, erc20TransferGasCost, gasPrice] = await Promise.all([
// Estimate gas for an ETH transfer.
this.evmProviderService.estimateGas({
account: vitalikAddress,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, is using vitalikAddress some kind of witty convention that everyone uses to estimate gas or does it have a special meaning behind?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just picked vitalik address because it was the first address that came to my mind tbh

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty original lol

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;
Expand Down
6 changes: 6 additions & 0 deletions libs/metrics/src/types/gasInfo.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type GasInfo = {
gasPrice: bigint; // wei
ethPrice?: number; // USD
ethTransferGas: bigint; // units of gas
erc20TransferGas: bigint; // units of gas
};
1 change: 1 addition & 0 deletions libs/metrics/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./gasInfo.type";
export * from "./tvl.type";
174 changes: 156 additions & 18 deletions libs/metrics/test/unit/l1/l1MetricsService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EvmProviderService>();

const mockPricingService = createMock<IPricingService>();

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 [
{
Expand Down Expand Up @@ -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],
},
Expand Down Expand Up @@ -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();
});
});

Expand Down
5 changes: 5 additions & 0 deletions libs/providers/src/providers/evmProvider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
decodeAbiParameters,
DecodeAbiParametersReturnType,
encodeDeployData,
EstimateGasParameters,
GetBlockReturnType,
Hex,
http,
Expand Down Expand Up @@ -74,6 +75,10 @@ export class EvmProviderService {
return this.client.getGasPrice();
}

async estimateGas(args: EstimateGasParameters<typeof this.chain>): Promise<bigint> {
return this.client.estimateGas(args);
}

/**
* Retrieves the value from a storage slot at a given address.
* @param {Address} address The address of the contract.
Expand Down
18 changes: 18 additions & 0 deletions libs/providers/test/unit/providers/evmProvider.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,24 @@ describe("EvmProviderService", () => {
});
});

describe("estimateGas", () => {
it("return the estimated gas for the given transaction", async () => {
const args = createMock<viem.EstimateGasParameters<typeof localhost>>({
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";
Expand Down
3 changes: 3 additions & 0 deletions libs/shared/src/constants/addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Address } from "abitype";

export const vitalikAddress: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
1 change: 1 addition & 0 deletions libs/shared/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./l1";
export * from "./addresses";
export const TOKEN_CACHE_TTL_IN_SEC = 60;
export const BASE_CURRENCY = "usd";
Loading