From 9238bcc80b5115fc902e6b6704c9daabbef04837 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:12:27 -0300 Subject: [PATCH] feat: tvl by chain (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Closes ZKS-76 ## Description Retrieve TVL by Chain ID of Shared Bridge from static token list: `tvl(chainId: number)` method implemented added `multicall` method to EvmProviderService uses multicall for fetching token balances uses Pricing Service for fetching prices --- libs/metrics/src/l1/l1MetricsService.ts | 54 +++++++++- .../test/unit/l1/l1MetricsService.spec.ts | 99 ++++++++++++++++++- libs/providers/src/exceptions/index.ts | 1 + .../exceptions/multicallNotFound.exception.ts | 5 + .../src/providers/evmProvider.service.ts | 27 ++++- .../providers/evmProvider.service.spec.ts | 69 +++++++++++-- libs/shared/src/constants/addresses.ts | 3 + 7 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 libs/providers/src/exceptions/multicallNotFound.exception.ts diff --git a/libs/metrics/src/l1/l1MetricsService.ts b/libs/metrics/src/l1/l1MetricsService.ts index 4dcd4ad..cc051d8 100644 --- a/libs/metrics/src/l1/l1MetricsService.ts +++ b/libs/metrics/src/l1/l1MetricsService.ts @@ -22,6 +22,7 @@ import { AssetTvl, GasInfo } from "@zkchainhub/metrics/types"; import { IPricingService, PRICING_PROVIDER } from "@zkchainhub/pricing"; import { EvmProviderService } from "@zkchainhub/providers"; import { AbiWithAddress, ChainId, L1_CONTRACTS, vitalikAddress } from "@zkchainhub/shared"; +import { ETH_TOKEN_ADDRESS } from "@zkchainhub/shared/constants/addresses"; import { erc20Tokens, isNativeToken, @@ -151,12 +152,55 @@ export class L1MetricsService { ): Promise<{ commited: number; verified: number; proved: number }> { return { commited: 100, verified: 100, proved: 100 }; } - //TODO: Implement tvl. - async tvl( - _chainId: number, - ): Promise<{ [asset: string]: { amount: number; amountUsd: number } }> { - return { ETH: { amount: 1000000, amountUsd: 1000000 } }; + + /** + * Retrieves the Total Value Locked for {chainId} by L1 token + * @returns A Promise that resolves to an array of AssetTvl objects representing the TVL for each asset. + */ + async tvl(chainId: number): Promise { + const erc20Addresses = erc20Tokens.map((token) => token.contractAddress); + + const balances = await this.fetchTokenBalancesByChain(chainId, 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); + } + + /** + * Fetches the token balances for the given addresses and ETH balance on {chainId} + * 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 fetchTokenBalancesByChain(chainId: number, addresses: Address[]) { + const chainIdBn = BigInt(chainId); + const balances = await this.evmProviderService.multicall({ + contracts: [ + ...addresses.map((tokenAddress) => { + return { + address: this.sharedBridge.address, + abi: sharedBridgeAbi, + functionName: "chainBalance", + args: [chainIdBn, tokenAddress], + } as const; + }), + { + address: this.sharedBridge.address, + abi: sharedBridgeAbi, + functionName: "chainBalance", + args: [chainIdBn, ETH_TOKEN_ADDRESS], + } as const, + ], + allowFailure: false, + }); + + return { ethBalance: balances[addresses.length]!, addressesBalance: balances.slice(0, -1) }; } + //TODO: Implement chainType. async chainType(_chainId: number): Promise<"validium" | "rollup"> { return "rollup"; diff --git a/libs/metrics/test/unit/l1/l1MetricsService.spec.ts b/libs/metrics/test/unit/l1/l1MetricsService.spec.ts index 93780b6..18def4b 100644 --- a/libs/metrics/test/unit/l1/l1MetricsService.spec.ts +++ b/libs/metrics/test/unit/l1/l1MetricsService.spec.ts @@ -11,7 +11,7 @@ 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, vitalikAddress } from "@zkchainhub/shared"; +import { ETH_TOKEN_ADDRESS, L1_CONTRACTS, vitalikAddress } from "@zkchainhub/shared"; import { nativeToken, WETH } from "@zkchainhub/shared/tokens/tokens"; // Mock implementations of the dependencies @@ -249,9 +249,100 @@ describe("L1MetricsService", () => { }); describe("tvl", () => { - it("return tvl", async () => { - const result = await l1MetricsService.tvl(1); - expect(result).toEqual({ ETH: { amount: 1000000, amountUsd: 1000000 } }); + it("return the TVL for chain id", 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 + const chainId = 324; // this is ZKsyncEra chain id + + jest.spyOn(mockEvmProviderService, "multicall").mockResolvedValue(mockBalances); + jest.spyOn(mockPricingService, "getTokenPrices").mockResolvedValue(mockPrices); + + const result = await l1MetricsService.tvl(chainId); + + 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.multicall).toHaveBeenCalledWith({ + contracts: [ + { + address: L1_CONTRACTS.SHARED_BRIDGE, + abi: sharedBridgeAbi, + functionName: "chainBalance", + args: [BigInt(chainId), "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"], + }, + { + address: L1_CONTRACTS.SHARED_BRIDGE, + abi: sharedBridgeAbi, + functionName: "chainBalance", + args: [BigInt(chainId), "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"], + }, + { + address: L1_CONTRACTS.SHARED_BRIDGE, + abi: sharedBridgeAbi, + functionName: "chainBalance", + args: [BigInt(chainId), ETH_TOKEN_ADDRESS], + }, + ], + allowFailure: false, + }); + expect(mockPricingService.getTokenPrices).toHaveBeenCalledWith([ + "ethereum", + "usd-coin", + "wrapped-bitcoin", + ]); + }); + + it("throws an error if the prices length is invalid", async () => { + const chainId = 324; + jest.spyOn(mockEvmProviderService, "multicall").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.tvl(chainId)).rejects.toThrowError( + "Invalid prices length", + ); }); }); diff --git a/libs/providers/src/exceptions/index.ts b/libs/providers/src/exceptions/index.ts index 4fb73e1..d5ef006 100644 --- a/libs/providers/src/exceptions/index.ts +++ b/libs/providers/src/exceptions/index.ts @@ -1,2 +1,3 @@ export * from "./invalidArgument.exception"; export * from "./dataDecode.exception"; +export * from "./multicallNotFound.exception"; diff --git a/libs/providers/src/exceptions/multicallNotFound.exception.ts b/libs/providers/src/exceptions/multicallNotFound.exception.ts new file mode 100644 index 0000000..45b3902 --- /dev/null +++ b/libs/providers/src/exceptions/multicallNotFound.exception.ts @@ -0,0 +1,5 @@ +export class MulticallNotFound extends Error { + constructor() { + super("Multicall contract address not found"); + } +} diff --git a/libs/providers/src/providers/evmProvider.service.ts b/libs/providers/src/providers/evmProvider.service.ts index 9cd018b..470b674 100644 --- a/libs/providers/src/providers/evmProvider.service.ts +++ b/libs/providers/src/providers/evmProvider.service.ts @@ -8,6 +8,7 @@ import { ContractConstructorArgs, ContractFunctionArgs, ContractFunctionName, + ContractFunctionParameters, ContractFunctionReturnType, createPublicClient, decodeAbiParameters, @@ -18,10 +19,16 @@ import { Hex, http, HttpTransport, + MulticallParameters, + MulticallReturnType, toHex, } from "viem"; -import { DataDecodeException, InvalidArgumentException } from "@zkchainhub/providers/exceptions"; +import { + DataDecodeException, + InvalidArgumentException, + MulticallNotFound, +} from "@zkchainhub/providers/exceptions"; import { AbiWithConstructor } from "@zkchainhub/providers/types"; /** @@ -164,4 +171,22 @@ export class EvmProviderService { throw new DataDecodeException("Error decoding return data with given AbiParameters"); } } + + /** + * Similar to readContract, but batches up multiple functions + * on a contract in a single RPC call via the multicall3 contract. + * @param {MulticallParameters} args - The parameters for the multicall. + * @returns — An array of results. If allowFailure is true, with accompanying status + * @throws {MulticallNotFound} if the Multicall contract is not found. + */ + async multicall< + contracts extends readonly unknown[] = readonly ContractFunctionParameters[], + allowFailure extends boolean = true, + >( + args: MulticallParameters, + ): Promise> { + if (!this.chain.contracts?.multicall3?.address) throw new MulticallNotFound(); + + return this.client.multicall(args); + } } diff --git a/libs/providers/test/unit/providers/evmProvider.service.spec.ts b/libs/providers/test/unit/providers/evmProvider.service.spec.ts index 73cc90f..72a6122 100644 --- a/libs/providers/test/unit/providers/evmProvider.service.spec.ts +++ b/libs/providers/test/unit/providers/evmProvider.service.spec.ts @@ -7,7 +7,7 @@ import { localhost } from "viem/chains"; import { Logger } from "winston"; import { EvmProviderService } from "@zkchainhub/providers"; -import { DataDecodeException } from "@zkchainhub/providers/exceptions"; +import { DataDecodeException, MulticallNotFound } from "@zkchainhub/providers/exceptions"; import { arrayAbiFixture, structAbiFixture, @@ -27,16 +27,18 @@ export const mockLogger: Partial = { error: jest.fn(), debug: jest.fn(), }; +const testAbi = parseAbi([ + "constructor(uint256 totalSupply)", + "function balanceOf(address owner) view returns (uint256)", + "function tokenURI(uint256 tokenId) pure returns (string)", +]); describe("EvmProviderService", () => { let viemProvider: EvmProviderService; - const testAbi = parseAbi([ - "constructor(uint256 totalSupply)", - "function balanceOf(address owner) view returns (uint256)", - "function tokenURI(uint256 tokenId) pure returns (string)", - ]); + let mockChain: viem.Chain; beforeEach(async () => { + mockChain = jest.mocked({ ...localhost, contracts: { multicall3: undefined } }); const app: TestingModule = await Test.createTestingModule({ providers: [ { @@ -47,8 +49,7 @@ describe("EvmProviderService", () => { provide: EvmProviderService, useFactory: () => { const rpcUrl = "http://localhost:8545"; - const chain = localhost; - return new EvmProviderService(rpcUrl, chain, mockLogger as Logger); + return new EvmProviderService(rpcUrl, mockChain, mockLogger as Logger); }, }, ], @@ -287,4 +288,56 @@ describe("EvmProviderService", () => { expect(returnValue).toEqual(arrayAbiFixture.args[0]); }); }); + describe("multicall", () => { + it("calls the multicall method of the Viem client with the correct arguments", async () => { + const contracts = [ + { + address: "0x123456789", + abi: testAbi, + functionName: "balanceOf", + args: ["0x987654321"], + } as const, + { + address: "0x123456789", + abi: testAbi, + functionName: "tokenURI", + args: [1n], + } as const, + { + address: "0x987654321", + abi: testAbi, + functionName: "totalSupply", + args: [], + } as const, + ]; + + const expectedReturnValue = [ + { result: 100n, status: true }, + { result: "tokenUri", status: true }, + { result: 1000n, status: true }, + ]; + mockChain.contracts = { multicall3: { address: "0x123456789" } }; + jest.spyOn(mockClient, "multicall").mockResolvedValue(expectedReturnValue); + + const returnValue = await viemProvider.multicall({ contracts }); + + expect(returnValue).toEqual(expectedReturnValue); + expect(mockClient.multicall).toHaveBeenCalledWith({ contracts }); + }); + + it("throws a MulticallNotFound error if the Multicall contract is not found for the chain", async () => { + const contracts = [ + { + address: "0x123456789", + abi: testAbi, + functionName: "balanceOf", + args: ["0x987654321"], + } as const, + ]; + + await expect(viemProvider.multicall({ contracts })).rejects.toThrowError( + MulticallNotFound, + ); + }); + }); }); diff --git a/libs/shared/src/constants/addresses.ts b/libs/shared/src/constants/addresses.ts index c8c6fd0..f8f3817 100644 --- a/libs/shared/src/constants/addresses.ts +++ b/libs/shared/src/constants/addresses.ts @@ -1,3 +1,6 @@ import { Address } from "abitype"; +// This is the address given to native ETH on L2 chains. +// See: https://github.com/matter-labs/era-contracts/blob/8a70bbbc48125f5bde6189b4e3c6a3ee79631678/l1-contracts/contracts/common/Config.sol#L105 +export const ETH_TOKEN_ADDRESS: Address = "0x0000000000000000000000000000000000000001"; export const vitalikAddress: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";