From ffa1c2fb6421dd6a4695bba9679c50d98e5256d3 Mon Sep 17 00:00:00 2001 From: ReflectiveChimp <55021052+ReflectiveChimp@users.noreply.github.com> Date: Fri, 23 Aug 2024 11:44:28 +0100 Subject: [PATCH] clm vaults (#35) * add classic support * lint --- biome.json | 43 ++- package.json | 7 +- src/queries/InvestorTimeline.graphql | 109 +++++-- src/queries/VaultHarvests.graphql | 33 ++- src/queries/VaultHistoricPrices.graphql | 87 +++++- src/queries/VaultHistoricPricesRange.graphql | 24 +- src/queries/VaultPrice.graphql | 32 +- src/queries/VaultsHarvests.graphql | 32 +- src/queries/VaultsHarvestsFiltered.graphql | 32 +- src/routes/v1/investor.ts | 196 ++++++++++--- src/routes/v1/vault.ts | 226 ++++++++++---- src/routes/v1/vaults.ts | 52 +++- src/utils/array.ts | 4 + src/utils/prices.ts | 110 +++++++ src/utils/sdk.ts | 4 + src/utils/timeline.ts | 293 ++++++++++++++++--- src/utils/tokens.ts | 13 + 17 files changed, 1056 insertions(+), 241 deletions(-) create mode 100644 src/utils/array.ts create mode 100644 src/utils/prices.ts create mode 100644 src/utils/tokens.ts diff --git a/biome.json b/biome.json index 2166dc5..3efbd0d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,16 @@ { "$schema": "https://biomejs.dev/schemas/1.8.2/schema.json", + "files": { + "ignore": [ + "./.husky/**", + "./.idea/**", + "./.vscode/**", + "./build/**", + "./node_modules/**", + "./src/queries/codegen/sdk.ts", + "./src/queries/codegen/schema.graphql" + ] + }, "formatter": { "enabled": true, "formatWithErrors": false, @@ -7,35 +18,19 @@ "indentWidth": 2, "lineEnding": "lf", "lineWidth": 100, - "attributePosition": "auto", - "ignore": [ - "./.husky/**", - "./build/**", - "./node_modules/**", - "src/queries/codegen/sdk.ts", - "src/queries/codegen/schema.graphql" - ] + "attributePosition": "auto" }, "organizeImports": { - "enabled": true, - "ignore": [ - "./.husky/**", - "./build/**", - "./node_modules/**", - "src/queries/codegen/sdk.ts", - "src/queries/codegen/schema.graphql" - ] + "enabled": true }, "linter": { "enabled": true, - "ignore": [ - "./.husky/**", - "./build/**", - "./node_modules/**", - "src/queries/codegen/sdk.ts", - "src/queries/codegen/schema.graphql" - ], - "rules": { "recommended": true } + "rules": { + "recommended": true, + "complexity": { + "noForEach": "off" + } + } }, "javascript": { "formatter": { diff --git a/package.json b/package.json index acf8be6..7e1986f 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "inspect": "node --inspect build/index.js", "deploy": "git checkout prod && git rebase main && git push && git checkout -", "dev": "ts-node-dev --respawn --require=dotenv/config src/index.ts", - "format": "biome check src/**/*.{ts,graphql}", - "format:fix": "biome check --write --unsafe src/**/*.{ts,graphql}", + "format": "biome check", + "format:fix": "biome check --write", + "format:fix:unsafe": "biome check --write --unsafe", "test": "npm run test:ts && npm run test:unit && npm run test:lint", "test:unit": "jest", "test:ts": "tsc --noEmit", @@ -48,7 +49,7 @@ "pino": "^8.19.0" }, "lint-staged": { - "src/**/*.{ts,tsx,graphql,json}": "biome format --write" + "src/**/*.{ts,graphql,json}": "biome check --write" }, "devDependencies": { "@biomejs/biome": "1.8.2", diff --git a/src/queries/InvestorTimeline.graphql b/src/queries/InvestorTimeline.graphql index cffa399..89e2f3a 100644 --- a/src/queries/InvestorTimeline.graphql +++ b/src/queries/InvestorTimeline.graphql @@ -5,7 +5,7 @@ fragment Token on Token { decimals } -fragment InvestorTimelineClmPosition on CLM { +fragment InvestorTimelineClmPositionClm on CLM { address: id managerToken { ...Token @@ -27,9 +27,15 @@ fragment InvestorTimelineClmPosition on CLM { nativeToUSDPrice } +fragment InvestorTimelineClmPosition on ClmPosition { + id + clm { + ...InvestorTimelineClmPositionClm + } +} + fragment InvestorTimelineClmPositionInteraction on ClmPositionInteraction { id - timestamp blockNumber createdWith { @@ -50,30 +56,93 @@ fragment InvestorTimelineClmPositionInteraction on ClmPositionInteraction { type } +fragment InvestorTimelineClassicPositionClassic on Classic { + address: id + rewardPoolTokens { + ...Token + } + rewardPoolTokensOrder + rewardPoolsTotalSupply + vaultSharesToken { + ...Token + } + vaultSharesTotalSupply + underlyingToken { + ...Token + } + underlyingAmount + underlyingToNativePrice + underlyingBreakdownTokens { + ...Token + } + underlyingBreakdownTokensOrder +} + +fragment InvestorTimelineClassicPosition on ClassicPosition { + id + classic { + ...InvestorTimelineClassicPositionClassic + } +} + +fragment InvestorTimelineClassicPositionInteraction on ClassicPositionInteraction { + id + timestamp + blockNumber + createdWith { + hash: id + } + rewardPoolBalances + rewardPoolBalancesDelta + vaultBalance + vaultBalanceDelta + boostBalance + boostBalanceDelta + totalBalance + vaultSharesTotalSupply + vaultUnderlyingAmount + vaultUnderlyingBreakdownBalances + vaultUnderlyingTotalSupply + underlyingBreakdownToNativePrices + underlyingToNativePrice + nativeToUSDPrice + type +} + query InvestorTimeline($investor_address: String!, $first: Int = 1000, $skip: Int = 0) { clmPositions(skip: $skip, first: $first, where: { investor: $investor_address, totalBalance_gt: 0 }) { - id - - managerBalance - rewardPoolBalances - totalBalance - clm { - ...InvestorTimelineClmPosition - } + ...InvestorTimelineClmPosition } - + clmPositionInteractions( - first: $first - skip: $skip - orderBy: timestamp - orderDirection: asc - where: { - investor: $investor_address, - type_in: [MANAGER_DEPOSIT, MANAGER_WITHDRAW, CLM_REWARD_POOL_STAKE, CLM_REWARD_POOL_UNSTAKE] - } + first: $first + skip: $skip + orderBy: timestamp + orderDirection: asc + where: { + investor: $investor_address, + type_in: [MANAGER_DEPOSIT, MANAGER_WITHDRAW, CLM_REWARD_POOL_STAKE, CLM_REWARD_POOL_UNSTAKE] + } ) { ...InvestorTimelineClmPositionInteraction - + investorPosition { + id + } + } + + classicPositions(skip: $skip, first: $first, where: { investor: $investor_address, totalBalance_gt: 0 }) { + ...InvestorTimelineClassicPosition + } + + classicPositionInteractions(first: $first + skip: $skip + orderBy: timestamp + orderDirection: asc + where: { + investor: $investor_address, + type_in: [VAULT_DEPOSIT, VAULT_WITHDRAW, CLASSIC_REWARD_POOL_STAKE, CLASSIC_REWARD_POOL_UNSTAKE] + }) { + ...InvestorTimelineClassicPositionInteraction investorPosition { id } diff --git a/src/queries/VaultHarvests.graphql b/src/queries/VaultHarvests.graphql index e8aba7e..1c1ce7e 100644 --- a/src/queries/VaultHarvests.graphql +++ b/src/queries/VaultHarvests.graphql @@ -1,14 +1,24 @@ -fragment HarvestData on ClmHarvestEvent { +fragment ClmHarvestData on ClmHarvestEvent { + id timestamp compoundedAmount0 compoundedAmount1 + nativeToUSDPrice token0ToNativePrice token1ToNativePrice - nativeToUSDPrice + underlyingAmount0 + underlyingAmount1 totalSupply: managerTotalSupply - createdWith { - id - } +} + +fragment ClassicHarvestData on ClassicHarvestEvent { + id + timestamp + compoundedAmount + nativeToUSDPrice + underlyingToNativePrice + underlyingAmount + totalSupply: vaultSharesTotalSupply } query VaultHarvests($vault_address: ID!) { @@ -23,7 +33,18 @@ query VaultHarvests($vault_address: ID!) { decimals } harvests(orderBy: timestamp, orderDirection: desc, first: 1000) { - ...HarvestData + ...ClmHarvestData + } + } + classic(id: $vault_address) { + underlyingToken { + decimals + } + sharesToken: vaultSharesToken { + decimals + } + harvests(orderBy: timestamp, orderDirection: desc, first: 1000) { + ...ClassicHarvestData } } } diff --git a/src/queries/VaultHistoricPrices.graphql b/src/queries/VaultHistoricPrices.graphql index 2a59f32..2c22afe 100644 --- a/src/queries/VaultHistoricPrices.graphql +++ b/src/queries/VaultHistoricPrices.graphql @@ -5,6 +5,73 @@ fragment Token on Token { decimals } +fragment ClmSnapshotData on ClmSnapshot { + roundedTimestamp + nativeToUSDPrice + priceRangeMin1 + priceOfToken0InToken1 + priceRangeMax1 + token0ToNativePrice + token1ToNativePrice + totalUnderlyingAmount0 + totalUnderlyingAmount1 + totalSupply: managerTotalSupply +} + +fragment ClassicSnapshotData on ClassicSnapshot { + roundedTimestamp + nativeToUSDPrice + underlyingAmount + underlyingToNativePrice + underlyingBreakdownToNativePrices + vaultUnderlyingBreakdownBalances + vaultUnderlyingTotalSupply + totalSupply: vaultSharesTotalSupply +} + +fragment ClmPriceData on CLM { + sharesToken: managerToken { + ...Token + } + underlyingToken0 { + ...Token + } + underlyingToken1 { + ...Token + } + snapshots( + first: $first + skip: $skip + orderBy: roundedTimestamp + orderDirection: asc + where: { roundedTimestamp_gte: $since, period: $period } + ) { + ...ClmSnapshotData + } +} + +fragment ClassicPriceData on Classic { + sharesToken: vaultSharesToken { + ...Token + } + underlyingToken { + ...Token + } + underlyingBreakdownTokens { + ...Token + } + underlyingBreakdownTokensOrder + snapshots( + first: $first + skip: $skip + orderBy: roundedTimestamp + orderDirection: asc + where: { roundedTimestamp_gte: $since, period: $period } + ) { + ...ClassicSnapshotData + } +} + query VaultHistoricPrices( $vault_address: ID! $since: BigInt! @@ -13,21 +80,9 @@ query VaultHistoricPrices( $skip: Int = 0 ) { clm(id: $vault_address) { - underlyingToken1 { - ...Token - } - snapshots( - first: $first - skip: $skip - orderBy: roundedTimestamp - orderDirection: asc - # TODO remove managerTotalSupply_gt once reindexed to not save snapshots when total supply is 0 - where: { roundedTimestamp_gte: $since, period: $period, managerTotalSupply_gt: 0 } - ) { - roundedTimestamp - priceRangeMin1 - priceOfToken0InToken1 - priceRangeMax1 - } + ...ClmPriceData + } + classic(id: $vault_address) { + ...ClassicPriceData } } diff --git a/src/queries/VaultHistoricPricesRange.graphql b/src/queries/VaultHistoricPricesRange.graphql index ad6b07a..29d1992 100644 --- a/src/queries/VaultHistoricPricesRange.graphql +++ b/src/queries/VaultHistoricPricesRange.graphql @@ -4,8 +4,7 @@ query VaultHistoricPricesRange($vault_address: ID!, $period: BigInt!) { first: 1 orderBy: roundedTimestamp orderDirection: asc - # TODO remove managerTotalSupply_gt once reindexed to not save snapshots when total supply is 0 - where: { period: $period, managerTotalSupply_gt: 0 } + where: { period: $period } ) { roundedTimestamp } @@ -13,8 +12,25 @@ query VaultHistoricPricesRange($vault_address: ID!, $period: BigInt!) { first: 1 orderBy: roundedTimestamp orderDirection: desc - # TODO remove managerTotalSupply_gt once reindexed to not save snapshots when total supply is 0 - where: { period: $period, managerTotalSupply_gt: 0 } + where: { period: $period } + ) { + roundedTimestamp + } + } + classic(id: $vault_address) { + minSnapshot: snapshots( + first: 1 + orderBy: roundedTimestamp + orderDirection: asc + where: { period: $period } + ) { + roundedTimestamp + } + maxSnapshot: snapshots( + first: 1 + orderBy: roundedTimestamp + orderDirection: desc + where: { period: $period } ) { roundedTimestamp } diff --git a/src/queries/VaultPrice.graphql b/src/queries/VaultPrice.graphql index aafa6ab..d6bd3ac 100644 --- a/src/queries/VaultPrice.graphql +++ b/src/queries/VaultPrice.graphql @@ -16,8 +16,38 @@ query VaultPrice($vault_address: ID!) { underlyingToken1 { ...Token } - priceOfToken0InToken1 + nativeToUSDPrice priceRangeMin1 + priceOfToken0InToken1 priceRangeMax1 + token0ToNativePrice + token1ToNativePrice + totalUnderlyingAmount0 + totalUnderlyingAmount1 + totalSupply: managerTotalSupply + } + classic(id: $vault_address) { + sharesToken: vaultSharesToken { + ...Token + } + underlyingToken { + ...Token + } + underlyingBreakdownTokens { + ...Token + } + underlyingBreakdownTokensOrder + nativeToUSDPrice + underlyingAmount + underlyingToNativePrice + underlyingBreakdownToNativePrices + vaultUnderlyingBreakdownBalances + vaultUnderlyingTotalSupply + totalSupply: vaultSharesTotalSupply + } + _meta { + block { + timestamp + } } } diff --git a/src/queries/VaultsHarvests.graphql b/src/queries/VaultsHarvests.graphql index c5a910a..0b904b2 100644 --- a/src/queries/VaultsHarvests.graphql +++ b/src/queries/VaultsHarvests.graphql @@ -1,16 +1,3 @@ -fragment HarvestData on ClmHarvestEvent { - timestamp - compoundedAmount0 - compoundedAmount1 - token0ToNativePrice - token1ToNativePrice - nativeToUSDPrice - totalSupply: managerTotalSupply - createdWith { - id - } -} - query VaultsHarvests($since: BigInt!, $first: Int = 1000, $skip: Int = 0) { clms(first: $first, skip: $skip, where: { lifecycle_not: INITIALIZING }) { vaultAddress: id @@ -29,7 +16,24 @@ query VaultsHarvests($since: BigInt!, $first: Int = 1000, $skip: Int = 0) { first: 1000 where: { timestamp_gte: $since } ) { - ...HarvestData + ...ClmHarvestData + } + } + classics(first: $first, skip: $skip, where: { lifecycle_not: INITIALIZING }) { + vaultAddress: id + underlyingToken { + decimals + } + sharesToken: vaultSharesToken { + decimals + } + harvests( + orderBy: timestamp + orderDirection: desc + first: 1000 + where: { timestamp_gte: $since } + ) { + ...ClassicHarvestData } } } diff --git a/src/queries/VaultsHarvestsFiltered.graphql b/src/queries/VaultsHarvestsFiltered.graphql index 2b2ae39..c1ae6ab 100644 --- a/src/queries/VaultsHarvestsFiltered.graphql +++ b/src/queries/VaultsHarvestsFiltered.graphql @@ -1,16 +1,3 @@ -fragment HarvestData on ClmHarvestEvent { - timestamp - compoundedAmount0 - compoundedAmount1 - token0ToNativePrice - token1ToNativePrice - nativeToUSDPrice - totalSupply: managerTotalSupply - createdWith { - id - } -} - query VaultsHarvestsFiltered( $since: BigInt! $first: Int = 1000 @@ -34,7 +21,24 @@ query VaultsHarvestsFiltered( first: 1000 where: { timestamp_gte: $since } ) { - ...HarvestData + ...ClmHarvestData + } + } + classics(first: $first, skip: $skip, where: { lifecycle_not: INITIALIZING, id_in: $vaults }) { + vaultAddress: id + underlyingToken { + decimals + } + sharesToken: vaultSharesToken { + decimals + } + harvests( + orderBy: timestamp + orderDirection: desc + first: 1000 + where: { timestamp_gte: $since } + ) { + ...ClassicHarvestData } } } diff --git a/src/routes/v1/investor.ts b/src/routes/v1/investor.ts index da5abc5..428730e 100644 --- a/src/routes/v1/investor.ts +++ b/src/routes/v1/investor.ts @@ -5,7 +5,13 @@ import { addressSchema, transactionHashSchema } from '../../schema/address'; import { bigDecimalSchema } from '../../schema/bigint'; import { getAsyncCache } from '../../utils/async-lock'; import type { Address, Hex } from '../../utils/scalar-types'; -import { actionsEnumSchema, getClmTimeline } from '../../utils/timeline'; +import { + type TimelineClassicInteraction, + type TimelineClmInteraction, + classicActionsEnumSchema, + clmActionsEnumSchema, + getInvestorTimeline, +} from '../../utils/timeline'; export default async function ( instance: FastifyInstance, @@ -50,6 +56,7 @@ export default async function ( } const timelineClmInteractionOutputSchema = Type.Object({ + type: Type.Literal('clm'), datetime: Type.String(), product_key: Type.String(), display_name: Type.String(), @@ -57,6 +64,7 @@ const timelineClmInteractionOutputSchema = Type.Object({ is_eol: Type.Boolean(), is_dashboard_eol: Type.Boolean(), transaction_hash: transactionHashSchema, + actions: Type.Array(clmActionsEnumSchema), /** called shares for legacy reasons, this is now the total between manager and reward pool */ share_balance: bigDecimalSchema, @@ -77,7 +85,6 @@ const timelineClmInteractionOutputSchema = Type.Object({ manager_address: addressSchema, manager_balance: bigDecimalSchema, manager_diff: bigDecimalSchema, - actions: Type.Array(actionsEnumSchema), // reward pool fields reward_pool_total: Type.Object({ @@ -92,52 +99,163 @@ const timelineClmInteractionOutputSchema = Type.Object({ }) ), }); +const timelineClassicInteractionOutputSchema = Type.Object({ + type: Type.Literal('classic'), + datetime: Type.String(), + product_key: Type.String(), + display_name: Type.String(), + chain: chainIdSchema, + is_eol: Type.Boolean(), + is_dashboard_eol: Type.Boolean(), + transaction_hash: transactionHashSchema, + actions: Type.Array(classicActionsEnumSchema), + + /** called shares for legacy reasons, this is now the total between vault/reward pools */ + share_balance: bigDecimalSchema, + share_diff: bigDecimalSchema, + share_to_underlying: bigDecimalSchema, + + underlying_address: addressSchema, + underlying_to_usd: bigDecimalSchema, + + underlying_breakdown: Type.Array( + Type.Object({ + token: addressSchema, + underlying_to_token: bigDecimalSchema, + token_to_usd: bigDecimalSchema, + }) + ), + + usd_balance: bigDecimalSchema, + usd_diff: bigDecimalSchema, + + // vault fields + vault_address: addressSchema, + vault_balance: bigDecimalSchema, + vault_diff: bigDecimalSchema, + + // reward pool fields + reward_pool_total: Type.Object({ + reward_pool_balance: bigDecimalSchema, + reward_pool_diff: bigDecimalSchema, + }), + reward_pool_details: Type.Array( + Type.Object({ + reward_pool_address: addressSchema, + reward_pool_balance: bigDecimalSchema, + reward_pool_diff: bigDecimalSchema, + }) + ), +}); + type TimelineClmInteractionOutput = Static; +type TimelineClassicInteractionOutput = Static; +type TimelineAnyInteractionOutput = TimelineClmInteractionOutput | TimelineClassicInteractionOutput; -async function getTimeline(investor_address: Address): Promise { - const timeline = await getClmTimeline(investor_address); +function clmInteractionToOutput(interaction: TimelineClmInteraction): TimelineClmInteractionOutput { + const { rewardPoolTokens, rewardPoolTotal, rewardPools } = interaction; - return timeline.map((interaction): TimelineClmInteractionOutput => { - const { rewardPoolTokens, rewardPoolTotal, rewardPools } = interaction; + return { + type: 'clm', + datetime: interaction.datetime.toISOString(), + product_key: `beefy:vault:${interaction.chain}:${interaction.managerToken.address}`, + display_name: interaction.managerToken.name || interaction.managerToken.address, + chain: interaction.chain, + is_eol: false, + is_dashboard_eol: false, + transaction_hash: interaction.transactionHash, + actions: interaction.actions, - return { - datetime: interaction.datetime.toISOString(), - product_key: `beefy:vault:${interaction.chain}:${interaction.managerToken.address}`, - display_name: interaction.managerToken.name || interaction.managerToken.address, - chain: interaction.chain, - is_eol: false, - is_dashboard_eol: false, - transaction_hash: interaction.transactionHash, + // legacy: share -> total + share_balance: interaction.total.balance.toString(), + share_diff: interaction.total.delta.toString(), - // legacy: share -> total - share_balance: interaction.total.balance.toString(), - share_diff: interaction.total.delta.toString(), + token0_to_usd: interaction.token0ToUsd.toString(), + underlying0_balance: interaction.underlying0.balance.toString(), + underlying0_diff: interaction.underlying0.delta.toString(), - token0_to_usd: interaction.token0ToUsd.toString(), - underlying0_balance: interaction.underlying0.balance.toString(), - underlying0_diff: interaction.underlying0.delta.toString(), + token1_to_usd: interaction.token1ToUsd.toString(), + underlying1_balance: interaction.underlying1.balance.toString(), + underlying1_diff: interaction.underlying1.delta.toString(), - token1_to_usd: interaction.token1ToUsd.toString(), - underlying1_balance: interaction.underlying1.balance.toString(), - underlying1_diff: interaction.underlying1.delta.toString(), + usd_balance: interaction.usd.balance.toString(), + usd_diff: interaction.usd.delta.toString(), - usd_balance: interaction.usd.balance.toString(), - usd_diff: interaction.usd.delta.toString(), + manager_address: interaction.managerToken.address, + manager_balance: interaction.manager.balance.toString(), + manager_diff: interaction.manager.delta.toString(), - manager_address: interaction.managerToken.address, - manager_balance: interaction.manager.balance.toString(), - manager_diff: interaction.manager.delta.toString(), - actions: interaction.actions, + reward_pool_total: { + reward_pool_balance: rewardPoolTotal.balance.toString(), + reward_pool_diff: rewardPoolTotal.delta.toString(), + }, + reward_pool_details: rewardPools.map((rewardPool, i) => ({ + reward_pool_address: rewardPoolTokens[i].address, + reward_pool_balance: rewardPool.balance.toString(), + reward_pool_diff: rewardPool.delta.toString(), + })), + }; +} - reward_pool_total: { - reward_pool_balance: rewardPoolTotal.balance.toString(), - reward_pool_diff: rewardPoolTotal.delta.toString(), - }, - reward_pool_details: rewardPools.map((rewardPool, i) => ({ - reward_pool_address: rewardPoolTokens[i].address, - reward_pool_balance: rewardPool.balance.toString(), - reward_pool_diff: rewardPool.delta.toString(), - })), - }; +function classicInteractionToOutput( + interaction: TimelineClassicInteraction +): TimelineClassicInteractionOutput { + const { rewardPoolTokens, rewardPoolTotal, rewardPools } = interaction; + + return { + type: 'classic', + datetime: interaction.datetime.toISOString(), + product_key: `beefy:vault:${interaction.chain}:${interaction.shareToken.address}`, + display_name: interaction.shareToken.name || interaction.shareToken.address, + chain: interaction.chain, + is_eol: false, + is_dashboard_eol: false, + transaction_hash: interaction.transactionHash, + actions: interaction.actions, + + // legacy: share -> total + share_balance: interaction.total.balance.toString(), + share_diff: interaction.total.delta.toString(), + share_to_underlying: interaction.shareToUnderlying.toString(), + + underlying_address: interaction.underlyingToken.address, + underlying_to_usd: interaction.underlyingToUsd.toString(), + + underlying_breakdown: interaction.underlyingBreakdownTokens.map((token, i) => ({ + token: token.address, + underlying_to_token: interaction.underlyingToBreakdown[i].toString(), + token_to_usd: interaction.underlyingBreakdownToUsd[i].toString(), + })), + + usd_balance: interaction.usd.balance.toString(), + usd_diff: interaction.usd.delta.toString(), + + vault_address: interaction.shareToken.address, + vault_balance: interaction.vault.balance.toString(), + vault_diff: interaction.vault.delta.toString(), + + reward_pool_total: { + reward_pool_balance: rewardPoolTotal.balance.toString(), + reward_pool_diff: rewardPoolTotal.delta.toString(), + }, + reward_pool_details: rewardPools.map((rewardPool, i) => ({ + reward_pool_address: rewardPoolTokens[i].address, + reward_pool_balance: rewardPool.balance.toString(), + reward_pool_diff: rewardPool.delta.toString(), + })), + }; +} + +async function getTimeline(investor_address: Address): Promise { + const timeline = await getInvestorTimeline(investor_address); + + return timeline.map((interaction): TimelineAnyInteractionOutput => { + if (interaction.type === 'clm') { + return clmInteractionToOutput(interaction); + } + if (interaction.type === 'classic') { + return classicInteractionToOutput(interaction); + } + throw new Error('Unknown interaction type'); }); } diff --git a/src/routes/v1/vault.ts b/src/routes/v1/vault.ts index fea9fdc..c97210f 100644 --- a/src/routes/v1/vault.ts +++ b/src/routes/v1/vault.ts @@ -1,21 +1,34 @@ import { type Static, Type } from '@sinclair/typebox'; import type { FastifyInstance, FastifyPluginOptions, FastifySchema } from 'fastify'; +import { omit } from 'lodash'; import { type ChainId, chainIdSchema } from '../../config/chains'; -import type { HarvestDataFragment, Token } from '../../queries/codegen/sdk'; +import type { + ClassicHarvestDataFragment, + ClmHarvestDataFragment, + Token, +} from '../../queries/codegen/sdk'; import { addressSchema } from '../../schema/address'; -import { - bigDecimalSchema, - bigintSchema, - timestampNumberSchema, - timestampStrSchema, -} from '../../schema/bigint'; +import { bigDecimalSchema, timestampStrSchema } from '../../schema/bigint'; import { type Period, getPeriodSeconds, periodSchema } from '../../schema/period'; +import { isDefined } from '../../utils/array'; import { getAsyncCache } from '../../utils/async-lock'; import { interpretAsDecimal } from '../../utils/decimal'; +import { sortEntitiesByOrderList } from '../../utils/entity-order'; +import { FriendlyError } from '../../utils/error'; +import { getLoggerFor } from '../../utils/log'; +import { + classicHistoricPricesSchema, + clmHistoricPricesSchema, + handleClassicPrice, + handleClmPrice, +} from '../../utils/prices'; import type { Address, Hex } from '../../utils/scalar-types'; import { getSdksForChain, paginate } from '../../utils/sdk'; +import { toToken } from '../../utils/tokens'; import { setOpts } from '../../utils/typebox'; +const logger = getLoggerFor('vault'); + export default async function ( instance: FastifyInstance, _opts: FastifyPluginOptions, @@ -220,11 +233,7 @@ export default async function ( done(); } -const vaultPriceSchema = Type.Object({ - min: bigintSchema, - current: bigintSchema, - max: bigintSchema, -}); +const vaultPriceSchema = Type.Union([clmHistoricPricesSchema, classicHistoricPricesSchema]); type VaultPrice = Static; const getVaultPrice = async ( @@ -239,29 +248,78 @@ const getVaultPrice = async ( ) ); - const vault = res.map(r => r.data.clm).find(v => !!v); - if (!vault) { - return undefined; + const timestamp = + res + .map(r => r.data._meta) + .find(v => !!v) + ?.block.timestamp?.toString() || '0'; + + const clm = res.map(r => r.data.clm).find(v => !!v); + if (clm) { + return handleClmPrice(clm.sharesToken, clm.underlyingToken0, clm.underlyingToken1, { + ...omit(clm, ['sharesToken', 'underlyingToken0', 'underlyingToken1', '__typename']), + roundedTimestamp: timestamp, + }); } - return { - min: vault.priceRangeMin1, - current: vault.priceOfToken0InToken1, - max: vault.priceRangeMax1, - }; + const classic = res.map(r => r.data.classic).find(v => !!v); + if (classic) { + const { sharesToken, underlyingToken } = classic; + const underlyingBreakdownTokens = sortEntitiesByOrderList( + classic.underlyingBreakdownTokens, + 'address', + classic.underlyingBreakdownTokensOrder + ) + .map(toToken) + .filter(isDefined); + if (underlyingBreakdownTokens.length < classic.underlyingBreakdownTokensOrder.length) { + throw new FriendlyError( + `Missing underlying breakdown tokens for classic ${sharesToken.address}` + ); + } + + return handleClassicPrice(sharesToken, underlyingToken, underlyingBreakdownTokens, { + ...omit(classic, [ + 'sharesToken', + 'underlyingToken', + 'underlyingBreakdownTokens', + '__typename', + ]), + roundedTimestamp: timestamp, + }); + } + + return undefined; }; -export const vaultHarvestSchema = Type.Object({ +export const clmHarvestSchema = Type.Object({ + id: Type.String({ description: 'Id of the harvest event' }), + type: Type.Literal('clm'), timestamp: setOpts(timestampStrSchema, { description: 'The timestamp of the harvest' }), compoundedAmount0: setOpts(bigDecimalSchema, { description: 'The amount of token0 compounded' }), compoundedAmount1: setOpts(bigDecimalSchema, { description: 'The amount of token1 compounded' }), token0ToUsd: setOpts(bigDecimalSchema, { description: 'The price of token0 in USD' }), token1ToUsd: setOpts(bigDecimalSchema, { description: 'The price of token1 in USD' }), + totalAmount0: setOpts(bigDecimalSchema, { description: 'The amount of token0 in the vault' }), + totalAmount1: setOpts(bigDecimalSchema, { description: 'The amount of token1 in the vault' }), + totalSupply: setOpts(bigDecimalSchema, { description: 'The total supply of the vault' }), +}); +export const classicHarvestSchema = Type.Object({ + id: Type.String({ description: 'Id of the harvest event' }), + type: Type.Literal('classic'), + timestamp: setOpts(timestampStrSchema, { description: 'The timestamp of the harvest' }), + compoundedAmount: setOpts(bigDecimalSchema, { + description: 'The amount of underlying compounded', + }), + underlyingToUsd: setOpts(bigDecimalSchema, { description: 'The price of underlying in USD' }), + totalUnderlying: setOpts(bigDecimalSchema, { + description: 'The total underlying deposited in the vault', + }), totalSupply: setOpts(bigDecimalSchema, { description: 'The total supply of the vault' }), - transactionHash: Type.String({ description: 'Transaction hash of the harvest' }), }); -export type VaultHarvest = Static; -const vaultHarvestsSchema = Type.Array(vaultHarvestSchema); +export type ClmHarvest = Static; +export type ClassicHarvest = Static; +const vaultHarvestsSchema = Type.Array(Type.Union([clmHarvestSchema, classicHarvestSchema])); type VaultHarvests = Static; const getVaultHarvests = async (chain: ChainId, vault_address: Address): Promise => { @@ -273,20 +331,55 @@ const getVaultHarvests = async (chain: ChainId, vault_address: Address): Promise ) ); - const vault = res.map(r => r.data.clm).find(v => !!v); - if (!vault) { - return []; + const clm = res.map(r => r.data.clm).find(v => !!v); + if (clm) { + return prepareClmHarvests(clm); + } + + const vault = res.map(r => r.data.classic).find(v => !!v); + if (vault) { + return prepareClassicHarvests(vault); } - return prepareVaultHarvests(vault); + return []; }; -export function prepareVaultHarvests(vault: { +export function prepareClassicHarvests(vault: { + underlyingToken: Pick; + sharesToken: Pick; + harvests: Array; +}): ClassicHarvest[] { + return vault.harvests.map(harvest => { + const underlyingToNativePrice = interpretAsDecimal(harvest.underlyingToNativePrice, 18); + const nativeToUsd = interpretAsDecimal(harvest.nativeToUSDPrice, 18); + const compoundedAmount = interpretAsDecimal( + harvest.compoundedAmount, + vault.underlyingToken.decimals + ); + const totalUnderlying = interpretAsDecimal( + harvest.underlyingAmount, + vault.underlyingToken.decimals + ); + const totalSupply = interpretAsDecimal(harvest.totalSupply, vault.sharesToken.decimals); + + return { + id: harvest.id, + type: 'classic', + timestamp: harvest.timestamp, + compoundedAmount: compoundedAmount.toString(), + underlyingToUsd: underlyingToNativePrice.mul(nativeToUsd).toString(), + totalUnderlying: totalUnderlying.toString(), + totalSupply: totalSupply.toString(), + }; + }); +} + +export function prepareClmHarvests(vault: { underlyingToken0: Pick; underlyingToken1: Pick; sharesToken: Pick; - harvests: Array; -}): VaultHarvest[] { + harvests: Array; +}): ClmHarvest[] { return vault.harvests.map(harvest => { const token0ToNativePrice = interpretAsDecimal(harvest.token0ToNativePrice, 18); const token1ToNativePrice = interpretAsDecimal(harvest.token1ToNativePrice, 18); @@ -299,27 +392,33 @@ export function prepareVaultHarvests(vault: { harvest.compoundedAmount1, vault.underlyingToken1.decimals ); + const totalAmount0 = interpretAsDecimal( + harvest.underlyingAmount0, + vault.underlyingToken0.decimals + ); + const totalAmount1 = interpretAsDecimal( + harvest.underlyingAmount1, + vault.underlyingToken1.decimals + ); const totalSupply = interpretAsDecimal(harvest.totalSupply, vault.sharesToken.decimals); return { + id: harvest.id, + type: 'clm', timestamp: harvest.timestamp, compoundedAmount0: compoundedAmount0.toString(), compoundedAmount1: compoundedAmount1.toString(), token0ToUsd: token0ToNativePrice.mul(nativeToUsd).toString(), token1ToUsd: token1ToNativePrice.mul(nativeToUsd).toString(), + totalAmount0: totalAmount0.toString(), + totalAmount1: totalAmount1.toString(), totalSupply: totalSupply.toString(), - transactionHash: harvest.createdWith.id, }; }); } const vaultHistoricPricesSchema = Type.Array( - Type.Object({ - t: timestampNumberSchema, - min: bigDecimalSchema, - v: bigDecimalSchema, - max: bigDecimalSchema, - }) + Type.Union([clmHistoricPricesSchema, classicHistoricPricesSchema]) ); type VaultHistoricPrices = Static; @@ -328,7 +427,7 @@ const getVaultHistoricPrices = async ( vault_address: Address, period: Period, since: string -): Promise => { +): Promise => { const res = await Promise.all( getSdksForChain(chain).map(async sdk => sdk.VaultHistoricPrices({ @@ -339,23 +438,44 @@ const getVaultHistoricPrices = async ( ) ); - const vault = res.map(r => r.data.clm).find(v => !!v); - if (!vault) { - return []; - } + const clm = res.map(r => r.data.clm).find(v => !!v); + if (clm) { + if (!clm.snapshots?.length) { + return []; + } - if (!vault.snapshots?.length) { - return []; + const { underlyingToken0, underlyingToken1, sharesToken } = clm; + + return clm.snapshots.map(snapshot => + handleClmPrice(sharesToken, underlyingToken0, underlyingToken1, snapshot) + ); } - const token1 = vault.underlyingToken1; + const classic = res.map(r => r.data.classic).find(v => !!v); + if (classic) { + if (!classic.snapshots?.length) { + return []; + } + + const { sharesToken, underlyingToken } = classic; + const underlyingBreakdownTokens = sortEntitiesByOrderList( + classic.underlyingBreakdownTokens, + 'address', + classic.underlyingBreakdownTokensOrder + ) + .map(toToken) + .filter(isDefined); + if (underlyingBreakdownTokens.length < classic.underlyingBreakdownTokensOrder.length) { + logger.error(`Missing underlying breakdown tokens for classic ${sharesToken.address}`); + return []; + } + + return classic.snapshots.map(snapshot => + handleClassicPrice(sharesToken, underlyingToken, underlyingBreakdownTokens, snapshot) + ); + } - return vault.snapshots.map(snapshot => ({ - t: Number.parseInt(snapshot.roundedTimestamp), - min: interpretAsDecimal(snapshot.priceRangeMin1, token1.decimals).toString(), - v: interpretAsDecimal(snapshot.priceOfToken0InToken1, token1.decimals).toString(), - max: interpretAsDecimal(snapshot.priceRangeMax1, token1.decimals).toString(), - })); + return undefined; }; const vaultHistoricPricesRangeSchema = Type.Union([ @@ -381,7 +501,7 @@ const getVaultHistoricPricesRange = async ( ) ); - const vault = res.map(r => r.data.clm).find(v => !!v); + const vault = res.map(r => r.data.clm ?? r.data.classic).find(v => !!v); if (!vault) { return undefined; } diff --git a/src/routes/v1/vaults.ts b/src/routes/v1/vaults.ts index fdd1e3c..47afc28 100644 --- a/src/routes/v1/vaults.ts +++ b/src/routes/v1/vaults.ts @@ -13,7 +13,12 @@ import { interpretAsDecimal } from '../../utils/decimal'; import type { Address, Hex } from '../../utils/scalar-types'; import { getSdksForChain, paginate } from '../../utils/sdk'; import { setOpts } from '../../utils/typebox'; -import { prepareVaultHarvests, vaultHarvestSchema } from './vault'; +import { + classicHarvestSchema, + clmHarvestSchema, + prepareClassicHarvests, + prepareClmHarvests, +} from './vault'; export default async function ( instance: FastifyInstance, @@ -191,10 +196,18 @@ const getVaults = async (chain: ChainId, period: Period): Promise => { }; const manyVaultHarvestSchema = Type.Array( - Type.Object({ - vaultAddress: addressSchema, - harvests: Type.Array(vaultHarvestSchema), - }) + Type.Union([ + Type.Object({ + vaultAddress: addressSchema, + type: Type.Literal('clm'), + harvests: Type.Array(clmHarvestSchema), + }), + Type.Object({ + vaultAddress: addressSchema, + type: Type.Literal('classic'), + harvests: Type.Array(classicHarvestSchema), + }), + ]) ); type ManyVaultsHarvests = Static; @@ -211,18 +224,33 @@ const getManyVaultsHarvests = async ( ) ); - const rawVaults = res.flatMap(chainRes => chainRes.data.clms); + const rawClms = res.flatMap(chainRes => chainRes.data.clms); + const rawClassics = res.flatMap(chainRes => chainRes.data.classics); + const vaultsWithHarvests: ManyVaultsHarvests = []; + + rawClms.forEach(vault => { + if (vault.harvests.length === 0) { + return; + } + + vaultsWithHarvests.push({ + vaultAddress: String(vault.vaultAddress), + type: 'clm', + harvests: prepareClmHarvests(vault), + }); + }); - return rawVaults.reduce((acc, vault): ManyVaultsHarvests => { + rawClassics.forEach(vault => { if (vault.harvests.length === 0) { - return acc; + return; } - acc.push({ + vaultsWithHarvests.push({ vaultAddress: String(vault.vaultAddress), - harvests: prepareVaultHarvests(vault), + type: 'classic', + harvests: prepareClassicHarvests(vault), }); + }); - return acc; - }, [] as ManyVaultsHarvests); + return vaultsWithHarvests; }; diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..539826d --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,4 @@ +/** Pass to Array.filter to remove null/undefined and narrow type */ +export function isDefined(value: T): value is Exclude { + return value !== undefined && value !== null; +} diff --git a/src/utils/prices.ts b/src/utils/prices.ts new file mode 100644 index 0000000..3f9acf8 --- /dev/null +++ b/src/utils/prices.ts @@ -0,0 +1,110 @@ +import { type Static, Type } from '@sinclair/typebox'; +import type { + ClassicPriceDataFragment, + ClassicSnapshotDataFragment, + ClmPriceDataFragment, + ClmSnapshotDataFragment, +} from '../queries/codegen/sdk'; +import { addressSchema } from '../schema/address'; +import { bigDecimalSchema, timestampNumberSchema } from '../schema/bigint'; +import { interpretAsDecimal } from './decimal'; +import type { Token } from './timeline'; + +export const clmHistoricPricesSchema = Type.Object({ + type: Type.Literal('clm'), + timestamp: timestampNumberSchema, + rangeMin: bigDecimalSchema, + currentPrice: bigDecimalSchema, + rangeMax: bigDecimalSchema, + token0ToUsd: bigDecimalSchema, + token1ToUsd: bigDecimalSchema, + totalAmount0: bigDecimalSchema, + totalAmount1: bigDecimalSchema, + totalSupply: bigDecimalSchema, +}); + +export const classicHistoricPricesSchema = Type.Object({ + type: Type.Literal('classic'), + timestamp: timestampNumberSchema, + underlyingToUsd: bigDecimalSchema, + totalUnderlyingAmount: bigDecimalSchema, + totalSupply: bigDecimalSchema, + totalUnderlyingSupply: bigDecimalSchema, + totalUnderlyingBreakdown: Type.Array( + Type.Object({ + token: addressSchema, + amount: bigDecimalSchema, + priceUsd: bigDecimalSchema, + }) + ), +}); + +export type ClmHistoricPrice = Static; +export type ClassicHistoricPrice = Static; + +export function handleClmPrice( + sharesToken: ClmPriceDataFragment['sharesToken'], + underlyingToken0: ClmPriceDataFragment['underlyingToken0'], + underlyingToken1: ClmPriceDataFragment['underlyingToken1'], + snapshot: ClmSnapshotDataFragment +): ClmHistoricPrice { + const nativeToUsd = interpretAsDecimal(snapshot.nativeToUSDPrice, 18); + + return { + type: 'clm', + timestamp: Number.parseInt(snapshot.roundedTimestamp), + rangeMin: interpretAsDecimal(snapshot.priceRangeMin1, underlyingToken1.decimals).toString(), + currentPrice: interpretAsDecimal( + snapshot.priceOfToken0InToken1, + underlyingToken1.decimals + ).toString(), + rangeMax: interpretAsDecimal(snapshot.priceRangeMax1, underlyingToken1.decimals).toString(), + token0ToUsd: interpretAsDecimal(snapshot.token0ToNativePrice, 18).mul(nativeToUsd).toString(), + token1ToUsd: interpretAsDecimal(snapshot.token1ToNativePrice, 18).mul(nativeToUsd).toString(), + totalAmount0: interpretAsDecimal( + snapshot.totalUnderlyingAmount0, + underlyingToken0.decimals + ).toString(), + totalAmount1: interpretAsDecimal( + snapshot.totalUnderlyingAmount1, + underlyingToken1.decimals + ).toString(), + totalSupply: interpretAsDecimal(snapshot.totalSupply, sharesToken.decimals).toString(), + }; +} + +export const handleClassicPrice = ( + sharesToken: ClassicPriceDataFragment['sharesToken'], + underlyingToken: ClassicPriceDataFragment['underlyingToken'], + underlyingBreakdownTokens: Token[], + snapshot: ClassicSnapshotDataFragment +): ClassicHistoricPrice => { + const nativeToUsd = interpretAsDecimal(snapshot.nativeToUSDPrice, 18); + + return { + type: 'classic', + timestamp: Number.parseInt(snapshot.roundedTimestamp), + underlyingToUsd: interpretAsDecimal(snapshot.underlyingToNativePrice, 18) + .mul(nativeToUsd) + .toString(), + totalUnderlyingAmount: interpretAsDecimal( + snapshot.underlyingAmount, + underlyingToken.decimals + ).toString(), + totalSupply: interpretAsDecimal(snapshot.totalSupply, sharesToken.decimals).toString(), + totalUnderlyingSupply: interpretAsDecimal( + snapshot.vaultUnderlyingTotalSupply, + underlyingToken.decimals + ).toString(), + totalUnderlyingBreakdown: underlyingBreakdownTokens.map((token, i) => ({ + token: token.address, + amount: interpretAsDecimal( + snapshot.vaultUnderlyingBreakdownBalances[i], + token.decimals + ).toString(), + priceUsd: interpretAsDecimal(snapshot.underlyingBreakdownToNativePrices[i], 18) + .mul(nativeToUsd) + .toString(), + })), + }; +}; diff --git a/src/utils/sdk.ts b/src/utils/sdk.ts index 526bd79..ef1c49b 100644 --- a/src/utils/sdk.ts +++ b/src/utils/sdk.ts @@ -101,6 +101,10 @@ export async function executeOnAllSdks( } } +export type SdkResult = Awaited> & SdkContext; +export type AllSdkResult = Array>; +export type PaginatedAllSdkResult = AllSdkRes>; + export async function paginate({ fetchPage, count, diff --git a/src/utils/timeline.ts b/src/utils/timeline.ts index 336a615..e81362a 100644 --- a/src/utils/timeline.ts +++ b/src/utils/timeline.ts @@ -1,21 +1,24 @@ -import type { Static } from '@sinclair/typebox'; +import { Enum, type Static } from '@sinclair/typebox'; import Decimal from 'decimal.js'; import { groupBy, keyBy } from 'lodash'; import type { ChainId } from '../config/chains'; import { + ClassicPositionInteractionType, ClmPositionInteractionType, + type InvestorTimelineClassicPositionFragment, + type InvestorTimelineClassicPositionInteractionFragment, type InvestorTimelineClmPositionFragment, type InvestorTimelineClmPositionInteractionFragment, - type TokenFragment, + type InvestorTimelineQuery, } from '../queries/codegen/sdk'; -import { ZERO_ADDRESS } from './address'; +import { isDefined } from './array'; import { fromUnixTime } from './date'; import { interpretAsDecimal } from './decimal'; import { sortEntitiesByOrderList } from './entity-order'; import { getLoggerFor } from './log'; import type { Address } from './scalar-types'; -import { executeOnAllSdks, paginate } from './sdk'; -import { StringEnum } from './typebox'; +import { type PaginatedAllSdkResult, executeOnAllSdks, paginate } from './sdk'; +import { toToken } from './tokens'; const logger = getLoggerFor('timeline'); @@ -30,10 +33,14 @@ export type Token = { name?: string | undefined; }; -export const actionsEnumSchema = StringEnum(Object.values(ClmPositionInteractionType)); -type ActionsEnum = Static; +export const clmActionsEnumSchema = Enum(ClmPositionInteractionType); +export const classicActionsEnumSchema = Enum(ClassicPositionInteractionType); -type TimelineClmInteraction = { +type ClmActionsEnum = Static; +type ClassicActionsEnum = Static; + +export type TimelineClmInteraction = { + type: 'clm'; datetime: Date; chain: ChainId; transactionHash: string; @@ -48,9 +55,32 @@ type TimelineClmInteraction = { underlying0: BalanceDelta; underlying1: BalanceDelta; usd: BalanceDelta; - actions: ActionsEnum[]; + actions: ClmActionsEnum[]; }; +export type TimelineClassicInteraction = { + type: 'classic'; + datetime: Date; + chain: ChainId; + transactionHash: string; + shareToken: Token; + underlyingToken: Token; + underlyingBreakdownTokens: Token[]; + rewardPoolTokens: Token[]; + shareToUnderlying: Decimal; + underlyingToUsd: Decimal; + underlyingToBreakdown: Decimal[]; + underlyingBreakdownToUsd: Decimal[]; + vault: BalanceDelta; + rewardPools: BalanceDelta[]; + rewardPoolTotal: BalanceDelta; + total: BalanceDelta; + usd: BalanceDelta; + actions: ClassicActionsEnum[]; +}; + +type TimelineAnyInteraction = TimelineClmInteraction | TimelineClassicInteraction; + const mergeBalanceDelta = (prev: T, next: T): T => { return { ...next, @@ -150,6 +180,7 @@ const mergeClmPositionInteractions = ( }; } else { acc[txHash] = { + type: 'clm', datetime: fromUnixTime(interaction.timestamp), chain, transactionHash: txHash, @@ -176,32 +207,23 @@ const mergeClmPositionInteractions = ( return Object.values(mergedByTxId); }; -function toToken(from: TokenFragment | undefined): Token | undefined { - return from?.address && from.address !== ZERO_ADDRESS - ? { - address: from.address, - decimals: Number(from.decimals), - name: from.name || undefined, - } - : undefined; -} - const clmPositionToInteractions = ( chainId: ChainId, position: InvestorTimelineClmPositionFragment, interactions: InvestorTimelineClmPositionInteractionFragment[] ): TimelineClmInteraction[] => { - const managerToken = toToken(position.managerToken); + const { id, clm } = position; + const managerToken = toToken(clm.managerToken); const orderedRewardPoolTokenAddresses = sortEntitiesByOrderList( - position.rewardPoolTokens, + clm.rewardPoolTokens, 'address', - position.rewardPoolTokensOrder + clm.rewardPoolTokensOrder ); const rewardPoolTokens = orderedRewardPoolTokenAddresses.map(toToken).filter(Boolean) as Token[]; - const token0 = toToken(position.underlyingToken0); - const token1 = toToken(position.underlyingToken1); + const token0 = toToken(clm.underlyingToken0); + const token1 = toToken(clm.underlyingToken1); if (!managerToken || !token0 || !token1) { - logger.error(`Missing token for position ${position.address}`); + logger.error(`Missing token for position ${id}`); return []; } @@ -215,18 +237,189 @@ const clmPositionToInteractions = ( ); }; -export async function getClmTimeline(investor_address: Address): Promise { - const res = await executeOnAllSdks(sdk => - paginate({ - fetchPage: ({ skip, first }) => sdk.InvestorTimeline({ investor_address, first, skip }), - count: res => [res.data.clmPositions.length, res.data.clmPositionInteractions.length], - }) +/** Merge ClassicPositionInteractions that share the same tx hash */ +const mergeClassicPositionInteractions = ( + chain: ChainId, + shareToken: Token, + underlyingToken: Token, + underlyingBreakdownTokens: Token[], + rewardPoolTokens: Token[], + interactions: InvestorTimelineClassicPositionInteractionFragment[] +): TimelineClassicInteraction[] => { + const mergedByTxId = interactions.reduce( + (acc, interaction) => { + const vaultTotalSupply = interpretAsDecimal(interaction.vaultSharesTotalSupply, 18); + const totalUnderlyingInVault = interpretAsDecimal(interaction.vaultUnderlyingAmount, 18); + const underlyingTotalSupply = interpretAsDecimal(interaction.vaultUnderlyingTotalSupply, 18); + const underlyingToNative = interpretAsDecimal(interaction.underlyingToNativePrice, 18); + const nativeToUsd = interpretAsDecimal(interaction.nativeToUSDPrice, 18); + const shareToUnderlying = totalUnderlyingInVault.div(vaultTotalSupply); + const underlyingToUsd = underlyingToNative.mul(nativeToUsd); + const underlyingToBreakdown = underlyingBreakdownTokens.map((breakdownToken, i) => + interpretAsDecimal( + interaction.vaultUnderlyingBreakdownBalances[i] || '0', + breakdownToken.decimals + ).div(underlyingTotalSupply) + ); + const underlyingBreakdownToUsd = interaction.underlyingBreakdownToNativePrices.map( + underlyingBreakdownToNativePrice => + interpretAsDecimal(underlyingBreakdownToNativePrice || '0', 18).mul(nativeToUsd) + ); + + const vault: BalanceDelta = { + balance: interpretAsDecimal(interaction.vaultBalance, shareToken.decimals), + delta: interpretAsDecimal(interaction.vaultBalanceDelta, shareToken.decimals), + }; + const rewardPools: BalanceDelta[] = rewardPoolTokens.map((rewardPoolToken, i) => ({ + balance: interpretAsDecimal( + interaction.rewardPoolBalances[i] || '0', + rewardPoolToken.decimals + ), + delta: interpretAsDecimal( + interaction.rewardPoolBalancesDelta[i] || '0', + rewardPoolToken.decimals + ), + })); + const rewardPoolTotal: BalanceDelta = rewardPools.reduce(sumBalanceDelta, { + balance: new Decimal(0), + delta: new Decimal(0), + }); + const total: BalanceDelta = { + balance: interpretAsDecimal(interaction.totalBalance, shareToken.decimals), + delta: vault.delta.add(rewardPoolTotal.delta), + }; + const usd: BalanceDelta = { + balance: total.balance.mul(underlyingToUsd), + delta: total.delta.mul(underlyingToUsd), + }; + const txHash: string = interaction.createdWith.hash; + + const existingTx = acc[txHash]; + if (existingTx) { + const mergedVault = mergeBalanceDelta(existingTx.vault, vault); + const mergedRewardPools = rewardPools.map((rp, i) => + existingTx.rewardPools[i] ? mergeBalanceDelta(existingTx.rewardPools[i], rp) : rp + ); + const mergedRewardPoolTotal = mergeBalanceDelta( + existingTx.rewardPoolTotal, + rewardPoolTotal + ); + const mergedTotal = mergeBalanceDelta(existingTx.total, total); + const mergedUsd = mergeBalanceDelta(existingTx.usd, usd); + + acc[txHash] = { + ...existingTx, + vault: mergedVault, + rewardPools: mergedRewardPools, + rewardPoolTotal: mergedRewardPoolTotal, + total: mergedTotal, + usd: mergedUsd, + actions: [...existingTx.actions, interaction.type], + }; + } else { + acc[txHash] = { + type: 'classic', + datetime: fromUnixTime(interaction.timestamp), + chain, + transactionHash: txHash, + shareToken, + underlyingToken, + underlyingBreakdownTokens, + rewardPoolTokens, + shareToUnderlying, + underlyingToUsd, + underlyingToBreakdown, + underlyingBreakdownToUsd, + vault, + rewardPools, + rewardPoolTotal, + total, + usd, + actions: [interaction.type], + }; + } + + return acc; + }, + {} as Record + ); + + return Object.values(mergedByTxId); +}; + +const classicPositionToInteractions = ( + chainId: ChainId, + position: InvestorTimelineClassicPositionFragment, + interactions: InvestorTimelineClassicPositionInteractionFragment[] +): TimelineClassicInteraction[] => { + const { id, classic } = position; + const shareToken = toToken(classic.vaultSharesToken); + const underlyingToken = toToken(classic.underlyingToken); + if (!shareToken || !underlyingToken) { + logger.error(`Missing token for position ${id}`); + return []; + } + + const underlyingBreakdownTokens = sortEntitiesByOrderList( + classic.underlyingBreakdownTokens, + 'address', + classic.underlyingBreakdownTokensOrder + ) + .map(toToken) + .filter(isDefined); + if (underlyingBreakdownTokens.length < classic.underlyingBreakdownTokensOrder.length) { + logger.error(`Missing underlying breakdown tokens for position ${id}`); + return []; + } + + const rewardPoolTokens = sortEntitiesByOrderList( + classic.rewardPoolTokens, + 'address', + classic.rewardPoolTokensOrder + ) + .map(toToken) + .filter(isDefined); + if (rewardPoolTokens.length < classic.rewardPoolTokensOrder.length) { + logger.error(`Missing reward pool tokens for position ${id}`); + return []; + } + + return mergeClassicPositionInteractions( + chainId, + shareToken, + underlyingToken, + underlyingBreakdownTokens, + rewardPoolTokens, + interactions ); +}; + +type PositionWithInteraction = Array<{ + position: InvestorTimelineQuery[`${T}Positions`][number] & { chain: ChainId }; + interactions: Array< + InvestorTimelineQuery[`${T}PositionInteractions`][number] & { chain: ChainId } + >; +}>; + +function getPositionsWithInteractions( + res: PaginatedAllSdkResult<'InvestorTimeline'>, + type: 'clm' +): PositionWithInteraction<'clm'>; +function getPositionsWithInteractions( + res: PaginatedAllSdkResult<'InvestorTimeline'>, + type: 'classic' +): PositionWithInteraction<'classic'>; +function getPositionsWithInteractions( + res: PaginatedAllSdkResult<'InvestorTimeline'>, + type: 'clm' | 'classic' +): PositionWithInteraction { + const positionsKey = `${type}Positions` as const; + const interactionsKey = `${type}PositionInteractions` as const; const positionsByChainAndId = keyBy( res.results.flatMap(pageRes => pageRes.flatMap(chainRes => - chainRes.data.clmPositions.map(position => ({ ...position, chain: chainRes.chain })) + chainRes.data[positionsKey].map(position => ({ ...position, chain: chainRes.chain })) ) ), position => `${position.chain}-${position.id}` @@ -235,7 +428,7 @@ export async function getClmTimeline(investor_address: Address): Promise pageRes.flatMap(chainRes => - chainRes.data.clmPositionInteractions.map(interaction => ({ + chainRes.data[interactionsKey].map(interaction => ({ ...interaction, chain: chainRes.chain, })) @@ -244,8 +437,38 @@ export async function getClmTimeline(investor_address: Address): Promise `${interaction.chain}-${interaction.investorPosition.id}` ); - return Object.entries(positionsByChainAndId).flatMap(([chainAndPositionId, position]) => { + return Object.entries(positionsByChainAndId).map(([chainAndPositionId, position]) => { const interactions = interactionsByChainAndPositionId[chainAndPositionId] || []; - return clmPositionToInteractions(position.chain, position.clm, interactions); + return { position, interactions }; }); } + +export async function getInvestorTimeline( + investor_address: Address +): Promise { + const res = await executeOnAllSdks(sdk => + paginate({ + fetchPage: ({ skip, first }) => sdk.InvestorTimeline({ investor_address, first, skip }), + count: res => [ + res.data.clmPositions.length, + res.data.clmPositionInteractions.length, + res.data.classicPositions.length, + res.data.classicPositionInteractions.length, + ], + }) + ); + + const clmPositions = getPositionsWithInteractions(res, 'clm'); + const classicPositions = getPositionsWithInteractions(res, 'classic'); + + const clmInteractions = clmPositions.flatMap(({ position, interactions }) => + clmPositionToInteractions(position.chain, position, interactions) + ); + const classicInteractions = classicPositions.flatMap(({ position, interactions }) => + classicPositionToInteractions(position.chain, position, interactions) + ); + + return [...clmInteractions, ...classicInteractions].sort( + (a, b) => a.datetime.getTime() - b.datetime.getTime() + ); +} diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts new file mode 100644 index 0000000..7e6f09a --- /dev/null +++ b/src/utils/tokens.ts @@ -0,0 +1,13 @@ +import type { TokenFragment } from '../queries/codegen/sdk'; +import { ZERO_ADDRESS } from './address'; +import type { Token } from './timeline'; + +export function toToken(from: TokenFragment | undefined): Token | undefined { + return from?.address && from.address !== ZERO_ADDRESS + ? { + address: from.address, + decimals: Number(from.decimals), + name: from.name || undefined, + } + : undefined; +}