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 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
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";
106 changes: 103 additions & 3 deletions libs/metrics/src/l1/l1MetricsService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import assert from "assert";
import { Inject, Injectable, LoggerService } from "@nestjs/common";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";
import {
Address,
ContractConstructorArgs,
formatUnits,
parseAbiParameters,
parseUnits,
} 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 { 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 { erc20Tokens, isNativeToken, tokens } from "@zkchainhub/shared/tokens/tokens";

/**
* Acts as a wrapper around Viem library to provide methods to interact with an EVM-based blockchain.
Expand All @@ -27,10 +39,98 @@ 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 array of AssetTvl objects representing the TVL for each asset.
*/
async l1Tvl(): Promise<AssetTvl[]> {
const erc20Addresses = erc20Tokens.map((token) => token.contractAddress);

const balances = await this.fetchTokenBalances(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);
}

/**
* Calculates the Total Value Locked (TVL) for each token based on the provided balances, addresses, and prices.
* @param balances - The balances object containing the ETH balance and an array of erc20 token addresses balance.
* @param addresses - The array of erc20 addresses.
* @param prices - The object containing the prices of tokens.
* @returns An array of AssetTvl objects representing the TVL for each token in descending order.
*/
private calculateTvl(
balances: { ethBalance: bigint; addressesBalance: bigint[] },
addresses: Address[],
prices: Record<string, number>,
): AssetTvl[] {
const tvl: AssetTvl[] = [];

for (const token of tokens) {
const { coingeckoId, ...tokenInfo } = token;

const balance = isNativeToken(token)
? balances.ethBalance
: balances.addressesBalance[
addresses.indexOf(tokenInfo.contractAddress as Address)
];
Comment on lines +76 to +80
Copy link
Collaborator

Choose a reason for hiding this comment

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

💎


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

const price = prices[coingeckoId] as number;
// math is done with bigints for better precision
const tvlValue = formatUnits(
balance * parseUnits(price.toString(), tokenInfo.decimals),
tokenInfo.decimals * 2,
);

const assetTvl: AssetTvl = {
amount: formatUnits(balance, tokenInfo.decimals),
amountUsd: tvlValue,
price: price.toString(),
...tokenInfo,
};

tvl.push(assetTvl);
}

// we assume the rounding error is negligible for sorting purposes
tvl.sort((a, b) => Number(b.amountUsd) - Number(a.amountUsd));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Good clarifying comment there 💯 , just one extremely nitpick question but figured is worth asking: this might cause tvl to have elements with equal amountUsd to be sorted differently even if run with the same set of data [1]. Are we ok with that?

Eg, this is potentially possible:

> tvl.sort()
[{name: "A", amountUsd: 1}, {name: "B", amountUsd: 2}]
> tvl.sort()
[{name: "B", amountUsd: 1}, {name: "A", amountUsd: 2}]

It seems that the JavaScript engines now generally tend to implement the sort function in a stable way, but there's a second thing to have in mind also: the order of the returned tvl, if the sort is stable, will probably depend on the order of the input tokens.

To wrap up, is the order of tvl critical? If yes, you might also sort by name (when amountUsd values are equal) or something like that to always have a consistent order, without depending on the sort implementation or the tokens order. If this is not the case, this works perfectly. 💯

[1] https://www.30secondsofcode.org/js/s/array-stable-sort/#:~:text=The%20ECMAScript%20specification%20does%20not,to%20be%20preserved%20after%20sorting.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, this is something we might not need to care about for now, but it's a good observation. Let's keep it as it is for now.

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 think that the chances of two tokens to have the same tvl is really low on the most relevant tokens, this is more likely the situation on token with low balances or 0ish TVL
adding a second sort by name i think is not necessary for now (we don't care if PEPE goes before or after MAGAIBA xd)

Copy link
Collaborator

@0xkenj1 0xkenj1 Aug 6, 2024

Choose a reason for hiding this comment

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

hahahahaha , nigiri is right. However we should be aware of this if we need to change it in the future. :)


return tvl;
}

/**
* Fetches the token balances for the given addresses and ETH balance.
* 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 fetchTokenBalances(
addresses: Address[],
): Promise<{ ethBalance: bigint; addressesBalance: 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,
);

assert(balances.length === addresses.length + 1, "Invalid balances length");

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

//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";
7 changes: 7 additions & 0 deletions libs/metrics/src/types/tvl.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenUnion } from "@zkchainhub/shared/tokens/tokens";

export type AssetTvl = Omit<TokenUnion, "coingeckoId"> & {
amount: string;
amountUsd: string;
price: string;
};
177 changes: 169 additions & 8 deletions libs/metrics/test/unit/l1/l1MetricsService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,94 @@
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", () => ({
...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 [
{
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,
},
];
},
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 +133,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 +155,93 @@ 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, 123_803_824374847279970609n]; // Mocked balances
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).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.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, 123_803_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
Loading