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: tvl by chain #39

Merged
merged 4 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
54 changes: 49 additions & 5 deletions libs/metrics/src/l1/l1MetricsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AssetTvl } 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 { ETH_TOKEN_ADDRESS } from "@zkchainhub/shared/constants/addresses";
import { erc20Tokens, isNativeToken, tokens } from "@zkchainhub/shared/tokens/tokens";

/**
Expand Down Expand Up @@ -137,12 +138,55 @@ export class L1MetricsService {
): Promise<{ commited: number; verified: number; proved: number }> {
return { commited: 100, verified: 100, proved: 100 };
}
//TODO: Implement tvl.
async tvl(
_chainId: number,
): Promise<{ [asset: string]: { amount: number; amountUsd: number } }> {
return { ETH: { amount: 1000000, amountUsd: 1000000 } };

/**
* Retrieves the Total Value Locked for {chainId} by L1 token
* @returns A Promise that resolves to an array of AssetTvl objects representing the TVL for each asset.
*/
async tvl(chainId: number): Promise<AssetTvl[]> {
const erc20Addresses = erc20Tokens.map((token) => token.contractAddress);

const balances = await this.fetchTokenBalancesByChain(chainId, erc20Addresses);
const pricesRecord = await this.pricingService.getTokenPrices(
tokens.map((token) => token.coingeckoId),
);

assert(Object.keys(pricesRecord).length === tokens.length, "Invalid prices length");

return this.calculateTvl(balances, erc20Addresses, pricesRecord);
}

/**
* Fetches the token balances for the given addresses and ETH balance on {chainId}
* Note: The last balance in the returned array is the ETH balance, so the fetch length should be addresses.length + 1.
* @param addresses - An array of addresses for which to fetch the token balances.
* @returns A promise that resolves to an object containing the ETH balance and an array of address balances.
*/
private async fetchTokenBalancesByChain(chainId: number, addresses: Address[]) {
const chainIdBn = BigInt(chainId);
const balances = await this.evmProviderService.multicall({
Copy link
Collaborator

Choose a reason for hiding this comment

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

should we also use multicall to fetchTokenBalances of the SharedBridge ??? I mean, watching this now, makes me wonder that we are adding some overhead by using a batching contract for just iterating over multiple calls and having no computation on it.

Let me know if you agree and i will added to tech debt

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah I thought it too jaja, sure add it to tech debt ser 🫡

contracts: [
...addresses.map((tokenAddress) => {
return {
address: this.sharedBridge.address,
abi: sharedBridgeAbi,
functionName: "chainBalance",
args: [chainIdBn, tokenAddress],
} as const;
}),
{
address: this.sharedBridge.address,
abi: sharedBridgeAbi,
functionName: "chainBalance",
args: [chainIdBn, ETH_TOKEN_ADDRESS],
} as const,
],
allowFailure: false,
});

return { ethBalance: balances[addresses.length]!, addressesBalance: balances.slice(0, -1) };
}

//TODO: Implement chainType.
async chainType(_chainId: number): Promise<"validium" | "rollup"> {
return "rollup";
Expand Down
99 changes: 95 additions & 4 deletions libs/metrics/test/unit/l1/l1MetricsService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 { ETH_TOKEN_ADDRESS, L1_CONTRACTS } from "@zkchainhub/shared";

// Mock implementations of the dependencies
const mockEvmProviderService = createMock<EvmProviderService>();
Expand Down Expand Up @@ -253,9 +253,100 @@ describe("L1MetricsService", () => {
});

describe("tvl", () => {
it("return tvl", async () => {
const result = await l1MetricsService.tvl(1);
expect(result).toEqual({ ETH: { amount: 1000000, amountUsd: 1000000 } });
it("return the TVL for chain id", async () => {
const mockBalances = [60_841_657_140641n, 135_63005559n, 123_803_824374847279970609n]; // Mocked balances
const mockPrices = { "wrapped-bitcoin": 66_129, "usd-coin": 0.999, ethereum: 3_181.09 }; // Mocked prices
const chainId = 324; // this is ZKsyncEra chain id

jest.spyOn(mockEvmProviderService, "multicall").mockResolvedValue(mockBalances);
jest.spyOn(mockPricingService, "getTokenPrices").mockResolvedValue(mockPrices);

const result = await l1MetricsService.tvl(chainId);

expect(result).toHaveLength(3);
expect(result).toEqual([
{
amount: "123803.824374847279970609",
amountUsd: expect.stringContaining("393831107.68"),
price: "3181.09",
name: "Ethereum",
symbol: "ETH",
contractAddress: null,
type: "native",
imageUrl:
"https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628",
decimals: 18,
},
{
amount: "60841657.140641",
amountUsd: expect.stringContaining("60780815.48"),
price: "0.999",
name: "USDC",
symbol: "USDC",
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
imageUrl:
"https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694",
type: "erc20",
decimals: 6,
},
{
amount: "135.63005559",
amountUsd: expect.stringContaining("8969079.94"),
price: "66129",
name: "Wrapped BTC",
symbol: "WBTC",
contractAddress: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
imageUrl:
"https://coin-images.coingecko.com/coins/images/7598/large/wrapped_bitcoin_wbtc.png?1696507857",
type: "erc20",
decimals: 8,
},
]);
expect(mockEvmProviderService.multicall).toHaveBeenCalledWith({
contracts: [
{
address: L1_CONTRACTS.SHARED_BRIDGE,
abi: sharedBridgeAbi,
functionName: "chainBalance",
args: [BigInt(chainId), "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
},
{
address: L1_CONTRACTS.SHARED_BRIDGE,
abi: sharedBridgeAbi,
functionName: "chainBalance",
args: [BigInt(chainId), "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"],
},
{
address: L1_CONTRACTS.SHARED_BRIDGE,
abi: sharedBridgeAbi,
functionName: "chainBalance",
args: [BigInt(chainId), ETH_TOKEN_ADDRESS],
},
],
allowFailure: false,
});
expect(mockPricingService.getTokenPrices).toHaveBeenCalledWith([
"ethereum",
"usd-coin",
"wrapped-bitcoin",
]);
});

it("throws an error if the prices length is invalid", async () => {
const chainId = 324;
jest.spyOn(mockEvmProviderService, "multicall").mockResolvedValue([
60_841_657_140641n,
135_63005559n,
123_803_824374847279970609n,
]);
jest.spyOn(mockPricingService, "getTokenPrices").mockResolvedValue({
ethereum: 3_181.09,
"usd-coin": 0.999,
});

await expect(l1MetricsService.tvl(chainId)).rejects.toThrowError(
"Invalid prices length",
);
});
});

Expand Down
1 change: 1 addition & 0 deletions libs/providers/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./invalidArgument.exception";
export * from "./dataDecode.exception";
export * from "./multicallNotFound.exception";
5 changes: 5 additions & 0 deletions libs/providers/src/exceptions/multicallNotFound.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class MulticallNotFound extends Error {
constructor() {
super("Multicall contract address not found");
}
}
27 changes: 26 additions & 1 deletion libs/providers/src/providers/evmProvider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ContractConstructorArgs,
ContractFunctionArgs,
ContractFunctionName,
ContractFunctionParameters,
ContractFunctionReturnType,
createPublicClient,
decodeAbiParameters,
Expand All @@ -17,10 +18,16 @@ import {
Hex,
http,
HttpTransport,
MulticallParameters,
MulticallReturnType,
toHex,
} from "viem";

import { DataDecodeException, InvalidArgumentException } from "@zkchainhub/providers/exceptions";
import {
DataDecodeException,
InvalidArgumentException,
MulticallNotFound,
} from "@zkchainhub/providers/exceptions";
import { AbiWithConstructor } from "@zkchainhub/providers/types";

/**
Expand Down Expand Up @@ -159,4 +166,22 @@ export class EvmProviderService {
throw new DataDecodeException("Error decoding return data with given AbiParameters");
}
}

/**
* Similar to readContract, but batches up multiple functions
* on a contract in a single RPC call via the multicall3 contract.
* @param {MulticallParameters} args - The parameters for the multicall.
* @returns — An array of results. If allowFailure is true, with accompanying status
* @throws {MulticallNotFound} if the Multicall contract is not found.
*/
async multicall<
contracts extends readonly unknown[] = readonly ContractFunctionParameters[],
allowFailure extends boolean = true,
>(
args: MulticallParameters<contracts, allowFailure>,
): Promise<MulticallReturnType<contracts, allowFailure>> {
if (!this.chain.contracts?.multicall3?.address) throw new MulticallNotFound();
Copy link
Collaborator

Choose a reason for hiding this comment

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

good catch here


return this.client.multicall<contracts, allowFailure>(args);
}
}
99 changes: 93 additions & 6 deletions libs/providers/test/unit/providers/evmProvider.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { localhost } from "viem/chains";
import { Logger } from "winston";

import { EvmProviderService } from "@zkchainhub/providers";
import { DataDecodeException } from "@zkchainhub/providers/exceptions";
import { DataDecodeException, MulticallNotFound } from "@zkchainhub/providers/exceptions";
import {
arrayAbiFixture,
structAbiFixture,
Expand All @@ -27,14 +27,14 @@ export const mockLogger: Partial<Logger> = {
error: jest.fn(),
debug: jest.fn(),
};
const testAbi = parseAbi([
"constructor(uint256 totalSupply)",
"function balanceOf(address owner) view returns (uint256)",
"function tokenURI(uint256 tokenId) pure returns (string)",
]);

describe("EvmProviderService", () => {
let viemProvider: EvmProviderService;
const testAbi = parseAbi([
"constructor(uint256 totalSupply)",
"function balanceOf(address owner) view returns (uint256)",
"function tokenURI(uint256 tokenId) pure returns (string)",
]);

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -269,4 +269,91 @@ describe("EvmProviderService", () => {
expect(returnValue).toEqual(arrayAbiFixture.args[0]);
});
});
describe("multicall", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

lets also add a test for the happy path

it("throws a MulticallNotFound error if the Multicall contract is not found", async () => {
const contracts = [
{
address: "0x123456789",
abi: testAbi,
functionName: "balanceOf",
args: ["0x987654321"],
} as const,
];

await expect(viemProvider.multicall({ contracts })).rejects.toThrowError(
MulticallNotFound,
);
});
});
});

describe("EvmProviderService (with mocked chain)", () => {
Copy link
Collaborator

@0xkenj1 0xkenj1 Aug 7, 2024

Choose a reason for hiding this comment

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

may we just mock the chain instead of splitting the tests into 2 describes ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah you're right, in spanish criollo: "flashie"

let viemProvider: EvmProviderService;

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
providers: [
{
provide: WINSTON_MODULE_PROVIDER,
useValue: mockLogger,
},
{
provide: EvmProviderService,
useFactory: () => {
const rpcUrl = "http://localhost:8545";
const chain = {
...localhost,
contracts: { multicall3: { address: "0x123456789" as viem.Address } },
};
return new EvmProviderService(rpcUrl, chain, mockLogger as Logger);
},
},
],
}).compile();

viemProvider = app.get<EvmProviderService>(EvmProviderService);
});

afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
describe("multicall", () => {
it("calls the multicall method of the Viem client with the correct arguments", async () => {
const contracts = [
{
address: "0x123456789",
abi: testAbi,
functionName: "balanceOf",
args: ["0x987654321"],
} as const,
{
address: "0x123456789",
abi: testAbi,
functionName: "tokenURI",
args: [1n],
} as const,
{
address: "0x987654321",
abi: testAbi,
functionName: "totalSupply",
args: [],
} as const,
];

const expectedReturnValue = [
{ result: 100n, status: true },
{ result: "tokenUri", status: true },
{ result: 1000n, status: true },
];

// Mock the multicall method of the Viem client
jest.spyOn(mockClient, "multicall").mockResolvedValue(expectedReturnValue);

const returnValue = await viemProvider.multicall({ contracts });

expect(returnValue).toEqual(expectedReturnValue);
expect(mockClient.multicall).toHaveBeenCalledWith({ contracts });
});
});
});
5 changes: 5 additions & 0 deletions libs/shared/src/constants/addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Address } from "abitype";

// This is the address given to native ETH on L2 chains.
// See: https://github.com/matter-labs/era-contracts/blob/8a70bbbc48125f5bde6189b4e3c6a3ee79631678/l1-contracts/contracts/common/Config.sol#L105
Comment on lines +3 to +4
Copy link
Collaborator

Choose a reason for hiding this comment

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

💯

export const ETH_TOKEN_ADDRESS: Address = "0x0000000000000000000000000000000000000001";
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";