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 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
90 changes: 57 additions & 33 deletions libs/metrics/src/l1/l1MetricsService.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
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 {
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 { Tvl } from "@zkchainhub/metrics/types";
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 { tokens } from "@zkchainhub/shared/tokens/tokens";
import { parseUnits } from "@zkchainhub/shared/utils";
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 @@ -36,61 +41,78 @@ export class L1MetricsService {

/**
* Retrieves the Total Value Locked by token on L1 Shared Bridge contract
* @returns A Promise that resolves to an object representing the TVL.
* @returns A Promise that resolves to an array of AssetTvl objects representing the TVL for each asset.
*/
async l1Tvl(): Promise<Tvl> {
const addresses = tokens
.filter((token) => !!token.contractAddress)
.map((token) => token.contractAddress) as Address[];
async l1Tvl(): Promise<AssetTvl[]> {
const erc20Addresses = erc20Tokens.map((token) => token.contractAddress);

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

assert(balances.length === addresses.length + 1, "Invalid balances length");
assert(Object.keys(pricesRecord).length === tokens.length, "Invalid prices length");

return this.calculateTvl(balances, addresses, pricesRecord);
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: bigint[],
balances: { ethBalance: bigint; addressesBalance: bigint[] },
addresses: Address[],
prices: Record<string, number>,
): Tvl {
const tvl: Tvl = {};
): AssetTvl[] {
const tvl: AssetTvl[] = [];

for (const token of tokens) {
const balance =
token.type === "native"
? balances[addresses.length]
: balances[addresses.indexOf(token.contractAddress as Address)];
const { coingeckoId, ...tokenInfo } = token;

assert(balance !== undefined, `Balance for ${token.symbol} not found`);
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.

💎


const price = prices[token.coingeckoId] as number;
const parsedBalance = parseUnits(balance, token.decimals);
const tvlValue = parsedBalance * price;
assert(balance !== undefined, `Balance for ${tokenInfo.symbol} not found`);

tvl[token.symbol] = {
amount: parsedBalance,
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,
name: token.name,
imageUrl: token.imageUrl,
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 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.
* 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<bigint[]> {
private async fetchTokenBalances(
addresses: Address[],
): Promise<{ ethBalance: bigint; addressesBalance: bigint[] }> {
const returnAbiParams = parseAbiParameters("uint256[]");
const args: ContractConstructorArgs<typeof tokenBalancesAbi> = [
L1_CONTRACTS.SHARED_BRIDGE,
Expand All @@ -104,7 +126,9 @@ export class L1MetricsService {
returnAbiParams,
);

return balances as bigint[];
assert(balances.length === addresses.length + 1, "Invalid balances length");

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

//TODO: Implement getBatchesInfo.
Expand Down
13 changes: 5 additions & 8 deletions libs/metrics/src/types/tvl.type.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
export type TokenTvl = {
amount: number;
amountUsd: number;
name: string;
imageUrl?: string;
};
import { TokenUnion } from "@zkchainhub/shared/tokens/tokens";

export type Tvl = {
[asset: string]: TokenTvl;
export type AssetTvl = Omit<TokenUnion, "coingeckoId"> & {
amount: string;
amountUsd: string;
price: string;
};
87 changes: 70 additions & 17 deletions libs/metrics/test/unit/l1/l1MetricsService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,43 @@ const mockEvmProviderService = createMock<EvmProviderService>();
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 [
{
Expand Down Expand Up @@ -119,37 +156,53 @@ describe("L1MetricsService", () => {

describe("l1Tvl", () => {
it("return the TVL on L1 Shared Bridge", async () => {
const mockBalances = [60_841_657_140641n, 135_63005559n, 12_3803_824374847279970609n]; // Mocked balances
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).toMatchObject({
ETH: {
amount: expect.closeTo(12_3803.824),
amountUsd: expect.closeTo(393_831_107.68),
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,
},
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),
{
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,
Expand Down Expand Up @@ -181,7 +234,7 @@ describe("L1MetricsService", () => {

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],
[60_841_657_140641n, 135_63005559n, 123_803_824374847279970609n],
]);
jest.spyOn(mockPricingService, "getTokenPrices").mockResolvedValue({
ethereum: 3_181.09,
Expand Down
45 changes: 31 additions & 14 deletions 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,26 +1,38 @@
/**
* The token list in this file was manually crafted and represents the top 50
* tokens by market cap, held by L1 Shared Bridge contract and with data
* present in Coingecko.
* Last updated: 2024-08-03
*
* This list is not exhaustive and can be updated with more tokens as needed.
* Link to the token list: https://etherscan.io/tokenholdings?a=0xD7f9f54194C633F36CCD5F3da84ad4a1c38cB2cB
*/

import { Address } from "abitype";

export type TokenType = {
export type Token<TokenType extends "erc20" | "native"> = {
name: string;
symbol: string;
coingeckoId: string;
type: "erc20" | "native";
contractAddress: Address | null;
type: TokenType;
contractAddress: TokenType extends "erc20" ? Address : null;
decimals: number;
imageUrl?: string;
};

export const tokens: TokenType[] = [
{
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,
},
export type TokenUnion = Token<"erc20"> | Token<"native">;

export const nativeToken: Readonly<Token<"native">> = {
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,
};

export const erc20Tokens: Readonly<Token<"erc20">[]> = [
{
name: "USDC",
symbol: "USDC",
Expand Down Expand Up @@ -489,3 +501,8 @@ export const tokens: TokenType[] = [
decimals: 18,
},
];

export const tokens: Readonly<TokenUnion[]> = [nativeToken, ...erc20Tokens];

export const isNativeToken = (token: TokenUnion): token is Token<"native"> =>
token.type === "native";
1 change: 0 additions & 1 deletion libs/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
export * from "./parseUnits";
7 changes: 0 additions & 7 deletions libs/shared/src/utils/parseUnits.ts

This file was deleted.

33 changes: 0 additions & 33 deletions libs/shared/test/unit/utils/parseUnits.spec.ts

This file was deleted.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
"abitype": "1.0.5",
"axios": "1.7.2",
"axios-mock-adapter": "1.22.0",
"bignumber.js": "9.1.2",
"cache-manager": "5.7.4",
"nest-winston": "1.9.7",
"reflect-metadata": "0.1.13",
Expand Down
Loading