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: calculate L1 TVL using batch request #37

Merged
merged 3 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions libs/metrics/src/l1/bytecode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const tokenBalancesBytecode =
Copy link
Collaborator

@0xkenj1 0xkenj1 Aug 2, 2024

Choose a reason for hiding this comment

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

do we have a script to generate this? is it necessary to have it as variable? maybe just plain text is good enough, given that we are not getting any typing from this value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

for now, i manually copied the bytecode generated on contracts compilation (same as with the abi). i think adding a script that runs after compilation that copies the bytecode into the .ts file is too much at this stage but can be a future enhancement

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

regarding plain text, i didn't follow you here, what are you suggesting? the bytecode as string is needed to be passed as argument of batchRequest on EvmProvider method

Copy link
Collaborator

Choose a reason for hiding this comment

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

i want to automate this for the cases in which the smart contract changes, so we use the compiled output each time we want to run or deploy or even running the pipeline.

this is kind of hardcoded, i don't like it if we have the contracts that can be compiled on the repo, maybe using the typechain version of viem could be an option.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

i would have to write a script or copy and move the json with compilation info but i think the json has too much extra info that is not needed. however i think this script should be written on a separate PR

Copy link
Collaborator

Choose a reason for hiding this comment

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

sounds good, i will add it to tech debt

"0x608060405234801561001057600080fd5b5060405161063538038061063583398181016040528101906100329190610332565b60008151905060006001826100479190610516565b67ffffffffffffffff811115610086577f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6040519080825280602002602001820160405280156100b45781602001602082028036833780820191505090505b50905060005b828110156101e75760008482815181106100fd577f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b6020026020010151905060008173ffffffffffffffffffffffffffffffffffffffff166370a08231886040518263ffffffff1660e01b81526004016101429190610443565b60206040518083038186803b15801561015a57600080fd5b505afa15801561016e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101929190610386565b9050808484815181106101ce577f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b60200260200101818152505082600101925050506100ba565b8473ffffffffffffffffffffffffffffffffffffffff1631828281518110610238577f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b602002602001018181525050600082604051602001610257919061045e565b60405160208183030381529060405290506020810180590381f35b6000610285610280846104b1565b610480565b905080838252602082019050828560208602820111156102a457600080fd5b60005b858110156102d457816102ba88826102de565b8452602084019350602083019250506001810190506102a7565b5050509392505050565b6000815190506102ed81610606565b92915050565b600082601f83011261030457600080fd5b8151610314848260208601610272565b91505092915050565b60008151905061032c8161061d565b92915050565b6000806040838503121561034557600080fd5b6000610353858286016102de565b925050602083015167ffffffffffffffff81111561037057600080fd5b61037c858286016102f3565b9150509250929050565b60006020828403121561039857600080fd5b60006103a68482850161031d565b91505092915050565b60006103bb8383610434565b60208301905092915050565b6103d08161056c565b82525050565b60006103e1826104ed565b6103eb8185610505565b93506103f6836104dd565b8060005b8381101561042757815161040e88826103af565b9750610419836104f8565b9250506001810190506103fa565b5085935050505092915050565b61043d8161059e565b82525050565b600060208201905061045860008301846103c7565b92915050565b6000602082019050818103600083015261047881846103d6565b905092915050565b6000604051905081810181811067ffffffffffffffff821117156104a7576104a66105d7565b5b8060405250919050565b600067ffffffffffffffff8211156104cc576104cb6105d7565b5b602082029050602081019050919050565b6000819050602082019050919050565b600081519050919050565b6000602082019050919050565b600082825260208201905092915050565b60006105218261059e565b915061052c8361059e565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03821115610561576105606105a8565b5b828201905092915050565b60006105778261057e565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000819050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b61060f8161056c565b811461061a57600080fd5b50565b6106268161059e565b811461063157600080fd5b5056fe";
82 changes: 79 additions & 3 deletions libs/metrics/src/l1/l1MetricsService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import assert from "assert";
import { Inject, Injectable, LoggerService } from "@nestjs/common";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";
import { Address, ContractConstructorArgs, parseAbiParameters } from "viem";

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 { Tvl } 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 { tokens } from "@zkchainhub/shared/tokens/tokens";
import { parseUnits } from "@zkchainhub/shared/utils";

/**
* Acts as a wrapper around Viem library to provide methods to interact with an EVM-based blockchain.
Expand All @@ -27,10 +34,79 @@ export class L1MetricsService {
@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService,
) {}

//TODO: Implement l1Tvl.
async l1Tvl(): Promise<{ [asset: string]: { amount: number; amountUsd: number } }> {
return { ETH: { amount: 1000000, amountUsd: 1000000 } };
/**
* Retrieves the Total Value Locked by token on L1 Shared Bridge contract
* @returns A Promise that resolves to an object representing the TVL.
*/
async l1Tvl(): Promise<Tvl> {
const addresses = tokens
.filter((token) => !!token.contractAddress)
.map((token) => token.contractAddress) as Address[];
Copy link
Collaborator

Choose a reason for hiding this comment

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

reduce


const balances = await this.fetchTokenBalances(addresses);
const pricesRecord = await this.pricingService.getTokenPrices(
tokens.map((token) => token.coingeckoId),
);

assert(balances.length === addresses.length + 1, "Invalid balances length");
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, why is this assert necessary here with the + 1? If it's related to the natspec for the fetchTokenBalances method, this assert might be better located inside the method?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

💯

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

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

private calculateTvl(
balances: bigint[],
addresses: Address[],
prices: Record<string, number>,
): Tvl {
const tvl: Tvl = {};

for (const token of tokens) {
const balance =
token.type === "native"
Copy link
Collaborator

Choose a reason for hiding this comment

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

we can add 2 typeguards isNativeToken and isErc20Token

? balances[addresses.length]
Copy link
Collaborator

Choose a reason for hiding this comment

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

is this correct ? always returning the same value for balance using address.length

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm I had the same doubt 🤔 but I think it's related with the structure of the return value of fetchTokenBalances. If I'm understanding correctly, the last element of that bigint[] array is a "special" value (which is being used here).

I'll write a suggestion at that method that might make things easier to understand here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the custom contract for batch requests, requests token balances + native balanceOf(address) which in L1 is eth balance as the last element of balances array, so we always return addresses + 1 (since native token doesn't have a contract itself)

Copy link
Collaborator

Choose a reason for hiding this comment

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

hmmmmmm, maybe using the batch contract just for tokens would be a better approach and would make the code more clear.

then you will have something like

{...calculateTvl(getTokenBalances()),getEthBalance()}

I would also rename TokenTvl to AssetTvl

Both calculateTvl(getTokenBalances()) and getEthBalance() would return AssetTvl

: balances[addresses.indexOf(token.contractAddress as Address)];

assert(balance !== undefined, `Balance for ${token.symbol} not found`);

const price = prices[token.coingeckoId] as number;
const parsedBalance = parseUnits(balance, token.decimals);
const tvlValue = parsedBalance * price;

tvl[token.symbol] = {
amount: parsedBalance,
amountUsd: tvlValue,
name: token.name,
imageUrl: token.imageUrl,
};
}

return tvl;
}

/**
* Fetches the token balances of Shared Bridgefor the given addresses.
* Note: The last balance in the returned array is the ETH balance.
* @param addresses The addresses for which to fetch the token balances.
* @returns A promise that resolves to an array of token balances as bigints.
*/
private async fetchTokenBalances(addresses: Address[]): Promise<bigint[]> {
const returnAbiParams = parseAbiParameters("uint256[]");
const args: ContractConstructorArgs<typeof tokenBalancesAbi> = [
L1_CONTRACTS.SHARED_BRIDGE,
addresses,
];

const [balances] = await this.evmProviderService.batchRequest(
tokenBalancesAbi,
tokenBalancesBytecode,
args,
returnAbiParams,
);

return balances as bigint[];
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm suggesting this purely from a pure code perspective, I've got no idea about the bussiness logic behind this so feel free to tell me that I'm absolutely wrong lol

Trying to make things a little bit more declarative, the fetchTokenBalances could return some kind of object that distinguishes between the eth balance and the rest of the balances as it seems that the eth balance is a pretty particular case for the consumers of this data in your code:

Suggested change
return balances as bigint[];
return { ethBalance: balances[balances.length], addressesBalances: balances.slice(0, -1) }

Something like that might make things easier to use: you can change the assertion I've commented on by dropping the + 1 that feels strange at first sight and also, there will be no "obscure" accesses to balances[balances.length] in the future, as it might bring some confusion to the reader.

Copy link
Collaborator

Choose a reason for hiding this comment

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

this approach looks quite better and its better than the one that i've pointed out in my prev comment.

}

//TODO: Implement getBatchesInfo.
async getBatchesInfo(
_chainId: number,
Expand Down
1 change: 1 addition & 0 deletions libs/metrics/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./tvl.type";
10 changes: 10 additions & 0 deletions libs/metrics/src/types/tvl.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type TokenTvl = {
amount: number;
amountUsd: number;
name: string;
imageUrl?: string;
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 add address here ?

};

export type Tvl = {
[asset: string]: TokenTvl;
};
124 changes: 116 additions & 8 deletions libs/metrics/test/unit/l1/l1MetricsService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
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 { 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";

// Mock implementations of the dependencies
const mockEvmProviderService = {
// Mock methods and properties as needed
};
const mockEvmProviderService = createMock<EvmProviderService>();

const mockPricingService = {
// Mock methods and properties as needed
};
const mockPricingService = createMock<IPricingService>();

jest.mock("@zkchainhub/shared/tokens/tokens", () => ({
get tokens() {
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,
},
{
name: "USDC",
symbol: "USDC",
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
coingeckoId: "usd-coin",
imageUrl:
"https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694",
type: "erc20",
decimals: 6,
},
{
name: "Wrapped BTC",
symbol: "WBTC",
contractAddress: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
coingeckoId: "wrapped-bitcoin",
imageUrl:
"https://coin-images.coingecko.com/coins/images/7598/large/wrapped_bitcoin_wbtc.png?1696507857",
type: "erc20",
decimals: 8,
},
];
},
}));

export const mockLogger: Partial<Logger> = {
log: jest.fn(),
Expand Down Expand Up @@ -60,6 +96,10 @@ describe("L1MetricsService", () => {
l1MetricsService = module.get<L1MetricsService>(L1MetricsService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe("constructor", () => {
it("initialize bridgeHub and sharedBridge", () => {
expect(l1MetricsService["bridgeHub"]).toEqual({
Expand All @@ -78,9 +118,77 @@ describe("L1MetricsService", () => {
});

describe("l1Tvl", () => {
it("return l1Tvl", async () => {
it("return the TVL on L1 Shared Bridge", async () => {
const mockBalances = [60_841_657_140641n, 135_63005559n, 12_3803_824374847279970609n]; // Mocked balances
Copy link
Collaborator

Choose a reason for hiding this comment

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

Any special reason to have 135_63005559n instead of 13_563_005_559n?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is to now where the decimal part starts on the bigint 😅 , do you think this is misleading?

const mockPrices = { "wrapped-bitcoin": 66_129, "usd-coin": 0.999, ethereum: 3_181.09 }; // Mocked prices

jest.spyOn(mockEvmProviderService, "batchRequest").mockResolvedValue([mockBalances]);
jest.spyOn(mockPricingService, "getTokenPrices").mockResolvedValue(mockPrices);

const result = await l1MetricsService.l1Tvl();
expect(result).toEqual({ ETH: { amount: 1000000, amountUsd: 1000000 } });

expect(result).toMatchObject({
ETH: {
amount: expect.closeTo(12_3803.824),
amountUsd: expect.closeTo(393_831_107.68),
name: "Ethereum",
imageUrl:
"https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628",
},
WBTC: {
amount: expect.closeTo(135.631),
amountUsd: expect.closeTo(8_969_079.95),
name: "Wrapped BTC",
imageUrl:
"https://coin-images.coingecko.com/coins/images/7598/large/wrapped_bitcoin_wbtc.png?1696507857",
},
USDC: {
amount: expect.closeTo(60_841_657.141),
amountUsd: expect.closeTo(60_780_815.48),
name: "USDC",
imageUrl:
"https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694",
},
});
expect(mockEvmProviderService.batchRequest).toHaveBeenCalledWith(
tokenBalancesAbi,
tokenBalancesBytecode,
[
L1_CONTRACTS.SHARED_BRIDGE,
[
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
],
],
[
{
type: "uint256[]",
},
],
);
expect(mockPricingService.getTokenPrices).toHaveBeenCalledWith([
"ethereum",
"usd-coin",
"wrapped-bitcoin",
]);
});

it("throws an error if the balances length is invalid", async () => {
jest.spyOn(mockEvmProviderService, "batchRequest").mockResolvedValue([[]]);

await expect(l1MetricsService.l1Tvl()).rejects.toThrowError("Invalid balances length");
});

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

await expect(l1MetricsService.l1Tvl()).rejects.toThrowError("Invalid prices length");
});
});

Expand Down
4 changes: 3 additions & 1 deletion libs/shared/src/tokens/tokens.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

lmk if suggestions is what we want

Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Address } from "abitype";

export type TokenType = {
name: string;
symbol: string;
coingeckoId: string;
type: "erc20" | "native";
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
export type TokenType = {
name: string;
symbol: string;
coingeckoId: string;
type: "erc20" | "native";
export type TokenType<TokenType extends "erc20" | "native"> = {
name: string;
symbol: string;
coingeckoId: string;
type: TokenType;

contractAddress: string | null;
contractAddress: Address | null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
contractAddress: Address | null;
contractAddress: TokenType == "erc20" ? Address : null;

decimals: number;
imageUrl?: string;
};
Expand Down
1 change: 1 addition & 0 deletions libs/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./parseUnits";
7 changes: 7 additions & 0 deletions libs/shared/src/utils/parseUnits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import BigNumber from "bignumber.js";

export const parseUnits = (value: bigint, decimals: number): number => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

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, i'll refactor to use: https://viem.sh/docs/utilities/formatUnits

return BigNumber(value.toString())
.div(10 ** decimals)
.toNumber();
};
33 changes: 33 additions & 0 deletions libs/shared/test/unit/utils/parseUnits.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { parseUnits } from "@zkchainhub/shared/utils";

describe("parseUnits", () => {
it("parse units correctly", () => {
const value = 1000000000000000000n;
const decimals = 18;
const expected = 1;

const result = parseUnits(value, decimals);

expect(result).toEqual(expected);
});

it("handle decimals correctly", () => {
const value = 123456789n;
const decimals = 9;
const expected = 0.123456789;

const result = parseUnits(value, decimals);

expect(result).toEqual(expected);
});

it("handles zero value correctly", () => {
const value = 0n;
const decimals = 18;
const expected = 0;

const result = parseUnits(value, decimals);

expect(result).toEqual(expected);
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@
"abitype": "1.0.5",
"axios": "1.7.2",
"axios-mock-adapter": "1.22.0",
"nest-winston": "1.9.7",
"bignumber.js": "9.1.2",
"cache-manager": "5.7.4",
"nest-winston": "1.9.7",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1",
"solhint-community": "4.0.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.