From 2e12b78959e0a32f6823e61bee8fed49b782b15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pr=C3=A9vost?= <998369+prevostc@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:05:13 +0100 Subject: [PATCH] Add top holder endpoint --- README.md | 3 + src/queries/LatestVaultSharesBalances.graphql | 32 +++ src/queries/VaultSharesBalances.graphql | 41 ++- src/routes/v1/contract.ts | 78 +++--- src/routes/v1/vault.ts | 248 ++++++++++++------ 5 files changed, 268 insertions(+), 134 deletions(-) create mode 100644 src/queries/LatestVaultSharesBalances.graphql diff --git a/README.md b/README.md index e9b5469..40e2f4d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ http://localhost:4000/api/v1/status https://balance-api.beefy.finance/api/v1/holders/counts/all http://localhost:4000/api/v1/holders/counts/all +https://balance-api.beefy.finance/api/v1/vault/arbitrum/top-holders?vault_addresses=0x0481ad5b536139472af5ce692330dbf00bbd8672&vault_addresses=0x0d1f71170d93121b48a9e8fc7400e8e6a6821500&limit=10 +http://localhost:4000/api/v1/vault/arbitrum/top-holders?vault_addresses=0x0481ad5b536139472af5ce692330dbf00bbd8672&vault_addresses=0x0d1f71170d93121b48a9e8fc7400e8e6a6821500&limit=10 + https://balance-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/src/queries/LatestVaultSharesBalances.graphql b/src/queries/LatestVaultSharesBalances.graphql new file mode 100644 index 0000000..5a98f34 --- /dev/null +++ b/src/queries/LatestVaultSharesBalances.graphql @@ -0,0 +1,32 @@ +query LatestVaultSharesBalances( + $token_in_1: [Bytes!]! + $token_in_2: [String!]! + $account_not_in: [String!]! + $tokenFirst: Int = 1000 + $tokenSkip: Int = 0 + $first: Int = 1000 + $skip: Int = 0 +) { + tokens(first: $tokenFirst, skip: $tokenSkip, where: { id_in: $token_in_1 }) { + id + name + decimals + symbol + + balances( + orderBy: id + orderDirection: desc + first: $first + skip: $skip + where: { + amount_gt: 0 + account_not_in: $account_not_in + } + ) { + account { + id + } + amount + } + } +} diff --git a/src/queries/VaultSharesBalances.graphql b/src/queries/VaultSharesBalances.graphql index 1327e36..9814824 100644 --- a/src/queries/VaultSharesBalances.graphql +++ b/src/queries/VaultSharesBalances.graphql @@ -3,33 +3,32 @@ query VaultSharesBalances( $token_in_2: [String!]! $account_not_in: [String!]! $block: Int + $tokenFirst: Int = 1000 + $tokenSkip: Int = 0 $first: Int = 1000 $skip: Int = 0 ) { - tokens(first: $first, skip: $skip, where: { id_in: $token_in_1 }) { + tokens(first: $tokenFirst, skip: $tokenSkip, 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 + + balances( + orderBy: id + orderDirection: desc + first: $first + skip: $skip + where: { + amount_gt: 0 + token_in: $token_in_2 + account_not_in: $account_not_in + } + ) { + account { + id + } + amount } - amount } -} +} \ No newline at end of file diff --git a/src/routes/v1/contract.ts b/src/routes/v1/contract.ts index 0cad936..664c3b1 100644 --- a/src/routes/v1/contract.ts +++ b/src/routes/v1/contract.ts @@ -1,5 +1,6 @@ import { type Static, Type } from '@sinclair/typebox'; import type { FastifyInstance, FastifyPluginOptions, FastifySchema } from 'fastify'; +import { min } from 'lodash'; import type { Hex } from 'viem'; import { type ChainId, chainIdSchema } from '../../config/chains'; import { addressSchema } from '../../schema/address'; @@ -87,48 +88,51 @@ const getContractHolders = async ( await Promise.all( getSdksForChain(chainId).map(sdk => paginate({ - fetchPage: ({ skip, first }) => - sdk.VaultSharesBalances({ - skip, - first, - block: Number(block), - account_not_in: ['0x0000000000000000000000000000000000000000'], // empty list returns nothing - token_in_1: [contract_address], - token_in_2: [contract_address], + fetchPage: ({ skip: tokenSkip, first: tokenFirst }) => + paginate({ + fetchPage: ({ skip, first }) => + sdk.VaultSharesBalances({ + tokenSkip, + tokenFirst, + skip, + first, + block: Number(block), + account_not_in: ['0x0000000000000000000000000000000000000000'], // empty list returns nothing + token_in_1: [contract_address], + token_in_2: [contract_address], + }), + count: res => min(res.data.tokens.map(token => token.balances.length)) ?? 0, }), - count: res => res.data.tokenBalances.length, + count: res => min(res.map(chainRes => chainRes.data.tokens.length)) ?? 0, }) ) ) ).flat(); - return res.flatMap(chainRes => { - const tokens = chainRes.data.tokens; - const balances = chainRes.data.tokenBalances; + return res.flatMap(chainRes => + chainRes.flatMap(tokenPage => + tokenPage.data.tokens.map(token => { + 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 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, - })), - }; - }); - }); + return { + id: token.id, + name: token.name, + symbol: token.symbol, + decimals: Number.parseInt(token.decimals, 10), + balances: token.balances.map(balance => ({ + balance: balance.amount, + holder: balance.account.id, + })), + }; + }) + ) + ); }; diff --git a/src/routes/v1/vault.ts b/src/routes/v1/vault.ts index fb15456..3f11385 100644 --- a/src/routes/v1/vault.ts +++ b/src/routes/v1/vault.ts @@ -1,6 +1,6 @@ import { type Static, Type } from '@sinclair/typebox'; import type { FastifyInstance, FastifyPluginOptions, FastifySchema } from 'fastify'; -import { groupBy, keyBy, uniq } from 'lodash'; +import { groupBy, keyBy, min, uniq } from 'lodash'; import type { Hex } from 'viem'; import { type ChainId, chainIdSchema } from '../../config/chains'; import { addressSchema } from '../../schema/address'; @@ -19,6 +19,49 @@ export default async function ( ) { const asyncCache = getAsyncCache(); + // all holder count list for all chains + { + const urlParamsSchema = Type.Object({ + chain: chainIdSchema, + }); + type UrlParams = Static; + + const querySchema = Type.Object({ + vault_addresses: Type.Array(addressSchema, { minItems: 1, maxItems: 100 }), + limit: Type.Number({ default: 100, minimum: 1, maximum: 1000 }), + }); + type QueryParams = Static; + + const schema: FastifySchema = { + tags: ['vault'], + params: urlParamsSchema, + querystring: querySchema, + response: { + 200: vaultHoldersSchema, + }, + }; + + instance.get<{ Params: UrlParams; Querystring: QueryParams }>( + '/:chain/top-holders', + { schema }, + async (request, reply) => { + const { chain } = request.params; + const { vault_addresses, limit } = request.query; + + if (!vault_addresses || !Array.isArray(vault_addresses) || vault_addresses.length === 0) { + throw new Error('vault_addresses is required'); + } + + const result = await asyncCache.wrap( + `vault:${chain}:${vault_addresses.join(',')}:top-holders:${limit}`, + 5 * 60 * 1000, + async () => await getTopHolders(chain, vault_addresses as Hex[], limit) + ); + reply.send(result); + } + ); + } + // all holder count list for all chains { const urlParamsSchema = Type.Object({ @@ -248,50 +291,53 @@ const getVaultHolders = async ( await Promise.all( getSdksForChain(chainId).map(sdk => paginate({ - fetchPage: ({ skip, first }) => - sdk.VaultSharesBalances({ - skip, - first, - block: Number(block), - account_not_in: excludeHolders, - token_in_1: tokens, - token_in_2: tokens, + fetchPage: ({ skip: tokenSkip, first: tokenFirst }) => + paginate({ + fetchPage: ({ skip, first }) => + sdk.VaultSharesBalances({ + tokenSkip, + tokenFirst, + skip, + first, + block: Number(block), + account_not_in: excludeHolders, + token_in_1: tokens, + token_in_2: tokens, + }), + count: res => min(res.data.tokens.map(token => token.balances.length)) ?? 0, }), - count: res => res.data.tokenBalances.length, + count: res => min(res.map(chainRes => chainRes.data.tokens.length)) ?? 0, }) ) ) ).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 res.flatMap(chainRes => + chainRes.flatMap(tokenPage => + tokenPage.data.tokens.map(token => { + 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, - })), - }; - }); - }); + return { + id: token.id, + name: token.name, + symbol: token.symbol, + decimals: Number.parseInt(token.decimals, 10), + balances: token.balances.map(balance => ({ + balance: balance.amount, + holder: balance.account.id, + })), + }; + }) + ) + ); }; const getVaultHoldersAsBaseVaultEquivalentForVaultAddress = async ( @@ -386,51 +432,54 @@ const _getVaultHoldersAsBaseVaultEquivalent = async ( await Promise.all( getSdksForChain(chainId).map(sdk => paginate({ - fetchPage: ({ skip, first }) => - sdk.VaultSharesBalances({ - skip, - first, - block: Number(block), - account_not_in: ['0x0000000000000000000000000000000000000000'], - token_in_1: tokens, - token_in_2: tokens, + fetchPage: ({ skip: tokenSkip, first: tokenFirst }) => + paginate({ + fetchPage: ({ skip, first }) => + sdk.VaultSharesBalances({ + tokenSkip, + tokenFirst, + skip, + first, + block: Number(block), + account_not_in: ['0x0000000000000000000000000000000000000000'], + token_in_1: tokens, + token_in_2: tokens, + }), + count: res => min(res.data.tokens.map(token => token.balances.length)) ?? 0, }), - count: res => res.data.tokenBalances.length, + count: res => min(res.map(chainRes => chainRes.data.tokens.length)) ?? 0, }) ) ) ).flat(); const balancesByContract = keyBy( - 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.toLowerCase(), - name: token.name, - symbol: token.symbol, - decimals: Number.parseInt(token.decimals, 10), - balances: tokenBalances.map(balance => ({ - balance: balance.amount, - holder: balance.account.id, - })), - }; - }); - }), + res.flatMap(chainRes => + chainRes.flatMap(tokenPage => + tokenPage.data.tokens.map(token => { + 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.toLowerCase(), + name: token.name, + symbol: token.symbol, + decimals: Number.parseInt(token.decimals, 10), + balances: token.balances.map(balance => ({ + balance: balance.amount, + holder: balance.account.id, + })), + }; + }) + ) + ), e => e.id ); @@ -590,3 +639,50 @@ const _getVaultHoldersAsBaseVaultEquivalent = async ( })); return mergedHolders; }; + +const getTopHolders = async (chainId: ChainId, vault_addresses: Hex[], limit: number) => { + const res = ( + await Promise.all( + getSdksForChain(chainId).map(sdk => + paginate({ + fetchPage: ({ skip: tokenSkip, first: tokenFirst }) => + sdk.VaultSharesBalances({ + tokenSkip, + tokenFirst, + skip: 0, + first: limit, + account_not_in: ['0x0000000000000000000000000000000000000000'], // providing an empty account_not_in will return 0 holders + token_in_1: vault_addresses, + token_in_2: vault_addresses, + }), + count: res => res.data.tokens.length, + }) + ) + ) + ).flat(); + + return res.flatMap(chainRes => + chainRes.data.tokens.map(token => { + 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: token.balances.map(balance => ({ + balance: balance.amount, + holder: balance.account.id, + })), + }; + }) + ); +};