diff --git a/README.md b/README.md index d1f4032..371bd42 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # beefy-balances-api - https://balances-api.beefy.finance/api/v1/status http://localhost:4000/api/v1/status - https://balances-api.beefy.finance/api/v1/holders/counts/all http://localhost:4000/api/v1/holders/counts/all + +https://balances-api.beefy.finance/api/v1/vault/base/baseswap-cow-weth-cbbtc/20449610/share-tokens-balances +http://localhost:4000/api/v1/vault/base/baseswap-cow-weth-cbbtc/20449610/share-tokens-balances + diff --git a/package-lock.json b/package-lock.json index 54a3426..80834bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "beefy-clm-api", + "name": "beefy-balances-api", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "beefy-clm-api", + "name": "beefy-balances-api", "version": "1.0.0", "hasInstallScript": true, "license": "gpl-3.0", @@ -21,6 +21,7 @@ "@sinclair/typebox": "^0.32.33", "@types/lodash-es": "^4.17.12", "async-lock": "^1.4.1", + "blockchain-addressbook": "^0.47.18", "decimal.js": "^10.4.3", "dotenv": "^16.4.5", "fastify": "^4.26.2", @@ -29,7 +30,8 @@ "graphql-tag": "^2.12.6", "lodash": "^4.17.21", "node-cache": "^5.1.2", - "pino": "^8.19.0" + "pino": "^8.19.0", + "viem": "^2.21.16" }, "devDependencies": { "@biomejs/biome": "1.8.2", @@ -55,6 +57,11 @@ "node": "^20.10.0" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz", + "integrity": "sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q==" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -3991,6 +3998,28 @@ "node": ">=8" } }, + "node_modules/@noble/curves": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz", + "integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4408,6 +4437,50 @@ "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", "dev": true }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", + "dependencies": { + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.4.0.tgz", + "integrity": "sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw==", + "dependencies": { + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.8" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sigstore/bundle": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz", @@ -4806,6 +4879,26 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abitype": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.5.tgz", + "integrity": "sha512-YzDhti7cjlfaBhHutMaboYB21Ha3rXR9QTkNJFzYC4kC8YclaiwPBBBJY8ejFdu2wnJeZCVZSMlQJ7fi8S6hsw==", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5394,6 +5487,11 @@ "readable-stream": "^3.4.0" } }, + "node_modules/blockchain-addressbook": { + "version": "0.47.18", + "resolved": "https://registry.npmjs.org/blockchain-addressbook/-/blockchain-addressbook-0.47.18.tgz", + "integrity": "sha512-o9kOhQgTnXpfCdyFrQ+o3DxKfihdXBla0HhiE+x90hMNrwJkKAtUA/5UNaFjeqGpiRbZm73WYBFYogMh3QLAiw==" + }, "node_modules/boxen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", @@ -8641,6 +8739,20 @@ "ws": "*" } }, + "node_modules/isows": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.4.tgz", + "integrity": "sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wagmi-dev" + } + ], + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -15287,7 +15399,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15617,6 +15729,36 @@ "node": ">=12" } }, + "node_modules/viem": { + "version": "2.21.16", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.21.16.tgz", + "integrity": "sha512-SvhaPzTj3a+zR/5OmtJ0acjA6oGDrgPg4vtO8KboXtvbjksXEkz+oFaNjZDgxpkqbps2SLi8oPCjdpRm6WgDmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.10.0", + "@noble/curves": "1.4.0", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.4.0", + "abitype": "1.0.5", + "isows": "1.0.4", + "webauthn-p256": "0.0.5", + "ws": "8.17.1" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -15644,6 +15786,21 @@ "node": ">= 8" } }, + "node_modules/webauthn-p256": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/webauthn-p256/-/webauthn-p256-0.0.5.tgz", + "integrity": "sha512-drMGNWKdaixZNobeORVIqq7k5DsRC9FnG201K2QjeOoQLmtSDaSsVZdkg6n5jUALJKcAG++zBPJXmv6hy0nWFg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "dependencies": { + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.4.0" + } + }, "node_modules/webcrypto-core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.0.tgz", @@ -15874,10 +16031,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 3d1c17b..a71dc33 100644 --- a/package.json +++ b/package.json @@ -32,21 +32,23 @@ "@fastify/etag": "^5.1.0", "@fastify/helmet": "^11.1.1", "@fastify/rate-limit": "^9.1.0", - "@fastify/swagger-ui": "^3.0.0", "@fastify/swagger": "^8.14.0", + "@fastify/swagger-ui": "^3.0.0", "@fastify/under-pressure": "^8.3.0", "@sinclair/typebox": "^0.32.33", "@types/lodash-es": "^4.17.12", "async-lock": "^1.4.1", + "blockchain-addressbook": "^0.47.18", "decimal.js": "^10.4.3", "dotenv": "^16.4.5", "fastify": "^4.26.2", + "graphql": "^16.6.0", "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", - "graphql": "^16.6.0", "lodash": "^4.17.21", "node-cache": "^5.1.2", - "pino": "^8.19.0" + "pino": "^8.19.0", + "viem": "^2.21.16" }, "lint-staged": { "src/**/*.{ts,graphql,json}": "biome check --write" @@ -54,9 +56,9 @@ "devDependencies": { "@biomejs/biome": "1.8.2", "@graphql-codegen/cli": "^5.0.2", + "@graphql-codegen/typescript": "^4.0.7", "@graphql-codegen/typescript-graphql-request": "^6.2.0", "@graphql-codegen/typescript-operations": "^4.2.1", - "@graphql-codegen/typescript": "^4.0.7", "@jest/globals": "^29.7.0", "@types/async-lock": "^1.4.2", "@types/jest": "^29.5.12", @@ -67,8 +69,8 @@ "npm-check-updates": "^16.14.18", "pino-pretty": "^11.0.0", "ts-jest": "^29.1.2", - "ts-node-dev": "^2.0.0", "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", "typescript": "^5.4.5" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/src/config/chains.ts b/src/config/chains.ts index 688fba7..8cfb241 100644 --- a/src/config/chains.ts +++ b/src/config/chains.ts @@ -3,7 +3,7 @@ import { StringEnum } from '../utils/typebox'; export enum ChainId { arbitrum = 'arbitrum', - avalanche = 'avalanche', + avax = 'avax', base = 'base', bsc = 'bsc', ethereum = 'ethereum', @@ -16,6 +16,7 @@ export enum ChainId { moonbeam = 'moonbeam', optimism = 'optimism', polygon = 'polygon', + rootstock = 'rootstock', sei = 'sei', zksync = 'zksync', } diff --git a/src/queries/VaultSharesBalances.graphql b/src/queries/VaultSharesBalances.graphql new file mode 100644 index 0000000..1327e36 --- /dev/null +++ b/src/queries/VaultSharesBalances.graphql @@ -0,0 +1,35 @@ +query VaultSharesBalances( + $token_in_1: [Bytes!]! + $token_in_2: [String!]! + $account_not_in: [String!]! + $block: Int + $first: Int = 1000 + $skip: Int = 0 +) { + tokens(first: $first, skip: $skip, where: { id_in: $token_in_1 }) { + id + name + decimals + symbol + } + tokenBalances( + block: { number: $block } + orderBy: id + orderDirection: desc + first: $first + skip: $skip + where: { + amount_gt: 0 + token_in: $token_in_2 + account_not_in: $account_not_in + } + ) { + token { + id + } + account { + id + } + amount + } +} diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index d7df790..dcf43eb 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import holders from './holders'; import status from './status'; +import vault from './vault'; export default async function ( instance: FastifyInstance, @@ -9,5 +10,6 @@ export default async function ( ) { instance.register(status, { prefix: '/status' }); instance.register(holders, { prefix: '/holders' }); + instance.register(vault, { prefix: '/vault' }); done(); } diff --git a/src/routes/v1/vault.ts b/src/routes/v1/vault.ts new file mode 100644 index 0000000..4f58ecf --- /dev/null +++ b/src/routes/v1/vault.ts @@ -0,0 +1,163 @@ +import { type Static, Type } from '@sinclair/typebox'; +import type { FastifyInstance, FastifyPluginOptions, FastifySchema } from 'fastify'; +import { uniq } from 'lodash'; +import type { Hex } from 'viem'; +import { type ChainId, chainIdSchema } from '../../config/chains'; +import { addressSchema } from '../../schema/address'; +import { bigintSchema } from '../../schema/bigint'; +import { getAsyncCache } from '../../utils/async-lock'; +import { getSdksForChain, paginate } from '../../utils/sdk'; +//import { getAllSdks, paginate } from '../../utils/sdk'; +import { getBeefyVaultConfig } from '../../vault-breakdown/vault/getBeefyVaultConfig'; + +export default async function ( + instance: FastifyInstance, + _opts: FastifyPluginOptions, + done: (err?: Error) => void +) { + const asyncCache = getAsyncCache(); + + // all holder count list for all chains + { + const urlParamsSchema = Type.Object({ + chain: chainIdSchema, + vault_id: Type.String({ description: 'The vault id or clm manager id or reward pool id' }), + block_number: bigintSchema, + }); + type UrlParams = Static; + + const schema: FastifySchema = { + tags: ['vault'], + params: urlParamsSchema, + response: { + 200: vaultHoldersSchema, + }, + }; + + instance.get<{ Params: UrlParams }>( + '/:chain/:vault_id/:block_number/share-tokens-balances', + { schema }, + async (request, reply) => { + const { chain, vault_id: input_vault_id, block_number } = request.params; + const vault_id = input_vault_id.replace(/-(rp|vault)$/, ''); + + if (vault_id !== input_vault_id) { + reply.code(301); + reply.redirect( + `/api/v1/vault/${chain}/${vault_id}/${block_number}/share-tokens-balances` + ); + return; + } + + const result = await asyncCache.wrap( + `vault:${chain}:${vault_id}:holders`, + 5 * 60 * 1000, + async () => await getVaultHolders(chain, vault_id, BigInt(block_number)) + ); + reply.send(result); + } + ); + } + + done(); +} + +const tokenBalancesSchema = Type.Object({ + id: addressSchema, + name: Type.String(), + symbol: Type.String(), + decimals: Type.Number(), + balances: Type.Array(Type.Object({ balance: Type.String(), holder: addressSchema })), +}); + +const vaultHoldersSchema = Type.Array(tokenBalancesSchema); +type VaultHolders = Static; + +const getVaultHolders = async ( + chainId: ChainId, + vault_id: string, + block: bigint +): Promise => { + // first get the addresses linked to that vault id + const configs = await getBeefyVaultConfig(chainId, vault => vault.id.startsWith(vault_id)); + + const tokens = uniq( + configs + .flatMap(config => + config.protocol_type === 'beefy_clm_vault' + ? [ + config.vault_address, + config.beefy_clm_manager.vault_address, + ...config.beefy_clm_manager.reward_pools.map(pool => pool.reward_pool_address), + ...config.beefy_clm_manager.boosts.map(boost => boost.boost_address), + ...config.reward_pools.map(pool => pool.reward_pool_address), + ...config.boosts.map(boost => boost.boost_address), + ] + : [ + config.vault_address, + ...config.reward_pools.map(pool => pool.reward_pool_address), + ...config.boosts.map(boost => boost.boost_address), + ] + ) + .map(address => address.toLowerCase() as Hex) + ); + + const strategies = uniq( + configs + .flatMap(config => + config.protocol_type === 'beefy_clm_vault' + ? [config.strategy_address, config.beefy_clm_manager.strategy_address] + : [config.strategy_address] + ) + .map(address => address.toLowerCase()) + ); + + const res = ( + await Promise.all( + getSdksForChain(chainId).map(sdk => + paginate({ + fetchPage: ({ skip, first }) => + sdk.VaultSharesBalances({ + skip, + first, + block: Number(block), + account_not_in: strategies, + token_in_1: tokens, + token_in_2: tokens, + }), + count: res => res.data.tokenBalances.length, + }) + ) + ) + ).flat(); + + return res.flatMap(chainRes => { + const tokens = chainRes.data.tokens; + const balances = chainRes.data.tokenBalances; + + return tokens.map(token => { + const tokenBalances = balances.filter(balance => balance.token.id === token.id); + + if (!token.symbol) { + throw new Error(`Token ${token.id} has no symbol`); + } + if (!token.decimals) { + throw new Error(`Token ${token.id} has no decimals`); + } + if (!token.name) { + throw new Error(`Token ${token.id} has no name`); + } + + return { + id: token.id, + name: token.name, + symbol: token.symbol, + decimals: Number.parseInt(token.decimals, 10), + balances: tokenBalances.map(balance => ({ + balance: balance.amount, + holder: balance.account.id, + })), + }; + }); + }); +}; diff --git a/src/utils/addressbook.ts b/src/utils/addressbook.ts new file mode 100644 index 0000000..abc1896 --- /dev/null +++ b/src/utils/addressbook.ts @@ -0,0 +1,24 @@ +import { type Token, addressBook } from 'blockchain-addressbook'; +import type { Hex } from 'viem'; +import type { ChainId } from '../config/chains'; + +export const getTokenAddressBySymbol = (chainId: ChainId, symbol: string): Hex | null => { + return (addressBook[chainId]?.tokens[symbol]?.address as Hex) || null; +}; + +export const getTokenConfigBySymbol = (chainId: ChainId, symbol: string): Token | null => { + return addressBook[chainId]?.tokens[symbol] ?? null; +}; + +export const getWNativeToken = (chainId: ChainId): Token => { + const token = addressBook[chainId]?.tokens.WNATIVE; + if (!token) { + throw new Error(`WNATIVE token is not available on chain ${chainId}`); + } + return token; +}; + +export const isNativeToken = (chainId: ChainId, symbol: string) => { + const wnative = getWNativeToken(chainId); + return `W${symbol}` === wnative.symbol || symbol === 'ETH'; +}; diff --git a/src/utils/viemClient.ts b/src/utils/viemClient.ts new file mode 100644 index 0000000..960939b --- /dev/null +++ b/src/utils/viemClient.ts @@ -0,0 +1,55 @@ +import { http, type Chain as ViemChain, createPublicClient } from 'viem'; +import { + arbitrum, + avalanche, + base, + bsc, + fantom, + fraxtal, + //kava, + linea, + mainnet, + manta, + mantle, + mode, + moonbeam, + optimism, + polygon, + rootstock, + sei, + zksync, +} from 'viem/chains'; +import type { ChainId } from '../config/chains'; +import { createCachedFactoryByChainId } from './factory'; + +const mapping: Record = { + arbitrum: arbitrum, + avax: avalanche, + base: base, + bsc: bsc, + ethereum: mainnet, + fantom: fantom, + fraxtal: fraxtal, + //kava: kava, + linea: linea, + manta: manta, + mantle: mantle, + mode: mode, + moonbeam: moonbeam, + optimism: optimism, + polygon: polygon, + rootstock: rootstock, + sei: sei, + zksync: zksync, +}; + +export const getViemClient = createCachedFactoryByChainId(chainId => { + return createPublicClient({ + chain: mapping[chainId], + transport: http(), + batch: { + multicall: true, + }, + }); +}); +export type BeefyViemClient = ReturnType; diff --git a/src/vault-breakdown/abi/BalancerPool.ts b/src/vault-breakdown/abi/BalancerPool.ts new file mode 100644 index 0000000..b8ee889 --- /dev/null +++ b/src/vault-breakdown/abi/BalancerPool.ts @@ -0,0 +1,41 @@ +export const BalancerPoolAbi = [ + { + inputs: [], + name: 'getVault', + outputs: [ + { + internalType: 'contract IVault', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getPoolId', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getActualSupply', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/BalancerVault.ts b/src/vault-breakdown/abi/BalancerVault.ts new file mode 100644 index 0000000..077e130 --- /dev/null +++ b/src/vault-breakdown/abi/BalancerVault.ts @@ -0,0 +1,31 @@ +export const BalancerVaultAbi = [ + { + inputs: [ + { + internalType: 'bytes32', + name: 'poolId', + type: 'bytes32', + }, + ], + name: 'getPoolTokens', + outputs: [ + { + internalType: 'contract IERC20[]', + name: 'tokens', + type: 'address[]', + }, + { + internalType: 'uint256[]', + name: 'balances', + type: 'uint256[]', + }, + { + internalType: 'uint256', + name: 'lastChangeBlock', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/BeefyClmStrategy.ts b/src/vault-breakdown/abi/BeefyClmStrategy.ts new file mode 100644 index 0000000..75abbad --- /dev/null +++ b/src/vault-breakdown/abi/BeefyClmStrategy.ts @@ -0,0 +1,19 @@ +export const BeefyClmStrategyAbi = [ + { + type: 'function', + name: 'price', + inputs: [], + outputs: [{ name: '_price', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'range', + inputs: [], + outputs: [ + { name: 'lowerPrice', type: 'uint256', internalType: 'uint256' }, + { name: 'upperPrice', type: 'uint256', internalType: 'uint256' }, + ], + stateMutability: 'view', + }, +] as const; diff --git a/src/vault-breakdown/abi/BeefyVaultConcLiq.ts b/src/vault-breakdown/abi/BeefyVaultConcLiq.ts new file mode 100644 index 0000000..0dccca7 --- /dev/null +++ b/src/vault-breakdown/abi/BeefyVaultConcLiq.ts @@ -0,0 +1,29 @@ +export const BeefyVaultConcLiqAbi = [ + { + inputs: [], + name: 'balances', + outputs: [ + { internalType: 'uint256', name: 'amount0', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'wants', + outputs: [ + { internalType: 'address', name: 'token0', type: 'address' }, + { internalType: 'address', name: 'token1', type: 'address' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/BeefyVaultV7Abi.ts b/src/vault-breakdown/abi/BeefyVaultV7Abi.ts new file mode 100644 index 0000000..e86673b --- /dev/null +++ b/src/vault-breakdown/abi/BeefyVaultV7Abi.ts @@ -0,0 +1,22 @@ +export const BeefyVaultV7Abi = [ + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'balance', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/CurvePool.ts b/src/vault-breakdown/abi/CurvePool.ts new file mode 100644 index 0000000..25585be --- /dev/null +++ b/src/vault-breakdown/abi/CurvePool.ts @@ -0,0 +1,36 @@ +export const CurvePoolAbi = [ + { + stateMutability: 'view', + type: 'function', + name: 'coins', + inputs: [ + { + name: 'arg0', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'address', + }, + ], + }, + { + stateMutability: 'view', + type: 'function', + name: 'balances', + inputs: [ + { + name: 'arg0', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + }, +] as const; diff --git a/src/vault-breakdown/abi/CurveToken.ts b/src/vault-breakdown/abi/CurveToken.ts new file mode 100644 index 0000000..32b02ed --- /dev/null +++ b/src/vault-breakdown/abi/CurveToken.ts @@ -0,0 +1,354 @@ +export const CurveTokenAbi = [ + { + name: 'Transfer', + inputs: [ + { + name: '_from', + type: 'address', + indexed: true, + }, + { + name: '_to', + type: 'address', + indexed: true, + }, + { + name: '_value', + type: 'uint256', + indexed: false, + }, + ], + anonymous: false, + type: 'event', + }, + { + name: 'Approval', + inputs: [ + { + name: '_owner', + type: 'address', + indexed: true, + }, + { + name: '_spender', + type: 'address', + indexed: true, + }, + { + name: '_value', + type: 'uint256', + indexed: false, + }, + ], + anonymous: false, + type: 'event', + }, + { + stateMutability: 'nonpayable', + type: 'constructor', + inputs: [ + { + name: '_name', + type: 'string', + }, + { + name: '_symbol', + type: 'string', + }, + ], + outputs: [], + }, + { + stateMutability: 'view', + type: 'function', + name: 'decimals', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + gas: 288, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'transfer', + inputs: [ + { + name: '_to', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 78640, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'transferFrom', + inputs: [ + { + name: '_from', + type: 'address', + }, + { + name: '_to', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 116582, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'approve', + inputs: [ + { + name: '_spender', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 39121, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'increaseAllowance', + inputs: [ + { + name: '_spender', + type: 'address', + }, + { + name: '_added_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 41665, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'decreaseAllowance', + inputs: [ + { + name: '_spender', + type: 'address', + }, + { + name: '_subtracted_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 41689, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'mint', + inputs: [ + { + name: '_to', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 80879, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'burnFrom', + inputs: [ + { + name: '_to', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + }, + ], + gas: 80897, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'set_minter', + inputs: [ + { + name: '_minter', + type: 'address', + }, + ], + outputs: [], + gas: 37785, + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'set_name', + inputs: [ + { + name: '_name', + type: 'string', + }, + { + name: '_symbol', + type: 'string', + }, + ], + outputs: [], + gas: 181462, + }, + { + stateMutability: 'view', + type: 'function', + name: 'name', + inputs: [], + outputs: [ + { + name: '', + type: 'string', + }, + ], + gas: 12918, + }, + { + stateMutability: 'view', + type: 'function', + name: 'symbol', + inputs: [], + outputs: [ + { + name: '', + type: 'string', + }, + ], + gas: 10671, + }, + { + stateMutability: 'view', + type: 'function', + name: 'balanceOf', + inputs: [ + { + name: 'arg0', + type: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + gas: 2963, + }, + { + stateMutability: 'view', + type: 'function', + name: 'allowance', + inputs: [ + { + name: 'arg0', + type: 'address', + }, + { + name: 'arg1', + type: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + gas: 3208, + }, + { + stateMutability: 'view', + type: 'function', + name: 'totalSupply', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + gas: 2808, + }, + { + stateMutability: 'view', + type: 'function', + name: 'minter', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + }, + ], + gas: 2838, + }, +] as const; diff --git a/src/vault-breakdown/abi/GammaHypervisorAbi.ts b/src/vault-breakdown/abi/GammaHypervisorAbi.ts new file mode 100644 index 0000000..cf6d5f8 --- /dev/null +++ b/src/vault-breakdown/abi/GammaHypervisorAbi.ts @@ -0,0 +1,34 @@ +export const GammaHypervisorAbi = [ + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + + { + inputs: [], + name: 'getTotalAmounts', + outputs: [ + { internalType: 'uint256', name: 'total0', type: 'uint256' }, + { internalType: 'uint256', name: 'total1', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token0', + outputs: [{ internalType: 'contract IERC20', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token1', + outputs: [{ internalType: 'contract IERC20', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/IchiAlmAbi.ts b/src/vault-breakdown/abi/IchiAlmAbi.ts new file mode 100644 index 0000000..e83fd46 --- /dev/null +++ b/src/vault-breakdown/abi/IchiAlmAbi.ts @@ -0,0 +1,45 @@ +export const IchiAlmAbi = [ + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getBasePosition', + outputs: [ + { internalType: 'uint128', name: 'liquidity', type: 'uint128' }, + { internalType: 'uint256', name: 'amount0', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getLimitPosition', + outputs: [ + { internalType: 'uint128', name: 'liquidity', type: 'uint128' }, + { internalType: 'uint256', name: 'amount0', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token0', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'token1', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/PendleMarket.ts b/src/vault-breakdown/abi/PendleMarket.ts new file mode 100644 index 0000000..69c2a83 --- /dev/null +++ b/src/vault-breakdown/abi/PendleMarket.ts @@ -0,0 +1,91 @@ +export const PendleMarketAbi = [ + { + inputs: [ + { + internalType: 'address', + name: 'router', + type: 'address', + }, + ], + name: 'readState', + outputs: [ + { + components: [ + { + internalType: 'int256', + name: 'totalPt', + type: 'int256', + }, + { + internalType: 'int256', + name: 'totalSy', + type: 'int256', + }, + { + internalType: 'int256', + name: 'totalLp', + type: 'int256', + }, + { + internalType: 'address', + name: 'treasury', + type: 'address', + }, + { + internalType: 'int256', + name: 'scalarRoot', + type: 'int256', + }, + { + internalType: 'uint256', + name: 'expiry', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'lnFeeRateRoot', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'reserveFeePercent', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'lastLnImpliedRate', + type: 'uint256', + }, + ], + internalType: 'struct MarketState', + name: 'market', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'readTokens', + outputs: [ + { + internalType: 'contract IStandardizedYield', + name: '_SY', + type: 'address', + }, + { + internalType: 'contract IPPrincipalToken', + name: '_PT', + type: 'address', + }, + { + internalType: 'contract IPYieldToken', + name: '_YT', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/PendleSyToken.ts b/src/vault-breakdown/abi/PendleSyToken.ts new file mode 100644 index 0000000..0f3a48c --- /dev/null +++ b/src/vault-breakdown/abi/PendleSyToken.ts @@ -0,0 +1,15 @@ +export const PendleSyTokenAbi = [ + { + inputs: [], + name: 'yieldToken', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/abi/SolidlyPoolAbi.ts b/src/vault-breakdown/abi/SolidlyPoolAbi.ts new file mode 100644 index 0000000..b143d96 --- /dev/null +++ b/src/vault-breakdown/abi/SolidlyPoolAbi.ts @@ -0,0 +1,24 @@ +export const SolidlyPoolAbi = [ + { + inputs: [], + name: 'metadata', + outputs: [ + { internalType: 'uint256', name: 'dec0', type: 'uint256' }, + { internalType: 'uint256', name: 'dec1', type: 'uint256' }, + { internalType: 'uint256', name: 'r0', type: 'uint256' }, + { internalType: 'uint256', name: 'r1', type: 'uint256' }, + { internalType: 'bool', name: 'st', type: 'bool' }, + { internalType: 'address', name: 't0', type: 'address' }, + { internalType: 'address', name: 't1', type: 'address' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/vault-breakdown/breakdown/getVaultBreakdown.ts b/src/vault-breakdown/breakdown/getVaultBreakdown.ts new file mode 100644 index 0000000..a116dbc --- /dev/null +++ b/src/vault-breakdown/breakdown/getVaultBreakdown.ts @@ -0,0 +1,58 @@ +import type { ChainId } from '../../config/chains'; +import { type BeefyViemClient, getViemClient } from '../../utils/viemClient'; +import type { BeefyProtocolType, BeefyVault } from '../vault/getBeefyVaultConfig'; +import { getAaveVaultBreakdown } from './protocol_type/aave'; +import { getBalancerAuraVaultBreakdown } from './protocol_type/balancer'; +import { getBeefyClmManagerBreakdown, getBeefyClmVaultBreakdown } from './protocol_type/beefy_clm'; +import { getCurveVaultBreakdown } from './protocol_type/curve'; +import { getGammaVaultBreakdown } from './protocol_type/gamma'; +import { getPendleVaultBreakdown } from './protocol_type/pendle'; +import { getSolidlyVaultBreakdown } from './protocol_type/solidly'; +import type { BeefyVaultBreakdown } from './types'; + +type BreakdownMethod = ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +) => Promise; + +const breakdownMethods: Record = { + aave: getAaveVaultBreakdown, + balancer_aura: getBalancerAuraVaultBreakdown, + beefy_clm_vault: getBeefyClmVaultBreakdown, + beefy_clm: getBeefyClmManagerBreakdown, + curve: getCurveVaultBreakdown, + gamma: getGammaVaultBreakdown, + ichi: getGammaVaultBreakdown, + pendle_equilibria: getPendleVaultBreakdown, + solidly: getSolidlyVaultBreakdown, +}; + +export const getVaultBreakdowns = async ( + chainId: ChainId, + blockNumber: bigint, + vaults: BeefyVault[] +): Promise => { + // group by protocol type + const vaultsPerProtocol: Record = vaults.reduce( + (acc, vault) => { + if (!acc[vault.protocol_type]) { + acc[vault.protocol_type] = []; + } + acc[vault.protocol_type].push(vault); + return acc; + }, + {} as Record + ); + + return ( + await Promise.all( + (Object.keys(vaultsPerProtocol) as BeefyProtocolType[]).map(async protocolType => { + const client = getViemClient(chainId); + const vaults = vaultsPerProtocol[protocolType]; + const getBreakdown = breakdownMethods[protocolType]; + return await Promise.all(vaults.map(vault => getBreakdown(client, blockNumber, vault))); + }) + ) + ).flat(); +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/aave.ts b/src/vault-breakdown/breakdown/protocol_type/aave.ts new file mode 100644 index 0000000..421a565 --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/aave.ts @@ -0,0 +1,38 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +/** + * @dev assumes no lend/borrow looping + */ +export const getAaveVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + + const [balance, vaultTotalSupply] = await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + ]); + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: [ + { + tokenAddress: vault.undelying_lp_address.toLocaleLowerCase() as Hex, + vaultBalance: balance, + }, + ], + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/balancer.ts b/src/vault-breakdown/breakdown/protocol_type/balancer.ts new file mode 100644 index 0000000..cf39ec5 --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/balancer.ts @@ -0,0 +1,90 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BalancerPoolAbi } from '../../abi/BalancerPool'; +import { BalancerVaultAbi } from '../../abi/BalancerVault'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +/** + * +export function getVaultTokenBreakdownBalancer(vault: BeefyVault): Array { + let balances = new Array() + + const vaultContract = BeefyVaultV7Contract.bind(Address.fromBytes(vault.sharesToken)) + const wantTotalBalance = vaultContract.balance() + + // fetch on chain data + const balancerPoolContract = BalancerPoolContract.bind(Address.fromBytes(vault.underlyingToken)) + const balancerVaultAddress = balancerPoolContract.getVault() + const balancerPoolId = balancerPoolContract.getPoolId() + const balancerTotalSupply = balancerPoolContract.getActualSupply() + const balancerVaultContract = BalancerVaultContract.bind(balancerVaultAddress) + const poolTokensRes = balancerVaultContract.getPoolTokens(balancerPoolId) + const poolTokens = poolTokensRes.getTokens() + const poolBalances = poolTokensRes.getBalances() + + // compute breakdown + for (let i = 0; i < poolTokens.length; i++) { + const poolToken = poolTokens[i] + const poolBalance = poolBalances[i] + balances.push(new TokenBalance(poolToken, poolBalance.times(wantTotalBalance).div(balancerTotalSupply))) + } + + return balances +} + + */ +export const getBalancerAuraVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + + const balancerPoolContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: BalancerPoolAbi, + }); + + const [ + vaultWantBalance, + vaultTotalSupply, + balancerVaultAddress, + balancerPoolId, + balancerTotalSupply, + ] = await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + balancerPoolContract.read.getVault({ blockNumber }), + balancerPoolContract.read.getPoolId({ blockNumber }), + balancerPoolContract.read.getActualSupply({ blockNumber }), + ]); + + const balancerVaultContract = getContract({ + client, + address: balancerVaultAddress, + abi: BalancerVaultAbi, + }); + const poolTokenRes = await balancerVaultContract.read.getPoolTokens([balancerPoolId], { + blockNumber, + }); + const poolTokens = poolTokenRes[0]; + const poolBalances = poolTokenRes[1]; + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: poolTokens.map((poolToken, i) => ({ + tokenAddress: poolToken.toLocaleLowerCase() as Hex, + vaultBalance: (poolBalances[i] * vaultWantBalance) / balancerTotalSupply, + })), + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/beefy_clm.ts b/src/vault-breakdown/breakdown/protocol_type/beefy_clm.ts new file mode 100644 index 0000000..76394ad --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/beefy_clm.ts @@ -0,0 +1,98 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyClmStrategyAbi } from '../../abi/BeefyClmStrategy'; +import { BeefyVaultConcLiqAbi } from '../../abi/BeefyVaultConcLiq'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +export const getBeefyClmManagerBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const managerContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultConcLiqAbi, + }); + + const strategyContract = getContract({ + client, + address: vault.strategy_address, + abi: BeefyClmStrategyAbi, + }); + + const [balances, vaultTotalSupply, wants, range, price] = await Promise.all([ + managerContract.read.balances({ blockNumber }), + managerContract.read.totalSupply({ blockNumber }), + managerContract.read.wants({ blockNumber }), + strategyContract.read.range({ blockNumber }), + strategyContract.read.price({ blockNumber }), + ]); + + // special rule to exclude out of range liquidity for concentrated liquidity vaults + const isLiquidityEligible = price >= range[0] && price <= range[1]; + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible, + balances: [ + { + tokenAddress: wants[0].toLocaleLowerCase() as Hex, + vaultBalance: balances[0], + }, + { + tokenAddress: wants[1].toLocaleLowerCase() as Hex, + vaultBalance: balances[1], + }, + ], + }; +}; + +export const getBeefyClmVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + if (vault.protocol_type !== 'beefy_clm_vault') { + throw new Error(`Invalid protocol type ${vault.protocol_type}`); + } + + const underlyingClmBreakdown = await getBeefyClmManagerBreakdown( + client, + blockNumber, + vault.beefy_clm_manager + ); + + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + + const underlyingContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: BeefyVaultConcLiqAbi, + }); + + const [underlyingBalance, vaultTotalSupply, underlyingTotalSypply] = await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + underlyingContract.read.totalSupply({ blockNumber }), + ]); + + return { + vault, + blockNumber, + vaultTotalSupply: vaultTotalSupply, + isLiquidityEligible: underlyingClmBreakdown.isLiquidityEligible, + balances: underlyingClmBreakdown.balances.map(tokenBalance => ({ + tokenAddress: tokenBalance.tokenAddress, + vaultBalance: (underlyingBalance * tokenBalance.vaultBalance) / underlyingTotalSypply, + })), + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/curve.ts b/src/vault-breakdown/breakdown/protocol_type/curve.ts new file mode 100644 index 0000000..f806127 --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/curve.ts @@ -0,0 +1,85 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import { CurvePoolAbi } from '../../abi/CurvePool'; +import { CurveTokenAbi } from '../../abi/CurveToken'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +/** + * @dev This breaks when the lp token and lp pool are different + * @dev Does not break down meta pools + * TODO try to find an on-chain way to get the lp pool (as vault only provides the lp token) + * TODO try to break down meta pools (where one token of the pool is another pool) + */ +export const getCurveVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + const curveTokenContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: CurveTokenAbi, + }); + const curvePoolContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: CurvePoolAbi, + }); + + const [ + vaultWantBalance, + vaultTotalSupply, + curveTotalSupply, + coin0, + coin1, + coin2, + coin3, + coin4, + coin5, + balance0, + balance1, + balance2, + balance3, + balance4, + balance5, + ] = await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + curveTokenContract.read.totalSupply({ blockNumber }), + curvePoolContract.read.coins([0n], { blockNumber }), + curvePoolContract.read.coins([1n], { blockNumber }), + curvePoolContract.read.coins([2n], { blockNumber }).catch(() => null), + curvePoolContract.read.coins([3n], { blockNumber }).catch(() => null), + curvePoolContract.read.coins([4n], { blockNumber }).catch(() => null), + curvePoolContract.read.coins([5n], { blockNumber }).catch(() => null), + curvePoolContract.read.balances([0n], { blockNumber }), + curvePoolContract.read.balances([1n], { blockNumber }), + curvePoolContract.read.balances([2n], { blockNumber }).catch(() => null), + curvePoolContract.read.balances([3n], { blockNumber }).catch(() => null), + curvePoolContract.read.balances([4n], { blockNumber }).catch(() => null), + curvePoolContract.read.balances([5n], { blockNumber }).catch(() => null), + ]); + + const coins = [coin0, coin1, coin2, coin3, coin4, coin5].filter(coin => coin !== null) as Hex[]; + const balances = [balance0, balance1, balance2, balance3, balance4, balance5].filter( + balance => balance !== null + ) as bigint[]; + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: coins.map((coin, i) => ({ + tokenAddress: coin, + vaultBalance: (vaultWantBalance * balances[i]) / curveTotalSupply, + })), + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/gamma.ts b/src/vault-breakdown/breakdown/protocol_type/gamma.ts new file mode 100644 index 0000000..17f629f --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/gamma.ts @@ -0,0 +1,49 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import { GammaHypervisorAbi } from '../../abi/GammaHypervisorAbi'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +export const getGammaVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + const hypervisorContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: GammaHypervisorAbi, + }); + + const [balance, vaultTotalSupply, totalSupply, totalAmounts, token0, token1] = await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + hypervisorContract.read.totalSupply({ blockNumber }), + hypervisorContract.read.getTotalAmounts({ blockNumber }), + hypervisorContract.read.token0({ blockNumber }), + hypervisorContract.read.token1({ blockNumber }), + ]); + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: [ + { + tokenAddress: token0.toLocaleLowerCase() as Hex, + vaultBalance: (totalAmounts[0] * balance) / totalSupply, + }, + { + tokenAddress: token1.toLocaleLowerCase() as Hex, + vaultBalance: (totalAmounts[1] * balance) / totalSupply, + }, + ], + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/ichi.ts b/src/vault-breakdown/breakdown/protocol_type/ichi.ts new file mode 100644 index 0000000..8e3d8d9 --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/ichi.ts @@ -0,0 +1,54 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import { IchiAlmAbi } from '../../abi/IchiAlmAbi'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +export const getGammaVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + const almContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: IchiAlmAbi, + }); + + const [balance, vaultTotalSupply, totalSupply, basePosition, limitPosition, token0, token1] = + await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + almContract.read.totalSupply({ blockNumber }), + almContract.read.getBasePosition({ blockNumber }), + almContract.read.getLimitPosition({ blockNumber }), + almContract.read.token0({ blockNumber }), + almContract.read.token1({ blockNumber }), + ]); + + const position0 = basePosition[0] + limitPosition[0]; + const position1 = basePosition[1] + limitPosition[1]; + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: [ + { + tokenAddress: token0.toLocaleLowerCase() as Hex, + vaultBalance: (position0 * balance) / totalSupply, + }, + { + tokenAddress: token1.toLocaleLowerCase() as Hex, + vaultBalance: (position1 * balance) / totalSupply, + }, + ], + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/pendle.ts b/src/vault-breakdown/breakdown/protocol_type/pendle.ts new file mode 100644 index 0000000..6a93d43 --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/pendle.ts @@ -0,0 +1,57 @@ +import { getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import { PendleMarketAbi } from '../../abi/PendleMarket'; +import { PendleSyTokenAbi } from '../../abi/PendleSyToken'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +// https://etherscan.io/address/0x00000000005BBB0EF59571E58418F9a4357b68A0 +// https://arbiscan.io/address/0x00000000005BBB0EF59571E58418F9a4357b68A0 +const PENDLE_ROUTER_ADDRESS = '0x00000000005BBB0EF59571E58418F9a4357b68A0'; + +export const getPendleVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + + const pendleMarketContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: PendleMarketAbi, + }); + + const vaultWantBalance = await vaultContract.read.balance({ blockNumber }); + const vaultTotalSupply = await vaultContract.read.totalSupply({ blockNumber }); + const tokenAddresses = await pendleMarketContract.read.readTokens({ blockNumber }); + const pendleState = await pendleMarketContract.read.readState([PENDLE_ROUTER_ADDRESS], { + blockNumber, + }); + + const syTokenContract = getContract({ + client, + address: tokenAddresses[0], + abi: PendleSyTokenAbi, + }); + + const syUnderlyingAddress = await syTokenContract.read.yieldToken({ blockNumber }); + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: [ + { + tokenAddress: syUnderlyingAddress, + vaultBalance: (pendleState.totalSy * vaultWantBalance) / pendleState.totalLp, + }, + ], + }; +}; diff --git a/src/vault-breakdown/breakdown/protocol_type/solidly.ts b/src/vault-breakdown/breakdown/protocol_type/solidly.ts new file mode 100644 index 0000000..17afac3 --- /dev/null +++ b/src/vault-breakdown/breakdown/protocol_type/solidly.ts @@ -0,0 +1,52 @@ +import { type Hex, getContract } from 'viem'; +import type { BeefyViemClient } from '../../../utils/viemClient'; +import { BeefyVaultV7Abi } from '../../abi/BeefyVaultV7Abi'; +import { SolidlyPoolAbi } from '../../abi/SolidlyPoolAbi'; +import type { BeefyVault } from '../../vault/getBeefyVaultConfig'; +import type { BeefyVaultBreakdown } from '../types'; + +export const getSolidlyVaultBreakdown = async ( + client: BeefyViemClient, + blockNumber: bigint, + vault: BeefyVault +): Promise => { + const vaultContract = getContract({ + client, + address: vault.vault_address, + abi: BeefyVaultV7Abi, + }); + const poolContract = getContract({ + client, + address: vault.undelying_lp_address, + abi: SolidlyPoolAbi, + }); + + const [balance, vaultTotalSupply, totalSupply, poolMetadata] = await Promise.all([ + vaultContract.read.balance({ blockNumber }), + vaultContract.read.totalSupply({ blockNumber }), + poolContract.read.totalSupply({ blockNumber }), + poolContract.read.metadata({ blockNumber }), + ]); + + const t0 = poolMetadata[5]; + const t1 = poolMetadata[6]; + const r0 = poolMetadata[2]; + const r1 = poolMetadata[3]; + + return { + vault, + blockNumber, + vaultTotalSupply, + isLiquidityEligible: true, + balances: [ + { + tokenAddress: t0.toLocaleLowerCase() as Hex, + vaultBalance: (r0 * balance) / totalSupply, + }, + { + tokenAddress: t1.toLocaleLowerCase() as Hex, + vaultBalance: (r1 * balance) / totalSupply, + }, + ], + }; +}; diff --git a/src/vault-breakdown/breakdown/types.ts b/src/vault-breakdown/breakdown/types.ts new file mode 100644 index 0000000..3f7bdd1 --- /dev/null +++ b/src/vault-breakdown/breakdown/types.ts @@ -0,0 +1,13 @@ +import type { Hex } from 'viem'; +import type { BeefyVault } from '../vault/getBeefyVaultConfig'; + +export type BeefyVaultBreakdown = { + vault: BeefyVault; + blockNumber: bigint; + vaultTotalSupply: bigint; + isLiquidityEligible: boolean; + balances: { + tokenAddress: Hex; + vaultBalance: bigint; + }[]; +}; diff --git a/src/vault-breakdown/config.ts b/src/vault-breakdown/config.ts new file mode 100644 index 0000000..9330a96 --- /dev/null +++ b/src/vault-breakdown/config.ts @@ -0,0 +1,12 @@ +import type { ChainId } from '../config/chains'; + +export const BEEFY_MOO_VAULT_API = 'https://api.beefy.finance/vaults'; +export const BEEFY_COW_VAULT_API = 'https://api.beefy.finance/cow-vaults'; +export const BEEFY_GOV_API = 'https://api.beefy.finance/gov-vaults'; +export const BEEFY_BOOST_API = 'https://api.beefy.finance/boosts'; + +// subgraph source: https://github.com/beefyfinance/l2-lxp-liquidity-subgraph +export const getBalanceSubgraphUrl = (chainId: ChainId) => + `https://api.goldsky.com/api/public/project_clu2walwem1qm01w40v3yhw1f/subgraphs/beefy-balances-${chainId}/latest/gn`; + +export const SUBGRAPH_PAGE_SIZE = 1000; diff --git a/src/vault-breakdown/fetchAllUserBreakdown.ts b/src/vault-breakdown/fetchAllUserBreakdown.ts new file mode 100644 index 0000000..9804ae6 --- /dev/null +++ b/src/vault-breakdown/fetchAllUserBreakdown.ts @@ -0,0 +1,144 @@ +import { uniq } from 'lodash'; +import type { Hex } from 'viem'; +import type { ChainId } from '../config/chains'; +import { getLoggerFor } from '../utils/log'; +import { getVaultBreakdowns } from './breakdown/getVaultBreakdown'; +import type { BeefyVaultBreakdown } from './breakdown/types'; +import { type BeefyVault, getBeefyVaultConfig } from './vault/getBeefyVaultConfig'; +import { getTokenBalances } from './vault/getTokenBalances'; + +const logger = getLoggerFor('vault-breakdown/fetchAllUserBreakdown'); + +export const getUserTVLAtBlock = async ( + chainId: ChainId, + blockNumber: bigint, + vaultFilter: (vault: BeefyVault) => boolean +) => { + logger.debug({ msg: 'Fetching user TVL', blockNumber, chainId }); + + const [allVaultConfigs, investorPositions] = await Promise.all([ + getBeefyVaultConfig(chainId, vaultFilter), + getTokenBalances(chainId, { + blockNumber: BigInt(blockNumber), + minBalance: BigInt(1), + }), + ]); + logger.debug({ + msg: 'Fetched vaults and positions', + vaultConfigs: allVaultConfigs.length, + positions: investorPositions.length, + }); + + const vaultConfigs = allVaultConfigs.filter(vaultFilter); + + // merge investor positions for clm and reward pools + const vaultRewardPoolMap: Record = {}; + for (const vault of vaultConfigs) { + vaultRewardPoolMap[vault.vault_address] = vault.vault_address; + for (const pool of vault.reward_pools) { + vaultRewardPoolMap[pool.reward_pool_address] = vault.vault_address; + } + } + + const mergedInvestorPositionsByInvestorAndClmAddress: Record< + string, + (typeof investorPositions)[0] + > = {}; + for (const position of investorPositions) { + const vaultAddress = vaultRewardPoolMap[position.token_address]; + const key = `${position.user_address}_${vaultAddress}`; + if (!mergedInvestorPositionsByInvestorAndClmAddress[key]) { + mergedInvestorPositionsByInvestorAndClmAddress[key] = position; + } else { + mergedInvestorPositionsByInvestorAndClmAddress[key].balance += position.balance; + } + } + const mergedPositions = Object.values(mergedInvestorPositionsByInvestorAndClmAddress); + + const vaultAddressWithActivePosition = uniq(investorPositions.map(pos => pos.token_address)); + const vaults = vaultConfigs.filter( + vault => + vaultAddressWithActivePosition.includes(vault.vault_address) || + vault.reward_pools.some(pool => + vaultAddressWithActivePosition.includes(pool.reward_pool_address) + ) + ); + // get breakdowns for all vaults + logger.debug({ + msg: 'Fetching breakdowns', + vaults: vaults.length, + blockNumber, + chainId, + }); + const breakdowns = await getVaultBreakdowns(chainId, BigInt(blockNumber), vaults); + logger.debug({ msg: 'Fetched breakdowns', breakdowns: breakdowns.length }); + + const breakdownByVaultAddress = breakdowns.reduce( + (acc, breakdown) => { + acc[breakdown.vault.vault_address.toLowerCase() as Hex] = breakdown; + return acc; + }, + {} as Record + ); + + // merge by investor address and token address + const investorTokenBalances: Record< + Hex /* investor */, + Record< + Hex /* token */, + { + balance: bigint /* amount */; + details: { + vault_id: string; + vault_address: string; + contribution: bigint; + }[]; + } + > + > = {}; + for (const position of mergedPositions) { + const breakdown = breakdownByVaultAddress[position.token_address]; + if (!breakdown) { + // some test vaults were never available in the api + continue; + } + + if (breakdown.isLiquidityEligible === false) { + // skip non-eligible vaults + continue; + } + + if (!investorTokenBalances[position.user_address]) { + investorTokenBalances[position.user_address] = {}; + } + + for (const breakdownBalance of breakdown.balances) { + if (!investorTokenBalances[position.user_address][breakdownBalance.tokenAddress]) { + investorTokenBalances[position.user_address][breakdownBalance.tokenAddress] = { + balance: BigInt(0), + details: [], + }; + } + + const breakdownContribution = + (position.balance * breakdownBalance.vaultBalance) / breakdown.vaultTotalSupply; + investorTokenBalances[position.user_address][breakdownBalance.tokenAddress].balance += + breakdownContribution; + investorTokenBalances[position.user_address][breakdownBalance.tokenAddress].details.push({ + vault_id: breakdown.vault.id, + vault_address: breakdown.vault.vault_address, + contribution: breakdownContribution, + }); + } + } + + // format output + return Object.entries(investorTokenBalances).flatMap(([investor, balances]) => + Object.entries(balances).map(([token, balance]) => ({ + block_number: blockNumber, + user_address: investor as Hex, + token_address: token as Hex, + token_balance: balance, + })) + ); +}; diff --git a/src/vault-breakdown/vault/getBeefyVaultConfig.ts b/src/vault-breakdown/vault/getBeefyVaultConfig.ts new file mode 100644 index 0000000..55b5f8d --- /dev/null +++ b/src/vault-breakdown/vault/getBeefyVaultConfig.ts @@ -0,0 +1,318 @@ +import { groupBy } from 'lodash'; +import type { Hex } from 'viem'; +import type { ChainId } from '../../config/chains'; + +import { getWNativeToken, isNativeToken } from '../../utils/addressbook'; +import { getAsyncCache } from '../../utils/async-lock'; +import { FriendlyError } from '../../utils/error'; +import { + BEEFY_BOOST_API, + BEEFY_COW_VAULT_API, + BEEFY_GOV_API, + BEEFY_MOO_VAULT_API, +} from '../config'; + +export type BeefyVault = { + id: string; + vault_address: Hex; + undelying_lp_address: Hex; + strategy_address: Hex; + vault_token_symbol: string; + chain: string; + reward_pools: BeefyRewardPool[]; + boosts: BeefyBoost[]; + pointStructureIds: string[]; + platformId: ApiPlatformId; +} & ( + | { + protocol_type: 'beefy_clm_vault'; + beefy_clm_manager: BeefyVault; + } + | { + protocol_type: Exclude; + } +); + +export type BeefyRewardPool = { + id: string; + clm_address: Hex; + reward_pool_address: Hex; +}; + +export type BeefyBoost = { + id: string; + boost_address: Hex; + underlying_address: Hex; +}; + +export type BeefyProtocolType = + | 'aave' + | 'balancer_aura' + | 'beefy_clm_vault' + | 'beefy_clm' + | 'curve' + | 'gamma' + | 'ichi' + | 'pendle_equilibria' + | 'solidly'; + +type ApiPlatformId = + | 'aerodrome' + | 'aura' + | 'beefy' + | 'curve' + | 'equilibria' + | 'gamma' + | 'ichi' + | 'lendle' + | 'lynex' + | 'magpie' + | 'mendi' + | 'nile' + | 'velodrome'; + +export type ApiStrategyTypeId = 'lp' | 'multi-lp' | 'multi-lp-locked' | 'cowcentrated'; + +export type ApiVault = { + id: string; + name: string; + status: 'active' | 'eol'; + earnedTokenAddress: string; + depositTokenAddresses?: string[]; + chain: string; + platformId: ApiPlatformId; + token: string; + tokenAddress?: string; + earnedToken: string; + isGovVault?: boolean; + strategyTypeId?: ApiStrategyTypeId; + bridged?: object; + assets?: string[]; + strategy: Hex; + pointStructureIds?: string[]; +}; + +export type ApiClmManager = { + id: string; + name: string; + status: 'active' | 'eol'; + version: number; + platformId: ApiPlatformId; + strategyTypeId?: ApiStrategyTypeId; + earnedToken: string; + strategy: string; + chain: string; + type: 'cowcentrated' | 'others'; + tokenAddress: string; // underlying pool address + depositTokenAddresses: string[]; // token0 and token1 + earnContractAddress: string; // reward pool address + earnedTokenAddress: string; // clm manager address + pointStructureIds?: string[]; +}; + +export type ApiClmRewardPool = { + id: string; + status: 'active' | 'eol'; + version: number; + platformId: ApiPlatformId; + strategyTypeId?: ApiStrategyTypeId; + chain: string; + tokenAddress: string; // clm address (want) + earnContractAddress: string; // reward pool address + earnedTokenAddresses: string[]; // reward tokens +}; + +export type ApiGovVault = { + id: string; + status: 'active' | 'eol'; + version: number; + chain: string; + tokenAddress: string; // clm address + earnContractAddress: string; // reward pool address + earnedTokenAddresses: string[]; +}; + +export type ApiBoost = { + id: string; + poolId: string; + + version: number; + chain: string; + status: 'active' | 'eol'; + + tokenAddress: string; // underlying + earnedTokenAddress: string; // reward token address + earnContractAddress: string; // reward pool address +}; + +const protocol_map: Record = { + aerodrome: 'solidly', + aura: 'balancer_aura', + beefy: 'beefy_clm', + curve: 'curve', + equilibria: 'pendle_equilibria', + gamma: 'gamma', + ichi: 'ichi', + lendle: 'aave', + lynex: 'solidly', + magpie: 'pendle_equilibria', + mendi: 'aave', + nile: 'solidly', + velodrome: 'solidly', +}; + +export const getBeefyVaultConfig = async ( + chain: ChainId, + vaultFilter: (vault: BeefyVault) => boolean +): Promise => { + const asyncCache = getAsyncCache(); + const allConfigs = await asyncCache.wrap(`beefy-vault-config:${chain}`, 5 * 60 * 1000, () => + getAllConfigs(chain) + ); + const filteredConfigs = allConfigs.filter(vaultFilter); + + // check for undefined protocol types + const notFoundProtocols = filteredConfigs.filter(v => !v.protocol_type); + if (notFoundProtocols.length > 0) { + const messages = notFoundProtocols.map( + v => + `Unknown platformId ${v.platformId} for vault ${v.id}. Devs need to implement breakdown for this protocol` + ); + throw new FriendlyError(messages.join('\n')); + } + + return filteredConfigs; +}; + +const getAllConfigs = async (chain: ChainId): Promise => { + const [cowVaultsData, mooVaultsData, clmRewardPoolData, [boostData, vaultRewardPoolData]] = + await Promise.all([ + fetch(`${BEEFY_COW_VAULT_API}/${chain}`) + .then(res => res.json()) + .then(res => (res as ApiClmManager[]).filter(vault => vault.chain === chain)), + fetch(`${BEEFY_MOO_VAULT_API}/${chain}`) + .then(res => res.json()) + .then(res => + (res as ApiVault[]) + .filter(vault => vault.chain === chain) + .filter(vault => vault.isGovVault !== true) + ), + fetch(`${BEEFY_GOV_API}/${chain}`) + .then(res => res.json()) + .then(res => + (res as ApiClmRewardPool[]).filter(g => g.chain === chain).filter(g => g.version === 2) + ), + fetch(`${BEEFY_BOOST_API}/${chain}`) + .then(res => res.json()) + .then(res => [ + (res as ApiBoost[]).filter(g => g.chain === chain).filter(g => g.version !== 2), + (res as ApiBoost[]).filter(g => g.chain === chain).filter(g => g.version === 2), + ]), + ]); + + const clmManagerAddresses = new Set( + cowVaultsData.map(v => v.earnedTokenAddress.toLocaleLowerCase()) + ); + const boostPerUnderlyingAddress = groupBy(boostData, b => b.tokenAddress?.toLocaleLowerCase()); + const vaultRewardPoolDataPerVaultAddress = groupBy(vaultRewardPoolData, v => + v.tokenAddress.toLocaleLowerCase() + ); + const clmRewardPoolDataPerClmAddress = groupBy(clmRewardPoolData, c => + c.tokenAddress.toLocaleLowerCase() + ); + + const clmVaultConfigs = cowVaultsData.map((vault): BeefyVault => { + const undelying_lp_address = vault.tokenAddress.toLocaleLowerCase() as Hex; + const vault_address = vault.earnedTokenAddress.toLocaleLowerCase() as Hex; + + const protocol_type: BeefyProtocolType | undefined = + vault.type === 'cowcentrated' ? 'beefy_clm' : protocol_map[vault.platformId]; + if (protocol_type === 'beefy_clm_vault') { + throw new FriendlyError('Invalid protocol'); + } + const reward_pools = clmRewardPoolDataPerClmAddress[vault_address] ?? []; + + const boosts = boostPerUnderlyingAddress[vault_address] ?? []; + + return { + id: vault.id, + vault_address, + chain: vault.chain, + vault_token_symbol: vault.earnedToken, + protocol_type, + platformId: vault.platformId, + strategy_address: vault.strategy.toLocaleLowerCase() as Hex, + undelying_lp_address, + reward_pools: reward_pools.map(pool => ({ + id: pool.id, + clm_address: pool.tokenAddress.toLocaleLowerCase() as Hex, + reward_pool_address: pool.earnContractAddress.toLocaleLowerCase() as Hex, + })), + boosts: boosts.map(boost => ({ + id: boost.id, + boost_address: boost.earnedTokenAddress.toLocaleLowerCase() as Hex, + underlying_address: boost.tokenAddress.toLocaleLowerCase() as Hex, + })), + pointStructureIds: vault.pointStructureIds ?? [], + }; + }); + + const mooVaultCofigs = mooVaultsData.map((vault): BeefyVault => { + let underlying_lp_address = vault.tokenAddress?.toLocaleLowerCase() as Hex | undefined; + const vault_address = vault.earnedTokenAddress.toLocaleLowerCase() as Hex; + + if ( + !underlying_lp_address && + (isNativeToken(chain, vault.token) || isNativeToken(chain, vault.name)) + ) { + const wnative = getWNativeToken(chain); + underlying_lp_address = wnative.address as Hex; + } + + if (!underlying_lp_address) { + throw new FriendlyError(`Missing "tokenAddress" field for vault ${vault.id}.`); + } + + const protocol_type: BeefyProtocolType | undefined = clmManagerAddresses.has( + underlying_lp_address + ) + ? 'beefy_clm_vault' + : protocol_map[vault.platformId]; + + const additionalConfig = + protocol_type === 'beefy_clm_vault' + ? { + protocol_type, + platformId: vault.platformId, + beefy_clm_manager: clmVaultConfigs.find( + v => v.vault_address === underlying_lp_address + ) as BeefyVault, + } + : { protocol_type, platformId: vault.platformId }; + const reward_pools = vaultRewardPoolDataPerVaultAddress[vault_address] ?? []; + const boosts = boostPerUnderlyingAddress[vault_address] ?? []; + return { + id: vault.id, + vault_address, + chain: vault.chain, + vault_token_symbol: vault.earnedToken, + ...additionalConfig, + strategy_address: vault.strategy.toLocaleLowerCase() as Hex, + undelying_lp_address: underlying_lp_address, + reward_pools: reward_pools.map(pool => ({ + id: pool.id, + clm_address: pool.tokenAddress.toLocaleLowerCase() as Hex, + reward_pool_address: pool.earnContractAddress.toLocaleLowerCase() as Hex, + })), + boosts: boosts.map(boost => ({ + id: boost.id, + boost_address: boost.earnedTokenAddress.toLocaleLowerCase() as Hex, + underlying_address: boost.tokenAddress.toLocaleLowerCase() as Hex, + })), + pointStructureIds: vault.pointStructureIds ?? [], + }; + }); + + const allConfigs = clmVaultConfigs.concat(mooVaultCofigs); + return allConfigs; +}; diff --git a/src/vault-breakdown/vault/getTokenBalances.ts b/src/vault-breakdown/vault/getTokenBalances.ts new file mode 100644 index 0000000..e5fc03b --- /dev/null +++ b/src/vault-breakdown/vault/getTokenBalances.ts @@ -0,0 +1,170 @@ +import type { Hex } from 'viem'; +import type { ChainId } from '../../config/chains'; +import { FriendlyError } from '../../utils/error'; +import { getLoggerFor } from '../../utils/log'; +import { SUBGRAPH_PAGE_SIZE, getBalanceSubgraphUrl } from '../config'; + +type TokenBalance = { + user_address: Hex; + token_address: Hex; + balance: bigint; +}; + +type QueryResult = { + [key in `tokenBalances${number}`]: { + account: { + id: Hex; + }; + token: { + id: Hex; + }; + amount: string; + }[]; +}; + +const logger = getLoggerFor('vault-breakdown/vault/getTokenBalances'); + +const PARRALEL_REQUESTS = 10; +const PARRALEL_REQUESTS_ARRAY = Array.from({ length: PARRALEL_REQUESTS }, (_, i) => i); + +export const getTokenBalances = async ( + chainId: ChainId, + filters: { + blockNumber?: bigint; + tokenAddresses?: Hex[]; + minBalance?: bigint; + } +): Promise => { + let allPositions: TokenBalance[] = []; + let skip = 0; + const startAt = Date.now(); + logger.debug({ + msg: 'Fetching user balances', + chainId, + filters, + }); + while (true) { + logger.trace({ + msg: 'Fetching user balances', + chainId, + filters, + skip, + }); + + const USER_BALANCES_QUERY = ` + fragment Balance on TokenBalance { + account { + id + } + token { + id + } + amount + } + + query UserBalances( + $first: Int!, + ${PARRALEL_REQUESTS_ARRAY.map(i => `$skip${i}: Int!`).join(', ')} + ) { + ${PARRALEL_REQUESTS_ARRAY.map( + i => ` + tokenBalances${i}: tokenBalances( + ${filters.blockNumber ? `block: { number: ${filters.blockNumber} }` : ''} + first: $first + ${ + filters.minBalance || filters.tokenAddresses?.length + ? `where: { + ${filters.minBalance ? `amount_gt: "${filters.minBalance}"` : ''} + ${ + filters.tokenAddresses?.length + ? `token_in: [${filters.tokenAddresses.map(a => `"${a}"`).join(', ')}]` + : '' + } + }` + : '' + } + skip: $skip${i} + orderBy: id + orderDirection: asc + ) { + ...Balance + } + ` + )} + } + `; + + const variables = { + first: SUBGRAPH_PAGE_SIZE, + ...PARRALEL_REQUESTS_ARRAY.reduce( + (acc, i) => { + acc[`skip${i}`] = skip + i * SUBGRAPH_PAGE_SIZE; + return acc; + }, + {} as { [key: string]: number } + ), + }; + + logger.trace({ + msg: 'Querying subgraph', + query: USER_BALANCES_QUERY, + chainId, + filters, + skip, + variables, + }); + + const response = await fetch(getBalanceSubgraphUrl(chainId), { + method: 'POST', + body: JSON.stringify({ + query: USER_BALANCES_QUERY, + variables, + }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const text = await response.text(); + console.error(text); + throw new FriendlyError(`Subgraph query failed with status ${response.status}: ${text}`); + } + + const res = (await response.json()) as + | { data: QueryResult } + | { errors: { message: string }[] }; + if ('errors' in res) { + const errors = res.errors.map(e => e.message).join(', '); + throw new FriendlyError(`Subgraph query failed: ${errors}`); + } + + const foundPositions = PARRALEL_REQUESTS_ARRAY.flatMap( + i => res.data[`tokenBalances${i}`] || [] + ).map( + (position): TokenBalance => ({ + balance: BigInt(position.amount), + user_address: position.account.id.toLocaleLowerCase() as Hex, + token_address: position.token.id.toLocaleLowerCase() as Hex, + }) + ); + + allPositions = allPositions.concat(foundPositions); + + logger.debug({ msg: 'Found user balances', chainId, positions: foundPositions.length }); + + if (res.data.tokenBalances9.length < SUBGRAPH_PAGE_SIZE) { + break; + } + + skip += SUBGRAPH_PAGE_SIZE * PARRALEL_REQUESTS; + } + + logger.debug({ + msg: 'Fetched user balances', + positions: allPositions.length, + chainId, + filters, + duration: Date.now() - startAt, + }); + + return allPositions; +};