diff --git a/libs/metrics/src/l1/bytecode/index.ts b/libs/metrics/src/l1/bytecode/index.ts new file mode 100644 index 0000000..3ef949c --- /dev/null +++ b/libs/metrics/src/l1/bytecode/index.ts @@ -0,0 +1,2 @@ +export const tokenBalancesBytecode = + "0x608060405234801561001057600080fd5b5060405161063538038061063583398181016040528101906100329190610332565b60008151905060006001826100479190610516565b67ffffffffffffffff811115610086577f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6040519080825280602002602001820160405280156100b45781602001602082028036833780820191505090505b50905060005b828110156101e75760008482815181106100fd577f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b6020026020010151905060008173ffffffffffffffffffffffffffffffffffffffff166370a08231886040518263ffffffff1660e01b81526004016101429190610443565b60206040518083038186803b15801561015a57600080fd5b505afa15801561016e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101929190610386565b9050808484815181106101ce577f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b60200260200101818152505082600101925050506100ba565b8473ffffffffffffffffffffffffffffffffffffffff1631828281518110610238577f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b602002602001018181525050600082604051602001610257919061045e565b60405160208183030381529060405290506020810180590381f35b6000610285610280846104b1565b610480565b905080838252602082019050828560208602820111156102a457600080fd5b60005b858110156102d457816102ba88826102de565b8452602084019350602083019250506001810190506102a7565b5050509392505050565b6000815190506102ed81610606565b92915050565b600082601f83011261030457600080fd5b8151610314848260208601610272565b91505092915050565b60008151905061032c8161061d565b92915050565b6000806040838503121561034557600080fd5b6000610353858286016102de565b925050602083015167ffffffffffffffff81111561037057600080fd5b61037c858286016102f3565b9150509250929050565b60006020828403121561039857600080fd5b60006103a68482850161031d565b91505092915050565b60006103bb8383610434565b60208301905092915050565b6103d08161056c565b82525050565b60006103e1826104ed565b6103eb8185610505565b93506103f6836104dd565b8060005b8381101561042757815161040e88826103af565b9750610419836104f8565b9250506001810190506103fa565b5085935050505092915050565b61043d8161059e565b82525050565b600060208201905061045860008301846103c7565b92915050565b6000602082019050818103600083015261047881846103d6565b905092915050565b6000604051905081810181811067ffffffffffffffff821117156104a7576104a66105d7565b5b8060405250919050565b600067ffffffffffffffff8211156104cc576104cb6105d7565b5b602082029050602081019050919050565b6000819050602082019050919050565b600081519050919050565b6000602082019050919050565b600082825260208201905092915050565b60006105218261059e565b915061052c8361059e565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03821115610561576105606105a8565b5b828201905092915050565b60006105778261057e565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000819050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b61060f8161056c565b811461061a57600080fd5b50565b6106268161059e565b811461063157600080fd5b5056fe"; diff --git a/libs/metrics/src/l1/l1MetricsService.ts b/libs/metrics/src/l1/l1MetricsService.ts index 346ebf5..75de2d7 100644 --- a/libs/metrics/src/l1/l1MetricsService.ts +++ b/libs/metrics/src/l1/l1MetricsService.ts @@ -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. @@ -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 { + 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, + ): 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 = [ + 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, diff --git a/libs/metrics/src/types/index.ts b/libs/metrics/src/types/index.ts index e69de29..411abef 100644 --- a/libs/metrics/src/types/index.ts +++ b/libs/metrics/src/types/index.ts @@ -0,0 +1 @@ +export * from "./tvl.type"; diff --git a/libs/metrics/src/types/tvl.type.ts b/libs/metrics/src/types/tvl.type.ts new file mode 100644 index 0000000..1b59dda --- /dev/null +++ b/libs/metrics/src/types/tvl.type.ts @@ -0,0 +1,7 @@ +import { TokenUnion } from "@zkchainhub/shared/tokens/tokens"; + +export type AssetTvl = Omit & { + amount: string; + amountUsd: string; + price: string; +}; diff --git a/libs/metrics/test/unit/l1/l1MetricsService.spec.ts b/libs/metrics/test/unit/l1/l1MetricsService.spec.ts index 1c6bb25..0a31a80 100644 --- a/libs/metrics/test/unit/l1/l1MetricsService.spec.ts +++ b/libs/metrics/test/unit/l1/l1MetricsService.spec.ts @@ -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(); -const mockPricingService = { - // Mock methods and properties as needed -}; +const mockPricingService = createMock(); + +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 = { log: jest.fn(), @@ -60,6 +133,10 @@ describe("L1MetricsService", () => { l1MetricsService = module.get(L1MetricsService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe("constructor", () => { it("initialize bridgeHub and sharedBridge", () => { expect(l1MetricsService["bridgeHub"]).toEqual({ @@ -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"); }); }); diff --git a/libs/shared/src/tokens/tokens.ts b/libs/shared/src/tokens/tokens.ts index ba1ded9..9865e86 100644 --- a/libs/shared/src/tokens/tokens.ts +++ b/libs/shared/src/tokens/tokens.ts @@ -1,24 +1,38 @@ -export type TokenType = { +/** + * 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 Token = { name: string; symbol: string; coingeckoId: string; - type: "erc20" | "native"; - contractAddress: string | 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> = { + 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[]> = [ { name: "USDC", symbol: "USDC", @@ -487,3 +501,8 @@ export const tokens: TokenType[] = [ decimals: 18, }, ]; + +export const tokens: Readonly = [nativeToken, ...erc20Tokens]; + +export const isNativeToken = (token: TokenUnion): token is Token<"native"> => + token.type === "native"; diff --git a/libs/shared/src/utils/index.ts b/libs/shared/src/utils/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 33c8aea..e6b6d92 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "abitype": "1.0.5", "axios": "1.7.2", "axios-mock-adapter": "1.22.0", - "nest-winston": "1.9.7", "cache-manager": "5.7.4", + "nest-winston": "1.9.7", "reflect-metadata": "0.1.13", "rxjs": "7.8.1", "solhint-community": "4.0.0",