Skip to content

Commit

Permalink
feat: calculate L1 TVL using batch request (#37)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes ZKS-75

## Description

Retrieve L1 TVL of Shared Bridge from static token list:

- l1Tvl method implemented
- uses batchRequest for fetching token balances
- uses Pricing Service for fetching prices
  • Loading branch information
0xnigir1 authored Aug 6, 2024
1 parent 54562eb commit 4ef73d9
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 26 deletions.
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 =
"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)
];

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));

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

0 comments on commit 4ef73d9

Please sign in to comment.